diff --git a/.gitignore b/.gitignore index c179ae7..7c3616a 100644 --- a/.gitignore +++ b/.gitignore @@ -75,4 +75,5 @@ build/reports/ coverage/ # Test file -index.html \ No newline at end of file +index.html +docker-compose.yml \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 6745ee5..4891c6a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -54,6 +54,7 @@ dependencies { // 웹소켓 - 사용자 채팅 기능 (게스트-가이드) implementation("org.springframework.boot:spring-boot-starter-websocket") + implementation("org.springframework.boot:spring-boot-starter-amqp") // 레디스 - 캐싱 및 세션 관리 implementation("org.springframework.boot:spring-boot-starter-data-redis") @@ -74,6 +75,7 @@ dependencies { testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") testImplementation("org.springframework.security:spring-security-test") + testImplementation("org.springframework.amqp:spring-rabbit-test") testImplementation("io.mockk:mockk:1.13.12") testRuntimeOnly("org.junit.platform:junit-platform-launcher") } diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/controller/ChatMessageController.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/controller/ChatMessageController.kt index 500ddef..4f4c7b3 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/controller/ChatMessageController.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/controller/ChatMessageController.kt @@ -4,8 +4,8 @@ import com.back.koreaTravelGuide.common.ApiResponse import com.back.koreaTravelGuide.domain.userChat.chatmessage.dto.ChatMessageResponse import com.back.koreaTravelGuide.domain.userChat.chatmessage.dto.ChatMessageSendRequest import com.back.koreaTravelGuide.domain.userChat.chatmessage.service.ChatMessageService +import com.back.koreaTravelGuide.domain.userChat.chatmessage.usecase.ChatMessagePublisher import org.springframework.http.ResponseEntity -import org.springframework.messaging.simp.SimpMessagingTemplate import org.springframework.security.access.AccessDeniedException import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.GetMapping @@ -20,7 +20,7 @@ import org.springframework.web.bind.annotation.RestController @RequestMapping("/api/userchat/rooms") class ChatMessageController( private val messageService: ChatMessageService, - private val messagingTemplate: SimpMessagingTemplate, + private val chatMessagePublisher: ChatMessagePublisher, ) { @GetMapping("/{roomId}/messages") fun listMessages( @@ -49,8 +49,8 @@ class ChatMessageController( val memberId = senderId ?: throw AccessDeniedException("인증이 필요합니다.") val saved = messageService.send(roomId, memberId, req.content) val response = ChatMessageResponse.from(saved) - messagingTemplate.convertAndSend( - "/topic/userchat/$roomId", + chatMessagePublisher.publishUserChat( + roomId, ApiResponse(msg = "메시지 전송", data = response), ) return ResponseEntity.status(201).body(ApiResponse(msg = "메시지 전송", data = response)) diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/controller/ChatMessageSocketController.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/controller/ChatMessageSocketController.kt index c2f9203..94f8eb4 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/controller/ChatMessageSocketController.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/controller/ChatMessageSocketController.kt @@ -4,10 +4,10 @@ import com.back.koreaTravelGuide.common.ApiResponse import com.back.koreaTravelGuide.domain.userChat.chatmessage.dto.ChatMessageResponse import com.back.koreaTravelGuide.domain.userChat.chatmessage.dto.ChatMessageSendRequest import com.back.koreaTravelGuide.domain.userChat.chatmessage.service.ChatMessageService +import com.back.koreaTravelGuide.domain.userChat.chatmessage.usecase.ChatMessagePublisher import org.springframework.messaging.handler.annotation.DestinationVariable import org.springframework.messaging.handler.annotation.MessageMapping import org.springframework.messaging.handler.annotation.Payload -import org.springframework.messaging.simp.SimpMessagingTemplate import org.springframework.security.access.AccessDeniedException import org.springframework.stereotype.Controller import java.security.Principal @@ -15,7 +15,7 @@ import java.security.Principal @Controller class ChatMessageSocketController( private val chatMessageService: ChatMessageService, - private val messagingTemplate: SimpMessagingTemplate, + private val chatMessagePublisher: ChatMessagePublisher, ) { @MessageMapping("/userchat/{roomId}/messages") fun handleMessage( @@ -26,8 +26,8 @@ class ChatMessageSocketController( val senderId = principal.name.toLongOrNull() ?: throw AccessDeniedException("인증이 필요합니다.") val saved = chatMessageService.send(roomId, senderId, req.content) val response = ChatMessageResponse.from(saved) - messagingTemplate.convertAndSend( - "/topic/userchat/$roomId", + chatMessagePublisher.publishUserChat( + roomId, ApiResponse(msg = "메시지 전송", data = response), ) } diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/usecase/ChatMessagePublisher.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/usecase/ChatMessagePublisher.kt new file mode 100644 index 0000000..2926f96 --- /dev/null +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/usecase/ChatMessagePublisher.kt @@ -0,0 +1,8 @@ +package com.back.koreaTravelGuide.domain.userChat.chatmessage.usecase + +interface ChatMessagePublisher { + fun publishUserChat( + roomId: Long, + payload: Any, + ) +} diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/usecase/RabbitChatMessagePublisher.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/usecase/RabbitChatMessagePublisher.kt new file mode 100644 index 0000000..55d37a2 --- /dev/null +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/usecase/RabbitChatMessagePublisher.kt @@ -0,0 +1,19 @@ +package com.back.koreaTravelGuide.domain.userChat.chatmessage.usecase + +import org.springframework.amqp.rabbit.core.RabbitTemplate +import org.springframework.context.annotation.Profile +import org.springframework.stereotype.Component + +@Profile("prod") +@Component +class RabbitChatMessagePublisher( + private val rabbitTemplate: RabbitTemplate, +) : ChatMessagePublisher { + override fun publishUserChat( + roomId: Long, + payload: Any, + ) { + val routingKey = "userchat.$roomId" + rabbitTemplate.convertAndSend("amq.topic", routingKey, payload) + } +} diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/usecase/SimpleChatMessagePublisher.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/usecase/SimpleChatMessagePublisher.kt new file mode 100644 index 0000000..38602cb --- /dev/null +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/usecase/SimpleChatMessagePublisher.kt @@ -0,0 +1,18 @@ +package com.back.koreaTravelGuide.domain.userChat.chatmessage.usecase + +import org.springframework.context.annotation.Profile +import org.springframework.messaging.simp.SimpMessagingTemplate +import org.springframework.stereotype.Component + +@Profile("!prod") +@Component +class SimpleChatMessagePublisher( + private val messagingTemplate: SimpMessagingTemplate, +) : ChatMessagePublisher { + override fun publishUserChat( + roomId: Long, + payload: Any, + ) { + messagingTemplate.convertAndSend("/topic/userchat/$roomId", payload) + } +} diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/stomp/UserChatRabbitWebSocketConfig.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/stomp/UserChatRabbitWebSocketConfig.kt new file mode 100644 index 0000000..5ef5e08 --- /dev/null +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/stomp/UserChatRabbitWebSocketConfig.kt @@ -0,0 +1,51 @@ +package com.back.koreaTravelGuide.domain.userChat.stomp + +import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter +import org.springframework.amqp.support.converter.MessageConverter +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Profile +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 + +// userChat에서만 사용할 것 같아서 전역에 두지 않고 userChat 도메인에 두었음 + +@Profile("prod") +@Configuration +@EnableWebSocketMessageBroker +class UserChatRabbitWebSocketConfig( + private val userChatStompAuthChannelInterceptor: UserChatStompAuthChannelInterceptor, + @Value("\${spring.rabbitmq.host}") private val rabbitHost: String, + @Value("\${spring.rabbitmq.stomp-port}") private val rabbitStompPort: Int, + @Value("\${spring.rabbitmq.username}") private val rabbitUsername: String, + @Value("\${spring.rabbitmq.password}") private val rabbitPassword: String, +) : WebSocketMessageBrokerConfigurer { + override fun registerStompEndpoints(registry: StompEndpointRegistry) { + registry.addEndpoint("/ws/userchat") + .setAllowedOriginPatterns("*") + .withSockJS() + } + + override fun configureMessageBroker(registry: MessageBrokerRegistry) { + registry + .setApplicationDestinationPrefixes("/pub") + .enableStompBrokerRelay("/topic") + .setRelayHost(rabbitHost) + .setRelayPort(rabbitStompPort) + .setClientLogin(rabbitUsername) + .setClientPasscode(rabbitPassword) + .setSystemLogin(rabbitUsername) + .setSystemPasscode(rabbitPassword) + } + + override fun configureClientInboundChannel(registration: ChannelRegistration) { + registration.interceptors(userChatStompAuthChannelInterceptor) + } + + @Bean + fun rabbitMessageConverter(): MessageConverter = Jackson2JsonMessageConverter() +} diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/config/UserChatWebSocketConfig.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/stomp/UserChatSimpleWebSocketConfig.kt similarity index 86% rename from src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/config/UserChatWebSocketConfig.kt rename to src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/stomp/UserChatSimpleWebSocketConfig.kt index 5a43aaa..8e2c9ff 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/config/UserChatWebSocketConfig.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/stomp/UserChatSimpleWebSocketConfig.kt @@ -1,17 +1,17 @@ -package com.back.koreaTravelGuide.domain.userChat.config +package com.back.koreaTravelGuide.domain.userChat.stomp import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Profile 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 -// userChat에서만 사용할 것 같아서 전역에 두지 않고 userChat 도메인에 두었음 - +@Profile("!prod") @Configuration @EnableWebSocketMessageBroker -class UserChatWebSocketConfig( +class UserChatSimpleWebSocketConfig( private val userChatStompAuthChannelInterceptor: UserChatStompAuthChannelInterceptor, ) : WebSocketMessageBrokerConfigurer { override fun registerStompEndpoints(registry: StompEndpointRegistry) { @@ -21,8 +21,8 @@ class UserChatWebSocketConfig( } override fun configureMessageBroker(registry: MessageBrokerRegistry) { - registry.enableSimpleBroker("/topic") registry.setApplicationDestinationPrefixes("/pub") + registry.enableSimpleBroker("/topic") } override fun configureClientInboundChannel(registration: ChannelRegistration) { diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/config/UserChatStompAuthChannelInterceptor.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/stomp/UserChatStompAuthChannelInterceptor.kt similarity index 96% rename from src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/config/UserChatStompAuthChannelInterceptor.kt rename to src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/stomp/UserChatStompAuthChannelInterceptor.kt index 9553bce..93553d9 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/config/UserChatStompAuthChannelInterceptor.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/stomp/UserChatStompAuthChannelInterceptor.kt @@ -1,4 +1,4 @@ -package com.back.koreaTravelGuide.domain.userChat.config +package com.back.koreaTravelGuide.domain.userChat.stomp import com.back.koreaTravelGuide.common.security.JwtTokenProvider import org.springframework.messaging.Message diff --git a/src/test/kotlin/com/back/koreaTravelGuide/domain/ai/tour/service/TourParamsParserTest.kt b/src/test/kotlin/com/back/koreaTravelGuide/domain/ai/tour/service/TourParamsParserTest.kt index d000173..20a4835 100644 --- a/src/test/kotlin/com/back/koreaTravelGuide/domain/ai/tour/service/TourParamsParserTest.kt +++ b/src/test/kotlin/com/back/koreaTravelGuide/domain/ai/tour/service/TourParamsParserTest.kt @@ -1,9 +1,9 @@ package com.back.koreaTravelGuide.domain.ai.tour.service -import kotlin.test.assertEquals -import kotlin.test.assertNull import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull class TourParamsParserTest { private val parser = TourParamsParser()