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
+155 -6
View File
@@ -12,8 +12,9 @@
**核心:Admin 是调度器,Client 是执行器,执行器不参与调度决策。**
- Client 的任务流程只有三个动作:领一个任务、提交结果、提交失败。
设置页另有一个幂等 Client 登记动作。**没有任何"去问 Admin 现在怎么想"的调用。**
- Client 的常规任务流程只有三个动作:领一个任务、提交结果、提交失败。
采购规格无法精确选择时,允许在同一次执行中额外提交一次 §7.1 规格解析命令;
设置页另有一个幂等 Client 登记动作。**没有任何“去问 Admin 现在怎么想”的状态查询。**
- Client 拿到任务就做完,中途不管任务是否被取消、是否被重派。
- Admin 负责任务的创建、更新、取消和派发(含重派),这些 Client 一律不感知。
- 结果提交必须幂等;网络重试不能创建重复结果。
@@ -43,7 +44,7 @@ X-Request-Id: <uuid>
Content-Type: application/json
```
结果和失败提交额外携带:
结果、失败和运行时规格解析提交额外携带:
```http
Idempotency-Key: <stable-key>
@@ -410,9 +411,153 @@ Idempotency-Key: task-id:attempt-id:failure-v1
- `[必须]` §6.1 的无条件接受规则同样适用于本接口。
- `[必须]` Admin 决定是否创建新的执行机会,但对已经可能下单的任务不得通过普通重试触发再次购买。
### 7.1 采购运行时规格解析命令
“运行时规格解析”是指:Client 已经打开当前 PDD 商品并选中颜色,但任务目标尺码
无法与页面上的可购买尺码精确对应时,把这一刻的候选快照交给 Admin 做一次受审计的
规格决策。它是**一次性 POST 业务命令**,不是任务状态查询、心跳或轮询;Client 不得
用它询问任务是否取消、是否重派或 AI 是否完成。
```http
POST /api/v1/client/tasks/{task_id}/spec-resolution
Idempotency-Key: spec-resolution-v1:<identity-sha256>
```
请求体 schema v1:
```json
{
"schema_version": 1,
"task_version": 3,
"attempt_id": "attempt-uuid",
"pdd_goods_id": "937122477375",
"original_options": {"color": "黑色", "size": "60公斤"},
"selected_color": "黑色",
"target_size": "60公斤",
"candidates": [
{
"candidate_id": "c1",
"raw_text": "120斤",
"options": {"color": "黑色", "size": "120斤"}
},
{
"candidate_id": "c2",
"raw_text": "130斤",
"options": {"color": "黑色", "size": "130斤"}
}
],
"candidate_snapshot_hash": "64位小写十六进制 SHA-256",
"observed_at": "2026-08-17T08:00:00Z"
}
```
字段和大小限制:
| 字段 | 规则 |
|---|---|
| 整个 JSON 请求体 | UTF-8 编码后最多 64 KiB;未知字段允许忽略但不得改变已知字段含义 |
| `schema_version` | v1 固定为 `1`;未知主版本拒绝,不猜测兼容 |
| `task_version` | 正整数,必须等于领取到的任务版本 |
| `attempt_id` | 1~191 个字符,同一次 `task_runs` 执行稳定复用 |
| `pdd_goods_id` | 1~191 个字符,必须等于任务商品 |
| `original_options` | 1~16 个字符串键值;键和值各 1~191 个字符,保持领取任务时的动态规格原文 |
| `selected_color` / `target_size` | 各 1~191 个字符,不允许控制字符 |
| `candidates` | 1~100 条,只提交当前颜色下页面显示为可购买的尺码,保持页面顺序 |
| `candidate_id` | 严格按数组顺序使用 `c1`、`c2` … `c100`,不得跳号或重复 |
| `raw_text` | PDD 页面原始尺码文字,1~191 个字符,不允许控制字符 |
| `options` | v1 只含非空 `color`、`size`;`color` 必须等于 `selected_color`,`size` 必须逐字等于 `raw_text` |
| `candidate_snapshot_hash` | 按下述算法计算的 64 位小写十六进制 SHA-256 |
| `observed_at` | 带时区 ISO 8601;推荐 UTC |
候选短编号由 Client 按页面顺序生成,Admin 必须逐项验证编号、原文和 options,不能
接受模型自行增加、改写或重新编号后的规格。快照哈希使用长度前缀,避免分隔符碰撞:
```text
frame(value) = UTF-8 字节长度的十进制文本 + ":" + value
material = frame("spec-resolution-v1")
+ frame(pdd_goods_id)
+ frame(selected_color)
+ frame(候选数量的十进制文本)
+ 依页面顺序为每条候选追加:
frame(candidate_id) + frame(raw_text)
+ frame(options.color) + frame(options.size)
candidate_snapshot_hash = lowercase_hex(sha256(UTF-8(material)))
```
幂等键同样是确定值,不直接拼接可能很长的任务编号:
```text
identity_material = frame(task_id) + frame(attempt_id)
+ frame(candidate_snapshot_hash) + frame("spec-resolution-v1")
Idempotency-Key = "spec-resolution-v1:" + lowercase_hex(sha256(UTF-8(identity_material)))
```
Admin 必须重新计算两个哈希。相同业务身份
`task_id + attempt_id + candidate_snapshot_hash` 只对应一条解析记录;相同键和相同内容
返回第一次保存的完整响应,相同键或相同业务身份携带不同内容返回
`409 IDEMPOTENCY_CONFLICT`。网络超时可以用原键和原请求重放,但 Client 不得修改内容后
沿用旧键,也不得循环请求等待结果。
成功处理统一返回 `200 OK`。`failed` 是已经持久化的业务结论,不是 HTTP 500:
```json
{
"schema_version": 1,
"resolution_id": "psr-uuid",
"outcome": "matched",
"source": "ai",
"candidate_snapshot_hash": "请求中的同一哈希",
"match": {
"candidate_id": "c1",
"raw_text": "120斤",
"options": {"color": "黑色", "size": "120斤"}
},
"confidence_bps": 9300,
"reason": "目标重量与候选范围唯一对应",
"resolved_at": "2026-08-17T08:00:01Z"
}
```
| `outcome` | 含义 | `match` |
|---|---|---|
| `matched` | 规则、AI 或历史解析得到唯一且通过服务端门禁的请求内候选 | 必须是请求候选的逐字副本 |
| `uncertain` | 有分析结果,但不能唯一、安全地选中一个候选 | `null` |
| `rejected` | 请求结构有效,但业务规则明确拒绝自动选择 | `null` |
| `failed` | 解析服务超时、格式错误或其他已审计失败 | `null` |
`source` 只允许 `rule`、`ai`、`reused` 或 `null`;`confidence_bps` 为 `0~10000`
整数或 `null`,`null` 表示该来源没有可比较的置信度,不能当作 0。`reason` 最多 500
个字符。只有 `matched` 可以返回非空 `match`,其编号、原文和 options 必须逐字来自
本次请求;响应不得包含模型生成的新规格。Client 仍须在继续前重新读取页面、复算快照并
精确核对,不能因为 Admin 返回 matched 就跳过既有数量、总价、地址、不可逆标记和单次
提交门禁。
稳定错误至少包括:
| HTTP | `error.code` | 场景 |
|---|---|---|
| `400` | `INVALID_BODY` | 非法 JSON 或请求体超过 64 KiB |
| `400` | `INVALID_SPEC_RESOLUTION_SCHEMA` | `schema_version` 不支持 |
| `422` | `INVALID_SPEC_RESOLUTION_REQUEST` | 字段长度、候选数量、候选编号或 options 无效 |
| `422` | `SPEC_RESOLUTION_HASH_MISMATCH` | 快照哈希或确定性幂等键与字段不一致 |
| `404` | `TASK_NOT_FOUND` | 任务不存在 |
| `422` | `TASK_NOT_PURCHASE` | 不是采购任务 |
| `409` | `TASK_VERSION_CONFLICT` | 请求任务版本与已领取版本不一致 |
| `422` | `PDD_GOODS_MISMATCH` | PDD 商品 ID 与任务不一致 |
| `403` | `TASK_NOT_CLAIMED_BY_CLIENT` | 该 Client 从未领取过此任务 |
| `409` | `IDEMPOTENCY_CONFLICT` | 相同幂等身份提交了不同内容 |
| `503` | `SPEC_RESOLUTION_UNAVAILABLE` | 在解析记录落库前 Admin 暂不可用,可有限重试原请求 |
任务已取消、重派或结束本身不是本命令的查询条件;只要该 Client 曾领取任务且任务身份
仍能核对,Admin 可以保存解析审计,但**不得修改任务状态**。候选观察先用短事务持久化;
规则/AI 调用不得占用数据库事务,最终决策和安全重放响应必须与 `idempotency_keys` 在同一
短事务提交。该记录不得覆盖 `pdd_products.skus_json`,也不得
保存原始无障碍 XML、截图、订单号、收货信息、Cookie、Token 或 API Key。旧 Client 不
调用本接口,继续按“规格不匹配即提交失败”的既有流程运行。
## 8. 幂等与重试
- `[必须]` 结果和失败提交必须使用持久化的 `Idempotency-Key`,格式见 [03 数据模型](03-data-model.md) §5.2。
- `[必须]` 结果、失败和运行时规格解析提交必须使用持久化的 `Idempotency-Key`;前两者格式见 [03 数据模型](03-data-model.md) §5.2,规格解析格式见 §7.1。
- `[必须]` Client 在发送前将完整请求写入 Outbox;重试使用相同键和相同内容。
- `[必须]` `claim` 需要 Admin 保证重复请求不会一次分配出多个任务。
- `[建议]` 对 `429`、`500` 和 `503` 使用有上限的指数退避,并遵守 `Retry-After`。
@@ -420,13 +565,14 @@ Idempotency-Key: task-id:attempt-id:failure-v1
## 9. Mock Admin 要求
`MockAdminGateway` 与 HTTP 实现暴露同一应用层接口。登记和三个任务方法都必须使用同一契约:
`MockAdminGateway` 与 HTTP 实现暴露同一应用层接口。登记、三个常规任务方法和一次性规格解析命令都必须使用同一契约:
```text
register_client(client, capabilities) # §4.1
claim_next(client, capabilities) # §5
submit_result(task_id, idempotency_key, result) # §6
submit_failure(task_id, idempotency_key, failure) # §7
resolve_purchase_spec(task_id, idempotency_key, observation) # §7.1
```
Mock 必须支持:
@@ -436,6 +582,7 @@ Mock 必须支持:
- 网络超时和暂时故障;
- 幂等重复提交(相同键相同内容);
- 幂等冲突(相同键不同内容);
- 规格解析的 matched / uncertain / rejected / failed,以及候选哈希不一致;
- **提交一个 Admin 侧已取消的任务,仍返回 `accepted: true`**(用来验证 §6.1);
- 结果校验失败。
@@ -457,7 +604,9 @@ Mock 测试通过不能替代与真实 Admin 的契约测试。
- 任务分「指定分配」和「无主」两种,`claim` 两种都返回,指定的优先。
采集任务不指定,采购任务可指定可留空。Client 不读全局任务池。见 §5.1。
- **没有租约、没有心跳、没有状态回查。** 任务流程只有 §5 / §6 / §7 三个调用;设置页另有 §4.1 的幂等登记调用。
- **没有租约、没有心跳、没有状态回查。** 常规任务流程仍是 §5 / §6 / §7;
§7.1 只是在同一次采购规格无法精确选择时提交候选并同步取得持久化决策,设置页另有
§4.1 的幂等登记调用。
- Admin 必须无条件接受已派发过的 Client 提交的结果。见 §6.1。
改动这张表里任何一条,都属于会影响业务结果的变更,必须先更新工单并经用户确认。