- 字典 complaint_type 4 项 - 小程序 5 接口详细字段表 + 请求/响应示例 + 错误码逐条 - 管理端 2 接口含权限模型 + 25 字段表(含通知状态、脱敏电话双字段) - 统一错误码 20301~20307 附加字段与前端应对 - 前端状态机: 客户视角 + 定制师视角 + 超管视角 - 通知链路说明 + 短信模板 SMS_505775156(审核通过, Nacos 已热更新) - 前端对接清单按管理端/小程序分点拆开
16 KiB
投诉模块完整接口契约(前端对接专用)
- 日期: 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. 字典
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 |
请求示例:
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
}
响应:
{"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:无
响应:
{"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>):
{
"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 字 |
请求示例:
PUT /mp/complaint/8123456789012345678/resolution
Authorization: Bearer eyJ...
Content-Type: application/json
{"resolutionNote":"已与定制师电话沟通,明天补偿安排"}
响应:
{"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,前端不需要传 customizerIdcomplaint: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(仅指定天) |
请求示例:
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 次 | - | 隐藏"再修改一次"按钮 |
响应结构统一:
{"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 接口 + 字典菜单 |