339 lines
16 KiB
Markdown
339 lines
16 KiB
Markdown
# 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` 的默认时长。
|
||
- 定时轮询的默认间隔与连续失败停止阈值。
|
||
- 分页游标的编码方式。
|
||
- 设备凭据的有效期与轮换策略。
|
||
- 证据资产的保留期与清理策略。
|