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 641a085..e68a139 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 @@ -149,6 +149,52 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto return if (node.performAction(AccessibilityNodeInfo.ACTION_CLICK)) FreshActionResult.SUCCESS else FreshActionResult.FAILED } + override fun tapPurchaseFresh(target: SnapshotNode): FreshActionResult { + val root = rootInActiveWindow ?: return FreshActionResult.NOT_FOUND + val candidates = mutableListOf() + walk(root) { node -> + val ownLabel = (node.text ?: node.contentDescription)?.toString()?.trim().orEmpty() + val bounds = Rect().also(node::getBoundsInScreen) + if (ownLabel == target.label && + node.className?.toString() == target.className && + node.isVisibleToUser && node.isEnabled && + kotlin.math.abs(bounds.centerX() - target.bounds.centerX) <= 32 && + kotlin.math.abs(bounds.centerY() - target.bounds.centerY) <= 32 + ) candidates += node + } + if (candidates.isEmpty()) return FreshActionResult.NOT_FOUND + if (candidates.size != 1) return FreshActionResult.AMBIGUOUS + 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 + } + val path = Path().apply { + moveTo(bounds.centerX().toFloat(), bounds.centerY().toFloat()) + } + val completed = AtomicBoolean(false) + val latch = CountDownLatch(1) + val queued = dispatchGesture( + GestureDescription.Builder().addStroke(GestureDescription.StrokeDescription(path, 0, 80)).build(), + object : GestureResultCallback() { + override fun onCompleted(gestureDescription: GestureDescription?) { + completed.set(true) + latch.countDown() + } + + override fun onCancelled(gestureDescription: GestureDescription?) { + latch.countDown() + } + }, + null, + ) + if (!queued) return FreshActionResult.FAILED + return if (latch.await(1_500, TimeUnit.MILLISECONDS) && completed.get()) { + FreshActionResult.SUCCESS + } else { + FreshActionResult.FAILED + } + } + override fun inputFresh(target: SnapshotNode, value: String): FreshActionResult { val root = rootInActiveWindow ?: return FreshActionResult.NOT_FOUND val candidates = mutableListOf() 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 29b6db9..cc47842 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 @@ -40,9 +40,14 @@ class PurchaseLiveAutomation( var unchangedCount = 0 for (attempt in 0 until 5) { pageProblem(snapshot) - val entries = mergedClickable(snapshot, snapshot.nodes.filter { it.visible && MASKED_PHONE.containsMatchIn(it.label) }) + val entries = mergedDirect(snapshot.nodes.filter { it.visible && it.enabled && MASKED_PHONE.containsMatchIn(it.label) }) if (entries.size == 1) { - click(entries.single(), "收货地址入口") + when (driver.tapPurchaseFresh(entries.single())) { + FreshActionResult.SUCCESS -> Unit + FreshActionResult.AMBIGUOUS -> fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "收货地址入口不唯一,未创建订单") + else -> fail("PURCHASE_ADDRESS_ENTRY_NOT_READY", "收货地址入口点击失败,未创建订单") + } + pause(1_000) snapshot = waitFor("PURCHASE_ADDRESS_PANEL_TIMEOUT", "收货地址页面打开超时,未创建订单") { it.nodes.any { node -> node.visible && node.label.replace(" ", "") == "收货地址" } } @@ -192,8 +197,8 @@ class PurchaseLiveAutomation( }.distinctBy { it.path } } - private fun mergedClickable(snapshot: UiSnapshot, nodes: List): List { - val candidates = uniqueClickable(snapshot, nodes) + private fun mergedDirect(nodes: List): List { + val candidates = nodes.distinctBy { it.path } 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 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 cf01b6a..f0678c6 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 @@ -6,6 +6,7 @@ import java.net.URLDecoder interface PurchaseUiDriver { fun capture(): UiSnapshot fun clickFresh(target: SnapshotNode): FreshActionResult + fun tapPurchaseFresh(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 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 072449c..e2a2d7c 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 @@ -22,6 +22,7 @@ class PurchaseLiveAutomationTest { val automation = PurchaseLiveAutomation(driver, pause = {}) val address = automation.updateShippingAddress("_cg11") assertEquals("广东省广州市天园街道骏景花园骏晖轩1202_cg11", address.expectedAddress) + assertEquals(1, driver.addressTaps) val final = automation.finalConfirmation(input(), address) assertEquals("_cg11", final.addressSuffix) @@ -93,6 +94,7 @@ class PurchaseLiveAutomationTest { var submitClicks = 0 var scopedSwipes = 0 var genericSwipes = 0 + var addressTaps = 0 override fun capture(): UiSnapshot = when (page) { "panel" -> snapshot(listOf(node("title", "收货地址"), node("modify", "修改", clickable = true), node("address", address))) @@ -107,7 +109,7 @@ class PurchaseLiveAutomationTest { ) if (duplicatePanels) nodes += node("panel2", "", scrollable = true, bounds = NodeBounds(0, 500, 1080, 2000)) if (addressVisible) { - nodes += node("phone", "138****5678", clickable = true) + nodes += node("phone", "138****5678") nodes += node("address", address) } snapshot(nodes) @@ -125,6 +127,13 @@ class PurchaseLiveAutomationTest { return FreshActionResult.SUCCESS } + override fun tapPurchaseFresh(target: SnapshotNode): FreshActionResult { + addressTaps++ + clicked += target.label + if (target.label == "138****5678") page = "panel" + return FreshActionResult.SUCCESS + } + override fun inputFresh(target: SnapshotNode, value: String): FreshActionResult { address = value; return FreshActionResult.SUCCESS } override fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean { genericSwipes++; return true } override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean { 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 6173ccd..b22ea80 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 @@ -287,6 +287,8 @@ class PurchaseRehearsalExecutorTest { return FreshActionResult.SUCCESS } + override fun tapPurchaseFresh(target: SnapshotNode): FreshActionResult = clickFresh(target) + override fun inputFresh(target: SnapshotNode, value: String): FreshActionResult { quantity = value.toLong() return FreshActionResult.SUCCESS diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index 0accaaa..50c2f21 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: 78583c8528b4157a90b1fe221fa39288480cb90a -synchronized_at: 2026-08-22T01:30:27Z +wiki_revision: c73a9813f171311b94c43ae737830cd6fb270ee3 +synchronized_at: 2026-08-22T01:45:17Z # 业务规则与术语 @@ -118,7 +118,7 @@ synchronized_at: 2026-08-22T01:30:27Z - Admin 从 SYB 当前页选择商品,可在“创建采购”和“重新解析”两种批量用途间切换;采购模式只允许选择预检合格行,表头全选不跨页。批量预检和正式创建均最多 100 条并逐条重新校验,每条 SYB 明细只创建一个独立任务;部分失败不回滚其他成功项。 - 批量创建使用服务端内置并经契约校验的正式采购规则,每条任务保存不可变快照;设备默认人工指定,也可以留空由符合能力的空闲设备领取。 - 批量创建的价格保护来自 PDD 商品档案而不是 SYB/Shopee 的 TWD 售价:已确认颜色映射时按该颜色人民币价格计算;待规格探测时用可用颜色最低价的 0.2 倍到最高价的 1.5 倍,参考价取最高价。没有可用颜色价格时不能创建。 -- 地址后缀为 `_cg{purchase_task.id}`。选好规格和数量后,Agent 仍停留在当前规格/下单面板;若收货地址被面板裁切,只允许在唯一、可见且占据主要宽度的纵向滚动容器内有限向下拉动来显示地址,禁止按页面最大滚动区域盲目滑动。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 d9f6d5b..e2470df 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: 78583c8528b4157a90b1fe221fa39288480cb90a -synchronized_at: 2026-08-22T01:30:27Z +wiki_revision: c73a9813f171311b94c43ae737830cd6fb270ee3 +synchronized_at: 2026-08-22T01:45:17Z # 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` 动作时启用。 -正式地址动作在已经选好规格、数量的当前规格/下单面板内执行,不额外点击进入确认页。若脱敏手机号地址入口被裁切,Agent 只在唯一、可见且占据主要屏幕宽度的纵向面板容器内执行有限向下拉动,每次重新读取页面;不得退化为选择页面最大滚动区域。地址入口的近乎完全重叠无障碍重复节点合并为一个目标,多个独立目标、多个候选面板、滚动无进展或页面切换超时均以分阶段错误结束并禁止创建订单。入口出现后,Agent 精确选择唯一修改按钮和详细地址输入框;按首个 `-` 或 `_` 截断旧后缀,追加任务 `addressSuffix`,保存并回读完整新地址。地址全文只在本次执行内存中存在,不进入本地最终确认快照、日志或服务端。 +正式地址动作在已经选好规格、数量的当前规格/下单面板内执行,不额外点击进入确认页。若脱敏手机号地址入口被裁切,Agent 只在唯一、可见且占据主要屏幕宽度的纵向面板容器内执行有限向下拉动,每次重新读取页面;不得退化为选择页面最大滚动区域。地址入口通过唯一脱敏手机号文字节点确认并在每次动作前重新定位;即使文字节点不可点击,也只允许在该文字节点中心执行一次精确手势,禁止向上查找并点击可能覆盖整个下单面板的宽泛可点击祖先。近乎完全重叠无障碍重复节点合并为一个目标,多个独立目标、多个候选面板、滚动无进展或页面切换超时均以分阶段错误结束并禁止创建订单。入口出现后,Agent 精确选择唯一修改按钮和详细地址输入框;按首个 `-` 或 `_` 截断旧后缀,追加任务 `addressSuffix`,保存并回读完整新地址。地址全文只在本次执行内存中存在,不进入本地最终确认快照、日志或服务端。 创建订单前先在本地事务保存 `order_submit_started`、不可逆时间、稳定的 `orderSubmitRequestId` 和脱敏最终确认快照,再调用服务端同名接口;只有两侧标记完成并重新校验商品、规格、数量、单价、地址后缀、PDD 包/Activity 与唯一创建订单按钮后,才点击一次。重启时重放同一标记请求并只读核单;无法取得唯一未付款订单号和 PDD 下单时间时提交 `order_result_unknown`。支付文字仅用于识别未付款/离开支付页,永不点击。服务端数据库仍是最终事实来源;双方均不保存原始控件树、截图、PDD 凭据或完整收货地址。