# 素材库: 新增批量修改分类接口(批量移动文件夹)

> **服务**: 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<String>` | ✅ | size 1-500 | 素材 ID 列表,**雪花 ID 用字符串避免前端精度丢失** |
| categoryCode | Body | String | ✅ | max 64 | 目标一级分类编码(如 `scenic` / `product` / `hotel` 等,从 `/admin/material/categories` 拿) |
| subCategoryId | Body | Long | ❌ | - | 目标子分类 ID。**null 或 0** 都表示仅放到一级分类 |

**出参 VO**: `Result<BatchMoveCategoryResultVO>`

| 字段 | 类型 | 说明 |
|------|------|------|
| total | int | 入参 materialIds 总数 |
| successCount | int | 成功移动条数 |
| failCount | int | 失败条数 = failures.size() |
| failures | `List<FailItem>` | 失败明细 |
| 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)
