mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-24 07:00:49 +08:00
feat:Websocket服务拆到单独服务,主后台保持单工通信
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 连接已断开");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user