From 5cd14d03a65b49de679449bd2fe80632c2f387bd Mon Sep 17 00:00:00 2001 From: API Changelog Bot Date: Fri, 15 May 2026 17:35:39 +0800 Subject: [PATCH] feat: material batch move category API (PR #2347, issue #2345) --- .../15_feat_material_batch_move_category.md | 199 ++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 changelogs/2026-05/15_feat_material_batch_move_category.md diff --git a/changelogs/2026-05/15_feat_material_batch_move_category.md b/changelogs/2026-05/15_feat_material_batch_move_category.md new file mode 100644 index 0000000..72390b6 --- /dev/null +++ b/changelogs/2026-05/15_feat_material_batch_move_category.md @@ -0,0 +1,199 @@ +# 素材库: 新增批量修改分类接口(批量移动文件夹) + +> **服务**: hl-resource-service (端口 8082) +> **PR**: #2347 +> **Issue**: #2345 +> **日期**: 2026-05-15 +> **影响范围**: 管理后台素材库页(`/material`)批量操作栏 + +--- + +## 一、背景 + +素材库当前批量操作栏只有「批量通过 / 批量驳回 / 批量导出 / 批量删除」,缺少**批量修改分类**(俗称"批量移动文件夹")。日常归档场景:运营把多条素材一次性归到同一个景区分类下,目前只能逐条 `PUT /admin/material/{id}`,效率低。 + +本次后端新增 1 个批量接口,前端需要在批量操作栏加一个按钮 + 分类树选择弹窗。 + +--- + +## 二、变更接口清单 + +| # | 接口 | 方法 | 路径 | 变更类型 | 说明 | +|---|------|------|------|----------|------| +| 1 | 批量修改素材分类 | PUT | `/admin/material/batch/category` | **新增** | 把多个素材一次性移动到同一目标分类,逐条失败不中断其他成功条目 | + +--- + +## 三、接口详情 + +### 1. 批量修改素材分类 `PUT /admin/material/batch/category` + +**入参 VO**: `BatchMoveCategoryRequest` + +| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | +|------|------|------|------|------|------| +| materialIds | Body | `List` | ✅ | size 1-500 | 素材 ID 列表,**雪花 ID 用字符串避免前端精度丢失** | +| categoryCode | Body | String | ✅ | max 64 | 目标一级分类编码(如 `scenic` / `product` / `hotel` 等,从 `/admin/material/categories` 拿) | +| subCategoryId | Body | Long | ❌ | - | 目标子分类 ID。**null 或 0** 都表示仅放到一级分类 | + +**出参 VO**: `Result` + +| 字段 | 类型 | 说明 | +|------|------|------| +| total | int | 入参 materialIds 总数 | +| successCount | int | 成功移动条数 | +| failCount | int | 失败条数 = failures.size() | +| failures | `List` | 失败明细 | +| failures[].materialId | String | 失败的素材 ID(跟入参一致用 String) | +| failures[].reason | String | 失败原因(中文) | + +#### 请求示例 + +```json +{ + "materialIds": ["2054027790054813698", "2054027790054813699"], + "categoryCode": "scenic", + "subCategoryId": null +} +``` + +#### 成功响应示例 + +```json +{ + "code": 200, + "message": "成功", + "data": { + "total": 2, + "successCount": 2, + "failCount": 0, + "failures": [] + }, + "success": true +} +``` + +#### 部分失败响应示例 + +```json +{ + "code": 200, + "message": "成功", + "data": { + "total": 3, + "successCount": 2, + "failCount": 1, + "failures": [ + { "materialId": "2054027790054813700", "reason": "素材不存在" } + ] + }, + "success": true +} +``` + +#### 错误响应 + +```json +// 目标 categoryCode 不存在 (整批拒绝) +{ "code": 370101, "message": "无效的父分类编码: xxxx", "data": null, "success": false } + +// 目标 subCategoryId 不属于 categoryCode (整批拒绝) +{ "code": 370103, "message": "...", "data": null, "success": false } + +// materialIds 为空 / 超过 500 条 (整批拒绝, Bean validation) +{ "code": 400, "message": "一次最多移动 500 个素材; 素材ID列表不能为空", "data": null, "success": false } +``` + +--- + +## 四、契约约束 + +| 场景 | payload | 行为 | +|------|---------|------| +| ✅ 移到一级分类 | `{ "materialIds":["1"], "categoryCode":"scenic" }` | 成功 | +| ✅ 移到一级分类(显式 null subCategoryId) | `{ ..., "subCategoryId": null }` | 同上 | +| ✅ 移到一级分类(显式 0) | `{ ..., "subCategoryId": 0 }` | 同 null 等价(后端 normalize) | +| ✅ 移到子分类 | `{ ..., "categoryCode":"scenic", "subCategoryId":2030000000000001 }` | 成功 | +| ❌ 目标父不存在 | `{ "categoryCode":"non_existent" }` | 整批 400 `code:370101` | +| ❌ 子分类不属于该父 | `{ "categoryCode":"scenic", "subCategoryId":属于product的 ID }` | 整批 400 `code:370103` | +| ❌ materialIds 为空 | `{ "materialIds":[] }` | 整批 400 | +| ❌ materialIds > 500 | `{ "materialIds":[501 条] }` | 整批 400 | + +### 单条失败原因 + +| reason 文本 | 触发场景 | +|------------|----------| +| 素材不存在 | 该 materialId 在 DB 找不到(可能已被删) | +| 无权操作该素材 | 当前 admin 角色对**源分类** `material.categoryCode` 无权 | +| 无权移动到目标分类 | 当前 admin 角色对**目标分类** `categoryCode` 无权 | + +--- + +## 五、数据库行为 + +接口**只更新两列**:`material.category_code` + `material.sub_category_id` + +**显式不写**的列: +- `review_status` — 移动分类**不触发重审**,审核结论保留 +- `status` — 启用状态保留 +- 其他字段保留 + +所以已通过审核的素材,移动到其他分类后**仍然是已通过状态**,无需重新审核。 + +--- + +## 六、边界行为 + +- 未登录 / 无 token → 401(网关拦截) +- 单条素材有任一权限问题 / 不存在 → 该条进 failures,**其他成功条目正常完成**(不回滚) +- 目标分类 / 子分类校验不过 → **整批 400 立即拒绝**(在循环之前一次性校验,前端能立即定位是目标选错) + +--- + +## 七、不影响范围 + +- **仅影响**: 管理后台素材库 `/material` 页的批量操作行为 +- **零影响**: + - 单条修改接口 `PUT /admin/material/{id}` 保持不变 + - 批量删除 `DELETE /admin/material/batch` 不变 + - 批量改标签 `PUT /admin/material/batch/tags` 不变 + - 批量导出 `POST /admin/material/export` 不变 + - 素材审核接口 / 审核状态字段 不动 + - 历史数据零迁移,无 DB schema 变更 + +--- + +## 八、测试环境已验证 + +测试服 (https://api.test.1814.love:9443) 部署完成后,用 admin / Admin@123456 真 token 跑通: + +``` +PUT /admin/material/batch/category + Case A: 目标 categoryCode 不存在 → 370101 "无效的父分类编码" ✓ + Case B: 502 条 (超 500) → 400 "一次最多移动 500 个素材" ✓ + Case C: materialIds 为空 → 400 "素材ID列表不能为空" ✓ + Case D: 真移动 1 条 (product → scenic) → 200, successCount=1 ✓ + GET /admin/material/{id} 验证: categoryCode 已更新 ✓ + GET /admin/material/{id} 验证: reviewStatus 不变 ✓ + 再次移回 (scenic → product) → 200, successCount=1 ✓ +``` + +验证素材: `materialId=2054027790054813698` (测试服真实素材) + +--- + +## 九、前端实现建议(mmg 参考) + +1. 批量操作栏新增「**批量移动**」按钮(建议放在「批量删除」**左侧**) +2. 点击按钮 → 弹出**分类树选择弹窗**(可复用现有左侧分类树组件) +3. 用户选完目标分类(支持只选一级 / 选到子分类两种)→ 调本接口 +4. 收到响应后: + - 如果 `failCount == 0` → toast "成功移动 N 条" + 关闭弹窗 + 刷新列表 + - 如果 `failCount > 0` → 弹窗展示 `failures` 明细表(materialId + reason),让用户知道哪些失败、为什么 + +--- + +## 十、相关链接 + +- 关联 Issue: [wx/HL#2345](https://git.1814.love:8443/wx/HL/issues/2345) +- 关联 PR: [wx/HL#2347](https://git.1814.love:8443/wx/HL/pulls/2347)