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 0969c2a..605a596 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 @@ -110,15 +110,26 @@ class PurchaseLiveAutomation( fun readOrderResult(): PurchaseOrderEvidence? { val labels = linkedSetOf() var backedOutOfPayment = false + var backedOutOfChooser = false repeat(60) { index -> val snapshot = driver.capture() - snapshot.nodes.filter { it.visible }.mapTo(labels) { it.label } + val currentLabels = snapshot.nodes.filter { it.visible }.map { it.label } + currentLabels.forEach(labels::add) + if (isKnownAndroidWechatChooser(snapshot, currentLabels)) { + if (backedOutOfChooser || !driver.backPurchase()) return null + backedOutOfChooser = true + pause(500) + return@repeat + } parseOrderEvidence(labels)?.let { return it } - val paymentVisible = labels.any { label -> PAYMENT_MARKERS.any(label::contains) } + val paymentVisible = isKnownPddPaymentActivity(snapshot) || + currentLabels.any { label -> PAYMENT_MARKERS.any(label::contains) } if (paymentVisible && !backedOutOfPayment) { backedOutOfPayment = true - driver.backPurchase() + if (!driver.backPurchase()) return null pause(500) + } else if (paymentVisible) { + return null } else if (index > 0 && index % 15 == 0) { driver.swipePurchase(SwipeDirection.UP, 400) } @@ -127,6 +138,19 @@ class PurchaseLiveAutomation( return null } + private fun isKnownAndroidWechatChooser(snapshot: UiSnapshot, labels: Collection): Boolean { + val packageKnown = snapshot.packageName in ANDROID_CHOOSER_PACKAGES + val activity = snapshot.activityName.orEmpty() + val activityKnown = activity in ANDROID_CHOOSER_ACTIVITIES || + ANDROID_CHOOSER_ACTIVITY_SUFFIXES.any(activity::endsWith) + val titleKnown = labels.any { it in ANDROID_CHOOSER_TITLES } + val wechatCandidate = labels.any { it.startsWith("微信") } + return packageKnown && activityKnown && titleKnown && wechatCandidate + } + + private fun isKnownPddPaymentActivity(snapshot: UiSnapshot): Boolean = + snapshot.packageName == PDD_PACKAGE && snapshot.activityName in PDD_PAYMENT_ACTIVITIES + private fun editAndVerifyAddress(panel: UiSnapshot, suffix: String): ShippingAddressProof { val modify = addressModifyTargets(panel) if (modify.size != 1) fail("PURCHASE_ADDRESS_EDIT_AMBIGUOUS", "没有找到唯一的地址修改按钮,未创建订单") @@ -289,5 +313,13 @@ class PurchaseLiveAutomation( val UNPAID_MARKERS = listOf("待付款", "待支付", "去支付") val ORDER_NO = Regex("(?:订单编号|订单号)\\s*[::]?\\s*([A-Za-z0-9-]{6,64})") val ORDER_TIME = Regex("(?:下单时间|创建时间)\\s*[::]?\\s*(20[0-9]{2}[-/.年][0-9]{1,2}[-/.月][0-9]{1,2}日?\\s+[0-9]{1,2}:[0-9]{2}(?::[0-9]{2})?)") + val ANDROID_CHOOSER_PACKAGES = setOf("android", "com.android.intentresolver") + val ANDROID_CHOOSER_ACTIVITIES = setOf( + "com.android.internal.app.ChooserActivity", + "com.android.internal.app.ResolverActivity", + ) + val ANDROID_CHOOSER_ACTIVITY_SUFFIXES = listOf(".ChooserActivity", ".ResolverActivity") + val ANDROID_CHOOSER_TITLES = setOf("选择要使用的应用", "使用以下应用打开", "完成操作时使用") + val PDD_PAYMENT_ACTIVITIES = setOf("com.xunmeng.pinduoduo.app_pay.core.PayActivity") } } 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 e80774e..d4a5310 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 @@ -49,6 +49,33 @@ class PurchaseLiveAutomationTest { assertTrue(runCatching { PurchaseRuleParser.parse(LIVE_RULE.replace("readOrderResult", "pay")) }.isFailure) } + @Test + fun `known Android WeChat chooser and PDD payment page are backed out once for read only reconciliation`() { + val driver = LiveDriver(chooserAfterSubmit = true) + val automation = PurchaseLiveAutomation(driver, pause = {}) + val address = automation.updateShippingAddress("_cg14") + automation.finalConfirmation(input(), address) + automation.submitOrderOnce() + + val order = automation.readOrderResult() + + assertEquals("PDD-202608210001", order?.orderNo) + assertEquals(2, driver.postSubmitBackCount) + assertFalse(driver.clicked.any { it.startsWith("微信") || it.contains("支付") }) + } + + @Test + fun `unrecognized chooser is never backed out automatically`() { + val driver = LiveDriver(chooserAfterSubmit = true, trustedChooser = false) + val automation = PurchaseLiveAutomation(driver, pause = {}) + val address = automation.updateShippingAddress("_cg15") + automation.finalConfirmation(input(), address) + automation.submitOrderOnce() + + assertEquals(null, automation.readOrderResult()) + assertEquals(0, driver.postSubmitBackCount) + } + @Test fun `clipped address is revealed by swiping the unique purchase panel`() { val driver = LiveDriver(addressClipped = true) @@ -87,6 +114,8 @@ class PurchaseLiveAutomationTest { private class LiveDriver( private val addressClipped: Boolean = false, private val duplicatePanels: Boolean = false, + private val chooserAfterSubmit: Boolean = false, + private val trustedChooser: Boolean = true, ) : PurchaseUiDriver { private var page = "confirmation" private var addressVisible = !addressClipped @@ -97,6 +126,8 @@ class PurchaseLiveAutomationTest { var genericSwipes = 0 var addressTaps = 0 var lastInputTargetPath: String? = null + var backCount = 0 + var postSubmitBackCount = 0 override fun capture(): UiSnapshot = when (page) { "panel" -> snapshot(listOf( @@ -116,6 +147,10 @@ class PurchaseLiveAutomationTest { 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))) + "chooser" -> UiSnapshot(if (trustedChooser) "android" else "example.untrusted", "com.android.internal.app.ChooserActivity", listOf( + node("chooser-title", "选择要使用的应用"), node("wechat-1", "微信"), node("wechat-2", "微信分身"), + )) + "payment" -> UiSnapshot(PDD, "com.xunmeng.pinduoduo.app_pay.core.PayActivity", listOf(node("pay", "立即支付", clickable = true))) else -> { val nodes = mutableListOf( node("root", "", bounds = NodeBounds(0, 0, 1080, 2200)), @@ -139,7 +174,7 @@ class PurchaseLiveAutomationTest { "138****5678" -> page = "panel" "修改" -> page = "edit" "保存" -> page = "panel" - "提交订单" -> { submitClicks++; page = "order" } + "提交订单" -> { submitClicks++; page = if (chooserAfterSubmit) "chooser" else "order" } } return FreshActionResult.SUCCESS } @@ -162,7 +197,16 @@ class PurchaseLiveAutomationTest { if (target.path == "panel" && direction == SwipeDirection.DOWN) addressVisible = true return true } - override fun backPurchase(): Boolean { page = "confirmation"; return true } + override fun backPurchase(): Boolean { + backCount++ + if (page == "chooser" || page == "payment") postSubmitBackCount++ + page = when (page) { + "chooser" -> "payment" + "payment" -> "order" + else -> "confirmation" + } + return true + } private fun snapshot(nodes: List) = UiSnapshot(PDD, ACTIVITY, nodes) private fun node( diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index 1a6afc9..df0c1f3 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: 9bfa80734dd6ce68d985e4ba1ad17025bc17b28b -synchronized_at: 2026-08-22T02:01:08Z +wiki_revision: 092d7df7156b270dbb2528eb8f28f40640b23cd2 +synchronized_at: 2026-08-22T02:10:40Z # 业务规则与术语 @@ -122,6 +122,7 @@ synchronized_at: 2026-08-22T02:01:08Z - 地址编辑页可以同时存在收货人、手机号和详细地址等多个输入框;Agent 只选择与“详细地址”标签纵向重叠且位于其右侧的唯一输入框,不能用页面输入框总数或顺序猜测。 - 点击创建订单前,Agent 必须先在本地事务保存 `order_submit_started`、不可逆时间、稳定请求 ID 和不含地址全文的最终确认快照,再用同一请求 ID通知服务端;两侧成功后才允许精确点击唯一创建订单按钮一次。 - 进入不可逆边界后,进程重启、断网、点击结果不明或无法取得唯一订单号/下单时间时只允许只读核单并进入 `order_result_unknown`,禁止再次点击;任务与订单正式关联仍以完整 PDD 订单号为准。 +- 创建订单后出现 Android 多微信应用选择器时,Agent 只有在系统包和 `ChooserActivity` / `ResolverActivity` 白名单、已知选择器标题、微信候选三类证据同时成立时,才允许按一次返回,绝不点击微信候选。回到 PDD 已知支付 Activity 或明确支付动作页面后最多再返回一次,再只读读取订单详情;任一页面不在白名单、一次返回后仍未离开或无法取得唯一订单号与下单时间时保持 `order_result_unknown`,禁止重下单或支付。 - Agent 本地 SQLite/Outbox 负责断网和重启恢复,服务端以 `task_id + task_attempt_id` 幂等接收并保存最终事实。 - 人工支付复核只记录 `paid` / `unpaid`;系统不执行或识别支付。快递单号与回填状态属于采购任务,后续物流工单实现。 - 采购任务领取时同时占用设备租约和可选 PDD 账号租约;租约过期后才可释放并重新领取。设备还存在采集任务时不能领取采购任务。 diff --git a/docs/08-agent-api-contract.md b/docs/08-agent-api-contract.md index 76fc483..28a3305 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: 9bfa80734dd6ce68d985e4ba1ad17025bc17b28b -synchronized_at: 2026-08-22T02:01:08Z +wiki_revision: 092d7df7156b270dbb2528eb8f28f40640b23cd2 +synchronized_at: 2026-08-22T02:10:40Z # MVP 共享 API 契约 @@ -515,6 +515,8 @@ Android #42/#36 使用本地 SQLite 保存恢复与重传所需的任务、attem 创建订单前先在本地事务保存 `order_submit_started`、不可逆时间、稳定的 `orderSubmitRequestId` 和脱敏最终确认快照,再调用服务端同名接口;只有两侧标记完成并重新校验商品、规格、数量、单价、地址后缀、PDD 包/Activity 与唯一创建订单按钮后,才点击一次。重启时重放同一标记请求并只读核单;无法取得唯一未付款订单号和 PDD 下单时间时提交 `order_result_unknown`。支付文字仅用于识别未付款/离开支付页,永不点击。服务端数据库仍是最终事实来源;双方均不保存原始控件树、截图、PDD 凭据或完整收货地址。 +创建订单后若出现 Android 多微信应用选择器,只读核单器必须同时确认前台包为 `android` / `com.android.intentresolver`、Activity 为白名单 `ChooserActivity` / `ResolverActivity`、页面出现已知系统选择器标题且至少一个候选以“微信”开头,才允许执行一次系统返回;不得点击任何微信候选。随后若前台为 PDD `com.xunmeng.pinduoduo.app_pay.core.PayActivity` 或当前页面出现支付动作文字,最多再返回一次。返回后只读解析唯一订单号和下单时间;选择器或支付页重复出现、白名单不成立、返回失败、结果不唯一或超时均返回未知结果,禁止再次点击创建订单、取消订单或支付。 + | 错误码 | 普通提示 | |---|---| | `PURCHASE_MODE_NOT_ALLOWED` | 当前任务模式不允许执行此操作 |