feat(websocket):重构 WebSocket 配置与服务实现

This commit is contained in:
Ray.Hao
2025-11-08 00:01:43 +08:00
parent 1aa6a15a80
commit ffb89e50da
3 changed files with 600 additions and 244 deletions

View File

@@ -27,7 +27,13 @@ import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
/**
* WebSocket配置
* WebSocket 配置
*
* 核心功能:
* - 配置 WebSocket 端点
* - 配置消息代理
* - 实现连接认证与授权
* - 管理用户会话生命周期
*
* @author Ray.Hao
* @since 3.0.0
@@ -37,133 +43,251 @@ import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerCo
@Slf4j
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
private final TokenManager tokenManager;
private final WebSocketService webSocketService;
private static final String WS_ENDPOINT = "/ws";
private static final String APP_DESTINATION_PREFIX = "/app";
private static final String USER_DESTINATION_PREFIX = "/user";
private static final String[] BROKER_DESTINATIONS = {"/topic", "/queue"};
public WebSocketConfig(TokenManager tokenManager, @Lazy WebSocketService webSocketService) {
this.tokenManager = tokenManager;
this.webSocketService = webSocketService;
}
private final TokenManager tokenManager;
private final WebSocketService webSocketService;
/**
* 注册一个端点,客户端通过这个端点进行连接
*/
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry
// 注册 /ws 的端点
.addEndpoint("/ws")
// 允许跨域
.setAllowedOriginPatterns("*");
}
public WebSocketConfig(TokenManager tokenManager, @Lazy WebSocketService webSocketService) {
this.tokenManager = tokenManager;
this.webSocketService = webSocketService;
log.info("✓ WebSocket 配置已加载");
}
/**
* 注册 STOMP 端点
*
* 客户端通过该端点建立 WebSocket 连接
*/
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry
.addEndpoint(WS_ENDPOINT)
.setAllowedOriginPatterns("*"); // 允许跨域(生产环境建议配置具体域名)
/**
* 配置消息代理
*/
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
// 客户端发送消息的请求前缀
registry.setApplicationDestinationPrefixes("/app");
log.info("✓ STOMP 端点已注册: {}", WS_ENDPOINT);
}
// 客户端订阅消息的请求前缀topic一般用于广播推送queue用于点对点推送
registry.enableSimpleBroker("/topic", "/queue");
/**
* 配置消息代理
*
* - /app 前缀:客户端发送消息到服务端的前缀
* - /topic 前缀:用于广播消息
* - /queue 前缀:用于点对点消息
* - /user 前缀:服务端发送给特定用户的消息前缀
*/
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
// 客户端发送消息的请求前缀
registry.setApplicationDestinationPrefixes(APP_DESTINATION_PREFIX);
// 服务端通知客户端的前缀可以不设置默认为user
registry.setUserDestinationPrefix("/user");
}
// 启用简单消息代理,处理 /topic 和 /queue 前缀的消息
registry.enableSimpleBroker(BROKER_DESTINATIONS);
// 服务端通知客户端的前缀
registry.setUserDestinationPrefix(USER_DESTINATION_PREFIX);
/**
* 配置客户端入站通道拦截器
* <p>
* 核心功能:
* 1. 连接建立时解析令牌并绑定用户身份
* 2. 连接关闭时触发下线通知
* 3. 异常Token的防御性处理
*/
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(new ChannelInterceptor() {
@Override
public Message<?> preSend(@NotNull Message<?> message, @NotNull MessageChannel channel) {
StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
if (accessor == null) {
return ChannelInterceptor.super.preSend(message, channel);
log.info("✓ 消息代理已配置: app={}, broker={}, user={}",
APP_DESTINATION_PREFIX, BROKER_DESTINATIONS, USER_DESTINATION_PREFIX);
}
/**
* 配置客户端入站通道拦截器
*
* 核心功能:
* 1. 连接建立时:解析 JWT Token 并绑定用户身份
* 2. 连接关闭时:触发用户下线通知
* 3. 安全防护:拦截无效连接请求
*/
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(new ChannelInterceptor() {
@Override
public Message<?> preSend(@NotNull Message<?> message, @NotNull MessageChannel channel) {
StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
// 防御性检查:确保 accessor 不为空
if (accessor == null) {
log.warn("⚠ 收到异常消息:无法获取 StompHeaderAccessor");
return ChannelInterceptor.super.preSend(message, channel);
}
StompCommand command = accessor.getCommand();
if (command == null) {
return ChannelInterceptor.super.preSend(message, channel);
}
try {
switch (command) {
case CONNECT:
handleConnect(accessor);
break;
case DISCONNECT:
handleDisconnect(accessor);
break;
case SUBSCRIBE:
handleSubscribe(accessor);
break;
default:
// 其他命令不需要特殊处理
break;
}
} catch (AuthenticationException ex) {
// 认证失败时强制关闭连接
log.error("❌ 连接认证失败: {}", ex.getMessage());
throw ex;
} catch (Exception ex) {
// 捕获其他未知异常
log.error("❌ WebSocket 消息处理异常", ex);
throw new MessagingException("消息处理失败: " + ex.getMessage());
}
return ChannelInterceptor.super.preSend(message, channel);
}
});
log.info("✓ 客户端入站通道拦截器已配置");
}
/**
* 处理客户端连接请求
*
* 安全校验流程:
* 1. 提取 Authorization 头
* 2. 验证 Bearer Token 格式
* 3. 解析并验证 JWT 有效性
* 4. 绑定用户身份到当前会话
* 5. 记录用户上线状态
*/
private void handleConnect(StompHeaderAccessor accessor) {
String authorization = accessor.getFirstNativeHeader(HttpHeaders.AUTHORIZATION);
// 安全检查:确保 Authorization 头存在且格式正确
if (StrUtil.isBlank(authorization)) {
log.warn("⚠ 非法连接请求:缺少 Authorization 头");
throw new AuthenticationCredentialsNotFoundException("缺少 Authorization 头");
}
if (!authorization.startsWith("Bearer ")) {
log.warn("⚠ 非法连接请求Authorization 头格式错误");
throw new BadCredentialsException("Authorization 头格式错误");
}
// 提取 JWT Token移除 "Bearer " 前缀)
String token = authorization.substring(7);
if (StrUtil.isBlank(token)) {
log.warn("⚠ 非法连接请求Token 为空");
throw new BadCredentialsException("Token 为空");
}
// 解析并验证 Token
Authentication authentication;
try {
// 处理客户端连接请求
if (StompCommand.CONNECT.equals(accessor.getCommand())) {
/*
* 安全校验流程:
* 1. 从HEADER中获取Authorization值
* 2. 校验Bearer Token格式合法性
* 3. 解析并验证JWT有效性
* 4. 绑定用户身份到当前会话
*/
String authorization = accessor.getFirstNativeHeader(HttpHeaders.AUTHORIZATION);
// 防御性校验确保Authorization头存在且格式正确
if (StrUtil.isBlank(authorization) || !authorization.startsWith("Bearer ")) {
log.warn("非法连接请求缺少有效的Authorization头");
throw new AuthenticationCredentialsNotFoundException("Missing authorization header");
}
// 提取并处理JWT令牌移除Bearer前缀
String token = authorization.substring(7);
Authentication authentication = tokenManager.parseToken(token);
// 令牌解析失败处理
if (authentication == null) {
log.error("令牌解析失败:{}", token);
throw new BadCredentialsException("Invalid token");
}
// 获取用户详细信息
SysUserDetails userDetails = (SysUserDetails) authentication.getPrincipal();
if (userDetails == null || StrUtil.isBlank(userDetails.getUsername())) {
log.error("无效的用户凭证:{}", token);
throw new BadCredentialsException("Invalid user credentials");
}
String username = userDetails.getUsername();
log.info("WebSocket连接建立用户[{}]", username);
// 绑定用户身份到当前会话(重要:用于@SendToUser等注解
accessor.setUser(authentication);
// 记录用户上线状态
webSocketService.userConnected(username, accessor.getSessionId());
}
// 处理客户端断开请求
else if (StompCommand.DISCONNECT.equals(accessor.getCommand())) {
/*
* 注意:只有成功建立过认证的连接才会触发下线事件
* 防止未认证成功的连接产生脏数据
*/
Authentication authentication = (Authentication) accessor.getUser();
if (authentication != null && authentication.isAuthenticated()) {
String username = ((SysUserDetails) authentication.getPrincipal()).getUsername();
log.info("WebSocket连接关闭用户[{}]", username);
// 记录用户下线状态
webSocketService.userDisconnected(username);
}
}
} catch (AuthenticationException ex) {
// 认证失败时强制关闭连接
log.error("连接认证失败:{}", ex.getMessage());
throw ex;
authentication = tokenManager.parseToken(token);
} catch (Exception ex) {
// 捕获其他未知异常
log.error("WebSocket连接处理异常", ex);
throw new MessagingException("Connection processing failed");
log.error("❌ Token 解析失败", ex);
throw new BadCredentialsException("Token 无效: " + ex.getMessage());
}
return ChannelInterceptor.super.preSend(message, channel);
}
});
}
// 验证解析结果
if (authentication == null || !authentication.isAuthenticated()) {
log.warn("⚠ Token 解析失败:认证对象无效");
throw new BadCredentialsException("Token 解析失败");
}
// 获取用户详细信息
Object principal = authentication.getPrincipal();
if (!(principal instanceof SysUserDetails)) {
log.error("❌ 无效的用户凭证类型: {}", principal.getClass().getName());
throw new BadCredentialsException("用户凭证类型错误");
}
SysUserDetails userDetails = (SysUserDetails) principal;
String username = userDetails.getUsername();
if (StrUtil.isBlank(username)) {
log.warn("⚠ 用户名为空");
throw new BadCredentialsException("用户名为空");
}
// 绑定用户身份到当前会话(重要:用于 @SendToUser 等注解)
accessor.setUser(authentication);
// 获取会话 ID
String sessionId = accessor.getSessionId();
if (sessionId == null) {
log.warn("⚠ 会话 ID 为空,使用临时 ID");
sessionId = "temp-" + System.nanoTime();
}
// 记录用户上线状态
try {
webSocketService.userConnected(username, sessionId);
log.info("✓ WebSocket 连接建立成功: 用户[{}], 会话[{}]", username, sessionId);
} catch (Exception ex) {
log.error("❌ 记录用户上线状态失败: 用户[{}], 会话[{}]", username, sessionId, ex);
// 不抛出异常,允许连接继续
}
}
/**
* 处理客户端断开连接事件
*
* 注意:
* - 只有成功建立过认证的连接才会触发下线事件
* - 防止未认证成功的连接产生脏数据
*/
private void handleDisconnect(StompHeaderAccessor accessor) {
Authentication authentication = (Authentication) accessor.getUser();
// 防御性检查:只处理已认证的连接
if (authentication == null || !authentication.isAuthenticated()) {
log.debug("未认证的连接断开,跳过处理");
return;
}
Object principal = authentication.getPrincipal();
if (!(principal instanceof SysUserDetails)) {
log.warn("⚠ 断开连接时用户凭证类型异常");
return;
}
SysUserDetails userDetails = (SysUserDetails) principal;
String username = userDetails.getUsername();
if (StrUtil.isNotBlank(username)) {
try {
webSocketService.userDisconnected(username);
log.info("✓ WebSocket 连接断开: 用户[{}]", username);
} catch (Exception ex) {
log.error("❌ 记录用户下线状态失败: 用户[{}]", username, ex);
}
}
}
/**
* 处理客户端订阅事件(可选)
*
* 用于记录订阅信息或实施订阅级别的权限控制
*/
private void handleSubscribe(StompHeaderAccessor accessor) {
Authentication authentication = (Authentication) accessor.getUser();
if (authentication != null && authentication.isAuthenticated()) {
String destination = accessor.getDestination();
String username = authentication.getName();
log.debug("用户[{}]订阅主题: {}", username, destination);
// TODO: 这里可以实现订阅级别的权限控制
// 例如:检查用户是否有权限订阅某个主题
}
}
}