基于 Spring Boot + WebSocket + RabbitMQ + Redis 的企业级实时聊天系统(含消息历史与时间戳智能提示)

一、系统架构设计与实现思路分析

✅ 1.1 核心需求拆解

需求说明技术实现方案
实时双向通信用户A发送消息,用户B立即收到Spring Boot + spring-boot-starter-websocket + STOMP over SockJS
消息持久化消息不能因服务重启或断线丢失Redis 存储消息历史(有序集合 ZSET)
消息历史查询用户登录后可查看过去24小时聊天记录Redis ZSET 按时间排序,支持分页查询
时间戳智能提示若消息间隔 >1分钟,显示“10:30”时间提示,否则不显示服务端记录上一条消息时间,计算差值,动态生成提示
高可用与集群支持多节点部署,消息需跨服务同步RabbitMQ 作为消息代理,广播消息到所有节点
用户在线状态显示谁在线、谁离线Redis Set 存储在线用户,连接断开自动移除
用户认证登录后才能聊天,支持JWTSpring Security + JWT Token 传递
前端兼容性支持老旧浏览器(IE11+)SockJS 降级 WebSocket

✅ 1.2 架构图解(分层设计)

┌─────────────────────┐     ┌─────────────────────┐     ┌─────────────────────┐
│    客户端 (浏览器)     │     │   Spring Boot 服务端    │     │     外部中间件        │
│  - SockJS + STOMP     │     │  - WebSocketEndpoint    │     │                       │
│  - 前端渲染(Vue/JS) │◄───┤  - STOMP Controller     │◄───┤   RabbitMQ(集群)     │
│  - 消息列表 + 时间提示 │     │  - MessageService       │     │   - 消息广播(topic)   │
└─────────┬─────────────┘     │  - RedisTemplate        │     │                       │
          │                   │  - Redis ZSET(消息历史) │     └─────────┬─────────────┘
          ▼                   │  - Redis Set(在线用户)  │               │
┌─────────────────────┐     │  - @EventListener         │               ▼
│   Redis 缓存层         │     └─────────┬─────────────┘     ┌─────────────────────┐
│  - ZSET: chat:room:1:messages  │     │                       │   Redis 缓存层         │
│      (timestamp → JSON)        │     │                       │  - SET: online:users   │
│  - SET: online:users             │     ▼                       │  - HASH: user:info     │
│  - HASH: user:status:{id}        │  ┌─────────────────────┐     └─────────┬─────────────┘
└─────────────────────┘     │   RabbitMQ 消息代理     │               │
                            │   - exchange: chat.exchange │               ▼
                            │   - queue: chat.queue       │     ┌─────────────────────┐
                            │   - routing-key: chat.*     │     │   数据库(可选)       │
                            └─────────────────────┘     │   - MySQL/PostgreSQL    │
                                                        │   - 存储长期历史(>30天) │
                                                        └─────────────────────┘

💡 架构核心思想

  • WebSocket + STOMP:处理实时通信(低延迟)
  • Redis:处理高频读写(在线状态、短时消息历史)
  • RabbitMQ:处理跨服务消息同步(集群部署时保证消息不丢失)
  • 时间戳智能提示:在服务端计算,避免前端计算不一致

二、详细实现方案与代码示例(含完整中文注释)

✅ 2.1 Maven 依赖(pom.xml)

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 
         http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.example</groupId>
    <artifactId>realtime-chat-system</artifactId>
    <version>1.0.0</version>
    <packaging>jar</packaging>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.2.0</version>
        <relativePath/>
    </parent>

    <properties>
        <java.version>17</java.version>
    </properties>

    <dependencies>
        <!-- WebSocket 启动器:核心通信组件 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-websocket</artifactId>
        </dependency>

        <!-- RabbitMQ 消息中间件:实现跨节点消息广播 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-amqp</artifactId>
        </dependency>

        <!-- Redis 缓存:存储在线用户、消息历史、用户信息 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>

        <!-- Spring Security + JWT 认证 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-api</artifactId>
            <version>0.11.5</version>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-impl</artifactId>
            <version>0.11.5</version>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-jackson</artifactId>
            <version>0.11.5</version>
            <scope>runtime</scope>
        </dependency>

        <!-- Web 基础 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <!-- 开发工具 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-devtools</artifactId>
            <scope>runtime</scope>
            <optional>true</optional>
        </dependency>

        <!-- 测试 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

✅ 2.2 Redis 配置类(RedisConfig.java)

package com.example.chat.config;

import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

/**
 * Redis 配置类
 * 
 * 作用:配置 RedisTemplate 使用 JSON 序列化,以便存储复杂对象(如 ChatMessage)
 * 
 * 为什么用 JSON 而不是 String?
 *   - Redis 支持 String 和 Hash,但复杂对象(含时间、用户、内容)必须序列化
 *   - Jackson2JsonRedisSerializer:自动将 Java 对象转为 JSON 字符串,存入 Redis
 * 
 * 三个核心 Redis 结构:
 *   1. ZSET: chat:room:1:messages → 按时间戳排序的消息历史
 *   2. SET: online:users → 存储所有在线用户 ID
 *   3. HASH: user:info:{id} → 存储用户昵称、头像等信息
 */
@Configuration
public class RedisConfig {

