Java 项目实战:交友项目 - 即时通信总结:全链路复盘、可复用清单与自检

概述

本篇是即时通信模块的收官总结。整个模块做的事用一句话说清:接入环信云 IM 并完成用户体系集成,打通「注册 → 凭证 → 好友 → 聊天 → 联系人」的完整链路。前面各篇已经把每个环节的代码逐个讲透,这篇不重复细节,只做四件事:把整个模块串成一张知识地图,把可复用的套路和踩过的坑沉淀成清单,给出上线前的自检表和技术练习。

纲要

  • 整体一句话与知识地图(Mermaid 全链路图)
  • 工程终态(目录树 + 各篇分工对照)
  • 可复用套路清单(核心部分,五条套路:套路 / 本篇怎么用 / 后续哪里用)
  • 关键坑回顾(12 条浓缩表)
  • 常见误解澄清
  • 上线前自检清单(可勾选)
  • 技术练习(每项:改哪里 / 怎么验证)
  • 下一章预告:小视频方案## 知识地图
    先看整章的脉络。从「要不要自己做 IM」这个选型问题出发,到联系人列表查询结束,五个环节环环相扣:

#mermaid-svg-6tiOxItWfnkbjgyF{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-6tiOxItWfnkbjgyF .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-6tiOxItWfnkbjgyF .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-6tiOxItWfnkbjgyF .error-icon{fill:#552222;}#mermaid-svg-6tiOxItWfnkbjgyF .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-6tiOxItWfnkbjgyF .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-6tiOxItWfnkbjgyF .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-6tiOxItWfnkbjgyF .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-6tiOxItWfnkbjgyF .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-6tiOxItWfnkbjgyF .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-6tiOxItWfnkbjgyF .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-6tiOxItWfnkbjgyF .marker{fill:#333333;stroke:#333333;}#mermaid-svg-6tiOxItWfnkbjgyF .marker.cross{stroke:#333333;}#mermaid-svg-6tiOxItWfnkbjgyF svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-6tiOxItWfnkbjgyF p{margin:0;}#mermaid-svg-6tiOxItWfnkbjgyF .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-6tiOxItWfnkbjgyF .cluster-label text{fill:#333;}#mermaid-svg-6tiOxItWfnkbjgyF .cluster-label span{color:#333;}#mermaid-svg-6tiOxItWfnkbjgyF .cluster-label span p{background-color:transparent;}#mermaid-svg-6tiOxItWfnkbjgyF .label text,#mermaid-svg-6tiOxItWfnkbjgyF span{fill:#333;color:#333;}#mermaid-svg-6tiOxItWfnkbjgyF .node rect,#mermaid-svg-6tiOxItWfnkbjgyF .node circle,#mermaid-svg-6tiOxItWfnkbjgyF .node ellipse,#mermaid-svg-6tiOxItWfnkbjgyF .node polygon,#mermaid-svg-6tiOxItWfnkbjgyF .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-6tiOxItWfnkbjgyF .rough-node .label text,#mermaid-svg-6tiOxItWfnkbjgyF .node .label text,#mermaid-svg-6tiOxItWfnkbjgyF .image-shape .label,#mermaid-svg-6tiOxItWfnkbjgyF .icon-shape .label{text-anchor:middle;}#mermaid-svg-6tiOxItWfnkbjgyF .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-6tiOxItWfnkbjgyF .rough-node .label,#mermaid-svg-6tiOxItWfnkbjgyF .node .label,#mermaid-svg-6tiOxItWfnkbjgyF .image-shape .label,#mermaid-svg-6tiOxItWfnkbjgyF .icon-shape .label{text-align:center;}#mermaid-svg-6tiOxItWfnkbjgyF .node.clickable{cursor:pointer;}#mermaid-svg-6tiOxItWfnkbjgyF .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-6tiOxItWfnkbjgyF .arrowheadPath{fill:#333333;}#mermaid-svg-6tiOxItWfnkbjgyF .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-6tiOxItWfnkbjgyF .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-6tiOxItWfnkbjgyF .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-6tiOxItWfnkbjgyF .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-6tiOxItWfnkbjgyF .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-6tiOxItWfnkbjgyF .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-6tiOxItWfnkbjgyF .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-6tiOxItWfnkbjgyF .cluster text{fill:#333;}#mermaid-svg-6tiOxItWfnkbjgyF .cluster span{color:#333;}#mermaid-svg-6tiOxItWfnkbjgyF 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-6tiOxItWfnkbjgyF .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-6tiOxItWfnkbjgyF rect.text{fill:none;stroke-width:0;}#mermaid-svg-6tiOxItWfnkbjgyF .icon-shape,#mermaid-svg-6tiOxItWfnkbjgyF .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-6tiOxItWfnkbjgyF .icon-shape p,#mermaid-svg-6tiOxItWfnkbjgyF .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-6tiOxItWfnkbjgyF .icon-shape .label rect,#mermaid-svg-6tiOxItWfnkbjgyF .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-6tiOxItWfnkbjgyF .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-6tiOxItWfnkbjgyF .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-6tiOxItWfnkbjgyF :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} IM 选型

