Files
cmautobuy/docs/client/04-admin-api-contract.md
T

19 KiB
Raw Blame History

04 Client–Admin API v1 契约

  • 文档状态:基线草案,待 Client 与 Admin 联合评审
  • 基础路径:/api/v1/client
  • 编码:UTF-8 JSON
  • 时间:带时区 ISO 8601,服务端优先返回 UTC
  • 金额:人民币分整数

本文档中 [必须] / [建议] / [待定] 的含义见 文档索引。没有标注的默认是 [必须]。看不懂的词(幂等、Outbox)查 术语表。

1. 设计原则

核心:Admin 是调度器,Client 是执行器,执行器不参与调度决策。

  • Client 的任务流程只有三个动作:领一个任务、提交结果、提交失败。 设置页另有一个幂等 Client 登记动作。没有任何"去问 Admin 现在怎么想"的调用。
  • Client 拿到任务就做完,中途不管任务是否被取消、是否被重派。
  • Admin 负责任务的创建、更新、取消和派发(含重派),这些 Client 一律不感知。
  • 结果提交必须幂等;网络重试不能创建重复结果。
  • Admin 必须无条件接受已派发过的 Client 提交的结果,理由见 §6.1。
  • 任务和结果使用版本化结构,未知字段应允许向前兼容。
  • Admin 业务错误返回稳定错误代码,不要求 Client 解析自然语言判断逻辑。

1.1 为什么没有租约和心跳

早期方案有租约(lease)和心跳,用来防止两个 Client 做同一个任务导致重复下单。后来去掉了,原因是:

本项目不自动付款(见 01 需求 §9),采购止于创建订单。 重复下单产生的是重复的未付款订单,人工审核时不付即可,代价和"白干一场"是一个量级。 为这点代价引入租约、心跳、过期判断和一整套中断逻辑,不划算。

Admin 想知道某个 Client 是不是卡死了,用领取后超时重派即可——这完全在 Admin 侧,Client 不参与。

注意:去掉租约不代表去掉防重复下单。防的是本机崩溃重启后重复下单, 靠 task_runs.irreversible_action_at 标记,见 03 数据模型 §7.3。那套机制反而更重要了。

2. 通用请求头

Authorization: Bearer <token>
X-Client-Id: client-001
X-Request-Id: <uuid>
Content-Type: application/json

结果和失败提交额外携带:

Idempotency-Key: <stable-key>

认证方式仍待 Admin 联合评审。无论最终采用哪种方式,访问令牌不得写入普通日志。

3. 通用错误

{
  "error": {
    "code": "IDEMPOTENCY_CONFLICT",
    "message": "相同幂等键提交了不同内容",
    "retryable": false,
    "request_id": "7a5d...",
    "details": {}
  }
}

建议状态码:

状态码 场景
400 请求结构或字段无效
401 未认证或凭据过期
403 Client 无权访问任务(从未派发给它)
404 任务不存在
409 幂等冲突
422 业务规则不满足
429 请求过于频繁
500/503 Admin 暂时故障,可按策略重试

注意 403 的含义:只有从未派发给这个 Client 的任务才返回 403。 任务已取消、已重派给别人,都不能返回 403,见 §6.1。

4. 任务对象

{
  "id": "cg1",
  "type": "purchase",
  "version": 3,
  "priority": 10,
  "payload": {
    "goods_id": "737116531267",
    "goods_url": "https://mobile.yangkeduo.com/goods.html?goods_id=737116531267",
    "expected_title": "商品标题",
    "options": {
      "color": "黑色",
      "size": "L"
    },
    "quantity": 2,
    "expected_price_cent": 3990,
    "max_price_cent": 4200
  },
  "created_at": "2026-08-06T07:00:00Z",
  "updated_at": "2026-08-06T07:05:00Z"
}

