Java 项目实战:交友项目环信云通信全解——体系集成、执行链路与 REST API 实战

引言

项目要具备 IM 能力,前端和后端各要接入环信的哪一部分、一条聊天消息到底流经哪些节点、项目的 userId 和环信账号怎么对应、服务端 REST API 又该怎么鉴权和封装------本文把这一整套从架构到代码的链路一次讲透。环信产品本身的概念(它是什么、免费额度、Console 怎么建应用)见前文环信通信:介绍,此处不再重复。

全文分两条主线:前半部分讲集成体系与执行过程 ,回答「谁接谁、消息怎么走、账号怎么映射」;后半部分讲服务端 REST API ,回答「怎么鉴权、有哪些能力、项目实际封装了哪几个」。两条主线共用同一个 HuanXinTemplate 组件,读完后半部分时建议随时回看前半部分的架构图。

纲要

  • 集成体系全景
  • 三方角色:交友 App、交友服务端、环信平台
  • 两处集成点:客户端 SDK 集成 + 服务端 REST API 集成
  • 三类消息通道:用户聊天、系统消息、好友关系
  • 执行过程
  • 四步工作流:注册环信用户 → 同步好友关系 → 下发环信凭证 → 端到端直聊
  • 以「给好友发一条消息」为例的完整时序
  • 用户体系打通
  • 环信 ID 映射规则:"hx" + userId(Constants.HX_USER_PREFIX)
  • 注册时机:新用户注册时同步注册,老用户批量补注册
  • 双 token 体系:项目 JWT 与环信账号密码
  • 服务端集成代码落点
  • HuanXinProperties + HuanXinTemplate + 自动装配
  • 服务端 API 的鉴权:clientId/clientSecret 换 app token
  • curl 换 token 示例(POST /{org}/{app}/token)
  • token 有效期与缓存策略
  • 与交友服务端自己的 JWT 是两套体系,不要混用
  • REST API 能力矩阵(用户管理 / 好友关系 / 消息 / 群组 / 历史消息)
  • HuanXinTemplate 四个方法逐个拆
  • createUser 为什么 toLowerCase
  • addContact / deleteContact 好友关系双写
  • sendMsg 的 from=admin 是什么
  • SDK(im-sdk-core 0.2.5)与裸 REST 的关系,.block() 的意义
  • 用第三方的数据边界:聊天记录不在自己手里的取舍
  • 常见误解澄清与常见坑排查
  • 可复现的验证流程

集成体系全景

环信官方对集成位置的说明很直接:环信和用户体系的集成主要发生在两个地方,服务器端集成 和客户端集成。对应到项目:

  • 项目前端:引入环信 Android SDK(可以理解成一个 jar 包),引入后客户端就具备了通过长连接收发消息的能力;
  • 项目后端:不引 SDK 的客户端部分,而是调用环信的服务端 API(示例项目使用的是环信官方 Server SDK,即对 REST API 的封装),完成用户注册、好友关系同步、系统消息下发。

把三方角色和交互关系画出来,就是这个架构:
#mermaid-svg-cIxy1y7VFQBqGV1N{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-cIxy1y7VFQBqGV1N .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-cIxy1y7VFQBqGV1N .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-cIxy1y7VFQBqGV1N .error-icon{fill:#552222;}#mermaid-svg-cIxy1y7VFQBqGV1N .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-cIxy1y7VFQBqGV1N .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-cIxy1y7VFQBqGV1N .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-cIxy1y7VFQBqGV1N .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-cIxy1y7VFQBqGV1N .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-cIxy1y7VFQBqGV1N .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-cIxy1y7VFQBqGV1N .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-cIxy1y7VFQBqGV1N .marker{fill:#333333;stroke:#333333;}#mermaid-svg-cIxy1y7VFQBqGV1N .marker.cross{stroke:#333333;}#mermaid-svg-cIxy1y7VFQBqGV1N svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-cIxy1y7VFQBqGV1N p{margin:0;}#mermaid-svg-cIxy1y7VFQBqGV1N .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-cIxy1y7VFQBqGV1N .cluster-label text{fill:#333;}#mermaid-svg-cIxy1y7VFQBqGV1N .cluster-label span{color:#333;}#mermaid-svg-cIxy1y7VFQBqGV1N .cluster-label span p{background-color:transparent;}#mermaid-svg-cIxy1y7VFQBqGV1N .label text,#mermaid-svg-cIxy1y7VFQBqGV1N span{fill:#333;color:#333;}#mermaid-svg-cIxy1y7VFQBqGV1N .node rect,#mermaid-svg-cIxy1y7VFQBqGV1N .node circle,#mermaid-svg-cIxy1y7VFQBqGV1N .node ellipse,#mermaid-svg-cIxy1y7VFQBqGV1N .node polygon,#mermaid-svg-cIxy1y7VFQBqGV1N .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-cIxy1y7VFQBqGV1N .rough-node .label text,#mermaid-svg-cIxy1y7VFQBqGV1N .node .label text,#mermaid-svg-cIxy1y7VFQBqGV1N .image-shape .label,#mermaid-svg-cIxy1y7VFQBqGV1N .icon-shape .label{text-anchor:middle;}#mermaid-svg-cIxy1y7VFQBqGV1N .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-cIxy1y7VFQBqGV1N .rough-node .label,#mermaid-svg-cIxy1y7VFQBqGV1N .node .label,#mermaid-svg-cIxy1y7VFQBqGV1N .image-shape .label,#mermaid-svg-cIxy1y7VFQBqGV1N .icon-shape .label{text-align:center;}#mermaid-svg-cIxy1y7VFQBqGV1N .node.clickable{cursor:pointer;}#mermaid-svg-cIxy1y7VFQBqGV1N .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-cIxy1y7VFQBqGV1N .arrowheadPath{fill:#333333;}#mermaid-svg-cIxy1y7VFQBqGV1N .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-cIxy1y7VFQBqGV1N .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-cIxy1y7VFQBqGV1N .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-cIxy1y7VFQBqGV1N .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-cIxy1y7VFQBqGV1N .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-cIxy1y7VFQBqGV1N .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-cIxy1y7VFQBqGV1N .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-cIxy1y7VFQBqGV1N .cluster text{fill:#333;}#mermaid-svg-cIxy1y7VFQBqGV1N .cluster span{color:#333;}#mermaid-svg-cIxy1y7VFQBqGV1N div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-cIxy1y7VFQBqGV1N .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-cIxy1y7VFQBqGV1N rect.text{fill:none;stroke-width:0;}#mermaid-svg-cIxy1y7VFQBqGV1N .icon-shape,#mermaid-svg-cIxy1y7VFQBqGV1N .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-cIxy1y7VFQBqGV1N .icon-shape p,#mermaid-svg-cIxy1y7VFQBqGV1N .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-cIxy1y7VFQBqGV1N .icon-shape .label rect,#mermaid-svg-cIxy1y7VFQBqGV1N .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-cIxy1y7VFQBqGV1N .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-cIxy1y7VFQBqGV1N .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-cIxy1y7VFQBqGV1N :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 环信平台
交友服务端(mf-app-server)
交友 App(Android)
①业务数据读写
②用户间聊天消息