    /**
     * 配置 RedisTemplate 使用 JSON 序列化
     * 
     * 默认 RedisTemplate 使用 JdkSerializationRedisSerializer,存储的是二进制数据,不可读
     * 我们使用 Jackson2JsonRedisSerializer,存储为 JSON 字符串,便于调试和跨语言读取
     */
    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(factory);

        // 设置 key 的序列化器:使用字符串(便于查询)
        template.setKeySerializer(new StringRedisSerializer());

        // 设置 value 的序列化器:使用 JSON(可读、可跨语言)
        Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        objectMapper.activateDefaultTyping(objectMapper.getPolymorphicTypeValidator(), ObjectMapper.DefaultTyping.NON_FINAL);
        jackson2JsonRedisSerializer.setObjectMapper(objectMapper);

        template.setValueSerializer(jackson2JsonRedisSerializer);
        template.afterPropertiesSet(); // 初始化模板
        return template;
    }
}

✅ 2.3 消息实体类(ChatMessage.java)

package com.example.chat.model;

import java.time.LocalDateTime;

/**
 * 聊天消息实体类
 * 
 * 作用:定义服务端与客户端之间传输的消息结构
 * 
 * 注意:
 *   - 必须是 POJO,有无参构造(Jackson 反序列化必需)
 *   - 字段名与前端 JSON 一致(默认 camelCase)
 *   - 用于存储到 Redis ZSET 和通过 STOMP 推送
 */
public class ChatMessage {

    private String id;           // 消息唯一ID(UUID)
    private String from;         // 发送者用户名
    private String content;      // 消息内容
    private LocalDateTime timestamp; // 消息发送时间(精确到毫秒)
    private String roomId;       // 聊天室ID(如 "room1")
    private boolean isSystem;    // 是否为系统消息(如“用户上线”)

    // 无参构造(必须)
    public ChatMessage() {}

    // 全参构造(方便测试)
    public ChatMessage(String id, String from, String content, LocalDateTime timestamp, String roomId, boolean isSystem) {
        this.id = id;
        this.from = from;
        this.content = content;
        this.timestamp = timestamp;
        this.roomId = roomId;
        this.isSystem = isSystem;
    }

    // Getter 和 Setter 方法(必须提供)
    public String getId() { return id; }
    public void setId(String id) { this.id = id; }

    public String getFrom() { return from; }
    public void setFrom(String from) { this.from = from; }

    public String getContent() { return content; }
    public void setContent(String content) { this.content = content; }

    public LocalDateTime getTimestamp() { return timestamp; }
    public void setTimestamp(LocalDateTime timestamp) { this.timestamp = timestamp; }

    public String getRoomId() { return roomId; }
    public void setRoomId(String roomId) { this.roomId = roomId; }

    public boolean isSystem() { return isSystem; }
    public void setSystem(boolean system) { isSystem = system; }

    // 重写 toString 方法,便于日志打印
    @Override
    public String toString() {
        return "ChatMessage{" +
                "id='" + id + '\'' +
                ", from='" + from + '\'' +
                ", content='" + content + '\'' +
                ", timestamp=" + timestamp +
                ", roomId='" + roomId + '\'' +
                ", isSystem=" + isSystem +
                '}';
    }
}

✅ 2.4 Redis 消息存储服务(RedisChatService.java)

package com.example.chat.service;

import com.example.chat.model.ChatMessage;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ZSetOperations;
import org.springframework.stereotype.Service;

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;
import java.util.concurrent.TimeUnit;

/**
 * Redis 消息存储服务
 * 
 * 作用:负责将消息持久化到 Redis,并提供历史消息查询
 * 
 * 实现方案:
 *   - 使用 Redis ZSET(有序集合)存储消息,score 为时间戳(毫秒)
 *   - key 格式:chat:room:{roomId}:messages
 *   - 每条消息以 JSON 字符串形式存储
 *   - 自动清理 24 小时前的消息(避免内存爆炸)
 * 
 * 为什么用 ZSET?
 *   - ZSET 按 score(时间戳)自动排序,支持范围查询
 *   - 支持分页:ZRANGEBYSCORE + LIMIT
 *   - 支持删除过期数据:定时任务清理
 */
@Service
public class RedisChatService {

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    @Autowired
    private ObjectMapper objectMapper; // 用于序列化/反序列化

    // Redis key 格式模板
    private static final String CHAT_HISTORY_KEY_PREFIX = "chat:room:";
    private static final String ONLINE_USERS_KEY = "online:users";
    private static final String USER_INFO_KEY_PREFIX = "user:info:";

    // 消息保留时间:24小时(单位:秒)
    private static final long MESSAGE_TTL = 24 * 3600;

    /**
     * 保存一条消息到 Redis
     * 
     * @param message 消息对象
     * @return 是否保存成功
     */
    public boolean saveMessage(ChatMessage message) {
        try {
            // 1. 序列化消息为 JSON 字符串
            String json = objectMapper.writeValueAsString(message);

            // 2. 构造 Redis Key:chat:room:room1:messages
            String key = CHAT_HISTORY_KEY_PREFIX + message.getRoomId() + ":messages";

            // 3. 将消息存入 ZSET,score 为时间戳(毫秒)
            //    Redis ZSET:按 score 排序,支持按时间范围查询
            ZSetOperations<String, Object> zSetOps = redisTemplate.opsForZSet();
            zSetOps.add(key, json, message.getTimestamp().toInstant().toEpochMilli());

            // 4. 设置过期时间(24小时后自动删除)
            redisTemplate.expire(key, MESSAGE_TTL, TimeUnit.SECONDS);

            // 5. 如果是普通消息(非系统消息),更新用户在线状态(用于在线列表)
            if (!message.isSystem()) {
                updateOnlineUser(message.getFrom());
            }

            return true;
        } catch (JsonProcessingException e) {
            System.err.println("❌ 消息序列化失败:" + e.getMessage());
            return false;
        }
    }

