Files
cmbuyer/docs/04-architecture.md
T

528 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 架构设计
> 本文定义系统结构、职责、单趟采购流程、安全边界和数据模型。框架与运行命令见
> [技术栈](03-tech-stack.md),双端线协议以 [API 合约](api.md)为唯一权威。
## 一、系统结构
```text
人工填链接(MVP) Excel / ERP(V2)
│ │
└──────────────┬───────────────┘
v
┌─────────────────────────────────────────┐
│ 采购服务(admin/,Go) │
│ · 建单、查询、开始采购授权与任务状态 │
│ · 提交围栏、结果调和、内部证据与审计 │
│ · 服务端渲染管理页面 │
└───────────────────┬─────────────────────┘
│ 本机回环 HTTP / JSON(MVP)
│ Bearer + 设备绑定;非回环前必须先上 TLS
v
┌─────────────────────────────────────────┐
│ 采购工具(client/,Python + PySide6) │
│ · 轮询领取、单趟执行、回传状态与证据 │
│ · 本地完整节点树与执行轨迹 │
└───────────────────┬─────────────────────┘
│ ADB(USB / WiFi)
v
Android 手机(拼多多 App)
```
- 采购服务:Go + gin,SQLite,goose migration,本地 SHA-256 证据存储。
- 采购工具:Python + uiautomator2 + PySide6;执行器只依赖 `TaskSource` / `ResultSink` 抽象。
- 页面判据与拼多多 App 版本绑定;版本不同即停止,不把前序项目页面结构当作事实。
## 二、职责划分
### 采购服务(`admin/`)
独占以下权威:
- 任务创建、批量开始采购、状态流转和终态判定;
- **一次性采购授权的签发**:管理员点击“开始采购”是唯一的人类授权动作;
- 任务不可变字段和最高总价校验;
- 提交订单前的原子围栏与点击后结果调和;
- 管理员会话、设备凭据、内部截图证据和审计记录。
采购服务不连接手机、不发 ADB 命令、不解析拼多多页面,也不持有支付或 AI provider 凭据。
### 采购工具(`client/`)
独占以下设备能力:
- ADB 连接、设备健康检查和已取证 App 版本校验;
- 打开商品、识别页面、精确选规格、设置数量、读取价格;
- 在满足全部门禁并取得服务端围栏后,精确点击一次“提交订单”;
- 截图、完整节点树和执行日志的本地采集,显式上传内部截图。
采购工具不自行修改任务约束、不扩大金额上限、不领取 `DRAFT`,也不能签发授权。没有服务端
明确返回 `click_permitted=true` 时,任何本地判断都不能创建订单。
### 权威冲突规则
服务端校验锁定的任务约束,客户端校验当前真机事实。任一端拒绝或两端摘要不一致,一律停止并
转人工;不能为了“继续跑”选择相信其中一端。
## 三、单趟采购执行
MVP 只做任务自带商品链接的 A 路径。创建任务与开始采购分离,但管理员开始后不再插入试选确认:
```text
管理员创建 DRAFT
│
├─ 勾选 DRAFT,查看选中数与最高总额
└─ 点击“开始采购(只创建待付款订单)”
│ 同一事务:校验版本 + 创建一次性授权 + PENDING
v
采购工具领取授权任务
│ 1. 打开 canonical 商品链接
│ 2. 通过证据/版本绑定、精确唯一的受控入口打开规格面板
│ 3. 按维度精确选择并读回颜色、尺码
│ 4. 【闸门一】读 SKU 单价;单价×数量不得超过最高总价
│ 5. 设置数量并精确读回
│ 6. 【闸门二】重读规格、数量与面板总额;总额不超最高总价
│ 7. 保持在合并式最终提交面板,不执行页面导航
│ 8. 【闸门三】重核规格/数量;最终控件金额等于闸门二且不超最高总价
│ 9. 上传验证摘要并申请服务端提交围栏
│ 10. 仅在 click_permitted=true 且提交控件唯一时点击一次
│ 11. 回传观察结果;不确定时只调和,不重试
v
WAITING_PAYMENT ──人核对与付款──> SUCCEEDED
或
RECONCILIATION_REQUIRED ──人工核查同一提交──> WAITING_PAYMENT / FAILED
```
“开始采购”锁定的是管理员填写的 `goods_id`、颜色、尺码、数量和**最高总价**,不是一张旧页面
截图里观察到的价格。价格在执行时实时读取,因此取消试选确认不会取消价格保护。
### 受控规格面板入口
T-103 最新真机证据表明:拼多多 `8.17.0`、goods_id `937122477375`、1080×2376 的规格面板
入口是屏幕底部购买区第一行 `快要抢光 + 金额`,不是商品内容区的同名促销小字。T-110 已批准把
该**特定证据、版本和页面状态**绑定的点击定义为受控导航。
- 只能精确唯一匹配底部非点击文本叶节点、其直接可点击 PDD 父容器及证据绑定结构,并且只点击
第一行文本叶节点中心;缺失、重复、版本失配、结构漂移或打开后面板不唯一时零后续点击。
- 商品内容区同名促销小字明确禁止作为入口。底部购买区入口金额只参与结构识别,不得解析、返回或
充当价格来源;规格选择后的最终红色控件金额也不得用于 Gate1/Gate2,只在 T-106 已确认的合并式
最终提交面板中作为 Gate3 独立金额角色。
- `免拼购买` 只允许作为已取证、不可点击的精确兄弟节点验证底部结构,不能作为选择器、目标或兜底;
它变为可点击、位置漂移或出现在其他结构时必须拒绝。“单独购买 / 直接拼成”等其他文案同样不能
用包含、前缀、同义或坐标兜底。
- 受控入口只负责进入已取证面板,不等于支付授权,也不能暴露通用任意点击能力。
- T-103 的隔离验证 capability 只包含开商品、开面板、选规格、读价和安全退出;数量、最终提交面板、
提交和支付仍由后续真机任务分别取证后才能接入生产单趟执行器。
2026-08-05 的五组 8.17.0 真机证据进一步证明,面板刚打开时颜色与尺码均未选中,尺码选项尚未
进入视口;选择目标颜色后,摘要只剩“请选择:尺码”,但尺码仍不可见。实现必须把未选初态、
仅颜色已选态、非目标尺码态和目标尺码态作为各自完整的证据 profile 匹配,禁止把不同状态的价格、
摘要、容器或坐标拼成一个宽松判据。
- 显示尺码只允许命名的私有 `reveal_size_options()` 窄能力:仅从已证明的“目标颜色已选、尺码未选
且隐藏”状态执行,绑定证据确认的嵌套滚动容器与列间安全通道,最多一次且不重试;不得公开通用
`swipe`、`scroll`、任意坐标或把底部提交区纳入动作范围。
- 生产 reveal 落地前,必须补齐“动作前后目标尺码均未选、尺码由隐藏变为可见、提交区未触发”的
同版本真机动作证据。现有“滚动后 M 已选中”快照不能证明滚动没有误点规格,不得据此猜测手势。
- 这组证据只能由独立 spike 在严格前置成立后执行一次固定、无参数手势采集;生产 Flow 不得调用该
spike。RPC 超时或响应不明时只允许只读调和,不得重试;候选后置必须再由人核对后才能成为生产判据。
- 尺码 action 的真实节点类型、精确文案和 selected 读回必须随 profile 一起验证;当前证据中的 S/M
是可点击 `TextView`。只有目标颜色与目标尺码双重读回一致后才能读取闸门一价格。
- 一次 Back 在调用前即封存且不得重试;仅“面板消失”不能作为生产安全退出的充分条件。同商品详情页
的严格退出判据由 T-104 取得独立真机证据后开放。
### 三道价格闸门
| 闸门 | 当前页面 | 判据 | 拒绝条件 |
| --- | --- | --- | --- |
| 一 | 规格面板,选中目标规格后 | 单价唯一可读;`单价 × 授权数量 <= 最高总价` | 不可读、有歧义或超上限 |
| 二 | 规格面板,数量读回后 | 颜色、尺码仍正确;目标数量面板总额唯一可读且不超最高总价 | 规格/数量漂移、总额不可读/有歧义或超上限 |
| 三 | 同一合并式最终提交面板 | 规格、数量正确;最终红色控件结构化文本中的金额唯一可读、严格等于闸门二且不超最高总价 | 任一不一致、不可读、有歧义或超上限 |
金额一律使用十进制字符串和十进制定点运算,不用浮点数。Gate1/Gate2 只从规格面板顶部已批准角色
读取;Gate3 只从同一最终面板唯一结构化精确文本 `提交订单 ¥{规范十进制金额}` 读取。详情正文、
搜索卡片、底部购买入口和其他按钮数字都不是价格来源;Gate3 金额也不得回流充当 Gate1/Gate2。
2026-08-06 的拼多多 `8.17.0` 真机证据证明同一规格的多件优惠是非线性的:数量 1 时规格面板顶部
当前金额为 `12.88`,数量 2 时变为 `32.76`,而不是 `12.88 × 2`。因此闸门一仍是设置数量前的单价
预检,闸门二必须读取设置后的**面板总额** `gate2_panel_total_price` 并直接比较最高总价;不得把
Gate1 乘数量、不得要求 Gate2 等于 Gate1。2026-08-06 T-106 进一步证明当前版本没有独立确认页:
同一面板的红色 `提交订单 ¥32.76` 会直接创建待付款订单并进入付款界面。因此 Gate3 改为在不可逆
动作前只读该最终控件金额,并要求它与 Gate2 顶部总额严格相等;这是同一稳定页面两个独立 UI 位置的
交叉校验,不是把按钮金额当 Gate2 兜底。
闸门一与闸门二发生在同一设备会话中,不需要管理员在中间确认。旧截图、缓存值和发布前 dry-run
都不能替代这次实时读取。
### B 路径(V2)
没有商品链接时由图片搜索只产出 `goods_id`,再汇入上述流程。不在搜索结果页读取价格或规格。
## 四、安全边界(硬约束)
以下每条都必须有测试证明,不得在任务中顺手放宽:
| 边界 | 规则 | 防止什么 |
| --- | --- | --- |
| 不付款 | 不点击支付、免密支付、先用后付或任何扣款控件 | 真实资金损失 |
| 提交四条件 | 授权+围栏、闸门二、闸门三、控件唯一同时成立,只点一次 | 误下单 / 重复下单 |
| 最终面板零点击 | 除安全返回和满足四条件后的最终红色控件外不点击任何控件 | 未知副作用 |
| 规格精确匹配 | 维度内等值唯一匹配,防 `红/粉红`、`1/10` 前缀碰撞 | 买错规格 |
| 数量读回复核 | 设置后精确读回,不一致即停 | 买错数量 |
| 三道价格闸门 | 任一道不可读、有歧义或不通过都停,不用别处数字凑 | 超预算 |
| 受控页面能力 | 页面动作按任务与证据分层;不得把通用 `click` 传入业务流程 | 边界扩散 |
| 外部支付页 | 检测到外部支付交接立即停止,不读取、保存或输入凭据 | 凭据泄露 |
| 安全校验 | 验证码、风控、人脸、短信出现即停止,不绕过 | 封号 / 违规 |
| 内部截图 | 可上传页面已显示的地址/手机号;不解析成字段或日志,完整 XML 不上传 | 非必要扩散 |
| 身份隔离 | 管理 session+CSRF 与设备 Bearer 分属不同路由域,混合凭据不叠加权限 | 设备越权 / 会话冒充 |
| Bearer 传输 | MVP 仅绑定 IPv4 回环 `127.0.0.1:8080`;非回环访问先建立 HTTPS/TLS 终止 | 明文局域网泄露 token |
| 授权一次性 | 一条任务版本只有一份有效授权;幂等重放不生成第二份 | 重复采购 |
| 服务端提交围栏 | 点击前原子创建唯一提交记录;失败或响应不明不得点击 | 并发 / 断网重复下单 |
| App 版本绑定 | 运行版本不同于证据版本时停止并重新取证 | 旧判据误点 |
### 提交订单的四个前置条件
“提交订单”是系统唯一会创建真实待付款订单的动作。以下四项同时满足才允许点击一次:
1. **一次性授权有效且服务端提交围栏已建立**:围栏把授权、任务、领取和本次验证摘要原子绑定到
唯一 `order_submission`;明确响应包含 `click_permitted=true`。
2. **闸门二通过**:目标规格和数量未漂移,目标数量面板总额唯一可读且不超过授权最高总价。
3. **闸门三通过**:同一最终面板的规格、数量正确;唯一结构化文本 `提交订单 ¥{金额}` 中金额严格
等于闸门二顶部总额,且不超过授权最高总价。
4. **提交控件唯一**:完整结构化文本唯一、启用,且其最近可点击祖先唯一。不得使用包含、前缀、
OCR、裸坐标或返回通用可点击对象。
围栏请求超时、断网、冲突或响应不明时不得点击。围栏建立后:
| 观察结果 | 处置 |
| --- | --- |
| 明确进入订单结果 / 待付款页 | 上报 `SUBMITTED`,任务转 `WAITING_PAYMENT` |
| 跳转外部支付 | 立即停止,上报结果不明确,不执行支付 |
| 出现验证码 / 风控 / 人脸 / 短信 | 立即停止,上报结果不明确,不绕过 |
| 超时、断连、页面无法判定 | 转 `RECONCILIATION_REQUIRED`,保留围栏和金额额度 |
后三种情况都禁止释放围栏、重新授权、重新领取或再次点击。只能调和同一提交记录。
### 发布前 dry-run 与生产围栏的区别
首次启用某一 App 版本的真实提交能力前,必须用独立真机任务完成只读 dry-run:在合并式最终提交
面板验证规格、数量、Gate2 顶部总额、Gate3 最终控件金额与最近可点击祖先唯一,然后退出且不点击。
它用于证明判据和不可达测试,不是每笔采购的
“第一趟”,也不产生可复用页面事实。生产任务仍在同一趟内重新通过三道闸门并申请服务端围栏。
## 五、数据模型
### 5.1 核心实体
```sql
CREATE TABLE tasks (
id TEXT PRIMARY KEY,
source TEXT NOT NULL, -- MANUAL | EXCEL | ERP
source_ref TEXT,
title TEXT NOT NULL,
goods_id TEXT NOT NULL,
sku_color TEXT NOT NULL,
sku_size TEXT NOT NULL,
quantity INTEGER NOT NULL CHECK (quantity > 0),
max_total_price TEXT NOT NULL, -- 十进制字符串
reference_asset_id TEXT,
status TEXT NOT NULL,
version INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- 管理员点击“开始采购”产生;锁定任务约束,不锁定旧观察价
CREATE TABLE order_authorizations (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL REFERENCES tasks(id),
task_version INTEGER NOT NULL,
start_key TEXT NOT NULL,
goods_id TEXT NOT NULL,
sku_color TEXT NOT NULL,
sku_size TEXT NOT NULL,
quantity INTEGER NOT NULL,
total_price_cap TEXT NOT NULL,
status TEXT NOT NULL, -- ACTIVE | CLAIMED | FENCED | CONSUMED | EXPIRED | ABANDONED
created_by TEXT NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
UNIQUE (task_id, task_version),
UNIQUE (start_key, task_id)
);
-- 一次领取产生一条可恢复执行;保存步骤摘要,不接收完整 XML
CREATE TABLE purchase_attempts (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL REFERENCES tasks(id),
authorization_id TEXT NOT NULL REFERENCES order_authorizations(id),
claim_generation INTEGER NOT NULL,
status TEXT NOT NULL,
gate1_unit_price TEXT,
gate2_panel_total_price TEXT,
quantity_read INTEGER,
gate3_submit_amount TEXT,
failure_code TEXT,
started_at TEXT NOT NULL,
finished_at TEXT,
UNIQUE (task_id, claim_generation),
UNIQUE (task_id, authorization_id, id, claim_generation)
);
-- attempt 的设备/session 所有权与可恢复租约;token 明文永不入库
CREATE TABLE purchase_attempt_claims (
attempt_id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
authorization_id TEXT NOT NULL UNIQUE,
claimed_by_device_id TEXT NOT NULL REFERENCES device_credentials(device_id),
session_id TEXT NOT NULL,
claim_generation INTEGER NOT NULL,
task_version INTEGER NOT NULL,
task_title TEXT NOT NULL,
authorization_task_version INTEGER NOT NULL,
goods_id TEXT NOT NULL,
sku_color TEXT NOT NULL,
sku_size TEXT NOT NULL,
quantity INTEGER NOT NULL,
total_price_cap TEXT NOT NULL,
authorization_expires_at TEXT NOT NULL,
claim_nonce BLOB NOT NULL, -- 32 字节随机 nonce
claim_token_sha256 BLOB NOT NULL, -- 32 字节 hash,不是 token 明文
lease_expires_at TEXT NOT NULL,
claimed_at TEXT NOT NULL,
closed_at TEXT,
FOREIGN KEY (task_id, authorization_id, attempt_id, claim_generation)
REFERENCES purchase_attempts(task_id, authorization_id, id, claim_generation)
);
CREATE UNIQUE INDEX purchase_attempt_claims_one_open_per_device_idx
ON purchase_attempt_claims (claimed_by_device_id) WHERE closed_at IS NULL;
-- claim-next 的 CLAIMED / EMPTY / BLOCKED 与 renew CAS 都持久化,保证跨重启幂等。
-- 复合外键同时绑定 attempt、设备、session、generation 与 token hash,应用 bug 不能跨归属写事实。
-- 真机真实点击前建立;一份授权最多一条
CREATE TABLE order_submissions (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL REFERENCES tasks(id),
authorization_id TEXT NOT NULL REFERENCES order_authorizations(id),
attempt_id TEXT NOT NULL REFERENCES purchase_attempts(id),
status TEXT NOT NULL, -- FENCED | SUBMITTED | RECONCILIATION_REQUIRED | MANUAL_RESOLVED
gate1_unit_price TEXT NOT NULL,
gate2_panel_total_price TEXT NOT NULL,
quantity_read INTEGER NOT NULL,
gate3_submit_amount TEXT NOT NULL,
created_at TEXT NOT NULL,
resolved_at TEXT,
UNIQUE (authorization_id),
UNIQUE (attempt_id)
);
-- 设备 token 明文只在签发完成后显示一次;数据库仅保存原始 32 字节 token 的 SHA-256
CREATE TABLE device_credentials (
device_id TEXT PRIMARY KEY, -- 规范小写 UUIDv4
display_name TEXT NOT NULL, -- 非秘密运维名称
token_sha256 BLOB NOT NULL UNIQUE, -- 恰好 32 字节
status TEXT NOT NULL, -- ACTIVE | REVOKED
created_at TEXT NOT NULL,
revoked_at TEXT,
CHECK ((status = 'ACTIVE' AND revoked_at IS NULL)
OR (status = 'REVOKED' AND revoked_at IS NOT NULL AND revoked_at >= created_at))
);
-- INTERNAL_RAW 原始截图;原文件名和客户端路径不进入数据库
CREATE TABLE evidence_assets (
id TEXT PRIMARY KEY,
upload_key TEXT NOT NULL,
task_id TEXT NOT NULL,
attempt_id TEXT NOT NULL,
kind TEXT NOT NULL, -- T-204 仅 SKU_PANEL_GATE_1
privacy_tier TEXT NOT NULL, -- 仅 INTERNAL_RAW
sha256 TEXT NOT NULL, -- 64 位小写十六进制
byte_size INTEGER NOT NULL,
content_type TEXT NOT NULL, -- 仅 image/png
width_px INTEGER NOT NULL,
height_px INTEGER NOT NULL,
storage_key TEXT NOT NULL, -- 由 SHA-256 唯一派生
uploaded_by_device_id TEXT NOT NULL,
captured_at TEXT NOT NULL,
created_at TEXT NOT NULL,
UNIQUE (uploaded_by_device_id, upload_key),
FOREIGN KEY (task_id, attempt_id) REFERENCES purchase_attempts(task_id, id)
);
```
MVP 不再用 `spec_trials` 作为审批记录,也不存在 `authorized_unit_price`。实际读价属于
`purchase_attempts` / `order_submissions` 的执行与审计事实;管理员授权的资金边界始终是
`total_price_cap`。
上述 `gate3_submit_amount` 是 T-106 真机结论后的目标 schema。当前历史 migration 仍含
`confirm_amount` / `confirm_page_amount`;T-210 必须在 T-205/T-208 消费前用受保护 migration 完成
迁移,旧列存在期间不得开放 submission fence,不能把旧名字继续解释成新事实。
设备凭据由本机管理 CLI 签发、列出和撤销。token 是 32 字节加密随机值,以 64 位小写十六进制
只显示一次;服务端把 token 解码回原始字节后计算 SHA-256,并与按设备 id 查出的 32 字节 BLOB
恒定时间比较。未知 id 也执行固定宽度 dummy compare。认证逐请求查库,因此撤销事务提交后才开始的
请求全部拒绝;提交前已经完成认证的在途请求不追溯取消。格式/未知/错配/撤销统一空 401,存储故障
空 503,两类都在读取业务请求体前失败闭合。
### 5.2 状态机
```text
DRAFT
└─开始采购(创建授权)→ PENDING
└─claim→ CLAIMED ─start→ ORDERING
├─围栏前验证失败→ NEEDS_MANUAL ─人工处理/重置→ DRAFT
├─围栏前授权过期/安全释放→ DRAFT
└─submission FENCED
├─明确创建→ WAITING_PAYMENT ─人工付款并标记→ SUCCEEDED
└─结果不明→ RECONCILIATION_REQUIRED
└─人工调和同一提交→ WAITING_PAYMENT / FAILED
DRAFT / PENDING / NEEDS_MANUAL ─管理员取消(围栏前)→ CANCELED
```
| 状态 | 含义与安全下一步 |
| --- | --- |
| `DRAFT` | 已保存,未授权;管理员可编辑/取消或点击开始采购;设备不可领取 |
| `PENDING` | 已有有效一次性授权,等待采购工具领取 |
| `CLAIMED` | 已由一个设备实例持有租约,尚未开始页面操作 |
| `ORDERING` | 单趟执行中,正在选规格、过闸门或申请围栏 |
| `NEEDS_MANUAL` | 围栏前失败;显示原因,由人核查后重置为 DRAFT 或取消 |
| `WAITING_PAYMENT` | 订单已明确创建,等待人在拼多多付款;**不是成功** |
| `RECONCILIATION_REQUIRED` | 围栏后结果不明;只能核查同一提交,不能重试 |
| `SUCCEEDED` | 人已付款并完成核对 |
| `FAILED` | 人工调和确认订单未创建或任务无法完成 |
| `CANCELED` | 围栏前由管理员取消,不再执行 |
批量 `DRAFT → PENDING` 必须全有或全无。服务端同时创建授权;“先改状态、稍后补授权”无效。
`WAITING_CONFIRMATION`、`PENDING_RETRIAL`、`AUTHORIZED` 和 `RUNNING(TRIAL)` 不再属于 MVP 状态。
### 5.3 授权、租约与恢复
- claim/renew 使用独立 32 字节 HMAC secret;配置为 64 位小写十六进制,不得等于 session secret 或
任一设备 token。每条 claim 以版本域分隔 HMAC 绑定 device/task/authorization/attempt/generation/
32 字节随机 nonce,SQLite 只保存 nonce 和 token SHA-256。启动会重建并恒定时核对所有 open/closed
claim;secret 错误时失败闭合,不轮换 token。
- claim 与 renew 的 SQLite 事务必须先通过条件 no-op UPDATE 取得写入线性化位置并复核设备 ACTIVE,
才能读取或重放 request。撤销先提交则请求失败;claim/renew 先取得写位置则该事务可完成,随后撤销
不会自动释放已建立的 claim。
- 同一授权最多一个 attempt,同一设备最多一个未关闭 claim。EMPTY、人工恢复冲突和续租响应都持久化;
同幂等键只能重放原结果,不能因候选变化、续租或服务重启递增 generation、轮换 token 或延长第二次。
- claim 行同时冻结成功响应所需的 task 标题/版本以及 authorization 版本、规格、数量、总价上限和到期
时间。旧 request 跨重启只从该快照重建;源 task/authorization 后续漂移不能改变历史响应,并会让新
恢复或续租失败闭合。进入 `ORDERING` 的同 attempt 仅接受 task version 恰好比 claim 快照加一。
- 租约 TTL 显式配置为正且严格短于授权 TTL;新到期时间不得超过授权到期。租约与授权边界相等即过期,
没有宽限;过期、撤销、停止轮询和进程退出均不关闭 claim、不释放授权、不允许另一设备接管。
- 授权带 `expires_at`,只有围栏前可转 `EXPIRED` / `ABANDONED`;任务回到 `DRAFT`,必须重新点击
开始采购。旧授权永不复活。
- 设备租约丢失不等于授权可安全重用。只有服务端确认该 attempt 未建立围栏,才能关闭 attempt 并
回到 `DRAFT` / `NEEDS_MANUAL`;不能自动重新领取并重复页面动作。
- 围栏建立后即使租约过期也只恢复同一 `order_submission` 的调和,不能回到可领取队列。
- 每种非终态都必须给出安全下一步,不能出现隐藏表单导致任务永久锁死。
#### 采购工具本地恢复状态
- 生产入口先取得基于规范数据库路径的 Windows `Global\` named mutex,再构造 DPAPI 和 SQLite;不能
以服务端“单设备最多一个 claim”替代本机单实例。
- profile、polling session、claim/renew request、open/historical claim、evidence marker/slot 使用 WAL、
`synchronous=FULL` 与 append-only/单调关闭约束。当前 session/claim 由 `closed_at IS NULL` partial
unique 保证唯一,历史关闭后不阻塞下一条,但不得删除或复活。
- `DurableClientGateway` 是轮询和截图接入的唯一顺序入口:先 durable prepare,再一次 HTTP,最后原子
commit。停止只把当前 session 的 `accept_new` 设为 false;飞行中响应仍提交,pending/open 不清除。
- 每次发送前校验 profile/session/request/active immutable business snapshot、renew history、evidence marker/slot/
receipt 的完整状态图;snapshot、renew response 与 evidence receipt 还保存不可变摘要。任一冗余事实不一致、
DPAPI context 不匹配、数据库缺行或文件 identity 变化时零 HTTP 停止,不能自行“修复”。
- 设备 Bearer 的 401 发生在服务端读取 body/写幂等事实前,因此槽保持 `PENDING`;只允许同 device id
更新 Bearer,并在用户再次开始后用原 key/body/file 尝试。claim token 和其他 frozen 配置不变。
- 原始 token 以 DPAPI current-user context 密文保存:device token 绑定 profile+device,claim/renew token
绑定 profile+attempt,密文不能跨行复用;日志 formatter 对 Bearer、裸 64 位 token 和
traceback 做最终脱敏。异常对象也只保留固定 reason,不挂接含响应 body/partial/path 的异常上下文。
### 5.4 证据分层
| 数据 | 位置 | 边界 |
| --- | --- | --- |
| 商品 / 规格 / 最终提交面板原始 screenshot | 采购工具本机 + 采购服务内部证据存储 | 可含页面已显示地址/手机号;设备鉴权上传、管理员登录查看,不遮罩 |
| 完整 XML | 仅采购工具本机隔离目录 | 可在内存解析页面判据;不上传、不写日志、Git、Vikunja |
| 最小 XML fixture | 采购工具测试 / Git | 只保留判据所需结构,确认无地址、手机号、支付凭据 |
| 外部支付页或支付凭据 | 不保存、不上传 | 检测到交接立即停止 |
| AI 调用记录(V2) | 仅采购工具本地 | 不进入采购服务 |
截图上传器只能接收调用方显式指定的截图,不能枚举证据目录或顺带上传 XML/manifest。证据响应
使用 `Cache-Control: no-store`,不能暴露为免登录静态目录。
内部截图存储采用以下固定边界:
- 单个 PNG 最大 10 MiB、单边最大 8192 px、总像素最大 16,777,216;同时验证 multipart MIME、
PNG 魔数、完整解码、字节数、尺寸和调用方声明的 SHA-256。
- 上传 handler 必须先通过逐请求 SQLite 设备认证并再次校验规范 principal,再解析 Content-Type 或
读取 body。空库、无效或已撤销凭据拒绝,认证存储故障返回 503;不把管理员 session 当设备身份。
- 首次 evidence INSERT 必须由 store 校验和 SQLite trigger 双重证明 `(task, attempt, authenticated device)`
对应未关闭 claim。历史同设备/upload key 重放先于该检查,因此人工关闭 claim 不会破坏已提交资产的
幂等读取;关闭后禁止新 upload key,且不为此增加 token/session 字段或放宽截图 kind。
- 文件写入显式配置的私有证据根目录:同目录随机临时文件 → 流式 hash → 校验 → `fsync` → 原子
rename 到 SHA-256 内容地址 → 最后事务写数据库。数据库永远不指向半文件或缺失文件。
- SQLite 与文件系统不能组成跨资源事务;极端故障最多留下不可达孤儿文件。不得为清理孤儿而删除
可能被其他资产记录并发复用的内容文件,自动保留/删除策略留给部署任务。
- SHA-256 只用于物理内容寻址,不是业务资产唯一键;不同合法证据可以引用相同内容。同设备主体与
`upload_key` 同载荷重放原资产,任一规范字段变化即冲突。
## 六、关键技术难点
| 难点 | 风险 | 应对 |
| --- | --- | --- |
| 页面结构随版本变化 | 旧选择器误点新页面 | 每条判据先真机取证,记录 App 版本、截图、XML、goods_id |
| 购买语义入口才打开面板 | 能力范围易扩散 | 只批准证据绑定的精确唯一入口;每种文案单独取证 |
| 当前价与原价/按钮价混杂 | 读错价格 | Gate1/Gate2 只读顶部角色;Gate3 只读最终控件结构化金额,角色不互相兜底 |
| 单趟页面状态变化 | 数量或促销导致金额非线性变化 | 同一趟读 Gate1 单价与 Gate2 顶部总额,再要求最终控件 Gate3 金额与 Gate2 严格相等 |
| WiFi ADB / 双通道 | 断连或操作错设备 | serial 必填;USB/WiFi 同设备或身份不明时 fail closed |
| 不可逆动作超时 | 可能已创建订单 | 服务端围栏 + 点击一次 + 只调和,不重试 |
| 双端契约漂移 | 静默不兼容 | [api.md](api.md) 唯一权威;契约改动跑完整双端门禁 |
## 七、推荐开发顺序
1. **Phase 0 地基**:双端骨架、测试命令、初始化脚本和原型。
2. **Phase 1 真机取证**:逐段验证商品打开、受控面板入口、精确规格与读价、数量、最终提交面板和提交
控件;真实点击前先完成独立 dry-run。每个 spike 的 capability 只覆盖当期动作。
3. **Phase 2 采购服务核心**:DRAFT 建单/列表、批量开始采购与授权、状态/证据/围栏/调和接口。
4. **Phase 3 双端打通**:设备身份、原子领取、单趟执行到围栏前、事件与截图。
5. **Phase 4 闭环**:经明确真机授权验证一次提交、待付款收口、失败分类、打包。
6. **V2**:图搜、Excel、ERP、自动核对、AI、多设备。
T-103 继续作为“规格选择与读价”的隔离前置,不含数量、最终提交面板或提交。生产单趟并不意味着在一个
任务里跳过逐段取证;它只意味着这些已验证能力集成后,每笔业务任务不再等待中途人工确认。
## 八、项目结构
```text
cmbuyer/
├── docs/
├── admin/
│ ├── cmd/server/
│ ├── internal/domain/
│ ├── internal/usecase/
│ ├── internal/transport/httpapi/
│ ├── internal/transport/webui/
│ ├── internal/storage/
│ └── migrations/
├── client/
│ ├── src/cmbuyer_client/device/
│ ├── src/cmbuyer_client/pdd/
│ ├── src/cmbuyer_client/core/
│ ├── src/cmbuyer_client/remote/
│ ├── src/cmbuyer_client/app/
│ └── tests/
└── scripts/
```
执行器依赖核心端口,不直接读取 Excel 或拼接 HTTP。T-303 只提供 `TaskSource` / `EvidenceSink`;完整
`ResultSink` 在服务端 events/fail/fence/result 契约落地后分阶段组合,来源变化不得改变安全执行器。
## 九、架构纪律
- 业务事实和 schema 变化同步本文与 [api.md](api.md),代码不得另起一套字段或状态。
- 第四节安全边界只能收紧。需要变更时先更新架构、任务边界和理由。
- 页面高风险能力先真机取证并隔离测试,再接入完整流程。
- 前序项目 `cmroubao` / `cmpdd` 只提供设计理由,不提供可直接复用的页面事实。