Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b8999f751 | ||
|
|
c6ee6c0e6b | ||
|
|
1e506006d5 | ||
|
|
2ea32fd678 |
@@ -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"
|
||||
|
||||
|
||||
+47
-7
@@ -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"),
|
||||
|
||||
+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(),
|
||||
|
||||
+25
-3
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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日志证实两阶段,但未记录旧版重开判断,不把上下文丢失假设宣称为唯一现场根因。
|
||||
|
||||
@@ -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日志证实两阶段,但未记录旧版重开判断,不把上下文丢失假设宣称为唯一现场根因。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user