# 【新增接口·管理后台 + 小程序】图标库模块 11 接口（全模块首次推送 changelog）

> **PR**: #3396（模块主体）+ #3398 / #3399（mp 端点公开化）+ #3401（上线后审计加固）
> **服务**: hl-user-service | **更新时间**: 2026-06-03
>
> **存放目录**: `changelogs-v2/2026-06/`
> **影响范围**: 管理后台「图标库」管理页（分类 + 图标 CRUD + SVG 上传解析）；小程序 / 前端 图标渲染（公开只读查询）

---

## ⚠️ 关键说明

图标库是后台统一维护的一套 SVG 图标，结构类似数据字典：一级「图标分类」(`icon_category`) + 二级「图标项」(`icon_item`)，图标项归属某分类编码 `categoryCode`。

**SVG 直接存库（不走 OSS）**：图标内容是 SVG 字符串，直存数据库 `svg_content`（MEDIUMTEXT，单图标后端限 256KB）。前端拿到 `svgContent` 直接渲染。

**新增图标分两步**：先调 §3.5 `POST /admin/icon/parse-svg` 上传 `.svg` 文件，后端读成字符串并做安全校验后**只返回字符串、不落库**；前端拿到 `svgContent` 再连同 `color` / `size` 等填进保存表单调 §3.6 `POST /admin/icon/item` 保存。

**mp 查询公开无需 token**：§3.10 / §3.11 是 UI 参考数据（同字典），无需登录即可访问，只返回 `status=ACTIVE` 的分类与图标，且分类被禁用时其图标也不再返回。

**SVG 安全校验**：上传 / 保存的 SVG 经服务端 XXE-safe 解析校验，拒绝含 `<script>` / `<foreignObject>` 元素、`on*` 事件属性、`javascript:` 外链的内容；前端渲染时仍建议做一次 sanitize 作纵深防御。

---

## 1. 接口背景

运营在后台「图标库」页统一维护 SVG 图标，供小程序 / 前端按分类拉取渲染（如分类入口图标、标签图标等）。

管理后台「图标库」页提供：
- 分类维护（列表 / 分页 / 新增 / 编辑 / 删除）
- 图标维护（分页 / 详情 / 新增 / 编辑 / 删除）
- 上传 SVG 文件解析为字符串

小程序 / 前端：按分类拉启用图标列表渲染（公开只读）。

---

## 2. 变更清单

### 管理后台（`/admin/icon/**`，需 admin JWT）

| # | 方法 | 路径 | 变更类型 | 说明 |
|---|------|------|----------|------|
| 1 | POST | `/admin/icon/category` | 首推 | 保存分类（id 空=新建，非空=修改） |
| 2 | GET | `/admin/icon/category/page` | 首推 | 分类分页（status / keyword 过滤） |
| 3 | GET | `/admin/icon/category/list` | 首推 | 启用分类列表（下拉用） |
| 4 | DELETE | `/admin/icon/category/{id}` | 首推 | 删除分类（其下有图标项时拒删） |
| 5 | POST | `/admin/icon/parse-svg` | 首推 | 上传 SVG 文件解析为字符串（不落库） |
| 6 | POST | `/admin/icon/item` | 首推 | 保存图标（id 空=新建，非空=修改） |
| 7 | GET | `/admin/icon/item/page` | 首推 | 图标分页（categoryCode / keyword 过滤） |
| 8 | GET | `/admin/icon/item/{id}` | 首推 | 图标详情（含 svgContent） |
| 9 | DELETE | `/admin/icon/item/{id}` | 首推 | 删除图标 |

### 小程序 / 前端（`/mp/icon/**`，公开无需 token）

| # | 方法 | 路径 | 变更类型 | 说明 |
|---|------|------|----------|------|
| 10 | GET | `/mp/icon/categories` | 首推 | 启用分类列表 |
| 11 | GET | `/mp/icon/items` | 首推 | 某分类下启用图标列表 |

---

## 3. 接口详情

### 3.1 保存分类

**POST** `/admin/icon/category`

- **使用场景**：图标库分类新增 / 编辑
- **认证**：管理后台 JWT
- **幂等**：否（同 `categoryCode` 重复新建抛 210701）

**Body 入参**（`IconCategorySaveReqVO`）：

