Files
cmbuyer/docs/api.md
T

414 lines
20 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.
# API 合约
> 本文是采购服务与采购工具之间的唯一线协议权威。页面判据和本地类名不是线协议。
> 接口形状参考前序项目的经验,但所有状态与安全语义在 cmbuyer 重新定义、测试和取证。
## 通用约定
- 生产前缀:`/api/v1`;管理页面路由见 [routes.md](routes.md)。
- JSON 使用 UTF-8;时间为 UTC RFC 3339;ID 为 UUID 字符串。
- 金额均为规范十进制字符串,如 `"12.88"`;禁止 JSON number 和浮点计算。
- 所有写接口接受 `request_id` / 业务幂等键;同键同载荷重放同一结果,同键异载荷返回 `409`。
- 任务写入携带 `expected_task_version`;版本冲突返回 `409 version_conflict`。
- 服务端错误不得回显设备 token、完整节点树、地址、手机号或支付信息。
### 身份
| 身份 | 凭据 | 能力 |
| --- | --- | --- |
| 管理员 | `HttpOnly; SameSite=Lax` 会话 cookie + CSRF;HTTPS 部署设 `Secure=true` | 建单、开始采购、查看内部证据、人工调和 |
| 设备 | `Authorization: Bearer <device-token>` + `X-CMBuyer-Device-ID` | 心跳、领取、事件、截图、围栏与结果 |
| ERP(V2) | 独立凭据 | 只读来源同步,不访问采购结果 |
设备凭据不能建单或开始采购;管理会话不能调用设备接口。凭据缺失或无效返回 `401`,已认证但无权
或管理写请求缺少有效 CSRF 返回 `403`;认证存储故障按下述规则返回 `503`。
设备请求的认证头采用以下固定格式:
- `Authorization` 和 `X-CMBuyer-Device-ID` 必须各出现且只出现一次;代理合并出的逗号列表也拒绝。
- Authorization scheme 按 HTTP 规则大小写不敏感,但 scheme 后只允许一个 ASCII 空格;token 必须是
加密随机生成的 32 字节值对应的 64 位小写十六进制文本。
- 设备 id 必须是规范小写 UUIDv4。token 与设备 id 同时绑定,未知、错配、格式错误和已撤销均返回
空 `401`,可带 `WWW-Authenticate: Bearer`,不区分具体原因。
- 认证器逐请求读取 SQLite,不缓存 ACTIVE 结论。SQLite 查询或连接故障返回空 `503`;401 与 503
都必须发生在 Content-Type 解析和 body 读取之前。
- 管理 cookie 不替代设备凭据;Bearer 也不替代管理 session/CSRF。两类凭据同时出现时,各路由仍只
采用自己的身份域,不把权限相加。
设备 token 由本机管理 CLI 签发,只在签发事务提交后向操作者显示一次;SQLite 仅保存 token 原始
32 字节的 SHA-256(32 字节 BLOB),list/revoke、日志、错误和 HTTP 响应均不显示 token 或 hash。
撤销幂等且不会恢复旧 token。MVP 服务只绑定 IPv4 回环 `127.0.0.1:8080`,设备 Bearer 只经过本机回环 HTTP;
未来非回环访问必须先建立 HTTPS/TLS 终止与代理信任边界。
### 错误响应
```json
{
"error": {
"code": "version_conflict",
"message": "任务已变化,请刷新后重选",
"retryable": false,
"request_id": "c3c9f507-7473-4fa6-8d71-8786c34c6301"
}
}
```
`retryable=true` 只表示接口调用可以按同一幂等键重放,不表示可以重试任何真机点击。
## 一、管理端接口
| 方法 | 路径 | 作用 |
| --- | --- | --- |
| `GET/POST` | `/login` | 登录页 / 建立管理员会话 |
| `POST` | `/logout` | 退出并使会话失效 |
| `GET` | `/tasks` | SSR 任务表格;关键词、状态、时间筛选 |
| `POST` | `/tasks` | 手工创建 `DRAFT` |
| `POST` | `/tasks/start-purchases` | 批量开始采购:创建一次性授权并原子转 `PENDING` |
| `GET` | `/tasks/{id}` | 任务完整页;同一 URL 也可由列表详情抽屉加载 |
| `POST` | `/tasks/{id}/reset-to-draft` | 围栏前人工处理后关闭旧授权,回到 `DRAFT` |
| `POST` | `/tasks/{id}/cancel` | 围栏前取消任务 |
| `POST` | `/order-submissions/{sid}/reconcile` | 围栏后人工调和同一提交 |
| `POST` | `/tasks/{id}/mark-paid` | 人工确认已付款并完成核对 |
| `GET` | `/evidence/{asset_id}` | 登录后读取内部截图;`Cache-Control: no-store` |
`GET /tasks/{id}` 的完整页与列表抽屉共享同一服务端数据模型和详情模板。列表只可用同源请求携带
`X-CMBuyer-View: drawer` 获取 HTML fragment;其他非空 view、跨站 fragment 请求或不接受
`text/html` 的 fragment 请求均拒绝。直接导航同一 URL 始终返回完整页。
`GET /evidence/{asset_id}` 不经静态目录:未登录先返回 `401`,不查询和泄露资产是否存在;登录后
缺失或畸形 id 返回空 `404`。成功只返回存储的 PNG,包含 `Content-Length`、固定安全文件名、
`Cache-Control: no-store` 与 `X-Content-Type-Options: nosniff`,不返回原文件名或服务端路径。
### `POST /tasks`
核心字段:
```json
{
"create_key": "d3c9f507-7473-4fa6-8d71-8786c34c6301",
"title": "纯棉短袖",
"product_url": "https://mobile.yangkeduo.com/goods.html?goods_id=937122477375",
"sku_color": "黑色CHA(纯棉)",
"sku_size": "M(建议100-115)",
"quantity": 2,
"max_total_price": "30.00"
}
```
- 服务端解析并保存 canonical URL 与 `goods_id`;URL 非拼多多商品页、`goods_id` 缺失或含歧义则拒绝。
- `max_total_price` 是本任务允许创建待付款订单的总额上限,不是参考单价。
- 成功只产生 `DRAFT`;不得创建授权、开放设备领取或触发真机。
### `POST /tasks/start-purchases`
```json
{
"start_key": "63c9f507-7473-4fa6-8d71-8786c34c6301",
"tasks": [
{"task_id": "83c9f507-7473-4fa6-8d71-8786c34c6301", "expected_task_version": 1},
{"task_id": "93c9f507-7473-4fa6-8d71-8786c34c6301", "expected_task_version": 1}
]
}
```
管理员按钮必须显示为“开始采购(只创建待付款订单)”。**点击本身就是授权**:允许采购工具按任务
锁定字段创建一笔待付款订单;不再等待试选后人工确认,也不授权付款。
服务端在一个事务中:
1. 校验列表非空、无重复任务,所有任务均为 `DRAFT` 且版本一致;
2. 校验每条任务的 `goods_id`、规格、正整数数量和最高总价完整;
3. 为每条任务创建一次性 `order_authorization`,锁定任务版本、上述字段、管理员、时间和有效期;
4. 把所有任务转为 `PENDING` 并递增版本。
任一条失败则整批不变。相同 `start_key` + 相同任务集合重放同一批结果;集合或版本不同返回 409。
```json
{
"start_key": "63c9f507-7473-4fa6-8d71-8786c34c6301",
"authorized_count": 2,
"tasks": [
{"task_id": "83c9f507-7473-4fa6-8d71-8786c34c6301", "task_version": 2, "authorization_id": "a3c9f507-7473-4fa6-8d71-8786c34c6301"},
{"task_id": "93c9f507-7473-4fa6-8d71-8786c34c6301", "task_version": 2, "authorization_id": "b3c9f507-7473-4fa6-8d71-8786c34c6301"}
],
"payment_automated": false
}
```
### 围栏前重置与围栏后调和
- `reset-to-draft` 必须同时校验任务版本、授权 id 与“尚无 `order_submission`”。关闭旧授权后回到
`DRAFT`;重新开始必须产生新版本与新授权。
- 一旦存在 `order_submission`,重置、取消、授权过期和重新开始都返回 `409 submission_fenced`。
- `reconcile` 只能处理指定 `sid`:人工记录“已创建待付款订单”或“确认未创建/无法完成”。它不能
触发设备点击、释放围栏或签发新授权。
## 二、设备侧接口
| 方法 | 路径 | 作用 |
| --- | --- | --- |
| `POST` | `/api/v1/devices/heartbeat` | 上报设备、ADB、App 版本和能力状态 |
| `POST` | `/api/v1/tasks/claim-next` | 原子领取一个 `PENDING` 授权任务或重放本设备未结束领取 |
| `POST` | `/api/v1/tasks/{id}/lease/renew` | 续租;只允许当前 claim |
| `POST` | `/api/v1/tasks/{id}/events` | 批量追加结构化步骤事件 |
| `POST` | `/api/v1/tasks/{id}/evidence` | 显式上传一个内部原始截图 |
| `POST` | `/api/v1/purchase-attempts/{aid}/fail` | 围栏前停止并回传失败摘要 |
| `POST` | `/api/v1/purchase-attempts/{aid}/submission-fence` | 提交当前三闸门摘要并原子申请唯一围栏 |
| `POST` | `/api/v1/order-submissions/{sid}/result` | 点击后一次性上报观察结果;只调和不重试 |
### `POST /api/v1/devices/heartbeat`
```json
{
"device_id": "e3c9f507-7473-4fa6-8d71-8786c34c6301",
"client_version": "0.1.0",
"adb_serial": "192.168.0.173:5555",
"android_release": "16",
"pdd_version": "8.17.0",
"state": "READY"
}
```
服务端可返回 `app_version_allowed=false`;采购工具必须停止领取,不能只显示警告后继续。
### `POST /api/v1/tasks/claim-next`
设备 id 只来自已经认证的 `X-CMBuyer-Device-ID`,不得放进 JSON。请求体上限 4096 字节,只接受
以下两个字段;二者都必须是规范小写 UUIDv4,未知字段、重复字段和额外 JSON 均拒绝:
```json
{
"session_id": "23c9f507-7473-4fa6-8d71-8786c34c6301",
"claim_request_id": "33c9f507-7473-4fa6-8d71-8786c34c6301"
}
```
成功领取或同一会话恢复返回 `200`:
```json
{
"task": {
"id": "13c9f507-7473-4fa6-8d71-8786c34c6301",
"version": 3,
"title": "纯棉短袖",
"product_url": "https://mobile.yangkeduo.com/goods.html?goods_id=937122477375",
"goods_id": "937122477375",
"sku_color": "黑色CHA(纯棉)",
"sku_size": "M(建议100-115)",
"quantity": 2,
"max_total_price": "30.00"
},
"authorization": {
"id": "73c9f507-7473-4fa6-8d71-8786c34c6301",
"task_version": 2,
"expires_at": "2026-08-04T10:00:00Z"
},
"attempt": {
"id": "53c9f507-7473-4fa6-8d71-8786c34c6301",
"claim_token": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"claim_generation": 1,
"lease_expires_at": "2026-08-04T09:05:00Z"
}
}
```
- 设备认证发生在 Content-Type 解析和 body 读取前。事务的第一条数据库业务语句取得 SQLite 写入
位置并再次条件确认设备仍为 `ACTIVE`;之后才允许查幂等记录、候选或返回 EMPTY/冲突。
- 只返回 `PENDING + ACTIVE + 严格未过期` 且 task/version/规格/数量/总价快照完全一致的最早授权;
服务端在同一事务中创建唯一 attempt/claim/request,并转为 `CLAIMED`。
- 同一 `claim_request_id` 同设备、同 session 稳定重放原结果;同键异载荷返回
`409 {"error":"idempotency_conflict"}`。没有候选返回空 `204`,且 EMPTY 也持久化稳定重放。
- claim 持久化完整成功响应快照;领取后的 task/authorization 源行变化不得让旧 request 的标题、规格、
数量、金额、版本或到期时间漂移。源快照不一致时,新恢复/续租失败闭合。
- 一个设备最多有一个未关闭 claim。同 session 且租约有效时重放原 attempt;同一 attempt 已按服务端
首事件原子进入 `ORDERING` 时也只在 task version 恰好为 claim 版本 +1 时恢复。不同 session、租约
过期或业务状态异常固定返回 `409 {"error":"claim_requires_manual"}`,不释放、不转领、不新建 attempt。
- `claim_token` 是 32 字节 HMAC 的 64 位小写十六进制表示,只证明一个 attempt 的归属,不是提交许可。
SQLite 仅保存随机 nonce 与 token SHA-256;同一 secret 重启后重建相同 token,错误 secret 拒绝启动。
- 响应不得包含自由动作脚本、CSS/XPath、通用坐标或支付能力。
### `POST /api/v1/tasks/{id}/lease/renew`
请求体同样限 4096 字节并执行严格 JSON 校验:
```json
{
"renew_request_id": "43c9f507-7473-4fa6-8d71-8786c34c6301",
"session_id": "23c9f507-7473-4fa6-8d71-8786c34c6301",
"attempt_id": "53c9f507-7473-4fa6-8d71-8786c34c6301",
"claim_generation": 1,
"claim_token": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"expected_lease_expires_at": "2026-08-04T09:05:00Z"
}
```
成功返回 `200`;响应不回显 token:
```json
{
"task_id": "13c9f507-7473-4fa6-8d71-8786c34c6301",
"attempt_id": "53c9f507-7473-4fa6-8d71-8786c34c6301",
"claim_generation": 1,
"lease_expires_at": "2026-08-04T09:06:00Z"
}
```
- 原设备/session/attempt/generation/token 必须同时匹配,当前租约与授权都必须严格晚于服务端 UTC
当前时间,`expected_lease_expires_at` 必须逐字等于数据库当前值;边界相等即过期且不能复活。
- 新到期时间是 `min(server_now + CMBUYER_CLAIM_LEASE_TTL, authorization.expires_at)`。续租不改变
token、generation、任务版本或业务状态。
- 成功续租先持久化 request 与响应;同一 `renew_request_id` 同载荷只重放旧响应,不再次 CAS 或延长。
同键异载荷返回 `idempotency_conflict`;非当前 claim 固定返回 `claim_not_current`,两者均为 `409`。
claim/renew 的格式错误固定为 `400 {"error":"invalid_request"}`,超限为
`413 {"error":"request_too_large"}`,Content-Type 错误为
`415 {"error":"unsupported_media_type"}`;设备认证/事务内撤销为无诊断 `401`,存储故障为无诊断 `503`。
### 事件与证据
事件只包含固定 `step` / `outcome` / `reason_code` 和非敏感摘要。禁止把完整 XML、地址、手机号、
页面全文或 token 塞进日志字段。
截图接口使用 `multipart/form-data`,只接受单个显式文件及以下元数据:
```json
{
"upload_key": "43c9f507-7473-4fa6-8d71-8786c34c6301",
"attempt_id": "33c9f507-7473-4fa6-8d71-8786c34c6301",
"kind": "SKU_PANEL_GATE_1",
"privacy_tier": "INTERNAL_RAW",
"sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"captured_at": "2026-08-04T09:01:00Z"
}
```
- 允许规格面板和确认页截图保留页面已显示的地址/手机号;不要求遮罩或裁剪。
- 不接受 XML、目录、manifest、本机绝对路径、外部支付页截图或支付凭据。
- T-204 只开放 `kind=SKU_PANEL_GATE_1`;后续 kind 必须由对应真机证据任务收紧扩展。
- `privacy_tier` 只能是 `INTERNAL_RAW`;时间必须是以 `Z` 结尾的 UTC RFC 3339。
- URL 中的 task id、`upload_key` 与 `attempt_id` 都必须是规范的小写 UUIDv4;`sha256` 必须是
恰好 64 位小写十六进制字符。
- 恰好一个带 `Content-Type: image/png` 的显式文件;除上述六个元数据字段外,未知或重复字段均拒绝。
- 单文件最多 10 MiB、单边最多 8192 px、总像素最多 16,777,216;服务端校验 PNG 魔数、完整解码、
字节数、尺寸与调用方声明的 64 位小写 SHA-256。
- `attempt_id` 必须由数据库复合外键证明属于 URL 中的 task,且首次写入必须存在由认证设备持有的
未关闭 claim;设备 A 不能向设备 B 的 attempt 上传。认证必须先于 Content-Type 解析和请求体读取。
- 同一设备主体和 `upload_key` 的同载荷重放返回原资产;即使 claim 后续由人工关闭,已成功资产仍先
重放历史结果。关闭后不得用新 upload key 写新证据;任务、attempt、截图或元数据变化返回 `409`。
- 首次成功返回 `201`,幂等重放返回 `200`。响应只含资产 id、关联 id、kind/tier、hash、字节数、
MIME、宽高和采集时间,不含设备 token、原文件名或存储路径。
- 生产上传使用逐请求 SQLite 设备认证;空凭据库、未知或已撤销设备均拒绝。不得使用管理员 session、
临时共享密钥或其他身份代替设备凭据。
### `POST /api/v1/purchase-attempts/{aid}/submission-fence`
客户端只有在当前页面四条件中的后三项已经满足后才能调用:
```json
{
"fence_key": "e3c9f507-7473-4fa6-8d71-8786c34c6301",
"task_id": "13c9f507-7473-4fa6-8d71-8786c34c6301",
"expected_task_version": 3,
"authorization_id": "73c9f507-7473-4fa6-8d71-8786c34c6301",
"claim_token": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"selected_color": "黑色CHA(纯棉)",
"selected_size": "M(建议100-115)",
"gate1_unit_price": "12.88",
"gate2_unit_price": "12.88",
"quantity_read": 2,
"confirm_page_amount": "25.76",
"submit_control_match_count": 1
}
```
服务端在一个事务中校验:任务/版本/claim/attempt 一致;授权有效未消费且字段等于任务快照;
规格与授权相等;数量相等;两个单价相等;计算金额及确认页金额均不超过 `total_price_cap`;提交控件
计数为一;此前不存在该授权或 attempt 的 submission。随后创建唯一 `order_submission`,授权转
`FENCED`,任务保持不可重领。
首次明确成功响应:
```json
{
"submission_id": "f3c9f507-7473-4fa6-8d71-8786c34c6301",
"status": "FENCED",
"click_permitted": true,
"submit_text": "提交订单"
}
```
- 任一校验失败返回错误,绝不返回 `click_permitted=true`。
- 同一 `fence_key` 的重放返回同一 `submission_id`,但 `click_permitted=false` 且
`reconciliation_required=true`;客户端不能凭重放响应点击。
- 客户端收到首次许可后,必须先把“围栏已取得/即将发出唯一点击”持久化,再执行点击。进程崩溃或
本地状态不明时宁可转调和,也不再次点击。
### `POST /api/v1/order-submissions/{sid}/result`
```json
{
"result_key": "03c9f507-7473-4fa6-8d71-8786c34c6301",
"attempt_id": "53c9f507-7473-4fa6-8d71-8786c34c6301",
"observation": "SUBMITTED",
"evidence_asset_id": "63c9f507-7473-4fa6-8d71-8786c34c6301"
}
```
`observation` 只允许:
- `SUBMITTED`:明确订单已创建,转 `WAITING_PAYMENT`;
- `EXTERNAL_PAYMENT_HANDOFF`:已跳外部支付,停止并转 `RECONCILIATION_REQUIRED`;
- `SECURITY_CHALLENGE`:出现安全校验,停止并转调和;
- `UNKNOWN`:超时、断连或页面不明,转调和。
提交后没有“retry”观察值。任何结果都不能释放围栏或开放第二次点击。
### 文本和金额校验
- 规格字段:Unicode 规范化后精确相等;不得包含、前缀、编辑距离或 AI 猜测。
- `goods_id`:仅 ASCII 十进制数字,canonical URL 中唯一。
- 金额:`0.01` 到系统配置上限,至多两位小数;规范化后再比较和持久化。
- 数量:正整数,服务端与设备均设置合理上限;不能从字符串静默截断。
## 三、采购工具本地模块合约
### `TaskSource` / `ResultSink`
```python
class TaskSource(Protocol):
def claim_next(self, session: Session) -> ClaimedPurchase | None: ...
def renew_lease(self, claim: Claim) -> Lease: ...
class ResultSink(Protocol):
def append_events(self, claim: Claim, events: list[TaskEvent]) -> None: ...
def upload_screenshot(self, claim: Claim, asset: ScreenshotAsset) -> AssetRef: ...
def fail_attempt(self, claim: Claim, failure: AttemptFailure) -> None: ...
def create_submission_fence(self, claim: Claim, proof: SubmissionProof) -> SubmissionPermit: ...
def report_submission_result(self, permit: SubmissionPermit, result: SubmissionResult) -> None: ...
```
执行器不能依赖具体 HTTP 或 Excel 实现。`SubmissionPermit` 只能由 `ResultSink` 的服务端成功响应构造,
业务代码不能手工 new 一个许可。
### 真机能力分层
| 能力 | 输入 | 输出 | 安全边界 |
| --- | --- | --- | --- |
| `open_product()` | canonical URL + 证据版本 | 已确认商品页 | URL、前台包、App 版本全部匹配 |
| `open_sku_panel()` | 版本绑定受控入口 | 已确认规格面板 | 精确唯一;无通用 click |
| `select_sku_options()` | 维度 → 精确值 | 选中态摘要 | 维度内唯一匹配并读回 |
| `read_sku_unit_price()` | 已确认规格面板 | 十进制单价 | 排除原价、按钮价和歧义候选 |
| `set_quantity_and_readback()` | 授权数量 | 实际数量 | 精确读回,否则停 |
| `go_to_order_confirm()` | 已通过闸门二 | 确认页摘要 | 后续真机任务取证后才实现 |
| `submit_order_once()` | 不可伪造的首次 `SubmissionPermit` | 观察结果 | 许可、闸门、唯一控件全校验;点前持久化;绝不重试 |
T-103 只实现隔离的 `SkuSelectionFlow`:前四项加安全退出。它的模块和静态依赖不得引用数量、确认页、
围栏、提交或支付能力。后续任务按取证顺序组合成生产 `SinglePassPurchaseFlow`。
## 四、实现前仍需定值
- 授权有效期、领取租约时长、心跳/轮询间隔和连续失败停止阈值;
- 内部截图保留期限;截图大小上限已固定为 10 MiB / 8192 px 单边 / 16,777,216 像素;
- 可配置单任务数量与最高总价系统上限;
- 首次真实提交真机任务的人工授权和待付款订单处置步骤。