fix(#64): bound purchase spec selection retries

This commit is contained in:
QiuSW
2026-08-22 09:07:13 +08:00
parent ec9f47f2ab
commit 22a024be64
6 changed files with 129 additions and 18 deletions
+17 -6
View File
@@ -268,11 +268,22 @@ func (s *Service) SubmitResult(ctx context.Context, taskID uint64, req ResultReq
if len(req.ProbedSpecs) == 0 {
return TaskPayload{}, fail(CodeInvalidRequest, "规格探测结果无效")
}
next = models.PurchaseTaskStatusSpecProbePending
a.Status = models.PurchaseAttemptStatusCompleted
t.SpecSource = "unresolved"
t.MappedColorSnapshot = ""
t.MappedSizeSnapshot = ""
if t.SpecDecisionRequestID != nil {
// One slow-path decision has already been frozen for this task. A
// second probe means the Agent still could not select that exact
// decision. Fail closed instead of clearing the auditable decision
// and leaving an active task that can be dispatched forever.
next = models.PurchaseTaskStatusFailed
a.Status = models.PurchaseAttemptStatusFailed
code, message := "PURCHASE_SPEC_NOT_MATCHED", "再次执行仍未能精确选择商品规格,请检查商品规格"
t.ErrorCode, t.ErrorMessage = &code, &message
} else {
next = models.PurchaseTaskStatusSpecProbePending
a.Status = models.PurchaseAttemptStatusCompleted
t.SpecSource = "unresolved"
t.MappedColorSnapshot = ""
t.MappedSizeSnapshot = ""
}
case "rehearsal_completed":
if t.ExecutionMode != models.PurchaseExecutionModeRehearsal {
return TaskPayload{}, fail(CodeStateConflict, "正式任务不能提交演练结果")
@@ -322,7 +333,7 @@ func (s *Service) SubmitResult(ctx context.Context, taskID uint64, req ResultReq
}
return valuePayload(s, t, a, false)
})
if err != nil || req.ResultType != "spec_probe_completed" || payload.Replayed {
if err != nil || req.ResultType != "spec_probe_completed" || payload.Replayed || payload.Status != models.PurchaseTaskStatusSpecProbePending {
return payload, err
}
return s.resolveProbedSpecs(ctx, taskID, req.TaskAttemptID, req.ProbedSpecs)
@@ -230,6 +230,48 @@ func TestSlowPathUsesTwoAttemptsAndFreezesDecision(t *testing.T) {
}
}
func TestSecondSpecProbeFailsClosedWithoutClearingDecision(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), false)
s := testService(db)
task, err := createLive(t, s, f)
if err != nil {
t.Fatal(err)
}
if _, err = s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
t.Fatal(err)
}
first, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
if err != nil {
t.Fatal(err)
}
probe := json.RawMessage(`{"dimensions":[{"key":"color","values":["黑色"]},{"key":"size","values":["XL"]}]}`)
if _, err = s.SubmitResult(context.Background(), task.ID, ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: first.TaskAttemptID, ResultType: "spec_probe_completed", ProbedSpecs: probe}, f.token); err != nil {
t.Fatal(err)
}
if _, err = s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
t.Fatal(err)
}
second, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
if err != nil || second.Phase != models.PurchaseAttemptPhasePurchase {
t.Fatalf("second attempt: %+v %v", second, err)
}
result, err := s.SubmitResult(context.Background(), task.ID, ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: second.TaskAttemptID, ResultType: "spec_probe_completed", ProbedSpecs: probe}, f.token)
if err != nil || result.Status != models.PurchaseTaskStatusFailed {
t.Fatalf("second probe must fail closed: %+v %v", result, err)
}
var saved models.PurchaseTask
if err = db.First(&saved, task.ID).Error; err != nil {
t.Fatal(err)
}
if saved.ErrorCode == nil || *saved.ErrorCode != "PURCHASE_SPEC_NOT_MATCHED" {
t.Fatalf("unexpected failure: %v %v", saved.ErrorCode, saved.ErrorMessage)
}
if saved.MappedColorSnapshot != "黑色" || saved.MappedSizeSnapshot != "XL" || saved.SpecDecisionRequestID == nil {
t.Fatalf("the first decision must remain auditable: %+v", saved)
}
}
func TestCreateUsesDeterministicTraditionalAndWeightMatching(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), false)