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