Java 项目实战: 交友项目 - 用户体系集成:根据环信id查询用户详情
环信消息里只有环信 ID(如 hx106),不带昵称头像,接收方拿到消息后必须反查项目自己的用户库才能把界面展示出来,本文实现这条反向查询链路,它是用户体系集成的最后一环。
纲要
- 业务背景:为什么需要「环信 ID → 项目用户」的反向查询
- 环信消息格式只携带环信 userID 和消息内容
- 客户端解析消息后要展示昵称、头像,只能再查交友服务端
- 接口设计:
GET /messages/userinfo,入参单个环信 ID,返回UserInfoVo - 代码链路(基于本文示例项目
mf的实际实现) MessagesController#userinfoMessagesService#findUserInfoByHuanxinUserApi#findByHuanxin→UserApiImpl按hx_user列查询- 映射闭环回顾:正向注册(
hx + userId)+ 反向反查(hx_user列条件查询) - 方案边界讨论:前缀解析 vs 数据库映射表
- 常见坑:查不到用户、NPE、批量场景
- 验证:Postman 全流程可复现
为什么需要反向查询
先回到消息格式。本文示例项目的客户端登录环信服务器后能自动收到推送消息,消息可以由客户端发,也可以由服务端发(前文在环信管理后台发送的就是模拟服务端发送)。环信是一个提供即时通信能力的第三方服务,负责在客户端与服务端之间传递聊天消息。双方约定的消息体大致长这样:
json
{
"huanxinId": "hx106",
"msg": "幸好我们还是彼此的唯一"
}
客户端自定义的聊天消息只有两个字段:发送方的环信 userID 和消息内容。消息经过环信服务器到达接收方后,接收方能拿到发送方在本文示例项目中的 userId 吗?拿不到,环信只传递环信 ID,不传递项目内部的用户 ID。
而环信本身只是一个即时通信通道,不提供用户体系。消息列表页要展示发送者昵称、头像,这些数据只存在于本文示例项目自己的数据库里。所以接收方解析出环信 ID 之后,必须调用交友服务端的接口做一次反向查询,把环信 ID 翻译成用户详情。
交友服务端 接收方客户端 环信服务器 发送方客户端 交友服务端 接收方客户端 环信服务器 发送方客户端 #mermaid-svg-RA1zRyQ5AHK5trEO{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-RA1zRyQ5AHK5trEO .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-RA1zRyQ5AHK5trEO .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-RA1zRyQ5AHK5trEO .error-icon{fill:#552222;}#mermaid-svg-RA1zRyQ5AHK5trEO .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-RA1zRyQ5AHK5trEO .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-RA1zRyQ5AHK5trEO .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-RA1zRyQ5AHK5trEO .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-RA1zRyQ5AHK5trEO .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-RA1zRyQ5AHK5trEO .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-RA1zRyQ5AHK5trEO .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-RA1zRyQ5AHK5trEO .marker{fill:#333333;stroke:#333333;}#mermaid-svg-RA1zRyQ5AHK5trEO .marker.cross{stroke:#333333;}#mermaid-svg-RA1zRyQ5AHK5trEO svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-RA1zRyQ5AHK5trEO p{margin:0;}#mermaid-svg-RA1zRyQ5AHK5trEO .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-RA1zRyQ5AHK5trEO text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-RA1zRyQ5AHK5trEO .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-RA1zRyQ5AHK5trEO .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-RA1zRyQ5AHK5trEO .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-RA1zRyQ5AHK5trEO .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-RA1zRyQ5AHK5trEO #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-RA1zRyQ5AHK5trEO .sequenceNumber{fill:white;}#mermaid-svg-RA1zRyQ5AHK5trEO #sequencenumber{fill:#333;}#mermaid-svg-RA1zRyQ5AHK5trEO #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-RA1zRyQ5AHK5trEO .messageText{fill:#333;stroke:none;}#mermaid-svg-RA1zRyQ5AHK5trEO .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-RA1zRyQ5AHK5trEO .labelText,#mermaid-svg-RA1zRyQ5AHK5trEO .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-RA1zRyQ5AHK5trEO .loopText,#mermaid-svg-RA1zRyQ5AHK5trEO .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-RA1zRyQ5AHK5trEO .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-RA1zRyQ5AHK5trEO .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-RA1zRyQ5AHK5trEO .noteText,#mermaid-svg-RA1zRyQ5AHK5trEO .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-RA1zRyQ5AHK5trEO .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-RA1zRyQ5AHK5trEO .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-RA1zRyQ5AHK5trEO .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-RA1zRyQ5AHK5trEO .actorPopupMenu{position:absolute;}#mermaid-svg-RA1zRyQ5AHK5trEO .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-RA1zRyQ5AHK5trEO .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-RA1zRyQ5AHK5trEO .actor-man circle,#mermaid-svg-RA1zRyQ5AHK5trEO line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-RA1zRyQ5AHK5trEO :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 发送消息(huanxinId=hx106 + 内容) 推送消息 解析出环信 ID hx106 GET /messages/userinfo?huanxinId=hx106 按 hx_user 列查 User,再查 UserInfo UserInfoVo(昵称、头像...) 展示昵称与头像
顺带说明服务端发消息的场景:本文示例项目中的指令消息(比如陌生人问题的回复)由 mfService 通过 HuanXinTemplate.sendMsg 发送,目标地址同样是 Constants.HX_USER_PREFIX + userId 拼出来的环信 ID。客户端收到后走的是同一条反查链路。真实生产环境里还有环信回调(REST Hook)的场景,交友服务端收到回调事件时手里也只有环信 ID,同样依赖这层反查。
接口设计
- 请求路径:
GET /messages/userinfo - 请求方式:GET
- 请求参数:
huanxinId(单个环信(一个提供即时通讯能力的第三方服务)用户 ID,如hx1) - 返回值:
UserInfoVo(用户详情,封装昵称、头像等,供消息界面展示)
注意入参是单个环信 ID,不是列表。聊天页一次只展示一个对话对象的资料,单个查询就够了。
代码实现
本文示例项目 mf/06-即时通信/01-代码/mf,涉及四个模块的改动:
tree
mf
├── mf-app-server # 应用服务层
│ └── src/main/java/com/mf/server
│ ├── controller
│ │ └── MessagesController.java # GET /messages/userinfo
│ └── service
│ └── MessagesService.java # findUserInfoByHuanxin
├── mf-dubbo
│ ├── mf-dubbo-interface
│ │ └── src/main/java/com/mf/dubbo/api
│ │ └── UserApi.java # 新增 findByHuanxin 定义
│ └── mf-dubbo-db
│ └── src/main/java/com/mf/dubbo/api
│ └── UserApiImpl.java # 按 hx_user 列查询实现
├── mf-model
│ └── src/main/java/com/mf/model
│ ├── domain/User.java # hxUser / hxPassword 字段
│ └── vo/UserInfoVo.java # 返回的详情 VO
└── mf-commons
└── src/main/java/com/mf/commons/utils
└── Constants.java # HX_USER_PREFIX = "hx"
MessagesController
java
package com.mf.server.controller;
import com.mf.model.vo.UserInfoVo;
import com.mf.server.service.MessagesService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/messages")
public class MessagesController {
@Autowired
private MessagesService messagesService;
/**
* 根据环信ID查询用户详细信息(环信是提供即时通讯能力的第三方服务)
*/
@GetMapping("/userinfo")
public ResponseEntity userinfo(String huanxinId) {
UserInfoVo vo = messagesService.findUserInfoByHuanxin(huanxinId);
return ResponseEntity.ok(vo);
}
}
GET 方式请求,参数直接写在方法签名上,Spring MVC 按参数名 huanxinId 自动绑定查询串。注意请求路径前缀要与类上的 @RequestMapping("/messages") 一致,完整路径是 /messages/userinfo。
MessagesService(核心实现)
java
package com.mf.server.service;
import com.mf.dubbo.api.UserApi;
import com.mf.dubbo.api.UserInfoApi;
import com.mf.model.domain.User;
import com.mf.model.domain.UserInfo;
import com.mf.model.vo.UserInfoVo;
import org.apache.dubbo.config.annotation.DubboReference;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
@Service
public class MessagesService {
@DubboReference
private UserApi userApi;
@DubboReference
private UserInfoApi userInfoApi;
/**
* 根据环信id查询用户详情
*/
public UserInfoVo findUserInfoByHuanxin(String huanxinId) {
//1、根据环信id查询用户
User user = userApi.findByHuanxin(huanxinId);
//2、根据用户id查询用户详情
UserInfo userInfo = userInfoApi.findById(user.getId());
UserInfoVo vo = new UserInfoVo();
BeanUtils.copyProperties(userInfo, vo); //copy同名同类型的属性
if (userInfo.getAge() != null) {
vo.setAge(userInfo.getAge().toString());
}
return vo;
}
}
思路分两步:
- 环信 ID → User。环信是本文示例项目使用的即时通讯云服务,其用户 ID 用于标识聊天账号。 。环信用户 ID 已经在注册时绑定到了
tb_user表(hx_user列),所以先通过UserApi.findByHuanxin拿到User对象,从中取出本文示例项目的userId。 - userId → UserInfo 。用户资料(昵称、头像)在
tb_user_info表,复用本文示例项目中已有的UserInfoApi.findById。
最后构建 UserInfoVo:BeanUtils.copyProperties 拷贝同名同类型属性,age 在 UserInfo 里是 Integer、在 VO 里是 String,类型不同拷不上,需要手动转一次。这段组装代码与本文示例项目中 UserInfoService 里「根据 ID 查询详情」的写法一致,直接复用即可。
UserApi 接口与实现
mf-dubbo-interface 的 UserApi 中新增方法定义:
java
package com.mf.dubbo.api;
import com.mf.model.domain.User;
public interface UserApi {
/**
* 根据环信(一个提供即时通讯能力的第三方服务)用户名查询用户信息
*/
User findByHuanxin(String huanxinId);
}
mf-dubbo-db 的 UserApiImpl 中实现(与既有方法并列):
java
package com.mf.dubbo.api;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.mf.dubbo.mappers.UserMapper;
import com.mf.model.domain.User;
import org.apache.dubbo.config.annotation.DubboService;
import org.springframework.beans.factory.annotation.Autowired;
@DubboService
public class UserApiImpl implements UserApi {
@Autowired
private UserMapper userMapper;
@Override
public User findByHuanxin(String huanxinId) {
QueryWrapper<User> qw = new QueryWrapper<>();
qw.eq("hx_user", huanxinId);
return userMapper.selectOne(qw);
}
}
用 QueryWrapper 构建 hx_user = ? 条件,selectOne 取唯一用户。写法与 findByMobile 完全同构,只是查询字段换成了 hx_user。
映射机制的完整闭环
至此,正向注册和反向反查合上了闭环:
渲染错误: Mermaid 渲染失败: Lexical error on line 2. Unrecognized text. ...t LR subgraph 正向:注册时建立映射 A[项目注册/ ----------------------^
正向(UserService 注册逻辑节选):
java
//注册环信用户
String hxUser = "hx" + user.getId();
Boolean create = huanXinTemplate.createUser(hxUser, Constants.INIT_PASSWORD);
if (create) {
user.setHxUser(hxUser);
user.setHxPassword(Constants.INIT_PASSWORD);
userApi.update(user);
}
反向反查的核心实现:findByHuanxin 按 hx_user 列查回 User。写入和查询用的是同一列,映射关系自洽。
前缀约定与映射存储:方案边界
阅读本文示例代码会发现一个关键设计决策:Constants.HX_USER_PREFIX = "hx" 这个前缀只出现在正向方向 ,mfService 发消息、MessagesService.contacts 加好友时拼接环信 ID。反向查询没有做「剥掉 hx 前缀取数字」的解析,而是老老实实查了数据库。
两种可行方案的对比:
| 维度 | 前缀解析(hx106 → 106) | DB 映射表(本文示例项目采用) |
|---|---|---|
| 反查成本 | 零额外 IO,纯字符串操作 | 多一次 SQL 查询 |
| 依赖前提 | 环信 ID 严格符合 前缀 + 数字 规则 |
注册时映射成功落库 |
| 规则变更适应性 | 差:前缀改了、出现非数字段,解析直接崩 | 好:查库不关心 ID 长什么样 |
| 前缀/ID 冲突风险 | 有:若环信应用混入其他来源用户,ID 规则不受控 | 无:只认自己写入的 hx_user 值 |
| 一致性风险 | 无状态,天然一致 | 注册环信成功但落库失败会丢映射,需补偿 |
本文示例项目选择 DB 映射是更稳妥的方案:hx_user 列就是持久化的映射表。前缀 "hx" 的真实作用不是给反查解析用的,而是命名隔离 ,本文示例项目的 userId 是纯数字,直接拿 106 当环信 ID 容易与环信应用内其他体系的账号撞名,加 hx 前缀保证唯一。这也是一种「约定」:如果哪天环信 ID 规则要变(比如换成 mf_106),前缀方案必须同步改解析代码,而 DB 方案只要写入时保持一致、查询侧完全无感。
如果实际项目中只能用前缀解析(比如历史遗留没存映射),务必给解析代码加防护:
java
// 前缀解析方案的防御性写法(本工程未采用,仅作扩展认知)
public Long parseUserId(String huanxinId) {
if (huanxinId == null || !huanxinId.startsWith(Constants.HX_USER_PREFIX)) {
return null; // 无 hx 前缀,不是项目用户,直接忽略
}
try {
return Long.valueOf(huanxinId.substring(Constants.HX_USER_PREFIX.length()));
} catch (NumberFormatException e) {
return null; // 前缀剥离后不是纯数字,规则外的 ID
}
}
常见坑
| 现象 | 原因 | 处理 |
|---|---|---|
NullPointerException,user.getId() 处报错 |
传入不存在的环信(即时通讯云服务)用户 ID,selectOne 返回 null,代码未判空就取 user.getId() |
生产代码应先判 user == null,返回空对象或业务异常 |
传非 hx 前缀的 ID 查不到 |
环信(即时通讯云服务)应用里其他来源的用户,hx_user 列不会有记录 |
查询返回 null 时视为「非项目用户」,调用方做兜底展示 |
| 用户已注销仍查到旧资料 | tb_user_info 已删但 tb_user 还在 |
资料查询返回 null 时整体返回空,避免半截数据 |
| 消息列表逐条反查,N 条消息打 N 次接口 | 当前查询接口按单个用户设计 | 批量场景应扩展为 findByHuanxins(List<String>) + in 查询,一次带回 |
注册时在环信(即时通讯云服务)创建用户失败,hx_user 为空 |
createUser 返回 false 未落库 |
该用户消息反查会落空,注册流程要记录失败并补偿 |
验证
前置:启动本文示例项目中的 mf-server、mf-app-server、mf-dubbo-db 三个服务,并确保 MySQL 中 tb_user、tb_user_info 表已有数据。
- 保证映射存在 :用手机号 + 验证码走一遍登录注册流程。新用户注册时,系统会自动执行
createUser("hx" + id, ...),并将映射关系写入hx_user表。 - 正向反查 :使用 Postman 发送
GET http://localhost:18080/messages/userinfo?huanxinId=hx1,并在请求头中携带登录后获取的Authorization令牌。接口应返回该用户的完整资料,包括nickname、avatar、age等字段。 - 传入不存在的环信 ID :将参数设为
huanxinId=hx99999。此时数据库查无此人,selectOne返回 null,实际测试中该实现会抛出空指针异常并返回 500 状态码,印证坑表第一行的改进点。 - 传入非 hx 前缀的 ID :将参数设为
huanxinId=test123。此时同样查不到数据,行为与上一步一致,说明该接口只识别系统自身写入的映射。
注意 Postman 里必须配置 token 头,否则会被登录拦截器挡在门外。
常见误解澄清
| 误解 | 澄清 |
|---|---|
| 「反查时要剥掉 hx 前缀转数字当 userId 用」 | 本文示例项目的实际读取代码没有做前缀剥离,直接按 hx_user 列查库;前缀剥离是另一种可选方案,边界见上文对比表 |
| 「环信消息里应该带昵称头像」 | 客户端自定义消息只有环信 ID 和内容,环信不提供用户体系,资料必须自己查 |
| 「这个接口是给环信服务器回调用的」 | 本文示例项目里它是给客户端解析消息后调用的;服务端回调场景是生产环境的延伸用法 |
| 「查详情要连查 user 表和 user_info 表两次 Dubbo」 | 确实是两步:先 UserApi.findByHuanxin 拿 userId,再 UserInfoApi.findById 拿资料,两表职责不同 |
| 「环信 ID 与 userId 永远可以通过公式互推」 | 互推依赖 hx + userId 的约定与 hx_user 列的一致性,任何一环(注册失败、规则变更)都会打破 |
API 速览
| 接口 | 方法 | 参数 | 返回 | 说明 |
|---|---|---|---|---|
/messages/userinfo |
GET | huanxinId(单个环信 ID) |
UserInfoVo |
根据环信 ID 反查用户详情,供消息界面展示 |
官方文档
- 环信即时通信云文档:环信是提供即时通信能力的云服务,本文示例中用于消息收发;该文档涵盖 REST API 与消息格式、REST Hook 回调机制
- MyBatis-Plus 条件构造器:MyBatis-Plus 是简化数据库操作的 Java 持久层框架,本文示例中用于构建查询条件;该文档介绍
QueryWrapper的eq/in用法
总结
- 环信(本文示例中使用的即时通讯云服务)只是消息通道,消息体里只有环信 ID;展示昵称头像必须由交友服务端做「环信 ID → 用户」的反向查询。
- 核心链路三步:
GET /messages/userinfo→userApi.findByHuanxin按hx_user列查 User →userInfoApi.findById查详情组装UserInfoVo。 - 用户体系集成的闭环 = 正向注册(
hx + userId注册环信并落库hx_user)+ 反向反查(同列条件查询),写入与读取共用一列,映射自洽。 - 前缀
"hx"是命名隔离约定而非解析依据;反查依赖持久化映射比字符串解析更能适应规则变更。 - 本文示例代码中发现的改进点:
user == null未判空会导致 NPE,生产实现需要补上兜底。 - 后续用户体系集成内容将总结收束本文:注册凭证、消息收发、反查详情三件事拼成完整的 IM 用户体系。