feat: 定义运行时规格解析契约与审计模型 (#254)

This commit is contained in:
chengma
2026-08-17 16:53:11 +08:00
parent b11a586527
commit 12414658d0
8 changed files with 736 additions and 16 deletions
+95 -2
View File
@@ -127,6 +127,8 @@ v18(#208)新增 `shops` 业务店铺和 `shop_channel_aliases` 渠道名称
名称精确回填,无法确认的记录保持未关联。
v19(#212)将产品概念收敛为唯一店铺名称和唯一启停状态。迁移按“任一旧开关停用
则保持停用”的安全规则合并 v18 状态,重建由 `shops` 派生的兼容数据和两侧关联。
v26(#254)新增 `purchase_spec_resolutions`,用一张表保存采购执行中的候选观察、
幂等身份和最终规格决策;不修改历史 SQLite migration,也不覆盖 PDD 商品主数据。
**v3 为什么丢弃旧 `sku_mappings` 数据(见 #20):** 新主键需要 `pdd_option_key`,
这是 Go 的 `service.OptionKey()` 用 `json.Marshal` 算出来的规范化键,SQL 语句
@@ -884,8 +886,8 @@ syb_orders ─(shopee_goods_id, spec_key)─┘
(skus_json 里是所有规格和价格)
syb_orders ──创建──→ tasks ──分配──→ clients
↑
users(采购员)──1:N 当前归属──────────┘
└──1:N──→ purchase_spec_resolutions
users(采购员)──1:N 当前归属──────────→ clients
```
两条关联都可以变,这是有意的:
@@ -906,6 +908,7 @@ Admin 使用集中 MySQL 8.4,Client 仍使用各自的本地 SQLite;两者
| 任务编号 | `tasks.task_id` | `pdd_tasks.remote_task_id` |
| 任务状态 | 7 个(§6) | 8 个,是本机执行状态 |
| 采集结果 | `pdd_products.skus_json` | `pdd_tasks.pdd_data` |
| 运行时规格解析 | `purchase_spec_resolutions` 候选观察与最终决策 | #257 在本地保存请求、幂等键和响应;原任务 payload 不覆盖 |
`[必须]` **两边的状态是两套,不要试图同步。**
Admin 只知道"发出去了 / 收到结果了",中间过程看不到,这是有意的设计。
@@ -1052,3 +1055,93 @@ CREATE UNIQUE INDEX idx_client_assignment_current
不新增批次表:提交时所选 `ready` 记录必须在同一事务内全部转为 `queued`;后台每次最多
读取 20 条并逐条领取为 `applying`。页面按 `apply_batch_id` 聚合当前批次进度。Admin
重启时,`queued` 恢复为 `ready` 且不会自动写远端,`applying` 转为 `needs_check`。
## 18. `purchase_spec_resolutions` 采购运行时规格解析(MySQL v26)
Client 在同一次采购中选中颜色后,如果任务尺码和 PDD 页面尺码无法精确对应,就通过
[Client 接口契约](../client/04-admin-api-contract.md) §7.1 提交当前可购买候选。本表用
**一条记录**同时保存候选观察、幂等身份和最终决策,不拆观察表、决策表或模型响应表。
```sql
CREATE TABLE purchase_spec_resolutions (
resolution_id VARCHAR(191) COLLATE utf8mb4_bin PRIMARY KEY,
task_id VARCHAR(191) COLLATE utf8mb4_bin NOT NULL,
attempt_id VARCHAR(191) COLLATE utf8mb4_bin NOT NULL,
client_id VARCHAR(191) COLLATE utf8mb4_bin NOT NULL,
task_version BIGINT NOT NULL CHECK (task_version > 0),
pdd_goods_id VARCHAR(191) COLLATE utf8mb4_bin NOT NULL,
original_options_json JSON NOT NULL,
selected_color VARCHAR(191) NOT NULL,
target_size VARCHAR(191) NOT NULL,
candidates_json JSON NOT NULL,
candidate_snapshot_hash CHAR(64) COLLATE utf8mb4_bin NOT NULL,
request_hash CHAR(64) COLLATE utf8mb4_bin NOT NULL,
observed_at VARCHAR(35) NOT NULL,
outcome VARCHAR(16) NOT NULL DEFAULT 'pending',
decision_source VARCHAR(16),
chosen_candidate_id VARCHAR(16) COLLATE utf8mb4_bin,
resolved_options_json JSON,
confidence_bps INT,
reason VARCHAR(500),
provider_id VARCHAR(191) COLLATE utf8mb4_bin,
source_model VARCHAR(191),
config_fingerprint CHAR(64) COLLATE utf8mb4_bin,
rules_version VARCHAR(32),
prompt_version VARCHAR(32),
created_at VARCHAR(35) NOT NULL,
decided_at VARCHAR(35),
UNIQUE KEY uq_purchase_spec_resolution_identity
(task_id, attempt_id, candidate_snapshot_hash),
KEY idx_purchase_spec_resolution_task
(task_id, created_at DESC, resolution_id),
KEY idx_purchase_spec_resolution_outcome
(outcome, created_at, resolution_id),
CONSTRAINT chk_purchase_spec_resolution_options CHECK (
JSON_TYPE(original_options_json) = 'OBJECT'
AND JSON_LENGTH(original_options_json) BETWEEN 1 AND 16
AND JSON_TYPE(candidates_json) = 'ARRAY'
AND JSON_LENGTH(candidates_json) BETWEEN 1 AND 100
AND (resolved_options_json IS NULL
OR JSON_TYPE(resolved_options_json) = 'OBJECT')),
CONSTRAINT chk_purchase_spec_resolution_outcome CHECK (
outcome IN ('pending', 'matched', 'uncertain', 'rejected', 'failed')),
CONSTRAINT chk_purchase_spec_resolution_source CHECK (
decision_source IS NULL OR decision_source IN ('rule', 'ai', 'reused')),
CONSTRAINT chk_purchase_spec_resolution_confidence CHECK (
confidence_bps IS NULL OR confidence_bps BETWEEN 0 AND 10000),
CONSTRAINT chk_purchase_spec_resolution_decision CHECK (
(outcome = 'pending' AND decided_at IS NULL
AND decision_source IS NULL AND chosen_candidate_id IS NULL
AND resolved_options_json IS NULL)
OR (outcome = 'matched' AND decided_at IS NOT NULL
AND decision_source IS NOT NULL AND chosen_candidate_id IS NOT NULL
AND resolved_options_json IS NOT NULL AND reason IS NOT NULL)
OR (outcome IN ('uncertain', 'rejected', 'failed')
AND decided_at IS NOT NULL AND chosen_candidate_id IS NULL
AND resolved_options_json IS NULL AND reason IS NOT NULL))
);
```
- `(task_id, attempt_id, candidate_snapshot_hash)` 是业务唯一身份;`request_hash` 用于发现
同一身份改了目标尺码、候选原文或其他内容。HTTP 的 `idempotency_keys` 仍保存可安全
重放的完整响应,两层幂等不能互相替代。
- `pending` 是内部恢复状态,不返回给 Client。先用短事务写候选观察,规则或 AI 调用在
事务外执行;最终 outcome 与 `idempotency_keys.response_body` 在另一个短事务一起提交。
崩溃留下 pending 时,相同请求读取原记录继续恢复,不能新建第二条。
- `matched` 必须有来源、候选短编号、原始 options、原因和决定时间;候选必须逐字来自
`candidates_json`。`uncertain/rejected/failed` 不得填写候选和 resolved options,避免
Client 把不确定结果当作可点击规格。
- `confidence_bps` 可空。NULL 表示规则或复用路径没有可比较分数,不得转换成 0;AI
分数使用 0~10000 基点。服务商、模型、配置指纹和规则/提示版本都是非敏感快照,
表中没有 API Key、完整提示词或完整模型响应。
- 本表故意不设指向 `tasks` 或 `clients` 的外键。现有页面允许硬删除任务和临时 Client
清单记录,规格决策审计不能因此阻塞原流程或被级联删除;Repository 查询始终使用稳定
ID,不把缺少主表记录解释成新的业务状态。
- 本表不参与任务状态机,不更新 `tasks.status/result_data`,也不写
`pdd_products.skus_json` 或长期 `spec_mappings`。它只回答这一执行尝试在这一候选快照下
可以安全选择哪个原始候选。
- `original_options_json`、`candidates_json` 和 `resolved_options_json` 只保存接口定义的
颜色/尺码字符串。不得保存原始无障碍 XML、截图、订单号、收货信息、Cookie、Token、
密码或 API Key;时间统一保存为 UTC ISO 8601。