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 dfa2923..df826fc 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 @@ -185,10 +185,14 @@ class PurchaseRehearsalExecutor( var screen = currentScreen(input) screen.problem?.let { return failure(it.code, it.message) } if (screen.specPanelOpen) return null - val aliases = action.textAliases - val candidates = if (aliases == null) listOfNotNull(screen.specEntry, screen.quickConfirmationEntry) else { - screen.sourceNodes.filter { it.visible && it.enabled && it.label in aliases } - } + val safeCandidates = listOfNotNull(screen.specEntry, screen.quickConfirmationEntry) + .distinctBy(SnapshotNode::path) + // A rule alias may only narrow the semantic candidates already accepted + // by PddScreenParser. It must never turn into a raw-page text lookup: + // review cards and unrelated controls can share arbitrary labels. + val candidates = action.textAliases?.let { aliases -> + safeCandidates.filter { it.label in aliases } + } ?: safeCandidates if (candidates.size > 1) return failure("RULE_AMBIGUOUS", "规格入口匹配到多个控件") val target = candidates.singleOrNull() ?: return failure("RULE_NOT_MATCHED", "没有找到商品规格入口") when (driver.clickFresh(target)) { 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 93efef5..90ecb81 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 @@ -101,6 +101,28 @@ class PurchaseRehearsalExecutorTest { assertFalse(driver.clicked.any { it.contains("订单") || it.contains("地址") || it.contains("支付") }) } + @Test + fun `spec probe opens panel through safe bottom purchase entry`() { + val driver = FakePurchaseDriver(bottomPurchaseEntry = true) + val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{\"dimensions\":[]}" }, pause = {}) + .execute(input().copy(executionMode = "live", phase = "spec_probe"), PurchaseRuleParser.parse(liveRule()), PurchaseAgentCapabilities.supported) + + assertEquals("spec_probe_completed", outcome.resultType) + assertTrue(driver.clicked.contains("免拼购买")) + assertFalse(driver.clicked.any { it.contains("订单") || it.contains("地址") || it.contains("支付") }) + } + + @Test + fun `spec aliases cannot bypass semantic safe candidates`() { + val driver = FakePurchaseDriver(includeReviewEntry = true) + val unsafeRule = rule().replace("[\"选择规格\"]", "[\"商品评价\"]") + val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {}) + .execute(input(), PurchaseRuleParser.parse(unsafeRule), PurchaseAgentCapabilities.supported) + + assertEquals("RULE_NOT_MATCHED", outcome.errorCode) + assertFalse(driver.clicked.contains("商品评价")) + } + @Test fun `parser keeps type only compatibility and rejects dangerous or unknown fields`() { val legacy = """{"schemaVersion":1,"ruleType":"pddPurchase","requiredCapabilities":["purchase.rehearsal.v1"],"actions":[{"type":"openProduct"}]}""" @@ -185,6 +207,8 @@ class PurchaseRehearsalExecutorTest { private val colors: List = listOf("黑色"), private val priceCent: Long = 2_000, private val duplicateOpen: Boolean = false, + private val bottomPurchaseEntry: Boolean = false, + private val includeReviewEntry: Boolean = false, ) : PurchaseUiDriver { var browser = false var panel = false @@ -202,10 +226,18 @@ class PurchaseRehearsalExecutorTest { return UiSnapshot("com.heytap.browser", "BrowserActivity", openNodes) } if (!panel) { - return UiSnapshot(PDD, ACTIVITY, listOf( + val nodes = mutableListOf( node("content", "", 0, 0, 1080, 2200), - node("spec", "选择规格", 20, 1000, 900, 1100, clickable = true), - )) + ) + if (bottomPurchaseEntry) { + nodes += node("buy", "", 500, 1800, 1080, 2180, clickable = true) + nodes += node("buy/price", "¥20.00", 560, 1840, 760, 1910, parentPath = "buy") + nodes += node("buy/label", "免拼购买", 780, 1840, 1040, 1910, parentPath = "buy") + } else { + nodes += node("spec", "选择规格", 20, 1000, 900, 1100, clickable = true) + } + if (includeReviewEntry) nodes += node("review", "商品评价", 20, 1200, 900, 1300, clickable = true) + return UiSnapshot(PDD, ACTIVITY, nodes) } val nodes = mutableListOf( node("content", "", 0, 0, 1080, 2200), @@ -231,7 +263,7 @@ class PurchaseRehearsalExecutorTest { clicked += target.label when (target.label) { "打开" -> browser = false - "选择规格" -> panel = true + "选择规格", "免拼购买" -> panel = true "XL" -> size = "XL" in colors -> color = target.label "增加数量" -> quantity++ diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index 6813a9d..e1175a6 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: c3f7561d0b57fd7ec3d1b2bff273f8d7da7b724f -synchronized_at: 2026-08-21T07:26:39Z +wiki_revision: 3186b2ad506dd2696cbdb85a46afaddbece0c325 +synchronized_at: 2026-08-21T08:08:28Z # 业务规则与术语 @@ -140,6 +140,7 @@ synchronized_at: 2026-08-21T07:26:39Z - Android Agent 端不使用 OCR/VLM 兜底,不保存原始控件树或截图。采集阶段不猜测缺失数据。 - 服务端顺云宝(SYB)登录例外:允许调用配置的线上 OCR 服务识别登录验证码(见 [#48](https://git.ilapage.cn/OPC/goauto/issues/48))。验证码图片会离开本项目发送到该服务,更换服务地址前必须重新评估。此例外只适用于 SYB 登录,不扩大到 Agent 端或任何 PDD 相关流程。 - 采购阶段的规格匹配由服务端决策(见 [#46](https://git.ilapage.cn/OPC/goauto/issues/46)):Agent 本地不得自行猜测规格或点击相近候选,只执行服务端下发的精确规格;AI 无匹配结果时明确失败。 +- 采购规则的 `openSpecPanel` 默认不写死页面文字。Agent 只在已由页面语义确认的规格入口或底部购买入口中选择;规则若提供 `textAliases`,只能进一步缩小这些安全候选,不能把任意同名页面文字变成可点击入口。 - 正式采购规则可以调用独立审核的改地址、创建待付款订单和只读核单动作;采集规则和演练规则不能调用。真机首次安装或验证仍需独立人工授权。 - 系统不提供自动支付、实时屏幕或管理端远程控制。付款、免密支付及任何等价动作始终禁止。 diff --git a/server/app/goauto/purchasecontract/default.go b/server/app/goauto/purchasecontract/default.go index 8fe64a3..b055fc4 100644 --- a/server/app/goauto/purchasecontract/default.go +++ b/server/app/goauto/purchasecontract/default.go @@ -7,5 +7,5 @@ import "encoding/json" // every task therefore freezes this exact snapshot so later contract changes // cannot silently alter work that is already queued. func DefaultLiveRule() json.RawMessage { - return json.RawMessage(`{"schemaVersion":1,"ruleType":"pddPurchase","requiredCapabilities":["purchase.live.v1","purchase.address-update.v1","purchase.order-create.v1","purchase.spec-probe.v1"],"actions":[{"type":"openProduct","textAliases":["打开拼多多APP","打开"],"waitAfterMs":1000},{"type":"verifyProduct"},{"type":"openSpecPanel","textAliases":["选择规格"],"swipeAfter":{"direction":"up","count":2,"durationMs":500,"intervalMs":1000}},{"type":"probeSpecs"},{"type":"selectSpec"},{"type":"setQuantity"},{"type":"verifyUnitPrice"},{"type":"verifyOrderSummary"},{"type":"updateShippingAddress"},{"type":"createOrder"},{"type":"readOrderResult"}]}`) + return json.RawMessage(`{"schemaVersion":1,"ruleType":"pddPurchase","requiredCapabilities":["purchase.live.v1","purchase.address-update.v1","purchase.order-create.v1","purchase.spec-probe.v1"],"actions":[{"type":"openProduct","textAliases":["打开拼多多APP","打开"],"waitAfterMs":1000},{"type":"verifyProduct"},{"type":"openSpecPanel","swipeAfter":{"direction":"up","count":2,"durationMs":500,"intervalMs":1000}},{"type":"probeSpecs"},{"type":"selectSpec"},{"type":"setQuantity"},{"type":"verifyUnitPrice"},{"type":"verifyOrderSummary"},{"type":"updateShippingAddress"},{"type":"createOrder"},{"type":"readOrderResult"}]}`) } diff --git a/server/app/goauto/purchasecontract/default_test.go b/server/app/goauto/purchasecontract/default_test.go index 50df237..5bf47be 100644 --- a/server/app/goauto/purchasecontract/default_test.go +++ b/server/app/goauto/purchasecontract/default_test.go @@ -17,4 +17,9 @@ func TestDefaultLiveRuleIsValidAndNeverContainsPayment(t *testing.T) { if rule.RuleType != RuleTypePurchase || len(rule.Actions) == 0 { t.Fatalf("unexpected rule: %+v", rule) } + for _, action := range rule.Actions { + if action.Type == "openSpecPanel" && action.TextAliases != nil { + t.Fatalf("default spec-panel action must use semantic safe entry detection, got aliases: %+v", action.TextAliases) + } + } }