采集任务的 payload 不要求颜色、尺码、数量和价格字段。采购任务必须包含 quantity 以及 max_price_cent 或等价的明确价格保护规则。max_price_cent 是人民币订单总价上限(整数分),不是商品单价;例如数量 2、单价上限 ¥11.80 时必须下发 2360。Client 只在最终订单确认页用页面订单总价与该上限比较,商品页或规格面板上含义不稳定的单价/小计不能直接用于总价超限判断。

任务对象里不含 Admin 侧状态。Client 不关心 Admin 那边把它标成什么,只管做完提交。

4.1 登记或更新 Client

PUT /api/v1/client/registration
X-Client-Id: <stable-client-id>
X-Request-Id: <uuid>

请求:

{
  "client": {
    "name": "办公室-01"
  },
  "supported_types": ["collect", "purchase"],
  "device": {
    "address": "192.168.0.173:5555",
    "platform": "android",
    "pdd_package": "com.xunmeng.pinduoduo"
  },
  "capabilities": {
    "purchase_mode": "dry_run",
    "schema_versions": [1]
  }
}

成功统一返回 200 OK:

{
  "registered": true,
  "client_id": "CLIENT-123456",
  "registered_at": "2026-08-06T09:00:00Z"
}

规则:

  • [必须] X-Client-Id 是 Client 唯一键;相同编号重复 PUT 执行幂等 upsert,不创建重复记录。
  • [必须] 登记接口不读取、领取或修改任务,也不返回任务。
  • [必须] client.name 可选且最多 50 字。显式登记携带非空名称时允许更新 Admin 名称;空名称更新保留已有名称,新建时用 Client ID 兜底。
  • [必须] 更新设备、能力、last_seen_at 和 updated_at。
  • [必须] supported_types 非空且只包含 collect、purchase;purchase_mode 只允许 dry_run、live;schema_versions 只包含正整数。
  • [必须] Client 必须先把设备号和名称保存到 SQLite,再在后台线程调用本接口;远端失败不得回滚本地保存。
  • [必须] 设备号生成后稳定复用,不向 Admin 发送生成设备号所用的原始硬件参数。
  • [必须] 不增加定时心跳。在线状态仍由登记、领取和提交产生的 last_seen_at 派生。

错误至少包括 MISSING_CLIENT_ID、INVALID_BODY、INVALID_CLIENT_PROFILE 和 CLIENT_REGISTER_FAILED,并使用 §3 的统一结构。

claim 继续支持隐式登记作为旧 Client 的兼容兜底:新编号可以创建 Client,已有 Client 只更新设备、能力和最近活动时间,不覆盖名称。

5. 领取任务

task.id 是 Admin 分配的不透明稳定字符串,当前采集编号为 cjN、 采购编号为 cgN。Client 必须原样写入 remote_task_id 并原样回传, 不校验历史前缀,不从编号推导类型或排序;任务类型以 task.type 为准。

POST /api/v1/client/tasks/claim

请求:

{
  "client": {
    "name": "办公室-01"
  },
  "supported_types": ["collect", "purchase"],
  "device": {
    "address": "192.168.0.173:5555",
    "platform": "android",
    "pdd_package": "com.xunmeng.pinduoduo"
  },
  "capabilities": {
    "purchase_mode": "dry_run",
    "schema_versions": [1]
  }
}

有任务时返回 200:

{
  "task": {
    "id": "COLLECT-20260810-0001",
    "type": "collect",
    "execution_mode": "dry_run",
    "version": 1,
    "priority": 10,
    "payload": {
      "goods_id": "737116531267",
      "goods_url": "https://mobile.yangkeduo.com/goods.html?goods_id=737116531267"
    }
  }
}

无可领取任务时返回 204 No Content。

Client 的 HttpAdminGateway.claim_next 已实现本接口,并原样序列化应用层已经 安全确认的 ClaimCapabilities。没有采购 Adapter 或设备未准备好时只声明 supported_types: ["collect"];条件满足时声明 collect,purchase。Client 不发送手工 真实采购启用请求;当前 Client 身份有效、已保存 Android 设备且 live Adapter 就绪时自动声明 purchase_mode: "live",其他情况均为 dry_run。一次调用最多领取一个任务,结果或失败通过 本页 §6 或 §7 提交,完整请求会先进入本地 Outbox。

