Spring Boot 3 + WebSocket + STOMP + JWT 实现实时消息推送完整方案

一、前言与环境

前言

后台管理系统中的通知提醒、审批待办、未读数变化、订单状态同步,本质上都是服务端先发生变化,客户端需要尽快感知。HTTP 轮询虽然能工作,但消息延迟明显,且大量无效请求会浪费服务端资源。WebSocket 允许服务端主动推送,避免了轮询的性能和实时性问题。本文结合开源项目的真实实现,从技术选型、消息模型、JWT 鉴权、前端封装到稳定性处理,完整展示如何在 Spring Boot 项目中落地 WebSocket 实时推送能力。

系统环境

组件 版本
JDK 17
Spring Boot 3.5.9
Vue 3.5.13
Node.js >= 18
WebSocket 客户端 @stomp/stompjs 7.2.1sockjs-client 1.6.1

如果你的环境和本文不完全一致,需要确认 Spring Boot 3.x 和 Spring Security / JWT 的配置方式是否匹配,前端 stompjs 与 sockjs-client 的版本跨度是否过大,以及如果项目前面还挂了网关、Nginx 或其他反向代理,ws 路径的转发是否已经正确放通。

二、方案设计

架构设计

整套方案遵循一个核心原则:HTTP 负责拉全量,WebSocket 负责推增量。页面初始化时通过 HTTP 获取未读数和通知列表,后续状态变化由 WebSocket 主动推送。这样职责边界清晰,WebSocket 专注于实时通知,不会被滥用成另一个接口层。

消息模型上,通知内容是公共信息,使用 /topic 广播;未读数是用户私有状态,通过 /user/queue 精准推送。

消息流程

整条链路的完整流程如下:

三、后端实现

WebSocket 配置

在 Spring Boot 里启用 WebSocket 消息代理需要实现 WebSocketMessageBrokerConfigurer 接口。核心配置包括三个部分:消息代理配置、端点注册、拦截器注册。

typescript 复制代码
@Configuration
@EnableWebSocketMessageBroker
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
​
    private final JwtChannelInterceptor jwtChannelInterceptor;
​
    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.enableSimpleBroker("/topic", "/queue");
        registry.setApplicationDestinationPrefixes("/app");
        registry.setUserDestinationPrefix("/user");
    }
​
    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws")
                .setAllowedOriginPatterns("*")
                .withSockJS();
    }
​
    @Override
    public void configureClientInboundChannel(ChannelRegistration registration) {
        registration.interceptors(jwtChannelInterceptor);
    }
}

这段配置把整条链路最核心的几件事约定清楚了。/ws 是前端建立连接的统一入口,/topic/queue 把广播消息和点对点消息的语义分开,/user 作为用户目标前缀会直接影响后续按用户推送的实现方式,而 JwtChannelInterceptor 被接入到入站通道之后,连接阶段就开始纳入统一鉴权。

enableSimpleBroker 启用了内存消息代理,支持 /topic/queue 两种目标前缀。这是 Spring 提供的简单实现,适合单体应用或中小规模部署。如果需要支持多实例集群,可以替换成 RabbitMQ 或 ActiveMQ 等外部消息代理。

setUserDestinationPrefix 定义了用户私有消息的前缀。当服务端调用 convertAndSendToUser(userId, "/queue/unread-count", message) 时,实际发送的目标是 /user/{userId}/queue/unread-count。这个转换由 Spring 自动完成,开发者不需要手动拼接路径。

JWT 鉴权集成

很多 WebSocket 教程把重点放在"能不能连接成功"上,但真实项目里如果不把认证问题处理好,这条链路几乎是不完整的。因为通知、未读数、待办这些内容都强依赖当前登录用户,一旦连接本身没有身份约束,后面的按用户推送就很容易出现安全和逻辑上的问题。

在 STOMP 的 CONNECT 阶段读取请求头里的 Authorization: Bearer xxx,只有 Token 校验通过后,才允许这条连接真正建立起来,并把当前用户信息放进会话上下文中。

