SpringBoot 即时聊天 IM 完整实现(HTTP会话管理 + WebSocket实时推送 + 离线消息)
一、前言
近期基于若依(RuoYi-Vue)后台框架自研一套站内即时聊天模块,完整覆盖会话创建、会话列表、历史消息、未读标记、实时消息推送、离线消息补发、心跳保活、Token鉴权 等核心能力。
整体技术架构分层清晰:
-
Controller:HTTP 接口,提供前端会话、历史消息、已读标记等查询操作
-
Service:核心业务逻辑,会话防重复创建、消息入库、未读数统计、离线消息处理
-
Mapper + MyBatis:复杂联表查询,实时计算用户专属未读消息、关联用户昵称头像
-
WebSocket:长连接实时推送、JWT鉴权、心跳PING/PONG、在线用户管理、离线消息推送
-
MySQL数据表:chat_session会话表 + chat_message消息表,配套唯一索引、联合查询索引解决并发与查询性能问题
本文完整贴出建表SQL、实体DTO、Controller、Service、MapperXML、WebSocket全套源码并附带设计思路、踩坑解决方案,可直接集成到现有SpringBoot后台项目。
二、整体设计思路
1. 会话唯一设计(解决重复创建会话并发问题)
两人聊天仅存在唯一一条会话记录,规则:
-
不区分发起人与接收人,统一
minId=小用户ID、maxId=大用户ID存储 -
数据库对
from_user_id, to_user_id建立联合唯一索引,杜绝重复会话 -
并发同时创建会话时捕获唯一索引冲突,二次查询返回已有会话,避免重复数据
2. 未读数实时计算方案
放弃会话表冗余未读字段单一计数,采用实时子查询统计:
-
联表
chat_message统计当前登录用户所有未读消息 -
支持多端登录、精准区分"发给我的未读",避免不同用户查看同一会话未读数错乱
3. 事务与解耦优化
消息入库、会话更新、未读数自增放入事务;WebSocket消息推送放到事务外部执行:
-
防止长连接推送阻塞数据库事务持有
-
推送失败不影响消息持久化,用户可通过会话列表轮询拉取离线消息兜底
4. WebSocket 安全设计
-
握手阶段校验URL路径userId + URL携带JWT Token双重校验
-
本地解析JWT密钥和后台登录体系统一,校验过期、非法Token直接关闭连接
-
单用户仅保留一条在线连接,新连接自动剔除旧连接
-
PING/PONG心跳保活,处理前端断线重连场景
-
发送消息加同步锁,规避多线程并发发送报通道异常
5. 离线消息补发
用户WebSocket上线时自动查询所有未读消息逐条推送,推送完成批量标记已读,避免重复推送。
三、MySQL 数据库建表语句
3.1 chat_session 聊天会话表
存储两个用户一对一会话基础信息,联合唯一索引保证双人仅一条会话
SQL
/*
Navicat Premium Data Transfer
Source Server : 46
Source Server Type : MySQL
Source Server Version : 80045
Source Host : 192.168.1.46:3306
Source Schema : shan_guo_dev
Target Server Type : MySQL
Target Server Version : 80045
File Encoding : 65001
Date: 10/07/2026 09:45:30
*/
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- Table structure for chat_session
-- ----------------------------
DROP TABLE IF EXISTS `chat_session`;
CREATE TABLE `chat_session` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '会话主键ID',
`from_user_id` bigint NOT NULL COMMENT '小ID用户',
`to_user_id` bigint NOT NULL COMMENT '大ID用户',
`last_content` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '会话最后一条消息内容(列表预览)',
`last_msg_time` datetime NULL DEFAULT NULL COMMENT '会话最后一条消息发送时间(排序依据)',
`unread_count` int NULL DEFAULT 0 COMMENT '当前会话未读消息数量(冗余字段)',
`create_time` datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '会话创建时间',
`update_time` datetime NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '会话更新时间',
PRIMARY KEY (`id`) USING BTREE,
-- 双人会话唯一约束,防止重复创建会话
UNIQUE INDEX `uk_user_pair`(`from_user_id`, `to_user_id`) USING BTREE,
INDEX `idx_from`(`from_user_id`) USING BTREE,
INDEX `idx_to`(`to_user_id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 3 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '聊天会话表' ROW_FORMAT = Dynamic;
SET FOREIGN_KEY_CHECKS = 1;
优化说明:原SQL存在重复唯一索引 uk_from_to、uk_user_pair,删除重复索引仅保留 uk_user_pair,减少索引占用。
3.2 chat_message 聊天消息表
存储每条聊天记录,联合索引加速未读消息查询
SQL
/*
Navicat Premium Data Transfer
Source Server : 46
Source Server Type : MySQL
Source Server Version : 80045
Source Host : 192.168.1.46:3306
Source Schema : shan_guo_dev
Target Server Type : MySQL
Target Server Version : 80045
File Encoding : 65001
Date: 10/07/2026 09:45:38
*/
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- Table structure for chat_message
-- ----------------------------
DROP TABLE IF EXISTS `chat_message`;
CREATE TABLE `chat_message` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '消息主键ID',
`session_id` bigint NOT NULL COMMENT '所属会话ID',
`sender_id` bigint NOT NULL COMMENT '消息发送人用户ID',
`receiver_id` bigint NOT NULL COMMENT '消息接收人用户ID',
`content` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '消息内容',
`msg_type` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '1' COMMENT '消息类型:1-文本 2-图片',
`read_status` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '0' COMMENT '读取状态:0-未读 1-已读',
`create_time` datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '消息发送时间',
PRIMARY KEY (`id`) USING BTREE,
-- 根据会话查询历史消息索引
INDEX `idx_session`(`session_id`) USING BTREE,
-- 用户未读消息查询联合索引,优化离线消息加载
INDEX `idx_receiver_read`(`receiver_id`, `read_status`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 11 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '聊天消息表' ROW_FORMAT = Dynamic;
SET FOREIGN_KEY_CHECKS = 1;
四、实体类 & DTO 完整代码
4.1 ChatSession 会话实体
Java
package com.para.system.chat.domain;
import lombok.Data;
import java.util.Date;
/**
* 聊天会话实体
*/
@Data
public class ChatSession {
/** 会话主键 */
private Long id;
/** 较小用户ID */
private Long fromUserId;
/** 较大用户ID */
private Long toUserId;
/** 最后一条消息内容 */
private String lastContent;
/** 最后消息时间 */
private Date lastMsgTime;
/** 冗余未读数 */
private Integer unreadCount;
/** 当前用户实时未读数(查询计算字段) */
private Integer realSelfUnread;
/** 创建时间 */
private Date createTime;
/** 更新时间 */
private Date updateTime;
// 联表sys_user查询字段
/** 对方昵称 */
private String targetNickName;
/** 对方头像 */
private String targetAvatar;
}
4.2 ChatMessage 消息实体
Java
package com.para.system.chat.domain;
import lombok.Data;
import java.util.Date;
/**
* 聊天消息实体
*/
@Data
public class ChatMessage {
/** 消息主键 */
private Long id;
/** 会话ID */
private Long sessionId;
/** 发送人ID */
private Long senderId;
/** 接收人ID */
private Long receiverId;
/** 消息内容 */
private String content;
/** 消息类型 1文本 2图片 */
private String msgType;
/** 读取状态 0未读 1已读 */
private String readStatus;
/** 发送时间 */
private Date createTime;
}
4.3 ChatMsgDTO WebSocket传输DTO
前后端WebSocket交互统一载体
Java
package com.para.system.chat.domain;
import lombok.Data;
/**
* WebSocket消息传输DTO
*/
@Data
public class ChatMsgDTO {
/** 会话ID */
private Long sessionId;
/** 接收人ID */
private Long receiverId;
/** 消息内容 */
private String content;
/** 消息类型 1文本 2图片 */
private String msgType;
}
4.4 ChatService 顶层接口
Java
package com.para.system.chat.service;
import com.para.system.chat.domain.ChatMessage;
import com.para.system.chat.domain.ChatSession;
import com.para.system.chat.domain.ChatMsgDTO;
import java.util.List;
public interface ChatService {
/**
* 获取或创建双人会话
*/
ChatSession getOrCreateSession(Long fromUid, Long toUid);
/**
* 保存消息并推送
*/
void saveAndPushMessage(Long senderId, ChatMsgDTO dto);
/**
* 查询会话历史消息
*/
List<ChatMessage> history(Long sessionId);
/**
* 查询用户会话列表,支持昵称搜索
*/
List<ChatSession> sessionList(Long uid, String nickName);
/**
* 批量标记会话全部消息已读
*/
void markAllRead(Long sessionId, Long uid);
/**
* 用户上线推送离线未读消息
*/
void pushOfflineMessage(Long uid);
}
五、代码分层实现
5.1 Controller 层:ChatController HTTP接口
提供前端所有同步查询接口:创建会话、会话列表(支持昵称搜索)、历史消息分页、标记消息已读。
统一继承项目基础控制器,内置分页工具、登录用户工具类。
Java
package com.para.system.chat.controller;
import com.para.common.core.controller.BaseController;
import com.para.common.core.domain.AjaxResult;
import com.para.common.core.page.TableDataInfo;
import com.para.common.utils.SecurityUtils;
import com.para.system.chat.domain.ChatSession;
import com.para.system.chat.service.ChatService;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import java.util.List;
/**
* 聊天接口控制器
* 提供前端HTTP接口:会话管理、消息查询、已读标记
*/
@RestController
@RequestMapping("/chat")
public class ChatController extends BaseController {
@Resource
private ChatService chatService;
/**
* 获取或创建与对方的会话
* @param toUserId 对方用户ID
*/
@GetMapping("/session")
public AjaxResult session(@RequestParam Long toUserId) {
try {
Long userId = SecurityUtils.getLoginUser().getUserId();
ChatSession session = chatService.getOrCreateSession(userId, toUserId);
return AjaxResult.success(session);
} catch (RuntimeException e) {
return AjaxResult.error(e.getMessage());
}
}
/**
* 获取当前用户的会话列表
* 按最后消息时间倒序,支持对方昵称模糊搜索
*/
@GetMapping("/sessionList")
public TableDataInfo sessionList(@RequestParam(required = false) String nickName) {
startPage();
Long userId = SecurityUtils.getLoginUser().getUserId();
List<ChatSession> list = chatService.sessionList(userId, nickName);
return getDataTable(list);
}
/**
* 获取会话历史消息(分页)
*/
@GetMapping("/history")
public TableDataInfo history(@RequestParam Long sessionId) {
startPage();
return getDataTable(chatService.history(sessionId));
}
/**
* 标记会话所有消息为已读,清空未读计数
*/
@PostMapping("/read")
public AjaxResult read(@RequestParam Long sessionId) {
Long userId = SecurityUtils.getLoginUser().getUserId();
chatService.markAllRead(sessionId, userId);
return AjaxResult.success();
}
}
5.2 Service 业务实现层 ChatServiceImpl
核心业务处理,包含会话防并发创建、消息事务入库、WebSocket推送、离线消息补发、批量已读标记。
Java
package com.para.system.chat.service.impl;
import com.para.common.utils.SecurityUtils;
import com.para.system.chat.domain.ChatMessage;
import com.para.system.chat.domain.ChatSession;
import com.para.system.chat.domain.ChatMsgDTO;
import com.para.system.chat.mapper.ChatMessageMapper;
import com.para.system.chat.mapper.ChatSessionMapper;
import com.para.system.chat.service.ChatService;
import com.para.system.chat.websocket.ChatWebSocketServer;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
import java.util.Date;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 聊天业务实现类
* 功能:会话管理、消息存储、未读计数、消息推送、离线消息补发
*/
@Slf4j
@Service
public class ChatServiceImpl implements ChatService {
@Resource
private ChatSessionMapper sessionMapper;
@Resource
private ChatMessageMapper msgMapper;
/**
* 获取或创建两个用户之间的聊天会话(防并发重复创建)
* @param fromUid 当前登录用户ID
* @param toUid 对方用户ID
* @return 会话对象
*/
@Override
public ChatSession getOrCreateSession(Long fromUid, Long toUid) {
// 禁止自己和自己聊天
if (fromUid.equals(toUid)) {
throw new RuntimeException("不允许与自己创建聊天会话");
}
// 统一大小ID存储,保证双人会话唯一
Long minId = Math.min(fromUid, toUid);
Long maxId = Math.max(fromUid, toUid);
Long loginUid = SecurityUtils.getLoginUser().getUserId();
// 查询已有会话
ChatSession session = sessionMapper.selectByUserPair(minId, maxId, loginUid);
if (session == null) {
try {
session = new ChatSession();
session.setFromUserId(minId);
session.setToUserId(maxId);
sessionMapper.insertSession(session);
} catch (Exception e) {
// 唯一索引冲突:其他线程已创建,日志告警不抛出异常
log.warn("会话创建冲突,重新查询会话记录");
}
// 冲突后二次查询返回最新会话
return sessionMapper.selectByUserPair(minId, maxId, loginUid);
}
return session;
}
/**
* 保存消息并异步推送给接收方
* 数据库操作在事务内,WebSocket推送在事务外,避免长连接阻塞事务
*/
@Override
public void saveAndPushMessage(Long senderId, ChatMsgDTO dto) {
Long receiverId = dto.getReceiverId();
// 事务入库、更新会话、未读数+1
saveMessageInTransaction(senderId, dto);
// 事务外推送消息,推送失败不影响消息存储
try {
ChatWebSocketServer.sendToUser(receiverId, dto);
} catch (Exception e) {
log.error("WebSocket推送消息失败,发送人:{},接收人:{}", senderId, receiverId, e);
}
}
/**
* 事务方法:保存消息、更新会话最后消息、未读数自增
*/
@Transactional(rollbackFor = Exception.class)
public void saveMessageInTransaction(Long senderId, ChatMsgDTO dto) {
Long sessionId = dto.getSessionId();
Long receiverId = dto.getReceiverId();
Date now = new Date();
// 1. 插入聊天消息,默认未读0
ChatMessage msg = new ChatMessage();
msg.setSessionId(sessionId);
msg.setSenderId(senderId);
msg.setReceiverId(receiverId);
msg.setContent(dto.getContent());
msg.setMsgType(dto.getMsgType());
msg.setReadStatus("0");
msg.setCreateTime(now);
msgMapper.insert(msg);
// 2. 更新会话最后一条消息内容和时间
sessionMapper.updateLastMsg(sessionId, dto.getContent(), now);
// 3. 当前会话未读计数+1
sessionMapper.incrUnread(sessionId);
}
/**
* 查询会话分页历史消息
*/
@Override
public List<ChatMessage> history(Long sessionId) {
return msgMapper.selectBySession(sessionId);
}
/**
* 查询当前用户会话列表,支持对方昵称模糊检索
*/
@Override
public List<ChatSession> sessionList(Long uid, String nickName) {
return sessionMapper.selectUserSessions(uid, nickName);
}
/**
* 批量标记会话全部消息已读,清空会话未读计数
*/
@Override
@Transactional
public void markAllRead(Long sessionId, Long uid) {
msgMapper.batchRead(sessionId, uid);
sessionMapper.clearUnread(sessionId);
}
/**
* 用户WebSocket上线,补发所有离线未读消息
*/
@Override
public void pushOfflineMessage(Long uid) {
List<ChatMessage> unreadList = msgMapper.selectUnread(uid);
if (unreadList == null || unreadList.isEmpty()) {
return;
}
// 逐条推送离线消息
for (ChatMessage msg : unreadList) {
ChatMsgDTO dto = new ChatMsgDTO();
dto.setSessionId(msg.getSessionId());
dto.setReceiverId(msg.getReceiverId());
dto.setContent(msg.getContent());
dto.setMsgType(msg.getMsgType());
ChatWebSocketServer.sendToUser(uid, dto);
}
// 推送完成批量标记已读,避免重复推送
try {
Set<Long> sessionIds = unreadList.stream()
.map(ChatMessage::getSessionId)
.collect(Collectors.toSet());
for (Long sessionId : sessionIds) {
msgMapper.batchRead(sessionId, uid);
sessionMapper.clearUnread(sessionId);
}
} catch (Exception e) {
log.error("离线消息标记已读失败", e);
}
}
}
5.3 Mapper 接口补充
ChatSessionMapper.java
Java
package com.para.system.chat.mapper;
import com.para.system.chat.domain.ChatSession;
import org.apache.ibatis.annotations.Param;
import java.util.List;
public interface ChatSessionMapper {
ChatSession selectByUserPair(@Param("from") Long minId,
@Param("to") Long maxId,
@Param("uid") Long loginUid);
void insertSession(ChatSession session);
void updateLastMsg(@Param("sid") Long sessionId,
@Param("content") String content,
@Param("time") java.util.Date time);
void incrUnread(@Param("sid") Long sessionId);
void clearUnread(@Param("sid") Long sessionId);
List<ChatSession> selectUserSessions(@Param("uid") Long uid,
@Param("nickName") String nickName);
}
ChatMessageMapper.java
Java
package com.para.system.chat.mapper;
import com.para.system.chat.domain.ChatMessage;
import org.apache.ibatis.annotations.Param;
import java.util.List;
public interface ChatMessageMapper {
void insert(ChatMessage message);
List<ChatMessage> selectBySession(@Param("sessionId") Long sessionId);
void batchRead(@Param("sessionId") Long sessionId, @Param("uid") Long userId);
List<ChatMessage> selectUnread(@Param("uid") Long userId);
}
5.4 MyBatis Mapper XML ChatSessionMapper.xml
核心复杂SQL:联表用户获取昵称头像、子查询实时统计未读、昵称模糊搜索、双人会话唯一查询。
XML
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.para.system.chat.mapper.ChatSessionMapper">
<!-- 会话结果映射,关联用户昵称、头像、实时未读数量 -->
<resultMap type="com.para.system.chat.domain.ChatSession" id="ChatSessionResult">
<result property="id" column="id" />
<result property="fromUserId" column="from_user_id" />
<result property="toUserId" column="to_user_id" />
<result property="lastContent" column="last_content" />
<result property="lastMsgTime" column="last_msg_time" />
<result property="unreadCount" column="unread_count" />
<result property="realSelfUnread" column="real_self_unread"/>
<result property="createTime" column="create_time" />
<result property="updateTime" column="update_time" />
<result column="nick_name" property="targetNickName"/>
<result column="avatar" property="targetAvatar"/>
</resultMap>
<!-- 根据双方用户ID查询唯一会话,关联用户信息+实时未读统计 -->
<select id="selectByUserPair" resultMap="ChatSessionResult">
SELECT
cs.*,
su.nick_name,
su.avatar,
COALESCE(cm.unread_total,0) AS real_self_unread
FROM chat_session cs
LEFT JOIN sys_user su ON su.user_id = (
CASE
WHEN cs.from_user_id = #{uid} THEN cs.to_user_id
ELSE cs.from_user_id
END
)
LEFT JOIN (
SELECT
session_id,
COUNT(1) unread_total
FROM chat_message
WHERE receiver_id = #{uid} AND read_status = '0'
GROUP BY session_id
) cm ON cs.id = cm.session_id
WHERE
(from_user_id = #{from} AND to_user_id = #{to})
OR
(from_user_id = #{to} AND to_user_id = #{from})
LIMIT 1
</select>
<!-- 新增双人会话 -->
<insert id="insertSession" parameterType="ChatSession">
INSERT INTO chat_session
(
from_user_id,
to_user_id,
last_content,
last_msg_time,
unread_count,
create_time,
update_time
)
VALUES
(
#{fromUserId},
#{toUserId},
#{lastContent},
#{lastMsgTime},
#{unreadCount},
sysdate(),
sysdate()
)
</insert>
<!-- 更新会话最后一条消息内容和时间 -->
<update id="updateLastMsg">
UPDATE chat_session
SET
last_content = #{content},
last_msg_time = #{time},
update_time = sysdate()
WHERE id = #{sid}
</update>
<!-- 会话未读计数 +1 -->
<update id="incrUnread">
UPDATE chat_session
SET
unread_count = unread_count + 1,
update_time = sysdate()
WHERE id = #{sid}
</update>
<!-- 清空会话未读计数 -->
<update id="clearUnread">
UPDATE chat_session
SET
unread_count = 0,
update_time = sysdate()
WHERE id = #{sid}
</update>
<!-- 查询当前用户全部会话,支持昵称模糊搜索,按最后消息倒序 -->
<select id="selectUserSessions" resultMap="ChatSessionResult">
SELECT
cs.*,
su.nick_name,
su.avatar,
COALESCE(cm.unread_total,0) AS real_self_unread
FROM chat_session cs
LEFT JOIN sys_user su ON su.user_id = (
CASE
WHEN cs.from_user_id = #{uid} THEN cs.to_user_id
ELSE cs.from_user_id
END
)
LEFT JOIN (
SELECT
session_id,
COUNT(1) unread_total
FROM chat_message
WHERE receiver_id = #{uid} AND read_status = '0'
GROUP BY session_id
) cm ON cs.id = cm.session_id
WHERE
(cs.from_user_id = #{uid} OR cs.to_user_id = #{uid})
<if test="nickName != null and nickName != ''">
AND su.nick_name LIKE CONCAT('%', #{nickName}, '%')
</if>
ORDER BY cs.last_msg_time DESC
</select>
</mapper>
5.5 ChatMessageMapper.xml(补充完整)
XML
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.para.system.chat.mapper.ChatMessageMapper">
<insert id="insert" parameterType="com.para.system.chat.domain.ChatMessage">
INSERT INTO chat_message
(session_id, sender_id, receiver_id, content, msg_type, read_status, create_time)
VALUES
(#{sessionId},#{senderId},#{receiverId},#{content},#{msgType},#{readStatus},#{createTime})
</insert>
<select id="selectBySession" resultType="com.para.system.chat.domain.ChatMessage">
SELECT * FROM chat_message
WHERE session_id = #{sessionId}
ORDER BY create_time ASC
</select>
<!-- 批量标记当前用户该会话所有消息为已读 -->
<update id="batchRead">
UPDATE chat_message
SET read_status = '1'
WHERE session_id = #{sessionId} AND receiver_id = #{uid} AND read_status = '0'
</update>
<!-- 查询用户所有未读消息(离线消息) -->
<select id="selectUnread" resultType="com.para.system.chat.domain.ChatMessage">
SELECT * FROM chat_message
WHERE receiver_id = #{uid} AND read_status = '0'
</select>
</mapper>
5.6 WebSocket 长连接服务 ChatWebSocketServer
实现握手鉴权、在线用户容器、心跳保活、消息收发、离线消息触发、线程安全发送。
Java
package com.para.system.chat.websocket;
import cn.hutool.json.JSONUtil;
import com.para.common.constant.Constants;
import com.para.common.utils.spring.SpringUtils;
import com.para.system.chat.domain.ChatMsgDTO;
import com.para.system.chat.service.ChatService;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import lombok.extern.log4j.Log4j2;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Component;
import javax.websocket.*;
import javax.websocket.CloseReason.CloseCodes;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
import java.io.IOException;
import java.net.URLDecoder;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Component
@ServerEndpoint("/ws/chat/{userId}")
@Log4j2
public class ChatWebSocketServer {
// 在线用户容器:userId -> WebSocket会话
private static final Map<Long, Session> ONLINE_USER_MAP = new ConcurrentHashMap<>();
// 静态获取Spring容器Bean,解决WebSocket无法直接注入Service问题
private static ChatService getChatService() {
return SpringUtils.getBean(ChatService.class);
}
private boolean isBlank(String str) {
return str == null || str.trim().length() == 0;
}
// 解析URL Query参数,兼容带等号的JWT Token
private String getQueryParam(Session session, String paramName) {
String query = session.getQueryString();
log.info("完整URL参数串: {}", query);
if (isBlank(query)) {
return null;
}
String[] paramArr = query.split("&");
for (String item : paramArr) {
int eqIndex = item.indexOf("=");
if (eqIndex == -1) continue;
String key = item.substring(0, eqIndex);
String value = item.substring(eqIndex + 1);
if (paramName.equals(key)) {
try {
String decodeVal = URLDecoder.decode(value, "UTF-8");
log.info("解析出token值: {}", decodeVal);
return decodeVal;
} catch (Exception e) {
log.error("URL解码token失败", e);
return value;
}
}
}
return null;
}
/**
* WebSocket握手建立连接
*/
@OnOpen
public void onOpen(Session session, @PathParam("userId") Long userId) {
log.info("收到WS握手请求,路径userId:{}", userId);
// 新连接自动剔除旧连接,保证单用户仅一条在线通道
ONLINE_USER_MAP.remove(userId);
String token = getQueryParam(session, "token");
// 无Token直接断开连接
if (isBlank(token)) {
log.warn("拦截断开:未携带登录凭证");
closeSession(session, new CloseReason(CloseCodes.CANNOT_ACCEPT, "未携带登录凭证"));
return;
}
// 解析JWT Token,与后台登录校验逻辑统一
String loginUserKey;
try {
Environment env = SpringUtils.getBean(Environment.class);
String secret = env.getProperty("token.secret");
Claims claims = Jwts.parser()
.setSigningKey(secret)
.parseClaimsJws(token)
.getBody();
loginUserKey = claims.get(Constants.LOGIN_USER_KEY).toString();
log.info("token解析成功,登录唯一标识:{}", loginUserKey);
} catch (Exception e) {
log.error("拦截断开:Token解析异常", e);
closeSession(session, new CloseReason(CloseCodes.CANNOT_ACCEPT, "Token无效或已过期"));
return;
}
// 校验通过存入在线容器,触发离线消息补发
log.info("全部校验通过,建立连接,路径用户ID:{}", userId);
ONLINE_USER_MAP.put(userId, session);
ChatService chatService = getChatService();
if (chatService != null) {
try {
chatService.pushOfflineMessage(userId);
} catch (Exception ignored) {
log.error("推送离线消息异常", ignored);
}
}
}
/**
* 同步安全发送消息,加锁防止并发发送通道异常
*/
private void safeSendText(Session session, String text) throws IOException {
synchronized (session) {
if (session.isOpen()) {
session.getBasicRemote().sendText(text);
}
}
}
/**
* 统一关闭会话封装方法
*/
private void closeSession(Session session, CloseReason reason) {
try {
session.close(reason);
} catch (Exception e) {
log.error("关闭会话异常", e);
}
}
/**
* 连接关闭回调
*/
@OnClose
public void onClose(Session session, CloseReason reason, @PathParam("userId") Long userId) {
ONLINE_USER_MAP.remove(userId);
log.info("连接关闭,用户ID:{} 关闭状态码:{}", userId, reason.getCloseCode());
}
/**
* 接收前端消息(业务消息 + PING心跳)
*/
@OnMessage
public void onMessage(String message, @PathParam("userId") Long senderId) {
if (isBlank(message)) {
return;
}
// 心跳PING回复PONG保活
if ("PING".equals(message)) {
try {
Session session = ONLINE_USER_MAP.get(senderId);
if(session != null && session.isOpen()){
safeSendText(session, "PONG");
log.info("收到PING心跳,回复PONG,用户ID:{}",senderId);
}
} catch (Exception e) {
log.error("回复PONG心跳失败", e);
}
return;
}
// 忽略服务端返回的PONG包
if ("PONG".equals(message)) {
return;
}
// 解析业务消息,调用Service保存并推送
try {
ChatMsgDTO dto = JSONUtil.toBean(message, ChatMsgDTO.class);
ChatService chatService = getChatService();
if (chatService != null) {
chatService.saveAndPushMessage(senderId, dto);
}
} catch (Exception e) {
log.error("处理消息异常", e);
}
}
/**
* 通道异常回调,过滤正常EOF关闭日志
*/
@OnError
public void onError(Session session, Throwable e, @PathParam("userId") Long userId) {
if(e instanceof java.io.EOFException){
log.info("WS通道正常关闭,用户ID:{}", userId);
}else{
log.error("WS异常,用户ID:{}", userId, e);
}
ONLINE_USER_MAP.remove(userId);
}
/**
* 静态工具方法:向指定用户推送消息
*/
public static void sendToUser(Long userId, Object data) {
Session session = ONLINE_USER_MAP.get(userId);
if (session != null && session.isOpen()) {
String json = JSONUtil.toJsonStr(data);
try {
synchronized (session) {
session.getBasicRemote().sendText(json);
}
} catch (Exception e) {
log.error("发送消息失败,用户ID:{}", userId, e);
}
}
}
}
六、核心难点与解决方案总结
1. 双人会话重复创建并发问题
-
数据库建立
from_user_id, to_user_id联合唯一索引 -
统一小ID存from、大ID存to,保证双人仅一条会话
-
捕获插入唯一索引异常,冲突后二次查询,避免重复数据
2. 不同用户查看同一会话未读数错乱
放弃会话表单数字段存储未读,使用子查询实时统计当前登录用户未读消息,每个用户查询时独立计算,多端、多用户隔离准确。
3. WebSocket无法注入Spring Bean
采用 SpringUtils.getBean() 静态工具类上下文获取Service,解决原生WebSocket非Spring管理无法依赖注入问题。
4. WebSocket并发发送报通道异常
消息发送方法加 synchronized(session) 同步锁,多线程发送时排队执行,规避 IllegalStateException。
5. 离线消息重复推送
用户上线一次性拉取所有未读消息推送完成后,批量分组标记消息已读并清空会话未读数,避免每次重连重复补发。
6. WebSocket安全鉴权
握手阶段双重校验:URL路径userId + URL携带JWT Token,复用后台登录JWT密钥解析,非法/过期Token直接主动关闭连接,杜绝非法连接。
7. 长连接阻塞数据库事务
消息入库、会话更新放入事务;WebSocket推送逻辑放到事务外部执行,防止推送超时长时间占用数据库连接。
七、扩展优化方向
-
消息持久化缓存:热门会话消息加入Redis缓存,减少MySQL分页查询压力
-
分布式WebSocket:当前单机内存存储在线用户,集群部署需改造Redis订阅发布实现跨服务推送
-
消息类型扩展:图片、文件、语音消息上传OSS后存储访问链接
-
会话置顶、删除、拉黑功能:会话表新增状态字段扩展
-
消息撤回、编辑:消息表增加撤回标识、原始内容字段
-
心跳超时自动下线:增加定时任务清理长期无心跳僵尸连接
-
消息幂等:前端携带消息唯一ID,避免重复发送重复入库
八、项目适配说明
该模块基于若依RuoYi-Vue SpringBoot后台开发,依赖项目内置工具类:
-
SecurityUtils:获取当前登录用户ID -
AjaxResult / TableDataInfo:统一前后端返回体 -
SpringUtils:Spring上下文静态获取Bean -
JWT工具、Token密钥配置
token.secret -
Hutool JSON工具做WebSocket消息序列化
可根据自有项目替换对应工具类,执行上方MySQL建表SQL并添加对应联合索引即可直接运行。