diff --git a/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomSummaryService.java b/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomSummaryService.java new file mode 100644 index 000000000..023096768 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomSummaryService.java @@ -0,0 +1,70 @@ +package gg.agit.konect.domain.chat.service; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.stereotype.Service; + +import gg.agit.konect.domain.chat.dto.ChatRoomSummaryResponse; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class ChatRoomSummaryService { + + private final ChatRoomSettingsService chatRoomSettingsService; + + public List summarizeChatRooms( + Integer userId, + List directRooms, + List clubRooms, + List groupRooms + ) { + List rooms = new ArrayList<>(); + rooms.addAll(directRooms); + rooms.addAll(clubRooms); + rooms.addAll(groupRooms); + + rooms = new ArrayList<>(chatRoomSettingsService.applyUserSettings(rooms, userId)); + rooms.sort(Comparator + .comparing( + (ChatRoomSummaryResponse room) -> + room.lastSentAt() != null ? room.lastSentAt() : room.createdAt(), + Comparator.reverseOrder() + )); + + return rooms; + } + + public List summarizeSearchableRooms( + Integer userId, + List directRooms, + List clubRooms + ) { + List rooms = new ArrayList<>(); + rooms.addAll(directRooms); + rooms.addAll(clubRooms); + + rooms = new ArrayList<>(chatRoomSettingsService.applyUserSettings(rooms, userId)); + rooms.sort( + Comparator.comparing(ChatRoomSummaryResponse::lastSentAt, + Comparator.nullsLast(Comparator.reverseOrder())) + .thenComparing(ChatRoomSummaryResponse::roomId) + ); + + return rooms; + } + + public Map getDefaultRoomNameMap( + List directRooms, + List clubRooms + ) { + Map defaultRoomNameMap = new HashMap<>(); + directRooms.forEach(room -> defaultRoomNameMap.put(room.roomId(), room.roomName())); + clubRooms.forEach(room -> defaultRoomNameMap.put(room.roomId(), room.roomName())); + return defaultRoomNameMap; + } +} diff --git a/src/main/java/gg/agit/konect/domain/chat/service/ChatSearchService.java b/src/main/java/gg/agit/konect/domain/chat/service/ChatSearchService.java new file mode 100644 index 000000000..69c5774c1 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/chat/service/ChatSearchService.java @@ -0,0 +1,167 @@ +package gg.agit.konect.domain.chat.service; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.stream.Collectors; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; + +import gg.agit.konect.domain.chat.dto.ChatMessageMatchResult; +import gg.agit.konect.domain.chat.dto.ChatMessageMatchesResponse; +import gg.agit.konect.domain.chat.dto.ChatRoomMatchesResponse; +import gg.agit.konect.domain.chat.dto.ChatRoomSummaryResponse; +import gg.agit.konect.domain.chat.dto.ChatSearchResponse; +import gg.agit.konect.domain.chat.enums.ChatType; +import gg.agit.konect.domain.chat.model.ChatMessage; +import gg.agit.konect.domain.chat.model.ChatRoomMember; +import gg.agit.konect.domain.chat.repository.ChatMessageRepository; +import gg.agit.konect.domain.chat.repository.ChatRoomMemberRepository; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class ChatSearchService { + + private final ChatMessageRepository chatMessageRepository; + private final ChatRoomMemberRepository chatRoomMemberRepository; + + public ChatSearchResponse search( + Integer userId, + String keyword, + List accessibleRooms, + Map defaultRoomNameMap, + Integer page, + Integer limit + ) { + String normalizedKeyword = normalizeKeyword(keyword); + ChatRoomMatchesResponse roomMatches = searchRoomsByName( + accessibleRooms, + defaultRoomNameMap, + normalizedKeyword, + page, + limit + ); + ChatMessageMatchesResponse messageMatches = searchByMessageContent( + userId, + accessibleRooms, + normalizedKeyword, + page, + limit + ); + + return new ChatSearchResponse(roomMatches, messageMatches); + } + + private ChatRoomMatchesResponse searchRoomsByName( + List accessibleRooms, + Map defaultRoomNameMap, + String keyword, + Integer page, + Integer limit + ) { + List matchedRooms = accessibleRooms.stream() + .filter(room -> matchesRoomName(room, keyword, defaultRoomNameMap)) + .toList(); + + return ChatRoomMatchesResponse.from(toPage(matchedRooms, page, limit)); + } + + private ChatMessageMatchesResponse searchByMessageContent( + Integer userId, + List accessibleRooms, + String keyword, + Integer page, + Integer limit + ) { + if (accessibleRooms.isEmpty() || keyword.isBlank()) { + return ChatMessageMatchesResponse.from(emptyPage(page, limit)); + } + + Map roomMap = accessibleRooms.stream() + .collect(Collectors.toMap(ChatRoomSummaryResponse::roomId, room -> room)); + List roomIds = accessibleRooms.stream() + .map(ChatRoomSummaryResponse::roomId) + .toList(); + List directRoomIds = accessibleRooms.stream() + .filter(room -> room.chatType() == ChatType.DIRECT) + .map(ChatRoomSummaryResponse::roomId) + .toList(); + Map visibleMessageFromMap = getVisibleMessageFromMap(directRoomIds, userId); + + List matchedMessages = chatMessageRepository + .searchLatestMatchingMessagesByChatRoomIds(roomIds, keyword) + .stream() + .filter(message -> isVisibleMessageMatch(message, roomMap, visibleMessageFromMap)) + .map(message -> ChatMessageMatchResult.from(roomMap.get(message.getChatRoom().getId()), message)) + .toList(); + + return ChatMessageMatchesResponse.from(toPage(matchedMessages, page, limit)); + } + + private String normalizeKeyword(String keyword) { + return keyword == null ? "" : keyword.trim(); + } + + private boolean matchesRoomName( + ChatRoomSummaryResponse room, + String keyword, + Map defaultRoomNameMap + ) { + return containsKeyword(room.roomName(), keyword) + || containsKeyword(defaultRoomNameMap.get(room.roomId()), keyword); + } + + private boolean containsKeyword(String text, String keyword) { + return text != null + && !keyword.isBlank() + && text.toLowerCase(Locale.ROOT).contains(keyword.toLowerCase(Locale.ROOT)); + } + + private Map getVisibleMessageFromMap(List roomIds, Integer userId) { + if (roomIds.isEmpty()) { + return Map.of(); + } + + Map visibleMessageFromMap = new HashMap<>(); + for (ChatRoomMember roomMember : chatRoomMemberRepository.findByChatRoomIdsAndUserId(roomIds, userId)) { + visibleMessageFromMap.put(roomMember.getChatRoomId(), roomMember.getVisibleMessageFrom()); + } + return visibleMessageFromMap; + } + + private boolean isVisibleMessageMatch( + ChatMessage message, + Map roomMap, + Map visibleMessageFromMap + ) { + ChatRoomSummaryResponse room = roomMap.get(message.getChatRoom().getId()); + if (room == null || room.chatType() != ChatType.DIRECT) { + return true; + } + + LocalDateTime visibleMessageFrom = visibleMessageFromMap.get(room.roomId()); + return visibleMessageFrom == null || message.getCreatedAt().isAfter(visibleMessageFrom); + } + + private Page toPage(List items, Integer page, Integer limit) { + PageRequest pageable = PageRequest.of(page - 1, limit); + long offset = (long)(page - 1) * limit; + if (offset >= items.size()) { + return new PageImpl<>(List.of(), pageable, items.size()); + } + + int fromIndex = (int)offset; + int toIndex = Math.min(fromIndex + limit, items.size()); + return new PageImpl<>(items.subList(fromIndex, toIndex), pageable, items.size()); + } + + private Page emptyPage(Integer page, Integer limit) { + return new PageImpl<>(List.of(), PageRequest.of(page - 1, limit), 0); + } +} 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 561b3ca38..4d3a2b62f 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 @@ -9,7 +9,6 @@ import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; -import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.Set; @@ -26,13 +25,10 @@ import gg.agit.konect.domain.chat.dto.AdminChatRoomProjection; import gg.agit.konect.domain.chat.dto.ChatInvitableUsersResponse; import gg.agit.konect.domain.chat.dto.ChatMessageDetailResponse; -import gg.agit.konect.domain.chat.dto.ChatMessageMatchResult; -import gg.agit.konect.domain.chat.dto.ChatMessageMatchesResponse; import gg.agit.konect.domain.chat.dto.ChatMessagePageResponse; import gg.agit.konect.domain.chat.dto.ChatMessageSendRequest; import gg.agit.konect.domain.chat.dto.ChatMuteResponse; import gg.agit.konect.domain.chat.dto.ChatRoomCreateRequest; -import gg.agit.konect.domain.chat.dto.ChatRoomMatchesResponse; import gg.agit.konect.domain.chat.dto.ChatRoomNameUpdateRequest; import gg.agit.konect.domain.chat.dto.ChatRoomResponse; import gg.agit.konect.domain.chat.dto.ChatRoomSummaryResponse; @@ -82,7 +78,8 @@ public class ChatService { private final UserRepository userRepository; private final ChatPresenceService chatPresenceService; private final ChatRoomMembershipService chatRoomMembershipService; - private final ChatRoomSettingsService chatRoomSettingsService; + private final ChatRoomSummaryService chatRoomSummaryService; + private final ChatSearchService chatSearchService; private final NotificationService notificationService; private final ApplicationEventPublisher eventPublisher; @@ -213,36 +210,20 @@ public ChatRoomsSummaryResponse getChatRooms(Integer userId) { List clubRooms = getClubChatRooms(userId); List groupRooms = getGroupChatRooms(userId); - List rooms = new ArrayList<>(); - rooms.addAll(directRooms); - rooms.addAll(clubRooms); - rooms.addAll(groupRooms); - rooms = new ArrayList<>(chatRoomSettingsService.applyUserSettings(rooms, userId)); - - rooms.sort( - Comparator.comparing( - (ChatRoomSummaryResponse room) -> - room.lastSentAt() != null ? room.lastSentAt() : room.createdAt(), - Comparator.reverseOrder() - ) + List rooms = chatRoomSummaryService.summarizeChatRooms( + userId, + directRooms, + clubRooms, + groupRooms ); return new ChatRoomsSummaryResponse(rooms); } public ChatSearchResponse searchChats(Integer userId, String keyword, Integer page, Integer limit) { - String normalizedKeyword = normalizeKeyword(keyword); AccessibleChatRooms accessibleChatRooms = getAccessibleChatRooms(userId); - ChatRoomMatchesResponse roomMatches = searchRoomsByName(accessibleChatRooms, normalizedKeyword, page, limit); - ChatMessageMatchesResponse messageMatches = searchByMessageContent( - userId, - accessibleChatRooms.rooms(), - normalizedKeyword, - page, - limit - ); - - return new ChatSearchResponse(roomMatches, messageMatches); + return chatSearchService.search(userId, keyword, accessibleChatRooms.rooms(), + accessibleChatRooms.defaultRoomNameMap(), page, limit); } public ChatInvitableUsersResponse getInvitableUsers( @@ -906,148 +887,18 @@ private AccessibleChatRooms getAccessibleChatRooms(Integer userId) { List directRooms = getDirectChatRooms(userId); List clubRooms = getClubChatRooms(userId); - List roomIds = new ArrayList<>(); - roomIds.addAll(directRooms.stream().map(ChatRoomSummaryResponse::roomId).toList()); - roomIds.addAll(clubRooms.stream().map(ChatRoomSummaryResponse::roomId).toList()); - - Map defaultRoomNameMap = getDefaultRoomNameMap(directRooms, clubRooms); - List rooms = new ArrayList<>(); - rooms.addAll(directRooms); - rooms.addAll(clubRooms); - rooms = new ArrayList<>(chatRoomSettingsService.applyUserSettings(rooms, userId)); - - rooms.sort( - Comparator.comparing(ChatRoomSummaryResponse::lastSentAt, - Comparator.nullsLast(Comparator.reverseOrder())) - .thenComparing(ChatRoomSummaryResponse::roomId) + Map defaultRoomNameMap = chatRoomSummaryService.getDefaultRoomNameMap( + directRooms, + clubRooms + ); + List rooms = chatRoomSummaryService.summarizeSearchableRooms( + userId, + directRooms, + clubRooms ); return new AccessibleChatRooms(rooms, defaultRoomNameMap); } - private ChatRoomMatchesResponse searchRoomsByName( - AccessibleChatRooms accessibleChatRooms, - String keyword, - Integer page, - Integer limit - ) { - List matchedRooms = accessibleChatRooms.rooms().stream() - .filter(room -> matchesRoomName(room, keyword, accessibleChatRooms.defaultRoomNameMap())) - .toList(); - - return ChatRoomMatchesResponse.from(toPage(matchedRooms, page, limit)); - } - - private ChatMessageMatchesResponse searchByMessageContent( - Integer userId, - List accessibleRooms, - String keyword, - Integer page, - Integer limit - ) { - if (accessibleRooms.isEmpty() || keyword.isBlank()) { - return ChatMessageMatchesResponse.from(emptyPage(page, limit)); - } - - Map roomMap = accessibleRooms.stream() - .collect(Collectors.toMap(ChatRoomSummaryResponse::roomId, room -> room)); - List roomIds = accessibleRooms.stream() - .map(ChatRoomSummaryResponse::roomId) - .toList(); - List directRoomIds = accessibleRooms.stream() - .filter(room -> room.chatType() == ChatType.DIRECT) - .map(ChatRoomSummaryResponse::roomId) - .toList(); - Map visibleMessageFromMap = getVisibleMessageFromMap(directRoomIds, userId); - - List matchedMessages = chatMessageRepository - .searchLatestMatchingMessagesByChatRoomIds(roomIds, keyword) - .stream() - .filter(message -> isVisibleMessageMatch(message, roomMap, visibleMessageFromMap)) - .map(message -> ChatMessageMatchResult.from(roomMap.get(message.getChatRoom().getId()), message)) - .toList(); - - return ChatMessageMatchesResponse.from(toPage(matchedMessages, page, limit)); - } - - private String normalizeKeyword(String keyword) { - if (keyword == null) { - return ""; - } - return keyword.trim(); - } - - private boolean containsKeyword(String text, String keyword) { - if (text == null || keyword.isBlank()) { - return false; - } - - return text.toLowerCase(Locale.ROOT).contains(keyword.toLowerCase(Locale.ROOT)); - } - - private Page toPage(List items, Integer page, Integer limit) { - PageRequest pageable = PageRequest.of(page - 1, limit); - long offset = (long)(page - 1) * limit; - if (offset >= items.size()) { - return new PageImpl<>(List.of(), pageable, items.size()); - } - - int fromIndex = (int)offset; - int toIndex = Math.min(fromIndex + limit, items.size()); - return new PageImpl<>(items.subList(fromIndex, toIndex), pageable, items.size()); - } - - private Page emptyPage(Integer page, Integer limit) { - return new PageImpl<>(List.of(), PageRequest.of(page - 1, limit), 0); - } - - private Map getVisibleMessageFromMap(List roomIds, Integer userId) { - if (roomIds.isEmpty()) { - return Map.of(); - } - - Map visibleMessageFromMap = new HashMap<>(); - for (ChatRoomMember roomMember : chatRoomMemberRepository.findByChatRoomIdsAndUserId(roomIds, userId)) { - visibleMessageFromMap.put(roomMember.getChatRoomId(), roomMember.getVisibleMessageFrom()); - } - return visibleMessageFromMap; - } - - private Map getDefaultRoomNameMap( - List directRooms, - List clubRooms - ) { - Map defaultRoomNameMap = new HashMap<>(); - directRooms.forEach(room -> defaultRoomNameMap.put(room.roomId(), room.roomName())); - clubRooms.forEach(room -> defaultRoomNameMap.put(room.roomId(), room.roomName())); - return defaultRoomNameMap; - } - - private boolean matchesRoomName( - ChatRoomSummaryResponse room, - String keyword, - Map defaultRoomNameMap - ) { - if (containsKeyword(room.roomName(), keyword)) { - return true; - } - - return containsKeyword(defaultRoomNameMap.get(room.roomId()), keyword); - } - - private boolean isVisibleMessageMatch( - ChatMessage message, - Map roomMap, - Map visibleMessageFromMap - ) { - ChatRoomSummaryResponse room = roomMap.get(message.getChatRoom().getId()); - if (room == null || room.chatType() != ChatType.DIRECT) { - return true; - } - - LocalDateTime visibleMessageFrom = visibleMessageFromMap.get(room.roomId()); - return visibleMessageFrom == null || message.getCreatedAt().isAfter(visibleMessageFrom); - } - private ChatRoom getDirectRoom(Integer roomId) { ChatRoom chatRoom = chatRoomRepository.findById(roomId) .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); diff --git a/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatRoomSummaryServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatRoomSummaryServiceTest.java new file mode 100644 index 000000000..61484b78f --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatRoomSummaryServiceTest.java @@ -0,0 +1,97 @@ +package gg.agit.konect.unit.domain.chat.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import gg.agit.konect.domain.chat.dto.ChatRoomSummaryResponse; +import gg.agit.konect.domain.chat.enums.ChatType; +import gg.agit.konect.domain.chat.service.ChatRoomSettingsService; +import gg.agit.konect.domain.chat.service.ChatRoomSummaryService; +import gg.agit.konect.support.ServiceTestSupport; + +class ChatRoomSummaryServiceTest extends ServiceTestSupport { + + @Mock + private ChatRoomSettingsService chatRoomSettingsService; + + @InjectMocks + private ChatRoomSummaryService chatRoomSummaryService; + + @Test + @DisplayName("summarizeChatRooms는 사용자 설정을 적용한 뒤 최신 대화 순으로 정렬한다") + void summarizeChatRoomsAppliesSettingsAndSortsByRecentActivity() { + // given + Integer userId = 10; + ChatRoomSummaryResponse olderRoom = createRoom(1, ChatType.DIRECT, "오래된 방", + LocalDateTime.of(2026, 4, 27, 9, 0), LocalDateTime.of(2026, 4, 27, 8, 0)); + ChatRoomSummaryResponse emptyNewRoom = createRoom(2, ChatType.GROUP, "새 빈 방", + null, LocalDateTime.of(2026, 4, 27, 11, 0)); + ChatRoomSummaryResponse newestRoom = createRoom(3, ChatType.CLUB_GROUP, "최신 방", + LocalDateTime.of(2026, 4, 27, 12, 0), LocalDateTime.of(2026, 4, 27, 7, 0)); + List combinedRooms = List.of(olderRoom, newestRoom, emptyNewRoom); + + given(chatRoomSettingsService.applyUserSettings(combinedRooms, userId)) + .willReturn(combinedRooms); + + // when + List result = chatRoomSummaryService.summarizeChatRooms( + userId, + List.of(olderRoom), + List.of(newestRoom), + List.of(emptyNewRoom) + ); + + // then + assertThat(result).extracting(ChatRoomSummaryResponse::roomId) + .containsExactly(3, 2, 1); + } + + @Test + @DisplayName("getDefaultRoomNameMap은 검색용 기본 방 이름을 보존한다") + void getDefaultRoomNameMapKeepsOriginalRoomNames() { + // given + ChatRoomSummaryResponse directRoom = createRoom(1, ChatType.DIRECT, "상대방", + LocalDateTime.of(2026, 4, 27, 9, 0), LocalDateTime.of(2026, 4, 27, 8, 0)); + ChatRoomSummaryResponse clubRoom = createRoom(2, ChatType.CLUB_GROUP, "동아리", + LocalDateTime.of(2026, 4, 27, 10, 0), LocalDateTime.of(2026, 4, 27, 8, 0)); + + // when + Map result = chatRoomSummaryService.getDefaultRoomNameMap( + List.of(directRoom), + List.of(clubRoom) + ); + + // then + assertThat(result).containsEntry(1, "상대방") + .containsEntry(2, "동아리"); + } + + private ChatRoomSummaryResponse createRoom( + Integer roomId, + ChatType chatType, + String roomName, + LocalDateTime lastSentAt, + LocalDateTime createdAt + ) { + return new ChatRoomSummaryResponse( + roomId, + chatType, + roomName, + null, + null, + lastSentAt, + createdAt, + 0, + false + ); + } +} 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 71e05479f..6df2bd4e1 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 @@ -52,7 +52,7 @@ import gg.agit.konect.domain.chat.repository.ChatRoomRepository; import gg.agit.konect.domain.chat.service.ChatPresenceService; import gg.agit.konect.domain.chat.service.ChatRoomMembershipService; -import gg.agit.konect.domain.chat.service.ChatRoomSettingsService; +import gg.agit.konect.domain.chat.service.ChatRoomSummaryService; import gg.agit.konect.domain.chat.service.ChatService; import gg.agit.konect.domain.club.model.Club; import gg.agit.konect.domain.club.model.ClubMember; @@ -102,7 +102,7 @@ class ChatServiceTest extends ServiceTestSupport { private ChatRoomMembershipService chatRoomMembershipService; @Mock - private ChatRoomSettingsService chatRoomSettingsService; + private ChatRoomSummaryService chatRoomSummaryService; @Mock private NotificationService notificationService;