# 小程序配置模块重构：联系我们 / 常见问题 / 协议政策

> **服务**: hl-user-service (端口 8081)
> **PR**: https://git.1814.love:8443/wx/HL/pulls/773
> **Issue**: https://git.1814.love:8443/wx/HL/issues/766
> **日期**: 2026-04-17
> **影响范围**: 管理端三大配置模块（联系我们 / 常见问题 / 协议政策）的所有写接口和大部分读接口

---

## 背景

小程序配置模块历史遗留问题较多：
- Status 字段混用 Integer(0/1) 与 String（"启用/停用"）
- VO 命名不统一：`FaqCategoryVO` / `AgreementRequest` 等不符合团队规范
- 协议政策每类型应只有一条记录，但历史接口允许多记录 CRUD，数据可能出现同类型多条
- 没有字典校验 / 条件必填 / 幂等 / 富文本 XSS 过滤，业务错常见 500

本次整改按团队规范重构三个模块，统一 VO 命名、字段类型、错误码、安全策略。

## 字段统一变更（全三模块）

### Status 字段：Integer → String

| 旧值 | 新值 |
|------|------|
| `1` | `"ACTIVE"` |
| `0` | `"INACTIVE"` |

影响所有请求体 / 响应体中的 `status` / `enabled` 字段，前端请求与展示都需切换成大写英文常量。

数据库迁移由 DDL 完成（幂等 SQL，已在 PR 中附带）。

### 业务错误码：500 → 400

字典值非法、条件校验失败、资源不存在等业务错从 `code=500` 改为 `code=400`，并附中文消息。HTTP 始终 200，前端按 `code` 区分。

### 写接口全部加 @Idempotent

9 个写接口启用幂等控制（3~5 秒窗口），前端连点 / 重试会被返：

```json
{
  "code": 429,
  "success": false,
  "message": "请勿重复提交"
}
```

改 body 后可再次提交。

### 富文本 XSS 过滤（后端自动处理）

以下字段提交时后端自动清洗 `<script>` / `<iframe>` / `on*` 事件属性等危险标签：
- `Contact.content`
- `FaqItem.answer`
- `Agreement.content`

前端展示时可直接 `v-html` 渲染，无需额外过滤。

---

## Contact（联系我们）

### 字段重命名

| 旧字段 | 新字段 | 说明 |
|--------|--------|------|
| `title` | `name` | 渠道名称 |
| `icon` | `iconUrl` | 图标 URL |

### 字典校验 `channelType`

| 值 | 说明 | 必填的关联字段 |
|----|------|----------------|
| `ABOUT` | 关于我们 | `content`（富文本介绍）必填 |
| `ONLINE_CS` | 在线客服 | `value`（链接/账号）必填 |
| `PHONE` | 客服电话 | `value`（电话号码）必填 |

非上述 3 值 → `code=400` + "渠道类型不支持"。

### 接口清单

#### 管理端

| 方法 | 路径 | 说明 | 变更 |
|------|------|------|------|
| POST | `/admin/contact` | 创建 | **Body 结构变更**：原 `ContactRequest` 废弃，改为 `ContactSaveReqVO`（字段见下） |
| PUT | `/admin/contact/{id}` | 修改 | 同上，Body 用 `ContactSaveReqVO`（id 由路径传） |
| DELETE | `/admin/contact/{id}` | 删除 | 签名不变 |
| GET | `/admin/contact/{id}` | 详情 | 响应用 `ContactRespVO`（字段重命名） |
| GET | `/admin/contact/page` | 分页 | 响应项为 `ContactRespVO` |
| GET | `/admin/contact/channel-types/enabled` | **新增**：渠道类型下拉 | `List<Map<value,label>>` |

#### `ContactSaveReqVO`（请求）

```json
{
  "channelType": "ABOUT",         // 必填。ABOUT / ONLINE_CS / PHONE
  "name": "关于呼籁",              // 必填。旧名 title
  "iconUrl": "https://...",       // 选填。旧名 icon
  "value": null,                  // ABOUT 可空；ONLINE_CS/PHONE 必填
  "content": "<p>...</p>",        // ABOUT 必填；其他可空。富文本 XSS 过滤
  "sortOrder": 1,                 // 选填
  "status": "ACTIVE"              // 选填，默认 ACTIVE
}
```

