diff --git a/admin/main.go b/admin/main.go index 9fc9a26..5f69eb4 100644 --- a/admin/main.go +++ b/admin/main.go @@ -8,8 +8,10 @@ package main import ( + "database/sql" "embed" "flag" + "fmt" "html/template" "io/fs" "log" @@ -60,29 +62,40 @@ func main() { log.Printf("数据库已就绪") // 3. Web 引擎 - r := gin.Default() - - // 模板:每个文件用 {{define "目录/名字"}} 声明自己的名字, - // 所以一次全部解析进来不会冲突。 - tmpl, err := template.ParseFS(templateFS, "templates/*/*.html") + r, err := newRouter(db) if err != nil { - log.Fatalf("解析模板失败: %v", err) + log.Fatalf("准备 Web 页面失败: %v", err) } - r.SetHTMLTemplate(tmpl) - - // 静态文件 - staticSub, err := fs.Sub(staticFS, "static") - if err != nil { - log.Fatalf("准备静态文件失败: %v", err) - } - r.StaticFS("/static", http.FS(staticSub)) - - // 4. 路由 - web.Register(r, db, config.OnlineThreshold) // 给浏览器的页面 - api.Register(r, db) // 给 Client 的接口 log.Printf("Admin 已启动: http://%s", *addr) if err := r.Run(*addr); err != nil { log.Fatalf("启动失败: %v", err) } } + +// newRouter 组装 Admin 的模板、静态文件和路由。 +// 单独提成函数是为了让页面冒烟测试无需真的占用 TCP 端口。 +func newRouter(db *sql.DB) (*gin.Engine, error) { + r := gin.New() + r.Use(gin.Logger(), gin.Recovery()) + + // 模板:每个文件用 {{define "目录/名字"}} 声明自己的名字, + // 所以一次全部解析进来不会冲突。 + tmpl, err := template.ParseFS(templateFS, "templates/*/*.html") + if err != nil { + return nil, fmt.Errorf("解析模板失败: %w", err) + } + r.SetHTMLTemplate(tmpl) + + // 静态文件 + staticSub, err := fs.Sub(staticFS, "static") + if err != nil { + return nil, fmt.Errorf("准备静态文件失败: %w", err) + } + r.StaticFS("/static", http.FS(staticSub)) + + // 4. 路由 + web.Register(r, db, config.OnlineThreshold) // 给浏览器的页面 + api.Register(r, db) // 给 Client 的接口 + return r, nil +} diff --git a/admin/main_test.go b/admin/main_test.go new file mode 100644 index 0000000..a91ac3b --- /dev/null +++ b/admin/main_test.go @@ -0,0 +1,76 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "cmautobuy/admin/repository" +) + +// 五个主页面都走一次真实路由和模板渲染。 +// 这样模板字段写错或新增列漏接时,测试阶段就会失败,不必等人工点页面。 +func TestMainPagesReturnOK(t *testing.T) { + db, err := repository.Open(t.TempDir()) + if err != nil { + t.Fatalf("打开测试数据库失败: %v", err) + } + defer db.Close() + if err := repository.Migrate(db); err != nil { + t.Fatalf("迁移测试数据库失败: %v", err) + } + + router, err := newRouter(db) + if err != nil { + t.Fatalf("组装路由失败: %v", err) + } + + for _, path := range []string{"/shopee", "/pdd", "/syb", "/tasks", "/clients"} { + t.Run(path, func(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, path, nil) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusOK { + t.Fatalf("GET %s = %d,期望 200,响应:%s", + path, response.Code, response.Body.String()) + } + }) + } + + pdd, err := repository.EnsurePddProduct( + db, "737116531267", + "https://mobile.yangkeduo.com/goods.html?goods_id=737116531267") + if err != nil { + t.Fatalf("准备 PDD 商品失败: %v", err) + } + resultJSON := `{ + "price_granularity":"color", + "dimensions":[{"key":"color","name":"颜色分类"},{"key":"size","name":"尺码"}], + "skus":[ + {"options":{"color":"黑色","size":"M"},"price_cent":470, + "price_observed_at":{"color":"黑色","size":"M"},"available":true}, + {"options":{"color":"黑色","size":"L"},"price_cent":470, + "price_observed_at":{"color":"黑色","size":"M"},"available":true} + ] + }` + if err := repository.SetCollectResult( + db, pdd.GoodsID, "测试商品", "测试旗舰店", resultJSON); err != nil { + t.Fatalf("准备采集结果失败: %v", err) + } + + request := httptest.NewRequest(http.MethodGet, "/pdd/detail?id=1", nil) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusOK { + t.Fatalf("GET /pdd/detail = %d,响应:%s", response.Code, response.Body.String()) + } + body := response.Body.String() + for _, want := range []string{ + "测试旗舰店", "价格按颜色采样", "✓ 实测", "推断", + } { + if !strings.Contains(body, want) { + t.Errorf("PDD 详情缺少 %q,响应:%s", want, body) + } + } +} diff --git a/admin/model/model.go b/admin/model/model.go index 0c0acc3..3bb3cda 100644 --- a/admin/model/model.go +++ b/admin/model/model.go @@ -75,6 +75,7 @@ type PddProduct struct { GoodsID string // 从 URL 解析,UNIQUE,防重靠它 URL string // 操作员填的链接原文 Title string // 采集回来,人工核对用 + ShopName string // 采集回来的店铺名,可能为空 SkusJSON string // schema_version + dimensions + skus CollectStatus CollectStatus diff --git a/admin/repository/db.go b/admin/repository/db.go index 83420de..6c189fc 100644 --- a/admin/repository/db.go +++ b/admin/repository/db.go @@ -264,7 +264,16 @@ var migrations = [][]string{ // 背景见 #20:v1 曾经被原地改写而不是新增版本,导致已经建过库的机器 // (user_version 已经越过 v1)永远不会重跑改写后的语句,程序拿着一个 // 和代码对不上的库静默启动。 -const schemaVersion = 3 +const schemaVersion = 4 + +// migrationV4 给 PDD 商品增加店铺名。 +// +// v3 是特殊的 Go 迁移,不能塞进上面的纯 SQL migrations。v4 必须等 v3 +// 建好 pdd_products 后再执行,所以单独放在这里。已经发布的 v1/v2 原文 +// 保持不动,老库才能可靠地逐版升级。 +var migrationV4 = []string{ + `ALTER TABLE pdd_products ADD COLUMN shop_name TEXT;`, +} // Migrate 把数据库升到最新版本。 // 已经是最新的就什么都不做,可以重复调用。 @@ -309,15 +318,46 @@ func Migrate(db *sql.DB) error { // sku_mappings 结构),再由这一步收敛成最终结构—— // 这样"全新库"和"老库升级"最终跑的是完全相同的 v3 代码, // 不需要分别维护两条路径。 - if reached < schemaVersion { + if reached < 3 { if err := migrateV3(db); err != nil { return err } + reached = 3 + } + + // v4 是普通的追加列迁移,但必须排在特殊 v3 后面执行。 + if reached < 4 { + if err := runSQLMigration(db, 4, migrationV4); err != nil { + return err + } } return nil } +// runSQLMigration 在一个事务里执行指定版本的 SQL,并最后更新 user_version。 +// 它只接收本文件中写死的版本号和 SQL,不接收外部输入。 +func runSQLMigration(db *sql.DB, version int, statements []string) error { + tx, err := db.Begin() + if err != nil { + return fmt.Errorf("开始迁移 v%d 失败: %w", version, err) + } + defer tx.Rollback() + + for i, stmt := range statements { + if _, err := tx.Exec(stmt); err != nil { + return fmt.Errorf("执行迁移 v%d 第 %d 条语句失败: %w", version, i+1, err) + } + } + if _, err := tx.Exec(fmt.Sprintf("PRAGMA user_version = %d", version)); err != nil { + return fmt.Errorf("更新 user_version 到 %d 失败: %w", version, err) + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("提交迁移 v%d 失败: %w", version, err) + } + return nil +} + // migrateV3 把库收敛成当前结构,对应工单 #20。 // // # 起点不止一种,不能用 user_version 推断结构 @@ -752,6 +792,13 @@ var requiredTables = []string{ "idempotency_keys", "task_claims", } +// requiredColumns 只列出不能靠“表存在”发现的关键追加列。 +// shop_name 是 v4 新增列;缺少它时查询 PDD 页面会直接失败,因此启动时 +// 就应给出明确错误,而不是等操作员点到页面才暴露。 +var requiredColumns = map[string][]string{ + "pdd_products": {"shop_name"}, +} + // CheckSchema 在 Migrate 成功后调用,确认代码依赖的表都在。 // // [必须] 缺表就返回错误,调用方要**拒绝启动**,不是打个警告继续跑。 @@ -759,8 +806,8 @@ var requiredTables = []string{ // 错误要等操作员点到那个页面才暴露——如果那是个写操作页面, // 暴露出来的就不是报错而是写坏数据。 // -// [建议] 只查表名,不逐列校验:够抓住"迁移没跑到、表没建出来"这一类问题, -// 代价也低。真出了列级别的不一致,业务 SQL 跑起来自然会报错。 +// 表名全部检查;关键的追加列也检查,防止 user_version 已更新但迁移未完整 +// 落地时,程序拿着缺列的库继续启动。 func CheckSchema(db *sql.DB) error { rows, err := db.Query(`SELECT name FROM sqlite_master WHERE type = 'table'`) if err != nil { @@ -786,13 +833,27 @@ func CheckSchema(db *sql.DB) error { missing = append(missing, t) } } - if len(missing) == 0 { - return nil + if len(missing) > 0 { + return fmt.Errorf( + "数据库结构与本程序不匹配:缺少表 %s。\n"+ + "这通常是数据库比程序旧、而迁移没有覆盖到。\n"+ + "请备份 data/admin.db 后删除它让程序重建,或联系维护者。", + strings.Join(missing, "、")) } - return fmt.Errorf( - "数据库结构与本程序不匹配:缺少表 %s。\n"+ - "这通常是数据库比程序旧、而迁移没有覆盖到。\n"+ - "请备份 data/admin.db 后删除它让程序重建,或联系维护者。", - strings.Join(missing, "、")) + for table, columns := range requiredColumns { + existingColumns, err := tableColumnSet(db, table) + if err != nil { + return fmt.Errorf("检查数据表 %s 的列失败: %w", table, err) + } + for _, column := range columns { + if !existingColumns[column] { + return fmt.Errorf( + "数据库结构与本程序不匹配:数据表 %s 缺少列 %s。\n"+ + "这通常是数据库迁移没有完整执行,请备份 data/admin.db 后联系维护者。", + table, column) + } + } + } + return nil } diff --git a/admin/repository/migrate_test.go b/admin/repository/migrate_test.go index 705f2f9..b605911 100644 --- a/admin/repository/migrate_test.go +++ b/admin/repository/migrate_test.go @@ -452,6 +452,37 @@ func TestMigrate_已经是最新版本再次调用不报错(t *testing.T) { } } +func TestMigrate_v4增加可空店铺名且老数据保持NULL(t *testing.T) { + db := newV2NewStructureDB(t) + if _, err := db.Exec(` + INSERT INTO pdd_products (goods_id, url, collect_status, created_at, updated_at) + VALUES ('737116531267', 'https://example.invalid', 'pending', '2026-08-01T00:00:00Z', '2026-08-01T00:00:00Z')`); err != nil { + t.Fatalf("插入 v2 老数据失败: %v", err) + } + + if err := Migrate(db); err != nil { + t.Fatalf("迁移到 v4 失败: %v", err) + } + + columns, err := tableColumnSet(db, "pdd_products") + if err != nil { + t.Fatalf("读取 pdd_products 列失败: %v", err) + } + if !columns["shop_name"] { + t.Fatal("v4 必须新增 shop_name 列") + } + + var shop sql.NullString + if err := db.QueryRow( + `SELECT shop_name FROM pdd_products WHERE goods_id = '737116531267'`, + ).Scan(&shop); err != nil { + t.Fatalf("读取老数据店铺名失败: %v", err) + } + if shop.Valid { + t.Errorf("老数据的 shop_name 应为 NULL,实际 %q", shop.String) + } +} + // v2 新结构库(#16 改写后的 v1)第一次 Migrate 时,migrateV3 检测到结构 // 已经是最终形态,只更新版本号、不改任何表——第二次调用(对应用户重启 // 服务)应该是彻底的空操作:user_version 已经是 3,Migrate 最外层的 @@ -848,6 +879,18 @@ func TestCheckSchema_v2老库迁移后通过(t *testing.T) { } } +func TestCheckSchema_缺少v4关键列时拒绝(t *testing.T) { + db := newV2NewStructureDB(t) + if err := migrateV3(db); err != nil { + t.Fatalf("准备 v3 数据库失败: %v", err) + } + + err := CheckSchema(db) + if err == nil || !strings.Contains(err.Error(), "shop_name") { + t.Fatalf("缺少 shop_name 时应拒绝启动并指出列名,实际 %v", err) + } +} + func TestCheckSchema_缺表时拒绝(t *testing.T) { db := newFreshDB(t) if _, err := db.Exec(`DROP TABLE pdd_products`); err != nil { diff --git a/admin/repository/pdd.go b/admin/repository/pdd.go index b496049..d8b0be6 100644 --- a/admin/repository/pdd.go +++ b/admin/repository/pdd.go @@ -12,7 +12,7 @@ import ( // pddColumns 是所有查询共用的列清单。 // 写成常量是为了让下面几个查询的列顺序和 scanPddProduct 永远对得上—— // 改列的时候只改这一处,改漏了会 Scan 到错的字段上,而且不报错。 -const pddColumns = `id, goods_id, url, title, skus_json, +const pddColumns = `id, goods_id, url, title, shop_name, skus_json, collect_status, collect_msg, artifact_ref, collected_at, deleted_at, created_at, updated_at` @@ -24,10 +24,10 @@ type rowScanner interface { // scanPddProduct 按 pddColumns 的顺序读一行。 func scanPddProduct(s rowScanner, extra ...any) (*model.PddProduct, error) { var p model.PddProduct - var title, skus, msg, artifact, collectedAt, deletedAt sql.NullString + var title, shopName, skus, msg, artifact, collectedAt, deletedAt sql.NullString dest := []any{ - &p.ID, &p.GoodsID, &p.URL, &title, &skus, + &p.ID, &p.GoodsID, &p.URL, &title, &shopName, &skus, &p.CollectStatus, &msg, &artifact, &collectedAt, &deletedAt, &p.CreatedAt, &p.UpdatedAt, } @@ -38,6 +38,7 @@ func scanPddProduct(s rowScanner, extra ...any) (*model.PddProduct, error) { } p.Title = title.String + p.ShopName = shopName.String p.SkusJSON = skus.String p.CollectMsg = msg.String p.ArtifactRef = artifact.String @@ -91,13 +92,13 @@ func EnsurePddProduct(q Execer, goodsID, url string) (*model.PddProduct, error) // 复活:清掉删除标记,状态回到待采集。 // 采集结果一并清空——记录被删过一次,旧数据不能再当成有效的用。 // - // title 也要清:它是**采集回来的**(SetCollectResult 写的), + // title 和 shop_name 也要清:它们是**采集回来的**(SetCollectResult 写的), // 属于采集结果的一部分。留着的话状态显示"未采集"、标题却有值, // 操作员会以为已经采过了。 _, err := q.Exec(` UPDATE pdd_products SET deleted_at = NULL, url = ?, collect_status = 'pending', - title = NULL, skus_json = NULL, collect_msg = NULL, + title = NULL, shop_name = NULL, skus_json = NULL, collect_msg = NULL, artifact_ref = NULL, collected_at = NULL, updated_at = ? WHERE goods_id = ?`, url, now, goodsID) @@ -304,18 +305,21 @@ func SoftDeletePddProducts(q Execer, goodsIDs []string) (int64, error) { // // `[必须]` 按 **PDD 的 goods_id** 定位,不是蝦皮的。采集的对象是 PDD 商品。 // -// title 由调用方从采集结果里取出来传进来,用于人工核对"采的是不是要的那个商品"。 -func SetCollectResult(q Execer, pddGoodsID, title, skusJSON string) error { +// title 和 shopName 由调用方从采集结果里取出来,用于人工核对商品来源。 +// shopName 为空时保留数据库已有值:这次没采到不代表上次采到的店铺名失效。 +func SetCollectResult(q Execer, pddGoodsID, title, shopName, skusJSON string) error { if pddGoodsID == "" { return fmt.Errorf("pdd goods_id 不能为空") } now := model.NowISO() res, err := q.Exec(` UPDATE pdd_products - SET skus_json = ?, title = ?, collect_status = 'collected', + SET skus_json = ?, title = ?, + shop_name = CASE WHEN TRIM(?) = '' THEN shop_name ELSE ? END, + collect_status = 'collected', collect_msg = NULL, collected_at = ?, updated_at = ? WHERE goods_id = ? AND deleted_at IS NULL`, - skusJSON, title, now, now, pddGoodsID) + skusJSON, title, shopName, shopName, now, now, pddGoodsID) if err != nil { return fmt.Errorf("保存 PDD 商品 %s 的采集结果失败: %w", pddGoodsID, err) } diff --git a/admin/service/pdd.go b/admin/service/pdd.go index 89cb3a6..a018c25 100644 --- a/admin/service/pdd.go +++ b/admin/service/pdd.go @@ -227,6 +227,7 @@ type PddProductView struct { GoodsID string URL string Title string // 未采集时是占位符 + ShopName string // 未采到时是占位符 StatusText string SkuCountText string // 未采集时是占位符 CollectedAt string @@ -269,6 +270,7 @@ func ListPddProducts(db *sql.DB, keyword string, status model.CollectStatus) (*P GoodsID: r.GoodsID, URL: r.URL, Title: r.Title, + ShopName: r.ShopName, StatusText: statusTextFor(r.PddProduct), CollectedAt: formatLocalTime(r.CollectedAt), UpdatedAt: formatLocalTime(r.UpdatedAt), @@ -278,6 +280,9 @@ func ListPddProducts(db *sql.DB, keyword string, status model.CollectStatus) (*P if v.Title == "" { v.Title = "(未采集,采集后自动回填)" } + if v.ShopName == "" { + v.ShopName = placeholder + } // -1 是"还没采过"。注意 0 要如实显示 0: // 采到 0 个规格说明采集出了问题,显示成 — 就看不出来了。 if r.SkuCount < 0 { @@ -318,7 +323,9 @@ func (r *PddListResult) StatusLine() string { type PddSkuView struct { Options []string // 按 DimensionNames 的顺序排好,模板直接 range PriceText string - Available string + // PriceSource 在按颜色采样时显示“✓ 实测”或“推断”,不能只靠颜色区分。 + PriceSource string + Available string } // PddProductDetail 是双击弹窗要显示的全部内容。 @@ -327,15 +334,17 @@ type PddProductDetail struct { GoodsID string URL string Title string + ShopName string StatusText string CollectMsg string CollectedAt string ArtifactRef string // Collected 为 false 时模板显示"尚未采集",而不是一张空表。 - Collected bool - DimensionNames []string - SKUs []PddSkuView + Collected bool + ShowPriceSource bool + DimensionNames []string + SKUs []PddSkuView // SkusError 非空表示 skus_json 存的东西解析不了。 // 这种情况要如实说出来,不能装作"没有规格"—— @@ -356,6 +365,7 @@ func GetPddProductDetail(db *sql.DB, id int64) (*PddProductDetail, error) { GoodsID: p.GoodsID, URL: p.URL, Title: p.Title, + ShopName: p.ShopName, StatusText: statusTextFor(*p), CollectMsg: p.CollectMsg, CollectedAt: formatLocalTime(p.CollectedAt), @@ -364,6 +374,9 @@ func GetPddProductDetail(db *sql.DB, id int64) (*PddProductDetail, error) { if d.Title == "" { d.Title = placeholder } + if d.ShopName == "" { + d.ShopName = placeholder + } if d.CollectMsg == "" { d.CollectMsg = placeholder } @@ -379,6 +392,7 @@ func GetPddProductDetail(db *sql.DB, id int64) (*PddProductDetail, error) { } d.Collected = true + d.ShowPriceSource = collected.PriceGranularity == "color" keys, names := dimensionOrder(collected) d.DimensionNames = names @@ -391,6 +405,12 @@ func GetPddProductDetail(db *sql.DB, id int64) (*PddProductDetail, error) { if sku.Available { row.Available = "是" } + if d.ShowPriceSource { + row.PriceSource = "推断" + if sameOptions(sku.Options, sku.PriceObservedAt) { + row.PriceSource = "✓ 实测" + } + } for _, k := range keys { value := sku.Options[k] if value == "" { @@ -403,6 +423,20 @@ func GetPddProductDetail(db *sql.DB, id int64) (*PddProductDetail, error) { return d, nil } +// sameOptions 判断价格读取时实际选中的组合是否就是当前这一行。 +// 两个 map 必须键和值都完全一致,避免只比较颜色后把多个尺码都标成实测。 +func sameOptions(options, observed map[string]string) bool { + if len(options) == 0 || len(options) != len(observed) { + return false + } + for key, value := range options { + if observed[key] != value { + return false + } + } + return true +} + // dimensionOrder 定下规格各维度的显示顺序,返回 (取值用的 key, 表头文字)。 // // 优先用采集结果里的 dimensions;它缺失时退回"把所有 SKU 出现过的 key 排序"。 diff --git a/admin/service/pdd_page_test.go b/admin/service/pdd_page_test.go index f7a0358..ca7ac7d 100644 --- a/admin/service/pdd_page_test.go +++ b/admin/service/pdd_page_test.go @@ -155,7 +155,7 @@ func TestCreatePddProduct_删除后重新创建复活且清空采集结果(t *te url := "https://mobile.yangkeduo.com/goods.html?goods_id=737116531267" goodsID, _ := CreatePddProduct(db, url) - if err := repository.SetCollectResult(db, goodsID, "旧标题", sampleSkusJSON); err != nil { + if err := repository.SetCollectResult(db, goodsID, "旧标题", "", sampleSkusJSON); err != nil { t.Fatalf("写采集结果失败: %v", err) } before, _ := repository.GetPddProductByGoodsID(db, goodsID) @@ -256,8 +256,8 @@ func TestListPddProducts_规格数(t *testing.T) { createProduct(t, db, "100000000002") // 采到 3 个 createProduct(t, db, "100000000003") // 采到 0 个 - repository.SetCollectResult(db, "100000000002", "有规格的", sampleSkusJSON) - repository.SetCollectResult(db, "100000000003", "没规格的", `{"skus": []}`) + repository.SetCollectResult(db, "100000000002", "有规格的", "", sampleSkusJSON) + repository.SetCollectResult(db, "100000000003", "没规格的", "", `{"skus": []}`) result, err := ListPddProducts(db, "", "") if err != nil { @@ -300,7 +300,7 @@ func TestListPddProducts_按采集状态筛选(t *testing.T) { db := newTestDB(t) createProduct(t, db, "100000000001") createProduct(t, db, "100000000002") - repository.SetCollectResult(db, "100000000002", "已采的", sampleSkusJSON) + repository.SetCollectResult(db, "100000000002", "已采的", "", sampleSkusJSON) collected, err := ListPddProducts(db, "", model.CollectCollected) if err != nil { @@ -372,7 +372,7 @@ func TestStatusLine_四个状态都列出来(t *testing.T) { db := newTestDB(t) createProduct(t, db, "100000000001") createProduct(t, db, "100000000002") - repository.SetCollectResult(db, "100000000002", "已采的", sampleSkusJSON) + repository.SetCollectResult(db, "100000000002", "已采的", "", sampleSkusJSON) result, _ := ListPddProducts(db, "", "") line := result.StatusLine() @@ -388,7 +388,7 @@ func TestStatusLine_四个状态都列出来(t *testing.T) { func TestGetPddProductDetail_规格表按dimensions排列(t *testing.T) { db := newTestDB(t) createProduct(t, db, "737116531267") - repository.SetCollectResult(db, "737116531267", "西装外套三件套", sampleSkusJSON) + repository.SetCollectResult(db, "737116531267", "西装外套三件套", "", sampleSkusJSON) p, _ := repository.GetPddProductByGoodsID(db, "737116531267") d, err := GetPddProductDetail(db, p.ID) @@ -424,12 +424,55 @@ func TestGetPddProductDetail_规格表按dimensions排列(t *testing.T) { } } +func TestPdd页面_显示店铺与按颜色采样标记(t *testing.T) { + db := newTestDB(t) + createProduct(t, db, "737116531267") + resultJSON := `{ + "goods_id":"737116531267", + "price_granularity":"color", + "dimensions":[ + {"key":"color","name":"颜色分类"}, + {"key":"size","name":"尺码"} + ], + "skus":[ + {"options":{"color":"黑色","size":"M"},"price_cent":470, + "price_observed_at":{"color":"黑色","size":"M"},"available":true}, + {"options":{"color":"黑色","size":"2XL"},"price_cent":470, + "price_observed_at":{"color":"黑色","size":"M"},"available":true} + ] + }` + repository.SetCollectResult( + db, "737116531267", "测试商品", "测试旗舰店", resultJSON) + + list, err := ListPddProducts(db, "", "") + if err != nil { + t.Fatalf("读取列表失败: %v", err) + } + if len(list.Rows) != 1 || list.Rows[0].ShopName != "测试旗舰店" { + t.Fatalf("列表应显示店铺名,实际 %+v", list.Rows) + } + + p, _ := repository.GetPddProductByGoodsID(db, "737116531267") + detail, err := GetPddProductDetail(db, p.ID) + if err != nil { + t.Fatalf("读取详情失败: %v", err) + } + if detail.ShopName != "测试旗舰店" || !detail.ShowPriceSource { + t.Errorf("详情应显示店铺和价格粒度提示,实际 shop=%q show=%v", + detail.ShopName, detail.ShowPriceSource) + } + if len(detail.SKUs) != 2 || detail.SKUs[0].PriceSource != "✓ 实测" || + detail.SKUs[1].PriceSource != "推断" { + t.Errorf("实测/推断标记不正确: %+v", detail.SKUs) + } +} + // dimensions 缺失时退回按 key 排序。不排的话 Go 的 map 是随机顺序, // 同一个商品每次刷新页面列的顺序都不一样。 func TestGetPddProductDetail_没有dimensions时按key排序且稳定(t *testing.T) { db := newTestDB(t) createProduct(t, db, "737116531267") - repository.SetCollectResult(db, "737116531267", "无维度信息", `{ + repository.SetCollectResult(db, "737116531267", "无维度信息", "", `{ "skus": [{"options": {"size": "M", "color": "黑色", "style": "A"}, "price_cent": 100, "available": true}] }`) @@ -661,7 +704,7 @@ func TestCreatePddCollectTasks_已删除的跳过(t *testing.T) { func TestCreatePddCollectTasks_已采集的跳过并单独计数(t *testing.T) { db := newTestDB(t) createProduct(t, db, "737116531267") - repository.SetCollectResult(db, "737116531267", "已采完的商品", sampleSkusJSON) + repository.SetCollectResult(db, "737116531267", "已采完的商品", "", sampleSkusJSON) result, err := CreatePddCollectTasks(db, []string{"737116531267"}) if err != nil { diff --git a/admin/service/pdd_test.go b/admin/service/pdd_test.go index 3c09f80..4c2be78 100644 --- a/admin/service/pdd_test.go +++ b/admin/service/pdd_test.go @@ -104,7 +104,7 @@ func TestEnsurePddProduct_重复保存不产生重复行(t *testing.T) { func TestEnsurePddProduct_不覆盖已有采集结果(t *testing.T) { db := newTestDB(t) repository.EnsurePddProduct(db, "PDD-1", "https://x/1") - if err := repository.SetCollectResult(db, "PDD-1", "商品甲", `{"skus":[1]}`); err != nil { + if err := repository.SetCollectResult(db, "PDD-1", "商品甲", "", `{"skus":[1]}`); err != nil { t.Fatal(err) } @@ -130,7 +130,7 @@ func TestEnsurePddProduct_不覆盖已有采集结果(t *testing.T) { func TestEnsurePddProduct_软删除后可复活(t *testing.T) { db := newTestDB(t) repository.EnsurePddProduct(db, "PDD-1", "https://x/1") - repository.SetCollectResult(db, "PDD-1", "商品甲", `{"skus":[1]}`) + repository.SetCollectResult(db, "PDD-1", "商品甲", "", `{"skus":[1]}`) if err := repository.SoftDeletePddProduct(db, "PDD-1"); err != nil { t.Fatal(err) diff --git a/admin/service/submit.go b/admin/service/submit.go index d7d2763..569247a 100644 --- a/admin/service/submit.go +++ b/admin/service/submit.go @@ -29,8 +29,11 @@ var ( // 客户端提交的完整内容会原样存进 tasks.result_data,这里只挑业务要用的解析出来。 // 用不到的字段不写进结构体,多余的 JSON 字段会被忽略,不影响向前兼容。 type collectedData struct { - GoodsID string `json:"goods_id"` - Title string `json:"title"` + GoodsID string `json:"goods_id"` + Title string `json:"title"` + ShopName string `json:"shop_name"` + // PriceGranularity 说明价格是逐个 SKU 实测,还是只按颜色采样。 + PriceGranularity string `json:"price_granularity"` // Dimensions 决定规格各维度的显示顺序。 // 少了它就只能按 Go 的 map 遍历,而 map 是无序的—— // 同一个商品每次刷新页面「颜色/尺码」的先后都可能变。 @@ -39,10 +42,12 @@ type collectedData struct { Name string `json:"name"` } `json:"dimensions"` SKUs []struct { - Options map[string]string `json:"options"` - PriceCent *int64 `json:"price_cent"` // 指针:采不到价格时是 null,不是 0 - Available bool `json:"available"` - RawPrice string `json:"raw_price"` + Options map[string]string `json:"options"` + PriceCent *int64 `json:"price_cent"` // 指针:采不到价格时是 null,不是 0 + ListPriceCent *int64 `json:"list_price_cent"` + PriceObservedAt map[string]string `json:"price_observed_at"` + Available bool `json:"available"` + RawPrice string `json:"raw_price"` } `json:"skus"` } @@ -53,6 +58,12 @@ func parseCollected(raw string) (*collectedData, error) { if err := json.Unmarshal([]byte(raw), &c); err != nil { return nil, fmt.Errorf("采集结果不是合法结构: %w", err) } + if c.PriceGranularity != "" && c.PriceGranularity != "color" && + c.PriceGranularity != "sku" { + return nil, fmt.Errorf( + "price_granularity 只能是 color 或 sku,收到 %q", + c.PriceGranularity) + } return &c, nil } @@ -153,7 +164,7 @@ func SubmitResult(db *sql.DB, taskID, clientID, idemKey string, rawBody []byte) return nil, err } } else if err := repository.SetCollectResult( - tx, info.PddGoodsID, collected.Title, pddData); err != nil { + tx, info.PddGoodsID, collected.Title, collected.ShopName, pddData); err != nil { return nil, err } } diff --git a/admin/service/submit_test.go b/admin/service/submit_test.go index 7d0565c..0a833c6 100644 --- a/admin/service/submit_test.go +++ b/admin/service/submit_test.go @@ -4,6 +4,7 @@ import ( "database/sql" "encoding/json" "errors" + "strings" "sync" "testing" "time" @@ -283,8 +284,10 @@ func pddProductState(t *testing.T, db *sql.DB, goodsID string) (status, title, s const collectBody = `{"task_version":1,"attempt_id":"a-1","result_type":"collect", "pdd_data":{"schema_version":1,"goods_id":"PDD-1","title":"测试商品", + "shop_name":"测试旗舰店","price_granularity":"color", "dimensions":[{"key":"color","name":"颜色分类"}], "skus":[{"options":{"color":"黑色","size":"M"},"price_cent":1256, + "price_observed_at":{"color":"黑色","size":"M"}, "available":true,"raw_price":"¥12.56"}]}}` func TestSubmitResult_采集结果写入PDD商品(t *testing.T) { @@ -307,6 +310,62 @@ func TestSubmitResult_采集结果写入PDD商品(t *testing.T) { if skus == "" { t.Error("skus_json 应被写入,实际为空") } + var shop sql.NullString + if err := db.QueryRow( + `SELECT shop_name FROM pdd_products WHERE goods_id = ?`, "PDD-1", + ).Scan(&shop); err != nil { + t.Fatalf("读取店铺名失败: %v", err) + } + if !shop.Valid || shop.String != "测试旗舰店" { + t.Errorf("店铺名应从采集结果落库,实际 %+v", shop) + } + if !strings.Contains(skus, `"price_granularity":"color"`) || + !strings.Contains(skus, `"price_observed_at"`) { + t.Errorf("价格粒度与实测组合应保留在 skus_json,实际 %s", skus) + } +} + +func TestSubmitResult_没有店铺名时不覆盖已有值(t *testing.T) { + db := newTestDB(t) + insertPddProduct(t, db, "PDD-1") + if _, err := db.Exec( + `UPDATE pdd_products SET shop_name = '旧店铺' WHERE goods_id = 'PDD-1'`, + ); err != nil { + t.Fatalf("准备已有店铺名失败: %v", err) + } + insertCollectTask(t, db, "TASK-C", "client-001", "SHOPEE-1", "PDD-1") + claimTask(t, db, "TASK-C", "client-001") + + body := `{"attempt_id":"a-1","result_type":"collect", + "pdd_data":{"goods_id":"PDD-1","title":"测试商品", + "skus":[{"options":{"color":"黑色"},"price_cent":100,"available":true}]}}` + if _, err := SubmitResult(db, "TASK-C", "client-001", "key-no-shop", []byte(body)); err != nil { + t.Fatalf("老版本报文不应失败: %v", err) + } + + var shop string + if err := db.QueryRow( + `SELECT shop_name FROM pdd_products WHERE goods_id = 'PDD-1'`, + ).Scan(&shop); err != nil { + t.Fatalf("读取店铺名失败: %v", err) + } + if shop != "旧店铺" { + t.Errorf("缺少 shop_name 时不应覆盖已有值,实际 %q", shop) + } +} + +func TestSubmitResult_拒绝未知价格采样粒度(t *testing.T) { + db := newTestDB(t) + insertPddProduct(t, db, "PDD-1") + insertCollectTask(t, db, "TASK-C", "client-001", "SHOPEE-1", "PDD-1") + claimTask(t, db, "TASK-C", "client-001") + + body := `{"attempt_id":"a-1","result_type":"collect", + "pdd_data":{"goods_id":"PDD-1","price_granularity":"unknown", + "skus":[{"options":{"color":"黑色"},"price_cent":100,"available":true}]}}` + if _, err := SubmitResult(db, "TASK-C", "client-001", "key-bad-granularity", []byte(body)); err == nil { + t.Fatal("未知 price_granularity 应被拒绝") + } } // 采到 0 个规格必须算失败——数据对业务毫无用处, diff --git a/admin/templates/pdd/edit_modal.html b/admin/templates/pdd/edit_modal.html index e1c8eae..838bc5a 100644 --- a/admin/templates/pdd/edit_modal.html +++ b/admin/templates/pdd/edit_modal.html @@ -31,6 +31,7 @@ autocomplete="off" class="wide">
+ 价格按颜色采样,同一颜色下各尺码显示同一价格。
+ 标 ✓ 的是实测价,其余为推断值。
+
| {{.}} | {{end}}价格 | + {{if $.D.ShowPriceSource}}价格来源 | {{end}}有货 | @@ -72,6 +80,7 @@ {{/* 价格底层存的是整数分。采不到价格显示「未采到」, 绝不显示 ¥0.00——那会让人以为是白菜价 */}}{{.PriceText}} | + {{if $.D.ShowPriceSource}}{{.PriceSource}} | {{end}}{{.Available}} | {{end}} diff --git a/admin/templates/pdd/list.html b/admin/templates/pdd/list.html index 2a36754..3d56c84 100644 --- a/admin/templates/pdd/list.html +++ b/admin/templates/pdd/list.html @@ -51,6 +51,7 @@商品 ID | 标题 | +店铺 | PDD 链接 | 采集状态 | {{/* 采到 1 个和采到 20 个差别很大:只采到 1 个通常是没点开规格面板, @@ -71,6 +72,7 @@{{.GoodsID}} | {{.Title}} | +{{.ShopName}} | {{.URL}} | @@ -83,7 +85,7 @@ {{else}} {{/* 空状态要分情况:从没建过 和 筛选没结果,下一步动作完全不同 */}}|
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| + |
{{if .IsFiltered}}
当前筛选条件下没有商品。 换个采集状态或清空搜索词再试。查看全部 diff --git a/docs/admin/03-data-model.md b/docs/admin/03-data-model.md index 8ccfe4f..da91ac8 100644 --- a/docs/admin/03-data-model.md +++ b/docs/admin/03-data-model.md @@ -93,6 +93,7 @@ SQLite 同一时刻只允许一个写事务,连接放太开会互相抢锁、 | v1 | 初始表结构:`shopee_products`(当时还带着 `pdd_data`/`collect_status` 等采集字段)、`shopee_skus`、`syb_orders`、`sku_mappings`(当时主键只有 `shopee_sku_id`)、`tasks`、`clients`、`idempotency_keys`。 | | v2 | 新增 `task_claims`(领取历史,见 §8)。 | | v3 | 把 PDD 采集数据从 `shopee_products` 拆到独立的 `pdd_products`(本文档 §4 描述的最终结构);重建 `shopee_products`,去掉已经搬走的四个字段;重建 `sku_mappings`,主键改成 `(shopee_sku_id, pdd_goods_id)`(§6.1 的理由)。 | +| v4 | `pdd_products` 增加可空的 `shop_name`;老数据保持 `NULL`。 | **v3 为什么丢弃旧 `sku_mappings` 数据(见 #20):** 新主键需要 `pdd_option_key`, 这是 Go 的 `service.OptionKey()` 用 `json.Marshal` 算出来的规范化键,SQL 语句 @@ -248,6 +249,7 @@ CREATE TABLE pdd_products ( goods_id TEXT NOT NULL UNIQUE, -- 从 PDD 链接解析 url TEXT NOT NULL, -- 操作员填的链接原文 title TEXT, -- 采集回来,人工核对用 + shop_name TEXT, -- 店铺名;采不到或老数据为 NULL skus_json TEXT, -- 采集结果,结构见 §4.2 collect_status TEXT NOT NULL DEFAULT 'pending' @@ -358,6 +360,8 @@ UPDATE pdd_products "schema_version": 1, "goods_id": "737116531267", "title": "【现货】西装外套三件套", + "shop_name": "XX旗舰店", + "price_granularity": "color", "captured_at": "2026-08-07T08:00:00Z", "dimensions": [ {"key": "color", "name": "颜色分类"}, @@ -365,7 +369,9 @@ UPDATE pdd_products ], "skus": [ {"options": {"color": "黑色", "size": "M"}, - "price_cent": 1256, "available": true, "raw_price": "¥12.56"}, + "price_cent": 1256, "list_price_cent": 1990, + "price_observed_at": {"color": "黑色", "size": "M"}, + "available": true, "raw_price": "券后¥12.56"}, {"options": {"color": "白色", "size": "M"}, "price_cent": 1256, "available": false, "raw_price": "¥12.56"} ] @@ -380,12 +386,18 @@ UPDATE pdd_products | `skus[].price_cent` | 建采购任务时带出 `max_price_cent` | 价格保护填不了,Client 会拒绝执行 | | `skus[].available` | 不给缺货规格建任务 | 白跑一趟,Client 到手机上才发现卖光 | | `goods_id` / `title` | 核对"采的是不是要的那个商品" | 链接跳转、采错商品时静默存错 | +| `shop_name` | 核对是否来自目标店铺 | 同标题商品无法区分来源;采不到时允许为空 | | `dimensions` | 界面按顺序渲染下拉框 | Go 的 map 无序,不知道该先显示颜色还是尺码 | +| `price_granularity` | 告诉下游价格是逐 SKU 实测还是按颜色推断 | 下单价格保护会把推断价误当实测价 | +| `price_observed_at` | 标明读取价格时实际选中的规格组合 | 无法区分哪一行是实测价 | | `raw_price` | 价格解析出错时对账 | 只有数字,出错了没法查 | `[必须]` 几条硬规则: - **`price_cent` 是整数分**,不是 `12.56` 也不是 `"12.56"`。这个数要参与价格保护比对,是会花钱的判断,禁止浮点。 +- **`price_cent` 是实付价**(如券后价、折后价),不是划线价;可选的划线价放在 `list_price_cent`。 +- **`price_granularity` 只能是 `color` 或 `sku`**。`color` 表示同颜色各尺码共用一次采样价;老 Client 不传时继续兼容。 +- **`price_observed_at` 必须记录读取价格时实际选中的完整组合**。按颜色采样时,同颜色下只有与它完全相同的那一行是实测,其余是推断。 - **采不到价格时给 `null`,不要给 0**。Admin 遇到 `null` 当"未知"处理并拒绝建任务,绝不当成 0 元。 - **`options` 嵌一层,不平铺 `color`/`size`**。支持任意多个维度,碰到三维商品(颜色/尺码/款式)平铺的结构直接装不下。 - **`dimensions` 只给 `key` 和 `name`,不给 values**。values 能从 `skus` 去重推出来,存两份迟早不一致。 diff --git a/docs/admin/04-client-api.md b/docs/admin/04-client-api.md index 4d2e705..d7935fd 100644 --- a/docs/admin/04-client-api.md +++ b/docs/admin/04-client-api.md @@ -170,7 +170,10 @@ Idempotency-Key: | |||||||||||||||