概述
回答对方的陌生人问题,服务端要做的不是「存一条回答」,而是把回答包装成一条 IM 消息经环信送达提问人,回答全程不落库。这里拆解这条发送链路:POST /mf/strangerQuestions如何组装消息 JSON、为什么发送方固定为 admin、发送失败怎么处理,以及内容无审核这个留下的口子。
纲要
- 业务场景:答题打招呼,回答经环信私信送达、不落库
- 消息格式:五个字段的 JSON 约定(userId / huanXinId / nickname / strangerQuestion / reply)
- 接口设计:
POST /mf/strangerQuestions,Map 接参的类型转换 - 实现链路:组装 JSON →
HuanXinTemplate.sendMsg→ 发送方为 admin 的系统消息 - 时序图:从提交回答到对方手机弹出私信
- 坑表:发送失败抛异常、日志文案错误、回答内容无审核
- 结尾衔接:好友申请:测试## 业务场景:回答就是一条私信
上一篇好友申请:查看陌生人问题里,用户在详情页拿到了对方的问题。填好答案点「聊一下」,App 发出POST /mf/strangerQuestions。服务端收到后做一件事:给提问人的环信账号发一条私信,消息体里带着回答人的资料、对方的问题原文和回答内容。提问人的 App 收到这条私信,弹出「有人回答了你的问题」,可以选择「确认添加」成为好友。
三个关键设计决策,先说结论:
| 决策 | 选择 | 为什么 |
|---|---|---|
| 回答存哪 | 不存库,只走环信 | 回答是一次性的搭讪语,未被采纳前没有查询需求;落库反而引入「谁有权看」的隐私问题 |
| 发送者身份 | 发送方固定为 admin 的系统消息 | 回答者与提问人还不是好友,环信不允许陌生人互发;用管理员身份绕开好友关系校验 |
| 回答人信息 | 放进消息体 JSON | 发送方是 admin,对方看不到真实发送人,资料必须随消息携带 |
| 消息体长这样(讲义原文样例): |
{"userId":106,"huanXinId":"hx106","nickname":"小妹","strangerQuestion":"你喜欢去看蔚蓝的大海还是去爬巍峨的高山?","reply":"我喜欢秋天的落叶,夏天的泉水,冬天的雪地,只要有你一切皆可~"}五个字段的语义:
userId:回答人的用户 id,对方「确认添加」时要拿它当 friendId;huanXinId:回答人的环信账号(hx前缀 + 用户 id),加好友时注册环信联系人用;nickname:回答人昵称,卡片直接展示;strangerQuestion:提问人自己的问题原文,客户端回显「你问的是 XXX,TA 回答……」;reply:回答内容,正文。
注意strangerQuestion是对方的问题,不是自己的,发送前要再查一次对方的tb_question,而不是信任前端回传。
接口设计
| 项 | 值 |
|---|---|
| 请求地址 | /mf/strangerQuestions |
| 请求方式 | POST |
| Content-Type | application/json |
| 请求体 | userId(提问人 id)、reply(回答内容) |
| 响应 | 空 body,200 表示环信发送成功 |
mfController
/** * 回复陌生人问题 */@PostMapping("/strangerQuestions")publicResponseEntityreplyQuestions(@RequestBodyMapmap){//前端传递的userId:是Integer类型的Stringobj=map.get("userId").toString();LonguserId=Long.valueOf(obj);Stringreply=map.get("reply").toString();mfService.replyQuestions(userId,reply);returnResponseEntity.ok(null);}这里用Map接 JSON 而不是 DTO,是教学工程的偷懒写法,代价在下一篇复盘里细说。注释里那句「前端传递的userId是Integer类型」就是实际踩出来的:JSON 里的整数反序列化成Integer,不能直接强转Long,所以绕道toString()再Long.valueOf。
实现链路
mf-app-server └── src/main/java/com/mf/server ├── controller │ └── mfController.java # POST /mf/strangerQuestions └── service └── mfService.java # 组装 JSON + 调 HuanXinTemplate mf-autoconfig └── com/mf/autoconfig/template └── HuanXinTemplate.java # sendMsg:EMService 发文本消息 mf-dubbo(读路径) ├── mf-dubbo-interface │ ├── QuestionApi.java # 查对方问题 │ └── UserInfoApi.java # 查自己昵称 └── mf-dubbo-db / mf-dubbo-mongomfService
//回复陌生人问题publicvoidreplyQuestions(LonguserId,Stringreply){//1、构造消息数据LongcurrentUserId=UserHolder.getUserId();UserInfouserInfo=userInfoApi.findById(currentUserId);Mapmap=newHashMap();map.put("userId",currentUserId);map.put("huanXinId",Constants.HX_USER_PREFIX+currentUserId);map.put("nickname",userInfo.getNickname());map.put("strangerQuestion",strangerQuestions(userId));map.put("reply",reply);Stringmessage=JSON.toJSONString(map);//2、调用template对象,发送消息BooleanaBoolean=template.sendMsg(Constants.HX_USER_PREFIX+userId,message);//1、接受方的环信id,2、消息if(!aBoolean){thrownewBusinessException(ErrorResult.error());}}分两步看:
组装消息。当前登录人(回答者)的 id 从UserHolder拿,昵称经 Dubbo 查UserInfo;环信 id 统一用常量Constants.HX_USER_PREFIX = "hx"拼前缀,收发两端拼 id 的规则必须一致,否则消息发给一个不存在的环信账号。strangerQuestion复用了上一篇的strangerQuestions(userId)方法,服务端重新查对方的问题,保证消息体里的问题和库里一致。
发送。HuanXinTemplate.sendMsg的实现:
//发送消息publicBooleansendMsg(Stringusername,Stringcontent){try{//接收人用户列表Set<String>set=CollUtil.newHashSet(username);//文本消息EMTextMessagemessage=newEMTextMessage().text(content);//发送消息 from:admin是管理员发送service.message().send("admin","users",set,message,null).block();returntrue;}catch(Exceptione){log.error("删除联系人失败~");}returnfalse;}环信 SDK 的send(from, type, targets, message, ext)第一个参数是发送方,这里固定传"admin",环信后台内置的管理员账号。这就是「系统消息」的落点:消息在环信侧的发送人是 admin,不是回答者本人。正因为是 admin 发的,才不受「非好友不能私聊」的限制;也正因为如此,回答人的身份只能靠消息体里的userId/huanXinId/nickname三个字段自证。
返回false时 Service 层抛BusinessException(ErrorResult.error()),接口返回 500,环信发不出去,这次搭讪就等于没发生,前端该重试重试。
时序图
整条链路没有任何写库动作:MongoDB、MySQL 都没动,唯一的持久化在环信服务器上(消息漫游记录)。
坑表
| 坑 | 现象 | 根因 | 处理 |
|---|---|---|---|
| 环信发送失败但看不出原因 | 接口 500,日志只有一句「删除联系人失败~」 | HuanXinTemplate.sendMsg的log.error文案是从deleteContact复制来的,复制粘贴错误 | 排查时忽略文案看堆栈;顺手把文案改对,别让日志骗人 |
| 强转 Long 报 ClassCastException | java.lang.Integer cannot be cast to java.lang.Long | JSON 整数默认反序列化为 Integer | 走toString()再Long.valueOf,或改用 DTO 接参 |
| 回答内容无审核 | 任何文本(广告、违规内容)都能直达对方私信 | 发送链路没有内容安全检测,配合「不落库」更无从追责 | 课程设计上留给第 10 章内容审核收口;生产必须在发送前接内容安全服务 |
| 环信 id 拼错发给空气 | 接口 500,对方永远收不到 | 收发两端前缀规则不一致 | 统一用Constants.HX_USER_PREFIX,不要手拼字符串 |
| 回答内容过长 | 消息被环信拒绝或截断 | 环信文本消息有长度上限 | 服务端对reply做长度校验,别只依赖前端 |
「回答内容无审核」值得单独说一句:这条链路从用户输入到对方手机,中间零检查。教学项目为了收敛主题留了这个口子,但它恰好是社交产品的合规命门,生产系统里「先审后发」是底线,这也是课程后面安排内容审核章节的原因。
API 速览
| 方法 | 路径 | 关键参数 | 返回 | 失败表现 |
|---|---|---|---|---|
| POST | /mf/strangerQuestions | body:userId、reply | 空 body | 环信失败抛BusinessException,500 |
验证要点
- 双账号端到端:A(1 号)提问,B(106 号)登录后 POST 回答,A 的 App 应收到 admin 私信,消息体五字段齐全;
- 消息身份验证:在环信控制台消息记录里确认 from 是
admin,而不是hx106; - 失败分支:把环信配置改成错误的 clientSecret 再发,接口应 500,且
tb_question、user_info无任何变更(不落库的可观测证据); - 问题一致性:先改掉对方的
tb_question记录再回答,收到的消息里strangerQuestion应是新问题,证明服务端重查了问题而不是信任前端。
常见误解澄清
| 误解 | 实际情况 |
|---|---|
| 回答会存进数据库 | 不落库,唯一持久化是环信侧的私信记录 |
| 消息以回答者本人身份发出 | from 是admin,回答者身份靠消息体 JSON 携带 |
strangerQuestion是自己的问题 | 是对方的问题原文,服务端重新查库回填 |
| 发送失败会静默忽略 | sendMsg返回 false 时抛BusinessException,前端收到 500 |
sendMsg的错误日志可信 | 文案「删除联系人失败~」是复制粘贴错误,排查时以异常堆栈为准 |
官方文档
- 环信即时通讯云 - 发送消息 REST 接口 ,
send(from, targetType, targets, message, ext)参数语义 - Fastjson toJSONString , Map 转 JSON 字符串
总结
回复陌生人问题是一条「组装即发送」的链路:五个字段拼一条 JSON,以 admin 系统消息的身份经环信送达提问人,全程不碰本地数据库。它的设计精髓在身份与通道的取舍,用 admin 发送换来了陌生人可达性,代价是回答人身份只能自证、内容没有审核。回答送达后,对方点「确认添加」才真正建立好友关系,那条「先环信后 Mongo」的双写链路已经在好友申请:测试里完整验证过,测试用例和排错表可以直接复用。