diff --git a/docs/08-agent-api-contract.md b/docs/08-agent-api-contract.md index 59c1323..ca049b8 100644 --- a/docs/08-agent-api-contract.md +++ b/docs/08-agent-api-contract.md @@ -556,7 +556,9 @@ Android #42/#36 使用本地 SQLite 保存恢复与重传所需的任务、attem 创建订单前先在本地事务保存 `order_submit_started`、不可逆时间、稳定的 `orderSubmitRequestId` 和脱敏最终确认快照,再调用服务端同名接口;只有两侧标记完成并重新校验商品、规格、数量、单价、地址后缀、PDD 包/Activity 与唯一创建订单按钮后,才点击一次。重启时重放同一标记请求并只读核单;无法取得唯一未付款订单号和 PDD 下单时间时提交 `order_result_unknown`。支付文字仅用于识别未付款/离开支付页,永不点击。服务端数据库仍是最终事实来源;双方均不保存原始控件树、截图、PDD 凭据或完整收货地址。 创建订单后若出现 Android 多微信应用选择器,只读核单器必须同时确认前台包为 `android` / `com.android.intentresolver`、Activity 为白名单 `ChooserActivity` / `ResolverActivity`、页面出现已知系统选择器标题且至少一个候选以“微信”开头,才允许执行一次系统返回;不得点击任何微信候选。若前台已经是精确微信包 `com.tencent.mm`,只读核单器不得点击、输入、登录、支付或强制停止微信,只允许执行一次无参数 PDD 启动 Intent 并等待既有 PDD 任务栈回到前台;`startActivity()` 成功只表示恢复请求已发起。请求后在固定最多 15 次、每次 200ms 的宽限期内允许微信或空窗口短暂残留,不执行点击、返回、输入或滑动;观察到 PDD 后结束宽限,宽限超时、已经观察到 PDD 后再次进入微信或出现稳定未知应用时返回未知结果。随后若前台为 PDD `com.xunmeng.pinduoduo.app_pay.core.PayActivity` 或当前页面出现支付动作文字,最多再返回一次。返回后只读解析唯一订单号和下单时间;选择器或支付页重复出现、白名单不成立、恢复动作重复、无法到达订单详情、结果不唯一或超时均返回未知结果,禁止再次点击创建订单、取消订单或支付。 -`order_result_unknown` 保持订单号和下单时间为空,但允许携带创建订单前已经严格验证的 `actualUnitPriceCent`。Agent 同时提交脱敏稳定失败阶段,服务端只接受白名单并按错误码写入固定提示,不信任或保存页面原文;阶段覆盖空窗口超时、选择器返回失败、微信恢复失败/超时、未知应用、支付页返回失败/重复、订单上下文缺失、订单号缺失/歧义、下单时间缺失/无效和待付款证据缺失。任务与 attempt 保存同一错误阶段,Admin 和 Agent 历史读取数据库最终事实;旧 Agent 未提交阶段时归一为 `PURCHASE_ORDER_RESULT_UNKNOWN`。 +Agent 主动提交 `order_result_unknown` 时保持订单号和下单时间为空,但允许携带创建订单前已经严格验证的 `actualUnitPriceCent`。Agent 同时提交脱敏稳定失败阶段,服务端只接受白名单并按错误码写入固定提示,不信任或保存页面原文;阶段覆盖空窗口超时、选择器返回失败、微信恢复失败/超时、未知应用、支付页返回失败/重复、订单上下文缺失、订单号缺失/歧义、下单时间缺失/无效和待付款证据缺失。任务与 attempt 保存同一错误阶段,Admin 和 Agent 历史读取数据库最终事实;旧 Agent 未提交阶段时归一为 `PURCHASE_ORDER_RESULT_UNKNOWN`。 + +#241 追加:全局订单号唯一性校验在人工处理结果未知、取消及 lifecycle 保存路径返回 `PURCHASE_ORDER_NUMBER_ALREADY_USED`(HTTP 409、`retryable=false`),提示“订单号已属于任务 CG-任务ID”;批量回填继续使用原有 `PURCHASE_BACKFILL_ORDER_ALREADY_USED`。不可逆边界后的 `order_created` 结果回传为例外:发现该号已属于其他任务时成功受理结果,将 `order_submit_started` 降级为 `order_result_unknown`,不回滚或自动重派。冲突订单号不写入 `pdd_order_no`,而以“读到订单号 X,但该号已属于任务 CG-yy”保存到任务和 attempt 的 `error_message`,两者 `error_code` 均为 `PURCHASE_ORDER_NUMBER_ALREADY_USED`;保留下单时间、不可逆时间及实际单价。attempt 以 failed 结束并保留原始 `order_created` 结果类型、请求 ID 和摘要,重复提交按原幂等协议返回;任务释放租约和运行槽,进入既有人工处理结果未知通道,权限不变。 | 错误码 | 普通提示 | |---|---| diff --git a/server/app/goauto/models/purchase_order_guard.go b/server/app/goauto/models/purchase_order_guard.go index 5e9f7ee..a880dd8 100644 --- a/server/app/goauto/models/purchase_order_guard.go +++ b/server/app/goauto/models/purchase_order_guard.go @@ -2,6 +2,7 @@ package models import ( "errors" + "fmt" "gorm.io/gorm" "gorm.io/gorm/clause" @@ -9,6 +10,16 @@ import ( var ErrPurchaseOrderNumberUsed = errors.New("purchase order number belongs to another task") +type PurchaseOrderNumberUsedError struct { + TaskID uint64 +} + +func (e *PurchaseOrderNumberUsedError) Error() string { + return fmt.Sprintf("订单号已属于任务 CG-%d", e.TaskID) +} + +func (e *PurchaseOrderNumberUsedError) Unwrap() error { return ErrPurchaseOrderNumberUsed } + // CheckPurchaseOrderNumber must run inside the caller's write transaction. // The existing singleton setting row serializes order assignments across // processes, including an absent order number, without relying on gap locks or @@ -24,7 +35,7 @@ func CheckPurchaseOrderNumber(tx *gorm.DB, taskID uint64, orderNo string) error return err } if len(others) != 0 { - return ErrPurchaseOrderNumberUsed + return &PurchaseOrderNumberUsedError{TaskID: others[0].ID} } return nil } diff --git a/server/app/goauto/purchase/handler.go b/server/app/goauto/purchase/handler.go index 5bfb059..52d2935 100644 --- a/server/app/goauto/purchase/handler.go +++ b/server/app/goauto/purchase/handler.go @@ -542,7 +542,7 @@ func writeError(c *gin.Context, err error) { status = http.StatusForbidden case CodeTaskNotFound: status = http.StatusNotFound - case CodeStateConflict, CodeCapabilityMismatch, CodeDeviceBusy, CodeTaskClaimed, CodeLeaseExpired, CodeMappingRequired, CodeResultConflict, CodeRePurchaseRequired: + case CodeStateConflict, CodeCapabilityMismatch, CodeDeviceBusy, CodeTaskClaimed, CodeLeaseExpired, CodeMappingRequired, CodeResultConflict, CodeRePurchaseRequired, CodeOrderNumberUsed: status = http.StatusConflict } c.JSON(status, gin.H{"code": code, "message": msg, "retryable": retryable}) diff --git a/server/app/goauto/purchase/lifecycle.go b/server/app/goauto/purchase/lifecycle.go index 543eb42..91e5ce2 100644 --- a/server/app/goauto/purchase/lifecycle.go +++ b/server/app/goauto/purchase/lifecycle.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "encoding/json" "errors" + "fmt" "strings" "time" @@ -378,6 +379,22 @@ func (s *Service) SubmitResult(ctx context.Context, taskID uint64, req ResultReq t.PDDOrderNo = &req.PDDOrderNo t.OrderSubmittedAt = req.OrderSubmittedAt t.ActualUnitPriceCent = req.ActualUnitPriceCent + // Keep the assignment lock until commit. A conflicting observation + // after the irreversible boundary must reach manual resolution, + // not roll back the result or claim another task's order number. + if e := models.CheckPurchaseOrderNumber(tx, t.ID, req.PDDOrderNo); e != nil { + var conflict *models.PurchaseOrderNumberUsedError + if !errors.As(e, &conflict) { + return TaskPayload{}, conflictOrInternal(e) + } + next = models.PurchaseTaskStatusOrderResultUnknown + t.PDDOrderNo = nil + failureCode := CodeOrderNumberUsed + message := fmt.Sprintf("读到订单号 %s,但该号已属于任务 %s", req.PDDOrderNo, taskNumber(conflict.TaskID)) + t.ErrorCode, t.ErrorMessage = &failureCode, &message + a.Status = models.PurchaseAttemptStatusFailed + a.ErrorCode, a.ErrorMessage = &failureCode, &message + } case "order_result_unknown": if t.ExecutionMode != models.PurchaseExecutionModeLive || t.Status != models.PurchaseTaskStatusOrderSubmitStarted { return TaskPayload{}, fail(CodeStateConflict, "当前任务不能标记订单结果未知") @@ -525,7 +542,10 @@ func (s *Service) applySpecDecision(ctx context.Context, taskID uint64, req Spec } t.StatusVersion++ t.StatusChangedAt = s.Now() - return tx.Save(&t).Error + if e := tx.Save(&t).Error; e != nil { + return conflictOrInternal(e) + } + return nil }) return t, replayed, err } @@ -728,6 +748,10 @@ func purchaseNotFound(err error) error { return internal(err) } func conflictOrInternal(err error) error { + var conflict *models.PurchaseOrderNumberUsedError + if errors.As(err, &conflict) { + return fail(CodeOrderNumberUsed, conflict.Error()) + } if isDuplicate(err) { return fail(CodeDeviceBusy, "设备或拼多多账号已有运行任务") } diff --git a/server/app/goauto/purchase/manual.go b/server/app/goauto/purchase/manual.go index acbd205..6767a97 100644 --- a/server/app/goauto/purchase/manual.go +++ b/server/app/goauto/purchase/manual.go @@ -80,7 +80,10 @@ func (s *Service) SelectWriteback(ctx context.Context, id uint64, req ManualRequ } out.WritebackStatus = models.PurchaseWritebackStatusPending out.WritebackSelectRequestID = &req.RequestID - return tx.Save(&out).Error + if e := tx.Save(&out).Error; e != nil { + return conflictOrInternal(e) + } + return nil }) return out, replayed, err } @@ -166,7 +169,10 @@ func (s *Service) manual(ctx context.Context, id uint64, req ManualRequest, appl return internal(e) } } - return tx.Save(&out).Error + if e := tx.Save(&out).Error; e != nil { + return conflictOrInternal(e) + } + return nil }) return out, replayed, err } diff --git a/server/app/goauto/purchase/order_backfill_test.go b/server/app/goauto/purchase/order_backfill_test.go index 4717a6d..0f4f8ec 100644 --- a/server/app/goauto/purchase/order_backfill_test.go +++ b/server/app/goauto/purchase/order_backfill_test.go @@ -385,20 +385,44 @@ func TestOrderBackfillRejectsLateAssignmentOfSameOrder(t *testing.T) { if err := db.Omit("Task").Create(&attempt).Error; err != nil { t.Fatal(err) } - _, err := s.SubmitResult(context.Background(), b.ID, ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: attempt.AttemptID, ResultType: "order_created", PDDOrderNo: "SHARED", OrderSubmittedAt: b.IrreversibleAt}, f.token) - if err == nil { - t.Fatal("old result path assigned duplicate order") + req := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: attempt.AttemptID, ResultType: "order_created", PDDOrderNo: "SHARED", OrderSubmittedAt: b.IrreversibleAt} + out, err := s.SubmitResult(context.Background(), b.ID, req, f.token) + if err != nil || out.Status != models.PurchaseTaskStatusOrderResultUnknown { + t.Fatalf("conflicting result must commit as unknown: %+v %v", out, err) } got := loadBackfillTask(t, db, b.ID) - if got.StatusVersion != b.StatusVersion || got.PDDOrderNo != nil { - t.Fatal("old result transaction was not rolled back") + if got.Status != models.PurchaseTaskStatusOrderResultUnknown || got.StatusVersion != b.StatusVersion+1 || got.PDDOrderNo != nil { + t.Fatalf("duplicate assignment was not safely downgraded: %+v", got) + } + wantMessage := "读到订单号 SHARED,但该号已属于任务 " + taskNumber(a.ID) + if got.ErrorCode == nil || *got.ErrorCode != CodeOrderNumberUsed || got.ErrorMessage == nil || *got.ErrorMessage != wantMessage { + t.Fatalf("conflict evidence missing: %+v", got) + } + if got.OrderSubmittedAt == nil || !got.OrderSubmittedAt.Equal(*req.OrderSubmittedAt) || got.IrreversibleAt == nil || got.LeaseExpiresAt != nil || got.DeviceRunSlot != nil || got.AccountRunSlot != nil { + t.Fatalf("boundary evidence or released lease missing: %+v", got) } var saved models.PurchaseTaskAttempt if err := db.First(&saved, attempt.ID).Error; err != nil { t.Fatal(err) } - if saved.Status != models.PurchaseAttemptStatusRunning || saved.ResultRequestID != nil { - t.Fatal("attempt result survived rolled back assignment") + if saved.Status != models.PurchaseAttemptStatusFailed || saved.ResultRequestID == nil || *saved.ResultRequestID != req.RequestID || saved.ResultHash == nil || saved.ResultType == nil || *saved.ResultType != "order_created" || saved.FinishedAt == nil || saved.ErrorCode == nil || *saved.ErrorCode != CodeOrderNumberUsed || saved.ErrorMessage == nil || *saved.ErrorMessage != wantMessage { + t.Fatalf("attempt result and conflict evidence missing: %+v", saved) + } + out, err = s.SubmitResult(context.Background(), b.ID, req, f.token) + if err != nil || !out.Replayed || out.Status != models.PurchaseTaskStatusOrderResultUnknown || loadBackfillTask(t, db, b.ID).StatusVersion != got.StatusVersion { + t.Fatalf("unknown result replay failed: %+v %v", out, err) + } + owner := loadBackfillTask(t, db, a.ID) + if owner.PDDOrderNo == nil || *owner.PDDOrderNo != "SHARED" || owner.Status != models.PurchaseTaskStatusOrderCreated { + t.Fatalf("existing owner changed: %+v", owner) + } + var count int64 + if err := db.Model(&models.PurchaseTask{}).Where("pdd_order_no = ?", "SHARED").Count(&count).Error; err != nil || count != 1 { + t.Fatalf("duplicate order: %d %v", count, err) + } + resolved, _, err := s.ResolveUnknown(context.Background(), b.ID, ManualRequest{RequestID: uuid.NewString(), OperatorID: 1, Status: models.PurchaseTaskStatusOrderCreated, PDDOrderNo: "CORRECTED", OrderSubmittedAt: req.OrderSubmittedAt}) + if err != nil || resolved.Status != models.PurchaseTaskStatusOrderCreated { + t.Fatalf("manual resolution unavailable: %+v %v", resolved, err) } } diff --git a/server/app/goauto/purchase/order_conflict_test.go b/server/app/goauto/purchase/order_conflict_test.go new file mode 100644 index 0000000..b62e91f --- /dev/null +++ b/server/app/goauto/purchase/order_conflict_test.go @@ -0,0 +1,74 @@ +package purchase + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "go-admin/app/goauto/models" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "gorm.io/gorm" +) + +func TestOrderNumberConflictBusinessErrors(t *testing.T) { + for _, path := range []string{"resolve_unknown", "cancel", "lifecycle"} { + t.Run(path, func(t *testing.T) { + db := testDB(t) + f := seed(t, db, liveCaps(), true) + s := testService(db) + owner := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown) + if r := runBackfill(t, s, f.token, uuid.NewString(), backfillItem(owner.ID, "SHARED"))[0]; r.Code != "BACKFILLED" { + t.Fatal(r) + } + status := models.PurchaseTaskStatusOrderResultUnknown + if path == "lifecycle" { + status = models.PurchaseTaskStatusRunning + } + task := backfillTask(t, db, f, status) + if path != "resolve_unknown" { + // Model legacy duplicate data predating the global save guard. + if err := db.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ?", task.ID).Updates(map[string]any{"pdd_order_no": "SHARED", "lease_expires_at": s.Now().Add(time.Minute)}).Error; err != nil { + t.Fatal(err) + } + } + req := ManualRequest{RequestID: uuid.NewString(), OperatorID: 1, Status: models.PurchaseTaskStatusOrderCreated, PDDOrderNo: "SHARED", OrderSubmittedAt: task.IrreversibleAt, Reason: "人工取消"} + var err error + switch path { + case "resolve_unknown": + _, _, err = s.ResolveUnknown(context.Background(), task.ID, req) + case "cancel": + _, _, err = s.Cancel(context.Background(), task.ID, req) + case "lifecycle": + attempt := models.PurchaseTaskAttempt{TaskID: task.ID, AttemptID: uuid.NewString(), AttemptNumber: 1, Phase: models.PurchaseAttemptPhasePurchase, Status: models.PurchaseAttemptStatusRunning, DeviceID: &f.device.ID, RuleSnapshotHash: purchaseRuleSnapshotHash(task.RuleSnapshot), SpecDecisionSnapshot: `{}`} + if e := db.Omit("Task").Create(&attempt).Error; e != nil { + t.Fatal(e) + } + _, err = s.MarkOrderSubmitStarted(context.Background(), task.ID, ActionRequest{RequestID: req.RequestID}, f.token) + } + want := "订单号已属于任务 " + taskNumber(owner.ID) + if code(err) != CodeOrderNumberUsed || err.Error() != want { + t.Fatalf("unmapped conflict: %v", err) + } + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + writeError(c, err) + var body struct { + Code string `json:"code"` + Message string `json:"message"` + Retryable bool `json:"retryable"` + } + if e := json.Unmarshal(w.Body.Bytes(), &body); e != nil || w.Code != http.StatusConflict || body.Code != CodeOrderNumberUsed || body.Message != want || body.Retryable { + t.Fatalf("unexpected HTTP error: %d %s (%v)", w.Code, w.Body.String(), e) + } + got := loadBackfillTask(t, db, task.ID) + if got.Status != status || got.StatusVersion != task.StatusVersion || got.UnknownResolveRequestID != nil || got.CancelRequestID != nil || got.OrderSubmitRequestID != nil { + t.Fatalf("rejected mutation persisted: %+v", got) + } + }) + } +} diff --git a/server/app/goauto/purchase/types.go b/server/app/goauto/purchase/types.go index b3431b6..0a1d7c2 100644 --- a/server/app/goauto/purchase/types.go +++ b/server/app/goauto/purchase/types.go @@ -22,6 +22,7 @@ const ( CodeRetryStale = "PURCHASE_RETRY_STALE" CodeSpecReprobeRejected = "PURCHASE_SPEC_REPROBE_REJECTED" CodeOrderResultUnknown = "PURCHASE_ORDER_RESULT_UNKNOWN" + CodeOrderNumberUsed = "PURCHASE_ORDER_NUMBER_ALREADY_USED" CodeOrderEmptyTimeout = "PURCHASE_ORDER_EMPTY_TIMEOUT" CodeOrderChooserBack = "PURCHASE_ORDER_CHOOSER_BACK_FAILED" CodeOrderWechatRestore = "PURCHASE_ORDER_WECHAT_RESTORE_FAILED"