PDD 商品页要能「创建采集任务」而不指定客户端——采集只是浏览商品页,
没有副作用,哪台设备采都一样。但原来的查询是
WHERE assigned_client = ? AND status = 'assigned'
无主任务(assigned_client 为空 + pending)永远没人能领,建出来就是死的。
改动
- ClaimNextTask 同时查两种:指定给本机的 + 无主的
- 排序 ORDER BY (assigned_client IS NULL), priority DESC, created_at
——指定给本机的优先。显式分配是人为决定,应当先兑现
- 原子更新两种情况合成一条语句:对"指定给我的"写 assigned_client
是写同一个值无副作用;对无主的,这一步就是"谁领到就标记谁"
- 表结构不用动(assigned_client 本来可空,status 已有 pending)
推翻了一条已定案的规则
Client 契约 §5.1 原写「Admin 只把任务分配给指定的 Client」,
现改为两种并存并说明各自适用场景:
- 采集任务不指定客户端
- 采购任务可指定可留空。涉及钱和账号——不同设备可能登着不同的
拼多多账号,需要指定账号时必须显式分配,留空即接受"谁先抢到谁下单"
Client 侧对两种没有区别,不需要知道任务原来有没有主。
已验证(Go 1.23.0)
- 新增 7 个测试,全量 62 个全过
- 并发抢占用例重复 20 次稳定:8 个客户端抢同一条无主任务,
正好 1 个拿到,且 assigned_client 记的就是那个赢家
- 既有测试未受影响,"只领分配给自己的"仍然成立
一处仍未解决的风险(已记入 #17 风险表)
tasks 表没有字段标记"该任务需要真实下单",所以契约里
"不向 dry_run 客户端分配真实下单任务"实际无法执行。
真实下单开关关闭时不出问题,开启前必须补该字段。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
14 KiB
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": "PDD-20260806-0001",
"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 或等价的明确价格保护规则。
任务对象里不含 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. 领取任务
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": {}
}
无可领取任务时返回 204 No Content。
[必须] 204 不是错误。 Client 要把它当"暂时没活干"处理,
不要报错,也不要因此触发重试风暴。首次 claim 通常返回 204;如果该编号已经预先分配任务,也可以直接返回 200。
client.name 是给人看的显示名,可以不填(新建时 Admin 用 X-Client-Id 兜底)。
在 claim 兼容登记中,它只在首次创建时被采纳,已有 Client 的名称不会被后台领取覆盖;用户通过 §4.1 显式保存非空名称时可以更新。
怎么把这条链路跑通、怎么确认设备登记成功,见 Admin 侧的设备登记联调手册。
规则:
[必须]领取必须在 Admin 内部原子完成,一次调用最多返回一个任务。[必须]Admin 不得向只声明dry_run的 Client 分配要求真实下单的任务。[必须]响应不包含租约。Client 拿到任务就开始做,做完再来领下一个。
5.1 Admin 侧的分配语义
任务分两种,claim 两种都会返回:
| 类型 | assigned_client |
Admin 侧状态 | 谁能领 |
|---|---|---|---|
| 指定分配 | 某个 Client 编号 | assigned |
只有那个 Client |
| 无主 | 空 | pending |
谁先抢到算谁的,领取时才记下领取者 |
[必须] 指定给本机的优先于无主的。 显式分配是人为决定,应当先兑现;
无主任务谁抢都一样,可以等。
哪种任务用哪种方式:
- 采集任务不指定客户端。 采集只是浏览商品页,没有副作用,哪台设备采都一样, 没必要每次都挑一台。
- 采购任务可以指定,也允许留空。 涉及钱和账号——不同设备可能登着不同的 拼多多账号,买到谁头上是有区别的。需要指定账号时必须显式分配, 留空就意味着接受"谁先抢到谁去下单"。
Client 侧对这两种没有任何区别:调 claim,拿到任务就做,做完提交。
不需要知道这个任务原来有没有主。
因此 Client 完全不需要看到全局任务池,本地也不缓存未领取的任务 (见 03 数据模型 §3.1)。操作人员想知道队列里还有多少活, 去 Admin 自己的界面看(01 需求 §9 已把 Admin 界面列为非目标)。
Admin 可以在超时后把任务重派给别的 Client,Client 侧对此无感知也不需要感知。
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": {}
}
采购结果把 result_type 换成 purchase,其余结构相同。
响应:
{
"accepted": true,
"result_id": "result-uuid",
"accepted_at": "2026-08-06T08:03:01Z"
}
规则:
[必须]相同Idempotency-Key和相同请求内容必须返回同一业务结果。[必须]相同键但不同内容返回409 IDEMPOTENCY_CONFLICT。[必须]Client 只有收到accepted: true后才能把本地任务标为succeeded。
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。
规则:
[必须]§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. 待联合确认
Admin 还没做完,下面这些要和 Admin 一起定。不许因此停工——先按"临时默认值"实现,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。
改动这张表里任何一条,都属于会影响业务结果的变更,必须先更新工单并经用户确认。