fix: 商品卡在「采集中」无法恢复 (#24)
MarkCollecting 原本只在 pending/failed 时成功,而全库只有客户端提交结果 才会把 collecting 改走。客户端离线、崩溃、任务被删都是常态——一旦发生, 这个商品就永久报废,界面上没有任何入口能救,只能改数据库。 改成 collecting 超过 15 分钟视为已超时,允许重新创建采集任务。 判定在读取那一刻现算,仍是同一条原子 UPDATE,不加后台清理协程: 后台扫要处理"扫到一半客户端正好提交了"的竞态,本项目已经在并发上 栽过两次,能不引入并发就不引入。 15 分钟写成有名字的常量并注明理由:采集本身几十秒到两分钟,加上排队 等客户端来领。宁可短也不要长——采集是只读的,多采一次没有副作用, 而卡死的代价是商品永久报废。 已知取舍:超时后老任务还在队列里,客户端上线可能两个都领走、采两次。 可接受(只读,后一次覆盖前一次),已写进代码注释和 03-data-model.md, 免得后来人当成 bug 去"修"成加锁或加租约——租约和心跳是被明确移除的设计。 时间比较用字符串,依赖 NowISO 产出定宽 UTC。已在 NowISO 上加注释: 改成带时区偏移的本地时间会让这个比较静默失效,不报错但判断全错。 界面两处:超时的显示「采集中(超时)」,否则操作员盯着「采集中」 不知道它已经死了;跳过原因按正在采集/已采集/已删除分类计数, 一个都没建成时告诉操作员还要等多久。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+205
-24
@@ -4,7 +4,9 @@ import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cmautobuy/admin/model"
|
||||
"cmautobuy/admin/repository"
|
||||
@@ -530,12 +532,12 @@ func TestCreatePddCollectTasks_不指定客户端(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
createProduct(t, db, "737116531267")
|
||||
|
||||
created, skipped, err := CreatePddCollectTasks(db, []string{"737116531267"})
|
||||
result, err := CreatePddCollectTasks(db, []string{"737116531267"})
|
||||
if err != nil {
|
||||
t.Fatalf("建任务失败: %v", err)
|
||||
}
|
||||
if created != 1 || skipped != 0 {
|
||||
t.Fatalf("created=%d skipped=%d,期望 1/0", created, skipped)
|
||||
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")
|
||||
@@ -582,13 +584,13 @@ func TestCreatePddCollectTasks_按商品去重(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
createProduct(t, db, "737116531267")
|
||||
|
||||
created, _, err := CreatePddCollectTasks(db,
|
||||
result, err := CreatePddCollectTasks(db,
|
||||
[]string{"737116531267", "737116531267", " 737116531267 ", ""})
|
||||
if err != nil {
|
||||
t.Fatalf("建任务失败: %v", err)
|
||||
}
|
||||
if created != 1 {
|
||||
t.Errorf("重复勾选只该建 1 个任务,实际 %d", created)
|
||||
if result.Created != 1 {
|
||||
t.Errorf("重复勾选只该建 1 个任务,实际 %d", result.Created)
|
||||
}
|
||||
|
||||
var n int
|
||||
@@ -598,28 +600,31 @@ func TestCreatePddCollectTasks_按商品去重(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 已经在采的跳过:再建一个就是让两台机器采同一个商品,白费一趟。
|
||||
// 已经在采、还没超时的跳过:再建一个就是让两台机器采同一个商品,白费一趟。
|
||||
// 而且跳过了几个必须报出来,静默跳过会让操作员等半天不知道为什么没动静。
|
||||
func TestCreatePddCollectTasks_采集中的跳过并报数(t *testing.T) {
|
||||
func TestCreatePddCollectTasks_采集中未超时的跳过并报数(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
createProduct(t, db, "100000000001")
|
||||
createProduct(t, db, "100000000002")
|
||||
|
||||
// 第一个先建一次,让它进入 collecting
|
||||
if _, _, err := CreatePddCollectTasks(db, []string{"100000000001"}); err != nil {
|
||||
// 第一个先建一次,让它进入 collecting,updated_at 是刚才,远没到 15 分钟
|
||||
if _, err := CreatePddCollectTasks(db, []string{"100000000001"}); err != nil {
|
||||
t.Fatalf("第一次建任务失败: %v", err)
|
||||
}
|
||||
|
||||
created, skipped, err := CreatePddCollectTasks(db,
|
||||
result, err := CreatePddCollectTasks(db,
|
||||
[]string{"100000000001", "100000000002"})
|
||||
if err != nil {
|
||||
t.Fatalf("第二次建任务失败: %v", err)
|
||||
}
|
||||
if created != 1 {
|
||||
t.Errorf("只该给没在采的那个建任务,实际建了 %d 个", created)
|
||||
if result.Created != 1 {
|
||||
t.Errorf("只该给没在采的那个建任务,实际建了 %d 个", result.Created)
|
||||
}
|
||||
if skipped != 1 {
|
||||
t.Errorf("采集中的那个应计入 skipped,实际 %d", skipped)
|
||||
if result.SkippedCollecting != 1 {
|
||||
t.Errorf("采集中且没超时的那个应计入 SkippedCollecting,实际 %d", result.SkippedCollecting)
|
||||
}
|
||||
if result.RetryWaitText == "" {
|
||||
t.Error("没超时的应该给出还要等多久,RetryWaitText 不该为空")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -629,12 +634,12 @@ func TestCreatePddCollectTasks_失败的可以重新采集(t *testing.T) {
|
||||
createProduct(t, db, "737116531267")
|
||||
repository.SetCollectFailed(db, "737116531267", "页面打不开", "")
|
||||
|
||||
created, skipped, err := CreatePddCollectTasks(db, []string{"737116531267"})
|
||||
result, err := CreatePddCollectTasks(db, []string{"737116531267"})
|
||||
if err != nil {
|
||||
t.Fatalf("建任务失败: %v", err)
|
||||
}
|
||||
if created != 1 || skipped != 0 {
|
||||
t.Errorf("失败的应该能重采,created=%d skipped=%d", created, skipped)
|
||||
if result.Created != 1 || result.Skipped() != 0 {
|
||||
t.Errorf("失败的应该能重采,Created=%d Skipped=%d", result.Created, result.Skipped())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -643,19 +648,195 @@ func TestCreatePddCollectTasks_已删除的跳过(t *testing.T) {
|
||||
createProduct(t, db, "737116531267")
|
||||
DeletePddProducts(db, []string{"737116531267"})
|
||||
|
||||
created, skipped, err := CreatePddCollectTasks(db, []string{"737116531267"})
|
||||
result, err := CreatePddCollectTasks(db, []string{"737116531267"})
|
||||
if err != nil {
|
||||
t.Fatalf("不该报错: %v", err)
|
||||
}
|
||||
if created != 0 || skipped != 1 {
|
||||
t.Errorf("已删除的应被跳过,created=%d skipped=%d", created, skipped)
|
||||
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)
|
||||
created, skipped, err := CreatePddCollectTasks(db, nil)
|
||||
if err != nil || created != 0 || skipped != 0 {
|
||||
t.Errorf("空输入应安静返回,得到 %d/%d/%v", created, skipped, err)
|
||||
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-已离线',
|
||||
'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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user