fix: order_result_unknown 补充"是否见过支付/待付款页"诊断标记 (#302)
order_result_unknown 目前是全有或全无:parseOrderEvidence 要求订单号、下单 时间、待付款/支付文案同时命中才算 order_created,任何一项缺失就落进同一个 order_result_unknown,无法区分"确实到过支付页只是没读全证据"和"根本没到 那一步"——前者大概率已在 PDD 建了真实订单。 Agent:readOrderResult 采样循环中,只要命中过支付页 Activity 或 unpaidContextVisible(待付款/待支付/去支付文案),记 paymentPageObserved, 与订单号是否解析成功无关,随 order_result_unknown 一起上报。 服务端:PurchaseTask 新增 PaymentPageObservedAt,仅在请求带 paymentPageObserved=true 时写入服务端当前时间;不回填既有 107 笔历史记录, 无法从历史数据反推当时是否见过支付页。 不改判定结果本身,order_created 的四项条件、批量重试逻辑均未动。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
This commit is contained in:
+16
-2
@@ -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<String>()
|
||||
@@ -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<String>): Boolean {
|
||||
val packageKnown = snapshot.packageName in ANDROID_CHOOSER_PACKAGES
|
||||
val activity = snapshot.activityName.orEmpty()
|
||||
|
||||
+5
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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" })
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 时不该写入时间戳")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+31
@@ -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
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user