概述
本篇是即时通信模块的收官总结。整个模块做的事用一句话说清:接入环信云 IM 并完成用户体系集成,打通「注册 → 凭证 → 好友 → 聊天 → 联系人」的完整链路。前面各篇已经把每个环节的代码逐个讲透,这篇不重复细节,只做四件事:把整个模块串成一张知识地图,把可复用的套路和踩过的坑沉淀成清单,给出上线前的自检表和技术练习。
纲要
- 整体一句话与知识地图(Mermaid 全链路图)
- 工程终态(目录树 + 各篇分工对照)
- 可复用套路清单(核心部分,五条套路:套路 / 本篇怎么用 / 后续哪里用)
- 关键坑回顾(12 条浓缩表)
- 常见误解澄清
- 上线前自检清单(可勾选)
- 技术练习(每项:改哪里 / 怎么验证)
- 下一章预告:小视频方案## 知识地图
先看整章的脉络。从「要不要自己做 IM」这个选型问题出发,到联系人列表查询结束,五个环节环环相扣:
#mermaid-svg-6tiOxItWfnkbjgyF{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-6tiOxItWfnkbjgyF .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-6tiOxItWfnkbjgyF .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-6tiOxItWfnkbjgyF .error-icon{fill:#552222;}#mermaid-svg-6tiOxItWfnkbjgyF .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-6tiOxItWfnkbjgyF .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-6tiOxItWfnkbjgyF .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-6tiOxItWfnkbjgyF .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-6tiOxItWfnkbjgyF .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-6tiOxItWfnkbjgyF .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-6tiOxItWfnkbjgyF .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-6tiOxItWfnkbjgyF .marker{fill:#333333;stroke:#333333;}#mermaid-svg-6tiOxItWfnkbjgyF .marker.cross{stroke:#333333;}#mermaid-svg-6tiOxItWfnkbjgyF svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-6tiOxItWfnkbjgyF p{margin:0;}#mermaid-svg-6tiOxItWfnkbjgyF .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-6tiOxItWfnkbjgyF .cluster-label text{fill:#333;}#mermaid-svg-6tiOxItWfnkbjgyF .cluster-label span{color:#333;}#mermaid-svg-6tiOxItWfnkbjgyF .cluster-label span p{background-color:transparent;}#mermaid-svg-6tiOxItWfnkbjgyF .label text,#mermaid-svg-6tiOxItWfnkbjgyF span{fill:#333;color:#333;}#mermaid-svg-6tiOxItWfnkbjgyF .node rect,#mermaid-svg-6tiOxItWfnkbjgyF .node circle,#mermaid-svg-6tiOxItWfnkbjgyF .node ellipse,#mermaid-svg-6tiOxItWfnkbjgyF .node polygon,#mermaid-svg-6tiOxItWfnkbjgyF .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-6tiOxItWfnkbjgyF .rough-node .label text,#mermaid-svg-6tiOxItWfnkbjgyF .node .label text,#mermaid-svg-6tiOxItWfnkbjgyF .image-shape .label,#mermaid-svg-6tiOxItWfnkbjgyF .icon-shape .label{text-anchor:middle;}#mermaid-svg-6tiOxItWfnkbjgyF .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-6tiOxItWfnkbjgyF .rough-node .label,#mermaid-svg-6tiOxItWfnkbjgyF .node .label,#mermaid-svg-6tiOxItWfnkbjgyF .image-shape .label,#mermaid-svg-6tiOxItWfnkbjgyF .icon-shape .label{text-align:center;}#mermaid-svg-6tiOxItWfnkbjgyF .node.clickable{cursor:pointer;}#mermaid-svg-6tiOxItWfnkbjgyF .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-6tiOxItWfnkbjgyF .arrowheadPath{fill:#333333;}#mermaid-svg-6tiOxItWfnkbjgyF .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-6tiOxItWfnkbjgyF .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-6tiOxItWfnkbjgyF .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-6tiOxItWfnkbjgyF .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-6tiOxItWfnkbjgyF .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-6tiOxItWfnkbjgyF .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-6tiOxItWfnkbjgyF .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-6tiOxItWfnkbjgyF .cluster text{fill:#333;}#mermaid-svg-6tiOxItWfnkbjgyF .cluster span{color:#333;}#mermaid-svg-6tiOxItWfnkbjgyF 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-6tiOxItWfnkbjgyF .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-6tiOxItWfnkbjgyF rect.text{fill:none;stroke-width:0;}#mermaid-svg-6tiOxItWfnkbjgyF .icon-shape,#mermaid-svg-6tiOxItWfnkbjgyF .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-6tiOxItWfnkbjgyF .icon-shape p,#mermaid-svg-6tiOxItWfnkbjgyF .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-6tiOxItWfnkbjgyF .icon-shape .label rect,#mermaid-svg-6tiOxItWfnkbjgyF .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-6tiOxItWfnkbjgyF .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-6tiOxItWfnkbjgyF .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-6tiOxItWfnkbjgyF :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} IM 选型
自研 vs 第三方
环信接入
四件套第五次应用
用户体系集成闭环
注册 / 凭证下发 / 反查
好友双写
环信联系人 + Friend 集合
联系人管理
分页查询 / keyword 筛选
聊天直达
消息走环信通道
选型的结论值得记一下:项目定位中型项目,自研 IM(Netty + WebSocket + MQ + MongoDB 那一套)人力物力成本太高,所以直接接第三方环信,成本低、开发周期短,代价是可控性差、聊天数据不在自己手里。这个取舍在选型时就要想清楚,不是写完代码才想起来。
各环节与本章篇目的对应关系,方便回查:
| 环节 | 代码落点 | 对应篇目 |
|---|---|---|
| IM 选型与环信介绍 | 无代码 | 即时通信:介绍 / 环信通信:介绍 |
| 环信接入四件套 | mf-autoconfig 的 HuanXin 三件 |
环信通信:API 介绍 / 抽取组件 |
| 注册环信(新用户 + 存量) | UserService#loginVerification + HuanxinTest#register |
将新用户注册到环信 |
| 凭证下发 | HuanXinController + HuanXinService |
查询用户环信账号密码 |
| 消息链路验证 | 环信 Console 发消息 + App 接收 | 环信后台发送消息测试 |
| 反向反查 | MessagesController#userinfo |
根据环信 ID 反查用户 |
| 好友申请 / 添加好友 | mfService#replyQuestions + MessagesService#contacts |
好友申请测试 / 添加好友系列 |
| 联系人列表 | MessagesService#findFriends |
联系人列表系列 |
本章工程终态
整章结束,工程里新增(或改动)的关键文件如下:
tree
mf
├── mf-autoconfig
│ └── src/main/java/com/mf/autoconfig
│ ├── properties/HuanXinProperties.java # appkey/clientId/clientSecret
│ └── template/HuanXinTemplate.java # createUser/addContact/deleteContact/sendMsg
├── mf-model
│ └── src/main/java/com/mf/model
│ ├── domain/User.java # 新增 hxUser/hxPassword 字段
│ ├── mongo/Friend.java # 好友关系集合 userId/friendId/created
│ └── vo/HuanXinUserVo.java # 凭证下发 VO
├── mf-dubbo
│ └── mf-dubbo-mongo/.../api/FriendApiImpl.java # 双写判重 + 分页查好友
├── mf-app-server
│ ├── src/main/java/com/mf/server
│ │ ├── controller/HuanXinController.java # GET /huanxin/user
│ │ ├── controller/MessagesController.java # /messages/userinfo /messages/contacts
│ │ └── service/MessagesService.java # 反查 / 加好友 / 联系人列表
│ └── src/test/java/com/wb/test/HuanxinTest.java # 批量注册存量用户
└── mf-app-server/src/main/resources/application.yml # mf.huanxin 三项配置
两个常量是全章的锚点,正向注册和反向反查都靠它们对齐:
java
// mf-commons/.../Constants.java
public static final String INIT_PASSWORD = "123456";
public static final String HX_USER_PREFIX = "hx";
映射规则就一行:环信 ID = HX_USER_PREFIX + userId,即 hx106。忘了这条规则,后面所有的「对不上号」问题都会找上门。
可复用套路清单
这一节是全章真正能带走的东西。每条套路标注了在本章怎么用、后续哪些场景还会用到:
| 套路 | 本章怎么用 | 后续哪里用 |
|---|---|---|
| 双体系账号映射 | 前缀映射:hx + userId,User 表加 hxUser/hxPassword 落库 |
任何接第三方账号体系的项目(支付商户号、云厂商账号、OSS 存储桶映射) |
| 凭证下发三铁律 | GET /huanxin/user 返回当前用户环信凭证 |
任何「客户端需要密钥才能调第三方」的场景 |
| 好友关系双写 | 先环信 addContact 再落 friend 集合,双向两条记录 |
关注/粉丝、点赞关系、任何「第三方状态 + 本地状态」并存的关系数据 |
| 第三方能力四件套 | Properties + Template + 自动配置 + yml,环信是第五次应用 | 后续 FastDFS、阿里云视频点播等一切第三方集成 |
| 分层验证 | Console 手发消息 → REST 工具测接口 → App 端到端 | 所有第三方集成联调,出问题时快速定位责任层 |
其中三条值得展开一句:
双体系账号映射 ,前缀映射(hx106)胜在零成本、规则透明,代价是映射规则写死在代码里;如果第三方对用户名有更苛刻的字符限制(长度、特殊字符),或者要做账号解绑换绑,就该换成显式映射表(一张 user_id ↔ third_id 的表)。选型判断标准是映射规则够不够「纯数字拼前缀」这么简单。
凭证下发三铁律 :userId 只从 token 解(UserHolder.getUserId(),绝不信前端传参);只返当前用户自己的凭证(接口里没有 id 参数,天然隔离);生产环境必须换掉 INIT_PASSWORD = "123456" 这种全员弱凭证,改为环信的用户级 token 或按用户随机密码。教学项目图省事用统一密码,这个债要在练习里还。
好友双写 :顺序是「先环信后本地」,环信失败直接抛异常回滚语义(实际上没回滚,只是不落库),环信成功才写 friend 集合。双向两条记录(userId/friendId 各存一份)是为了查「我的好友」和「谁加了我」都不用扫全表。双写天然存在不一致窗口,兜底靠对账任务,见练习。
关键坑回顾
整章 12 个坑,按链路顺序排:
| # | 坑 | 现象与根因 | 怎么防 |
|---|---|---|---|
| 1 | frinedId 拼写 bug |
FriendApiImpl#save 判重条件写成 Criteria.where("frinedId"),字段根本不存在,exists 恒为 false,判重完全失效,重复加好友无限插重复记录 |
字符串字段名无编译期检查;用唯一索引兜底(见练习一) |
| 2 | 环信注册失败被吞 | createUser catch 里只打一句「创建环信用户失败~」,不带异常详情不带用户名;调用方拿到 false 后静默跳过 hxUser 落库,用户对此毫不知情 |
日志带上下文;注册失败要有补偿手段(练习二) |
| 3 | 重复 createUser 报「已存在」也返回 false |
环信对已存在用户抛异常,Template 统一 catch 返回 false;批量注册脚本对已注册用户全部误判为失败 | 区分「业务性失败」和「幂等成功」,按异常类型分档返回 |
| 4 | INIT_PASSWORD 弱凭证 |
全员 123456,知道规则就能登任何人的环信账号 |
生产换用户级 token 或随机密码 |
| 5 | 凭证接口鉴权 | /huanxin/user 若漏配 JWT 拦截器,任何人可拿到他人环信凭证 |
接口纳入统一鉴权链,用 token 解出的 userId 查库 |
| 6 | hasLiked 被喜欢污染 |
消息模块「谁喜欢我」列表复用 day05 的 Movement 时间线查询,hasLiked 反映的是当前用户行为,混进被喜欢列表造成数据污染 |
列表查询和详情查询的字段语义分开,别复用带状态的查询 |
| 7 | 好友双写不一致 | 先环信后本地,环信成功、本地落库失败时,环信有联系人但 friend 集合没记录,联系人列表查不到 |
对账任务兜底(练习四) |
| 8 | 聊天记录数据边界在环信 | 服务端不落聊天消息,Console 与 REST History API 有保留期限,业务想存聊天记录得走环信回调 | 接入前想清楚数据归属,需要留存就订阅回调入库 |
| 9 | username 大小写 | createUser 里 username.toLowerCase() 注册,但凭证下发直接回库取 hxUser;两处大小写不一致,App 登环信报用户不存在 |
映射规则统一小写,注册与下发共用同一份落库值 |
| 10 | 漏拼 huanXinId 静默缺失 |
陌生人消息 JSON 里漏拼 huanXinId 字段,客户端解析头像、跳转详情静默失败,不报任何错 |
消息体定义成 VO 类序列化,别手拼 Map;字段缺失要有契约测试 |
| 11 | total 写死 0 |
findFriends 返回 new PageResult(page, pagesize, 0l, vos),前端分页组件拿不到总数,翻页失灵 |
分页先 count 再 skip/limit,total 是分页契约的一部分 |
| 12 | keyword 筛选分层错位 |
Mongo 侧先分页切 10 条,MySQL 侧再按 nickname 筛,先切后筛,符合条件的记录可能根本没进这一页 | 筛选条件下推到查询层,或按筛选条件先查 id 再分页 |
常见误解澄清
| 误解 | 实际情况 |
|---|---|
| 接了环信,聊天数据就归自己管 | 消息存储、送达、历史记录都在环信侧,服务端只是「账号 + 关系」的管理者。要留存聊天记录得走环信服务端回调 |
| 环信注册成功 = 用户体系集成完成 | 还差两环:凭证下发接口让 App 拿到账号密码,App 端用凭证自动登录环信。三环缺一,聊天都跑不通 |
createUser 返回 false 说明用户没建上 |
环信报「用户已存在」也会走进 catch 返回 false。false 不等于没建上,要按异常类型区分 |
| 好友关系只在环信侧维护就够 | 环信联系人接口不支持分页、筛选、关联 UserInfo。联系人列表的展示需求(分页、昵称搜索、头像昵称)全靠本地 friend 集合 + MySQL |
| 双写必须做成分布式事务 | 本章的量级用「先环信后本地 + 对账兜底」就够。事务强度按业务损失定,加好友丢一条可补偿,没必要上重型方案 |
total 写 0 只是个数字,不影响功能 |
前端分页组件依赖 total 判断还有没有下一页,写 0 等于宣告「只有一页」,翻页直接失灵 |
上线前自检清单
按链路顺序勾,前面不过后面全白测:
- 环信 Console 有用户 :注册一个新号,Console 用户列表出现
hx+id;存量用户跑过批量注册脚本 - 凭证接口带 JWT 返回正确 :带 token 请求
GET /huanxin/user,返回的 username/password 与库里一致;不带 token 返回 401 - 加好友双写成功 :A 加 B 后,环信 Console 里 A 的联系人有 B,
friend集合出现 userId/friendId 互换的两条记录;重复加不产生新记录(修完坑一后验证) - 联系人列表返回 huanXinId :
GET /messages/contacts返回的每条ContactVo.userId是hx+id格式,头像昵称齐全 - 聊天消息 App 可收:Console 给测试号发一条文本,App 端收到;App 端互发也通
- 环信 id 反查正确 :
GET /messages/userinfo?huanxinId=hx106返回对应用户详情,传错 id 返回合理错误而非 500
客户端联调两个高频故障,多半不是服务端的锅:App 连不上环信,先查客户端 APPKey 配置与应用是否匹配;消息展示错乱,先清客户端缓存再重测。
技术练习
作业都转成了技术任务,每项给「改哪里」和「怎么验证」:
一、修 frinedId 拼写 bug(必做)
改哪里:FriendApiImpl#save 里两处 Criteria.where("frinedId") 改为 "friendId";再给 friend 集合建复合唯一索引:
javascript
// mongo shell:对 friend 集合建 userId+friendId 复合唯一索引
db.friend.createIndex({ userId: 1, friendId: 1 }, { unique: true })
存量重复数据先按 {userId, friendId} 分组去重再建索引,否则建索引直接失败。怎么验证:同一对用户重复调加好友接口,friend 集合不新增记录。顺带体会一层:Criteria 的字段名是纯字符串,拼错编译器不管;如果这套关系落在 MySQL 用 MyBatis-Plus 的 LambdaQueryWrapper 写,方法引用拼错直接编译不过,这就是「字符串 key 静默失效」家族的治本对比。
二、补环信注册失败补偿
改哪里:给 HuanXinTemplate#createUser 的日志补上异常详情和用户名;新增一个定时任务,扫描 hxUser 为空的用户重试注册。怎么验证:手动造一条 hxUser 为空的用户,任务跑完后 hxUser/hxPassword 补齐、环信 Console 可见。
三、凭证接口改用户级 token
改哪里:注册用户时为每个人生成随机密码(或接环信用户级 token 接口),替换 INIT_PASSWORD 常量的使用点。怎么验证:两个用户的环信凭证不同;用 A 的凭证登 B 的环信账号失败。
四、好友双写对账任务
改哪里:定时比对环信联系人接口返回的关系集合与 friend 集合,差集补偿写回(环信有本地无 → 补落库;本地有环信无 → 补 addContact)。怎么验证:手动删掉一侧数据,任务跑完两侧重新对齐。
五、取消好友功能(工程未实现,完整闭环练习)
改哪里:MessagesService 加 deleteFriend(Long friendId) 方法,先调 huanXinTemplate.deleteContact(Template 里已有此方法但无业务调用),再双向删除 friend 集合两条记录:
java
package com.mf.server.service;
import com.mf.autoconfig.template.HuanXinTemplate;
import com.mf.commons.utils.Constants;
import com.mf.dubbo.api.FriendApi;
import com.mf.server.interceptor.UserHolder;
import org.apache.dubbo.config.annotation.DubboReference;
import org.springframework.stereotype.Service;
@Service
public class FriendDeleteService {
@DubboReference
private FriendApi friendApi;
@Autowired
private HuanXinTemplate huanXinTemplate;
//取消好友:先删环信联系人,再双向删本地关系
public void deleteFriend(Long friendId) {
Long userId = UserHolder.getUserId();
Boolean removed = huanXinTemplate.deleteContact(
Constants.HX_USER_PREFIX + userId,
Constants.HX_USER_PREFIX + friendId);
if (!removed) {
throw new RuntimeException("环信删除联系人失败");
}
friendApi.delete(userId, friendId);
}
}
配套要给 FriendApi/FriendApiImpl 补 delete 方法(双向删,参照 save 的对称写法),注意别把 frinedId 的坑再抄一遍。怎么验证:删除后环信 Console 里联系人消失,friend 集合两条记录都没了,双方联系人列表同步减少。
六、消息模块查询补全(基础巩固)
改哪里:按接口文档实现「谁点赞我 / 谁评论我 / 谁喜欢我」三个列表(数据已在 day05 的 Comment/Movement 里,注意别踩坑六的 hasLiked 污染),以及系统公告分页查询(MySQL 公告表,title/content/created,标准分页)。怎么验证:三列表数据与操作记录一致,公告分页 total 正确。
下一章预告
第七章「附近的人」(MongoDB 地理位置检索)将在本章之后展开,那章的关键词是 2dsphere 索引和 $near 查询。再下一章进入第八章「小视频方案」,核心是 FastDFS 分布式文件系统,还记得之前文件存储方案篇里「本地存储 / 云存储 / 自建分布式存储」的三岔口吗?小视频的大文件场景正好把自建这条路走一遍:上传、缩略图、视频点播全要过 FastDFS 的 Tracker 和 Storage。
总结
第六章的技术含量不在任何单个接口,而在链路:选型定了接环信,四件套把第三方能力包成 Template,用户体系集成用一条 hx + userId 映射规则把两套账号焊死,好友双写让「关系」在两边各有一份账,联系人列表再把本地关系查询做成产品功能。回头看,最容易翻车的全是「字符串约定」,拼错的字段名、漏拼的消息字段、大小写不一的 username,这类错编译器看不见、运行时静默,唯一可靠的防线是分层验证加唯一索引这类「机器强制的约定」。套路清单里五条能带走,坑表里十二条最好别亲自再踩一遍。