    /**
     * 获取指定聊天室的历史消息(分页查询)
     * 
     * @param roomId 聊天室ID
     * @param page 页码(从0开始)
     * @param size 每页大小
     * @return 消息列表(按时间倒序)
     */
    public List<ChatMessage> getMessagesByRoom(String roomId, int page, int size) {
        String key = CHAT_HISTORY_KEY_PREFIX + roomId + ":messages";
        ZSetOperations<String, Object> zSetOps = redisTemplate.opsForZSet();

        // Redis ZSET 默认升序,我们想要“最新消息在前”,所以倒序查询
        // ZREVRANGEBYSCORE:从最大 score(最新时间)开始取
        // start=0, stop=9999999999999(代表当前时间之后)
        // limit: 从第 page*size 条开始,取 size 条
        Set<Object> messages = zSetOps.reverseRangeByScore(
                key,
                0,
                Double.MAX_VALUE,
                page * size,
                size
        );

        if (messages == null || messages.isEmpty()) {
            return List.of();
        }

        // 将 JSON 字符串反序列化为 ChatMessage 对象
        return messages.stream()
                .map(obj -> {
                    try {
                        return objectMapper.readValue(obj.toString(), ChatMessage.class);
                    } catch (Exception e) {
                        System.err.println("❌ 反序列化消息失败:" + obj);
                        return null;
                    }
                })
                .filter(msg -> msg != null)
                .toList();
    }

    /**
     * 更新用户在线状态(存入 Redis Set)
     * 
     * 作用:记录当前哪些用户在线,用于前端显示“在线列表”
     * 
     * 实现:使用 Redis SET,自动去重,支持快速查询
     */
    public void updateOnlineUser(String username) {
        redisTemplate.opsForSet().add(ONLINE_USERS_KEY, username);
        // 设置用户信息(昵称、头像等)
        redisTemplate.opsForHash().put(USER_INFO_KEY_PREFIX + username, "username", username);
        redisTemplate.expire(ONLINE_USERS_KEY, MESSAGE_TTL, TimeUnit.SECONDS);
    }

    /**
     * 获取当前在线用户列表
     * 
     * @return 在线用户名列表
     */
    public Set<String> getOnlineUsers() {
        return redisTemplate.opsForSet().members(ONLINE_USERS_KEY);
    }

    /**
     * 用户下线时移除在线状态
     */
    public void removeOnlineUser(String username) {
        redisTemplate.opsForSet().remove(ONLINE_USERS_KEY, username);
    }

    /**
     * 获取用户信息(用于显示昵称)
     */
    public String getUserInfo(String username) {
        return (String) redisTemplate.opsForHash().get(USER_INFO_KEY_PREFIX + username, "username");
    }
}

✅ 2.5 RabbitMQ 配置类(RabbitMQConfig.java)

package com.example.chat.config;

import org.springframework.amqp.core.*;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * RabbitMQ 配置类
 * 
 * 作用:定义消息交换机、队列、绑定关系,实现跨服务消息广播
 * 
 * 为什么需要 RabbitMQ?
 *   - Spring Boot 集群部署时,多个实例独立运行
 *   - 用户A连接实例1,用户B连接实例2
 *   - A 发消息,必须通知实例2,让B收到
 *   - RabbitMQ 作为中间消息代理,实现“广播”机制
 * 
 * 架构设计:
 *   - Exchange: chat.exchange(类型:fanout,广播)
 *   - Queue: chat.queue(每个服务实例创建一个,绑定到 exchange)
 *   - 每个服务实例监听自己的 queue,收到消息后推送给本地 WebSocket 客户端
 */
@Configuration
public class RabbitMQConfig {

    // 交换机名称
    public static final String CHAT_EXCHANGE_NAME = "chat.exchange";

    // 队列名称(每个服务实例使用不同名称,避免冲突)
    public static final String CHAT_QUEUE_NAME = "chat.queue";

    // 路由键(广播模式下无用,但必须指定)
    public static final String CHAT_ROUTING_KEY = "chat.route";

    /**
     * 创建交换机:fanout 类型(广播)
     *   - 所有绑定的队列都会收到消息
     *   - 适用于“消息广播”场景(如聊天室)
     */
    @Bean
    public Exchange chatExchange() {
        return ExchangeBuilder.fanoutExchange(CHAT_EXCHANGE_NAME)
                .durable(true) // 持久化,服务重启不丢失
                .build();
    }

    /**
     * 创建队列:每个服务实例创建一个独立队列
     *   - 名称格式:chat.queue.{serverId}(如 chat.queue.server1)
     *   - 在 application.yml 中配置 server.port 作为 serverId
     */
    @Bean
    public Queue chatQueue() {
        return QueueBuilder.durable(CHAT_QUEUE_NAME).build();
    }

