diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 4204c63..3ded304 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 = 76 - versionName = "0.9.63" + versionCode = 77 + versionName = "0.9.64" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 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 59c1168..cb7fafa 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 @@ -468,17 +468,27 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto } override fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean { - val root = rootInActiveWindow ?: return false + return swipePurchaseWithEvidence(direction, durationMs).result == PurchaseSwipeResult.SUCCESS + } + + override fun swipePurchaseWithEvidence(direction: SwipeDirection, durationMs: Long): PurchaseSwipeOutcome { + // Count and resolve against the same live root: a missing root or a + // failed dispatch must never masquerade as an empty scrollable list. + val root = rootInActiveWindow ?: return PurchaseSwipeOutcome(PurchaseSwipeResult.ROOT_UNAVAILABLE, 0) val candidates = mutableListOf() walk(root) { node -> if (node.isVisibleToUser && node.isScrollable) candidates += node } + if (candidates.isEmpty()) return PurchaseSwipeOutcome(PurchaseSwipeResult.NO_SCROLLABLE_TARGET, 0) val horizontal = direction == SwipeDirection.LEFT || direction == SwipeDirection.RIGHT val directional = candidates.filter { candidate -> Rect().also(candidate::getBoundsInScreen).let { if (horizontal) it.width() > it.height() else it.height() >= it.width() } } val target = (directional.ifEmpty { candidates }).maxByOrNull { candidate -> Rect().also(candidate::getBoundsInScreen).let { it.width().toLong() * it.height() } - } ?: return false - return swipeNode(target, direction, durationMs, preferScrollAction = false) + } ?: return PurchaseSwipeOutcome(PurchaseSwipeResult.FAILED, candidates.size) + val result = if (swipeNode(target, direction, durationMs, preferScrollAction = false)) { + PurchaseSwipeResult.SUCCESS + } else PurchaseSwipeResult.FAILED + return PurchaseSwipeOutcome(result, candidates.size) } override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean { 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 20012a1..a15dd6e 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 @@ -3,6 +3,10 @@ package cn.ilapage.goauto.agent.automation import java.net.URI import java.net.URLDecoder +enum class PurchaseSwipeResult { SUCCESS, NO_SCROLLABLE_TARGET, ROOT_UNAVAILABLE, FAILED } + +data class PurchaseSwipeOutcome(val result: PurchaseSwipeResult, val visibleScrollableCount: Int) + interface PurchaseUiDriver { fun capture(): UiSnapshot fun clickFresh(target: SnapshotNode): FreshActionResult @@ -32,6 +36,18 @@ interface PurchaseUiDriver { fun specRowSwipeFailureReason(): String = "gestureFailed" fun inputFresh(target: SnapshotNode, value: String): FreshActionResult fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean + /** Detailed result only for the rule's reversible spec-panel reveal swipe. */ + fun swipePurchaseWithEvidence(direction: SwipeDirection, durationMs: Long): PurchaseSwipeOutcome { + val snapshot = capture() + val count = snapshot.nodes.count { it.visible && it.scrollable } + val result = when { + snapshot.nodes.isEmpty() -> PurchaseSwipeResult.ROOT_UNAVAILABLE + count == 0 -> PurchaseSwipeResult.NO_SCROLLABLE_TARGET + swipePurchase(direction, durationMs) -> PurchaseSwipeResult.SUCCESS + else -> PurchaseSwipeResult.FAILED + } + return PurchaseSwipeOutcome(result, count) + } fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean fun pullDownGoodsPage(): Boolean = swipePurchase(SwipeDirection.DOWN, 550) fun backPurchase(): Boolean @@ -1150,17 +1166,34 @@ class PurchaseRehearsalExecutor( private fun applyPostAction(input: PurchaseExecutionInput, action: PurchaseAction): PurchaseExecutionOutcome? { if (action.waitAfterMs > 0) pause(action.waitAfterMs) action.swipeAfter?.let { swipe -> - // The stock purchase rule asks to reveal additional selector rows after - // opening the sheet. A fully-evidenced non-scrollable selector has no - // scroll target, and treating that absence as an action failure blocks - // an otherwise safe exact-spec flow. Keep all other configured swipes - // mandatory; this exception is limited to that confirmed panel state. - if (action.type == PurchaseActionType.OPEN_SPEC_PANEL && - currentScreen(input).specPanelType == SpecPanelType.NON_SCROLLABLE_CONFIRMATION - ) return null + val reveal = action.type == PurchaseActionType.OPEN_SPEC_PANEL + val initialScreen = if (reveal) currentScreen(input) else null + if (initialScreen?.specPanelType == SpecPanelType.NON_SCROLLABLE_CONFIRMATION) { + val count = initialScreen.sourceNodes.count { it.visible && it.scrollable } + panelDiagnostic("ruleSwipe;outcome=skipped_confirmed_panel;panel=${initialScreen.specPanelType};scrollables=$count;reveal=true") + return null + } repeat(swipe.count) { index -> - if (!driver.swipePurchase(swipe.direction, swipe.durationMs)) { - return failure("RULE_ACTION_FAILED", "规则要求的有限滑动失败") + // Keep other actions on the original Boolean path. Only the + // post-open reveal may treat an absent scroll target as a no-op. + val outcome = if (reveal) { + driver.swipePurchaseWithEvidence(swipe.direction, swipe.durationMs) + } else if (driver.swipePurchase(swipe.direction, swipe.durationMs)) { + PurchaseSwipeOutcome(PurchaseSwipeResult.SUCCESS, 0) + } else { + val count = driver.capture().nodes.count { it.visible && it.scrollable } + PurchaseSwipeOutcome(PurchaseSwipeResult.FAILED, count) + } + if (outcome.result != PurchaseSwipeResult.SUCCESS) { + val screen = currentScreen(input) + val evidence = "panel=${screen.specPanelType};scrollables=${outcome.visibleScrollableCount};" + + "reveal=$reveal;reason=${outcome.result.name.lowercase()}" + if (reveal && outcome.result == PurchaseSwipeResult.NO_SCROLLABLE_TARGET) { + panelDiagnostic("ruleSwipe;outcome=skipped_no_scroll_target;$evidence") + return null + } + panelDiagnostic("ruleSwipe;outcome=failed;$evidence") + return failure("RULE_ACTION_FAILED", "规则要求的有限滑动失败 [$evidence]") } if (index < swipe.count - 1 && swipe.intervalMs > 0) pause(swipe.intervalMs) } 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 bd02933..683b444 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 @@ -9,6 +9,8 @@ import cn.ilapage.goauto.agent.automation.PurchaseExecutionInput import cn.ilapage.goauto.agent.automation.PurchaseRehearsalExecutor import cn.ilapage.goauto.agent.automation.PurchaseRuleParser import cn.ilapage.goauto.agent.automation.PurchaseSpecGesturePolicy +import cn.ilapage.goauto.agent.automation.PurchaseSwipeOutcome +import cn.ilapage.goauto.agent.automation.PurchaseSwipeResult import cn.ilapage.goauto.agent.automation.PurchaseUiDriver import cn.ilapage.goauto.agent.automation.RuleValidationException import cn.ilapage.goauto.agent.automation.SnapshotNode @@ -766,11 +768,118 @@ class PurchaseRehearsalExecutorTest { @Test fun `open spec panel skips required follow-up swipe only for confirmed non-scrollable panel`() { val driver = FakePurchaseDriver(nonScrollablePanel = true, purchaseSwipeSucceeds = false) - val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {}) + val diagnostics = mutableListOf() + val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {}, panelDiagnostic = diagnostics::add) .execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) assertEquals("rehearsal_completed", outcome.resultType) assertEquals(0, driver.swipeCount) + assertTrue(diagnostics.any { it.contains("skipped_confirmed_panel;panel=NON_SCROLLABLE_CONFIRMATION;scrollables=0;reveal=true") }) + } + + @Test + fun `open spec reveal skips missing scroll target regardless of panel classification`() { + val driver = FakePurchaseDriver( + panelOverride = SpecPanelFixtures.liveShapedSheet(), + purchaseSwipeSucceeds = false, + ) + var probes = 0 + val diagnostics = mutableListOf() + val outcome = PurchaseRehearsalExecutor( + driver, { driver.browser = true; true }, { probes++; "{}" }, + pause = {}, panelDiagnostic = diagnostics::add, + ).execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + + 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") }) + } + + @Test + fun `open spec reveal with a scroll target still fails on gesture failure`() { + val driver = FakePurchaseDriver(purchaseSwipeSucceeds = false) + var probes = 0 + val diagnostics = mutableListOf() + val outcome = PurchaseRehearsalExecutor( + driver, { driver.browser = true; true }, { probes++; "{}" }, + 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;") }) + assertFalse(outcome.message.contains("黑色")) + assertFalse(outcome.message.contains(input().goodsId)) + } + + @Test + fun `open spec reveal does not skip a missing accessibility root`() { + val driver = FakePurchaseDriver(revealSwipeOutcomes = mutableListOf( + PurchaseSwipeOutcome(PurchaseSwipeResult.ROOT_UNAVAILABLE, 0), + )) + 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("RULE_ACTION_FAILED", outcome.errorCode) + assertEquals(0, probes) + assertTrue(outcome.message.contains("scrollables=0;reveal=true;reason=root_unavailable")) + } + + @Test + fun `open spec reveal stops remaining swipes after target disappears`() { + val driver = FakePurchaseDriver(revealSwipeOutcomes = mutableListOf( + PurchaseSwipeOutcome(PurchaseSwipeResult.SUCCESS, 1), + PurchaseSwipeOutcome(PurchaseSwipeResult.NO_SCROLLABLE_TARGET, 0), + )) + 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) + + assertEquals("spec_probe_completed", outcome.resultType) + assertEquals(1, driver.swipeCount) + assertEquals(1, pauses.count { it == 1000L }) + } + + @Test + fun `scrollable spec reveal preserves configured swipe count and interval`() { + 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) + + assertEquals("spec_probe_completed", outcome.resultType) + assertEquals(2, driver.swipeCount) + assertEquals(1, pauses.count { it == 1000L }) + } + + @Test + fun `missing scroll target outside spec reveal remains a mandatory action failure`() { + val driver = FakePurchaseDriver(purchaseSwipeSucceeds = false) + val configured = rule().replace( + "\"waitAfterMs\":700}", + "\"waitAfterMs\":700,\"swipeAfter\":{\"direction\":\"up\",\"count\":1,\"durationMs\":500,\"intervalMs\":0}}", + ) + val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = {}) + .execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(configured), PurchaseAgentCapabilities.supported) + + assertEquals("RULE_ACTION_FAILED", outcome.errorCode) + assertTrue(outcome.message.contains("scrollables=0;reveal=false")) + assertFalse(driver.panel) + } + + @Test + fun `skipping a reveal does not skip a failed specification probe`() { + val driver = FakePurchaseDriver(panelOverride = SpecPanelFixtures.liveShapedSheet()) + var probes = 0 + val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { probes++; null }, pause = {}) + .execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + + assertEquals("PURCHASE_SPEC_NOT_MATCHED", outcome.errorCode) + assertEquals(1, probes) } @Test @@ -1249,6 +1358,8 @@ class PurchaseRehearsalExecutorTest { private val nonScrollablePanel: Boolean = false, private val unrecognizedPanel: Boolean = false, private val purchaseSwipeSucceeds: Boolean = true, + private val panelOverride: UiSnapshot? = null, + private val revealSwipeOutcomes: MutableList = mutableListOf(), initiallyInAgent: Boolean = false, private val panelBecomesUnknownAfterSizeProof: Boolean = false, ) : PurchaseUiDriver { @@ -1351,6 +1462,7 @@ class PurchaseRehearsalExecutorTest { if (includeReviewEntry) nodes += node("review", "商品评价", 20, 1200, 900, 1300, clickable = true) return UiSnapshot(PDD, ACTIVITY, nodes) } + panelOverride?.let { return it } if (unrecognizedPanel) { return UiSnapshot(PDD, ACTIVITY, listOf( node("content", "", 0, 0, 1080, 2200), @@ -1532,6 +1644,13 @@ class PurchaseRehearsalExecutorTest { return purchaseSwipeSucceeds } + override fun swipePurchaseWithEvidence(direction: SwipeDirection, durationMs: Long): PurchaseSwipeOutcome { + if (revealSwipeOutcomes.isEmpty()) return super.swipePurchaseWithEvidence(direction, durationMs) + val outcome = revealSwipeOutcomes.removeAt(0) + if (outcome.result == PurchaseSwipeResult.SUCCESS) swipePurchase(direction, durationMs) + return outcome + } + override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean { swipeInPaths += target.path if (restoreHiddenColorOnDownSwipe && quantity == 2L && direction == SwipeDirection.DOWN) { diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index 875db05..4a98ed5 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: 4bf8482e49509812b41ee678f40cf3c7c0cf5474 -synchronized_at: 2026-09-29T08:16:37Z +wiki_revision: 88ed799e92f64a73b3bd9472ab6c0b06fa080b37 +synchronized_at: 2026-09-29T09:48:30Z @@ -365,6 +365,8 @@ PddProductDetailCollector ## 采购规格面板预滑动兼容(#238) +> 历史分支版本说明:本节绑定的 `58a6c1c` 不在本次核验的 main `7a2c2d1` 祖先中,不能据此宣称当前 main 或已安装 APK 已取消全部预滑动。#348 的窄范围实现与版本边界见本页对应补充。 + 代码基线 `58a6c1c`,Android 0.9.60 / versionCode 73(构建完成不等同于已安装/发布)。`PurchaseRehearsalExecutor.applyPostAction` 对 `openSpecPanel.swipeAfter` 只兼容解析、不执行机械预滑动,`waitAfterMs` 保留;首趟继续原 `probeSpecs` 遍历,第二趟继续原 `selectSpec` 精确查找与容器内有界滚动。其他动作的后置滑动仍沿用既有执行语义,失败不会被统一忽略。 `GoAutoAccessibilityService.swipePurchase` 的失败分类由 `PurchaseSwipeFailureReason` 枚举提供;共享 `swipeNode` 仅增加可选分类回调,不改变手势目标、轨迹、1500ms 回调等待或其他调用者行为。`GoAutoPurchasePanel` 日志经 `AgentForegroundService` 关联 task、attempt、device 与规则快照哈希,新增预滑动跳过/必需滑动失败标量;不记录节点文字、坐标、原始控件树、截图或凭据。 @@ -587,3 +589,12 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态 - 列表:`sybimport/service.go List` 的 `purchaseType`(`pdd` 默认 / `excluded` / `all`),与 `processStage`、`createdFrom/To` 为 AND。 - 重算:`sybproductfilter/recompute.go`——`recomputeChanges` 规划(只读所需列);`recomputeFingerprint` 对排序后的(id、方向、规则 ID、类型、关键词)JSON 做 sha256;`writeRecomputeChanges` 逐行 `FOR UPDATE` 锁 `syb_product`,并以 `FOR SHARE` 加锁读复查 `purchase_task` / `return_match`(MySQL REPEATABLE-READ 下普通读看不到快照后的提交)。`recompute_mysql_integration_test.go` 在设置 `GOAUTO_IT_MYSQL_DSN` 时于临时库做双连接并发验证。 - Web:`syb-products/index.vue`(采购类型筛选、阶段自动切换、规则显示、按钮计数)、`syb-product-filters/index.vue`(命中/标记口径、重算预览与执行)、`syb-sync-runs/index.vue`(「其中本次规则命中 N 条」)。 + +## 采购规格面板揭示滑动无目标兼容(#348) + +实现绑定 `6550b9f`,Android 0.9.64 / versionCode 77;基于 main `7a2c2d1`,当前仅分支实现,未合并、未发布、未安装真机。 + +- `PurchaseUiDriver.swipePurchaseWithEvidence` 返回 `PurchaseSwipeOutcome`(结果枚举与可见可滚动节点数)。生产 `GoAutoAccessibilityService` 从同一个实时无障碍 root 统计并定位目标;SUCCESS、NO_SCROLLABLE_TARGET、ROOT_UNAVAILABLE、FAILED 明确区分。原 Boolean swipePurchase 返回语义、目标选择与 swipeNode 不变,不修改采集器。 +- `PurchaseRehearsalExecutor.applyPostAction` 仅在 openSpecPanel 的揭示滑动遇到 NO_SCROLLABLE_TARGET 时跳过剩余次数;保留 NON_SCROLLABLE_CONFIRMATION 原例外。其他动作仍走原 Boolean 必需滑动路径;root 不可用或有目标而手势失败仍 RULE_ACTION_FAILED。 +- 失败/跳过通过既有 panelDiagnostic 回调写 GoAutoPurchasePanel 结构日志,仅含面板枚举、scrollables 计数、reveal 布尔与固定结果原因;失败摘要带同样标量并沿原任务结果提交。没有节点文字、坐标、树或截图。 +- probeSpecs、selectSpec、最终复核、规则 JSON、能力、服务端、Admin、数据库、任务租约及下单/支付边界不变。无滑动不代表规格探测成功。 diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index d58090c..d859b8a 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: c0b1a842c79b4d1dba4c4435df106349e65e5eb1 -synchronized_at: 2026-09-29T08:16:48Z +wiki_revision: 5d9810286d76c52a90902c27a5316f39164f36e0 +synchronized_at: 2026-09-29T09:48:45Z @@ -489,6 +489,8 @@ synchronized_at: 2026-09-22T02:56:27Z ## 打开采购规格面板后按需滚动(#238) +> 历史分支版本说明:本节绑定的 `58a6c1c` 不在本次核验的 main `7a2c2d1` 祖先中,不能据此宣称当前 main 或已安装 APK 已取消全部预滑动。#348 的窄范围实现与版本边界见本页对应补充。 + - Android 0.9.60 / versionCode 73,代码 `58a6c1c` 起,规则 `openSpecPanel.swipeAfter` 保留格式校验与旧快照兼容,但不执行打开面板后的固定次数预滑动;不以“必须滑两次成功”作为进入规格探测/选择的条件。动作后的 `waitAfterMs` 仍生效。 - 首趟规格探测和第二趟精确选择仍使用各自既有的按需横向/纵向、有界与稳定终止策略。取消预滑动不等于不探测隐藏规格,也不等于只看首屏。目标不存在、歧义、页面证据不足或必要的有界查找失败时仍明确失败。 - 此调整覆盖所有已经安全识别打开的面板,不再仅特判 NON_SCROLLABLE_CONFIRMATION;不弱化面板验证、精确选中、地址、价格、任务租约、创建订单边界或禁止支付规则。 @@ -760,3 +762,9 @@ SYB 回填与资料完整性独立:正式SYB任务已有单号且仍关联有 - SYB 商品页新增「采购类型」筛选:需 PDD 采购(默认、重置后同)/ 无需 PDD 采购 / 全部,与处理阶段、创建时间等筛选同时生效;选择「退货待确认」「已用退货」「无需采购」阶段时自动切到「全部」,可手动改回。无需采购行显示命中规则,可勾选但只计入「匹配退货」,不显示单条「采购」。 - 管理员「按当前规则重算」:先预览(两个方向的变更数、因已有采购任务 / 有效退货匹配跳过的数量、最多 20 条样例)再确认执行。执行必须携带预览指纹,数据、规则或其证据变化时拒绝并提示重新预览,不静默执行另一批。执行时逐行加锁并以加锁读复查:该商品有任何采购任务或有效退货匹配则跳过;审计日志与返回数量按实际写入/跳过计。规则变更从不自动重算。 - 统计口径:同步记录与规则列表的「命中」是本次同步命中规则的次数(含已存在明细);「当前带此规则标记的明细 N 条」按数据库实际统计。两者不得混用。 + +## 采购规格面板揭示滑动无目标兼容(#348) + +Android 0.9.64 / versionCode 77,源码 `6550b9f`(分支实现,尚未安装/发布)。已安全打开规格面板后,规则 openSpecPanel.swipeAfter 的某次揭示滑动若没有任何可见可滚动目标,则无需滑动,跳过本动作剩余次数并记录诊断;不再因缺少滚动节点而失败。保留 NON_SCROLLABLE_CONFIRMATION 原跳过行为。 + +有可滚动目标却派发失败、被取消或等待失败,以及无障碍 root 不可用,仍明确失败;其他动作的必需滑动不放宽。有可滚动目标时保留规则 count、direction、durationMs、intervalMs 与 waitAfterMs。后续规格探测、精确选择与最终复核全部照常,缺规格或探测无结果仍失败,不猜测、不新增点击目标或支付。 diff --git a/docs/08-agent-api-contract.md b/docs/08-agent-api-contract.md index a64fdbf..8b85f9e 100644 --- a/docs/08-agent-api-contract.md +++ b/docs/08-agent-api-contract.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Android-Agent-API-Contract wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.- -wiki_revision: 92e9f8568c9c0da1d78159391c1545f09c4cec14 -synchronized_at: 2026-09-29T08:17:45Z +wiki_revision: 18260ec2725eaeb4dcf2543422d58ee4ede8b981 +synchronized_at: 2026-09-29T09:50:08Z @@ -1179,6 +1179,8 @@ Agent 携带既有 Token(可已失效)及恢复码重新调用注册接口 ### openSpecPanel 后置滑动兼容与诊断(#238) +> 历史分支版本说明:本节绑定的 `58a6c1c` 不在本次核验的 main `7a2c2d1` 祖先中,不能据此宣称当前 main 或已安装 APK 已取消全部预滑动。#348 的窄范围实现与版本边界见本页对应补充。 + 版本边界:Android 0.9.60 / versionCode 73,代码 `58a6c1c`。不修改 JSON schema、能力标识、任务接口或已有快照哈希。`openSpecPanel.swipeAfter` 仍按 direction/count/durationMs/intervalMs 原约束校验;解析成功后不执行该准备性滑动,`waitAfterMs` 保留。其他动作后置滑动沿用旧执行语义。旧 Server 可继续下发原快照;旧 APK 仍按原策略执行,不能将本契约描述当作旧设备已获得兼容。 规格探测与精确选择自行负责按需有界滚动,原始候选、精确点击和选中复核不变。跳过预滑动不作为规格探测成功或订单创建证据。 @@ -1438,3 +1440,11 @@ Web 批量入口冻结原匹配 ID 并顺序调用;HTTP 409 状态冲突跳过 - batch-delete 在既有阻塞状态基础上增加 matching,409 整批拒绝;避免删除正在匹配的记录。Android/采购/付款流程无变化。 实现绑定:`fix/350-inner-code-matching`,源码提交 `9c5e20e`;未合并 main、未部署,正式环境仍需发布后启用。 + +### openSpecPanel 无滚动目标兼容与诊断(#348) + +版本绑定 `6550b9f`,Android 0.9.64 / versionCode 77(分支实现,未合并、安装或发布)。本次没有 JSON schema、动作参数、能力标识、任务接口或规则快照哈希变化。旧 Server 继续下发相同快照;旧 APK 不因本文更新而改变行为。 + +仅 openSpecPanel.swipeAfter 的 NO_SCROLLABLE_TARGET 可跳过剩余揭示滑动;NON_SCROLLABLE_CONFIRMATION 原例外保持。存在目标而手势失败、窗口 root 不可用,以及其他动作的必需滑动失败,仍返回 RULE_ACTION_FAILED;后续 probeSpecs/selectSpec/最终复核不跳过。 + +既有本地 GoAutoPurchasePanel 回调增加 ruleSwipe 事件:outcome=skipped_confirmed_panel|skipped_no_scroll_target|failed;panel 为面板枚举,scrollables 为非负计数,reveal 为布尔,失败/无目标 reason=success|no_scrollable_target|root_unavailable|failed 中实际适用的固定值。失败 error message 增加这些标量,原 task/attempt/device/规则快照绑定与结果提交字段不变。不记录节点文本、规格、地址、订单、坐标、树、整屏截图或凭据。