feat:Websocket服务拆到单独服务,主后台保持单工通信

This commit is contained in:
zpaeng
2025-09-02 23:10:29 +08:00
parent c337195b16
commit 78a65c6afe
35 changed files with 1504 additions and 329 deletions

View File

@@ -0,0 +1,13 @@
package com.openisle.websocket;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class WebsocketServiceApplication {
public static void main(String[] args) {
SpringApplication.run(WebsocketServiceApplication.class, args);
}
}

View File

@@ -0,0 +1,27 @@
package com.openisle.websocket.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.amqp.rabbit.connection.ConnectionFactory;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class RabbitMQConfig {
@Bean
public Jackson2JsonMessageConverter messageConverter() {
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new com.fasterxml.jackson.datatype.jsr310.JavaTimeModule());
objectMapper.disable(com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
return new Jackson2JsonMessageConverter(objectMapper);
}
@Bean
public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory) {
RabbitTemplate template = new RabbitTemplate(connectionFactory);
template.setMessageConverter(messageConverter());
return template;
}
}

View File

@@ -0,0 +1,84 @@
package com.openisle.websocket.config;
import com.openisle.websocket.security.JwtService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.simp.stomp.StompCommand;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.messaging.support.ChannelInterceptor;
import org.springframework.messaging.support.MessageHeaderAccessor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
@Component
@RequiredArgsConstructor
@Slf4j
public class WebSocketAuthInterceptor implements ChannelInterceptor {
private final JwtService jwtService;
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
if (accessor != null && StompCommand.CONNECT.equals(accessor.getCommand())) {
log.info("WebSocket CONNECT 请求 - 开始认证");
String authHeader = accessor.getFirstNativeHeader("Authorization");
log.debug("Authorization 头: {}", authHeader != null ? "存在" : "缺失");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7);
log.debug("提取的token长度: {}", token.length());
try {
String username = jwtService.extractUsername(token);
log.debug("从token中提取的用户名: {}", username);
if (username != null && jwtService.isTokenValid(token)) {
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(username, null, new ArrayList<>());
SecurityContextHolder.getContext().setAuthentication(authToken);
accessor.setUser(authToken);
log.info("WebSocket 连接认证成功,用户: {}", username);
} else {
log.warn("WebSocket 连接认证失败 - token无效或用户名为空");
log.debug("用户名: {}, token有效性: {}", username, jwtService.isTokenValid(token));
return null; // 拒绝连接
}
} catch (Exception e) {
log.error("WebSocket JWT token处理异常: {}", e.getMessage(), e);
return null; // 拒绝连接
}
} else {
log.warn("WebSocket 连接认证失败 - 缺少有效的Authorization头");
log.debug("Authorization头内容: {}", authHeader);
return null; // 拒绝连接
}
} else if (accessor != null && StompCommand.SUBSCRIBE.equals(accessor.getCommand())) {
log.debug("WebSocket SUBSCRIBE 请求到: {}", accessor.getDestination());
} else if (accessor != null && StompCommand.SEND.equals(accessor.getCommand())) {
log.debug("WebSocket SEND 请求到: {}", accessor.getDestination());
}
return message;
}
@Override
public void postSend(Message<?> message, MessageChannel channel, boolean sent) {
StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
if (accessor != null) {
if (StompCommand.CONNECT.equals(accessor.getCommand()) && sent) {
log.info("WebSocket 连接建立成功");
} else if (StompCommand.DISCONNECT.equals(accessor.getCommand())) {
log.info("WebSocket 连接已断开");
}
}
}
}

View File

@@ -0,0 +1,73 @@
package com.openisle.websocket.config;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.ChannelRegistration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
@Configuration
@EnableWebSocketMessageBroker
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
private final WebSocketAuthInterceptor webSocketAuthInterceptor;
@Value("${app.website-url}")
private String websiteUrl;
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
ThreadPoolTaskScheduler ts = new ThreadPoolTaskScheduler();
ts.setPoolSize(1);
ts.setThreadNamePrefix("wss-heartbeat-thread-");
ts.initialize();
config.enableSimpleBroker("/queue", "/topic")
.setHeartbeatValue(new long[]{10000, 10000})
.setTaskScheduler(ts);
config.setApplicationDestinationPrefixes("/app");
config.setUserDestinationPrefix("/user");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
// 1) 原生 WebSocket不带 SockJS
registry.addEndpoint("/api/ws")
.setAllowedOriginPatterns(
"https://staging.open-isle.com",
"https://www.staging.open-isle.com",
websiteUrl,
websiteUrl.replace("://www.", "://"),
"http://localhost:*",
"http://127.0.0.1:*",
"http://192.168.7.98:*",
"http://30.211.97.238:*"
);
// 2) SockJS 回退:单独路径
registry.addEndpoint("/api/sockjs")
.setAllowedOriginPatterns(
"https://staging.open-isle.com",
"https://www.staging.open-isle.com",
websiteUrl,
websiteUrl.replace("://www.", "://"),
"http://localhost:*",
"http://127.0.0.1:*",
"http://192.168.7.98:*",
"http://30.211.97.238:*"
)
.withSockJS()
.setWebSocketEnabled(true)
.setSessionCookieNeeded(false);
}
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(webSocketAuthInterceptor);
}
}

