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(() => {