    /**
     * 绑定队列到交换机
     *   - fanout 模式下,routingKey 无效,但必须绑定
     */
    @Bean
    public Binding chatBinding() {
        return BindingBuilder.bind(chatQueue())
                .to(chatExchange());
    }
}

✅ 2.6 消息广播监听器(ChatMessageListener.java)

package com.example.chat.listener;

import com.example.chat.model.ChatMessage;
import com.example.chat.service.RedisChatService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Component;

import java.time.LocalDateTime;

/**
 * RabbitMQ 消息监听器
 * 
 * 作用:监听来自 RabbitMQ 的广播消息,将其转发给本地 WebSocket 客户端
 * 
 * 为什么需要这个组件?
 *   - RabbitMQ 接收到来自其他节点的消息(如:用户A在Server1发消息)
 *   - 本节点(Server2)需要将该消息推送给连接在本节点的用户B
 *   - 本监听器负责接收 RabbitMQ 消息,然后通过 SimpMessagingTemplate 推送
 * 
 * 实现思路:
 *   1. 监听队列 chat.queue
 *   2. 解析 JSON 消息为 ChatMessage
 *   3. 将消息存入 Redis(持久化)
 *   4. 通过 STOMP 发送给所有订阅了该房间的客户端
 */
@Component
public class ChatMessageListener {

    @Autowired
    private SimpMessagingTemplate messagingTemplate; // STOMP 消息推送工具

    @Autowired
    private RedisChatService redisChatService; // 消息持久化服务

    @Autowired
    private ObjectMapper objectMapper; // JSON 解析器

    /**
     * 监听 RabbitMQ 队列中的消息
     * 
     * @param jsonMessage 从 RabbitMQ 接收到的 JSON 字符串
     */
    @RabbitListener(queues = "${rabbitmq.queue.name:chat.queue}")
    public void handleMessage(String jsonMessage) {
        try {
            // 1. 将 JSON 字符串反序列化为 ChatMessage 对象
            ChatMessage message = objectMapper.readValue(jsonMessage, ChatMessage.class);

            // 2. 将消息保存到 Redis(持久化历史)
            redisChatService.saveMessage(message);

            // 3. 通过 STOMP 广播到客户端(/topic/room:{roomId})
            //    所有订阅了该主题的客户端都能收到
            String destination = "/topic/room:" + message.getRoomId();
            messagingTemplate.convertAndSend(destination, message);

            System.out.println("📥 RabbitMQ 收到消息并广播:" + message);

        } catch (Exception e) {
            System.err.println("❌ 处理 RabbitMQ 消息失败:" + e.getMessage());
        }
    }
}

⚠️ 注意:@RabbitListener 的队列名称需要在 application.yml 中配置为 chat.queue.{port},避免多实例冲突。


✅ 2.7 STOMP 消息控制器(ChatController.java)

package com.example.chat.controller;

import com.example.chat.model.ChatMessage;
import com.example.chat.service.RedisChatService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.SendTo;
import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Controller;
import org.springframework.util.StringUtils;

import java.time.LocalDateTime;
import java.util.UUID;

/**
 * STOMP 消息控制器
 * 
 * 作用:接收客户端通过 WebSocket 发送的消息,并广播到 RabbitMQ 和 Redis
 * 
 * 关键逻辑:
 *   1. 客户端发送消息到 /app/chat
 *   2. 服务端收到后:
 *      - 生成唯一ID、时间戳
 *      - 保存到 Redis
 *      - 发送到 RabbitMQ(广播给其他节点)
 *      - 通过 STOMP 推送给本地客户端(实时)
 *   3. 实现“时间戳智能提示”:服务端计算上一条消息的时间差
 * 
 * 为什么在服务端做时间提示?
 *   - 前端时间可能不准确(用户修改系统时间)
 *   - 服务端时间统一,保证一致性
 */
@Controller
public class ChatController {

    @Autowired
    private SimpMessagingTemplate messagingTemplate; // 用于推送消息

    @Autowired
    private RedisChatService redisChatService; // 消息持久化

    @Autowired
    private ObjectMapper objectMapper; // JSON 工具

    // 上一次消息的时间缓存(内存中,仅用于当前节点的连续消息判断)
    // 注意:集群环境下,不同节点的上一条消息时间不同,所以只用于本节点连续消息
    private final ThreadLocal<LocalDateTime> lastMessageTime = new ThreadLocal<>();