自研 vs 第三方
环信接入

四件套第五次应用
用户体系集成闭环

注册 / 凭证下发 / 反查
好友双写

环信联系人 + Friend 集合
联系人管理

分页查询 / keyword 筛选
聊天直达

消息走环信通道

选型的结论值得记一下:项目定位中型项目,自研 IM(Netty + WebSocket + MQ + MongoDB 那一套)人力物力成本太高,所以直接接第三方环信,成本低、开发周期短,代价是可控性差、聊天数据不在自己手里。这个取舍在选型时就要想清楚,不是写完代码才想起来。

各环节与本章篇目的对应关系,方便回查:

环节 代码落点 对应篇目
IM 选型与环信介绍 无代码 即时通信:介绍 / 环信通信:介绍
环信接入四件套 mf-autoconfig 的 HuanXin 三件 环信通信:API 介绍 / 抽取组件
注册环信(新用户 + 存量) UserService#loginVerification + HuanxinTest#register 将新用户注册到环信
凭证下发 HuanXinController + HuanXinService 查询用户环信账号密码
消息链路验证 环信 Console 发消息 + App 接收 环信后台发送消息测试
反向反查 MessagesController#userinfo 根据环信 ID 反查用户
好友申请 / 添加好友 mfService#replyQuestions + MessagesService#contacts 好友申请测试 / 添加好友系列
联系人列表 MessagesService#findFriends 联系人列表系列

本章工程终态

整章结束,工程里新增(或改动)的关键文件如下:

tree 复制代码
mf
├── mf-autoconfig
│   └── src/main/java/com/mf/autoconfig
│       ├── properties/HuanXinProperties.java    # appkey/clientId/clientSecret
│       └── template/HuanXinTemplate.java        # createUser/addContact/deleteContact/sendMsg
├── mf-model
│   └── src/main/java/com/mf/model
│       ├── domain/User.java                     # 新增 hxUser/hxPassword 字段
│       ├── mongo/Friend.java                    # 好友关系集合 userId/friendId/created
│       └── vo/HuanXinUserVo.java                # 凭证下发 VO
├── mf-dubbo
│   └── mf-dubbo-mongo/.../api/FriendApiImpl.java   # 双写判重 + 分页查好友
├── mf-app-server
│   ├── src/main/java/com/mf/server
│   │   ├── controller/HuanXinController.java    # GET /huanxin/user
│   │   ├── controller/MessagesController.java   # /messages/userinfo /messages/contacts
│   │   └── service/MessagesService.java         # 反查 / 加好友 / 联系人列表
│   └── src/test/java/com/wb/test/HuanxinTest.java  # 批量注册存量用户
└── mf-app-server/src/main/resources/application.yml  # mf.huanxin 三项配置

两个常量是全章的锚点,正向注册和反向反查都靠它们对齐:

java 复制代码
// mf-commons/.../Constants.java
public static final String INIT_PASSWORD = "123456";
public static final String HX_USER_PREFIX = "hx";

映射规则就一行:环信 ID = HX_USER_PREFIX + userId,即 hx106。忘了这条规则,后面所有的「对不上号」问题都会找上门。

可复用套路清单

这一节是全章真正能带走的东西。每条套路标注了在本章怎么用、后续哪些场景还会用到:

