概述
好友加完之后,用户去哪里找聊天对象?答案就是联系人列表页,IM 模块的「通讯录」。联系人列表是一个典型的跨存储组装接口:MongoDB 里查好友关系,MySQL 里查好友资料,最后拼上环信 ID 返回给前端。这里把这条列表接口的查询设计、VO 组装和从 Controller 到 MongoTemplate 的完整链路写透,重点讲清 huanXinId 这一步为什么不能漏。
业务场景
即时通信整条链路里,联系人列表是「加好友」和「聊天」之间的衔接环节:
#mermaid-svg-lKqDQ6VfyZRsno5K{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-lKqDQ6VfyZRsno5K .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-lKqDQ6VfyZRsno5K .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-lKqDQ6VfyZRsno5K .error-icon{fill:#552222;}#mermaid-svg-lKqDQ6VfyZRsno5K .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-lKqDQ6VfyZRsno5K .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-lKqDQ6VfyZRsno5K .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-lKqDQ6VfyZRsno5K .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-lKqDQ6VfyZRsno5K .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-lKqDQ6VfyZRsno5K .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-lKqDQ6VfyZRsno5K .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-lKqDQ6VfyZRsno5K .marker{fill:#333333;stroke:#333333;}#mermaid-svg-lKqDQ6VfyZRsno5K .marker.cross{stroke:#333333;}#mermaid-svg-lKqDQ6VfyZRsno5K svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-lKqDQ6VfyZRsno5K p{margin:0;}#mermaid-svg-lKqDQ6VfyZRsno5K .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-lKqDQ6VfyZRsno5K .cluster-label text{fill:#333;}#mermaid-svg-lKqDQ6VfyZRsno5K .cluster-label span{color:#333;}#mermaid-svg-lKqDQ6VfyZRsno5K .cluster-label span p{background-color:transparent;}#mermaid-svg-lKqDQ6VfyZRsno5K .label text,#mermaid-svg-lKqDQ6VfyZRsno5K span{fill:#333;color:#333;}#mermaid-svg-lKqDQ6VfyZRsno5K .node rect,#mermaid-svg-lKqDQ6VfyZRsno5K .node circle,#mermaid-svg-lKqDQ6VfyZRsno5K .node ellipse,#mermaid-svg-lKqDQ6VfyZRsno5K .node polygon,#mermaid-svg-lKqDQ6VfyZRsno5K .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-lKqDQ6VfyZRsno5K .rough-node .label text,#mermaid-svg-lKqDQ6VfyZRsno5K .node .label text,#mermaid-svg-lKqDQ6VfyZRsno5K .image-shape .label,#mermaid-svg-lKqDQ6VfyZRsno5K .icon-shape .label{text-anchor:middle;}#mermaid-svg-lKqDQ6VfyZRsno5K .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-lKqDQ6VfyZRsno5K .rough-node .label,#mermaid-svg-lKqDQ6VfyZRsno5K .node .label,#mermaid-svg-lKqDQ6VfyZRsno5K .image-shape .label,#mermaid-svg-lKqDQ6VfyZRsno5K .icon-shape .label{text-align:center;}#mermaid-svg-lKqDQ6VfyZRsno5K .node.clickable{cursor:pointer;}#mermaid-svg-lKqDQ6VfyZRsno5K .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-lKqDQ6VfyZRsno5K .arrowheadPath{fill:#333333;}#mermaid-svg-lKqDQ6VfyZRsno5K .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-lKqDQ6VfyZRsno5K .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-lKqDQ6VfyZRsno5K .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-lKqDQ6VfyZRsno5K .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-lKqDQ6VfyZRsno5K .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-lKqDQ6VfyZRsno5K .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-lKqDQ6VfyZRsno5K .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-lKqDQ6VfyZRsno5K .cluster text{fill:#333;}#mermaid-svg-lKqDQ6VfyZRsno5K .cluster span{color:#333;}#mermaid-svg-lKqDQ6VfyZRsno5K 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-lKqDQ6VfyZRsno5K .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-lKqDQ6VfyZRsno5K rect.text{fill:none;stroke-width:0;}#mermaid-svg-lKqDQ6VfyZRsno5K .icon-shape,#mermaid-svg-lKqDQ6VfyZRsno5K .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-lKqDQ6VfyZRsno5K .icon-shape p,#mermaid-svg-lKqDQ6VfyZRsno5K .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-lKqDQ6VfyZRsno5K .icon-shape .label rect,#mermaid-svg-lKqDQ6VfyZRsno5K .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-lKqDQ6VfyZRsno5K .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-lKqDQ6VfyZRsno5K .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-lKqDQ6VfyZRsno5K :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 点击联系人
好友申请
同意添加
环信注册好友关系
Mongo 写入 friend 集合
联系人列表页
进入聊天
用户添加好友成功后,点消息页右上角按钮进入联系人列表,找到目标用户,点击即进入聊天页面。所以这个页面本质是一次分页查询:查出「我的好友」,带上头像、昵称等展示信息。
数据落在两个存储里:
| 数据 | 位置 | 说明 |
|---|---|---|
| 好友关系(userId / friendId / created) | MongoDB friend 集合(mf-users 服务) | 添加好友时写入,双列双向两条记录 |
| 用户资料(头像、昵称、性别、年龄、城市) | MySQL userinfo 表(mf-users 服务) | 注册时已有,无需新写 |
未读消息数不在这个接口的职责里,它由客户端环信 SDK 本地维护,服务端没有未读数逻辑(参见联系人管理需求分析中的方案对比,此处不再展开)。
查询设计
为什么按 userId 等值查询就够了
交友项目的 friend 集合是双列双向 设计:A 和 B 成为好友,会写入两条记录,(userId=A, friendId=B) 和 (userId=B, friendId=A)。这是联系人管理需求分析里对比过的方案:另一种做法是单列存一条 (userId=A, friendId=B),查 B 的好友时得写 userId = B OR friendId = B。
双列方案的直接收益体现在这个列表接口上:查「我的好友」只需要 userId = 当前用户id 一个等值条件。等值条件在 MongoDB 里可以直接命中索引,也不存在 OR 查询难以走索引的问题。代价是写放大(加一个好友写两条),但好友关系是低频写、高频读的场景,这个交换是划算的。
created 倒序的含义
分页查询带了 Sort.by(Sort.Order.desc("created")),最近添加的好友排在前面。这符合通讯录的直觉:用户刚加完人,回头打开列表,新联系人应该一眼看到。微信的通讯录、QQ 的好友列表也是类似排序。
分页方式
skip((page - 1) * pagesize).limit(pagesize),标准的跳过式分页。联系人列表数据量小(几百条以内),skip/limit 的深分页问题在这里不构成威胁,够用。
keyword 筛选的位置
接口有个 keyword 参数,语义是「昵称关键字」。注意它不是作用在 friend 集合上的,friend 集合里只有 id,没有昵称。所以筛选发生在第二步:批查用户资料时,把 keyword 塞进 UserInfo 查询条件,由 UserInfoApi 的 findByIds(userIds, info) 在 userinfo 表里过滤。排序分页在 Mongo,筛选在 MySQL,这是跨存储查询的一个典型取舍。
接口设计与数据流
联系人列表接口在 YAPI 上的定义:
- 地址:
GET /messages/contacts - 请求参数:
page(页码,默认 1)、pagesize(页大小,默认 10)、keyword(昵称关键字,可选) - 返回:
PageResult,items里是ContactVo列表
| 项 | 内容 |
|---|---|
| 路径 | GET /messages/contacts |
| 参数 page | 当前页码,默认 1 |
| 参数 pagesize | 每页条数,默认 10 |
| 参数 keyword | 昵称关键字,可空,用于筛选 |
| 返回 | PageResult 包裹的 ContactVo 列表 |
数据来源横跨两个存储:
| 数据 | 存储位置 | 查询方式 |
|---|---|---|
| 好友关系(userId → friendId) | MongoDB friend 集合 |
userId 等值 + created 倒序分页 |
| 好友资料(头像、昵称、性别、年龄、城市) | MySQL user_info 表 |
id in (...) 批量查询 + 昵称模糊筛选 |
| 环信 ID(huanXinId) | 不落库,组装时拼接 | "hx" + friendId |
这个设计的背景在前面添加好友里已经说过:添加好友时,双向两条 Friend 记录写进了 MongoDB,同时好友关系也注册到了环信服务器。查「我的好友」列表只需要 userId 单字段等值,查谁的好友,userId 就是谁。而前端拿到联系人后要能直接发起环信聊天,接口必须把环信 ID 一起下发,这就是 ContactVo.userId 字段存的是 hx+数字 而不是普通数字的原因。
整个请求的数据流如下:
#mermaid-svg-wrn8U2oUZGSIo0fO{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-wrn8U2oUZGSIo0fO .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-wrn8U2oUZGSIo0fO .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-wrn8U2oUZGSIo0fO .error-icon{fill:#552222;}#mermaid-svg-wrn8U2oUZGSIo0fO .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-wrn8U2oUZGSIo0fO .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-wrn8U2oUZGSIo0fO .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-wrn8U2oUZGSIo0fO .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-wrn8U2oUZGSIo0fO .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-wrn8U2oUZGSIo0fO .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-wrn8U2oUZGSIo0fO .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-wrn8U2oUZGSIo0fO .marker{fill:#333333;stroke:#333333;}#mermaid-svg-wrn8U2oUZGSIo0fO .marker.cross{stroke:#333333;}#mermaid-svg-wrn8U2oUZGSIo0fO svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-wrn8U2oUZGSIo0fO p{margin:0;}#mermaid-svg-wrn8U2oUZGSIo0fO .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-wrn8U2oUZGSIo0fO .cluster-label text{fill:#333;}#mermaid-svg-wrn8U2oUZGSIo0fO .cluster-label span{color:#333;}#mermaid-svg-wrn8U2oUZGSIo0fO .cluster-label span p{background-color:transparent;}#mermaid-svg-wrn8U2oUZGSIo0fO .label text,#mermaid-svg-wrn8U2oUZGSIo0fO span{fill:#333;color:#333;}#mermaid-svg-wrn8U2oUZGSIo0fO .node rect,#mermaid-svg-wrn8U2oUZGSIo0fO .node circle,#mermaid-svg-wrn8U2oUZGSIo0fO .node ellipse,#mermaid-svg-wrn8U2oUZGSIo0fO .node polygon,#mermaid-svg-wrn8U2oUZGSIo0fO .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-wrn8U2oUZGSIo0fO .rough-node .label text,#mermaid-svg-wrn8U2oUZGSIo0fO .node .label text,#mermaid-svg-wrn8U2oUZGSIo0fO .image-shape .label,#mermaid-svg-wrn8U2oUZGSIo0fO .icon-shape .label{text-anchor:middle;}#mermaid-svg-wrn8U2oUZGSIo0fO .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-wrn8U2oUZGSIo0fO .rough-node .label,#mermaid-svg-wrn8U2oUZGSIo0fO .node .label,#mermaid-svg-wrn8U2oUZGSIo0fO .image-shape .label,#mermaid-svg-wrn8U2oUZGSIo0fO .icon-shape .label{text-align:center;}#mermaid-svg-wrn8U2oUZGSIo0fO .node.clickable{cursor:pointer;}#mermaid-svg-wrn8U2oUZGSIo0fO .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-wrn8U2oUZGSIo0fO .arrowheadPath{fill:#333333;}#mermaid-svg-wrn8U2oUZGSIo0fO .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-wrn8U2oUZGSIo0fO .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-wrn8U2oUZGSIo0fO .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-wrn8U2oUZGSIo0fO .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-wrn8U2oUZGSIo0fO .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-wrn8U2oUZGSIo0fO .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-wrn8U2oUZGSIo0fO .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-wrn8U2oUZGSIo0fO .cluster text{fill:#333;}#mermaid-svg-wrn8U2oUZGSIo0fO .cluster span{color:#333;}#mermaid-svg-wrn8U2oUZGSIo0fO 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-wrn8U2oUZGSIo0fO .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-wrn8U2oUZGSIo0fO rect.text{fill:none;stroke-width:0;}#mermaid-svg-wrn8U2oUZGSIo0fO .icon-shape,#mermaid-svg-wrn8U2oUZGSIo0fO .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-wrn8U2oUZGSIo0fO .icon-shape p,#mermaid-svg-wrn8U2oUZGSIo0fO .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-wrn8U2oUZGSIo0fO .icon-shape .label rect,#mermaid-svg-wrn8U2oUZGSIo0fO .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-wrn8U2oUZGSIo0fO .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-wrn8U2oUZGSIo0fO .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-wrn8U2oUZGSIo0fO :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 前端请求
GET /messages/contacts
MessagesController
接收 page/pagesize/keyword
MessagesService.findFriends
FriendApi.findByUserId
MongoDB 分页查好友关系
CollUtil.getFieldValues
提取 friendId 列表
UserInfoApi.findByIds
MySQL 批量查资料
ContactVo.init
拼 huanXinId = hx + id
PageResult 返回
返回示例:
json
{
"counts": 0,
"pagesize": 10,
"pages": 0,
"page": 1,
"items": [
{
"id": 106,
"userId": "hx106",
"avatar": "https://mf.xxx.com/avatar/106.png",
"nickname": "青梅",
"gender": "woman",
"age": 24,
"city": "上海"
},
{
"id": 2,
"userId": "hx2",
"avatar": "https://mf.xxx.com/avatar/2.png",
"nickname": "活力女孩",
"gender": "woman",
"age": 22,
"city": "上海"
}
]
}
注意 userId 的值:"hx" + 数据库 id。前端拿它去环信发起会话,不要当数据库主键用。
原型线框
原型文件消息中的「选择联系人」页:
doc
+--------------------------------------+
| < 选择联系人 [+] |
+--------------------------------------+
| [搜索用户 ] |
+--------------------------------------+
| 你关注的用户 > |
+--------------------------------------+
| +------+ |
| | 头像 | 昵称 > |
| +------+ |
| +------+ |
| | 头像 | 昵称 > |
| +------+ |
| +------+ |
| | 头像 | 昵称 > |
| +------+ |
+--------------------------------------+
要点:
- 顶栏左侧「返回」回到消息页;「搜索用户」输入框对应接口的
keyword参数; - 「你关注的用户」是另一个入口(查关注列表加好友),不在本接口范围;
- 列表项展示头像 + 昵称(对应 ContactVo 的 avatar / nickname),点击任意联系人进入与该用户的聊天页面,用的是
userId字段里的环信 ID; - 排序按添加时间倒序,新加的好友出现在列表前部。
VO 组装
ContactVo 七字段
实读 mf-model 的 ContactVo,字段就这七个:
| 字段 | 类型 | 来源 |
|---|---|---|
| id | Long | userinfo 主键 |
| userId | String | 「hx」 + 用户 id,即环信 ID |
| avatar | String | 用户头像 |
| nickname | String | 昵称 |
| gender | String | 性别 |
| age | Integer | 年龄 |
| city | String | 城市 |
没有未读数字段,也没有「备注名」之类的东西,这个 VO 只服务列表展示和「点击进聊天」这一个动作。
userId 字段为什么是环信 ID
这是 IM 联系人和普通社交好友最关键的一处差异。字段名叫 userId,但 init() 方法里实际赋的是 "hx" + userInfo.getId():
java
vo.setUserId("hx" + userInfo.getId().toString());
原因是:前端拿到这个列表后,点击联系人要进入聊天,而聊天走的是环信通道,前端用环信 ID 登录环信 SDK、给对方发消息。如果服务端只下发数据库自增 id,前端还得自己拼 "hx" 前缀,这个约定就会散落在客户端代码里;环信 ID 的格式一旦调整(比如换前缀、换编码),所有客户端版本都得跟着改。在服务端拼好,前端拿到的就是可直接用于环信的 ID,格式收敛到一个地方。
同一套「消息-联系人」接口里,前端会把 huanXinId 回传给 GET /messages/userinfo 查对方详情,两端对的是同一套 ID 体系。
跨存储批查的组装流程
整个查询三步,全部是批量操作,没有循环查库:
- FriendApi(Dubbo → Mongo)分页查当前用户的好友关系,拿到
List<Friend>; - 提取所有
friendId,一次调用 UserInfoApi 的findByIds批查用户资料(带 keyword 筛选),返回Map<Long, UserInfo>; - 遍历 friend 列表,按 friendId 从 Map 里取资料,
ContactVo.init()拷贝属性并拼环信 ID。
MySQL userinfo UserInfoApi(Dubbo) MongoDB friend FriendApi(Dubbo) MessagesService MessagesController 客户端 MySQL userinfo UserInfoApi(Dubbo) MongoDB friend FriendApi(Dubbo) MessagesService MessagesController 客户端 #mermaid-svg-HeIwtE85gTRDIq5P{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-HeIwtE85gTRDIq5P .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-HeIwtE85gTRDIq5P .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-HeIwtE85gTRDIq5P .error-icon{fill:#552222;}#mermaid-svg-HeIwtE85gTRDIq5P .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-HeIwtE85gTRDIq5P .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-HeIwtE85gTRDIq5P .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-HeIwtE85gTRDIq5P .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-HeIwtE85gTRDIq5P .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-HeIwtE85gTRDIq5P .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-HeIwtE85gTRDIq5P .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-HeIwtE85gTRDIq5P .marker{fill:#333333;stroke:#333333;}#mermaid-svg-HeIwtE85gTRDIq5P .marker.cross{stroke:#333333;}#mermaid-svg-HeIwtE85gTRDIq5P svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-HeIwtE85gTRDIq5P p{margin:0;}#mermaid-svg-HeIwtE85gTRDIq5P .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-HeIwtE85gTRDIq5P text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-HeIwtE85gTRDIq5P .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-HeIwtE85gTRDIq5P .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-HeIwtE85gTRDIq5P .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-HeIwtE85gTRDIq5P .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-HeIwtE85gTRDIq5P #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-HeIwtE85gTRDIq5P .sequenceNumber{fill:white;}#mermaid-svg-HeIwtE85gTRDIq5P #sequencenumber{fill:#333;}#mermaid-svg-HeIwtE85gTRDIq5P #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-HeIwtE85gTRDIq5P .messageText{fill:#333;stroke:none;}#mermaid-svg-HeIwtE85gTRDIq5P .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-HeIwtE85gTRDIq5P .labelText,#mermaid-svg-HeIwtE85gTRDIq5P .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-HeIwtE85gTRDIq5P .loopText,#mermaid-svg-HeIwtE85gTRDIq5P .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-HeIwtE85gTRDIq5P .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-HeIwtE85gTRDIq5P .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-HeIwtE85gTRDIq5P .noteText,#mermaid-svg-HeIwtE85gTRDIq5P .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-HeIwtE85gTRDIq5P .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-HeIwtE85gTRDIq5P .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-HeIwtE85gTRDIq5P .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-HeIwtE85gTRDIq5P .actorPopupMenu{position:absolute;}#mermaid-svg-HeIwtE85gTRDIq5P .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-HeIwtE85gTRDIq5P .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-HeIwtE85gTRDIq5P .actor-man circle,#mermaid-svg-HeIwtE85gTRDIq5P line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-HeIwtE85gTRDIq5P :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} alt 好友列表为空 有数据 GET /messages/contacts?page=1&pagesize=10&keyword= findFriends(page, pagesize, keyword) findByUserId(当前用户id, page, pagesize) userId 等值 + created 倒序 + skip/limit List<Friend> List<Friend> 空 PageResult 提取 friendId 列表 findByIds(userIds, keyword 筛选条件) 批量查询用户资料 Map<userId, UserInfo> Map<userId, UserInfo> ContactVo.init() 拷贝属性 + 拼 "hx" 前缀 PageResult(page, pagesize, vos) JSON(含 huanXinId 语义的 userId 字段)
代码实现:完整链路
本次改动涉及五个模块,共六个文件:
| 模块 | 文件 | 职责 |
|---|---|---|
| mf-model | com/mf/model/vo/ContactVo.java |
联系人 VO,init() 中拼 huanXinId |
| mf-model | com/mf/model/mongo/Friend.java |
好友关系实体(已存在,本节只读) |
| mf-app-server | com/mf/server/controller/MessagesController.java |
列表接口入口 |
| mf-app-server | com/mf/server/service/MessagesService.java |
组装链路核心 |
| mf-dubbo-interface | com/mf/dubbo/api/FriendApi.java |
API 接口定义 |
| mf-dubbo-mongo | com/mf/dubbo/api/FriendApiImpl.java |
MongoDB 分页查询实现 |
| mf-dubbo-db | com/mf/dubbo/api/UserInfoApiImpl.java |
批查资料 + 新增昵称条件 |
相关文件在工程中的位置:
tree
mf
├── mf-model
│ └── src/main/java/com/mf/model
│ ├── domain
│ │ └── UserInfo.java # MySQL 用户详情实体
│ ├── mongo
│ │ └── Friend.java # MongoDB 好友关系实体
│ └── vo
│ ├── ContactVo.java # 本节新增
│ └── PageResult.java
├── mf-app-server
│ └── src/main/java/com/mf/server
│ ├── controller
│ │ └── MessagesController.java # 新增 GET /contacts
│ ├── interceptor
│ │ └── UserHolder.java # 从线程上下文取当前用户 id
│ └── service
│ └── MessagesService.java # 新增 findFriends()
└── mf-dubbo
├── mf-dubbo-interface
│ └── src/main/java/com/mf/dubbo/api
│ ├── FriendApi.java # 新增 findByUserId 声明
│ └── UserInfoApi.java
├── mf-dubbo-mongo
│ └── src/main/java/com/mf/dubbo/api
│ └── FriendApiImpl.java # 新增 findByUserId 实现
└── mf-dubbo-db
└── src/main/java/com/mf/dubbo/api
└── UserInfoApiImpl.java # findByIds 补充昵称模糊条件
定义 ContactVo
在 mf-model 模块中定义联系人 VO。七个字段对应前端列表项要展示的数据,其中 userId 字段比较特殊,它存的不是数据库里的数字 id,而是环信 ID。
java
package com.mf.model.vo;
import com.mf.model.domain.UserInfo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.beans.BeanUtils;
import java.io.Serializable;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ContactVo implements Serializable {
private Long id; // 好友的用户 id(数字)
private String userId; // 环信 ID,格式 "hx" + 用户 id,前端拿它登录环信发起聊天
private String avatar; // 头像
private String nickname; // 昵称
private String gender; // 性别
private Integer age; // 年龄
private String city; // 城市
/**
* 静态工厂:由 UserInfo 构建 ContactVo
* 同名同类型属性自动拷贝,userId 单独覆盖为环信 ID
*/
public static ContactVo init(UserInfo userInfo) {
ContactVo vo = new ContactVo();
if (userInfo != null) {
BeanUtils.copyProperties(userInfo, vo);
// 关键一步:拼接环信 ID,这一步漏了前端就无法发起环信聊天
vo.setUserId("hx" + userInfo.getId().toString());
}
return vo;
}
}
把转换逻辑收在 init() 静态工厂里有个好处:拼 hx 前缀的规则只存在于一处,后面不管是联系人列表还是别的接口要下发环信 ID,都走同一个方法,不会出现有的接口拼了前缀有的没拼的分裂局面。
对照一下 UserInfo 与 ContactVo 的字段差异:
| UserInfo 字段 | ContactVo 字段 | 拷贝方式 |
|---|---|---|
id(Long) |
id(Long) |
BeanUtils 同名同类型自动拷贝 |
id(Long) |
userId(String) |
手动拼接 "hx" + id,类型和值都变了 |
avatar |
avatar |
自动拷贝 |
nickname |
nickname |
自动拷贝 |
gender |
gender |
自动拷贝 |
age(Integer) |
age(Integer) |
自动拷贝 |
city |
city |
自动拷贝 |
mf-app-server:Controller
MessagesController 中新增列表接口。注意这个 Controller 里已经有一个 POST /messages/contacts(添加好友),同一个路径靠请求方式区分:POST 是加好友,GET 是查列表。
java
package com.mf.server.controller;
import com.mf.model.vo.PageResult;
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.*;
import java.util.Map;
@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);
}
/**
* 添加好友(POST /contacts)
*/
@PostMapping("/contacts")
public ResponseEntity contacts(@RequestBody Map map) {
Long friendId = Long.valueOf(map.get("userId").toString());
messagesService.contacts(friendId);
return ResponseEntity.ok(null);
}
/**
* 分页查询联系人列表(GET /contacts)
*/
@GetMapping("/contacts")
public ResponseEntity contacts(@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "10") Integer pagesize,
String keyword) {
PageResult pr = messagesService.findFriends(page, pagesize, keyword);
return ResponseEntity.ok(pr);
}
}
三个参数的处理方式:
page、pagesize用@RequestParam(defaultValue = ...)给默认值,前端不传也能正常翻页;keyword不加注解,可以不传,用于按昵称搜索联系人,视频演示里输入关键字后两条好友只剩一条,就是靠这个参数过滤的。
mf-app-server:Service 组装链路(这里核心)
MessagesService.findFriends() 是整个接口的核心,四步组装:查关系 → 提取 id → 批查资料 → 构建 VO。
java
package com.mf.server.service;
import cn.hutool.core.collection.CollUtil;
import com.mf.autoconfig.template.HuanXinTemplate;
import com.mf.commons.utils.Constants;
import com.mf.dubbo.api.FriendApi;
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.mongo.Friend;
import com.mf.model.vo.ContactVo;
import com.mf.model.vo.ErrorResult;
import com.mf.model.vo.PageResult;
import com.mf.model.vo.UserInfoVo;
import com.mf.server.exception.BusinessException;
import com.mf.server.interceptor.UserHolder;
import org.apache.dubbo.config.annotation.DubboReference;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@Service
public class MessagesService {
@DubboReference
private UserApi userApi;
@DubboReference
private UserInfoApi userInfoApi;
@DubboReference
private FriendApi friendApi;
@Autowired
private HuanXinTemplate huanXinTemplate;
/**
* 根据环信 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); //拷贝同名同类型属性
if (userInfo.getAge() != null) {
vo.setAge(userInfo.getAge().toString());
}
return vo;
}
/**
* 添加好友关系:先注册环信,再落 MongoDB
*/
public void contacts(Long friendId) {
//1、将好友关系注册到环信
Boolean aBoolean = huanXinTemplate.addContact(Constants.HX_USER_PREFIX + UserHolder.getUserId(),
Constants.HX_USER_PREFIX + friendId);
if (!aBoolean) {
throw new BusinessException(ErrorResult.error());
}
//2、注册成功后,记录好友关系到 MongoDB
friendApi.save(UserHolder.getUserId(), friendId);
}
/**
* 分页查询联系人列表
* 组装链路:MongoDB 查关系 → 提取 friendId → Dubbo 批查资料 → 拼 huanXinId 构建 VO
*/
public PageResult findFriends(Integer page, Integer pagesize, String keyword) {
//1、调用 API 查询当前用户的好友关系数据 -- List<Friend>
List<Friend> list = friendApi.findByUserId(UserHolder.getUserId(), page, pagesize);
if (CollUtil.isEmpty(list)) {
//没有好友,直接返回空分页对象
return new PageResult();
}
//2、提取数据列表中的好友 id(注意提取的是 friendId,不是 userId)
List<Long> userIds = CollUtil.getFieldValues(list, "friendId", Long.class);
//3、调用 UserInfoApi 批量查询好友的用户详情,keyword 作为昵称模糊条件
UserInfo info = new UserInfo();
info.setNickname(keyword);
Map<Long, UserInfo> map = userInfoApi.findByIds(userIds, info);
//4、循环 Friend 构建 VO(拼 huanXinId 的动作发生在 ContactVo.init 内部)
List<ContactVo> vos = new ArrayList<>();
for (Friend friend : list) {
UserInfo userInfo = map.get(friend.getFriendId());
if (userInfo != null) {
ContactVo vo = ContactVo.init(userInfo);
vos.add(vo);
}
}
return new PageResult(page, pagesize, 0L, vos);
}
}
逐段拆解这条链路的设计意图:
第一步查关系 。当前登录用户 id 从 UserHolder 拿,它在拦截器里已经从 token 解析并放进线程上下文,Service 里直接取,不用层层传参。查询交给 friendApi.findByUserId(),走 Dubbo 到 mf-dubbo-mongo 服务,只查当前页的 Friend 记录。
第二步提取 id 。CollUtil.getFieldValues(list, "friendId", Long.class) 是 Hutool 的反射工具,一行代码把每条 Friend 的 friendId 抽出来组成 List<Long>。这里提取的字段必须是 friendId:列表里存的是 userId=我、friendId=对方 的记录,要展示的是对方,取 userId 就查到自己头上了。
第三步批查资料 。拿到 id 列表后调 userInfoApi.findByIds(),一次 Dubbo 调用把本页所有好友的资料查回来,返回 Map<Long, UserInfo>(id → 详情)。这比循环里逐个调 findById 少了 N-1 次 RPC。keyword 塞进一个 UserInfo 对象传过去,作为昵称模糊查询条件,null 时条件自动跳过。
第四步构建 VO 。循环 Friend,从 Map 里按 friendId 取出 UserInfo,交给 ContactVo.init() 转换。判空兜底是必须的:如果某个好友的 user_info 记录不存在(被注销、数据不完整),map.get() 返回 null,直接跳过这条而不是抛 NPE。
最后 new PageResult(page, pagesize, 0L, vos),注意总记录数传了 0L,前端这里只依赖 items 做列表展示和翻页加载,counts 没有参与总页数计算,这是该项目多个列表接口的统一简化写法。
huanXinId 拼接的位置与意义
这里最关键的一个实现点:"hx" + id 拼在 ContactVo.init() 里,也就是 Service 组装 VO 的环节,而不是在 API 层或数据库里。
为什么这一步重要?对比一下环信 ID 缺失和不缺失时前端的处境:
| 场景 | 前端行为 | 结果 |
|---|---|---|
| VO 带正确 huanXinId | 用 hx106 登录环信 SDK,向 hx109 发消息 |
聊天正常收发 |
| VO 漏拼 huanXinId(userId 是普通数字或 null) | 环信 SDK 找不到对端账号 | 发送失败或消息石沉大海 |
| 接口 HTTP 层面 | 状态码 200,字段结构完整 | 不报错,页面列表照常显示 |
第三行就是坑所在:漏拼 huanXinId 不会让接口报错,列表照样渲染出来,昵称头像都有,只有真正点进聊天发消息时才发现发不出去,静默缺失,从现象反查到 VO 组装这一步,排查成本很高。
这也是 IM 联系人接口和普通好友接口的本质差异:
- 普通业务接口(如「谁看过我」)只需要展示数据,VO 里的字段都是「给人看」的;
- IM 联系人接口的
userId字段是「给环信 SDK 用」的凭证,用户登录环信后,所有会话、消息都靠环信 ID 寻址。数据库里从不存环信 ID,它是在返回给前端的那一刻才拼出来的,规则就是Constants.HX_USER_PREFIX定义的hx前缀加用户 id。
回忆一下添加好友的代码(contacts() 方法):注册环信用的是 Constants.HX_USER_PREFIX + userId,和 ContactVo.init() 里的 "hx" + id 是同一个规则的两处表达。前端拿列表里的 userId 登录环信,双方账号在环信服务器上已经是好友关系,消息通道直接打通。
mf-dubbo-interface:FriendApi 接口定义
java
package com.mf.dubbo.api;
import com.mf.model.mongo.Friend;
import java.util.List;
public interface FriendApi {
//添加好友(双向保存两条记录)
void save(Long userId, Long friendId);
//查询好友列表(userId 等值 + created 倒序 + 分页)
List<Friend> findByUserId(Long userId, Integer page, Integer pagesize);
}
mf-dubbo-mongo:FriendApiImpl 实现
findByUserId 的查询条件值得细看。因为好友关系是双向两条记录,查「我的好友」只需要 userId 单字段等值,不需要再 and("friendId") 什么条件。
java
package com.mf.dubbo.api;
import com.mf.model.mongo.Friend;
import org.apache.dubbo.config.annotation.DubboService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Sort;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;
import java.util.List;
@DubboService
public class FriendApiImpl implements FriendApi {
@Autowired
private MongoTemplate mongoTemplate;
/**
* 添加好友:双向保存(自己指向对方、对方指向自己)
* 注:早期讲义代码查询条件里把字段名写成了 "frinedId"(拼写错误),
* 与实体 Friend 的 friendId 字段对不上,导致查重失效、可重复插入,
* 实现时务必以实体字段名为准
*/
@Override
public void save(Long userId, Long friendId) {
//1、保存自己的好友数据
Query query1 = Query.query(Criteria.where("userId").is(userId)
.and("friendId").is(friendId));
//1.1 判断好友关系是否存在,不存在才保存
if (!mongoTemplate.exists(query1, Friend.class)) {
Friend friend1 = new Friend();
friend1.setUserId(userId);
friend1.setFriendId(friendId);
friend1.setCreated(System.currentTimeMillis());
mongoTemplate.save(friend1);
}
//2、保存好友的数据(反向记录)
Query query2 = Query.query(Criteria.where("userId").is(friendId)
.and("friendId").is(userId));
if (!mongoTemplate.exists(query2, Friend.class)) {
Friend friend1 = new Friend();
friend1.setUserId(friendId);
friend1.setFriendId(userId);
friend1.setCreated(System.currentTimeMillis());
mongoTemplate.save(friend1);
}
}
/**
* 分页查询某用户的好友列表
* 条件:userId 等值;排序:created 倒序(最新添加的在前);分页:skip + limit
*/
@Override
public List<Friend> findByUserId(Long userId, Integer page, Integer pagesize) {
Criteria criteria = Criteria.where("userId").is(userId);
Query query = Query.query(criteria)
.skip((page - 1) * pagesize) //跳过前几页的数据
.limit(pagesize) //只取本页条数
.with(Sort.by(Sort.Order.desc("created"))); //按添加时间倒序
return mongoTemplate.find(query, Friend.class);
}
}
三个查询要素拆开看:
- 等值条件 :
Criteria.where("userId").is(userId),双向记录设计的直接收益,单字段就能圈定「某人所有的好友」。 - 排序 :
Sort.by(Sort.Order.desc("created")),最新加的好友排前面。加了倒序后分页翻页才稳定,否则 MongoDB 不保证无排序查询的返回顺序,翻页时可能出现重复或遗漏。 - 分页 :
skip((page-1)*pagesize)跳过前面的页,limit(pagesize)限定本页条数。这是 MongoDB 分页的标准写法,page从 1 开始。
save() 的注释里标了一个历史问题:早期代码里查询条件写的是 "frinedId"(friend 拼错了),MongoDB 是无模式存储,写入不会报错,但这个字符串和实体字段 friendId 对不上,exists() 永远查不到已存在的记录,查重形同虚设,好友可以重复插入。这类错误在关系型数据库会因列不存在直接抛异常,在 MongoDB 里却完全静默,所以写 Criteria 时字段名务必对照实体类,不要凭记忆敲。
mf-dubbo-db:UserInfoApiImpl 补充昵称条件
联系人列表带 keyword 搜索,findByIds 的筛选条件里要加上昵称模糊匹配。之前该方法只处理了年龄和性别条件,这次补充第三个:
java
package com.mf.dubbo.api;
import cn.hutool.core.collection.CollUtil;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.mf.dubbo.mappers.UserInfoMapper;
import com.mf.model.domain.UserInfo;
import org.apache.dubbo.config.annotation.DubboService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.util.StringUtils;
import java.util.List;
import java.util.Map;
@DubboService
public class UserInfoApiImpl implements UserInfoApi {
@Autowired
private UserInfoMapper userInfoMapper;
@Override
public void save(UserInfo userInfo) {
userInfoMapper.insert(userInfo);
}
@Override
public void update(UserInfo userInfo) {
userInfoMapper.updateById(userInfo);
}
@Override
public UserInfo findById(Long id) {
return userInfoMapper.selectById(id);
}
/**
* 按用户 id 列表批量查询详情,info 中非空字段作为附加筛选条件
*/
@Override
public Map<Long, UserInfo> findByIds(List<Long> userIds, UserInfo info) {
QueryWrapper qw = new QueryWrapper();
//1、用户 id 列表(in 批量查询,一次查回本页全部资料)
qw.in("id", userIds);
//2、附加筛选条件:年龄、性别、昵称,非空才拼进条件
if (info != null) {
if (info.getAge() != null) {
qw.lt("age", info.getAge());
}
if (!StringUtils.isEmpty(info.getGender())) {
qw.eq("gender", info.getGender());
}
//新增:昵称模糊匹配,支持只输入昵称的一部分
if (!StringUtils.isEmpty(info.getNickname())) {
qw.like("nickname", info.getNickname());
}
}
List<UserInfo> list = userInfoMapper.selectList(qw);
//3、按 id 组装 Map,供调用方 O(1) 取单条详情
Map<Long, UserInfo> map = CollUtil.fieldValueMap(list, "id");
return map;
}
}
qw.like("nickname", info.getNickname()) 生成的 SQL 是 nickname like '%xxx%',MyBatis-Plus 的 like 自动在两侧加 %。演示里输入关键字后两条好友只剩一条,走的就是这个条件。三个条件都是「非空才拼」,keyword 不传时只按 in(id列表) 查,不影响正常列表。
CollUtil.fieldValueMap(list, "id") 与 Service 里的 getFieldValues 是一对反向操作:前者把对象列表转成「属性值 → 对象」的 Map,后者从对象列表抽取属性值列表,都是 Hutool 反射工具,省掉手写循环。
完整时序
一次列表请求经过的完整调用链:
ContactVo.init UserInfoApi(Dubbo→MySQL) FriendApi(Dubbo→MongoDB) MessagesService MessagesController 客户端 ContactVo.init UserInfoApi(Dubbo→MySQL) FriendApi(Dubbo→MongoDB) MessagesService MessagesController 客户端 #mermaid-svg-AjEpYrZ8L43SVIxO{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-AjEpYrZ8L43SVIxO .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-AjEpYrZ8L43SVIxO .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-AjEpYrZ8L43SVIxO .error-icon{fill:#552222;}#mermaid-svg-AjEpYrZ8L43SVIxO .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-AjEpYrZ8L43SVIxO .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-AjEpYrZ8L43SVIxO .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-AjEpYrZ8L43SVIxO .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-AjEpYrZ8L43SVIxO .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-AjEpYrZ8L43SVIxO .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-AjEpYrZ8L43SVIxO .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-AjEpYrZ8L43SVIxO .marker{fill:#333333;stroke:#333333;}#mermaid-svg-AjEpYrZ8L43SVIxO .marker.cross{stroke:#333333;}#mermaid-svg-AjEpYrZ8L43SVIxO svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-AjEpYrZ8L43SVIxO p{margin:0;}#mermaid-svg-AjEpYrZ8L43SVIxO .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-AjEpYrZ8L43SVIxO text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-AjEpYrZ8L43SVIxO .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-AjEpYrZ8L43SVIxO .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-AjEpYrZ8L43SVIxO .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-AjEpYrZ8L43SVIxO .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-AjEpYrZ8L43SVIxO #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-AjEpYrZ8L43SVIxO .sequenceNumber{fill:white;}#mermaid-svg-AjEpYrZ8L43SVIxO #sequencenumber{fill:#333;}#mermaid-svg-AjEpYrZ8L43SVIxO #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-AjEpYrZ8L43SVIxO .messageText{fill:#333;stroke:none;}#mermaid-svg-AjEpYrZ8L43SVIxO .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-AjEpYrZ8L43SVIxO .labelText,#mermaid-svg-AjEpYrZ8L43SVIxO .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-AjEpYrZ8L43SVIxO .loopText,#mermaid-svg-AjEpYrZ8L43SVIxO .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-AjEpYrZ8L43SVIxO .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-AjEpYrZ8L43SVIxO .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-AjEpYrZ8L43SVIxO .noteText,#mermaid-svg-AjEpYrZ8L43SVIxO .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-AjEpYrZ8L43SVIxO .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-AjEpYrZ8L43SVIxO .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-AjEpYrZ8L43SVIxO .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-AjEpYrZ8L43SVIxO .actorPopupMenu{position:absolute;}#mermaid-svg-AjEpYrZ8L43SVIxO .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-AjEpYrZ8L43SVIxO .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-AjEpYrZ8L43SVIxO .actor-man circle,#mermaid-svg-AjEpYrZ8L43SVIxO line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-AjEpYrZ8L43SVIxO :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} UserHolder 取当前用户 id(拦截器已解析 token) alt 列表为空 loop 遍历本页每条 Friend GET /messages/contacts?page=1&pagesize=10&keyword= findFriends(page, pagesize, keyword) findByUserId(userId, page, pagesize) MongoDB: userId 等值 created 倒序 + skip/limit List<Friend> new PageResult()(空) CollUtil.getFieldValues 提取 friendId 列表 findByIds(userIds, infonickname=keyword) MySQL: id in (...) + nickname like Map<Long, UserInfo> map.get(friend.getFriendId()),null 则跳过 init(userInfo) BeanUtils 拷贝 + 拼 huanXinId("hx"+id) ContactVo PageResult(page, pagesize, 0L, vos) 200 OK + PageResult JSON
异常与边界
| 场景 | 系统行为 | 设计说明 |
|---|---|---|
| 无好友 | 返回空的 PageResult(各字段为空值) | 服务层 CollUtil.isEmpty 判断后直接短路,不发起批查 |
| 好友资料已注销 | 该联系人不出现,列表缩短 | 批查 Map 里取不到 UserInfo 时跳过,不报错;残留的 friend 记录不清理 |
| 分页越界(page 超出范围) | 返回空列表 | skip 超出总量时 Mongo 自然返回空集 |
| keyword 无匹配 | 返回空列表 | 筛选发生在 userinfo 查询,不影响 friend 分页 |
| 环信 ID 拼接遗漏 | 列表能展示,但点击后无法聊天 | 最隐蔽的一类问题:接口不报错,前端拿未拼前缀的 id 去环信会话必然失败。联调时「列表正常但进不去聊天」先查这里 |
| 分页 total 口径 | new PageResult(page, pagesize, 0l, vos) 总数写死 0 |
实读代码确认:课程实现里 counts 没有真正统计。客户端如果依赖 total 做「加载更多」判断,得改用 items 是否为空 |
验证
用两个账号互相加好友,确认 friend 集合各有两条记录;打开联系人列表,核对:新加的好友排在前面(created 倒序)、头像昵称年龄城市完整、userId 字段值为 "hx" + 数据库 id;拿返回的 userId 直接去环信发一条消息,验证 ID 可用;翻页:加 3 个好友、pagesize 设 2,确认第 1 页 2 条、第 2 页 1 条、第 3 页为空;传一个不存在的昵称关键字,确认返回空列表而不是报错。
视频里的验证路径可以完整复现:
-
准备两个互为好友的账号 :模拟器左侧登录用户「小二黑」(id 106),右侧登录「隐翼天机」(id 109)。若两者还不是好友,在右侧侧边栏对左侧发起添加请求(回答「陌生人问题」),或在左侧消息页通过
POST /messages/contacts添加。添加成功后 MongoDB 的friend集合里应有两条互指记录,环信服务器上两人也已互为好友。 -
调列表接口 :左侧进入消息页,点顶部「联系人」按钮,前端发出
GET /messages/contacts?page=1&pagesize=10。也可以直接带 token 用 Postman 调:bashcurl "http://localhost:18080/messages/contacts?page=1&pagesize=10" \ -H "Authorization: Bearer <token>" -
逐字段核对 VO :响应
items里每个元素检查七个字段,id是数字 id(109);userId必须是"hx109"(重点核对,以hx开头);avatar、nickname、gender、age、city与user_info表里该好友的数据一致。 -
搜索过滤 :请求带上
keyword=隐,结果应只剩昵称包含「隐」的那条,对应qw.like条件生效。 -
分页与倒序核对 :批量插入十几条好友记录(写个单元测试循环调
friendApi.save即可),pagesize=5翻页验证:第 1 页 5 条、第 2 页接着往下,无重复无遗漏;列表顺序与friend集合里created倒序一致,最新添加的在最前。 -
端到端聊天 :右侧选中小二黑发一条消息,左侧会话列表收到内容并回复,这条链路依赖的正是第 3 步核对过的
huanXinId,任何一端拼错都卡在这里。
常见误解澄清
| 误解 | 事实 |
|---|---|
| ContactVo 里有未读数字段 | 没有。实读 VO 只有 id/userId/avatar/nickname/gender/age/city 七个字段,未读数由客户端环信 SDK 本地维护 |
userId 字段是数据库用户 id |
是环信 ID(「hx」 + 数据库 id),供前端直接对接环信 SDK |
| 环信 ID 存在数据库某张表里 | 不落库,纯运行时拼接。数据库里没有 hx106 这样的值,它只在接口返回瞬间存在 |
| keyword 在 MongoDB 里按昵称筛选 | friend 集合里没有昵称,筛选发生在批查 userinfo 那一步 |
| 查「我的好友」需要 userId、friendId 两个条件 OR | 双列双向设计下只需 userId = 当前用户 等值一条查询 |
| 每个好友要查一次用户资料 | 一次 findByIds 批查,返回 Map 后内存组装 |
VO 的 id 和 userId 是同一个东西 |
id 是数据库主键(Long),userId 是字符串环信 ID,用途不同 |
提取 id 时取 Friend.userId |
取 friendId。记录是「我指向对方」,要展示的是对方资料 |
PageResult 构造时传 0L 是 bug |
该项目多个列表接口的统一简化:前端只依赖 items 渲染和翻页,counts 不参与计算 |
keyword 传空字符串会影响查询 |
StringUtils.isEmpty 判空后跳过,等价于无条件 |
| MongoDB 字段名写错会在启动或查询时报错 | 不会。MongoDB 无模式,字段名只是文档里的一个 key,写错只会查不到数据(静默失败),这是 frinedId 拼写问题难以发现的根源 |
常见坑
| 坑 | 现象 | 规避 |
|---|---|---|
| 漏拼 huanXinId | 接口 200、列表正常显示,但点进聊天发不出消息,不报任何错 | 拼接规则收敛在 ContactVo.init() 一处;联调时必须核对返回 JSON 里的 userId 以 hx 开头 |
Criteria 字段名拼写错误(如 frinedId) |
查重失效、数据重复插入,全程无报错 | 字段名一律对照实体类写;写完用 find() 实际查一次验证 |
| 批查漏条件 | 列表搜索不生效,或 info 为 null 时 NPE |
附加条件逐个判空;info 对象本身判空 |
| 分页参数未给默认值 | 前端不传参数直接 400 | @RequestParam(defaultValue = "1") / "10" |
提取 id 提成了 userId |
列表里查出的全是自己的资料 | 记住方向:userId 是记录归属人,friendId 是要展示的人 |
| 注销用户未兜底 | 某好友资料缺失时循环内 NPE,整个列表 500 | map.get() 后判 null 跳过 |
| 无排序分页 | 翻页出现重复或遗漏记录 | MongoDB 分页必须配 Sort,保证顺序稳定 |
API 速览
| 项 | 内容 |
|---|---|
| 接口 | GET /messages/contacts |
| 参数 | page(默认 1)、pagesize(默认 10)、keyword(昵称关键字,可选) |
| 返回 | PageResult,items 为 ContactVo[] |
| ContactVo 字段 | id、userId(环信ID)、avatar、nickname、gender、age、city |
| 依赖服务 | mf-dubbo-mongo(好友关系)、mf-dubbo-db(用户资料) |
| 数据库 | MongoDB friend 集合、MySQL user_info 表 |
官方文档
- Spring Data MongoDB - Query, Criteria, Sort:
skip/limit/with(Sort)组合分页的官方说明 - Hutool - CollUtil 工具类:
getFieldValues、fieldValueMap的用法 - 环信即时通讯云 - Web IM 文档:环信 ID 登录与好友关系管理的官方文档
- MyBatis-Plus - 条件构造器:
QueryWrapper的in/like/lt/eq
总结
- 联系人列表是 IM 的通讯录页:加好友成功后记录进 Mongo friend 集合,列表接口把它翻出来给用户选人聊天;
- 查询设计吃到了双向双记录的红利:一个等值条件 + created 倒序 + skip/limit 分页,不需要 OR;
- 组装链路四步走:MongoDB 分页查关系 → Hutool 提取 friendId → Dubbo 批查 MySQL 资料 →
ContactVo.init构建 VO,代码量不大但横跨两个存储和一次 RPC,是项目里跨服务组装的典型样本; huanXinId在 VO 组装时拼接,是 IM 接口区别于普通接口的关键:它不报错的静默缺失最容易漏测,联调时必须逐字段核对返回值;- 双向两条好友记录让列表查询退化成单字段等值,
userId等值加created倒序加skip/limit,就是 MongoDB 分页查询的全部; - MongoDB 里字段名写错的代价是静默的,
frinedId那个拼写问题的教训值得记住:无模式存储把错误推迟到了业务层,写查询条件时对照实体类是最便宜的验证。