Compare commits

..
12 changed files with 237 additions and 23 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 = 90
versionName = "0.9.77"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -69,6 +69,7 @@ data class PurchaseExecutionInput(
val mappedColor: String,
val mappedSize: String,
val specResolutionAllowed: Boolean = false,
val specRematchAllowed: Boolean = false,
val quantity: Long,
val minUnitPriceCent: Long,
val maxUnitPriceCent: Long,
@@ -93,6 +94,9 @@ 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 val rematchSpecs: ((PurchaseExecutionInput) -> PurchaseExecutionInput?)? = null,
) {
private var purchasePanelContext: PurchasePanelContext? = null
private var pageIdentity: Pair<String?, String?> = null to null
@@ -125,14 +129,14 @@ class PurchaseRehearsalExecutor(
PurchaseActionType.OPEN_SPEC_PANEL -> {
openSpecPanel(input, action) ?: recoverSoldOut(input, closeSpecPanel = true) ?: openSpecPanel(input, action)
}
PurchaseActionType.SELECT_SPEC -> if (input.phase == "spec_probe") null else selectSpecs(input, rule, specSelectionProofs)
PurchaseActionType.SELECT_SPEC -> if (input.phase == "spec_probe") null else selectSpecsWithRematch(input, rule, specSelectionProofs)
PurchaseActionType.SET_QUANTITY -> if (input.phase == "spec_probe") null else setQuantity(input.quantity)
PurchaseActionType.VERIFY_UNIT_PRICE -> if (input.phase == "spec_probe") null else verifyPrice(input).also {
if (it == null) observedPrice = currentScreen(input).priceCent
}
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 +181,7 @@ class PurchaseRehearsalExecutor(
input.specResolutionAllowed &&
PurchaseActionType.PROBE_SPECS in rule.actions.map { it.type }
) {
return probeOutcome()
return probeOutcome(input)
}
return failure
}
@@ -188,10 +192,42 @@ class PurchaseRehearsalExecutor(
else failure("PURCHASE_RULE_INVALID", "正式采购规则缺少核单动作")
}
private fun canReuseCurrentProduct(input: PurchaseExecutionInput): Boolean =
currentScreen(input).let { screen ->
screen.problem == null && screen.hasPurchaseProductEvidence()
private fun selectSpecsWithRematch(input: PurchaseExecutionInput, rule: PurchaseRule, proofs: MutableMap<String, ExactSpecSelectionProof>): PurchaseExecutionOutcome? {
val first = selectSpecs(input, rule, proofs)
if (first?.errorCode != SPEC_TARGET_NOT_VISIBLE || !input.specRematchAllowed || rematchSpecs == null) return first
val refreshed = rematchSpecs.invoke(input) ?: return first
panelDiagnostic("specRematch=accepted;pageReuse=true")
proofs.clear()
return selectSpecs(refreshed.copy(specRematchAllowed = false), rule, proofs)
}
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 +1235,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)
}
@@ -84,6 +84,7 @@ data class PurchaseAgentTask(
val mappedColor: String,
val mappedSize: String,
val specResolutionAllowed: Boolean,
val specRematchAllowed: Boolean,
val quantity: Long,
val minUnitPriceCent: Long,
val maxUnitPriceCent: Long,
@@ -453,6 +454,16 @@ class AgentApiClient(private val serverUrl: String) {
requireNotNull(request("POST", "/api/agent/v1/purchase-tasks/$taskId/result", payload, token))
}
fun rematchPurchaseTask(taskId: Long, attemptId: String, probedSpecs: String, token: String): PurchaseAgentTask {
val payload = JSONObject().put("requestId", UUID.randomUUID().toString())
.put("taskAttemptId", attemptId).put("resultType", "spec_rematch_completed")
.put("probedSpecs", JSONObject(probedSpecs))
val data = requireNotNull(request("POST", "/api/agent/v1/purchase-tasks/$taskId/result", payload, token)).getJSONObject("data")
val pending = purchaseTask(data)
val claimed = claimPurchaseTask(taskId, UUID.randomUUID().toString(), token)
return startPurchaseTask(claimed.taskId, UUID.randomUUID().toString(), token)
}
fun collectionHistory(token: String, page: Int, status: String?, taskNo: String?, days: Int = 30, pageSize: Int = 20): HistoryPage<CollectionHistoryItem> {
val data = requireNotNull(request("GET", historyPath("/api/agent/v1/collection-tasks", page, status, taskNo, days, pageSize), null, token)).getJSONObject("data")
return HistoryPage(
@@ -600,6 +611,7 @@ class AgentApiClient(private val serverUrl: String) {
mappedColor = data.optString("mappedColor"),
mappedSize = data.optString("mappedSize"),
specResolutionAllowed = data.optBoolean("specResolutionAllowed", false),
specRematchAllowed = data.optBoolean("specRematchAllowed", false),
quantity = data.getLong("quantity"),
minUnitPriceCent = data.getLong("minUnitPriceCent"),
maxUnitPriceCent = data.getLong("maxUnitPriceCent"),
@@ -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")
@@ -462,7 +464,7 @@ class AgentForegroundService : Service() {
val claimed = if (initial.status == "pending") {
api.claimPurchaseTask(initial.taskId, UUID.randomUUID().toString(), token)
} else initial
val task = if (claimed.status == "pending") {
var task = if (claimed.status == "pending") {
api.startPurchaseTask(claimed.taskId, UUID.randomUUID().toString(), token)
} else claimed
check(task.status == "running" && task.taskAttemptId.isNotBlank()) { "采购任务没有有效 attempt" }
@@ -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,7 +507,19 @@ class AgentForegroundService : Service() {
}
recordDiagnostic("event=started")
PurchaseRehearsalExecutor(
probeHandoffActivity = handoffActivity,
onProbeHandoff = { activity -> purchaseProbeHandoff.remember(handoffKey, activity,
accessibility.currentForegroundRevision(), SystemClock.elapsedRealtime()) },
driver = accessibility,
rematchSpecs = { execution ->
val raw = collectPurchaseProbe(accessibility, task, parsedRule)
if (raw == null) null else {
val refreshed = api.rematchPurchaseTask(task.taskId, task.taskAttemptId, raw, token)
task = refreshed
execution.copy(mappedColor = refreshed.mappedColor, mappedSize = refreshed.mappedSize,
specRematchAllowed = refreshed.specRematchAllowed)
}
},
openLink = { PddLinkLauncher(this).open(it, preferDirect = true) },
probeSpecs = { collectPurchaseProbe(accessibility, task, parsedRule) },
stepChanged = { step ->
@@ -541,6 +558,7 @@ class AgentForegroundService : Service() {
minUnitPriceCent = task.minUnitPriceCent,
maxUnitPriceCent = task.maxUnitPriceCent,
addressSuffix = task.addressSuffix,
specRematchAllowed = task.specRematchAllowed,
),
parsedRule,
PurchaseAgentCapabilities.supported,
@@ -550,6 +568,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 +589,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))
}
}
+12 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Architecture-and-Code-Map
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.-
wiki_revision: c314a2c5a1334ec05bce1740646b4f9da8e91cdd
synchronized_at: 2026-09-10T08:57:57Z
wiki_revision: 029dc3e155e1828484c62efd0ac804616fcbc714
synchronized_at: 2026-09-10T09:13:27Z
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
@@ -450,3 +450,13 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
- 就绪检查不修改原地址回读和结构下单入口规则;超时后只对既有识别的面板、唯一可用滚动区域执行原有有限滚动。零/多个区域仍失败,不凭地址入口或未知底部节点授权下单。最终精确规格、数量、价格、地址与单次下单边界不变。
- 私有采购诊断新增布尔 savedAddressMatched、savedPanelMatched;整数 savedLegacySubmitCandidates、savedAddressCards、savedAddressTargets、savedScrollCandidates、savedConfirmationPolls。只记录标量并绑定task/attempt/device/ruleHash,不保存地址、原始树或整屏图。
- CG109 v1现场读取未达到空闲状态,不能确认原失败具体缺哪项证据;v2覆盖迟到确认节点及可观测性,不将合成测试通过冒充真机修复验收。无Server/Web/API/迁移/权限变化,不需要服务器重启。
## 探测到正式采购的单次面板交接(#262)
2026-09-10 实现绑定 `2ea32fd`,Android0.9.76/code89;基于#260/#261,未声明合并main或真实采购验收通过。
- AgentForegroundService通过PurchaseProbeHandoff只在成功spec_probe且结束后新快照仍识别PDD规格面板时保留一次性内存交接。仅taskId/goodsId/ruleHash/deviceId、Activity、前台revision和单调时钟时间,不保留控件节点、地址或选中状态,不落盘;有效期180秒。
- 正式阶段消费时检查同任务/商品/规则/设备、前台revision及时间,消费后即清除。失配/超时/无任务或采集任务调度/结果异常/进程重启均不能沿用;普通手动再试不会继承已消费交接。现有服务端租约、互斥和匹配等待策略不变。
- 有交接时,执行器恢复限定的purchaseEntryContext,最多10次、间隔200ms新读取(捕获耗时另计);连续两次相同PDD Activity且具备既有商品/面板证据才跳过openProduct。空树可有限等待,其他应用/Activity改变/页面风险立即拒绝复用。没有交接时保留原一次判断路径;不把UNKNOWN当成功,不跳过正式精确规格/数量/价格/地址或下单验证。
- 私有诊断新增handoffPresent、productReused布尔值,reusePolls整数及reuseReason固定枚举legacy_evidence/page_problem/page_changed/handoff_ready/evidence_timeout。仍绑定task/attempt/device/ruleHash,无敏感文本。
- 无Server/Web、共享API或数据库变化,不需重启服务器。只有合成测试验证;原CG107/108日志证实两阶段,但未记录旧版重开判断,不把上下文丢失假设宣称为唯一现场根因。
+12 -2
View File
@@ -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: 756b861e9db598a75c7793b54df5b908384252b6
synchronized_at: 2026-09-10T08:58:00Z
wiki_revision: d8aeff02fba57cba0b53fb22cdb524fd5cc0cb3e
synchronized_at: 2026-09-10T09:13:33Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -600,3 +600,13 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
- 就绪检查不修改原地址回读和结构下单入口规则;超时后只对既有识别的面板、唯一可用滚动区域执行原有有限滚动。零/多个区域仍失败,不凭地址入口或未知底部节点授权下单。最终精确规格、数量、价格、地址与单次下单边界不变。
- 私有采购诊断新增布尔 savedAddressMatched、savedPanelMatched;整数 savedLegacySubmitCandidates、savedAddressCards、savedAddressTargets、savedScrollCandidates、savedConfirmationPolls。只记录标量并绑定task/attempt/device/ruleHash,不保存地址、原始树或整屏图。
- CG109 v1现场读取未达到空闲状态,不能确认原失败具体缺哪项证据;v2覆盖迟到确认节点及可观测性,不将合成测试通过冒充真机修复验收。无Server/Web/API/迁移/权限变化,不需要服务器重启。
## 探测到正式采购的单次面板交接(#262)
2026-09-10 实现绑定 `2ea32fd`,Android0.9.76/code89;基于#260/#261,未声明合并main或真实采购验收通过。
- AgentForegroundService通过PurchaseProbeHandoff只在成功spec_probe且结束后新快照仍识别PDD规格面板时保留一次性内存交接。仅taskId/goodsId/ruleHash/deviceId、Activity、前台revision和单调时钟时间,不保留控件节点、地址或选中状态,不落盘;有效期180秒。
- 正式阶段消费时检查同任务/商品/规则/设备、前台revision及时间,消费后即清除。失配/超时/无任务或采集任务调度/结果异常/进程重启均不能沿用;普通手动再试不会继承已消费交接。现有服务端租约、互斥和匹配等待策略不变。
- 有交接时,执行器恢复限定的purchaseEntryContext,最多10次、间隔200ms新读取(捕获耗时另计);连续两次相同PDD Activity且具备既有商品/面板证据才跳过openProduct。空树可有限等待,其他应用/Activity改变/页面风险立即拒绝复用。没有交接时保留原一次判断路径;不把UNKNOWN当成功,不跳过正式精确规格/数量/价格/地址或下单验证。
- 私有诊断新增handoffPresent、productReused布尔值,reusePolls整数及reuseReason固定枚举legacy_evidence/page_problem/page_changed/handoff_ready/evidence_timeout。仍绑定task/attempt/device/ruleHash,无敏感文本。
- 无Server/Web、共享API或数据库变化,不需重启服务器。只有合成测试验证;原CG107/108日志证实两阶段,但未记录旧版重开判断,不把上下文丢失假设宣称为唯一现场根因。
+19 -3
View File
@@ -342,6 +342,22 @@ func (s *Service) SubmitResult(ctx context.Context, taskID uint64, req ResultReq
now := s.Now()
next := ""
switch req.ResultType {
case "spec_rematch_completed":
if t.ExecutionMode != models.PurchaseExecutionModeLive || t.Status != models.PurchaseTaskStatusRunning || t.SpecDecisionRequestID == nil || t.IrreversibleAt != nil || len(req.ProbedSpecs) == 0 {
return TaskPayload{}, fail(CodeSpecRematchRejected, "当前任务不满足一次现场重新匹配条件")
}
var rematchCount int64
if e := tx.Model(&models.PurchaseTaskAttempt{}).Where("task_id = ? AND result_type = ?", t.ID, "spec_rematch_completed").Count(&rematchCount).Error; e != nil {
return TaskPayload{}, internal(e)
}
if rematchCount > 0 {
return TaskPayload{}, fail(CodeSpecRematchRejected, "该任务的现场重新匹配次数已用尽")
}
next = models.PurchaseTaskStatusSpecProbePending
a.Status = models.PurchaseAttemptStatusCompleted
t.SpecDecisionRequestID = nil
t.SpecDecisionBy = nil
t.MappedColorSnapshot, t.MappedSizeSnapshot, t.SpecSource = "", "", "unresolved"
case "spec_probe_completed":
if len(req.ProbedSpecs) == 0 {
return TaskPayload{}, fail(CodeInvalidRequest, "规格探测结果无效")
@@ -413,7 +429,7 @@ func (s *Service) SubmitResult(ctx context.Context, taskID uint64, req ResultReq
a.ResultHash = &digest
a.ResultType = &req.ResultType
a.FinishedAt = &now
if req.ResultType == "spec_probe_completed" {
if req.ResultType == "spec_probe_completed" || req.ResultType == "spec_rematch_completed" {
a.SpecDecisionSnapshot = string(req.ProbedSpecs)
}
if e := tx.Omit("Task").Save(a).Error; e != nil {
@@ -424,7 +440,7 @@ func (s *Service) SubmitResult(ctx context.Context, taskID uint64, req ResultReq
}
return valuePayload(s, t, a, false)
})
if err != nil || req.ResultType != "spec_probe_completed" || payload.Replayed || payload.Status != models.PurchaseTaskStatusSpecProbePending {
if err != nil || (req.ResultType != "spec_probe_completed" && req.ResultType != "spec_rematch_completed") || payload.Replayed || payload.Status != models.PurchaseTaskStatusSpecProbePending {
return payload, err
}
return s.resolveProbedSpecs(ctx, taskID, req.TaskAttemptID, req.ProbedSpecs)
@@ -700,7 +716,7 @@ func ensureAccountFree(tx *gorm.DB, accountID *uint64, taskID uint64, now time.T
return nil
}
func (s *Service) payload(t models.PurchaseTask, a *models.PurchaseTaskAttempt, replayed bool) (*TaskPayload, error) {
p := &TaskPayload{TaskID: t.ID, ExecutionMode: t.ExecutionMode, Status: t.Status, DeviceID: t.DeviceID, PDDProductID: t.PDDProductID, PDDURL: t.PDDURLSnapshot, PDDGoodsID: t.PDDGoodsIDSnapshot, TargetColor: t.TargetColorSnapshot, TargetSize: t.TargetSizeSnapshot, MappedColor: t.MappedColorSnapshot, MappedSize: t.MappedSizeSnapshot, SpecResolutionAllowed: specResolutionAllowed(t), Quantity: t.Quantity, MinUnitPriceCent: t.MinUnitPriceCent, MaxUnitPriceCent: t.MaxUnitPriceCent, Currency: t.Currency, AddressSuffix: t.AddressSuffix, RuleSnapshot: json.RawMessage(t.RuleSnapshot), LeaseExpiresAt: t.LeaseExpiresAt, LeaseVersion: t.LeaseVersion, Replayed: replayed}
p := &TaskPayload{TaskID: t.ID, ExecutionMode: t.ExecutionMode, Status: t.Status, DeviceID: t.DeviceID, PDDProductID: t.PDDProductID, PDDURL: t.PDDURLSnapshot, PDDGoodsID: t.PDDGoodsIDSnapshot, TargetColor: t.TargetColorSnapshot, TargetSize: t.TargetSizeSnapshot, MappedColor: t.MappedColorSnapshot, MappedSize: t.MappedSizeSnapshot, SpecResolutionAllowed: specResolutionAllowed(t), SpecRematchAllowed: t.SpecDecisionRequestID != nil && t.TaskType == models.PurchaseTaskTypeSYBOrder && t.Status == models.PurchaseTaskStatusRunning, Quantity: t.Quantity, MinUnitPriceCent: t.MinUnitPriceCent, MaxUnitPriceCent: t.MaxUnitPriceCent, Currency: t.Currency, AddressSuffix: t.AddressSuffix, RuleSnapshot: json.RawMessage(t.RuleSnapshot), LeaseExpiresAt: t.LeaseExpiresAt, LeaseVersion: t.LeaseVersion, Replayed: replayed}
if a != nil {
p.TaskAttemptID = a.AttemptID
p.AttemptNumber = a.AttemptNumber
+2
View File
@@ -21,6 +21,7 @@ const (
CodeRetryUnsafe = "PURCHASE_RETRY_UNSAFE"
CodeRetryStale = "PURCHASE_RETRY_STALE"
CodeSpecReprobeRejected = "PURCHASE_SPEC_REPROBE_REJECTED"
CodeSpecRematchRejected = "PURCHASE_SPEC_REMATCH_REJECTED"
CodeOrderResultUnknown = "PURCHASE_ORDER_RESULT_UNKNOWN"
CodeOrderEmptyTimeout = "PURCHASE_ORDER_EMPTY_TIMEOUT"
CodeOrderChooserBack = "PURCHASE_ORDER_CHOOSER_BACK_FAILED"
@@ -137,6 +138,7 @@ type TaskPayload struct {
MappedColor string `json:"mappedColor"`
MappedSize string `json:"mappedSize"`
SpecResolutionAllowed bool `json:"specResolutionAllowed"`
SpecRematchAllowed bool `json:"specRematchAllowed"`
Quantity int64 `json:"quantity"`
MinUnitPriceCent int64 `json:"minUnitPriceCent"`
MaxUnitPriceCent int64 `json:"maxUnitPriceCent"`