Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e506006d5 | ||
|
|
2ea32fd678 | ||
|
|
4e130f5b29 | ||
|
|
d0a82f3e01 | ||
|
|
713111da0a | ||
|
|
04b6e75eb9 | ||
|
|
61a00e248f | ||
|
|
5ea07f10ad | ||
|
|
7de4318001 | ||
|
|
3c1ffa4ff7 |
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "cn.ilapage.goauto.agent"
|
||||
minSdk = 23
|
||||
targetSdk = 34
|
||||
versionCode = 86
|
||||
versionName = "0.9.73"
|
||||
versionCode = 89
|
||||
versionName = "0.9.76"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
|
||||
+10
-1
@@ -186,7 +186,7 @@ object PddScreenParser {
|
||||
// purchase/order/payment controls must never become collection click targets.
|
||||
private val nonConfigurableClickDenylist = listOf("提交订单", "确认订单", "支付", "付款")
|
||||
|
||||
fun parse(snapshot: UiSnapshot, config: PddCollectorConfig, goodsId: String, evidence: PageEvidence?, purchaseContext: PurchasePanelContext? = null, collectionContext: Boolean = false): ParsedPddScreen {
|
||||
fun parse(snapshot: UiSnapshot, config: PddCollectorConfig, goodsId: String, evidence: PageEvidence?, purchaseContext: PurchasePanelContext? = null, collectionContext: Boolean = false, purchaseEntryContext: Boolean = false): ParsedPddScreen {
|
||||
val visibleNodes = snapshot.nodes.filter { it.visible }
|
||||
val visible = visibleNodes.mapNotNull { node ->
|
||||
val descendants = descendants(node, visibleNodes)
|
||||
@@ -333,6 +333,14 @@ object PddScreenParser {
|
||||
boundedScrollables.size == 1 && headedPanelScrollable != null && headings.size == 1 &&
|
||||
dimensions.size == 1 && dimensions.single().values.isNotEmpty() &&
|
||||
hasClose && hasQuantityControls && hasPaymentArea && hasOrderSubmitAction
|
||||
// Only the executor that just activated a safe entry on this same
|
||||
// product Activity can enable this first-view purchase exception.
|
||||
val singleDimensionPurchasePanel = purchaseEntryContext && !collectionContext &&
|
||||
snapshot.packageName == PDD_PACKAGE && !snapshot.activityName.isNullOrBlank() &&
|
||||
goodsId.isNotBlank() && problem == null && !hasSelectionSummary &&
|
||||
boundedScrollables.size == 1 && headedPanelScrollable != null && headings.size == 1 &&
|
||||
dimensions.size == 1 && dimensions.single().values.isNotEmpty() &&
|
||||
hasClose && hasQuantityControls && hasPaymentArea && hasOrderSubmitAction
|
||||
// Some selected-spec panels omit both the "已选" prefix and a confirm
|
||||
// button. Keep the same structural evidence required for checkout.
|
||||
val specPanelType = when {
|
||||
@@ -340,6 +348,7 @@ object PddScreenParser {
|
||||
orderConfirmationEvidence -> SpecPanelType.ORDER_CONFIRMATION
|
||||
structuredSelectionPanel -> SpecPanelType.NORMAL_SCROLLABLE
|
||||
singleDimensionCollectionPanel -> SpecPanelType.NORMAL_SCROLLABLE
|
||||
singleDimensionPurchasePanel -> SpecPanelType.NORMAL_SCROLLABLE
|
||||
panelScrollable != null && (hasSelectionSummary || hasSubmitHint || (hasPanelTitle && hasPanelAction)) -> SpecPanelType.NORMAL_SCROLLABLE
|
||||
hasSelectionSummary && hasPanelTitle && hasPanelAction -> SpecPanelType.NON_SCROLLABLE_CONFIRMATION
|
||||
// Some PDD builds expose the complete selector as non-scrollable
|
||||
|
||||
+69
-12
@@ -32,6 +32,7 @@ class PurchaseLiveException(val code: String, message: String, val actualUnitPri
|
||||
class PurchaseLiveAutomation(
|
||||
private val driver: PurchaseUiDriver,
|
||||
private val pause: (Long) -> Unit = Thread::sleep,
|
||||
private val diagnostic: (String) -> Unit = {},
|
||||
) {
|
||||
private var submitAttempted = false
|
||||
private var savedAddressProof: ShippingAddressProof? = null
|
||||
@@ -126,7 +127,9 @@ class PurchaseLiveAutomation(
|
||||
}
|
||||
pause(1_000)
|
||||
snapshot = waitFor("PURCHASE_ADDRESS_PANEL_TIMEOUT", "收货地址页面打开超时,未创建订单") {
|
||||
it.nodes.any { node -> node.visible && node.label.replace(" ", "") == "收货地址" }
|
||||
it.packageName == PDD_PACKAGE &&
|
||||
it.nodes.any { node -> node.visible && node.label.replace(" ", "") == "收货地址" } &&
|
||||
addressModifyTargets(it).size == 1
|
||||
}
|
||||
return editAndVerifyAddress(snapshot, addressSuffix)
|
||||
}
|
||||
@@ -488,10 +491,8 @@ class PurchaseLiveAutomation(
|
||||
structuralSubmitAnchor = null
|
||||
val modify = addressModifyTargets(panel)
|
||||
if (modify.size != 1) fail("PURCHASE_ADDRESS_EDIT_AMBIGUOUS", "没有找到唯一的地址修改按钮,未创建订单")
|
||||
click(modify.single(), "修改地址")
|
||||
val edit = waitFor("PURCHASE_ADDRESS_EDIT_TIMEOUT", "地址编辑页面打开超时,未创建订单") { snapshot ->
|
||||
snapshot.nodes.any { it.visible && it.label.replace(" ", "").contains("详细地址") }
|
||||
}
|
||||
click(modify.single(), "修改地址", 1_000)
|
||||
val edit = waitForAddressEditorReady()
|
||||
val editors = shippingAddressEditors(edit)
|
||||
if (editors.size != 1) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "没有找到唯一的详细地址输入框,未创建订单")
|
||||
val current = editors.single().label.trim()
|
||||
@@ -504,19 +505,21 @@ class PurchaseLiveAutomation(
|
||||
}
|
||||
val save = uniqueClickable(stable, stable.nodes.filter { it.visible && it.enabled && it.label == "保存" })
|
||||
if (save.size != 1) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "地址保存按钮不唯一,未创建订单")
|
||||
click(save.single(), "保存地址")
|
||||
var savedEvidence = waitForStableAddressEditorExit()
|
||||
click(save.single(), "保存地址", 1_000)
|
||||
var savedEvidence = waitForSavedConfirmation(waitForStableAddressEditorExit(), expected, suffix)
|
||||
if (!savedAddressPageReady(savedEvidence, expected, suffix)) {
|
||||
if (isPurchaseConfirmationPanel(savedEvidence)) {
|
||||
savedEvidence = restoreFinalEvidenceInCurrentPanel(savedEvidence, expected, suffix)
|
||||
} else {
|
||||
if (!driver.backPurchase()) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "地址保存后无法返回订单页面,未创建订单")
|
||||
pause(1_000)
|
||||
val returned = waitFor("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后无法返回订单页面,未创建订单") {
|
||||
savedAddressPageReady(it, expected, suffix) || isPurchaseConfirmationPanel(it)
|
||||
}
|
||||
savedEvidence = if (!savedAddressPageReady(returned, expected, suffix)) {
|
||||
restoreFinalEvidenceInCurrentPanel(returned, expected, suffix)
|
||||
} else returned
|
||||
val settled = waitForSavedConfirmation(returned, expected, suffix)
|
||||
savedEvidence = if (!savedAddressPageReady(settled, expected, suffix)) {
|
||||
restoreFinalEvidenceInCurrentPanel(settled, expected, suffix)
|
||||
} else settled
|
||||
}
|
||||
}
|
||||
val proof = ShippingAddressProof(expected, suffix)
|
||||
@@ -538,6 +541,39 @@ class PurchaseLiveAutomation(
|
||||
)
|
||||
}
|
||||
|
||||
/** Wait for late address/footer nodes before deciding to scroll. No device actions here. */
|
||||
private fun waitForSavedConfirmation(initial: UiSnapshot, expected: String, suffix: String): UiSnapshot {
|
||||
var snapshot = initial
|
||||
var readyActivity: String? = null
|
||||
repeat(50) { poll ->
|
||||
pageProblem(snapshot)
|
||||
// A stable address list needs the existing single Back, not a footer wait.
|
||||
if (snapshot.nodes.any { it.visible && it.label == "收货地址" } &&
|
||||
addressModifyTargets(snapshot).size == 1) return snapshot
|
||||
val ready = savedAddressPageReady(snapshot, expected, suffix)
|
||||
if (ready && readyActivity != null && readyActivity == snapshot.activityName) {
|
||||
savedConfirmationDiagnostic(snapshot, expected, suffix, poll + 1)
|
||||
return snapshot
|
||||
}
|
||||
readyActivity = if (ready) snapshot.activityName else null
|
||||
if (poll < 49) {
|
||||
pause(200)
|
||||
snapshot = driver.capture()
|
||||
}
|
||||
}
|
||||
savedConfirmationDiagnostic(snapshot, expected, suffix, 50)
|
||||
return snapshot
|
||||
}
|
||||
|
||||
private fun savedConfirmationDiagnostic(snapshot: UiSnapshot, expected: String, suffix: String, polls: Int) {
|
||||
val address = resolveAddressEntries(snapshot)
|
||||
diagnostic("savedAddressMatched=${hasSavedAddressEvidence(snapshot, expected, suffix)};" +
|
||||
"savedPanelMatched=${savedPurchasePanel(snapshot, expected, suffix) != null};" +
|
||||
"savedLegacySubmitCandidates=${legacySubmitTargets(snapshot).size};" +
|
||||
"savedAddressCards=${address.addressCardCount};savedAddressTargets=${address.tapTargetCount};" +
|
||||
"savedScrollCandidates=${savedAddressPanelScrollTargets(snapshot).size};savedConfirmationPolls=$polls")
|
||||
}
|
||||
|
||||
private fun restoreFinalEvidenceInCurrentPanel(
|
||||
initial: UiSnapshot,
|
||||
expected: String,
|
||||
@@ -552,6 +588,7 @@ class PurchaseLiveAutomation(
|
||||
}
|
||||
val panels = savedAddressPanelScrollTargets(snapshot)
|
||||
if (panels.size != 1) {
|
||||
savedConfirmationDiagnostic(snapshot, expected, suffix, 0)
|
||||
val reason = if (panels.isEmpty()) "未找到可用的规格面板滚动区域" else "找到多个规格面板滚动区域"
|
||||
fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "地址保存后${reason},未创建订单")
|
||||
}
|
||||
@@ -715,13 +752,33 @@ class PurchaseLiveAutomation(
|
||||
}.distinctBy { it.path }
|
||||
}
|
||||
|
||||
private fun click(node: SnapshotNode, label: String) {
|
||||
private fun waitForAddressEditorReady(): UiSnapshot {
|
||||
var previous: SnapshotNode? = null
|
||||
var previousActivity: String? = null
|
||||
var count = 0
|
||||
repeat(50) { poll ->
|
||||
val snapshot = driver.capture()
|
||||
pageProblem(snapshot)
|
||||
val editors = if (snapshot.packageName == PDD_PACKAGE) shippingAddressEditors(snapshot) else emptyList()
|
||||
count = editors.size
|
||||
diagnostic("addressEditorCandidates=$count;addressEditorPolls=${poll + 1}")
|
||||
val editor = editors.singleOrNull()
|
||||
if (editor != null && previous != null && snapshot.activityName == previousActivity &&
|
||||
editor.path == previous!!.path && editor.bounds == previous!!.bounds && editor.label == previous!!.label) return snapshot
|
||||
previous = editor
|
||||
previousActivity = snapshot.activityName
|
||||
pause(200)
|
||||
}
|
||||
fail("PURCHASE_ADDRESS_UPDATE_FAILED", "详细地址输入框未稳定就绪,未创建订单 [candidates=$count]")
|
||||
}
|
||||
|
||||
private fun click(node: SnapshotNode, label: String, settleMillis: Long = 500) {
|
||||
when (driver.clickFresh(node)) {
|
||||
FreshActionResult.SUCCESS -> Unit
|
||||
FreshActionResult.AMBIGUOUS -> fail("RULE_AMBIGUOUS", "$label 不唯一")
|
||||
else -> fail("RULE_ACTION_FAILED", "$label 点击失败")
|
||||
}
|
||||
pause(500)
|
||||
pause(settleMillis)
|
||||
}
|
||||
|
||||
private fun legacySubmitTargets(snapshot: UiSnapshot): List<SnapshotNode> = uniqueClickable(
|
||||
|
||||
+50
-9
@@ -93,19 +93,23 @@ 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
|
||||
private var purchaseEntryIdentity: Pair<String?, String?>? = null
|
||||
private var entryRecoveryReason = "not_checked"
|
||||
|
||||
fun execute(input: PurchaseExecutionInput, rule: PurchaseRule, supportedCapabilities: Set<String>): PurchaseExecutionOutcome {
|
||||
purchasePanelContext = null
|
||||
purchaseEntryIdentity = null
|
||||
validateBeforeDeviceAction(input, rule, supportedCapabilities)?.let { return it }
|
||||
var observedPrice: Long? = null
|
||||
var addressProof: ShippingAddressProof? = null
|
||||
var irreversibleStarted = false
|
||||
val specSelectionProofs = mutableMapOf<String, ExactSpecSelectionProof>()
|
||||
val live = PurchaseLiveAutomation(driver, pause)
|
||||
val live = PurchaseLiveAutomation(driver, pause, panelDiagnostic)
|
||||
for (action in rule.actions) {
|
||||
// The immediate phase-two handoff can reuse the PDD page retained by
|
||||
// spec_probe. A later manual retry may start from Agent (or another
|
||||
@@ -130,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 {
|
||||
@@ -175,7 +179,7 @@ class PurchaseRehearsalExecutor(
|
||||
input.specResolutionAllowed &&
|
||||
PurchaseActionType.PROBE_SPECS in rule.actions.map { it.type }
|
||||
) {
|
||||
return probeOutcome()
|
||||
return probeOutcome(input)
|
||||
}
|
||||
return failure
|
||||
}
|
||||
@@ -186,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,
|
||||
@@ -380,6 +407,7 @@ class PurchaseRehearsalExecutor(
|
||||
val beforeSignature = specActionSignature(screen)
|
||||
val beforeIdentity = pageIdentity
|
||||
val click = driver.clickFreshDetailed(requireNotNull(target))
|
||||
purchaseEntryIdentity = if (click.result == FreshActionResult.SUCCESS) beforeIdentity else null
|
||||
panelDiagnostic("entrySource=${screen.specEntrySource ?: "unknown"};entryClick=${click.result};entryReason=${click.reason}")
|
||||
when (click.result) {
|
||||
// The parser already narrowed to a single semantic candidate; the
|
||||
@@ -452,7 +480,14 @@ class PurchaseRehearsalExecutor(
|
||||
after.problem != null || after.reviewPageOpen -> "page_problem"
|
||||
after.specPanelOpen -> "panel_open"
|
||||
after.hasCloseControl -> "close_control"
|
||||
after.hasPaymentArea -> "payment_area"
|
||||
// A product-page payment-method promotion is not a checkout page.
|
||||
// Keep real order/payment/address evidence blocking the one entry retry.
|
||||
pageIdentity.second?.contains("pay", ignoreCase = true) == true ||
|
||||
after.sourceNodes.any { node -> node.visible && listOf(
|
||||
"立即支付", "确认支付", "去支付", "去付款", "支付密码", "收银台",
|
||||
"订单详情", "订单编号", "订单号", "待付款", "待支付", "确认订单",
|
||||
"提交订单", "收货地址", "收货信息",
|
||||
).any(node.label::contains) } -> "payment_area"
|
||||
after.hasOrderSubmitAction -> "order_action"
|
||||
after.hasQuantityControls -> "quantity_controls"
|
||||
after.hasSelectionSummary -> "selection_summary"
|
||||
@@ -1189,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)
|
||||
}
|
||||
|
||||
@@ -1205,7 +1244,9 @@ class PurchaseRehearsalExecutor(
|
||||
private fun currentScreen(input: PurchaseExecutionInput): ParsedPddScreen {
|
||||
val snapshot = driver.capture()
|
||||
pageIdentity = snapshot.packageName to snapshot.activityName
|
||||
val screen = PddScreenParser.parse(snapshot, DEFAULT_COLLECTOR, input.goodsId, null, purchasePanelContext)
|
||||
val screen = PddScreenParser.parse(snapshot, DEFAULT_COLLECTOR, input.goodsId, null, purchasePanelContext,
|
||||
purchaseEntryContext = purchaseEntryIdentity != null && purchaseEntryIdentity == pageIdentity)
|
||||
if (purchaseEntryIdentity != pageIdentity) purchaseEntryIdentity = null
|
||||
purchasePanelContext = if (screen.isPddPackage && screen.problem == null && screen.specPanelOpen) {
|
||||
screen.specPanelContainer?.let {
|
||||
PurchasePanelContext(it, normalizedTarget("color", input.mappedColor).orEmpty())
|
||||
|
||||
+5
-2
@@ -58,10 +58,13 @@ class PurchaseDiagnosticStore(private val directory: File, private val now: () -
|
||||
private val VERSION = Regex("[0-9]+\\.[0-9]+\\.[0-9]+")
|
||||
private val FILE_NAME = Regex("[1-9][0-9]*_[A-Za-z0-9-]{1,80}\\.jsonl")
|
||||
private val numbers = setOf("specEntryCandidates", "explicit", "nested", "bottomPurchase", "entryReadyWaitPolls",
|
||||
"entryReadyWaitMillis", "scrollables", "headings", "options", "entryWaitPolls", "entryWaitMillis")
|
||||
"entryReadyWaitMillis", "scrollables", "headings", "options", "entryWaitPolls", "entryWaitMillis",
|
||||
"addressEditorCandidates", "addressEditorPolls", "savedLegacySubmitCandidates", "savedAddressCards",
|
||||
"savedAddressTargets", "savedScrollCandidates", "savedConfirmationPolls", "reusePolls")
|
||||
private val booleans = setOf("panelAlreadyOpen", "reviewPage", "pageEvidence", "pageChanged", "summary", "quantity",
|
||||
"orderAction", "closeControl", "paymentArea")
|
||||
"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 }
|
||||
}
|
||||
@@ -27,6 +27,20 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class PddProductDetailCollectorTest {
|
||||
@Test fun `purchase entry can recognize one heading only with combined structure`() {
|
||||
val snapshot = singleDimensionPanel()
|
||||
assertFalse(PddScreenParser.parse(snapshot, config(), GOODS_ID, null).specPanelOpen)
|
||||
assertEquals(SpecPanelType.NORMAL_SCROLLABLE,
|
||||
PddScreenParser.parse(snapshot, config(), GOODS_ID, null, purchaseEntryContext = true).specPanelType)
|
||||
for (missing in listOf("close", "plus", "minus", "info/quantity", "payment", "submit", "scroll/color-title", "scroll/color")) {
|
||||
val changed = snapshot.copy(nodes = snapshot.nodes.filterNot { it.path == missing })
|
||||
assertFalse(missing, PddScreenParser.parse(changed, config(), GOODS_ID, null, purchaseEntryContext = true).specPanelOpen)
|
||||
}
|
||||
assertFalse(PddScreenParser.parse(snapshot.copy(packageName = "other"), config(), GOODS_ID, null, purchaseEntryContext = true).specPanelOpen)
|
||||
assertFalse(PddScreenParser.parse(snapshot.copy(activityName = null), config(), GOODS_ID, null, purchaseEntryContext = true).specPanelOpen)
|
||||
assertFalse(PddScreenParser.parse(snapshot, config(), "", null, purchaseEntryContext = true).specPanelOpen)
|
||||
}
|
||||
|
||||
private fun singleDimensionPanel(): UiSnapshot {
|
||||
val snapshot = prefixlessPanel()
|
||||
return snapshot.copy(nodes = snapshot.nodes.filterNot {
|
||||
|
||||
@@ -18,6 +18,58 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class PurchaseLiveAutomationTest {
|
||||
@Test fun `address editor waits for loaded unique stable input after one second transitions`() {
|
||||
val driver = LiveDriver()
|
||||
var polls = 0
|
||||
driver.editTransform = { snapshot ->
|
||||
polls++
|
||||
when (polls) {
|
||||
1 -> snapshot.copy(nodes = snapshot.nodes.filterNot { it.path == "editor-address" })
|
||||
2 -> snapshot.copy(nodes = snapshot.nodes.map { if (it.path == "editor-address") it.copy(text = "") else it })
|
||||
else -> snapshot
|
||||
}
|
||||
}
|
||||
val pauses = mutableListOf<Long>()
|
||||
PurchaseLiveAutomation(driver, pause = { pauses += it }).updateShippingAddress("_cg81")
|
||||
assertTrue(polls >= 4)
|
||||
assertEquals("editor-address", driver.lastInputTargetPath)
|
||||
assertEquals(4, pauses.count { it == 1_000L })
|
||||
assertEquals(0, driver.submitClicks)
|
||||
}
|
||||
|
||||
@Test fun `persistently ambiguous address editors never receive input`() {
|
||||
val driver = LiveDriver()
|
||||
driver.editTransform = { snapshot -> snapshot.copy(nodes = snapshot.nodes + snapshot.nodes.single { it.path == "editor-address" }.copy(path = "duplicate-editor")) }
|
||||
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg81") }.exceptionOrNull() as PurchaseLiveException
|
||||
assertEquals("PURCHASE_ADDRESS_UPDATE_FAILED", error.code)
|
||||
assertTrue(error.message!!.contains("candidates=2"))
|
||||
assertEquals(null, driver.lastInputTargetPath)
|
||||
assertFalse(driver.clicked.contains("保存地址"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non scrollable saved panel waits for late address nodes without back or swipe`() {
|
||||
val driver = LiveDriver(savedStructuredFooter = "复购价,")
|
||||
var polls = 0
|
||||
driver.structuredTransform = { s ->
|
||||
polls++
|
||||
s.copy(nodes = s.nodes.filterNot {
|
||||
it.path.startsWith("root/popup/specs") ||
|
||||
(polls < 8 && it.path.startsWith("root/popup/address"))
|
||||
})
|
||||
}
|
||||
val logs = mutableListOf<String>()
|
||||
val automation = PurchaseLiveAutomation(driver, pause = {}, diagnostic = logs::add)
|
||||
val proof = automation.updateShippingAddress("_cg81")
|
||||
automation.finalConfirmation(input().copy(addressSuffix = "_cg81"), proof)
|
||||
assertTrue(polls >= 9)
|
||||
assertEquals(0, driver.scopedSwipes)
|
||||
assertEquals(0, driver.backCount)
|
||||
assertEquals(0, driver.submitClicks)
|
||||
assertTrue(logs.any { it.contains("savedAddressMatched=true") && it.contains("savedScrollCandidates=0") })
|
||||
assertFalse(logs.any { it.contains("_cg81") || it.contains("138") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `saved address recognizes split dynamic footer outside spec scroller without a swipe`() {
|
||||
for (label in listOf("复购价,", "专享优惠,")) {
|
||||
@@ -902,13 +954,14 @@ class PurchaseLiveAutomationTest {
|
||||
var postSubmitCaptureCount = 0
|
||||
val currentPage: String get() = page
|
||||
var structuredTransform: (UiSnapshot) -> UiSnapshot = { it }
|
||||
var editTransform: (UiSnapshot) -> UiSnapshot = { it }
|
||||
|
||||
override fun capture(): UiSnapshot {
|
||||
if (submitClicks > 0) {
|
||||
postSubmitCaptureCount++
|
||||
if (pendingPostSubmitPages.isNotEmpty()) page = pendingPostSubmitPages.removeAt(0)
|
||||
}
|
||||
return currentSnapshot().let { if (page == "structured") structuredTransform(it) else it }
|
||||
return currentSnapshot().let { if (page == "structured") structuredTransform(it) else if (page == "edit") editTransform(it) else it }
|
||||
}
|
||||
|
||||
private fun currentSnapshot(): UiSnapshot = when (page) {
|
||||
|
||||
@@ -28,6 +28,51 @@ 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 = {})
|
||||
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
assertEquals("spec_probe_completed", outcome.resultType)
|
||||
}
|
||||
@get:Rule val diagnosticTemporary = TemporaryFolder()
|
||||
|
||||
@Test
|
||||
@@ -956,9 +1001,22 @@ class PurchaseRehearsalExecutorTest {
|
||||
assertEquals(0, driver.specTapCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `payment method promotion alone permits one safe product entry retry`() {
|
||||
for (effect in listOf(true, false)) {
|
||||
val driver = FakePurchaseDriver(entryActionHasEffect = false, dynamicProduct = true,
|
||||
afterEntryMutation = "payment-promotion", specTapResult = FreshActionResult.SUCCESS, specTapHasEffect = effect)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
if (effect) assertEquals(outcome.message, "rehearsal_completed", outcome.resultType)
|
||||
else assertEquals("PURCHASE_SPEC_ENTRY_CLICK_NO_EFFECT", outcome.errorCode)
|
||||
assertEquals(1, driver.specTapCount)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `changed entry identity or unsafe page never receives recovery tap`() {
|
||||
for (mode in listOf("missing", "duplicate", "activity", "payment")) {
|
||||
for (mode in listOf("missing", "duplicate", "activity", "payment", "order")) {
|
||||
val diagnostics = mutableListOf<String>()
|
||||
val driver = FakePurchaseDriver(entryActionHasEffect = false, dynamicProduct = true,
|
||||
afterEntryMutation = mode, specTapResult = FreshActionResult.SUCCESS)
|
||||
@@ -967,7 +1025,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
assertEquals(mode, "failed", outcome.resultType)
|
||||
assertEquals(mode, 0, driver.specTapCount)
|
||||
val expected = mapOf("missing" to "entry_missing", "duplicate" to "entry_ambiguous",
|
||||
"activity" to "identity_changed", "payment" to "payment_area").getValue(mode)
|
||||
"activity" to "identity_changed", "payment" to "payment_area", "order" to "payment_area").getValue(mode)
|
||||
assertTrue(diagnostics.toString(), diagnostics.any { it.contains("entryRecovery=rejected;reason=$expected;") })
|
||||
}
|
||||
}
|
||||
@@ -1035,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
|
||||
@@ -1406,6 +1464,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
private val reviewBackSucceeds: Boolean = true,
|
||||
private val hiddenSizeUntilUpSwipes: Int = 0,
|
||||
private val prefixlessSingleHeadingAfterColor: Boolean = false,
|
||||
private val singleHeadingOnEntry: Boolean = false,
|
||||
private val hideSizeAfterSelection: Boolean = false,
|
||||
private val revealGridSizeAfterUpSwipes: Int? = null,
|
||||
private val openClickResults: MutableList<FreshActionResult> = mutableListOf(),
|
||||
@@ -1549,7 +1608,9 @@ class PurchaseRehearsalExecutorTest {
|
||||
if (entryAttempted) {
|
||||
if (afterEntryMutation == "missing") nodes.removeAll { it.path == "spec" }
|
||||
if (afterEntryMutation == "duplicate") nodes += node("spec2", "选择规格", 20, 1120, 900, 1190, clickable = true)
|
||||
if (afterEntryMutation == "payment") nodes += node("payment", "微信支付", 20, 1800, 900, 1900)
|
||||
if (afterEntryMutation == "payment-promotion") nodes += node("payment", "微信支付", 20, 1800, 900, 1900)
|
||||
if (afterEntryMutation == "payment") nodes += node("payment", "确认支付", 20, 1800, 900, 1900)
|
||||
if (afterEntryMutation == "order") nodes += node("order", "订单详情", 20, 1800, 900, 1900)
|
||||
if (afterEntryMutation == "activity") return UiSnapshot(PDD, "OtherActivity", nodes)
|
||||
}
|
||||
return UiSnapshot(PDD, ACTIVITY, nodes)
|
||||
@@ -1560,7 +1621,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
node("panel-title", "确认款式", 20, 396, 300, 430),
|
||||
))
|
||||
}
|
||||
val singleHeading = prefixlessSingleHeadingAfterColor && color != null
|
||||
val singleHeading = singleHeadingOnEntry || (prefixlessSingleHeadingAfterColor && color != null)
|
||||
val hideColor = singleHeading || (hideColorAfterQuantitySet && quantity == 2L && !hiddenColorRestored)
|
||||
val hideSize = (hideSizeAfterQuantitySet && quantity == 2L) || (hideSizeAfterSelection && size != null)
|
||||
val hideSummary = hideSelectedSummaryAfterQuantitySet && quantity == 2L
|
||||
|
||||
+11
@@ -42,6 +42,17 @@ class PurchaseDiagnosticStoreTest {
|
||||
assertEquals("close_control", entry.getJSONObject("evidence").getString("reason"))
|
||||
}
|
||||
|
||||
@Test fun `saved confirmation diagnostics retain only bounded scalars`() {
|
||||
val fields = PurchaseDiagnosticStore.sanitize("savedAddressMatched=false;savedPanelMatched=true;" +
|
||||
"savedLegacySubmitCandidates=0;savedAddressCards=1;savedAddressTargets=1;" +
|
||||
"savedScrollCandidates=0;savedConfirmationPolls=50;savedAddress=secret;" +
|
||||
"savedAddressMatched=secret;savedScrollCandidates=-1")
|
||||
assertEquals(7, fields.length())
|
||||
assertFalse(fields.getBoolean("savedAddressMatched"))
|
||||
assertEquals(50, fields.getInt("savedConfirmationPolls"))
|
||||
assertFalse(fields.toString().contains("secret"))
|
||||
}
|
||||
|
||||
@Test fun `retains last five attempts and last 128 events including failure`() {
|
||||
val dir = temporary.newFolder()
|
||||
var time = 1000L
|
||||
|
||||
@@ -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: 223cb9035a6b6e63072aa6b075889fcd0d6c2f23
|
||||
synchronized_at: 2026-09-09T08:18:24Z
|
||||
wiki_revision: 029dc3e155e1828484c62efd0ac804616fcbc714
|
||||
synchronized_at: 2026-09-10T09:13:27Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 架构与代码地图
|
||||
@@ -377,3 +377,86 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
|
||||
- 删除 `PddCollectionHomeNavigator` 和专属首页/搜索层判据。新导航器仅使用 capture/back、当前包名、openAgentPreservingTab;1~5 次 Back,每次后 500ms,前台离开 PDD 终止剩余 Back;保留风险场景停止。最后打开 Agent 并等待 500ms 验证包名,不验证 PDD 首页。仍在原任务互斥与唤醒锁范围内完成,结束后原 finally 释放。
|
||||
- `GoAutoCollector` 日志关联 taskId、attemptNumber、deviceId、规则快照 SHA-256;记录 continuousReturn、configuredBacks、固定 reason 和 actions。原因含 count_completed、left_pdd、back_failed、unsafe_page、agent_launch_failed、agent_not_confirmed、navigation_exception、invalid_count;actions 为 Back 尝试数,不代表最终页面。无节点文字、树、截图或个人数据落盘;不扩大采购诊断存储。
|
||||
- `ContinuousCollectionTest` 覆盖范围/终态、1~5 次、无变化仍按次数、前台变化、风险、动作/启动失败、包名确认和异常;`ContinuousCollectionSettingsTest` 覆盖开关与次数持久化、默认/越界/写入失败。`:app:testDebugUnitTest :app:assembleDebug` 376 项通过。旧首页专属测试随旧行为替换,新版未安装,UI 大字体/横屏、多设备和完整采集真机链路尚未验证。
|
||||
|
||||
## SYB 存疑规格候选解析(#251)
|
||||
|
||||
实现绑定 `64b4205`,仅 Server `app/goauto/sybimport`;无 API 字段、数据库迁移、权限、并发模式或 UI 变化。
|
||||
|
||||
- `parse_candidates.go: ParseWithCandidates` 先运行原 `Parse`,仅 uncertain 时解码并校验已存 SpecsJSON;复用 `closedShopeeCandidates` 提取颜色/尺码封闭候选,拒绝未知有值角色/重复角色。完整候选组合与原文归一比较唯一时确认,其他情况返回原 ParseResult。
|
||||
- `ApplyDetail` 在加载既有蝦皮商品后、写入规格前调用;`Reparse` 在保留人工/AI 确认保护后加载关联候选调用。新增 ParseResult 私有 matchedCandidates 标记,使 `mergeParsedSpec` 对已经存在的候选直接返回,避免按维度名重复合并或改变映射。
|
||||
- #198 `ProcessSpecAIParseRun → Reparse` 自然接入确定性优化,仍需 AI 的记录沿用原后续步骤,不新增异步任务/Provider 调用/接口。
|
||||
- 验证 `go test ./app/goauto/sybimport ./app/goauto/aimatching ./app/goauto/shopeeproduct ./app/goauto/purchase` 和 `go build ./...` 通过;新增候选正反/格式/组合色/单维度、歧义/空值、同步重解析一致、人工保护及无需调用 AI 测试。AI 原有置信度/失败重试测试改用仍需语义理解的合成输入,继续验证原分支。
|
||||
- 本轮只使用本地测试数据与 httptest AI,未触发真实 SYB 同步、外部 AI、采购、数据库迁移或服务重启;线上及历史数据效果尚待授权验证。
|
||||
|
||||
|
||||
## 蝦皮详情匹配等待与回读(#254)
|
||||
|
||||
实现绑定 `9088e6b`(2026-09-10,分支 fix/254-match-loading);已通过合成数据测试,尚未合并 main 或发布线上。
|
||||
|
||||
- Server `shopeeproduct.Service.DetailWithAutoMatchBudget` 为详情 GET 附加派生的整次匹配预算,不返回 Provider、Key 等配置。`AutoMatchMappings` 读取 AI 设置 T,使用 2T+10 秒的单个父 context;颜色、尺码、保存及结果回读共用剩余时间。初始配置读取也受有界 context 约束,读取耗时从后续预算扣除。
|
||||
- 批量定时匹配调用内部 `autoMatchMappings` 并继续使用原 MaxProviderTimeout=600 秒,不随本次详情入口调整;候选建议与其他 AI 功能不改。
|
||||
- Web `ShopeeProductDetailDrawer` 在提交前以普通 GET 刷新商品与预算,仅 auto-match POST 使用 (2T+20) 秒超时。准备失败/缺少预算时不发 POST;旧 Server 必须先更新,不能只发布新版 Web。
|
||||
- 加载状态同时绑定商品加载代次和操作序号。关闭/切换立即结束该页面等待状态;同商品重开读取详情,旧响应/旧 finally 不得污染新操作。匹配期间禁止在当前抽屉编辑映射,原人工草稿放弃确认保留。
|
||||
- POST 未正常返回时只回读详情及关联 PDD,不自动重发 POST;回读失败结束转圈并保留刷新入口。GET 沿用单请求10秒超时,完整结果回读最多两次串行 GET,不是无限等待。
|
||||
- 验证:Go shopeeproduct/aimatching/clientapi 测试与 go build;Web `node --test tests/unit/shopee-match-lifecycle.test.cjs`,Playwright shopee-match-lifecycle 与 shopee-pdd-picker,受影响 ESLint 与 build:prod。
|
||||
|
||||
|
||||
## SYB 行内一键关联最近采集(#253)
|
||||
|
||||
实现绑定 `b1594dc`,2026-09-10;分支 feat/253-one-click-link,包含 #254 依赖。已完成合成数据测试及构建,未合并 main、未部署,不代表当前线上已具备此能力。
|
||||
|
||||
- Web `syb-products/index.vue` 在“去关联”旁增加“一键关联+下拉箭头”,共享 `quick-link.js` 页面级状态。`quick-link-device-preference.js` 按当前浏览器来源、API环境、用户ID保存设备ID,独立于批量采购偏好;不存密钥或商品数据。首次主动作选机继续原行,箭头选机只保存偏好。
|
||||
- Server `shopeeproduct/latest_collection.go` 复用 LinkPDD 的可选 Admin 模式。短事务核对 SYB→Shopee、未关联/规格版本、设备状态,选择并锁定最新 completed/completed_partial 的 agent_current_page 任务及其 PDD;仅完整采集、active且有可用颜色或尺码时更新,WHERE pdd_product_id IS NULL 不覆盖其他操作者的关联。响应保留该事务产生的规格版本及来源task/device/PDD,不能用后续GET替换冻结上下文再自动匹配。
|
||||
- 不新增表、迁移、路由或权限对账。原手动关联/替换请求不变;共享 Handler 明确拒绝 Client API 使用此跨模块模式,不能借 Shopee-only write grant 读取 SYB/设备上下文。来源任务原规则快照不改,taskId 可回溯既有采集证据。
|
||||
- 关联后读取现有采购准备;需要人工确认或不具备AI资格时停止自动链路并保留关联,其他情况调用原 AutoMatchMappings,沿用 #254 动态预算及原事务保存/上下文冲突保护。最后刷新实际处理阶段,不调用创建采购/订单接口。
|
||||
- 运行期间冻结行、商品、手机;前端重复主动作禁用,切换偏好仍只影响未来操作。卸载或 keep-alive deactivated 令旧操作失效,禁止旧回调继续下一步;不声称已经取消服务端在途写入。异常仅有限GET回读和刷新,不自动重发POST。
|
||||
- 验证命令:Server `go test -p 1 ./...`、`go build ./...`;Web `node --test tests/unit/syb-quick-link.test.cjs tests/unit/shopee-match-lifecycle.test.cjs`、`pnpm exec playwright test tests/e2e/syb-quick-link.spec.ts tests/e2e/shopee-match-lifecycle.spec.ts --workers=1`、受影响文件 ESLint、`pnpm build:prod`。SQLite 单连接并发测试不代替生产 MySQL 行锁验收;真实AI、生产数据、跨设备人工闭环待验证。
|
||||
|
||||
## SYB 行内一键替换(#258)
|
||||
|
||||
实现绑定 `2b01974`;部署与验收见 #258 发布证据。本节补充 #253,不改变原未关联入口。
|
||||
|
||||
- `syb-products/quick-link.js` 复用分体按钮、设备偏好和匹配/回读流程;已关联且允许处理阶段时同位置显示一键替换。先只读预览,固定任务、目标商品及规格版本;不同商品一次确认,同商品不重绑。
|
||||
- `shopeeproduct/latest_collection.go` 在原 latestCollection 增加可选 replacement 模式。预览和提交均短事务锁定 SYB、Shopee、原PDD、设备、来源采集与目标PDD,核对旧关联及上下文。提交按预览 taskId 读取,不重新挑选后来采集的商品。
|
||||
- Admin 路由注入 `purchase.ValidateQuickReplacement`,在事务内复用采购准备数据集及处理阶段计算,不调用 AI。缺少校验函数拒绝替换;客户端密钥不注入且仍拒绝 latestCollection。无新路由、权限、表或迁移。
|
||||
- 关联完成后才执行现有 AI 匹配,不在数据库事务中等待 Provider;同一 SYB 正式采购创建与替换共用 SYB 行锁。历史采购快照不变。SQLite 合成测试覆盖冲突与串行并发请求,不能替代生产 MySQL 多连接锁验收。
|
||||
|
||||
## 辅助信息缺失的采集关联(#259)
|
||||
|
||||
实现绑定 `e467706`,仅 Server;部署/验收见 #259。替代 #253/#258 中“部分完成一律拒绝”的限制,不改变 completed 原有路径。
|
||||
|
||||
- completed_partial 的 missing 必须是非空合法列表且仅包含 salesText、shopName、reviewCount,才进入辅助信息兼容检查;title、规格/价格缺失、遍历截断、SKU数量上限及未知原因仍拒绝。
|
||||
- `shopeeproduct/partial_collection.go` 核对本次 task 的维度和值、可售SKU的完整标记/规格引用/非负价格、本次颜色价格,以及当前档案可选值由本次有效SKU覆盖,不能借旧档案合并残留的规格或价格通过。真实单维度允许,不虚构另一维度;本次明确报告缺少规格仍拒绝。
|
||||
- 一键关联、替换预览及确认提交共用验证。失败沿用409 LATEST_COLLECTION_UNAVAILABLE及现有提示,不回退更早采集、不换手机。状态 completed_partial 和 missing 原样保留;辅助字段缺失仍可在任务详情查看。
|
||||
- 无接口字段、数据库迁移、Android/Web或权限变更;普通人工关联与采购/AI资格不变。不将“允许关联”视为“已匹配或已采购”。
|
||||
|
||||
## 采购单标题面板与地址就绪等待(#260、#261)
|
||||
|
||||
实现绑定 #260 `3c1ffa4`(执行器回归测试 `7de4318`)、#261 `5ea07f1`,Android 0.9.74 / versionCode87,基于0.9.73。安装及真实采购验收以工单证据为准;不代表 main 的 Android 已整合这些分支。
|
||||
|
||||
- 采购仅在安全规格入口点击返回SUCCESS后,记录本次页面包名/Activity作为内存上下文;开始新执行或页面身份改变时清除。`PddScreenParser` 的新增 purchaseEntryContext 默认关闭,仅该采购执行器传入;普通解析和采集原判定保留。
|
||||
- 当前仍为同一PDD Activity、商品ID非空、没有页面风险、没有选中摘要,且唯一有标题规格滚动区、一个维度有值,同时具有关闭、数量、支付展示区域与下单操作证据时,单标题可识别为NORMAL_SCROLLABLE。只是允许进入既有精确规格探测/选择,不执行下单或付款,不全局认可UNKNOWN,不增加入口重复点击或滑动。
|
||||
- 地址入口点击原有1000ms保留;地址列表等待增加唯一修改目标要求。点击修改和保存由500ms改为1000ms,其他通用动作间隔不变,无新配置项。
|
||||
- 修改后不再只见“详细地址”文字就立即写入。最多50次、每次间隔200ms读取(不含读取耗时),检查PDD页面风险;唯一可见启用有内容的详细地址EditText,连续两次路径、边界、内容及Activity相同才继续原输入流程。始终零个/多个或不稳定时失败,不取第一个候选。
|
||||
- 输入内容生成、输入后回读、保存后退出及返回规格面板、最终复核和单次下单边界不变。新增本机脱敏诊断 addressEditorCandidates / addressEditorPolls 两个整数,与task/attempt/device/ruleHash关联;不记录地址内容、原始树或整屏截图。
|
||||
- 仅Android;无Server/Web、共享API字段、数据库迁移或权限变化。真实采购、地址写入及订单创建不作为自动安装验证,不执行付款。
|
||||
|
||||
### v2 商品入口补点与地址返回等待(#260、#261)
|
||||
|
||||
2026-09-10 实现绑定 #260 `713111d`、#261 `d0a82f3`,Android 0.9.75 / versionCode88;真实采购效果以工单验收为准,非 main 合并声明。
|
||||
|
||||
- 入口恢复不再把全页面支付方式展示文案单独视为支付页。当前包名/Activity、商品标题和安全入口必须与点击前一致,入口唯一且可用;已打开面板、关闭控件、数量、选中摘要、订单操作、风险/评价页面仍拒绝恢复。支付 Activity 或明确支付、订单、地址文本继续阻止补点。仅允许既有一次重新定位后的规格入口中心手势,随后仍验证面板,不循环、不扩展到地址保存或下单。
|
||||
- 保存地址后的确认读取、必要时一次返回后的确认读取,增加最多50次、间隔200ms的有界就绪检查,页面捕获耗时另计。连续两次同一Activity满足原地址回读与确认面板条件后继续;完整信息不要求滚动区域。已明确到地址列表时保留原返回路径,不等待列表出现下单按钮;返回后增加1000ms缓冲。
|
||||
- 就绪检查不修改原地址回读和结构下单入口规则;超时后只对既有识别的面板、唯一可用滚动区域执行原有有限滚动。零/多个区域仍失败,不凭地址入口或未知底部节点授权下单。最终精确规格、数量、价格、地址与单次下单边界不变。
|
||||
- 私有采购诊断新增布尔 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: 232757b70c87203d6270020fdc912720b98696c8
|
||||
synchronized_at: 2026-09-10T06:42:20Z
|
||||
wiki_revision: d8aeff02fba57cba0b53fb22cdb524fd5cc0cb3e
|
||||
synchronized_at: 2026-09-10T09:13:33Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
@@ -37,6 +37,8 @@ synchronized_at: 2026-09-10T06:42:20Z
|
||||
- PDD 重新采集或更换关联后,目标规格仍存在则映射继续有效;目标规格消失时详情标记“已失效”,服务端拒绝保存不存在的目标,采购预检和创建也拒绝使用失效映射并提示重新选择。
|
||||
- 批量软删除逐条校验引用(虾皮商品被 SYB 明细或采购任务引用时不可删除)并逐条返回结果;已删除商品默认不出现在列表,可筛选查看并恢复。
|
||||
|
||||
- #257:蝦皮颜色与尺码建议/一键匹配先保留仍有效的已确认映射,再复用相同标准化比较:简体、空白、全半角与大小写统一。颜色标准化后仅命中一个当前可选 PDD 候选时,不调用 AI,建议标为 matched,一键匹配按既有流程保存 exact_match + confirmed;未命中或多个不同候选标准化后相同,仍走原 AI 与置信度/候选校验。只转换比较键,蝦皮/PDD 原规格名不改,映射目标保存 PDD 原始候选名称;不引入颜色同义词猜测、不自动重算历史映射。
|
||||
|
||||
## SYB 商品明细
|
||||
|
||||
- 一行对应 SYB 一条明确的商品/颜色/尺码/数量明细;唯一键为「蝦皮订单号 `code` + 来源明细 `id`」组合,不是全局唯一 ID(未在多货运单样本中验证过全局唯一性)。
|
||||
@@ -558,3 +560,53 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
|
||||
- 先保存关联,再复用现有颜色/尺码自动匹配及保存。有效已确认映射保留;普通部分匹配保存合格结果,AI故障不提交本轮新映射,但已保存关联保留。后续可从“去匹配”或详情人工继续。failed 且无人工/AI确认时不能自动跳过人工解析;uncertain 不统一拦截,仍以原采购准备规则为准。
|
||||
- 操作完成不保证立即可采购,必须刷新现有处理阶段;缺规格、缺映射、解析需确认或其他原资格不满足时继续显示真实待处理阶段。不自动创建采购任务、不下单、不支付。
|
||||
- 请求超时/网络中断只做有限回读,不重复提交;读回失败明确结果未确认,避免无限转圈。离开页面不代表后端停止或回滚。普通用户沿用现有采购员权限,无新管理员专属开关。
|
||||
|
||||
## SYB 已关联商品的一键替换(#258)
|
||||
|
||||
实现绑定 `2b01974`;部署与验收见 #258 发布证据。
|
||||
|
||||
- 复用一键关联及小箭头的位置,保留原查看 PDD 商品。PDD 待采集、采集失败、停用或缺价格的对应处理阶段可替换;采集中、已创建/运行采购、成功或结果未知不开放。服务端提交时再次检查,不能只相信按钮。
|
||||
- 复用每用户/浏览器/API环境的手机偏好;首次选择继续当前行,小箭头仅切换偏好。预览仅选所选手机最新 completed/completed_partial 临时采集,部分成功或不可用不回退旧商品。确认后固定来源任务和目标,不改用新采集;来源状态、设备、目标规格或旧关联改变时拒绝。
|
||||
- 不同商品显示一次确认,包含旧/新商品、手机和共享蝦皮档案影响;取消不写入。同商品不弹替换确认且不重写关联,只继续匹配。共享该蝦皮档案的 SYB 明细使用新关联,历史采购任务快照不改。
|
||||
- 沿用既有更换关联和有效映射检查,再复用自动匹配保存。部分匹配、AI失败或解析需人工处理时保留已保存关联,显示真实处理阶段,不保证立即可采购、不自动创建采购。
|
||||
- 网络异常只有限回读,区分目标商品、仍为原商品和其他关联,不把“已有某个关联”误报为本次替换成功;不自动重发写请求。没有新增后台作业、订单或付款操作。
|
||||
|
||||
## 辅助信息缺失的采集关联(#259)
|
||||
|
||||
实现绑定 `e467706`,仅 Server;部署/验收见 #259。替代 #253/#258 中“部分完成一律拒绝”的限制,不改变 completed 原有路径。
|
||||
|
||||
- completed_partial 的 missing 必须是非空合法列表且仅包含 salesText、shopName、reviewCount,才进入辅助信息兼容检查;title、规格/价格缺失、遍历截断、SKU数量上限及未知原因仍拒绝。
|
||||
- `shopeeproduct/partial_collection.go` 核对本次 task 的维度和值、可售SKU的完整标记/规格引用/非负价格、本次颜色价格,以及当前档案可选值由本次有效SKU覆盖,不能借旧档案合并残留的规格或价格通过。真实单维度允许,不虚构另一维度;本次明确报告缺少规格仍拒绝。
|
||||
- 一键关联、替换预览及确认提交共用验证。失败沿用409 LATEST_COLLECTION_UNAVAILABLE及现有提示,不回退更早采集、不换手机。状态 completed_partial 和 missing 原样保留;辅助字段缺失仍可在任务详情查看。
|
||||
- 无接口字段、数据库迁移、Android/Web或权限变更;普通人工关联与采购/AI资格不变。不将“允许关联”视为“已匹配或已采购”。
|
||||
|
||||
## 采购单标题面板与地址就绪等待(#260、#261)
|
||||
|
||||
实现绑定 #260 `3c1ffa4`(执行器回归测试 `7de4318`)、#261 `5ea07f1`,Android 0.9.74 / versionCode87,基于0.9.73。安装及真实采购验收以工单证据为准;不代表 main 的 Android 已整合这些分支。
|
||||
|
||||
- 采购仅在安全规格入口点击返回SUCCESS后,记录本次页面包名/Activity作为内存上下文;开始新执行或页面身份改变时清除。`PddScreenParser` 的新增 purchaseEntryContext 默认关闭,仅该采购执行器传入;普通解析和采集原判定保留。
|
||||
- 当前仍为同一PDD Activity、商品ID非空、没有页面风险、没有选中摘要,且唯一有标题规格滚动区、一个维度有值,同时具有关闭、数量、支付展示区域与下单操作证据时,单标题可识别为NORMAL_SCROLLABLE。只是允许进入既有精确规格探测/选择,不执行下单或付款,不全局认可UNKNOWN,不增加入口重复点击或滑动。
|
||||
- 地址入口点击原有1000ms保留;地址列表等待增加唯一修改目标要求。点击修改和保存由500ms改为1000ms,其他通用动作间隔不变,无新配置项。
|
||||
- 修改后不再只见“详细地址”文字就立即写入。最多50次、每次间隔200ms读取(不含读取耗时),检查PDD页面风险;唯一可见启用有内容的详细地址EditText,连续两次路径、边界、内容及Activity相同才继续原输入流程。始终零个/多个或不稳定时失败,不取第一个候选。
|
||||
- 输入内容生成、输入后回读、保存后退出及返回规格面板、最终复核和单次下单边界不变。新增本机脱敏诊断 addressEditorCandidates / addressEditorPolls 两个整数,与task/attempt/device/ruleHash关联;不记录地址内容、原始树或整屏截图。
|
||||
- 仅Android;无Server/Web、共享API字段、数据库迁移或权限变化。真实采购、地址写入及订单创建不作为自动安装验证,不执行付款。
|
||||
|
||||
### v2 商品入口补点与地址返回等待(#260、#261)
|
||||
|
||||
2026-09-10 实现绑定 #260 `713111d`、#261 `d0a82f3`,Android 0.9.75 / versionCode88;真实采购效果以工单验收为准,非 main 合并声明。
|
||||
|
||||
- 入口恢复不再把全页面支付方式展示文案单独视为支付页。当前包名/Activity、商品标题和安全入口必须与点击前一致,入口唯一且可用;已打开面板、关闭控件、数量、选中摘要、订单操作、风险/评价页面仍拒绝恢复。支付 Activity 或明确支付、订单、地址文本继续阻止补点。仅允许既有一次重新定位后的规格入口中心手势,随后仍验证面板,不循环、不扩展到地址保存或下单。
|
||||
- 保存地址后的确认读取、必要时一次返回后的确认读取,增加最多50次、间隔200ms的有界就绪检查,页面捕获耗时另计。连续两次同一Activity满足原地址回读与确认面板条件后继续;完整信息不要求滚动区域。已明确到地址列表时保留原返回路径,不等待列表出现下单按钮;返回后增加1000ms缓冲。
|
||||
- 就绪检查不修改原地址回读和结构下单入口规则;超时后只对既有识别的面板、唯一可用滚动区域执行原有有限滚动。零/多个区域仍失败,不凭地址入口或未知底部节点授权下单。最终精确规格、数量、价格、地址与单次下单边界不变。
|
||||
- 私有采购诊断新增布尔 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日志证实两阶段,但未记录旧版重开判断,不把上下文丢失假设宣称为唯一现场根因。
|
||||
|
||||
Reference in New Issue
Block a user