diff --git a/server/app/goauto/purchase/admin_query.go b/server/app/goauto/purchase/admin_query.go index e7b86d4..f2ec79e 100644 --- a/server/app/goauto/purchase/admin_query.go +++ b/server/app/goauto/purchase/admin_query.go @@ -145,7 +145,7 @@ func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminLis } items := make([]AdminTaskItem, 0, len(tasks)) for _, task := range tasks { - items = append(items, adminTaskItem(task, deviceNames, s.retryEligibility(ctx, task, true))) + items = append(items, adminTaskItem(task, deviceNames, s.retryQueryEligibility(ctx, task, true))) } return AdminListResponse{Items: items, Total: total, Page: req.Page, PageSize: req.PageSize}, nil } @@ -175,7 +175,7 @@ func (s *Service) AdminDetail(ctx context.Context, taskID uint64) (AdminDetailRe StartedAt: attempt.StartedAt, FinishedAt: attempt.FinishedAt, CreatedAt: attempt.CreatedAt, }) } - return AdminDetailResponse{Task: adminTaskItem(task, deviceNames, s.retryEligibility(ctx, task, true)), Attempts: items}, nil + return AdminDetailResponse{Task: adminTaskItem(task, deviceNames, s.retryQueryEligibility(ctx, task, true)), Attempts: items}, nil } func loadDeviceNames(db *gorm.DB, tasks []models.PurchaseTask) (map[uint64]string, error) { diff --git a/server/app/goauto/purchase/retry.go b/server/app/goauto/purchase/retry.go index 29d0e1c..fc57e69 100644 --- a/server/app/goauto/purchase/retry.go +++ b/server/app/goauto/purchase/retry.go @@ -143,6 +143,36 @@ func validateRetryTaskIDs(raw []uint64) ([]uint64, error) { } func (s *Service) retryEligibility(ctx context.Context, task models.PurchaseTask, checkDeviceBusy bool) retryDecision { + decision := s.retryStateEligibility(ctx, task) + if !decision.Allowed { + return decision + } + + preview := s.previewOne(ctx, *task.SYBProductID) + if !preview.Eligible { + return retryDecision{ReasonCode: preview.ReasonCode, Reason: preview.Reason} + } + decision = s.retryDeviceEligibility(ctx, task, checkDeviceBusy) + if !decision.Allowed { + return decision + } + return retryDecision{Allowed: true, Preview: preview} +} + +// retryQueryEligibility is deliberately limited to local task and device +// safety checks. Admin list/detail queries must never call previewOne because +// previewOne can fall back to the external AI provider for unresolved specs. +// The batch-retry command runs retryEligibility and performs that complete, +// current-archive validation only after the operator submits the retry. +func (s *Service) retryQueryEligibility(ctx context.Context, task models.PurchaseTask, checkDeviceBusy bool) retryDecision { + decision := s.retryStateEligibility(ctx, task) + if !decision.Allowed { + return decision + } + return s.retryDeviceEligibility(ctx, task, checkDeviceBusy) +} + +func (s *Service) retryStateEligibility(ctx context.Context, task models.PurchaseTask) retryDecision { deny := func(code, message string) retryDecision { return retryDecision{ReasonCode: code, Reason: message} } @@ -166,10 +196,12 @@ func (s *Service) retryEligibility(ctx context.Context, task models.PurchaseTask if latest.ID != task.ID { return deny(CodeRetryStale, fmt.Sprintf("同一 SYB 商品已有更新任务 %s", taskNumber(latest.ID))) } + return retryDecision{Allowed: true} +} - preview := s.previewOne(ctx, *task.SYBProductID) - if !preview.Eligible { - return deny(preview.ReasonCode, preview.Reason) +func (s *Service) retryDeviceEligibility(ctx context.Context, task models.PurchaseTask, checkDeviceBusy bool) retryDecision { + deny := func(code, message string) retryDecision { + return retryDecision{ReasonCode: code, Reason: message} } if task.DeviceID != nil { if err := s.validateBatchDevice(ctx, task.DeviceID); err != nil { @@ -189,7 +221,7 @@ func (s *Service) retryEligibility(ctx context.Context, task models.PurchaseTask } } } - return retryDecision{Allowed: true, Preview: preview} + return retryDecision{Allowed: true} } func retryItemRequestID(batchRequestID string, sourceTaskID uint64) string { diff --git a/server/app/goauto/purchase/retry_test.go b/server/app/goauto/purchase/retry_test.go index e439b4b..be24552 100644 --- a/server/app/goauto/purchase/retry_test.go +++ b/server/app/goauto/purchase/retry_test.go @@ -5,12 +5,20 @@ import ( "strings" "testing" + "go-admin/app/goauto/aimatching" "go-admin/app/goauto/models" "github.com/google/uuid" "gorm.io/gorm" ) +type countingRetryMatcher struct{ calls int } + +func (matcher *countingRetryMatcher) Resolve(context.Context, aimatching.MatchRequest) (aimatching.MatchResult, error) { + matcher.calls++ + return aimatching.MatchResult{}, &aimatching.Error{Code: aimatching.CodeNoMatch, Message: "no match"} +} + func failedLiveTask(t *testing.T, db *gorm.DB, service *Service, f fixture) models.PurchaseTask { t.Helper() task, err := createLive(t, service, f) @@ -181,6 +189,41 @@ func TestAdminListRetryEligibilityRejectsBusyOriginalDevice(t *testing.T) { } } +func TestAdminQueriesDoNotResolveSpecsButBatchRetryStillDoes(t *testing.T) { + db := testDB(t) + f := seed(t, db, liveCaps(), false) + service := testService(db) + failed := failedLiveTask(t, db, service, f) + if err := db.Model(&models.PDDProduct{}).Where("id = ?", f.pdd.ID).Update("specs_json", `[{"name":"颜色","role":"color","values":[{"name":"白色","selectable":true,"priceCent":2000}]},{"name":"尺码","role":"size","values":[{"name":"L","selectable":true}]}]`).Error; err != nil { + t.Fatal(err) + } + matcher := &countingRetryMatcher{} + service.Matcher = matcher + + list, err := service.AdminList(context.Background(), AdminListRequest{Page: 1, PageSize: 20, TaskID: failed.ID}) + if err != nil { + t.Fatal(err) + } + if len(list.Items) != 1 || !list.Items[0].Retryable || matcher.calls != 0 { + t.Fatalf("admin list resolved specs: items=%+v matcherCalls=%d", list.Items, matcher.calls) + } + detail, err := service.AdminDetail(context.Background(), failed.ID) + if err != nil { + t.Fatal(err) + } + if !detail.Task.Retryable || matcher.calls != 0 { + t.Fatalf("admin detail resolved specs: task=%+v matcherCalls=%d", detail.Task, matcher.calls) + } + + retry, err := service.BatchRetry(context.Background(), BatchRetryRequest{RequestID: uuid.NewString(), TaskIDs: []uint64{failed.ID}}) + if err != nil { + t.Fatal(err) + } + if matcher.calls != 1 || retry.CreatedCount != 0 || retry.FailedCount != 1 || retry.Items[0].ReasonCode != CodeMappingRequired { + t.Fatalf("batch retry skipped full matching: result=%+v matcherCalls=%d", retry, matcher.calls) + } +} + func TestFailedTaskNoLongerNeedsRepurchaseAuthorization(t *testing.T) { db := testDB(t) f := seed(t, db, liveCaps(), true)