diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseLiveAutomation.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseLiveAutomation.kt index b68517a..bca56cc 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseLiveAutomation.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseLiveAutomation.kt @@ -20,7 +20,15 @@ data class FinalConfirmationEvidence( ) data class PurchaseOrderEvidence(val orderNo: String, val submittedAt: String) -data class PurchaseOrderReadFailure(val code: String, val message: String) +data class PurchaseOrderReadFailure( + val code: String, + val message: String, + // `[必须]` 与是否读到订单号无关:只要采样期间出现过待付款/支付相关文案, + // 就说明大概率已经点通了下单、PDD 侧可能已经建了订单,只是没能读全证据 + // (比如返回窗口太短、错过了订单详情页)。给 order_result_unknown 分优先级用, + // 不改变判定结果本身(#302)。 + val paymentPageObserved: Boolean = false, +) class PurchaseLiveException(val code: String, message: String, val actualUnitPriceCent: Long? = null) : IllegalStateException(message) @@ -340,8 +348,9 @@ class PurchaseLiveAutomation( /** Reads the order flow with bounded chooser/payment Back actions and one verified PDD foreground request. */ fun readOrderResult(): PurchaseOrderEvidence? { lastOrderReadFailure = null + var paymentPageObserved = false fun unknown(code: String, message: String): PurchaseOrderEvidence? { - lastOrderReadFailure = PurchaseOrderReadFailure(code, message) + lastOrderReadFailure = PurchaseOrderReadFailure(code, message, paymentPageObserved) return null } val labels = linkedSetOf() @@ -399,6 +408,7 @@ class PurchaseLiveAutomation( // it permits only bounded reading gestures below, never a payment click. val paymentVisible = isKnownPddPaymentActivity(snapshot) && !orderContextVisible && !unpaidContextVisible if (paymentVisible) { + paymentPageObserved = true if (paymentBackAttempts == 0) { paymentBackAttempts++ if (!driver.backPurchase()) { @@ -420,6 +430,7 @@ class PurchaseLiveAutomation( return@repeat } consecutivePaymentSamplesAfterBack = 0 + if (unpaidContextVisible) paymentPageObserved = true if (!orderContextVisible && !unpaidContextVisible) { val entries = orderDetailEntryTargets(snapshot) if (entries.size > 1) { @@ -452,6 +463,9 @@ class PurchaseLiveAutomation( return unknown(failure.code, failure.message) } + // orderEvidenceFailure 之外的路径(选择器卡住、微信恢复超时等)同样要带上 + // 已经采集到的 paymentPageObserved,不能让 unknown() 之外的 return 漏标。 + private fun isKnownAndroidWechatChooser(snapshot: UiSnapshot, labels: Collection): Boolean { val packageKnown = snapshot.packageName in ANDROID_CHOOSER_PACKAGES val activity = snapshot.activityName.orEmpty() diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt index aa484fa..3aee85b 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt @@ -77,6 +77,8 @@ data class PurchaseExecutionOutcome( val pddOrderNo: String? = null, val orderSubmittedAt: String? = null, val actualUnitPriceCent: Long? = null, + // 仅 order_result_unknown 有意义:见 PurchaseOrderReadFailure 的说明(#302)。 + val paymentPageObserved: Boolean = false, ) class PurchaseRehearsalExecutor( @@ -140,6 +142,8 @@ class PurchaseRehearsalExecutor( live.submitOrderOnce() null } catch (error: PurchaseLiveException) { + // paymentPageObserved 留默认 false:此处是 submitOrderOnce() 本身失败 + // (提交按钮点击不明确),readOrderResult 还没跑过,谈不上见没见过支付页。 if (irreversibleStarted) PurchaseExecutionOutcome( "order_result_unknown", "PURCHASE_ORDER_RESULT_UNKNOWN", @@ -157,6 +161,7 @@ class PurchaseRehearsalExecutor( readFailure?.code ?: "PURCHASE_ORDER_RESULT_UNKNOWN", readFailure?.message ?: "无法确认订单是否创建,请人工检查", actualUnitPriceCent = observedPrice, + paymentPageObserved = readFailure?.paymentPageObserved ?: false, ) } } diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt index ee5d550..a759f86 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt @@ -619,6 +619,7 @@ class AgentForegroundService : Service() { "order_result_unknown", readFailure?.code ?: "PURCHASE_ORDER_RESULT_UNKNOWN", readFailure?.message ?: "无法确认订单是否创建,请人工检查", + paymentPageObserved = readFailure?.paymentPageObserved ?: false, ) } else { PurchaseExecutionOutcome("order_created", message = "订单已创建,等待人工检查和支付", pddOrderNo = evidence.orderNo, orderSubmittedAt = evidence.submittedAt) diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/service/PurchaseResultPayload.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/service/PurchaseResultPayload.kt index fe23a86..476afd5 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/service/PurchaseResultPayload.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/service/PurchaseResultPayload.kt @@ -16,6 +16,10 @@ internal fun purchaseResultPayload( put("errorCode", outcome.errorCode ?: "AGENT_EXECUTION_ERROR") put("errorMessage", outcome.message.take(1000)) } + // 仅 order_result_unknown 有意义;服务端只在这个结果类型下读取,其它类型忽略。 + if (outcome.resultType == "order_result_unknown" && outcome.paymentPageObserved) { + put("paymentPageObserved", true) + } outcome.probedSpecs?.let { put("probedSpecs", JSONObject(it)) } outcome.pddOrderNo?.let { put("pddOrderNo", it) } outcome.orderSubmittedAt?.let { put("orderSubmittedAt", it) } diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseLiveAutomationTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseLiveAutomationTest.kt index 7b6a5b7..e4fe2d9 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseLiveAutomationTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseLiveAutomationTest.kt @@ -317,6 +317,35 @@ class PurchaseLiveAutomationTest { assertFalse(driver.clicked.any { it.contains("支付") }) } + // `[必须]` #302:卡在支付页放弃时,paymentPageObserved 必须为 true——这正是 + // 「大概率已经下单只是没读到证据」的信号,区分于真的什么都没发生的失败。 + @Test + fun `payment repeated failure marks paymentPageObserved so it can be triaged later`() { + val driver = LiveDriver(postSubmitCaptureSequence = List(4) { "payment" }) + val automation = PurchaseLiveAutomation(driver, pause = {}) + val address = automation.updateShippingAddress("_cg55") + automation.finalConfirmation(input().copy(addressSuffix = "_cg55"), address) + automation.submitOrderOnce() + + assertEquals(null, automation.readOrderResult()) + assertEquals("PURCHASE_ORDER_PAYMENT_REPEATED", automation.lastOrderReadFailure?.code) + assertTrue(automation.lastOrderReadFailure?.paymentPageObserved ?: false) + } + + // 从未出现过支付/待付款文案的失败不该被标记,否则这个字段就没有区分价值了。 + @Test + fun `a failure that never saw the payment page leaves paymentPageObserved false`() { + val driver = LiveDriver(postSubmitCaptureSequence = List(15) { "empty" }) + val automation = PurchaseLiveAutomation(driver, pause = {}) + val address = automation.updateShippingAddress("_cg30") + automation.finalConfirmation(input().copy(addressSuffix = "_cg30"), address) + automation.submitOrderOnce() + + assertEquals(null, automation.readOrderResult()) + assertEquals("PURCHASE_ORDER_EMPTY_TIMEOUT", automation.lastOrderReadFailure?.code) + assertFalse(automation.lastOrderReadFailure?.paymentPageObserved ?: true) + } + @Test fun `continuous payment activity still stops at bounded post back samples without payment clicks`() { val driver = LiveDriver(postSubmitCaptureSequence = List(4) { "payment" }) diff --git a/server/app/goauto/models/purchase.go b/server/app/goauto/models/purchase.go index 2d0c5dd..bd82862 100644 --- a/server/app/goauto/models/purchase.go +++ b/server/app/goauto/models/purchase.go @@ -138,12 +138,16 @@ type PurchaseTask struct { StatusVersion uint64 `json:"statusVersion" gorm:"not null;default:1"` StatusChangedAt time.Time `json:"statusChangedAt" gorm:"not null"` - PDDOrderNo *string `json:"pddOrderNo" gorm:"size:100;index"` - OrderSubmittedAt *time.Time `json:"orderSubmittedAt"` - IrreversibleAt *time.Time `json:"irreversibleAt"` - PaymentReviewStatus string `json:"paymentReviewStatus" gorm:"size:16;not null;default:pending;check:ck_purchase_task_payment_review,payment_review_status IN ('pending','paid','unpaid')"` - PaymentReviewedAt *time.Time `json:"paymentReviewedAt"` - PaymentReviewedBy *uint64 `json:"paymentReviewedBy"` + PDDOrderNo *string `json:"pddOrderNo" gorm:"size:100;index"` + OrderSubmittedAt *time.Time `json:"orderSubmittedAt"` + IrreversibleAt *time.Time `json:"irreversibleAt"` + // PaymentPageObservedAt 仅在 order_result_unknown 时可能有值:采样期间见过 + // 待付款/支付相关文案,说明大概率已经在 PDD 建了订单,只是没能读全证据。 + // 用于给积压的 order_result_unknown 记录分优先级,不改变判定结果本身(#302)。 + PaymentPageObservedAt *time.Time `json:"paymentPageObservedAt"` + PaymentReviewStatus string `json:"paymentReviewStatus" gorm:"size:16;not null;default:pending;check:ck_purchase_task_payment_review,payment_review_status IN ('pending','paid','unpaid')"` + PaymentReviewedAt *time.Time `json:"paymentReviewedAt"` + PaymentReviewedBy *uint64 `json:"paymentReviewedBy"` TrackingNo *string `json:"trackingNo" gorm:"size:120"` TrackingCollectedAt *time.Time `json:"trackingCollectedAt"` diff --git a/server/app/goauto/purchase/lifecycle.go b/server/app/goauto/purchase/lifecycle.go index 381e495..7cc01b9 100644 --- a/server/app/goauto/purchase/lifecycle.go +++ b/server/app/goauto/purchase/lifecycle.go @@ -395,6 +395,9 @@ func (s *Service) SubmitResult(ctx context.Context, taskID uint64, req ResultReq a.ErrorCode, a.ErrorMessage = &failureCode, &failureMessage t.ErrorCode, t.ErrorMessage = &failureCode, &failureMessage t.ActualUnitPriceCent = req.ActualUnitPriceCent + if req.PaymentPageObserved { + t.PaymentPageObservedAt = &now + } case "failed": next = models.PurchaseTaskStatusFailed a.Status = models.PurchaseAttemptStatusFailed diff --git a/server/app/goauto/purchase/order_failure_evidence_test.go b/server/app/goauto/purchase/order_failure_evidence_test.go index da08337..28342e5 100644 --- a/server/app/goauto/purchase/order_failure_evidence_test.go +++ b/server/app/goauto/purchase/order_failure_evidence_test.go @@ -153,3 +153,57 @@ func TestSubmitFailedResultRecordsErrorOnAttempt(t *testing.T) { t.Fatalf("attempt error message not recorded: %+v", attempt.ErrorMessage) } } + +// `[必须]` 采样期间见过待付款/支付页时必须落库,这是后续按优先级处理积压的 +// order_result_unknown 记录的唯一依据(#302)。 +func TestSubmitOrderResultUnknownRecordsPaymentPageObserved(t *testing.T) { + db := testDB(t) + f := seed(t, db, liveCaps(), true) + s := testService(db) + task, _ := createLive(t, s, f) + started := startLivePurchaseAfterProbe(t, s, f, task) + if _, err := s.MarkOrderSubmitStarted(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil { + t.Fatal(err) + } + if _, err := s.SubmitResult(context.Background(), task.ID, ResultRequest{ + RequestID: uuid.NewString(), TaskAttemptID: started.TaskAttemptID, ResultType: "order_result_unknown", + ErrorCode: CodeOrderPaymentRepeat, ErrorMessage: paymentRepeatAgentMessage, + PaymentPageObserved: true, + }, f.token); err != nil { + t.Fatal(err) + } + + var saved models.PurchaseTask + if err := db.First(&saved, task.ID).Error; err != nil { + t.Fatal(err) + } + if saved.PaymentPageObservedAt == nil { + t.Fatal("paymentPageObserved=true 却没有落库时间戳") + } +} + +// 没见过支付页时不能编造一个时间戳——这个字段的价值就在于它只在真见过时才有值。 +func TestSubmitOrderResultUnknownLeavesPaymentPageObservedEmptyByDefault(t *testing.T) { + db := testDB(t) + f := seed(t, db, liveCaps(), true) + s := testService(db) + task, _ := createLive(t, s, f) + started := startLivePurchaseAfterProbe(t, s, f, task) + if _, err := s.MarkOrderSubmitStarted(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil { + t.Fatal(err) + } + if _, err := s.SubmitResult(context.Background(), task.ID, ResultRequest{ + RequestID: uuid.NewString(), TaskAttemptID: started.TaskAttemptID, ResultType: "order_result_unknown", + ErrorCode: CodeOrderPaymentRepeat, ErrorMessage: paymentRepeatAgentMessage, + }, f.token); err != nil { + t.Fatal(err) + } + + var saved models.PurchaseTask + if err := db.First(&saved, task.ID).Error; err != nil { + t.Fatal(err) + } + if saved.PaymentPageObservedAt != nil { + t.Fatal("未上报 paymentPageObserved 时不该写入时间戳") + } +} diff --git a/server/app/goauto/purchase/types.go b/server/app/goauto/purchase/types.go index b3431b6..52c8703 100644 --- a/server/app/goauto/purchase/types.go +++ b/server/app/goauto/purchase/types.go @@ -159,6 +159,9 @@ type ResultRequest struct { ErrorCode string `json:"errorCode,omitempty"` ErrorMessage string `json:"errorMessage,omitempty"` ProbedSpecs json.RawMessage `json:"probedSpecs,omitempty"` + // PaymentPageObserved 仅 order_result_unknown 使用;见 models.PurchaseTask + // 的 PaymentPageObservedAt 说明(#302)。 + PaymentPageObserved bool `json:"paymentPageObserved,omitempty"` } type SpecDecisionRequest struct { diff --git a/server/cmd/migrate/migration/version-local/1789800000000_purchase_task_payment_page_observed.go b/server/cmd/migrate/migration/version-local/1789800000000_purchase_task_payment_page_observed.go new file mode 100644 index 0000000..931fa61 --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1789800000000_purchase_task_payment_page_observed.go @@ -0,0 +1,31 @@ +package version_local + +import ( + "runtime" + + goautomigrations "go-admin/app/goauto/migrations" + "go-admin/cmd/migrate/migration" + common "go-admin/common/models" + + "gorm.io/gorm" +) + +func init() { + _, file, _, _ := runtime.Caller(0) + migration.Migrate.SetVersion(migration.GetFilename(file), migratePurchaseTaskPaymentPageObserved) +} + +// migratePurchaseTaskPaymentPageObserved adds the diagnostic column so +// order_result_unknown records can be prioritized by whether the Agent ever +// saw a payment/unpaid page (#302). +// +// `[必须]` 只加列,不回填。既有 107 笔历史 order_result_unknown 记录无法从数据里 +// 反推当时是否见过支付页,留空表示"未知",不能猜成 true 或 false。 +func migratePurchaseTaskPaymentPageObserved(db *gorm.DB, version string) error { + return db.Transaction(func(tx *gorm.DB) error { + if err := goautomigrations.Migrate(tx); err != nil { + return err + } + return tx.Create(&common.Migration{Version: version}).Error + }) +}