docs: add T-639 generation lifecycle task

This commit is contained in:
chengma
2026-07-16 16:49:49 +08:00
parent f5892bb310
commit 2675f85598
+184
View File
@@ -0,0 +1,184 @@
---
id: T-639
title: 商品套图生成完成状态复位与可响应停止
phase: 7
deps: [T-638]
status: TODO
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生成、③更新蝦皮、④账号管理或⑤设置。
## 执行记录