| 字段 | 类型 | 必填 | 校验 | 说明 |
|---|---|---|---|---|
| `id` | Long | ❌ | — | 空=新建，非空=修改 |
| `categoryCode` | string | ✅ | `@Size(max=64)` | 分类编码（全局唯一，创建后不可改） |
| `categoryName` | string | ✅ | `@Size(max=100)` | 分类名称 |
| `sort` | int | ❌ | — | 排序号（升序），默认 0 |
| `status` | string | ❌ | `@Pattern(ACTIVE\|DISABLED)` | 状态，默认 ACTIVE |
| `remark` | string | ❌ | `@Size(max=255)` | 备注 |

**出参**：`Result<Long>`（`data` = 分类 ID）

**典型示例 请求**：
```
POST /admin/icon/category
Authorization: Bearer <token>
Content-Type: application/json

{ "categoryCode": "weather", "categoryName": "天气", "sort": 1 }
```

**典型示例 响应**：
```json
{ "code": 200, "data": 2062087054098898945, "message": "成功" }
```

**异常 响应**（编码重复）：
```json
{ "code": 210701, "message": "图标分类编码已存在", "data": null }
```

**错误码**：400 参数校验 / **210701** 分类编码已存在 / **210702** 分类不存在（修改时 id 无效）/ 401 未登录

---

### 3.2 分类分页

**GET** `/admin/icon/category/page`

- **使用场景**：分类管理列表页
- **认证**：管理后台 JWT
- **幂等**：是（只读）

**Query 入参**（`IconCategoryPageReqVO`）：

| 字段 | 类型 | 必填 | 默认 | 说明 |
|---|---|---|---|---|
| `page` | int | ❌ | 1 | 页码 |
| `pageSize` | int | ❌ | 20 | 每页条数 |
| `status` | string | ❌ | — | 状态精确过滤（ACTIVE / DISABLED） |
| `keyword` | string | ❌ | — | 模糊匹配分类编码 / 名称 |

**出参**：`Result<PageResult<IconCategoryRespVO>>`

| 字段 | 类型 | 说明 |
|---|---|---|
| `records[].id` | string | 分类 ID（Long 序列化为 String） |
| `records[].categoryCode` | string | 分类编码 |
| `records[].categoryName` | string | 分类名称 |
| `records[].sort` | int | 排序号 |
| `records[].status` | string | 状态（ACTIVE / DISABLED） |
| `records[].remark` | string | 备注 |
| `records[].createTime` | datetime | 创建时间 |
| `total` / `page` / `pageSize` | int | 分页元数据 |

**错误码**：400 参数校验 / 401 未登录

---

### 3.3 启用分类列表

**GET** `/admin/icon/category/list`

- **使用场景**：图标新增页的分类下拉
- **认证**：管理后台 JWT
- **幂等**：是

**入参**：无

**出参**：`Result<List<IconCategoryRespVO>>`（仅 `status=ACTIVE`，按 sort 升序）

**错误码**：401 未登录

---

### 3.4 删除分类

**DELETE** `/admin/icon/category/{id}`

- **使用场景**：删除空分类
- **认证**：管理后台 JWT
- **幂等**：是

**路径入参**：

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | Long | ✅ | 分类 ID |

**出参**：`Result<Void>`（`data: null`）

**异常 响应**（分类下仍有图标项）：
```json
{ "code": 210703, "message": "该分类下存在图标项，无法删除", "data": null }
```

**错误码**：**210702** 分类不存在 / **210703** 分类下存在图标项不可删 / 401 未登录

---

### 3.5 上传 SVG 解析（保存图标前置步骤）

**POST** `/admin/icon/parse-svg`

- **使用场景**：新增图标前上传 `.svg` 文件，拿到 SVG 字符串回填表单
- **认证**：管理后台 JWT
- **幂等**：是（仅解析，不落库）

**入参**（multipart/form-data）：

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `file` | file | ✅ | SVG 文件（`.svg`） |

**出参**：`Result<IconSvgParseRespVO>`

| 字段 | 类型 | 说明 |
|---|---|---|
| `svgContent` | string | 解析出的 SVG 字符串（回填保存表单） |
| `fileName` | string | 原始文件名 |
| `size` | long | 文件字节数 |

