Files

27 KiB
Raw Permalink Blame History

API 合约

本文是采购服务与采购工具之间的唯一线协议权威。页面判据和本地类名不是线协议。 接口形状参考前序项目的经验,但所有状态与安全语义在 cmbuyer 重新定义、测试和取证。

通用约定

  • 生产前缀:/api/v1;管理页面路由见 routes.md。
  • JSON 使用 UTF-8;时间为 UTC RFC 3339;ID 为 UUID 字符串。
  • 金额均为规范十进制字符串,如 "12.88";禁止 JSON number 和浮点计算。
  • 所有写接口接受 request_id / 业务幂等键;同键同载荷重放同一结果,同键异载荷返回 409。
  • 任务写入携带 expected_task_version;版本冲突返回 409 version_conflict。
  • 服务端错误不得回显设备 token、完整节点树、地址、手机号或支付信息。

身份

身份 凭据 能力
管理员 HttpOnly; SameSite=Lax 会话 cookie + CSRF;HTTPS 部署设 Secure=true 建单、开始采购、查看内部证据、人工调和
设备 Authorization: Bearer <device-token> + X-CMBuyer-Device-ID 心跳、领取、事件、截图、围栏与结果
ERP(V2) 独立凭据 只读来源同步,不访问采购结果

设备凭据不能建单或开始采购;管理会话不能调用设备接口。凭据缺失或无效返回 401,已认证但无权 或管理写请求缺少有效 CSRF 返回 403;认证存储故障按下述规则返回 503。

设备请求的认证头采用以下固定格式:

  • Authorization 和 X-CMBuyer-Device-ID 必须各出现且只出现一次;代理合并出的逗号列表也拒绝。
  • Authorization scheme 按 HTTP 规则大小写不敏感,但 scheme 后只允许一个 ASCII 空格;token 必须是 加密随机生成的 32 字节值对应的 64 位小写十六进制文本。
  • 设备 id 必须是规范小写 UUIDv4。token 与设备 id 同时绑定,未知、错配、格式错误和已撤销均返回 空 401,可带 WWW-Authenticate: Bearer,不区分具体原因。
  • 认证器逐请求读取 SQLite,不缓存 ACTIVE 结论。SQLite 查询或连接故障返回空 503;401 与 503 都必须发生在 Content-Type 解析和 body 读取之前。
  • 管理 cookie 不替代设备凭据;Bearer 也不替代管理 session/CSRF。两类凭据同时出现时,各路由仍只 采用自己的身份域,不把权限相加。

设备 token 由本机管理 CLI 签发,只在签发事务提交后向操作者显示一次;SQLite 仅保存 token 原始 32 字节的 SHA-256(32 字节 BLOB),list/revoke、日志、错误和 HTTP 响应均不显示 token 或 hash。 撤销幂等且不会恢复旧 token。MVP 服务只绑定 IPv4 回环 127.0.0.1:8080,设备 Bearer 只经过本机回环 HTTP; 未来非回环访问必须先建立 HTTPS/TLS 终止与代理信任边界。

错误响应

{
  "error": {
    "code": "version_conflict",
    "message": "任务已变化,请刷新后重选",
    "retryable": false,
    "request_id": "c3c9f507-7473-4fa6-8d71-8786c34c6301"
  }
}

retryable=true 只表示接口调用可以按同一幂等键重放,不表示可以重试任何真机点击。

一、管理端接口

方法 路径 作用
GET/POST /login 登录页 / 建立管理员会话
POST /logout 退出并使会话失效
GET /tasks SSR 任务表格;关键词、状态、时间筛选
POST /tasks 手工创建 DRAFT
POST /tasks/start-purchases 批量开始采购:创建一次性授权并原子转 PENDING
GET /tasks/{id} 任务完整页;同一 URL 也可由列表详情抽屉加载
POST /tasks/{id}/reset-to-draft 围栏前人工处理后关闭旧授权,回到 DRAFT
POST /tasks/{id}/cancel 围栏前取消任务
POST /order-submissions/{sid}/reconcile 围栏后人工调和同一提交
POST /tasks/{id}/mark-paid 人工确认已付款并完成核对
GET /evidence/{asset_id} 登录后读取内部截图;Cache-Control: no-store

