raw_data 不进 Git 原始报表含逐商品台币销售额等商业数据,进了 Git 就是永久历史。 - .gitignore 排除 raw_data/ - 改掉三处"仓库里有一份样本"的失真表述,改为向项目负责人索取 - 06 §2.1 相应加强:既然大样本不进库,admin/testdata/ 下的脱敏小样本 就必须提交,否则别人拉下来测试跑不了;并写明脱敏做法 主按钮文案 开始自动获取 → 获取任务 ⇄ 停止获取 只改按钮标签。"自动获取"作为功能名保留(状态栏、Tab 顺序、 协调器开关等处不动),05 §4.1 加了一句说明两者不是一回事。 已知遗留:pdd_ui.py 自身仍不一致——构造时用「获取任务」, 但状态机 485/487 行仍是「开始自动获取」/「停止自动获取」, 会覆盖掉构造时的文字。该文件有未提交改动,本次未触碰, 差异已记入 02 §3.1,需另开工单修。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
17 KiB
02 Client 系统架构
- 文档状态:基线草案,待架构评审
- 适用范围:
client/
本文档中 [必须] / [建议] / [待定] 的含义见 文档索引。没有标注的默认是 [必须]。看不懂的词查 术语表。
代码现状和本文描述的目标结构不一致,已知差异见 §3.1。
1. 架构目标
Client 采用分层、可替换适配器和本地可靠队列设计,目标是:
- Admin 未完成时仍可通过模拟适配器开发和测试;
- 界面、任务编排、数据库、Admin 接口和 PDD 自动化互相解耦;
- 采购产生不可逆副作用后,即使断网或程序重启也不会重复下单;
- 拼多多界面变化只影响 PDD 适配层,不扩散到界面和领域模型;
- 所有后台结果通过信号回到 Qt 主线程,窗口关闭后不会访问已销毁控件。
2. 逻辑架构
┌─────────────────────────────────────────────────────┐
│ 表现层:MainWindow / PDDTaskPage / SettingsPage │
│ TaskTableModel / TaskDetailView │
└──────────────────────┬──────────────────────────────┘
│ 命令与只读视图模型
┌──────────────────────▼──────────────────────────────┐
│ 应用层:TaskCoordinator / TaskDispatcher │
│ ResultSubmissionService │
└───────────────┬───────────────────────┬─────────────┘
│ │
┌───────────────▼─────────────┐ ┌──────▼──────────────┐
│ 领域层:Task / TaskRun │ │ 工作线程 │
│ 状态机 / 结果模型 / 规则 │ │ 单设备串行执行器 │
└───────────────┬─────────────┘ └──────┬──────────────┘
│ │
┌───────────────▼───────────────────────▼─────────────┐
│ 基础设施层 │
│ SQLite Repository / AdminGateway / PDD Adapter │
│ ArtifactStore / Logging / CredentialStore │
└─────────────────────────────────────────────────────┘
依赖方向只能由外向内:基础设施实现领域或应用层定义的接口,领域层不得导入 Qt、uiautomator2 或 HTTP 客户端。
3. 建议目录
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,看到了不用停下来问。
| 项目 | 目标(文档描述) | 现状(代码实际) |
|---|---|---|
| 顶级导航 | 2 个:PDD 任务、设置 | 3 个:pdd、设备、设置(src/ui_main.py) |
| 主窗口文件 | src/ui/main_window.py |
src/ui_main.py |
| 目录分层 | domain / application / infrastructure / workers / ui | 只有 src/、src/util/、src/demo1/ |
| PDD 任务页 | 任务表格 + 搜索 + 状态栏 | 单个商品的输入表单(链接/颜色/尺码 + 开始按钮) |
| 主按钮文案 | 「获取任务」⇄「停止获取」 | 构造时是「获取任务」,但状态机(pdd_ui.py 485/487 行)仍写着「开始自动获取」/「停止自动获取」,会覆盖掉,待修 |
| 页面与事件层 | ui/ 下按页面分文件 |
src/pdd_ui.py、src/pdd_ui_event.py、src/settings_ui.py、src/settings_ui_event.py,目前只有约束 docstring,尚无实现 |
| SQLite / Admin / Outbox / 任务协调器 | 见 §4 | 尚未实现 |
| PDD 自动化 | infrastructure/pdd/ 适配层 |
src/util/ 下的独立函数 + src/demo1/auto_v1.py 演示脚本 |
处理原则:
- 迁移按工单分批做,不要顺手大改目录。每次只搬和当前工单相关的那部分。
- 搬完一项就来更新这张表,把对应行删掉。
src/demo1/是实验脚本,正式代码不得导入它(见 §9)。里面的硬编码商品号、设备地址、print都不能带进正式服务。- 表里没列到、但你发现的新差异:只影响写法的按文档做;会影响业务结果的按 文档索引 处理。
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. 线程模型
Qt 主线程
├── 窗口、页面、表格模型和用户事件
└── 接收后台信号并更新界面
任务工作线程
├── Admin 请求
├── uiautomator2 调用
├── 页面等待与 XML 解析
└── 单个任务的串行执行
结果提交工作线程或同一任务线程的独立队列
└── Outbox 重试,不重复执行 PDD 操作
[必须]QWidget 只能在 Qt 主线程创建和访问。[必须]一个 Android 设备由一个工作线程独占,不跨线程共享 uiautomator2 Device 对象。[必须]后台信号只传递不可变数据、稳定编号或轻量视图模型。[必须]点“停止获取”后不再领取新任务,当前任务在定义的安全点退出。[建议]关闭窗口时应选择停止、等待或后台继续;MVP 默认安全停止并持久化状态。
5.1 Worker 模板(项目统一写法,照抄即可)
本项目的长任务统一使用 QObject + moveToThread 这一种写法。不要用 QThread 子类、QRunnable 或 Python 原生 threading,混着用会很难排查。
Worker 本体(放在 src/workers/ 下,里面一行界面代码都不许有):
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))
在主线程里启动它:
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 后台结果回主线程 / 迟到结果
窗口关掉了,后台任务还在跑,跑完再发信号——这时槽函数去访问已经销毁的控件,程序就崩了。
处理办法:在窗口关闭时断开连接,并置一个标志位。
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. 任务引擎状态
任务协调器状态:
stopped → polling → claiming → executing → submitting
▲ │ │ │ │
└──────────┴── backoff/error ─────┴────────────┘
任务领域状态遵循 数据模型 的状态机。协调器状态与任务状态必须分开:协调器可能正在轮询,但当前没有任务;任务也可能已经完成但结果仍在提交。
7. 数据所有权
分工很简单,记住一句话:Admin 管"有哪些活、最终算不算数",本地库管"我干了什么"。
| 谁 | 是什么的权威 |
|---|---|
| Admin | 任务池、任务分配、任务定义、最终业务状态 |
| Client SQLite | 本机已领取任务的执行状态、诊断信息和未提交结果 |
由此得出:
[必须]本地库只存已领取的任务,不是 Admin 任务池的镜像。见 03 数据模型 §3.1。[必须]本地不保存也不查询 Admin 侧状态。Admin 取消了、重派了,Client 一律不感知,照做完照提交。[必须]admin_payload保留claim时收到的原始任务,规范化字段用于业务查询。[必须]PDD 操作完成后,任务状态更新和 Outbox 创建必须在同一 SQLite 事务中完成,写法见 03 §5.1。
因为本地不再镜像任务池、也不回查状态,两边根本没有重叠的数据, 原来那套"同步不得覆盖本地运行状态"的冲突消解规则整套都不需要了。这是这个设计最大的好处。
8. 核心流程
Client 与 Admin 的全部交互只有三次调用:领一个任务、提交结果、提交失败。 没有任何"去问 Admin 现在怎么想"的调用,理由见 04 接口契约 §1.1。
任务领取与执行
- 调用 Admin
claim领一个任务;返回 204 表示暂时没活,按轮询周期退避后再试。 - Client 持久化任务,状态置
claimed。 - 根据
task_type分派给采集或采购执行器。 - 工作线程执行,持续更新
current_step(只写本地,不上报 Admin)。 - 完成后在同一事务里写入结果与 Outbox,状态置
result_pending。 - Outbox 提交成功、Admin 返回
accepted: true后标记succeeded。 - 回到第 1 步领下一个任务。同一时间只做一个任务。
中途 Admin 是否取消了这个任务、是否重派给了别人,Client 不查也不管,做完照样提交—— Admin 侧必须无条件接受,见 04 §6.1。
采购崩溃恢复
- 在最终提交订单前写入不可逆阶段标记。
- 如果进程在该阶段退出,重启后进入订单核对流程。
- 核对不到订单时进入人工处理,禁止自动重新下单。
- Admin 提交失败只重试 Outbox,不再次操作拼多多。
9. PDD 适配边界
PDD Adapter 对应用层提供稳定接口:
collect(task) -> CollectResult
purchase(task, mode) -> PurchaseResult
reconcile_purchase(task, run) -> PurchaseResult | ManualReview
现有 wait_goods_page、规格面板坐标、颜色尺码选择和下单按钮定位函数可以迁移到该适配层。实验脚本中的硬编码商品、设备、文件路径和 print 不得进入正式服务。
PDD 页面可能出现登录失效、验证码、控件树不完整、A/B 页面、库存变化和价格变化。适配层必须返回结构化错误,不得把这些情况统一返回 False。
10. 关键架构决策
- 使用 PyQt5、Qt Widgets 和 PyQt-Fluent-Widgets,不混用其他 Qt 绑定。
- 使用 SQLite 作为本地可靠缓存和执行账本。
- 使用 Gateway 隔离 Admin,先实现 Mock,再接入 HTTP。
- 使用 Outbox 保证结果最终提交,并隔离“提交重试”与“业务重做”。
- MVP 单设备串行执行,不并行控制多个设备。
- 采集规格采用通用维度和 SKU 组合结构,不把模型锁死为颜色与尺码两个数组。
- MVP 采购默认演练模式,真实下单必须通过独立安全验收。