Files
yingqing/docs/bug_todo/商品批量删除与恢复优化-todo.md
T

172 lines
9.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 商品批量删除与恢复优化 TODO
> 状态: Step 1、Step 2 已实施,等待浏览器验证
> 页面: 商品库 `/products`、垃圾桶
> 优先级: P1(用户选择与实际删除结果不一致,可能误以为全部已删除)
## 1. 问题现象
在商品库 `/products` 的卡片视图中,同时勾选 2 个商品后点击底部操作条的「删除选中」:
- 页面明确显示“已选 2 项”。
- 实际只删除第 1 个被选中的商品。
- 浏览器网络面板只观察到一条单商品删除请求:
```http
DELETE /api/products/42d443aa-1c80-441a-aa08-ca2883482fca/
```
因此,当前批量删除入口与后端单商品删除接口之间的批量处理未闭环。该问题需要同时覆盖垃圾桶的批量恢复,避免“批量删除修好、批量恢复仍只处理第一项”的同类缺口。
## 2. 已确认根因与影响范围
已知:
- 单商品删除接口当前形态为 `DELETE /api/products/:id/`
- 商品删除遵循既有垃圾桶语义:普通删除进入垃圾桶,不应物理删除记录或资产文件。
- 当前 UI 支持多选,并在底部操作条显示选中数量。
- 商品页已将全部选中 ID 传入 `Promise.all(ids.map(...))`,选中集合没有丢失。
- 每个商品删除都会再调用 App 级 `action(() => api.deleteProduct(id), ...)`
- `action()` 有全局单飞锁 `actionInFlightRef`:第一项开始后占锁;同一批次后续项命中锁时,直接返回 `null`,不调用接口。
- `null` 是已兑现的 Promise 值,`Promise.all` 不会将它识别为失败。因此页面会误以为整批已经处理完毕。
已确认的调用链:
```text
批量删除选中 ID
→ ProductsPage: Promise.all(ids.map(onDelete))
→ App: action(() => api.deleteProduct(id))
→ 第 1 项取得全局 actionInFlightRef 锁并发出 DELETE
→ 第 2 项及后续项被全局锁拦截,返回 null,不发 HTTP 请求
→ Promise.all 将 null 当作成功完成
```
影响范围:
- 商品库「删除选中」:选中多项时通常仅第 1 项实际发出 `DELETE /api/products/:id/`
- 垃圾桶「全部恢复」中的商品:商品恢复同样经过 `action(() => api.restoreProduct(id))`,第 1 个商品外的后续商品会被同一把锁拦截。
- 垃圾桶「清空」中的商品:商品二级删除同样经过 `action(() => api.purgeProduct(id))`,也有同类风险。
- 垃圾桶内的资产、模特、自由创作、项目操作当前直接调用对应 API,不经过该 App 级锁;本问题确认只影响其中的商品项。
非根因:
- 不是多选状态只保留了首项:商品页在调用批量逻辑前已正确得到完整 ID 数组。
- 不是后端只支持删除一项:后端单商品删除接口会正常完成软删除,且没有共享的批量限制。
- 不需要为此新增后端批量删除或恢复接口;现有单项接口可复用。
## 3. 目标与边界
目标:
- 勾选 N 个商品后,删除操作必须准确处理 N 个商品。
- 垃圾桶勾选 N 个商品后,恢复操作必须准确处理 N 个商品。
- 成功、部分失败、全部失败均给出与实际结果一致的反馈,并保留可重试项。
- 完成后清理已成功处理商品的选择状态,失败项仍保持选中。
边界:
- 只处理商品库及商品垃圾桶的“批量删除 / 批量恢复”行为;不扩展到模特、资产、项目等其他业务。
- 不改变既有软删除、垃圾桶和二级隐藏的数据语义。
- 不物理删除数据库记录、关联资产或对象存储文件。
- 不借本次改动重做商品库页面视觉、卡片、多选交互或接口鉴权。
## 4. 推荐实施方案
优先复用已验证的单商品接口,并为“同一次商品批量操作”提供专用批量执行路径:直接调用 `api.deleteProduct` / `api.restoreProduct` / `api.purgeProduct`,不逐项经过全局单飞 `action()`。批量执行器负责统一的请求中状态、结果汇总、一次刷新和一次反馈;单商品入口继续保留 `action()` 的防连点保护。
### Step 1:抽出商品批量执行器,隔离全局单飞锁
- 在 App 层或商品操作模块新增仅供批量使用的执行器,接收明确的 ID 数组和单项 API 函数。
- 批量执行器不得逐项调用 `action()`;它应自行维护本批次 loading 状态,以免用户重复点击同一批操作。
- 使用 `Promise.allSettled` 收集每个 ID 的结果,保留成功 ID 与失败 ID,不能把 `null` 或未启动请求当作成功。
- 整批请求完成后只刷新一次商品数据;批量过程中不让每一项各自触发全局 `loadData()`
验收:
- 传入 2 个 ID 时,执行器必定尝试 2 次单项请求。
- 执行器的结果能精确对应每一个输入 ID,不存在静默的 `null` 成功。
- 单商品操作仍经过既有 `action()`,保持原有防重复提交行为。
状态: 已实施,等待浏览器验证。
实施记录:
- App 已新增商品批量执行器:整批操作只占用一次全局锁,批内直接调用每个商品的 API。
- 执行器使用 `Promise.allSettled` 返回每个 ID 的成功 / 失败结果,仅在至少一项成功后刷新一次全局商品数据。
- 商品库“删除选中”已接入该执行器;失败商品会重新保持选中,方便下一步重试处理。
- 单商品删除入口仍使用原有 `action()`,未改变单项删除与软删除语义。
- 已通过 TypeScript 编译检查:`tsc -b`
### Step 1.1:商品库批量删除接入
- 对每个选中的商品 ID 执行删除;不得只使用首个 ID。
- 通过 Step 1 的批量执行器调用 `api.deleteProduct`,不再从每一项进入 `action()`
- 等待全部请求结束后汇总结果,避免请求未完成就刷新列表或清空选择。
- 全部成功:刷新商品列表,提示实际删除数量,清空选择。
- 部分失败:刷新已成功删除的项目,提示“成功 X 项,失败 Y 项”,仅保留失败商品的选择状态。
- 全部失败:保留全部选择状态,展示可理解的错误信息,不显示成功提示。
验收:
- 勾选 2 个商品时,网络面板出现对应的 2 次删除处理(或 1 次包含 2 个 ID 的批量请求)。
- 两个商品均从正常商品列表消失,并在垃圾桶可见。
- 删除 1 项时,原有单项删除行为不回归。
### Step 2:修复垃圾桶中的商品批量恢复与清空
- 对垃圾桶“全部恢复”中的商品使用同一个批量执行器直接调用 `api.restoreProduct`,不再逐项进入 `action()`
- 对“清空垃圾桶”中的商品使用同一个批量执行器直接调用 `api.purgeProduct`,消除同类遗漏风险。
- 非商品分类继续使用其原有直接 API 调用,不扩大本次改动范围。
- 恢复成功的商品回到正常商品列表,不再显示在垃圾桶。
- 部分失败时,失败商品仍留在垃圾桶且保持选中,可直接重试。
验收:
- 垃圾桶中有 2 个商品时点击“全部恢复”,网络面板出现对应的 2 次 `POST /api/products/:id/restore/`,两个商品都回到商品库。
- 垃圾桶中有 2 个商品时点击“清空垃圾桶”,网络面板出现对应的 2 次 `DELETE /api/products/:id/purge/`
- 部分失败时,成功与失败数量、列表位置和选择状态均正确。
状态: 已实施,等待浏览器验证。
实施记录:
- 垃圾桶“全部恢复”会将商品与其他分类拆分处理:商品只调用一次专用批量执行器,其他分类保持原有直接 API 请求。
- 垃圾桶“清空”对商品采用相同的批量执行器,避免仅第一个商品实际发出 `purge` 请求。
- 商品失败 ID 会保留在垃圾桶;成功 ID 才会从垃圾桶页面移除。
- 已通过 TypeScript 编译检查:`tsc -b`
### Step 3:回归与防回归测试
- 为前端商品批量删除、全部恢复、清空补测试:2 项成功、部分失败、全部失败、单项操作。
- 专门断言:批量操作不会调用 App 级单飞 `action()` 两次;每一个商品 ID 都会形成一个对应 API 请求。
- 确认删除 / 恢复后卡片数量、选中数量、底部操作条和垃圾桶数量同步正确。
- 验证重复点击防护:请求进行中禁用对应操作,避免同一商品被重复提交。
- 执行前端类型检查和现有相关测试。
验收:
- 以上四类测试通过。
- 手动验证商品库与垃圾桶各至少一次 2 项批量操作。
- 未改变商品删除的软删除语义,也未影响单商品删除、单商品恢复。
状态: 自动检查已完成;真实页面批量回归待确认。
验证记录:
- 前端 TypeScript 编译检查已通过:`tsc -b`
- `git diff --check` 已通过。
- 已检查本地 `http://localhost:5173/products`:当前商品库有 5 个商品;垃圾桶中没有商品记录,无法在不先删除现有商品的前提下验证“全部恢复”两个商品。
- 后端 `ProductTrashTests` 未能启动:当前项目 `.venv` 仍指向已不存在的本机 Python 3.11 路径;未为测试重建或改写依赖环境。
- 待确认的真实页面回归:选中 2 个商品删除并在垃圾桶全部恢复,确认各有 2 个对应请求,且两个商品均正确回到商品库。
### Step 3 执行结果(2026-07-13
- 已在本地真实页面完成批量删除回归:选择 2 个商品后确认删除,商品库由 5 个变为 3 个,两个选中商品均进入垃圾桶。
- 已完成“全部恢复”回归:两个商品均回到商品库,且商品标题均重新可见;垃圾桶已无可恢复项目。
- 本次“全部恢复”同时恢复了原有的 2 条自由创作记录,已事先取得用户确认。
- 已验证批量删除与批量恢复都正确处理了 2 个商品;清空垃圾桶、部分失败、全部失败与重复点击保护分支仍待补充验证。
## 5. 完成定义
用户在商品库或垃圾桶选择任意数量的商品时,页面显示的选中数量、网络实际处理数量、成功 / 失败反馈和最终列表结果完全一致。