View File

@@ -0,0 +1,15 @@
package com.openisle.websocket.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class MessageNotificationPayload implements Serializable {
private String targetUsername;
private Object payload;
}

View File

@@ -0,0 +1,113 @@
package com.openisle.websocket.listener;
import com.openisle.websocket.dto.MessageNotificationPayload;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.lang.Nullable;
import org.springframework.messaging.handler.annotation.Header;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
@Component
@RequiredArgsConstructor
@Slf4j
public class NotificationListener {
private final SimpMessagingTemplate messagingTemplate;
/**
* Unified listener for all sharded queues and the backward-compatible legacy queue.
*
* @param payload The message payload.
* @param queueName The name of the queue the message was consumed from. This header is optional.
*/
@RabbitListener(
id = "shardedListenerContainer",
queues = {
"notifications-queue-0", "notifications-queue-1", "notifications-queue-2", "notifications-queue-3",
"notifications-queue-4", "notifications-queue-5", "notifications-queue-6", "notifications-queue-7",
"notifications-queue-8", "notifications-queue-9", "notifications-queue-a", "notifications-queue-b",
"notifications-queue-c", "notifications-queue-d", "notifications-queue-e", "notifications-queue-f",
"notifications-queue"
}
)
public void receiveMessage(MessageNotificationPayload payload, @Header("amqp_consumedQueue") @Nullable String queueName) {
if (queueName != null) {
String queueNamePrefix = "notifications-queue-";
if (queueName.startsWith(queueNamePrefix)) {
String shardIndexStr = queueName.substring(queueNamePrefix.length());
log.info("=== RabbitMQ Message Received from Shard {} ({}) ===", shardIndexStr, queueName);
} else {
log.info("=== RabbitMQ Message Received from Legacy Queue ({}) ===", queueName);
}
}
String username = payload.getTargetUsername();
Object payloadObject = payload.getPayload();
log.info("Target username: {}", username);
log.info("Payload object type: {}", payloadObject != null ? payloadObject.getClass().getSimpleName() : "null");
log.info("Payload content: {}", payloadObject);
try {
if (payloadObject instanceof Map) {
Map<String, Object> payloadMap = (Map<String, Object>) payloadObject;
// 处理包含完整对话信息的消息 - 完全复制之前的WebSocket发送逻辑
if (payloadMap.containsKey("message") && payloadMap.containsKey("conversation") && payloadMap.containsKey("senderId")) {
Object messageObj = payloadMap.get("message");
Map<String, Object> conversationInfo = (Map<String, Object>) payloadMap.get("conversation");
Long conversationId = ((Number) conversationInfo.get("id")).longValue();
Long senderId = ((Number) payloadMap.get("senderId")).longValue();
List<Map<String, Object>> participants = (List<Map<String, Object>>) conversationInfo.get("participants");
// 1. 发送到conversation topic
String conversationDestination = "/topic/conversation/" + conversationId;
messagingTemplate.convertAndSend(conversationDestination, messageObj);
log.info("Message broadcasted to destination: {}", conversationDestination);
// 2. 为所有参与者(除发送者外)发送到个人频道和未读数量
for (Map<String, Object> participant : participants) {
Long participantUserId = ((Number) participant.get("userId")).longValue();
String participantUsername = (String) participant.get("username");
if (!participantUserId.equals(senderId)) {
// 发送到用户个人消息频道
String userDestination = "/topic/user/" + participantUserId + "/messages";
messagingTemplate.convertAndSend(userDestination, messageObj);
log.info("Message notification sent to destination: {}", userDestination);
// 发送未读数量
if (payloadMap.containsKey("unreadCount")) {
messagingTemplate.convertAndSendToUser(participantUsername, "/queue/unread-count", payloadMap.get("unreadCount"));
log.info("Sent unread count to user {} via /user/{}/queue/unread-count", participantUsername, participantUsername);
}
// 发送频道未读数量(如果有)
if (payloadMap.containsKey("channelUnread")) {
messagingTemplate.convertAndSendToUser(participantUsername, "/queue/channel-unread", payloadMap.get("channelUnread"));
log.info("Sent channel-unread to {}", participantUsername);
}
}
}
}
// 处理简化的消息格式(向后兼容)
else if (payloadMap.containsKey("message")) {
if (payloadMap.containsKey("unreadCount")) {
messagingTemplate.convertAndSendToUser(username, "/queue/unread-count", payloadMap.get("unreadCount"));
log.info("Sent unread count to user {} via /user/{}/queue/unread-count", username, username);
}
if (payloadMap.containsKey("channelUnread")) {
messagingTemplate.convertAndSendToUser(username, "/queue/channel-unread", payloadMap.get("channelUnread"));
log.info("Sent channel-unread to {}", username);
}
}
}
} catch (Exception e) {
log.error("Failed to process and send message for user {}", username, e);
}
}
}

