Files
cmbuyer/docs/04-architecture.md
T

434 lines
25 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)。
## 一、系统结构
```text
第三方 ERP(顺运宝) Excel 表格 人工填链接
│ │ │
└────────────┬───────────┴────────────────────┘
v
┌─────────────────────────────────────────┐
│ web 端(Go 单二进制) │
│ · 建单与任务生命周期 │
│ · 候选确认与下单授权(唯一决策权威) │
│ · 证据存储与审计 │
│ · 管理 Web(服务端渲染) │
└─────────────────────────────────────────┘
^ HTTP / JSON
│ Bearer Token + 设备绑定
v
┌─────────────────────────────────────────┐
│ desk 端(Python + PySide6) │
│ · 领任务、跑流程、回传结果 │
│ · 本地执行轨迹与证据落盘 │
│ · AI 辅助调用(P1) │
└─────────────────────────────────────────┘
│ ADB(USB / WiFi)
v
┌─────────────────────────────────────────┐
│ Android 手机(拼多多 App) │
└─────────────────────────────────────────┘
```
组件落位:
- web 端:Go + gin,入口 `web/cmd/server/main.go`,模板 `web/internal/transport/webui/templates/`
- desk 端:Python,入口 `desk/src/main.py`,真机流程 `desk/src/android/pdd_flow.py`
- 数据库:SQLite,迁移由 goose 管理
- 证据存储:web 端本地文件系统,SHA-256 寻址
- 外部服务:顺运宝 ERP(只读)、AI provider(P1,仅 desk 端调用)
## 二、职责划分
### web 端
**独占**:
- 任务的创建、状态流转和终态判定
- 候选商品的接收与展示
- **下单授权的签发与作废**——这是唯一的资金决策权威
- 金额上限的判定
- 证据资产的存储与访问控制
- 管理员会话与设备凭据
**不做**:
- 不连接手机、不发 ADB 命令
- 不保存、代理或下发任何 AI provider 凭据
- 不解析拼多多页面
### desk 端
**独占**:
- ADB 连接与设备健康检查
- 拼多多页面识别、点击、选规格、设数量
- 页面截图与节点树采集
- AI 调用(P1)
- 完整执行轨迹的本地留档
**不做**:
- **不自行决定买哪个候选**——必须等 web 端的授权
- **不自行放宽金额上限**——本地校验只能更严,不能更松
- 不直接读 Excel 或访问 ERP
- 不在没有授权的情况下执行任何创建订单的动作
### 权威冲突规则
两端都会校验规格、数量、金额。**判定不一致时一律转人工,不取任一方结论。**
这条是硬规则:双闸门的价值在于分歧能被发现,自动选一边等于把双闸门降级成单闸门。
## 三、两趟执行
这是本项目最核心的结构决策。**MVP 只做 A 路径(任务自带商品链接),分两趟跑完。**
```text
┌──────────────── 第一趟:试选 ────────────────┐
│ desk 端轮询领取待执行任务 │
│ 1. open_product(url) │
│ 2. 打开规格面板 │
│ 3. 按维度精确勾选颜色分类、尺码 │
│ 4. 【闸门一】读该 SKU 单价,算合计 │
│ 5. 截图 │
│ 6. 退出商品,释放手机 │
│ 7. 回传标题 / 选中规格 / 单价 / 合计 / 截图 │
└────────────────────┬─────────────────────────┘
v
任务转 WAITING_CONFIRMATION
│
┌────────────────────┴─────────────────────────┐
│ 人在 web 端确认:机器选对了吗 │
│ 看:需求 vs 选中规格、单价、合计、截图 │
│ 点「确认下单(不付款)」→ 签发授权,锁定授权价 │
│ 或「退回,不买」→ 任务终止 │
└────────────────────┬─────────────────────────┘
v
┌──────────────── 第二趟:下单 ────────────────┐
│ desk 端轮询拿到授权 │
│ 1. 重新 open_product(url) │
│ 2. 重新按维度精确勾选同一规格 │
│ 3. 【闸门二】重读单价,必须与授权价一致 │
│ 4. 设数量并复核 │
│ 5. 进订单确认页 │
│ 6. 【闸门三】读「实付款」,不得超授权上限 │
│ 7. 三个闸门全过 → 点一次「提交订单」 │
│ 8. 回传订单截图 │
└────────────────────┬─────────────────────────┘
v
任务转 WAITING_PAYMENT
│
人工在拼多多核对后付款
```
### 为什么分两趟而不是停在面板上等人
一台手机是瓶颈。若第一趟停在规格面板等人确认,手机被占住跑不了别的任务,面板还可能
超时或被拼多多重置。**第一趟必须退出并释放手机**,第二趟重新进入。
代价是同一商品走两遍,但第二趟很快,而且换来两个好处:手机可以在人思考时继续跑别的
任务的试选;价格变动能被第二趟抓住。
### 三道价格闸门
| 闸门 | 位置 | 作用 | 不通过时 |
| --- | --- | --- | --- |
| 一 | 第一趟规格面板 | 读该 SKU 单价,算合计,回传给人看 | 读不到即停,转人工 |
| 二 | 第二趟规格面板 | 重读单价,**必须与授权时锁定的价格一致** | 不一致即停,转人工 |
| 三 | 订单确认页 | 读「实付款」,不得超授权总额上限 | 超出即停,转人工 |
**闸门二不可省略。** 人确认的是「32.50 元这一单」,不是「这个商品」。拼多多价格波动
常见,不能因为「人已经确认过」就照下不误。
**价格只在规格面板和订单确认页读。** 商品详情页正文和搜索结果卡片上的价格文本在真机上
被拆成多个节点(`¥` 与数字分离)、带 `券后` 一类前缀、实付价 / 原价 / 促销价难以区分
——前序项目在这里耗掉大量时间且无可靠结论。
### B 路径(图片搜索)——V2,MVP 不做
任务只有参考图、没有链接时,需要先搜图找出商品。**该路径推迟到 V2**,MVP 阶段建单必须
提供商品链接。
V2 实现时仍遵守:**图搜的唯一产出是 goods_id**,不在搜索结果卡片上读价格或据价筛选,
拿到 goods_id 后汇入本节的两趟流程。
## 四、安全边界(硬约束)
以下每一条都必须有单元测试证明,且不得在任务中「顺手放宽」。
| 边界 | 规则 | 违反后果 |
| --- | --- | --- |
| 不付款 | 任何路径都不点击支付、免密支付、先用后付或扣款控件 | 真实资金损失 |
| **提交订单四条件** | 见下方专节。四者缺一不可,且**只允许点击一次** | 误下单 / 重复下单 |
| 订单确认页其余零点击 | 除「提交订单」与返回外,不点击确认页上任何控件 | 误触发未知动作 |
| 规格精确匹配 | 按维度等值匹配,防前缀碰撞(`红`/`粉红`、`1`/`10`);找不到即停 | 买错货 |
| 提交订单控件唯一 | 文本精确等于「提交订单」且可点击祖先唯一,否则停 | 点到未知控件 |
| 数量必须复核 | 设置后读回确认精确等于要求值,否则停 | 买错数量 |
| 价格三道闸门 | 见第三节。任一道读不到或不通过即停,**不用其他位置的数字凑合** | 超预算采购 |
| 第一趟不下单 | 试选阶段只勾选规格和读价,**绝不点击「现在买」或任何进入下单流程的入口** | 无授权下单 |
| 外部支付页 | 检测到微信等外部支付交接立即停止、转人工、保留证据 | 凭据泄露 |
| 安全校验 | 检测到验证码、风控、人脸、短信校验立即停止,不尝试绕过 | 封号 / 违规 |
| 敏感信息 | 只读非敏感摘要,不提取收货地址原文、手机号、支付凭据 | 隐私泄露 |
| 授权一次性 | 一笔授权只能产生一笔订单,重复提交幂等 | 重复采购 |
| 服务端提交围栏 | 真机点击前必须由 web 端原子冻结授权并创建唯一提交记录;失败或响应不明不得点击 | 并发 / 断网导致重复下单 |
| App 版本失配即停 | 运行版本与本项目已取证版本不一致时停止领取真机任务,先重新取证 | 旧判据误点新页面 |
### 提交订单的四个前置条件
这是本项目唯一会创建真实待付款订单的动作。**四者同时满足才允许点击,且只点一次:**
1. **授权存在且未消费,并已建立服务端提交围栏**——web 端已签发、desk 端已 ack;
真机点击前,web 端在一个原子事务中把授权从可执行态冻结为本次唯一
`order_submission`。围栏接口失败或响应不明时不得点击。
2. **闸门二通过**——第二趟重读的单价与授权时锁定的价格一致。
3. **闸门三通过**——订单确认页「实付款」不超过授权总额上限。
4. **控件唯一**——文本精确等于「提交订单」且可点击祖先唯一。
点击之后,**无论发生什么都不重试**:
| 点击后观察到 | 处置 |
| --- | --- |
| 正常进入订单结果页 | 回传订单截图,任务转 `WAITING_PAYMENT` |
| 跳转微信等外部支付 | 立即停止,转人工,提示「订单可能已创建、支付未完成」 |
| 安全校验 | 立即停止,转人工,保留证据 |
| 超时或页面无法判定 | 转人工,**预留金额额度**,提示订单状态不明 |
后三种情况一律**禁止自动重试点击**。授权保持永久围栏,结果明确后再记为已消费;在此之前
也绝不能重新开放——宁可人工核实一遍,不可能重复下单。
### dry-run、提交围栏与结果调和
下单被拆成三个不可逆程度不同的阶段,任何客户端本地判断都不能替代服务端围栏:
1. **dry-run(只读演练)**:进入订单确认页,读取规格、数量和「实付款」,确认提交控件
唯一,上传证据后退出。该阶段绝不点击「提交订单」,也不消费授权。
2. **提交围栏**:真实第二趟再次读取并通过三道闸门后,desk 端向 web 端申请围栏。web 端
原子校验任务版本、命令、未消费授权和唯一性,创建 `order_submissions` 记录并冻结授权。
只有明确收到成功响应,desk 端才可点击一次。
3. **结果调和**:点击后只上报观察结果。明确创建则转 `WAITING_PAYMENT`;超时、外部支付、
安全校验或断连均转 `RECONCILIATION_REQUIRED`,保留额度并由人核查。**不得释放围栏、
重新签发授权或自动重试点击。**
授权超时和主动放弃只允许发生在提交围栏建立之前。围栏之后即使租约过期,也只能恢复
同一提交记录并进入调和,不能把任务重新放回可领取队列。
## 五、数据模型
### 5.1 核心实体
```sql
-- 采购任务:创建后业务约束不可变
CREATE TABLE tasks (
id TEXT PRIMARY KEY, -- UUID
source TEXT NOT NULL, -- MANUAL | EXCEL | ERP
source_ref TEXT, -- 外部单号(如虾皮订单号),非内部 id
title TEXT NOT NULL,
goods_id TEXT NOT NULL, -- MVP 必填;B 路径(可为空)推迟到 V2
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, -- 参考图,MVP 可选;B 路径(V2)必填
status TEXT NOT NULL,
version INTEGER NOT NULL DEFAULT 1,-- 乐观锁
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- 第一趟试选结果:人做确认决策的依据
CREATE TABLE spec_trials (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL REFERENCES tasks(id),
attempt INTEGER NOT NULL,
product_title TEXT NOT NULL, -- 商品页读到的标题
selected_color TEXT NOT NULL, -- 实际勾选到的颜色分类
selected_size TEXT NOT NULL, -- 实际勾选到的尺码
unit_price TEXT NOT NULL, -- 闸门一读到的单价
total_price TEXT NOT NULL, -- unit_price × quantity
evidence_sha256 TEXT NOT NULL, -- 规格面板截图
created_at TEXT NOT NULL,
UNIQUE (task_id, attempt)
);
-- 下单授权:唯一的资金决策记录
CREATE TABLE order_authorizations (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL REFERENCES tasks(id),
spec_trial_id TEXT NOT NULL REFERENCES spec_trials(id),
version INTEGER NOT NULL,
goods_id TEXT NOT NULL,
sku_color TEXT NOT NULL,
sku_size TEXT NOT NULL,
quantity INTEGER NOT NULL,
authorized_unit_price TEXT NOT NULL, -- 锁定价:闸门二据此比对
total_price_cap TEXT NOT NULL, -- 授权总额上限:闸门三据此比对
note TEXT, -- 可选备注
status TEXT NOT NULL,
created_by TEXT NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL, -- 围栏前超时自动作废;围栏后不再释放
UNIQUE (task_id, version)
);
-- 真实点击前的服务端一次性围栏;一笔授权最多一条
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),
command_id TEXT NOT NULL,
dry_run_id TEXT NOT NULL,
status TEXT NOT NULL, -- FENCED | SUBMITTED | RECONCILIATION_REQUIRED | MANUAL_RESOLVED
verified_unit_price TEXT NOT NULL,
quantity_read INTEGER NOT NULL,
confirm_page_amount TEXT NOT NULL,
created_at TEXT NOT NULL,
resolved_at TEXT,
UNIQUE (authorization_id),
UNIQUE (command_id)
);
```
`authorized_unit_price` 是第二趟闸门二的比对基准,**必须来自人确认时看到的那个试选
结果**,不能在签发时重新取值。`expires_at` 见 5.3 节。
MVP 的授权没有「选择理由 / 拒绝理由」——那是从多个候选里挑一个时的留档需求。这里人
只回答「机器选对了吗」,保留一个可选 `note` 即可。
金额一律用**十进制字符串**存储和传输,不用浮点数。
### 5.2 状态机
任务状态(web 端权威)。两趟执行对应两次 `CLAIMED → RUNNING`:
```text
PENDING / PENDING_RETRIAL ─claim→ CLAIMED ─start→ RUNNING(TRIAL)
↑ │ ├→ NEEDS_MANUAL
└────────release──────┘ └→ WAITING_CONFIRMATION
├→ CANCELED
└→ AUTHORIZED
└claim→ ORDERING
├→ NEEDS_MANUAL(围栏前失败)
└→ [submission FENCED]
├→ WAITING_PAYMENT
│ └→ SUCCEEDED
└→ RECONCILIATION_REQUIRED
└→ 人工核查 / 调和
```
| 状态 | 含义 |
| --- | --- |
| `RUNNING(TRIAL)` | 第一趟试选中:正在勾选规格、读价、截图 |
| `WAITING_CONFIRMATION` | 试选已回传,**等人确认机器选对了没** |
| `PENDING_RETRIAL` | 旧授权已过期或在围栏前被放弃,必须重新跑第一趟取得新价格 |
| `AUTHORIZED` | 已签发授权,等 desk 端下一轮轮询领走 |
| `ORDERING` | 第二趟下单中:重新选规格、过闸门二三、提交订单 |
| `WAITING_PAYMENT` | 订单已创建,等人在拼多多付款。**这不是成功** |
| `RECONCILIATION_REQUIRED` | 已建立提交围栏,但点击结果不明确;可能已创建订单,只能核查,不能重试 |
| `NEEDS_MANUAL` | 围栏前的转人工情形(规格不匹配、价格不符、页面识别失败等) |
| `SUCCEEDED` | 订单已付款且核对通过 |
授权状态:`PENDING_DELIVERY → DELIVERED → ACKNOWLEDGED → EXECUTING → FENCED → CONSUMED`。
人退回或重新确认时旧授权转 `SUPERSEDED`;超时转 `EXPIRED`。
### 5.3 授权超时(MVP 必做,不得推后)
> **前序项目的教训**:曾出现 `EXECUTING` 授权永不推进,导致确认表单被永久隐藏、任务
> 锁死,只能新建任务绕过。
规则:
- 每笔授权带 `expires_at`。**仅在尚未建立提交围栏时**,超时自动转 `EXPIRED`。
- 授权 `EXPIRED` 后任务转 `PENDING_RETRIAL`,先重新跑第一趟取得新价格,再回到人工确认;
不允许在旧 `spec_trials` 上直接重新确认。
- web 端在围栏建立前提供「放弃当前授权」入口;围栏建立后改为「进入人工核查」,不得
作废或释放授权。
- **任何时候都不允许出现「任务停在某状态且界面上没有任何可用动作」的组合。**
这是验收项,不是实现细节。
授权过期后重新确认时,必须重新走第一趟试选取得新的 `spec_trials` 记录——不能复用旧的
锁定价,因为价格可能已经变了。已建立围栏的授权不参与本超时流程。
### 5.4 证据分层
| 数据 | 位置 | 理由 |
| --- | --- | --- |
| 候选商品页 / 规格页截图 | 上传 web 端 | 管理员做授权决策必须看 |
| 订单确认页截图 | 上传 web 端 | 授权后核对与审计必须留 |
| 订单核对截图 | 上传 web 端 | 资金核对证据 |
| 完整节点树 XML | **仅 desk 端本地** | 体积大、含页面全文、只用于排障 |
| AI 调用记录(P1) | **仅 desk 端本地** | 含 prompt / 响应全文,脱敏成本高 |
| 失败现场快照 | 仅 desk 端本地,可按需手工导出 | 同上 |
上传前必须脱敏:**不上传含收货地址、手机号、支付凭据的截图区域或文本。**
## 六、关键技术难点
| 难点 | 说明 | 应对 |
| --- | --- | --- |
| 拼多多页面结构随版本变化 | 前序项目已观察到详情页无独立规格入口、价格节点拆分等变化 | **每条判据先做真机 spike 取证再写代码**;判据与 App 版本一并记录 |
| 规格面板上的价格位置 | 选中 SKU 后价格显示在哪、是否含券后前缀,未取证 | **T-103 必须一并取证**,闸门一依赖它;读不到就转人工,不用详情页数字凑合 |
| 同一商品两趟结果不一致 | 第二趟价格变了、规格选项变了或商品下架 | 闸门二拦截;一律转人工,不自动放弃也不自动继续 |
| 图搜结果含跨类目商品(V2) | 搜服装出现纸巾 | B 路径只产 goods_id 且限 5 个;后续用 VLM 看截图筛同款 |
| WiFi ADB 稳定性 | 息屏、换网、DHCP 续租会断连 | 超时可配置;断连视为技术失败并保留现场,不重试点击 |
| 同一手机 USB + WiFi 同时在线 | `adb devices` 列出两条,自动选设备会失败 | 设备档案必须显式指定 serial,不允许留空自动选 |
| 不可逆动作的重试 | 点击「现在买」后超时,无法判断订单是否已创建 | 一律转人工并预留金额额度,**禁止自动重试点击** |
| 双端契约漂移 | 两端独立演进会静默不兼容 | 契约改动必跑完整门禁;[api.md](api.md) 是唯一权威 |
**高风险功能先做最小原型。** Phase 1 的真机 spike 必须先于 Phase 2 的界面开发完成。
## 七、推荐开发顺序
1. **Phase 0 地基**:两端骨架、测试命令、`init` 脚本可运行。
2. **Phase 1 真机取证**:WiFi ADB 连通;打开商品 → 打开规格面板 → 按维度精确勾选颜色
分类和尺码 → **读到该 SKU 单价** → 设数量 → 进订单确认页 → 读「实付款」。
**结论写入文档,判据带拼多多 App 版本。**
3. **Phase 2 web 端核心**:数据模型与状态机、手工建单、任务查询、试选结果接收、
确认页与授权签发、**授权超时与放弃**。
4. **Phase 3 双端打通**:设备侧 API、desk 端 `HttpTaskSource`/`HttpResultSink`、
定时轮询、第一趟试选端到端。
5. **Phase 4 闭环收尾**:第二趟下单(含三道闸门与提交)、失败分类、完整验收、打包。
6. **V2 及以后**:图片搜索路径、候选对照台、Excel 导入、ERP 建单、订单自动核对、AI 辅助。
**不要在 Phase 1 结论出来之前写 Phase 2 的页面**——确认页要显示什么,取决于真机上
究竟能读到什么。尤其是闸门一的单价,如果规格面板上读不可靠,整个确认页的设计要改。
## 八、项目结构
```text
cmbuyer/
├── docs/
├── web/ # Go
│ ├── cmd/server/
│ ├── internal/
│ │ ├── domain/ # 实体与状态机,无外部依赖
│ │ ├── usecase/ # 业务用例
│ │ ├── transport/
│ │ │ ├── httpapi/ # 设备侧 API
│ │ │ └── webui/ # 管理页面 + 模板 + 静态资源
│ │ └── storage/ # SQLite 与证据资产
│ └── migrations/
├── desk/ # Python
│ ├── src/
│ │ ├── android/ # adb / device / pdd_flow
│ │ ├── core/ # models / task_runner / sources 抽象
│ │ ├── remote/ # HttpTaskSource / HttpResultSink
│ │ └── app/ # PySide6 GUI
│ └── tests/
└── scripts/
```
`desk/src/core/sources.py` 必须保留 `TaskSource` / `ResultSink` 抽象,执行器只依赖抽象。
这样离线 Excel 模式可作为降级路径存在,且执行器不因来源变化而改动。
## 九、架构纪律
- 业务事实和 schema 变化必须同步更新本文与 [api.md](api.md)。
- 不在代码里发明文档没有的接口、字段和状态。
- 第四节的安全边界不得在任务中放宽;确需变更时先改本文并说明理由。
- 高风险模块先单独真机验证,再接入完整流程。
- 前序项目 `cmroubao` / `cmpdd` 是设计依据,**不是事实来源**;引用其结论时必须在本项目
重新验证。