Java 项目实战: 交友项目 - 用户体系集成:根据环信id查询用户详情

Java 项目实战: 交友项目 - 用户体系集成:根据环信id查询用户详情

环信消息里只有环信 ID(如 hx106),不带昵称头像,接收方拿到消息后必须反查项目自己的用户库才能把界面展示出来,本文实现这条反向查询链路,它是用户体系集成的最后一环。

纲要

  • 业务背景:为什么需要「环信 ID → 项目用户」的反向查询
  • 环信消息格式只携带环信 userID 和消息内容
  • 客户端解析消息后要展示昵称、头像,只能再查交友服务端
  • 接口设计:GET /messages/userinfo,入参单个环信 ID,返回 UserInfoVo
  • 代码链路(基于本文示例项目 mf 的实际实现)
  • MessagesController#userinfo
  • MessagesService#findUserInfoByHuanxin
  • UserApi#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;
    }
}

思路分两步:

  1. 环信 ID → User。环信是本文示例项目使用的即时通讯云服务,其用户 ID 用于标识聊天账号。 。环信用户 ID 已经在注册时绑定到了 tb_user 表(hx_user 列),所以先通过 UserApi.findByHuanxin 拿到 User 对象,从中取出本文示例项目的 userId。
  2. 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 表已有数据。

  1. 保证映射存在 :用手机号 + 验证码走一遍登录注册流程。新用户注册时,系统会自动执行 createUser("hx" + id, ...),并将映射关系写入 hx_user 表。
  2. 正向反查 :使用 Postman 发送 GET http://localhost:18080/messages/userinfo?huanxinId=hx1,并在请求头中携带登录后获取的 Authorization 令牌。接口应返回该用户的完整资料,包括 nickname、avatar、age 等字段。
  3. 传入不存在的环信 ID :将参数设为 huanxinId=hx99999。此时数据库查无此人,selectOne 返回 null,实际测试中该实现会抛出空指针异常并返回 500 状态码,印证坑表第一行的改进点。
  4. 传入非 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 用户体系。
相关推荐
努力努力再努力wz41 分钟前
【Lua 入门系列】从顺序执行到分支与循环:一文理解 Lua 控制结构、作用域与跳转规则
junit·单元测试·lua
摇滚侠1 小时前
《On Java 中文版 基础卷》阅读笔记 复用 13
java·笔记·python
2601_968900771 小时前
玻璃深加工的工艺窗口:4 道工序的温度与时间参数边界
android·java·javascript
薛之谦_1 小时前
用 Java 实现阿里云式 IAM 子用户管理:架构设计与表结构详解
java·python·阿里云
甜到心里的蛋糕1 小时前
抖音评论自动回复机器人部署实践:无头 Chrome + DeepSeek + NapCat(2C2G 云主机)
java·jvm·chrome·python·抖音·deepseek
Java爱好狂.1 小时前
阿里1658页Java面试题总结(含答案)
java·后端·高并发·架构师·java面试·java面试题·java八股文
Thanks_ks2 小时前
从 0.1+0.2 不等于 0.3 说起:聊聊浮点数精度问题
java·python·数值计算·精度丢失·bigdecimal·浮点数·ieee754
海鸥-w2 小时前
区域名称同步存储实现笔记
java·笔记
Rain的Java大神之路2 小时前
LeetCode 21 合并两个有序链表:一道 Easy 题,凭什么刷掉一半候选人?
java·后端·算法