Files
cmautobuy/docs/client/02-architecture.md
T

465 lines
26 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.
# 02 Client 系统架构
- 文档状态:基线草案,待架构评审
- 适用范围:`client/`
本文档中 `[必须]` / `[建议]` / `[待定]` 的含义见 [文档索引](../README.md#文档标注说明)。没有标注的默认是 `[必须]`。看不懂的词查 [术语表](00-glossary.md)。
**代码现状和本文描述的目标结构不一致,已知差异见 §3.1。**
## 1. 架构目标
Client 采用分层、可替换适配器和本地可靠队列设计,目标是:
- Admin 未完成时仍可通过模拟适配器开发和测试;
- 界面、任务编排、数据库、Admin 接口和 PDD 自动化互相解耦;
- 采购产生不可逆副作用后,即使断网或程序重启也不会重复下单;
- 拼多多界面变化只影响 PDD 适配层,不扩散到界面和领域模型;
- 所有后台结果通过信号回到 Qt 主线程,窗口关闭后不会访问已销毁控件。
## 2. 逻辑架构
```text
┌─────────────────────────────────────────────────────┐
│ 表现层:MainWindow / PDDTaskPage / SettingsPage │
│ TaskTableModel / TaskDetailView │
└──────────────────────┬──────────────────────────────┘
│ 命令与只读视图模型
┌──────────────────────▼──────────────────────────────┐
│ 应用层:TaskCoordinator / TaskDispatcher │
│ ResultSubmissionService │
└───────────────┬───────────────────────┬─────────────┘
│ │
┌───────────────▼─────────────┐ ┌──────▼──────────────┐
│ 领域层:Task / TaskRun │ │ 工作线程 │
│ 状态机 / 结果模型 / 规则 │ │ 单设备串行执行器 │
└───────────────┬─────────────┘ └──────┬──────────────┘
│ │
┌───────────────▼───────────────────────▼─────────────┐
│ 基础设施层 │
│ SQLite Repository / AdminGateway / PDD Adapter │
│ ArtifactStore / Logging / CredentialStore │
└─────────────────────────────────────────────────────┘
```
依赖方向只能由外向内:基础设施实现领域或应用层定义的接口,领域层不得导入 Qt、uiautomator2 或 HTTP 客户端。
## 3. 建议目录
```text
client/
├── buyer_main.py
├── src/
│ ├── domain/
│ │ ├── task_models.py
│ │ ├── task_status.py
│ │ └── result_models.py
│ ├── application/
│ │ ├── task_coordinator.py
│ │ ├── task_dispatcher.py
│ │ └── submission_service.py
│ ├── infrastructure/
│ │ ├── admin/
│ │ ├── db/
│ │ ├── pdd/
│ │ ├── artifacts/
│ │ └── credentials/
│ ├── workers/
│ │ └── task_worker.py
│ └── ui/
│ ├── main_window.py
│ ├── pdd_task_page.py
│ ├── settings_page.py
│ ├── task_table_model.py
│ └── task_detail_view.py
└── tests/
├── unit/
├── integration/
├── fixtures/
└── device/
```
### 3.1 现状与目标的差异
上面是**目标**结构,现在的代码还没到那一步。**下面这些不一致是已知的,不是 bug,看到了不用停下来问。**
| 项目 | 目标(文档描述) | 现状(代码实际) |
|---|---|---|
| 主窗口文件 | `src/ui/main_window.py` | `src/ui_main.py`。**注意**:`client/AGENTS.md` 已按 `src/ui_main.py` 写规则,两处不一致,见下方说明 |
| 目录分层 | domain / application / infrastructure / workers / ui | 平铺在 `src/` 下:`db.py`、`db_schema.py`、`task_repository.py`、`settings_repository.py`、`task_models.py` 等,另有 `src/util/`、`src/demo1/` |
| 主按钮文案 | 「自动获取」⇄「停止自动获取」 | 已按持续串行模式实现 |
| Admin 网关 | `AdminGateway` + Mock/HTTP 两实现 | 登记、领取、结果和失败提交已实现 |
| 任务应用服务 | `TaskDispatcher` / `CollectTaskService` / `PurchaseTaskService` | 自动获取先补交 Outbox,再按领取时间执行本地任务,最后按安全能力领取并分派新任务 |
| Outbox 提交 | 从 `outbox_events` 取件重试 | 采集结果与失败已实现,重试不重复采集 |
| PDD 自动化 | `infrastructure/pdd/` 适配层 | `pdd_device_service.py` 与 `pdd_collect_service.py` 已接入采集主链 |
已经对齐、不再是差异的(保留在这里做个记录,下次可删):
- **顶级导航**:已改为 2 个(pdd任务 / 参数设置),与 [05 界面规范](05-ui-specification.md) §2 一致。
- **PDD 任务页**:已有任务表格(`TaskTableModel` + `TableView`)、类型/状态筛选、
关键词搜索和底部状态条,并实现了 `canFetchMore` 增量加载。
- **SQLite 表结构**:`db_schema.py` 已建好 `pdd_tasks`、`task_runs`、
`outbox_events`、`app_settings` 四张表;`status` 的 8 个取值与
[03 数据模型](03-data-model.md) §3 完全一致,也没有残留已废弃的
`sync_state` / `lease_token` / `admin_status`。
> **关于「主窗口文件」这一行:** 目标写的是 `src/ui/main_window.py`(本文 §3 建议目录),
> 但 `client/AGENTS.md` 的代码分层规则写的是 `src/ui_main.py`,而代码按后者在走。
> **两处文档自相矛盾,需要定一个。** 考虑到代码已经形成了 `src/` 平铺的组织方式
> (`pdd_ui.py` / `settings_ui.py` / `task_repository.py` …),
> 建议把 §3 的建议目录改成与之一致,而不是反过来搬代码。这属于会改基线的决定,要走工单。
处理原则:
- **迁移按工单分批做**,不要顺手大改目录。每次只搬和当前工单相关的那部分。
- 搬完一项就来更新这张表,把对应行删掉。
- `src/demo1/` 是实验脚本,**正式代码不得导入它**(见 §9)。里面的硬编码商品号、设备地址、`print` 都不能带进正式服务。
- 表里没列到、但你发现的新差异:只影响写法的按文档做;会影响业务结果的按 [文档索引](../README.md#文档和代码对不上怎么办) 处理。
## 4. 组件职责
### 表现层
- 只负责渲染、输入转发、焦点和用户反馈。
- 不直接执行 HTTP、SQLite 长查询或 uiautomator2。
- 表格通过稳定任务编号访问数据,不持有完整 `pdd_data`。
- `ui_main.py` 负责应用和窗口装配,业务事件通过应用服务绑定。
### 应用层
- `TaskCoordinator` 管理自动获取开关、领取轮询、调度器状态和安全停止。
- `TaskDispatcher` 按任务类型选择采集或采购执行器。
- `ResultSubmissionService` 从 Outbox 提交结果并处理重试和确认。
没有独立的同步服务——Client 不向 Admin 查询任何东西,领取逻辑并在 `TaskCoordinator` 里。
### 领域层
- 定义任务、执行记录、采集结果、采购结果和状态转换。
- 校验金额、数量、任务版本和允许的状态转换。
- 不关心结果来自 Mock Admin、HTTP 或具体 Android 设备。
### 基础设施层
- `AdminGateway` 定义 Admin 边界;`MockAdminGateway` 和 `HttpAdminGateway` 提供不同实现。
- Repository 封装 SQLite,界面和自动化代码不得直接拼接业务 SQL。
- PDD Adapter 封装设备连接、页面识别、采集和采购。
- ArtifactStore 保存失败截图、无障碍 XML 和结构化诊断文件。
- `UpdateService` 负责认证清单、下载校验和安全暂存;URL/账号由 Repository 保存,
密码只从 Windows 凭据管理器短暂读入内存。正在运行的主程序不替换自身,目录
替换与回退只由下一次启动的 `Launcher.exe` 执行。
## 5. 线程模型
```text
Qt 主线程
├── 窗口、页面、表格模型和用户事件
└── 接收后台信号并更新界面
持久设备工作线程(单设备、固定 QThread)
├── uiautomator2 Device 的创建、健康检查、调用和释放
├── 页面等待与 XML 解析
└── 采集、采购、订单核对和手动批量重新执行的串行命令
结果提交工作线程或同一任务线程的独立队列
└── Outbox 重试,不重复执行 PDD 操作
更新工作线程
├── 读取系统凭据并检查认证清单
└── 下载、SHA256 校验和安全解压到 data/update/
```
- `[必须]` QWidget 只能在 Qt 主线程创建和访问。
- `[必须]` 一个 Android 设备由一个工作线程独占,不跨线程共享 uiautomator2 Device 对象。
- `[必须]` 同一设备在 90 秒空闲期内只复用 Device 连接;每个任务必须重新读取
当前应用、页面、控件树、商品、价格、规格和采购安全状态。
- `[必须]` 停止自动获取、切换或删除已保存设备、设备断连、配置变化、窗口关闭
和空闲超时都会在设备线程释放连接。关闭只使用协作停止和 `quit()/wait()`。
- `[必须]` `task_runs.irreversible_action_at` 有值后只允许把核单命令放入队列,
不得重新执行采购下单步骤。
- `[必须]` 后台信号只传递不可变数据、稳定编号或轻量视图模型。
- `[必须]` 点“停止获取”后不再领取新任务,当前任务在定义的安全点退出。
- `[建议]` 关闭窗口时应选择停止、等待或后台继续;MVP 默认安全停止并持久化状态。
- `[必须]` 更新检查和下载使用独立 `QObject + moveToThread` Worker;下载完成只提示
下次启动生效,不得为了更新强制中断采集或采购任务。
### 5.1 Worker 模板(项目统一写法,照抄即可)
本项目的长任务**统一使用 `QObject` + `moveToThread`** 这一种写法。不要用 `QThread` 子类、`QRunnable` 或 Python 原生 `threading`,混着用会很难排查。
Worker 本体(放在 `src/workers/` 下,**里面一行界面代码都不许有**):
```python
from PyQt5.QtCore import QObject, pyqtSignal
class TaskWorker(QObject):
"""在后台线程里执行一个任务。
输入:任务编号。
输出:通过信号返回,不直接改界面。
"""
# 信号里只放不可变的简单数据,不要放 QWidget,也不要放数据库连接
progressChanged = pyqtSignal(str) # 当前步骤,例如 "collect_skus"
finished = pyqtSignal(str, object) # 任务编号, 结果对象
failed = pyqtSignal(str, str, str) # 任务编号, 错误代码, 错误说明
def __init__(self, remote_task_id: str):
# 注意:不能传 parent,有 parent 的对象没法 moveToThread
super().__init__()
self._remote_task_id = remote_task_id
self._cancelled = False
def cancel(self) -> None:
"""主线程调用。只置一个标志位,绝不强杀线程。"""
self._cancelled = True
def run(self) -> None:
"""线程启动后自动调用。整个函数体必须被 try 包住。"""
try:
for step in ("open_goods", "collect_skus"):
if self._cancelled:
return
self.progressChanged.emit(step)
result = self._do_step(step)
self.finished.emit(self._remote_task_id, result)
except Exception as exc: # 兜底,防止线程静默死掉
self.failed.emit(self._remote_task_id, "PDD_PAGE_UNKNOWN", str(exc))
```
在主线程里启动它:
```python
from PyQt5.QtCore import QThread
def start_task(self, remote_task_id: str) -> None:
# 必须用 self._ 存起来,否则对象被垃圾回收,程序会直接崩
self._thread = QThread(self)
self._worker = TaskWorker(remote_task_id)
self._worker.moveToThread(self._thread)
self._thread.started.connect(self._worker.run)
self._worker.progressChanged.connect(self._on_progress)
self._worker.finished.connect(self._on_finished)
self._worker.failed.connect(self._on_failed)
# 收尾:任务结束 → 退出线程 → 删掉 worker
self._worker.finished.connect(self._thread.quit)
self._worker.failed.connect(self._thread.quit)
self._thread.finished.connect(self._worker.deleteLater)
self._thread.start()
```
新手最容易踩的坑:
| 坑 | 后果 | 正确做法 |
|---|---|---|
| 不用 `self._` 保存 thread/worker | 程序莫名崩溃 | 存成实例属性 |
| 给 Worker 传了 parent | `moveToThread` 失败 | `super().__init__()` 不传 parent |
| `run()` 里没有 try | 后台线程静默死掉,界面一直显示"执行中" | 整个 `run()` 包在 try 里 |
| 用 `thread.terminate()` 停任务 | 数据库写一半、订单状态不明 | 用 `cancel()` 置标志位,在安全点退出 |
| 在 `run()` 里改界面 | 随机崩溃,且很难复现 | 只 emit 信号 |
PDD 设备执行器是这个模板的长生命周期版本:QThread 在第一次设备任务时创建,
后续通过队列信号提交一条命令,命令完成后线程继续等待;不能在 Worker 内写无限
轮询。设备 Worker 仍不访问界面。停止接单后等待当前命令到安全点结束,再在线程内
释放 Device,最后执行 `quit()/wait()`。Outbox 批量重新上报不操作手机,继续使用
独立的网络 Worker,不进入设备命令队列。
### 5.2 后台结果回主线程 / 迟到结果
窗口关掉了,后台任务还在跑,跑完再发信号——这时槽函数去访问已经销毁的控件,程序就崩了。
处理办法:**在窗口关闭时断开连接,并置一个标志位。**
```python
def closeEvent(self, event):
self._closing = True
if getattr(self, "_worker", None) is not None:
self._worker.cancel()
# 断开所有连到本窗口的信号,之后迟到的结果不会再进来
self._worker.progressChanged.disconnect()
self._worker.finished.disconnect()
self._worker.failed.disconnect()
if getattr(self, "_thread", None) is not None:
self._thread.quit()
self._thread.wait(3000) # 最多等 3 秒,别无限期卡住关闭
super().closeEvent(event)
def _on_finished(self, remote_task_id: str, result) -> None:
if self._closing: # 双保险
return
self.statusLabel.setText(f"{remote_task_id} 完成")
```
注意:**断开信号只是不更新界面,任务的数据该落库还是要落库**。落库由应用层负责,和界面在不在没关系——这正是"结果先写本地再提交 Admin"的意义,见 §8。
### 5.3 在线更新启动顺序
```text
主程序:系统凭据 → 认证清单 → 用户确认 → 下载并校验 → app.new + pending.json
Launcher:确认主程序未运行 → app 改名 app.old → app.new 改名 app → 启动主程序
主程序:窗口成功创建 → 写 healthy.json
Launcher:健康标记正确则完成;提前退出则恢复 app.old
```
更新 ZIP 只允许 `app/` 内容,拒绝绝对路径、`..`、反斜杠路径和符号链接。Launcher
不联网、不处理凭据,也不自更新。启动自动检查只在 URL、账号和系统密码都已保存时
执行,网络失败只更新设置页状态,不阻止主窗口使用。
## 6. 任务引擎状态
任务协调器状态:
```text
stopped → polling → claiming → executing → submitting
▲ │ │ │ │
└──────────┴── backoff/error ─────┴────────────┘
```
任务领域状态遵循 [数据模型](03-data-model.md) 的状态机。协调器状态与任务状态必须分开:协调器可能正在轮询,但当前没有任务;任务也可能已经完成但结果仍在提交。
## 7. 数据所有权
分工很简单,记住一句话:**Admin 管"有哪些活、最终算不算数",本地库管"我干了什么"。**
| 谁 | 是什么的权威 |
|---|---|
| Admin | 任务池、任务分配、任务定义、最终业务状态 |
| Client SQLite | **本机已领取任务的执行状态、诊断信息和未提交结果** |
由此得出:
- `[必须]` 本地库**只存已领取的任务**,不是 Admin 任务池的镜像。见 [03 数据模型](03-data-model.md) §3.1。
- `[必须]` 本地**不保存也不查询** Admin 侧状态。Admin 取消了、重派了,Client 一律不感知,照做完照提交。
- `[必须]` `admin_payload` 保留 `claim` 时收到的原始任务,规范化字段用于业务查询。
- `[必须]` PDD 操作完成后,任务状态更新和 Outbox 创建必须在同一 SQLite 事务中完成,写法见 [03](03-data-model.md) §5.1。
因为本地不再镜像任务池、也不回查状态,两边根本没有重叠的数据,
原来那套"同步不得覆盖本地运行状态"的冲突消解规则**整套都不需要了**。这是这个设计最大的好处。
## 8. 核心流程
Client 与 Admin 的任务交互只有三种调用:领一个任务、提交结果、提交失败。
设置页另有一个幂等 Client 登记调用;它不读取、领取或修改任务。
项目仍然没有心跳、租约和 Admin 状态回查。
**没有任何"去问 Admin 现在怎么想"的调用**,理由见 [04 接口契约](04-admin-api-contract.md) §1.1。
### 任务领取与执行
1. 若存在 `irreversible_action_at`,只允许先核对该采购订单;不得补交其他结果、执行任务或领取任务。
2. 没有待核对采购时,优先补交一条本地 Outbox;提交结果不依赖 Android 设备。
3. 没有待提交结果时,在工作线程通过 ADB 检查已保存设备号是否仍为 `device` 状态。检查失败立即停止,不执行本地任务,也不领取新任务。
4. 执行最早的本地待处理任务;没有本地任务时才调用 Admin `claim`。返回 204 表示暂时没活,按轮询周期退避后再试。
5. Client 持久化新领取的任务,状态置 `claimed`。
6. 根据 `task_type` 分派给采集或采购执行器。
7. 工作线程执行,持续更新 `current_step`(只写本地,不上报 Admin)。
8. 完成后在同一事务里写入结果与 Outbox,状态置 `result_pending`。
9. Outbox 提交成功、Admin 返回 `accepted: true` 后标记 `succeeded`,然后继续下一轮。**同一时间只做一个任务。**
`TaskDispatcher` 是能力声明的唯一入口。没有可用采购 Adapter、没有已保存
Android 设备或本地持久化未准备好时,只声明 `collect`;条件满足时才声明
`collect,purchase`。Admin 新建采购任务固定为 live;Client 不再读取手工授权,只有当前
Client 身份有效、已保存 Android 设备且 live Adapter 就绪时才自动声明
`purchase_mode=live`,否则声明 `dry_run` 且不能领取新建的 live 采购任务。领取响应必须先完整校验并
写入 SQLite,Repository 提交成功后才能分派,避免任务已在 Admin 领取却在本地丢失。
中途 Admin 是否取消了这个任务、是否重派给了别人,Client 不查也不管,做完照样提交——
Admin 侧必须无条件接受,见 [04](04-admin-api-contract.md) §6.1。
### 采购崩溃恢复
- 每个采购关键动作前,先在同一 SQLite 事务中更新任务和执行记录的步骤。
- 演练运行中断且没有不可逆标记时,原执行记录先结束为失败,任务再回到
`claimed`;恢复执行必须创建新的 `attempt_id`,不会存在两条并发运行。
- `irreversible_action_at` 有值时,启动恢复立即转为 `manual_review / reconcile_purchase`。
`PurchaseReconcileService` 只能调用独立的只读 Adapter,不会调用采购 Adapter。
- 核单要求订单页提供非空订单编号、有效下单时间和未付款状态;下单时间必须位于
本地 `order_submitted_at` 前后 5 分钟,且窗口内只有一个候选。商品编号来自任务,
规格、数量和确认总价来自不可逆动作前持久化的 `final_confirmation`,不要求订单页
重复提供。未找到、多候选、订单号或时间缺失、非未付款或结果不确定均保持人工处理。
- Admin 提交失败只重试 Outbox,不再次操作拼多多。
## 9. PDD 适配边界
PDD Adapter 对应用层提供稳定接口:
```text
collect(task) -> CollectResult
purchase(task, mode) -> PurchaseResult
reconcile_purchase(task, run) -> PurchaseResult | ManualReview
```
采购演练通过 `PddPurchaseAdapter` 的窄接口逐步读取最新页面状态。该接口只提供
打开商品、读取状态、精确选择动态规格、设置数量、进入提交前确认页和停止,
**不提供提交订单或付款方法**。这样即使应用层调用错误,也没有可误触的真实下单入口。
真实采购另用 `PddLivePurchaseAdapter`,只增加 `submit_order_once`。服务层在最新
页面复核规格、数量、价格、库存和唯一提交目标后,先提交
`irreversible_action_at` 事务,再允许 Adapter 点击一次;之后无论点击结果是否明确,
都只进入订单核对。该接口不提供付款或取消订单方法。
采购规格使用完整 `options` 对象精确比较,不假定只有颜色和尺码两个维度。
只读核对另用 `PddPurchaseReconcileAdapter`,只暴露 `read_order_candidates`
和 `close`,不暴露选规格、设数量、下单或付款方法。
正式只读核单由 `pdd_u2_purchase_reconcile_adapter.py` 实现,只允许使用白名单
导航进入个人中心、我的订单和待付款列表,以及返回和滚动。订单匹配只使用页面中的
订单编号、下单时间和付款状态;页面上出现的商品或金额文字可以解析诊断,但不能覆盖
下单前确认快照。Client 不保存收货人、地址或电话。最终匹配由领域服务完成,不能让
页面解析层单独决定采购成功。
正式 Client 由 `pdd_u2_purchase_adapter.py` 分别实现演练和 live 接口,并在
`ui_main.py` 注入工厂。Adapter 通过设备工作线程绑定的
`PersistentPddDeviceService` 独占一次任务会话;同一设备可在 90 秒内复用底层
Device 连接,但每次判断都重新读取当前包名和控件树。当前 Admin 下发的 `color` 和 `size`
使用精确文字匹配;其他动态维度直接停止,不做相似匹配。原生 PDD
控件树不暴露商品编号,因此商品编号来自 Adapter 本次已校验并打开的
PDD URL,包名和页面类型仍以最新控件树确认。
部分 PDD 页面在点击商品页购买入口后直接进入包含规格和数量的
订单确认页。Adapter 只点击这一次可逆入口;`enter_confirmation`
和 `stop_before_submit` 只读确认“提交订单”等最终按钮存在,**不点击它**。
现有 `wait_goods_page`、规格面板坐标、颜色尺码选择和下单按钮定位函数可以迁移到该适配层。实验脚本中的硬编码商品、设备、文件路径和 `print` 不得进入正式服务。
PDD 页面可能出现登录失效、验证码、控件树不完整、A/B 页面、库存变化和价格变化。适配层必须返回结构化错误,不得把这些情况统一返回 `False`。
采集商品时按“首页就绪 → 读取当前首页摘要 → 有限滚动补采评价和店铺 →
点击规格入口 → 确认规格面板出现 → 颜色列表归左 → 按行蛇形逐色点击并采价 →
向下滚动并只读尺码 → 组装结果”的顺序执行。颜色点击可能改变列表位置,因此每次点击后必须重新读取
控件树,不能复用旧坐标;左右边缘以连续两次没有新颜色且视口稳定为准。
当前业务价格粒度固定为颜色。第一行从左到右、下一行从右到左交替遍历,操作
顺序采用蛇形以减少滑动,输出维度仍恢复为页面每行从左到右的自然顺序。尺码
只读取文字和当前可用状态,不点击、不参与采价。商品首屏的标题和已拼数量必须
保留;首屏缺少评价或店铺时,打开规格面板前小幅、慢速向下浏览并分别累积这两个
字段,不要求它们同时出现在一屏。获取完整、页面不再变化或达到次数上限后停止,
字段仍缺失时保存证据,但不阻塞核心规格采集。
## 10. 关键架构决策
1. 使用 PyQt5、Qt Widgets 和 PyQt-Fluent-Widgets,不混用其他 Qt 绑定。
2. 使用 SQLite 作为本地可靠缓存和执行账本。
3. 使用 Gateway 隔离 Admin,先实现 Mock,再接入 HTTP。
4. 使用 Outbox 保证结果最终提交,并隔离“提交重试”与“业务重做”。
5. MVP 单设备串行执行,不并行控制多个设备。
6. 采集规格采用通用维度和 SKU 组合结构,不把模型锁死为颜色与尺码两个数组。
7. Admin 新建采购任务固定为真实下单(不支付);Client 身份、已选设备和 live Adapter 就绪后自动声明 live,真实设备结果仍按独立工单验收。
## 11. 相关文档
- [上手指南](00-getting-started.md)
- [术语表](00-glossary.md)
- [产品需求基线](01-requirements.md)
- [数据模型](03-data-model.md)
- [Admin 接口契约](04-admin-api-contract.md)
- [界面交互规范](05-ui-specification.md)
- [质量、安全与测试](06-quality-security.md)