fix(#53): parameterize purchase rule actions

This commit is contained in:
QiuSW
2026-08-20 17:53:10 +08:00
parent dfa425ba5f
commit afeb2723ea
5 changed files with 236 additions and 11 deletions
+43 -6
View File
@@ -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
<!-- gitea-wiki-mirror:end -->
# 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` 的动作以及未知动作一律拒绝;服务端不下发任意脚本。
### 管理端接口
+5 -4
View File
@@ -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
<!-- gitea-wiki-mirror:end -->
# 当前 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 真机总验收。
## 延期
@@ -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)
+85 -1
View File
@@ -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)
@@ -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(), "演练规则不能") {