Java 项目实战:交友项目 - 联系人管理需求分析
上篇完成了用户体系集成,任何用环信做 IM 的项目都要走那套流程。接下来就是具体业务了,联系人管理(好友管理)。这里先把需求拆清楚:添加好友分几步、联系人列表的数据从哪来、未读消息数到底谁负责统计。接口和代码留到下一篇细说。
纲要
- 业务场景:IM 的联系人页 = 好友列表 + 会话入口,一份社交关系两处用
- 需求拆解:发送好友申请 → 同意添加 → 查看好友,共三件事
- 数据模型:
friend集合双向两条记录的设计 - 未读消息数:服务端不管,客户端环信 SDK 本地维护(附两种方案对比)
- 接口设计:
GET/POST /messages/contacts的路径、参数、返回 - 核心流程:添加好友与查询联系人列表的时序图
- 原型线框:消息页与选择联系人页
- 异常边界与验证要点
业务场景:为什么 IM 页面里会有「联系人」
打开交友 App 底部有「交友 / 消息 / 我的」三个 tab,用户切到「消息」tab,第一屏是会话列表,点「选择联系人」就进入通讯录,这里列出的就是他的全部好友。用户从这里找到要聊天的人,点一下进入聊天页。
联系人和社交好友的关系值得单独说一句。在很多产品里这是两套体系:关注/粉丝是社交关系,IM 联系人是通信录,两者可以不一致。项目不是这样,一份好友关系,两处使用:
- 社交侧:好友是推荐、互动、圈子互动的深化结果
- IM 侧:好友同时是环信里的联系人,直接发起单聊
所以加好友时服务端要做两件事:把关系写进 MongoDB(社交侧可查),同时调用环信接口注册好友关系(IM 侧能发消息)。这两步缺一不可,后面讲异常边界时会回到这个「双写」带来的对账问题。
需求拆解:三件事
原型演示的流程走一遍:用户 A 在推荐里看到用户 B,点「聊一下」查看 B 设置的陌生人问题,填好答案提交,请求发到项目的 Java 服务器,服务器调环信把这条消息推到 B 的手机上。B 看到的是一条「陌生人消息」,上面有 A 的问题和 B 自己的答案,B 点击「确认添加」,两人结为好友。之后 B 打开消息 tab → 通讯录,就能看到刚添加的 A,点进去开始在线聊天。
把流程摊开,联系人管理本质上是三项功能:
| 功能 | 触发动作 | 服务端动作 |
|---|---|---|
| 发送好友申请 | A 填答案点「聊一下」 | 组装消息体,调环信推送给 B |
| 同意并添加好友 | B 点「确认添加」 | 环信 addContact + MongoDB 写好友关系(双写) |
| 查看好友 | 用户打开通讯录 | 分页查好友列表,组装用户资料返回 |
除此之外还要补齐过程中缺的接口,比如点开某个用户时的详情页(查看用户信息),这些在上一节的 §5.2-5.4 已经覆盖。逻辑都清晰,代码量不大,难的是想清楚数据怎么组装。
数据模型:friend 集合是双向两条记录
好友关系存在 MongoDB 的 friend 集合里(注意它是 Mongo collection,不是 MySQL 的表)。关键设计:一条好友关系存两条记录 ,A 和 B 互为好友,就写 (userId=A, friendId=B) 和 (userId=B, friendId=A) 两条。查询时永远只按 userId = 当前用户 去查,不需要 or 条件。
java
package com.mf.model.mongo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.bson.types.ObjectId;
import org.springframework.data.mongodb.core.mapping.Document;
/**
* 好友表:好友关系表
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Document(collection = "friend")
public class Friend implements java.io.Serializable {
private static final long serialVersionUID = 6003135946820874230L;
private ObjectId id;
private Long userId; //用户id
private Long friendId; //好友id
private Long created; //时间
}
为什么存两条而不是一条记录配一个 or 查询?好友列表是高频读场景,双向两条记录让查询条件退化为单字段等值匹配,配合索引就是最简单的查询路径;写入时多写一条,属于用写成本换读成本,对这种读多写少的关系数据是划算的。写入时先 exists 判重再 save,防止重复添加产生重复记录。
存储位置上,项目的工程结构是三层:app-server 收 HTTP 请求,Dubbo 调用落到 dubbo 模块操作存储。联系人相关的类分布如下:
tree
mf
├── mf-app-server # 接口层
│ └── com/mf/server
│ ├── controller/MessagesController.java # /messages/contacts
│ └── service/MessagesService.java # 组装逻辑
├── mf-dubbo
│ ├── mf-dubbo-interface
│ │ └── com/mf/dubbo/api/FriendApi.java # save / findByUserId
│ └── mf-dubbo-mongo
│ └── com/mf/dubbo/api/FriendApiImpl.java
└── mf-model
└── com/mf/model
├── mongo/Friend.java # 好友关系 collection
└── vo/ContactVo.java # 联系人返回 VO
未读消息数:服务端不统计,客户端自己算
联系人列表页要显示「未读消息数」(小红点),这个数从哪来?通读本项目的工程代码,结论很明确:服务端完全没有未读数的逻辑 。ContactVo 里只有 id、userId、avatar、nickname、gender、age、city 七个字段,没有任何未读计数;HuanXinTemplate 只封装了四个方法,createUser、addContact、deleteContact、sendMsg,也不涉及离线或未读查询。
这不是偷懒,环信的官方能力就是这么设计的:未读数由客户端 SDK 在本地维护 。环信的 iOS/Android SDK 里每个会话(EMConversation)自带 unreadCount 属性,消息收发过程中 SDK 自动增减,App 刷新联系人页时直接读本地数据库渲染。服务端 REST API 虽然能查到离线消息数,但项目选择了不走这条路。
两种方案对比:
| 方案 | 做法 | 优点 | 缺点 | 项目的选择 |
|---|---|---|---|---|
| 客户端本地统计 | 环信 SDK 维护每会话 unreadCount,App 端直接读 | 服务端零开发;实时性好;SDK 原生支持 | 换设备后本地计数丢失(需拉漫游消息重算);多端未读数不同步 | 采用 |
| 服务端统计 | 自建存储记录每对用户的已读位点,或调环信 REST API 查离线数 | 多端一致;换设备不丢 | 要维护已读位点的存储与更新,或依赖环信接口有调用限额 | 未采用 |
理解这个边界对排查问题很关键:如果用户反馈「未读小红点不准」,问题大概率在客户端 SDK 的本地数据,而不是服务端接口,服务端压根没参与未读数计算。服务端的联系人列表接口返回的数据里连未读字段都没有。
跨存储组装:好友 id → 用户资料
联系人列表的展示数据要靠一次跨存储组装:friend 集合里只有 id 没有昵称头像,需要拿 friendId 批量去查用户资料,再拼成 ContactVo。和推荐列表、圈子列表的做法一脉相承,关系数据存一处,展示资料存另一处,服务层用一次批量查询把它们拼起来。这个套路在项目里已经反复出现,熟了之后看接口就知道它内部怎么走。
接口设计
两个接口共用 /messages/contacts 路径,靠 HTTP 方法区分。
| 接口 | 方法 | 路径 | 参数 | 说明 |
|---|---|---|---|---|
| 添加好友 | POST | /messages/contacts |
body:{"userId": 1} |
双写:环信 addContact + Mongo 存关系 |
| 联系人列表 | GET | /messages/contacts |
page、pagesize、keyword(昵称搜索,可省略) |
分页查好友列表 |
联系人列表的返回结构(PageResult 包裹 ContactVo 列表):
json
{
"counts": 2,
"pagesizes": 10,
"pages": 1,
"page": 1,
"items": [
{
"id": 106,
"userId": "hx106",
"avatar": "https://mf.com/106.jpg",
"nickname": "小妹",
"gender": "woman",
"age": 18,
"city": "上海"
},
{
"id": 2,
"userId": "hx2",
"avatar": "https://mf.com/2.jpg",
"nickname": "小刚",
"gender": "man",
"age": 22,
"city": "上海"
}
]
}
userId 字段返回的是环信 ID("hx" + 用户id 前缀),客户端拿它直接对接环信 SDK 发起会话,接口设计时就考虑到了下一跳的用法。
核心流程
添加好友(双写)
MongoDB 环信服务器 交友服务端 用户B(客户端) MongoDB 环信服务器 交友服务端 用户B(客户端) #mermaid-svg-JDvnENbpNxoXYE0P{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-JDvnENbpNxoXYE0P .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-JDvnENbpNxoXYE0P .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-JDvnENbpNxoXYE0P .error-icon{fill:#552222;}#mermaid-svg-JDvnENbpNxoXYE0P .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-JDvnENbpNxoXYE0P .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-JDvnENbpNxoXYE0P .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-JDvnENbpNxoXYE0P .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-JDvnENbpNxoXYE0P .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-JDvnENbpNxoXYE0P .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-JDvnENbpNxoXYE0P .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-JDvnENbpNxoXYE0P .marker{fill:#333333;stroke:#333333;}#mermaid-svg-JDvnENbpNxoXYE0P .marker.cross{stroke:#333333;}#mermaid-svg-JDvnENbpNxoXYE0P svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-JDvnENbpNxoXYE0P p{margin:0;}#mermaid-svg-JDvnENbpNxoXYE0P .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-JDvnENbpNxoXYE0P text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-JDvnENbpNxoXYE0P .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-JDvnENbpNxoXYE0P .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-JDvnENbpNxoXYE0P .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-JDvnENbpNxoXYE0P .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-JDvnENbpNxoXYE0P #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-JDvnENbpNxoXYE0P .sequenceNumber{fill:white;}#mermaid-svg-JDvnENbpNxoXYE0P #sequencenumber{fill:#333;}#mermaid-svg-JDvnENbpNxoXYE0P #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-JDvnENbpNxoXYE0P .messageText{fill:#333;stroke:none;}#mermaid-svg-JDvnENbpNxoXYE0P .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-JDvnENbpNxoXYE0P .labelText,#mermaid-svg-JDvnENbpNxoXYE0P .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-JDvnENbpNxoXYE0P .loopText,#mermaid-svg-JDvnENbpNxoXYE0P .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-JDvnENbpNxoXYE0P .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-JDvnENbpNxoXYE0P .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-JDvnENbpNxoXYE0P .noteText,#mermaid-svg-JDvnENbpNxoXYE0P .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-JDvnENbpNxoXYE0P .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-JDvnENbpNxoXYE0P .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-JDvnENbpNxoXYE0P .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-JDvnENbpNxoXYE0P .actorPopupMenu{position:absolute;}#mermaid-svg-JDvnENbpNxoXYE0P .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-JDvnENbpNxoXYE0P .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-JDvnENbpNxoXYE0P .actor-man circle,#mermaid-svg-JDvnENbpNxoXYE0P line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-JDvnENbpNxoXYE0P :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 前置:A 填答案后,服务端已通过环信把陌聊消息推给 B alt 环信注册成功 环信注册失败 POST /messages/contacts {userId: A} addContact(hxA, hxB) 成功/失败 保存 (A,B) 与 (B,A) 两条记录 OK 200,结为好友 抛 BusinessException,不写库
顺序是「先环信、后 Mongo」:环信那边失败就抛异常直接结束,Mongo 里不会留下环信侧不存在的孤儿好友关系。反过来(先写库后调环信)一旦环信调用失败,本地就多了一条「看起来是好友、实际聊不了」的脏数据。当然这个顺序也有代价,环信成功后如果写 Mongo 失败,环信侧会多一份好友关系,这是双写固有的不一致窗口,靠下一篇接口实现里的判重逻辑和重新添加来兜底。
查询联系人列表
渲染错误: Mermaid 渲染失败: Parse error on line 10: ...S: List<Friend> S->>S: CollUti -----------------------^ Expecting '()', 'SOLID_OPEN_ARROW', 'DOTTED_OPEN_ARROW', 'SOLID_ARROW', 'SOLID_ARROW_TOP', 'SOLID_ARROW_BOTTOM', 'STICK_ARROW_TOP', 'STICK_ARROW_BOTTOM', 'SOLID_ARROW_TOP_DOTTED', 'SOLID_ARROW_BOTTOM_DOTTED', 'STICK_ARROW_TOP_DOTTED', 'STICK_ARROW_BOTTOM_DOTTED', 'SOLID_ARROW_TOP_REVERSE', 'SOLID_ARROW_BOTTOM_REVERSE', 'STICK_ARROW_TOP_REVERSE', 'STICK_ARROW_BOTTOM_REVERSE', 'SOLID_ARROW_TOP_REVERSE_DOTTED', 'SOLID_ARROW_BOTTOM_REVERSE_DOTTED', 'STICK_ARROW_TOP_REVERSE_DOTTED', 'STICK_ARROW_BOTTOM_REVERSE_DOTTED', 'BIDIRECTIONAL_SOLID_ARROW', 'DOTTED_ARROW', 'BIDIRECTIONAL_DOTTED_ARROW', 'SOLID_CROSS', 'DOTTED_CROSS', 'SOLID_POINT', 'DOTTED_POINT', got 'NEWLINE'
排序是好友建立时间(created)倒序,不是「最近联系」排序,ContactVo 里没有 lastMessage、lastTime 这类字段,会话时间在客户端环信 SDK 的会话列表里有,服务端通讯录接口不掺和。keyword 搜索走的是用户资料查询时的昵称条件。
原型线框
消息 tab 首屏会话列表:
doc
┌──────────────────────────────────┐
│ 消息 11:21 │
├──────────────────────────────────┤
│ [头像] 小刚 │
│ java视频给我发一份 │
│ 11:21 │
├──────────────────────────────────┤
│ [头像] 大鹏 │
│ 布置的作业你做了么 │
│ 昨天 │
├──────────────────────────────────┤
│ [头像] 小美 │
│ 程序员报名中 │
│ 2018-01-02 │
├──────────────────────────────────┤
│ 交友 [消息] 我的 │
└──────────────────────────────────┘
要点:
- 时间显示规则:当天之内显示时间(11:21),前一天的信息显示「昨天」,更早的显示具体日期(2018-01-02)
- 用户左滑可删除信息
- 未读数小红点由客户端环信 SDK 本地维护,服务端接口不含该数据点「选择联系人」进入通讯录页:
doc
┌──────────────────────────────────┐
│ 返回 选择联系人 │
├──────────────────────────────────┤
│ 搜索用户 │
├──────────────────────────────────┤
│ 你关注的用户 │
├──────────────────────────────────┤
│ [头像] 小美 上海 │
│ [头像] 小刚 上海 │
│ [头像] 大鹏 上海 │
│ [头像] 更多 │
├──────────────────────────────────┤
│ 交友 [消息] 我的 │
└──────────────────────────────────┘
要点:
- 顶部搜索框对应接口的
keyword参数(按昵称搜) - 列表项点击后进入聊天页面;头像点击进入用户详情页
- 「更多」点击出弹框:拉黑、举报、查看聊天消息
- 点击返回后回到消息界面
异常与边界
| 场景 | 服务端行为 | 取舍说明 |
|---|---|---|
| 无好友(空列表) | CollUtil.isEmpty 判断后直接返回空 PageResult |
不查用户资料,省一次跨服务调用 |
| 好友资料已注销 | map.get(friendId) 为 null 时跳过,不进 VO 列表 |
列表静默少一条而非报错;潜在副作用是分页条数与实际展示数可能对不上 |
| 未读数获取失败 | 服务端不处理(不参与未读数);客户端读 SDK 本地计数失败时降级为不显示 | 未读数是纯展示性数据,取不到就藏起小红点,不值得为它报错打断列表加载 |
| 环信成功、Mongo 失败 | 抛异常给用户;环信侧残留好友关系 | 双写不一致窗口,用户重新添加时 Mongo 判重 + 环信幂等,重试即可修复 |
| 环信失败、Mongo 未写 | 直接抛 BusinessException,本地无脏数据 |
顺序保证:先环信后 Mongo |
| 环信与本地好友不一致 | 以 friend 集合为准展示通讯录;环信关系只影响能否发消息 |
通讯录列表读的是本地库,即使环信侧关系丢失,列表照常显示,聊天发起时才会暴露;对账可做定期比对环信 roster 与本地集合,项目未实现,属于已知简化 |
删好友的场景顺带说一句:HuanXinTemplate 里有 deleteContact 方法(对应环信 service.contact().remove()),真正的好友解除要把环信关系和 Mongo 两条记录一起删,任何一边只删一半都会复现上面的一致性问题。
验证要点
- 加好友后,双方通讯录都出现对方(验证 Mongo 双向两条记录)
- 环信后台查看 roster,确认好友关系已在环信侧注册
- 列表分页:好友数超过 pagesize 时翻页正确,keyword 按昵称过滤生效
- 未读数:A 给 B 发消息,B 的会话红点 +1,B 打开会话后归零(均为客户端 SDK 行为)
- 删除好友:在环信控制台删除好友后,本地通讯录列表仍然显示该用户(数据在 Mongo),但发起聊天会因环信关系不存在而失败,验证时别把这当 bug,这正是双存储各自独立的体现
- 换设备登录:联系人列表完整(服务端数据),未读数不延续(客户端本地数据)
常见误解澄清
| 误解 | 事实 |
|---|---|
| 联系人列表接口会返回未读消息数 | 不会。ContactVo 没有未读字段,未读数由客户端环信 SDK 本地维护 |
| friend 表是一条记录存一对好友 | 是 userId+friendId 双列、双向两条记录,查询只按 userId 等值匹配 |
| 联系人按最近聊天时间排序 | 按 created(好友建立时间)倒序,接口没有最近联系时间字段 |
| 添加好友只写数据库 | 双写:先调环信 addContact 注册 IM 关系,成功后再写 MongoDB |
| 通讯录数据在环信那边 | 列表数据来自本地 friend 集合 + 用户资料服务,环信只负责消息通道和好友注册 |
| 环信删好友后本地列表会同步消失 | 不会,两边存储独立,本地列表照样显示,只是聊天发不出去 |
官方文档
- 环信服务端 REST API(好友/联系人接口:
service.contact().add/remove) - 环信客户端 SDK:会话与未读数
总结
联系人管理的需求面很薄,但背后有三个值得记住的设计决策。其一,社交好友与 IM 联系人共用一份关系,添加时对环信和 MongoDB 双写,写入顺序「先环信后 Mongo」把不一致风险压到最小。其二,好友关系存双向两条记录,读路径退化为单字段等值查询,读多写少的数据就该这么设计。其三,未读数完全交给客户端 SDK 本地维护,服务端接口不碰它,明确能力边界比堆功能重要,如果哪天产品要求多端未读同步,再评估服务端统计方案也不迟。
下一篇联系人列表:代码实现把这些设计落到代码:ContactVo、MessagesController 的分页接口、FriendApiImpl 的 save 与 findByUserId,逐段拆解。