diff --git a/docs/tasks/T-604.md b/docs/tasks/T-604.md new file mode 100644 index 0000000..750c338 --- /dev/null +++ b/docs/tasks/T-604.md @@ -0,0 +1,120 @@ +--- +id: T-604 +title: AI工场拉取主图 QThread 生命周期闪退修复 +phase: 7 +deps: [T-591] +status: TODO +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 生图、下载、导出业务规则。 +- 不引入新的多线程框架。 + +## 执行记录 + +待执行。