fix: 修复顺运宝采集任务孤儿状态 (#165)

This commit is contained in:
chengma
2026-08-11 17:00:33 +08:00
parent c8b1fd686a
commit 9e328984e9
17 changed files with 508 additions and 67 deletions
+9 -4
View File
@@ -576,7 +576,7 @@ func (r CollectTaskResult) Skipped() int {
// 返回的跳过分类必须显示给操作员。静默跳过的话,
// 操作员会以为任务建好了,等半天没动静也不知道为什么。
func CreatePddCollectTasks(db *sql.DB, goodsIDs []string) (CollectTaskResult, error) {
return createPddCollectTasks(db, goodsIDs, "", "", "")
return createPddCollectTasks(db, goodsIDs, "", "", "", nil)
}
// CreatePddCollectTasksForUser 为 PDD 批量页面创建可选客户端的采集任务。
@@ -586,7 +586,7 @@ func CreatePddCollectTasksForUser(db *sql.DB, actor *model.User, goodsIDs []stri
if err != nil {
return CollectTaskResult{}, err
}
return createPddCollectTasks(db, goodsIDs, strings.TrimSpace(clientID), visibleUserID, actor.UserID)
return createPddCollectTasks(db, goodsIDs, strings.TrimSpace(clientID), visibleUserID, actor.UserID, nil)
}
// RecollectPddProductForUser 为详情页一次明确的重新采集创建单商品任务。
@@ -658,7 +658,8 @@ func RecollectPddProductForUser(db *sql.DB, actor *model.User, goodsID, clientID
return result, nil
}
func createPddCollectTasks(db *sql.DB, goodsIDs []string, clientID, visibleUserID, createdByUserID string) (CollectTaskResult, error) {
func createPddCollectTasks(db *sql.DB, goodsIDs []string, clientID, visibleUserID, createdByUserID string,
sybSourcesByGoodsID map[string][]string) (CollectTaskResult, error) {
var result CollectTaskResult
goodsIDs = dedupe(goodsIDs)
@@ -725,8 +726,12 @@ func createPddCollectTasks(db *sql.DB, goodsIDs []string, clientID, visibleUserI
continue
}
taskID := newCollectTaskID()
if err := repository.InsertCollectTaskForClientAndUser(
tx, newCollectTaskID(), p.GoodsID, p.URL, clientID, createdByUserID); err != nil {
tx, taskID, p.GoodsID, p.URL, clientID, createdByUserID); err != nil {
return CollectTaskResult{}, err
}
if err := repository.InsertCollectTaskSybSources(tx, taskID, sybSourcesByGoodsID[p.GoodsID]); err != nil {
return CollectTaskResult{}, err
}
result.Created++
+17
View File
@@ -856,6 +856,23 @@ func TestCreatePddCollectTasks_采集中未超时的跳过并报数(t *testing.T
}
}
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)
+16 -13
View File
@@ -143,19 +143,16 @@ func CreateSybPddCollectTask(db *sql.DB, sybID string) (CollectTaskResult, error
if context == nil {
return CollectTaskResult{}, fmt.Errorf("顺运宝明细不存在")
}
return CreateShopeePddCollectTask(db, context.Order.ShopeeGoodsID)
if context.PddGoodsID == "" {
return CollectTaskResult{}, fmt.Errorf("请先关联 PDD 商品")
}
return createPddCollectTasks(db, []string{context.PddGoodsID}, "", "", "",
map[string][]string{context.PddGoodsID: {context.Order.SybID}})
}
// CreateSybPddCollectTaskForUser 为网页登录入口记录任务创建人。
func CreateSybPddCollectTaskForUser(db *sql.DB, actor *model.User, sybID string) (CollectTaskResult, error) {
context, err := repository.GetSybOrderContext(db, strings.TrimSpace(sybID))
if err != nil {
return CollectTaskResult{}, err
}
if context == nil {
return CollectTaskResult{}, fmt.Errorf("顺运宝明细不存在")
}
return CreateShopeePddCollectTaskForUser(db, actor, context.Order.ShopeeGoodsID)
return CreateSybPddCollectTasksForUser(db, actor, []string{sybID}, "")
}
// CreateSybPddCollectTasksForUser 把勾选的顺运宝明细转换成 PDD 商品后批量建采集任务。
@@ -168,6 +165,7 @@ func CreateSybPddCollectTasksForUser(db *sql.DB, actor *model.User, sybIDs []str
}
goodsIDs := make([]string, 0, len(sybIDs))
sourcesByGoodsID := make(map[string][]string)
for _, sybID := range sybIDs {
context, err := repository.GetSybOrderContext(db, sybID)
if err != nil {
@@ -177,11 +175,16 @@ func CreateSybPddCollectTasksForUser(db *sql.DB, actor *model.User, sybIDs []str
return CollectTaskResult{}, invalidInput("顺运宝明细 %s 不存在,请刷新页面后重新选择", sybID)
}
stage, _, _, _ := sybStageFor(*context)
if stage != SybStagePddPending && stage != SybStagePddFailed {
return CollectTaskResult{}, invalidInput("顺运宝明细 %s 已不处于 PDD 待采集或采集失败状态,请刷新页面后重新选择", sybID)
if stage != SybStagePddPending && stage != SybStagePddFailed && stage != SybStagePddCollectingStale {
return CollectTaskResult{}, invalidInput("顺运宝明细 %s 已不处于 PDD 待采集、采集失败或采集超时状态,请刷新页面后重新选择", sybID)
}
goodsIDs = append(goodsIDs, context.PddGoodsID)
sourcesByGoodsID[context.PddGoodsID] = append(sourcesByGoodsID[context.PddGoodsID], sybID)
}
return CreatePddCollectTasksForUser(db, actor, dedupe(goodsIDs), clientID)
visibleUserID, err := taskActorScope(actor)
if err != nil {
return CollectTaskResult{}, err
}
return createPddCollectTasks(db, dedupe(goodsIDs), strings.TrimSpace(clientID),
visibleUserID, actor.UserID, sourcesByGoodsID)
}
+36
View File
@@ -143,6 +143,42 @@ func TestCreateSybPddCollectTasksForUser_同一PDD商品去重(t *testing.T) {
if err := db.QueryRow(`SELECT COUNT(*) FROM tasks WHERE task_type='collect'`).Scan(&taskCount); err != nil || taskCount != 1 {
t.Fatalf("同一 PDD 商品应只建一条任务,count=%d err=%v", taskCount, err)
}
var sourceCount int
if err := db.QueryRow(`SELECT COUNT(*) FROM task_syb_sources`).Scan(&sourceCount); err != nil || sourceCount != 2 {
t.Fatalf("去重任务应保留两条顺运宝来源,count=%d err=%v", sourceCount, err)
}
for _, keyword := range []string{"ORDER-SYB-1", "ORDER-SYB-2", "SYB-1", "SYB-2"} {
list, err := ListTasksView(db, repository.TaskFilter{Keyword: keyword}, 1)
if err != nil || list.Total != 1 || len(list.Rows) != 1 {
t.Fatalf("按顺运宝来源 %q 搜索任务失败: total=%d rows=%d err=%v", keyword, list.Total, len(list.Rows), err)
}
}
if _, err := CreateSybPddCollectTasksForUser(db, admin, []string{"SYB-1"}, ""); !IsValidationError(err) {
t.Fatalf("未超时且有有效任务时应拒绝重复创建,实际 %v", err)
}
}
func TestCreateSybPddCollectTasksForUser_无任务的采集中状态可恢复(t *testing.T) {
db := newTestDB(t)
admin, _, _ := insertTaskUsers(t, db)
seedShopeeProduct(t, db, "1001", "商品一")
if _, err := AssociateShopeePdd(db, "1001", pddURLA, false); err != nil {
t.Fatal(err)
}
seedSybCollectOrder(t, db, "SYB-ORPHAN", "1001")
if _, err := db.Exec(`UPDATE pdd_products SET collect_status='collecting',updated_at=? WHERE goods_id=?`,
model.NowISO(), "737116531267"); err != nil {
t.Fatal(err)
}
result, err := CreateSybPddCollectTasksForUser(db, admin, []string{"SYB-ORPHAN"}, "")
if err != nil || result.Created != 1 {
t.Fatalf("孤儿采集中状态应能重建任务:result=%+v err=%v", result, err)
}
var sources int
if err := db.QueryRow(`SELECT COUNT(*) FROM task_syb_sources WHERE syb_id='SYB-ORPHAN'`).Scan(&sources); err != nil || sources != 1 {
t.Fatalf("重建任务应保存顺运宝来源:count=%d err=%v", sources, err)
}
}
func TestCreateSybPddCollectTasksForUser_不可采集明细整批拒绝(t *testing.T) {
+28 -14
View File
@@ -950,15 +950,16 @@ type SybOrderView struct {
}
const (
SybStageSpecMissing = "spec_missing"
SybStagePddMissing = "pdd_missing"
SybStagePddPending = "pdd_pending"
SybStagePddCollecting = "pdd_collecting"
SybStagePddFailed = "pdd_failed"
SybStageMappingPending = "mapping_pending"
SybStagePurchaseReady = "purchase_ready"
SybStageTaskCreated = "task_created"
SybStagePurchaseBlocked = "purchase_blocked"
SybStageSpecMissing = "spec_missing"
SybStagePddMissing = "pdd_missing"
SybStagePddPending = "pdd_pending"
SybStagePddCollecting = "pdd_collecting"
SybStagePddCollectingStale = "pdd_collecting_stale"
SybStagePddFailed = "pdd_failed"
SybStageMappingPending = "mapping_pending"
SybStagePurchaseReady = "purchase_ready"
SybStageTaskCreated = "task_created"
SybStagePurchaseBlocked = "purchase_blocked"
)
// SybStageOption 是顺运宝处理阶段筛选项。
@@ -974,6 +975,7 @@ func SybStageOptions() []SybStageOption {
{Value: SybStagePddMissing, Text: "未关联 PDD"},
{Value: SybStagePddPending, Text: "PDD 待采集"},
{Value: SybStagePddCollecting, Text: "PDD 采集中"},
{Value: SybStagePddCollectingStale, Text: "PDD 采集中(超时)"},
{Value: SybStagePddFailed, Text: "PDD 采集失败"},
{Value: SybStageMappingPending, Text: "规格待匹配"},
{Value: SybStagePurchaseReady, Text: "可创建采购任务"},
@@ -1008,6 +1010,8 @@ func sybStageFor(c repository.SybOrderContext) (stage, text, help, action string
return SybStagePddMissing, "未关联 PDD", "下一步关联采购商品。", "关联 PDD"
case c.PddCollectStatus == string(model.CollectPending):
return SybStagePddPending, "PDD 待采集", "已关联 PDD 商品,尚未创建采集任务。", "创建采集任务"
case c.PddCollectStatus == string(model.CollectCollecting) && sybCollectingIsStale(c):
return SybStagePddCollectingStale, "PDD 采集中(超时)", "采集状态已超时或找不到有效任务,可以重新创建采集任务。", "重新创建采集任务"
case c.PddCollectStatus == string(model.CollectCollecting):
return SybStagePddCollecting, "PDD 采集中", "客户端正在采集规格和价格,请稍后刷新。", "查看采集状态"
case c.PddCollectStatus == string(model.CollectFailed):
@@ -1027,6 +1031,14 @@ func sybStageFor(c repository.SybOrderContext) (stage, text, help, action string
}
}
func sybCollectingIsStale(c repository.SybOrderContext) bool {
if !c.HasActiveCollectTask {
return true
}
updatedAt, ok := model.ParseISO(c.PddUpdatedAt)
return !ok || time.Since(updatedAt) > model.CollectStaleAfter
}
// SybListResult 是列表页要的全部数据。
type SybListResult struct {
Rows []SybOrderView
@@ -1045,7 +1057,8 @@ type SybListResult struct {
func ListSybOrdersView(db *sql.DB, keyword, shop, stage string, page int) (*SybListResult, error) {
shop = strings.TrimSpace(shop)
stage = ParseSybStage(stage)
if stage == SybStageMappingPending || stage == SybStagePurchaseReady ||
if stage == SybStagePddCollecting || stage == SybStagePddCollectingStale ||
stage == SybStageMappingPending || stage == SybStagePurchaseReady ||
stage == SybStageTaskCreated || stage == SybStagePurchaseBlocked {
return listAdvancedSybStage(db, keyword, shop, stage, page)
}
@@ -1131,7 +1144,8 @@ func sybOrderViewFor(context repository.SybOrderContext) SybOrderView {
Quantity: o.Quantity, ImageURL: o.ImageURL,
UpdatedAt: formatLocalTime(o.UpdatedAt)}
v.Stage, v.StageText, v.StageHelp, v.ActionText = sybStageFor(context)
v.CanCollect = v.Stage == SybStagePddPending || v.Stage == SybStagePddFailed
v.CanCollect = v.Stage == SybStagePddPending || v.Stage == SybStagePddFailed ||
v.Stage == SybStagePddCollectingStale
v.CanPurchase = v.Stage == SybStagePurchaseReady
switch {
case v.CanCollect:
@@ -1205,9 +1219,10 @@ func GetSybProcessingDetail(db *sql.DB, sybID string) (*SybProcessingDetail, err
PddGoodsID: context.PddGoodsID, PddURL: context.PddGoodsURL,
CollectStatus: context.PddCollectStatus, CollectMsg: context.PddCollectMsg,
}
d.Stage, d.StageText, d.StageHelp, _ = sybStageFor(*context)
d.CanCollect = context.PddGoodsID != "" &&
(context.PddCollectStatus == string(model.CollectPending) ||
context.PddCollectStatus == string(model.CollectFailed))
(d.Stage == SybStagePddPending || d.Stage == SybStagePddFailed ||
d.Stage == SybStagePddCollectingStale)
d.HasActiveTask = context.HasActiveTask
if context.PddCollectStatus == string(model.CollectCollected) && context.PddSkusJSON != "" {
choices, keys, names, parseErr := pddOptionChoices(context.PddSkusJSON)
@@ -1226,7 +1241,6 @@ func GetSybProcessingDetail(db *sql.DB, sybID string) (*SybProcessingDetail, err
}
}
d.ContextVersion = mappingContextVersion(*context)
d.Stage, d.StageText, d.StageHelp, _ = sybStageFor(*context)
return d, nil
}
+24 -1
View File
@@ -113,7 +113,15 @@ func TestSybStageFor_全部筛选阶段与逐行推导一致(t *testing.T) {
{SybStageSpecMissing, "核对数据", copyWith(func(c *repository.SybOrderContext) { c.Order.SpecKey = "" })},
{SybStagePddMissing, "关联 PDD", copyWith(func(c *repository.SybOrderContext) { c.PddGoodsID, c.PddCollectStatus = "", "" })},
{SybStagePddPending, "创建采集任务", copyWith(func(c *repository.SybOrderContext) { c.PddCollectStatus = string(model.CollectPending) })},
{SybStagePddCollecting, "查看采集状态", copyWith(func(c *repository.SybOrderContext) { c.PddCollectStatus = string(model.CollectCollecting) })},
{SybStagePddCollecting, "查看采集状态", copyWith(func(c *repository.SybOrderContext) {
c.PddCollectStatus = string(model.CollectCollecting)
c.PddUpdatedAt = model.NowISO()
c.HasActiveCollectTask = true
})},
{SybStagePddCollectingStale, "重新创建采集任务", copyWith(func(c *repository.SybOrderContext) {
c.PddCollectStatus = string(model.CollectCollecting)
c.PddUpdatedAt = model.NowISO()
})},
{SybStagePddFailed, "重新采集", copyWith(func(c *repository.SybOrderContext) { c.PddCollectStatus = string(model.CollectFailed) })},
{SybStageMappingPending, "匹配规格", copyWith(func(c *repository.SybOrderContext) { c.MappingOptionKey = "" })},
{SybStageTaskCreated, "查看采购任务", copyWith(func(c *repository.SybOrderContext) { c.HasActiveTask = true })},
@@ -150,6 +158,10 @@ func TestSybOrderViewFor_批量动作能力按阶段隔离(t *testing.T) {
context := base
context.PddCollectStatus = string(tc.status)
context.MappingOptionKey = tc.mappingKey
if tc.status == model.CollectCollecting {
context.PddUpdatedAt = model.NowISO()
context.HasActiveCollectTask = true
}
view := sybOrderViewFor(context)
if view.CanCollect != tc.wantCollect || view.CanPurchase != tc.wantPurchase {
t.Errorf("status=%s: CanCollect=%v CanPurchase=%v", tc.status, view.CanCollect, view.CanPurchase)
@@ -171,3 +183,14 @@ func TestSybOrderViewFor_批量动作能力按阶段隔离(t *testing.T) {
t.Fatalf("采购就绪行应只允许采购:CanCollect=%v CanPurchase=%v", view.CanCollect, view.CanPurchase)
}
}
func TestSybCollectingIsStale_有效任务但时间损坏时允许恢复(t *testing.T) {
context := repository.SybOrderContext{
PddCollectStatus: string(model.CollectCollecting),
PddUpdatedAt: "not-a-time",
HasActiveCollectTask: true,
}
if !sybCollectingIsStale(context) {
t.Fatal("无法解析更新时间时不应永久停留在正常采集中")
}
}
+15 -3
View File
@@ -404,7 +404,7 @@ func (r *TaskListResult) StatusLine() string {
// DeleteTasks 批量删除任务,返回实际删掉的条数。
func DeleteTasks(db *sql.DB, taskIDs []string) (int64, error) {
return repository.DeleteTasks(db, dedupe(taskIDs))
return deleteTasksInScope(db, dedupe(taskIDs), "", false)
}
// DeleteTasksForUser 保证整批删除要么全部在当前范围内成功,要么全部回滚。
@@ -413,7 +413,10 @@ func DeleteTasksForUser(db *sql.DB, actor *model.User, taskIDs []string) (int64,
if err != nil {
return 0, err
}
ids := dedupe(taskIDs)
return deleteTasksInScope(db, dedupe(taskIDs), visibleUserID, true)
}
func deleteTasksInScope(db *sql.DB, ids []string, visibleUserID string, enforceExactScope bool) (int64, error) {
if len(ids) == 0 {
return 0, nil
}
@@ -422,13 +425,22 @@ func DeleteTasksForUser(db *sql.DB, actor *model.User, taskIDs []string) (int64,
return 0, fmt.Errorf("开始删除任务事务失败: %w", err)
}
defer tx.Rollback()
goodsIDs, err := repository.ListCollectTaskGoodsIDsInScope(tx, ids, visibleUserID)
if err != nil {
return 0, err
}
n, err := repository.DeleteTasksInScope(tx, ids, visibleUserID)
if err != nil {
return 0, err
}
if n != int64(len(ids)) {
if enforceExactScope && n != int64(len(ids)) {
return 0, ErrTaskNotVisible
}
for _, goodsID := range goodsIDs {
if _, err := repository.ResetCollectingIfNoActiveTask(tx, goodsID); err != nil {
return 0, err
}
}
if err := tx.Commit(); err != nil {
return 0, fmt.Errorf("提交删除任务事务失败: %w", err)
}
+27
View File
@@ -594,6 +594,33 @@ func TestDeleteTasks_批量删除(t *testing.T) {
}
}
func TestDeleteTasks_删除最后有效采集任务才回收商品状态(t *testing.T) {
db := newTestDB(t)
if _, err := repository.EnsurePddProduct(db, "111", "https://mobile.yangkeduo.com/goods.html?goods_id=111"); err != nil {
t.Fatal(err)
}
if _, err := db.Exec(`UPDATE pdd_products SET collect_status='collecting' WHERE goods_id='111'`); err != nil {
t.Fatal(err)
}
insertTestTask(t, db, testTaskParams{taskID: "COL-A", taskType: model.TaskCollect, status: model.TaskPending, pddGoodsID: "111"})
insertTestTask(t, db, testTaskParams{taskID: "COL-B", taskType: model.TaskCollect, status: model.TaskAssigned, pddGoodsID: "111"})
if _, err := DeleteTasks(db, []string{"COL-A"}); err != nil {
t.Fatal(err)
}
product, err := repository.GetPddProductByGoodsID(db, "111")
if err != nil || product.CollectStatus != model.CollectCollecting {
t.Fatalf("仍有有效任务时不应回收:status=%s err=%v", product.CollectStatus, err)
}
if _, err := DeleteTasks(db, []string{"COL-B"}); err != nil {
t.Fatal(err)
}
product, err = repository.GetPddProductByGoodsID(db, "111")
if err != nil || product.CollectStatus != model.CollectPending {
t.Fatalf("删除最后有效任务后应回收为 pending:status=%s err=%v", product.CollectStatus, err)
}
}
func TestTaskScope_采购员只能查看自己的任务且不能伪造筛选(t *testing.T) {
db := newTestDB(t)
admin, buyerA, buyerB := insertTaskUsers(t, db)