Compare commits

...
Author SHA1 Message Date
QiuSW 22755e05e3 fix(agent): 有界等待采购规格入口就绪并收紧商品页证据放行 (#211) 2026-09-03 17:53:25 +08:00
QiuSW 494969d4f8 fix(android): bound payment transition sampling (#210)
Allow up to two consecutive evidence-free PayActivity transition samples after the single safe Back; fail on the third post-Back sample (200ms cadence) to preserve a finite no-click payment boundary.
2026-09-03 17:16:20 +08:00
4 changed files with 125 additions and 38 deletions
@@ -117,7 +117,8 @@ class PurchaseLiveAutomation(
return null
}
val labels = linkedSetOf<String>()
var backedOutOfPayment = false
var paymentBackAttempts = 0
var consecutivePaymentSamplesAfterBack = 0
var backedOutOfChooser = false
var restoredFromWechat = false
var pddObservedAfterWechatRestore = false
@@ -170,17 +171,27 @@ class PurchaseLiveAutomation(
// it permits only bounded reading gestures below, never a payment click.
val paymentVisible = isKnownPddPaymentActivity(snapshot) && !orderContextVisible && !unpaidContextVisible
if (paymentVisible) {
if (!backedOutOfPayment) {
backedOutOfPayment = true
if (paymentBackAttempts == 0) {
paymentBackAttempts++
if (!driver.backPurchase()) {
return unknown("PURCHASE_ORDER_PAYMENT_BACK_FAILED", "支付页无法安全返回订单详情")
}
pause(500)
} else {
return unknown("PURCHASE_ORDER_PAYMENT_REPEATED", "支付页重复出现,已停止自动核单")
consecutivePaymentSamplesAfterBack++
if (consecutivePaymentSamplesAfterBack >= ORDER_RESULT_PAYMENT_POST_BACK_MAX_SAMPLES) {
return unknown(
"PURCHASE_ORDER_PAYMENT_REPEATED",
"支付页安全返回后持续无订单证据,已停止自动核单" +
"[paymentBackAttempts=$paymentBackAttempts;" +
"consecutivePaymentSamplesAfterBack=$consecutivePaymentSamplesAfterBack]",
)
}
pause(ORDER_RESULT_SAMPLE_INTERVAL_MS)
}
return@repeat
}
consecutivePaymentSamplesAfterBack = 0
if (!orderContextVisible && !unpaidContextVisible) {
val entries = orderDetailEntryTargets(snapshot)
if (entries.size > 1) {
@@ -469,6 +480,7 @@ 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_SCROLL_SAMPLE_INTERVAL = 15
const val ORDER_RESULT_SAMPLE_INTERVAL_MS = 200L
}
@@ -224,8 +224,9 @@ class PurchaseRehearsalExecutor(
repeat(50) {
val snapshot = driver.capture()
pageProblem(snapshot)?.let { return it }
if (snapshot.packageName == PDD_PACKAGE && snapshot.nodes.any { it.visible }) {
return recoverSoldOut(input, PddScreenParser.parse(snapshot, DEFAULT_COLLECTOR, input.goodsId, null))
val screen = PddScreenParser.parse(snapshot, DEFAULT_COLLECTOR, input.goodsId, null)
if (screen.hasPurchaseProductEvidence()) {
return recoverSoldOut(input, screen)
}
pause(100)
}
@@ -266,35 +267,44 @@ class PurchaseRehearsalExecutor(
private fun openSpecPanel(input: PurchaseExecutionInput, action: PurchaseAction): PurchaseExecutionOutcome? {
var screen = currentScreen(input)
if (screen.reviewPageOpen) return leaveUnexpectedReviewPage(input)
screen.problem?.let { return failure(it.code, it.message) }
if (screen.specPanelOpen) return null
val safeCandidates = listOfNotNull(
screen.specEntry?.let { anchor -> anchor to (screen.specEntryClickTarget ?: anchor) },
screen.quickConfirmationEntry?.let { it to it },
).distinctBy { it.second.path }
// A rule alias may only narrow the semantic candidates already accepted
// by PddScreenParser. It must never turn into a raw-page text lookup:
// review cards and unrelated controls can share arbitrary labels.
val candidates = action.textAliases?.let { aliases ->
safeCandidates.filter { (anchor, _) -> specEntryMatchesAliases(screen, anchor, aliases) }
} ?: safeCandidates
if (candidates.size > 1) {
panelDiagnostic(specEntryEvidence(screen, candidates.size))
return failure(SPEC_ENTRY_TARGET_AMBIGUOUS, "规格入口候选不唯一 [${specEntryEvidence(screen, candidates.size)}]")
var entryReadyWaitPolls = 0
var target: SnapshotNode? = null
while (target == null) {
if (screen.reviewPageOpen) return leaveUnexpectedReviewPage(input)
screen.problem?.let { return failure(it.code, it.message) }
if (screen.specPanelOpen) return null
val safeCandidates = listOfNotNull(
screen.specEntry?.let { anchor -> anchor to (screen.specEntryClickTarget ?: anchor) },
screen.quickConfirmationEntry?.let { it to it },
).distinctBy { it.second.path }
// A rule alias may only narrow the semantic candidates already accepted
// by PddScreenParser. It must never turn into a raw-page text lookup:
// review cards and unrelated controls can share arbitrary labels.
val candidates = action.textAliases?.let { aliases ->
safeCandidates.filter { (anchor, _) -> specEntryMatchesAliases(screen, anchor, aliases) }
} ?: safeCandidates
if (candidates.size > 1) {
panelDiagnostic(specEntryEvidence(screen, candidates.size, entryReadyWaitPolls))
return failure(SPEC_ENTRY_TARGET_AMBIGUOUS, "规格入口候选不唯一 [${specEntryEvidence(screen, candidates.size, entryReadyWaitPolls)}]")
}
target = candidates.singleOrNull()?.second
if (target != null) continue
if (entryReadyWaitPolls >= SPEC_ENTRY_READY_WAIT_POLLS) {
panelDiagnostic(specEntryEvidence(screen, 0, entryReadyWaitPolls))
return failure(SPEC_ENTRY_NOT_FOUND, "没有找到安全的商品规格入口 [${specEntryEvidence(screen, 0, entryReadyWaitPolls)}]")
}
pause(SPEC_ENTRY_READY_POLL_MILLIS)
entryReadyWaitPolls++
screen = currentScreen(input)
}
val target = candidates.singleOrNull()?.second ?: run {
panelDiagnostic(specEntryEvidence(screen, 0))
return failure(SPEC_ENTRY_NOT_FOUND, "没有找到安全的商品规格入口 [${specEntryEvidence(screen, 0)}]")
}
val click = driver.clickFreshDetailed(target)
val click = driver.clickFreshDetailed(requireNotNull(target))
when (click.result) {
// The parser already narrowed to a single semantic candidate; the
// ambiguity here comes from the live tree matching that target more
// than once at click time, so the wording must not claim otherwise.
FreshActionResult.AMBIGUOUS -> return failure(
SPEC_ENTRY_TARGET_AMBIGUOUS,
"规格入口点击目标不唯一 [${specEntryEvidence(screen, candidates.size)}]",
"规格入口点击目标不唯一 [${specEntryEvidence(screen, 1, entryReadyWaitPolls)}]",
)
FreshActionResult.SUCCESS -> Unit
else -> return failure(SPEC_ENTRY_CLICK_FAILED, click.reason.specEntrySubreason())
@@ -315,11 +325,12 @@ class PurchaseRehearsalExecutor(
"options=${screen.panelOptionCount};summary=${screen.hasSelectionSummary};quantity=${screen.hasQuantityControls};" +
"orderAction=${screen.hasOrderSubmitAction};pageEvidence=${screen.pageEvidenceMatched}"
private fun specEntryEvidence(screen: ParsedPddScreen, candidateCount: Int): String =
private fun specEntryEvidence(screen: ParsedPddScreen, candidateCount: Int, entryReadyWaitPolls: Int = 0): String =
"specEntryCandidates=$candidateCount;explicit=${screen.explicitSpecEntryCount};" +
"nested=${screen.nestedSpecEntryCount};bottomPurchase=${screen.bottomPurchaseEntryCount};" +
"panelAlreadyOpen=${screen.specPanelOpen};reviewPage=${screen.reviewPageOpen};" +
"pageEvidence=${screen.pageEvidenceMatched}"
"pageEvidence=${screen.pageEvidenceMatched};entryReadyWaitPolls=$entryReadyWaitPolls;" +
"entryReadyWaitMillis=${entryReadyWaitPolls * SPEC_ENTRY_READY_POLL_MILLIS}"
private fun specEntryMatchesAliases(screen: ParsedPddScreen, candidate: SnapshotNode, aliases: List<String>): Boolean {
val prefix = "${candidate.path}/"
@@ -677,6 +688,8 @@ class PurchaseRehearsalExecutor(
private const val OPEN_PRODUCT_POLL_LIMIT = 50
private const val OPEN_PRODUCT_RETRY_POLLS = 10
private const val OPEN_PRODUCT_POLL_MILLIS = 100L
private const val SPEC_ENTRY_READY_WAIT_POLLS = 20
private const val SPEC_ENTRY_READY_POLL_MILLIS = 100L
private const val SPEC_SELECTION_CLICK_ATTEMPTS = 3
private const val SPEC_SELECTION_SUCCESS_VERIFY_POLLS = 20
private const val SPEC_SELECTION_FAILED_VERIFY_POLLS = 5
@@ -199,8 +199,25 @@ class PurchaseLiveAutomationTest {
}
@Test
fun `repeated known payment activity still stops without scrolling or payment clicks`() {
val driver = LiveDriver(postSubmitCaptureSequence = listOf("payment", "payment"))
fun `payment transition frame after safe back reaches unpaid order evidence without payment clicks`() {
val driver = LiveDriver(postSubmitCaptureSequence = listOf("payment", "payment", "order"))
val automation = PurchaseLiveAutomation(driver, pause = {})
val address = automation.updateShippingAddress("_cg55")
automation.finalConfirmation(input().copy(addressSuffix = "_cg55"), address)
automation.submitOrderOnce()
val order = automation.readOrderResult()
assertEquals("PDD-202608210001", order?.orderNo)
assertEquals("2026-08-21T02:30:00Z", order?.submittedAt)
assertEquals(1, driver.postSubmitBackCount)
assertEquals(0, driver.genericSwipes)
assertFalse(driver.clicked.any { it.contains("支付") })
}
@Test
fun `continuous payment activity still stops at bounded post back samples without payment clicks`() {
val driver = LiveDriver(postSubmitCaptureSequence = List(4) { "payment" })
val automation = PurchaseLiveAutomation(driver, pause = {})
val address = automation.updateShippingAddress("_cg55")
automation.finalConfirmation(input().copy(addressSuffix = "_cg55"), address)
@@ -208,6 +225,12 @@ class PurchaseLiveAutomationTest {
assertEquals(null, automation.readOrderResult())
assertEquals("PURCHASE_ORDER_PAYMENT_REPEATED", automation.lastOrderReadFailure?.code)
assertEquals(
"支付页安全返回后持续无订单证据,已停止自动核单" +
"[paymentBackAttempts=1;consecutivePaymentSamplesAfterBack=3]",
automation.lastOrderReadFailure?.message,
)
assertEquals(4, driver.postSubmitCaptureCount)
assertEquals(1, driver.postSubmitBackCount)
assertEquals(0, driver.genericSwipes)
assertFalse(driver.clicked.any { it.contains("支付") })
@@ -445,23 +445,52 @@ class PurchaseRehearsalExecutorTest {
@Test
fun `missing spec entry emits scalar source counts without node text`() {
val diagnostics = mutableListOf<String>()
val pauses = mutableListOf<Long>()
val driver = FakePurchaseDriver(missingSpecEntry = true)
val outcome = PurchaseRehearsalExecutor(
driver,
{ driver.browser = true; true },
{ null },
pause = {},
pause = pauses::add,
panelDiagnostic = diagnostics::add,
).execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_ENTRY_NOT_FOUND", outcome.errorCode)
assertEquals(
"specEntryCandidates=0;explicit=0;nested=0;bottomPurchase=0;panelAlreadyOpen=false;reviewPage=false;pageEvidence=true",
"specEntryCandidates=0;explicit=0;nested=0;bottomPurchase=0;panelAlreadyOpen=false;reviewPage=false;pageEvidence=true;entryReadyWaitPolls=20;entryReadyWaitMillis=2000",
diagnostics.single(),
)
// One 100ms pause belongs to the existing open-product foreground poll;
// the diagnostic proves the entry-ready loop itself used exactly 20.
assertEquals(21, pauses.count { it == 100L })
assertFalse(diagnostics.single().contains("选择规格"))
}
@Test
fun `open spec panel waits for a late safe bottom purchase entry`() {
val pauses = mutableListOf<Long>()
val driver = FakePurchaseDriver(bottomPurchaseEntry = true, specEntryVisibleAfterPddCaptures = 4)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{\"dimensions\":[]}" }, pause = pauses::add)
.execute(input().copy(executionMode = "live", phase = "spec_probe"), PurchaseRuleParser.parse(liveRule()), PurchaseAgentCapabilities.supported)
assertEquals("spec_probe_completed", outcome.resultType)
assertTrue(driver.clickedPaths.contains("buy"))
// One 100ms pause belongs to the existing open-product foreground poll;
// two belong to the entry-ready wait before the bottom bar appears.
assertEquals(3, pauses.count { it == 100L })
}
@Test
fun `verify product waits for product evidence instead of a visible loading frame`() {
val pauses = mutableListOf<Long>()
val driver = FakePurchaseDriver(loadingPddCaptures = 1)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = pauses::add)
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
assertEquals(1, pauses.count { it == 100L })
}
@Test
fun `transient sold out page recovers before opening specs`() {
val driver = FakePurchaseDriver(soldOut = true, recoverSoldOutAfterPull = true)
@@ -613,7 +642,7 @@ class PurchaseRehearsalExecutorTest {
assertEquals("PURCHASE_SPEC_ENTRY_NOT_FOUND", outcome.errorCode)
assertEquals(
"没有找到安全的商品规格入口 [specEntryCandidates=0;explicit=0;nested=0;bottomPurchase=0;panelAlreadyOpen=false;reviewPage=false;pageEvidence=true]",
"没有找到安全的商品规格入口 [specEntryCandidates=0;explicit=0;nested=0;bottomPurchase=0;panelAlreadyOpen=false;reviewPage=false;pageEvidence=true;entryReadyWaitPolls=20;entryReadyWaitMillis=2000]",
outcome.message,
)
@@ -623,7 +652,7 @@ class PurchaseRehearsalExecutorTest {
assertEquals("PURCHASE_SPEC_ENTRY_TARGET_AMBIGUOUS", ambiguous.errorCode)
assertEquals(
"规格入口点击目标不唯一 [specEntryCandidates=1;explicit=1;nested=0;bottomPurchase=0;panelAlreadyOpen=false;reviewPage=false;pageEvidence=true]",
"规格入口点击目标不唯一 [specEntryCandidates=1;explicit=1;nested=0;bottomPurchase=0;panelAlreadyOpen=false;reviewPage=false;pageEvidence=true;entryReadyWaitPolls=0;entryReadyWaitMillis=0]",
ambiguous.message,
)
}
@@ -777,6 +806,8 @@ class PurchaseRehearsalExecutorTest {
private val duplicateOpen: Boolean = false,
private val bottomPurchaseEntry: Boolean = false,
private val missingSpecEntry: Boolean = false,
private val specEntryVisibleAfterPddCaptures: Int = 0,
private val loadingPddCaptures: Int = 0,
private val includeReviewEntry: Boolean = false,
private val openReviewOnBottomClick: Boolean = false,
private val reviewBackSucceeds: Boolean = true,
@@ -819,6 +850,7 @@ class PurchaseRehearsalExecutorTest {
private var allSpecsUnavailable = allSpecsUnavailable
private var productEvidenceLost = false
private var reviewPage = false
private var pddCaptureCount = 0
val clicked = mutableListOf<String>()
val clickedPaths = mutableListOf<String>()
@@ -829,6 +861,10 @@ class PurchaseRehearsalExecutorTest {
openNodes += node("content", "", 0, 0, 1080, 2200)
return UiSnapshot("com.heytap.browser", "BrowserActivity", openNodes)
}
pddCaptureCount++
if (pddCaptureCount <= loadingPddCaptures) {
return UiSnapshot(PDD, ACTIVITY, listOf(node("content", "", 0, 0, 1080, 2200)))
}
if (!panel) {
if (reviewPage) {
return UiSnapshot(PDD, ACTIVITY, listOf(
@@ -844,18 +880,21 @@ class PurchaseRehearsalExecutorTest {
if (soldOut) {
return UiSnapshot(PDD, ACTIVITY, listOf(
node("content", "", 0, 0, 1080, 2200),
node("title", "测试商品标题文本", 20, 200, 900, 280, className = "android.widget.ViewPager"),
node("sold-out", "商品已售罄", 100, 300, 900, 380),
node("similar", "相似商品", 100, 500, 900, 580),
))
}
val nodes = mutableListOf(
node("content", "", 0, 0, 1080, 2200),
node("title", "测试商品标题文本", 20, 200, 900, 280, className = "android.widget.ViewPager"),
)
if (bottomPurchaseEntry) {
val specEntryReady = pddCaptureCount > specEntryVisibleAfterPddCaptures
if (bottomPurchaseEntry && specEntryReady) {
nodes += node("buy", "", 500, 1800, 1080, 2180, clickable = true)
nodes += node("buy/price", "¥20.00", 560, 1840, 760, 1910, parentPath = "buy")
nodes += node("buy/label", "免拼购买", 780, 1840, 1040, 1910, parentPath = "buy")
} else if (!missingSpecEntry) {
} else if (!missingSpecEntry && specEntryReady) {
nodes += node("spec", "选择规格", 20, 1000, 900, 1100, clickable = true)
}
if (includeReviewEntry) nodes += node("review", "商品评价", 20, 1200, 900, 1300, clickable = true)