套路 本章怎么用 后续哪里用
双体系账号映射 前缀映射:hx + userId,User 表加 hxUser/hxPassword 落库 任何接第三方账号体系的项目(支付商户号、云厂商账号、OSS 存储桶映射)
凭证下发三铁律 GET /huanxin/user 返回当前用户环信凭证 任何「客户端需要密钥才能调第三方」的场景
好友关系双写 先环信 addContact 再落 friend 集合,双向两条记录 关注/粉丝、点赞关系、任何「第三方状态 + 本地状态」并存的关系数据
第三方能力四件套 Properties + Template + 自动配置 + yml,环信是第五次应用 后续 FastDFS、阿里云视频点播等一切第三方集成
分层验证 Console 手发消息 → REST 工具测接口 → App 端到端 所有第三方集成联调,出问题时快速定位责任层

其中三条值得展开一句:

双体系账号映射 ,前缀映射(hx106)胜在零成本、规则透明,代价是映射规则写死在代码里;如果第三方对用户名有更苛刻的字符限制(长度、特殊字符),或者要做账号解绑换绑,就该换成显式映射表(一张 user_id ↔ third_id 的表)。选型判断标准是映射规则够不够「纯数字拼前缀」这么简单。

凭证下发三铁律 :userId 只从 token 解(UserHolder.getUserId(),绝不信前端传参);只返当前用户自己的凭证(接口里没有 id 参数,天然隔离);生产环境必须换掉 INIT_PASSWORD = "123456" 这种全员弱凭证,改为环信的用户级 token 或按用户随机密码。教学项目图省事用统一密码,这个债要在练习里还。

好友双写 :顺序是「先环信后本地」,环信失败直接抛异常回滚语义(实际上没回滚,只是不落库),环信成功才写 friend 集合。双向两条记录(userId/friendId 各存一份)是为了查「我的好友」和「谁加了我」都不用扫全表。双写天然存在不一致窗口,兜底靠对账任务,见练习。

关键坑回顾

整章 12 个坑,按链路顺序排:

# 坑 现象与根因 怎么防
1 frinedId 拼写 bug FriendApiImpl#save 判重条件写成 Criteria.where("frinedId"),字段根本不存在,exists 恒为 false,判重完全失效,重复加好友无限插重复记录 字符串字段名无编译期检查;用唯一索引兜底(见练习一)
2 环信注册失败被吞 createUser catch 里只打一句「创建环信用户失败~」,不带异常详情不带用户名;调用方拿到 false 后静默跳过 hxUser 落库,用户对此毫不知情 日志带上下文;注册失败要有补偿手段(练习二)
3 重复 createUser 报「已存在」也返回 false 环信对已存在用户抛异常,Template 统一 catch 返回 false;批量注册脚本对已注册用户全部误判为失败 区分「业务性失败」和「幂等成功」,按异常类型分档返回
4 INIT_PASSWORD 弱凭证 全员 123456,知道规则就能登任何人的环信账号 生产换用户级 token 或随机密码
5 凭证接口鉴权 /huanxin/user 若漏配 JWT 拦截器,任何人可拿到他人环信凭证 接口纳入统一鉴权链,用 token 解出的 userId 查库
6 hasLiked 被喜欢污染 消息模块「谁喜欢我」列表复用 day05 的 Movement 时间线查询,hasLiked 反映的是当前用户行为,混进被喜欢列表造成数据污染 列表查询和详情查询的字段语义分开,别复用带状态的查询
7 好友双写不一致 先环信后本地,环信成功、本地落库失败时,环信有联系人但 friend 集合没记录,联系人列表查不到 对账任务兜底(练习四)
8 聊天记录数据边界在环信 服务端不落聊天消息,Console 与 REST History API 有保留期限,业务想存聊天记录得走环信回调 接入前想清楚数据归属,需要留存就订阅回调入库
9 username 大小写 createUser 里 username.toLowerCase() 注册,但凭证下发直接回库取 hxUser;两处大小写不一致,App 登环信报用户不存在 映射规则统一小写,注册与下发共用同一份落库值
10 漏拼 huanXinId 静默缺失 陌生人消息 JSON 里漏拼 huanXinId 字段,客户端解析头像、跳转详情静默失败,不报任何错 消息体定义成 VO 类序列化,别手拼 Map;字段缺失要有契约测试
11 total 写死 0 findFriends 返回 new PageResult(page, pagesize, 0l, vos),前端分页组件拿不到总数,翻页失灵 分页先 count 再 skip/limit,total 是分页契约的一部分
12 keyword 筛选分层错位 Mongo 侧先分页切 10 条,MySQL 侧再按 nickname 筛,先切后筛,符合条件的记录可能根本没进这一页 筛选条件下推到查询层,或按筛选条件先查 id 再分页