View File

@@ -0,0 +1,19 @@
package com.openisle.websocket.security;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import java.util.Collections;
@Configuration
@RequiredArgsConstructor
public class ApplicationConfig {
@Bean
public UserDetailsService userDetailsService() {
return username -> new User(username, "", Collections.emptyList());
}
}

View File

@@ -0,0 +1,98 @@
package com.openisle.websocket.security;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Date;
import java.util.function.Function;
@Service
public class JwtService {
private static final Logger logger = LoggerFactory.getLogger(JwtService.class);
@Value("${app.jwt.secret}")
private String secret;
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}
public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
final Claims claims = extractAllClaims(token);
return claimsResolver.apply(claims);
}
public boolean isTokenValid(String token) {
try {
return !isTokenExpired(token);
} catch (Exception e) {
return false;
}
}
private boolean isTokenExpired(String token) {
return extractExpiration(token).before(new Date());
}
private Date extractExpiration(String token) {
return extractClaim(token, Claims::getExpiration);
}
private Claims extractAllClaims(String token) {
logger.debug("解析JWT token - secret长度: {}", secret != null ? secret.length() : "null");
try {
return Jwts
.parserBuilder()
.setSigningKey(getSignInKey())
.build()
.parseClaimsJws(token)
.getBody();
} catch (Exception e) {
logger.error("JWT解析失败: {}", e.getMessage());
throw e;
}
}
private Key getSignInKey() {
// 使用与backend相同的密钥处理方式直接Base64解码
byte[] keyBytes;
try {
// 尝试Base64解码
keyBytes = java.util.Base64.getDecoder().decode(secret);
} catch (IllegalArgumentException e) {
// 如果不是Base64格式使用UTF-8字节
keyBytes = secret.getBytes(StandardCharsets.UTF_8);
// 确保密钥长度至少256位32字节
if (keyBytes.length < 32) {
MessageDigest digest;
try {
digest = MessageDigest.getInstance("SHA-256");
keyBytes = digest.digest(keyBytes);
} catch (NoSuchAlgorithmException ex) {
throw new IllegalStateException("SHA-256 not available", ex);
}
}
}
return Keys.hmacShaKeyFor(keyBytes);
}
public String validateAndGetSubject(String token) {
Claims claims = Jwts.parserBuilder()
.setSigningKey(getSignInKey())
.build()
.parseClaimsJws(token)
.getBody();
return claims.getSubject();
}
}

View File

@@ -0,0 +1,71 @@
package com.openisle.websocket.security;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.List;
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
@Value("${app.website-url}")
private String websiteUrl;
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of(
"http://127.0.0.1:8080",
"http://127.0.0.1:8081",
"http://127.0.0.1:8082",
"http://127.0.0.1:3000",
"http://127.0.0.1:3001",
"http://127.0.0.1",
"http://localhost:8080",
"http://localhost:8081",
"http://localhost:8082",
"http://localhost:3000",
"http://localhost:3001",
"http://localhost",
"http://30.211.97.238:3000",
"http://30.211.97.238",
"http://192.168.7.98",
"http://192.168.7.98:3000",
websiteUrl,
websiteUrl.replace("://www.", "://")
));
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(List.of("*"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf().disable()
.cors(Customizer.withDefaults())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/**").permitAll() // Permit all HTTP requests
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);
return http.build();
}
}