Java 项目实战:交友项目 - 联系人管理需求分析

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 集合 + 用户资料服务,环信只负责消息通道和好友注册
环信删好友后本地列表会同步消失 不会,两边存储独立,本地列表照样显示,只是聊天发不出去

官方文档

总结

联系人管理的需求面很薄,但背后有三个值得记住的设计决策。其一,社交好友与 IM 联系人共用一份关系,添加时对环信和 MongoDB 双写,写入顺序「先环信后 Mongo」把不一致风险压到最小。其二,好友关系存双向两条记录,读路径退化为单字段等值查询,读多写少的数据就该这么设计。其三,未读数完全交给客户端 SDK 本地维护,服务端接口不碰它,明确能力边界比堆功能重要,如果哪天产品要求多端未读同步,再评估服务端统计方案也不迟。

下一篇联系人列表:代码实现把这些设计落到代码:ContactVo、MessagesController 的分页接口、FriendApiImpl 的 save 与 findByUserId,逐段拆解。

相关推荐
lazy H1 小时前
【计算机网络】第 13 篇:NAT 和端口映射是什么?为什么本地服务外网访问不了?
java·网络·网络协议·学习·计算机网络·408
学到老活到老1 小时前
第 22 集:Filter、Interceptor、AOP 都能拦截,为什么不能互换?
java·后端·spring
lusklusklusk1 小时前
Python 全局变量与局部变量区别——global 关键字、__main__ 块与作用域示例
开发语言·前端·python
sun03222 小时前
【JDK 动态代理】 和 【CGLIB 代理】对比
java·数据库·sql
文丑颜不良啊2 小时前
LeetCode1261~1280题目解析
java·leetcode
郭小李2 小时前
spring 中的 @EnableWebMvc注解和WebMvcConfigurer接口
java·后端·spring
马剑威(威哥爱编程)2 小时前
Python 智能体开发实战 01:装了三遍环境还在报错?虚拟环境和 DeepSeek 一次配到位
开发语言·人工智能·python·agent
octopus_c2 小时前
C++ 入门
开发语言·c++·算法
步行cgn2 小时前
JSON.stringify 详解:把 JavaScript 对象变成 JSON 字符串
开发语言·javascript·json