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

27 KiB
Raw Blame History

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,看到了不用停下来问。

项目 目标(文档描述) 现状(代码实际)
主窗口文件 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 界面规范 §2 一致。
  • PDD 任务页:已有任务表格(TaskTableModel + TableView)、类型/状态筛选、 关键词搜索和底部状态条,并实现了 canFetchMore 增量加载。
  • SQLite 表结构:db_schema.py 已建好 pdd_tasks、task_runs、 outbox_events、app_settings 四张表;status 的 8 个取值与 03 数据模型 §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 都不能带进正式服务。
  • 表里没列到、但你发现的新差异:只影响写法的按文档做;会影响业务结果的按 文档索引 处理。

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. 线程模型

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/ 下,里面一行界面代码都不许有):

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 信号

PDD 设备执行器是这个模板的长生命周期版本:QThread 在第一次设备任务时创建, 后续通过队列信号提交一条命令,命令完成后线程继续等待;不能在 Worker 内写无限 轮询。设备 Worker 仍不访问界面。停止接单后等待当前命令到安全点结束,再在线程内 释放 Device,最后执行 quit()/wait()。Outbox 批量重新上报不操作手机,继续使用 独立的网络 Worker,不进入设备命令队列。

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。

5.3 在线更新启动顺序

主程序:系统凭据 → 认证清单 → 用户确认 → 下载并校验 → app.new + pending.json
Launcher:确认主程序未运行 → app 改名 app.old → app.new 改名 app → 启动主程序
主程序:窗口成功创建 → 写 healthy.json
Launcher:健康标记正确则完成;提前退出则恢复 app.old

更新 ZIP 只允许 app/ 内容,拒绝绝对路径、..、反斜杠路径和符号链接。Launcher 不联网、不处理凭据,也不自更新。启动自动检查只在 URL、账号和系统密码都已保存时 执行,网络失败只更新设置页状态,不阻止主窗口使用。

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 的任务交互只有三种调用:领一个任务、提交结果、提交失败。 设置页另有一个幂等 Client 登记调用;它不读取、领取或修改任务。 项目仍然没有心跳、租约和 Admin 状态回查。 没有任何"去问 Admin 现在怎么想"的调用,理由见 04 接口契约 §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 §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 对应用层提供稳定接口:

collect(task) -> CollectResult
purchase(task, mode) -> PurchaseResult
reconcile_purchase(task, run) -> PurchaseResult | ManualReview

采购演练通过 PddPurchaseAdapter 的窄接口逐步读取最新页面状态。该接口只提供 打开商品、读取状态、精确选择动态规格、设置数量、进入提交前确认页和停止, 不提供提交订单或付款方法。这样即使应用层调用错误,也没有可误触的真实下单入口。 真实采购另用 PddLivePurchaseAdapter,只增加下单前地址标记更新和 submit_order_once。地址标记使用任务现有的 remote_task_id,只替换程序生成的 末尾标记;修改、保存和回读任一步不可靠时在可逆阶段停止。地址只在设备会话内处理, 不进入数据库、日志、诊断产物或接口结果。服务层在地址更新后重新读取最新页面, 复核规格、数量、价格、库存和唯一提交目标后,先提交 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,包名和页面类型仍以最新控件树确认。

颜色已可靠选中但尺码确实不在页面时,采购 Adapter 调用 PddCollectService.collect_second_dimension_candidates,复用采集侧经过真机验证的 第二规格只读遍历。遍历完成后,Adapter 在内存中保留全部观察项及可用状态,并仅为 可购买项按页面顺序生成 c1、c2 等候选和 spec-resolution-v1 快照哈希。只有完整 遍历才能抛出 PddPurchaseSpecResolutionRequired;遍历不完整、页面丢失、没有可购买项, 候选重复、超过 100 条、文字或维度不合法,或完整列表中仍存在目标的精确/繁简等价文字, 都返回普通采购错误。该 Adapter 不调用 Admin,也不点击任何远端解析结果;网络请求和解析后复核属于后续工单。

部分 PDD 页面在点击商品页购买入口后直接进入包含规格和数量的 订单确认页。Adapter 只点击这一次可逆入口;enter_confirmation 和 stop_before_submit 只读确认“提交订单”等最终按钮存在,不点击它。

现有 wait_goods_page、规格面板坐标、颜色尺码选择和下单按钮定位函数可以迁移到该适配层。实验脚本中的硬编码商品、设备、文件路径和 print 不得进入正式服务。

PDD 页面可能出现登录失效、验证码、控件树不完整、A/B 页面、库存变化和价格变化。适配层必须返回结构化错误,不得把这些情况统一返回 False。

规格面板既可能是带 scrollable=true 的滚动容器,也可能是自绘的非滚动容器。 非滚动容器使用组合证据识别:关闭或确认标题、选择摘要、独立规格标题,以及 “确定”按钮;另一种全高面板则必须同时具有唯一数量输入框、唯一加减按钮和底部 提交提示。以“请选择/已选”开头的摘要不是规格标题。已经确认进入规格确认页但结构 仍无法解析时返回规格数据不完整,不得继续误报规格入口或面板加载超时。

采集商品时按“首页就绪 → 读取当前首页摘要 → 有限滚动补采评价和店铺 → 点击规格入口 → 确认规格面板出现 → 颜色列表归左 → 按行蛇形逐色点击并采价 → 向下滚动并只读尺码 → 组装结果”的顺序执行。颜色点击可能改变列表位置,因此每次点击后必须重新读取 控件树,不能复用旧坐标;左右边缘以连续两次没有新颜色且视口稳定为准。

当前业务价格粒度固定为颜色。第一行从左到右、下一行从右到左交替遍历,操作 顺序采用蛇形以减少滑动,输出维度仍恢复为页面每行从左到右的自然顺序。尺码 只读取文字和当前可用状态,不点击、不参与采价。商品首屏的标题和已拼数量必须 保留;首屏缺少评价或店铺时,打开规格面板前小幅、慢速向下浏览并分别累积这两个 字段,不要求它们同时出现在一屏。获取完整、页面不再变化或达到次数上限后停止, 字段仍缺失时保存证据,但不阻塞核心规格采集。

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. 相关文档