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

436 lines
17 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 Client–Admin API v1 契约
- 文档状态:基线草案,待 Client 与 Admin 联合评审
- 基础路径:`/api/v1/client`
- 编码:UTF-8 JSON
- 时间:带时区 ISO 8601,服务端优先返回 UTC
- 金额:人民币分整数
本文档中 `[必须]` / `[建议]` / `[待定]` 的含义见 [文档索引](../README.md#文档标注说明)。没有标注的默认是 `[必须]`。看不懂的词(幂等、Outbox)查 [术语表](00-glossary.md)。
## 1. 设计原则
**核心:Admin 是调度器,Client 是执行器,执行器不参与调度决策。**
- Client 的任务流程只有三个动作:领一个任务、提交结果、提交失败。
设置页另有一个幂等 Client 登记动作。**没有任何"去问 Admin 现在怎么想"的调用。**
- Client 拿到任务就做完,中途不管任务是否被取消、是否被重派。
- Admin 负责任务的创建、更新、取消和派发(含重派),这些 Client 一律不感知。
- 结果提交必须幂等;网络重试不能创建重复结果。
- Admin **必须无条件接受**已派发过的 Client 提交的结果,理由见 §6.1。
- 任务和结果使用版本化结构,未知字段应允许向前兼容。
- Admin 业务错误返回稳定错误代码,不要求 Client 解析自然语言判断逻辑。
### 1.1 为什么没有租约和心跳
早期方案有租约(lease)和心跳,用来防止两个 Client 做同一个任务导致重复下单。后来去掉了,原因是:
**本项目不自动付款**(见 [01 需求](01-requirements.md) §9),采购止于创建订单。
重复下单产生的是重复的**未付款**订单,人工审核时不付即可,代价和"白干一场"是一个量级。
为这点代价引入租约、心跳、过期判断和一整套中断逻辑,不划算。
Admin 想知道某个 Client 是不是卡死了,用**领取后超时重派**即可——这完全在 Admin 侧,Client 不参与。
> 注意:去掉租约**不代表**去掉防重复下单。防的是**本机崩溃重启后重复下单**,
> 靠 `task_runs.irreversible_action_at` 标记,见 [03 数据模型](03-data-model.md) §7.3。那套机制反而更重要了。
## 2. 通用请求头
```http
Authorization: Bearer <token>
X-Client-Id: client-001
X-Request-Id: <uuid>
Content-Type: application/json
```
结果和失败提交额外携带:
```http
Idempotency-Key: <stable-key>
```
认证方式仍待 Admin 联合评审。无论最终采用哪种方式,访问令牌不得写入普通日志。
## 3. 通用错误
```json
{
"error": {
"code": "IDEMPOTENCY_CONFLICT",
"message": "相同幂等键提交了不同内容",
"retryable": false,
"request_id": "7a5d...",
"details": {}
}
}
```
建议状态码:
| 状态码 | 场景 |
|---|---|
| `400` | 请求结构或字段无效 |
| `401` | 未认证或凭据过期 |
| `403` | Client 无权访问任务(从未派发给它) |
| `404` | 任务不存在 |
| `409` | 幂等冲突 |
| `422` | 业务规则不满足 |
| `429` | 请求过于频繁 |
| `500/503` | Admin 暂时故障,可按策略重试 |
注意 `403` 的含义:只有**从未派发给这个 Client** 的任务才返回 403。
任务已取消、已重派给别人,都**不能**返回 403,见 §6.1。
## 4. 任务对象
```json
{
"id": "PDD-20260806-0001",
"type": "purchase",
"version": 3,
"priority": 10,
"payload": {
"goods_id": "737116531267",
"goods_url": "https://mobile.yangkeduo.com/goods.html?goods_id=737116531267",
"expected_title": "商品标题",
"options": {
"color": "黑色",
"size": "L"
},
"quantity": 2,
"expected_price_cent": 3990,
"max_price_cent": 4200
},
"created_at": "2026-08-06T07:00:00Z",
"updated_at": "2026-08-06T07:05:00Z"
}
```
采集任务的 `payload` 不要求颜色、尺码、数量和价格字段。采购任务必须包含 `quantity` 以及 `max_price_cent` 或等价的明确价格保护规则。
任务对象里**不含 Admin 侧状态**。Client 不关心 Admin 那边把它标成什么,只管做完提交。
## 4.1 登记或更新 Client
```http
PUT /api/v1/client/registration
X-Client-Id: <stable-client-id>
X-Request-Id: <uuid>
```
请求:
```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]
}
}
```
成功统一返回 `200 OK`:
```json
{
"registered": true,
"client_id": "CLIENT-123456",
"registered_at": "2026-08-06T09:00:00Z"
}
```
规则:
- `[必须]` `X-Client-Id` 是 Client 唯一键;相同编号重复 `PUT` 执行幂等 upsert,不创建重复记录。
- `[必须]` 登记接口不读取、领取或修改任务,也不返回任务。
- `[必须]` `client.name` 可选且最多 50 字。显式登记携带非空名称时允许更新 Admin 名称;空名称更新保留已有名称,新建时用 Client ID 兜底。
- `[必须]` 更新设备、能力、`last_seen_at` 和 `updated_at`。
- `[必须]` `supported_types` 非空且只包含 `collect`、`purchase`;`purchase_mode` 只允许 `dry_run`、`live`;`schema_versions` 只包含正整数。
- `[必须]` Client 必须先把设备号和名称保存到 SQLite,再在后台线程调用本接口;远端失败不得回滚本地保存。
- `[必须]` 设备号生成后稳定复用,不向 Admin 发送生成设备号所用的原始硬件参数。
- `[必须]` 不增加定时心跳。在线状态仍由登记、领取和提交产生的 `last_seen_at` 派生。
错误至少包括 `MISSING_CLIENT_ID`、`INVALID_BODY`、`INVALID_CLIENT_PROFILE` 和 `CLIENT_REGISTER_FAILED`,并使用 §3 的统一结构。
`claim` 继续支持隐式登记作为旧 Client 的兼容兜底:新编号可以创建 Client,已有 Client 只更新设备、能力和最近活动时间,不覆盖名称。
## 5. 领取任务
```http
POST /api/v1/client/tasks/claim
```
请求:
```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]
}
}
```
有任务时返回 `200`:
```json
{
"task": {}
}
```
无可领取任务时返回 `204 No Content`。
Client 的 `HttpAdminGateway.claim_next` 已实现本接口,并原样序列化应用层已经
安全确认的 `ClaimCapabilities`。没有采购演练 Adapter 或设备未准备好时只声明
`supported_types: ["collect"]`;条件满足时声明 `collect,purchase`,但
`purchase_mode` 固定为 `dry_run`。一次调用最多领取一个任务,结果或失败通过
本页 §6 或 §7 提交,完整请求会先进入本地 Outbox。
采购响应在写入本地前必须校验非空 `goods_url`、`goods_id`、动态 `options`、
正整数 `quantity` 和正整数分 `max_price_cent`。字段不完整时停止新的领取并向
操作员显示协议错误;不得让不完整任务进入手机执行。
`[必须]` **`204` 不是错误。** Client 要把它当"暂时没活干"处理,
不要报错,也不要因此触发重试风暴。首次 claim 通常返回 204;如果该编号已经预先分配任务,也可以直接返回 200。
`client.name` 是给人看的显示名,可以不填(新建时 Admin 用 `X-Client-Id` 兜底)。
在 claim 兼容登记中,它只在首次创建时被采纳,已有 Client 的名称不会被后台领取覆盖;用户通过 §4.1 显式保存非空名称时可以更新。
> 怎么把这条链路跑通、怎么确认设备登记成功,见
> [Admin 侧的设备登记联调手册](../admin/07-设备登记联调手册.md)。
规则:
- `[必须]` 领取必须在 Admin 内部原子完成,一次调用最多返回一个任务。
- `[必须]` Admin 不得向只声明 `dry_run` 的 Client 分配要求真实下单的任务。
- `[必须]` 响应**不包含**租约。Client 拿到任务就开始做,做完再来领下一个。
### 5.1 Admin 侧的分配语义
任务分两种,`claim` **两种都会返回**:
| 类型 | `assigned_client` | Admin 侧状态 | 谁能领 |
|---|---|---|---|
| **指定分配** | 某个 Client 编号 | `assigned` | 只有那个 Client |
| **无主** | 空 | `pending` | **谁先抢到算谁的**,领取时才记下领取者 |
`[必须]` **指定给本机的优先于无主的。** 显式分配是人为决定,应当先兑现;
无主任务谁抢都一样,可以等。
哪种任务用哪种方式:
- **采集任务不指定客户端。** 采集只是浏览商品页,没有副作用,哪台设备采都一样,
没必要每次都挑一台。
- **采购任务可以指定,也允许留空。** 涉及钱和账号——不同设备可能登着不同的
拼多多账号,买到谁头上是有区别的。**需要指定账号时必须显式分配**,
留空就意味着接受"谁先抢到谁去下单"。
Client 侧对这两种没有任何区别:调 `claim`,拿到任务就做,做完提交。
**不需要知道这个任务原来有没有主。**
因此 Client 完全不需要看到全局任务池,本地也不缓存未领取的任务
(见 [03 数据模型](03-data-model.md) §3.1)。操作人员想知道队列里还有多少活,
去 Admin 自己的界面看([01 需求](01-requirements.md) §9 已把 Admin 界面列为非目标)。
Admin 可以在超时后把任务重派给别的 Client,Client 侧对此**无感知也不需要感知**。
### 5.2 已知缺口:领取成功但本地保存失败
Admin 返回任务时已经把它改成 `claimed`。Client 随后写 SQLite,如果磁盘或数据库
此时失败,服务端任务会处于已领取、本机却没有执行记录的状态。
当前处理方式:界面持续显示任务编号和本地保存错误,要求操作人员记录编号并联系
维护者;不得静默继续领取下一条。自动补偿或 Admin 侧回收由后续独立工单处理。
## 6. 提交成功结果
```http
POST /api/v1/client/tasks/{task_id}/result
Idempotency-Key: task-id:attempt-id:result-v1
```
采集结果:
```json
{
"task_version": 3,
"attempt_id": "attempt-uuid",
"result_type": "collect",
"completed_at": "2026-08-06T08:03:00Z",
"pdd_data": {
"goods_id": "737116531267",
"title": "测试商品",
"shop_name": "XX旗舰店",
"price_granularity": "color",
"dimensions": [
{"key": "color", "name": "颜色分类"},
{"key": "size", "name": "尺码"}
],
"skus": [{
"options": {"color": "黑色", "size": "M"},
"price_cent": 470,
"list_price_cent": 1990,
"price_observed_at": {"color": "黑色"},
"available": true,
"raw_price": "折后¥4.7"
}]
}
}
```
采购结果把 `result_type` 换成 `purchase`,其余结构相同。
响应:
```json
{
"accepted": true,
"result_id": "result-uuid",
"accepted_at": "2026-08-06T08:03:01Z"
}
```
规则:
- `[必须]` 相同 `Idempotency-Key` 和相同请求内容必须返回同一业务结果。
- `[必须]` 相同键但不同内容返回 `409 IDEMPOTENCY_CONFLICT`。
- `[必须]` Client 只有收到 `accepted: true` 后才能把本地任务标为 `succeeded`。
- `[必须]` `price_cent` 是实际支付价的整数分;划线价可放在 `list_price_cent`。
- `[必须]` `price_granularity` 只能是 `color` 或 `sku`。当前 Client 按颜色采样时填 `color`;同一颜色的尺码组合可以共享颜色价格,但 `price_observed_at` 只填写实际选中的颜色,不得虚构尺码。以后逐个选择完整组合采价时才使用 `sku`。
- `[必须]` 采集任务中,颜色无法选中或未采到稳定价格时,相关 `price_cent`、`raw_price` 和 `list_price_cent` 允许为 `null`,`price_observed_at` 允许为空对象;Admin 不得仅因部分或全部颜色缺价拒绝结果。
- `[必须]` `shop_name` 采不到时允许省略或留空;Admin 不得因此拒绝老版本 Client,也不得用空值覆盖已保存的店铺名。
### 6.1 Admin 必须无条件接受
这是对 Admin 侧的**强约束**,实现时最容易被顺手违反,务必写进 Admin 的验收标准:
- `[必须]` Admin **不得**因为任务已取消而拒绝结果。
- `[必须]` Admin **不得**因为任务已重派给别的 Client 而拒绝结果。
- `[必须]` Admin **必须**能接受同一任务来自**多个 Client** 的多份结果。怎么去重、以哪份为准,是 Admin 内部的事,不要求 Client 配合。
- `[必须]` 只要这个任务曾经派发给该 Client,提交就必须被接受。只有从未派发过才返回 `403`。
原因:Client 中途不查任务状态(§1),所以它**必然**会提交一些"Admin 那边已经不要了"的结果。
如果 Admin 拒收,Client 侧就会出现大量无法处理的失败——而 Client 已经真的把事情做完了,
甚至可能已经下了单,这些数据必须能交上去留痕。
`accepted: true` 的含义是"**我收到并存下了**",不代表 Admin 认可这个任务仍然有效。
任务到底算不算数,由 Admin 和人工审核决定,与 Client 无关。
## 7. 提交失败或人工处理结果
```http
POST /api/v1/client/tasks/{task_id}/failure
Idempotency-Key: task-id:attempt-id:failure-v1
```
```json
{
"task_version": 3,
"attempt_id": "attempt-uuid",
"status": "manual_review",
"error": {
"code": "AMBIGUOUS_ORDER_MATCH",
"message": "发现多个可能属于当前任务的订单",
"retryable": false,
"step": "reconcile_order"
},
"diagnostics": {
"artifact_ids": ["artifact-uuid"]
},
"reported_at": "2026-08-06T08:03:00Z"
}
```
`status` 只能是 `retry_wait`、`manual_review`、`failed` 或 `cancelled`。
成功响应与结果接口使用同一种确认结构,并额外返回任务状态:
```json
{
"accepted": true,
"result_id": "result-uuid",
"task_status": "assigned",
"accepted_at": "2026-08-06T08:03:01Z"
}
```
`[必须]` `result_id` 非空。旧 Admin 已经写入幂等表、但没有 `result_id` 的
历史失败响应,Client 可按 `accepted + task_status + accepted_at` 兼容一次,
避免把已经接收的数据误报为拒绝。
规则:
- `[必须]` §6.1 的无条件接受规则同样适用于本接口。
- `[必须]` Admin 决定是否创建新的执行机会,但对已经可能下单的任务不得通过普通重试触发再次购买。
## 8. 幂等与重试
- `[必须]` 结果和失败提交必须使用持久化的 `Idempotency-Key`,格式见 [03 数据模型](03-data-model.md) §5.2。
- `[必须]` Client 在发送前将完整请求写入 Outbox;重试使用相同键和相同内容。
- `[必须]` `claim` 需要 Admin 保证重复请求不会一次分配出多个任务。
- `[建议]` 对 `429`、`500` 和 `503` 使用有上限的指数退避,并遵守 `Retry-After`。
- `[必须]` 对认证、幂等冲突和业务校验错误不得无限重试。
## 9. Mock Admin 要求
`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
```
Mock 必须支持:
- 无任务可领取(`claim` 返回 204);
- 采集和采购任务;
- 网络超时和暂时故障;
- 幂等重复提交(相同键相同内容);
- 幂等冲突(相同键不同内容);
- **提交一个 Admin 侧已取消的任务,仍返回 `accepted: true`**(用来验证 §6.1);
- 结果校验失败。
Mock 测试通过不能替代与真实 Admin 的契约测试。
## 10. 待联合确认
下面这些仍需联合确认。**不许因此停工**——先按“临时默认值”实现,Mock Gateway
也按这个值模拟,等定了再按工单改。
| # | 待确认什么 | 临时默认值(先这么做) |
|---|---|---|
| 1 | 认证、Client 注册和凭据刷新方式 | 请求头预留 `Authorization: Bearer <token>`,token 从设置读;Mock 不校验 |
| 2 | Admin 对人工处理任务的后续操作 | Client 只负责报告 `manual_review` 然后停手,不猜 Admin 会怎么处理 |
| 3 | Artifact 是独立上传还是只报本地引用 | **只报本地引用**(路径 + 哈希),不实现上传 |
| 4 | Admin 超时重派的等待时长 | 纯 Admin 侧策略,Client 不参与也不需要知道 |
**已定案(不再是待确认项):**
- 任务分「指定分配」和「无主」两种,`claim` 两种都返回,指定的优先。
采集任务不指定,采购任务可指定可留空。Client 不读全局任务池。见 §5.1。
- **没有租约、没有心跳、没有状态回查。** 任务流程只有 §5 / §6 / §7 三个调用;设置页另有 §4.1 的幂等登记调用。
- Admin 必须无条件接受已派发过的 Client 提交的结果。见 §6.1。
改动这张表里任何一条,都属于会影响业务结果的变更,必须先更新工单并经用户确认。