Files
cmshoppe/docs/tasks/T-639.md
T
chengma b68e22c094
Tests / Python 3.11 / Windows (push) Has been cancelled
fix(product-suite): finalize and cancel generation
2026-07-16 17:12:41 +08:00

190 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
id: T-639
title: 商品套图生成完成状态复位与可响应停止
phase: 7
deps: [T-638]
status: DONE
created: 2026-07-16
---
## 问题 / 背景
⑥「商品套图」点击「生成套图」后出现两个关联问题:
1. 本轮预定数量的图片已经全部生成并显示,但按钮仍停留在「停止生成」,没有完成弹窗,也不会恢复为「生成套图」。
2. 用户手动点击停止并确认后,按钮显示「正在停止...」,但长时间没有结束,表现为停止无效。
本地 SQLite 最近一轮现场显示:同一项目 5 个 job 全部为 `succeeded`,没有 `queued/submitted/running`,生成资产也已全部入库。这说明服务端生成、图片下载和保存已经完成,卡住的是客户端生成生命周期,不是 cmhub 仍在继续生成。
当前代码存在以下缺口:
- `SuiteTaskState.generation_running()` 只判断 `state.worker is not None`;只有 `_on_generation_finished()` 收到 `finished/cancelled` 信号后才清除 `state.worker/thread`。没有 `QThread.finished`、本轮 job 终态或超时看门狗兜底,最终信号未正常处理时 GUI 会永久保持运行状态。
- 生成信号目前通过捕获 `state` 的 lambda 回调,没有本轮唯一标识。旧 worker 晚到的 progress/finished 信号可能影响同一任务后续新一轮生成。
- `BaseWorker.cancel()` 只设置布尔标记。`run_jobs()` 在 `wait(..., FIRST_COMPLETED)` 中没有短超时,所有 future 都在运行或阻塞时,主调度循环无法及时观察停止标记。
- `future.cancel()` 只能取消尚未开始的任务,已进入提交、轮询或下载的任务需要各阶段主动检查停止标记。
- 提交请求单次最长约 36 秒、轮询请求单次最长约 15 秒;图片下载使用最长 900 秒读取预算,`_download_and_save_job_image()` 没有向下载层传入 `should_stop`。
- Windows curl 下载使用阻塞式 `subprocess.run()`,requests 下载虽然按块读取,但块循环没有检查停止状态。因此下载阶段是手动停止最明显的无响应区。
- 当前测试只 mock `run_jobs()` 后直接调用 worker `execute()`,没有覆盖真实 `QThread` 完成信号、终态兜底、旧信号隔离和下载中停止。
## 方案
### 1. 每轮生成增加唯一运行标识
修改 `app/gui/tabs/product_suite.py`、`app/gui/workers.py`:
- 每次 `start_generation()` 创建不可复用的 `run_token`,写入当前 `SuiteTaskState` 和 `ProductSuiteGenerateWorker`。
- progress、finished、cancelled、failed 与线程结束处理都必须携带或能够解析该 `run_token`。
- GUI 处理信号前同时校验 `state.key + run_token`;不是当前活动轮次的旧信号只做必要日志,不修改按钮、进度、结果筛选或完成弹窗。
- 一轮只允许执行一次统一 finalize;`finished`、`cancelled`、`failed`、线程结束兜底和终态看门狗即使先后到达,也不能重复弹窗或重复清状态。
- 生成信号优先连接到 `ProductSuiteTab` 的 QObject 绑定槽或等价明确主线程上下文,避免 ThreadPoolExecutor/QThread 发出的信号通过无上下文 lambda 直接操作 QWidget。
不修改历史 job 的 `task_key/task_id` 幂等语义;`run_token` 只用于本次 GUI/worker 生命周期,不写入 SQLite schema。
### 2. 统一生成收尾与数据库终态兜底
新增统一 finalize 入口,负责:
- 停止本轮完成看门狗;
- 清除当前匹配轮次的 `state.worker/state.thread/state.started_at`;
- 根据本轮 `job_ids` 刷新成功、失败、停止数量;
- 恢复「生成套图(N)」按钮、编辑控件和原图操作;
- 刷新当前结果;
- 正常完成、失败或停止只给一次中文汇总。
收尾来源:
1. 正常 `worker.finished/cancelled/failed`;
2. `QThread.finished` 兜底;
3. 本轮所有 `job_ids` 已连续两次确认进入终态,但 worker 最终信号仍未完成的终态看门狗。
终态集合为 `succeeded/failed/expired/cancelled`。只有本轮已知 `job_ids` 数量等于计划数量,且每一项都进入终态时,才可用数据库结果兜底完成;不得把历史图片数量或项目全部历史 job 数量当成本轮完成依据。
若线程已结束但仍有非终态 job:
- GUI 仍必须解除永久卡死状态;
- 非终态且已有 `task_id` 的 job 保留/修正为可继续查询语义;
- 没有 `task_id` 且未开始的 job 标记为 `cancelled`;
- 显示中文提示「生成线程已结束,部分任务可稍后继续查询」,不能误报全部成功。
若数据库已确认本轮全部终态而旧线程仍未退出:
- GUI 可结束当前轮次并恢复操作;
- 旧 worker/thread 引用继续保留到真实 `QThread.finished`,不得提前销毁;
- 旧线程后续信号因 `run_token` 失效而不得覆盖新轮次。
### 3. 调度循环及时响应停止
修改 `app/image_studio_generation.py`:
- `wait(..., FIRST_COMPLETED)` 增加约 `100~250ms` 的短超时,使主循环能够持续检查 `should_stop()`。
- 用户停止后立即取消尚未开始的 futures,并把对应 job 标记为 `cancelled`。
- 已经进入全局 semaphore 等待、但尚未提交 cmhub 的任务必须在获得槽位后首先检查停止标记,不再提交。
- 已有 `task_id` 的任务停止本地等待后保留 `JOB_RECOVERY_RESUME`,提示服务端任务可能继续完成、下次可继续查询。
- 停止汇总必须满足 `success + failed + cancelled == total`,不遗留永远处于 queued/running 的本轮 job。
- `ThreadPoolExecutor` 仍采用协作式停止,不使用线程强杀。
### 4. 提交、轮询和下载阶段支持停止
提交和轮询:
- 请求前后都检查 `should_stop()`。
- 已发出的 HTTP 请求不做不安全强杀;最迟在本次有界请求返回后停止。提交约受 36 秒单次预算约束,轮询约受 15 秒单次预算约束。
- 轮询 sleep 继续使用可取消等待,停止后不再发下一次 GET。
图片下载:
- `_download_and_save_job_image()`、`ai._download_cmhub_image_with_retry()` 及内部 requests/curl 下载路径增加可选 `should_stop`,默认 `None` 保持现有调用兼容。
- 下载开始前、每次重试前后、重试等待期间和保存 JPEG 前检查停止。
- requests 流式下载每个数据块后检查停止;停止时关闭 response、丢弃内存数据和临时文件。
- Windows curl 从阻塞式 `subprocess.run()` 调整为隐藏窗口的 `subprocess.Popen()` + 短间隔 `poll()`;检测停止时终止 curl,等待有界退出,必要时再 kill,并清理 curl 配置和临时图片。
- 下载取消必须映射为 `cancelled`,不能记录成普通下载失败;已拿到 `task_id` 的 job 保留 `JOB_RECOVERY_RESUME`。
- 若图片已下载到临时路径但尚未入资产库时收到停止,删除本轮临时文件,不新增 asset。
### 5. 停止按钮和用户反馈
- 第一次点击「停止生成」并确认后设置取消标记,按钮显示「正在停止...」。
- 停止处理中再次点击不重复弹确认框,只提示「正在停止当前套图任务」。
- 进度区域继续显示已完成、失败、停止数量;不能冻结在旧值。
- 停止完成汇总明确区分:
- 已成功保存的图片;
- 已失败任务;
- 已停止、后续可继续查询的任务。
- 不向用户展示 cmhub API 路径、图片 URL、Python 异常对象或线程实现细节。
### 6. 诊断日志
增加脱敏后的商品套图生命周期日志,至少包含:
- `run_token` 的短标识、项目 ID、本轮计划数和 job ID 数;
- stop requested;
- worker finished/cancelled/failed;
- thread finished fallback;
- terminal watchdog finalize;
- 最终 success/failed/cancelled/active 数量和总用时。
日志不得记录提示词全文、API Key、图片 URL、账号密码或 Cookie。
## 验收要点
- [ ] 本轮计划 N 张全部成功后,最后一个 job 入库后约 2 秒内自动恢复「生成套图(N)」并只弹一次中文完成汇总。
- [ ] 数据库本轮 job 已全部终态但 worker 最终信号被模拟丢失时,终态看门狗仍能恢复 GUI,不永久显示「停止生成」。
- [ ] worker 线程异常结束时,`QThread.finished` 兜底清理状态并给中文提示。
- [ ] 旧轮次的迟到 progress/finished 信号不会清除或覆盖新轮次状态。
- [ ] 停止后尚未开始的任务立即取消,不再提交 cmhub。
- [ ] 停止轮询中的任务时,不再发下一次轮询;已有 `task_id` 的 job 可在后续继续查询。
- [ ] 停止 requests 下载时会在数据块边界退出并关闭 response,不保存半张图片。
- [ ] 停止 curl 下载时 curl 子进程被有界终止,且不会弹出黑色控制台窗口。
- [ ] 停止后本轮所有 job 最终都有明确状态,汇总满足 `success + failed + cancelled == total`。
- [ ] 停止过程中按钮和进度仍有反馈,结束后控件恢复,不需要重启软件。
- [ ] 正常生成、历史重试、继续查询、全局最多 5 个 cmhub 在途任务和点数记录语义不受影响。
- [ ] 不出现 `QThread: Destroyed while thread is still running`。
## 测试
- `tests/test_product_suite_gui.py`:
- 正常完成恢复按钮并只汇总一次;
- worker finished 信号缺失时由终态看门狗完成;
- thread finished 异常兜底;
- 旧 `run_token` 信号不影响新轮次;
- 停止按钮重复点击语义。
- `tests/test_product_suite_workers.py`:
- worker 所有进度/结果携带同一 `run_token`;
- cancel 汇总数量完整。
- `tests/test_image_studio_generation.py`:
- 调度 wait 短超时后能取消排队 future;
- semaphore 等待任务停止后不提交;
- 轮询停止保留 resume;
- 下载停止删除临时文件并记 cancelled。
- `tests/test_ai.py`:
- requests 分块下载取消并关闭 response;
- curl Popen 取消、terminate/kill 和临时文件清理;
- 正常 requests/curl 下载兼容。
- 运行:
```bash
py -3.10 -m unittest tests.test_product_suite_gui
py -3.10 -m unittest tests.test_product_suite_workers
py -3.10 -m unittest tests.test_image_studio_generation
py -3.10 -m unittest tests.test_ai
py -3.10 -m unittest discover -s tests
py -3.10 -m ruff check app tests main.py
py -3.10 -m compileall app main.py
git diff --check
```
## 边界(不改什么)
- 不修改 SQLite schema,不新增持久化 `run_token` 字段。
- 不调用或假设存在 cmhub 服务端取消任务接口;客户端停止不代表服务端已停止,也不承诺退回点数。
- 不使用 `QThread.terminate()`、Python 线程强杀或结束整个应用进程。
- 不删除已成功保存的历史生成图片。
- 不改变套图数量计算、分类设置、提示词模板、比例、模型别名、并发上限或计费规则。
- 不修改 CDP、蝦皮主图拉取、①导入采集、②AI生成、③更新蝦皮、④账号管理或⑤设置。
## 执行记录
- 2026-07-16:每轮商品套图生成新增内存 `run_token`,worker 的 progress/summary 携带同一 token,GUI 改用主线程绑定槽处理信号。正常结果、`QThread.finished` 和每750ms检查的本轮 job 终态看门狗统一进入幂等 finalize;旧轮次迟到信号会被忽略,旧线程引用仍由模块级容器保留到真实结束。
- 2026-07-16:统一 finalize 按本轮明确 `job_ids` 查询 SQLite,恢复按钮和编辑控件,并只显示一次中文完成/停止/异常汇总。线程异常结束时,已有 `task_id` 的非终态 job 记为 `cancelled + resume`,未提交任务记为 `cancelled + regenerate`;生命周期诊断日志只记录 token 短值、项目ID、数量、来源和用时,不记录提示词、URL或密钥。
- 2026-07-16:`image_studio_generation.run_jobs()` 的 future wait 改为200ms短轮询,停止后及时取消未开始任务;提交/轮询请求返回后再次检查停止。下载链路新增可选 `should_stop`:requests 在流式数据块边界取消并关闭 response,curl 改为隐藏窗口 `Popen`,停止时有界 terminate/kill,重试等待可取消,停止后的本地文件不入资产库。
- 2026-07-16:补齐真实 QThread 完成、终态信号丢失兜底、线程异常收尾、旧 token、重复停止、立即停止、排队 future 取消、下载取消 resume、requests response 关闭和 curl 子进程/临时文件清理测试。相关 90 项通过;隔离工作树全量 533 项 unittest、Ruff、compileall、`git diff --check` 全部通过。当前主工作区全量测试仅有3项原有封面默认模板 `papa1` 改名导致的失败,该未提交用户改动未纳入本任务。