Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c55455d35a | ||
|
|
37714d39b5 |
+64
-27
@@ -42,6 +42,7 @@ class PurchaseLiveAutomation(
|
||||
private val pause: (Long) -> Unit = Thread::sleep,
|
||||
/** Boolean/count-only diagnostics; never receives address text. */
|
||||
private val panelDiagnostic: (String) -> Unit = {},
|
||||
private val monotonicClockMs: () -> Long = { System.nanoTime() / 1_000_000L },
|
||||
) {
|
||||
private var submitAttempted = false
|
||||
var lastOrderReadFailure: PurchaseOrderReadFailure? = null
|
||||
@@ -366,48 +367,83 @@ class PurchaseLiveAutomation(
|
||||
var consecutiveEmptySnapshots = 0
|
||||
var unpaidStartSample: Int? = null
|
||||
var unpaidSwipes = 0
|
||||
repeat(ORDER_RESULT_MAX_SAMPLES + ORDER_RESULT_UNPAID_MAX_SAMPLES) { index ->
|
||||
var paymentBackSucceededAt: Long? = null
|
||||
var orderContextObserved = false
|
||||
// Extra observations cannot reopen navigation/scroll budgets. Late unpaid evidence
|
||||
// still receives its existing independent budget below.
|
||||
var passiveExtension = false
|
||||
fun observationRemainingMs(): Long? {
|
||||
val startedAt = paymentBackSucceededAt ?: return null
|
||||
if (unpaidStartSample != null || (orderContextObserved && !passiveExtension)) return null
|
||||
return ORDER_RESULT_POST_PAYMENT_OBSERVATION_MS - (monotonicClockMs() - startedAt)
|
||||
}
|
||||
fun observePause(durationMs: Long) {
|
||||
val remaining = observationRemainingMs()
|
||||
val duration = if (remaining == null) durationMs else minOf(durationMs, remaining.coerceAtLeast(0))
|
||||
if (duration > 0) pause(duration)
|
||||
}
|
||||
var nextSample = 0
|
||||
while (true) {
|
||||
val index = nextSample++
|
||||
// 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) ||
|
||||
if (unpaidStart == null && index >= ORDER_RESULT_MAX_SAMPLES &&
|
||||
paymentBackSucceededAt != null && !orderContextObserved) {
|
||||
passiveExtension = true
|
||||
}
|
||||
if ((observationRemainingMs()?.let { it <= 0 } == true) ||
|
||||
(unpaidStart == null && index >= ORDER_RESULT_MAX_SAMPLES && !passiveExtension) ||
|
||||
(unpaidStart != null && index - unpaidStart >= ORDER_RESULT_UNPAID_MAX_SAMPLES)) {
|
||||
val failure = orderEvidenceFailure(labels)
|
||||
return unknown(failure.code, failure.message)
|
||||
}
|
||||
val snapshot = driver.capture()
|
||||
// A blocking capture can finish after the deadline. Do not act on or accept that frame.
|
||||
if (observationRemainingMs()?.let { it <= 0 } == true) {
|
||||
val failure = orderEvidenceFailure(labels)
|
||||
return unknown(failure.code, failure.message)
|
||||
}
|
||||
if (snapshot.packageName.isNullOrBlank()) {
|
||||
consecutiveEmptySnapshots++
|
||||
if (consecutiveEmptySnapshots >= ORDER_RESULT_MAX_EMPTY_SAMPLES) {
|
||||
return unknown("PURCHASE_ORDER_EMPTY_TIMEOUT", "等待订单页面时无障碍窗口持续为空")
|
||||
}
|
||||
pause(ORDER_RESULT_SAMPLE_INTERVAL_MS)
|
||||
return@repeat
|
||||
observePause(ORDER_RESULT_SAMPLE_INTERVAL_MS)
|
||||
continue
|
||||
}
|
||||
consecutiveEmptySnapshots = 0
|
||||
val currentLabels = snapshot.nodes.filter { it.visible }.map { it.label }
|
||||
if (isKnownAndroidWechatChooser(snapshot, currentLabels)) {
|
||||
if (passiveExtension && !backedOutOfChooser) {
|
||||
val failure = orderEvidenceFailure(labels)
|
||||
return unknown(failure.code, failure.message)
|
||||
}
|
||||
if (backedOutOfChooser || !driver.backPurchase()) {
|
||||
return unknown("PURCHASE_ORDER_CHOOSER_BACK_FAILED", "系统应用选择页无法安全返回")
|
||||
}
|
||||
backedOutOfChooser = true
|
||||
pause(500)
|
||||
return@repeat
|
||||
observePause(500)
|
||||
continue
|
||||
}
|
||||
if (snapshot.packageName == WECHAT_PACKAGE) {
|
||||
if (!restoredFromWechat) {
|
||||
if (passiveExtension) {
|
||||
val failure = orderEvidenceFailure(labels)
|
||||
return unknown(failure.code, failure.message)
|
||||
}
|
||||
if (!driver.bringPddToForeground()) {
|
||||
return unknown("PURCHASE_ORDER_WECHAT_RESTORE_FAILED", "从微信恢复拼多多的请求失败")
|
||||
}
|
||||
restoredFromWechat = true
|
||||
pause(500)
|
||||
return@repeat
|
||||
observePause(500)
|
||||
continue
|
||||
}
|
||||
if (pddObservedAfterWechatRestore || ++wechatRestorePendingSamples >= ORDER_RESULT_WECHAT_RESTORE_MAX_SAMPLES) {
|
||||
return unknown("PURCHASE_ORDER_WECHAT_RESTORE_TIMEOUT", "从微信恢复拼多多后未在限定时间到达订单页面")
|
||||
}
|
||||
pause(ORDER_RESULT_SAMPLE_INTERVAL_MS)
|
||||
return@repeat
|
||||
observePause(ORDER_RESULT_SAMPLE_INTERVAL_MS)
|
||||
continue
|
||||
}
|
||||
if (snapshot.packageName != PDD_PACKAGE) {
|
||||
return unknown("PURCHASE_ORDER_UNEXPECTED_APP", "核单期间出现未授权应用")
|
||||
@@ -426,7 +462,8 @@ class PurchaseLiveAutomation(
|
||||
if (!driver.backPurchase()) {
|
||||
return unknown("PURCHASE_ORDER_PAYMENT_BACK_FAILED", "支付页无法安全返回订单详情")
|
||||
}
|
||||
pause(500)
|
||||
paymentBackSucceededAt = monotonicClockMs()
|
||||
observePause(500)
|
||||
} else {
|
||||
consecutivePaymentSamplesAfterBack++
|
||||
if (consecutivePaymentSamplesAfterBack >= ORDER_RESULT_PAYMENT_POST_BACK_MAX_SAMPLES) {
|
||||
@@ -437,20 +474,21 @@ class PurchaseLiveAutomation(
|
||||
"consecutivePaymentSamplesAfterBack=$consecutivePaymentSamplesAfterBack]",
|
||||
)
|
||||
}
|
||||
pause(ORDER_RESULT_SAMPLE_INTERVAL_MS)
|
||||
observePause(ORDER_RESULT_SAMPLE_INTERVAL_MS)
|
||||
}
|
||||
return@repeat
|
||||
continue
|
||||
}
|
||||
consecutivePaymentSamplesAfterBack = 0
|
||||
if (orderContextVisible) orderContextObserved = 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
|
||||
if (unpaidStartSample != null || passiveExtension) {
|
||||
// Neither unpaid reading nor the added observation budget permits a new click.
|
||||
observePause(ORDER_RESULT_SAMPLE_INTERVAL_MS)
|
||||
continue
|
||||
}
|
||||
val entries = orderDetailEntryTargets(snapshot)
|
||||
if (entries.size > 1) {
|
||||
@@ -466,27 +504,25 @@ class PurchaseLiveAutomation(
|
||||
else -> return unknown("PURCHASE_ORDER_DETAIL_ENTRY_FAILED", "订单详情入口点击失败,已停止只读核单")
|
||||
}
|
||||
orderDetailEntryOpened = true
|
||||
pause(500)
|
||||
return@repeat
|
||||
observePause(500)
|
||||
continue
|
||||
}
|
||||
pause(ORDER_RESULT_SAMPLE_INTERVAL_MS)
|
||||
return@repeat
|
||||
observePause(ORDER_RESULT_SAMPLE_INTERVAL_MS)
|
||||
continue
|
||||
}
|
||||
currentLabels.forEach(labels::add)
|
||||
parseOrderEvidence(labels)?.let { return it }
|
||||
if (unpaidStartSample != null && unpaidSwipes < ORDER_RESULT_UNPAID_MAX_SWIPES) {
|
||||
unpaidSwipes++
|
||||
driver.swipePurchase(SwipeDirection.UP, 400)
|
||||
pause(ORDER_RESULT_UNPAID_SETTLE_MS)
|
||||
return@repeat
|
||||
observePause(ORDER_RESULT_UNPAID_SETTLE_MS)
|
||||
continue
|
||||
}
|
||||
if (unpaidStartSample == null && index > 0 && index % ORDER_RESULT_SCROLL_SAMPLE_INTERVAL == 0) {
|
||||
if (!passiveExtension && unpaidStartSample == null && index > 0 && index % ORDER_RESULT_SCROLL_SAMPLE_INTERVAL == 0) {
|
||||
driver.swipePurchase(SwipeDirection.UP, 400)
|
||||
}
|
||||
pause(ORDER_RESULT_SAMPLE_INTERVAL_MS)
|
||||
observePause(ORDER_RESULT_SAMPLE_INTERVAL_MS)
|
||||
}
|
||||
val failure = orderEvidenceFailure(labels)
|
||||
return unknown(failure.code, failure.message)
|
||||
}
|
||||
|
||||
// orderEvidenceFailure 之外的路径(选择器卡住、微信恢复超时等)同样要带上
|
||||
@@ -1038,6 +1074,7 @@ class PurchaseLiveAutomation(
|
||||
val ANDROID_CHOOSER_TITLES = setOf("选择要使用的应用", "使用以下应用打开", "完成操作时使用")
|
||||
val PDD_PAYMENT_ACTIVITIES = setOf("com.xunmeng.pinduoduo.app_pay.core.PayActivity")
|
||||
const val ORDER_RESULT_MAX_SAMPLES = 60
|
||||
const val ORDER_RESULT_POST_PAYMENT_OBSERVATION_MS = 30_000L
|
||||
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 = 25
|
||||
|
||||
@@ -18,6 +18,283 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class PurchaseLiveAutomationTest {
|
||||
@Test
|
||||
fun `payment back observes late complete order beyond original sample budget`() {
|
||||
val driver = OrderObservationDriver { sample ->
|
||||
when {
|
||||
sample == 1 -> observationPage("payment")
|
||||
sample < 100 -> observationPage("loading")
|
||||
else -> observationPage("complete")
|
||||
}
|
||||
}
|
||||
val order = observationAutomation(driver).readOrderResult()
|
||||
|
||||
assertEquals("PDD-DEMO-365", order?.orderNo)
|
||||
assertEquals(100, driver.captures)
|
||||
assertEquals(1, driver.backs)
|
||||
assertEquals(0, driver.swipes)
|
||||
assertEquals(0, driver.clicks)
|
||||
assertEquals(0, driver.foregroundRequests)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deadline starts at late successful back and never resets on incomplete evidence or jitter`() {
|
||||
val driver = OrderObservationDriver { sample ->
|
||||
observationPage(when {
|
||||
sample < 58 -> "loading"
|
||||
sample == 58 -> "payment"
|
||||
sample > 60 && sample % 3 == 0 -> "incomplete"
|
||||
else -> "loading"
|
||||
})
|
||||
}.apply { backDuration = 4_000; captureDuration = 25 }
|
||||
val automation = observationAutomation(driver, pauseOverhead = 17)
|
||||
|
||||
assertEquals(null, automation.readOrderResult())
|
||||
assertTrue(driver.captures > 100)
|
||||
assertEquals(driver.backCompletedAt!! + 30_000 + 17, driver.elapsed)
|
||||
assertTrue(automation.lastOrderReadFailure!!.paymentPageObserved)
|
||||
assertObservationActions(driver, backs = 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `capture that crosses deadline cannot accept late complete evidence or trigger unpaid scroll`() {
|
||||
for (latePage in listOf("complete", "unpaid", "entry", "chooser", "wechat")) {
|
||||
lateinit var driver: OrderObservationDriver
|
||||
driver = OrderObservationDriver { sample ->
|
||||
if (sample == 1) {
|
||||
driver.captureDuration = 30_000
|
||||
observationPage("payment")
|
||||
} else observationPage(latePage)
|
||||
}
|
||||
val automation = observationAutomation(driver)
|
||||
|
||||
assertEquals(null, automation.readOrderResult())
|
||||
assertEquals(2, driver.captures)
|
||||
assertEquals(30_500L, driver.elapsed)
|
||||
assertObservationActions(driver, backs = 1)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `slow captures exhaust fixed observation deadline before original sixty samples`() {
|
||||
val driver = OrderObservationDriver { sample -> observationPage(if (sample == 1) "payment" else "loading") }
|
||||
.apply { captureDuration = 1_000 }
|
||||
val automation = observationAutomation(driver)
|
||||
|
||||
assertEquals(null, automation.readOrderResult())
|
||||
assertTrue(driver.captures < 60)
|
||||
assertTrue(driver.elapsed >= driver.backCompletedAt!! + 30_000)
|
||||
assertTrue(driver.elapsed < driver.backCompletedAt!! + 31_000)
|
||||
assertObservationActions(driver, backs = 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `incomplete context first appearing during extension stays passive until late time arrives`() {
|
||||
val driver = OrderObservationDriver { sample -> observationPage(when {
|
||||
sample == 1 -> "payment"
|
||||
sample < 80 -> "loading"
|
||||
sample < 100 -> "incomplete"
|
||||
else -> "complete"
|
||||
}) }
|
||||
|
||||
assertEquals("PDD-DEMO-365", observationAutomation(driver).readOrderResult()?.orderNo)
|
||||
assertEquals(100, driver.captures)
|
||||
assertObservationActions(driver, backs = 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `late detail entry chooser and WeChat cannot add navigation during extension`() {
|
||||
for (latePage in listOf("entry", "chooser", "wechat")) {
|
||||
val driver = OrderObservationDriver { sample -> observationPage(when {
|
||||
sample == 1 -> "payment"
|
||||
sample <= 60 -> "loading"
|
||||
else -> latePage
|
||||
}) }
|
||||
val automation = observationAutomation(driver)
|
||||
assertEquals(null, automation.readOrderResult())
|
||||
assertEquals("PURCHASE_ORDER_CONTEXT_NOT_FOUND", automation.lastOrderReadFailure?.code)
|
||||
assertTrue(driver.captures > 60)
|
||||
assertObservationActions(driver, backs = 1)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `previous chooser and WeChat recovery retain repeat failures during extension`() {
|
||||
for (kind in listOf("chooser", "wechat")) {
|
||||
val driver = OrderObservationDriver { sample -> observationPage(when {
|
||||
sample == 1 || sample > 60 -> kind
|
||||
sample == 2 -> "payment"
|
||||
else -> "loading"
|
||||
}) }
|
||||
val automation = observationAutomation(driver)
|
||||
assertEquals(null, automation.readOrderResult())
|
||||
assertEquals(
|
||||
if (kind == "chooser") "PURCHASE_ORDER_CHOOSER_BACK_FAILED" else "PURCHASE_ORDER_WECHAT_RESTORE_TIMEOUT",
|
||||
automation.lastOrderReadFailure?.code,
|
||||
)
|
||||
assertEquals(61, driver.captures)
|
||||
assertEquals(if (kind == "chooser") 2 else 1, driver.backs)
|
||||
assertEquals(if (kind == "wechat") 1 else 0, driver.foregroundRequests)
|
||||
assertEquals(0, driver.swipes)
|
||||
assertEquals(0, driver.clicks)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `late unpaid context retains its independent thirty samples and four swipes`() {
|
||||
val driver = OrderObservationDriver { sample -> observationPage(when {
|
||||
sample == 1 -> "payment"
|
||||
sample < 140 -> "loading"
|
||||
else -> "unpaid"
|
||||
}) }
|
||||
val automation = observationAutomation(driver)
|
||||
|
||||
assertEquals(null, automation.readOrderResult())
|
||||
assertEquals(169, driver.captures)
|
||||
assertTrue(driver.elapsed > 30_000)
|
||||
assertObservationActions(driver, backs = 1, swipes = 4)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unpaid markers alone never activate payment back extension`() {
|
||||
val driver = OrderObservationDriver { observationPage("unpaid") }
|
||||
val automation = observationAutomation(driver)
|
||||
|
||||
assertEquals(null, automation.readOrderResult())
|
||||
assertEquals(30, driver.captures)
|
||||
assertTrue(automation.lastOrderReadFailure!!.paymentPageObserved)
|
||||
assertObservationActions(driver, swipes = 4)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing payment back retains original sixty samples and existing context scroll budget`() {
|
||||
for (kind in listOf("loading", "incomplete")) {
|
||||
val driver = OrderObservationDriver { observationPage(kind) }
|
||||
assertEquals(null, observationAutomation(driver).readOrderResult())
|
||||
assertEquals(60, driver.captures)
|
||||
assertObservationActions(driver, swipes = if (kind == "incomplete") 3 else 0)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `order context within original budget retains original sampling and gestures after back`() {
|
||||
val driver = OrderObservationDriver { sample -> observationPage(if (sample == 1) "payment" else "incomplete") }
|
||||
assertEquals(null, observationAutomation(driver).readOrderResult())
|
||||
assertEquals(60, driver.captures)
|
||||
assertObservationActions(driver, backs = 1, swipes = 3)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failed payment back stops immediately without activating observation`() {
|
||||
val driver = OrderObservationDriver { observationPage("payment") }.apply { backSucceeds = false }
|
||||
val automation = observationAutomation(driver)
|
||||
assertEquals(null, automation.readOrderResult())
|
||||
assertEquals("PURCHASE_ORDER_PAYMENT_BACK_FAILED", automation.lastOrderReadFailure?.code)
|
||||
assertEquals(1, driver.captures)
|
||||
assertEquals(0L, driver.elapsed)
|
||||
assertObservationActions(driver, backs = 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `extension preserves repeated payment empty and unexpected app early failures`() {
|
||||
for ((kind, code, expectedCaptures) in listOf(
|
||||
Triple("payment", "PURCHASE_ORDER_PAYMENT_REPEATED", 85),
|
||||
Triple("empty", "PURCHASE_ORDER_EMPTY_TIMEOUT", 75),
|
||||
Triple("unexpected", "PURCHASE_ORDER_UNEXPECTED_APP", 61),
|
||||
)) {
|
||||
val driver = OrderObservationDriver { sample -> observationPage(when {
|
||||
sample == 1 -> "payment"
|
||||
sample <= 60 -> "loading"
|
||||
else -> kind
|
||||
}) }
|
||||
val automation = observationAutomation(driver)
|
||||
assertEquals(null, automation.readOrderResult())
|
||||
assertEquals(code, automation.lastOrderReadFailure?.code)
|
||||
assertEquals(expectedCaptures, driver.captures)
|
||||
assertObservationActions(driver, backs = 1)
|
||||
}
|
||||
}
|
||||
|
||||
private fun observationAutomation(driver: OrderObservationDriver, pauseOverhead: Long = 0) = PurchaseLiveAutomation(
|
||||
driver,
|
||||
pause = { driver.elapsed += it + pauseOverhead },
|
||||
monotonicClockMs = { driver.elapsed },
|
||||
)
|
||||
|
||||
private fun assertObservationActions(driver: OrderObservationDriver, backs: Int = 0, swipes: Int = 0) {
|
||||
assertEquals(backs, driver.backs)
|
||||
assertEquals(swipes, driver.swipes)
|
||||
assertEquals(0, driver.clicks)
|
||||
assertEquals(0, driver.foregroundRequests)
|
||||
}
|
||||
|
||||
private class OrderObservationDriver(
|
||||
val frame: (Int) -> UiSnapshot,
|
||||
) : PurchaseUiDriver by LiveDriver() {
|
||||
var elapsed = 0L
|
||||
var captures = 0
|
||||
var backs = 0
|
||||
var swipes = 0
|
||||
var clicks = 0
|
||||
var foregroundRequests = 0
|
||||
var captureDuration = 0L
|
||||
var backDuration = 0L
|
||||
var backCompletedAt: Long? = null
|
||||
var backSucceeds = true
|
||||
override fun capture(): UiSnapshot {
|
||||
elapsed += captureDuration
|
||||
return frame(++captures)
|
||||
}
|
||||
override fun backPurchase(): Boolean {
|
||||
backs++
|
||||
elapsed += backDuration
|
||||
if (backSucceeds) backCompletedAt = elapsed
|
||||
return backSucceeds
|
||||
}
|
||||
override fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean {
|
||||
swipes++
|
||||
return true
|
||||
}
|
||||
override fun clickFresh(target: SnapshotNode): FreshActionResult {
|
||||
clicks++
|
||||
return FreshActionResult.SUCCESS
|
||||
}
|
||||
override fun bringPddToForeground(): Boolean {
|
||||
foregroundRequests++
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private fun observationPage(kind: String): UiSnapshot {
|
||||
val labels = when (kind) {
|
||||
"payment" -> listOf("立即支付")
|
||||
"complete" -> listOf("订单号:PDD-DEMO-365", "下单时间:2026-10-08 11:21:43", "立即支付")
|
||||
"incomplete" -> listOf("订单号:PDD-DEMO-365", "立即支付")
|
||||
"unpaid" -> listOf("待付款")
|
||||
"entry" -> listOf("查看订单")
|
||||
"chooser" -> listOf("选择要使用的应用", "微信")
|
||||
else -> emptyList()
|
||||
}
|
||||
return UiSnapshot(
|
||||
when (kind) {
|
||||
"empty" -> null
|
||||
"wechat" -> "com.tencent.mm"
|
||||
"unexpected" -> "com.example.unexpected"
|
||||
"chooser" -> "android"
|
||||
else -> PDD
|
||||
},
|
||||
when (kind) {
|
||||
"payment" -> "com.xunmeng.pinduoduo.app_pay.core.PayActivity"
|
||||
"chooser" -> "com.android.internal.app.ChooserActivity"
|
||||
else -> ACTIVITY
|
||||
},
|
||||
labels.map { text ->
|
||||
SnapshotNode(text, null, text, null, null, "android.widget.TextView",
|
||||
NodeBounds(0, 100, 500, 180), true, false, false, false, true, true)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two second payment transition and two needed scrolls yield order and payable amount without clicks`() {
|
||||
val driver = ReadOnlyOrderDriver(paymentMs = 2500)
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Business-Rules-and-Glossary
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.-
|
||||
wiki_revision: 233b72e9e2415a2456bb8dd27c770df881960db4
|
||||
synchronized_at: 2026-10-08T01:42:04Z
|
||||
wiki_revision: 25fdd0c4837882dccb27c7b9e740d6d97065d490
|
||||
synchronized_at: 2026-10-08T03:25:26Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -670,10 +670,21 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
|
||||
|
||||
实现分支 fix/325-order-result,2026-09-19;未部署、未真机验收。下单点击及不可逆边界不改。已知 PayActivity 允许一次原有 Back,随后从3次放宽为25次200ms采样(约5秒等待,不含页面捕获耗时),持续无证据仍为 PURCHASE_ORDER_PAYMENT_REPEATED。微信恢复、系统选择器、空窗口和陌生应用的既有边界保留。
|
||||
|
||||
过渡阶段最多60次采样。首次识别到待付款证据后单独分配30次采样预算,不被之前绕路消耗,不因文字反复出现重置;核单总采样上限90次。缺少唯一订单号或时间时立即向上滑动读取下方内容,最多4次、手势400ms、每次后等500ms;读全立即停止,已有完整字段不滑动。该待付款读取分支不点击任何控件,也不点击付款。纯支付页不使用该滑动分支;出现可读待付款证据优先于复用的 Activity 名称。按采样间隔约18秒加有限手势/额外等待,实际耗时还含capture等调用;未改变2分钟任务租约或5分钟唤醒锁,也不宣称新增了执行器总体墙钟超时机制。
|
||||
原 #325 过渡阶段最多60次采样。首次识别到待付款证据后单独分配30次采样预算,不被之前绕路消耗,不因文字反复出现重置;原核单总采样上限90次。#365 工单分支增加下节所述成功支付页返回后的定时观察例外,不能再把90次视为该例外路径的总上限。缺少唯一订单号或时间时立即向上滑动读取下方内容,最多4次、手势400ms、每次后等500ms;读全立即停止,已有完整字段不滑动。该待付款读取分支不点击任何控件,也不点击付款。纯支付页不使用该滑动分支;出现可读待付款证据优先于复用的 Activity 名称。按采样间隔约18秒加有限手势/额外等待,实际耗时还含capture等调用;未改变2分钟任务租约或5分钟唤醒锁,也不宣称新增了执行器总体墙钟超时机制。
|
||||
|
||||
应付金额从同一订单的可见标签集合中读取,兼容“应付:,13元”等标点;不跨无关联节点拼接,不读拼单价、优惠或实付。父子重复同金额去重,多值/缺失/无效/溢出省略,不因金额失败丢弃订单号与时间。仅存Admin,不推断支付、不改SYB金额、不修改地址行为。历史回填及界面语义未在本单统一,详见API契约#325补充。
|
||||
|
||||
## 支付页安全返回后的核单观察(#365)
|
||||
|
||||
实现绑定 `37714d39b5ac8ff720cfa1d0d2609f9a8af3fbd9`,工单分支已实现,未合并 main、未安装或发布;自动化验证不代表真机核单成功率已改善。仅 Android 变化,Admin、Server、数据库和上传状态契约不改。
|
||||
|
||||
- 只有识别到 PDD 支付页且既有一次 Back 成功后,才记录单调时钟起点。在尚未取得订单/待付款上下文时,允许观察至该起点后约30秒,不因反复采样或上下文缺失重置;不能用 paymentPageObserved 代替成功返回事实。
|
||||
- 无成功返回,或在原60次内已取得普通订单上下文的路径,保持原预算。返回失败、持续支付页25次、空窗口15次、微信恢复失败和未授权应用等独立失败保持,不保证每种失败等满30秒。
|
||||
- 原60次之后的新增观察只采样,不因迟到的详情入口、选择器、微信页面新增点击、返回、拉前台或滑动。延长期出现非待付款不完整订单上下文,可只读等待迟到字段至同一期限;完整证据立即结束。
|
||||
- 首次出现待付款证据后,仍使用原独立30次采样及最多4次有界读取滑动;不新增该分支额度。该分支有自己的预算,30秒不是整个核单方法的全局时限。
|
||||
- 固定期限检查包含采样耗时与既有暂停,单次 capture 本身不能被此机制中断;不承诺严格墙钟总耗时。不增加采样硬上限来提前替代30秒期限。
|
||||
- 仍无完整证据时沿用已有失败原因与 order_result_unknown 处理,不自动补录、重试采购或再次创建订单;永久禁止付款。这只是对加载慢的有界缓解,不是对历史未知原因的根因修复。
|
||||
|
||||
## 采购订单资料完整性及简化单号回填(#326)
|
||||
|
||||
用户2026-09-19确认内部系统简化流程,原型v1通过。实现3a2472d,尚未发布或真实SYB验收。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Troubleshooting
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Troubleshooting
|
||||
wiki_revision: ac3a0b74704f398cec54149267089c87c8375a3e
|
||||
synchronized_at: 2026-10-06T09:50:33Z
|
||||
wiki_revision: fee37efc79b644ecfa3237283dd45f0a09796f44
|
||||
synchronized_at: 2026-10-08T03:25:33Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 故障排查
|
||||
@@ -114,3 +114,25 @@ adb -s <serial> shell run-as cn.ilapage.goauto.agent sqlite3 -readonly databases
|
||||
- 保留现有加载、错误提示、取消和旧请求隔离逻辑,不增加自动重试。慢请求在 10~60 秒间完成时不再被原 10 秒客户端预算提前中止;超过 60 秒仍会超时,上游更短的超时也可能先终止请求。
|
||||
- 此调整不优化后端执行速度。处理阶段筛选当前先加载候选并计算阶段、后分页;不选店铺且采购类型为全部时,候选可能很大。遇到持续慢查询,应另行分析候选预筛选与数据库执行计划,不能据此认为延长前端预算已解决后端性能问题。
|
||||
- 只需发布包含该提交的 Web 资源即可生效,不要求数据库迁移、Android 安装或后端参数变更;发布仍需人工授权。
|
||||
|
||||
## Android 地址编辑页控件就绪等待(#366,环节②)
|
||||
|
||||
实现绑定 `7fdfe04658d16a05ebcb016ee964a7263eb74802`,位于独立工单分支,未合并 main、未装机或发布;合成测试与 APK 构建结果见 #366 工单,不代表真机采购验收。只修改点击“修改”后等待详细地址输入框就绪的环节;地址列表识别(①)仍缺真实单/多地址列表证据,保存退出(③)、返回与最终复核(④)不改。
|
||||
|
||||
- 标题出现不再立即触发输入框缺失失败。在“详细地址”标签的既有同排区域,统计可见 EditText 的 structural 数量(包括空值/禁用)及其 enabled、实际 text 非空且未判为 hint 的子集 ready 数量。只有 structural=1 且 ready=1 才继续,读取的地址与就绪判定来自同一帧;后续 inputFresh 仍重新定位且只尝试一次。
|
||||
- 原地址只读取同帧实际 text.trim(),不使用 contentDescription 补空。API 26+ 捕获内存 hintText / showingHintText;showingHintText=true 或 text.trim() 等于非空 hintText.trim() 时不就绪,继续等待。全局 label 和其他调用方的语义保持不变,这两个新增元数据不落盘、不上报、不记日志。
|
||||
- API <26 或应用未正确提供 hint 元数据时,不能完全区分非空 text 是提示还是实际值;仅凭此修复不能保证所有设备/页面的提示文字均被识别,不使用提示词猜测。
|
||||
- 沿用单轮最多 50 次采样、原 200ms 暂停及点击后的既有暂停。不再叠加标题等待与控件等待,不设置“三次多候选即失败”;采样与解析另有耗时,不能将其表述为严格 10 秒墙钟超时。既有安全异常仍可提前终止。
|
||||
- 等待超时使用现有错误码:从未观察到详细地址标题为 PURCHASE_ADDRESS_EDIT_TIMEOUT;曾见标题但候选未就绪/不唯一为 PURCHASE_ADDRESS_UPDATE_FAILED。titleSeen 跨帧保留,因此最后一帧空树不会抹掉此前的标题证据。
|
||||
- 诊断仅含固定 stage=address_editor_ready、reason 分类以及 titleSeen、structural、ready 标量,不含地址、收货人、电话或节点文本。具体原因以实现和测试中的固定分类为准;这不是新增 SQLite 字段或上传接口。
|
||||
- 收货人/电话不能作为备用目标;区域重叠造成歧义时不输入、不保存。无额外点击、滑动、返回、保存或下单重试,正常单次动作路径保持。
|
||||
- 此修复不能证明历史失败均因慢加载,也不解决保存地址后返回超时。识别①不能仅凭“不是规格面板+无提交按钮”推断已进入地址列表:两项都依赖同一个面板解析结果。
|
||||
|
||||
## 支付页返回后核单仍缺少证据(#365)
|
||||
|
||||
实现绑定 `37714d39b5ac8ff720cfa1d0d2609f9a8af3fbd9`(工单分支,未合并、安装或发布)。成功执行既有一次支付页安全返回后,在无订单上下文阶段以固定单调时钟起点观察约30秒;原预算后的新增观察不增加页面动作,待付款分支仍沿用独立读取预算。
|
||||
|
||||
- 先确认运行版本是否包含此提交,再区分失败码:持续支付页、空窗口、微信恢复和未授权应用等独立条件可提前结束,并非所有失败都等待30秒。
|
||||
- 完整订单证据出现就结束;超时保留原订单结果未知语义,不能因为等待更久就把任务改为可重试失败或自动重下单。
|
||||
- 固定期限从 Back 成功时起算,不是从提交订单或进入核单函数起算,也不会因新样本重置;capture 阻塞不能被该检查中断,待付款分支拥有独立预算。
|
||||
- 不因本次延长断言历史问题就是加载慢。延长期出现必须点击才能展开的入口仍不会新增点击;页面识别/挽留弹窗等原因需要真实诊断证据另行分析。#364 的失败现场诊断不属于本单实现。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Android-Agent-API-Contract
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.-
|
||||
wiki_revision: 640da0a86bf1057d418dbeb062c07a84cb5b1d8e
|
||||
synchronized_at: 2026-10-08T01:42:56Z
|
||||
wiki_revision: f97ac6b8784fc200ee68e9e2eb4a1201296049f0
|
||||
synchronized_at: 2026-10-08T03:25:37Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -641,6 +641,17 @@ Agent 主动提交 `order_result_unknown` 时保持订单号和下单时间为
|
||||
| `PURCHASE_ORDER_RESULT_UNKNOWN` | 无法确认订单是否创建,请人工检查 |
|
||||
| `PURCHASE_PAYMENT_FORBIDDEN` | 系统禁止自动付款 |
|
||||
|
||||
### 地址编辑页初始等待诊断(#366,环节②)
|
||||
|
||||
实现绑定 `7fdfe04658d16a05ebcb016ee964a7263eb74802`(工单分支,尚未合并 main 或发布)。本修订只细化 Android 点击“修改”后的输入框等待及错误说明,不增加 HTTP 字段、数据库字段或错误码,不改变任务结果提交、设备/任务/规则关联和保存后的最终复核。
|
||||
|
||||
- 一个现有 50 次采样预算内,只有详细地址区域可见 EditText 的结构候选数为 1,且其中 enabled、实际 text 非空并排除明确 hint 的就绪候选数也为 1,才允许后续既有单次输入;结构计数包括空值和禁用候选。
|
||||
- ready 和原地址不使用 contentDescription 补空;showingHintText=true 或实际 text.trim() 等于非空 hintText.trim() 时排除。hintText / showingHintText 仅为 API 26+ 的内存节点元数据,低版本为 null,不新增持久化或上传字段,不改变全局 label。应用缺失/错误的 hint 元数据或 API <26 时无法完全区分提示与实际值。
|
||||
- 超时从未观察到“详细地址”标题时沿用 `PURCHASE_ADDRESS_EDIT_TIMEOUT`;曾见标题但输入框缺失、为空、仅显示 hint、禁用或存在歧义时沿用 `PURCHASE_ADDRESS_UPDATE_FAILED`。
|
||||
- 此等待失败的 `errorMessage` 可附固定诊断 `stage=address_editor_ready;reason=<固定分类>;titleSeen=<布尔>;structural=<数量>;ready=<数量>`。reason 为 `title_missing`、`editor_missing`、`editor_ambiguous`、`editor_disabled`、`editor_empty` 或 `editor_hint`;titleSeen 累计保留,数量取最后采样帧,不包含地址值、收货人、电话及节点文本。
|
||||
- 既有页面安全异常优先失败,不必耗尽预算;`inputFresh` 失败仍不重试。其它使用 `PURCHASE_ADDRESS_UPDATE_FAILED` 的路径保持原义,不要求它们附本段诊断。
|
||||
- ①地址列表识别仍待真实场景证据,③保存退出、④返回与最终复核不改;本段不代表整单完成或真机验收。
|
||||
|
||||
## Agent 当前设备任务历史(#90)
|
||||
|
||||
四个只读接口统一使用设备注册所得的 Device Token,只返回该 Token 对应设备最近 30 天内的任务;设备 A 查询设备 B 的任务时按不存在处理,不泄露任务是否存在。
|
||||
|
||||
Reference in New Issue
Block a user