**典型示例 请求**：
```
POST /admin/icon/parse-svg
Authorization: Bearer <token>
Content-Type: multipart/form-data; boundary=...

（form-data: file=@sunny.svg）
```

**典型示例 响应**：
```json
{
  "code": 200,
  "data": {
    "svgContent": "<svg viewBox=\"0 0 24 24\"><path d=\"M0 0h24v24H0z\"/></svg>",
    "fileName": "sunny.svg",
    "size": 1234
  },
  "message": "成功"
}
```

**错误码**：**210753** 文件为空 / **210754** 非合法 SVG（非 XML 或根元素不是 svg）/ **210755** 超 256KB / **210756** 文件读取失败 / **210757** 含不安全内容（脚本 / 事件 / 外链）/ 401 未登录

---

### 3.6 保存图标

**POST** `/admin/icon/item`

- **使用场景**：图标新增 / 编辑
- **认证**：管理后台 JWT
- **幂等**：否（同分类 `iconCode` 重复新建抛 210751）

**Body 入参**（`IconItemSaveReqVO`）：

| 字段 | 类型 | 必填 | 校验 | 说明 |
|---|---|---|---|---|
| `id` | Long | ❌ | — | 空=新建，非空=修改 |
| `categoryCode` | string | ✅ | `@Size(max=64)` | 所属分类编码（新建校验分类存在；**修改时不可改**） |
| `iconCode` | string | ✅ | `@Size(max=64)` | 图标编码（同分类内唯一；**修改时不可改**） |
| `name` | string | ✅ | `@Size(max=100)` | 图标名称 |
| `svgContent` | string | ✅ | `@Size(max=262144)` | SVG 字符串（来自 §3.5 解析结果，经安全校验） |
| `color` | string | ❌ | `@Size(max=16)` | 默认色值 hex，如 `#FFCC00` |
| `size` | int | ❌ | — | 默认尺寸（像素） |
| `keywords` | string | ❌ | `@Size(max=255)` | 搜索关键词（逗号分隔） |
| `sort` | int | ❌ | — | 排序号，默认 0 |
| `status` | string | ❌ | `@Pattern(ACTIVE\|DISABLED)` | 状态，默认 ACTIVE |
| `remark` | string | ❌ | `@Size(max=255)` | 备注 |

**出参**：`Result<Long>`（`data` = 图标 ID）

**典型示例 请求**：
```
POST /admin/icon/item
Authorization: Bearer <token>
Content-Type: application/json

{
  "categoryCode": "weather",
  "iconCode": "sunny",
  "name": "晴天",
  "svgContent": "<svg viewBox=\"0 0 24 24\"><path d=\"M0 0h24v24H0z\"/></svg>",
  "color": "#FFCC00",
  "size": 24,
  "keywords": "晴,太阳,sunny"
}
```

**典型示例 响应**：
```json
{ "code": 200, "data": 2062087056506441730, "message": "成功" }
```

**异常 响应**（SVG 含脚本 / 事件 / 外链）：
```json
{ "code": 210757, "message": "SVG 含不安全内容(脚本/事件/外链)，请清理后重试", "data": null }
```

**错误码**：400 参数校验 / **210702** 分类不存在 / **210751** 同分类图标编码已存在 / **210752** 图标不存在（修改时 id 无效）/ **210754** SVG 非法 / **210755** SVG 超 256KB / **210757** SVG 含不安全内容 / 401 未登录

> **SaveReqVO 全量快照语义**：修改时请先用 §3.8 详情拉取再整体回填提交，未传的可空字段会被覆盖为空。`categoryCode` / `iconCode` 创建后不可变，修改时被忽略。

---

### 3.7 图标分页

**GET** `/admin/icon/item/page`

- **使用场景**：图标管理列表页
- **认证**：管理后台 JWT
- **幂等**：是

**Query 入参**（`IconItemPageReqVO`）：

| 字段 | 类型 | 必填 | 默认 | 说明 |
|---|---|---|---|---|
| `page` | int | ❌ | 1 | 页码 |
| `pageSize` | int | ❌ | 20 | 每页条数 |
| `categoryCode` | string | ❌ | — | 所属分类精确过滤 |
| `keyword` | string | ❌ | — | 模糊匹配图标编码 / 名称 / 关键词 |

**出参**：`Result<PageResult<IconItemRespVO>>`，`records[]` 为完整图标 VO（字段见 §3.8）

