From 93beef8b36bcef48b45b8cdd270d2c7a5338f444 Mon Sep 17 00:00:00 2001
From: chengma
Date: Fri, 7 Aug 2026 17:13:25 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BF=9D=E5=AD=98=E5=B9=B6=E5=B1=95?=
=?UTF-8?q?=E7=A4=BA=20PDD=20=E5=BA=97=E9=93=BA=E4=B8=8E=E4=BB=B7=E6=A0=BC?=
=?UTF-8?q?=E9=87=87=E6=A0=B7=E4=BF=A1=E6=81=AF=20(#31)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
admin/main.go | 49 ++++++++++------
admin/main_test.go | 76 +++++++++++++++++++++++++
admin/model/model.go | 1 +
admin/repository/db.go | 83 ++++++++++++++++++++++++----
admin/repository/migrate_test.go | 43 ++++++++++++++
admin/repository/pdd.go | 22 +++++---
admin/service/pdd.go | 42 ++++++++++++--
admin/service/pdd_page_test.go | 59 +++++++++++++++++---
admin/service/pdd_test.go | 4 +-
admin/service/submit.go | 25 ++++++---
admin/service/submit_test.go | 59 ++++++++++++++++++++
admin/templates/pdd/edit_modal.html | 9 +++
admin/templates/pdd/list.html | 4 +-
docs/admin/03-data-model.md | 14 ++++-
docs/admin/04-client-api.md | 5 +-
docs/admin/05-ui-specification.md | 48 ++++++++++++----
docs/client/04-admin-api-contract.md | 22 +++++++-
17 files changed, 492 insertions(+), 73 deletions(-)
create mode 100644 admin/main_test.go
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">
标题{{.Title}}(只读,采集后自动回填)
+ 店铺{{.ShopName}}(只读,采集后自动回填)
采集状态{{.StatusText}}
失败原因{{.CollectMsg}}
采集时间{{.CollectedAt}}
@@ -55,6 +56,12 @@
建议重新采集一次。
{{else}}
+ {{if .ShowPriceSource}}
+
+ 价格按颜色采样,同一颜色下各尺码显示同一价格。
+ 标 ✓ 的是实测价,其余为推断值。
+
+ {{end}}
@@ -62,6 +69,7 @@
{{/* 列顺序按采集结果里的 dimensions 定,不是 Go map 的随机顺序 */}}
{{range .DimensionNames}}| {{.}} | {{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: ::result-v1
1. 用 `Idempotency-Key` 查是否处理过 → 处理过就返回上次的结果,**不重复落库**;
2. 把 `pdd_data` 写进 `tasks.result_data`;
3. 采集任务:校验返回的 `goods_id` 与请求一致(不一致返回 `422 COLLECT_GOODS_MISMATCH`),
- 再写进 `pdd_products.skus_json`,`collect_status` 置 `collected`;
+ 将 `shop_name` 写进 `pdd_products.shop_name`,完整 `pdd_data` 写进
+ `pdd_products.skus_json`,`collect_status` 置 `collected`;
+ 没有 `shop_name` 或值为空时保留已有店铺名;
+ `price_granularity` 非空时只能是 `color` 或 `sku`;
**`skus` 为空要置 `failed` 而不是 `collected`**,见 [03 数据模型](03-data-model.md) §4.3;
4. `tasks.status` 置 `succeeded`;
5. 刷新客户端 `last_seen_at`;
diff --git a/docs/admin/05-ui-specification.md b/docs/admin/05-ui-specification.md
index 30d5e67..063fc47 100644
--- a/docs/admin/05-ui-specification.md
+++ b/docs/admin/05-ui-specification.md
@@ -53,6 +53,25 @@
- 从没导入过 → "还没有数据,点左上角『导入』开始"
- 搜索无结果 → "没有匹配的记录" + 清除条件入口
+### 3.1 搜索框宽度
+
+`[必须]` 搜索框加 `class="search-narrow"` 收到 30%,让「搜索」按钮紧挨着它。
+目前 **PDD 商品页、顺运宝数据页、采集采购页**在用;
+蝦皮数据页和客户端列表页仍是撑满的(暂未统一)。
+
+`[必须]` **不要去改 `.toolbar input[type="text"]` 那条通用规则**——
+五个页面共用它,改了会把没打算改的页面一起改掉。
+
+`[必须]` 搜索表单上的 `grow` 类**要保留**,它负责把「删除」按钮顶到最右侧。
+去掉的话删除按钮会跑到搜索按钮旁边。
+
+`[必须]` **不要额外加对齐样式**(`margin-right: auto`、`justify-content` 之类)。
+输入框不再伸展之后,多余空间自然留在按钮之后,按钮就贴着输入框了;
+额外加会和 `grow` 叠加出难预料的结果。
+
+`[建议]` placeholder 要短。收到 30% 之后长文案显示不全——
+真装不下就缩短文案,**不要把宽度调回去**。
+
## 4. 蝦皮数据页
### 4.1 工具条
@@ -153,10 +172,7 @@
决定提交到哪个地址),表格里的勾选框只挂一份。
为每个操作各复制一套勾选框的话,两套迟早会不同步。
-`[必须]` 搜索框用 `class="search-narrow"` 收到 30%,让「搜索」按钮紧挨着它。
-**不要去改 `.toolbar input[type="text"]` 那条通用规则**——五个页面共用它,
-改了会把蝦皮 / 顺运宝 / 任务 / 客户端四页的搜索框一起改掉。
-搜索表单上的 `grow` 类要保留,它负责把「删除」按钮顶到最右侧。
+`[必须]` 搜索框用 `class="search-narrow"` 收到 30%,见 [§3.1](#31-搜索框宽度)。
### 5.2 表格列
@@ -165,6 +181,7 @@
| 勾选 | 值是 `goods_id`,支持批量删除、批量建采集任务 |
| 商品 ID | `goods_id` |
| 标题 | 采集回来的;未采集时显示"(未采集,采集后自动回填)" |
+| 店铺 | 采集回来的 `shop_name`;未采到显示 `—`,截断处可查看完整文字 |
| PDD 链接 | 截断显示,可点开(`target="_blank"` 要带 `rel="noopener noreferrer"`) |
| 采集状态 | 中文文字,**不能只靠颜色**;失败时在下面补一行原因;`collecting` 超过 15 分钟没动静显示「**采集中(超时)**」,见下 |
| 规格数 | 从 `skus_json` 算 |
@@ -217,15 +234,15 @@
│ 商品 ID 737116531267 (只读) │
│ PDD 链接 [___________________](可编辑) │
│ 标题 西装外套三件套 (只读) │
+│ 店铺 XX旗舰店 (只读) │
│ 采集状态 已采集 │
│ 失败原因 — │
│ 采集时间 2026-08-07 15:20 │
├─────────────────────────────────────────────┤
│ 规格(3 个) │
-│ 颜色分类 尺码 价格 有货 │
-│ 黑色 M ¥12.56 是 │
-│ 白色 M ¥12.56 否 │
-│ 红色 L 未采到 是 │
+│ 颜色分类 尺码 价格 价格来源 有货 │
+│ 黑色 M ¥12.56 ✓ 实测 是 │
+│ 黑色 L ¥12.56 推断 是 │
├─────────────────────────────────────────────┤
│ [重新采集] [取消] [保存] │
└─────────────────────────────────────────────┘
@@ -241,6 +258,11 @@ Go 的 map 是无序的,不靠它定顺序的话,同一个商品每次刷新
`price_cent` 为 `null` 时显示"未采到",**不得显示成 ¥0.00**——
0 元和采不到价格是两回事,而这个数要参与价格保护比对(会花钱)。
+`[必须]` `price_granularity == "color"` 时,规格表上方显示:
+“价格按颜色采样,同一颜色下各尺码显示同一价格。标 ✓ 的是实测价,其余为推断值。”
+规格表增加“价格来源”列;`options` 与 `price_observed_at` 完全相同的行显示
+“✓ 实测”,其余显示“推断”。状态不能只靠颜色表达。
+
`[必须]` 三种"没有规格"要分开显示,处理方式不一样:
| 情况 | 显示 |
@@ -315,11 +337,13 @@ Go 的 map 是无序的,不靠它定顺序的话,同一个商品每次刷新
### 6.1 工具条
```text
-[同步] [创建采购任务] 订单号 [________] [搜索] [删除]
+[同步] [创建采购任务] 订单号 [___] [搜索] [删除]
```
`[待定]` MVP 阶段**同步按钮只做占位**:点击提示"同步功能待接入",不发请求。
+`[必须]` 搜索框宽度见 [§3.1](#31-搜索框宽度)。
+
### 6.2 表格列
☐ / 货运单ID / 订单号 / 商品标题 / 蝦皮商品ID / 规格SKU / 数量 /
@@ -384,13 +408,17 @@ Go 的 map 是无序的,不靠它定顺序的话,同一个商品每次刷新
### 7.1 工具条
```text
-[类型▾ 全部] [状态▾ 全部] 关键词[____________] [搜索] [删除]
+[类型▾ 全部] [状态▾ 全部] 关键词[___] [搜索] [删除]
```
- **类型**:全部 / 采集 / 采购。
- **状态**:全部 + 7 个状态,见 [01 需求](01-requirements.md) §6.2。
- **关键词**:同时匹配任务编号、订单号、PDD 商品 ID。
+`[必须]` 搜索框宽度见 [§3.1](#31-搜索框宽度)。
+placeholder 写「任务编号 / 订单号 / 商品 ID」,**不要写全「PDD 商品 ID」**——
+收到 30% 之后装不下,会被截断成看不出意思的半截文字。
+
### 7.2 表格列
☐ / 任务编号 / 类型 / **目标** / 状态 / 客户端 / 更新时间
diff --git a/docs/client/04-admin-api-contract.md b/docs/client/04-admin-api-contract.md
index 7b0bfd8..cc3851d 100644
--- a/docs/client/04-admin-api-contract.md
+++ b/docs/client/04-admin-api-contract.md
@@ -270,7 +270,24 @@ Idempotency-Key: task-id:attempt-id:result-v1
"attempt_id": "attempt-uuid",
"result_type": "collect",
"completed_at": "2026-08-06T08:03:00Z",
- "pdd_data": {}
+ "pdd_data": {
+ "goods_id": "737116531267",
+ "title": "测试商品",
+ "shop_name": "XX旗舰店",
+ "price_granularity": "color",
+ "dimensions": [
+ {"key": "color", "name": "颜色分类"},
+ {"key": "size", "name": "尺码"}
+ ],
+ "skus": [{
+ "options": {"color": "黑色", "size": "M"},
+ "price_cent": 470,
+ "list_price_cent": 1990,
+ "price_observed_at": {"color": "黑色", "size": "M"},
+ "available": true,
+ "raw_price": "折后¥4.7"
+ }]
+ }
}
```
@@ -291,6 +308,9 @@ Idempotency-Key: task-id:attempt-id:result-v1
- `[必须]` 相同 `Idempotency-Key` 和相同请求内容必须返回同一业务结果。
- `[必须]` 相同键但不同内容返回 `409 IDEMPOTENCY_CONFLICT`。
- `[必须]` Client 只有收到 `accepted: true` 后才能把本地任务标为 `succeeded`。
+- `[必须]` `price_cent` 是实际支付价的整数分;划线价可放在 `list_price_cent`。
+- `[必须]` `price_granularity` 只能是 `color` 或 `sku`。按颜色采样时填 `color`,并为每条 SKU 填写读取该价格时实际选中的 `price_observed_at`。
+- `[必须]` `shop_name` 采不到时允许省略或留空;Admin 不得因此拒绝老版本 Client,也不得用空值覆盖已保存的店铺名。
### 6.1 Admin 必须无条件接受
|