Files
cmbuyer/docs/api.md
T

13 KiB
Raw Blame History

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; Secure; SameSite=Lax 会话 cookie + CSRF 建单、开始采购、查看内部证据、人工调和
设备 Authorization: Bearer <device-token> + 设备 id 心跳、领取、事件、截图、围栏与结果
ERP(V2) 独立凭据 只读来源同步,不访问采购结果

设备凭据不能建单或开始采购;管理会话不能调用设备接口。未认证统一返回 401,无权返回 403。

错误响应

{
  "error": {
    "code": "version_conflict",
    "message": "任务已变化,请刷新后重选",
    "retryable": false,
    "request_id": "018f..."
  }
}

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

POST /tasks

核心字段:

{
  "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

{
  "start_key": "018f...",
  "tasks": [
    {"task_id": "018f-task-1", "expected_task_version": 1},
    {"task_id": "018f-task-2", "expected_task_version": 1}
  ]
}

管理员按钮必须显示为“开始采购(只创建待付款订单)”。点击本身就是授权:允许采购工具按任务 锁定字段创建一笔待付款订单;不再等待试选后人工确认,也不授权付款。

服务端在一个事务中:

  1. 校验列表非空、无重复任务,所有任务均为 DRAFT 且版本一致;
  2. 校验每条任务的 goods_id、规格、正整数数量和最高总价完整;
  3. 为每条任务创建一次性 order_authorization,锁定任务版本、上述字段、管理员、时间和有效期;
  4. 把所有任务转为 PENDING 并递增版本。

任一条失败则整批不变。相同 start_key + 相同任务集合重放同一批结果;集合或版本不同返回 409。

{
  "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
}

围栏前重置与围栏后调和

  • 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": "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

请求携带 device_id、session_id、claim_request_id。领取与授权绑定且具租约:

{
  "task": {
    "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": "30.00"
  },
  "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"
  }
}
  • 只返回有 ACTIVE 授权的 PENDING;服务端在一个事务中转为 CLAIMED 并创建 attempt。
  • 同一 claim_request_id 同载荷重放同一结果;并发设备只有一个成功。
  • 一个设备有未结束领取时优先重放该领取,不能悄悄领第二条。
  • 响应不得包含自由动作脚本、CSS/XPath、通用坐标或支付能力。

事件与证据

事件只包含固定 step / outcome / reason_code 和非敏感摘要。禁止把完整 XML、地址、手机号、 页面全文或 token 塞进日志字段。

截图接口使用 multipart/form-data,只接受单个显式文件及以下元数据:

{
  "attempt_id": "018f-attempt",
  "kind": "SKU_PANEL_GATE_1",
  "privacy_tier": "INTERNAL_RAW",
  "sha256": "64-lowercase-hex",
  "captured_at": "2026-08-04T09:01:00Z"
}
  • 允许规格面板和确认页截图保留页面已显示的地址/手机号;不要求遮罩或裁剪。
  • 不接受 XML、目录、manifest、本机绝对路径、外部支付页截图或支付凭据。
  • MIME、尺寸、字节数和 SHA-256 必须校验;资产只经管理员鉴权端点读取。

POST /api/v1/purchase-attempts/{aid}/submission-fence

客户端只有在当前页面四条件中的后三项已经满足后才能调用:

{
  "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": "25.76",
  "submit_control_match_count": 1
}

服务端在一个事务中校验:任务/版本/claim/attempt 一致;授权有效未消费且字段等于任务快照; 规格与授权相等;数量相等;两个单价相等;计算金额及确认页金额均不超过 total_price_cap;提交控件 计数为一;此前不存在该授权或 attempt 的 submission。随后创建唯一 order_submission,授权转 FENCED,任务保持不可重领。

首次明确成功响应:

{
  "submission_id": "018f-submission",
  "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": "018f-result",
  "attempt_id": "018f-attempt",
  "observation": "SUBMITTED",
  "evidence_asset_id": "018f-asset"
}

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。

四、实现前仍需定值

  • 授权有效期、领取租约时长、心跳/轮询间隔和连续失败停止阈值;
  • 截图大小上限和内部保留期限;
  • 可配置单任务数量与最高总价系统上限;
  • 首次真实提交真机任务的人工授权和待付款订单处置步骤。