From ac5634c4dab13d49dc31073efb05fae24eb6f348 Mon Sep 17 00:00:00 2001 From: JanooGwan Date: Tue, 31 Mar 2026 21:23:19 +0900 Subject: [PATCH 1/9] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85=EB=B0=A9=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/chat/controller/ChatApi.java | 24 +++ .../chat/controller/ChatController.java | 12 ++ .../chat/dto/ChatMessageMatchResult.java | 45 ++++++ .../chat/dto/ChatMessageMatchesResponse.java | 37 +++++ .../chat/dto/ChatRoomMatchesResponse.java | 37 +++++ .../domain/chat/dto/ChatSearchResponse.java | 14 ++ .../repository/ChatMessageRepository.java | 28 ++++ .../domain/chat/service/ChatService.java | 153 ++++++++++++++---- .../googlesheets/GoogleSheetsConfig.java | 14 +- .../integration/domain/chat/ChatApiTest.java | 108 +++++++++++++ .../support/IntegrationTestSupport.java | 12 ++ 11 files changed, 448 insertions(+), 36 deletions(-) create mode 100644 src/main/java/gg/agit/konect/domain/chat/dto/ChatMessageMatchResult.java create mode 100644 src/main/java/gg/agit/konect/domain/chat/dto/ChatMessageMatchesResponse.java create mode 100644 src/main/java/gg/agit/konect/domain/chat/dto/ChatRoomMatchesResponse.java create mode 100644 src/main/java/gg/agit/konect/domain/chat/dto/ChatSearchResponse.java diff --git a/src/main/java/gg/agit/konect/domain/chat/controller/ChatApi.java b/src/main/java/gg/agit/konect/domain/chat/controller/ChatApi.java index 6b1b5a6f8..8e5a41aec 100644 --- a/src/main/java/gg/agit/konect/domain/chat/controller/ChatApi.java +++ b/src/main/java/gg/agit/konect/domain/chat/controller/ChatApi.java @@ -15,11 +15,13 @@ import gg.agit.konect.domain.chat.dto.ChatMuteResponse; import gg.agit.konect.domain.chat.dto.ChatRoomsSummaryResponse; import gg.agit.konect.domain.chat.dto.ChatRoomResponse; +import gg.agit.konect.domain.chat.dto.ChatSearchResponse; import gg.agit.konect.global.auth.annotation.UserId; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; @Tag(name = "(Normal) Chat: 채팅", description = "채팅 API") @RequestMapping("/chats") @@ -74,6 +76,28 @@ ResponseEntity getChatRooms( @UserId Integer userId ); + @Operation(summary = "채팅방 이름과 메시지 내용으로 채팅방을 검색한다.", description = """ + ## 설명 + - 현재 사용자가 접근 가능한 채팅방만 검색합니다. + - 채팅방 이름 매칭 결과와 메시지 내용 매칭 결과를 분리해서 반환합니다. + + ## 로직 + - 1:1 채팅은 상대방 이름으로 검색합니다. + - 그룹 채팅은 동아리 이름으로 검색합니다. + - 메시지 검색 결과는 채팅방별 최신 매칭 메시지 1개만 반환합니다. + - page, limit는 채팅방 이름 검색 결과와 메시지 검색 결과에 각각 동일하게 적용됩니다. + """) + @GetMapping("/rooms/search") + ResponseEntity searchChats( + @NotBlank(message = "검색어는 필수입니다.") + @RequestParam(name = "keyword") String keyword, + @Min(value = 1, message = "페이지 번호는 1 이상이어야 합니다.") + @RequestParam(name = "page", defaultValue = "1") Integer page, + @Min(value = 1, message = "페이지 당 항목 수는 1 이상이어야 합니다.") + @RequestParam(name = "limit", defaultValue = "20") Integer limit, + @UserId Integer userId + ); + @Operation(summary = "채팅방 메시지 리스트를 조회한다.", description = """ ## 설명 - 특정 채팅방의 메시지 목록을 페이지네이션으로 조회합니다. diff --git a/src/main/java/gg/agit/konect/domain/chat/controller/ChatController.java b/src/main/java/gg/agit/konect/domain/chat/controller/ChatController.java index ef919c574..26342e45d 100644 --- a/src/main/java/gg/agit/konect/domain/chat/controller/ChatController.java +++ b/src/main/java/gg/agit/konect/domain/chat/controller/ChatController.java @@ -15,6 +15,7 @@ import gg.agit.konect.domain.chat.dto.ChatRoomCreateRequest; import gg.agit.konect.domain.chat.dto.ChatRoomResponse; import gg.agit.konect.domain.chat.dto.ChatRoomsSummaryResponse; +import gg.agit.konect.domain.chat.dto.ChatSearchResponse; import gg.agit.konect.domain.chat.service.ChatService; import gg.agit.konect.global.auth.annotation.UserId; import jakarta.validation.Valid; @@ -53,6 +54,17 @@ public ResponseEntity getChatRooms( return ResponseEntity.ok(response); } + @Override + public ResponseEntity searchChats( + @RequestParam(name = "keyword") String keyword, + @RequestParam(name = "page", defaultValue = "1") Integer page, + @RequestParam(name = "limit", defaultValue = "20") Integer limit, + @UserId Integer userId + ) { + ChatSearchResponse response = chatService.searchChats(userId, keyword, page, limit); + return ResponseEntity.ok(response); + } + @Override public ResponseEntity getChatRoomMessages( @RequestParam(name = "page", defaultValue = "1") Integer page, diff --git a/src/main/java/gg/agit/konect/domain/chat/dto/ChatMessageMatchResult.java b/src/main/java/gg/agit/konect/domain/chat/dto/ChatMessageMatchResult.java new file mode 100644 index 000000000..91a56d4af --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/chat/dto/ChatMessageMatchResult.java @@ -0,0 +1,45 @@ +package gg.agit.konect.domain.chat.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.time.LocalDateTime; + +import com.fasterxml.jackson.annotation.JsonFormat; + +import gg.agit.konect.domain.chat.enums.ChatType; +import gg.agit.konect.domain.chat.model.ChatMessage; +import io.swagger.v3.oas.annotations.media.Schema; + +public record ChatMessageMatchResult( + @Schema(description = "채팅방 ID", example = "1", requiredMode = REQUIRED) + Integer roomId, + + @Schema(description = "채팅 타입", example = "DIRECT", requiredMode = REQUIRED) + ChatType chatType, + + @Schema(description = "채팅방 이름", example = "개발팀", requiredMode = REQUIRED) + String roomName, + + @Schema(description = "채팅방 이미지 URL", example = "https://example.com/image.png", requiredMode = NOT_REQUIRED) + String roomImageUrl, + + @Schema(description = "검색에 매칭된 메시지 내용", example = "안녕하세요", requiredMode = REQUIRED) + String matchedMessage, + + @Schema(description = "매칭된 메시지 전송 시간", example = "2025.12.19 23:21", requiredMode = REQUIRED) + @JsonFormat(pattern = "yyyy.MM.dd HH:mm") + LocalDateTime matchedMessageSentAt +) { + + public static ChatMessageMatchResult from(ChatRoomSummaryResponse room, ChatMessage message) { + return new ChatMessageMatchResult( + room.roomId(), + room.chatType(), + room.roomName(), + room.roomImageUrl(), + message.getContent(), + message.getCreatedAt() + ); + } +} diff --git a/src/main/java/gg/agit/konect/domain/chat/dto/ChatMessageMatchesResponse.java b/src/main/java/gg/agit/konect/domain/chat/dto/ChatMessageMatchesResponse.java new file mode 100644 index 000000000..78b6360e1 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/chat/dto/ChatMessageMatchesResponse.java @@ -0,0 +1,37 @@ +package gg.agit.konect.domain.chat.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; + +import org.springframework.data.domain.Page; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record ChatMessageMatchesResponse( + @Schema(description = "조건에 해당하는 메시지 매칭 총 개수", example = "10", requiredMode = REQUIRED) + Long totalCount, + + @Schema(description = "현재 페이지에서 조회된 메시지 매칭 개수", example = "5", requiredMode = REQUIRED) + Integer currentCount, + + @Schema(description = "최대 페이지", example = "2", requiredMode = REQUIRED) + Integer totalPage, + + @Schema(description = "현재 페이지", example = "1", requiredMode = REQUIRED) + Integer currentPage, + + @Schema(description = "메시지 내용으로 매칭된 채팅방 목록", requiredMode = REQUIRED) + List messages +) { + + public static ChatMessageMatchesResponse from(Page page) { + return new ChatMessageMatchesResponse( + page.getTotalElements(), + page.getNumberOfElements(), + page.getTotalPages(), + page.getNumber() + 1, + page.getContent() + ); + } +} diff --git a/src/main/java/gg/agit/konect/domain/chat/dto/ChatRoomMatchesResponse.java b/src/main/java/gg/agit/konect/domain/chat/dto/ChatRoomMatchesResponse.java new file mode 100644 index 000000000..3d50631c2 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/chat/dto/ChatRoomMatchesResponse.java @@ -0,0 +1,37 @@ +package gg.agit.konect.domain.chat.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; + +import org.springframework.data.domain.Page; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record ChatRoomMatchesResponse( + @Schema(description = "조건에 해당하는 채팅방 총 개수", example = "10", requiredMode = REQUIRED) + Long totalCount, + + @Schema(description = "현재 페이지에서 조회된 채팅방 개수", example = "5", requiredMode = REQUIRED) + Integer currentCount, + + @Schema(description = "최대 페이지", example = "2", requiredMode = REQUIRED) + Integer totalPage, + + @Schema(description = "현재 페이지", example = "1", requiredMode = REQUIRED) + Integer currentPage, + + @Schema(description = "채팅방 이름으로 매칭된 채팅방 목록", requiredMode = REQUIRED) + List rooms +) { + + public static ChatRoomMatchesResponse from(Page page) { + return new ChatRoomMatchesResponse( + page.getTotalElements(), + page.getNumberOfElements(), + page.getTotalPages(), + page.getNumber() + 1, + page.getContent() + ); + } +} diff --git a/src/main/java/gg/agit/konect/domain/chat/dto/ChatSearchResponse.java b/src/main/java/gg/agit/konect/domain/chat/dto/ChatSearchResponse.java new file mode 100644 index 000000000..cff92558d --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/chat/dto/ChatSearchResponse.java @@ -0,0 +1,14 @@ +package gg.agit.konect.domain.chat.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record ChatSearchResponse( + @Schema(description = "채팅방 이름으로 매칭된 검색 결과", requiredMode = REQUIRED) + ChatRoomMatchesResponse roomMatches, + + @Schema(description = "메시지 내용으로 매칭된 검색 결과", requiredMode = REQUIRED) + ChatMessageMatchesResponse messageMatches +) { +} diff --git a/src/main/java/gg/agit/konect/domain/chat/repository/ChatMessageRepository.java b/src/main/java/gg/agit/konect/domain/chat/repository/ChatMessageRepository.java index dc7876acd..0aa17dbfb 100644 --- a/src/main/java/gg/agit/konect/domain/chat/repository/ChatMessageRepository.java +++ b/src/main/java/gg/agit/konect/domain/chat/repository/ChatMessageRepository.java @@ -102,6 +102,34 @@ SELECT MAX(m2.id) """) List findLatestMessagesByRoomIds(@Param("roomIds") List roomIds); + @Query( + value = """ + SELECT cm + FROM ChatMessage cm + JOIN FETCH cm.chatRoom cr + WHERE cr.id IN :roomIds + AND LOWER(cm.content) LIKE LOWER(CONCAT('%', :keyword, '%')) + AND cm.id = ( + SELECT MAX(innerCm.id) + FROM ChatMessage innerCm + WHERE innerCm.chatRoom.id = cr.id + AND LOWER(innerCm.content) LIKE LOWER(CONCAT('%', :keyword, '%')) + ) + ORDER BY cm.createdAt DESC, cm.id DESC + """, + countQuery = """ + SELECT COUNT(DISTINCT cm.chatRoom.id) + FROM ChatMessage cm + WHERE cm.chatRoom.id IN :roomIds + AND LOWER(cm.content) LIKE LOWER(CONCAT('%', :keyword, '%')) + """ + ) + Page searchLatestMatchingMessagesByChatRoomIds( + @Param("roomIds") List roomIds, + @Param("keyword") String keyword, + Pageable pageable + ); + @Query(""" SELECT COUNT(m) FROM ChatMessage m 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 da1b7cbc3..3efafde1d 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 @@ -10,6 +10,7 @@ import java.util.Comparator; import java.util.HashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.Set; @@ -17,18 +18,23 @@ import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; 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.ChatRoomResponse; import gg.agit.konect.domain.chat.dto.ChatRoomSummaryResponse; import gg.agit.konect.domain.chat.dto.ChatRoomsSummaryResponse; +import gg.agit.konect.domain.chat.dto.ChatSearchResponse; import gg.agit.konect.domain.chat.dto.AdminChatRoomProjection; import gg.agit.konect.domain.chat.dto.UnreadMessageCount; import gg.agit.konect.domain.chat.enums.ChatType; @@ -125,44 +131,23 @@ public ChatRoomResponse createOrGetAdminChatRoom(Integer currentUserId) { @Transactional public ChatRoomsSummaryResponse getChatRooms(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 muteMap = getMuteMap(roomIds, userId); - List rooms = new ArrayList<>(); - - directRooms.forEach(room -> rooms.add(new ChatRoomSummaryResponse( - room.roomId(), - room.chatType(), - room.roomName(), - room.roomImageUrl(), - room.lastMessage(), - room.lastSentAt(), - room.unreadCount(), - muteMap.getOrDefault(room.roomId(), false) - ))); - - clubRooms.forEach(room -> rooms.add(new ChatRoomSummaryResponse( - room.roomId(), - room.chatType(), - room.roomName(), - room.roomImageUrl(), - room.lastMessage(), - room.lastSentAt(), - room.unreadCount(), - muteMap.getOrDefault(room.roomId(), false) - ))); + return new ChatRoomsSummaryResponse(getAccessibleChatRooms(userId)); + } - rooms.sort( - Comparator.comparing(ChatRoomSummaryResponse::lastSentAt, Comparator.nullsLast(Comparator.reverseOrder())) - .thenComparing(ChatRoomSummaryResponse::roomId) + @Transactional + public ChatSearchResponse searchChats(Integer userId, String keyword, Integer page, Integer limit) { + String normalizedKeyword = normalizeKeyword(keyword); + List accessibleRooms = getAccessibleChatRooms(userId); + + ChatRoomMatchesResponse roomMatches = searchRoomsByName(accessibleRooms, normalizedKeyword, page, limit); + ChatMessageMatchesResponse messageMatches = searchByMessageContent( + accessibleRooms, + normalizedKeyword, + page, + limit ); - return new ChatRoomsSummaryResponse(rooms); + return new ChatSearchResponse(roomMatches, messageMatches); } @Transactional @@ -516,6 +501,104 @@ private ChatMessageDetailResponse sendClubMessageByRoomId(Integer roomId, Intege ); } + private List 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 muteMap = getMuteMap(roomIds, userId); + List rooms = new ArrayList<>(); + directRooms.forEach(room -> rooms.add(applyMute(room, muteMap))); + clubRooms.forEach(room -> rooms.add(applyMute(room, muteMap))); + + rooms.sort( + Comparator.comparing(ChatRoomSummaryResponse::lastSentAt, Comparator.nullsLast(Comparator.reverseOrder())) + .thenComparing(ChatRoomSummaryResponse::roomId) + ); + return rooms; + } + + private ChatRoomSummaryResponse applyMute( + ChatRoomSummaryResponse room, + Map muteMap + ) { + return new ChatRoomSummaryResponse( + room.roomId(), + room.chatType(), + room.roomName(), + room.roomImageUrl(), + room.lastMessage(), + room.lastSentAt(), + room.unreadCount(), + muteMap.getOrDefault(room.roomId(), false) + ); + } + + private ChatRoomMatchesResponse searchRoomsByName( + List accessibleRooms, + String keyword, + Integer page, + Integer limit + ) { + List matchedRooms = accessibleRooms.stream() + .filter(room -> containsKeyword(room.roomName(), keyword)) + .toList(); + + return ChatRoomMatchesResponse.from(toPage(matchedRooms, page, limit)); + } + + private ChatMessageMatchesResponse searchByMessageContent( + List accessibleRooms, + String keyword, + Integer page, + Integer limit + ) { + if (accessibleRooms.isEmpty()) { + 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(); + + Page matchedMessages = chatMessageRepository + .searchLatestMatchingMessagesByChatRoomIds(roomIds, keyword, PageRequest.of(page - 1, limit)) + .map(message -> ChatMessageMatchResult.from(roomMap.get(message.getChatRoom().getId()), message)); + + return ChatMessageMatchesResponse.from(matchedMessages); + } + + 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); + int fromIndex = Math.min((page - 1) * limit, items.size()); + 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 getMuteMap(List roomIds, Integer userId) { if (roomIds.isEmpty()) { return Map.of(); diff --git a/src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsConfig.java b/src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsConfig.java index bb85274b0..2d3f23742 100644 --- a/src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsConfig.java +++ b/src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsConfig.java @@ -9,6 +9,8 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.Resource; +import org.springframework.core.io.ResourceLoader; import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport; import com.google.api.client.json.gson.GsonFactory; @@ -27,10 +29,11 @@ public class GoogleSheetsConfig { private final GoogleSheetsProperties googleSheetsProperties; + private final ResourceLoader resourceLoader; @Bean public GoogleCredentials googleCredentials() throws IOException { - try (InputStream in = new FileInputStream(googleSheetsProperties.credentialsPath())) { + try (InputStream in = openCredentialsStream()) { return GoogleCredentials.fromStream(in) .createScoped(Arrays.asList( SheetsScopes.SPREADSHEETS, @@ -81,4 +84,13 @@ public Drive buildUserDriveService(String refreshToken) throws IOException, Gene .setApplicationName(googleSheetsProperties.applicationName()) .build(); } + + private InputStream openCredentialsStream() throws IOException { + String credentialsPath = googleSheetsProperties.credentialsPath(); + if (credentialsPath != null && credentialsPath.startsWith("classpath:")) { + Resource resource = resourceLoader.getResource(credentialsPath); + return resource.getInputStream(); + } + return new FileInputStream(credentialsPath); + } } 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 d5037be7c..e00fc7d99 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 @@ -24,12 +24,16 @@ import gg.agit.konect.domain.chat.repository.ChatRoomMemberRepository; import gg.agit.konect.domain.chat.repository.ChatRoomRepository; import gg.agit.konect.domain.chat.service.ChatPresenceService; +import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.club.model.ClubMember; import gg.agit.konect.domain.notification.enums.NotificationTargetType; import gg.agit.konect.domain.notification.repository.NotificationMuteSettingRepository; import gg.agit.konect.domain.notification.service.NotificationService; import gg.agit.konect.domain.university.model.University; import gg.agit.konect.domain.user.model.User; import gg.agit.konect.support.IntegrationTestSupport; +import gg.agit.konect.support.fixture.ClubFixture; +import gg.agit.konect.support.fixture.ClubMemberFixture; import gg.agit.konect.support.fixture.UniversityFixture; import gg.agit.konect.support.fixture.UserFixture; @@ -274,6 +278,91 @@ void getMessagesForbidden() throws Exception { } } + @Nested + @DisplayName("GET /chats/rooms/search - 채팅 검색") + class SearchChats { + + private User secondTargetUser; + private Club developmentClub; + + @BeforeEach + void setUpSearchFixture() { + targetUser = createUser("개발팀", "2021136002"); + secondTargetUser = createUser("개발자", "2021136003"); + outsiderUser = createUser("외부유저", "2021136004"); + developmentClub = persist(ClubFixture.create(university, "개발동아리")); + createClubMember(developmentClub, normalUser); + clearPersistenceContext(); + } + + @Test + @DisplayName("채팅방 이름과 상대방 이름으로 검색 결과를 분리해서 반환한다") + void searchChatsReturnsRoomMatchesForDirectAndGroupRooms() throws Exception { + // given + ChatRoom directRoom = createDirectChatRoom(normalUser, targetUser); + persistChatMessage(directRoom, normalUser, "안녕하세요"); + mockLoginUser(normalUser.getId()); + + // when & then + performGet("/chats/rooms/search?keyword=개발&page=1&limit=10") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.roomMatches.totalCount").value(2)) + .andExpect(jsonPath("$.roomMatches.currentCount").value(2)) + .andExpect(jsonPath("$.roomMatches.totalPage").value(1)) + .andExpect(jsonPath("$.roomMatches.currentPage").value(1)) + .andExpect(jsonPath("$.roomMatches.rooms[0].roomName").value("개발팀")) + .andExpect(jsonPath("$.roomMatches.rooms[0].chatType").value("DIRECT")) + .andExpect(jsonPath("$.roomMatches.rooms[1].roomName").value("개발동아리")) + .andExpect(jsonPath("$.roomMatches.rooms[1].chatType").value("GROUP")) + .andExpect(jsonPath("$.messageMatches.totalCount").value(0)) + .andExpect(jsonPath("$.messageMatches.currentCount").value(0)); + } + + @Test + @DisplayName("메시지 검색은 접근 가능한 방에서 방별 최신 매칭 메시지 1개만 반환한다") + void searchChatsReturnsLatestMatchingMessagePerAccessibleRoom() throws Exception { + // given + ChatRoom firstRoom = createDirectChatRoom(normalUser, targetUser); + ChatRoom secondRoom = createDirectChatRoom(normalUser, secondTargetUser); + ChatRoom outsiderRoom = createDirectChatRoom(outsiderUser, targetUser); + + persistChatMessage(firstRoom, normalUser, "첫 번째 키워드"); + persistChatMessage(secondRoom, secondTargetUser, "두 번째 키워드"); + persistChatMessage(outsiderRoom, outsiderUser, "외부 키워드"); + persistChatMessage(firstRoom, targetUser, "최신 키워드"); + mockLoginUser(normalUser.getId()); + + // when & then + performGet("/chats/rooms/search?keyword=키워드&page=1&limit=10") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.roomMatches.totalCount").value(0)) + .andExpect(jsonPath("$.messageMatches.totalCount").value(2)) + .andExpect(jsonPath("$.messageMatches.currentCount").value(2)) + .andExpect(jsonPath("$.messageMatches.messages[0].roomName").value("개발팀")) + .andExpect(jsonPath("$.messageMatches.messages[0].matchedMessage").value("최신 키워드")) + .andExpect(jsonPath("$.messageMatches.messages[1].roomName").value("개발자")) + .andExpect(jsonPath("$.messageMatches.messages[1].matchedMessage").value("두 번째 키워드")); + } + + @Test + @DisplayName("채팅방 검색 결과에 페이지네이션을 적용한다") + void searchChatsAppliesPaginationToRoomMatches() throws Exception { + // given + createDirectChatRoom(normalUser, targetUser); + createDirectChatRoom(normalUser, secondTargetUser); + mockLoginUser(normalUser.getId()); + + // when & then + performGet("/chats/rooms/search?keyword=개발&page=2&limit=1") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.roomMatches.totalCount").value(3)) + .andExpect(jsonPath("$.roomMatches.currentCount").value(1)) + .andExpect(jsonPath("$.roomMatches.totalPage").value(3)) + .andExpect(jsonPath("$.roomMatches.currentPage").value(2)) + .andExpect(jsonPath("$.roomMatches.rooms[0].roomName").value("개발자")); + } + } + @Nested @DisplayName("POST /chats/rooms/{chatRoomId}/mute - 채팅방 뮤트 토글") class ToggleMute { @@ -326,6 +415,25 @@ private User createUser(String name, String studentId) { return persist(UserFixture.createUser(university, name, studentId)); } + private ClubMember createClubMember(Club club, User user) { + Club managedClub = entityManager.getReference(Club.class, club.getId()); + User managedUser = entityManager.getReference(User.class, user.getId()); + ClubMember clubMember = persist(ClubMemberFixture.createMember(managedClub, managedUser)); + clearPersistenceContext(); + return clubMember; + } + + private ChatMessage persistChatMessage(ChatRoom chatRoom, User sender, String content) { + ChatRoom managedChatRoom = entityManager.getReference(ChatRoom.class, chatRoom.getId()); + User managedSender = entityManager.getReference(User.class, sender.getId()); + + ChatMessage chatMessage = persist(ChatMessage.of(managedChatRoom, managedSender, content)); + managedChatRoom.updateLastMessage(chatMessage.getContent(), chatMessage.getCreatedAt()); + entityManager.flush(); + clearPersistenceContext(); + return chatMessage; + } + private long countDirectRoomsBetween(User firstUser, User secondUser) { return chatRoomRepository.findByUserId(firstUser.getId()).stream() .map(ChatRoom::getId) diff --git a/src/test/java/gg/agit/konect/support/IntegrationTestSupport.java b/src/test/java/gg/agit/konect/support/IntegrationTestSupport.java index 7dc95b204..8241fc838 100644 --- a/src/test/java/gg/agit/konect/support/IntegrationTestSupport.java +++ b/src/test/java/gg/agit/konect/support/IntegrationTestSupport.java @@ -23,6 +23,9 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.util.MultiValueMap; +import com.google.api.services.drive.Drive; +import com.google.api.services.sheets.v4.Sheets; +import com.google.auth.oauth2.GoogleCredentials; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.persistence.EntityManager; @@ -71,6 +74,15 @@ public abstract class IntegrationTestSupport { @MockitoBean protected LoggingProperties loggingProperties; + @MockitoBean + protected GoogleCredentials googleCredentials; + + @MockitoBean + protected Sheets googleSheets; + + @MockitoBean + protected Drive googleDrive; + @BeforeEach void setUpCommonMocks() throws Exception { given(loginCheckInterceptor.preHandle(any(), any(), any())).willReturn(true); From dceff67bf8e5dc5ea21667dfca63cf81c123cca0 Mon Sep 17 00:00:00 2001 From: JanooGwan Date: Tue, 31 Mar 2026 21:51:45 +0900 Subject: [PATCH 2/9] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20limit=20=EC=83=81=ED=95=9C=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agit/konect/domain/chat/controller/ChatApi.java | 5 +++++ .../konect/integration/domain/chat/ChatApiTest.java | 13 +++++++++++++ 2 files changed, 18 insertions(+) diff --git a/src/main/java/gg/agit/konect/domain/chat/controller/ChatApi.java b/src/main/java/gg/agit/konect/domain/chat/controller/ChatApi.java index 8e5a41aec..3c770cb0e 100644 --- a/src/main/java/gg/agit/konect/domain/chat/controller/ChatApi.java +++ b/src/main/java/gg/agit/konect/domain/chat/controller/ChatApi.java @@ -20,6 +20,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; @@ -27,6 +28,8 @@ @RequestMapping("/chats") public interface ChatApi { + int MAX_SEARCH_LIMIT = 100; + @Operation(summary = "채팅방을 생성하거나 기존 채팅방을 반환한다.", description = """ ## 설명 - 특정 유저와의 1:1 채팅방을 생성하거나 기존 채팅방을 반환합니다. @@ -86,6 +89,7 @@ ResponseEntity getChatRooms( - 그룹 채팅은 동아리 이름으로 검색합니다. - 메시지 검색 결과는 채팅방별 최신 매칭 메시지 1개만 반환합니다. - page, limit는 채팅방 이름 검색 결과와 메시지 검색 결과에 각각 동일하게 적용됩니다. + - limit는 최대 100까지 허용됩니다. """) @GetMapping("/rooms/search") ResponseEntity searchChats( @@ -94,6 +98,7 @@ ResponseEntity searchChats( @Min(value = 1, message = "페이지 번호는 1 이상이어야 합니다.") @RequestParam(name = "page", defaultValue = "1") Integer page, @Min(value = 1, message = "페이지 당 항목 수는 1 이상이어야 합니다.") + @Max(value = MAX_SEARCH_LIMIT, message = "페이지 당 항목 수는 100 이하여야 합니다.") @RequestParam(name = "limit", defaultValue = "20") Integer limit, @UserId Integer userId ); 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 e00fc7d99..6a29120d5 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 @@ -361,6 +361,19 @@ void searchChatsAppliesPaginationToRoomMatches() throws Exception { .andExpect(jsonPath("$.roomMatches.currentPage").value(2)) .andExpect(jsonPath("$.roomMatches.rooms[0].roomName").value("개발자")); } + + @Test + @DisplayName("limit가 최대값을 초과하면 400을 반환한다") + void searchChatsWithTooLargeLimitFails() throws Exception { + // given + mockLoginUser(normalUser.getId()); + + // when & then + performGet("/chats/rooms/search?keyword=개발&page=1&limit=101") + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_REQUEST_BODY")) + .andExpect(jsonPath("$.fieldErrors[0].field").value("searchChats.limit")); + } } @Nested From 92e6acf1482517e5452b485f7dd31a160b9edf58 Mon Sep 17 00:00:00 2001 From: JanooGwan Date: Tue, 31 Mar 2026 21:52:09 +0900 Subject: [PATCH 3/9] =?UTF-8?q?refactor:=20=EC=B1=84=ED=8C=85=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20=EC=A1=B0=ED=9A=8C=20=ED=8A=B8=EB=9E=9C=EC=9E=AD?= =?UTF-8?q?=EC=85=98=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/gg/agit/konect/domain/chat/service/ChatService.java | 1 - 1 file changed, 1 deletion(-) 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 3efafde1d..0be035133 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 @@ -134,7 +134,6 @@ public ChatRoomsSummaryResponse getChatRooms(Integer userId) { return new ChatRoomsSummaryResponse(getAccessibleChatRooms(userId)); } - @Transactional public ChatSearchResponse searchChats(Integer userId, String keyword, Integer page, Integer limit) { String normalizedKeyword = normalizeKeyword(keyword); List accessibleRooms = getAccessibleChatRooms(userId); From 5c43771a21f23614b986ec1aa8346865c2c73607 Mon Sep 17 00:00:00 2001 From: JanooGwan Date: Tue, 31 Mar 2026 21:52:28 +0900 Subject: [PATCH 4/9] =?UTF-8?q?fix:=20Google=20Sheets=20credentials=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=20=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../infrastructure/googlesheets/GoogleSheetsConfig.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsConfig.java b/src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsConfig.java index 2d3f23742..eb5fed40e 100644 --- a/src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsConfig.java +++ b/src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsConfig.java @@ -87,10 +87,15 @@ public Drive buildUserDriveService(String refreshToken) throws IOException, Gene private InputStream openCredentialsStream() throws IOException { String credentialsPath = googleSheetsProperties.credentialsPath(); - if (credentialsPath != null && credentialsPath.startsWith("classpath:")) { + if (credentialsPath == null || credentialsPath.isBlank()) { + throw new IOException("Google Sheets credentials path is not configured."); + } + + if (credentialsPath.startsWith("classpath:")) { Resource resource = resourceLoader.getResource(credentialsPath); return resource.getInputStream(); } + return new FileInputStream(credentialsPath); } } From 10d7d6e61260e1296ef8c30315ca32f4f7d072d9 Mon Sep 17 00:00:00 2001 From: JanooGwan Date: Tue, 31 Mar 2026 22:11:36 +0900 Subject: [PATCH 5/9] =?UTF-8?q?fix:=20=EC=B1=84=ED=8C=85=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=20=EA=B2=BD?= =?UTF-8?q?=EA=B3=84=20=EB=B3=B4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/gg/agit/konect/domain/chat/service/ChatService.java | 1 + 1 file changed, 1 insertion(+) 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 0be035133..3efafde1d 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 @@ -134,6 +134,7 @@ public ChatRoomsSummaryResponse getChatRooms(Integer userId) { return new ChatRoomsSummaryResponse(getAccessibleChatRooms(userId)); } + @Transactional public ChatSearchResponse searchChats(Integer userId, String keyword, Integer page, Integer limit) { String normalizedKeyword = normalizeKeyword(keyword); List accessibleRooms = getAccessibleChatRooms(userId); From 4f3720aa2e3c8a83958cb433122d8f03a2593358 Mon Sep 17 00:00:00 2001 From: JanooGwan Date: Wed, 1 Apr 2026 00:11:38 +0900 Subject: [PATCH 6/9] =?UTF-8?q?fix:=20=EC=B1=84=ED=8C=85=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20=EA=B0=80=EC=8B=9C=EC=84=B1=20=EB=B0=8F=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=20=EB=A7=A4=EC=B9=AD=20=EB=B3=B4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/chat/controller/ChatApi.java | 4 +- .../repository/ChatMessageRepository.java | 15 +-- .../domain/chat/service/ChatService.java | 96 ++++++++++++++++--- 3 files changed, 87 insertions(+), 28 deletions(-) diff --git a/src/main/java/gg/agit/konect/domain/chat/controller/ChatApi.java b/src/main/java/gg/agit/konect/domain/chat/controller/ChatApi.java index 317d98837..eec5d703c 100644 --- a/src/main/java/gg/agit/konect/domain/chat/controller/ChatApi.java +++ b/src/main/java/gg/agit/konect/domain/chat/controller/ChatApi.java @@ -90,8 +90,8 @@ ResponseEntity getChatRooms( - 채팅방 이름 매칭 결과와 메시지 내용 매칭 결과를 분리해서 반환합니다. ## 로직 - - 1:1 채팅은 상대방 이름으로 검색합니다. - - 그룹 채팅은 동아리 이름으로 검색합니다. + - 1:1 채팅은 상대방 이름과 사용자가 지정한 채팅방 이름으로 검색합니다. + - 그룹 채팅은 동아리 이름과 사용자가 지정한 채팅방 이름으로 검색합니다. - 메시지 검색 결과는 채팅방별 최신 매칭 메시지 1개만 반환합니다. - page, limit는 채팅방 이름 검색 결과와 메시지 검색 결과에 각각 동일하게 적용됩니다. - limit는 최대 100까지 허용됩니다. diff --git a/src/main/java/gg/agit/konect/domain/chat/repository/ChatMessageRepository.java b/src/main/java/gg/agit/konect/domain/chat/repository/ChatMessageRepository.java index e711d0f6d..82551da83 100644 --- a/src/main/java/gg/agit/konect/domain/chat/repository/ChatMessageRepository.java +++ b/src/main/java/gg/agit/konect/domain/chat/repository/ChatMessageRepository.java @@ -125,26 +125,19 @@ SELECT MAX(m2.id) FROM ChatMessage cm JOIN FETCH cm.chatRoom cr WHERE cr.id IN :roomIds - AND LOWER(cm.content) LIKE LOWER(CONCAT('%', :keyword, '%')) + AND LOCATE(LOWER(:keyword), LOWER(cm.content)) > 0 AND cm.id = ( SELECT MAX(innerCm.id) FROM ChatMessage innerCm WHERE innerCm.chatRoom.id = cr.id - AND LOWER(innerCm.content) LIKE LOWER(CONCAT('%', :keyword, '%')) + AND LOCATE(LOWER(:keyword), LOWER(innerCm.content)) > 0 ) ORDER BY cm.createdAt DESC, cm.id DESC - """, - countQuery = """ - SELECT COUNT(DISTINCT cm.chatRoom.id) - FROM ChatMessage cm - WHERE cm.chatRoom.id IN :roomIds - AND LOWER(cm.content) LIKE LOWER(CONCAT('%', :keyword, '%')) """ ) - Page searchLatestMatchingMessagesByChatRoomIds( + List searchLatestMatchingMessagesByChatRoomIds( @Param("roomIds") List roomIds, - @Param("keyword") String keyword, - Pageable pageable + @Param("keyword") String keyword ); @Query(""" 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 5d6eeab5e..2644ade30 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 @@ -160,16 +160,17 @@ public void leaveChatRoom(Integer userId, Integer roomId) { } public ChatRoomsSummaryResponse getChatRooms(Integer userId) { - return new ChatRoomsSummaryResponse(getAccessibleChatRooms(userId)); + return new ChatRoomsSummaryResponse(getAccessibleChatRooms(userId).rooms()); } @Transactional public ChatSearchResponse searchChats(Integer userId, String keyword, Integer page, Integer limit) { String normalizedKeyword = normalizeKeyword(keyword); - List accessibleRooms = getAccessibleChatRooms(userId); - ChatRoomMatchesResponse roomMatches = searchRoomsByName(accessibleRooms, normalizedKeyword, page, limit); + AccessibleChatRooms accessibleChatRooms = getAccessibleChatRooms(userId); + ChatRoomMatchesResponse roomMatches = searchRoomsByName(accessibleChatRooms, normalizedKeyword, page, limit); ChatMessageMatchesResponse messageMatches = searchByMessageContent( - accessibleRooms, + userId, + accessibleChatRooms.rooms(), normalizedKeyword, page, limit @@ -629,7 +630,7 @@ private ChatMessageDetailResponse sendClubMessageByRoomId(Integer roomId, Intege ); } - private List getAccessibleChatRooms(Integer userId) { + private AccessibleChatRooms getAccessibleChatRooms(Integer userId) { chatRoomMembershipService.ensureClubRoomMemberships(userId); List directRooms = getDirectChatRooms(userId); @@ -641,6 +642,7 @@ private List getAccessibleChatRooms(Integer userId) { Map muteMap = getMuteMap(roomIds, userId); Map customRoomNameMap = getCustomRoomNameMap(roomIds, userId); + Map defaultRoomNameMap = getDefaultRoomNameMap(directRooms, clubRooms); List rooms = new ArrayList<>(); directRooms.forEach(room -> rooms.add(applyRoomSettings(room, muteMap, customRoomNameMap))); clubRooms.forEach(room -> rooms.add(applyRoomSettings(room, muteMap, customRoomNameMap))); @@ -649,7 +651,7 @@ private List getAccessibleChatRooms(Integer userId) { Comparator.comparing(ChatRoomSummaryResponse::lastSentAt, Comparator.nullsLast(Comparator.reverseOrder())) .thenComparing(ChatRoomSummaryResponse::roomId) ); - return rooms; + return new AccessibleChatRooms(rooms, defaultRoomNameMap); } private ChatRoomSummaryResponse applyRoomSettings( @@ -670,25 +672,26 @@ private ChatRoomSummaryResponse applyRoomSettings( } private ChatRoomMatchesResponse searchRoomsByName( - List accessibleRooms, + AccessibleChatRooms accessibleChatRooms, String keyword, Integer page, Integer limit ) { - List matchedRooms = accessibleRooms.stream() - .filter(room -> containsKeyword(room.roomName(), keyword)) + 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()) { + if (accessibleRooms.isEmpty() || keyword.isBlank()) { return ChatMessageMatchesResponse.from(emptyPage(page, limit)); } @@ -697,12 +700,16 @@ private ChatMessageMatchesResponse searchByMessageContent( List roomIds = accessibleRooms.stream() .map(ChatRoomSummaryResponse::roomId) .toList(); + Map visibleMessageFromMap = getVisibleMessageFromMap(roomIds, userId); - Page matchedMessages = chatMessageRepository - .searchLatestMatchingMessagesByChatRoomIds(roomIds, keyword, PageRequest.of(page - 1, limit)) - .map(message -> ChatMessageMatchResult.from(roomMap.get(message.getChatRoom().getId()), message)); + 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(matchedMessages); + return ChatMessageMatchesResponse.from(toPage(matchedMessages, page, limit)); } private String normalizeKeyword(String keyword) { @@ -722,7 +729,12 @@ private boolean containsKeyword(String text, String keyword) { private Page toPage(List items, Integer page, Integer limit) { PageRequest pageable = PageRequest.of(page - 1, limit); - int fromIndex = Math.min((page - 1) * limit, items.size()); + 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()); } @@ -750,6 +762,28 @@ private Map getMuteMap(List roomIds, Integer userId) return muteMap; } + 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 Map getCustomRoomNameMap(List roomIds, Integer userId) { if (roomIds.isEmpty()) { return Map.of(); @@ -764,6 +798,38 @@ private String resolveRoomName(Integer roomId, String defaultRoomName, 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 record AccessibleChatRooms( + List rooms, + Map defaultRoomNameMap + ) { + } + private ChatRoom getDirectRoom(Integer roomId) { ChatRoom chatRoom = chatRoomRepository.findById(roomId) .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); From 544193f97f5006545fa3ce6cb6629df24294fa73 Mon Sep 17 00:00:00 2001 From: JanooGwan Date: Wed, 1 Apr 2026 00:12:38 +0900 Subject: [PATCH 7/9] =?UTF-8?q?test:=20=EC=B1=84=ED=8C=85=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20=ED=9A=8C=EA=B7=80=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../integration/domain/chat/ChatApiTest.java | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) 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 c7e34d3fc..e31174f52 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 @@ -754,6 +754,8 @@ void setUpSearchFixture() { void searchChatsReturnsRoomMatchesForDirectAndGroupRooms() throws Exception { // given ChatRoom directRoom = createDirectChatRoom(normalUser, targetUser); + ChatRoom groupRoom = persist(ChatRoom.groupOf(developmentClub)); + addRoomMember(groupRoom, normalUser); persistChatMessage(directRoom, normalUser, "안녕하세요"); mockLoginUser(normalUser.getId()); @@ -798,12 +800,84 @@ void searchChatsReturnsLatestMatchingMessagePerAccessibleRoom() throws Exception .andExpect(jsonPath("$.messageMatches.messages[1].matchedMessage").value("두 번째 키워드")); } + @Test + @DisplayName("나간 1:1 채팅방의 숨김 메시지는 검색 결과에 노출되지 않는다") + void searchChatsExcludesHiddenMessagesFromLeftDirectRoom() throws Exception { + // given + ChatRoom directRoom = createDirectChatRoom(normalUser, targetUser); + + mockLoginUser(normalUser.getId()); + performPost("/chats/rooms/" + directRoom.getId() + "/messages", new ChatMessageSendRequest("비밀 키워드")) + .andExpect(status().isOk()); + performDelete("/chats/rooms/" + directRoom.getId()) + .andExpect(status().isNoContent()); + + mockLoginUser(targetUser.getId()); + performPost("/chats/rooms/" + directRoom.getId() + "/messages", new ChatMessageSendRequest("다시 안녕")) + .andExpect(status().isOk()); + + mockLoginUser(normalUser.getId()); + + // when & then + performGet("/chats/rooms/search?keyword=비밀&page=1&limit=10") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.roomMatches.totalCount").value(0)) + .andExpect(jsonPath("$.messageMatches.totalCount").value(0)) + .andExpect(jsonPath("$.messageMatches.currentCount").value(0)); + } + + @Test + @DisplayName("메시지 검색은 LIKE 특수문자를 리터럴로 처리한다") + void searchChatsTreatsLikeSpecialCharactersAsLiteral() throws Exception { + // given + ChatRoom firstRoom = createDirectChatRoom(normalUser, targetUser); + ChatRoom secondRoom = createDirectChatRoom(normalUser, secondTargetUser); + + persistChatMessage(firstRoom, normalUser, "100% 완료"); + persistChatMessage(secondRoom, secondTargetUser, "1000 완료"); + mockLoginUser(normalUser.getId()); + LinkedMultiValueMap params = new LinkedMultiValueMap<>(Map.of( + "keyword", List.of("100%"), + "page", List.of("1"), + "limit", List.of("10") + )); + + // when & then + performGet("/chats/rooms/search", params) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.roomMatches.totalCount").value(0)) + .andExpect(jsonPath("$.messageMatches.totalCount").value(1)) + .andExpect(jsonPath("$.messageMatches.currentCount").value(1)) + .andExpect(jsonPath("$.messageMatches.messages[0].roomName").value("개발팀")) + .andExpect(jsonPath("$.messageMatches.messages[0].matchedMessage").value("100% 완료")); + } + + @Test + @DisplayName("커스텀 채팅방 이름이 있어도 기본 상대방 이름으로 검색할 수 있다") + void searchChatsMatchesDefaultNameEvenWithCustomRoomName() throws Exception { + // given + ChatRoom directRoom = createDirectChatRoom(normalUser, targetUser); + mockLoginUser(normalUser.getId()); + performPatch("/chats/rooms/" + directRoom.getId() + "/name", new ChatRoomNameUpdateRequest("내 메모")) + .andExpect(status().isOk()); + + // when & then + performGet("/chats/rooms/search?keyword=개발팀&page=1&limit=10") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.roomMatches.totalCount").value(1)) + .andExpect(jsonPath("$.roomMatches.currentCount").value(1)) + .andExpect(jsonPath("$.roomMatches.rooms[0].roomId").value(directRoom.getId())) + .andExpect(jsonPath("$.roomMatches.rooms[0].roomName").value("내 메모")); + } + @Test @DisplayName("채팅방 검색 결과에 페이지네이션을 적용한다") void searchChatsAppliesPaginationToRoomMatches() throws Exception { // given createDirectChatRoom(normalUser, targetUser); createDirectChatRoom(normalUser, secondTargetUser); + ChatRoom groupRoom = persist(ChatRoom.groupOf(developmentClub)); + addRoomMember(groupRoom, normalUser); mockLoginUser(normalUser.getId()); // when & then @@ -816,6 +890,23 @@ void searchChatsAppliesPaginationToRoomMatches() throws Exception { .andExpect(jsonPath("$.roomMatches.rooms[0].roomName").value("개발자")); } + @Test + @DisplayName("매우 큰 page 값이어도 빈 검색 결과를 안전하게 반환한다") + void searchChatsWithVeryLargePageReturnsEmptyResult() throws Exception { + // given + createDirectChatRoom(normalUser, targetUser); + ChatRoom groupRoom = persist(ChatRoom.groupOf(developmentClub)); + addRoomMember(groupRoom, normalUser); + mockLoginUser(normalUser.getId()); + + // when & then + performGet("/chats/rooms/search?keyword=개발&page=2147483647&limit=100") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.roomMatches.totalCount").value(2)) + .andExpect(jsonPath("$.roomMatches.currentCount").value(0)) + .andExpect(jsonPath("$.roomMatches.currentPage").value(2147483647)); + } + @Test @DisplayName("limit가 최대값을 초과하면 400을 반환한다") void searchChatsWithTooLargeLimitFails() throws Exception { From 5f2407028e92b634a0bf4cc4278316d0a74cfe23 Mon Sep 17 00:00:00 2001 From: JanooGwan Date: Wed, 1 Apr 2026 00:42:43 +0900 Subject: [PATCH 8/9] =?UTF-8?q?refactor:=20=EC=B1=84=ED=8C=85=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20=EC=A1=B0=ED=9A=8C=20=ED=8A=B8=EB=9E=9C=EC=9E=AD?= =?UTF-8?q?=EC=85=98=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/gg/agit/konect/domain/chat/service/ChatService.java | 1 - 1 file changed, 1 deletion(-) 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 2644ade30..40b49dd57 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 @@ -163,7 +163,6 @@ public ChatRoomsSummaryResponse getChatRooms(Integer userId) { return new ChatRoomsSummaryResponse(getAccessibleChatRooms(userId).rooms()); } - @Transactional public ChatSearchResponse searchChats(Integer userId, String keyword, Integer page, Integer limit) { String normalizedKeyword = normalizeKeyword(keyword); AccessibleChatRooms accessibleChatRooms = getAccessibleChatRooms(userId); From f69dd835819fbea4d0a941ed289ef1f28b460a64 Mon Sep 17 00:00:00 2001 From: JanooGwan Date: Wed, 1 Apr 2026 01:22:39 +0900 Subject: [PATCH 9/9] =?UTF-8?q?refactor:=20=EC=B1=84=ED=8C=85=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20direct=20room=20=EC=A1=B0=ED=9A=8C=20=EB=B2=94?= =?UTF-8?q?=EC=9C=84=20=EC=B6=95=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gg/agit/konect/domain/chat/service/ChatService.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 40b49dd57..00dfb8477 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 @@ -699,7 +699,11 @@ private ChatMessageMatchesResponse searchByMessageContent( List roomIds = accessibleRooms.stream() .map(ChatRoomSummaryResponse::roomId) .toList(); - Map visibleMessageFromMap = getVisibleMessageFromMap(roomIds, userId); + 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)