fix(android): retain one-use probe panel handoff for purchase #262

This commit is contained in:
QiuSW
2026-09-10 17:11:51 +08:00
parent 4e130f5b29
commit 2ea32fd678
7 changed files with 157 additions and 14 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "cn.ilapage.goauto.agent"
minSdk = 23
targetSdk = 34
versionCode = 88
versionName = "0.9.75"
versionCode = 89
versionName = "0.9.76"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -93,6 +93,8 @@ class PurchaseRehearsalExecutor(
private val stepChanged: (String) -> Unit = {},
private val panelDiagnostic: (String) -> Unit = {},
private val beforeOrderSubmit: (FinalConfirmationEvidence) -> Unit = { throw PurchaseLiveException("PURCHASE_MODE_NOT_ALLOWED", "当前执行器没有正式采购授权") },
private val probeHandoffActivity: String? = null,
private val onProbeHandoff: (String) -> Unit = {},
) {
private var purchasePanelContext: PurchasePanelContext? = null
private var pageIdentity: Pair<String?, String?> = null to null
@@ -132,7 +134,7 @@ class PurchaseRehearsalExecutor(
}
PurchaseActionType.VERIFY_ORDER_SUMMARY -> if (input.phase == "spec_probe") null else verifySummary(input, observedPrice, specSelectionProofs)
PurchaseActionType.PROBE_SPECS -> {
if (input.phase == "spec_probe") return probeOutcome()
if (input.phase == "spec_probe") return probeOutcome(input)
null
}
PurchaseActionType.UPDATE_SHIPPING_ADDRESS -> try {
@@ -177,7 +179,7 @@ class PurchaseRehearsalExecutor(
input.specResolutionAllowed &&
PurchaseActionType.PROBE_SPECS in rule.actions.map { it.type }
) {
return probeOutcome()
return probeOutcome(input)
}
return failure
}
@@ -188,10 +190,33 @@ class PurchaseRehearsalExecutor(
else failure("PURCHASE_RULE_INVALID", "正式采购规则缺少核单动作")
}
private fun canReuseCurrentProduct(input: PurchaseExecutionInput): Boolean =
currentScreen(input).let { screen ->
screen.problem == null && screen.hasPurchaseProductEvidence()
private fun canReuseCurrentProduct(input: PurchaseExecutionInput): Boolean {
if (probeHandoffActivity == null) {
val screen = currentScreen(input)
val reuse = screen.problem == null && screen.hasPurchaseProductEvidence()
panelDiagnostic("handoffPresent=false;productReused=$reuse;reusePolls=1;reuseReason=legacy_evidence")
return reuse
}
var consecutive = 0
repeat(10) { poll ->
purchaseEntryIdentity = PDD_PACKAGE to probeHandoffActivity
val screen = currentScreen(input)
if ((pageIdentity.first != null && pageIdentity != (PDD_PACKAGE to probeHandoffActivity)) || screen.problem != null) {
purchaseEntryIdentity = null
panelDiagnostic("handoffPresent=true;productReused=false;reusePolls=${poll + 1};reuseReason=${if (screen.problem != null) "page_problem" else "page_changed"}")
return false
}
consecutive = if (screen.hasPurchaseProductEvidence()) consecutive + 1 else 0
if (consecutive >= 2) {
panelDiagnostic("handoffPresent=true;productReused=true;reusePolls=${poll + 1};reuseReason=handoff_ready")
return true
}
if (poll < 9) pause(200)
}
purchaseEntryIdentity = null
panelDiagnostic("handoffPresent=true;productReused=false;reusePolls=10;reuseReason=evidence_timeout")
return false
}
private fun validateBeforeDeviceAction(
input: PurchaseExecutionInput,
@@ -1199,9 +1224,13 @@ class PurchaseRehearsalExecutor(
return null
}
private fun probeOutcome(): PurchaseExecutionOutcome {
private fun probeOutcome(input: PurchaseExecutionInput): PurchaseExecutionOutcome {
stepChanged("probeSpecs")
val result = probeSpecs() ?: return failure("PURCHASE_SPEC_NOT_MATCHED", "商品规格探测失败")
val screen = currentScreen(input)
if (input.phase == "spec_probe" && screen.problem == null && screen.isPddPackage && screen.specPanelOpen) {
pageIdentity.second?.takeIf(String::isNotBlank)?.let(onProbeHandoff)
}
return PurchaseExecutionOutcome("spec_probe_completed", message = "商品规格已回传,等待服务端匹配", probedSpecs = result)
}
@@ -60,10 +60,11 @@ class PurchaseDiagnosticStore(private val directory: File, private val now: () -
private val numbers = setOf("specEntryCandidates", "explicit", "nested", "bottomPurchase", "entryReadyWaitPolls",
"entryReadyWaitMillis", "scrollables", "headings", "options", "entryWaitPolls", "entryWaitMillis",
"addressEditorCandidates", "addressEditorPolls", "savedLegacySubmitCandidates", "savedAddressCards",
"savedAddressTargets", "savedScrollCandidates", "savedConfirmationPolls")
"savedAddressTargets", "savedScrollCandidates", "savedConfirmationPolls", "reusePolls")
private val booleans = setOf("panelAlreadyOpen", "reviewPage", "pageEvidence", "pageChanged", "summary", "quantity",
"orderAction", "closeControl", "paymentArea", "savedAddressMatched", "savedPanelMatched")
"orderAction", "closeControl", "paymentArea", "savedAddressMatched", "savedPanelMatched", "handoffPresent", "productReused")
private val enums = mapOf(
"reuseReason" to setOf("legacy_evidence", "page_problem", "page_changed", "handoff_ready", "evidence_timeout"),
"event" to setOf("started"),
"entrySource" to setOf("unknown", "explicit_selection", "nested_selection", "bottom_purchase", "bottom_purchase_rightmost"),
"entryClick" to FreshActionResult.values().map { it.name }.toSet(),
@@ -87,6 +87,7 @@ class AgentForegroundService : Service() {
private val collectionCooldownWakeActive = AtomicBoolean(false)
private val currentPageResultNotificationUntil = AtomicLong(0L)
private val idleReturn = IdleReturnCoordinator()
private val purchaseProbeHandoff = PurchaseProbeHandoff()
private var taskWakeLock: PowerManager.WakeLock? = null
private var cooldownWakeLock: PowerManager.WakeLock? = null
private var collectionCooldownWakeLock: PowerManager.WakeLock? = null
@@ -283,7 +284,7 @@ class AgentForegroundService : Service() {
evaluateIdleReturn()
return "$MANUAL_COLLECTION_COOLDOWN_PREFIX${CollectionCooldownPolicy.remainingSeconds(System.currentTimeMillis(), ticket)}"
}
TaskDispatchDecision.CHECK_COLLECTION -> Unit
TaskDispatchDecision.CHECK_COLLECTION -> purchaseProbeHandoff.clear()
}
val task = api.nextTask(token)
if (task == null) {
@@ -291,6 +292,7 @@ class AgentForegroundService : Service() {
return MANUAL_EMPTY
}
cancelIdleReturn("收到新的采集任务")
purchaseProbeHandoff.clear()
if (!taskMutex.tryAcquire(task.taskId)) return MANUAL_BUSY
if (task.status == "running") runningTaskId.set(task.taskId)
stateStore.setActiveTask(task.taskId, "collection")
@@ -495,6 +497,9 @@ class AgentForegroundService : Service() {
val diagnosticContext = PurchaseDiagnosticContext(task.taskId, task.taskAttemptId, diagnosticDeviceId,
snapshotHash, BuildConfig.VERSION_NAME, task.phase)
val diagnosticStarted = SystemClock.elapsedRealtime()
val handoffKey = PurchaseProbeHandoff.Key(task.taskId, task.pddGoodsId, snapshotHash, diagnosticDeviceId)
val handoffActivity = purchaseProbeHandoff.consume(handoffKey, task.phase,
accessibility.currentForegroundRevision(), diagnosticStarted)
val recordDiagnostic: (String) -> Unit = { evidence ->
val elapsed = SystemClock.elapsedRealtime() - diagnosticStarted
runCatching { diagnosticExecutor.execute { purchaseDiagnostics.record(diagnosticContext, elapsed, evidence) } }
@@ -502,6 +507,9 @@ class AgentForegroundService : Service() {
}
recordDiagnostic("event=started")
PurchaseRehearsalExecutor(
probeHandoffActivity = handoffActivity,
onProbeHandoff = { activity -> purchaseProbeHandoff.remember(handoffKey, activity,
accessibility.currentForegroundRevision(), SystemClock.elapsedRealtime()) },
driver = accessibility,
openLink = { PddLinkLauncher(this).open(it, preferDirect = true) },
probeSpecs = { collectPurchaseProbe(accessibility, task, parsedRule) },
@@ -550,6 +558,7 @@ class AgentForegroundService : Service() {
}
val requestId = UUID.randomUUID().toString()
val payload = purchaseResultPayload(requestId, task.taskAttemptId, outcome)
if (outcome.resultType != "spec_probe_completed") purchaseProbeHandoff.clear()
purchaseStore.completeAndEnqueue(task.taskId, task.taskAttemptId, requestId, payload)
resultSafelyStored = true
PurchaseResultBubblePolicy.create(
@@ -570,7 +579,10 @@ class AgentForegroundService : Service() {
} catch (error: Exception) {
stateStore.update("TASK_ERROR", error.message ?: "采购演练执行异常", tokenStored = true)
} finally {
if (!resultSafelyStored) cancelIdleReturn("采购结果未安全保存")
if (!resultSafelyStored) {
purchaseProbeHandoff.clear()
cancelIdleReturn("采购结果未安全保存")
}
releaseTaskWakeLock()
}
}
@@ -0,0 +1,23 @@
package cn.ilapage.goauto.agent.service
/** One-use, process-local recognition context; never contains nodes or selection proofs. */
internal class PurchaseProbeHandoff {
data class Key(val taskId: Long, val goodsId: String, val ruleHash: String, val deviceId: Long)
private data class Ticket(val key: Key, val activity: String, val revision: Long, val time: Long)
private var ticket: Ticket? = null
@Synchronized fun remember(key: Key, activity: String, revision: Long, now: Long) {
ticket = Ticket(key, activity, revision, now)
}
@Synchronized fun consume(key: Key, phase: String, revision: Long, now: Long): String? {
val previous = ticket
ticket = null
return previous?.takeIf {
phase == "purchase" && it.key == key && it.revision == revision &&
now - it.time in 0..180_000 && it.activity.isNotBlank()
}?.activity
}
@Synchronized fun clear() { ticket = null }
}
@@ -28,6 +28,45 @@ import org.junit.Assert.assertTrue
import org.junit.Test
class PurchaseRehearsalExecutorTest {
@Test fun `single heading probe hands recognition to new purchase executor without opening link`() {
val driver = FakePurchaseDriver(singleHeadingOnEntry = true)
var handoff: String? = null
val probe = PurchaseRehearsalExecutor(driver, { true }, { "{}" }, pause = {}, onProbeHandoff = { handoff = it })
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("spec_probe_completed", probe.resultType)
assertTrue(handoff != null)
var opens = 0
val logs = mutableListOf<String>()
PurchaseRehearsalExecutor(driver, { opens++; true }, { null }, pause = {},
probeHandoffActivity = handoff, panelDiagnostic = logs::add)
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals(0, opens)
assertTrue(logs.any { it.contains("handoffPresent=true;productReused=true;reusePolls=2;") })
}
@Test fun `handoff tolerates transient empty tree but never reuses another app`() {
for (mode in listOf("transient", "otherApp", "timeout")) {
val original = FakePurchaseDriver(singleHeadingOnEntry = true)
var handoff: String? = null
PurchaseRehearsalExecutor(original, { true }, { "{}" }, pause = {}, onProbeHandoff = { handoff = it })
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
var captures = 0
var opens = 0
val driver = object : PurchaseUiDriver by original {
override fun capture(): UiSnapshot {
captures++
return if (captures <= if (mode == "timeout") 10 else 2) UiSnapshot(if (mode == "otherApp") "cn.ilapage.goauto.agent" else null, null, emptyList()) else original.capture()
}
}
val logs = mutableListOf<String>()
PurchaseRehearsalExecutor(driver, { opens++; true }, { null }, pause = {},
probeHandoffActivity = handoff, panelDiagnostic = logs::add)
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals(if (mode == "transient") 0 else 1, opens)
assertTrue(logs.any { it.contains("productReused=${mode == "transient"}") })
if (mode == "timeout") assertTrue(logs.any { it.contains("reusePolls=10;reuseReason=evidence_timeout") })
}
}
@Test fun `safe entry with one visible heading completes probe without reopening`() {
val driver = FakePurchaseDriver(singleHeadingOnEntry = true)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = {})
@@ -1054,10 +1093,10 @@ 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;entryReadyWaitPolls=20;entryReadyWaitMillis=2000",
diagnostics.single(),
diagnostics.single { it.startsWith("specEntryCandidates=") },
)
assertEquals(21, pauses.count { it == 100L })
assertFalse(diagnostics.single().contains("选择规格"))
assertFalse(diagnostics.any { it.contains("选择规格") })
}
@Test
@@ -0,0 +1,39 @@
package cn.ilapage.goauto.agent.service
import org.junit.Assert.*
import org.junit.Test
class PurchaseProbeHandoffTest {
private val key = PurchaseProbeHandoff.Key(107, "123456", "a".repeat(64), 7)
@Test fun `same task consumes once and manual retry cannot inherit`() {
val store = PurchaseProbeHandoff()
store.remember(key, "PddActivity", 3, 100)
assertEquals("PddActivity", store.consume(key, "purchase", 3, 60_100))
assertNull(store.consume(key, "purchase", 3, 60_101))
}
@Test fun `mismatched task goods rule device phase revision and expiry reject and consume`() {
for (other in listOf(key.copy(taskId = 108), key.copy(goodsId = "654321"),
key.copy(ruleHash = "b".repeat(64)), key.copy(deviceId = 8))) {
val store = PurchaseProbeHandoff()
store.remember(key, "PddActivity", 3, 100)
assertNull(store.consume(other, "purchase", 3, 101))
assertNull(store.consume(key, "purchase", 3, 102))
}
for ((phase, revision, now) in listOf(Triple("spec_probe", 3L, 101L),
Triple("purchase", 4L, 101L), Triple("purchase", 3L, 180_101L), Triple("purchase", 3L, 99L))) {
val store = PurchaseProbeHandoff()
store.remember(key, "PddActivity", 3, 100)
assertNull(store.consume(key, phase, revision, now))
}
}
@Test fun `clear and process recreation discard context`() {
val store = PurchaseProbeHandoff()
store.remember(key, "PddActivity", 3, 100)
store.clear()
assertNull(store.consume(key, "purchase", 3, 101))
assertNull(PurchaseProbeHandoff().consume(key, "purchase", 3, 101))
}
}