Files
cmbuyer/docs/api.md
T

14 KiB
Raw Blame History

API 合约

本文定义 web 端对外的 HTTP 接口,以及 desk 端本地模块的合约。 这是双端之间的唯一权威。 实现前可细化,但不得在代码里另起一套不兼容接口。

本合约的设备心跳、任务领取、事件、证据、授权命令与 ack 结构参考了前序项目 cmroubao;同时在本项目重新审计并补回 dry-run、提交前服务端围栏和点击后调和。 前序接口是设计依据,不是可直接照搬的运行事实。

通用约定

  • 传输:JSON over HTTP。MVP 局域网内运行,生产部署应加 HTTPS。
  • 编码:UTF-8。
  • 时间:RFC 3339,带时区,UTC 存储。
  • 金额:十进制字符串(如 "45.60"),不用浮点数。
  • 幂等:所有创建类接口接受幂等键,重复提交返回同一结果而不是第二笔。

鉴权

客户端 方式 说明
管理 Web Session Cookie + CSRF Token 表单提交必须带 CSRF
desk 端 Authorization: Bearer <device_token> 凭据绑定设备标识,可单独撤销
ERP 对接 Authorization: Bearer <connector_token> 只能调用货运同步接口

三种身份互不通用。设备凭据不能创建任务或签发授权;管理会话不能调用设备接口。

错误响应

{
  "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,见 需求第三节后续迭代表。

POST /tasks/start-trials

管理页面以带 CSRF 的表单提交结构化任务版本列表:

{
  "start_key": "<幂等键>",
  "tasks": [
    { "id": "018f...", "expected_task_version": 1 },
    { "id": "0190...", "expected_task_version": 3 }
  ]
}
  • 只接受当前状态为 DRAFT 的任务;该动作含义是允许 desk 端领取第一趟试选,不签发下单 授权、不建立提交围栏、不创建订单、不付款。
  • 服务端在一个事务内校验全部任务存在、属于当前管理范围、状态仍为 DRAFT 且版本匹配, 然后统一转 PENDING 并递增版本。任一项失败返回 409 conflict,整批不产生部分成功。
  • tasks 为空或包含重复 id 返回 400 invalid_argument;重复 start_key 返回第一次的结果, 不重复推进版本。
  • SSR 成功后 303 返回原任务列表查询地址;冲突时保留筛选条件,刷新表格并要求重新选择。

POST /tasks/{id}/order-authorizations

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

二、设备侧接口(desk 端调用)

全部要求有效设备 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

{ "device_id": "desk-01", "claim_key": "<幂等键>" }

成功:

{
  "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}/spec-trial(第一趟回传)

{
  "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 上传。
  • 服务端接收后创建 spec_trials 记录,任务转 WAITING_CONFIRMATION。

dry-run 与真实提交协议

POST /api/v1/tasks/{id}/order-dry-runs/start 创建或重放一次演练记录。desk 端随后只允许 进入订单确认页、读取非敏感摘要和验证提交控件唯一,不允许点击。完成后调用 POST /api/v1/order-dry-runs/{rid}/ready:

{
  "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 只报布尔值,不得回传地址原文或手机号。

真实第二趟重新通过三道闸门后,desk 端在点击前调用 POST /api/v1/tasks/{id}/order-submissions/start:

{
  "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,desk 端才允许点击一次。 超时、网络错误、冲突或响应无法解析时不得点击,转人工查询该幂等键。
  • dry_run_id 只证明曾完成安全演练;服务端仍以本次真实提交请求携带的闸门读数复核。

点击后调用 POST /api/v1/order-submissions/{sid}/reconcile:

{
  "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 的内容,防止凭据误入审计日志。
  • 超长必须由客户端截断后再发,服务端拒绝而不是静默截断。

三、desk 端本地模块合约

TaskSource / ResultSink

执行器只依赖抽象,不认识来源:

class TaskSource(ABC):
    @abstractmethod
    def load_tasks(self) -> list[OrderTask]: ...

class ResultSink(ABC):
    @abstractmethod
    def save_task_result(self, task: OrderTask) -> None: ...

实现:

实现 用途
HttpTaskSource 从 web 端领取任务(默认)
HttpResultSink 回传结果到 web 端(默认)
FixtureTaskSource 仅测试 / 演示:读取仓库内假数据,不接触真实订单
JsonlResultSink 仅测试 / 断连暂存:本地追加写入,恢复连接后按幂等键补传

真机流程模块

desk/src/android/pdd_flow.py 的公开入口,每个都不得越界:

函数 输入 输出 副作用边界
open_product(url) 商品 URL 页面快照路径 只打开页面,不点击购买
open_sku_panel() - 面板快照 只点规格入口,不提交
select_sku_options(items) {维度: 值} 选中证据 按维度精确匹配,找不到抛错
set_quantity(n) 数量 读回值 必须复核等于 n
read_sku_unit_price(xml) 规格面板 XML 单价或 None 闸门一;读不到返回 None,不猜
leave_product() - - 第一趟结束时退出并释放手机
go_to_order_confirm() - 确认页摘要 可能创建订单,需显式授权
read_order_confirm_info(xml) 页面 XML 非敏感摘要 闸门三;不提取地址原文、手机号
submit_order(auth, submission) 授权 + 已建立的提交围栏 提交结果 唯一创建真实订单入口,四条件与围栏全通过后只点一次

两个不可逆入口:

  • go_to_order_confirm() 必须校验授权存在;submit_order() 还必须校验授权已由服务端围栏 且 submission 与当前任务、命令、授权完全一致。
  • 第一趟的代码路径不得引用这两个函数。 必须有测试证明试选流程不可达它们。
  • search_by_image() 属 B 路径,V2 再实现。

四、待实现时确认

  • 规格面板上单价的节点位置与文本形态(阻塞闸门一,由 T-103 真机取证确定)。
  • 授权 expires_at 的默认时长。
  • 定时轮询的默认间隔与连续失败停止阈值。
  • 分页游标的编码方式。
  • 设备凭据的有效期与轮换策略。
  • 证据资产的保留期与清理策略。