commit 0b645ee8b3d7c2fa32f25c28cdff2fc07eef432d Author: chengma Date: Thu Aug 6 11:44:39 2026 +0800 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..47c63f2 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..f9019d4 --- /dev/null +++ b/AGENTS.md @@ -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 专用技术栈、线程、自动化安全、界面和验证规则不在根文件重复维护。 diff --git a/client/AGENTS.md b/client/AGENTS.md new file mode 100644 index 0000000..8760441 --- /dev/null +++ b/client/AGENTS.md @@ -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 固件;真实设备测试必须单独标记并说明是否执行真实下单。 +- 交付时说明已运行的命令、结果、未验证的设备行为和新增依赖。 diff --git a/client/requirements.txt b/client/requirements.txt new file mode 100644 index 0000000..70f28da --- /dev/null +++ b/client/requirements.txt @@ -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 diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..b8362c0 --- /dev/null +++ b/docs/README.md @@ -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 尚未完成,真实采购的最终下单开关、认证方式和部分字段仍需评审确认;这些事项已在对应文档中标注为 `[待定]`,并给出了临时默认值。 diff --git a/docs/client/00-getting-started.md b/docs/client/00-getting-started.md new file mode 100644 index 0000000..01a2ee4 --- /dev/null +++ b/docs/client/00-getting-started.md @@ -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)(技术栈和红线)。 diff --git a/docs/client/00-glossary.md b/docs/client/00-glossary.md new file mode 100644 index 0000000..081bc67 --- /dev/null +++ b/docs/client/00-glossary.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)。 diff --git a/docs/client/01-requirements.md b/docs/client/01-requirements.md new file mode 100644 index 0000000..7e416e4 --- /dev/null +++ b/docs/client/01-requirements.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。 diff --git a/docs/client/02-architecture.md b/docs/client/02-architecture.md new file mode 100644 index 0000000..ed6f8fb --- /dev/null +++ b/docs/client/02-architecture.md @@ -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) diff --git a/docs/client/03-data-model.md b/docs/client/03-data-model.md new file mode 100644 index 0000000..f5ec142 --- /dev/null +++ b/docs/client/03-data-model.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 +::result-v1 # 提交成功结果 +::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。 diff --git a/docs/client/04-admin-api-contract.md b/docs/client/04-admin-api-contract.md new file mode 100644 index 0000000..300ad51 --- /dev/null +++ b/docs/client/04-admin-api-contract.md @@ -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 +X-Client-Id: client-001 +X-Request-Id: +Content-Type: application/json +``` + +结果和失败提交额外携带: + +```http +Idempotency-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 从设置读;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。 + +改动这张表里任何一条,都属于会影响业务结果的变更,必须先更新工单并经用户确认。 diff --git a/docs/client/05-ui-specification.md b/docs/client/05-ui-specification.md new file mode 100644 index 0000000..06b87e6 --- /dev/null +++ b/docs/client/05-ui-specification.md @@ -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% 缩放。 diff --git a/docs/client/06-quality-security.md b/docs/client/06-quality-security.md new file mode 100644 index 0000000..6c46d41 --- /dev/null +++ b/docs/client/06-quality-security.md @@ -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/// +├── 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`。 diff --git a/docs/templates/task.md b/docs/templates/task.md new file mode 100644 index 0000000..39b02d1 --- /dev/null +++ b/docs/templates/task.md @@ -0,0 +1,103 @@ +# 工单与归档模板 + +复制下面的模板去用,不要每次从零写。字段说明见 [AGENTS.md](../../AGENTS.md)。 + +- **建单时**用模板 A,贴到 Gitea 工单正文里。 +- **做完后**用模板 B,另存为 `docs/task/<工单号>-<简短名称>.md`。 + +模板里带 `(选填)` 的行,没有内容就整行删掉,不要留个空标题。 + +--- + +## 模板 A:单元任务工单(建单时用) + +```markdown +## 基本信息 + +- 类型:需求 / 缺陷 / 重构 +- 父级大工单:# +- 所属 MVP / 版本: +- 阶段: + +## 要解决什么 + + + +## 做什么 / 不做什么 + +- 做: +- 不做: + +## 怎么做 + + + +## 验收标准 + + + +- [ ] +- [ ] + +## 怎么验证 + + + +## 风险和回退(选填) + + +``` + +--- + +## 模板 B:完成归档(`docs/task/` 用) + +```markdown +# <工单号> <标题> + +- 类型:需求 / 缺陷 / 重构 +- 父级大工单:# +- 所属 MVP / 版本: +- 状态:已完成 +- 日期:YYYY-MM-DD +- Gitea 工单:<链接> + +## 背景与目标 + + + +## 最终方案 + + + +## 改了哪些 + + + +## 验收结果 + +| 验收标准 | 结果 | +|---|---| +| | 通过 / 未通过 | + +## 测试 + +- 执行的命令: +- 结果: +- **没验证到的部分**: + +## 遗留问题(选填) + +## 相关提交 + +- `<提交哈希>` <提交说明> +``` + +--- + +## 写工单的几个提醒 + +1. **验收标准要能打勾。** "表格显示正常"没法验证;"1000 条数据下滚动到底能继续加载,且当前选中行不丢"可以验证。 +2. **"没验证到的部分"不许留空。** 真机没跑就写真机没跑。瞒下来的风险最后都会变成事故。 +3. **不确定的东西写进工单,不要只写在代码注释里。** 聊天记录和注释都不算数。 +4. **一个工单只干一件事。** 顺手改的无关内容单独开工单,别混进来。