GET /tasks/{id} 的完整页与列表抽屉共享同一服务端数据模型和详情模板。列表只可用同源请求携带 X-CMBuyer-View: drawer 获取 HTML fragment;其他非空 view、跨站 fragment 请求或不接受 text/html 的 fragment 请求均拒绝。直接导航同一 URL 始终返回完整页。

GET /evidence/{asset_id} 不经静态目录:未登录先返回 401,不查询和泄露资产是否存在;登录后 缺失或畸形 id 返回空 404。成功只返回存储的 PNG,包含 Content-Length、固定安全文件名、 Cache-Control: no-store 与 X-Content-Type-Options: nosniff,不返回原文件名或服务端路径。

POST /tasks

核心字段:

{
  "create_key": "d3c9f507-7473-4fa6-8d71-8786c34c6301",
  "title": "纯棉短袖",
  "product_url": "https://mobile.yangkeduo.com/goods.html?goods_id=937122477375",
  "sku_color": "黑色CHA(纯棉)",
  "sku_size": "M(建议100-115)",
  "quantity": 2,
  "max_total_price": "30.00"
}
  • 服务端解析并保存 canonical URL 与 goods_id;URL 非拼多多商品页、goods_id 缺失或含歧义则拒绝。
  • title 以 Go strings.TrimSpace(Unicode White_Space)后的持久化值计,最多 120 个 Unicode code point;sku_color、sku_size 同样按持久化值计,各最多 80 个 Unicode code point。非法 UTF-8 必须先拒绝,不能把替换字符当作合法 code point;为消除 Go 与 Python 默认 trim 差异,U+001C--U+001F 四个 C0 分隔符无论位置一律拒绝;超限不得截断。
  • goods_id 只允许 1--32 位 ASCII 数字;规范金额只允许 1--32 个 ASCII 字符。
  • max_total_price 是本任务允许创建待付款订单的总额上限,不是参考单价。
  • 成功只产生 DRAFT;不得创建授权、开放设备领取或触发真机。

POST /tasks/start-purchases

{
  "start_key": "63c9f507-7473-4fa6-8d71-8786c34c6301",
  "tasks": [
    {"task_id": "83c9f507-7473-4fa6-8d71-8786c34c6301", "expected_task_version": 1},
    {"task_id": "93c9f507-7473-4fa6-8d71-8786c34c6301", "expected_task_version": 1}
  ]
}

管理员按钮必须显示为“开始采购(只创建待付款订单)”。点击本身就是授权:允许采购工具按任务 锁定字段创建一笔待付款订单;不再等待试选后人工确认,也不授权付款。

服务端在一个事务中:

  1. 校验列表非空、无重复任务,所有任务均为 DRAFT 且版本一致;
  2. 校验每条任务的 goods_id、规格、正整数数量和最高总价完整;
  3. 为每条任务创建一次性 order_authorization,锁定任务版本、上述字段、管理员、时间和有效期;
  4. 把所有任务转为 PENDING 并递增版本。

任一条失败则整批不变。相同 start_key + 相同任务集合重放同一批结果;集合或版本不同返回 409。

{
  "start_key": "63c9f507-7473-4fa6-8d71-8786c34c6301",
  "authorized_count": 2,
  "tasks": [
    {"task_id": "83c9f507-7473-4fa6-8d71-8786c34c6301", "task_version": 2, "authorization_id": "a3c9f507-7473-4fa6-8d71-8786c34c6301"},
    {"task_id": "93c9f507-7473-4fa6-8d71-8786c34c6301", "task_version": 2, "authorization_id": "b3c9f507-7473-4fa6-8d71-8786c34c6301"}
  ],
  "payment_automated": false
}

围栏前重置与围栏后调和

  • reset-to-draft 必须同时校验任务版本、授权 id 与“尚无 order_submission”。关闭旧授权后回到 DRAFT;重新开始必须产生新版本与新授权。
  • 一旦存在 order_submission,重置、取消、授权过期和重新开始都返回 409 submission_fenced。
  • reconcile 只能处理指定 sid:人工记录“已创建待付款订单”或“确认未创建/无法完成”。它不能 触发设备点击、释放围栏或签发新授权。

