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 4ebef03..0969c2a 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 @@ -134,7 +134,7 @@ class PurchaseLiveAutomation( 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() } + val editors = shippingAddressEditors(edit) if (editors.size != 1) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "没有找到唯一的详细地址输入框,未创建订单") val current = editors.single().label.trim() val body = current.split(Regex("[-_]"), limit = 2).firstOrNull()?.trim().orEmpty() @@ -231,6 +231,21 @@ class PurchaseLiveAutomation( return emptyList() } + private fun shippingAddressEditors(snapshot: UiSnapshot): List { + val labels = snapshot.nodes.filter { node -> + node.visible && node.label.replace(" ", "").contains("详细地址") + } + if (labels.isEmpty()) return emptyList() + return snapshot.nodes.filter { editor -> + editor.visible && editor.enabled && editor.className?.endsWith("EditText") == true && editor.label.isNotBlank() && + labels.any { label -> + editor.bounds.top <= label.bounds.bottom && + editor.bounds.bottom >= label.bounds.top && + editor.bounds.left >= label.bounds.left + } + }.distinctBy { it.path } + } + private fun click(node: SnapshotNode, label: String) { when (driver.clickFresh(node)) { FreshActionResult.SUCCESS -> Unit 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 18bb2f4..e80774e 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 @@ -23,6 +23,7 @@ class PurchaseLiveAutomationTest { val address = automation.updateShippingAddress("_cg11") assertEquals("广东省广州市天园街道骏景花园骏晖轩1202_cg11", address.expectedAddress) assertEquals(1, driver.addressTaps) + assertEquals("editor-address", driver.lastInputTargetPath) val final = automation.finalConfirmation(input(), address) assertEquals("_cg11", final.addressSuffix) @@ -95,6 +96,7 @@ class PurchaseLiveAutomationTest { var scopedSwipes = 0 var genericSwipes = 0 var addressTaps = 0 + var lastInputTargetPath: String? = null override fun capture(): UiSnapshot = when (page) { "panel" -> snapshot(listOf( @@ -104,8 +106,13 @@ class PurchaseLiveAutomationTest { node("address", address), )) "edit" -> snapshot(listOf( - node("title", "修改收货地址"), node("detail", "详细地址"), - node("editor", address, className = "android.widget.EditText"), + node("title", "修改收货地址"), + node("recipient-label", "收货人", bounds = NodeBounds(20, 200, 160, 260)), + node("editor-recipient", "测试收货人", className = "android.widget.EditText", bounds = NodeBounds(180, 190, 900, 270)), + node("phone-label", "手机号", bounds = NodeBounds(20, 300, 160, 360)), + node("editor-phone", "13800000000", className = "android.widget.EditText", bounds = NodeBounds(180, 290, 900, 370)), + node("detail", "详细地址", bounds = NodeBounds(20, 400, 160, 460)), + node("editor-address", address, className = "android.widget.EditText", bounds = NodeBounds(180, 390, 900, 480)), node("save-parent", "", clickable = true), node("save", "保存", parentPath = "save-parent"), )) "order" -> snapshot(listOf(node("status", "待付款"), node("order", "订单号:PDD-202608210001"), node("time", "下单时间:2026-08-21 10:30:00"), node("pay", "立即支付", clickable = true))) @@ -144,7 +151,11 @@ class PurchaseLiveAutomationTest { return FreshActionResult.SUCCESS } - override fun inputFresh(target: SnapshotNode, value: String): FreshActionResult { address = value; return FreshActionResult.SUCCESS } + override fun inputFresh(target: SnapshotNode, value: String): FreshActionResult { + lastInputTargetPath = target.path + 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 { scopedSwipes++ diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index be8d103..1a6afc9 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: 1a159d83d38175ce2d66b9b1c11ae300b88f4a10 -synchronized_at: 2026-08-22T01:51:38Z +wiki_revision: 9bfa80734dd6ce68d985e4ba1ad17025bc17b28b +synchronized_at: 2026-08-22T02:01:08Z # 业务规则与术语 @@ -119,6 +119,7 @@ synchronized_at: 2026-08-22T01:51:38Z - 批量创建使用服务端内置并经契约校验的正式采购规则,每条任务保存不可变快照;设备默认人工指定,也可以留空由符合能力的空闲设备领取。 - 批量创建的价格保护来自 PDD 商品档案而不是 SYB/Shopee 的 TWD 售价:已确认颜色映射时按该颜色人民币价格计算;待规格探测时用可用颜色最低价的 0.2 倍到最高价的 1.5 倍,参考价取最高价。没有可用颜色价格时不能创建。 - 地址后缀为 `_cg{purchase_task.id}`。选好规格和数量后,Agent 仍停留在当前规格/下单面板;若收货地址被面板裁切,只允许在唯一、可见且占据主要宽度的纵向滚动容器内有限向下拉动来显示地址,禁止按页面最大滚动区域盲目滑动。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 0849120..76fc483 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: 1a159d83d38175ce2d66b9b1c11ae300b88f4a10 -synchronized_at: 2026-08-22T01:51:38Z +wiki_revision: 9bfa80734dd6ce68d985e4ba1ad17025bc17b28b +synchronized_at: 2026-08-22T02:01:08Z # MVP 共享 API 契约 @@ -511,6 +511,8 @@ Android #42/#36 使用本地 SQLite 保存恢复与重传所需的任务、attem 正式地址动作在已经选好规格、数量的当前规格/下单面板内执行,不额外点击进入确认页。若脱敏手机号地址入口被裁切,Agent 只在唯一、可见且占据主要屏幕宽度的纵向面板容器内执行有限向下拉动,每次重新读取页面;不得退化为选择页面最大滚动区域。地址入口通过唯一脱敏手机号文字节点确认并在每次动作前重新定位;即使文字节点不可点击,也只允许在该文字节点中心执行一次精确手势,禁止向上查找并点击可能覆盖整个下单面板的宽泛可点击祖先。近乎完全重叠无障碍重复节点合并为一个目标,多个独立目标、多个候选面板、滚动无进展或页面切换超时均以分阶段错误结束并禁止创建订单。入口出现后,Agent 精确选择唯一修改按钮和详细地址输入框;“修改”“保存”和“提交订单”等文字节点即使要沿父链执行点击,也始终保留原始文字、类名和位置作为新鲜定位锚点,可点击父节点只用于验证和最终动作,禁止用无文字父节点替换锚点。按首个 `-` 或 `_` 截断旧后缀,追加任务 `addressSuffix`,保存并回读完整新地址。地址全文只在本次执行内存中存在,不进入本地最终确认快照、日志或服务端。 +地址编辑页存在多个输入框时,以“详细地址”标签为结构锚点,只接受与标签纵向相交且位于标签右侧的唯一、可见、启用、非空输入框;不能要求页面只有一个输入框,也不能按输入框顺序猜测。 + 创建订单前先在本地事务保存 `order_submit_started`、不可逆时间、稳定的 `orderSubmitRequestId` 和脱敏最终确认快照,再调用服务端同名接口;只有两侧标记完成并重新校验商品、规格、数量、单价、地址后缀、PDD 包/Activity 与唯一创建订单按钮后,才点击一次。重启时重放同一标记请求并只读核单;无法取得唯一未付款订单号和 PDD 下单时间时提交 `order_result_unknown`。支付文字仅用于识别未付款/离开支付页,永不点击。服务端数据库仍是最终事实来源;双方均不保存原始控件树、截图、PDD 凭据或完整收货地址。 | 错误码 | 普通提示 |