(不经过交友服务端)
③REST API:创建用户/加联系人/发系统消息
④系统消息
环信 Android SDK

长连接收发消息
业务请求

(HTTP + JWT)
业务 Service
HuanXinTemplate

(环信 Server SDK 封装)
MySQL:User 账号

MongoDB:好友/问答等业务数据
用户体系
好友/联系人关系
消息通道与消息存储

关键在这三类消息通道,走向完全不同:

通道 发起方 路径 是否经过交友服务端 典型场景
用户间聊天 交友 App App → 环信 → 对方 App 否,端到端直达 好友私聊
系统消息 交友服务端 服务端 → 环信 → App 是(发起段) 回复陌生人问题、通知送达
好友关系 交友服务端 服务端 → 环信(联系人 API)+ 服务端自己落库 是 添加好友时双写

第一类最容易理解错:聊天消息是两个客户端之间借环信通道直达的,交友服务端在这条链路上不扮演任何角色 。服务端集成做的事情全部发生在聊天开始之前,把用户和好友关系这些「数据底座」准备好。用一句话概括就是:服务端集成主要为了提供数据,真正的即时通信全部在客户端完成。

执行过程:从注册到聊天的完整链路

把整个工作流拆成四步,前三步都是为第四步做准备。

第一步:客户端用户注册。 用户注册项目时,交友服务端先把用户信息存进自己的库,然后给这个用户分配一个环信账号("hx" + userId,密码统一初始值),通过服务端 API 注册到环信,并把映射关系(hxUser、hxPassword 字段)回写到项目的用户表里。

第二步:注册好友关系。 用户 A 添加 B 为好友时,交友服务端做双写 :一边把好友关系记到自己的业务库,一边调用环信的联系人 API(contact().add),让环信也维护一份联系人关系。环信只有知道了好友关系,才允许两端互发消息。

第三步:客户端获取环信凭证。 App 从交友服务端查询当前用户在环信的账号和密码,注意这个账号密码是项目分配的(hx123 这种),不是用户的手机号密码。App 拿到凭证后直接登录环信服务器,这一步和交友服务端没有关系。

第四步:手机端在线聊天。 双方都登录环信后,就可以脱离交友服务端直接点对点聊天了。

以「给好友发一条消息」为例,完整时序如下:
交友 App(用户 B) 环信服务器 交友服务端 交友 App(用户 A) 交友 App(用户 B) 环信服务器 交友服务端 交友 App(用户 A) #mermaid-svg-ZgpnhBOq3baqRhCR{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-ZgpnhBOq3baqRhCR .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ZgpnhBOq3baqRhCR .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ZgpnhBOq3baqRhCR .error-icon{fill:#552222;}#mermaid-svg-ZgpnhBOq3baqRhCR .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ZgpnhBOq3baqRhCR .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ZgpnhBOq3baqRhCR .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ZgpnhBOq3baqRhCR .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ZgpnhBOq3baqRhCR .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ZgpnhBOq3baqRhCR .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ZgpnhBOq3baqRhCR .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ZgpnhBOq3baqRhCR .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ZgpnhBOq3baqRhCR .marker.cross{stroke:#333333;}#mermaid-svg-ZgpnhBOq3baqRhCR svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ZgpnhBOq3baqRhCR p{margin:0;}#mermaid-svg-ZgpnhBOq3baqRhCR .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-ZgpnhBOq3baqRhCR text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-ZgpnhBOq3baqRhCR .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-ZgpnhBOq3baqRhCR .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-ZgpnhBOq3baqRhCR .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-ZgpnhBOq3baqRhCR .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-ZgpnhBOq3baqRhCR #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-ZgpnhBOq3baqRhCR .sequenceNumber{fill:white;}#mermaid-svg-ZgpnhBOq3baqRhCR #sequencenumber{fill:#333;}#mermaid-svg-ZgpnhBOq3baqRhCR #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-ZgpnhBOq3baqRhCR .messageText{fill:#333;stroke:none;}#mermaid-svg-ZgpnhBOq3baqRhCR .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-ZgpnhBOq3baqRhCR .labelText,#mermaid-svg-ZgpnhBOq3baqRhCR .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-ZgpnhBOq3baqRhCR .loopText,#mermaid-svg-ZgpnhBOq3baqRhCR .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-ZgpnhBOq3baqRhCR .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-ZgpnhBOq3baqRhCR .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-ZgpnhBOq3baqRhCR .noteText,#mermaid-svg-ZgpnhBOq3baqRhCR .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-ZgpnhBOq3baqRhCR .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-ZgpnhBOq3baqRhCR .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-ZgpnhBOq3baqRhCR .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-ZgpnhBOq3baqRhCR .actorPopupMenu{position:absolute;}#mermaid-svg-ZgpnhBOq3baqRhCR .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-ZgpnhBOq3baqRhCR .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-ZgpnhBOq3baqRhCR .actor-man circle,#mermaid-svg-ZgpnhBOq3baqRhCR line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-ZgpnhBOq3baqRhCR :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 前置(历史步骤):A 注册时交友服务端已调 createUser("hx"+A) 注册环信账号 前置:A、B 加好友时交友服务端已调 addContact 同步联系人关系 交友服务端全程不感知这条聊天消息 聊天记录保存在环信,不在项目库 登录项目(手机号+验证码),获得 JWT token 查询环信账号密码(接口返回 huanXinId 等信息) 返回 hx123 / 123456(项目分配的环信凭证) 用环信凭证登录(长连接,与交友服务端无关) 同样流程登录环信 发送聊天消息(环信 SDK 长连接) 环信投递消息给对方 App

