From ab6e3dbe5b9589834849b6a73a66484cdb976fa0 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Mon, 4 Nov 2024 15:00:58 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=E2=9C=8F=EF=B8=8F=20Append=20UnreadMes?= =?UTF-8?q?sageCount=20Field=20in=20the=20ChatRoomResponse=20(#190)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: chat-message-status entity * feat: chat_message_status_repository * feat: impl cache_repository * feat: impl chat message status domain business logic * rename: add read_last_read_message_id docs about if absent data, return null * fix: add constructor in the chat_message_status entity * fix: add domain service validation logic * test: chat_message_status_service unit test * rename: add custom & impl into name because of avoidance jpa auto scan * fix: set chat_message_status unique constraints * test: chat_message_status_service integration test * fix: add unread_message_count field into chat_room_res * fix: add flow in the chat_room_search_service * fix: join service result type tuple to triple * fix: change chat_room_detail mapper mechanism * fix: change flow in the use_case * test: add test_jpa_config rabbitmq null bean * test: all test fix * test: chat_room_search_test * rename: convert from chat_member_res.detail to member_detail due to swagger docs * rename: convert from chat_res.detail to chat_detail due to swagger docs * fix: logic modified when get chat room detail info --- .../api/apis/chat/api/ChatMemberApi.java | 2 +- .../api/apis/chat/api/ChatRoomApi.java | 2 +- .../api/apis/chat/dto/ChatMemberRes.java | 6 +- .../pennyway/api/apis/chat/dto/ChatRes.java | 6 +- .../api/apis/chat/dto/ChatRoomRes.java | 18 +- .../apis/chat/mapper/ChatMemberMapper.java | 4 +- .../api/apis/chat/mapper/ChatRoomMapper.java | 42 +++-- .../chat/service/ChatMemberJoinService.java | 13 +- .../chat/service/ChatRoomSearchService.java | 24 ++- .../apis/chat/usecase/ChatMemberUseCase.java | 8 +- .../apis/chat/usecase/ChatRoomUseCase.java | 5 +- .../ChatMemberBathGetControllerTest.java | 10 +- .../ChatRoomSaveControllerUnitTest.java | 4 +- .../ChatMemberBatchGetIntegrationTest.java | 6 +- .../service/ChatMemberJoinServiceTest.java | 30 ++- .../service/ChatRoomSearchServiceTest.java | 127 +++++++++++++ .../co/pennyway/api/config/TestJpaConfig.java | 7 + .../chatstatus/domain/ChatMessageStatus.java | 49 +++++ ...hatMessageStatusCacheCustomRepository.java | 20 ++ ...essageStatusCacheCustomRepositoryImpl.java | 51 +++++ .../ChatMessageStatusRepository.java | 27 +++ .../service/ChatMessageStatusService.java | 72 +++++++ .../pennyway/domain/config/TestJpaConfig.java | 7 + ...atMessageStatusServiceIntegrationTest.java | 125 +++++++++++++ .../service/ChatMessageStatusServiceTest.java | 175 ++++++++++++++++++ 25 files changed, 780 insertions(+), 60 deletions(-) create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/service/ChatRoomSearchServiceTest.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/chatstatus/domain/ChatMessageStatus.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/chatstatus/repository/ChatMessageStatusCacheCustomRepository.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/chatstatus/repository/ChatMessageStatusCacheCustomRepositoryImpl.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/chatstatus/repository/ChatMessageStatusRepository.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/chatstatus/service/ChatMessageStatusService.java create mode 100644 pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/chatstatus/service/ChatMessageStatusServiceIntegrationTest.java create mode 100644 pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/chatstatus/service/ChatMessageStatusServiceTest.java diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/api/ChatMemberApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/api/ChatMemberApi.java index 45c5552e..7f8ab1b7 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/api/ChatMemberApi.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/api/ChatMemberApi.java @@ -60,6 +60,6 @@ ResponseEntity joinChatRoom( @ApiResponseExplanations(errors = { @ApiExceptionExplanation(value = ApiErrorCode.class, constant = "OVERFLOW_QUERY_PARAMETER", summary = "쿼리 파라미터 오버플로우", description = "쿼리 파라미터가 최대 개수를 초과하여 채팅방 멤버 조회에 실패했습니다.") }) - @ApiResponse(responseCode = "200", description = "채팅방 멤버 조회 성공", content = @Content(schemaProperties = @SchemaProperty(name = "chatMembers", array = @ArraySchema(schema = @Schema(implementation = ChatMemberRes.Detail.class))))) + @ApiResponse(responseCode = "200", description = "채팅방 멤버 조회 성공", content = @Content(schemaProperties = @SchemaProperty(name = "chatMembers", array = @ArraySchema(schema = @Schema(implementation = ChatMemberRes.MemberDetail.class))))) ResponseEntity readChatMembers(@PathVariable("chatRoomId") Long chatRoomId, @Validated @NotEmpty @RequestParam("ids") Set ids); } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/api/ChatRoomApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/api/ChatRoomApi.java index 93d10fe0..7e2bc922 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/api/ChatRoomApi.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/api/ChatRoomApi.java @@ -41,7 +41,7 @@ public interface ChatRoomApi { @ApiResponse(responseCode = "200", description = "채팅방 검색 성공", content = @Content(schemaProperties = @SchemaProperty(name = "chatRooms", schema = @Schema(implementation = SliceResponseTemplate.class)))) ResponseEntity searchChatRooms(@Validated ChatRoomReq.SearchQuery query, @AuthenticationPrincipal SecurityUserDetails user); - @Operation(summary = "채팅방 조회", method = "GET", description = "사용자가 가입한 채팅방 중 특정 채팅방의 상세 정보를 조회한다. 채팅방의 상세 정보에는 채팅방의 참여자 목록과 최근 채팅 메시지 목록 등이 포함된다.") + @Operation(summary = "채팅방 상세 조회", method = "GET", description = "사용자가 가입한 채팅방 중 특정 채팅방의 상세 정보를 조회한다. 채팅방의 상세 정보에는 채팅방의 참여자 목록과 최근 채팅 메시지 목록 등이 포함된다.") @Parameter(name = "chatRoomId", description = "조회할 채팅방의 식별자", example = "1", required = true) @ApiResponse(responseCode = "200", description = "채팅방 조회 성공", content = @Content(schemaProperties = @SchemaProperty(name = "chatRoom", schema = @Schema(implementation = ChatRoomRes.RoomWithParticipants.class)))) ResponseEntity getChatRoom(@PathVariable("chatRoomId") Long chatRoomId, @AuthenticationPrincipal SecurityUserDetails user); diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/dto/ChatMemberRes.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/dto/ChatMemberRes.java index 9b642b2e..59399bd2 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/dto/ChatMemberRes.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/dto/ChatMemberRes.java @@ -12,7 +12,7 @@ public final class ChatMemberRes { @Schema(description = "채팅방 참여자 상세 정보") - public record Detail( + public record MemberDetail( @Schema(description = "채팅방 참여자 ID", type = "long") Long id, @Schema(description = "채팅방 참여자 이름") @@ -27,8 +27,8 @@ public record Detail( @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime createdAt ) { - public static Detail from(ChatMember chatMember, boolean isContainNotifyEnabled) { - return new Detail( + public static MemberDetail from(ChatMember chatMember, boolean isContainNotifyEnabled) { + return new MemberDetail( chatMember.getId(), chatMember.getName(), chatMember.getRole(), diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/dto/ChatRes.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/dto/ChatRes.java index 28ed899d..aa03eb15 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/dto/ChatRes.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/dto/ChatRes.java @@ -12,7 +12,7 @@ public final class ChatRes { @Schema(description = "채팅 메시지 상세 정보") - public record Detail( + public record ChatDetail( @Schema(description = "채팅방 ID", type = "long") Long chatRoomId, @Schema(description = "채팅 ID", type = "long") @@ -30,8 +30,8 @@ public record Detail( @Schema(description = "채팅 보낸 사람 ID", type = "long") Long senderId ) { - public static Detail from(ChatMessage message) { - return new Detail( + public static ChatDetail from(ChatMessage message) { + return new ChatDetail( message.getChatRoomId(), message.getChatId(), message.getContent(), diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/dto/ChatRoomRes.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/dto/ChatRoomRes.java index 6c05627e..a723ac0f 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/dto/ChatRoomRes.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/dto/ChatRoomRes.java @@ -32,9 +32,11 @@ public record Detail( @Schema(description = "채팅방 개설일") @JsonSerialize(using = LocalDateTimeSerializer.class) @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") - LocalDateTime createdAt + LocalDateTime createdAt, + @Schema(description = "읽지 않은 메시지 수. 100 이상의 값을 가지면, 100으로 표시된다.") + long unreadMessageCount ) { - public Detail(Long id, String title, String description, String backgroundImageUrl, boolean isPrivate, boolean isAdmin, int participantCount, LocalDateTime createdAt) { + public Detail(Long id, String title, String description, String backgroundImageUrl, boolean isPrivate, boolean isAdmin, int participantCount, LocalDateTime createdAt, long unreadMessageCount) { this.id = id; this.title = title; this.description = Objects.toString(description, ""); @@ -43,9 +45,10 @@ public Detail(Long id, String title, String description, String backgroundImageU this.isAdmin = isAdmin; this.participantCount = participantCount; this.createdAt = createdAt; + this.unreadMessageCount = (unreadMessageCount > 100) ? 100 : unreadMessageCount; } - public static Detail from(ChatRoom chatRoom, boolean isAdmin, int participantCount) { + public static Detail of(ChatRoom chatRoom, boolean isAdmin, int participantCount, long unreadMessageCount) { return new Detail( chatRoom.getId(), chatRoom.getTitle(), @@ -54,7 +57,8 @@ public static Detail from(ChatRoom chatRoom, boolean isAdmin, int participantCou chatRoom.getPassword() != null, isAdmin, participantCount, - chatRoom.getCreatedAt() + chatRoom.getCreatedAt(), + unreadMessageCount ); } } @@ -70,13 +74,13 @@ public record Summary( @Builder public record RoomWithParticipants( @Schema(description = "채팅방에서 내 정보") - ChatMemberRes.Detail myInfo, + ChatMemberRes.MemberDetail myInfo, @Schema(description = "최근에 채팅 메시지를 보낸 참여자의 상세 정보 목록") - List recentParticipants, + List recentParticipants, @Schema(description = "채팅방에서 내 정보와 최근 활동자를 제외한 참여자 ID 목록") List otherParticipantIds, @Schema(description = "최근 채팅 이력. 메시지는 최신순으로 정렬되어 반환.") - List recentMessages + List recentMessages ) { } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/mapper/ChatMemberMapper.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/mapper/ChatMemberMapper.java index 517a6aea..d208f7c0 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/mapper/ChatMemberMapper.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/mapper/ChatMemberMapper.java @@ -8,9 +8,9 @@ @Mapper public final class ChatMemberMapper { - public static List toChatMemberResDetail(List chatMembers) { + public static List toChatMemberResDetail(List chatMembers) { return chatMembers.stream() - .map(chatMember -> ChatMemberRes.Detail.from(chatMember, false)) + .map(chatMember -> ChatMemberRes.MemberDetail.from(chatMember, false)) .toList(); } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/mapper/ChatRoomMapper.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/mapper/ChatRoomMapper.java index 6937988c..28107ad1 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/mapper/ChatRoomMapper.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/mapper/ChatRoomMapper.java @@ -14,21 +14,36 @@ import java.util.ArrayList; import java.util.List; +import java.util.Map; @Mapper public final class ChatRoomMapper { - - public static SliceResponseTemplate toChatRoomResDetails(Slice details, Pageable pageable) { - List contents = toChatRoomResDetails(details.getContent()); + List contents = new ArrayList<>(); + for (ChatRoomDetail detail : details.getContent()) { + contents.add( + new ChatRoomRes.Detail( + detail.id(), + detail.title(), + detail.description(), + detail.backgroundImageUrl(), + detail.password() != null, + detail.isAdmin(), + detail.participantCount(), + detail.createdAt(), + 0 + ) + ); + } return SliceResponseTemplate.of(contents, pageable, contents.size(), details.hasNext()); } - public static List toChatRoomResDetails(List details) { + public static List toChatRoomResDetails(Map details) { List responses = new ArrayList<>(); - for (ChatRoomDetail detail : details) { + for (Map.Entry entry : details.entrySet()) { + ChatRoomDetail detail = entry.getKey(); responses.add( new ChatRoomRes.Detail( detail.id(), @@ -38,7 +53,8 @@ public static List toChatRoomResDetails(List detail.password() != null, detail.isAdmin(), detail.participantCount(), - detail.createdAt() + detail.createdAt(), + entry.getValue() ) ); } @@ -46,21 +62,21 @@ public static List toChatRoomResDetails(List return responses; } - public static ChatRoomRes.Detail toChatRoomResDetail(ChatRoom chatRoom, boolean isAdmin, int participantCount) { - return ChatRoomRes.Detail.from(chatRoom, isAdmin, participantCount); + public static ChatRoomRes.Detail toChatRoomResDetail(ChatRoom chatRoom, boolean isAdmin, int participantCount, long unreadMessageCount) { + return ChatRoomRes.Detail.of(chatRoom, isAdmin, participantCount, unreadMessageCount); } public static ChatRoomRes.RoomWithParticipants toChatRoomResRoomWithParticipants(ChatMember myInfo, List recentParticipants, List otherMemberIds, List chatMessages) { - List recentParticipantsRes = recentParticipants.stream() - .map(participant -> ChatMemberRes.Detail.from(participant, false)) + List recentParticipantsRes = recentParticipants.stream() + .map(participant -> ChatMemberRes.MemberDetail.from(participant, false)) .toList(); - List chatMessagesRes = chatMessages.stream() - .map(ChatRes.Detail::from) + List chatMessagesRes = chatMessages.stream() + .map(ChatRes.ChatDetail::from) .toList(); return ChatRoomRes.RoomWithParticipants.builder() - .myInfo(ChatMemberRes.Detail.from(myInfo, true)) + .myInfo(ChatMemberRes.MemberDetail.from(myInfo, true)) .recentParticipants(recentParticipantsRes) .otherParticipantIds(otherMemberIds) .recentMessages(chatMessagesRes) diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/service/ChatMemberJoinService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/service/ChatMemberJoinService.java index f66805fd..5efbe382 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/service/ChatMemberJoinService.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/service/ChatMemberJoinService.java @@ -1,5 +1,6 @@ package kr.co.pennyway.api.apis.chat.service; +import kr.co.pennyway.domain.common.redis.message.service.ChatMessageService; import kr.co.pennyway.domain.common.redisson.DistributedLock; import kr.co.pennyway.domain.domains.chatroom.domain.ChatRoom; import kr.co.pennyway.domain.domains.chatroom.exception.ChatRoomErrorCode; @@ -14,7 +15,8 @@ import kr.co.pennyway.infra.common.event.ChatRoomJoinEvent; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.apache.commons.lang3.tuple.Pair; +import org.apache.commons.lang3.tuple.ImmutableTriple; +import org.apache.commons.lang3.tuple.Triple; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; @@ -28,6 +30,8 @@ public class ChatMemberJoinService { private final ChatRoomService chatRoomService; private final ChatMemberService chatMemberService; + private final ChatMessageService chatMessageService; + private final ApplicationEventPublisher eventPublisher; /** @@ -37,10 +41,10 @@ public class ChatMemberJoinService { * @param userId Long : 가입하려는 사용자의 ID * @param chatRoomId Long : 가입하려는 채팅방의 ID * @param password Integer : 비공개 채팅방의 경우 비밀번호 정보를 입력받으며, 채팅방에 비밀번호가 없을 경우 null - * @return Pair - 채팅방 정보와 현재 가입한 회원 수 + * @return Triple : 가입한 채팅방 정보, 현재 채팅방의 회원 수, 읽지 않은 메시지 수 */ @DistributedLock(key = "'chat-room-join-' + #chatRoomId") - public Pair execute(Long userId, Long chatRoomId, Integer password) { + public Triple execute(Long userId, Long chatRoomId, Integer password) { ChatRoom chatRoom = chatRoomService.readChatRoom(chatRoomId).orElseThrow(() -> new ChatRoomErrorException(ChatRoomErrorCode.NOT_FOUND_CHAT_ROOM)); Long currentMemberCount = chatMemberService.countActiveMembers(chatRoomId); @@ -56,10 +60,11 @@ public Pair execute(Long userId, Long chatRoomId, Integer pas User user = userService.readUser(userId).orElseThrow(() -> new UserErrorException(UserErrorCode.NOT_FOUND)); ChatMember member = chatMemberService.createMember(user, chatRoom); + Long unreadMessageCount = chatMessageService.countUnreadMessages(chatRoomId, 0L); eventPublisher.publishEvent(ChatRoomJoinEvent.of(chatRoomId, member.getName())); - return Pair.of(chatRoom, currentMemberCount.intValue() + 1); + return ImmutableTriple.of(chatRoom, currentMemberCount.intValue() + 1, unreadMessageCount); } private boolean isFullRoom(Long currentMemberCount) { diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/service/ChatRoomSearchService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/service/ChatRoomSearchService.java index caf101b1..0cf26a28 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/service/ChatRoomSearchService.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/service/ChatRoomSearchService.java @@ -1,7 +1,9 @@ package kr.co.pennyway.api.apis.chat.service; +import kr.co.pennyway.domain.common.redis.message.service.ChatMessageService; import kr.co.pennyway.domain.domains.chatroom.dto.ChatRoomDetail; import kr.co.pennyway.domain.domains.chatroom.service.ChatRoomService; +import kr.co.pennyway.domain.domains.chatstatus.service.ChatMessageStatusService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Pageable; @@ -9,17 +11,35 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.HashMap; import java.util.List; +import java.util.Map; @Slf4j @Service @RequiredArgsConstructor public class ChatRoomSearchService { private final ChatRoomService chatRoomService; + private final ChatMessageStatusService chatMessageStatusService; + private final ChatMessageService chatMessageService; + /** + * 사용자 ID가 속한 채팅방 목록을 조회한다. + * + * @return 채팅방 목록 (채팅방 정보, 읽지 않은 메시지 수) + */ @Transactional(readOnly = true) - public List readChatRooms(Long userId) { - return chatRoomService.readChatRoomsByUserId(userId); + public Map readChatRooms(Long userId) { + List chatRooms = chatRoomService.readChatRoomsByUserId(userId); + Map result = new HashMap<>(); + + for (ChatRoomDetail chatRoom : chatRooms) { + Long lastReadMessageId = chatMessageStatusService.readLastReadMessageId(userId, chatRoom.id()); + Long unreadCount = chatMessageService.countUnreadMessages(chatRoom.id(), lastReadMessageId); + result.put(chatRoom, unreadCount); + } + + return result; } @Transactional(readOnly = true) diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/usecase/ChatMemberUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/usecase/ChatMemberUseCase.java index c1b23d6e..a3c8b4a1 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/usecase/ChatMemberUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/usecase/ChatMemberUseCase.java @@ -11,7 +11,7 @@ import kr.co.pennyway.domain.domains.member.domain.ChatMember; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.apache.commons.lang3.tuple.Pair; +import org.apache.commons.lang3.tuple.Triple; import java.util.List; import java.util.Set; @@ -24,12 +24,12 @@ public class ChatMemberUseCase { private final ChatMemberSearchService chatMemberSearchService; public ChatRoomRes.Detail joinChatRoom(Long userId, Long chatRoomId, Integer password) { - Pair chatRoom = chatMemberJoinService.execute(userId, chatRoomId, password); + Triple chatRoom = chatMemberJoinService.execute(userId, chatRoomId, password); - return ChatRoomMapper.toChatRoomResDetail(chatRoom.getLeft(), false, chatRoom.getRight()); + return ChatRoomMapper.toChatRoomResDetail(chatRoom.getLeft(), false, chatRoom.getMiddle(), chatRoom.getRight()); } - public List readChatMembers(Long chatRoomId, Set chatMemberIds) { + public List readChatMembers(Long chatRoomId, Set chatMemberIds) { List chatMembers = chatMemberSearchService.readChatMembers(chatRoomId, chatMemberIds); return ChatMemberMapper.toChatMemberResDetail(chatMembers); diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/usecase/ChatRoomUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/usecase/ChatRoomUseCase.java index ee9e52a5..682a4f92 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/usecase/ChatRoomUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/usecase/ChatRoomUseCase.java @@ -16,6 +16,7 @@ import org.springframework.data.domain.Slice; import java.util.List; +import java.util.Map; import java.util.Set; @UseCase @@ -30,11 +31,11 @@ public class ChatRoomUseCase { public ChatRoomRes.Detail createChatRoom(ChatRoomReq.Create request, Long userId) { ChatRoom chatRoom = chatRoomSaveService.createChatRoom(request, userId); - return ChatRoomMapper.toChatRoomResDetail(chatRoom, true, 1); + return ChatRoomMapper.toChatRoomResDetail(chatRoom, true, 1, 0); } public List getChatRooms(Long userId) { - List chatRooms = chatRoomSearchService.readChatRooms(userId); + Map chatRooms = chatRoomSearchService.readChatRooms(userId); return ChatRoomMapper.toChatRoomResDetails(chatRooms); } diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/controller/ChatMemberBathGetControllerTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/controller/ChatMemberBathGetControllerTest.java index 2172cd62..ecd039e4 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/controller/ChatMemberBathGetControllerTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/controller/ChatMemberBathGetControllerTest.java @@ -53,7 +53,7 @@ void successReadChatMembers() throws Exception { // given Long chatRoomId = 1L; Set memberIds = Set.of(1L, 2L, 3L); - List expectedResponse = createMockMemberDetails(); + List expectedResponse = createMockMemberDetails(); given(chatMemberUseCase.readChatMembers(chatRoomId, memberIds)).willReturn(expectedResponse); @@ -116,11 +116,11 @@ void failReadChatMembersWhenIdsIsEmpty() throws Exception { .andDo(print()); } - private List createMockMemberDetails() { + private List createMockMemberDetails() { return List.of( - new ChatMemberRes.Detail(1L, "User1", ChatMemberRole.MEMBER, null, LocalDateTime.now()), - new ChatMemberRes.Detail(2L, "User2", ChatMemberRole.MEMBER, null, LocalDateTime.now()), - new ChatMemberRes.Detail(3L, "User3", ChatMemberRole.MEMBER, null, LocalDateTime.now()) + new ChatMemberRes.MemberDetail(1L, "User1", ChatMemberRole.MEMBER, null, LocalDateTime.now()), + new ChatMemberRes.MemberDetail(2L, "User2", ChatMemberRole.MEMBER, null, LocalDateTime.now()), + new ChatMemberRes.MemberDetail(3L, "User3", ChatMemberRole.MEMBER, null, LocalDateTime.now()) ); } } diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/controller/ChatRoomSaveControllerUnitTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/controller/ChatRoomSaveControllerUnitTest.java index f913c21e..6d4aed94 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/controller/ChatRoomSaveControllerUnitTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/controller/ChatRoomSaveControllerUnitTest.java @@ -52,7 +52,7 @@ void createChatRoomSuccess() throws Exception { // given ChatRoom fixture = ChatRoomFixture.PRIVATE_CHAT_ROOM.toEntity(1L); ChatRoomReq.Create request = ChatRoomFixture.PRIVATE_CHAT_ROOM.toCreateRequest(); - given(chatRoomUseCase.createChatRoom(request, 1L)).willReturn(ChatRoomRes.Detail.from(fixture, true, 1)); + given(chatRoomUseCase.createChatRoom(request, 1L)).willReturn(ChatRoomRes.Detail.of(fixture, true, 1, 10)); // when ResultActions result = performPostChatRoom(request); @@ -70,7 +70,7 @@ void createChatRoomSuccessWithNullBackgroundImageUrl() throws Exception { ChatRoom fixture = ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntity(1L); ChatRoomReq.Create request = ChatRoomFixture.PUBLIC_CHAT_ROOM.toCreateRequest(); - given(chatRoomUseCase.createChatRoom(request, 1L)).willReturn(ChatRoomRes.Detail.from(fixture, true, 1)); + given(chatRoomUseCase.createChatRoom(request, 1L)).willReturn(ChatRoomRes.Detail.of(fixture, true, 1, 10)); // when ResultActions result = performPostChatRoom(request); diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/integration/ChatMemberBatchGetIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/integration/ChatMemberBatchGetIntegrationTest.java index 3ce1ad4d..e1f70870 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/integration/ChatMemberBatchGetIntegrationTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/integration/ChatMemberBatchGetIntegrationTest.java @@ -85,13 +85,13 @@ void successReadChatMembers() { HttpMethod.GET, owner, null, - new TypeReference>>>() { + new TypeReference>>>() { }, chatRoom.getId(), String.join(",", memberIds.stream().map(String::valueOf).toList()) ); - SuccessResponse>> body = (SuccessResponse>>) response.getBody(); - List payload = body.getData().get("chatMembers"); + SuccessResponse>> body = (SuccessResponse>>) response.getBody(); + List payload = body.getData().get("chatMembers"); // then assertEquals(HttpStatus.OK, response.getStatusCode()); diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/service/ChatMemberJoinServiceTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/service/ChatMemberJoinServiceTest.java index 42c0f95b..7b16af7f 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/service/ChatMemberJoinServiceTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/service/ChatMemberJoinServiceTest.java @@ -3,6 +3,7 @@ import kr.co.pennyway.api.config.fixture.ChatMemberFixture; import kr.co.pennyway.api.config.fixture.ChatRoomFixture; import kr.co.pennyway.api.config.fixture.UserFixture; +import kr.co.pennyway.domain.common.redis.message.service.ChatMessageService; import kr.co.pennyway.domain.domains.chatroom.domain.ChatRoom; import kr.co.pennyway.domain.domains.chatroom.exception.ChatRoomErrorCode; import kr.co.pennyway.domain.domains.chatroom.exception.ChatRoomErrorException; @@ -14,7 +15,7 @@ import kr.co.pennyway.domain.domains.user.service.UserService; import kr.co.pennyway.infra.common.event.ChatRoomJoinEvent; import lombok.extern.slf4j.Slf4j; -import org.apache.commons.lang3.tuple.Pair; +import org.apache.commons.lang3.tuple.Triple; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -44,13 +45,15 @@ public class ChatMemberJoinServiceTest { @Mock private ChatMemberService chatMemberService; @Mock + private ChatMessageService chatMessageService; + @Mock private ApplicationEventPublisher eventPublisher; private Long userId = 1L; private Long chatRoomId = 1L; @BeforeEach void setUp() { - chatMemberJoinService = new ChatMemberJoinService(userService, chatRoomService, chatMemberService, eventPublisher); + chatMemberJoinService = new ChatMemberJoinService(userService, chatRoomService, chatMemberService, chatMessageService, eventPublisher); } @Test @@ -88,18 +91,25 @@ void successWhenChatRoomIsNotFullAndPublic() { // given ChatRoom expectedChatRoom = createPublicRoom(); User expectedUser = createUser(); + Long expectedUnreadCount = 10L; // 추가 given(chatRoomService.readChatRoom(chatRoomId)).willReturn(Optional.of(expectedChatRoom)); given(chatMemberService.countActiveMembers(chatRoomId)).willReturn(AVAILABLE_CAPACITY); given(userService.readUser(userId)).willReturn(Optional.of(expectedUser)); given(chatMemberService.createMember(expectedUser, expectedChatRoom)).willReturn(ChatMemberFixture.MEMBER.toEntity(expectedUser, expectedChatRoom)); + given(chatMessageService.countUnreadMessages(chatRoomId, 0L)).willReturn(expectedUnreadCount); // when - chatMemberJoinService.execute(userId, chatRoomId, null); + Triple result = chatMemberJoinService.execute(userId, chatRoomId, null); // then - verify(eventPublisher, times(1)).publishEvent(any(ChatRoomJoinEvent.class)); + assertAll( + () -> assertEquals(expectedChatRoom, result.getLeft()), + () -> assertEquals(AVAILABLE_CAPACITY + 1, result.getMiddle().longValue()), + () -> assertEquals(expectedUnreadCount, result.getRight()), + () -> verify(eventPublisher, times(1)).publishEvent(any(ChatRoomJoinEvent.class)) + ); } @Test @@ -110,20 +120,23 @@ void successWhenChatRoomIsNotFullAndPasswordIsMatch() { User expectedUser = createUser(); ChatMember expectedMember = ChatMemberFixture.MEMBER.toEntity(expectedUser, expectedChatRoom); Integer validPassword = expectedChatRoom.getPassword(); + Long expectedUnreadCount = 5L; given(chatRoomService.readChatRoom(chatRoomId)).willReturn(Optional.of(expectedChatRoom)); given(chatMemberService.countActiveMembers(chatRoomId)).willReturn(AVAILABLE_CAPACITY); given(userService.readUser(userId)).willReturn(Optional.of(expectedUser)); given(chatMemberService.createMember(expectedUser, expectedChatRoom)).willReturn(expectedMember); + given(chatMessageService.countUnreadMessages(chatRoomId, 0L)).willReturn(expectedUnreadCount); // when - Pair result = chatMemberJoinService.execute(userId, chatRoomId, validPassword); + Triple result = chatMemberJoinService.execute(userId, chatRoomId, validPassword); // then assertAll( () -> assertEquals(expectedChatRoom, result.getLeft()), - () -> assertEquals(FULL_ROOM_CAPACITY, result.getRight().longValue()), + () -> assertEquals(AVAILABLE_CAPACITY + 1, result.getMiddle().longValue()), + () -> assertEquals(expectedUnreadCount, result.getRight()), () -> verify(eventPublisher, times(1)).publishEvent(any(ChatRoomJoinEvent.class)) ); } @@ -164,7 +177,7 @@ void explicitEventPublishedWhenJoinSuccess() { @DisplayName("채팅방 가입 시 정해진 순서대로 검증이 수행된다") void verifyValidationOrder() { // given - InOrder inOrder = inOrder(chatRoomService, chatMemberService, userService); + InOrder inOrder = inOrder(chatRoomService, chatMemberService, userService, chatMessageService); ChatRoom expectedChatRoom = createPrivateRoom(); User expectedUser = createUser(); @@ -173,9 +186,9 @@ void verifyValidationOrder() { given(chatRoomService.readChatRoom(chatRoomId)).willReturn(Optional.of(expectedChatRoom)); given(chatMemberService.countActiveMembers(chatRoomId)).willReturn(AVAILABLE_CAPACITY); - given(userService.readUser(userId)).willReturn(Optional.of(expectedUser)); given(chatMemberService.createMember(expectedUser, expectedChatRoom)).willReturn(expectedMember); + given(chatMessageService.countUnreadMessages(chatRoomId, 0L)).willReturn(0L); // when chatMemberJoinService.execute(userId, chatRoomId, validPassword); @@ -184,6 +197,7 @@ void verifyValidationOrder() { inOrder.verify(chatRoomService).readChatRoom(chatRoomId); inOrder.verify(chatMemberService).countActiveMembers(chatRoomId); inOrder.verify(userService).readUser(userId); + inOrder.verify(chatMessageService).countUnreadMessages(chatRoomId, 0L); } @Test diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/service/ChatRoomSearchServiceTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/service/ChatRoomSearchServiceTest.java new file mode 100644 index 00000000..8a2c1ee7 --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/service/ChatRoomSearchServiceTest.java @@ -0,0 +1,127 @@ +package kr.co.pennyway.api.apis.chat.service; + +import kr.co.pennyway.domain.common.redis.message.service.ChatMessageService; +import kr.co.pennyway.domain.domains.chatroom.dto.ChatRoomDetail; +import kr.co.pennyway.domain.domains.chatroom.service.ChatRoomService; +import kr.co.pennyway.domain.domains.chatstatus.service.ChatMessageStatusService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InOrder; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.inOrder; + +@ExtendWith(MockitoExtension.class) +class ChatRoomSearchServiceTest { + @InjectMocks + private ChatRoomSearchService chatRoomSearchService; + + @Mock + private ChatRoomService chatRoomService; + @Mock + private ChatMessageStatusService chatMessageStatusService; + @Mock + private ChatMessageService chatMessageService; + + @Test + @DisplayName("사용자의 채팅방 목록과 각 방의 읽지 않은 메시지 수를 정상적으로 조회한다") + void successReadChatRooms() { + // given + Long userId = 1L; + List chatRooms = List.of( + new ChatRoomDetail(1L, "Room1", "", "", 123456, LocalDateTime.now(), true, 2), + new ChatRoomDetail(2L, "Room2", "", "", null, LocalDateTime.now(), false, 2) + ); + + given(chatRoomService.readChatRoomsByUserId(userId)).willReturn(chatRooms); + + // room1: 마지막으로 읽은 메시지 ID 10, 읽지 않은 메시지 5개 + given(chatMessageStatusService.readLastReadMessageId(userId, 1L)).willReturn(10L); + given(chatMessageService.countUnreadMessages(1L, 10L)).willReturn(5L); + + // room2: 마지막으로 읽은 메시지 ID 20, 읽지 않은 메시지 3개 + given(chatMessageStatusService.readLastReadMessageId(userId, 2L)).willReturn(20L); + given(chatMessageService.countUnreadMessages(2L, 20L)).willReturn(3L); + + // when + Map result = chatRoomSearchService.readChatRooms(userId); + + // then + assertAll( + () -> assertEquals(2, result.size()), + () -> assertEquals(5L, result.get(chatRooms.get(0))), + () -> assertEquals(3L, result.get(chatRooms.get(1))) + ); + } + + @Test + @DisplayName("채팅방이 없는 경우 빈 Map을 반환한다") + void returnEmptyMapWhenNoRooms() { + // given + Long userId = 1L; + given(chatRoomService.readChatRoomsByUserId(userId)).willReturn(Collections.emptyList()); + + // when + Map result = chatRoomSearchService.readChatRooms(userId); + + // then + assertTrue(result.isEmpty()); + } + + @Test + @DisplayName("읽지 않은 메시지 수 조회 중, 모든 조회가 실패한다.") + void continueProcessingOnError() { + // given + Long userId = 1L; + List chatRooms = List.of( + new ChatRoomDetail(1L, "Room1", "", "", 123456, LocalDateTime.now(), true, 2), + new ChatRoomDetail(2L, "Room2", "", "", null, LocalDateTime.now(), false, 2) + ); + + given(chatRoomService.readChatRoomsByUserId(userId)).willReturn(chatRooms); + + // room1: 정상 처리 + given(chatMessageStatusService.readLastReadMessageId(userId, 1L)).willReturn(10L); + + // room2: 오류 발생 + given(chatMessageStatusService.readLastReadMessageId(userId, 2L)) + .willThrow(new RuntimeException("Failed to get last read message id")); + + // when - then + assertThrows(RuntimeException.class, () -> chatRoomSearchService.readChatRooms(userId)); + } + + @Test + @DisplayName("각 서비스 호출이 정해진 순서대로 실행된다") + void verifyServiceCallOrder() { + // given + Long userId = 1L; + List chatRooms = List.of( + new ChatRoomDetail(1L, "Room1", "", "", 123456, LocalDateTime.now(), true, 2) + ); + + InOrder inOrder = inOrder(chatRoomService, chatMessageStatusService, chatMessageService); + + given(chatRoomService.readChatRoomsByUserId(userId)).willReturn(chatRooms); + given(chatMessageStatusService.readLastReadMessageId(userId, 1L)).willReturn(10L); + given(chatMessageService.countUnreadMessages(userId, 10L)).willReturn(5L); + + // when + chatRoomSearchService.readChatRooms(userId); + + // then + inOrder.verify(chatRoomService).readChatRoomsByUserId(userId); + inOrder.verify(chatMessageStatusService).readLastReadMessageId(userId, 1L); + inOrder.verify(chatMessageService).countUnreadMessages(userId, 10L); + } +} diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/TestJpaConfig.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/TestJpaConfig.java index a8bd8405..2e8ceef6 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/TestJpaConfig.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/TestJpaConfig.java @@ -8,6 +8,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; +import org.springframework.data.redis.core.RedisTemplate; @TestConfiguration public class TestJpaConfig { @@ -25,4 +26,10 @@ public JPAQueryFactory testJpaQueryFactory() { public SQLTemplates testSqlTemplates() { return new MySQLTemplates(); } + + @Bean + @ConditionalOnMissingBean + public RedisTemplate testRedisTemplate() { + return null; + } } \ No newline at end of file diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/chatstatus/domain/ChatMessageStatus.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/chatstatus/domain/ChatMessageStatus.java new file mode 100644 index 00000000..5dae825b --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/chatstatus/domain/ChatMessageStatus.java @@ -0,0 +1,49 @@ +package kr.co.pennyway.domain.domains.chatstatus.domain; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.Objects; + +@Entity +@Getter +@Table(name = "chat_message_status", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_chat_message_status", + columnNames = {"user_id", "chat_room_id"} + ) + }) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ChatMessageStatus { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private Long userId; + private Long chatRoomId; + private Long lastReadMessageId; + private LocalDateTime updatedAt; + + public ChatMessageStatus(Long userId, Long chatRoomId, Long lastReadMessageId) { + this.userId = Objects.requireNonNull(userId, "userId must not be null"); + this.chatRoomId = Objects.requireNonNull(chatRoomId, "chatRoomId must not be null"); + this.lastReadMessageId = Objects.requireNonNull(lastReadMessageId, "lastReadMessageId must not be null"); + this.updatedAt = LocalDateTime.now(); + } + + @PrePersist + @PreUpdate + protected void onUpdate() { + this.updatedAt = LocalDateTime.now(); + } + + public void updateLastReadMessageId(Long messageId) { + if (this.lastReadMessageId == null || messageId > this.lastReadMessageId) { + this.lastReadMessageId = messageId; + } + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/chatstatus/repository/ChatMessageStatusCacheCustomRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/chatstatus/repository/ChatMessageStatusCacheCustomRepository.java new file mode 100644 index 00000000..85efb842 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/chatstatus/repository/ChatMessageStatusCacheCustomRepository.java @@ -0,0 +1,20 @@ +package kr.co.pennyway.domain.domains.chatstatus.repository; + +import java.util.Optional; + +public interface ChatMessageStatusCacheCustomRepository { + /** + * 캐시 데이터에서 마지막으로 읽은 메시지 ID를 조회합니다. + */ + Optional findLastReadMessageId(Long userId, Long chatRoomId); + + /** + * 캐시 데이터에 마지막으로 읽은 메시지 ID를 저장합니다. + */ + void saveLastReadMessageId(Long userId, Long chatRoomId, Long messageId); + + /** + * 캐시 데이터를 삭제합니다. + */ + void deleteLastReadMessageId(Long userId, Long chatRoomId); +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/chatstatus/repository/ChatMessageStatusCacheCustomRepositoryImpl.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/chatstatus/repository/ChatMessageStatusCacheCustomRepositoryImpl.java new file mode 100644 index 00000000..5a6031c8 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/chatstatus/repository/ChatMessageStatusCacheCustomRepositoryImpl.java @@ -0,0 +1,51 @@ +package kr.co.pennyway.domain.domains.chatstatus.repository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Repository; + +import java.time.Duration; +import java.util.Optional; + +@Slf4j +@Repository +@RequiredArgsConstructor +public class ChatMessageStatusCacheCustomRepositoryImpl implements ChatMessageStatusCacheCustomRepository { + private static final String CACHE_KEY_PREFIX = "chat:last_read:"; + private static final Duration CACHE_TTL = Duration.ofHours(1); + + private final RedisTemplate redisTemplate; + + @Override + public Optional findLastReadMessageId(Long userId, Long chatRoomId) { + String value = redisTemplate.opsForValue().get(formatCacheKey(userId, chatRoomId)); + return Optional.ofNullable(value).map(Long::parseLong); + } + + @Override + public void saveLastReadMessageId(Long userId, Long chatRoomId, Long messageId) { + try { + String key = formatCacheKey(userId, chatRoomId); + String currentValue = redisTemplate.opsForValue().get(key); + + if (currentValue != null && Long.parseLong(currentValue) >= messageId) { + return; + } + + redisTemplate.opsForValue().set(key, messageId.toString()); + redisTemplate.expire(key, CACHE_TTL); + } catch (Exception e) { + log.error("Failed to cache message status: userId={}, roomId={}, messageId={}", userId, chatRoomId, messageId, e); + } + } + + @Override + public void deleteLastReadMessageId(Long userId, Long chatRoomId) { + redisTemplate.delete(formatCacheKey(userId, chatRoomId)); + } + + private String formatCacheKey(Long userId, Long chatRoomId) { + return CACHE_KEY_PREFIX + chatRoomId + ":" + userId; + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/chatstatus/repository/ChatMessageStatusRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/chatstatus/repository/ChatMessageStatusRepository.java new file mode 100644 index 00000000..118b15b4 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/chatstatus/repository/ChatMessageStatusRepository.java @@ -0,0 +1,27 @@ +package kr.co.pennyway.domain.domains.chatstatus.repository; + +import kr.co.pennyway.domain.domains.chatstatus.domain.ChatMessageStatus; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +public interface ChatMessageStatusRepository extends JpaRepository, ChatMessageStatusCacheCustomRepository { + Optional findByUserIdAndChatRoomId(Long userId, Long chatRoomId); + + @Query("SELECT c FROM ChatMessageStatus c WHERE c.userId = :userId AND c.chatRoomId IN :roomIds") + List findAllByUserIdAndChatRoomIdIn(Long userId, Collection roomIds); + + @Modifying + @Query(value = """ + INSERT INTO chat_message_status (user_id, chat_room_id, last_read_message_id, updated_at) + VALUES (:userId, :roomId, :messageId, NOW()) + ON DUPLICATE KEY UPDATE + last_read_message_id = GREATEST(last_read_message_id, :messageId), + updated_at = NOW() + """, nativeQuery = true) + void saveLastReadMessageIdInBulk(Long userId, Long roomId, Long messageId); +} \ No newline at end of file diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/chatstatus/service/ChatMessageStatusService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/chatstatus/service/ChatMessageStatusService.java new file mode 100644 index 00000000..567cda95 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/chatstatus/service/ChatMessageStatusService.java @@ -0,0 +1,72 @@ +package kr.co.pennyway.domain.domains.chatstatus.service; + +import kr.co.pennyway.common.annotation.DomainService; +import kr.co.pennyway.domain.domains.chatstatus.repository.ChatMessageStatusRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Map; + +@Slf4j +@DomainService +@RequiredArgsConstructor +public class ChatMessageStatusService { + private final ChatMessageStatusRepository chatMessageStatusRepository; + + /** + * 마지막으로 읽은 메시지 ID를 저장합니다. + * + * @throws IllegalArgumentException 사용자 ID, 채팅방 ID, 메시지 ID가 null이거나 0보다 작을 경우 + */ + public void saveLastReadMessageId(Long userId, Long roomId, Long messageId) { + validateInputs(userId, roomId, messageId); + chatMessageStatusRepository.saveLastReadMessageId(userId, roomId, messageId); + } + + /** + * 마지막으로 읽은 메시지 ID를 조회합니다. + * + * @return 마지막으로 읽은 메시지 ID가 없을 경우 0을 반환합니다. + */ + @Transactional(readOnly = true) + public Long readLastReadMessageId(Long userId, Long chatRoomId) { + return chatMessageStatusRepository.findLastReadMessageId(userId, chatRoomId) + .orElseGet(() -> chatMessageStatusRepository + .findByUserIdAndChatRoomId(userId, chatRoomId) + .map(status -> { + Long lastReadId = status.getLastReadMessageId(); + chatMessageStatusRepository.saveLastReadMessageId(userId, chatRoomId, lastReadId); + return lastReadId; + }) + .orElse(0L)); + } + + /** + * 여러 사용자의 읽은 메시지 ID를 일괄 업데이트합니다. + * + * @param updates 사용자별 읽은 메시지 ID 목록 (사용자 ID -> (채팅방 ID -> 메시지 ID)) + */ + // TODO: 이 메서드는 임시로 사용되는 메서드이며, 성능 평가 이후 개선될 여지가 있습니다. + @Transactional + public void bulkUpdateReadStatus(Map> updates) { + updates.forEach((userId, roomUpdates) -> + roomUpdates.forEach((roomId, messageId) -> { + chatMessageStatusRepository.saveLastReadMessageIdInBulk(userId, roomId, messageId); + chatMessageStatusRepository.deleteLastReadMessageId(userId, roomId); + }) + ); + } + + private void validateInputs(Long userId, Long chatRoomId, Long lastReadMessageId) { + if (userId == null || userId <= 0) { + throw new IllegalArgumentException("Invalid userId: " + userId); + } + if (chatRoomId == null || chatRoomId <= 0) { + throw new IllegalArgumentException("Invalid chatRoomId: " + chatRoomId); + } + if (lastReadMessageId == null || lastReadMessageId <= 0) { + throw new IllegalArgumentException("Invalid lastReadMessageId: " + lastReadMessageId); + } + } +} diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/TestJpaConfig.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/TestJpaConfig.java index 28b19d56..a351070c 100644 --- a/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/TestJpaConfig.java +++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/TestJpaConfig.java @@ -8,6 +8,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; +import org.springframework.data.redis.core.RedisTemplate; @TestConfiguration public class TestJpaConfig { @@ -25,4 +26,10 @@ public JPAQueryFactory testJpaQueryFactory() { public SQLTemplates testSqlTemplates() { return new MySQLTemplates(); } + + @Bean + @ConditionalOnMissingBean + public RedisTemplate testRedisTemplate() { + return null; + } } diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/chatstatus/service/ChatMessageStatusServiceIntegrationTest.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/chatstatus/service/ChatMessageStatusServiceIntegrationTest.java new file mode 100644 index 00000000..5979b57b --- /dev/null +++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/chatstatus/service/ChatMessageStatusServiceIntegrationTest.java @@ -0,0 +1,125 @@ +package kr.co.pennyway.domain.domains.chatstatus.service; + +import kr.co.pennyway.domain.config.ContainerDBTestConfig; +import kr.co.pennyway.domain.config.DomainIntegrationTest; +import kr.co.pennyway.domain.config.TestJpaConfig; +import kr.co.pennyway.domain.domains.chatstatus.domain.ChatMessageStatus; +import kr.co.pennyway.domain.domains.chatstatus.repository.ChatMessageStatusRepository; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterEach; +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.jdbc.AutoConfigureTestDatabase; +import org.springframework.context.annotation.Import; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertEquals; + +@Slf4j +@DomainIntegrationTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import({TestJpaConfig.class}) +public class ChatMessageStatusServiceIntegrationTest extends ContainerDBTestConfig { + @Autowired + private ChatMessageStatusRepository chatMessageStatusRepository; + + @Autowired + private ChatMessageStatusService chatMessageStatusService; + + @Test + @DisplayName("메시지 읽음 상태를 정상적으로 저장하고 조회한다") + void saveAndReadMessageStatus() { + // given + Long userId = 1L; + Long chatRoomId = 1L; + Long messageId = 100L; + + // when + chatMessageStatusService.saveLastReadMessageId(userId, chatRoomId, messageId); + Long lastReadId = chatMessageStatusService.readLastReadMessageId(userId, chatRoomId); + + // then + assertEquals(messageId, lastReadId); + } + + @Test + @DisplayName("여러 메시지 읽음 상태를 벌크로 저장한다") + void bulkSaveMessageStatus() { + // given + Map> updates = new HashMap<>(); + + // user1의 업데이트 + Map user1Updates = new HashMap<>(); + user1Updates.put(1L, 100L); // user1은 1번 방에서 100번 메시지까지 읽음 + user1Updates.put(2L, 200L); // user1은 2번 방에서 200번 메시지까지 읽음 + updates.put(1L, user1Updates); + + // user2의 업데이트 + Map user2Updates = new HashMap<>(); + user2Updates.put(1L, 150L); // user2는 1번 방에서 150번 메시지까지 읽음 + updates.put(2L, user2Updates); + + // when + chatMessageStatusService.bulkUpdateReadStatus(updates); + + // then + assertAll( + () -> assertEquals(100L, chatMessageStatusService.readLastReadMessageId(1L, 1L)), + () -> assertEquals(200L, chatMessageStatusService.readLastReadMessageId(1L, 2L)), + () -> assertEquals(150L, chatMessageStatusService.readLastReadMessageId(2L, 1L)) + ); + } + + @Test + @DisplayName("더 작은 메시지 ID로 업데이트를 시도하면 무시된다") + void ignoresSmallerMessageId() { + // given + Long userId = 1L; + Long chatRoomId = 1L; + + // when + chatMessageStatusService.saveLastReadMessageId(userId, chatRoomId, 100L); + chatMessageStatusService.saveLastReadMessageId(userId, chatRoomId, 50L); + + // then + assertEquals(100L, chatMessageStatusService.readLastReadMessageId(userId, chatRoomId)); + } + + @Test + @DisplayName("존재하지 않는 읽음 상태 조회 시 0을 반환한다") + void returnsZeroForNonExistentStatus() { + // when + Long result = chatMessageStatusService.readLastReadMessageId(999L, 999L); + + // then + assertEquals(0L, result); + } + + @Test + @DisplayName("벌크 업데이트 시 기존 값보다 작은 메시지 ID는 업데이트되지 않는다") + void bulkUpdateRespectsMessageIdOrder() { + // given + Long userId = 1L; + Long chatRoomId = 1L; + + chatMessageStatusRepository.save(new ChatMessageStatus(userId, chatRoomId, 200L)); + + Map> updates = new HashMap<>(); + updates.put(userId, Map.of(chatRoomId, 100L)); // 1번 사용자가 1번 방에서 100번 메시지까지 읽음 + + // when + chatMessageStatusService.bulkUpdateReadStatus(updates); + + // then + assertEquals(200L, chatMessageStatusService.readLastReadMessageId(userId, chatRoomId)); + } + + @AfterEach + void tearDown() { + chatMessageStatusRepository.deleteAll(); + } +} diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/chatstatus/service/ChatMessageStatusServiceTest.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/chatstatus/service/ChatMessageStatusServiceTest.java new file mode 100644 index 00000000..b36b8bf9 --- /dev/null +++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/chatstatus/service/ChatMessageStatusServiceTest.java @@ -0,0 +1,175 @@ +package kr.co.pennyway.domain.domains.chatstatus.service; + +import kr.co.pennyway.domain.domains.chatstatus.domain.ChatMessageStatus; +import kr.co.pennyway.domain.domains.chatstatus.repository.ChatMessageStatusRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +@ExtendWith(MockitoExtension.class) +public class ChatMessageStatusServiceTest { + @InjectMocks + private ChatMessageStatusService chatMessageStatusService; + + @Mock + private ChatMessageStatusRepository chatMessageStatusRepository; + + private static Stream provideInvalidInputs() { + return Stream.of( + Arguments.of(null, 1L, 1L), + Arguments.of(1L, null, 1L), + Arguments.of(1L, 1L, null), + Arguments.of(-1L, 1L, 1L), + Arguments.of(1L, -1L, 1L), + Arguments.of(1L, 1L, -1L) + ); + } + + @Test + @DisplayName("캐시에서 마지막 읽은 메시지 ID를 조회한다") + void getLastReadMessageIdFromCache() { + // given + Long userId = 1L; + Long chatRoomId = 1L; + Long messageId = 100L; + + given(chatMessageStatusRepository.findLastReadMessageId(userId, chatRoomId)).willReturn(Optional.of(messageId)); + + // when + Long result = chatMessageStatusService.readLastReadMessageId(userId, chatRoomId); + + // then + assertEquals(messageId, result); + verify(chatMessageStatusRepository).findLastReadMessageId(userId, chatRoomId); + verifyNoMoreInteractions(chatMessageStatusRepository); + } + + @Test + @DisplayName("캐시 미스 시 DB에서 조회하고 캐시를 갱신한다") + void getLastReadMessageIdFromDB() { + // given + Long userId = 1L; + Long chatRoomId = 1L; + Long messageId = 100L; + + ChatMessageStatus status = new ChatMessageStatus(userId, chatRoomId, messageId); + + given(chatMessageStatusRepository.findLastReadMessageId(userId, chatRoomId)).willReturn(Optional.empty()); + given(chatMessageStatusRepository.findByUserIdAndChatRoomId(userId, chatRoomId)).willReturn(Optional.of(status)); + + // when + Long result = chatMessageStatusService.readLastReadMessageId(userId, chatRoomId); + + // then + assertEquals(messageId, result); + verify(chatMessageStatusRepository).findLastReadMessageId(userId, chatRoomId); + verify(chatMessageStatusRepository).findByUserIdAndChatRoomId(userId, chatRoomId); + verify(chatMessageStatusRepository).saveLastReadMessageId(userId, chatRoomId, messageId); + } + + @Test + @DisplayName("DB에도 데이터가 없는 경우 0을 반환한다") + void returnZeroWhenNoDataExists() { + // given + Long userId = 1L; + Long chatRoomId = 1L; + + given(chatMessageStatusRepository.findLastReadMessageId(userId, chatRoomId)) + .willReturn(Optional.empty()); + given(chatMessageStatusRepository.findByUserIdAndChatRoomId(userId, chatRoomId)) + .willReturn(Optional.empty()); + + // when + Long result = chatMessageStatusService.readLastReadMessageId(userId, chatRoomId); + + // then + assertEquals(0L, result); + verify(chatMessageStatusRepository).findLastReadMessageId(userId, chatRoomId); + verify(chatMessageStatusRepository).findByUserIdAndChatRoomId(userId, chatRoomId); + verifyNoMoreInteractions(chatMessageStatusRepository); + } + + @Test + @DisplayName("벌크 업데이트 시 모든 항목이 정상적으로 처리된다") + void bulkUpdateProcessesAllItems() { + // given + Map> updates = new HashMap<>(); + Map user1Updates = new HashMap<>(); + user1Updates.put(1L, 100L); + user1Updates.put(2L, 200L); + + Map user2Updates = new HashMap<>(); + user2Updates.put(1L, 150L); + + updates.put(1L, user1Updates); + updates.put(2L, user2Updates); + + // when + chatMessageStatusService.bulkUpdateReadStatus(updates); + + // then + verify(chatMessageStatusRepository).saveLastReadMessageIdInBulk(1L, 1L, 100L); + verify(chatMessageStatusRepository).saveLastReadMessageIdInBulk(1L, 2L, 200L); + verify(chatMessageStatusRepository).saveLastReadMessageIdInBulk(2L, 1L, 150L); + verify(chatMessageStatusRepository).deleteLastReadMessageId(1L, 1L); + verify(chatMessageStatusRepository).deleteLastReadMessageId(1L, 2L); + verify(chatMessageStatusRepository).deleteLastReadMessageId(2L, 1L); + } + + @Test + @DisplayName("새 메시지 저장 시 정상적으로 처리된다") + void saveNewMessageStatus() { + // given + Long userId = 1L; + Long chatRoomId = 1L; + Long messageId = 100L; + + // when + chatMessageStatusService.saveLastReadMessageId(userId, chatRoomId, messageId); + + // then + verify(chatMessageStatusRepository).saveLastReadMessageId(userId, chatRoomId, messageId); + verifyNoMoreInteractions(chatMessageStatusRepository); + } + + @Test + @DisplayName("cache repository에서 예외 발생 시 적절히 처리된다 (현재는 예외를 던짐)") + void handleRepositoryException() { + // given + Long userId = 1L; + Long chatRoomId = 1L; + + given(chatMessageStatusRepository.findLastReadMessageId(userId, chatRoomId)).willThrow(new RuntimeException("Redis connection failed")); + + // when - then + assertThrows(RuntimeException.class, () -> + chatMessageStatusService.readLastReadMessageId(userId, chatRoomId) + ); + } + + @ParameterizedTest + @MethodSource("provideInvalidInputs") + @DisplayName("잘못된 입력값이 들어올 경우 적절히 처리된다") + void handleInvalidInputs(Long userId, Long chatRoomId, Long messageId) { + assertThrows(IllegalArgumentException.class, () -> + chatMessageStatusService.saveLastReadMessageId(userId, chatRoomId, messageId) + ); + } +}