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
+55
View File
@@ -0,0 +1,55 @@
package service
import (
"encoding/json"
"fmt"
)
// OptionKey 把一个规格组合变成**稳定的字符串标识**。
//
// {"size":"M","color":"黑色"} -> {"color":"黑色","size":"M"}
// {"color":"黑色","size":"M"} -> {"color":"黑色","size":"M"} (同一个结果)
//
// # 为什么需要它
//
// 采集回来的 PDD 数据里**没有 SKU 编号**,一个规格只能靠它的
// options 组合来认。而 JSON 对象的键是无序的:
//
// 存映射时:{"color":"黑色","size":"M"}
// 采回来时:{"size":"M","color":"黑色"}
//
// 这两个是同一个规格,但字符串不相等。直接比原始 JSON 会匹配不上,
// 而且是**静默失效**——不报错,只是查不到,最后表现为"明明匹配过却说待匹配"。
//
// # 为什么只能有这一处实现
//
// 存映射用它算 key,查规格也用它算 key,两边必须**逐字节一致**。
// 如果 Client 那边也算一份、或者别处再写一个"差不多"的版本,
// 只要有一点点不同(空格、转义、键序),映射就会静默对不上。
// 所以:**Client 只上报 options 对象,key 一律由 Admin 这一个函数算。**
//
// 实现上直接用 json.Marshal —— Go 序列化 map 时会**按键名排序**,
// 这正好就是我们要的规范化,不用自己拼字符串(自己拼容易漏掉
// 值里含分隔符、含引号之类的边界情况)。
func OptionKey(options map[string]string) (string, error) {
if len(options) == 0 {
return "", fmt.Errorf("规格组合不能为空")
}
buf, err := json.Marshal(options)
if err != nil {
return "", fmt.Errorf("规格组合无法序列化: %w", err)
}
return string(buf), nil
}
// OptionKeyFromJSON 从原始 options JSON 算出 key。
//
// 用于处理采集回来的数据:先解析成 map,再走 OptionKey,
// 这样键序、空格、缩进的差异都会被抹平。
func OptionKeyFromJSON(raw string) (string, error) {
var options map[string]string
if err := json.Unmarshal([]byte(raw), &options); err != nil {
return "", fmt.Errorf("options 不是合法的字符串对象: %w", err)
}
return OptionKey(options)
}
+322
View File
@@ -0,0 +1,322 @@
package service
import (
"database/sql"
"testing"
"cmautobuy/admin/model"
"cmautobuy/admin/repository"
)
// ── OptionKey ──────────────────────────────────────────
// 键序不同的同一个规格必须算出同一个 key。
// 这是整个匹配机制的地基:采集回来的 JSON 键序和存映射时不一定一样,
// 算不出同一个 key,匹配就会**静默失效**——不报错,只是查不到。
func TestOptionKey_键序不影响结果(t *testing.T) {
a, err := OptionKey(map[string]string{"color": "黑色", "size": "M"})
if err != nil {
t.Fatal(err)
}
b, err := OptionKey(map[string]string{"size": "M", "color": "黑色"})
if err != nil {
t.Fatal(err)
}
if a != b {
t.Errorf("同一个规格算出了不同的 key:\n%s\n%s", a, b)
}
}
func TestOptionKey_不同规格算出不同key(t *testing.T) {
a, _ := OptionKey(map[string]string{"color": "黑色", "size": "M"})
b, _ := OptionKey(map[string]string{"color": "黑色", "size": "L"})
if a == b {
t.Error("不同规格不该算出同一个 key")
}
}
// 维度数量不固定——可能有第三个维度(款式),key 要能容纳。
func TestOptionKey_支持任意多个维度(t *testing.T) {
k, err := OptionKey(map[string]string{"color": "黑色", "size": "M", "style": "加绒"})
if err != nil {
t.Fatalf("三个维度应该合法: %v", err)
}
if k == "" {
t.Error("key 不该为空")
}
}
func TestOptionKey_空组合报错(t *testing.T) {
if _, err := OptionKey(map[string]string{}); err == nil {
t.Error("空规格组合应该报错,不能算出一个看起来正常的 key")
}
}
// 从 JSON 原文算 key,和从 map 算的结果必须一致——
// 存映射走一条路径、查规格走另一条路径,两边算不出同一个值就白搭。
func TestOptionKeyFromJSON_与map版本一致(t *testing.T) {
fromMap, _ := OptionKey(map[string]string{"color": "黑色", "size": "M"})
fromJSON, err := OptionKeyFromJSON(`{"size":"M","color":"黑色"}`)
if err != nil {
t.Fatal(err)
}
if fromMap != fromJSON {
t.Errorf("两条路径算出不同的 key:\nmap %s\njson %s", fromMap, fromJSON)
}
}
// ── PDD 商品的建立与复活 ───────────────────────────────
func TestEnsurePddProduct_新建(t *testing.T) {
db := newTestDB(t)
p, err := repository.EnsurePddProduct(db, "PDD-1", "https://x/1")
if err != nil {
t.Fatalf("新建失败: %v", err)
}
if p.GoodsID != "PDD-1" {
t.Errorf("goods_id 不对: %s", p.GoodsID)
}
if p.CollectStatus != model.CollectPending {
t.Errorf("新建的商品应为待采集,实际 %s", p.CollectStatus)
}
}
// 同一个商品重复保存链接,不能产生第二行——
// goods_id 上有 UNIQUE,重复了会直接插入失败。
func TestEnsurePddProduct_重复保存不产生重复行(t *testing.T) {
db := newTestDB(t)
for i := 0; i < 3; i++ {
if _, err := repository.EnsurePddProduct(db, "PDD-1", "https://x/1"); err != nil {
t.Fatalf("第 %d 次保存失败: %v", i+1, err)
}
}
var n int
db.QueryRow(`SELECT COUNT(*) FROM pdd_products WHERE goods_id='PDD-1'`).Scan(&n)
if n != 1 {
t.Errorf("应只有 1 行,实际 %d 行", n)
}
}
// 已采集的商品再次保存链接,不能把采集结果冲掉。
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 {
t.Fatal(err)
}
if _, err := repository.EnsurePddProduct(db, "PDD-1", "https://x/1?from=share"); err != nil {
t.Fatalf("再次保存失败: %v", err)
}
p, _ := repository.GetPddProductByGoodsID(db, "PDD-1")
if p.CollectStatus != model.CollectCollected {
t.Errorf("采集状态被冲掉了,实际 %s", p.CollectStatus)
}
if p.SkusJSON == "" {
t.Error("采集结果被清空了")
}
if p.URL != "https://x/1?from=share" {
t.Errorf("链接原文应更新,实际 %s", p.URL)
}
}
// 软删除后再填同一个链接要能复活。
// 不复活的话,goods_id 上的 UNIQUE 会让插入失败,
// 操作员会看到一个莫名其妙的错误。
func TestEnsurePddProduct_软删除后可复活(t *testing.T) {
db := newTestDB(t)
repository.EnsurePddProduct(db, "PDD-1", "https://x/1")
repository.SetCollectResult(db, "PDD-1", "商品甲", `{"skus":[1]}`)
if err := repository.SoftDeletePddProduct(db, "PDD-1"); err != nil {
t.Fatal(err)
}
p, _ := repository.GetPddProductByGoodsID(db, "PDD-1")
if !p.IsDeleted() {
t.Fatal("前置条件不对,应已软删除")
}
revived, err := repository.EnsurePddProduct(db, "PDD-1", "https://x/1")
if err != nil {
t.Fatalf("复活失败: %v", err)
}
if revived.IsDeleted() {
t.Error("复活后不该还带删除标记")
}
if revived.CollectStatus != model.CollectPending {
t.Errorf("复活后应回到待采集,实际 %s", revived.CollectStatus)
}
// 删过一次的记录,旧采集结果不能再当有效的用
if revived.SkusJSON != "" {
t.Error("复活后应清空旧采集结果,避免用到删除前的过期数据")
}
var n int
db.QueryRow(`SELECT COUNT(*) FROM pdd_products WHERE goods_id='PDD-1'`).Scan(&n)
if n != 1 {
t.Errorf("复活不该新增行,实际 %d 行", n)
}
}
// ── 采集中不允许重复发起 ───────────────────────────────
func TestMarkCollecting_采集中不允许再次发起(t *testing.T) {
db := newTestDB(t)
repository.EnsurePddProduct(db, "PDD-1", "https://x/1")
ok, err := repository.MarkCollecting(db, "PDD-1")
if err != nil || !ok {
t.Fatalf("第一次应成功: ok=%v err=%v", ok, err)
}
ok, err = repository.MarkCollecting(db, "PDD-1")
if err != nil {
t.Fatal(err)
}
if ok {
t.Error("已在采集中,不该允许再建一个采集任务")
}
}
func TestMarkCollecting_失败后可重新采集(t *testing.T) {
db := newTestDB(t)
repository.EnsurePddProduct(db, "PDD-1", "https://x/1")
repository.SetCollectFailed(db, "PDD-1", "页面超时", "")
ok, err := repository.MarkCollecting(db, "PDD-1")
if err != nil {
t.Fatal(err)
}
if !ok {
t.Error("采集失败的商品应该允许重新采集")
}
}
// ── 映射隔离:本工单的安全核心 ─────────────────────────
// insertShopeeSKU 造蝦皮商品和它的一个规格。
// sku_mappings 有外键指向 shopee_skus,映射相关的测试都要先造它。
func insertShopeeSKU(t *testing.T, db *sql.DB, goodsID, skuID string) {
t.Helper()
now := model.NowISO()
if _, err := db.Exec(`
INSERT INTO shopee_products (goods_id, title, created_at, updated_at)
VALUES (?, '测试商品', ?, ?)
ON CONFLICT(goods_id) DO NOTHING`, goodsID, now, now); err != nil {
t.Fatalf("插入蝦皮商品失败: %v", err)
}
if _, err := db.Exec(`
INSERT INTO shopee_skus (sku_id, goods_id, spec_raw, created_at, updated_at)
VALUES (?, ?, '黑色,M', ?, ?)`, skuID, goodsID, now, now); err != nil {
t.Fatalf("插入蝦皮规格失败: %v", err)
}
}
// insertMapping 造一条规格映射。
func insertMapping(t *testing.T, db *sql.DB, shopeeSKU, pddGoodsID string, options map[string]string) {
t.Helper()
key, err := OptionKey(options)
if err != nil {
t.Fatal(err)
}
if _, err := db.Exec(`
INSERT INTO sku_mappings (shopee_sku_id, pdd_goods_id, pdd_option_key,
pdd_options, goods_id, mapped_at)
VALUES (?, ?, ?, ?, 'SHOPEE-1', ?)`,
shopeeSKU, pddGoodsID, key, key, model.NowISO()); err != nil {
t.Fatalf("插入映射失败: %v", err)
}
}
// findMapping 模拟业务查询:**永远带上当前的 PDD 商品**。
// 这个条件就是隔离机制本身,少了它旧映射就会被误用。
func findMapping(t *testing.T, db *sql.DB, shopeeSKU, currentPddGoodsID string) (string, bool) {
t.Helper()
var key string
err := db.QueryRow(`
SELECT pdd_option_key FROM sku_mappings
WHERE shopee_sku_id = ? AND pdd_goods_id = ?`,
shopeeSKU, currentPddGoodsID).Scan(&key)
if err == sql.ErrNoRows {
return "", false
}
if err != nil {
t.Fatal(err)
}
return key, true
}
// 换了 PDD 商品之后,旧映射必须查不到。
//
// 不隔离的话:B 恰好也有"黑色/M码"这个组合,但完全是另一件衣服,
// 就会**静默买错东西**,而且事后查不出来。
func TestSKUMapping_换PDD商品后查不到旧映射(t *testing.T) {
db := newTestDB(t)
insertShopeeSKU(t, db, "SHOPEE-1", "SKU-1")
repository.EnsurePddProduct(db, "PDD-A", "https://x/a")
repository.EnsurePddProduct(db, "PDD-B", "https://x/b")
// 蝦皮 SKU 原来匹配到 PDD 商品 A 的"黑色/M码"
insertMapping(t, db, "SKU-1", "PDD-A", map[string]string{"color": "黑色", "size": "M码"})
if _, ok := findMapping(t, db, "SKU-1", "PDD-A"); !ok {
t.Fatal("前置条件不对:A 的映射应该查得到")
}
// A 下架,换成 B。查 B 的映射应该查不到 —— 界面显示"待匹配"
if key, ok := findMapping(t, db, "SKU-1", "PDD-B"); ok {
t.Errorf("换成 B 之后不该查到映射,却拿到了 %q —— "+
"这正是会导致买错商品的情况", key)
}
}
// 换回原来的 PDD 商品,旧映射要还能用。
// 这是把 pdd_goods_id 放进主键(而不是换商品时删掉旧映射)换来的好处:
// PDD 商品补货换回去时,之前的人工匹配成果直接复用。
func TestSKUMapping_换回原商品旧映射仍可用(t *testing.T) {
db := newTestDB(t)
insertShopeeSKU(t, db, "SHOPEE-1", "SKU-1")
repository.EnsurePddProduct(db, "PDD-A", "https://x/a")
repository.EnsurePddProduct(db, "PDD-B", "https://x/b")
insertMapping(t, db, "SKU-1", "PDD-A", map[string]string{"color": "黑色", "size": "M码"})
// 换到 B,也匹配一次
insertMapping(t, db, "SKU-1", "PDD-B", map[string]string{"color": "黑色", "size": "M"})
// 换回 A
key, ok := findMapping(t, db, "SKU-1", "PDD-A")
if !ok {
t.Fatal("换回 A 之后,A 的旧映射应该还在")
}
expect, _ := OptionKey(map[string]string{"color": "黑色", "size": "M码"})
if key != expect {
t.Errorf("拿到的是别的商品的映射:期望 %s,实际 %s", expect, key)
}
}
// 同一个蝦皮 SKU 可以同时有多个 PDD 商品的映射,互不干扰。
func TestSKUMapping_同一SKU可对多个PDD商品各有映射(t *testing.T) {
db := newTestDB(t)
insertShopeeSKU(t, db, "SHOPEE-1", "SKU-1")
repository.EnsurePddProduct(db, "PDD-A", "https://x/a")
repository.EnsurePddProduct(db, "PDD-B", "https://x/b")
insertMapping(t, db, "SKU-1", "PDD-A", map[string]string{"color": "黑色", "size": "M码"})
insertMapping(t, db, "SKU-1", "PDD-B", map[string]string{"color": "黑", "size": "均码"})
var n int
db.QueryRow(`SELECT COUNT(*) FROM sku_mappings WHERE shopee_sku_id='SKU-1'`).Scan(&n)
if n != 2 {
t.Errorf("应能同时存在 2 条映射,实际 %d 条", n)
}
kA, _ := findMapping(t, db, "SKU-1", "PDD-A")
kB, _ := findMapping(t, db, "SKU-1", "PDD-B")
if kA == kB {
t.Error("两个 PDD 商品的映射不该相同")
}
}
+78 -12
View File
@@ -20,8 +20,47 @@ var (
ErrNeverClaimed = errors.New("该任务从未分配给这个客户端")
// ErrIdempotencyConflict 同一个键提交了不同内容 -> 409
ErrIdempotencyConflict = repository.ErrIdempotencyConflict
// ErrCollectMismatch 客户端采回来的商品不是请求的那个 -> 422
ErrCollectMismatch = errors.New("采集结果与请求的商品不一致")
)
// collectedData 是采集结果里 Admin 关心的那几项。
//
// 客户端提交的完整内容会原样存进 tasks.result_data,这里只挑业务要用的解析出来。
// 用不到的字段不写进结构体,多余的 JSON 字段会被忽略,不影响向前兼容。
type collectedData struct {
GoodsID string `json:"goods_id"`
Title string `json:"title"`
SKUs []struct {
Options map[string]string `json:"options"`
PriceCent *int64 `json:"price_cent"` // 指针:采不到价格时是 null,不是 0
Available bool `json:"available"`
RawPrice string `json:"raw_price"`
} `json:"skus"`
}
// parseCollected 解析采集结果。解析不了直接报错,不要当成"采到 0 个规格"——
// 那是两回事:一个是客户端发的东西有问题,一个是商品确实没规格。
func parseCollected(raw string) (*collectedData, error) {
var c collectedData
if err := json.Unmarshal([]byte(raw), &c); err != nil {
return nil, fmt.Errorf("采集结果不是合法结构: %w", err)
}
return &c, nil
}
// artifactRef 从失败上报里取诊断产物位置。
//
// 按已定案的 Artifact 策略(docs/client/04 §10 待确认 #3),
// 客户端**只报本地引用、不上传文件**。所以这里存的是一个位置字符串,
// 告诉操作员去哪台客户端的哪个目录捞截图,Admin 自己显示不了图。
func artifactRef(req FailureRequest) string {
if len(req.Diagnostics) == 0 {
return ""
}
return string(req.Diagnostics)
}
// ResultRequest 是客户端提交成功结果的请求体。
type ResultRequest struct {
TaskVersion int `json:"task_version"`
@@ -70,7 +109,7 @@ func SubmitResult(db *sql.DB, taskID, clientID, idemKey string, rawBody []byte)
}
return submitInTx(db, taskID, clientID, idemKey, rawBody,
func(tx *sql.Tx, taskType model.TaskType, goodsID string) (map[string]any, error) {
func(tx *sql.Tx, info *repository.TaskInfo) (map[string]any, error) {
pddData := string(req.PddData)
if strings.TrimSpace(pddData) == "" {
pddData = "{}"
@@ -79,9 +118,35 @@ func SubmitResult(db *sql.DB, taskID, clientID, idemKey string, rawBody []byte)
if err := repository.MarkTaskSucceeded(tx, taskID, pddData); err != nil {
return nil, err
}
// 采集任务的结果还要落到商品级,供后续规格匹配使用
if taskType == model.TaskCollect {
if err := repository.SetCollectResult(tx, goodsID, pddData); err != nil {
// 采集任务的结果还要落到 PDD 商品上,供后续规格匹配使用。
// 注意用的是 PddGoodsID —— 被采集的是 PDD 商品,不是蝦皮商品。
if info.TaskType == model.TaskCollect {
collected, err := parseCollected(pddData)
if err != nil {
return nil, err
}
// 校验一:客户端采回来的商品必须就是我们要的那个。
// 链接跳转、采错商品时如果不拦,会把 B 的规格价格
// 存到 A 名下,后面按它下单就是买错东西。
if info.PddGoodsID != "" && collected.GoodsID != "" &&
collected.GoodsID != info.PddGoodsID {
return nil, fmt.Errorf(
"%w: 请求采集的是商品 %s,客户端返回的却是 %s",
ErrCollectMismatch, info.PddGoodsID, collected.GoodsID)
}
// 校验二:一个规格都没采到,对业务毫无用处
// (商品下架、页面改版、解析器没认出来)。
// 这种情况必须算失败,不能显示"已采集"让操作员空欢喜。
if len(collected.SKUs) == 0 {
if err := repository.SetCollectFailed(tx, info.PddGoodsID,
"未采集到任何规格,请检查商品是否已下架", ""); err != nil {
return nil, err
}
} else if err := repository.SetCollectResult(
tx, info.PddGoodsID, collected.Title, pddData); err != nil {
return nil, err
}
}
@@ -109,18 +174,19 @@ func SubmitFailure(db *sql.DB, taskID, clientID, idemKey string, rawBody []byte)
}
return submitInTx(db, taskID, clientID, idemKey, rawBody,
func(tx *sql.Tx, taskType model.TaskType, goodsID string) (map[string]any, error) {
func(tx *sql.Tx, info *repository.TaskInfo) (map[string]any, error) {
if err := repository.MarkTaskFailure(
tx, taskID, newStatus, req.Error.Code, req.Error.Message); err != nil {
return nil, err
}
// 采集失败要让操作员在蝦皮数据页看得见原因
if taskType == model.TaskCollect {
// 采集失败要让操作员看得见原因和诊断产物位置
if info.TaskType == model.TaskCollect {
msg := req.Error.Message
if req.Error.Code != "" {
msg = req.Error.Code + ": " + msg
}
if err := repository.SetCollectFailed(tx, goodsID, msg); err != nil {
if err := repository.SetCollectFailed(
tx, info.PddGoodsID, msg, artifactRef(req)); err != nil {
return nil, err
}
}
@@ -157,7 +223,7 @@ func mapFailureStatus(reported string) (model.TaskStatus, bool) {
// 业务写入部分由 apply 提供,它拿到的 tx 和外层是同一个。
func submitInTx(
db *sql.DB, taskID, clientID, idemKey string, rawBody []byte,
apply func(tx *sql.Tx, taskType model.TaskType, goodsID string) (map[string]any, error),
apply func(tx *sql.Tx, info *repository.TaskInfo) (map[string]any, error),
) (string, error) {
hash := repository.HashRequest(rawBody)
@@ -176,11 +242,11 @@ func submitInTx(
}
// 2. 任务得存在
exists, taskType, goodsID, err := repository.TaskExists(tx, taskID)
info, err := repository.GetTaskInfo(tx, taskID)
if err != nil {
return "", err
}
if !exists {
if info == nil {
return "", ErrTaskNotFound
}
@@ -195,7 +261,7 @@ func submitInTx(
}
// 4. 业务写入
resp, err := apply(tx, taskType, goodsID)
resp, err := apply(tx, info)
if err != nil {
return "", err
}
+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,否则操作员看不到为什么采不到")
}
}