fix: wait for unpaid order evidence and report payable total (#325)

This commit is contained in:
QiuSW
2026-09-19 11:52:53 +08:00
parent 10be37498d
commit b76fb73e12
9 changed files with 256 additions and 10 deletions
@@ -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<String>): 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<String>): 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("(?<![\\p{L}])应付[ \\t::,,]*[¥¥]?[ \\t]*(\\d+(?:\\.\\d{1,2})?)[ \\t]*元(?![0-9.])")
val ORDER_NO = Regex("(?:订单编号|订单号)\\s*[::]?\\s*([A-Za-z0-9-]{6,64})")
val ORDER_TIME = Regex("(?:下单时间|创建时间)\\s*[::]?\\s*(20[0-9]{2}[-/.年][0-9]{1,2}[-/.月][0-9]{1,2}日?\\s+[0-9]{1,2}:[0-9]{2}(?::[0-9]{2})?)")
val ANDROID_CHOOSER_PACKAGES = setOf("android", "com.android.intentresolver")
@@ -777,7 +813,10 @@ class PurchaseLiveAutomation(
const val ORDER_RESULT_MAX_SAMPLES = 60
const val ORDER_RESULT_MAX_EMPTY_SAMPLES = 15
const val ORDER_RESULT_WECHAT_RESTORE_MAX_SAMPLES = 15
const val ORDER_RESULT_PAYMENT_POST_BACK_MAX_SAMPLES = 3
const val ORDER_RESULT_PAYMENT_POST_BACK_MAX_SAMPLES = 25
const val ORDER_RESULT_UNPAID_MAX_SAMPLES = 30
const val ORDER_RESULT_UNPAID_MAX_SWIPES = 4
const val ORDER_RESULT_UNPAID_SETTLE_MS = 500L
const val ORDER_RESULT_SCROLL_SAMPLE_INTERVAL = 15
const val ORDER_RESULT_SAMPLE_INTERVAL_MS = 200L
const val SPEC_CONFIRMATION_MAX_SAMPLES = 20
@@ -79,6 +79,7 @@ data class PurchaseExecutionOutcome(
val actualUnitPriceCent: Long? = null,
// 仅 order_result_unknown 有意义:见 PurchaseOrderReadFailure 的说明(#302)。
val paymentPageObserved: Boolean = false,
val pddOrderAmountCent: Long? = null,
)
class PurchaseRehearsalExecutor(
@@ -154,7 +155,7 @@ class PurchaseRehearsalExecutor(
}
PurchaseActionType.READ_ORDER_RESULT -> {
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",
@@ -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))
@@ -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()
@@ -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<String>() 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<String> = 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("支付") })
@@ -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(
+6
View File
@@ -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, "当前任务不能标记订单结果未知")
@@ -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")
}
})
}
}
+1
View File
@@ -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"`