From d7cc62fd9a5cc5cfcf33be9ef53a5bdb18986f31 Mon Sep 17 00:00:00 2001 From: Alex Chang Date: Wed, 16 Sep 2026 19:56:09 -0400 Subject: [PATCH 1/3] fix: classify blocked brand capture sources --- internal/brandcapture/fetch.go | 4 ++++ internal/brandcapture/fetch_test.go | 23 ++++++++++++++++++++++ internal/webapp/api_errors.go | 3 +++ internal/webapp/brand_capture.go | 2 ++ internal/webapp/brand_capture_test.go | 28 +++++++++++++++++++++++++++ 5 files changed, 60 insertions(+) diff --git a/internal/brandcapture/fetch.go b/internal/brandcapture/fetch.go index b895420..54cdded 100644 --- a/internal/brandcapture/fetch.go +++ b/internal/brandcapture/fetch.go @@ -23,6 +23,7 @@ const ( var ( ErrInvalidURL = errors.New("capture URL is invalid") ErrNonPublicDestination = errors.New("capture destination is not public") + ErrSourceAccessDenied = errors.New("capture source denied automated access") ) type Resolver interface { @@ -152,6 +153,9 @@ func (fetcher *Fetcher) fetch(ctx context.Context, rawURL string, allowedMediaTy } defer response.Body.Close() if response.StatusCode < 200 || response.StatusCode >= 300 { + if response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden || response.StatusCode == http.StatusTooManyRequests { + return nil, nil, fmt.Errorf("%w: HTTP %d", ErrSourceAccessDenied, response.StatusCode) + } return nil, nil, fmt.Errorf("capture source returned HTTP %d", response.StatusCode) } mediaType, _, err := mime.ParseMediaType(response.Header.Get("Content-Type")) diff --git a/internal/brandcapture/fetch_test.go b/internal/brandcapture/fetch_test.go index 1227e16..908c07f 100644 --- a/internal/brandcapture/fetch_test.go +++ b/internal/brandcapture/fetch_test.go @@ -158,6 +158,29 @@ func TestFetcherEnforcesMediaTypeAndDecodedSize(t *testing.T) { } } +func TestFetcherClassifiesSourceAccessDenied(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusForbidden) + })) + defer server.Close() + serverURL, _ := url.Parse(server.URL) + fetcher := &Fetcher{ + Resolver: staticResolver{addresses: map[string][]net.IPAddr{ + "capture.example": {{IP: net.ParseIP("93.184.216.34")}}, + }}, + DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, network, server.Listener.Addr().String()) + }, + Timeout: 5 * time.Second, + } + + _, err := fetcher.Capture(t.Context(), "http://capture.example:"+serverURL.Port()+"/") + if !errors.Is(err, ErrSourceAccessDenied) { + t.Fatalf("expected access-denied classification, got %v", err) + } +} + func contains(values []string, wanted string) bool { for _, value := range values { if value == wanted { diff --git a/internal/webapp/api_errors.go b/internal/webapp/api_errors.go index c06fd09..a3d61c1 100644 --- a/internal/webapp/api_errors.go +++ b/internal/webapp/api_errors.go @@ -26,6 +26,7 @@ const ( apiCodeResourceUnavailable = "resource_unavailable" apiCodeRevisionConflict = "revision_conflict" apiCodeServiceUnavailable = "service_unavailable" + apiCodeBrandCaptureDenied = "brand_capture_source_access_denied" apiCodeSMTPNotConfigured = "smtp_not_configured" apiCodeUnauthorized = "unauthorized" apiCodeUnsupportedMediaType = "unsupported_media_type" @@ -57,6 +58,7 @@ var apiErrorMessages = map[string]map[string]string{ apiCodeResourceUnavailable: "The required storage resource is unavailable; no data was accepted.", apiCodeRevisionConflict: "This record changed since it was loaded. Refresh and try again.", apiCodeServiceUnavailable: "This service is temporarily unavailable.", + apiCodeBrandCaptureDenied: "The source website rejected automated access. Enter the brand details manually or try an authorized static page.", apiCodeSMTPNotConfigured: "SMTP is not configured. Configure email delivery or use the manual sharing workflow.", apiCodeUnauthorized: "Your session is missing or expired. Sign in again.", apiCodeUnsupportedMediaType: "The uploaded file type is not supported.", @@ -81,6 +83,7 @@ var apiErrorMessages = map[string]map[string]string{ apiCodeResourceUnavailable: "必要的儲存資源目前無法使用,系統未接受任何資料。", apiCodeRevisionConflict: "此資料在載入後已被變更,請重新整理後再試。", apiCodeServiceUnavailable: "此服務目前暫時無法使用。", + apiCodeBrandCaptureDenied: "來源網站拒絕自動讀取。請改用人工填寫品牌資料,或嘗試已授權的靜態頁面。", apiCodeSMTPNotConfigured: "尚未設定 SMTP,請設定郵件傳送或使用手動分享流程。", apiCodeUnauthorized: "登入工作階段不存在或已過期,請重新登入。", apiCodeUnsupportedMediaType: "不支援此上傳檔案類型。", diff --git a/internal/webapp/brand_capture.go b/internal/webapp/brand_capture.go index b0b6c95..9aa6afe 100644 --- a/internal/webapp/brand_capture.go +++ b/internal/webapp/brand_capture.go @@ -43,6 +43,8 @@ func (s *Server) adminCaptureWebsiteBrand(w http.ResponseWriter, r *http.Request if errors.Is(err, brandcapture.ErrInvalidURL) || errors.Is(err, brandcapture.ErrNonPublicDestination) { status = http.StatusUnprocessableEntity code = apiCodeValidationFailed + } else if errors.Is(err, brandcapture.ErrSourceAccessDenied) { + code = apiCodeBrandCaptureDenied } s.writeAPIError(w, r, status, code) return diff --git a/internal/webapp/brand_capture_test.go b/internal/webapp/brand_capture_test.go index d78c176..9bcde72 100644 --- a/internal/webapp/brand_capture_test.go +++ b/internal/webapp/brand_capture_test.go @@ -122,6 +122,34 @@ func TestAdminBrandCaptureClassifiesUnsafeURL(t *testing.T) { } } +func TestAdminBrandCaptureReportsSourceAccessDenied(t *testing.T) { + store, err := sqlite.CreatePOC(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close() }) + capturer := &fakeBrandCapturer{err: errors.Join(brandcapture.ErrSourceAccessDenied, errors.New("HTTP 403"))} + app, _, err := New(store, Config{ + BaseURL: "https://catalog.example.test", AdminToken: "test-admin-token", EnablePOCAdmin: true, + BrandCapturer: capturer, + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(app.Close) + server := httptest.NewServer(app) + t.Cleanup(server.Close) + jar, _ := cookiejar.New(nil) + client := &http.Client{Jar: jar} + csrf := loginAdmin(t, client, server.URL) + response := postAdminJSON(t, client, server.URL+"/admin/api/website/capture", csrf, `{"source_url":"https://blocked.example"}`) + var body apiErrorResponse + decodeResponseJSON(t, response, &body) + if response.StatusCode != http.StatusBadGateway || body.Code != apiCodeBrandCaptureDenied { + t.Fatalf("blocked capture status=%d body=%+v", response.StatusCode, body) + } +} + func TestBrandCaptureProposalLeavesSEOAssetsAndCustomCSSUntouched(t *testing.T) { current := site.DefaultConfiguration() current.Organization.PrimaryLogoAsset = "asset-1" From 66d7367a5c3c47e7164bff8949a3478ec0933c5c Mon Sep 17 00:00:00 2001 From: Alex Chang Date: Thu, 17 Sep 2026 21:34:59 -0400 Subject: [PATCH 2/3] feat: replace brand capture with website brand import Replace runtime website capture with a request-bound external-model import workflow. This keeps the production Go process free of browser and AI runtime dependencies while validating the exact source URL, rights confirmation, structured brand data, and preview/publish boundaries. Add the Admin import flow, public brand layouts, schema validation, tests, documentation, and regenerated embedded assets. --- README-zh_CN.md | 52 +- README-zh_TW.md | 52 +- README.md | 52 +- docs/en/architecture.md | 99 + docs/en/engineering-case-study.md | 133 + docs/en/operations.md | 106 + docs/zh-CN/architecture.md | 99 + docs/zh-CN/engineering-case-study.md | 133 + docs/zh-CN/operations.md | 106 + docs/zh-TW/architecture.md | 99 + docs/zh-TW/engineering-case-study.md | 133 + docs/zh-TW/operations.md | 106 + internal/brandcapture/capture.go | 328 - internal/brandcapture/capture_test.go | 95 - internal/brandcapture/fetch.go | 291 - internal/brandcapture/fetch_test.go | 191 - internal/brandcapture/poc06_test.go | 416 - internal/brandcapture/proposal.go | 191 - internal/brandcapture/proposal_test.go | 90 - internal/publishing/html.go | 130 +- internal/publishing/html_brand_test.go | 78 + internal/site/brand_layout.go | 452 + internal/site/brand_layout_test.go | 127 + internal/site/config.go | 260 +- internal/site/config_test.go | 23 + internal/site/contrast_test.go | 32 + internal/webapp/api_errors.go | 3 - internal/webapp/brand_capture.go | 63 - internal/webapp/brand_capture_test.go | 169 - internal/webapp/brand_import.go | 786 + internal/webapp/brand_import_schema.json | 873 + internal/webapp/brand_import_test.go | 291 + internal/webapp/contact_url_test.go | 72 + .../webapp/header_actions_regression_test.go | 60 + internal/webapp/installer.go | 2 +- internal/webapp/public_css_test.go | 28 + internal/webapp/server.go | 66 +- internal/webapp/static/admin/admin.js | 41863 ++++++++-------- internal/webapp/static/public/public.css | 292 +- internal/webapp/templates/pages.tmpl | 21 +- internal/webapp/website.go | 15 +- internal/webapp/website_test.go | 4 +- web/ui/src/admin/WebsitePanel.tsx | 451 +- web/ui/src/admin/brandImportLabels.ts | 164 + web/ui/src/admin/formValues.test.ts | 34 + web/ui/src/admin/formValues.ts | 9 + web/ui/src/admin/types.ts | 142 +- web/ui/src/public/style.css | 292 +- 48 files changed, 26346 insertions(+), 23228 deletions(-) create mode 100644 docs/en/architecture.md create mode 100644 docs/en/engineering-case-study.md create mode 100644 docs/en/operations.md create mode 100644 docs/zh-CN/architecture.md create mode 100644 docs/zh-CN/engineering-case-study.md create mode 100644 docs/zh-CN/operations.md create mode 100644 docs/zh-TW/architecture.md create mode 100644 docs/zh-TW/engineering-case-study.md create mode 100644 docs/zh-TW/operations.md delete mode 100644 internal/brandcapture/capture.go delete mode 100644 internal/brandcapture/capture_test.go delete mode 100644 internal/brandcapture/fetch.go delete mode 100644 internal/brandcapture/fetch_test.go delete mode 100644 internal/brandcapture/poc06_test.go delete mode 100644 internal/brandcapture/proposal.go delete mode 100644 internal/brandcapture/proposal_test.go create mode 100644 internal/publishing/html_brand_test.go create mode 100644 internal/site/brand_layout.go create mode 100644 internal/site/brand_layout_test.go create mode 100644 internal/site/contrast_test.go delete mode 100644 internal/webapp/brand_capture.go delete mode 100644 internal/webapp/brand_capture_test.go create mode 100644 internal/webapp/brand_import.go create mode 100644 internal/webapp/brand_import_schema.json create mode 100644 internal/webapp/brand_import_test.go create mode 100644 internal/webapp/contact_url_test.go create mode 100644 internal/webapp/header_actions_regression_test.go create mode 100644 internal/webapp/public_css_test.go create mode 100644 web/ui/src/admin/brandImportLabels.ts create mode 100644 web/ui/src/admin/formValues.test.ts create mode 100644 web/ui/src/admin/formValues.ts diff --git a/README-zh_CN.md b/README-zh_CN.md index 4399254..89a7222 100644 --- a/README-zh_CN.md +++ b/README-zh_CN.md @@ -2,40 +2,35 @@ [English](README.md) | [繁體中文](README-zh_TW.md) | 简体中文 -**把产品数据变成正式、可搜索、可询价的官方网站,不必运维一整套应用系统。** +Prods 是供制造商与经销商自托管的技术产品目录与 RFQ(询价请求)系统。管理员维护产品数据、分类专属规格与文档;买家发现已发布产品并提出询价。半导体与电子元器件目录不只是扁平产品列表:产品身份、适用规格、原始值、datasheet 与发布状态都有实际意义。 -Prods 是为制造商、经销商与其他 B2B 产品团队打造的自托管产品目录与 RFQ(询价请求)系统。小型团队可导入并维护技术产品数据,安全发布给访客、搜索引擎与机器使用,再从单个便携式 Go 可执行文件接收真实买家需求。 +单个 Go 进程提供公开网站、内嵌 Admin 应用程序与生命周期工具,SQLite 为主数据库。Public 由 Go 生成 HTML,通过 React 增强交互;Admin 使用 React、headless Refine Core 与 Ant Design v6。Node.js 是构建依赖,不是部署后的运行要求。 -运行时包含 Admin 后台、公开网站、SQLite 支持、搜索、发布、RFQ、备份、恢复与运维工具。Node.js 只在从源代码构建时需要;部署后不需要 Node.js、独立数据库服务器、Docker、队列或外部搜索服务。 +## 工程文档与当前状态 -## 为什么选择 Prods - -- **沿用现有数据开始。** 管理产品、分类、规格、字典、文件、翻译与 Website 配置。XLSX 导入/导出及批量操作可维护大型产品目录,不必逐条开表单修改。 -- **精确控制公开状态。** 产品页、结构化数据、JSON、Markdown 与路由状态一起切换。更新已发布产品时,旧的有效版本会保留到新版本完成;Hide 与 Archive 会立即停止新的公开访问。 -- **同时服务工程师、采购、搜索引擎与 AI 工具。** 产品目录浏览、搜索、分页、文件及 RFQ 都有服务器端语意 HTML,没有 JavaScript 仍能使用。同一份已发布模型也供应 JSON-LD、JSON、Markdown、Sitemap、manifest 与 `llms.txt`。 -- **先获取需求,不假装成电商。** 访客可以询问一个或多个产品目录产品;搜索无结果时也能主动提交 Requested Part。Prods 以可重播且耐久的方式记录 RFQ,价格、供货、资格审查与后续联系仍由企业决定。 -- **运维方式清楚可掌握。** 单个进程负责 migration、写入节流、持久化任务、审计、备份、restore journal 与健康检查。Terminal 会显示当前状态、下一步、URL、Admin 路径、主机信息、资源路径与实时日志。 -- **掌握部署与数据。** Prods Community 采 AGPL 许可,以 SQLite 储存主要状态,将资产与备份放在明确的本机目录,可在 Windows 或 Linux 上执行并搭配自选的反向代理。 - -## 适合的团队 - -Prods 适合需要官方产品目录与 RFQ 管道,但不想自行拼装并运维 CMS、定制数据库应用、搜索服务与独立后台的产品型企业。尤其适合元器件、工业、技术型与 B2B 产品目录,这些情境通常重视料号、分类、规格、文件、公开控制与询价脉络。 +| 文档 | 用途 | +| --- | --- | +| [架构概览](docs/zh-CN/architecture.md) | 组件、请求/数据流程、持久化、认证与系统边界 | +| [工程案例研究](docs/zh-CN/engineering-case-study.md) | 六项决策及其证据、替代方案、取舍与演进触发条件 | +| [运维与生产环境证据](docs/zh-CN/operations.md) | 安装、备份/恢复、部署控制与尚未验证的运维声明 | -V1 刻意不包含结帐、定价、库存 Availability、CRM、通用页面编辑器、任意自定义 JavaScript,也不会由 RFQ 自动创建产品。这些边界让产品目录真实数据、公开内容与买家需求维持清楚可控。 +指南描述 2026-09-17 查看时的工作目录(`VERSION`:`v0.6.8`)。源代码、测试与发布 workflow 展示已实现的机制;本次核查未验证运行中的生产部署、客户负载或可用性目标。Release workflow 发布构建产物,不部署运行中的站点。历史需求与 ADR 保持内部文档属性;指南提供源代码链接与说明,不重新公开内部内容。 -## 主要能力 +## 已实现范围 -| 领域 | Prods 提供的能力 | +| 领域 | 代码仓库中的实现 | | --- | --- | -| 产品目录作业 | Current/Archived 产品、任意深度分类、规格与 Spec Set、字典、图片/文件、分页、排序、XLSX 导入/导出,以及批量 Publish/Hide/Archive/分类/生命周期操作 | -| Website 与多语 | 版本化 Website 配置、预览/发布流程、Public Copy 覆写、导览与主题控制、独立 Admin UI 语言,以及十种内建公开语言 | -| 公开探索 | 服务器端搜索与产品目录页、产品/分类/制造商/品牌/应用路由、Unicode folding 搜索、JSON-LD、JSON、Markdown、Sitemap、robots、manifest 与 `llms.txt` | -| RFQ | 多产品询价、搜索无结果后的 Requested Part、high-entropy idempotency key、持久化回执、Admin 查看,以及由用户明确触发的可选 SMTP 发送 | -| 权限与可追溯性 | 不透明 server-side session、CSRF 防护、能力型角色、Admin scope,以及必要管理操作记录 | -| 运维 | Liveness/readiness、runtime log、排程与单次备份、验证式 restore、Recovery、Owner 救援、Maintenance、资产清理与手动版本检查 | -| 发布 | 默认空白安装、可选且精确对应版本的 sample data、嵌入源代码 revision/版本、checksum,以及 Linux/Windows amd64 构建产物 | +| 产品目录 | Current/Archived 产品、分类、Spec Set、原始规格值、字典、图片/文档、XLSX 导入/导出与逐产品批量结果 | +| Website 与语言 | 工作中/预览/发布配置、Public Copy 覆盖、分类列表 profile、独立 Admin 语言与十种内置公开语言 | +| 公开发现 | 语义 HTML、搜索/列表/分页、产品与分类字典路由、JSON-LD、JSON、Markdown、Sitemap、manifest 与 `llms.txt` | +| RFQ | 目录产品及访客明确提出的目录外型号、canonical-payload idempotency、持久化回执、Admin 查看与可选且明确触发的 SMTP 发送 | +| 访问 | 不透明 server-side session、服务器端 capability、CSRF 检查与审计记录 | +| 运维 | Installer、health/readiness、runtime log、backup、journaled restore、Recovery、Maintenance 与可选 service 集成 | +| 交付 | 内嵌 UI/sample payload、source/version metadata、checksum 与 Linux/Windows/macOS 构建目标;cross-build 不等于原生运行验收 | + +V1 排除结账、价格/库存承诺、CRM、通用页面编辑器、任意 JavaScript/template 与外部写入 token。RFQ 不创建 Product,也不自动发送邮件。机器可读输出不代表 AI/RAG 实现;语义相似不代表电气兼容。案例研究详细区分这些边界,以及已实现、延后与探索性工作。 -内建语言为 `en-US`、`zh-TW`、`zh-CN`、`ja-JP`、`ko-KR`、`de-DE`、`fr-FR`、`it-IT`、`es-ES`、`pt-BR`。内置默认文案已通过 key、placeholder、plural、格式与版面检查,但尚未经专业母语、法律或行销审核;正式发布前请审阅所有面向公网文案。 +内置语言为 `en-US`、`zh-TW`、`zh-CN`、`ja-JP`、`ko-KR`、`de-DE`、`fr-FR`、`it-IT`、`es-ES`、`pt-BR`。代码仓库包含资源契约检查,不声称已完成专业母语、法律或营销审核。工程指南与 README 同步提供英文、繁体中文与简体中文。 ## 快速开始 @@ -48,7 +43,7 @@ V1 刻意不包含结帐、定价、库存 Availability、CRM、通用页面编 执行前请先验证下载档。Linux: ```sh -sha256sum --check SHA256SUMS +sha256sum --check --ignore-missing SHA256SUMS chmod +x prods-linux-amd64 ./prods-linux-amd64 ``` @@ -137,7 +132,7 @@ RFQ 不会创建 Product、不会虚构价格或 availability,也不会自动 ## 备份、恢复与 Maintenance -可在 Admin 创建与监看备份,或执行单次备份: +可在 Admin 创建与跟踪在线备份。命令行 backup、restore 或 Owner recovery 前,先停止运行中的 instance,并使用相同配置/data 路径;这些命令需要获取 instance ownership lock。单次备份: ```sh ./prods-linux-amd64 --backup-now @@ -200,6 +195,7 @@ Browser / crawler - Go 1.27.1 - Node.js 24 +- Python 3,用于许可政策验证 - pnpm 10.28.1,由 `packageManager` 固定 ```sh diff --git a/README-zh_TW.md b/README-zh_TW.md index 591cd56..6b0aa07 100644 --- a/README-zh_TW.md +++ b/README-zh_TW.md @@ -2,40 +2,35 @@ [English](README.md) | 繁體中文 | [简体中文](README-zh_CN.md) -**把產品資料變成正式、可搜尋、可詢價的官方網站,不必維運一整套應用系統。** +Prods 是供製造商與經銷商自架的技術產品型錄與 RFQ(詢價請求)系統。管理員維護產品資料、分類專屬規格與文件;買家探索已發布產品並提出詢價。半導體與電子零組件型錄不只是扁平產品清單:產品身分、適用規格、原始值、datasheet 與發布狀態都具有意義。 -Prods 是為製造商、經銷商與其他 B2B 產品團隊打造的自架產品型錄與 RFQ(詢價請求)系統。小型團隊可匯入並維護技術產品資料,安全發布給訪客、搜尋引擎與機器使用,再從同一個可攜式 Go 執行檔接收真實買家需求。 +單一 Go 行程提供公開網站、內嵌 Admin 應用程式與生命週期工具,SQLite 為主要資料庫。Public 由 Go 生成 HTML,以 React 增強互動;Admin 使用 React、headless Refine Core 與 Ant Design v6。Node.js 是建置相依工具,不是部署後的執行需求。 -執行環境內含 Admin 後台、公開網站、SQLite 支援、搜尋、發布、RFQ、備份、復原與維運工具。Node.js 只在從原始碼建置時需要;部署後不需要 Node.js、獨立資料庫伺服器、Docker、佇列或外部搜尋服務。 +## 工程文件與目前狀態 -## 為什麼選擇 Prods - -- **沿用既有資料開始。** 管理產品、分類、規格、字典、文件、翻譯與 Website 設定。XLSX 匯入/匯出及批次操作可處理大量型錄,不必逐筆開表單修改。 -- **精準控制公開狀態。** 產品頁、結構化資料、JSON、Markdown 與路由狀態一起切換。更新已發布產品時,舊的有效版本會保留到新版本完成;Hide 與 Archive 會立即停止新的公開存取。 -- **同時服務工程師、採購、搜尋引擎與 AI 工具。** 型錄瀏覽、搜尋、分頁、文件及 RFQ 都有伺服器端語意 HTML,沒有 JavaScript 仍能使用。同一份已發布模型也供應 JSON-LD、JSON、Markdown、Sitemap、manifest 與 `llms.txt`。 -- **先取得需求,不假裝成電商。** 訪客可以詢問一個或多個型錄產品;搜尋無結果時也能主動提交 Requested Part。Prods 以可重播且耐久的方式記錄 RFQ,價格、供貨、資格審查與後續聯絡仍由企業決定。 -- **維運方式清楚可掌握。** 單一行程負責 migration、寫入節流、耐久工作、稽核、備份、restore journal 與健康檢查。Terminal 會顯示目前狀態、下一步、網址、Admin 路徑、主機資訊、資源路徑與即時 log。 -- **掌握部署與資料。** Prods Community 採 AGPL 授權,以 SQLite 儲存主要狀態,將資產與備份放在明確的本機目錄,可在 Windows 或 Linux 上執行並搭配自選的反向代理。 - -## 適合的團隊 - -Prods 適合需要官方產品型錄與 RFQ 管道,但不想自行拼裝並維運 CMS、客製資料庫應用、搜尋服務與獨立後台的產品型企業。尤其適合零組件、工業、技術型與 B2B 型錄,這些情境通常重視料號、分類、規格、文件、公開控制與詢價脈絡。 +| 文件 | 用途 | +| --- | --- | +| [架構概覽](docs/zh-TW/architecture.md) | 元件、請求/資料流程、持久化、認證與系統邊界 | +| [工程案例研究](docs/zh-TW/engineering-case-study.md) | 六項決策及其證據、替代方案、取捨與演進觸發條件 | +| [維運與正式環境證據](docs/zh-TW/operations.md) | 安裝、備份/復原、部署控制與尚未驗證的維運宣稱 | -V1 刻意不包含結帳、定價、庫存 Availability、CRM、通用頁面編輯器、任意自訂 JavaScript,也不會由 RFQ 自動建立產品。這些邊界讓型錄真實資料、公開內容與買家需求維持清楚可控。 +指南描述 2026-09-17 檢視的工作目錄(`VERSION`:`v0.6.8`)。原始碼、測試與發布 workflow 呈現已實作機制;本次查核未驗證運行中的正式部署、客戶負載或可用性目標。Release workflow 發布成品,不部署運行中的站點。歷史需求與 ADR 保持內部文件身分;指南提供原始碼連結與說明,不重新公開內部內容。 -## 主要能力 +## 已實作範圍 -| 領域 | Prods 提供的能力 | +| 領域 | 儲存庫中的實作 | | --- | --- | -| 型錄作業 | Current/Archived 產品、任意深度分類、規格與 Spec Set、字典、圖片/文件、分頁、排序、XLSX 匯入/匯出,以及批次 Publish/Hide/Archive/分類/生命週期操作 | -| Website 與多語 | 版本化 Website 設定、預覽/發布流程、Public Copy 覆寫、導覽與主題控制、獨立 Admin UI 語系,以及十種內建公開語系 | -| 公開探索 | 伺服器端搜尋與型錄頁、產品/分類/製造商/品牌/應用路由、Unicode folding 搜尋、JSON-LD、JSON、Markdown、Sitemap、robots、manifest 與 `llms.txt` | -| RFQ | 多產品詢價、搜尋無結果後的 Requested Part、high-entropy idempotency key、耐久收據、Admin 檢視,以及明確觸發的選用 SMTP 寄送 | -| 權限與可追溯性 | 不透明 server-side session、CSRF 防護、能力型角色、Admin scope,以及必要管理操作記錄 | -| 維運 | Liveness/readiness、runtime log、排程與單次備份、驗證式 restore、Recovery、Owner 救援、Maintenance、資產清理與手動版本檢查 | -| 發布 | 預設空白安裝、選用且精確對應版本的 sample data、內嵌原始碼 revision/版本、checksum,以及 Linux/Windows amd64 成品 | +| 型錄 | Current/Archived 產品、分類、Spec Set、原始規格值、字典、圖片/文件、XLSX 匯入/匯出與逐產品批次結果 | +| Website 與語系 | 工作中/預覽/發布設定、Public Copy 覆寫、分類列表 profile、獨立 Admin 語系與十種內建公開語系 | +| 公開探索 | 語意 HTML、搜尋/列表/分頁、產品與分類字典路由、JSON-LD、JSON、Markdown、Sitemap、manifest 與 `llms.txt` | +| RFQ | 型錄產品及訪客明確提出的非型錄料號、canonical-payload idempotency、持久收據、Admin 檢視與選用且明確觸發的 SMTP 寄送 | +| 存取 | 不透明 server-side session、伺服器端 capability、CSRF 檢查與稽核紀錄 | +| 維運 | Installer、health/readiness、runtime log、backup、journaled restore、Recovery、Maintenance 與選用 service 整合 | +| 交付 | 內嵌 UI/sample payload、source/version metadata、checksum 與 Linux/Windows/macOS 建置目標;cross-build 不等於原生執行驗收 | + +V1 排除結帳、價格/庫存承諾、CRM、通用頁面編輯器、任意 JavaScript/template 與外部寫入 token。RFQ 不建立 Product,也不自動寄信。機器可讀輸出不代表 AI/RAG 實作;語意相似不代表電氣相容。案例研究詳細區分這些邊界,以及已實作、延後與探索性工作。 -內建語系為 `en-US`、`zh-TW`、`zh-CN`、`ja-JP`、`ko-KR`、`de-DE`、`fr-FR`、`it-IT`、`es-ES`、`pt-BR`。預設文字已通過 key、placeholder、plural、格式與版面檢查,但尚未經專業母語、法律或行銷審校;正式上線前請審閱所有對外文案。 +內建語系為 `en-US`、`zh-TW`、`zh-CN`、`ja-JP`、`ko-KR`、`de-DE`、`fr-FR`、`it-IT`、`es-ES`、`pt-BR`。儲存庫包含資源契約檢查,不宣稱已經專業母語、法律或行銷審校。工程指南與 README 同步提供英文、繁體中文與簡體中文。 ## 快速開始 @@ -48,7 +43,7 @@ V1 刻意不包含結帳、定價、庫存 Availability、CRM、通用頁面編 執行前請先驗證下載檔。Linux: ```sh -sha256sum --check SHA256SUMS +sha256sum --check --ignore-missing SHA256SUMS chmod +x prods-linux-amd64 ./prods-linux-amd64 ``` @@ -137,7 +132,7 @@ RFQ 不會建立 Product、不會虛構價格或 availability,也不會自動 ## 備份、復原與 Maintenance -可在 Admin 建立與監看備份,或執行單次備份: +可在 Admin 建立與追蹤線上備份。命令列 backup、restore 或 Owner recovery 前,先停止運行中的 instance,並使用相同設定/data 路徑;這些命令需要取得 instance ownership lock。單次備份: ```sh ./prods-linux-amd64 --backup-now @@ -200,6 +195,7 @@ Browser / crawler - Go 1.27.1 - Node.js 24 +- Python 3,用於授權政策驗證 - pnpm 10.28.1,由 `packageManager` 固定 ```sh diff --git a/README.md b/README.md index f80ff8b..3f85206 100644 --- a/README.md +++ b/README.md @@ -2,40 +2,35 @@ English | [繁體中文](README-zh_TW.md) | [简体中文](README-zh_CN.md) -**Turn product data into an official, searchable catalog and RFQ website without operating an application stack.** +Prods is a self-hosted technical product catalog and request-for-quotation (RFQ) system for manufacturers and distributors. Administrators maintain product data, category-specific specifications and documents; buyers discover published products and submit inquiries. Semiconductor and electronic-component catalogs need more than a flat product list: part identity, applicable specifications, raw values, datasheets and publication state all carry meaning. -Prods is a self-hosted product catalog and request-for-quotation system for manufacturers, distributors, and other B2B product teams. It helps a small team import and maintain technical product data, publish it safely across human- and machine-readable formats, and capture real buyer requests from one portable Go binary. +One Go process serves the public website, embedded Admin application and lifecycle tools, with SQLite as the primary database. Public content is Go-generated HTML with React enhancements; Admin uses React, headless Refine Core and Ant Design v6. Node.js is a build dependency, not a deployed runtime requirement. -The runtime includes the Admin application, public website, SQLite support, search, publishing, RFQ intake, backup, recovery, and operational tooling. Node.js is only needed when building from source. A deployed site does not require Node.js, a separate database server, Docker, a queue, or an external search service. +## Engineering documentation and current status -## Why Prods - -- **Launch with the data you already have.** Manage Products, categories, specifications, dictionaries, documents, translations, and Website settings. XLSX import/export and bulk actions support catalog maintenance without forcing every change through individual forms. -- **Publish with controlled visibility.** Product pages, structured data, JSON, Markdown, and route state move together. Updating a Published Product keeps the previous valid revision online until the replacement is ready; Hide and Archive stop new public access immediately. -- **Serve engineers, buyers, crawlers, and AI tools.** Core browsing, search, pagination, documents, and RFQ work from server-rendered semantic HTML without JavaScript. The same published model feeds JSON-LD, JSON, Markdown, Sitemap, manifest, and `llms.txt` outputs. -- **Capture demand without pretending to be a commerce platform.** Visitors can request one or more catalog Products or submit a Requested Part when search has no result. Prods records durable, replay-safe RFQs and leaves pricing, availability, qualification, and follow-up to the business. -- **Keep operations understandable.** One process owns migrations, bounded writes, durable jobs, audit records, backups, restore journals, and health checks. The terminal shows the current state, next action, URLs, Admin path, host details, resource paths, and live logs. -- **Own the deployment and the data.** Prods Community is AGPL-licensed, stores primary state in SQLite, and keeps assets and backups in explicit local roots. Release builds target Linux, Windows, and macOS behind the reverse proxy you choose. - -## Who it is for - -Prods is designed for product businesses that need an official catalog and RFQ channel but do not want to assemble and operate a CMS, custom database application, search service, and separate back office. It is especially suited to component, industrial, technical, and B2B catalogs where part numbers, taxonomy, specifications, documents, publication control, and inquiry context matter. +| Read | Purpose | +| --- | --- | +| [Architecture overview](docs/en/architecture.md) | Components, request/data flows, persistence, authentication and system boundaries | +| [Engineering case study](docs/en/engineering-case-study.md) | Six decisions with evidence, alternatives, trade-offs and evolution triggers | +| [Operations and production evidence](docs/en/operations.md) | Installation, backup/recovery, deployment controls and unverified operational claims | -V1 deliberately does not implement checkout, pricing, inventory availability, CRM, a generic page builder, arbitrary custom JavaScript, or automated Product creation from an RFQ. Those boundaries keep catalog truth, public publishing, and buyer inquiries explicit. +These guides describe the working tree reviewed on 2026-09-17 (`VERSION`: `v0.6.8`). Source, tests and release workflows demonstrate implemented mechanisms; this review did not verify a live production deployment, customer workload or availability target. Release workflows publish artifacts, not a running site. Historical requirements and ADRs remain internal; the guides provide source-linked explanations without republishing them. -## Capabilities +## Implemented scope -| Area | What Prods provides | +| Area | Implementation in this repository | | --- | --- | -| Catalog operations | Current and Archived Products, arbitrary-depth categories, specifications and spec sets, dictionaries, images/documents, pagination, sorting, XLSX import/export, and bulk Publish/Hide/Archive/category/lifecycle actions | -| Website and languages | Versioned Website settings, preview/publish flow, Public Copy overrides, navigation and theme controls, a separate Admin UI locale, and ten built-in public locales | -| Public discovery | Server-rendered search and catalog pages, Product/category/manufacturer/brand/application routes, Unicode-folded search, JSON-LD, JSON, Markdown, Sitemap, robots, manifest, and `llms.txt` | -| RFQ | Multi-product requests, Requested Part intake from no-result searches, high-entropy idempotency keys, durable receipts, Admin review, and explicit optional SMTP sending | -| Access and accountability | Opaque server-side sessions, CSRF protection, capability-based roles, scoped Admin access, and records for required Admin actions | -| Operations | Liveness/readiness, runtime logs, scheduled and one-shot backup, verified restore, Recovery, Owner recovery, Maintenance, asset cleanup, and on-demand update checking | -| Distribution | Empty installation by default, optional exact-version sample data, embedded source revision/version, checksums, and Linux/Windows/macOS release artifacts | +| Catalog | Current/Archived products, categories, Spec Sets, raw specification values, dictionaries, images/documents, XLSX import/export and per-product bulk outcomes | +| Website and languages | Working/preview/publish settings, Public Copy overrides, category listing profiles, independent Admin locale and ten built-in public locales | +| Public discovery | Semantic HTML, search/listing/pagination, product and taxonomy routes, JSON-LD, JSON, Markdown, Sitemap, manifest and `llms.txt` | +| RFQ | Catalog products and explicitly requested uncatalogued parts, canonical-payload idempotency, durable receipts, Admin review and optional explicit SMTP sending | +| Access | Opaque server-side sessions, server-side capabilities, CSRF checks and audit records | +| Operations | Installer, health/readiness, runtime logs, backup, journaled restore, Recovery, Maintenance and optional service integration | +| Distribution | Embedded UI/sample payload, source/version metadata, checksums and Linux/Windows/macOS build targets; cross-builds are not native runtime acceptance | + +V1 excludes checkout, pricing/inventory promises, CRM, a generic page builder, arbitrary JavaScript/templates and external write tokens. An RFQ does not create a Product or automatically send email. Machine-readable outputs are not an AI/RAG implementation; semantic similarity is not evidence of electrical compatibility. These boundaries and the distinction between implemented, deferred and exploratory work are detailed in the case study. -The built-in locales are `en-US`, `zh-TW`, `zh-CN`, `ja-JP`, `ko-KR`, `de-DE`, `fr-FR`, `it-IT`, `es-ES`, and `pt-BR`. Their bundled defaults have passed key, placeholder, plural, formatting, and layout checks, but have not received professional native-language, legal, or marketing review. Review customer-facing copy before a production launch. +The built-in locales are `en-US`, `zh-TW`, `zh-CN`, `ja-JP`, `ko-KR`, `de-DE`, `fr-FR`, `it-IT`, `es-ES`, and `pt-BR`. The repository includes resource-contract checks; no professional native-language, legal or marketing review is claimed. The engineering guides and READMEs are synchronized in English, Traditional Chinese and Simplified Chinese. ## Quick start @@ -48,7 +43,7 @@ Download the files for your version from [GitHub Releases](https://github.com/he Verify the downloaded files before running them. On Linux: ```sh -sha256sum --check SHA256SUMS +sha256sum --check --ignore-missing SHA256SUMS chmod +x prods-linux-amd64 ./prods-linux-amd64 ``` @@ -137,7 +132,7 @@ RFQ submission never creates a Product, invents a price or availability state, o ## Backup, recovery, and maintenance -Create and monitor backups in Admin, or run a one-shot backup: +Create and monitor online backups in Admin. For command-line backup, restore or Owner recovery, first stop the running instance and use the same configuration/data paths; these commands acquire the instance ownership lock. To create a one-shot backup: ```sh ./prods-linux-amd64 --backup-now @@ -200,6 +195,7 @@ Required tools: - Go 1.27.1 - Node.js 24 +- Python 3 for license-policy validation - pnpm 10.28.1, pinned by `packageManager` ```sh diff --git a/docs/en/architecture.md b/docs/en/architecture.md new file mode 100644 index 0000000..cc48a49 --- /dev/null +++ b/docs/en/architecture.md @@ -0,0 +1,99 @@ +# Architecture overview + +English | [繁體中文](../zh-TW/architecture.md) | [简体中文](../zh-CN/architecture.md) + +[README](../../README.md) · [Engineering case study](engineering-case-study.md) · [Operations](operations.md) + +This is a source-based description of the working tree reviewed on 2026-09-17, with `VERSION` set to `v0.6.8`. It describes implementation, not certification of a released artifact or a live deployment. English is the semantic source for these three-language engineering guides; they do not supersede product requirements or historical decisions. + +## System context + +Prods serves a company's technical catalog and request-for-quotation (RFQ) workflow. Administrators maintain product data, category-specific specifications, documents and website settings. Visitors discover published products and submit inquiries, including a Requested Part when the catalog has no match. RFQ intake records demand; it does not establish price, stock, electrical compatibility or a sales order. + +The runtime is a modular monolith: one Go process, a SQLite database and managed filesystem roots. Workers run inside that process. A deployment may put a TLS reverse proxy in front; the repository does not establish that a particular production proxy is running. + +```mermaid +flowchart TB + V[Visitors and crawlers] --> P[Public HTML and read formats] + A[Administrators] --> U[Embedded Admin application] + H[Host operator] --> S[Installer and Recovery] + subgraph G[One Prods Go process] + P --> D[Application and domain operations] + U --> API[Admin HTTP API] + API --> D + S --> L[Startup and recovery lifecycle] + D --> W[Write admission and SQL repositories] + D --> J[In-process publication and other workers] + J --> W + end + W --> DB[(SQLite)] + J --> F[Managed assets and generated files] + L --> DB + L --> B[Backup manifests and restore journals] +``` + +The diagram groups responsibilities, not independently deployable services. Entry points and wiring are in [`cmd/prods/main.go`](../../cmd/prods/main.go) and [`internal/webapp/server.go`](../../internal/webapp/server.go). + +## Components and reasons for their boundaries + +| Boundary | Current implementation | Why it exists | +| --- | --- | --- | +| Domain and persistence | `internal/catalog`, `inquiries`, `site`, `localization`; SQL repositories in `internal/storage/sqlite` | Rules, durable state and transactions stay in Go rather than depending on a browser framework. | +| Public website | Go templates and generated product representations; dynamic search/listing/RFQ handlers | Core content and basic navigation remain available without JavaScript. | +| Public enhancements | React roots for RFQ selection and listing controls | Interactive selection enhances existing HTML without requiring whole-page hydration. | +| Admin | React, headless Refine Core, React Router, TanStack Query and native Ant Design v6 | Interactive management can use application state and forms without adding Admin dependencies to Public. | +| System UI | Separate Installer/Recovery entry points and embedded system assets | A damaged database or unavailable normal Admin must not make recovery depend on that same database/session. | +| Background work | Publication, import, backup, mail and search-notification managers in the process | Durable records track work; an in-memory wake-up is not proof of completion. | + +See [`web/ui/package.json`](../../web/ui/package.json), the separate [`Vite configurations`](../../web/ui/vite.public.config.ts), [`Public roots`](../../web/ui/src/public/main.tsx), and [`system entry`](../../web/ui/src/system/main.tsx). Public HTML is generated by Go; this is not React server-side rendering. The Admin uses `@refinedev/core`, not `@refinedev/antd`. + +## Request and data flows + +Public operational routes include `GET /api/locales` and `POST /api/rfqs`, alongside HTML `GET/POST /rfq`. They are separate from generated read formats and authenticated `/admin/api/` operations. These routes are not a promise of a versioned external write-token API. + +**Catalog edit and publication.** An Admin request carries a session, CSRF token and expected revision. The repository checks the write and records state, required audit and publication intent in a transaction. The publication engine stages representations and activates the product's public unit. A saved edit and an activated public revision are distinct events. The previous valid unit can remain available while a replacement is prepared. Hide/Archive updates visibility so new admissions cannot use the revoked product; an already admitted transfer may finish. Public admission happens before conditional response and Range handling. Shared assets remain available through other valid references. + +Website-wide configuration and route changes have a separate site-epoch activation boundary. Search and other aggregate views may converge after product activation, subject to visibility checks. Generated-history directories are not exposed as a generic filesystem service. Evidence: [`catalog_store.go`](../../internal/storage/sqlite/catalog_store.go), [`publication_store.go`](../../internal/storage/sqlite/publication_store.go), [`protocol.go`](../../internal/publishing/protocol.go), [`publication tests`](../../internal/webapp/publication_test.go). + +**Public discovery.** Public responses use [`PublicView`](../../internal/publishing/view.go), an explicit published-data model, rather than serializing Admin records. HTML, JSON-LD, JSON and Markdown share that model. Search folds Unicode using application code and escapes literal LIKE wildcards; identity comparison is separate. [`cataloglisting`](../../internal/cataloglisting/listing.go) computes applicable columns across the listing scope, not just the current page. Locale resolution records actual field provenance and skips disabled candidates. Closing multilingual editing only hides editing controls; published locale changes require Website publication. Evidence: [`product.go`](../../internal/catalog/product.go), [`resolver.go`](../../internal/localization/resolver.go), [`website localization tests`](../../internal/webapp/website_localization_test.go). + +**RFQ submission.** The visitor explicitly submits a form. A high-entropy idempotency key identifies the attempt; a versioned canonical payload hash identifies its content. The RFQ, items and replayable receipt commit together. Repeating the same key and payload returns the saved receipt; a changed payload conflicts. A no-results search becomes a Requested Part only after a visitor chooses that action. SMTP sending is a separate authorized operation with recorded outcomes. Evidence: [`rfq.go`](../../internal/inquiries/rfq.go), [`SubmitRFQ`](../../internal/storage/sqlite/store.go), [`receipt lookup`](../../internal/storage/sqlite/rfq_receipt.go), [`mail manager`](../../internal/maildelivery/manager.go). + +## Persistence model + +| Data | Representation and constraint | +| --- | --- | +| Product identity | Stable opaque ID; Current/Archived record state and Published/Hidden visibility are separate. Manufacturer + Part Number conflicts are checked inside controlled writes, not a DB unique constraint. A blank manufacturer is distinct from a named one. | +| Technical specifications | Spec definitions, reusable Spec Sets, category assignments and per-product raw values; normalized values carry source/spec versions and status. Missing or unparseable values are not silently turned into zero. | +| Publication | Durable intents, active publication records, site epochs and generated immutable units. Generated output is derived, not the only copy of catalog truth. | +| Website and languages | Working/published settings, official Public Copy definitions/defaults and customer overrides; content source locale is independent of the Admin UI locale. | +| Operations | Sessions, jobs, RFQ receipts, audit, backup records and migration history in SQLite; restore evidence also lives in filesystem journals. | +| Files | Immutable assets referenced by stable IDs, private secrets/configuration, staged work and backups in managed roots. Files are durably placed before committed references. | + +The base schema is in [`store.go`](../../internal/storage/sqlite/store.go); subsequent schema changes are in [`migrations.go`](../../internal/storage/sqlite/migrations.go). [`spec_document_store.go`](../../internal/storage/sqlite/spec_document_store.go), [`asset_store.go`](../../internal/storage/sqlite/asset_store.go) and [`D9 identity tests`](../../internal/storage/sqlite/d9_identity_test.go) show the data contracts. + +`beginWrite` serializes admitted writes with a channel and context cancellation. This coordinates one process; it is not a distributed scheduler or a latency guarantee. Atomic Excel import validates ahead of its final all-or-nothing transaction, but that transaction can still delay RFQ writes. Product Bulk instead records separate per-product outcomes and permits partial success. See [`import_store.go`](../../internal/storage/sqlite/import_store.go) and [`product_bulk_store.go`](../../internal/storage/sqlite/product_bulk_store.go). + +## Authentication and Admin APIs + +Normal installations use a versioned password verifier and durable opaque server-side sessions. SQLite stores a session-token digest rather than the bearer value. Server-side capabilities authorize operations; hiding a button is not authorization. The shared Admin transport sends same-origin credentials and CSRF headers, rejects stale-session responses and reports an interrupted write as an unknown outcome. Automatic mutation retries and optimistic success are disabled. + +The Refine DataProvider handles supported resource operations. Named adapters preserve Hide, Archive, Clone, URL change, preview and other domain workflows. A named command need not have a unique HTTP verb or endpoint: the current `publish` adapter uses a revisioned Product PUT. Generic delete is rejected in favor of Archive. Evidence: [`password.go`](../../internal/identity/password.go), [`session tests`](../../internal/storage/sqlite/installation_test.go), [`api.ts`](../../web/ui/src/admin/api.ts), [`dataProvider.ts`](../../web/ui/src/admin/dataProvider.ts), [`catalogCommands.ts`](../../web/ui/src/admin/catalogCommands.ts), [`AdminProviders.tsx`](../../web/ui/src/admin/AdminProviders.tsx). + +## Runtime, installation and recovery + +Defaults resolve from the executable directory. Configuration precedence is CLI → environment → one `prods.ini` → defaults. Node.js/pnpm build the embedded assets; they are not required on the application host. The SQLite driver is `modernc.org/sqlite`; release builds disable CGO. Optional Linux systemd and Windows service code exists. Release scripts also cross-compile macOS artifacts; compilation alone is not platform acceptance. + +Startup classifies durable state before normal operation. Fresh data enters Installer; an incomplete installation resumes with a new claim. Installer commits the Owner, minimum settings, system categories, required log and Ready marker atomically, then requires restart. An unrecognized/damaged database enters Recovery Required, never a replacement installation. Migration requires backup evidence. Restore stages roots and writes a durable journal; after `prepared`, only the same operation rolls forward, checking each root before final verification. Maintenance controls admission while keeping repair paths available. See [operations](operations.md) for runtime evidence and limits. + +## Constraints and evolution triggers + +These are review criteria, not approved migrations or a roadmap: + +- Revisit database/write scheduling if measured contention makes RFQ latency unacceptable during representative imports; there is no verified production traffic envelope here. +- Revisit process boundaries if profiling shows that worker resource limits cannot protect interactive work, or independent availability becomes a concrete requirement. +- Revisit search after measured catalog/query workloads outgrow the current projection; external search is not currently required. +- Revisit the Admin bridge if supported upgrades break pinned compatibility or adapters become harder to maintain than the framework saves. +- Revisit storage/deployment assumptions when native-platform or filesystem tests show locking, rename, sync or recovery behavior differs. No multi-node failover is implemented by the instance lock. + +The implementation includes machine-readable catalog outputs, not an LLM, RAG service or electrical-replacement recommendation engine. V1 excludes commerce, CRM, arbitrary JavaScript/templates, a generic page builder and externally issued write tokens. The [case study](engineering-case-study.md) explains the trade-offs without turning these boundaries into claims about future scale. diff --git a/docs/en/engineering-case-study.md b/docs/en/engineering-case-study.md new file mode 100644 index 0000000..fd485af --- /dev/null +++ b/docs/en/engineering-case-study.md @@ -0,0 +1,133 @@ +# Prods engineering case study + +English | [繁體中文](../zh-TW/engineering-case-study.md) | [简体中文](../zh-CN/engineering-case-study.md) + +[README](../../README.md) · [Architecture](architecture.md) · [Operations and evidence limits](operations.md) + +## Context and evidence + +A technical B2B catalog has to preserve distinctions that a name/description/image product list cannot express. A semiconductor's voltage range, a connector's contact count and a module's interface are different specifications. Raw values may include conditions or tolerances. Manufacturer identity, part number, lifecycle and datasheets help a buyer identify a part, but none establishes current stock or suitability as a replacement. + +Prods connects administrator-maintained catalog data to public discovery and RFQ intake. The same inquiry workflow accepts catalog products and visitor-entered Requested Parts, so an incomplete catalog does not require inventing product records. The implementation has category/specification models, public publication protocols, an Admin application and installation/recovery paths. It does not establish customer adoption or a running production deployment. + +This account was checked against the working tree on 2026-09-17 (`VERSION`: `v0.6.8`). Links below point to public source and tests. Internal requirements and historical ADRs remain local; this document neither republishes them nor creates retrospective ADRs. Alternatives below are architectural comparisons, not claims that every alternative was prototyped. Evolution triggers are review criteria proposed by this guide. + +## Requirements that drove the implementation + +| Requirement | Observable implementation consequence | +| --- | --- | +| A small team can operate a self-hosted catalog | One executable embeds the UI and runs SQLite-backed application services. | +| Buyers and crawlers can read core content without JavaScript | Go produces usable HTML; React enhances selected controls. | +| Saved data and public visibility must be distinguishable | Publication intents and active revisions separate edits from public activation. | +| Repeated RFQ submissions must have an inspectable outcome | Canonical payload hashes and durable receipts share the RFQ transaction. | +| Technical data differs by category and can be incomplete | Spec Sets, raw values and derived normalization records are separate. | +| Damaged state must not be mistaken for a new site | Startup inspection, Installer and journaled Recovery have explicit boundaries. | + +Traffic volume, revenue, customer count, high availability and a need for distributed services are not established requirements or measured results in this account. + +## 1. One process with explicit modules + +**Decision.** Run HTTP, application services and bounded background work in one Go executable. + +**Context.** A self-hosted catalog already needs persistent data, asset files and a recovery procedure. Requiring separate API, renderer, queue and search deployments would add operational dependencies to those responsibilities. + +**Alternatives considered.** Separate services and an external queue are a useful comparison when components require independent scaling or failure isolation; no such production requirement is established here. + +**Why this choice.** [`main.go`](../../cmd/prods/main.go) wires the services, while [`publishing.Repository`](../../internal/publishing/protocol.go) and the [`backup Snapshotter`](../../internal/recovery/backup.go) make persistence dependencies explicit. SQL-backed work records retain intent across restarts without a queue server. + +**Trade-offs.** Packaging and local diagnosis are simpler. CPU, memory, process failure and maintenance remain shared. Package boundaries make reasoning and testing easier; they do not make a later service split free, particularly around transactions and visibility. + +**Failure / evolution trigger.** Representative measurements show that workers cannot coexist with interactive traffic within resource limits, or a deployment needs independently available components. Establish that evidence before introducing network boundaries. + +## 2. SQLite with controlled writes + +**Decision.** Use SQLite as the primary database and SQL-first repositories with serialized write admission. + +**Context.** Catalog reads, short Admin/RFQ writes and occasional large imports have different costs. Atomic import and durable inquiry capture need a clear contention contract. + +**Alternatives considered.** A database server can support different concurrency and operational arrangements; it also adds provisioning, access and backup responsibilities. Splitting an import into partial commits would shorten individual transactions but change the all-or-nothing contract. + +**Why this choice.** [`beginWrite`](../../internal/storage/sqlite/store.go) provides one in-process write admission point. [`import_store.go`](../../internal/storage/sqlite/import_store.go) validates and rechecks the import at commit. Backups pair a consistent DB snapshot with referenced assets and other required roots. + +**Trade-offs.** There is no database server to operate, but writes contend. A long final import transaction may delay RFQs. The writer channel is not a priority/fairness guarantee. Backing up a DB file alone is insufficient when records refer to external files and secrets. + +**Failure / evolution trigger.** Measure queue wait, RFQ latency, import transaction duration and restore time against an operator-agreed target. Revisit preparation, scheduling or storage if that workload cannot meet the target. This guide supplies no invented throughput ceiling or SLA. + +**Evidence to inspect.** [`Atomic import tests`](../../internal/storage/sqlite/import_store_test.go) cover revision rechecks, receipt replay and rollback; [`backup tests`](../../internal/recovery/backup_files_test.go) cover paired files. The tagged [`POC-02 harness`](../../internal/storage/sqlite/poc02_test.go) is an experiment, not production capacity evidence. + +## 3. Separate Public rendering from Admin interaction + +**Decision.** Generate Public HTML in Go and use isolated React enhancements; use React/Refine/Ant Design for the Admin application. + +**Context.** Product information, search links and basic RFQ forms need to work for visitors and crawlers before JavaScript runs. Administrators need interactive lists, forms, previews and job status. + +**Alternatives considered.** A single full-page SPA would put core Public behavior behind client execution. A separate Node rendering service would change the runtime distribution model. Server-only Admin forms would avoid client state but give up the chosen management framework. + +**Why this choice.** [`Public templates`](../../internal/webapp/templates/pages.tmpl) and [`publication rendering`](../../internal/publishing/render.go) produce the content. [`Public roots`](../../web/ui/src/public/main.tsx) add RFQ selection/listing controls. Separate Vite entry points embed Admin and system applications without turning Public into an Admin bundle. + +**Trade-offs.** Two presentation models require consistent localization and behavior. Shared published data reduces drift across HTML/JSON/Markdown, but does not automatically prove every rendering path correct. Go-generated HTML is not React SSR. + +**Failure / evolution trigger.** New interactions cannot be implemented as bounded enhancements without duplicating core data behavior, or measured rendering/maintenance costs justify another model while retaining the no-JavaScript core journey. + +**Evidence to inspect.** [`Search/listing HTML tests`](../../internal/webapp/search_test.go), [`machine-format tests`](../../internal/publishing/machine_test.go) and [`localized-unit tests`](../../internal/publishing/localized_unit_test.go). + +## 4. Domain commands retain their own outcomes + +**Decision.** Use the Refine DataProvider for supported resources and named adapters for meaningful transitions. Keep publication and RFQ success tied to their own durable evidence. + +**Context.** Editing a field, archiving a product, publishing a website and retrying an inquiry do not have interchangeable effects. A lost response does not prove a failed write. + +**Alternatives considered.** Mapping everything to generic CRUD, optimistic UI updates or automatic mutation retries would simplify some client code but obscure revisions, partial success and ambiguous outcomes. + +**Why this choice.** [`dataProvider.ts`](../../web/ui/src/admin/dataProvider.ts) rejects Product delete; [`catalogCommands.ts`](../../web/ui/src/admin/catalogCommands.ts) names Archive, Hide, Clone and preview. Publish currently uses a revisioned Product PUT: the boundary is semantic, not a promise of one endpoint per command. [`api.ts`](../../web/ui/src/admin/api.ts) tells users to inspect saved state when a write's outcome is unknown. [`AdminProviders.tsx`](../../web/ui/src/admin/AdminProviders.tsx) disables mutation retry and optimistic mutation mode. + +The same discipline reaches persistence. [`Publication`](../../internal/publishing/protocol.go) stages a unit before activating it, and revocation denies new admission before ETag/Range processing. [`SubmitRFQ`](../../internal/storage/sqlite/store.go) stores the canonical payload hash and receipt in the RFQ transaction. Same-key/same-payload replay differs from a conflicting edit. Product Bulk permits per-product partial success; Excel import is whole-batch atomic. + +**Trade-offs.** Adapters and explicit outcome handling require more code. A publication save may precede public activation; users need state they can inspect. SMTP can have an uncertain result and cannot be treated as exactly-once delivery. + +**Failure / evolution trigger.** Commands bypass the shared transport, receipts cannot reconcile interruptions, or tests show stale data becoming public. Fix the contract and its evidence before hiding the problem with automatic retries. + +**Evidence to inspect.** [`Adapter tests`](../../web/ui/src/admin/adapters.test.ts), [`normal publication tests`](../../internal/webapp/publication_test.go), [`POC-03 protocol tests`](../../internal/publishing/poc03_test.go), [`RFQ receipt tests`](../../internal/storage/sqlite/rfq_receipt_test.go). Renderer feasibility and publication correctness are separate gates; neither is proof of production readiness. + +## 5. Category-specific data with controlled presentation + +**Decision.** Model specifications separately from Products, reuse Spec Sets, retain raw values and track normalization as derived data. Configure category listing profiles separately from those values. + +**Context.** A giant flat product schema would mix unrelated component properties. Treating every raw string as a reliable scalar would misrepresent units, ranges, conditions and missing data. + +**Alternatives considered.** One column per possible property creates schema churn; a free-form blob alone gives little support for applicability, provenance or controlled filtering. An arbitrary per-product page layout would combine data maintenance with presentation design. + +**Why this choice.** [`maintenance.go`](../../internal/catalog/maintenance.go) defines SpecValue and NormalizedValue separately, including source revision, semantic version and status. [`spec_document_store.go`](../../internal/storage/sqlite/spec_document_store.go) reconciles applicability. [`listing.go`](../../internal/cataloglisting/listing.go) derives Common columns from the full scope's applicability rather than the first row or current page. Product identity remains independent of paths and search folding. + +**Trade-offs.** Applicability, stale normalization and category changes require explicit reconciliation. The model and normalization records do not establish a general electrical parser or prove two parts compatible. Structured configuration is less flexible than arbitrary templates, but preserves a reviewable publishing boundary. + +**Failure / evolution trigger.** Real categories cannot express required properties or users repeatedly need unsupported calculations. Add a defined data/normalization contract with examples and failure behavior before expanding layout or inference capabilities. + +**Evidence to inspect.** [`Common/profile tests`](../../internal/cataloglisting/listing_test.go), [`catalog maintenance tests`](../../internal/catalog/maintenance_test.go), [`blank-manufacturer identity tests`](../../internal/storage/sqlite/d9_identity_test.go). + +## 6. Installation and recovery are lifecycle boundaries + +**Decision.** Distinguish fresh, installing, ready, upgrade-required and recovery-required state. Treat restore as a journaled operation with roll-forward after preparation. + +**Context.** A missing initial database and a damaged existing database require opposite responses. Restoring DB, assets and secrets across roots cannot be represented by one filesystem rename. + +**Alternatives considered.** Automatically initializing on any open error risks replacing evidence of an existing site. Copying roots without a journal cannot distinguish a completed step from an interrupted one. Returning to an older point is a separate restore, not an implicit undo after preparation. + +**Why this choice.** [`Inspect` and `CompleteInstallation`](../../internal/storage/sqlite/store.go) classify state and commit the initial Owner/Ready state together. [`recovery_ui.go`](../../cmd/prods/recovery_ui.go) provides a separate repair entry. [`restore.go`](../../internal/recovery/restore.go) records per-root hashes and progress, resumes the same operation and verifies all roots before completion. + +**Trade-offs.** Startup and recovery have more states and more code. A prepared operation may require repair before normal service resumes. Local manifests and read-only files do not provide off-host disaster recovery, tamper-proof storage or a recovery-time guarantee. + +**Failure / evolution trigger.** An interrupted step cannot be reconciled from durable evidence, platform file operations behave differently, or the operator's recovery target exceeds measured restore behavior. Test the actual platform/storage topology and revise the protocol deliberately. + +**Evidence to inspect.** [`Installation tests`](../../internal/storage/sqlite/installation_test.go), [`restore fault-boundary tests`](../../internal/recovery/restore_fault_test.go), [`shutdown tests`](../../cmd/prods/shutdown_test.go). Synthetic failures and cross-compilation do not replace native power-loss, filesystem or service-manager acceptance. + +## Scope and AI positioning + +**Unpublished local work, excluded from this documentation commit:** the reviewed working tree contained `internal/webapp/brand_import.go` and `web/ui/src/admin/brandImportLabels.ts`, implementing a human-mediated external ChatGPT workflow. Prods generates a request-bound prompt/schema; the operator runs it outside Prods and pastes JSON back. Validation checks request/version/source-URL bindings and produces a proposed diff. Explicit approval with an expected revision saves Website Working Copy; publication remains separate. Structural validation does not independently verify the external model's observations. This code was already in progress during this review; no release or end-to-end external-model acceptance is claimed. It is not a built-in inference or RAG service. + +- **Implemented:** catalog maintenance, category/specification data, public discovery, publication, RFQ intake, Admin and lifecycle/operations code described above; JSON/Markdown/`llms.txt` are readable outputs. +- **Planned or deferred:** broader content composition/headless capabilities are outside current V1 delivery; this guide makes no commitment to dates or implementation designs. +- **Exploratory:** AI-assisted RFQ extraction could be evaluated only against a real workflow, with explicit confidence, verification and failure boundaries. It is not documented as an implemented feature. +- **Out of scope:** checkout, pricing/inventory promises, CRM, arbitrary per-entity layouts or JavaScript, external write tokens, authoritative cross-manufacturer replacements, an AI chatbot or RAG service. Semantic similarity is not evidence of electrical compatibility. + +The repository supports a concrete account of product boundaries, implementation and operational mechanisms. Establishing a running production system additionally requires deployment-specific evidence; [operations](operations.md) lists what is missing. diff --git a/docs/en/operations.md b/docs/en/operations.md new file mode 100644 index 0000000..3d6510e --- /dev/null +++ b/docs/en/operations.md @@ -0,0 +1,106 @@ +# Operations and production evidence + +English | [繁體中文](../zh-TW/operations.md) | [简体中文](../zh-CN/operations.md) + +[README](../../README.md) · [Architecture](architecture.md) · [Engineering case study](engineering-case-study.md) + +## Production status + +Reviewed on 2026-09-17 against the working tree (`VERSION`: `v0.6.8`). The repository contains a runnable application, release packaging, operational controls and automated tests. These are implementation evidence. This documentation review did not verify a live production endpoint, its deployed revision, real customer workload, production TLS configuration or an off-host restore exercise. “Production” here describes an intended operator-managed installation serving real catalog/RFQ data; it is not an asserted deployment milestone. + +Source links and test files below let readers inspect the mechanisms. A test's presence is not a claim that it passed on every platform, and workflow configuration is not a successful CI run. The checkout includes ongoing changes; it is not itself a clean release artifact. + +## Runtime and deployment boundary + +The release model is one platform-specific Go binary, embedded frontend assets, local SQLite and managed files. [`main.go`](../../cmd/prods/main.go) takes an OS-level database ownership lock before opening normal or one-shot operations. [`service_linux.go`](../../internal/platform/service_linux.go) generates an optional systemd unit; [`service_windows.go`](../../internal/platform/service_windows.go) implements Windows service integration. A service definition is not evidence of service-manager acceptance on a real host. + +Internet-facing use expects an operator-supplied TLS reverse proxy and an explicit HTTPS Base URL. Prods checks request authority and supports configured trusted proxy addresses. The repository does not supply evidence of a specific live proxy/TLS topology. A reverse proxy must forward to the application, not expose data, backups, secrets or generated-history directories. See [`proxy_trust.go`](../../internal/webapp/proxy_trust.go). + +Defaults are resolved relative to the executable: + +| Setting | Default / role | +| --- | --- | +| Listen | `:8080`; this is not a loopback-only default | +| Base URL | `http://127.0.0.1:8080` | +| Config | `prods.ini` | +| Data / DB | `data/` / `data/prods.db` | +| Backups | `backups/` | +| Managed data subdirectories | Assets, work, generated output, secrets, control journals and logs | + +Precedence is CLI flag → environment variable → one config file → defaults. [`hostconfig/config.go`](../../internal/hostconfig/config.go) defines the flags and validation. Use the README's example and the actual binary's `--help`; advanced paths and credentials should not be inferred from a developer's local configuration. + +## How to start an isolated local installation + +Use a Linux amd64 release binary and a new directory, or the source-build commands in the [README](../../README.md). The following Linux shell example assumes the binary is in the current directory and port 18080 is free: + +```sh +demo_dir="$(mktemp -d)" +cp ./prods-linux-amd64 "$demo_dir/prods" +chmod +x "$demo_dir/prods" +"$demo_dir/prods" --listen 127.0.0.1:18080 --base-url http://127.0.0.1:18080 +``` + +1. Open the one-time Installer URL printed by the terminal. Create the Owner and complete installation. An empty catalog is valid; sample data is optional and bound to the binary's release version. +2. After Prods exits, repeat the last command. Sign in at `http://127.0.0.1:18080/admin`; inspect the public site at `/`. +3. Check `http://127.0.0.1:18080/health/live` and `/health/ready`. Readiness must be evaluated separately from process liveness. Stop with `Ctrl+C` when finished. + +If the explicit port is occupied, choose another and change both listen and Base URL. If startup reports damaged/unrecognized data, retain the directory and use Recovery; do not treat it as a request for reinstallation. Installer and normal-startup behavior are exercised by [`main_test.go`](../../cmd/prods/main_test.go), [`installer tests`](../../internal/webapp/installer_test.go) and [`installation tests`](../../internal/storage/sqlite/installation_test.go). + +## How to back up and restore + +For a running site, use Admin Backup or the scheduler. For the command-line one-shot operations below, first stop the service/foreground instance and use the same executable/config/data paths. The ownership lock prevents a second process from sharing the instance. + +```sh +./prods-linux-amd64 --backup-now +./prods-linux-amd64 --restore-backup +``` + +Replace the restore placeholder with a selected backup. Restore changes persistent state; the second command is an alternative operation, not a required follow-up to every backup. It normally requires a verified pre-restore backup of the current site. The host-authorized `--allow-restore-without-prebackup` exception exists for a state that cannot be backed up; it is not the default procedure. + +Backup pairs a consistent database snapshot with its referenced assets, configured roots and files, and an independent manifest. The manifest records content verification and read-only application separately. Secrets make backups sensitive. A checksum/read-only flag does not establish encryption, tamper resistance or survival of host loss. External secret requirements must also be satisfied. + +Restore stages and verifies roots, persists `prepared`, then rolls the same operation forward using per-root evidence. After final verification, restart normally and check readiness, Admin access, public pages and required assets. If recovery reports missing evidence or mismatched roots, retain the journal and source backup; do not delete the journal to force normal startup. See [`backup.go`](../../internal/recovery/backup.go), [`restore.go`](../../internal/recovery/restore.go), [`restore fault tests`](../../internal/recovery/restore_fault_test.go) and [`backup/restore CLI tests`](../../cmd/prods/backup_restore_test.go). + +## How to recover Owner access + +With the normal instance stopped, use the same configuration and an existing active Owner's address: + +```sh +./prods-linux-amd64 --recover-owner owner@example.com +``` + +The command prints an expiring one-time link and exits. Restart Prods normally, then open the link before it expires. Keep the link private. The command requires a completed site database; it does not reopen Installer or repair an unreadable database. Evidence: [`main.go`](../../cmd/prods/main.go), [`identity_store.go`](../../internal/storage/sqlite/identity_store.go) and [`user lifecycle tests`](../../internal/webapp/user_lifecycle_test.go). + +## Startup, upgrades, shutdown and diagnosis + +- **Startup:** durable inspection distinguishes fresh, installing, ready, upgrade and recovery states. Incomplete installation receives a new claim; successful installation requires restart. +- **Migrations:** versioned SQL and migration history ship in the binary. Upgrade startup creates/verifies a backup and records migration progress before normal operation. An interrupted operation is reconciled or enters Recovery, not silently accepted. +- **Shutdown:** new work is drained before shutdown and component closure. HTTP shutdown has a timeout and can force-close; this is not a guarantee that every client request completes under all failures. [`shutdown_test.go`](../../cmd/prods/shutdown_test.go) covers bounded behavior. +- **Maintenance:** new Public/RFQ admission returns 503 while permitted Admin, Recovery, system assets and liveness remain available. This is a controlled outage, not high availability. +- **Diagnosis:** JSON runtime logs and the interactive terminal expose runtime state; Admin exposes jobs and health. [`runtime_log.go`](../../internal/platform/runtime_log.go) and [`runtime_health.go`](../../internal/platform/runtime_health.go) implement local mechanisms. No external monitoring/on-call deployment is established here. +- **Secrets/integrations:** normal-site SMTP and Google credentials are read from private files. Optional mail/indexing operations are distinct from catalog serving; a provider receipt does not prove indexing or exactly-once mail delivery. Inspect [`maildelivery`](../../internal/maildelivery/manager.go) and [`searchnotify`](../../internal/searchnotify/searchnotify.go). + +## Build, release and verification scope + +[`ci.yml`](../../.github/workflows/ci.yml) defines frontend typecheck/tests, embedded-asset drift checks, Go normal/PoC tests, vet, license/sample checks and cross-builds. [`release.yml`](../../.github/workflows/release.yml) validates a `v*` tag and publishes release files; it does not deploy a running site. [`build-release.sh`](../../scripts/build-release.sh) checks `VERSION`, Go version, source revision, clean working tree and exact sample artifacts, then emits binaries, build information and checksums. + +Linux amd64, Windows amd64 and macOS amd64/arm64 are build targets. Windows/macOS native runtime, services, signing and platform-specific recovery require separate evidence for the exact artifact. Linux tests cannot settle those questions. POC-01 renderer feasibility and POC-03 publication protocol correctness are different gates; a rendering smoke test does not establish publication correctness or production readiness. + +The full source-verification commands remain in the [README](../../README.md). Frontend builds rewrite embedded files, and release packaging requires a clean checkout. Run them in a suitable checkout rather than overwriting unrelated in-progress work merely to validate documentation. + +## Current Operational Boundaries + +| Repository evidence | Boundary of the claim | +| --- | --- | +| Instance locking and one-process runtime | No multi-node coordination, failover or zero-downtime upgrade guarantee. | +| Serialized SQLite writes, import tests | No measured production capacity or zero RFQ wait during atomic imports. | +| Publication admission and protocol tests | Tests cover stated scenarios; previously admitted transfers may finish after revocation. | +| Receipts and audit records | Durable application evidence, not an exactly-once guarantee for external services. | +| Backup manifests and restore journals | No verified off-host backup policy, recovery-time/recovery-point objective or power-loss guarantee for every filesystem. | +| Sessions, CSRF, capability checks and file validation | Implemented controls, not security certification or a comprehensive security guarantee. | +| Ten built-in locale resources | No professional native-language, legal or marketing review claimed. | +| Build/release workflows and sample catalog | No customer counts, business traction, live traffic, SLA or actual deployment inferred from fixtures or automation. | + +## Evidence still needed for a production account + +An operator can supplement this repository with the deployed version/source revision, host/platform, service/proxy configuration, TLS validation, persistent-root mapping, backup retention/off-host destination, a dated restore exercise and measured workload/incident evidence. Keep credentials and customer data private. Until that evidence exists, describe the implementation and its tests rather than an unverified production success story. diff --git a/docs/zh-CN/architecture.md b/docs/zh-CN/architecture.md new file mode 100644 index 0000000..12c8caa --- /dev/null +++ b/docs/zh-CN/architecture.md @@ -0,0 +1,99 @@ +# 架构概览 + +[English](../en/architecture.md) | [繁體中文](../zh-TW/architecture.md) | 简体中文 + +[README](../../README-zh_CN.md) · [工程案例研究](engineering-case-study.md) · [运维](operations.md) + +本文档根据 2026-09-17 查看的工作目录源代码撰写,当时 `VERSION` 为 `v0.6.8`。内容描述实现,不代表已验证某个发布成品或生产部署。这组三语工程指南以英文为语义基准,不取代产品需求或历史决策。 + +## 系统情境 + +Prods 提供企业技术产品目录与 RFQ(询价请求)流程。管理员维护产品数据、分类专属规格、文件及网站配置;访客探索已发布产品并提交询价,产品目录找不到时也可提出 Requested Part。RFQ 记录需求,不代表价格、库存、电气兼容性或销售订单。 + +运行环境采用模块化单体:一个 Go 进程、一份 SQLite 数据库及受管理的文件系统目录。后台任务在同一进程运行。部署时可在前方放置 TLS 反向代理;代码仓库并未证实任何特定正式代理正在运作。 + +```mermaid +flowchart TB + V[访客与爬虫] --> P[公开 HTML 与读取格式] + A[管理员] --> U[内嵌 Admin 应用程序] + H[主机操作者] --> S[Installer 与 Recovery] + subgraph G[单一 Prods Go 进程] + P --> D[应用与领域操作] + U --> API[Admin HTTP API] + API --> D + S --> L[启动与恢复生命周期] + D --> W[写入准入与 SQL 存储层] + D --> J[进程内发布及其他后台任务] + J --> W + end + W --> DB[(SQLite)] + J --> F[受管理资产与生成文件] + L --> DB + L --> B[备份 manifest 与恢复 journal] +``` + +图中分组代表责任,不是可独立部署的服务。入口与组装位于 [`cmd/prods/main.go`](../../cmd/prods/main.go) 及 [`internal/webapp/server.go`](../../internal/webapp/server.go)。 + +## 组件及边界理由 + +| 边界 | 目前实现 | 存在理由 | +| --- | --- | --- | +| 领域与持久化 | `internal/catalog`、`inquiries`、`site`、`localization`;SQL repository 位于 `internal/storage/sqlite` | 规则、持久状态与事务留在 Go,不依赖浏览器框架。 | +| 公开网站 | Go template、生成的产品表示,以及动态搜索/列表/RFQ handler | 核心内容与基本导航不需要 JavaScript。 | +| 公开交互增强 | RFQ 选取与列表控制的 React root | 在既有 HTML 上增加交互,不要求整页 hydration。 | +| Admin | React、headless Refine Core、React Router、TanStack Query、原生 Ant Design v6 | 管理界面可使用应用状态与表单,不把 Admin 依赖软件包带入 Public。 | +| System UI | 独立 Installer/Recovery 入口与内嵌 system assets | 数据库损坏或正常 Admin 无法使用时,恢复不能依赖同一数据库/session。 | +| 后台任务 | 进程内的发布、导入、备份、邮件与搜索通知管理器 | 以持久记录追踪工作;内存中的唤醒信号不代表完成。 | + +参见 [`web/ui/package.json`](../../web/ui/package.json)、分离的 [`Vite 配置`](../../web/ui/vite.public.config.ts)、[`Public roots`](../../web/ui/src/public/main.tsx) 及 [`system 入口`](../../web/ui/src/system/main.tsx)。Public HTML 由 Go 生成,并非 React server-side rendering。Admin 使用 `@refinedev/core`,未使用 `@refinedev/antd`。 + +## 请求与数据流程 + +Public operational routes 包含 `GET /api/locales`、`POST /api/rfqs` 及 HTML `GET/POST /rfq`,与生成的读取格式、需要认证的 `/admin/api/` 操作分开。这些路由不代表承诺提供带版本的外部 write-token API。 + +**产品目录编辑与发布。** Admin 请求带入 session、CSRF token 与预期 revision。存储层检查写入,并在同一事务记录状态、必要审计及发布意图。发布引擎准备各种表示,再启用产品的公开单元。存储编辑与启用公开 revision 是不同事件;准备替代版本时,可继续提供前一个有效单元。Hide/Archive 更新可见性,使新的访问准入无法使用已撤销产品;已准入的传输可以完成。公开准入先于条件式响应及 Range 处理。共用资产仍可通过其他有效引用提供。 + +全站配置与路由变更有独立的 site epoch 启用边界。搜索等汇总查看可在产品启用后收敛,但必须检查可见性。历史生成目录不通过通用文件服务公开。证据:[`catalog_store.go`](../../internal/storage/sqlite/catalog_store.go)、[`publication_store.go`](../../internal/storage/sqlite/publication_store.go)、[`protocol.go`](../../internal/publishing/protocol.go)、[`发布测试`](../../internal/webapp/publication_test.go)。 + +**公开探索。** 公开响应使用明确的已发布数据模型 [`PublicView`](../../internal/publishing/view.go),不直接序列化 Admin 记录。HTML、JSON-LD、JSON 与 Markdown 共用此模型。搜索由应用程序处理 Unicode folding,并转义作为字面值的 LIKE 万用字元;身份比对是另一套责任。[`cataloglisting`](../../internal/cataloglisting/listing.go) 依整个列表范围计算适用字段,不只看目前页面。语言解析记录各字段的实际来源,并跳过已停用的候选语言。关闭多语言编辑只隐藏编辑控制;公开语言变更必须经过 Website 发布。证据:[`product.go`](../../internal/catalog/product.go)、[`resolver.go`](../../internal/localization/resolver.go)、[`Website 多语测试`](../../internal/webapp/website_localization_test.go)。 + +**RFQ 提交。** 访客明确提交表单。高熵 idempotency key 识别这次提交,具版本的 canonical payload hash 识别内容。RFQ、项目与可重放回执一起提交事务。相同 key/内容返回既有回执;内容不同则冲突。搜索无结果只在访客主动选择后才转成 Requested Part。SMTP 寄送是另一个经授权且记录结果的操作。证据:[`rfq.go`](../../internal/inquiries/rfq.go)、[`SubmitRFQ`](../../internal/storage/sqlite/store.go)、[`回执查询`](../../internal/storage/sqlite/rfq_receipt.go)、[`邮件管理器`](../../internal/maildelivery/manager.go)。 + +## 持久化模型 + +| 数据 | 表示与约束 | +| --- | --- | +| 产品身份 | 稳定不透明 ID;Current/Archived 记录状态与 Published/Hidden 可见性分开。Manufacturer + Part Number 冲突在受控写入内检查,不靠 DB unique constraint。空白制造商与具名制造商不同。 | +| 技术规格 | Spec definition、可重用 Spec Set、分类指派及逐产品原始值;规范化值带有来源/规格版本与状态。缺值或无法解析的值不会默默变成零。 | +| 发布 | 持久意图、启用记录、site epoch 与不可变生成单元。生成输出是衍生数据,不是产品目录真实数据的唯一副本。 | +| Website 与语言 | 工作中/已发布配置、官方 Public Copy 定义/默认值及客户覆盖;内容来源语言与 Admin UI 语言无关。 | +| 运维 | SQLite 保存 session、工作、RFQ 回执、审计、备份记录与 migration history;文件系统 journal 另保存恢复证据。 | +| 文件 | 以稳定 ID 引用的不可变资产、私有 secrets/配置、暂存工作与备份分属受管理目录。文件先完成持久化放置,再提交引用。 | + +基础 schema 位于 [`store.go`](../../internal/storage/sqlite/store.go),后续变更位于 [`migrations.go`](../../internal/storage/sqlite/migrations.go)。[`spec_document_store.go`](../../internal/storage/sqlite/spec_document_store.go)、[`asset_store.go`](../../internal/storage/sqlite/asset_store.go) 与 [`D9 身份测试`](../../internal/storage/sqlite/d9_identity_test.go) 呈现数据契约。 + +`beginWrite` 使用 channel 与 context cancellation 序列化已准入写入。这是单进程协调,不是分散式调度器或延迟保证。Atomic Excel Import 在最终全成全败事务前先验证,但最终事务仍可能延迟 RFQ 写入。Product Bulk 则记录逐产品结果,允许部分成功。参见 [`import_store.go`](../../internal/storage/sqlite/import_store.go) 与 [`product_bulk_store.go`](../../internal/storage/sqlite/product_bulk_store.go)。 + +## 认证与 Admin API + +正常安装使用具版本的密码验证器与持久、不透明的 server-side session。SQLite 存储 session token 的摘要,不存 bearer 原值。操作由服务器端 capability 授权;隐藏按钮不是授权。共用 Admin transport 传送 same-origin credentials 与 CSRF header,拒绝过期 session 的响应,并把中断写入标示为结果未知。不启用自动 mutation retry 或乐观成功。 + +Refine DataProvider 处理支持的 resource 操作;具名 adapter 保留 Hide、Archive、Clone、URL 变更、preview 等领域流程。具名命令不一定有独立 HTTP verb 或 endpoint:目前 `publish` adapter 使用带 revision 的 Product PUT。通用 delete 被拒绝,应使用 Archive。证据:[`password.go`](../../internal/identity/password.go)、[`session 测试`](../../internal/storage/sqlite/installation_test.go)、[`api.ts`](../../web/ui/src/admin/api.ts)、[`dataProvider.ts`](../../web/ui/src/admin/dataProvider.ts)、[`catalogCommands.ts`](../../web/ui/src/admin/catalogCommands.ts)、[`AdminProviders.tsx`](../../web/ui/src/admin/AdminProviders.tsx)。 + +## 运行环境、安装与恢复 + +默认路径以可执行文件目录为基准。配置优先序为 CLI → 环境变量 → 单一 `prods.ini` → 默认值。Node.js/pnpm 用于构建内嵌资产,不是应用主机的运行需求。SQLite driver 为 `modernc.org/sqlite`,发布构建停用 CGO。已有可选 Linux systemd 与 Windows service 代码;发布脚本也交叉编译 macOS 成品,但编译不等于平台验收。 + +启动先分类持久状态,再进入正常操作。全新数据进入 Installer;未完成安装以新 claim 继续。Installer 原子提交 Owner、最低站点配置、系统分类、必要 log 与 Ready marker,然后要求重启。无法辨识/损坏的数据库进入 Recovery Required,不能改成重新安装。Migration 需要备份证据。Restore 先准备各 root 并写入持久 journal;`prepared` 后只允许同一操作向前完成,逐 root 检查后再做最终验证。Maintenance 控制新操作准入,同时保留修复路径。运行证据与限制见[运维指南](operations.md)。 + +## 限制与演进触发条件 + +以下是重新评估的条件,不是已核准迁移或 roadmap: + +- 代表性导入的实测竞争使 RFQ 延迟不可接受时,重新评估数据库/写入调度;目前没有经验证的生产流量范围。 +- Profiling 显示 worker 资源限制无法保护交互操作,或独立可用性成为明确需求时,重新评估进程边界。 +- 实际产品目录/查询负载超出目前搜索投影能力时,再评估搜索;目前不要求外部搜索服务。 +- 受支持的升级破坏固定软件包兼容性,或 adapter 维护成本超过框架效益时,重新评估 Admin bridge。 +- 原生平台或文件系统测试显示 locking、rename、sync、recovery 行为不同时,重新评估存储/部署假设。Instance lock 没有实现多节点 failover。 + +目前实现包含机器可读产品目录输出,不包含 LLM、RAG 服务或电气替代料推荐引擎。V1 排除电商、CRM、任意 JavaScript/template、通用页面编辑器与对外发行的写入 token。[案例研究](engineering-case-study.md) 说明取舍,不把边界变成未来规模承诺。 diff --git a/docs/zh-CN/engineering-case-study.md b/docs/zh-CN/engineering-case-study.md new file mode 100644 index 0000000..203346a --- /dev/null +++ b/docs/zh-CN/engineering-case-study.md @@ -0,0 +1,133 @@ +# Prods 工程案例研究 + +[English](../en/engineering-case-study.md) | [繁體中文](../zh-TW/engineering-case-study.md) | 简体中文 + +[README](../../README-zh_CN.md) · [架构](architecture.md) · [运维与证据限制](operations.md) + +## 情境与证据 + +技术型 B2B 产品目录必须保留一般名称/描述/图片清单无法表达的差异。半导体的电压范围、连接器的接点数与模块的界面是不同规格;原始值可能带有条件或容差。制造商身份、型号、生命周期与 datasheet 有助于辨识产品,但不代表即时库存或可作为替代料。 + +Prods 将管理员维护的产品目录连接到公开探索与 RFQ。相同询价流程接受产品目录产品及访客输入的 Requested Part,因此产品目录不完整时也不必捏造产品记录。实现包含分类/规格模型、公开发布协议、Admin 应用程序及安装/恢复路径;这些不构成客户采用或生产部署正在运行的证据。 + +本文于 2026-09-17 对照工作目录核查(`VERSION`:`v0.6.8`)。以下链接指向公开源代码与测试。内部需求及历史 ADR 留在本机;本文不重新公开其内容,也不事后补造 ADR。替代方案是架构比较,不表示每个方案都曾做过原型。演进触发条件是本指南提出的评估准则。 + +## 真正影响实现的需求 + +| 需求 | 可观察的实现结果 | +| --- | --- | +| 小型团队可以自托管运维产品目录 | 单一可执行文件内嵌 UI,运行以 SQLite 为基础的应用服务。 | +| 买家与爬虫无 JavaScript 也能读取核心内容 | Go 产生可用 HTML;React 增强部分控制。 | +| 已存储数据与公开可见性必须可区分 | 发布意图与启用 revision 分开编辑与公开启用。 | +| 重复 RFQ 提交需要可核查的结果 | Canonical payload hash 与持久回执和 RFQ 共用事务。 | +| 技术数据因分类而异,也可能不完整 | Spec Set、原始值与衍生规范化记录分开。 | +| 损坏状态不能被当成新站点 | 启动检查、Installer 与 journaled Recovery 有明确边界。 | + +本文没有把流量、营收、客户数、高可用性或分散式服务需求描述成已确认需求或实测结果。 + +## 1. 单进程与明确模块 + +**决策。** 在一个 Go 可执行文件内运行 HTTP、应用服务与有界后台任务。 + +**情境。** 自托管产品目录已经需要持久数据、资产文件与恢复程序;再要求独立 API、renderer、queue 与 search 部署,会增加运维依赖性。 + +**替代方案。** 组件需要独立扩展或故障隔离时,可比较独立服务与外部队列;目前没有证据确立这种生产环境需求。 + +**选择理由。** [`main.go`](../../cmd/prods/main.go) 组装服务,[`publishing.Repository`](../../internal/publishing/protocol.go) 与 [`backup Snapshotter`](../../internal/recovery/backup.go) 明确表达持久化依赖性。SQL 工作记录在重启后保留意图,不需 queue server。 + +**取舍。** 封装与本机诊断较简单,但 CPU、内存、进程故障及维护仍共用。软件包边界有助推理与测试,不表示未来拆服务没有成本,尤其涉及事务与可见性时。 + +**失败/演进触发条件。** 代表性测量显示 worker 无法在资源限制内与交互流量共存,或部署确实需要组件独立可用。先建立证据,再引入网络边界。 + +## 2. SQLite 与受控写入 + +**决策。** 使用 SQLite 作为主要数据库,以 SQL-first repository 与序列化写入准入协调操作。 + +**情境。** 产品目录读取、短 Admin/RFQ 写入及偶尔的大型导入成本不同。Atomic Import 与持久询价保存需要明确的资源竞争契约。 + +**替代方案。** 数据库服务器可提供不同的并发与运维安排,也增加部署、访问及备份责任。拆分导入事务可缩短个别事务,却会改变全成全败契约。 + +**选择理由。** [`beginWrite`](../../internal/storage/sqlite/store.go) 提供单进程共用写入准入点。[`import_store.go`](../../internal/storage/sqlite/import_store.go) 在提交时验证并重新检查导入。备份将一致 DB 快照与引用资产及其他必要 root 配对。 + +**取舍。** 不需运维数据库服务器,但写入会竞争。长时间最终导入事务可能延迟 RFQ;writer channel 不保证优先级或公平性。记录引用外部文件与 secrets 时,只备份 DB 档不足以恢复。 + +**失败/演进触发条件。** 依操作者同意的目标测量排队等待、RFQ 延迟、导入事务时间与恢复时间。负载无法符合目标时,再调整准备阶段、调度或存储。本指南不捏造吞吐量上限或 SLA。 + +**可核查证据。** [`Atomic Import 测试`](../../internal/storage/sqlite/import_store_test.go) 涵盖 revision 重验、回执重放与 rollback;[`备份测试`](../../internal/recovery/backup_files_test.go) 涵盖文件配对。带标签的 [`POC-02 harness`](../../internal/storage/sqlite/poc02_test.go) 是实验,不是生产容量证据。 + +## 3. Public 呈现与 Admin 交互分离 + +**决策。** 由 Go 生成 Public HTML,以局部 React 增强;Admin 使用 React/Refine/Ant Design。 + +**情境。** 访客与爬虫需要在 JavaScript 运行前取得产品信息、搜索链接及基本 RFQ 表单;管理员需要交互列表、表单、预览与工作状态。 + +**替代方案。** 单一整页 SPA 会让核心 Public 行为依赖客户端运行。独立 Node renderer 会改变运行环境的交付模型。纯服务器 Admin 表单可避免客户端状态,但会放弃当前选用的管理框架。 + +**选择理由。** [`Public template`](../../internal/webapp/templates/pages.tmpl) 与 [`发布呈现`](../../internal/publishing/render.go) 产生内容;[`Public roots`](../../web/ui/src/public/main.tsx) 加入 RFQ 选取/列表控制。分离的 Vite 入口内嵌 Admin 与 system 应用,不把 Public 变成 Admin bundle。 + +**取舍。** 两种呈现模型需要一致的多语与行为。共用已发布数据可降低 HTML/JSON/Markdown 差异,却不会自动证明所有呈现路径正确。Go HTML 不是 React SSR。 + +**失败/演进触发条件。** 新交互无法在不重复核心数据行为下局部增强,或实测呈现/维护成本足以支持另一模型;仍须保留无 JavaScript 的核心旅程。 + +**可核查证据。** [`搜索/列表 HTML 测试`](../../internal/webapp/search_test.go)、[`机器格式测试`](../../internal/publishing/machine_test.go) 及 [`多语单元测试`](../../internal/publishing/localized_unit_test.go)。 + +## 4. 领域命令保留各自结果语义 + +**决策。** Refine DataProvider 处理支持的 resource;具业务意义的状态转换走具名 adapter。发布及 RFQ 的成功判定各自依据持久证据。 + +**情境。** 编辑字段、封存产品、发布网站与重试询价,效果不能互换。响应遗失不代表写入失败。 + +**替代方案。** 全部映射成通用 CRUD、乐观 UI 或自动 mutation retry,能简化部分客户端代码,却会掩盖 revision、部分成功及结果不确定性。 + +**选择理由。** [`dataProvider.ts`](../../web/ui/src/admin/dataProvider.ts) 拒绝删除 Product;[`catalogCommands.ts`](../../web/ui/src/admin/catalogCommands.ts) 命名 Archive、Hide、Clone 与 preview。Publish 目前使用带 revision 的 Product PUT:这是语义边界,不保证每个命令都有独立 endpoint。[`api.ts`](../../web/ui/src/admin/api.ts) 在写入结果未知时引导用户核查已保存状态;[`AdminProviders.tsx`](../../web/ui/src/admin/AdminProviders.tsx) 停用 mutation retry 与乐观 mutation mode。 + +持久化也遵循相同原则。[`发布`](../../internal/publishing/protocol.go) 先准备单元再启用,撤销则在 ETag/Range 前拒绝新的准入。[`SubmitRFQ`](../../internal/storage/sqlite/store.go) 在 RFQ 事务中保存 canonical payload hash 与回执。相同 key/内容重放与冲突编辑是不同情况。Product Bulk 允许逐产品部分成功;Excel Import 则整批原子提交。 + +**取舍。** Adapter 与明确结果处理增加代码。存储发布操作可能早于公开启用,用户需要可查看状态。SMTP 结果可能不确定,不能当作 exactly-once delivery。 + +**失败/演进触发条件。** 命令绕过共用 transport、回执不足以厘清中断结果,或测试出现过期数据公开时,应先修复契约与证据,不用自动重试掩盖问题。 + +**可核查证据。** [`Adapter 测试`](../../web/ui/src/admin/adapters.test.ts)、[`正常发布测试`](../../internal/webapp/publication_test.go)、[`POC-03 协议测试`](../../internal/publishing/poc03_test.go)、[`RFQ 回执测试`](../../internal/storage/sqlite/rfq_receipt_test.go)。Renderer feasibility 与 publication correctness 是不同门槛,两者都不等于生产环境就绪。 + +## 5. 分类专属数据与受控呈现 + +**决策。** 规格与 Product 分开建模,重用 Spec Set、保留原始值,并把规范化结果视为衍生数据。分类列表 profile 与数据值分开配置。 + +**情境。** 巨大的扁平产品 schema 会混合不相干的组件属性;把所有原始字符串当成可靠标量,会误解单位、范围、条件与缺值。 + +**替代方案。** 每种属性一个字段会造成 schema 频繁变更;只有自由格式 blob 又难以支持适用性、来源及受控筛选。任意逐产品页面 layout 会把数据维护与呈现设计混在一起。 + +**选择理由。** [`maintenance.go`](../../internal/catalog/maintenance.go) 分别定义 SpecValue 与 NormalizedValue,包含来源 revision、语义版本与状态。[`spec_document_store.go`](../../internal/storage/sqlite/spec_document_store.go) 调整适用性。[`listing.go`](../../internal/cataloglisting/listing.go) 依完整范围的适用性产生 Common 字段,不只看第一列或目前页面。产品身份仍与路径及搜索 folding 分离。 + +**取舍。** 适用性、过期规范化及分类变更需要明确 reconciliation。数据模型与规范化记录不代表通用电气解析器,也不能证明两个器件兼容。结构化配置比任意 template 少一些弹性,但保留可审查的发布边界。 + +**失败/演进触发条件。** 真实分类无法表达必要属性,或用户反复需要未支持计算时,先定义附示例及失败行为的数据/规范化契约,再扩展 layout 或推论能力。 + +**可核查证据。** [`Common/profile 测试`](../../internal/cataloglisting/listing_test.go)、[`产品目录维护测试`](../../internal/catalog/maintenance_test.go)、[`空白制造商身份测试`](../../internal/storage/sqlite/d9_identity_test.go)。 + +## 6. 安装与恢复是生命周期边界 + +**决策。** 区分 fresh、installing、ready、upgrade-required 与 recovery-required。Restore 使用 journal,准备完成后向前完成同一操作。 + +**情境。** 初次没有数据库与既有数据库损坏,需要相反处理。跨 root 恢复 DB、资产与 secrets,不能用单一 filesystem rename 表达。 + +**替代方案。** 任何开启错误都自动初始化,可能覆盖既有站点证据。没有 journal 的 root 复制,无法区分某一步已完成或被中断。回到更早时间点是另一个 restore,不是准备后的隐含 undo。 + +**选择理由。** [`Inspect 与 CompleteInstallation`](../../internal/storage/sqlite/store.go) 分类状态,并一起提交初始 Owner/Ready。[`recovery_ui.go`](../../cmd/prods/recovery_ui.go) 提供独立修复入口。[`restore.go`](../../internal/recovery/restore.go) 记录逐 root hash 与进度,恢复同一操作,完成前验证全部 root。 + +**取舍。** 启动/恢复有更多状态与代码。Prepared 操作可能需要修复后才能回到正常服务。本机 manifest 与只读文件不提供异地主机灾难恢复、防篡改存储或恢复时间保证。 + +**失败/演进触发条件。** 中断步骤无法从持久证据厘清、平台文件操作行为不同,或操作者恢复目标超出实测行为时,应测试实际平台/存储拓扑,再明确修订协议。 + +**可核查证据。** [`安装测试`](../../internal/storage/sqlite/installation_test.go)、[`restore 故障边界测试`](../../internal/recovery/restore_fault_test.go)、[`shutdown 测试`](../../cmd/prods/shutdown_test.go)。模拟故障与交叉编译不能代替真实平台断电、文件系统或 service manager 验收。 + +## 范围与 AI 定位 + +**尚未发布的本地工作,不包含在本次文档提交中:** 核查的工作目录包含 `internal/webapp/brand_import.go` 与 `web/ui/src/admin/brandImportLabels.ts`,实现由人操作的外部 ChatGPT 流程。Prods 生成绑定请求的 prompt/schema;操作者在 Prods 外执行,再粘贴 JSON。验证检查 request/version/source URL 绑定并生成建议差异。明确批准并带入 expected revision 后只保存 Website Working Copy;发布仍是另一项操作。结构验证不会独立证实外部模型的观察。这些代码在本次核查时已处于开发中,不声称已发布或完成外部模型端到端验收,也不是内置推理或 RAG 服务。 + +- **已实现:** 前述产品目录维护、分类/规格数据、公开探索、发布、RFQ、Admin 与生命周期/运维代码;JSON/Markdown/`llms.txt` 是可读输出。 +- **规划或延后:** 更广的内容编排/headless 能力不属目前 V1 交付;本文不承诺日期或实现设计。 +- **探索性:** AI 辅助 RFQ 撷取只有在真实工作流程、可信度、验证及失败边界明确时才值得评估;不是已实现功能。 +- **范围外:** 结账、价格/库存承诺、CRM、任意逐主体 layout 或 JavaScript、外部写入 token、权威跨厂替代料推荐、AI 聊天助理或 RAG 服务。语义相似不代表电气兼容。 + +代码仓库能支持产品边界、实现与运维机制的具体说明。要进一步证明生产系统正在运行,还需要部署专属证据;缺口列于[运维指南](operations.md)。 diff --git a/docs/zh-CN/operations.md b/docs/zh-CN/operations.md new file mode 100644 index 0000000..cf3335a --- /dev/null +++ b/docs/zh-CN/operations.md @@ -0,0 +1,106 @@ +# 运维与生产环境证据 + +[English](../en/operations.md) | [繁體中文](../zh-TW/operations.md) | 简体中文 + +[README](../../README-zh_CN.md) · [架构](architecture.md) · [工程案例研究](engineering-case-study.md) + +## 生产环境状态 + +于 2026-09-17 对照工作目录查看(`VERSION`:`v0.6.8`)。代码仓库包含可运行应用程序、发布封装、运维控制及自动化测试,这些是实现证据。本次文档核查未验证生产 endpoint、其部署 revision、真实客户负载、生产 TLS 配置或异地主机恢复演练。这里的「生产环境」指预期由操作者管理、处理真实产品目录/RFQ 数据的安装,不是已达成的部署里程碑。 + +以下源代码与测试链接可供查看机制。测试存在不代表每个平台都通过;workflow 配置也不等于 CI 运行成功。目前 checkout 包含进行中的变更,本身不是干净的发布成品。 + +## 运行与部署边界 + +发布模型为平台专属 Go 可执行文件、内嵌前端资产、本机 SQLite 及受管理文件。[`main.go`](../../cmd/prods/main.go) 在正常或单次操作前取得 OS 层级数据库 ownership lock。[`service_linux.go`](../../internal/platform/service_linux.go) 产生可选 systemd unit;[`service_windows.go`](../../internal/platform/service_windows.go) 实现 Windows service 集成。Service 定义不代表已在真实主机验收 service manager。 + +对外部署预期由操作者提供 TLS 反向代理及明确 HTTPS Base URL。Prods 检查 request authority,支持配置 trusted proxy 地址。代码仓库没有特定生产 proxy/TLS 拓扑的运行证据。反向代理应转送至应用,不应公开 data、backup、secrets 或历史生成目录。参见 [`proxy_trust.go`](../../internal/webapp/proxy_trust.go)。 + +默认路径相对于可执行文件: + +| 配置 | 默认值/用途 | +| --- | --- | +| Listen | `:8080`;并非只监听 loopback | +| Base URL | `http://127.0.0.1:8080` | +| Config | `prods.ini` | +| Data/DB | `data/`/`data/prods.db` | +| Backups | `backups/` | +| 受管理 data 子目录 | 资产、work、生成输出、secrets、control journals 与 logs | + +优先序为 CLI flag → 环境变量 → 单一 config → 默认值。[`hostconfig/config.go`](../../internal/hostconfig/config.go) 定义 flag 与验证。请使用 README 示例与实际可执行文件的 `--help`;不要从开发者本机配置推测高级路径或凭证。 + +## 如何启动隔离的本机安装 + +使用 Linux amd64 发布可执行文件与新目录,或依 [README](../../README-zh_CN.md) 从源代码构建。以下 Linux shell 示例假设可执行文件在目前目录,且 port 18080 可用: + +```sh +demo_dir="$(mktemp -d)" +cp ./prods-linux-amd64 "$demo_dir/prods" +chmod +x "$demo_dir/prods" +"$demo_dir/prods" --listen 127.0.0.1:18080 --base-url http://127.0.0.1:18080 +``` + +1. 开启 terminal 输出的一次性 Installer URL,建立 Owner 并完成安装。空产品目录是有效状态;sample data 为可选且绑定可执行文件发布版本。 +2. Prods 结束后,重复最后一个命令。在 `http://127.0.0.1:18080/admin` 登入,并于 `/` 查看公开网站。 +3. 检查 `http://127.0.0.1:18080/health/live` 与 `/health/ready`。Readiness 与进程 liveness 必须分开判断。完成后以 `Ctrl+C` 停止。 + +若明确指定的 port 已占用,请换 port 并同步修改 listen/Base URL。启动若报告数据损坏或无法辨识,保留目录并使用 Recovery,不要当作重新安装请求。Installer 与正常启动行为由 [`main_test.go`](../../cmd/prods/main_test.go)、[`Installer 测试`](../../internal/webapp/installer_test.go) 及 [`安装测试`](../../internal/storage/sqlite/installation_test.go) 涵盖。 + +## 如何备份与恢复 + +运行中的站点使用 Admin Backup 或调度器。以下命令列单次操作前,先停止 service/前台进程,并使用相同可执行文件、config 与 data 路径。Ownership lock 防止第二个进程共用 instance。 + +```sh +./prods-linux-amd64 --backup-now +./prods-linux-amd64 --restore-backup +``` + +以选定备份取代 restore placeholder。Restore 会变更持久状态;第二个命令是另一项操作,不是每次备份后都必须运行。默认要求先取得目前站点的已验证 pre-restore backup。`--allow-restore-without-prebackup` 是针对无法备份状态的主机授权例外,不是默认流程。 + +Backup 将一致数据库快照与其引用资产、指定 root/文件及独立 manifest 配对。Manifest 分开记录内容验证与只读应用结果。Secrets 使备份具有敏感性;checksum/只读标志不代表加密、防篡改或主机损失后仍可恢复。外部 secret 需求也必须满足。 + +Restore 准备并验证各 root、持久记录 `prepared`,再依逐 root 证据向前完成同一操作。最终验证后正常重启,检查 readiness、Admin、公开页面与必要资产。Recovery 若报告缺失证据或 root 不符,保留 journal 与来源备份;不要删除 journal 强迫正常启动。参见 [`backup.go`](../../internal/recovery/backup.go)、[`restore.go`](../../internal/recovery/restore.go)、[`restore 故障测试`](../../internal/recovery/restore_fault_test.go) 及 [`backup/restore CLI 测试`](../../cmd/prods/backup_restore_test.go)。 + +## 如何恢复 Owner 访问 + +正常 instance 停止后,以相同配置及既有 active Owner 的地址运行: + +```sh +./prods-linux-amd64 --recover-owner owner@example.com +``` + +命令输出有期限的一次性链接后结束。正常重启 Prods,在到期前开启链接,并保持链接私密。命令要求已完成安装的站点数据库,不会重开 Installer,也不修复无法读取的数据库。证据:[`main.go`](../../cmd/prods/main.go)、[`identity_store.go`](../../internal/storage/sqlite/identity_store.go)、[`用户生命周期测试`](../../internal/webapp/user_lifecycle_test.go)。 + +## 启动、升级、关闭与诊断 + +- **启动:** 持久状态检查区分 fresh、installing、ready、upgrade 与 recovery。未完成安装取得新 claim,成功安装要求重启。 +- **Migration:** 具版本 SQL 与 migration history 随可执行文件交付。升级启动先建立/验证备份并记录进度,再允许正常运行。中断操作必须厘清或进入 Recovery,不会默默接受。 +- **Shutdown:** 关闭与释放组件前先停止接收新工作并排空。HTTP shutdown 有 timeout,可能强制关闭,不保证所有故障下每个请求都完成。[`shutdown_test.go`](../../cmd/prods/shutdown_test.go) 涵盖有界行为。 +- **Maintenance:** 新 Public/RFQ 准入回 503,保留允许的 Admin、Recovery、system assets 与 liveness。这是受控停机,不是高可用性。 +- **诊断:** JSON runtime log 与交互 terminal 呈现状态;Admin 提供工作与 health 查看。[`runtime_log.go`](../../internal/platform/runtime_log.go) 及 [`runtime_health.go`](../../internal/platform/runtime_health.go) 实现本机机制;这里没有外部监控/值班部署证据。 +- **Secrets/集成:** 正常站点从私有文件读取 SMTP 与 Google 凭证。可选 mail/indexing 操作与产品目录服务分开;provider 回执不证明已索引或 exactly-once 邮件投递。参见 [`maildelivery`](../../internal/maildelivery/manager.go) 与 [`searchnotify`](../../internal/searchnotify/searchnotify.go)。 + +## 构建、发布与验证范围 + +[`ci.yml`](../../.github/workflows/ci.yml) 定义前端 typecheck/test、内嵌资产差异检查、Go normal/PoC test、vet、license/sample 检查及 cross-build。[`release.yml`](../../.github/workflows/release.yml) 验证 `v*` tag 并发布文件,不部署运行中的站点。[`build-release.sh`](../../scripts/build-release.sh) 检查 `VERSION`、Go 版本、source revision、干净工作目录与精确 sample 成品,再输出可执行文件、构建信息与 checksum。 + +构建目标为 Linux amd64、Windows amd64 及 macOS amd64/arm64。Windows/macOS 原生 runtime、service、签名及平台专属恢复,需针对同一成品另备证据,不能靠 Linux 测试结案。POC-01 renderer feasibility 与 POC-03 publication protocol correctness 是不同门槛;呈现 smoke test 不能证明发布正确性或生产环境就绪。 + +完整源代码验证命令保留在 [README](../../README-zh_CN.md)。前端构建会重写内嵌文件,release packaging 要求干净 checkout。请在适当 checkout 运行,不要只为验证文件而覆盖其他进行中工作。 + +## 目前运维边界 + +| 代码仓库证据 | 宣称边界 | +| --- | --- | +| Instance lock 与单进程运行 | 不保证多节点协调、failover 或零停机升级。 | +| 序列化 SQLite 写入与 import 测试 | 没有生产容量实测,也不保证 atomic import 期间 RFQ 零等待。 | +| 发布准入与协议测试 | 测试涵盖指定情境;已准入传输可在撤销后完成。 | +| 回执与审计记录 | 是持久应用证据,不是外部服务 exactly-once 保证。 | +| Backup manifest 与 restore journal | 没有经验证的异地主机备份政策、RTO/RPO 或所有文件系统的断电保证。 | +| Session、CSRF、capability 与文件验证 | 是已实现控制,不是安全认证或全面安全保证。 | +| 十种内建语言资源 | 不宣称已经专业母语、法律或行销审校。 | +| 构建/发布 workflow 与 sample 产品目录 | 不从 fixture 或自动化推论客户数、商业成效、生产流量、SLA 或实际部署。 | + +## 生产环境叙事仍需的证据 + +操作者可补充部署版本/source revision、主机/平台、service/proxy 配置、TLS 验证、持久 root 对照、备份保留/异地主机位置、有日期的恢复演练及测量负载/事件记录。凭证与客户数据保持私密。在证据齐备前,描述实现与测试,不使用尚未验证的生产环境成功叙事。 diff --git a/docs/zh-TW/architecture.md b/docs/zh-TW/architecture.md new file mode 100644 index 0000000..f38c659 --- /dev/null +++ b/docs/zh-TW/architecture.md @@ -0,0 +1,99 @@ +# 架構概覽 + +[English](../en/architecture.md) | 繁體中文 | [简体中文](../zh-CN/architecture.md) + +[README](../../README-zh_TW.md) · [工程案例研究](engineering-case-study.md) · [維運](operations.md) + +本文件根據 2026-09-17 檢視的工作目錄原始碼撰寫,當時 `VERSION` 為 `v0.6.8`。內容描述實作,不代表已驗證某個發布成品或正式部署。這組三語工程指南以英文為語意基準,不取代產品需求或歷史決策。 + +## 系統情境 + +Prods 提供企業技術型錄與 RFQ(詢價請求)流程。管理員維護產品資料、分類專屬規格、文件及網站設定;訪客探索已發布產品並提交詢價,型錄找不到時也可提出 Requested Part。RFQ 記錄需求,不代表價格、庫存、電氣相容性或銷售訂單。 + +執行環境採模組化單體:一個 Go 行程、一份 SQLite 資料庫及受管理的檔案系統目錄。背景工作在同一行程執行。部署時可在前方放置 TLS 反向代理;儲存庫並未證實任何特定正式代理正在運作。 + +```mermaid +flowchart TB + V[訪客與爬蟲] --> P[公開 HTML 與讀取格式] + A[管理員] --> U[內嵌 Admin 應用程式] + H[主機操作者] --> S[Installer 與 Recovery] + subgraph G[單一 Prods Go 行程] + P --> D[應用與領域操作] + U --> API[Admin HTTP API] + API --> D + S --> L[啟動與復原生命週期] + D --> W[寫入准入與 SQL 儲存層] + D --> J[行程內發布及其他背景工作] + J --> W + end + W --> DB[(SQLite)] + J --> F[受管理資產與生成檔案] + L --> DB + L --> B[備份 manifest 與還原 journal] +``` + +圖中分組代表責任,不是可獨立部署的服務。入口與組裝位於 [`cmd/prods/main.go`](../../cmd/prods/main.go) 及 [`internal/webapp/server.go`](../../internal/webapp/server.go)。 + +## 元件及邊界理由 + +| 邊界 | 目前實作 | 存在理由 | +| --- | --- | --- | +| 領域與持久化 | `internal/catalog`、`inquiries`、`site`、`localization`;SQL repository 位於 `internal/storage/sqlite` | 規則、持久狀態與交易留在 Go,不依賴瀏覽器框架。 | +| 公開網站 | Go template、生成的產品表示,以及動態搜尋/列表/RFQ handler | 核心內容與基本導覽不需要 JavaScript。 | +| 公開互動增強 | RFQ 選取與列表控制的 React root | 在既有 HTML 上增加互動,不要求整頁 hydration。 | +| Admin | React、headless Refine Core、React Router、TanStack Query、原生 Ant Design v6 | 管理介面可使用應用狀態與表單,不把 Admin 相依套件帶入 Public。 | +| System UI | 獨立 Installer/Recovery 入口與內嵌 system assets | 資料庫損壞或正常 Admin 無法使用時,復原不能依賴同一資料庫/session。 | +| 背景工作 | 行程內的發布、匯入、備份、郵件與搜尋通知管理器 | 以持久紀錄追蹤工作;記憶體中的喚醒訊號不代表完成。 | + +參見 [`web/ui/package.json`](../../web/ui/package.json)、分離的 [`Vite 設定`](../../web/ui/vite.public.config.ts)、[`Public roots`](../../web/ui/src/public/main.tsx) 及 [`system 入口`](../../web/ui/src/system/main.tsx)。Public HTML 由 Go 生成,並非 React server-side rendering。Admin 使用 `@refinedev/core`,未使用 `@refinedev/antd`。 + +## 請求與資料流程 + +Public operational routes 包含 `GET /api/locales`、`POST /api/rfqs` 及 HTML `GET/POST /rfq`,與生成的讀取格式、需認證的 `/admin/api/` 操作分開。這些路由不代表承諾提供具版本的外部 write-token API。 + +**型錄編輯與發布。** Admin 請求帶入 session、CSRF token 與預期 revision。儲存層檢查寫入,並在同一交易記錄狀態、必要稽核及發布意圖。發布引擎準備各種表示,再啟用產品的公開單元。儲存編輯與啟用公開 revision 是不同事件;準備替代版本時,可繼續提供前一個有效單元。Hide/Archive 更新可見性,使新的存取准入無法使用已撤銷產品;已准入的傳輸可以完成。公開准入先於條件式回應及 Range 處理。共用資產仍可透過其他有效引用提供。 + +全站設定與路由變更有獨立的 site epoch 啟用邊界。搜尋等彙總檢視可在產品啟用後收斂,但必須檢查可見性。歷史生成目錄不透過通用檔案服務公開。證據:[`catalog_store.go`](../../internal/storage/sqlite/catalog_store.go)、[`publication_store.go`](../../internal/storage/sqlite/publication_store.go)、[`protocol.go`](../../internal/publishing/protocol.go)、[`發布測試`](../../internal/webapp/publication_test.go)。 + +**公開探索。** 公開回應使用明確的已發布資料模型 [`PublicView`](../../internal/publishing/view.go),不直接序列化 Admin 紀錄。HTML、JSON-LD、JSON 與 Markdown 共用此模型。搜尋由應用程式處理 Unicode folding,並跳脫作為字面值的 LIKE 萬用字元;身分比對是另一套責任。[`cataloglisting`](../../internal/cataloglisting/listing.go) 依整個列表範圍計算適用欄位,不只看目前頁面。語系解析記錄各欄位的實際來源,並跳過已停用的候選語系。關閉多語編輯只隱藏編輯控制;公開語系變更必須經過 Website 發布。證據:[`product.go`](../../internal/catalog/product.go)、[`resolver.go`](../../internal/localization/resolver.go)、[`Website 多語測試`](../../internal/webapp/website_localization_test.go)。 + +**RFQ 提交。** 訪客明確提交表單。高熵 idempotency key 識別這次提交,具版本的 canonical payload hash 識別內容。RFQ、項目與可重播收據一起提交交易。相同 key/內容回傳既有收據;內容不同則衝突。搜尋無結果只在訪客主動選擇後才轉成 Requested Part。SMTP 寄送是另一個經授權且記錄結果的操作。證據:[`rfq.go`](../../internal/inquiries/rfq.go)、[`SubmitRFQ`](../../internal/storage/sqlite/store.go)、[`收據查詢`](../../internal/storage/sqlite/rfq_receipt.go)、[`郵件管理器`](../../internal/maildelivery/manager.go)。 + +## 持久化模型 + +| 資料 | 表示與約束 | +| --- | --- | +| 產品身分 | 穩定不透明 ID;Current/Archived 紀錄狀態與 Published/Hidden 可見性分開。Manufacturer + Part Number 衝突在受控寫入內檢查,不靠 DB unique constraint。空白製造商與具名製造商不同。 | +| 技術規格 | Spec definition、可重用 Spec Set、分類指派及逐產品原始值;正規化值帶有來源/規格版本與狀態。缺值或無法解析的值不會默默變成零。 | +| 發布 | 持久意圖、啟用紀錄、site epoch 與不可變生成單元。生成輸出是衍生資料,不是型錄真實資料的唯一副本。 | +| Website 與語系 | 工作中/已發布設定、官方 Public Copy 定義/預設值及客戶覆寫;內容來源語系與 Admin UI 語系無關。 | +| 維運 | SQLite 保存 session、工作、RFQ 收據、稽核、備份紀錄與 migration history;檔案系統 journal 另保存還原證據。 | +| 檔案 | 以穩定 ID 引用的不可變資產、私有 secrets/設定、暫存工作與備份分屬受管理目錄。檔案先完成耐久放置,再提交引用。 | + +基礎 schema 位於 [`store.go`](../../internal/storage/sqlite/store.go),後續變更位於 [`migrations.go`](../../internal/storage/sqlite/migrations.go)。[`spec_document_store.go`](../../internal/storage/sqlite/spec_document_store.go)、[`asset_store.go`](../../internal/storage/sqlite/asset_store.go) 與 [`D9 身分測試`](../../internal/storage/sqlite/d9_identity_test.go) 呈現資料契約。 + +`beginWrite` 使用 channel 與 context cancellation 序列化已准入寫入。這是單行程協調,不是分散式排程器或延遲保證。Atomic Excel Import 在最終全成全敗交易前先驗證,但最終交易仍可能延遲 RFQ 寫入。Product Bulk 則記錄逐產品結果,允許部分成功。參見 [`import_store.go`](../../internal/storage/sqlite/import_store.go) 與 [`product_bulk_store.go`](../../internal/storage/sqlite/product_bulk_store.go)。 + +## 認證與 Admin API + +正常安裝使用具版本的密碼驗證器與持久、不透明的 server-side session。SQLite 儲存 session token 的摘要,不存 bearer 原值。操作由伺服器端 capability 授權;隱藏按鈕不是授權。共用 Admin transport 傳送 same-origin credentials 與 CSRF header,拒絕過期 session 的回應,並把中斷寫入標示為結果未知。不啟用自動 mutation retry 或樂觀成功。 + +Refine DataProvider 處理支援的 resource 操作;具名 adapter 保留 Hide、Archive、Clone、URL 變更、preview 等領域流程。具名命令不一定有獨立 HTTP verb 或 endpoint:目前 `publish` adapter 使用帶 revision 的 Product PUT。通用 delete 被拒絕,應使用 Archive。證據:[`password.go`](../../internal/identity/password.go)、[`session 測試`](../../internal/storage/sqlite/installation_test.go)、[`api.ts`](../../web/ui/src/admin/api.ts)、[`dataProvider.ts`](../../web/ui/src/admin/dataProvider.ts)、[`catalogCommands.ts`](../../web/ui/src/admin/catalogCommands.ts)、[`AdminProviders.tsx`](../../web/ui/src/admin/AdminProviders.tsx)。 + +## 執行環境、安裝與復原 + +預設路徑以執行檔目錄為基準。設定優先序為 CLI → 環境變數 → 單一 `prods.ini` → 預設值。Node.js/pnpm 用於建置內嵌資產,不是應用主機的執行需求。SQLite driver 為 `modernc.org/sqlite`,發布建置停用 CGO。已有選用 Linux systemd 與 Windows service 程式碼;發布腳本也交叉編譯 macOS 成品,但編譯不等於平台驗收。 + +啟動先分類持久狀態,再進入正常操作。全新資料進入 Installer;未完成安裝以新 claim 繼續。Installer 原子提交 Owner、最低站點設定、系統分類、必要 log 與 Ready marker,然後要求重啟。無法辨識/損壞的資料庫進入 Recovery Required,不能改成重新安裝。Migration 需要備份證據。Restore 先準備各 root 並寫入持久 journal;`prepared` 後只允許同一操作向前完成,逐 root 檢查後再做最終驗證。Maintenance 控制新操作准入,同時保留修復路徑。執行證據與限制見[維運指南](operations.md)。 + +## 限制與演進觸發條件 + +以下是重新評估的條件,不是已核准遷移或 roadmap: + +- 代表性匯入的實測競爭使 RFQ 延遲不可接受時,重新評估資料庫/寫入排程;目前沒有經驗證的正式流量範圍。 +- Profiling 顯示 worker 資源限制無法保護互動操作,或獨立可用性成為明確需求時,重新評估行程邊界。 +- 實際型錄/查詢負載超出目前搜尋投影能力時,再評估搜尋;目前不要求外部搜尋服務。 +- 受支援的升級破壞固定套件相容性,或 adapter 維護成本超過框架效益時,重新評估 Admin bridge。 +- 原生平台或檔案系統測試顯示 locking、rename、sync、recovery 行為不同時,重新評估儲存/部署假設。Instance lock 沒有實作多節點 failover。 + +目前實作包含機器可讀型錄輸出,不包含 LLM、RAG 服務或電氣替代料推薦引擎。V1 排除電商、CRM、任意 JavaScript/template、通用頁面編輯器與對外發行的寫入 token。[案例研究](engineering-case-study.md) 說明取捨,不把邊界變成未來規模承諾。 diff --git a/docs/zh-TW/engineering-case-study.md b/docs/zh-TW/engineering-case-study.md new file mode 100644 index 0000000..c24adeb --- /dev/null +++ b/docs/zh-TW/engineering-case-study.md @@ -0,0 +1,133 @@ +# Prods 工程案例研究 + +[English](../en/engineering-case-study.md) | 繁體中文 | [简体中文](../zh-CN/engineering-case-study.md) + +[README](../../README-zh_TW.md) · [架構](architecture.md) · [維運與證據限制](operations.md) + +## 情境與證據 + +技術型 B2B 型錄必須保留一般名稱/描述/圖片清單無法表達的差異。半導體的電壓範圍、連接器的接點數與模組的介面是不同規格;原始值可能帶有條件或容差。製造商身分、料號、生命週期與 datasheet 有助於辨識產品,但不代表即時庫存或可作為替代料。 + +Prods 將管理員維護的型錄連接到公開探索與 RFQ。相同詢價流程接受型錄產品及訪客輸入的 Requested Part,因此型錄不完整時也不必捏造產品紀錄。實作包含分類/規格模型、公開發布協議、Admin 應用程式及安裝/復原路徑;這些不構成客戶採用或正式部署正在運行的證據。 + +本文於 2026-09-17 對照工作目錄查核(`VERSION`:`v0.6.8`)。以下連結指向公開原始碼與測試。內部需求及歷史 ADR 留在本機;本文不重新公開其內容,也不事後補造 ADR。替代方案是架構比較,不表示每個方案都曾做過原型。演進觸發條件是本指南提出的評估準則。 + +## 真正影響實作的需求 + +| 需求 | 可觀察的實作結果 | +| --- | --- | +| 小型團隊可以自架維運型錄 | 單一執行檔內嵌 UI,執行以 SQLite 為基礎的應用服務。 | +| 買家與爬蟲無 JavaScript 也能讀取核心內容 | Go 產生可用 HTML;React 增強部分控制。 | +| 已儲存資料與公開可見性必須可區分 | 發布意圖與啟用 revision 分開編輯與公開啟用。 | +| 重複 RFQ 提交需要可查核的結果 | Canonical payload hash 與持久收據和 RFQ 共用交易。 | +| 技術資料因分類而異,也可能不完整 | Spec Set、原始值與衍生正規化紀錄分開。 | +| 損壞狀態不能被當成新站點 | 啟動檢查、Installer 與 journaled Recovery 有明確邊界。 | + +本文沒有把流量、營收、客戶數、高可用性或分散式服務需求描述成已確認需求或實測結果。 + +## 1. 單行程與明確模組 + +**決策。** 在一個 Go 執行檔內執行 HTTP、應用服務與有界背景工作。 + +**情境。** 自架型錄已經需要持久資料、資產檔案與復原程序;再要求獨立 API、renderer、queue 與 search 部署,會增加維運相依性。 + +**替代方案。** 元件需要獨立擴展或故障隔離時,可比較獨立服務與外部佇列;目前沒有證據確立這種正式環境需求。 + +**選擇理由。** [`main.go`](../../cmd/prods/main.go) 組裝服務,[`publishing.Repository`](../../internal/publishing/protocol.go) 與 [`backup Snapshotter`](../../internal/recovery/backup.go) 明確表達持久化相依性。SQL 工作紀錄在重啟後保留意圖,不需 queue server。 + +**取捨。** 封裝與本機診斷較簡單,但 CPU、記憶體、行程故障及維護仍共用。套件邊界有助推理與測試,不表示未來拆服務沒有成本,尤其涉及交易與可見性時。 + +**失敗/演進觸發條件。** 代表性量測顯示 worker 無法在資源限制內與互動流量共存,或部署確實需要元件獨立可用。先建立證據,再引入網路邊界。 + +## 2. SQLite 與受控寫入 + +**決策。** 使用 SQLite 作為主要資料庫,以 SQL-first repository 與序列化寫入准入協調操作。 + +**情境。** 型錄讀取、短 Admin/RFQ 寫入及偶爾的大型匯入成本不同。Atomic Import 與持久詢價保存需要明確的資源競爭契約。 + +**替代方案。** 資料庫伺服器可提供不同的並行與維運安排,也增加佈建、存取及備份責任。拆分匯入交易可縮短個別交易,卻會改變全成全敗契約。 + +**選擇理由。** [`beginWrite`](../../internal/storage/sqlite/store.go) 提供單行程共用寫入准入點。[`import_store.go`](../../internal/storage/sqlite/import_store.go) 在提交時驗證並重新檢查匯入。備份將一致 DB 快照與引用資產及其他必要 root 配對。 + +**取捨。** 不需維運資料庫伺服器,但寫入會競爭。長時間最終匯入交易可能延遲 RFQ;writer channel 不保證優先級或公平性。紀錄引用外部檔案與 secrets 時,只備份 DB 檔不足以復原。 + +**失敗/演進觸發條件。** 依操作者同意的目標量測排隊等待、RFQ 延遲、匯入交易時間與還原時間。負載無法符合目標時,再調整準備階段、排程或儲存。本指南不捏造吞吐量上限或 SLA。 + +**可查核證據。** [`Atomic Import 測試`](../../internal/storage/sqlite/import_store_test.go) 涵蓋 revision 重驗、收據重播與 rollback;[`備份測試`](../../internal/recovery/backup_files_test.go) 涵蓋檔案配對。帶標籤的 [`POC-02 harness`](../../internal/storage/sqlite/poc02_test.go) 是實驗,不是正式容量證據。 + +## 3. Public 呈現與 Admin 互動分離 + +**決策。** 由 Go 生成 Public HTML,以局部 React 增強;Admin 使用 React/Refine/Ant Design。 + +**情境。** 訪客與爬蟲需要在 JavaScript 執行前取得產品資訊、搜尋連結及基本 RFQ 表單;管理員需要互動列表、表單、預覽與工作狀態。 + +**替代方案。** 單一整頁 SPA 會讓核心 Public 行為依賴客戶端執行。獨立 Node renderer 會改變執行環境的交付模型。純伺服器 Admin 表單可避免客戶端狀態,但會放棄目前選用的管理框架。 + +**選擇理由。** [`Public template`](../../internal/webapp/templates/pages.tmpl) 與 [`發布呈現`](../../internal/publishing/render.go) 產生內容;[`Public roots`](../../web/ui/src/public/main.tsx) 加入 RFQ 選取/列表控制。分離的 Vite 入口內嵌 Admin 與 system 應用,不把 Public 變成 Admin bundle。 + +**取捨。** 兩種呈現模型需要一致的多語與行為。共用已發布資料可降低 HTML/JSON/Markdown 差異,卻不會自動證明所有呈現路徑正確。Go HTML 不是 React SSR。 + +**失敗/演進觸發條件。** 新互動無法在不重複核心資料行為下局部增強,或實測呈現/維護成本足以支持另一模型;仍須保留無 JavaScript 的核心旅程。 + +**可查核證據。** [`搜尋/列表 HTML 測試`](../../internal/webapp/search_test.go)、[`機器格式測試`](../../internal/publishing/machine_test.go) 及 [`多語單元測試`](../../internal/publishing/localized_unit_test.go)。 + +## 4. 領域命令保留各自結果語意 + +**決策。** Refine DataProvider 處理支援的 resource;具業務意義的狀態轉換走具名 adapter。發布及 RFQ 的成功判定各自依據持久證據。 + +**情境。** 編輯欄位、封存產品、發布網站與重試詢價,效果不能互換。回應遺失不代表寫入失敗。 + +**替代方案。** 全部映射成通用 CRUD、樂觀 UI 或自動 mutation retry,能簡化部分客戶端程式碼,卻會掩蓋 revision、部分成功及結果不確定性。 + +**選擇理由。** [`dataProvider.ts`](../../web/ui/src/admin/dataProvider.ts) 拒絕刪除 Product;[`catalogCommands.ts`](../../web/ui/src/admin/catalogCommands.ts) 命名 Archive、Hide、Clone 與 preview。Publish 目前使用帶 revision 的 Product PUT:這是語意邊界,不保證每個命令都有獨立 endpoint。[`api.ts`](../../web/ui/src/admin/api.ts) 在寫入結果未知時引導使用者查核已保存狀態;[`AdminProviders.tsx`](../../web/ui/src/admin/AdminProviders.tsx) 停用 mutation retry 與樂觀 mutation mode。 + +持久化也遵循相同原則。[`發布`](../../internal/publishing/protocol.go) 先準備單元再啟用,撤銷則在 ETag/Range 前拒絕新的准入。[`SubmitRFQ`](../../internal/storage/sqlite/store.go) 在 RFQ 交易中保存 canonical payload hash 與收據。相同 key/內容重播與衝突編輯是不同情況。Product Bulk 允許逐產品部分成功;Excel Import 則整批原子提交。 + +**取捨。** Adapter 與明確結果處理增加程式碼。儲存發布操作可能早於公開啟用,使用者需要可查閱狀態。SMTP 結果可能不確定,不能當作 exactly-once delivery。 + +**失敗/演進觸發條件。** 命令繞過共用 transport、收據不足以釐清中斷結果,或測試出現過期資料公開時,應先修復契約與證據,不用自動重試掩蓋問題。 + +**可查核證據。** [`Adapter 測試`](../../web/ui/src/admin/adapters.test.ts)、[`正常發布測試`](../../internal/webapp/publication_test.go)、[`POC-03 協議測試`](../../internal/publishing/poc03_test.go)、[`RFQ 收據測試`](../../internal/storage/sqlite/rfq_receipt_test.go)。Renderer feasibility 與 publication correctness 是不同門檻,兩者都不等於正式環境就緒。 + +## 5. 分類專屬資料與受控呈現 + +**決策。** 規格與 Product 分開建模,重用 Spec Set、保留原始值,並把正規化結果視為衍生資料。分類列表 profile 與資料值分開設定。 + +**情境。** 巨大的扁平產品 schema 會混合不相干的元件屬性;把所有原始字串當成可靠純量,會誤解單位、範圍、條件與缺值。 + +**替代方案。** 每種屬性一個欄位會造成 schema 頻繁變更;只有自由格式 blob 又難以支援適用性、來源及受控篩選。任意逐產品頁面 layout 會把資料維護與呈現設計混在一起。 + +**選擇理由。** [`maintenance.go`](../../internal/catalog/maintenance.go) 分別定義 SpecValue 與 NormalizedValue,包含來源 revision、語意版本與狀態。[`spec_document_store.go`](../../internal/storage/sqlite/spec_document_store.go) 調整適用性。[`listing.go`](../../internal/cataloglisting/listing.go) 依完整範圍的適用性產生 Common 欄位,不只看第一列或目前頁面。產品身分仍與路徑及搜尋 folding 分離。 + +**取捨。** 適用性、過期正規化及分類變更需要明確 reconciliation。資料模型與正規化紀錄不代表通用電氣解析器,也不能證明兩顆料相容。結構化設定比任意 template 少一些彈性,但保留可審查的發布邊界。 + +**失敗/演進觸發條件。** 真實分類無法表達必要屬性,或使用者反覆需要未支援計算時,先定義附範例及失敗行為的資料/正規化契約,再擴展 layout 或推論能力。 + +**可查核證據。** [`Common/profile 測試`](../../internal/cataloglisting/listing_test.go)、[`型錄維護測試`](../../internal/catalog/maintenance_test.go)、[`空白製造商身分測試`](../../internal/storage/sqlite/d9_identity_test.go)。 + +## 6. 安裝與復原是生命週期邊界 + +**決策。** 區分 fresh、installing、ready、upgrade-required 與 recovery-required。Restore 使用 journal,準備完成後向前完成同一操作。 + +**情境。** 初次沒有資料庫與既有資料庫損壞,需要相反處理。跨 root 還原 DB、資產與 secrets,不能用單一 filesystem rename 表達。 + +**替代方案。** 任何開啟錯誤都自動初始化,可能覆蓋既有站點證據。沒有 journal 的 root 複製,無法區分某一步已完成或被中斷。回到更早時間點是另一個 restore,不是準備後的隱含 undo。 + +**選擇理由。** [`Inspect 與 CompleteInstallation`](../../internal/storage/sqlite/store.go) 分類狀態,並一起提交初始 Owner/Ready。[`recovery_ui.go`](../../cmd/prods/recovery_ui.go) 提供獨立修復入口。[`restore.go`](../../internal/recovery/restore.go) 記錄逐 root hash 與進度,恢復同一操作,完成前驗證全部 root。 + +**取捨。** 啟動/復原有更多狀態與程式碼。Prepared 操作可能需要修復後才能回到正常服務。本機 manifest 與唯讀檔案不提供異地主機災難復原、防竄改儲存或復原時間保證。 + +**失敗/演進觸發條件。** 中斷步驟無法從持久證據釐清、平台檔案操作行為不同,或操作者復原目標超出實測行為時,應測試實際平台/儲存拓撲,再明確修訂協議。 + +**可查核證據。** [`安裝測試`](../../internal/storage/sqlite/installation_test.go)、[`restore 故障邊界測試`](../../internal/recovery/restore_fault_test.go)、[`shutdown 測試`](../../cmd/prods/shutdown_test.go)。模擬故障與交叉編譯不能代替原生斷電、檔案系統或 service manager 驗收。 + +## 範圍與 AI 定位 + +**尚未發布的本機工作,不包含於本次文件提交:** 查核的工作目錄包含 `internal/webapp/brand_import.go` 與 `web/ui/src/admin/brandImportLabels.ts`,實作由人操作的外部 ChatGPT 流程。Prods 產生綁定請求的 prompt/schema;操作者在 Prods 外執行,再貼回 JSON。驗證檢查 request/version/source URL 綁定並產生建議差異。明確核准且帶入 expected revision 後只儲存 Website Working Copy;發布仍是另一操作。結構驗證不會獨立證實外部模型的觀察。這些程式碼在本次查核時已處於開發中,不宣稱已發布或完成外部模型端到端驗收,也不是內建推論或 RAG 服務。 + +- **已實作:** 前述型錄維護、分類/規格資料、公開探索、發布、RFQ、Admin 與生命週期/維運程式碼;JSON/Markdown/`llms.txt` 是可讀輸出。 +- **規劃或延後:** 更廣的內容編排/headless 能力不屬目前 V1 交付;本文不承諾日期或實作設計。 +- **探索性:** AI 輔助 RFQ 擷取只有在真實工作流程、可信度、驗證及失敗邊界明確時才值得評估;不是已實作功能。 +- **範圍外:** 結帳、價格/庫存承諾、CRM、任意逐主體 layout 或 JavaScript、外部寫入 token、權威跨廠替代料推薦、AI 聊天助理或 RAG 服務。語意相似不代表電氣相容。 + +儲存庫能支持產品邊界、實作與維運機制的具體說明。要進一步證明正式系統正在運行,還需要部署專屬證據;缺口列於[維運指南](operations.md)。 diff --git a/docs/zh-TW/operations.md b/docs/zh-TW/operations.md new file mode 100644 index 0000000..14cd08a --- /dev/null +++ b/docs/zh-TW/operations.md @@ -0,0 +1,106 @@ +# 維運與正式環境證據 + +[English](../en/operations.md) | 繁體中文 | [简体中文](../zh-CN/operations.md) + +[README](../../README-zh_TW.md) · [架構](architecture.md) · [工程案例研究](engineering-case-study.md) + +## 正式環境狀態 + +於 2026-09-17 對照工作目錄檢視(`VERSION`:`v0.6.8`)。儲存庫包含可執行應用程式、發布封裝、維運控制及自動化測試,這些是實作證據。本次文件查核未驗證正式 endpoint、其部署 revision、真實客戶負載、正式 TLS 設定或異地主機還原演練。這裡的「正式環境」指預期由操作者管理、處理真實型錄/RFQ 資料的安裝,不是已達成的部署里程碑。 + +以下原始碼與測試連結可供檢視機制。測試存在不代表每個平台都通過;workflow 設定也不等於 CI 執行成功。目前 checkout 包含進行中的變更,本身不是乾淨的發布成品。 + +## 執行與部署邊界 + +發布模型為平台專屬 Go 執行檔、內嵌前端資產、本機 SQLite 及受管理檔案。[`main.go`](../../cmd/prods/main.go) 在正常或單次操作前取得 OS 層級資料庫 ownership lock。[`service_linux.go`](../../internal/platform/service_linux.go) 產生選用 systemd unit;[`service_windows.go`](../../internal/platform/service_windows.go) 實作 Windows service 整合。Service 定義不代表已在真實主機驗收 service manager。 + +對外部署預期由操作者提供 TLS 反向代理及明確 HTTPS Base URL。Prods 檢查 request authority,支援設定 trusted proxy 位址。儲存庫沒有特定正式 proxy/TLS 拓撲的執行證據。反向代理應轉送至應用,不應公開 data、backup、secrets 或歷史生成目錄。參見 [`proxy_trust.go`](../../internal/webapp/proxy_trust.go)。 + +預設路徑相對於執行檔: + +| 設定 | 預設值/用途 | +| --- | --- | +| Listen | `:8080`;並非只監聽 loopback | +| Base URL | `http://127.0.0.1:8080` | +| Config | `prods.ini` | +| Data/DB | `data/`/`data/prods.db` | +| Backups | `backups/` | +| 受管理 data 子目錄 | 資產、work、生成輸出、secrets、control journals 與 logs | + +優先序為 CLI flag → 環境變數 → 單一 config → 預設值。[`hostconfig/config.go`](../../internal/hostconfig/config.go) 定義 flag 與驗證。請使用 README 範例與實際執行檔的 `--help`;不要從開發者本機設定推測進階路徑或憑證。 + +## 如何啟動隔離的本機安裝 + +使用 Linux amd64 發布執行檔與新目錄,或依 [README](../../README-zh_TW.md) 從原始碼建置。以下 Linux shell 範例假設執行檔在目前目錄,且 port 18080 可用: + +```sh +demo_dir="$(mktemp -d)" +cp ./prods-linux-amd64 "$demo_dir/prods" +chmod +x "$demo_dir/prods" +"$demo_dir/prods" --listen 127.0.0.1:18080 --base-url http://127.0.0.1:18080 +``` + +1. 開啟 terminal 印出的一次性 Installer URL,建立 Owner 並完成安裝。空型錄是有效狀態;sample data 為選用且綁定執行檔發布版本。 +2. Prods 結束後,重複最後一個命令。在 `http://127.0.0.1:18080/admin` 登入,並於 `/` 檢視公開網站。 +3. 檢查 `http://127.0.0.1:18080/health/live` 與 `/health/ready`。Readiness 與行程 liveness 必須分開判斷。完成後以 `Ctrl+C` 停止。 + +若明確指定的 port 已占用,請換 port 並同步修改 listen/Base URL。啟動若回報資料損壞或無法辨識,保留目錄並使用 Recovery,不要當作重新安裝請求。Installer 與正常啟動行為由 [`main_test.go`](../../cmd/prods/main_test.go)、[`Installer 測試`](../../internal/webapp/installer_test.go) 及 [`安裝測試`](../../internal/storage/sqlite/installation_test.go) 涵蓋。 + +## 如何備份與還原 + +運行中的站點使用 Admin Backup 或排程器。以下命令列單次操作前,先停止 service/前景行程,並使用相同執行檔、config 與 data 路徑。Ownership lock 防止第二個行程共用 instance。 + +```sh +./prods-linux-amd64 --backup-now +./prods-linux-amd64 --restore-backup +``` + +以選定備份取代 restore placeholder。Restore 會變更持久狀態;第二個命令是另一項操作,不是每次備份後都必須執行。預設要求先取得目前站點的已驗證 pre-restore backup。`--allow-restore-without-prebackup` 是針對無法備份狀態的主機授權例外,不是預設流程。 + +Backup 將一致資料庫快照與其引用資產、指定 root/檔案及獨立 manifest 配對。Manifest 分開記錄內容驗證與唯讀套用結果。Secrets 使備份具有敏感性;checksum/唯讀旗標不代表加密、防竄改或主機損失後仍可復原。外部 secret 需求也必須滿足。 + +Restore 準備並驗證各 root、持久記錄 `prepared`,再依逐 root 證據向前完成同一操作。最終驗證後正常重啟,檢查 readiness、Admin、公開頁面與必要資產。Recovery 若回報缺失證據或 root 不符,保留 journal 與來源備份;不要刪除 journal 強迫正常啟動。參見 [`backup.go`](../../internal/recovery/backup.go)、[`restore.go`](../../internal/recovery/restore.go)、[`restore 故障測試`](../../internal/recovery/restore_fault_test.go) 及 [`backup/restore CLI 測試`](../../cmd/prods/backup_restore_test.go)。 + +## 如何復原 Owner 存取 + +正常 instance 停止後,以相同設定及既有 active Owner 的位址執行: + +```sh +./prods-linux-amd64 --recover-owner owner@example.com +``` + +命令印出有期限的一次性連結後結束。正常重啟 Prods,在到期前開啟連結,並保持連結私密。命令要求已完成安裝的站點資料庫,不會重開 Installer,也不修復無法讀取的資料庫。證據:[`main.go`](../../cmd/prods/main.go)、[`identity_store.go`](../../internal/storage/sqlite/identity_store.go)、[`使用者生命週期測試`](../../internal/webapp/user_lifecycle_test.go)。 + +## 啟動、升級、關閉與診斷 + +- **啟動:** 持久狀態檢查區分 fresh、installing、ready、upgrade 與 recovery。未完成安裝取得新 claim,成功安裝要求重啟。 +- **Migration:** 具版本 SQL 與 migration history 隨執行檔交付。升級啟動先建立/驗證備份並記錄進度,再允許正常執行。中斷操作必須釐清或進入 Recovery,不會默默接受。 +- **Shutdown:** 關閉與釋放元件前先停止接收新工作並排空。HTTP shutdown 有 timeout,可能強制關閉,不保證所有故障下每個請求都完成。[`shutdown_test.go`](../../cmd/prods/shutdown_test.go) 涵蓋有界行為。 +- **Maintenance:** 新 Public/RFQ 准入回 503,保留允許的 Admin、Recovery、system assets 與 liveness。這是受控停機,不是高可用性。 +- **診斷:** JSON runtime log 與互動 terminal 呈現狀態;Admin 提供工作與 health 檢視。[`runtime_log.go`](../../internal/platform/runtime_log.go) 及 [`runtime_health.go`](../../internal/platform/runtime_health.go) 實作本機機制;這裡沒有外部監控/值班部署證據。 +- **Secrets/整合:** 正常站點從私有檔案讀取 SMTP 與 Google 憑證。選用 mail/indexing 操作與型錄服務分開;provider 收據不證明已索引或 exactly-once 郵件投遞。參見 [`maildelivery`](../../internal/maildelivery/manager.go) 與 [`searchnotify`](../../internal/searchnotify/searchnotify.go)。 + +## 建置、發布與驗證範圍 + +[`ci.yml`](../../.github/workflows/ci.yml) 定義前端 typecheck/test、內嵌資產差異檢查、Go normal/PoC test、vet、license/sample 檢查及 cross-build。[`release.yml`](../../.github/workflows/release.yml) 驗證 `v*` tag 並發布檔案,不部署運行中的站點。[`build-release.sh`](../../scripts/build-release.sh) 檢查 `VERSION`、Go 版本、source revision、乾淨工作目錄與精確 sample 成品,再輸出執行檔、建置資訊與 checksum。 + +建置目標為 Linux amd64、Windows amd64 及 macOS amd64/arm64。Windows/macOS 原生 runtime、service、簽章及平台專屬復原,需針對同一成品另備證據,不能靠 Linux 測試結案。POC-01 renderer feasibility 與 POC-03 publication protocol correctness 是不同門檻;呈現 smoke test 不能證明發布正確性或正式環境就緒。 + +完整原始碼驗證命令保留在 [README](../../README-zh_TW.md)。前端建置會重寫內嵌檔案,release packaging 要求乾淨 checkout。請在適當 checkout 執行,不要只為驗證文件而覆寫其他進行中工作。 + +## 目前維運邊界 + +| 儲存庫證據 | 宣稱邊界 | +| --- | --- | +| Instance lock 與單行程執行 | 不保證多節點協調、failover 或零停機升級。 | +| 序列化 SQLite 寫入與 import 測試 | 沒有正式容量實測,也不保證 atomic import 期間 RFQ 零等待。 | +| 發布准入與協議測試 | 測試涵蓋指定情境;已准入傳輸可在撤銷後完成。 | +| 收據與稽核紀錄 | 是持久應用證據,不是外部服務 exactly-once 保證。 | +| Backup manifest 與 restore journal | 沒有經驗證的異地主機備份政策、RTO/RPO 或所有檔案系統的斷電保證。 | +| Session、CSRF、capability 與檔案驗證 | 是已實作控制,不是安全認證或全面安全保證。 | +| 十種內建語系資源 | 不宣稱已經專業母語、法律或行銷審校。 | +| 建置/發布 workflow 與 sample 型錄 | 不從 fixture 或自動化推論客戶數、商業成效、正式流量、SLA 或實際部署。 | + +## 正式環境敘事仍需的證據 + +操作者可補充部署版本/source revision、主機/平台、service/proxy 設定、TLS 驗證、持久 root 對照、備份保留/異地主機位置、有日期的還原演練及量測負載/事件紀錄。憑證與客戶資料保持私密。在證據齊備前,描述實作與測試,不使用尚未驗證的正式環境成功敘事。 diff --git a/internal/brandcapture/capture.go b/internal/brandcapture/capture.go deleted file mode 100644 index c598436..0000000 --- a/internal/brandcapture/capture.go +++ /dev/null @@ -1,328 +0,0 @@ -package brandcapture - -import ( - "bytes" - "errors" - "io" - "net/url" - "regexp" - "strings" - - nethtml "golang.org/x/net/html" -) - -const ( - MaxHTMLBytes int64 = 1 << 20 - MaxStylesheetBytes int64 = 256 << 10 - MaxStylesheets = 4 - maxCandidateValues = 20 -) - -var ( - hexColorPattern = regexp.MustCompile(`(?i)#[0-9a-f]{3}(?:[0-9a-f]{3})?\b`) - fontPattern = regexp.MustCompile(`(?i)font-family\s*:\s*([^;}]+)`) -) - -type Navigation struct { - Label string `json:"label"` - URL string `json:"url"` -} - -// Candidate contains suggestions only. It intentionally has no script, -// canonical, SEO, raw HTML, or raw CSS field. -type Candidate struct { - SourceURL string `json:"source_url"` - Organization string `json:"organization,omitempty"` - Title string `json:"title,omitempty"` - LogoURLs []string `json:"logo_urls,omitempty"` - Colors []string `json:"colors,omitempty"` - Fonts []string `json:"fonts,omitempty"` - Navigation []Navigation `json:"navigation,omitempty"` - FooterText string `json:"footer_text,omitempty"` - StylesheetURLs []string `json:"stylesheet_urls,omitempty"` - Warnings []string `json:"warnings,omitempty"` - RequiresBrowser bool `json:"requires_browser"` - UnavailableDynamicParts []string `json:"unavailable_dynamic_parts,omitempty"` - ManualCorrections []string `json:"manual_corrections,omitempty"` - NeedsRightsConfirmation bool `json:"needs_rights_confirmation"` - WorkingCopyOnly bool `json:"working_copy_only"` -} - -func Capture(sourceURL string, reader io.Reader) (Candidate, error) { - base, err := ParsePublicURL(sourceURL) - if err != nil { - return Candidate{}, err - } - body, err := io.ReadAll(io.LimitReader(reader, MaxHTMLBytes+1)) - if err != nil { - return Candidate{}, err - } - if int64(len(body)) > MaxHTMLBytes { - return Candidate{}, errors.New("HTML exceeds capture limit") - } - document, err := nethtml.Parse(bytes.NewReader(body)) - if err != nil { - return Candidate{}, errors.New("HTML could not be parsed") - } - candidate := Candidate{ - SourceURL: sourceURL, NeedsRightsConfirmation: true, WorkingCopyOnly: true, - } - var styles []string - var scriptCount, navigationAnchors int - var walk func(*nethtml.Node, bool, bool) - walk = func(node *nethtml.Node, inNavigation, inFooter bool) { - if node.Type == nethtml.ElementNode { - tag := strings.ToLower(node.Data) - attrs := nodeAttributes(node) - role := strings.ToLower(attrs["role"]) - classID := strings.ToLower(attrs["class"] + " " + attrs["id"]) - if tag == "nav" || role == "navigation" || (tag == "header" && strings.Contains(classID, "nav")) { - inNavigation = true - } - if tag == "footer" || role == "contentinfo" { - inFooter = true - } - switch tag { - case "title": - candidate.Title = bounded(firstNonEmpty(candidate.Title, nodeText(node)), 200) - case "meta": - key := strings.ToLower(firstNonEmpty(attrs["property"], attrs["name"])) - value := bounded(strings.TrimSpace(attrs["content"]), 200) - switch key { - case "og:site_name", "application-name": - candidate.Organization = firstNonEmpty(candidate.Organization, value) - case "theme-color": - candidate.Colors = appendColor(candidate.Colors, value) - } - case "style": - styles = append(styles, rawNodeText(node)) - case "script": - scriptCount++ - case "link": - rel := strings.ToLower(attrs["rel"]) - if strings.Contains(rel, "stylesheet") { - candidate.StylesheetURLs = appendUnique(candidate.StylesheetURLs, resolveURL(base, attrs["href"]), MaxStylesheets) - } else if strings.Contains(rel, "icon") { - candidate.LogoURLs = appendUnique(candidate.LogoURLs, resolveURL(base, attrs["href"]), maxCandidateValues) - } - case "img": - meaning := strings.ToLower(attrs["alt"] + " " + attrs["class"] + " " + attrs["id"]) - if strings.Contains(meaning, "logo") || strings.Contains(meaning, "brand") { - candidate.LogoURLs = appendUnique(candidate.LogoURLs, - resolveURL(base, firstNonEmpty(attrs["src"], attrs["data-src"])), maxCandidateValues) - } - case "a": - if inNavigation && len(candidate.Navigation) < maxCandidateValues { - label := bounded(nodeText(node), 120) - href := resolveURL(base, attrs["href"]) - if label != "" && href != "" && !containsNavigation(candidate.Navigation, label, href) { - candidate.Navigation = append(candidate.Navigation, Navigation{Label: label, URL: href}) - navigationAnchors++ - } - } - } - if inline := attrs["style"]; inline != "" { - styles = append(styles, inline) - } - if tag == "script" || tag == "style" || tag == "template" || tag == "noscript" { - return - } - } - if inFooter && node.Type == nethtml.TextNode && len(candidate.FooterText) < 500 { - candidate.FooterText += " " + strings.TrimSpace(node.Data) - } - for child := node.FirstChild; child != nil; child = child.NextSibling { - walk(child, inNavigation, inFooter) - } - } - walk(document, false, false) - candidate.FooterText = bounded(strings.Join(strings.Fields(candidate.FooterText), " "), 500) - if candidate.Organization == "" { - candidate.Organization = bounded(organizationFromTitle(candidate.Title), 200) - } - for _, style := range styles { - ApplyStyles(&candidate, style) - } - if scriptCount > 0 && navigationAnchors == 0 { - candidate.RequiresBrowser = true - candidate.UnavailableDynamicParts = appendUnique(candidate.UnavailableDynamicParts, "navigation", maxCandidateValues) - } - Finalize(&candidate) - return candidate, nil -} - -func ApplyStyles(candidate *Candidate, css string) { - if candidate == nil { - return - } - for _, color := range hexColorPattern.FindAllString(css, -1) { - candidate.Colors = appendColor(candidate.Colors, color) - } - for _, match := range fontPattern.FindAllStringSubmatch(css, -1) { - font := strings.TrimSpace(match[1]) - if len(font) <= 160 && validFontCandidate(font) { - candidate.Fonts = appendUnique(candidate.Fonts, font, maxCandidateValues) - } - } -} - -func Finalize(candidate *Candidate) { - if candidate == nil { - return - } - candidate.ManualCorrections = candidate.ManualCorrections[:0] - for _, item := range []struct { - missing bool - name string - }{ - {candidate.Organization == "", "organization"}, - {len(candidate.LogoURLs) == 0, "logo"}, - {len(candidate.Colors) == 0, "colors"}, - {len(candidate.Fonts) == 0, "fonts"}, - {len(candidate.Navigation) == 0, "navigation"}, - } { - if item.missing { - candidate.ManualCorrections = append(candidate.ManualCorrections, item.name) - } - } -} - -func nodeAttributes(node *nethtml.Node) map[string]string { - result := make(map[string]string, len(node.Attr)) - for _, attribute := range node.Attr { - result[strings.ToLower(attribute.Key)] = attribute.Val - } - return result -} - -func nodeText(node *nethtml.Node) string { - var parts []string - var walk func(*nethtml.Node) - walk = func(current *nethtml.Node) { - if current.Type == nethtml.ElementNode { - switch strings.ToLower(current.Data) { - case "script", "style", "template", "noscript": - return - } - } - if current.Type == nethtml.TextNode { - parts = append(parts, current.Data) - } - for child := current.FirstChild; child != nil; child = child.NextSibling { - walk(child) - } - } - walk(node) - return strings.Join(strings.Fields(strings.Join(parts, " ")), " ") -} - -func rawNodeText(node *nethtml.Node) string { - var value strings.Builder - var walk func(*nethtml.Node) - walk = func(current *nethtml.Node) { - if current.Type == nethtml.TextNode { - value.WriteString(current.Data) - } - for child := current.FirstChild; child != nil; child = child.NextSibling { - walk(child) - } - } - walk(node) - return value.String() -} - -func resolveURL(base *url.URL, raw string) string { - reference, err := url.Parse(strings.TrimSpace(raw)) - if err != nil || raw == "" { - return "" - } - resolved := base.ResolveReference(reference) - validated, err := ParsePublicURL(resolved.String()) - if err != nil { - return "" - } - return validated.String() -} - -func firstNonEmpty(values ...string) string { - for _, value := range values { - if strings.TrimSpace(value) != "" { - return strings.TrimSpace(value) - } - } - return "" -} - -func appendUnique(values []string, candidate string, limit int) []string { - if candidate == "" || len(values) >= limit { - return values - } - for _, value := range values { - if strings.EqualFold(value, candidate) { - return values - } - } - return append(values, candidate) -} - -func appendColor(values []string, candidate string) []string { - candidate = normalizeHexColor(candidate) - if candidate == "" { - return values - } - return appendUnique(values, candidate, maxCandidateValues) -} - -func normalizeHexColor(value string) string { - value = strings.ToLower(strings.TrimSpace(value)) - if len(value) == 4 && value[0] == '#' { - return "#" + strings.Repeat(value[1:2], 2) + strings.Repeat(value[2:3], 2) + strings.Repeat(value[3:4], 2) - } - if len(value) == 7 && hexColorPattern.MatchString(value) { - return value - } - return "" -} - -func validFontCandidate(value string) bool { - for _, character := range value { - if !(character >= 'a' && character <= 'z') && !(character >= 'A' && character <= 'Z') && - !(character >= '0' && character <= '9') && !strings.ContainsRune(" ,.'\"_-", character) { - return false - } - } - return value != "" -} - -func organizationFromTitle(title string) string { - for _, separator := range []string{" | ", " – ", " — ", " - "} { - if before, _, ok := strings.Cut(title, separator); ok { - return strings.TrimSpace(before) - } - } - return strings.TrimSpace(title) -} - -func containsNavigation(values []Navigation, label, target string) bool { - for _, value := range values { - if strings.EqualFold(value.Label, label) && value.URL == target { - return true - } - } - return false -} - -func bounded(value string, maximum int) string { - value = strings.TrimSpace(value) - if len(value) <= maximum { - return value - } - for maximum > 0 && !isUTF8RuneStart(value[maximum]) { - maximum-- - } - return value[:maximum] -} - -func isUTF8RuneStart(value byte) bool { - return value&0xc0 != 0x80 -} diff --git a/internal/brandcapture/capture_test.go b/internal/brandcapture/capture_test.go deleted file mode 100644 index 9c2e3e3..0000000 --- a/internal/brandcapture/capture_test.go +++ /dev/null @@ -1,95 +0,0 @@ -package brandcapture - -import ( - "encoding/json" - "strings" - "testing" - "unicode/utf8" -) - -func TestCaptureExtractsBoundedSemanticCandidateWithoutSourceIdentity(t *testing.T) { - markup := ` - Acme Components | Electronics - - - - - - - -
-
Copyright Acme
- ` - candidate, err := Capture("https://www.acme.example/about", strings.NewReader(markup)) - if err != nil { - t.Fatal(err) - } - if candidate.Organization != "Acme Components" || candidate.Title != "Acme Components | Electronics" { - t.Fatalf("unexpected identity candidate: %+v", candidate) - } - if len(candidate.Navigation) != 2 || candidate.Navigation[0].URL != "https://www.acme.example/products" { - t.Fatalf("unexpected navigation: %+v", candidate.Navigation) - } - if len(candidate.StylesheetURLs) != 1 || candidate.StylesheetURLs[0] != "https://www.acme.example/brand.css" { - t.Fatalf("unexpected stylesheets: %+v", candidate.StylesheetURLs) - } - if len(candidate.LogoURLs) != 1 || candidate.LogoURLs[0] != "https://www.acme.example/logo.svg" { - t.Fatalf("unexpected logo: %+v", candidate.LogoURLs) - } - if candidate.Colors[0] != "#1a2b3c" || candidate.Colors[1] != "#aabbcc" || candidate.Fonts[0] != `"Inter", sans-serif` { - t.Fatalf("unexpected theme: colors=%v fonts=%v", candidate.Colors, candidate.Fonts) - } - if !candidate.NeedsRightsConfirmation || !candidate.WorkingCopyOnly { - t.Fatalf("candidate safety flags missing: %+v", candidate) - } - encoded, err := json.Marshal(candidate) - if err != nil { - t.Fatal(err) - } - for _, forbidden := range []string{"canonical", "analytics.js", "例子企業 - 產品