二、设备侧接口

方法 路径 作用
POST /api/v1/devices/heartbeat 上报设备、ADB、App 版本和能力状态
POST /api/v1/tasks/claim-next 原子领取一个 PENDING 授权任务或重放本设备未结束领取
POST /api/v1/tasks/{id}/lease/renew 续租;只允许当前 claim
POST /api/v1/tasks/{id}/events 批量追加结构化步骤事件
POST /api/v1/tasks/{id}/evidence 显式上传一个内部原始截图
POST /api/v1/purchase-attempts/{aid}/fail 围栏前停止并回传失败摘要
POST /api/v1/purchase-attempts/{aid}/submission-fence 提交当前三闸门摘要并原子申请唯一围栏
POST /api/v1/order-submissions/{sid}/result 点击后一次性上报观察结果;只调和不重试

POST /api/v1/devices/heartbeat

{
  "device_id": "e3c9f507-7473-4fa6-8d71-8786c34c6301",
  "client_version": "0.1.0",
  "adb_serial": "192.168.0.173:5555",
  "android_release": "16",
  "pdd_version": "8.17.0",
  "state": "READY"
}

服务端可返回 app_version_allowed=false;采购工具必须停止领取,不能只显示警告后继续。

POST /api/v1/tasks/claim-next

设备 id 只来自已经认证的 X-CMBuyer-Device-ID,不得放进 JSON。请求体上限 4096 字节,只接受 以下两个字段;二者都必须是规范小写 UUIDv4,未知字段、重复字段和额外 JSON 均拒绝:

{
  "session_id": "23c9f507-7473-4fa6-8d71-8786c34c6301",
  "claim_request_id": "33c9f507-7473-4fa6-8d71-8786c34c6301"
}

成功领取或同一会话恢复返回 200:

{
  "task": {
    "id": "13c9f507-7473-4fa6-8d71-8786c34c6301",
    "version": 3,
    "title": "纯棉短袖",
    "product_url": "https://mobile.yangkeduo.com/goods.html?goods_id=937122477375",
    "goods_id": "937122477375",
    "sku_color": "黑色CHA(纯棉)",
    "sku_size": "M(建议100-115)",
    "quantity": 2,
    "max_total_price": "30.00"
  },
  "authorization": {
    "id": "73c9f507-7473-4fa6-8d71-8786c34c6301",
    "task_version": 2,
    "expires_at": "2026-08-04T10:00:00Z"
  },
  "attempt": {
    "id": "53c9f507-7473-4fa6-8d71-8786c34c6301",
    "claim_token": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
    "claim_generation": 1,
    "lease_expires_at": "2026-08-04T09:05:00Z"
  }
}
  • 设备认证发生在 Content-Type 解析和 body 读取前。事务的第一条数据库业务语句取得 SQLite 写入 位置并再次条件确认设备仍为 ACTIVE;之后才允许查幂等记录、候选或返回 EMPTY/冲突。
  • 只返回 PENDING + ACTIVE + 严格未过期 且 task/version/规格/数量/总价快照完全一致的最早授权; 服务端在同一事务中创建唯一 attempt/claim/request,并转为 CLAIMED。
  • 同一 claim_request_id 同设备、同 session 稳定重放原结果;同键异载荷返回 409 {"error":"idempotency_conflict"}。没有候选返回空 204,且 EMPTY 也持久化稳定重放。
  • claim 持久化完整成功响应快照;领取后的 task/authorization 源行变化不得让旧 request 的标题、规格、 数量、金额、版本或到期时间漂移。每次首次构造和旧 request 重放都重新校验持久化响应快照的字段 上限;源快照不一致时,新恢复/续租失败闭合。
  • 响应内 title 最多 120 个 Unicode code point,sku_color / sku_size 各最多 80 个;goods_id 为 1--32 位 ASCII 数字,max_total_price 为最多 32 个 ASCII 字符的规范金额。创建、授权快照、 candidate、持久化 claim snapshot 和 HTTP 输出共用同一合法域;既有畸形行只失败闭合,不迁移、 截断或改写。candidate 查询必须先读取 task 与 authorization 两侧字段并分别验证;任一侧畸形必须 回滚且不得持久化 EMPTY,只有两侧均合法但快照不一致时才跳过。最坏合法字段组合编码后必须明确 小于既有 32 KiB claim 响应上限。
  • 一个设备最多有一个未关闭 claim。同 session 且租约有效时重放原 attempt;同一 attempt 已按服务端 首事件原子进入 ORDERING 时也只在 task version 恰好为 claim 版本 +1 时恢复。不同 session、租约 过期或业务状态异常固定返回 409 {"error":"claim_requires_manual"},不释放、不转领、不新建 attempt。
  • claim_token 是 32 字节 HMAC 的 64 位小写十六进制表示,只证明一个 attempt 的归属,不是提交许可。 SQLite 仅保存随机 nonce 与 token SHA-256;同一 secret 重启后重建相同 token,错误 secret 拒绝启动。
  • 响应不得包含自由动作脚本、CSS/XPath、通用坐标或支付能力。

