--- 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` 改名导致的失败,该未提交用户改动未纳入本任务。