20 KiB
API 合约
本文是采购服务与采购工具之间的唯一线协议权威。页面判据和本地类名不是线协议。 接口形状参考前序项目的经验,但所有状态与安全语义在 cmbuyer 重新定义、测试和取证。
通用约定
- 生产前缀:
/api/v1;管理页面路由见 routes.md。 - JSON 使用 UTF-8;时间为 UTC RFC 3339;ID 为 UUID 字符串。
- 金额均为规范十进制字符串,如
"12.88";禁止 JSON number 和浮点计算。 - 所有写接口接受
request_id/ 业务幂等键;同键同载荷重放同一结果,同键异载荷返回409。 - 任务写入携带
expected_task_version;版本冲突返回409 version_conflict。 - 服务端错误不得回显设备 token、完整节点树、地址、手机号或支付信息。
身份
| 身份 | 凭据 | 能力 |
|---|---|---|
| 管理员 | HttpOnly; SameSite=Lax 会话 cookie + CSRF;HTTPS 部署设 Secure=true |
建单、开始采购、查看内部证据、人工调和 |
| 设备 | Authorization: Bearer <device-token> + X-CMBuyer-Device-ID |
心跳、领取、事件、截图、围栏与结果 |
| ERP(V2) | 独立凭据 | 只读来源同步,不访问采购结果 |
设备凭据不能建单或开始采购;管理会话不能调用设备接口。凭据缺失或无效返回 401,已认证但无权
或管理写请求缺少有效 CSRF 返回 403;认证存储故障按下述规则返回 503。
设备请求的认证头采用以下固定格式:
Authorization和X-CMBuyer-Device-ID必须各出现且只出现一次;代理合并出的逗号列表也拒绝。- Authorization scheme 按 HTTP 规则大小写不敏感,但 scheme 后只允许一个 ASCII 空格;token 必须是 加密随机生成的 32 字节值对应的 64 位小写十六进制文本。
- 设备 id 必须是规范小写 UUIDv4。token 与设备 id 同时绑定,未知、错配、格式错误和已撤销均返回
空
401,可带WWW-Authenticate: Bearer,不区分具体原因。 - 认证器逐请求读取 SQLite,不缓存 ACTIVE 结论。SQLite 查询或连接故障返回空
503;401 与 503 都必须发生在 Content-Type 解析和 body 读取之前。 - 管理 cookie 不替代设备凭据;Bearer 也不替代管理 session/CSRF。两类凭据同时出现时,各路由仍只 采用自己的身份域,不把权限相加。
设备 token 由本机管理 CLI 签发,只在签发事务提交后向操作者显示一次;SQLite 仅保存 token 原始
32 字节的 SHA-256(32 字节 BLOB),list/revoke、日志、错误和 HTTP 响应均不显示 token 或 hash。
撤销幂等且不会恢复旧 token。MVP 服务只绑定 IPv4 回环 127.0.0.1:8080,设备 Bearer 只经过本机回环 HTTP;
未来非回环访问必须先建立 HTTPS/TLS 终止与代理信任边界。
错误响应
{
"error": {
"code": "version_conflict",
"message": "任务已变化,请刷新后重选",
"retryable": false,
"request_id": "c3c9f507-7473-4fa6-8d71-8786c34c6301"
}
}
retryable=true 只表示接口调用可以按同一幂等键重放,不表示可以重试任何真机点击。
一、管理端接口
| 方法 | 路径 | 作用 |
|---|---|---|
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 |
GET /tasks/{id} 的完整页与列表抽屉共享同一服务端数据模型和详情模板。列表只可用同源请求携带
X-CMBuyer-View: drawer 获取 HTML fragment;其他非空 view、跨站 fragment 请求或不接受
text/html 的 fragment 请求均拒绝。直接导航同一 URL 始终返回完整页。
GET /evidence/{asset_id} 不经静态目录:未登录先返回 401,不查询和泄露资产是否存在;登录后
缺失或畸形 id 返回空 404。成功只返回存储的 PNG,包含 Content-Length、固定安全文件名、
Cache-Control: no-store 与 X-Content-Type-Options: nosniff,不返回原文件名或服务端路径。
POST /tasks
核心字段:
{
"create_key": "d3c9f507-7473-4fa6-8d71-8786c34c6301",
"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
{
"start_key": "63c9f507-7473-4fa6-8d71-8786c34c6301",
"tasks": [
{"task_id": "83c9f507-7473-4fa6-8d71-8786c34c6301", "expected_task_version": 1},
{"task_id": "93c9f507-7473-4fa6-8d71-8786c34c6301", "expected_task_version": 1}
]
}
管理员按钮必须显示为“开始采购(只创建待付款订单)”。点击本身就是授权:允许采购工具按任务 锁定字段创建一笔待付款订单;不再等待试选后人工确认,也不授权付款。
服务端在一个事务中:
- 校验列表非空、无重复任务,所有任务均为
DRAFT且版本一致; - 校验每条任务的
goods_id、规格、正整数数量和最高总价完整; - 为每条任务创建一次性
order_authorization,锁定任务版本、上述字段、管理员、时间和有效期; - 把所有任务转为
PENDING并递增版本。
任一条失败则整批不变。相同 start_key + 相同任务集合重放同一批结果;集合或版本不同返回 409。
{
"start_key": "63c9f507-7473-4fa6-8d71-8786c34c6301",
"authorized_count": 2,
"tasks": [
{"task_id": "83c9f507-7473-4fa6-8d71-8786c34c6301", "task_version": 2, "authorization_id": "a3c9f507-7473-4fa6-8d71-8786c34c6301"},
{"task_id": "93c9f507-7473-4fa6-8d71-8786c34c6301", "task_version": 2, "authorization_id": "b3c9f507-7473-4fa6-8d71-8786c34c6301"}
],
"payment_automated": false
}
围栏前重置与围栏后调和
reset-to-draft必须同时校验任务版本、授权 id 与“尚无order_submission”。关闭旧授权后回到DRAFT;重新开始必须产生新版本与新授权。- 一旦存在
order_submission,重置、取消、授权过期和重新开始都返回409 submission_fenced。 reconcile只能处理指定sid:人工记录“已创建待付款订单”或“确认未创建/无法完成”。它不能 触发设备点击、释放围栏或签发新授权。
二、设备侧接口
| 方法 | 路径 | 作用 |
|---|---|---|
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 |
点击后一次性上报观察结果;只调和不重试 |
POST /api/v1/devices/heartbeat
{
"device_id": "e3c9f507-7473-4fa6-8d71-8786c34c6301",
"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
设备 id 只来自已经认证的 X-CMBuyer-Device-ID,不得放进 JSON。请求体上限 4096 字节,只接受
以下两个字段;二者都必须是规范小写 UUIDv4,未知字段、重复字段和额外 JSON 均拒绝:
{
"session_id": "23c9f507-7473-4fa6-8d71-8786c34c6301",
"claim_request_id": "33c9f507-7473-4fa6-8d71-8786c34c6301"
}
成功领取或同一会话恢复返回 200:
{
"task": {
"id": "13c9f507-7473-4fa6-8d71-8786c34c6301",
"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": "30.00"
},
"authorization": {
"id": "73c9f507-7473-4fa6-8d71-8786c34c6301",
"task_version": 2,
"expires_at": "2026-08-04T10:00:00Z"
},
"attempt": {
"id": "53c9f507-7473-4fa6-8d71-8786c34c6301",
"claim_token": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"claim_generation": 1,
"lease_expires_at": "2026-08-04T09:05:00Z"
}
}
- 设备认证发生在 Content-Type 解析和 body 读取前。事务的第一条数据库业务语句取得 SQLite 写入
位置并再次条件确认设备仍为
ACTIVE;之后才允许查幂等记录、候选或返回 EMPTY/冲突。 - 只返回
PENDING + ACTIVE + 严格未过期且 task/version/规格/数量/总价快照完全一致的最早授权; 服务端在同一事务中创建唯一 attempt/claim/request,并转为CLAIMED。 - 同一
claim_request_id同设备、同 session 稳定重放原结果;同键异载荷返回409 {"error":"idempotency_conflict"}。没有候选返回空204,且 EMPTY 也持久化稳定重放。 - claim 持久化完整成功响应快照;领取后的 task/authorization 源行变化不得让旧 request 的标题、规格、 数量、金额、版本或到期时间漂移。源快照不一致时,新恢复/续租失败闭合。
- 一个设备最多有一个未关闭 claim。同 session 且租约有效时重放原 attempt;同一 attempt 已按服务端
首事件原子进入
ORDERING时也只在 task version 恰好为 claim 版本 +1 时恢复。不同 session、租约 过期或业务状态异常固定返回409 {"error":"claim_requires_manual"},不释放、不转领、不新建 attempt。 claim_token是 32 字节 HMAC 的 64 位小写十六进制表示,只证明一个 attempt 的归属,不是提交许可。 SQLite 仅保存随机 nonce 与 token SHA-256;同一 secret 重启后重建相同 token,错误 secret 拒绝启动。- 响应不得包含自由动作脚本、CSS/XPath、通用坐标或支付能力。
POST /api/v1/tasks/{id}/lease/renew
请求体同样限 4096 字节并执行严格 JSON 校验:
{
"renew_request_id": "43c9f507-7473-4fa6-8d71-8786c34c6301",
"session_id": "23c9f507-7473-4fa6-8d71-8786c34c6301",
"attempt_id": "53c9f507-7473-4fa6-8d71-8786c34c6301",
"claim_generation": 1,
"claim_token": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"expected_lease_expires_at": "2026-08-04T09:05:00Z"
}
成功返回 200;响应不回显 token:
{
"task_id": "13c9f507-7473-4fa6-8d71-8786c34c6301",
"attempt_id": "53c9f507-7473-4fa6-8d71-8786c34c6301",
"claim_generation": 1,
"lease_expires_at": "2026-08-04T09:06:00Z"
}
- 原设备/session/attempt/generation/token 必须同时匹配,当前租约与授权都必须严格晚于服务端 UTC
当前时间,
expected_lease_expires_at必须逐字等于数据库当前值;边界相等即过期且不能复活。 - 新到期时间是
min(server_now + CMBUYER_CLAIM_LEASE_TTL, authorization.expires_at)。续租不改变 token、generation、任务版本或业务状态。 - 成功续租先持久化 request 与响应;同一
renew_request_id同载荷只重放旧响应,不再次 CAS 或延长。 同键异载荷返回idempotency_conflict;非当前 claim 固定返回claim_not_current,两者均为409。
claim/renew 的格式错误固定为 400 {"error":"invalid_request"},超限为
413 {"error":"request_too_large"},Content-Type 错误为
415 {"error":"unsupported_media_type"};设备认证/事务内撤销为无诊断 401,存储故障为无诊断 503。
事件与证据
事件只包含固定 step / outcome / reason_code 和非敏感摘要。禁止把完整 XML、地址、手机号、
页面全文或 token 塞进日志字段。
截图接口使用 multipart/form-data,只接受单个显式文件及以下元数据:
{
"upload_key": "43c9f507-7473-4fa6-8d71-8786c34c6301",
"attempt_id": "33c9f507-7473-4fa6-8d71-8786c34c6301",
"kind": "SKU_PANEL_GATE_1",
"privacy_tier": "INTERNAL_RAW",
"sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"captured_at": "2026-08-04T09:01:00Z"
}
- 允许规格面板和确认页截图保留页面已显示的地址/手机号;不要求遮罩或裁剪。
- 不接受 XML、目录、manifest、本机绝对路径、外部支付页截图或支付凭据。
- T-204 只开放
kind=SKU_PANEL_GATE_1;后续 kind 必须由对应真机证据任务收紧扩展。 privacy_tier只能是INTERNAL_RAW;时间必须是以Z结尾的 UTC RFC 3339。- URL 中的 task id、
upload_key与attempt_id都必须是规范的小写 UUIDv4;sha256必须是 恰好 64 位小写十六进制字符。 - 恰好一个带
Content-Type: image/png的显式文件;除上述六个元数据字段外,未知或重复字段均拒绝。 - 单文件最多 10 MiB、单边最多 8192 px、总像素最多 16,777,216;服务端校验 PNG 魔数、完整解码、 字节数、尺寸与调用方声明的 64 位小写 SHA-256。
attempt_id必须由数据库复合外键证明属于 URL 中的 task,且首次写入必须存在由认证设备持有的 未关闭 claim;设备 A 不能向设备 B 的 attempt 上传。认证必须先于 Content-Type 解析和请求体读取。- 同一设备主体和
upload_key的同载荷重放返回原资产;即使 claim 后续由人工关闭,已成功资产仍先 重放历史结果。关闭后不得用新 upload key 写新证据;任务、attempt、截图或元数据变化返回409。 - 首次成功返回
201,幂等重放返回200。响应只含资产 id、关联 id、kind/tier、hash、字节数、 MIME、宽高和采集时间,不含设备 token、原文件名或存储路径。 - 生产上传使用逐请求 SQLite 设备认证;空凭据库、未知或已撤销设备均拒绝。不得使用管理员 session、 临时共享密钥或其他身份代替设备凭据。
POST /api/v1/purchase-attempts/{aid}/submission-fence
客户端只有在当前页面四条件中的后三项已经满足后才能调用:
{
"fence_key": "e3c9f507-7473-4fa6-8d71-8786c34c6301",
"task_id": "13c9f507-7473-4fa6-8d71-8786c34c6301",
"expected_task_version": 3,
"authorization_id": "73c9f507-7473-4fa6-8d71-8786c34c6301",
"claim_token": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"selected_color": "黑色CHA(纯棉)",
"selected_size": "M(建议100-115)",
"gate1_unit_price": "12.88",
"gate2_unit_price": "12.88",
"quantity_read": 2,
"confirm_page_amount": "25.76",
"submit_control_match_count": 1
}
服务端在一个事务中校验:任务/版本/claim/attempt 一致;授权有效未消费且字段等于任务快照;
规格与授权相等;数量相等;两个单价相等;计算金额及确认页金额均不超过 total_price_cap;提交控件
计数为一;此前不存在该授权或 attempt 的 submission。随后创建唯一 order_submission,授权转
FENCED,任务保持不可重领。
首次明确成功响应:
{
"submission_id": "f3c9f507-7473-4fa6-8d71-8786c34c6301",
"status": "FENCED",
"click_permitted": true,
"submit_text": "提交订单"
}
- 任一校验失败返回错误,绝不返回
click_permitted=true。 - 同一
fence_key的重放返回同一submission_id,但click_permitted=false且reconciliation_required=true;客户端不能凭重放响应点击。 - 客户端收到首次许可后,必须先把“围栏已取得/即将发出唯一点击”持久化,再执行点击。进程崩溃或 本地状态不明时宁可转调和,也不再次点击。
POST /api/v1/order-submissions/{sid}/result
{
"result_key": "03c9f507-7473-4fa6-8d71-8786c34c6301",
"attempt_id": "53c9f507-7473-4fa6-8d71-8786c34c6301",
"observation": "SUBMITTED",
"evidence_asset_id": "63c9f507-7473-4fa6-8d71-8786c34c6301"
}
observation 只允许:
SUBMITTED:明确订单已创建,转WAITING_PAYMENT;EXTERNAL_PAYMENT_HANDOFF:已跳外部支付,停止并转RECONCILIATION_REQUIRED;SECURITY_CHALLENGE:出现安全校验,停止并转调和;UNKNOWN:超时、断连或页面不明,转调和。
提交后没有“retry”观察值。任何结果都不能释放围栏或开放第二次点击。
文本和金额校验
- 规格字段:Unicode 规范化后精确相等;不得包含、前缀、编辑距离或 AI 猜测。
goods_id:仅 ASCII 十进制数字,canonical URL 中唯一。- 金额:
0.01到系统配置上限,至多两位小数;规范化后再比较和持久化。 - 数量:正整数,服务端与设备均设置合理上限;不能从字符串静默截断。
三、采购工具本地模块合约
TaskSource / ResultSink
class TaskSource(Protocol):
def claim_next(self, session: Session) -> ClaimedPurchase | None: ...
def renew_lease(self, claim: Claim) -> Lease: ...
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 一个许可。
真机能力分层
| 能力 | 输入 | 输出 | 安全边界 |
|---|---|---|---|
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 |
观察结果 | 许可、闸门、唯一控件全校验;点前持久化;绝不重试 |
T-103 只实现隔离的 SkuSelectionFlow:前四项加安全退出。它的模块和静态依赖不得引用数量、确认页、
围栏、提交或支付能力。后续任务按取证顺序组合成生产 SinglePassPurchaseFlow。
四、实现前仍需定值
- 授权有效期、领取租约时长、心跳/轮询间隔和连续失败停止阈值;
- 内部截图保留期限;截图大小上限已固定为 10 MiB / 8192 px 单边 / 16,777,216 像素;
- 可配置单任务数量与最高总价系统上限;
- 首次真实提交真机任务的人工授权和待付款订单处置步骤。