--- id: T-604 title: AI工场拉取主图 QThread 生命周期闪退修复 phase: 7 deps: [T-591] status: DONE created: 2026-07-11 --- ## 问题 / 背景 运行最新代码,在⑥AI工场输入商品ID后点击「拉取主图」,程序可能闪退,控制台报错: ```text QThread: Destroyed while thread is still running ``` 初步分析确认这不是 Shopee/CDP 读取主图本身的错误,而是 AI工场 tab 的后台线程生命周期管理不一致导致: - `app/gui/tabs/image_studio.py` 的 `_start_worker()` 只用 `self._running_thread` 保存当前 `QThread`。 - `ImageStudioPullImagesWorker` 返回结果或失败后,`_on_pull_finished()` / `_on_worker_failed()` 会调用 `_finish_worker()`。 - `_finish_worker()` 立刻把 `self._running_thread = None`。 - 此时 `worker.finished` 虽然已经发出,但 `QThread` 可能还没有真正发出 `thread.finished`、事件循环还没退出。 - Python 引用被提前清掉后,Qt 线程对象可能在仍运行时被销毁,从而触发闪退。 对比①采集、②AI生成、③更新、⑤设置等模块,它们都是把线程引用保留到 `thread.finished` 后再清理。⑥AI工场当前没有按同样模式处理,所以「拉取主图」「下载原图」「生成图片」「继续查询」「导出终选」这些共用 `_start_worker()` 的操作理论上都有同类风险,只是拉取主图在失败/快速返回时更容易触发。 还有一个交互缺口:`pull_main_images()` 如果 `current_project` 不为空,会忽略用户刚在输入框里填的新商品ID,继续使用当前项目的旧 `account_alias/item_id`。这会造成“输入商品ID拉取主图”行为不符合用户预期。 ## 方案 ### 1. 修复 AI工场 QThread 生命周期 修改 `app/gui/tabs/image_studio.py`: - `_start_worker(worker, thread_name)` 改成与其他 tab 一致的模式: - 调用 `run_worker(worker, thread_name=thread_name, start=False)`。 - 连接 `thread.finished` 到新的 `_forget_running_thread(thread)`。 - 先保存 `self._running_worker = worker`、`self._running_thread = thread`。 - 设置运行态后再 `thread.start()`。 - 新增 `_forget_running_thread(thread)`: - 只有 `self._running_thread is thread` 时,才把 `self._running_thread = None`、`self._running_worker = None`。 - 只在 `thread.finished` 后清理线程引用。 - `_finish_worker()` 不再直接清空 `self._running_thread`。 - 它只负责恢复按钮/控件运行态。 - 如需避免重复恢复 UI,可以增加 `_worker_running` 或通过当前线程引用判断,保持幂等。 ### 2. 避免失败路径重复处理 当前 `BaseWorker.run()` 异常时会先发 `failed`,再发 `finished({"ok": False})`。AI工场的各个 finished 槽又会在 `ok=False` 时转调 `_on_worker_failed()`,可能导致同一个失败重复弹窗/重复清理。 本任务同步收敛 AI工场失败处理: - 对 `worker.failed` 仍保留 `_on_worker_failed()`,显示中文错误和恢复 UI。 - 对各 `_on_*_finished(summary)` 中 `summary.get("ok") is False` 的分支,避免再次弹窗;可以直接返回,或只在当前仍处于 running 且未处理失败时处理一次。 - 不改 `BaseWorker` 的全局行为,避免影响其他 tab。 ### 3. 拉取主图尊重当前输入 修改 `pull_main_images()`: - 点击「拉取主图」时,以当前账号下拉和商品ID输入框为准。 - 如果当前输入与 `current_project` 不一致,允许创建/切换到对应项目后再拉取。 - 不再因为 `current_project is not None` 就强制覆盖成旧项目的 `account_alias/item_id`。 - 保留输入为空、账号为空时的中文阻断提示。 ### 4. 不改 CDP 读取主图业务链路 本任务不修改: - `app/image_studio.py::pull_remote_main_image_urls()` 的业务语义。 - `app/editor.py` 的 `open_product()`、`read_product_image_urls()`、`close_readonly_product()`。 - 账号登录检测、Chrome 启动、Shopee 页面选择器。 如果拉取失败,仍应通过现有 worker 错误路径给中文弹窗和日志,不应闪退。 ## 验收要点 - 在⑥AI工场输入商品ID,点击「拉取主图」,成功时不闪退,主图列表刷新。 - 商品ID无效、账号未登录、Chrome/CDP不可用、Shopee页面加载失败等快速失败场景下,不再出现 `QThread: Destroyed while thread is still running`。 - 「拉取主图」失败只弹一次错误提示,不重复弹窗、不重复写同一条失败日志。 - 拉取主图运行期间按钮禁用;完成或失败后按钮恢复。 - 连续多次拉取不同商品ID时,使用当前输入框里的商品ID,不被旧 `current_project` 覆盖。 - 「下载原图」「开始生成」「继续查询任务」「导出终选」这些共用 AI工场 `_start_worker()` 的操作不回归,完成/失败后线程引用都在 `thread.finished` 后清理。 - 不改变 CDP 读取蝦皮主图逻辑,不改变商品详情页打开/关闭规则。 ## 测试要求 优先补 GUI 单元测试,覆盖线程生命周期和拉取参数: - `tests/test_gui.py` 或新增合适测试文件: - mock `run_worker()` 返回可控 fake thread,确认 `_start_worker()` 先保存线程,只有触发 `thread.finished` 后才清空引用。 - 调用 `_finish_worker()` 后确认不会提前清空 `self._running_thread`。 - 构造 `current_project` 与输入框商品ID不一致的场景,确认 `ImageStudioPullImagesWorker` 使用当前输入的商品ID。 验证命令: ```bash python -m ruff check app tests main.py py -3.10 -m compileall app main.py py -3.10 -m unittest discover -s tests git diff --check ``` 如能人工验证,补充运行 GUI 后的实测: - 输入一个有效商品ID拉取主图成功。 - 输入一个无效商品ID拉取主图失败但不闪退。 ## 边界(不改什么) - 不改 `app/workers.py` 的全局 `BaseWorker.run()` 和 `run_worker()` 行为,避免影响①②③⑤。 - 不改 Shopee/CDP 选择器和读取主图逻辑。 - 不改 AI工场 DB schema。 - 不改 cmhub 生图、下载、导出业务规则。 - 不引入新的多线程框架。 ## 执行记录 - 2026-07-11:已完成。 - 代码: - `app/gui/tabs/image_studio.py`:AI工场 `_start_worker()` 改为 `run_worker(..., start=False)` 后保存 `worker/thread`,连接 `thread.finished` 再清理引用,最后手动 `thread.start()`;`_finish_worker()` 不再提前置空 `self._running_thread`,避免 `QThread` 仍运行时被销毁。 - `app/gui/tabs/image_studio.py`:新增 `_worker_error_handled` 与 `_handle_finished_error()`,收敛 `failed` + `finished(ok=False)` 双信号路径,避免失败重复弹窗/重复日志。 - `app/gui/tabs/image_studio.py`:`pull_main_images()` 改为使用当前账号下拉和商品ID输入框,不再被旧 `current_project` 覆盖。 - 测试: - `tests/test_gui.py`:新增 AI工场线程生命周期测试,确认 `_finish_worker()` 后线程引用仍保留,只有 `thread.finished` 后才清理。 - `tests/test_gui.py`:新增失败只处理一次测试,覆盖 `failed` 后又收到 `finished(ok=False)` 不重复弹窗。 - `tests/test_gui.py`:新增拉取主图参数测试,覆盖当前输入商品ID不同于旧项目时仍传入当前输入。 - 验证: - 主工作区定向验证通过: - `py -3.10 -m unittest tests.test_gui.GuiTests.test_image_studio_worker_thread_is_kept_until_thread_finished tests.test_gui.GuiTests.test_image_studio_worker_failure_is_handled_once tests.test_gui.GuiTests.test_image_studio_pull_main_images_uses_current_item_input` - `python -m ruff check app tests main.py` - `py -3.10 -m compileall app main.py` - `py -3.10 -m unittest tests.test_gui.GuiTests.test_image_studio_tab_builds_project_pool_and_template_controls tests.test_gui.GuiTests.test_image_studio_generation_log_uses_cmhub_tier_summary tests.test_gui.GuiTests.test_image_studio_worker_thread_is_kept_until_thread_finished tests.test_gui.GuiTests.test_image_studio_worker_failure_is_handled_once tests.test_gui.GuiTests.test_image_studio_pull_main_images_uses_current_item_input` - `git diff --check -- app\gui\tabs\image_studio.py tests\test_gui.py docs\tasks\T-604.md` - 主工作区全量 `py -3.10 -m unittest discover -s tests` 因既有未提交默认提示词改动失败 3 个用例,失败内容为 `papa1` 与乱码 `默认` 文件名不一致,不属于 T-604。 - 为满足提交前全绿验证,已在临时干净 worktree `D:\chengma\cmshopee-t604-verify` 只应用 T-604 diff 后通过: - `python -m ruff check app tests main.py` - `py -3.10 -m compileall app main.py` - `py -3.10 -m unittest discover -s tests`(378 tests) - `git diff --check -- app\gui\tabs\image_studio.py tests\test_gui.py docs\tasks\T-604.md`