时序图里最值得标出来的就是最后那个 Note:这条聊天消息交友服务端完全不知道。消息内容、发送时间、聊天记录都存在环信侧。这是选第三方 IM 必须接受的架构代价,后文「数据边界」一节专门展开。

另外本文示例项目的链路里,交友服务端感知聊天内容的机制是没有的,没有配环信的回调(Webhook)把消息回传到项目服务器。所以项目的业务侧对聊天内容是「盲」的,只能通过环信 Console 后台人工查看,程序层面无法直接查询。

用户体系集成:项目 userId 与环信 ID 的映射

这是整套集成里最核心的认知。环信只实现即时通信能力,不保存用户信息,头像、昵称这些身份信息不需要传给环信。但环信要求:每个 App 用户必须有一个环信账号,有账号才能聊天。于是就有了双体系账号打通的问题。

映射规则

示例项目的做法(实读工程 Constants.java):

java 复制代码
package com.mf.commons.utils;

public class Constants {

    public static final String INIT_PASSWORD = "123456";

    public static final String HX_USER_PREFIX = "hx";
}

规则就一行:环信 ID = "hx" + 项目 userId ,比如项目用户 id 为 1,环信 ID 就是 hx1;密码统一初始值 123456。项目用户表里加两个字段 hxUser、hxPassword 存这份映射。

