From b76fb73e123399295c0b02883317de412ad0886a Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Sat, 19 Sep 2026 11:52:53 +0800 Subject: [PATCH] fix: wait for unpaid order evidence and report payable total (#325) --- .../automation/PurchaseLiveAutomation.kt | 51 +++++++-- .../automation/PurchaseRehearsalExecutor.kt | 3 +- .../agent/service/AgentForegroundService.kt | 2 +- .../agent/service/PurchaseResultPayload.kt | 3 + .../agent/PurchaseLiveAutomationTest.kt | 102 +++++++++++++++++- .../goauto/agent/PurchaseResultPayloadTest.kt | 10 ++ server/app/goauto/purchase/lifecycle.go | 6 ++ .../purchase/order_payable_amount_test.go | 88 +++++++++++++++ server/app/goauto/purchase/types.go | 1 + 9 files changed, 256 insertions(+), 10 deletions(-) create mode 100644 server/app/goauto/purchase/order_payable_amount_test.go 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 bca56cc..14aed4d 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 @@ -19,7 +19,7 @@ data class FinalConfirmationEvidence( val activityName: String, ) -data class PurchaseOrderEvidence(val orderNo: String, val submittedAt: String) +data class PurchaseOrderEvidence(val orderNo: String, val submittedAt: String, val pddOrderAmountCent: Long? = null) data class PurchaseOrderReadFailure( val code: String, val message: String, @@ -362,7 +362,17 @@ class PurchaseLiveAutomation( var orderDetailEntryOpened = false var wechatRestorePendingSamples = 0 var consecutiveEmptySnapshots = 0 - repeat(ORDER_RESULT_MAX_SAMPLES) { index -> + var unpaidStartSample: Int? = null + var unpaidSwipes = 0 + repeat(ORDER_RESULT_MAX_SAMPLES + ORDER_RESULT_UNPAID_MAX_SAMPLES) { index -> + // The first unpaid page receives its own bounded budget, even after a long handoff. + // Never reset it on repeated labels or navigation back to payment. + val unpaidStart = unpaidStartSample + if ((unpaidStart == null && index >= ORDER_RESULT_MAX_SAMPLES) || + (unpaidStart != null && index - unpaidStart >= ORDER_RESULT_UNPAID_MAX_SAMPLES)) { + val failure = orderEvidenceFailure(labels) + return unknown(failure.code, failure.message) + } val snapshot = driver.capture() if (snapshot.packageName.isNullOrBlank()) { consecutiveEmptySnapshots++ @@ -430,8 +440,16 @@ class PurchaseLiveAutomation( return@repeat } consecutivePaymentSamplesAfterBack = 0 - if (unpaidContextVisible) paymentPageObserved = true + if (unpaidContextVisible) { + paymentPageObserved = true + if (unpaidStartSample == null) unpaidStartSample = index + } if (!orderContextVisible && !unpaidContextVisible) { + if (unpaidStartSample != null) { + // After reaching the unpaid page only observe; never click a newly exposed control. + pause(ORDER_RESULT_SAMPLE_INTERVAL_MS) + return@repeat + } val entries = orderDetailEntryTargets(snapshot) if (entries.size > 1) { return unknown("PURCHASE_ORDER_DETAIL_ENTRY_AMBIGUOUS", "订单详情入口不唯一,已停止只读核单") @@ -454,7 +472,13 @@ class PurchaseLiveAutomation( } currentLabels.forEach(labels::add) parseOrderEvidence(labels)?.let { return it } - if (index > 0 && index % ORDER_RESULT_SCROLL_SAMPLE_INTERVAL == 0) { + if (unpaidStartSample != null && unpaidSwipes < ORDER_RESULT_UNPAID_MAX_SWIPES) { + unpaidSwipes++ + driver.swipePurchase(SwipeDirection.UP, 400) + pause(ORDER_RESULT_UNPAID_SETTLE_MS) + return@repeat + } + if (unpaidStartSample == null && index > 0 && index % ORDER_RESULT_SCROLL_SAMPLE_INTERVAL == 0) { driver.swipePurchase(SwipeDirection.UP, 400) } pause(ORDER_RESULT_SAMPLE_INTERVAL_MS) @@ -728,7 +752,18 @@ class PurchaseLiveAutomation( runCatching { SimpleDateFormat(pattern, Locale.ROOT).apply { isLenient = false; timeZone = TimeZone.getTimeZone("Asia/Shanghai") }.parse(normalized) }.getOrNull() } ?: return null val output = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.ROOT).apply { timeZone = TimeZone.getTimeZone("UTC") }.format(parsed) - return PurchaseOrderEvidence(orderNumbers.single(), output) + return PurchaseOrderEvidence(orderNumbers.single(), output, parsePayableAmount(labels)) + } + + private fun parsePayableAmount(labels: Collection): Long? { + val amounts = labels.flatMap { label -> + PAYABLE_AMOUNT.findAll(label).map { match -> + runCatching { match.groupValues[1].toBigDecimal().movePointRight(2).longValueExact() }.getOrNull() + }.toList() + } + // Repeated parent/child labels are fine; conflicting/overflow amounts are omitted. + if (amounts.any { it == null }) return null + return amounts.distinct().singleOrNull() } private fun orderEvidenceFailure(labels: Collection): PurchaseOrderReadFailure { @@ -764,6 +799,7 @@ class PurchaseLiveAutomation( val UNPAID_MARKERS = listOf("待付款", "待支付", "去支付") val ORDER_DETAIL_ENTRY_MARKERS = setOf("查看订单", "订单详情") val ORDER_CONTEXT_MARKERS = listOf("订单编号", "订单号", "下单时间", "创建时间") + val PAYABLE_AMOUNT = Regex("(? { if (!irreversibleStarted) failure("PURCHASE_RULE_INVALID", "尚未进入创建订单边界") - else live.readOrderResult()?.let { PurchaseExecutionOutcome("order_created", message = "订单已创建,等待人工检查和支付", pddOrderNo = it.orderNo, orderSubmittedAt = it.submittedAt, actualUnitPriceCent = observedPrice) } + else live.readOrderResult()?.let { PurchaseExecutionOutcome("order_created", message = "订单已创建,等待人工检查和支付", pddOrderNo = it.orderNo, orderSubmittedAt = it.submittedAt, actualUnitPriceCent = observedPrice, pddOrderAmountCent = it.pddOrderAmountCent) } ?: live.lastOrderReadFailure.let { readFailure -> PurchaseExecutionOutcome( "order_result_unknown", 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 ffae726..c833f6a 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 @@ -724,7 +724,7 @@ class AgentForegroundService : Service() { paymentPageObserved = readFailure?.paymentPageObserved ?: false, ) } else { - PurchaseExecutionOutcome("order_created", message = "订单已创建,等待人工检查和支付", pddOrderNo = evidence.orderNo, orderSubmittedAt = evidence.submittedAt) + PurchaseExecutionOutcome("order_created", message = "订单已创建,等待人工检查和支付", pddOrderNo = evidence.orderNo, orderSubmittedAt = evidence.submittedAt, pddOrderAmountCent = evidence.pddOrderAmountCent) } val requestId = UUID.randomUUID().toString() purchaseStore.completeAndEnqueue(interrupted.taskId, interrupted.attemptId, requestId, purchaseResultPayload(requestId, interrupted.attemptId, outcome)) 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 476afd5..bc5bbc6 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 @@ -24,5 +24,8 @@ internal fun purchaseResultPayload( outcome.pddOrderNo?.let { put("pddOrderNo", it) } outcome.orderSubmittedAt?.let { put("orderSubmittedAt", it) } outcome.actualUnitPriceCent?.let { put("actualUnitPriceCent", it) } + if (outcome.resultType == "order_created") { + outcome.pddOrderAmountCent?.let { put("pddOrderAmountCent", it) } + } } .toString() 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 e4fe2d9..f464187 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 @@ -18,6 +18,104 @@ import org.junit.Assert.assertTrue import org.junit.Test class PurchaseLiveAutomationTest { + @Test + fun `two second payment transition and two needed scrolls yield order and payable amount without clicks`() { + val driver = ReadOnlyOrderDriver(paymentMs = 2500) + val order = PurchaseLiveAutomation(driver, pause = { driver.elapsed += it }).readOrderResult() + assertEquals("PDD-DEMO-325", order?.orderNo) + assertEquals("2026-09-19T03:21:43Z", order?.submittedAt) + assertEquals(1300L, order?.pddOrderAmountCent) + assertEquals(1, driver.backs) + assertEquals(2, driver.swipes) + assertEquals(0, driver.clicks) + } + + @Test + fun `unpaid page arriving near original limit gets independent read budget`() { + val driver = ReadOnlyOrderDriver(preludeSamples = 58) + val order = PurchaseLiveAutomation(driver, pause = { driver.elapsed += it }).readOrderResult() + assertEquals("PDD-DEMO-325", order?.orderNo) + assertTrue(driver.captures > 60) + assertEquals(2, driver.swipes) + assertEquals(0, driver.clicks) + } + + @Test + fun `unpaid scrolling is immediate and bounded when order never becomes visible`() { + val driver = ReadOnlyOrderDriver(requiredSwipes = 100) + val automation = PurchaseLiveAutomation(driver, pause = { driver.elapsed += it }) + assertEquals(null, automation.readOrderResult()) + assertEquals(4, driver.swipes) + assertEquals(0L, driver.firstSwipeAt) + assertTrue(driver.captures <= 30) + assertEquals(0, driver.clicks) + } + + @Test + fun `already visible order never scrolls and missing or conflicting payable amount never blocks order`() { + val cases = listOf( + emptyList() to null, + listOf("拼单价¥16", "平台优惠-¥3", "实付:13元") to null, + listOf("应付:13元", "应付:14元") to null, + listOf("应付:-13元", "应付:13.123元", "预计应付:13元") to null, + listOf("应付:999999999999999999999元") to null, + listOf("应付:13.20元", "应付:13.20元") to 1320L, + listOf("应付:0元") to 0L, + ) + for ((amounts, expected) in cases) { + val driver = ReadOnlyOrderDriver(requiredSwipes = 0, amounts = amounts) + val order = PurchaseLiveAutomation(driver, pause = {}).readOrderResult() + assertEquals("PDD-DEMO-325", order?.orderNo) + assertEquals(expected, order?.pddOrderAmountCent) + assertEquals(0, driver.swipes) + assertEquals(0, driver.clicks) + } + } + + private class ReadOnlyOrderDriver( + val paymentMs: Long = 0, + val preludeSamples: Int = 0, + val requiredSwipes: Int = 2, + val amounts: List = listOf("拼单价¥16", "平台优惠-¥3", "已优惠3元使用3元平台无门槛券,应付:,13元,(免运费)"), + ) : PurchaseUiDriver by LiveDriver() { + var elapsed = 0L + var captures = 0 + var backs = 0 + var swipes = 0 + var clicks = 0 + var firstSwipeAt: Long? = null + override fun capture(): UiSnapshot { + captures++ + if (captures <= preludeSamples) return UiSnapshot(PDD, ACTIVITY, emptyList()) + if (elapsed < paymentMs) return UiSnapshot(PDD, "com.xunmeng.pinduoduo.app_pay.core.PayActivity", listOf(node("立即支付"))) + return UiSnapshot(PDD, ACTIVITY, buildList { + add(node("待付款")) + // Amount scrolls away before the order fields become visible. + if (swipes == 0) amounts.forEach { add(node(it)) } + add(node("订单号:PDD-DEMO-325", swipes >= requiredSwipes)) + add(node("下单时间:2026-09-19 11:21:43", swipes >= requiredSwipes)) + add(node("去支付")) + }) + } + override fun backPurchase(): Boolean { backs++; return true } + override fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean { + assertEquals(SwipeDirection.UP, direction) + assertTrue(elapsed >= paymentMs) + if (firstSwipeAt == null) firstSwipeAt = elapsed + swipes++ + return true + } + override fun clickFresh(target: SnapshotNode): FreshActionResult { + clicks++ + throw AssertionError("Read-only unpaid branch must never click") + } + private fun node(text: String, visible: Boolean = true) = SnapshotNode( + text, null, text, null, null, "android.widget.TextView", + NodeBounds(0, 100, 500, if (visible) 180 else 100), + false, false, false, false, true, visible, + ) + } + @Test fun `verified spec panel advances through one exact confirm target`() { val driver = SpecConfirmationDriver() @@ -358,10 +456,10 @@ class PurchaseLiveAutomationTest { assertEquals("PURCHASE_ORDER_PAYMENT_REPEATED", automation.lastOrderReadFailure?.code) assertEquals( "支付页安全返回后持续无订单证据,已停止自动核单" + - "[paymentBackAttempts=1;consecutivePaymentSamplesAfterBack=3]", + "[paymentBackAttempts=1;consecutivePaymentSamplesAfterBack=25]", automation.lastOrderReadFailure?.message, ) - assertEquals(4, driver.postSubmitCaptureCount) + assertEquals(26, driver.postSubmitCaptureCount) assertEquals(1, driver.postSubmitBackCount) assertEquals(0, driver.genericSwipes) assertFalse(driver.clicked.any { it.contains("支付") }) diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseResultPayloadTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseResultPayloadTest.kt index 8a3bd3c..4b6b6ae 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseResultPayloadTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseResultPayloadTest.kt @@ -8,6 +8,16 @@ import org.junit.Assert.assertFalse import org.junit.Test class PurchaseResultPayloadTest { + @Test + fun `created order serializes optional payable total separately from unit price`() { + val outcome = PurchaseExecutionOutcome("order_created", message = "test", actualUnitPriceCent = 1600, pddOrderAmountCent = 1300) + val payload = JSONObject(purchaseResultPayload("r", "a", outcome)) + assertEquals(1300L, payload.getLong("pddOrderAmountCent")) + assertEquals(1600L, payload.getLong("actualUnitPriceCent")) + assertFalse(JSONObject(purchaseResultPayload("r", "a", outcome.copy(pddOrderAmountCent = null))).has("pddOrderAmountCent")) + assertFalse(JSONObject(purchaseResultPayload("r", "a", outcome.copy(resultType = "order_result_unknown"))).has("pddOrderAmountCent")) + } + @Test fun `unknown order result keeps stable failure and verified price without order fields`() { val payload = JSONObject(purchaseResultPayload( diff --git a/server/app/goauto/purchase/lifecycle.go b/server/app/goauto/purchase/lifecycle.go index 4d58310..db7b2a0 100644 --- a/server/app/goauto/purchase/lifecycle.go +++ b/server/app/goauto/purchase/lifecycle.go @@ -341,6 +341,9 @@ func (s *Service) SubmitResult(ctx context.Context, taskID uint64, req ResultReq if req.ActualUnitPriceCent != nil && *req.ActualUnitPriceCent < 0 { return TaskPayload{}, fail(CodeInvalidRequest, "实际单价无效") } + if req.PDDOrderAmountCent != nil && (*req.PDDOrderAmountCent < 0 || req.ResultType != "order_created") { + return TaskPayload{}, fail(CodeInvalidRequest, "订单应付金额仅可随已创建订单提交且不能为负数") + } now := s.Now() next := "" switch req.ResultType { @@ -396,6 +399,9 @@ func (s *Service) SubmitResult(ctx context.Context, taskID uint64, req ResultReq a.Status = models.PurchaseAttemptStatusFailed a.ErrorCode, a.ErrorMessage = &failureCode, &message } + if next == models.PurchaseTaskStatusOrderCreated && req.PDDOrderAmountCent != nil && t.PDDOrderAmountCent == nil { + t.PDDOrderAmountCent = req.PDDOrderAmountCent + } case "order_result_unknown": if t.ExecutionMode != models.PurchaseExecutionModeLive || t.Status != models.PurchaseTaskStatusOrderSubmitStarted { return TaskPayload{}, fail(CodeStateConflict, "当前任务不能标记订单结果未知") diff --git a/server/app/goauto/purchase/order_payable_amount_test.go b/server/app/goauto/purchase/order_payable_amount_test.go new file mode 100644 index 0000000..1583601 --- /dev/null +++ b/server/app/goauto/purchase/order_payable_amount_test.go @@ -0,0 +1,88 @@ +package purchase + +import ( + "context" + "testing" + + "github.com/google/uuid" + "go-admin/app/goauto/models" + "gorm.io/gorm" +) + +func TestOrderResultPayableAmount(t *testing.T) { + ptr := func(n int64) *int64 { return &n } + for _, tc := range []struct { + name string + amount, existing, want *int64 + result string + invalid bool + }{ + {"payable distinct from unit price", ptr(1300), nil, ptr(1300), "order_created", false}, + {"zero", ptr(0), nil, ptr(0), "order_created", false}, + {"old agent omitted", nil, nil, nil, "order_created", false}, + {"omitted preserves existing", nil, ptr(1200), ptr(1200), "order_created", false}, + {"does not overwrite existing", ptr(1300), ptr(1200), ptr(1200), "order_created", false}, + {"negative", ptr(-1), nil, nil, "order_created", true}, + {"unknown cannot attach amount", ptr(1300), nil, nil, "order_result_unknown", true}, + {"failed cannot attach amount", ptr(1300), nil, nil, "failed", true}, + } { + t.Run(tc.name, func(t *testing.T) { + db := testDB(t) + f := seed(t, db, liveCaps(), true) + s := testService(db) + task, err := createLive(t, s, f) + if err != nil { + t.Fatal(err) + } + 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 tc.existing != nil { + if err = db.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ?", task.ID).Update("pdd_order_amount_cent", *tc.existing).Error; err != nil { + t.Fatal(err) + } + } + now := s.Now() + req := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: started.TaskAttemptID, + ResultType: tc.result, PDDOrderNo: "PDD-DEMO-325", OrderSubmittedAt: &now, + ActualUnitPriceCent: ptr(1600), PDDOrderAmountCent: tc.amount} + _, err = s.SubmitResult(context.Background(), task.ID, req, f.token) + if tc.invalid { + if code(err) != CodeInvalidRequest { + t.Fatalf("want invalid request, got %v", err) + } + } else { + if err != nil { + t.Fatal(err) + } + replay, e := s.SubmitResult(context.Background(), task.ID, req, f.token) + if e != nil || !replay.Replayed { + t.Fatalf("replay failed: %v", e) + } + req.PDDOrderAmountCent = ptr(999) + if _, e = s.SubmitResult(context.Background(), task.ID, req, f.token); code(e) != CodeResultConflict { + t.Fatalf("changed amount replay accepted: %v", e) + } + queued := loadOrderWriteback(t, db, task.ID) + if queued.OrderNo != "PDD-DEMO-325" { + t.Fatal("SYB order number queue missing") + } + } + var saved models.PurchaseTask + if e := db.First(&saved, task.ID).Error; e != nil { + t.Fatal(e) + } + if (saved.PDDOrderAmountCent == nil) != (tc.want == nil) || (tc.want != nil && *saved.PDDOrderAmountCent != *tc.want) { + t.Fatal("unexpected saved payable amount") + } + if tc.invalid { + if saved.Status != models.PurchaseTaskStatusOrderSubmitStarted { + t.Fatal("invalid result changed status") + } + } else if saved.ActualUnitPriceCent == nil || *saved.ActualUnitPriceCent != 1600 { + t.Fatal("unit price changed") + } + }) + } +} diff --git a/server/app/goauto/purchase/types.go b/server/app/goauto/purchase/types.go index 3f35e88..0680d32 100644 --- a/server/app/goauto/purchase/types.go +++ b/server/app/goauto/purchase/types.go @@ -157,6 +157,7 @@ type ResultRequest struct { PDDOrderNo string `json:"pddOrderNo,omitempty"` OrderSubmittedAt *time.Time `json:"orderSubmittedAt,omitempty"` ActualUnitPriceCent *int64 `json:"actualUnitPriceCent,omitempty"` + PDDOrderAmountCent *int64 `json:"pddOrderAmountCent,omitempty"` ErrorCode string `json:"errorCode,omitempty"` ErrorMessage string `json:"errorMessage,omitempty"` ProbedSpecs json.RawMessage `json:"probedSpecs,omitempty"`