diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 80750d30..5aec7b4b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -75,6 +75,7 @@ kotest-assertions-core = { group = "io.kotest", name = "kotest-assertions-core", kotest-extensions-spring = { group = "io.kotest.extensions", name = "kotest-extensions-spring", version.ref = "kotest-extensions-spring" } spring-boot-starter-actuator = { group = "org.springframework.boot", name = "spring-boot-starter-actuator", version.ref = "spring-boot" } spring-boot-starter-test = { group = "org.springframework.boot", name = "spring-boot-starter-test", version.ref = "spring-boot" } +spring-boot-starter-websocket = { group = "org.springframework.boot", name = "spring-boot-starter-websocket", version.ref = "spring-boot" } kotest-extensions-testcontainers = { group = "io.kotest.extensions", name = "kotest-extensions-testcontainers", version.ref = "kotest-extensions-testcontainers" } testcontainers-junit-jupiter = { group = "org.testcontainers", name = "junit-jupiter", version.ref = "testcontainers" } diff --git a/idle-application/src/main/kotlin/com/swm/idle/application/chat/domain/ChatMessageService.kt b/idle-application/src/main/kotlin/com/swm/idle/application/chat/domain/ChatMessageService.kt new file mode 100644 index 00000000..c2f7c921 --- /dev/null +++ b/idle-application/src/main/kotlin/com/swm/idle/application/chat/domain/ChatMessageService.kt @@ -0,0 +1,20 @@ +package com.swm.idle.application.chat.domain + +import com.swm.idle.domain.chat.entity.jpa.ChatMessage +import com.swm.idle.domain.chat.enums.SenderType +import org.springframework.stereotype.Service +import java.util.* + +@Service +class ChatMessageService { + + fun createByUser(roomId: UUID, userId: UUID, contents: List): ChatMessage { + return ChatMessage( + roomId = roomId, + senderId = userId, + senderType = SenderType.USER, + contents = contents + ) + } + +} diff --git a/idle-application/src/main/kotlin/com/swm/idle/application/chat/facade/ChatMessageFacadeService.kt b/idle-application/src/main/kotlin/com/swm/idle/application/chat/facade/ChatMessageFacadeService.kt new file mode 100644 index 00000000..5d48e641 --- /dev/null +++ b/idle-application/src/main/kotlin/com/swm/idle/application/chat/facade/ChatMessageFacadeService.kt @@ -0,0 +1,33 @@ +package com.swm.idle.application.chat.facade + +import com.swm.idle.application.chat.domain.ChatMessageService +import com.swm.idle.domain.chat.entity.jpa.ChatMessage +import com.swm.idle.domain.chat.event.ChatMessageRedisPublisher +import org.springframework.stereotype.Service +import java.util.* + +@Service +class ChatMessageFacadeService( + private val chatMessageRedisPublisher: ChatMessageRedisPublisher, + private val chatMessageService: ChatMessageService, +) { + + fun sendTextMessage( + roomId: UUID, + senderId: UUID, + contents: List, + ) { + chatMessageService.createByUser( + roomId = roomId, + userId = senderId, + contents = contents, + ).also { + chatMessageRedisPublisher.publish(it) + } + } + + fun saveMessage(chatMessage: ChatMessage) { + // TODO : 메세지 저장 로직 구현 + } + +} diff --git a/idle-application/src/main/kotlin/com/swm/idle/application/chat/facade/ChatRoomFacadeService.kt b/idle-application/src/main/kotlin/com/swm/idle/application/chat/facade/ChatRoomFacadeService.kt new file mode 100644 index 00000000..29c73dbe --- /dev/null +++ b/idle-application/src/main/kotlin/com/swm/idle/application/chat/facade/ChatRoomFacadeService.kt @@ -0,0 +1,7 @@ +package com.swm.idle.application.chat.facade + +import org.springframework.stereotype.Service + +@Service +class ChatRoomFacadeService { +} diff --git a/idle-domain/src/main/kotlin/com/swm/idle/domain/chat/config/ChatMessageRedisConfig.kt b/idle-domain/src/main/kotlin/com/swm/idle/domain/chat/config/ChatMessageRedisConfig.kt new file mode 100644 index 00000000..b77637f4 --- /dev/null +++ b/idle-domain/src/main/kotlin/com/swm/idle/domain/chat/config/ChatMessageRedisConfig.kt @@ -0,0 +1,41 @@ +package com.swm.idle.domain.chat.config + +import com.swm.idle.domain.chat.event.ChatMessageRedisConsumer +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.data.redis.connection.RedisConnectionFactory +import org.springframework.data.redis.listener.ChannelTopic +import org.springframework.data.redis.listener.RedisMessageListenerContainer +import org.springframework.data.redis.listener.adapter.MessageListenerAdapter + +@Configuration +class ChatMessageRedisConfig { + + @Bean + fun redisListenerContainer( + connectionFactory: RedisConnectionFactory, + messageListenerAdapter: MessageListenerAdapter, + ): RedisMessageListenerContainer { + val container = RedisMessageListenerContainer() + container.setConnectionFactory(connectionFactory) + container.addMessageListener(messageListenerAdapter, chatChannelTopic()) + return container + } + + @Bean + fun messageListenerAdapter(chatMessageRedisConsumer: ChatMessageRedisConsumer): MessageListenerAdapter { + return MessageListenerAdapter(chatMessageRedisConsumer) + } + + @Bean + fun chatChannelTopic(): ChannelTopic { + return ChannelTopic(CHAT_MESSAGE) + } + + companion object { + + const val CHAT_MESSAGE = "chat_message" + + } + +} diff --git a/idle-domain/src/main/kotlin/com/swm/idle/domain/chat/entity/jpa/ChatMessage.kt b/idle-domain/src/main/kotlin/com/swm/idle/domain/chat/entity/jpa/ChatMessage.kt index e4d4ef35..4120be0e 100644 --- a/idle-domain/src/main/kotlin/com/swm/idle/domain/chat/entity/jpa/ChatMessage.kt +++ b/idle-domain/src/main/kotlin/com/swm/idle/domain/chat/entity/jpa/ChatMessage.kt @@ -2,7 +2,6 @@ package com.swm.idle.domain.chat.entity.jpa import com.swm.idle.domain.chat.enums.ContentType import com.swm.idle.domain.chat.enums.SenderType -//import com.swm.idle.domain.chat.vo.Content import com.swm.idle.domain.common.entity.BaseEntity import jakarta.persistence.Column import jakarta.persistence.Entity @@ -43,6 +42,12 @@ class ChatMessage( data class Content( val type: ContentType, val value: String, - ) + ) { + + init { + require(value.isNotBlank()) { "채팅 메세지는 최소 1자 이상 입력해 주셔야 합니다." } + require(value.length <= 500) { "채팅 메세지는 500자를 초과할 수 없습니다." } + } + } } diff --git a/idle-domain/src/main/kotlin/com/swm/idle/domain/chat/event/ChatMessageRedisConsumer.kt b/idle-domain/src/main/kotlin/com/swm/idle/domain/chat/event/ChatMessageRedisConsumer.kt new file mode 100644 index 00000000..9c765bb9 --- /dev/null +++ b/idle-domain/src/main/kotlin/com/swm/idle/domain/chat/event/ChatMessageRedisConsumer.kt @@ -0,0 +1,30 @@ +package com.swm.idle.domain.chat.event + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import com.swm.idle.domain.chat.entity.jpa.ChatMessage +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.context.ApplicationEventPublisher +import org.springframework.data.redis.connection.Message +import org.springframework.data.redis.connection.MessageListener +import org.springframework.stereotype.Component + +@Component +class ChatMessageRedisConsumer( + private val applicationEventPublisher: ApplicationEventPublisher, + private val objectMapper: ObjectMapper, +) : MessageListener { + + val logger = KotlinLogging.logger {} + + override fun onMessage( + message: Message, + pattern: ByteArray?, + ) { + logger.debug { "Received message: $message" } + + objectMapper.readValue(message.body) + .also { applicationEventPublisher.publishEvent(it) } + } + +} diff --git a/idle-domain/src/main/kotlin/com/swm/idle/domain/chat/event/ChatMessageRedisPublisher.kt b/idle-domain/src/main/kotlin/com/swm/idle/domain/chat/event/ChatMessageRedisPublisher.kt new file mode 100644 index 00000000..b8a6616f --- /dev/null +++ b/idle-domain/src/main/kotlin/com/swm/idle/domain/chat/event/ChatMessageRedisPublisher.kt @@ -0,0 +1,22 @@ +package com.swm.idle.domain.chat.event + +import com.swm.idle.domain.chat.config.ChatMessageRedisConfig +import com.swm.idle.domain.chat.entity.jpa.ChatMessage +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.data.redis.core.RedisTemplate +import org.springframework.stereotype.Component + +@Component +class ChatMessageRedisPublisher( + private val redisTemplate: RedisTemplate, +) { + + private val logger = KotlinLogging.logger {} + + fun publish(chatMessage: ChatMessage) { + logger.info { "RedisPublisher 도달 " } + + redisTemplate.convertAndSend(ChatMessageRedisConfig.CHAT_MESSAGE, chatMessage) + } + +} diff --git a/idle-domain/src/main/kotlin/com/swm/idle/domain/common/config/RedisConfig.kt b/idle-domain/src/main/kotlin/com/swm/idle/domain/common/config/RedisConfig.kt index 25c63038..580298af 100644 --- a/idle-domain/src/main/kotlin/com/swm/idle/domain/common/config/RedisConfig.kt +++ b/idle-domain/src/main/kotlin/com/swm/idle/domain/common/config/RedisConfig.kt @@ -1,5 +1,9 @@ package com.swm.idle.domain.common.config +import com.fasterxml.jackson.core.json.JsonReadFeature +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule +import com.fasterxml.jackson.module.kotlin.KotlinModule import com.swm.idle.domain.common.properties.RedisProperties import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.context.annotation.Bean @@ -10,6 +14,8 @@ 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.repository.configuration.EnableRedisRepositories +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer +import org.springframework.data.redis.serializer.StringRedisSerializer @Configuration @EnableConfigurationProperties(RedisProperties::class) @@ -30,10 +36,22 @@ class RedisConfig( } @Bean - fun redisTemplate(redisConnectionFactory: RedisConnectionFactory?): RedisTemplate<*, *> { - val template = RedisTemplate() - template.connectionFactory = redisConnectionFactory - return template + fun redisTemplate(redisConnectionFactory: RedisConnectionFactory?): RedisTemplate { + val serializer = GenericJackson2JsonRedisSerializer(objectMapper) + + return RedisTemplate().apply { + connectionFactory = redisConnectionFactory!! + keySerializer = StringRedisSerializer() + valueSerializer = serializer + hashKeySerializer = StringRedisSerializer() + hashValueSerializer = serializer + } + } + + private val objectMapper = ObjectMapper().apply { + registerModule(KotlinModule.Builder().build()) + registerModule(JavaTimeModule()) + enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature()) } } diff --git a/idle-presentation/build.gradle.kts b/idle-presentation/build.gradle.kts index 24e97e3a..db5712ab 100644 --- a/idle-presentation/build.gradle.kts +++ b/idle-presentation/build.gradle.kts @@ -13,6 +13,7 @@ dependencies { implementation(project(":idle-infrastructure:monitoring")) implementation(libs.spring.boot.starter.web) + implementation(libs.spring.boot.starter.websocket) implementation(libs.spring.boot.starter.data.jpa) implementation(libs.springdoc.openapi.starter.webmvc.ui) diff --git a/idle-presentation/src/main/kotlin/com/swm/idle/presentation/chat/config/WebSocketConfig.kt b/idle-presentation/src/main/kotlin/com/swm/idle/presentation/chat/config/WebSocketConfig.kt new file mode 100644 index 00000000..79dc4da2 --- /dev/null +++ b/idle-presentation/src/main/kotlin/com/swm/idle/presentation/chat/config/WebSocketConfig.kt @@ -0,0 +1,24 @@ +package com.swm.idle.presentation.chat.config + +import org.springframework.context.annotation.Configuration +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 + +@Configuration +@EnableWebSocketMessageBroker +class WebSocketConfig : WebSocketMessageBrokerConfigurer { + + override fun registerStompEndpoints(registry: StompEndpointRegistry) { + registry + .addEndpoint("/websocket") + .setAllowedOriginPatterns("*") + } + + override fun configureMessageBroker(registry: MessageBrokerRegistry) { + registry.enableSimpleBroker("/topic") + registry.setApplicationDestinationPrefixes("/app") + } + +} diff --git a/idle-presentation/src/main/kotlin/com/swm/idle/presentation/chat/controller/ChatMessageHandler.kt b/idle-presentation/src/main/kotlin/com/swm/idle/presentation/chat/controller/ChatMessageHandler.kt new file mode 100644 index 00000000..9f925a96 --- /dev/null +++ b/idle-presentation/src/main/kotlin/com/swm/idle/presentation/chat/controller/ChatMessageHandler.kt @@ -0,0 +1,30 @@ +package com.swm.idle.presentation.chat.controller + +import com.swm.idle.application.chat.facade.ChatMessageFacadeService +import com.swm.idle.domain.chat.entity.jpa.ChatMessage +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.context.event.EventListener +import org.springframework.messaging.simp.SimpMessageSendingOperations +import org.springframework.stereotype.Controller + +@Controller +class ChatMessageHandler( + private val simpMessageSendingOperations: SimpMessageSendingOperations, + private val chatMessageFacadeService: ChatMessageFacadeService, +) { + + private val logger = KotlinLogging.logger { } + + @EventListener + fun handleChatMessage(chatMessage: ChatMessage) { + logger.info { "Handler까지 도달 " } + + simpMessageSendingOperations.convertAndSend( + "/topic/chat-rooms/${chatMessage.roomId}", + chatMessage + ) + + chatMessageFacadeService.saveMessage(chatMessage) + } + +} diff --git a/idle-presentation/src/main/kotlin/com/swm/idle/presentation/chat/controller/ChatWebSocketController.kt b/idle-presentation/src/main/kotlin/com/swm/idle/presentation/chat/controller/ChatWebSocketController.kt new file mode 100644 index 00000000..14b0bf32 --- /dev/null +++ b/idle-presentation/src/main/kotlin/com/swm/idle/presentation/chat/controller/ChatWebSocketController.kt @@ -0,0 +1,30 @@ +package com.swm.idle.presentation.chat.controller + +import com.swm.idle.application.chat.facade.ChatMessageFacadeService +import com.swm.idle.support.common.uuid.UuidCreator +import com.swm.idle.support.transfer.chat.SendChatMessageRequest +import org.springframework.messaging.handler.annotation.DestinationVariable +import org.springframework.messaging.handler.annotation.MessageMapping +import org.springframework.messaging.handler.annotation.SendTo +import org.springframework.stereotype.Controller +import java.util.* + +@Controller +class ChatWebSocketController( + private val chatMessageService: ChatMessageFacadeService, +) { + + @MessageMapping("/chat-rooms/{roomId}") + @SendTo("/topic/chat-rooms/{roomId}") + fun sendTextMessage( + @DestinationVariable roomId: UUID, + request: SendChatMessageRequest, + ) { + chatMessageService.sendTextMessage( + roomId = roomId, + senderId = UuidCreator.create(), // TODO: 토큰 인증 구현 + contents = request.contents, + ) + } + +} diff --git a/idle-support/transfer/src/main/kotlin/com/swm/idle/support/transfer/chat/SendChatMessageRequest.kt b/idle-support/transfer/src/main/kotlin/com/swm/idle/support/transfer/chat/SendChatMessageRequest.kt new file mode 100644 index 00000000..fabe0408 --- /dev/null +++ b/idle-support/transfer/src/main/kotlin/com/swm/idle/support/transfer/chat/SendChatMessageRequest.kt @@ -0,0 +1,7 @@ +package com.swm.idle.support.transfer.chat + +import com.swm.idle.domain.chat.entity.jpa.ChatMessage + +data class SendChatMessageRequest( + val contents: List, +)