#### `ContactRespVO`（响应）

```json
{
  "id": 1,
  "channelType": "ABOUT",
  "name": "关于呼籁",               // 旧名 title
  "iconUrl": "https://...",        // 旧名 icon
  "value": null,
  "content": "<p>...</p>",
  "sortOrder": 1,
  "status": "ACTIVE",              // 旧为 Integer 0/1
  "createTime": "2026-04-17 10:00:00",
  "updateTime": "2026-04-17 10:00:00"
}
```

---

## Faq（常见问题）

### 分类（Category）

| 方法 | 路径 | 说明 | 变更 |
|------|------|------|------|
| POST | `/admin/faq/categories` | 创建分类 | Body 改为 `FaqCategorySaveReqVO`（原 `FaqCategoryRequest` 废弃） |
| PUT | `/admin/faq/categories/{id}` | 修改分类 | 同上 |
| DELETE | `/admin/faq/categories/{id}` | 删除分类 | **新增业务校验**：分类下有条目时 `code=400` + "该分类下仍有常见问题，无法删除" |
| GET | `/admin/faq/categories/{id}` | 详情 | 响应 `FaqCategoryRespVO` |
| GET | `/admin/faq/categories/page` | 分页 | 响应 `FaqCategoryRespVO` |
| GET | `/admin/faq/categories/enabled` | **新增**：分类下拉 | `List<Map<value,label>>`，只返回 ACTIVE |

#### `FaqCategorySaveReqVO`

```json
{
  "name": "预订须知",
  "sortOrder": 1,
  "status": "ACTIVE"
}
```

### 条目（Item）

| 方法 | 路径 | 说明 | 变更 |
|------|------|------|------|
| POST | `/admin/faq/items` | 创建条目 | Body 改为 `FaqItemSaveReqVO`（原 `FaqItemRequest` 废弃） |
| PUT | `/admin/faq/items/{id}` | 修改条目 | 同上 |
| DELETE | `/admin/faq/items/{id}` | 删除 | 签名不变 |
| GET | `/admin/faq/items/{id}` | 详情 | 响应 `FaqItemRespVO` |
| GET | `/admin/faq/items/page` | 分页 | 响应 `FaqItemRespVO` |

#### `FaqItemSaveReqVO`

```json
{
  "categoryId": 1,           // 必填，必须是存在且 ACTIVE 的分类
  "question": "如何退订?",
  "answer": "<p>...</p>",    // 富文本，XSS 过滤
  "sortOrder": 1,
  "status": "ACTIVE"
}
```

> 若 `categoryId` 指向不存在 / 已停用的分类 → `code=400`。

### VO 命名调整（前端 TypeScript 定义需要改）

| 旧类型 | 新类型 |
|--------|--------|
| `FaqCategoryVO` | `FaqCategoryRespVO` |
| `FaqItemVO` | `FaqItemRespVO` |
| `FaqCategoryRequest` | `FaqCategorySaveReqVO` |
| `FaqItemRequest` | `FaqItemSaveReqVO` |

---

## Agreement（协议政策）

**核心变化**：每个 `type` 只有一条记录（saveByType 模式），废弃多记录 CRUD。

### 废弃接口（前端必须下线）

| 方法 | 路径 | 处理 |
|------|------|------|
| ~~POST~~ | ~~`/admin/agreement`~~ | **删除**。改用 `/save-by-type` |
| ~~PUT~~ | ~~`/admin/agreement/{id}`~~ | **删除**。改用 `/save-by-type` |
| ~~DELETE~~ | ~~`/admin/agreement/{id}`~~ | **删除**。协议不允许删除，只能停用或覆盖 |

### 新接口

| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/admin/agreement/save-by-type` | 按类型保存：该 type 不存在则创建，存在则覆盖（幂等） |
| GET | `/admin/agreement/by-type/{type}` | 按类型查询，type 参数为字典值 |
| GET | `/admin/agreement/list` | 返回所有启用协议（简要列表） |

### 字典 `agreement_type`

| 值 | 说明 |
|----|------|
| `USER_AGREEMENT` | 用户协议 |
| `PRIVACY_POLICY` | 隐私政策 |

非上述两值 → `code=400` + "协议类型不支持"。

### 旧类型迁移（DDL 完成）

| 旧值 | 新值 / 处理 |
|------|-------------|
| `user` | `USER_AGREEMENT` |
| `privacy` | `PRIVACY_POLICY` |
| `travel` | 软删（`deleted_at` 设置） |
| `refund` | 软删（`deleted_at` 设置） |

### `AgreementSaveReqVO`（请求）

```json
{
  "type": "USER_AGREEMENT",          // 必填。USER_AGREEMENT / PRIVACY_POLICY
  "title": "用户协议",                // 必填
  "content": "<p>...</p>",            // 必填，XSS 过滤
  "updateDate": "2026-04-17",         // 必填，LocalDate
  "sortOrder": 1                      // 选填
}
```

> 原 `AgreementRequest` 废弃。

### `AgreementRespVO`（响应）

```json
{
  "id": 1,
  "type": "USER_AGREEMENT",
  "title": "用户协议",
  "content": "<p>...</p>",
  "updateDate": "2026-04-17",         // 新增字段：LocalDate
  "sortOrder": 1,
  "status": "ACTIVE",
  "createTime": "2026-04-17 10:00:00",
  "updateTime": "2026-04-17 10:00:00"
}
```

---

## 前端适配清单

### 必须改

- [ ] **所有模块**：请求体 / 响应体 `status` 字段由 `Integer(0/1)` 换为 `String("ACTIVE"/"INACTIVE")`
- [ ] **Contact**：请求体字段 `title → name`、`icon → iconUrl`，响应同步
- [ ] **Contact**：新建/编辑表单中 `channelType` 切换时，校验关联字段必填逻辑（ABOUT→content，ONLINE_CS/PHONE→value）
- [ ] **Contact**：渠道类型改用 `/admin/contact/channel-types/enabled` 下拉接口，不要写死前端枚举
- [ ] **Faq**：分类下拉改用 `/admin/faq/categories/enabled`
- [ ] **Faq**：删除分类时处理"分类下有条目"的 400 错误提示
- [ ] **Faq**：TypeScript 类型改名 `FaqCategoryVO → FaqCategoryRespVO` 等
- [ ] **Agreement**：三个废弃接口全部下线，统一改用 `/save-by-type`
- [ ] **Agreement**：表单类型下拉只保留 `USER_AGREEMENT` / `PRIVACY_POLICY`，清除 `travel` / `refund`
- [ ] **Agreement**：响应 VO 新字段 `updateDate`，列表需要展示（建议列）
- [ ] **全局**：业务错处理从 `code === 500` 改为 `code === 400`（500 留给真正的系统异常）
- [ ] **全局**：连点/重试按钮要处理 429 "请勿重复提交"

### 无需改

- 列表/详情接口路径不变（除 Agreement 删除的 3 个）
- 富文本字段不用在前端做 XSS 过滤（后端处理了）

---

## 部署步骤（后端已完成，前端配合验证）

由用户/测试服务器管理员在测试环境执行：

1. 执行 DDL：`sql/mp_config_refactor_v2.sql`（hl_user_service 库，幂等可重跑）
2. 清 Redis 缓存：`redis-cli DEL hl:menu:tree:* hl:dict:*`
3. 重启 `hl-user-service`
4. 管理员重新登录加载新菜单

---

## 向后兼容性

| 场景 | 兼容性 |
|------|--------|
| 旧前端继续发 Integer status | ❌ **不兼容**。会触发 `@Pattern` 校验返回 400 |
| 旧前端继续用 `title`/`icon` 字段名 | ❌ **不兼容**。响应 VO 不再返这两个字段 |
| 旧前端调 `POST/PUT/DELETE /admin/agreement` | ❌ **不兼容**。接口已删除，404 |
| 旧前端不传 `channelType` 必填字段 | ❌ 400 + 中文消息 |

**前端必须随本 PR 同步更新**，否则管理端会出现大面积报错。