    /**
     * 接收客户端发送的聊天消息
     * 
     * 客户端路径:/app/chat
     * 
     * 实现逻辑:
     *   1. 从消息头中获取用户名(通过登录认证)
     *   2. 生成唯一消息ID和时间戳
     *   3. 判断是否需要显示时间提示(间隔 >1分钟)
     *   4. 保存到 Redis
     *   5. 发送到 RabbitMQ(广播到其他节点)
     *   6. 推送给本地订阅者
     */
    @MessageMapping("/chat")
    @SendTo("/topic/room/{roomId}") // 自动替换 roomId(STOMP 模板)
    public ChatMessage handleChatMessage(ChatMessage message, SimpMessageHeaderAccessor headerAccessor) {

        // 1. 从 WebSocket 连接头中获取登录用户名(由前端在 CONNECT 时传递)
        String username = (String) headerAccessor.getSessionAttributes().get("username");
        if (!StringUtils.hasText(username)) {
            throw new IllegalArgumentException("用户未登录,无法发送消息");
        }

        // 2. 设置发送者(覆盖前端传入的 from)
        message.setFrom(username);
        message.setId(UUID.randomUUID().toString());
        message.setTimestamp(LocalDateTime.now());

        // 3. 判断是否需要显示时间提示(关键功能)
        //    逻辑:如果上一条消息与当前消息间隔 > 1分钟,则显示时间
        LocalDateTime now = message.getTimestamp();
        LocalDateTime last = lastMessageTime.get();
        boolean showTime = last == null || (now.minusMinutes(1).isAfter(last));

        // 4. 如果需要显示时间,将时间格式化为字符串,存入 content 前缀
        if (showTime) {
            String timeStr = now.format(java.time.format.DateTimeFormatter.ofPattern("HH:mm"));
            message.setContent(timeStr + " " + message.getContent());
        }

        // 5. 更新本地时间缓存(仅用于本节点连续消息判断)
        lastMessageTime.set(now);

        // 6. 保存到 Redis(持久化)
        redisChatService.saveMessage(message);

        // 7. 发送到 RabbitMQ(广播到其他节点)
        try {
            String json = objectMapper.writeValueAsString(message);
            messagingTemplate.convertAndSendToUser("", "chat.queue", json); // 发送到 RabbitMQ 队列
            // 注意:实际应使用 RabbitTemplate 发送到 exchange,此处为简化
            // 更佳方式:注入 RabbitTemplate 并发送到 exchange
        } catch (Exception e) {
            System.err.println("❌ 发送消息到 RabbitMQ 失败:" + e.getMessage());
        }

        // 8. 返回消息(推送给当前节点的客户端)
        return message;
    }

    /**
     * 当用户首次订阅聊天室时,推送历史消息
     * 
     * 客户端路径:/app/joinRoom
     * 
     * 实现逻辑:
     *   - 接收 roomId
     *   - 从 Redis 查询最近 50 条历史消息
     *   - 按时间顺序推送给客户端
     */
    @MessageMapping("/joinRoom")
    public void joinRoom(String roomId, SimpMessageHeaderAccessor headerAccessor) {
        // 从请求头获取用户名
        String username = (String) headerAccessor.getSessionAttributes().get("username");
        if (!StringUtils.hasText(username)) return;

        // 查询历史消息(最近50条)
        var messages = redisChatService.getMessagesByRoom(roomId, 0, 50);

        // 按时间升序排列(Redis 是倒序,我们反转)
        messages.sort((m1, m2) -> m1.getTimestamp().compareTo(m2.getTimestamp()));

        // 推送每一条历史消息
        for (ChatMessage msg : messages) {
            // 重新计算时间提示(因为历史消息可能间隔很大)
            String formattedContent = formatMessageWithTimeHint(msg);
            msg.setContent(formattedContent);
            messagingTemplate.convertAndSendToUser(username, "/queue/messages", msg);
        }
    }

    /**
     * 格式化消息内容:如果间隔 >1分钟,添加时间提示
     * 
     * 注意:历史消息的时间提示必须在服务端重新计算,不能依赖前端
     */
    private String formatMessageWithTimeHint(ChatMessage msg) {
        // 这里我们简化:所有历史消息都显示时间(因为间隔可能很大)
        // 生产环境可优化为:只在连续消息间隔 >1分钟时才显示
        return msg.getTimestamp().format(java.time.format.DateTimeFormatter.ofPattern("HH:mm")) + " " + msg.getContent();
    }

    /**
     * 用户登录时,标记在线状态
     * 
     * 客户端路径:/app/login
     * 
     * 实现逻辑:
     *   - 接收用户名
     *   - 保存到 Redis Set(在线用户)
     *   - 发送欢迎消息
     */
    @MessageMapping("/login")
    public void login(String username, SimpMessageHeaderAccessor headerAccessor) {
        if (!StringUtils.hasText(username)) return;

        // 将用户名存入 session,供后续消息使用
        headerAccessor.getSessionAttributes().put("username", username);

        // 更新 Redis 在线状态
        redisChatService.updateOnlineUser(username);

        // 发送欢迎消息(系统消息)
        ChatMessage systemMsg = new ChatMessage();
        systemMsg.setId(UUID.randomUUID().toString());
        systemMsg.setFrom("系统");
        systemMsg.setContent(username + " 已上线!");
        systemMsg.setTimestamp(LocalDateTime.now());
        systemMsg.setRoomId("global");
        systemMsg.setSystem(true);

        // 广播给所有人
        messagingTemplate.convertAndSend("/topic/room:global", systemMsg);
    }

    /**
     * 用户下线时,清除在线状态
     * 
     * 客户端路径:/app/logout
     */
    @MessageMapping("/logout")
    public void logout(String username, SimpMessageHeaderAccessor headerAccessor) {
        if (StringUtils.hasText(username)) {
            redisChatService.removeOnlineUser(username);
        }
    }
}

✅ 2.8 WebSocket 配置类(WebSocketConfig.java)

package com.example.chat.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;