反过来也靠这条规则工作:App 端登录环信后收到消息,拿到的是对方的环信 ID(如 hx8),App 请求交友服务端「根据环信 ID 查用户详情」,服务端用这个 ID 反查用户表拿到项目用户,再查详情返回(工程实读 MessagesService#findUserInfoByHuanxin 就是这么写的)。加不加前缀看着无所谓,实际上是个隔离手段,避免环信 ID 空间和将来其他体系的 ID 空间裸撞。

双体系账号映射表

维度 示例项目体系 环信体系
账号标识 手机号 / 自增 userId hx + userId(如 hx123)
密码 用户设置的密码(MD5 存储) 示例项目统一分配的初始值 123456
登录凭证 JWT token(服务端签发,含 id + mobile) 环信账号 + 密码(App 直连环信登录)
存储位置 示例项目 MySQL 用户表(hxUser/hxPassword 字段记录映射) 环信平台用户体系(只有账号密码,无业务信息)
头像昵称等资料 示例项目自己存 不传给环信,App 展示时找交友服务端查

两个要点:

  • 注册时机 :新用户在注册逻辑里同步注册环信(工程实读 UserService#loginVerification,新用户落库后立刻调 huanXinTemplate.createUser);存量老用户靠单元测试批量补注册。
  • 双 token 体系:示例项目 JWT 只用于 App 访问交友服务端的业务接口;环信凭证只用于 App 直连环信的长连接登录。两套凭证互不通用,谁也不能拿 JWT 去登环信。

注册代码落点

新用户注册时同步注册环信(工程实读 mf-app-server 的 UserService):

java 复制代码
public Map loginVerification(String phone, String code) {
    //1、从 redis 中获取下发的验证码
    String redisCode = redisTemplate.opsForValue().get("CHECK_CODE_" + phone);
    //2、对验证码进行校验(验证码是否存在,是否和输入的验证码一致)
    if(StringUtils.isEmpty(redisCode) || !redisCode.equals(code)) {
        //验证码无效
        throw new BusinessException(ErrorResult.loginError());
    }
    //3、删除 redis 中的验证码
    redisTemplate.delete("CHECK_CODE_" + phone);
    //4、通过手机号码查询用户
    User user = userApi.findByMobile(phone);
    boolean isNew = false;
    //5、如果用户不存在,创建用户保存到数据库中
    if(user == null) {
        user = new User();
        user.setMobile(phone);
        user.setPassword(DigestUtils.md5Hex("123456"));
        Long userId = userApi.save(user);
        user.setId(userId);
        isNew = true;

        //注册环信用户:环信 ID = 前缀 hx + 项目 userId
        String hxUser = Constants.HX_USER_PREFIX + user.getId();
        Boolean create = huanXinTemplate.createUser(hxUser, Constants.INIT_PASSWORD);
        if(create) {
            //回写映射关系到项目用户表
            user.setHxUser(hxUser);
            user.setHxPassword(Constants.INIT_PASSWORD);
            userApi.update(user);
        }
    }

    //6、通过 JWT 生成 token(存入 id 和手机号码)
    Map tokenMap = new HashMap();
    tokenMap.put("id", user.getId());
    tokenMap.put("mobile", phone);
    String token = JwtUtils.getToken(tokenMap);
    //7、构造返回值
    Map retMap = new HashMap();
    retMap.put("token", token);
    retMap.put("isNew", isNew);

    return retMap;
}

存量老用户的批量补注册,写一个单元测试跑一遍即可(注意测试账号最多支持 100 个环信用户):

java 复制代码
//批量注册
@Test
public void register() {
    List<User> users = userApi.findAll();
    for (User user : users) {
        Boolean create = template.createUser(Constants.HX_USER_PREFIX + user.getId(), Constants.INIT_PASSWORD);
        if (create) {
            user.setHxUser(Constants.HX_USER_PREFIX + user.getId());
            user.setHxPassword(Constants.INIT_PASSWORD);
            userApi.update(user);
        }
    }
}

服务端集成代码落点

服务端和环信的所有交互都收敛到一个组件里。组件抽取在 mf-autoconfig 工程,套路和前文自动装配完全一样:Properties + Template + AutoConfiguration 三件套(套路细节见前文,此处只看环信这份的实现)。

工程结构:

tree 复制代码
mf-autoconfig
└── src/main/java/com/mf/autoconfig
    ├── mfAutoConfiguration.java      # 自动配置类:注册 HuanXinTemplate Bean
    ├── properties
    │   └── HuanXinProperties.java       # 绑定 mf.huanxin 前缀配置
    └── template
        └── HuanXinTemplate.java         # 环信操作模板:封装 Server SDK

配置(放在 mf-app-server 的 application.yml):

yml 复制代码
mf:
  huanxin:
    appkey: 1110201018107234#mf
    clientId: YXA6nxJJ_pdEQ_eYUlqcRicS4w
    clientSecret: YXA6GMUxVEZhAvxlMn4OvHSXbWuEUTE

三个值都来自环信 Console 创建应用后的页面。Properties 对象绑定这份配置:

java 复制代码
package com.mf.autoconfig.properties;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;

@Configuration
@ConfigurationProperties(prefix = "mf.huanxin")
@Data
public class HuanXinProperties {

    private String appkey;
    private String clientId;
    private String clientSecret;

}

HuanXinTemplate 是核心,构造时用配置构建 EMService,对外暴露四个方法,恰好对应服务端集成的四类操作,创建用户、加联系人、删联系人、发系统消息:

java 复制代码
package com.mf.autoconfig.template;

import cn.hutool.core.collection.CollUtil;
import com.easemob.im.server.EMProperties;
import com.easemob.im.server.EMService;
import com.easemob.im.server.model.EMTextMessage;
import com.mf.autoconfig.properties.HuanXinProperties;
import lombok.extern.slf4j.Slf4j;

import java.util.Set;

@Slf4j
public class HuanXinTemplate {

    private EMService service;

    public HuanXinTemplate(HuanXinProperties properties) {
        EMProperties emProperties = EMProperties.builder()
                .setAppkey(properties.getAppkey())
                .setClientId(properties.getClientId())
                .setClientSecret(properties.getClientSecret())
                .build();
        service = new EMService(emProperties);
    }

    //创建环信用户
    public Boolean createUser(String username, String password) {
        try {
            //环信要求用户名小写,这里统一转一次
            service.user().create(username.toLowerCase(), password)
                    .block();
            return true;
        } catch (Exception e) {
            log.error("创建环信用户失败~");
        }
        return false;
    }

    //添加联系人(同步好友关系到环信)
    public Boolean addContact(String username1, String username2) {
        try {
            service.contact().add(username1, username2)
                    .block();
            return true;
        } catch (Exception e) {
            log.error("添加联系人失败~");
        }
        return false;
    }

    //删除联系人
    public Boolean deleteContact(String username1, String username2) {
        try {
            service.contact().remove(username1, username2)
                    .block();
            return true;
        } catch (Exception e) {
            log.error("删除联系人失败~");
        }
        return false;
    }

    //发送消息(from 为 admin,即管理员/系统身份发送)
    public Boolean sendMsg(String username, String content) {
        try {
            //接收人环信 ID 集合
            Set<String> set = CollUtil.newHashSet(username);
            //文本消息
            EMTextMessage message = new EMTextMessage().text(content);
            service.message().send("admin", "users", set, message, null)
                    .block();
            return true;
        } catch (Exception e) {
            log.error("发送消息失败~");
        }
        return false;
    }
}

几个实现细节:

  • 环信 Server SDK 的 API 是响应式的,返回 Mono,这里统一 .block() 同步等待,业务代码拿到的就是简单的 Boolean。失败时吞掉异常返回 false,调用方据此决定要不要抛业务异常。
  • createUser 里对用户名做了 toLowerCase(),环信用户名要求小写,hx123 这种本来就全小写,但保险起见统一转。
  • sendMsg 的发送方写死 admin,即系统管理员身份。这正好对应「系统消息」通道:交友服务端以 admin 名义把消息推给指定环信用户。
  • 自动装配注册(mfAutoConfiguration):
java 复制代码
package com.mf.autoconfig;

import com.mf.autoconfig.properties.*;
import com.mf.autoconfig.template.*;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;

@EnableConfigurationProperties({
        //其他省略
        HuanXinProperties.class
})
public class mfAutoConfiguration {
    //其他省略
    @Bean
    public HuanXinTemplate huanXinTemplate(HuanXinProperties properties) {
        return new HuanXinTemplate(properties);
    }
}

写完自测一个注册用例(HuanXinTest),能创建成功说明 appkey 三元组配置没问题:

java 复制代码
@RunWith(SpringRunner.class)
@SpringBootTest
public class HuanXinTest {

    @Autowired
    private HuanXinTemplate template;

    @Test
    public void testRegister() {
        template.createUser("user01", "123456");
    }
}

四方法对应的三类通道

把 HuanXinTemplate 的四个方法放回前文架构图里,对应关系一目了然:

方法 对应通道 项目业务调用点(实读)
createUser 用户体系导入 UserService#loginVerification 新用户注册
addContact / deleteContact 好友关系同步 MessagesService#contacts 添加好友(双写:先同步环信,再落 MongoDB)
sendMsg 系统消息下发 mfService#replyQuestions 回复陌生人问题

replyQuestions 是系统消息通道的典型用例,回答陌生人的提问,双方不是好友,不能走端到端聊天,只能由服务端代发。消息体是一段 JSON,把 App 渲染需要的资料全部带上:

java 复制代码
//回复陌生人问题
public void replyQuestions(Long userId, String reply) {
    //1、构造消息数据
    Long currentUserId = UserHolder.getUserId();
    UserInfo userInfo = userInfoApi.findById(currentUserId);
    Map map = new HashMap();
    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);
    String message = JSON.toJSONString(map);
    //2、调用 template 对象,以 admin 身份发给对方的环信 ID
    Boolean aBoolean = template.sendMsg(Constants.HX_USER_PREFIX + userId, message);
    if(!aBoolean) {
        throw new BusinessException(ErrorResult.error());
    }
}

服务端 API 的鉴权:先拿 token

前面讲的是「服务端该做什么」,接下来进入「服务端具体怎么调」。环信服务端 API 全部走 HTTPS + JSON,鉴权用 Bearer Token。这个 token 不是用户级别的,而是应用级别 的:拿着创建应用时环信分配的 clientId / clientSecret 去换一个 app token,之后所有服务端请求都带上它。

tree 复制代码
注册应用后,环信分配给你的三样东西:
├── appkey        组织#应用,如 easemob-demo#chatdemo
├── client_id     换 token 的账号
└── client_secret 换 token 的密钥

用 curl 换 token 的写法(对应环信官方 API 格式):

bash 复制代码
curl -X POST "https://a1.easemob.com/{org_name}/{app_name}/token" \
  -H "Content-Type: application/json" \
  -d '{
    "grant_type": "client_credentials",
    "client_id": "YXA6xxxxxxxxxxxx",
    "client_secret": "YXA6yyyyyyyyyyyy",
    "ttl": 1024000
  }'