**错误码**：400 参数校验 / 401 未登录

---

### 3.8 图标详情

**GET** `/admin/icon/item/{id}`

- **使用场景**：详情查看 / 编辑前预填
- **认证**：管理后台 JWT
- **幂等**：是

**路径入参**：

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | Long | ✅ | 图标 ID |

**出参**：`Result<IconItemRespVO>`

| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | string | 图标 ID |
| `categoryCode` | string | 所属分类编码 |
| `iconCode` | string | 图标编码 |
| `name` | string | 图标名称 |
| `svgContent` | string | SVG 字符串 |
| `color` | string | 默认色值 hex |
| `size` | int | 默认尺寸（像素） |
| `keywords` | string | 搜索关键词 |
| `sort` | int | 排序号 |
| `status` | string | 状态（ACTIVE / DISABLED） |
| `remark` | string | 备注 |
| `createTime` / `updateTime` | datetime | 时间 |

**典型示例 响应**：
```json
{
  "code": 200,
  "data": {
    "id": "2062087056506441730",
    "categoryCode": "weather",
    "iconCode": "sunny",
    "name": "晴天",
    "svgContent": "<svg viewBox=\"0 0 24 24\"><path d=\"M0 0h24v24H0z\"/></svg>",
    "color": "#FFCC00",
    "size": 24,
    "keywords": "晴,太阳,sunny",
    "sort": 0,
    "status": "ACTIVE",
    "remark": null,
    "createTime": "2026-06-03 16:00:00",
    "updateTime": "2026-06-03 16:00:00"
  },
  "message": "成功"
}
```

**错误码**：**210752** 图标不存在 / 401 未登录

---

### 3.9 删除图标

**DELETE** `/admin/icon/item/{id}`

- **使用场景**：删除图标
- **认证**：管理后台 JWT
- **幂等**：是

**路径入参**：

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | Long | ✅ | 图标 ID |

**出参**：`Result<Void>`（`data: null`）

**错误码**：**210752** 图标不存在 / 401 未登录

---

### 3.10【公开】启用分类列表

**GET** `/mp/icon/categories`

- **使用场景**：小程序 / 前端拉图标分类
- **认证**：**公开，无需 token**
- **幂等**：是

**入参**：无

**出参**：`Result<List<IconCategorySimpleRespVO>>`（仅 `status=ACTIVE`，按 sort 升序）

| 字段 | 类型 | 说明 |
|---|---|---|
| `categoryCode` | string | 分类编码 |
| `categoryName` | string | 分类名称 |
| `sort` | int | 排序号 |

**典型示例 请求**：
```
GET /mp/icon/categories
（无需 Authorization）
```

**典型示例 响应**：
```json
{
  "code": 200,
  "data": [
    { "categoryCode": "weather", "categoryName": "天气", "sort": 1 }
  ],
  "message": "成功"
}
```

---

### 3.11【公开】某分类下启用图标列表

**GET** `/mp/icon/items`

- **使用场景**：小程序 / 前端按分类渲染图标
- **认证**：**公开，无需 token**
- **幂等**：是

**Query 入参**：

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `categoryCode` | string | ✅ | 分类编码 |

**出参**：`Result<List<IconItemSimpleRespVO>>`（仅 `status=ACTIVE` 的图标，且分类须为 ACTIVE）

| 字段 | 类型 | 说明 |
|---|---|---|
| `iconCode` | string | 图标编码 |
| `name` | string | 图标名称 |
| `svgContent` | string | SVG 字符串（直接渲染） |
| `color` | string | 默认色值 hex |
| `size` | int | 默认尺寸（像素） |

**典型示例 请求**：
```
GET /mp/icon/items?categoryCode=weather
（无需 Authorization）
```

**典型示例 响应**：
```json
{
  "code": 200,
  "data": [
    {
      "iconCode": "sunny",
      "name": "晴天",
      "svgContent": "<svg viewBox=\"0 0 24 24\"><path d=\"M0 0h24v24H0z\"/></svg>",
      "color": "#FFCC00",
      "size": 24
    }
  ],
  "message": "成功"
}
```

**错误码**：400 `categoryCode` 缺失

---

## 6. 枚举 / 数据字典

### 6.1 状态（`status`）