java 复制代码
@Component
@RequiredArgsConstructor
@Slf4j
public class JwtChannelInterceptor implements ChannelInterceptor {
​
    private final JwtUtils jwtUtils;
    private final UserSessionService userSessionService;
    private final UserDetailsService userDetailsService;
​
    @Override
    public Message<?> preSend(Message<?> message, MessageChannel channel) {
        StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
​
        if (accessor != null && StompCommand.CONNECT.equals(accessor.getCommand())) {
            String token = extractToken(accessor);
​
            if (token == null || !userSessionService.isTokenValid(token)) {
                return null;
            }
​
            String username = jwtUtils.getUsernameFromToken(token);
            AuthUser authUser = (AuthUser) userDetailsService.loadUserByUsername(username);
​
            UsernamePasswordAuthenticationToken authentication =
                    new UsernamePasswordAuthenticationToken(authUser, null, authUser.getAuthorities());
​
            accessor.setUser(authentication);
            accessor.getSessionAttributes().put("userId", authUser.getUserId());
            accessor.getSessionAttributes().put("username", authUser.getUsername());
            accessor.getSessionAttributes().put("token", token);
​
            userSessionService.setSessionActive(token, true);
        } else if (accessor != null && StompCommand.DISCONNECT.equals(accessor.getCommand())) {
            String token = (String) accessor.getSessionAttributes().get("token");
            if (token != null) {
                userSessionService.setSessionActive(token, false);
            }
        }
​
        return message;
    }
}

这个拦截器的核心逻辑是在 CONNECT 命令到达时进行 Token 校验。如果 Token 无效或已过期,直接返回 null,连接会被拒绝。如果 Token 有效,则从 Token 中提取用户信息,构造 Authentication 对象并设置到 STOMP 会话中。

我把 userIdusernametoken 都存入了 SessionAttributes。这样做的目的是在后续的消息处理和连接断开时,可以直接从会话中获取用户信息,而不需要重复解析 Token。

另外,在连接建立和断开时都调用了 userSessionService.setSessionActive。这个方法用于维护用户的在线状态。当需要给所有在线用户推送未读数时,可以通过这个服务查询当前在线的用户列表,避免给离线用户推送消息。

消息推送服务封装

如果在业务代码中直接使用 SimpMessagingTemplate 进行消息推送,代码会变得分散且难以维护。更好的做法是封装一个通用的消息推送服务,统一管理所有的推送逻辑。

typescript 复制代码
@Service
@RequiredArgsConstructor
@Slf4j
public class WebSocketMessageService {
​
    private final SimpMessagingTemplate messagingTemplate;
​
    public void sendToUser(String userId, String destination, Object message) {
        messagingTemplate.convertAndSendToUser(userId, destination, message);
    }
​
    public void broadcast(String destination, Object message) {
        messagingTemplate.convertAndSend(destination, message);
    }
​
    public void sendNoticeToUser(Long userId, Object notice) {
        sendToUser(userId.toString(), "/queue/notices", notice);
    }
​
    public void broadcastNotice(Object notice) {
        broadcast("/topic/notices", notice);
    }
}

这个服务提供了四个方法:sendToUser 用于点对点推送,broadcast 用于广播推送,sendNoticeToUserbroadcastNotice 是针对通知场景的便捷方法。

这种封装的好处是多方面的。业务代码不再直接依赖 SimpMessagingTemplate,降低了耦合度。所有的推送逻辑都收口到这个服务中,便于统一添加日志、监控、限流等横切关注点。当需要扩展新的推送场景时,只需要在这个服务中添加新方法即可。

业务层实现

通知发布的业务逻辑需要同时处理数据持久化和消息推送。这两个操作必须在同一个事务中完成,确保数据一致性。

less 复制代码
@Override
@Transactional(rollbackFor = Exception.class)
public Long publishNotice(NoticeDTO dto, Long publisherId) {
    SysNotice notice = SysNotice.INSTANCE.toEntity(dto);
    notice.setPublisherId(publisherId);
    notice.setPublishTime(LocalDateTime.now());
    notice.setStatus(1);
​
    this.save(notice);
​
    sysNoticeWebSocketService.broadcast(notice);
    sysNoticeWebSocketService.broadcastUnreadCountToAllOnlineUsers();
​
    return notice.getId();
}

