# 04 Client–Admin API v1 契约 - 文档状态:基线草案,待 Client 与 Admin 联合评审 - 基础路径:`/api/v1/client` - 编码:UTF-8 JSON - 时间:带时区 ISO 8601,服务端优先返回 UTC - 金额:人民币分整数 本文档中 `[必须]` / `[建议]` / `[待定]` 的含义见 [文档索引](../README.md#文档标注说明)。没有标注的默认是 `[必须]`。看不懂的词(幂等、Outbox)查 [术语表](00-glossary.md)。 ## 1. 设计原则 **核心:Admin 是调度器,Client 是执行器,执行器不参与调度决策。** - Client 的常规任务流程只有三个动作:领一个任务、提交结果、提交失败。 采购规格无法精确选择时,允许在同一次执行中额外提交一次 §7.1 规格解析命令; 设置页另有一个幂等 Client 登记动作。**没有任何“去问 Admin 现在怎么想”的状态查询。** - Client 拿到任务就做完,中途不管任务是否被取消、是否被重派。 - Admin 负责任务的创建、更新、取消和派发(含重派),这些 Client 一律不感知。 - 结果提交必须幂等;网络重试不能创建重复结果。 - Admin **必须无条件接受**已派发过的 Client 提交的结果,理由见 §6.1。 - 任务和结果使用版本化结构,未知字段应允许向前兼容。 - Admin 业务错误返回稳定错误代码,不要求 Client 解析自然语言判断逻辑。 ### 1.1 为什么没有租约和心跳 早期方案有租约(lease)和心跳,用来防止两个 Client 做同一个任务导致重复下单。后来去掉了,原因是: **本项目不自动付款**(见 [01 需求](01-requirements.md) §9),采购止于创建订单。 重复下单产生的是重复的**未付款**订单,人工审核时不付即可,代价和"白干一场"是一个量级。 为这点代价引入租约、心跳、过期判断和一整套中断逻辑,不划算。 Admin 想知道某个 Client 是不是卡死了,用**领取后超时重派**即可——这完全在 Admin 侧,Client 不参与。 > 注意:去掉租约**不代表**去掉防重复下单。防的是**本机崩溃重启后重复下单**, > 靠 `task_runs.irreversible_action_at` 标记,见 [03 数据模型](03-data-model.md) §7.3。那套机制反而更重要了。 ## 2. 通用请求头 ```http Authorization: Bearer X-Client-Id: client-001 X-Request-Id: Content-Type: application/json ``` 结果、失败和运行时规格解析提交额外携带: ```http Idempotency-Key: ``` 认证方式仍待 Admin 联合评审。无论最终采用哪种方式,访问令牌不得写入普通日志。 ## 3. 通用错误 ```json { "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. 任务对象 ```json { "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 ```http PUT /api/v1/client/registration X-Client-Id: X-Request-Id: ``` 请求: ```json { "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`: ```json { "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` 为准。 ```http POST /api/v1/client/tasks/claim ``` 请求: ```json { "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`: ```json { "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/07-设备登记联调手册.md)。 规则: - `[必须]` 领取必须在 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 数据模型](03-data-model.md) §3.1)。操作人员想知道队列里还有多少活, 去 Admin 自己的界面看([01 需求](01-requirements.md) §9 已把 Admin 界面列为非目标)。 Admin 可以在超时后把任务重派给别的 Client,Client 侧对此**无感知也不需要感知**。 ### 5.2 已知缺口:领取成功但本地保存失败 Admin 返回任务时已经把它改成 `claimed`。Client 随后写 SQLite,如果磁盘或数据库 此时失败,服务端任务会处于已领取、本机却没有执行记录的状态。 当前处理方式:界面持续显示任务编号和本地保存错误,要求操作人员记录编号并联系 维护者;不得静默继续领取下一条。自动补偿或 Admin 侧回收由后续独立工单处理。 ## 6. 提交成功结果 ```http POST /api/v1/client/tasks/{task_id}/result Idempotency-Key: task-id:attempt-id:result-v1 ``` 采集结果: ```json { "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](03-data-model.md) 携带 `purchase`。真实采购成功必须同时满足 `mode=live`、 `order_submitted=true`、`payment_attempted=false`、`payment_status=unpaid`、 `match_status=matched`,并包含非空订单编号和带时区下单时间。0 个或多个候选、 订单编号或时间无效、下单时间超出本地提交时间前后 5 分钟及非未付款订单改走 §7 人工处理,不得提交采购成功结果。商品、规格、数量和金额使用任务及下单前确认 快照,不要求订单详情页面重复提供。 响应: ```json { "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. 提交失败或人工处理结果 ```http POST /api/v1/client/tasks/{task_id}/failure Idempotency-Key: task-id:attempt-id:failure-v1 ``` ```json { "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`。 成功响应与结果接口使用同一种确认结构,并额外返回任务状态: ```json { "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 决定是否创建新的执行机会,但对已经可能下单的任务不得通过普通重试触发再次购买。 ### 7.1 采购运行时规格解析命令 “运行时规格解析”是指:Client 已经打开当前 PDD 商品并选中颜色,但任务目标尺码 无法与页面上的可购买尺码精确对应时,把这一刻的候选快照交给 Admin 做一次受审计的 规格决策。它是**一次性 POST 业务命令**,不是任务状态查询、心跳或轮询;Client 不得 用它询问任务是否取消、是否重派或 AI 是否完成。 ```http POST /api/v1/client/tasks/{task_id}/spec-resolution Idempotency-Key: spec-resolution-v1: ``` 请求体 schema v1: ```json { "schema_version": 1, "task_version": 3, "attempt_id": "attempt-uuid", "pdd_goods_id": "937122477375", "original_options": {"color": "黑色", "size": "60公斤"}, "selected_color": "黑色", "target_size": "60公斤", "candidates": [ { "candidate_id": "c1", "raw_text": "120斤", "options": {"color": "黑色", "size": "120斤"} }, { "candidate_id": "c2", "raw_text": "130斤", "options": {"color": "黑色", "size": "130斤"} } ], "candidate_snapshot_hash": "64位小写十六进制 SHA-256", "observed_at": "2026-08-17T08:00:00Z" } ``` 字段和大小限制: | 字段 | 规则 | |---|---| | 整个 JSON 请求体 | UTF-8 编码后最多 64 KiB;未知字段允许忽略但不得改变已知字段含义 | | `schema_version` | v1 固定为 `1`;未知主版本拒绝,不猜测兼容 | | `task_version` | 正整数,必须等于领取到的任务版本 | | `attempt_id` | 1~191 个字符,同一次 `task_runs` 执行稳定复用 | | `pdd_goods_id` | 1~191 个字符,必须等于任务商品 | | `original_options` | 1~16 个字符串键值;键和值各 1~191 个字符,保持领取任务时的动态规格原文 | | `selected_color` / `target_size` | 各 1~191 个字符,不允许控制字符 | | `candidates` | 1~100 条,只提交当前颜色下页面显示为可购买的尺码,保持页面顺序 | | `candidate_id` | 严格按数组顺序使用 `c1`、`c2` … `c100`,不得跳号或重复 | | `raw_text` | PDD 页面原始尺码文字,1~191 个字符,不允许控制字符 | | `options` | v1 只含非空 `color`、`size`;`color` 必须等于 `selected_color`,`size` 必须逐字等于 `raw_text` | | `candidate_snapshot_hash` | 按下述算法计算的 64 位小写十六进制 SHA-256 | | `observed_at` | 带时区 ISO 8601;推荐 UTC | 候选短编号由 Client 按页面顺序生成,Admin 必须逐项验证编号、原文和 options,不能 接受模型自行增加、改写或重新编号后的规格。快照哈希使用长度前缀,避免分隔符碰撞: ```text frame(value) = UTF-8 字节长度的十进制文本 + ":" + value material = frame("spec-resolution-v1") + frame(pdd_goods_id) + frame(selected_color) + frame(候选数量的十进制文本) + 依页面顺序为每条候选追加: frame(candidate_id) + frame(raw_text) + frame(options.color) + frame(options.size) candidate_snapshot_hash = lowercase_hex(sha256(UTF-8(material))) ``` 幂等键同样是确定值,不直接拼接可能很长的任务编号: ```text identity_material = frame(task_id) + frame(attempt_id) + frame(candidate_snapshot_hash) + frame("spec-resolution-v1") Idempotency-Key = "spec-resolution-v1:" + lowercase_hex(sha256(UTF-8(identity_material))) ``` Admin 必须重新计算两个哈希。相同业务身份 `task_id + attempt_id + candidate_snapshot_hash` 只对应一条解析记录;相同键和相同内容 返回第一次保存的完整响应,相同键或相同业务身份携带不同内容返回 `409 IDEMPOTENCY_CONFLICT`。网络超时可以用原键和原请求重放,但 Client 不得修改内容后 沿用旧键,也不得循环请求等待结果。 成功处理统一返回 `200 OK`。`failed` 是已经持久化的业务结论,不是 HTTP 500: ```json { "schema_version": 1, "resolution_id": "psr-uuid", "outcome": "matched", "source": "ai", "candidate_snapshot_hash": "请求中的同一哈希", "match": { "candidate_id": "c1", "raw_text": "120斤", "options": {"color": "黑色", "size": "120斤"} }, "confidence_bps": 9300, "reason": "目标重量与候选范围唯一对应", "resolved_at": "2026-08-17T08:00:01Z" } ``` | `outcome` | 含义 | `match` | |---|---|---| | `matched` | 规则、AI 或历史解析得到唯一且通过服务端门禁的请求内候选 | 必须是请求候选的逐字副本 | | `uncertain` | 有分析结果,但不能唯一、安全地选中一个候选 | `null` | | `rejected` | 请求结构有效,但业务规则明确拒绝自动选择 | `null` | | `failed` | 解析服务超时、格式错误或其他已审计失败 | `null` | `source` 只允许 `rule`、`ai`、`reused` 或 `null`;`confidence_bps` 为 `0~10000` 整数或 `null`,`null` 表示该来源没有可比较的置信度,不能当作 0。`reason` 最多 500 个字符。只有 `matched` 可以返回非空 `match`,其编号、原文和 options 必须逐字来自 本次请求;响应不得包含模型生成的新规格。Client 仍须在继续前重新读取页面、复算快照并 精确核对,不能因为 Admin 返回 matched 就跳过既有数量、总价、地址、不可逆标记和单次 提交门禁。 稳定错误至少包括: | HTTP | `error.code` | 场景 | |---|---|---| | `400` | `INVALID_BODY` | 非法 JSON 或请求体超过 64 KiB | | `400` | `INVALID_SPEC_RESOLUTION_SCHEMA` | `schema_version` 不支持 | | `422` | `INVALID_SPEC_RESOLUTION_REQUEST` | 字段长度、候选数量、候选编号或 options 无效 | | `422` | `SPEC_RESOLUTION_HASH_MISMATCH` | 快照哈希或确定性幂等键与字段不一致 | | `404` | `TASK_NOT_FOUND` | 任务不存在 | | `422` | `TASK_NOT_PURCHASE` | 不是采购任务 | | `409` | `TASK_VERSION_CONFLICT` | 请求任务版本与已领取版本不一致 | | `422` | `PDD_GOODS_MISMATCH` | PDD 商品 ID 与任务不一致 | | `403` | `TASK_NOT_CLAIMED_BY_CLIENT` | 该 Client 从未领取过此任务 | | `409` | `IDEMPOTENCY_CONFLICT` | 相同幂等身份提交了不同内容 | | `503` | `SPEC_RESOLUTION_UNAVAILABLE` | 在解析记录落库前 Admin 暂不可用,可有限重试原请求 | 任务已取消、重派或结束本身不是本命令的查询条件;只要该 Client 曾领取任务且任务身份 仍能核对,Admin 可以保存解析审计,但**不得修改任务状态**。候选观察先用短事务持久化; 规则/AI 调用不得占用数据库事务,最终决策和安全重放响应必须与 `idempotency_keys` 在同一 短事务提交。该记录不得覆盖 `pdd_products.skus_json`,也不得 保存原始无障碍 XML、截图、订单号、收货信息、Cookie、Token 或 API Key。旧 Client 不 调用本接口,继续按“规格不匹配即提交失败”的既有流程运行。 ## 8. 幂等与重试 - `[必须]` 结果、失败和运行时规格解析提交必须使用持久化的 `Idempotency-Key`;前两者格式见 [03 数据模型](03-data-model.md) §5.2,规格解析格式见 §7.1。 - `[必须]` Client 在发送前将完整请求写入 Outbox;重试使用相同键和相同内容。 - `[必须]` `claim` 需要 Admin 保证重复请求不会一次分配出多个任务。 - `[建议]` 对 `429`、`500` 和 `503` 使用有上限的指数退避,并遵守 `Retry-After`。 - `[必须]` 对认证、幂等冲突和业务校验错误不得无限重试。 ## 9. Mock Admin 要求 `MockAdminGateway` 与 HTTP 实现暴露同一应用层接口。登记、三个常规任务方法和一次性规格解析命令都必须使用同一契约: ```text 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 resolve_purchase_spec(task_id, idempotency_key, observation) # §7.1 ``` Mock 必须支持: - 无任务可领取(`claim` 返回 204); - 采集和采购任务; - 网络超时和暂时故障; - 幂等重复提交(相同键相同内容); - 幂等冲突(相同键不同内容); - 规格解析的 matched / uncertain / rejected / failed,以及候选哈希不一致; - **提交一个 Admin 侧已取消的任务,仍返回 `accepted: true`**(用来验证 §6.1); - 结果校验失败。 Mock 测试通过不能替代与真实 Admin 的契约测试。 ## 10. 待联合确认 下面这些仍需联合确认。**不许因此停工**——先按“临时默认值”实现,Mock Gateway 也按这个值模拟,等定了再按工单改。 | # | 待确认什么 | 临时默认值(先这么做) | |---|---|---| | 1 | 认证、Client 注册和凭据刷新方式 | 请求头预留 `Authorization: Bearer `,token 从设置读;Mock 不校验 | | 2 | Admin 对人工处理任务的后续操作 | Client 只负责报告 `manual_review` 然后停手,不猜 Admin 会怎么处理 | | 3 | Artifact 是独立上传还是只报本地引用 | **只报本地引用**(路径 + 哈希),不实现上传 | | 4 | Admin 超时重派的等待时长 | 纯 Admin 侧策略,Client 不参与也不需要知道 | **已定案(不再是待确认项):** - 任务分「指定分配」和「无主」两种,`claim` 两种都返回,指定的优先。 采集任务不指定,采购任务可指定可留空。Client 不读全局任务池。见 §5.1。 - **没有租约、没有心跳、没有状态回查。** 常规任务流程仍是 §5 / §6 / §7; §7.1 只是在同一次采购规格无法精确选择时提交候选并同步取得持久化决策,设置页另有 §4.1 的幂等登记调用。 - Admin 必须无条件接受已派发过的 Client 提交的结果。见 §6.1。 改动这张表里任何一条,都属于会影响业务结果的变更,必须先更新工单并经用户确认。