Files
cmbuyer/docs/api.md
T

291 lines
12 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 合约
> 本文定义 web 端对外的 HTTP 接口,以及 desk 端本地模块的合约。
> **这是双端之间的唯一权威。** 实现前可细化,但不得在代码里另起一套不兼容接口。
>
> 本合约的设备心跳、任务领取、事件、证据、授权命令与 ack 结构参考了前序项目
> `cmroubao`;同时在本项目重新审计并补回 dry-run、提交前服务端围栏和点击后调和。
> 前序接口是设计依据,不是可直接照搬的运行事实。
## 通用约定
- 传输:JSON over HTTP。MVP 局域网内运行,生产部署应加 HTTPS。
- 编码:UTF-8。
- 时间:RFC 3339,带时区,UTC 存储。
- 金额:**十进制字符串**(如 `"45.60"`),不用浮点数。
- 幂等:所有创建类接口接受幂等键,重复提交返回同一结果而不是第二笔。
### 鉴权
| 客户端 | 方式 | 说明 |
| --- | --- | --- |
| 管理 Web | Session Cookie + CSRF Token | 表单提交必须带 CSRF |
| desk 端 | `Authorization: Bearer <device_token>` | 凭据绑定设备标识,可单独撤销 |
| ERP 对接 | `Authorization: Bearer <connector_token>` | 只能调用货运同步接口 |
三种身份互不通用。设备凭据**不能**创建任务或签发授权;管理会话**不能**调用设备接口。
### 错误响应
```json
{
"error": {
"code": "invalid_argument",
"message": "数量必须是正整数",
"field": "quantity"
}
}
```
错误码枚举:`invalid_argument`、`unauthenticated`、`permission_denied`、`not_found`、
`conflict`、`failed_precondition`、`internal`。
- 未登录访问受保护资源:`401`,管理页面重定向到 `/login`。
- 已登录但无权限:`403`。**不存在**与**无权限**必须使用不同内部原因,但响应体不得泄露
任务内容。
## 一、管理端接口
管理页面为服务端渲染,表单直接 POST 到下列路径,成功后 303 重定向。
| 方法 | 路径 | 职责 |
| --- | --- | --- |
| `POST` | `/login` | 建立管理会话 |
| `POST` | `/logout` | 销毁会话 |
| `GET` | `/tasks` | 任务列表,支持 `q`、`status`、`days`、`cursor` |
| `POST` | `/tasks` | 手工建单(F-001) |
| `GET` | `/tasks/{id}` | 任务详情 |
| `POST` | `/tasks/{id}/cancel` | 取消任务 |
| `POST` | `/tasks/{id}/order-authorizations` | 确认试选结果并签发授权(F-008) |
| `POST` | `/tasks/{id}/order-authorizations/{aid}/abandon` | 围栏前放弃授权,任务转待重新试选(F-010) |
| `POST` | `/tasks/{id}/reject` | 退回不买,任务终止 |
| `POST` | `/tasks/{id}/mark-paid` | 人工核对付款后标记完成(MVP 简化收口) |
> Excel 导入(`/tasks/import`)与 ERP 货运(`/freight*`)已移出 MVP,见
> [需求](02-requirements.md)第三节后续迭代表。
### `POST /tasks/{id}/order-authorizations`
```json
{
"authorization_key": "<幂等键>",
"expected_task_version": 3,
"spec_trial_id": "018f...",
"note": ""
}
```
- **授权内容不由客户端提交。** `goods_id`、规格、数量、`authorized_unit_price` 全部由
服务端从 `spec_trial_id` 指向的试选记录取值——人确认的是那一次试选,不是一组自由填写
的参数。
- `expected_task_version` 不匹配返回 `409 conflict`。
- `total_price_cap` 由服务端按任务的价格上限计算,客户端无法提高。
- 响应中返回 `expires_at`;围栏建立前超时后授权自动 `EXPIRED`,任务转
`PENDING_RETRIAL`,必须重新跑第一趟。
- 已存在 `order_submission` 时,放弃或超时处理返回 `409 conflict`;该授权只能调和结果或
转人工核查,不能重新开放为可执行。
- MVP 没有「选择理由 / 拒绝理由」——那是多候选择一时的留档需求。这里只有可选 `note`。
## 二、设备侧接口(desk 端调用)
全部要求有效设备 Bearer;凭据中的设备标识是权威身份,请求体里的设备字段仅作核对。
| 方法 | 路径 | 职责 |
| --- | --- | --- |
| `POST` | `/api/v1/devices/heartbeat` | 上报版本与就绪位,核对服务端活跃任务 |
| `POST` | `/api/v1/tasks/claim-next` | 原子领取或重放;**同时覆盖待试选与已授权两类** |
| `POST` | `/api/v1/tasks/{id}/start` | `CLAIMED → RUNNING`,创建 execution |
| `POST` | `/api/v1/tasks/{id}/heartbeat` | 更新当前步骤与运行租约 |
| `POST` | `/api/v1/tasks/{id}/release` | 未开始时退回 `PENDING` |
| `POST` | `/api/v1/tasks/{id}/events` | 幂等补报执行事件 |
| `POST` | `/api/v1/tasks/{id}/evidence` | 上传证据资产,SHA-256 寻址 |
| `POST` | `/api/v1/tasks/{id}/spec-trial` | **第一趟**:回传试选结果,任务转 `WAITING_CONFIRMATION` |
| `POST` | `/api/v1/tasks/{id}/commands/next` | **第二趟**:拉取或重放已签发的下单授权 |
| `POST` | `/api/v1/tasks/{id}/commands/{cid}/ack` | 落盘后幂等确认命令 |
| `POST` | `/api/v1/tasks/{id}/order-dry-runs/start` | 开始只读演练;绝不消费授权、绝不允许提交 |
| `POST` | `/api/v1/order-dry-runs/{rid}/ready` | 回传确认页只读结果与证据,结束演练 |
| `POST` | `/api/v1/tasks/{id}/order-submissions/start` | **真实点击前**原子建立唯一提交围栏 |
| `POST` | `/api/v1/order-submissions/{sid}/reconcile` | 点击后上报明确或不明确结果,只调和不重试 |
| `POST` | `/api/v1/order-submissions/{sid}/manual-review` | 将围栏后的不确定结果交给人工核查 |
| `POST` | `/api/v1/tasks/{id}/needs-manual` | 转人工,带原因码与证据 |
| `POST` | `/api/v1/tasks/{id}/fail` | 提交结构化失败与证据 |
> `/candidates`(多候选回传)、`/reference-image`(图搜参考图)、`/order-record`
> (订单自动核对)随 B 路径与 F-016 一并推迟到 V2。
### `POST /api/v1/tasks/claim-next`
```json
{ "device_id": "desk-01", "claim_key": "<幂等键>" }
```
成功:
```json
{
"task": {
"id": "018f...",
"leg": "TRIAL",
"goods_id": "7531364299",
"product_url": "https://mobile.yangkeduo.com/goods.html?goods_id=7531364299",
"sku_color": "白色",
"sku_size": "XL",
"quantity": 2,
"max_total_price": "80.00"
},
"claim_token": "...",
"claim_generation": 1,
"lease_expires_at": "2026-08-03T10:30:00Z"
}
```
- **`leg` 决定这一趟做什么**:`"TRIAL"` = 第一趟试选,`"ORDER"` = 第二趟下单。
`leg` 为 `"ORDER"` 时只返回 `authorization_id`;完整、不可变的授权命令必须通过
`/commands/next` 拉取并落盘,再调用 `/commands/{cid}/ack`。领取接口不重复定义授权载荷。
- 无可领任务返回 `200` 且 `task` 为 `null`,**不是 404**。
- 后续所有该任务的调用必须携带 `X-Claim-Token` 与匹配的 `claim_generation`。
### `POST /api/v1/tasks/{id}/spec-trial`(第一趟回传)
```json
{
"attempt": 1,
"product_title": "2026夏季新款纯棉圆领短袖T恤男女同款宽松半袖",
"selected_color": "白色",
"selected_size": "XL",
"unit_price": "32.50",
"total_price": "65.00",
"evidence_sha256": "…"
}
```
- `selected_color` / `selected_size` 是**实际勾选到的值**,不是任务要求的值。
服务端据此与任务要求比对并在确认页显示 ✓ / ✗。
- `unit_price` 来自闸门一(规格面板)。**读不到时不要发这个接口**,改发
`/needs-manual` 并带原因码 `UNIT_PRICE_UNREADABLE`。
- `total_price` = `unit_price` × 任务数量,服务端会重算校验。
- 证据须先经 `/evidence` 上传。
- 服务端接收后创建 `spec_trials` 记录,任务转 `WAITING_CONFIRMATION`。
### dry-run 与真实提交协议
`POST /api/v1/tasks/{id}/order-dry-runs/start` 创建或重放一次演练记录。desk 端随后只允许
进入订单确认页、读取非敏感摘要和验证提交控件唯一,不允许点击。完成后调用
`POST /api/v1/order-dry-runs/{rid}/ready`:
```json
{
"command_id": "…",
"verified_unit_price": "32.50",
"quantity_read": 2,
"confirm_page_amount": "65.00",
"has_address": true,
"evidence_sha256": "…"
}
```
- dry-run 只证明当次页面达到 `READY`,不冻结授权,也不能作为稍后真实点击时的页面事实。
- `has_address` 只报布尔值,**不得回传地址原文或手机号**。
真实第二趟重新通过三道闸门后,desk 端在点击前调用
`POST /api/v1/tasks/{id}/order-submissions/start`:
```json
{
"submission_key": "<幂等键>",
"command_id": "…",
"dry_run_id": "…",
"expected_task_version": 5,
"verified_unit_price": "32.50",
"quantity_read": 2,
"confirm_page_amount": "65.00"
}
```
- 服务端在一个事务中校验命令、任务版本、授权未消费、闸门值与唯一性,创建或重放唯一
`order_submission` 并把授权置为 `FENCED`。同一授权或命令不得产生第二条提交记录。
- 只有明确收到 `201/200` 且响应中的 `click_permitted: true`,desk 端才允许点击一次。
超时、网络错误、冲突或响应无法解析时**不得点击**,转人工查询该幂等键。
- `dry_run_id` 只证明曾完成安全演练;服务端仍以本次真实提交请求携带的闸门读数复核。
点击后调用 `POST /api/v1/order-submissions/{sid}/reconcile`:
```json
{
"outcome": "SUBMITTED",
"evidence_sha256": "…"
}
```
- `outcome` 枚举:`SUBMITTED`(明确看到订单结果)、`UNCERTAIN`(超时或无法判断)、
`HANDED_OFF`(外部支付)、`SECURITY_CHECK`。
- `SUBMITTED` 转 `WAITING_PAYMENT`;其余一律转 `RECONCILIATION_REQUIRED`,授权保持已围栏并
预留金额额度。重复调用只重放同一调和结果。
- 任一结果都**禁止再次点击、释放围栏或重新签发授权**。无法自动调和时调用
`/manual-review` 记录人工核查请求与证据。
### 文本字段校验
所有自由文本字段(事件消息、失败原因、备注):
- UTF-8,有长度上限(事件消息 1000 字节,备注 500 字节)。
- 拒绝含 `authorization:`、`api_key`、`bearer ` 的内容,防止凭据误入审计日志。
- **超长必须由客户端截断后再发,服务端拒绝而不是静默截断。**
## 三、desk 端本地模块合约
### `TaskSource` / `ResultSink`
执行器只依赖抽象,不认识来源:
```python
class TaskSource(ABC):
@abstractmethod
def load_tasks(self) -> list[OrderTask]: ...
class ResultSink(ABC):
@abstractmethod
def save_task_result(self, task: OrderTask) -> None: ...
```
实现:
| 实现 | 用途 |
| --- | --- |
| `HttpTaskSource` | 从 web 端领取任务(默认) |
| `HttpResultSink` | 回传结果到 web 端(默认) |
| `FixtureTaskSource` | 仅测试 / 演示:读取仓库内假数据,不接触真实订单 |
| `JsonlResultSink` | 仅测试 / 断连暂存:本地追加写入,恢复连接后按幂等键补传 |
### 真机流程模块
`desk/src/android/pdd_flow.py` 的公开入口,每个都不得越界:
| 函数 | 输入 | 输出 | 副作用边界 |
| --- | --- | --- | --- |
| `open_product(url)` | 商品 URL | 页面快照路径 | 只打开页面,不点击购买 |
| `open_sku_panel()` | - | 面板快照 | 只点规格入口,不提交 |
| `select_sku_options(items)` | `{维度: 值}` | 选中证据 | 按维度精确匹配,找不到抛错 |
| `set_quantity(n)` | 数量 | 读回值 | 必须复核等于 n |
| `read_sku_unit_price(xml)` | 规格面板 XML | 单价或 `None` | **闸门一**;读不到返回 `None`,不猜 |
| `leave_product()` | - | - | 第一趟结束时退出并释放手机 |
| `go_to_order_confirm()` | - | 确认页摘要 | **可能创建订单**,需显式授权 |
| `read_order_confirm_info(xml)` | 页面 XML | 非敏感摘要 | **闸门三**;不提取地址原文、手机号 |
| `submit_order(auth, submission)` | 授权 + 已建立的提交围栏 | 提交结果 | **唯一创建真实订单入口**,四条件与围栏全通过后只点一次 |
两个不可逆入口:
- `go_to_order_confirm()` 必须校验授权存在;`submit_order()` 还必须校验授权已由服务端围栏
且 `submission` 与当前任务、命令、授权完全一致。
- **第一趟的代码路径不得引用这两个函数。** 必须有测试证明试选流程不可达它们。
- `search_by_image()` 属 B 路径,V2 再实现。
## 四、待实现时确认
- **规格面板上单价的节点位置与文本形态**(阻塞闸门一,由 T-103 真机取证确定)。
- 授权 `expires_at` 的默认时长。
- 定时轮询的默认间隔与连续失败停止阈值。
- 分页游标的编码方式。
- 设备凭据的有效期与轮换策略。
- 证据资产的保留期与清理策略。