--- id: T-694 title: 商品套图拖放源项二次删除修复 phase: 7 deps: [T-693] status: DONE created: 2026-07-23 --- ## 问题 / 背景 T-693 将排序信号延后到 `super().startDrag(Qt.MoveAction)` 返回后,解决了拖拽期间同步刷新列表的问题,但真实 GUI 连续把参考图拖到第一位时,仍会按照拖动顺序逐张从商品原图区域消失,最终只剩下未被拖动的主图。 现场 SQLite 再次确认原图记录、状态和本地文件均完整,列表消失没有写入数据库。真实根因是当前代码同时使用两套移动所有权:自定义 `dropEvent()` 已通过 `takeItem()` + `insertItem()` 手动移动源项,而 `QAbstractItemView.startDrag()` 在 `MoveAction` 完成后还会按 Qt 内建 model/view 语义清理移动源项。一次拖拽因此发生“手动移动一次 + Qt 源端删除一次”,每轮少一个 GUI 项。 T-693 的测试直接调用 `_begin_internal_drag()`、`_move_dragged_item_to()`、`_finish_internal_drag()`,绕过了真实的 `super().startDrag()`,所以没有覆盖 Qt 返回 MoveAction 后的源端清理,形成了错误的全绿结果。 ## 方案 ### 1. 单一拖拽所有权 - `ProductOriginalList.startDrag()` 不再调用 `super().startDrag(Qt.MoveAction)`。 - 由列表自行创建 `QDrag`,source 固定为当前列表;MIME 只承载当前单张原图的内部拖拽数据,拖拽缩略图作为可选视觉反馈,并以 `Qt.MoveAction` 执行。 - 自定义 `dropEvent()` 继续是唯一改变列表顺序的位置,只调用一次受控 `takeItem()` + `insertItem()`;不交给 Qt 默认 model drop,也不允许 Qt 再删除移动源项。 - `QDrag.exec()` 返回后清理拖拽上下文,再按 T-693 的快照规则提交一次唯一顺序。 ### 2. 失败自动恢复 - 拖拽开始快照、待提交顺序和结束时列表必须数量相等、ID 唯一且集合一致。 - 若拖拽结束时列表数量或 ID 集合异常,不写库;发送独立恢复信号,让 `ProductSuiteTab` 从 SQLite 重新加载当前商品原图,并显示“原图排序未完成,已恢复原顺序,请重试”。 - 恢复信号不得复用 `orderChanged`,避免把残缺或旧顺序误写数据库;切换任务、数据库异常或页面已关闭时保持无破坏失败。 - SQLite `reorder_original_assets()` 的完整集合、重复 ID 和项目归属校验继续作为服务层最后防线。 ### 3. 覆盖真实 startDrag 路径 - 测试必须调用 `ProductOriginalList.startDrag()`,用可控 `QDrag` 替身在 `exec()` 中触发一次内部移动;验证没有调用基类 `startDrag()` 的二次删除语义,结束后图片数量和 ID 集合不变且只发送一次 `orderChanged`。 - 连续把参考1、参考2、参考3、参考4移到第一位,每轮都经过完整自定义 `startDrag()` 生命周期,并验证 SQLite 顺序、缩略图数量和“主图 / 参考N”连续标签。 - 模拟 `QDrag.exec()` 返回前列表被意外移除一项,验证不发送 `orderChanged`、发送一次恢复信号,页面从 SQLite 恢复全部原图。 - 保留原地释放、取消拖拽、重复 ID、排序锁定和外部文件 URL 拖入回归。 ## 验收要点 - 连续把多张参考图拖到第一位,商品原图数量始终不变,不再按拖动顺序消失。 - 每次有效拖拽只移动一个现有项,不复制、不删除;结束后 GUI 与 SQLite 的图片 ID 集合及顺序一致。 - 拖拽过程中若出现任何数量或 ID 异常,界面自动恢复数据库顺序,不把异常状态保存。 - 原图不少于两张时始终存在“参考1”,所有参考图按当前位置连续编号。 - 自动验证: - `py -3.10 -m unittest tests.test_product_suite_gui tests.test_image_studio tests.test_product_suite` - `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、图片文件、原图删除功能、生成任务、提示词、cmhub、蝦皮 CDP 或①至⑤模块。 - 不改外部文件拖入和剪贴板导入;内部 MIME 只用于当前列表单张图片排序,不接受跨列表或跨商品拖放。 - 不以 `CopyAction` 伪装移动来规避 Qt 清理,不使用固定延时,不放宽服务层数据完整性校验。 ## 执行记录 - 2026-07-23 完成。 - `ProductOriginalList.startDrag()` 不再调用 `QAbstractItemView.startDrag(MoveAction)`,改为创建 source 为当前列表的 `QDrag`,使用私有 MIME 类型承载单张原图 ID,并复用缩略图作为拖拽视觉反馈。自定义 `dropEvent()` 仍是唯一执行 `takeItem()` + `insertItem()` 的位置,因此不会再由 Qt 基类二次删除移动源项。 - 拖拽进入、移动和投放同时校验当前列表来源及私有 MIME;外部文件 URL 仍走原导入路径。`QDrag.exec()` 返回后才提交待保存顺序,取消或结束时数量、唯一性、ID 集合异常均不写库,并通过独立 `orderRestoreRequested` 信号让页面从 SQLite 恢复原图及显示中文重试提示。 - 测试改为模拟完整 `startDrag()` 路径并控制 `QDrag.exec()`:覆盖单次移动不触发源端删除、连续4次更换主图后5张图片和连续标签保持完整、异常少项不发送排序信号,以及页面从 SQLite 自动恢复。同步更新 `docs/04-architecture.md` 与 `docs/routes.md`。 - 验证通过:`py -3.10 -m unittest tests.test_product_suite_gui tests.test_image_studio tests.test_product_suite`(112项)、`py -3.10 -m unittest discover -s tests`(679项)、`py -3.10 -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`。离屏 PySide6 仍有字体目录和插件能力告警,但无测试失败。