From a49dc696e9ec58e2381bbb3b67cfc41bb0c0fbec Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Tue, 6 Oct 2026 17:15:30 +0800 Subject: [PATCH 1/7] fix(android): guard spec probe clicks and persist attempt diagnostics (#361) --- android/app/build.gradle.kts | 4 +- .../agent/automation/CurrentPageCollection.kt | 3 + .../automation/GoAutoAccessibilityService.kt | 21 ++++- .../automation/PddProductDetailCollector.kt | 20 ++++- .../automation/PurchaseRehearsalExecutor.kt | 22 ++++- .../agent/automation/SizeAdviceClickPolicy.kt | 42 ++++++++++ .../agent/automation/SpecClickDiagnostic.kt | 32 +++++++ .../persistence/AgentDiagnosticSchema.kt | 18 +++- .../agent/persistence/AgentDiagnosticStore.kt | 23 ++++- .../agent/service/AgentForegroundService.kt | 21 ++++- .../agent/PddProductDetailCollectorTest.kt | 41 +++++++++ .../agent/PurchaseRehearsalExecutorTest.kt | 84 ++++++++++++++++--- .../automation/SizeAdviceClickPolicyTest.kt | 46 ++++++++++ .../automation/SpecClickDiagnosticTest.kt | 21 +++++ .../AgentDiagnosticStoreMigrationTest.kt | 35 ++++++++ 15 files changed, 406 insertions(+), 27 deletions(-) create mode 100644 android/app/src/main/java/cn/ilapage/goauto/agent/automation/SizeAdviceClickPolicy.kt create mode 100644 android/app/src/main/java/cn/ilapage/goauto/agent/automation/SpecClickDiagnostic.kt create mode 100644 android/app/src/test/java/cn/ilapage/goauto/agent/automation/SizeAdviceClickPolicyTest.kt create mode 100644 android/app/src/test/java/cn/ilapage/goauto/agent/automation/SpecClickDiagnosticTest.kt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 3ded304..02a19d0 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -11,8 +11,8 @@ android { applicationId = "cn.ilapage.goauto.agent" minSdk = 23 targetSdk = 34 - versionCode = 77 - versionName = "0.9.64" + versionCode = 81 + versionName = "0.9.68" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/CurrentPageCollection.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/CurrentPageCollection.kt index f7c3046..e811634 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/CurrentPageCollection.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/CurrentPageCollection.kt @@ -339,6 +339,7 @@ class CurrentPageIdentityRunner( when (outcome.result) { FreshActionResult.SUCCESS -> return ShareClickResult(true, "") FreshActionResult.AMBIGUOUS -> return ShareClickResult(false, "商品分享入口不唯一") + FreshActionResult.BLOCKED -> return ShareClickResult(false, "已阻止点击尺码建议入口") FreshActionResult.NOT_FOUND, FreshActionResult.FAILED -> Unit } } @@ -366,6 +367,7 @@ class CurrentPageIdentityRunner( when (outcome.result) { FreshActionResult.SUCCESS -> return ShareClickResult(true, "") FreshActionResult.AMBIGUOUS -> return ShareClickResult(false, "复制链接入口不唯一") + FreshActionResult.BLOCKED -> return ShareClickResult(false, "已阻止点击尺码建议入口") FreshActionResult.NOT_FOUND, FreshActionResult.FAILED -> Unit } } @@ -528,6 +530,7 @@ class CurrentPageIdentityRunner( FreshClickReason.NO_CLICKABLE_ANCESTOR -> AgentDiagnosticReason.NO_CLICKABLE_ANCESTOR FreshClickReason.ACTION_CLICK_FALSE -> AgentDiagnosticReason.ACTION_CLICK_FALSE FreshClickReason.UNKNOWN -> AgentDiagnosticReason.UNKNOWN + FreshClickReason.SIZE_ADVICE_BLOCKED -> AgentDiagnosticReason.SIZE_ADVICE_CLICK_BLOCKED } private data class SharePreparation( diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt index cb7fafa..be6c6f4 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt @@ -162,7 +162,7 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto while (!target.isClickable) { target = target.parent ?: return false } - return target.performAction(AccessibilityNodeInfo.ACTION_CLICK) + return !sizeAdviceBlocked(target) && target.performAction(AccessibilityNodeInfo.ACTION_CLICK) } override fun input(node: UiNodeRef, value: String): Boolean { @@ -212,7 +212,7 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto if (!node.isClickable || !node.isEnabled || !node.isVisibleToUser || NodeBounds(bounds.left, bounds.top, bounds.right, bounds.bottom) != fresh.bounds || (node.text?.toString()?.trim().takeUnless { it.isNullOrEmpty() } ?: node.contentDescription?.toString()?.trim().orEmpty()) != fresh.label) return false - return node.performAction(AccessibilityNodeInfo.ACTION_CLICK) + return !sizeAdviceBlocked(node) && node.performAction(AccessibilityNodeInfo.ACTION_CLICK) } fun scrollBackfill(): Boolean { @@ -329,6 +329,15 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto override fun clickFresh(target: SnapshotNode): FreshActionResult = clickFreshDetailed(target).result + private fun sizeAdviceBlocked(node: AccessibilityNodeInfo): Boolean = + node.packageName?.toString() == "com.xunmeng.pinduoduo" && SizeAdviceClickPolicy.blocked( + node, + clickable = { it.isClickable }, + parent = { it.parent }, + children = { current -> (0 until current.childCount).mapNotNull(current::getChild) }, + labels = { listOfNotNull(it.text?.toString(), it.contentDescription?.toString()) }, + ) + override fun clickFreshDetailed(target: SnapshotNode): FreshClickOutcome { val root = rootInActiveWindow ?: return FreshClickOutcome(FreshActionResult.NOT_FOUND, FreshClickReason.ROOT_UNAVAILABLE) val candidates = mutableListOf() @@ -356,6 +365,9 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto ) ancestorDepth++ } + if (sizeAdviceBlocked(node)) return FreshClickOutcome( + FreshActionResult.BLOCKED, FreshClickReason.SIZE_ADVICE_BLOCKED, candidates.size, ancestorDepth, + ) return if (node.performAction(AccessibilityNodeInfo.ACTION_CLICK)) { FreshClickOutcome(FreshActionResult.SUCCESS, FreshClickReason.SUCCESS, candidates.size, ancestorDepth) } else { @@ -392,6 +404,9 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto ) ancestorDepth++ } + if (sizeAdviceBlocked(node)) return FreshClickOutcome( + FreshActionResult.BLOCKED, FreshClickReason.SIZE_ADVICE_BLOCKED, 1, ancestorDepth, + ) return if (node.performAction(AccessibilityNodeInfo.ACTION_CLICK)) { FreshClickOutcome(FreshActionResult.SUCCESS, FreshClickReason.SUCCESS, 1, ancestorDepth) } else { @@ -414,6 +429,7 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto } if (candidates.isEmpty()) return FreshActionResult.NOT_FOUND if (candidates.size != 1) return FreshActionResult.AMBIGUOUS + if (sizeAdviceBlocked(candidates.single())) return FreshActionResult.BLOCKED val bounds = Rect().also(candidates.single()::getBoundsInScreen) if (bounds.width() < 2 || bounds.height() < 2 || Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { return FreshActionResult.FAILED @@ -438,6 +454,7 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto } if (candidates.isEmpty()) return FreshActionResult.NOT_FOUND if (candidates.size != 1) return FreshActionResult.AMBIGUOUS + if (sizeAdviceBlocked(candidates.single())) return FreshActionResult.BLOCKED val bounds = Rect().also(candidates.single()::getBoundsInScreen) val freshTarget = target.copy(bounds = NodeBounds(bounds.left, bounds.top, bounds.right, bounds.bottom)) if (!PurchaseSpecGesturePolicy.isAllowed(freshTarget, width, height)) { diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt index d025052..bedd81b 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt @@ -38,7 +38,7 @@ data class UiSnapshot( val nodes: List, ) -enum class FreshActionResult { SUCCESS, NOT_FOUND, AMBIGUOUS, FAILED } +enum class FreshActionResult { SUCCESS, NOT_FOUND, AMBIGUOUS, FAILED, BLOCKED } enum class FreshClickReason { SUCCESS, @@ -47,6 +47,7 @@ enum class FreshClickReason { TARGET_AMBIGUOUS, NO_CLICKABLE_ANCESTOR, ACTION_CLICK_FALSE, + SIZE_ADVICE_BLOCKED, UNKNOWN, } @@ -1021,7 +1022,8 @@ class PddProductDetailCollector( while (opened == null) { val target = entry ?: return failure("RULE_NOT_MATCHED", "未找到 PDD 商品规格入口") val beforeClick = screenSignature(current) - val clickResult = driver.clickFresh(target) + val clickResult = clickSpecTarget(target, AgentDiagnosticStage.SPEC_ENTRY_CLICK) + if (clickResult == FreshActionResult.BLOCKED) return sizeAdviceFailure() trace( "spec-entry source=${entrySource ?: "unknown"} label=${target.label.take(40)} " + "class=${target.className} bounds=${target.bounds} result=$clickResult", @@ -1131,7 +1133,8 @@ class PddProductDetailCollector( opened = screen break } - when (driver.clickFresh(recoveryEntry)) { + when (clickSpecTarget(recoveryEntry, AgentDiagnosticStage.QUICK_CONFIRMATION_CLICK)) { + FreshActionResult.BLOCKED -> return sizeAdviceFailure() FreshActionResult.SUCCESS -> { quickConfirmationRecovered = true pause(100) @@ -1431,9 +1434,10 @@ class PddProductDetailCollector( } ?: return null attempted += value.text val beforeClick = screen - val clickResult = driver.clickFresh(value.node) + val clickResult = clickSpecTarget(value.node, AgentDiagnosticStage.COLOR_CLICK) trace("color-click color=${traceLabel(value.text)} result=$clickResult") when (clickResult) { + FreshActionResult.BLOCKED -> return sizeAdviceFailure() FreshActionResult.AMBIGUOUS -> return failure("RULE_AMBIGUOUS", "颜色“${value.text}”匹配到多个控件") FreshActionResult.NOT_FOUND, FreshActionResult.FAILED -> { missing += "selection:${value.text}" @@ -1538,6 +1542,14 @@ class PddProductDetailCollector( return null } + private fun sizeAdviceFailure() = failure("SIZE_ADVICE_CLICK_BLOCKED", "已阻止点击尺码建议入口") + + private fun clickSpecTarget(target: SnapshotNode, stage: AgentDiagnosticStage): FreshActionResult { + val outcome = driver.clickFreshDetailed(target) + if (taskId > 0) runCatching { diagnostic(specClickDiagnostic(taskId, stage, target, outcome)) } + return outcome.result + } + private fun recordColorImage(reason: AgentDiagnosticReason) { if (taskId <= 0) return diagnostic(AgentDiagnosticEvent(taskId = taskId, stage = AgentDiagnosticStage.COLOR_IMAGE, reason = reason, attempt = 1)) diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt index a15dd6e..4fe0b51 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt @@ -1,5 +1,7 @@ package cn.ilapage.goauto.agent.automation +import cn.ilapage.goauto.agent.persistence.AgentDiagnosticStage + import java.net.URI import java.net.URLDecoder @@ -106,6 +108,7 @@ 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 probeClickDiagnostic: (AgentDiagnosticStage, SnapshotNode, FreshClickOutcome) -> Unit = { _, _, _ -> }, ) { private var purchasePanelContext: PurchasePanelContext? = null @@ -393,6 +396,10 @@ class PurchaseRehearsalExecutor( } val beforeSignature = specActionSignature(screen) val click = driver.clickFreshDetailed(requireNotNull(target)) + if (input.phase == "spec_probe") runCatching { + probeClickDiagnostic(AgentDiagnosticStage.SPEC_ENTRY_CLICK, requireNotNull(target), click) + } + if (click.result == FreshActionResult.BLOCKED) return sizeAdviceFailure() when (click.result) { // The parser already narrowed to a single semantic candidate; the // ambiguity here comes from the live tree matching that target more @@ -414,7 +421,14 @@ class PurchaseRehearsalExecutor( ) } - when (driver.tapSpecFresh(requireNotNull(target))) { + val gestureResult = driver.tapSpecFresh(requireNotNull(target)) + if (input.phase == "spec_probe") runCatching { + probeClickDiagnostic(AgentDiagnosticStage.SPEC_ENTRY_GESTURE, requireNotNull(target), FreshClickOutcome( + gestureResult, if (gestureResult == FreshActionResult.BLOCKED) FreshClickReason.SIZE_ADVICE_BLOCKED else FreshClickReason.UNKNOWN, + )) + } + when (gestureResult) { + FreshActionResult.BLOCKED -> return sizeAdviceFailure() FreshActionResult.AMBIGUOUS -> return failure( SPEC_ENTRY_TARGET_AMBIGUOUS, "规格入口手势目标不唯一 [${specEntryEvidence(screen, 1, entryReadyWaitPolls)}]", @@ -605,6 +619,7 @@ class PurchaseRehearsalExecutor( val lookup = locateExactSpec(input, dimension, target) lookup.failure?.let { return it } val outcome = driver.clickFreshDetailed(requireNotNull(lookup.node)) + if (outcome.result == FreshActionResult.BLOCKED) return sizeAdviceFailure() if (outcome.result == FreshActionResult.AMBIGUOUS) { return failure(SPEC_TARGET_AMBIGUOUS, "精确规格匹配到多个控件") } @@ -618,6 +633,7 @@ class PurchaseRehearsalExecutor( val refreshedLookup = locateExactSpec(input, dimension, target) refreshedLookup.failure?.let { return it } val gesture = driver.tapSpecFresh(requireNotNull(refreshedLookup.node)) + if (gesture == FreshActionResult.BLOCKED) return sizeAdviceFailure() if (gesture == FreshActionResult.AMBIGUOUS) { return failure(SPEC_TARGET_AMBIGUOUS, "精确规格手势目标不唯一") } @@ -1165,6 +1181,8 @@ class PurchaseRehearsalExecutor( private fun applyPostAction(input: PurchaseExecutionInput, action: PurchaseAction): PurchaseExecutionOutcome? { if (action.waitAfterMs > 0) pause(action.waitAfterMs) + // The collector owns probe scrolling. Preserve settling time and every purchase-phase action. + if (input.phase == "spec_probe" && action.type == PurchaseActionType.OPEN_SPEC_PANEL) return null action.swipeAfter?.let { swipe -> val reveal = action.type == PurchaseActionType.OPEN_SPEC_PANEL val initialScreen = if (reveal) currentScreen(input) else null @@ -1207,6 +1225,8 @@ class PurchaseRehearsalExecutor( return PurchaseExecutionOutcome("spec_probe_completed", message = "商品规格已回传,等待服务端匹配", probedSpecs = result) } + private fun sizeAdviceFailure() = failure("SIZE_ADVICE_CLICK_BLOCKED", "已阻止点击尺码建议入口") + private fun normalizedTarget(dimension: String, raw: String): String? { val trimmed = raw.trim() if (dimension != "size") return trimmed.takeIf(String::isNotBlank) diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/SizeAdviceClickPolicy.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/SizeAdviceClickPolicy.kt new file mode 100644 index 0000000..d4a2237 --- /dev/null +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/SizeAdviceClickPolicy.kt @@ -0,0 +1,42 @@ +package cn.ilapage.goauto.agent.automation + +/** Only inspects the actual click receiver, never the whole page or unrelated siblings. */ +internal object SizeAdviceClickPolicy { + private val phrases = listOf( + "查看尺码建议", "查看尺碼建議", "查看尺寸建议", "查看尺寸建議", + "尺码助手", "尺碼助手", "尺寸助手", "智能选码", "智能選碼", "选码建议", "選碼建議", + ) + + fun containsAdvice(label: String): Boolean { + val compact = label.filterNot(Char::isWhitespace) + return phrases.any(compact::contains) + } + + fun blocked( + target: N, + clickable: (N) -> Boolean, + parent: (N) -> N?, + children: (N) -> List, + labels: (N) -> List, + ): Boolean { + var receiver = target + while (!clickable(receiver)) { + val ancestor = parent(receiver) + if (ancestor == null) { + // A gesture can still reach a non-clickable leaf. Inspect the original + // target, not the unrelated root subtree, if no receiver is exposed. + receiver = target + break + } + receiver = ancestor + } + val pending = ArrayDeque() + pending.add(receiver) + while (pending.isNotEmpty()) { + val node = pending.removeFirst() + if (labels(node).any(::containsAdvice)) return true + pending.addAll(children(node)) + } + return false + } +} diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/SpecClickDiagnostic.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/SpecClickDiagnostic.kt new file mode 100644 index 0000000..76e12de --- /dev/null +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/SpecClickDiagnostic.kt @@ -0,0 +1,32 @@ +package cn.ilapage.goauto.agent.automation + +import cn.ilapage.goauto.agent.persistence.AgentDiagnosticEvent +import cn.ilapage.goauto.agent.persistence.AgentDiagnosticReason +import cn.ilapage.goauto.agent.persistence.AgentDiagnosticStage + +/** No labels, bounds, selectors, or raw trace strings cross this boundary. */ +internal fun specClickDiagnostic( + taskId: Long, stage: AgentDiagnosticStage, target: SnapshotNode, outcome: FreshClickOutcome, +) = AgentDiagnosticEvent( + taskId = taskId, + stage = stage, + reason = when (outcome.reason) { + FreshClickReason.SIZE_ADVICE_BLOCKED -> AgentDiagnosticReason.SIZE_ADVICE_CLICK_BLOCKED + FreshClickReason.ROOT_UNAVAILABLE -> AgentDiagnosticReason.ROOT_UNAVAILABLE + FreshClickReason.NO_CLICKABLE_ANCESTOR -> AgentDiagnosticReason.NO_CLICKABLE_ANCESTOR + else -> when (outcome.result) { + FreshActionResult.SUCCESS -> AgentDiagnosticReason.CLICK_SUCCESS + FreshActionResult.BLOCKED -> AgentDiagnosticReason.SIZE_ADVICE_CLICK_BLOCKED + FreshActionResult.AMBIGUOUS -> AgentDiagnosticReason.TARGET_AMBIGUOUS + FreshActionResult.NOT_FOUND -> AgentDiagnosticReason.TARGET_NOT_FOUND + FreshActionResult.FAILED -> AgentDiagnosticReason.ACTION_CLICK_FALSE + } + }, + candidateCount = outcome.candidateCount, + targetClassName = target.className?.takeIf { it in setOf( + "android.widget.TextView", "android.widget.Button", "android.widget.LinearLayout", + "android.widget.FrameLayout", "android.view.View", "android.view.ViewGroup", + ) }, + targetClickable = target.clickable, + clickableAncestorDepth = outcome.clickableAncestorDepth, +) diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticSchema.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticSchema.kt index fab4c66..3105726 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticSchema.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticSchema.kt @@ -1,7 +1,12 @@ package cn.ilapage.goauto.agent.persistence internal object AgentDiagnosticSchema { - const val VERSION = 2 + const val VERSION = 3 + + val purchaseDiagnosticColumns = linkedMapOf( + "task_type" to "TEXT", "task_attempt_id" to "TEXT", "device_id" to "INTEGER", + "phase" to "TEXT", "rule_snapshot_hash" to "TEXT", + ) val colorDiagnosticColumns = linkedMapOf( "color_row_count" to "INTEGER", @@ -42,6 +47,11 @@ internal object AgentDiagnosticSchema { initial_selected_size_count INTEGER, selected_summary_present INTEGER, horizontal_swipe_count INTEGER, + task_type TEXT, + task_attempt_id TEXT, + device_id INTEGER, + phase TEXT, + rule_snapshot_hash TEXT, agent_version TEXT NOT NULL, created_at INTEGER NOT NULL )""".trimIndent() @@ -52,4 +62,10 @@ internal object AgentDiagnosticSchema { if (name in existingColumns) null else "ALTER TABLE agent_diagnostic ADD COLUMN $name $definition" } } + + fun migrationStatements(oldVersion: Int, newVersion: Int, existingColumns: Set): List = + v2MigrationStatements(oldVersion, newVersion, existingColumns) + + if (oldVersion < 3 && newVersion >= 3) purchaseDiagnosticColumns.mapNotNull { (name, definition) -> + if (name in existingColumns) null else "ALTER TABLE agent_diagnostic ADD COLUMN $name $definition" + } else emptyList() } diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticStore.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticStore.kt index 7570f93..f19d23d 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticStore.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticStore.kt @@ -7,6 +7,10 @@ import android.database.sqlite.SQLiteOpenHelper import cn.ilapage.goauto.agent.BuildConfig enum class AgentDiagnosticStage { + SPEC_ENTRY_CLICK, + SPEC_ENTRY_GESTURE, + QUICK_CONFIRMATION_CLICK, + COLOR_CLICK, DETAIL_ENTRY, SPEC_PANEL_ENTRY, COLOR_DISCOVERY, @@ -20,6 +24,7 @@ enum class AgentDiagnosticStage { } enum class AgentDiagnosticReason { + SIZE_ADVICE_CLICK_BLOCKED, DETAIL_ENTRY_MATCHED, SPEC_PANEL_OPENED, SPEC_PANEL_RECOVERED, @@ -104,6 +109,11 @@ data class AgentDiagnosticEvent( val selectedSummaryPresent: Boolean? = null, val horizontalSwipeCount: Int? = null, val createdAt: Long = System.currentTimeMillis(), + val taskType: String = "collection", + val taskAttemptId: String? = null, + val deviceId: Long? = null, + val phase: String? = null, + val ruleSnapshotHash: String? = null, ) class SafeAgentDiagnosticRecorder( @@ -130,7 +140,7 @@ class AgentDiagnosticStore(context: Context) : SQLiteOpenHelper(context, DATABAS override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { val existing = columnNames(db) - AgentDiagnosticSchema.v2MigrationStatements(oldVersion, newVersion, existing).forEach(db::execSQL) + AgentDiagnosticSchema.migrationStatements(oldVersion, newVersion, existing).forEach(db::execSQL) } private fun columnNames(db: SQLiteDatabase): Set = @@ -144,11 +154,22 @@ class AgentDiagnosticStore(context: Context) : SQLiteOpenHelper(context, DATABAS @Synchronized fun record(event: AgentDiagnosticEvent) { require(event.taskId > 0) + require(event.taskType in setOf("collection", "purchase")) + if (event.taskType == "purchase") { + require(event.taskAttemptId?.matches(Regex("[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}")) == true) + require((event.deviceId ?: 0) > 0 && event.phase in setOf("spec_probe", "purchase")) + require(event.ruleSnapshotHash?.matches(Regex("[0-9a-fA-F]{64}")) == true) + } val db = writableDatabase db.beginTransaction() try { val values = ContentValues().apply { put("task_id", event.taskId) + put("task_type", event.taskType) + put("task_attempt_id", event.taskAttemptId) + event.deviceId?.let { put("device_id", it) } + put("phase", event.phase) + put("rule_snapshot_hash", event.ruleSnapshotHash) put("stage", event.stage.name) put("reason", event.reason.name) put("attempt", event.attempt.coerceAtLeast(0)) diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt index f8fba2b..5febd8d 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt @@ -22,6 +22,7 @@ import cn.ilapage.goauto.agent.ClipboardRelayActivity import cn.ilapage.goauto.agent.MainActivity import cn.ilapage.goauto.agent.R import cn.ilapage.goauto.agent.automation.CollectionAssembler +import cn.ilapage.goauto.agent.automation.specClickDiagnostic import cn.ilapage.goauto.agent.automation.AgentCapabilities import cn.ilapage.goauto.agent.automation.CurrentPageIdentityRunner import cn.ilapage.goauto.agent.automation.CurrentPageLaunchResult @@ -589,6 +590,13 @@ class AgentForegroundService : Service() { val snapshotHash = task.ruleSnapshotHash.takeIf { snapshotHashValid } ?: "0".repeat(64) purchaseStore.recordRunning(task.taskId, task.taskAttemptId, snapshotHash) val taskLabel = if (task.executionMode == "live") "正式采购" else "采购演练" + val diagnosticDeviceId = runCatching { identityStore.credentials()?.deviceId }.getOrNull() + val probeDiagnostic: (AgentDiagnosticEvent) -> Unit = { event -> + diagnosticRecorder.record(event.copy( + taskId = task.taskId, taskType = "purchase", taskAttemptId = task.taskAttemptId, + deviceId = diagnosticDeviceId, phase = task.phase, ruleSnapshotHash = task.ruleSnapshotHash, + )) + } stateStore.update("BUSY", "正在执行${taskLabel}任务 #${task.taskId}", tokenStored = true) updateNotification("$taskLabel #${task.taskId}") @@ -612,7 +620,10 @@ class AgentForegroundService : Service() { PurchaseRehearsalExecutor( driver = accessibility, openLink = { PddLinkLauncher(this).open(it, preferDirect = true) }, - probeSpecs = { collectPurchaseProbe(accessibility, task, parsedRule) }, + probeSpecs = { collectPurchaseProbe(accessibility, task, parsedRule, probeDiagnostic) }, + probeClickDiagnostic = { stage, target, outcome -> + probeDiagnostic(specClickDiagnostic(task.taskId, stage, target, outcome)) + }, stepChanged = { step -> lastStep.set(step) purchaseStore.updateStep(task.taskId, task.taskAttemptId, step) @@ -683,7 +694,10 @@ class AgentForegroundService : Service() { } } - private fun collectPurchaseProbe(accessibility: GoAutoAccessibilityService, task: PurchaseAgentTask, purchaseRule: PurchaseRule): String? { + private fun collectPurchaseProbe( + accessibility: GoAutoAccessibilityService, task: PurchaseAgentTask, purchaseRule: PurchaseRule, + diagnostic: (AgentDiagnosticEvent) -> Unit, + ): String? { val snapshot = accessibility.capture() val activity = snapshot.activityName ?: return null val collector = PurchaseRehearsalExecutor.collectorForProbe(purchaseRule) @@ -694,7 +708,8 @@ class AgentForegroundService : Service() { pageEvidence = PageEvidence("com.xunmeng.pinduoduo", activity, NodeSelector()), collector = collector, ) - val result = PddProductDetailCollector(accessibility).collect(task.pddGoodsId, rule) + val result = PddProductDetailCollector(accessibility, taskId = task.taskId, diagnostic = diagnostic) + .collect(task.pddGoodsId, rule) // #334: a collector failure (e.g. SPEC_PANEL_TOP_COLLAPSED) must not // collapse into a generic "spec probe failed" outcome. Carry the // real code/message through the opaque probeSpecs callback so diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/PddProductDetailCollectorTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/PddProductDetailCollectorTest.kt index e91e37b..3e39802 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/PddProductDetailCollectorTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/PddProductDetailCollectorTest.kt @@ -27,6 +27,47 @@ import org.junit.Assert.assertTrue import org.junit.Test class PddProductDetailCollectorTest { + @Test + fun `blocked color records ancestor evidence and never clicks a different color`() { + val base = FakeCollectorDriver(colors = listOf("红色", "蓝色")) + var attempts = 0 + val driver = object : PddCollectorDriver by base { + override fun clickFreshDetailed(target: SnapshotNode): cn.ilapage.goauto.agent.automation.FreshClickOutcome { + attempts++ + return cn.ilapage.goauto.agent.automation.FreshClickOutcome( + FreshActionResult.BLOCKED, cn.ilapage.goauto.agent.automation.FreshClickReason.SIZE_ADVICE_BLOCKED, 1, 2, + ) + } + } + var clock = 0L + val events = mutableListOf() + val result = PddProductDetailCollector(driver, { clock }, { clock += it }, taskId = 1, diagnostic = events::add) + .collect(GOODS_ID, rule()) + assertFalse(result.successful) + assertEquals("SIZE_ADVICE_CLICK_BLOCKED", result.code) + assertEquals(1, attempts) + assertEquals(0, base.clickCount) + val event = events.single { it.stage == AgentDiagnosticStage.COLOR_CLICK } + assertEquals(AgentDiagnosticReason.SIZE_ADVICE_CLICK_BLOCKED, event.reason) + assertEquals(2, event.clickableAncestorDepth) + } + + @Test + fun `probe collector records clicks and discovers offscreen weight labelled sizes`() { + val driver = FakeCollectorDriver( + colors = listOf("黑色"), + sizePages = listOf(listOf("M 建议80-100斤"), listOf("4XL 建议160-180斤")), + ) + var clock = 0L + val events = mutableListOf() + val result = PddProductDetailCollector(driver, { clock }, { clock += it }, taskId = 1, diagnostic = events::add) + .collect(GOODS_ID, rule()) + assertTrue(result.successful) + assertEquals(listOf("M 建议80-100斤", "4XL 建议160-180斤"), result.payload!!.dimensions.first { it.key == "size" }.values) + assertTrue(driver.swipes.any { it.first == SwipeDirection.UP }) + assertTrue(events.any { it.stage == AgentDiagnosticStage.COLOR_CLICK && it.reason == AgentDiagnosticReason.CLICK_SUCCESS }) + } + private fun prefixlessPanel(): UiSnapshot = UiSnapshot(PDD_PACKAGE, ACTIVITY, listOf( node("root", "", 0, 0, 1080, 2376), node("close", "关闭", 970, 270, 1050, 350, clickable = true), diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt index 683b444..43de665 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt @@ -24,6 +24,62 @@ import org.junit.Assert.assertTrue import org.junit.Test class PurchaseRehearsalExecutorTest { + @Test + fun `probe skips post open swipes but preserves wait and records click`() { + val driver = FakePurchaseDriver() + val pauses = mutableListOf() + val clicks = mutableListOf() + val configured = rule().replace("\"type\":\"openSpecPanel\"", "\"type\":\"openSpecPanel\",\"waitAfterMs\":1234") + val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, + pause = pauses::add, probeClickDiagnostic = { _, _, click -> clicks += click }, + ).execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(configured), PurchaseAgentCapabilities.supported) + assertEquals("spec_probe_completed", outcome.resultType) + assertEquals(0, driver.swipeCount) + assertEquals(1, pauses.count { it == 1234L }) + assertTrue(clicks.any { it.result == FreshActionResult.SUCCESS }) + } + + @Test + fun `blocked entry stops without gesture or probe`() { + val driver = FakePurchaseDriver(forcedEntryClickReason = FreshClickReason.SIZE_ADVICE_BLOCKED) + var probes = 0 + val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { probes++; "{}" }, pause = {}) + .execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + assertEquals("SIZE_ADVICE_CLICK_BLOCKED", outcome.errorCode) + assertEquals(0, driver.specTapCount) + assertEquals(0, probes) + } + + @Test + fun `blocked size stops without gesture retry`() { + val driver = FakePurchaseDriver(forcedSizeClickReason = FreshClickReason.SIZE_ADVICE_BLOCKED) + val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {}) + .execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + assertEquals("SIZE_ADVICE_CLICK_BLOCKED", outcome.errorCode) + assertEquals(0, driver.specTapCount) + assertEquals(1, driver.sizeClickCount) + } + + @Test + fun `blocked gesture stops without another click`() { + val driver = FakePurchaseDriver(forcedEntryClickReason = FreshClickReason.ACTION_CLICK_FALSE, specTapResult = FreshActionResult.BLOCKED) + var probes = 0 + val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { probes++; "{}" }, pause = {}) + .execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + assertEquals("SIZE_ADVICE_CLICK_BLOCKED", outcome.errorCode) + assertEquals(1, driver.specTapCount) + assertEquals(0, probes) + } + + @Test + fun `purchase still fails required post open swipe`() { + val driver = FakePurchaseDriver(purchaseSwipeSucceeds = false) + val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {}) + .execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + assertEquals("RULE_ACTION_FAILED", outcome.errorCode) + assertEquals(1, driver.swipeCount) + } + @Test fun `color selection then single size heading completes without selecting color again`() { val driver = FakePurchaseDriver(prefixlessSingleHeadingAfterColor = true) @@ -778,7 +834,7 @@ class PurchaseRehearsalExecutorTest { } @Test - fun `open spec reveal skips missing scroll target regardless of panel classification`() { + fun `probe skips reveal regardless of panel classification`() { val driver = FakePurchaseDriver( panelOverride = SpecPanelFixtures.liveShapedSheet(), purchaseSwipeSucceeds = false, @@ -793,11 +849,11 @@ class PurchaseRehearsalExecutorTest { assertEquals(outcome.message, "spec_probe_completed", outcome.resultType) assertEquals(1, probes) assertEquals(0, driver.swipeCount) - assertTrue(diagnostics.any { it.contains("panel=REQUIRED_EVIDENCE;scrollables=0;reveal=true") }) + assertFalse(diagnostics.any { it.startsWith("ruleSwipe;") }) } @Test - fun `open spec reveal with a scroll target still fails on gesture failure`() { + fun `probe does not invoke failing reveal even with a scroll target`() { val driver = FakePurchaseDriver(purchaseSwipeSucceeds = false) var probes = 0 val diagnostics = mutableListOf() @@ -806,11 +862,10 @@ class PurchaseRehearsalExecutorTest { pause = {}, panelDiagnostic = diagnostics::add, ).execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) - assertEquals("RULE_ACTION_FAILED", outcome.errorCode) - assertEquals(0, probes) - assertEquals(1, driver.swipeCount) - assertTrue(outcome.message.contains("panel=NORMAL_SCROLLABLE;scrollables=1;reveal=true;reason=failed")) - assertTrue(diagnostics.any { it.startsWith("ruleSwipe;outcome=failed;") }) + assertEquals("spec_probe_completed", outcome.resultType) + assertEquals(1, probes) + assertEquals(0, driver.swipeCount) + assertFalse(diagnostics.any { it.startsWith("ruleSwipe;") }) assertFalse(outcome.message.contains("黑色")) assertFalse(outcome.message.contains(input().goodsId)) } @@ -822,7 +877,7 @@ class PurchaseRehearsalExecutorTest { )) var probes = 0 val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { probes++; "{}" }, pause = {}) - .execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + .execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) assertEquals("RULE_ACTION_FAILED", outcome.errorCode) assertEquals(0, probes) @@ -837,9 +892,9 @@ class PurchaseRehearsalExecutorTest { )) val pauses = mutableListOf() val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add) - .execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + .execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) - assertEquals("spec_probe_completed", outcome.resultType) + assertEquals("rehearsal_completed", outcome.resultType) assertEquals(1, driver.swipeCount) assertEquals(1, pauses.count { it == 1000L }) } @@ -849,9 +904,9 @@ class PurchaseRehearsalExecutorTest { val driver = FakePurchaseDriver() val pauses = mutableListOf() val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add) - .execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + .execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) - assertEquals("spec_probe_completed", outcome.resultType) + assertEquals("rehearsal_completed", outcome.resultType) assertEquals(2, driver.swipeCount) assertEquals(1, pauses.count { it == 1000L }) } @@ -1597,6 +1652,7 @@ class PurchaseRehearsalExecutorTest { FreshClickReason.TARGET_AMBIGUOUS -> FreshActionResult.AMBIGUOUS FreshClickReason.NO_CLICKABLE_ANCESTOR, FreshClickReason.ACTION_CLICK_FALSE, FreshClickReason.UNKNOWN -> FreshActionResult.FAILED FreshClickReason.SUCCESS -> FreshActionResult.SUCCESS + FreshClickReason.SIZE_ADVICE_BLOCKED -> FreshActionResult.BLOCKED } return FreshClickOutcome(result, forcedEntryClickReason) } @@ -1607,6 +1663,7 @@ class PurchaseRehearsalExecutorTest { FreshClickReason.NO_CLICKABLE_ANCESTOR, FreshClickReason.ACTION_CLICK_FALSE, FreshClickReason.UNKNOWN -> FreshActionResult.FAILED FreshClickReason.TARGET_AMBIGUOUS -> FreshActionResult.AMBIGUOUS FreshClickReason.SUCCESS -> FreshActionResult.SUCCESS + FreshClickReason.SIZE_ADVICE_BLOCKED -> FreshActionResult.BLOCKED } return FreshClickOutcome(result, forcedSizeClickReason) } @@ -1616,6 +1673,7 @@ class PurchaseRehearsalExecutorTest { FreshActionResult.NOT_FOUND -> FreshClickReason.TARGET_NOT_FOUND FreshActionResult.AMBIGUOUS -> FreshClickReason.TARGET_AMBIGUOUS FreshActionResult.FAILED -> FreshClickReason.ACTION_CLICK_FALSE + FreshActionResult.BLOCKED -> FreshClickReason.SIZE_ADVICE_BLOCKED } return FreshClickOutcome(result, reason) } diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/automation/SizeAdviceClickPolicyTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/automation/SizeAdviceClickPolicyTest.kt new file mode 100644 index 0000000..41edcaf --- /dev/null +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/automation/SizeAdviceClickPolicyTest.kt @@ -0,0 +1,46 @@ +package cn.ilapage.goauto.agent.automation + +import org.junit.Assert.* +import org.junit.Test + +class SizeAdviceClickPolicyTest { + private class Node(val label: String, val clickable: Boolean, val description: String = "") { + var parent: Node? = null + val children = mutableListOf() + fun add(node: Node): Node { children += node; node.parent = this; return node } + } + private fun blocked(node: Node) = SizeAdviceClickPolicy.blocked( + node, { it.clickable }, { it.parent }, { it.children }, { listOf(it.label, it.description) }, + ) + + @Test fun sameRowAdviceBlocksDirectReceiverAndLeafGesture() { + val row = Node("", true) + val title = row.add(Node("尺码", false)) + row.add(Node("查看尺码建议", false)) + assertTrue(blocked(row)) + assertTrue(blocked(title)) + } + + @Test fun adviceElsewhereDoesNotBlockNormalOptionOrWeightDescription() { + val panel = Node("", true) + panel.add(Node("查看尺码建议", true)) + val option = panel.add(Node("M 建议80-100斤", true)) + val leaf = option.add(Node("M 建议80-100斤", false)) + assertFalse(blocked(option)) + assertFalse(blocked(leaf)) + } + + @Test fun traditionalAndContentDescriptionAreProtectedButPlainAdviceIsNot() { + assertTrue(blocked(Node("", true, "查看尺碼建議"))) + assertTrue(blocked(Node("查 看 尺 寸 建 议", true))) + assertTrue(blocked(Node("智能選碼", true))) + assertFalse(blocked(Node("XL 建議55.5-60.0公斤穿", true))) + assertFalse(blocked(Node("尺码", true))) + } + + @Test fun nonClickableAdviceLeafWithoutClickableAncestorCannotBypassGestureGuard() { + val root = Node("", false) + assertTrue(blocked(root.add(Node("查看尺码建议", false)))) + assertFalse(blocked(root.add(Node("M 建议80-100斤", false)))) + } +} diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/automation/SpecClickDiagnosticTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/automation/SpecClickDiagnosticTest.kt new file mode 100644 index 0000000..267424f --- /dev/null +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/automation/SpecClickDiagnosticTest.kt @@ -0,0 +1,21 @@ +package cn.ilapage.goauto.agent.automation + +import cn.ilapage.goauto.agent.persistence.AgentDiagnosticReason +import cn.ilapage.goauto.agent.persistence.AgentDiagnosticStage +import org.junit.Assert.* +import org.junit.Test + +class SpecClickDiagnosticTest { + @Test fun recordsOnlyStructuralFieldsIncludingBlockedAncestor() { + val node = SnapshotNode("private-path", null, "private-label", "private-description", "private-id", + "private-class", NodeBounds(1,2,3,4), false, false, false, false, true, true) + val event = specClickDiagnostic(12, AgentDiagnosticStage.COLOR_CLICK, node, + FreshClickOutcome(FreshActionResult.BLOCKED, FreshClickReason.SIZE_ADVICE_BLOCKED, 1, 2)) + assertEquals(AgentDiagnosticReason.SIZE_ADVICE_CLICK_BLOCKED, event.reason) + assertEquals(1, event.candidateCount) + assertEquals(2, event.clickableAncestorDepth) + assertNull(event.targetClassName) + assertFalse(event.toString().contains("private")) + assertEquals("collection", event.taskType) + } +} diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticStoreMigrationTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticStoreMigrationTest.kt index 710c9fd..1115c3e 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticStoreMigrationTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticStoreMigrationTest.kt @@ -7,6 +7,41 @@ import org.junit.Assert.assertTrue import org.junit.Test class AgentDiagnosticStoreMigrationTest { + @Test + fun upgradeFromV1OrV2PreservesOldRowsAndSeparatesPurchaseAttempts() { + for (oldVersion in listOf(1, 2)) withDatabase { db -> + db.createStatement().use { it.execute(CREATE_V1_TABLE_SQL) } + if (oldVersion == 2) migrateV1ToV2(db) + db.createStatement().use { it.execute("INSERT INTO agent_diagnostic " + + "(task_id,stage,reason,attempt,elapsed_ms,agent_version,created_at) " + + "VALUES (1,'DETAIL_ENTRY','DETAIL_ENTRY_MATCHED',2,10,'old',1000)") } + AgentDiagnosticSchema.migrationStatements(oldVersion, 3, columnNames(db)).forEach { sql -> + db.createStatement().use { it.execute(sql) } + } + assertTrue(columnNames(db).containsAll(AgentDiagnosticSchema.purchaseDiagnosticColumns.keys)) + assertTrue(columnNames(db).containsAll(AgentDiagnosticSchema.colorDiagnosticColumns.keys)) + assertTrue(AgentDiagnosticSchema.migrationStatements(oldVersion, 3, columnNames(db)).isEmpty()) + db.createStatement().use { statement -> + statement.executeQuery("SELECT task_type,task_attempt_id,attempt FROM agent_diagnostic").use { + assertTrue(it.next()); assertEquals(null, it.getString(1)); assertEquals(null, it.getString(2)); assertEquals(2, it.getInt(3)) + } + } + for (attemptId in listOf("11111111-1111-4111-8111-111111111111", "22222222-2222-4222-8222-222222222222")) { + db.prepareStatement("INSERT INTO agent_diagnostic " + + "(task_id,stage,reason,attempt,elapsed_ms,agent_version,created_at,task_type,task_attempt_id,device_id,phase,rule_snapshot_hash) " + + "VALUES (1,'COLOR_CLICK','CLICK_SUCCESS',3,10,'new',2000,'purchase',?,7,'spec_probe',?)").use { + it.setString(1, attemptId); it.setString(2, "a".repeat(64)); assertEquals(1, it.executeUpdate()) + } + } + assertEquals(3, rowCount(db)) + db.createStatement().use { statement -> + statement.executeQuery("SELECT COUNT(DISTINCT task_attempt_id) FROM agent_diagnostic WHERE task_type='purchase' AND task_id=1").use { + assertTrue(it.next()); assertEquals(2, it.getInt(1)) + } + } + } + } + @Test fun freshDatabaseCreatesV2ColumnsAndAcceptsSingleColorDiagnosticRecord() = withDatabase { db -> db.createStatement().use { it.execute(AgentDiagnosticSchema.createTableSql) } From 75ab25658d574b3aacaed1df2caf24367207a543 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Tue, 6 Oct 2026 17:19:19 +0800 Subject: [PATCH 2/7] docs: document spec probe diagnostics and safe verification (#361) --- docs/02-architecture-and-code-map.md | 14 ++++++++++++-- docs/03-business-rules-and-glossary.md | 13 +++++++++++-- docs/06-troubleshooting.md | 24 +++++++++++++----------- 3 files changed, 36 insertions(+), 15 deletions(-) diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index fba0b18..338dd8d 100644 --- a/docs/02-architecture-and-code-map.md +++ b/docs/02-architecture-and-code-map.md @@ -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: f73ab171684662d23f298d7e4927168b1a40b01a -synchronized_at: 2026-10-06T01:39:19Z +wiki_revision: 3165d7d419a46f41fa70a63286799deda9a8ce9f +synchronized_at: 2026-10-06T09:17:36Z @@ -617,3 +617,13 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态 - `sybimport.Handler.List/Detail` 复用产品模型返回 nullable 字段。列表不额外访问 SYB;不新增旧订单扫描、并发、定时任务或采购拦截。 - Web 只在 SYB 商品列表订单号后增加固定 72px 的“SYB状态”列;退货列改名“匹配退货”、min-width 200→160,保留图片/告警/操作并允许换行。使用现有标签与加载/失败态,其他列及 Android 不变。 - 迁移重复执行和三态接口/导入已在隔离 SQLite 测试,Server/Web 构建通过;尚未验证正式 MySQL 执行或真实同步后的页面。先授权迁移,再发布才能用于业务环境。 + +## Android 规格探测与诊断增补(#361) + +实现绑定 a49dc69,Agent 0.9.68 / 81,尚未合并 main。 + +- PurchaseRehearsalExecutor.applyPostAction 只在 spec_probe + OPEN_SPEC_PANEL 跳过 swipeAfter,保留等待和 purchase 行为。 +- SizeAdviceClickPolicy 对最终点击接收者的子树检查明确建议入口短语;GoAutoAccessibilityService 的 ACTION_CLICK 和两种中心手势入口使用该策略。FreshActionResult.BLOCKED / FreshClickReason.SIZE_ADVICE_BLOCKED 阻止规格流程继续兜底。 +- PddProductDetailCollector 的入口、快速确认恢复和颜色点击通过 clickFreshDetailed 获取结果;SpecClickDiagnostic 构造白名单结构事件。AgentForegroundService 给探测执行器与采集器接入既有 SafeAgentDiagnosticRecorder / 单线程队列,未接入原始 trace。 +- AgentDiagnosticSchema v3:agent_diagnostic 追加可空 task_type TEXT、task_attempt_id TEXT、device_id INTEGER、phase TEXT、rule_snapshot_hash TEXT。onUpgrade 支持 V1/V2 追加并检查已有列;保留旧行且新字段为 NULL。AgentDiagnosticStore 写采购记录时验证类型、UUID、正设备 ID、阶段和 64 位十六进制哈希。动作 attempt 与采购 attempt UUID 分离,全库 50 条/7 天保留边界不变。 +- 无 Server/Web/业务库或共享接口字段变化,无订单提交流程变化。新错误沿既有 errorCode 字符串回传;真实探测/采购验收仍待用户授权。 diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index 0a09368..e37fb14 100644 --- a/docs/03-business-rules-and-glossary.md +++ b/docs/03-business-rules-and-glossary.md @@ -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: f5077a19801882ebb4e94c5b0fe93f00fa6ebc7d -synchronized_at: 2026-10-06T03:27:21Z +wiki_revision: ec20bf476551c3dbc1d5aaa6301605bfe695e102 +synchronized_at: 2026-10-06T09:17:39Z @@ -801,3 +801,12 @@ Android 0.9.64 / versionCode 77,源码 `6550b9f`(分支实现,尚未安装 - RawSpecHalves 复用 Parse,ResolveKeys 复用两者,原始两侧角色、注释塌缩消歧与采购规格键继续共享同一判断,不另立角色推断规则。 - 更新规则本身不会改历史数据库。正常重同步沿用现有保护规则;需要定向重新解析时复用 ReparseBatch,force=false 默认跳过人工及 AI 确认行。该流程可能重算同商品其他未确认明细,执行前必须核验完整影响集合。 - ERPGo 规格同步及 Merge 仍只追加并保留映射,不自动清除旧反向档案值。清理须独立授权、指定对象并验证引用/映射,保留恢复依据;不自动扩大为全库数据清理,不修改采购任务快照。 + +## Android 规格探测点击保护(#361) + +实现绑定 a49dc69(工单分支,Agent 0.9.68 / 81,未合并 main);自动化验证通过,现场采购修复尚待验收。 + +- 仅 spec_probe 阶段的 OPEN_SPEC_PANEL 跳过规则 swipeAfter;waitAfterMs 和既有页面稳定等待保留,采集器继续自身的有限规格遍历。purchase 阶段原规则滑动不变。 +- PDD 点击按最终接收动作的控件或最近可点击父容器检查尺码建议类明确短语,包含简繁体;无障碍直接点击、祖先点击与中心手势共享判断。不可因面板其他位置存在建议入口而拒绝所有规格;“M 建议80-100斤”等合法选项不按“建议”泛化拦截。命中返回 SIZE_ADVICE_CLICK_BLOCKED,规格入口/规格选择不再手势兜底,颜色采集不改点其他候选。 +- 规格探测的结构化诊断复用本地诊断库,以 purchase 类型、task ID、服务端 attempt UUID、设备、phase 和规则快照哈希关联;不保存原始 trace/标签/树/截图。诊断失败不改变任务结果。 +- 真机验证不能依赖探测失败作为停止边界:live 任务探测匹配成功后可继续正式下单。必须明确演练覆盖 spec_probe 的方式,或另获正式创建订单授权;永久禁止付款。 diff --git a/docs/06-troubleshooting.md b/docs/06-troubleshooting.md index 6b2c8f0..6c143db 100644 --- a/docs/06-troubleshooting.md +++ b/docs/06-troubleshooting.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Troubleshooting wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Troubleshooting -wiki_revision: 40e77a656d1c21d72454ad722a4f71a0a4491241 -synchronized_at: 2026-10-05T07:18:16Z +wiki_revision: ac3a0b74704f398cec54149267089c87c8375a3e +synchronized_at: 2026-10-06T09:17:51Z # 故障排查 @@ -86,24 +86,26 @@ sqlite3 -readonly agent-diagnostics.db "SELECT task_id,reason,color_row_count,co 读取时记录设备、Agent 版本、任务号和规则快照;工单只回写查询得到的脱敏聚合数值。读取完成后删除本地导出副本。正式 APK 若不允许 `run-as`,停止排查并确认安全的只读诊断出口,不通过放宽应用安全配置或上传完整数据库绕过。 -## Android 采购规格入口本地诊断(#249) +## Android 采购规格入口本地诊断(#249 / #361) -实现绑定 `99faf5a`,Agent `0.9.67`(versionCode 80)。单元测试与 Debug 构建已通过,新增版本真机留存/读取尚待验证;本节不属于上文既有实测结论。 +#249 的 JSONL 采购诊断实现位于独立分支(历史绑定 99faf5a),未合并基线 main 64f0e49;不能假定运行该 main 的设备存在 files/purchase_diagnostics。main 已有 goauto_diagnostics.db / agent_diagnostic,本单复用它,不整体合并 #249。 -采购执行器启动后,在应用私有 `files/purchase_diagnostics/_.jsonl` 保存白名单结构化入口诊断。元数据包含 taskId、attemptId、deviceId、agentVersion、phase、ruleHash(当前任务规则快照 SHA-256)、timestamp 和 elapsedMs。现有任务接口只有 attemptId,没有 attemptNumber;用 attemptId 对照服务端历史,不推算重试序号。 +#361 实现绑定 a49dc69,Agent 0.9.68(versionCode 81),当前为工单分支,未合并 main 或发布 Admin。已完成 Debug 构建和单元测试,按用户授权覆盖安装一台设备;未执行真机探测或采购重试,不能视为现场修复验收。 -证据仅包含入口来源枚举、候选数、点击结果/原因枚举、等待轮数、面板结构布尔值/计数,以及受控恢复是否执行及固定拒绝原因。没有商品标题、规格原文、地址、手机号、Cookie、Token、链接、原始控件树或截图。entryWaitMillis 是该等待循环累计的计划等待量;elapsedMs 才是自本次采购执行器诊断起点计量的实际耗时,timestamp 是后台写入时间。 +规格探测的入口点击/手势、快速确认恢复、颜色点击,以及原有面板/颜色/尺码发现记录接入既有异步诊断队列。来源 stage 为 SPEC_ENTRY_CLICK / SPEC_ENTRY_GESTURE / QUICK_CONFIRMATION_CLICK / COLOR_CLICK;reason 为点击结果或 SIZE_ADVICE_CLICK_BLOCKED 等固定枚举。只存候选数、可点击布尔、白名单类名和可得的祖先层级,不保存原始 trace、标签、地址、手机号、控件树或截图。 -复用现有单线程诊断队列异步写入,写入/排队失败不改变采购结果;不新增点击、滑动、等待或采购门禁。最多保留最近 5 个 attempt 文件、每文件最后 128 条事件,超过 7 天的文件在服务启动或下次写入时清理。异常断电或存储故障仍可能丢失诊断,不能将日志缺失当成动作未执行;不上传 Admin,不增加服务端接口。 +本地 SQLite schema v3 仅追加可空 task_type、task_attempt_id、device_id、phase、rule_snapshot_hash。采购记录绑定 purchase_task.id、服务端 attempt UUID、设备、阶段与规则哈希;现有 attempt 仍是动作内次数,不能当作采购 attempt ID。新采集记录标识 collection;旧记录新增字段为 NULL,不猜测或回填归属。V1/V2 自动升级保留旧数据;旧版 SQLiteOpenHelper 不保证能降级打开 v3,回退前需单独评估,不卸载清数据。 -仅对允许 run-as 的 Debug APK,在设备已连接且选定准确序列号后只读提取: +保留边界沿用全库最近 50 条及 7 天(写入时清理),日志可能因容量、断电或存储异常缺失;缺日志不能证明未点击。写入/排队失败不改变执行结果,不新增上传接口。 + +读取须确认准确设备、Debug APK、task ID 与 attempt UUID。设备具备 sqlite3 且允许 run-as 时可执行下面的只读查询;如缺 sqlite3/run-as,则停止并另行确认诊断读取路径,不放宽权限、不导出业务库。命令尚未在本单设备验证: ```powershell -adb -s shell run-as cn.ilapage.goauto.agent ls files/purchase_diagnostics -adb -s exec-out run-as cn.ilapage.goauto.agent cat files/purchase_diagnostics/_.jsonl +adb -s shell run-as cn.ilapage.goauto.agent sqlite3 -readonly databases/goauto_diagnostics.db "SELECT task_id,task_attempt_id,device_id,phase,rule_snapshot_hash,stage,reason,candidate_count,clickable_ancestor_depth,created_at FROM agent_diagnostic WHERE task_type='purchase' AND task_id= AND task_attempt_id='' ORDER BY id;" ``` -先列出文件,再读取本次任务/attempt 的准确文件名;不得扩大为导出所有应用数据或完整数据库。新版安装前的失败不会补生成日志;需要用户授权后重试才能产生新证据。Release 若禁止 run-as 则停止,不更改权限绕过;首版没有导出界面。该功能用于确定拒绝分支,并不代表已经修复对应采购故障。 +如果日志出现 SIZE_ADVICE_CLICK_BLOCKED,只能证明保护已触发,不代表正常商品探测成功;正常商品仍必须读到预期颜色尺码。禁止直接重试 live 任务作“仅探测”验证:探测匹配后任务可回 pending 并继续下单。演练按 PDD 商品创建,且可能由档案匹配直接跳过 spec_probe;必须确认实际阶段和覆盖路径,装机与真机测试分别取得授权。 + ## SYB 商品列表查询等待(#355) 实现绑定 `a24c206`,仅 Web 客户端;2026-10-05 与 #353/#354 合并至 main `1b4f7cd` 并配套发布 Server/Web 至现有 167 服务器。已通过本地合成测试及线上只读页面验证:列表请求实际等待预算为 60000ms,原筛选组合超过 10 秒后正常返回。发布记录和回退目标见 #355 工单。 From 9a4495addd0077875d2cb0450df6f35c63220312 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Tue, 6 Oct 2026 17:49:11 +0800 Subject: [PATCH 3/7] fix(android): deduplicate truncated same-card spec captions (#362) --- .../automation/PddProductDetailCollector.kt | 46 +++- .../agent/automation/SpecValueNormalizer.kt | 4 + .../goauto/agent/TruncatedSpecCardTest.kt | 226 ++++++++++++++++++ 3 files changed, 273 insertions(+), 3 deletions(-) create mode 100644 android/app/src/test/java/cn/ilapage/goauto/agent/TruncatedSpecCardTest.kt diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt index d025052..c9c2eff 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt @@ -585,8 +585,8 @@ object PddScreenParser { /** * Outermost labelled clickable ancestor inside the value region whose label - * prefixes every candidate nested in it. Rows that contain several distinct - * options never qualify because their members do not share its label. + * prefixes every candidate nested in it, or a single image/caption card with + * a truncated caption. A shared ancestor alone is not card identity. */ private fun optionBlock( candidate: SnapshotNode, @@ -606,7 +606,8 @@ object PddScreenParser { if (raw.clickable && raw.label.isNotBlank() && resolved != null && inValueRegion(resolved)) { val outer = compact(resolved.label) val members = candidates.filter { it.path == resolved.path || it.path.startsWith("${resolved.path}/") } - if (outer.isEmpty() || members.any { !compact(it.label).startsWith(outer) }) break + val existingBlock = outer.isNotEmpty() && members.all { compact(it.label).startsWith(outer) } + if (!existingBlock && !isTruncatedDisplayCard(resolved, members, sourceByPath)) break block = resolved } parentPath = raw.parentPath @@ -614,6 +615,45 @@ object PddScreenParser { return block } + /** + * #362: narrowly recognize one full-labelled image above one truncated text + * leaf. Do not reverse the old prefix rule for arbitrary nested options. + * Non-clickable layout wrappers are fine; another clickable container is + * ambiguous, even if its label/price also happens to be a prefix match. + */ + private fun isTruncatedDisplayCard( + block: SnapshotNode, + members: List, + sourceByPath: Map, + ): Boolean { + fun name(label: String) = SpecValueNormalizer.normalizeColor(label).filterNot { it.isWhitespace() } + val full = name(block.label) + if (full.isEmpty()) return false + val children = members.filter { it.path != block.path } + val image = children.singleOrNull { it.className == "android.widget.ImageView" } ?: return false + val caption = children.singleOrNull { it.className == "android.widget.TextView" } ?: return false + if (children.size != 2 || name(image.label) != full) return false + val short = name(caption.label) + if (short.isEmpty() || short == full || !full.startsWith(short)) return false + + val descendants = sourceByPath.values.filter { it.path.startsWith("${block.path}/") } + // Image and caption must be leaves, not two independently labelled cards. + if (children.any { child -> descendants.any { it.parentPath == child.path } }) return false + if (descendants.any { node -> + node.clickable && node.label.isNotBlank() && + descendants.any { it.parentPath == node.path } + }) return false + if (children.any { child -> + child.bounds.left < block.bounds.left || child.bounds.right > block.bounds.right || + child.bounds.top < block.bounds.top || child.bounds.bottom > block.bounds.bottom + }) return false + if (image.bounds.top >= caption.bounds.top || image.bounds.bottom >= caption.bounds.bottom || + minOf(image.bounds.right, caption.bounds.right) <= maxOf(image.bounds.left, caption.bounds.left)) return false + + val prices = members.mapNotNull { SpecValueNormalizer.trailingPriceAmount(it.label) } + return prices.isEmpty() || prices.all { it.compareTo(prices.first()) == 0 } + } + /** The nearby clickable row holding [node], or null when there is none or it is not a plausible entry row. */ private fun clickableEntryRow(node: SnapshotNode, sourceByPath: Map, screenHeight: Int): SnapshotNode? { var current: SnapshotNode? = node diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/SpecValueNormalizer.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/SpecValueNormalizer.kt index f011e4f..c500fa1 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/SpecValueNormalizer.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/SpecValueNormalizer.kt @@ -13,6 +13,10 @@ object SpecValueNormalizer { fun normalizeSize(value: String): String = normalizeTrailingPrice(value) + /** The same terminal-price grammar as normalization; never reads numbers inside a spec. */ + internal fun trailingPriceAmount(value: String): java.math.BigDecimal? = + trailingPrice.find(value)?.value?.replace(currency, "")?.trim()?.toBigDecimalOrNull() + fun isSafeColor(value: String): Boolean = isSafeValue(value) fun isSafeSize(value: String): Boolean = isSafeValue(value) diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/TruncatedSpecCardTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/TruncatedSpecCardTest.kt new file mode 100644 index 0000000..3968849 --- /dev/null +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/TruncatedSpecCardTest.kt @@ -0,0 +1,226 @@ +package cn.ilapage.goauto.agent + +import cn.ilapage.goauto.agent.automation.* +import org.junit.Assert.* +import org.junit.Test + +/** Synthetic cards only: no device tree, address or order data. */ +class TruncatedSpecCardTest { + private val card = "r/sheet/body/list/color/o0" + private val other = "r/sheet/body/list/color/o1" + private val full = "黑色示例长裤【有抽绳】 有口袋不起球" + private val short = "黑色示例长裤【有抽绳】 有口袋" + private val size = "2XL建议130-150斤" + + private fun sheet( + outer: String = "$full ¥18.8", + caption: String = "$short ¥18.8", + image: String = outer, + selected: Boolean = true, + otherSelected: Boolean = false, + ): UiSnapshot = SpecPanelFixtures.taskOptionDedupSheet().let { snapshot -> + snapshot.copy(nodes = snapshot.nodes.map { node -> + when (node.path) { + card, "$card/img" -> node.copy(text = null, contentDescription = if (node.path == card) outer else image, selected = selected) + "$card/l/t" -> node.copy(text = caption, selected = selected) + "r/sheet/body/selected" -> node.copy(text = "请选择: 尺码") + else -> when { + node.path.startsWith("$card/") -> node.copy(selected = selected) + node.path == other || node.path.startsWith("$other/") -> node.copy(selected = otherSelected) + else -> node + } + } + }) + } + + private fun parse(snapshot: UiSnapshot) = PddScreenParser.parse( + snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "123456789", null, + ) + + private fun colors(snapshot: UiSnapshot) = parse(snapshot).dimensions.single { it.key == "color" }.values + + @Test fun `full image and truncated clickable caption are one selected color`() { + val result = colors(sheet()) + assertEquals(listOf(full, "白条纹"), result.map { it.text }) + assertTrue(result.first().node.selected) + assertFalse(result.last().node.selected) + assertEquals(5, parse(sheet()).dimensions.single { it.key == "size" }.values.size) + } + + @Test fun `checked display state survives grouping`() { + val snapshot = sheet(selected = false).let { it.copy(nodes = it.nodes.map { n -> + if (n.path == "$card/l/t") n.copy(checked = true) else n + }) } + assertTrue(colors(snapshot).single { it.text == full }.node.checked) + assertFalse(colors(snapshot).any { it.text == short }) + } + + @Test fun `whitespace and equivalent terminal prices do not change full identity`() { + assertEquals(listOf(full, "白条纹"), colors(sheet(caption = "黑色示例长裤【有抽绳】\n有口袋 ¥18.80")).map { it.text }) + } + + @Test fun `one or both absent prices still require card evidence`() { + for ((outer, caption) in listOf(full to short, "$full ¥18.8" to short, full to "$short ¥18.8")) { + assertEquals(listOf(full, "白条纹"), colors(sheet(outer, caption)).map { it.text }) + } + } + + @Test fun `different prices refuse truncated merge`() { + assertTrue(colors(sheet(caption = "$short ¥28.8")).any { it.text == short }) + } + + @Test fun `non prefix caption remains independent`() { + assertTrue(colors(sheet(caption = "灰色示例 ¥18.8")).any { it.text == "灰色示例" }) + } + + @Test fun `empty price only and missing complete image are not prefix evidence`() { + for (caption in listOf("", "¥18.8")) { + val snapshot = sheet(caption = caption, selected = false).let { it.copy(nodes = it.nodes.map { n -> + if (n.path == "$card/l/t") n.copy(checked = true) else n + }) } + // An empty label may be omitted by the parser; a pure-price value + // must not be promoted into the full card via empty-prefix matching. + if (caption.isNotEmpty()) assertTrue(colors(snapshot).any { it.text.isEmpty() && it.node.checked }) + } + assertTrue(colors(sheet(image = "$short ¥18.8")).any { it.text == short }) + assertTrue(colors(sheet(outer = "¥18.8")).any { it.text == short }) + } + + @Test fun `truncated leaf outside card geometry is not merged`() { + val snapshot = sheet().let { it.copy(nodes = it.nodes.map { n -> + if (n.path == "$card/l/t") n.copy(bounds = NodeBounds(710, 1340, 1040, 1447)) else n + }) } + assertTrue(colors(snapshot).any { it.text == short }) + } + + @Test fun `clickable caption wrapper is not silently assumed to be a display leaf`() { + val snapshot = sheet().let { it.copy(nodes = it.nodes.map { n -> + if (n.path == "$card/l") n.copy(text = "$short ¥18.8", clickable = true) else n + }) } + assertTrue(colors(snapshot).any { it.text == short }) + } + + @Test fun `adjacent prefix named cards stay distinct even at same price`() { + val snapshot = sheet(outer = "黑色加绒 ¥18.8", caption = "黑色加 ¥18.8").let { it.copy(nodes = it.nodes.map { n -> + if (n.path == other || n.path == "$other/img" || n.path == "$other/l/t") + n.copy(text = "黑色 ¥18.8", contentDescription = null) else n + }) } + assertEquals(listOf("黑色加绒", "黑色"), colors(snapshot).map { it.text }) + } + + @Test fun `labelled clickable common parent cannot swallow independent cards`() { + val snapshot = sheet().let { it.copy(nodes = it.nodes.map { n -> + if (n.path == "r/sheet/body/list/color") n.copy(text = "$full ¥18.8", clickable = true, + bounds = NodeBounds(0, 1044, 1080, 1447)) else n + }) } + assertTrue(colors(snapshot).any { it.text == "白条纹" }) + } + + @Test fun `nested same price prefix card with own image and caption remains independent`() { + val base = sheet(outer = "黑色加绒 ¥18.8", caption = "黑色加 ¥18.8") + val parent = base.nodes.single { it.path == card } + val nested = listOf( + parent.copy(path = "$card/nested", parentPath = card, text = "黑色 ¥18.8", contentDescription = null), + parent.copy(path = "$card/nested/img", parentPath = "$card/nested", text = "黑色 ¥18.8", contentDescription = null, className = "android.widget.ImageView"), + parent.copy(path = "$card/nested/text", parentPath = "$card/nested", text = "黑色 ¥18.8", contentDescription = null, className = "android.widget.TextView"), + ) + assertTrue(colors(base.copy(nodes = base.nodes + nested)).any { it.text == "黑色" }) + } + + @Test fun `numeric and weight size values and badge regression remain intact`() { + val baseline = SpecPanelFixtures.taskOptionDedupSheet() + assertEquals(listOf("兰条纹", "白条纹"), colors(baseline).map { it.text }) + val snapshot = sheet().let { it.copy(nodes = it.nodes.map { n -> + if (n.path.endsWith("size/o0/t")) n.copy(text = "42 ¥18.8") else n + }) } + val sizes = parse(snapshot).dimensions.single { it.key == "size" }.values.map { it.text } + assertTrue(sizes.containsAll(listOf("42", size, "3XL建议150-170斤"))) + } + + @Test fun `collector used by collection and spec probe emits only full color values`() { + var selectedColor = full + val driver = object : PddCollectorDriver { + override fun capture() = sheet(selected = selectedColor == full, otherSelected = selectedColor == "白条纹") + override fun clickFresh(target: SnapshotNode): FreshActionResult { + selectedColor = if (target.path.startsWith(card)) full else "白条纹" + return FreshActionResult.SUCCESS + } + override fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode?) = false + override fun pullDownGoodsPage() = false + override fun back() = false + } + var clock = 0L + val rule = CollectionRule( + schemaVersion = 2, steps = emptyList(), ruleType = "pddProductDetail", + pageEvidence = PageEvidence(SpecPanelFixtures.PDD, SpecPanelFixtures.ACTIVITY, NodeSelector(text = "颜色分类")), + collector = PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, + ) + val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect("123456789", rule) + assertTrue(result.message, result.successful) + assertEquals(listOf(full, "白条纹"), requireNotNull(result.payload).dimensions.single { it.key == "color" }.values) + } + + private fun executor(driver: PurchaseUiDriver) = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}) + + private class Driver(private val snapshot: UiSnapshot) : PurchaseUiDriver { + var opened = false + var sizeSelected = false + val clicks = mutableListOf() + override fun capture() = if (opened) snapshot.copy(nodes = snapshot.nodes.map { n -> + if (n.path.contains("/size/o")) n.copy(selected = sizeSelected && n.path.startsWith("r/sheet/body/list/size/o2")) else n + }) else SpecPanelFixtures.productDetailPage() + override fun clickFresh(target: SnapshotNode): FreshActionResult { + clicks += target.label + if (target.label.startsWith("请选择")) opened = true + if (target.label == "2XL建议130-150斤") sizeSelected = true + return FreshActionResult.SUCCESS + } + override fun tapPurchaseFresh(target: SnapshotNode) = FreshActionResult.FAILED + override fun inputFresh(target: SnapshotNode, value: String) = FreshActionResult.FAILED + override fun swipePurchase(direction: SwipeDirection, durationMs: Long) = false + override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long) = false + override fun backPurchase() = false + } + + private fun run(snapshot: UiSnapshot, target: String = full, driver: Driver = Driver(snapshot)) = executor(driver).execute( + PurchaseExecutionInput(362, "rehearsal", "purchase", "https://mobile.yangkeduo.com/goods.html?goods_id=123456789", + "123456789", target, size, quantity = 1, minUnitPriceCent = 1000, maxUnitPriceCent = 3000), + PurchaseRuleParser.parse("""{"schemaVersion":1,"ruleType":"pddPurchase","requiredCapabilities":["purchase.rehearsal.v1"], + "actions":[{"type":"openProduct"},{"type":"verifyProduct"},{"type":"openSpecPanel"},{"type":"selectSpec"}, + {"type":"setQuantity"},{"type":"verifyUnitPrice"},{"type":"verifyOrderSummary"}]}"""), + PurchaseAgentCapabilities.supported, + ) + + @Test fun `dedup lets existing confirmation complete color size and rehearsal`() { + val snapshot = sheet() + val driver = Driver(snapshot) + val result = run(snapshot, driver = driver) + assertEquals(result.message, "rehearsal_completed", result.resultType) + assertEquals(1, driver.clicks.count { it == size }) + assertFalse(driver.clicks.any { it.contains("订单") || it.contains("支付") }) + } + + @Test fun `other selected card still fails immediate confirmation`() { + for (selected in listOf(false, true)) { + val result = run(sheet(selected = selected, otherSelected = true)) + assertEquals("PURCHASE_SPEC_SELECTION_UNCONFIRMED", result.errorCode) + } + } + + @Test fun `old truncated mapped target is not silently upgraded`() { + assertEquals("PURCHASE_SPEC_TARGET_NOT_VISIBLE", run(sheet(), short).errorCode) + } + + @Test fun `existing final confirmation target first ordering is documented not changed`() { + val screen = parse(sheet(otherSelected = true)) + val executor = executor(Driver(sheet())) + val immediate = PurchaseRehearsalExecutor::class.java.declaredMethods.single { it.name == "isExactSpecSelected" } + immediate.isAccessible = true + assertEquals(false, immediate.invoke(executor, screen, "color", full)) + val final = PurchaseRehearsalExecutor::class.java.declaredMethods.single { it.name == "verifyExactSpecSelection" } + final.isAccessible = true + val result = final.invoke(executor, screen, "color", full, null) + val confirmed = result.javaClass.getDeclaredField("confirmed").apply { isAccessible = true } + assertEquals(true, confirmed.get(result)) // Existing baseline; NOT a newly approved multi-selection behavior. + } +} From 1f5bab81928daf833b6d51e52fc3543f54a5fffe Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Tue, 6 Oct 2026 17:54:50 +0800 Subject: [PATCH 4/7] fix(android): preserve independent cards under labelled groups (#362) --- .../automation/PddProductDetailCollector.kt | 14 +++++++++ .../goauto/agent/TruncatedSpecCardTest.kt | 29 +++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt index c9c2eff..64473ba 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt @@ -375,6 +375,7 @@ object PddScreenParser { val candidates = panelVisible.asSequence() .filter { it.clickable && inValueRegion(it) } .filterNot { isExactHeadingLabel(it.label, config) } + .filterNot { containsDistinctOptionCard(it, sourceByPath) } .filterNot { node -> isExcludedOptionLabel(node.label) || descendants(node, visibleNodes).any { descendant -> isExcludedOptionLabel(descendant.label) } @@ -604,6 +605,7 @@ object PddScreenParser { // Only a block with its own label names the option; a blank row // container resolved from its first child never merges options. if (raw.clickable && raw.label.isNotBlank() && resolved != null && inValueRegion(resolved)) { + if (containsDistinctOptionCard(resolved, sourceByPath)) break val outer = compact(resolved.label) val members = candidates.filter { it.path == resolved.path || it.path.startsWith("${resolved.path}/") } val existingBlock = outer.isNotEmpty() && members.all { compact(it.label).startsWith(outer) } @@ -615,6 +617,18 @@ object PddScreenParser { return block } + /** A labelled group must neither name its children nor become a synthetic selected option. */ + private fun containsDistinctOptionCard(block: SnapshotNode, sourceByPath: Map): Boolean { + val raw = sourceByPath[block.path] ?: return false + if (raw.label.isBlank()) return false + val name = SpecValueNormalizer.normalizeColor(raw.label).filterNot { it.isWhitespace() } + return sourceByPath.values.any { child -> + child.path.startsWith("${block.path}/") && child.clickable && child.label.isNotBlank() && + SpecValueNormalizer.normalizeColor(child.label).filterNot { it.isWhitespace() } != name && + sourceByPath.values.any { it.parentPath == child.path } + } + } + /** * #362: narrowly recognize one full-labelled image above one truncated text * leaf. Do not reverse the old prefix rule for arbitrary nested options. diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/TruncatedSpecCardTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/TruncatedSpecCardTest.kt index 3968849..10aada9 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/TruncatedSpecCardTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/TruncatedSpecCardTest.kt @@ -127,6 +127,35 @@ class TruncatedSpecCardTest { assertTrue(colors(base.copy(nodes = base.nodes + nested)).any { it.text == "黑色" }) } + @Test fun `short labelled common parent does not merge its longer independent cards`() { + val parent = "r/sheet/body/list/color/cards" + val base = sheet(outer = "黑色加绒", caption = "黑色加绒") + val nodes = base.nodes.map { n -> + val isOther = n.path == other || n.path == "$other/img" || n.path == "$other/l/t" + val child = if (isOther) n.copy(text = "黑色", contentDescription = null) else n + child.copy( + path = child.path.replace(card, "$parent/o0").replace(other, "$parent/o1"), + parentPath = child.parentPath?.replace(card, "$parent/o0")?.replace(other, "$parent/o1") + ?.let { if ((n.path == card || n.path == other)) parent else it }, + ) + } + val container = base.nodes.single { it.path == card }.copy( + path = parent, parentPath = parent.substringBeforeLast('/'), text = "黑色", contentDescription = null, + bounds = NodeBounds(36, 1044, 698, 1447), + ) + val values = colors(base.copy(nodes = nodes + container)) + assertEquals(setOf("黑色加绒", "黑色"), values.map { it.text }.toSet()) + assertTrue(values.single { it.text == "黑色加绒" }.node.selected) + assertFalse(values.single { it.text == "黑色" }.node.selected) + } + + @Test fun `same named clickable caption wrapper keeps original badge merging`() { + val snapshot = SpecPanelFixtures.taskOptionDedupSheet().let { it.copy(nodes = it.nodes.map { n -> + if (n.path == "$card/l") n.copy(text = "兰条纹", clickable = true) else n + }) } + assertEquals(listOf("兰条纹", "白条纹"), colors(snapshot).map { it.text }) + } + @Test fun `numeric and weight size values and badge regression remain intact`() { val baseline = SpecPanelFixtures.taskOptionDedupSheet() assertEquals(listOf("兰条纹", "白条纹"), colors(baseline).map { it.text }) From 46b92dd6c5bf00ad5b668f8c448dc78e8a161fcd Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Tue, 6 Oct 2026 17:55:45 +0800 Subject: [PATCH 5/7] docs: sync spec card dedup and prerequisite Wiki facts (#362) --- docs/02-architecture-and-code-map.md | 14 ++++++++++++-- docs/03-business-rules-and-glossary.md | 24 ++++++++++++++++++++++-- docs/06-troubleshooting.md | 24 +++++++++++++----------- 3 files changed, 47 insertions(+), 15 deletions(-) diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index fba0b18..2e1a753 100644 --- a/docs/02-architecture-and-code-map.md +++ b/docs/02-architecture-and-code-map.md @@ -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: f73ab171684662d23f298d7e4927168b1a40b01a -synchronized_at: 2026-10-06T01:39:19Z +wiki_revision: 3165d7d419a46f41fa70a63286799deda9a8ce9f +synchronized_at: 2026-10-06T09:50:17Z @@ -617,3 +617,13 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态 - `sybimport.Handler.List/Detail` 复用产品模型返回 nullable 字段。列表不额外访问 SYB;不新增旧订单扫描、并发、定时任务或采购拦截。 - Web 只在 SYB 商品列表订单号后增加固定 72px 的“SYB状态”列;退货列改名“匹配退货”、min-width 200→160,保留图片/告警/操作并允许换行。使用现有标签与加载/失败态,其他列及 Android 不变。 - 迁移重复执行和三态接口/导入已在隔离 SQLite 测试,Server/Web 构建通过;尚未验证正式 MySQL 执行或真实同步后的页面。先授权迁移,再发布才能用于业务环境。 + +## Android 规格探测与诊断增补(#361) + +实现绑定 a49dc69,Agent 0.9.68 / 81,尚未合并 main。 + +- PurchaseRehearsalExecutor.applyPostAction 只在 spec_probe + OPEN_SPEC_PANEL 跳过 swipeAfter,保留等待和 purchase 行为。 +- SizeAdviceClickPolicy 对最终点击接收者的子树检查明确建议入口短语;GoAutoAccessibilityService 的 ACTION_CLICK 和两种中心手势入口使用该策略。FreshActionResult.BLOCKED / FreshClickReason.SIZE_ADVICE_BLOCKED 阻止规格流程继续兜底。 +- PddProductDetailCollector 的入口、快速确认恢复和颜色点击通过 clickFreshDetailed 获取结果;SpecClickDiagnostic 构造白名单结构事件。AgentForegroundService 给探测执行器与采集器接入既有 SafeAgentDiagnosticRecorder / 单线程队列,未接入原始 trace。 +- AgentDiagnosticSchema v3:agent_diagnostic 追加可空 task_type TEXT、task_attempt_id TEXT、device_id INTEGER、phase TEXT、rule_snapshot_hash TEXT。onUpgrade 支持 V1/V2 追加并检查已有列;保留旧行且新字段为 NULL。AgentDiagnosticStore 写采购记录时验证类型、UUID、正设备 ID、阶段和 64 位十六进制哈希。动作 attempt 与采购 attempt UUID 分离,全库 50 条/7 天保留边界不变。 +- 无 Server/Web/业务库或共享接口字段变化,无订单提交流程变化。新错误沿既有 errorCode 字符串回传;真实探测/采购验收仍待用户授权。 diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index 0a09368..af5d8ef 100644 --- a/docs/03-business-rules-and-glossary.md +++ b/docs/03-business-rules-and-glossary.md @@ -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: f5077a19801882ebb4e94c5b0fe93f00fa6ebc7d -synchronized_at: 2026-10-06T03:27:21Z +wiki_revision: ec1f676abf07b70f874c40afa286581b6ba62964 +synchronized_at: 2026-10-06T09:50:20Z @@ -801,3 +801,23 @@ Android 0.9.64 / versionCode 77,源码 `6550b9f`(分支实现,尚未安装 - RawSpecHalves 复用 Parse,ResolveKeys 复用两者,原始两侧角色、注释塌缩消歧与采购规格键继续共享同一判断,不另立角色推断规则。 - 更新规则本身不会改历史数据库。正常重同步沿用现有保护规则;需要定向重新解析时复用 ReparseBatch,force=false 默认跳过人工及 AI 确认行。该流程可能重算同商品其他未确认明细,执行前必须核验完整影响集合。 - ERPGo 规格同步及 Merge 仍只追加并保留映射,不自动清除旧反向档案值。清理须独立授权、指定对象并验证引用/映射,保留恢复依据;不自动扩大为全库数据清理,不修改采购任务快照。 + +## Android 规格探测点击保护(#361) + +实现绑定 a49dc69(工单分支,Agent 0.9.68 / 81,未合并 main);自动化验证通过,现场采购修复尚待验收。 + +- 仅 spec_probe 阶段的 OPEN_SPEC_PANEL 跳过规则 swipeAfter;waitAfterMs 和既有页面稳定等待保留,采集器继续自身的有限规格遍历。purchase 阶段原规则滑动不变。 +- PDD 点击按最终接收动作的控件或最近可点击父容器检查尺码建议类明确短语,包含简繁体;无障碍直接点击、祖先点击与中心手势共享判断。不可因面板其他位置存在建议入口而拒绝所有规格;“M 建议80-100斤”等合法选项不按“建议”泛化拦截。命中返回 SIZE_ADVICE_CLICK_BLOCKED,规格入口/规格选择不再手势兜底,颜色采集不改点其他候选。 +- 规格探测的结构化诊断复用本地诊断库,以 purchase 类型、task ID、服务端 attempt UUID、设备、phase 和规则快照哈希关联;不保存原始 trace/标签/树/截图。诊断失败不改变任务结果。 +- 真机验证不能依赖探测失败作为停止边界:live 任务探测匹配成功后可继续正式下单。必须明确演练覆盖 spec_probe 的方式,或另获正式创建订单授权;永久禁止付款。 + +## Android 同一卡片截短规格显示去重(#362) + +实现绑定工单分支提交 9a4495a(基于 main 64f0e49);未合并 main、未装机,现场效果尚待验证。此节不表示 #361 已验收或已集成。 + +- 共享 PddScreenParser.optionBlock 保留原“成员以外层完整标签开头”的分支(含徽标/附加说明)。只新增已知图片卡片形态的保守兼容:外层有自身完整标签,候选显示成员恰为一个完整标签 ImageView 叶节点和一个截短 TextView 叶节点;允许非点击布局包装,不跨另一个带标签且可点击的容器。 +- 两个显示叶节点必须位于卡片边界内,图片在字幕上方且横向相交(允许图片/字幕边缘重叠)。缺少完整图片、多个字幕或图片、嵌套独立卡片、文字归属不明确时不采用新增合并分支。相邻同价且前缀相似的不同规格不能凭文字合并。 +- 比较使用既有 SpecValueNormalizer 尾部价格语法和空白压缩,双方非空,截短名须为完整名的严格前缀;所有实际存在的尾价须数值一致,18.8 与 18.80 等价。缺失价格不构成同卡证据,必须仍满足全部结构条件。不去掉体重、尺码数字或款式文字。 +- 合并后仍使用外层完整原文按既有规则去尾价作为规格值,沿用原点击节点排序、selected/checked 聚合;普通采集与采购探测共用这一路径。精确定位、即时确认、选中证明、最终复核及下单行为不改,不允许模糊点击。 +- 历史任务若已映射到截短值,不自动升格为完整值,不修改任务快照、映射或历史数据;找不到精确目标仍明确失败。即时确认先查其他选中值,而最终确认先接受唯一目标已选中,是既有实现差异,本修复不调整或掩盖该差异。 +- #361 的诊断未写入疑点继续独立核查。解析合成测试可先行,后续用于手机安装的集成版本须包含 #361,并经明确授权安装/真机验证;不以已有单次采购成功代替完整验收,不执行付款。 diff --git a/docs/06-troubleshooting.md b/docs/06-troubleshooting.md index 6b2c8f0..5490939 100644 --- a/docs/06-troubleshooting.md +++ b/docs/06-troubleshooting.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Troubleshooting wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Troubleshooting -wiki_revision: 40e77a656d1c21d72454ad722a4f71a0a4491241 -synchronized_at: 2026-10-05T07:18:16Z +wiki_revision: ac3a0b74704f398cec54149267089c87c8375a3e +synchronized_at: 2026-10-06T09:50:33Z # 故障排查 @@ -86,24 +86,26 @@ sqlite3 -readonly agent-diagnostics.db "SELECT task_id,reason,color_row_count,co 读取时记录设备、Agent 版本、任务号和规则快照;工单只回写查询得到的脱敏聚合数值。读取完成后删除本地导出副本。正式 APK 若不允许 `run-as`,停止排查并确认安全的只读诊断出口,不通过放宽应用安全配置或上传完整数据库绕过。 -## Android 采购规格入口本地诊断(#249) +## Android 采购规格入口本地诊断(#249 / #361) -实现绑定 `99faf5a`,Agent `0.9.67`(versionCode 80)。单元测试与 Debug 构建已通过,新增版本真机留存/读取尚待验证;本节不属于上文既有实测结论。 +#249 的 JSONL 采购诊断实现位于独立分支(历史绑定 99faf5a),未合并基线 main 64f0e49;不能假定运行该 main 的设备存在 files/purchase_diagnostics。main 已有 goauto_diagnostics.db / agent_diagnostic,本单复用它,不整体合并 #249。 -采购执行器启动后,在应用私有 `files/purchase_diagnostics/_.jsonl` 保存白名单结构化入口诊断。元数据包含 taskId、attemptId、deviceId、agentVersion、phase、ruleHash(当前任务规则快照 SHA-256)、timestamp 和 elapsedMs。现有任务接口只有 attemptId,没有 attemptNumber;用 attemptId 对照服务端历史,不推算重试序号。 +#361 实现绑定 a49dc69,Agent 0.9.68(versionCode 81),当前为工单分支,未合并 main 或发布 Admin。已完成 Debug 构建和单元测试,按用户授权覆盖安装一台设备;未执行真机探测或采购重试,不能视为现场修复验收。 -证据仅包含入口来源枚举、候选数、点击结果/原因枚举、等待轮数、面板结构布尔值/计数,以及受控恢复是否执行及固定拒绝原因。没有商品标题、规格原文、地址、手机号、Cookie、Token、链接、原始控件树或截图。entryWaitMillis 是该等待循环累计的计划等待量;elapsedMs 才是自本次采购执行器诊断起点计量的实际耗时,timestamp 是后台写入时间。 +规格探测的入口点击/手势、快速确认恢复、颜色点击,以及原有面板/颜色/尺码发现记录接入既有异步诊断队列。来源 stage 为 SPEC_ENTRY_CLICK / SPEC_ENTRY_GESTURE / QUICK_CONFIRMATION_CLICK / COLOR_CLICK;reason 为点击结果或 SIZE_ADVICE_CLICK_BLOCKED 等固定枚举。只存候选数、可点击布尔、白名单类名和可得的祖先层级,不保存原始 trace、标签、地址、手机号、控件树或截图。 -复用现有单线程诊断队列异步写入,写入/排队失败不改变采购结果;不新增点击、滑动、等待或采购门禁。最多保留最近 5 个 attempt 文件、每文件最后 128 条事件,超过 7 天的文件在服务启动或下次写入时清理。异常断电或存储故障仍可能丢失诊断,不能将日志缺失当成动作未执行;不上传 Admin,不增加服务端接口。 +本地 SQLite schema v3 仅追加可空 task_type、task_attempt_id、device_id、phase、rule_snapshot_hash。采购记录绑定 purchase_task.id、服务端 attempt UUID、设备、阶段与规则哈希;现有 attempt 仍是动作内次数,不能当作采购 attempt ID。新采集记录标识 collection;旧记录新增字段为 NULL,不猜测或回填归属。V1/V2 自动升级保留旧数据;旧版 SQLiteOpenHelper 不保证能降级打开 v3,回退前需单独评估,不卸载清数据。 -仅对允许 run-as 的 Debug APK,在设备已连接且选定准确序列号后只读提取: +保留边界沿用全库最近 50 条及 7 天(写入时清理),日志可能因容量、断电或存储异常缺失;缺日志不能证明未点击。写入/排队失败不改变执行结果,不新增上传接口。 + +读取须确认准确设备、Debug APK、task ID 与 attempt UUID。设备具备 sqlite3 且允许 run-as 时可执行下面的只读查询;如缺 sqlite3/run-as,则停止并另行确认诊断读取路径,不放宽权限、不导出业务库。命令尚未在本单设备验证: ```powershell -adb -s shell run-as cn.ilapage.goauto.agent ls files/purchase_diagnostics -adb -s exec-out run-as cn.ilapage.goauto.agent cat files/purchase_diagnostics/_.jsonl +adb -s shell run-as cn.ilapage.goauto.agent sqlite3 -readonly databases/goauto_diagnostics.db "SELECT task_id,task_attempt_id,device_id,phase,rule_snapshot_hash,stage,reason,candidate_count,clickable_ancestor_depth,created_at FROM agent_diagnostic WHERE task_type='purchase' AND task_id= AND task_attempt_id='' ORDER BY id;" ``` -先列出文件,再读取本次任务/attempt 的准确文件名;不得扩大为导出所有应用数据或完整数据库。新版安装前的失败不会补生成日志;需要用户授权后重试才能产生新证据。Release 若禁止 run-as 则停止,不更改权限绕过;首版没有导出界面。该功能用于确定拒绝分支,并不代表已经修复对应采购故障。 +如果日志出现 SIZE_ADVICE_CLICK_BLOCKED,只能证明保护已触发,不代表正常商品探测成功;正常商品仍必须读到预期颜色尺码。禁止直接重试 live 任务作“仅探测”验证:探测匹配后任务可回 pending 并继续下单。演练按 PDD 商品创建,且可能由档案匹配直接跳过 spec_probe;必须确认实际阶段和覆盖路径,装机与真机测试分别取得授权。 + ## SYB 商品列表查询等待(#355) 实现绑定 `a24c206`,仅 Web 客户端;2026-10-05 与 #353/#354 合并至 main `1b4f7cd` 并配套发布 Server/Web 至现有 167 服务器。已通过本地合成测试及线上只读页面验证:列表请求实际等待预算为 60000ms,原筛选组合超过 10 秒后正常返回。发布记录和回退目标见 #355 工单。 From cb00194b0fc4c7672d7cf6d18fe9e32a7504db36 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Tue, 6 Oct 2026 17:57:41 +0800 Subject: [PATCH 6/7] docs: record independent spec card boundary (#362) --- docs/03-business-rules-and-glossary.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index af5d8ef..80dfb39 100644 --- a/docs/03-business-rules-and-glossary.md +++ b/docs/03-business-rules-and-glossary.md @@ -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: ec1f676abf07b70f874c40afa286581b6ba62964 -synchronized_at: 2026-10-06T09:50:20Z +wiki_revision: 7aab259e077ac33f858ec2d47da8ef28cd354a27 +synchronized_at: 2026-10-06T09:56:16Z @@ -813,9 +813,10 @@ Android 0.9.64 / versionCode 77,源码 `6550b9f`(分支实现,尚未安装 ## Android 同一卡片截短规格显示去重(#362) -实现绑定工单分支提交 9a4495a(基于 main 64f0e49);未合并 main、未装机,现场效果尚待验证。此节不表示 #361 已验收或已集成。 +实现绑定工单分支提交 9a4495a + 1f5bab8(基于 main 64f0e49);未合并 main、未装机,现场效果尚待验证。此节不表示 #361 已验收或已集成。 - 共享 PddScreenParser.optionBlock 保留原“成员以外层完整标签开头”的分支(含徽标/附加说明)。只新增已知图片卡片形态的保守兼容:外层有自身完整标签,候选显示成员恰为一个完整标签 ImageView 叶节点和一个截短 TextView 叶节点;允许非点击布局包装,不跨另一个带标签且可点击的容器。 +- 若带自身标签的候选容器包含另一个有不同规范化名称、可点击且有自身子节点的卡片,该容器不作为规格值,也不能成为这些卡片的合并块。这一结构边界同时保护原前缀分支,避免共同容器“黑色”吞并子卡片“黑色加绒”,以及容器聚合 selected 污染另一张卡片;同名包装和原徽标分支保留。 - 两个显示叶节点必须位于卡片边界内,图片在字幕上方且横向相交(允许图片/字幕边缘重叠)。缺少完整图片、多个字幕或图片、嵌套独立卡片、文字归属不明确时不采用新增合并分支。相邻同价且前缀相似的不同规格不能凭文字合并。 - 比较使用既有 SpecValueNormalizer 尾部价格语法和空白压缩,双方非空,截短名须为完整名的严格前缀;所有实际存在的尾价须数值一致,18.8 与 18.80 等价。缺失价格不构成同卡证据,必须仍满足全部结构条件。不去掉体重、尺码数字或款式文字。 - 合并后仍使用外层完整原文按既有规则去尾价作为规格值,沿用原点击节点排序、selected/checked 聚合;普通采集与采购探测共用这一路径。精确定位、即时确认、选中证明、最终复核及下单行为不改,不允许模糊点击。 From 279cb558477895d03b5a9c5fcdb7cfed78c8cfb4 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Tue, 6 Oct 2026 18:03:31 +0800 Subject: [PATCH 7/7] docs: bind installed integrated Agent 0.9.69 (#362) --- docs/03-business-rules-and-glossary.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index 80dfb39..4640f9a 100644 --- a/docs/03-business-rules-and-glossary.md +++ b/docs/03-business-rules-and-glossary.md @@ -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: 7aab259e077ac33f858ec2d47da8ef28cd354a27 -synchronized_at: 2026-10-06T09:56:16Z +wiki_revision: 81cec55e15c968efcc2cb4c4e5babcd17a40a3f4 +synchronized_at: 2026-10-06T10:02:05Z @@ -813,7 +813,7 @@ Android 0.9.64 / versionCode 77,源码 `6550b9f`(分支实现,尚未安装 ## Android 同一卡片截短规格显示去重(#362) -实现绑定工单分支提交 9a4495a + 1f5bab8(基于 main 64f0e49);未合并 main、未装机,现场效果尚待验证。此节不表示 #361 已验收或已集成。 +解析修复绑定 9a4495a + 1f5bab8(基于 main 64f0e49);用户随后授权构建安装,工单分支于 35119ea 集成 #361,Agent 0.9.69 / 82 已于 2026-10-06 覆盖安装到指定设备。尚未合并 main,现场采购效果未验证;集成不表示 #361 的诊断疑点已解决或整体验收通过。 - 共享 PddScreenParser.optionBlock 保留原“成员以外层完整标签开头”的分支(含徽标/附加说明)。只新增已知图片卡片形态的保守兼容:外层有自身完整标签,候选显示成员恰为一个完整标签 ImageView 叶节点和一个截短 TextView 叶节点;允许非点击布局包装,不跨另一个带标签且可点击的容器。 - 若带自身标签的候选容器包含另一个有不同规范化名称、可点击且有自身子节点的卡片,该容器不作为规格值,也不能成为这些卡片的合并块。这一结构边界同时保护原前缀分支,避免共同容器“黑色”吞并子卡片“黑色加绒”,以及容器聚合 selected 污染另一张卡片;同名包装和原徽标分支保留。