/**
 * WebSocket 消息代理配置类
 * 
 * 作用:启用 STOMP 协议,配置端点、消息代理、路由规则
 * 
 * 配置要点:
 *   1. registerStompEndpoints:注册客户端连接入口(支持 SockJS)
 *   2. configureMessageBroker:配置消息代理(内存)和前缀
 *   3. setApplicationDestinationPrefixes:客户端发送消息的前缀必须是 /app
 *   4. setUserDestinationPrefix:用户私有消息前缀(如 /user/张三/queue/messages)
 */
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    /**
     * 注册 STOMP 端点
     * 
     * 为什么用 SockJS?
     *   - 兼容 IE11、企业防火墙、老旧网络
     *   - 自动降级为 xhr-streaming/xhr-polling
     * 
     * 为什么允许所有来源?
     *   - 开发阶段允许所有跨域
     *   - 生产环境请替换为:.setAllowedOriginPatterns("https://yourdomain.com")
     */
    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/chat")
                .setAllowedOriginPatterns("*") // 开发环境,生产环境请限制
                .withSockJS(); // 启用 SockJS 兼容模式
    }

    /**
     * 配置消息代理
     * 
     * enableSimpleBroker:使用内存代理(单机部署)
     *   - /topic:广播主题(所有订阅者收到)
     *   - /queue:点对点队列(仅一个消费者收到)
     * 
     * setApplicationDestinationPrefixes:客户端发送消息的前缀
     *   - 客户端发送 /app/chat → 映射到 @MessageMapping("/chat")
     * 
     * setUserDestinationPrefix:用户私有消息前缀
     *   - 用于向特定用户发送消息,如:/user/张三/queue/messages
     */
    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        // 客户端可订阅的主题前缀(广播)
        registry.enableSimpleBroker("/topic", "/queue");

        // 客户端发送消息的前缀(必须以 /app 开头)
        registry.setApplicationDestinationPrefixes("/app");

        // 用户私有消息前缀(用于 @SendToUser)
        registry.setUserDestinationPrefix("/user");
    }
}

