From 3619435a97bcf7a30951cc6f8366c43d42ca90dd Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Thu, 27 Aug 2026 18:02:32 +0800 Subject: [PATCH] refactor(#116): separate read-only aliases from actions --- AGENTS.md | 4 +- CLAUDE.md | 2 +- docs/03-business-rules-and-glossary.md | 12 +++--- docs/08-agent-api-contract.md | 14 +++---- .../app/goauto/purchasecontract/contract.go | 41 +++++++++++++++++-- .../goauto/purchasecontract/contract_test.go | 23 ++++++++++- .../goauto/purchasecontract/default_test.go | 6 +-- 7 files changed, 78 insertions(+), 24 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ebac8bb..a77f96a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,8 +25,8 @@ ## 1. 永久规则 - 不把密码、Token、Cookie、私钥、PDD 账号凭据、个人数据或生产数据写入代码、日志、工单和文档。例外:经用户于 2026-08-21 明确确认的 #62 内部 AI Provider API Key,可明文保存在专用 `ai_matching_setting` 数据表,并只返回给管理员用于下次查看和替换;它仍不得出现在代码、日志、工单、Wiki、任务快照、采购员接口或 Android 接口中。 -- 不执行付款;任何自动支付实现、入口或测试都禁止进入本项目。 -- 当前采集 MVP 只实现 PDD 商品、规则、任务、Android 执行和任务详情。采购是独立的后续高风险 MVP,未通过对应原型和工单门禁前不能混入采集代码。 +- 不执行付款。当前项目不实现任何自动支付动作、入口或测试;后续如需实现,必须单独建单评估,并至少具备显式能力位、服务端开关、单笔金额上限与人工授权四项控制。支付、下单和订单相关文字允许作为只读识别信号出现在采集与采购规则中,用于判断页面形态;任何规则都不得把它们配置为点击目标。 +- 当前采集 MVP 只实现 PDD 商品、规则、任务、Android 执行和任务详情。采购是独立的后续高风险 MVP,未通过对应原型和工单门禁前不能混入采集代码;采集规则可以描述订单确认面板的只读特征,这不构成采购代码混入采集。 - 不保存原始控件树和设备截图;只保存结构化任务日志、错误码、任务规则快照和采集结果。 - 一台设备同一时刻只执行一个任务;手机离线时当前采集任务失败,默认不重试、不自动换机。 - Android Agent 端:找不到控件、验证码、风控、人机验证或登录失效时明确失败,不使用 OCR/VLM。 diff --git a/CLAUDE.md b/CLAUDE.md index 87264d7..13c0841 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,5 +32,5 @@ ## GoAuto 专用提醒 -- 采购相关任务涉及创建 PDD 订单,属于不可逆操作;任何模型都不得跳过 `AGENTS.md` 的采购门禁,也不得实现支付。 +- 采购相关任务涉及创建 PDD 订单,属于不可逆操作;任何模型都不得跳过 `AGENTS.md` 的采购门禁。当前项目不实现支付动作、入口或测试;支付、下单和订单相关文字只可作为规则中的只读识别信号,不得配置为点击目标。 - 规格匹配的决策权在服务端(见 #46);Agent 本地不得猜测规格或点击相近候选。 diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index b053257..a201f89 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: f3d85bf36ae7543aaa3fa1c6926e9bc0f221fb1e -synchronized_at: 2026-08-27T09:22:25Z +wiki_revision: 26618d4312a1e7091f6b483111c31e67f70f26eb +synchronized_at: 2026-08-27T09:57:37Z # 业务规则与术语 @@ -90,7 +90,7 @@ synchronized_at: 2026-08-27T09:22:25Z - 假售罄恢复只使用主商品规格/购买强证据判断页面是否正常;顶部“相似商品”可以作为受控兜底证据,推荐卡片的标题、价格和销量不得冒充主商品证据。 - 商品规格遍历必须用已识别规格节点锁定横向颜色容器和纵向面板容器;颜色按视觉行蛇形遍历,滑动完成后重新读取节点,尺码只读并允许在标题滚出后沿已锁定容器续页。 - PDD 已选规格的订单确认形态只可由详情页、选择摘要、唯一数量控件、支付区、唯一底部提交动作和唯一主要滚动容器的组合证据确认;底部提交动作只读且永久禁止点击。Agent 只在该已确认容器内最多三次向下拖动回顶,每次重新读取节点;“参考分类”作为尺码的精确标题别名处理。 -- Agent 可扩展,但规则必须按任务类型授权:采集规则不能创建订单,采购规则未来可以使用独立的创建订单能力,任何规则都不能付款。 +- Agent 可扩展,但规则必须按任务类型授权:采集规则不能创建订单,采购规则只能使用独立审核的创建订单能力。当前项目不实现支付动作、入口或测试;支付、下单和订单相关文字可以作为只读页面证据配置,但不得成为点击目标。后续支付能力必须单独评估并至少具备显式能力位、服务端开关、单笔金额上限和人工授权。 ## 采集任务 @@ -127,7 +127,7 @@ synchronized_at: 2026-08-27T09:22:25Z - 创建时固化三个商品身份、蝦皮订单号、目标和映射规格、数量、价格区间、币种、URL、`goods_id`、规则、设备和可选账号引用。商品档案后续修改不改变任务解释。正式任务的蝦皮订单号来自 SYB 商品 `order_code`,同一订单号可以对应多条商品和多个采购任务,不作为唯一键。 - Android 无法可靠识别登录中的 PDD 账号,因此账号引用可空;已知账号才参与账号级串行,未知账号不会阻止采购任务。 - 同一 SYB 明细可以保留多个历史采购任务,但最多只能有一个活动任务。重新采购新建任务,旧订单不删除、不覆盖。 -- `execution_mode` 创建后不可变:`rehearsal` 只能完成商品、规格、数量和价格复核;`live` 才可能获得改地址和创建订单能力。任何模式永远禁止支付。 +- `execution_mode` 创建后不可变:`rehearsal` 只能完成商品、规格、数量和价格复核;`live` 才可能获得改地址和创建订单能力。任何模式都不支持可执行支付动作;规则中的支付相关文字只可作为只读识别信号。 - 演练规则在服务端契约层拒绝改地址、创建订单和核单动作;正式动作还必须通过版本化能力协商。规则只包含类型化动作,禁止任意脚本。 - 价格保护保存参考单价、最低单价、最高单价和币种,执行时以 PDD App 实际单价判断;价格越界明确失败,不考虑优惠券。 - Admin 从 SYB 当前页选择商品,可在“创建采购”和“重新解析”两种批量用途间切换;采购模式只允许选择预检合格行,表头全选不跨页。批量预检和正式创建均最多 100 条并逐条重新校验,每条 SYB 明细只创建一个独立任务;部分失败不回滚其他成功项。 @@ -172,7 +172,7 @@ synchronized_at: 2026-08-27T09:22:25Z - 采购阶段的规格匹配由服务端决策(见 [#46](https://git.ilapage.cn/OPC/goauto/issues/46)、[#62](https://git.ilapage.cn/OPC/goauto/issues/62)):Agent 本地不得自行猜测规格或点击相近候选,只执行服务端下发的精确规格;AI Provider 只有一个 OpenAI-compatible 配置,由管理员维护。根据 #62 已确认的内部部署例外,API Key 明文保存在专用设置表,并只向管理员设置接口返回以便查看和替换;它仍不得写入代码、日志、工单、Wiki、任务快照、采购员接口或 Android 接口。Provider Base URL 不限制内网或公网,支持 HTTP/HTTPS;HTTP 不加密传输中的 API Key,生产环境建议 HTTPS。 - 采购规则的 `openSpecPanel` 默认不写死页面文字。Agent 只在已由页面语义确认的规格入口或底部购买入口中选择;规则若提供 `textAliases`,只能进一步缩小这些安全候选,不能把任意同名页面文字变成可点击入口。 - 正式采购规则可以调用独立审核的改地址、创建待付款订单和只读核单动作;采集规则和演练规则不能调用。真机首次安装或验证仍需独立人工授权。 -- 系统不提供自动支付、实时屏幕或管理端远程控制。付款、免密支付及任何等价动作始终禁止。 +- 系统当前不提供自动支付、实时屏幕或管理端远程控制。付款、免密支付及任何等价动作不能配置为点击目标;只读识别字段可以包含支付或订单文字。后续若新增支付能力,必须由独立工单和显式控制重新评估。 ## 设备身份与认证 @@ -285,4 +285,4 @@ synchronized_at: 2026-08-27T09:22:25Z - 当前页面任务身份确认前允许 `pdd_product_id` 为空、URL/goods_id 快照为空;普通管理端任务不放宽。身份确认后不可改成其他商品,结果 goods_id 必须与任务一致。 - 当前页面任务不能使用“重新采集”重置;失败后重新打开目标商品并新建任务。成功提交完成、部分完成或失败结果后,继续进入设备的采集任务执行间隔。 - 分享入口、复制链接、剪贴板、链接、页面或身份不唯一时明确失败并释放任务槽;不使用 OCR/VLM,不猜测商品身份。 -- 该能力只采集商品资料;不创建采购任务、不选择采购规格、不修改地址、不创建订单,永久禁止支付。 +- 该能力只采集商品资料;不创建采购任务、不选择采购规格、不修改地址、不创建订单,也不执行支付动作。 diff --git a/docs/08-agent-api-contract.md b/docs/08-agent-api-contract.md index 96a7495..ac6aa5c 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: b722977b0c8b7db2ce0d3c8c2a1351f42941cb24 -synchronized_at: 2026-08-27T07:27:38Z +wiki_revision: 3d6a43359e015d4d6eaeb896f63dc1920ca601f6 +synchronized_at: 2026-08-27T09:58:31Z # MVP 共享 API 契约 @@ -141,7 +141,7 @@ GET /api/admin/v1/collection-rules/templates/pdd-product-detail-v1 - `activityName` 可声明该步骤允许执行的精确 Activity;商品详情页规则应同时校验 PDD 包名、目标 Activity 和唯一控件,避免登录页等同包页面误判为商品页。 - `input` 必须有 `value`,`extract` 必须有 `field`;单步 `timeoutMs` 范围为 100~30000 毫秒。 - `optional: true` 仅表示单步超时后继续,适合不同 ROM 可能不存在的浏览器/系统“打开”确认层;多匹配和动作执行失败仍立即失败。 -- 规则解析阶段拒绝支付、付款、提交订单等危险目标。完整控件树只在 Android 内存中用于匹配,不保存也不上传。 +- 规则解析阶段按用途校验文字:支付、付款、提交订单等可以出现在只读识别字段中,但不能成为点击目标;修改地址、收货地址在可配置点击目标和只读识别字段中都拒绝。完整控件树只在 Android 内存中用于匹配,不保存也不上传。 - 浏览器和 Android 系统包中的 `click` 只允许精确目标 `打开拼多多APP`、`打开拼多多 App` 或 `打开`;其它点击在规则解析阶段拒绝。Android 先以浏览器打开服务端已规范化的 PDD URL,再由这些受控步骤进入拼多多。 ### v2 PDD 商品详情规则 @@ -163,7 +163,7 @@ v2 完整示例见 [PDD 商品详情规则](rules/pdd-product-detail-v2.proposed {"hooks":{"afterSpecPanelOpen":[{"action":"swipe","target":"specPanel","direction":"up","count":2,"settleMs":350}]}} ``` -Agent 使用可扩展的类型化动作注册表,而不是任意脚本。采集规则不能创建订单;未来采购规则可以引用单独审核的创建订单能力,但任何规则都不能执行付款。 +Agent 使用可扩展的类型化动作注册表,而不是任意脚本。采集规则不能创建订单;采购规则可以引用单独审核的创建订单能力。当前契约不实现支付动作,`pay` / `payment` 动作仍被拒绝;订单和支付文字只允许作为只读识别信号,不能成为点击目标。 Android 的 `pddProductDetailV1` 采集器执行以下固定流程: @@ -393,7 +393,7 @@ POST /api/agent/v1/tasks/{taskId}/fail ## 采购任务共享契约(#33、#34、#36、#42、#44、#53、#67) -本节固定采购域的数据和接口边界;`purchase_task`、`purchase_task_attempt`、规则校验、服务端 HTTP 状态机、Admin 已有任务管理、SYB 创建入口、Android 演练执行及 #36 正式地址/订单动作已经落地。#36 尚未获得真机创建订单授权和验收。任何实现都不得扩展为自动支付。 +本节固定采购域的数据和接口边界;`purchase_task`、`purchase_task_attempt`、规则校验、服务端 HTTP 状态机、Admin 已有任务管理、SYB 创建入口、Android 演练执行及 #36 正式地址/订单动作已经落地。#36 尚未获得真机创建订单授权和验收。当前项目没有自动支付动作、入口或测试;后续支付能力必须另行建单并通过显式能力位、服务端开关、金额上限和人工授权门禁。 ### 任务与快照 @@ -469,14 +469,14 @@ POST /api/agent/v1/tasks/{taskId}/fail - `textAliases` 省略时使用 Agent 对该类型化动作的内置语义目标;显式提供时必须包含 1~16 个互不重复、无首尾空白的非空文字,每项最多 64 个字符。 - 文字候选按控件文字或内容描述**精确匹配**;候选合并后必须唯一命中。点击动作只允许点击唯一文字节点或其最近的可点击父容器,不允许模糊匹配、猜测相近候选、改点兄弟节点。 -- `textAliases` 不能包含地址修改、创建/提交订单、订单号或支付相关文字,防止用安全 action 绕过危险动作类型和能力门禁。 +- 动作 `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` 的动作以及未知动作一律拒绝;服务端不下发任意脚本。 +演练规则必须包含 `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/server/app/goauto/purchasecontract/contract.go b/server/app/goauto/purchasecontract/contract.go index 662e146..95b1069 100644 --- a/server/app/goauto/purchasecontract/contract.go +++ b/server/app/goauto/purchasecontract/contract.go @@ -49,8 +49,12 @@ var swipeAfterActions = map[string]bool{ "openProduct": true, "openSpecPanel": true, "selectSpec": true, "probeSpecs": true, } -var forbiddenTextFragments = []string{ - "pay", "payment", "支付", "付款", "免密", "修改地址", "收货地址", "创建订单", "提交订单", "订单号", +var forbiddenClickFragments = []string{ + "pay", "payment", "支付", "付款", "免密", "创建订单", "提交订单", "订单号", +} + +var forbiddenAlwaysFragments = []string{ + "修改地址", "收货地址", } type Action struct { @@ -109,7 +113,7 @@ func Validate(raw []byte, executionMode string) (RuleSnapshot, error) { } for index, action := range rule.Actions { if strings.EqualFold(action.Type, "pay") || strings.Contains(strings.ToLower(action.Type), "payment") { - return rule, fmt.Errorf("actions[%d] 包含永远禁止的支付动作", index) + return rule, fmt.Errorf("actions[%d] 包含尚未实现的支付动作", index) } if safeActions[action.Type] { if action.Type == "probeSpecs" && !capabilities[CapabilitySpecProbeV1] { @@ -157,7 +161,7 @@ func validateSafeActionParameters(index int, action Action) error { } seen[alias] = true lower := strings.ToLower(alias) - for _, fragment := range forbiddenTextFragments { + for _, fragment := range append(forbiddenAlwaysFragments, forbiddenClickFragments...) { if strings.Contains(lower, fragment) { return fmt.Errorf("actions[%d].textAliases[%d] 包含禁止的地址、下单或支付文字", index, aliasIndex) } @@ -188,6 +192,35 @@ func validateSafeActionParameters(index int, action Action) error { return nil } +// ValidateReadOnlyTextAliases validates text used only as page evidence. Order +// and payment wording is allowed here; address wording remains forbidden in +// every configurable field. Callers choose limits that match their contract. +func ValidateReadOnlyTextAliases(field string, aliases []string, maxAliases int, maxRunes int) error { + if len(aliases) == 0 || len(aliases) > maxAliases { + return fmt.Errorf("%s 必须包含 1..%d 项", field, maxAliases) + } + seen := make(map[string]bool, len(aliases)) + for index, alias := range aliases { + if alias == "" || strings.TrimSpace(alias) != alias { + return fmt.Errorf("%s[%d] 必须是无首尾空白的非空文字", field, index) + } + if !utf8.ValidString(alias) || utf8.RuneCountInString(alias) > maxRunes { + return fmt.Errorf("%s[%d] 最多包含 %d 个字符", field, index, maxRunes) + } + if seen[alias] { + return fmt.Errorf("%s 包含重复文字 %q", field, alias) + } + seen[alias] = true + lower := strings.ToLower(alias) + for _, fragment := range forbiddenAlwaysFragments { + if strings.Contains(lower, fragment) { + return fmt.Errorf("%s[%d] 包含禁止的地址文字", field, index) + } + } + } + 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 cb2bf4c..0f7ab07 100644 --- a/server/app/goauto/purchasecontract/contract_test.go +++ b/server/app/goauto/purchasecontract/contract_test.go @@ -104,11 +104,32 @@ func TestRehearsalRuleRejectsIrreversibleActions(t *testing.T) { func TestEveryModeRejectsPayment(t *testing.T) { raw := []byte(`{"schemaVersion":1,"ruleType":"pddPurchase","requiredCapabilities":["purchase.live.v1"],"actions":[{"type":"pay"}]}`) - if _, err := Validate(raw, "live"); err == nil || !strings.Contains(err.Error(), "禁止") { + if _, err := Validate(raw, "live"); err == nil || !strings.Contains(err.Error(), "尚未实现") { t.Fatalf("expected payment rejection, got %v", err) } } +func TestReadOnlyAliasesAllowOrderEvidenceAndRejectAddresses(t *testing.T) { + allowed := []string{"提交订单", "支付方式", "订单号", "付款说明"} + if err := ValidateReadOnlyTextAliases("collector.textAliases", allowed, 20, 30); err != nil { + t.Fatalf("read-only order evidence rejected: %v", err) + } + for _, forbidden := range []string{"修改地址", "查看收货地址"} { + if err := ValidateReadOnlyTextAliases("collector.textAliases", []string{forbidden}, 20, 30); err == nil || !strings.Contains(err.Error(), "地址") { + t.Fatalf("expected address rejection for %q, got %v", forbidden, err) + } + } +} + +func TestReadOnlyAliasesEnforceShapeLimits(t *testing.T) { + tests := [][]string{nil, {""}, {" 订单号"}, {"订单号", "订单号"}, {strings.Repeat("长", 31)}} + for _, aliases := range tests { + if err := ValidateReadOnlyTextAliases("collector.textAliases", aliases, 20, 30); err == nil { + t.Fatalf("expected read-only alias rejection for %#v", aliases) + } + } +} + func TestLiveRuleRequiresCapabilitiesForIrreversibleActions(t *testing.T) { raw := []byte(`{"schemaVersion":1,"ruleType":"pddPurchase","requiredCapabilities":["purchase.live.v1","purchase.address-update.v1","purchase.order-create.v1"],"actions":[{"type":"openProduct"},{"type":"updateShippingAddress"},{"type":"createOrder"},{"type":"readOrderResult"}]}`) rule, err := Validate(raw, "live") diff --git a/server/app/goauto/purchasecontract/default_test.go b/server/app/goauto/purchasecontract/default_test.go index 5bf47be..ae0d35b 100644 --- a/server/app/goauto/purchasecontract/default_test.go +++ b/server/app/goauto/purchasecontract/default_test.go @@ -7,9 +7,6 @@ import ( func TestDefaultLiveRuleIsValidAndNeverContainsPayment(t *testing.T) { raw := DefaultLiveRule() - if strings.Contains(strings.ToLower(string(raw)), "pay") || strings.Contains(string(raw), "支付") { - t.Fatalf("default live rule contains a payment action: %s", raw) - } rule, err := Validate(raw, "live") if err != nil { t.Fatal(err) @@ -18,6 +15,9 @@ func TestDefaultLiveRuleIsValidAndNeverContainsPayment(t *testing.T) { t.Fatalf("unexpected rule: %+v", rule) } for _, action := range rule.Actions { + if strings.EqualFold(action.Type, "pay") || strings.Contains(strings.ToLower(action.Type), "payment") { + t.Fatalf("default live rule contains a payment action: %+v", action) + } if action.Type == "openSpecPanel" && action.TextAliases != nil { t.Fatalf("default spec-panel action must use semantic safe entry detection, got aliases: %+v", action.TextAliases) }