Files
cmbuyer/docs/api.md
T

337 lines
15 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; Secure; SameSite=Lax` 会话 cookie + CSRF | 建单、开始采购、查看内部证据、人工调和 |
| 设备 | `Authorization: Bearer <device-token>` + 设备 id | 心跳、领取、事件、截图、围栏与结果 |
| ERP(V2) | 独立凭据 | 只读来源同步,不访问采购结果 |
设备凭据不能建单或开始采购;管理会话不能调用设备接口。未认证统一返回 `401`,无权返回 `403`。
### 错误响应
```json
{
"error": {
"code": "version_conflict",
"message": "任务已变化,请刷新后重选",
"retryable": false,
"request_id": "018f..."
}
}
```
`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": "018f...",
"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": "018f...",
"tasks": [
{"task_id": "018f-task-1", "expected_task_version": 1},
{"task_id": "018f-task-2", "expected_task_version": 1}
]
}
```
管理员按钮必须显示为“开始采购(只创建待付款订单)”。**点击本身就是授权**:允许采购工具按任务
锁定字段创建一笔待付款订单;不再等待试选后人工确认,也不授权付款。
服务端在一个事务中:
1. 校验列表非空、无重复任务,所有任务均为 `DRAFT` 且版本一致;
2. 校验每条任务的 `goods_id`、规格、正整数数量和最高总价完整;
3. 为每条任务创建一次性 `order_authorization`,锁定任务版本、上述字段、管理员、时间和有效期;
4. 把所有任务转为 `PENDING` 并递增版本。
任一条失败则整批不变。相同 `start_key` + 相同任务集合重放同一批结果;集合或版本不同返回 409。
```json
{
"start_key": "018f...",
"authorized_count": 2,
"tasks": [
{"task_id": "018f-task-1", "task_version": 2, "authorization_id": "018f-auth-1"},
{"task_id": "018f-task-2", "task_version": 2, "authorization_id": "018f-auth-2"}
],
"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": "desk-01",
"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`
请求携带 `device_id`、`session_id`、`claim_request_id`。领取与授权绑定且具租约:
```json
{
"task": {
"id": "018f-task",
"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": "018f-auth",
"task_version": 2,
"expires_at": "2026-08-04T10:00:00Z"
},
"attempt": {
"id": "018f-attempt",
"claim_token": "opaque-single-claim-token",
"claim_generation": 1,
"lease_expires_at": "2026-08-04T09:05:00Z"
}
}
```
- 只返回有 `ACTIVE` 授权的 `PENDING`;服务端在一个事务中转为 `CLAIMED` 并创建 attempt。
- 同一 `claim_request_id` 同载荷重放同一结果;并发设备只有一个成功。
- 一个设备有未结束领取时优先重放该领取,不能悄悄领第二条。
- 响应不得包含自由动作脚本、CSS/XPath、通用坐标或支付能力。
### 事件与证据
事件只包含固定 `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。认证必须先于 Content-Type 解析和请求体读取。
- 同一设备主体和 `upload_key` 的同载荷重放返回原资产;任务、attempt、截图或元数据变化返回 `409`。
- 首次成功返回 `201`,幂等重放返回 `200`。响应只含资产 id、关联 id、kind/tier、hash、字节数、
MIME、宽高和采集时间,不含设备 token、原文件名或存储路径。
- T-301 接入真实设备 Bearer 身份前,生产 `DeviceAuthenticator` 固定拒绝全部上传;不得使用管理员
session、临时 token 或共享密钥代替设备身份。
### `POST /api/v1/purchase-attempts/{aid}/submission-fence`
客户端只有在当前页面四条件中的后三项已经满足后才能调用:
```json
{
"fence_key": "018f-fence-request",
"task_id": "018f-task",
"expected_task_version": 3,
"authorization_id": "018f-auth",
"claim_token": "opaque-single-claim-token",
"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": "018f-submission",
"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": "018f-result",
"attempt_id": "018f-attempt",
"observation": "SUBMITTED",
"evidence_asset_id": "018f-asset"
}
```
`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 像素;
- 可配置单任务数量与最高总价系统上限;
- 首次真实提交真机任务的人工授权和待付款订单处置步骤。