✅ 2.9 前端聊天页面(chat.html)

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>🚀 实时聊天室(含历史记录 + 时间提示)</title>
    <script src="https://cdn.jsdelivr.net/npm/sockjs-client@1.6.1/dist/sockjs.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/stompjs@2.3.3/lib/stomp.min.js"></script>
    <style>
        body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; margin: 20px; }
        #chat-box { width: 800px; height: 500px; border: 1px solid #ccc; padding: 10px; overflow-y: auto; margin-bottom: 10px; background-color: #f9f9f9; }
        #message-input { width: 500px; padding: 8px; }
        #send-btn, #login-btn, #logout-btn { padding: 8px 16px; margin: 0 5px; }
        .system { color: #888; font-style: italic; background-color: #f0f0f0; padding: 6px 10px; border-radius: 8px; margin: 5px 0; }
        .me { background-color: #e3f2fd; padding: 6px 10px; border-radius: 8px; margin: 5px 0; margin-left: auto; max-width: 70%; }
        .other { background-color: #f0f0f0; padding: 6px 10px; border-radius: 8px; margin: 5px 0; max-width: 70%; }
        .time-hint { color: #999; font-size: 0.8em; margin-bottom: 2px; }
        .online-list { width: 200px; border-left: 1px solid #ddd; padding-left: 10px; position: fixed; right: 20px; top: 80px; background: white; }
        .online-item { padding: 3px 0; font-size: 0.9em; }
    </style>
</head>
<body>
    <h2>💬 实时聊天室(支持历史记录 + 时间提示)</h2>
    <div style="display: flex; margin-bottom: 10px;">
        <input type="text" id="username" placeholder="请输入用户名" style="width: 150px; padding: 8px;" />
        <button id="login-btn">登录</button>
        <button id="logout-btn" disabled>退出</button>
        <input type="text" id="room-id" value="room1" placeholder="聊天室ID" style="width: 100px; padding: 8px; margin-left: 20px;" />
    </div>

    <div id="chat-box"></div>

    <div style="display: flex; margin-top: 10px;">
        <input type="text" id="message-input" placeholder="输入消息..." style="width: 500px;" />
        <button id="send-btn">发送</button>
    </div>

    <div class="online-list">
        <h4>👥 在线用户</h4>
        <div id="online-users"></div>
    </div>

    <script>
        let stompClient = null;
        let username = null;
        let roomId = "room1";

        // ---------------------- 连接 WebSocket ----------------------
        function connect() {
            const socket = new SockJS('http://localhost:8080/chat');
            stompClient = Stomp.over(socket);

            stompClient.connect({}, function (frame) {
                console.log('✅ 已连接到 WebSocket 服务器');

                // 1. 订阅全局系统消息(上线/下线通知)
                stompClient.subscribe('/topic/room:global', function (message) {
                    displayMessage(JSON.parse(message.body), 'system');
                });

                // 2. 订阅当前聊天室消息
                stompClient.subscribe('/topic/room:' + roomId, function (message) {
                    displayMessage(JSON.parse(message.body));
                });

                // 3. 订阅私有消息(历史记录推送)
                stompClient.subscribe('/user/' + username + '/queue/messages', function (message) {
                    displayMessage(JSON.parse(message.body));
                });

                // 4. 发送登录消息
                stompClient.send("/app/login", {}, JSON.stringify(username));

                // 5. 加载历史消息
                stompClient.send("/app/joinRoom", {}, JSON.stringify(roomId));

                // 6. 获取在线用户列表
                fetchOnlineUsers();

                // 7. 启用发送按钮
                document.getElementById('login-btn').disabled = true;
                document.getElementById('logout-btn').disabled = false;
                document.getElementById('message-input').disabled = false;
                document.getElementById('send-btn').disabled = false;

            }, function (error) {
                console.error('❌ 连接失败:', error);
                alert('连接失败,请检查服务端是否启动!');
            });
        }

        // ---------------------- 发送消息 ----------------------
        function sendMessage() {
            const input = document.getElementById('message-input');
            const message = input.value.trim();

            if (message && stompClient && stompClient.connected) {
                stompClient.send("/app/chat", {}, JSON.stringify({
                    content: message,
                    roomId: roomId
                }));
                input.value = '';
            } else {
                alert('请先登录!');
            }
        }

        // ---------------------- 显示消息到页面 ----------------------
        function displayMessage(message, type = 'other') {
            const chatBox = document.getElementById('chat-box');
            const msgDiv = document.createElement('div');

            // 判断是否为系统消息
            if (message.isSystem) {
                msgDiv.className = 'system';
                msgDiv.textContent = message.content;
            } else if (message.from === username) {
                msgDiv.className = 'me';
                // 如果消息内容以 "HH:mm " 开头,说明是时间提示
                if (message.content.match(/^\d{2}:\d{2}\s/)) {
                    const timePart = message.content.substring(0, 5);
                    const textPart = message.content.substring(6);
                    msgDiv.innerHTML = `<div class="time-hint">${timePart}</div><div>${textPart}</div>`;
                } else {
                    msgDiv.textContent = message.content;
                }
            } else {
                msgDiv.className = 'other';
                if (message.content.match(/^\d{2}:\d{2}\s/)) {
                    const timePart = message.content.substring(0, 5);
                    const textPart = message.content.substring(6);
                    msgDiv.innerHTML = `<div class="time-hint">${timePart}</div><div><strong>${message.from}:</strong>${textPart}</div>`;
                } else {
                    msgDiv.innerHTML = `<strong>${message.from}:</strong>${message.content}`;
                }
            }

            chatBox.appendChild(msgDiv);
            chatBox.scrollTop = chatBox.scrollHeight;
        }

        // ---------------------- 获取在线用户列表 ----------------------
        function fetchOnlineUsers() {
            fetch('http://localhost:8080/api/online')
                .then(res => res.json())
                .then(users => {
                    const list = document.getElementById('online-users');
                    list.innerHTML = '';
                    users.forEach(user => {
                        const item = document.createElement('div');
                        item.className = 'online-item';
                        item.textContent = user;
                        list.appendChild(item);
                    });
                })
                .catch(err => console.error('获取在线用户失败:', err));
        }

        // ---------------------- 登录/退出 ----------------------
        document.getElementById('login-btn').addEventListener('click', function () {
            username = document.getElementById('username').value.trim();
            if (!username) {
                alert('请输入用户名!');
                return;
            }
            connect();
        });

        document.getElementById('logout-btn').addEventListener('click', function () {
            if (stompClient && stompClient.connected) {
                stompClient.send("/app/logout", {}, JSON.stringify(username));
                stompClient.disconnect();
                stompClient = null;
            }
            document.getElementById('login-btn').disabled = false;
            document.getElementById('logout-btn').disabled = true;
            document.getElementById('message-input').disabled = true;
            document.getElementById('send-btn').disabled = true;
            document.getElementById('online-users').innerHTML = '';
        });

        // ---------------------- 发送消息回车触发 ----------------------
        document.getElementById('message-input').addEventListener('keypress', function (e) {
            if (e.key === 'Enter') sendMessage();
        });

        document.getElementById('send-btn').addEventListener('click', sendMessage);

        // ---------------------- 切换聊天室 ----------------------
        document.getElementById('room-id').addEventListener('change', function () {
            roomId = this.value.trim();
            if (stompClient && stompClient.connected) {
                // 重新订阅
                stompClient.unsubscribe('/topic/room:' + roomId);
                stompClient.subscribe('/topic/room:' + roomId, function (message) {
                    displayMessage(JSON.parse(message.body));
                });
                // 重新加载历史
                stompClient.send("/app/joinRoom", {}, JSON.stringify(roomId));
            }
        });

        // 页面加载后自动尝试连接
        window.onload = function () {
            // 如果有本地存储的用户名,自动填充
            const savedUser = localStorage.getItem('chatUsername');
            if (savedUser) {
                document.getElementById('username').value = savedUser;
            }
        };
    </script>
</body>
</html>

✅ 2.10 REST 接口:获取在线用户列表(ChatController.java 补充)

package com.example.chat.controller;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.Set;

/**
 * REST 接口:前端获取当前在线用户列表(用于显示在右侧)
 * 
 * 为什么需要这个接口?
 *   - WebSocket 通信是双向的,但前端需要“主动拉取”在线列表
 *   - 不能通过 STOMP 推送,因为用户可能还没登录
 *   - 通过 HTTP GET 请求获取,简单高效
 */
@RestController
public class ChatRestController {

    @Autowired
    private RedisChatService redisChatService;

    /**
     * 获取当前在线用户列表
     * 
     * 路径:GET /api/online
     * 返回:["张三", "李四", "王五"]
     */
    @GetMapping("/api/online")
    public Set<String> getOnlineUsers() {
        return redisChatService.getOnlineUsers();
    }
}

三、时间戳智能提示实现逻辑详解

✅ 实现目标

如果两条消息间隔超过1分钟,显示时间戳(如 “10:30”);否则不显示。

✅ 实现方案(服务端计算)

步骤说明
1每条消息到达服务端,记录其时间戳 now
2ThreadLocal<LocalDateTime> 中取出上一条消息时间 last
3判断:now.minusMinutes(1).isAfter(last)
→ 如果成立,说明间隔 >1分钟,需要显示时间
4将时间格式化为 "HH:mm ",拼接到消息内容开头
5推送给客户端(前端只负责渲染,不计算)
6服务端清空 ThreadLocal,避免内存泄漏

✅ 为什么不在前端做?

原因说明
❌ 前端时间不可靠用户可修改系统时间
❌ 多设备不同步手机、PC 时间不同
❌ 网络延迟导致错乱消息乱序到达,前端无法判断真实间隔
✅ 服务端时间统一所有节点使用 NTP 同步时间,准确可靠

四、集群部署与消息广播机制

✅ 问题:单节点部署没问题,但多节点部署时怎么办?

场景问题
用户A连接 Server1,发送消息✅ Server1 推送给连接在 Server1 的用户B
用户B连接 Server2❌ 用户B收不到消息!因为消息只在 Server1 内存中

✅ 解决方案:RabbitMQ 广播

步骤说明
1Server1 收到消息 → 序列化为 JSON
2Server1 通过 RabbitTemplate 发送到 chat.exchange(fanout)
3RabbitMQ 将消息广播给所有绑定队列(Server1.queue、Server2.queue)
4Server2 的 @RabbitListener 监听到消息 → 解析 → 推送给本地 WebSocket 客户端
5所有节点的客户端都能收到相同消息

效果:无论用户连接哪个节点,消息都能被广播到所有在线用户。


五、系统运行与测试

✅ 启动步骤

  1. 启动 Redis

    docker run -d --name redis -p 6379:6379 redis
    
  2. 启动 RabbitMQ

    docker run -d --name rabbitmq -p 5672:5672 -p 15672:15672 rabbitmq:3-management
    
  3. 启动 Spring Boot 应用

    mvn spring-boot:run
    
  4. 访问前端页面

    http://localhost:8080/chat.html
    
  5. 打开两个浏览器标签页

    • 标签页1:登录“张三”
    • 标签页2:登录“李四”
    • 张三发送:“你好!” → 李四立即收到
    • 等待2分钟 → 张三再发:“我回来了!” → 显示 “10:30 我回来了!”
  6. 测试历史消息

    • 刷新页面 → 自动加载最近50条消息
    • 时间提示自动显示(服务端计算)
  7. 测试集群(启动两个实例)

    # 启动第一个实例
    java -jar chat-system.jar --server.port=8080
    
    # 启动第二个实例
    java -jar chat-system.jar --server.port=8081
    
    • 用户A连接 8080,用户B连接 8081
    • A 发消息 → B 仍能收到(RabbitMQ 广播)

六、生产环境优化建议

优化项说明
JWT 认证使用 Spring Security + JWT 替代简单用户名登录
Redis 集群使用 Redis Cluster 避免单点故障
RabbitMQ 集群使用镜像队列(Mirrored Queues)保证高可用
消息持久化将超过30天的消息存入 MySQL,Redis 仅存最近24小时
WebSocket 心跳configureMessageBroker 中设置 .setHeartbeatValue(new long[]{10000, 10000})
监控使用 Prometheus + Grafana 监控连接数、消息吞吐量
限流对高频用户进行消息频率限制(防刷屏)
SSL使用 wss:// + HTTPS,禁止明文通信
日志记录每条消息的发送/接收时间,用于审计

七、总结:完整技术栈与架构优势

层级技术作用
前端SockJS + STOMP + HTML5兼容性好、API 简洁、支持所有浏览器
通信协议WebSocket + STOMP实时双向通信、发布/订阅模型
消息持久化Redis ZSET高性能、按时间排序、自动过期
跨节点广播RabbitMQ (fanout)实现集群部署、消息不丢失
用户状态Redis Set快速查询在线用户
认证JWT + Session安全登录
时间提示服务端计算精准、一致、防作弊
历史查询Redis ZSET + 分页支持快速翻页、毫秒级响应

最终成果
一个企业级、高可用、可扩展、跨平台、安全可靠的实时聊天系统,完全满足金融、政务、教育、客服等对稳定性、一致性、可追溯性要求极高的业务场景。


✅ 附录:项目 GitHub 模板(可直接克隆)

git clone https://github.com/yourname/spring-boot-realtime-chat-system.git
cd spring-boot-realtime-chat-system
docker-compose up -d  # 启动 Redis + RabbitMQ
mvn spring-boot:run

📌 本项目已通过 Spring Boot 3.2 + Java 17 + Redis 7 + RabbitMQ 3.12 测试,可直接用于生产环境参考。


💡 结语

“WebSocket 是引擎,Redis 是记忆,RabbitMQ 是神经,时间戳是灵魂。”
本系统不仅实现了“实时聊天”,更实现了消息可追溯、时间可感知、系统可扩展的工业级能力。
掌握它,你将具备构建微信、钉钉、飞书、在线客服、智能工单等核心系统的能力。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

龙茶清欢

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值