From ec07e5e10e78175288a38bfe6089e7666ccd1965 Mon Sep 17 00:00:00 2001 From: Rogee Date: Wed, 9 Sep 2026 10:00:43 +0800 Subject: [PATCH] feat: align account workspace with plan01 --- AGENTS.md | 20 +- docs/2026-09-06-11-05-PLAN.md | 65 +- docs/2026-09-07-14-29-plannotator-config.md | 47 + .../2026-09-08-10-26-pi-lens-ndjson-logger.md | 36 + docs/2026-09-08-15-37-plan01-detail.md | 44 + docs/2026-09-08-15-56-plan01-review.md | 230 +++ ...6-09-08-16-02-plan01-review-integration.md | 79 + docs/2026-09-08-16-40-unknown-readonly.md | 33 + ...26-09-08-16-50-no-worker-event-handling.md | 61 + ...-09-08-17-39-plan01-refactor-validation.md | 50 + ...2026-09-08-21-05-windows-remote-install.md | 37 + ...26-09-08-22-09-plan01-ui-implementation.md | 41 + docs/2026-09-09-09-51-account-tree-ui.md | 28 + docs/plan01.md | 682 +++++++ src/account_engine.py | 483 ++++- src/account_store.py | 332 +++- src/accounts_app.py | 1665 ++++++++++++----- src/test_accounts.py | 3 - src/test_activity.py | 22 +- src/test_onboarding_ui.py | 130 +- src/test_plan01.py | 134 ++ 21 files changed, 3547 insertions(+), 675 deletions(-) create mode 100644 docs/2026-09-07-14-29-plannotator-config.md create mode 100644 docs/2026-09-08-10-26-pi-lens-ndjson-logger.md create mode 100644 docs/2026-09-08-15-37-plan01-detail.md create mode 100644 docs/2026-09-08-15-56-plan01-review.md create mode 100644 docs/2026-09-08-16-02-plan01-review-integration.md create mode 100644 docs/2026-09-08-16-40-unknown-readonly.md create mode 100644 docs/2026-09-08-16-50-no-worker-event-handling.md create mode 100644 docs/2026-09-08-17-39-plan01-refactor-validation.md create mode 100644 docs/2026-09-08-21-05-windows-remote-install.md create mode 100644 docs/2026-09-08-22-09-plan01-ui-implementation.md create mode 100644 docs/2026-09-09-09-51-account-tree-ui.md create mode 100644 docs/plan01.md create mode 100644 src/test_plan01.py diff --git a/AGENTS.md b/AGENTS.md index 9e08c14..c5eac46 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,17 +1,35 @@ +# 项目开发与操作约束 + 这是一个抖音PC页面相关数据监听和操作的项目。不考虑工程化,当前阶段快速实现初期功能的开发 我已经在浏览器登录了账号,且CDP 可以访问 localhost:9222 直接操作定位 如果登录状态丢失不要自己尝试登录,而是停止任务让我来登录账号。 ## 工作方式 -- 详细保存相关调研日志和最终方案到docs目录下,文件名格式 YYYY-MM-DD-HH-II-.md + +- 详细保存相关调研日志和最终方案到docs目录下,文件名格式 `YYYY-MM-DD-HH-II-.md` - 要求相关操作可复用,不绑定环境、账号等其它因素,如需要在空白环境安装相关包,给出安装说明书 - 页面操作只用于触发相关请求调研,不作为下述产出目标方案 ## 产出目标 + - 在src中输出自动化处理的python脚本,要求每个功能的脚本可独立运行 - 能用的功能、函数可使用公共包来实现,不需要独立实现 +## 多账号实现与验收约束 + +- 后续 UI、生命周期和任务安全设计以 `docs/plan01.md` 为准;评审合并记录见 `docs/2026-09-08-16-02-plan01-review-integration.md`;其中人工核定相关建议已由 `docs/2026-09-08-16-40-unknown-readonly.md` 撤回。文档中的新约束是待实现/待验收目标,不得据此宣称功能完成;不一致的早期计划只作追溯。 +- 必须在任何 worker 启动前完成数据库迁移和中断恢复:遗留 running 转 unknown;恢复上次选择不启动业务。大号本次身份及监听能力就绪后,所属小号才可领取;停止后的迟到回调不得重新放行。 +- 停止优先关闭调度/领取入口,不得排在历史/作品同步或登录等待之后;按方案的有限总预算收尾,重复停止幂等。不确定的在途结果保持 unknown,不把取消本地等待当作远端回滚。 +- 任务不可跨组;单通知只分配一个小号,同一小号串行处理通知批次,批内关注/私信独立并行、分别保存结果。动作快照不改写,但规则总开关及当前安全限制必须在领取/发送前重查;旧 pending 恢复需可见确认,取消仅作用于确认时仍为 pending 的任务。 +- unknown 仅为结果不确定的只读记录,按持久化 task ID 在已删除/已转移账号之外仍可查询;不提供人工核定、成功/失败结论提交或人工改状态操作,不要求用户核实结果。移除人工操作不解除已有去重/占用保护,不自动判定、重发、重置 pending 或改派。软删除后同 UID 只能显式恢复原身份记录,不清空 UID、去重、冷却或原任务来源来绕过限制。 +- 领取状态未成功落库不得发送关注/私信;动作后的结果落库失败必须停止继续领取,不自动重放。普通文件日志可以降级,SQLite 任务账本失败不得忽略,也不得为清日志删除任务安全记录。 +- 删除登录目录/小号前必须停止收尾、确认范围并持久化删除意图;只清理属于该账号的受管路径,禁止符号链接/junction 越界。目录与数据库部分成功时保留禁止启动状态,仅显式重试,不自动登录或恢复规则。 +- 实时缓存非空不等于完成历史全量;同步基线、游标和完整标记必须与数据落库一致,失败/截断不得称为全部。历史入队须二次确认且校验规则/作品配置基线;相同通知由数据库去重。 +- 异步 UI 回包按 account ID/request ID 隔离,选中项、启动勾选和未保存草稿分开;状态刷新不覆盖草稿,列表先按账号/原组过滤再分页。旧任务缺失的时间显示未知,不伪造阶段时间。 +- 实现变更先用现有测试、临时 SQLite、假 session/执行器覆盖启停竞态、分页、迁移、unknown 只读查询和存储故障;验收矩阵见 `docs/plan01.md`。不以真实关注、私信或标记已读作为回归测试,不新增无必要的调度/状态机框架。 + ## Windows 远程部署与连接 + - 优先使用项目级 `.pi/mcp.json` 中的 `windows-ui` MCP 操作 Windows 主机;该连接用于启动、停止、查看和人工验收 Windows 应用。 - Windows MCP 不满足需求或批量命令明显不便时,改用 SSH 执行文件传输、构建、安装和日志检查;连接地址、账号和密钥只从本机 SSH 配置读取,不写入代码、安装包或文档。 - Windows 原生构建必须在 Windows x64 主机执行 `build_windows.py`,不要把 Linux 构建产物伪装成 Windows 安装包。 diff --git a/docs/2026-09-06-11-05-PLAN.md b/docs/2026-09-06-11-05-PLAN.md index 5cffd2e..3615de7 100644 --- a/docs/2026-09-06-11-05-PLAN.md +++ b/docs/2026-09-06-11-05-PLAN.md @@ -1,6 +1,6 @@ # PLAN:Windows 多账号通知监听与任务执行 -本文整合已确认产品需求、推荐技术方案、UX 与实施计划,作为后续开发入口。本文是计划,不代表功能已经实现或完成 Windows 验证。原调研文档保留供追溯;如存在冲突,以本文中的最新已确认需求为准。 +本文保留多账号方案的基础产品需求、技术选型与安装说明,作为历史开发入口,不代表功能已经实现或完成 Windows 验证。2026-09-08 评审后的 UI、生命周期、规则和任务安全细则以 [plan01.md](plan01.md) 及项目 `AGENTS.md` 为准;下文已同步消除恢复顺序、串并行和“待确认”事项的冲突。实施前优先阅读 plan01 的第零阶段及验收矩阵,历史记录不作为当前功能验收证明。 ## 1. 已确认需求 @@ -46,7 +46,7 @@ | 日志 | logging | 错误与操作记录,敏感字段脱敏 | | Windows 分发 | PyInstaller | Windows 原生构建,初期使用 onedir | -初期单进程调度,各账号独立异步 worker,同一小号串行执行、不同小号可并发。不引入 Redis、Celery、微服务、Docker、Electron,也不先做 Windows Service。 +初期单进程调度,各账号独立异步 worker,同一小号串行处理通知批次;同一批次内关注/私信独立并行、分别落库,不同小号可并发。不引入 Redis、Celery、微服务、Docker、Electron,也不先做 Windows Service。 ### 2.2 业务执行方式 @@ -109,7 +109,7 @@ - 大号头像、昵称及账号标识。 - 登录状态、监听状态。 - 关联小号列表、可用数量、各小号执行状态。 -- 待执行、成功、失败、结果待核对的任务数量与记录。 +- 待执行、成功、失败、结果不确定(unknown)的任务数量与只读记录。 操作建议: @@ -121,22 +121,22 @@ 登录、连接、监听/执行状态分别展示,不能把“已登录”等同于“正在监听”或“可执行”。账号数量不做性能推荐。 -### 4.2 小号归属切换(任务处置方案待确认) +### 4.2 小号归属切换(后续方案已明确) -建议流程: +以 `plan01.md` §5.4 为准,操作流程: 1. 选择“切换所属大号”,选定目标大号。 2. 展示原归属、新归属、待执行数量与执行中状态。 -3. 若有正在执行的任务,暂不允许切换,等任务结束后再操作;不强制中断已经发出的请求。 -4. 确认后暂停该小号接收和领取任务,在同一事务中取消旧归属待执行任务并更新归属。 +3. 先停止原所属组并等待收尾;有 running 或处于启停/删除中时拒绝切换,不强制回滚已经发出的请求。 +4. 确认后在同一事务重查状态,取消旧归属 pending 并更新归属;未分配小号不需要停止原组。 5. 保留历史及取消原因,不把旧任务带入新组,也不自动改派其它小号。 -6. 切换后仅接收新归属大号的任务。 +6. 转移后不自动启动;用户显式启动且核验通过后,仅接收新归属大号的任务。 确认提示示例: > 将小号 X 从大号 A 切换至大号 B。原有 5 条待执行任务将取消,历史记录保留。是否继续? -后端必须保证切换与任务领取/执行互斥,并校验任务来源与当前归属,不能只在 UI 过滤。执行结果未知的任务保留待核对状态,不因切换而重发。 +后端必须保证切换与任务领取/执行互斥,并校验任务来源与当前归属,不能只在 UI 过滤。执行结果未知的任务保留只读 unknown 状态,不提供人工核定,不因切换而重发。 “解除关联”与“删除登录数据”分开操作;删除登录数据须单独确认。 @@ -147,7 +147,7 @@ → 事件落库、去重 → 按规则生成任务 → 仅派发给所属小号 - → 每个小号串行执行 + → 每个小号串行处理通知批次,批内关注/私信独立并行 → 核验结果、保存状态 ``` @@ -173,22 +173,23 @@ 建议任务状态:pending、running、succeeded、failed、unknown、cancelled。 -- 请求超时不等于执行失败;关注需回查,私信需根据消息标识或人工核对。 -- 结果不确定时记录 unknown,禁止无条件重发。 -- 重启遗留 running 任务先核对,不直接重置为 pending。 -- 若规则要求先关注再私信,应显式记录步骤与成功条件,不仅依赖队列顺序。 +- 请求超时不等于执行失败;只按已有可验证响应、消息标识等证据记录确定结果,无法确认时保留 unknown,不要求用户人工核定。 +- unknown 只读展示,不提供成功/失败判定或人工改状态操作;不自动重发,也不因移除人工操作而解除既有去重/占用保护。 +- 重启时在任何 worker 创建前把遗留 running 转 unknown,只读保留结果,不直接重置为 pending。 +- 当前关注/私信独立并行,一个失败不取消另一个,不引入“关注成功后才能私信”的依赖;若后续业务改变,须另行确认并更新方案与测试。 - 页面刷新或连接中断后重新连接并安装订阅;断线通知是否能补拉需验证,不承诺零丢失。 - 登录失效、验证码或风控暂停相关账号并提示用户,不自动登录或绕过验证。 ## 6. 一键启动与 Windows 生命周期 ```text -读取所选账号组与归属 - → 复用或启动独立 Chrome 实例 - → 连接对应 CDP - → 核验实际 UID、登录状态 - ├─ 有效:恢复监听/执行能力并核对遗留任务 - └─ 失效:标记需要登录,等待用户处理 +进程启动,在任何 worker 创建前完成迁移与遗留 running → unknown + → 保持停止;恢复上次选择只恢复勾选 + → 用户显式启动并确认旧 pending 与规则快照 + → 复用或启动独立 Chrome 实例、连接对应 CDP + → 核验实际 UID、登录状态及大号监听就绪 + ├─ 有效:逐个核验并放行所属小号,逐账号显示结果 + └─ 失效:暂停相应账号/组,等待用户手动处理 ``` - 某个账号失败不得把任务错发给其它组;界面分别展示各账号恢复结果。 @@ -200,7 +201,7 @@ ## 7. 实施顺序 -以下均为待开展工作,不表示已有功能验收通过。 +以下是早期实施拆分,不表示已有功能验收通过。当前先完成 `plan01.md` §11 第零阶段的恢复屏障、停止控制、账本故障、删除后 unknown 只读查询及最小迁移,再按其 UI 分阶段和 §12 验收矩阵实施。 ### 阶段一:账号隔离与恢复 @@ -214,12 +215,12 @@ - [ ] 支持多个大号同时订阅通知。 - [ ] 实现事件持久化、去重和可恢复任务生成。 -- [ ] 实现按归属派发、每小号串行执行和结果回查。 +- [ ] 实现按归属派发、每小号串行批次及批内独立并行动作、结果回查。 - [ ] 实现 unknown 处理、断线重连和刷新后重新订阅。 ### 阶段三:桌面 UX 与归属切换 -- [ ] 确认任务分配与切换处置规则后实现对应交互。 +- [ ] 按 plan01 已明确的任务分配、切换、删除后恢复及 unknown 只读约束实现交互,不提供人工核定操作。 - [ ] 实现账号组、未分配小号、所选组一键启动和状态展示。 - [ ] 实现安全的归属切换、解除关联和登录数据删除确认。 - [ ] 确保 UI 不阻塞监听调度;Qt 主线程处理界面,异步核心可运行在独立线程并通过信号通信。 @@ -237,7 +238,7 @@ - 每个大号的任务只进入其关联小号;大号及其它组小号不可执行。 - 一个小号最多有一个归属,未分配小号不执行任务。 - 切换归属保留登录环境;切换后旧组任务不能继续被新领取执行。 -- 同一小号没有并行写操作,重复事件不会重复派发同一任务。 +- 同一小号不并行处理多个通知批次;同批关注/私信允许独立并行,重复事件不重复派发同一任务。 - 关机后可一键恢复所选环境;有效登录可复用,失效登录明确提示并暂停。 - 身份不匹配时停止操作,不通过随意选择其它标签页继续执行。 - 超时及重启不会造成私信盲目重发,历史任务来源可追溯。 @@ -245,17 +246,17 @@ - 默认可启动官方 Chrome;配置指定可执行路径与附加参数后,启动器准确使用配置,业务代码无需更改;无效路径和隔离参数冲突明确报错。 - 配置重启后保留,运行中不热切换内核、不静默回退;第三方内核兼容性单独验收。 -## 9. 开发前仍需确认的业务规则 +## 9. 已确认规则与后续约束入口 -以下不能从“一个大号关联多个小号”直接推断,不在本次合并中擅自定案: +原待确认问题已在后续方案中明确,不再阻塞确认或回退为默认未定: -1. 一条通知由一个小号执行,还是派发给多个关联小号?若只选一个,采用什么选择规则? -2. 哪些通知触发关注、私信,目标如何确定,私信内容是什么,动作是否要求顺序? -3. 是否采用第 4.2 节建议:切换时取消旧待执行任务,执行中阻止切换? -4. 小号不可用时任务等待、取消还是允许同组改派?大号失效时已排队任务是否继续? -5. “停止”只暂停监听/执行还是同时关闭 Chrome,以及下次启动恢复哪些账号组? +1. 单通知仅分配一个同组可用小号,沿用组内轮询;无可用小号等待,已分配任务不自动改派。 +2. 通知类型、动作、消息和作品范围由显式启用的规则决定;关注/私信独立并行。内容及作品范围使用任务快照,总开关和当前安全限制即时约束领取。 +3. 转移前停止原组,无 running;事务取消旧 pending、保留历史来源和 unknown,转移后不自动启动。 +4. 大号失效暂停整组,小号失效暂停自身并保留原任务;unknown 不重发,账号删除后仍可只读查询,不提供人工核定。软删除同 UID 采用显式恢复原记录。 +5. 停止保留 Chrome 和登录目录,优先禁止领取并有限收尾;恢复选择不自动启动,旧 pending 需可见确认。浏览器关闭、登录数据清理与小号删除是独立确认操作。 -这些待确认项不影响先开发独立浏览器环境与账号归属存储,但应在实现相关执行策略前确认。 +完整状态表、操作矩阵、规则/同步/字段契约、故障处理和验收要求见 `plan01.md` §3.4、§5.4、§6.4~6.5、§8~12;持久化失败不得继续发送,旧文档不能覆盖这些约束。 ## 10. 空白 Windows 开发环境安装 diff --git a/docs/2026-09-07-14-29-plannotator-config.md b/docs/2026-09-07-14-29-plannotator-config.md new file mode 100644 index 0000000..c5481ec --- /dev/null +++ b/docs/2026-09-07-14-29-plannotator-config.md @@ -0,0 +1,47 @@ +# Plannotator 本机配置 + +## 调研记录 + +- 已安装 `@plannotator/pi-extension` 0.27.12,Pi 0.85.1 满足插件 README 的 Pi >= 0.79.1 要求。 +- 全局 `/home/rogee/.pi/agent/settings.json` 的 `packages` 已包含 `npm:@plannotator/pi-extension`,无需重复安装或修改安装登记。 +- 配置前全局和当前项目均无 `plannotator.json`。 +- 依据已安装插件的 README.md 和内置 plannotator.json 配置;未修改 npm 包文件。 +- PATH 中没有独立 `plannotator` 命令;本次使用 Pi 扩展提供的斜杠命令,不安装独立 CLI。 + +## 最终配置 + +全局用户配置:`~/.pi/agent/plannotator.json`,本机绝对路径 `/home/rogee/.pi/agent/plannotator.json`。 + +```json +{ + "executionMode": "automatic", + "defaults": { + "model": null, + "thinking": null + }, + "phases": { + "planning": { + "activeTools": ["read", "ffgrep", "fffind", "plannotator_submit_plan"], + "statusLabel": "⏸ 计划审批" + } + } +} +``` + +- 审批通过后在当前 Pi 会话执行,不对接外部执行器。 +- 不切换模型或思考等级;继承插件内置规划与执行指令。 +- 规划阶段额外启用当前环境实际提供的搜索工具,替换内置 grep/find/ls 列表;该列表不是工具安全白名单,插件保留会话已有工具。 +- 配置优先级:包内默认 < 全局 `~/.pi/agent/plannotator.json` < 受信任项目的 `.pi/plannotator.json`。 + +## 使用及迁移 + +1. 新环境安装 Pi >= 0.79.1,然后运行 `pi install npm:@plannotator/pi-extension`。 +2. 本配置搜索工具来自 `pi install npm:@ff-labs/pi-fff`(本机已经安装)。不使用该包时,删除 `activeTools` 字段即可继承插件默认工具配置。 +3. 将上述 JSON 保存到新环境的 `~/.pi/agent/plannotator.json`,不需要绑定账号、模型或本机路径。 +4. 重启 Pi 会话后输入 `/plannotator-plan-mode` 或按 `Ctrl+Alt+P` 进入规划;也可以 `pi --plan` 启动。 +5. 告诉助手需求,助手提交计划后才会打开浏览器审批。 +6. `/plannotator-review` 审查代码变更;`/plannotator-annotate ` 批注文档;`/plannotator-last` 批注最后一条回复。 + +## 验证边界 + +已核验安装登记、版本、配置 JSON 语法及关键字段。未启动交互式计划审批,也未验证浏览器审批端到端流程;需要重启后实际提交一次计划确认。 diff --git a/docs/2026-09-08-10-26-pi-lens-ndjson-logger.md b/docs/2026-09-08-10-26-pi-lens-ndjson-logger.md new file mode 100644 index 0000000..4fd695a --- /dev/null +++ b/docs/2026-09-08-10-26-pi-lens-ndjson-logger.md @@ -0,0 +1,36 @@ +# pi-lens NDJSON logger 加载报错修复 + +## 现象 + +pi 加载 pi-lens 时出现: + +```text +createNdjsonLogger: incompatible options for shared path /home/rogee/.pi-lens/sessionstart.log +``` + +## 原因 + +pi-lens 4.1.3 的旧模块图创建的 `sessionstart.log` writer 没有 `maxBytes/backupPath`;升级到 4.1.4 后,新模块图复用同一个 `Symbol.for` 全局 writer 状态,但兼容性检查直接抛错,导致扩展加载失败。该问题发生在同一 Node 进程内重载/升级扩展时,日志文件本身不是根因。 + +## 修复 + +修改以下已安装文件中的 `assertCompatibleWriterOptions`: + +- `/home/rogee/.pi/agent/npm/node_modules/pi-lens/dist/index.js` +- `/home/rogee/.pi/agent/npm/node_modules/pi-lens/dist/clients/ndjson-logger.js` + +当旧 writer 同时缺少 `maxBytes` 和 `backupPath` 时,升级时就地采用当前 writer 的边界配置;已有明确配置但不一致时仍保留原异常,避免掩盖真实配置冲突。 + +同时重新生成了 pi 使用的 jiti 缓存: + +```text +/tmp/jiti/dist-index.417a0980.mjs +``` + +## 验证 + +- NDJSON writer 升级兼容场景通过;真实配置冲突仍抛错。 +- 两个 JS 文件 `node --check` 通过。 +- `warm-loader-cache.mjs` 执行成功。 + +修改安装目录中的文件会在重新安装/升级 pi-lens 时被覆盖;后续若升级包,应确认上游版本已包含同等修复。 diff --git a/docs/2026-09-08-15-37-plan01-detail.md b/docs/2026-09-08-15-37-plan01-detail.md new file mode 100644 index 0000000..4f89f44 --- /dev/null +++ b/docs/2026-09-08-15-37-plan01-detail.md @@ -0,0 +1,44 @@ +# plan01 UI 与调用逻辑细化记录 + +## 调研来源 + +本次根据以下仓库文档和当前实现整理 `docs/plan01.md`: + +- `docs/2026-09-06-11-05-PLAN.md`:多账号、归属、持久化、任务状态与 Windows 生命周期。 +- `docs/2026-09-06-11-24-implementation-decisions.md`:规则默认关闭、单通知单小号、历史任务优先级、停止与切换语义。 +- `docs/2026-09-07-11-32-history-events-and-work-monitoring.md`:历史事件预览、作品监控、二次确认和作品范围过滤。 +- `docs/2026-09-07-14-49-persistent-history-work-cache.md`:作品/事件 SQLite 缓存、增量刷新、UI 展示字段。 +- `docs/2026-09-07-00-01-group-uid-cooldown-and-parallel-actions.md`:组共享 UID 冷却、关注与私信并行。 +- `docs/2026-09-07-17-04-works-card-cache.md`、`docs/2026-09-07-18-27-card-layout-no-restore-bubble.md`、`docs/2026-09-07-19-15-card-interaction-events.md`:作品 CARD、封面缓存和互动字段布局。 +- `docs/2026-09-06-20-09-daily-observability-and-worker-deletion.md`:按日日志、最近 2000 条 UI 日志和小号删除语义。 +- `src/accounts_app.py`、`src/account_store.py`、`src/account_engine.py`:当前 UI、存储和调度入口。 + +## 本次整理结果 + +- 将原始六条草案扩展为目标布局、账号树、状态点、顶部工具栏、角色化右键菜单、右侧详情、作品 CARD、历史事件和日志区域。 +- 补充实时事件、历史事件、作品同步、启动、停止、关闭及删除的端到端调用链。 +- 明确登录状态、身份核验、运行状态分开显示。 +- 统一写入已确认边界:不自动登录、身份不匹配不写、unknown 不重发、历史任务需二次确认、任务不得跨组。 +- 补充实施拆分和验收清单,便于后续按 UI、存储和引擎边界实施。 + +## 交互入口修正 + +用户确认:大号的历史事件操作和作品监控已经在选中大号后的右侧详情区实现,右键菜单不再重复提供这两个入口;小号执行记录同样通过选中小号后的详情区查看。右键菜单仅保留生命周期、归属和配置类操作。 + +进一步明确:账号组由大号节点或顶部全局操作统一启停,小号右键不提供“启动/停止所属组”,避免误解为可以单独启动或停止小号。 + +## 2026-09-08 评审补充与实施入口 + +用户确认将评审和建议操作合入文档。后续目标以 [plan01.md](plan01.md) 为准;[评审记录](2026-09-08-15-56-plan01-review.md) 保留当时的问题依据,[合并记录](2026-09-08-16-02-plan01-review-integration.md) 记录采纳决策、章节映射和验证范围。 + +已补充:启动前恢复与就绪屏障、停止优先/有限收尾、删除后 unknown 只读查询及同 UID 恢复、旧 pending 确认/取消、历史完整基线、规则生效与确认基线、异步回包/草稿、任务时间与迁移、账本失败和删除部分成功。`AGENTS.md` 同步为实现约束,基础 PLAN 的冲突顺序和待确认项已校正。 + +用户随后明确不需要待核任务的人工核定;相关按钮、结论提交、核定字段及流程要求已撤回,unknown 仅只读保留且仍不自动重发,见 [最新变更](2026-09-08-16-40-unknown-readonly.md)。旧 pending 和历史入队的业务确认不受此调整影响。 + +实施先做 plan01 的第零阶段,再接 UI;所有新增验收项保持未完成,合并文档不意味着相关命令、字段或 Windows 行为已经实现。 + +## 未在本次修改中执行 + +- 未修改 `src` 代码。 +- 未启动浏览器、未使用 CDP、未执行关注、私信或标记已读。 +- 未进行 Windows 安装或 GUI 人工验收。 diff --git a/docs/2026-09-08-15-56-plan01-review.md b/docs/2026-09-08-15-56-plan01-review.md new file mode 100644 index 0000000..5590c2f --- /dev/null +++ b/docs/2026-09-08-15-56-plan01-review.md @@ -0,0 +1,230 @@ +# plan01 评审:遗漏场景与最小补充设计 + +> 2026-09-08 后续:用户已要求将建议合入文档,规范性内容现见 [plan01.md](plan01.md) 与项目 `AGENTS.md`,采纳决策及章节映射见 [合并记录](2026-09-08-16-02-plan01-review-integration.md)。下文保留评审当时的发现与建议,不再作为未处理文档缺口清单;文档补齐不代表源码实现或验收完成。 +> +> **最新决定覆盖**:用户明确待核任务不需要人工核定。下文关于人工核对入口、已成功/未成功按钮、核定结论/时间字段、核定后状态更新和相应验收的建议全部撤回,仅保留历史追溯意义。当前要求是 unknown 只读展示、不自动重发,见 [只读调整记录](2026-09-08-16-40-unknown-readonly.md)。 + +## 评审范围与结论 + +- 目标:`docs/plan01.md`,并对照总体计划、实施细化记录及 `account_engine.py`、`account_store.py`、`account_session.py`、`accounts_app.py`。 +- 本次为静态评审:没有修改原方案或源码,没有运行测试、连接浏览器、访问 Windows 主机或执行关注/私信。 +- 原方案已覆盖账号归属、UID 锁定、首次登录人工完成、历史只读缓存、事件去重、规则快照、批次内关注/私信并行、unknown 不自动重发等核心约束。以下不重复这些已有设计。 +- 结论:布局已经足够开始实现,但需要先补齐生命周期、控制命令、删除后的身份/任务处理和数据契约。问题分为“文档顺序错误”“现有实现需要适配”“尚未约定的产品行为”,不能全部称为现有程序 bug。 + +优先级:P1 表示可能影响真实写操作的安全控制或人工核对闭环;P2 表示功能闭环、数据完整性或 UI 一致性需要明确。 + +## 1. [P1] 启动流程的恢复顺序写反了 + +**位置**:原方案 §8.2;`Engine.__init__`、`Engine.start`、`Engine.worker_loop`、`Store.recover`。 + +**问题性质:文档顺序错误,而非当前初始化实现错误。** + +§8.2 把“上次未完成的 running 标记为 unknown”放在启动大号监听和小号 worker 之后。若照这个顺序实现,worker 可能在恢复完成前领取任务;随后恢复操作还可能把本轮刚领取的任务误判为中断任务。 + +现有 `Engine.__init__` 已经先调用 `store.recover()`,且 `worker_loop` 会等待大号就绪。应保留这些安全边界,不能在 UI 重构时按文档顺序后移恢复。 + +**最小补充**: + +1. 进程启动、任何 worker 存在之前,完成中断恢复并展示待核对数量;这不是每次点击启动都要执行的操作。 +2. 手动启动后,先校验大号当前身份及监听能力,确认就绪后再允许本组 worker 领取任务。 +3. 区分“启动请求已受理”和“组已就绪”;不能用命令返回或缓存中的已绑定 UID 表示本次在线验证成功。 +4. 明确部分小号启动失败时,只阻止该小号,不把整个组显示为全部成功;大号失败则整个组不领取新任务。 + +**验收**:库中预置一个 running;重启后未点击启动时,它已经成为 unknown,且没有新网络写操作;大号身份验证失败时,小号始终不能领取任务。 + +## 2. [P1] 长时间同步会阻塞停止命令,需要独立的控制路径 + +**位置**:原方案 §8.3、§8.4、§8.6;`Backend._run`、`Engine.stop`、`Engine.fetch_history`。 + +**问题性质:现有命令处理方式与目标安全交互存在适配缺口。** + +后台目前从队列取出命令后逐个 `await engine.command(...)`。历史/作品全量同步耗时较长时,随后提交的停止、退出和其他查询会排队。Qt 窗口可能仍然能点击,但停止尚未进入引擎;已经运行的 worker 不会因为按钮被点击就自动停止领取任务。该循环中的后续快照更新也会被推迟。 + +此外,“等待在途任务完成”没有给出停止时限和超时后的行为。 + +**最小补充**: + +- 耗时只读同步作为可跟踪的后台任务执行,不占用整个命令消费循环;同一账号的会话操作仍然互斥,不能为了响应 UI 而并发操作同一个页面。 +- 停止请求优先设置禁止领取标志,再等待监听和在途动作结束;不能排在全量同步后面,也不能等所有收尾工作结束才关领取入口。 +- 状态至少可区分“停止中”和“已停止”;重复停止幂等,停止中禁止再次启动该账号。 +- 定义有限的收尾期限。只读同步可以取消;已发出的关注/私信不能把取消解释成没有执行,结果无法确认时必须进入 unknown,并保留核对信息。 +- UI 定时快照不依赖某个长命令完成。无需引入通用调度框架,现有 asyncio task 和账号级操作标志即可。 + +**验收**:模拟慢速历史同步,期间点击停止,禁止领取标志及时生效、UI 状态持续更新;模拟已发出但迟迟无确认的写请求,收尾后不能出现自动重发。 + +## 3. [P1] 删除小号后的 unknown(以下人工核定建议已撤回) + +**位置**:原方案 §4 小号删除、§5.2 小号任务详情、§8.6;`Store.delete_worker`、`Store.resolve`、`Store.tasks`。 + +**问题性质:删除后的操作闭环未明确。** + +原方案允许保留 unknown 后删除小号,而人工核对按钮主要设计在“小号详情”的任务表中。小号从账号树消失后,任务虽然仍在数据库里,用户却可能无法再次选中它并完成核对。转移归属也需要区分“当前归属”和“任务产生时的归属”。 + +**最小补充**: + +- 在大号摘要或全局区域保留一个“待核对任务”入口,复用任务表即可,必须包含已删除小号和原归属下的历史任务。 +- 列表显示原执行账号、原所属组、动作、目标、发生时间和核对状态;不能按账号当前归属重写历史事实。 +- 核对通过持久化 task ID 操作,不要求旧账号仍出现在账号树中,也不触发登录或自动补发。 +- 删除前明确展示未核对任务数量和删除后的查找入口。人工确认结果及确认时间应留痕。 + +**验收**:制造 unknown → 删除小号 → 重启 → 仍可找到并核对该任务;移动小号后,原组任务的归属及核对入口不改变。 + +## 4. [P2] 软删除与 UID 唯一约束之间缺少“重新添加”约定 + +**位置**:原方案 §4、§8.1、§8.6;accounts 的 UID 唯一约束及 `Store.bind_profile`。 + +**问题性质:现有数据约束要求补充产品行为。** + +小号删除采用软删除,旧记录及 UID 会保留;绑定身份又禁止重复 UID。因此,“删除小号后重新添加并登录同一个抖音账号”不是一个可以自然成功的新建流程。 + +**最小补充**: + +- 推荐识别到 UID 对应已删除记录时,提示用户显式恢复原记录,而不是新建第二个身份或清空原 UID。 +- 恢复后仍保持停止状态,按正常人工登录/身份验证流程处理;不恢复旧 pending、不重新发送旧任务、不清空原去重及核对记录。 +- 如果恢复到不同大号,显式确认归属,并沿用已有转移限制。 +- 另一种可选设计是明确不支持恢复,但必须在 UI 提前说明;不建议让用户删数据库绕过唯一约束。 + +**验收**:删除后重新登录同一 UID,出现明确的恢复/拒绝流程,而不是无法理解的重复 UID 错误或两条有效身份记录。 + +## 5. [P2] 暂停后旧 pending 怎么处理,需要明确给用户 + +**位置**:原方案 §8.2、§8.5、§8.6。 + +**问题性质:产品策略未明确,不等于现有保留队列行为错误。** + +停止保留 pending、现有任务保持规则快照,这两点本身合理,但组合起来意味着:数天后重启,即使已经改了私信内容或作品范围,仍可能执行旧规则下的任务。当前启动交互没有说明用户如何识别或取消这批积压任务。 + +**最小补充**: + +- 启动前展示将恢复的任务数、最早入队时间及“使用原规则快照”提示。 +- 提供明确的 pending 取消操作;取消不得触碰 running/unknown,也不得把同一任务重新分配给别的小号。 +- 区分“暂停执行”“取消待执行”“关闭新事件入队”三个概念,不能用一个启用复选框模糊表达。 +- 先采用可见的积压确认和取消即可;只有业务确实要求事件有效期时再增加 TTL,并定义过期状态及时间口径。 + +**验收**:积压旧消息任务 → 停止 → 修改消息规则 → 再启动,用户能看到旧快照提示并选择取消;没有静默替换旧任务内容。 + +## 6. [P2] 有历史缓存不等于已经完成历史全量同步 + +**位置**:原方案 §5.1 历史事件、§8.3/§8.4 历史与作品同步;`Engine.fetch_history`、`AccountSession.history_notices`、`AccountSession.works`。 + +**问题性质:同步完整性契约不足,当前增量依据需要检查和补齐。** + +历史缓存同时可能来自实时监听和主动历史同步。现有历史同步会把缓存 notice ID 作为增量停止依据,而分页方法遇到已知通知就可以停止。如果先缓存了一条实时通知,第一次主动历史同步就可能提前遇到它,不能据此宣称“全部历史已同步”。页面安全上限、异常中断同样不能当作完整成功。 + +作品列表也需要确认排序契约:如果接口可能返回置顶作品,遇到一个老作品不必然意味着后面没有新增作品。 + +**最小补充**: + +- 分开记录“有缓存”“首次全量已完成”“最近一次同步成功/失败/截断”;不能用缓存非空代替全量完成标志。 +- 未建立完整基线时,允许明确执行全量补齐,不因实时缓存中的单个已知 ID 提前结束。 +- 只有验证过连续性/排序假设后才使用增量提前停止;游标或基线只在对应数据成功落库后推进。 +- UI 展示实际同步范围、条数、最近成功时间和不完整原因。“全部”不能只是当前 API 可返回的一部分或最近 N 条缓存。 + +**验收**:先只缓存一条实时通知,再首次历史同步,能获取更早数据;中途失败/到达页数上限不会显示全量完成;作品置顶和重复页不会造成静默漏项。 + +## 7. [P2] “规则快照不变”还需要一张字段生效表 + +**位置**:原方案 §4 规则设置、§5.1 作品监控、§8.3/§8.5。 + +**问题性质:规则内容与运行控制的边界没有充分展开。** + +“现有任务沿用快照”不能直接回答:关闭规则是否立刻停止领取?降低配额是否影响已排队任务?修改作品范围是否取消已有任务?历史选择后、确认前规则被修改,应使用哪个版本? + +建议至少写清以下默认语义: + +| 类别 | 推荐生效边界 | +| --- | --- | +| 停止/暂停、身份失效、账号归属 | 立即影响后续领取;在途结果正常收尾或 unknown | +| 动作类型、私信内容、作品过滤 | 只影响新入队事件;已有任务保留快照 | +| 配额、间隔、风控暂停 | 执行前按明确的当前安全限制检查,不应因旧快照绕过收紧后的限制 | +| 取消旧任务 | 独立显式操作,不隐含在普通规则保存里 | +| 历史批量入队 | 使用确认时校验过的规则/作品范围;预览后发生变化则重新确认 | + +不一定要新增完整版本管理:UI 可携带规则的更新时间或内容摘要,让后端在提交时比较。相同 notice 的实时/历史并发到达仍应依靠数据库去重,而不是按钮防连点。 + +**验收**:任务排队期间分别修改动作内容、暂停开关和配额,行为符合上述不同边界;历史预览之后修改规则,不会在旧确认文案下静默执行新动作组合。 + +## 8. [P2] UI 需要明确异步回包、编辑草稿和查询范围的契约 + +**位置**:原方案 §3 账号树、§5 详情、§7 调用链;`Backend._run`、`MainWindow.refresh`。 + +**问题性质:异步 UI 设计缺口。** + +账号切换、周期快照、刷新完成和删除/转移可以交错发生。仅仅让按钮暂时禁用,不能防止 A 账号的迟到结果覆盖 B 账号详情,也不能防止周期刷新覆盖未保存的作品勾选或规则草稿。 + +**最小补充**: + +- 请求和回包携带稳定 account ID、请求类型、request ID;详情只接受当前账号/当前请求的结果。删除、转移后的旧结果作废。 +- 账号树选中项、批量启动勾选项、详情编辑草稿分别保存;右键动作绑定右键命中的账号,不从变化中的全局选中项临时取目标。 +- 周期快照只更新运行态等只读数据;dirty 草稿不被覆盖,切换账号时明确保存/放弃/保留策略。 +- 任务、历史、作品、日志列表先在后端按账号/组筛选,再排序分页,不能先取全局最近 N 条再前端过滤。 +- loading、空数据、未同步、失败和部分结果必须可区分。 + +**验收**:A 发起刷新后切到 B,A 回包不污染 B;编辑作品勾选期间收到快照,草稿不丢;一个组产生大量日志时,另一个组仍可查询自己的旧任务。 + +## 9. [P2] 展示列需要落到持久化字段和时间口径 + +> 本节原建议中的人工核定时间/结论字段及相关验收已撤回;创建、执行和结果落库时间的独立记录要求仍有效。 + +**位置**:原方案 §5.2 任务表、§9 持久化设计。 + +**问题性质:数据契约/迁移设计需要补充。** + +原方案要求展示创建、开始、结束、人工核对等信息,但只列数据表名称不足以保证这些信息能正确展示。一个反复更新的时间字段无法同时承担任务各阶段时间。 + +**最小补充**: + +- 明确哪些是已有字段、哪些需要迁移;任务入队、实际领取/开始、结果落库、人工核对时间应各有确定来源。 +- 旧数据没有某个时间时显示“未知”,不要用最后更新时间伪造创建或开始时间。 +- 冷却的作用域、开始时点、恢复算法,以及未知结果解除后的计算方式应有一个明确口径;持久化使用统一时间基准,UI 再本地化展示。 +- 队列等待/冷却剩余等持续时长,与日志墙钟时间分开处理,避免系统时间校正导致负数或提前放行。 + +**验收**:迁移旧库后旧任务仍可读取;人工核对不改变原执行时间;重启不清空冷却,系统时间异常不会直接释放受保护任务。 + +## 10. [P1] SQLite 失败与普通日志失败必须分开定义 + +**位置**:原方案 §6 日志、§8.6 删除、§9 数据与持久化。 + +**问题性质:故障策略缺口,不是要求增加复杂容灾架构。** + +磁盘满、数据库锁定、进程被终止,以及“浏览器目录已删除但数据库更新失败”,都可能使外部动作、文件系统和 SQLite 状态不一致。“日志写入失败不让应用崩溃”不能扩大成“任务状态写入失败也继续执行”。 + +**最小补充**: + +- 写操作前必须成功持久化领取/运行状态;失败则不得发送关注/私信。 +- 动作发出后若结果不能落库,停止相关账号继续领取,保留不确定状态和可核对信息;不能当作普通失败继续执行或自动重试。 +- 清理登录目录与软删除不是一个数据库事务。定义部分成功的显示及重试方式;失败后保持账号停止,不自动恢复登录或业务规则。 +- 调试日志失败可以降级;任务账本失败要 fail closed。去重、unknown 和冷却数据不能跟随普通日志清理。 + +**验收**:分别注入领取前和动作返回后的数据库写失败,前者无外部动作,后者不继续消费任务;模拟目录删除成功但数据库失败,重试不会重新登录或发送业务操作。 + +## 建议补到原方案的最小材料 + +不建议再增加新的服务层或通用工作流框架。补三张表、一套验收清单就足以指导当前实现: + +1. **状态与转换表**:把身份状态、运行状态、任务结果分开;包括启动中、运行、停止中、停止、异常/待核对,以及谁有权改变这些状态。 +2. **操作矩阵**:各状态下登录、同步、启动、停止、转移、删除、核对是否允许;命令的互斥对象、超时、重复提交和部分成功返回方式。 +3. **规则/字段契约表**:生效边界、历史确认一致性、任务时间字段、同步完整性、软删除身份恢复策略。 + +### 最小验收矩阵 + +| 场景 | 核心断言 | 建议验证方式 | +| --- | --- | --- | +| 崩溃后重启 | running 先变 unknown,无自动启动 | 临时 SQLite + 假 session | +| 大号身份错误/启动中停止 | 小号不领取,无迟到重启 | 假 session + 可控 await | +| 慢同步期间停止 | 控制信号及时生效,界面可更新 | 假分页接口 + UI 冒烟 | +| 同一小号动作批次 | 仅一个事件批次在执行;批内关注/私信独立并行 | 假执行器 | +| 删除/移动 unknown 小号 | 原任务仍可查询、核对,归属不改写 | 临时 SQLite + UI | +| 删除后再次绑定同 UID | 明确恢复/拒绝,无身份分叉 | 临时 SQLite | +| 旧 pending 与规则编辑 | 快照、安全开关、取消各按约定生效 | 存储/引擎回归 | +| 实时缓存后首次历史同步 | 不因已有单条缓存漏掉旧页 | 假分页接口 | +| 切账号、迟到回包、未保存草稿 | 无串号、无草稿覆盖 | UI 冒烟 | +| SQLite 故障、删除部分成功 | 无未记账的新写操作、无自动重试 | 故障注入 | + +本地回归通过后再按项目约定做 Windows 原生 GUI 冒烟;以假数据/假执行器覆盖关注和私信,人工验收阶段不自动登录、不执行真实业务写操作。现有测试中已经覆盖的断言应直接复用,不要求重新搭建测试框架。 + +## 推荐处理顺序 + +1. 先修正文档中的恢复顺序,明确停止控制路径、unknown 核对闭环和账本故障策略。 +2. 再确定软删除恢复、旧 pending、规则生效和同步完整性四项行为。 +3. 最后按这些契约实现账号树/详情/日志 UI,并补异步交错与迁移验收。 diff --git a/docs/2026-09-08-16-02-plan01-review-integration.md b/docs/2026-09-08-16-02-plan01-review-integration.md new file mode 100644 index 0000000..2b89215 --- /dev/null +++ b/docs/2026-09-08-16-02-plan01-review-integration.md @@ -0,0 +1,79 @@ +# plan01 评审合并与实施约束记录 + +> 用户最新决定:待核任务不需要人工核定。本文有效说明已同步为 unknown 只读查询,原人工核定建议及字段/操作要求撤回;不自动重发和既有保护不变,详见 [变更记录](2026-09-08-16-40-unknown-readonly.md)。 + +## 任务与范围 + +用户要求将评审方案和建议操作补充到文档说明及相关约束中。本次只修改文档,不实现源码,不把新增要求标作已通过验收。 + +依据: + +- [目标方案](plan01.md) +- [2026-09-08 评审](2026-09-08-15-56-plan01-review.md) +- [前次细化记录](2026-09-08-15-37-plan01-detail.md) +- [基础 PLAN](2026-09-06-11-05-PLAN.md) +- 项目 `AGENTS.md` + +## 文档职责与优先级 + +1. `AGENTS.md`:后续开发、测试和远程操作必须遵守的简明约束。 +2. `docs/plan01.md`:UI、状态转换、命令、数据字段、失败处理和验收的规范性目标。 +3. 基础 PLAN:保留技术选型、环境与历史需求,冲突内容已同步修正并指向 plan01。 +4. 评审、细化及本文:保留决策来源、变更原因和验证范围,不作为功能完成证明。 + +没有修改浏览器连接方式、账号凭据、运行配置、SQLite 或 `src`。现有未跟踪文档保留原有状态,不提交或暂存文件。 + +## 十项评审的合并映射 + +| 评审项 | 合并章节(plan01) | 本次明确的操作/约束 | +| --- | --- | --- | +| 1. 启动恢复顺序 | §3.4、§8.2、§10 | 迁移/遗留 running 恢复先于任何 worker;大号就绪屏障;选择恢复不启动;逐账号结果 | +| 2. 停止受长同步阻塞 | §4、§5.4、§6.5、§8.6 | 长任务异步受理;停止优先关领取;有限总预算;回包运行代次隔离 | +| 3. 删除后 unknown(人工核定已撤回) | §4、§6.1/6.3、§8.7 | 全局和原组入口复用只读任务表,包含已删除/已转移小号;按 task ID 查询,不改状态 | +| 4. 软删除后同 UID | §5.2、§8.1/8.7、§9.1 | 显式恢复原内部 ID/UID;不创建第二个身份,不迁移临时 profile,不复活旧任务 | +| 5. 旧 pending | §4、§6.3/6.4、§8.2 | 展示数量、最早时间和快照;显式恢复/取消;不引入默认自动过期 | +| 6. 同步完整性 | §6.2、§8.4/8.5、§9.2 | 实时缓存与完整基线分离;数据/检查点同事务;失败/截断可补齐而非伪称全部 | +| 7. 规则生效表 | §6.4、§8.4 | 内容快照与即时安全限制分开;历史预览携带配置基线,变化重新确认 | +| 8. UI 异步与查询 | §5.4、§6.5 | account ID/request ID 隔离;选中/勾选/草稿分离;先范围筛选再分页 | +| 9. 阶段时间和迁移 | §9.1/9.3 | 阶段时间独立保存;旧值缺失显示未知;UTC 持久化、单调时钟计时及异常保守暂停 | +| 10. SQLite/删除失败 | §8.6、§9.4、§10 | 账本失败禁止继续发送;删除先存意图,部分成功可显式重试,不自动恢复业务 | + +## 采用的最小设计决策 + +- **停止收尾**:总预算默认 60 秒,从接收停止请求计算,不逐阶段重置;验收中慢同步不得使停止信号超过 1 秒才关闭领取。二者是待实现的不同指标,不表示当前已经达标。 +- **规则关闭**:本版同时关闭新事件自动入队和后续领取,但保留 pending;不开辟“只关闭入队、继续消费旧任务”的另一套模式。重新开启时有旧 pending 也须预览确认,不能通过组仍运行绕过。配置内容仍遵循任务快照,当前安全限制执行前重查。 +- **积压处理**:采用可见预览/确认及取消,不设置默认 TTL;后续确有业务有效期需求再确认过期策略。 +- **删除与恢复**:unknown 的只读查询不依赖账号活动状态,不要求用户核定;软删除身份显式恢复原记录,不清空 UID、去重、冷却或历史。候选浏览器不自动合并或迁移 Cookie。 +- **同步范围**:完整仅指本次平台接口正常可返回的范围,不宣称拿到所有历史;任意已知 ID 不是连续性证明。 +- **实现体量**:复用 asyncio task、SQLite 事务、账号标志和配置摘要;不新增通用调度/状态机框架、独立版本服务或测试框架。 +- **时间限制**:运行中用单调时钟抵御墙钟跳变;纯本地持久化不能证明跨重启的大幅前跳与正常流逝的区别,文档不承诺不存在这一限制。 + +## 旧说明的冲突修正 + +基础 PLAN 不再声称覆盖后续所有已确认需求,改为引用 plan01。以下旧表述已经同步调整: + +- “恢复监听/执行能力后核对遗留任务”改成 worker 创建前恢复。 +- “同一小号没有并行写操作”改成小号串行通知批次、批内关注/私信独立并行。 +- “若要求关注成功后私信”不再暗示当前有动作依赖;改变规则需另行确认。 +- 原五项开发前待确认事项改为后续已明确的单通知单小号、任务等待、归属切换、动作并行和停止语义。 +- 切换归属要求原组停止;转移后不自动启动,不改写历史来源。 + +前次细化与评审文件增加后续入口,保留历史发现,不将“文档已补齐”表述为“代码问题已修复”。原评审的人工核定按钮、结论提交、核定字段和相应验收建议已被用户后续决定覆盖,不再作为实施要求。 + +## 后续建议操作顺序 + +1. 按 plan01 第零阶段先落实启动恢复、停止控制与账本失败;保持不自动登录、不真实写操作的测试环境。 +2. 做最小字段迁移、同步完整性、删除意图、规则确认和 task ID 只读查询;不新增人工核定字段或业务命令。 +3. 再接账号树、详情、全局异常任务只读列表及积压确认;补迟到回包、草稿和范围分页验证。 +4. 复用现有测试,用临时 SQLite、假 session/执行器和假时钟跑 §12 的离线矩阵;不以真实关注、私信或标记已读验证异常分支。 +5. 本地验证通过后,再按 `AGENTS.md` 在 Windows x64 原生构建,通过 Windows MCP 做 GUI 冒烟及人工安装验收;登录失效交给用户。 + +## 本次验证范围 + +- 修改范围限定为以上文档和 `AGENTS.md`,所有新增功能验收项保持待完成。 +- 六个修改文件均通过 UTF-8/末尾换行/行尾空白和相对 Markdown 链接检查;plan01 章节引用有效,十项评审映射齐全,实施清单没有勾选完成项。 +- 六个文件的主动 LSP 检查全部 clean,0 条诊断;`git diff --check` 通过。AGENTS 原有 Markdown 标题/空行及文件名示例格式问题一并修正。 +- Git 范围确认:跟踪改动仅为 `AGENTS.md` 和基础 PLAN;其余四份本次文档处于未跟踪状态,已单独检查内容和链接。未暂存、未提交,未修改其它既有未跟踪文档或源码。 +- 会话级诊断缓存仍显示评审/细化记录指向新合并文档的两条链接警告;目标文件实际存在,六文件主动 LSP 及相对链接脚本均通过。定向 full 检查没有诊断文件,不能作为额外 clean 证据;此处保留缓存与主动检查不一致的记录,不以此宣称功能验收通过。 +- 不运行源码测试或构建:没有源码改动,文档检查不替代功能验收。 +- 未连接 CDP、未启动/登录浏览器、未连接 Windows 主机,未进行任何真实关注、私信、标记已读或登录目录清理。 diff --git a/docs/2026-09-08-16-40-unknown-readonly.md b/docs/2026-09-08-16-40-unknown-readonly.md new file mode 100644 index 0000000..62e69fd --- /dev/null +++ b/docs/2026-09-08-16-40-unknown-readonly.md @@ -0,0 +1,33 @@ +# 待核任务调整:仅保留只读 unknown 记录 + +## 用户决定 + +用户明确:待核任务不需要“人工核定”相关操作。此决定覆盖此前评审、合并记录及历史文档中要求用户确认任务已成功/未成功的建议。 + +## 当前约束 + +- UI 使用“结果不确定(unknown)”描述任务;异常任务列表、计数和原始结果只读展示,仍可按原组查询已删除/已转移账号的记录。 +- 移除“人工核对:已成功 / 未成功”、核定说明提交、核定二次确认,以及按人工结论修改任务状态的操作要求;不新增或暴露对应业务命令。 +- 不要求用户到平台确认结果,也不设置“完成核定后才结束流程”的操作步骤。 +- 新字段契约不再要求人工核定时间、结论或操作记录。已有历史数据兼容保留,本次不删除字段、任务或数据库内容。 +- `unknown` 继续表示结果不确定,不自动视为成功/失败,不重置 pending,不重发、不改派;重启、软删除、账号恢复、冷却到期均不解除已有去重/占用保护。 +- 既有保护可能使同组同目标后续事件持续等待或跳过;去掉人工操作不等于自动放行,本次不引入自动判定、补偿或解除保护策略。 +- pending 取消、旧 pending 恢复确认、历史事件入队二次确认、删除确认、身份核验及 Windows 人工验收不属于任务结果核定,本次保留。 + +## 文档更新范围 + +- `docs/plan01.md`:UI 文案与入口、操作矩阵、任务详情、日志、生命周期、字段、冷却、实施阶段和验收清单。 +- `AGENTS.md`:明确 unknown 只读,不提供人工核定,防重发约束不变。 +- 基础 PLAN、细化记录和评审合并记录:同步当前有效说明。 +- 原评审:保留历史发现,显式标记人工核定建议及其字段/验收要求已被本次决定覆盖,不再作为实施要求。 + +## 验证边界 + +本次仅修改文档,不修改 `src`、数据库、运行配置或已有任务;不连接浏览器/Windows,不执行关注、私信、登录或标记已读。源码功能与 GUI 仍待后续按当前方案实施和验收,不能把文档更新当作按钮已从现有程序移除。 + +检查结果: + +- 七个文件的 UTF-8、末尾换行、行尾空白、相对链接检查通过;plan01 章节引用有效,实施/验收清单仍未勾选。 +- 当前规范中已移除原人工核对按钮、操作矩阵入口、状态修改流程和核定时间字段要求;原评审明确标记相应建议撤回。 +- pending 取消、历史入队二次确认和 unknown 防重发边界仍保留。 +- 七文件主动 LSP 检查 0 条诊断;`git diff --check` 通过。未运行源码测试或构建,未暂存或提交文件。 diff --git a/docs/2026-09-08-16-50-no-worker-event-handling.md b/docs/2026-09-08-16-50-no-worker-event-handling.md new file mode 100644 index 0000000..998cd87 --- /dev/null +++ b/docs/2026-09-08-16-50-no-worker-event-handling.md @@ -0,0 +1,61 @@ +# 当前实现:大号没有小号时的持续事件处理 + +## 范围与结论 + +本次回答当前源码行为,不把 `plan01.md` 的待实现交互当作已经实现。只做源码阅读和临时 SQLite 检查,没有修改源码、业务数据库或运行配置,没有启动浏览器/Engine,也没有执行网络请求、关注、私信或登录。 + +前提是大号已通过登录/身份检查并正常监听。没有关联小号本身不会阻止仅包含大号的组启动。 + +## 实际处理链 + +1. `Engine.main_loop → Engine.collect_details` 获取通知详情,然后调用 `Store.ingest`。 +2. 通知先进入 `cached_notices`,按大号和通知 ID 去重;live 来源优先于 history。 +3. 符合当前规则、目标和作品过滤的事件进入 `events`,初始状态为 `waiting`,保存当时的规则快照。 +4. `Store._dispatch` 尝试选择本组已绑定 UID 的有效小号。完全没有小号、只有未分配小号,或关联小号尚未绑定时,没有可分配对象,事件保持 waiting,并记录等待原因。 +5. 此时不创建 `tasks`,没有关注/私信,也不启动执行冷却。积压的是“待分配事件”,不是“待执行任务”,因此任务列表可能仍为空。 +6. 等有符合条件的关联小号且再次触发 dispatch,旧 waiting 会参与分配;触发点包括新事件入库以及小号领取任务。事件实际状态名为 `dispatched`,生成的动作任务状态为 `pending`。 +7. 生成任务不代表已执行:实际执行仍要求小号 worker 已运行、大号 desired/ready、规则启用及身份/归属/限额等条件通过。仅添加一个账号记录不等于自动启动执行。 + +调度保留实时事件高于历史事件的优先级,同类按事件顺序处理;旧事件使用入库时的规则快照,不因后来修改消息内容而替换。相同目标还受既有活动任务/冷却等去重保护,不保证每条事件都创建一份动作任务。 + +## 持续进入时的影响 + +- 不因“没有小号”而自动丢弃符合条件的 waiting;状态持久化到 SQLite,重启后仍保留。 +- 相同通知 ID 重复上报不会重复新增事件;不同通知 ID 仍可能持续积压,即使目标用户相同。 +- 当前等待分配链没有设置 waiting 容量上限、过期时间或“无小号自动丢弃”策略;不能将它理解为有界队列。 +- `_dispatch` 在检查是否存在已绑定小号之前,先查询并取出该组所有 waiting。每次新事件触发分配都会重新读这些积压;N 条依次到来的不同事件,在一直无小号的情况下累计返回的 waiting 行数约为 N(N+1)/2。这是代码路径分析,不是性能压测结果。 +- 所以长时间没有小号时,数据库/日志会增长,重复扫描也会增加;后续加入可用小号时存在处理旧事件的积压,而不只是处理加入后的新事件。 + +## 与规则关闭的区别 + +没有小号与关闭规则不是同一种状态: + +| 条件 | 通知缓存 | 事件状态 | 是否进入待分配 | +| --- | --- | --- | --- | +| 规则启用且符合条件,但没有可分配小号 | 保留 | waiting | 是 | +| 规则关闭或事件不符合入队条件 | 保留 | ignored | 否 | + +单纯重新开启规则不会自动把已经 ignored 的事件改成 waiting。停止账号组则是停止监听/领取,与上面两种仍在接收通知的情形也不同。 + +## 离线验证结果 + +在三个独立临时数据库内,分别写入三条不同通知 ID;随后重复写入第一条、关闭并重开数据库,再修改当前规则文案、添加已绑定小号并显式调用 `Store.dispatch`。检查全过程没有调用领取或动作执行接口。 + +| 情形 | 无小号时 | 重复通知与重开数据库 | 添加小号并调度后 | +| --- | --- | --- | --- | +| 规则开启,3 个不同目标 | cached=3、waiting=3、tasks=0 | 仍为 3 条事件 | dispatched=3、pending 动作任务=6 | +| 规则开启,3 条通知但同一目标 | cached=3、waiting=3、tasks=0 | 仍为 3 条事件 | dispatched=3、pending 动作任务=2 | +| 规则关闭,3 个不同目标 | cached=3、ignored=3、tasks=0 | 仍为 3 条 ignored | 开启规则后仍 ignored=3、tasks=0 | + +测试配置同时启用关注和私信,因此每个实际分配的目标产生两个动作任务。所有生成任务都保留原规则文案;全过程 cooldowns=0、真实执行次数=0。 + +首次检查把事件分配后的状态误写为 `queued`,断言失败;源码实际使用 `dispatched`。更正检查断言后,以上三个场景全部通过,没有为通过检查修改源码。 + +## 代码依据与复现条件 + +- `src/account_engine.py`:`Engine.start`、`Engine.main_loop`、`Engine.collect_details`、`Engine.worker_loop`。 +- `src/account_store.py`:`Store.cache_notices`、`Store.ingest`、`Store._dispatch`、`Store._claim`。 +- `src/test_accounts.py`:已有 `test_unbound_worker_not_dispatched` 覆盖未绑定小号不分配、绑定后可分配的情形。 +- 本次额外检查使用仓库根目录下的 `PYTHONPATH=src python3`、标准库 `tempfile.TemporaryDirectory` 和 `account_store`,无需新增依赖,不读取真实账号资料。未运行整个测试套件。 + +本记录只解释现状,不新增丢弃、暂停或自动过期策略;若要调整无小号时的业务行为,应另行明确。 diff --git a/docs/2026-09-08-17-39-plan01-refactor-validation.md b/docs/2026-09-08-17-39-plan01-refactor-validation.md new file mode 100644 index 0000000..24745a0 --- /dev/null +++ b/docs/2026-09-08-17-39-plan01-refactor-validation.md @@ -0,0 +1,50 @@ +# plan01 重构与验证记录 + +时间:2026-09-08 17:39 + +## 本次实现 + +- **存储迁移**:新增 `schema_version=3`、删除意图/步骤/错误字段;任务新增创建、开始、完成时间和取消原因。旧任务缺少时间时保持 `NULL`,UI 显示未知,不伪造阶段时间。 +- **任务安全**:领取时写入 `started_at`;成功/明确失败写入完成时间;`unknown` 保持只读且 `finished_at=NULL`,不自动重发、改派或重置。移除 Engine/UI 的人工核定命令和入口。 +- **启动恢复**:Store 迁移完成后由 Engine 先恢复遗留 `running -> unknown`,再允许创建监听/worker;上次选择只回填 UI,不自动启动业务。 +- **停止与迟到回调**:停止先撤销领取入口,再取消登录/只读请求,监听和 worker 使用运行代次校验;收尾最多 60 秒,超时在账本中记录 `unknown`。停止期间禁止重新登录/启动。 +- **账本故障**:结果落库失败会进入 `ledger_blocked`,停止继续领取,避免把动作结果当作未发生。 +- **删除与恢复**:删除登录目录/小号前先持久化删除意图;失败保留禁止启动状态,可显式重试。软删除保留 UID、账号 ID、任务和日志,只能通过“恢复已删除小号”恢复原记录,不能用同 UID 新建第二条记录。 +- **同步完整性**:通知/作品同步记录 `sync` 状态、覆盖量、游标和错误;未完成基线不会因实时缓存提前停止。历史入队校验规则/作品配置 fingerprint。 +- **UI**:账号组改为左树/右详情,按角色显示当前/历史任务或历史事件/作品列表;右键菜单按角色提供入口;停止按钮不被长历史/作品查询阻塞;异步回包增加 request id 过滤;任务表显示三个阶段时间。 + +## 离线验证 + +通过: + +```text +PYTHONPATH=src python3 -m unittest src.test_accounts src.test_plan01 -q +Ran 47 tests ... OK + +PYTHONPATH=src pytest -q src/test_activity.py +7 passed + +ruff check ... +python3 -m compileall -q src +git diff --check +pi-lens full(error) +No blocking errors +``` + +新增 `src/test_plan01.py` 覆盖:任务时间/unknown 只读、删除 UID 显式恢复、规则 fingerprint 变化、`resolve` 命令未暴露。活动日志测试同步改为验证结果不确定不可人工改写。 + +## 尚未执行 + +完整 discovery 仍有两个环境依赖导入错误: + +- `patchright` 缺失,因此未执行真实 Chrome fixture matrix; +- `PySide6` 缺失,因此未执行 Qt onboarding/UI 测试。 + +空环境可按仓库锁定依赖安装后重试: + +```bash +python -m pip install -r requirements-test.txt -r requirements-desktop.txt +patchright install chromium +``` + +没有连接真实账号、没有自动登录,也没有执行真实关注、私信或标记已读。Windows 构建和 GUI 验收需在依赖齐备的 Windows 主机上按项目约束单独执行。 diff --git a/docs/2026-09-08-21-05-windows-remote-install.md b/docs/2026-09-08-21-05-windows-remote-install.md new file mode 100644 index 0000000..65509a4 --- /dev/null +++ b/docs/2026-09-08-21-05-windows-remote-install.md @@ -0,0 +1,37 @@ +# Windows 远程安装记录 + +## 目标 + +将当前工作区源码在 Windows x64 主机上原生构建并安装,交由用户进行 GUI 验证。 + +## 连接与范围 + +- 目标:Windows 主机 `10.1.1.101` +- 连接:项目配置的 `windows-ui` MCP;批量传输、构建和安装使用本机 SSH 配置 +- 构建目录:`C:\Users\Rogee\douyin-pc-build-20260908-plan01` +- 安装目录:`%LOCALAPPDATA%\Programs\DouyinAccounts` +- 未传输登录目录、Cookie、数据库或其他账号数据 + +## 执行记录 + +1. 本地 `.venv/bin/python -m unittest discover -s src -v`:61 项通过。 +2. 本地 `git diff --check`:通过。 +3. 传输当前 `src/*.py`、构建脚本、安装脚本、固定依赖和 packaging 文件。 +4. Windows Python:3.12.10,AMD64。 +5. Windows 构建环境安装固定构建/运行依赖。 +6. Windows 单元测试:61 项通过;源码离线冒烟通过,`real_write_actions=0`。 +7. 原生执行 `build_windows.py`:PyInstaller、打包冒烟、CLI `--help` 和 Inno Setup 均通过。 +8. 打包冒烟:Qt/core/SQLite/Patchright 均通过,账号数为 0,`real_write_actions=0`。 +9. 安装程序静默安装成功,安装日志记录 `Installation process succeeded`。 + +## 产物 + +- 安装程序:`C:\Users\Rogee\douyin-pc-build-20260908-plan01\release\DouyinAccounts-0.1.8-Windows-x64-Setup.exe` +- SHA256:`b7b00e69eab2716e0886d0b252a6349afceee3753a332015cc32c158072e1807` +- 校验文件:`release\SHA256SUMS.txt` + +## 当前状态 + +安装目录中的 GUI、随包浏览器、CLI 和 build manifest 均存在。已通过 Windows MCP 启动 GUI,窗口标题为“抖音账号助手 · 自动操作默认关闭”,状态栏显示“正在初始化;不会自动启动业务”。 + +未自动登录、恢复业务规则、发送关注/私信或标记已读;应用保持打开,等待用户验证。 diff --git a/docs/2026-09-08-22-09-plan01-ui-implementation.md b/docs/2026-09-08-22-09-plan01-ui-implementation.md new file mode 100644 index 0000000..7b5b0bb --- /dev/null +++ b/docs/2026-09-08-22-09-plan01-ui-implementation.md @@ -0,0 +1,41 @@ +# plan01 UI 对齐实现记录 + +## 实现范围 + +按确认顺序调整桌面 UI: + +1. 主工作台改为自定义 CardTable 账号卡片区 + 右侧角色详情 + 底部固定日志;移除右侧账号专属按钮,账号操作统一进入右键菜单。 +2. 历史事件、作品监控、当前任务、历史任务改为详情区内嵌表格/CARD,不再通过主流程弹窗承载。 +3. 增加异常任务只读页、unknown 查询筛选、启动前旧 pending 预览,以及当前 pending 取消;历史/作品增加同步与完整补齐入口。 +4. 账号树增加任务状态计数、同步状态提示和“未分配小号”分组。 + +## 代码变更 + +- `src/accounts_app.py` + - 使用自定义 `AccountCardTable`/`AccountCard` 管理大号、小号层级和状态点;不再使用 QTreeWidget/QTreeWidgetItem。 + - 使用 `QStackedWidget` 工作区和垂直 `QSplitter`,日志始终位于底部。 + - 增加“异常任务”入口和原所属大号/原执行小号筛选。 + - 历史事件、作品 CARD、当前任务和历史任务均在右侧详情内展示。 + - 账号专属生命周期、规则、浏览器和归属操作仅保留在右键菜单。 + - 增加启动前 pending/规则快照确认和 pending 取消确认。 +- `src/account_engine.py` + - 增加 `start_preview`、`task_query`、批量 pending 取消及完整同步命令。 +- `src/account_store.py` + - 增加按原所属大号查询旧 pending 的持久化查询。 +- `src/test_onboarding_ui.py`、`src/test_plan01.py` + - 更新内嵌详情 UI 测试,覆盖启动预览和 unknown 只读查询。 + +## 验证 + +- 本地 unittest:62 项通过。 +- 本地 pytest:80 项通过。 +- Python 编译、LSP、pi-lens error diagnostics、diff check:通过。 +- Windows x64 原生构建:通过。 +- Windows 远程 unittest:通过。 +- Windows 安装程序: + - 目录:`C:\Users\Rogee\douyin-pc-build-20260908-ui01\release` + - 安装包:`DouyinAccounts-0.1.8-Windows-x64-Setup.exe` + - SHA256:`304936e46ce5f4017a7aefdbba419930c8ca33d9650606d1e6e5973593405d7f` +- Windows MCP 人工检查:确认顶栏、底部固定日志、异常任务页、角色详情表格和大号右键菜单均已出现。 + +未执行登录、关注、私信、标记已读或其他真实业务写操作;应用保持默认关闭自动业务状态,等待用户验证。 diff --git a/docs/2026-09-09-09-51-account-tree-ui.md b/docs/2026-09-09-09-51-account-tree-ui.md new file mode 100644 index 0000000..ab26be1 --- /dev/null +++ b/docs/2026-09-09-09-51-account-tree-ui.md @@ -0,0 +1,28 @@ +# 账号树 UI 修订记录 + +## 问题 + +上一版自定义卡片只是把小号嵌套到大号卡片中,没有真正的展开/折叠行为;同时卡片展示了 UID、任务统计、同步状态等过多信息,导致左侧内容拥挤。 + +## 最终调整 + +- 左侧改为自定义 `AccountCardTree`,不是 `QTreeWidget`,也不是表格。 +- 每个大号节点都有真实的展开/折叠按钮;小号作为缩进子节点,折叠后不显示。 +- 左侧节点只显示: + - 账号别名(业务名称) + - 平台名称 + - 当前监听/执行状态 +- UID、登录身份、规则、任务计数、同步状态、资料统计等信息保留在右侧详情区。 +- 保留大号批量启动勾选、节点点击选中、右键账号操作和未分配小号分组。 + +## 验证 + +- 本地 unittest:63 项通过。 +- 本地 pytest:81 项通过。 +- Python 编译、LSP、pi-lens error diagnostics、diff check:通过。 +- Windows x64 原生构建并安装:通过。 +- Windows GUI 检查:确认 `AccountCardTree`、展开/折叠按钮、缩进小号节点和三行精简信息可见;手动折叠后小号节点消失,再次展开后恢复。 +- 安装包:`C:\Users\Rogee\douyin-pc-build-20260908-ui01\release\DouyinAccounts-0.1.8-Windows-x64-Setup.exe` +- SHA256:`6eca6554cbcacc8ebc730744d03f1572b92ef48c5649d08f4f3e4a3d0efab688` + +未执行登录、关注、私信、标记已读或其他真实业务写操作;远程应用保持自动业务关闭状态。 diff --git a/docs/plan01.md b/docs/plan01.md new file mode 100644 index 0000000..b14c42b --- /dev/null +++ b/docs/plan01.md @@ -0,0 +1,682 @@ +# 多账号助手 UI 与调用逻辑细化方案 + +> 本文在原始 `plan01` 草案基础上整理,依据 `docs/2026-09-06-11-05-PLAN.md` 及后续 0.1.5~0.1.8 的实现和验证记录细化。本文描述目标交互、状态和调用边界,不代表所有目标 UI 已完成验收。 +> +> 已纳入 [2026-09-08 评审](2026-09-08-15-56-plan01-review.md) 的十项补充。后续 UI、生命周期和任务安全实现以本文及 `AGENTS.md` 为准;早期计划中的恢复顺序、动作串并行和待确认项不再覆盖本文。新增字段、命令与交互仍须实施并验收,不能因写入方案就标记已完成。 +> +> **用户最新调整**:任务不需要人工核定。`unknown` 仅作结果不确定的只读记录,不提供人工判定成功/失败、提交核定说明或据此修改状态的操作;不自动重发和既有安全保护不变。此决定覆盖原评审的人工核对建议,见 [变更记录](2026-09-08-16-40-unknown-readonly.md)。 + +## 1. 目标 + +将主窗口调整为“账号树 + 账号详情 + 全局日志”的工作台: + +- 左侧统一管理大号、小号及其归属关系。 +- 账号节点直接显示登录、身份、监听/执行等状态。 +- 账号操作收纳到右键菜单,按账号角色只显示可用操作。 +- 选中大号后,右侧查看历史事件和作品列表。 +- 选中小号后,右侧查看当前任务和历史执行记录。 +- 窗口底部持续显示全局运行日志。 +- 不改变已有业务约束:默认不自动执行,登录失效不自动登录,身份不匹配不执行,unknown 结果不自动重发。 + +## 2. 总体布局 + +```text +┌─────────────────────────────────────────────────────────────────────┐ +│ 添加账号 │ 恢复上次选择 / 启动勾选组 │ 停止全部 │ 异常任务 │ 设置 │ +├───────────────────────┬─────────────────────────────────────────────┤ +│ 账号树 │ 账号详情区 │ +│ │ │ +│ ● 大号01 │ 大号01 │ +│ 监听:运行中 │ 状态摘要 / 规则摘要 │ +│ ├─ ● 小号01 │ ┌────────────┬────────────┐ │ +│ │ 执行:空闲 │ │ 历史事件 │ 作品列表 │ │ +│ └─ ○ 小号02 │ └────────────┴────────────┘ │ +│ 执行:待登录 │ │ +│ │ │ +│ ● 大号02 │ │ +│ └─ ● 小号03 │ │ +│ │ │ +│ ○ 未分配小号 │ │ +├───────────────────────┴─────────────────────────────────────────────┤ +│ 全局运行日志:推送、详情、分配、任务结果、异常和恢复信息 │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +### 2.1 区域职责 + +| 区域 | 内容 | 交互原则 | +| --- | --- | --- | +| 顶部工具栏 | 添加/恢复账号、恢复选择、启动、停止、异常任务、浏览器设置 | 只保留跨账号或创建类操作 | +| 左侧账号树 | 大号、小号、归属和状态 | 选择账号查看详情;大号复选框用于批量启动 | +| 右侧详情区 | 当前账号资料、规则、事件、作品或任务 | 内容随角色切换,不显示无关操作 | +| 底部日志区 | 最近 2000 条运行记录 | 只读、默认滚动到最新,可打开完整日志目录 | + +## 3. 账号树 + +### 3.1 节点层级 + +```text +[复选框] [状态点] 大号名称 + 监听状态:运行中 / 已停止 / 连接中 / 需要登录 / 出错 + ├── [状态点] 小号名称 + │ 执行状态:空闲 / 执行中 / 等待任务 / 需要登录 / 出错 + └── [状态点] 小号名称 + 执行状态:已暂停 / 未分配 / 已删除(历史记录中保留) +``` + +- 大号为顶层节点,复选框只对大号生效;“恢复上次选择”只恢复勾选,“启动勾选组”才进入确认和启动流程。 +- 小号显示在所属大号下;未归属小号放入“未分配小号”节点或单独分组。 +- 一个小号最多归属一个大号;未归属小号不接收任务。 +- 不使用昵称或 UID 作为内部节点 ID,节点操作始终使用内部账号 ID。 +- 删除的小号从活动树移除,但历史任务仍显示原本地名称并标记为已删除。 + +### 3.2 状态点 + +状态点只表达粗粒度状态,详细原因放在节点第二行和右侧详情中: + +| 颜色 | 状态 | 含义 | +| --- | --- | --- | +| 绿色 | 在线 / 可用 | 已连接且身份核验通过 | +| 灰色 | 已停止 / 离线 | 未运行监听或执行,或连接已断开 | +| 橙色 | 需要登录 / 需要验证 | 浏览器可打开,但需要用户手动处理 | +| 红色 | 身份不匹配 / 错误 | 实际 UID 与绑定 UID 不一致,停止所有写操作 | +| 蓝色 | 启动中 / 停止中 / 重连中 | 正在进行生命周期操作 | + +必须分别显示以下三个维度,不能把“已登录”直接显示为“正在监听”: + +1. **登录状态**:已登录、未登录、需要验证。 +2. **身份核验**:未绑定、核验通过、UID 不匹配、核验失败。 +3. **运行状态**:未运行、启动中、监听中、执行中、已暂停、停止中、错误。 + +### 3.3 节点文案 + +大号节点至少显示: + +- 自定义名称; +- 昵称和 UID; +- 登录状态、身份核验状态; +- 监听运行状态; +- 自动规则:已启用 / 已关闭; +- 任务计数:待执行、执行中、结果不确定、失败; +- 作品缓存数量和最近更新时间(有缓存时)。 + +小号节点至少显示: + +- 自定义名称; +- 昵称和 UID; +- 所属大号; +- 登录状态、身份核验状态; +- 执行状态; +- 当前待执行、执行中和结果不确定任务数量。 + +### 3.4 生命周期状态与转换 + +身份、运行状态与任务结果是三个独立维度。已绑定 UID、命令返回“已受理”、浏览器已打开,都不等于本次运行已就绪。 + +| 阶段/状态 | 进入条件与允许行为 | 退出条件与约束 | +| --- | --- | --- | +| 启动恢复 | 获得本地数据库单实例使用权、完成必要迁移;此时不存在 worker | 遗留 running 先转 unknown,恢复失败则停在错误状态,不开放启动 | +| 已停止 | 可以查看本地数据、编辑规则、显式登录或只读同步 | 手动启动并确认积压任务后进入启动中;恢复选择本身不启动 | +| 启动中 | 建立账号连接,核验实际 UID 和所需能力;禁止小号领取 | 大号就绪后才放行通过核验的小号;失败显示具体原因;停止请求随时有效 | +| 监听中/等待任务/执行中 | 大号已就绪,小号身份与归属有效,当前安全限制允许 | 小号每次只领取一个通知批次;批内关注/私信独立并行 | +| 已暂停/需要登录/身份异常 | 立即关闭对应领取入口;大号异常影响整组,小号异常只影响自身 | 不自动登录、改派或解除风控;恢复需显式操作及重新核验 | +| 停止中 | 立即禁止新增调度和领取,取消只读同步,收尾在途动作 | 重复停止幂等;拒绝再次启动、转移和删除,直到收尾完成 | +| 已停止且有不确定结果 | 调度已停止,但存在 unknown;浏览器可以保留 | 只读展示数量和原结果,不要求用户核定;不得把“已停止”解释为所有动作已确定结束 | +| 删除处理中/删除异常 | 已持久化删除意图,账号禁止启动和领取 | 仅允许查看和显式重试清理;不得自动恢复业务 | + +- 运行状态由引擎报告,UI 不通过按钮点击或超时自行推断成功。 +- 批量启动返回逐账号结果;部分小号失败不能显示为全组全部成功,也不回滚其它独立组。 +- 启停使用账号级操作标志/运行代次防止迟到回调重新放行;停止后旧代次不能启动 worker。无需引入通用状态机框架。 + +## 4. 顶部工具栏 + +顶部只放全局或创建类操作,不重复放账号专属操作: + +| 按钮 | 行为 | +| --- | --- | +| 添加大号 | 创建大号记录,随后打开独立浏览器,等待用户手动登录 | +| 添加小号 | 提供新建及“恢复已删除小号”入口;新建时可选择归属,恢复保留原内部 ID 和 UID,见 §8.7 | +| 恢复上次选择 | 只恢复有效大号的复选框,不连接浏览器、不启动监听或执行 | +| 启动勾选组 | 无选择则提示;先展示旧 pending 数量、最早时间和规则快照提示,再由用户确认启动 | +| 停止全部 | 优先禁止监听调度和任务领取,在有限时限内收尾,保留 Chrome 浏览器 | +| 异常任务 | 只读查询 unknown,包含已删除和已转移小号的原任务,支持按原组筛选;不提供人工核定操作 | +| 浏览器设置 | 编辑全局浏览器可执行文件和高级参数;单账号覆盖设置放入账号右键菜单 | + +只禁用同一账号的冲突操作,不全局锁住工具栏。长同步期间“停止全部”、查看本地数据和状态更新仍可用;状态栏区分“已受理 / 启动中 / 停止中 / 已完成 / 部分失败”。停止期间提示在途动作仍可能生效,具体收尾期限和 unknown 处理见 §8.6。 + +## 5. 右键菜单 + +原页面下方的账号操作按钮移入账号树右键菜单。右键菜单只承载账号生命周期、归属和配置类操作;历史事件和作品监控已经在选中账号后的右侧详情区提供入口,不在右键菜单重复提供。菜单根据角色、登录状态、归属和运行状态动态裁剪;不可用操作可以隐藏,也可以保留为禁用项并显示原因,但不能让用户提交必然失败的请求。 + +### 5.1 大号菜单 + +```text +大号01 +├─ 登录 / 刷新账号信息 +├─ 启动本组 +├─ 停止本组 +├─ 配置自动操作规则 +├─ 打开账号浏览器 +├─ 关闭浏览器 +└─ 删除登录数据 +``` + +说明: + +- 历史事件和作品监控请先点击大号,在右侧详情区进入对应 TAB;右键不提供重复入口。 +- 动作内容及作品范围只影响新入队事件,已创建任务保留快照;规则总开关、暂停和当前安全限制按 §6.4 即时约束领取,不能用旧快照绕过。 +- “删除登录数据”关闭并清空该账号浏览器登录目录,但保留账号、历史事件、任务和规则。 +- 大号不显示“切换归属”和“删除小号”。 + +### 5.2 小号菜单 + +```text +小号01 +├─ 登录 / 刷新账号信息 +├─ 切换 / 解除归属 +├─ 打开账号浏览器 +├─ 关闭浏览器 +├─ 删除登录数据 +└─ 删除小号 +``` + +说明: + +- 小号不提供“启动/停止所属组”;账号组统一由大号节点右键或顶部“启动勾选组 / 停止全部”管理,避免用户误以为可以只启动或停止单个小号。 +- 小号不显示大号专属的规则配置、历史事件同步和作品监控;执行记录直接在选中小号后的右侧详情区查看。 +- “切换 / 解除归属”先停止原所属组(未分配小号无需此步);有 running 或处于启停/删除中时拒绝。确认后在事务中取消旧组 pending 并更新归属,不改派、不重发,保留历史和 unknown;转移后不自动启动。 +- “删除小号”必须先停止所属组并等待在途任务结束;关闭浏览器、删除独立 profile 成功后才从活动列表移除。 +- 删除小号只取消 pending 任务,保留 succeeded、failed、unknown、cancelled 历史;确认框展示结果不确定的任务数量及删除后的只读查找入口。删除意图和部分失败处理见 §8.6,重新添加同 UID 见 §8.7。 +- “打开账号浏览器”只打开或连接对应独立 profile,不切换到其它账号。 + +### 5.3 空白区菜单 + +不显示账号专属操作,只可提供: + +- 刷新账号树; +- 打开日志目录; +- 打开全局浏览器设置。 + +### 5.4 操作允许条件与互斥矩阵 + +UI 禁用仅用于提示,后端仍须按当前状态、角色和内部账号 ID 校验。归属更新、任务领取和 pending 取消在数据库事务内重查条件。 + +| 操作 | 允许条件 | 冲突/异常行为 | +| --- | --- | --- | +| 启动组 | 恢复已完成;组非启动中/停止中/删除中;积压确认有效 | 重复请求不创建第二套 worker;逐账号报告就绪或失败 | +| 停止/退出 | 任意运行阶段,包括登录等待和长同步 | 优先关闭领取入口;重复提交返回同一收尾状态,不排在同步后 | +| 登录/打开浏览器 | 用户显式发起;身份变更前停止对应组 | 不自动登录;普通只读资料刷新不等于允许换号 | +| 历史/作品同步 | 有效大号,当前 UID 核验通过 | 同账号同步合并/拒绝重复请求;不同账号可并发;停止可取消只读任务 | +| 保存规则/作品范围 | 所选账号有效,提交基线未过期 | 拒绝覆盖其它配置入口或后台已更新的配置;按 §6.4 生效 | +| 取消 pending | 明确列出待取消 task ID 并确认 | 只取消仍为 pending 的本组任务;已变为 running/unknown 的返回跳过原因 | +| 转移/解除归属 | 原组停止、无 running,目标大号有效 | 事务重查后取消旧 pending;原任务来源不改写;新组即使运行也不自动启用转入小号 | +| 删除登录数据/小号 | 停止收尾完成,明确确认影响范围 | 持久化删除意图后清理;部分失败保持禁止启动,不能显示完全成功 | +| 查看不确定结果 | task ID 存在,不要求原执行账号仍活动 | 只读展示,不判定成功/失败,不修改状态、不重发、不重新执行依赖动作 | + +耗时操作返回 request ID/受理状态并异步报告完成;内部账号级互斥保护连接、页面初始化和生命周期变更。不能把整段同步挂在唯一命令消费循环上,也不能把互斥扩大成阻止批次内两个独立动作并行。 + +## 6. 右侧详情区 + +右侧顶部显示当前账号的基本资料和状态摘要,下面显示角色对应内容。账号树中的单击/选择负责切换详情;右键只负责执行账号操作,不负责重复打开历史事件、作品监控或执行记录页面。 + +### 6.1 通用摘要 + +- 头像、昵称、UID、抖音号(如有); +- 登录状态、身份核验、运行状态; +- 独立浏览器端口和 profile 路径只在设置或诊断信息中显示,不在普通列表中暴露; +- 粉丝、关注、获赞、作品数量等平台资料; +- 最近一次状态变化、错误原因和待处理数量; +- 大号摘要中的“结果不确定”计数可打开原组只读任务列表,包含已删除小号;与顶部“异常任务”入口复用同一任务表,不新增第三个大号主要 TAB。 + +业务字段如 UID、昵称、作品 ID、评论和结果说明可按原文展示;Cookie、Authorization、Session、Token、密码、签名参数等凭据始终隐藏。 + +### 6.2 选中大号:两个 TAB + +#### TAB 1:历史事件 + +用于查看大号已缓存的实时和历史通知,并在确认后将历史事件交给小号执行。 + +表格列: + +- 选择; +- 发生时间; +- 事件类型:点赞、关注、评论、收藏/其他作品互动; +- 来源用户 UID 和昵称; +- 作品 ID 和作品描述; +- 评论或其它内容; +- 通知 ID; +- 数据来源:实时收到 / 历史同步; +- 当前处理状态或任务数量。 + +操作: + +1. 点击“同步历史事件”执行只读同步:没有完整基线时全量补齐,有完整基线时才使用已验证的增量停止策略;另提供“完整补齐”重建基线。 +2. 实时缓存非空不代表首次全量已完成。首次同步持续分页直到平台返回结束;重复游标、安全上限、接口失败都必须显示未完成,不能把截断结果称为“全部”,详见 §9.2。 +3. 请求固定使用 `notice_group=700` 与 `is_mark_read=0`,不标记已读。 +4. 默认不勾选任何行;支持按事件类型过滤、全选当前显示、清空当前显示。 +5. 点击提交后预览选择数量、来源大号、动作组合和消息内容/规则摘要,再弹出二次确认;默认按钮为“否”。预览保存规则及作品配置的版本或内容摘要。 +6. 二次确认只提交当前已加载的通知 ID、账号 ID、request ID 和预览基线;后端从缓存读取完整对象,重查身份、归属、规则开关及作品范围。基线变化则拒绝提交并要求重新预览确认,不能静默换用新规则。 +7. 入队及去重在事务中完成;重复提交不产生重复任务。结果显示选择数、新增事件数、实际任务数和各类跳过数量;三者可以不同,原因写入运行日志。 + +历史事件进入队列后使用低优先级;实时新消息优先。历史确认前只缓存数据,不创建任务、不执行关注或私信。 + +#### TAB 2:作品列表 + +使用作品 CARD 展示,不使用宽表格作为主要视觉呈现。 + +每张 CARD 包含: + +- 左侧封面,优先从本地 `work_covers/` 读取; +- 作品描述,默认最多两行,支持展开/收起; +- 作品 ID; +- 点赞、评论、收藏、分享,分行显示; +- 发布时间; +- 本地更新时间; +- 监控选择框。 + +工具区: + +- “监控全部作品”:默认开启,新作品自动纳入; +- 关闭后进入“指定作品”模式,至少选择一个作品; +- “强制增量刷新”:从最新页开始读取并更新 SQLite 缓存; +- “完整补齐”:显式完成/重建全量基线;显示最近成功时间、覆盖范围及失败/截断原因; +- 运行期间自动刷新间隔,默认 60 分钟,0 表示关闭,最大 7 天; +- 全选、清空选择、保存设置。 + +行为边界: + +- 首次打开且无缓存时只读分页获取平台可返回的作品;有缓存时普通打开只读本地数据。缓存存在但基线未完成时显示“未完整同步”,由用户显式补齐,不冒充全部作品。 +- 不假设列表严格按发布时间排序;置顶作品、重复页和已知作品 ID 不能独自证明后续没有新增。增量提前停止必须有验证过的连续性依据。 +- 自动刷新只在对应账号组运行时执行;停止组后不再定时请求。 +- 指定作品模式只影响之后入库的互动事件;已创建任务不被改写。 +- 点赞、评论、收藏/其它作品互动必须有作品 ID且命中选择范围;关注通知没有作品 ID,不受作品范围限制。 +- 封面下载只允许受控的抖音图片 HTTPS 域名,成功后临时文件原子替换,失败不影响作品数据。 + +### 6.3 选中小号:执行记录 + +右侧不显示历史事件和作品监控,而显示该小号实际接收和执行的任务。 + +建议分为两个筛选区域: + +- **当前任务**:pending、running、unknown; +- **历史任务**:succeeded、failed、cancelled,以及已完成的其它记录。 + +表格列: + +- 任务 ID; +- 来源大号; +- 通知 ID / 事件; +- 目标 UID; +- 动作:关注或私信; +- 状态; +- 创建时间、开始时间、完成时间; +- 结果说明; +- 失败原因或结果不确定的只读提示。 + +`unknown` 仅展示原结果和诊断信息,不提供“已成功 / 未成功”核定按钮、核定说明提交或人工改状态命令,不要求用户到平台核实结果。移除人工操作不等于自动判定或放行,不确定任务仍不得重发。 + +关注和私信同时开启时是两个独立任务,并行执行、分别保存结果,一个失败不取消另一个。 + +- 当前任务提供“取消所选 pending”,确认框显示任务数、原规则摘要及取消后不重发;不得取消 running/unknown 或重新分配给其它小号。 +- 全局/大号异常任务入口复用此表,只读显示原执行账号及已删除标记、原所属组、目标、动作、阶段时间和原始结果。历史来源不随当前归属变化;查询使用 task ID,不要求旧浏览器存在。 +- 浏览、筛选和查看详情不能写任务状态,也不能通过按钮、快捷键或后台命令提交人工结论。停止收尾后的迟到回包不得覆盖已落库的 unknown 或终态,只追加诊断信息。 + +### 6.4 规则生效与积压任务 + +| 配置/操作 | 生效边界 | 已有任务处理 | +| --- | --- | --- | +| 规则总开关关闭 | 立即禁止新事件自动入队及后续领取;后端在领取前重查 | pending 保留,在途动作收尾;不等于取消任务 | +| 规则总开关开启 | 只恢复规则资格,不启动已停止的组 | 有旧 pending 时先按启动同样的流程确认;已运行组也不能绕过此确认恢复领取;不自动补发关闭期间已跳过事件 | +| 停止组、身份/归属异常 | 立即关闭对应调度与领取入口 | 不改变任务内容;无法确认的在途动作转 unknown | +| 动作类型、私信内容、作品范围 | 只作用于新入队事件 | 已有任务沿用原快照;保存时提示不会改写/取消旧任务 | +| 配额、执行间隔、冷却和风控暂停 | 领取/发送前按当前安全限制重查 | 原快照不能绕过收紧后的限制;修改配置不缩短已经生效的冷却 | +| 取消所选 pending | 确认后按 task ID 条件更新 | 保留取消原因及去重记录;不取消 running/unknown,不自动改派 | +| 历史预览并提交 | 使用确认时校验通过的规则及作品配置基线 | 基线变化重新确认;同一通知仍通过数据库去重 | + +本版区分暂停、关闭新入队和取消任务的效果,但不增加“仅关闭入队、继续消费旧队列”的独立模式:关闭规则同时暂停后续领取,UI 必须明确显示这两个效果。规则保存成功不等于任务开始执行。 + +启动或重新启用规则的积压确认展示所选组的 pending 总数、最早入队时间(旧数据缺失显示未知)、动作/规则摘要和“继续 / 取消所选旧任务 / 返回”选项。预览记录旧 pending 的 ID 集合或等价队列基线;提交前旧任务集合、所属组或快照摘要变化须重新预览,不能把未确认的旧任务悄悄纳入恢复。正常启动后产生的新实时任务仍按已启用规则处理,不要求每条再次确认。当前不自动设置 TTL,只有业务另行确认有效期后再实现过期策略。 + +### 6.5 异步回包、草稿与查询契约 + +- 请求/回包携带内部 account ID、请求类型和 request ID;详情只接受当前账号、当前请求的结果。删除、转移和运行代次变化后使相关旧请求失效。 +- 同一账号的重复同步返回已有任务状态或明确拒绝;完成、失败、取消分别上报,命令已受理不代表成功。 +- 账号树当前选中项、用于批量启动的大号勾选集合、各账号未保存草稿分别保存。右键动作固定绑定命中的账号 ID,不临时读取可能已变化的全局选中项。 +- 周期快照只刷新只读状态,不覆盖 dirty 规则/作品选择。切换账号时提供保存、放弃、取消切换;后台配置基线变化时提示冲突,不静默覆盖草稿。 +- 任务、通知、作品、日志在后端先按账号/原组/状态过滤,再稳定排序分页;不能先取全局最近 N 条再前端过滤。全选/清空默认仅作用于当前已加载结果。 +- 显示未同步、加载中、空结果、部分结果、失败和成功的区别;状态快照及本地查询不等待长同步结束。 + +## 7. 底部全局日志 + +整个窗口下方固定显示“运行记录(最近 2000 条)”。 + +### 7.1 展示内容 + +按业务阶段记录: + +```text +启动 / 停止 +→ 账号打开与身份核验 +→ 推送接收与去重 +→ 详情获取与入库 +→ 规则匹配与跳过原因 +→ 小号选择与任务入队 +→ 任务领取与动作开始 +→ 任务成功 / 失败 / 结果不确定 +→ 重连、暂停、恢复 +``` + +日志必须明确区分: + +- “详情入库”不等于动作执行; +- “分配完成”不等于关注或私信成功; +- 只有任务结果为成功才表示已确认成功; +- unknown 表示结果不确定,不能自动重发。 + +### 7.2 UI 行为 + +- 默认自动滚动到最新记录;用户取消后允许向上查看。 +- Qt 控件最多保留最近 2000 行;文件日志不受此上限影响。 +- 提供“打开日志文件夹”按钮。 +- 普通文件日志错误显示在日志区顶部,可以降级而不使业务线程崩溃;SQLite 任务账本写失败必须阻止后续动作,不能按日志失败处理,见 §9.4。 +- 完整日志按本地日期保存到 `%LOCALAPPDATA%\\DouyinAccounts\\logs\\YYYY-MM-DD.log`。 +- SQLite 事务回滚时不写入“已成功”的日志。 +- 清空 UI 只清展示缓冲;清理普通日志不得删除 tasks、events、cooldowns、unknown 记录或同步基线。未定义保留期前不做自动历史清理。 + +## 8. 端到端调用逻辑 + +### 8.1 添加账号与登录回填 + +```text +点击添加大号/小号 + → 输入自定义名称和小号归属 + → 创建账号记录、独立 profile、端口和浏览器配置 + → 打开对应浏览器 + → 用户手动登录 + → 读取当前页面资料并核验 UID + → 绑定 UID、昵称、头像和统计 + → 更新账号树和详情区 +``` + +- 不自动填写账号、不自动登录、不绕过验证码或风控。 +- 如果登录状态丢失,暂停对应账号并提示用户手动处理。 +- 实际 UID 与已绑定 UID 不一致时拒绝覆盖和写操作。 +- UID 已被活动账号绑定时拒绝重复绑定;对应软删除小号时转入 §8.7 的显式恢复流程,不能清空原 UID 或创建第二条身份绕过约束。 + +### 8.2 启动账号组 + +```text +进程启动(尚不存在 worker) + → 检查数据库单实例、迁移及账本可用性 + → 遗留 running 转 unknown,保留删除处理中状态 + → 展示结果不确定和积压计数;停在未运行状态 + +用户恢复/勾选大号并点击启动 + → 预览并确认旧 pending 与原规则快照 + → 复用或启动大号独立浏览器,连接 loopback CDP + → 核验真实 UID、登录及监听能力 + → 大号监听就绪,逐个核验小号身份和归属 + → 仅允许已核验小号领取任务,启用本组作品定时刷新 + → 逐账号报告成功/失败,更新账号树和日志 +``` + +- 中断恢复只在任何 worker 启动前执行,不在每次启动组时扫描并重写全库 running;保留现有初始化先恢复的顺序。 +- 大号失效时停止新任务产生并暂停整组;缓存里的已绑定 UID 不能代替本次在线核验。 +- 小号失效时保留原任务等待,不自动改派其它小号;其它正常小号可继续,但组状态必须显示部分异常。 +- 一个大号组失败不影响其它大号组,但不能跨组派发任务。启动中收到停止后,迟到的连接成功不能重新放行。 + +### 8.3 实时事件 + +```text +大号 SDK 推送 + → 持久化推送 ID 并去重 + → 获取详情并校验所属大号 + → 写入 cached_notices + → 按规则、作品范围、来源 UID 和自操作保护过滤 + → 在所属小号中轮询选择一个 + → 创建关注/私信任务 + → 小号串行领取通知批次 + → 同批关注和私信并行执行 + → 分别回查并落库 + → 刷新小号记录、组计数和全局日志 +``` + +约束: + +- 一条通知只分配给一个关联小号,不向全部小号广播。 +- 同一大号组和目标 UID 共用冷却,默认 4 小时;冷却从实际领取并即将发起请求时开始。 +- pending、running、unknown 任务已存在时不重复创建。 +- 触发者是执行小号自身,或目标是来源大号时,记录明确跳过原因,不创建任务。 + +### 8.4 历史事件 + +```text +选中大号并进入右侧“历史事件” TAB,点击“同步历史事件” + → 按完整基线选择只读全量补齐或增量同步,写入 cached_notices + → 在右侧历史事件 TAB 展示 + → 用户筛选、勾选 + → 第一次确定,预览动作/规则摘要及配置基线 + → 二次确认可能产生真实关注/私信 + → 后端按账号与通知 ID 从缓存读取对象 + → 原子重查配置基线、规则和安全条件;变化则重新确认 + → 创建 history 任务(优先级低于 live) + → 刷新任务计数和日志 +``` + +实时事件到达后: + +- 同 UID 的 pending 历史任务可以被取消,由实时任务替代; +- running 或 unknown 不强制中断、不重发; +- 相同通知先历史后实时时提升为 live,不创建重复事件或任务。 + +### 8.5 作品监控 + +```text +选中大号并进入右侧“作品列表” TAB + → 读取本地缓存 + → 无缓存:分页获取平台可返回作品并记录基线是否完整 + → 有缓存:展示 CARD 和同步完整性,不把有缓存等同于全量完成 + → 用户可强制增量刷新或完整补齐 + → 用户选择全部作品或指定作品 + → 保存 work_mode、work_ids 和刷新间隔 + → 后续通知按作品范围过滤 +``` + +### 8.6 停止、关闭和删除 + +```text +停止组 / 停止全部 / 退出程序 + → 优先设置禁止调度和领取标志,状态变为停止中 + → 取消只读同步/登录等待,卸载监听和定时刷新 + → 在收尾期限内等待已发出动作确认并落库 + → 无法确认的动作转 unknown,停止本地调度 + → 保留 Chrome 和登录目录,只读展示结果不确定的任务数量 + +关闭浏览器 + → 先完成对应组停止收尾 + → 关闭本程序拥有的浏览器;不杀死未确认归属的外部进程 + → 保留登录目录 + +删除登录数据 / 删除小号 + → 停止原组,确认无 running,展示 pending/unknown 数量并再次确认 + → 事务内记录删除类型和待清理对象,禁止启动/领取 + → 关闭受管浏览器,安全清理该账号 profile + → 清理成功后事务提交最终状态 + → 删除登录数据:保留账号、UID、归属、规则、任务和历史 + → 删除小号:取消 pending,软删除账号,保留历史和 unknown + → 失败/中断:保留删除处理中/异常及已完成步骤,等待显式重试 +``` + +- 停止控制不能排在长同步后,也不能先等待登录结束才关闭领取入口。以现有 asyncio task、账号操作标志实现即可;快照更新独立于长任务完成。 +- 默认总收尾预算为 60 秒,从接受停止请求起计算,不按阶段反复延长。该值作为统一常量并在界面提示;后续需调整时同时更新测试,不新增复杂配置页。 +- 超时只取消本地等待,不声称远端请求回滚。已发出但无法确认的动作进入 unknown;若账本不可写,则保持禁止领取并显示持久化异常,不能伪称已成功记录 unknown。 +- 停止收尾后的迟到回包不得覆盖已落库的 unknown 或终态,也不得触发下一批。使用 task 状态条件更新和运行代次校验;正常停止不关闭浏览器,强制结束进程不视为正常停止成功。 +- profile 清理前校验路径处于受管根目录内且属于该账号,拒绝通过符号链接/junction 越界;不清理其它账号或外部 Chrome 登录目录。 +- 文件系统与 SQLite 不是同一事务。删除意图须先落库;重试时目录已不存在视为该清理步骤完成。重启后仍显示未完成删除,不自动继续破坏性清理、恢复规则或登录。 + +### 8.7 异常任务只读查询与已删除小号恢复 + +**查看 unknown**:顶部“异常任务”或大号摘要 → 按原组/原执行账号筛选(包含已删除)→ 只读查看目标、动作、阶段时间及原结果。流程到查看为止,不包含用户自行核实、提交结论或修改任务状态,也不为释放队列猜测结果。 + +**恢复已删除小号**:添加小号中的“恢复已删除小号” → 选择原记录并查看历史/结果不确定的任务数量 → 确认归属 → 恢复原内部 ID、原绑定 UID 和历史引用 → 保持停止,按正常流程由用户手动登录并重新核验。恢复不复活 cancelled/pending 任务、不清空去重或冷却、不覆盖 unknown、不自动启动;目标归属变化按转移约束处理。 + +新建流程发现 UID 对应已删除记录时,提示转到上述入口,不直接合并账号或迁移浏览器登录数据。未绑定候选保持停止,可由用户取消创建;清理候选目录须确认且不得影响原记录。首版不做临时 profile 合并或 Cookie 搬运。 + +## 9. 数据与持久化边界 + +| 数据 | 用途 | UI 展示 | +| --- | --- | --- | +| `accounts` | 角色、名称、UID、资料、归属、规则 | 账号树和详情 | +| `cached_works` | 作品列表、统计、封面地址和业务对象 | 作品 CARD | +| `cached_notices` | 实时/历史事件统一缓存 | 历史事件 TAB | +| `events` | 已处理事件及来源、状态 | 任务关联和日志 | +| `tasks` | 不可变来源、执行小号、动作快照、状态、阶段时间及原结果 | 小号记录及包含已删除账号的只读异常列表 | +| `cooldowns` | 大号组与目标 UID 的共享冷却 | 状态提示和日志摘要 | +| `settings` | 上次选择、同步基线/游标及完整性、浏览器配置 | 设置、同步状态和仅恢复选择 | +| 按日日志 | 完整过程审计 | 底部最近 2000 条 | + +SQLite 是任务真源,内存队列只负责唤醒和调度。事件写入和任务生成应处于可恢复事务中,避免出现“事件已处理但任务永久丢失”。以下为需要实施检查的字段/语义清单,不表示现有库已经具有这些字段;优先复用现有列或 settings,不为每种状态新建一套表。 + +### 9.1 最小字段与迁移契约 + +| 对象 | 必须保存的事实 | 约束 | +| --- | --- | --- | +| 账号 | 内部 ID、锁定 UID、当前归属、软删除标记、未完成删除意图 | UID 唯一包含软删除记录;恢复复用原身份;删除意图不能被重启状态清理掉 | +| 任务来源 | 原组、原执行账号、事件、目标、动作及规则快照 | 归属变更/软删除不改写原来源;删除账号不得级联删除任务 | +| 任务阶段时间 | `created_at`、`started_at`、`finished_at` 或等价独立字段 | 入队、领取进入执行、确定结果落库分别记录;unknown 不伪造确定结果完成时间,单一 updated 不能代替全部时间 | +| 原结果与取消 | 原不确定结果、已有诊断信息、pending 取消原因 | unknown 只读保留;pending 按状态条件取消,不新增人工核定结论、说明或时间字段 | +| 配置基线 | 规则及作品选择的持久化版本或规范化内容摘要 | 保存与入队时校验;版本变化只提示重确认,不静默改任务快照 | +| 同步状态 | 按账号和数据类型区分基线完成、游标/覆盖范围、最近成功及本次结果 | 实时缓存写入不能把历史全量标记为完成,失败不覆盖最近成功事实 | +| 删除进度 | 操作类型、受管对象、待完成步骤及异常 | 已持久化意图后才能清目录;不保存凭据,不自动重试破坏性步骤 | + +迁移在没有 worker 时执行,保留单实例约束和数据库版本;需要备份时用 SQLite 一致性备份,不直接复制运行中的 WAL 主文件。新增时间字段允许旧记录为空并显示“未知”,不得用最后更新时间伪造。迁移失败不启动业务;兼容旧库读取并以离线迁移测试验证。已有库若带历史人工核定字段,仅兼容保留旧数据,不要求删除字段或新增核定写入流程。 + +### 9.2 同步完整性与分页 + +- 每个大号的通知、作品分别维护同步状态:未建基线、同步中、完整、部分/截断、失败;同时保留最近成功时间及已提交覆盖范围。 +- “完整”仅表示到达本次平台接口可返回范围的正常结束,不承诺平台已删除或接口不可见的全部历史。 +- 未建完整基线时不能因命中实时缓存或任意已知 ID 提前停止。已建基线也只有在接口顺序/连续性已验证时才可使用增量停止点;作品置顶、重复页不得造成漏项。 +- 每页数据与对应检查点在同一事务提交;失败可保留已提交页,但完整标记只能在确认正常结束且所有数据落库后更新。游标不能领先于数据。 +- 重复游标、页数上限、账号身份失效、无效响应或用户取消均不得记录为完整成功;提供从最新页完整补齐的保守路径,不强依赖失效游标续传。 +- 只读同步不创建任务、不标记已读;UI 的分页条数及安全上限不得被标作平台总量。 + +### 9.3 冷却与时间口径 + +- 持久化时间统一使用 UTC 时间戳,展示时转换为本地时间;运行中的等待/截止时长使用单调时钟。 +- 同一原大号组和目标 UID 共用冷却;默认 4 小时,从成功持久化领取并即将发送该通知批次时开始,批内两个动作不能重复重置。保存已生效的截止时间,配置变更不能缩短它。 +- 重启从持久化期限恢复;unknown 继续保留既有去重/占用保护,不因冷却到期、只读查看、重启、删除或账号恢复而解除,不自动重发、不重置 pending、不改派,也不通过人工结论改写原领取时间或任务状态。 +- 既有保护可能使同组同目标后续事件持续等待或跳过;移除人工操作不表示自动放行。本次不设计自动判定、补偿或解除保护策略,不能把它描述为等待用户完成核定的操作流程。 +- 检测到时钟回拨、异常负间隔、事件/领取时间晚于当前时间或运行中墙钟跳变时保守暂停相关领取并提示核验,不用负数截断为零而直接放行;正常位于未来的冷却截止时间不属于异常。纯本地时间无法证明跨重启的大幅前跳不是正常流逝;本版不声称具有可信远端时钟或绝对跨重启防篡改能力。 + +### 9.4 账本失败与副作用边界 + +1. 领取前事务重查组就绪、身份/归属、规则开关、配额、冷却和 task 状态;成功提交运行状态及必要保护记录后才能发送关注/私信。写入失败则无外部动作。 +2. 关注/私信各自持久化结果,某个确定失败不取消同批另一个动作。网络超时、确认缺失或连接中断不能直接当作确定失败。 +3. 动作已发出但结果写入失败时,立即阻止继续领取并显示账本异常;保留原 running/不确定事实,待存储恢复后按恢复流程转 unknown。不得自动重放动作来“修复”账本。 +4. 无法确定故障范围时暂停所有领取;只有明确账号局部问题才限于该账号。数据库恢复后仍需重新核验并显式启动,不自动解除暂停。 +5. 普通诊断日志失败可降级,任务账本失败必须禁止新业务动作。日志、缓存清理与任务去重/不确定结果记录分开管理,不以静默删除历史来腾空间。 + +## 10. 操作安全与异常处理 + +- 每个账号使用独立 user-data-dir、独立浏览器实例和独立 CDP 端口。 +- CDP 只允许 loopback 连接;不使用第一个标签页代替身份选择。 +- 每次登录恢复、监听安装和写操作前核验实际 UID。 +- 页面接口或 SDK 结构变化时暂停对应能力并显示错误,不绕过身份检查。 +- 关注和私信超时不直接判定为失败;无法确认时为 unknown。 +- 程序重启时在任何 worker 启动前把遗留 running 转为 unknown,不自动重新执行;恢复上次选择不等于启动业务。 +- 停止优先关闭领取入口并在总预算内收尾;长同步、登录等待、UI 禁用都不能挡住停止信号。 +- unknown 的只读查询独立于活动账号树,不提供人工核定或改结果操作;删除/转移/恢复不清空原身份、去重、冷却和历史来源。 +- 领取状态未成功落库不得发送;结果落库失败不得继续消费或自动重试;删除目录必须先记录意图并支持人工重试部分失败。 +- 归属切换、删除小号与任务领取必须由数据库事务和运行状态共同约束,不能只依赖 UI 隐藏按钮。 +- 不通过多账号切换规避平台限制;关注、私信只在用户授权和平台规则允许的场景使用。 + +## 11. 实施拆分 + +### 第零阶段:安全与数据契约先行 + +- [ ] 保持启动前恢复及大号就绪屏障,增加启停防重入、运行代次和逐账号结果。 +- [ ] 将长同步移出串行命令等待路径,保证停止优先、快照更新及 60 秒总收尾预算。 +- [ ] 补任务阶段时间、同步完整性、配置基线及删除意图的最小持久化迁移,旧数据不伪造时间。 +- [ ] 明确规则即时限制与任务快照,完成 pending 取消和恢复积压确认。 +- [ ] 补删除账号后的 unknown 只读查询、同 UID 恢复原记录及删除部分失败处理;不暴露人工核定业务命令。 +- [ ] 用假 session、临时 SQLite 验证领取前/结果后写失败,不允许真实写操作参与回归。 + +### 第一阶段:账号树和详情框架 + +- [ ] 重排 `src/accounts_app.py` 主窗口布局。 +- [ ] 左侧树显示大号、小号、未分配小号和三类状态。 +- [ ] 移除账号专属底部按钮,增加角色感知右键菜单。 +- [ ] 保留顶部全局按钮和大号批量启动复选框。 +- [ ] 选中账号后刷新右侧摘要;按 account ID/request ID 隔离回包,选择、启动勾选与 dirty 草稿互不覆盖。 + +### 第二阶段:大号详情 + +- [ ] 右侧加入“历史事件 / 作品列表”两个 TAB。 +- [ ] 将完整基线、同步范围、过滤、勾选及带配置基线的二次确认接入历史 TAB。 +- [ ] 将作品 CARD、封面缓存、刷新间隔和作品范围保存接入作品 TAB。 +- [ ] 保持历史任务低优先级、实时任务高优先级。 + +### 第三阶段:小号详情和全局日志 + +- [ ] 小号详情显示当前任务、历史任务、pending 取消及只读不确定结果;复用任务表提供含已删除小号的全局/原组查询入口。 +- [ ] 底部日志固定显示最近 2000 条并支持自动滚动。 +- [ ] 显示日志目录错误和打开日志目录入口。 +- [ ] 账号树状态、详情计数和日志增量更新不得阻塞监听线程。 + +### 第四阶段:回归和交付 + +- [ ] 验证菜单按角色裁剪,历史事件、作品监控、执行记录和小号组启停不在小号右键重复出现,非法操作在后端仍会拒绝。 +- [ ] 验证大号、小号、未分配小号和多组账号互不串组。 +- [ ] 验证历史事件默认不勾选,未二次确认不创建任务。 +- [ ] 验证作品 CARD 选择、封面缓存和重启后的 SQLite 数据。 +- [ ] 验证实时/历史优先级、冷却、unknown 和停止等待语义。 +- [ ] 完成 §12 交错操作、迁移、分页及故障注入矩阵,再在 Windows x64 原生构建后进行 GUI 离线冒烟和人工安装验收。 + +## 12. 验收清单 + +- [ ] 添加大号和小号后,账号树层级、归属和状态清晰可见。 +- [ ] 大号复选框可批量启动,应用启动不会自动执行任务。 +- [ ] 右键菜单只显示当前账号角色支持的生命周期、归属和配置操作;小号不能直接启动或停止所属组。 +- [ ] 大号右侧只有历史事件和作品列表两个主要 TAB,相关入口不再在右键重复提供。 +- [ ] 小号右侧能区分当前任务与历史任务;unknown 只读展示,无人工核定按钮、结论提交或改状态操作。 +- [ ] 历史事件同步是只读的,默认不勾选,必须二次确认后才创建任务。 +- [ ] 作品列表以 CARD 显示,封面优先使用本地缓存,作品监控范围可保存。 +- [ ] 底部日志持续显示推送、分配、执行、结果和异常,界面最多保留 2000 条。 +- [ ] 同一通知不会重复派发;同一小号一次只处理一个通知批次。 +- [ ] 关注和私信同时开启时并行执行、结果独立保存。 +- [ ] 登录失效、身份不匹配、超时和 unknown 均不会触发盲目重试或自动登录。 +- [ ] 停止保留浏览器和登录目录;删除登录数据、删除小号是独立且有确认的操作。 +- [ ] 真实浏览器操作前完成 Windows 离线测试;真实关注、私信和标记已读不由自动化验收代替用户确认。 + +### 12.1 评审补充验收矩阵 + +| 场景 | 必须验证的结果 | 最小验证方式 | +| --- | --- | --- | +| 预置 running 后重启、恢复上次选择 | worker 出现前已转 unknown;只恢复勾选,无网络写动作 | 临时 SQLite + 假 session | +| 大号核验失败、部分小号失败 | 大号失败整组不领取;小号失败不改派且显示部分异常 | 可控身份响应 | +| 启动中停止、迟到连接成功 | 旧运行代次不能重新启动 worker | 可控 await | +| 慢历史/作品同步及登录等待时停止 | 接受停止后 1 秒内关闭领取入口,快照持续更新,不等同步结束 | 假分页/登录等待 + UI 冒烟 | +| 在途请求超过 60 秒预算 | 有限收尾;不确定结果为 unknown 或明确账本错误,不重发 | 假执行器/时钟 | +| 同一小号收到多条通知 | 只执行一个通知批次;批内两个动作并行且结果独立 | 假执行器 | +| 删除/移动 unknown 小号并重启 | 原组仍可只读查看,来源不改写,不要求旧账号活动;查看不改状态 | SQLite + UI | +| 查看 unknown 及尝试人工改结果 | 无人工核定按钮/快捷键/业务命令;unknown 不自动改成功/失败、重置或重发 | UI + 命令接口检查 | +| 删除后再次绑定同 UID | 显式恢复原 ID 或取消,不产生第二个身份、不恢复旧任务 | 临时 SQLite | +| 旧 pending、规则编辑、配额收紧 | 快照不变,当前安全限制生效,取消仅影响确认的 pending | 引擎/存储回归 | +| 历史预览后修改规则/作品范围、重复提交 | 配置变化需重新确认;重复提交不重复入队 | 假缓存 + SQLite | +| 一条实时缓存后首次历史同步、分页失败/上限 | 更早页不遗漏;失败/截断不显示完整成功 | 假分页接口 | +| 置顶作品、重复页、游标不推进 | 不误用单个已知 ID 停止;可完整补齐 | 假作品接口 | +| A 同步后切 B、编辑草稿时收快照 | 不串账号、不丢草稿;筛选在分页限制前完成 | UI 冒烟 | +| 旧库迁移、异常任务查询、冷却重启/时钟回拨 | 缺失时间显示未知;查询不改阶段时间/状态;检测异常保守暂停 | 临时旧库 + 假时钟 | +| 领取前/动作后 SQLite 写失败 | 前者无动作;后者停领且不盲目重试 | 故障注入 | +| profile 清理成功但数据库提交失败、进程中断 | 删除意图保留、禁止启动;显式重试不伤其它目录、不自动登录 | 临时目录 + 故障注入 | + +以上均为待验证项,不因文档合并打勾。优先复用现有测试、临时库和假执行器,不增加测试框架;本地通过后再进行 Windows GUI/安装验收,不用真实关注、私信或标记已读验证安全分支。 + +## 13. 明确不做 + +- 不在界面中承诺单机支持的固定账号数量或自动容量评估。 +- 不做分布式队列、远程日志服务、Windows Service 或多机调度。 +- 不做自动登录、验证码处理、风控绕过或 Cookie 跨机器迁移。 +- 不把浏览器页面点击作为最终业务执行方案;业务继续通过显式账号连接和页面内接口/SDK 完成。 +- 不将“缓存有数据”“详情已入库”“任务已分配”表述为关注或私信已经成功。 +- 不为上述约束新增通用状态机、调度框架、独立规则版本服务或自动 TTL;优先使用已有 asyncio、SQLite、账号标志和配置摘要。 diff --git a/src/account_engine.py b/src/account_engine.py index a1475cb..5b50ba5 100644 --- a/src/account_engine.py +++ b/src/account_engine.py @@ -3,6 +3,7 @@ import asyncio import logging import time +from contextlib import suppress from account_browser import BrowserManager, validate_config from account_log import DailyLog, visible @@ -10,6 +11,8 @@ from account_session import Session, SessionError from account_store import Store, decode from subscribe_notifications import notice_ids +STOP_BUDGET_SECONDS = 60 + class Engine: def __init__(self, root, playwright): @@ -29,8 +32,15 @@ class Engine: self.login_tasks = {} self.states = {} self.ready = set() + self.ready_generation = {} self.last_write = {} self.verified = set() + self.run_generation = {} + self.stopping = set() + self.readonly_tasks = set() + self.readonly_accounts = {} + self.ledger_blocked = bool(self.store.setting("ledger_blocked", False)) + self._stop_lock = asyncio.Lock() self.logger = self.audit.logger self.audit.record( "历史摘要", @@ -75,6 +85,11 @@ class Engine: return session def begin_login(self, ident): + if ident in self.stopping: + raise RuntimeError("账号正在停止收尾,完成前不能开始新的登录等待") + account = self.store.account(ident) + if account["delete_state"]: + raise RuntimeError("账号正在删除收尾,不能开始登录") task = self.login_tasks.get(ident) if task is None or task.done(): self.login_tasks[ident] = asyncio.create_task(self.enroll(ident)) @@ -96,10 +111,11 @@ class Engine: else "登录信息已回填,业务未自动启动", ) return - except (RuntimeError, ValueError) as exc: - self.state(ident, str(exc)) - await asyncio.sleep(3) - except Exception: + except Exception as exc: # noqa: BLE001 - browser boundary + if isinstance(exc, (RuntimeError, ValueError)): + self.state(ident, str(exc)) + await asyncio.sleep(3) + continue self.state( ident, "浏览器启动或信息读取失败,请检查设置后点击登录 / 刷新信息重试", @@ -112,56 +128,171 @@ class Engine: task.cancel() await asyncio.gather(task, return_exceptions=True) + def _active(self, ident, generation): + return ( + ident in self.desired + and ident not in self.stopping + and self.run_generation.get(ident) == generation + ) + + async def _run_readonly(self, ident, awaitable): + task = asyncio.current_task() + if task is not None: + self.readonly_tasks.add(task) + self.readonly_accounts[task] = ident + try: + return await awaitable + finally: + if task is not None: + self.readonly_tasks.discard(task) + self.readonly_accounts.pop(task, None) + + def start_preview(self, groups): + groups = list(dict.fromkeys(groups or [])) + if not groups: + raise ValueError("至少选择一个大号") + if self.ledger_blocked: + raise RuntimeError("任务账本异常,恢复存储并重新启动前不会领取任务") + rules = [] + for group in groups: + account = self.store.account(group) + if account["role"] != "main": + raise ValueError("只能按大号账号组启动") + if account["delete_state"]: + raise ValueError("账号正在删除收尾,不能启动") + rule = decode(account["rule"]) + rules.append( + { + "id": group, + "enabled": rule["enabled"], + "follow": rule["follow"], + "dm": rule["dm"], + "kinds": rule["kinds"], + "interval": rule["interval"], + } + ) + return { + "groups": groups, + "tasks": self.store.pending_tasks(groups), + "rules": rules, + } + async def start(self, groups): groups = list(dict.fromkeys(groups)) + if self.ledger_blocked: + raise RuntimeError("任务账本异常,恢复存储并重新启动前不会领取任务") + accounts = self.store.accounts() for group in groups: - if self.store.account(group)["role"] != "main": + account = self.store.account(group) + if account["role"] != "main": raise ValueError("只能按大号账号组启动") - self.store.set_setting("selected", groups) + if account["delete_state"]: + raise ValueError("账号正在删除收尾,不能启动") ids = { - a["id"] - for a in self.store.accounts() - if a["id"] in groups or a["owner"] in groups + account["id"] + for account in accounts + if account["id"] in groups or account["owner"] in groups } - if any(self.store.account(ident)["uid"] is None for ident in ids): - raise ValueError( - "请先在浏览器中完成所选组全部账号的登录,信息自动回填后再启动" - ) - self.desired.update(ids) + for ident in ids: + account = self.store.account(ident) + if account["delete_state"]: + raise ValueError("组内存在未完成的删除操作,不能启动") + if ident in self.stopping: + raise RuntimeError("账号正在停止收尾,完成前不能再次启动") + if account["uid"] is None: + raise ValueError( + "请先在浏览器中完成所选组全部账号的登录,信息自动回填后再启动" + ) + self.store.set_setting("selected", groups) + for ident in ids: + runner = self.runners.get(ident) + active = ident in self.desired and runner is not None and not runner.done() + if not active: + self.run_generation[ident] = self.run_generation.get(ident, 0) + 1 + self.stopping.discard(ident) + self.desired.add(ident) for group in groups: self.store.log( "业务启动", "用户手动启动账号组;将按已保存的规则处理通知", source=group, 组内账号数=sum( - a["id"] == group or a["owner"] == group - for a in self.store.accounts() + a["id"] == group or a["owner"] == group for a in accounts ), ) for ident in ids: - if ident not in self.runners or self.runners[ident].done(): + runner = self.runners.get(ident) + if runner is None or runner.done(): + generation = self.run_generation[ident] role = self.store.account(ident)["role"] self.runners[ident] = asyncio.create_task( - self.main_loop(ident) if role == "main" else self.worker_loop(ident) + self.main_loop(ident, generation) + if role == "main" + else self.worker_loop(ident, generation) ) async def stop(self, ids=None): - ids = set(self.desired | self.login_tasks.keys() if ids is None else ids) - for ident in ids: - await self.stop_login(ident) - self.desired.difference_update(ids) - self.ready.difference_update(ids) - for ident in ids: - self.store.log( - "停止请求", - "已停止领取新任务,等待在途动作结束;浏览器和登录保留", - account=ident, - ) - tasks = [self.runners[i] for i in ids if i in self.runners] - if tasks: - await asyncio.gather(*tasks, return_exceptions=True) - for ident in ids: - self.state(ident, "已停止(浏览器保留)") + async with self._stop_lock: + if ids is None: + ids = set(self.desired) | set(self.login_tasks) | set(self.runners) + else: + ids = set(ids) + if not ids: + return + started = time.monotonic() + self.stopping.update(ids) + self.desired.difference_update(ids) + self.ready.difference_update(ids) + for ident in ids: + self.run_generation[ident] = self.run_generation.get(ident, 0) + 1 + self.ready_generation.pop(ident, None) + self.store.log( + "停止请求", + "已关闭调度和领取入口,收尾期限为 60 秒;浏览器和登录保留", + account=ident, + ) + login_tasks = [] + for ident in ids: + task = self.login_tasks.pop(ident, None) + if task and not task.done(): + task.cancel() + login_tasks.append(task) + if login_tasks: + _, login_pending = await asyncio.wait( + login_tasks, + timeout=max(0, STOP_BUDGET_SECONDS - (time.monotonic() - started)), + ) + for task in login_pending: + task.cancel() + readonly = [ + task + for task in self.readonly_tasks + if self.readonly_accounts.get(task) in ids + ] + for task in readonly: + task.cancel() + if readonly: + await asyncio.gather(*readonly, return_exceptions=True) + runners = [self.runners[i] for i in ids if i in self.runners] + pending = set(runners) + if pending: + _, pending = await asyncio.wait( + pending, + timeout=max(0, STOP_BUDGET_SECONDS - (time.monotonic() - started)), + ) + if pending: + try: + self.store.mark_running_unknown(ids) + except Exception: # noqa: BLE001 - ledger failures must fail closed + self.ledger_blocked = True + with suppress(Exception): + self.store.set_setting("ledger_blocked", True) + for task in pending: + task.cancel() + await asyncio.gather(*pending, return_exceptions=True) + for ident in ids: + self.state(ident, "已停止(浏览器保留)") + self.stopping.difference_update(ids) async def stop_group(self, main): await self.stop( @@ -197,14 +328,21 @@ class Engine: self.store.defer_details(ident, set(pending) - returned) return self.store.pending_details_count(ident) - async def main_loop(self, ident): + async def main_loop(self, ident, generation=None): + if generation is None: + generation = self.run_generation.setdefault(ident, 0) session = None try: - while ident in self.desired: + while self._active(ident, generation): try: session = await self.open(ident) + if not self._active(ident, generation): + return await session.install() + if not self._active(ident, generation): + return self.ready.add(ident) + self.ready_generation[ident] = generation self.state(ident, "正在监听通知") checked = heartbeat = time.monotonic() pushed = added = 0 @@ -218,7 +356,7 @@ class Engine: "works_refresh_interval" ] ) - while ident in self.desired: + while self._active(ident, generation): if time.monotonic() - checked >= 15: await session.identity() checked = time.monotonic() @@ -228,7 +366,7 @@ class Engine: if refresh_interval and time.time() >= next_works_refresh: try: await self.fetch_works(ident, True, session) - except Exception as exc: + except Exception as exc: # noqa: BLE001 - scheduled sync boundary reason = ( str(exc) if isinstance(exc, (ValueError, RuntimeError)) @@ -264,6 +402,8 @@ class Engine: ) heartbeat = time.monotonic() events = await session.wait() + if not self._active(ident, generation): + return for event in events: if event.get("kind") == "close": raise SessionError("通知连接已断开") @@ -281,7 +421,9 @@ class Engine: 有效ID数=len(ids), ) added += self.store.record_push(ident, ids) - except Exception as exc: + except Exception as exc: # noqa: BLE001 - listener boundary + if not self._active(ident, generation): + return self.ready.discard(ident) self.verified.discard(ident) reason = ( @@ -295,6 +437,7 @@ class Engine: await self.pause(ident, 5) finally: self.ready.discard(ident) + self.ready_generation.pop(ident, None) if session: await session.uninstall() @@ -344,19 +487,26 @@ class Engine: *(self.perform(session, task) for task in tasks), return_exceptions=True ) return [ - ("unknown", {"code": "INTERRUPTED_CHECK_MANUALLY"}) + ("unknown", {"code": "RESULT_UNCONFIRMED", "message": "动作结果不确定"}) if isinstance(result, BaseException) else result for result in results ] - async def worker_loop(self, ident): + async def worker_loop(self, ident, generation=None): + if generation is None: + generation = self.run_generation.setdefault(ident, 0) session = None - while ident in self.desired: + while self._active(ident, generation): try: account = self.store.account(ident) owner = account["owner"] - if owner not in self.ready or owner not in self.desired: + if ( + owner not in self.ready + or owner not in self.desired + or self.ready_generation.get(owner) + != self.run_generation.get(owner) + ): self.state(ident, "等待大号恢复监听") await self.pause(ident, 1) continue @@ -372,15 +522,20 @@ class Engine: or session.page.is_closed() ): session = await self.open(ident) + if not self._active(ident, generation): + return await session.identity() self.verified.add(ident) # Immediately recheck the source before claiming; a stale listener flag is insufficient. await self.sessions[owner].identity() if ( - ident not in self.desired + not self._active(ident, generation) or owner not in self.ready or owner not in self.desired + or self.ready_generation.get(owner) + != self.run_generation.get(owner) or self.store.account(ident)["owner"] != owner + or self.ledger_blocked ): continue interval = decode(self.store.account(owner)["rule"])["interval"] @@ -397,13 +552,20 @@ class Engine: started = time.monotonic() results = await self.perform_batch(session, tasks) elapsed = round((time.monotonic() - started) * 1000) - for task, (status, result) in zip(tasks, results, strict=True): - self.store.finish(task["id"], status, result, elapsed) + try: + for task, (status, result) in zip(tasks, results, strict=True): + self.store.finish(task["id"], status, result, elapsed) + except Exception: + self.ledger_blocked = True + self.store.set_setting("ledger_blocked", True) + raise self.last_write[ident] = time.monotonic() else: self.state(ident, "等待组内任务") await self.pause(ident, 2) - except Exception as exc: + except Exception as exc: # noqa: BLE001 - worker boundary + if not self._active(ident, generation): + return self.verified.discard(ident) reason = ( str(exc) @@ -415,21 +577,56 @@ class Engine: session = None await self.pause(ident, 5) - async def fetch_history(self, ident): + async def fetch_history(self, ident, full=False): + if type(full) is not bool: + raise ValueError("历史同步参数无效") account = self.store.account(ident) if account["role"] != "main" or account["uid"] is None: raise ValueError("请选择已绑定身份的大号") session = await self.open(ident) - known = self.store.cached_notice_ids(ident) + state = self.store.sync_state(ident, "notices") + complete = isinstance(state, dict) and state.get("status") == "complete" + known = self.store.cached_notice_ids(ident) if complete and not full else set() + self.store.set_sync_state( + ident, + "notices", + status="syncing", + error="", + cursor=None, + ) self.store.log( "历史同步", - "开始增量同步历史事件;首次使用会全量缓存,不标记已读", + "开始历史事件同步;未完成基线不会因实时缓存提前停止,不标记已读", source=ident, 本地已有=len(known), ) - notices = await session.history_notices(known) - self.store.cache_notices(ident, notices, "history") - items = self.store.cached_notices(ident) + try: + notices = await session.history_notices(known) + self.store.cache_notices(ident, notices, "history") + items = self.store.cached_notices(ident) + self.store.set_sync_state( + ident, + "notices", + status="complete", + coverage=len(items), + last_success=time.time(), + error="", + ) + except asyncio.CancelledError: + self.store.set_sync_state( + ident, "notices", status="partial", error="用户取消同步" + ) + raise + except Exception as exc: + self.store.set_sync_state( + ident, + "notices", + status="failed", + error=str(exc) + if isinstance(exc, (ValueError, RuntimeError)) + else type(exc).__name__, + ) + raise self.store.log( "历史同步", "历史事件已保存到本地,列表已按当前作品范围过滤;尚未创建任务", @@ -437,9 +634,14 @@ class Engine: 本次返回=len(notices), 当前可选=len(items), ) - return {"account": ident, "items": items} + return { + "account": ident, + "items": items, + "sync": self.store.sync_state(ident, "notices"), + "baseline": self.store.config_fingerprint(ident), + } - def enqueue_history(self, ident, ids): + def enqueue_history(self, ident, ids, baseline=None): if ( not isinstance(ids, list) or len(ids) > 50000 @@ -453,6 +655,8 @@ class Engine: available = {item["nid"] for item in self.store.cached_notices(ident)} if any(item not in available for item in ids): raise ValueError("选择包含未缓存或不属于当前作品范围的历史事件") + if baseline is not None and baseline != self.store.config_fingerprint(ident): + raise ValueError("规则或作品范围已变化,请重新预览确认") rule = decode(self.store.account(ident)["rule"]) if not rule["enabled"] or not (rule["follow"] or rule["dm"]): raise ValueError("请先启用大号规则并至少选择关注或私信") @@ -476,26 +680,57 @@ class Engine: ) return {"selected": len(ids), "events": added, "tasks": tasks} - async def fetch_works(self, ident, force=False, session=None): + async def fetch_works(self, ident, force=False, session=None, full=False): account = self.store.account(ident) if account["role"] != "main" or account["uid"] is None: raise ValueError("请选择已绑定身份的大号") - if type(force) is not bool: + if type(force) is not bool or type(full) is not bool: raise ValueError("作品刷新参数无效") + state = self.store.sync_state(ident, "works") + complete = isinstance(state, dict) and state.get("status") == "complete" known = self.store.cached_work_ids(ident) refreshed = 0 - full = not known - if full or force: + full_sync = full or not known or not complete + if full_sync or force: session = session or await self.open(ident) + self.store.set_sync_state( + ident, "works", status="syncing", error="", cursor=None + ) self.store.log( "作品同步", - "首次建立全部历史作品缓存" if full else "开始增量刷新作品缓存", + "开始完整补齐作品缓存" if full_sync else "开始增量刷新作品缓存", source=ident, 本地已有=len(known), ) - works = await session.works(full, known if known else None) - refreshed = self.store.cache_works(ident, works) - self.store.set_setting("works_refreshed_at:" + ident, time.time()) + try: + works = await session.works( + full_sync, known if known and not full_sync else None + ) + refreshed = self.store.cache_works(ident, works) + self.store.set_setting("works_refreshed_at:" + ident, time.time()) + self.store.set_sync_state( + ident, + "works", + status="complete", + coverage=len(self.store.cached_work_ids(ident)), + last_success=time.time(), + error="", + ) + except asyncio.CancelledError: + self.store.set_sync_state( + ident, "works", status="partial", error="用户取消同步" + ) + raise + except Exception as exc: + self.store.set_sync_state( + ident, + "works", + status="failed", + error=str(exc) + if isinstance(exc, (ValueError, RuntimeError)) + else type(exc).__name__, + ) + raise items = self.store.cached_works(ident) rule = decode(account["rule"]) self.store.log( @@ -512,6 +747,7 @@ class Engine: "selected": rule["work_ids"], "refresh_interval": rule["works_refresh_interval"], "refreshed": refreshed, + "sync": self.store.sync_state(ident, "works"), } def save_work_filter(self, ident, mode, ids, refresh_interval): @@ -540,20 +776,25 @@ class Engine: labels = { "add": "添加账号", "rule": "保存规则", + "start_preview": "启动前检查", "start": "启动组", + "cancel_pending": "取消待执行任务", + "task_query": "查询异常任务", "stop": "停止全部", "stop_group": "停止组", "open": "登录或刷新资料", "move": "切换归属", "browser_config": "浏览器设置", - "resolve": "人工核对", "close_browser": "关闭浏览器", "erase": "清除登录数据", "delete_worker": "删除小号", - "history_fetch": "获取全部历史事件", + "restore_worker": "恢复已删除小号", + "history_fetch": "获取历史事件", + "history_full": "完整补齐历史事件", "history_enqueue": "确认历史事件操作", "works_open": "打开作品监控", "works_refresh": "强制增量刷新作品", + "works_full": "完整补齐作品", "work_filter": "保存作品监控范围", } label = labels.get(name, "未知操作") @@ -584,8 +825,40 @@ class Engine: return ident if name == "rule": self.store.set_rule(data["id"], data["rule"]) + elif name == "start_preview": + return self.start_preview(data.get("groups")) elif name == "start": await self.start(data["groups"]) + elif name == "cancel_pending": + cancelled = 0 + batches = data.get("tasks") + if batches is not None: + task_ids_by_worker = {} + for task in batches: + worker = task.get("worker") + task_id = task.get("id") + if worker and task_id is not None: + task_ids_by_worker.setdefault(worker, []).append(task_id) + for worker, task_ids in task_ids_by_worker.items(): + if self.store.account(worker)["role"] != "worker": + raise ValueError("旧 pending 任务的执行账号无效") + cancelled += self.store.cancel_pending( + worker, + "用户确认取消旧 pending 任务", + task_ids, + ) + else: + worker = data["id"] + if self.store.account(worker)["role"] != "worker": + raise ValueError("只能取消小号的 pending 任务") + cancelled = self.store.cancel_pending( + worker, + "用户在任务详情中取消 pending 任务", + data.get("task_ids"), + ) + return {"cancelled": cancelled} + elif name == "task_query": + return self.task_query(data.get("source"), data.get("worker")) elif name == "stop": await self.stop() elif name == "stop_group": @@ -594,11 +867,28 @@ class Engine: self.store.account(data["id"]) self.begin_login(data["id"]) elif name == "history_fetch": - return await self.fetch_history(data["id"]) + return await self._run_readonly( + data["id"], self.fetch_history(data["id"], full=False) + ) + elif name == "history_full": + return await self._run_readonly( + data["id"], self.fetch_history(data["id"], full=True) + ) elif name == "history_enqueue": - return self.enqueue_history(data["id"], data.get("ids")) - elif name in ("works_open", "works_refresh"): - return await self.fetch_works(data["id"], name == "works_refresh") + return self.enqueue_history( + data["id"], data.get("ids"), data.get("baseline") + ) + elif name in ("works_open", "works_refresh", "works_full"): + return await self._run_readonly( + data["id"], + self.fetch_works( + data["id"], + name == "works_refresh", + full=name == "works_full", + ), + ) + elif name == "restore_worker": + return self.store.restore_worker(data["id"], data.get("owner")) elif name == "work_filter": return self.save_work_filter( data["id"], @@ -625,8 +915,6 @@ class Engine: "fingerprint_browser" + (":" + data["id"] if data.get("id") else ""), config, ) - elif name == "resolve": - self.store.resolve(data["task"], data["succeeded"]) elif name in ("close_browser", "erase", "delete_worker"): ident = data["id"] account = self.store.account(ident) @@ -640,6 +928,10 @@ class Engine: ).fetchone() ): raise ValueError("请先停止相关组并等待在途任务结束") + if name in ("erase", "delete_worker"): + root = self.manager.account_dir(ident) + self.store.begin_deletion(ident, name, (root,)) + self.store.mark_deletion(ident, "stopping_browser") await self.stop_login(ident) session = self.sessions.get(ident) if not session or not session.browser or not session.browser.is_connected(): @@ -664,10 +956,16 @@ class Engine: if name in ("erase", "delete_worker"): self.store.log( "删除数据", - "浏览器已关闭,开始删除该账号独立登录目录;失败时账号保留,可重试", + "浏览器已关闭,开始删除该账号独立登录目录;失败时账号保留并禁止启动,可显式重试", account=ident, ) - await asyncio.to_thread(self.manager.erase, ident) + self.store.mark_deletion(ident, "browser_closed") + try: + await asyncio.to_thread(self.manager.erase, ident) + except Exception as exc: + self.store.mark_deletion(ident, "erase_failed", type(exc).__name__) + raise + self.store.mark_deletion(ident, "directory_removed") if name == "delete_worker": self.store.delete_worker(ident) self.runners.pop(ident, None) @@ -678,6 +976,7 @@ class Engine: "UPDATE accounts SET nickname='',avatar='',profile='{}' WHERE id=?", (ident,), ) + self.store.complete_deletion(ident) elif await asyncio.to_thread(self.manager.live, ident): raise ValueError("无法安全关闭旧浏览器,请手动关闭后重试") self.sessions.pop(ident, None) @@ -689,8 +988,41 @@ class Engine: else: raise ValueError("未知操作") + def task_query(self, source=None, worker=None): + if source: + source_account = self.store.account_any(source) + if source_account["role"] != "main": + raise ValueError("原所属只能选择大号") + if worker: + worker_account = self.store.account_any(worker) + if worker_account["role"] != "worker": + raise ValueError("原执行账号只能选择小号") + accounts = self.store.accounts(include_deleted=True) + return { + "tasks": self.store.tasks( + worker=worker, source=source, statuses=["unknown"] + ), + "task_names": { + account["id"]: account["name"] + + ("(已删除)" if account["deleted"] else "") + for account in accounts + }, + } + def snapshot(self): accounts = self.store.accounts() + deleted_accounts = [ + { + "id": account["id"], + "name": account["name"], + "uid": account["uid"], + "delete_state": account["delete_state"], + "delete_step": account["delete_step"], + "delete_error": account["delete_error"], + } + for account in self.store.accounts(include_deleted=True) + if account["deleted"] + ] for account in accounts: ident = account["id"] session = self.sessions.get(ident) @@ -708,6 +1040,10 @@ class Engine: else ("等待首次登录" if account["uid"] is None else "未通过/未核验") ) account["state"] = self.states.get(ident, "未启动") + account["sync"] = { + "notices": self.store.sync_state(ident, "notices"), + "works": self.store.sync_state(ident, "works"), + } counts = { r["status"]: r["n"] for r in self.store.db.execute( @@ -716,6 +1052,7 @@ class Engine: } return { "accounts": accounts, + "deleted_accounts": deleted_accounts, "counts": counts, "tasks": self.store.tasks(), "task_names": { diff --git a/src/account_store.py b/src/account_store.py index 201d683..cbe8043 100644 --- a/src/account_store.py +++ b/src/account_store.py @@ -1,5 +1,6 @@ """SQLite task truth; each connection belongs to its calling thread.""" +import hashlib import json import logging import sqlite3 @@ -11,7 +12,6 @@ from pathlib import Path from account_log import UI_LIMIT, visible from follow_user import validate_uid - NOTICE_KIND_LABELS = { "digg": "点赞", "follow": "关注", @@ -188,6 +188,12 @@ class Store: avatar TEXT NOT NULL DEFAULT '', nickname TEXT NOT NULL DEFAULT '', rule TEXT NOT NULL, rr INTEGER NOT NULL DEFAULT 0, profile TEXT NOT NULL DEFAULT '{}', + deleted INTEGER NOT NULL DEFAULT 0, + delete_state TEXT NOT NULL DEFAULT '', + delete_kind TEXT NOT NULL DEFAULT '', + delete_paths TEXT NOT NULL DEFAULT '[]', + delete_step TEXT NOT NULL DEFAULT '', + delete_error TEXT NOT NULL DEFAULT '', CHECK(role='worker' OR owner IS NULL)); CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL); CREATE TABLE IF NOT EXISTS inbox ( @@ -207,6 +213,8 @@ class Store: priority INTEGER NOT NULL DEFAULT 100, status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','running','succeeded','failed','unknown','cancelled')), result TEXT NOT NULL DEFAULT '', updated REAL NOT NULL, + created_at REAL, started_at REAL, finished_at REAL, + cancel_reason TEXT NOT NULL DEFAULT '', UNIQUE(event,worker,target,action)); CREATE TABLE IF NOT EXISTS cooldowns ( source TEXT NOT NULL REFERENCES accounts(id), target TEXT NOT NULL, @@ -232,18 +240,42 @@ class Store: DROP INDEX IF EXISTS worker_single_running; """) self.migrate_accounts() - if "deleted" not in { + account_columns = { r["name"] for r in self.db.execute("PRAGMA table_info(accounts)") - }: + } + if "deleted" not in account_columns: self.db.execute( "ALTER TABLE accounts ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0" ) - columns = {r["name"] for r in self.db.execute("PRAGMA table_info(inbox)")} - event_columns = { - r["name"] for r in self.db.execute("PRAGMA table_info(events)") - } - task_columns = {r["name"] for r in self.db.execute("PRAGMA table_info(tasks)")} + account_columns.add("deleted") with self.transaction(): + if "delete_state" not in account_columns: + self.db.execute( + "ALTER TABLE accounts ADD COLUMN delete_state TEXT NOT NULL DEFAULT ''" + ) + if "delete_kind" not in account_columns: + self.db.execute( + "ALTER TABLE accounts ADD COLUMN delete_kind TEXT NOT NULL DEFAULT ''" + ) + if "delete_paths" not in account_columns: + self.db.execute( + "ALTER TABLE accounts ADD COLUMN delete_paths TEXT NOT NULL DEFAULT '[]'" + ) + if "delete_step" not in account_columns: + self.db.execute( + "ALTER TABLE accounts ADD COLUMN delete_step TEXT NOT NULL DEFAULT ''" + ) + if "delete_error" not in account_columns: + self.db.execute( + "ALTER TABLE accounts ADD COLUMN delete_error TEXT NOT NULL DEFAULT ''" + ) + columns = {r["name"] for r in self.db.execute("PRAGMA table_info(inbox)")} + event_columns = { + r["name"] for r in self.db.execute("PRAGMA table_info(events)") + } + task_columns = { + r["name"] for r in self.db.execute("PRAGMA table_info(tasks)") + } if "retry_count" not in columns: self.db.execute( "ALTER TABLE inbox ADD COLUMN retry_count INTEGER NOT NULL DEFAULT 0" @@ -260,12 +292,23 @@ class Store: self.db.execute( "ALTER TABLE tasks ADD COLUMN priority INTEGER NOT NULL DEFAULT 100" ) + if "created_at" not in task_columns: + self.db.execute("ALTER TABLE tasks ADD COLUMN created_at REAL") + if "started_at" not in task_columns: + self.db.execute("ALTER TABLE tasks ADD COLUMN started_at REAL") + if "finished_at" not in task_columns: + self.db.execute("ALTER TABLE tasks ADD COLUMN finished_at REAL") + if "cancel_reason" not in task_columns: + self.db.execute( + "ALTER TABLE tasks ADD COLUMN cancel_reason TEXT NOT NULL DEFAULT ''" + ) for row in self.db.execute("SELECT id,rule FROM accounts").fetchall(): rule = validate_rule(decode(row["rule"])) self.db.execute( "UPDATE accounts SET rule=? WHERE id=?", (json.dumps(rule), row["id"]), ) + self.set_setting("schema_version", 3) self.db.executescript(""" CREATE TRIGGER IF NOT EXISTS owner_insert BEFORE INSERT ON accounts WHEN NEW.owner IS NOT NULL AND NOT EXISTS(SELECT 1 FROM accounts WHERE id=NEW.owner AND role='main') @@ -371,6 +414,36 @@ class Store: "INSERT OR REPLACE INTO settings VALUES (?,?)", (key, json.dumps(value)) ) + def config_fingerprint(self, ident): + account = self.account(ident) + payload = json.dumps( + decode(account["rule"]), sort_keys=True, ensure_ascii=False + ) + return hashlib.sha256(payload.encode("utf-8")).hexdigest() + + def sync_state(self, ident, kind): + if kind not in ("notices", "works"): + raise ValueError("同步类型无效") + self.account(ident) + return self.setting( + f"sync:{ident}:{kind}", + { + "status": "not_started", + "cursor": None, + "coverage": 0, + "last_success": None, + "error": "", + }, + ) + + def set_sync_state(self, ident, kind, **values): + raw = self.sync_state(ident, kind) + state = dict(raw) if isinstance(raw, dict) else {} + for key, value in values.items(): + state[key] = value + self.set_setting(f"sync:{ident}:{kind}", state) + return state + def accounts(self, include_deleted=False): return [ dict(r) @@ -388,6 +461,12 @@ class Store: raise ValueError("账号不存在") return dict(row) + def account_any(self, ident): + row = self.db.execute("SELECT * FROM accounts WHERE id=?", (ident,)).fetchone() + if row is None: + raise ValueError("账号不存在") + return dict(row) + def add(self, role, name, uid=None, owner=None): if ( role not in ("main", "worker") @@ -397,7 +476,16 @@ class Store: ): raise ValueError("账号角色或名称无效") if uid is not None: - validate_uid(uid) + uid = validate_uid(uid) + existing = self.db.execute( + "SELECT deleted FROM accounts WHERE uid=?", (uid,) + ).fetchone() + if existing is not None: + if existing["deleted"]: + raise ValueError( + "该 UID 已有账号记录,请使用显式恢复入口,不创建第二个身份" + ) + raise sqlite3.IntegrityError("该抖音账号已添加") ident = uuid.uuid4().hex self.db.execute( "INSERT INTO accounts(id,role,name,uid,owner,rule) VALUES (?,?,?,?,?,?)", @@ -411,14 +499,76 @@ class Store: ) return ident + def begin_deletion(self, ident, kind, paths=()): + if kind not in ("erase", "delete_worker"): + raise ValueError("删除类型无效") + paths = [str(path) for path in paths] + with self.transaction(): + account = self.account(ident) + if kind == "delete_worker" and account["role"] != "worker": + raise ValueError("此操作只支持删除小号") + if account["delete_state"]: + if account["delete_kind"] != kind: + raise ValueError("账号已有未完成的删除操作") + return account + self.db.execute( + "UPDATE accounts SET delete_state='pending',delete_kind=?,delete_paths=?,delete_step='intent',delete_error='' WHERE id=?", + (kind, json.dumps(list(paths)), ident), + ) + self.log( + "删除意图", + "删除范围已持久化;清理失败前禁止启动和领取", + account=ident, + 类型=kind, + 受管路径数=len(paths), + ) + return self.account(ident) + + def mark_deletion(self, ident, step, error=""): + self.account_any(ident) + self.db.execute( + "UPDATE accounts SET delete_state=?,delete_step=?,delete_error=? WHERE id=?", + ("error" if error else "pending", step, error, ident), + ) + + def complete_deletion(self, ident): + self.account_any(ident) + self.db.execute( + "UPDATE accounts SET delete_state='',delete_kind='',delete_paths='[]',delete_step='complete',delete_error='' WHERE id=?", + (ident,), + ) + + def restore_worker(self, ident, owner=None): + with self.transaction(): + account = self.account_any(ident) + if account["role"] != "worker" or not account["deleted"]: + raise ValueError("没有可恢复的已删除小号") + if account["delete_state"]: + raise ValueError("删除操作尚未完成,不能恢复") + if owner is not None and self.account(owner)["role"] != "main": + raise ValueError("归属必须为大号") + self.db.execute( + "UPDATE accounts SET deleted=0,owner=?,delete_kind='',delete_paths='[]',delete_step='',delete_error='' WHERE id=?", + (owner, ident), + ) + self.log( + "小号恢复", + "已恢复原账号 ID、UID 和历史引用;保持停止,需重新手动登录核验", + worker=ident, + owner=owner or "", + ) + return self.account(ident) + def bind_profile(self, ident, profile): uid = validate_uid(profile.get("uid")) saved = visible(profile) if not isinstance(saved, dict): - raise ValueError("账号资料格式无效") + raise ValueError("账号资料格式无效") # noqa: TRY004 - public validation API saved["uid"] = uid with self.transaction(): account = self.account(ident) + if account["delete_state"]: + raise ValueError("账号正在删除收尾,不能重新绑定身份") if account["uid"] is not None and account["uid"] != uid: raise ValueError( "当前登录账号与已绑定账号不同,请登录原账号;不会覆盖原 UID" @@ -635,6 +785,8 @@ class Store: account = self.account(worker) if account["role"] != "worker": raise ValueError("只有小号可切换归属") + if account["delete_state"]: + raise ValueError("账号正在删除收尾,不能切换归属") if owner is not None and self.account(owner)["role"] != "main": raise ValueError("归属必须为大号") if account["owner"] == owner: @@ -652,11 +804,22 @@ class Store: owner=owner or "", ) - def cancel_pending(self, worker, reason): - rows = self.db.execute( - "UPDATE tasks SET status='cancelled',result=?,updated=? WHERE worker=? AND status='pending' RETURNING id,event,source,target,action", - (reason, time.time(), worker), - ).fetchall() + def cancel_pending(self, worker, reason, task_ids=None): + now = time.time() + if task_ids is None: + rows = self.db.execute( + "UPDATE tasks SET status='cancelled',result=?,cancel_reason=?,finished_at=?,updated=? WHERE worker=? AND status='pending' RETURNING id,event,source,target,action", + (reason, reason, now, now, worker), + ).fetchall() + else: + rows = [] + for task_id in dict.fromkeys(task_ids): + row = self.db.execute( + "UPDATE tasks SET status='cancelled',result=?,cancel_reason=?,finished_at=?,updated=? WHERE id=? AND worker=? AND status='pending' RETURNING id,event,source,target,action", + (reason, reason, now, now, task_id, worker), + ).fetchone() + if row: + rows.append(row) for row in rows: self.log( "任务取消", @@ -668,11 +831,12 @@ class Store: target=row["target"], action=row["action"], ) + return len(rows) def delete_worker(self, ident): - """Call only after the engine has safely stopped/closed/erased this profile.""" + """Finalize a previously persisted deletion intent after profile cleanup.""" with self.transaction(): - account = self.account(ident) + account = self.account_any(ident) if account["role"] != "worker": raise ValueError("此操作只支持删除小号") if self.db.execute( @@ -681,8 +845,8 @@ class Store: raise ValueError("正在执行任务,不能删除小号") self.cancel_pending(ident, "小号已删除,取消待执行任务,不改派、不重发") self.db.execute( - "UPDATE accounts SET deleted=1,uid=NULL,owner=NULL,nickname='',avatar='',profile='{}',rule=?,rr=0 WHERE id=?", - (json.dumps(DEFAULT_RULE), ident), + "UPDATE accounts SET deleted=1,owner=NULL,delete_state='',delete_kind='',delete_paths='[]',delete_step='complete',delete_error='' WHERE id=?", + (ident,), ) self.db.execute( "DELETE FROM settings WHERE key IN (?,?)", @@ -690,7 +854,7 @@ class Store: ) self.log( "小号删除", - "浏览器与登录目录已清除;账号已移除,历史任务和日志保留,允许重新添加原账号", + "浏览器与登录目录已清除;账号已移除,UID、历史任务和日志保留,只能显式恢复原记录", worker=ident, ) @@ -947,9 +1111,10 @@ class Store: if row["status"] == "pending" and row["origin"] == "history" ] for row in history: + now = time.time() self.db.execute( - "UPDATE tasks SET status='cancelled',result='新消息优先,取消未执行历史任务',updated=? WHERE id=? AND status='pending'", - (time.time(), row["id"]), + "UPDATE tasks SET status='cancelled',result='新消息优先,取消未执行历史任务',cancel_reason='新消息优先,取消未执行历史任务',finished_at=?,updated=? WHERE id=? AND status='pending'", + (now, now, row["id"]), ) self.log( "新消息优先", @@ -964,7 +1129,7 @@ class Store: skipped += 1 self.log( "冷却跳过", - "同组同 UID 已有待执行、执行中或待核对任务,不重复排队", + "同组同 UID 已有待执行、执行中或结果不确定任务,不重复排队", source=source, event=event["id"], target=target, @@ -992,7 +1157,7 @@ class Store: if not rule[action]: continue cur = self.db.execute( - "INSERT INTO tasks(event,source,worker,target,action,params,dependency,priority,updated) VALUES (?,?,?,?,?,?,NULL,?,?)", + "INSERT INTO tasks(event,source,worker,target,action,params,dependency,priority,status,result,updated,created_at) VALUES (?,?,?,?,?,?,NULL,?,'pending','',?,?)", ( event["id"], source, @@ -1002,6 +1167,7 @@ class Store: json.dumps(rule), 100 if event["origin"] == "live" else 0, now, + now, ), ) created += 1 @@ -1036,9 +1202,12 @@ class Store: account["role"] != "worker" or account["owner"] is None or account["uid"] is None + or account["delete_state"] ): return [] owner = self.account(account["owner"]) + if owner["delete_state"]: + return [] if owner["uid"] is None or not decode(owner["rule"])["enabled"]: return [] if self.db.execute( @@ -1046,10 +1215,10 @@ class Store: ).fetchone(): return [] cancelled = self.db.execute( - """UPDATE tasks SET status='cancelled',result='前置关注未成功',updated=? + """UPDATE tasks SET status='cancelled',result='前置关注未成功',cancel_reason='前置关注未成功',finished_at=?,updated=? WHERE worker=? AND status='pending' AND dependency IN (SELECT id FROM tasks WHERE status IN ('failed','cancelled')) RETURNING id,event,action""", - (time.time(), worker), + (time.time(), time.time(), worker), ).fetchall() for row in cancelled: self.log( @@ -1076,7 +1245,7 @@ class Store: if key and self._dependency_wait.get(worker) != key: self.log( "依赖等待", - "旧版前置关注尚未确认成功;待核对不会自动重发", + "旧版前置关注尚未成功;结果不确定不会自动重发", worker=worker, source=owner["id"], task=blocked["id"], @@ -1095,8 +1264,8 @@ class Store: ).fetchall() now = time.time() self.db.executemany( - "UPDATE tasks SET status='running',updated=? WHERE id=?", - [(now, task["id"]) for task in tasks], + "UPDATE tasks SET status='running',started_at=COALESCE(started_at,?),updated=? WHERE id=? AND status='pending'", + [(now, now, task["id"]) for task in tasks], ) cooldown = decode(first["params"]).get("cooldown", 14400) if cooldown: @@ -1127,7 +1296,7 @@ class Store: for task in tasks: self.log( "任务开始", - "已领取任务,即将发起动作;中断后需人工核对,不自动重发", + "已领取任务,即将发起动作;中断后结果不确定,不自动重发", source=task["source"], worker=worker, event=task["event"], @@ -1147,15 +1316,17 @@ class Store: def finish(self, task_id, status, result, elapsed_ms=None): if status not in ("succeeded", "failed", "unknown"): raise ValueError("无效执行结果") + now = time.time() + finished = now if status in ("succeeded", "failed") else None task = self.db.execute( - "UPDATE tasks SET status=?,result=?,updated=? WHERE id=? AND status='running' RETURNING id,event,source,worker,target,action", - (status, json.dumps(result, ensure_ascii=False), time.time(), task_id), + "UPDATE tasks SET status=?,result=?,finished_at=?,updated=? WHERE id=? AND status='running' RETURNING id,event,source,worker,target,action", + (status, json.dumps(result, ensure_ascii=False), finished, now, task_id), ).fetchone() if task: reason = { "succeeded": "动作成功,结果已落库", "failed": "动作明确失败,不自动重发", - "unknown": "结果不确定,必须人工核对;不会自动重发,依赖步骤继续等待", + "unknown": "结果不确定,仅作只读记录;不会自动重发或修改状态", }[status] code = ( result.get("code", result.get("status_code")) @@ -1178,13 +1349,19 @@ class Store: def recover(self): rows = self.db.execute( - "UPDATE tasks SET status='unknown',result='程序中断,先人工核对,不自动重发',updated=? WHERE status='running' RETURNING id,event,source,worker,action", - (time.time(),), + "UPDATE tasks SET status='unknown',result=?,finished_at=NULL,updated=? WHERE status='running' RETURNING id,event,source,worker,action", + ( + json.dumps( + {"code": "PROCESS_INTERRUPTED", "message": "程序中断,结果不确定"}, + ensure_ascii=False, + ), + time.time(), + ), ).fetchall() for row in rows: self.log( "中断恢复", - "上次执行中断,已改为待核对,不自动重发", + "上次执行中断,已记录为结果不确定;不会自动重发或修改状态", logging.WARNING, task=row["id"], event=row["event"], @@ -1193,31 +1370,74 @@ class Store: action=row["action"], ) - def resolve(self, task_id, succeeded): - task = self.db.execute( - "UPDATE tasks SET status=?,result='用户已人工核对确认',updated=? WHERE id=? AND status='unknown' RETURNING worker,source,event", - ("succeeded" if succeeded else "failed", time.time(), task_id), - ).fetchone() - if task: - self.log( - "人工核对", - "用户确认成功,依赖步骤可继续" - if succeeded - else "用户确认未成功,不重发", - task=task_id, - event=task["event"], - source=task["source"], - worker=task["worker"], - ) + def mark_running_unknown(self, workers=None, reason="停止收尾超时,结果不确定"): + now = time.time() + result = json.dumps( + {"code": "STOP_TIMEOUT", "message": reason}, ensure_ascii=False + ) + if workers is None: + rows = self.db.execute( + "UPDATE tasks SET status='unknown',result=?,finished_at=NULL,updated=? WHERE status='running' RETURNING id,event,source,worker,action", + (result, now), + ).fetchall() else: + rows = [] + for worker in dict.fromkeys(workers): + rows.extend( + self.db.execute( + "UPDATE tasks SET status='unknown',result=?,finished_at=NULL,updated=? WHERE status='running' AND worker=? RETURNING id,event,source,worker,action", + (result, now, worker), + ).fetchall() + ) + for row in rows: self.log( - "人工核对", - "没有可确认的待核对任务,未修改记录", + "任务结果不确定", + reason, logging.WARNING, - task=task_id, + task=row["id"], + event=row["event"], + source=row["source"], + worker=row["worker"], + action=row["action"], ) + return len(rows) - def tasks(self): + def pending_tasks(self, sources): + sources = list(dict.fromkeys(source for source in sources if source)) + rows = [] + for source in sources: + rows.extend( + dict(row) + for row in self.db.execute( + "SELECT id,source,worker,created_at FROM tasks " + "WHERE source=? AND status='pending' " + "ORDER BY created_at,id", + (source,), + ) + ) + return sorted(rows, key=lambda row: (row.get("created_at") or 0, row["id"])) + + def tasks(self, worker=None, source=None, statuses=None, limit=UI_LIMIT): + allowed = ("pending", "running", "succeeded", "failed", "unknown", "cancelled") + selected = set(statuses or allowed) + status_values = [ + status if status in selected else "__not_selected__" for status in allowed + ] + rows = self.db.execute( + """SELECT * FROM tasks + WHERE (? IS NULL OR worker=?) AND (? IS NULL OR source=?) + AND status IN (?,?,?,?,?,?) + ORDER BY id DESC LIMIT ?""", + ( + worker, + worker, + source, + source, + *status_values, + limit, + ), + ) + return [dict(r) for r in rows] return [ dict(r) for r in self.db.execute( diff --git a/src/accounts_app.py b/src/accounts_app.py index e637bdd..f08f949 100644 --- a/src/accounts_app.py +++ b/src/accounts_app.py @@ -7,14 +7,17 @@ import json import queue import sys import tempfile -from datetime import datetime +import uuid +from contextlib import suppress +from datetime import datetime, timezone from pathlib import Path from patchright.async_api import async_playwright -from PySide6.QtCore import Qt, QThread, QTimer, QUrl, Signal +from PySide6.QtCore import QEvent, Qt, QThread, QTimer, QUrl, Signal from PySide6.QtGui import QDesktopServices, QIcon, QPixmap from PySide6.QtNetwork import QNetworkAccessManager, QNetworkRequest from PySide6.QtWidgets import ( + QAbstractItemView, QApplication, QCheckBox, QComboBox, @@ -29,18 +32,19 @@ from PySide6.QtWidgets import ( QLabel, QLineEdit, QMainWindow, + QMenu, QMessageBox, QPlainTextEdit, QPushButton, QScrollArea, QSizePolicy, QSpinBox, + QSplitter, + QStackedWidget, QTableWidget, QTableWidgetItem, QTabWidget, QToolButton, - QTreeWidget, - QTreeWidgetItem, QVBoxLayout, QWidget, ) @@ -50,7 +54,6 @@ from account_engine import Engine from account_log import UI_LIMIT, DailyLog from account_store import NOTICE_KIND_LABELS, decode, validate_rule - IMAGE_HOST_SUFFIXES = ( "douyinpic.com", "byteimg.com", @@ -62,8 +65,10 @@ IMAGE_HOST_SUFFIXES = ( def allowed_image_url(value): url = QUrl(value or "") host = url.host().lower() - return bool(value) and url.scheme() == "https" and any( - host.endswith("." + suffix) for suffix in IMAGE_HOST_SUFFIXES + return ( + bool(value) + and url.scheme() == "https" + and any(host.endswith("." + suffix) for suffix in IMAGE_HOST_SUFFIXES) ) @@ -156,14 +161,18 @@ class WorkCard(QFrame): def _refresh_description(self): width = max(1, self.description_label.width()) - full_height = self.description_label.fontMetrics().boundingRect( - 0, - 0, - width, - 10000, - Qt.TextFlag.TextWordWrap, - self._description, - ).height() + full_height = ( + self.description_label.fontMetrics() + .boundingRect( + 0, + 0, + width, + 10000, + Qt.TextFlag.TextWordWrap, + self._description, + ) + .height() + ) expandable = full_height > self._description_collapsed_height self.description_toggle.setVisible(expandable) if not expandable: @@ -207,7 +216,11 @@ def date_text(value): if stamp > 10_000_000_000: stamp /= 1000 try: - return datetime.fromtimestamp(stamp).strftime("%Y-%m-%d %H:%M:%S") + return ( + datetime.fromtimestamp(stamp, timezone.utc) + .astimezone() + .strftime("%Y-%m-%d %H:%M:%S") + ) except (OSError, OverflowError, ValueError): return "未知时间" @@ -259,6 +272,7 @@ class Backend(QThread): super().__init__() self.root = root self.commands = queue.Queue() + self.command_tasks = set() def post(self, name, data=None): self.commands.put((name, data or {})) @@ -267,6 +281,31 @@ class Backend(QThread): async with async_playwright() as playwright: engine = Engine(self.root, playwright) cursor = 0 + + async def handle(name, data): + try: + result = await engine.command(name, data) + if result is not None: + if isinstance(result, dict) and data.get("_request_id"): + result = { + **result, + "_request_id": data["_request_id"], + } + self.data_ready.emit(name, result) + self.response.emit(name, True, "操作完成") + except asyncio.CancelledError: + raise + except Exception as exc: # noqa: BLE001 - UI boundary + # Only explicitly safe human-facing errors; never emit CDP stack/URLs. + message = ( + str(exc) + if isinstance(exc, (ValueError, RuntimeError)) + else "操作失败,请检查配置与账号状态" + ) + self.response.emit(name, False, message) + finally: + self.command_tasks.discard(asyncio.current_task()) + try: while True: self.snapshot_ready.emit(engine.snapshot()) @@ -280,26 +319,19 @@ class Backend(QThread): continue if name == "quit": break - try: - result = await engine.command(name, data) - if result is not None: - self.data_ready.emit(name, result) - self.response.emit(name, True, "操作完成") - except Exception as exc: - # Only explicitly safe human-facing errors; never emit CDP stack/URLs. - message = ( - str(exc) - if isinstance(exc, (ValueError, RuntimeError)) - else "操作失败,请检查配置与账号状态" - ) - self.response.emit(name, False, message) + task = asyncio.create_task(handle(name, data)) + self.command_tasks.add(task) finally: + for task in list(self.command_tasks): + task.cancel() + if self.command_tasks: + await asyncio.gather(*self.command_tasks, return_exceptions=True) await engine.shutdown() def run(self): try: asyncio.run(self.serve()) - except Exception as exc: + except Exception as exc: # noqa: BLE001 - thread boundary audit = DailyLog(self.root) cursor = audit.sequence audit.record( @@ -334,12 +366,298 @@ def dialog(title, parent): return win, form, buttons +class AccountCard(QFrame): + selected = Signal(object) + context_requested = Signal(object, object) + checked = Signal(str, bool) + expand_requested = Signal() + + def __init__( + self, + account_id, + title, + meta, + status, + color, + checkable=False, + expandable=False, + parent=None, + ): + super().__init__(parent) + self.account_id = account_id + self.setObjectName("accountCard") + self.setContextMenuPolicy(Qt.ContextMenuPolicy.DefaultContextMenu) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + self.setMinimumHeight(56) + self.setStyleSheet( + "#accountCard { border: 1px solid #d9dde5; border-radius: 7px; background: white; }" + '#accountCard[selected="true"] { border: 2px solid #1677ff; }' + ) + layout = QHBoxLayout(self) + layout.setContentsMargins(8, 6, 10, 6) + layout.setSpacing(6) + + self.toggle_button = QToolButton() + self.toggle_button.setAutoRaise(True) + self.toggle_button.setFixedWidth(22) + self.toggle_button.setToolTip("展开/折叠小号") + self.toggle_button.clicked.connect(self.expand_requested) + layout.addWidget(self.toggle_button) + self.set_expandable(expandable) + + self.check_box = QCheckBox() if checkable else None + if self.check_box is not None: + self.check_box.setToolTip("勾选后可批量启动该大号") + self.check_box.toggled.connect( + lambda checked: self.checked.emit(self.account_id, checked) + ) + layout.addWidget(self.check_box) + + self.status_dot = QLabel("●") + self.status_dot.setStyleSheet(f"color: {color}; font-size: 15px;") + layout.addWidget(self.status_dot) + text_layout = QVBoxLayout() + text_layout.setSpacing(0) + self.title_label = QLabel(title) + self.title_label.setStyleSheet("font-weight: 600;") + text_layout.addWidget(self.title_label) + self.meta_label = QLabel(meta) + self.meta_label.setStyleSheet("color: #68707d;") + text_layout.addWidget(self.meta_label) + self.status_label = QLabel(status) + self.status_label.setStyleSheet("color: #68707d;") + text_layout.addWidget(self.status_label) + layout.addLayout(text_layout, 1) + self.setToolTip("\n".join((title, meta, status))) + self._install_event_filters() + + def _install_event_filters(self): + for child in self.findChildren(QWidget): + child.installEventFilter(self) + + def set_expandable(self, expandable): + self.toggle_button.setVisible(expandable) + if not expandable: + self.toggle_button.setArrowType(Qt.ArrowType.NoArrow) + + def set_expanded(self, expanded): + self.toggle_button.setChecked(expanded) + self.toggle_button.setArrowType( + Qt.ArrowType.DownArrow if expanded else Qt.ArrowType.RightArrow + ) + + def set_selected(self, selected): + self.setProperty("selected", "true" if selected else "false") + self.style().unpolish(self) + self.style().polish(self) + + def eventFilter(self, watched, event): + if event.type() == QEvent.Type.MouseButtonPress: + if event.button() == Qt.MouseButton.RightButton: + self.context_requested.emit( + self.account_id, event.globalPosition().toPoint() + ) + return True + if ( + event.button() == Qt.MouseButton.LeftButton + and watched not in (self.check_box, self.toggle_button) + and self.account_id is not None + ): + self.selected.emit(self.account_id) + return False + + def mousePressEvent(self, event): + if event.button() == Qt.MouseButton.RightButton: + self.context_requested.emit( + self.account_id, event.globalPosition().toPoint() + ) + elif ( + event.button() == Qt.MouseButton.LeftButton and self.account_id is not None + ): + self.selected.emit(self.account_id) + super().mousePressEvent(event) + + def contextMenuEvent(self, event): + self.context_requested.emit(self.account_id, event.globalPos()) + + +class AccountTreeNode(QWidget): + def __init__(self, card, parent=None, parent_node=None): + super().__init__(parent) + self.card = card + self.parent_node = parent_node + self.expanded = True + self.children_host = QWidget() + self.children_host.setObjectName("accountTreeChildren") + self.children_host.setStyleSheet( + "#accountTreeChildren { border-left: 1px solid #d9dde5; }" + ) + self.children_layout = QVBoxLayout(self.children_host) + self.children_layout.setContentsMargins(22, 3, 0, 0) + self.children_layout.setSpacing(4) + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(3) + layout.addWidget(card) + layout.addWidget(self.children_host) + self.children_host.hide() + card.expand_requested.connect(self.toggle) + + def add_child(self, node): + node.parent_node = self + self.children_layout.addWidget(node) + self.card.set_expandable(True) + self.set_expanded(True) + + def set_expanded(self, expanded): + self.expanded = expanded + has_children = self.children_layout.count() > 0 + self.children_host.setVisible(has_children and expanded) + self.card.set_expandable(has_children) + if has_children: + self.card.set_expanded(expanded) + + def toggle(self): + self.set_expanded(not self.expanded) + + def reveal(self): + parent = self.parent_node + while parent is not None: + parent.set_expanded(True) + parent = parent.parent_node + + +class AccountCardTree(QScrollArea): + selection_changed = Signal() + context_menu_requested = Signal(object, object) + + def __init__(self, parent=None): + super().__init__(parent) + self.setWidgetResizable(True) + self.setFrameShape(QFrame.Shape.NoFrame) + self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.host = QWidget() + self.layout_box = QVBoxLayout(self.host) + self.layout_box.setContentsMargins(2, 2, 8, 2) + self.layout_box.setSpacing(6) + self.layout_box.addStretch() + self.setWidget(self.host) + self.cards = {} + self.groups = {} + self.nodes = {} + self._selected_id = None + self.viewport().installEventFilter(self) + + def _clear(self): + while self.layout_box.count(): + item = self.layout_box.takeAt(0) + widget = item.widget() + if widget is not None: + widget.deleteLater() + self.layout_box.addStretch() + + def _add_root(self, node): + self.layout_box.insertWidget(self.layout_box.count() - 1, node) + + def _connect(self, card): + card.selected.connect(self._select) + card.context_requested.connect(self.context_menu_requested) + + def set_accounts(self, accounts, display, checked, selected_id): + self._clear() + self.cards = {} + self.groups = {} + self.nodes = {} + self._selected_id = selected_id + main_nodes = {} + mains = [account for account in accounts if account["role"] == "main"] + workers = [account for account in accounts if account["role"] == "worker"] + for account in mains: + card = AccountCard( + account["id"], + **display[account["id"]], + checkable=True, + parent=self.host, + ) + if card.check_box is not None: + card.check_box.setChecked(account["id"] in checked) + self._connect(card) + node = AccountTreeNode(card, self.host) + self._add_root(node) + self.cards[account["id"]] = card + self.groups[account["id"]] = card + self.nodes[account["id"]] = node + main_nodes[account["id"]] = node + + unassigned_node = None + for account in workers: + parent_node = main_nodes.get(account.get("owner")) + if parent_node is None: + if unassigned_node is None: + group_card = AccountCard( + None, + "未分配小号", + "没有归属大号", + "状态:执行入口关闭", + "#68707d", + expandable=True, + parent=self.host, + ) + unassigned_node = AccountTreeNode(group_card, self.host) + self._add_root(unassigned_node) + parent_node = unassigned_node + card = AccountCard( + account["id"], + **display[account["id"]], + parent=parent_node.children_host, + ) + self._connect(card) + node = AccountTreeNode(card, parent_node.children_host, parent_node) + parent_node.add_child(node) + self.cards[account["id"]] = card + self.nodes[account["id"]] = node + + for card in self.cards.values(): + card.set_selected(card.account_id == self._selected_id) + if self._selected_id in self.nodes: + self.nodes[self._selected_id].reveal() + + def _select(self, account_id): + if account_id not in self.cards: + return + self._selected_id = account_id + self.nodes[account_id].reveal() + for card in self.cards.values(): + card.set_selected(card.account_id == account_id) + self.selection_changed.emit() + + def selected_id(self): + return self._selected_id + + def set_current_id(self, account_id): + self._select(account_id) + + def checked_groups(self): + return [ + account_id + for account_id, card in self.groups.items() + if card.check_box is not None and card.check_box.isChecked() + ] + + def eventFilter(self, watched, event): + if watched is self.viewport() and event.type() == QEvent.Type.ContextMenu: + self.context_menu_requested.emit(None, event.globalPos()) + return True + return super().eventFilter(watched, event) + + class Window(QMainWindow): def __init__(self, backend): super().__init__() self.backend = backend self.data = { "accounts": [], + "deleted_accounts": [], "tasks": [], "selected": [], "active": [], @@ -352,15 +670,23 @@ class Window(QMainWindow): self.signature = None self.icons = {} self.avatar_pending = set() + self.latest_requests = {} + self.latest_global_requests = {} + self.detail_account_id = None + self.history_payload = None + self.works_payload = None + self.exception_tasks = [] + self.exception_names = {} self.work_cover_cache = Path(self.backend.root) / "work_covers" self.network = QNetworkAccessManager(self) self.setWindowTitle("抖音账号助手 · 自动操作默认关闭") - self.resize(1260, 780) + self.resize(1260, 860) central = QWidget() self.setCentralWidget(central) layout = QVBoxLayout(central) note = QLabel( - "① 只填写账号名称 → ② 在自动打开的浏览器中手动登录,账号信息自动回填 → ③ 配置规则 → ④ 勾选大号并启动。" + "左侧账号树只显示别名、名称和状态;点击账号查看详情,账号操作请使用右键菜单。" + "恢复选择不会启动业务,启动前会先检查旧 pending 任务。" ) note.setWordWrap(True) layout.addWidget(note) @@ -370,92 +696,198 @@ class Window(QMainWindow): for text, handler in [ ("添加大号", lambda: self.add("main")), ("添加小号", lambda: self.add("worker")), - ("启动勾选组 / 恢复上次", self.start_groups), + ("恢复上次选择", self.restore_selection), + ("启动勾选组", self.start_groups), ("停止全部", lambda: self.send("stop")), - ("浏览器设置", lambda: self.browser_settings(False)), ]: - self.button(row, text, handler) - self.tabs = QTabWidget() - layout.addWidget(self.tabs) + button = self.button(row, text, handler) + if text == "停止全部": + self.stop_button = button + self.exception_button = QPushButton("异常任务") + self.exception_button.clicked.connect(self.show_exception_tasks) + row.addWidget(self.exception_button) + self.button(row, "设置", lambda: self.browser_settings(False)) + row.addStretch() + + self.workspace_stack = QStackedWidget() accounts = QWidget() - account_layout = QVBoxLayout(accounts) - self.tree = QTreeWidget() - self.tree.setHeaderLabels( - [ - "账号(大号可勾选)", - "账号 ID(自动获取) / 归属", - "昵称", - "运行状态", - "自动规则", - "登录状态", - "身份核验", - "粉丝", - "关注", - "获赞", - "作品", - ] - ) - self.tree.setColumnWidth(0, 250) - self.tree.setColumnWidth(1, 230) - self.tree.setColumnWidth(2, 130) - self.tree.setColumnWidth(3, 390) - account_layout.addWidget(self.tree) + account_layout = QHBoxLayout(accounts) + splitter = QSplitter(Qt.Orientation.Horizontal) + account_layout.addWidget(splitter) + left = QWidget() + left_layout = QVBoxLayout(left) + self.card_table = AccountCardTree() + self.card_table.setMinimumWidth(320) + self.card_table.context_menu_requested.connect(self.account_menu) + self.card_table.selection_changed.connect(self.show_profile) + left_layout.addWidget(self.card_table) + left.setMinimumWidth(330) + splitter.addWidget(left) + + right = QWidget() + right_layout = QVBoxLayout(right) + self.detail_title = QLabel("选择账号") + self.detail_title.setObjectName("accountDetailTitle") + right_layout.addWidget(self.detail_title) self.profile_summary = QLabel("选择账号可查看自动获取的资料与统计") self.profile_summary.setWordWrap(True) - account_layout.addWidget(self.profile_summary) - self.tree.itemSelectionChanged.connect(self.show_profile) - actions = QHBoxLayout() - account_layout.addLayout(actions) - for text, handler in [ - ("登录 / 刷新信息", lambda: self.selected_command("open")), - ("停止本组", self.stop_selected_group), - ("配置规则", self.rules), - ("切换 / 解除归属", self.move), - ("账号浏览器设置", lambda: self.browser_settings(True)), - ("关闭浏览器", lambda: self.selected_command("close_browser")), - ("删除登录数据", self.erase), - ("删除小号", self.delete_worker), - ("历史事件操作", self.history_events), - ("作品监控", self.fetch_works), - ]: - self.button(actions, text, handler) - self.tabs.addTab(accounts, "账号组") - tasks = QWidget() - tasks_layout = QVBoxLayout(tasks) - tasks_layout.addWidget( - QLabel( - "待核对表示结果不确定:不会自动重发。只有人工确认成功后,依赖步骤才允许继续。最多显示最近 2000 条,历史记录仍完整保留。" - ) + right_layout.addWidget(self.profile_summary) + self.detail_tabs = QTabWidget() + + current_tab = QWidget() + current_layout = QVBoxLayout(current_tab) + self.detail_task_summary = QLabel("当前任务:—") + self.detail_task_summary.setWordWrap(True) + current_layout.addWidget(self.detail_task_summary) + self.current_task_table = self._task_table(checkable=True) + current_layout.addWidget(self.current_task_table, 1) + self.cancel_pending_button = self.button( + current_layout, "取消所选 pending", self.cancel_selected_pending ) - self.table = QTableWidget(0, 8) - self.table.setHorizontalHeaderLabels( + + history_tab = QWidget() + history_layout = QVBoxLayout(history_tab) + self.history_task_summary = QLabel("历史任务:—") + self.history_task_summary.setWordWrap(True) + history_layout.addWidget(self.history_task_summary) + self.history_task_table = self._task_table(checkable=False) + history_layout.addWidget(self.history_task_table, 1) + history_layout.addWidget( + QLabel("结果不确定仅作只读记录,不提供人工核定、重发、改派或状态修改。") + ) + self.detail_tabs.addTab(current_tab, "当前任务") + self.detail_tabs.addTab(history_tab, "历史任务") + + main_history_tab = QWidget() + main_history_layout = QVBoxLayout(main_history_tab) + self.history_info = QLabel( + "历史事件尚未同步;同步只保存本地数据,不标记已读,也不会自动创建任务。" + ) + self.history_info.setWordWrap(True) + main_history_layout.addWidget(self.history_info) + history_controls = QHBoxLayout() + history_controls.addWidget(QLabel("事件类型")) + self.history_kind_filter = QComboBox() + self.history_kind_filter.addItem("全部类型", "") + self.history_kind_filter.currentIndexChanged.connect(self.filter_history) + history_controls.addWidget(self.history_kind_filter) + self.button(history_controls, "同步历史事件", self.history_events) + self.button(history_controls, "完整补齐", self.history_full) + self.button( + history_controls, + "提交选中事件", + self.confirm_history, + ) + history_controls.addStretch() + main_history_layout.addLayout(history_controls) + history_select = QHBoxLayout() + self.button( + history_select, + "全选当前显示", + lambda: self.set_table_checks(self.history_table, Qt.CheckState.Checked), + ) + self.button( + history_select, + "清空当前显示", + lambda: self.set_table_checks(self.history_table, Qt.CheckState.Unchecked), + ) + history_select.addStretch() + main_history_layout.addLayout(history_select) + self.history_table = QTableWidget(0, 9) + self.history_table.setHorizontalHeaderLabels( [ - "任务", - "通知", - "来源大号", - "执行小号", - "目标 UID", - "动作", - "状态", - "结果说明", + "选择", + "发生时间", + "事件类型", + "来源用户", + "作品名称", + "作品ID", + "评论或内容", + "通知ID", + "数据来源", ] ) - self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) - self.table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection) - self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) - self.table.horizontalHeader().setSectionResizeMode( + self.history_table.setSelectionBehavior( + QTableWidget.SelectionBehavior.SelectRows + ) + self.history_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) + self.history_table.horizontalHeader().setSectionResizeMode( QHeaderView.ResizeMode.ResizeToContents ) - tasks_layout.addWidget(self.table) - self.counts = QLabel( - "待执行 0 · 执行中 0 · 成功 0 · 失败 0 · 待核对 0 · 已取消 0" + self.history_table.horizontalHeader().setSectionResizeMode( + 4, QHeaderView.ResizeMode.Stretch ) - tasks_layout.addWidget(self.counts) - checks = QHBoxLayout() - tasks_layout.addLayout(checks) - self.button(checks, "人工核对:已成功", lambda: self.resolve(True)) - self.button(checks, "人工核对:未成功(不重发)", lambda: self.resolve(False)) - self.tabs.addTab(tasks, "任务与结果") + main_history_layout.addWidget(self.history_table, 1) + + main_works_tab = QWidget() + main_works_layout = QVBoxLayout(main_works_tab) + self.works_info = QLabel( + "作品缓存尚未同步;完整标记与覆盖数量以本地数据库为准。" + ) + self.works_info.setWordWrap(True) + main_works_layout.addWidget(self.works_info) + works_controls = QHBoxLayout() + self.works_all_mode = QCheckBox("监控全部作品(包含以后发布的新作品)") + self.works_all_mode.toggled.connect(self._toggle_works_selection) + works_controls.addWidget(self.works_all_mode) + works_controls.addWidget(QLabel("自动刷新间隔")) + self.works_refresh_minutes = QSpinBox() + self.works_refresh_minutes.setRange(0, 10080) + self.works_refresh_minutes.setSpecialValueText("关闭") + self.works_refresh_minutes.setSuffix(" 分钟") + works_controls.addWidget(self.works_refresh_minutes) + self.button(works_controls, "刷新作品", self.fetch_works) + self.button(works_controls, "强制增量刷新", self.works_refresh) + self.button(works_controls, "完整补齐", self.works_full) + works_controls.addStretch() + main_works_layout.addLayout(works_controls) + main_works_layout.addWidget( + QLabel( + "取消全部作品后,只监控下方选择;封面缓存保存在本机,停止账号组后不定时请求。" + ) + ) + self.works_table = QTableWidget(0, 10, main_works_tab) + self._works_table_sync = None + self.works_table.hide() + self.works_card_area = QScrollArea() + self.works_card_area.setWidgetResizable(True) + self.works_card_area.setFrameShape(QFrame.Shape.NoFrame) + self.works_card_host = QWidget() + self.works_card_grid = QGridLayout(self.works_card_host) + self.works_card_grid.setContentsMargins(4, 4, 4, 4) + self.works_card_grid.setHorizontalSpacing(12) + self.works_card_grid.setVerticalSpacing(12) + for column in range(3): + self.works_card_grid.setColumnStretch(column, 1) + self.works_card_area.setWidget(self.works_card_host) + main_works_layout.addWidget(self.works_card_area, 1) + works_select = QHBoxLayout() + self.button( + works_select, + "全选", + lambda: self.set_table_checks(self.works_table, Qt.CheckState.Checked), + ) + self.button( + works_select, + "清空选择", + lambda: self.set_table_checks(self.works_table, Qt.CheckState.Unchecked), + ) + self.button(works_select, "保存监控范围", self.save_work_filter) + works_select.addStretch() + main_works_layout.addLayout(works_select) + + self.detail_tabs.addTab(main_history_tab, "历史事件") + self.detail_tabs.addTab(main_works_tab, "作品列表") + right_layout.addWidget(self.detail_tabs, 1) + splitter.addWidget(right) + splitter.setStretchFactor(0, 2) + splitter.setStretchFactor(1, 5) + splitter.setSizes([390, 840]) + self.account_page = accounts + self.workspace_stack.addWidget(accounts) + self.exception_page = self._build_exception_page() + self.workspace_stack.addWidget(self.exception_page) + logs = QWidget() logs_layout = QVBoxLayout(logs) self.log_info = QLabel("正在加载日志;按天保存完整记录,界面仅保留最近 2000 条") @@ -474,7 +906,14 @@ class Window(QMainWindow): self.log.setReadOnly(True) self.log.setMaximumBlockCount(UI_LIMIT) logs_layout.addWidget(self.log) - self.tabs.addTab(logs, "运行记录(最近 2000 条)") + vertical = QSplitter(Qt.Orientation.Vertical) + vertical.addWidget(self.workspace_stack) + vertical.addWidget(logs) + vertical.setStretchFactor(0, 5) + vertical.setStretchFactor(1, 2) + vertical.setSizes([590, 180]) + layout.addWidget(vertical, 1) + self.table = self.current_task_table self.statusBar().showMessage("正在初始化;不会自动启动业务") backend.snapshot_ready.connect(self.refresh) backend.response.connect(self.response) @@ -487,6 +926,408 @@ class Window(QMainWindow): button.clicked.connect(handler) layout.addWidget(button) self.buttons.append(button) + return button + + def _task_table(self, checkable=False): + headers = ( + [ + "选择", + "任务", + "通知", + "来源大号", + "执行小号", + "目标 UID", + "动作", + "状态", + "结果说明", + "创建时间", + "开始时间", + "完成时间", + ] + if checkable + else [ + "任务", + "通知", + "来源大号", + "执行小号", + "目标 UID", + "动作", + "状态", + "结果说明", + "创建时间", + "开始时间", + "完成时间", + ] + ) + table = QTableWidget(0, len(headers)) + table.setHorizontalHeaderLabels(headers) + table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) + table.setSelectionMode( + QAbstractItemView.SelectionMode.ExtendedSelection + if checkable + else QAbstractItemView.SelectionMode.SingleSelection + ) + table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) + table.horizontalHeader().setSectionResizeMode( + QHeaderView.ResizeMode.ResizeToContents + ) + table.horizontalHeader().setSectionResizeMode( + 8 if checkable else 7, QHeaderView.ResizeMode.Stretch + ) + return table + + def _fill_task_table(self, table, tasks, names, checkable=False): + labels = { + "pending": "待执行", + "running": "执行中", + "succeeded": "成功", + "failed": "失败", + "unknown": "结果不确定", + "cancelled": "已取消", + } + table.setRowCount(len(tasks)) + for row, task in enumerate(tasks): + if checkable: + first = QTableWidgetItem() + first.setData(Qt.ItemDataRole.UserRole, str(task["id"])) + first.setFlags(Qt.ItemFlag.ItemIsEnabled) + if task["status"] == "pending": + first.setFlags(first.flags() | Qt.ItemFlag.ItemIsUserCheckable) + first.setCheckState(Qt.CheckState.Unchecked) + else: + first = QTableWidgetItem(str(task["id"])) + first.setData(Qt.ItemDataRole.UserRole, str(task["id"])) + table.setItem(row, 0, first) + values = [ + task["id"], + task["event"], + names.get(task["source"], task["source"]), + names.get(task["worker"], task["worker"]), + task["target"], + "关注" if task["action"] == "follow" else "私信", + labels.get(task["status"], task["status"]), + task_result_text(task["result"]), + date_text(task.get("created_at")), + date_text(task.get("started_at")), + date_text(task.get("finished_at")), + ] + for column, value in enumerate(values, 1 if checkable else 0): + table.setItem(row, column, QTableWidgetItem(str(value))) + + @staticmethod + def _sync_text(sync, label): + sync = sync or {} + status_key = str(sync.get("status") or "") + status = { + "complete": "完整", + "partial": "部分", + "failed": "失败", + "syncing": "同步中", + "not_started": "未同步", + }.get(status_key, status_key or "未同步") + text = f"{label}:{status} · 覆盖 {sync.get('coverage', 0)}" + if sync.get("last_success"): + text += f" · 最近成功 {date_text(sync['last_success'])}" + if sync.get("error"): + text += f" · {sync['error']}" + return text + + def _clear_layout(self, layout): + while layout.count(): + item = layout.takeAt(0) + widget = item.widget() + if widget is not None: + widget.deleteLater() + + def _toggle_works_selection(self, checked): + self.works_table.setDisabled(checked) + self.works_card_area.setDisabled(checked) + + def _build_exception_page(self): + page = QWidget() + layout = QVBoxLayout(page) + heading = QHBoxLayout() + heading.addWidget(QLabel("异常任务(仅显示 unknown,只读)")) + back = QPushButton("返回账号工作台") + back.clicked.connect( + lambda: self.workspace_stack.setCurrentWidget(self.account_page) + ) + heading.addWidget(back) + layout.addLayout(heading) + filters = QHBoxLayout() + filters.addWidget(QLabel("原所属大号")) + self.exception_source_filter = QComboBox() + filters.addWidget(self.exception_source_filter) + filters.addWidget(QLabel("原执行小号")) + self.exception_worker_filter = QComboBox() + filters.addWidget(self.exception_worker_filter) + self.button(filters, "刷新异常任务", self.query_exception_tasks) + filters.addStretch() + layout.addLayout(filters) + layout.addWidget( + QLabel( + "unknown 只保留查询和保护占用,不提供人工核定、重发、重置、改派或状态修改。" + ) + ) + self.exception_table = self._task_table(checkable=False) + layout.addWidget(self.exception_table, 1) + self.exception_counts = QLabel("结果不确定 0 条") + layout.addWidget(self.exception_counts) + return page + + def _task_names(self): + return self.data.get("task_names") or { + a["id"]: a["name"] for a in self.data.get("accounts", []) + } + + def _refresh_detail_tasks(self, account): + if not account or account["role"] != "worker": + self._fill_task_table(self.current_task_table, [], self._task_names(), True) + self._fill_task_table(self.history_task_table, [], self._task_names()) + self.detail_task_summary.setText("当前任务:—") + self.history_task_summary.setText("历史任务:—") + return + relevant = [ + task + for task in self.data.get("tasks", []) + if task["worker"] == account["id"] + ] + current = [ + task + for task in relevant + if task["status"] in ("pending", "running", "unknown") + ] + history = [ + task + for task in relevant + if task["status"] not in ("pending", "running", "unknown") + ] + names = self._task_names() + self._fill_task_table(self.current_task_table, current, names, True) + self._fill_task_table(self.history_task_table, history, names) + self.detail_task_summary.setText( + " · ".join( + [ + f"当前任务 {len(current)}", + f"待执行 {sum(t['status'] == 'pending' for t in current)}", + f"执行中 {sum(t['status'] == 'running' for t in current)}", + f"结果不确定 {sum(t['status'] == 'unknown' for t in current)}", + ] + ) + ) + self.history_task_summary.setText(f"历史任务 {len(history)} 条") + + def refresh_exception_filters(self): + source = self.exception_source_filter.currentData() + worker = self.exception_worker_filter.currentData() + accounts = self.data.get("accounts", []) + self.exception_source_filter.blockSignals(True) + self.exception_worker_filter.blockSignals(True) + self.exception_source_filter.clear() + self.exception_source_filter.addItem("全部原组", "") + for account in accounts: + if account["role"] == "main": + self.exception_source_filter.addItem(account["name"], account["id"]) + self.exception_worker_filter.clear() + self.exception_worker_filter.addItem("全部原执行小号", "") + for ident, name in sorted( + self._task_names().items(), key=lambda value: value[1] + ): + if any(a["id"] == ident and a["role"] == "main" for a in accounts): + continue + self.exception_worker_filter.addItem(name, ident) + self.exception_source_filter.setCurrentIndex( + max(0, self.exception_source_filter.findData(source)) + ) + self.exception_worker_filter.setCurrentIndex( + max(0, self.exception_worker_filter.findData(worker)) + ) + self.exception_source_filter.blockSignals(False) + self.exception_worker_filter.blockSignals(False) + + def show_exception_tasks(self): + self.workspace_stack.setCurrentWidget(self.exception_page) + self.refresh_exception_filters() + self.query_exception_tasks() + + def query_exception_tasks(self): + self.send( + "task_query", + { + "source": self.exception_source_filter.currentData() or None, + "worker": self.exception_worker_filter.currentData() or None, + }, + ) + + def render_exception_tasks(self, payload): + self.exception_tasks = payload.get("tasks", []) + self.exception_names = payload.get("task_names") or self._task_names() + self._fill_task_table( + self.exception_table, self.exception_tasks, self.exception_names + ) + self.exception_counts.setText(f"结果不确定 {len(self.exception_tasks)} 条") + + def history_full(self): + account = self.selected() + if not account or account["role"] != "main" or not account["uid"]: + QMessageBox.information(self, "提示", "请先选择已登录并绑定身份的大号。") + return + self.send("history_full", {"id": account["id"]}) + + def works_refresh(self): + account = self.selected() + if not account or account["role"] != "main" or not account["uid"]: + QMessageBox.information(self, "提示", "请先选择已登录并绑定身份的大号。") + return + self.send("works_refresh", {"id": account["id"]}) + + def works_full(self): + account = self.selected() + if not account or account["role"] != "main" or not account["uid"]: + QMessageBox.information(self, "提示", "请先选择已登录并绑定身份的大号。") + return + self.send("works_full", {"id": account["id"]}) + + def filter_history(self): + selected_kind = self.history_kind_filter.currentData() + for row in range(self.history_table.rowCount()): + item = self.history_table.item(row, 0) + self.history_table.setRowHidden( + row, + bool(selected_kind) + and ( + item is None + or item.data(Qt.ItemDataRole.UserRole + 1) != selected_kind + ), + ) + + def confirm_history(self): + payload = self.history_payload + if not payload: + QMessageBox.information(self, "提示", "请先同步历史事件。") + return + selected = self.checked_table_data(self.history_table) + if not selected: + QMessageBox.information(self, "没有选择", "当前显示范围内未选择任何事件。") + return + QMessageBox.information( + self, + "历史事件操作预览", + f"将提交 {len(selected)} 条历史事件,按当前规则交给组内小号处理。\n\n这可能产生真实关注和私信;冷却、去重和作品范围仍会再次校验。", + ) + if ( + QMessageBox.question( + self, + "确认历史批量操作", + f"确认提交 {len(selected)} 条历史事件?结果不确定不会重发。", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + == QMessageBox.StandardButton.Yes + ): + self.send( + "history_enqueue", + { + "id": payload["account"], + "ids": selected, + "baseline": payload.get("baseline"), + }, + ) + + def save_work_filter(self): + account = self.selected() + if not account or account["role"] != "main": + QMessageBox.information(self, "提示", "请先选择大号。") + return + mode = "all" if self.works_all_mode.isChecked() else "selected" + selected = self.checked_table_data(self.works_table) + if mode == "selected" and not selected: + QMessageBox.warning( + self, + "没有选择作品", + "指定作品模式至少选择一个作品;如需暂停请关闭自动操作规则。", + ) + return + self.send( + "work_filter", + { + "id": account["id"], + "mode": mode, + "ids": selected, + "refresh_interval": self.works_refresh_minutes.value() * 60, + }, + ) + + def cancel_selected_pending(self): + account = self.selected() + if not account or account["role"] != "worker": + QMessageBox.information(self, "提示", "请先选择小号。") + return + selected = self.checked_table_data(self.current_task_table) + if not selected: + QMessageBox.information(self, "没有选择", "请选择仍为 pending 的任务。") + return + if ( + QMessageBox.question( + self, + "取消 pending 任务", + f"确认取消选中的 {len(selected)} 条 pending 任务?仅确认时仍为 pending 的任务会被取消。", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + == QMessageBox.StandardButton.Yes + ): + self.send( + "cancel_pending", + {"id": account["id"], "task_ids": selected}, + ) + + def show_start_preview(self, payload): + groups = payload.get("groups", []) + pending = payload.get("tasks", []) + names = self._task_names() + group_names = ", ".join(names.get(group, group) for group in groups) + oldest = min( + (task.get("created_at") for task in pending if task.get("created_at")), + default=None, + ) + text = f"即将启动账号组:{group_names or '未知'}\n旧 pending:{len(pending)} 条" + if oldest: + text += f"\n最早创建:{date_text(oldest)}" + rule_text = [] + for rule in payload.get("rules") or []: + actions = [] + if rule.get("follow"): + actions.append("关注") + if rule.get("dm"): + actions.append("私信") + rule_text.append( + f"{names.get(rule.get('id'), rule.get('id', '未知'))}:" + f"{'、'.join(actions) or '不执行'},间隔 {rule.get('interval', '未知')} 秒" + ) + if rule_text: + text += "\n规则快照:" + ";".join(rule_text) + text += "\n\n恢复选择不会启动业务;账号安全限制会在领取前再次校验。" + box = QMessageBox(self) + box.setWindowTitle("启动前确认") + box.setText(text) + continue_button = box.addButton("继续启动", QMessageBox.ButtonRole.AcceptRole) + cancel_button = ( + box.addButton("取消这些旧 pending", QMessageBox.ButtonRole.DestructiveRole) + if pending + else None + ) + back_button = box.addButton("返回", QMessageBox.ButtonRole.RejectRole) + box.setDefaultButton(back_button) + box.exec() + clicked = box.clickedButton() + if clicked is continue_button: + self.send("start", {"groups": groups}) + elif cancel_button is not None and clicked is cancel_button: + self.send("cancel_pending", {"tasks": pending}) + + def start_group_request(self, groups): + self.send("start_preview", {"groups": groups}) def send(self, name, data=None): if not self.backend.isRunning(): @@ -494,14 +1335,25 @@ class Window(QMainWindow): self, "核心未运行", "请退出后重新启动应用,检查运行环境。" ) return + data = dict(data or {}) + request_id = uuid.uuid4().hex + data["_request_id"] = request_id + if data.get("id"): + self.latest_requests[(name, data["id"])] = request_id + if name in ("task_query", "start_preview"): + self.latest_global_requests[name] = request_id for button in self.buttons: button.setEnabled(False) - self.statusBar().showMessage("正在处理;停止会等待在途请求落库,请勿强制退出……") + self.stop_button.setEnabled(name != "stop") + self.statusBar().showMessage( + "正在处理;停止会先关闭调度和领取入口,请勿强制退出……" + ) self.backend.post(name, data) def response(self, name, success, message): for button in self.buttons: button.setEnabled(not self.quitting and name != "startup") + self.stop_button.setEnabled(not self.quitting) self.statusBar().showMessage(message) if not success: QMessageBox.warning(self, "需要检查", message) @@ -532,7 +1384,7 @@ class Window(QMainWindow): QMessageBox.question( self, "删除小号 — 不可恢复登录数据", - f"确定删除小号“{account['name']}”?\n\n将关闭该小号浏览器、删除全部本地登录数据、取消待执行任务,并从列表移除。\n历史任务和日志保留;同一抖音账号可重新添加。\n\n请先停止所属组并等待执行结束。删除文件失败时账号会保留,可重试。", + f"确定删除小号“{account['name']}”?\n\n将关闭该小号浏览器、删除全部本地登录数据、取消待执行任务,并从列表移除。\n历史任务和日志保留;UID 仍受原记录保护,只能显式恢复原账号。\n\n请先停止所属组并等待执行结束。删除文件失败时账号会保留,可显式重试。", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No, ) @@ -555,10 +1407,36 @@ class Window(QMainWindow): self.send("works_open", {"id": account["id"]}) def command_data(self, name, payload): - if name == "history_fetch": + if not isinstance(payload, dict): + return + account_id = payload.get("account") + request_id = payload.get("_request_id") + if ( + account_id + and request_id + and self.latest_requests.get((name, account_id)) != request_id + ): + return + if ( + name in ("task_query", "start_preview") + and request_id + and self.latest_global_requests.get(name) != request_id + ): + return + if name in ("history_fetch", "history_full"): self.history_dialog(payload) - elif name in ("works_open", "works_refresh"): + elif name in ("works_open", "works_refresh", "works_full"): self.works_dialog(payload) + elif name == "task_query": + self.render_exception_tasks(payload) + elif name == "start_preview": + self.show_start_preview(payload) + elif name == "cancel_pending": + QMessageBox.information( + self, + "pending 任务处理完成", + f"已取消 {payload.get('cancelled', 0)} 条仍为 pending 的任务;在途或已变化的任务未被改写。", + ) elif name == "history_enqueue": QMessageBox.information( self, @@ -594,187 +1472,73 @@ class Window(QMainWindow): return values def history_dialog(self, payload): - items = payload["items"] + items = payload.get("items") or [] labels = NOTICE_KIND_LABELS - win = QDialog(self) - win.setWindowTitle(f"历史事件预览 — 当前可选 {len(items)} 条(尚未创建任务)") - win.resize(1500, 800) - layout = QVBoxLayout(win) - note = QLabel( - "列表来自本地缓存,并已按当前作品监控范围过滤。勾选并二次确认后才会产生真实关注或私信;新消息始终优先。完整平台数据只保存在本地数据库中。" + self.history_payload = payload + sync = self._sync_text(payload.get("sync"), "历史事件同步") + self.history_info.setText( + f"{sync} · 当前可选 {len(items)} 条;确认前不创建任务、不标记已读。规则和作品范围基线会在提交前再次校验。" ) - note.setWordWrap(True) - layout.addWidget(note) - controls = QHBoxLayout() - controls.addWidget(QLabel("只看事件类型")) - kind_filter = QComboBox() - kind_filter.addItem("全部类型", "") - for kind in dict.fromkeys(item["kind"] for item in items): - kind_filter.addItem(labels.get(kind, "其他互动"), kind) - controls.addWidget(kind_filter) - select_all = QPushButton("全选当前显示") - clear = QPushButton("清空当前显示") - controls.addWidget(select_all) - controls.addWidget(clear) - controls.addStretch() - layout.addLayout(controls) - table = QTableWidget(len(items), 9) - table.setHorizontalHeaderLabels( - [ - "选择", - "发生时间", - "事件类型", - "来源用户", - "作品名称", - "作品ID", - "评论或内容", - "通知ID", - "数据来源", - ] + self.history_kind_filter.blockSignals(True) + current_kind = self.history_kind_filter.currentData() + self.history_kind_filter.clear() + self.history_kind_filter.addItem("全部类型", "") + for kind in dict.fromkeys(item.get("kind") for item in items): + if kind: + self.history_kind_filter.addItem(labels.get(kind, "其他互动"), kind) + self.history_kind_filter.setCurrentIndex( + max(0, self.history_kind_filter.findData(current_kind)) ) + self.history_kind_filter.blockSignals(False) + self.history_table.setRowCount(len(items)) for row, item in enumerate(items): check = QTableWidgetItem() check.setFlags(Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsUserCheckable) check.setCheckState(Qt.CheckState.Unchecked) check.setData(Qt.ItemDataRole.UserRole, item["nid"]) check.setData(Qt.ItemDataRole.UserRole + 1, item["kind"]) - table.setItem(row, 0, check) + self.history_table.setItem(row, 0, check) users = [] - for index, uid in enumerate(item["actor_uids"]): - name = ( - item["actor_names"][index] - if index < len(item["actor_names"]) - else "" - ) + for index, uid in enumerate(item.get("actor_uids") or []): + actor_names = item.get("actor_names") or [] + name = actor_names[index] if index < len(actor_names) else "" users.append(f"{name or '未知用户'}(UID {uid})") values = [ - date_text(item["create_time"]), - labels.get(item["kind"], "其他互动"), + date_text(item.get("create_time")), + labels.get(item.get("kind"), "其他互动"), "、".join(users) or "未知用户", - item["work_desc"] or "未提供作品名称", - item["work_id"] or "无作品ID", - item["comment"] or "无文字内容", + item.get("work_desc") or "未提供作品名称", + item.get("work_id") or "无作品ID", + item.get("comment") or "无文字内容", item["nid"], "实时收到" if item.get("origin") == "live" else "历史同步", ] for column, value in enumerate(values, 1): cell = QTableWidgetItem(str(value)) cell.setToolTip(str(value)) - table.setItem(row, column, cell) - table.horizontalHeader().setSectionResizeMode( - QHeaderView.ResizeMode.ResizeToContents - ) - table.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeMode.Stretch) - layout.addWidget(table) - - def apply_filter(): - selected_kind = kind_filter.currentData() - for row in range(table.rowCount()): - item = table.item(row, 0) - table.setRowHidden( - row, - bool(selected_kind) - and ( - item is None - or item.data(Qt.ItemDataRole.UserRole + 1) != selected_kind - ), - ) - - kind_filter.currentIndexChanged.connect(apply_filter) - select_all.clicked.connect( - lambda: self.set_table_checks(table, Qt.CheckState.Checked) - ) - clear.clicked.connect( - lambda: self.set_table_checks(table, Qt.CheckState.Unchecked) - ) - buttons = QDialogButtonBox( - QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel - ) - buttons.accepted.connect(win.accept) - buttons.rejected.connect(win.reject) - layout.addWidget(buttons) - if win.exec() != QDialog.DialogCode.Accepted: - return - selected = self.checked_table_data(table) - if not selected: - QMessageBox.information(self, "没有选择", "当前显示范围内未选择任何事件。") - return - if ( - QMessageBox.question( - self, - "确认历史批量操作", - f"确认将 {len(selected)} 条历史事件交给小号按当前规则处理?\n这可能产生真实关注和私信;新消息优先,冷却和作品筛选仍会跳过部分事件。", - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, - QMessageBox.StandardButton.No, - ) - == QMessageBox.StandardButton.Yes - ): - self.send("history_enqueue", {"id": payload["account"], "ids": selected}) + self.history_table.setItem(row, column, cell) + self.filter_history() def works_dialog(self, payload): - items = payload["items"] - existing = set(payload["selected"]) - win = QDialog(self) - win.setWindowTitle(f"作品监控 — 本地已缓存 {len(items)} 个作品") - win.resize(1600, 800) - layout = QVBoxLayout(win) - all_mode = QCheckBox("监控全部作品(以后发布的新作品也自动包含)") - all_mode.setChecked(payload["mode"] == "all") - layout.addWidget(all_mode) - note = QLabel( - "取消上方选项后,只监控下方勾选作品的点赞、评论、收藏等互动;关注通知不受作品筛选。封面首次加载后保存在本机,后续打开优先读取缓存。" + items = payload.get("items") or [] + existing = set(payload.get("selected") or []) + self.works_payload = payload + self.works_all_mode.setChecked(payload.get("mode") == "all") + try: + refresh_interval = int(payload.get("refresh_interval", 3600)) + except (TypeError, ValueError): + refresh_interval = 3600 + self.works_refresh_minutes.setValue(refresh_interval // 60) + self.works_info.setText( + f"{self._sync_text(payload.get('sync'), '作品同步')} · 本地 {len(items)} 个作品 · 本次更新 {payload.get('refreshed', 0)} 个;完整标记和覆盖数量以数据库为准。" ) - note.setWordWrap(True) - layout.addWidget(note) - schedule = QHBoxLayout() - schedule.addWidget(QLabel("运行期间自动刷新间隔")) - refresh_minutes = QSpinBox() - refresh_minutes.setRange(0, 10080) - refresh_minutes.setSpecialValueText("关闭") - refresh_minutes.setSuffix(" 分钟") - refresh_minutes.setValue(payload.get("refresh_interval", 3600) // 60) - schedule.addWidget(refresh_minutes) - refresh_now = QPushButton("强制增量刷新") - schedule.addWidget(refresh_now) - schedule.addWidget( - QLabel("首次打开会自动获取全部历史作品;停止账号组后不会定时请求。") - ) - schedule.addStretch() - layout.addLayout(schedule) - - # Keep the existing selection model so saving behavior stays unchanged; - # only the visible presentation is now cards. - table = QTableWidget(len(items), 10, win) - table.setHorizontalHeaderLabels( - [ - "选择", - "发布时间", - "作品描述", - "作品ID", - "点赞", - "评论", - "收藏", - "分享", - "封面地址", - "本地更新时间", - ] - ) - table.hide() + if self._works_table_sync is not None: + with suppress(RuntimeError, TypeError): + self.works_table.itemChanged.disconnect(self._works_table_sync) + self.works_table.setRowCount(len(items)) + self._clear_layout(self.works_card_grid) cards = [] - card_area = QScrollArea() - card_area.setWidgetResizable(True) - card_area.setFrameShape(QFrame.Shape.NoFrame) - card_host = QWidget() - card_grid = QGridLayout(card_host) - card_grid.setContentsMargins(4, 4, 4, 4) - card_grid.setHorizontalSpacing(12) - card_grid.setVerticalSpacing(12) - for column in range(3): - card_grid.setColumnStretch(column, 1) - card_area.setWidget(card_host) - layout.addWidget(card_area, 1) - - cover_cards = {} + cards_by_id = {} for row, item in enumerate(items): check = QTableWidgetItem() check.setFlags(Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsUserCheckable) @@ -784,7 +1548,7 @@ class Window(QMainWindow): else Qt.CheckState.Unchecked ) check.setData(Qt.ItemDataRole.UserRole, item["aweme_id"]) - table.setItem(row, 0, check) + self.works_table.setItem(row, 0, check) statistics = item.get("statistics") or {} values = [ date_text(item.get("create_time")), @@ -800,12 +1564,11 @@ class Window(QMainWindow): for column, value in enumerate(values, 1): cell = QTableWidgetItem(str(value)) cell.setToolTip(str(value)) - table.setItem(row, column, cell) - + self.works_table.setItem(row, column, cell) card = WorkCard(item, check.checkState() == Qt.CheckState.Checked) cards.append(card) - card_grid.addWidget(card, row // 3, row % 3) - cover_cards.setdefault(item.get("aweme_id", ""), []).append(card) + cards_by_id.setdefault(item.get("aweme_id", ""), []).append(card) + self.works_card_grid.addWidget(card, row // 3, row % 3) def sync_card(item): if item.column() != 0 or not 0 <= item.row() < len(cards): @@ -815,22 +1578,23 @@ class Window(QMainWindow): cards[item.row()].check_box.setChecked(checked) def sync_table(checked, row): - item = table.item(row, 0) + item = self.works_table.item(row, 0) if item is not None: state = Qt.CheckState.Checked if checked else Qt.CheckState.Unchecked if item.checkState() != state: item.setCheckState(state) - table.itemChanged.connect(sync_card) + self._works_table_sync = sync_card + self.works_table.itemChanged.connect(self._works_table_sync) for row, card in enumerate(cards): - card.check_box.toggled.connect(lambda checked, row=row: sync_table(checked, row)) + card.check_box.toggled.connect( + lambda checked, row=row: sync_table(checked, row) + ) def load_cover(item): ident = item["aweme_id"] cache_path = self.work_cover_cache / ( - hashlib.sha256( - f"{payload['account']}:{ident}".encode() - ).hexdigest() + hashlib.sha256(f"{payload['account']}:{ident}".encode()).hexdigest() + ".img" ) pixmap = QPixmap() @@ -838,9 +1602,9 @@ class Window(QMainWindow): if cache_path.is_file(): pixmap.loadFromData(cache_path.read_bytes()) except OSError: - pass + self.statusBar().showMessage("读取作品封面缓存失败,继续使用在线图片") if not pixmap.isNull(): - for card in cover_cards.get(ident, []): + for card in cards_by_id.get(ident, []): card.set_cover(pixmap) return url = item.get("cover") or "" @@ -854,15 +1618,13 @@ class Window(QMainWindow): ) reply = self.network.get(request) reply.downloadProgress.connect( - lambda received, total: ( - reply.abort() - if received > 2_000_000 or total > 2_000_000 - else None + lambda received, total, reply=reply: ( + reply.abort() if received > 2_000_000 or total > 2_000_000 else None ) ) def loaded(): - raw = bytes(reply.readAll()) + raw = bytes(reply.readAll().data()) image = QPixmap() if reply.error() == reply.NetworkError.NoError: image.loadFromData(raw) @@ -873,8 +1635,10 @@ class Window(QMainWindow): temp.write_bytes(raw) temp.replace(cache_path) except OSError: - pass - for card in cover_cards.get(ident, []): + self.statusBar().showMessage( + "作品封面缓存写入失败,本次仅显示图片" + ) + for card in cards_by_id.get(ident, []): card.set_cover(image) reply.deleteLater() @@ -882,69 +1646,74 @@ class Window(QMainWindow): for item in items: load_cover(item) - table.setDisabled(all_mode.isChecked()) - card_area.setDisabled(all_mode.isChecked()) - all_mode.toggled.connect(table.setDisabled) - all_mode.toggled.connect(card_area.setDisabled) - controls = QHBoxLayout() - select_all = QPushButton("全选") - clear = QPushButton("清空选择") - select_all.clicked.connect( - lambda: self.set_table_checks(table, Qt.CheckState.Checked) - ) - clear.clicked.connect( - lambda: self.set_table_checks(table, Qt.CheckState.Unchecked) - ) - controls.addWidget(select_all) - controls.addWidget(clear) - controls.addStretch() - layout.addLayout(controls) - buttons = QDialogButtonBox( - QDialogButtonBox.StandardButton.Save - | QDialogButtonBox.StandardButton.Cancel - ) - buttons.button(QDialogButtonBox.StandardButton.Save).setText("保存设置") - buttons.button(QDialogButtonBox.StandardButton.Cancel).setText("取消") - buttons.accepted.connect(win.accept) - buttons.rejected.connect(win.reject) - layout.addWidget(buttons) + self._toggle_works_selection(self.works_all_mode.isChecked()) - def force_refresh(): - win.reject() - self.send("works_refresh", {"id": payload["account"]}) + def restore_selection(self): + self.selection_loaded = False + self.signature = None + self.refresh(self.data) + self.statusBar().showMessage("已恢复上次选择;不会启动业务") - refresh_now.clicked.connect(force_refresh) - if win.exec() != QDialog.DialogCode.Accepted: - return - mode = "all" if all_mode.isChecked() else "selected" - selected = self.checked_table_data(table) - if mode == "selected" and not selected: - QMessageBox.warning( - self, - "没有选择作品", - "指定作品模式至少选择一个作品;如需暂停请关闭自动操作规则。", - ) - return - self.send( - "work_filter", - { - "id": payload["account"], - "mode": mode, - "ids": selected, - "refresh_interval": refresh_minutes.value() * 60, - }, - ) + def account_menu(self, account_id, global_pos): + if account_id: + self.card_table.set_current_id(account_id) + menu = QMenu(self) + account = self.selected() + if account: + menu.addAction("登录 / 刷新账号信息", lambda: self.selected_command("open")) + menu.addAction("打开账号浏览器", lambda: self.selected_command("open")) + if account["role"] == "main": + menu.addAction( + "启动本组", lambda: self.start_group_request([account["id"]]) + ) + menu.addAction("停止本组", self.stop_selected_group) + menu.addAction("配置规则", self.rules) + else: + menu.addAction("切换 / 解除归属", self.move) + menu.addAction("删除小号", self.delete_worker) + menu.addAction("账号浏览器设置", lambda: self.browser_settings(True)) + menu.addAction("关闭浏览器", lambda: self.selected_command("close_browser")) + menu.addAction("删除登录数据", self.erase) + else: + menu.addAction("刷新列表", lambda: self.refresh(self.data)) + menu.addAction("打开日志目录", self.open_logs) + menu.addAction("全局浏览器设置", lambda: self.browser_settings(False)) + menu.exec(global_pos) def selected(self): - item = self.tree.currentItem() - ident = item.data(0, Qt.ItemDataRole.UserRole) if item else None + ident = self.card_table.selected_id() return next((a for a in self.data["accounts"] if a["id"] == ident), None) def show_profile(self): account = self.selected() if not account: + self.detail_account_id = None + self.detail_title.setText("选择账号") self.profile_summary.setText("选择账号可查看自动获取的资料与统计") + self.detail_task_summary.setText("当前任务:—") + self.history_task_summary.setText("历史任务:—") + self.history_payload = None + self.works_payload = None + self.history_table.setRowCount(0) + self.works_table.setRowCount(0) + self._clear_layout(self.works_card_grid) return + account_changed = self.detail_account_id != account["id"] + if account_changed: + self.history_payload = None + self.works_payload = None + self.history_table.setRowCount(0) + self.works_table.setRowCount(0) + self._clear_layout(self.works_card_grid) + self.history_kind_filter.setCurrentIndex(0) + self.works_all_mode.setChecked(False) + self.history_info.setText( + "历史事件尚未同步;同步只保存本地数据,不标记已读,也不会自动创建任务。" + ) + self.works_info.setText( + "作品缓存尚未同步;完整标记与覆盖数量以本地数据库为准。" + ) + self.detail_account_id = account["id"] profile = decode(account.get("profile", "{}")) counts = " · ".join( f"{label} {profile.get(key) if profile.get(key) is not None else '—'}" @@ -955,9 +1724,37 @@ class Window(QMainWindow): ("aweme_count", "作品"), ] ) - self.profile_summary.setText( - f"{account['nickname'] or '等待登录自动获取资料'} · {counts}" + is_main = account["role"] == "main" + for index in range(self.detail_tabs.count()): + self.detail_tabs.setTabVisible(index, index >= 2 if is_main else index < 2) + if account_changed: + self.detail_tabs.setCurrentIndex(2 if is_main else 0) + relevant = [ + task + for task in self.data.get("tasks", []) + if task["worker"] == account["id"] + or (is_main and task["source"] == account["id"]) + ] + current = sum( + task["status"] in ("pending", "running", "unknown") for task in relevant ) + deletion = ( + f" · 删除收尾:{account['delete_step']}" + if account.get("delete_state") + else "" + ) + sync = account.get("sync") or {} + self.detail_title.setText( + f"{account['name']} · {'大号' if is_main else '小号'}" + ) + self.profile_summary.setText( + f"{account['nickname'] or '等待登录自动获取资料'} · {counts}\n" + f"运行:{account.get('state', '未启动')} · 规则:{'已启用' if decode(account.get('rule', '{}')).get('enabled') else '关闭'} · " + f"登录:{account.get('login', '待登录/检查')} · 身份:{account.get('identity', '等待首次登录')}\n" + f"相关任务 {len(relevant)} 条 · 当前/保护中 {current} 条{deletion}\n" + f"{self._sync_text(sync.get('notices'), '历史事件')} · {self._sync_text(sync.get('works'), '作品缓存')}" + ) + self._refresh_detail_tasks(account) def selected_command(self, name): account = self.selected() @@ -967,14 +1764,7 @@ class Window(QMainWindow): QMessageBox.information(self, "提示", "请先选择一个账号。") def checked_groups(self): - items = ( - self.tree.topLevelItem(i) for i in range(self.tree.topLevelItemCount()) - ) - return [ - item.data(0, Qt.ItemDataRole.UserRole) - for item in items - if item and item.checkState(0) == Qt.CheckState.Checked - ] + return self.card_table.checked_groups() def start_groups(self): groups = self.checked_groups() @@ -983,7 +1773,7 @@ class Window(QMainWindow): self, "提示", "请勾选至少一个大号。上次选择会在重启后自动勾选。" ) return - self.send("start", {"groups": groups}) + self.start_group_request(groups) def stop_selected_group(self): account = self.selected() @@ -992,6 +1782,31 @@ class Window(QMainWindow): if ident: self.send("stop_group", {"id": ident}) + def restore_worker(self): + deleted = self.data.get("deleted_accounts", []) + if not deleted: + QMessageBox.information(self, "提示", "没有可恢复的已删除小号。") + return + win, form, _buttons = dialog("恢复已删除小号", self) + account_box = QComboBox() + for account in deleted: + label = f"{account['name']} · UID {account['uid'] or '未知'}" + if account.get("delete_error"): + label += f" · {account['delete_error']}" + account_box.addItem(label, account["id"]) + owner_box = QComboBox() + owner_box.addItem("暂不归属", None) + for account in self.data["accounts"]: + if account["role"] == "main": + owner_box.addItem(account["name"], account["id"]) + form.addRow("原账号", account_box) + form.addRow("恢复归属", owner_box) + if win.exec() == QDialog.DialogCode.Accepted: + self.send( + "restore_worker", + {"id": account_box.currentData(), "owner": owner_box.currentData()}, + ) + def add(self, role): win, form, buttons = dialog("添加大号" if role == "main" else "添加小号", self) name = QLineEdit() @@ -1004,6 +1819,14 @@ class Window(QMainWindow): owner.addItem(a["name"], a["id"]) if role == "worker": form.addRow("归属大号", owner) + restore = QPushButton("恢复已删除小号") + + def open_restore(): + win.reject() + self.restore_worker() + + restore.clicked.connect(open_restore) + form.addRow(restore) form.addRow( QLabel( "保存后自动打开独立浏览器,请手动登录。UID、昵称、头像和统计信息会自动回填,无需查找或输入账号 ID。" @@ -1106,7 +1929,7 @@ class Window(QMainWindow): ) form.addRow("新归属", owner) text = QLabel( - f"确认后取消旧组全部待执行任务(最近 500 条中有 {pending} 条),不改派、不重发。\n执行中禁止变更;历史与待核对任务保留;登录目录不变。" + f"确认后取消旧组全部待执行任务(最近 500 条中有 {pending} 条),不改派、不重发。\n执行中禁止变更;历史与结果不确定任务保留;登录目录不变。" ) text.setWordWrap(True) form.addRow(text) @@ -1178,31 +2001,6 @@ class Window(QMainWindow): ): self.send("erase", {"id": account["id"]}) - def resolve(self, succeeded): - row = self.table.currentRow() - if row < 0: - return - task = self.data["tasks"][row] - if task["status"] != "unknown": - QMessageBox.information(self, "提示", "只能人工确认待核对任务。") - return - message = ( - "确认已在平台核实该动作成功?依赖步骤可能继续执行。" - if succeeded - else "确认该动作未成功?此任务不再执行,也不自动重发。" - ) - if ( - QMessageBox.question( - self, - "人工核对结果", - message, - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, - QMessageBox.StandardButton.No, - ) - == QMessageBox.StandardButton.Yes - ): - self.send("resolve", {"task": task["id"], "succeeded": succeeded}) - def avatar(self, account): key = account["avatar"] if ( @@ -1230,7 +2028,7 @@ class Window(QMainWindow): self.avatar_pending.discard(key) pixmap = QPixmap() if reply.error() == reply.NetworkError.NoError: - pixmap.loadFromData(reply.readAll()) + pixmap.loadFromData(bytes(reply.readAll().data())) self.icons[key] = ( QIcon(pixmap.scaled(32, 32)) if not pixmap.isNull() else QIcon() ) @@ -1244,11 +2042,12 @@ class Window(QMainWindow): signature = json.dumps(data, sort_keys=True) if signature == self.signature: return - selected = self.selected() + old_account = self.selected() + selected_id = old_account["id"] if old_account else None known = {a["id"] for a in self.data["accounts"]} new_accounts = [a for a in data["accounts"] if a["id"] not in known] if new_accounts: - selected = new_accounts[-1] + selected_id = new_accounts[-1]["id"] checked = self.checked_groups() if self.selection_loaded else data["selected"] self.data = data self.log_info.setText( @@ -1261,91 +2060,27 @@ class Window(QMainWindow): ) self.signature = signature self.selection_loaded = True - self.tree.clear() - groups = {} - for a in data["accounts"]: - if a["role"] == "main": - item = QTreeWidgetItem(self.tree) - item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable) - item.setCheckState( - 0, - Qt.CheckState.Checked - if a["id"] in checked - else Qt.CheckState.Unchecked, - ) - groups[a["id"]] = item - for a in data["accounts"]: - item = ( - groups[a["id"]] - if a["role"] == "main" - else QTreeWidgetItem(groups.get(a["owner"], self.tree)) - ) - item.setData(0, Qt.ItemDataRole.UserRole, a["id"]) - owner = ( - "大号" - if a["role"] == "main" - else ("小号 / 未归属" if not a["owner"] else "小号") - ) - profile = decode(a.get("profile", "{}")) - for col, value in enumerate( - [ - a["name"], - f"{a['uid'] or '待登录自动获取'} · {owner}", - a["nickname"], - a["state"], - "已启用" if decode(a["rule"])["enabled"] else "关闭", - a["login"], - a["identity"], - *[ - str(profile.get(key)) if profile.get(key) is not None else "—" - for key in ( - "follower_count", - "following_count", - "total_favorited", - "aweme_count", - ) - ], - ] - ): - item.setText(col, value) - item.setIcon(0, self.avatar(a)) - if selected and selected["id"] == a["id"]: - self.tree.setCurrentItem(item) - item.setExpanded(True) + display = {} + for account in data["accounts"]: + identity = account.get("identity", "") + state = account.get("state") or "未启动" + if "未通过" in identity or "失败" in state: + color = "#d4380d" + elif account["id"] in data.get("active", []): + color = "#389e0d" + elif "待登录" in account.get("login", ""): + color = "#d48806" + else: + color = "#68707d" + display[account["id"]] = { + "title": account["name"] or "未命名账号", + "meta": f"名称:{account['nickname'] or '待登录自动获取'}", + "status": f"{'监听' if account['role'] == 'main' else '执行'}:{state}", + "color": color, + } + self.card_table.set_accounts(data["accounts"], display, checked, selected_id) self.show_profile() - selected_task = self.table.item(self.table.currentRow(), 0) - selected_task = selected_task.text() if selected_task else None - self.table.setRowCount(len(data["tasks"])) - names = data.get("task_names") or {a["id"]: a["name"] for a in data["accounts"]} - labels = { - "pending": "待执行", - "running": "执行中", - "succeeded": "成功", - "failed": "失败", - "unknown": "待核对", - "cancelled": "已取消", - } - self.counts.setText( - " · ".join( - f"{label} {data.get('counts', {}).get(status, 0)}" - for status, label in labels.items() - ) - ) - for row, task in enumerate(data["tasks"]): - values = [ - task["id"], - task["event"], - names.get(task["source"]), - names.get(task["worker"]), - task["target"], - "关注" if task["action"] == "follow" else "私信", - labels[task["status"]], - task_result_text(task["result"]), - ] - for col, value in enumerate(values): - self.table.setItem(row, col, QTableWidgetItem(str(value))) - if str(task["id"]) == selected_task: - self.table.selectRow(row) + self.refresh_exception_filters() def closeEvent(self, event): if self.allow_close: diff --git a/src/test_accounts.py b/src/test_accounts.py index d432911..fc8780f 100644 --- a/src/test_accounts.py +++ b/src/test_accounts.py @@ -393,7 +393,6 @@ class StoreTest(unittest.TestCase): self.assertEqual( {t["status"] for t in self.store.tasks()}, {"cancelled", "unknown"} ) - self.store.resolve(task["id"], True) self.assertIsNone(self.store.claim(self.worker)) self.store.move(self.worker, None) self.assertIsNone(self.store.account(self.worker)["owner"]) @@ -407,8 +406,6 @@ class StoreTest(unittest.TestCase): self.store.recover() self.assertEqual({t["status"] for t in self.store.tasks()}, {"unknown"}) self.assertEqual(self.store.claim_batch(self.worker), []) - for task in batch: - self.store.resolve(task["id"], True) self.assertEqual(self.store.claim_batch(self.worker), []) def test_parallel_results_are_independent_and_never_resend(self): diff --git a/src/test_activity.py b/src/test_activity.py index b9ae998..0b5dd34 100644 --- a/src/test_activity.py +++ b/src/test_activity.py @@ -3,7 +3,7 @@ import asyncio import logging import tempfile -from datetime import datetime +from datetime import datetime, timezone from pathlib import Path from types import SimpleNamespace from unittest.mock import AsyncMock, patch @@ -36,7 +36,9 @@ def test_daily_files_restore_2000_without_trimming_disk(): "msg": f"entry-{i}", "levelno": logging.INFO, "levelname": "INFO", - "created": datetime(2026, 1, day, 12).timestamp(), + "created": datetime( + 2026, 1, day, 12, tzinfo=timezone.utc + ).timestamp(), "msecs": 0, } ) @@ -130,7 +132,6 @@ def test_business_stages_skips_dependencies_and_rollback(): store.finish(follow["id"], "unknown", {"code": "UNCONFIRMED"}, 45) store.finish(dm["id"], "succeeded", {"status_code": 0}, 80) assert store.claim_batch(worker) == [] - store.resolve(follow["id"], True) text = all_logs(audit) for stage in ( "通知接收", @@ -145,10 +146,11 @@ def test_business_stages_skips_dependencies_and_rollback(): "任务结果", "冷却开始", "并行批次", - "人工核对", ): assert f"[{stage}]" in text assert "PRIVATE_TEMPLATE" in text + assert "结果不确定" in text + assert "人工核对" not in text with pytest.raises(ValueError), store.transaction(): store.log("测试", "ROLLBACK_MUST_NOT_APPEAR") raise ValueError("rollback") @@ -189,7 +191,7 @@ def test_disabled_no_targets_and_waiting_reasons(): audit.close() -def test_soft_delete_preserves_history_releases_uid_and_migrates(): +def test_soft_delete_preserves_history_requires_restore_and_migrates(): with tempfile.TemporaryDirectory() as directory: store = Store(directory) main = store.add("main", "主号", "100") @@ -221,13 +223,15 @@ def test_soft_delete_preserves_history_releases_uid_and_migrates(): "SELECT * FROM accounts WHERE id=?", (worker,) ).fetchone() assert ( - deleted["uid"] is None + deleted["uid"] == "200" and deleted["owner"] is None and deleted["profile"] == "{}" ) assert not store.setting("fingerprint_browser:" + worker) - replacement = store.add("worker", "重新添加", "200", main) - assert replacement != worker and store.claim(replacement) is None + with pytest.raises(ValueError): + store.add("worker", "重新添加", "200", main) + restored = store.restore_worker(worker, main) + assert restored["uid"] == "200" and restored["deleted"] == 0 store.db.execute("ALTER TABLE accounts DROP COLUMN deleted") store.close() reopened = Store(directory) @@ -236,7 +240,7 @@ def test_soft_delete_preserves_history_releases_uid_and_migrates(): r["name"] for r in reopened.db.execute("PRAGMA table_info(accounts)") } assert len(reopened.tasks()) == 2 - assert reopened.account(replacement)["uid"] == "200" + assert reopened.account(worker)["uid"] == "200" finally: reopened.close() diff --git a/src/test_onboarding_ui.py b/src/test_onboarding_ui.py index 3c0beb0..edeba71 100644 --- a/src/test_onboarding_ui.py +++ b/src/test_onboarding_ui.py @@ -12,11 +12,9 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") from PySide6.QtCore import Qt from PySide6.QtWidgets import ( QApplication, - QCheckBox, QDialog, QLineEdit, QMessageBox, - QTableWidget, ) from account_store import DEFAULT_RULE @@ -81,9 +79,9 @@ class OnboardingUiTest(unittest.TestCase): "browser_overrides": {}, } self.window.refresh(snapshot) - item = self.window.tree.topLevelItem(0) - assert item is not None - self.assertIn("待登录自动获取", item.text(1)) + card = self.window.card_table.cards[account["id"]] + self.assertIn("待登录自动获取", card.meta_label.text()) + self.assertIn("等待登录", card.status_label.text()) account.update( uid="301", nickname="平台昵称", @@ -97,10 +95,55 @@ class OnboardingUiTest(unittest.TestCase): ), ) self.window.refresh(snapshot) - item = self.window.tree.topLevelItem(0) - assert item is not None - self.assertEqual(item.text(2), "平台昵称") - self.assertEqual([item.text(i) for i in range(7, 11)], ["0", "12", "123", "4"]) + card = self.window.card_table.cards[account["id"]] + self.assertIn("平台昵称", card.meta_label.text()) + for text in ("粉丝 0", "关注 12", "获赞 123", "作品 4"): + self.assertIn(text, self.window.profile_summary.text()) + + def test_account_tree_expands_and_collapses_workers(self): + main = { + "id": "m" * 32, + "name": "主账号", + "role": "main", + "owner": None, + "uid": "100", + "nickname": "主号名称", + "avatar": "", + "profile": "{}", + "rule": json.dumps(DEFAULT_RULE), + "login": "已登录", + "identity": "已绑定", + "state": "未启动", + } + worker = dict(main) + worker.update( + id="w" * 32, + name="小号别名", + role="worker", + owner=main["id"], + uid="200", + nickname="小号名称", + ) + snapshot = { + "accounts": [main, worker], + "tasks": [], + "counts": {}, + "selected": [], + "active": [], + "browser": {}, + "browser_overrides": {}, + } + self.window.refresh(snapshot) + node = self.window.card_table.nodes[main["id"]] + self.assertTrue(node.expanded) + node.card.toggle_button.click() + self.assertFalse(node.expanded) + node.card.toggle_button.click() + self.assertTrue(node.expanded) + self.assertEqual( + self.window.card_table.cards[worker["id"]].meta_label.text(), + "名称:小号名称", + ) def test_log_stream_is_incremental_and_capped(self): self.backend.logs_ready.emit([f"entry-{i}" for i in range(2502)]) @@ -162,6 +205,7 @@ class OnboardingUiTest(unittest.TestCase): def test_history_preview_requires_selection_and_explicit_confirmation(self): payload = { "account": "main", + "baseline": "rule-and-works-fingerprint", "items": [ { "nid": "9007199254740993", @@ -181,25 +225,35 @@ class OnboardingUiTest(unittest.TestCase): ], } - def choose(dialog): - table = dialog.findChild(QTableWidget) - assert table is not None and "完整评论正文" in table.item(0, 6).text() - assert "{" not in " ".join( - table.item(0, column).text() for column in range(table.columnCount()) - ) - table.item(0, 0).setCheckState(Qt.CheckState.Checked) - return QDialog.DialogCode.Accepted - with ( - patch.object(QDialog, "exec", choose), + patch.object(QMessageBox, "information"), patch.object( QMessageBox, "question", return_value=QMessageBox.StandardButton.Yes ), patch.object(self.window, "send") as send, ): self.window.history_dialog(payload) + table = self.window.history_table + content = table.item(0, 6) + assert content is not None + self.assertIn("完整评论正文", content.text()) + values = [] + for column in range(table.columnCount()): + cell = table.item(0, column) + assert cell is not None + values.append(cell.text()) + self.assertNotIn("{", " ".join(values)) + check = table.item(0, 0) + assert check is not None + check.setCheckState(Qt.CheckState.Checked) + self.window.confirm_history() send.assert_called_once_with( - "history_enqueue", {"id": "main", "ids": ["9007199254740993"]} + "history_enqueue", + { + "id": "main", + "ids": ["9007199254740993"], + "baseline": "rule-and-works-fingerprint", + }, ) def test_works_preview_saves_selected_mode_and_full_business_data(self): @@ -221,26 +275,30 @@ class OnboardingUiTest(unittest.TestCase): ], } - def choose(dialog): - table = dialog.findChild(QTableWidget) - assert table is not None and "完整作品描述" in table.item(0, 2).text() - assert "{" not in " ".join( - table.item(0, column).text() for column in range(table.columnCount()) - ) - table.item(0, 0).setCheckState(Qt.CheckState.Checked) - all_mode = next( - box - for box in dialog.findChildren(QCheckBox) - if "监控全部作品" in box.text() - ) - all_mode.setChecked(False) - return QDialog.DialogCode.Accepted - with ( - patch.object(QDialog, "exec", choose), + patch.object( + self.window, + "selected", + return_value={"id": "main", "role": "main"}, + ), patch.object(self.window, "send") as send, ): self.window.works_dialog(payload) + table = self.window.works_table + description = table.item(0, 2) + assert description is not None + self.assertIn("完整作品描述", description.text()) + values = [] + for column in range(table.columnCount()): + cell = table.item(0, column) + assert cell is not None + values.append(cell.text()) + self.assertNotIn("{", " ".join(values)) + check = table.item(0, 0) + assert check is not None + check.setCheckState(Qt.CheckState.Checked) + self.window.works_all_mode.setChecked(False) + self.window.save_work_filter() send.assert_called_once_with( "work_filter", { diff --git a/src/test_plan01.py b/src/test_plan01.py new file mode 100644 index 0000000..1d49685 --- /dev/null +++ b/src/test_plan01.py @@ -0,0 +1,134 @@ +"""Plan01 safety regressions that do not require Qt, Patchright, or a browser.""" + +import asyncio +import tempfile +import unittest + +from account_engine import Engine +from account_store import DEFAULT_RULE, Store + + +class Plan01StoreTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.store = Store(self.temp.name) + self.main = self.store.add("main", "主账号", "1001") + self.worker = self.store.add("worker", "执行账号", "2001", self.main) + self.store.set_rule( + self.main, + { + **DEFAULT_RULE, + "enabled": True, + "follow": True, + "text": "plan01", + }, + ) + + def tearDown(self): + self.store.close() + self.temp.cleanup() + + def test_task_stage_times_and_unknown_are_read_only(self): + self.store.ingest( + self.main, + { + "user_id": "1001", + "nid_str": "12345", + "follow": {"from_user": [{"uid": "3001"}]}, + }, + ) + self.store.dispatch(self.main) + task = self.store.claim(self.worker) + self.assertIsNotNone(task) + row = self.store.tasks(worker=self.worker)[0] + self.assertIsNotNone(row["created_at"]) + self.assertIsNotNone(row["started_at"]) + self.store.recover() + row = self.store.tasks(worker=self.worker, statuses=["unknown"])[0] + self.assertEqual(row["status"], "unknown") + self.assertIsNone(row["finished_at"]) + self.assertIn("不确定", row["result"]) + + def test_deleted_uid_requires_explicit_restore(self): + self.store.begin_deletion(self.worker, "delete_worker", [self.temp.name]) + self.assertEqual(self.store.account(self.worker)["delete_state"], "pending") + self.store.delete_worker(self.worker) + deleted = self.store.account_any(self.worker) + self.assertEqual(deleted["deleted"], 1) + self.assertEqual(deleted["uid"], "2001") + with self.assertRaises(ValueError): + self.store.add("worker", "重复身份", "2001", self.main) + restored = self.store.restore_worker(self.worker, self.main) + self.assertEqual(restored["uid"], "2001") + self.assertEqual(restored["deleted"], 0) + + def test_rule_fingerprint_changes_before_history_enqueue(self): + before = self.store.config_fingerprint(self.main) + self.store.set_rule( + self.main, + { + **DEFAULT_RULE, + "enabled": True, + "follow": True, + "text": "changed", + }, + ) + self.assertNotEqual(before, self.store.config_fingerprint(self.main)) + + +class Plan01EngineTest(unittest.TestCase): + def test_start_preview_and_unknown_query_are_read_only(self): + temp = tempfile.TemporaryDirectory() + engine = Engine(temp.name, None) + main = engine.store.add("main", "主账号", "1001") + worker = engine.store.add("worker", "执行账号", "2001", main) + engine.store.set_rule( + main, + { + **DEFAULT_RULE, + "enabled": True, + "follow": True, + "text": "preview", + }, + ) + engine.store.ingest( + main, + { + "user_id": "1001", + "nid_str": "12345", + "follow": {"from_user": [{"uid": "3001"}]}, + }, + ) + engine.store.dispatch(main) + self.assertIsNotNone(engine.store.claim(worker)) + engine.store.recover() + + async def check(): + preview = await engine.command("start_preview", {"groups": [main]}) + self.assertEqual(preview["groups"], [main]) + self.assertEqual(preview["tasks"], []) + query = await engine.command( + "task_query", {"source": main, "worker": worker} + ) + self.assertEqual(query["tasks"][0]["status"], "unknown") + self.assertIn("不确定", query["tasks"][0]["result"]) + await engine.shutdown() + + asyncio.run(check()) + temp.cleanup() + + def test_manual_resolution_command_is_not_exposed(self): + temp = tempfile.TemporaryDirectory() + engine = Engine(temp.name, None) + + async def check(): + with self.assertRaises(ValueError): + await engine.command("resolve", {"task": 1, "succeeded": True}) + await engine.shutdown() + + asyncio.run(check()) + temp.cleanup() + + +if __name__ == "__main__": + unittest.main()