diff --git a/changelogs/2026-04/2026-04-23_order-complaint-full-contract.md b/changelogs/2026-04/2026-04-23_order-complaint-full-contract.md new file mode 100644 index 0000000..b327c50 --- /dev/null +++ b/changelogs/2026-04/2026-04-23_order-complaint-full-contract.md @@ -0,0 +1,420 @@ +# 投诉模块完整接口契约(前端对接专用) + +- **日期**: 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 `(userId 由网关注入 request.userId) + - 管理端 `/admin/**`:`Authorization: Bearer ` + 按钮权限 + +--- + +## 📚 目录 + +- [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 + +响应 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` | ✅ | 非空,元素∈{ITINERARY,VEHICLE,HOTEL,OTHER} | 可多选 | +| `content` | String | ✅ | 1~500 字 | 投诉文字 | +| `imageUrls` | `List` | ❌ | ≤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`): + +```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` | 图片 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` | - | 多选,如 `?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 +``` + +**响应**(`PageResult`)。单项字段见 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` | | +| `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` 或同级组件 +- [ ] 视频预览 `