常见误解澄清

误解 实际情况
接了环信,聊天数据就归自己管 消息存储、送达、历史记录都在环信侧,服务端只是「账号 + 关系」的管理者。要留存聊天记录得走环信服务端回调
环信注册成功 = 用户体系集成完成 还差两环:凭证下发接口让 App 拿到账号密码,App 端用凭证自动登录环信。三环缺一,聊天都跑不通
createUser 返回 false 说明用户没建上 环信报「用户已存在」也会走进 catch 返回 false。false 不等于没建上,要按异常类型区分
好友关系只在环信侧维护就够 环信联系人接口不支持分页、筛选、关联 UserInfo。联系人列表的展示需求(分页、昵称搜索、头像昵称)全靠本地 friend 集合 + MySQL
双写必须做成分布式事务 本章的量级用「先环信后本地 + 对账兜底」就够。事务强度按业务损失定,加好友丢一条可补偿,没必要上重型方案
total 写 0 只是个数字,不影响功能 前端分页组件依赖 total 判断还有没有下一页,写 0 等于宣告「只有一页」,翻页直接失灵

上线前自检清单

按链路顺序勾,前面不过后面全白测:

  • 环信 Console 有用户 :注册一个新号,Console 用户列表出现 hx+id;存量用户跑过批量注册脚本
  • 凭证接口带 JWT 返回正确 :带 token 请求 GET /huanxin/user,返回的 username/password 与库里一致;不带 token 返回 401
  • 加好友双写成功 :A 加 B 后,环信 Console 里 A 的联系人有 B,friend 集合出现 userId/friendId 互换的两条记录;重复加不产生新记录(修完坑一后验证)
  • 联系人列表返回 huanXinId :GET /messages/contacts 返回的每条 ContactVo.userId 是 hx+id 格式,头像昵称齐全
  • 聊天消息 App 可收:Console 给测试号发一条文本,App 端收到;App 端互发也通
  • 环信 id 反查正确 :GET /messages/userinfo?huanxinId=hx106 返回对应用户详情,传错 id 返回合理错误而非 500

客户端联调两个高频故障,多半不是服务端的锅:App 连不上环信,先查客户端 APPKey 配置与应用是否匹配;消息展示错乱,先清客户端缓存再重测。

技术练习

作业都转成了技术任务,每项给「改哪里」和「怎么验证」:

一、修 frinedId 拼写 bug(必做)

改哪里:FriendApiImpl#save 里两处 Criteria.where("frinedId") 改为 "friendId";再给 friend 集合建复合唯一索引:

javascript 复制代码
// mongo shell:对 friend 集合建 userId+friendId 复合唯一索引
db.friend.createIndex({ userId: 1, friendId: 1 }, { unique: true })

存量重复数据先按 {userId, friendId} 分组去重再建索引,否则建索引直接失败。怎么验证:同一对用户重复调加好友接口,friend 集合不新增记录。顺带体会一层:Criteria 的字段名是纯字符串,拼错编译器不管;如果这套关系落在 MySQL 用 MyBatis-Plus 的 LambdaQueryWrapper 写,方法引用拼错直接编译不过,这就是「字符串 key 静默失效」家族的治本对比。

二、补环信注册失败补偿

改哪里:给 HuanXinTemplate#createUser 的日志补上异常详情和用户名;新增一个定时任务,扫描 hxUser 为空的用户重试注册。怎么验证:手动造一条 hxUser 为空的用户,任务跑完后 hxUser/hxPassword 补齐、环信 Console 可见。

三、凭证接口改用户级 token

改哪里:注册用户时为每个人生成随机密码(或接环信用户级 token 接口),替换 INIT_PASSWORD 常量的使用点。怎么验证:两个用户的环信凭证不同;用 A 的凭证登 B 的环信账号失败。

四、好友双写对账任务

