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 7c296b1..641a085 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 @@ -184,6 +184,21 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto return swipeNode(target, direction, durationMs, preferScrollAction = false) } + override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean { + val root = rootInActiveWindow ?: return false + val matches = mutableListOf() + walk(root) { node -> + val bounds = Rect().also(node::getBoundsInScreen) + if (node.isVisibleToUser && node.isEnabled && node.isScrollable && + node.className?.toString() == target.className && + kotlin.math.abs(bounds.centerX() - target.bounds.centerX) <= 32 && + kotlin.math.abs(bounds.centerY() - target.bounds.centerY) <= 32 + ) matches += node + } + if (matches.size != 1) return false + return swipeNode(matches.single(), direction, durationMs, preferScrollAction = false) + } + override fun backPurchase(): Boolean = performGlobalAction(GLOBAL_ACTION_BACK) override fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode?): Boolean { diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseLiveAutomation.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseLiveAutomation.kt index c73b576..29b6db9 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseLiveAutomation.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseLiveAutomation.kt @@ -36,22 +36,32 @@ class PurchaseLiveAutomation( fun updateShippingAddress(addressSuffix: String): ShippingAddressProof { if (!addressSuffix.matches(Regex("^_cg[1-9][0-9]*$"))) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "采购任务的地址标记无效") var snapshot = driver.capture() - repeat(4) { attempt -> + var previousSignature: String? = null + var unchangedCount = 0 + for (attempt in 0 until 5) { pageProblem(snapshot) - val entries = uniqueClickable(snapshot, snapshot.nodes.filter { it.visible && MASKED_PHONE.containsMatchIn(it.label) }) + val entries = mergedClickable(snapshot, snapshot.nodes.filter { it.visible && MASKED_PHONE.containsMatchIn(it.label) }) if (entries.size == 1) { click(entries.single(), "收货地址入口") - snapshot = waitFor { it.nodes.any { node -> node.visible && node.label.replace(" ", "") == "收货地址" } } + snapshot = waitFor("PURCHASE_ADDRESS_PANEL_TIMEOUT", "收货地址页面打开超时,未创建订单") { + it.nodes.any { node -> node.visible && node.label.replace(" ", "") == "收货地址" } + } return editAndVerifyAddress(snapshot, addressSuffix) } - if (entries.size > 1) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "收货地址入口不唯一,未创建订单") - if (attempt < 3 && !driver.swipePurchase(SwipeDirection.DOWN, 350)) { - fail("PURCHASE_ADDRESS_UPDATE_FAILED", "没有找到收货地址入口,未创建订单") + if (entries.size > 1) fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "收货地址入口不唯一,未创建订单") + val panel = purchasePanelScrollTargets(snapshot) + if (panel.size != 1) fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "没有找到唯一的规格面板滚动区域,未创建订单") + val signature = viewportSignature(snapshot, panel.single()) + unchangedCount = if (signature == previousSignature) unchangedCount + 1 else 0 + if (unchangedCount >= 2 || attempt == 4) break + previousSignature = signature + if (!driver.swipePurchaseIn(panel.single(), SwipeDirection.DOWN, 350)) { + fail("PURCHASE_ADDRESS_PANEL_TIMEOUT", "规格面板无法下拉显示收货地址,未创建订单") } pause(500) snapshot = driver.capture() } - fail("PURCHASE_ADDRESS_UPDATE_FAILED", "没有找到收货地址入口,未创建订单") + fail("PURCHASE_ADDRESS_PANEL_TIMEOUT", "规格面板下拉后仍未找到收货地址,未创建订单") } fun finalConfirmation(input: PurchaseExecutionInput, address: ShippingAddressProof): FinalConfirmationEvidence { @@ -114,9 +124,11 @@ class PurchaseLiveAutomation( private fun editAndVerifyAddress(panel: UiSnapshot, suffix: String): ShippingAddressProof { val modify = addressModifyTargets(panel) - if (modify.size != 1) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "没有找到唯一的地址修改按钮,未创建订单") + if (modify.size != 1) fail("PURCHASE_ADDRESS_EDIT_AMBIGUOUS", "没有找到唯一的地址修改按钮,未创建订单") click(modify.single(), "修改地址") - val edit = waitFor { snapshot -> snapshot.nodes.any { it.visible && it.label.replace(" ", "").contains("详细地址") } } + val edit = waitFor("PURCHASE_ADDRESS_EDIT_TIMEOUT", "地址编辑页面打开超时,未创建订单") { snapshot -> + snapshot.nodes.any { it.visible && it.label.replace(" ", "").contains("详细地址") } + } val editors = edit.nodes.filter { it.visible && it.enabled && it.className?.endsWith("EditText") == true && it.label.isNotBlank() } if (editors.size != 1) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "没有找到唯一的详细地址输入框,未创建订单") val current = editors.single().label.trim() @@ -124,31 +136,53 @@ class PurchaseLiveAutomation( if (body.isBlank()) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "当前详细地址无效,未创建订单") val expected = body + suffix if (driver.inputFresh(editors.single(), expected) != FreshActionResult.SUCCESS) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "详细地址修改失败,未创建订单") - val stable = waitFor { snapshot -> snapshot.nodes.count { it.visible && it.enabled && it.className?.endsWith("EditText") == true && it.label == expected } == 1 } + val stable = waitFor("PURCHASE_ADDRESS_INPUT_MISMATCH", "详细地址修改后回读不一致,未创建订单") { snapshot -> + snapshot.nodes.count { it.visible && it.enabled && it.className?.endsWith("EditText") == true && it.label == expected } == 1 + } val save = uniqueClickable(stable, stable.nodes.filter { it.visible && it.enabled && it.label == "保存" }) if (save.size != 1) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "地址保存按钮不唯一,未创建订单") click(save.single(), "保存地址") - var saved = waitFor { snapshot -> + var saved = waitFor("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存超时,未创建订单") { snapshot -> snapshot.nodes.any { it.visible && it.label.contains(expected) } || finalSubmitTargets(snapshot).size == 1 } if (finalSubmitTargets(saved).size != 1) { if (!driver.backPurchase()) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "地址保存后无法返回订单页面,未创建订单") - saved = waitFor { finalSubmitTargets(it).size == 1 } + saved = waitFor("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后无法返回订单页面,未创建订单") { finalSubmitTargets(it).size == 1 } } if (saved.nodes.none { it.visible && it.label.contains(expected) }) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "地址保存后回读不一致,未创建订单") return ShippingAddressProof(expected, suffix) } - private fun waitFor(predicate: (UiSnapshot) -> Boolean): UiSnapshot { + private fun waitFor(code: String, message: String, predicate: (UiSnapshot) -> Boolean): UiSnapshot { repeat(50) { val snapshot = driver.capture() pageProblem(snapshot) if (predicate(snapshot)) return snapshot pause(200) } - fail("PURCHASE_ADDRESS_UPDATE_FAILED", "页面切换超时,未创建订单") + fail(code, message) } + private fun purchasePanelScrollTargets(snapshot: UiSnapshot): List { + val screenWidth = snapshot.nodes.maxOfOrNull { it.bounds.right }?.coerceAtLeast(1) ?: 1 + val screenHeight = snapshot.nodes.maxOfOrNull { it.bounds.bottom }?.coerceAtLeast(1) ?: 1 + return snapshot.nodes.filter { node -> + node.visible && node.enabled && node.scrollable && + node.bounds.height >= node.bounds.width && + node.bounds.width * 100 >= screenWidth * 60 && + node.bounds.height * 100 >= screenHeight * 20 + }.distinctBy { it.path } + } + + private fun viewportSignature(snapshot: UiSnapshot, panel: SnapshotNode): String = snapshot.nodes + .filter { node -> node.visible && inside(node.bounds, panel.bounds) } + .joinToString("|") { node -> + listOf(node.className.orEmpty(), node.bounds.left, node.bounds.top, node.bounds.right, node.bounds.bottom, node.clickable, node.scrollable).joinToString(":") + } + + private fun inside(child: NodeBounds, parent: NodeBounds): Boolean = + child.centerX in parent.left..parent.right && child.centerY in parent.top..parent.bottom + private fun uniqueClickable(snapshot: UiSnapshot, nodes: List): List { val byPath = snapshot.nodes.associateBy { it.path } return nodes.mapNotNull { source -> @@ -158,6 +192,23 @@ class PurchaseLiveAutomation( }.distinctBy { it.path } } + private fun mergedClickable(snapshot: UiSnapshot, nodes: List): List { + val candidates = uniqueClickable(snapshot, nodes) + val merged = mutableListOf() + candidates.sortedBy { it.bounds.width.toLong() * it.bounds.height }.forEach { candidate -> + if (merged.none { existing -> nearDuplicate(existing.bounds, candidate.bounds) }) merged += candidate + } + return merged + } + + private fun nearDuplicate(first: NodeBounds, second: NodeBounds): Boolean { + val overlapWidth = (minOf(first.right, second.right) - maxOf(first.left, second.left)).coerceAtLeast(0) + val overlapHeight = (minOf(first.bottom, second.bottom) - maxOf(first.top, second.top)).coerceAtLeast(0) + val overlap = overlapWidth.toLong() * overlapHeight + val smaller = minOf(first.width.toLong() * first.height, second.width.toLong() * second.height) + return smaller > 0 && overlap * 100 >= smaller * 98 + } + private fun addressModifyTargets(snapshot: UiSnapshot): List { val all = uniqueClickable(snapshot, snapshot.nodes.filter { it.visible && it.label == "修改" }) if (all.size <= 1) return all 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 250d08e..cf01b6a 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 @@ -8,6 +8,7 @@ interface PurchaseUiDriver { fun clickFresh(target: SnapshotNode): FreshActionResult fun inputFresh(target: SnapshotNode, value: String): FreshActionResult fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean + fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean fun backPurchase(): Boolean } diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseLiveAutomationTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseLiveAutomationTest.kt index 3b0c68b..072449c 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseLiveAutomationTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseLiveAutomationTest.kt @@ -47,6 +47,27 @@ class PurchaseLiveAutomationTest { assertTrue(runCatching { PurchaseRuleParser.parse(LIVE_RULE.replace("readOrderResult", "pay")) }.isFailure) } + @Test + fun `clipped address is revealed by swiping the unique purchase panel`() { + val driver = LiveDriver(addressClipped = true) + val address = PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg12") + + assertEquals("广东省广州市天园街道骏景花园骏晖轩1202_cg12", address.expectedAddress) + assertEquals(1, driver.scopedSwipes) + assertEquals(0, driver.genericSwipes) + } + + @Test + fun `ambiguous purchase panels fail without swiping or clicking`() { + val driver = LiveDriver(addressClipped = true, duplicatePanels = true) + val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg13") } + .exceptionOrNull() as PurchaseLiveException + + assertEquals("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", error.code) + assertEquals(0, driver.scopedSwipes) + assertTrue(driver.clicked.isEmpty()) + } + private fun input() = PurchaseExecutionInput( taskId = 11, executionMode = "live", @@ -61,21 +82,36 @@ class PurchaseLiveAutomationTest { addressSuffix = "_cg11", ) - private class LiveDriver : PurchaseUiDriver { + private class LiveDriver( + private val addressClipped: Boolean = false, + private val duplicatePanels: Boolean = false, + ) : PurchaseUiDriver { private var page = "confirmation" + private var addressVisible = !addressClipped private var address = "广东省广州市天园街道骏景花园骏晖轩1202-old" val clicked = mutableListOf() var submitClicks = 0 + var scopedSwipes = 0 + var genericSwipes = 0 override fun capture(): UiSnapshot = when (page) { "panel" -> snapshot(listOf(node("title", "收货地址"), node("modify", "修改", clickable = true), node("address", address))) "edit" -> snapshot(listOf(node("title", "修改收货地址"), node("detail", "详细地址"), node("editor", address, className = "android.widget.EditText"), node("save", "保存", clickable = true))) "order" -> snapshot(listOf(node("status", "待付款"), node("order", "订单号:PDD-202608210001"), node("time", "下单时间:2026-08-21 10:30:00"), node("pay", "立即支付", clickable = true))) - else -> snapshot(listOf( - node("phone", "138****5678", clickable = true), node("address", address), - node("price", "¥20.00"), node("selected", "已选 黑色 XL"), - node("quantity", "2", className = "android.widget.EditText"), node("submit", "提交订单", clickable = true), - )) + else -> { + val nodes = mutableListOf( + node("root", "", bounds = NodeBounds(0, 0, 1080, 2200)), + node("panel", "", scrollable = true, bounds = NodeBounds(0, 400, 1080, 2100)), + node("price", "¥20.00"), node("selected", "已选 黑色 XL"), + node("quantity", "2", className = "android.widget.EditText"), node("submit", "提交订单", clickable = true), + ) + if (duplicatePanels) nodes += node("panel2", "", scrollable = true, bounds = NodeBounds(0, 500, 1080, 2000)) + if (addressVisible) { + nodes += node("phone", "138****5678", clickable = true) + nodes += node("address", address) + } + snapshot(nodes) + } } override fun clickFresh(target: SnapshotNode): FreshActionResult { @@ -90,12 +126,23 @@ class PurchaseLiveAutomationTest { } override fun inputFresh(target: SnapshotNode, value: String): FreshActionResult { address = value; return FreshActionResult.SUCCESS } - override fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean = true + override fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean { genericSwipes++; return true } + override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean { + scopedSwipes++ + if (target.path == "panel" && direction == SwipeDirection.DOWN) addressVisible = true + return true + } override fun backPurchase(): Boolean { page = "confirmation"; return true } private fun snapshot(nodes: List) = UiSnapshot(PDD, ACTIVITY, nodes) - private fun node(path: String, text: String, clickable: Boolean = false, className: String = "android.widget.TextView") = - SnapshotNode(path, null, text, null, null, className, NodeBounds(0, 0, 500, 100), clickable, false, false, false, true, true) + private fun node( + path: String, + text: String, + clickable: Boolean = false, + scrollable: Boolean = false, + className: String = "android.widget.TextView", + bounds: NodeBounds = NodeBounds(0, 0, 500, 100), + ) = SnapshotNode(path, null, text, null, null, className, bounds, clickable, scrollable, false, false, true, true) } private companion object { 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 96cff7c..6173ccd 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 @@ -298,6 +298,9 @@ class PurchaseRehearsalExecutorTest { return true } + override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean = + swipePurchase(direction, durationMs) + override fun backPurchase(): Boolean = true private fun node( diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index 2a21953..0accaaa 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: e4ecce2abf9a5ac210522747ccadee110aec0aa1 -synchronized_at: 2026-08-22T01:05:28Z +wiki_revision: 78583c8528b4157a90b1fe221fa39288480cb90a +synchronized_at: 2026-08-22T01:30:27Z # 业务规则与术语 @@ -118,7 +118,7 @@ synchronized_at: 2026-08-22T01:05:28Z - Admin 从 SYB 当前页选择商品,可在“创建采购”和“重新解析”两种批量用途间切换;采购模式只允许选择预检合格行,表头全选不跨页。批量预检和正式创建均最多 100 条并逐条重新校验,每条 SYB 明细只创建一个独立任务;部分失败不回滚其他成功项。 - 批量创建使用服务端内置并经契约校验的正式采购规则,每条任务保存不可变快照;设备默认人工指定,也可以留空由符合能力的空闲设备领取。 - 批量创建的价格保护来自 PDD 商品档案而不是 SYB/Shopee 的 TWD 售价:已确认颜色映射时按该颜色人民币价格计算;待规格探测时用可用颜色最低价的 0.2 倍到最高价的 1.5 倍,参考价取最高价。没有可用颜色价格时不能创建。 -- 地址后缀为 `_cg{purchase_task.id}`。Agent 只在唯一地址入口、唯一修改按钮和唯一详细地址输入框均成立时修改;按首个 `-` 或 `_` 截取地址主体后追加当前后缀,保存后必须回读完整新地址。修改或回读失败时禁止创建订单,地址全文和控件树不落库。 +- 地址后缀为 `_cg{purchase_task.id}`。选好规格和数量后,Agent 仍停留在当前规格/下单面板;若收货地址被面板裁切,只允许在唯一、可见且占据主要宽度的纵向滚动容器内有限向下拉动来显示地址,禁止按页面最大滚动区域盲目滑动。Agent 只在唯一地址入口、唯一修改按钮和唯一详细地址输入框均成立时修改;近乎完全重叠的无障碍重复节点按一个目标处理,仍有多个独立目标或页面切换超时则明确失败。按首个 `-` 或 `_` 截取地址主体后追加当前后缀,保存后必须回读完整新地址。修改或回读失败时禁止创建订单,地址全文和控件树不落库。 - 点击创建订单前,Agent 必须先在本地事务保存 `order_submit_started`、不可逆时间、稳定请求 ID 和不含地址全文的最终确认快照,再用同一请求 ID通知服务端;两侧成功后才允许精确点击唯一创建订单按钮一次。 - 进入不可逆边界后,进程重启、断网、点击结果不明或无法取得唯一订单号/下单时间时只允许只读核单并进入 `order_result_unknown`,禁止再次点击;任务与订单正式关联仍以完整 PDD 订单号为准。 - Agent 本地 SQLite/Outbox 负责断网和重启恢复,服务端以 `task_id + task_attempt_id` 幂等接收并保存最终事实。 diff --git a/docs/08-agent-api-contract.md b/docs/08-agent-api-contract.md index 300aa3c..d9f6d5b 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: e4ecce2abf9a5ac210522747ccadee110aec0aa1 -synchronized_at: 2026-08-22T01:05:28Z +wiki_revision: 78583c8528b4157a90b1fe221fa39288480cb90a +synchronized_at: 2026-08-22T01:30:27Z # MVP 共享 API 契约 @@ -509,7 +509,7 @@ Admin 列表与详情由 #35 实现;#44 在 SYB 商品列表提供单条/当 Android #42/#36 使用本地 SQLite 保存恢复与重传所需的任务、attempt 和 Outbox;最终结果和 Outbox 在同一事务写入。演练执行器仍在安全点停止。Agent 0.2.0 另外声明 `purchase.live.v1`、`purchase.address-update.v1` 和 `purchase.order-create.v1`,仅在 `live` 规则同时包含受限 `updateShippingAddress`、`createOrder`、`readOrderResult` 动作时启用。 -正式地址动作通过脱敏手机号结构定位唯一地址入口,精确选择唯一修改按钮和详细地址输入框;按首个 `-` 或 `_` 截断旧后缀,追加任务 `addressSuffix`,保存并回读完整新地址。地址全文只在本次执行内存中存在,不进入本地最终确认快照、日志或服务端。 +正式地址动作在已经选好规格、数量的当前规格/下单面板内执行,不额外点击进入确认页。若脱敏手机号地址入口被裁切,Agent 只在唯一、可见且占据主要屏幕宽度的纵向面板容器内执行有限向下拉动,每次重新读取页面;不得退化为选择页面最大滚动区域。地址入口的近乎完全重叠无障碍重复节点合并为一个目标,多个独立目标、多个候选面板、滚动无进展或页面切换超时均以分阶段错误结束并禁止创建订单。入口出现后,Agent 精确选择唯一修改按钮和详细地址输入框;按首个 `-` 或 `_` 截断旧后缀,追加任务 `addressSuffix`,保存并回读完整新地址。地址全文只在本次执行内存中存在,不进入本地最终确认快照、日志或服务端。 创建订单前先在本地事务保存 `order_submit_started`、不可逆时间、稳定的 `orderSubmitRequestId` 和脱敏最终确认快照,再调用服务端同名接口;只有两侧标记完成并重新校验商品、规格、数量、单价、地址后缀、PDD 包/Activity 与唯一创建订单按钮后,才点击一次。重启时重放同一标记请求并只读核单;无法取得唯一未付款订单号和 PDD 下单时间时提交 `order_result_unknown`。支付文字仅用于识别未付款/离开支付页,永不点击。服务端数据库仍是最终事实来源;双方均不保存原始控件树、截图、PDD 凭据或完整收货地址。