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条件。
packagecom.mf.model.mongo;importlombok.AllArgsConstructor;importlombok.Data;importlombok.NoArgsConstructor;importorg.bson.types.ObjectId;importorg.springframework.data.mongodb.core.mapping.Document;/** * 好友表:好友关系表 */@Data@NoArgsConstructor@AllArgsConstructor@Document(collection="friend")publicclassFriendimplementsjava.io.Serializable{privatestaticfinallongserialVersionUID=6003135946820874230L;privateObjectIdid;privateLonguserId;//用户idprivateLongfriendId;//好友idprivateLongcreated;//时间}为什么存两条而不是一条记录配一个or查询?好友列表是高频读场景,双向两条记录让查询条件退化为单字段等值匹配,配合索引就是最简单的查询路径;写入时多写一条,属于用写成本换读成本,对这种读多写少的关系数据是划算的。写入时先exists判重再save,防止重复添加产生重复记录。
存储位置上,项目的工程结构是三层:app-server 收 HTTP 请求,Dubbo 调用落到 dubbo 模块操作存储。联系人相关的类分布如下:
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列表):
{"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 发起会话,接口设计时就考虑到了下一跳的用法。
核心流程
添加好友(双写)
顺序是「先环信、后 Mongo」:环信那边失败就抛异常直接结束,Mongo 里不会留下环信侧不存在的孤儿好友关系。反过来(先写库后调环信)一旦环信调用失败,本地就多了一条「看起来是好友、实际聊不了」的脏数据。当然这个顺序也有代价,环信成功后如果写 Mongo 失败,环信侧会多一份好友关系,这是双写固有的不一致窗口,靠下一篇接口实现里的判重逻辑和重新添加来兜底。
查询联系人列表
排序是好友建立时间(created)倒序,不是「最近联系」排序,ContactVo 里没有 lastMessage、lastTime 这类字段,会话时间在客户端环信 SDK 的会话列表里有,服务端通讯录接口不掺和。keyword 搜索走的是用户资料查询时的昵称条件。
原型线框
消息 tab 首屏会话列表:
┌──────────────────────────────────┐ │ 消息 11:21 │ ├──────────────────────────────────┤ │ [头像] 小刚 │ │ java视频给我发一份 │ │ 11:21 │ ├──────────────────────────────────┤ │ [头像] 大鹏 │ │ 布置的作业你做了么 │ │ 昨天 │ ├──────────────────────────────────┤ │ [头像] 小美 │ │ 程序员报名中 │ │ 2018-01-02 │ ├──────────────────────────────────┤ │ 交友 [消息] 我的 │ └──────────────────────────────────┘要点:
- 时间显示规则:当天之内显示时间(11:21),前一天的信息显示「昨天」,更早的显示具体日期(2018-01-02)
- 用户左滑可删除信息
- 未读数小红点由客户端环信 SDK 本地维护,服务端接口不含该数据点「选择联系人」进入通讯录页:
┌──────────────────────────────────┐ │ 返回 选择联系人 │ ├──────────────────────────────────┤ │ 搜索用户 │ ├──────────────────────────────────┤ │ 你关注的用户 │ ├──────────────────────────────────┤ │ [头像] 小美 上海 │ │ [头像] 小刚 上海 │ │ [头像] 大鹏 上海 │ │ [头像] 更多 │ ├──────────────────────────────────┤ │ 交友 [消息] 我的 │ └──────────────────────────────────┘要点:
- 顶部搜索框对应接口的
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,逐段拆解。