feat: 接入 erpgo 店铺与商品查询并原子同步 (#20)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,12 @@
|
||||
#
|
||||
# 各项含义见 Wiki「本地开发与验证」和「参数设置」页上的说明。
|
||||
|
||||
erpgo:
|
||||
# 店铺刷新和商品同步使用的服务根地址;未配置时保留本地缓存。
|
||||
base_url: ""
|
||||
# 在 erpgo 后台生成的 API Key,仅保存于本机,不得提交或分享。
|
||||
api_key: ""
|
||||
|
||||
huohanhan:
|
||||
# 货憨憨网站地址,一般不用改,域名变了才改。结尾不要带斜杠。
|
||||
base_url: https://www.huohanhan.com
|
||||
|
||||
@@ -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
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 项目档案
|
||||
|
||||
## 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 可执行文件路径、用户数据目录 | 启动专属浏览器 | 「参数设置」页配置,属于路径不属于凭据 |
|
||||
|
||||
+8
-2
@@ -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
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 开发工作流
|
||||
@@ -46,6 +46,12 @@ synchronized_at: 2026-09-02T07:25:57Z
|
||||
|
||||
Gitea 暂时不可用时可以准备工单和 Wiki 草稿,但不得把本地草稿宣称为线上事实,也不得绕过此门禁开始产品功能开发。
|
||||
|
||||
## 内部系统实施授权(2026-09-28)
|
||||
|
||||
本项目为内部系统,不设置独立方案、原型或高风险实施人工确认门禁。用户明确要求实施且目标与预期行为明确时,Agent 完成必要分析后直接推进,不因 API、配置、凭据存取、SQLite 迁移或并发等风险分类再次索要实施许可。此规则优先于通用流程中的额外实施审批表述。
|
||||
|
||||
风险仍用于确定工单、设计证据、测试、回退和证据要求。需要工单时,以用户实施指令和明确范围作为授权基线,先建单再修改,无需单独审批方案。目标或必要信息不明确时澄清,不增加审批。凭据保护、业务红线、真实验证、工作区保护与最终验收保持有效;发布、支付、真实店铺批量写入/覆盖/删除、破坏性迁移和其他不可逆操作仍需明确操作授权,已有授权不重复索要。只要求分析、建单或文档时不扩大为产品实施。真实依赖未满足或违反安全规则时仍停止;工单完成后保持待验收,用户验收后关闭。
|
||||
|
||||
## 按治理模式选择最小门禁
|
||||
|
||||
项目在 Project-Profile 选择轻量、标准或高风险模式。内部、单人、低风险项目优先使用轻量模式;单个任务风险高于项目默认模式时,只升级该任务,不抬高全部日常工作。不得为了流程完整而增加没有实际作用的工单、原型、文档或测试。
|
||||
|
||||
@@ -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
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 架构与代码地图
|
||||
|
||||
## 当前查询调用链(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 同一事务保存商品和诊断
|
||||
→ 事件推送进度,前端刷新表格
|
||||
|
||||
使用者勾选商品点击「上传视频」
|
||||
|
||||
@@ -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
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
|
||||
## 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 解析错误不透传可能包含字段原值的解析器文本。
|
||||
|
||||
## 核心术语
|
||||
|
||||
| 术语 | 含义 |
|
||||
|
||||
@@ -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
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 本地开发与验证
|
||||
|
||||
## 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 骨架建立前无法运行,建立后必须回到本页把它们改成经过实际运行验证的命令,并删除「计划」标注。
|
||||
|
||||
@@ -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
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 常见修改
|
||||
|
||||
本页说明典型改动要改哪里、验证什么、属于什么风险。产品代码尚未建立,涉及 Go 与前端的条目标注为「计划」,骨架建立后按实际路径回填。
|
||||
|
||||
## 内部系统实施授权(2026-09-28)
|
||||
|
||||
本项目为内部系统,不设置独立方案、原型或高风险实施人工确认门禁。用户明确要求实施且目标与预期行为明确时,Agent 完成必要分析后直接推进,不因 API、配置、凭据存取、SQLite 迁移或并发等风险分类再次索要实施许可。此规则优先于通用流程中的额外实施审批表述。
|
||||
|
||||
风险仍用于确定工单、设计证据、测试、回退和证据要求。需要工单时,以用户实施指令和明确范围作为授权基线,先建单再修改,无需单独审批方案。目标或必要信息不明确时澄清,不增加审批。凭据保护、业务红线、真实验证、工作区保护与最终验收保持有效;发布、支付、真实店铺批量写入/覆盖/删除、破坏性迁移和其他不可逆操作仍需明确操作授权,已有授权不重复索要。只要求分析、建单或文档时不扩大为产品实施。真实依赖未满足或违反安全规则时仍停止;工单完成后保持待验收,用户验收后关闭。
|
||||
|
||||
## 风险分级
|
||||
|
||||
风险按影响范围判断,不按代码行数判断。
|
||||
|
||||
@@ -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
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 故障排查
|
||||
|
||||
## 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 转发至其他主机。
|
||||
|
||||
本页面向接手维护的初级程序员。目标是快速定位问题归属,并说明什么情况下必须停下来找人。
|
||||
|
||||
## 排查顺序
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
/**
|
||||
* 参数设置页。
|
||||
*
|
||||
* 四张卡片:货憨憨账号、淘宝专属浏览器、下载与任务、货憨憨图片空间。
|
||||
* 参数卡片:erpgo 查询、货憨憨上传、淘宝专属浏览器、下载与任务、货憨憨图片空间。
|
||||
*
|
||||
* 两条不能违反的规则:
|
||||
* 1. 淘宝那张卡片里没有账号密码输入框,将来也不要加。
|
||||
@@ -150,15 +150,29 @@ onUnmounted(() => {
|
||||
<div v-if="cfg" class="page">
|
||||
<div class="head">
|
||||
<div class="title">参数设置</div>
|
||||
<div class="sub">账号、浏览器与任务参数。密码只保存在本机 config.yaml,不进仓库和日志</div>
|
||||
<div class="sub">账号、浏览器与任务参数。密码与 API Key 只保存在本机 config.yaml,不进仓库和日志</div>
|
||||
</div>
|
||||
|
||||
<div class="body">
|
||||
<div class="grid">
|
||||
<n-card title="erpgo 商品查询" size="small">
|
||||
<template #header-extra>
|
||||
<span class="faint">用于店铺刷新和商品同步</span>
|
||||
</template>
|
||||
<n-form label-placement="top" size="small">
|
||||
<n-form-item label="服务地址">
|
||||
<n-input v-model:value="cfg.erpgo.baseUrl" placeholder="https://erpgo.example.com" :input-props="{ 'aria-label': 'erpgo 服务地址' }" />
|
||||
</n-form-item>
|
||||
<n-form-item label="API Key">
|
||||
<n-input v-model:value="cfg.erpgo.apiKey" type="password" show-password-on="click" :input-props="{ 'aria-label': 'erpgo API Key', autocomplete: 'off' }" />
|
||||
</n-form-item>
|
||||
<div class="faint">保存后查询生效。未配置或服务不可用时,已有商品和任务会保留。</div>
|
||||
</n-form>
|
||||
</n-card>
|
||||
<!-- 货憨憨 -->
|
||||
<n-card title="货憨憨 ERP 账号" size="small">
|
||||
<template #header-extra>
|
||||
<span class="faint">用于下载商品数据和上传视频</span>
|
||||
<span class="faint">用于现有视频上传</span>
|
||||
</template>
|
||||
<n-form label-placement="top" size="small">
|
||||
<div class="two">
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user