POST /api/v1/tasks/{id}/lease/renew

请求体同样限 4096 字节并执行严格 JSON 校验:

{
  "renew_request_id": "43c9f507-7473-4fa6-8d71-8786c34c6301",
  "session_id": "23c9f507-7473-4fa6-8d71-8786c34c6301",
  "attempt_id": "53c9f507-7473-4fa6-8d71-8786c34c6301",
  "claim_generation": 1,
  "claim_token": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
  "expected_lease_expires_at": "2026-08-04T09:05:00Z"
}

成功返回 200;响应不回显 token:

{
  "task_id": "13c9f507-7473-4fa6-8d71-8786c34c6301",
  "attempt_id": "53c9f507-7473-4fa6-8d71-8786c34c6301",
  "claim_generation": 1,
  "lease_expires_at": "2026-08-04T09:06:00Z"
}
  • 原设备/session/attempt/generation/token 必须同时匹配,当前租约与授权都必须严格晚于服务端 UTC 当前时间,expected_lease_expires_at 必须逐字等于数据库当前值;边界相等即过期且不能复活。
  • 新到期时间是 min(server_now + CMBUYER_CLAIM_LEASE_TTL, authorization.expires_at)。续租不改变 token、generation、任务版本或业务状态。
  • 成功续租先持久化 request 与响应;同一 renew_request_id 同载荷只重放旧响应,不再次 CAS 或延长。 同键异载荷返回 idempotency_conflict;非当前 claim 固定返回 claim_not_current,两者均为 409。

claim/renew 的格式错误固定为 400 {"error":"invalid_request"},超限为 413 {"error":"request_too_large"},Content-Type 错误为 415 {"error":"unsupported_media_type"};设备认证/事务内撤销为无诊断 401,存储故障为无诊断 503。

事件与证据

事件只包含固定 step / outcome / reason_code 和非敏感摘要。禁止把完整 XML、地址、手机号、 页面全文或 token 塞进日志字段。

截图接口使用 multipart/form-data,只接受单个显式文件及以下元数据:

