docs(architecture): adopt authorized single-pass purchase

This commit is contained in:
QiuSW
2026-08-04 16:25:34 +08:00
parent 8ba9b231f4
commit cfd5440ac0
23 changed files with 1264 additions and 1835 deletions
+209 -235
View File
@@ -1,341 +1,315 @@
# API 合约
> 本文定义采购服务(`admin/`)对外的 HTTP 接口,以及采购工具(`client/`)本地模块的合约。
> **这是双端之间的唯一权威。** 实现前可细化,但不得在代码里另起一套不兼容接口。
>
> 本合约的设备心跳、任务领取、事件、证据、授权命令与 ack 结构参考了前序项目
> `cmroubao`;同时在本项目重新审计并补回 dry-run、提交前服务端围栏和点击后调和。
> 前序接口是设计依据,不是可直接照搬的运行事实。
> 本文是采购服务与采购工具之间的唯一线协议权威。页面判据和本地类名不是线协议。
> 接口形状参考前序项目的经验,但所有状态与安全语义在 cmbuyer 重新定义、测试和取证。
## 通用约定
- 传输:JSON over HTTP。MVP 局域网内运行,生产部署应加 HTTPS。
- 编码:UTF-8。
- 时间:RFC 3339,带时区,UTC 存储。
- 金额:**十进制字符串**(如 `"45.60"`),不用浮点数。
- 幂等:所有创建类接口接受幂等键,重复提交返回同一结果而不是第二笔。
- 生产前缀:`/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、完整节点树、地址、手机号或支付信息。
### 鉴权
### 身份
| 客户端 | 方式 | 说明 |
| 身份 | 凭据 | 能力 |
| --- | --- | --- |
| 管理 Web | Session Cookie + CSRF Token | 表单提交必须带 CSRF |
| 采购工具 | `Authorization: Bearer <device_token>` | 凭据绑定设备标识,可单独撤销 |
| ERP 对接 | `Authorization: Bearer <connector_token>` | 只能调用货运同步接口 |
| 管理员 | `HttpOnly; Secure; SameSite=Lax` 会话 cookie + CSRF | 建单、开始采购、查看内部证据、人工调和 |
| 设备 | `Authorization: Bearer <device-token>` + 设备 id | 心跳、领取、事件、截图、围栏与结果 |
| ERP(V2) | 独立凭据 | 只读来源同步,不访问采购结果 |
三种身份互不通用。设备凭据**不能**创建任务或签发授权;管理会话**不能**调用设备接口。
设备凭据不能建单或开始采购;管理会话不能调用设备接口。未认证统一返回 `401`,无权返回 `403`。
### 错误响应
```json
{
"error": {
"code": "invalid_argument",
"message": "数量必须是正整数",
"field": "quantity"
"code": "version_conflict",
"message": "任务已变化,请刷新后重选",
"retryable": false,
"request_id": "018f..."
}
}
```
错误码枚举:`invalid_argument`、`unauthenticated`、`permission_denied`、`not_found`、
`conflict`、`failed_precondition`、`internal`。
- 未登录访问受保护资源:`401`,管理页面重定向到 `/login`。
- 已登录但无权限:`403`。**不存在**与**无权限**必须使用不同内部原因,但响应体不得泄露
任务内容。
`retryable=true` 只表示接口调用可以按同一幂等键重放,不表示可以重试任何真机点击。
## 一、管理端接口
管理页面为服务端渲染,表单直接 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 简化收口) |
| `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` |
> Excel 导入(`/tasks/import`)与 ERP 货运(`/freight*`)已移出 MVP,见
> [需求](02-requirements.md)第三节后续迭代表。
### `POST /tasks`
### `POST /tasks/start-trials`
管理页面以带 CSRF 的表单提交结构化任务版本列表:
核心字段:
```json
{
"start_key": "<幂等键>",
"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": [
{ "id": "018f...", "expected_task_version": 1 },
{ "id": "0190...", "expected_task_version": 3 }
{"task_id": "018f-task-1", "expected_task_version": 1},
{"task_id": "018f-task-2", "expected_task_version": 1}
]
}
```
- 只接受当前状态为 `DRAFT` 的任务;该动作含义是允许采购工具领取第一趟试选,**不签发下单
授权、不建立提交围栏、不创建订单、不付款**。
- 服务端在一个事务内校验全部任务存在、属于当前管理范围、状态仍为 `DRAFT` 且版本匹配,
然后统一转 `PENDING` 并递增版本。任一项失败返回 `409 conflict`,整批不产生部分成功。
- `tasks` 为空或包含重复 id 返回 `400 invalid_argument`;重复 `start_key` 返回第一次的结果,
不重复推进版本。
- SSR 成功后 `303` 返回原任务列表查询地址;冲突时保留筛选条件,刷新表格并要求重新选择。
管理员按钮必须显示为“开始采购(只创建待付款订单)”。**点击本身就是授权**:允许采购工具按任务
锁定字段创建一笔待付款订单;不再等待试选后人工确认,也不授权付款。
### `POST /tasks/{id}/order-authorizations`
服务端在一个事务中:
1. 校验列表非空、无重复任务,所有任务均为 `DRAFT` 且版本一致;
2. 校验每条任务的 `goods_id`、规格、正整数数量和最高总价完整;
3. 为每条任务创建一次性 `order_authorization`,锁定任务版本、上述字段、管理员、时间和有效期;
4. 把所有任务转为 `PENDING` 并递增版本。
任一条失败则整批不变。相同 `start_key` + 相同任务集合重放同一批结果;集合或版本不同返回 409。
```json
{
"authorization_key": "<幂等键>",
"expected_task_version": 3,
"spec_trial_id": "018f...",
"note": ""
"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
}
```
- **授权内容不由客户端提交。** `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`。
### 围栏前重置与围栏后调和
## 二、设备侧接口(采购工具调用)
- `reset-to-draft` 必须同时校验任务版本、授权 id 与“尚无 `order_submission`”。关闭旧授权后回到
`DRAFT`;重新开始必须产生新版本与新授权。
- 一旦存在 `order_submission`,重置、取消、授权过期和重新开始都返回 `409 submission_fenced`。
- `reconcile` 只能处理指定 `sid`:人工记录“已创建待付款订单”或“确认未创建/无法完成”。它不能
触发设备点击、释放围栏或签发新授权。
全部要求有效设备 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` | 提交结构化失败与证据 |
| `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` | 点击后一次性上报观察结果;只调和不重试 |
> `/candidates`(多候选回传)、`/reference-image`(图搜参考图)、`/order-record`
> (订单自动核对)随 B 路径与 F-016 一并推迟到 V2。
### `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`
```json
{ "device_id": "desk-01", "claim_key": "<幂等键>" }
```
成功:
请求携带 `device_id`、`session_id`、`claim_request_id`。领取与授权绑定且具租约:
```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",
"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": "80.00"
"max_total_price": "30.00"
},
"claim_token": "...",
"claim_generation": 1,
"lease_expires_at": "2026-08-03T10:30:00Z"
"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"
}
}
```
- **`leg` 决定这一趟做什么**:`"TRIAL"` = 第一趟试选,`"ORDER"` = 第二趟下单。
`leg` 为 `"ORDER"` 时只返回 `authorization_id`;完整、不可变的授权命令必须通过
`/commands/next` 拉取并落盘,再调用 `/commands/{cid}/ack`。领取接口不重复定义授权载荷。
- 无可领任务返回 `200` 且 `task` 为 `null`,**不是 404**。
- 后续所有该任务的调用必须携带 `X-Claim-Token` 与匹配的 `claim_generation`。
- 只返回有 `ACTIVE` 授权的 `PENDING`;服务端在一个事务中转为 `CLAIMED` 并创建 attempt。
- 同一 `claim_request_id` 同载荷重放同一结果;并发设备只有一个成功。
- 一个设备有未结束领取时优先重放该领取,不能悄悄领第二条。
- 响应不得包含自由动作脚本、CSS/XPath、通用坐标或支付能力。
### `POST /api/v1/tasks/{id}/evidence`(内部原始截图)
### 事件与证据
> 本接口属于生产第一趟证据链,由 T-204 实现。T-103 只做本机真机可行性验证,不调用本接口,
> 也不为上传目的继续扩展截图遮罩器。正式流程直接上传原始截图;T-103 与 T-204 的区分只是任务拆分。
事件只包含固定 `step` / `outcome` / `reason_code` 和非敏感摘要。禁止把完整 XML、地址、手机号、
页面全文或 token 塞进日志字段。
这是内部系统。规格面板和订单确认页固定展示的地址与手机号允许保留在原始截图中。本接口只接受截图
文件及如下审计元数据;完整 XML、manifest、本机路径和目录中的其他文件一律不接收:
截图接口使用 `multipart/form-data`,只接受单个显式文件及以下元数据:
```json
{
"kind": "SKU_PANEL_SCREENSHOT",
"attempt_id": "018f-attempt",
"kind": "SKU_PANEL_GATE_1",
"privacy_tier": "INTERNAL_RAW",
"artifact_sha256": "<原始截图 SHA-256>",
"pdd_version": "8.17.0"
"sha256": "64-lowercase-hex",
"captured_at": "2026-08-04T09:01:00Z"
}
```
- `privacy_tier` 必须精确为 `INTERNAL_RAW`,明确告知服务端该资产可能包含页面个人信息。
- 上传截图的 SHA-256 必须等于 `artifact_sha256`。服务端不接收截图以外的原始文件、完整 XML、
本机路径、源 manifest 或地址/手机号的结构化字段。支付凭据和外部支付页截图始终拒绝。
- 原始目录不得被 `HttpResultSink` 或证据上传器枚举;调用方必须显式传入单个截图文件。
- 完整 XML 永不上传。最小 XML 只用于采购工具离线 fixture;fixture 不得包含地址、手机号或支付凭据。
- 资产不得放在公开静态目录;读取要求管理会话,响应使用 `Cache-Control: no-store`。
- 允许规格面板和确认页截图保留页面已显示的地址/手机号;不要求遮罩或裁剪。
- 不接受 XML、目录、manifest、本机绝对路径、外部支付页截图或支付凭据。
- MIME、尺寸、字节数和 SHA-256 必须校验;资产只经管理员鉴权端点读取。
### `POST /api/v1/tasks/{id}/spec-trial`(第一趟回传)
### `POST /api/v1/purchase-attempts/{aid}/submission-fence`
客户端只有在当前页面四条件中的后三项已经满足后才能调用:
```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=INTERNAL_RAW`。
- 服务端接收后创建 `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",
"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": "65.00",
"has_address": true,
"evidence_sha256": "…"
"confirm_page_amount": "25.76",
"submit_control_match_count": 1
}
```
- dry-run 只证明当次页面达到 `READY`,不冻结授权,也不能作为稍后真实点击时的页面事实。
- `has_address` 只报布尔值,**不得回传地址原文或手机号**。
服务端在一个事务中校验:任务/版本/claim/attempt 一致;授权有效未消费且字段等于任务快照;
规格与授权相等;数量相等;两个单价相等;计算金额及确认页金额均不超过 `total_price_cap`;提交控件
计数为一;此前不存在该授权或 attempt 的 submission。随后创建唯一 `order_submission`,授权转
`FENCED`,任务保持不可重领。
真实第二趟重新通过三道闸门后,采购工具在点击前调用
`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"
"submission_id": "018f-submission",
"status": "FENCED",
"click_permitted": true,
"submit_text": "提交订单"
}
```
- 服务端在一个事务中校验命令、任务版本、授权未消费、闸门值与唯一性,创建或重放唯一
`order_submission` 并把授权置为 `FENCED`。同一授权或命令不得产生第二条提交记录。
- 只有明确收到 `201/200` 且响应中的 `click_permitted: true`,采购工具才允许点击一次。
超时、网络错误、冲突或响应无法解析时**不得点击**,转人工查询该幂等键。
- `dry_run_id` 只证明曾完成安全演练;服务端仍以本次真实提交请求携带的闸门读数复核。
- 任一校验失败返回错误,绝不返回 `click_permitted=true`。
- 同一 `fence_key` 的重放返回同一 `submission_id`,但 `click_permitted=false` 且
`reconciliation_required=true`;客户端不能凭重放响应点击。
- 客户端收到首次许可后,必须先把“围栏已取得/即将发出唯一点击”持久化,再执行点击。进程崩溃或
本地状态不明时宁可转调和,也不再次点击。
点击后调用 `POST /api/v1/order-submissions/{sid}/reconcile`:
### `POST /api/v1/order-submissions/{sid}/result`
```json
{
"outcome": "SUBMITTED",
"evidence_sha256": "…"
"result_key": "018f-result",
"attempt_id": "018f-attempt",
"observation": "SUBMITTED",
"evidence_asset_id": "018f-asset"
}
```
- `outcome` 枚举:`SUBMITTED`(明确看到订单结果)、`UNCERTAIN`(超时或无法判断)、
`HANDED_OFF`(外部支付)、`SECURITY_CHECK`。
- `SUBMITTED` 转 `WAITING_PAYMENT`;其余一律转 `RECONCILIATION_REQUIRED`,授权保持已围栏并
预留金额额度。重复调用只重放同一调和结果。
- 任一结果都**禁止再次点击、释放围栏或重新签发授权**。无法自动调和时调用
`/manual-review` 记录人工核查请求与证据。
`observation` 只允许:
### 文本字段校验
- `SUBMITTED`:明确订单已创建,转 `WAITING_PAYMENT`;
- `EXTERNAL_PAYMENT_HANDOFF`:已跳外部支付,停止并转 `RECONCILIATION_REQUIRED`;
- `SECURITY_CHALLENGE`:出现安全校验,停止并转调和;
- `UNKNOWN`:超时、断连或页面不明,转调和。
所有自由文本字段(事件消息、失败原因、备注):
提交后没有“retry”观察值。任何结果都不能释放围栏或开放第二次点击。
- UTF-8,有长度上限(事件消息 1000 字节,备注 500 字节)。
- 拒绝含 `authorization:`、`api_key`、`bearer ` 的内容,防止凭据误入审计日志。
- **超长必须由客户端截断后再发,服务端拒绝而不是静默截断。**
### 文本和金额校验
- 规格字段:Unicode 规范化后精确相等;不得包含、前缀、编辑距离或 AI 猜测。
- `goods_id`:仅 ASCII 十进制数字,canonical URL 中唯一。
- 金额:`0.01` 到系统配置上限,至多两位小数;规范化后再比较和持久化。
- 数量:正整数,服务端与设备均设置合理上限;不能从字符串静默截断。
## 三、采购工具本地模块合约
### `TaskSource` / `ResultSink`
执行器只依赖抽象,不认识来源:
```python
class TaskSource(ABC):
@abstractmethod
def load_tasks(self) -> list[OrderTask]: ...
class TaskSource(Protocol):
def claim_next(self, session: Session) -> ClaimedPurchase | None: ...
def renew_lease(self, claim: Claim) -> Lease: ...
class ResultSink(ABC):
@abstractmethod
def save_task_result(self, task: OrderTask) -> None: ...
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 一个许可。
| 实现 | 用途 |
| --- | --- |
| `HttpTaskSource` | 从采购服务领取任务(默认) |
| `HttpResultSink` | 回传结果到采购服务(默认) |
| `FixtureTaskSource` | 仅测试 / 演示:读取仓库内假数据,不接触真实订单 |
| `JsonlResultSink` | 仅测试 / 断连暂存:本地追加写入,恢复连接后按幂等键补传 |
### 真机能力分层
### 真机流程模块
| 能力 | 输入 | 输出 | 安全边界 |
| --- | --- | --- | --- |
| `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` | 观察结果 | 许可、闸门、唯一控件全校验;点前持久化;绝不重试 |
`client/src/android/pdd_flow.py` 的公开入口按 capability 分离,每个都不得越界:
T-103 只实现隔离的 `SkuSelectionFlow`:前四项加安全退出。它的模块和静态依赖不得引用数量、确认页、
围栏、提交或支付能力。后续任务按取证顺序组合成生产 `SinglePassPurchaseFlow`。
| 函数 | 可用趟次 | 输入 | 输出 | 副作用边界 |
| --- | --- | --- | --- | --- |
| `open_product(url)` | TRIAL / ORDER | 商品 URL | 页面快照路径 | 只打开页面,不点击控件 |
| `open_trial_sku_panel(evidence_key)` | **仅 TRIAL** | 版本与证据绑定键 | 面板快照 | 只点击精确唯一、已取证的受控入口;当前仅 `快要抢光`,无通用 click |
| `select_sku_options(items)` | TRIAL / ORDER | `{维度: 值}` | 选中证据 | 按维度精确匹配,找不到抛错 |
| `capture_evidence(screenshot)` | TRIAL / ORDER | 单个原始截图路径 | 截图 SHA-256 | 只处理显式截图文件;不得枚举目录或读取/上传 XML,T-204 接入内部证据 API |
| `read_sku_unit_price(xml)` | TRIAL / ORDER | 当前规格面板 XML 或安全最小 fixture | 单价或 `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` 的默认时长。
- 定时轮询的默认间隔与连续失败停止阈值。
- 分页游标的编码方式。
- 设备凭据的有效期与轮换策略。
- 证据资产的保留期与清理策略。
- 授权有效期、领取租约时长、心跳/轮询间隔和连续失败停止阈值;
- 截图大小上限和内部保留期限;
- 可配置单任务数量与最高总价系统上限;
- 首次真实提交真机任务的人工授权和待付款订单处置步骤。