From 3f1b27b4e246cdb74319625e71f39f002dff4203 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Mon, 28 Sep 2026 17:50:48 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5=20erpgo=20=E5=BA=97?= =?UTF-8?q?=E9=93=BA=E4=B8=8E=E5=95=86=E5=93=81=E6=9F=A5=E8=AF=A2=E5=B9=B6?= =?UTF-8?q?=E5=8E=9F=E5=AD=90=E5=90=8C=E6=AD=A5=20(#20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.go | 38 +-- app_live_test.go | 74 +++++ app_sync_test.go | 165 ++++++++++ config.example.yaml | 6 + docs/00-project-profile.md | 28 +- docs/01-workflow.md | 10 +- docs/02-architecture-and-code-map.md | 35 ++- docs/03-business-rules-and-glossary.md | 16 +- docs/04-local-development-and-verification.md | 41 ++- docs/05-common-changes.md | 10 +- docs/06-troubleshooting.md | 25 +- frontend/src/views/ProductListView.vue | 39 ++- frontend/src/views/SettingsView.vue | 20 +- internal/config/config.go | 40 ++- internal/config/config_test.go | 38 +++ internal/erpgo/client.go | 285 ++++++++++++++++++ internal/erpgo/client_test.go | 218 ++++++++++++++ internal/logx/logx.go | 1 + internal/logx/logx_test.go | 8 + internal/store/diagnosis.go | 17 +- internal/store/product.go | 32 +- internal/store/sync_test.go | 93 ++++++ 22 files changed, 1180 insertions(+), 59 deletions(-) create mode 100644 app_live_test.go create mode 100644 app_sync_test.go create mode 100644 internal/erpgo/client.go create mode 100644 internal/erpgo/client_test.go create mode 100644 internal/store/sync_test.go diff --git a/app.go b/app.go index ccf49b8..767bfa5 100644 --- a/app.go +++ b/app.go @@ -18,6 +18,7 @@ import ( "cmsp/internal/config" "cmsp/internal/downloader" + "cmsp/internal/erpgo" "cmsp/internal/huohanhan" "cmsp/internal/logx" "cmsp/internal/store" @@ -818,16 +819,16 @@ func (a *App) ExportLogs() (string, error) { // ---------------------------------------------------------------- 商品操作 -// DownloadProductData 从货憨憨拉取商品列表到本地(需求 R1)。 +// DownloadProductData 通过 erpgo 拉取商品列表到本地(需求 R1)。 func (a *App) DownloadProductData(platformShopID string) error { if a.db == nil { - return fmt.Errorf("数据库未就绪,请查看运行日志") + return erpgo.BridgeError(fmt.Errorf("数据库未就绪")) } if platformShopID == "" { - return fmt.Errorf("请先选择店铺") + return &erpgo.Error{Code: "INVALID_ARGUMENT", Message: "请先选择店铺"} } - client, err := a.newHuohanhanClient() + client, err := erpgo.NewClient(a.cfg.ERPGo, nil) if err != nil { return err } @@ -840,22 +841,13 @@ func (a *App) DownloadProductData(platformShopID string) error { a.log.Info("已拉取 %d/%d 页", current, total) }) if err != nil { - a.log.Error("下载商品数据失败:%v", err) + a.log.Error("下载商品数据失败:%s", erpgo.LogSummary(err)) return err } updatedAt := time.Now().Format("2006-01-02 15:04:05") - if err := a.db.UpsertProducts(products, updatedAt); err != nil { - a.log.Error("保存商品数据失败:%v", err) - return err - } - for _, product := range products { - if product.ID == "" { - continue - } - if err := a.db.ReplaceDiagnoses(product.ID, diagnoses[product.ID], updatedAt); err != nil { - a.log.Error("保存商品诊断失败:%v", err) - return err - } + if err := a.db.SyncProducts(products, diagnoses, updatedAt); err != nil { + a.log.Error("保存商品与诊断失败:%s", erpgo.LogSummary(err)) + return erpgo.BridgeError(err) } a.log.Success("商品数据下载完成,共拉取 %d 条", len(products)) return nil @@ -1197,12 +1189,12 @@ func (a *App) GetShopsUpdatedAt() (string, error) { return a.db.ShopsUpdatedAt() } -// RefreshShops 从货憨憨读取最新店铺并全量替换本地缓存。 +// RefreshShops 通过 erpgo 读取最新店铺并全量替换本地缓存。 func (a *App) RefreshShops() ([]store.Shop, error) { if a.db == nil { - return nil, fmt.Errorf("数据库未就绪,请查看运行日志") + return nil, erpgo.BridgeError(fmt.Errorf("数据库未就绪")) } - client, err := a.newHuohanhanClient() + client, err := erpgo.NewClient(a.cfg.ERPGo, nil) if err != nil { return nil, err } @@ -1212,7 +1204,7 @@ func (a *App) RefreshShops() ([]store.Shop, error) { } shops, err := client.ListShops(ctx) if err != nil { - a.log.Error("更新店铺列表失败:%v", err) + a.log.Error("更新店铺列表失败:%s", erpgo.LogSummary(err)) return nil, err } @@ -1232,8 +1224,8 @@ func (a *App) RefreshShops() ([]store.Shop, error) { }) } if err := a.db.ReplaceShops(cached, updatedAt); err != nil { - a.log.Error("保存店铺缓存失败:%v", err) - return nil, err + a.log.Error("保存店铺缓存失败:%s", erpgo.LogSummary(err)) + return nil, erpgo.BridgeError(err) } a.log.Success("店铺列表更新完成,共 %d 个店铺", len(cached)) return cached, nil diff --git a/app_live_test.go b/app_live_test.go new file mode 100644 index 0000000..9007d03 --- /dev/null +++ b/app_live_test.go @@ -0,0 +1,74 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "testing" + + "cmsp/internal/config" + "cmsp/internal/store" +) + +// 显式设置配置路径才执行真实查询;只写临时 SQLite,不执行店铺写操作。 +func TestERPGoLiveReadOnlySync(t *testing.T) { + path := os.Getenv("CMSP_ERPGo_LIVE_CONFIG") + if path == "" { + t.Skip("set CMSP_ERPGo_LIVE_CONFIG to run authorized read-only integration") + } + cfg, err := config.Load(path) + if err != nil { + t.Fatal("cannot load local integration configuration") + } + a := NewApp() + a.ctx = context.Background() + a.cfg = cfg + dbPath := filepath.Join(t.TempDir(), "live-query.db") + db, err := store.Open(dbPath) + if err != nil { + t.Fatal("cannot open temporary database") + } + a.db = db + t.Cleanup(func() { _ = a.db.Close() }) + shops, err := a.RefreshShops() + if err != nil { + t.Fatalf("live shop query failed: %v", err) + } + t.Logf("live shop query and local cache succeeded: %d shops", len(shops)) + if len(shops) == 0 { + t.Skip("no shops available for product integration") + } + if err := a.DownloadProductData(shops[0].PlatformShopID); err != nil { + t.Fatalf("live product pagination or persistence failed: %v", err) + } + page, err := a.db.ListProducts(store.ProductQuery{PlatformShopID: shops[0].PlatformShopID, Page: 1, PageSize: 1}) + if err != nil { + t.Fatal("cannot read synchronized products") + } + t.Logf("live sequential product pagination and local persistence succeeded: %d products", page.Total) + if len(page.Items) > 0 { + p := page.Items[0] + diagnoses, err := a.db.ListDiagnoses(p.ID) + if err != nil { + t.Fatal("cannot read persisted diagnoses") + } + if err := a.db.UpdateProductStatus(p.ID, store.VideoFound, store.DownloadDone, store.UploadDone, "fictional-local-error"); err != nil { + t.Fatal("cannot seed local states") + } + if err := a.db.SyncProducts([]store.Product{p}, map[string][]store.Diagnosis{p.ID: diagnoses}, "demo-resync-time"); err != nil { + t.Fatal("cannot persist repeated local sync") + } + if err := a.db.Close(); err != nil { + t.Fatal("cannot close local cache") + } + a.db, err = store.Open(dbPath) + if err != nil { + t.Fatal("cannot reopen local cache") + } + got, found, err := a.db.GetProduct(p.ID) + if err != nil || !found || got.VideoStatus != store.VideoFound || got.DownloadStatus != store.DownloadDone || got.UploadStatus != store.UploadDone || got.LastError != "fictional-local-error" { + t.Fatal("local states did not survive persistence and reopen") + } + t.Log("download/upload/local video states survived repeated local persistence and database reopen") + } +} diff --git a/app_sync_test.go b/app_sync_test.go new file mode 100644 index 0000000..4c48178 --- /dev/null +++ b/app_sync_test.go @@ -0,0 +1,165 @@ +package main + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "cmsp/internal/config" + "cmsp/internal/store" +) + +func newSyncTestApp(t *testing.T, handler http.HandlerFunc) *App { + t.Helper() + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + a := NewApp() + a.ctx = context.Background() + a.cfg.ERPGo = config.ERPGoConfig{BaseURL: server.URL, APIKey: "fictional-query-key"} + db, err := store.Open(filepath.Join(t.TempDir(), "demo.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + a.db = db + if err := db.SyncProducts([]store.Product{{ID: "demo-product", ItemID: "demo-item", PlatformShopID: "demo-shop", ItemName: "old-title", VideoDiagnosis: store.VideoDiagnosisOK}}, map[string][]store.Diagnosis{"demo-product": {{Type: "old-diagnosis"}}}, "old-time"); err != nil { + t.Fatal(err) + } + if err := db.UpdateProductStatus("demo-product", store.VideoFound, store.DownloadDone, store.UploadDone, "old-error"); err != nil { + t.Fatal(err) + } + if err := db.ReplaceShops([]store.Shop{{ID: "old-shop", PlatformShopID: "demo-shop", ShopName: "old-name", Platform: "0"}}, "old-time"); err != nil { + t.Fatal(err) + } + return a +} + +func queryResponse(w http.ResponseWriter, status int, data any, code string) { + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]any{"code": status, "data": data, "errorCode": code, "msg": "fictional-query-key", "requestId": "demo-reference"}) +} + +func appProductPage(current, pages int) map[string]any { + total := 1 + if pages == 2 { + total = 201 + } + return map[string]any{ + "source": "huohanhan", "fetchedAt": "2026-09-28T10:00:00+08:00", "platformShopId": "demo-shop", "itemStatus": "NORMAL", "size": 200, "current": current, "pages": pages, "total": total, + "records": []any{map[string]any{"id": "demo-product", "itemId": "demo-item", "platformShopId": "demo-shop", "itemStatus": "NORMAL", "itemName": "new-title", "createTime": "2026-09-01 10:00:00", "videoDiagnosis": "missing", "diagnoses": []any{map[string]any{"field": "ALL", "type": "缺少视频", "solution": "上传视频"}}, "uploadStatus": "pending", "downloadStatus": "pending", "videoStatus": "none"}}, + } +} + +func assertOldSyncData(t *testing.T, a *App) { + t.Helper() + p, _, err := a.db.GetProduct("demo-product") + d, _ := a.db.ListDiagnoses("demo-product") + if err != nil || p.ItemName != "old-title" || p.SyncedAt != "old-time" || p.VideoStatus != store.VideoFound || p.DownloadStatus != store.DownloadDone || p.UploadStatus != store.UploadDone || p.LastError != "old-error" || len(d) != 1 || d[0].Type != "old-diagnosis" { + t.Fatal("failed sync modified original data or states") + } +} + +func TestAppQueryAndAtomicProductSync(t *testing.T) { + for _, failSecondPage := range []bool{false, true} { + t.Run(map[bool]string{false: "success", true: "second-page-failure"}[failSecondPage], func(t *testing.T) { + a := newSyncTestApp(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.Header.Get("X-API-Key") != "fictional-query-key" || r.URL.Path != "/api/v1/integrations/huohanhan/products" { + t.Error("unexpected request or side effect") + } + if failSecondPage && r.URL.Query().Get("current") == "2" { + queryResponse(w, 502, nil, "HHH_UPSTREAM_ERROR") + return + } + pages := 1 + if failSecondPage { + pages = 2 + } + queryResponse(w, 200, appProductPage(1, pages), "") + }) + err := a.DownloadProductData("demo-shop") + if failSecondPage { + if err == nil || !strings.Contains(err.Error(), "HHH_UPSTREAM_ERROR") { + t.Fatalf("missing stable error: %v", err) + } + assertOldSyncData(t, a) + } else { + p, _, _ := a.db.GetProduct("demo-product") + d, _ := a.db.ListDiagnoses("demo-product") + if err != nil || p.ItemName != "new-title" || p.VideoDiagnosis != store.VideoDiagnosisMissing || p.CreatedAt != "2026-09-01 10:00:00" || p.VideoStatus != store.VideoFound || p.DownloadStatus != store.DownloadDone || p.UploadStatus != store.UploadDone || p.LastError != "old-error" || len(d) != 1 || d[0].Type != "缺少视频" { + t.Fatalf("sync lost local states or mapping: %v", err) + } + } + if strings.Contains(a.log.Text(), "fictional-query-key") { + t.Fatal("query credential exposed in logs") + } + }) + } +} + +func TestAppDiagnosisWriteFailureRollsBackSync(t *testing.T) { + a := newSyncTestApp(t, func(w http.ResponseWriter, _ *http.Request) { queryResponse(w, 200, appProductPage(1, 1), "") }) + if _, err := a.db.DB().Exec(`CREATE TRIGGER fail_sync BEFORE INSERT ON product_diagnoses BEGIN SELECT RAISE(ABORT,'fictional write failure'); END`); err != nil { + t.Fatal(err) + } + err := a.DownloadProductData("demo-shop") + if err == nil || !strings.Contains(err.Error(), "LOCAL_SYNC_FAILED") { + t.Fatalf("missing write error: %v", err) + } + assertOldSyncData(t, a) +} + +func TestAppShopsFailurePreservesCacheAndSuccessRefreshes(t *testing.T) { + for _, fail := range []bool{false, true} { + a := newSyncTestApp(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/api/v1/integrations/huohanhan/shops" { + t.Error("unexpected shop query") + } + if fail { + queryResponse(w, 401, nil, "API_KEY_INVALID") + return + } + queryResponse(w, 200, map[string]any{"source": "huohanhan", "fetchedAt": "2026-09-28T10:00:00+08:00", "items": []any{map[string]any{"id": "demo-internal-shop", "platformShopId": "demo-shop", "shopName": "new-name", "platform": "0"}}}, "") + }) + _, err := a.RefreshShops() + shops, _ := a.GetCachedShops() + if len(shops) != 1 { + t.Fatal("cache disappeared") + } + if fail && (err == nil || shops[0].ShopName != "old-name" || shops[0].UpdatedAt != "old-time") { + t.Fatal("failed shop query changed cache") + } + if !fail && (err != nil || shops[0].ShopName != "new-name") { + t.Fatal("shops not saved") + } + if strings.Contains(a.log.Text(), "fictional-query-key") { + t.Fatal("credential in log") + } + } +} + +func TestAppMissingQuerySettingsRetainsDataAndUploadSettings(t *testing.T) { + a := newSyncTestApp(t, func(w http.ResponseWriter, _ *http.Request) { t.Error("unconfigured query made a request") }) + a.cfg.ERPGo = config.ERPGoConfig{} + a.cfg.Huohanhan.Account = "fictional-upload-account" + a.cfg.Huohanhan.Password = "fictional-upload-password" + err := a.DownloadProductData("demo-shop") + if err == nil || !strings.Contains(err.Error(), "ERPGo_NOT_CONFIGURED") { + t.Fatal("missing configuration not reported") + } + assertOldSyncData(t, a) + a.cfgPath = filepath.Join(t.TempDir(), "config.yaml") + if err := a.SaveConfig(a.cfg); err != nil { + t.Fatal(err) + } + loaded, err := config.Load(a.cfgPath) + if err != nil || loaded.Huohanhan.Password != "fictional-upload-password" { + t.Fatal("old upload settings changed") + } + if strings.Contains(a.log.Text(), "fictional-upload-password") { + t.Fatal("save exposed upload password") + } +} diff --git a/config.example.yaml b/config.example.yaml index c43adb4..afb729b 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -15,6 +15,12 @@ # # 各项含义见 Wiki「本地开发与验证」和「参数设置」页上的说明。 +erpgo: + # 店铺刷新和商品同步使用的服务根地址;未配置时保留本地缓存。 + base_url: "" + # 在 erpgo 后台生成的 API Key,仅保存于本机,不得提交或分享。 + api_key: "" + huohanhan: # 货憨憨网站地址,一般不用改,域名变了才改。结尾不要带斜杠。 base_url: https://www.huohanhan.com diff --git a/docs/00-project-profile.md b/docs/00-project-profile.md index b3d0c93..49705af 100644 --- a/docs/00-project-profile.md +++ b/docs/00-project-profile.md @@ -2,12 +2,24 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Project-Profile wiki_url: https://git.ilapage.cn/chengma/cmsp/wiki/Project-Profile.- -wiki_revision: 06187f883b4e72da8be7f542953f92850898502b -synchronized_at: 2026-09-02T07:59:03Z +wiki_revision: 86664a13bbe51f695279c7e0f01271f518e5526d +synchronized_at: 2026-09-28T09:47:37Z # 项目档案 +## erpgo 查询依赖与配置(2026-09-28) + +cmsp 仍为单交付桌面应用,没有自身常驻服务。店铺刷新与商品同步依赖已有 erpgo 服务,由其统一使用服务端配置的货憨憨账号查询。视频上传仍通过本机货憨憨配置直连,不迁移上传链路。 + +| 本机配置 | 用途与边界 | +|---|---| +| erpgo.base_url | erpgo 服务根地址,包含 http:// 或 https://,不能带用户信息、查询参数或片段 | +| erpgo.api_key | 查询凭据,仅保存在本机 config.yaml;界面默认掩码,不进日志、工单、Wiki 或提交 | +| huohanhan.* | 保留现有视频上传及认证配置,不因查询接入而删除 | + +有效 Key 可访问 erpgo 配置账号全部 Shopee 店铺,没有独立只读权限体系。旧配置缺少 erpgo 字段仍可读取;未配置查询凭据时保留本地缓存和上传设置,查询明确失败,不回退另一账号。config.yaml 不提交、不打包或共享。 + 本页记录不经常变化、所有维护者都需要知道的信息。它是项目档案的事实来源;仓库内 `docs/00-project-profile.md` 是只读镜像。 ## 基本信息 @@ -34,6 +46,12 @@ synchronized_at: 2026-09-02T07:59:03Z 轻量模式只裁剪工单、原型、测试和文档流程,不取消凭据保护、不可逆操作授权和真实测试证据要求。 +## 内部系统实施授权(2026-09-28) + +本项目为内部系统,不设置独立方案、原型或高风险实施人工确认门禁。用户明确要求实施且目标与预期行为明确时,Agent 完成必要分析后直接推进,不因 API、配置、凭据存取、SQLite 迁移或并发等风险分类再次索要实施许可。此规则优先于通用流程中的额外实施审批表述。 + +风险仍用于确定工单、设计证据、测试、回退和证据要求。需要工单时,以用户实施指令和明确范围作为授权基线,先建单再修改,无需单独审批方案。目标或必要信息不明确时澄清,不增加审批。凭据保护、业务红线、真实验证、工作区保护与最终验收保持有效;发布、支付、真实店铺批量写入/覆盖/删除、破坏性迁移和其他不可逆操作仍需明确操作授权,已有授权不重复索要。只要求分析、建单或文档时不扩大为产品实施。真实依赖未满足或违反安全规则时仍停止;工单完成后保持待验收,用户验收后关闭。 + ## DevHarness 来源与基线 | 项目 | 内容 | @@ -47,7 +65,7 @@ synchronized_at: 2026-09-02T07:59:03Z | 子项目 / 交付单元 | 职责 | 技术栈 | 构建与测试 | 版本与发布方式 | 规则入口 | 共享边界 | |---|---|---|---|---|---|---| -| cmsp 桌面应用 | 商品同步、淘宝以图搜与视频下载、视频上传货憨憨的 GUI 与任务引擎 | Go + Wails v2 + Vue 3 + Naive UI + SQLite | 计划中,见[本地开发与验证](04-local-development-and-verification.md) | 内部分发 Windows 单文件可执行程序,不公开发布 | 根目录 `AGENTS.md` | 无跨仓库契约;依赖货憨憨 ERP 与淘宝的外部接口 | +| cmsp 桌面应用 | 商品同步、淘宝以图搜与视频下载、视频上传货憨憨的 GUI 与任务引擎 | Go + Wails v2 + Vue 3 + Naive UI + SQLite | 计划中,见[本地开发与验证](04-local-development-and-verification.md) | 内部分发 Windows 单文件可执行程序,不公开发布 | 根目录 `AGENTS.md` | 查询消费 erpgo 契约;上传仍依赖货憨憨 ERP,淘宝流程仍在本机 | 当前是单交付单元,不拆分子仓库。 @@ -70,7 +88,7 @@ synchronized_at: 2026-09-02T07:59:03Z | 语言与框架 | Go 1.22+、Wails v2、Vue 3、Naive UI | | 本地状态存储 | SQLite(商品、任务、视频、下载与上传状态、货憨憨认证状态) | | 外部依赖程序 | Google Chrome(专属 Profile,用于淘宝登录与 CDP)、`ffprobe`(视频完整性校验) | -| 外部服务 | 货憨憨 ERP HTTP 接口;淘宝 MTOP 以图搜接口;验证码识别为外部 HTTP OCR 服务 | +| 外部服务 | erpgo 店铺与商品查询接口;货憨憨 ERP 上传 HTTP 接口;淘宝 MTOP 以图搜接口;验证码识别为外部 HTTP OCR 服务 | | 支持环境 | Windows 10 及以上;不支持 macOS 与 Linux | | 开发命令行 | PowerShell 7 `pwsh`;不套用 Bash 语法,见[本地开发与验证](04-local-development-and-verification.md) | | 常驻服务 | 无。本项目是单机桌面程序,不部署服务端 | @@ -122,7 +140,7 @@ Go 与前端目录在项目骨架建立后补入[架构与代码地图](02-archi | 凭据或配置 | 用途 | 提供方式 | |---|---|---| -| 货憨憨账号与密码 | 登录 ERP 拉取商品、上传视频 | 由使用者在程序「参数设置」页填写,保存在本机配置文件;**不得写入仓库、日志、工单或 Wiki** | +| 货憨憨账号与密码 | 登录 ERP 上传视频(商品查询改走 erpgo) | 由使用者在程序「参数设置」页填写,保存在本机配置文件;**不得写入仓库、日志、工单或 Wiki** | | 货憨憨认证 token 与 cookies | 复用登录态 | 运行时保存在本机 SQLite;日志必须脱敏 | | 淘宝登录态 | 以图搜与访问商品详情 | 只保存在专属 Chrome Profile 目录,程序不读取、不落盘、不上传 | | Chrome 可执行文件路径、用户数据目录 | 启动专属浏览器 | 「参数设置」页配置,属于路径不属于凭据 | diff --git a/docs/01-workflow.md b/docs/01-workflow.md index 33240b5..5e19ed9 100644 --- a/docs/01-workflow.md +++ b/docs/01-workflow.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Development-Workflow wiki_url: https://git.ilapage.cn/chengma/cmsp/wiki/Development-Workflow.- -wiki_revision: bdde4b3aa64377fb9a3db1314f3f203cdba7e69b -synchronized_at: 2026-09-02T07:25:57Z +wiki_revision: 9bee25b223a7e74ca4d80b4a662fa29551e9118f +synchronized_at: 2026-09-28T09:47:38Z # 开发工作流 @@ -46,6 +46,12 @@ synchronized_at: 2026-09-02T07:25:57Z Gitea 暂时不可用时可以准备工单和 Wiki 草稿,但不得把本地草稿宣称为线上事实,也不得绕过此门禁开始产品功能开发。 +## 内部系统实施授权(2026-09-28) + +本项目为内部系统,不设置独立方案、原型或高风险实施人工确认门禁。用户明确要求实施且目标与预期行为明确时,Agent 完成必要分析后直接推进,不因 API、配置、凭据存取、SQLite 迁移或并发等风险分类再次索要实施许可。此规则优先于通用流程中的额外实施审批表述。 + +风险仍用于确定工单、设计证据、测试、回退和证据要求。需要工单时,以用户实施指令和明确范围作为授权基线,先建单再修改,无需单独审批方案。目标或必要信息不明确时澄清,不增加审批。凭据保护、业务红线、真实验证、工作区保护与最终验收保持有效;发布、支付、真实店铺批量写入/覆盖/删除、破坏性迁移和其他不可逆操作仍需明确操作授权,已有授权不重复索要。只要求分析、建单或文档时不扩大为产品实施。真实依赖未满足或违反安全规则时仍停止;工单完成后保持待验收,用户验收后关闭。 + ## 按治理模式选择最小门禁 项目在 Project-Profile 选择轻量、标准或高风险模式。内部、单人、低风险项目优先使用轻量模式;单个任务风险高于项目默认模式时,只升级该任务,不抬高全部日常工作。不得为了流程完整而增加没有实际作用的工单、原型、文档或测试。 diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index 8b6150f..f7baff4 100644 --- a/docs/02-architecture-and-code-map.md +++ b/docs/02-architecture-and-code-map.md @@ -2,15 +2,35 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Architecture-and-Code-Map wiki_url: https://git.ilapage.cn/chengma/cmsp/wiki/Architecture-and-Code-Map.- -wiki_revision: 94d20f4ed447c66e00b06c993afc8736fae04d15 -synchronized_at: 2026-09-02T07:59:04Z +wiki_revision: 9757a12d42f1f824ea906f5729d8ced09f0ccf0e +synchronized_at: 2026-09-28T09:47:38Z # 架构与代码地图 +## 当前查询调用链(2026-09-28) + +本节描述已实现的店铺/商品查询路径,优先于本页历史目标设计中的直连查询描述。视频上传与淘宝流程沿用原实现。 + +```text +参数设置 → internal/config(erpgo 服务地址、API Key) +RefreshShops → internal/erpgo.Client.ListShops + → GET /api/v1/integrations/huohanhan/shops + → internal/store.ReplaceShops(同一事务替换本地店铺缓存) +DownloadProductData → internal/erpgo.Client.DownloadAllProducts + → GET /api/v1/integrations/huohanhan/products(指定店铺,串行分页) + → 完整校验并按内部商品 id 去重 + → internal/store.SyncProducts(商品和诊断同一事务) + → 前端刷新本地商品列表 +``` + +主要入口是 app.go 的 RefreshShops、DownloadProductData;internal/erpgo/client.go 负责白名单转换、稳定错误码及分页完整性;internal/store/product.go 的 SyncProducts 使用与诊断仓储共用的事务辅助函数,不改变 SQLite 表结构。 + +查询只读取货憨憨现有数据,不触发 Shopee 同步;不自动回退直连。任何请求/校验失败不返回可落库的部分数据;数据库失败回滚商品与诊断。下载、上传、视频记录和断点状态保留,不依据列表删除商品。internal/huohanhan 仍用于现有视频上传,原客户端代码保留用于回归与回退。 + ## 当前实现状态 -**截至 2026-09-02,本仓库还没有产品代码。** 本页的目录结构和执行路径是已确认的目标设计,不是既有实现。阅读时必须区分: +**历史设计基线截至 2026-09-02;当前查询实现见上方 2026-09-28 专节。** 本页的目录结构和执行路径是已确认的目标设计,不是既有实现。阅读时必须区分: - **当前事实**:仓库有 DevHarness 骨架、文档镜像,以及 Go 骨架(Wails 入口 + config / store / logx 三个包,见下方代码地图)。淘宝与货憨憨的业务能力尚未实现,目前只存在于仓库外的两份 Python 参考实现。 - **目标规范**:本页描述的 Go 目录、执行路径和边界。 @@ -22,7 +42,7 @@ Go 骨架建立后,本页必须改写为对当前代码的描述,并在工 cmsp 是运行在使用者本机的单机桌面程序,没有服务端,没有多用户,没有权限模型。它是三个外部系统之间的搬运工: ```text -货憨憨 ERP --拉取--> cmsp(本机 SQLite) --上传--> 货憨憨 ERP --同步--> Shopee +货憨憨 ERP --查询--> erpgo --拉取--> cmsp(本机 SQLite) --上传--> 货憨憨 ERP --同步--> Shopee | +--以图搜、抓视频--> 淘宝(专属 Chrome) ``` @@ -40,6 +60,7 @@ cmsp/ ├─ internal/ │ ├─ config/ 参数设置的读取、校验与持久化 │ ├─ store/ SQLite 打开、迁移与仓储;商品、任务、视频、认证状态 +│ ├─ erpgo/ 店铺/商品只读查询、字段转换与分页校验 │ ├─ huohanhan/ 货憨憨 ERP 客户端 │ │ ├─ auth.go 登录、验证码 OCR、认证状态复用与失效重登 │ │ ├─ client.go 统一请求、401 重试 @@ -73,9 +94,9 @@ cmsp/ ```text 使用者点击「同步数据」 → app.go 校验参数 - → internal/huohanhan/auth 取得有效认证(复用 SQLite 中的 token,失效则重新登录) - → internal/huohanhan/product 分页拉取指定 Shopee 店铺商品 - → internal/store 写入或更新商品行 + → internal/erpgo 使用 X-API-Key 调用查询接口 + → 串行分页并完整校验指定 Shopee 店铺在售商品 + → internal/store.SyncProducts 同一事务保存商品和诊断 → 事件推送进度,前端刷新表格 使用者勾选商品点击「上传视频」 diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index 4b377de..1965f07 100644 --- a/docs/03-business-rules-and-glossary.md +++ b/docs/03-business-rules-and-glossary.md @@ -2,12 +2,24 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Business-Rules-and-Glossary wiki_url: https://git.ilapage.cn/chengma/cmsp/wiki/Business-Rules-and-Glossary.- -wiki_revision: 64f2636dd1ea47967f7ba41bf7a9ea1f0768479c -synchronized_at: 2026-09-02T03:57:23Z +wiki_revision: ec7b9922df62bd33fb7bb8a423fb459e81f62e44 +synchronized_at: 2026-09-28T09:47:38Z # 业务规则与术语 +## erpgo 查询与本地状态规则(2026-09-28) + +- 查询由 erpgo 负责货憨憨认证;本机认证状态仍用于原上传链路。SQLite 继续是本地任务、视频和工作流状态的唯一事实来源。 +- 在售商品固定 NORMAL,串行每页 200 条,最多 200 页。total/pages 表示全部在售商品,不是缺少视频数量。 +- id 为货憨憨内部商品 ID;itemId 为 Shopee 商品号;platformShopId 为店铺号。按 id 去重,重复记录采用后取得的数据及诊断,不以 itemId 代替内部 ID。 +- videoDiagnosis=missing 表示明确诊断“缺少视频”;ok 只表示未出现该诊断,包括无诊断,不证明真实视频存在或已在 Shopee 生效。前端使用稳定枚举,不按中文诊断文本分支。 +- fetchedAt 是货憨憨读取时间,不是 Shopee 更新时间;createTime 保留上游字符串。上游分页不是一致性快照,去重不能保证读取期间没有遗漏。 +- 请求、响应或分页校验失败,保留原商品、诊断、店铺缓存和任务状态;不将部分结果视为成功。完整分页成功后商品和诊断同一事务落库,任一写入失败全部回滚。 +- 成功同步只更新商品基础字段与诊断;保留 video_status、download_status、upload_status、视频记录、last_error 与任务断点,不依据查询列表删除商品,不引入额外工作流状态重置。 +- 查询不触发 Shopee 同步、上传、修改或删除。店铺成功刷新可按既有规则替换缓存;合法空商品集合不删除本地商品。 +- 新 API Key、X-API-Key 与配置原文禁止日志输出;YAML 解析错误不透传可能包含字段原值的解析器文本。 + ## 核心术语 | 术语 | 含义 | diff --git a/docs/04-local-development-and-verification.md b/docs/04-local-development-and-verification.md index 047f7cf..3076dc8 100644 --- a/docs/04-local-development-and-verification.md +++ b/docs/04-local-development-and-verification.md @@ -2,12 +2,49 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Local-Development-and-Verification wiki_url: https://git.ilapage.cn/chengma/cmsp/wiki/Local-Development-and-Verification.- -wiki_revision: c539cdacdad897d81aee331d4d787e5a9f85a8cd -synchronized_at: 2026-09-02T07:25:58Z +wiki_revision: 8418abc7f51ec71c3d5830df59316b1d816b46a2 +synchronized_at: 2026-09-28T09:47:39Z # 本地开发与验证 +## erpgo 查询配置与验证(2026-09-28) + +内部使用者从「参数设置 → erpgo 商品查询」填写服务根地址和 API Key,再点击「保存设置」。Key 默认遮蔽,可主动查看;保存失败保留输入。原「货憨憨 ERP 账号」继续用于视频上传,不能因为查询接入而删掉。 + +本机 YAML 字段示例(不含真实凭据): + +```yaml +erpgo: + base_url: "https://erpgo.example.com" + api_key: "" +``` + +地址填写服务根路径,客户端追加 /api/v1/integrations/huohanhan;Key 仅通过 X-API-Key 请求头发送,不使用 URL 参数。修改本机配置后重启,或在设置页保存以立即生效。旧配置可读取;未配置时只能查看缓存,刷新查询会提示补齐设置。 + +从仓库根目录验证: + +```powershell +go test ./... +go vet ./... +go build -o "$env:TEMP\cmsp-check.exe" . +npm --prefix frontend run build +& "$env:USERPROFILE\goin\wails.exe" build +python dev_scripts/harness.py check --strict +``` + +Wails CLI 路径以实际安装为准;当前代码已在 Windows 使用 v2.16.0 构建验证,CLI 生成 frontend/wailsjs/go 绑定。构建不等于真机界面全部通过验证。 + +经授权后执行真实只读联调,配置只从本机文件读取: + +```powershell +$env:CMSP_ERPGo_LIVE_CONFIG = Join-Path (Get-Location) 'config.yaml' +go test . -run TestERPGoLiveReadOnlySync -v -count=1 -timeout 10m +Remove-Item Env:CMSP_ERPGo_LIVE_CONFIG +``` + +该测试读取店铺及首个店铺全部在售商品,只写临时 SQLite,验证商品/诊断落库与状态保留及数据库重开;不修改现有数据库,不上传或修改真实店铺。默认 go test 不执行该联调。浏览器模拟只能证明设置控件和错误展示,不能代替 Windows WebView2、Narrator、高对比度或真实上传验收。 + ## 当前可执行范围 **产品代码尚未建立。** 目前本仓库只能执行文档结构检查与 Harness 自测。下面标注「计划」的命令在 Go 骨架建立前无法运行,建立后必须回到本页把它们改成经过实际运行验证的命令,并删除「计划」标注。 diff --git a/docs/05-common-changes.md b/docs/05-common-changes.md index 811ff77..cdbb9d4 100644 --- a/docs/05-common-changes.md +++ b/docs/05-common-changes.md @@ -2,14 +2,20 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Common-Changes wiki_url: https://git.ilapage.cn/chengma/cmsp/wiki/Common-Changes.- -wiki_revision: a852c6f6430902be970468b30802282617c09f93 -synchronized_at: 2026-09-02T03:57:24Z +wiki_revision: d14f0c2f6b4acd3b50fab0fbb5b36062c6f40353 +synchronized_at: 2026-09-28T09:47:39Z # 常见修改 本页说明典型改动要改哪里、验证什么、属于什么风险。产品代码尚未建立,涉及 Go 与前端的条目标注为「计划」,骨架建立后按实际路径回填。 +## 内部系统实施授权(2026-09-28) + +本项目为内部系统,不设置独立方案、原型或高风险实施人工确认门禁。用户明确要求实施且目标与预期行为明确时,Agent 完成必要分析后直接推进,不因 API、配置、凭据存取、SQLite 迁移或并发等风险分类再次索要实施许可。此规则优先于通用流程中的额外实施审批表述。 + +风险仍用于确定工单、设计证据、测试、回退和证据要求。需要工单时,以用户实施指令和明确范围作为授权基线,先建单再修改,无需单独审批方案。目标或必要信息不明确时澄清,不增加审批。凭据保护、业务红线、真实验证、工作区保护与最终验收保持有效;发布、支付、真实店铺批量写入/覆盖/删除、破坏性迁移和其他不可逆操作仍需明确操作授权,已有授权不重复索要。只要求分析、建单或文档时不扩大为产品实施。真实依赖未满足或违反安全规则时仍停止;工单完成后保持待验收,用户验收后关闭。 + ## 风险分级 风险按影响范围判断,不按代码行数判断。 diff --git a/docs/06-troubleshooting.md b/docs/06-troubleshooting.md index d5f3836..618312c 100644 --- a/docs/06-troubleshooting.md +++ b/docs/06-troubleshooting.md @@ -2,12 +2,33 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Troubleshooting wiki_url: https://git.ilapage.cn/chengma/cmsp/wiki/Troubleshooting -wiki_revision: cbccea30b7c68f29e4872c0cbb9083ab45827d98 -synchronized_at: 2026-09-02T03:57:24Z +wiki_revision: dcbde6376c9810e59552bddc8c8732c19f94a1fd +synchronized_at: 2026-09-28T09:47:40Z # 故障排查 +## erpgo 店铺与商品同步排查(2026-09-28) + +先查看稳定 errorCode、HTTP 状态及 requestId。界面按错误码显示提示,不按中文上游消息分支,也不输出原始响应。所有查询失败保留本地缓存和任务;不要清库或删视频来修复连接问题。 + +| errorCode | 处理 | +|---|---| +| ERPGo_NOT_CONFIGURED | 参数设置填写 erpgo 服务地址与 API Key 并保存 | +| INVALID_ARGUMENT | 检查服务根地址、店铺参数;地址不能带账号、查询参数或片段 | +| API_KEY_INVALID(401) | 检查本机 Key 是否有效;不要把 Key 发进工单或日志 | +| SHOP_ACCESS_DENIED(403) | 检查 erpgo 当前账号是否包含该店铺 | +| HHH_AUTH_FAILED(502) | 在 erpgo 检查货憨憨账号配置与认证,不修改本机上传账号来修查询 | +| HHH_UPSTREAM_ERROR(502) | 保留 requestId,检查提供方;稍后手动重试,不紧密重试 | +| HHH_UPSTREAM_TIMEOUT(504) | 检查提供方与上游连接,稍后重试 | +| SERVICE_UNAVAILABLE(503) / NETWORK_ERROR | 检查服务地址、网络与服务状态 | +| RATE_LIMITED(429) | 稍后重试;提供方当前没有独立限流器,该码仅兼容后续契约 | +| INTERNAL_ERROR(500) / INVALID_RESPONSE | 带 requestId 联系维护者;非法页码、跨店记录、缺失 ID、非在售记录或超过分页上限不会作为完整同步 | +| LOCAL_SYNC_FAILED | 检查 SQLite 文件、目录权限与磁盘;商品和诊断事务回滚,原数据保留 | +| REQUEST_CANCELLED | 查询已取消,原数据保留 | + +客户端单请求超时 150 秒;提供方上游 context 预算 120 秒、单次 HTTP 默认 30 秒。客户端不自动重试普通错误,也不自动回退直连。HTTP 重定向被拒绝,填写最终服务地址,避免把 X-API-Key 转发至其他主机。 + 本页面向接手维护的初级程序员。目标是快速定位问题归属,并说明什么情况下必须停下来找人。 ## 排查顺序 diff --git a/frontend/src/views/ProductListView.vue b/frontend/src/views/ProductListView.vue index 7519027..84f57cc 100644 --- a/frontend/src/views/ProductListView.vue +++ b/frontend/src/views/ProductListView.vue @@ -16,7 +16,7 @@ * 默认选中「缺少视频」:这些才是需要拿主图去淘宝搜视频的商品, * 也是本工具的主要工作对象。 * - * 注意:本页查的是本地 SQLite,不联网。点「下载数据」才会请求货憨憨。 + * 注意:本页查的是本地 SQLite,不联网。点「下载数据」才会通过 erpgo 查询货憨憨。 * * 已下架商品不清理:货憨憨那边删掉或下架的商品,本地会保留下来 * (2026-09-03 负责人决定)。所以本地总数可能大于店铺当前商品数, @@ -90,7 +90,7 @@ const uploadStatusOptions = [ ] // ---- 店铺下拉 ---- -// 店铺列表必须来自货憨憨 erp/shop/all,不能在前端写死。 +// 店铺列表通过 erpgo 查询货憨憨,不能在前端写死。 const shopOptions = ref([]) const shopLoading = ref(false) const shopRefreshing = ref(false) @@ -100,7 +100,7 @@ const shopRegions = ref({}) // 展开店铺下拉时按需刷新。 // -// 为什么不是每次展开都刷:刷新要先完成货憨憨登录,一次一两秒, +// 为什么不是每次展开都刷:刷新需要 erpgo 查询货憨憨,一次一两秒, // 每点一下下拉框就卡一下很烦人。所以只有距上次刷新超过下面这个 // 间隔才真的去请求,其余时候直接用缓存。 const 店铺刷新间隔毫秒 = 5 * 60 * 1000 @@ -153,7 +153,7 @@ async function loadShops() { } function showRefreshFailure(payload) { - const reason = payload?.reason || '未知原因' + const reason = queryErrorMessage(payload?.reason || '未知原因') const cachedAt = payload?.cachedAt || '' const cachedCount = Number(payload?.cachedCount || 0) const cacheStatus = cachedAt @@ -494,7 +494,7 @@ async function downloadProductData() { await search(false) message.success(`下载完成,本店铺共 ${total.value} 条商品`) } catch (err) { - message.error(`下载数据失败:${err}`) + message.error(`下载数据失败:${queryErrorMessage(err)}`) } finally { running.value = '' } @@ -534,6 +534,35 @@ function formatElapsed(seconds) { return minutes > 0 ? `${minutes}分${rest}秒` : `${rest}秒` } +// Wails 错误通道携带 JSON 文本;判断只使用稳定错误码。 +function queryErrorMessage(err) { + let detail + try { + detail = JSON.parse(String(err)) + } catch (_) { + return String(err) + } + const messages = { + ERPGo_NOT_CONFIGURED: '请在参数设置填写 erpgo 服务地址和 API Key', + INVALID_ARGUMENT: '请检查所选店铺与 erpgo 服务地址', + API_KEY_INVALID: 'erpgo API Key 无效,请检查参数设置', + SHOP_ACCESS_DENIED: 'erpgo 不允许访问此店铺,请检查服务端账号与店铺', + HHH_AUTH_FAILED: '货憨憨认证失败,请检查 erpgo 的账号配置', + HHH_UPSTREAM_ERROR: '货憨憨查询失败,请稍后重试', + HHH_UPSTREAM_TIMEOUT: '查询超时,请稍后重试', + SERVICE_UNAVAILABLE: 'erpgo 服务暂不可用', + RATE_LIMITED: '查询过于频繁,请稍后重试', + INTERNAL_ERROR: 'erpgo 内部错误,请联系维护者', + NETWORK_ERROR: '无法连接 erpgo,请检查服务地址和网络', + REQUEST_CANCELLED: '查询已取消', + INVALID_RESPONSE: 'erpgo 返回的数据或分页不符合契约,请联系维护者', + LOCAL_SYNC_FAILED: '本地同步失败,请查看运行日志', + } + const text = messages[detail.errorCode] || '同步失败,请联系维护者' + const reference = detail.requestId ? `(请求编号:${detail.requestId})` : '' + return `${text}${reference}。原数据与任务状态已保留。` +} + function formatFileSize(size) { const value = Number(size || 0) if (value < 1024 * 1024) return `${Math.round(value / 1024)} KB` diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index 6894f4c..c3be536 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -2,7 +2,7 @@ /** * 参数设置页。 * - * 四张卡片:货憨憨账号、淘宝专属浏览器、下载与任务、货憨憨图片空间。 + * 参数卡片:erpgo 查询、货憨憨上传、淘宝专属浏览器、下载与任务、货憨憨图片空间。 * * 两条不能违反的规则: * 1. 淘宝那张卡片里没有账号密码输入框,将来也不要加。 @@ -150,15 +150,29 @@ onUnmounted(() => {
参数设置
-
账号、浏览器与任务参数。密码只保存在本机 config.yaml,不进仓库和日志
+
账号、浏览器与任务参数。密码与 API Key 只保存在本机 config.yaml,不进仓库和日志
+ + + + + + + + + +
保存后查询生效。未配置或服务不可用时,已有商品和任务会保留。
+
+
diff --git a/internal/config/config.go b/internal/config/config.go index 7df5ae3..86da485 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -22,6 +22,7 @@ package config import ( "fmt" + "net/url" "os" "path/filepath" "strings" @@ -34,11 +35,32 @@ import ( // yaml tag 就是配置文件里的键名。改名会导致老配置读不出来, // 所以除非有充分理由,不要改已有字段的 tag。 type Config struct { + ERPGo ERPGoConfig `yaml:"erpgo" json:"erpgo"` Huohanhan HuohanhanConfig `yaml:"huohanhan" json:"huohanhan"` Taobao TaobaoConfig `yaml:"taobao" json:"taobao"` Download DownloadConfig `yaml:"download" json:"download"` } +// ERPGoConfig 用于查询店铺和商品;APIKey 只保存在本机,禁止进入日志。 +type ERPGoConfig struct { + BaseURL string `yaml:"base_url" json:"baseUrl"` + APIKey string `yaml:"api_key" json:"apiKey"` +} + +// Validate 允许未配置的新旧安装继续使用本地缓存及原上传功能。 +func (c ERPGoConfig) Validate() error { + if strings.TrimSpace(c.BaseURL) != "" { + u, err := url.Parse(strings.TrimSpace(c.BaseURL)) + if err != nil || u == nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.Opaque != "" { + return fmt.Errorf("erpgo 地址须为 http:// 或 https:// 服务地址,不能包含账号、查询参数或片段") + } + } + if strings.ContainsAny(c.APIKey, "\r\n") { + return fmt.Errorf("erpgo API Key 不能包含换行") + } + return nil +} + // HuohanhanConfig 是货憨憨 ERP 的连接信息。 type HuohanhanConfig struct { // BaseURL 是货憨憨网站地址,不带结尾的斜杠。 @@ -90,6 +112,7 @@ type DownloadConfig struct { // 账号和密码故意留空:程序不内置任何凭据,必须由使用者自己填。 func Default() Config { return Config{ + ERPGo: ERPGoConfig{}, Huohanhan: HuohanhanConfig{ BaseURL: "https://www.huohanhan.com", Account: "", @@ -160,6 +183,9 @@ func DefaultVideoDir() string { // 这里不校验账号密码对不对,那要等真正登录时才知道。 // 这里只保证「格式上能用」。 func (c Config) Validate() error { + if err := c.ERPGo.Validate(); err != nil { + return err + } h := c.Huohanhan if strings.TrimSpace(h.BaseURL) == "" { return fmt.Errorf("货憨憨网址不能为空") @@ -245,6 +271,9 @@ func checkIntRange(value, min, max int, name string) error { // 不要直接传 Config。 func (c Config) Desensitized() Config { copied := c + if copied.ERPGo.APIKey != "" { + copied.ERPGo.APIKey = "******" + } if copied.Huohanhan.Password != "" { copied.Huohanhan.Password = "******" } @@ -268,7 +297,8 @@ func Load(path string) (Config, error) { // 新字段会保留默认值而不是变成零值。 cfg := Default() if err := yaml.Unmarshal(raw, &cfg); err != nil { - return Config{}, fmt.Errorf("配置文件不是有效的 YAML:%w", err) + // YAML 类型错误可能包含原始字段值,配置含凭据,不能透传到日志。 + return Config{}, fmt.Errorf("配置文件不是有效的 YAML,请检查格式及字段类型") } return cfg, nil } @@ -308,6 +338,12 @@ func (c Config) Render() string { # [必须] 密码要用双引号包起来。纯数字密码不加引号会被 YAML 当成 # 整数,读取时直接报错,前导 0 也会丢。 +erpgo: + # 店铺刷新和商品同步使用的服务根地址,未填写时只能查看本地缓存。 + base_url: %s + # API Key 只保存在本机,不得提交、分享或写入日志。 + api_key: %s + huohanhan: # 货憨憨网站地址,一般不用改,域名变了才改。结尾不要带斜杠。 base_url: %s @@ -371,6 +407,8 @@ download: # 网络层下载失败后的重试次数;HTTP 4xx 和 ffprobe 校验失败不会重试。 download_retries: %d `, + quoted(c.ERPGo.BaseURL), + quoted(c.ERPGo.APIKey), yamlString(c.Huohanhan.BaseURL), yamlString(c.Huohanhan.Account), quoted(c.Huohanhan.Password), diff --git a/internal/config/config_test.go b/internal/config/config_test.go index ae23297..4ab1640 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -7,6 +7,44 @@ import ( "testing" ) +func TestERPGoConfigPersistenceCompatibilityAndRedaction(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + cfg := Default() + cfg.ERPGo = ERPGoConfig{BaseURL: "https://erpgo.example.com", APIKey: `fictional-key-"quoted"\value`} + if err := Save(path, cfg); err != nil { + t.Fatal(err) + } + loaded, err := Load(path) + if err != nil || loaded.ERPGo != cfg.ERPGo { + t.Fatalf("erpgo settings did not survive restart: %v", err) + } + if loaded.Desensitized().ERPGo.APIKey != "******" || loaded.ERPGo.APIKey != cfg.ERPGo.APIKey { + t.Fatal("credential redaction changed original config") + } + if err := os.WriteFile(path, []byte("huohanhan:\n account: fictional-account\n password: fictional-password\n"), 0600); err != nil { + t.Fatal(err) + } + old, err := Load(path) + if err != nil || old.ERPGo != (ERPGoConfig{}) || old.Huohanhan.Password != "fictional-password" || old.Validate() != nil { + t.Fatalf("old upload config no longer works: %v", err) + } +} + +func TestERPGoConfigRejectsCredentialURLsAndInvalidHeaders(t *testing.T) { + for _, address := range []string{"erpgo.example.com", "ftp://erpgo.example.com", "https://fictional-user:fictional-password@erpgo.example.com", "https://erpgo.example.com?key=fictional", "https://erpgo.example.com#fragment", "https://"} { + t.Run(address, func(t *testing.T) { + cfg := Default() + cfg.ERPGo = ERPGoConfig{BaseURL: address, APIKey: "fictional-key"} + if err := cfg.Validate(); err == nil || strings.Contains(err.Error(), "fictional") { + t.Fatal("invalid address accepted or exposed") + } + }) + } + if (ERPGoConfig{APIKey: "fictional\nkey"}).Validate() == nil { + t.Fatal("header injection accepted") + } +} + // 默认配置必须是合法的,否则第一次启动程序就会报错。 // 新增字段忘了写默认值或校验规则时,这个测试会失败。 func TestDefaultConfigIsValid(t *testing.T) { diff --git a/internal/erpgo/client.go b/internal/erpgo/client.go new file mode 100644 index 0000000..158abf2 --- /dev/null +++ b/internal/erpgo/client.go @@ -0,0 +1,285 @@ +// Package erpgo 只消费 ERPGo 的货憨憨查询接口,不调用同步或写入接口。 +package erpgo + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "cmsp/internal/config" + "cmsp/internal/store" +) + +const pageSize = 200 +const maximumPages = 200 + +// Error 的 JSON 文本跨 Wails 错误通道传递稳定错误码,不透传上游原文。 +type Error struct { + Code string `json:"errorCode"` + Message string `json:"message"` + Status int `json:"status,omitempty"` + RequestID string `json:"requestId,omitempty"` +} + +func (e *Error) Error() string { + b, _ := json.Marshal(e) + return string(b) +} + +func failure(code string, status int, requestID string) *Error { + messages := map[string]string{ + "ERPGo_NOT_CONFIGURED": "请在参数设置填写 erpgo 服务地址和 API Key", + "INVALID_ARGUMENT": "查询参数或 erpgo 地址格式不正确,请检查设置", + "API_KEY_INVALID": "erpgo API Key 无效,请检查参数设置", + "SHOP_ACCESS_DENIED": "erpgo 不允许查询此店铺,请检查服务端账号与店铺", + "HHH_AUTH_FAILED": "erpgo 的货憨憨认证失败,请检查服务端账号配置", + "HHH_UPSTREAM_ERROR": "货憨憨查询失败,原数据已保留,请稍后重试", + "HHH_UPSTREAM_TIMEOUT": "货憨憨查询超时,原数据已保留,请稍后重试", + "SERVICE_UNAVAILABLE": "erpgo 服务暂不可用,原数据已保留", + "RATE_LIMITED": "查询过于频繁,请稍后重试", + "INTERNAL_ERROR": "erpgo 内部错误,请联系维护者", + "NETWORK_ERROR": "无法连接 erpgo,请检查服务地址和网络", + "REQUEST_CANCELLED": "查询已取消,原数据已保留", + "INVALID_RESPONSE": "erpgo 返回的数据或分页不符合契约,原数据已保留", + } + message, ok := messages[code] + if !ok { + code, message = "INVALID_RESPONSE", messages["INVALID_RESPONSE"] + } + return &Error{Code: code, Message: message, Status: status, RequestID: requestID} +} + +type Client struct { + baseURL string + apiKey string + http *http.Client +} + +func NewClient(cfg config.ERPGoConfig, httpClient *http.Client) (*Client, error) { + if err := cfg.Validate(); err != nil { + return nil, failure("INVALID_ARGUMENT", 0, "") + } + if strings.TrimSpace(cfg.BaseURL) == "" || strings.TrimSpace(cfg.APIKey) == "" { + return nil, failure("ERPGo_NOT_CONFIGURED", 0, "") + } + client := http.Client{Timeout: 150 * time.Second} + if httpClient != nil { + client = *httpClient + } + // 防止重定向将 X-API-Key 转发到其他主机;维护者应填写最终服务地址。 + client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } + return &Client{baseURL: strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/"), apiKey: strings.TrimSpace(cfg.APIKey), http: &client}, nil +} + +type envelope struct { + Code int `json:"code"` + ErrorCode string `json:"errorCode"` + RequestID string `json:"requestId"` + Data json.RawMessage `json:"data"` +} + +func (c *Client) get(ctx context.Context, endpoint string, query url.Values, dest any) error { + u := c.baseURL + "/api/v1/integrations/huohanhan/" + endpoint + if len(query) > 0 { + u += "?" + query.Encode() + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return failure("INVALID_ARGUMENT", 0, "") + } + req.Header.Set("X-API-Key", c.apiKey) + req.Header.Set("Accept", "application/json") + response, err := c.http.Do(req) + if err != nil { + if errors.Is(err, context.Canceled) { + return failure("REQUEST_CANCELLED", 0, "") + } + if errors.Is(err, context.DeadlineExceeded) { + return failure("HHH_UPSTREAM_TIMEOUT", 0, "") + } + return failure("NETWORK_ERROR", 0, "") + } + defer response.Body.Close() + const maxBytes = 16 << 20 + raw, err := io.ReadAll(io.LimitReader(response.Body, maxBytes+1)) + if err != nil || len(raw) > maxBytes { + return failure("INVALID_RESPONSE", response.StatusCode, "") + } + var result envelope + if json.Unmarshal(raw, &result) != nil { + return failure("INVALID_RESPONSE", response.StatusCode, "") + } + requestID := safeRequestID(result.RequestID, c.apiKey) + if response.StatusCode != http.StatusOK || result.Code != http.StatusOK { + if result.Code != response.StatusCode || result.ErrorCode == "" { + return failure("INVALID_RESPONSE", response.StatusCode, requestID) + } + return failure(result.ErrorCode, response.StatusCode, requestID) + } + if result.ErrorCode != "" || len(result.Data) == 0 || string(result.Data) == "null" || json.Unmarshal(result.Data, dest) != nil { + return failure("INVALID_RESPONSE", response.StatusCode, requestID) + } + return nil +} + +func safeRequestID(id, key string) string { + if len(id) > 128 || strings.Contains(id, key) { + return "" + } + for _, r := range id { + if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_') { + return "" + } + } + return id +} + +type metadata struct { + Source string `json:"source"` + FetchedAt string `json:"fetchedAt"` +} + +func (m metadata) valid() bool { + _, err := time.Parse(time.RFC3339, m.FetchedAt) + return m.Source == "huohanhan" && err == nil +} + +// Shop 只声明契约中的白名单,避免将上游额外字段透传到本地或界面。 +type Shop struct { + ID string `json:"id"` + PlatformShopID string `json:"platformShopId"` + ShopName string `json:"shopName"` + ShopAlias string `json:"shopAlias"` + Region string `json:"region"` + RegionName string `json:"regionName"` + Platform string `json:"platform"` + Status string `json:"status"` +} + +func (c *Client) ListShops(ctx context.Context) ([]store.Shop, error) { + var data struct { + metadata + Items []Shop `json:"items"` + } + if err := c.get(ctx, "shops", nil, &data); err != nil { + return nil, err + } + if !data.valid() || data.Items == nil { + return nil, failure("INVALID_RESPONSE", 200, "") + } + shops := make([]store.Shop, 0, len(data.Items)) + seen := make(map[string]bool) + for _, s := range data.Items { + if strings.TrimSpace(s.PlatformShopID) == "" || s.Platform != "0" || seen[s.PlatformShopID] { + return nil, failure("INVALID_RESPONSE", 200, "") + } + seen[s.PlatformShopID] = true + shops = append(shops, store.Shop{ID: s.ID, PlatformShopID: s.PlatformShopID, ShopName: s.ShopName, ShopAlias: s.ShopAlias, Region: s.Region, RegionName: s.RegionName, Platform: s.Platform, Status: s.Status}) + } + return shops, nil +} + +type product struct { + ID string `json:"id"` + ItemID string `json:"itemId"` + PlatformShopID string `json:"platformShopId"` + ShopName string `json:"shopName"` + ItemName string `json:"itemName"` + MainImage string `json:"mainImage"` + Currency string `json:"currency"` + MinSkuPrice float64 `json:"minSkuPrice"` + ItemStatus string `json:"itemStatus"` + CreateTime string `json:"createTime"` + QualityLevel string `json:"qualityLevel"` + VideoDiagnosis string `json:"videoDiagnosis"` + Diagnoses []store.Diagnosis `json:"diagnoses"` +} + +type productPage struct { + metadata + PlatformShopID string `json:"platformShopId"` + ItemStatus string `json:"itemStatus"` + Current int `json:"current"` + Size int `json:"size"` + Total int `json:"total"` + Pages int `json:"pages"` + Records []product `json:"records"` +} + +// DownloadAllProducts 先完整拉取并校验;任何页失败都不返回可落库的部分结果。 +func (c *Client) DownloadAllProducts(ctx context.Context, shopID string, onProgress func(int, int)) ([]store.Product, map[string][]store.Diagnosis, error) { + shopID = strings.TrimSpace(shopID) + if shopID == "" { + return nil, nil, failure("INVALID_ARGUMENT", 0, "") + } + products := make([]store.Product, 0) + diagnoses := make(map[string][]store.Diagnosis) + seen := make(map[string]int) + for current := 1; current <= maximumPages; current++ { + var page productPage + q := url.Values{"platformShopId": {shopID}, "current": {strconv.Itoa(current)}, "size": {strconv.Itoa(pageSize)}} + if err := c.get(ctx, "products", q, &page); err != nil { + return nil, nil, err + } + if !page.valid() || page.PlatformShopID != shopID || page.ItemStatus != "NORMAL" || page.Current != current || page.Size != pageSize || page.Total < 0 || page.Total > maximumPages*pageSize || page.Pages < 0 || page.Pages > maximumPages || page.Records == nil || len(page.Records) > pageSize || page.Pages != (page.Total+pageSize-1)/pageSize { + return nil, nil, failure("INVALID_RESPONSE", 200, "") + } + if len(page.Records) == 0 { + if page.Total == 0 && page.Pages == 0 || current > page.Pages { + return products, diagnoses, nil + } + return nil, nil, failure("INVALID_RESPONSE", 200, "") + } + if current > page.Pages { + return nil, nil, failure("INVALID_RESPONSE", 200, "") + } + for _, p := range page.Records { + if strings.TrimSpace(p.ID) == "" || strings.TrimSpace(p.ItemID) == "" || p.PlatformShopID != shopID || p.ItemStatus != "NORMAL" || p.Diagnoses == nil || (p.VideoDiagnosis != store.VideoDiagnosisMissing && p.VideoDiagnosis != store.VideoDiagnosisOK) { + return nil, nil, failure("INVALID_RESPONSE", 200, "") + } + item := store.Product{ID: p.ID, ItemID: p.ItemID, PlatformShopID: p.PlatformShopID, ShopName: p.ShopName, ItemName: p.ItemName, MainImage: p.MainImage, Currency: p.Currency, MinSkuPrice: p.MinSkuPrice, ItemStatus: p.ItemStatus, CreatedAt: p.CreateTime, QualityLevel: p.QualityLevel, VideoDiagnosis: p.VideoDiagnosis} + if index, ok := seen[p.ID]; ok { + products[index] = item + } else { + seen[p.ID] = len(products) + products = append(products, item) + } + for i := range p.Diagnoses { + p.Diagnoses[i].ProductID = p.ID + } + diagnoses[p.ID] = p.Diagnoses + } + if onProgress != nil { + onProgress(current, page.Pages) + } + if current == page.Pages { + return products, diagnoses, nil + } + } + return nil, nil, failure("INVALID_RESPONSE", 200, "") +} + +// BridgeError 为本地数据库等错误提供相同的前端错误协议。 +func BridgeError(err error) error { + var apiError *Error + if errors.As(err, &apiError) { + return apiError + } + return &Error{Code: "LOCAL_SYNC_FAILED", Message: "保存同步数据失败,原数据已保留,请查看运行日志"} +} + +// LogSummary 不包含上游原文、查询 URL 或凭据。 +func LogSummary(err error) string { + var e *Error + if errors.As(err, &e) { + return fmt.Sprintf("%s (HTTP %d, requestId=%s)", e.Code, e.Status, e.RequestID) + } + return "LOCAL_SYNC_FAILED" +} diff --git a/internal/erpgo/client_test.go b/internal/erpgo/client_test.go new file mode 100644 index 0000000..afae4d3 --- /dev/null +++ b/internal/erpgo/client_test.go @@ -0,0 +1,218 @@ +package erpgo + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + + "cmsp/internal/config" + "cmsp/internal/store" +) + +const fictionalKey = "fictional-api-key-for-tests" + +func writeResponse(w http.ResponseWriter, status int, data any, code string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]any{"code": status, "data": data, "errorCode": code, "requestId": "demo-request-001", "msg": fictionalKey}) +} + +func sampleProduct(id string) product { + return product{ID: id, ItemID: "demo-item-" + id, PlatformShopID: "demo-shop", ItemStatus: "NORMAL", VideoDiagnosis: "missing", Diagnoses: []store.Diagnosis{{Field: "ALL", Type: "缺少视频", Solution: "上传视频"}}} +} + +func samplePage(current int, records ...product) productPage { + return productPage{metadata: metadata{Source: "huohanhan", FetchedAt: "2026-09-28T10:00:00+08:00"}, PlatformShopID: "demo-shop", ItemStatus: "NORMAL", Current: current, Size: pageSize, Total: 201, Pages: 2, Records: records} +} + +func testClient(t *testing.T, handler http.HandlerFunc) *Client { + t.Helper() + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + client, err := NewClient(config.ERPGoConfig{BaseURL: server.URL, APIKey: fictionalKey}, nil) + if err != nil { + t.Fatal(err) + } + return client +} + +func assertCode(t *testing.T, err error, code string) { + t.Helper() + var e *Error + if !errors.As(err, &e) || e.Code != code { + t.Fatalf("expected %s, got %v", code, err) + } + if strings.Contains(err.Error(), fictionalKey) || strings.Contains(LogSummary(err), fictionalKey) { + t.Fatal("error exposed fictional credential") + } +} + +func TestSequentialPaginationDeduplicatesAndMapsFields(t *testing.T) { + requests := []int{} + client := testClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/api/v1/integrations/huohanhan/products" || r.Header.Get("X-API-Key") != fictionalKey || r.URL.Query().Get("size") != "200" || r.URL.Query().Get("platformShopId") != "demo-shop" { + t.Errorf("unexpected query request") + } + current, _ := strconv.Atoi(r.URL.Query().Get("current")) + requests = append(requests, current) + p := sampleProduct("one") + p.CreateTime = "2026-09-01 10:00:00" + if current == 1 { + p.ItemName = "old fictional title" + writeResponse(w, 200, samplePage(current, p), "") + } else { + p.ItemName = "new fictional title" + writeResponse(w, 200, samplePage(current, p, sampleProduct("two")), "") + } + }) + progress := 0 + items, diagnoses, err := client.DownloadAllProducts(context.Background(), "demo-shop", func(current, total int) { + progress++ + if current != progress || total != 2 { + t.Error("incorrect progress") + } + }) + if err != nil || len(items) != 2 || len(requests) != 2 || requests[0] != 1 || requests[1] != 2 || progress != 2 { + t.Fatalf("pagination failed: %v, count=%d", err, len(items)) + } + if items[0].ID != "one" || items[0].ItemID != "demo-item-one" || items[0].CreatedAt != "2026-09-01 10:00:00" || items[0].ItemName != "new fictional title" || diagnoses["one"][0].ProductID != "one" { + t.Fatal("IDs, timestamp, duplicate or diagnosis conversion changed") + } +} + +func TestSecondPageFailureReturnsNoPartialData(t *testing.T) { + client := testClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("current") == "1" { + writeResponse(w, 200, samplePage(1, sampleProduct("one")), "") + } else { + writeResponse(w, 502, nil, "HHH_UPSTREAM_ERROR") + } + }) + items, diagnoses, err := client.DownloadAllProducts(context.Background(), "demo-shop", nil) + assertCode(t, err, "HHH_UPSTREAM_ERROR") + if items != nil || diagnoses != nil { + t.Fatal("partial sync data escaped") + } +} + +func TestInvalidPagesAndRecordsFailClosed(t *testing.T) { + cases := map[string]func(*productPage){ + "wrong page": func(p *productPage) { p.Current = 2 }, + "wrong page size": func(p *productPage) { p.Size = 0 }, + "over limit": func(p *productPage) { p.Pages = 201; p.Total = 40200 }, + "negative total": func(p *productPage) { p.Total = -1 }, + "inconsistent metadata": func(p *productPage) { p.Pages = 1 }, + "null records": func(p *productPage) { p.Records = nil }, + "premature empty": func(p *productPage) { p.Records = []product{} }, + "wrong shop": func(p *productPage) { p.PlatformShopID = "other" }, + "cross-shop record": func(p *productPage) { p.Records[0].PlatformShopID = "other" }, + "missing ID": func(p *productPage) { p.Records[0].ID = "" }, + "missing item ID": func(p *productPage) { p.Records[0].ItemID = "" }, + "non-selling": func(p *productPage) { p.Records[0].ItemStatus = "UNLIST" }, + "invalid diagnosis": func(p *productPage) { p.Records[0].VideoDiagnosis = "unknown" }, + "null diagnoses": func(p *productPage) { p.Records[0].Diagnoses = nil }, + "invalid source": func(p *productPage) { p.Source = "cache" }, + "invalid timestamp": func(p *productPage) { p.FetchedAt = "yesterday" }, + } + for name, mutate := range cases { + t.Run(name, func(t *testing.T) { + client := testClient(t, func(w http.ResponseWriter, _ *http.Request) { + p := samplePage(1, sampleProduct("one")) + mutate(&p) + writeResponse(w, 200, p, "") + }) + items, diagnoses, err := client.DownloadAllProducts(context.Background(), "demo-shop", nil) + assertCode(t, err, "INVALID_RESPONSE") + if items != nil || diagnoses != nil { + t.Fatal("invalid data escaped") + } + }) + } +} + +func TestEmptyShopAndNoDiagnosis(t *testing.T) { + for _, empty := range []bool{true, false} { + client := testClient(t, func(w http.ResponseWriter, _ *http.Request) { + p := samplePage(1) + p.Pages, p.Total, p.Records = 0, 0, []product{} + if !empty { + item := sampleProduct("one") + item.VideoDiagnosis, item.Diagnoses = "ok", []store.Diagnosis{} + p.Pages, p.Total, p.Records = 1, 1, []product{item} + } + writeResponse(w, 200, p, "") + }) + items, diagnoses, err := client.DownloadAllProducts(context.Background(), "demo-shop", nil) + if err != nil || items == nil || diagnoses == nil || (!empty && (items[0].VideoDiagnosis != "ok" || len(diagnoses["one"]) != 0)) { + t.Fatalf("legal empty data failed: %v", err) + } + } +} + +func TestHTTPErrorCodesAndRedaction(t *testing.T) { + for code, status := range map[string]int{"INVALID_ARGUMENT": 400, "API_KEY_INVALID": 401, "SHOP_ACCESS_DENIED": 403, "HHH_AUTH_FAILED": 502, "HHH_UPSTREAM_ERROR": 502, "SERVICE_UNAVAILABLE": 503, "HHH_UPSTREAM_TIMEOUT": 504, "RATE_LIMITED": 429, "INTERNAL_ERROR": 500} { + t.Run(code, func(t *testing.T) { + client := testClient(t, func(w http.ResponseWriter, _ *http.Request) { writeResponse(w, status, nil, code) }) + _, err := client.ListShops(context.Background()) + assertCode(t, err, code) + var e *Error + _ = errors.As(err, &e) + if e.Status != status || e.RequestID != "demo-request-001" { + t.Fatal("lost error reference") + } + }) + } +} + +func TestShopsAndEmptyShops(t *testing.T) { + for _, empty := range []bool{true, false} { + client := testClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/api/v1/integrations/huohanhan/shops" || r.Header.Get("X-API-Key") != fictionalKey { + t.Error("unexpected shop query") + } + items := []Shop{} + if !empty { + items = append(items, Shop{ID: "internal-shop", PlatformShopID: "demo-shop", ShopName: "虚构测试店铺", Platform: "0"}) + } + writeResponse(w, 200, map[string]any{"source": "huohanhan", "fetchedAt": "2026-09-28T10:00:00+08:00", "items": items}, "") + }) + shops, err := client.ListShops(context.Background()) + if err != nil || shops == nil || (!empty && shops[0].PlatformShopID != "demo-shop") { + t.Fatalf("shop mapping failed: %v", err) + } + } +} + +func TestMalformedResponseRedirectAndCancellation(t *testing.T) { + for _, raw := range []string{"not-json", `{"code":200,"data":null}`, `{"code":200,"data":{"items":null}}`} { + client := testClient(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(raw)) }) + _, err := client.ListShops(context.Background()) + assertCode(t, err, "INVALID_RESPONSE") + } + targetRequests := 0 + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { targetRequests++ })) + defer target.Close() + client := testClient(t, func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, target.URL, 302) }) + _, err := client.ListShops(context.Background()) + assertCode(t, err, "INVALID_RESPONSE") + if targetRequests != 0 { + t.Fatal("redirect followed with credential") + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err = client.ListShops(ctx) + assertCode(t, err, "REQUEST_CANCELLED") + if safeRequestID(fictionalKey, fictionalKey) != "" { + t.Fatal("request ID exposed credential") + } +} + +func TestMissingConfig(t *testing.T) { + _, err := NewClient(config.ERPGoConfig{}, nil) + assertCode(t, err, "ERPGo_NOT_CONFIGURED") +} diff --git a/internal/logx/logx.go b/internal/logx/logx.go index 5d905e3..d8de000 100644 --- a/internal/logx/logx.go +++ b/internal/logx/logx.go @@ -48,6 +48,7 @@ var maskPatterns = []struct { {"淘宝 cookie", regexp.MustCompile(`(?i)(_tb_token_=|tracknick=)[^;&\s]+`), "${1}***"}, // HTTP 认证头 {"Authorization", regexp.MustCompile(`(?i)(authorization:\s*bearer\s+)\S+`), "${1}***"}, + {"API Key", regexp.MustCompile(`(?i)((x-api-key|api[_-]?key)\s*[":=]+\s*"?)[^"\s,}]+`), "${1}***"}, // 整个 Cookie 请求头 {"Cookie 头", regexp.MustCompile(`(?i)(cookie:\s*)\S.*`), "${1}***"}, // 常见的密码字段,覆盖 password=xxx、"password":"xxx"、密码:xxx diff --git a/internal/logx/logx_test.go b/internal/logx/logx_test.go index 4acee2c..42092ed 100644 --- a/internal/logx/logx_test.go +++ b/internal/logx/logx_test.go @@ -6,6 +6,14 @@ import ( "testing" ) +func TestMaskAPIKeys(t *testing.T) { + for _, input := range []string{"X-API-Key: fictional-secret", "api_key=fictional-secret", `{"apiKey":"fictional-secret"}`} { + if strings.Contains(Mask(input), "fictional-secret") { + t.Fatal("API Key escaped log masking") + } + } +} + // 这是本包最重要的测试:敏感内容绝不能出现在日志里。 // 每新增一条脱敏规则,都要在这里补一个用例。 func TestMaskHidesSecrets(t *testing.T) { diff --git a/internal/store/diagnosis.go b/internal/store/diagnosis.go index 4d39010..028dbc3 100644 --- a/internal/store/diagnosis.go +++ b/internal/store/diagnosis.go @@ -1,6 +1,9 @@ package store -import "fmt" +import ( + "database/sql" + "fmt" +) // Diagnosis 是货憨憨返回的一条商品质量诊断。 type Diagnosis struct { @@ -20,7 +23,16 @@ func (s *Store) ReplaceDiagnoses(productID string, items []Diagnosis, now string return fmt.Errorf("开启商品诊断事务失败:%w", err) } defer tx.Rollback() + if err := replaceDiagnoses(tx, productID, items, now); err != nil { + return err + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("提交商品诊断事务失败:%w", err) + } + return nil +} +func replaceDiagnoses(tx *sql.Tx, productID string, items []Diagnosis, now string) error { if _, err := tx.Exec(`DELETE FROM product_diagnoses WHERE product_id = ?`, productID); err != nil { return fmt.Errorf("清空商品 %s 的旧诊断失败:%w", productID, err) } @@ -39,9 +51,6 @@ func (s *Store) ReplaceDiagnoses(productID string, items []Diagnosis, now string } } - if err := tx.Commit(); err != nil { - return fmt.Errorf("提交商品诊断事务失败:%w", err) - } return nil } diff --git a/internal/store/product.go b/internal/store/product.go index a862a84..749d6ad 100644 --- a/internal/store/product.go +++ b/internal/store/product.go @@ -102,7 +102,16 @@ func (s *Store) UpsertProducts(items []Product, now string) error { } // Rollback 在已经 Commit 后调用会返回错误,这里忽略即可。 defer tx.Rollback() + if err := upsertProducts(tx, items, now); err != nil { + return err + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("提交事务失败:%w", err) + } + return nil +} +func upsertProducts(tx *sql.Tx, items []Product, now string) error { stmt, err := tx.Prepare(` INSERT INTO products ( id, item_id, item_name, main_image, shop_name, platform_shop_id, @@ -146,8 +155,29 @@ func (s *Store) UpsertProducts(items []Product, now string) error { } } + return nil +} + +// SyncProducts 在同一事务中保存完整同步的商品与诊断,不修改本地工作流状态。 +func (s *Store) SyncProducts(items []Product, diagnoses map[string][]Diagnosis, now string) error { + tx, err := s.db.Begin() + if err != nil { + return fmt.Errorf("开启商品同步事务失败:%w", err) + } + defer tx.Rollback() + if err := upsertProducts(tx, items, now); err != nil { + return err + } + for _, product := range items { + if product.ID == "" { + return fmt.Errorf("同步商品缺少内部 ID") + } + if err := replaceDiagnoses(tx, product.ID, diagnoses[product.ID], now); err != nil { + return err + } + } if err := tx.Commit(); err != nil { - return fmt.Errorf("提交事务失败:%w", err) + return fmt.Errorf("提交商品同步事务失败:%w", err) } return nil } diff --git a/internal/store/sync_test.go b/internal/store/sync_test.go new file mode 100644 index 0000000..0cf2310 --- /dev/null +++ b/internal/store/sync_test.go @@ -0,0 +1,93 @@ +package store + +import "testing" + +func TestSyncProductsKeepsAllLocalStatesAndVideoRecords(t *testing.T) { + s := newTestStore(t) + statuses := []struct{ video, download, upload string }{ + {VideoFound, DownloadDone, UploadDone}, + {VideoNone, DownloadFailed, UploadFailed}, + {VideoPending, DownloadRunning, UploadRunning}, + } + for _, status := range statuses { + id := status.video + if err := s.UpsertProducts([]Product{{ID: id, ItemID: "demo-item", VideoDiagnosis: VideoDiagnosisOK}}, "old-time"); err != nil { + t.Fatal(err) + } + if err := s.UpdateProductStatus(id, status.video, status.download, status.upload, "old-error"); err != nil { + t.Fatal(err) + } + if _, err := s.DB().Exec(`INSERT INTO videos(product_id,source_item,status) VALUES (?, 'demo-source', 'downloaded')`, id); err != nil { + t.Fatal(err) + } + if err := s.ReplaceDiagnoses(id, []Diagnosis{{Type: "old-diagnosis"}}, "old-time"); err != nil { + t.Fatal(err) + } + items := []Product{{ID: id, ItemID: "demo-item", ItemName: "new-title", VideoDiagnosis: VideoDiagnosisMissing, VideoStatus: VideoPending, DownloadStatus: DownloadPending, UploadStatus: UploadPending}} + if err := s.SyncProducts(items, map[string][]Diagnosis{id: {{Field: "ALL", Type: "缺少视频"}}}, "new-time"); err != nil { + t.Fatal(err) + } + p, _, err := s.GetProduct(id) + if err != nil || p.VideoStatus != status.video || p.DownloadStatus != status.download || p.UploadStatus != status.upload || p.LastError != "old-error" || p.ItemName != "new-title" || p.VideoDiagnosis != VideoDiagnosisMissing { + t.Fatalf("local state lost: %+v, %v", p, err) + } + d, err := s.ListDiagnoses(id) + if err != nil || len(d) != 1 || d[0].Type != "缺少视频" { + t.Fatal("diagnoses not replaced") + } + var count int + if err := s.DB().QueryRow(`SELECT COUNT(*) FROM videos WHERE product_id=? AND source_item='demo-source'`, id).Scan(&count); err != nil || count != 1 { + t.Fatal("video or source record lost") + } + } +} + +func TestSyncProductsRollsBackOnDiagnosisFailure(t *testing.T) { + s := newTestStore(t) + if err := s.SyncProducts([]Product{{ID: "demo-old", ItemName: "old-title", VideoDiagnosis: VideoDiagnosisOK}}, map[string][]Diagnosis{"demo-old": {{Type: "old-diagnosis"}}}, "old-time"); err != nil { + t.Fatal(err) + } + if _, err := s.DB().Exec(`CREATE TRIGGER fail_diagnosis BEFORE INSERT ON product_diagnoses WHEN NEW.type='demo-fail' BEGIN SELECT RAISE(ABORT,'fictional diagnostic failure'); END`); err != nil { + t.Fatal(err) + } + items := []Product{{ID: "demo-old", ItemName: "new-title", VideoDiagnosis: VideoDiagnosisMissing}, {ID: "demo-new", ItemName: "new-product"}} + err := s.SyncProducts(items, map[string][]Diagnosis{"demo-old": {{Type: "demo-fail"}}}, "new-time") + if err == nil { + t.Fatal("expected diagnosis failure") + } + p, _, err := s.GetProduct("demo-old") + if err != nil || p.ItemName != "old-title" || p.VideoDiagnosis != VideoDiagnosisOK || p.SyncedAt != "old-time" { + t.Fatal("product transaction did not roll back") + } + if _, found, _ := s.GetProduct("demo-new"); found { + t.Fatal("new product escaped rollback") + } + d, _ := s.ListDiagnoses("demo-old") + if len(d) != 1 || d[0].Type != "old-diagnosis" { + t.Fatal("diagnosis deletion did not roll back") + } +} + +func TestSyncProductsRollsBackOnProductFailureAndClearsOldDiagnoses(t *testing.T) { + s := newTestStore(t) + if err := s.SyncProducts([]Product{{ID: "demo-old", ItemName: "old-title"}}, map[string][]Diagnosis{"demo-old": {{Type: "old-diagnosis"}}}, "old-time"); err != nil { + t.Fatal(err) + } + if _, err := s.DB().Exec(`CREATE TRIGGER fail_product BEFORE INSERT ON products WHEN NEW.id='demo-fail' BEGIN SELECT RAISE(ABORT,'fictional product failure'); END`); err != nil { + t.Fatal(err) + } + if err := s.SyncProducts([]Product{{ID: "demo-old", ItemName: "new-title"}, {ID: "demo-fail"}}, nil, "new-time"); err == nil { + t.Fatal("expected product failure") + } + p, _, _ := s.GetProduct("demo-old") + if p.ItemName != "old-title" { + t.Fatal("earlier product write did not roll back") + } + if err := s.SyncProducts([]Product{{ID: "demo-old", ItemName: "new-title"}}, nil, "new-time"); err != nil { + t.Fatal(err) + } + d, _ := s.ListDiagnoses("demo-old") + if len(d) != 0 { + t.Fatal("stale diagnosis remained") + } +}