{
  "upload_key": "43c9f507-7473-4fa6-8d71-8786c34c6301",
  "attempt_id": "33c9f507-7473-4fa6-8d71-8786c34c6301",
  "kind": "SKU_PANEL_GATE_1",
  "privacy_tier": "INTERNAL_RAW",
  "sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
  "captured_at": "2026-08-04T09:01:00Z"
}
  • 允许规格面板和合并式最终提交面板截图保留页面已显示的地址/手机号;不要求遮罩或裁剪。
  • 不接受 XML、目录、manifest、本机绝对路径、外部支付页截图或支付凭据。
  • T-204 只开放 kind=SKU_PANEL_GATE_1;后续 kind 必须由对应真机证据任务收紧扩展。
  • privacy_tier 只能是 INTERNAL_RAW;时间必须是以 Z 结尾的 UTC RFC 3339。
  • URL 中的 task id、upload_key 与 attempt_id 都必须是规范的小写 UUIDv4;sha256 必须是 恰好 64 位小写十六进制字符。
  • 恰好一个带 Content-Type: image/png 的显式文件;除上述六个元数据字段外,未知或重复字段均拒绝。
  • 单文件最多 10 MiB、单边最多 8192 px、总像素最多 16,777,216;服务端校验 PNG 魔数、完整解码、 字节数、尺寸与调用方声明的 64 位小写 SHA-256。
  • attempt_id 必须由数据库复合外键证明属于 URL 中的 task,且首次写入必须存在由认证设备持有的 未关闭 claim;设备 A 不能向设备 B 的 attempt 上传。认证必须先于 Content-Type 解析和请求体读取。
  • 同一设备主体和 upload_key 的同载荷重放返回原资产;即使 claim 后续由人工关闭,已成功资产仍先 重放历史结果。关闭后不得用新 upload key 写新证据;任务、attempt、截图或元数据变化返回 409。
  • 首次成功返回 201,幂等重放返回 200。响应只含资产 id、关联 id、kind/tier、hash、字节数、 MIME、宽高和采集时间,不含设备 token、原文件名或存储路径。
  • 生产上传使用逐请求 SQLite 设备认证;空凭据库、未知或已撤销设备均拒绝。不得使用管理员 session、 临时共享密钥或其他身份代替设备凭据。

POST /api/v1/purchase-attempts/{aid}/submission-fence

客户端只有在当前页面四条件中的后三项已经满足后才能调用:

{
  "fence_key": "e3c9f507-7473-4fa6-8d71-8786c34c6301",
  "task_id": "13c9f507-7473-4fa6-8d71-8786c34c6301",
  "expected_task_version": 3,
  "authorization_id": "73c9f507-7473-4fa6-8d71-8786c34c6301",
  "claim_token": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
  "selected_color": "黑色CHA(纯棉)",
  "selected_size": "M(建议100-115)",
  "gate1_unit_price": "12.88",
  "gate2_panel_total_price": "32.76",
  "quantity_read": 2,
  "gate3_submit_amount": "32.76",
  "submit_control_text": "提交订单 ¥32.76",
  "submit_control_match_count": 1,
  "nearest_clickable_ancestor_unique": true
}

gate3_submit_amount 是目标数量下合并式最终提交面板红色控件显示的金额;旧 confirm_page_amount / confirm_amount 语义已被 T-106 真机事实否定。当前仓库历史 migration 仍含旧列, T-210/T-208 消费前必须用受保护 migration 改为 gate3_submit_amount;迁移未完成不得开放 submission fence。

服务端在一个事务中校验:任务/版本/claim/attempt 一致;授权有效未消费且字段等于任务快照; 规格与授权相等;数量相等;闸门一单价预检通过;闸门二目标数量面板总额不超过 total_price_cap; Gate3 最终控件金额严格等于闸门二面板总额且再次不超过上限;完整结构化文本必须唯一、启用且最近 可点击祖先唯一;此前不存在该授权或 attempt 的 submission。随后创建唯一 order_submission,授权转 FENCED,任务保持不可重领。

gate2_panel_total_price 是目标数量下规格面板顶部证据角色的总额,不是单价。拼多多多件优惠可能 非线性,禁止用 gate1_unit_price × quantity_read 推导它,也禁止继续用旧字段名 gate2_unit_price 承载总额。最终红色控件金额只能作为 Gate3,绝不能用于 Gate1/Gate2 或为顶部金额兜底。

首次明确成功响应:

{
  "submission_id": "f3c9f507-7473-4fa6-8d71-8786c34c6301",
  "status": "FENCED",
  "click_permitted": true,
  "submit_control_text": "提交订单 ¥32.76"
}
  • 任一校验失败返回错误,绝不返回 click_permitted=true。
  • 同一 fence_key 的重放返回同一 submission_id,但 click_permitted=false 且 reconciliation_required=true;客户端不能凭重放响应点击。
  • 客户端收到首次许可后,必须先把“围栏已取得/即将发出唯一点击”持久化,再执行点击。进程崩溃或 本地状态不明时宁可转调和,也不再次点击。

POST /api/v1/order-submissions/{sid}/result