采购响应在写入本地前必须校验非空 goods_url、goods_id、动态 options、 正整数 quantity 和人民币订单总价上限(正整数分)max_price_cent。字段不完整时停止新的领取并向 操作员显示协议错误;不得让不完整任务进入手机执行。

[必须] 204 不是错误。 Client 要把它当"暂时没活干"处理, 不要报错,也不要因此触发重试风暴。首次 claim 通常返回 204;如果该编号已经预先分配任务,也可以直接返回 200。

client.name 是给人看的显示名,可以不填(新建时 Admin 用 X-Client-Id 兜底)。 在 claim 兼容登记中,它只在首次创建时被采纳,已有 Client 的名称不会被后台领取覆盖;用户通过 §4.1 显式保存非空名称时可以更新。

怎么把这条链路跑通、怎么确认设备登记成功,见 Admin 侧的设备登记联调手册。

规则:

  • [必须] 领取必须在 Admin 内部原子完成,一次调用最多返回一个任务。
  • [必须] Admin 可以把真实采购任务预先指派给当前账号可见的任意 Client;领取时仍不得把 live 任务返回给只声明 dry_run 的 Client。
  • [必须] execution_mode 只允许 dry_run、live。Admin Web 新建采购任务固定为 live;历史任务和接口缺省兼容值仍是 dry_run,Client 不得自行把模式从演练提升为真实。
  • [必须] 声明 dry_run 的 Client 只能领取 dry_run;声明 live 的 Client 可以领取两种模式。能力只缩小可领取范围,不修改任务模式。
  • [必须] 响应不包含租约。Client 拿到任务就开始做,做完再来领下一个。

5.1 Admin 侧的分配语义

任务分两种,claim 两种都会返回:

类型 assigned_client Admin 侧状态 谁能领
指定分配 某个 Client 编号 assigned 只有那个 Client
无主 空 pending 谁先抢到算谁的,领取时才记下领取者

领取还必须同时满足执行能力:dry_run Client 永远看不到 live 任务; live Client 可以领取 dry_run 和 live。Admin Web 创建真实采购任务时显式指定 当前账号可见的 Client,不要求创建时已经声明 live;未就绪或离线 Client 的任务保持 assigned,直到该 Client 自动声明 live 后领取,因此真实任务不会进入无主任务池。

[必须] 指定给本机的优先于无主的。 显式分配是人为决定,应当先兑现; 无主任务谁抢都一样,可以等。

哪种任务用哪种方式:

  • 采集任务不指定客户端。 采集只是浏览商品页,没有副作用,哪台设备采都一样, 没必要每次都挑一台。
  • 采购任务可以指定,也允许留空。 涉及钱和账号——不同设备可能登着不同的 拼多多账号,买到谁头上是有区别的。需要指定账号时必须显式分配, 留空就意味着接受"谁先抢到谁去下单"。

Client 侧对这两种没有任何区别:调 claim,拿到任务就做,做完提交。 不需要知道这个任务原来有没有主。

因此 Client 完全不需要看到全局任务池,本地也不缓存未领取的任务 (见 03 数据模型 §3.1)。操作人员想知道队列里还有多少活, 去 Admin 自己的界面看(01 需求 §9 已把 Admin 界面列为非目标)。

Admin 可以在超时后把任务重派给别的 Client,Client 侧对此无感知也不需要感知。

5.2 已知缺口:领取成功但本地保存失败

Admin 返回任务时已经把它改成 claimed。Client 随后写 SQLite,如果磁盘或数据库 此时失败,服务端任务会处于已领取、本机却没有执行记录的状态。

