From 60c75261d3396a22a96d826c0f38931aaaf2bf13 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Thu, 17 Sep 2026 14:50:05 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E9=87=87=E8=B4=AD=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E6=89=B9=E9=87=8F=E9=87=8D=E8=AF=95=E6=94=B9=E4=B8=BA=E5=8E=9F?= =?UTF-8?q?=E5=9C=B0=E9=87=8D=E8=AF=95=E9=80=89=E4=B8=AD=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=EF=BC=8C=E4=B8=8D=E5=86=8D=E6=96=B0=E5=BB=BA=20(#303)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 采购员勾选失败任务点重试,要的是这条任务本身再跑一次。原先 BatchRetry 一律 调用 Create 新建(CG-224 → CG-234),任务号变化,同一 SYB 明细的多次尝试分散在 多个任务上。 - BatchRetry 改为逐条调用既有 Reset:任务号不变,状态回到 pending,本次执行记入 purchase_task_attempt。Reset 的全部保护原样沿用——碰过下单边界、同一明细已有 更新任务、规格快照不完整均拒绝并返回原因,拒绝后不退回新建。 - 先识别重放再做资格预检:首次重试后任务已是 pending,先预检会把同一 requestId 的重复提交判为「只有失败任务可以重试」,破坏幂等。测试抓到后修正。 - 资格预检关闭设备占用检查,同一设备上勾选的多条可排队;真正的占用判断在 Reset 事务内,ensureDeviceFree 不把租约为空的 pending 计为占用。 - AgentRetry(替代商品已匹配、继续采购)改走 batchRetryCreate,保持新建:商品已 替换,原任务的商品与规格快照不能复用。 - 前端文案改为「已重试 / 第 N 次执行」。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F --- server/app/goauto/purchase/retry.go | 68 +++++++++++++++++- server/app/goauto/purchase/retry_test.go | 71 ++++++++++++++----- web/src/views/goauto/purchase-tasks/index.vue | 8 +-- 3 files changed, 125 insertions(+), 22 deletions(-) diff --git a/server/app/goauto/purchase/retry.go b/server/app/goauto/purchase/retry.go index 6ee4f81..c02111c 100644 --- a/server/app/goauto/purchase/retry.go +++ b/server/app/goauto/purchase/retry.go @@ -32,6 +32,8 @@ type BatchRetryItem struct { Replayed bool `json:"replayed,omitempty"` ReasonCode string `json:"reasonCode,omitempty"` Reason string `json:"reason,omitempty"` + // AttemptNumber 仅原地重试时有值:该任务本次是第几次执行(#303)。 + AttemptNumber int `json:"attemptNumber,omitempty"` } type BatchRetryResponse struct { @@ -69,7 +71,71 @@ type preparedRetry struct { // BatchRetry creates new pending purchase tasks from failed task identities. // The failed rows remain immutable history; the current product, mapping, // price guard and server-owned rule are resolved again at retry time. +// BatchRetry re-executes the selected failed tasks in place (#303). +// +// `[必须]` 不新建任务。采购员在管理页勾选失败任务点「重试」,要的是这条任务本身 +// 再跑一次:任务号不变,尝试记入 purchase_task_attempt。安全判据完全复用 Reset—— +// 碰过下单边界、同一 SYB 明细已有更新任务、规格快照不完整的都会被拒绝并返回原因, +// 拒绝后也不退回新建,否则等于绕过了 Reset 的保护。 func (s *Service) BatchRetry(ctx context.Context, req BatchRetryRequest) (BatchRetryResponse, error) { + if _, err := uuid.Parse(strings.TrimSpace(req.RequestID)); err != nil { + return BatchRetryResponse{}, fail(CodeInvalidRequest, "requestId 无效") + } + ids, err := validateRetryTaskIDs(req.TaskIDs) + if err != nil { + return BatchRetryResponse{}, err + } + response := BatchRetryResponse{Items: make([]BatchRetryItem, len(ids))} + for index, taskID := range ids { + item := BatchRetryItem{SourceTaskID: taskID, SourceTaskNo: taskNumber(taskID)} + var source models.PurchaseTask + if queryErr := s.DB.WithContext(ctx).First(&source, taskID).Error; queryErr != nil { + if !errors.Is(queryErr, gorm.ErrRecordNotFound) { + return BatchRetryResponse{}, internal(queryErr) + } + item.ReasonCode, item.Reason = CodeTaskNotFound, "采购任务不存在或已被删除" + response.Items[index] = item + response.FailedCount++ + continue + } + itemRequestID := retryItemRequestID(req.RequestID, taskID) + // `[必须]` 先识别重放,再做资格预检。第一次重试后任务已是 pending, + // 若先预检会被判「只有失败任务可以重试」拒掉,同一 requestId 重复提交就 + // 不再幂等。已有对应尝试记录时直接交给 Reset,由它返回 Replayed。 + var replayAttempt models.PurchaseTaskAttempt + replayErr := s.DB.WithContext(ctx).Where("attempt_id = ?", purchaseResetAttemptID(itemRequestID, taskID)).First(&replayAttempt).Error + if replayErr != nil && !errors.Is(replayErr, gorm.ErrRecordNotFound) { + return BatchRetryResponse{}, internal(replayErr) + } + isReplay := replayErr == nil + // 预检与原批量入口一致(状态、档案可采购性、设备),但 checkDeviceBusy 关闭: + // 同一设备上勾选的多条任务要能排队,真正的占用判断在 Reset 的事务里做。 + if decision := s.retryEligibility(ctx, source, false); !isReplay && !decision.Allowed { + item.ReasonCode, item.Reason = decision.ReasonCode, decision.Reason + response.Items[index] = item + response.FailedCount++ + continue + } + reset, resetErr := s.Reset(ctx, taskID, PurchaseResetRequest{RequestID: itemRequestID}) + if resetErr != nil { + item.ReasonCode, item.Reason, _ = serviceErrorFields(resetErr) + response.Items[index] = item + response.FailedCount++ + continue + } + sameID := taskID + item.Created, item.TaskID, item.TaskNo, item.Replayed = true, &sameID, taskNumber(taskID), reset.Replayed + item.AttemptNumber = reset.AttemptNumber + response.Items[index] = item + response.CreatedCount++ + } + return response, nil +} + +// batchRetryCreate creates replacement tasks. Only AgentRetry uses it: once a +// replacement product has been matched the original task cannot be reused, +// because its product and specification snapshots describe the old product. +func (s *Service) batchRetryCreate(ctx context.Context, req BatchRetryRequest) (BatchRetryResponse, error) { if _, err := uuid.Parse(strings.TrimSpace(req.RequestID)); err != nil { return BatchRetryResponse{}, fail(CodeInvalidRequest, "requestId 无效") } @@ -179,7 +245,7 @@ func (s *Service) AgentRetry(ctx context.Context, taskID uint64, req AgentRetryR return AgentRetryResponse{}, fail(decision.ReasonCode, decision.Reason) } } - result, err := s.BatchRetry(ctx, BatchRetryRequest{RequestID: req.RequestID, TaskIDs: []uint64{taskID}}) + result, err := s.batchRetryCreate(ctx, BatchRetryRequest{RequestID: req.RequestID, TaskIDs: []uint64{taskID}}) if err != nil { return AgentRetryResponse{}, err } diff --git a/server/app/goauto/purchase/retry_test.go b/server/app/goauto/purchase/retry_test.go index 986454e..d1054df 100644 --- a/server/app/goauto/purchase/retry_test.go +++ b/server/app/goauto/purchase/retry_test.go @@ -36,13 +36,16 @@ func failedLiveTask(t *testing.T, db *gorm.DB, service *Service, f fixture) mode return task } -func TestBatchRetryCreatesNewTaskPreservesFailureAndReplays(t *testing.T) { +// `[必须]` #303:采购管理页勾选失败任务点重试,要的是这条任务本身再跑一次—— +// 任务号不变、不产生新任务,本次执行记入 purchase_task_attempt。 +func TestBatchRetryReexecutesSelectedTaskInPlaceAndReplays(t *testing.T) { db := testDB(t) f := seed(t, db, liveCaps(), true) setCollectedPDDPrice(t, db, f.pdd.ID) service := testService(db) failed := failedLiveTask(t, db, service, f) - if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Update("order_code", "SYB-CHANGED").Error; err != nil { + var before int64 + if err := db.Model(&models.PurchaseTask{}).Count(&before).Error; err != nil { t.Fatal(err) } request := BatchRetryRequest{RequestID: uuid.NewString(), TaskIDs: []uint64{failed.ID}} @@ -51,36 +54,69 @@ func TestBatchRetryCreatesNewTaskPreservesFailureAndReplays(t *testing.T) { if err != nil { t.Fatal(err) } - if first.CreatedCount != 1 || first.FailedCount != 0 || len(first.Items) != 1 || !first.Items[0].Created || first.Items[0].TaskID == nil { + if first.CreatedCount != 1 || first.FailedCount != 0 || len(first.Items) != 1 || first.Items[0].TaskID == nil { t.Fatalf("unexpected retry result: %+v", first) } - if *first.Items[0].TaskID == failed.ID { - t.Fatalf("retry reused failed task: %+v", first.Items[0]) + if *first.Items[0].TaskID != failed.ID { + t.Fatalf("retry must reuse the selected task, got %+v", first.Items[0]) } - var oldTask, newTask models.PurchaseTask - if err = db.First(&oldTask, failed.ID).Error; err != nil { + var after int64 + if err := db.Model(&models.PurchaseTask{}).Count(&after).Error; err != nil { t.Fatal(err) } - if err = db.First(&newTask, *first.Items[0].TaskID).Error; err != nil { + if after != before { + t.Fatalf("retry created a new purchase task: before=%d after=%d", before, after) + } + var task models.PurchaseTask + if err = db.First(&task, failed.ID).Error; err != nil { t.Fatal(err) } - if oldTask.Status != models.PurchaseTaskStatusFailed || newTask.Status != models.PurchaseTaskStatusPending || newTask.AddressSuffix == failed.AddressSuffix || newTask.DeviceID == nil || *newTask.DeviceID != f.device.ID { - t.Fatalf("retry facts mismatch: old=%+v new=%+v", oldTask, newTask) + if task.Status != models.PurchaseTaskStatusPending || task.ErrorCode != nil || task.AddressSuffix != failed.AddressSuffix { + t.Fatalf("task was not reset in place: %+v", task) } - if newTask.ShopeeOrderNoSnapshot != failed.ShopeeOrderNoSnapshot { - t.Fatalf("retry changed order snapshot: old=%q new=%q", failed.ShopeeOrderNoSnapshot, newTask.ShopeeOrderNoSnapshot) + if first.Items[0].AttemptNumber < 1 { + t.Fatalf("attempt number missing: %+v", first.Items[0]) } replay, err := service.BatchRetry(context.Background(), request) if err != nil { t.Fatal(err) } - if replay.CreatedCount != 1 || !replay.Items[0].Replayed || replay.Items[0].TaskID == nil || *replay.Items[0].TaskID != newTask.ID { + if replay.CreatedCount != 1 || !replay.Items[0].Replayed || *replay.Items[0].TaskID != failed.ID || replay.Items[0].AttemptNumber != first.Items[0].AttemptNumber { t.Fatalf("retry replay was not stable: first=%+v replay=%+v", first, replay) } - var count int64 - if err = db.Model(&models.PurchaseTask{}).Where("syb_product_id = ?", f.syb.ID).Count(&count).Error; err != nil || count != 2 { - t.Fatalf("retry created duplicate tasks: count=%d err=%v", count, err) +} + +// `[必须]` 碰过下单边界的失败任务可能已在 PDD 建了订单,必须拒绝,而且拒绝后 +// 不能退回新建——否则等于绕过了 Reset 的保护,重复下单。 +func TestBatchRetryRefusesTaskPastOrderBoundaryWithoutCreating(t *testing.T) { + db := testDB(t) + f := seed(t, db, liveCaps(), true) + setCollectedPDDPrice(t, db, f.pdd.ID) + service := testService(db) + failed := failedLiveTask(t, db, service, f) + if err := db.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ?", failed.ID).Update("irreversible_at", service.Now()).Error; err != nil { + t.Fatal(err) + } + var before int64 + db.Model(&models.PurchaseTask{}).Count(&before) + + result, err := service.BatchRetry(context.Background(), BatchRetryRequest{RequestID: uuid.NewString(), TaskIDs: []uint64{failed.ID}}) + if err != nil { + t.Fatal(err) + } + if result.CreatedCount != 0 || result.FailedCount != 1 || result.Items[0].ReasonCode == "" { + t.Fatalf("task past the order boundary must be refused: %+v", result) + } + var after int64 + db.Model(&models.PurchaseTask{}).Count(&after) + if after != before { + t.Fatalf("refused retry must not create a task: before=%d after=%d", before, after) + } + var task models.PurchaseTask + db.First(&task, failed.ID) + if task.Status != models.PurchaseTaskStatusFailed { + t.Fatalf("refused task changed status: %q", task.Status) } } @@ -174,7 +210,8 @@ func TestAdminListRetryEligibilityRejectsStaleAndOfflineFailures(t *testing.T) { t.Fatal(err) } - created, err := service.BatchRetry(context.Background(), BatchRetryRequest{RequestID: uuid.NewString(), TaskIDs: []uint64{failed.ID}}) + // 用仍会新建的替代路径制造「同一明细已有更新任务」的前提。 + created, err := service.batchRetryCreate(context.Background(), BatchRetryRequest{RequestID: uuid.NewString(), TaskIDs: []uint64{failed.ID}}) if err != nil || created.CreatedCount != 1 { t.Fatalf("create newer task: %+v %v", created, err) } diff --git a/web/src/views/goauto/purchase-tasks/index.vue b/web/src/views/goauto/purchase-tasks/index.vue index c50bf66..cfefe28 100644 --- a/web/src/views/goauto/purchase-tasks/index.vue +++ b/web/src/views/goauto/purchase-tasks/index.vue @@ -92,7 +92,7 @@ - + @@ -103,11 +103,11 @@ - + - - + +