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

9.9 KiB
Raw Permalink Blame History

商品批量删除与恢复优化 TODO

状态: Step 1、Step 2 已实施,等待浏览器验证 页面: 商品库 /products、垃圾桶 优先级: P1(用户选择与实际删除结果不一致,可能误以为全部已删除)

1. 问题现象

在商品库 /products 的卡片视图中,同时勾选 2 个商品后点击底部操作条的「删除选中」:

  • 页面明确显示“已选 2 项”。
  • 实际只删除第 1 个被选中的商品。
  • 浏览器网络面板只观察到一条单商品删除请求:
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 不会将它识别为失败。因此页面会误以为整批已经处理完毕。

已确认的调用链:

批量删除选中 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. 完成定义

用户在商品库或垃圾桶选择任意数量的商品时,页面显示的选中数量、网络实际处理数量、成功 / 失败反馈和最终列表结果完全一致。