Java 项目实战:交友项目 - 联系人列表

概述

好友加完之后,用户去哪里找聊天对象?答案就是联系人列表页,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 体系。

跨存储批查的组装流程

整个查询三步,全部是批量操作,没有循环查库:

  1. FriendApi(Dubbo → Mongo)分页查当前用户的好友关系,拿到 List<Friend>;
  2. 提取所有 friendId,一次调用 UserInfoApi 的 findByIds 批查用户资料(带 keyword 筛选),返回 Map<Long, UserInfo>;
  3. 遍历 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 页为空;传一个不存在的昵称关键字,确认返回空列表而不是报错。

视频里的验证路径可以完整复现:

  1. 准备两个互为好友的账号 :模拟器左侧登录用户「小二黑」(id 106),右侧登录「隐翼天机」(id 109)。若两者还不是好友,在右侧侧边栏对左侧发起添加请求(回答「陌生人问题」),或在左侧消息页通过 POST /messages/contacts 添加。添加成功后 MongoDB 的 friend 集合里应有两条互指记录,环信服务器上两人也已互为好友。

  2. 调列表接口 :左侧进入消息页,点顶部「联系人」按钮,前端发出 GET /messages/contacts?page=1&pagesize=10。也可以直接带 token 用 Postman 调:

    bash 复制代码
    curl "http://localhost:18080/messages/contacts?page=1&pagesize=10" \
         -H "Authorization: Bearer <token>"
  3. 逐字段核对 VO :响应 items 里每个元素检查七个字段,id 是数字 id(109);userId 必须是 "hx109"(重点核对,以 hx 开头);avatar、nickname、gender、age、city 与 user_info 表里该好友的数据一致。

  4. 搜索过滤 :请求带上 keyword=隐,结果应只剩昵称包含「隐」的那条,对应 qw.like 条件生效。

  5. 分页与倒序核对 :批量插入十几条好友记录(写个单元测试循环调 friendApi.save 即可),pagesize=5 翻页验证:第 1 页 5 条、第 2 页接着往下,无重复无遗漏;列表顺序与 friend 集合里 created 倒序一致,最新添加的在最前。

  6. 端到端聊天 :右侧选中小二黑发一条消息,左侧会话列表收到内容并回复,这条链路依赖的正是第 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 表

官方文档

总结

  • 联系人列表是 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 那个拼写问题的教训值得记住:无模式存储把错误推迟到了业务层,写查询条件时对照实体类是最便宜的验证。
相关推荐
我命由我123451 小时前
访客系统形式(有用户概念、无用户概念)
java·开发语言·前端·javascript·python·产品运营·产品经理
架构师伯子明1 小时前
FFmpeg C++学习(一):编译和demo
开发语言·c++·学习
海域云SeaArea_1 小时前
Windows Server 2008 R2 从零安装 IIS7.5(二):php.ini 配置
开发语言·windows·php
叼烟扛炮1 小时前
[LangChain] 学习笔记(02):LCEL 链式调用与动态模型配置实战
服务器·开发语言·网络·笔记·学习·langchain
xixingzhe21 小时前
图表中同比与环比的区别:看趋势还是看变化?
开发语言·python
andyweike2 小时前
Swift
开发语言·ios·swift
纪念 2292 小时前
C++算法(六)
开发语言·c++·算法
香蕉大香蕉(06敬畏市场)2 小时前
MySQL 单点详解(01)
java·数据库·mysql·面试·博客·实习
LaughingZhu2 小时前
Product Hunt 热榜 2026-10-30:Empacer、Scape、Floot in ChatGPT
java·前端·数据库·人工智能·chatgpt·notion