diff --git a/build.gradle b/build.gradle index 1434d67..da7e10d 100644 --- a/build.gradle +++ b/build.gradle @@ -37,6 +37,10 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-redis' //redis implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0' implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.integration:spring-integration-core' + implementation 'org.springframework.integration:spring-integration-redis' + implementation 'org.springframework.integration:spring-integration-redis' + } diff --git a/src/main/java/spring/socket_server/common/auth/Role.java b/src/main/java/spring/socket_server/common/auth/Role.java new file mode 100644 index 0000000..55e5fb2 --- /dev/null +++ b/src/main/java/spring/socket_server/common/auth/Role.java @@ -0,0 +1,12 @@ +package spring.socket_server.common.auth; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Getter +public enum Role { + USER("ROLE_USER"), ADMIN("ROLE_ADMIN"), GUEST("ROLE_GUEST"); + + private final String value; +} \ No newline at end of file diff --git a/src/main/java/spring/socket_server/common/auth/StompPrincipal.java b/src/main/java/spring/socket_server/common/auth/StompPrincipal.java new file mode 100644 index 0000000..863e488 --- /dev/null +++ b/src/main/java/spring/socket_server/common/auth/StompPrincipal.java @@ -0,0 +1,22 @@ +package spring.socket_server.common.auth; + +import java.security.Principal; + +public class StompPrincipal implements Principal { + private final String name; + private final Role role; + + public StompPrincipal(String name, Role roles) { + this.name = name; + this.role = roles; + } + + @Override + public String getName() { + return name; + } + + public Role getRoles() { + return role; + } +} \ No newline at end of file diff --git a/src/main/java/spring/socket_server/common/auth/StompPrincipalHandshakeHandler.java b/src/main/java/spring/socket_server/common/auth/StompPrincipalHandshakeHandler.java new file mode 100644 index 0000000..609ce80 --- /dev/null +++ b/src/main/java/spring/socket_server/common/auth/StompPrincipalHandshakeHandler.java @@ -0,0 +1,27 @@ +package spring.socket_server.common.auth; + +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.server.support.DefaultHandshakeHandler; + +import java.security.Principal; +import java.util.Map; + +//TODO: 이후 WebSocket 연결 시 Authorization 헤더의 JWT를 파싱해 userId와 Role을 설정하도록 수정 예정 +// 현재는 구동만을 위한 간략화 코드. +public class StompPrincipalHandshakeHandler extends DefaultHandshakeHandler { + + @Override + protected Principal determineUser(ServerHttpRequest request, + WebSocketHandler wsHandler, + Map attributes) { + + // 1) user-id 헤더 또는 쿼리 파라미터로부터 사용자 이름을 꺼냄 + String userId = request.getHeaders().getFirst("user-id"); + + // 2) 역할 정보 + Role role = Role.USER; + return new StompPrincipal(userId, role); + } + +} \ No newline at end of file diff --git a/src/main/java/spring/socket_server/common/auth/StompUserInterceptor.java b/src/main/java/spring/socket_server/common/auth/StompUserInterceptor.java new file mode 100644 index 0000000..df97bc0 --- /dev/null +++ b/src/main/java/spring/socket_server/common/auth/StompUserInterceptor.java @@ -0,0 +1,39 @@ +package spring.socket_server.common.auth; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.messaging.support.MessageHeaderAccessor; +import org.springframework.messaging.support.ChannelInterceptor; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; + +import java.security.Principal; +import java.util.UUID; + +@Slf4j +// 2) STOMP CONNECT 프레임을 가로채는 Interceptor +public class StompUserInterceptor implements ChannelInterceptor { + + @Override + public Message preSend (Message message, MessageChannel channel) { + StompHeaderAccessor accessor = + MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); + + if (accessor != null && StompCommand.CONNECT.equals(accessor.getCommand())) { + + String rawUserId = accessor.getFirstNativeHeader("user-id"); + + String userId = (rawUserId == null || rawUserId.isBlank()) + ? "guest-" + UUID.randomUUID() + : rawUserId; + + Principal principal = () -> userId; + accessor.setUser(principal); + } + return message; + } + + +} + diff --git a/src/main/java/spring/socket_server/common/config/RedisConfig.java b/src/main/java/spring/socket_server/common/config/RedisConfig.java index b91307a..294abf0 100644 --- a/src/main/java/spring/socket_server/common/config/RedisConfig.java +++ b/src/main/java/spring/socket_server/common/config/RedisConfig.java @@ -1,5 +1,6 @@ package spring.socket_server.common.config; +import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.beans.factory.annotation.Value; import org.springframework.cache.annotation.EnableCaching; import org.springframework.context.annotation.Bean; @@ -9,17 +10,9 @@ import org.springframework.data.redis.connection.RedisStandaloneConfiguration; import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.data.redis.listener.PatternTopic; -import org.springframework.data.redis.listener.RedisMessageListenerContainer; -import org.springframework.data.redis.listener.adapter.MessageListenerAdapter; import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; -import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; -import spring.socket_server.domain.chat.handler.ChattingPubSubHandler; -import spring.socket_server.domain.game.handler.GamePubSubHandler; - -import static spring.socket_server.common.constants.WebSocketConstants.CHAT_PREFIX; -import static spring.socket_server.common.constants.WebSocketConstants.GAME_PREFIX; +import org.springframework.messaging.converter.MappingJackson2MessageConverter; @EnableCaching @Configuration @@ -41,34 +34,6 @@ public RedisConnectionFactory redisConnectionFactory() { return lettuceConnectionFactory; } - //Redis Pub/Sub에서 메시지를 리스닝하는 컨테이너 -> (구독자가 어떤 메시지를 받을지 관리) - @Bean - public RedisMessageListenerContainer redisMessageListenerContainer( - RedisConnectionFactory connectionFactory, - MessageListenerAdapter chatListenerAdapter, - MessageListenerAdapter gameListenerAdapter) { - - RedisMessageListenerContainer container = new RedisMessageListenerContainer(); - container.setConnectionFactory(connectionFactory); - - //컨테이너는 각 채널의 리스닝어뎁터를 받음 -> 해당 컨테이너가 모든 채널 관리가 가능함! - container.addMessageListener(chatListenerAdapter, new PatternTopic(CHAT_PREFIX+"*")); //전체 채팅 채널 - container.addMessageListener(gameListenerAdapter, new PatternTopic(GAME_PREFIX + "*"));//인게임 관련 채널 - - return container; - } - - // Redis에서 메시지를 수신하면 RedisSubscriber 클래스의 onMessage 메서드를 호출하도록 설정 - @Bean - public MessageListenerAdapter chatListenerAdapter(ChattingPubSubHandler chattingSubscriber) { - return new MessageListenerAdapter(chattingSubscriber, "onMessage"); - } - - @Bean - public MessageListenerAdapter gameListenerAdapter(GamePubSubHandler gameSubscriber) { - return new MessageListenerAdapter(gameSubscriber, "onMessage"); - } - @Bean @Primary public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory) { @@ -84,7 +49,7 @@ public RedisTemplate redisTemplate(RedisConnectionFactory redisC return redisTemplate; } - @Bean + @Bean("jsonRedisTemplate") public RedisTemplate redisTemplateObject(RedisConnectionFactory connectionFactory) { RedisTemplate template = new RedisTemplate<>(); template.setConnectionFactory(connectionFactory); @@ -102,5 +67,13 @@ public RedisTemplate redisTemplateObject(RedisConnectionFactory return template; } + @Bean + public MappingJackson2MessageConverter jackson2MessageConverter() { + MappingJackson2MessageConverter converter = new MappingJackson2MessageConverter(); + converter.setObjectMapper(new ObjectMapper()); + converter.setSerializedPayloadClass(String.class); // JSON 문자열 처리 + return converter; + } + } diff --git a/src/main/java/spring/socket_server/common/config/RedisIntegrationConfig.java b/src/main/java/spring/socket_server/common/config/RedisIntegrationConfig.java new file mode 100644 index 0000000..0143821 --- /dev/null +++ b/src/main/java/spring/socket_server/common/config/RedisIntegrationConfig.java @@ -0,0 +1,210 @@ +package spring.socket_server.common.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.lettuce.core.dynamic.intercept.MethodInterceptor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.task.TaskExecutor; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; +import org.springframework.integration.channel.ExecutorChannel; +import org.springframework.integration.config.EnableIntegration; +import org.springframework.integration.core.GenericHandler; +import org.springframework.integration.core.MessageProducer; +import org.springframework.integration.dsl.IntegrationFlow; +import org.springframework.integration.redis.inbound.RedisInboundChannelAdapter; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.messaging.support.ErrorMessage; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import spring.socket_server.common.exception.ExceptionRes; +import spring.socket_server.common.exception.global.CustomException; +import spring.socket_server.common.exception.global.GlobalExceptionCode; +import spring.socket_server.domain.personal.handler.PersonalPubSubHandler; +import spring.socket_server.domain.room.handler.RoomPubSubHandler; +import org.aopalliance.aop.Advice; +import org.aopalliance.intercept.MethodInvocation; + +import static spring.socket_server.common.constants.WebSocketConstants.*; + +@Slf4j +@Configuration +@EnableIntegration +public class RedisIntegrationConfig { + private final String REDIS_MESSAGE_SOURCE = "redis_messageSource"; + ObjectMapper objectMapper = new ObjectMapper(); + private final RoomPubSubHandler roomHandler; + private final PersonalPubSubHandler personalPubSubHandler; + + public RedisIntegrationConfig(RoomPubSubHandler roomHandler, PersonalPubSubHandler personalPubSubHandler) { + this.roomHandler = roomHandler; + this.personalPubSubHandler=personalPubSubHandler; + } + + @Bean("redisExecutor") + public TaskExecutor redisExecutor() { + ThreadPoolTaskExecutor exec = new ThreadPoolTaskExecutor(); + exec.setCorePoolSize(20); + exec.setMaxPoolSize(200); + exec.setQueueCapacity(1000); + exec.setThreadNamePrefix("redis-exec-"); + exec.initialize(); + return exec; + } + + @Bean("broadcastExecutor") + public TaskExecutor broadcastExecutor() { + ThreadPoolTaskExecutor exec = new ThreadPoolTaskExecutor(); + exec.setCorePoolSize(10); + exec.setMaxPoolSize(50); + exec.setQueueCapacity(500); + exec.setThreadNamePrefix("broadcast-exec-"); + exec.initialize(); + return exec; + } + + // Channels + @Bean + public MessageChannel redisInputChannel(@Qualifier("redisExecutor") TaskExecutor exec) { + return new ExecutorChannel(exec); + } + + @Bean + public MessageChannel personalMessageChannel (@Qualifier("redisExecutor") TaskExecutor exec) { + return new ExecutorChannel(exec); + } + + @Bean + public MessageChannel roomMessageChannel(@Qualifier("redisExecutor") TaskExecutor exec) { + return new ExecutorChannel(exec); + } + + @Bean + public MessageChannel unknownMessageChannel(@Qualifier("redisExecutor") TaskExecutor exec) { + return new ExecutorChannel(exec); + } + + @Bean + public MessageChannel redisErrorChannel(@Qualifier("redisExecutor") TaskExecutor exec) { + return new ExecutorChannel(exec); + } + + + @Bean + public MessageProducer redisInboundAdapter(RedisConnectionFactory cf) { + RedisInboundChannelAdapter adapter = new RedisInboundChannelAdapter(cf); + adapter.setTopicPatterns( + // 개인 유저 메시지 + PERSONAL_PREFIX +"*", + // 채팅 + CHAT_ALL_CHANNEL, // /sub/chat/all + CHAT_PRIVATE_CHANNEL + "*", // /sub/chat/private/* + CHAT_ROOM_CHANNEL + "*", // /sub/chat/room/* + + // 대기방 + ROOM_LIST_INFO, // /sub/room/list/info + ROOM_CREATE_INFO, // /sub/room/create/info + ROOM_LEAVE + "*", // /sub/room/leave/* + ROOM_UPDATE + "*", // /sub/room/update/* + + // 게임 + GAME_READY_CHANNEL + "*", // /sub/game/ready/* + GAME_END_CHANNEL + "*", // /sub/game/end/* + GAME_INFO_CHANNEL + "*", // /sub/game/info/* + GAME_START_CHANNEL + "*" // /sub/game/start/* + ); + adapter.setSerializer(new Jackson2JsonRedisSerializer<>(Object.class)); + adapter.setOutputChannel(redisInputChannel(redisExecutor())); + adapter.setErrorChannel(redisErrorChannel(redisExecutor())); + return adapter; + } + + @Bean + public IntegrationFlow redisRoutingFlow() { + return IntegrationFlow.from("redisInputChannel") + .route(Message.class, msg -> { + String topic = (String) msg.getHeaders().get(REDIS_MESSAGE_SOURCE); + if (topic.startsWith(PERSONAL_PREFIX)) return "personalMessageChannel"; + if (topic.startsWith(GAME_PREFIX)) return "gameMessageChannel"; + if (topic.startsWith(CHAT_PREFIX)) return "chatMessageChannel"; + if (topic.startsWith(ROOM_PREFIX)) return "roomMessageChannel"; + return "unknownMessageChannel"; + }) + .get(); + } + + @Bean + public IntegrationFlow personalMessageFlow() { + return IntegrationFlow.from("personalMessageChannel") + .handle((msg, headers) -> { + RedisMessage redisMessage = objectMapper.convertValue(msg, RedisMessage.class); + log.info("👤 [개인 메시지] user={}, topic={}, payload={}", + redisMessage.userId(), redisMessage.topic(), redisMessage.payload()); + personalPubSubHandler.onMessage(redisMessage.topic()+redisMessage.userId(), redisMessage); + return null; + }).get(); + } + + @Bean + public IntegrationFlow roomMessageFlow() { + return IntegrationFlow.from("roomMessageChannel") + .handle((msg, headers) -> { + RedisMessage redisMessage = objectMapper.convertValue(msg, RedisMessage.class); + log.info("📦 [방 메시지] topic={}, user={}, payload={}", + redisMessage.topic(), redisMessage.userId(), redisMessage.payload()); + roomHandler.onMessage(redisMessage.topic(), redisMessage); + return null; + }).get(); + } + + @Bean + public IntegrationFlow unknownMessageFlow() { + return IntegrationFlow.from("unknownMessageChannel") + .handle((GenericHandler) (payload, headers) -> { + log.warn("❓ [알 수 없는 메시지] payload={}, headers={}", payload, headers); + return null; + }).get(); + } + + + @Bean + public IntegrationFlow redisErrorFlow(SimpMessagingTemplate template) { + return IntegrationFlow.from("redisErrorChannel") + .handle((GenericHandler) (payload, headers) -> { + log.debug("❗ redisErrorFlow triggered. Payload type: {}, Headers: {}", payload.getClass(), headers); + + Throwable t; + if (payload instanceof ErrorMessage em) { + t = em.getPayload(); + } else if (payload instanceof Throwable th) { + t = th; + } else { + return null; + } + + try { + if (t.getMessage() != null && t.getMessage().contains("userId")) { + RedisMessage redisMessage = objectMapper.readValue(t.getMessage(), RedisMessage.class); + String userId = redisMessage.userId(); + + ExceptionRes dto = (t instanceof CustomException ce) + ? ExceptionRes.from(ce.getExceptionCode()) + : ExceptionRes.from(GlobalExceptionCode.INTERNAL_SERVER_ERROR); + + template.convertAndSend(ERROR_CHANNEL_PREFIX+userId+ERROR_CHANEL, dto); + log.debug("🚨 에러 메시지 전송 완료 → /user/{}/queue/errors", userId); + } else { + log.warn("❌ [redisErrorFlow] userId 추출 실패. 메시지 내용: {}", t.getMessage()); + } + } catch (Exception e) { + log.error("❌ [redisErrorFlow] RedisMessage 역직렬화 실패", e); + } + + return null; + }) + .get(); + } +} diff --git a/src/main/java/spring/socket_server/common/config/RedisMessage.java b/src/main/java/spring/socket_server/common/config/RedisMessage.java new file mode 100644 index 0000000..e687d09 --- /dev/null +++ b/src/main/java/spring/socket_server/common/config/RedisMessage.java @@ -0,0 +1,10 @@ +package spring.socket_server.common.config; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record RedisMessage( + String userId, + String topic, + Object payload) +{} diff --git a/src/main/java/spring/socket_server/common/config/WebSocketConfig.java b/src/main/java/spring/socket_server/common/config/WebSocketConfig.java index e3de847..048a11c 100644 --- a/src/main/java/spring/socket_server/common/config/WebSocketConfig.java +++ b/src/main/java/spring/socket_server/common/config/WebSocketConfig.java @@ -1,30 +1,41 @@ package spring.socket_server.common.config; import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.ChannelRegistration; import org.springframework.messaging.simp.config.MessageBrokerRegistry; import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; import org.springframework.web.socket.config.annotation.StompEndpointRegistry; import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; +import spring.socket_server.common.auth.StompPrincipalHandshakeHandler; +import spring.socket_server.common.auth.StompUserInterceptor; @Configuration @EnableWebSocketMessageBroker public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + @Override + public void configureClientInboundChannel(ChannelRegistration registration) { + registration.interceptors(new StompUserInterceptor()); + } + //레디스 메시지 브로커 사용 @Override public void configureMessageBroker(MessageBrokerRegistry registry) { - registry.enableSimpleBroker("/sub"); // 구독 (Subscribe) + registry.enableSimpleBroker("/sub", "/user"); // 구독 (Subscribe) registry.setApplicationDestinationPrefixes("/pub"); // 발행 (Publish) + registry.setUserDestinationPrefix("/user"); } //초기 핸드쉐이크 과정에서 사용할 endpoint @Override public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint("/ws-connect") //https로 변경시 wws로 변경 + .setHandshakeHandler(new StompPrincipalHandshakeHandler()) .setAllowedOriginPatterns("*") .withSockJS(); // SockJS 지원 - registry.addEndpoint("/ws-connect") // todo : 실제 배포시엔 삭제해야함 + registry.addEndpoint("/ws-connect") //todo : 실제 배포시엔 sockJs만 이용하므로, 해당 코드 삭제 필요 + .setHandshakeHandler(new StompPrincipalHandshakeHandler()) .setAllowedOriginPatterns("*"); } diff --git a/src/main/java/spring/socket_server/common/constants/WebSocketConstants.java b/src/main/java/spring/socket_server/common/constants/WebSocketConstants.java index 3ef8f50..749318b 100644 --- a/src/main/java/spring/socket_server/common/constants/WebSocketConstants.java +++ b/src/main/java/spring/socket_server/common/constants/WebSocketConstants.java @@ -3,18 +3,29 @@ public interface WebSocketConstants { // 채팅 관련 채널 String CHAT_PREFIX = "/sub/chat/"; - String CHAT_ALL_CHANNEL = CHAT_PREFIX + "all/"; // 전체 채팅 + String CHAT_ALL_CHANNEL = CHAT_PREFIX + "all"; // 전체 채팅 String CHAT_PRIVATE_CHANNEL = CHAT_PREFIX + "private/"; // + 보낼 상대 닉네임 (1:1 채팅) String CHAT_ROOM_CHANNEL = CHAT_PREFIX + "room/"; // + 방 고유 ID (방 채팅) + //대기방 관련 채널 + String ROOM_PREFIX = "/sub/room/"; + String ROOM_LIST_INFO = ROOM_PREFIX + "list/info"; + String ROOM_LEAVE = ROOM_PREFIX+"leave/"; // + roomId + String ROOM_CREATE_INFO = ROOM_PREFIX + "create/info"; + String ROOM_UPDATE = ROOM_PREFIX + "update/"; // + roomId + // 게임 관련 채널 String GAME_PREFIX = "/sub/game/"; - String GAME_CHANNEL = GAME_PREFIX + "room/"; // + 방 고유 ID (게임 방) - String GAME_READY_CHANNEL = GAME_CHANNEL + "ready/"; // + roomId - String GAME_END_CHANNEL = GAME_CHANNEL + "end/"; // + roomId - String GAME_INFO_CHANNEL = GAME_CHANNEL + "info/"; // + roomId - String GAME_START_CHANNEL = GAME_CHANNEL + "start/"; // + roomId + String GAME_READY_CHANNEL = GAME_PREFIX + "ready/"; // + roomId + String GAME_END_CHANNEL = GAME_PREFIX + "end/"; // + roomId + String GAME_INFO_CHANNEL = GAME_PREFIX + "info/"; // + roomId + String GAME_START_CHANNEL = GAME_PREFIX + "start/"; // + roomId + + // 개인 유저 관련 채널 + String PERSONAL_PREFIX = "/sub/personal/"; + String PERSONAL_ROOM_CREATE_RESPONSE = PERSONAL_PREFIX +"room/create/"; //+userId - // 게임 종료 메시지 - String GAME_END_MESSAGE = "게임 종료"; + // 에러 처리 채널 + String ERROR_CHANNEL_PREFIX = "/user/"; + String ERROR_CHANEL = "/queue/errors"; } diff --git a/src/main/java/spring/socket_server/common/controller/StompConnectionController.java b/src/main/java/spring/socket_server/common/controller/StompConnectionController.java index 17d737c..e4a593b 100644 --- a/src/main/java/spring/socket_server/common/controller/StompConnectionController.java +++ b/src/main/java/spring/socket_server/common/controller/StompConnectionController.java @@ -6,19 +6,16 @@ import org.springframework.messaging.handler.annotation.MessageMapping; import org.springframework.messaging.handler.annotation.Payload; import org.springframework.stereotype.Controller; -import spring.socket_server.common.manager.ChannelManager; //WebSocket으로 들어온 메시지를 Redis에 발행 @Controller @RequiredArgsConstructor public class StompConnectionController { - private final ChannelManager channelManager; // WebSocket 첫 연결 시 초기 채널 관리 @MessageMapping("/connect") public void onConnect(@Payload String nickName, @Header("simpSessionId") String sessionId) { - channelManager.subscribeToInitialChannels(nickName, sessionId); + //추후 세션ID ↔ 유저ID 매핑에 사용. 지금은 사용 x } - //자동배포 성공 테스트 주석 } diff --git a/src/main/java/spring/socket_server/common/exception/ExceptionCode.java b/src/main/java/spring/socket_server/common/exception/ExceptionCode.java new file mode 100644 index 0000000..c39f1b0 --- /dev/null +++ b/src/main/java/spring/socket_server/common/exception/ExceptionCode.java @@ -0,0 +1,10 @@ +package spring.socket_server.common.exception; + +import org.springframework.http.HttpStatus; + +public interface ExceptionCode { + HttpStatus getStatus(); + String getCode(); + String getMessage(); +} + diff --git a/src/main/java/spring/socket_server/common/exception/ExceptionRes.java b/src/main/java/spring/socket_server/common/exception/ExceptionRes.java new file mode 100644 index 0000000..e5ca12e --- /dev/null +++ b/src/main/java/spring/socket_server/common/exception/ExceptionRes.java @@ -0,0 +1,33 @@ +package spring.socket_server.common.exception; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Builder; +import org.springframework.http.HttpStatus; + +import java.util.Map; + +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +public record ExceptionRes( + String code, + HttpStatus status, + String message, + Map fields +) { + public static ExceptionRes from(ExceptionCode error){ + return ExceptionRes.builder() + .status(error.getStatus()) + .code(error.getCode()) + .message(error.getMessage()) + .build(); + } + + public static ExceptionRes from(ExceptionCode error, Map fields) { + return ExceptionRes.builder() + .status(error.getStatus()) + .code(error.getCode()) + .message(error.getMessage()) + .fields(fields) + .build(); + } +} diff --git a/src/main/java/spring/socket_server/common/exception/GlobalException.java b/src/main/java/spring/socket_server/common/exception/GlobalException.java deleted file mode 100644 index 11a128e..0000000 --- a/src/main/java/spring/socket_server/common/exception/GlobalException.java +++ /dev/null @@ -1,4 +0,0 @@ -package spring.socket_server.common.exception; - -public class GlobalException { -} diff --git a/src/main/java/spring/socket_server/common/exception/SocketGlobalExceptionHandler.java b/src/main/java/spring/socket_server/common/exception/SocketGlobalExceptionHandler.java new file mode 100644 index 0000000..b3a0876 --- /dev/null +++ b/src/main/java/spring/socket_server/common/exception/SocketGlobalExceptionHandler.java @@ -0,0 +1,48 @@ +package spring.socket_server.common.exception; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.handler.annotation.MessageExceptionHandler; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.messaging.simp.annotation.SendToUser; +import org.springframework.web.bind.annotation.ControllerAdvice; +import spring.socket_server.common.exception.global.CustomException; +import spring.socket_server.common.exception.global.FieldValidationException; +import spring.socket_server.common.exception.global.GlobalExceptionCode; +import org.springframework.validation.FieldError; + +import java.util.Map; +import java.util.stream.Collectors; + +@Slf4j +@ControllerAdvice +public class SocketGlobalExceptionHandler { + + @MessageExceptionHandler(CustomException.class) + @SendToUser("/queue/errors") + public ExceptionRes handleCustomException(final CustomException ex) { + ExceptionCode error = ex.getExceptionCode(); + return ExceptionRes.from(error); + } + + @MessageExceptionHandler(FieldValidationException.class) + @SendToUser("/queue/errors") + public ExceptionRes handleInvalidField(final FieldValidationException e) { + ExceptionCode error = GlobalExceptionCode.FIELD_VALIDATION_ERROR; + return ExceptionRes.from(error, e.getFieldErrors()); + } + + @MessageExceptionHandler(MethodArgumentNotValidException.class) + @SendToUser("/queue/errors") + public ExceptionRes handleValidationException(final MethodArgumentNotValidException e) { + Map fieldErrors = e.getBindingResult() + .getFieldErrors() + .stream() + .collect(Collectors.toMap ( + FieldError::getField, + FieldError::getDefaultMessage, + (existing, replacement) -> existing + )); + return ExceptionRes.from(GlobalExceptionCode.FIELD_VALIDATION_ERROR, fieldErrors); + } +} + diff --git a/src/main/java/spring/socket_server/common/exception/global/CustomException.java b/src/main/java/spring/socket_server/common/exception/global/CustomException.java new file mode 100644 index 0000000..95fce23 --- /dev/null +++ b/src/main/java/spring/socket_server/common/exception/global/CustomException.java @@ -0,0 +1,16 @@ +package spring.socket_server.common.exception.global; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import spring.socket_server.common.exception.ExceptionCode; + +@Getter +@AllArgsConstructor +public class CustomException extends RuntimeException{ + private final ExceptionCode exceptionCode; + + @Override + public String getMessage() { + return exceptionCode.getMessage(); + } +} diff --git a/src/main/java/spring/socket_server/common/exception/global/FieldValidationException.java b/src/main/java/spring/socket_server/common/exception/global/FieldValidationException.java new file mode 100644 index 0000000..f3039b0 --- /dev/null +++ b/src/main/java/spring/socket_server/common/exception/global/FieldValidationException.java @@ -0,0 +1,20 @@ +package spring.socket_server.common.exception.global; + +import lombok.Getter; + +import java.util.Map; + +@Getter +public class FieldValidationException extends RuntimeException{ + private final Map fieldErrors; + + public FieldValidationException(String field, String message) { + super("필드 검증에 실패했습니다."); + this.fieldErrors = Map.of(field, message); + } + + public FieldValidationException(Map fieldErrors) { + super("입력값 검증에 실패했습니다."); + this.fieldErrors = fieldErrors; + } +} diff --git a/src/main/java/spring/socket_server/common/exception/global/GlobalExceptionCode.java b/src/main/java/spring/socket_server/common/exception/global/GlobalExceptionCode.java new file mode 100644 index 0000000..f3e6131 --- /dev/null +++ b/src/main/java/spring/socket_server/common/exception/global/GlobalExceptionCode.java @@ -0,0 +1,34 @@ +package spring.socket_server.common.exception.global; + +import lombok.AllArgsConstructor; +import org.springframework.http.HttpStatus; +import spring.socket_server.common.exception.ExceptionCode; + +@AllArgsConstructor +public enum GlobalExceptionCode implements ExceptionCode { + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "GLOBAL-500-001", "서버 에러입니다. 서버 팀에 문의해주세요."), + FIELD_VALIDATION_ERROR(HttpStatus.BAD_REQUEST, "GLOBAL-400-001", "요청한 필드 값이 유효하지 않습니다."), + CSRF_INVALID(HttpStatus.FORBIDDEN, "GLOBAL-403-001", "CSRF 토큰이 유효하지 않습니다."), + USER_NOT_FOUND(HttpStatus.UNAUTHORIZED, "AUTH-401-001", "인증된 사용자를 찾을 수 없습니다."), + INVALID_MESSAGE_FORMAT(HttpStatus.BAD_REQUEST, "GLOBAL_400_002", "유효하지 않은 메시지 포맷 입니다."), + INVALID_CHANNEL(HttpStatus.BAD_REQUEST, "GLOBAL_400_003", "유효하지 않은 채널입니다."); + + private final HttpStatus status; + private final String code; + private final String message; + + @Override + public HttpStatus getStatus() { + return status; + } + + @Override + public String getCode() { + return code; + } + + @Override + public String getMessage() { + return message; + } +} diff --git a/src/main/java/spring/socket_server/common/exception/room/RoomExceptionCode.java b/src/main/java/spring/socket_server/common/exception/room/RoomExceptionCode.java new file mode 100644 index 0000000..406c5f8 --- /dev/null +++ b/src/main/java/spring/socket_server/common/exception/room/RoomExceptionCode.java @@ -0,0 +1,34 @@ +package spring.socket_server.common.exception.room; + +import lombok.AllArgsConstructor; +import org.springframework.http.HttpStatus; +import spring.socket_server.common.exception.ExceptionCode; + +@AllArgsConstructor +public enum RoomExceptionCode implements ExceptionCode { + USER_ALREADY_IN_ANOTHER_ROOM(HttpStatus.BAD_REQUEST, "ROOM_400_001", "이미 다른 방에 입장 중 입니다."), + USER_ALREADY_IN_ROOM(HttpStatus.BAD_REQUEST, "ROOM_400_002", "이미 이 방에 존재 합니다."), + USER_NOT_IN_ROOM(HttpStatus.BAD_REQUEST, "ROOM_400_003", "방에 존재하지 않는 유저입니다."), + NOT_ROOM_OWNER(HttpStatus.FORBIDDEN, "ROOM_403_001", "방장 권한이 없습니다."), + INVALID_ROOM_ID(HttpStatus.BAD_REQUEST, "ROOM_400_004", "유효하지 않은 고유 방 코드 입니다."), + ROOM_ID_EXHAUSTED(HttpStatus.SERVICE_UNAVAILABLE, "GLOBAL-503-001", "사용 가능한 방 코드가 모두 소진되었습니다. 서버 팀에 문의해주세요."); + + private final HttpStatus status; + private final String code; + private final String message; + + @Override + public HttpStatus getStatus() { + return status; + } + + @Override + public String getCode() { + return code; + } + + @Override + public String getMessage() { + return message; + } +} diff --git a/src/main/java/spring/socket_server/common/listener/PubSubHandler.java b/src/main/java/spring/socket_server/common/listener/PubSubHandler.java index 8944555..42bd67c 100644 --- a/src/main/java/spring/socket_server/common/listener/PubSubHandler.java +++ b/src/main/java/spring/socket_server/common/listener/PubSubHandler.java @@ -1,23 +1,22 @@ package spring.socket_server.common.listener; import com.fasterxml.jackson.databind.ObjectMapper; -import org.springframework.data.redis.connection.Message; -import org.springframework.data.redis.connection.MessageListener; +import lombok.extern.slf4j.Slf4j; import org.springframework.messaging.simp.SimpMessagingTemplate; -import org.springframework.stereotype.Service; - -import java.nio.charset.StandardCharsets; +import spring.socket_server.common.config.RedisMessage; +import spring.socket_server.common.exception.global.CustomException; +import spring.socket_server.common.exception.global.GlobalExceptionCode; import java.util.HashMap; import java.util.Map; import java.util.function.BiConsumer; -//입력된 채널과 메시지를 처리한 후 (sub), 처리된 결과를 해당 채널의 다른 구독자에게 전파 (pub) -@Service -public abstract class PubSubHandler implements MessageListener { - private final ObjectMapper objectMapper = new ObjectMapper(); // JSON 변환을 위한 ObjectMapper +//Redis Pub/Sub 메시지를 채널별로 분기하여 처리하고, WebSocket 구독자에게 전달하는 추상 핸들러 베이스 클래스 +@Slf4j +public abstract class PubSubHandler { + private final ObjectMapper objectMapper = new ObjectMapper(); // JSON 변환기 protected final SimpMessagingTemplate messagingTemplate; - protected final Map> handlers; + protected final Map> handlers; public PubSubHandler(SimpMessagingTemplate messagingTemplate) { this.messagingTemplate = messagingTemplate; @@ -25,40 +24,46 @@ public PubSubHandler(SimpMessagingTemplate messagingTemplate) { initHandlers(); } - // 각 하위 클래스에서 핸들러 초기화 - protected abstract void initHandlers(); - - @Override - public void onMessage(Message message, byte[] pattern) { - String channel = new String(message.getChannel(), StandardCharsets.UTF_8); - String msg = message.toString(); + // DSL IntegrationFlow에서 호출될 entry point + public void onMessage(String topic, Object payload) { + validatePayloadFormat(payload); - // 채널에 맞는 처리 메소드 호출 handlers.entrySet().stream() - .filter(entry -> extractChannelType(channel).equals(entry.getKey())) + .filter(e -> topic.startsWith(e.getKey())) .findFirst() .map(Map.Entry::getValue) .orElse(this::handleUnknownChannel) - .accept(channel, msg); + .accept(topic, payload); } - // 채널 타입 추출 (채널 이름에서 구체적인 타입 추출) - private String extractChannelType(String channel) { - return channel.substring(0, channel.lastIndexOf("/") + 1); + protected abstract void initHandlers(); + + protected String extractParam(String topic, String prefix) { + if (!topic.startsWith(prefix)) { + throw new CustomException(GlobalExceptionCode.INVALID_CHANNEL); + } + return topic.substring(prefix.length()); + } + + private void handleUnknownChannel(String topic, Object object ) { + throw new CustomException(GlobalExceptionCode.INVALID_CHANNEL); } - // 기본 핸들러: 알 수 없는 채널에 대한 처리 - private void handleUnknownChannel(String channel, String message) { - System.out.println("[알 수 없는 채널] " + channel + " 메시지: " + message); + // RedisIntegrationConfig에서 전달된 메시지가 RedisMessage 형식인지 검증 + private void validatePayloadFormat(Object payload){ + if (!(payload instanceof RedisMessage)) { + throw new CustomException(GlobalExceptionCode.INVALID_MESSAGE_FORMAT); + } } - //todo : 소켓 내 에러 처리 로직 추가. - protected T convertMessageToDto(String message, Class dtoClass) { + protected T convertMessageToDto(Object raw, Class clazz) { try { - return objectMapper.readValue(message, dtoClass); + if (raw instanceof Map map) { + map.remove("@class"); + } + return objectMapper.convertValue(raw, clazz); } catch (Exception e) { - System.err.println("[JSON 변환 오류] " + e.getMessage()); - return null; + throw new CustomException(GlobalExceptionCode.INVALID_MESSAGE_FORMAT); } } -} \ No newline at end of file +} diff --git a/src/main/java/spring/socket_server/common/manager/ChannelManager.java b/src/main/java/spring/socket_server/common/manager/ChannelManager.java deleted file mode 100644 index 7d32089..0000000 --- a/src/main/java/spring/socket_server/common/manager/ChannelManager.java +++ /dev/null @@ -1,84 +0,0 @@ -package spring.socket_server.common.manager; - -import lombok.RequiredArgsConstructor; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.stereotype.Component; - -import java.util.Set; -import java.util.stream.Collectors; - -import static spring.socket_server.common.constants.WebSocketConstants.*; - -@Component -@RequiredArgsConstructor -public class ChannelManager { - private final RedisTemplate redisTemplate; - private static final String CHANNEL_PREFIX = "channel:"; - - - // 사용자에게 초기 채널을 구독하게 하기 위한 함수 (전체 채팅방 + 귓말 구독) - public void subscribeToInitialChannels(String username, String sessionId) { - subscribeToChannel(sessionId, CHAT_ALL_CHANNEL); - subscribeToChannel(sessionId, CHAT_PRIVATE_CHANNEL + username); - } - - // 대기방 입장 시 처리 (전체 채팅방 해지 / 대기방 채널 + 게임 채널 구독) - public void subscribeToWaitingRoom(String roomId, String sessionId) { - subscribeToChannel(sessionId, CHAT_ROOM_CHANNEL + roomId); - subscribeToChannel(sessionId, GAME_CHANNEL + roomId); - - unsubscribeFromChannel(sessionId, CHAT_ALL_CHANNEL); - } - - // 게임 시작 시 처리 (대기방 채팅 + 귓말 채널 해지) - public void subscribeToGame(String nickName, String roomId, String sessionId) { - unsubscribeFromChannel(sessionId, CHAT_ROOM_CHANNEL + roomId); - unsubscribeFromChannel(sessionId, CHAT_PRIVATE_CHANNEL + nickName); - } - - // 게임 종료 시 처리 (대기방 채팅 + 귓말 채널 구독) - public void subscribeToEndGame(String nickName, String roomId, String sessionId) { - subscribeToChannel(sessionId, CHAT_ROOM_CHANNEL + roomId); - subscribeToChannel(sessionId, CHAT_PRIVATE_CHANNEL + nickName); - } - - // 대기방 퇴장 시 처리 (대기방 채널 + 게임 채널 해지 / 전체 채팅방 구독) - public void subscribeToExitWaitingRoom(String roomId, String sessionId) { - unsubscribeFromChannel(sessionId, CHAT_ROOM_CHANNEL + roomId); - unsubscribeFromChannel(sessionId, GAME_CHANNEL + roomId); - - subscribeToChannel(sessionId, CHAT_ALL_CHANNEL); - } - - // 채널을 세션에 추가 (구독) - private void subscribeToChannel(String sessionId, String channel) { - redisTemplate.opsForSet().add(CHANNEL_PREFIX + sessionId, channel); - } - - // 채널을 세션에서 제거 (구독 해지) - private void unsubscribeFromChannel(String sessionId, String channel) { - redisTemplate.opsForSet().remove(CHANNEL_PREFIX + sessionId, channel); - } - - // 세션이 구독한 모든 채널 조회 - public Set getSubscribedChannels(String sessionId) { - Set rawChannels = redisTemplate.opsForSet().members(CHANNEL_PREFIX + sessionId); - - if (rawChannels == null || rawChannels.isEmpty()) { - return Set.of(); - } - - return rawChannels.stream() - .map(Object::toString) - .collect(Collectors.toSet()); - } - - // 세션 삭제 시(로그아웃) 모든 채널 구독 해지 - public void removeAllSubscriptions(String sessionId) { - redisTemplate.delete(CHANNEL_PREFIX + sessionId); - } - - public RedisTemplate getRedisTemplate () { - return redisTemplate; - } -} diff --git a/src/main/java/spring/socket_server/domain/chat/api/ChattingApi.java b/src/main/java/spring/socket_server/domain/chat/api/ChattingApi.java deleted file mode 100644 index 05a7c80..0000000 --- a/src/main/java/spring/socket_server/domain/chat/api/ChattingApi.java +++ /dev/null @@ -1,16 +0,0 @@ -package spring.socket_server.domain.chat.api; -import io.swagger.v3.oas.annotations.tags.Tag; -import org.springframework.messaging.handler.annotation.DestinationVariable; -import org.springframework.messaging.handler.annotation.Header; -import org.springframework.messaging.handler.annotation.Payload; -import spring.socket_server.domain.chat.dto.ChatMessageReq; - -@Tag(name = "[채팅 소켓 API]", description = "채팅 소켓 관련 API") -public interface ChattingApi { - - public void sendMessageToAll(@Payload ChatMessageReq message, @Header("simpSessionId") String sessionId); - - public void sendPrivateMessage(@Payload ChatMessageReq message); - - public void sendRoomMessage(@DestinationVariable String roomId, @Payload ChatMessageReq message); -} diff --git a/src/main/java/spring/socket_server/domain/chat/controller/ChattingController.java b/src/main/java/spring/socket_server/domain/chat/controller/ChattingController.java index c9c408b..627b83e 100644 --- a/src/main/java/spring/socket_server/domain/chat/controller/ChattingController.java +++ b/src/main/java/spring/socket_server/domain/chat/controller/ChattingController.java @@ -7,7 +7,6 @@ import org.springframework.messaging.handler.annotation.MessageMapping; import org.springframework.messaging.handler.annotation.Payload; import org.springframework.stereotype.Controller; -import spring.socket_server.domain.chat.api.ChattingApi; import spring.socket_server.domain.chat.dto.ChatMessageReq; import static spring.socket_server.common.constants.WebSocketConstants.*; @@ -15,7 +14,7 @@ //WebSocket으로 들어온 메시지를 Redis에 발행 @Controller("/chat") @RequiredArgsConstructor -public class ChattingController implements ChattingApi { +public class ChattingController { private final RedisTemplate redisTemplate; // 1. 전체 채팅방 메시지 전송 diff --git a/src/main/java/spring/socket_server/domain/chat/handler/ChattingPubSubHandler.java b/src/main/java/spring/socket_server/domain/chat/handler/ChattingPubSubHandler.java index f1dccf3..567ffb3 100644 --- a/src/main/java/spring/socket_server/domain/chat/handler/ChattingPubSubHandler.java +++ b/src/main/java/spring/socket_server/domain/chat/handler/ChattingPubSubHandler.java @@ -1,13 +1,16 @@ package spring.socket_server.domain.chat.handler; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.stereotype.Service; import spring.socket_server.common.listener.PubSubHandler; import spring.socket_server.domain.chat.dto.ChatMessageReq; +import static io.lettuce.core.pubsub.PubSubOutput.Type.message; import static spring.socket_server.common.constants.WebSocketConstants.*; @Service +@Qualifier("chattingPubSubHandler") public class ChattingPubSubHandler extends PubSubHandler { public ChattingPubSubHandler(SimpMessagingTemplate messagingTemplate) { @@ -22,19 +25,13 @@ protected void initHandlers() { } // 채팅 처리 메소드 - private void handleAllChat(String message) { - ChatMessageReq chatMessageReq = convertMessageToDto(message, ChatMessageReq.class); - messagingTemplate.convertAndSend(CHAT_ALL_CHANNEL, chatMessageReq); // 전체 채팅방 메시지 처리 + private void handleAllChat( Object object) { + } - private void handlePrivateChat(String channel, String message) { - String nickName = channel.replace(CHAT_PRIVATE_CHANNEL, ""); - messagingTemplate.convertAndSend(CHAT_PRIVATE_CHANNEL + nickName, message); // 귓속말 처리 + private void handlePrivateChat(String channel, Object object) { } - private void handleRoomChat(String channel, String message) { - String roomId = channel.replace(CHAT_ROOM_CHANNEL, ""); - ChatMessageReq chatMessageReq = convertMessageToDto(message, ChatMessageReq.class); - messagingTemplate.convertAndSend(CHAT_ROOM_CHANNEL + roomId, chatMessageReq); // 대기방 채팅 처리 + private void handleRoomChat(String channel, Object object) { } } diff --git a/src/main/java/spring/socket_server/domain/chat/service/ChatLogService.java b/src/main/java/spring/socket_server/domain/chat/service/ChatLogService.java index c3dbf8e..0598b02 100644 --- a/src/main/java/spring/socket_server/domain/chat/service/ChatLogService.java +++ b/src/main/java/spring/socket_server/domain/chat/service/ChatLogService.java @@ -1,4 +1,26 @@ package spring.socket_server.domain.chat.service; -public class ChatLogService { +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class ChatLogService { + private final RedisTemplate redisTemplate; + + private String chatKey(String roomId) { + return "room:" + roomId + ":chatlog"; + } + + public void saveChatMessage(String roomId, String message) { + redisTemplate.opsForList().rightPush(chatKey(roomId), message); + redisTemplate.opsForList().trim(chatKey(roomId), -10, -1); // 최신 10개만 유지 + } + + public List getLast10Messages(String roomId) { + return redisTemplate.opsForList().range(chatKey(roomId), 0, -1); + } } diff --git a/src/main/java/spring/socket_server/domain/game/api/GameApi.java b/src/main/java/spring/socket_server/domain/game/api/GameApi.java deleted file mode 100644 index b9f5943..0000000 --- a/src/main/java/spring/socket_server/domain/game/api/GameApi.java +++ /dev/null @@ -1,15 +0,0 @@ -package spring.socket_server.domain.game.api; - -import io.swagger.v3.oas.annotations.tags.Tag; -import org.springframework.messaging.handler.annotation.DestinationVariable; -import org.springframework.messaging.handler.annotation.Header; -import org.springframework.messaging.handler.annotation.Payload; -import spring.socket_server.domain.game.dto.GameReadyStatus; - -@Tag(name = "[게임 소켓 API]", description = "게임 소켓 관련 API") -public interface GameApi { - public void enterGameRoom(@DestinationVariable String roomId, @Header("simpSessionId") String sessionId); - - public void sendReadyStatus(@DestinationVariable String roomId, @Payload GameReadyStatus readyStatus); - -} diff --git a/src/main/java/spring/socket_server/domain/game/controller/GameController.java b/src/main/java/spring/socket_server/domain/game/controller/GameController.java index 3d0d962..a584eee 100644 --- a/src/main/java/spring/socket_server/domain/game/controller/GameController.java +++ b/src/main/java/spring/socket_server/domain/game/controller/GameController.java @@ -7,25 +7,19 @@ import org.springframework.messaging.handler.annotation.MessageMapping; import org.springframework.messaging.handler.annotation.Payload; import org.springframework.stereotype.Controller; -import spring.socket_server.common.manager.ChannelManager; -import spring.socket_server.domain.game.api.GameApi; import spring.socket_server.domain.game.dto.GameReadyStatus; -import spring.socket_server.common.constants.WebSocketConstants.*; - import static spring.socket_server.common.constants.WebSocketConstants.*; //WebSocket으로 들어온 메시지를 Redis에 발행 @Controller("/game") @RequiredArgsConstructor -public class GameController implements GameApi { +public class GameController { private final RedisTemplate pubSubHandler; - private final ChannelManager channelManager; // 1. 대기방 입장시 채널 관리 @MessageMapping("/enter/room/{roomId}") public void enterGameRoom(@DestinationVariable String roomId, @Header("simpSessionId") String sessionId) { - channelManager.subscribeToWaitingRoom(roomId, sessionId); } // 3. 대기방 레디 상태 업데이트 diff --git a/src/main/java/spring/socket_server/domain/game/enumType/AiMode.java b/src/main/java/spring/socket_server/domain/game/enumType/AiMode.java new file mode 100644 index 0000000..a84d600 --- /dev/null +++ b/src/main/java/spring/socket_server/domain/game/enumType/AiMode.java @@ -0,0 +1,22 @@ +package spring.socket_server.domain.game.enumType; + +public enum AiMode { + BASIC("기본", "1"), + ADVANCED("고급", "2"); + + private final String description; + private final String level; + + AiMode(String description, String level) { + this.description = description; + this.level = level; + } + + public String getDescription() { + return description; + } + + public String getDifficultyLevel() { + return level; + } +} diff --git a/src/main/java/spring/socket_server/domain/game/enumType/GameMode.java b/src/main/java/spring/socket_server/domain/game/enumType/GameMode.java new file mode 100644 index 0000000..3db7458 --- /dev/null +++ b/src/main/java/spring/socket_server/domain/game/enumType/GameMode.java @@ -0,0 +1,22 @@ +package spring.socket_server.domain.game.enumType; + +public enum GameMode { + GAME_1("게임1", 2, 2), + GAME_2("게임2", 4, 8); + + private final int minPlayers; + private final int maxPlayers; + + GameMode(String description, int minPlayers, int maxPlayers) { + this.minPlayers = minPlayers; + this.maxPlayers = maxPlayers; + } + + public int getMinPlayers() { + return minPlayers; + } + + public int getMaxPlayers() { + return maxPlayers; + } +} diff --git a/src/main/java/spring/socket_server/domain/game/handler/GamePubSubHandler.java b/src/main/java/spring/socket_server/domain/game/handler/GamePubSubHandler.java index 26a6e57..cbd085f 100644 --- a/src/main/java/spring/socket_server/domain/game/handler/GamePubSubHandler.java +++ b/src/main/java/spring/socket_server/domain/game/handler/GamePubSubHandler.java @@ -1,5 +1,6 @@ package spring.socket_server.domain.game.handler; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.stereotype.Service; import spring.socket_server.common.listener.PubSubHandler; @@ -9,6 +10,7 @@ @Service +@Qualifier("gamePubSubHandler") public class GamePubSubHandler extends PubSubHandler { public GamePubSubHandler(SimpMessagingTemplate messagingTemplate) { @@ -23,21 +25,21 @@ protected void initHandlers() { handlers.put(GAME_INFO_CHANNEL, this::handleGameInfo); } - private void handleGameReady(String channel, String message) { - GameReadyStatus gameReadyStatus = convertMessageToDto(message, GameReadyStatus.class); + private void handleGameReady(String channel, Object object) { + GameReadyStatus gameReadyStatus = convertMessageToDto(object, GameReadyStatus.class); messagingTemplate.convertAndSend(channel, gameReadyStatus); } - private void handleGameStart(String channel, String message) { - messagingTemplate.convertAndSend(channel, message); + private void handleGameStart(String channel, Object object) { + messagingTemplate.convertAndSend(channel, object); } - private void handleGameEnd(String channel, String message) { - messagingTemplate.convertAndSend(channel, message); + private void handleGameEnd(String channel, Object object) { + messagingTemplate.convertAndSend(channel, object); } - private void handleGameInfo(String channel, String message) { + private void handleGameInfo(String channel, Object object) { } - } + diff --git a/src/main/java/spring/socket_server/domain/personal/handler/PersonalPubSubHandler.java b/src/main/java/spring/socket_server/domain/personal/handler/PersonalPubSubHandler.java new file mode 100644 index 0000000..c8f842d --- /dev/null +++ b/src/main/java/spring/socket_server/domain/personal/handler/PersonalPubSubHandler.java @@ -0,0 +1,36 @@ +package spring.socket_server.domain.personal.handler; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.messaging.simp.user.SimpUserRegistry; +import org.springframework.stereotype.Service; +import spring.socket_server.common.config.RedisMessage; +import spring.socket_server.common.listener.PubSubHandler; +import spring.socket_server.domain.room.model.RoomMetadata; + +import static spring.socket_server.common.constants.WebSocketConstants.PERSONAL_ROOM_CREATE_RESPONSE; + +@Slf4j +@Service +@Qualifier("PersonalPubSubHandler") +public class PersonalPubSubHandler extends PubSubHandler { + + public PersonalPubSubHandler(SimpMessagingTemplate messagingTemplate) { + super(messagingTemplate); + } + + @Override + protected void initHandlers() { + handlers.put(PERSONAL_ROOM_CREATE_RESPONSE, this::roomCreateResponse); + } + + private void roomCreateResponse (String channel, Object object) { + RedisMessage redisMessage = (RedisMessage) object; + RoomMetadata roomMetadata = convertMessageToDto(redisMessage.payload(), RoomMetadata.class); + messagingTemplate.convertAndSend(channel, roomMetadata); + } + + +} + diff --git a/src/main/java/spring/socket_server/domain/room/RoomField/RoomField.java b/src/main/java/spring/socket_server/domain/room/RoomField/RoomField.java new file mode 100644 index 0000000..e971d00 --- /dev/null +++ b/src/main/java/spring/socket_server/domain/room/RoomField/RoomField.java @@ -0,0 +1,36 @@ +package spring.socket_server.domain.room.RoomField; + +import spring.socket_server.common.exception.global.FieldValidationException; + +import java.util.Arrays; + +public enum RoomField { + TITLE("title"), + PASSWORD("password"), + HAS_PASSWORD("hasPassword"), + AI_LEVEL("aiLevel"), + GAME_MODE("gameMode"), + MIN("min"), + MAX("max"), + OWNER("owner"); + + private final String redisField; + + RoomField(String redisField) { + this.redisField = redisField; + } + + public String getRedisField() { + return redisField; + } + + public static RoomField from(String name) { + return Arrays.stream(values()) + .filter(f -> f.name().equalsIgnoreCase(name)) + .findFirst() + .orElseThrow(() -> new FieldValidationException( + "field", name+" : 지원하지 않는 필드입니다: " + )); + } +} + diff --git a/src/main/java/spring/socket_server/domain/room/api/RoomApi.java b/src/main/java/spring/socket_server/domain/room/api/RoomApi.java deleted file mode 100644 index 2f6496a..0000000 --- a/src/main/java/spring/socket_server/domain/room/api/RoomApi.java +++ /dev/null @@ -1,4 +0,0 @@ -package spring.socket_server.domain.room.api; - -public interface RoomApi { -} diff --git a/src/main/java/spring/socket_server/domain/room/controller/RoomController.java b/src/main/java/spring/socket_server/domain/room/controller/RoomController.java index 6ac1f55..6b972da 100644 --- a/src/main/java/spring/socket_server/domain/room/controller/RoomController.java +++ b/src/main/java/spring/socket_server/domain/room/controller/RoomController.java @@ -1,4 +1,116 @@ package spring.socket_server.domain.room.controller; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.messaging.handler.annotation.DestinationVariable; +import org.springframework.messaging.handler.annotation.Header; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.stereotype.Controller; +import org.springframework.validation.annotation.Validated; +import spring.socket_server.common.config.RedisMessage; +import spring.socket_server.common.exception.global.CustomException; +import spring.socket_server.common.exception.global.FieldValidationException; +import spring.socket_server.common.exception.global.GlobalExceptionCode; +import spring.socket_server.common.exception.room.RoomExceptionCode; +import spring.socket_server.domain.room.RoomField.RoomField; +import spring.socket_server.domain.room.dto.CreateRoomRequest; +import spring.socket_server.domain.room.dto.RoomJoinedEvent; +import spring.socket_server.domain.room.dto.UpdateRoomFieldRequest; +import spring.socket_server.domain.room.model.RoomMetadata; +import spring.socket_server.domain.room.service.RoomService; +import spring.socket_server.domain.room.service.RoomUserService; + +import java.security.Principal; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static spring.socket_server.common.constants.WebSocketConstants.*; + +@Slf4j +@Controller +@MessageMapping("/room") +@RequiredArgsConstructor public class RoomController { + private final RoomService roomService; + private final RoomUserService roomUserService; + private final RedisTemplate stringRedisTemplate; + @Qualifier("jsonRedisTemplate") + private final RedisTemplate objectRedisTemplate; + +// @MessageMapping("/enter/{roomId}") +// public void enterRoom(@DestinationVariable String roomId, Principal principal) { +// String userId = principal.getName(); +// +// if (roomUserService.getCurrentRoomOfUser(userId).isPresent()) { +// throw new CustomException(RoomExceptionCode.USER_ALREADY_IN_ANOTHER_ROOM); +// } +// if (roomUserService.isUserInRoom(userId, roomId)) { +// throw new CustomException(RoomExceptionCode.USER_ALREADY_IN_ROOM); +// } +// +// roomUserService.joinRoom(userId, roomId); +// +// RoomMetadata metadata = roomService.getRoomInfo(roomId); +// List users = roomUserService.getUsersInRoom(roomId); +// +// // 현재 방 정보 + 유저 리스트 전송 +// objectRedisTemplate.convertAndSend(ROOM_LIST_INFO, new RoomJoinedEvent(metadata, users, userId)); +// } +// +// @MessageMapping("/leave/{roomId}") +// public void leaveRoom(@DestinationVariable String roomId, Principal principal) { +// String userId = principal.getName(); +// roomUserService.leaveRoom(userId, roomId); +// +// List remainingUsers = roomUserService.getUsersInRoom(roomId); +// if (remainingUsers.isEmpty()) { +// roomService.deleteRoom(roomId); +// } +// +// stringRedisTemplate.convertAndSend(ROOM_LEAVE + roomId, userId); +// } + + @MessageMapping("/create") + public void createRoom(@Payload CreateRoomRequest request, Principal principal) { + String userId = principal.getName(); + //request 수동 유효성 검사해주는 코드 필요함. + + if (roomUserService.getCurrentRoomOfUser(userId).isPresent()) { + throw new CustomException(RoomExceptionCode.USER_ALREADY_IN_ANOTHER_ROOM); + } + + String roomId = roomService.createRoom(request, userId); + RoomMetadata metadata = roomService.getRoomInfo(roomId); + + objectRedisTemplate.convertAndSend(ROOM_CREATE_INFO, new RedisMessage(userId, ROOM_CREATE_INFO, metadata)); //방 목록 생성 브로드 캐스트 용 + objectRedisTemplate.convertAndSend(PERSONAL_ROOM_CREATE_RESPONSE, new RedisMessage(userId, PERSONAL_ROOM_CREATE_RESPONSE, metadata)); //본인의 대기방 생성 확인 용 + } + +// @MessageMapping("/update/{roomId}") +// public void updateRoomField(@DestinationVariable String roomId, @Payload UpdateRoomFieldRequest request, Principal principal) { +// String userId = principal.getName(); +// +// //현재 user가 해당 방의 onwer 가 맞는지 검사 +// RoomMetadata room = roomService.getRoomInfo(roomId); +// if (!room.getOwner().equals(userId)) { +// throw new CustomException(RoomExceptionCode.NOT_ROOM_OWNER); +// } +// +// //방장 변경 요청일 시, 변경 하려는 상대방의 존재 확인 +// RoomField requestField = RoomField.from(request.field()); +// if (requestField.equals(RoomField.OWNER) +// && !roomUserService.isUserInRoom(request.value(), roomId)) { +// throw new CustomException(RoomExceptionCode.USER_NOT_IN_ROOM); +// } +// +// // 클라이언트에게 방 정보 최신화 알림 +// RoomMetadata updated = roomService.updateRoomField(roomId, request.field(), request.value()); +// objectRedisTemplate.convertAndSend(ROOM_UPDATE + roomId, updated); +// } + } diff --git a/src/main/java/spring/socket_server/domain/room/dto/CreateRoomRequest.java b/src/main/java/spring/socket_server/domain/room/dto/CreateRoomRequest.java new file mode 100644 index 0000000..8419c5f --- /dev/null +++ b/src/main/java/spring/socket_server/domain/room/dto/CreateRoomRequest.java @@ -0,0 +1,12 @@ +package spring.socket_server.domain.room.dto; + +import spring.socket_server.domain.game.enumType.AiMode; +import spring.socket_server.domain.game.enumType.GameMode; + +public record CreateRoomRequest( + String title, + boolean hasPassword, + String password, + AiMode aimode, + GameMode gameMode +) {} diff --git a/src/main/java/spring/socket_server/domain/room/dto/RoomJoinedEvent.java b/src/main/java/spring/socket_server/domain/room/dto/RoomJoinedEvent.java new file mode 100644 index 0000000..bf60bbc --- /dev/null +++ b/src/main/java/spring/socket_server/domain/room/dto/RoomJoinedEvent.java @@ -0,0 +1,11 @@ +package spring.socket_server.domain.room.dto; + +import spring.socket_server.domain.room.model.RoomMetadata; + +import java.util.List; + +public record RoomJoinedEvent ( + RoomMetadata metadata, + List users, + String userId +){} diff --git a/src/main/java/spring/socket_server/domain/room/dto/UpdateRoomFieldRequest.java b/src/main/java/spring/socket_server/domain/room/dto/UpdateRoomFieldRequest.java new file mode 100644 index 0000000..d355e7a --- /dev/null +++ b/src/main/java/spring/socket_server/domain/room/dto/UpdateRoomFieldRequest.java @@ -0,0 +1,8 @@ +package spring.socket_server.domain.room.dto; + +import jakarta.validation.constraints.NotBlank; + +public record UpdateRoomFieldRequest( + @NotBlank String field, + @NotBlank String value +) {} diff --git a/src/main/java/spring/socket_server/domain/room/handler/RoomPubSubHandler.java b/src/main/java/spring/socket_server/domain/room/handler/RoomPubSubHandler.java new file mode 100644 index 0000000..f6cdefd --- /dev/null +++ b/src/main/java/spring/socket_server/domain/room/handler/RoomPubSubHandler.java @@ -0,0 +1,59 @@ +package spring.socket_server.domain.room.handler; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Service; +import spring.socket_server.common.config.RedisMessage; +import spring.socket_server.common.exception.global.CustomException; +import spring.socket_server.common.exception.global.GlobalExceptionCode; +import spring.socket_server.common.listener.PubSubHandler; +import spring.socket_server.domain.room.dto.RoomJoinedEvent; +import spring.socket_server.domain.room.model.RoomMetadata; + +import static io.lettuce.core.pubsub.PubSubOutput.Type.message; +import static spring.socket_server.common.constants.WebSocketConstants.*; + +@Slf4j +@Service +@Qualifier("roomPubSubHandler") +public class RoomPubSubHandler extends PubSubHandler { + + private final RoomMetadata roomMetadata; + + public RoomPubSubHandler(SimpMessagingTemplate messagingTemplate, RoomMetadata roomMetadata) { + super(messagingTemplate); + this.roomMetadata = roomMetadata; + } + + @Override + protected void initHandlers() { +// handlers.put(ROOM_LIST_INFO, this::roomListInfo); +// handlers.put(ROOM_LEAVE, this::roomLeave); + handlers.put(ROOM_CREATE_INFO, this::roomCreateInfo); +// handlers.put(ROOM_UPDATE, this::roomUpdate); + } + +// private void roomListInfo(String channel, Object object) { +// RoomJoinedEvent event = convertMessageToDto(message, RoomJoinedEvent.class); +// messagingTemplate.convertAndSend(channel, event); +// } +// +// private void roomLeave(String channel, Object object) { +// String roomId = extractParam(channel, ROOM_LEAVE); +// messagingTemplate.convertAndSend(channel+roomId, message); +// } + + private void roomCreateInfo(String channel, Object object) { + RedisMessage redisMessage = (RedisMessage) object; + RoomMetadata roomMetadata = convertMessageToDto(redisMessage.payload(), RoomMetadata.class); + messagingTemplate.convertAndSend(channel, roomMetadata); + } + +// private void roomUpdate(String channel, Object object) { +// RoomMetadata updatedRoomMetadata = convertMessageToDto(message, RoomMetadata.class); +// String roomId = extractParam(channel, ROOM_UPDATE); +// messagingTemplate.convertAndSend(channel+roomId, updatedRoomMetadata); +// } + +} diff --git a/src/main/java/spring/socket_server/domain/room/model/RoomMetadata.java b/src/main/java/spring/socket_server/domain/room/model/RoomMetadata.java index 80ecf23..653e563 100644 --- a/src/main/java/spring/socket_server/domain/room/model/RoomMetadata.java +++ b/src/main/java/spring/socket_server/domain/room/model/RoomMetadata.java @@ -1,4 +1,39 @@ package spring.socket_server.domain.room.model; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Map; + +@Component +@Getter +@NoArgsConstructor +@AllArgsConstructor public class RoomMetadata { + private String id; + private String title; + private String owner; + private boolean hasPassword; + private String password; + private int max; + private int min; + private String aiLevel; + private String gameMode; + + + public static RoomMetadata fromRedisMap(String id, Map map) { + RoomMetadata metadata = new RoomMetadata(); + metadata.id = id; + metadata.title = (String) map.getOrDefault("title", ""); + metadata.owner = (String) map.getOrDefault("owner", ""); + metadata.hasPassword = Boolean.parseBoolean((String) map.getOrDefault("hasPassword", "false")); + metadata.password = (String) map.getOrDefault("password", ""); + metadata.max = Integer.parseInt((String) map.getOrDefault("max", "0")); + metadata.min = Integer.parseInt((String) map.getOrDefault("min", "0")); + metadata.aiLevel = (String) map.getOrDefault("aiLevel", "NORMAL"); + metadata.gameMode = (String) map.getOrDefault("gameMode", "STANDARD"); + return metadata; + } } diff --git a/src/main/java/spring/socket_server/domain/room/repository/RoomRedisRepository.java b/src/main/java/spring/socket_server/domain/room/repository/RoomRedisRepository.java deleted file mode 100644 index 615db2a..0000000 --- a/src/main/java/spring/socket_server/domain/room/repository/RoomRedisRepository.java +++ /dev/null @@ -1,4 +0,0 @@ -package spring.socket_server.domain.room.repository; - -public class RoomRedisRepository { -} diff --git a/src/main/java/spring/socket_server/domain/room/service/RoomIdService.java b/src/main/java/spring/socket_server/domain/room/service/RoomIdService.java new file mode 100644 index 0000000..ab0d26b --- /dev/null +++ b/src/main/java/spring/socket_server/domain/room/service/RoomIdService.java @@ -0,0 +1,51 @@ +package spring.socket_server.domain.room.service; + +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; +import spring.socket_server.common.exception.global.CustomException; +import spring.socket_server.common.exception.room.RoomExceptionCode; + +import java.util.HashSet; +import java.util.Set; + +@Service +@RequiredArgsConstructor +public class RoomIdService { + + private static final String POOL_KEY = "room:ids:available"; + private final RedisTemplate redisTemplate; + + //애플리케이션 시작 시 한 번만 실행되며, + //풀 키가 비어 있으면 0000~9999 문자열을 모두 SADD로 채웁니다. + @PostConstruct + private void initPoolIfNeeded() { + Boolean exists = redisTemplate.hasKey(POOL_KEY); + if (Boolean.FALSE.equals(exists) || redisTemplate.opsForSet().size(POOL_KEY) == 0) { + Set allIds = new HashSet<>(10000); + for (int i = 0; i < 10000; i++) { + allIds.add(String.format("%04d", i)); + } + redisTemplate.opsForSet().add(POOL_KEY, allIds.toArray(new String[0])); + } + } + + // 사용 가능한 ID를 하나 꺼내서 반환합니다. + // 풀에서 꺼낸 ID는 즉시 제거되므로 중복 할당 되지 않습니다. + public String allocateRoomId() { + String id = redisTemplate.opsForSet().pop(POOL_KEY); + if (id == null) { + throw new CustomException(RoomExceptionCode.ROOM_ID_EXHAUSTED); + } + return id; + } + + //방 삭제 시 호출하여 ID를 풀에 다시 추가합니다. + public void releaseRoomId(String id) { + if (id == null || (!id.matches("\\d{4}"))) { + throw new CustomException(RoomExceptionCode.INVALID_ROOM_ID); + } + redisTemplate.opsForSet().add(POOL_KEY, id); + } +} diff --git a/src/main/java/spring/socket_server/domain/room/service/RoomService.java b/src/main/java/spring/socket_server/domain/room/service/RoomService.java index 0ab1d45..72d228d 100644 --- a/src/main/java/spring/socket_server/domain/room/service/RoomService.java +++ b/src/main/java/spring/socket_server/domain/room/service/RoomService.java @@ -1,4 +1,60 @@ package spring.socket_server.domain.room.service; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import spring.socket_server.domain.room.RoomField.RoomField; +import spring.socket_server.domain.room.dto.CreateRoomRequest; +import spring.socket_server.domain.room.model.RoomMetadata; + +import java.util.Map; + +@Service +@RequiredArgsConstructor public class RoomService { + private final RoomIdService roomIdService; + private final RedisTemplate redisTemplate; + + //todo : lua 스크립트 적용 + public String createRoom(CreateRoomRequest request, String ownerId) { + String roomId = roomIdService.allocateRoomId(); + + Map roomData = Map.of ( + RoomField.TITLE.getRedisField(), request.title(), + RoomField.OWNER.getRedisField(), ownerId, + RoomField.HAS_PASSWORD.getRedisField(), String.valueOf(request.hasPassword()), + RoomField.PASSWORD.getRedisField(), request.password(), + RoomField.MAX.getRedisField(), String.valueOf(request.gameMode().getMaxPlayers()), + RoomField.MIN.getRedisField(), String.valueOf(request.gameMode().getMinPlayers()), + RoomField.AI_LEVEL.getRedisField(), request.aimode().name(), + RoomField.GAME_MODE.getRedisField(), request.gameMode().name() + ); + + redisTemplate.opsForHash().putAll(getRoomKey(roomId), roomData); + return roomId; + } + + //todo : lua 스크립트 적용 + public void deleteRoom (String roomId) { + redisTemplate.delete(getRoomKey(roomId)); + redisTemplate.delete(getRoomKey(roomId) + ":users"); + redisTemplate.delete(getRoomKey(roomId) + ":chatlog"); + roomIdService.releaseRoomId(roomId); + } + + public RoomMetadata updateRoomField (String roomId, String fieldName, String value) { + RoomField field = RoomField.from(fieldName); + redisTemplate.opsForHash().put(getRoomKey(roomId), field.getRedisField(), value); + return getRoomInfo(roomId); + } + + public RoomMetadata getRoomInfo(String roomId) { + Map fields = redisTemplate.opsForHash().entries(getRoomKey(roomId)); + return RoomMetadata.fromRedisMap(roomId, fields); + } + + private String getRoomKey(String roomId) { + return "room:" + roomId; + } } diff --git a/src/main/java/spring/socket_server/domain/room/service/RoomUserService.java b/src/main/java/spring/socket_server/domain/room/service/RoomUserService.java new file mode 100644 index 0000000..522bfc7 --- /dev/null +++ b/src/main/java/spring/socket_server/domain/room/service/RoomUserService.java @@ -0,0 +1,62 @@ +package spring.socket_server.domain.room.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisCallback; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class RoomUserService { + private final RedisTemplate redisTemplate; + + private String roomUserKey(String roomId) { + return "room:" + roomId + ":users"; + } + + private String userRoomKey(String userId) { + return "user:" + userId + ":rooms"; + } + + private String userCurrentRoomKey(String userId) { + return "user:" + userId + ":currentRoom"; + } + + public void joinRoom(String userId, String roomId) { + redisTemplate.execute((RedisCallback) connection -> { + connection.multi(); + connection.sAdd(roomUserKey(roomId).getBytes(), userId.getBytes()); + connection.sAdd(userRoomKey(userId).getBytes(), roomId.getBytes()); + connection.set(userCurrentRoomKey(userId).getBytes(), roomId.getBytes()); + return connection.exec(); + }); + } + + public void leaveRoom(String userId, String roomId) { + redisTemplate.execute((RedisCallback) connection -> { + connection.multi(); + connection.sRem(roomUserKey(roomId).getBytes(), userId.getBytes()); + connection.sRem(userRoomKey(userId).getBytes(), roomId.getBytes()); + connection.del(userCurrentRoomKey(userId).getBytes()); + return connection.exec(); + }); + } + + public boolean isUserInRoom(String userId, String roomId) { + return Boolean.TRUE.equals(redisTemplate.opsForSet().isMember(roomUserKey(roomId), userId)); + } + + public List getUsersInRoom(String roomId) { + return new ArrayList<>(redisTemplate.opsForSet().members(roomUserKey(roomId))); + } + + public Optional getCurrentRoomOfUser(String userId) { + String value = redisTemplate.opsForValue().get(userCurrentRoomKey(userId)); + return Optional.ofNullable(value); + } +} + diff --git a/src/main/java/spring/socket_server/domain/user/api/UserApi.java b/src/main/java/spring/socket_server/domain/user/api/UserApi.java deleted file mode 100644 index 3b05561..0000000 --- a/src/main/java/spring/socket_server/domain/user/api/UserApi.java +++ /dev/null @@ -1,4 +0,0 @@ -package spring.socket_server.domain.user.api; - -public interface UserApi { -} diff --git a/src/main/java/spring/socket_server/domain/user/controller/UserController.java b/src/main/java/spring/socket_server/domain/user/controller/UserController.java deleted file mode 100644 index b04e799..0000000 --- a/src/main/java/spring/socket_server/domain/user/controller/UserController.java +++ /dev/null @@ -1,4 +0,0 @@ -package spring.socket_server.domain.user.controller; - -public class UserController { -} diff --git a/src/main/java/spring/socket_server/domain/user/service/UserService.java b/src/main/java/spring/socket_server/domain/user/service/UserService.java deleted file mode 100644 index 87e4166..0000000 --- a/src/main/java/spring/socket_server/domain/user/service/UserService.java +++ /dev/null @@ -1,4 +0,0 @@ -package spring.socket_server.domain.user.service; - -public class UserService { -} diff --git a/src/test/java/spring/socket_server/domain/room/controller/RoomControllerTest.java b/src/test/java/spring/socket_server/domain/room/controller/RoomControllerTest.java new file mode 100644 index 0000000..e47a78f --- /dev/null +++ b/src/test/java/spring/socket_server/domain/room/controller/RoomControllerTest.java @@ -0,0 +1,157 @@ +//package spring.socket_server.domain.room.controller; +// +//import org.junit.jupiter.api.BeforeEach; +//import org.junit.jupiter.api.Test; +//import org.mockito.*; +//import org.springframework.data.redis.core.RedisTemplate; +//import spring.socket_server.domain.room.dto.CreateRoomRequest; +//import spring.socket_server.domain.room.dto.RoomJoinedEvent; +//import spring.socket_server.domain.room.dto.UpdateRoomFieldRequest; +//import spring.socket_server.domain.room.model.RoomMetadata; +//import spring.socket_server.domain.room.service.RoomService; +//import spring.socket_server.domain.room.service.RoomUserService; +//import spring.socket_server.domain.game.enumType.AiMode; +//import spring.socket_server.domain.game.enumType.GameMode; +// +// +//import java.util.List; +//import java.util.Optional; +// +//import static org.junit.jupiter.api.Assertions.assertEquals; +//import static org.mockito.Mockito.*; +// +//class RoomControllerTest { +// +// @InjectMocks +// private RoomController roomController; +// +// @Mock +// private RoomService roomService; +// +// @Mock +// private RoomUserService roomUserService; +// +// @Mock +// private RedisTemplate objectRedisTemplate; +// @Mock +// private RedisTemplate stringRedisTemplate; +// +// +// @BeforeEach +// void setup() { +// MockitoAnnotations.openMocks(this); +// } +// +// @Test +// void 대기방_입장_성공() { +// +// String roomId = "room1"; +// String ownerId = "owner1"; +// RoomMetadata metadata = new RoomMetadata( +// "Test Room", ownerId, false, "", 4, 2, "NORMAL", "STANDARD" +// ); +// List usersAfterJoin = List.of(ownerId); +// +// String userId = "user1"; +// +// // 전제 조건 1: 아직 어떤 방에도 없고, 해당 방에도 미입장 상태 +// when(roomUserService.getCurrentRoomOfUser(userId)) +// .thenReturn(Optional.empty()); +// when(roomUserService.isUserInRoom(userId, roomId)) +// .thenReturn(false); +// +// // 전제 조건 2: 방 정보 조회 및 입장 후 유저 리스트 +// when(roomService.getRoomInfo(roomId)) +// .thenReturn(metadata); +// when(roomUserService.getUsersInRoom(roomId)) +// .thenReturn(usersAfterJoin); +// +// //로직 실행 +// roomController.enterRoom(roomId, userId); +// +// // 결과 확인 +// // 1) 실제로 joinRoom 호출 됐는지 +// verify(roomUserService).joinRoom(userId, roomId); +// +// // 2) 정확한 채널로 이벤트 발행됐는지, 페이로드 내부 검증 +// ArgumentCaptor captor = +// ArgumentCaptor.forClass(RoomJoinedEvent.class); +// verify(objectRedisTemplate) +// .convertAndSend(eq(ROOM_INIT_INFO + roomId), captor.capture()); +// +// RoomJoinedEvent event = captor.getValue(); +// // payload 검증 +// assertEquals(metadata, event.metadata(), +// "RoomMetadata가 그대로 전달되어야 합니다."); +// assertEquals(usersAfterJoin, event.users(), +// "입장 후 방에 남은 사용자 리스트가 전달되어야 합니다."); +// assertEquals(userId, event.userId(), +// "입장한 사용자 ID가 전달되어야 합니다."); +// } +// +//// @Test +//// void testCreateRoom_success() { +//// String roomId = "room123"; +//// String userId = "userX"; +//// +//// CreateRoomRequest request = new CreateRoomRequest( +//// roomId, "test title", false, "", AiMode.BASIC, GameMode.GAME_1 +//// ); +//// +//// RoomMetadata metadata = new RoomMetadata("test title", userId, false, "", 4, 2, "NORMAL", "STANDARD"); +//// +//// when(roomUserService.getCurrentRoomOfUser(userId)).thenReturn(Optional.empty()); +//// when(roomService.getRoomInfo(roomId)).thenReturn(metadata); +//// +//// roomController.createRoom(request, userId); +//// +//// verify(roomService).createRoom(request, userId); +//// verify(redisTemplate).convertAndSend(eq("room:create"), any(RoomMetadata.class)); +//// } +//// +//// @Test +//// void testLeaveRoom_successAndDeletesIfEmpty() { +//// String roomId = "roomA"; +//// String userId = "userA"; +//// +//// when(roomUserService.getUsersInRoom(roomId)).thenReturn(List.of()); // Empty → 삭제 +//// +//// roomController.leaveRoom(roomId, userId); +//// +//// verify(roomUserService).leaveRoom(userId, roomId); +//// verify(roomService).deleteRoom(roomId); +//// verify(redisTemplate).convertAndSend("room:left:" + roomId, userId); +//// } +//// +//// @Test +//// void testUpdateRoomField_ownerTransferSuccess() { +//// String roomId = "roomX"; +//// String userId = "admin"; +//// String newOwnerId = "newGuy"; +//// +//// UpdateRoomFieldRequest req = new UpdateRoomFieldRequest(roomId, "owner", newOwnerId); +//// RoomMetadata oldMetadata = new RoomMetadata("title", userId, false, "", 4, 2, "EASY", "STANDARD"); +//// +//// when(roomService.getRoomInfo(roomId)).thenReturn(oldMetadata); +//// when(roomUserService.isUserInRoom(newOwnerId, roomId)).thenReturn(true); +//// +//// roomController.updateRoomField(req, userId); +//// +//// verify(roomService).updateRoomField(roomId, "owner", newOwnerId); +//// verify(redisTemplate).convertAndSend(eq("room:update:" + roomId), any(RoomMetadata.class)); +//// } +//// +//// @Test +//// void testUpdateRoomField_failIfNotOwner() { +//// String roomId = "roomY"; +//// String userId = "nonOwner"; +//// +//// UpdateRoomFieldRequest req = new UpdateRoomFieldRequest(roomId, "owner", "someone"); +//// RoomMetadata metadata = new RoomMetadata("test", "actualOwner", false, "", 4, 2, "NORMAL", "STANDARD"); +//// +//// when(roomService.getRoomInfo(roomId)).thenReturn(metadata); +//// +//// org.junit.jupiter.api.Assertions.assertThrows(IllegalStateException.class, () -> +//// roomController.updateRoomField(req, userId)); +//// } +//}