原来 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>
323 lines
11 KiB
Go
323 lines
11 KiB
Go
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 商品的映射不该相同")
|
||
}
|
||
}
|