Files
cmautobuy/admin/service/pdd_test.go
T
chengmaandClaude Opus 5 998c06a2bf 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>
2026-08-07 10:27:39 +08:00

323 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 商品的映射不该相同")
}
}