{
  "result_key": "03c9f507-7473-4fa6-8d71-8786c34c6301",
  "attempt_id": "53c9f507-7473-4fa6-8d71-8786c34c6301",
  "observation": "SUBMITTED",
  "evidence_asset_id": "63c9f507-7473-4fa6-8d71-8786c34c6301"
}

observation 只允许:

  • SUBMITTED:明确订单已创建,转 WAITING_PAYMENT;
  • EXTERNAL_PAYMENT_HANDOFF:已跳外部支付,停止并转 RECONCILIATION_REQUIRED;
  • SECURITY_CHALLENGE:出现安全校验,停止并转调和;
  • UNKNOWN:超时、断连或页面不明,转调和。

提交后没有“retry”观察值。任何结果都不能释放围栏或开放第二次点击。

文本和金额校验

  • 标题/规格字段:输入按 Go strings.TrimSpace 的 Unicode White_Space 集形成实际持久化值;title 最多 120 个 Unicode code point,颜色与尺码各最多 80 个。服务端先拒绝非法 UTF-8,再计 code point; U+001C--U+001F 四个 C0 分隔符在任意位置均拒绝,客户端使用同一固定空白集而不依赖 Python str.strip() 默认语义。不按 UTF-8 字节或视觉 grapheme 计数,不截断超限值。规格比较仍为规范化后 精确相等;不得包含、前缀、编辑距离或 AI 猜测。
  • goods_id:仅 1--32 位 ASCII 十进制数字,canonical URL 中唯一。
  • 金额:0.01 到系统配置上限,至多两位小数;规范化后必须是最多 32 个 ASCII 字符,再比较和持久化。
  • 数量:正整数,服务端与设备均设置合理上限;不能从字符串静默截断。

三、采购工具本地模块合约

T-303 已实现端口

class TaskSource(Protocol):
    def claim_next(self, credentials: DeviceCredentials, request: ClaimRequest) -> ClaimedTask | None: ...
    def renew(self, credentials: DeviceCredentials, request: RenewRequest) -> RenewResult: ...

class EvidenceSink(Protocol):
    def upload(self, credentials: DeviceCredentials, evidence: EvidenceUpload) -> AssetReceipt: ...

T-303 只实现 claim/renew 与 SKU_PANEL_GATE_1 单张 PNG;不得用运行时 NotImplementedError 伪造 events/fail/fence/result 或完整 ResultSink。T-304/T-306 必须通过 DurableClientGateway 调用:它先把 同一个 request/upload key 与完整载荷写入 SQLite,再最多发送一次 HTTP;401 保留 PENDING,由用户修复 同一 device id 的 Bearer 后显式重放;网络、超时、503、截断、非法/未知 2xx 同样只保留原槽。协议/409 终止槽但不换 key。成功响应落库失败时,重启仍用原 key 向服务端恢复事实。

金额按服务端合法域接受规范 ASCII 十进制正数字符串(最低 0.01,恰好两位小数、无前导零,最多 32 个 ASCII 字符);goods_id 只接受 1--32 位 ASCII 数字,title 最多 120 个 Unicode code point, 颜色与尺码各最多 80 个。客户端用与 Go strings.TrimSpace 相同的固定 Unicode White_Space 集校验 持久化文本,并与服务端共同拒绝任意位置的 U+001C--U+001F;不得截断或修复漂移响应。wire 整数为正 int64,拒绝 bool。claim 成功 响应总上限仍为 32 KiB,最坏合法字段组合由双端契约测试证明严格小于该值。RFC3339Nano 按 0--9 位 小数的纳秒时间轴比较,不能用 Python 微秒精度截断。

