416 lines
22 KiB
Markdown
416 lines
22 KiB
Markdown
# 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 和结构化诊断文件。
|
||
|
||
## 5. 线程模型
|
||
|
||
```text
|
||
Qt 主线程
|
||
├── 窗口、页面、表格模型和用户事件
|
||
└── 接收后台信号并更新界面
|
||
|
||
任务工作线程
|
||
├── Admin 请求
|
||
├── uiautomator2 调用
|
||
├── 页面等待与 XML 解析
|
||
└── 单个任务的串行执行
|
||
|
||
结果提交工作线程或同一任务线程的独立队列
|
||
└── Outbox 重试,不重复执行 PDD 操作
|
||
```
|
||
|
||
- `[必须]` QWidget 只能在 Qt 主线程创建和访问。
|
||
- `[必须]` 一个 Android 设备由一个工作线程独占,不跨线程共享 uiautomator2 Device 对象。
|
||
- `[必须]` 后台信号只传递不可变数据、稳定编号或轻量视图模型。
|
||
- `[必须]` 点“停止获取”后不再领取新任务,当前任务在定义的安全点退出。
|
||
- `[建议]` 关闭窗口时应选择停止、等待或后台继续;MVP 默认安全停止并持久化状态。
|
||
|
||
### 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 信号 |
|
||
|
||
### 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。
|
||
|
||
## 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. 优先补交一条本地 Outbox。提交结果不依赖 Android 设备。
|
||
2. 没有待提交结果时,在工作线程通过 ADB 检查已保存设备号是否仍为 `device` 状态。检查失败立即停止,不执行本地任务,也不领取新任务。
|
||
3. 执行最早的本地待处理任务;没有本地任务时才调用 Admin `claim`。返回 204 表示暂时没活,按轮询周期退避后再试。
|
||
4. Client 持久化新领取的任务,状态置 `claimed`。
|
||
5. 根据 `task_type` 分派给采集或采购执行器。
|
||
6. 工作线程执行,持续更新 `current_step`(只写本地,不上报 Admin)。
|
||
7. 完成后在同一事务里写入结果与 Outbox,状态置 `result_pending`。
|
||
8. Outbox 提交成功、Admin 返回 `accepted: true` 后标记 `succeeded`。
|
||
9. 回到第 1 步处理下一轮。**同一时间只做一个任务。**
|
||
|
||
`TaskDispatcher` 是能力声明的唯一入口。没有可用采购演练 Adapter、没有已保存
|
||
Android 设备或本地持久化未准备好时,只声明 `collect`;条件满足时才声明
|
||
`collect,purchase`,且 `purchase_mode` 永远是 `dry_run`。领取响应必须先完整校验并
|
||
写入 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。
|
||
- 核对到唯一候选也仍需人工最终确认;未找到、多候选或结果不确定均保持人工处理。
|
||
- Admin 提交失败只重试 Outbox,不再次操作拼多多。
|
||
|
||
## 9. PDD 适配边界
|
||
|
||
PDD Adapter 对应用层提供稳定接口:
|
||
|
||
```text
|
||
collect(task) -> CollectResult
|
||
purchase(task, mode) -> PurchaseResult
|
||
reconcile_purchase(task, run) -> PurchaseResult | ManualReview
|
||
```
|
||
|
||
采购演练通过 `PddPurchaseAdapter` 的窄接口逐步读取最新页面状态。该接口只提供
|
||
打开商品、读取状态、精确选择动态规格、设置数量、进入提交前确认页和停止,
|
||
**不提供提交订单或付款方法**。这样即使应用层调用错误,也没有可误触的真实下单入口。
|
||
采购规格使用完整 `options` 对象精确比较,不假定只有颜色和尺码两个维度。
|
||
只读核对另用 `PddPurchaseReconcileAdapter`,只暴露 `read_order_match`
|
||
和 `close`,不暴露选规格、设数量、下单或付款方法。
|
||
|
||
正式 Client 由 `pdd_u2_purchase_adapter.py` 实现上述演练接口,并在
|
||
`ui_main.py` 注入工厂。Adapter 通过 `PddDeviceService` 独占连接,每次判断
|
||
都重新读取当前包名和控件树。当前 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. MVP 采购默认演练模式,真实下单必须通过独立安全验收。
|
||
|
||
## 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)
|