改哪里:定时比对环信联系人接口返回的关系集合与 friend 集合,差集补偿写回(环信有本地无 → 补落库;本地有环信无 → 补 addContact)。怎么验证:手动删掉一侧数据,任务跑完两侧重新对齐。

五、取消好友功能(工程未实现,完整闭环练习)

改哪里:MessagesService 加 deleteFriend(Long friendId) 方法,先调 huanXinTemplate.deleteContact(Template 里已有此方法但无业务调用),再双向删除 friend 集合两条记录:

java 复制代码
package com.mf.server.service;

import com.mf.autoconfig.template.HuanXinTemplate;
import com.mf.commons.utils.Constants;
import com.mf.dubbo.api.FriendApi;
import com.mf.server.interceptor.UserHolder;
import org.apache.dubbo.config.annotation.DubboReference;
import org.springframework.stereotype.Service;

@Service
public class FriendDeleteService {

    @DubboReference
    private FriendApi friendApi;

    @Autowired
    private HuanXinTemplate huanXinTemplate;

    //取消好友:先删环信联系人,再双向删本地关系
    public void deleteFriend(Long friendId) {
        Long userId = UserHolder.getUserId();
        Boolean removed = huanXinTemplate.deleteContact(
                Constants.HX_USER_PREFIX + userId,
                Constants.HX_USER_PREFIX + friendId);
        if (!removed) {
            throw new RuntimeException("环信删除联系人失败");
        }
        friendApi.delete(userId, friendId);
    }
}

配套要给 FriendApi/FriendApiImpl 补 delete 方法(双向删,参照 save 的对称写法),注意别把 frinedId 的坑再抄一遍。怎么验证:删除后环信 Console 里联系人消失,friend 集合两条记录都没了,双方联系人列表同步减少。

六、消息模块查询补全(基础巩固)

改哪里:按接口文档实现「谁点赞我 / 谁评论我 / 谁喜欢我」三个列表(数据已在 day05 的 Comment/Movement 里,注意别踩坑六的 hasLiked 污染),以及系统公告分页查询(MySQL 公告表,title/content/created,标准分页)。怎么验证:三列表数据与操作记录一致,公告分页 total 正确。

下一章预告

第七章「附近的人」(MongoDB 地理位置检索)将在本章之后展开,那章的关键词是 2dsphere 索引和 $near 查询。再下一章进入第八章「小视频方案」,核心是 FastDFS 分布式文件系统,还记得之前文件存储方案篇里「本地存储 / 云存储 / 自建分布式存储」的三岔口吗?小视频的大文件场景正好把自建这条路走一遍:上传、缩略图、视频点播全要过 FastDFS 的 Tracker 和 Storage。

总结

第六章的技术含量不在任何单个接口,而在链路:选型定了接环信,四件套把第三方能力包成 Template,用户体系集成用一条 hx + userId 映射规则把两套账号焊死,好友双写让「关系」在两边各有一份账,联系人列表再把本地关系查询做成产品功能。回头看,最容易翻车的全是「字符串约定」,拼错的字段名、漏拼的消息字段、大小写不一的 username,这类错编译器看不见、运行时静默,唯一可靠的防线是分层验证加唯一索引这类「机器强制的约定」。套路清单里五条能带走,坑表里十二条最好别亲自再踩一遍。

相关推荐
威哥爱编程1 小时前
Python 智能体开发实战 07:让它查课表它张口就编?给 Agent 接上真正能动手的工具
python·agent
文丑颜不良啊1 小时前
LeetCode1501~1520题目解析
java·leetcode
2601_962885721 小时前
按主板/科创/创业/北交分板扫描选股
python
威哥爱编程1 小时前
Python 智能体开发实战 08:上线第一天就挂?给 Agent 装上重试、超时和降级三条保险丝
python·agent
Dmz丶1 小时前
前端转 AI 100 天 Day 35:结构化输出——让 LLM 稳定返回 JSON
python
在世修行1 小时前
智学文枢:数据管理服务
python·审计日志·pyside·数据清理·孤儿文件
日日大王学编程1 小时前
DeepSeekHarness开源Agent底座详解
python·开源·deep learning
Eider19982 小时前
python基础语法之并发基础练习
开发语言·python
长不大的杨瑞杰2 小时前
OpenCode v2 LSP 实战:内置语言服务器、自定义配置与诊断闭环
java·人工智能·typescript·lsp·opencode