From 444ad6444b2a97de5270afc5ccad4e937ab2d2a6 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Wed, 30 Oct 2024 20:00:08 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=E2=9C=A8=20Chatroom=20Join=20Event=20R?= =?UTF-8?q?elay=20(#185)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add message_content_type enum class * feat: add message_category_type enum class * feat: two types of converter impl * feat: chat_message entity with step builder * rename: type package path transfer * feat: chat_message repository & domain service * fix: chat_message redis entity @id package spring to jakarta * test: dao layer test * chore: configuring the rabbit listener container factory & socket application fix * chore: add rabbitmq dependency into the socket module * feat: impl chat_join_event_listener * refactor: seperate business logic from listener * rename: contants -> constants * feat: add system message enum classes * refactor: using send_message_command for dto * chore: when integration tests run in the external-api, amqp connect is blocked --- .../src/main/resources/application.yml | 6 +- .../MessageCategoryTypeConverter.java | 13 + .../MessageContentTypeConverter.java | 13 + .../redis/message/domain/ChatMessage.java | 68 ++++++ .../message/domain/ChatMessageBuilder.java | 229 ++++++++++++++++++ .../repository/ChatMessageRepository.java | 7 + .../message/service/ChatMessageService.java | 22 ++ .../message/type/MessageCategoryType.java | 33 +++ .../message/type/MessageContentType.java | 35 +++ .../repository/ChatMessageRepositoryTest.java | 168 +++++++++++++ .../infra/config/MessageBrokerConfig.java | 9 + pennyway-socket/build.gradle | 3 + .../StompNativeHeaderFields.java | 2 +- .../constants/SystemMessageConstants.java | 9 + .../constants/SystemMessageTemplate.java | 25 ++ .../inbound/ConnectAuthenticateHandler.java | 2 +- .../socket/dto/SendMessageCommand.java | 70 ++++++ .../socket/relay/ChatJoinEventListener.java | 41 ++++ .../service/ChatMessageSendService.java | 49 ++++ .../src/main/resources/application.yml | 1 + 20 files changed, 802 insertions(+), 3 deletions(-) create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/MessageCategoryTypeConverter.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/MessageContentTypeConverter.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/message/domain/ChatMessage.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/message/domain/ChatMessageBuilder.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/message/repository/ChatMessageRepository.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/message/service/ChatMessageService.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/message/type/MessageCategoryType.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/message/type/MessageContentType.java create mode 100644 pennyway-domain/src/test/java/kr/co/pennyway/domain/common/redis/message/repository/ChatMessageRepositoryTest.java rename pennyway-socket/src/main/java/kr/co/pennyway/socket/common/{contants => constants}/StompNativeHeaderFields.java (85%) create mode 100644 pennyway-socket/src/main/java/kr/co/pennyway/socket/common/constants/SystemMessageConstants.java create mode 100644 pennyway-socket/src/main/java/kr/co/pennyway/socket/common/constants/SystemMessageTemplate.java create mode 100644 pennyway-socket/src/main/java/kr/co/pennyway/socket/dto/SendMessageCommand.java create mode 100644 pennyway-socket/src/main/java/kr/co/pennyway/socket/relay/ChatJoinEventListener.java create mode 100644 pennyway-socket/src/main/java/kr/co/pennyway/socket/service/ChatMessageSendService.java diff --git a/pennyway-app-external-api/src/main/resources/application.yml b/pennyway-app-external-api/src/main/resources/application.yml index dd8d54ca3..89119eabf 100644 --- a/pennyway-app-external-api/src/main/resources/application.yml +++ b/pennyway-app-external-api/src/main/resources/application.yml @@ -57,4 +57,8 @@ springdoc: spring: config: activate: - on-profile: test \ No newline at end of file + on-profile: test + +pennyway: + rabbitmq: + validate-connection: false \ No newline at end of file diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/MessageCategoryTypeConverter.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/MessageCategoryTypeConverter.java new file mode 100644 index 000000000..f8de80798 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/MessageCategoryTypeConverter.java @@ -0,0 +1,13 @@ +package kr.co.pennyway.domain.common.converter; + +import jakarta.persistence.Converter; +import kr.co.pennyway.domain.common.redis.message.type.MessageCategoryType; + +@Converter +public class MessageCategoryTypeConverter extends AbstractLegacyEnumAttributeConverter { + private static final String ENUM_NAME = "메시지 카테고리 타입"; + + public MessageCategoryTypeConverter() { + super(MessageCategoryType.class, false, ENUM_NAME); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/MessageContentTypeConverter.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/MessageContentTypeConverter.java new file mode 100644 index 000000000..8305588da --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/MessageContentTypeConverter.java @@ -0,0 +1,13 @@ +package kr.co.pennyway.domain.common.converter; + +import jakarta.persistence.Converter; +import kr.co.pennyway.domain.common.redis.message.type.MessageContentType; + +@Converter +public class MessageContentTypeConverter extends AbstractLegacyEnumAttributeConverter { + private static final String ENUM_NAME = "메시지 컨텐츠 타입"; + + public MessageContentTypeConverter() { + super(MessageContentType.class, false, ENUM_NAME); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/message/domain/ChatMessage.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/message/domain/ChatMessage.java new file mode 100644 index 000000000..948cb437a --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/message/domain/ChatMessage.java @@ -0,0 +1,68 @@ +package kr.co.pennyway.domain.common.redis.message.domain; + +import jakarta.persistence.Convert; +import jakarta.persistence.Id; +import kr.co.pennyway.domain.common.converter.MessageCategoryTypeConverter; +import kr.co.pennyway.domain.common.converter.MessageContentTypeConverter; +import kr.co.pennyway.domain.common.redis.message.type.MessageCategoryType; +import kr.co.pennyway.domain.common.redis.message.type.MessageContentType; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.redis.core.RedisHash; + +import java.time.LocalDateTime; + +/** + * 채팅 메시지를 표현하는 클래스입니다. + * Redis에 저장되는 채팅 메시지의 기본 단위입니다. + */ +@Getter +@RedisHash +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ChatMessage { + /** + * 채팅 메시지 ID는 "chatroom:{roomId}:message:{messageId}" 형태로 생성한다. + */ + @Id + private String id; + private String content; + @Convert(converter = MessageContentTypeConverter.class) + private MessageContentType contentType; + @Convert(converter = MessageCategoryTypeConverter.class) + private MessageCategoryType categoryType; + private LocalDateTime createdAt; + private LocalDateTime deletedAt; + private Long sender; + + protected ChatMessage(ChatMessageBuilder builder) { + this.id = "chatroom:" + builder.getChatRoomId() + ":message:" + builder.getChatId(); + this.content = builder.getContent(); + this.contentType = builder.getContentType(); + this.categoryType = builder.getCategoryType(); + this.createdAt = LocalDateTime.now(); + this.deletedAt = null; + this.sender = builder.getSender(); + } + + public Long getChatRoomId() { + return Long.parseLong(id.split(":")[1]); + } + + public Long getChatId() { + return Long.parseLong(id.split(":")[3]); + } + + @Override + public String toString() { + return "ChatMessage{" + + "id='" + id + '\'' + + ", content='" + content + '\'' + + ", contentType=" + contentType + + ", categoryType=" + categoryType + + ", createdAt=" + createdAt + + ", deletedAt=" + deletedAt + + ", sender=" + sender + + '}'; + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/message/domain/ChatMessageBuilder.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/message/domain/ChatMessageBuilder.java new file mode 100644 index 000000000..006707276 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/message/domain/ChatMessageBuilder.java @@ -0,0 +1,229 @@ +package kr.co.pennyway.domain.common.redis.message.domain; + +import kr.co.pennyway.domain.common.redis.message.type.MessageCategoryType; +import kr.co.pennyway.domain.common.redis.message.type.MessageContentType; +import org.springframework.lang.NonNull; + +import java.util.Objects; + +/** + * 채팅 메시지 생성을 위한 Step Builder입니다. + * 필수 필드들을 순차적으로 설정하도록 강제하여 객체 생성의 안정성을 보장합니다. + * + *

사용 예시: + *

+ * ChatMessage message = ChatMessage.builder()
+ *     .chatRoomId(123L)
+ *     .chatId(456L)
+ *     .content("Hello")
+ *     .contentType(MessageContentType.TEXT)
+ *     .categoryType(MessageCategoryType.NORMAL)
+ *     .sender(789L)
+ *     .build();
+ * 
+ */ +public final class ChatMessageBuilder { + private Long chatRoomId; + private Long chatId; + private String content; + private MessageContentType contentType; + private MessageCategoryType categoryType; + private long sender; + + private ChatMessageBuilder() { + } + + /** + * ChatMessage 빌더의 시작점입니다. + * + * @return 채팅방 ID 설정 단계 + */ + public static ChatRoomIdStep builder() { + return new Steps(); + } + + Long getChatRoomId() { + return chatRoomId; + } + + Long getChatId() { + return chatId; + } + + String getContent() { + return content; + } + + MessageContentType getContentType() { + return contentType; + } + + MessageCategoryType getCategoryType() { + return categoryType; + } + + long getSender() { + return sender; + } + + /** + * 채팅방 ID 설정 단계입니다. + * 채팅 메시지가 속한 채팅방의 ID를 지정합니다. + */ + public interface ChatRoomIdStep { + /** + * 채팅방 ID를 설정합니다. + * + * @param chatRoomId 채팅방 ID + * @return 채팅 메시지 ID 설정 단계 + * @throws NullPointerException chatRoomId가 null인 경우 + */ + ChatIdStep chatRoomId(Long chatRoomId); + } + + /** + * 채팅 메시지 ID 설정 단계입니다. + * 개별 채팅 메시지를 식별하기 위한 ID를 지정합니다. + */ + public interface ChatIdStep { + /** + * 채팅 메시지 ID를 설정합니다. + * + * @param chatId 채팅 메시지 ID + * @return 메시지 내용 설정 단계 + * @throws NullPointerException chatId가 null인 경우 + */ + ContentStep chatId(Long chatId); + } + + /** + * 메시지 내용 설정 단계입니다. + * 채팅 메시지의 실제 내용을 지정합니다. + */ + public interface ContentStep { + /** + * 메시지 내용을 설정합니다. + * + * @param content 메시지 내용 + * @return 메시지 타입 설정 단계 + * @throws NullPointerException content가 null인 경우 + * @throws IllegalArgumentException content가 5000자를 초과하는 경우 + */ + ContentTypeStep content(String content); + } + + /** + * 메시지 타입 설정 단계입니다. + * 메시지의 형식(텍스트, 이미지, 파일 등)을 지정합니다. + */ + public interface ContentTypeStep { + /** + * 메시지 타입을 설정합니다. + * + * @param contentType 메시지 타입 + * @return 메시지 카테고리 설정 단계 + * @throws NullPointerException contentType이 null인 경우 + */ + CategoryTypeStep contentType(MessageContentType contentType); + } + + /** + * 메시지 카테고리 설정 단계입니다. + * 메시지의 종류(일반, 시스템 등)를 지정합니다. + */ + public interface CategoryTypeStep { + /** + * 메시지 카테고리를 설정합니다. + * + * @param categoryType 메시지 카테고리 + * @return 발신자 설정 단계 + * @throws NullPointerException categoryType이 null인 경우 + */ + SenderStep categoryType(MessageCategoryType categoryType); + } + + /** + * 발신자 설정 단계입니다. + * 메시지를 보낸 사용자의 ID를 지정합니다. + */ + public interface SenderStep { + /** + * 발신자 ID를 설정합니다. + * + * @param sender 발신자 ID + * @return 빌드 단계 + */ + BuildStep sender(Long sender); + } + + /** + * 최종 빌드 단계입니다. + * 모든 필수 필드가 설정된 후 ChatMessage 객체를 생성합니다. + */ + public interface BuildStep { + /** + * 설정된 값들을 사용하여 ChatMessage 객체를 생성합니다. + * + * @return 생성된 ChatMessage 객체 + */ + ChatMessage build(); + } + + private static class Steps implements + ChatRoomIdStep, + ChatIdStep, + ContentStep, + ContentTypeStep, + CategoryTypeStep, + SenderStep, + BuildStep { + + private final ChatMessageBuilder builder = new ChatMessageBuilder(); + + @Override + public ChatIdStep chatRoomId(@NonNull final Long chatRoomId) { + builder.chatRoomId = Objects.requireNonNull(chatRoomId, "chatRoomId must not be null"); + return this; + } + + @Override + public ContentStep chatId(@NonNull final Long chatId) { + builder.chatId = Objects.requireNonNull(chatId, "chatId must not be null"); + return this; + } + + @Override + public ContentTypeStep content(@NonNull final String content) { + builder.content = Objects.requireNonNull(content, "content must not be null"); + + if (content.length() > 5000) { + throw new IllegalArgumentException("content length must be less than or equal to 5000"); + } + + return this; + } + + @Override + public CategoryTypeStep contentType(@NonNull final MessageContentType contentType) { + builder.contentType = Objects.requireNonNull(contentType, "contentType must not be null"); + return this; + } + + @Override + public SenderStep categoryType(@NonNull final MessageCategoryType categoryType) { + builder.categoryType = Objects.requireNonNull(categoryType, "categoryType must not be null"); + return this; + } + + @Override + public BuildStep sender(@NonNull final Long sender) { + builder.sender = sender; + return this; + } + + @Override + public ChatMessage build() { + return new ChatMessage(builder); + } + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/message/repository/ChatMessageRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/message/repository/ChatMessageRepository.java new file mode 100644 index 000000000..d1d2bd5d9 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/message/repository/ChatMessageRepository.java @@ -0,0 +1,7 @@ +package kr.co.pennyway.domain.common.redis.message.repository; + +import kr.co.pennyway.domain.common.redis.message.domain.ChatMessage; +import org.springframework.data.repository.CrudRepository; + +public interface ChatMessageRepository extends CrudRepository { +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/message/service/ChatMessageService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/message/service/ChatMessageService.java new file mode 100644 index 000000000..da81008b2 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/message/service/ChatMessageService.java @@ -0,0 +1,22 @@ +package kr.co.pennyway.domain.common.redis.message.service; + +import kr.co.pennyway.common.annotation.DomainService; +import kr.co.pennyway.domain.common.redis.message.domain.ChatMessage; +import kr.co.pennyway.domain.common.redis.message.repository.ChatMessageRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@DomainService +@RequiredArgsConstructor +public class ChatMessageService { + private final ChatMessageRepository chatMessageRepository; + + public ChatMessage save(ChatMessage chatMessage) { + return chatMessageRepository.save(chatMessage); + } + + public void delete(final long chatRoomId, final long chatId) { + chatMessageRepository.deleteById("chatroom:" + chatRoomId + ":message:" + chatId); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/message/type/MessageCategoryType.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/message/type/MessageCategoryType.java new file mode 100644 index 000000000..20da41c93 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/message/type/MessageCategoryType.java @@ -0,0 +1,33 @@ +package kr.co.pennyway.domain.common.redis.message.type; + +import com.fasterxml.jackson.annotation.JsonCreator; +import kr.co.pennyway.domain.common.converter.LegacyCommonType; +import lombok.RequiredArgsConstructor; + +import java.util.Map; +import java.util.stream.Stream; + +@RequiredArgsConstructor +public enum MessageCategoryType implements LegacyCommonType { + NORMAL("0", "NORMAL"), + SYSTEM("1", "SYSTEM"); + + private static final Map stringToEnum = Stream.of(values()).collect(java.util.stream.Collectors.toMap(Object::toString, e -> e)); + private final String code; + private final String type; + + @JsonCreator + public static MessageCategoryType fromString(String type) { + return stringToEnum.get(type.toUpperCase()); + } + + @Override + public String getCode() { + return null; + } + + @Override + public String toString() { + return type; + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/message/type/MessageContentType.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/message/type/MessageContentType.java new file mode 100644 index 000000000..d7b895282 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/message/type/MessageContentType.java @@ -0,0 +1,35 @@ +package kr.co.pennyway.domain.common.redis.message.type; + +import com.fasterxml.jackson.annotation.JsonCreator; +import kr.co.pennyway.domain.common.converter.LegacyCommonType; +import lombok.RequiredArgsConstructor; + +import java.util.Map; +import java.util.stream.Stream; + +@RequiredArgsConstructor +public enum MessageContentType implements LegacyCommonType { + TEXT("0", "TEXT"), + IMAGE("1", "IMAGE"), + VIDEO("2", "VIDEO"), + FILE("3", "FILE"); + + private static final Map stringToEnum = Stream.of(values()).collect(java.util.stream.Collectors.toMap(Object::toString, e -> e)); + private final String code; + private final String type; + + @JsonCreator + public static MessageContentType fromString(String type) { + return stringToEnum.get(type.toUpperCase()); + } + + @Override + public String getCode() { + return code; + } + + @Override + public String toString() { + return type; + } +} diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/common/redis/message/repository/ChatMessageRepositoryTest.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/common/redis/message/repository/ChatMessageRepositoryTest.java new file mode 100644 index 000000000..a5bb0986b --- /dev/null +++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/common/redis/message/repository/ChatMessageRepositoryTest.java @@ -0,0 +1,168 @@ +package kr.co.pennyway.domain.common.redis.message.repository; + +import kr.co.pennyway.domain.common.redis.message.domain.ChatMessage; +import kr.co.pennyway.domain.common.redis.message.domain.ChatMessageBuilder; +import kr.co.pennyway.domain.common.redis.message.type.MessageCategoryType; +import kr.co.pennyway.domain.common.redis.message.type.MessageContentType; +import kr.co.pennyway.domain.config.ContainerRedisTestConfig; +import kr.co.pennyway.domain.config.RedisConfig; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.data.redis.DataRedisTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.ContextConfiguration; + +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import static org.junit.jupiter.api.Assertions.*; + +@Slf4j +@ContextConfiguration(classes = {RedisConfig.class}) +@DataRedisTest(properties = "spring.config.location=classpath:application-domain.yml") +@ActiveProfiles("test") +public class ChatMessageRepositoryTest extends ContainerRedisTestConfig { + @Autowired + private ChatMessageRepository chatMessageRepository; + + private ChatMessage chatMessage; + + @BeforeEach + void setUp() { + chatMessage = ChatMessageBuilder.builder() + .chatRoomId(1L) + .chatId(1L) + .content("Hello") + .contentType(MessageContentType.TEXT) + .categoryType(MessageCategoryType.NORMAL) + .sender(1L) + .build(); + } + + @Test + @DisplayName("Happy Path: 채팅 메시지 저장에 성공한다.") + void successSaveChatMessage() { + // when + ChatMessage savedMessage = chatMessageRepository.save(chatMessage); + + // then + log.info("Saved message: {}", savedMessage); + assertAll( + () -> assertNotNull(savedMessage, "저장된 메시지는 null이 아니어야 합니다"), + () -> assertTrue(savedMessage.getId().matches("chatroom:\\d+:message:\\d+"), "ID는 'chatroom:{roomId}:message:{messageId}' 형태여야 합니다"), + () -> assertEquals(chatMessage.getId(), savedMessage.getId(), "저장 전후의 ID가 동일해야 합니다") + ); + } + + @Test + @DisplayName("ID로 채팅 메시지 조회에 성공한다.") + void successFindChatMessageById() { + // given + ChatMessage savedMessage = chatMessageRepository.save(chatMessage); + + // when + Optional foundMessage = chatMessageRepository.findById(chatMessage.getId()); + + // then + assertAll( + () -> assertTrue(foundMessage.isPresent(), "저장된 메시지는 조회할 수 있어야 합니다"), + () -> assertEquals(savedMessage.getId(), foundMessage.get().getId(), "조회된 메시지의 ID가 일치해야 합니다") + ); + } + + @Test + @DisplayName("Enum 타입들이 올바르게 저장 및 조회된다.") + void successSaveAndFindEnumTypes() { + // given + ChatMessage savedMessage = chatMessageRepository.save(chatMessage); + + // when + Optional foundMessage = chatMessageRepository.findById(savedMessage.getId()); + + // then + assertAll( + () -> assertTrue(foundMessage.isPresent(), "저장된 메시지는 조회할 수 있어야 합니다"), + () -> assertEquals(MessageContentType.TEXT, foundMessage.get().getContentType(), + "contentType이 올바르게 저장/조회되어야 합니다"), + () -> assertEquals(MessageCategoryType.NORMAL, foundMessage.get().getCategoryType(), + "categoryType이 올바르게 저장/조회되어야 합니다") + ); + } + + @Test + @DisplayName("동일한 메시지를 여러 스레드에서 동시에 저장할 때, 중복 저장되지 않는다.") + void successSaveChatMessageConcurrently() throws InterruptedException { + // given + int threadCount = 10; + CountDownLatch latch = new CountDownLatch(threadCount); + ExecutorService executorService = Executors.newFixedThreadPool(threadCount); + Set savedIds = ConcurrentHashMap.newKeySet(); + + // when + for (int i = 0; i < threadCount; i++) { + executorService.execute(() -> { + try { + ChatMessage saved = chatMessageRepository.save(chatMessage); + savedIds.add(saved.getId()); + } finally { + latch.countDown(); + } + }); + } + latch.await(); + executorService.shutdown(); + + // then + assertEquals(1, savedIds.size(), "동일한 ID의 메시지는 한 번만 저장되어야 합니다"); + ChatMessage foundMessage = chatMessageRepository.findById(chatMessage.getId()).orElse(null); + assertNotNull(foundMessage, "저장된 메시지는 조회할 수 있어야 합니다"); + } + + @Test + @DisplayName("데이터 정합성 검증") + void successCheckDataIntegrity() { + // given + chatMessageRepository.save(chatMessage); + + // when + ChatMessage foundMessage = chatMessageRepository.findById(chatMessage.getId()).orElse(null); + + // then + log.info("Found message: {}", foundMessage); + assertAll( + () -> assertEquals(chatMessage.getId(), foundMessage.getId()), + () -> assertEquals(chatMessage.getContent(), foundMessage.getContent()), + () -> assertEquals(chatMessage.getContentType(), foundMessage.getContentType()), + () -> assertEquals(chatMessage.getCategoryType(), foundMessage.getCategoryType()), + () -> assertEquals(chatMessage.getCreatedAt(), foundMessage.getCreatedAt()), + () -> assertEquals(chatMessage.getDeletedAt(), foundMessage.getDeletedAt()), + () -> assertEquals(chatMessage.getSender(), foundMessage.getSender()) + ); + } + + @Test + @DisplayName("메시지 내용이 5000자를 초과하면 저장 시 예외가 발생한다.") + void throwExceptionWhenContentExceeds5000Characters() { + // given + String longContent = "a".repeat(5001); + + // when & then + assertThrows(IllegalArgumentException.class, + () -> ChatMessageBuilder.builder() + .chatRoomId(1L) + .chatId(1L) + .content(longContent) + .contentType(MessageContentType.TEXT) + .categoryType(MessageCategoryType.NORMAL) + .sender(1L) + .build(), + "메시지 내용이 5000자를 초과하면 예외가 발생해야 합니다"); + } +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/MessageBrokerConfig.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/MessageBrokerConfig.java index 90ad71215..b56061b92 100644 --- a/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/MessageBrokerConfig.java +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/MessageBrokerConfig.java @@ -101,6 +101,7 @@ public ConnectionFactory createConnectionFactory() { return factory; } + @Bean @ConditionalOnProperty(prefix = "pennyway.rabbitmq", name = "validate-connection", havingValue = "true", matchIfMissing = false) ApplicationRunner connectionFactoryRunner(ConnectionFactory cf) { return args -> { @@ -114,10 +115,18 @@ ApplicationRunner connectionFactoryRunner(ConnectionFactory cf) { } @Bean + @ConditionalOnProperty(prefix = "pennyway.rabbitmq", name = "chat-join-event-listener", havingValue = "true", matchIfMissing = false) public SimpleRabbitListenerContainerFactory simpleRabbitListenerContainerFactory(ConnectionFactory connectionFactory, MessageConverter messageConverter) { SimpleRabbitListenerContainerFactory factory = new SimpleRabbitListenerContainerFactory(); factory.setConnectionFactory(connectionFactory); factory.setMessageConverter(messageConverter); + + factory.setConcurrentConsumers(2); + factory.setMaxConcurrentConsumers(10); + + factory.setErrorHandler(t -> log.error("An error occurred in the listener", t)); + factory.setAutoStartup(true); + return factory; } diff --git a/pennyway-socket/build.gradle b/pennyway-socket/build.gradle index 55ec916bd..6070f230a 100644 --- a/pennyway-socket/build.gradle +++ b/pennyway-socket/build.gradle @@ -22,4 +22,7 @@ dependencies { /* Reactor Netty */ implementation group: 'org.springframework.boot', name: 'spring-boot-starter-reactor-netty', version: '3.3.4' + + /* RabbitMQ (for listener) */ + implementation group: 'org.springframework.boot', name: 'spring-boot-starter-amqp', version: '3.3.4' } \ No newline at end of file diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/contants/StompNativeHeaderFields.java b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/constants/StompNativeHeaderFields.java similarity index 85% rename from pennyway-socket/src/main/java/kr/co/pennyway/socket/common/contants/StompNativeHeaderFields.java rename to pennyway-socket/src/main/java/kr/co/pennyway/socket/common/constants/StompNativeHeaderFields.java index cbe682a5d..7ffcd6410 100644 --- a/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/contants/StompNativeHeaderFields.java +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/constants/StompNativeHeaderFields.java @@ -1,4 +1,4 @@ -package kr.co.pennyway.socket.common.contants; +package kr.co.pennyway.socket.common.constants; public enum StompNativeHeaderFields { DEVICE_ID("device-id"), diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/constants/SystemMessageConstants.java b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/constants/SystemMessageConstants.java new file mode 100644 index 000000000..5bd733721 --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/constants/SystemMessageConstants.java @@ -0,0 +1,9 @@ +package kr.co.pennyway.socket.common.constants; + +public final class SystemMessageConstants { + public static final long SYSTEM_SENDER_ID = 0L; + + private SystemMessageConstants() { + throw new UnsupportedOperationException("Constants class cannot be instantiated"); + } +} diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/constants/SystemMessageTemplate.java b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/constants/SystemMessageTemplate.java new file mode 100644 index 000000000..c76d4e4c6 --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/constants/SystemMessageTemplate.java @@ -0,0 +1,25 @@ +package kr.co.pennyway.socket.common.constants; + +/** + * 채팅방 가입, 퇴장 등 시스템 메시지 템플릿 클래스 + */ +public enum SystemMessageTemplate { + JOIN_MESSAGE_FORMAT("%s님이 입장하셨습니다."), + ; + + private final String value; + + SystemMessageTemplate(String value) { + this.value = value; + } + + /** + * 사용자 이름을 받아 시스템 메시지로 변환한다. + * + * @param userName String: 사용자 이름 + * @return 포맷팅된 시스템 메시지 + */ + public String convertToMessage(String userName) { + return String.format(value, userName); + } +} diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/interceptor/handler/inbound/ConnectAuthenticateHandler.java b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/interceptor/handler/inbound/ConnectAuthenticateHandler.java index a20b63931..65ba6f14b 100644 --- a/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/interceptor/handler/inbound/ConnectAuthenticateHandler.java +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/interceptor/handler/inbound/ConnectAuthenticateHandler.java @@ -10,7 +10,7 @@ import kr.co.pennyway.infra.common.jwt.AuthConstants; import kr.co.pennyway.infra.common.jwt.JwtClaims; import kr.co.pennyway.infra.common.util.JwtClaimsParserUtil; -import kr.co.pennyway.socket.common.contants.StompNativeHeaderFields; +import kr.co.pennyway.socket.common.constants.StompNativeHeaderFields; import kr.co.pennyway.socket.common.exception.InterceptorErrorCode; import kr.co.pennyway.socket.common.exception.InterceptorErrorException; import kr.co.pennyway.socket.common.interceptor.marker.ConnectCommandHandler; diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/dto/SendMessageCommand.java b/pennyway-socket/src/main/java/kr/co/pennyway/socket/dto/SendMessageCommand.java new file mode 100644 index 000000000..dcbf5b387 --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/dto/SendMessageCommand.java @@ -0,0 +1,70 @@ +package kr.co.pennyway.socket.dto; + +import kr.co.pennyway.domain.common.redis.message.type.MessageCategoryType; +import kr.co.pennyway.domain.common.redis.message.type.MessageContentType; +import kr.co.pennyway.socket.common.constants.SystemMessageConstants; + +/** + * 채팅 메시지 전송을 위한 Command 클래스 + */ +public record SendMessageCommand( + long chatRoomId, // 채팅방 아이디는 음수일 수 없다. + String content, // 메시지 내용은 5000자를 초과할 수 없다. + MessageContentType contentType, // 메시지 타입은 NULL일 수 없다. + MessageCategoryType categoryType, // 메시지 카테고리는 NULL일 수 없다. + long senderId // 발신자 아이디는 음수일 수 없다. +) { + public SendMessageCommand { + if (chatRoomId <= 0) { + throw new IllegalArgumentException("채팅방 아이디는 0 혹은 음수일 수 없습니다."); + } + if (content.length() > 5000) { + throw new IllegalArgumentException("메시지 내용은 5000자를 초과할 수 없습니다."); + } + if (contentType == null) { + throw new IllegalArgumentException("메시지 타입은 NULL일 수 없습니다."); + } + if (categoryType == null) { + throw new IllegalArgumentException("메시지 카테고리는 NULL일 수 없습니다."); + } + if (senderId < 0) { + throw new IllegalArgumentException("발신자 아이디는 음수일 수 없습니다."); + } + } + + /** + * 시스템 메시지를 생성합니다. + * + * @param chatRoomId long : 채팅방 아이디 + * @param content String : 메시지 내용 + * @return {@link MessageContentType#TEXT}, {@link MessageCategoryType#SYSTEM}, {@link SystemMessageConstants#SYSTEM_SENDER_ID}로 생성된 SendMessageCommand + */ + public static SendMessageCommand createSystemMessage(long chatRoomId, String content) { + return new SendMessageCommand( + chatRoomId, + content, + MessageContentType.TEXT, + MessageCategoryType.SYSTEM, + SystemMessageConstants.SYSTEM_SENDER_ID + ); + } + + /** + * 사용자 메시지를 생성합니다. + * + * @param chatRoomId long : 채팅방 아이디 + * @param content String : 메시지 내용 + * @param contentType {@link MessageContentType} : 메시지 타입 + * @param senderId long : 발신자 아이디 + * @return {@link MessageCategoryType#NORMAL}로 생성된 SendMessageCommand + */ + public static SendMessageCommand createUserMessage(long chatRoomId, String content, MessageContentType contentType, long senderId) { + return new SendMessageCommand( + chatRoomId, + content, + contentType, + MessageCategoryType.NORMAL, + senderId + ); + } +} \ No newline at end of file diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/relay/ChatJoinEventListener.java b/pennyway-socket/src/main/java/kr/co/pennyway/socket/relay/ChatJoinEventListener.java new file mode 100644 index 000000000..76e325035 --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/relay/ChatJoinEventListener.java @@ -0,0 +1,41 @@ +package kr.co.pennyway.socket.relay; + +import kr.co.pennyway.infra.common.event.ChatRoomJoinEvent; +import kr.co.pennyway.infra.common.properties.ChatExchangeProperties; +import kr.co.pennyway.socket.common.constants.SystemMessageTemplate; +import kr.co.pennyway.socket.dto.SendMessageCommand; +import kr.co.pennyway.socket.service.ChatMessageSendService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.rabbit.annotation.Exchange; +import org.springframework.amqp.rabbit.annotation.Queue; +import org.springframework.amqp.rabbit.annotation.QueueBinding; +import org.springframework.amqp.rabbit.annotation.RabbitListener; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +@EnableConfigurationProperties({ChatExchangeProperties.class}) +public class ChatJoinEventListener { + private static final String JOIN_MESSAGE_SUFFIX = "님이 입장하셨습니다."; + + private final ChatMessageSendService chatMessageSendService; + + @RabbitListener( + containerFactory = "simpleRabbitListenerContainerFactory", + bindings = @QueueBinding( + value = @Queue("${pennyway.rabbitmq.chat-join-event.queue}"), + exchange = @Exchange(value = "${pennyway.rabbitmq.chat.exchange}"), + key = "${pennyway.rabbitmq.chat-join-event.routing-key}" + ) + ) + public void handleJoinEvent(ChatRoomJoinEvent event) { + log.debug("handleJoinEvent: {}", event); + + chatMessageSendService.execute( + SendMessageCommand.createSystemMessage(event.chatRoomId(), SystemMessageTemplate.JOIN_MESSAGE_FORMAT.convertToMessage(event.userName())) + ); + } +} diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/service/ChatMessageSendService.java b/pennyway-socket/src/main/java/kr/co/pennyway/socket/service/ChatMessageSendService.java new file mode 100644 index 000000000..0e5224873 --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/service/ChatMessageSendService.java @@ -0,0 +1,49 @@ +package kr.co.pennyway.socket.service; + +import kr.co.pennyway.domain.common.redis.message.domain.ChatMessage; +import kr.co.pennyway.domain.common.redis.message.domain.ChatMessageBuilder; +import kr.co.pennyway.domain.common.redis.message.service.ChatMessageService; +import kr.co.pennyway.infra.client.broker.MessageBrokerAdapter; +import kr.co.pennyway.infra.client.guid.IdGenerator; +import kr.co.pennyway.infra.common.properties.ChatExchangeProperties; +import kr.co.pennyway.socket.dto.SendMessageCommand; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +@EnableConfigurationProperties({ChatExchangeProperties.class}) +public class ChatMessageSendService { + private final ChatMessageService chatMessageService; + + private final MessageBrokerAdapter messageBrokerAdapter; + private final IdGenerator idGenerator; + private final ChatExchangeProperties chatExchangeProperties; + + /** + * 채팅 메시지를 전송한다. + * + * @param command SendMessageCommand : 채팅 메시지 전송을 위한 Command + */ + public void execute(SendMessageCommand command) { + ChatMessage message = ChatMessageBuilder.builder() + .chatRoomId(command.chatRoomId()) + .chatId(idGenerator.generate()) + .content(command.content()) + .contentType(command.contentType()) + .categoryType(command.categoryType()) + .sender(command.senderId()) + .build(); + + chatMessageService.save(message); + + messageBrokerAdapter.convertAndSend( + chatExchangeProperties.getExchange(), + "chat.room." + command.chatRoomId(), + message + ); + } +} diff --git a/pennyway-socket/src/main/resources/application.yml b/pennyway-socket/src/main/resources/application.yml index 32218373b..29dce0f67 100644 --- a/pennyway-socket/src/main/resources/application.yml +++ b/pennyway-socket/src/main/resources/application.yml @@ -14,6 +14,7 @@ pennyway: allowed-origin-patterns: ${ALLOWED_ORIGIN_PATTERNS:*} rabbitmq: validate-connection: true + chat-join-event-listener: true message-broker: external: