Files
cmautobuy/docs/admin/04-client-api.md
T

393 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 04 Admin 侧的 Client 接口实现
- 文档状态:基线草案,待联合评审
- 适用范围:`admin/handler/api/`
> **权威契约在 Client 那边:**[docs/client/04-admin-api-contract.md](../client/04-admin-api-contract.md)。
> 本文只讲**Admin 这边怎么实现**。两边说法不一致时**以 Client 契约为准**,要改先走工单。
本文档中 `[必须]` / `[建议]` / `[待定]` 的含义见 [文档索引](../README.md#文档标注说明)。
## 1. 一共五个接口
```http
PUT /api/v1/client/registration 登记或更新 Client
POST /api/v1/client/tasks/claim 领一个任务
POST /api/v1/client/tasks/{task_id}/result 提交成功结果
POST /api/v1/client/tasks/{task_id}/failure 提交失败/需人工
POST /api/v1/client/tasks/{task_id}/spec-resolution 一次性解析当前规格候选
```
#254 冻结第五个接口的契约和审计模型;Handler、路由和匹配服务由 #255 实现。在 #255
完成前,旧 Client 仍只使用原四个接口,新 Client 不得把路由尚未提供误判为可重试的 AI 失败。
`task_id` 是不透明的稳定字符串,当前采集任务为 `cjN`、采购任务为
`cgN`。Client 必须原样保存和回传,不校验旧前缀,不从编号推导类型;
业务类型始终以响应中的 `type` 为准。
第五个接口只接收 Client 当下观察到的规格候选并同步返回一份持久化决策,不读取或返回
任务调度状态。`[必须]` **不得新增“让 Client 查询状态”类接口**,也不得加回租约和心跳。
理由见 Client 契约 §1.1:本项目人工付款,重复下单只产生重复的**未付款**订单,
不值得为它引入一整套中断逻辑。
### 1.1 独立登记接口
设置页保存当前设备时调用:
```http
PUT /api/v1/client/registration
X-Client-Id: <stable-client-id>
```
请求体与 claim 的 Client、设备和能力部分相同。相同 `X-Client-Id` 重复调用执行 upsert,统一返回 `200 OK`:
```json
{
"registered": true,
"client_id": "CLIENT-123456",
"registered_at": "2026-08-06T09:00:00Z"
}
```
`[必须]` 本接口只登记 Client,不查询、领取或修改任务,也不返回任务。
`[必须]` 显式登记携带非空名称时允许更新名称;空名称更新保留现有名称,新建时使用 Client ID 兜底。设备、能力、`last_seen_at` 和 `updated_at` 正常更新。
`[必须]` 校验 `client.name` 最多 50 字、任务类型、执行模式和结构版本。错误使用统一 JSON 结构,至少覆盖 `MISSING_CLIENT_ID`、`INVALID_BODY`、`INVALID_CLIENT_PROFILE` 和 `CLIENT_REGISTER_FAILED`。
## 2. 领取任务
```http
POST /api/v1/client/tasks/claim
X-Client-Id: client-001
```
请求体里有设备信息和能力声明,还有客户端名称(见 §3)。
**处理步骤:**
1. 先做客户端注册/更新(§3);
2. 查 `tasks`,**两种任务都要查**:
- 指定给本机的:`assigned_client = X-Client-Id` 且 `status = 'assigned'`
- 无主的:`assigned_client IS NULL` 且 `status = 'pending'`
排序 `ORDER BY (assigned_client IS NULL), priority DESC, created_at`
—— **指定给本机的优先**;
网页创建的采购任务固定走第一种:创建前已经校验当前账号对客户端的可见范围,
并写入 `pdd_options`、`quantity` 和人民币订单总价上限 `max_price_cent`。总价上限由采购员确认的 SKU 单价上限乘以顺运宝最新数量得到;这不改变 Client 接口字段。
3. 没有就返回 `204 No Content`;
4. 有就把 `status` 改成 `claimed`、记 `claimed_at`,返回任务。
`[必须]` 第 2 步只返回**分配给这个客户端**的任务。这是已定案的分配模型
(Client 契约 §5.1),不要做成谁都能抢。
`[必须]` 第 4 步的查询和更新要在**同一个事务**里,用条件更新防并发:
```sql
UPDATE tasks
SET status = 'claimed', assigned_client = ?, claimed_at = ?, updated_at = ?
WHERE task_id = ?
AND ( (status = 'assigned' AND assigned_client = ?)
OR (status = 'pending' AND assigned_client IS NULL) );
-- 两种情况合成一条:对"指定给我的",写 assigned_client 是写同一个值,无副作用。
-- 检查影响行数,为 0 说明被别人抢先了,重新取下一条
```
**响应体:**
```json
{
"task": {
"id": "cg1",
"type": "purchase",
"execution_mode": "dry_run",
"version": 1,
"priority": 10,
"payload": {
"goods_id": "737116531267",
"goods_url": "https://mobile.yangkeduo.com/goods.html?goods_id=737116531267",
"options": { "color": "黑色", "size": "M码" },
"quantity": 2,
"max_price_cent": 4200
},
"created_at": "2026-08-06T07:00:00Z",
"updated_at": "2026-08-06T07:05:00Z"
}
}
```
`[必须]` **响应里不含租约**,也不含 Admin 侧状态。Client 不关心这些。
`execution_mode` 只允许 `dry_run` / `live`;Admin Web 新建采购任务固定为 `live`,历史任务和接口缺省兼容值仍为 `dry_run`。领取查询必须按
Client 上报的 `capabilities.purchase_mode` 过滤:`dry_run` Client 只能看到演练任务,
`live` Client 可以看到两种;过滤只决定能否领取,绝不改变任务自身模式。
Admin Web 可以把 live 任务预先指派给当前账号可见的任意 Client,不在创建时按该字段
拒绝;尚未就绪的指定 Client 不会领取,待其设备与真实采购执行器就绪并自动上报 live 后领取。
`[必须]` `payload.goods_url` 必须有值;采购任务的 `quantity` 和 `max_price_cent` 必须有值。`max_price_cent` 固定表示人民币订单总价上限,不得下发单价。
这些在建任务时就该校验住(见 [01 需求](01-requirements.md) §5),
不要等到这里才发现发不出去。
## 3. claim 保留隐式登记作为兼容兜底
`[必须]` **有独立登记接口,但不设心跳接口。**
旧 Client 可能直接调用 `claim`,其请求里已经带齐初始化需要的信息:
```http
X-Client-Id: client-001 ← 序列号,主键
```
```json
{
"client": { "name": "办公室-01" },
"supported_types": ["collect", "purchase"],
"device": { "address": "192.168.0.173:5555",
"platform": "android",
"pdd_package": "com.xunmeng.pinduoduo" },
"capabilities": { "purchase_mode": "dry_run", "schema_versions": [1] }
}
```
> **已定案:** `client.name` 同时用于显式登记和 claim 兼容登记。
> Client 的设置页本来就有「设备名」输入框(`deviceNameInput`,50 字上限),
> 显式登记会更新非空名称;claim 对已有 Client 不覆盖名称。
> Admin 容忍名称缺失,新建时用 `X-Client-Id` 当显示名。
>
> 登记流程的完整说明见 [07 设备登记联调手册](07-设备登记联调手册.md)。
claim 的兼容登记处理:
```text
upsert clients:
没有该 client_id → 新增,name 用上报的(没有就用 client_id 兜底)
已有 → 更新 device / capabilities / last_seen_at
**name 不更新**
```
`[必须]` `name` **只在第一次注册时写入,之后的 claim 一律不更新它**。
这样操作员在界面上改成好记的名字("仓库那台")之后,
客户端每次 claim 都不会把它覆盖回上报的默认名。
实现上就是 `ON CONFLICT ... DO UPDATE SET` 里**不包含 `name`**,
不需要额外加"是否人工改过"的标记列。
`[必须]` `result`、`failure` 和 `spec-resolution` 接口**也要刷新 `last_seen_at`**。
否则客户端执行长任务期间不调 claim,会被误判成离线。
新客户端直接调用 claim 时,`204` 是正常的无任务结果,Client 必须正常处理;如果已经预先分配任务,也可能首次调用就返回 `200`。
## 4. 提交结果
```http
POST /api/v1/client/tasks/{task_id}/result
Idempotency-Key: <task_id>:<attempt_id>:result-v1
```
处理:
1. 用 `Idempotency-Key` 查是否处理过 → 处理过就返回上次的结果,**不重复落库**;
2. 把 `pdd_data` 写进 `tasks.result_data`;
3. 采集任务:校验返回的 `goods_id` 与请求一致(不一致返回 `422 COLLECT_GOODS_MISMATCH`),
将 `shop_name` 写进 `pdd_products.shop_name`,完整 `pdd_data` 写进
`pdd_products.skus_json`,`collect_status` 置 `collected`;
没有 `shop_name` 或值为空时保留已有店铺名;
`price_granularity` 非空时只能是 `color` 或 `sku`;
**`skus` 为空要置 `failed` 而不是 `collected`**,见 [03 数据模型](03-data-model.md) §4.3;
4. `tasks.status` 置 `succeeded`;
5. 刷新客户端 `last_seen_at`;
6. 返回 `{"accepted": true, "result_id": "...", "accepted_at": "..."}`。
第 2~4 步 `[必须]` 在**同一个事务**里。
### 4.1 无条件接受 —— 最容易写错的一条
`[必须]` 下面每一条都不允许违反:
- **不得**因为任务已取消而拒绝结果;
- **不得**因为任务已重派给别的客户端而拒绝结果;
- **必须**能接受同一任务来自**多个客户端**的多份结果;
- 只有**从未分配给该客户端**的任务才返回 `403`。
原因:Client 中途不查任务状态(契约 §1),所以它**必然**会提交一些
"Admin 这边已经不要了"的结果。而它可能真的已经下单了,这些数据必须能交上来留痕。
`accepted: true` 的意思是"**我收到并存下了**",不代表这个任务还算数。
任务算不算数由人工审核决定。
`[建议]` 已取消任务收到的结果,落库时标记出来,在界面上让操作员能看到"这单已取消但客户端还是买了"。
## 5. 提交失败
```http
POST /api/v1/client/tasks/{task_id}/failure
Idempotency-Key: <task_id>:<attempt_id>:failure-v1
```
`status` 只会是 `retry_wait`、`manual_review`、`failed`、`cancelled` 之一,按下表落地:
| Client 报告 | Admin 置为 | 说明 |
|---|---|---|
| `retry_wait` | `assigned` | 放回去,等它再来领 |
| `manual_review` | `manual_review` | 等人处理 |
| `failed` | `failed` | |
| `cancelled` | `cancelled` | |
采集任务失败时,同步把 `pdd_products.collect_status` 置 `failed`,
错误信息写进 `collect_msg`、诊断产物位置写进 `artifact_ref`,界面上要看得见。
成功响应:
```json
{
"accepted": true,
"result_id": "result-uuid",
"task_status": "assigned",
"accepted_at": "2026-08-06T08:03:01Z"
}
```
`[必须]` `result_id` 非空,并与同一幂等键保存的响应一起返回。失败提交也使用
Client `SubmissionReceipt` 的统一确认字段,不能只返回 `task_status`。
`[必须]` §4.1 的无条件接受**同样适用于本接口**。
### 5.1 采购运行时规格解析
权威 HTTP schema、长度限制、哈希算法和错误代码见 Client 契约 §7.1。Admin 侧路由为:
```http
POST /api/v1/client/tasks/{task_id}/spec-resolution
Idempotency-Key: spec-resolution-v1:<identity-sha256>
```
本命令只在 Client 已进入采购规格面板、选中颜色后仍无法精确匹配任务尺码时使用。请求
schema v1 只包含:`task_version`、`attempt_id`、`pdd_goods_id`、领取任务时的
`original_options`、`selected_color`、`target_size`、当前颜色下 1~100 条可购买候选、
候选快照哈希和观测时间。整个请求体最多 64 KiB。候选编号必须按页面顺序严格使用
`c1`~`c100`,每条只保存页面原文及原始 `color/size`;禁止原始控件树和截图。
运行时 AI 调用必须使用独立的 **30 秒上限**;服务商 `timeout_seconds` 更短时以更短值
为准,并继承 Client 断开造成的更早取消。Client 为本命令保留最多 **60 秒**等待时间,
因此 Admin 必须在自己的预算内完成审计和响应。其他 Client 接口继续使用原短超时,
本命令仍不得改成 GET、后台轮询或无限等待。
处理顺序:
1. 限制请求体大小并校验 schema、字段长度、候选数量、连续短编号、原文和 options;
2. 按 Client 契约 §7.1 的长度前缀算法重新计算候选快照哈希和确定性幂等键;
3. 核对任务存在、类型为采购、版本和 PDD 商品一致,并确认 `X-Client-Id` 在
`task_claims` 中领取过该任务;任务当前是否取消、重派或结束不作为状态查询条件;
4. 计算完整规范请求的 `request_hash`。先按
`(task_id, attempt_id, candidate_snapshot_hash)` 复查 `purchase_spec_resolutions`:
相同 `request_hash` 复用旧记录,不同 `request_hash` 返回 `409 IDEMPOTENCY_CONFLICT`;
5. 用短事务保存 pending 候选观察,事务外执行后续工单提供的规则/AI 服务,只允许选择
请求中已有候选;再用一个短事务完成解析记录并把可安全重放的响应写入
`idempotency_keys`。Admin 崩溃留下的 pending 记录由相同请求恢复,不另建记录;
6. 返回 `200`,不更新 `tasks.status`、`tasks.result_data` 或
`pdd_products.skus_json`,刷新 Client `last_seen_at`。
业务响应固定为 `matched/uncertain/rejected/failed`。只有 `matched` 返回非空 `match`,
且 `candidate_id`、`raw_text`、`options` 必须是请求候选的逐字副本;`source` 只允许
`rule/ai/reused/null`,`confidence_bps` 为 `0~10000` 或 NULL。NULL 表示来源没有可比较
置信度,不能当作 0。`failed` 是已落库、可幂等重放的业务结论,仍返回 HTTP 200;只有
记录尚未落库时的基础设施故障才返回可有限重试的 `503 SPEC_RESOLUTION_UNAVAILABLE`。
稳定校验错误与 Client 契约保持一致:`INVALID_BODY`、
`INVALID_SPEC_RESOLUTION_SCHEMA`、`INVALID_SPEC_RESOLUTION_REQUEST`、
`SPEC_RESOLUTION_HASH_MISMATCH`、`TASK_NOT_FOUND`、`TASK_NOT_PURCHASE`、
`TASK_VERSION_CONFLICT`、`PDD_GOODS_MISMATCH`、`TASK_NOT_CLAIMED_BY_CLIENT`、
`IDEMPOTENCY_CONFLICT`。所有错误使用本文 §7 的统一 JSON 结构。
解析表只保存候选观察、最终决策、非敏感服务商/模型指纹和审计时间。不保存模型 API
Key、完整提示词/响应、原始 XML、订单号、收货信息、Cookie 或 Token。旧 Client 不调用
本命令,原四个接口的路径和语义不变。本命令不提供 GET、进度查询、心跳或轮询能力。
## 6. 幂等怎么做
`[必须]` 建一张表记录处理过的键:
```sql
CREATE TABLE idempotency_keys (
key TEXT PRIMARY KEY,
request_hash TEXT NOT NULL, -- 请求体的哈希
response_body TEXT NOT NULL, -- 上次返回了什么
created_at TEXT NOT NULL
);
```
- 键相同、哈希相同 → 直接返回 `response_body`,不再处理;
- 键相同、哈希不同 → 返回 `409 IDEMPOTENCY_CONFLICT`;
- 键不存在 → 正常处理,然后连同响应一起写入,**和业务写入在同一事务**。
不这么做的话,Client 网络超时重发就会产生两条结果。规格解析还要同时依靠
`purchase_spec_resolutions` 的业务唯一键防止换一个 HTTP 键重复决策;观察、决策和
`idempotency_keys` 必须在同一事务收敛。
## 7. 错误格式
页面出错渲染错误页,**接口出错返回 JSON**,两者不要混:
```json
{
"error": {
"code": "IDEMPOTENCY_CONFLICT",
"message": "相同幂等键提交了不同内容",
"retryable": false,
"request_id": "7a5d...",
"details": {}
}
}
```
状态码用法见 Client 契约 §3。注意 `403` 的含义:**只有从未分配给该客户端的任务**才返回它。
## 8. 认证
`[待定]` 认证方式尚未定案(Client 契约 §10 待确认 #1)。
临时默认:接口读 `Authorization: Bearer <token>` 请求头但**不校验**,
MVP 阶段只在内网/本机运行。
`[必须]` 无论最终怎么做,**token 不得写进日志**。
## 9. Admin 侧实现清单
Client 契约里散落的 Admin 侧硬要求,汇总在这里,**可以直接当验收标准用**:
- [ ] `PUT /registration` 相同 Client ID 幂等 upsert,不创建重复记录
- [ ] 登记接口不读取、领取或修改任务
- [ ] 显式登记更新非空名称,空名称更新保留已有名称
- [ ] 登记接口校验名称、任务类型、执行模式和结构版本
- [x] `PUT /registration` 幂等:重复调用不产生重复记录
- [x] 登记**不读取、不领取、不修改任何任务**,响应不含 `task`
- [x] 显式登记带非空名称更新名称;空名称保留原名;新建时用 client_id 兜底
- [x] claim 隐式登记**不更新名称**
- [x] 登记和 claim **共用同一套字段校验**,非法内容返回 `422 INVALID_CLIENT_PROFILE`
- [x] 校验不通过时不写库
- [ ] `claim` 一次只返回一个任务
- [ ] 能领到无主任务(`assigned_client IS NULL` + `pending`),领取时写入领取者
- [ ] 指定给本机的任务优先于无主任务
- [ ] 不会返回指定给别的客户端的任务
- [ ] `claim` 原子完成,并发下不会把同一任务发给两个客户端
- [ ] `claim` 无任务时返回 `204`,不是 `200` 加空对象
- [ ] 响应不含租约、不含 Admin 侧状态
- [ ] `payload.goods_url` 必有值
- [ ] 采购任务的 `quantity`、`max_price_cent` 必有值,后者是人民币订单总价上限的分整数
- [ ] 可以预先指派 live 任务给可见 Client,但领取时不向只声明 `dry_run` 的客户端返回 live 任务
- [ ] `result` / `failure` 幂等:同键同内容返回同结果
- [ ] `spec-resolution` 校验 64 KiB、schema、字段长度、1~100 候选和连续短编号
- [ ] 重新计算候选快照哈希和确定性幂等键,不信任 Client 自报哈希
- [ ] 相同任务、尝试、候选快照只保存一条解析记录;同内容返回首次响应,不同内容返回 `409`
- [ ] 只有 matched 返回请求内候选的逐字副本;不接受模型生成规格
- [ ] uncertain / rejected / failed 安全停止且可幂等重放,置信度保留 NULL 语义
- [ ] 规格解析不修改任务状态和 PDD 主数据,不提供 GET、轮询、租约或心跳
- [ ] 规格解析审计不含原始 XML、订单、收货信息和任何凭据
- [ ] 同键不同内容返回 `409`
- [ ] **任务已取消,仍接受结果**
- [ ] **任务已重派,仍接受结果**
- [ ] **同一任务接受多个客户端的多份结果**
- [ ] 只有从未分配过的任务才返回 `403`
- [ ] `claim` / `result` / `failure` / `spec-resolution` 都刷新 `last_seen_at`
- [ ] token 不进日志