feat(#33): define purchase task data and API contract
This commit is contained in:
@@ -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 订单。
|
||||
|
||||
@@ -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/` |
|
||||
|
||||
@@ -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”和系统确认框“打开”属于允许动作。
|
||||
|
||||
@@ -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` | 系统禁止自动付款 |
|
||||
|
||||
@@ -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 真机总验收。
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user