diff --git a/docs/00-project-profile.md b/docs/00-project-profile.md index 87111bd..69d5788 100644 --- a/docs/00-project-profile.md +++ b/docs/00-project-profile.md @@ -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 # 项目档案 @@ -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 订单。 diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index c1ca880..6abd25f 100644 --- a/docs/02-architecture-and-code-map.md +++ b/docs/02-architecture-and-code-map.md @@ -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 # 架构与代码地图 @@ -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/` | diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index b3cdf1d..5c3bcf5 100644 --- a/docs/03-business-rules-and-glossary.md +++ b/docs/03-business-rules-and-glossary.md @@ -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 # 业务规则与术语 ## 当前范围 -当前已实施范围包含 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”和系统确认框“打开”属于允许动作。 diff --git a/docs/08-agent-api-contract.md b/docs/08-agent-api-contract.md index b6c7241..6dc788d 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: c94df94b3f7aa6d8d1b163bc77fa5b1462adbb0c -synchronized_at: 2026-08-20T08:05:39Z +wiki_revision: f9245c856a45c10dfbae7fe32b365ca11b008782 +synchronized_at: 2026-08-20T08:47:11Z # 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` | 系统禁止自动付款 | diff --git a/docs/09-delivery-issues.md b/docs/09-delivery-issues.md index 5f524ef..2eb345d 100644 --- a/docs/09-delivery-issues.md +++ b/docs/09-delivery-issues.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Delivery-Issues wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Delivery-Issues.- -wiki_revision: c94df94b3f7aa6d8d1b163bc77fa5b1462adbb0c -synchronized_at: 2026-08-20T08:05:43Z +wiki_revision: f9245c856a45c10dfbae7fe32b365ca11b008782 +synchronized_at: 2026-08-20T08:47:11Z # 当前 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 真机总验收。 diff --git a/server/app/goauto/migrations/migrate.go b/server/app/goauto/migrations/migrate.go index 2ee2537..f18e9df 100644 --- a/server/app/goauto/migrations/migrate.go +++ b/server/app/goauto/migrations/migrate.go @@ -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{}, diff --git a/server/app/goauto/migrations/purchase_contract_test.go b/server/app/goauto/migrations/purchase_contract_test.go new file mode 100644 index 0000000..50a11ca --- /dev/null +++ b/server/app/goauto/migrations/purchase_contract_test.go @@ -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) + } + } + } + } +} diff --git a/server/app/goauto/models/purchase.go b/server/app/goauto/models/purchase.go new file mode 100644 index 0000000..5efb882 --- /dev/null +++ b/server/app/goauto/models/purchase.go @@ -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" } diff --git a/server/app/goauto/purchasecontract/contract.go b/server/app/goauto/purchasecontract/contract.go new file mode 100644 index 0000000..b8963d6 --- /dev/null +++ b/server/app/goauto/purchasecontract/contract.go @@ -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 +} diff --git a/server/app/goauto/purchasecontract/contract_test.go b/server/app/goauto/purchasecontract/contract_test.go new file mode 100644 index 0000000..5395ab9 --- /dev/null +++ b/server/app/goauto/purchasecontract/contract_test.go @@ -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) + } +} diff --git a/server/cmd/migrate/migration/version-local/1786701100000_purchase_contract.go b/server/cmd/migrate/migration/version-local/1786701100000_purchase_contract.go new file mode 100644 index 0000000..832a300 --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1786701100000_purchase_contract.go @@ -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 + }) +}