Files
cmbuyer/docs/api.md
T

339 lines
16 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 合约
> 本文定义采购服务(`admin/`)对外的 HTTP 接口,以及采购工具(`client/`)本地模块的合约。
> **这是双端之间的唯一权威。** 实现前可细化,但不得在代码里另起一套不兼容接口。
>
> 本合约的设备心跳、任务领取、事件、证据、授权命令与 ack 结构参考了前序项目
> `cmroubao`;同时在本项目重新审计并补回 dry-run、提交前服务端围栏和点击后调和。
> 前序接口是设计依据,不是可直接照搬的运行事实。
## 通用约定
- 传输:JSON over HTTP。MVP 局域网内运行,生产部署应加 HTTPS。
- 编码:UTF-8。
- 时间:RFC 3339,带时区,UTC 存储。
- 金额:**十进制字符串**(如 `"45.60"`),不用浮点数。
- 幂等:所有创建类接口接受幂等键,重复提交返回同一结果而不是第二笔。
### 鉴权
| 客户端 | 方式 | 说明 |
| --- | --- | --- |
| 管理 Web | Session Cookie + CSRF Token | 表单提交必须带 CSRF |
| 采购工具 | `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` | 手工建单,初始状态为 `DRAFT`(F-001) |
| `POST` | `/tasks/start-trials` | 批量把 `DRAFT` 原子转为 `PENDING`,开始第一趟试选(F-018) |
| `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/start-trials`
管理页面以带 CSRF 的表单提交结构化任务版本列表:
```json
{
"start_key": "<幂等键>",
"tasks": [
{ "id": "018f...", "expected_task_version": 1 },
{ "id": "0190...", "expected_task_version": 3 }
]
}
```
- 只接受当前状态为 `DRAFT` 的任务;该动作含义是允许采购工具领取第一趟试选,**不签发下单
授权、不建立提交围栏、不创建订单、不付款**。
- 服务端在一个事务内校验全部任务存在、属于当前管理范围、状态仍为 `DRAFT` 且版本匹配,
然后统一转 `PENDING` 并递增版本。任一项失败返回 `409 conflict`,整批不产生部分成功。
- `tasks` 为空或包含重复 id 返回 `400 invalid_argument`;重复 `start_key` 返回第一次的结果,
不重复推进版本。
- SSR 成功后 `303` 返回原任务列表查询地址;冲突时保留筛选条件,刷新表格并要求重新选择。
### `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`。
## 二、设备侧接口(采购工具调用)
全部要求有效设备 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}/evidence`(只接收脱敏派生物)
规格面板和订单确认页可能固定展示地址与掩码手机号。采购工具必须先在本机隔离目录保存原始证据,
再由确定性脱敏器生成派生 screenshot/XML;本接口只接受派生截图及如下审计元数据:
```json
{
"kind": "SKU_PANEL_SCREENSHOT",
"privacy_tier": "SANITIZED",
"artifact_sha256": "<派生截图 SHA-256>",
"sanitizer_version": "sku-panel-pdd-8.17.0-v1"
}
```
- `privacy_tier` 必须精确为 `SANITIZED`;缺失、其他值或 sanitizer 元数据不完整均拒绝。
- 上传内容的 SHA-256 必须等于 `artifact_sha256`。服务端不接收原始文件、原始哈希、原始路径、
原始 XML、地址、手机号或支付凭据;原始/派生哈希映射只存在于采购工具本机 manifest。
- 原始目录不得被 `HttpResultSink` 或证据上传器枚举;调用方必须显式传入已原子发布的派生目录。
- 完整 XML 永不上传。经自动复检的最小脱敏 XML 只用于采购工具离线 fixture;仍命中手机号模式或
脱敏结果不确定时,客户端调用 `/needs-manual`,不得上传截图或继续试选。
### `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` 上传,且对应资产必须为 `privacy_tier=SANITIZED`。
- 服务端接收后创建 `spec_trials` 记录,任务转 `WAITING_CONFIRMATION`。
### dry-run 与真实提交协议
`POST /api/v1/tasks/{id}/order-dry-runs/start` 创建或重放一次演练记录。采购工具随后只允许
进入订单确认页、读取非敏感摘要和验证提交控件唯一,不允许点击。完成后调用
`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` 只报布尔值,**不得回传地址原文或手机号**。
真实第二趟重新通过三道闸门后,采购工具在点击前调用
`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`,采购工具才允许点击一次。
超时、网络错误、冲突或响应无法解析时**不得点击**,转人工查询该幂等键。
- `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 ` 的内容,防止凭据误入审计日志。
- **超长必须由客户端截断后再发,服务端拒绝而不是静默截断。**
## 三、采购工具本地模块合约
### `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` | 从采购服务领取任务(默认) |
| `HttpResultSink` | 回传结果到采购服务(默认) |
| `FixtureTaskSource` | 仅测试 / 演示:读取仓库内假数据,不接触真实订单 |
| `JsonlResultSink` | 仅测试 / 断连暂存:本地追加写入,恢复连接后按幂等键补传 |
### 真机流程模块
`client/src/android/pdd_flow.py` 的公开入口按 capability 分离,每个都不得越界:
| 函数 | 可用趟次 | 输入 | 输出 | 副作用边界 |
| --- | --- | --- | --- | --- |
| `open_product(url)` | TRIAL / ORDER | 商品 URL | 页面快照路径 | 只打开页面,不点击控件 |
| `open_trial_sku_panel(evidence_key)` | **仅 TRIAL** | 版本与证据绑定键 | 面板快照 | 只点击精确唯一、已取证的受控入口;当前仅 `快要抢光`,无通用 click |
| `select_sku_options(items)` | TRIAL / ORDER | `{维度: 值}` | 选中证据 | 按维度精确匹配,找不到抛错 |
| `sanitize_evidence(raw_manifest)` | TRIAL / ORDER | 本机隔离目录 manifest | 派生 manifest | 原子发布脱敏派生物;失败不发布,原始内容不进入日志/上传 |
| `read_sku_unit_price(xml)` | TRIAL / ORDER | 脱敏规格面板 XML | 单价或 `None` | **闸门一 / 二**;读不到返回 `None`,不猜 |
| `leave_product()` | TRIAL / ORDER | - | - | 第一趟结束时退出并释放手机 |
| `set_quantity(n)` | **仅 ORDER** | 数量 | 读回值 | 必须复核等于 n;TRIAL capability 不暴露 |
| `go_to_order_confirm()` | **仅 ORDER** | - | 确认页摘要 | 需显式授权;TRIAL capability 不暴露 |
| `read_order_confirm_info(xml)` | **仅 ORDER** | 脱敏页面 XML | 非敏感摘要 | **闸门三**;不提取地址原文、手机号 |
| `submit_order(auth, submission)` | **仅 ORDER** | 授权 + 已建立的提交围栏 | 提交结果 | **唯一创建真实订单入口**,四条件与围栏全通过后只点一次 |
能力隔离规则:
- `go_to_order_confirm()` 必须校验授权存在;`submit_order()` 还必须校验授权已由服务端围栏
且 `submission` 与当前任务、命令、授权完全一致。
- 第一趟只能拿到 `TrialSkuFlow` 窄接口,接口中不得出现通用 `click`、`set_quantity()`、
`go_to_order_confirm()`、`submit_order()` 或支付能力;静态依赖测试必须证明试选流程不可达它们。
- `open_trial_sku_panel()` 的点击是唯一批准的购买语义控件例外,只用于打开已取证规格面板;入口
缺失/重复、App 版本不符、面板判据不唯一或出现未知终态控件时停止。其他入口文案不得推断复用。
- `search_by_image()` 属 B 路径,V2 再实现。
## 四、待实现时确认
- **规格面板上单价的节点位置与文本形态**(阻塞闸门一,由 T-103 真机取证确定)。
- 授权 `expires_at` 的默认时长。
- 定时轮询的默认间隔与连续失败停止阈值。
- 分页游标的编码方式。
- 设备凭据的有效期与轮换策略。
- 证据资产的保留期与清理策略。