当前处理方式:界面持续显示任务编号和本地保存错误,要求操作人员记录编号并联系 维护者;不得静默继续领取下一条。自动补偿或 Admin 侧回收由后续独立工单处理。

6. 提交成功结果

POST /api/v1/client/tasks/{task_id}/result
Idempotency-Key: task-id:attempt-id:result-v1

采集结果:

{
  "task_version": 3,
  "attempt_id": "attempt-uuid",
  "result_type": "collect",
  "completed_at": "2026-08-06T08:03:00Z",
  "pdd_data": {
    "goods_id": "737116531267",
    "title": "测试商品",
    "shop_name": "XX旗舰店",
    "price_granularity": "color",
    "dimensions": [
      {"key": "color", "name": "颜色分类"},
      {"key": "size", "name": "尺码"}
    ],
    "skus": [{
      "options": {"color": "黑色", "size": "M"},
      "price_cent": 470,
      "list_price_cent": 1990,
      "price_observed_at": {"color": "黑色"},
      "available": true,
      "raw_price": "折后¥4.7"
    }]
  }
}

采购结果把 result_type 换成 purchase,并按 03 数据模型 §8.2 携带 purchase。真实采购成功必须同时满足 mode=live、 order_submitted=true、payment_attempted=false、payment_status=unpaid、 match_status=matched,并包含非空订单编号和带时区下单时间。0 个或多个候选、 订单编号或时间无效、下单时间超出本地提交时间前后 5 分钟及非未付款订单改走 §7 人工处理,不得提交采购成功结果。商品、规格、数量和金额使用任务及下单前确认 快照,不要求订单详情页面重复提供。

响应:

{
  "accepted": true,
  "result_id": "result-uuid",
  "accepted_at": "2026-08-06T08:03:01Z"
}

规则:

  • [必须] 相同 Idempotency-Key 和相同请求内容必须返回同一业务结果。
  • [必须] 相同键但不同内容返回 409 IDEMPOTENCY_CONFLICT。
  • [必须] Client 只有收到 accepted: true 后才能把本地任务标为 succeeded。
  • [必须] price_cent 是实际支付价的整数分;划线价可放在 list_price_cent。
  • [必须] price_granularity 只能是 color 或 sku。当前 Client 按颜色采样时填 color;同一颜色的尺码组合可以共享颜色价格,但 price_observed_at 只填写实际选中的颜色,不得虚构尺码。以后逐个选择完整组合采价时才使用 sku。
  • [必须] 采集任务中,颜色无法选中或未采到稳定价格时,相关 price_cent、raw_price 和 list_price_cent 允许为 null,price_observed_at 允许为空对象;Admin 不得仅因部分或全部颜色缺价拒绝结果。
  • [必须] shop_name 采不到时允许省略或留空;Admin 不得因此拒绝老版本 Client,也不得用空值覆盖已保存的店铺名。

6.1 Admin 必须无条件接受

这是对 Admin 侧的强约束,实现时最容易被顺手违反,务必写进 Admin 的验收标准:

  • [必须] Admin 不得因为任务已取消而拒绝结果。
  • [必须] Admin 不得因为任务已重派给别的 Client 而拒绝结果。
  • [必须] Admin 必须能接受同一任务来自多个 Client 的多份结果。怎么去重、以哪份为准,是 Admin 内部的事,不要求 Client 配合。
  • [必须] 只要这个任务曾经派发给该 Client,提交就必须被接受。只有从未派发过才返回 403。

原因:Client 中途不查任务状态(§1),所以它必然会提交一些"Admin 那边已经不要了"的结果。 如果 Admin 拒收,Client 侧就会出现大量无法处理的失败——而 Client 已经真的把事情做完了, 甚至可能已经下了单,这些数据必须能交上去留痕。

accepted: true 的含义是"我收到并存下了",不代表 Admin 认可这个任务仍然有效。 任务到底算不算数,由 Admin 和人工审核决定,与 Client 无关。

7. 提交失败或人工处理结果

