From afeb2723ea0acfb03f2ab31b7b12f491602f4a9b Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Thu, 20 Aug 2026 17:53:10 +0800 Subject: [PATCH] fix(#53): parameterize purchase rule actions --- docs/08-agent-api-contract.md | 49 ++++++++-- docs/09-delivery-issues.md | 9 +- server/app/goauto/purchase/service_test.go | 13 +++ .../app/goauto/purchasecontract/contract.go | 86 +++++++++++++++++- .../goauto/purchasecontract/contract_test.go | 90 +++++++++++++++++++ 5 files changed, 236 insertions(+), 11 deletions(-) diff --git a/docs/08-agent-api-contract.md b/docs/08-agent-api-contract.md index a47d80a..9b014b5 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: 5da7f2250f851caab6d0d1713f869b619ed55eb2 -synchronized_at: 2026-08-20T08:47:11Z +wiki_revision: 8b81c816989f9063a6927939a3391c103ddc08ad +synchronized_at: 2026-08-20T09:51:45Z # MVP 共享 API 契约 @@ -369,7 +369,7 @@ POST /api/agent/v1/tasks/{taskId}/fail | `DEVICE_BUSY` | 设备已有活动任务 | 否 | | `DEVICE_CAPABILITY_MISMATCH` | 设备缺少任务规则要求的版本化能力 | 否 | -## 采购任务共享契约(#33、#34) +## 采购任务共享契约(#33、#34、#53) 本节固定采购域的数据和接口边界;`purchase_task`、`purchase_task_attempt`、规则校验及服务端 HTTP 状态机已落地,Admin 页面和 Android 执行动作分别由后续工单实现。任何实现都不得扩展为自动支付。 @@ -399,7 +399,7 @@ POST /api/agent/v1/tasks/{taskId}/fail ### 规则快照与能力 -采购规则是 JSON 对象: +采购规则是 JSON 对象。#53 在既有 schema v1 中增加受限参数,不引入任意脚本: ```json { @@ -407,8 +407,21 @@ POST /api/agent/v1/tasks/{taskId}/fail "ruleType": "pddPurchase", "requiredCapabilities": ["purchase.rehearsal.v1"], "actions": [ - {"type": "openProduct"}, - {"type": "verifyProduct"}, + { + "type": "openProduct", + "textAliases": ["打开拼多多APP", "打开"], + "waitAfterMs": 1000 + }, + { + "type": "openSpecPanel", + "textAliases": ["选择规格"], + "swipeAfter": { + "direction": "up", + "count": 2, + "durationMs": 500, + "intervalMs": 1000 + } + }, {"type": "selectSpec"}, {"type": "setQuantity"}, {"type": "verifyUnitPrice"}, @@ -417,6 +430,30 @@ POST /api/agent/v1/tasks/{taskId}/fail } ``` +安全动作参数矩阵: + +| action | `textAliases` | `waitAfterMs` | `swipeAfter` | +|---|---:|---:|---:| +| `openProduct` | 是 | 是 | 是 | +| `verifyProduct` | 是 | 是 | 否 | +| `openSpecPanel` | 是 | 是 | 是 | +| `selectSpec` | 是 | 是 | 是 | +| `setQuantity` | 是 | 是 | 否 | +| `verifyUnitPrice` | 是 | 是 | 否 | +| `verifyOrderSummary` | 是 | 是 | 否 | +| `probeSpecs` | 是 | 是 | 是 | + +参数规则: + +- `textAliases` 省略时使用 Agent 对该类型化动作的内置语义目标;显式提供时必须包含 1~16 个互不重复、无首尾空白的非空文字,每项最多 64 个字符。 +- 文字候选按控件文字或内容描述**精确匹配**;候选合并后必须唯一命中。点击动作只允许点击唯一文字节点或其最近的可点击父容器,不允许模糊匹配、猜测相近候选、改点兄弟节点。 +- `textAliases` 不能包含地址修改、创建/提交订单、订单号或支付相关文字,防止用安全 action 绕过危险动作类型和能力门禁。 +- `waitAfterMs` 表示动作成功后的等待时间,范围为 0~30000 毫秒;省略时为 0。 +- `swipeAfter` 表示动作成功后执行一个有限滑动计划。`direction` 只能为 `up` / `down` / `left` / `right`,`count` 为 1~10,`durationMs` 为 100~2000,`intervalMs` 为 0~5000 且省略时为 0。 +- 未在矩阵中授权的 action/参数组合、未知字段、空候选和越界值一律拒绝。`updateShippingAddress`、`createOrder`、`readOrderResult` 等正式动作在其独立高风险契约完成前不接受上述参数。 +- 旧的仅含 `actions[].type` 的规则继续有效:候选使用 Agent 内置语义,等待为 0,不执行动作后滑动。 +- 服务端保存创建任务时收到的完整原始规则快照;规则后来更新为规则 B,不会改变已有任务中的规则 A 快照。 + 演练规则必须包含 `purchase.rehearsal.v1`,并且不能包含 `updateShippingAddress`、`createOrder`、`readOrderResult`。正式规则必须包含 `purchase.live.v1`;改地址和创建订单还分别要求 `purchase.address-update.v1`、`purchase.order-create.v1`。`probeSpecs` 要求 `purchase.spec-probe.v1`。任意模式下,`pay`、名称包含 `payment` 的动作以及未知动作一律拒绝;服务端不下发任意脚本。 ### 管理端接口 diff --git a/docs/09-delivery-issues.md b/docs/09-delivery-issues.md index afb9fef..12903c5 100644 --- a/docs/09-delivery-issues.md +++ b/docs/09-delivery-issues.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Delivery-Issues wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Delivery-Issues.- -wiki_revision: e80a0564cf94d2feb3c068d0bf24419d36e21e67 -synchronized_at: 2026-08-20T08:47:11Z +wiki_revision: f1f993a6ed4aa4dc53e1ad7943ac60bca6db790e +synchronized_at: 2026-08-20T09:51:48Z # 当前 MVP 交付工单索引 @@ -62,15 +62,16 @@ synchronized_at: 2026-08-20T08:47:11Z | T37 | [#39](https://git.ilapage.cn/OPC/goauto/issues/39) | 采购闭环真机端到端验收 | #33~#38、#42 | | T38 | [#40](https://git.ilapage.cn/OPC/goauto/issues/40) | 虾皮商品档案、PDD 关联与规格映射(2026-08-20 已验收) | 已完成 | | T39 | [#41](https://git.ilapage.cn/OPC/goauto/issues/41) | SYB 货运单商品导入与虾皮信息提取(2026-08-20 已验收) | 已完成 | -| T40 | [#42](https://git.ilapage.cn/OPC/goauto/issues/42) | Android 采购演练规则与持久执行基线 | #33、#34;只演练,不改地址、不创建订单 | +| T40 | [#42](https://git.ilapage.cn/OPC/goauto/issues/42) | Android 采购演练规则与持久执行基线 | #33、#34、#53;只演练,不改地址、不创建订单 | | T43 | [#45](https://git.ilapage.cn/OPC/goauto/issues/45) | Admin PDD 商品列表多选与批量采集任务创建(2026-08-18 已验收) | 已完成 | | T46 | [#48](https://git.ilapage.cn/OPC/goauto/issues/48) | SYB ERP 客户端移植与真实导入 | #41;已实施,等待验收 | | T47 | [#49](https://git.ilapage.cn/OPC/goauto/issues/49) | 店铺管理与 SYB 同步店铺过滤(2026-08-20 已验收) | 已完成 | | T48 | [#50](https://git.ilapage.cn/OPC/goauto/issues/50) | SYB 导入后台任务与进度展示(2026-08-20 已验收) | 已完成 | | T49 | [#51](https://git.ilapage.cn/OPC/goauto/issues/51) | 优化 SYB 商品列表工具栏与列宽(2026-08-20 已验收) | 已完成 | | T50 | [#52](https://git.ilapage.cn/OPC/goauto/issues/52) | 修复 SYB 同步预创建失败无法定位(2026-08-20 已验收) | 已完成 | +| T51 | [#53](https://git.ilapage.cn/OPC/goauto/issues/53) | 补齐采购规则参数化动作契约 | 已实施,等待验收;通过后解除 #42 阻塞 | -推荐依赖顺序:#31、#40、#41 完成商品域 → #33、#34 建立采购契约和服务端状态机 → #42 完成不下单演练 → #35 管理端人工处理 → #36 高风险真实订单动作 → #37、#38 物流闭环 → #39 真机总验收。 +推荐依赖顺序:#31、#40、#41 完成商品域 → #33、#34 建立采购契约和服务端状态机 → #53 补齐参数化动作契约 → #42 完成不下单演练 → #35 管理端人工处理 → #36 高风险真实订单动作 → #37、#38 物流闭环 → #39 真机总验收。 ## 延期 diff --git a/server/app/goauto/purchase/service_test.go b/server/app/goauto/purchase/service_test.go index 3451459..9b28ddf 100644 --- a/server/app/goauto/purchase/service_test.go +++ b/server/app/goauto/purchase/service_test.go @@ -113,6 +113,19 @@ func TestRehearsalCannotContainOrderActions(t *testing.T) { } } +func TestCreateRehearsalPreservesParameterizedRuleSnapshot(t *testing.T) { + db := testDB(t) + f := seed(t, db, []string{purchasecontract.CapabilityPurchaseRehearsalV1}, true) + raw := []byte(`{"schemaVersion":1,"ruleType":"pddPurchase","requiredCapabilities":["purchase.rehearsal.v1"],"actions":[{"type":"openProduct","textAliases":["打开拼多多APP","打开"],"waitAfterMs":1000},{"type":"openSpecPanel","swipeAfter":{"direction":"up","count":2,"durationMs":500,"intervalMs":1000}},{"type":"verifyOrderSummary"}]}`) + task, _, err := testService(db).Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ExecutionMode: models.PurchaseExecutionModeRehearsal, PDDProductID: &f.pdd.ID, Quantity: 1, Currency: "CNY", MaxUnitPriceCent: 1000, RuleSnapshot: raw}) + if err != nil { + t.Fatalf("parameterized rehearsal create failed: %v", err) + } + if task.RuleSnapshot != string(raw) { + t.Fatalf("rule snapshot changed:\nwant %s\n got %s", raw, task.RuleSnapshot) + } +} + func TestCreateAndLifecycleValidateCapabilitiesAndIdempotentResult(t *testing.T) { db := testDB(t) f := seed(t, db, liveCaps(), true) diff --git a/server/app/goauto/purchasecontract/contract.go b/server/app/goauto/purchasecontract/contract.go index b8963d6..662e146 100644 --- a/server/app/goauto/purchasecontract/contract.go +++ b/server/app/goauto/purchasecontract/contract.go @@ -9,6 +9,7 @@ import ( "regexp" "sort" "strings" + "unicode/utf8" ) const ( @@ -20,6 +21,14 @@ const ( CapabilitySpecProbeV1 = "purchase.spec-probe.v1" CapabilityAddressUpdateV1 = "purchase.address-update.v1" CapabilityOrderCreateV1 = "purchase.order-create.v1" + + MaxTextAliases = 16 + MaxTextAliasRunes = 64 + MaxWaitAfterMs = 30_000 + MaxSwipeCount = 10 + MinSwipeDurationMs = 100 + MaxSwipeDurationMs = 2_000 + MaxSwipeIntervalMs = 5_000 ) var capabilityPattern = regexp.MustCompile(`^[a-z][a-z0-9.-]{0,79}$`) @@ -36,8 +45,26 @@ var liveOnlyActions = map[string]string{ "readOrderResult": CapabilityPurchaseLiveV1, } +var swipeAfterActions = map[string]bool{ + "openProduct": true, "openSpecPanel": true, "selectSpec": true, "probeSpecs": true, +} + +var forbiddenTextFragments = []string{ + "pay", "payment", "支付", "付款", "免密", "修改地址", "收货地址", "创建订单", "提交订单", "订单号", +} + type Action struct { - Type string `json:"type"` + Type string `json:"type"` + TextAliases []string `json:"textAliases,omitempty"` + WaitAfterMs *int `json:"waitAfterMs,omitempty"` + SwipeAfter *SwipePlan `json:"swipeAfter,omitempty"` +} + +type SwipePlan struct { + Direction string `json:"direction"` + Count int `json:"count"` + DurationMs int `json:"durationMs"` + IntervalMs int `json:"intervalMs,omitempty"` } type RuleSnapshot struct { @@ -88,6 +115,9 @@ func Validate(raw []byte, executionMode string) (RuleSnapshot, error) { if action.Type == "probeSpecs" && !capabilities[CapabilitySpecProbeV1] { return rule, fmt.Errorf("动作 probeSpecs 缺少能力 %s", CapabilitySpecProbeV1) } + if err := validateSafeActionParameters(index, action); err != nil { + return rule, err + } continue } requiredCapability, liveOnly := liveOnlyActions[action.Type] @@ -100,10 +130,64 @@ func Validate(raw []byte, executionMode string) (RuleSnapshot, error) { if !capabilities[requiredCapability] { return rule, fmt.Errorf("动作 %s 缺少能力 %s", action.Type, requiredCapability) } + if len(action.TextAliases) > 0 || action.WaitAfterMs != nil || action.SwipeAfter != nil { + return rule, fmt.Errorf("actions[%d] 的正式动作 %s 暂不支持参数化", index, action.Type) + } } return rule, nil } +func validateSafeActionParameters(index int, action Action) error { + if action.TextAliases != nil && len(action.TextAliases) == 0 { + return fmt.Errorf("actions[%d].textAliases 显式提供时不能为空", index) + } + if len(action.TextAliases) > MaxTextAliases { + return fmt.Errorf("actions[%d].textAliases 最多包含 %d 项", index, MaxTextAliases) + } + seen := make(map[string]bool, len(action.TextAliases)) + for aliasIndex, alias := range action.TextAliases { + if alias == "" || strings.TrimSpace(alias) != alias { + return fmt.Errorf("actions[%d].textAliases[%d] 必须是无首尾空白的非空文字", index, aliasIndex) + } + if !utf8.ValidString(alias) || utf8.RuneCountInString(alias) > MaxTextAliasRunes { + return fmt.Errorf("actions[%d].textAliases[%d] 最多包含 %d 个字符", index, aliasIndex, MaxTextAliasRunes) + } + if seen[alias] { + return fmt.Errorf("actions[%d].textAliases 包含重复文字 %q", index, alias) + } + seen[alias] = true + lower := strings.ToLower(alias) + for _, fragment := range forbiddenTextFragments { + if strings.Contains(lower, fragment) { + return fmt.Errorf("actions[%d].textAliases[%d] 包含禁止的地址、下单或支付文字", index, aliasIndex) + } + } + } + if action.WaitAfterMs != nil && (*action.WaitAfterMs < 0 || *action.WaitAfterMs > MaxWaitAfterMs) { + return fmt.Errorf("actions[%d].waitAfterMs 必须在 0..%d 之间", index, MaxWaitAfterMs) + } + if action.SwipeAfter == nil { + return nil + } + if !swipeAfterActions[action.Type] { + return fmt.Errorf("actions[%d] 的动作 %s 不支持 swipeAfter", index, action.Type) + } + swipe := action.SwipeAfter + if swipe.Direction != "up" && swipe.Direction != "down" && swipe.Direction != "left" && swipe.Direction != "right" { + return fmt.Errorf("actions[%d].swipeAfter.direction 只支持 up、down、left、right", index) + } + if swipe.Count < 1 || swipe.Count > MaxSwipeCount { + return fmt.Errorf("actions[%d].swipeAfter.count 必须在 1..%d 之间", index, MaxSwipeCount) + } + if swipe.DurationMs < MinSwipeDurationMs || swipe.DurationMs > MaxSwipeDurationMs { + return fmt.Errorf("actions[%d].swipeAfter.durationMs 必须在 %d..%d 之间", index, MinSwipeDurationMs, MaxSwipeDurationMs) + } + if swipe.IntervalMs < 0 || swipe.IntervalMs > MaxSwipeIntervalMs { + return fmt.Errorf("actions[%d].swipeAfter.intervalMs 必须在 0..%d 之间", index, MaxSwipeIntervalMs) + } + return nil +} + func RequiredCapabilities(rule RuleSnapshot) []string { result := append([]string(nil), rule.RequiredCapabilities...) sort.Strings(result) diff --git a/server/app/goauto/purchasecontract/contract_test.go b/server/app/goauto/purchasecontract/contract_test.go index 5395ab9..cb2bf4c 100644 --- a/server/app/goauto/purchasecontract/contract_test.go +++ b/server/app/goauto/purchasecontract/contract_test.go @@ -1,10 +1,100 @@ package purchasecontract import ( + "reflect" "strings" "testing" ) +func TestRehearsalRuleAcceptsBoundedParameters(t *testing.T) { + raw := []byte(`{"schemaVersion":1,"ruleType":"pddPurchase","requiredCapabilities":["purchase.rehearsal.v1","purchase.spec-probe.v1"],"actions":[{"type":"openProduct","textAliases":["打开拼多多APP","打开"],"waitAfterMs":1000},{"type":"openSpecPanel","textAliases":["选择规格"],"swipeAfter":{"direction":"up","count":2,"durationMs":500,"intervalMs":1000}},{"type":"probeSpecs","swipeAfter":{"direction":"left","count":3,"durationMs":300}}]}`) + rule, err := Validate(raw, "rehearsal") + if err != nil { + t.Fatalf("valid parameterized rule rejected: %v", err) + } + if !reflect.DeepEqual(rule.Actions[0].TextAliases, []string{"打开拼多多APP", "打开"}) || rule.Actions[0].WaitAfterMs == nil || *rule.Actions[0].WaitAfterMs != 1000 { + t.Fatalf("parameters were not preserved: %#v", rule.Actions[0]) + } + if got := rule.Actions[1].SwipeAfter; got == nil || got.Direction != "up" || got.Count != 2 || got.DurationMs != 500 || got.IntervalMs != 1000 { + t.Fatalf("swipe plan was not preserved: %#v", got) + } +} + +func TestTypeOnlyRuleRemainsCompatible(t *testing.T) { + raw := []byte(`{"schemaVersion":1,"ruleType":"pddPurchase","requiredCapabilities":["purchase.rehearsal.v1"],"actions":[{"type":"openProduct"},{"type":"verifyProduct"}]}`) + rule, err := Validate(raw, "rehearsal") + if err != nil { + t.Fatalf("legacy type-only rule rejected: %v", err) + } + for _, action := range rule.Actions { + if len(action.TextAliases) != 0 || action.WaitAfterMs != nil || action.SwipeAfter != nil { + t.Fatalf("legacy defaults changed: %#v", action) + } + } +} + +func TestParameterizedRuleRejectsUnknownFields(t *testing.T) { + tests := []string{ + `{"schemaVersion":1,"ruleType":"pddPurchase","requiredCapabilities":["purchase.rehearsal.v1"],"actions":[{"type":"openProduct","selector":{"text":"打开"}}]}`, + `{"schemaVersion":1,"ruleType":"pddPurchase","requiredCapabilities":["purchase.rehearsal.v1"],"actions":[{"type":"openProduct","swipeAfter":{"direction":"up","count":2,"durationMs":500,"unknown":1}}]}`, + } + for _, raw := range tests { + if _, err := Validate([]byte(raw), "rehearsal"); err == nil || !strings.Contains(err.Error(), "字段无效") { + t.Fatalf("expected unknown field rejection for %s, got %v", raw, err) + } + } +} + +func TestParameterizedRuleRejectsInvalidAliases(t *testing.T) { + tests := []string{ + `"textAliases":[]`, + `"textAliases":[""]`, + `"textAliases":[" 打开"]`, + `"textAliases":["打开","打开"]`, + `"textAliases":["立即支付"]`, + `"textAliases":["submit payment"]`, + } + for _, params := range tests { + raw := `{"schemaVersion":1,"ruleType":"pddPurchase","requiredCapabilities":["purchase.rehearsal.v1"],"actions":[{"type":"openProduct",` + params + `}]}` + if _, err := Validate([]byte(raw), "rehearsal"); err == nil { + t.Fatalf("expected alias rejection for %s", params) + } + } +} + +func TestParameterizedRuleRejectsOutOfBoundsWaitAndSwipe(t *testing.T) { + tests := []string{ + `"waitAfterMs":-1`, + `"waitAfterMs":30001`, + `"swipeAfter":{"direction":"diagonal","count":1,"durationMs":500}`, + `"swipeAfter":{"direction":"up","count":0,"durationMs":500}`, + `"swipeAfter":{"direction":"up","count":11,"durationMs":500}`, + `"swipeAfter":{"direction":"up","count":1,"durationMs":99}`, + `"swipeAfter":{"direction":"up","count":1,"durationMs":2001}`, + `"swipeAfter":{"direction":"up","count":1,"durationMs":500,"intervalMs":5001}`, + } + for _, params := range tests { + raw := `{"schemaVersion":1,"ruleType":"pddPurchase","requiredCapabilities":["purchase.rehearsal.v1"],"actions":[{"type":"openProduct",` + params + `}]}` + if _, err := Validate([]byte(raw), "rehearsal"); err == nil { + t.Fatalf("expected bounded parameter rejection for %s", params) + } + } +} + +func TestSwipeAfterIsLimitedToDeclaredActions(t *testing.T) { + raw := []byte(`{"schemaVersion":1,"ruleType":"pddPurchase","requiredCapabilities":["purchase.rehearsal.v1"],"actions":[{"type":"verifyUnitPrice","swipeAfter":{"direction":"up","count":1,"durationMs":500}}]}`) + if _, err := Validate(raw, "rehearsal"); err == nil || !strings.Contains(err.Error(), "不支持 swipeAfter") { + t.Fatalf("expected action parameter matrix rejection, got %v", err) + } +} + +func TestLiveOnlyActionsRejectParametersUntilTheirContractExists(t *testing.T) { + raw := []byte(`{"schemaVersion":1,"ruleType":"pddPurchase","requiredCapabilities":["purchase.live.v1","purchase.order-create.v1"],"actions":[{"type":"createOrder","waitAfterMs":1000}]}`) + if _, err := Validate(raw, "live"); err == nil || !strings.Contains(err.Error(), "暂不支持参数化") { + t.Fatalf("expected live parameter rejection, got %v", err) + } +} + func TestRehearsalRuleRejectsIrreversibleActions(t *testing.T) { raw := []byte(`{"schemaVersion":1,"ruleType":"pddPurchase","requiredCapabilities":["purchase.rehearsal.v1"],"actions":[{"type":"openProduct"},{"type":"updateShippingAddress"}]}`) if _, err := Validate(raw, "rehearsal"); err == nil || !strings.Contains(err.Error(), "演练规则不能") {