这里先将通知保存到数据库,然后广播通知内容,最后给所有在线用户推送各自的未读数。这三个操作的顺序不能颠倒。如果先推送消息再保存数据库,可能会出现用户收到通知但数据库中查不到记录的情况。

未读数推送的实现需要遍历所有在线用户,逐个计算未读数并推送。

typescript 复制代码
public void broadcastUnreadCountToAllOnlineUsers() {
    List<String> onlineUsers = userSessionService.getOnlineUsers();
    for (String username : onlineUsers) {
        Long unreadCount = sysNoticeService.getUnreadCountByUsername(username);
        sendUnreadCountToUser(username, unreadCount);
    }
}
​
public void sendUnreadCountToUser(String username, Long unreadCount) {
    UnreadCountVO vo = new UnreadCountVO(unreadCount);
    webSocketMessageService.sendToUser(username, "/queue/unread-count", vo);
}

这里有一个性能优化点。如果在线用户数量很大,逐个查询未读数会产生大量数据库查询。可以考虑批量查询所有用户的未读数,然后再逐个推送。

四、前端实现

连接管理

前端的 WebSocket 连接管理是整个方案中最复杂的部分。除了基本的连接建立和消息订阅,还需要处理心跳保活、自动重连、页面恢复等场景。

我把这些逻辑封装成了一个 useWebSocket 组合式函数,提供统一的连接管理能力。

javascript 复制代码
import SockJS from 'sockjs-client'
import { Client } from '@stomp/stompjs'
import { ref } from 'vue'
​
export function useWebSocket() {
  const client = ref<Client | null>(null)
  const connected = ref(false)
​
  function connect(url: string, token: string) {
    const socket = new SockJS(url)
​
    client.value = new Client({
      webSocketFactory: () => socket,
      connectHeaders: {
        Authorization: `Bearer ${token}`
      },
      reconnectDelay: 5000,
      heartbeatIncoming: 4000,
      heartbeatOutgoing: 4000,
​
      onConnect: () => {
        connected.value = true
        
        client.value?.subscribe('/user/queue/unread-count', (message) => {
          const data = JSON.parse(message.body)
          handleUnreadCountChange(data)
        })
​
        client.value?.subscribe('/topic/notices', (message) => {
          const notice = JSON.parse(message.body)
          handleNewNotice(notice)
        })
​
        fetchInitialData()
      },
​
      onDisconnect: () => {
        connected.value = false
      },
​
      onStompError: (frame) => {
        console.error('STOMP error:', frame)
      }
    })
​
    client.value.activate()
  }
​
  function disconnect() {
    client.value?.deactivate()
  }
​
  return {
    connect,
    disconnect,
    connected
  }
}

连接建立时,通过 connectHeaders 传入 JWT Token。这个 Token 会在服务端的拦截器中被提取和校验。

reconnectDelay 设置为 5000 毫秒,表示连接断开后会自动尝试重连,重连间隔为 5 秒。这个值不宜设置得太小,否则在服务端临时不可用时会产生大量无效的重连请求。

heartbeatIncomingheartbeatOutgoing 都设置为 4000 毫秒,表示客户端和服务端每 4 秒会互相发送一次心跳消息。心跳机制的作用是尽早发现连接异常,避免连接已经断开但客户端还认为连接正常的情况。

连接成功后,立即订阅两个通道并调用 fetchInitialData 拉取初始数据。这个顺序很重要。如果先拉取数据再订阅通道,可能会错过拉取数据和订阅通道之间产生的消息。

这里有一个细节:连接成功之后,我依然会调用一次 HTTP 接口去拉未读数和最近通知列表。WebSocket 更适合推送变化,而不是替代页面初始化。页面第一次进入时,先让 HTTP 把全量状态补齐,等页面进入持续运行阶段,再交给 WebSocket 去推送增量变化。

稳定性优化

除了基本的重连机制,还需要处理一些特殊场景。

当用户切换浏览器标签页时,浏览器可能会降低后台标签页的优先级,甚至暂停 JavaScript 执行。当用户切回标签页时,需要检查连接状态,如果连接已断开则尝试重连。

javascript 复制代码
document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'visible' && !connected.value) {
    connect(wsUrl, token)
  }
})