返回:

json 复制代码
{
  "access_token": "YWMtxxxxxxxxxxxxxxxxxxxx",
  "expires_in": 1024000
}

两个要点:

  • token 不用每次都换 。ttl 不传时默认也有有效期(官方默认数天量级),拿到后缓存在内存或 Redis 里,过期前刷新即可。每次请求前都去换一遍 token 虽然也能跑,纯属浪费一次 RTT。
  • 环信 app token 和交友服务端自己的 JWT 是两套完全独立的鉴权体系。项目的 JWT 解决「App 用户 → 交友服务端」的身份问题;环信 app token 解决「交友服务端 → 环信开放 REST」的调用授权问题。用户登录项目的 token 拿去调环信 API 是不通的,反之亦然。
tree 复制代码
两套鉴权体系(各管一段):
App 用户 ──JWT──> 交友服务端 ──app token(Bearer)──> 环信 REST API

REST API 能力矩阵

环信服务端 REST API 覆盖面很宽,但示例项目只用了其中一小块。先把全景列出来,标注示例项目是否用到,避免你以为每一类都要接:

API 分类 典型能力 项目是否用到 对应方法 / 说明
用户管理 注册用户、查详情、改密码、删除用户 用到注册 service.user().create(...);注册示例项目账号时同步建环信账号
好友关系 添加联系人、删除联系人、查好友列表 用到 service.contact().add/remove(...);与 MySQL friend 表双写
消息发送 发文本/图片/透传消息,指定 from 与目标 用到 service.message().send(...);系统通知、陌生人回答送达
群组 / 聊天室 建群、拉人、踢人、群消息 示例项目未使用 示例项目没有群聊场景,你按需查阅官方文档
历史消息 / 离线 拉取历史消息、离线消息数 示例项目未直接使用 消息历史由环信云端保存,客户端 SDK 可自行拉取

对示例项目来说,注册用户、维护好友关系、服务端发消息这三类就足够撑起整个 IM 功能了。

项目的封装:HuanXinTemplate

示例项目没有在各业务模块里散落调用环信 SDK,而是抽了一个模板类统一收口。先看它依赖的配置对象:

java 复制代码
package com.mf.autoconfig.properties;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;

@Configuration
@ConfigurationProperties(prefix = "mf.huanxin")
@Data
public class HuanXinProperties {

    private String appkey;
    private String clientId;
    private String clientSecret;

}

yml 里对应的三项配置,直接从环信 Console 创建的应用页复制:

yaml 复制代码
mf:
  huanxin:
    appkey: easemob-demo#mf
    client-id: YXA6xxxxxxxx
    client-secret: YXA6yyyyyyyy

模板类完整代码(mf-autoconfig 模块):

java 复制代码
package com.mf.autoconfig.template;

import cn.hutool.core.collection.CollUtil;
import com.easemob.im.server.EMProperties;
import com.easemob.im.server.EMService;
import com.easemob.im.server.model.EMTextMessage;
import com.mf.autoconfig.properties.HuanXinProperties;
import lombok.extern.slf4j.Slf4j;

import java.util.Set;

@Slf4j
public class HuanXinTemplate {

    private EMService service;

    public HuanXinTemplate(HuanXinProperties properties) {
        EMProperties emProperties = EMProperties.builder()
                .setAppkey(properties.getAppkey())
                .setClientId(properties.getClientId())
                .setClientSecret(properties.getClientSecret())
                .build();
        service = new EMService(emProperties);
    }

