diff --git a/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomRepository.java b/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomRepository.java index a3a064f5b..f2789c553 100644 --- a/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomRepository.java +++ b/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomRepository.java @@ -2,9 +2,11 @@ import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_CHAT_ROOM; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.Repository; import org.springframework.data.repository.query.Param; @@ -19,6 +21,30 @@ public interface ChatRoomRepository extends Repository { ChatRoom save(ChatRoom chatRoom); + @Modifying(flushAutomatically = true) + @Query(""" + UPDATE ChatRoom cr + SET cr.lastMessageContent = :content, + cr.lastMessageSentAt = :sentAt + WHERE cr.id = :roomId + AND NOT EXISTS ( + SELECT 1 + FROM ChatMessage cm + WHERE cm.chatRoom.id = :roomId + AND cm.id <> :messageId + AND ( + cm.createdAt > :sentAt + OR (cm.createdAt = :sentAt AND cm.id > :messageId) + ) + ) + """) + int updateLastMessageIfLatest( + @Param("roomId") Integer roomId, + @Param("messageId") Integer messageId, + @Param("content") String content, + @Param("sentAt") LocalDateTime sentAt + ); + @Query(""" SELECT DISTINCT cr FROM ChatRoom cr diff --git a/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java b/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java index 1aa03cc44..8b43f73ba 100644 --- a/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java +++ b/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java @@ -746,7 +746,7 @@ private ChatMessageDetailResponse sendDirectMessage( senderMember.restoreDirectRoom(); } - chatRoom.updateLastMessage(chatMessage.getContent(), chatMessage.getCreatedAt()); + syncLastMessage(chatRoom, chatMessage); members.stream() .filter(member -> !member.getUserId().equals(userId)) .filter(ChatRoomMember::hasLeft) @@ -828,7 +828,7 @@ private ChatMessageDetailResponse sendClubMessageByRoomId(Integer roomId, Intege ensureRoomMember(room, sender, member.getCreatedAt()); ChatMessage message = chatMessageRepository.save(ChatMessage.of(room, sender, content)); - room.updateLastMessage(message.getContent(), message.getCreatedAt()); + syncLastMessage(room, message); updateClubMessageLastReadAt(roomId, userId, message.getCreatedAt()); List members = chatRoomMemberRepository.findByChatRoomId(roomId); @@ -909,7 +909,7 @@ private ChatMessageDetailResponse sendGroupMessageByRoomId(Integer roomId, Integ } ChatMessage message = chatMessageRepository.save(ChatMessage.of(room, sender, content)); - room.updateLastMessage(message.getContent(), message.getCreatedAt()); + syncLastMessage(room, message); updateLastReadAt(roomId, userId, message.getCreatedAt()); List members = chatRoomMemberRepository.findByChatRoomId(roomId); @@ -1210,6 +1210,20 @@ private void publishAdminChatEventIfNeeded(boolean isSystemAdminRoom, User sende } } + private void syncLastMessage(ChatRoom room, ChatMessage message) { + // 채팅방 목록은 chat_room.last_message_*를 직접 조회하므로 + // 동시 전송에서도 가장 최신 메시지만 메타데이터를 덮어쓰도록 DB 조건을 같이 건다. + int updated = chatRoomRepository.updateLastMessageIfLatest( + room.getId(), + message.getId(), + message.getContent(), + message.getCreatedAt() + ); + if (updated > 0) { + room.updateLastMessage(message.getContent(), message.getCreatedAt()); + } + } + private ChatRoomMember getRoomMember(Integer roomId, Integer userId) { return chatRoomMemberRepository.findByChatRoomIdAndUserId(roomId, userId) .orElseThrow(() -> CustomException.of(FORBIDDEN_CHAT_ROOM_ACCESS)); diff --git a/src/main/java/gg/agit/konect/domain/user/service/UserService.java b/src/main/java/gg/agit/konect/domain/user/service/UserService.java index aa3edc2e2..76ec4a8ad 100644 --- a/src/main/java/gg/agit/konect/domain/user/service/UserService.java +++ b/src/main/java/gg/agit/konect/domain/user/service/UserService.java @@ -131,12 +131,24 @@ private void sendWelcomeMessage(User newUser) { ChatMessage chatMessage = chatMessageRepository.save( ChatMessage.of(chatRoom, operator, DEFAULT_WELCOME_MESSAGE) ); - chatRoom.updateLastMessage(chatMessage.getContent(), chatMessage.getCreatedAt()); + syncLastMessage(chatRoom, chatMessage); } catch (Exception e) { log.warn("회원가입 환영 메시지 전송 실패. userId={}", newUser.getId(), e); } } + private void syncLastMessage(ChatRoom chatRoom, ChatMessage chatMessage) { + int updated = chatRoomRepository.updateLastMessageIfLatest( + chatRoom.getId(), + chatMessage.getId(), + chatMessage.getContent(), + chatMessage.getCreatedAt() + ); + if (updated > 0) { + chatRoom.updateLastMessage(chatMessage.getContent(), chatMessage.getCreatedAt()); + } + } + private UnRegisteredUser findUnregisteredUser(String email, String providerId, Provider provider) { if (StringUtils.hasText(providerId)) { if (unRegisteredUserRepository.existsByProviderIdAndProvider(providerId, provider)) { diff --git a/src/main/resources/db/migration/V70__backfill_chat_room_last_message_metadata.sql b/src/main/resources/db/migration/V70__backfill_chat_room_last_message_metadata.sql new file mode 100644 index 000000000..3fb3d0cdb --- /dev/null +++ b/src/main/resources/db/migration/V70__backfill_chat_room_last_message_metadata.sql @@ -0,0 +1,24 @@ +-- 채팅방 목록/관리 쿼리는 chat_room.last_message_*를 직접 사용하므로 +-- 기존 메시지 이력이 있는 방도 최신 메시지 메타데이터를 다시 맞춰 준다. +-- MAX(created_at)으로 최신 메시지를 고르고, 같은 시각이면 id로 타이브레이크한다. +UPDATE chat_room cr +LEFT JOIN ( + SELECT + cm1.chat_room_id, + cm1.content, + cm1.created_at + FROM chat_message cm1 + JOIN ( + SELECT chat_room_id, MAX(created_at) AS max_created_at + FROM chat_message + GROUP BY chat_room_id + ) cm2 ON cm2.chat_room_id = cm1.chat_room_id AND cm2.max_created_at = cm1.created_at + WHERE cm1.id = ( + SELECT MAX(id) + FROM chat_message + WHERE chat_room_id = cm1.chat_room_id + AND created_at = cm2.max_created_at + ) +) latest_msg ON latest_msg.chat_room_id = cr.id +SET cr.last_message_content = latest_msg.content, + cr.last_message_sent_at = latest_msg.created_at; diff --git a/src/test/java/gg/agit/konect/integration/domain/chat/ChatApiTest.java b/src/test/java/gg/agit/konect/integration/domain/chat/ChatApiTest.java index b7a479826..fb90e01f6 100644 --- a/src/test/java/gg/agit/konect/integration/domain/chat/ChatApiTest.java +++ b/src/test/java/gg/agit/konect/integration/domain/chat/ChatApiTest.java @@ -718,6 +718,35 @@ void sendMessageSuccess() throws Exception { .containsExactly("안녕하세요"); } + @Test + @DisplayName("메시지를 전송하면 chat_room last message 메타데이터도 함께 갱신된다") + @Sql( + statements = CHAT_TEST_DATA_CLEANUP_SQL, + config = @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED), + executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD + ) + void sendMessageUpdatesChatRoomLastMessageColumns() throws Exception { + // given + ChatRoom chatRoom = createDirectChatRoom(normalUser, targetUser); + mockLoginUser(normalUser.getId()); + + // when + performPost("/chats/rooms/" + chatRoom.getId() + "/messages", new ChatMessageSendRequest("메타데이터 확인")) + .andExpect(status().isOk()); + + // then + TestTransaction.flagForCommit(); + TestTransaction.end(); + + transactionTemplate.execute(status -> { + clearPersistenceContext(); + ChatRoom updatedRoom = chatRoomRepository.findById(chatRoom.getId()).orElseThrow(); + assertThat(updatedRoom.getLastMessageContent()).isEqualTo("메타데이터 확인"); + assertThat(updatedRoom.getLastMessageSentAt()).isNotNull(); + return null; + }); + } + @Test @DisplayName("관리자가 문의방에 답변하면 실제 문의 사용자에게 알림을 보낸다") void adminReplySendsNotificationToInquiryUser() throws Exception { @@ -910,6 +939,15 @@ void leaveDirectChatRoomAndShowOnlyNewMessages() throws Exception { performPost("/chats/rooms/" + chatRoom.getId() + "/messages", new ChatMessageSendRequest("다시 안녕")) .andExpect(status().isOk()); + transactionTemplate.execute(status -> { + clearPersistenceContext(); + ChatRoomMember restoredMember = chatRoomMemberRepository + .findByChatRoomIdAndUserId(chatRoom.getId(), normalUser.getId()) + .orElseThrow(); + assertThat(restoredMember.hasLeft()).isFalse(); + return null; + }); + mockLoginUser(normalUser.getId()); performGet("/chats/rooms") .andExpect(status().isOk()) diff --git a/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatServiceTest.java index 45760b453..e28c3856e 100644 --- a/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatServiceTest.java +++ b/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatServiceTest.java @@ -825,6 +825,9 @@ void sendMessageInDirectRoomSavesMessageAndSendsNotification() { given(chatRoomMemberRepository.findByChatRoomId(directRoom.getId())) .willReturn(List.of(senderMember, receiverMember)); given(chatMessageRepository.save(any(ChatMessage.class))).willReturn(savedMessage); + given(chatRoomRepository.updateLastMessageIfLatest( + directRoom.getId(), savedMessage.getId(), savedMessage.getContent(), savedMessage.getCreatedAt() + )).willReturn(1); given(chatRoomMemberRepository.updateLastReadAtIfOlder(eq(directRoom.getId()), eq(senderId), any(LocalDateTime.class))) .willReturn(1); @@ -843,6 +846,44 @@ void sendMessageInDirectRoomSavesMessageAndSendsNotification() { eq("hello")); } + @Test + @DisplayName("sendMessage는 이미 더 최신 메시지가 있으면 room 마지막 메시지 메타데이터를 덮어쓰지 않는다") + void sendMessageDoesNotOverwriteRoomMetadataWhenNewerMessageAlreadyExists() { + Integer senderId = 10; + Integer receiverId = 20; + User sender = createUser(senderId, "보낸이", UserRole.USER); + User receiver = createUser(receiverId, "받는이", UserRole.USER); + ChatRoom directRoom = createRoom(1, ChatType.DIRECT, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember senderMember = createRoomMember(directRoom, sender, false, + LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember receiverMember = createRoomMember(directRoom, receiver, false, + LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatMessage savedMessage = createMessage(100, directRoom, sender, "older", + LocalDateTime.of(2026, 4, 11, 10, 1)); + + ReflectionTestUtils.setField(directRoom, "lastMessageContent", "newer"); + ReflectionTestUtils.setField(directRoom, "lastMessageSentAt", LocalDateTime.of(2026, 4, 11, 10, 2)); + + given(chatRoomRepository.findById(directRoom.getId())).willReturn(Optional.of(directRoom)); + given(userRepository.getById(senderId)).willReturn(sender); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(directRoom.getId(), senderId)) + .willReturn(Optional.of(senderMember)); + given(chatRoomMemberRepository.findByChatRoomId(directRoom.getId())) + .willReturn(List.of(senderMember, receiverMember)); + given(chatMessageRepository.save(any(ChatMessage.class))).willReturn(savedMessage); + given(chatRoomRepository.updateLastMessageIfLatest( + directRoom.getId(), savedMessage.getId(), savedMessage.getContent(), savedMessage.getCreatedAt() + )).willReturn(0); + given(chatRoomMemberRepository.updateLastReadAtIfOlder(eq(directRoom.getId()), eq(senderId), + any(LocalDateTime.class))) + .willReturn(1); + + chatService.sendMessage(senderId, directRoom.getId(), new ChatMessageSendRequest("older")); + + assertThat(directRoom.getLastMessageContent()).isEqualTo("newer"); + assertThat(directRoom.getLastMessageSentAt()).isEqualTo(LocalDateTime.of(2026, 4, 11, 10, 2)); + } + @Test @DisplayName("sendMessage는 group room에서 메시지를 저장하고 그룹 알림을 보낸다") void sendMessageInGroupRoomSavesMessageAndSendsGroupNotification() { @@ -860,6 +901,9 @@ void sendMessageInGroupRoomSavesMessageAndSendsGroupNotification() { given(chatRoomMemberRepository.findByChatRoomIdAndUserId(groupRoom.getId(), senderId)) .willReturn(Optional.of(senderMember)); given(chatMessageRepository.save(any(ChatMessage.class))).willReturn(savedMessage); + given(chatRoomRepository.updateLastMessageIfLatest( + groupRoom.getId(), savedMessage.getId(), savedMessage.getContent(), savedMessage.getCreatedAt() + )).willReturn(1); given(chatRoomMemberRepository.updateLastReadAtIfOlder(eq(groupRoom.getId()), eq(senderId), any(LocalDateTime.class))) .willReturn(1); @@ -926,6 +970,9 @@ void sendMessageInClubRoomSavesMessageAndSendsGroupNotification() { given(chatRoomMemberRepository.findByChatRoomIdAndUserId(clubRoom.getId(), senderId)) .willReturn(Optional.of(senderRoomMember)); given(chatMessageRepository.save(any(ChatMessage.class))).willReturn(savedMessage); + given(chatRoomRepository.updateLastMessageIfLatest( + clubRoom.getId(), savedMessage.getId(), savedMessage.getContent(), savedMessage.getCreatedAt() + )).willReturn(1); given(chatRoomMemberRepository.updateLastReadAtIfOlder(eq(clubRoom.getId()), eq(senderId), any(LocalDateTime.class))) .willReturn(1); @@ -972,6 +1019,9 @@ void sendMessageAdminBypassesMembershipInSystemAdminRoom() { given(chatRoomMemberRepository.findByChatRoomId(systemAdminRoom.getId())) .willReturn(List.of(systemAdminMember, targetMember)); given(chatMessageRepository.save(any(ChatMessage.class))).willReturn(savedMessage); + given(chatRoomRepository.updateLastMessageIfLatest( + systemAdminRoom.getId(), savedMessage.getId(), savedMessage.getContent(), savedMessage.getCreatedAt() + )).willReturn(1); // when ChatMessageDetailResponse response = chatService.sendMessage(adminId, systemAdminRoom.getId(),