fix(android): retain one-use probe panel handoff for purchase #262
This commit is contained in:
@@ -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"
|
||||
|
||||
|
||||
+35
-6
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+3
-2
@@ -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(),
|
||||
|
||||
+14
-2
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user