引言
项目要具备 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为什么toLowerCaseaddContact/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 |
验证流程
按以下步骤可以完整复现一遍,确认集成是通的:
- 换 token :用前文「服务端 API 的鉴权」中的 curl 调
POST /{org_name}/{app_name}/token,拿到access_token; - 建用户 :跑
service.user().create("user01", "123456").block()(或直接 curlPOST /users); - Console 查看用户 :登录环信即时通讯云 Console → 进入你的应用 → 左侧「运营服务」→「用户管理」,能看到
user01已经出现在用户列表里; - 建好友 :同样方式注册
user02,执行service.contact().add("user01","user02").block(),回到 Console 打开 user01 的「更多」→「查看 IM 用户好友」,能看到 user02(双向关系,user02 那边同理); - 发消息 :执行
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 和配置对象抽取为可复用的自动配置组件;随后用户体系集成:将信用户注册到环信会真正把项目注册用户落到环信侧。