POST /api/v1/client/tasks/{task_id}/failure
Idempotency-Key: task-id:attempt-id:failure-v1
{
  "task_version": 3,
  "attempt_id": "attempt-uuid",
  "status": "manual_review",
  "error": {
    "code": "AMBIGUOUS_ORDER_MATCH",
    "message": "发现多个可能属于当前任务的订单",
    "retryable": false,
    "step": "reconcile_order"
  },
  "diagnostics": {
    "artifact_ids": ["artifact-uuid"]
  },
  "reported_at": "2026-08-06T08:03:00Z"
}

status 只能是 retry_wait、manual_review、failed 或 cancelled。

成功响应与结果接口使用同一种确认结构,并额外返回任务状态:

{
  "accepted": true,
  "result_id": "result-uuid",
  "task_status": "assigned",
  "accepted_at": "2026-08-06T08:03:01Z"
}

[必须] result_id 非空。旧 Admin 已经写入幂等表、但没有 result_id 的 历史失败响应,Client 可按 accepted + task_status + accepted_at 兼容一次, 避免把已经接收的数据误报为拒绝。

规则:

  • [必须] §6.1 的无条件接受规则同样适用于本接口。
  • [必须] Admin 决定是否创建新的执行机会,但对已经可能下单的任务不得通过普通重试触发再次购买。

8. 幂等与重试

  • [必须] 结果和失败提交必须使用持久化的 Idempotency-Key,格式见 03 数据模型 §5.2。
  • [必须] Client 在发送前将完整请求写入 Outbox;重试使用相同键和相同内容。
  • [必须] claim 需要 Admin 保证重复请求不会一次分配出多个任务。
  • [建议] 对 429、500 和 503 使用有上限的指数退避,并遵守 Retry-After。
  • [必须] 对认证、幂等冲突和业务校验错误不得无限重试。

9. Mock Admin 要求

MockAdminGateway 与 HTTP 实现暴露同一应用层接口。登记和三个任务方法都必须使用同一契约:

register_client(client, capabilities)             # §4.1
claim_next(client, capabilities)                  # §5
submit_result(task_id, idempotency_key, result)   # §6
submit_failure(task_id, idempotency_key, failure) # §7

Mock 必须支持:

  • 无任务可领取(claim 返回 204);
  • 采集和采购任务;
  • 网络超时和暂时故障;
  • 幂等重复提交(相同键相同内容);
  • 幂等冲突(相同键不同内容);
  • 提交一个 Admin 侧已取消的任务,仍返回 accepted: true(用来验证 §6.1);
  • 结果校验失败。

Mock 测试通过不能替代与真实 Admin 的契约测试。

10. 待联合确认

下面这些仍需联合确认。不许因此停工——先按“临时默认值”实现,Mock Gateway 也按这个值模拟,等定了再按工单改。

# 待确认什么 临时默认值(先这么做)
1 认证、Client 注册和凭据刷新方式 请求头预留 Authorization: Bearer <token>,token 从设置读;Mock 不校验
2 Admin 对人工处理任务的后续操作 Client 只负责报告 manual_review 然后停手,不猜 Admin 会怎么处理
3 Artifact 是独立上传还是只报本地引用 只报本地引用(路径 + 哈希),不实现上传
4 Admin 超时重派的等待时长 纯 Admin 侧策略,Client 不参与也不需要知道

已定案(不再是待确认项):

  • 任务分「指定分配」和「无主」两种,claim 两种都返回,指定的优先。 采集任务不指定,采购任务可指定可留空。Client 不读全局任务池。见 §5.1。
  • 没有租约、没有心跳、没有状态回查。 任务流程只有 §5 / §6 / §7 三个调用;设置页另有 §4.1 的幂等登记调用。
  • Admin 必须无条件接受已派发过的 Client 提交的结果。见 §6.1。

改动这张表里任何一条,都属于会影响业务结果的变更,必须先更新工单并经用户确认。