14 KiB
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的默认时长。 - 定时轮询的默认间隔与连续失败停止阈值。
- 分页游标的编码方式。
- 设备凭据的有效期与轮换策略。
- 证据资产的保留期与清理策略。