    //创建环信用户
    public Boolean createUser(String username, String password) {
        try {
            //创建环信用户
            service.user().create(username.toLowerCase(), password)
                    .block();
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            log.error("创建环信用户失败~");
        }
        return false;
    }

    //添加联系人
    public Boolean addContact(String username1, String username2) {
        try {
            //添加双向好友关系
            service.contact().add(username1, username2)
                    .block();
            return true;
        } catch (Exception e) {
            log.error("添加联系人失败~");
        }
        return false;
    }

    //删除联系人
    public Boolean deleteContact(String username1, String username2) {
        try {
            //删除双向好友关系
            service.contact().remove(username1, username2)
                    .block();
            return true;
        } catch (Exception e) {
            log.error("删除联系人失败~");
        }
        return false;
    }

    //发送消息
    public Boolean sendMsg(String username, String content) {
        try {
            //接收人用户列表
            Set<String> set = CollUtil.newHashSet(username);
            //文本消息
            EMTextMessage message = new EMTextMessage().text(content);
            //发送消息  from:admin是管理员发送
            service.message().send("admin", "users",
                    set, message, null).block();
            return true;
        } catch (Exception e) {
            log.error("发送消息失败~");
        }
        return false;
    }
}

构造方法里用 EMProperties.builder() 把三项配置装进 EMService,这一步就是 SDK 内部的鉴权初始化,token 的获取与刷新被 SDK 包掉了,外部看不到。接下来对四个方法逐个拆解。

createUser:为什么 toLowerCase

java 复制代码
service.user().create(username.toLowerCase(), password).block();

环信的 username 是有字符规范的:只允许字母、数字,且对大小写敏感 。项目的用户名来源可能是手机号、随机构造的 ID,中间可能混着大写字母。这里统一转小写,保证「注册时是什么、之后查询/加好友/发消息时用的就是什么」,如果注册用 User01、后面拿 user01 去查,环信会按两个不同账号处理,直接查不到。

工程里还会给环信账号加统一前缀 hx(Constants.HX_USER_PREFIX = "hx"),把项目用户 ID 映射成环信 ID 时再拼上,避免与环信保留账号(如 admin)撞名。

addContact / deleteContact:好友关系双写

java 复制代码
service.contact().add(username1, username2).block();

环信侧的联系人关系是双向 的:add("user01","user02") 执行完,在管理后台看 user01 的好友是 user02,user02 的好友也是 user01,一条 API 维护了两条关系。

为什么示例项目加好友要调用环信联系人 API?因为示例项目自己的好友关系存在 MySQL 的 friend 表里,MySQL 知道双方是好友,环信不知道,App 端就聊不起来,环信客户端 SDK 收发消息前会校验联系人关系(或至少依赖通讯录展示),环信侧没有这条联系人记录,两个人在 IM 通道里就是陌生人。所以加好友成功后要双写:MySQL friend 表 + 环信联系人,两边同时落,删好友同理两边同时删。
#mermaid-svg-pwlBjAWKnrB1SH55{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-pwlBjAWKnrB1SH55 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-pwlBjAWKnrB1SH55 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-pwlBjAWKnrB1SH55 .error-icon{fill:#552222;}#mermaid-svg-pwlBjAWKnrB1SH55 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-pwlBjAWKnrB1SH55 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-pwlBjAWKnrB1SH55 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-pwlBjAWKnrB1SH55 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-pwlBjAWKnrB1SH55 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-pwlBjAWKnrB1SH55 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-pwlBjAWKnrB1SH55 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-pwlBjAWKnrB1SH55 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-pwlBjAWKnrB1SH55 .marker.cross{stroke:#333333;}#mermaid-svg-pwlBjAWKnrB1SH55 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-pwlBjAWKnrB1SH55 p{margin:0;}#mermaid-svg-pwlBjAWKnrB1SH55 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-pwlBjAWKnrB1SH55 .cluster-label text{fill:#333;}#mermaid-svg-pwlBjAWKnrB1SH55 .cluster-label span{color:#333;}#mermaid-svg-pwlBjAWKnrB1SH55 .cluster-label span p{background-color:transparent;}#mermaid-svg-pwlBjAWKnrB1SH55 .label text,#mermaid-svg-pwlBjAWKnrB1SH55 span{fill:#333;color:#333;}#mermaid-svg-pwlBjAWKnrB1SH55 .node rect,#mermaid-svg-pwlBjAWKnrB1SH55 .node circle,#mermaid-svg-pwlBjAWKnrB1SH55 .node ellipse,#mermaid-svg-pwlBjAWKnrB1SH55 .node polygon,#mermaid-svg-pwlBjAWKnrB1SH55 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-pwlBjAWKnrB1SH55 .rough-node .label text,#mermaid-svg-pwlBjAWKnrB1SH55 .node .label text,#mermaid-svg-pwlBjAWKnrB1SH55 .image-shape .label,#mermaid-svg-pwlBjAWKnrB1SH55 .icon-shape .label{text-anchor:middle;}#mermaid-svg-pwlBjAWKnrB1SH55 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-pwlBjAWKnrB1SH55 .rough-node .label,#mermaid-svg-pwlBjAWKnrB1SH55 .node .label,#mermaid-svg-pwlBjAWKnrB1SH55 .image-shape .label,#mermaid-svg-pwlBjAWKnrB1SH55 .icon-shape .label{text-align:center;}#mermaid-svg-pwlBjAWKnrB1SH55 .node.clickable{cursor:pointer;}#mermaid-svg-pwlBjAWKnrB1SH55 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-pwlBjAWKnrB1SH55 .arrowheadPath{fill:#333333;}#mermaid-svg-pwlBjAWKnrB1SH55 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-pwlBjAWKnrB1SH55 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-pwlBjAWKnrB1SH55 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-pwlBjAWKnrB1SH55 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-pwlBjAWKnrB1SH55 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-pwlBjAWKnrB1SH55 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-pwlBjAWKnrB1SH55 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-pwlBjAWKnrB1SH55 .cluster text{fill:#333;}#mermaid-svg-pwlBjAWKnrB1SH55 .cluster span{color:#333;}#mermaid-svg-pwlBjAWKnrB1SH55 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-pwlBjAWKnrB1SH55 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-pwlBjAWKnrB1SH55 rect.text{fill:none;stroke-width:0;}#mermaid-svg-pwlBjAWKnrB1SH55 .icon-shape,#mermaid-svg-pwlBjAWKnrB1SH55 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-pwlBjAWKnrB1SH55 .icon-shape p,#mermaid-svg-pwlBjAWKnrB1SH55 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-pwlBjAWKnrB1SH55 .icon-shape .label rect,#mermaid-svg-pwlBjAWKnrB1SH55 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-pwlBjAWKnrB1SH55 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-pwlBjAWKnrB1SH55 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-pwlBjAWKnrB1SH55 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 项目加好友成功
MySQL friend 表
环信 contact 联系人
业务层查询好友列表
App 端 IM 收发消息

