package service import ( "database/sql" "errors" "fmt" "strings" "sync" "testing" "time" "cmautobuy/admin/model" "cmautobuy/admin/repository" ) func TestListPddProducts_统一每页20条并收敛越界页(t *testing.T) { db := newTestDB(t) for i := 0; i < 25; i++ { goodsID := fmt.Sprintf("73711653%04d", i) if _, err := CreatePddProduct(db, "https://mobile.yangkeduo.com/goods.html?goods_id="+goodsID); err != nil { t.Fatalf("创建第 %d 个商品失败: %v", i, err) } } second, err := ListPddProducts(db, "", "", 2) if err != nil { t.Fatal(err) } if len(second.Rows) != 5 || second.FilteredTotal != 25 || second.Page != 2 || second.TotalPages != 2 { t.Fatalf("第二页结果不对: rows=%d total=%d page=%d/%d", len(second.Rows), second.FilteredTotal, second.Page, second.TotalPages) } overflow, err := ListPddProducts(db, "", "", 999) if err != nil { t.Fatal(err) } if overflow.Page != 2 || len(overflow.Rows) != 5 { t.Fatalf("越界页应收敛到末页: page=%d rows=%d", overflow.Page, len(overflow.Rows)) } } // ── 链接解析 ─────────────────────────────────────────── func TestParsePddGoodsID_认得的写法(t *testing.T) { cases := []struct { name string url string want string }{ {"标准商品页", "https://mobile.yangkeduo.com/goods.html?goods_id=737116531267", "737116531267"}, {"goods2 带一堆参数", "https://mobile.yangkeduo.com/goods2.html?_x_org=2&goods_id=737116531267&refer_page=1", "737116531267"}, {"优惠券落地页用的是 _x_goods_id", "https://yangkeduo.com/duo_coupon_landing.html?_x_goods_id=737116531267", "737116531267"}, {"pinduoduo.com 域名", "https://mobile.pinduoduo.com/goods.html?goods_id=737116531267", "737116531267"}, {"http 也认", "http://mobile.yangkeduo.com/goods.html?goods_id=737116531267", "737116531267"}, {"前后有空格", " https://mobile.yangkeduo.com/goods.html?goods_id=737116531267 ", "737116531267"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { got, err := ParsePddGoodsID(tc.url) if err != nil { t.Fatalf("应该解析成功: %v", err) } if got != tc.want { t.Errorf("goods_id = %q,期望 %q", got, tc.want) } }) } } // 解析不出来必须报错。容错兜底会让同一个商品存成好几行, // 采好几遍,规格映射还说不清指向哪一行。 func TestParsePddGoodsID_认不出的一律报错(t *testing.T) { cases := []struct{ name, url string }{ {"空串", ""}, {"只有空格", " "}, {"短链没有 goods_id", "https://p.pinduoduo.com/AbCdEfGh"}, {"不是拼多多", "https://item.taobao.com/item.htm?id=737116531267"}, {"不带协议", "mobile.yangkeduo.com/goods.html?goods_id=737116531267"}, {"goods_id 不是数字", "https://mobile.yangkeduo.com/goods.html?goods_id=abc123456"}, {"goods_id 太短像是被截断", "https://mobile.yangkeduo.com/goods.html?goods_id=123"}, {"goods_id 为空", "https://mobile.yangkeduo.com/goods.html?goods_id="}, {"域名只是长得像", "https://yangkeduo.com.evil.example/goods.html?goods_id=737116531267"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { got, err := ParsePddGoodsID(tc.url) if !errors.Is(err, ErrBadPddURL) { t.Fatalf("期望 ErrBadPddURL,实际 err=%v got=%q", err, got) } // 报错要说清下一步该怎么办,光说"无效"操作员不知道改什么 if len(err.Error()) < len("PDD 链接无效")+4 { t.Errorf("错误信息太笼统: %q", err.Error()) } }) } } // ── 价格显示 ─────────────────────────────────────────── // price_cent 为 null 显示"未采到",**不能显示 ¥0.00**。 // 0 元和采不到价格是两回事,而这个数是要参与价格保护比对的。 func TestFormatPriceCent(t *testing.T) { cent := func(v int64) *int64 { return &v } cases := []struct { in *int64 want string }{ {nil, "未采到"}, {cent(1256), "¥12.56"}, {cent(0), "¥0.00"}, {cent(5), "¥0.05"}, {cent(100), "¥1.00"}, {cent(123456), "¥1234.56"}, } for _, tc := range cases { if got := formatPriceCent(tc.in); got != tc.want { t.Errorf("formatPriceCent(%v) = %q,期望 %q", tc.in, got, tc.want) } } } // ── 创建 ─────────────────────────────────────────────── func TestCreatePddProduct_只填链接即可(t *testing.T) { db := newTestDB(t) goodsID, err := CreatePddProduct(db, "https://mobile.yangkeduo.com/goods.html?goods_id=737116531267") if err != nil { t.Fatalf("创建失败: %v", err) } if goodsID != "737116531267" { t.Errorf("goods_id = %q", goodsID) } p, _ := repository.GetPddProductByGoodsID(db, goodsID) if p == nil { t.Fatal("应该建出一行") } if p.CollectStatus != model.CollectPending { t.Errorf("新建的状态应为 pending,实际 %s", p.CollectStatus) } if p.Title != "" || p.SkusJSON != "" { t.Error("标题和规格应该留空,等采集回填") } } func TestCreatePddProduct_链接解析不出就不写库(t *testing.T) { db := newTestDB(t) if _, err := CreatePddProduct(db, "https://p.pinduoduo.com/AbCdEfGh"); !errors.Is(err, ErrBadPddURL) { t.Fatalf("期望 ErrBadPddURL,实际 %v", err) } var n int db.QueryRow(`SELECT COUNT(*) FROM pdd_products`).Scan(&n) if n != 0 { t.Errorf("解析失败时不该写库,实际有 %d 行", n) } } func TestCreatePddProduct_重复创建不产生第二行(t *testing.T) { db := newTestDB(t) url := "https://mobile.yangkeduo.com/goods.html?goods_id=737116531267" for i := 0; i < 3; i++ { if _, err := CreatePddProduct(db, url); err != nil { t.Fatalf("第 %d 次创建失败: %v", i+1, err) } } var n int db.QueryRow(`SELECT COUNT(*) FROM pdd_products`).Scan(&n) if n != 1 { t.Errorf("同一个商品应该只有 1 行,实际 %d 行", n) } } // 软删除后重新创建同一链接要复活原行,并且**旧采集结果被清空**。 // 记录被删过一次,旧数据不该再当有效的用。 func TestCreatePddProduct_删除后重新创建复活且清空采集结果(t *testing.T) { db := newTestDB(t) url := "https://mobile.yangkeduo.com/goods.html?goods_id=737116531267" goodsID, _ := CreatePddProduct(db, url) if err := repository.SetCollectResult(db, goodsID, "旧标题", "", sampleSkusJSON); err != nil { t.Fatalf("写采集结果失败: %v", err) } before, _ := repository.GetPddProductByGoodsID(db, goodsID) if _, err := DeletePddProducts(db, []string{goodsID}); err != nil { t.Fatalf("删除失败: %v", err) } if _, err := CreatePddProduct(db, url); err != nil { t.Fatalf("重新创建失败: %v", err) } after, _ := repository.GetPddProductByGoodsID(db, goodsID) if after.ID != before.ID { t.Errorf("应该复活原行(id %d),实际是新行 id %d", before.ID, after.ID) } if after.IsDeleted() { t.Error("复活后 deleted_at 应该清掉") } if after.SkusJSON != "" || after.Title != "" { t.Errorf("复活后采集结果应清空,实际 title=%q skus=%q", after.Title, after.SkusJSON) } if after.CollectStatus != model.CollectPending { t.Errorf("复活后状态应回到 pending,实际 %s", after.CollectStatus) } } // ── 编辑链接 ─────────────────────────────────────────── func TestUpdatePddProductURL_同一个商品可以改写法(t *testing.T) { db := newTestDB(t) goodsID, _ := CreatePddProduct(db, "https://mobile.yangkeduo.com/goods.html?goods_id=737116531267") p, _ := repository.GetPddProductByGoodsID(db, goodsID) newURL := "https://mobile.yangkeduo.com/goods2.html?goods_id=737116531267&refer_page=1" if err := UpdatePddProductURL(db, p.ID, newURL); err != nil { t.Fatalf("保存失败: %v", err) } after, _ := repository.GetPddProductByID(db, p.ID) if after.URL != newURL { t.Errorf("链接没保存上: %q", after.URL) } } // 换成另一个商品必须拒绝:这一行上挂着采集结果和 spec_mappings, // goods_id 一换那些数据就全指到错的商品上,之后按它下单就是买错东西。 func TestUpdatePddProductURL_换成别的商品要拒绝(t *testing.T) { db := newTestDB(t) goodsID, _ := CreatePddProduct(db, "https://mobile.yangkeduo.com/goods.html?goods_id=737116531267") p, _ := repository.GetPddProductByGoodsID(db, goodsID) err := UpdatePddProductURL(db, p.ID, "https://mobile.yangkeduo.com/goods.html?goods_id=999888777666") if !errors.Is(err, ErrPddGoodsIDChanged) { t.Fatalf("期望 ErrPddGoodsIDChanged,实际 %v", err) } after, _ := repository.GetPddProductByID(db, p.ID) if after.GoodsID != goodsID { t.Error("拒绝之后 goods_id 不该变") } if !strings.Contains(err.Error(), "创建") { t.Errorf("错误信息要告诉操作员改用「创建」,实际 %q", err.Error()) } } // ── 列表 ─────────────────────────────────────────────── // sampleSkusJSON 是一份采集结果样本,两个维度三个规格,其中一个采不到价格。 const sampleSkusJSON = `{ "schema_version": 1, "goods_id": "737116531267", "title": "西装外套三件套", "dimensions": [ {"key": "color", "name": "颜色分类"}, {"key": "size", "name": "尺码"} ], "skus": [ {"options": {"color": "黑色", "size": "M"}, "price_cent": 1256, "available": true, "raw_price": "¥12.56"}, {"options": {"color": "白色", "size": "M"}, "price_cent": 1256, "available": false, "raw_price": "¥12.56"}, {"options": {"color": "红色", "size": "L"}, "price_cent": null, "available": true, "raw_price": ""} ] }` func createProduct(t *testing.T, db *sql.DB, goodsID string) { t.Helper() if _, err := CreatePddProduct(db, "https://mobile.yangkeduo.com/goods.html?goods_id="+goodsID); err != nil { t.Fatalf("创建商品 %s 失败: %v", goodsID, err) } } // 规格数:未采集显示 —,采到几个就显示几个。 // **采到 0 个要如实显示 0**,那说明采集出了问题,显示成 — 就看不出来了。 func TestListPddProducts_规格数(t *testing.T) { db := newTestDB(t) createProduct(t, db, "100000000001") // 没采过 createProduct(t, db, "100000000002") // 采到 3 个 createProduct(t, db, "100000000003") // 采到 0 个 repository.SetCollectResult(db, "100000000002", "有规格的", "", sampleSkusJSON) repository.SetCollectResult(db, "100000000003", "没规格的", "", `{"skus": []}`) result, err := ListPddProducts(db, "", "", 1) if err != nil { t.Fatalf("查列表失败: %v", err) } want := map[string]string{ "100000000001": placeholder, "100000000002": "3", "100000000003": "0", } for _, row := range result.Rows { if got := row.SkuCountText; got != want[row.GoodsID] { t.Errorf("商品 %s 规格数 = %q,期望 %q", row.GoodsID, got, want[row.GoodsID]) } } } // skus_json 存进了坏数据时,列表页必须还能打开。 // JSON_LENGTH 碰到非法 JSON 会让整条查询报错,那样页面直接白屏。 func TestListPddProducts_坏掉的采集结果不影响列表打开(t *testing.T) { db := newTestDB(t) createProduct(t, db, "100000000001") if _, err := db.Exec( `UPDATE pdd_products SET skus_json = '这不是JSON' WHERE goods_id = ?`, "100000000001"); err != nil { t.Fatalf("造坏数据失败: %v", err) } result, err := ListPddProducts(db, "", "", 1) if err != nil { t.Fatalf("列表页应该照样打得开: %v", err) } if len(result.Rows) != 1 || result.Rows[0].SkuCountText != placeholder { t.Errorf("坏数据的规格数应显示占位符,实际 %+v", result.Rows) } } func TestListPddProducts_按采集状态筛选(t *testing.T) { db := newTestDB(t) createProduct(t, db, "100000000001") createProduct(t, db, "100000000002") repository.SetCollectResult(db, "100000000002", "已采的", "", sampleSkusJSON) collected, err := ListPddProducts(db, "", model.CollectCollected, 1) if err != nil { t.Fatalf("筛选失败: %v", err) } if len(collected.Rows) != 1 || collected.Rows[0].GoodsID != "100000000002" { t.Errorf("按「已采集」筛选应只剩 1 条,实际 %d 条", len(collected.Rows)) } if !collected.IsFiltered { t.Error("IsFiltered 应为 true,空状态文案要靠它分情况") } // 统计不受筛选影响:状态条是全局概览 if collected.Total != 2 { t.Errorf("Total 应是全部 2 条,不该跟着筛选变,实际 %d", collected.Total) } } func TestListPddProducts_按商品ID和链接搜索(t *testing.T) { db := newTestDB(t) createProduct(t, db, "737116531267") createProduct(t, db, "999888777666") byID, _ := ListPddProducts(db, "7371165", "", 1) if len(byID.Rows) != 1 || byID.Rows[0].GoodsID != "737116531267" { t.Errorf("按 ID 片段搜索失败,命中 %d 条", len(byID.Rows)) } byURL, _ := ListPddProducts(db, "yangkeduo", "", 1) if len(byURL.Rows) != 2 { t.Errorf("按链接搜索应命中 2 条,实际 %d 条", len(byURL.Rows)) } } // LIKE 的通配符要转义,否则搜 "7_7" 会把 "737…" 也捞出来,看着像搜索坏了。 // // 注意别拿单个 "_" 当测试词:链接里的 goods_id= 本来就带下划线, // 那种情况命中是对的,测不出问题。 func TestListPddProducts_搜索词里的通配符不当通配符用(t *testing.T) { db := newTestDB(t) createProduct(t, db, "737116531267") for _, kw := range []string{"7_7", "%"} { result, err := ListPddProducts(db, kw, "", 1) if err != nil { t.Fatalf("搜 %q 出错: %v", kw, err) } if len(result.Rows) != 0 { t.Errorf("搜 %q 应该一条都搜不到,实际 %d 条", kw, len(result.Rows)) } } } func TestListPddProducts_删除的查不到(t *testing.T) { db := newTestDB(t) createProduct(t, db, "737116531267") DeletePddProducts(db, []string{"737116531267"}) result, _ := ListPddProducts(db, "", "", 1) if len(result.Rows) != 0 { t.Errorf("软删除的不该出现在列表里,实际 %d 条", len(result.Rows)) } if result.Total != 0 { t.Errorf("统计也不该算上删掉的,实际 %d", result.Total) } } func TestStatusLine_四个状态都列出来(t *testing.T) { db := newTestDB(t) createProduct(t, db, "100000000001") createProduct(t, db, "100000000002") repository.SetCollectResult(db, "100000000002", "已采的", "", sampleSkusJSON) result, _ := ListPddProducts(db, "", "", 1) line := result.StatusLine() for _, want := range []string{"共 2 条", "已采集 1", "未采集 1", "采集中 0", "采集失败 0"} { if !strings.Contains(line, want) { t.Errorf("状态条缺少 %q:%s", want, line) } } } // ── 弹窗详情 ─────────────────────────────────────────── func TestGetPddProductDetail_规格表按dimensions排列(t *testing.T) { db := newTestDB(t) createProduct(t, db, "737116531267") repository.SetCollectResult(db, "737116531267", "西装外套三件套", "", sampleSkusJSON) p, _ := repository.GetPddProductByGoodsID(db, "737116531267") d, err := GetPddProductDetail(db, p.ID) if err != nil { t.Fatalf("读详情失败: %v", err) } if !d.Collected { t.Fatal("已采集的应标为 Collected") } // 表头按 dimensions 里的顺序,用的是 name 不是 key if len(d.DimensionNames) != 2 || d.DimensionNames[0] != "颜色分类" || d.DimensionNames[1] != "尺码" { t.Errorf("维度顺序不对: %v", d.DimensionNames) } if len(d.SKUs) != 3 { t.Fatalf("应有 3 个规格,实际 %d", len(d.SKUs)) } first := d.SKUs[0] if len(first.Options) != 2 || first.Options[0] != "黑色" || first.Options[1] != "M" { t.Errorf("第一行规格值顺序不对: %v", first.Options) } if first.PriceText != "¥12.56" || first.Available != "是" { t.Errorf("第一行价格/有货不对: %q %q", first.PriceText, first.Available) } if d.SKUs[1].Available != "否" { t.Errorf("缺货的应显示「否」,实际 %q", d.SKUs[1].Available) } // price_cent 为 null 的那一行 if d.SKUs[2].PriceText != "未采到" { t.Errorf("采不到价格应显示「未采到」而不是 ¥0.00,实际 %q", d.SKUs[2].PriceText) } } func TestPdd页面_显示店铺与按颜色采样标记(t *testing.T) { db := newTestDB(t) createProduct(t, db, "737116531267") resultJSON := `{ "goods_id":"737116531267", "price_granularity":"color", "dimensions":[ {"key":"color","name":"颜色分类"}, {"key":"size","name":"尺码"} ], "skus":[ {"options":{"color":"黑色","size":"M"},"price_cent":470, "price_observed_at":{"color":"黑色","size":"M"},"available":true}, {"options":{"color":"黑色","size":"2XL"},"price_cent":470, "price_observed_at":{"color":"黑色","size":"M"},"available":true} ] }` repository.SetCollectResult( db, "737116531267", "测试商品", "测试旗舰店", resultJSON) list, err := ListPddProducts(db, "", "", 1) if err != nil { t.Fatalf("读取列表失败: %v", err) } if len(list.Rows) != 1 || list.Rows[0].ShopName != "测试旗舰店" { t.Fatalf("列表应显示店铺名,实际 %+v", list.Rows) } p, _ := repository.GetPddProductByGoodsID(db, "737116531267") detail, err := GetPddProductDetail(db, p.ID) if err != nil { t.Fatalf("读取详情失败: %v", err) } if detail.ShopName != "测试旗舰店" || !detail.ShowPriceSource { t.Errorf("详情应显示店铺和价格粒度提示,实际 shop=%q show=%v", detail.ShopName, detail.ShowPriceSource) } if len(detail.SKUs) != 2 || detail.SKUs[0].PriceSource != "✓ 实测" || detail.SKUs[1].PriceSource != "推断" { t.Errorf("实测/推断标记不正确: %+v", detail.SKUs) } } // dimensions 缺失时退回按 key 排序。不排的话 Go 的 map 是随机顺序, // 同一个商品每次刷新页面列的顺序都不一样。 func TestGetPddProductDetail_没有dimensions时按key排序且稳定(t *testing.T) { db := newTestDB(t) createProduct(t, db, "737116531267") repository.SetCollectResult(db, "737116531267", "无维度信息", "", `{ "skus": [{"options": {"size": "M", "color": "黑色", "style": "A"}, "price_cent": 100, "available": true}] }`) p, _ := repository.GetPddProductByGoodsID(db, "737116531267") for i := 0; i < 5; i++ { d, err := GetPddProductDetail(db, p.ID) if err != nil { t.Fatalf("读详情失败: %v", err) } want := []string{"color", "size", "style"} for j, name := range want { if d.DimensionNames[j] != name { t.Fatalf("第 %d 次读,维度顺序应稳定为 %v,实际 %v", i+1, want, d.DimensionNames) } } if d.SKUs[0].Options[0] != "黑色" { t.Fatalf("规格值应跟着表头一起排,实际 %v", d.SKUs[0].Options) } } } func TestGetPddProductDetail_未采集时不算已采集(t *testing.T) { db := newTestDB(t) createProduct(t, db, "737116531267") p, _ := repository.GetPddProductByGoodsID(db, "737116531267") d, err := GetPddProductDetail(db, p.ID) if err != nil { t.Fatalf("读详情失败: %v", err) } if d.Collected { t.Error("没采过的不该标为已采集——界面要显示「尚未采集」而不是空表") } if d.StatusText != "未采集" { t.Errorf("状态文字 = %q", d.StatusText) } if d.SkusError != "" { t.Errorf("没采过不是解析出错,SkusError 应为空,实际 %q", d.SkusError) } } // 采集结果坏掉要如实说出来,不能装作"没有规格"—— // 前者是数据坏了要重采,后者是商品本身没规格,处理方式不一样。 func TestGetPddProductDetail_采集结果坏掉时说清楚(t *testing.T) { db := newTestDB(t) createProduct(t, db, "737116531267") db.Exec(`UPDATE pdd_products SET skus_json = '坏数据' WHERE goods_id = ?`, "737116531267") p, _ := repository.GetPddProductByGoodsID(db, "737116531267") d, err := GetPddProductDetail(db, p.ID) if err != nil { t.Fatalf("不该整个失败,要能打开弹窗: %v", err) } if d.SkusError == "" { t.Error("应该给出解析失败的说明") } if d.Collected { t.Error("解析不了就不能当成已采集") } } func TestGetPddProductDetail_删除的读不到(t *testing.T) { db := newTestDB(t) createProduct(t, db, "737116531267") p, _ := repository.GetPddProductByGoodsID(db, "737116531267") DeletePddProducts(db, []string{"737116531267"}) d, err := GetPddProductDetail(db, p.ID) if err != nil { t.Fatalf("不该报错: %v", err) } if d != nil { t.Error("已删除的应返回 nil,让界面提示「已被删除,请刷新」") } } // ── 创建采集任务 ─────────────────────────────────────── // collectTaskOf 读出某个商品对应的采集任务。 func collectTaskOf(t *testing.T, db *sql.DB, goodsID string) (status, assigned, url string, ok bool) { t.Helper() var a sql.NullString err := db.QueryRow(` SELECT status, assigned_client, pdd_goods_url FROM tasks WHERE task_type = 'collect' AND pdd_goods_id = ?`, goodsID, ).Scan(&status, &a, &url) if err == sql.ErrNoRows { return "", "", "", false } if err != nil { t.Fatalf("查采集任务失败: %v", err) } return status, a.String, url, true } // `[必须]` 默认不指定客户端。常规采集哪台机器跑都一样, // 保持无主待领可以避免某台机器关着时一直等待。 func TestCreatePddCollectTasks_不指定客户端(t *testing.T) { db := newTestDB(t) createProduct(t, db, "737116531267") result, err := CreatePddCollectTasks(db, []string{"737116531267"}) if err != nil { t.Fatalf("建任务失败: %v", err) } if result.Created != 1 || result.Skipped() != 0 { t.Fatalf("Created=%d Skipped=%d,期望 1/0", result.Created, result.Skipped()) } status, assigned, url, ok := collectTaskOf(t, db, "737116531267") if !ok { t.Fatal("应该建出一条采集任务") } if assigned != "" { t.Errorf("assigned_client 必须为空,实际 %q", assigned) } if status != "pending" { t.Errorf("状态应为 pending(无主待领),实际 %q", status) } // Client 契约里 pdd_goods_url 必填,没有它客户端拿到任务也不知道去哪采 if url == "" { t.Error("pdd_goods_url 必须有值") } // 建完任务商品状态要变成采集中 p, _ := repository.GetPddProductByGoodsID(db, "737116531267") if p.CollectStatus != model.CollectCollecting { t.Errorf("商品状态应为 collecting,实际 %s", p.CollectStatus) } } // 建出来的任务必须真的能被领走,否则页面上永远卡在"采集中"。 func TestCreatePddCollectTasks_建出的任务能被任意客户端领走(t *testing.T) { db := newTestDB(t) createProduct(t, db, "737116531267") CreatePddCollectTasks(db, []string{"737116531267"}) task, err := ClaimNextTask(db, "client-随便哪台", []string{"collect"}) if err != nil { t.Fatalf("领取失败: %v", err) } if task == nil { t.Fatal("采集任务应该能被任意客户端领到") } if task.PddGoodsID != "737116531267" { t.Errorf("领到的任务商品不对: %q", task.PddGoodsID) } } func TestCreatePddCollectTasksForUser_指定客户端时只等待该客户端领取(t *testing.T) { db := newTestDB(t) admin := prepareAdminUser(t, db, time.Date(2026, 8, 10, 1, 0, 0, 0, time.UTC)) RegisterClient(db, model.Client{ClientID: "client-chosen", Name: "指定机器"}, true) RegisterClient(db, model.Client{ClientID: "client-other", Name: "其他机器"}, true) createProduct(t, db, "737116531267") result, err := CreatePddCollectTasksForUser(db, admin, []string{"737116531267"}, "client-chosen") if err != nil || result.Created != 1 { t.Fatalf("指定客户端建任务失败: result=%+v err=%v", result, err) } status, assigned, _, ok := collectTaskOf(t, db, "737116531267") if !ok || status != "assigned" || assigned != "client-chosen" { t.Fatalf("任务初始状态或客户端不对: status=%q assigned=%q ok=%t", status, assigned, ok) } var creator string if err := db.QueryRow(`SELECT created_by_user_id FROM tasks WHERE pdd_goods_id='737116531267'`).Scan(&creator); err != nil || creator != admin.UserID { t.Fatalf("采集任务创建人=%q err=%v,期望 %q", creator, err, admin.UserID) } otherTask, err := ClaimNextTask(db, "client-other", []string{"collect"}) if err != nil || otherTask != nil { t.Fatalf("其他客户端不应领到指定任务: task=%+v err=%v", otherTask, err) } chosenTask, err := ClaimNextTask(db, "client-chosen", []string{"collect"}) if err != nil || chosenTask == nil { t.Fatalf("指定客户端应该能领取任务: task=%+v err=%v", chosenTask, err) } } func TestCreatePddCollectTasksForUser_采购员不能指定别人客户端且整批回滚(t *testing.T) { db := newTestDB(t) admin, buyerA, buyerB := prepareClientAssignmentUsers(t, db) RegisterClient(db, model.Client{ClientID: "client-b", Name: "B 的机器"}, true) if _, _, err := AssignClient(db, admin, "client-b", buyerB.UserID, time.Date(2026, 8, 10, 1, 0, 0, 0, time.UTC)); err != nil { t.Fatal(err) } createProduct(t, db, "737116531267") createProduct(t, db, "937122477375") _, err := CreatePddCollectTasksForUser(db, buyerA, []string{"737116531267", "937122477375"}, "client-b") if !IsValidationError(err) { t.Fatalf("不可见客户端应返回表单错误,实际 %v", err) } var taskCount int if err := db.QueryRow(`SELECT COUNT(*) FROM tasks WHERE task_type = 'collect'`).Scan(&taskCount); err != nil { t.Fatal(err) } if taskCount != 0 { t.Fatalf("拒绝后不应产生部分任务,实际 %d", taskCount) } for _, goodsID := range []string{"737116531267", "937122477375"} { product, _ := repository.GetPddProductByGoodsID(db, goodsID) if product.CollectStatus != model.CollectPending { t.Errorf("拒绝后商品 %s 状态不应改变,实际 %s", goodsID, product.CollectStatus) } } } func TestCreatePddCollectTasksForUser_不指定时保持无主待领(t *testing.T) { db := newTestDB(t) admin := prepareAdminUser(t, db, time.Date(2026, 8, 10, 1, 0, 0, 0, time.UTC)) createProduct(t, db, "737116531267") if _, err := CreatePddCollectTasksForUser(db, admin, []string{"737116531267"}, ""); err != nil { t.Fatal(err) } status, assigned, _, _ := collectTaskOf(t, db, "737116531267") if status != "pending" || assigned != "" { t.Errorf("默认应保持无主待领,status=%q assigned=%q", status, assigned) } } func TestRecollectPddProductForUser_已采集商品保留旧结果并创建任务(t *testing.T) { db := newTestDB(t) admin := prepareAdminUser(t, db, time.Date(2026, 8, 10, 1, 0, 0, 0, time.UTC)) createProduct(t, db, "737116531267") if err := repository.SetCollectResult( db, "737116531267", "旧标题", "旧店铺", sampleSkusJSON); err != nil { t.Fatal(err) } before, _ := repository.GetPddProductByGoodsID(db, "737116531267") result, err := RecollectPddProductForUser(db, admin, "737116531267", "") if err != nil || result.Created != 1 || result.Skipped() != 0 { t.Fatalf("已采集商品应该能明确重采: result=%+v err=%v", result, err) } after, _ := repository.GetPddProductByGoodsID(db, "737116531267") if after.CollectStatus != model.CollectCollecting { t.Errorf("重采后状态应为 collecting,实际 %s", after.CollectStatus) } if after.Title != before.Title || after.ShopName != before.ShopName || after.SkusJSON != before.SkusJSON || after.CollectedAt != before.CollectedAt { t.Errorf("重采开始时不应清空旧结果,before=%+v after=%+v", before, after) } status, assigned, _, ok := collectTaskOf(t, db, "737116531267") if !ok || status != "pending" || assigned != "" { t.Fatalf("应该创建无主待领任务: status=%q assigned=%q ok=%t", status, assigned, ok) } } func TestRecollectPddProductForUser_连续点击只创建一条任务(t *testing.T) { db := newTestDB(t) admin := prepareAdminUser(t, db, time.Date(2026, 8, 10, 1, 0, 0, 0, time.UTC)) createProduct(t, db, "737116531267") if err := repository.SetCollectResult(db, "737116531267", "已采集", "", sampleSkusJSON); err != nil { t.Fatal(err) } first, err := RecollectPddProductForUser(db, admin, "737116531267", "") if err != nil || first.Created != 1 { t.Fatalf("第一次重采失败: result=%+v err=%v", first, err) } second, err := RecollectPddProductForUser(db, admin, "737116531267", "") if err != nil || second.Created != 0 || second.SkippedCollecting != 1 { t.Fatalf("第二次应识别正在采集: result=%+v err=%v", second, err) } var taskCount int if err := db.QueryRow(`SELECT COUNT(*) FROM tasks WHERE task_type='collect' AND pdd_goods_id=?`, "737116531267").Scan(&taskCount); err != nil { t.Fatal(err) } if taskCount != 1 { t.Fatalf("连续点击只能创建一条任务,实际 %d", taskCount) } } func TestRecollectPddProductForUser_不可见客户端时完整回滚(t *testing.T) { db := newTestDB(t) admin, buyerA, buyerB := prepareClientAssignmentUsers(t, db) RegisterClient(db, model.Client{ClientID: "client-b", Name: "B 的机器"}, true) if _, _, err := AssignClient(db, admin, "client-b", buyerB.UserID, time.Date(2026, 8, 10, 1, 0, 0, 0, time.UTC)); err != nil { t.Fatal(err) } createProduct(t, db, "737116531267") if err := repository.SetCollectResult(db, "737116531267", "旧标题", "", sampleSkusJSON); err != nil { t.Fatal(err) } _, err := RecollectPddProductForUser(db, buyerA, "737116531267", "client-b") if !IsValidationError(err) { t.Fatalf("不可见客户端应返回表单错误,实际 %v", err) } p, _ := repository.GetPddProductByGoodsID(db, "737116531267") if p.CollectStatus != model.CollectCollected { t.Errorf("拒绝后商品仍应为 collected,实际 %s", p.CollectStatus) } var taskCount int if err := db.QueryRow(`SELECT COUNT(*) FROM tasks WHERE task_type='collect'`).Scan(&taskCount); err != nil { t.Fatal(err) } if taskCount != 0 { t.Fatalf("拒绝后不应创建任务,实际 %d", taskCount) } } func TestCreatePddCollectTasks_按商品去重(t *testing.T) { db := newTestDB(t) createProduct(t, db, "737116531267") result, err := CreatePddCollectTasks(db, []string{"737116531267", "737116531267", " 737116531267 ", ""}) if err != nil { t.Fatalf("建任务失败: %v", err) } if result.Created != 1 { t.Errorf("重复勾选只该建 1 个任务,实际 %d", result.Created) } var n int db.QueryRow(`SELECT COUNT(*) FROM tasks WHERE task_type = 'collect'`).Scan(&n) if n != 1 { t.Errorf("库里应只有 1 条采集任务,实际 %d", n) } } // 已经在采、还没超时的跳过:再建一个就是让两台机器采同一个商品,白费一趟。 // 而且跳过了几个必须报出来,静默跳过会让操作员等半天不知道为什么没动静。 func TestCreatePddCollectTasks_采集中未超时的跳过并报数(t *testing.T) { db := newTestDB(t) createProduct(t, db, "100000000001") createProduct(t, db, "100000000002") // 第一个先建一次,让它进入 collecting,updated_at 是刚才,远没到 15 分钟 if _, err := CreatePddCollectTasks(db, []string{"100000000001"}); err != nil { t.Fatalf("第一次建任务失败: %v", err) } result, err := CreatePddCollectTasks(db, []string{"100000000001", "100000000002"}) if err != nil { t.Fatalf("第二次建任务失败: %v", err) } if result.Created != 1 { t.Errorf("只该给没在采的那个建任务,实际建了 %d 个", result.Created) } if result.SkippedCollecting != 1 { t.Errorf("采集中且没超时的那个应计入 SkippedCollecting,实际 %d", result.SkippedCollecting) } if result.RetryWaitText == "" { t.Error("没超时的应该给出还要等多久,RetryWaitText 不该为空") } } // 采集失败的可以重新采集,不能一直卡在失败状态。 func TestCreatePddCollectTasks_失败的可以重新采集(t *testing.T) { db := newTestDB(t) createProduct(t, db, "737116531267") repository.SetCollectFailed(db, "737116531267", "页面打不开", "") result, err := CreatePddCollectTasks(db, []string{"737116531267"}) if err != nil { t.Fatalf("建任务失败: %v", err) } if result.Created != 1 || result.Skipped() != 0 { t.Errorf("失败的应该能重采,Created=%d Skipped=%d", result.Created, result.Skipped()) } } func TestCreatePddCollectTasks_已删除的跳过(t *testing.T) { db := newTestDB(t) createProduct(t, db, "737116531267") DeletePddProducts(db, []string{"737116531267"}) result, err := CreatePddCollectTasks(db, []string{"737116531267"}) if err != nil { t.Fatalf("不该报错: %v", err) } if result.Created != 0 || result.SkippedDeleted != 1 { t.Errorf("已删除的应被跳过,Created=%d SkippedDeleted=%d", result.Created, result.SkippedDeleted) } } // 已经采集完成的跳过:本来就不需要重新采集,不该算进"正在采集"那一类。 func TestCreatePddCollectTasks_已采集的跳过并单独计数(t *testing.T) { db := newTestDB(t) createProduct(t, db, "737116531267") repository.SetCollectResult(db, "737116531267", "已采完的商品", "", sampleSkusJSON) result, err := CreatePddCollectTasks(db, []string{"737116531267"}) if err != nil { t.Fatalf("不该报错: %v", err) } if result.Created != 0 || result.SkippedCollected != 1 || result.SkippedCollecting != 0 { t.Errorf("已采集的应单独计入 SkippedCollected,实际 %+v", result) } } func TestCreatePddCollectTasks_什么都没勾时不建任务(t *testing.T) { db := newTestDB(t) result, err := CreatePddCollectTasks(db, nil) if err != nil || result.Created != 0 || result.Skipped() != 0 { t.Errorf("空输入应安静返回,得到 %+v/%v", result, err) } } // ── 超时:#24 要修的死锁 ───────────────────────────────── // insertStaleCollectingProduct 直接建一条"20 分钟前就进入 collecting"的商品, // 并配一条同样卡在 20 分钟前的采集任务,模拟"客户端领了任务却再也没提交结果" // (离线/崩溃/任务被删)的常见场景——这正是 #24 复现步骤里卡死的那种状态。 // // `[必须]` 用固定时间戳而不是让代码在测试运行时调用 time.Now() 现算 20 分钟前, // 否则测试结果会跟着执行的那一刻的系统时钟漂移,CLAUDE.md §5 明确要求这样写。 func insertStaleCollectingProduct(t *testing.T, db *sql.DB, goodsID string) { t.Helper() createProduct(t, db, goodsID) staleAt := time.Now().Add(-20 * time.Minute).UTC().Format(model.TimeLayout) if _, err := db.Exec( `UPDATE pdd_products SET collect_status = 'collecting', updated_at = ? WHERE goods_id = ?`, staleAt, goodsID); err != nil { t.Fatalf("构造超时数据失败: %v", err) } // 卡死的这一条旧任务:客户端已经 claimed,但再也没提交结果, // updated_at 和商品一样停在 20 分钟前。 if _, err := db.Exec(` INSERT INTO tasks (task_id, task_type, status, assigned_client, pdd_goods_url, pdd_goods_id, created_at, updated_at) VALUES (?, 'collect', 'claimed', 'client-已离线', CONCAT('https://mobile.yangkeduo.com/goods.html?goods_id=', ?), ?, ?, ?)`, "COL-STALE-"+goodsID, goodsID, goodsID, staleAt, staleAt); err != nil { t.Fatalf("构造卡死任务失败: %v", err) } } // 验收标准:「collecting 且 updated_at 超过 15 分钟 → 能重新创建采集任务」。 // 老任务不会被撤销——它还在队列里,见 MarkCollecting 注释里"可能采两次"的取舍。 func TestCreatePddCollectTasks_超时后可以重新建任务(t *testing.T) { db := newTestDB(t) insertStaleCollectingProduct(t, db, "737116531267") result, err := CreatePddCollectTasks(db, []string{"737116531267"}) if err != nil { t.Fatalf("建任务失败: %v", err) } if result.Created != 1 || result.SkippedCollecting != 0 { t.Errorf("超时的应该能重建,Created=%d SkippedCollecting=%d", result.Created, result.SkippedCollecting) } // 商品状态回到 collecting(这次是"新一轮"采集),updated_at 也刷新了 p, _ := repository.GetPddProductByGoodsID(db, "737116531267") if p.CollectStatus != model.CollectCollecting { t.Errorf("商品状态应回到 collecting,实际 %s", p.CollectStatus) } // [已知取舍] 旧的那条卡死任务还在队列里没被撤销,加上新建的这条,应该有 2 条。 var n int db.QueryRow(`SELECT COUNT(*) FROM tasks WHERE task_type = 'collect' AND pdd_goods_id = ?`, "737116531267").Scan(&n) if n != 2 { t.Errorf("应该是 1 条旧的卡死任务 + 1 条新建的 = 2 条,实际 %d", n) } } // 验收标准:「collecting 但没到 15 分钟 → 仍然跳过,不重复建」。 // 用 5 分钟前(明显没到 15 分钟阈值)而不是踩着边界,测试更稳。 func TestCreatePddCollectTasks_collecting未到十五分钟仍然跳过(t *testing.T) { db := newTestDB(t) createProduct(t, db, "737116531267") notStaleAt := time.Now().Add(-5 * time.Minute).UTC().Format(model.TimeLayout) if _, err := db.Exec( `UPDATE pdd_products SET collect_status = 'collecting', updated_at = ? WHERE goods_id = ?`, notStaleAt, "737116531267"); err != nil { t.Fatalf("构造数据失败: %v", err) } result, err := CreatePddCollectTasks(db, []string{"737116531267"}) if err != nil { t.Fatalf("不该报错: %v", err) } if result.Created != 0 || result.SkippedCollecting != 1 { t.Errorf("没到 15 分钟的应该继续跳过,Created=%d SkippedCollecting=%d", result.Created, result.SkippedCollecting) } } // 并发:多个请求同时对同一个"已超时"商品建采集任务,只能产生 1 条新任务。 // 加了超时分支之后,原有的原子抢占(#18 已验证过的性质)不能因此失效—— // 否则多台客户端会同时去采同一个商品,白费好几趟。 func TestCreatePddCollectTasks_并发对同一个已超时商品建任务只产生一条(t *testing.T) { db := newTestDB(t) insertStaleCollectingProduct(t, db, "737116531267") const workers = 8 var ( wg sync.WaitGroup mu sync.Mutex totalCreated int lastErr error ) for i := 0; i < workers; i++ { wg.Add(1) go func() { defer wg.Done() result, err := CreatePddCollectTasks(db, []string{"737116531267"}) mu.Lock() defer mu.Unlock() if err != nil { lastErr = err return } totalCreated += result.Created }() } wg.Wait() if lastErr != nil { t.Fatalf("并发建任务出错: %v", lastErr) } if totalCreated != 1 { t.Fatalf("同一个已超时商品被并发建了 %d 次任务,期望正好 1 次", totalCreated) } // 旧的卡死任务 1 条 + 新建的 1 条 = 2 条,不会因为并发多建出第 3 条 var n int db.QueryRow(`SELECT COUNT(*) FROM tasks WHERE task_type = 'collect' AND pdd_goods_id = ?`, "737116531267").Scan(&n) if n != 2 { t.Errorf("库里采集任务数应为 2,实际 %d", n) } } // ── 提示文案 ─────────────────────────────────────────── func TestFormatCollectTaskMessage_建成且有跳过时按原因分类(t *testing.T) { msg := FormatCollectTaskMessage(CollectTaskResult{ Created: 2, SkippedCollecting: 1, SkippedDeleted: 1, }) want := "已创建 2 个采集任务,等待客户端领取(跳过 1 个正在采集的、1 个已删除的)" if msg != want { t.Errorf("消息 = %q,期望 %q", msg, want) } } // 一个都没建成时不能只说"已创建 0 个",要告诉操作员还要等多久。 func TestFormatCollectTaskMessage_一个都没建成时说明还要等多久(t *testing.T) { msg := FormatCollectTaskMessage(CollectTaskResult{ SkippedCollecting: 1, RetryWaitText: "12 分钟", }) want := "没有创建任何任务:1 个正在采集中(还需等待约 12 分钟才可重试)" if msg != want { t.Errorf("消息 = %q,期望 %q", msg, want) } } func TestFormatCollectTaskMessage_全部建成不带跳过语句(t *testing.T) { msg := FormatCollectTaskMessage(CollectTaskResult{Created: 3}) want := "已创建 3 个采集任务,等待客户端领取" if msg != want { t.Errorf("消息 = %q,期望 %q", msg, want) } }