当浏览器从离线状态恢复到在线状态时,也需要尝试重连。

scss 复制代码
window.addEventListener('online', () => {
  if (!connected.value) {
    connect(wsUrl, token)
  }
})

这些细节虽然不复杂,但它们决定了 WebSocket 方案在真实业务场景下的可用性。没有这些处理,用户在弱网环境下会频繁遇到"消息收不到"的问题。

用户体验优化

仅仅把消息推送到前端是不够的,还需要考虑如何将消息展示给用户。

当收到新通知时,如果页面当前可见,应该弹出一个通知卡片,提示用户有新消息。如果页面不可见,可以考虑使用浏览器的 Notification API 发送系统通知。

javascript 复制代码
function handleNewNotice(notice: Notice) {
  noticeStore.addNotice(notice)

  if (document.visibilityState === 'visible') {
    showNoticeCard(notice)
  } else {
    if (Notification.permission === 'granted') {
      new Notification('新通知', {
        body: notice.title,
        icon: '/favicon.ico'
      })
    }
  }
}

未读数变化时,需要同步更新页面上所有显示未读数的地方,包括顶部导航栏的徽章、通知列表的未读标记等。

scss 复制代码
function handleUnreadCountChange(data: UnreadCountVO) {
  noticeStore.setUnreadCount(data.unreadCount)
}

这些交互细节看似简单,但它们直接影响用户对"实时推送"的感知。如果消息推送到了但用户没有察觉,那么实时推送的价值就大打折扣了。

五、源码与在线体验

完整源码gitee.com/leven2018/l...

在线体验http://106.54.167.194/admin/index

  • 🔐 RBAC 权限控制:完整的角色权限体系,支持菜单权限、按钮权限、数据权限的细粒度控制,可精确到每个按钮的显示与隐藏
  • 🤖 AI 智能集成:集成大模型与 RAG 技术,支持 Tool Calling(AI 主动调用业务接口)和 MCP 协议(接入外部工具服务),可运用于智能客服、知识库问答、自动发布通知、联网搜索等多种 AI 应用场景
  • 🏢 多租户架构:基于共享数据库模式的多租户设计,支持租户数据隔离、租户套餐配置、差异化权限管理,适用于 SaaS 平台场景
  • 🎨 界面个性化:后台管理界面支持 4 种不同布局模式(经典、顶部菜单、混合、简约),内置多套主题换肤功能
  • 👥 实时用户监控:支持在线用户实时监控、会话管理、异地登录检测、一键踢出用户等安全管控功能
  • 📊 SQL 性能监控:集成 Druid 监控,实时展示 SQL 执行统计、慢查询分析、数据库连接池状态等性能指标
  • ✏️ 富文本编辑:集成 wangEditor 富文本编辑器,支持图文混排、表格插入、代码高亮等功能,可用于文章、博客发布等内容管理场景

欢迎 Star ⭐ 和 Fork,项目包含本文涉及的所有代码。

六、总结

这套方案不仅解决了通知推送的问题,更重要的是沉淀了一套可复用的实时消息基础设施。后续的审批提醒、待办推送、订单状态变更等场景都可以直接复用这套能力,只需要添加新的消息类型和订阅通道即可。

相关推荐
Kilsme_Czy1 小时前
Lanchain4j的入门学习
后端
@PHARAOH2 小时前
HOW - Go 开发入门(四)- ORM 对象关系映射
开发语言·后端·golang
cmd2 小时前
Vue3 JSX 语法速查:v-model、事件、插槽一网打尽
vue.js
计算机学姐2 小时前
基于SpringBoot的汽车美容保养系统
java·spring boot·后端·spring·tomcat·汽车·mybatis
爱吃山竹的大肚肚2 小时前
依赖冲突快速解决
java·spring boot·后端·spring cloud·maven
得物技术2 小时前
大禹平台:流批一体离线Dump平台的设计与应用|得物技术
java·后端·算法
Java编程爱好者2 小时前
Java工程师复健Spring IoC:所有Java开发的第一个面试题
后端
轩情吖2 小时前
MySQL之表的增删查改
android·开发语言·c++·后端·mysql·adb·
卤蛋七号2 小时前
springboot整合validation详细教程
后端