diff --git a/server/app/goauto/task/current_page.go b/server/app/goauto/task/current_page.go index 3cefb97..fc73c6b 100644 --- a/server/app/goauto/task/current_page.go +++ b/server/app/goauto/task/current_page.go @@ -255,7 +255,9 @@ func (service *Service) IdentifyCurrentPage(ctx context.Context, taskID uint64, return serviceError(CodeCurrentPageIdentityConflict, "当前商品与任务已识别商品不一致") } now := service.Now() - if err := tx.Model(&models.CollectionTask{}).Where("id = ?", record.ID).Updates(map[string]any{ + // Validate the locked task's real state; an empty model fails BeforeSave + // when a retried current-page task already has a product binding. + if err := tx.Model(&record).Where("id = ?", record.ID).Updates(map[string]any{ "identify_request_id": request.RequestID, "identity_resolved_at": now, }).Error; err != nil { return internalError(err) diff --git a/server/app/goauto/task/current_page_retry_test.go b/server/app/goauto/task/current_page_retry_test.go new file mode 100644 index 0000000..ba16bea --- /dev/null +++ b/server/app/goauto/task/current_page_retry_test.go @@ -0,0 +1,153 @@ +package task + +import ( + "context" + "reflect" + "testing" + + "go-admin/app/goauto/models" + "go-admin/app/goauto/rulecontract" + + "github.com/google/uuid" +) + +func TestCurrentPageReidentifyAfterSameTaskRetry(t *testing.T) { + db := openTaskDatabase(t) + _, token := registerTaskDeviceWithCapabilities(t, db, "retry-device", []string{ + rulecontract.CapabilitySchemaV2, + rulecontract.CapabilityPDDProductDetailV1, + rulecontract.CapabilityPDDCurrentPageShareV1, + }) + _, otherToken := registerTaskDevice(t, db, "other-device") + rule := models.CollectionRule{Name: "retry-rule", ContentJSON: v2TaskRuleSnapshot()} + if err := db.Create(&rule).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&models.AgentManualCollectionSetting{ID: 1, RuleID: rule.ID}).Error; err != nil { + t.Fatal(err) + } + service := newTaskService(db) + ctx := context.Background() + created, err := service.CreateCurrentPage(ctx, CurrentPageCreateRequest{RequestID: uuid.NewString()}, token) + if err != nil { + t.Fatal(err) + } + load := func() models.CollectionTask { + t.Helper() + var record models.CollectionTask + if err := db.First(&record, created.TaskID).Error; err != nil { + t.Fatal(err) + } + return record + } + request := CurrentPageIdentifyRequest{ + RequestID: uuid.NewString(), ShareURL: "https://mobile.yangkeduo.com/goods.html?goods_id=700000000255", + } + // Direct links resolve locally: the regression never calls PDD or a live DB. + identity, err := service.IdentifyCurrentPage(ctx, created.TaskID, request, token) + if err != nil { + t.Fatal(err) + } + const failureCode = "SPEC_PANEL_EVIDENCE_NOT_MATCHED" + if _, err := service.SubmitFailure(ctx, created.TaskID, FailRequest{ + RequestID: uuid.NewString(), Code: failureCode, Message: "Synthetic panel failure", + }, token); err != nil { + t.Fatal(err) + } + reset, err := service.ResetForDevice(ctx, created.TaskID, ActionRequest{RequestID: uuid.NewString()}, token) + if err != nil || reset.TaskID != created.TaskID || reset.AttemptNumber != 2 || reset.Status != models.TaskStatusPending { + t.Fatalf("same-task reset: %+v, %v", reset, err) + } + resetRecord := load() + if resetRecord.PDDProductID == nil || *resetRecord.PDDProductID != identity.PDDProductID || + resetRecord.IdentifyRequestID != nil || resetRecord.IdentityResolvedAt != nil { + t.Fatal("reset must keep the product and clear per-attempt identification") + } + if _, err := service.Claim(ctx, created.TaskID, ActionRequest{RequestID: uuid.NewString()}, token); err != nil { + t.Fatal(err) + } + if _, err := service.Start(ctx, created.TaskID, ActionRequest{RequestID: uuid.NewString()}, token); err != nil { + t.Fatal(err) + } + before := load() + request.RequestID = uuid.NewString() + for _, tc := range []struct { + name, token, url, code string + }{ + {"other device", otherToken, request.ShareURL, CodeTaskNotFound}, + {"other product", token, "https://mobile.yangkeduo.com/goods.html?goods_id=700000000256", CodeCurrentPageIdentityConflict}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := service.IdentifyCurrentPage(ctx, created.TaskID, CurrentPageIdentifyRequest{RequestID: uuid.NewString(), ShareURL: tc.url}, tc.token) + if taskErrorCode(t, err) != tc.code { + t.Fatalf("expected %s, got %v", tc.code, err) + } + if !reflect.DeepEqual(before, load()) { + t.Fatal("rejected identification changed the task") + } + }) + } + reidentified, err := service.IdentifyCurrentPage(ctx, created.TaskID, request, token) + if err != nil { + t.Fatalf("reidentify same product after retry: %v", err) + } + if reidentified.TaskID != created.TaskID || reidentified.PDDProductID != identity.PDDProductID || + reidentified.GoodsID != identity.GoodsID || reidentified.URL != identity.URL || !reidentified.Replayed { + t.Fatalf("identity changed: %+v", reidentified) + } + after := load() + if after.IdentifyRequestID == nil || *after.IdentifyRequestID != request.RequestID || after.IdentityResolvedAt == nil { + t.Fatal("new attempt identification was not persisted") + } + // Apart from identity metadata and the normal update timestamp, every field + // (including status, device, rule, lease and uniqueness slots) must survive. + expected := before + expected.IdentifyRequestID = after.IdentifyRequestID + expected.IdentityResolvedAt = after.IdentityResolvedAt + expected.UpdatedAt = after.UpdatedAt + if !reflect.DeepEqual(expected, after) { + t.Fatal("reidentification changed unrelated task fields") + } + service.ResolveCurrentPageShare = func(context.Context, string) (ResolvedPDDShare, error) { + t.Fatal("replay must not resolve the link again") + return ResolvedPDDShare{}, nil + } + replay, err := service.IdentifyCurrentPage(ctx, created.TaskID, request, token) + if err != nil || replay != reidentified || !reflect.DeepEqual(after, load()) { + t.Fatalf("replay changed identity or task: %+v, %v", replay, err) + } + service.ResolveCurrentPageShare = nil + title := "Synthetic collected product" + result, err := service.SubmitResult(ctx, created.TaskID, ResultRequest{ + RequestID: uuid.NewString(), Status: models.TaskStatusCompleted, + Product: ResultProduct{PDDGoodsID: identity.GoodsID, Title: &title}, + Dimensions: []ResultDimension{{Key: "color", Name: "颜色", Values: []string{"黑色"}}}, + ColorPrices: []ResultColorPrice{{Color: "黑色", PriceCent: 1200}}, + SKUs: []ResultSKU{{Specs: map[string]string{"color": "黑色"}, PriceCent: 1200, Available: true}}, + }, token) + if err != nil || result.Task.ID != created.TaskID || result.Task.Status != models.TaskStatusCompleted { + t.Fatalf("complete retried collection: %+v, %v", result, err) + } + terminal := load() + request.RequestID = uuid.NewString() + if _, err := service.IdentifyCurrentPage(ctx, created.TaskID, request, token); taskErrorCode(t, err) != CodeTaskStateConflict { + t.Fatalf("expected terminal-state rejection: %v", err) + } + if !reflect.DeepEqual(terminal, load()) { + t.Fatal("terminal identification changed the task") + } + var archived []models.CollectionTaskAttempt + if err := db.Where("task_id = ?", created.TaskID).Find(&archived).Error; err != nil { + t.Fatal(err) + } + if len(archived) != 1 || archived[0].AttemptNumber != 1 || archived[0].Status != models.TaskStatusFailed || + archived[0].ErrorCode == nil || *archived[0].ErrorCode != failureCode { + t.Fatal("original failed attempt was not preserved") + } + for _, model := range []any{&models.CollectionTask{}, &models.PDDProduct{}} { + var count int64 + if err := db.Model(model).Count(&count).Error; err != nil || count != 1 { + t.Fatalf("retry duplicated task/product: count=%d, %v", count, err) + } + } +}