feat: PDD 商品数据独立成表 (#16)

原来 pdd_data 是 shopee_products 上的一个 JSON 字段,两个蝦皮商品指向
同一个 PDD 链接时会各存一份、各采一次;collect_status 描述的是 PDD 商品的
状态,却挂在蝦皮商品上,两份可能不一致。

更要紧的是 PDD 商品变动频繁(A 下架就得换 B),而 sku_mappings 只按
shopee_sku_id 做键——换商品后旧映射还在,B 恰好有同名规格但完全是另一件货
时会静默买错,事后查不出来。

改动
- 新增 pdd_products 表:id 主键 + goods_id UNIQUE + 4 个状态值(去掉
  no_link,「未填链接」改由 shopee_products.pdd_goods_id 为空表达)+
  软删除可复活
- shopee_products 去掉 pdd_data / collect_status / collect_error /
  collected_at,pdd_goods_id 改为引用
- sku_mappings 主键改为 (shopee_sku_id, pdd_goods_id),新增 pdd_option_key。
  查映射永远带上当前 PDD 商品,换商品后天然查不到旧映射,不需要删数据;
  换回原商品时旧映射直接复用
- 新增 OptionKey():用 json.Marshal 实现(Go 序列化 map 按键名排序,
  天然规范化),不自己拼字符串——规格文字里可能含 = 或 ;。
  存映射和查 SKU 必须用同一个函数,各写一遍会静默算出不同结果
- 采集结果改落 pdd_products,新增两条校验:
  返回的 goods_id 与请求不符 → 整体回滚拒绝(422),不静默存下;
  skus 为空数组 → 置 failed 而非 collected,否则界面显示"已采集"
  但数据毫无用处

实施时超出工单但必要的三处
- TaskExists 重构为 GetTaskInfo:原函数只返回蝦皮 goods_id,
  而采集结果要按 PDD goods_id 落库,不改取不到正确的键
- 复活时一并清空旧采集结果(skus_json / collect_msg / collected_at),
  否则复活后会显示"已采集"但数据是删除前的
- 删除 repository/shopee.go:两个函数签名全变且已迁到 pdd.go,留着是死代码

已验证(Go 1.23.0)
- go vet / gofmt / go test 全过,55 个测试
- 端到端补验了工单未覆盖的 HTTP 层:goods_id 不符返回 422
  COLLECT_GOODS_MISMATCH 且整体回滚(skus_json 空、任务仍 claimed、
  幂等记录 0 条);skus 为空返回 200 但状态 failed

遗留
- MarkCollecting / SoftDeletePddProduct 暂无调用方,等界面工单接上
- artifact_ref 存 diagnostics 原始 JSON,未按 client-001:artifacts/... 规范化,
  因 Client 侧尚未定义 diagnostics 结构
- 界面未实现(工单明确排除),四个页面仍为骨架

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
chengma
2026-08-07 10:27:39 +08:00
co-authored by Claude Opus 5
parent 036144ec8e
commit 998c06a2bf
15 changed files with 1127 additions and 203 deletions
+106 -36
View File
@@ -240,43 +240,119 @@ func TestSubmitResult_任务不存在(t *testing.T) {
}
}
// ── 采集任务的结果要落到商品级 ─────────────────────────
// ── 采集任务的结果要落到 PDD 商品 ──────────────────────
func TestSubmitResult_采集结果写入商品级(t *testing.T) {
db := newTestDB(t)
// insertPddProduct 造一个待采集的 PDD 商品。
func insertPddProduct(t *testing.T, db *sql.DB, pddGoodsID string) {
t.Helper()
now := model.NowISO()
if _, err := db.Exec(`
INSERT INTO shopee_products (goods_id, title, pdd_goods_url,
collect_status, created_at, updated_at)
VALUES ('G-1','测试商品','https://x/1','collecting',?,?)`, now, now); err != nil {
t.Fatal(err)
}
if _, err := db.Exec(`
INSERT INTO tasks (task_id, task_type, status, assigned_client, goods_id,
pdd_goods_url, created_at, updated_at)
VALUES ('TASK-C','collect','assigned','client-001','G-1','https://x/1',?,?)`,
INSERT INTO pdd_products (goods_id, url, collect_status, created_at, updated_at)
VALUES (?, ?, 'collecting', ?, ?)`,
pddGoodsID, "https://mobile.yangkeduo.com/goods.html?goods_id="+pddGoodsID,
now, now); err != nil {
t.Fatal(err)
t.Fatalf("插入 PDD 商品失败: %v", err)
}
}
// insertCollectTask 造一个采集任务,关联指定的蝦皮商品和 PDD 商品。
func insertCollectTask(t *testing.T, db *sql.DB, taskID, client, shopeeGoodsID, pddGoodsID string) {
t.Helper()
now := model.NowISO()
if _, err := db.Exec(`
INSERT INTO tasks (task_id, task_type, status, assigned_client,
goods_id, pdd_goods_id, pdd_goods_url, created_at, updated_at)
VALUES (?, 'collect', 'assigned', ?, ?, ?, 'https://x/1', ?, ?)`,
taskID, client, shopeeGoodsID, pddGoodsID, now, now); err != nil {
t.Fatalf("插入采集任务失败: %v", err)
}
}
// pddProductState 读回 PDD 商品的采集状态、标题和结果。
func pddProductState(t *testing.T, db *sql.DB, goodsID string) (status, title, skus, msg string) {
t.Helper()
var ti, sk, ms sql.NullString
if err := db.QueryRow(`
SELECT collect_status, title, skus_json, collect_msg
FROM pdd_products WHERE goods_id = ?`, goodsID,
).Scan(&status, &ti, &sk, &ms); err != nil {
t.Fatalf("查询 PDD 商品失败: %v", err)
}
return status, ti.String, sk.String, ms.String
}
const collectBody = `{"task_version":1,"attempt_id":"a-1","result_type":"collect",
"pdd_data":{"schema_version":1,"goods_id":"PDD-1","title":"测试商品",
"dimensions":[{"key":"color","name":"颜色分类"}],
"skus":[{"options":{"color":"黑色","size":"M"},"price_cent":1256,
"available":true,"raw_price":"¥12.56"}]}}`
func TestSubmitResult_采集结果写入PDD商品(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 := `{"task_version":1,"attempt_id":"a-1","result_type":"collect","pdd_data":{"schema_version":1,"skus":[]}}`
if _, err := SubmitResult(db, "TASK-C", "client-001", "key-c", []byte(body)); err != nil {
if _, err := SubmitResult(db, "TASK-C", "client-001", "key-c", []byte(collectBody)); err != nil {
t.Fatalf("提交采集结果失败: %v", err)
}
var status, data string
if err := db.QueryRow(
`SELECT collect_status, COALESCE(pdd_data,'') FROM shopee_products WHERE goods_id='G-1'`,
).Scan(&status, &data); err != nil {
t.Fatal(err)
}
status, title, skus, _ := pddProductState(t, db, "PDD-1")
if status != "collected" {
t.Errorf("采集状态应为 collected,实际 %s", status)
}
if data == "" {
t.Error("pdd_data 应被写入商品级,实际为空")
if title != "测试商品" {
t.Errorf("标题应从采集结果里取出来,实际 %q", title)
}
if skus == "" {
t.Error("skus_json 应被写入,实际为空")
}
}
// 采到 0 个规格必须算失败——数据对业务毫无用处,
// 显示"已采集"会让操作员以为好了,等建任务时才发现不对。
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":{"schema_version":1,"goods_id":"PDD-1","skus":[]}}`
if _, err := SubmitResult(db, "TASK-C", "client-001", "key-c", []byte(body)); err != nil {
t.Fatalf("提交不该报错,应该记成采集失败: %v", err)
}
status, _, _, msg := pddProductState(t, db, "PDD-1")
if status != "failed" {
t.Errorf("采到 0 个规格应记为 failed,实际 %s", status)
}
if msg == "" {
t.Error("应写明失败原因,否则操作员不知道为什么")
}
}
// 客户端采回来的商品和请求的对不上时必须拒绝——
// 不拦就会把 B 的规格价格存到 A 名下,之后按它下单就是买错东西。
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")
// 请求采 PDD-1,客户端却返回了 PDD-999
body := `{"attempt_id":"a-1","result_type":"collect",
"pdd_data":{"schema_version":1,"goods_id":"PDD-999","title":"别的商品",
"skus":[{"options":{"color":"黑色"},"price_cent":100,"available":true}]}}`
_, err := SubmitResult(db, "TASK-C", "client-001", "key-c", []byte(body))
if !errors.Is(err, ErrCollectMismatch) {
t.Fatalf("期望 ErrCollectMismatch,实际 %v", err)
}
// 事务应整体回滚,PDD 商品不能被写脏
status, _, skus, _ := pddProductState(t, db, "PDD-1")
if status == "collected" || skus != "" {
t.Errorf("采错商品时不该写入任何结果,实际 status=%s skus=%q", status, skus)
}
}
@@ -321,14 +397,10 @@ func TestSubmitFailure_非法状态被拒绝(t *testing.T) {
}
}
func TestSubmitFailure_采集失败写回商品级(t *testing.T) {
func TestSubmitFailure_采集失败写回PDD商品(t *testing.T) {
db := newTestDB(t)
now := model.NowISO()
db.Exec(`INSERT INTO shopee_products (goods_id,title,collect_status,created_at,updated_at)
VALUES ('G-1','测试商品','collecting',?,?)`, now, now)
db.Exec(`INSERT INTO tasks (task_id,task_type,status,assigned_client,goods_id,
pdd_goods_url,created_at,updated_at)
VALUES ('TASK-C','collect','assigned','client-001','G-1','https://x/1',?,?)`, now, now)
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","status":"failed",
@@ -337,14 +409,12 @@ func TestSubmitFailure_采集失败写回商品级(t *testing.T) {
t.Fatalf("提交失败: %v", err)
}
var status, errMsg string
db.QueryRow(`SELECT collect_status, COALESCE(collect_error,'')
FROM shopee_products WHERE goods_id='G-1'`).Scan(&status, &errMsg)
status, _, _, msg := pddProductState(t, db, "PDD-1")
if status != "failed" {
t.Errorf("采集状态应为 failed,实际 %s", status)
}
if errMsg == "" {
t.Error("失败原因应写入 collect_error,否则操作员看不到为什么采不到")
if msg == "" {
t.Error("失败原因应写入 collect_msg,否则操作员看不到为什么采不到")
}
}