本地恢复合约

  • 数据库固定为 %LOCALAPPDATA%\cmbuyer\state\client-state.sqlite3,WAL + synchronous=FULL;Windows 缺少 LOCALAPPDATA 时失败,不回退到 home 创建第二套状态。
  • device token 与 claim token 的原始 32 字节只以当前用户 DPAPI 密文 BLOB 入库;前者 context 绑定 profile+device,后者绑定 profile+attempt,跨行交换密文会解密失败。claim token 不可更换;有 pending/open 状态时冻结 service/device/ADB/transport/轮询与超时配置,仅允许同 device id 修复 Bearer; idle 时切换 device id 也必须同时提供新 token。
  • Global\cmbuyer-<db-path-hash> named mutex 在任何 SQLite/DPAPI 打开前取得;同一状态库跨 Windows session 只允许一个采购工具进程。
  • 运行根目录与数据库路径在构造时固化为绝对路径;之后 cwd 改变不得打开第二套库或绕过原 mutex。
  • polling session、claim history、renew request 和 evidence marker/slot 都是 append-only 历史;当前行用 closed_at IS NULL partial unique 表示。恢复或发送前在同一 SQLite 读快照校验整张状态图,冗余列、 snapshot、request、claim token、marker、slot、receipt 任一不一致都零 HTTP 失败闭合。
  • evidence 槽唯一键是 (attempt_id, kind)。首次发送前固定显式路径的 regular/non-reparse 文件 identity、 size、mtime、SHA-256、IHDR 尺寸与字节;pending 时变化即停,并始终用首次保存的 exact metadata 重放 相同 multipart。成功后 receipt 成为事实,源文件变化或删除只返回原 receipt,不再次上传;receipt 尺寸必须与本地 IHDR 一致。客户端做签名/IHDR/尺寸/hash 防御;采购服务仍负责完整 PNG 解码权威校验。

完整 ResultSink 只有在 T-205/T-208 服务端契约完成后才由后续任务组合;SubmissionPermit 只能由 服务端首次明确成功响应构造,业务代码不能手工创建。

真机能力分层

能力 输入 输出 安全边界
open_product() canonical URL + 证据版本 已确认商品页 URL、前台包、App 版本全部匹配
open_sku_panel() 版本绑定受控入口 已确认规格面板 精确唯一;无通用 click
select_sku_options() 维度 → 精确值 选中态摘要 维度内唯一匹配并读回
read_sku_unit_price() 已确认规格面板 十进制单价 排除原价、按钮价和歧义候选
set_quantity_and_readback() 授权数量 实际数量 精确读回,否则停
read_sku_panel_total_price() 已读回目标数量 十进制面板总额 只读证据绑定的顶部当前金额;排除底部提交按钮金额和歧义候选
observe_final_submit_panel() 已通过闸门二且仍在同一面板 Gate3 摘要 复核规格/数量;只读最终控件结构化金额;零页面点击
submit_order_once() 不可伪造的首次 SubmissionPermit 观察结果 许可、闸门、唯一控件全校验;点前持久化;绝不重试

T-103 只实现隔离的 SkuSelectionFlow:前四项加安全退出。它的模块和静态依赖不得引用数量、最终提交面板、 围栏、提交或支付能力。后续任务按取证顺序组合成生产 SinglePassPurchaseFlow。

T-107 已实现的 observe_gate3() 是无 device 的纯 XML observer,只接受可信 Gate2Observation、当前 XML、显式截图路径和带时区采集时间。它分别重读当前面板顶部 gate2_panel_total_price 与最终完整文本 提交订单 ¥{gate3_submit_amount},要求两者唯一、规范、严格相等且不超 max_total_price;同时精确 复核目标规格摘要和数量 2。面板坐标只接受 T-106 原态或 T-107 人工五项确认的整体上移 77px 态, 两套配置互斥且所有角色必须整套精确命中,不接受范围匹配或坐标混搭。成功 DTO 只包含规格/数量、两个独立金额、完整文本、匹配数、启用态、 “最近可点击祖先唯一”布尔值、截图路径和采集时间,不包含 selector、bounds、XML 节点、祖先句柄或 可操作对象。FinalSubmitPanelRunner 永久是围栏前 dry-run:RPC 仅允许截图、XML 和一次 pressKey Back, 不接受提交开关,不上传证据;Back 结果不明或返回页未连续两帧命中 T-106 同商品正判据时不重试。

四、实现前仍需定值

  • 授权有效期、领取租约时长、心跳/轮询间隔和连续失败停止阈值;
  • 内部截图保留期限;截图大小上限已固定为 10 MiB / 8192 px 单边 / 16,777,216 像素;
  • 可配置单任务数量与最高总价系统上限;
  • 首次真实提交真机任务的人工授权和待付款订单处置步骤。