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:
+32
@@ -0,0 +1,32 @@
|
|||||||
|
# 本地数据:数据库、日志、截图、控件树 XML
|
||||||
|
# 位置和用途见 docs/client/03-data-model.md §2.1
|
||||||
|
client/data/
|
||||||
|
data/
|
||||||
|
|
||||||
|
# 打包产物
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
|
||||||
|
# 跑 demo 脚本时在当前目录生成的调试产物
|
||||||
|
# (src/demo1/auto_v1.py 会写这些,不要提交)
|
||||||
|
/*.xml
|
||||||
|
/*.png
|
||||||
|
client/*.xml
|
||||||
|
client/*.png
|
||||||
|
|
||||||
|
# 编辑器
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
|
||||||
|
# Windows
|
||||||
|
Thumbs.db
|
||||||
|
Desktop.ini
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
# 项目协作规则
|
||||||
|
|
||||||
|
## 需求、缺陷与任务工作流
|
||||||
|
|
||||||
|
本流程适用于新需求、缺陷修复、重构以及会改变程序行为的任务。正式工单采用 **史诗级大工单(Epic)→ 最小可行产品工单(MVP)→ 单元任务工单** 三级结构。Gitea 工单是实施期间的事实来源,本地 `docs/task` 是任务完成后的最终归档。
|
||||||
|
|
||||||
|
工单和归档直接使用 [docs/templates/task.md](docs/templates/task.md) 里的模板,不要每次自拟结构。
|
||||||
|
|
||||||
|
### 0. 小改动直通
|
||||||
|
|
||||||
|
**不改变程序行为**的改动可以直接提交,不必建工单、不必归档,只需在提交信息里写清改了什么:
|
||||||
|
|
||||||
|
- 错别字、注释、文档措辞;
|
||||||
|
- 代码格式、导入排序、变量改名(不跨文件、不改公开接口);
|
||||||
|
- 补充类型标注、补充文档字符串;
|
||||||
|
- 删除确认无人使用的死代码。
|
||||||
|
|
||||||
|
只要满足下面任意一条,就**不属于**小改动,必须走完整流程:
|
||||||
|
|
||||||
|
- 改了数据库结构、Admin 接口、任务状态或 `pdd_data` 结构;
|
||||||
|
- 改了采购、下单、幂等、崩溃恢复或线程相关代码;
|
||||||
|
- 改了界面上用户能看出来的东西;
|
||||||
|
- 你不确定它算不算小改动。
|
||||||
|
|
||||||
|
Gitea 使用约定(**首次使用前需由项目负责人补全**):
|
||||||
|
|
||||||
|
- Gitea 地址:`<待填写>`
|
||||||
|
- 仓库:`<待填写>`
|
||||||
|
- 工单标签:`<待填写>`
|
||||||
|
- 里程碑命名:`<待填写>`
|
||||||
|
|
||||||
|
在补全之前,按 §3.6 处理:输出完整工单草稿并说明阻塞,不要跳过建单直接实施。
|
||||||
|
|
||||||
|
### 1. 工单层级与职责
|
||||||
|
|
||||||
|
```text
|
||||||
|
史诗级大工单(Epic)
|
||||||
|
├── MVP 工单
|
||||||
|
│ ├── 阶段 1:单元任务工单
|
||||||
|
│ ├── 阶段 2:单元任务工单
|
||||||
|
│ └── MVP 集成验收
|
||||||
|
└── MVP 之后:后续版本的单元任务工单
|
||||||
|
```
|
||||||
|
|
||||||
|
- **大工单:**记录完整产品目标、总体范围与非目标、总体架构、阶段路线、全局风险、所有已知任务索引和最终完成标准。
|
||||||
|
- **MVP 工单:**记录首个可交付版本的范围与非范围、阶段计划、单元任务清单、集成风险和 MVP 验收标准。MVP 是大工单的可交付子集,不等同于普通开发阶段。
|
||||||
|
- **单元任务工单:**唯一的实际执行单位,记录具体问题、方案、修改范围、依赖、验收标准、实施记录和测试结果。
|
||||||
|
- **阶段:**默认使用大工单或 MVP 工单中的章节、清单或 Gitea 里程碑表达,不增加正式工单层级。只有阶段需要独立负责人、独立验收或复杂协调时才单独建工单。
|
||||||
|
- 原则上不再增加比单元任务更深的工单层级;复杂任务应拆成多个可独立验证的同级任务。
|
||||||
|
|
||||||
|
### 2. 需求讨论与总体方案确认
|
||||||
|
|
||||||
|
1. 用户提出需求、现象或缺陷,助手先复述目标并检查相关代码、日志和运行环境。
|
||||||
|
2. 助手按照软件开发规范分析并提出总体方案,明确:
|
||||||
|
- 已确认事实与仍待验证的假设;
|
||||||
|
- 根因或需求边界;
|
||||||
|
- 目标、非目标、建议方案和影响范围;
|
||||||
|
- MVP 边界以及 MVP 之后的内容;
|
||||||
|
- 阶段拆分、已知单元任务和依赖顺序;
|
||||||
|
- 风险、兼容性、回退方式和总体验证方法。
|
||||||
|
3. 用户审核总体方案,双方继续讨论和修订,直到用户明确确认最终方案。
|
||||||
|
4. 最终方案确认前默认只允许阅读、诊断、实验性验证和方案整理;不得提前实施正式代码、创建工单、提交 Git 或扩大范围,除非用户明确要求某项操作。
|
||||||
|
|
||||||
|
**这条和"类和函数骨架"的边界:** 用户自己写骨架不受本条限制,那是用户在表达需求。本条约束的是助手——方案确认前,助手不写实现代码、不提交 Git。用户已经写好骨架并要求补全时,视为该部分方案已确认,助手可以直接补全,但仍不得越出骨架范围新增功能。
|
||||||
|
|
||||||
|
### 3. 创建大工单、MVP 工单与单元任务
|
||||||
|
|
||||||
|
1. 用户确认总体方案后,先创建大工单,并写入完整目标、总体方案、阶段路线和所有已知任务索引。
|
||||||
|
2. 再创建 MVP 工单,在其中定义首个可交付范围、排除项、阶段、验收标准和 MVP 单元任务清单,同时与大工单双向关联。
|
||||||
|
3. 准备实施某个阶段前,先为其中已具备明确边界的工作创建单元任务工单,再把工单编号更新到 MVP 和大工单的任务清单。
|
||||||
|
4. 不必为尚不明确的远期工作提前创建空泛的单元工单;先在大工单中保留规划项,待范围清晰后再建单。
|
||||||
|
5. 每个 Gitea 工单必须使用自己的工单号作为唯一追踪编号,后续分支、提交、进度记录和本地归档都引用对应编号。
|
||||||
|
6. 如果 Gitea 暂时不可用,应先输出完整工单草稿并说明阻塞;未经用户同意,不得绕过建单直接进入正式实施。
|
||||||
|
|
||||||
|
### 4. 各层工单内容规范
|
||||||
|
|
||||||
|
大工单和 MVP 工单至少包含:
|
||||||
|
|
||||||
|
- 背景、目标、非目标和范围边界;
|
||||||
|
- 已确认总体方案和关键架构决策;
|
||||||
|
- 阶段或里程碑;
|
||||||
|
- 子工单清单及当前状态;
|
||||||
|
- 依赖、全局风险和回退策略;
|
||||||
|
- 集成及最终验收标准。
|
||||||
|
|
||||||
|
单元任务工单直接套用 [模板 A](docs/templates/task.md),必填项只有 6 条:
|
||||||
|
|
||||||
|
1. 基本信息(类型、父级大工单、所属 MVP/版本、阶段);
|
||||||
|
2. 要解决什么(缺陷附复现步骤);
|
||||||
|
3. 做什么 / 不做什么;
|
||||||
|
4. 已确认的实现方案(含预计修改文件);
|
||||||
|
5. 可逐项打勾的验收标准;
|
||||||
|
6. 验证方式(可复制的命令;需要真机时写明)。
|
||||||
|
|
||||||
|
"风险和回退"为选填,但改动涉及采购下单、数据库迁移或 Admin 接口时必填。
|
||||||
|
|
||||||
|
### 5. 单元任务拆分标准
|
||||||
|
|
||||||
|
单元任务不按固定数量机械拆分。每个任务应满足:
|
||||||
|
|
||||||
|
- 目标单一、边界清晰且可以独立理解;
|
||||||
|
- 具有明确输入、输出和可逐项检查的验收标准;
|
||||||
|
- 能够独立开发、测试、提交和回退;
|
||||||
|
- 修改文件和依赖范围可控,不混入顺手重构或无关修复;
|
||||||
|
- 一个提交或一组紧密相关的提交可以完成;
|
||||||
|
- 如果仍包含多个互不依赖的交付结果,应继续拆分为同级任务。
|
||||||
|
|
||||||
|
### 6. 新增工单与父工单同步
|
||||||
|
|
||||||
|
1. 出现新需求、缺陷、遗漏工作或技术债时,先判断它属于当前 MVP、MVP 后续版本,还是改变大工单总体范围。
|
||||||
|
2. 先创建新的单元任务工单,并填写父级大工单、所属 MVP/版本、阶段和依赖关系。
|
||||||
|
3. 属于当前 MVP 时,同时更新 MVP 工单和大工单的任务清单;不属于当前 MVP 时,只更新大工单和对应的后续版本/里程碑,不得擅自扩大 MVP。
|
||||||
|
4. 新工单会改变已确认范围、交付结果、验收标准或时间安排时,必须先更新父工单并交由用户重新审核,确认后才能实施。
|
||||||
|
5. 详细分析和实施记录只保存在单元工单;MVP 工单只维护交付进度和集成状态;大工单只维护总体路线和范围,避免三处复制同一正文。
|
||||||
|
|
||||||
|
### 7. Git 准备与任务实施
|
||||||
|
|
||||||
|
1. 只有单元任务工单已建立、方案和验收标准明确后,才能进入正式实施。
|
||||||
|
2. 开始前检查工作区和当前分支,保留用户已有及与任务无关的改动。
|
||||||
|
3. 严格按照单元工单范围实施;不得把其他工单、顺手重构或无关修复混入当前任务。
|
||||||
|
4. 只有存在与工单相关的实际文件变更时才提交 Git,不得为了流程创建空提交。
|
||||||
|
5. 提交信息应简洁描述变更并引用单元工单号,例如 `fix: 修复规格匹配 (#123)`。
|
||||||
|
6. 实现过程中执行与风险相称的检查和测试,把关键进度、测试结果和提交哈希记录到单元工单。
|
||||||
|
|
||||||
|
### 8. 实施中的变更管理
|
||||||
|
|
||||||
|
- 范围、技术方案、接口、数据结构、依赖、验收标准、测试计划、风险或交付时间发生变化时,必须先更新单元工单。
|
||||||
|
- 变化影响 MVP 工单或大工单时,还必须同步更新对应父工单的范围、风险、计划或任务索引。
|
||||||
|
- 会改变用户已确认方案或交付结果的实质性变化,必须先写入工单并再次交由用户审核,确认后才能继续实施。
|
||||||
|
- 不改变外部行为的普通实现细节可以继续推进,但应在单元工单的实施记录中说明重要取舍。
|
||||||
|
- 阻塞、失败方案、新发现的根因或无法完成的验收项必须及时更新工单,不得只保留在聊天记录或代码注释中。
|
||||||
|
- 工单状态必须与实际进度一致:待确认、待实施、进行中、阻塞、待验收、已完成。
|
||||||
|
|
||||||
|
### 9. 单元任务完成与本地归档
|
||||||
|
|
||||||
|
1. 完成实现后,先按单元工单的验收标准执行验证,记录实际结果和未验证内容。
|
||||||
|
2. 将实现代码按可理解、可验证的粒度提交,所有提交都引用单元工单号。
|
||||||
|
3. 更新单元工单,写明最终实现、与原方案的差异、测试结果、实现提交和遗留问题。
|
||||||
|
4. 将完整单元任务归档为 `docs/task/<工单号>-<简短名称>.md`,目录不存在时创建。
|
||||||
|
5. 本地任务文档直接套用 [模板 B](docs/templates/task.md),必填项只有 6 条:
|
||||||
|
|
||||||
|
1. 头部信息(工单号、标题、类型、父级、版本、状态、日期、Gitea 链接);
|
||||||
|
2. 背景与目标;
|
||||||
|
3. 最终方案(与建单方案有出入时写清差异和原因);
|
||||||
|
4. 改了哪些文件;
|
||||||
|
5. 验收结果(逐项);
|
||||||
|
6. 测试:执行的命令、结果、**未验证到的部分**。
|
||||||
|
|
||||||
|
第 6 条的"未验证到的部分"不允许留空,没有就写"无"。"遗留问题"为选填。
|
||||||
|
6. 单独提交本地归档文档,例如 `docs: 归档任务 #123`,并把归档提交哈希和文档路径回写到单元工单。
|
||||||
|
7. 用户验收通过或明确同意后关闭单元工单,并立即更新 MVP 工单和大工单的任务清单及汇总状态。
|
||||||
|
|
||||||
|
### 10. MVP 工单与大工单的完成顺序
|
||||||
|
|
||||||
|
1. 单元任务逐个完成、归档、验收和关闭。
|
||||||
|
2. MVP 范围内所有任务完成后执行集成、回归和 MVP 验收,记录跨任务测试结果。
|
||||||
|
3. MVP 验收通过后,在 `docs/task` 创建对应的 MVP 总结文档,提交并回写 Gitea,然后关闭 MVP 工单。
|
||||||
|
4. 大工单规划的所有版本和最终验收完成后,创建大工单总结文档,提交并回写 Gitea,最后关闭大工单。
|
||||||
|
5. 不得因为单元任务已完成而提前关闭 MVP 工单,也不得因为 MVP 已完成而提前关闭仍有后续范围的大工单。
|
||||||
|
|
||||||
|
### 11. 记录与安全原则
|
||||||
|
|
||||||
|
- Gitea 单元工单保留详细讨论、决策、进度和变更历史;MVP 工单和大工单保留汇总;`docs/task` 保存与最终代码版本对应的结论。
|
||||||
|
- 聊天记录不能替代 Gitea 工单或本地任务文档。
|
||||||
|
- 父工单使用 `- [ ] #工单号` / `- [x] #工单号` 等清单维护子工单索引,不复制子工单全文。
|
||||||
|
- 工单和归档中的命令、日志与截图必须去除密码、访问令牌、浏览器 Cookie、个人数据及其他敏感信息。
|
||||||
|
- 每个 Git 提交必须可理解、可验证、可追溯,且不得包含无关文件。
|
||||||
|
|
||||||
|
## 面向初级程序员的开发与文档规则
|
||||||
|
|
||||||
|
本项目默认由初级程序员阅读和维护。设计、代码、注释、文档和交付说明都应以容易理解、容易调试和容易继续修改为优先目标。
|
||||||
|
|
||||||
|
新人从这两份开始读,不要一上来啃基线文档:
|
||||||
|
|
||||||
|
- [docs/client/00-getting-started.md](docs/client/00-getting-started.md) —— 装环境、跑起来、常见报错
|
||||||
|
- [docs/client/00-glossary.md](docs/client/00-glossary.md) —— 专业术语一句话解释
|
||||||
|
|
||||||
|
### 类和函数骨架
|
||||||
|
|
||||||
|
- 初级程序员可以先创建类或函数骨架,再由助手按照已确认工单补全实现。
|
||||||
|
- 骨架应尽量写明名称、职责、参数、返回值和可能的错误;暂未实现的部分使用清晰的 `TODO`,不得伪装成已经完成。
|
||||||
|
- 助手补全骨架前先理解原有命名和职责,能在现有边界内完成时不要随意重命名、移动文件或重做架构。
|
||||||
|
- 如果骨架存在明显职责混乱、线程错误或安全风险,先解释问题并提出最小调整方案;涉及已确认方案变化时按工单流程重新审核。
|
||||||
|
- 补全后删除已经完成的 `TODO`,保留仍未完成且有明确原因的事项。
|
||||||
|
|
||||||
|
### 代码可读性
|
||||||
|
|
||||||
|
- 优先使用直白、常见的 Python 写法,不为减少几行代码使用晦涩技巧、元编程或过深继承。
|
||||||
|
- 类和函数保持单一职责;函数过长或同时处理界面、网络、数据库和自动化时,应按职责拆分。
|
||||||
|
- 名称应表达业务含义,避免无意义缩写以及 `data1`、`temp2`、`handle` 等模糊名称。
|
||||||
|
- 公共类、函数和复杂业务规则应提供简短中文文档字符串,说明“做什么、输入什么、返回什么、何时失败”。
|
||||||
|
- 注释重点解释“为什么这样做”和风险约束,不逐行翻译代码。
|
||||||
|
- 使用类型标注、枚举和小型数据对象表达边界;避免到处传递结构不明的字典。
|
||||||
|
- 错误应返回或抛出明确类型和信息,不使用裸 `except`,不静默忽略失败。
|
||||||
|
- 除非确实需要扩展点,不提前设计复杂抽象;出现第二个真实实现后再考虑提取通用层。
|
||||||
|
- 修改现有代码时保持风格一致,并尽量提供一个最小调用示例或对应测试。
|
||||||
|
|
||||||
|
### 文档编写
|
||||||
|
|
||||||
|
- 文档使用简洁、自然的中文,先说明用途和结论,再写必要步骤和规则。
|
||||||
|
- 一个文档只解决一个主要问题;使用短段落和短列表,避免堆叠大段理论说明。
|
||||||
|
- 专业术语统一收录在 [docs/client/00-glossary.md](docs/client/00-glossary.md)。新词要么在术语表里加一条,要么在正文用一句话解释,两者至少做一个;不能翻译的类名、字段名、命令和协议名保持原样。
|
||||||
|
- 命令和示例必须可以直接复制,并说明从哪个目录执行、预期看到什么结果。
|
||||||
|
- 数据结构、状态对应关系和接口字段优先使用小型表格或短示例;关系简单时不要绘制复杂架构图。
|
||||||
|
- 文档只记录稳定需求、使用方法和关键约束,不复制容易与代码失去同步的内部实现细节。
|
||||||
|
- 进度、临时方案和待办写入 Gitea 工单;完成记录写入 `docs/task`,不反复修改长期基线文档。
|
||||||
|
- 修改已有长文档时应顺便删除重复和过时内容,但不得为了缩短文档丢失安全、数据和验收约束。
|
||||||
|
- 文档末尾只列真正未解决、需要用户决定的问题,不添加泛化的“未来可优化”清单。
|
||||||
|
|
||||||
|
### 助手交付说明
|
||||||
|
|
||||||
|
- 面向初级程序员说明改了什么、为什么、从哪里开始读以及怎样运行验证。
|
||||||
|
- 优先给出最短可行操作,不一次列出大量可选方案;存在重要取舍时再解释替代方案。
|
||||||
|
- 报错分析应指出具体位置、直接原因、修复方法和验证方式,不只给出修改后的完整代码。
|
||||||
|
- 不假设维护者熟悉 Qt 线程、SQLite 事务、Admin 幂等或 uiautomator2 控件树;涉及这些概念时给出一句话背景。
|
||||||
|
|
||||||
|
## Client 子项目
|
||||||
|
|
||||||
|
- 处理 `client/` 下的需求、代码、测试或文档前,必须读取并遵循 `client/AGENTS.md`。
|
||||||
|
- 从仓库根目录启动 Codex 时也不能跳过该文件。
|
||||||
|
- Client 的详细需求和技术基线位于 `docs/client/`;按当前任务只读取相关文档。
|
||||||
|
- Client 专用技术栈、线程、自动化安全、界面和验证规则不在根文件重复维护。
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# Client 子项目规则
|
||||||
|
|
||||||
|
本文件适用于 `client/` 下的全部代码和测试,并继承仓库根目录 `AGENTS.md` 的工作流、Git、安全、文档和初级程序员维护规则。
|
||||||
|
|
||||||
|
## 开始工作前
|
||||||
|
|
||||||
|
- 从仓库根目录执行 Client 任务时,也必须先读取本文件。
|
||||||
|
- 第一次接手本项目,先看这两份:
|
||||||
|
- 环境和运行:[00 上手指南](../docs/client/00-getting-started.md)
|
||||||
|
- 看不懂的词:[00 术语表](../docs/client/00-glossary.md)
|
||||||
|
- 只读取与当前任务有关的长期基线,不必每次加载全部文档:
|
||||||
|
- 需求边界:[01 产品需求基线](../docs/client/01-requirements.md)
|
||||||
|
- 模块和线程:[02 系统架构](../docs/client/02-architecture.md)
|
||||||
|
- SQLite 与 JSON:[03 数据模型](../docs/client/03-data-model.md)
|
||||||
|
- Admin 对接:[04 接口契约](../docs/client/04-admin-api-contract.md)
|
||||||
|
- 页面和交互:[05 界面规范](../docs/client/05-ui-specification.md)
|
||||||
|
- 测试和采购安全:[06 质量与安全](../docs/client/06-quality-security.md)
|
||||||
|
- 文档仍是草案或存在未决事项时,不得自行选择会改变业务结果的答案,应按根目录工单流程确认。
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- 固定使用 **Python 3.10 + PyQt5 + Qt Widgets + PyQt-Fluent-Widgets**。
|
||||||
|
- 界面组件优先使用 `qfluentwidgets`;没有合适组件时再使用 PyQt5 标准控件。
|
||||||
|
- 只有两者都无法满足明确需求时才创建自定义控件,并说明原因。
|
||||||
|
- 不得混用 PyQt6、PySide2 或 PySide6,也不得安装其他 Qt 绑定对应的 Fluent Widgets 包。
|
||||||
|
- 当前开发和验证解释器为 `C:/Python310/python.exe`。
|
||||||
|
- 依赖及版本以 `client/requirements.txt` 为准,版本必须固定。
|
||||||
|
- 新增或升级依赖前先检查准确版本、许可证和打包影响,并经过工单确认;确认后同步更新 `requirements.txt`。
|
||||||
|
|
||||||
|
## 代码分层
|
||||||
|
|
||||||
|
- 界面层只负责显示和输入转发,不直接调用 Admin、SQLite 长查询或 uiautomator2。
|
||||||
|
- `src/ui_main.py` 负责应用和窗口装配;事件绑定放在 `src/ui_event.py` 或后续应用层服务中。
|
||||||
|
- 领域模型不导入 Qt、uiautomator2 或具体 HTTP 客户端。
|
||||||
|
- Admin 通过 Gateway 接口隔离,Mock 和 HTTP 实现必须遵守相同契约。
|
||||||
|
- SQLite 通过 Repository 访问,不在页面或自动化函数中散落业务 SQL。
|
||||||
|
- PDD 页面识别、采集和采购函数放在 PDD 适配层;演示脚本不得被正式流程直接导入。
|
||||||
|
- 类和函数骨架遵循根目录的初级程序员维护规则,优先补全现有清晰边界,不随意重做架构。
|
||||||
|
|
||||||
|
## 数据与接口
|
||||||
|
|
||||||
|
- 数据库字段、任务状态和 `pdd_data` 结构以 `docs/client/03-data-model.md` 为准。
|
||||||
|
- Admin 路径、字段和幂等语义以 `docs/client/04-admin-api-contract.md` 为准;Admin 未完成时使用 Mock,不臆造正式响应。
|
||||||
|
- Client 与 Admin 只有三个调用:领取、提交结果、提交失败。**不得新增"向 Admin 查询状态"类接口。**
|
||||||
|
- 金额使用人民币分整数,时间使用带时区 ISO 8601,稳定任务编号不得使用表格行号代替。
|
||||||
|
- 文件路径一律通过 `data_dir()`(可写数据)和 `app_dir()`(只读资源)获取,见 [03 数据模型 §2.2](../docs/client/03-data-model.md)。**不许硬编码路径,不许用 `os.getcwd()` 或 `__file__` 直接拼**,打包成 exe 后会失效。
|
||||||
|
- PDD 结果必须先写入 SQLite,再通过 Outbox 提交 Admin。
|
||||||
|
- 任务领取后中途不查 Admin 状态;Admin 取消或重派一律不感知,做完照常提交。
|
||||||
|
|
||||||
|
## 线程与自动化
|
||||||
|
|
||||||
|
- QWidget 只能在 Qt 主线程创建和访问。
|
||||||
|
- 禁止在主线程执行 Admin 请求、uiautomator2/ADB、`time.sleep()`、轮询、大型 XML 解析或文件批量写入。
|
||||||
|
- 长任务统一使用 `QObject` + `moveToThread` 的 Worker 写法,模板见 [02 架构 §5.1](../docs/client/02-architecture.md);不得混用 `QThread` 子类、`QRunnable` 或 `threading`。
|
||||||
|
- 后台结果通过信号回到主线程;窗口关闭时按 [02 架构 §5.2](../docs/client/02-architecture.md) 断开连接,防止迟到结果访问已销毁控件。
|
||||||
|
- 一个 Android 设备同一时间只由一个工作线程控制,不跨线程共享 uiautomator2 Device 对象。
|
||||||
|
- 自动化任务必须处理启动、运行、安全停止、成功、失败、取消、超时、设备断开和迟到结果。
|
||||||
|
- 窗口或页面销毁后,后台回调不得再访问对应控件。
|
||||||
|
|
||||||
|
## 采购安全
|
||||||
|
|
||||||
|
- 真实下单开关默认关闭;未满足质量文档中的采购安全门禁不得启用。
|
||||||
|
- 高风险点击前必须使用最新页面状态重新校验商品、规格、数量、价格和目标坐标。
|
||||||
|
- 进入不可逆下单阶段前先持久化执行步骤。
|
||||||
|
- 不可逆阶段发生崩溃或结果不确定时,只能核对订单或转人工处理,禁止自动重新下单。
|
||||||
|
- 多个订单候选、验证码、登录失效、未知弹窗、价格超限或规格不确定时停止自动执行。
|
||||||
|
|
||||||
|
## 界面规则
|
||||||
|
|
||||||
|
- 顶级模块保持为“PDD 任务”和“设置”,页面使用唯一且稳定的 `objectName`。
|
||||||
|
- 优先使用 `FluentWindow`、Fluent 导航、主题和图标;不得使用表情符号充当结构图标。
|
||||||
|
- 使用布局、尺寸策略和伸缩项,不用固定坐标排列常规界面。
|
||||||
|
- 任务表格使用模型/视图和稳定任务编号,不把完整 `pdd_data` 放入隐藏列,也不为每个单元格创建常驻 QWidget。
|
||||||
|
- “开始自动获取”是界面上唯一会产生外部后果的命令;其余操作只读本地数据库。
|
||||||
|
- 本地只保存已领取的任务,不缓存 Admin 任务池;已完成任务永久保留,不得清理。
|
||||||
|
- 普通成功更新页面状态即可;可恢复错误使用 `InfoBar`,只有必须阻断决策时才使用模态对话框。
|
||||||
|
- 主要流程必须支持键盘;表单具有可见标签;状态和错误不能只依赖颜色。
|
||||||
|
- 验证浅色、深色、Windows 贴靠和 100%–200% 显示缩放。
|
||||||
|
- 避免全局 QSS 覆盖 Fluent 控件;局部样式必须保留悬停、按下、焦点和禁用状态。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
全部命令从 `client/` 目录执行,可直接复制。
|
||||||
|
|
||||||
|
**1. 语法检查**(改了哪个文件就换成哪个)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
C:/Python310/python.exe -m py_compile src/ui_main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. 界面离屏冒烟测试**(不弹窗口,跑完自动退出,CI 和本地都用这条)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$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
|
||||||
|
```
|
||||||
|
|
||||||
|
打印 `OK` 即通过。
|
||||||
|
|
||||||
|
**3. 手动看界面**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
C:/Python310/python.exe buyer_main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
当前 `buyer_main.py` 只装配并显示窗口,**不连接设备、不执行任何自动化**,可以随时运行。
|
||||||
|
|
||||||
|
将来"开始自动获取"接上真实设备后,**启动自动化的命令会产生真实外部操作,届时不得作为普通冒烟测试运行**;那时的日常验证仍以第 2 条离屏测试为准。
|
||||||
|
|
||||||
|
**4. 其他情况**
|
||||||
|
|
||||||
|
- 修改数据库时测试首次建库和从上一版本迁移。
|
||||||
|
- 修改 Admin Gateway 时运行 Mock 与 HTTP 共用的契约测试。
|
||||||
|
- 修改 PDD 解析时优先使用脱敏 XML 固件;真实设备测试必须单独标记并说明是否执行真实下单。
|
||||||
|
- 交付时说明已运行的命令、结果、未验证的设备行为和新增依赖。
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Client 运行依赖
|
||||||
|
#
|
||||||
|
# 安装方法(在 client 目录下执行):
|
||||||
|
# C:/Python310/python.exe -m pip install -r requirements.txt
|
||||||
|
#
|
||||||
|
# 版本必须固定,不要改成不带 == 的写法(发布门禁要求,见 docs/client/06-quality-security.md §10)。
|
||||||
|
# 如果 pip 报"找不到该版本",处理办法见 docs/client/00-getting-started.md §2。
|
||||||
|
#
|
||||||
|
# 只支持 PyQt5。禁止安装 PyQt6 / PySide2 / PySide6,会和 PyQt-Fluent-Widgets 冲突。
|
||||||
|
|
||||||
|
# 界面
|
||||||
|
PyQt5==5.15.11
|
||||||
|
PyQt-Fluent-Widgets==1.11.3
|
||||||
|
|
||||||
|
# 安卓自动化
|
||||||
|
uiautomator2==3.2.5
|
||||||
|
|
||||||
|
# 后续接入 Admin HTTP 接口时再放开(当前只用 Mock,不需要装):
|
||||||
|
# requests==2.32.3
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# 项目文档索引
|
||||||
|
|
||||||
|
本目录保存项目级文档。长期稳定的 Client 基线文档放在 `docs/client`,实施过程和完成记录放在 `docs/task`,两者不得混用。
|
||||||
|
|
||||||
|
## 新人从这里开始
|
||||||
|
|
||||||
|
| 文档 | 用途 |
|
||||||
|
|---|---|
|
||||||
|
| [00 上手指南](client/00-getting-started.md) | 装环境、装依赖、连手机、把程序跑起来、常见报错 |
|
||||||
|
| [00 术语表](client/00-glossary.md) | Outbox、幂等、SKU 等专业词的一句话解释 |
|
||||||
|
|
||||||
|
## 按任务找文档
|
||||||
|
|
||||||
|
**不用通读全部文档**,按你要做的事挑:
|
||||||
|
|
||||||
|
| 我要做的事 | 主要看 | 顺带看 |
|
||||||
|
|---|---|---|
|
||||||
|
| 第一次把项目跑起来 | [00 上手指南](client/00-getting-started.md) | — |
|
||||||
|
| 改界面、加页面、调表格 | [05 界面交互规范](client/05-ui-specification.md) | [02 架构](client/02-architecture.md) §5 线程 |
|
||||||
|
| 加字段、改表、写 SQL | [03 数据模型](client/03-data-model.md) | [02 架构](client/02-architecture.md) §7 数据所有权 |
|
||||||
|
| 对接 Admin、写 Gateway | [04 接口契约](client/04-admin-api-contract.md) | [03 数据模型](client/03-data-model.md) §5 Outbox |
|
||||||
|
| 写自动化、控制手机 | [02 架构](client/02-architecture.md) §9 | [06 质量与安全](client/06-quality-security.md) §4 |
|
||||||
|
| 碰采购、下单相关代码 | [06 质量与安全](client/06-quality-security.md) §3 | [01 需求](client/01-requirements.md) §4.2 |
|
||||||
|
| 写测试 | [06 质量与安全](client/06-quality-security.md) §2 | — |
|
||||||
|
| 搞不清这功能到底要不要做 | [01 产品需求基线](client/01-requirements.md) | — |
|
||||||
|
| 打包成 exe、改文件路径 | [01 需求](client/01-requirements.md) §8.1 | [03 数据模型](client/03-data-model.md) §2 |
|
||||||
|
| 建工单、写归档 | [模板](templates/task.md) | 根目录 [AGENTS.md](../AGENTS.md) |
|
||||||
|
|
||||||
|
无论做哪一样,都必须先看一遍 [client/AGENTS.md](../client/AGENTS.md)(技术栈和红线)。
|
||||||
|
|
||||||
|
## Client 基线文档
|
||||||
|
|
||||||
|
| 文档 | 用途 |
|
||||||
|
|---|---|
|
||||||
|
| [01 产品需求基线](client/01-requirements.md) | 定义目标、范围、业务流程和验收边界 |
|
||||||
|
| [02 系统架构](client/02-architecture.md) | 定义模块、依赖、线程模型和故障恢复原则 |
|
||||||
|
| [03 数据模型](client/03-data-model.md) | 定义 SQLite 表、状态和 `pdd_data` JSON 结构 |
|
||||||
|
| [04 Admin 接口契约](client/04-admin-api-contract.md) | 定义 Client 与 Admin 的版本化接口边界 |
|
||||||
|
| [05 界面交互规范](client/05-ui-specification.md) | 定义 PDD 任务页、设置页和表格交互 |
|
||||||
|
| [06 质量、安全与测试](client/06-quality-security.md) | 定义测试策略、采购安全、日志和发布门禁 |
|
||||||
|
|
||||||
|
## 文档标注说明
|
||||||
|
|
||||||
|
基线文档中的条目按下面三档标注,没有标注的默认是 `[必须]`:
|
||||||
|
|
||||||
|
| 标注 | 含义 |
|
||||||
|
|---|---|
|
||||||
|
| `[必须]` | 不许改。要改先走工单,并经用户确认 |
|
||||||
|
| `[建议]` | 默认这么做;有更合适的做法可以换,但要在工单里说明原因 |
|
||||||
|
| `[待定]` | 还没定下来。文档会给一个临时默认值,先按临时值做,别停工 |
|
||||||
|
|
||||||
|
## 文档生命周期
|
||||||
|
|
||||||
|
- `docs/client` 只记录不随单个任务频繁变化的产品和技术基线。
|
||||||
|
- 日常需求、缺陷、进度、阻塞和方案变更以 Gitea 工单为事实来源。
|
||||||
|
- 单元任务完成后,按 `AGENTS.md` 归档到 `docs/task/<工单号>-<简短名称>.md`。
|
||||||
|
- 基线发生实质变化时,必须先更新对应 Gitea 工单并完成评审,再在同一任务中更新这里的相关文档。
|
||||||
|
|
||||||
|
## 文档和代码对不上怎么办
|
||||||
|
|
||||||
|
现在的代码还没做到文档描述的目标状态,**对不上是正常的**,已知差异列在 [02 架构](client/02-architecture.md) §3.1。
|
||||||
|
|
||||||
|
按下面处理,不要一发现不一致就停工:
|
||||||
|
|
||||||
|
| 情况 | 怎么办 |
|
||||||
|
|---|---|
|
||||||
|
| 差异已经列在 §3.1 差异清单里 | 按代码现状继续做,不用停 |
|
||||||
|
| 差异不在清单里,但只影响写法、不影响业务结果 | 按文档做,并在工单里记一句 |
|
||||||
|
| 差异会影响业务结果(金额、数量、状态、下单与否、数据结构) | **停下来**,在工单里说明,等用户确认哪边是对的 |
|
||||||
|
|
||||||
|
判断不了算不算"影响业务结果"时,按最后一行处理。
|
||||||
|
|
||||||
|
## 文档状态
|
||||||
|
|
||||||
|
当前 Client 文档为 **基线草案**。Admin 尚未完成,真实采购的最终下单开关、认证方式和部分字段仍需评审确认;这些事项已在对应文档中标注为 `[待定]`,并给出了临时默认值。
|
||||||
@@ -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`。
|
||||||
Vendored
+103
@@ -0,0 +1,103 @@
|
|||||||
|
# 工单与归档模板
|
||||||
|
|
||||||
|
复制下面的模板去用,不要每次从零写。字段说明见 [AGENTS.md](../../AGENTS.md)。
|
||||||
|
|
||||||
|
- **建单时**用模板 A,贴到 Gitea 工单正文里。
|
||||||
|
- **做完后**用模板 B,另存为 `docs/task/<工单号>-<简短名称>.md`。
|
||||||
|
|
||||||
|
模板里带 `(选填)` 的行,没有内容就整行删掉,不要留个空标题。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 模板 A:单元任务工单(建单时用)
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## 基本信息
|
||||||
|
|
||||||
|
- 类型:需求 / 缺陷 / 重构
|
||||||
|
- 父级大工单:#
|
||||||
|
- 所属 MVP / 版本:
|
||||||
|
- 阶段:
|
||||||
|
|
||||||
|
## 要解决什么
|
||||||
|
|
||||||
|
<!-- 现在是什么情况,为什么要改。缺陷要写清怎么复现。 -->
|
||||||
|
|
||||||
|
## 做什么 / 不做什么
|
||||||
|
|
||||||
|
- 做:
|
||||||
|
- 不做:
|
||||||
|
|
||||||
|
## 怎么做
|
||||||
|
|
||||||
|
<!-- 已经和用户确认过的方案。写清楚改哪几个文件、动不动数据库、动不动接口。 -->
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
<!-- 一条一条能打勾的。别写"功能正常"这种没法验证的。 -->
|
||||||
|
|
||||||
|
- [ ]
|
||||||
|
- [ ]
|
||||||
|
|
||||||
|
## 怎么验证
|
||||||
|
|
||||||
|
<!-- 具体到能复制粘贴的命令;需要真机的要写明。 -->
|
||||||
|
|
||||||
|
## 风险和回退(选填)
|
||||||
|
|
||||||
|
<!-- 可能出什么问题,出了怎么退回去。碰采购、数据库迁移、Admin 接口的必填。 -->
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 模板 B:完成归档(`docs/task/` 用)
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# <工单号> <标题>
|
||||||
|
|
||||||
|
- 类型:需求 / 缺陷 / 重构
|
||||||
|
- 父级大工单:#
|
||||||
|
- 所属 MVP / 版本:
|
||||||
|
- 状态:已完成
|
||||||
|
- 日期:YYYY-MM-DD
|
||||||
|
- Gitea 工单:<链接>
|
||||||
|
|
||||||
|
## 背景与目标
|
||||||
|
|
||||||
|
<!-- 原来什么问题,这次要达到什么。 -->
|
||||||
|
|
||||||
|
## 最终方案
|
||||||
|
|
||||||
|
<!-- 实际怎么做的。和建单时的方案有出入就写清楚差在哪、为什么改。 -->
|
||||||
|
|
||||||
|
## 改了哪些
|
||||||
|
|
||||||
|
<!-- 主要文件清单 + 一句话说明各改了什么。 -->
|
||||||
|
|
||||||
|
## 验收结果
|
||||||
|
|
||||||
|
| 验收标准 | 结果 |
|
||||||
|
|---|---|
|
||||||
|
| | 通过 / 未通过 |
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
- 执行的命令:
|
||||||
|
- 结果:
|
||||||
|
- **没验证到的部分**:<!-- 必填。没有就写"无"。真机行为、迁移、并发这类经常漏,漏了要写出来。 -->
|
||||||
|
|
||||||
|
## 遗留问题(选填)
|
||||||
|
|
||||||
|
## 相关提交
|
||||||
|
|
||||||
|
- `<提交哈希>` <提交说明>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 写工单的几个提醒
|
||||||
|
|
||||||
|
1. **验收标准要能打勾。** "表格显示正常"没法验证;"1000 条数据下滚动到底能继续加载,且当前选中行不丢"可以验证。
|
||||||
|
2. **"没验证到的部分"不许留空。** 真机没跑就写真机没跑。瞒下来的风险最后都会变成事故。
|
||||||
|
3. **不确定的东西写进工单,不要只写在代码注释里。** 聊天记录和注释都不算数。
|
||||||
|
4. **一个工单只干一件事。** 顺手改的无关内容单独开工单,别混进来。
|
||||||
Reference in New Issue
Block a user