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