一、前言与环境
前言
后台管理系统中的通知提醒、审批待办、未读数变化、订单状态同步,本质上都是服务端先发生变化,客户端需要尽快感知。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.1、sockjs-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 会话中。
我把 userId、username、token 都存入了 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 用于广播推送,sendNoticeToUser 和 broadcastNotice 是针对通知场景的便捷方法。
这种封装的好处是多方面的。业务代码不再直接依赖 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 秒。这个值不宜设置得太小,否则在服务端临时不可用时会产生大量无效的重连请求。
heartbeatIncoming 和 heartbeatOutgoing 都设置为 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,项目包含本文涉及的所有代码。
六、总结
这套方案不仅解决了通知推送的问题,更重要的是沉淀了一套可复用的实时消息基础设施。后续的审批提醒、待办推送、订单状态变更等场景都可以直接复用这套能力,只需要添加新的消息类型和订阅通道即可。