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/2] 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/2] 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 工单。