Files
cmroubao/docs/api.md
T

982 lines
41 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 合约
> MVP 使用 `/api/v1`、UTF-8 JSON over HTTPS。文件上传使用 `multipart/form-data`。
> Web 页面可以直接调用同一 usecase,但不能形成不同的业务规则。
## 通用约定
- 所有 ID 为 UUID 字符串。
- 时间为 UTC ISO 8601,例如 `2026-07-25T08:30:00Z`。
- 金额使用十进制定点字符串,例如 `"199.00"`,币种固定 `CNY`;不使用浮点数。
- 写接口在合约标注处接收 `Idempotency-Key` 请求头。
- App 的任务执行接口同时需要用户 Bearer Token、设备身份和 `X-Claim-Token`。
- 分页使用 `limit` 和不透明 `cursor`;MVP `limit` 最大 100。
- 客户端不得根据 HTTP 超时判断操作失败,必须查询资源最终状态。
管理 Web/API 使用 ADMIN 服务端会话;App 执行接口使用 BUYER + 设备 Bearer token。
两种身份不能互换。HTTP 明文只允许 loopback 开发监听,非 loopback 服务必须配置
certificate/private key 并直接启用 TLS。
通用错误:
```json
{
"error": {
"code": "TASK_STATE_CONFLICT",
"message": "任务当前状态不允许此操作",
"retryable": false,
"details": {}
},
"request_id": "f2cf02e9-57e0-4fca-817b-c85228acfc81"
}
```
HTTP 语义:
- `400` 请求格式或业务校验失败
- `401` 未建立有效身份
- `403` 身份有效但无权限、设备禁用或 claim 不属于当前设备
- `404` 资源不存在,外部响应不泄露其他人的资源存在性
- `409` 幂等冲突、状态冲突或设备已有活跃任务
- `413` 文件过大
- `415` 不支持的媒体类型
- `422` 结构可解析但字段不满足 schema
- `429` 频率限制
- `503` 依赖暂不可用
## 服务健康
### `GET /healthz`
不需要业务身份,只检查进程和 SQLite 连接,不返回版本、路径、DSN、连接池统计或内部
错误。响应带 `Cache-Control: no-store`,默认不返回 CORS header。
数据库可用:
```json
{"status":"ok"}
```
返回 `200`。数据库不可用时返回 `503`:
```json
{"status":"unavailable"}
```
健康检查失败不能终止进程;未知路由和不允许的方法分别使用稳定 `404`/`405` JSON。
## 认证
### 管理 Web 会话
`POST /login` 接受表单账号密码,成功后设置 `HttpOnly`、`Secure`、`SameSite=Lax`
会话 Cookie。loopback HTTP 开发时不设置 `Secure`,非 loopback 服务必须直接启用
TLS。管理会话固定 8 小时绝对有效期;`POST /logout` 撤销服务端会话并清除 Cookie。
Web 会话不能调用设备执行接口。
未登录页面请求以 `303` 跳转 `/login?next=...`;`next` 只允许 `/tasks` 及其本站
子路径。未授权管理 API 返回 `401 ADMIN_SESSION_REQUIRED`。Cookie 认证的管理 API
写请求除原 Content-Type/幂等要求外,还必须携带与 CSRF Cookie 匹配的
`X-CSRF-Token`。
`POST /login` 在通过表单和 CSRF 校验、执行 bcrypt 前,按服务端观察到的来源地址
限流。5 分钟内最多 10 次;成功登录清零。超限返回 `429`、`Retry-After` 和通用
中文提示,不暴露账号是否存在。
### `POST /api/v1/auth/token`
采购 App 建立用户和设备联合会话。
```json
{
"username": "buyer01",
"password": "local-input-only",
"device_id": "f0c3d438-1c44-4f80-b898-c645afe7eaa8",
"device_token": "local-input-only",
"app_version": "0.1.0",
"android_version": "待设备填写"
}
```
成功:
```json
{
"access_token": "opaque-token-returned-once",
"token_type": "Bearer",
"expires_in": 3600,
"user": {
"id": "d950db90-cf58-4f21-86fd-067e1a91a3a6",
"username": "buyer01",
"role": "BUYER"
},
"device": {
"id": "f0c3d438-1c44-4f80-b898-c645afe7eaa8",
"enabled": true
}
}
```
密码和设备 token 不得出现在响应、日志或 execution event 中。
T-204 固定使用 256 bit 随机 opaque access token,数据库只保存 SHA-256,固定
1 小时过期。用户必须为有效 `BUYER`,设备必须预授权且启用;未绑定设备在首次成功
登录时原子绑定当前采购员,已绑定其他采购员时拒绝。T-204 不提供设备自助登记、
refresh 或 App logout;Android 安全存储接入属于 T-206。
App token 登录与管理登录使用独立限流 scope,同样为每个来源地址 5 分钟最多 10 次。
超限返回 `429`、`Retry-After` 和稳定错误码 `AUTH_RATE_LIMITED`,`retryable=true`。
## 资产
### `POST /api/v1/assets`
管理会话上传任务参考图片;App 也可用设备会话上传执行截图。请求必须带
`Idempotency-Key`。
表单字段:
- `file`:必填,MVP 允许可解码 JPEG/PNG/WebP;请求最多 20 MiB、最长边最多
10000 px、总像素最多 25 MP。
- `purpose`:`TASK_REFERENCE` 或 `EXECUTION_EVIDENCE`。
- `task_id`:证据图片必填;参考图创建时为空。
成功:
```json
{
"id": "2bbc1bf2-30f7-497e-a52b-bcb4c61d57c3",
"media_type": "image/jpeg",
"size_bytes": 183245,
"sha256": "hex-value",
"created_at": "2026-07-25T08:30:00Z"
}
```
T-203 成功返回 `201`。使用相同 `Idempotency-Key` 和相同图片内容重试时返回同一
资产;同 key 不同内容返回 `409`。
`TASK_REFERENCE` 在后端统一白底合成、缩放到最长边不超过 2048 px,并以质量 90
编码为匿名 JPEG。响应的 `media_type`、`size_bytes` 和 `sha256` 都描述规范化结果,
不描述原始上传文件。T-203 尚不接受 `EXECUTION_EVIDENCE`。
### `GET /api/v1/assets/{asset_id}/content`
受鉴权的文件流。只能读取用户有权查看的任务资产;不返回服务端文件路径。
## Go ERP 会话与货运信息
顺运宝仅由 Go API 进程直连;浏览器不复用 ERP 凭证,也不接收 Cookie、JWT、用户资料或
原始响应。货运同步只接收以下最小化规范结构:
```json
{
"schema_version": 1,
"query": {"mode":"ORDER_NUMBER"},
"orders": [{
"external_stock_id": "99001122",
"source_code": "masked-at-log-boundary",
"platform_order_no": "optional",
"shop_name": "来源店铺",
"source_created_at": "2026-07-28T08:00:00+08:00",
"order_status": "0",
"purchase_status": "0",
"is_canceled": false,
"items": [{
"external_item_id": "880011",
"title": "商品标题",
"product_spec": "灰色,2XL",
"sku": "灰色,2XL",
"quantity": 2,
"product_thumb_ref": "190000000",
"original_unit_price_minor": 12950,
"original_currency": "TWD",
"purchase_status": "0"
}]
}]
}
```
不得返回 receiver、receiverTel、receiverAddr、Cookie、JWT、ERP 用户资料或完整原始
对象。多商品必须全部保留;缺失详情返回协议错误,不允许部分成功。
商品 `product_spec` 和采购使用的 `sku` 均来自 ERP `productSpec`,不得静默回退
`sku/variationSku`。`productPrice` 严格转换为 `original_unit_price_minor`;缺失价格为
`null`,币种固定为 `TWD`。`product_thumb_ref` 来自商品 `productThumb`,存在时必须是
规范化正整数,不能使用货运单 ID、商品明细 ID 或完整 URL。
货运详情中的每个商品还返回:
```json
{
"image_status": "READY",
"image_error_code": null,
"image_url": "/api/v1/freight-items/{item_id}/image"
}
```
`image_status` 为 `NONE/PENDING/READY/MISSING/FAILED`。只有 `READY` 返回同源
`image_url`;其他状态返回 `null`,失败时 `image_error_code` 是不包含上游正文的稳定错误码。
元数据同步先提交,图片在剩余请求预算内 best-effort 缓存,因此图片失败不改变货运同步的
`SUCCEEDED` 终态。后续新同步会重试非 `READY` 图片,相同引用的 `READY` 图片不会重复下载。
### `GET /api/v1/freight-items/{item_id}/image`
Admin 会话鉴权的本地货运商品图片流。仅当商品仍在当前货运版本中、当前 `productThumb`
与已缓存引用一致且状态为 `READY` 时返回 `200 image/jpeg`;不存在、未完成、引用已变化或
无权访问统一返回 `404`。响应包含 `Content-Length`、ETag、
`X-Content-Type-Options: nosniff` 和 `Cache-Control: private, no-store`,不返回 ERP URL、
查询参数或服务端文件路径。
后端只使用配置的 `CMROUBAO_SHUNYUNBAO_URL` origin 和固定
`/api/p/file?id=<productThumb>` 请求图片,复用 ERP Cookie jar 并禁止重定向。下载内容经过
既有大小、尺寸、像素和解码校验,统一白底规范化为最长边不超过 2048 px 的 JPEG。
### Go ERP 会话(T-230、T-236)
`/erp`、`/erp/captcha`、`/erp/login` 和 `/api/v1/erp-session*` 不再暴露。创建货运同步
前,单一 API 进程在未认证时以同一 Cookie jar 获取验证码、调用受控 OCR、登录并校验用户。
验证码图片、OCR 文字、Cookie、JWT、账号、密码和原始响应都不返回浏览器/API,也不写 SQLite。
登录成功后从 `data.user` 严格读取 id/username,并请求
`GET /am/user/get?id=<user.id>`;返回身份必须一致。只有 HTTP 401/403 或 ERP code `-2` 被视为
会话失效,其他 `status=false` 返回 `ERP_RESPONSE_INVALID`。
ERP 登录明确返回 `status=false`、`msg=图片验证码不正确` 时,首次失败后最多额外重试 6 次,
单次认证最多登录 7 次;每次都重新获取验证码图片并重新调用 OCR。认证总预算为 20 秒,调用方
deadline 更短时以调用方为准。账号密码错误、其他登录拒绝、OCR 异常、网络错误和协议错误均不
重试;次数耗尽返回 `ERP_LOGIN_REJECTED`。
ERP 配置来源:
- `CMROUBAO_SHUNYUNBAO_URL`:默认 `https://www.shunyunbaoerp.com`;只接受无路径、
userinfo、query 或 fragment 的 HTTPS origin。
- `CMROUBAO_SHUNYUNBAO_USERNAME` 与 `CMROUBAO_SHUNYUNBAO_PASSWORD`:必须同时设置;
为空时连接页明确显示未配置,服务进程仍可用于其他本地功能。
- `cmd/api` 只把标准工作目录 `backend-api/.env` 中的上述三个值作为回退。系统环境变量
优先;文件缺失不报错。该解析器不修改全局环境,不加载数据库、HTTP、TLS 或 authctl
密码,也不支持变量展开、命令执行或任意路径。
- `CMROUBAO_OCR_API_URL` 为可选 OCR `POST` endpoint;只允许 HTTPS,或本机 loopback HTTP,
不允许 userinfo、query、fragment 或重定向。请求使用 `multipart/form-data` 的 `file` 字段;
OCR 不可达、超时、非成功、过大或响应无有效文本时,货运创建返回 `503 OCR_SERVICE_INVALID`
且不会创建同步记录。预检的稳定错误还包括 `422 ERP_NOT_CONFIGURED`、
`422 ERP_LOGIN_REJECTED`、`502 ERP_RESPONSE_INVALID` 和 `503 ERP_UNAVAILABLE`;SSR 导入页
用相同 code 显示可关闭弹窗并保留表单。所有 message 都是固定匿名文本,不含 ERP/OCR 原始
响应、验证码、Cookie、账号或密码。
- `CMROUBAO_ERP_DEBUG_LOG` 默认 `false`,仅接受 `true` 或 `false`。临时设为 `true` 后,
API 启动窗口记录 ERP request 的 method/path,以及 response 的 status、content type、长度和
最多 4 KiB 的递归脱敏 JSON 摘要;不记录请求 body、完整 URL query、header、Cookie、验证码、
OCR 文本、账号、密码、token、订单标识、收件信息、非 JSON body 或图片。诊断完成后必须设回
`false` 并重启 API。Windows 可使用根目录 `start-backend.bat --erp-debug` 仅为本次 API
进程覆盖开启;也可与 `--migrate` 组合。该模式额外在 ERP 登录前输出单行
`erp_ocr_result value="..." length=...`,值只限本次有效 OCR 结果;OCR 失败或无效时只输出
`class=failed` 或 `class=invalid`,不输出原文。每次登录另输出
`erp_login_attempt attempt=<n> max_attempts=7 result=<固定分类>`,不包含凭证或 ERP 原始响应。
会话不写 SQLite 或 Redis;服务重启后在下一次货运导入前重新经 OCR 建立会话。
### `POST /api/v1/freight-syncs`
ADMIN 创建货运同步记录,必须带 `Idempotency-Key`:
```json
{"mode":"ORDER_NUMBER","order_number":"完整单号"}
```
T-224 增加:
```json
{
"mode":"CREATED_RANGE",
"created_from":"2026-07-22",
"created_to":"2026-07-28"
}
```
手动范围最多 7 个自然日;也可以只提交
`{"mode":"CREATED_RANGE","sync_to_now":true}`。后者在没有水位时从当天开始,有水位时
从成功水位前回看 10 分钟对应的自然日开始,后端再按最多 7 天切窗。
`ORDER_NUMBER` 在同一请求内执行认证、ERP 查询、规范化和事务落库,总预算 55 秒。首次成功
返回 `201` 和最终 `SUCCEEDED` run,成功幂等重放返回 `200`,两者均设置
`Location: /api/v1/freight-orders`。同时只能执行一个完整单号同步,其他请求快速返回
`409 FREIGHT_SYNC_BUSY`;超时返回 `504 FREIGHT_SYNC_TIMEOUT`,并使用独立 cleanup context
把已创建 run 保存为 `FAILED`。Admin Web 成功后跳转 `/freight?notice=import-succeeded`,
此时列表已能读取货运头和全部商品明细。
`CREATED_RANGE` 和 `sync_to_now` 保持异步,响应 `202` 和 `PENDING` run,随后在后台执行。
订单号不进入 URL、事件 message 或访问日志;数据库只保存规范值及用于审计/检索的受控字段,
不保存 ERP 凭证、Cookie 或 JWT。HTTP Server `WriteTimeout` 为 70 秒,ERP 单次请求 timeout
仍为 30 秒。
### `GET /api/v1/freight-syncs/{sync_id}`
返回 `PENDING/RUNNING/SUCCEEDED/FAILED`、查询模式、匿名查询摘要、开始/结束时间、
订单/商品计数和稳定错误码。失败不返回 ERP 原始 body 或个人信息。
### `GET /api/v1/freight-sync-watermark`
返回当前 ADMIN creator 的顺运宝最近成功日期同步水位;尚未成功同步时返回
`{"watermark":null}`。只有完整批次落库成功才在同一事务推进水位;失败、空响应以外
的协议错误和较旧范围成功均不会覆盖较新的水位。
### `GET /api/v1/freight-orders`
返回当前 ADMIN creator 最近更新的货运单并支持 `limit`(1..100)。响应不包含
收件人字段或 ERP 原文;列表筛选和 cursor 后置,不属于本次日期同步闭环。
### `GET /api/v1/freight-orders/{id}`
T-222 返回货运头、全部当前商品明细和 revision/hash 状态。T-223 再增加采购需求和
已生成 task 引用。不存在和跨 creator 统一 404;响应 `Cache-Control: no-store`。
T-227 的 Go source 使用当前内存会话,按 `listTotal -> list 分页 -> listByStock` 查询;
完整单号由请求同步调用,日期范围由后台 worker 调用。每次先校验会话;列表最多 100 条、
每页 20 条,详情每批最多 100 个外部 stock ID。
顺运宝详情 `created` 已确认可能只有分钟精度;无时区的
`yyyy-MM-dd HH:mm[:ss]` 或 `yyyy-MM-ddTHH:mm[:ss]` 均严格按 Asia/Shanghai 解释并转 UTC,
RFC3339 保留其显式时区。其他未知时间格式继续返回 `ERP_RESPONSE_INVALID`。
未配置、未登录、找不到货运单、响应协议错误和暂时不可用分别落为
`ERP_NOT_CONFIGURED`、`ERP_SESSION_REQUIRED`、`ERP_FREIGHT_NOT_FOUND`、
`ERP_RESPONSE_INVALID` 和 `ERP_UNAVAILABLE`,不返回 ERP 原始错误 body。
### `POST /api/v1/freight-items/{item_id}/procurement-request`
T-223 创建或重放该商品当前 revision 的采购需求。ERP 数字状态不自动解释,ADMIN
必须显式确认仍需采购:
```json
{"confirm_procurement_needed":true}
```
请求快照保留 title、product spec、SKU、quantity、来源 hash/revision 和当时的取消/
采购状态。缺标题、SKU、正整数数量或来源明确取消时创建为 `BLOCKED`,不调用 VLM
补字段;字段合格但没有受控参考图时为 `NEEDS_IMAGE`。
### `PUT /api/v1/procurement-requests/{id}/reference-asset`
绑定当前 ADMIN 已上传的 `TASK_REFERENCE` asset。asset 必须尚未属于其他任务/请求,
内容仍走既有解码、像素、规范化和 hash 校验。请求体:
```json
{"image_asset_id":"2bbc1bf2-30f7-497e-a52b-bcb4c61d57c3"}
```
绑定成功后进入 `READY`。来源 revision/hash 或货运取消标记已变化时返回
`409 PROCUREMENT_SOURCE_CHANGED`。
### `POST /api/v1/procurement-requests/{id}/purchase-task`
必须带 `Idempotency-Key`,请求体是空 JSON 对象 `{}`。只有 `READY` request 可生成
任务;task、`purchase_task_sources` 和 request 的 `TASK_CREATED` 状态在同一事务内
提交。同一 request revision 即使使用不同幂等 key 重试也返回同一 task。来源后续
更新只显示 `source_changed=true`,不修改 PENDING、CLAIMED、RUNNING 或终态任务。
管理 Web 在 `/freight/{id}` 内提供对应的人工确认、参考图上传和生成任务操作;写操作
分别使用 `/freight/items/{id}/procurement-request`、
`/freight/procurement-requests/{id}/reference` 和
`/freight/procurement-requests/{id}/purchase-task`,均受 ADMIN session 与 CSRF
保护。
## 采购任务
### `POST /api/v1/tasks`
采购管理员或授权的外部管理系统创建任务。必须带 `Idempotency-Key`。
```json
{
"source_ref": "external-admin-task-10001",
"title": "黑色双肩包",
"sku": "BLACK-20L",
"description": "容量约20L,外观接近参考图",
"image_asset_id": "2bbc1bf2-30f7-497e-a52b-bcb4c61d57c3",
"quantity": 2,
"max_budget": "200.00"
}
```
规则:
- `title`、`sku` 和 `image_asset_id` 必填,`description` 可为空。
- `title` 最多 120 个 Unicode 字符且不超过 2048 个 UTF-8 字节;`sku` 不超过
512 个 UTF-8 字节;`description` 不超过 8192 个 UTF-8 字节。
- `quantity` 是正整数。
- `max_budget` 可为空,否则为大于零、最多两位小数的 CNY 金额,表示当前任务全部
数量的最高商品总预算,不含尚无法确认的运费或优惠。
- 同一调用方的 `source_ref` 如填写必须唯一。
成功返回 `201`:
```json
{
"id": "37c9c715-9b51-4ed5-984e-66dad2710c71",
"status": "PENDING",
"title": "黑色双肩包",
"sku": "BLACK-20L",
"quantity": 2,
"max_budget": "200.00",
"created_at": "2026-07-25T08:30:00Z"
}
```
### `GET /api/v1/tasks`
管理端列表。可选参数:`q`、`status`、`created_from`、`created_to`、`limit`、
`cursor`。`q` 匹配任务 ID、`source_ref`、标题或 SKU;默认 `limit=20`,最大 100。
排序固定为 `created_at DESC, id DESC`,不透明 cursor 同时编码这两个字段。
```json
{
"items": [
{
"id": "37c9c715-9b51-4ed5-984e-66dad2710c71",
"title": "黑色双肩包",
"sku": "BLACK-20L",
"status": "RUNNING",
"quantity": 2,
"device_name": "pdd-phone-01",
"updated_at": "2026-07-25T08:35:00Z"
}
],
"next_cursor": null
}
```
### `GET /api/v1/tasks/{task_id}`
管理会话返回完整任务、当前 execution、时间线、候选和资产元数据。App 只能读取
当前设备已领取的任务。响应必须同时包含:
- `original_requirement`:不可变原始输入。
- `derived_requirement`:模型输出,可能为空。
- `claim`:管理端可见设备和到期时间;claim token 永不返回。
- `execution`:step、outcome、错误、`order_submitted`。
- `events` 和 `assets`:有权限的摘要。
T-204 已实现的 `TASK_CREATED`、`TASK_CANCELED` 事件包含可空
`actor_user_id`;新管理操作写入真实 ADMIN 用户 ID,T-203 历史事件返回 `null`。
### `POST /api/v1/tasks/{task_id}/cancel`
管理端取消任务。`PENDING` 可立即取消;执行中只设置取消请求,App 在安全检查点确认
后进入 `CANCELED`。终态返回 `409`。
```json
{
"reason": "需求已撤销"
}
```
`PENDING/CLAIMED` 立即进入 `CANCELED`。`RUNNING/WAITING_CONFIRMATION` 只记录
取消请求并保持原状态;重复请求不重复写事件。App 的任务 heartbeat 会返回
`cancel_requested=true`,只有 App 在安全检查点调用 `cancel-ack` 后才进入
`CANCELED` 并结束 execution。
### `POST /api/v1/tasks/{task_id}/order-authorizations`
T-215 起由有效 ADMIN 会话对当前 `WAITING_CONFIRMATION` execution 创建一次性待投递
授权。Cookie API 请求必须带 `X-CSRF-Token` 和 `Idempotency-Key`;请求体见
[`T-215`](tasks/T-215.md)。关键规则:
- 顶层和每个 item 只引用 v7 `candidate_key`,不得用 ordinal 或 URL 选品。
- `items` 恰好覆盖当前 execution 全部 observation,只有顶层 key 对应项为
`ACCEPT`;所有项必须包含 T-208 schema v1 合法理由。
- `execution_id`、task content hash 和 expected task version 必须同时匹配;服务端
从任务/observation 读取 SKU、数量、候选规格、价格和身份指纹写入授权快照。
- 首次成功返回 `201` 和 `PENDING_DELIVERY` 授权;同 key 同 body 重放返回相同
授权并带 `replayed=true`,同 key 不同 body 返回 `409`。
- 未投递前改选必须引用当前 `supersedes_authorization_id`;旧授权变为
`SUPERSEDED`。已投递或不是最新授权时返回 `409`。
Admin `GET /api/v1/tasks/{task_id}` 同时返回当前及历史
`order_authorizations`。授权仅允许 T-216 设备命令领取,不代表浏览器可以直接操作
手机,也不授权付款。
### `POST /api/v1/tasks/{task_id}/commands/next`
T-216 起由原 BUYER/设备主动拉取当前 execution 的下单命令。请求带 bearer、
`X-Claim-Token`,body 提交 `device_id`、`execution_id` 和 `claim_generation`。
服务端校验有效 claim/租约、`WAITING_CONFIRMATION`、未取消和同一 active
authorization。没有命令返回 `204`;首次投递把授权置为 `DELIVERED`,之后对
`DELIVERED/ACKNOWLEDGED` 返回同一 schema v1 command。
命令只含 task/execution/content hash、原始 SKU/数量、candidate key、观测标题/
规格/价格和 T-214 四个指纹,不含第三方 URL。`observed_ordinal` 仅是重新定位提示。
`command_sha256` 固定 canonical payload,投递重试不得变化。
### `POST /api/v1/tasks/{task_id}/commands/{command_id}/ack`
App 严格校验并写入 Keystore-backed 加密状态后,使用 bearer、claim token、
`Idempotency-Key` 提交 execution、generation 和 `command_sha256`。成功把
`DELIVERED -> ACKNOWLEDGED`;相同 key/body 和同 command/hash 可安全重试,其他
command/hash/设备或无效租约返回冲突。ACK 只表示命令已可靠保存,不表示开始操作、
创建订单或付款。
### `POST /api/v1/tasks/{task_id}/order-dry-runs/start`
T-217 App 在加密保存 dry-run 意图后调用。请求带 BUYER bearer、claim token、
`Idempotency-Key`,body 固定 device、execution、generation、command id/hash。
服务端要求有效租约、任务仍为 `WAITING_CONFIRMATION`、未取消且授权为
`ACKNOWLEDGED`;事务内创建 PREPARING dry-run、把授权置为 `EXECUTING` 并写开始
事件。相同请求重放返回同一记录。
### `POST /api/v1/tasks/{task_id}/order-dry-runs/{command_id}/ready`
App 到达确认订单页并先加密保存 READY 后,提交当前 card/detail 指纹、规范化标题、
已选 SKU、数量、单价、商品总额和受控 evidence asset。服务端重新核对 command、
task/execution/claim、数量、预算和 evidence 归属后,把同一 dry-run 置为 READY 并写
事件;相同请求可重放。该响应不表示订单已提交,且不授权付款。
### `POST /api/v1/tasks/{task_id}/order-submissions/start`
T-218 App 在最终提交前调用。请求带 BUYER bearer、claim token、
`Idempotency-Key`,body 固定 device、execution、generation、authorization、
command id/hash、dry-run id/hash 和 App 重新核验的 SKU/数量/单价/总额。服务端要求
有效租约、未取消、authorization=`EXECUTING`、dry-run=`READY` 且所有快照完全一致。
成功事务内创建一个 `order_submission`、写 `ORDER_SUBMISSION_FENCED` 并使原授权
不可再次创建 submission。响应返回稳定 submission ID 和 `fenced_at`。同 key/body
重放返回相同记录;同 key 不同 body、第二个 submission 或非 READY 返回冲突。围栏
成功只表示 App 获得过一次点击机会;从此即使响应丢失或 App 退出也只能对账,不能
再次提交。
### `POST /api/v1/tasks/{task_id}/order-submissions/{submission_id}/reconcile`
App 唯一识别待付款订单并先上传受控 evidence 后,提交订单编号、平台显示下单时间、
标题、SKU、数量、金额、状态和 evidence asset/hash。服务端核对原 device/task/
execution/generation、submission、期望快照、证据归属和幂等键;成功将 submission
置为 `RECONCILED`、authorization 置为 `CONSUMED` 并记录
`order_submitted=true`。订单编号不得进入 URL、日志、事件 message 或模型字段。
围栏创建后允许原 user/device/generation/claim token 在租约到期后完成该 submission
的对账,但任务必须仍未取消且 execution 未结束;该例外不适用于 start,也不产生
新的提交资格。
### `POST /api/v1/tasks/{task_id}/order-submissions/{submission_id}/manual-review`
围栏后无法唯一回读订单时,App 用相同身份提交受限 reason code 和可选 evidence。
服务端将 submission 置为 `MANUAL_REVIEW` 并写审计事件,但不释放围栏、不允许生成
第二个 submission。后续只允许人员或同一设备重新对账,不提供“重试提交”接口。
三个接口都不接收支付方式、支付密码或付款结果,也不授权客户端点击付款控件。
Admin `GET /api/v1/tasks/{task_id}` 在 T-219 增加 `order_submissions` 数组。数组按
`fenced_at`、ID 升序;包含 submission/authorization/dry-run/execution 身份、状态、
预期标题/SKU/数量/单价/总额和围栏时间。`RECONCILED` 项另外返回订单号、平台下单
时间、`PENDING_PAYMENT`、对账 evidence asset/hash 和对账时间;
`MANUAL_REVIEW` 返回受限 reason code。响应必须 `Cache-Control: no-store`,订单号
不进入任何资源 URL。无记录时返回 `[]`,不能省略字段或返回 `null`。
## 设备与领取
### `POST /api/v1/devices/heartbeat`
App 空闲或运行时上报设备状态;运行时任务续租使用任务专用 heartbeat。
```json
{
"app_version": "0.1.0",
"android_version": "16",
"pdd_version": "device-observed-value",
"readiness": {
"accessibility_enabled": true,
"pdd_installed": true,
"active_task_id": null
}
}
```
`device_id` 可以省略;若提供,必须与 Bearer token 中的设备一致。服务端时间是唯一
租约时钟。响应返回服务端认定的 `active_task_id`、`client_state_matches`、
readiness 上报时间和 `server_time`。heartbeat 超过配置 TTL 或任一就绪位为 false
时,claim 返回 `409 DEVICE_NOT_READY`。
### `POST /api/v1/tasks/claim-next`
由用户点击触发,在单个 SQLite immediate transaction 中领取下一条任务。App 必须
在请求前生成并安全保存相互独立的 256 bit Raw URL `X-Claim-Token` 和
`Idempotency-Key`;网络结果不确定时复用同一组值。服务端只保存 token 的
SHA-256,响应和日志都不回显原 token 或 hash。
```json
{}
```
有任务时:
```json
{
"task": {
"id": "37c9c715-9b51-4ed5-984e-66dad2710c71",
"status": "CLAIMED",
"title": "黑色双肩包",
"sku": "BLACK-20L",
"description": "容量约20L,外观接近参考图",
"image_asset_id": "2bbc1bf2-30f7-497e-a52b-bcb4c61d57c3",
"reference_image_sha256": "64-char-lowercase-hex",
"task_content_sha256": "64-char-lowercase-hex",
"reference_image_url": "/api/v1/tasks/37c9c715-9b51-4ed5-984e-66dad2710c71/reference-image?claim_generation=1",
"quantity": 2,
"max_budget": "200.00",
"currency": "CNY",
"version": 2,
"claim_generation": 1,
"claim_issued_at": "2026-07-25T08:30:00Z",
"claim_expires_at": "2026-07-25T08:40:00Z"
},
"replayed": false,
"server_time": "2026-07-25T08:30:00Z"
}
```
`task_content_sha256` 是服务端对不可变任务内容生成的版本化摘要,客户端视为 opaque
并在候选和终态结果中原样回显;服务端拒绝与当前 execution 快照不一致的摘要。
App 下载参考图后必须独立校验 `reference_image_sha256`。
当前设备自己有已过期 `CLAIMED` 时优先回收该任务,避免设备唯一归属冲突;否则按
`created_at ASC, id ASC` 选择 `PENDING` 或已过期 `CLAIMED`。没有任务返回 `204`;
同 key 的无任务重放始终保持 `204`。同 key、同 token 的活跃 claim 重放返回同一
任务,即使状态已进入 `RUNNING/WAITING_CONFIRMATION`;请求不同返回
`409 IDEMPOTENCY_CONFLICT`;原 claim 已释放、取消或被其他领取回收时返回
`409 CLAIM_REPLAY_EXPIRED`。同一设备不能再领取第二条活跃任务。
### `GET /api/v1/tasks/{task_id}/reference-image`
claim 响应给出的受保护参考图地址。请求使用 BUYER Bearer token、
`X-Claim-Token` 和 URL 中的 `claim_generation`;只有当前用户、当前设备、匹配
generation/token 且租约未过期的活跃任务可以读取。成功返回匿名规范化 JPEG,
设置 `private, no-store`、`nosniff`、长度和 ETag,不返回存储路径。
### `POST /api/v1/tasks/{task_id}/start`
把当前设备持有的 `CLAIMED` 任务改为 `RUNNING` 并创建 execution。请求带
`X-Claim-Token` 和 `Idempotency-Key`。
```json
{
"claim_generation": 1,
"expected_version": 2
}
```
同 key、同请求重放返回同一 execution;错误用户/设备/token 返回 `403`,过期租约、
状态或版本冲突返回 `409`。成功响应包含更新后的 `task`、`execution`、`replayed`
和 `server_time`。默认 running lease 为 30 分钟(允许配置 5 至 120 分钟),
`execution.execution_expires_at` 是 App 可以离线继续自动化的上限,不是后台自动
重分配时间。
### `POST /api/v1/tasks/{task_id}/heartbeat`
运行时续租并返回是否请求取消:
```json
{
"execution_id": "e3190742-a24b-441e-b5c1-c7ed10ed342f",
"claim_generation": 1,
"step": "SCAN_RESULTS"
}
```
```json
{
"task": {
"id": "37c9c715-9b51-4ed5-984e-66dad2710c71",
"status": "RUNNING",
"version": 4,
"claim_generation": 1,
"claim_expires_at": "2026-07-25T09:03:30Z"
},
"execution": {
"id": "e3190742-a24b-441e-b5c1-c7ed10ed342f",
"current_step": "SCAN_RESULTS",
"order_submitted": false,
"execution_expires_at": "2026-07-25T09:03:30Z"
},
"cancel_requested": false,
"server_time": "2026-07-25T08:33:30Z"
}
```
只接受 1 至 64 字节的大写 ASCII step。heartbeat 使用服务端 UTC 更新 execution、
设备最近在线时间、任务 version 和 `execution_expires_at`,不写高频任务事件。
App 每 30 秒 best-effort 调用;网络失败时可执行到上一次服务端截止时间。截止时间
到达后 App 持久化 `SAFE_STOPPED` 并停止外部动作,任务保持原非终态且绝不回到
领取队列。原设备重连后可以用同一 execution/claim heartbeat 同步状态;后端不延长
已经过期的授权,且此时只接受 `step=SAFE_STOPPED`。若响应包含取消请求,App 可以
继续调用 `cancel-ack`;没有取消时仍保持安全停止,不自动恢复采购。
### `POST /api/v1/tasks/{task_id}/release`
只允许尚未开始的 `CLAIMED` 任务释放回 `PENDING`。运行中使用取消/失败流程。
请求带 `X-Claim-Token`、`Idempotency-Key`,body 与 start 相同。成功后清除当前
用户、设备、token hash 和租约,但保留递增过的 `claim_generation` 供审计。
### `POST /api/v1/tasks/{task_id}/cancel-ack`
App 收到取消请求并在安全检查点停止后调用:
```json
{
"execution_id": "e3190742-a24b-441e-b5c1-c7ed10ed342f",
"claim_generation": 1,
"expected_version": 5
}
```
请求带 `X-Claim-Token` 和 `Idempotency-Key`。只有匹配的未结束 execution、设备
归属和已存在的管理取消请求可以确认;App 已在安全检查点停止时,即使离线授权刚
过期也允许原设备补交确认。成功把任务置为 `CANCELED`、结束 execution、清除 claim
秘密并追加带用户/设备 actor 的事件。同 key 重放不产生第二个事件。
## App 本地 AI 边界
MVP 后端不实现 `/api/v1/tasks/{task_id}/ai/*`,不保存 VLM 配置或 Key,也不代理
第三方模型。App 使用本机配置的 OpenAI 兼容 adapter 完成需求提取和候选评估;后台
任务 payload 不能携带或覆盖 provider、Base URL、model、prompt 或 API Key。
App 支持 `MANUAL_FIRST` 和 `AI_ASSISTED`。execution 结果必须记录实际模式;
`AI_ASSISTED` 还要记录 provider ID、model、prompt/schema version、reference/
candidate evidence SHA-256 和结构化模型判断。结果不得包含 Key、Authorization、
完整 endpoint、订单号、店铺名或供应商原始响应正文。
`sku`、`quantity` 和 `max_budget` 始终来自原任务,模型不能覆盖。App 按 ordinal
串行评估,每个 execution 最多一次需求提取、最多 5 次候选评估,并由本地确定性规则
产生建议。模型不能返回页面动作、建议 ordinal、人工确认状态或订单授权;低置信度、
无效 schema、证据不足或预算不确定时转人工。
## 执行事件与结果
App 使用加密 outbox 按“事件 -> evidence asset -> 候选 -> 人工 review -> 终态”
顺序提交。所有写接口
重新校验 BUYER/device/task/execution/claim 和幂等键。原设备可以在
`execution_expires_at` 后补报授权内已经产生的结果;后端记录
`received_after_execution_expiry=true`,但这不允许 App 在过期后继续自动化。
### `POST /api/v1/tasks/{task_id}/events`
批量追加事件,必须带 `Idempotency-Key`:
```json
{
"execution_id": "e3190742-a24b-441e-b5c1-c7ed10ed342f",
"claim_generation": 1,
"events": [
{
"event_id": "client-generated-uuid",
"step": "SEARCH",
"type": "STEP_COMPLETED",
"message": "已进入搜索结果页",
"occurred_at": "2026-07-25T08:34:00Z"
}
]
}
```
`message` 不能包含凭证或完整个人敏感信息;同一 `event_id` 重放不重复插入。
### `POST /api/v1/tasks/{task_id}/evidence`
以原始 `image/jpeg`、`image/png` 或 `image/webp` body 上传一张受控截图;请求必须带
`Authorization: Bearer`、`X-Claim-Token`、`Idempotency-Key`、`X-Execution-ID` 和
`X-Claim-Generation`。服务端规范化存为 JPEG 并返回 asset ID、SHA-256、尺寸及
`received_after_execution_expiry`,不接受图片 URL,也不从第三方下载图片。
### `POST /api/v1/tasks/{task_id}/candidates`
批量保存当前 execution 实际检查的 `0..5` 个原始曝光候选,必须带
`Idempotency-Key`。正式参考图检索使用固定审计值 `PDD_IMAGE_SEARCH`,不把标题或
SKU 伪装成图片检索词:
```json
{
"execution_id": "e3190742-a24b-441e-b5c1-c7ed10ed342f",
"claim_generation": 1,
"task_content_sha256": "64-char-lowercase-hex",
"execution_mode": "AI_ASSISTED",
"search_query": "PDD_IMAGE_SEARCH",
"provenance": {
"provider_id": "device-configured-provider",
"model": "device-configured-model",
"prompt_version": "candidate-evaluation-v2",
"schema_version": 2
},
"candidates": [
{
"ordinal": 1,
"title": "页面可见标题",
"sku_text": "BLACK-L",
"price": "189.00",
"product_url": "",
"image_url": "",
"card_signature": "64-char-lowercase-hex",
"detail_signature": "64-char-lowercase-hex",
"detail_evidence_sha256": "detail-asset-64-char-lowercase-hex",
"specification_evidence_sha256": "specification-asset-64-char-lowercase-hex",
"evidence_asset_ids": [
"7b733922-f90f-4bc4-a9ad-3e8ec4769122",
"4095ea37-eb4f-47c7-989b-adf060e45a32"
],
"evaluation": {
"decision": "REVIEW",
"score": 0.82,
"matched": ["颜色和尺码均有可见证据"],
"missing_or_uncertain": [],
"rejection_reasons": [],
"confidence": 0.78,
"hard_constraints": [
{
"kind": "COLOR",
"expected": "BLACK",
"status": "MATCH",
"evidence": "候选页面显示黑色"
},
{
"kind": "SIZE",
"expected": "L",
"status": "MATCH",
"evidence": "候选页面显示 L 码"
}
]
}
}
],
"recommendation": {
"candidate_ordinal": 1,
"policy_version": "local-recommendation-v1",
"reasons": ["当前证据下匹配分最高"]
}
}
```
`MANUAL_FIRST` 时 `provenance` 和 `evaluation` 为空,但候选观察、搜索审计值和人工
结果仍可提交。`AI_ASSISTED` 为每个原始 observation 保存 evaluation;颜色/尺码状态
可以是 `MATCH/MISMATCH/UNKNOWN`,模型拒绝项也必须保留。schema v2 的
`recommendation` 只能指向颜色和尺码均为 `MATCH`、分数和置信度均不低于 `0.75`
且没有拒绝原因的原始 ordinal;没有满足项时只省略 recommendation,不能删除原始
候选。后端在同一事务内写入 search run、observation、model evaluation 和
recommendation,并保留旧 JSON 审计副本。每个非空候选必须按
`DETAIL`、`SPECIFICATION` 顺序绑定两个不同的 evidence asset ID,且两个声明哈希
必须分别等于后端保存的实际 asset 哈希;卡片和详情语义签名也必须是小写 SHA-256。
后端用版本化的 execution、原 ordinal、详情签名和规格证据哈希生成稳定的
execution-scoped `candidate_key`。Admin 任务详情在 observation 的 `identity` 中
返回该 key、四个指纹及 identity 版本;旧 v6 observation 可没有 identity。
`product_url` 和 `image_url` 只有设备实际取得可信 URL 时才提交,不能伪造;后端不
请求这些 URL,主要证据必须是已鉴权 asset。
### `POST /api/v1/tasks/{task_id}/human-reviews`
采购员确认候选后、提交终态前调用。请求使用设备 Bearer token、`X-Claim-Token`
和 `Idempotency-Key`:
```json
{
"execution_id": "e3190742-a24b-441e-b5c1-c7ed10ed342f",
"claim_generation": 1,
"task_content_sha256": "64-char-lowercase-hex",
"reason_schema_version": 1,
"outcome": "CANDIDATE_ACCEPTED",
"selected_candidate_ordinal": 2,
"primary_reason_code": "SELECTED_BEST_MATCH",
"note": "",
"supersedes_review_id": null,
"items": [
{
"candidate_ordinal": 1,
"label": "REJECT",
"primary_reason_code": "NOT_BEST_MATCH",
"reason_codes": ["NOT_BEST_MATCH"],
"note": ""
},
{
"candidate_ordinal": 2,
"label": "ACCEPT",
"primary_reason_code": "SKU_MATCH",
"reason_codes": ["SKU_MATCH"],
"note": ""
}
]
}
```
非空 review 必须恰好覆盖本次所有 observation;接受时只有所选 ordinal 为
`ACCEPT`,其余全部为 `REJECT`。零候选只允许 `NO_MATCH` 或
`MANUAL_REQUIRED` 且 items 为空。理由使用 T-208 版本 1 allowlist;任务没有预算时
禁止价格类理由,`OTHER` 必须带 4-200 字备注。同一幂等键重放返回原 review;
修订必须通过 `supersedes_review_id` 引用当前最新版本,服务端追加版本并保留历史。
### `POST /api/v1/tasks/{task_id}/complete`
人员完成确认后调用,必须带 `Idempotency-Key`:
```json
{
"execution_id": "e3190742-a24b-441e-b5c1-c7ed10ed342f",
"claim_generation": 1,
"task_content_sha256": "64-char-lowercase-hex",
"execution_mode": "AI_ASSISTED",
"outcome": "CANDIDATE_ACCEPTED",
"operator_reason": "款式和预算符合验证要求",
"candidate": {
"ordinal": 1,
"title": "页面可见标题",
"sku_text": "BLACK-L",
"price": "189.00",
"product_url": "",
"image_url": "",
"card_signature": "64-char-lowercase-hex",
"detail_signature": "64-char-lowercase-hex",
"detail_evidence_sha256": "detail-asset-64-char-lowercase-hex",
"specification_evidence_sha256": "specification-asset-64-char-lowercase-hex",
"evidence_asset_ids": [
"7b733922-f90f-4bc4-a9ad-3e8ec4769122",
"4095ea37-eb4f-47c7-989b-adf060e45a32"
],
"evaluation": null
},
"order_submitted": false
}
```
`outcome` 允许:
- `CANDIDATE_ACCEPTED`
- `CANDIDATE_REJECTED`
- `NO_MATCH`
- `MANUAL_REQUIRED`
后端对 MVP 强制 `order_submitted=false`,成功后任务进入 `SUCCEEDED`;这里的成功表示
验证工作流正常结束,业务结果由 `outcome` 表达。
T-207 第一版要求 `operator_reason` 为人员输入的简短审计说明,但不把它当作可训练
标签。T-208 在第一版链路跑通后扩展为版本化 `human_review` 合约,至少包含
`reason_schema_version`、可空选择候选、逐候选 `ACCEPT/REJECT`、主要理由码、
附加理由码和受限备注。改选必须同时提交原推荐项拒绝理由与替代项选择理由;全部
无匹配必须覆盖每个曝光候选。具体 endpoint 和 schema 在领取 T-208 时冻结。
模型评估理由、确定性推荐理由和 `human_review` 分开保存。服务端不接受客户端把
模型理由标记为已由人员确认;第三方商品/图片 URL 只作为受限观测字段,不触发后端
下载,主要证据仍通过鉴权 asset API 上传。
### `POST /api/v1/tasks/{task_id}/fail`
```json
{
"execution_id": "e3190742-a24b-441e-b5c1-c7ed10ed342f",
"claim_generation": 1,
"error": {
"code": "PDD_RISK_CONTROL",
"message": "检测到平台风险提示,已停止自动化",
"step": "SCAN_RESULTS",
"retryable": false
},
"evidence_asset_ids": ["7b733922-f90f-4bc4-a9ad-3e8ec4769122"]
}
```
后端只接受文档登记的错误码族和合法状态迁移。
## 待实现前固定
- 上传大小、像素和保留期限的具体数值。
- VLM `confidence` 阈值、模型和提示词版本记录格式。
- 外部管理后台的服务账号认证方式和调用频率。