sendMsg:from=admin 的含义

java 复制代码
service.message().send("admin", "users", set, message, null).block();

五个参数依次是:发送方、目标类型(users 表示单聊用户列表,支持多人)、接收人集合、消息体、扩展字段(null 表示不用环信的自定义扩展)。

关键在第一个参数 admin,这是环信每个应用内置的管理员账号 。交友服务端发的不是「某个用户发给另一个用户」的聊天消息,而是系统通知类消息(比如「你的问题有人回答了」)。这种消息用一个虚拟的第三方身份发出最合理:from=admin 表示管理员账号下发,接收方在 App 里看到的是系统消息。如果 from 随便填一个普通用户名,要么权限不足被拒,要么在接收方那里伪装成了真人消息,语义就乱了。

SDK 与裸 REST 的关系

项目用的 im-sdk-core(版本 0.2.5,com.easemob.im)是环信官方的 Java Server SDK,本质是 REST API 的封装:

  • token 获取与刷新:SDK 拿着 clientId/clientSecret 自动换 token、过期自动重取,业务代码零感知;
  • HTTP 细节:URL 拼接、JSON 序列化、错误码解析全部包掉;
  • 响应式 API :SDK 基于 Reactor 实现,所有方法返回 Mono/Flux,所以要 .block() 才真正发出请求并等待结果。

为什么模板类里敢用 .block()?因为项目业务代码是普通的同步调用栈,加好友的 Service 方法要拿到「成功/失败」才能决定后续逻辑,不阻塞等结果就没法往下走。.block() 就是把响应式的异步调用转回同步语义。

.block() 的位置要留意:它只能出现在普通线程里。如果将来把这段代码挪进 Reactor 的调度线程(如 reactor-http-nio)或 WebFlux 请求线程,block() 会抛 IllegalStateException: block()/blockFirst()/blockLast() are blocking, which is not supported in thread...,甚至造成死锁。项目是 Spring MVC 同步栈,没这个问题。

如果不用 SDK,直接裸调 REST,同一个「创建用户」长这样:

bash 复制代码
# 第一步:换 token(前文已给完整命令,此处假设已拿到 $TOKEN)

# 第二步:创建用户,POST /{org_name}/{app_name}/users
curl -X POST "https://a1.easemob.com/{org_name}/{app_name}/users" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "username": "user01",
    "password": "123456"
  }'

对照看,service.user().create("user01", "123456").block() 帮你省掉的就是换 token、拼 URL、带 header、处理响应这一整套。SDK 的价值就在这。

用第三方的数据边界

架构定型后有个绕不开的事实:聊天记录存在环信,不在示例项目自己库里。这个取舍两边都要看清楚。

好处:

  • 免维护。消息的高可用存储、离线消息、多端同步、亿级并发投递,这些都是 IM 里最难啃的部分,环信全包了,示例项目一行代码不用写;
  • 上线快。中小项目选第三方方案的核心动机就是成本低、开发周期短。

代价:

  • 无法做深度业务分析。聊天内容在示例项目程序层面拿不到,想做「聊天热词分析」「基于对话的兴趣推荐」这类功能就没了数据基础。对比一下:示例项目的动态内容审核是自己做的,因为动态数据在示例项目自己库里;而聊天内容在环信手里,同样的审核逻辑复制不过去;
  • 服务端不感知聊天事件。没有配置环信回调的情况下,谁给谁发了消息、什么时候发的,示例项目的交友服务端一无所知;
  • 数据迁移受制于人。哪天想换 IM 供应商,历史聊天记录的导出和格式转换都要看对方支持程度。

选型时这个账要提前算:如果业务对聊天数据有强诉求(社交产品靠聊天内容做增长分析的很多),要么选自主研发,要么在环信侧配置消息回调把数据落地一份到自己库里,后者能缓解但不能完全消除边界问题。

常见误解澄清

误解 实际情况
聊天消息会经过项目服务器,可以拦截分析 用户间消息走环信通道端到端直达,交友服务端不在链路上,完全不感知
环信会自动同步项目的用户 不会。环信不保存用户信息,每个用户必须由服务端显式调 API 注册环信账号
项目 JWT 和环信登录用同一套凭证 两套体系。JWT 访问项目业务接口,环信凭证(hx+id / 123456)用于 App 直连环信
聊天记录存在项目库,可以随意查询 记录在环信侧,项目程序无法查询,只能通过环信 Console 人工查看
头像昵称也要注册给环信 不需要。环信只要账号密码,展示资料时 App 找交友服务端查(按环信 ID 反查用户)

