172 lines
9.9 KiB
Markdown
172 lines
9.9 KiB
Markdown
# 商品批量删除与恢复优化 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. 完成定义
|
||
|
||
用户在商品库或垃圾桶选择任意数量的商品时,页面显示的选中数量、网络实际处理数量、成功 / 失败反馈和最终列表结果完全一致。
|