# 投诉模块完整接口契约（前端对接专用）

- **日期**: 2026-04-23（更新版，替代 2026-04-23_order-complaint-module.md 的简版）
- **相关 PR**: #1221 主 + #1222 #1223 #1225 #1235（hotfix）+ Nacos SMS_505775156
- **Closes**: #1220
- **短信模板**: `SMS_505775156`（阿里云审核通过，签名"呼籁旅行"）
- **网关前缀**: `https://api.test.1814.love:9443`（测试）/ `https://api.1814.love`（正式）
- **鉴权**:
  - 小程序 `/mp/**`：`Authorization: Bearer <mp-token>`（userId 由网关注入 request.userId）
  - 管理端 `/admin/**`：`Authorization: Bearer <admin-token>` + 按钮权限

---

## 📚 目录

- [A. 字典](#a-字典)
- [B. 小程序端 5 接口](#b-小程序端-5-接口)
- [C. 管理端 2 接口](#c-管理端-2-接口)
- [D. 统一错误码表](#d-统一错误码表)
- [E. 前端状态机](#e-前端状态机)
- [F. 通知链路（后端自动，前端参考）](#f-通知链路)

---

## A. 字典

```http
GET /admin/dict/data/complaint_type
Authorization: Bearer <admin-token>

响应 data:
[
  {"dictDataId":90401,"dictValue":"ITINERARY","dictLabel":"行程","sortOrder":1,"status":"ACTIVE"},
  {"dictDataId":90402,"dictValue":"VEHICLE","dictLabel":"车","sortOrder":2,"status":"ACTIVE"},
  {"dictDataId":90403,"dictValue":"HOTEL","dictLabel":"房","sortOrder":3,"status":"ACTIVE"},
  {"dictDataId":90404,"dictValue":"OTHER","dictLabel":"其他","sortOrder":4,"status":"ACTIVE"}
]
```

小程序端无单独字典接口；响应里已内嵌 `types:[{code,label}]` 结构，**前端不需要自己 map**。

---

## B. 小程序端 5 接口

统一前缀：`/mp/complaint`。全部接口不需要前端传 `userId`，网关从 Token 注入。

### B1. `POST /mp/complaint/create` — 发起投诉

**请求 body**（`MpComplaintSaveReqVO`）：

| 字段 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|
| `orderId` | Long | ✅ | - | 订单 ID（序列化为 String，前端直接透传） |
| `dayNumber` | Integer | ❌ | null/0/≥1 | null 或 0 = 整程投诉；≥1 = 第 N 天 |
| `types` | `List<String>` | ✅ | 非空，元素∈{ITINERARY,VEHICLE,HOTEL,OTHER} | 可多选 |
| `content` | String | ✅ | 1~500 字 | 投诉文字 |
| `imageUrls` | `List<String>` | ❌ | ≤9 项，单个 URL ≤500 字符 | OSS 直传后的 URL |
| `videoUrl` | String | ❌ | ≤500 字符 | 单个视频 URL |

**请求示例**：

```http
POST /mp/complaint/create
Authorization: Bearer eyJ...
Content-Type: application/json

{
  "orderId": "7123456789012345678",
  "dayNumber": 2,
  "types": ["ITINERARY", "VEHICLE"],
  "content": "第二天景点讲解员迟到半小时，车辆空调坏了",
  "imageUrls": [
    "https://cdn.hulalv.com/complaint/2026/04/abc.jpg",
    "https://cdn.hulalv.com/complaint/2026/04/def.jpg"
  ],
  "videoUrl": null
}
```

**响应**：

```json
{"code":200,"message":"成功","data":"8123456789012345678","success":true}
```

`data` 是 complaintId（Long 序列化为 String）。

**错误码**：20301（10 分钟冷却）/ 20302（订单非本人）/ 20303（dayNumber 超范围，不能投诉未发生的天）

---

### B2. `POST /mp/complaint/{id}/cancel` — 撤回投诉

**路径参数**：`id` = 投诉 ID

**请求 body**：无

**响应**：

```json
{"code":200,"message":"成功","data":null,"success":true}
```

**错误码**：20304（不存在或已撤回）/ 20305（非本人）/ 20306（创建已超 10 分钟）

**规则**：仅**创建后 10 分钟内**且**本人**可撤回；软删 `deleted_at` + `status=WITHDRAWN`；企微+短信已发出**不撤回**（定制师需要自行忽略已收到的通知）。

---

### B3. `GET /mp/complaint/my-page` — 我的投诉分页

**Query**：

| 参数 | 类型 | 默认 | 约束 |
|------|------|------|------|
| `pageNo` | Integer | 1 | ≥1 |
| `pageSize` | Integer | 20 | 1~50 |

**响应**（分页 `PageResult<MpComplaintSimpleRespVO>`）：

```json
{
  "code":200,"message":"成功","success":true,
  "data":{
    "list":[
      {
        "id":"8123456789012345678",
        "orderId":"7123456789012345678",
        "orderNo":"HL20260422001",
        "groupCode":"TG001",
        "dayNumber":2,
        "dayLabel":"第2天",
        "types":[
          {"code":"ITINERARY","label":"行程"},
          {"code":"VEHICLE","label":"车"}
        ],
        "contentSnippet":"第二天景点讲解员迟到半小时,沟通后...",
        "imageCount":3,
        "videoCount":0,
        "isResolved":false,
        "resolutionEditCount":0,
        "createdAt":"2026-04-23 10:15:30"
      }
    ],
    "total":"8"
  }
}
```

列表按 `createdAt` **倒序**。

---

### B4. `GET /mp/complaint/{id}` — 投诉详情

**响应**（`MpComplaintRespVO`，20 字段）：

| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | Long | 投诉 ID |
| `orderId` / `orderNo` / `groupCode` | - | 订单冗余信息 |
| `dayNumber` | Integer | 0=整程 / ≥1=具体天 |
| `dayLabel` | String | "整程" 或 "第 N 天" |
| `types` | `List<{code,label}>` | 投诉类型列表 |
| `content` | String | 完整文字（区别于 list 的 snippet） |
| `contentSnippet` | String | 前 50 字 |
| `imageUrls` | `List<String>` | 图片 URL 列表 |
| `videoUrl` | String | 视频 URL 或 null |
| `imageCount` / `videoCount` | Integer | 数量（便于列表快速渲染） |
| `resolutionNote` | String | 解决备注（客户本人填） |
| `resolutionEditCount` | Integer | 已修改次数 0~2 |
| `resolutionUpdatedAt` | DateTime | 最近一次修改时间 |
| `isResolved` | Boolean | resolutionNote 非空 |
| **`canCancel`** | Boolean | 是否可撤回（创建 < 10min 内 + status=ACTIVE） |
| **`canEditResolution`** | Boolean | 是否可填/改解决备注（editCount < 2） |
| `status` | String | ACTIVE / WITHDRAWN |
| `createdAt` | DateTime | 创建时间 |

**错误码**：20304（不存在/已撤回）/ 20305（非本人）

**⚠️ 前端注意**：`canCancel` / `canEditResolution` 是**后端计算返回**，前端**直接用**，**不要自己根据 createdAt 算时间差**（时钟偏移问题）。

---

### B5. `PUT /mp/complaint/{id}/resolution` — 填/改解决备注

**请求 body**：

| 字段 | 类型 | 必填 | 约束 |
|------|------|------|------|
| `resolutionNote` | String | ✅ | 1~500 字 |

**请求示例**：

```http
PUT /mp/complaint/8123456789012345678/resolution
Authorization: Bearer eyJ...
Content-Type: application/json

{"resolutionNote":"已与定制师电话沟通,明天补偿安排"}
```

**响应**：

```json
{"code":200,"message":"成功","data":1,"success":true}
```

`data` = 剩余可编辑次数（2 - newEditCount）。0 = 已锁定不能再改。

**错误码**：20305（非本人）/ 20307（已达 2 次上限）

**规则**：最多写入 **2 次**（一次填 + 一次改）；达 2 次后 `canEditResolution=false`。

---

## C. 管理端 2 接口

统一前缀：`/admin/complaint`。**只读**，无写操作。

**权限**（按钮权限码）：
- `complaint:list:self`（定制师）— 后端强制在 SQL 里注入 `WHERE customizer_id = currentAdminId`，**前端不需要传 customizerId**
- `complaint:list:all`（超管/主管）— 无过滤
- `complaint:detail` — 详情

角色默认绑定：`SUPER_ADMIN(1)` / `ADMIN(2)` → `:all + :detail`；`CUSTOMIZER(3)` → `:self + :detail`

### C1. `GET /admin/complaint/page` — 分页列表

**Query**（`ComplaintPageReqVO`）：

| 参数 | 类型 | 默认 | 说明 |
|------|------|------|------|
| `pageNo` | Integer | 1 | - |
| `pageSize` | Integer | 20 | - |
| `keyword` | String | - | 订单号 / 昵称 / 内容 **模糊** 匹配 |
| `orderNo` | String | - | 订单号 **精确** 匹配 |
| `groupCode` | String | - | 团号 **精确** |
| `customizerNickname` | String | - | 定制师昵称 **模糊** |
| `types` | `List<String>` | - | 多选，如 `?types=ITINERARY&types=VEHICLE` |
| `isResolved` | Boolean | - | true=已解决（备注非空）/ false=未解决 / 不传=全部 |
| `dateFrom` | LocalDate | - | `yyyy-MM-dd`，含当天 |
| `dateTo` | LocalDate | - | `yyyy-MM-dd`，含当天 |
| `dayScope` | String | ALL | `ALL` / `WHOLE`（仅整程）/ `SPECIFIC`（仅指定天） |

**请求示例**：

```http
GET /admin/complaint/page?pageNo=1&pageSize=20&types=ITINERARY&isResolved=false&dateFrom=2026-04-01&dateTo=2026-04-30&dayScope=ALL
Authorization: Bearer <admin-token>
```

**响应**（`PageResult<ComplaintRespVO>`）。单项字段见 C2，列表里 `contactPhoneDecrypted` **为 null**（仅详情按权限才返明文）。

### C2. `GET /admin/complaint/{id}` — 详情

**响应**（`ComplaintRespVO`，完整字段）：

| 字段 | 类型 | 说明 |
|------|------|------|
| `id` / `orderId` / `orderNo` / `groupCode` | - | 基础 |
| `customizerId` / `customizerNickname` | - | 定制师 |
| `userId` / `userNickname` | - | 投诉人 |
| `contactName` | String | 联系人姓名 |
| **`contactPhoneMasked`** | String | 脱敏后 4 位，如 `***1234`（**永远返**） |
| **`contactPhoneDecrypted`** | String | 明文，**仅有权时返**；否则 null |
| `dayNumber` / `dayLabel` | - | 天维度 |
| `types` | `List<{code,label}>` | |
| `content` | String | 完整文字 |
| `contentSnippet` | String | 前 50 字（列表用） |
| `imageUrls` | `List<String>` | |
| `videoUrl` | String | |
| `resolutionNote` / `resolutionEditCount` / `resolutionUpdatedAt` / `isResolved` | - | 解决备注（客户本人写） |
| **`notifyWechatStatus`** | String | `PENDING` / `SUCCESS` / `FAIL` / `SKIP` |
| **`notifyWechatError`** | String | FAIL 时的错误信息 |
| **`notifySmsStatus`** | String | 同上 |
| **`notifySmsError`** | String | 同上 |
| `status` | String | `ACTIVE` / `WITHDRAWN` |
| `createdAt` / `updatedAt` | DateTime | |
| `canRemindCustomizer` | Boolean | **永远 false**（预留，MVP 管理端只读） |

**⚠️ 前端展示建议**：
- 列表页只显示 `contactPhoneMasked`；详情页**如果** `contactPhoneDecrypted` 非空则按"点击查看"展开，否则灰显
- `notifyWechatStatus` / `notifySmsStatus` 用小圆点 + 中文标签渲染（SUCCESS 绿 / FAIL 红 / SKIP 灰 / PENDING 黄）

---

## D. 统一错误码表

| Code | 常量 | 场景 | 附加字段 | 前端应做 |
|------|------|------|---------|---------|
| 20301 | `ERR_COMPLAINT_COOLDOWN` | 同 user+order+day 10 分钟内重复投诉 | `remainingSeconds`（业务码信息里） | 展示"请 X 秒后再试"倒计时 |
| 20302 | `ERR_ORDER_NOT_OWN` | 订单不属于当前 user | - | 提示"订单不存在或无权" |
| 20303 | `ERR_DAY_NUMBER_INVALID` | dayNumber 超出已发生范围 | `maxDayNumber` | 限制前端表单 dayNumber 选择范围 |
| 20304 | `ERR_COMPLAINT_NOT_FOUND` | 投诉不存在或已撤回 | - | 跳回我的投诉列表 |
| 20305 | `ERR_COMPLAINT_NOT_OWN` | 非本人投诉 | - | 提示"无权限" |
| 20306 | `ERR_WITHDRAW_EXPIRED` | 撤回已超 10 分钟 | - | 隐藏撤回按钮 |
| 20307 | `ERR_RESOLUTION_LOCKED` | 解决备注已填满 2 次 | - | 隐藏"再修改一次"按钮 |

**响应结构统一**：

```json
{"code":20301,"message":"10 分钟内已投诉过该天行程","data":null,"success":false}
```

HTTP 状态码恒为 **200**（项目规范），**只看 body 的 `code`**。

---

## E. 前端状态机

### 小程序端客户视角

```
[订单详情]
   │
   ├── 点击"整程投诉" → dayNumber=null → POST /create
   └── 点击第 N 天的"投诉" → dayNumber=N → POST /create
                                 │
                                 ├── 20301 → 展示冷却倒计时
                                 ├── 20303 → 提示"尚未开始的天无法投诉"
                                 └── 200 → 跳转[我的投诉列表]
                                                │
                                                └── 点某条 → [详情]
                                                              ├── canCancel=true → 展示"撤回"按钮
                                                              │     点击 → POST /{id}/cancel
                                                              │            ├── 20306 → 按钮已过期灰掉
                                                              │            └── 200 → 回列表
                                                              ├── canEditResolution=true → 展示"填/改解决备注"按钮
                                                              │     点击 → 弹编辑框 → PUT /{id}/resolution
                                                              │            ├── 20307 → 灰掉按钮
                                                              │            └── 200 → 刷新详情
                                                              └── 只读展示
```

### 管理端定制师视角

```
[投诉管理菜单] → /admin/complaint/page
      │
      ├── 看到所有自己负责订单的投诉（后端按 :self 过滤）
      ├── 顶部筛选：订单号 / 团号 / 类型 / 是否已解决 / 时间 / 整程or指定天
      └── 点某条 → [详情]（只读，无任何写按钮）
                      ├── 查看完整文字 / 图片预览 / 视频播放
                      ├── 看客户写的 resolutionNote（若有）
                      └── 看 notifyWechatStatus / notifySmsStatus 确认是否收到通知
```

### 管理端超管视角

同上，但看到**全部投诉**（后端按 `:all` 不加 customizerId 过滤）。

---

## F. 通知链路

**前端无感知**，此处仅说明供你理解触发场景：

- 投诉创建 `POST /mp/complaint/create` 成功 → afterCommit 触发双链路 @Async
  - **企微**：发给 `order.customizerId → admin_user.enterprise_wechat_id → wechat_user.userid` 对应的定制师，文案：
    ```
    【客户投诉】
    团号：TG001
    订单号：HL20260422001
    联系人：张三 13800138000
    第 2 天 · 行程/车
    内容：第二天景点讲解员迟到半小时,沟通后...（前 50 字）
    图片 3 视频 0
    ```
  - **短信**（阿里云 `SMS_505775156`，签名 呼籁旅行）：
    ```
    【呼籁旅行】团号TG001订单HL20260422001第2天收到客户投诉(行程/车)，请登录后台查看。
    ```
- 定制师未绑企微（`enterprise_wechat_id` 空）→ `notifyWechatStatus=SKIP`
- 定制师无手机号（`wechat_user.mobile` 空）→ `notifySmsStatus=SKIP`
- 任一链路失败 → status=FAIL + error 字段写入，不阻塞主流程
- **前端不需要关心重试**（后端无自动重试，定制师可通过"我的投诉"列表底部的创建时间对比企微收件时间自行排查）

---

## G. 前端对接清单

### 管理端（hl-ui admin）
- [ ] 菜单 `投诉管理`（1250 系列，侧边栏末尾）自动由 sys_menu 返回渲染
- [ ] 列表页：表格 + 7 个筛选条件（上方） + dayScope 切换标签
- [ ] 详情页（Drawer 或独立页）：完整展示 C2 所有字段
- [ ] 图片预览走 `el-image` 或同级组件
- [ ] 视频预览 `<video controls>`
- [ ] `contactPhoneDecrypted` 使用"点击查看"折叠
- [ ] `notifyWechatStatus` / `notifySmsStatus` 状态圆点
- [ ] Long 字段（id/orderId/userId/customizerId）**不要**做数字运算，直接当字符串透传

### 小程序端（hl-mp）
- [ ] 订单详情页每日卡片右上新增"投诉"入口 → dayNumber=N
- [ ] 订单详情页底部新增"整程投诉"入口 → dayNumber=null
- [ ] 投诉表单页：多选类型（chip 或 checkbox）+ 文字（counter 0/500）+ 图片上传（OSS 直传，最多 9）+ 视频上传（最多 1）
- [ ] 冷却 20301：显示"{remainingSeconds} 秒后可再次投诉"倒计时 Toast
- [ ] "我的投诉"入口（我的 tab 底部 或 订单详情页按钮）→ 列表 → 详情
- [ ] 详情页按 `canCancel` / `canEditResolution` 动态显隐按钮
- [ ] 撤回成功 → 回列表 + 自动移除该条（软删不再返回）

---

## H. 冷启动兜底

- 投诉表 `order_complaint` 已在测试服 `hl_order_service_v2` 库就绪（29 字段 + UNIQUE 防刷索引）
- 字典 `complaint_type` + 菜单 1250 系列已执行
- Nacos `aliyun.sms.template.complaint-notify: SMS_505775156` 已热更新（测试服）
- 正式服上线前需运维同步 Nacos 一次（阿里云账号跨环境共享，模板码通用）

---

## 变更记录

| 日期 | 变更 |
|------|------|
| 2026-04-23 | 阿里云模板审核通过 `SMS_505775156`，Nacos 热更新，短信真发 |
| 2026-04-23 | 完整接口契约本文件（替代简版） |
| 2026-04-22 | 投诉模块上线（PR #1221），mp 5 接口 + admin 2 接口 + 字典菜单 |
