ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java 项目实战:交友项目 - 好友申请:回复陌生人问题

Java 项目实战:交友项目 - 好友申请:回复陌生人问题

概述

回答对方的陌生人问题,服务端要做的不是「存一条回答」,而是把回答包装成一条 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-Typeapplication/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-mongo

mfService

//回复陌生人问题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,环信发不出去,这次搭讪就等于没发生,前端该重试重试。

时序图

提问者App环信服务器Dubbo(UserInfo/Question)mfServicemfController回答者App提问者App环信服务器Dubbo(UserInfo/Question)mfServicemfController回答者Appalt[发送失败][发送成功]POST /mf/strangerQuestions {userId, reply}replyQuestions(106, "……")查自己昵称 + 查对方问题nickname / strangerQuestion组装五字段 JSON(fastjson)sendMsg("hx106", message) from=admin发送成功 / 失败抛 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/strangerQuestionsbody: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」的双写链路已经在好友申请:测试里完整验证过,测试用例和排错表可以直接复用。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进