**所属字段**：分类 / 图标的 `status` | **类型**：`String` | **必填**：❌（默认 ACTIVE）

**`@Pattern` 强校验枚举**：

| 值 | 含义 |
|---|---|
| `ACTIVE` | 启用 |
| `DISABLED` | 禁用 |

> mp 端只返回 ACTIVE 的分类与图标；分类 DISABLED 时其图标在 mp 端也不返回。

---

## 7. 错误码（全模块汇总）

| code | 含义 | 触发条件 | 涉及接口 |
|---|---|---|---|
| 400 | 参数校验失败 | 字段缺失 / 长度 / 枚举值不合法 | 所有写入 + §3.11 |
| 401 | 未登录 | admin JWT 无效或缺失 | 全部 admin 接口（mp 端公开不需要） |
| **210701** | 分类编码已存在 | `categoryCode` 唯一约束 | §3.1 |
| **210702** | 分类不存在 | 分类 id / categoryCode 无效 | §3.1 §3.4 §3.6 |
| **210703** | 分类下存在图标项，不可删 | 删分类时其下仍有图标 | §3.4 |
| **210751** | 同分类图标编码已存在 | `(categoryCode, iconCode)` 唯一约束 | §3.6 |
| **210752** | 图标不存在 | 图标 id 无效 | §3.6 §3.8 §3.9 |
| **210753** | 上传 SVG 文件为空 | 文件为空 | §3.5 |
| **210754** | SVG 内容不合法 | 非合法 XML 或根元素不是 `<svg>` | §3.5 §3.6 |
| **210755** | SVG 内容过大 | 超过 256KB | §3.5 §3.6 |
| **210756** | SVG 文件读取失败 | 文件流读取异常 | §3.5 |
| **210757** | SVG 含不安全内容 | 含 `<script>` / `<foreignObject>` / `on*` 事件 / `javascript:` 外链 | §3.5 §3.6 |

> 错误码段位 210700-210799 归属图标库模块。

---

## 9. 业务边界

- ✅ **两级结构**：分类（`icon_category`）+ 图标（`icon_item`），图标按 `categoryCode` 归属分类
- ✅ **SVG 直存库**：`svg_content` MEDIUMTEXT 直存 SVG 字符串，不走 OSS；单图标后端限 256KB
- ✅ **上传解析与保存分离**：§3.5 只解析返回字符串不落库；§3.6 才落库
- ✅ **唯一约束**：`categoryCode` 全局唯一；`(categoryCode, iconCode)` 联合唯一
- ✅ **编码不可变**：图标的 `categoryCode` / `iconCode` 创建后不可改（修改时忽略）
- ✅ **SVG 服务端安全校验**：XXE-safe 解析 + 拒绝脚本 / 事件 / 外链
- ✅ **mp 公开只读**：§3.10 §3.11 无需 token，只返 ACTIVE；分类禁用其图标也不返

---

## 11. 影响评估

- **是否破坏向后兼容**：否（全新模块，无存量接口改动）
- **前端是否必须同步上线**：是（新功能，前端按本 changelog 对接管理页 + 渲染端）
- **影响已有数据**：无（新建 `icon_category` / `icon_item` 两表）

---

## 12. 注意事项

- **分页参数名**：`page` / `pageSize`（不是 pageNo）
- **Long 主键序列化**：`id` 等 Long 字段 JSON 返回为 String，前端不要当 Number 解析
- **新增图标流程**：先 §3.5 上传解析拿 `svgContent` → 填表单（补 color / size / 分类 / 编码）→ §3.6 保存
- **渲染 SVG**：拿到 `svgContent` 直接内联渲染，**务必先做一次前端 sanitize**（后端已拒明显恶意内容，前端作纵深防御）
- **mp 端公开**：§3.10 / §3.11 无需 token；先拉分类再用 `categoryCode` 拉图标
- **状态语义**：`ACTIVE` 启用 / `DISABLED` 禁用，mp 端只见 ACTIVE

---

## 13. 关联 / 联系人

### 13.1 链接

- **同模块 PR**：#3396（主体）/ #3398 / #3399（mp 公开化）/ #3401（审计加固）
- **关联工单**：#3390

### 13.2 联系人

- **后端负责人**: @wx
- **前端对接（管理后台 + 小程序）**: 待指派
