diff --git a/docs/tasks/T-639.md b/docs/tasks/T-639.md new file mode 100644 index 0000000..dae69e0 --- /dev/null +++ b/docs/tasks/T-639.md @@ -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生成、③更新蝦皮、④账号管理或⑤设置。 + +## 执行记录