常见坑

坑 现象 规避
username 大小写不一致 注册时 User01,查询/加好友时传 user01,环信返回用户不存在 统一 toLowerCase() 后再操作,示例项目在模板类里统一转
token 过期未刷新 请求全部 401 auth failed 缓存 token 并在过期前刷新;用 SDK 则自动处理
from 用错 发系统消息时 from 填了普通用户,报权限不足 服务端发的系统消息统一 from=admin
好友关系只写一边 MongoDB friend 集合有记录、环信没有联系人,App 端聊天失败 加/删好友双写:MongoDB friend 集合 + 环信 contact,包在同一个模板方法里
.block() 用错位置 在 Reactor/WebFlux 线程里 block 抛异常或死锁 只在普通同步线程里 block;响应式栈里改用订阅链

API 速览

方法 作用 底层 SDK 调用
HuanXinTemplate.createUser(username, password) 创建环信用户 service.user().create(...).block()
HuanXinTemplate.addContact(u1, u2) 添加联系人(好友关系) service.contact().add(...).block()
HuanXinTemplate.deleteContact(u1, u2) 删除联系人 service.contact().remove(...).block()
HuanXinTemplate.sendMsg(username, content) 以 admin 身份发系统消息 service.message().send(...).block()
配置绑定 mf.huanxin 前缀:appkey / clientId / clientSecret @ConfigurationProperties

验证流程

按以下步骤可以完整复现一遍,确认集成是通的:

  1. 换 token :用前文「服务端 API 的鉴权」中的 curl 调 POST /{org_name}/{app_name}/token,拿到 access_token;
  2. 建用户 :跑 service.user().create("user01", "123456").block()(或直接 curl POST /users);
  3. Console 查看用户 :登录环信即时通讯云 Console → 进入你的应用 → 左侧「运营服务」→「用户管理」,能看到 user01 已经出现在用户列表里;
  4. 建好友 :同样方式注册 user02,执行 service.contact().add("user01","user02").block(),回到 Console 打开 user01 的「更多」→「查看 IM 用户好友」,能看到 user02(双向关系,user02 那边同理);
  5. 发消息 :执行 service.message().send("admin","users",CollUtil.newHashSet("user02"), new EMTextMessage().text("hello"), null).block(),然后在 Console 的「数据统计」→「单聊消息」里观察计数从 0 变 1;App 端登录 user02 的客户端 SDK,能直接收到这条消息。

五步全绿,说明 token、用户、好友、消息这条链路都通了。

官方文档

总结

  • 项目接入环信是「两端集成」:App 端引 Android SDK 走长连接,服务端用 Server SDK 调 REST API,缺一不可。
  • 服务端集成只做数据准备(用户注册、好友关系同步、系统消息下发),真正的聊天在两个客户端之间经环信直达,不经过交友服务端。
  • 用户体系打通靠一条映射规则:环信 ID = "hx" + 项目 userId,映射关系(hxUser/hxPassword)回写项目用户表;新用户注册时同步注册,老用户批量补注册。
  • 凭证是双体系:项目 JWT 与环信账号密码各自独立,App 登录环信这一步与交友服务端无关。
  • 组件封装复用自动装配套路:HuanXinProperties 绑定 mf.huanxin 配置,HuanXinTemplate 收敛全部环信交互,mfAutoConfiguration 注册 Bean。
  • 环信服务端 API 的鉴权是应用级的:clientId/clientSecret 换 app token,Bearer 方式携带,token 可缓存不必每次重取;它和项目自己的 JWT 用户鉴权是两套体系。
  • 项目实际只用了三类 API:用户注册、好友(联系人)增删、服务端发消息;群组和历史消息本项目未使用。
  • HuanXinTemplate 把这些收口成四个方法:createUser 统一小写规范用户名,addContact/deleteContact 与 MySQL 双写好友关系保证 App 能聊,sendMsg 以 admin 身份发系统消息。
  • SDK(im-sdk-core 0.2.5)包掉了 token 与 HTTP 细节,响应式接口在同步业务栈里用 .block() 转同步,注意别放进响应式线程。
  • 数据边界要心里有数:聊天记录在环信,换来免维护的 IM 能力,代价是项目程序拿不到聊天数据,深度业务分析做不了。

后续文章按工程推进顺序是环信通信:抽取组件,把 HuanXinTemplate 和配置对象抽取为可复用的自动配置组件;随后用户体系集成:将信用户注册到环信会真正把项目注册用户落到环信侧。

相关推荐
god_cvz1 小时前
第三章:12、经验沉淀与自优化
java·ai
小冯不疯1 小时前
查询旺店通货品策略实战教程:从源系统拉取到轻易云的端到端落地
java·大数据·数据库
SunnyDays10111 小时前
Java 实现 Excel 与 Markdown 相互转换:表格导出、格式设置与多表提取
java·excel转markdown·markdown转excel
zjx23881 小时前
java很老的web工程重构,加入详细异常链信息分析
java·重构
山东孙燕姿1 小时前
设备台账重构:从一张表到三层模型
android·java·网络·低代码·重构·rxjava
萧瑟余晖2 小时前
Spring缓存抽象详解
java·spring·缓存
贾伟康2 小时前
【共创稿事节】一个 UserDataManager 管八类数据:Preferences 持久化的克制用法
java·服务器·前端·harmonyos·arkts·鸿蒙
卓豪终端管理2 小时前
【无标题】
java·服务器·前端
智_永无止境2 小时前
又找到一个纯Java的OCR项目,识别的格式还挺多
java·开发语言·ocr