Spring Boot整合Keycloak认证的STOMP WebSocket通知推送异常排查
我最近在做一个Spring Boot项目,用STOMP over SockJS实现实时通知功能,同时集成了Keycloak做JWT认证。目前遇到个头疼的问题:后端明明已经触发了消息推送逻辑,前端却完全收不到通知。想请大伙帮忙看看哪里出问题了,先把当前的实现和情况列出来:
一、整体业务流程
- 前端通过
/ws端点发起WebSocket连接,在请求头里携带JWT的Authorization: Bearer <token> - 后端通过
ChannelInterceptor拦截CONNECT请求,解析JWT后将用户的sub(UUID格式)设置为WebSocket会话的principal - 前端成功连接后,订阅
/user/notifications主题 - 后端业务逻辑触发通知时,调用
SimpMessagingTemplate.convertAndSendToUser(userId, "/notifications", message)推送消息,这里的userId就是从Keycloak拿到的用户sub
二、核心代码配置
1. WebSocketConfig配置类
@Configuration @EnableWebSocketMessageBroker @Order(Ordered.HIGHEST_PRECEDENCE + 99) @RequiredArgsConstructor public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { private final JwtDecoder jwtDecoder; private final JwtAuthConverter jwtAuthConverter; @Value("${oxyreimobile.oauth2.keycloak.realm}") private String realm; @Override public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint("/ws") .setAllowedOriginPatterns("*") // TODO 后续调整为具体允许的源 .withSockJS(); } @Override public void configureMessageBroker(MessageBrokerRegistry registry) { registry.enableSimpleBroker("/user"); registry.setApplicationDestinationPrefixes("/app"); registry.setUserDestinationPrefix("/user"); } @Override public void configureClientInboundChannel(ChannelRegistration registration) { registration.interceptors(new ChannelInterceptor() { @Override public Message<?> preSend(Message<?> message, MessageChannel channel) { StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message); if (StompCommand.CONNECT.equals(accessor.getCommand())) { String authHeader = accessor.getFirstNativeHeader("Authorization"); if (authHeader != null && authHeader.startsWith("Bearer ")) { String token = authHeader.substring(7); try { Jwt jwt = jwtDecoder.decode(token); Authentication authentication = jwtAuthConverter.convert(jwt); SecurityContextHolder.getContext().setAuthentication(authentication); accessor.setUser(authentication); System.out.println("✅ WebSocket CONNECT: Principal = " + authentication.getName()); } catch (Exception e) { throw new RuntimeException("JWT non valido: " + e.getMessage()); } } } return message; } }); } }
2. 后端推送逻辑
业务服务中调用推送的代码:
// 业务触发时调用,emailCaregiver是用户邮箱,先拿到sub再推 sendNotificationWebSocket(keycloakServices.getSub(emailCaregiver), n); private void sendNotificationWebSocket(String userId, Notifications notifica) { System.out.println("📤 Invio WebSocket a utente: " + userId + " contenuto: " + notifica.getSubject()); sim.convertAndSendToUser( userId, "/notifications", Map.of("content", "Nuova notifica: " + notifica.getSubject()) ); }
3. 前端测试代码
我写了个简单的HTML页面测试:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>Test WebSocket</title> </head> <body> <h1>Test WebSocket</h1> <!-- SockJS --> <script src="https://cdn.jsdelivr.net/npm/sockjs-client/dist/sockjs.min.js"></script> <!-- STOMP --> <script src="https://cdn.jsdelivr.net/npm/stompjs/lib/stomp.min.js"></script> <script> const socket = new SockJS('http://localhost:8080/ws'); const client = Stomp.over(socket); const token = "这里替换成有效JWT"; client.connect( { Authorization: `Bearer ${token}` }, frame => { console.log('✅ Connesso al WebSocket:', frame); client.subscribe(`/user/notifications`, msg => { console.log("📩 Notifica ricevuta:", msg.body); }); }, error => console.error('❌ Errore connessione WebSocket:', error) ); </script> </body> </html>
三、后端关键日志
从日志里能看到关键环节都是正常的:
2025-09-19T23:29:26.755+02:00 DEBUG 10008 --- [app] [nio-8080-exec-5] s.w.s.h.LoggingWebSocketHandlerDecorator : New WebSocketServerSockJsSession[id=smnkzqoc] ✅ WebSocket CONNECT: Principal = 71412206-e72d-478c-b1f2-4447f386424d 2025-09-19T23:29:29.239+02:00 DEBUG 10008 --- [app] [nboundChannel-2] o.s.m.s.b.SimpleBrokerMessageHandler : Processing CONNECT session=smnkzqoc 2025-09-19T23:29:36.053+02:00 DEBUG 10008 --- [app] [nboundChannel-5] o.s.m.s.b.SimpleBrokerMessageHandler : Processing SUBSCRIBE /user/notifications id=sub-0 session=smnkzqoc 2025-09-19T23:29:43.693+02:00 INFO 10008 --- [app] [nio-8080-exec-2] c.m.o.o.s.impl.KeycloakServiceImpl : getSub of the username useruser@live.it 📤 Invio WebSocket a utente: 71412206-e72d-478c-b1f2-4447f386424d contenuto: E' presente un questionario da compilare, accedi all'apposita area per completare il <B>Questionnaire</B> 2025-09-19T23:29:43.920+02:00 DEBUG 10008 --- [app] [nio-80...
可以看到:
- 连接时设置的principal是
71412206-e72d-478c-b1f2-4447f386424d - 前端成功订阅了
/user/notifications - 推送时用的userId和principal完全一致
四、已确认&疑问
✅ 已确认:
- JWT解析正常,principal设置正确(日志打印的principal和用户sub一致)
- 前端连接、订阅都成功,没有报错
- 推送的userId和会话principal完全匹配
❓ 疑问:
明明所有环节看起来都正常,为什么前端就是收不到推送的消息?有没有大佬遇到过类似的问题,或者帮我看看代码配置里有没有隐藏的坑?
五、我自己琢磨的几个排查方向
UserDestinationPrefix的映射逻辑:我配置了
setUserDestinationPrefix("/user"),前端订阅/user/notifications,后端convertAndSendToUser会不会把目标转换成/user/71412206-e72d-478c-b1f2-4447f386424d/notifications?那前端订阅的是/user/notifications,是不是应该订阅/user/{userId}/notifications?不对啊,我记得Spring会自动处理用户目标的映射,前端只需要订阅/user/notifications就行?Authentication的name是否正确:
convertAndSendToUser默认是用Authentication.getName()来匹配WebSocket会话的,我的jwtAuthConverter是不是真的把JWT的sub设置成了Authentication的name?会不会转换器返回的name是其他字段(比如用户名)?SimpleBroker的配置:我用了
enableSimpleBroker("/user"),是不是应该把/user下的所有子路径都代理?有没有可能SimpleBroker的配置不支持这种用户专属的目标转发?SockJS的兼容性:会不会是SockJS的帧处理有问题?比如消息已经推到前端,但SockJS没有正确转发到STOMP客户端?
希望大伙能给点思路,或者指出我代码里的明显错误,感谢!




