feat: PDD 商品页面 (#18)

打通 Client↔Admin 闭环的一环:录入 PDD 链接 → 创建采集任务 →
客户端领走去采 → 规格和价格显示在页面上。#16 写的 MarkCollecting 和
SoftDeletePddProduct 至此才有生产调用方。

链接解析严格、不做容错兜底:goods_id 上有 UNIQUE 约束,防重全靠它。
猜一个的话同一商品会存成好几行、采好几遍,规格映射还说不清指向哪一行。
短链一律拒绝,卡域名是为了拦"粘了淘宝链接"这种失误。

创建采集任务先占状态再建任务:MarkCollecting 只在 pending/failed 时成功,
同时充当"有没有人已经在采"的判断,与建任务在同一事务里,
所以并发点多次只会建出一个任务(实测 4 并发 → 1 条)。

submit.go 的 collectedData 增加 Dimensions —— 没有它就只能按 Go 的 map
遍历,而 map 无序,同一商品每次刷新"颜色/尺码"的先后都可能变。

顺带修 #16 一处缺陷:EnsurePddProduct 复活分支清空了 skus_json 却漏了
title,导致复活后状态显示"未采集"但标题还留着旧值。

审查打回一次:清理"PDD 链接唯一的录入口"这一过期说法(全库 5 处),
以及 shopee.go 里"空 → no_link"的过期 TODO —— no_link 已在 #16
从 CHECK 约束删除,照写会直接撞约束。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
chengma
2026-08-07 11:27:06 +08:00
co-authored by Claude Opus 5
parent fab20cfd2f
commit eb8d357f5a
16 changed files with 2333 additions and 72 deletions
+24
View File
@@ -262,3 +262,27 @@ func MarkTaskFailure(q Execer, taskID string, newStatus model.TaskStatus, errCod
}
return nil
}
// InsertCollectTask 建一条采集任务。
//
// `[必须]` 采集任务**不指定客户端**:assigned_client 为 NULL、status 为 pending,
// 谁领到就在领取时标记谁(见 #17 和 ClaimNextTask)。采集是纯读取操作,
// 哪台机器跑都一样,指定了反而会在那台机器关着的时候干等。
//
// goodsURL 必填 —— Client 契约里 pdd_goods_url 是 NOT NULL,
// 没有它客户端拿到任务也不知道去哪采。
func InsertCollectTask(q Execer, taskID, goodsID, goodsURL string) error {
if goodsID == "" || goodsURL == "" {
return fmt.Errorf("采集任务的商品 ID 和链接都不能为空")
}
now := model.NowISO()
_, err := q.Exec(`
INSERT INTO tasks (task_id, task_type, status, assigned_client,
pdd_goods_url, pdd_goods_id, created_at, updated_at)
VALUES (?, 'collect', 'pending', NULL, ?, ?, ?, ?)`,
taskID, goodsURL, goodsID, now, now)
if err != nil {
return fmt.Errorf("创建商品 %s 的采集任务失败: %w", goodsID, err)
}
return nil
}