Files
cmautobuy/admin/service/pdd_page_test.go
T

1092 lines
40 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"
"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())
}
var taskID string
if err := db.QueryRow(`SELECT task_id FROM tasks WHERE task_type='collect' AND pdd_goods_id='737116531267'`).Scan(&taskID); err != nil || taskID != "cj1" {
t.Fatalf("第一条采集任务编号=%q err=%v,期望 cj1", taskID, err)
}
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, "100000000003")
if _, err := db.Exec(`UPDATE pdd_products SET collect_status='collecting',updated_at=? WHERE goods_id=?`,
model.NowISO(), "100000000003"); err != nil {
t.Fatal(err)
}
result, err := CreatePddCollectTasks(db, []string{"100000000003"})
if err != nil {
t.Fatalf("孤儿采集中状态重建失败: %v", err)
}
if result.Created != 1 || result.SkippedCollecting != 0 {
t.Fatalf("没有有效任务时应立即重建,实际 %+v", result)
}
}
// 采集失败的可以重新采集,不能一直卡在失败状态。
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)
}
}