feat(#33): define purchase task data and API contract

This commit is contained in:
QiuSW
2026-08-20 16:49:43 +08:00
parent 903280fada
commit 978f42806a
11 changed files with 749 additions and 20 deletions
+6 -6
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Project-Profile
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Project-Profile.-
wiki_revision: c94df94b3f7aa6d8d1b163bc77fa5b1462adbb0c
synchronized_at: 2026-08-20T08:05:07Z
wiki_revision: f9245c856a45c10dfbae7fe32b365ca11b008782
synchronized_at: 2026-08-20T08:47:11Z
<!-- gitea-wiki-mirror:end -->
# 项目档案
@@ -17,8 +17,8 @@ synchronized_at: 2026-08-20T08:05:07Z
| 主要使用者 | 管理员、采购人员、开发维护者 |
| Gitea 仓库 | `OPC/goauto` |
| 默认分支 | `main` |
| 当前已实施范围 | PDD 商品与采集闭环、Shopee/SYB 商品档案、SYB 接口导入、店铺准入管理与后台同步记录 |
| 后续设计范围 | 采购演练、创建待付款订单和物流回填 |
| 当前已实施范围 | PDD 商品与采集闭环、虾皮/SYB 商品档案、SYB 接口导入、店铺准入、后台同步记录、采购任务数据与共享契约 |
| 后续设计范围 | 采购任务服务、Android 演练、创建待付款订单和物流回填 |
| 预计规模 | 20 台 Android;每天约 100 个采集任务、200 个采购任务 |
## 建设基线
@@ -68,6 +68,6 @@ synchronized_at: 2026-08-20T08:05:07Z
#31 PDD 商品档案原型、数据库、API、Admin 页面和采集结果写回已于 2026-08-17 通过用户验收;#45 PDD 商品列表批量采集入口已于 2026-08-18 通过用户验收。#32 采购闭环原型及后续采购工单仍按各自门禁推进。采购永不支付,真实地址修改和创建订单属于必须再次人工确认的高风险范围。
#49 已完成 SYB 店铺表、管理 API、Admin 只读/管理界面、真实列表发现及导入双重过滤,正在等待用户验收。真实 MySQL 迁移和既有错误导入数据清理尚未执行;清理必须先确认精确 SQL 与影响行数。
#40、#41、#49~#52 已于 2026-08-20 通过用户验收,虾皮/SYB 商品档案、店铺过滤、异步同步记录、列表布局和 MySQL 同步缺陷修复均已完成。
#50 原型已于 2026-08-20 通过用户验收,后台导入任务、持久化进度、同步记录页面、单任务互斥和启动中断恢复已实现,正在等待代码验收;尚未对本地真实 MySQL 执行新增迁移,也未用真实 SYB 长任务做在线验证。
#33 已建立采购任务、任务尝试和可选 PDD 账号引用的数据契约,完成规则能力隔离与 MySQL 8.4 迁移验证,等待用户验收。HTTP 状态机、Admin 页面和 Android 执行仍属于后续工单;当前实现不会创建 PDD 订单。
+8 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Architecture-and-Code-Map
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.-
wiki_revision: c94df94b3f7aa6d8d1b163bc77fa5b1462adbb0c
synchronized_at: 2026-08-20T08:05:15Z
wiki_revision: f9245c856a45c10dfbae7fe32b365ca11b008782
synchronized_at: 2026-08-20T08:47:11Z
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
@@ -61,6 +61,9 @@ Android Portal/Agent
| `collection_color_price` | 任务、颜色、该颜色统一使用的整数分价格 |
| `collection_sku` | 任务、整数分价格、可用性、完整性 |
| `collection_sku_value` | SKU 与规格值的多对多关联 |
| `pdd_account` | 可选的账号调度引用,只保存名称和状态,不保存凭据 |
| `purchase_task` | 商品外键和不可变快照、执行模式、状态/租约 guard、价格边界、订单、人工支付复核、物流与回填事实 |
| `purchase_task_attempt` | `task_id + attempt_id` 幂等执行记录、阶段、规则哈希、固化规格决策和结构化错误 |
`collection_task` 的状态仅为 `pending`、`running`、`completed`、`completed_partial`、`failed`。设备身份和心跳表属于 Agent 领取任务的必要基础,不承载 PDD 业务数据。
@@ -68,6 +71,8 @@ Android Portal/Agent
数据库使用两个可空 guard 列表达跨数据库唯一约束:活动任务的 `active_slot=1`,运行中设备的 `device_run_slot=1`;终态记录对应列为 `NULL`。复合唯一索引据此保证同商品最多一个活动任务、同设备最多一个运行中任务,同时允许保留任意数量的终态历史任务。状态与 guard 列还有数据库检查约束,必须在同一条状态变更语句中更新。
采购表使用同一 guard 思路:`purchase_task.active_slot` 保证同一 SYB 明细最多一个活动任务,`device_run_slot` 保证设备串行,`account_run_slot` 在账号已知时保证账号串行。账号未知是合法状态。正式任务必须引用 SYB 的规则由模型钩子和后续创建服务双重校验;MySQL 8.4 不允许 `syb_product_id` 同时参与带参照动作的外键和跨字段 CHECK,因此不在该列添加数据库 CHECK。
## 配置分层
配置分三层,下层覆盖上层:
@@ -112,6 +117,7 @@ Android Portal/Agent
| SYB 商品明细增量迁移 | `server/cmd/migrate/migration/version-local/1786700700000_syb_product_import.go` |
| SYB 店铺准入、发现与过滤 | `server/app/goauto/sybshop/`、`server/app/goauto/sybimport/`;迁移 `server/cmd/migrate/migration/version-local/1786700900000_syb_shop.go` |
| SYB 后台导入任务、进度、单任务互斥与启动恢复 | `server/app/goauto/sybimport/sync_run.go`、`sync_run_handler.go`;表 `syb_sync_run`,迁移 `server/cmd/migrate/migration/version-local/1786701000000_syb_sync_run.go` |
| 采购任务数据与类型化规则契约 | `server/app/goauto/models/purchase.go`、`server/app/goauto/purchasecontract/`;迁移 `server/cmd/migrate/migration/version-local/1786701100000_purchase_contract.go` |
| 任务领取、结果、重置与删除 | `server/app/goauto/task/` |
| 管理端基线 | `web/`(go-admin-ui v3.0.0) |
| 管理端闭环页面 | `web/src/views/goauto/` |
+18 -3
View File
@@ -2,15 +2,15 @@
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: 8506167d120ed2e80de7e1953629185e1a6271e5
synchronized_at: 2026-08-20T08:05:18Z
wiki_revision: f9245c856a45c10dfbae7fe32b365ca11b008782
synchronized_at: 2026-08-20T08:47:11Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
## 当前范围
当前已实施范围包含 PDD 商品最新档案、规则、单个及批量采集任务、Android 执行和结构化结果。顺云宝、Shopee、采购、规则发布流程、全局停机和实时屏幕仍由独立工单推进。
当前已实施范围包含 PDD 商品最新档案与采集闭环、虾皮商品档案、SYB 商品与后台导入,以及采购任务数据和规则契约。采购任务服务、Admin 页面、Android 演练、真实创建待付款订单和物流回填仍由独立工单推进。
## PDD 商品
@@ -103,6 +103,21 @@ synchronized_at: 2026-08-20T08:05:18Z
- 存储时展开 SKU 组合,将颜色价格复制到该颜色各尺码 SKU。
- SKU 不完整仍提交已有结果并标记 `completed_partial`。
## 采购任务契约
- 采购域使用独立的 `purchase_task`、`purchase_task_attempt` 和可选 `pdd_account` 引用;PDD、虾皮和 SYB 商品表不保存采购订单、支付、物流或回填字段。
- 正式任务必须引用一条 SYB 商品明细;演练任务可以从 PDD 商品人工创建且不引用 SYB。
- 创建时固化三个商品身份、目标和映射规格、数量、价格区间、币种、URL、`goods_id`、规则、设备和可选账号引用。商品档案后续修改不改变任务解释。
- Android 无法可靠识别登录中的 PDD 账号,因此账号引用可空;已知账号才参与账号级串行,未知账号不会阻止采购任务。
- 同一 SYB 明细可以保留多个历史采购任务,但最多只能有一个活动任务。重新采购新建任务,旧订单不删除、不覆盖。
- `execution_mode` 创建后不可变:`rehearsal` 只能完成商品、规格、数量和价格复核;`live` 才可能获得改地址和创建订单能力。任何模式永远禁止支付。
- 演练规则在服务端契约层拒绝改地址、创建订单和核单动作;正式动作还必须通过版本化能力协商。规则只包含类型化动作,禁止任意脚本。
- 价格保护保存参考单价、最低单价、最高单价和币种,执行时以 PDD App 实际单价判断;价格越界明确失败,不考虑优惠券。
- 地址后缀为 `_cg{purchase_task.id}`。修改失败时禁止创建订单;任务与订单正式关联仍以完整 PDD 订单号为准。
- 点击创建订单前必须先保存 `order_submit_started` 和不可逆时间;结果不明时进入 `order_result_unknown`,禁止自动再次点击。
- Agent 本地 Room/Outbox 负责断网和重启恢复,服务端以 `task_id + task_attempt_id` 幂等接收并保存最终事实。
- 人工支付复核只记录 `paid` / `unpaid`;系统不执行或识别支付。快递单号与回填状态属于采购任务,后续物流工单实现。
## 自动化边界
- 浏览器“打开拼多多APP”和系统确认框“打开”属于允许动作。
+90 -2
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: c94df94b3f7aa6d8d1b163bc77fa5b1462adbb0c
synchronized_at: 2026-08-20T08:05:39Z
wiki_revision: f9245c856a45c10dfbae7fe32b365ca11b008782
synchronized_at: 2026-08-20T08:47:11Z
<!-- gitea-wiki-mirror:end -->
# MVP 共享 API 契约
@@ -368,3 +368,91 @@ POST /api/agent/v1/tasks/{taskId}/fail
| `TASK_ALREADY_CLAIMED` | 未指定任务已被其他设备领取 | 否 |
| `DEVICE_BUSY` | 设备已有活动任务 | 否 |
| `DEVICE_CAPABILITY_MISMATCH` | 设备缺少任务规则要求的版本化能力 | 否 |
## 采购任务共享契约(#33)
本节固定采购域的数据和接口边界;`purchase_task`、`purchase_task_attempt` 与规则校验已经落地,HTTP 路由、状态机服务和 Admin 页面分别由 #34、#35、#44 实现。任何实现都不得扩展为自动支付。
### 任务与快照
- `executionMode` 只能是 `rehearsal` 或 `live`,创建后不可修改。
- 正式 `live` 任务必须引用一条 `syb_product`;无副作用的 `rehearsal` 可以不引用 SYB。
- 创建时固化 SYB、虾皮商品、PDD 商品、目标规格、映射规格、数量、价格区间、币种、URL、`goods_id`、规则和可选 PDD 账号引用。以后商品档案修改不会改写任务。
- Android 无法可靠读取当前 PDD 账号,因此 `pddAccountId` 和 `pddAccountRefSnapshot` 均可空。已知账号时参与账号级串行,未知时不阻止任务。
- 地址后缀由任务 ID 唯一确定为 `_cg{taskId}`;#34 创建任务时必须在同一事务内回写快照。
- 一个任务最多对应一个 PDD 订单;重新采购必须新建任务,旧任务和旧订单保留。
状态集合:
| 状态 | 含义 | 是否占用 SYB 活动槽 |
|---|---|---|
| `pending` | 待执行 | 是 |
| `spec_probe_pending` | 第一趟探测结束,待服务端固化规格并重新派发 | 是 |
| `running` | Agent 执行中 | 是 |
| `rehearsal_completed` | 演练安全结束,未改地址、未创建订单 | 否 |
| `order_submit_started` | 不可逆标记已落库,只能核单,禁止再次点击 | 是 |
| `order_created` | 已取得唯一 PDD 订单号和下单时间 | 是 |
| `order_result_unknown` | 无法确认是否下单,必须人工处理 | 是 |
| `failed` / `cancelled` | 终态 | 否 |
同一个 `sybProductId` 最多一个占用活动槽的任务;一个设备最多一个执行中的采购任务;已知的同一个 PDD 账号最多一个执行中的采购任务。终态历史不删除。
### 规则快照与能力
采购规则是 JSON 对象:
```json
{
"schemaVersion": 1,
"ruleType": "pddPurchase",
"requiredCapabilities": ["purchase.rehearsal.v1"],
"actions": [
{"type": "openProduct"},
{"type": "verifyProduct"},
{"type": "selectSpec"},
{"type": "setQuantity"},
{"type": "verifyUnitPrice"},
{"type": "verifyOrderSummary"}
]
}
```
演练规则必须包含 `purchase.rehearsal.v1`,并且不能包含 `updateShippingAddress`、`createOrder`、`readOrderResult`。正式规则必须包含 `purchase.live.v1`;改地址和创建订单还分别要求 `purchase.address-update.v1`、`purchase.order-create.v1`。`probeSpecs` 要求 `purchase.spec-probe.v1`。任意模式下,`pay`、名称包含 `payment` 的动作以及未知动作一律拒绝;服务端不下发任意脚本。
### 管理端接口(由 #34/#35/#44 实现)
| 方法 | 路径 | 幂等键 / 说明 |
|---|---|---|
| `POST` | `/api/admin/v1/purchase-tasks` | `requestId`;单条创建 |
| `POST` | `/api/admin/v1/purchase-tasks/batch` | 批次 `requestId`;逐条成功或失败,不合并任务 |
| `GET` | `/api/admin/v1/purchase-tasks` | 分页列表 |
| `GET` | `/api/admin/v1/purchase-tasks/{taskId}` | 任务、快照、attempt、订单与物流事实 |
| `POST` | `/api/admin/v1/purchase-tasks/{taskId}/authorize-repurchase` | 一次性授权;创建新任务后自动消耗 |
| `POST` | `/api/admin/v1/purchase-tasks/{taskId}/payment-review` | `paid` 或 `unpaid`,管理员和采购员可操作 |
| `POST` | `/api/admin/v1/purchase-tasks/{taskId}/select-writeback` | 人工选择回填候选;允许明确覆盖旧选择 |
创建请求的价格保护使用整数分:`referenceUnitPriceCent`、`minUnitPriceCent`、`maxUnitPriceCent` 和 `currency`。执行时以 PDD App 实际单价校验;低于最小值或高于最大值均返回普通人可理解的价格越界错误,不考虑优惠券,不以订单总价替代单价判断。
### Android 接口(由 #34 实现)
| 方法 | 路径 | 说明 |
|---|---|---|
| `GET` | `/api/agent/v1/purchase-tasks/next` | 返回与设备能力兼容的指定任务或空闲任务 |
| `POST` | `/api/agent/v1/purchase-tasks/{taskId}/claim` | `requestId` 原子领取,并建立设备/可选账号租约 |
| `POST` | `/api/agent/v1/purchase-tasks/{taskId}/start` | 创建不可变 `taskAttemptId` |
| `POST` | `/api/agent/v1/purchase-tasks/{taskId}/attempts/{taskAttemptId}/result` | `requestId`;幂等提交演练、规格探测、订单或失败结果 |
结果提交至少关联 `taskId`、`taskAttemptId`、`deviceId`、规则快照哈希和结构化结果。相同 attempt 的重复提交必须返回同一事实;不同内容不得覆盖。慢路径第一趟提交规格后释放设备租约,任务进入 `spec_probe_pending`;服务端固化同一 attempt 的 AI 决策后,第二趟使用新的 attempt 重新派发。
Agent Room 只保存恢复执行所需的任务、attempt 和 Outbox;服务端数据库是最终事实来源。双方均不保存原始控件树、截图、PDD 凭据或完整收货地址。
| 错误码 | 普通提示 |
|---|---|
| `PURCHASE_MODE_NOT_ALLOWED` | 当前任务模式不允许执行此操作 |
| `PURCHASE_RULE_INVALID` | 采购规则不可用,请联系管理员 |
| `AGENT_CAPABILITY_MISMATCH` | 当前手机版本不支持这个任务 |
| `PURCHASE_SPEC_NOT_MATCHED` | 没有找到可用的商品规格 |
| `PURCHASE_PRICE_OUT_OF_RANGE` | 当前商品单价超出允许范围 |
| `PURCHASE_ADDRESS_UPDATE_FAILED` | 收货地址修改失败,未创建订单 |
| `PURCHASE_ORDER_RESULT_UNKNOWN` | 无法确认订单是否创建,请人工检查 |
| `PURCHASE_PAYMENT_FORBIDDEN` | 系统禁止自动付款 |
+9 -7
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: c94df94b3f7aa6d8d1b163bc77fa5b1462adbb0c
synchronized_at: 2026-08-20T08:05:43Z
wiki_revision: f9245c856a45c10dfbae7fe32b365ca11b008782
synchronized_at: 2026-08-20T08:47:11Z
<!-- gitea-wiki-mirror:end -->
# 当前 MVP 交付工单索引
@@ -53,20 +53,22 @@ synchronized_at: 2026-08-20T08:05:43Z
|---|---|---|---|
| T29 | [#31](https://git.ilapage.cn/OPC/goauto/issues/31) | PDD 商品采购档案与规格 JSON 管理(2026-08-17 已验收) | 已完成 |
| T30 | [#32](https://git.ilapage.cn/OPC/goauto/issues/32) | 采购闭环数据关系与交互原型(2026-08-18 已验收) | 已完成;原型已确认,是全部采购代码依据 |
| T31 | [#33](https://git.ilapage.cn/OPC/goauto/issues/33) | 采购任务数据模型与共享 API 契约 | #31、#40、#41;#32 原型已通过 |
| T31 | [#33](https://git.ilapage.cn/OPC/goauto/issues/33) | 采购任务数据模型与共享 API 契约 | 已实施,等待验收;MySQL 8.4 迁移已验证 |
| T32 | [#34](https://git.ilapage.cn/OPC/goauto/issues/34) | 服务端采购任务、租约、幂等与状态机 | #33 |
| T33 | [#35](https://git.ilapage.cn/OPC/goauto/issues/35) | Admin 采购任务与人工处理页面 | #33、#34 |
| T34 | [#36](https://git.ilapage.cn/OPC/goauto/issues/36) | Android 地址后缀、不可逆门禁与创建订单 | #33、#34、#42;真机前再次人工确认 |
| T35 | [#37](https://git.ilapage.cn/OPC/goauto/issues/37) | 服务端物流调度与货运宝自动回填 | 采购任务与有效订单能力 |
| T36 | [#38](https://git.ilapage.cn/OPC/goauto/issues/38) | Android PDD 订单物流采集规则 | 采购订单关联契约 |
| T37 | [#39](https://git.ilapage.cn/OPC/goauto/issues/39) | 采购闭环真机端到端验收 | #33~#38、#42 |
| T38 | [#40](https://git.ilapage.cn/OPC/goauto/issues/40) | 虾皮商品档案、PDD 关联与规格映射 | #31;商品域独立于采购任务;Stage B 服务端与 Admin 页面均已完成 |
| T39 | [#41](https://git.ilapage.cn/OPC/goauto/issues/41) | SYB 货运单商品导入与虾皮信息提取 | #40;源数据域独立于采购任务;Stage B 解析/落库/管理端 API/Admin 页面已完成,SYB 接口拉取客户端待实施 |
| 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;只演练,不改地址、不创建订单 |
| 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 同步店铺过滤 | #48;代码与原型已完成,等待验收;真实迁移和历史数据清理需另行确认 |
| T48 | [#50](https://git.ilapage.cn/OPC/goauto/issues/50) | SYB 导入后台任务与进度展示 | #48、#49;原型已验收,代码已实现并等待验收 |
| 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 已验收) | 已完成 |
推荐依赖顺序:#31、#40、#41 完成商品域 → #33、#34 建立采购契约和服务端状态机 → #42 完成不下单演练 → #35 管理端人工处理 → #36 高风险真实订单动作 → #37、#38 物流闭环 → #39 真机总验收。
+3
View File
@@ -20,6 +20,9 @@ func MigratedModels() []any {
&models.SYBSession{},
&models.SYBShop{},
&models.SYBSyncRun{},
&models.PDDAccount{},
&models.PurchaseTask{},
&models.PurchaseTaskAttempt{},
&models.CollectionRule{},
&models.CollectionTask{},
&models.CollectionDimension{},
@@ -0,0 +1,199 @@
package migrations_test
import (
"reflect"
"strings"
"testing"
"time"
"go-admin/app/goauto/models"
)
type purchaseFixtures struct {
syb models.SYBProduct
shopee models.ShopeeProduct
pdd models.PDDProduct
device models.AgentDevice
account models.PDDAccount
}
func seedPurchaseFixtures(t *testing.T) purchaseFixtures {
t.Helper()
db := openDatabase(t)
now := time.Now().UTC()
pdd := models.PDDProduct{GoodsID: "719834019024", URL: "https://mobile.yangkeduo.com/goods.html?goods_id=719834019024", Title: "PDD 快照标题"}
if err := db.Create(&pdd).Error; err != nil {
t.Fatalf("create pdd: %v", err)
}
shopee := models.ShopeeProduct{ShopeeItemID: "26154802794", Title: "虾皮快照标题", ShopName: "测试店铺", PDDProductID: &pdd.ID, Currency: "TWD"}
if err := db.Create(&shopee).Error; err != nil {
t.Fatalf("create shopee: %v", err)
}
syB := models.SYBProduct{OrderCode: "TEST-CODE", DetailID: 1001, StockID: 2001, ShopeeItemID: shopee.ShopeeItemID, ShopeeProductID: &shopee.ID, ProductTitle: shopee.Title, TargetColor: "黑色", TargetSize: "XL", Quantity: 2, UnitPriceCent: 23900, ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{}`}
if err := db.Create(&syB).Error; err != nil {
t.Fatalf("create syb: %v", err)
}
device := models.AgentDevice{InstallID: "purchase-test-install", Name: "Samsung", Manufacturer: "Samsung", Model: "S24", AndroidVersion: "15", AgentVersion: "1.0", Status: models.DeviceStatusOnline, TokenDigest: strings.Repeat("c", 64), TokenIssuedAt: now}
if err := db.Create(&device).Error; err != nil {
t.Fatalf("create device: %v", err)
}
account := models.PDDAccount{Name: "测试账号引用", Status: "active"}
if err := db.Create(&account).Error; err != nil {
t.Fatalf("create account: %v", err)
}
return purchaseFixtures{syb: syB, shopee: shopee, pdd: pdd, device: device, account: account}
}
func newPurchaseTask(fixtures purchaseFixtures, requestID string, status string) models.PurchaseTask {
deviceID, accountID, sybID := fixtures.device.ID, fixtures.account.ID, fixtures.syb.ID
return models.PurchaseTask{
SYBProductID: &sybID, ShopeeProductID: fixtures.shopee.ID, PDDProductID: fixtures.pdd.ID,
DeviceID: &deviceID, PDDAccountID: &accountID, ExecutionMode: models.PurchaseExecutionModeLive, Status: status,
ShopeeItemIDSnapshot: fixtures.shopee.ShopeeItemID, ShopeeTitleSnapshot: fixtures.shopee.Title,
ShopeeShopNameSnapshot: fixtures.shopee.ShopName, PDDURLSnapshot: fixtures.pdd.URL,
PDDGoodsIDSnapshot: fixtures.pdd.GoodsID, PDDTitleSnapshot: fixtures.pdd.Title,
TargetColorSnapshot: fixtures.syb.TargetColor, TargetSizeSnapshot: fixtures.syb.TargetSize,
MappedColorSnapshot: "黑色", MappedSizeSnapshot: "XL", SpecSource: "manual_mapping",
Quantity: fixtures.syb.Quantity, ReferenceUnitPriceCent: 2000, MinUnitPriceCent: 400,
MaxUnitPriceCent: 3000, Currency: "CNY", RuleType: "pddPurchase", RuleSchemaVersion: 1,
RequiredCapabilitiesJSON: `[]`, RuleSnapshot: `{}`, CreateRequestID: requestID,
}
}
func TestPurchaseContractTablesAreMigrated(t *testing.T) {
db := openDatabase(t)
for _, table := range []string{"pdd_account", "purchase_task", "purchase_task_attempt"} {
if !db.Migrator().HasTable(table) {
t.Fatalf("missing table %s", table)
}
}
}
func TestPurchaseSYBForeignKeyDoesNotUseMySQLIncompatibleCheck(t *testing.T) {
field, ok := reflect.TypeOf(models.PurchaseTask{}).FieldByName("SYBProductID")
if !ok {
t.Fatal("PurchaseTask.SYBProductID missing")
}
if strings.Contains(strings.ToLower(field.Tag.Get("gorm")), "check:") {
t.Fatalf("MySQL 8.4 rejects CHECK plus foreign-key referential actions on syb_product_id: %s", field.Tag.Get("gorm"))
}
}
func TestLivePurchaseRequiresSYBButRehearsalMayOmitIt(t *testing.T) {
db := openDatabase(t)
fixtures := seedPurchaseFixtures(t)
live := newPurchaseTask(fixtures, "00000000-0000-0000-0000-000000000001", models.PurchaseTaskStatusPending)
live.SYBProductID = nil
if err := db.Create(&live).Error; err == nil || !strings.Contains(err.Error(), "requires syb_product_id") {
t.Fatalf("expected live SYB requirement, got %v", err)
}
rehearsal := newPurchaseTask(fixtures, "00000000-0000-0000-0000-000000000002", models.PurchaseTaskStatusPending)
rehearsal.ExecutionMode = models.PurchaseExecutionModeRehearsal
rehearsal.SYBProductID = nil
if err := db.Create(&rehearsal).Error; err != nil {
t.Fatalf("rehearsal without SYB should be allowed: %v", err)
}
}
func TestOneActivePurchaseTaskPerSYBAndHistoryIsPreserved(t *testing.T) {
db := openDatabase(t)
fixtures := seedPurchaseFixtures(t)
first := newPurchaseTask(fixtures, "00000000-0000-0000-0000-000000000011", models.PurchaseTaskStatusPending)
if err := db.Create(&first).Error; err != nil {
t.Fatalf("create first: %v", err)
}
second := newPurchaseTask(fixtures, "00000000-0000-0000-0000-000000000012", models.PurchaseTaskStatusPending)
if err := db.Create(&second).Error; err == nil {
t.Fatal("second active task for one SYB row was accepted")
}
if err := first.SetStatus(models.PurchaseTaskStatusCancelled); err != nil {
t.Fatal(err)
}
if err := db.Save(&first).Error; err != nil {
t.Fatalf("cancel first: %v", err)
}
if err := db.Create(&second).Error; err != nil {
t.Fatalf("new task after cancellation should be allowed: %v", err)
}
var count int64
db.Model(&models.PurchaseTask{}).Where("syb_product_id = ?", fixtures.syb.ID).Count(&count)
if count != 2 {
t.Fatalf("expected full task history, got %d", count)
}
}
func TestRunningPurchaseUsesDeviceAndOptionalAccountLocks(t *testing.T) {
db := openDatabase(t)
fixtures := seedPurchaseFixtures(t)
first := newPurchaseTask(fixtures, "00000000-0000-0000-0000-000000000021", models.PurchaseTaskStatusRunning)
if err := db.Create(&first).Error; err != nil {
t.Fatalf("create running task: %v", err)
}
otherFixtures := fixtures
otherFixtures.syb.ID = 0
otherFixtures.syb.OrderCode = "TEST-CODE-2"
otherFixtures.syb.DetailID = 1002
if err := db.Create(&otherFixtures.syb).Error; err != nil {
t.Fatalf("create second syb row: %v", err)
}
second := newPurchaseTask(otherFixtures, "00000000-0000-0000-0000-000000000022", models.PurchaseTaskStatusRunning)
if err := db.Create(&second).Error; err == nil {
t.Fatal("same device/account should not run two purchase tasks")
}
withoutAccount := newPurchaseTask(otherFixtures, "00000000-0000-0000-0000-000000000023", models.PurchaseTaskStatusPending)
withoutAccount.PDDAccountID = nil
withoutAccount.PDDAccountRefSnapshot = ""
if err := db.Create(&withoutAccount).Error; err != nil {
t.Fatalf("unknown PDD account must remain allowed: %v", err)
}
}
func TestPurchaseSnapshotsDoNotChangeWithProductArchives(t *testing.T) {
db := openDatabase(t)
fixtures := seedPurchaseFixtures(t)
task := newPurchaseTask(fixtures, "00000000-0000-0000-0000-000000000031", models.PurchaseTaskStatusPending)
if err := db.Create(&task).Error; err != nil {
t.Fatalf("create task: %v", err)
}
db.Model(&models.ShopeeProduct{}).Where("id = ?", fixtures.shopee.ID).Update("title", "已修改标题")
db.Model(&models.PDDProduct{}).Where("id = ?", fixtures.pdd.ID).Updates(map[string]any{"title": "已修改 PDD 标题", "url": "https://example.invalid/new"})
var stored models.PurchaseTask
if err := db.First(&stored, task.ID).Error; err != nil {
t.Fatal(err)
}
if stored.ShopeeTitleSnapshot != "虾皮快照标题" || stored.PDDTitleSnapshot != "PDD 快照标题" || stored.PDDURLSnapshot != fixtures.pdd.URL {
t.Fatalf("snapshot changed with archive: %+v", stored)
}
}
func TestPurchaseAttemptIdempotency(t *testing.T) {
db := openDatabase(t)
fixtures := seedPurchaseFixtures(t)
task := newPurchaseTask(fixtures, "00000000-0000-0000-0000-000000000041", models.PurchaseTaskStatusPending)
if err := db.Create(&task).Error; err != nil {
t.Fatal(err)
}
attempt := models.PurchaseTaskAttempt{TaskID: task.ID, AttemptID: "10000000-0000-0000-0000-000000000001", AttemptNumber: 1, Phase: models.PurchaseAttemptPhasePurchase, Status: models.PurchaseAttemptStatusPending, RuleSnapshotHash: strings.Repeat("a", 64), SpecDecisionSnapshot: `{}`}
if err := db.Create(&attempt).Error; err != nil {
t.Fatal(err)
}
duplicate := attempt
duplicate.ID = 0
if err := db.Create(&duplicate).Error; err == nil {
t.Fatal("duplicate task attempt was accepted")
}
}
func TestProductModelsContainNoPurchaseOrderOrLogisticsFields(t *testing.T) {
for _, model := range []any{models.PDDProduct{}, models.ShopeeProduct{}, models.SYBProduct{}} {
typ := reflect.TypeOf(model)
for _, forbidden := range []string{"Purchase", "OrderNo", "Tracking", "Payment", "Writeback"} {
for i := 0; i < typ.NumField(); i++ {
if strings.Contains(typ.Field(i).Name, forbidden) {
t.Fatalf("%s unexpectedly owns purchase field %s", typ.Name(), typ.Field(i).Name)
}
}
}
}
}
+231
View File
@@ -0,0 +1,231 @@
package models
import (
"fmt"
"time"
"gorm.io/gorm"
)
const (
PurchaseExecutionModeRehearsal = "rehearsal"
PurchaseExecutionModeLive = "live"
PurchaseTaskStatusPending = "pending"
PurchaseTaskStatusSpecProbePending = "spec_probe_pending"
PurchaseTaskStatusRunning = "running"
PurchaseTaskStatusRehearsalCompleted = "rehearsal_completed"
PurchaseTaskStatusOrderSubmitStarted = "order_submit_started"
PurchaseTaskStatusOrderCreated = "order_created"
PurchaseTaskStatusOrderResultUnknown = "order_result_unknown"
PurchaseTaskStatusFailed = "failed"
PurchaseTaskStatusCancelled = "cancelled"
PurchaseAttemptPhaseSpecProbe = "spec_probe"
PurchaseAttemptPhasePurchase = "purchase"
PurchaseAttemptStatusPending = "pending"
PurchaseAttemptStatusRunning = "running"
PurchaseAttemptStatusCompleted = "completed"
PurchaseAttemptStatusFailed = "failed"
PurchasePaymentReviewPending = "pending"
PurchasePaymentReviewPaid = "paid"
PurchasePaymentReviewUnpaid = "unpaid"
PurchaseLogisticsStatusPending = "pending"
PurchaseLogisticsStatusCollected = "collected"
PurchaseLogisticsStatusFailed = "failed"
PurchaseWritebackStatusNotSelected = "not_selected"
PurchaseWritebackStatusPending = "pending"
PurchaseWritebackStatusRunning = "running"
PurchaseWritebackStatusSucceeded = "succeeded"
PurchaseWritebackStatusFailed = "failed"
)
// PDDAccount is an optional operator-maintained reference used only for
// account-level scheduling. Android cannot reliably read the logged-in PDD
// account, so purchase tasks never require this relation and this table never
// stores credentials, cookies, phone numbers or other account secrets.
type PDDAccount struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
Name string `json:"name" gorm:"size:100;not null;uniqueIndex:ux_pdd_account_name"`
Status string `json:"status" gorm:"size:16;not null;default:active;check:ck_pdd_account_status,status IN ('active','disabled')"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (PDDAccount) TableName() string { return "pdd_account" }
// PurchaseTask is the server-side business fact for one SYB product line and
// one PDD order at most. Re-purchase creates another row; old rows and orders
// are never overwritten or deleted. Product-domain records remain independent:
// this table consumes them through foreign keys plus immutable snapshots.
type PurchaseTask struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
// MySQL 8.4 rejects a column used by both a foreign key with referential
// actions and a cross-field CHECK. The live-mode requirement is therefore
// enforced by the model hook and the #34 creation service, not a DB CHECK.
SYBProductID *uint64 `json:"sybProductId" gorm:"index;uniqueIndex:ux_purchase_task_active_syb,priority:1"`
SYBProduct *SYBProduct `json:"-" gorm:"constraint:OnUpdate:CASCADE,OnDelete:RESTRICT"`
ShopeeProductID uint64 `json:"shopeeProductId" gorm:"not null;index"`
ShopeeProduct ShopeeProduct `json:"-" gorm:"constraint:OnUpdate:CASCADE,OnDelete:RESTRICT"`
PDDProductID uint64 `json:"pddProductId" gorm:"not null;index"`
PDDProduct PDDProduct `json:"-" gorm:"constraint:OnUpdate:CASCADE,OnDelete:RESTRICT"`
DeviceID *uint64 `json:"deviceId" gorm:"index;uniqueIndex:ux_purchase_task_running_device,priority:1"`
Device *AgentDevice `json:"-"`
PDDAccountID *uint64 `json:"pddAccountId" gorm:"index;uniqueIndex:ux_purchase_task_running_account,priority:1"`
PDDAccount *PDDAccount `json:"-" gorm:"constraint:OnUpdate:CASCADE,OnDelete:RESTRICT"`
ExecutionMode string `json:"executionMode" gorm:"size:16;not null;check:ck_purchase_task_execution_mode,execution_mode IN ('rehearsal','live')"`
Status string `json:"status" gorm:"size:32;not null;index;check:ck_purchase_task_status,status IN ('pending','spec_probe_pending','running','rehearsal_completed','order_submit_started','order_created','order_result_unknown','failed','cancelled')"`
// Nullable guard columns make uniqueness portable to SQLite and MySQL.
// ActiveSlot limits one non-terminal live task per SYB line. DeviceRunSlot
// and AccountRunSlot protect only the period in which Agent actions run.
ActiveSlot *uint8 `json:"-" gorm:"uniqueIndex:ux_purchase_task_active_syb,priority:2"`
DeviceRunSlot *uint8 `json:"-" gorm:"uniqueIndex:ux_purchase_task_running_device,priority:2"`
AccountRunSlot *uint8 `json:"-" gorm:"uniqueIndex:ux_purchase_task_running_account,priority:2"`
ShopeeItemIDSnapshot string `json:"shopeeItemIdSnapshot" gorm:"size:64;not null"`
ShopeeTitleSnapshot string `json:"shopeeTitleSnapshot" gorm:"size:500;not null;default:''"`
ShopeeShopNameSnapshot string `json:"shopeeShopNameSnapshot" gorm:"size:255;not null;default:''"`
PDDURLSnapshot string `json:"pddUrlSnapshot" gorm:"type:text;not null"`
PDDGoodsIDSnapshot string `json:"pddGoodsIdSnapshot" gorm:"size:32;not null;index"`
PDDTitleSnapshot string `json:"pddTitleSnapshot" gorm:"size:500;not null;default:''"`
TargetColorSnapshot string `json:"targetColorSnapshot" gorm:"size:255;not null;default:''"`
TargetSizeSnapshot string `json:"targetSizeSnapshot" gorm:"size:255;not null;default:''"`
MappedColorSnapshot string `json:"mappedColorSnapshot" gorm:"size:255;not null;default:''"`
MappedSizeSnapshot string `json:"mappedSizeSnapshot" gorm:"size:255;not null;default:''"`
SpecSource string `json:"specSource" gorm:"size:32;not null;default:unresolved;check:ck_purchase_task_spec_source,spec_source IN ('unresolved','manual_mapping','exact_match','ai_match')"`
SpecDecisionSnapshot string `json:"-" gorm:"type:json;not null"`
Quantity int64 `json:"quantity" gorm:"not null;check:ck_purchase_task_quantity,quantity >= 1"`
ReferenceUnitPriceCent int64 `json:"referenceUnitPriceCent" gorm:"not null;check:ck_purchase_task_reference_price,reference_unit_price_cent >= 0"`
MinUnitPriceCent int64 `json:"minUnitPriceCent" gorm:"not null;check:ck_purchase_task_min_price,min_unit_price_cent >= 0"`
MaxUnitPriceCent int64 `json:"maxUnitPriceCent" gorm:"not null;check:ck_purchase_task_max_price,max_unit_price_cent >= min_unit_price_cent"`
Currency string `json:"currency" gorm:"size:3;not null"`
RuleType string `json:"ruleType" gorm:"size:32;not null"`
RuleSchemaVersion int `json:"ruleSchemaVersion" gorm:"not null"`
RequiredCapabilitiesJSON string `json:"-" gorm:"type:json;not null"`
RuleSnapshot string `json:"-" gorm:"type:json;not null"`
PDDAccountRefSnapshot string `json:"pddAccountRefSnapshot" gorm:"size:100;not null;default:''"`
AddressSuffix string `json:"addressSuffix" gorm:"size:32;not null;default:''"`
LeaseExpiresAt *time.Time `json:"leaseExpiresAt" gorm:"index"`
LeaseVersion uint64 `json:"leaseVersion" gorm:"not null;default:0"`
CreateRequestID string `json:"-" gorm:"size:36;not null;uniqueIndex:ux_purchase_task_create_request_id"`
ClaimRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_purchase_task_claim_request_id"`
PDDOrderNo *string `json:"pddOrderNo" gorm:"size:100;index"`
OrderSubmittedAt *time.Time `json:"orderSubmittedAt"`
IrreversibleAt *time.Time `json:"irreversibleAt"`
PaymentReviewStatus string `json:"paymentReviewStatus" gorm:"size:16;not null;default:pending;check:ck_purchase_task_payment_review,payment_review_status IN ('pending','paid','unpaid')"`
PaymentReviewedAt *time.Time `json:"paymentReviewedAt"`
PaymentReviewedBy *uint64 `json:"paymentReviewedBy"`
TrackingNo *string `json:"trackingNo" gorm:"size:120"`
TrackingCollectedAt *time.Time `json:"trackingCollectedAt"`
LogisticsStatus string `json:"logisticsStatus" gorm:"size:16;not null;default:pending;check:ck_purchase_task_logistics_status,logistics_status IN ('pending','collected','failed')"`
WritebackStatus string `json:"writebackStatus" gorm:"size:20;not null;default:not_selected;check:ck_purchase_task_writeback_status,writeback_status IN ('not_selected','pending','running','succeeded','failed')"`
WritebackRequestID *string `json:"-" gorm:"size:64;uniqueIndex:ux_purchase_task_writeback_request_id"`
WritebackAt *time.Time `json:"writebackAt"`
RePurchaseAuthorizedAt *time.Time `json:"rePurchaseAuthorizedAt"`
RePurchaseAuthorizedBy *uint64 `json:"rePurchaseAuthorizedBy"`
RePurchaseConsumedAt *time.Time `json:"rePurchaseConsumedAt"`
ErrorCode *string `json:"errorCode" gorm:"size:64;index"`
ErrorMessage *string `json:"errorMessage" gorm:"size:1000"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (PurchaseTask) TableName() string { return "purchase_task" }
func (task *PurchaseTask) BeforeCreate(_ *gorm.DB) error { return task.syncPurchaseGuardSlots() }
func (task *PurchaseTask) BeforeSave(_ *gorm.DB) error { return task.syncPurchaseGuardSlots() }
func (task *PurchaseTask) SetStatus(status string) error {
task.Status = status
return task.syncPurchaseGuardSlots()
}
func (task *PurchaseTask) syncPurchaseGuardSlots() error {
one := uint8(1)
active := false
running := false
switch task.Status {
case PurchaseTaskStatusPending, PurchaseTaskStatusSpecProbePending:
active = true
case PurchaseTaskStatusRunning, PurchaseTaskStatusOrderSubmitStarted:
active, running = true, true
case PurchaseTaskStatusOrderCreated, PurchaseTaskStatusOrderResultUnknown:
active = true
case PurchaseTaskStatusRehearsalCompleted, PurchaseTaskStatusFailed, PurchaseTaskStatusCancelled:
case "":
return fmt.Errorf("purchase task status is required")
default:
return fmt.Errorf("unsupported purchase task status %q", task.Status)
}
if task.ExecutionMode != PurchaseExecutionModeRehearsal && task.ExecutionMode != PurchaseExecutionModeLive {
return fmt.Errorf("unsupported purchase execution mode %q", task.ExecutionMode)
}
if task.ExecutionMode == PurchaseExecutionModeLive && task.SYBProductID == nil {
return fmt.Errorf("live purchase task requires syb_product_id")
}
if running && task.DeviceID == nil {
return fmt.Errorf("running purchase task requires device_id")
}
if active && task.SYBProductID != nil {
task.ActiveSlot = &one
} else {
task.ActiveSlot = nil
}
if running {
task.DeviceRunSlot = &one
if task.PDDAccountID != nil {
task.AccountRunSlot = &one
} else {
task.AccountRunSlot = nil
}
} else {
task.DeviceRunSlot = nil
task.AccountRunSlot = nil
}
if task.SpecDecisionSnapshot == "" {
task.SpecDecisionSnapshot = "{}"
}
if task.RequiredCapabilitiesJSON == "" {
task.RequiredCapabilitiesJSON = "[]"
}
return nil
}
// PurchaseTaskAttempt gives each Agent execution a stable idempotency key.
// The Agent may keep a matching Room row and Outbox entry, while this server
// row remains the final business fact. No accessibility tree or screenshot is
// stored here.
type PurchaseTaskAttempt struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
TaskID uint64 `json:"taskId" gorm:"not null;index;uniqueIndex:ux_purchase_attempt_number,priority:1"`
Task PurchaseTask `json:"-" gorm:"constraint:OnUpdate:CASCADE,OnDelete:RESTRICT"`
AttemptID string `json:"attemptId" gorm:"size:36;not null;uniqueIndex:ux_purchase_attempt_id"`
AttemptNumber int `json:"attemptNumber" gorm:"not null;uniqueIndex:ux_purchase_attempt_number,priority:2;check:ck_purchase_attempt_number,attempt_number >= 1"`
Phase string `json:"phase" gorm:"size:16;not null;check:ck_purchase_attempt_phase,phase IN ('spec_probe','purchase')"`
Status string `json:"status" gorm:"size:16;not null;index;check:ck_purchase_attempt_status,status IN ('pending','running','completed','failed')"`
DeviceID *uint64 `json:"deviceId" gorm:"index"`
RuleSnapshotHash string `json:"ruleSnapshotHash" gorm:"size:64;not null"`
SpecDecisionSnapshot string `json:"-" gorm:"type:json;not null"`
ResultRequestID *string `json:"-" gorm:"size:64;uniqueIndex:ux_purchase_attempt_result_request_id"`
ErrorCode *string `json:"errorCode" gorm:"size:64;index"`
ErrorMessage *string `json:"errorMessage" gorm:"size:1000"`
StartedAt *time.Time `json:"startedAt"`
FinishedAt *time.Time `json:"finishedAt"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (PurchaseTaskAttempt) TableName() string { return "purchase_task_attempt" }
@@ -0,0 +1,121 @@
package purchasecontract
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"regexp"
"sort"
"strings"
)
const (
RuleTypePurchase = "pddPurchase"
SchemaVersionV1 = 1
CapabilityPurchaseRehearsalV1 = "purchase.rehearsal.v1"
CapabilityPurchaseLiveV1 = "purchase.live.v1"
CapabilitySpecProbeV1 = "purchase.spec-probe.v1"
CapabilityAddressUpdateV1 = "purchase.address-update.v1"
CapabilityOrderCreateV1 = "purchase.order-create.v1"
)
var capabilityPattern = regexp.MustCompile(`^[a-z][a-z0-9.-]{0,79}$`)
var safeActions = map[string]bool{
"openProduct": true, "verifyProduct": true, "openSpecPanel": true,
"selectSpec": true, "setQuantity": true, "verifyUnitPrice": true,
"verifyOrderSummary": true, "probeSpecs": true,
}
var liveOnlyActions = map[string]string{
"updateShippingAddress": CapabilityAddressUpdateV1,
"createOrder": CapabilityOrderCreateV1,
"readOrderResult": CapabilityPurchaseLiveV1,
}
type Action struct {
Type string `json:"type"`
}
type RuleSnapshot struct {
SchemaVersion int `json:"schemaVersion"`
RuleType string `json:"ruleType"`
RequiredCapabilities []string `json:"requiredCapabilities"`
Actions []Action `json:"actions"`
}
func Validate(raw []byte, executionMode string) (RuleSnapshot, error) {
var rule RuleSnapshot
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&rule); err != nil {
return rule, fmt.Errorf("采购规则字段无效: %w", err)
}
if err := ensureEOF(decoder); err != nil {
return rule, err
}
if rule.SchemaVersion != SchemaVersionV1 || rule.RuleType != RuleTypePurchase {
return rule, errors.New("采购规则必须声明 schemaVersion=1、ruleType=pddPurchase")
}
if executionMode != "rehearsal" && executionMode != "live" {
return rule, errors.New("executionMode 只支持 rehearsal 或 live")
}
if len(rule.Actions) == 0 || len(rule.Actions) > 64 {
return rule, errors.New("actions 必须包含 1..64 个类型化动作")
}
capabilities := make(map[string]bool, len(rule.RequiredCapabilities))
for _, capability := range rule.RequiredCapabilities {
if !capabilityPattern.MatchString(capability) {
return rule, fmt.Errorf("能力标识无效: %s", capability)
}
capabilities[capability] = true
}
baseCapability := CapabilityPurchaseRehearsalV1
if executionMode == "live" {
baseCapability = CapabilityPurchaseLiveV1
}
if !capabilities[baseCapability] {
return rule, fmt.Errorf("规则缺少能力 %s", baseCapability)
}
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)
}
if safeActions[action.Type] {
if action.Type == "probeSpecs" && !capabilities[CapabilitySpecProbeV1] {
return rule, fmt.Errorf("动作 probeSpecs 缺少能力 %s", CapabilitySpecProbeV1)
}
continue
}
requiredCapability, liveOnly := liveOnlyActions[action.Type]
if !liveOnly {
return rule, fmt.Errorf("actions[%d].type 不受支持: %s", index, action.Type)
}
if executionMode != "live" {
return rule, fmt.Errorf("演练规则不能包含动作 %s", action.Type)
}
if !capabilities[requiredCapability] {
return rule, fmt.Errorf("动作 %s 缺少能力 %s", action.Type, requiredCapability)
}
}
return rule, nil
}
func RequiredCapabilities(rule RuleSnapshot) []string {
result := append([]string(nil), rule.RequiredCapabilities...)
sort.Strings(result)
return result
}
func AddressSuffix(taskID uint64) string { return fmt.Sprintf("_cg%d", taskID) }
func ensureEOF(decoder *json.Decoder) error {
var extra any
if err := decoder.Decode(&extra); err != io.EOF {
return errors.New("规则 JSON 只能包含一个对象")
}
return nil
}
@@ -0,0 +1,37 @@
package purchasecontract
import (
"strings"
"testing"
)
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(), "演练规则不能") {
t.Fatalf("expected rehearsal boundary error, got %v", err)
}
}
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(), "禁止") {
t.Fatalf("expected payment rejection, got %v", err)
}
}
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")
if err != nil {
t.Fatalf("valid live rule rejected: %v", err)
}
if len(RequiredCapabilities(rule)) != 3 {
t.Fatalf("unexpected capabilities: %v", RequiredCapabilities(rule))
}
}
func TestAddressSuffixUsesTaskID(t *testing.T) {
if got := AddressSuffix(11); got != "_cg11" {
t.Fatalf("AddressSuffix(11) = %q", got)
}
}
@@ -0,0 +1,27 @@
package version_local
import (
"runtime"
goautomigrations "go-admin/app/goauto/migrations"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
"gorm.io/gorm"
)
// This additive migration introduces the #33 purchase-domain contract tables.
// It does not add purchase, order or logistics fields to any product table.
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), migratePurchaseContract)
}
func migratePurchaseContract(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := goautomigrations.Migrate(tx); err != nil {
return err
}
return tx.Create(&common.Migration{Version: version}).Error
})
}