fix(#61): use safe purchase spec entry

This commit is contained in:
QiuSW
2026-08-21 16:08:57 +08:00
parent 36b11ece5d
commit aebe80f305
5 changed files with 53 additions and 11 deletions
@@ -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)) {
@@ -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<String> = 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++
+3 -2
View File
@@ -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
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -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`,只能进一步缩小这些安全候选,不能把任意同名页面文字变成可点击入口。
- 正式采购规则可以调用独立审核的改地址、创建待付款订单和只读核单动作;采集规则和演练规则不能调用。真机首次安装或验证仍需独立人工授权。
- 系统不提供自动支付、实时屏幕或管理端远程控制。付款、免密支付及任何等价动作始终禁止。
@@ -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"}]}`)
}
@@ -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)
}
}
}