docs: 建立面向初级程序员的文档基线
按初级程序员可读、可维护的目标重写项目文档,并定案三项设计决策。 新增 - docs/client/00-getting-started.md 上手指南:装环境、跑起来、常见报错 - docs/client/00-glossary.md 术语表:Outbox、幂等、不可逆阶段等 - docs/templates/task.md 工单与归档模板 - client/requirements.txt 固定依赖版本 - .gitignore 屏蔽 data/、打包产物和调试产物 设计定案 - 本地库只存已领取任务,不再镜像 Admin 任务池;已完成任务永久保留 - 去掉租约、心跳和状态回查;Admin 接口从 5 个降到 3 个 (本项目人工付款,重复下单只产生未付款订单,由人工审核处理) - 打包采用 PyInstaller one-dir:launcher.exe + app/ + data/,便携模式 文档改进 - 补齐可直接抄的代码模板:Worker 线程、事务写库、幂等键、增量加载、错误提示、路径解析 - 状态机由 ASCII 图改为完整转换表,并补充崩溃恢复规则 - 消除文档与现有代码的冲突,02 新增“现状与目标差异”清单 - 待确认事项一律给出临时默认值,避免阻塞开发 - AGENTS.md 新增“小改动直通”,工单必填项由 8 项压缩到 6 项 说明:Gitea 尚未配置,本次无对应工单号;AGENTS.md §0 的 Gitea 信息待补。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
# 00 Client 上手指南
|
||||
|
||||
- 文档状态:基线草案
|
||||
- 读者:第一次接手本项目的开发者
|
||||
- 目标:照着做完,能在自己电脑上把界面跑起来,并知道下一步该读哪份文档
|
||||
|
||||
这份文档只讲"怎么把环境搭好、怎么跑起来"。业务规则不在这里,见 [文档索引](../README.md)。
|
||||
遇到不认识的词(Outbox、幂等、不可逆阶段……),先查 [术语表](00-glossary.md),不要跳过。
|
||||
|
||||
## 1. 准备电脑环境
|
||||
|
||||
本项目只在 **Windows** 上开发和运行。
|
||||
|
||||
| 需要什么 | 版本 | 怎么确认已经装好 |
|
||||
|---|---|---|
|
||||
| Python | 3.10 | 打开 PowerShell 执行 `C:/Python310/python.exe --version`,输出 `Python 3.10.x` |
|
||||
| Git | 任意较新版本 | `git --version` 有输出 |
|
||||
| adb(安卓调试工具) | 任意较新版本 | `adb version` 有输出 |
|
||||
|
||||
说明:
|
||||
|
||||
- 项目固定使用 `C:/Python310/python.exe`。**不要用 `python` 这个命令**,因为电脑上可能装了多个 Python,`python` 指向哪一个不确定。本文档所有命令都写全路径。
|
||||
- adb 一般随 Android SDK Platform Tools 安装。装好后要把它所在目录加入系统环境变量 `Path`,否则 `adb` 命令找不到。
|
||||
|
||||
## 2. 安装依赖
|
||||
|
||||
在 PowerShell 里执行(`cd` 到仓库根目录,也就是有 `AGENTS.md` 的那一层):
|
||||
|
||||
```powershell
|
||||
cd D:\chengma\cmautobuy\client
|
||||
C:/Python310/python.exe -m pip install -r requirements.txt
|
||||
```
|
||||
|
||||
预期:最后一行显示 `Successfully installed ...`。
|
||||
|
||||
如果报错 `Could not find a version that satisfies the requirement`,说明 `requirements.txt` 里钉的版本在你的网络源上没有。按下面两步处理:
|
||||
|
||||
```powershell
|
||||
# 1. 先不指定版本装上(把 xxx 换成报错的那个包名)
|
||||
C:/Python310/python.exe -m pip install xxx
|
||||
|
||||
# 2. 查出实际装了哪个版本
|
||||
C:/Python310/python.exe -m pip freeze | Select-String "xxx"
|
||||
```
|
||||
|
||||
然后把查到的版本号填回 `client/requirements.txt`,并在提交信息里说明改了哪个版本。**不要把版本号删掉留空**,`06-quality-security.md` 的发布门禁要求依赖版本必须是固定的。
|
||||
|
||||
## 3. 准备安卓手机
|
||||
|
||||
只做采集/采购的界面开发时**可以跳过这一步**,界面不连手机也能打开。
|
||||
|
||||
1. 手机打开「开发者选项」→「USB 调试」。
|
||||
2. 用数据线连电脑,执行 `adb devices`,能看到一行设备号 + `device`。
|
||||
3. 打开无线调试(这样后面不用一直插着线):
|
||||
|
||||
```powershell
|
||||
adb tcpip 5555
|
||||
adb connect 192.168.0.173:5555
|
||||
```
|
||||
|
||||
把 `192.168.0.173` 换成手机的实际 IP(手机「设置 → 关于手机 → 状态信息」里能看到)。
|
||||
|
||||
4. 初始化 uiautomator2(只需做一次,它会往手机装一个辅助 App):
|
||||
|
||||
```powershell
|
||||
C:/Python310/python.exe -m uiautomator2 init
|
||||
```
|
||||
|
||||
5. 手机上登录好拼多多。**项目不做自动登录,也不绕过验证码**,见 `01-requirements.md` §9。
|
||||
|
||||
## 4. 跑起来
|
||||
|
||||
```powershell
|
||||
cd D:\chengma\cmautobuy\client
|
||||
C:/Python310/python.exe buyer_main.py
|
||||
```
|
||||
|
||||
**这个命令是安全的**:它只打开界面,不会连手机、不会下单。放心随便跑。
|
||||
|
||||
仓库根目录还有一个 `run_buy.bat`,双击也能启动,但它用的是 `python` 而不是 `C:/Python310/python.exe`,多版本环境下可能启动失败。**开发时请用上面的命令,不要用 bat**。
|
||||
|
||||
## 5. 你应该看到什么
|
||||
|
||||
一个窗口打开,左边是导航栏,包含几个页面(pdd / 设备 / 设置)。窗口标题是「采集采购工具」。
|
||||
|
||||
看到窗口 = 环境没问题,可以开始干活了。
|
||||
|
||||
> 注意:现在的界面和 `05-ui-specification.md` 描述的**不一样**(页面数量、按钮文字都不同)。这不是你装错了,是代码还没改到目标状态。差异清单见 `02-architecture.md` §3.1。
|
||||
|
||||
## 6. 常见报错
|
||||
|
||||
| 报错 | 原因 | 怎么办 |
|
||||
|---|---|---|
|
||||
| `ModuleNotFoundError: No module named 'src'` | 你在仓库根目录跑的 `buyer_main.py` | `cd` 到 `client` 目录再跑 |
|
||||
| `ModuleNotFoundError: No module named 'qfluentwidgets'` | 依赖没装,或装到了别的 Python 上 | 回到第 2 步,注意用 `C:/Python310/python.exe -m pip` |
|
||||
| `ImportError: DLL load failed`(导入 PyQt5 时) | 装了多个 Qt 绑定互相冲突 | 卸载干净再装:`pip uninstall PyQt5 PyQt6 PySide2 PySide6 -y`,然后重新执行第 2 步 |
|
||||
| 窗口一闪就没了 | 直接双击 .py 文件运行的 | 必须在 PowerShell 里跑,才能看到错误信息 |
|
||||
| `adb: device offline` / `connect failed` | 手机和电脑不在同一个 WiFi,或手机重启过 | 重新执行 `adb connect <手机IP>:5555` |
|
||||
| 跑 demo 后目录里多出 `xxx_home.xml`、`xxx_home.png` | `src/demo1/auto_v1.py` 会把控件树和截图写到当前目录 | 正常现象,这些是调试产物,**不要提交到 Git** |
|
||||
|
||||
界面相关的问题排查不了时,先确认是不是 Qt 主线程被卡住了 —— 见 `02-architecture.md` §5。
|
||||
|
||||
## 7. 数据库在哪、怎么看
|
||||
|
||||
程序自己产生的东西**全在 `data/` 目录下**,不会散落到别处:
|
||||
|
||||
```text
|
||||
client/data/
|
||||
├── client.db 数据库
|
||||
├── logs/ 运行日志
|
||||
└── artifacts/ 失败截图和控件树 XML
|
||||
```
|
||||
|
||||
跑源码时它就在 `client\data\`;将来打包成 exe 后,它在 `launcher.exe` 旁边。
|
||||
|
||||
想看里面的数据,装一个免费工具 **DB Browser for SQLite**,用它打开 `client.db` 即可。表结构和每个字段什么意思,见 [03 数据模型](03-data-model.md)。
|
||||
|
||||
> 写代码时**不要自己拼这个路径**,调 `data_dir()` 函数,见 [03 数据模型](03-data-model.md) §2.2。
|
||||
|
||||
> 注意:程序正在运行时也可以用工具打开数据库读数据(项目用的是 WAL 模式)。但**不要用工具改数据**,容易和程序打架。
|
||||
|
||||
## 8. 常用代码模板在哪
|
||||
|
||||
写代码时不用自己从零想,这几段直接抄:
|
||||
|
||||
| 我要做 | 抄哪里 |
|
||||
|---|---|
|
||||
| 把耗时活儿放到后台线程 | [02 架构](02-architecture.md) §5.1 Worker 模板 |
|
||||
| 后台结果安全地更新界面 | [02 架构](02-architecture.md) §5.2 |
|
||||
| 任务状态和 Outbox 一起写库 | [03 数据模型](03-data-model.md) §5.1 |
|
||||
| 生成幂等键 | [03 数据模型](03-data-model.md) §5.2 |
|
||||
| 表格滚动到底自动加载更多 | [05 界面规范](05-ui-specification.md) §5.3 |
|
||||
| 弹一个可重试的错误提示 | [05 界面规范](05-ui-specification.md) §9.1 |
|
||||
|
||||
## 9. 提交代码前的自检
|
||||
|
||||
```powershell
|
||||
cd D:\chengma\cmautobuy\client
|
||||
|
||||
# 1. 语法能过
|
||||
C:/Python310/python.exe -m py_compile src/ui_main.py
|
||||
|
||||
# 2. 界面不开窗口也能建起来(不会弹窗,跑完自动退出)
|
||||
$env:QT_QPA_PLATFORM="offscreen"
|
||||
C:/Python310/python.exe -c "from src.ui_main import MainWindow; from PyQt5.QtWidgets import QApplication; app=QApplication([]); w=MainWindow(); print('OK'); app.quit()"
|
||||
Remove-Item Env:QT_QPA_PLATFORM
|
||||
```
|
||||
|
||||
第 2 条打印出 `OK` 就算通过。完整的验证要求见 [client/AGENTS.md](../../client/AGENTS.md) §验证。
|
||||
|
||||
## 10. 接下来读什么
|
||||
|
||||
不用一次读完全部文档。按你要做的事挑:
|
||||
|
||||
| 我要做的事 | 读这份 |
|
||||
|---|---|
|
||||
| 改界面、加页面、调表格 | [05 界面交互规范](05-ui-specification.md) |
|
||||
| 加字段、改表、写 SQL | [03 数据模型](03-data-model.md) |
|
||||
| 对接 Admin、写 Gateway | [04 接口契约](04-admin-api-contract.md) |
|
||||
| 写自动化、点手机 | [02 架构](02-architecture.md) §9 + [06 质量与安全](06-quality-security.md) §4 |
|
||||
| 搞不清这功能到底要不要做 | [01 产品需求基线](01-requirements.md) |
|
||||
|
||||
无论做哪一样,都必须先看一遍 [client/AGENTS.md](../../client/AGENTS.md)(技术栈和红线)。
|
||||
@@ -0,0 +1,116 @@
|
||||
# 00 术语表
|
||||
|
||||
- 文档状态:基线草案
|
||||
- 用途:其他文档里出现的专业词,在这里能查到一句话解释
|
||||
|
||||
看文档时遇到不认识的词,先查这里。**不要靠猜**——这些词大多和数据安全、不重复下单直接相关,猜错会写出真出问题的代码。
|
||||
|
||||
## 1. 最重要的三个
|
||||
|
||||
这三个词撑起了整个项目的可靠性设计,先看懂它们。本节最后还解释了一件事:
|
||||
为什么本项目**没有**别的任务系统常见的"租约"和"心跳"。
|
||||
|
||||
### 幂等(idempotent)
|
||||
|
||||
**一句话:** 同一个操作做一次和做十次,结果一样。
|
||||
|
||||
**为什么要有:** 网络会超时。你给 Admin 发了"采购成功",但没收到回复——你不知道 Admin 到底收到没有。如果直接重发,Admin 可能记两笔。幂等就是让重发变安全:每次提交都带一个**幂等键**(`Idempotency-Key`),Admin 看到相同的键就知道"这条我处理过了",直接返回上次的结果,不会重复记账。
|
||||
|
||||
**在本项目里:** 幂等键存在 `outbox_events.idempotency_key`,格式见 [03 数据模型](03-data-model.md) §5.2。
|
||||
|
||||
### Outbox(待发件箱)
|
||||
|
||||
**一句话:** 一张本地表,结果先写进这张表,再由后台慢慢发给 Admin。
|
||||
|
||||
**为什么要有:** 手机上已经下单了,这一步**不可撤销**。如果这时候网断了,直接把结果丢掉,就等于白下单一次还没人知道。所以流程必须是:先把结果落到本地数据库(连同 Outbox 记录,在同一个事务里),再由后台反复重试发送。发不出去就一直留着,程序重启也还在。
|
||||
|
||||
**关键区别:** Outbox 重试的是**"提交这个结果"**,不是**"重新做一遍这个任务"**。发送失败只会重发消息,绝不会再去手机上点一次下单。
|
||||
|
||||
**在本项目里:** 表 `outbox_events`,见 [03 数据模型](03-data-model.md) §5。
|
||||
|
||||
### 不可逆阶段标记
|
||||
|
||||
**一句话:** 数据库里的一个时间戳,意思是"这个任务我已经点过下单了,撤不回来了"。
|
||||
|
||||
**为什么要有:** 程序可能在下单那一刻崩溃或断电。重启后它不知道刚才那单到底成没成——
|
||||
这时候如果傻乎乎地重跑一遍,就会买两次。所以规则是:**点下单之前先把这个标记写进数据库**。
|
||||
重启后只要看到它有值,就只准去"我的订单"里核对,绝不准重新下单。
|
||||
|
||||
**为什么它现在特别重要:** 本项目没有租约和心跳(见下方说明),
|
||||
防重复下单**全靠它**。改动相关代码要格外小心。
|
||||
|
||||
**在本项目里:** `task_runs.irreversible_action_at`,规则见 [03 数据模型](03-data-model.md) §7.3。
|
||||
|
||||
### 为什么没有"租约"和"心跳"
|
||||
|
||||
你可能在别的任务系统里见过这两个词——Admin 发一个限时凭证证明"这个任务归你",
|
||||
Client 定期发心跳续期,过期就得停手。**本项目故意没有这套东西。**
|
||||
|
||||
原因:本项目不自动付款,采购止于创建订单。就算两台 Client 撞车各下一单,
|
||||
产生的也只是重复的**未付款**订单,人工审核时不付即可。为这点代价引入租约、心跳、
|
||||
过期判断和一整套中断逻辑,不划算。
|
||||
|
||||
Admin 想知道某台 Client 是不是卡死了,用"领取后超时重派"就行,Client 不参与。
|
||||
完整理由见 [04 接口契约](04-admin-api-contract.md) §1.1。
|
||||
|
||||
## 2. 数据和接口
|
||||
|
||||
| 词 | 一句话解释 | 在本项目里指 |
|
||||
|---|---|---|
|
||||
| 分配 | Admin 决定某个任务归哪台 Client 做。**分配 ≠ 领取**:分配是 Admin 单方面指定,领取是 Client 主动调 `claim` 去拿 | 见 [04](04-admin-api-contract.md) §5.1 |
|
||||
| 退避 / backoff | 重试失败后逐次拉长等待时间(1秒、2秒、4秒……),别把服务端打垮 | Outbox 重试和领取轮询,见 [04](04-admin-api-contract.md) §8 |
|
||||
| DTO | 只用来在系统之间传数据的简单对象,没有业务逻辑 | Admin 接口收发的那些数据结构 |
|
||||
| Repository | 专门放数据库读写代码的一层。**别的地方不许直接写 SQL** | `src/infrastructure/db/` 下的类 |
|
||||
| Gateway | 隔离外部系统的一层。换实现(Mock ↔ 真 HTTP)时,上层代码不用改 | `AdminGateway` / `MockAdminGateway` |
|
||||
| 状态机 | 规定"什么状态能变成什么状态"的一张表,不在表里的变化就是 bug | 任务状态转换,见 [03](03-data-model.md) §7 |
|
||||
| 便携模式 | 程序和它的数据放在同一个文件夹里,整个拷走就能用,不往系统目录写东西 | `data/` 在 `launcher.exe` 旁边,见 [03](03-data-model.md) §2.1 |
|
||||
| frozen | Python 判断"我现在是不是打包成 exe 在跑"的标志 | `getattr(sys, "frozen", False)`,见 [03](03-data-model.md) §2.2 |
|
||||
| one-dir / one-file | PyInstaller 的两种打包形态:one-dir 产出一个文件夹,one-file 挤成单个 exe。**本项目用 one-dir**,好排查 | 见 [01 需求](01-requirements.md) §8.1 |
|
||||
| WAL | SQLite 的一种工作模式,好处是"读"和"写"可以同时进行,不互相锁死 | 建库时 `PRAGMA journal_mode = WAL` |
|
||||
| 事务 | 一组数据库操作,要么全部成功,要么全部不生效 | 写任务结果 + 写 Outbox 必须在一个事务里 |
|
||||
| schema_version | JSON 数据的版本号。以后结构变了,靠它认出旧数据 | `pdd_data` 和 `admin_payload` 的第一个字段 |
|
||||
|
||||
## 3. 界面和线程
|
||||
|
||||
| 词 | 一句话解释 | 在本项目里指 |
|
||||
|---|---|---|
|
||||
| Qt 主线程 | 唯一允许创建和修改界面控件的线程。**在这个线程里做耗时的事,界面就会卡死转圈** | 见 [02 架构](02-architecture.md) §5 |
|
||||
| Worker | 放在后台线程里干耗时活儿(联网、点手机、读大文件)的对象 | `TaskWorker`,模板见 [02](02-architecture.md) §5.1 |
|
||||
| 信号 / signal | Qt 里跨线程传消息的机制。后台线程算完了,用信号通知主线程去更新界面 | 后台结果回主线程的唯一正规途径 |
|
||||
| 模型/视图 | 表格分成两半:数据在"模型"里,显示在"视图"里。好处是几万行也不卡 | `TaskTableModel` + `TableView` |
|
||||
| 增量加载 | 表格先只加载 50 行,用户滚到底了再去数据库拿下一批 | `canFetchMore` / `fetchMore`,见 [05](05-ui-specification.md) §5.3 |
|
||||
| 委托 / delegate | 告诉表格"这一列要画成按钮/链接"的画笔对象。**比给每个格子塞一个真控件省资源得多** | "详情"那一列 |
|
||||
| InfoBar | Fluent 风格的横条提示。可以停着不消失,还能带按钮 | 可恢复错误的标准提示方式 |
|
||||
| 模态对话框 | 弹出来必须先处理它、否则动不了主窗口的窗口。**很打断人,只在必须做决定时用** | 高危确认 |
|
||||
| DPI / 显示缩放 | Windows 里"文字和应用的大小"那个百分比设置 | 需验证 100%~200% |
|
||||
| 高对比度 | Windows 的一种无障碍主题,颜色极简 | 需验证界面不能只靠颜色区分状态 |
|
||||
|
||||
## 4. 自动化和安全
|
||||
|
||||
| 词 | 一句话解释 | 在本项目里指 |
|
||||
|---|---|---|
|
||||
| uiautomator2 | 用 Python 控制安卓手机点击、滑动、读屏幕的库 | 采集和采购的执行手段 |
|
||||
| 控件树 / hierarchy | 手机当前这一屏所有元素的结构化描述(XML),能看到每个元素的文字和坐标 | `device.dump_hierarchy()` 的返回值 |
|
||||
| adb | 电脑和安卓手机通信的命令行工具 | 连设备用 |
|
||||
| SKU | 一个具体的、可以单独下单的商品组合。比如"黑色 + L 码"是一个 SKU,"黑色 + M 码"是另一个 | `pdd_data.skus`,每个 SKU 有自己的价格 |
|
||||
| 规格维度 | 选择商品时的一类选项,比如"颜色"是一个维度,"尺码"是另一个维度 | `pdd_data.dimensions` |
|
||||
| 不可逆阶段 | 一旦跨过去就撤不回来的操作阶段(真的把订单提交出去了) | 进入前必须先写 `irreversible_action_at`,见本文 §1 |
|
||||
| 演练模式 / dry_run | 前面步骤全做(选规格、选数量、算价格),**就是不点最后那个下单按钮** | 默认模式,见 [01](01-requirements.md) §8 |
|
||||
| 人工处理 / manual_review | 程序判断不了,停下来等人看。**绝不自己猜着往下做** | 任务状态之一,比如查到多个可能的订单时 |
|
||||
| 订单核对 / reconcile | 程序崩了、不确定到底下单成功没有,去"我的订单"里找证据 | 崩溃恢复的唯一正确做法,**不是重新下单** |
|
||||
| Artifact / 诊断产物 | 出错时留下的截图、控件树 XML、步骤日志,用来事后查原因 | 存在文件里,数据库只存路径 |
|
||||
| 脱敏 | 把敏感信息(token、Cookie、手机号、地址)从日志和截图里抹掉再保存 | 写 Artifact 前必做 |
|
||||
| 门禁 | 一组必须全部满足才允许做某事的条件清单 | 真实下单门禁,见 [06](06-quality-security.md) §3 |
|
||||
| 契约测试 | 用同一套测试去考 Mock 和真实 HTTP 两种实现,确保它们行为一致 | `AdminGateway` 的两个实现都要过 |
|
||||
|
||||
## 5. 流程相关
|
||||
|
||||
| 词 | 一句话解释 |
|
||||
|---|---|
|
||||
| 大工单 / Epic | 一个完整产品目标的总纲,下面挂很多小工单 |
|
||||
| MVP | 最小可用版本。先做出来能用的一小块,而不是一次做完所有功能 |
|
||||
| 单元任务工单 | 实际干活的最小单位,一个人一次能做完、能单独测、能单独回退 |
|
||||
| 归档 | 任务做完后,把结论写成 `docs/task/<工单号>-<名称>.md` 存起来 |
|
||||
| 基线文档 | `docs/client/` 下这几份,记录长期稳定的规则。改它们要谨慎 |
|
||||
|
||||
流程细节见仓库根目录 [AGENTS.md](../../AGENTS.md)。
|
||||
@@ -0,0 +1,245 @@
|
||||
# 01 Client 产品需求基线
|
||||
|
||||
- 文档状态:基线草案,待需求评审
|
||||
- 适用范围:`client/`
|
||||
- 产品类型:Windows 桌面自动化客户端
|
||||
|
||||
本文档中 `[必须]` / `[建议]` / `[待定]` 的含义见 [文档索引](../README.md#文档标注说明)。没有标注的默认是 `[必须]`。看不懂的词查 [术语表](00-glossary.md)。
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
Client 从 Admin 获取拼多多任务,在一台已授权的 Android 设备上通过 uiautomator2 执行商品数据采集或采购操作,并将结果可靠地提交回 Admin。
|
||||
|
||||
产品目标:
|
||||
|
||||
1. 在统一界面中查看、搜索和跟踪**本机已领取**的全部 PDD 任务(含正在做的和做完的)。
|
||||
2. 自动领取并串行执行采集任务和采购任务。
|
||||
3. 在 Admin 或网络暂时不可用时保存结果并安全重试,不能丢失采集结果或重复采购。
|
||||
4. 为失败、异常页面和不确定订单提供可诊断、可恢复的人工处理入口。
|
||||
5. 在 Admin 尚未完成时,通过本地模拟接口完成大部分 Client 开发和测试。
|
||||
|
||||
## 2. 用户与外部系统
|
||||
|
||||
- **操作人员:**配置 Admin 和 Android 设备,启动或停止自动获取任务,查看状态和详情,处理异常。
|
||||
- **Admin:**创建任务、分配任务、维护任务状态,并接收 Client 执行结果。
|
||||
- **Android 设备:**登录并运行拼多多应用,由 Client 独占一个自动化执行会话。
|
||||
- **拼多多应用:**被自动化操作的外部系统,不属于本项目控制范围。
|
||||
|
||||
## 3. 产品范围
|
||||
|
||||
Client 顶级导航仅包含:
|
||||
|
||||
1. **PDD 任务**:本机已领取任务的展示、搜索、自动执行和详情查看。
|
||||
2. **设置**:Admin、Android 设备、自动化、安全和诊断设置。
|
||||
|
||||
设备连接不作为独立顶级模块,统一放入设置页。
|
||||
|
||||
**Client 不显示 Admin 任务池。** 本地只保存本机已领取的任务(含执行中和已完成),
|
||||
不缓存"待领取"的任务。要拿新任务只有一条路——由任务引擎向 Admin 领取。
|
||||
操作人员想知道队列里还有多少活没派下来,去 Admin 自己的界面看。
|
||||
这条决定的完整理由见 [03 数据模型](03-data-model.md) §3.1。
|
||||
|
||||
## 4. 任务类型
|
||||
|
||||
### 4.1 采集任务
|
||||
|
||||
输入至少包含远程任务编号和商品链接。Client 应采集:
|
||||
|
||||
- 商品编号、商品链接和商品标题;
|
||||
- 店铺名称;
|
||||
- 已拼数量及原始显示文字;
|
||||
- 评价数量及原始显示文字;
|
||||
- 所有规格维度及其可见值;
|
||||
- 每个规格组合对应的价格、币种和可用状态;
|
||||
- 采集时间、设备及必要诊断产物引用。
|
||||
|
||||
颜色、尺码和价格不能只保存为互不关联的列表,必须以 SKU 规格组合保存价格关系。对于“1.2 万+”等近似数值,同时保存规范化数值、原始文字和近似标记。
|
||||
|
||||
### 4.2 采购任务
|
||||
|
||||
输入至少包含:
|
||||
|
||||
- 远程任务编号;
|
||||
- 商品编号或商品链接;
|
||||
- 目标颜色和尺码;
|
||||
- 采购数量;
|
||||
- 预期价格或最高允许价格;
|
||||
- 任务版本及幂等信息。
|
||||
|
||||
Client 应执行:
|
||||
|
||||
1. 校验商品、规格、数量、库存和价格保护条件。
|
||||
2. 选择指定颜色、尺码和数量。
|
||||
3. 进入订单提交前状态并再次校验价格。
|
||||
4. 在允许真实下单时提交订单。
|
||||
5. 获取并核对订单编号和下单时间。
|
||||
6. 保存本地结果并提交 Admin。
|
||||
|
||||
仅凭“我的订单”列表中的最新一条记录不能认定为当前任务订单。必须结合任务开始时间、商品、规格、数量和金额核对;存在多个候选时进入人工处理状态,不得猜测或重新下单。
|
||||
|
||||
## 5. PDD 任务页需求
|
||||
|
||||
页面分为三个稳定区域。
|
||||
|
||||
### 5.1 顶部命令区
|
||||
|
||||
- “开始自动获取”是持续任务引擎的主操作,启动后切换为“停止自动获取”。
|
||||
- 停止自动获取只停止领取新任务;当前任务应运行到安全停止点。
|
||||
- 搜索条件至少支持任务类型、任务状态和关键词。
|
||||
- 关键词搜索任务编号、商品编号和商品标题。
|
||||
- “搜索”只查询本地数据库,不触发任务执行。
|
||||
|
||||
### 5.2 中部任务表格
|
||||
|
||||
表格显示的是**本机已领取的全部任务**,包括正在做的和早已做完的。已完成的任务永久保留,不会被清理。
|
||||
|
||||
表格要让操作人员一眼看出:这是什么任务、买的什么、什么规格、多少钱、现在到哪一步了。
|
||||
|
||||
**具体列有哪些、每列怎么显示,以 [05 界面交互规范](05-ui-specification.md) §5.1 为准**(那是唯一权威定义,改列只改那一处)。
|
||||
|
||||
本文只规定不可动摇的业务要求:
|
||||
|
||||
- 默认按 `updated_at DESC, id DESC` 稳定排序。
|
||||
- 所有任务可通过增量加载访问,禁止为每个单元格创建常驻复杂控件。
|
||||
- `pdd_data` 是详情数据,不作为隐藏表格列整体加载;表格模型只保留稳定任务编号,查看详情时从数据库读取。
|
||||
- 单击行只选择,双击、Enter 或“详情”打开任务详情。
|
||||
|
||||
### 5.3 底部状态区
|
||||
|
||||
持续显示:
|
||||
|
||||
- 自动获取是否开启;
|
||||
- 当前任务编号和类型;
|
||||
- 当前执行步骤;
|
||||
- 最近一次领取到任务的时间;
|
||||
- 最近一次成功或可恢复错误摘要。
|
||||
|
||||
重要错误应同时使用持久信息条提供重试或进入设置的入口,不能只依赖自动消失提示。
|
||||
|
||||
## 6. 设置页需求
|
||||
|
||||
设置分为以下组:
|
||||
|
||||
1. **Admin:**服务地址、Client 编号、认证状态、请求超时和测试连接。
|
||||
2. **Android 设备:**ADB 地址、拼多多包名、设备测试连接。
|
||||
3. **自动化:**轮询周期、任务超时、最大重试次数和演练模式。
|
||||
4. **安全与诊断:**价格允许偏差、最大购买数量、日志目录、截图/XML 保留周期。
|
||||
|
||||
密码和访问令牌不得以明文写入普通 SQLite 设置或日志。
|
||||
|
||||
## 7. 任务状态
|
||||
|
||||
任务共有 8 个标准状态:`claimed`、`running`、`result_pending`、`retry_wait`、`manual_review`、`succeeded`、`failed`、`cancelled`。
|
||||
|
||||
没有"待领取"状态——任务在领取成功那一刻才写进本地库,见 [03 数据模型](03-data-model.md) §3.1。
|
||||
|
||||
**每个状态的中文显示、含义和允许的转换,以 [03 数据模型](03-data-model.md) §7 为准**(那是唯一权威定义,写代码和写测试都看那一张表)。
|
||||
|
||||
本文只强调两条产品级红线:
|
||||
|
||||
- 任何自动流程都不许把 `manual_review` 改回执行中状态,必须由人处理。
|
||||
- Client 拿到任务就做完。中途 Admin 取消了这个任务,Client **不管**,照做完照提交,由人工审核时处理。
|
||||
- 程序崩溃后,处于不可逆采购阶段的任务只能执行订单核对,**不能重新下单**。
|
||||
|
||||
## 8. MVP 范围
|
||||
|
||||
MVP 包含:
|
||||
|
||||
- 两模块 Fluent 界面;
|
||||
- SQLite 数据库和迁移;
|
||||
- 本地任务表格、搜索、筛选和详情;
|
||||
- Mock Admin 接口;
|
||||
- 任务领取与串行执行;
|
||||
- 后台任务协调器、轮询、停止、重试和结果待提交队列;
|
||||
- 真实商品采集及 SKU 数据组装;
|
||||
- 采购演练模式,完成规格和数量选择但不执行最终下单;
|
||||
- 重启恢复、断网、设备断开和重复提交测试。
|
||||
|
||||
MVP 之后:
|
||||
|
||||
- 接入真实 Admin HTTP 接口;
|
||||
- 经过安全验收后启用真实订单提交;
|
||||
- 完成订单核对和人工处理流程;
|
||||
- 打包成 exe 并做生产环境验证(策略见 §8.1)。
|
||||
|
||||
### 8.1 打包策略
|
||||
|
||||
**打包动作属于 MVP 之后**,但策略现在就定下来,避免写代码时留下不好打包的结构。
|
||||
|
||||
**工具:** PyInstaller 6.x,**one-dir 模式**(不是 one-file)。
|
||||
one-file 每次启动都要解压到临时目录,启动慢,出错几乎没法排查;one-dir 缺什么文件一眼就能看见。
|
||||
|
||||
**产出目录结构:**
|
||||
|
||||
```text
|
||||
CMAutoBuy/ 整个文件夹拷到任何机器都能用
|
||||
├── launcher.exe 双击这个启动
|
||||
├── app/ 运行时依赖,用户不要动
|
||||
│ ├── python310.dll
|
||||
│ ├── PyQt5/Qt5/
|
||||
│ │ ├── bin/ Qt5Core.dll / Qt5Gui.dll / Qt5Widgets.dll …
|
||||
│ │ └── plugins/platforms/qwindows.dll
|
||||
│ ├── qfluentwidgets/ qss 样式、字体、图标资源
|
||||
│ ├── adbutils/binaries/ adb.exe
|
||||
│ └── …
|
||||
└── data/ 本地数据,升级时保留(见 [03 数据模型](03-data-model.md) §2.1)
|
||||
├── client.db
|
||||
├── logs/
|
||||
└── artifacts/
|
||||
```
|
||||
|
||||
`app/` 这个名字来自 PyInstaller 的 `--contents-directory app` 参数(默认叫 `_internal`)。
|
||||
|
||||
**升级方式:** 删掉 `launcher.exe` 和 `app/`,换成新版本,**`data/` 原样不动**。
|
||||
数据库靠 `PRAGMA user_version` 自动迁移,见 [03](03-data-model.md) §2.3。
|
||||
|
||||
**已知风险点**(打包工单必须逐项验证):
|
||||
|
||||
| 风险 | 症状 |
|
||||
|---|---|
|
||||
| Qt 平台插件没收集到 | 启动即报 `could not find or load the Qt platform plugin "windows"` |
|
||||
| `qfluentwidgets` 资源没收集到 | 能启动,但界面全白、控件没样式 |
|
||||
| `adbutils` 的 adb 二进制没收集到 | 界面正常,但连不上手机 |
|
||||
| `uiautomator2` 初始化流程 | 打包后没有 `python -m uiautomator2 init` 这条命令,需确认辅助 App 怎么装 |
|
||||
| 杀软误报 | 空白版本信息的 exe 最容易被拦。必须给 exe 加图标和版本信息(产品名、版本号)。根治需要代码签名证书 |
|
||||
| 装到 `C:\Program Files\` | 无写权限,数据被重定向到 VirtualStore,设置改了不生效 |
|
||||
|
||||
具体的 PyInstaller 参数和 hook 配置跟依赖版本强相关,**不在本文档固化**,
|
||||
由打包工单在真实 Windows 环境上验证后写进归档。
|
||||
|
||||
## 9. 非目标
|
||||
|
||||
- 不开发 Admin 管理界面或 Admin 数据库。
|
||||
- 不自动注册、登录或绕过拼多多验证码、风控和安全机制。
|
||||
- 不自动完成支付;当前采购范围止于创建订单并获取订单信息。
|
||||
- MVP 不支持一台 Client 同时并行控制多台设备。
|
||||
- 不保证拼多多未公开界面在所有版本中保持兼容,必须通过版本化适配和诊断产物处理变化。
|
||||
|
||||
## 10. 非功能需求
|
||||
|
||||
- 所有网络、数据库长操作和 uiautomator2 操作不得阻塞 Qt 主线程。
|
||||
- 同一设备同一时间只执行一个任务。
|
||||
- 任务领取、结果提交和采购操作必须具有去重或幂等保护。
|
||||
- 结果先本地持久化,再提交 Admin;Admin 暂时不可用不能导致重新采购。
|
||||
- 时间在数据库和接口中使用带时区的 ISO 8601,内部优先使用 UTC,界面转换为本地时间。
|
||||
- 金额以人民币分的整数保存,禁止使用浮点数作为业务金额。
|
||||
- 任务、日志和诊断信息不得包含密码、访问令牌、Cookie 或不必要的个人信息。
|
||||
|
||||
## 11. 待确认事项
|
||||
|
||||
这些还没定下来,但**不许因此停工**。先按"临时默认值"做,等定了再按工单改。
|
||||
|
||||
| # | 待确认什么 | 临时默认值(先这么做) | 定下来之前会卡住什么 |
|
||||
|---|---|---|---|
|
||||
| 1 | Admin 的认证方式和 Client 注册方式 | Mock Gateway 不做认证;HTTP 实现预留 `Authorization: Bearer` 请求头,token 从设置读 | 只卡真实 HTTP 联调,不卡 MVP |
|
||||
| 2 | 真实采购启用条件和人工确认策略 | 真实下单开关**保持关闭**,一律走演练模式 | 卡真实下单,不卡 MVP |
|
||||
| 3 | 价格允许偏差 | 默认 0 分(即实际价格必须等于预期价格才继续),可在设置页改 | 不卡。先按最严的来 |
|
||||
| 4 | 是否存在颜色、尺码之外的第三个规格维度 | **按"可能有任意多个维度"实现**,用 `dimensions` 数组,不要写死两个 | 不卡。写死才会返工 |
|
||||
|
||||
第 4 条特别注意:即使目前见到的商品都只有颜色和尺码两个维度,也**不许**把数据结构写成两个固定字段。见 [03 数据模型](03-data-model.md) §8.1。
|
||||
|
||||
**已定案(不再是待确认项):**
|
||||
|
||||
- Admin 只分配任务给指定 Client,Client 不读全局任务池。见 [04](04-admin-api-contract.md) §5.1。
|
||||
- **没有租约、没有心跳、没有状态回查。** Client 只有领取、提交结果、提交失败三个调用。见 [04](04-admin-api-contract.md) §1.1。
|
||||
- 本地只存已领取任务,已完成任务**永久保留**。诊断产物(截图、XML、日志)仍按 `diagnostics.retention_days` 清理,两者不是一回事。见 [03](03-data-model.md) §3.1。
|
||||
@@ -0,0 +1,358 @@
|
||||
# 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,看到了不用停下来问。**
|
||||
|
||||
| 项目 | 目标(文档描述) | 现状(代码实际) |
|
||||
|---|---|---|
|
||||
| 顶级导航 | 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 任务页 | 任务表格 + 搜索 + 状态栏 | 单个商品的输入表单(链接/颜色/尺码 + 开始按钮) |
|
||||
| 主按钮文案 | 「开始自动获取」 | 「开始任务」 |
|
||||
| 事件层 | `src/ui_event.py` 或应用层服务 | `src/ui_event.py` 是空文件 |
|
||||
| SQLite / Admin / Outbox / 任务协调器 | 见 §4 | 尚未实现 |
|
||||
| PDD 自动化 | `infrastructure/pdd/` 适配层 | `src/util/` 下的独立函数 + `src/demo1/auto_v1.py` 演示脚本 |
|
||||
|
||||
处理原则:
|
||||
|
||||
- **迁移按工单分批做**,不要顺手大改目录。每次只搬和当前工单相关的那部分。
|
||||
- 搬完一项就来更新这张表,把对应行删掉。
|
||||
- `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 的全部交互只有三次调用:领一个任务、提交结果、提交失败。
|
||||
**没有任何"去问 Admin 现在怎么想"的调用**,理由见 [04 接口契约](04-admin-api-contract.md) §1.1。
|
||||
|
||||
### 任务领取与执行
|
||||
|
||||
1. 调用 Admin `claim` 领一个任务;返回 204 表示暂时没活,按轮询周期退避后再试。
|
||||
2. Client 持久化任务,状态置 `claimed`。
|
||||
3. 根据 `task_type` 分派给采集或采购执行器。
|
||||
4. 工作线程执行,持续更新 `current_step`(只写本地,不上报 Admin)。
|
||||
5. 完成后在同一事务里写入结果与 Outbox,状态置 `result_pending`。
|
||||
6. Outbox 提交成功、Admin 返回 `accepted: true` 后标记 `succeeded`。
|
||||
7. 回到第 1 步领下一个任务。**同一时间只做一个任务。**
|
||||
|
||||
中途 Admin 是否取消了这个任务、是否重派给了别人,Client 不查也不管,做完照样提交——
|
||||
Admin 侧必须无条件接受,见 [04](04-admin-api-contract.md) §6.1。
|
||||
|
||||
### 采购崩溃恢复
|
||||
|
||||
- 在最终提交订单前写入不可逆阶段标记。
|
||||
- 如果进程在该阶段退出,重启后进入订单核对流程。
|
||||
- 核对不到订单时进入人工处理,禁止自动重新下单。
|
||||
- Admin 提交失败只重试 Outbox,不再次操作拼多多。
|
||||
|
||||
## 9. PDD 适配边界
|
||||
|
||||
PDD Adapter 对应用层提供稳定接口:
|
||||
|
||||
```text
|
||||
collect(task) -> CollectResult
|
||||
purchase(task, mode) -> PurchaseResult
|
||||
reconcile_purchase(task, run) -> PurchaseResult | ManualReview
|
||||
```
|
||||
|
||||
现有 `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)
|
||||
@@ -0,0 +1,557 @@
|
||||
# 03 Client 数据模型
|
||||
|
||||
- 文档状态:基线草案,待数据评审
|
||||
- 数据库:SQLite
|
||||
- 数据库位置:程序目录下的 `data/client.db`(见 §2.1;怎么打开看数据见 [上手指南](00-getting-started.md) §7)
|
||||
|
||||
本文档中 `[必须]` / `[建议]` / `[待定]` 的含义见 [文档索引](../README.md#文档标注说明)。没有标注的默认是 `[必须]`。
|
||||
|
||||
## 1. 设计原则
|
||||
|
||||
- `[必须]` Admin 是任务定义和最终业务状态的权威来源;SQLite 是 Client 执行状态和未提交结果的权威来源。
|
||||
- `[必须]` 金额统一使用人民币分的整数,禁止使用浮点数。
|
||||
- `[必须]` 时间使用带时区的 ISO 8601 字符串,数据库内部优先保存 UTC。
|
||||
- `[必须]` 原始 Admin 数据和 PDD 数据使用版本化 JSON,规范化字段用于表格和查询。
|
||||
- `[必须]` 完成 PDD 操作后,结果保存与 Outbox 创建必须处于同一事务,写法见 §5.1。
|
||||
- `[必须]` 无障碍 XML、截图和长日志存文件,数据库只保存路径、哈希和摘要。
|
||||
|
||||
## 2. 文件位置与数据库初始化
|
||||
|
||||
### 2.1 `data` 目录
|
||||
|
||||
`[必须]` 所有程序自己产生的、需要写入的东西,**全部放在 `data/` 目录下**,不散落到别处:
|
||||
|
||||
```text
|
||||
data/
|
||||
├── client.db SQLite 数据库(本文档描述的所有表)
|
||||
├── logs/ 运行日志
|
||||
└── artifacts/ 失败截图、控件树 XML、步骤日志
|
||||
```
|
||||
|
||||
`data/` 的位置:
|
||||
|
||||
| 什么情况 | `data/` 在哪 |
|
||||
|---|---|
|
||||
| 打包成 exe 后 | `launcher.exe` 旁边(见 [01 需求](01-requirements.md) §8.1 的目录结构) |
|
||||
| 直接跑源码 | `client/data/`(已在 `.gitignore` 里,不会被提交) |
|
||||
|
||||
这叫**便携模式**:整个程序文件夹拷到哪都能用,出问题把文件夹打包发出来就能复现。
|
||||
代价是**不许把程序装到 `C:\Program Files\`**——那个目录普通用户没有写权限,
|
||||
Windows 会把写入悄悄重定向到 VirtualStore,然后你会遇到"明明改了设置却没生效"这种极难查的问题。
|
||||
装到 `D:\CMAutoBuy\` 这类用户可写的目录。
|
||||
|
||||
> `[必须]` **访问令牌、密码、Cookie 绝不能写进 `data/`。**
|
||||
> `data/` 是明文的,而这个目录的设计目的就是"整个拷走",拷一次等于泄露一次。
|
||||
> 凭据按 [06 质量与安全](06-quality-security.md) §7 存 Windows 凭据管理器。
|
||||
|
||||
### 2.2 路径解析(只准用这两个函数)
|
||||
|
||||
打包后**只读资源**和**可写数据**在两个完全不同的地方,这是最容易搞混的点。
|
||||
统一封装成下面两个函数,`[必须]` 代码里不许出现第二处拼路径的写法:
|
||||
|
||||
```python
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def data_dir() -> Path:
|
||||
"""可写数据目录:数据库、日志、截图都放这儿。
|
||||
|
||||
打包后 = launcher.exe 旁边的 data/
|
||||
跑源码 = client/data/
|
||||
"""
|
||||
if getattr(sys, "frozen", False): # frozen=True 说明是打包后的 exe
|
||||
base = Path(sys.executable).parent / "data"
|
||||
else:
|
||||
base = Path(__file__).resolve().parents[1] / "data"
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
return base
|
||||
|
||||
|
||||
def app_dir() -> Path:
|
||||
"""只读资源目录:图标、内置 qss 之类,**不要往这里写东西**。
|
||||
|
||||
打包后 = app/ 目录(PyInstaller 解包位置)
|
||||
跑源码 = client/
|
||||
"""
|
||||
if getattr(sys, "frozen", False):
|
||||
return Path(sys._MEIPASS)
|
||||
return Path(__file__).resolve().parents[1]
|
||||
```
|
||||
|
||||
用法:
|
||||
|
||||
```python
|
||||
db_path = data_dir() / "client.db"
|
||||
log_dir = data_dir() / "logs"
|
||||
icon = app_dir() / "resources" / "app.ico"
|
||||
```
|
||||
|
||||
| 别这么做 | 为什么 |
|
||||
|---|---|
|
||||
| 往 `app_dir()` 里写文件 | 打包后那是只读的解包目录,升级时整个被替换掉 |
|
||||
| 用 `os.getcwd()` 定位数据 | 双击 exe 和从命令行启动,当前目录不一样 |
|
||||
| 用 `__file__` 直接拼路径 | 打包后 `__file__` 指向的是压缩包里的虚拟路径 |
|
||||
| 硬编码 `D:\CMAutoBuy\data` | 换台机器就废了 |
|
||||
|
||||
### 2.3 数据库初始化
|
||||
|
||||
每个线程使用独立 SQLite 连接,并至少设置:
|
||||
|
||||
```sql
|
||||
PRAGMA foreign_keys = ON;
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA busy_timeout = 5000;
|
||||
```
|
||||
|
||||
使用 `PRAGMA user_version` 管理顺序迁移。数据库升级必须支持从所有已发布版本迁移,不得在启动时直接删除旧库重建。
|
||||
|
||||
这条在打包后尤其重要:升级 = 换掉 `launcher.exe` 和 `app/`,`data/` 原样保留,
|
||||
所以新版本必须能读旧数据库。
|
||||
|
||||
## 3. `pdd_tasks`
|
||||
|
||||
保存**本机已领取**任务的定义、执行状态和结果。
|
||||
|
||||
> **重要:本地库不是 Admin 任务池的镜像。** 只有 `claim` 成功的任务才会写进这张表,
|
||||
> 所以这里没有"待领取"的任务。Admin 那边有哪些任务还没分下来,Client 不需要知道也不去查。
|
||||
> 这条决定影响很多地方,背景见 §3.1。
|
||||
|
||||
```sql
|
||||
CREATE TABLE pdd_tasks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
remote_task_id TEXT NOT NULL UNIQUE,
|
||||
task_type TEXT NOT NULL
|
||||
CHECK (task_type IN ('collect', 'purchase')),
|
||||
goods_id TEXT,
|
||||
goods_url TEXT NOT NULL,
|
||||
title TEXT,
|
||||
target_color TEXT,
|
||||
target_size TEXT,
|
||||
price_cent INTEGER CHECK (price_cent IS NULL OR price_cent >= 0),
|
||||
quantity INTEGER CHECK (quantity IS NULL OR quantity > 0),
|
||||
status TEXT NOT NULL DEFAULT 'claimed'
|
||||
CHECK (status IN (
|
||||
'claimed', 'running',
|
||||
'result_pending', 'retry_wait',
|
||||
'manual_review', 'succeeded',
|
||||
'failed', 'cancelled'
|
||||
)),
|
||||
current_step TEXT,
|
||||
priority INTEGER NOT NULL DEFAULT 0,
|
||||
version INTEGER NOT NULL DEFAULT 1 CHECK (version > 0),
|
||||
admin_payload TEXT NOT NULL DEFAULT '{}',
|
||||
pdd_data TEXT,
|
||||
retry_count INTEGER NOT NULL DEFAULT 0 CHECK (retry_count >= 0),
|
||||
last_error_code TEXT,
|
||||
last_error_message TEXT,
|
||||
received_at TEXT NOT NULL,
|
||||
started_at TEXT,
|
||||
finished_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_pdd_tasks_list
|
||||
ON pdd_tasks(updated_at DESC, id DESC);
|
||||
|
||||
CREATE INDEX idx_pdd_tasks_status_type
|
||||
ON pdd_tasks(status, task_type);
|
||||
|
||||
CREATE INDEX idx_pdd_tasks_goods_id
|
||||
ON pdd_tasks(goods_id);
|
||||
```
|
||||
|
||||
### 字段语义
|
||||
|
||||
- `remote_task_id`:跨 Admin、Client、日志、提交和归档使用的稳定任务编号。Mock 任务也必须分配稳定编号。
|
||||
- `price_cent`:列表摘要价格;采集任务通常为最低可用 SKU 价格,详细价格以 `pdd_data.skus` 为准。
|
||||
- `status`:**本机执行状态**,只由本机的执行流程和 Outbox 提交响应驱动。Admin 那边把任务标成什么,本地不知道也不需要知道(见 §3.1)。
|
||||
- `current_step`:当前安全步骤,例如 `open_goods`、`collect_skus`、`select_options`、`placing_order`、`reconcile_order`。
|
||||
- `admin_payload`:`claim` 时收到的原始 Admin 任务 JSON,用于审计和向前兼容。
|
||||
- `pdd_data`:采集或采购结果 JSON,未产生结果时为空。
|
||||
- `received_at`:**领取时间**,即 `claim` 成功的时刻。
|
||||
- `updated_at`:本地更新时间,也是表格默认排序字段。
|
||||
|
||||
### 3.1 本地只存已领取任务
|
||||
|
||||
`[必须]` 任务只在 `claim` 成功那一刻落库,初始状态就是 `claimed`。
|
||||
|
||||
**为什么这么设计:** 如果本地也存一份"待领取"的任务,本地库就同时是"任务池镜像"和"执行账本"两个角色。
|
||||
这两个角色的数据所有权是打架的——Admin 说任务是 `pending`,本地说正在 `running`,到底听谁的?
|
||||
为了调和它就得引入游标同步、版本合并、"同步不得覆盖本地执行状态"一大堆规则,而这些复杂度换来的
|
||||
只是"提前知道任务池里有什么",对 Client 的行为没有任何影响。任务分配本来就是 Admin 说了算。
|
||||
|
||||
由此带来的规则:
|
||||
|
||||
| 规则 | 说明 |
|
||||
|---|---|
|
||||
| `[必须]` 状态机没有 `pending` | 起点是 `claimed`,见 §7 |
|
||||
| `[必须]` 已完成任务永久保留 | `succeeded` / `failed` / `cancelled` 的记录**不删**。崩溃恢复防重复下单依赖历史记录,见 §7.3 |
|
||||
| `[必须]` 本地不保存 Admin 侧状态 | Client 拿到任务就做完,中途不查 Admin 怎么想。Admin 取消了、重派了,Client 一律不感知,照做完照提交,见 [04 接口契约](04-admin-api-contract.md) §1 |
|
||||
| `[必须]` 提交一定会被接受 | Admin 必须无条件接受已派发过的结果,见 [04](04-admin-api-contract.md) §6.1。所以本地不需要"提交被拒"的处理分支 |
|
||||
|
||||
**关于"永久保留":** 任务记录不设保留期,一直留着。但**诊断产物(截图、XML、长日志)仍然按
|
||||
`diagnostics.retention_days` 定期清理**——两者是不同的东西,别搞混。清理诊断文件不影响任务记录和审计字段。
|
||||
|
||||
数据量增长靠索引和增量加载扛(见 §3 的索引和 [05 界面规范](05-ui-specification.md) §5.3)。
|
||||
将来真的大到影响性能,再单独开工单做归档,不要现在提前设计。
|
||||
|
||||
列表默认查询:
|
||||
|
||||
```sql
|
||||
SELECT id, remote_task_id, task_type, title, target_color, target_size,
|
||||
price_cent, quantity, status, updated_at
|
||||
FROM pdd_tasks
|
||||
WHERE /* 本地搜索与筛选条件 */
|
||||
ORDER BY updated_at DESC, id DESC
|
||||
LIMIT :limit OFFSET :offset;
|
||||
```
|
||||
|
||||
## 4. `task_runs`
|
||||
|
||||
每次自动执行生成一条记录,任务重试时创建新的 `attempt_no`,不得覆盖历史。
|
||||
|
||||
```sql
|
||||
CREATE TABLE task_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id INTEGER NOT NULL,
|
||||
attempt_id TEXT NOT NULL UNIQUE,
|
||||
attempt_no INTEGER NOT NULL CHECK (attempt_no > 0),
|
||||
device_address TEXT NOT NULL,
|
||||
run_status TEXT NOT NULL
|
||||
CHECK (run_status IN (
|
||||
'running', 'succeeded', 'failed',
|
||||
'cancelled', 'manual_review'
|
||||
)),
|
||||
current_step TEXT,
|
||||
started_at TEXT NOT NULL,
|
||||
finished_at TEXT,
|
||||
irreversible_action_at TEXT,
|
||||
order_submitted_at TEXT,
|
||||
error_code TEXT,
|
||||
error_message TEXT,
|
||||
diagnostics_json TEXT,
|
||||
artifact_directory TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (task_id) REFERENCES pdd_tasks(id) ON DELETE CASCADE,
|
||||
UNIQUE (task_id, attempt_no)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_task_runs_task
|
||||
ON task_runs(task_id, attempt_no DESC);
|
||||
```
|
||||
|
||||
`irreversible_action_at` 一旦写入,恢复逻辑不得再次下单,只能核对订单或转人工处理。
|
||||
|
||||
## 5. `outbox_events`
|
||||
|
||||
保存等待提交 Admin 的可靠事件。
|
||||
|
||||
```sql
|
||||
CREATE TABLE outbox_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id INTEGER NOT NULL,
|
||||
event_type TEXT NOT NULL
|
||||
CHECK (event_type IN (
|
||||
'collect_result', 'purchase_result', 'task_failure'
|
||||
)),
|
||||
idempotency_key TEXT NOT NULL UNIQUE,
|
||||
payload_json TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending', 'sending', 'sent', 'failed')),
|
||||
attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count >= 0),
|
||||
next_retry_at TEXT,
|
||||
last_error TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
sent_at TEXT,
|
||||
FOREIGN KEY (task_id) REFERENCES pdd_tasks(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_outbox_pending
|
||||
ON outbox_events(status, next_retry_at, id);
|
||||
```
|
||||
|
||||
状态为 `sending` 的记录在异常退出后必须恢复为可重试状态。相同 `idempotency_key` 的事件不得生成第二条记录。
|
||||
|
||||
### 5.1 结果和 Outbox 必须一起写(事务模板)
|
||||
|
||||
`[必须]` 更新任务结果和插入 Outbox 记录**必须在同一个事务里**。
|
||||
|
||||
为什么:如果只写了任务结果、没写 Outbox,这条结果就永远发不出去了;如果只写了 Outbox、没写结果,发出去的是空的。手机上已经操作过的事没法撤销,所以数据库这边必须一次成功或一次都不做。
|
||||
|
||||
照抄这段:
|
||||
|
||||
```python
|
||||
import json
|
||||
import sqlite3
|
||||
|
||||
|
||||
def save_result_and_enqueue(
|
||||
conn: sqlite3.Connection,
|
||||
task_id: int,
|
||||
remote_task_id: str,
|
||||
attempt_id: str,
|
||||
event_type: str,
|
||||
pdd_data: dict,
|
||||
) -> None:
|
||||
"""把任务结果和待发送事件一次性写进数据库。
|
||||
|
||||
要么两条都成功,要么两条都不写。中间失败会自动回滚。
|
||||
"""
|
||||
now = utc_now_iso()
|
||||
key = build_idempotency_key(remote_task_id, attempt_id, event_type)
|
||||
payload = json.dumps({"pdd_data": pdd_data}, ensure_ascii=False)
|
||||
|
||||
# 关键就是这个 with:正常走完自动 COMMIT,中间抛异常自动 ROLLBACK
|
||||
with conn:
|
||||
conn.execute(
|
||||
"UPDATE pdd_tasks"
|
||||
" SET status = 'result_pending', pdd_data = ?, updated_at = ?"
|
||||
" WHERE id = ?",
|
||||
(json.dumps(pdd_data, ensure_ascii=False), now, task_id),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO outbox_events"
|
||||
" (task_id, event_type, idempotency_key, payload_json,"
|
||||
" created_at, updated_at)"
|
||||
" VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(task_id, event_type, key, payload, now, now),
|
||||
)
|
||||
```
|
||||
|
||||
注意:
|
||||
|
||||
| 别这么做 | 为什么 |
|
||||
|---|---|
|
||||
| 在 `with conn:` 里手动 `conn.commit()` | 会把事务提前切断,后面那条就不在同一个事务里了 |
|
||||
| 在 `with conn:` 里发网络请求 | 请求慢的时候数据库一直被锁着,别的线程全卡住 |
|
||||
| 两个 `with conn:` 分开写 | 那就是两个事务,等于没保护 |
|
||||
| 多个线程共用一个 `conn` | SQLite 连接不能跨线程用,见 §2 |
|
||||
|
||||
### 5.2 幂等键怎么生成
|
||||
|
||||
`[必须]` 幂等键的格式与 [04 接口契约](04-admin-api-contract.md) §6、§7 保持一致:
|
||||
|
||||
```text
|
||||
<remote_task_id>:<attempt_id>:result-v1 # 提交成功结果
|
||||
<remote_task_id>:<attempt_id>:failure-v1 # 提交失败/人工处理
|
||||
```
|
||||
|
||||
```python
|
||||
_KEY_SUFFIX = {
|
||||
"collect_result": "result-v1",
|
||||
"purchase_result": "result-v1",
|
||||
"task_failure": "failure-v1",
|
||||
}
|
||||
|
||||
|
||||
def build_idempotency_key(
|
||||
remote_task_id: str, attempt_id: str, event_type: str
|
||||
) -> str:
|
||||
"""生成提交给 Admin 的幂等键。
|
||||
|
||||
重试时必须用完全相同的键,所以键生成后要存进
|
||||
outbox_events.idempotency_key,不要每次重新算。
|
||||
"""
|
||||
try:
|
||||
suffix = _KEY_SUFFIX[event_type]
|
||||
except KeyError:
|
||||
raise ValueError(f"未知的 event_type: {event_type}") from None
|
||||
return f"{remote_task_id}:{attempt_id}:{suffix}"
|
||||
```
|
||||
|
||||
三条规则:
|
||||
|
||||
1. **用 `remote_task_id`,不要用本地自增 `id`。** 本地 id 换台电脑、重建数据库就变了,Admin 那边认不出来。
|
||||
2. **键一旦入库就不能变。** 重试是拿库里存的键去发,不是现算一个。
|
||||
3. **键相同,内容也必须相同。** 同一个键发不同内容,Admin 会返回 `409 IDEMPOTENCY_CONFLICT`。内容真的变了,说明这是一次新的执行,应该用新的 `attempt_id`。
|
||||
|
||||
## 6. `app_settings`
|
||||
|
||||
保存非敏感设置和设置版本。
|
||||
|
||||
```sql
|
||||
CREATE TABLE app_settings (
|
||||
setting_key TEXT PRIMARY KEY,
|
||||
value_json TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
设置键 `[建议]`(新增键沿用 `<分组>.<名称>` 的写法):
|
||||
|
||||
- `admin.base_url`
|
||||
- `admin.client_id`
|
||||
- `admin.request_timeout_seconds`
|
||||
- `device.address`
|
||||
- `device.pdd_package`
|
||||
- `automation.poll_interval_seconds`
|
||||
- `automation.max_retries`
|
||||
- `automation.dry_run`
|
||||
- `safety.max_quantity`
|
||||
- `safety.price_tolerance_cent`
|
||||
- `diagnostics.artifact_directory`
|
||||
- `diagnostics.retention_days`
|
||||
|
||||
访问令牌、密码和 Cookie 不得存入本表。
|
||||
|
||||
## 7. 任务状态转换
|
||||
|
||||
### 7.1 完整转换表
|
||||
|
||||
`[必须]` 下表以外的状态变化一律不允许。写代码和写测试都以这张表为准。
|
||||
|
||||
**起点是 `claimed`,没有 `pending`。** 任务在 `claim` 成功那一刻才落库,理由见 §3.1。
|
||||
|
||||
| 当前状态 | 可以变成 | 什么时候 | 谁来改 |
|
||||
|---|---|---|---|
|
||||
| (无记录) | `claimed` | `claim` 成功,任务首次写入本地库 | 任务协调器 |
|
||||
| `claimed` 已领取 | `running` | 工作线程开始操作设备 | 任务执行器 |
|
||||
| `claimed` | `retry_wait` | 设备连不上等可恢复错误,**还没碰过设备** | 任务执行器 |
|
||||
| `claimed` | `failed` | 任务数据不合法(缺必填字段、数量为 0 等) | 任务执行器 |
|
||||
| `claimed` | `cancelled` | 用户停止,且尚未开始执行 | 任务协调器 |
|
||||
| `running` 执行中 | `result_pending` | PDD 操作完成,**且结果已落库**(见 §5.1) | 任务执行器 |
|
||||
| `running` | `retry_wait` | 可恢复失败(页面超时、网络抖动),**且未进入不可逆阶段** | 任务执行器 |
|
||||
| `running` | `manual_review` | 需要人判断:多个订单候选、验证码、登录失效、价格超限、规格不确定 | 任务执行器 |
|
||||
| `running` | `failed` | 不可恢复且不需要人处理(商品下架、链接失效) | 任务执行器 |
|
||||
| `running` | `cancelled` | 用户停止,且已到安全点、未进入不可逆阶段 | 任务协调器 |
|
||||
| `result_pending` 结果待提交 | `succeeded` | Admin 返回 `accepted: true` | 结果提交服务 |
|
||||
| `result_pending` | `manual_review` | 重试次数超上限仍提交不上去 | 结果提交服务 |
|
||||
| `retry_wait` 重试等待 | `running` | 退避时间到,**本地直接重跑**,新建 `task_runs` 记录(`attempt_no` 加 1) | 任务协调器 |
|
||||
| `retry_wait` | `failed` | 超过最大重试次数,且从未进入不可逆阶段 | 任务协调器 |
|
||||
| `retry_wait` | `manual_review` | 超过最大重试次数,但**曾经进入过不可逆阶段** | 任务协调器 |
|
||||
| `manual_review` 需要人工 | 不自动变 | 只能由人在 Admin 侧处理;需要再执行时由 Admin 下发新任务 | 人 |
|
||||
| `succeeded` / `failed` / `cancelled` | **终态,不再变化** | — | — |
|
||||
|
||||
**注意 `retry_wait` → `running` 是纯本地操作。** 任务已经在本地库里了,重试直接重跑就行,
|
||||
**不需要再向 Admin 要一次**。没有租约,也就没有"重新获取执行权"这回事。
|
||||
|
||||
### 7.2 补充规则
|
||||
|
||||
- `[必须]` 任务只能通过 `claim` 成功进入本地库,不许本地自己造一条 `claimed` 记录。
|
||||
- `[必须]` 只有 `running` 状态才允许操作设备、产生业务执行步骤。
|
||||
- `[必须]` 只有 Admin 返回 `accepted: true` 才能进入 `succeeded`,本地不许自己判定成功。
|
||||
- `[必须]` `manual_review` 不自动重新执行,任何自动流程都不许把它改回 `running`。
|
||||
- `[必须]` 已经是 `succeeded`、`failed`、`cancelled` 的任务,不得被迟到的后台回调改回运行中状态。
|
||||
- `[必须]` 每次重试都要新建一条 `task_runs` 记录(`attempt_no` 加 1),不许覆盖上一次的记录。
|
||||
|
||||
### 7.3 崩溃重启后怎么恢复
|
||||
|
||||
`[必须]` 程序启动时,把上次残留的状态按下表处理:
|
||||
|
||||
| 重启时发现 | 怎么处理 |
|
||||
|---|---|
|
||||
| `status = 'running'`,且 `task_runs.irreversible_action_at` **为空** | 说明还没下单,改成 `retry_wait`,正常重试 |
|
||||
| `status = 'running'`,且 `irreversible_action_at` **不为空** | 说明可能已经下单了。**保持 `running`**,把 `current_step` 改成 `reconcile_order` 去核对订单。核对不到就转 `manual_review`。**任何情况下都不许重新下单** |
|
||||
| `status = 'claimed'` | 说明领到了还没开始动手,保持不变,等协调器重新调度执行。**不需要再向 Admin 领一次** |
|
||||
| `status = 'result_pending'` | 不动状态,交给 Outbox 继续重试提交 |
|
||||
| `outbox_events.status = 'sending'` | 改回 `pending`,让它能被重新发送 |
|
||||
|
||||
一句话记住:**`irreversible_action_at` 有值 = 只准查,不准买。**
|
||||
|
||||
## 8. `pdd_data` JSON
|
||||
|
||||
### 8.1 通用商品数据
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"goods": {
|
||||
"goods_id": "737116531267",
|
||||
"url": "https://mobile.yangkeduo.com/goods.html?goods_id=737116531267",
|
||||
"title": "商品标题"
|
||||
},
|
||||
"shop": {
|
||||
"name": "店铺名称"
|
||||
},
|
||||
"metrics": {
|
||||
"sales": {
|
||||
"value": 12000,
|
||||
"raw": "已拼1.2万+",
|
||||
"approximate": true
|
||||
},
|
||||
"reviews": {
|
||||
"value": 2356,
|
||||
"raw": "2356条评价",
|
||||
"approximate": false
|
||||
}
|
||||
},
|
||||
"dimensions": [
|
||||
{
|
||||
"key": "color",
|
||||
"name": "颜色分类",
|
||||
"values": [
|
||||
{"text": "黑色", "available": true},
|
||||
{"text": "白色", "available": true}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "size",
|
||||
"name": "尺码",
|
||||
"values": [
|
||||
{"text": "M", "available": true},
|
||||
{"text": "L", "available": true}
|
||||
]
|
||||
}
|
||||
],
|
||||
"skus": [
|
||||
{
|
||||
"options": {"color": "黑色", "size": "L"},
|
||||
"price_cent": 3990,
|
||||
"currency": "CNY",
|
||||
"available": true,
|
||||
"raw_price": "¥39.90"
|
||||
}
|
||||
],
|
||||
"purchase": null,
|
||||
"captured_at": "2026-08-06T08:00:00Z",
|
||||
"source": {
|
||||
"client_id": "client-001",
|
||||
"device_address": "192.168.0.173:5555",
|
||||
"pdd_package": "com.xunmeng.pinduoduo"
|
||||
},
|
||||
"artifacts": []
|
||||
}
|
||||
```
|
||||
|
||||
### 8.2 采购结果
|
||||
|
||||
采购任务在同一结构中增加:
|
||||
|
||||
```json
|
||||
{
|
||||
"purchase": {
|
||||
"mode": "dry_run",
|
||||
"requested": {
|
||||
"color": "黑色",
|
||||
"size": "L",
|
||||
"quantity": 2,
|
||||
"max_price_cent": 4200
|
||||
},
|
||||
"confirmed": {
|
||||
"color": "黑色",
|
||||
"size": "L",
|
||||
"quantity": 2,
|
||||
"unit_price_cent": 3990,
|
||||
"total_price_cent": 7980
|
||||
},
|
||||
"order_no": null,
|
||||
"ordered_at": null,
|
||||
"ordered_at_raw": null,
|
||||
"match_status": "not_submitted"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
真实下单后 `mode` 为 `live`,`match_status` 只能是 `matched`、`ambiguous` 或 `not_found`。只有 `matched` 可以自动报告采购成功。
|
||||
|
||||
## 9. JSON 兼容规则
|
||||
|
||||
- 所有 JSON 根对象必须包含整数 `schema_version`。
|
||||
- 新版本只允许新增可选字段或通过新版本迁移,不得改变现有字段含义。
|
||||
- 未识别字段应保留,不得在同步或重新保存时静默丢弃。
|
||||
- 写入数据库和提交 Admin 前必须验证 JSON 结构。
|
||||
- 敏感信息不得放入 `admin_payload`、`pdd_data` 或诊断 JSON。
|
||||
@@ -0,0 +1,293 @@
|
||||
# 04 Client–Admin API v1 契约
|
||||
|
||||
- 文档状态:基线草案,待 Client 与 Admin 联合评审
|
||||
- 基础路径:`/api/v1/client`
|
||||
- 编码:UTF-8 JSON
|
||||
- 时间:带时区 ISO 8601,服务端优先返回 UTC
|
||||
- 金额:人民币分整数
|
||||
|
||||
本文档中 `[必须]` / `[建议]` / `[待定]` 的含义见 [文档索引](../README.md#文档标注说明)。没有标注的默认是 `[必须]`。看不懂的词(幂等、Outbox)查 [术语表](00-glossary.md)。
|
||||
|
||||
## 1. 设计原则
|
||||
|
||||
**核心:Admin 是调度器,Client 是执行器,执行器不参与调度决策。**
|
||||
|
||||
- Client 只有三个动作:领一个任务、提交结果、提交失败。**没有任何"去问 Admin 现在怎么想"的调用。**
|
||||
- Client 拿到任务就做完,中途不管任务是否被取消、是否被重派。
|
||||
- Admin 负责任务的创建、更新、取消和派发(含重派),这些 Client 一律不感知。
|
||||
- 结果提交必须幂等;网络重试不能创建重复结果。
|
||||
- Admin **必须无条件接受**已派发过的 Client 提交的结果,理由见 §6.1。
|
||||
- 任务和结果使用版本化结构,未知字段应允许向前兼容。
|
||||
- Admin 业务错误返回稳定错误代码,不要求 Client 解析自然语言判断逻辑。
|
||||
|
||||
### 1.1 为什么没有租约和心跳
|
||||
|
||||
早期方案有租约(lease)和心跳,用来防止两个 Client 做同一个任务导致重复下单。后来去掉了,原因是:
|
||||
|
||||
**本项目不自动付款**(见 [01 需求](01-requirements.md) §9),采购止于创建订单。
|
||||
重复下单产生的是重复的**未付款**订单,人工审核时不付即可,代价和"白干一场"是一个量级。
|
||||
为这点代价引入租约、心跳、过期判断和一整套中断逻辑,不划算。
|
||||
|
||||
Admin 想知道某个 Client 是不是卡死了,用**领取后超时重派**即可——这完全在 Admin 侧,Client 不参与。
|
||||
|
||||
> 注意:去掉租约**不代表**去掉防重复下单。防的是**本机崩溃重启后重复下单**,
|
||||
> 靠 `task_runs.irreversible_action_at` 标记,见 [03 数据模型](03-data-model.md) §7.3。那套机制反而更重要了。
|
||||
|
||||
## 2. 通用请求头
|
||||
|
||||
```http
|
||||
Authorization: Bearer <token>
|
||||
X-Client-Id: client-001
|
||||
X-Request-Id: <uuid>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
结果和失败提交额外携带:
|
||||
|
||||
```http
|
||||
Idempotency-Key: <stable-key>
|
||||
```
|
||||
|
||||
认证方式仍待 Admin 联合评审。无论最终采用哪种方式,访问令牌不得写入普通日志。
|
||||
|
||||
## 3. 通用错误
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "IDEMPOTENCY_CONFLICT",
|
||||
"message": "相同幂等键提交了不同内容",
|
||||
"retryable": false,
|
||||
"request_id": "7a5d...",
|
||||
"details": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
建议状态码:
|
||||
|
||||
| 状态码 | 场景 |
|
||||
|---|---|
|
||||
| `400` | 请求结构或字段无效 |
|
||||
| `401` | 未认证或凭据过期 |
|
||||
| `403` | Client 无权访问任务(从未派发给它) |
|
||||
| `404` | 任务不存在 |
|
||||
| `409` | 幂等冲突 |
|
||||
| `422` | 业务规则不满足 |
|
||||
| `429` | 请求过于频繁 |
|
||||
| `500/503` | Admin 暂时故障,可按策略重试 |
|
||||
|
||||
注意 `403` 的含义:只有**从未派发给这个 Client** 的任务才返回 403。
|
||||
任务已取消、已重派给别人,都**不能**返回 403,见 §6.1。
|
||||
|
||||
## 4. 任务对象
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "PDD-20260806-0001",
|
||||
"type": "purchase",
|
||||
"version": 3,
|
||||
"priority": 10,
|
||||
"payload": {
|
||||
"goods_id": "737116531267",
|
||||
"goods_url": "https://mobile.yangkeduo.com/goods.html?goods_id=737116531267",
|
||||
"expected_title": "商品标题",
|
||||
"options": {
|
||||
"color": "黑色",
|
||||
"size": "L"
|
||||
},
|
||||
"quantity": 2,
|
||||
"expected_price_cent": 3990,
|
||||
"max_price_cent": 4200
|
||||
},
|
||||
"created_at": "2026-08-06T07:00:00Z",
|
||||
"updated_at": "2026-08-06T07:05:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
采集任务的 `payload` 不要求颜色、尺码、数量和价格字段。采购任务必须包含 `quantity` 以及 `max_price_cent` 或等价的明确价格保护规则。
|
||||
|
||||
任务对象里**不含 Admin 侧状态**。Client 不关心 Admin 那边把它标成什么,只管做完提交。
|
||||
|
||||
## 5. 领取任务
|
||||
|
||||
```http
|
||||
POST /api/v1/client/tasks/claim
|
||||
```
|
||||
|
||||
请求:
|
||||
|
||||
```json
|
||||
{
|
||||
"supported_types": ["collect", "purchase"],
|
||||
"device": {
|
||||
"address": "192.168.0.173:5555",
|
||||
"platform": "android",
|
||||
"pdd_package": "com.xunmeng.pinduoduo"
|
||||
},
|
||||
"capabilities": {
|
||||
"purchase_mode": "dry_run",
|
||||
"schema_versions": [1]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
有任务时返回 `200`:
|
||||
|
||||
```json
|
||||
{
|
||||
"task": {}
|
||||
}
|
||||
```
|
||||
|
||||
无可领取任务时返回 `204 No Content`。
|
||||
|
||||
规则:
|
||||
|
||||
- `[必须]` 领取必须在 Admin 内部原子完成,一次调用最多返回一个任务。
|
||||
- `[必须]` Admin 不得向只声明 `dry_run` 的 Client 分配要求真实下单的任务。
|
||||
- `[必须]` 响应**不包含**租约。Client 拿到任务就开始做,做完再来领下一个。
|
||||
|
||||
### 5.1 Admin 侧的分配语义
|
||||
|
||||
已确认:**Admin 只把任务分配给指定的 Client**,`claim` 只会返回分配给本 `X-Client-Id` 的任务。
|
||||
|
||||
因此 Client 完全不需要看到全局任务池,本地也不缓存未领取的任务
|
||||
(见 [03 数据模型](03-data-model.md) §3.1)。操作人员想知道队列里还有多少活,
|
||||
去 Admin 自己的界面看([01 需求](01-requirements.md) §9 已把 Admin 界面列为非目标)。
|
||||
|
||||
Admin 可以在超时后把任务重派给别的 Client,Client 侧对此**无感知也不需要感知**。
|
||||
|
||||
## 6. 提交成功结果
|
||||
|
||||
```http
|
||||
POST /api/v1/client/tasks/{task_id}/result
|
||||
Idempotency-Key: task-id:attempt-id:result-v1
|
||||
```
|
||||
|
||||
采集结果:
|
||||
|
||||
```json
|
||||
{
|
||||
"task_version": 3,
|
||||
"attempt_id": "attempt-uuid",
|
||||
"result_type": "collect",
|
||||
"completed_at": "2026-08-06T08:03:00Z",
|
||||
"pdd_data": {}
|
||||
}
|
||||
```
|
||||
|
||||
采购结果把 `result_type` 换成 `purchase`,其余结构相同。
|
||||
|
||||
响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"accepted": true,
|
||||
"result_id": "result-uuid",
|
||||
"accepted_at": "2026-08-06T08:03:01Z"
|
||||
}
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- `[必须]` 相同 `Idempotency-Key` 和相同请求内容必须返回同一业务结果。
|
||||
- `[必须]` 相同键但不同内容返回 `409 IDEMPOTENCY_CONFLICT`。
|
||||
- `[必须]` Client 只有收到 `accepted: true` 后才能把本地任务标为 `succeeded`。
|
||||
|
||||
### 6.1 Admin 必须无条件接受
|
||||
|
||||
这是对 Admin 侧的**强约束**,实现时最容易被顺手违反,务必写进 Admin 的验收标准:
|
||||
|
||||
- `[必须]` Admin **不得**因为任务已取消而拒绝结果。
|
||||
- `[必须]` Admin **不得**因为任务已重派给别的 Client 而拒绝结果。
|
||||
- `[必须]` Admin **必须**能接受同一任务来自**多个 Client** 的多份结果。怎么去重、以哪份为准,是 Admin 内部的事,不要求 Client 配合。
|
||||
- `[必须]` 只要这个任务曾经派发给该 Client,提交就必须被接受。只有从未派发过才返回 `403`。
|
||||
|
||||
原因:Client 中途不查任务状态(§1),所以它**必然**会提交一些"Admin 那边已经不要了"的结果。
|
||||
如果 Admin 拒收,Client 侧就会出现大量无法处理的失败——而 Client 已经真的把事情做完了,
|
||||
甚至可能已经下了单,这些数据必须能交上去留痕。
|
||||
|
||||
`accepted: true` 的含义是"**我收到并存下了**",不代表 Admin 认可这个任务仍然有效。
|
||||
任务到底算不算数,由 Admin 和人工审核决定,与 Client 无关。
|
||||
|
||||
## 7. 提交失败或人工处理结果
|
||||
|
||||
```http
|
||||
POST /api/v1/client/tasks/{task_id}/failure
|
||||
Idempotency-Key: task-id:attempt-id:failure-v1
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"task_version": 3,
|
||||
"attempt_id": "attempt-uuid",
|
||||
"status": "manual_review",
|
||||
"error": {
|
||||
"code": "AMBIGUOUS_ORDER_MATCH",
|
||||
"message": "发现多个可能属于当前任务的订单",
|
||||
"retryable": false,
|
||||
"step": "reconcile_order"
|
||||
},
|
||||
"diagnostics": {
|
||||
"artifact_ids": ["artifact-uuid"]
|
||||
},
|
||||
"reported_at": "2026-08-06T08:03:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
`status` 只能是 `retry_wait`、`manual_review`、`failed` 或 `cancelled`。
|
||||
|
||||
规则:
|
||||
|
||||
- `[必须]` §6.1 的无条件接受规则同样适用于本接口。
|
||||
- `[必须]` Admin 决定是否创建新的执行机会,但对已经可能下单的任务不得通过普通重试触发再次购买。
|
||||
|
||||
## 8. 幂等与重试
|
||||
|
||||
- `[必须]` 结果和失败提交必须使用持久化的 `Idempotency-Key`,格式见 [03 数据模型](03-data-model.md) §5.2。
|
||||
- `[必须]` Client 在发送前将完整请求写入 Outbox;重试使用相同键和相同内容。
|
||||
- `[必须]` `claim` 需要 Admin 保证重复请求不会一次分配出多个任务。
|
||||
- `[建议]` 对 `429`、`500` 和 `503` 使用有上限的指数退避,并遵守 `Retry-After`。
|
||||
- `[必须]` 对认证、幂等冲突和业务校验错误不得无限重试。
|
||||
|
||||
## 9. Mock Admin 要求
|
||||
|
||||
`MockAdminGateway` 与 HTTP 实现暴露同一应用层接口,只有三个方法:
|
||||
|
||||
```text
|
||||
claim_next(client, capabilities) # §5
|
||||
submit_result(task_id, idempotency_key, result) # §6
|
||||
submit_failure(task_id, idempotency_key, failure) # §7
|
||||
```
|
||||
|
||||
Mock 必须支持:
|
||||
|
||||
- 无任务可领取(`claim` 返回 204);
|
||||
- 采集和采购任务;
|
||||
- 网络超时和暂时故障;
|
||||
- 幂等重复提交(相同键相同内容);
|
||||
- 幂等冲突(相同键不同内容);
|
||||
- **提交一个 Admin 侧已取消的任务,仍返回 `accepted: true`**(用来验证 §6.1);
|
||||
- 结果校验失败。
|
||||
|
||||
Mock 测试通过不能替代与真实 Admin 的契约测试。
|
||||
|
||||
## 10. 待联合确认
|
||||
|
||||
Admin 还没做完,下面这些要和 Admin 一起定。**不许因此停工**——先按"临时默认值"实现,Mock Gateway 也按这个值模拟,等定了再按工单改。
|
||||
|
||||
| # | 待确认什么 | 临时默认值(先这么做) |
|
||||
|---|---|---|
|
||||
| 1 | 认证、Client 注册和凭据刷新方式 | 请求头预留 `Authorization: Bearer <token>`,token 从设置读;Mock 不校验 |
|
||||
| 2 | Admin 对人工处理任务的后续操作 | Client 只负责报告 `manual_review` 然后停手,不猜 Admin 会怎么处理 |
|
||||
| 3 | Artifact 是独立上传还是只报本地引用 | **只报本地引用**(路径 + 哈希),不实现上传 |
|
||||
| 4 | Admin 超时重派的等待时长 | 纯 Admin 侧策略,Client 不参与也不需要知道 |
|
||||
|
||||
**已定案(不再是待确认项):**
|
||||
|
||||
- Admin 只把任务分配给指定 Client,Client 不读全局任务池。见 §5.1。
|
||||
- **没有租约、没有心跳、没有状态回查。** Client 只有 §5 / §6 / §7 三个调用。见 §1.1。
|
||||
- Admin 必须无条件接受已派发过的 Client 提交的结果。见 §6.1。
|
||||
|
||||
改动这张表里任何一条,都属于会影响业务结果的变更,必须先更新工单并经用户确认。
|
||||
@@ -0,0 +1,336 @@
|
||||
# 05 Client 界面交互规范
|
||||
|
||||
- 文档状态:基线草案,待界面评审
|
||||
- 技术栈:PyQt5、Qt Widgets、PyQt-Fluent-Widgets
|
||||
- 当前验证组件版本:PyQt-Fluent-Widgets 1.11.3(版本以 `client/requirements.txt` 为准)
|
||||
|
||||
本文档中 `[必须]` / `[建议]` / `[待定]` 的含义见 [文档索引](../README.md#文档标注说明)。没有标注的默认是 `[必须]`。看不懂的词查 [术语表](00-glossary.md)。
|
||||
|
||||
## 1. 设计目标
|
||||
|
||||
- 让操作人员在一个页面完成任务监控、搜索和异常定位。
|
||||
- 界面上只有一个会产生外部后果的命令:“开始自动获取”。其余操作全部只读本地数据库。
|
||||
- 长任务状态始终可找到,不使用连续模态弹窗打断工作。
|
||||
- 任务表格在数据增长后仍保持响应速度、稳定选择和可访问性。
|
||||
- 界面只展示任务状态,不在 Qt 主线程执行 Admin 或手机自动化。
|
||||
|
||||
## 2. 应用外壳
|
||||
|
||||
主窗口使用 `FluentWindow`,顶级导航保持稳定顺序:
|
||||
|
||||
1. **PDD 任务**:主要业务页面。
|
||||
2. **设置**:固定在导航底部。
|
||||
|
||||
页面必须使用稳定对象名:
|
||||
|
||||
- `pddTaskPage`
|
||||
- `settingsPage`
|
||||
|
||||
> **现状提醒:** 当前代码里是 3 个导航项(pdd / 设备 / 设置),和这里写的 2 个不一样。这是已知差异,见 [02 架构 §3.1](02-architecture.md)。设备相关设置最终要并入设置页,但要按工单单独做,不要顺手改。
|
||||
|
||||
窗口建议默认尺寸为 1280×800,有效最小尺寸不低于 960×600。窗口变窄时允许表格水平滚动和顶部命令换行,不能隐藏主要任务入口。
|
||||
|
||||
## 3. PDD 任务页布局
|
||||
|
||||
```text
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ PDD 任务 │
|
||||
│ [开始自动获取] [类型▼] [状态▼] [关键词............] [搜索] │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 类型 │ 商品标题 │ 颜色 │ 尺码 │ 价格 │ 数量 │ 状态 │ 更新时间 │详情│
|
||||
│ │
|
||||
│ 任务数据表格 │
|
||||
│ │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 自动获取:已开启 · 当前 PDD-0001 · 正在采集 SKU · 已完成 42 个 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
垂直区域依次为顶部命令区、可伸展表格区和稳定状态区。表格获得全部剩余高度,状态区不得遮挡最后一行或滚动条。
|
||||
|
||||
## 4. 顶部命令区
|
||||
|
||||
### 4.1 开始/停止自动获取
|
||||
|
||||
- 使用 `PrimaryPushButton`,是页面唯一主要强调操作。
|
||||
- 初始文本为“开始自动获取”,图标表达开始。
|
||||
- 启动成功后文本变为“停止自动获取”,按钮位置和宽度尽量稳定。
|
||||
- 启动过程中禁用重复点击,显示“正在启动”。
|
||||
- 停止表示不再领取新任务;当前任务进入安全停止流程。
|
||||
- 如果设备、Admin 或必要设置无效,按钮可禁用,但附近必须说明缺少的条件。
|
||||
|
||||
### 4.2 搜索与筛选
|
||||
|
||||
建议控件:
|
||||
|
||||
- 任务类型:`ComboBox`,选项为“全部、采集、采购”。
|
||||
- 任务状态:`ComboBox`,选项为“全部”及标准任务状态。
|
||||
- 关键词:`SearchLineEdit`,提示“任务编号、商品编号或商品标题”。
|
||||
- 搜索:普通 `PushButton`。
|
||||
|
||||
筛选条件之间采用 AND。点击搜索或在关键词输入框按 Enter 执行本地数据库查询,不请求领取任务。活动筛选必须可见,筛选无结果时保留条件并提供清除入口。
|
||||
|
||||
## 5. 任务表格
|
||||
|
||||
表格显示的是**本机已领取的全部任务**,包括正在做的和早已做完的。已完成任务永久保留,不会被清理,所以数据只增不减——增量加载和索引是必须的,不是优化。
|
||||
|
||||
使用 `qfluentwidgets.TableView`、自定义 `QAbstractTableModel` 和 Repository 查询。不得使用行号作为任务身份,也不得把完整 `pdd_data` 放入模型。
|
||||
|
||||
### 5.1 列定义
|
||||
|
||||
| 列 | 对齐 | 显示规则 |
|
||||
|---|---|---|
|
||||
| 任务类型 | 居中 | “采集”或“采购”,颜色只作辅助 |
|
||||
| 商品标题 | 左对齐、可伸展 | 空值显示“尚未获取标题”,截断时提供完整工具提示 |
|
||||
| 颜色 | 左对齐 | 无值显示 `—` |
|
||||
| 尺码 | 左对齐 | 无值显示 `—` |
|
||||
| 价格 | 右对齐 | 格式为 `¥39.90`;采集摘要可显示 `¥39.90 起` |
|
||||
| 数量 | 右对齐 | 采购数量;采集显示 `—` |
|
||||
| 状态 | 居中 | 中文状态文字和状态图标/标记 |
|
||||
| 更新时间 | 左对齐 | 本地时区,精确到秒或按产品确认格式 |
|
||||
| 操作 | 居中 | “详情”链接或委托绘制按钮 |
|
||||
|
||||
### 5.2 数据行为
|
||||
|
||||
- 默认排序为 `updated_at DESC, id DESC`。
|
||||
- 排序、筛选和数据变化后以稳定任务编号恢复当前行。
|
||||
- 初始加载有限批次,滚动时通过 `canFetchMore/fetchMore` 增量加载后续任务。
|
||||
- 单击非交互区域只设置当前行和选择。
|
||||
- 双击行、按 Enter 或点击“详情”执行相同的非破坏性详情命令。
|
||||
- “详情”使用委托或链接语义,不为每行创建常驻 QWidget。
|
||||
- 空状态要分情况,文案不能一样:
|
||||
- 加载中;
|
||||
- **从没领取过任务** —— “还没有领取过任务,点击‘开始自动获取’开始领取”(不要写“暂无数据”,操作人员会以为是出错了);
|
||||
- 筛选无结果 —— 提供清除条件入口;
|
||||
- Admin 离线 —— 本地数据照常显示,只在信息条说明领取失败;
|
||||
- 加载失败。
|
||||
|
||||
> **界面上说的"任务编号"一律指 `remote_task_id`**(例如 `PDD-20260806-0001`),不是数据库自增 `id`,更不是表格行号。行号会随排序和筛选变化,拿它当任务身份必出错。
|
||||
|
||||
### 5.3 增量加载模板(照抄即可)
|
||||
|
||||
任务可能有几万条,一次全查出来界面会卡住。做法是:先查 50 条,用户滚到底了再查下一批。Qt 的模型/视图自带这个机制,实现 `canFetchMore` 和 `fetchMore` 两个方法就行。
|
||||
|
||||
```python
|
||||
from PyQt5.QtCore import QAbstractTableModel, QModelIndex, Qt
|
||||
|
||||
PAGE_SIZE = 50
|
||||
|
||||
|
||||
class TaskTableModel(QAbstractTableModel):
|
||||
"""任务表格的数据来源。只存显示需要的几个字段。"""
|
||||
|
||||
def __init__(self, repository, parent=None):
|
||||
super().__init__(parent)
|
||||
self._repository = repository
|
||||
self._rows: list[dict] = [] # 已经加载进来的行
|
||||
self._filters: dict = {} # 当前搜索条件
|
||||
self._has_more = True # 数据库里还有没有没取完的
|
||||
|
||||
def rowCount(self, parent=QModelIndex()) -> int:
|
||||
return 0 if parent.isValid() else len(self._rows)
|
||||
|
||||
def canFetchMore(self, parent=QModelIndex()) -> bool:
|
||||
"""Qt 会自己调用它,问"还能再加载吗"。"""
|
||||
return not parent.isValid() and self._has_more
|
||||
|
||||
def fetchMore(self, parent=QModelIndex()) -> None:
|
||||
"""Qt 在用户滚到底时自己调用它。"""
|
||||
if parent.isValid():
|
||||
return
|
||||
|
||||
page = self._repository.list_tasks(
|
||||
filters=self._filters, limit=PAGE_SIZE, offset=len(self._rows)
|
||||
)
|
||||
if not page:
|
||||
self._has_more = False
|
||||
return
|
||||
|
||||
# 必须用 begin/end 包住,直接改 self._rows 界面不会刷新
|
||||
start = len(self._rows)
|
||||
self.beginInsertRows(QModelIndex(), start, start + len(page) - 1)
|
||||
self._rows.extend(page)
|
||||
self.endInsertRows()
|
||||
|
||||
self._has_more = len(page) == PAGE_SIZE
|
||||
|
||||
def apply_filters(self, filters: dict) -> None:
|
||||
"""换搜索条件时整表重来。"""
|
||||
self.beginResetModel()
|
||||
self._filters = filters
|
||||
self._rows = []
|
||||
self._has_more = True
|
||||
self.endResetModel()
|
||||
```
|
||||
|
||||
注意:
|
||||
|
||||
| 别这么做 | 为什么 |
|
||||
|---|---|
|
||||
| 直接 `self._rows.append(...)` 不加 `beginInsertRows` | 界面不刷新,或者直接崩 |
|
||||
| 把整个 `pdd_data` 存进 `_rows` | 几万条 JSON 全在内存里,程序会变得很卡 |
|
||||
| 在 `fetchMore` 里发网络请求 | 这是主线程,界面会卡住。这里只能查本地 SQLite |
|
||||
| 给每个单元格 `setIndexWidget` 放按钮 | 几万个控件,内存直接爆。"详情"那列用委托画 |
|
||||
|
||||
换筛选之后要恢复用户原来选中的行:**先记下当前行的 `remote_task_id`,重新加载完再按这个编号找回来**,不要记行号。
|
||||
|
||||
## 6. 任务详情
|
||||
|
||||
详情是信息查看,不要求用户立即决策,优先使用非模态详情窗口或右侧详情面板,不使用简单消息框承载长 JSON。
|
||||
|
||||
详情至少分为:
|
||||
|
||||
1. **基本信息:**任务编号、类型、商品、规格、数量、金额和状态。
|
||||
2. **执行状态:**当前步骤、设备、尝试次数、开始/结束时间和最近错误。
|
||||
3. **PDD 数据:**标题、店铺、销量、评价、规格维度和 SKU 表格。
|
||||
4. **采购结果:**演练/真实模式、确认规格、价格、订单编号、下单时间和核对状态。
|
||||
5. **原始数据与诊断:**格式化 `admin_payload`、`pdd_data`、日志和 Artifact 入口。
|
||||
|
||||
原始 JSON 默认折叠,提供复制或导出操作;复制前不得包含凭据和敏感信息。
|
||||
|
||||
## 7. 底部状态区
|
||||
|
||||
状态区占据稳定位置,示例:
|
||||
|
||||
```text
|
||||
自动获取:已开启 · 当前任务 PDD-0001(采集)· 正在遍历规格 · 最近领取 16:20:31
|
||||
```
|
||||
|
||||
应表达:
|
||||
|
||||
- 引擎状态:已停止、轮询中、领取中、执行中、提交中、退避等待、正在停止。
|
||||
- 当前任务和当前步骤。
|
||||
- 最近一次领取到任务的时间。
|
||||
- 可恢复错误摘要及下一步。
|
||||
|
||||
高频轮询无任务时不要连续弹提示。设备断开、认证失败、结果提交持续失败等需要操作的问题使用 `InfoBar`,并提供“重试”“打开设置”或“查看详情”。
|
||||
|
||||
## 8. 设置页
|
||||
|
||||
设置页使用可滚动布局和 Fluent 设置卡片,分组如下。
|
||||
|
||||
### Admin
|
||||
|
||||
- 服务地址;
|
||||
- Client 编号;
|
||||
- 认证状态,不直接回显完整令牌;
|
||||
- 请求超时;
|
||||
- “测试连接”及最近结果。
|
||||
|
||||
### Android 设备
|
||||
|
||||
- ADB 地址;
|
||||
- PDD 包名;
|
||||
- “测试连接”;
|
||||
- 当前设备型号、Android 版本和 PDD 当前状态摘要。
|
||||
|
||||
### 自动化
|
||||
|
||||
- 轮询周期;
|
||||
- 任务超时;
|
||||
- 最大重试次数;
|
||||
- 演练模式开关。
|
||||
|
||||
### 安全与诊断
|
||||
|
||||
- 最大购买数量;
|
||||
- 价格允许偏差;
|
||||
- Artifact 目录;
|
||||
- 保留天数;
|
||||
- 打开日志目录。
|
||||
|
||||
设置采用显式“保存设置”或项目统一的即时保存模式,不能在同一页面随机混用。MVP 推荐显式保存,验证失败时保留输入并聚焦第一个错误字段。
|
||||
|
||||
## 9. 状态与反馈
|
||||
|
||||
| 场景 | 反馈方式 |
|
||||
|---|---|
|
||||
| 领取到新任务 | 更新表格和状态区,不弹模态框 |
|
||||
| 暂时没有可领取的任务 | 只更新状态区,**不要连续弹提示** |
|
||||
| 搜索无结果 | 表格空状态和清除条件入口 |
|
||||
| Admin 暂时离线 | 持久信息条,本地数据继续可用 |
|
||||
| 设备断开 | 持久信息条,提供打开设置和重试 |
|
||||
| 任务运行 | 底部状态区和必要进度,不阻塞整个窗口 |
|
||||
| 任务失败 | 行状态、详情和信息条,不显示原始堆栈 |
|
||||
| 多个订单候选 | 状态改为“需要人工处理”,打开详情决策 |
|
||||
| 普通任务成功 | 更新行和状态区,不弹“成功”对话框 |
|
||||
|
||||
### 9.1 错误提示模板(照抄即可)
|
||||
|
||||
规则很简单:**普通成功什么都不弹**(更新界面就够了);**出错用 `InfoBar`**;**只有必须让用户当场做决定时才用对话框**。
|
||||
|
||||
```python
|
||||
from PyQt5.QtCore import Qt
|
||||
from PyQt5.QtWidgets import QPushButton
|
||||
from qfluentwidgets import InfoBar, InfoBarPosition
|
||||
|
||||
|
||||
def show_recoverable_error(self, title: str, content: str, on_retry) -> None:
|
||||
"""显示一条可重试的错误提示。
|
||||
|
||||
title:一句话说清出了什么事
|
||||
content:说清已经保留了什么、下一步该干什么
|
||||
on_retry:点"重试"时调用的函数
|
||||
"""
|
||||
bar = InfoBar.error(
|
||||
title=title,
|
||||
content=content,
|
||||
orient=Qt.Horizontal,
|
||||
isClosable=True,
|
||||
position=InfoBarPosition.TOP_RIGHT,
|
||||
duration=-1, # -1 = 不自动消失。重要错误必须用 -1
|
||||
parent=self,
|
||||
)
|
||||
|
||||
retry_button = QPushButton("重试", bar)
|
||||
retry_button.clicked.connect(on_retry)
|
||||
retry_button.clicked.connect(bar.close)
|
||||
bar.addWidget(retry_button)
|
||||
```
|
||||
|
||||
调用示例:
|
||||
|
||||
```python
|
||||
self.show_recoverable_error(
|
||||
title="Admin 连接失败",
|
||||
content="本地任务仍可查看,结果已保存,联网后会自动提交。",
|
||||
on_retry=self._sync_task_list,
|
||||
)
|
||||
```
|
||||
|
||||
写提示语的三条要求(对应 [06 质量与安全](06-quality-security.md) §5):
|
||||
|
||||
1. **说清发生了什么** —— "Admin 连接失败",不是"操作失败"。
|
||||
2. **说清保住了什么** —— "结果已保存"。用户最怕的是白干。
|
||||
3. **说清下一步** —— 给"重试"按钮,或者给"打开设置"。
|
||||
|
||||
不要做的事:
|
||||
|
||||
| 别这么做 | 改成 |
|
||||
|---|---|
|
||||
| 把 Python 异常堆栈贴到界面上 | 界面显示人话,堆栈写进日志 |
|
||||
| 轮询没任务也弹一条提示 | 只更新底部状态区 |
|
||||
| 用 `QMessageBox` 报告普通错误 | 用 `InfoBar`,别打断用户 |
|
||||
| 错误提示 3 秒自动消失 | 重要错误 `duration=-1`,让用户自己关 |
|
||||
|
||||
## 10. 键盘与无障碍
|
||||
|
||||
- Tab 顺序:自动获取 → 类型 → 状态 → 关键词 → 搜索 → 表格 → 状态区可操作项。
|
||||
- `Ctrl+F` 聚焦关键词,Enter 打开当前行详情。不绑定 `F5`——界面上没有需要刷新的远端数据。
|
||||
- 仅图标按钮必须设置准确的无障碍名称和工具提示。
|
||||
- 表单具有可见标签,占位符不能替代标签。
|
||||
- 状态、错误和任务类型不能只依赖颜色。
|
||||
- 表格焦点、当前行和选择状态必须可区分。
|
||||
- 支持浅色、深色、高对比度和 100%–200% 显示缩放。
|
||||
|
||||
## 11. 原型与验收
|
||||
|
||||
该页面属于主要界面改版。正式重写桌面界面前,建议先制作使用模拟数据的可运行 HTML 原型,确认信息架构、列宽、筛选、详情和状态变化。HTML 原型只用于确认交互,不能替代 PyQt 的窗口、性能、DPI 和无障碍验证。
|
||||
|
||||
桌面实现至少验证:
|
||||
|
||||
- 空数据、少量数据、大量数据和超长标题;
|
||||
- 紧凑、默认、最大化和 Windows 贴靠;
|
||||
- 筛选和增量加载后的当前行保持;
|
||||
- 快速重复点击、后台迟到结果和窗口关闭;
|
||||
- 键盘、浅色、深色、高对比度和 200% 缩放。
|
||||
@@ -0,0 +1,207 @@
|
||||
# 06 Client 质量、安全与测试基线
|
||||
|
||||
- 文档状态:基线草案,待质量评审
|
||||
- 适用范围:Client 源码、配置、数据库、自动化和发布产物
|
||||
|
||||
本文档中 `[必须]` / `[建议]` / `[待定]` 的含义见 [文档索引](../README.md#文档标注说明)。没有标注的默认是 `[必须]`。看不懂的词查 [术语表](00-glossary.md)。
|
||||
|
||||
## 1. 质量目标
|
||||
|
||||
- 同一台 Client 不重复执行或重复提交同一个采购任务;崩溃重启后也不重复下单。
|
||||
- 网络和 Admin 故障不丢失已完成结果。
|
||||
- 设备或 PDD 页面异常可诊断,不以错误坐标继续执行高风险操作。
|
||||
- 所有阻塞工作离开 Qt 主线程,窗口在长任务期间保持可用。
|
||||
- 代码、接口、数据库和诊断产物不泄露凭据及不必要的个人数据。
|
||||
|
||||
## 2. 测试分层
|
||||
|
||||
### 2.1 单元测试
|
||||
|
||||
必须覆盖:
|
||||
|
||||
- 任务状态转换和非法转换;
|
||||
- 金额、数量、价格上限和规格校验;
|
||||
- Admin DTO 与 `pdd_data` JSON 验证;
|
||||
- SQLite Repository、迁移和默认排序;
|
||||
- Outbox 幂等、退避和崩溃恢复;
|
||||
- 销量、评价和价格文字解析;
|
||||
- 控件树坐标解析、规格匹配和选中状态判断;
|
||||
- 订单候选匹配规则。
|
||||
|
||||
PDD 解析测试优先使用脱敏的 XML 固件,不要求每次连接真实设备。
|
||||
|
||||
### 2.2 集成测试
|
||||
|
||||
使用临时 SQLite 和 Mock Admin 覆盖:
|
||||
|
||||
- 原子领取和重复领取;
|
||||
- 提交一个 Admin 侧已取消的任务,仍被接受并标记 `succeeded`;
|
||||
- 采集/采购任务分派;
|
||||
- 结果先落库再进入 Outbox;
|
||||
- Admin 超时后重试相同幂等键;
|
||||
- 程序在提交前、下单阶段和提交后异常退出;
|
||||
- 窗口关闭后后台结果不会更新已销毁界面。
|
||||
|
||||
### 2.3 界面测试
|
||||
|
||||
- Python 语法编译;
|
||||
- 离屏实例化主窗口;
|
||||
- 表格模型排序、筛选和增量加载;
|
||||
- 开始/停止按钮防重复;
|
||||
- Ctrl+F、Enter 和 Tab 顺序;
|
||||
- 加载、空、错误、离线、运行和人工处理状态;
|
||||
- 浅色、深色、高对比度和 100%–200% 缩放。
|
||||
|
||||
### 2.4 设备测试
|
||||
|
||||
真实设备测试必须显式标记,不应混入默认快速测试:
|
||||
|
||||
- PDD 首页、商品页、规格面板和订单列表识别;
|
||||
- 不同商品规格数量、滚动方式和缺货状态;
|
||||
- 登录失效、验证码、网络慢、页面变化和弹窗;
|
||||
- 采集完整性;
|
||||
- 采购演练及订单核对。
|
||||
|
||||
### 2.5 契约和端到端测试
|
||||
|
||||
- Mock Gateway 与 Http Gateway 对同一接口测试集通过。
|
||||
- Admin 提供可测试环境后执行版本、错误和幂等契约测试,含"已取消任务的结果仍被接受"。
|
||||
- 端到端测试从任务领取开始,以 Admin 确认结果和本地状态一致结束。
|
||||
|
||||
## 3. 采购安全门禁
|
||||
|
||||
真实下单开关默认关闭。全部满足后才能通过独立工单启用:
|
||||
|
||||
1. Admin 原子领取和结果幂等已经通过联合测试。
|
||||
2. Outbox 断网和重启恢复测试通过。
|
||||
3. 商品编号、标题、规格、数量、库存和价格保护校验通过。
|
||||
4. 最终下单前后均有持久化步骤标记。
|
||||
5. 进程在不可逆阶段退出后只执行订单核对,不会重新下单。
|
||||
6. 订单匹配能识别唯一候选;多个候选进入人工处理。
|
||||
7. 演练模式在代表性商品和设备上通过验收。
|
||||
8. 操作人员明确确认真实下单范围和安全设置。
|
||||
|
||||
真实下单不得通过调试参数、默认配置或界面误操作意外开启。
|
||||
|
||||
## 4. 自动化防护
|
||||
|
||||
- 点击前必须确认包名、页面类型、目标语义和坐标边界。
|
||||
- 高风险点击应在最新控件树上重新定位,不使用长时间缓存的坐标。
|
||||
- 规格选择后读取最新控件树并验证选中状态。
|
||||
- 最终下单前重新读取实际价格、数量和规格。
|
||||
- 页面出现验证码、登录、支付、权限请求或未知模态层时停止并转人工处理。
|
||||
- uiautomator2 连接由单一工作线程独占,任务间清理临时状态。
|
||||
- 所有循环具有超时、最大滑动次数和可取消检查。
|
||||
|
||||
> **运营提醒(不是代码规则):** Admin 超时重派可能导致同一任务被两台 Client 各下一单,
|
||||
> 产生重复的未付款订单。人工审核时取消多余订单即可。但未付款订单长期堆积可能触发平台风控,
|
||||
> 需要操作人员留意,不要放着不管。
|
||||
>
|
||||
> 将来若要在程序里防这个,正确的加固点是**最终下单前的最后一次校验**(本节已要求重新读取价格、
|
||||
> 数量和规格,顺带确认任务有效性即可),**不要**改回周期性回查 Admin 状态。
|
||||
|
||||
## 5. 错误分类
|
||||
|
||||
建议使用稳定错误代码:
|
||||
|
||||
| 分类 | 示例 |
|
||||
|---|---|
|
||||
| `ADMIN_*` | 认证失败、超时、请求无效、幂等冲突 |
|
||||
| `DEVICE_*` | 设备离线、ADB 失败、应用未启动 |
|
||||
| `PDD_PAGE_*` | 页面超时、未知页面、登录失效、验证码 |
|
||||
| `PDD_DATA_*` | 标题缺失、规格不完整、价格解析失败 |
|
||||
| `PURCHASE_*` | 规格缺货、价格超限、下单状态不确定 |
|
||||
| `ORDER_*` | 未找到订单、多个候选、详情不匹配 |
|
||||
| `DB_*` | 迁移失败、写入失败、数据库锁超时 |
|
||||
| `OUTBOX_*` | 提交失败、幂等冲突、结果被拒绝 |
|
||||
|
||||
错误反馈必须说明发生了什么、已保留什么以及下一步是什么。原始异常堆栈仅写入诊断日志,不直接展示给普通用户。
|
||||
|
||||
## 6. 日志与诊断
|
||||
|
||||
日志至少包含:
|
||||
|
||||
- 时间、级别、模块和稳定事件名;
|
||||
- `client_id`、`remote_task_id`、`attempt_id` 和请求编号;
|
||||
- 当前步骤、持续时间、结果和稳定错误代码;
|
||||
- Admin 响应状态,但不记录认证头和完整敏感正文。
|
||||
|
||||
建议事件:
|
||||
|
||||
- `task_claim_started/succeeded/empty/failed`
|
||||
- `task_claimed`
|
||||
- `task_step_changed`
|
||||
- `task_result_persisted`
|
||||
- `outbox_send_started/succeeded/failed`
|
||||
- `purchase_irreversible_step_entered`
|
||||
- `order_reconcile_completed`
|
||||
|
||||
失败 Artifact 目录建议:
|
||||
|
||||
```text
|
||||
artifacts/<remote_task_id>/<attempt_id>/
|
||||
├── metadata.json
|
||||
├── hierarchy.xml
|
||||
├── screenshot.png
|
||||
└── steps.jsonl
|
||||
```
|
||||
|
||||
Artifact 写入前应脱敏,数据库只保存引用。保留周期由设置控制,删除不得影响任务结果和审计字段。
|
||||
|
||||
## 7. 凭据与隐私
|
||||
|
||||
- Admin 必须使用 HTTPS;生产环境不得允许静默忽略证书错误。
|
||||
- Token 优先保存到 Windows 凭据管理器或由安全环境注入。
|
||||
- **`data/` 目录是明文的,且设计上就是"整个拷走",凭据绝不能写进去**,见 [03](03-data-model.md) §2.1。
|
||||
- 设置页只能显示认证状态或掩码值。
|
||||
- 日志、数据库、截图、XML、Gitea 工单和 `docs/task` 都不得包含 Token、Cookie、密码或支付信息。
|
||||
- 只保存完成任务所需的订单编号和时间,不采集无关收货人、地址、电话等个人数据。
|
||||
- 不实现验证码、风控或平台安全机制绕过。
|
||||
|
||||
## 8. 数据可靠性
|
||||
|
||||
- 数据库迁移前创建可恢复备份或采用可回滚迁移步骤。
|
||||
- 所有可写文件只能落在 `data/` 下,路径一律通过 `data_dir()` 取,见 [03 数据模型](03-data-model.md) §2.2。
|
||||
- 每个任务状态变化和执行记录在同一事务内保持一致。
|
||||
- `result_pending` 任务必须存在对应结果和 Outbox 记录。
|
||||
- Outbox 发送成功前不得删除结果正文。
|
||||
- 迟到的 Admin 响应不得覆盖更新的本地执行状态。
|
||||
- 数据库损坏、磁盘写满和只读目录必须产生可操作错误,不能继续下单。
|
||||
|
||||
## 9. 性能和响应性
|
||||
|
||||
- 表格使用模型/视图和增量加载,不为每个单元格创建 QWidget。
|
||||
- 搜索、排序和分页使用索引支持的 SQLite 查询。
|
||||
- 高频进度信号需要节流,避免每次 XML 节点变化都刷新界面。
|
||||
- 大型 XML 解析、截图和文件写入位于工作线程。
|
||||
- 目标基线:普通本地搜索在典型数据量下保持即时反馈,任务运行时界面可持续操作。
|
||||
|
||||
## 10. 发布门禁
|
||||
|
||||
每个版本至少满足下面全部条件。**"谁负责"这一列是为了让你知道哪些不用自己扛**——不是你负责的项,去找对应的人,不要自己拍板放行。
|
||||
|
||||
| # | 门禁项 | 怎么验证 | 谁负责 |
|
||||
|---|---|---|---|
|
||||
| 1 | 依赖版本已固定,且能重复安装 | `client/requirements.txt` 里每一行都带 `==`;在干净环境执行 `pip install -r requirements.txt` 能装成功 | 开发者 |
|
||||
| 2 | 全部默认单元测试和集成测试通过 | 跑测试命令,不允许有跳过而没说明的用例 | 开发者 |
|
||||
| 3 | UI 离屏冒烟测试通过 | 见 [client/AGENTS.md](../../client/AGENTS.md) §验证 第 2 条 | 开发者 |
|
||||
| 4 | 数据库从上一发布版本迁移成功 | 拿上一版本的 `client.db` 副本启动新版本,数据不丢 | 开发者 |
|
||||
| 5 | Mock Admin 契约测试通过 | Mock 和 HTTP 两个实现跑同一套测试 | 开发者 |
|
||||
| 6 | Windows 干净环境启动测试通过 | **未打包版本**:找一台没装过本项目的机器,照 [00 上手指南](00-getting-started.md) 从头走一遍。**已打包版本**:把整个文件夹拷到一台**没装 Python** 的机器上双击 `launcher.exe` | 开发者 |
|
||||
| 6b | 升级不丢数据(仅打包版本) | 换掉 `launcher.exe` 和 `app/`,`data/` 保留,启动后任务、日志、设置都还在 | 开发者 |
|
||||
| 7 | 日志和产物无敏感信息 | 翻一遍日志和 `artifacts/`,确认没有 token、Cookie、密码、收货人信息 | 开发者 |
|
||||
| 8 | 开源和商业许可证已确认 | 新增依赖的许可证是否允许本项目的使用方式 | **项目负责人**(不是开发者自己判断) |
|
||||
| 9 | 真实下单版本额外满足采购安全门禁 | 逐条核对 §3 的 8 项 | **项目负责人 + 操作人员共同确认** |
|
||||
|
||||
第 8、9 项开发者**不要自己判断放行**。新增依赖时把包名和许可证类型报给项目负责人;真实下单开关必须由项目负责人和实际操作的人一起确认,见 §3。
|
||||
|
||||
## 11. 任务完成定义
|
||||
|
||||
单元任务只有同时满足以下条件才算完成:
|
||||
|
||||
1. 工单验收标准逐项通过。
|
||||
2. 代码、迁移、测试和必要文档同步完成。
|
||||
3. 没有静默跳过的测试或未说明的真实设备假设。
|
||||
4. 变更不泄露敏感信息,也不扩大自动化权限。
|
||||
5. Gitea 工单更新最终结果和提交哈希。
|
||||
6. 完成记录归档到 `docs/task`。
|
||||
Reference in New Issue
Block a user