From e944770f0c82fa63f7b7454cc0af58f788a24b30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Wed, 15 Apr 2026 11:47:09 +0900 Subject: [PATCH 01/50] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85=EB=B0=A9=20?= =?UTF-8?q?=EB=A9=A4=EB=B2=84=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?API=20=EC=B6=94=EA=B0=80=20(#549)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 채팅방 멤버 조회 응답 DTO 추가 - ChatRoomMemberResponse: 개별 멤버 정보 (userId, name, imageUrl, isOwner, joinedAt) - ChatRoomMembersResponse: 멤버 목록 래퍼 클라이언트가 채팅방 참여자 정보를 확인할 수 있도록 응답 객체 정의 * feat: 활성 멤버 조회 Repository 메서드 추가 - findActiveMembersByChatRoomId: 채팅방 ID로 활성 멤버 조회 - JOIN FETCH로 User 정보를 한 번에 로딩 (N+1 방지) - leftAt IS NULL 조건으로 나간 멤버 필터링 * feat: 채팅방 멤버 조회 Service 로직 구현 - getChatRoomMembers: 멤버 목록 조회 비즈니스 로직 - validateMembership: existsByChatRoomIdAndUserId로 권한 검증 - toMemberResponse: Entity → DTO 변환 (createdAt을 joinedAt으로 매핑) 비멤버 접근 시 FORBIDDEN_CHAT_ROOM_ACCESS 예외 발생 * feat: 채팅방 멤버 조회 API 엔드포인트 추가 - GET /chats/rooms/{chatRoomId}/members 엔드포인트 추가 - ChatApi 인터페이스: Swagger 문서화 (설명, 에러 코드) - ChatController 구현체: @UserId로 사용자 ID 주입 채팅방 접속 시 해당 방의 멤버 목록을 조회할 수 있는 API 제공 * test: 채팅방 멤버 조회 Service 단위 테스트 추가 - 멤버 조회 성공 케이스 검증 - 비멤버 접근 시 FORBIDDEN 예외 발생 검증 - 나간 멤버는 조회되지 않음 검증 Mockito를 사용하여 Repository 의존성 모킹 * refactor: 불필요한 주석과 import 제거 - Service: WHAT 설명 주석 제거 (메서드명이 충분히 표현) - Service: 코드 변경 이력 주석 제거 - Test: 사용되지 않는 any import 제거 코드 리뷰 피드백 반영하여 가독성 개선 * feat: 탈퇴한 유저 필터링 추가 - getChatRoomMembers: 탈퇴한 유저(deletedAt != null) 제외 - Stream filter로 deletedAt == null인 멤버만 변환 탈퇴한 사용자는 채팅방 멤버 목록에 노출되지 않음 * feat: 어드민 시스템 어드민 방 멤버 조회 권한 추가 - getChatRoomMembers: 현재 사용자 정보를 UserRepository에서 조회 - validateMembership: 어드민이 시스템 어드민 방 조회 시 권한 검증 스킵 - NOT_FOUND_USER import 추가 - 테스트: UserRepository mock 설정 추가 어드민은 시스템 어드민 방의 멤버 목록을 조회할 수 있음 * fix: 존재하지 않는 채팅방 조회 시 404 반환 - getChatRoomMembers: 채팅방 존재 여부를 멤버십 검증 전에 확인 - 존재하지 않는 채팅방: NOT_FOUND_CHAT_ROOM (404) - 비멤버 접근: FORBIDDEN_CHAT_ROOM_ACCESS (403) - validateMembership 파라미터를 ChatRoom으로 변경 - 테스트: ChatRoomRepository mock 설정 추가 잘못된 순서로 인해 존재하지 않는 방도 403이 뜨던 문제 해결 * chore: 코드 포맷팅 * fix: 나간 멤버 권한 검증 로직 수정 - existsActiveByChatRoomIdAndUserId 메서드 추가 (leftAt IS NULL 조건) - validateMembership에서 활성 멤버만 조회 가능하도록 변경 - 나간 멤버 접근 시 FORBIDDEN 예외 발생 테스트 추가 나간 멤버가 여전히 멤버로 인식되는 문제 해결 --- .../domain/chat/controller/ChatApi.java | 21 ++ .../chat/controller/ChatController.java | 13 ++ .../chat/dto/ChatRoomMemberResponse.java | 12 ++ .../chat/dto/ChatRoomMembersResponse.java | 8 + .../repository/ChatRoomMemberRepository.java | 21 ++ .../service/ChatRoomMembershipService.java | 43 ++++ .../ChatRoomMembershipServiceTest.java | 203 ++++++++++++++++++ 7 files changed, 321 insertions(+) create mode 100644 src/main/java/gg/agit/konect/domain/chat/dto/ChatRoomMemberResponse.java create mode 100644 src/main/java/gg/agit/konect/domain/chat/dto/ChatRoomMembersResponse.java create mode 100644 src/test/java/gg/agit/konect/unit/domain/chat/service/ChatRoomMembershipServiceTest.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 b42c87be9..2dd9c3488 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 @@ -16,6 +16,7 @@ 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.ChatRoomMembersResponse; import gg.agit.konect.domain.chat.dto.ChatRoomNameUpdateRequest; import gg.agit.konect.domain.chat.dto.ChatRoomResponse; import gg.agit.konect.domain.chat.dto.ChatRoomsSummaryResponse; @@ -23,6 +24,7 @@ import gg.agit.konect.domain.chat.enums.ChatInviteSortBy; import gg.agit.konect.global.auth.annotation.UserId; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import jakarta.validation.constraints.Max; @@ -267,4 +269,23 @@ ResponseEntity createGroupChatRoom( @Valid @RequestBody ChatRoomCreateRequest.Group request, @UserId Integer userId ); + + @Operation(summary = "채팅방 멤버 목록 조회", description = """ + ## 설명 + - 특정 채팅방의 모든 멤버 목록을 조회합니다. + + ## 로직 + - 채팅방에 참여 중인 멤버만 조회할 수 있습니다. + - 나간 멤버(leftAt이 설정된 멤버)는 목록에 포함되지 않습니다. + - 각 멤버의 userId, 이름, 프로필 이미지, 방장 여부, 참여 시간을 반환합니다. + + ## 에러 + - FORBIDDEN_CHAT_ROOM_ACCESS (403): 채팅방에 접근할 권한이 없습니다. + - NOT_FOUND_CHAT_ROOM (404): 채팅방을 찾을 수 없습니다. + """) + @GetMapping("/rooms/{chatRoomId}/members") + ResponseEntity getChatRoomMembers( + @Parameter(description = "채팅방 ID") @PathVariable Integer chatRoomId, + @UserId Integer userId + ); } 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 844a25f53..10a18059f 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 @@ -2,6 +2,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -14,11 +15,13 @@ 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.ChatRoomMembersResponse; import gg.agit.konect.domain.chat.dto.ChatRoomNameUpdateRequest; 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.enums.ChatInviteSortBy; +import gg.agit.konect.domain.chat.service.ChatRoomMembershipService; import gg.agit.konect.domain.chat.service.ChatService; import gg.agit.konect.global.auth.annotation.UserId; import jakarta.validation.Valid; @@ -31,6 +34,7 @@ public class ChatController implements ChatApi { private final ChatService chatService; + private final ChatRoomMembershipService chatRoomMembershipService; @Override public ResponseEntity createOrGetChatRoom( @@ -147,4 +151,13 @@ public ResponseEntity createGroupChatRoom( ChatRoomResponse response = chatService.createGroupChatRoom(userId, request); return ResponseEntity.ok(response); } + + @Override + @GetMapping("/rooms/{chatRoomId}/members") + public ResponseEntity getChatRoomMembers( + @PathVariable Integer chatRoomId, + @UserId Integer userId) { + ChatRoomMembersResponse response = chatRoomMembershipService.getChatRoomMembers(chatRoomId, userId); + return ResponseEntity.ok(response); + } } diff --git a/src/main/java/gg/agit/konect/domain/chat/dto/ChatRoomMemberResponse.java b/src/main/java/gg/agit/konect/domain/chat/dto/ChatRoomMemberResponse.java new file mode 100644 index 000000000..7a1fc1309 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/chat/dto/ChatRoomMemberResponse.java @@ -0,0 +1,12 @@ +package gg.agit.konect.domain.chat.dto; + +import java.time.LocalDateTime; + +public record ChatRoomMemberResponse( + Integer userId, + String name, + String profileImageUrl, + boolean isOwner, + LocalDateTime joinedAt +) { +} diff --git a/src/main/java/gg/agit/konect/domain/chat/dto/ChatRoomMembersResponse.java b/src/main/java/gg/agit/konect/domain/chat/dto/ChatRoomMembersResponse.java new file mode 100644 index 000000000..3534d197c --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/chat/dto/ChatRoomMembersResponse.java @@ -0,0 +1,8 @@ +package gg.agit.konect.domain.chat.dto; + +import java.util.List; + +public record ChatRoomMembersResponse( + List members +) { +} diff --git a/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomMemberRepository.java b/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomMemberRepository.java index 1b7252648..05135e668 100644 --- a/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomMemberRepository.java +++ b/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomMemberRepository.java @@ -120,6 +120,27 @@ List countUnreadByRoomIdsAndUserId( @Param("userId") Integer userId ); + @Query(""" + SELECT COUNT(crm) > 0 + FROM ChatRoomMember crm + WHERE crm.id.chatRoomId = :chatRoomId + AND crm.id.userId = :userId + AND crm.leftAt IS NULL + """) + boolean existsActiveByChatRoomIdAndUserId( + @Param("chatRoomId") Integer chatRoomId, + @Param("userId") Integer userId + ); + + @Query(""" + SELECT crm + FROM ChatRoomMember crm + JOIN FETCH crm.user + WHERE crm.id.chatRoomId = :chatRoomId + AND crm.leftAt IS NULL + """) + List findActiveMembersByChatRoomId(@Param("chatRoomId") Integer chatRoomId); + List saveAll(Iterable chatRoomMembers); } diff --git a/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMembershipService.java b/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMembershipService.java index 3dd25d68c..8d8687c36 100644 --- a/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMembershipService.java +++ b/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMembershipService.java @@ -2,6 +2,7 @@ import static gg.agit.konect.global.code.ApiResponseCode.FORBIDDEN_CHAT_ROOM_ACCESS; import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_CHAT_ROOM; +import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_USER; import java.time.LocalDateTime; import java.util.List; @@ -15,6 +16,8 @@ import gg.agit.konect.domain.chat.model.ChatRoom; import gg.agit.konect.domain.chat.model.ChatRoomMember; +import gg.agit.konect.domain.chat.dto.ChatRoomMemberResponse; +import gg.agit.konect.domain.chat.dto.ChatRoomMembersResponse; import gg.agit.konect.domain.chat.repository.ChatRoomMemberRepository; import gg.agit.konect.domain.chat.repository.ChatRoomRepository; import gg.agit.konect.domain.club.model.Club; @@ -39,6 +42,46 @@ public class ChatRoomMembershipService { private final ClubMemberRepository clubMemberRepository; private final UserRepository userRepository; + @Transactional(readOnly = true) + public ChatRoomMembersResponse getChatRoomMembers(Integer chatRoomId, Integer currentUserId) { + User currentUser = userRepository.findById(currentUserId) + .orElseThrow(() -> CustomException.of(NOT_FOUND_USER)); + + // 채팅방 존재 여부 먼저 확인 + ChatRoom chatRoom = chatRoomRepository.findById(chatRoomId) + .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); + + validateMembership(chatRoom, currentUser); + + List members = chatRoomMemberRepository.findActiveMembersByChatRoomId(chatRoomId); + + return new ChatRoomMembersResponse(members.stream() + .filter(member -> member.getUser().getDeletedAt() == null) + .map(this::toMemberResponse) + .toList()); + } + + private void validateMembership(ChatRoom chatRoom, User currentUser) { + // 어드민은 시스템 어드민 방의 멤버를 조회할 수 있음 + if (currentUser.isAdmin() && isSystemAdminRoom(chatRoom.getId())) { + return; + } + + if (!chatRoomMemberRepository.existsActiveByChatRoomIdAndUserId(chatRoom.getId(), currentUser.getId())) { + throw CustomException.of(FORBIDDEN_CHAT_ROOM_ACCESS); + } + } + + private ChatRoomMemberResponse toMemberResponse(ChatRoomMember member) { + return new ChatRoomMemberResponse( + member.getUser().getId(), + member.getUser().getName(), + member.getUser().getImageUrl(), + member.isOwner(), + member.getCreatedAt() + ); + } + @Transactional public void addClubMember(ClubMember clubMember) { LocalDateTime baseline = Objects.requireNonNull(clubMember.getCreatedAt()); diff --git a/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatRoomMembershipServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatRoomMembershipServiceTest.java new file mode 100644 index 000000000..2726fdefd --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatRoomMembershipServiceTest.java @@ -0,0 +1,203 @@ +package gg.agit.konect.unit.domain.chat.service; + +import static gg.agit.konect.global.code.ApiResponseCode.FORBIDDEN_CHAT_ROOM_ACCESS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; + +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import gg.agit.konect.domain.chat.dto.ChatRoomMemberResponse; +import gg.agit.konect.domain.chat.dto.ChatRoomMembersResponse; +import gg.agit.konect.domain.chat.model.ChatRoom; +import gg.agit.konect.domain.chat.model.ChatRoomMember; +import gg.agit.konect.domain.chat.repository.ChatRoomMemberRepository; +import gg.agit.konect.domain.chat.repository.ChatRoomRepository; +import gg.agit.konect.domain.chat.service.ChatRoomMembershipService; +import gg.agit.konect.domain.club.repository.ClubMemberRepository; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.domain.user.repository.UserRepository; +import gg.agit.konect.global.exception.CustomException; + +@ExtendWith(MockitoExtension.class) +class ChatRoomMembershipServiceTest { + + @Mock + private ChatRoomRepository chatRoomRepository; + + @Mock + private ChatRoomMemberRepository chatRoomMemberRepository; + + @Mock + private ClubMemberRepository clubMemberRepository; + + @Mock + private UserRepository userRepository; + + @InjectMocks + private ChatRoomMembershipService chatRoomMembershipService; + + @Test + @DisplayName("채팅방 멤버 목록 조회 성공") + void getChatRoomMembers_success() { + // given + Integer chatRoomId = 1; + Integer currentUserId = 100; + + User user1 = User.builder() + .id(100) + .name("User1") + .imageUrl("image1.jpg") + .build(); + + User user2 = User.builder() + .id(200) + .name("User2") + .imageUrl("image2.jpg") + .build(); + + ChatRoom chatRoom = ChatRoom.builder() + .id(chatRoomId) + .build(); + + ChatRoomMember member1 = ChatRoomMember.ofOwner(chatRoom, user1, LocalDateTime.now()); + ChatRoomMember member2 = ChatRoomMember.of(chatRoom, user2, LocalDateTime.now()); + + given(userRepository.findById(currentUserId)) + .willReturn(java.util.Optional.of(user1)); + given(chatRoomRepository.findById(chatRoomId)) + .willReturn(java.util.Optional.of(chatRoom)); + given(chatRoomMemberRepository.existsActiveByChatRoomIdAndUserId(chatRoomId, currentUserId)) + .willReturn(true); + given(chatRoomMemberRepository.findActiveMembersByChatRoomId(chatRoomId)) + .willReturn(List.of(member1, member2)); + + // when + ChatRoomMembersResponse response = chatRoomMembershipService.getChatRoomMembers(chatRoomId, currentUserId); + + // then + assertThat(response.members()).hasSize(2); + + ChatRoomMemberResponse firstMember = response.members().get(0); + assertThat(firstMember.userId()).isEqualTo(100); + assertThat(firstMember.name()).isEqualTo("User1"); + assertThat(firstMember.isOwner()).isTrue(); + + ChatRoomMemberResponse secondMember = response.members().get(1); + assertThat(secondMember.userId()).isEqualTo(200); + assertThat(secondMember.name()).isEqualTo("User2"); + assertThat(secondMember.isOwner()).isFalse(); + } + + @Test + @DisplayName("비멤버가 조회 시도 시 FORBIDDEN 예외 발생") + void getChatRoomMembers_forbiddenWhenNotMember() { + // given + Integer chatRoomId = 1; + Integer currentUserId = 100; + + User user1 = User.builder() + .id(100) + .name("User1") + .imageUrl("image1.jpg") + .build(); + + ChatRoom chatRoom = ChatRoom.builder() + .id(chatRoomId) + .build(); + + given(userRepository.findById(currentUserId)) + .willReturn(java.util.Optional.of(user1)); + given(chatRoomRepository.findById(chatRoomId)) + .willReturn(java.util.Optional.of(chatRoom)); + given(chatRoomMemberRepository.existsActiveByChatRoomIdAndUserId(chatRoomId, currentUserId)) + .willReturn(false); + + // when & then + assertThatThrownBy(() -> chatRoomMembershipService.getChatRoomMembers(chatRoomId, currentUserId)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", FORBIDDEN_CHAT_ROOM_ACCESS); + } + + @Test + @DisplayName("나간 멤버가 조회 시도 시 FORBIDDEN 예외 발생") + void getChatRoomMembers_forbiddenWhenLeftMember() { + // given + Integer chatRoomId = 1; + Integer currentUserId = 100; + + User user1 = User.builder() + .id(100) + .name("User1") + .imageUrl("image1.jpg") + .build(); + + ChatRoom chatRoom = ChatRoom.builder() + .id(chatRoomId) + .build(); + + given(userRepository.findById(currentUserId)) + .willReturn(java.util.Optional.of(user1)); + given(chatRoomRepository.findById(chatRoomId)) + .willReturn(java.util.Optional.of(chatRoom)); + given(chatRoomMemberRepository.existsActiveByChatRoomIdAndUserId(chatRoomId, currentUserId)) + .willReturn(false); // 나간 멤버는 활성 멤버가 아님 + + // when & then + assertThatThrownBy(() -> chatRoomMembershipService.getChatRoomMembers(chatRoomId, currentUserId)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", FORBIDDEN_CHAT_ROOM_ACCESS); + } + + @Test + @DisplayName("나간 멤버는 조회되지 않음") + void getChatRoomMembers_excludesLeftMembers() { + // given + Integer chatRoomId = 1; + Integer currentUserId = 100; + + User user1 = User.builder() + .id(100) + .name("User1") + .imageUrl("image1.jpg") + .build(); + + User user2 = User.builder() + .id(200) + .name("User2") + .imageUrl("image2.jpg") + .build(); + + ChatRoom chatRoom = ChatRoom.builder() + .id(chatRoomId) + .build(); + + ChatRoomMember member1 = ChatRoomMember.of(chatRoom, user1, LocalDateTime.now()); + ChatRoomMember member2 = ChatRoomMember.of(chatRoom, user2, LocalDateTime.now()); + member2.leaveDirectRoom(LocalDateTime.now()); // member2는 나간 상태 + + given(userRepository.findById(currentUserId)) + .willReturn(java.util.Optional.of(user1)); + given(chatRoomRepository.findById(chatRoomId)) + .willReturn(java.util.Optional.of(chatRoom)); + given(chatRoomMemberRepository.existsActiveByChatRoomIdAndUserId(chatRoomId, currentUserId)) + .willReturn(true); + given(chatRoomMemberRepository.findActiveMembersByChatRoomId(chatRoomId)) + .willReturn(List.of(member1)); // 나간 멤버는 조회되지 않음 + + // when + ChatRoomMembersResponse response = chatRoomMembershipService.getChatRoomMembers(chatRoomId, currentUserId); + + // then + assertThat(response.members()).hasSize(1); + assertThat(response.members().get(0).userId()).isEqualTo(100); + } +} From 4441d1e1ec55f6140cf48017b40ec3e36c94f9a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Wed, 15 Apr 2026 12:15:24 +0900 Subject: [PATCH 02/50] =?UTF-8?q?test:=20ChatRoomMembershipService=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BB=A4=EB=B2=84=EB=A6=AC?= =?UTF-8?q?=EC=A7=80=20=EB=B3=B4=EA=B0=95=20(#551)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: ChatRoomMembershipService 테스트 커버리지 보강 - 멤버십 서비스의 public 메서드별 정상·예외 분기를 테스트로 고정해 회귀를 막는다 - 중복 생성과 접근 권한 같은 비정상 흐름도 함께 검증해 실제 서비스 로직의 의도를 드러낸다 - lastReadAt 갱신 기준과 system-admin 예외 경로를 명시적으로 확인해 경계 조건 누락을 방지한다 * chore: 코드 포맷팅 * chore: 코드 포맷팅 * test: 리뷰 코멘트의 primitive 타입 제안을 반영 - null 가능성이 없는 테스트 지역 변수는 primitive 타입으로 정리해 불필요한 boxing 경고를 없앤다 - roomId 선언을 int로 통일해 정적 분석 코멘트를 반영하면서 기존 테스트 동작은 그대로 유지한다 - 대상 테스트를 다시 실행해 리뷰 반영 이후에도 병합된 테스트 파일이 정상 동작함을 확인한다 --- .../ChatRoomMembershipServiceTest.java | 505 ++++++++++++++---- 1 file changed, 408 insertions(+), 97 deletions(-) diff --git a/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatRoomMembershipServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatRoomMembershipServiceTest.java index 2726fdefd..ff999145f 100644 --- a/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatRoomMembershipServiceTest.java +++ b/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatRoomMembershipServiceTest.java @@ -1,34 +1,52 @@ package gg.agit.konect.unit.domain.chat.service; +import static gg.agit.konect.domain.chat.service.ChatRoomMembershipService.SYSTEM_ADMIN_ID; import static gg.agit.konect.global.code.ApiResponseCode.FORBIDDEN_CHAT_ROOM_ACCESS; +import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_CHAT_ROOM; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import java.time.LocalDateTime; import java.util.List; +import java.util.Optional; +import org.assertj.core.api.ThrowableAssert.ThrowingCallable; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.test.util.ReflectionTestUtils; import gg.agit.konect.domain.chat.dto.ChatRoomMemberResponse; import gg.agit.konect.domain.chat.dto.ChatRoomMembersResponse; +import gg.agit.konect.domain.chat.enums.ChatType; import gg.agit.konect.domain.chat.model.ChatRoom; import gg.agit.konect.domain.chat.model.ChatRoomMember; import gg.agit.konect.domain.chat.repository.ChatRoomMemberRepository; import gg.agit.konect.domain.chat.repository.ChatRoomRepository; import gg.agit.konect.domain.chat.service.ChatRoomMembershipService; +import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.club.model.ClubMember; import gg.agit.konect.domain.club.repository.ClubMemberRepository; +import gg.agit.konect.domain.user.enums.UserRole; import gg.agit.konect.domain.user.model.User; import gg.agit.konect.domain.user.repository.UserRepository; +import gg.agit.konect.global.code.ApiResponseCode; import gg.agit.konect.global.exception.CustomException; +import gg.agit.konect.support.ServiceTestSupport; +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; -@ExtendWith(MockitoExtension.class) -class ChatRoomMembershipServiceTest { +class ChatRoomMembershipServiceTest extends ServiceTestSupport { @Mock private ChatRoomRepository chatRoomRepository; @@ -52,33 +70,16 @@ void getChatRoomMembers_success() { Integer chatRoomId = 1; Integer currentUserId = 100; - User user1 = User.builder() - .id(100) - .name("User1") - .imageUrl("image1.jpg") - .build(); - - User user2 = User.builder() - .id(200) - .name("User2") - .imageUrl("image2.jpg") - .build(); - - ChatRoom chatRoom = ChatRoom.builder() - .id(chatRoomId) - .build(); - - ChatRoomMember member1 = ChatRoomMember.ofOwner(chatRoom, user1, LocalDateTime.now()); - ChatRoomMember member2 = ChatRoomMember.of(chatRoom, user2, LocalDateTime.now()); - - given(userRepository.findById(currentUserId)) - .willReturn(java.util.Optional.of(user1)); - given(chatRoomRepository.findById(chatRoomId)) - .willReturn(java.util.Optional.of(chatRoom)); - given(chatRoomMemberRepository.existsActiveByChatRoomIdAndUserId(chatRoomId, currentUserId)) - .willReturn(true); - given(chatRoomMemberRepository.findActiveMembersByChatRoomId(chatRoomId)) - .willReturn(List.of(member1, member2)); + User user1 = createUser(currentUserId, "User1", UserRole.USER); + User user2 = createUser(200, "User2", UserRole.USER); + ChatRoom chatRoom = createRoom(chatRoomId, ChatType.GROUP, LocalDateTime.now()); + ChatRoomMember member1 = createRoomMember(chatRoom, user1, true, LocalDateTime.now()); + ChatRoomMember member2 = createRoomMember(chatRoom, user2, false, LocalDateTime.now()); + + given(userRepository.findById(currentUserId)).willReturn(Optional.of(user1)); + given(chatRoomRepository.findById(chatRoomId)).willReturn(Optional.of(chatRoom)); + given(chatRoomMemberRepository.existsActiveByChatRoomIdAndUserId(chatRoomId, currentUserId)).willReturn(true); + given(chatRoomMemberRepository.findActiveMembersByChatRoomId(chatRoomId)).willReturn(List.of(member1, member2)); // when ChatRoomMembersResponse response = chatRoomMembershipService.getChatRoomMembers(chatRoomId, currentUserId); @@ -103,28 +104,18 @@ void getChatRoomMembers_forbiddenWhenNotMember() { // given Integer chatRoomId = 1; Integer currentUserId = 100; + User user = createUser(currentUserId, "User1", UserRole.USER); + ChatRoom chatRoom = createRoom(chatRoomId, ChatType.GROUP, LocalDateTime.now()); - User user1 = User.builder() - .id(100) - .name("User1") - .imageUrl("image1.jpg") - .build(); - - ChatRoom chatRoom = ChatRoom.builder() - .id(chatRoomId) - .build(); - - given(userRepository.findById(currentUserId)) - .willReturn(java.util.Optional.of(user1)); - given(chatRoomRepository.findById(chatRoomId)) - .willReturn(java.util.Optional.of(chatRoom)); - given(chatRoomMemberRepository.existsActiveByChatRoomIdAndUserId(chatRoomId, currentUserId)) - .willReturn(false); + given(userRepository.findById(currentUserId)).willReturn(Optional.of(user)); + given(chatRoomRepository.findById(chatRoomId)).willReturn(Optional.of(chatRoom)); + given(chatRoomMemberRepository.existsActiveByChatRoomIdAndUserId(chatRoomId, currentUserId)).willReturn(false); // when & then - assertThatThrownBy(() -> chatRoomMembershipService.getChatRoomMembers(chatRoomId, currentUserId)) - .isInstanceOf(CustomException.class) - .hasFieldOrPropertyWithValue("errorCode", FORBIDDEN_CHAT_ROOM_ACCESS); + assertErrorCode( + () -> chatRoomMembershipService.getChatRoomMembers(chatRoomId, currentUserId), + FORBIDDEN_CHAT_ROOM_ACCESS + ); } @Test @@ -133,28 +124,18 @@ void getChatRoomMembers_forbiddenWhenLeftMember() { // given Integer chatRoomId = 1; Integer currentUserId = 100; + User user = createUser(currentUserId, "User1", UserRole.USER); + ChatRoom chatRoom = createRoom(chatRoomId, ChatType.GROUP, LocalDateTime.now()); - User user1 = User.builder() - .id(100) - .name("User1") - .imageUrl("image1.jpg") - .build(); - - ChatRoom chatRoom = ChatRoom.builder() - .id(chatRoomId) - .build(); - - given(userRepository.findById(currentUserId)) - .willReturn(java.util.Optional.of(user1)); - given(chatRoomRepository.findById(chatRoomId)) - .willReturn(java.util.Optional.of(chatRoom)); - given(chatRoomMemberRepository.existsActiveByChatRoomIdAndUserId(chatRoomId, currentUserId)) - .willReturn(false); // 나간 멤버는 활성 멤버가 아님 + given(userRepository.findById(currentUserId)).willReturn(Optional.of(user)); + given(chatRoomRepository.findById(chatRoomId)).willReturn(Optional.of(chatRoom)); + given(chatRoomMemberRepository.existsActiveByChatRoomIdAndUserId(chatRoomId, currentUserId)).willReturn(false); // when & then - assertThatThrownBy(() -> chatRoomMembershipService.getChatRoomMembers(chatRoomId, currentUserId)) - .isInstanceOf(CustomException.class) - .hasFieldOrPropertyWithValue("errorCode", FORBIDDEN_CHAT_ROOM_ACCESS); + assertErrorCode( + () -> chatRoomMembershipService.getChatRoomMembers(chatRoomId, currentUserId), + FORBIDDEN_CHAT_ROOM_ACCESS + ); } @Test @@ -164,34 +145,15 @@ void getChatRoomMembers_excludesLeftMembers() { Integer chatRoomId = 1; Integer currentUserId = 100; - User user1 = User.builder() - .id(100) - .name("User1") - .imageUrl("image1.jpg") - .build(); - - User user2 = User.builder() - .id(200) - .name("User2") - .imageUrl("image2.jpg") - .build(); - - ChatRoom chatRoom = ChatRoom.builder() - .id(chatRoomId) - .build(); - - ChatRoomMember member1 = ChatRoomMember.of(chatRoom, user1, LocalDateTime.now()); - ChatRoomMember member2 = ChatRoomMember.of(chatRoom, user2, LocalDateTime.now()); - member2.leaveDirectRoom(LocalDateTime.now()); // member2는 나간 상태 - - given(userRepository.findById(currentUserId)) - .willReturn(java.util.Optional.of(user1)); - given(chatRoomRepository.findById(chatRoomId)) - .willReturn(java.util.Optional.of(chatRoom)); - given(chatRoomMemberRepository.existsActiveByChatRoomIdAndUserId(chatRoomId, currentUserId)) - .willReturn(true); - given(chatRoomMemberRepository.findActiveMembersByChatRoomId(chatRoomId)) - .willReturn(List.of(member1)); // 나간 멤버는 조회되지 않음 + User user1 = createUser(currentUserId, "User1", UserRole.USER); + User user2 = createUser(200, "User2", UserRole.USER); + ChatRoom chatRoom = createRoom(chatRoomId, ChatType.GROUP, LocalDateTime.now()); + ChatRoomMember member1 = createRoomMember(chatRoom, user1, false, LocalDateTime.now()); + + given(userRepository.findById(currentUserId)).willReturn(Optional.of(user1)); + given(chatRoomRepository.findById(chatRoomId)).willReturn(Optional.of(chatRoom)); + given(chatRoomMemberRepository.existsActiveByChatRoomIdAndUserId(chatRoomId, currentUserId)).willReturn(true); + given(chatRoomMemberRepository.findActiveMembersByChatRoomId(chatRoomId)).willReturn(List.of(member1)); // when ChatRoomMembersResponse response = chatRoomMembershipService.getChatRoomMembers(chatRoomId, currentUserId); @@ -199,5 +161,354 @@ void getChatRoomMembers_excludesLeftMembers() { // then assertThat(response.members()).hasSize(1); assertThat(response.members().get(0).userId()).isEqualTo(100); + assertThat(response.members().get(0).name()).isEqualTo("User1"); + } + + @Test + @DisplayName("addClubMember는 기존 클럽 채팅방이 없으면 생성하고 멤버를 저장한다") + void addClubMemberCreatesClubRoomAndSavesMember() { + // given + Club club = createClub(10); + User user = createUser(20, "동아리원", UserRole.USER); + ClubMember clubMember = createClubMember(club, user, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoom savedRoom = createRoom(30, ChatType.CLUB_GROUP, LocalDateTime.of(2026, 4, 11, 10, 0)); + + given(chatRoomRepository.findByClubId(club.getId())).willReturn(Optional.empty()); + given(chatRoomRepository.save(any(ChatRoom.class))).willReturn(savedRoom); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(savedRoom.getId(), user.getId())) + .willReturn(Optional.empty()); + + // when + chatRoomMembershipService.addClubMember(clubMember); + + // then + verify(chatRoomRepository).save(any(ChatRoom.class)); + verify(chatRoomMemberRepository).save(any(ChatRoomMember.class)); + } + + @Test + @DisplayName("addClubMember는 채팅방 동시 생성 중복 예외가 나면 재조회한 방에 멤버를 추가한다") + void addClubMemberReloadsRoomWhenClubRoomCreationHitsDuplicate() { + // given + Club club = createClub(10); + User user = createUser(20, "동아리원", UserRole.USER); + ClubMember clubMember = createClubMember(club, user, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoom existingRoom = createRoom(31, ChatType.CLUB_GROUP, LocalDateTime.of(2026, 4, 11, 10, 1)); + + given(chatRoomRepository.findByClubId(club.getId())) + .willReturn(Optional.empty()) + .willReturn(Optional.of(existingRoom)); + given(chatRoomRepository.save(any(ChatRoom.class))) + .willThrow(new DuplicateKeyException("duplicate room")); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(existingRoom.getId(), user.getId())) + .willReturn(Optional.empty()); + + // when + chatRoomMembershipService.addClubMember(clubMember); + + // then + verify(chatRoomRepository).save(any(ChatRoom.class)); + verify(chatRoomRepository, times(2)).findByClubId(club.getId()); + verify(chatRoomMemberRepository).save(any(ChatRoomMember.class)); + } + + @Test + @DisplayName("addClubMember는 이미 멤버가 있으면 더 최신 createdAt일 때만 lastReadAt을 갱신한다") + void addClubMemberUpdatesLastReadAtOnlyWhenBaselineIsNewer() { + // given + Club club = createClub(10); + User user = createUser(20, "동아리원", UserRole.USER); + ChatRoom room = createRoom(30, ChatType.CLUB_GROUP, LocalDateTime.of(2026, 4, 11, 9, 0)); + ClubMember clubMember = createClubMember(club, user, LocalDateTime.of(2026, 4, 11, 11, 0)); + ChatRoomMember existingMember = createRoomMember(room, user, false, LocalDateTime.of(2026, 4, 11, 10, 0)); + + given(chatRoomRepository.findByClubId(club.getId())).willReturn(Optional.of(room)); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), user.getId())) + .willReturn(Optional.of(existingMember)); + + // when + chatRoomMembershipService.addClubMember(clubMember); + + // then + assertThat(existingMember.getLastReadAt()).isEqualTo(clubMember.getCreatedAt()); + verify(chatRoomMemberRepository, never()).save(any(ChatRoomMember.class)); + } + + @Test + @DisplayName("addClubMember는 이미 더 최신 lastReadAt이 있으면 저장하지 않고 유지한다") + void addClubMemberKeepsLastReadAtWhenExistingMemberIsNewer() { + // given + Club club = createClub(10); + User user = createUser(20, "동아리원", UserRole.USER); + ChatRoom room = createRoom(30, ChatType.CLUB_GROUP, LocalDateTime.of(2026, 4, 11, 9, 0)); + ClubMember clubMember = createClubMember(club, user, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember existingMember = createRoomMember(room, user, false, LocalDateTime.of(2026, 4, 11, 11, 0)); + + given(chatRoomRepository.findByClubId(club.getId())).willReturn(Optional.of(room)); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), user.getId())) + .willReturn(Optional.of(existingMember)); + + // when + chatRoomMembershipService.addClubMember(clubMember); + + // then + assertThat(existingMember.getLastReadAt()).isEqualTo(LocalDateTime.of(2026, 4, 11, 11, 0)); + verify(chatRoomMemberRepository, never()).save(any(ChatRoomMember.class)); + } + + @Test + @DisplayName("addDirectMembers는 joinedAt이 null이면 즉시 실패한다") + void addDirectMembersFailsWhenJoinedAtIsNull() { + // given + ChatRoom room = createRoom(10, ChatType.DIRECT, LocalDateTime.of(2026, 4, 11, 10, 0)); + User firstUser = createUser(20, "첫번째", UserRole.USER); + User secondUser = createUser(30, "두번째", UserRole.USER); + + // when & then + assertThatThrownBy(() -> chatRoomMembershipService.addDirectMembers(room, firstUser, secondUser, null)) + .isInstanceOf(NullPointerException.class); + verify(chatRoomMemberRepository, never()).save(any(ChatRoomMember.class)); + } + + @Test + @DisplayName("addDirectMembers는 멤버 저장 중 중복 예외가 나면 무시하고 나머지 멤버를 계속 처리한다") + void addDirectMembersIgnoresDuplicateMemberSave() { + // given + ChatRoom room = createRoom(10, ChatType.DIRECT, LocalDateTime.of(2026, 4, 11, 10, 0)); + User firstUser = createUser(20, "첫번째", UserRole.USER); + User secondUser = createUser(30, "두번째", UserRole.USER); + LocalDateTime joinedAt = LocalDateTime.of(2026, 4, 11, 10, 5); + + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), firstUser.getId())) + .willReturn(Optional.empty()); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), secondUser.getId())) + .willReturn(Optional.empty()); + given(chatRoomMemberRepository.save(any(ChatRoomMember.class))) + .willThrow(new DuplicateKeyException("duplicate member")) + .willReturn(createRoomMember(room, secondUser, false, joinedAt)); + + // when + chatRoomMembershipService.addDirectMembers(room, firstUser, secondUser, joinedAt); + + // then + verify(chatRoomMemberRepository, times(2)).save(any(ChatRoomMember.class)); + } + + @Test + @DisplayName("removeClubMember는 클럽 채팅방이 있을 때만 멤버를 삭제한다") + void removeClubMemberDeletesOnlyWhenClubRoomExists() { + // given + ChatRoom room = createRoom(10, ChatType.CLUB_GROUP, LocalDateTime.of(2026, 4, 11, 10, 0)); + given(chatRoomRepository.findByClubId(1)).willReturn(Optional.of(room)); + + // when + chatRoomMembershipService.removeClubMember(1, 20); + + // then + verify(chatRoomMemberRepository).deleteByChatRoomIdAndUserId(room.getId(), 20); + } + + @Test + @DisplayName("updateDirectRoomLastReadAt는 system-admin room에서 admin이 읽으면 시스템 관리자 멤버만 갱신한다") + void updateDirectRoomLastReadAtUpdatesSystemAdminForAdminReader() { + // given + int roomId = 10; + User admin = createUser(99, "관리자", UserRole.ADMIN); + ChatRoom room = createRoom(roomId, ChatType.DIRECT, LocalDateTime.of(2026, 4, 11, 10, 0)); + LocalDateTime readAt = LocalDateTime.of(2026, 4, 11, 10, 5); + given(chatRoomMemberRepository.findRoomMemberIdsByChatRoomIds(List.of(roomId))) + .willReturn(List.of(new Object[] {roomId, SYSTEM_ADMIN_ID, readAt})); + + // when + chatRoomMembershipService.updateDirectRoomLastReadAt(roomId, admin, readAt, room); + + // then + verify(chatRoomMemberRepository).updateLastReadAtIfOlder(roomId, SYSTEM_ADMIN_ID, readAt); + verify(chatRoomMemberRepository, never()).existsByChatRoomIdAndUserId(roomId, admin.getId()); + verify(chatRoomMemberRepository, never()).updateLastReadAtIfOlder(roomId, admin.getId(), readAt); + } + + @Test + @DisplayName("updateDirectRoomLastReadAt는 일반 멤버가 직접 채팅방에 속하면 본인 lastReadAt을 갱신한다") + void updateDirectRoomLastReadAtUpdatesReaderWhenMemberExists() { + // given + int roomId = 10; + User user = createUser(20, "사용자", UserRole.USER); + ChatRoom room = createRoom(roomId, ChatType.DIRECT, LocalDateTime.of(2026, 4, 11, 10, 0)); + LocalDateTime readAt = LocalDateTime.of(2026, 4, 11, 10, 5); + given(chatRoomMemberRepository.existsByChatRoomIdAndUserId(roomId, user.getId())).willReturn(true); + + // when + chatRoomMembershipService.updateDirectRoomLastReadAt(roomId, user, readAt, room); + + // then + verify(chatRoomMemberRepository).updateLastReadAtIfOlder(roomId, user.getId(), readAt); + } + + @Test + @DisplayName("updateDirectRoomLastReadAt는 일반 사용자가 멤버가 아니면 접근을 거부한다") + void updateDirectRoomLastReadAtRejectsNonMemberUser() { + // given + int roomId = 10; + User user = createUser(20, "사용자", UserRole.USER); + ChatRoom room = createRoom(roomId, ChatType.DIRECT, LocalDateTime.of(2026, 4, 11, 10, 0)); + LocalDateTime readAt = LocalDateTime.of(2026, 4, 11, 10, 5); + given(chatRoomMemberRepository.existsByChatRoomIdAndUserId(roomId, user.getId())).willReturn(false); + + // when & then + assertErrorCode( + () -> chatRoomMembershipService.updateDirectRoomLastReadAt(roomId, user, readAt, room), + FORBIDDEN_CHAT_ROOM_ACCESS + ); + verify(chatRoomMemberRepository, never()).updateLastReadAtIfOlder(roomId, user.getId(), readAt); + } + + @Test + @DisplayName("updateDirectRoomLastReadAt는 admin이 system-admin room 비멤버여도 본인 멤버를 생성하지 않는다") + void updateDirectRoomLastReadAtSkipsAdminMembershipCreationInSystemAdminRoom() { + // given + int roomId = 10; + User admin = createUser(99, "관리자", UserRole.ADMIN); + ChatRoom room = createRoom(roomId, ChatType.DIRECT, LocalDateTime.of(2026, 4, 11, 10, 0)); + LocalDateTime readAt = LocalDateTime.of(2026, 4, 11, 10, 5); + given(chatRoomMemberRepository.findRoomMemberIdsByChatRoomIds(List.of(roomId))) + .willReturn(List.of(new Object[] {roomId, SYSTEM_ADMIN_ID, readAt})); + + // when + chatRoomMembershipService.updateDirectRoomLastReadAt(roomId, admin, readAt, room); + + // then + verify(chatRoomMemberRepository, never()).save(any(ChatRoomMember.class)); + } + + @Test + @DisplayName("ensureClubRoomMember는 group room이 아니거나 club이 없으면 채팅방을 찾을 수 없다고 본다") + void ensureClubRoomMemberRejectsNonClubGroupRoom() { + // given + ChatRoom directRoom = createRoom(10, ChatType.DIRECT, LocalDateTime.of(2026, 4, 11, 10, 0)); + given(chatRoomRepository.findById(directRoom.getId())).willReturn(Optional.of(directRoom)); + + // when & then + assertErrorCode(() -> chatRoomMembershipService.ensureClubRoomMember(directRoom.getId(), 20), + NOT_FOUND_CHAT_ROOM); + verify(clubMemberRepository, never()).getByClubIdAndUserId(any(), any()); + } + + @Test + @DisplayName("ensureClubRoomMember는 club 멤버 createdAt 기준으로 채팅방 멤버를 보장한다") + void ensureClubRoomMemberCreatesOrUpdatesMemberFromClubMemberBaseline() { + // given + Club club = createClub(10); + ChatRoom room = createRoom(30, ChatType.CLUB_GROUP, LocalDateTime.of(2026, 4, 11, 9, 0)); + ReflectionTestUtils.setField(room, "club", club); + User user = createUser(20, "동아리원", UserRole.USER); + ClubMember clubMember = createClubMember(club, user, LocalDateTime.of(2026, 4, 11, 10, 0)); + + given(chatRoomRepository.findById(room.getId())).willReturn(Optional.of(room)); + given(clubMemberRepository.getByClubIdAndUserId(club.getId(), user.getId())).willReturn(clubMember); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), user.getId())) + .willReturn(Optional.empty()); + + // when + chatRoomMembershipService.ensureClubRoomMember(room.getId(), user.getId()); + + // then + verify(chatRoomMemberRepository).save(any(ChatRoomMember.class)); + } + + @Test + @DisplayName("updateLastReadAt는 저장된 값이 더 오래된 경우에만 갱신 쿼리를 위임한다") + void updateLastReadAtDelegatesConditionalUpdate() { + // when + LocalDateTime readAt = LocalDateTime.of(2026, 4, 11, 10, 0); + chatRoomMembershipService.updateLastReadAt(10, 20, readAt); + + // then + verify(chatRoomMemberRepository).updateLastReadAtIfOlder(10, 20, readAt); + } + + @Test + @DisplayName("중복이 아닌 DataIntegrityViolationException은 삼키지 않고 다시 던진다") + void addClubMemberRethrowsNonDuplicateIntegrityViolation() { + // given + Club club = createClub(10); + User user = createUser(20, "동아리원", UserRole.USER); + ClubMember clubMember = createClubMember(club, user, LocalDateTime.of(2026, 4, 11, 10, 0)); + DataIntegrityViolationException exception = new DataIntegrityViolationException("other constraint"); + + given(chatRoomRepository.findByClubId(club.getId())).willReturn(Optional.empty()); + given(chatRoomRepository.save(any(ChatRoom.class))).willThrow(exception); + + // when & then + assertThatThrownBy(() -> chatRoomMembershipService.addClubMember(clubMember)) + .isSameAs(exception); + } + + @Test + @DisplayName("root cause 메시지에 duplicate key가 있으면 채팅방 멤버 저장 중복도 무시한다") + void addDirectMembersIgnoresDuplicateByRootCauseMessage() { + // given + ChatRoom room = createRoom(10, ChatType.DIRECT, LocalDateTime.of(2026, 4, 11, 10, 0)); + User firstUser = createUser(20, "첫번째", UserRole.USER); + User secondUser = createUser(30, "두번째", UserRole.USER); + LocalDateTime joinedAt = LocalDateTime.of(2026, 4, 11, 10, 5); + DataIntegrityViolationException duplicateLikeException = new DataIntegrityViolationException( + "constraint violated", + new RuntimeException("duplicate key value violates unique constraint") + ); + + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), firstUser.getId())) + .willReturn(Optional.empty()); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), secondUser.getId())) + .willReturn(Optional.empty()); + given(chatRoomMemberRepository.save(any(ChatRoomMember.class))) + .willThrow(duplicateLikeException) + .willReturn(createRoomMember(room, secondUser, false, joinedAt)); + + // when + chatRoomMembershipService.addDirectMembers(room, firstUser, secondUser, joinedAt); + + // then + verify(chatRoomMemberRepository, times(2)).save(any(ChatRoomMember.class)); + } + + private Club createClub(Integer clubId) { + return ClubFixture.createWithId(UniversityFixture.createWithId(1), clubId, "BCSD"); + } + + private User createUser(Integer userId, String name, UserRole role) { + return UserFixture.createUserWithId(userId, name, role); + } + + private ClubMember createClubMember(Club club, User user, LocalDateTime createdAt) { + ClubMember clubMember = ClubMemberFixture.createMember(club, user); + ReflectionTestUtils.setField(clubMember, "createdAt", createdAt); + return clubMember; + } + + private ChatRoom createRoom(Integer id, ChatType type, LocalDateTime createdAt) { + ChatRoom room = switch (type) { + case DIRECT -> ChatRoom.directOf(); + case GROUP -> ChatRoom.groupOf(); + case CLUB_GROUP -> ChatRoom.clubGroupOf(createClub(77)); + default -> throw new IllegalArgumentException("Unsupported ChatType: " + type); + }; + ReflectionTestUtils.setField(room, "id", id); + ReflectionTestUtils.setField(room, "createdAt", createdAt); + return room; + } + + private ChatRoomMember createRoomMember(ChatRoom room, User user, boolean isOwner, LocalDateTime lastReadAt) { + ChatRoomMember member = isOwner + ? ChatRoomMember.ofOwner(room, user, lastReadAt) + : ChatRoomMember.of(room, user, lastReadAt); + ReflectionTestUtils.setField(member, "createdAt", lastReadAt); + return member; + } + + private void assertErrorCode(ThrowingCallable callable, ApiResponseCode errorCode) { + assertThatThrownBy(callable) + .isInstanceOf(CustomException.class) + .satisfies(exception -> assertThat(((CustomException)exception).getErrorCode()).isEqualTo(errorCode)); } } From 1d03fbafd9ac2115fe09d34c65ce74814b370454 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Thu, 16 Apr 2026 14:10:01 +0900 Subject: [PATCH 03/50] =?UTF-8?q?chore:=20=EB=A1=A4=EB=A7=81=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=20gzip=20=EC=95=95=EC=B6=95=20=EC=A0=81=EC=9A=A9=20(#?= =?UTF-8?q?552)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Logback 롤링 설정이 새로 생성되는 일별 로그를 즉시 gzip으로 압축하도록 조정 - 운영 중 특정 날짜 로그를 파일 단위로 확인하기 쉬운 구조는 유지하면서 디스크 사용량을 줄이도록 선택 - 별도 배치 압축 작업 없이도 backend 로그와 scheduler 로그가 동일한 방식으로 보관되도록 맞춤 --- src/main/resources/logback-spring.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml index c04ca5810..77cdd23b6 100644 --- a/src/main/resources/logback-spring.xml +++ b/src/main/resources/logback-spring.xml @@ -17,7 +17,7 @@ ${LOG_PATH}/konect-backend.log - ${LOG_PATH}/konect-backend.%d{yyyy-MM-dd}.log + ${LOG_PATH}/konect-backend.%d{yyyy-MM-dd}.log.gz 30 3GB @@ -29,7 +29,7 @@ ${LOG_PATH}/scheduler.log - ${LOG_PATH}/scheduler.%d{yyyy-MM-dd}.log + ${LOG_PATH}/scheduler.%d{yyyy-MM-dd}.log.gz 7 1GB From 1e289f101cd9df213f73e6a813ed5c4ba4b51d49 Mon Sep 17 00:00:00 2001 From: JanooGwan <103417427+JanooGwan@users.noreply.github.com> Date: Thu, 16 Apr 2026 14:12:13 +0900 Subject: [PATCH 04/50] =?UTF-8?q?fix:=20ClaudeClient=20=EC=8B=9C=EC=8A=A4?= =?UTF-8?q?=ED=85=9C=20=ED=94=84=EB=A1=AC=ED=94=84=ED=8A=B8=20study=5Ftime?= =?UTF-8?q?=20=ED=85=8C=EC=9D=B4=EB=B8=94=20=ED=9E=8C=ED=8A=B8=20=EA=B5=AC?= =?UTF-8?q?=EC=B2=B4=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../infrastructure/claude/client/ClaudeClient.java | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/main/java/gg/agit/konect/infrastructure/claude/client/ClaudeClient.java b/src/main/java/gg/agit/konect/infrastructure/claude/client/ClaudeClient.java index ff88f189b..69e08c671 100644 --- a/src/main/java/gg/agit/konect/infrastructure/claude/client/ClaudeClient.java +++ b/src/main/java/gg/agit/konect/infrastructure/claude/client/ClaudeClient.java @@ -53,7 +53,19 @@ public class ClaudeClient { - club_apply: 동아리 지원 - university_schedule: 학사 일정 - council_notice: 학생회 공지사항 - - study_time_*: 공부 시간 관련 테이블 + + ## 순공 시간(study time) 관련 테이블 상세 + - study_timer: 현재 타이머가 켜진 세션 정보 (user_id, started_at). 실시간으로 타이머를 실행 중인 사용자만 존재함. + - study_time_daily: 일별 누적 공부 시간 (user_id, study_date DATE, total_seconds BIGINT). "오늘", "24시간 이내", "최근 N일" 등 날짜 기반 조회 시 이 테이블 사용. + - study_time_monthly: 월별 누적 공부 시간 (user_id, study_month DATE, total_seconds BIGINT). 월 단위 조회 시 이 테이블 사용. + - study_time_total: 사용자별 전체 누적 공부 시간 (user_id, total_seconds BIGINT). 누적 합계 조회 시 이 테이블 사용. + - study_time_ranking: 랭킹 데이터 (ranking_type_id, university_id, target_id, target_name, daily_seconds, monthly_seconds) + - ranking_type: 랭킹 타입 (1=CLUB, 2=STUDENT_NUMBER, 3=PERSONAL) + + ### 순공 시간 조회 예시 + - "오늘 또는 24시간 이내 순공 시간 기록이 있는 사용자 수" → study_time_daily에서 study_date = CURDATE() 조건 + - "이번 달 순공 시간 기록이 있는 사용자 수" → study_time_monthly에서 study_month = DATE_FORMAT(NOW(), '%Y-%m-01') 조건 + - "현재 타이머 실행 중인 사용자 수" → study_timer에서 COUNT(*) ## 응답 규칙 - 반드시 한국어로 응답 From 5da8880358f8b18e08452252552e02e7c7dafcc7 Mon Sep 17 00:00:00 2001 From: JanooGwan <103417427+JanooGwan@users.noreply.github.com> Date: Thu, 16 Apr 2026 14:36:32 +0900 Subject: [PATCH 05/50] =?UTF-8?q?fix:=20show=20tables=20=EC=84=A0=ED=96=89?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20=EC=9B=90=EC=B9=99=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20=EB=B0=8F=20=EC=A4=84=20=EA=B8=B8=EC=9D=B4=20120=EC=9E=90=20?= =?UTF-8?q?=EC=A0=9C=ED=95=9C=20=EC=A4=80=EC=88=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../claude/client/ClaudeClient.java | 27 ++++++++++++------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/src/main/java/gg/agit/konect/infrastructure/claude/client/ClaudeClient.java b/src/main/java/gg/agit/konect/infrastructure/claude/client/ClaudeClient.java index 69e08c671..369d332dc 100644 --- a/src/main/java/gg/agit/konect/infrastructure/claude/client/ClaudeClient.java +++ b/src/main/java/gg/agit/konect/infrastructure/claude/client/ClaudeClient.java @@ -31,6 +31,10 @@ public class ClaudeClient { private static final String SYSTEM_PROMPT = """ 당신은 KONECT 서비스의 데이터 분석 AI 에이전트입니다. + ## 필수 원칙 + DB를 조회할 때는 항상 list_tables 도구(SHOW TABLES)로 먼저 테이블 목록을 확인하고, + 실제 존재하는 테이블 중에서 판단하여 조회한다. + ## 역할 사용자의 질문을 분석하고, 데이터베이스에서 필요한 데이터를 조회하여 답변합니다. @@ -40,7 +44,7 @@ public class ClaudeClient { 3. query: SQL SELECT 쿼리 실행 (읽기 전용) ## 작업 방식 - 1. 질문과 관련된 테이블이 확실하지 않으면 반드시 list_tables로 먼저 확인 + 1. 반드시 list_tables로 테이블 목록을 먼저 확인 2. 테이블 구조가 필요하면 describe_table로 컬럼 정보 확인 3. 적절한 SQL 쿼리를 작성하여 데이터 조회 4. 결과를 바탕으로 친절하고 자연스럽게 답변 @@ -55,17 +59,22 @@ public class ClaudeClient { - council_notice: 학생회 공지사항 ## 순공 시간(study time) 관련 테이블 상세 - - study_timer: 현재 타이머가 켜진 세션 정보 (user_id, started_at). 실시간으로 타이머를 실행 중인 사용자만 존재함. - - study_time_daily: 일별 누적 공부 시간 (user_id, study_date DATE, total_seconds BIGINT). "오늘", "24시간 이내", "최근 N일" 등 날짜 기반 조회 시 이 테이블 사용. - - study_time_monthly: 월별 누적 공부 시간 (user_id, study_month DATE, total_seconds BIGINT). 월 단위 조회 시 이 테이블 사용. - - study_time_total: 사용자별 전체 누적 공부 시간 (user_id, total_seconds BIGINT). 누적 합계 조회 시 이 테이블 사용. - - study_time_ranking: 랭킹 데이터 (ranking_type_id, university_id, target_id, target_name, daily_seconds, monthly_seconds) + - study_timer: 현재 타이머 실행 중인 세션 (user_id, started_at) + 실시간으로 타이머를 켠 사용자만 존재. 현재 상태 조회용. + - study_time_daily: 일별 누적 공부 시간 (user_id, study_date DATE, total_seconds BIGINT) + 날짜 기반 질문("오늘", "24시간 이내", "최근 N일")에 사용. + - study_time_monthly: 월별 누적 공부 시간 (user_id, study_month DATE, total_seconds BIGINT) + 월 단위 질문에 사용. + - study_time_total: 사용자별 전체 누적 공부 시간 (user_id, total_seconds BIGINT) + 누적 합계 질문에 사용. + - study_time_ranking: 랭킹 데이터 + (ranking_type_id, university_id, target_id, target_name, daily_seconds, monthly_seconds) - ranking_type: 랭킹 타입 (1=CLUB, 2=STUDENT_NUMBER, 3=PERSONAL) ### 순공 시간 조회 예시 - - "오늘 또는 24시간 이내 순공 시간 기록이 있는 사용자 수" → study_time_daily에서 study_date = CURDATE() 조건 - - "이번 달 순공 시간 기록이 있는 사용자 수" → study_time_monthly에서 study_month = DATE_FORMAT(NOW(), '%Y-%m-01') 조건 - - "현재 타이머 실행 중인 사용자 수" → study_timer에서 COUNT(*) + - "오늘/24시간 이내 순공 기록 사용자 수" → study_time_daily, study_date = CURDATE() + - "이번 달 순공 기록 사용자 수" → study_time_monthly, study_month = DATE_FORMAT(NOW(), '%Y-%m-01') + - "현재 타이머 실행 중인 사용자 수" → study_timer, COUNT(*) ## 응답 규칙 - 반드시 한국어로 응답 From 845049c9d93181d2542067a16548753d90c279a1 Mon Sep 17 00:00:00 2001 From: JanooGwan <103417427+JanooGwan@users.noreply.github.com> Date: Thu, 16 Apr 2026 14:49:25 +0900 Subject: [PATCH 06/50] =?UTF-8?q?fix:=20JaCoCo=20=EC=A0=9C=EC=99=B8=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=EC=97=90=20claude,=20mcp=20=EC=9D=B8?= =?UTF-8?q?=ED=94=84=EB=9D=BC=20=ED=81=B4=EB=9D=BC=EC=9D=B4=EC=96=B8?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 3 +++ 1 file changed, 3 insertions(+) diff --git a/build.gradle b/build.gradle index ed42fe26e..40581bbd4 100644 --- a/build.gradle +++ b/build.gradle @@ -139,6 +139,9 @@ jacocoTestReport { // Exception "**/exception/*.class", "**/*Exception.class", + // 외부 API 클라이언트 (단위 테스트 어려운 클래스) + "**/infrastructure/claude/**", + "**/infrastructure/mcp/**", // 기타 "**/Application.class", // Spring Boot 메인 클래스 ]) From f1c0dabffa1fab78ffa552bbeff3b385e0324eaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EA=B4=80=EA=B7=9C?= Date: Thu, 16 Apr 2026 17:39:13 +0900 Subject: [PATCH 07/50] =?UTF-8?q?fix:=20=EB=A9=94=EC=8B=9C=EC=A7=80=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=EA=B2=B0=EA=B3=BC=EC=97=90=20unreadCount,?= =?UTF-8?q?=20isMuted=20=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80=20(#555)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit roomMatches와 동일하게 messageMatches 응답에도 읽지 않은 메시지 수와 알림 뮤트 여부를 포함하도록 수정 Co-authored-by: Claude Opus 4.6 (1M context) --- .../domain/chat/dto/ChatMessageMatchResult.java | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) 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 index 1c68031c9..d2161ed92 100644 --- a/src/main/java/gg/agit/konect/domain/chat/dto/ChatMessageMatchResult.java +++ b/src/main/java/gg/agit/konect/domain/chat/dto/ChatMessageMatchResult.java @@ -32,7 +32,13 @@ public record ChatMessageMatchResult( LocalDateTime matchedMessageSentAt, @Schema(description = "검색에 매칭된 메시지 ID", example = "42", requiredMode = REQUIRED) - Integer matchedMessageId + Integer matchedMessageId, + + @Schema(description = "읽지 않은 메시지 수", example = "3", requiredMode = REQUIRED) + Integer unreadCount, + + @Schema(description = "채팅방 알림 뮤트 여부", example = "false", requiredMode = REQUIRED) + Boolean isMuted ) { public static ChatMessageMatchResult from(ChatRoomSummaryResponse room, ChatMessage message) { @@ -43,7 +49,9 @@ public static ChatMessageMatchResult from(ChatRoomSummaryResponse room, ChatMess room.roomImageUrl(), message.getContent(), message.getCreatedAt(), - message.getId() + message.getId(), + room.unreadCount(), + room.isMuted() ); } } From 1029a9313e2dee9d05d179ea93907d454e6ad615 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Thu, 16 Apr 2026 21:38:08 +0900 Subject: [PATCH 08/50] =?UTF-8?q?fix:=20=EC=99=84=EB=A3=8C=EB=90=9C=20SSE?= =?UTF-8?q?=20emitter=20=EC=A0=84=EC=86=A1=20=EC=98=88=EC=99=B8=EB=A5=BC?= =?UTF-8?q?=20=EC=A0=95=EB=A6=AC=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20(#556)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 완료된 SSE emitter 전송 예외를 정리하도록 처리 - 이미 complete된 emitter에 send를 시도하면 IllegalStateException이 발생할 수 있어 알림 전송 자체보다 끊어진 연결 정리를 우선하도록 변경 - IOException만 completeWithError로 마무리하고 IllegalStateException은 emitter 제거로만 끝내 이미 종료된 emitter를 다시 종료하면서 예외가 전파되는 상황을 막음 - 완료된 emitter가 맵에 남아 있어도 예외 없이 정리되는 단위 테스트를 추가해 재연 경로를 고정하고 회귀를 방지 * fix: SSE emitter 종료 경합 예외를 추가로 흡수 - send 실패 후 completeWithError 호출도 이미 종료된 emitter와 경합할 수 있어 정리 단계의 예외가 다시 상위로 전파되지 않도록 별도 보호 구문을 추가 - 알림 전송 실패 처리의 목적은 끊어진 emitter를 제거하는 것이므로 종료 경쟁에서 생기는 IllegalStateException은 로그만 남기고 삼키도록 선택 - 기존 단위 테스트를 다시 실행해 알림 inbox SSE 경로가 회귀 없이 유지되는지 확인 * test: 완료된 SSE emitter 테스트를 결정적으로 고정 - subscribe 경로의 completion callback 타이밍에 따라 테스트가 false positive가 될 수 있어 완료된 emitter를 직접 주입하는 방식으로 재현 조건을 고정 - 이번 테스트의 목적은 IllegalStateException 정리 분기를 안정적으로 검증하는 것이므로 구독 로직 부수효과보다 완료된 emitter 상태 자체를 최소 셋업으로 구성 - 수정 후 NotificationInboxSseService 단위 테스트를 다시 실행해 회귀 없이 통과함을 확인 --- .../service/NotificationInboxSseService.java | 11 ++++++++-- .../NotificationInboxSseServiceTest.java | 22 +++++++++---------- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/src/main/java/gg/agit/konect/domain/notification/service/NotificationInboxSseService.java b/src/main/java/gg/agit/konect/domain/notification/service/NotificationInboxSseService.java index 4f357d06c..6889bf584 100644 --- a/src/main/java/gg/agit/konect/domain/notification/service/NotificationInboxSseService.java +++ b/src/main/java/gg/agit/konect/domain/notification/service/NotificationInboxSseService.java @@ -47,10 +47,17 @@ public void send(Integer userId, NotificationInboxResponse notification) { } try { emitter.send(SseEmitter.event().name("notification").data(notification)); - } catch (IOException e) { + } catch (IOException | IllegalStateException e) { log.warn("SSE send failed: userId={}", userId, e); emitters.remove(userId, emitter); - emitter.completeWithError(e); + if (e instanceof IOException ioException) { + try { + emitter.completeWithError(ioException); + } catch (IllegalStateException completeException) { + log.warn("SSE emitter already completed while closing after send failure: userId={}", userId, + completeException); + } + } } } } diff --git a/src/test/java/gg/agit/konect/unit/domain/notification/service/NotificationInboxSseServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/notification/service/NotificationInboxSseServiceTest.java index f5aa81969..41b725c56 100644 --- a/src/test/java/gg/agit/konect/unit/domain/notification/service/NotificationInboxSseServiceTest.java +++ b/src/test/java/gg/agit/konect/unit/domain/notification/service/NotificationInboxSseServiceTest.java @@ -2,6 +2,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatNoException; import java.lang.reflect.Field; import java.util.Map; @@ -109,20 +110,19 @@ void subscribeCompletesPreviousEmitterBeforeReplacement() throws Exception { } @Test - @DisplayName("send는 IOException 발생 시 emitter를 제거한다") - void sendRemovesEmitterOnIOException() { + @DisplayName("send는 이미 완료된 emitter가 남아 있어도 예외 없이 정리한다") + void sendRemovesCompletedEmitterOnIllegalStateException() throws Exception { // given - notificationInboxSseService.subscribe(1); + // subscribe의 completion callback에 의존하지 않고, + // 이미 종료된 emitter가 맵에 남아 있는 상태를 결정적으로 재현한다. + SseEmitter emitter = new SseEmitter(); + emitter.complete(); + emitters().put(1, emitter); NotificationInboxResponse response = createMockNotificationResponse(); - // when - // emitter가 존재하는 상태에서 전송 시도 - notificationInboxSseService.send(1, response); - - // then - // 메서드가 정상적으로 동작하는지 확인 - assertThatCode(() -> notificationInboxSseService.send(1, response)) - .doesNotThrowAnyException(); + // when & then + assertThatNoException().isThrownBy(() -> notificationInboxSseService.send(1, response)); + assertThat(emitters()).doesNotContainKey(1); } @SuppressWarnings("unchecked") From a9f5e011f64c0a95d5c72ceb322bb9764b0a3b0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Thu, 16 Apr 2026 22:09:21 +0900 Subject: [PATCH 09/50] =?UTF-8?q?chore:=20=EB=AA=A8=EB=8B=88=ED=84=B0?= =?UTF-8?q?=EB=A7=81=20=EC=8A=A4=ED=83=9D=20=EC=A0=9C=EA=B1=B0=20(#557)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: OpenTelemetry Java agent 배선을 제거해 배포 이미지를 단순화 - Docker 이미지와 stage/prod 배포 워크플로우에서 OTel Java agent 의존성을 함께 제거해 런타임 추적 구성을 저장소 밖으로 정리한다. - CI 빌드가 더 이상 agent 캐시, checksum, 런타임 전용 환경변수 해제 흐름에 기대지 않도록 바꿔 이미지 빌드 경로를 단순하게 유지한다. * chore: Prometheus 노출 설정을 제거해 백엔드 모니터링 의존성을 정리 - Prometheus registry 의존성과 별도 monitoring 설정 파일을 제거해 애플리케이션이 더 이상 메트릭 수집 구성을 내장하지 않도록 정리한다. - Docker healthcheck가 여전히 /actuator/health 를 사용하므로 actuator 는 유지하고, 배포 안정성에 직접 영향이 큰 health 경로만 남긴다. * chore: 저장소에서 monitoring 스택 배포 자산을 제거 - Prometheus, Grafana, Loki, Promtail 배포 워크플로우와 compose 자산을 함께 제거해 저장소가 더 이상 별도 모니터링 스택을 관리하지 않도록 정리한다. - monitoring 전용 env 예외와 타깃 갱신 스크립트까지 함께 삭제해 남은 운영 진입점을 줄이고 제거 범위를 한 커밋 안에 묶는다. --- .dockerignore | 1 - .env.example | 8 --- .github/workflows/deploy-monitoring.yml | 56 ---------------- .github/workflows/deploy-prod.yml | 20 ------ .github/workflows/deploy-stage.yml | 20 ------ .gitignore | 1 - Dockerfile | 1 - build.gradle | 2 - monitoring/.env.example | 8 --- monitoring/docker-compose.yml | 66 ------------------- monitoring/loki.yml | 25 ------- monitoring/prometheus.yml | 12 ---- monitoring/promtail.yml | 25 ------- .../scripts/update-backend-active-target.sh | 45 ------------- src/main/resources/application-monitoring.yml | 28 -------- src/main/resources/application.yml | 10 ++- 16 files changed, 9 insertions(+), 319 deletions(-) delete mode 100644 .github/workflows/deploy-monitoring.yml delete mode 100644 monitoring/.env.example delete mode 100644 monitoring/docker-compose.yml delete mode 100644 monitoring/loki.yml delete mode 100644 monitoring/prometheus.yml delete mode 100644 monitoring/promtail.yml delete mode 100755 monitoring/scripts/update-backend-active-target.sh delete mode 100644 src/main/resources/application-monitoring.yml diff --git a/.dockerignore b/.dockerignore index 47ad3113c..76e4eafb7 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,4 +5,3 @@ !build/ !build/libs/ !build/libs/KONECT_API.jar -!opentelemetry-javaagent.jar diff --git a/.env.example b/.env.example index 056fd20e3..bb16d2338 100644 --- a/.env.example +++ b/.env.example @@ -59,11 +59,3 @@ CLAUDE_MODEL=claude-sonnet-4-20250514 # MCP Bridge Configuration MCP_BRIDGE_URL=http://localhost:3100 -# Tracing Configuration (OTel Java Agent -> Tempo) -JAVA_TOOL_OPTIONS=-javaagent:/app/opentelemetry-javaagent.jar -OTEL_SERVICE_NAME=your-service-name -OTEL_TRACES_EXPORTER=otlp -OTEL_METRICS_EXPORTER=none -OTEL_LOGS_EXPORTER=none -OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf -OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=http://your-monitoring-host:4318/v1/traces diff --git a/.github/workflows/deploy-monitoring.yml b/.github/workflows/deploy-monitoring.yml deleted file mode 100644 index 77d0d595b..000000000 --- a/.github/workflows/deploy-monitoring.yml +++ /dev/null @@ -1,56 +0,0 @@ -name: Monitoring Deploy - -on: - push: - branches: [ main ] - paths: - - "monitoring/**" - - ".github/workflows/deploy-monitoring.yml" - workflow_dispatch: - -jobs: - deploy-monitoring: - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - - name: Transfer monitoring configs to server - uses: appleboy/scp-action@917f8b81dfc1ccd331fef9e2d61bdc6c8be94634 # v0.1.7 - with: - host: ${{ secrets.SERVER_IP }} - username: ${{ secrets.SERVER_USER }} - key: ${{ secrets.SERVER_SSH_KEY }} - port: ${{ secrets.SERVER_PORT }} - source: "monitoring" - target: ${{ secrets.PROD_WORK_DIR }} - rm: false - - - name: Deploy monitoring stack - uses: appleboy/ssh-action@v1.2.0 - env: - WORK_DIR: ${{ secrets.PROD_WORK_DIR }} - MONITORING_ENV: ${{ secrets.MONITORING_ENV }} - with: - host: ${{ secrets.SERVER_IP }} - username: ${{ secrets.SERVER_USER }} - key: ${{ secrets.SERVER_SSH_KEY }} - port: ${{ secrets.SERVER_PORT }} - envs: WORK_DIR,MONITORING_ENV - script: | - set -euo pipefail - : "${WORK_DIR:?}" - : "${MONITORING_ENV:?}" - cd "${WORK_DIR}/monitoring" - umask 077 - printf '%s' "${MONITORING_ENV}" > .env - chmod 600 .env - - docker volume inspect monitoring_prometheus-data >/dev/null 2>&1 || docker volume create monitoring_prometheus-data - docker volume inspect monitoring_grafana-data >/dev/null 2>&1 || docker volume create monitoring_grafana-data - docker volume inspect monitoring_loki-data >/dev/null 2>&1 || docker volume create monitoring_loki-data - docker volume inspect monitoring_promtail-positions >/dev/null 2>&1 || docker volume create monitoring_promtail-positions - - docker compose up -d - curl -fsS -X POST "http://127.0.0.1:${PROMETHEUS_PORT:-9090}/-/reload" || true - docker compose ps diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml index 97e74ea10..bbaf92299 100644 --- a/.github/workflows/deploy-prod.yml +++ b/.github/workflows/deploy-prod.yml @@ -29,23 +29,6 @@ jobs: restore-keys: | gradle-${{ runner.os }}- - - name: Cache OpenTelemetry Java Agent - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 - with: - path: ~/.cache/otel-java-agent - key: otel-agent-${{ runner.os }}-${{ vars.OTEL_JAVA_AGENT_VERSION }}-${{ vars.OTEL_JAVA_AGENT_SHA256 }} - - - name: Prepare OpenTelemetry Agent - run: | - mkdir -p ~/.cache/otel-java-agent - if [ ! -f ~/.cache/otel-java-agent/opentelemetry-javaagent.jar ]; then - wget -O ~/.cache/otel-java-agent/opentelemetry-javaagent.jar \ - "https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/download/v${{ vars.OTEL_JAVA_AGENT_VERSION }}/opentelemetry-javaagent.jar" - fi - # Verify checksum - echo "${{ vars.OTEL_JAVA_AGENT_SHA256 }} $HOME/.cache/otel-java-agent/opentelemetry-javaagent.jar" | sha256sum -c - - cp ~/.cache/otel-java-agent/opentelemetry-javaagent.jar . - - name: Grant execute permission for gradlew run: chmod +x gradlew @@ -54,7 +37,6 @@ jobs: set -a source .env.example set +a - unset JAVA_TOOL_OPTIONS ./gradlew bootJar -x test --build-cache -Dspring.profiles.active=prod - name: Set up Docker Buildx @@ -85,8 +67,6 @@ jobs: labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max - build-args: | - OTEL_JAVA_AGENT_VERSION=${{ vars.OTEL_JAVA_AGENT_VERSION }} - name: Backup prod MySQL before deploy uses: appleboy/ssh-action@v1.2.0 diff --git a/.github/workflows/deploy-stage.yml b/.github/workflows/deploy-stage.yml index 4cfacc032..cf8d1b549 100644 --- a/.github/workflows/deploy-stage.yml +++ b/.github/workflows/deploy-stage.yml @@ -29,23 +29,6 @@ jobs: restore-keys: | gradle-${{ runner.os }}- - - name: Cache OpenTelemetry Java Agent - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 - with: - path: ~/.cache/otel-java-agent - key: otel-agent-${{ runner.os }}-${{ vars.OTEL_JAVA_AGENT_VERSION }}-${{ vars.OTEL_JAVA_AGENT_SHA256 }} - - - name: Prepare OpenTelemetry Agent - run: | - mkdir -p ~/.cache/otel-java-agent - if [ ! -f ~/.cache/otel-java-agent/opentelemetry-javaagent.jar ]; then - wget -O ~/.cache/otel-java-agent/opentelemetry-javaagent.jar \ - "https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/download/v${{ vars.OTEL_JAVA_AGENT_VERSION }}/opentelemetry-javaagent.jar" - fi - # Verify checksum - echo "${{ vars.OTEL_JAVA_AGENT_SHA256 }} $HOME/.cache/otel-java-agent/opentelemetry-javaagent.jar" | sha256sum -c - - cp ~/.cache/otel-java-agent/opentelemetry-javaagent.jar . - - name: Grant execute permission for gradlew run: chmod +x gradlew @@ -54,7 +37,6 @@ jobs: set -a source .env.example set +a - unset JAVA_TOOL_OPTIONS ./gradlew bootJar -x test --build-cache -Dspring.profiles.active=stage - name: Set up Docker Buildx @@ -85,8 +67,6 @@ jobs: labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max - build-args: | - OTEL_JAVA_AGENT_VERSION=${{ vars.OTEL_JAVA_AGENT_VERSION }} - name: Backup stage MySQL before deploy uses: appleboy/ssh-action@v1.2.0 diff --git a/.gitignore b/.gitignore index 83f128d4e..08f6460cd 100644 --- a/.gitignore +++ b/.gitignore @@ -41,7 +41,6 @@ out/ *adminsdk.json logs -/monitoring/.env .env* !.env.example diff --git a/Dockerfile b/Dockerfile index a3f1c8d10..3478b1d25 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,7 +9,6 @@ RUN addgroup -g 1000 -S konect \ && chmod 755 /app /app/logs COPY --chown=1000:1000 build/libs/KONECT_API.jar KONECT_API.jar -COPY --chown=1000:1000 opentelemetry-javaagent.jar opentelemetry-javaagent.jar USER 1000:1000 diff --git a/build.gradle b/build.gradle index 40581bbd4..61e77d23b 100644 --- a/build.gradle +++ b/build.gradle @@ -65,8 +65,6 @@ dependencies { implementation platform('software.amazon.awssdk:bom:2.41.14') implementation 'software.amazon.awssdk:s3' - // monitoring - implementation 'io.micrometer:micrometer-registry-prometheus' // notification implementation 'com.google.firebase:firebase-admin:9.2.0' diff --git a/monitoring/.env.example b/monitoring/.env.example deleted file mode 100644 index 9a9434aa9..000000000 --- a/monitoring/.env.example +++ /dev/null @@ -1,8 +0,0 @@ -GRAFANA_ADMIN_USER=temp -GRAFANA_ADMIN_PASSWORD=temp - -PROMETHEUS_PORT=9090 - -GRAFANA_PORT=3000 - -LOKI_PORT=3100 diff --git a/monitoring/docker-compose.yml b/monitoring/docker-compose.yml deleted file mode 100644 index 44e3bbef5..000000000 --- a/monitoring/docker-compose.yml +++ /dev/null @@ -1,66 +0,0 @@ -services: - prometheus: - image: prom/prometheus:v3.5.0 - command: - - "--config.file=/etc/prometheus/prometheus.yml" - - "--web.enable-lifecycle" - ports: - - "127.0.0.1:${PROMETHEUS_PORT:-9090}:9090" - extra_hosts: - - "host.docker.internal:host-gateway" - volumes: - - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro - - ./targets:/etc/prometheus/targets:ro - - prometheus-data:/prometheus - restart: unless-stopped - - grafana: - image: grafana/grafana:12.3.1 - ports: - - "127.0.0.1:${GRAFANA_PORT:-3000}:3000" - env_file: - - .env - environment: - - GF_SECURITY_ADMIN_USER=${GRAFANA_ADMIN_USER:?} - - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD:?} - volumes: - - grafana-data:/var/lib/grafana - restart: unless-stopped - depends_on: - - prometheus - - loki: - image: grafana/loki:3.1.0 - ports: - - "127.0.0.1:${LOKI_PORT:-3100}:3100" - command: -config.file=/etc/loki/config.yml - volumes: - - ./loki.yml:/etc/loki/config.yml:ro - - loki-data:/loki - restart: unless-stopped - - promtail: - image: grafana/promtail:3.1.0 - command: -config.file=/etc/promtail/config.yml - volumes: - - ./promtail.yml:/etc/promtail/config.yml:ro - - ../logs:/var/log/konect-backend:ro - - /var/log/nginx:/var/log/nginx:ro - - promtail-positions:/var/lib/promtail - restart: unless-stopped - depends_on: - - loki - -volumes: - prometheus-data: - external: true - name: monitoring_prometheus-data - grafana-data: - external: true - name: monitoring_grafana-data - loki-data: - external: true - name: monitoring_loki-data - promtail-positions: - external: true - name: monitoring_promtail-positions diff --git a/monitoring/loki.yml b/monitoring/loki.yml deleted file mode 100644 index b822f04be..000000000 --- a/monitoring/loki.yml +++ /dev/null @@ -1,25 +0,0 @@ -auth_enabled: false - -server: - http_listen_port: 3100 - -common: - path_prefix: /loki - storage: - filesystem: - chunks_directory: /loki/chunks - rules_directory: /loki/rules - replication_factor: 1 - ring: - kvstore: - store: inmemory - -schema_config: - configs: - - from: 2026-01-01 - store: tsdb - object_store: filesystem - schema: v13 - index: - prefix: index_ - period: 24h diff --git a/monitoring/prometheus.yml b/monitoring/prometheus.yml deleted file mode 100644 index 3b7ea667e..000000000 --- a/monitoring/prometheus.yml +++ /dev/null @@ -1,12 +0,0 @@ -global: - scrape_interval: 15s - evaluation_interval: 15s - -scrape_configs: - - job_name: "konect-backend" - metrics_path: "/actuator/prometheus" - scrape_interval: 10s - file_sd_configs: - - files: - - /etc/prometheus/targets/backend-active.json - refresh_interval: 5s diff --git a/monitoring/promtail.yml b/monitoring/promtail.yml deleted file mode 100644 index 0b7eb3a72..000000000 --- a/monitoring/promtail.yml +++ /dev/null @@ -1,25 +0,0 @@ -server: - http_listen_port: 9080 - grpc_listen_port: 0 - -positions: - filename: /var/lib/promtail/positions.yml - sync_period: 1s - -clients: - - url: http://loki:3100/loki/api/v1/push - -scrape_configs: - - job_name: konect-backend - static_configs: - - targets: [localhost] - labels: - job: konect-backend - __path__: /var/log/konect-backend/konect-backend*.log - - - job_name: nginx - static_configs: - - targets: [localhost] - labels: - job: nginx - __path__: /var/log/nginx/*.log diff --git a/monitoring/scripts/update-backend-active-target.sh b/monitoring/scripts/update-backend-active-target.sh deleted file mode 100755 index 1d243befd..000000000 --- a/monitoring/scripts/update-backend-active-target.sh +++ /dev/null @@ -1,45 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ACTIVE_PORT="${1:-}" - -if [[ -z "${ACTIVE_PORT}" ]]; then - echo "Usage: $0 " >&2 - exit 1 -fi - -if [[ "${ACTIVE_PORT}" != "8080" && "${ACTIVE_PORT}" != "8081" ]]; then - echo "active-port must be 8080 or 8081" >&2 - exit 1 -fi - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -TARGETS_FILE="${TARGETS_FILE:-${SCRIPT_DIR}/../targets/backend-active.json}" -TARGETS_DIR="$(dirname "${TARGETS_FILE}")" -PROMETHEUS_RELOAD_URL="${PROMETHEUS_RELOAD_URL:-http://127.0.0.1:${PROMETHEUS_PORT:-9090}/-/reload}" - -mkdir -p "${TARGETS_DIR}" - -TMP_FILE="$(mktemp)" -trap 'rm -f "${TMP_FILE}"' EXIT - -cat > "${TMP_FILE}" </dev/null; then - echo "warning: failed to reload Prometheus (${PROMETHEUS_RELOAD_URL})" >&2 -fi - -echo "Updated backend metrics target to host.docker.internal:${ACTIVE_PORT}" diff --git a/src/main/resources/application-monitoring.yml b/src/main/resources/application-monitoring.yml deleted file mode 100644 index 4ec70381f..000000000 --- a/src/main/resources/application-monitoring.yml +++ /dev/null @@ -1,28 +0,0 @@ -logging: - ignored-url-patterns: - - /**/api-docs/** - - /**/swagger-ui/** - - /error - - /favicon.ico - - /actuator/** - - /notifications/inbox/stream - -management: - endpoints: - web: - exposure: - include: health, info, prometheus - - endpoint: - health: - show-details: never - - metrics: - distribution: - percentiles-histogram: - http: - server: - requests: true - - tags: - application: konect-backend diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index c81f1d98e..278ad980e 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,3 +1,12 @@ +logging: + ignored-url-patterns: + - /**/api-docs/** + - /**/swagger-ui/** + - /error + - /favicon.ico + - /actuator/** + - /notifications/inbox/stream + spring: application: name: konect-backend @@ -7,7 +16,6 @@ spring: import: - classpath:application-db.yml - classpath:application-infrastructure.yml - - classpath:application-monitoring.yml - classpath:application-security.yml - optional:file:.env.${spring.profiles.active}[.properties] servlet: From 83ff829e24f69c79a8e4f9ef85c4c9a0f3cf4ab8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:09:46 +0900 Subject: [PATCH 10/50] =?UTF-8?q?[codex]=20=EB=A1=9C=EA=B7=B8=20=EC=B6=94?= =?UTF-8?q?=EC=A0=81=20=EC=83=81=EA=B4=80=EA=B4=80=EA=B3=84=EB=A5=BC=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC=ED=95=98=EA=B3=A0=20OTel=20=ED=9D=94?= =?UTF-8?q?=EC=A0=81=EC=9D=84=20=EC=A0=9C=EA=B1=B0=20(#558)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: 로그 추적 상관관계를 정리하고 OTel 흔적을 제거 - 요청 단위 추적이 로그와 비동기 작업 경계에서 끊기지 않도록 requestId 응답 헤더와 MDC 전파를 추가했다 - Datadog과 기존 MDC 키를 함께 읽는 로그 패턴으로 맞춰 배포 환경 차이 때문에 trace 상관관계가 깨지는 상황을 줄였다 - Dockerfile, 예시 환경변수, 배포 워크플로우에서 OpenTelemetry agent 관련 설정을 제거해 현재 운영 기준과 맞지 않는 추적 흔적이 남지 않게 했다 - 관련 단위 테스트를 추가하고 실행해 로깅 상관관계와 설정 회귀를 방지했다 * fix: CORS에서 request id 응답 헤더를 노출 - 응답에 내려주는 X-Request-ID를 브라우저가 읽지 못하면 프론트와 서버 로그를 같은 요청으로 묶기 어려워진다 - WebConfig의 exposedHeaders에 X-Request-ID를 추가해 cross-origin 환경에서도 요청 상관관계 헤더를 읽을 수 있게 했다 - 설정 회귀를 막기 위해 CORS 등록값을 직접 검증하는 단위 테스트를 추가했다 * fix: request id 헤더를 검증 후 로그에 반영 - 클라이언트가 보낸 X-Request-ID를 그대로 신뢰하면 로그 포맷 손상과 추적 품질 저하가 생길 수 있다 - request id를 trim한 뒤 허용 문자와 길이를 검증하고 실패하면 새 값을 발급하도록 바꿔 응답 헤더, 로그, MDC가 같은 정제된 값을 쓰게 했다 - 유효한 값, 공백 포함 값, 잘못된 값을 각각 검증하는 단위 테스트를 추가해 입력 검증 회귀를 막았다 * fix: Datadog 로그 패턴을 canonical key로 정리 - 여러 MDC 키를 이어붙이면 trace와 span 값이 하나의 문자열처럼 보일 수 있어 로그 파싱과 추적 연결이 불안정해진다 - 운영 로그 패턴을 dd.trace_id와 dd.span_id 기준으로 단순화해 한 종류의 canonical key만 출력하도록 정리했다 - 설정 파일 문자열 포함 여부가 아니라 실제 패턴 렌더링 결과를 검증하는 테스트로 바꿔 로그 포맷 회귀를 막았다 * chore: 코드 포맷팅 --- .env.example | 1 - Dockerfile | 2 +- .../konect/global/config/AsyncConfig.java | 5 + .../agit/konect/global/config/WebConfig.java | 2 +- .../global/logging/MdcTaskDecorator.java | 33 ++++++ .../global/logging/RequestLoggingFilter.java | 18 ++- src/main/resources/logback-spring.xml | 6 +- .../unit/global/config/WebConfigTest.java | 53 +++++++++ .../logging/LogbackTracingPatternTest.java | 91 +++++++++++++++ .../global/logging/MdcTaskDecoratorTest.java | 42 +++++++ .../logging/RequestLoggingFilterTest.java | 109 ++++++++++++++++++ 11 files changed, 354 insertions(+), 8 deletions(-) create mode 100644 src/main/java/gg/agit/konect/global/logging/MdcTaskDecorator.java create mode 100644 src/test/java/gg/agit/konect/unit/global/config/WebConfigTest.java create mode 100644 src/test/java/gg/agit/konect/unit/global/logging/LogbackTracingPatternTest.java create mode 100644 src/test/java/gg/agit/konect/unit/global/logging/MdcTaskDecoratorTest.java create mode 100644 src/test/java/gg/agit/konect/unit/global/logging/RequestLoggingFilterTest.java diff --git a/.env.example b/.env.example index bb16d2338..19137532d 100644 --- a/.env.example +++ b/.env.example @@ -58,4 +58,3 @@ CLAUDE_MODEL=claude-sonnet-4-20250514 # MCP Bridge Configuration MCP_BRIDGE_URL=http://localhost:3100 - diff --git a/Dockerfile b/Dockerfile index 3478b1d25..d2a5df041 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,4 +25,4 @@ ENTRYPOINT ["java", \ "-Xlog:gc*:file=/app/logs/gc.log:time,uptime,level,tags:filecount=5,filesize=10m", \ "-XX:+HeapDumpOnOutOfMemoryError", \ "-XX:HeapDumpPath=/app/logs/heapdump.hprof", \ - "-jar", "KONECT_API.jar"] \ No newline at end of file + "-jar", "KONECT_API.jar"] diff --git a/src/main/java/gg/agit/konect/global/config/AsyncConfig.java b/src/main/java/gg/agit/konect/global/config/AsyncConfig.java index f88deb3ef..50f54e027 100644 --- a/src/main/java/gg/agit/konect/global/config/AsyncConfig.java +++ b/src/main/java/gg/agit/konect/global/config/AsyncConfig.java @@ -11,6 +11,7 @@ import org.springframework.scheduling.annotation.AsyncConfigurer; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import gg.agit.konect.global.logging.MdcTaskDecorator; import lombok.extern.slf4j.Slf4j; @Slf4j @@ -44,6 +45,7 @@ public Executor getAsyncExecutor() { executor.setMaxPoolSize(DEFAULT_MAX_POOL_SIZE); executor.setQueueCapacity(DEFAULT_QUEUE_CAPACITY); executor.setThreadNamePrefix("async-default-"); + executor.setTaskDecorator(new MdcTaskDecorator()); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.setWaitForTasksToCompleteOnShutdown(true); executor.setAwaitTerminationSeconds(DEFAULT_AWAIT_TERMINATION_SECONDS); @@ -63,6 +65,7 @@ public Executor sheetSyncTaskExecutor() { executor.setMaxPoolSize(SHEET_SYNC_MAX_POOL_SIZE); executor.setQueueCapacity(SHEET_SYNC_QUEUE_CAPACITY); executor.setThreadNamePrefix("sheet-sync-"); + executor.setTaskDecorator(new MdcTaskDecorator()); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.setWaitForTasksToCompleteOnShutdown(true); executor.setAwaitTerminationSeconds(SHEET_SYNC_AWAIT_TERMINATION_SECONDS); @@ -77,6 +80,7 @@ public Executor notificationTaskExecutor() { executor.setMaxPoolSize(NOTIFICATION_MAX_POOL_SIZE); executor.setQueueCapacity(NOTIFICATION_QUEUE_CAPACITY); executor.setThreadNamePrefix("notification-"); + executor.setTaskDecorator(new MdcTaskDecorator()); executor.setRejectedExecutionHandler((runnable, pool) -> { log.warn("알림 스레드풀 포화로 작업이 거절되었습니다. poolSize={}, activeCount={}, queueSize={}", pool.getPoolSize(), pool.getActiveCount(), pool.getQueue().size()); @@ -95,6 +99,7 @@ public Executor slackTaskExecutor() { executor.setMaxPoolSize(SLACK_MAX_POOL_SIZE); executor.setQueueCapacity(SLACK_QUEUE_CAPACITY); executor.setThreadNamePrefix("slack-"); + executor.setTaskDecorator(new MdcTaskDecorator()); executor.setRejectedExecutionHandler((runnable, pool) -> { log.warn("Slack 스레드풀 포화로 작업이 거절되었습니다. poolSize={}, activeCount={}, queueSize={}", pool.getPoolSize(), pool.getActiveCount(), pool.getQueue().size()); diff --git a/src/main/java/gg/agit/konect/global/config/WebConfig.java b/src/main/java/gg/agit/konect/global/config/WebConfig.java index 42d6ba717..e38c9367e 100644 --- a/src/main/java/gg/agit/konect/global/config/WebConfig.java +++ b/src/main/java/gg/agit/konect/global/config/WebConfig.java @@ -31,7 +31,7 @@ public void addCorsMappings(CorsRegistry registry) { .allowedOrigins(corsProperties.allowedOrigins().toArray(new String[0])) .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH") .allowedHeaders("*") - .exposedHeaders("Authorization") + .exposedHeaders("Authorization", "X-Request-ID") .allowCredentials(true) .maxAge(CORS_PREFLIGHT_MAX_AGE_SECONDS); } diff --git a/src/main/java/gg/agit/konect/global/logging/MdcTaskDecorator.java b/src/main/java/gg/agit/konect/global/logging/MdcTaskDecorator.java new file mode 100644 index 000000000..f63b3830f --- /dev/null +++ b/src/main/java/gg/agit/konect/global/logging/MdcTaskDecorator.java @@ -0,0 +1,33 @@ +package gg.agit.konect.global.logging; + +import java.util.Map; + +import org.slf4j.MDC; +import org.springframework.core.task.TaskDecorator; + +public class MdcTaskDecorator implements TaskDecorator { + + @Override + public Runnable decorate(Runnable runnable) { + Map callerContext = MDC.getCopyOfContextMap(); + + return () -> { + Map workerContext = MDC.getCopyOfContextMap(); + + try { + if (callerContext == null) { + MDC.clear(); + } else { + MDC.setContextMap(callerContext); + } + runnable.run(); + } finally { + if (workerContext == null) { + MDC.clear(); + } else { + MDC.setContextMap(workerContext); + } + } + }; + } +} diff --git a/src/main/java/gg/agit/konect/global/logging/RequestLoggingFilter.java b/src/main/java/gg/agit/konect/global/logging/RequestLoggingFilter.java index 24faaf307..653c762e3 100644 --- a/src/main/java/gg/agit/konect/global/logging/RequestLoggingFilter.java +++ b/src/main/java/gg/agit/konect/global/logging/RequestLoggingFilter.java @@ -3,6 +3,7 @@ import java.io.IOException; import java.util.Objects; import java.util.UUID; +import java.util.regex.Pattern; import org.slf4j.MDC; import org.springframework.beans.factory.ObjectProvider; @@ -32,6 +33,10 @@ public class RequestLoggingFilter extends OncePerRequestFilter { private static final String REQUEST_ID = "requestId"; private static final String REQUEST_ID_HEADER = "X-Request-ID"; + private static final int MAX_REQUEST_ID_LENGTH = 128; + // 응답 헤더와 로그에 그대로 남는 값이므로 제어 문자와 과도한 길이는 허용하지 않는다. + private static final Pattern REQUEST_ID_PATTERN = Pattern.compile( + "[A-Za-z0-9._-]{1," + MAX_REQUEST_ID_LENGTH + "}"); private final ObjectProvider pathMatcherProvider; private final LoggingProperties properties; @@ -54,6 +59,7 @@ public void doFilterInternal(HttpServletRequest request, HttpServletResponse res try { MDC.put(REQUEST_ID, requestId); + cachedResponse.setHeader(REQUEST_ID_HEADER, requestId); stopWatch.start(); log.info("request start [requestId: {}, uri: {} {}]", requestId, method, uri); chain.doFilter(cachedRequest, cachedResponse); @@ -80,8 +86,16 @@ private boolean isIgnoredUrl(HttpServletRequest request) { private String getRequestId(HttpServletRequest httpRequest) { String requestId = httpRequest.getHeader(REQUEST_ID_HEADER); if (ObjectUtils.isEmpty(requestId)) { - return UUID.randomUUID().toString().replace("-", ""); + return generateRequestId(); } - return requestId; + String trimmedRequestId = requestId.trim(); + if (!REQUEST_ID_PATTERN.matcher(trimmedRequestId).matches()) { + return generateRequestId(); + } + return trimmedRequestId; + } + + private String generateRequestId() { + return UUID.randomUUID().toString().replace("-", ""); } } diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml index 77cdd23b6..4d19fd7c2 100644 --- a/src/main/resources/logback-spring.xml +++ b/src/main/resources/logback-spring.xml @@ -9,7 +9,7 @@ - [%d{yyyy-MM-dd'T'HH:mm:ss.SSS}] %clr(%-5level) %clr(${PID:-}){magenta} %clr(---){faint} %clr([%15.15thread]){faint} %clr([trace=%X{trace_id:-}%X{traceId:-} span=%X{span_id:-}%X{spanId:-} request=%X{requestId:-}]){yellow} %clr(%-40.40logger{36}){cyan} : %msg%n + [%d{yyyy-MM-dd'T'HH:mm:ss.SSS}] %clr(%-5level) %clr(${PID:-}){magenta} %clr(---){faint} %clr([%15.15thread]){faint} %clr([trace=%X{dd.trace_id:-} span=%X{dd.span_id:-} request=%X{requestId:-}]){yellow} %clr(%-40.40logger{36}){cyan} : %msg%n @@ -22,7 +22,7 @@ 3GB - [%d{yyyy-MM-dd'T'HH:mm:ss.SSS}] %-5level ${PID:-} --- [%15.15thread] [trace=%X{trace_id:-}%X{traceId:-} span=%X{span_id:-}%X{spanId:-} request=%X{requestId:-}] %-40.40logger{36} : %msg%n + [%d{yyyy-MM-dd'T'HH:mm:ss.SSS}] %-5level ${PID:-} --- [%15.15thread] [trace=%X{dd.trace_id:-} span=%X{dd.span_id:-} request=%X{requestId:-}] %-40.40logger{36} : %msg%n @@ -34,7 +34,7 @@ 1GB - [%d{yyyy-MM-dd'T'HH:mm:ss.SSS}] %-5level [%thread] : %msg%n + [%d{yyyy-MM-dd'T'HH:mm:ss.SSS}] %-5level [%thread] [trace=%X{dd.trace_id:-} span=%X{dd.span_id:-} request=%X{requestId:-}] : %msg%n diff --git a/src/test/java/gg/agit/konect/unit/global/config/WebConfigTest.java b/src/test/java/gg/agit/konect/unit/global/config/WebConfigTest.java new file mode 100644 index 000000000..15f2dc281 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/global/config/WebConfigTest.java @@ -0,0 +1,53 @@ +package gg.agit.konect.unit.global.config; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.lang.reflect.Field; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.web.servlet.config.annotation.CorsRegistration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.cors.CorsConfiguration; + +import gg.agit.konect.global.auth.web.AuthorizationInterceptor; +import gg.agit.konect.global.auth.web.LoginCheckInterceptor; +import gg.agit.konect.global.auth.web.LoginUserArgumentResolver; +import gg.agit.konect.global.config.CorsProperties; +import gg.agit.konect.global.config.WebConfig; + +class WebConfigTest { + + @Test + @DisplayName("CORS 응답은 request id 헤더를 브라우저에 노출한다") + void exposesRequestIdHeader() throws Exception { + // given + WebConfig webConfig = new WebConfig( + new CorsProperties(List.of("http://localhost:3000")), + org.mockito.Mockito.mock(LoginCheckInterceptor.class), + org.mockito.Mockito.mock(AuthorizationInterceptor.class), + org.mockito.Mockito.mock(LoginUserArgumentResolver.class) + ); + CorsRegistry registry = new CorsRegistry(); + + // when + webConfig.addCorsMappings(registry); + + // then + CorsConfiguration corsConfiguration = firstCorsConfiguration(registry); + assertThat(corsConfiguration.getExposedHeaders()) + .contains("Authorization", "X-Request-ID"); + } + + @SuppressWarnings("unchecked") + private CorsConfiguration firstCorsConfiguration(CorsRegistry registry) throws Exception { + Field registrationsField = CorsRegistry.class.getDeclaredField("registrations"); + registrationsField.setAccessible(true); + List registrations = (List)registrationsField.get(registry); + + Field configField = CorsRegistration.class.getDeclaredField("config"); + configField.setAccessible(true); + return (CorsConfiguration)configField.get(registrations.getFirst()); + } +} diff --git a/src/test/java/gg/agit/konect/unit/global/logging/LogbackTracingPatternTest.java b/src/test/java/gg/agit/konect/unit/global/logging/LogbackTracingPatternTest.java new file mode 100644 index 000000000..1785d94fa --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/global/logging/LogbackTracingPatternTest.java @@ -0,0 +1,91 @@ +package gg.agit.konect.unit.global.logging; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.StringReader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +import javax.xml.parsers.DocumentBuilderFactory; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import org.xml.sax.InputSource; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.PatternLayout; +import ch.qos.logback.classic.spi.LoggingEvent; + +class LogbackTracingPatternTest { + + @Test + @DisplayName("운영 로그 패턴은 Datadog canonical MDC 키만 사용한다") + void usesDatadogCanonicalMdcKeys() throws Exception { + // given + String logbackConfig = Files.readString(Path.of("src/main/resources/logback-spring.xml")); + + // when & then + List patterns = tracePatterns(logbackConfig); + assertThat(patterns).hasSize(3); + assertThat(patterns).allSatisfy(pattern -> { + assertThat(pattern).contains("%X{dd.trace_id:-}"); + assertThat(pattern).contains("%X{dd.span_id:-}"); + assertThat(pattern).contains("%X{requestId:-}"); + assertThat(pattern).doesNotContain("%X{trace_id:-}"); + assertThat(pattern).doesNotContain("%X{traceId:-}"); + assertThat(pattern).doesNotContain("%X{span_id:-}"); + assertThat(pattern).doesNotContain("%X{spanId:-}"); + }); + } + + @Test + @DisplayName("로그 패턴은 trace, span, request id를 구분된 값으로 렌더링한다") + void rendersTraceSectionWithCanonicalKeys() { + // given + String pattern = "[trace=%X{dd.trace_id:-} span=%X{dd.span_id:-} request=%X{requestId:-}] %msg%n"; + LoggerContext loggerContext = (LoggerContext)LoggerFactory.getILoggerFactory(); + Logger logger = loggerContext.getLogger("test"); + LoggingEvent loggingEvent = new LoggingEvent( + LogbackTracingPatternTest.class.getName(), + logger, + Level.INFO, + "test message", + null, + null + ); + loggingEvent.setMDCPropertyMap(Map.of( + "dd.trace_id", "123", + "dd.span_id", "456", + "requestId", "req-789" + )); + + PatternLayout layout = new PatternLayout(); + layout.setContext(loggerContext); + layout.setPattern(pattern); + layout.start(); + + // when + String rendered = layout.doLayout(loggingEvent); + + // then + assertThat(rendered).isEqualTo("[trace=123 span=456 request=req-789] test message" + System.lineSeparator()); + } + + private List tracePatterns(String logbackConfig) throws Exception { + var documentBuilderFactory = DocumentBuilderFactory.newInstance(); + documentBuilderFactory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + var documentBuilder = documentBuilderFactory.newDocumentBuilder(); + var document = documentBuilder.parse(new InputSource(new StringReader(logbackConfig))); + var patternNodes = document.getElementsByTagName("pattern"); + + return java.util.stream.IntStream.range(0, patternNodes.getLength()) + .mapToObj(index -> patternNodes.item(index).getTextContent().trim()) + .filter(pattern -> pattern.contains("trace=")) + .toList(); + } +} diff --git a/src/test/java/gg/agit/konect/unit/global/logging/MdcTaskDecoratorTest.java b/src/test/java/gg/agit/konect/unit/global/logging/MdcTaskDecoratorTest.java new file mode 100644 index 000000000..648e7c5c6 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/global/logging/MdcTaskDecoratorTest.java @@ -0,0 +1,42 @@ +package gg.agit.konect.unit.global.logging; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.slf4j.MDC; + +import gg.agit.konect.global.logging.MdcTaskDecorator; + +class MdcTaskDecoratorTest { + + @Test + @DisplayName("task decorator는 호출 스레드의 MDC를 작업 스레드로 전달한다") + void propagatesCallerMdcContext() { + // given + MdcTaskDecorator taskDecorator = new MdcTaskDecorator(); + AtomicReference requestIdInTask = new AtomicReference<>(); + AtomicReference traceIdInTask = new AtomicReference<>(); + MDC.put("requestId", "request-123"); + MDC.put("dd.trace_id", "trace-456"); + + try { + // when + Runnable decoratedTask = taskDecorator.decorate(() -> { + requestIdInTask.set(MDC.get("requestId")); + traceIdInTask.set(MDC.get("dd.trace_id")); + }); + MDC.clear(); + decoratedTask.run(); + + // then + assertThat(requestIdInTask.get()).isEqualTo("request-123"); + assertThat(traceIdInTask.get()).isEqualTo("trace-456"); + assertThat(MDC.getCopyOfContextMap()).isNull(); + } finally { + MDC.clear(); + } + } +} diff --git a/src/test/java/gg/agit/konect/unit/global/logging/RequestLoggingFilterTest.java b/src/test/java/gg/agit/konect/unit/global/logging/RequestLoggingFilterTest.java new file mode 100644 index 000000000..cc874210b --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/global/logging/RequestLoggingFilterTest.java @@ -0,0 +1,109 @@ +package gg.agit.konect.unit.global.logging; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.support.StaticListableBeanFactory; +import org.springframework.util.AntPathMatcher; +import org.springframework.util.PathMatcher; +import org.springframework.mock.web.MockFilterChain; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServlet; + +import gg.agit.konect.global.logging.LoggingProperties; +import gg.agit.konect.global.logging.RequestLoggingFilter; + +class RequestLoggingFilterTest { + + private static final String REQUEST_ID_HEADER = "X-Request-ID"; + + @Test + @DisplayName("유효한 request id 헤더는 응답 헤더에도 그대로 내려준다") + void echoesValidatedRequestIdHeader() throws ServletException, IOException { + // given + RequestLoggingFilter filter = createFilter(); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/notifications/inbox"); + request.addHeader(REQUEST_ID_HEADER, "incoming-request-id"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + // when + filter.doFilter(request, response, new MockFilterChain(new NoOpServlet())); + + // then + assertThat(response.getHeader(REQUEST_ID_HEADER)).isEqualTo("incoming-request-id"); + } + + @Test + @DisplayName("앞뒤 공백이 있는 request id 헤더는 trim 후 응답 헤더에 내려준다") + void trimsIncomingRequestIdHeader() throws ServletException, IOException { + // given + RequestLoggingFilter filter = createFilter(); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/notifications/inbox"); + request.addHeader(REQUEST_ID_HEADER, " incoming-request-id "); + MockHttpServletResponse response = new MockHttpServletResponse(); + + // when + filter.doFilter(request, response, new MockFilterChain(new NoOpServlet())); + + // then + assertThat(response.getHeader(REQUEST_ID_HEADER)).isEqualTo("incoming-request-id"); + } + + @Test + @DisplayName("request id 헤더가 없으면 생성한 값을 응답 헤더로 내려준다") + void generatesAndReturnsRequestIdHeader() throws ServletException, IOException { + // given + RequestLoggingFilter filter = createFilter(); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/notifications/inbox"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + // when + filter.doFilter(request, response, new MockFilterChain(new NoOpServlet())); + + // then + assertThat(response.getHeader(REQUEST_ID_HEADER)).isNotBlank(); + } + + @Test + @DisplayName("유효하지 않은 request id 헤더면 새 값을 생성해 응답 헤더에 내려준다") + void generatesRequestIdForInvalidHeader() throws ServletException, IOException { + // given + RequestLoggingFilter filter = createFilter(); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/notifications/inbox"); + request.addHeader(REQUEST_ID_HEADER, "invalid request id"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + // when + filter.doFilter(request, response, new MockFilterChain(new NoOpServlet())); + + // then + assertThat(response.getHeader(REQUEST_ID_HEADER)) + .isNotBlank() + .isNotEqualTo("invalid request id") + .matches("[A-Za-z0-9._-]{1,128}"); + } + + private RequestLoggingFilter createFilter() { + StaticListableBeanFactory beanFactory = new StaticListableBeanFactory(); + beanFactory.addBean("pathMatcher", new AntPathMatcher()); + ObjectProvider pathMatcherProvider = beanFactory.getBeanProvider(PathMatcher.class); + return new RequestLoggingFilter(pathMatcherProvider, new LoggingProperties(List.of())); + } + + private static class NoOpServlet extends HttpServlet { + + @Override + protected void service(jakarta.servlet.http.HttpServletRequest req, + jakarta.servlet.http.HttpServletResponse resp) { + resp.setStatus(MockHttpServletResponse.SC_OK); + } + } +} From 64df724bfaba768cc2058f00e6e7ac92e6af4489 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Tue, 21 Apr 2026 15:40:45 +0900 Subject: [PATCH 11/50] =?UTF-8?q?fix:=20=EC=B1=84=ED=8C=85=EB=B0=A9=20?= =?UTF-8?q?=EB=A7=88=EC=A7=80=EB=A7=89=20=EB=A9=94=EC=8B=9C=EC=A7=80=20?= =?UTF-8?q?=EB=A9=94=ED=83=80=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=8F=99?= =?UTF-8?q?=EA=B8=B0=ED=99=94=20(#559)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 채팅방 마지막 메시지 메타데이터를 DB에 즉시 동기화 - chat_room.last_message_*가 null로 남아 채팅방 목록과 실제 메시지 상태가 어긋나는 문제를 막는다 - 메시지 저장 직후 chat_room 메타데이터를 명시적으로 업데이트해 영속성 컨텍스트 clear 경로에 의존하지 않도록 정리한다 - 회원가입 환영 메시지처럼 같은 패턴을 쓰는 경로도 함께 맞춰 신규 방의 초기 메타데이터 누락을 방지한다 - 기존 운영 데이터는 Flyway 백필 마이그레이션과 회귀 테스트로 함께 보강해 재발을 막는다 * chore: 코드 포맷팅 * refactor: 마지막 메시지 동기화 코드를 읽기 쉽게 정리 - sendMessage의 명시적 readOnly=false 표기를 기본 @Transactional로 단순화해 클래스 레벨 설정과의 관계만 남긴다 - ChatRoomRepository updateLastMessage 시그니처 타입 표기를 정리해 읽는 부담을 줄인다 - UserService의 환영 메시지 경로도 syncLastMessage 헬퍼를 사용하도록 맞춰 채팅 메타데이터 갱신 책임을 일관되게 유지한다 * fix: 리뷰 지적 반영해 마지막 메시지 갱신 조건을 보강 - last_message 메타데이터 갱신 쿼리에서 clearAutomatically를 제거해 direct room 복구 흐름의 엔티티 변경이 detach로 유실되지 않게 한다 - 최신 메시지보다 오래된 트랜잭션이 chat_room 마지막 메시지 요약을 덮어쓰지 못하도록 messageId tie-breaker 조건을 추가한다 - direct room 재노출과 조건부 메타데이터 갱신을 테스트로 고정해 리뷰에서 지적된 회귀를 막는다 * fix: 마지막 메시지 동기화의 자기 자신 비교를 제외 - 마지막 메시지 갱신 조건에서 현재 저장한 메시지 자신은 비교 대상에서 제외해 DB timestamp 정밀도 차이로 인한 오판을 막는다 - direct 채팅방 목록과 재입장 시나리오가 chat_room.last_message_* 컬럼에 의존하므로 간헐적으로 이전 메시지가 남는 상태를 방지한다 - CI에서 흔들리던 채팅방 나가기/마지막 메시지 메타데이터 테스트와 jacoco 경로를 로컬에서 다시 검증했다 --- .../chat/repository/ChatRoomRepository.java | 26 ++++++++++ .../domain/chat/service/ChatService.java | 20 ++++++-- .../domain/user/service/UserService.java | 14 +++++- ...ckfill_chat_room_last_message_metadata.sql | 24 +++++++++ .../integration/domain/chat/ChatApiTest.java | 38 ++++++++++++++ .../domain/chat/service/ChatServiceTest.java | 50 +++++++++++++++++++ 6 files changed, 168 insertions(+), 4 deletions(-) create mode 100644 src/main/resources/db/migration/V70__backfill_chat_room_last_message_metadata.sql diff --git a/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomRepository.java b/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomRepository.java index a3a064f5b..f2789c553 100644 --- a/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomRepository.java +++ b/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomRepository.java @@ -2,9 +2,11 @@ import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_CHAT_ROOM; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.Repository; import org.springframework.data.repository.query.Param; @@ -19,6 +21,30 @@ public interface ChatRoomRepository extends Repository { ChatRoom save(ChatRoom chatRoom); + @Modifying(flushAutomatically = true) + @Query(""" + UPDATE ChatRoom cr + SET cr.lastMessageContent = :content, + cr.lastMessageSentAt = :sentAt + WHERE cr.id = :roomId + AND NOT EXISTS ( + SELECT 1 + FROM ChatMessage cm + WHERE cm.chatRoom.id = :roomId + AND cm.id <> :messageId + AND ( + cm.createdAt > :sentAt + OR (cm.createdAt = :sentAt AND cm.id > :messageId) + ) + ) + """) + int updateLastMessageIfLatest( + @Param("roomId") Integer roomId, + @Param("messageId") Integer messageId, + @Param("content") String content, + @Param("sentAt") LocalDateTime sentAt + ); + @Query(""" SELECT DISTINCT cr FROM ChatRoom cr diff --git a/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java b/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java index 1aa03cc44..8b43f73ba 100644 --- a/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java +++ b/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java @@ -746,7 +746,7 @@ private ChatMessageDetailResponse sendDirectMessage( senderMember.restoreDirectRoom(); } - chatRoom.updateLastMessage(chatMessage.getContent(), chatMessage.getCreatedAt()); + syncLastMessage(chatRoom, chatMessage); members.stream() .filter(member -> !member.getUserId().equals(userId)) .filter(ChatRoomMember::hasLeft) @@ -828,7 +828,7 @@ private ChatMessageDetailResponse sendClubMessageByRoomId(Integer roomId, Intege ensureRoomMember(room, sender, member.getCreatedAt()); ChatMessage message = chatMessageRepository.save(ChatMessage.of(room, sender, content)); - room.updateLastMessage(message.getContent(), message.getCreatedAt()); + syncLastMessage(room, message); updateClubMessageLastReadAt(roomId, userId, message.getCreatedAt()); List members = chatRoomMemberRepository.findByChatRoomId(roomId); @@ -909,7 +909,7 @@ private ChatMessageDetailResponse sendGroupMessageByRoomId(Integer roomId, Integ } ChatMessage message = chatMessageRepository.save(ChatMessage.of(room, sender, content)); - room.updateLastMessage(message.getContent(), message.getCreatedAt()); + syncLastMessage(room, message); updateLastReadAt(roomId, userId, message.getCreatedAt()); List members = chatRoomMemberRepository.findByChatRoomId(roomId); @@ -1210,6 +1210,20 @@ private void publishAdminChatEventIfNeeded(boolean isSystemAdminRoom, User sende } } + private void syncLastMessage(ChatRoom room, ChatMessage message) { + // 채팅방 목록은 chat_room.last_message_*를 직접 조회하므로 + // 동시 전송에서도 가장 최신 메시지만 메타데이터를 덮어쓰도록 DB 조건을 같이 건다. + int updated = chatRoomRepository.updateLastMessageIfLatest( + room.getId(), + message.getId(), + message.getContent(), + message.getCreatedAt() + ); + if (updated > 0) { + room.updateLastMessage(message.getContent(), message.getCreatedAt()); + } + } + private ChatRoomMember getRoomMember(Integer roomId, Integer userId) { return chatRoomMemberRepository.findByChatRoomIdAndUserId(roomId, userId) .orElseThrow(() -> CustomException.of(FORBIDDEN_CHAT_ROOM_ACCESS)); diff --git a/src/main/java/gg/agit/konect/domain/user/service/UserService.java b/src/main/java/gg/agit/konect/domain/user/service/UserService.java index aa3edc2e2..76ec4a8ad 100644 --- a/src/main/java/gg/agit/konect/domain/user/service/UserService.java +++ b/src/main/java/gg/agit/konect/domain/user/service/UserService.java @@ -131,12 +131,24 @@ private void sendWelcomeMessage(User newUser) { ChatMessage chatMessage = chatMessageRepository.save( ChatMessage.of(chatRoom, operator, DEFAULT_WELCOME_MESSAGE) ); - chatRoom.updateLastMessage(chatMessage.getContent(), chatMessage.getCreatedAt()); + syncLastMessage(chatRoom, chatMessage); } catch (Exception e) { log.warn("회원가입 환영 메시지 전송 실패. userId={}", newUser.getId(), e); } } + private void syncLastMessage(ChatRoom chatRoom, ChatMessage chatMessage) { + int updated = chatRoomRepository.updateLastMessageIfLatest( + chatRoom.getId(), + chatMessage.getId(), + chatMessage.getContent(), + chatMessage.getCreatedAt() + ); + if (updated > 0) { + chatRoom.updateLastMessage(chatMessage.getContent(), chatMessage.getCreatedAt()); + } + } + private UnRegisteredUser findUnregisteredUser(String email, String providerId, Provider provider) { if (StringUtils.hasText(providerId)) { if (unRegisteredUserRepository.existsByProviderIdAndProvider(providerId, provider)) { diff --git a/src/main/resources/db/migration/V70__backfill_chat_room_last_message_metadata.sql b/src/main/resources/db/migration/V70__backfill_chat_room_last_message_metadata.sql new file mode 100644 index 000000000..3fb3d0cdb --- /dev/null +++ b/src/main/resources/db/migration/V70__backfill_chat_room_last_message_metadata.sql @@ -0,0 +1,24 @@ +-- 채팅방 목록/관리 쿼리는 chat_room.last_message_*를 직접 사용하므로 +-- 기존 메시지 이력이 있는 방도 최신 메시지 메타데이터를 다시 맞춰 준다. +-- MAX(created_at)으로 최신 메시지를 고르고, 같은 시각이면 id로 타이브레이크한다. +UPDATE chat_room cr +LEFT JOIN ( + SELECT + cm1.chat_room_id, + cm1.content, + cm1.created_at + FROM chat_message cm1 + JOIN ( + SELECT chat_room_id, MAX(created_at) AS max_created_at + FROM chat_message + GROUP BY chat_room_id + ) cm2 ON cm2.chat_room_id = cm1.chat_room_id AND cm2.max_created_at = cm1.created_at + WHERE cm1.id = ( + SELECT MAX(id) + FROM chat_message + WHERE chat_room_id = cm1.chat_room_id + AND created_at = cm2.max_created_at + ) +) latest_msg ON latest_msg.chat_room_id = cr.id +SET cr.last_message_content = latest_msg.content, + cr.last_message_sent_at = latest_msg.created_at; diff --git a/src/test/java/gg/agit/konect/integration/domain/chat/ChatApiTest.java b/src/test/java/gg/agit/konect/integration/domain/chat/ChatApiTest.java index b7a479826..fb90e01f6 100644 --- a/src/test/java/gg/agit/konect/integration/domain/chat/ChatApiTest.java +++ b/src/test/java/gg/agit/konect/integration/domain/chat/ChatApiTest.java @@ -718,6 +718,35 @@ void sendMessageSuccess() throws Exception { .containsExactly("안녕하세요"); } + @Test + @DisplayName("메시지를 전송하면 chat_room last message 메타데이터도 함께 갱신된다") + @Sql( + statements = CHAT_TEST_DATA_CLEANUP_SQL, + config = @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED), + executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD + ) + void sendMessageUpdatesChatRoomLastMessageColumns() throws Exception { + // given + ChatRoom chatRoom = createDirectChatRoom(normalUser, targetUser); + mockLoginUser(normalUser.getId()); + + // when + performPost("/chats/rooms/" + chatRoom.getId() + "/messages", new ChatMessageSendRequest("메타데이터 확인")) + .andExpect(status().isOk()); + + // then + TestTransaction.flagForCommit(); + TestTransaction.end(); + + transactionTemplate.execute(status -> { + clearPersistenceContext(); + ChatRoom updatedRoom = chatRoomRepository.findById(chatRoom.getId()).orElseThrow(); + assertThat(updatedRoom.getLastMessageContent()).isEqualTo("메타데이터 확인"); + assertThat(updatedRoom.getLastMessageSentAt()).isNotNull(); + return null; + }); + } + @Test @DisplayName("관리자가 문의방에 답변하면 실제 문의 사용자에게 알림을 보낸다") void adminReplySendsNotificationToInquiryUser() throws Exception { @@ -910,6 +939,15 @@ void leaveDirectChatRoomAndShowOnlyNewMessages() throws Exception { performPost("/chats/rooms/" + chatRoom.getId() + "/messages", new ChatMessageSendRequest("다시 안녕")) .andExpect(status().isOk()); + transactionTemplate.execute(status -> { + clearPersistenceContext(); + ChatRoomMember restoredMember = chatRoomMemberRepository + .findByChatRoomIdAndUserId(chatRoom.getId(), normalUser.getId()) + .orElseThrow(); + assertThat(restoredMember.hasLeft()).isFalse(); + return null; + }); + mockLoginUser(normalUser.getId()); performGet("/chats/rooms") .andExpect(status().isOk()) diff --git a/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatServiceTest.java index 45760b453..e28c3856e 100644 --- a/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatServiceTest.java +++ b/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatServiceTest.java @@ -825,6 +825,9 @@ void sendMessageInDirectRoomSavesMessageAndSendsNotification() { given(chatRoomMemberRepository.findByChatRoomId(directRoom.getId())) .willReturn(List.of(senderMember, receiverMember)); given(chatMessageRepository.save(any(ChatMessage.class))).willReturn(savedMessage); + given(chatRoomRepository.updateLastMessageIfLatest( + directRoom.getId(), savedMessage.getId(), savedMessage.getContent(), savedMessage.getCreatedAt() + )).willReturn(1); given(chatRoomMemberRepository.updateLastReadAtIfOlder(eq(directRoom.getId()), eq(senderId), any(LocalDateTime.class))) .willReturn(1); @@ -843,6 +846,44 @@ void sendMessageInDirectRoomSavesMessageAndSendsNotification() { eq("hello")); } + @Test + @DisplayName("sendMessage는 이미 더 최신 메시지가 있으면 room 마지막 메시지 메타데이터를 덮어쓰지 않는다") + void sendMessageDoesNotOverwriteRoomMetadataWhenNewerMessageAlreadyExists() { + Integer senderId = 10; + Integer receiverId = 20; + User sender = createUser(senderId, "보낸이", UserRole.USER); + User receiver = createUser(receiverId, "받는이", UserRole.USER); + ChatRoom directRoom = createRoom(1, ChatType.DIRECT, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember senderMember = createRoomMember(directRoom, sender, false, + LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember receiverMember = createRoomMember(directRoom, receiver, false, + LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatMessage savedMessage = createMessage(100, directRoom, sender, "older", + LocalDateTime.of(2026, 4, 11, 10, 1)); + + ReflectionTestUtils.setField(directRoom, "lastMessageContent", "newer"); + ReflectionTestUtils.setField(directRoom, "lastMessageSentAt", LocalDateTime.of(2026, 4, 11, 10, 2)); + + given(chatRoomRepository.findById(directRoom.getId())).willReturn(Optional.of(directRoom)); + given(userRepository.getById(senderId)).willReturn(sender); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(directRoom.getId(), senderId)) + .willReturn(Optional.of(senderMember)); + given(chatRoomMemberRepository.findByChatRoomId(directRoom.getId())) + .willReturn(List.of(senderMember, receiverMember)); + given(chatMessageRepository.save(any(ChatMessage.class))).willReturn(savedMessage); + given(chatRoomRepository.updateLastMessageIfLatest( + directRoom.getId(), savedMessage.getId(), savedMessage.getContent(), savedMessage.getCreatedAt() + )).willReturn(0); + given(chatRoomMemberRepository.updateLastReadAtIfOlder(eq(directRoom.getId()), eq(senderId), + any(LocalDateTime.class))) + .willReturn(1); + + chatService.sendMessage(senderId, directRoom.getId(), new ChatMessageSendRequest("older")); + + assertThat(directRoom.getLastMessageContent()).isEqualTo("newer"); + assertThat(directRoom.getLastMessageSentAt()).isEqualTo(LocalDateTime.of(2026, 4, 11, 10, 2)); + } + @Test @DisplayName("sendMessage는 group room에서 메시지를 저장하고 그룹 알림을 보낸다") void sendMessageInGroupRoomSavesMessageAndSendsGroupNotification() { @@ -860,6 +901,9 @@ void sendMessageInGroupRoomSavesMessageAndSendsGroupNotification() { given(chatRoomMemberRepository.findByChatRoomIdAndUserId(groupRoom.getId(), senderId)) .willReturn(Optional.of(senderMember)); given(chatMessageRepository.save(any(ChatMessage.class))).willReturn(savedMessage); + given(chatRoomRepository.updateLastMessageIfLatest( + groupRoom.getId(), savedMessage.getId(), savedMessage.getContent(), savedMessage.getCreatedAt() + )).willReturn(1); given(chatRoomMemberRepository.updateLastReadAtIfOlder(eq(groupRoom.getId()), eq(senderId), any(LocalDateTime.class))) .willReturn(1); @@ -926,6 +970,9 @@ void sendMessageInClubRoomSavesMessageAndSendsGroupNotification() { given(chatRoomMemberRepository.findByChatRoomIdAndUserId(clubRoom.getId(), senderId)) .willReturn(Optional.of(senderRoomMember)); given(chatMessageRepository.save(any(ChatMessage.class))).willReturn(savedMessage); + given(chatRoomRepository.updateLastMessageIfLatest( + clubRoom.getId(), savedMessage.getId(), savedMessage.getContent(), savedMessage.getCreatedAt() + )).willReturn(1); given(chatRoomMemberRepository.updateLastReadAtIfOlder(eq(clubRoom.getId()), eq(senderId), any(LocalDateTime.class))) .willReturn(1); @@ -972,6 +1019,9 @@ void sendMessageAdminBypassesMembershipInSystemAdminRoom() { given(chatRoomMemberRepository.findByChatRoomId(systemAdminRoom.getId())) .willReturn(List.of(systemAdminMember, targetMember)); given(chatMessageRepository.save(any(ChatMessage.class))).willReturn(savedMessage); + given(chatRoomRepository.updateLastMessageIfLatest( + systemAdminRoom.getId(), savedMessage.getId(), savedMessage.getContent(), savedMessage.getCreatedAt() + )).willReturn(1); // when ChatMessageDetailResponse response = chatService.sendMessage(adminId, systemAdminRoom.getId(), From d915d84541028b2fffb0af2886d010764517a49c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Tue, 21 Apr 2026 21:28:03 +0900 Subject: [PATCH 12/50] =?UTF-8?q?chore:=20.gitignore=20=ED=95=AD=EB=AA=A9?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +++ package-lock.json | 6 ------ skills-lock.json | 10 ---------- 3 files changed, 3 insertions(+), 16 deletions(-) delete mode 100644 package-lock.json delete mode 100644 skills-lock.json diff --git a/.gitignore b/.gitignore index 08f6460cd..fd394e626 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,6 @@ mcp-bridge/.env **/google-service-account.json .omx/ + +skills-lock.json +/package-lock.json diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 0768fb21e..000000000 --- a/package-lock.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "KONECT_BACK_END", - "lockfileVersion": 3, - "requires": true, - "packages": {} -} diff --git a/skills-lock.json b/skills-lock.json deleted file mode 100644 index ec5db745d..000000000 --- a/skills-lock.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "version": 1, - "skills": { - "gh-address-comments": { - "source": "smithery.ai", - "sourceType": "well-known", - "computedHash": "39acf09da5896afde2b61b22f4354a72dbed6633da9e8a578eacec4899b0ecfb" - } - } -} From a6879e60f7b10de8c60ac636fe430c8f2536ae3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Wed, 22 Apr 2026 17:07:03 +0900 Subject: [PATCH 13/50] =?UTF-8?q?docs:=20=EC=B1=84=ED=8C=85=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EA=B0=80=EC=9D=B4=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=ED=9A=8C=EA=B7=80=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EB=B3=B4=EA=B0=95=20(#562)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: 채팅 도메인 정책 가이드를 추가 - 채팅 도메인 작업 전에 AI가 먼저 읽어야 할 정책 중심 가이드를 chat 하위 AGENTS.md로 정리했다. - direct, group, club group, SYSTEM_ADMIN 문의방의 차이와 목록 요약, 읽음, 멤버십, 검색, 초대 정책을 한 문서에 모아 회귀 포인트를 드러냈다. - 마지막 메시지, unreadCount, visibleMessageFrom, 문의방 재사용처럼 수정 시 쉽게 놓치는 연쇄 영향을 명시해 안전한 변경 기준을 만들었다. * test: 채팅 정책 회귀 테스트를 보강 - 문의방 멤버 목록 조회, 문의방 admin 뮤트, 일반 group 검색 제외를 통합 테스트로 추가해 문서화한 정책을 직접 잠갔다. - 테스트 작성 과정에서 문의방 senderId 표현 정책이 문서처럼 단정적이지 않다는 점을 확인해 AGENTS 문구를 실제 동작 기준의 주의사항으로 낮췄다. - 문서와 테스트를 함께 조정해 채팅 도메인 가이드가 현재 구현과 어긋난 전제를 주입하지 않도록 정리했다. - 검증은 ChatApiTest 단위로 실행해 새 케이스와 기존 채팅 API 시나리오가 함께 통과하는지 확인했다. * docs: 문의방 senderId 문구 실제 동작 기준으로 조정 * test: ChatApiTest 타입 참조를 import로 정리 - parseChatRoomId와 extractRoomIds에서 fully-qualified 타입 참조를 제거해 테스트 가독성을 높였다. - MvcResult, JsonNode, ArrayList import를 추가하고 시그니처와 지역 변수 선언을 단순 타입으로 맞췄다. - 로직은 바꾸지 않고 표현만 정리해 리뷰 지적을 반영했다. --- .../java/gg/agit/konect/domain/chat/AGENTS.md | 270 ++++++++++++++++++ .../integration/domain/chat/ChatApiTest.java | 120 ++++++-- 2 files changed, 368 insertions(+), 22 deletions(-) create mode 100644 src/main/java/gg/agit/konect/domain/chat/AGENTS.md diff --git a/src/main/java/gg/agit/konect/domain/chat/AGENTS.md b/src/main/java/gg/agit/konect/domain/chat/AGENTS.md new file mode 100644 index 000000000..a6e10ebb9 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/chat/AGENTS.md @@ -0,0 +1,270 @@ +# 채팅 도메인 가이드 + +## 이 도메인은 무엇을 하는가 + +채팅 도메인은 사용자가 채팅방에서 메시지를 주고받고, 읽음 상태를 관리하고, 채팅방 목록에서 현재 대화 상태를 확인하게 하는 도메인이다. + +이 도메인에서 중요한 것은 메시지 저장 자체보다 아래 상태가 서로 같은 정책을 바라보는 것이다. + +- 메시지 자체 +- 사용자별 `lastReadAt` +- 채팅방 목록에 보이는 마지막 메시지와 마지막 전송 시각 +- 사용자별 `unreadCount` +- 멤버십 상태와 메시지 가시 범위 + +채팅 관련 작업을 할 때는 항상 "이 변경이 목록 요약, 읽음 상태, 멤버십 가시성까지 같이 맞는가"를 먼저 확인해야 한다. + +## 채팅방 타입 + +### `DIRECT` + +- 1:1 채팅방이다. +- 멤버가 나가도 레코드를 바로 삭제하지 않는다. +- `leftAt`, `visibleMessageFrom`, `lastReadAt`로 사용자의 현재 상태를 관리한다. +- 나간 뒤에는 과거 메시지가 그대로 다시 보이는 것이 아니라, 현재 사용자에게 다시 보여야 하는 메시지 범위가 따로 관리된다. +- 새 메시지가 오거나 사용자가 다시 대화를 열면 방이 다시 보이는 상태로 복원될 수 있다. +- 일반 direct와 `SYSTEM_ADMIN` 문의방은 같은 `DIRECT` 타입이지만 멤버십/읽음 처리 정책이 다를 수 있다. + +### `GROUP` + +- 일반 그룹 채팅방이다. +- direct처럼 `나감 -> 복원` 상태를 유지하기보다 현재 참여 여부 중심으로 관리한다. +- 나가면 멤버 제거에 가깝게 동작한다. +- 강퇴는 group 계열에서만 허용된다. + +### `CLUB_GROUP` + +- 동아리 기반 그룹 채팅방이다. +- 사용자가 직접 나갈 수 없다. +- 접근 가능 여부는 채팅방 멤버 레코드만이 아니라 동아리 멤버십과 함께 본다. +- 동아리 멤버 변화에 따라 채팅방 접근 가능 여부와 멤버 보장이 함께 움직일 수 있다. + +### `SYSTEM_ADMIN` 문의방 + +- 구현상 `DIRECT` 채팅방이지만 일반 1:1 방과 똑같이 보면 안 된다. +- 방 재사용 기준은 `SYSTEM_ADMIN + 일반 사용자` 2인 구조다. +- 다른 admin 사용자들도 같은 문의방을 조회하고 메시지를 보낼 수 있지만, 멤버로 추가되지는 않는다. +- 일반 admin을 멤버로 추가하면 `findByTwoUsers` 재사용 규칙이 깨져 문의방이 중복 생성될 수 있다. +- unreadCount와 읽음 기준도 일반 direct와 달리 `SYSTEM_ADMIN` 기준으로 집계되는 흐름이 있다. + +## 기능이 실제로 어떻게 동작해야 하는가 + +### 채팅방 생성/재사용 + +- direct 채팅방은 같은 두 사용자 조합이면 기존 방을 재사용한다. +- admin이 일반 사용자와 direct 채팅을 시작하면 일반 admin과의 1:1 방이 아니라 `SYSTEM_ADMIN` 문의방 재사용 정책을 따른다. +- 다른 admin이 같은 일반 사용자와 문의를 이어도 새로운 방을 만들지 않고 같은 `SYSTEM_ADMIN` 문의방을 본다. +- group 채팅방 생성은 요청자 자신을 제외한 중복 없는 사용자 집합을 기준으로 한다. +- 자기 자신만으로는 direct/group 채팅방을 만들 수 없다. +- club group 채팅방은 동아리별로 하나를 보장하려고 하며, 동시 생성 상황에서는 중복 생성 대신 재조회한다. + +### 메시지 전송 + +- 메시지를 보내면 메시지 저장만 끝나면 안 된다. +- `chat_room.last_message_*` 메타데이터도 함께 최신 상태로 맞아야 한다. +- direct 채팅방에서는 상대방이 이미 나간 상태였다면, 새 메시지에 의해 다시 보여야 하는지까지 함께 처리한다. +- direct 채팅방에서 보낸 사람이 나간 상태였다면, 본인이 다시 대화를 시작한 것으로 보고 나간 상태를 해제한다. +- 보낸 사람의 `lastReadAt`도 새 메시지 시각에 맞춰 갱신된다. +- club group과 group 메시지는 전송 시 발신자의 읽음 기준을 함께 올린다. +- club group 메시지는 발신자가 현재 동아리 멤버여야 하며, 필요하면 채팅방 멤버십도 보장한다. +- group 메시지는 `leftAt`이 있는 사용자가 보낼 수 없다. +- 문의방에서 일반 admin이 메시지를 보내는 경우는 일반 direct와 달리 admin 멤버십을 추가하지 않고 특수 경로로 처리한다. +- 따라서 메시지 전송 로직 수정은 항상 마지막 메시지, 목록 요약, unreadCount, direct 방 복원 정책과 함께 본다. + +### 읽음 처리 + +- 읽음 처리는 별도 boolean이 아니라 `lastReadAt` 기준 시각 갱신이다. +- 더 최신 시각으로만 갱신되어야 한다. +- unreadCount는 `lastReadAt`만으로 계산되지 않는다. +- direct 채팅방에서는 `visibleMessageFrom` 이후에 실제로 보이는 메시지 범위와 함께 계산되어야 한다. +- 읽음 처리와 unreadCount가 서로 다른 메시지 집합을 바라보면 바로 회귀가 난다. +- 문의방에서 다른 admin이 메시지를 읽는 경우도 admin 본인 멤버가 아니라 `SYSTEM_ADMIN`의 `lastReadAt`을 갱신한다. +- 읽음 기준 갱신은 항상 더 최신 시각에만 반영되어야 하며, 이전 시각으로 되돌아가면 안 된다. + +### 채팅방 목록 요약 + +- 채팅방 목록은 단순 방 목록이 아니라 사용자에게 보이는 현재 대화 상태 요약이다. +- 마지막 메시지, 마지막 전송 시각, unreadCount, 방 이름, mute 여부가 함께 조합된다. +- direct 채팅방은 현재 사용자에게 보이지 않는 메시지를 마지막 메시지처럼 노출하면 안 된다. +- direct 채팅방은 나간 상태여도 새 메시지가 있으면 목록에 다시 보일 수 있다. +- direct와 group 계열은 목록 구성 방식이 다르므로 공통 처리로 단순화할 때 특히 조심해야 한다. +- direct 방 이름은 상대 사용자 이름이 기본값이지만, 사용자별 커스텀 방 이름이 우선 적용될 수 있다. +- club group 방 이름은 동아리 이름이 기본값이다. +- 일반 group 방 이름은 기본적으로 `그룹 채팅`이다. +- mute 여부는 채팅방 단위 설정으로 목록 응답에 합쳐진다. +- admin이 보는 문의방 목록은 일반 direct 목록과 별도 최적화 조회를 사용하며, 사용자가 실제로 응답한 방만 노출한다. +- direct 방 목록은 `chat_room.last_message_*`를 직접 쓰지만, club/group 목록은 최신 메시지 조회 결과를 조합한다. 둘을 같은 방식이라고 가정하면 안 된다. + +### 방 이름 변경과 뮤트 + +- 커스텀 방 이름은 사용자별 설정이다. +- 커스텀 방 이름은 기본 방 이름을 덮어쓰지만, 다른 사용자에게는 영향을 주지 않는다. +- 공백만 들어오면 커스텀 방 이름을 제거하고 기본 이름으로 되돌린다. +- 뮤트 설정도 사용자별 채팅방 설정이다. +- 뮤트 토글은 direct, group, club group 모두 현재 사용자가 접근 가능한 방에서만 가능하다. +- 문의방에서는 admin이 멤버가 아니어도 같은 `SYSTEM_ADMIN` 문의방에 접근 가능한 경우 뮤트 토글이 허용된다. + +### 멤버십 변경 + +- direct 채팅방에서 나가기는 멤버 삭제가 아니라 상태 변경이다. +- direct 채팅방에서 나가면 `leftAt`이 기록되고, `visibleMessageFrom`과 `lastReadAt`도 함께 조정된다. +- direct 채팅방은 상대방의 새 메시지로 다시 보이는 상태가 될 수 있다. +- direct 채팅방은 사용자가 직접 다시 대화를 열 때 새 대화처럼 다시 시작하는 정책이 있다. +- group 채팅방과 club group 채팅방은 direct처럼 복원 정책을 쓰지 않는다. +- club group은 임의로 나갈 수 없다. +- 강퇴는 group 계열에서만 허용되며, 요청자 권한과 대상 조건을 함께 본다. +- 그룹 채팅방 멤버 목록 조회는 `leftAt IS NULL`인 active 멤버만 대상으로 하며, 삭제된 사용자도 제외한다. +- 다른 admin은 `SYSTEM_ADMIN` 문의방의 멤버 목록을 조회할 수 있다. +- club 멤버가 추가되면 club group 채팅방 멤버도 보장될 수 있고, club 멤버에서 제거되면 채팅방 멤버도 제거될 수 있다. +- 멤버 추가와 방 생성은 동시성 상황에서 중복 생성이 나도 결과적으로 동일한 방/멤버십으로 수렴하도록 처리한다. + +### 메시지 조회와 접근 + +- direct 메시지 조회는 `visibleMessageFrom` 이후 메시지만 보여야 한다. +- direct 방을 조회할 때 사용자가 이미 나간 상태라도 새 메시지가 있어 다시 볼 수 있는 상태면 조회와 함께 복원될 수 있다. +- group 메시지 조회는 현재 active 멤버만 허용된다. +- club group 메시지 조회는 채팅 멤버 테이블만이 아니라 현재 동아리 멤버십 기준으로 접근을 보장한다. +- messageId로 특정 메시지 페이지를 계산할 때는 방 접근 권한과 메시지 가시 범위를 먼저 검증해야 한다. +- 권한 없음과 메시지 미존재를 구분해서 정보가 새지 않도록, 특정 조회 경로는 동일한 `NOT_FOUND_CHAT_ROOM`으로 처리한다. + +### 검색 + +- 채팅 검색은 `방 이름 검색`과 `메시지 내용 검색` 결과를 함께 반환한다. +- 현재 구현의 채팅 검색 대상은 direct와 club group 방이며, 일반 group 방은 검색 대상에 포함되지 않는다. +- 방 이름 검색은 현재 사용자에게 접근 가능한 방 집합만 대상으로 한다. +- 메시지 내용 검색은 각 방에서 키워드와 매칭되는 가장 최신 메시지만 대상으로 한다. +- direct 채팅방 메시지 검색은 `visibleMessageFrom` 이전 메시지를 결과에 포함하면 안 된다. +- 검색 결과의 방 이름도 커스텀 방 이름과 기본 방 이름 정책을 함께 반영해야 한다. + +### 초대 가능 사용자 조회 + +- 초대 가능 사용자는 현재 사용자가 같은 active 채팅방에서 함께 본 적 있는 사용자 집합을 기준으로 한다. +- 자기 자신은 제외한다. +- 나간 멤버는 제외한다. +- 삭제된 사용자는 제외한다. +- admin 사용자는 초대 후보에서 제외한다. +- 이름순 정렬과 동아리순 정렬은 다른 정책이다. +- 동아리순 정렬은 "현재 요청자와 실제로 공유하는 대표 동아리"를 기준으로 섹션을 만든다. +- 공유 동아리가 없는 사용자는 `기타` 섹션으로 떨어질 수 있다. + +## 절대 놓치면 안 되는 정책 + +- 마지막 메시지 메타데이터는 사용자에게 실제로 보이는 최신 메시지 기준과 어긋나면 안 된다. +- unreadCount는 `lastReadAt`만이 아니라 메시지 가시 범위와 함께 계산되어야 한다. +- direct 채팅방에서 `leftAt`, `visibleMessageFrom`, `lastReadAt`는 서로 분리된 독립 값처럼 다루면 안 된다. +- direct 채팅방의 나감, 복원, 재오픈 정책은 group 계열에 그대로 일반화하면 안 된다. +- club group 접근 가능 여부는 채팅 멤버 테이블만으로 판단하지 않는다. +- direct 채팅방에서 보이지 않는 메시지를 목록 요약이나 검색 결과의 기준으로 삼으면 안 된다. +- 문의방에서 일반 admin을 멤버로 추가하는 변경은 방 재사용 정책을 깨뜨릴 수 있으므로 금지에 가깝게 다뤄야 한다. +- 권한 없음과 메시지 미존재를 다른 에러로 노출하면 messageId 조회/검색 경로에서 정보 누출이 생길 수 있다. +- `chat_room.last_message_*`는 동시 전송 상황에서도 가장 최신 메시지만 반영되어야 한다. +- club group과 group의 멤버 삭제 정책을 direct의 `leftAt` 상태 관리로 바꾸면 안 된다. +- 검색이 모든 방 타입을 다루는 것으로 가정하면 안 된다. 현재 검색 정책은 목록 정책과 범위가 다를 수 있다. + +## 수정 시 함께 확인해야 하는 것 + +### 메시지 저장 로직을 바꿀 때 + +- 마지막 메시지 메타데이터 +- direct 방 복원 여부 +- 보낸 사람 읽음 기준 갱신 +- 목록 요약 정렬 +- 문의방 특수 처리 +- 알림/이벤트 후속 효과 + +### 읽음 처리 로직을 바꿀 때 + +- unreadCount 계산 +- direct 방 가시 범위와의 일관성 +- 목록 배지와 요약 값 +- 문의방의 `SYSTEM_ADMIN` 읽음 기준 처리 + +### direct 멤버십 정책을 바꿀 때 + +- `leftAt` +- `visibleMessageFrom` +- `lastReadAt` +- 방 재노출 조건 +- 과거 메시지 노출 범위 +- 검색/메시지 점프 가시성 +- 목록 재노출 조건 + +### group / club group 멤버십 정책을 바꿀 때 + +- 접근 권한 +- 멤버 제거와 강퇴 정책 +- 목록 노출 여부 +- club 멤버십 연동 + +### 목록/검색 쿼리를 바꿀 때 + +- 커스텀 방 이름 우선순위 +- mute 여부 합성 +- 검색 대상 방 타입 범위 +- direct 방의 가시 메시지 기준 +- messageId 페이지 계산 정렬 일관성 +- 문의방 목록 최적화 쿼리 조건 + +### 초대 가능 사용자 조회를 바꿀 때 + +- active 멤버만 포함되는지 +- admin/삭제 사용자 제외가 유지되는지 +- 동아리 섹션 기준이 실제 공유 동아리인지 +- `기타` 섹션 분류가 유지되는지 + +## 주요 클래스와 책임 + +### `ChatService` + +- 메시지 전송, 목록 요약, 접근 처리, 멤버십 변화가 만나는 중심 서비스다. +- 정책 변경 영향이 가장 넓게 퍼지는 진입점이다. +- 문의방 특수 처리, 검색, 메시지 점프, 방 이름/mute 조합도 여기서 다룬다. + +### `ChatRoom` + +- 방 타입과 마지막 메시지 메타데이터를 가진다. +- 목록 요약 기준과 직접 연결된다. + +### `ChatRoomMember` + +- 사용자별 `lastReadAt`, `visibleMessageFrom`, `leftAt`, 커스텀 방 이름, 소유자 여부를 가진다. +- direct 채팅방의 가시성 정책 핵심 상태가 여기에 있다. + +### `ChatMessage` + +- 실제 메시지 데이터다. +- 목록 요약과 unreadCount 계산의 기준 데이터다. + +### Repository 계층 + +- 최신 메시지 조회, unreadCount 계산, 목록 조회 최적화 쿼리가 들어 있다. +- 조회 쿼리를 바꾸면 정책이 깨지기 쉬우므로 결과 의미를 먼저 확인해야 한다. + +### `ChatRoomMembershipService` + +- club group 멤버 보장, direct/group 멤버십 업데이트, 문의방 읽음 예외 처리를 맡는다. +- 동시 생성/중복 멤버십을 흡수하는 방어 로직이 있다. + +## 이 문서로 먼저 이해해야 하는 것 + +채팅 도메인 작업을 시작할 때는 아래 질문에 답할 수 있어야 한다. + +- 이 변경이 direct 채팅방의 `나감 -> 다시 보임 -> 새 대화 시작` 정책을 깨뜨리지 않는가 +- 이 변경이 마지막 메시지와 unreadCount를 서로 다른 기준으로 계산하게 만들지 않는가 +- 이 변경이 사용자에게 보이지 않는 메시지를 목록에 노출하게 만들지 않는가 +- 이 변경이 group 정책을 direct에, 또는 direct 정책을 group에 잘못 일반화하지 않는가 +- 이 변경이 문의방의 `SYSTEM_ADMIN + 일반 사용자` 재사용 규칙을 깨뜨리지 않는가 +- 이 변경이 messageId 조회나 검색에서 보이면 안 되는 메시지 존재를 노출하지 않는가 +- 이 변경이 커스텀 방 이름, mute, 섹션 정렬 같은 목록 부가 정책을 누락시키지 않는가 + +이 질문에 바로 답할 수 없으면 코드를 먼저 고치지 말고, 관련 정책과 상태를 다시 확인해야 한다. + +## 이번 문서의 범위 밖 + +아래 항목은 이 문서의 상세 구현 설명에서는 다루지 않는다. + +- Presence 기록 +- 알림 전송 구현 세부 +- AdminChatReceivedEvent 후속 소비 흐름 + +이 항목을 수정할 때도 위 핵심 정책과의 연결 여부를 먼저 확인해야 한다. 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 fb90e01f6..dd147fec6 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 @@ -7,9 +7,12 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.List; import java.util.Map; +import com.fasterxml.jackson.databind.JsonNode; + import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -21,6 +24,7 @@ import org.springframework.test.context.jdbc.SqlConfig; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.context.transaction.TestTransaction; +import org.springframework.test.web.servlet.MvcResult; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionTemplate; @@ -219,6 +223,28 @@ private boolean isDirectRoomBetween(Integer roomId, Integer firstUserId, Integer && roomMembers.stream().anyMatch(member -> member.getUserId().equals(secondUserId)); } + private int parseChatRoomId(MvcResult result) throws Exception { + String responseBody = result.getResponse().getContentAsString(); + return objectMapper.readTree(responseBody).get("chatRoomId").asInt(); + } + + private List extractRoomIds(MvcResult result) throws Exception { + String responseBody = result.getResponse().getContentAsString(); + JsonNode root = objectMapper.readTree(responseBody); + JsonNode rooms = root.get("rooms"); + List roomIds = new ArrayList<>(); + if (rooms != null && rooms.isArray()) { + for (JsonNode room : rooms) { + JsonNode roomIdNode = + room.has("chatRoomId") ? room.get("chatRoomId") : room.get("roomId"); + if (roomIdNode != null) { + roomIds.add(roomIdNode.asInt()); + } + } + } + return roomIds; + } + @Nested @DisplayName("POST /chats/rooms - 일반 채팅방 생성") class CreateDirectChatRoom { @@ -386,6 +412,34 @@ void adminCanReadInquiryRoomMessagesWithoutMembership() throws Exception { .containsExactlyInAnyOrder(SYSTEM_ADMIN_ID, normalUser.getId()); } + @Test + @DisplayName("관리자는 멤버가 아니어도 문의방 멤버 목록을 조회할 수 있다") + void adminCanReadInquiryRoomMembersWithoutMembership() throws Exception { + User anotherAdmin = persist(UserFixture.createAdmin(university)); + clearPersistenceContext(); + + mockLoginUser(normalUser.getId()); + int chatRoomId = parseChatRoomId( + performPost("/chats/rooms/admin") + .andExpect(status().isOk()) + .andReturn() + ); + + clearPersistenceContext(); + + mockLoginUser(anotherAdmin.getId()); + performGet("/chats/rooms/" + chatRoomId + "/members") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.members.length()").value(2)) + .andExpect(jsonPath("$.members[*].userId").value( + org.hamcrest.Matchers.containsInAnyOrder(SYSTEM_ADMIN_ID, normalUser.getId()) + )); + + assertThat(chatRoomMemberRepository.findByChatRoomId(chatRoomId)) + .extracting(ChatRoomMember::getUserId) + .containsExactlyInAnyOrder(SYSTEM_ADMIN_ID, normalUser.getId()); + } + @Test @DisplayName("어드민이 나간 문의 채팅방에 사용자가 새 메시지를 보내 어드민 목록에 다시 노출된다") @Transactional(propagation = Propagation.REQUIRES_NEW) @@ -444,28 +498,6 @@ void adminLeftInquiryRoomReappearsWhenUserSendsNewMessage() throws Exception { assertThat(extractRoomIds(adminRoomsAfterNewMessage)).contains(chatRoomId); } - private int parseChatRoomId(org.springframework.test.web.servlet.MvcResult result) throws Exception { - String responseBody = result.getResponse().getContentAsString(); - return objectMapper.readTree(responseBody).get("chatRoomId").asInt(); - } - - private List extractRoomIds(org.springframework.test.web.servlet.MvcResult result) throws Exception { - String responseBody = result.getResponse().getContentAsString(); - com.fasterxml.jackson.databind.JsonNode root = objectMapper.readTree(responseBody); - com.fasterxml.jackson.databind.JsonNode rooms = root.get("rooms"); - List roomIds = new java.util.ArrayList<>(); - if (rooms != null && rooms.isArray()) { - for (com.fasterxml.jackson.databind.JsonNode room : rooms) { - // roomId 또는 chatRoomId 필드 확인 - com.fasterxml.jackson.databind.JsonNode roomIdNode = - room.has("chatRoomId") ? room.get("chatRoomId") : room.get("roomId"); - if (roomIdNode != null) { - roomIds.add(roomIdNode.asInt()); - } - } - } - return roomIds; - } } @Nested @@ -2040,6 +2072,33 @@ void toggleMuteByKickedMemberReturnsForbidden() throws Exception { .andExpect(status().isForbidden()) .andExpect(jsonPath("$.code").value("FORBIDDEN_CHAT_ROOM_ACCESS")); } + + @Test + @DisplayName("다른 관리자는 문의방 멤버가 아니어도 뮤트 설정을 변경할 수 있다") + void anotherAdminCanToggleMuteInInquiryRoomWithoutMembership() throws Exception { + User anotherAdmin = persist(UserFixture.createAdmin(university)); + clearPersistenceContext(); + + mockLoginUser(normalUser.getId()); + int chatRoomId = parseChatRoomId( + performPost("/chats/rooms/admin") + .andExpect(status().isOk()) + .andReturn() + ); + + mockLoginUser(anotherAdmin.getId()); + performPost("/chats/rooms/" + chatRoomId + "/mute") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isMuted").value(true)); + + clearPersistenceContext(); + + assertThat(notificationMuteSettingRepository.findByTargetTypeAndTargetIdAndUserId( + NotificationTargetType.CHAT_ROOM, + chatRoomId, + anotherAdmin.getId() + )).isPresent(); + } } @Nested @@ -2082,5 +2141,22 @@ void searchChatsWithSingleCharacterKeywordReturnsOk() throws Exception { performGet("/chats/rooms/search?keyword=a&page=1&limit=20") .andExpect(status().isOk()); } + + @Test + @DisplayName("일반 group 채팅방 메시지는 검색 대상에 포함되지 않는다") + void searchChatsExcludesOpenGroupRoomMessages() throws Exception { + User groupMember = createUser("그룹멤버", "2021136014"); + ChatRoom groupRoom = createGroupChatRoomWithOwner(normalUser, groupMember); + persistChatMessage(groupRoom, groupMember, "그룹전용키워드"); + clearPersistenceContext(); + + mockLoginUser(normalUser.getId()); + + performGet("/chats/rooms/search?keyword=그룹전용키워드&page=1&limit=20") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.roomMatches.totalCount").value(0)) + .andExpect(jsonPath("$.messageMatches.totalCount").value(0)) + .andExpect(jsonPath("$.messageMatches.currentCount").value(0)); + } } } From cdc858fb310510107d1f0c01dd4bb4609a38680a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Wed, 22 Apr 2026 18:43:08 +0900 Subject: [PATCH 14/50] =?UTF-8?q?docs:=20=EB=8F=99=EC=95=84=EB=A6=AC=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EA=B0=80=EC=9D=B4=EB=93=9C=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=EC=9E=91=EC=84=B1=20(#563)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(club): add club domain guide * test(club): add unit coverage for domain policies * chore: 코드 포맷팅 * test: 리뷰 코멘트 반영을 위해 club 권한 검증 근거를 보강 - 동아리 도메인 가이드에서 회원 제거 권한과 club_group 접근 검증 기준을 실제 구현 흐름에 맞춰 명확히 정리했다. - 어드민 비회원 사용자가 leader 권한 검증을 우회하는 정책은 서비스 테스트 목 대신 검증기 단위 테스트로 고정해 책임 경계를 분리했다. - removeMember 서비스 테스트에는 validateLeaderAccess 위임 검증을 추가해 서비스가 권한 판단을 자체 구현으로 우회하지 않도록 막았다. * chore: 코드 포맷팅 --- .../java/gg/agit/konect/domain/club/AGENTS.md | 414 ++++++++++++++++++ .../unit/domain/club/model/ClubTest.java | 47 ++ .../ClubMemberManagementServiceTest.java | 49 +++ .../service/ClubMemberSheetServiceTest.java | 1 + .../service/ClubPermissionValidatorTest.java | 42 ++ .../service/ClubRecruitmentServiceTest.java | 117 +++++ 6 files changed, 670 insertions(+) create mode 100644 src/main/java/gg/agit/konect/domain/club/AGENTS.md create mode 100644 src/test/java/gg/agit/konect/unit/domain/club/model/ClubTest.java create mode 100644 src/test/java/gg/agit/konect/unit/domain/club/service/ClubPermissionValidatorTest.java create mode 100644 src/test/java/gg/agit/konect/unit/domain/club/service/ClubRecruitmentServiceTest.java diff --git a/src/main/java/gg/agit/konect/domain/club/AGENTS.md b/src/main/java/gg/agit/konect/domain/club/AGENTS.md new file mode 100644 index 000000000..00174bdaa --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/AGENTS.md @@ -0,0 +1,414 @@ +# 동아리 도메인 가이드 + +## 이 도메인은 무엇을 하는가 + +동아리 도메인은 동아리의 생성과 공개 조회, 운영 설정, 모집 공고, 지원서, 회원/사전 회원 관리, 구글 시트 연동을 함께 다루는 도메인이다. + +이 도메인에서 중요한 것은 단순 CRUD가 아니라 아래 상태가 서로 같은 정책을 바라보는 것이다. + +- 동아리 자체 정보와 공개 노출 상태 +- 역할 체계 (`PRESIDENT`, `VICE_PRESIDENT`, `MANAGER`, `MEMBER`) +- 모집 가능 여부와 지원 가능 여부 +- 지원서 문항의 현재 버전과 과거 지원 시점의 문항 버전 +- 실제 회원(`ClubMember`)과 가입 전 사전 회원(`ClubPreMember`) +- 구글 시트에 반영되는 인명부 상태 +- 동아리 전용 그룹 채팅방 멤버십 + +동아리 관련 작업을 할 때는 항상 “이 변경이 권한 경계, 회원 상태, 지원 이력, 시트/채팅 연동까지 같이 맞는가”를 먼저 확인해야 한다. + +## 역할과 권한 + +### 역할 계층 + +역할 우선순위는 아래와 같다. + +- `PRESIDENT` +- `VICE_PRESIDENT` +- `MANAGER` +- `MEMBER` + +`ClubPosition.priority`가 낮을수록 더 높은 권한이며, `canManage()`는 자신보다 낮은 직책만 관리할 수 있다는 뜻이다. + +### 권한 검증 기준 + +`ClubPermissionValidator`는 아래 세 가지 경계를 쓴다. + +- `validatePresidentAccess` → 회장만 +- `validateLeaderAccess` → 회장/부회장 +- `validateManagerAccess` → 회장/부회장/운영진 + +중요한 점은 **어드민은 위 세 검증을 모두 우회한다**는 것이다. 즉 도메인 정책을 볼 때 “어드민 예외”를 항상 별도로 생각해야 한다. + +### 권한이 실제로 어떻게 쓰이는가 + +- 동아리 생성은 **어드민 전용**이다. +- 동아리 일반 정보 수정(`description`, `imageUrl`, `location`, `introduce`)은 **운영진 이상**이 가능하다. +- 동아리 설정 토글(모집공고/지원서/회비), 모집 공고 수정, 지원서 문항 교체, 회비 정보 변경, 시트 관련 작업은 **운영진 이상**이 가능하다. +- 직책 변경과 회원 제거는 **리더(회장/부회장) 이상**이 가능하다. +- 회장 위임과 부회장 지정/해제는 **회장만** 할 수 있다. +- 멤버 목록 조회는 **동아리 회원이면 가능**하지만, 학번 원문 노출은 관리자 계층과 일반 회원이 다르다. + +### 권한 관련 절대 놓치면 안 되는 것 + +- 어드민 우회가 있다고 해서 모든 제약이 사라지는 것은 아니다. 예를 들어 `removeMember`는 어드민이어도 회장을 제거할 수 없고, `MEMBER`가 아닌 대상을 바로 제거할 수도 없다. +- `LEADERS`와 `MANAGERS`는 다르다. 운영진(`MANAGER`)은 설정/모집/지원서 관리가 가능하지만, 직책 변경이나 회원 제거는 리더 검증을 통과해야 한다. +- 현재 구현 기준으로 `updateBasicInfo`는 API 설명과 달리 **매니저 권한 검증**을 사용한다. 문서/스웨거만 보고 admin-only라고 단정하면 안 된다. + +## 기능이 실제로 어떻게 동작해야 하는가 + +### 동아리 생성과 기본 조회 + +- 동아리 생성은 어드민만 가능하다. +- 생성 시 동아리 엔티티만 저장하는 것이 아니라 다음이 함께 일어난다. + - 동아리 그룹 채팅방 생성 + - 지정된 회장 유저를 `PRESIDENT`로 가입 + - 회장을 채팅방 멤버로 추가 + - 기본 지원 문항 2개 생성 + - `본인의 전화번호를 입력해주세요.` (필수) + - `지원 동기` (선택) +- 동아리 상세 조회의 `isApplied`는 “현재 회원”이거나 “현재 pending 지원이 있음”이면 `true`다. +- 동아리 목록 조회의 pending 표시는 지원 이력만 보면 안 되고, **이미 회원이 된 경우 pending 집합에서 제거**해야 한다. + +### 공개 조회와 관리 조회 + +- `getClubs`는 같은 대학의 동아리만 조회한다. +- 모집 필터가 없을 때도 모집 중인 동아리를 먼저 보여주고, 모집 필터가 있을 때는 현재 모집 중인 동아리만 보여준다. +- 모집 중 판단은 `isRecruitmentEnabled`가 켜져 있고 모집공고가 존재하며, 아래 둘 중 하나를 만족할 때다. + - `isAlwaysRecruiting = true` + - `startAt <= now <= endAt` +- 관리 중인 동아리 조회는 일반 사용자에게는 `MANAGERS` 이상 소속만 돌려주고, 어드민에게는 전체 동아리를 돌려준다. + +### 멤버 목록 조회 + +- 멤버 목록은 동아리 회원만 조회할 수 있다. 비회원은 `FORBIDDEN_CLUB_MEMBER_ACCESS`다. +- 학번 노출 정책은 역할에 따라 다르다. + - 어드민: 원문 노출 + - 회장/부회장/운영진: 원문 노출 + - 일반 회원: 마스킹 노출 +- 즉 멤버 목록 조회는 “조회 가능 여부”와 “민감정보 원문 노출 여부”를 따로 봐야 한다. + +### 모집 공고 + +- 모집 공고는 없으면 생성, 있으면 수정하는 upsert 정책이다. +- 상시 모집이면 `startAt`, `endAt`이 있으면 안 된다. +- 상시 모집이 아니면 `startAt`, `endAt`이 둘 다 있어야 하고, `startAt <= endAt` 이어야 한다. +- 공고 수정 시 이미 등록된 이미지는 전부 비우고 새 요청 기준으로 다시 채운다. +- 모집 공고 조회의 `isApplied`는 회원 여부 또는 pending 지원 여부에 의해 결정된다. + +### 설정 토글 + +- 설정은 `isRecruitmentEnabled`, `isApplicationEnabled`, `isFeeRequired` 세 토글을 독립적으로 가진다. +- PATCH 요청은 **들어온 필드만 바꾼다**. 누락된 필드는 유지된다. +- 설정 API는 토글을 바꿀 뿐, 모집공고/지원서 문항/회비 상세를 자동으로 생성하거나 정합성 보정을 해주지 않는다. +- 따라서 “토글이 켜졌다 = 실제 운영에 필요한 상세 데이터가 완비됐다”라고 보면 안 된다. + +### 회비 정보 + +- 회비 정보는 부분 업데이트처럼 보이지만 실제 정책은 더 엄격하다. +- `Club.replaceFeeInfo()` 헬퍼 자체는 회비 관련 값이 전부 비어 있으면 회비 상세를 **전부 제거**한다. +- 회비 관련 값이 전부 채워져 있으면 새 값으로 **전부 교체**한다. +- 일부만 채워진 partial update는 허용되지 않는다. +- 다만 현재 `ClubApplicationService.replaceFeeInfo()` 경로는 먼저 `bankId`로 은행명을 해석하므로, 공개 API 관점에서는 사실상 “전체 교체” 흐름으로 이해하는 편이 안전하다. + +### 지원서와 지원 이력 + +- 현재 회원은 지원할 수 없다. +- 이미 pending 지원이 있으면 다시 지원할 수 없다. +- 회비가 필수인 동아리는 납부 이미지가 없으면 지원할 수 없다. +- 지원 시점의 활성 문항 목록으로 답변을 검증한다. + - 존재하지 않는 문항 id에 답하면 안 된다. + - 필수 문항 답변이 비어 있으면 안 된다. + - 빈 답변은 저장하지 않지만, 필수 검증은 통과해야 한다. +- 지원 저장 후 운영진 이상에게 제출 이벤트를 발행한다. +- `getAppliedClubs`는 pending 지원만 보여주되, 이미 회원이 된 경우는 제외한다. +- 운영진이 보는 pending 지원 목록은 현재 모집공고 기준을 탄다. + - 상시 모집이면 club 전체 pending 지원을 본다. + - 기간 모집이면 `startAt ~ endAt` 사이에 생성된 pending 지원만 본다. + +### 지원서 문항 버전 관리 + +이 부분은 이 도메인에서 특히 잘 깨지는 정책이다. + +- 문항 교체는 hard delete가 아니라 **soft delete(`deletedAt`)** 기반이다. +- 기존 문항과 내용/필수 여부가 완전히 같으면 display order만 바꾼다. +- 기존 문항의 내용 또는 필수 여부가 바뀌면 기존 문항을 soft delete 하고 새 문항을 만든다. +- 요청에서 빠진 기존 문항도 soft delete 된다. +- 즉 “지원 문항 수정”은 현재 문항 집합을 바꾸는 작업이지, 과거 지원서의 질문 텍스트를 덮어쓰는 작업이 아니다. + +### 과거 지원서 답변 조회 + +- 승인된 회원의 지원서 답변 조회는 **현재 문항 목록**이 아니라 **지원 당시 보였던 문항 목록**을 기준으로 재구성한다. +- 질문 가시성은 아래 기준이다. + - `createdAt <= appliedAt` + - `deletedAt == null || deletedAt > appliedAt` +- 특히 `deletedAt == appliedAt`이면 그 문항은 이미 보이지 않는 것으로 처리된다. +- 따라서 문항 versioning 로직을 바꾸면 승인된 회원의 과거 지원서 조회가 바로 깨질 수 있다. + +### 지원 승인 / 거절 + +- 승인/거절은 `PENDING` 상태에서만 가능하다. +- 승인/거절 대상 지원서는 pessimistic lock으로 가져오므로 동시 처리 경쟁을 줄이려는 의도가 있다. +- 이미 처리된 지원서를 다시 승인/거절하면 `ALREADY_PROCESSED_CLUB_APPLY`다. +- 승인 시 다음이 함께 일어난다. + - `ClubMember`를 `MEMBER` 직책으로 생성 + - 동아리 채팅방 멤버십 추가 + - 지원 상태를 `APPROVED`로 변경 + - 승인 이벤트 발행 +- 거절 시에는 상태를 `REJECTED`로 바꾸고 거절 이벤트를 발행한다. + +### 회원 직책 변경 + +- 자기 자신의 직책은 변경할 수 없다. +- 직책 변경은 리더 이상만 가능하다. +- 일반 리더(어드민 아님)는 두 가지를 모두 통과해야 한다. + - 현재 대상 회원을 관리할 수 있는가 (`requester.canManage(target)`) + - 바꾸려는 새 직책을 부여할 수 있는가 (`requester.getClubPosition().canManage(newPosition)`) +- 즉 부회장은 회장을 만들 수 없고, 운영진/부회장 같은 상위 직책을 자기 권한 밖으로 올릴 수 없다. +- 부회장은 최대 1명이다. +- 운영진은 최대 20명이다. + +### 회장 위임 / 부회장 변경 + +- 회장 위임은 회장만 가능하다. +- 자기 자신에게 회장을 다시 위임할 수 없다. +- 회장 위임은 현재 회장을 `MEMBER`로 내리고 새 회장을 `PRESIDENT`로 올린다. +- 부회장 변경도 회장만 가능하다. +- 부회장 userId가 `null`이면 현재 부회장을 해제한다. +- 새 부회장을 지정하면 기존 부회장은 `MEMBER`로 내리고 새 대상을 `VICE_PRESIDENT`로 올린다. + +### 회원 제거 + +- 자기 자신은 제거할 수 없다. +- 회원 제거는 리더 이상만 가능하다. +- 회장은 제거할 수 없다. +- **직접 제거 가능한 대상은 `MEMBER`만** 이다. +- 즉 운영진/부회장/회장을 내보내려면 먼저 직책을 내리는 별도 흐름을 거쳐야 한다. +- 어드민이 아닌 요청자는 `requester.canManage(target)` 검증을 통과해야 하므로, 자기보다 높거나 같은 위상의 대상은 제거할 수 없다. +- 어드민은 위 계층 검증은 우회하지만, `MEMBER`만 직접 제거 가능하다는 제약 자체는 그대로 적용된다. +- 제거 시에는 club member row 삭제와 함께 동아리 채팅방 멤버십도 제거된다. + +### 사전 회원(`ClubPreMember`) + +사전 회원은 아직 가입하지 않은 사용자를 동아리 쪽에서 먼저 등록해두는 임시 상태다. + +- 운영진 이상이 추가/배치추가/조회/삭제할 수 있다. +- `(clubId, studentNumber, name)` 기준 중복은 허용되지 않는다. +- 추가 시 같은 대학 + 같은 학번 사용자 후보를 찾고, 이름까지 일치시키는 방식으로 매칭한다. +- 정확히 한 명만 매칭되면 `ClubPreMember`를 만들지 않고 바로 `ClubMember`로 넣는다. +- 일치 사용자가 없으면 `ClubPreMember`로 저장한다. +- 이름까지 일치하는 동명이인이 여러 명이면 자동 등록하지 않고 `AMBIGUOUS_USER_MATCH`로 막는다. +- 배치 추가는 각 행을 독립 트랜잭션처럼 처리해서 일부 성공 / 일부 실패 결과를 같이 반환한다. + +### 회원가입 시 사전 회원 흡수 + +이 흐름은 club 서비스가 아니라 `UserService.joinPreMembers`에 있다. + +- 사용자가 가입하면 같은 대학/학번/이름으로 매칭되는 `ClubPreMember`를 찾아 실제 `ClubMember`로 전환한다. +- 전환 후에는 채팅방 멤버십도 추가된다. +- 사전 회원 직책이 `PRESIDENT`였으면 현재 회장을 먼저 제거하고 새 가입자를 회장으로 올린다. +- 즉 사전 회원 정책을 수정할 때는 동아리 내부 코드뿐 아니라 회원가입 흐름과 채팅 멤버십 반영도 같이 봐야 한다. + +### 시트 등록 / 동기화 / 불러오기 + +시트 기능은 운영진 이상만 다룰 수 있다. + +- 시트 URL 등록은 Spreadsheet ID를 추출하고, 헤더 분석 결과를 저장한다. +- 등록 시 `googleSheetId`뿐 아니라 `sheetColumnMapping`도 함께 갱신된다. +- 시트 동기화는 현재 회원 + 사전 회원을 함께 대상으로 한다. +- 시트 동기화는 등록된 sheet id가 없거나 blank면 바로 실패한다. +- 동기화 결과의 count에는 실제 회원 수와 사전 회원 수가 모두 포함된다. +- 컬럼 매핑이 있으면 해당 컬럼만 갱신하고, 없으면 기본 헤더(`Name`, `StudentId`, `Email`, `Phone`, `Position`, `JoinedAt`) 기준으로 전체를 다시 쓴다. +- 구글 시트 접근 거부가 나면 Slack 알림용 `SheetSyncFailedEvent`가 발행된다. + +### 시트 기반 사전 회원 Import + +시트 import는 “동아리 인명부를 읽어서 실회원과 사전 회원을 동시에 정리하는 흐름”이다. + +- 이름/학번이 비어 있으면 해당 row는 건너뛴다. +- 전화번호 형식이 이상하면 warning을 남긴다. +- 직책 텍스트가 인식되지 않으면 기본값은 `MEMBER`다. +- 회장이 2명 이상 감지되면 warning을 남긴다. +- 이미 현재 회원으로 존재하는 학번은 다시 등록하지 않는다. +- 학번 기준 후보 중 이름까지 정확히 맞는 유저가 1명이면 자동으로 실제 회원 등록을 준비한다. +- 이름까지 맞는 후보가 여러 명이면 자동 매칭하지 않고 warning을 남긴다. +- 기존 pre-member와 동일 `(studentNumber, name)`이면 다시 만들지 않는다. +- 적용 시 실제 회원으로 들어간 학번은 기존 pre-member 후보에서 정리하고, 저장된 실제 회원은 모두 채팅방 멤버십에 추가한다. + +### 시트 마이그레이션 + +- 시트 마이그레이션은 기존 스프레드시트를 공식 템플릿 기반 시트로 복사하는 흐름이다. +- 운영진 이상만 가능하다. +- 사용자 OAuth Drive 권한, 서비스 계정 권한 부여, 템플릿 복사, 폴더 이동, 컬럼 분석, 데이터 쓰기, rollback cleanup이 모두 한 흐름에 들어 있다. +- 따라서 이 영역은 단순 CRUD가 아니라 외부 API 실패, 권한 회수, orphan 파일 정리까지 같이 보는 것이 맞다. + +### 채팅 도메인과의 결합 + +동아리 도메인은 채팅 도메인과 느슨하지 않다. + +- 동아리 생성 시 동아리 그룹 채팅방을 만든다. +- 회장 생성, 지원 승인, 사전 회원 직접 등록, 시트 import로 실제 회원 등록, 회원가입 시 pre-member 흡수 시점마다 채팅방 멤버십이 추가될 수 있다. +- 회원 제거와 회장 교체 일부 흐름에서는 채팅방 멤버십이 제거된다. +- 따라서 club_group 접근 권한이나 메시지 조회 권한을 판단할 때는 chat member row만 믿으면 안 되고, 현재 `ClubMember` 상태/직책도 함께 확인해야 한다. +- 따라서 회원 상태를 바꾸는 코드를 수정할 때는 club 테이블만 맞추고 끝났다고 보면 안 된다. + +## 절대 놓치면 안 되는 정책 + +- 권한 검증은 `manager / leader / president` 세 층으로 나뉘며, 서로 대체 가능하지 않다. +- 어드민 bypass와 club 내부 역할 검증은 같은 개념이 아니다. 둘을 섞어 단순화하면 안 된다. +- `removeMember`는 “회원 제거” API이지 “아무 직책이나 제거” API가 아니다. 현재 구현상 `MEMBER`만 직접 제거 가능하다. +- 부회장은 최대 1명, 운영진은 최대 20명이다. +- 지원서 문항은 soft delete/versioning을 쓰므로 과거 지원서 답변은 현재 문항 기준으로 재구성하면 안 된다. +- 승인된 회원의 과거 답변 조회는 항상 **지원 시점에 보였던 질문** 기준이어야 한다. +- pending 지원과 실제 회원 상태를 동시에 보고 “지원 중” 표기를 계산해야 한다. +- 회비 필수 여부는 납부 이미지 요구와 직접 연결된다. +- 설정 토글은 관련 상세 데이터까지 자동 정비해주지 않는다. +- 사전 회원 자동 매칭은 학번만으로 끝나지 않고 이름까지 확인한다. 동명이인 모호성은 실패로 처리한다. +- 회원 상태 변경은 채팅방 멤버십 반영까지 같이 봐야 한다. +- 시트 기능은 외부 권한/네트워크/API 실패가 끼어드는 영역이라, 단순한 도메인 로직처럼 다루면 안 된다. +- 현재 저장소 기준으로 시트 동기화 실행은 명시적 sync API 경로에서만 직접 확인된다. 멤버 변경 시 자동 sync를 당연한 전제로 두면 안 된다. + +## 수정 시 함께 확인해야 하는 것 + +### 권한 로직을 바꿀 때 + +- `ClubPermissionValidator` 호출 지점 +- 어드민 bypass 유무 +- manager / leader / president 경계가 올바른지 +- API 문서 설명과 구현이 어긋나는 부분이 없는지 + +### 회원 직책/제거 로직을 바꿀 때 + +- 자기 자신 변경/제거 금지 +- 대상 관리 가능 여부 +- 새 직책 부여 가능 여부 +- 부회장 1명 제한 +- 운영진 20명 제한 +- 회장 제거 금지 +- `MEMBER`만 직접 제거 가능한 현재 정책 +- 채팅방 멤버십 추가/삭제 반영 + +### 지원/지원서 문항 로직을 바꿀 때 + +- pending 중복 지원 차단 +- 현재 회원 지원 차단 +- 회비 필수 시 이미지 요구 +- 필수 문항 검증 +- soft delete 기반 문항 버전 유지 +- 과거 지원서 답변 조회의 시점별 가시성 +- 승인/거절 idempotency (`ALREADY_PROCESSED_CLUB_APPLY`) +- 승인 시 회원 + 채팅 멤버십 생성 + +### 공개 조회 / 관리 조회를 바꿀 때 + +- 같은 대학 필터 +- 모집 중 정렬과 필터 조건 +- pending 표시에서 이미 회원인 케이스 제거 +- 멤버 목록 접근 제어 +- 학번 마스킹/비마스킹 정책 + +### 설정 / 모집공고를 바꿀 때 + +- 모집공고 on/off와 실제 공고 존재 여부를 혼동하지 않는지 +- 상시 모집 vs 기간 모집 날짜 검증 +- 이미지 교체 시 clear + re-add 정책 유지 여부 +- PATCH가 부분 업데이트라는 점 +- 토글과 상세 데이터 정합성 책임이 어디에 있는지 + +### 사전 회원 / 회원가입 연동을 바꿀 때 + +- 동일 대학 + 학번 + 이름 매칭 정책 +- 동명이인 모호성 처리 +- pre-member -> member 전환 시 채팅 멤버십 추가 +- pre-member가 회장일 때 기존 회장 교체 정책 +- batch add의 item별 독립 실패 처리 + +### 시트 관련 로직을 바꿀 때 + +- `googleSheetId`와 `sheetColumnMapping` 갱신 +- 멤버 + 사전 회원 동시 반영 +- warning 생성 규칙 +- access denied / invalid grant / rollback cleanup 처리 +- service account 권한 부여와 제거 +- 매핑 기반 부분 업데이트와 기본 전체 쓰기 fallback + +## 주요 클래스와 책임 + +### `ClubPermissionValidator` + +- club 도메인 권한 경계의 중심이다. +- admin bypass를 포함한 회장/리더/매니저 접근 검증을 담당한다. +- 대부분의 정책 변경은 이 클래스 호출 범위를 먼저 확인해야 한다. + +### `ClubService` + +- 동아리 생성, 공개 조회, 관리 조회, 멤버 목록 조회를 담당한다. +- 생성 시 채팅방 생성과 기본 지원 문항 생성까지 끌고 들어간다. +- 목록/상세의 `isApplied` 같은 사용자별 파생 상태를 만든다. + +### `ClubApplicationService` + +- 지원, 승인/거절, 지원서 문항 교체, 회비 정보, 승인 이력 답변 조회를 담당한다. +- 문항 versioning과 “지원 시점 기준 가시성” 정책의 핵심 서비스다. +- 승인 시 회원 생성과 채팅방 멤버십 추가까지 책임진다. + +### `ClubMemberManagementService` + +- 직책 변경, 회장 위임, 부회장 변경, 회원 제거, 사전 회원 수동 등록을 담당한다. +- 직책 hierarchy와 cardinality 제한이 가장 많이 모여 있는 서비스다. +- 채팅방 멤버십 추가/삭제까지 연결된다. + +### `ClubRecruitmentService` + +- 모집 공고 조회/저장을 담당한다. +- 상시 모집 / 기간 모집 날짜 정책과 이미지 교체 정책의 중심이다. + +### `ClubSettingsService` + +- 모집공고/지원서/회비 토글과 그 요약 정보 조회를 담당한다. +- 토글과 실제 상세 데이터의 분리를 이해할 때 봐야 하는 서비스다. + +### `ClubMemberSheetService` + +- 시트 id 등록과 명시적 sheet sync를 담당한다. +- 헤더 분석 결과를 도메인 설정에 저장하고, sync 진입점을 제공한다. + +### `SheetImportService` + +- 스프레드시트에서 회원/사전 회원 후보를 읽어 import plan을 만들고 적용한다. +- 자동 매칭, warning, pre-member 정리, 채팅 멤버십 반영이 여기 모여 있다. + +### `SheetMigrationService` + +- 기존 시트를 공식 템플릿 시트로 이관하는 외부 연동 서비스다. +- Drive/Sheets 권한, 복사, rollback cleanup, 컬럼 분석까지 포함한다. + +### `ClubSheetIntegratedService` + +- 시트 분석 + 등록 + import를 한 번에 묶는 orchestration 서비스다. + +### `Club`, `ClubMember`, `ClubPreMember`, `ClubApply`, `ClubApplyQuestion`, `ClubRecruitment` + +- 동아리 설정, 회원 상태, 사전 회원 상태, 지원 상태, 질문 버전, 모집공고 기간 정책을 각각 보관한다. +- 특히 `ClubApplyQuestion.deletedAt`, `ClubMember.clubPosition`, `Club.isRecruitmentEnabled / isApplicationEnabled / isFeeRequired`, `Club.googleSheetId / sheetColumnMapping`은 영향 범위가 크다. + +### Repository 계층 + +- `ClubQueryRepository`는 공개 목록의 필터/정렬 의미를 만든다. +- `ClubMemberRepository`는 역할 정렬, 멤버 조회, 인원 제한 계산에 직접 연결된다. +- `ClubApplyQuestionRepository`는 문항 시점 가시성의 기준 쿼리를 가진다. +- `ClubApplyRepository`는 pending 중복 검사와 승인 대상 lock 조회를 담당한다. + +## 이 문서로 먼저 이해해야 하는 것 + +동아리 도메인 작업을 시작할 때는 아래 질문에 답할 수 있어야 한다. + +- 이 변경이 `manager / leader / president` 권한 경계를 흐리게 만들지 않는가 +- 이 변경이 어드민 bypass와 club 내부 역할 정책을 섞어버리지 않는가 +- 이 변경이 직책 변경/회원 제거에서 부회장 1명, 운영진 20명, 회장 제거 금지 정책을 깨뜨리지 않는가 +- 이 변경이 지원서 문항 versioning과 과거 답변 조회를 현재 문항 기준으로 잘못 단순화하지 않는가 +- 이 변경이 pending 지원, 실제 회원, 공개 상세의 `isApplied` 계산을 서로 다르게 만들지 않는가 +- 이 변경이 사전 회원 등록, 회원가입 전환, 시트 import, 채팅 멤버십 반영을 서로 어긋나게 만들지 않는가 +- 이 변경이 시트 기능을 단순 내부 로직처럼 다뤄 외부 권한/rollback 위험을 놓치지 않는가 +- 이 변경이 API 문서 설명만 믿고 실제 구현 경계를 잘못 이해하게 만들지 않는가 + +이 질문에 바로 답할 수 없으면 코드를 먼저 고치지 말고, 관련 서비스와 cross-domain 연동부터 다시 확인해야 한다. diff --git a/src/test/java/gg/agit/konect/unit/domain/club/model/ClubTest.java b/src/test/java/gg/agit/konect/unit/domain/club/model/ClubTest.java new file mode 100644 index 000000000..fbedfbf62 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/club/model/ClubTest.java @@ -0,0 +1,47 @@ +package gg.agit.konect.unit.domain.club.model; + +import static gg.agit.konect.global.code.ApiResponseCode.INVALID_REQUEST_BODY; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.global.exception.CustomException; +import gg.agit.konect.support.ServiceTestSupport; +import gg.agit.konect.support.fixture.ClubFixture; +import gg.agit.konect.support.fixture.UniversityFixture; + +class ClubTest extends ServiceTestSupport { + + @Test + @DisplayName("replaceFeeInfo는 네 필드가 모두 비면 기존 회비 정보를 전부 제거한다") + void replaceFeeInfoClearsAllFeeFieldsWhenEveryFieldIsBlank() { + // given + Club club = ClubFixture.createWithId(UniversityFixture.createWithId(1), 1, "BCSD"); + club.replaceFeeInfo("30000", "국민은행", "123-456-7890", "BCSD"); + + // when + club.replaceFeeInfo(null, null, null, null); + + // then + assertThat(club.getFeeAmount()).isNull(); + assertThat(club.getFeeBank()).isNull(); + assertThat(club.getFeeAccountNumber()).isNull(); + assertThat(club.getFeeAccountHolder()).isNull(); + } + + @Test + @DisplayName("replaceFeeInfo는 일부만 비어 있는 partial 입력을 거부한다") + void replaceFeeInfoRejectsPartialInput() { + // given + Club club = ClubFixture.createWithId(UniversityFixture.createWithId(1), 1, "BCSD"); + + // when & then + assertThatThrownBy(() -> club.replaceFeeInfo("30000", "국민은행", null, null)) + .isInstanceOf(CustomException.class) + .satisfies(exception -> assertThat(((CustomException)exception).getErrorCode()) + .isEqualTo(INVALID_REQUEST_BODY)); + } +} diff --git a/src/test/java/gg/agit/konect/unit/domain/club/service/ClubMemberManagementServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/club/service/ClubMemberManagementServiceTest.java index b044909c1..0eaf0d6cb 100644 --- a/src/test/java/gg/agit/konect/unit/domain/club/service/ClubMemberManagementServiceTest.java +++ b/src/test/java/gg/agit/konect/unit/domain/club/service/ClubMemberManagementServiceTest.java @@ -476,10 +476,59 @@ void removeMemberWorksNormally() { clubMemberManagementService.removeMember(clubId, targetUserId, requesterId); // then + verify(clubPermissionValidator).validateLeaderAccess(clubId, requesterId); verify(clubMemberRepository).delete(target); verify(chatRoomMembershipService).removeClubMember(clubId, targetUserId); } + @Test + @DisplayName("removeMember는 어드민이 동아리 소속이 아니어도 일반 회원 제거를 수행할 수 있다") + void removeMemberAllowsAdminWithoutClubMembership() { + // given + Integer clubId = 1; + Integer requesterId = 100; + Integer targetUserId = 200; + Club club = createClub(); + User adminUser = UserFixture.createUserWithId(requesterId, "관리자", UserRole.ADMIN); + User targetUser = UserFixture.createUserWithId(targetUserId, "대상", UserRole.USER); + ClubMember target = ClubMemberFixture.createMember(club, targetUser); + + when(clubRepository.getById(clubId)).thenReturn(club); + when(userRepository.getById(requesterId)).thenReturn(adminUser); + when(clubMemberRepository.getByClubIdAndUserId(clubId, targetUserId)).thenReturn(target); + + // when + clubMemberManagementService.removeMember(clubId, targetUserId, requesterId); + + // then + verify(clubPermissionValidator).validateLeaderAccess(clubId, requesterId); + verify(clubMemberRepository).delete(target); + verify(chatRoomMembershipService).removeClubMember(clubId, targetUserId); + } + + @Test + @DisplayName("removeMember는 어드민이라도 운영진 이상 직책은 직접 제거할 수 없다") + void removeMemberRejectsAdminRemovingNonMemberPosition() { + // given + Integer clubId = 1; + Integer requesterId = 100; + Integer targetUserId = 200; + Club club = createClub(); + User adminUser = UserFixture.createUserWithId(requesterId, "관리자", UserRole.ADMIN); + User targetUser = UserFixture.createUserWithId(targetUserId, "운영진", UserRole.USER); + ClubMember target = ClubMemberFixture.createManager(club, targetUser); + + when(clubRepository.getById(clubId)).thenReturn(club); + when(userRepository.getById(requesterId)).thenReturn(adminUser); + when(clubMemberRepository.getByClubIdAndUserId(clubId, targetUserId)).thenReturn(target); + + // when & then + assertErrorCode( + () -> clubMemberManagementService.removeMember(clubId, targetUserId, requesterId), + CANNOT_REMOVE_NON_MEMBER + ); + } + @Test @DisplayName("changeVicePresident는 같은 부회장 재지정 시 아무 변화 없음") void changeVicePresidentHandlesSameVicePresident() { diff --git a/src/test/java/gg/agit/konect/unit/domain/club/service/ClubMemberSheetServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/club/service/ClubMemberSheetServiceTest.java index 663e6eda9..4935b4b06 100644 --- a/src/test/java/gg/agit/konect/unit/domain/club/service/ClubMemberSheetServiceTest.java +++ b/src/test/java/gg/agit/konect/unit/domain/club/service/ClubMemberSheetServiceTest.java @@ -113,6 +113,7 @@ void updateSheetIdWorksNormally() throws JsonProcessingException { verify(clubPermissionValidator).validateManagerAccess(clubId, requesterId); verify(sheetHeaderMapper).analyzeAllSheets("test-sheet-id"); assertThat(club.getGoogleSheetId()).isEqualTo("test-sheet-id"); + assertThat(club.getSheetColumnMapping()).isEqualTo("{}"); } @Test diff --git a/src/test/java/gg/agit/konect/unit/domain/club/service/ClubPermissionValidatorTest.java b/src/test/java/gg/agit/konect/unit/domain/club/service/ClubPermissionValidatorTest.java new file mode 100644 index 000000000..ec51949c6 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/club/service/ClubPermissionValidatorTest.java @@ -0,0 +1,42 @@ +package gg.agit.konect.unit.domain.club.service; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.Mockito.verifyNoInteractions; + +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.club.repository.ClubMemberRepository; +import gg.agit.konect.domain.club.service.ClubPermissionValidator; +import gg.agit.konect.domain.user.enums.UserRole; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.domain.user.repository.UserRepository; +import gg.agit.konect.support.ServiceTestSupport; +import gg.agit.konect.support.fixture.UserFixture; + +class ClubPermissionValidatorTest extends ServiceTestSupport { + + @Mock + private ClubMemberRepository clubMemberRepository; + + @Mock + private UserRepository userRepository; + + @InjectMocks + private ClubPermissionValidator clubPermissionValidator; + + @Test + @DisplayName("validateLeaderAccess는 동아리 소속이 없는 어드민도 허용한다") + void validateLeaderAccessAllowsAdminWithoutClubMembership() { + // given + Integer clubId = 1; + User admin = UserFixture.createUserWithId(100, "관리자", UserRole.ADMIN); + + // when & then + assertThatCode(() -> clubPermissionValidator.validateLeaderAccess(clubId, admin)) + .doesNotThrowAnyException(); + verifyNoInteractions(clubMemberRepository); + } +} diff --git a/src/test/java/gg/agit/konect/unit/domain/club/service/ClubRecruitmentServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/club/service/ClubRecruitmentServiceTest.java new file mode 100644 index 000000000..496c120b7 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/club/service/ClubRecruitmentServiceTest.java @@ -0,0 +1,117 @@ +package gg.agit.konect.unit.domain.club.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; + +import java.time.LocalDateTime; + +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.club.dto.ClubRecruitmentResponse; +import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.club.model.ClubRecruitment; +import gg.agit.konect.domain.club.repository.ClubApplyRepository; +import gg.agit.konect.domain.club.repository.ClubMemberRepository; +import gg.agit.konect.domain.club.repository.ClubRecruitmentRepository; +import gg.agit.konect.domain.club.repository.ClubRepository; +import gg.agit.konect.domain.club.service.ClubPermissionValidator; +import gg.agit.konect.domain.club.service.ClubRecruitmentService; +import gg.agit.konect.domain.user.enums.UserRole; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.domain.user.repository.UserRepository; +import gg.agit.konect.support.ServiceTestSupport; +import gg.agit.konect.support.fixture.ClubFixture; +import gg.agit.konect.support.fixture.UniversityFixture; +import gg.agit.konect.support.fixture.UserFixture; + +class ClubRecruitmentServiceTest extends ServiceTestSupport { + + @Mock + private ClubRepository clubRepository; + + @Mock + private ClubRecruitmentRepository clubRecruitmentRepository; + + @Mock + private ClubMemberRepository clubMemberRepository; + + @Mock + private ClubApplyRepository clubApplyRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private ClubPermissionValidator clubPermissionValidator; + + @InjectMocks + private ClubRecruitmentService clubRecruitmentService; + + @Test + @DisplayName("getRecruitment는 현재 회원이면 isApplied=true를 반환한다") + void getRecruitmentMarksAppliedForMember() { + // given + Club club = ClubFixture.createWithId(UniversityFixture.createWithId(1), 1, "BCSD"); + User user = UserFixture.createUserWithId( + UniversityFixture.createWithId(1), + 10, + "회원", + "20240001", + UserRole.USER + ); + ClubRecruitment recruitment = ClubRecruitment.of( + LocalDateTime.now().minusDays(1), + LocalDateTime.now().plusDays(1), + false, + "모집 공고", + club + ); + + given(clubRepository.getById(1)).willReturn(club); + given(userRepository.getById(10)).willReturn(user); + given(clubRecruitmentRepository.getByClubId(1)).willReturn(recruitment); + given(clubMemberRepository.existsByClubIdAndUserId(1, 10)).willReturn(true); + + // when + ClubRecruitmentResponse response = clubRecruitmentService.getRecruitment(1, 10); + + // then + assertThat(response.isApplied()).isTrue(); + } + + @Test + @DisplayName("getRecruitment는 회원이 아니어도 pending 지원이 있으면 isApplied=true를 반환한다") + void getRecruitmentMarksAppliedForPendingApplicant() { + // given + Club club = ClubFixture.createWithId(UniversityFixture.createWithId(1), 1, "BCSD"); + User user = UserFixture.createUserWithId( + UniversityFixture.createWithId(1), + 10, + "지원자", + "20240001", + UserRole.USER + ); + ClubRecruitment recruitment = ClubRecruitment.of( + null, + null, + true, + "상시 모집", + club + ); + + given(clubRepository.getById(1)).willReturn(club); + given(userRepository.getById(10)).willReturn(user); + given(clubRecruitmentRepository.getByClubId(1)).willReturn(recruitment); + given(clubMemberRepository.existsByClubIdAndUserId(1, 10)).willReturn(false); + given(clubApplyRepository.existsPendingByClubIdAndUserId(1, 10)).willReturn(true); + + // when + ClubRecruitmentResponse response = clubRecruitmentService.getRecruitment(1, 10); + + // then + assertThat(response.isApplied()).isTrue(); + } +} From 0e2e4a8735b3790259c38151d7747a4172a8e271 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Fri, 24 Apr 2026 11:42:45 +0900 Subject: [PATCH 15/50] =?UTF-8?q?fix:=20=EC=9A=B4=EC=98=81=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=EC=97=90=EC=84=9C=20Swagger=EB=A5=BC=20=EB=B9=84?= =?UTF-8?q?=ED=99=9C=EC=84=B1=ED=99=94=20(#565)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 운영 환경에서 Swagger를 비활성화 - 운영 서버에서 API 명세와 Swagger UI가 외부에 노출되지 않도록 prod 프로필에서 springdoc을 끈다 - Swagger 관련 Bean과 커스텀 initializer 컨트롤러도 prod에서는 생성되지 않게 막아 우회 노출 가능성을 줄인다 - stage/local 개발 편의성은 유지하고, 운영 환경에만 최소 범위로 적용한다 * chore: 코드 포맷팅 --- .../java/gg/agit/konect/global/config/SwaggerConfig.java | 6 ++++-- .../konect/global/config/SwaggerUiResourceController.java | 2 ++ src/main/resources/application-prod.yml | 5 +++++ 3 files changed, 11 insertions(+), 2 deletions(-) create mode 100644 src/main/resources/application-prod.yml diff --git a/src/main/java/gg/agit/konect/global/config/SwaggerConfig.java b/src/main/java/gg/agit/konect/global/config/SwaggerConfig.java index d26ce1129..e6b9986e0 100644 --- a/src/main/java/gg/agit/konect/global/config/SwaggerConfig.java +++ b/src/main/java/gg/agit/konect/global/config/SwaggerConfig.java @@ -5,6 +5,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; import org.springdoc.core.models.GroupedOpenApi; @@ -16,6 +17,7 @@ import io.swagger.v3.oas.models.servers.Server; @Configuration +@Profile("!prod") public class SwaggerConfig { private final String serverUrl; @@ -58,8 +60,8 @@ public GroupedOpenApi publicApi() { .addOpenApiCustomizer(openApi -> openApi.setTags( openApi.getTags() != null ? openApi.getTags().stream() - .sorted((a, b) -> a.getName().compareTo(b.getName())) - .toList() + .sorted((a, b) -> a.getName().compareTo(b.getName())) + .toList() : null )) .build(); diff --git a/src/main/java/gg/agit/konect/global/config/SwaggerUiResourceController.java b/src/main/java/gg/agit/konect/global/config/SwaggerUiResourceController.java index 3253d9472..247b69b19 100644 --- a/src/main/java/gg/agit/konect/global/config/SwaggerUiResourceController.java +++ b/src/main/java/gg/agit/konect/global/config/SwaggerUiResourceController.java @@ -2,6 +2,7 @@ import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.Resource; +import org.springframework.context.annotation.Profile; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; @@ -11,6 +12,7 @@ @Hidden @RestController +@Profile("!prod") public class SwaggerUiResourceController { private static final String SWAGGER_INITIALIZER_PATH = "static/swagger-ui/swagger-initializer.js"; diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml new file mode 100644 index 000000000..7b9828578 --- /dev/null +++ b/src/main/resources/application-prod.yml @@ -0,0 +1,5 @@ +springdoc: + api-docs: + enabled: false + swagger-ui: + enabled: false From ad8eef5c8dbd0ff3d5c8aa131f7b0e3792076c6f Mon Sep 17 00:00:00 2001 From: JanooGwan <103417427+JanooGwan@users.noreply.github.com> Date: Fri, 24 Apr 2026 20:11:43 +0900 Subject: [PATCH 16/50] =?UTF-8?q?hotfix:=20DB=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=EC=97=90=20comment=20=EC=B6=94=EA=B0=80=20=EB=B0=8F?= =?UTF-8?q?=20ClaudeClient=20=ED=94=84=EB=A1=AC=ED=94=84=ED=8A=B8=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: ClaudeClient 시스템 프롬프트 study_time 테이블 힌트 구체화 * fix: show tables 선행 조회 원칙 추가 및 줄 길이 120자 제한 준수 * fix: JaCoCo 제외 목록에 claude, mcp 인프라 클라이언트 추가 * fix: cache ai database schema context * fix: DB 스키마 캐시 생명주기 및 테이블 설명 정리 * fix: DB 스키마 캐시 실패 처리 및 도구 우선순위 정리 --------- Co-authored-by: 이동훈 <64298482+dh2906@users.noreply.github.com> Co-authored-by: 신관규 Co-authored-by: Claude Opus 4.6 (1M context) --- build.gradle | 4 +- .../domain/chat/controller/ChatApi.java | 4 +- .../claude/client/ClaudeClient.java | 61 +++--- .../claude/client/DatabaseSchemaCache.java | 171 +++++++++++++++++ .../V71__add_table_comments_for_ai_schema.sql | 104 ++++++++++ .../client/DatabaseSchemaCacheTest.java | 177 ++++++++++++++++++ 6 files changed, 482 insertions(+), 39 deletions(-) create mode 100644 src/main/java/gg/agit/konect/infrastructure/claude/client/DatabaseSchemaCache.java create mode 100644 src/main/resources/db/migration/V71__add_table_comments_for_ai_schema.sql create mode 100644 src/test/java/gg/agit/konect/unit/infrastructure/claude/client/DatabaseSchemaCacheTest.java diff --git a/build.gradle b/build.gradle index 61e77d23b..23415d2ba 100644 --- a/build.gradle +++ b/build.gradle @@ -138,8 +138,8 @@ jacocoTestReport { "**/exception/*.class", "**/*Exception.class", // 외부 API 클라이언트 (단위 테스트 어려운 클래스) - "**/infrastructure/claude/**", - "**/infrastructure/mcp/**", + "**/infrastructure/claude/client/ClaudeClient.class", + "**/infrastructure/mcp/client/McpClient.class", // 기타 "**/Application.class", // Spring Boot 메인 클래스 ]) 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 2dd9c3488..d91bca18c 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 @@ -273,12 +273,12 @@ ResponseEntity createGroupChatRoom( @Operation(summary = "채팅방 멤버 목록 조회", description = """ ## 설명 - 특정 채팅방의 모든 멤버 목록을 조회합니다. - + ## 로직 - 채팅방에 참여 중인 멤버만 조회할 수 있습니다. - 나간 멤버(leftAt이 설정된 멤버)는 목록에 포함되지 않습니다. - 각 멤버의 userId, 이름, 프로필 이미지, 방장 여부, 참여 시간을 반환합니다. - + ## 에러 - FORBIDDEN_CHAT_ROOM_ACCESS (403): 채팅방에 접근할 권한이 없습니다. - NOT_FOUND_CHAT_ROOM (404): 채팅방을 찾을 수 없습니다. diff --git a/src/main/java/gg/agit/konect/infrastructure/claude/client/ClaudeClient.java b/src/main/java/gg/agit/konect/infrastructure/claude/client/ClaudeClient.java index 369d332dc..d4bd26758 100644 --- a/src/main/java/gg/agit/konect/infrastructure/claude/client/ClaudeClient.java +++ b/src/main/java/gg/agit/konect/infrastructure/claude/client/ClaudeClient.java @@ -32,50 +32,25 @@ public class ClaudeClient { 당신은 KONECT 서비스의 데이터 분석 AI 에이전트입니다. ## 필수 원칙 - DB를 조회할 때는 항상 list_tables 도구(SHOW TABLES)로 먼저 테이블 목록을 확인하고, - 실제 존재하는 테이블 중에서 판단하여 조회한다. + DB를 조회할 때는 먼저 database_schema 도구로 캐시된 실제 테이블 목록, 테이블 comment, + 컬럼 구조를 확인하고, 존재하는 테이블과 컬럼만 사용한다. + 시스템 프롬프트의 과거 지식보다 database_schema 도구 결과를 우선 신뢰한다. ## 역할 사용자의 질문을 분석하고, 데이터베이스에서 필요한 데이터를 조회하여 답변합니다. ## 사용 가능한 도구 - 1. list_tables: 데이터베이스의 모든 테이블 목록 조회 - 2. describe_table: 특정 테이블의 컬럼 구조 조회 - 3. query: SQL SELECT 쿼리 실행 (읽기 전용) + 1. database_schema: 캐시된 실제 DB 스키마, 테이블 comment, 컬럼 구조 조회 + 2. list_tables: database_schema 조회 실패 또는 캐시된 목록에 확신이 없을 때만 테이블 목록 재조회 + 3. describe_table: 특정 테이블의 최신 컬럼 구조를 재확인해야 할 때만 사용 + 4. query: SQL SELECT 쿼리 실행 (읽기 전용) ## 작업 방식 - 1. 반드시 list_tables로 테이블 목록을 먼저 확인 - 2. 테이블 구조가 필요하면 describe_table로 컬럼 정보 확인 + 1. 먼저 database_schema로 현재 DB 스키마와 테이블 comment 확인 + 2. database_schema가 실패했거나 특정 테이블 구조가 더 필요할 때만 list_tables 또는 describe_table 사용 3. 적절한 SQL 쿼리를 작성하여 데이터 조회 4. 결과를 바탕으로 친절하고 자연스럽게 답변 - ## 주요 테이블 힌트 (예시, 전체 목록은 list_tables로 확인) - - users: 사용자 정보 (deleted_at IS NULL = 활성 사용자) - - club: 동아리 정보 - - club_member: 동아리 멤버 (role: PRESIDENT, VICE_PRESIDENT, MANAGER, MEMBER) - - club_recruitment: 모집 공고 - - club_apply: 동아리 지원 - - university_schedule: 학사 일정 - - council_notice: 학생회 공지사항 - - ## 순공 시간(study time) 관련 테이블 상세 - - study_timer: 현재 타이머 실행 중인 세션 (user_id, started_at) - 실시간으로 타이머를 켠 사용자만 존재. 현재 상태 조회용. - - study_time_daily: 일별 누적 공부 시간 (user_id, study_date DATE, total_seconds BIGINT) - 날짜 기반 질문("오늘", "24시간 이내", "최근 N일")에 사용. - - study_time_monthly: 월별 누적 공부 시간 (user_id, study_month DATE, total_seconds BIGINT) - 월 단위 질문에 사용. - - study_time_total: 사용자별 전체 누적 공부 시간 (user_id, total_seconds BIGINT) - 누적 합계 질문에 사용. - - study_time_ranking: 랭킹 데이터 - (ranking_type_id, university_id, target_id, target_name, daily_seconds, monthly_seconds) - - ranking_type: 랭킹 타입 (1=CLUB, 2=STUDENT_NUMBER, 3=PERSONAL) - - ### 순공 시간 조회 예시 - - "오늘/24시간 이내 순공 기록 사용자 수" → study_time_daily, study_date = CURDATE() - - "이번 달 순공 기록 사용자 수" → study_time_monthly, study_month = DATE_FORMAT(NOW(), '%Y-%m-01') - - "현재 타이머 실행 중인 사용자 수" → study_timer, COUNT(*) - ## 응답 규칙 - 반드시 한국어로 응답 - 답변은 질문한 것에 대해서만 할 것 @@ -84,6 +59,15 @@ public class ClaudeClient { - 데이터베이스에 정말 없는 정보만 정중히 거절 """; + private static final Map DATABASE_SCHEMA_TOOL = Map.of( + "name", "database_schema", + "description", "캐시된 실제 DB 스키마 요약을 조회합니다. 테이블 comment와 컬럼 구조를 포함합니다.", + "input_schema", Map.of( + "type", "object", + "properties", Map.of() + ) + ); + private static final Map QUERY_TOOL = Map.of( "name", "query", "description", "MySQL 데이터베이스에 SELECT 쿼리를 실행합니다. 읽기 전용입니다.", @@ -124,21 +108,24 @@ public class ClaudeClient { ); private static final List> ALL_TOOLS = List.of( - QUERY_TOOL, LIST_TABLES_TOOL, DESCRIBE_TABLE_TOOL + DATABASE_SCHEMA_TOOL, QUERY_TOOL, LIST_TABLES_TOOL, DESCRIBE_TABLE_TOOL ); private final RestClient restClient; private final ClaudeProperties claudeProperties; private final McpClient mcpClient; + private final DatabaseSchemaCache databaseSchemaCache; private final ObjectMapper objectMapper; public ClaudeClient(RestClient.Builder restClientBuilder, ClaudeProperties claudeProperties, McpClient mcpClient, + DatabaseSchemaCache databaseSchemaCache, ObjectMapper objectMapper) { this.restClient = restClientBuilder.build(); this.claudeProperties = claudeProperties; this.mcpClient = mcpClient; + this.databaseSchemaCache = databaseSchemaCache; this.objectMapper = objectMapper; } @@ -274,6 +261,10 @@ private List> processToolCalls(JsonNode content) { private String executeToolCall(String toolName, JsonNode input) { try { return switch (toolName) { + case "database_schema" -> { + log.debug("Loading cached database schema"); + yield databaseSchemaCache.getSchemaSummary(); + } case "query" -> { String sql = input.path("sql").asText(); log.debug("Executing SQL query via MCP"); diff --git a/src/main/java/gg/agit/konect/infrastructure/claude/client/DatabaseSchemaCache.java b/src/main/java/gg/agit/konect/infrastructure/claude/client/DatabaseSchemaCache.java new file mode 100644 index 000000000..c7f5f98f0 --- /dev/null +++ b/src/main/java/gg/agit/konect/infrastructure/claude/client/DatabaseSchemaCache.java @@ -0,0 +1,171 @@ +package gg.agit.konect.infrastructure.claude.client; + +import java.time.Duration; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.dao.DataAccessException; +import org.springframework.dao.DataRetrievalFailureException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +public class DatabaseSchemaCache { + + private static final Duration FAILURE_RETRY_INTERVAL = Duration.ofSeconds(30); + private static final String FALLBACK_SCHEMA = + "DB 스키마 요약 조회에 실패했습니다. list_tables와 describe_table 도구로 다시 확인하세요."; + private static final String TABLE_SCHEMA_SQL = """ + SELECT table_name, table_comment + FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_type = 'BASE TABLE' + AND table_name <> 'flyway_schema_history' + ORDER BY table_name + """; + private static final String COLUMN_SCHEMA_SQL = """ + SELECT table_name, + column_name, + column_type, + is_nullable, + column_key, + column_comment + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name <> 'flyway_schema_history' + ORDER BY table_name, ordinal_position + """; + + private final JdbcTemplate jdbcTemplate; + private volatile String cachedSchema; + private volatile Instant retrySchemaLoadAt = Instant.EPOCH; + + public DatabaseSchemaCache(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public String getSchemaSummary() { + String currentSchema = cachedSchema; + if (currentSchema != null) { + return currentSchema; + } + Instant now = Instant.now(); + if (now.isBefore(retrySchemaLoadAt)) { + return FALLBACK_SCHEMA; + } + + synchronized (this) { + if (cachedSchema != null) { + return cachedSchema; + } + now = Instant.now(); + if (now.isBefore(retrySchemaLoadAt)) { + return FALLBACK_SCHEMA; + } + + try { + cachedSchema = loadSchemaSummary(); + return cachedSchema; + } catch (DataAccessException e) { + log.error("Failed to load database schema summary", e); + retrySchemaLoadAt = now.plus(FAILURE_RETRY_INTERVAL); + return FALLBACK_SCHEMA; + } + } + } + + private String loadSchemaSummary() { + List tables = jdbcTemplate.query( + TABLE_SCHEMA_SQL, + (rs, rowNum) -> new TableSchema( + rs.getString("table_name"), + nullSafeTrim(rs.getString("table_comment")) + ) + ); + + if (tables.isEmpty()) { + log.warn("Database schema cache loaded an empty schema summary"); + throw new DataRetrievalFailureException("Database schema summary is empty"); + } + + List columns = jdbcTemplate.query( + COLUMN_SCHEMA_SQL, + (rs, rowNum) -> new ColumnSchema( + rs.getString("table_name"), + rs.getString("column_name"), + rs.getString("column_type"), + nullSafeTrim(rs.getString("is_nullable")), + nullSafeTrim(rs.getString("column_key")), + nullSafeTrim(rs.getString("column_comment")) + ) + ); + + Map columnsByTable = new LinkedHashMap<>(); + for (ColumnSchema column : columns) { + StringBuilder tableColumns = columnsByTable.computeIfAbsent( + column.tableName(), + ignored -> new StringBuilder() + ); + tableColumns + .append(" - ") + .append(column.columnName()) + .append(" ") + .append(column.columnType()); + + if ("NO".equals(column.isNullable())) { + tableColumns.append(" NOT NULL"); + } + if (!column.columnKey().isBlank()) { + tableColumns.append(" ").append(column.columnKey()); + } + if (!column.comment().isBlank()) { + tableColumns.append(": ").append(column.comment()); + } + tableColumns.append('\n'); + } + + StringBuilder summary = new StringBuilder(); + summary.append("현재 DB 스키마 요약입니다. 테이블 comment를 우선 신뢰하고, 존재하는 테이블/컬럼만 사용하세요.\n"); + for (TableSchema table : tables) { + summary.append("- ") + .append(table.tableName()) + .append(": ") + .append(table.comment().isBlank() ? "설명 없음" : table.comment()) + .append('\n'); + StringBuilder tableColumns = columnsByTable.get(table.tableName()); + if (tableColumns != null) { + summary.append(tableColumns); + } + } + + return summary.toString(); + } + + private String nullSafeTrim(String value) { + if (value == null || value.isBlank()) { + return ""; + } + return value.trim(); + } + + private record TableSchema( + String tableName, + String comment + ) { + } + + private record ColumnSchema( + String tableName, + String columnName, + String columnType, + String isNullable, + String columnKey, + String comment + ) { + } +} diff --git a/src/main/resources/db/migration/V71__add_table_comments_for_ai_schema.sql b/src/main/resources/db/migration/V71__add_table_comments_for_ai_schema.sql new file mode 100644 index 000000000..d3c96824f --- /dev/null +++ b/src/main/resources/db/migration/V71__add_table_comments_for_ai_schema.sql @@ -0,0 +1,104 @@ +ALTER TABLE `advertisement` + COMMENT = '앱 광고 배너와 노출 기간 정보'; + +ALTER TABLE `bank` + COMMENT = '회비 납부 등에 사용할 은행 코드와 은행명'; + +ALTER TABLE `chat_message` + COMMENT = '채팅방 메시지 내역'; + +ALTER TABLE `chat_room` + COMMENT = '1대1, 동아리 단체, 시스템 관리자 채팅방'; + +ALTER TABLE `chat_room_member` + COMMENT = '채팅방 참여자, 읽음 시점, 퇴장/숨김 상태'; + +ALTER TABLE `club` + COMMENT = '대학교별 동아리 기본 정보'; + +ALTER TABLE `club_apply` + COMMENT = '사용자의 동아리 지원 신청'; + +ALTER TABLE `club_apply_answer` + COMMENT = '동아리 지원서 질문별 답변'; + +ALTER TABLE `club_apply_question` + COMMENT = '동아리 모집 지원서 질문'; + +ALTER TABLE `club_member` + COMMENT = '동아리 소속 회원과 역할'; + +ALTER TABLE `club_pre_member` + COMMENT = '동아리 사전 등록 회원'; + +ALTER TABLE `club_recruitment` + COMMENT = '동아리 모집 공고와 모집 기간'; + +ALTER TABLE `club_recruitment_image` + COMMENT = '동아리 모집 공고 이미지'; + +ALTER TABLE `council` + COMMENT = '대학교 학생회 정보'; + +ALTER TABLE `council_notice` + COMMENT = '학생회 공지사항'; + +ALTER TABLE `council_notice_read_history` + COMMENT = '학생회 공지사항 사용자별 읽음 기록'; + +ALTER TABLE `group_chat_message` + COMMENT = '레거시 그룹 채팅 메시지'; + +ALTER TABLE `group_chat_read_status` + COMMENT = '레거시 그룹 채팅 읽음 상태'; + +ALTER TABLE `group_chat_room` + COMMENT = '레거시 그룹 채팅방'; + +ALTER TABLE `notification_device_token` + COMMENT = '사용자별 푸시 알림 디바이스 토큰'; + +ALTER TABLE `notification_inbox` + COMMENT = '사용자 알림함에 저장되는 알림 내역'; + +ALTER TABLE `notification_mute_setting` + COMMENT = '사용자별 알림 뮤트 설정'; + +ALTER TABLE `ranking_type` + COMMENT = '순공 랭킹 타입, CLUB/STUDENT_NUMBER/PERSONAL'; + +ALTER TABLE `schedule` + COMMENT = '서비스 일반 일정'; + +ALTER TABLE `study_time_daily` + COMMENT = '사용자별 일별 누적 순공 시간, study_date 기준'; + +ALTER TABLE `study_time_monthly` + COMMENT = '사용자별 월별 누적 순공 시간, study_month는 월의 첫날'; + +ALTER TABLE `study_time_ranking` + COMMENT = '순공 랭킹 집계, 타입과 대학별 대상의 일간/월간 시간'; + +ALTER TABLE `study_time_total` + COMMENT = '사용자별 전체 누적 순공 시간'; + +ALTER TABLE `study_timer` + COMMENT = '현재 순공 타이머가 실행 중인 사용자 세션'; + +ALTER TABLE `university` + COMMENT = '대학교와 캠퍼스 정보'; + +ALTER TABLE `university_schedule` + COMMENT = '대학교 학사 일정'; + +ALTER TABLE `unregistered_user` + COMMENT = '가입 절차를 완료하지 않은 OAuth 사용자'; + +ALTER TABLE `user_oauth_account` + COMMENT = '사용자별 OAuth 제공자 계정과 리프레시 토큰'; + +ALTER TABLE `users` + COMMENT = '서비스 사용자 계정, deleted_at이 NULL이면 활성 사용자'; + +ALTER TABLE `version` + COMMENT = '앱 버전 관리 정보'; diff --git a/src/test/java/gg/agit/konect/unit/infrastructure/claude/client/DatabaseSchemaCacheTest.java b/src/test/java/gg/agit/konect/unit/infrastructure/claude/client/DatabaseSchemaCacheTest.java new file mode 100644 index 000000000..a188223c8 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/infrastructure/claude/client/DatabaseSchemaCacheTest.java @@ -0,0 +1,177 @@ +package gg.agit.konect.unit.infrastructure.claude.client; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.Instant; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.dao.QueryTimeoutException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.test.util.ReflectionTestUtils; + +import gg.agit.konect.infrastructure.claude.client.DatabaseSchemaCache; +import gg.agit.konect.support.ServiceTestSupport; + +class DatabaseSchemaCacheTest extends ServiceTestSupport { + + private static final String FALLBACK_SCHEMA = + "DB 스키마 요약 조회에 실패했습니다. list_tables와 describe_table 도구로 다시 확인하세요."; + + @Mock + private JdbcTemplate jdbcTemplate; + + @InjectMocks + private DatabaseSchemaCache databaseSchemaCache; + + @Test + @DisplayName("정상 조회 시 DB 스키마 요약을 생성하고 서버 생명주기 동안 캐시한다") + void getSchemaSummaryFormatsAndCachesSchema() { + // given + givenTableRows(table("users", "서비스 사용자 계정")); + givenColumnRows( + column("users", "id", "int", "NO", "PRI", ""), + column("users", "email", "varchar(100)", "NO", "", "사용자 이메일") + ); + + // when + String firstSummary = databaseSchemaCache.getSchemaSummary(); + String secondSummary = databaseSchemaCache.getSchemaSummary(); + + // then + assertThat(firstSummary).isEqualTo(secondSummary); + assertThat(firstSummary) + .contains("- users: 서비스 사용자 계정") + .contains(" - id int NOT NULL PRI") + .contains(" - email varchar(100) NOT NULL: 사용자 이메일"); + verify(jdbcTemplate, times(1)).query(tableSchemaSql(), rowMapper()); + verify(jdbcTemplate, times(1)).query(columnSchemaSql(), rowMapper()); + } + + @Test + @DisplayName("스키마 조회 실패 시 짧은 재시도 대기 시간 동안 fallback을 반환한다") + void getSchemaSummaryReturnsFallbackWithoutRepeatedDbHitsWhenSchemaLoadFails() { + // given + given(jdbcTemplate.query(tableSchemaSql(), rowMapper())) + .willThrow(new QueryTimeoutException("timeout")); + + // when + String firstSummary = databaseSchemaCache.getSchemaSummary(); + String secondSummary = databaseSchemaCache.getSchemaSummary(); + + // then + assertThat(firstSummary).isEqualTo(FALLBACK_SCHEMA); + assertThat(secondSummary).isEqualTo(FALLBACK_SCHEMA); + verify(jdbcTemplate, times(1)).query(tableSchemaSql(), rowMapper()); + } + + @Test + @DisplayName("테이블 목록이 비어 있으면 성공 캐시로 저장하지 않는다") + void getSchemaSummaryDoesNotCacheEmptySchemaAsSuccess() { + // given + AtomicInteger tableQueryCount = new AtomicInteger(); + given(jdbcTemplate.query(tableSchemaSql(), rowMapper())) + .willAnswer(invocation -> { + if (tableQueryCount.getAndIncrement() == 0) { + return List.of(); + } + return mapRows(invocation.getArgument(1), table("users", "서비스 사용자 계정")); + }); + givenColumnRows(column("users", "id", "int", "NO", "PRI", "")); + + // when + String firstSummary = databaseSchemaCache.getSchemaSummary(); + ReflectionTestUtils.setField(databaseSchemaCache, "retrySchemaLoadAt", Instant.EPOCH); + String secondSummary = databaseSchemaCache.getSchemaSummary(); + + // then + assertThat(firstSummary).isEqualTo(FALLBACK_SCHEMA); + assertThat(secondSummary).contains("- users: 서비스 사용자 계정"); + verify(jdbcTemplate, times(2)).query(tableSchemaSql(), rowMapper()); + verify(jdbcTemplate, times(1)).query(columnSchemaSql(), rowMapper()); + } + + private void givenTableRows(ResultSet... rows) { + given(jdbcTemplate.query(tableSchemaSql(), rowMapper())) + .willAnswer(invocation -> mapRows(invocation.getArgument(1), rows)); + } + + private void givenColumnRows(ResultSet... rows) { + given(jdbcTemplate.query(columnSchemaSql(), rowMapper())) + .willAnswer(invocation -> mapRows(invocation.getArgument(1), rows)); + } + + private List mapRows(RowMapper rowMapper, ResultSet... rows) throws SQLException { + List mappedRows = new java.util.ArrayList<>(); + for (int i = 0; i < rows.length; i++) { + mappedRows.add(rowMapper.mapRow(rows[i], i)); + } + return mappedRows; + } + + private ResultSet table(String tableName, String tableComment) { + ResultSet resultSet = mock(ResultSet.class); + try { + given(resultSet.getString("table_name")).willReturn(tableName); + given(resultSet.getString("table_comment")).willReturn(tableComment); + } catch (SQLException e) { + throw new IllegalStateException(e); + } + return resultSet; + } + + private ResultSet column( + String tableName, + String columnName, + String columnType, + String isNullable, + String columnKey, + String columnComment + ) { + ResultSet resultSet = mock(ResultSet.class); + try { + given(resultSet.getString("table_name")).willReturn(tableName); + given(resultSet.getString("column_name")).willReturn(columnName); + given(resultSet.getString("column_type")).willReturn(columnType); + given(resultSet.getString("is_nullable")).willReturn(isNullable); + given(resultSet.getString("column_key")).willReturn(columnKey); + given(resultSet.getString("column_comment")).willReturn(columnComment); + } catch (SQLException e) { + throw new IllegalStateException(e); + } + return resultSet; + } + + private boolean isTableSchemaSql(String sql) { + return sql != null && sql.contains("information_schema.tables"); + } + + private boolean isColumnSchemaSql(String sql) { + return sql != null && sql.contains("information_schema.columns"); + } + + private String tableSchemaSql() { + return argThat(this::isTableSchemaSql); + } + + private String columnSchemaSql() { + return argThat(this::isColumnSchemaSql); + } + + @SuppressWarnings("unchecked") + private RowMapper rowMapper() { + return org.mockito.ArgumentMatchers.any(RowMapper.class); + } +} From 81ba8aba9dcf830cc01047213d889269396a24df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Fri, 24 Apr 2026 21:50:12 +0900 Subject: [PATCH 17/50] =?UTF-8?q?docs:=20=EC=9C=A0=EC=A0=80=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EC=A0=95=EC=B1=85=20=EA=B0=80=EC=9D=B4?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80=20(#568)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 유저 도메인 작업 시 회원가입, OAuth 연동, 탈퇴 유예기간을 같은 기준으로 판단할 수 있도록 코드 옆 가이드를 추가 - 사전 동아리 회원 흡수, 환영 메시지, Apple 탈퇴 후속 처리처럼 다른 도메인과 맞물리는 정책을 문서화 - 문서에 적은 핵심 후속 효과가 회귀되지 않도록 회원가입과 탈퇴 통합 테스트를 보강 --- .../java/gg/agit/konect/domain/user/AGENTS.md | 273 ++++++++++++++++++ .../domain/user/UserSignupApiTest.java | 71 +++++ .../domain/user/UserWithdrawApiTest.java | 41 +++ 3 files changed, 385 insertions(+) create mode 100644 src/main/java/gg/agit/konect/domain/user/AGENTS.md diff --git a/src/main/java/gg/agit/konect/domain/user/AGENTS.md b/src/main/java/gg/agit/konect/domain/user/AGENTS.md new file mode 100644 index 000000000..698171b14 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/user/AGENTS.md @@ -0,0 +1,273 @@ +# 유저 도메인 가이드 + +## 이 도메인은 무엇을 하는가 + +유저 도메인은 OAuth 로그인 이후의 회원가입, 계정 연동, 토큰 재발급, 활동 시각 갱신, 회원 탈퇴와 복구 유예기간을 관리하는 도메인이다. + +이 도메인에서 중요한 것은 단순한 사용자 CRUD가 아니라 아래 상태가 서로 같은 정책을 바라보는 것이다. + +- 실제 회원(`User`) +- 회원가입 전 임시 사용자(`UnRegisteredUser`) +- OAuth 제공자별 계정 연결(`UserOAuthAccount`) +- 쿠키 기반 signup token과 refresh token +- 탈퇴 상태(`deletedAt`)와 7일 복구 유예기간 +- 동아리 사전 회원(`ClubPreMember`) 흡수 +- 동아리 회장 탈퇴 제한 +- 가입 환영 메시지용 direct 채팅방과 마지막 메시지 메타데이터 +- Apple / Google Drive refresh token + +유저 관련 작업을 할 때는 항상 "이 변경이 OAuth 식별자, 탈퇴/복구 정책, 동아리 사전 회원 전환, 채팅/알림 후속 효과까지 같이 맞는가"를 먼저 확인해야 한다. + +## 사용자 상태 + +### `UnRegisteredUser` + +- OAuth 로그인은 아직 `User`를 만들지 않고 임시 사용자인 `UnRegisteredUser`를 만든다. +- signup token은 이 임시 사용자 정보를 기반으로 추가 정보 입력 화면과 최종 회원가입을 이어준다. +- Google, Naver, Kakao 흐름은 이메일과 provider 조합으로 임시 사용자를 찾거나 만든다. +- Apple 흐름은 providerId가 더 중요한 식별자이며, 이메일이 없는 신규 Apple 사용자는 가입을 진행할 수 없다. +- Apple은 최초 로그인 시 받은 이름과 refresh token을 임시 사용자 또는 OAuth 계정에 보존할 수 있다. + +### `User` + +- 실제 회원은 대학, 이메일, 이름, 학번, 마케팅 동의, 기본 프로필 이미지를 가진다. +- 기본 역할은 `USER`다. +- `ADMIN`은 별도 역할이며, 동아리 도메인의 회장/부회장/운영진 권한과 같은 개념이 아니다. +- 탈퇴는 row 삭제가 아니라 `deletedAt`을 채우는 soft delete다. +- `UserRepository.getById()`는 `deletedAt IS NULL`인 사용자만 찾는다. +- 탈퇴한 사용자의 이름은 `User.getName()`에서 `탈퇴한 사용자`로 표시된다. + +### `UserOAuthAccount` + +- OAuth 계정은 `User`와 provider별 외부 계정을 연결한다. +- 한 사용자는 provider별로 하나의 OAuth 계정만 가질 수 있다. +- providerId와 oauthEmail은 활성 사용자 기준으로 다른 사용자와 충돌하면 안 된다. +- providerId가 없는 상태로 primary OAuth 계정이 만들어질 수 있지만, 일반 계정 연동(`linkOAuthAccount`)은 providerId가 필요하다. +- Apple refresh token과 Google Drive refresh token은 모두 `UserOAuthAccount`에 저장되지만, 쓰임과 생명주기가 다르다. + +## 기능이 실제로 어떻게 동작해야 하는가 + +### OAuth 로그인과 회원가입 전 상태 + +- OAuth 로그인에서 이미 가입된 활성 사용자가 있으면 임시 사용자를 새로 만들지 않는다. +- 가입되지 않은 사용자라면 `UnRegisteredUser`를 만들고 signup token으로 회원가입을 이어간다. +- signup token은 Redis에 `auth:signup:{token}` 형태로 저장되며 TTL은 10분이다. +- 회원가입 사전 입력 조회는 signup token을 읽기만 한다. +- 실제 회원가입은 signup token을 consume해서 한 번만 사용할 수 있게 한다. +- signup token 값은 이메일, provider, providerId, 이름을 직렬화한 값이다. +- 구분자(`|`)가 들어간 값이나 provider가 깨진 값은 유효하지 않은 signup token으로 처리된다. + +### 회원가입 완료 + +- 회원가입은 아래 순서의 부수 효과를 함께 가진다. + - signup token claims에서 이메일, provider, providerId를 가져온다. + - `UnRegisteredUser`를 찾는다. + - 대학을 검증한다. + - `User`를 생성한다. + - primary OAuth 계정을 연결한다. + - 같은 대학/학번/이름의 사전 동아리 회원을 실제 동아리 회원으로 전환한다. + - 운영자 계정이 있으면 환영 direct 메시지를 보낸다. + - 임시 사용자를 삭제한다. + - `UserRegisteredEvent`를 발행한다. + - refresh token 쿠키와 access token 헤더를 내려준다. +- Apple 회원가입은 providerId가 비어 있으면 실패한다. +- 이미 같은 providerId 또는 같은 provider/oauthEmail로 가입된 활성 사용자가 있으면 다시 가입할 수 없다. +- 회원가입 성공 후 signup token 쿠키는 제거되어야 한다. + +### 사전 동아리 회원 흡수 + +- 회원가입 시 같은 대학, 같은 학번, 같은 이름의 `ClubPreMember`를 모두 찾는다. +- 매칭된 사전 회원은 각 동아리의 실제 `ClubMember`로 전환된다. +- 전환된 회원은 동아리 그룹 채팅방 멤버십에도 추가된다. +- 사전 회원 직책이 `PRESIDENT`면 기존 회장을 먼저 제거하고 새 가입자를 회장으로 올린다. +- 기존 회장을 제거할 때도 동아리 채팅방 멤버십을 함께 제거한다. +- 전환이 끝난 `ClubPreMember`는 삭제된다. +- 즉 회원가입 로직을 바꿀 때는 동아리 회원 상태와 club group 채팅방 멤버십까지 같이 확인해야 한다. + +### 가입 환영 메시지 + +- 회원가입 후 가장 작은 id의 활성 admin 사용자를 운영자로 골라 환영 메시지를 보낸다. +- 운영자가 없으면 환영 메시지는 생략된다. +- 운영자와 신규 사용자가 같은 사용자가 되면 안 된다. +- direct 채팅방이 이미 있으면 재사용하고, 없으면 새로 만든다. +- direct 멤버십을 보장한 뒤 운영자 메시지를 저장한다. +- 저장된 메시지는 `chat_room.last_message_*`에도 최신 메시지 조건으로 동기화한다. +- 환영 메시지 실패는 회원가입 전체를 실패시키지 않고 warning 로그로 남긴다. +- 이 로직은 채팅 도메인의 마지막 메시지 메타데이터 정책과 맞아야 한다. + +### 로그인, refresh token, 활동 시각 + +- refresh token은 JWT이며 TTL은 30일이다. +- refresh token에는 issuer, 만료 시각, jti, user id, `token_type=refresh`가 들어간다. +- refresh token 검증은 서명, issuer, 만료, token type, user id claim을 모두 확인한다. +- refresh 요청은 기존 refresh token을 검증한 뒤 새 refresh token으로 rotate한다. +- refresh 성공 시 `lastLoginAt`과 `lastActivityAt`을 함께 현재 시각으로 갱신한다. +- 일반 활동 시각 갱신은 userId가 null이면 아무 것도 하지 않는다. +- `updateLastActivityAt`은 사용자가 이미 탈퇴했거나 없으면 조용히 건너뛴다. + +### OAuth 계정 연동 + +- 연동 상태 조회는 모든 `Provider`에 대해 linked 여부를 반환한다. +- 일반 OAuth 계정 연동은 provider 문자열을 대문자 enum으로 해석한다. +- 지원하지 않는 provider는 `UNSUPPORTED_PROVIDER`다. +- 연동 요청은 provider별 verifier로 토큰을 검증한 뒤 저장해야 한다. +- 다른 활성 사용자가 같은 providerId 또는 provider/oauthEmail을 이미 쓰고 있으면 연동할 수 없다. +- 같은 사용자에게 이미 같은 provider 계정이 있으면 기존 계정을 갱신한다. +- 기존 계정의 providerId가 비어 있는 경우에는 새 providerId를 채울 수 있다. +- 기존 계정에 다른 providerId가 이미 있으면 충돌로 막아야 한다. +- Apple 연동은 Apple refresh token이 있으면 기존 계정에 갱신한다. + +### 탈퇴와 복구 유예기간 + +- 회원 탈퇴는 사용자 row를 삭제하지 않고 `deletedAt`을 현재 시각으로 채운다. +- 회장인 사용자는 탈퇴할 수 없다. +- 여러 동아리 중 하나라도 회장이면 탈퇴할 수 없다. +- 부회장, 운영진, 일반 회원은 탈퇴할 수 있다. +- 탈퇴 시 연결된 Apple OAuth 계정의 refresh token은 즉시 revoke를 시도한다. +- 탈퇴 후에는 `UserWithdrawnEvent`가 발행된다. +- 탈퇴 API는 refresh token과 signup token 쿠키를 함께 제거한다. +- 탈퇴한 사용자는 일반 `getById` 경로에서 더 이상 활성 사용자로 조회되지 않는다. + +### 탈퇴 계정 복구와 OAuth 정리 + +- 탈퇴 사용자는 7일 복구 유예기간을 가진다. +- OAuth 연동/회원가입 과정에서 같은 providerId 또는 provider/oauthEmail의 탈퇴 계정이 발견되면 복구 또는 정리를 먼저 시도한다. +- stage 프로필이 아니고 7일 이내 탈퇴라면 기존 사용자를 복구한다. +- stage 프로필이거나 7일이 지난 탈퇴 계정이면 기존 OAuth 계정 연결을 삭제하고 새 연결이 가능하게 한다. +- 00:10 스케줄러는 7일이 지난 탈퇴 사용자의 OAuth 계정 연결을 삭제한다. +- Apple token revoke 스케줄러는 매일 00:00에 7일이 지난 탈퇴 Apple 계정의 refresh token을 revoke하고, 성공 시 저장된 refresh token을 비운다. +- 복구 유예기간 정책을 바꿀 때는 즉시 탈퇴 처리, OAuth 충돌 처리, 스케줄러 삭제/토큰 폐기 시점을 함께 맞춰야 한다. + +### Google Drive OAuth + +- Google Drive OAuth는 로그인용 Google OAuth와 같은 `UserOAuthAccount`의 `googleDriveRefreshToken` 필드를 쓴다. +- Drive OAuth state는 Redis에 10분 TTL로 저장되고 callback에서 한 번만 consume된다. +- Drive refresh token은 Google provider 계정이 있어야 저장할 수 있다. +- 재동의 과정에서 새 refresh token이 내려오지 않아도 기존 refresh token이 있으면 유지한다. +- 기존 refresh token도 없고 새 refresh token도 없으면 Drive 인증 실패다. +- 동아리 시트 기능은 이 refresh token 존재 여부에 영향을 받는다. + +## 절대 놓치면 안 되는 정책 + +- `User`의 탈퇴는 hard delete가 아니라 `deletedAt` soft delete다. +- 활성 사용자 조회는 대부분 `deletedAt IS NULL` 기준이다. +- `User.getName()`은 탈퇴 사용자 이름을 원문 그대로 노출하지 않는다. +- 회원가입 token은 읽기와 consume의 의미가 다르다. 실제 가입에서는 반드시 consume해야 한다. +- Apple은 providerId가 핵심 식별자다. Apple 가입에서 providerId가 없으면 정상 가입으로 보면 안 된다. +- providerId 없는 primary 계정 생성은 허용되지만, 일반 OAuth 계정 연동에는 providerId가 필요하다. +- OAuth providerId와 oauthEmail 중 하나만 봐서 중복을 판단하면 안 된다. +- 탈퇴 계정의 OAuth 연결은 7일 복구 유예기간과 stage 프로필 예외를 함께 본다. +- 회장 사용자는 탈퇴할 수 없다. 동아리 하나라도 회장이면 막아야 한다. +- 사전 동아리 회원 흡수는 이름까지 일치해야 한다. +- 사전 회원이 회장이면 기존 회장과 채팅방 멤버십도 함께 교체된다. +- 회원가입 환영 메시지 실패는 회원가입 실패로 전파하지 않는다. +- refresh token은 access token과 다른 `token_type=refresh` claim을 가져야 한다. +- refresh 성공은 토큰 재발급뿐 아니라 로그인 시각 갱신이다. +- Google Drive refresh token은 별도 OAuth state 흐름으로 저장되며, 로그인용 OAuth 계정과 같은 row를 쓴다. + +## 수정 시 함께 확인해야 하는 것 + +### 회원가입 로직을 바꿀 때 + +- signup token read/consume 구분 +- `UnRegisteredUser` 조회 기준 +- providerId와 oauthEmail 중복 검증 +- Apple providerId 필수 정책 +- primary OAuth 계정 생성 +- 사전 동아리 회원 흡수 +- 동아리 채팅방 멤버십 추가 +- 환영 direct 메시지와 마지막 메시지 동기화 +- `UserRegisteredEvent` 발행 +- signup token 쿠키 제거와 refresh token 설정 + +### OAuth 계정 연동을 바꿀 때 + +- provider별 verifier 검증 +- providerId 필수 여부 +- 같은 사용자 기존 provider 계정 갱신 규칙 +- 다른 활성 사용자와의 providerId/oauthEmail 충돌 +- 탈퇴 계정 복구 또는 OAuth 계정 정리 +- Apple refresh token 저장 +- Google Drive refresh token과의 row 공유 + +### 탈퇴/복구 정책을 바꿀 때 + +- 회장 탈퇴 제한 +- `deletedAt` 기반 활성 사용자 필터 +- Apple token revoke 시점 +- `UserWithdrawnEvent` 발행 +- refresh/signup 쿠키 제거 +- 7일 복구 유예기간 +- stage 프로필 예외 +- 탈퇴 OAuth 계정 삭제 스케줄러 +- 탈퇴 사용자 이름 노출 정책 + +### 활동 시각과 토큰을 바꿀 때 + +- refresh token TTL +- issuer와 secret 검증 +- `token_type=refresh` 검증 +- rotate 후 새 refresh token 쿠키 설정 +- `lastLoginAt`과 `lastActivityAt` 갱신 범위 +- 탈퇴 사용자의 활동 시각 갱신 처리 + +### 동아리/채팅 연동을 바꿀 때 + +- `ClubPreMember` 매칭 기준 +- 회장 사전 회원의 기존 회장 교체 +- `ClubMember` 저장과 club group 멤버십 추가 +- 기존 회장 제거 시 club group 멤버십 제거 +- 환영 direct 채팅방 재사용 기준 +- `chat_room.last_message_*` 최신 메시지 조건부 갱신 + +## 주요 클래스와 책임 + +### `UserService` + +- 회원가입, 사용자 정보 조회, 회원 탈퇴가 모이는 중심 서비스다. +- 동아리 사전 회원 흡수, 환영 메시지, 이벤트 발행처럼 다른 도메인과 만나는 지점이 많다. + +### `UserOAuthAccountService` + +- OAuth 계정 연동, 연동 상태 조회, 탈퇴 계정 복구/정리, OAuth 계정 cleanup을 담당한다. +- providerId와 oauthEmail 충돌 정책을 바꿀 때 가장 먼저 봐야 한다. + +### `SignupTokenService` + +- 회원가입 token 발급, 조회, consume, 직렬화/역직렬화를 담당한다. +- token TTL과 claim 구조를 바꾸면 회원가입 prefill과 실제 가입 흐름을 같이 확인해야 한다. + +### `RefreshTokenService` + +- refresh token 발급, 검증, rotate를 담당한다. +- JWT secret, issuer, claim 정책을 바꾸면 인증 전역에 영향을 준다. + +### `UserActivityService` + +- 로그인 시각과 활동 시각 갱신을 담당한다. +- null userId와 탈퇴 사용자 처리 방식이 다르므로 단순 공통화하면 안 된다. + +### `UserSchedulerService` / `UserSchedulerTxService` + +- 7일 유예기간이 지난 Apple token revoke를 담당한다. +- 외부 Apple revoke 성공 후 저장된 refresh token을 비우는 순서를 유지해야 한다. + +### `UserOAuthAccountCleanupScheduler` + +- 7일 유예기간이 지난 탈퇴 사용자의 OAuth 계정 연결 삭제를 담당한다. +- 복구 유예기간과 동일한 기준을 써야 한다. + +### `GoogleDriveOAuthService` + +- Google Drive 권한 위임용 OAuth state와 refresh token 저장을 담당한다. +- 동아리 구글 시트 기능과 직접 연결된다. + +### `UserRepository` + +- 활성 사용자 기준 조회를 담당한다. +- `getById()`는 탈퇴 사용자를 반환하지 않는다. + +### `UserOAuthAccountRepository` + +- OAuth providerId, oauthEmail, provider별 사용자 계정 조회를 담당한다. +- 활성 사용자만 찾는 조회와 탈퇴 사용자까지 포함하는 계정 조회가 섞여 있으므로 쿼리 조건을 바꿀 때 주의해야 한다. diff --git a/src/test/java/gg/agit/konect/integration/domain/user/UserSignupApiTest.java b/src/test/java/gg/agit/konect/integration/domain/user/UserSignupApiTest.java index 7339b0098..9d35713fc 100644 --- a/src/test/java/gg/agit/konect/integration/domain/user/UserSignupApiTest.java +++ b/src/test/java/gg/agit/konect/integration/domain/user/UserSignupApiTest.java @@ -10,6 +10,11 @@ import java.time.Duration; +import gg.agit.konect.domain.chat.enums.ChatType; +import gg.agit.konect.domain.chat.model.ChatRoom; +import gg.agit.konect.domain.chat.model.ChatRoomMember; +import gg.agit.konect.domain.chat.repository.ChatRoomMemberRepository; +import gg.agit.konect.domain.chat.repository.ChatRoomRepository; import gg.agit.konect.domain.club.enums.ClubPosition; import gg.agit.konect.domain.club.model.Club; import gg.agit.konect.domain.club.model.ClubMember; @@ -21,6 +26,7 @@ import gg.agit.konect.domain.user.dto.SignupRequest; import gg.agit.konect.domain.user.model.UnRegisteredUser; import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.domain.user.event.UserRegisteredEvent; import gg.agit.konect.domain.user.service.RefreshTokenService; import gg.agit.konect.domain.user.service.SignupTokenService; import gg.agit.konect.domain.user.repository.UnRegisteredUserRepository; @@ -40,6 +46,8 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.event.ApplicationEvents; +import org.springframework.test.context.event.RecordApplicationEvents; import org.springframework.test.web.servlet.ResultActions; import java.util.List; @@ -47,6 +55,7 @@ import jakarta.servlet.http.Cookie; @DisplayName("회원가입 API 테스트") +@RecordApplicationEvents class UserSignupApiTest extends IntegrationTestSupport { @Autowired @@ -61,6 +70,15 @@ class UserSignupApiTest extends IntegrationTestSupport { @Autowired private ClubMemberRepository clubMemberRepository; + @Autowired + private ChatRoomRepository chatRoomRepository; + + @Autowired + private ChatRoomMemberRepository chatRoomMemberRepository; + + @Autowired + private ApplicationEvents applicationEvents; + @MockitoBean private SignupTokenService signupTokenService; @@ -119,6 +137,8 @@ void signupSuccess() throws Exception { assertThat(savedUser).isNotNull(); assertThat(savedUser.getName()).isEqualTo("홍길동"); assertThat(savedUser.getEmail()).isEqualTo(email); + assertThat(applicationEvents.stream(UserRegisteredEvent.class)) + .contains(UserRegisteredEvent.from(email, Provider.GOOGLE.name())); assertSignupTokenConsumedOnce(); } @@ -162,6 +182,10 @@ void signupWithPreMemberAutoJoinsClub() throws Exception { ClubMember clubMember = clubMemberRepository.getByClubIdAndUserId(club.getId(), savedUser.getId()); assertThat(clubMember.getClubPosition()).isEqualTo(ClubPosition.MEMBER); + ChatRoom clubRoom = chatRoomRepository.findByClubId(club.getId()).orElseThrow(); + assertThat(chatRoomMemberRepository.existsByChatRoomIdAndUserId(clubRoom.getId(), savedUser.getId())) + .isTrue(); + // PreMember는 삭제되었는지 확인 List remainingPreMembers = clubPreMemberRepository.findAllByClubId(club.getId()); assertThat(remainingPreMembers).isEmpty(); @@ -187,6 +211,9 @@ void signupWithPreMemberPresidentReplacesExistingPresident() throws Exception { .clubPosition(ClubPosition.PRESIDENT) .build(); persist(preMemberPresident); + ChatRoom existingClubRoom = persist(ChatRoom.clubGroupOf(club)); + User managedExistingPresident = entityManager.find(User.class, existingPresident.getId()); + persist(ChatRoomMember.of(existingClubRoom, managedExistingPresident, existingClubRoom.getCreatedAt())); clearPersistenceContext(); // 기존 회장이 존재하는지 확인 @@ -212,6 +239,50 @@ void signupWithPreMemberPresidentReplacesExistingPresident() throws Exception { assertThat(clubMemberRepository.findPresidentByClubId(club.getId())).isPresent(); assertThat(clubMemberRepository.findPresidentByClubId(club.getId()).get().getUser().getId()) .isEqualTo(savedUser.getId()); + ChatRoom clubRoom = chatRoomRepository.findByClubId(club.getId()).orElseThrow(); + assertThat(chatRoomMemberRepository.existsByChatRoomIdAndUserId(clubRoom.getId(), savedUser.getId())) + .isTrue(); + assertThat(chatRoomMemberRepository.existsByChatRoomIdAndUserId( + clubRoom.getId(), + existingPresident.getId() + )) + .isFalse(); + assertSignupTokenConsumedOnce(); + } + + @Test + @DisplayName("회원가입 시 운영자가 있으면 환영 direct 메시지와 마지막 메시지를 저장한다") + void signupSendsWelcomeMessageWhenAdminExists() throws Exception { + // given + User admin = persist(UserFixture.createAdmin(university)); + String email = "welcome@koreatech.ac.kr"; + String studentNumber = "2021136010"; + + UnRegisteredUser unRegisteredUser = UnRegisteredUserFixture.createGoogle(email); + persist(unRegisteredUser); + clearPersistenceContext(); + + SignupRequest request = new SignupRequest("환영대상", university.getId(), studentNumber, true); + stubSignupTokenClaims(email); + + // when + performSignup(request) + .andExpect(status().isOk()); + + // then + clearPersistenceContext(); + User savedUser = findSavedUser(studentNumber); + assertThat(savedUser).isNotNull(); + + ChatRoom welcomeRoom = chatRoomRepository.findByTwoUsers(admin.getId(), savedUser.getId(), ChatType.DIRECT) + .orElseThrow(); + assertThat(welcomeRoom.getLastMessageContent()) + .isEqualTo("KONECT에 오신 것을 환영합니다. 궁금한 점이 있으면 언제든 문의해 주세요."); + assertThat(welcomeRoom.getLastMessageSentAt()).isNotNull(); + assertThat(chatRoomMemberRepository.existsByChatRoomIdAndUserId(welcomeRoom.getId(), admin.getId())) + .isTrue(); + assertThat(chatRoomMemberRepository.existsByChatRoomIdAndUserId(welcomeRoom.getId(), savedUser.getId())) + .isTrue(); assertSignupTokenConsumedOnce(); } diff --git a/src/test/java/gg/agit/konect/integration/domain/user/UserWithdrawApiTest.java b/src/test/java/gg/agit/konect/integration/domain/user/UserWithdrawApiTest.java index 5c93d3cff..a41bb68b3 100644 --- a/src/test/java/gg/agit/konect/integration/domain/user/UserWithdrawApiTest.java +++ b/src/test/java/gg/agit/konect/integration/domain/user/UserWithdrawApiTest.java @@ -1,13 +1,18 @@ package gg.agit.konect.integration.domain.user; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import gg.agit.konect.domain.club.model.Club; import gg.agit.konect.domain.club.repository.ClubMemberRepository; import gg.agit.konect.domain.university.model.University; +import gg.agit.konect.domain.user.enums.Provider; +import gg.agit.konect.domain.user.event.UserWithdrawnEvent; import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.domain.user.model.UserOAuthAccount; import gg.agit.konect.domain.user.repository.UserRepository; +import gg.agit.konect.infrastructure.oauth.AppleTokenRevocationService; import gg.agit.konect.support.IntegrationTestSupport; import gg.agit.konect.support.fixture.ClubFixture; import gg.agit.konect.support.fixture.ClubMemberFixture; @@ -19,10 +24,14 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.event.ApplicationEvents; +import org.springframework.test.context.event.RecordApplicationEvents; import java.time.LocalDateTime; @DisplayName("회원 탈퇴 API 테스트") +@RecordApplicationEvents class UserWithdrawApiTest extends IntegrationTestSupport { @Autowired @@ -31,6 +40,12 @@ class UserWithdrawApiTest extends IntegrationTestSupport { @Autowired private ClubMemberRepository clubMemberRepository; + @Autowired + private ApplicationEvents applicationEvents; + + @MockitoBean + private AppleTokenRevocationService appleTokenRevocationService; + private University university; private Club club; @@ -84,6 +99,32 @@ void withdrawWithoutClubMembershipSuccess() throws Exception { assertThat(withdrawnUser.getDeletedAt()).isNotNull(); } + @Test + @DisplayName("Apple OAuth 계정이 있으면 탈퇴 시 토큰을 revoke하고 탈퇴 이벤트를 발행한다") + void withdrawWithAppleOAuthRevokesTokenAndPublishesEvent() throws Exception { + // given + User user = persist(UserFixture.createUser(university, "애플회원", "2021136010")); + persist(UserOAuthAccount.of( + user, + Provider.APPLE, + "apple-provider-id", + "apple@konect.test", + "apple-refresh-token" + )); + clearPersistenceContext(); + + mockLoginUser(user.getId()); + + // when + performDelete("/users/withdraw") + .andExpect(status().isNoContent()); + + // then + verify(appleTokenRevocationService).revoke("apple-refresh-token"); + assertThat(applicationEvents.stream(UserWithdrawnEvent.class)) + .contains(UserWithdrawnEvent.from(user.getEmail(), Provider.APPLE.name())); + } + @Test @DisplayName("회장은 탈퇴할 수 없다") void withdrawAsPresidentFails() throws Exception { From d71e80889205f139c43783a1ecd61feeb26d7a2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Sat, 25 Apr 2026 15:00:34 +0900 Subject: [PATCH 18/50] =?UTF-8?q?docs:=20=EC=88=9C=EA=B3=B5=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=A0=95=EC=B1=85=20?= =?UTF-8?q?=EA=B0=80=EC=9D=B4=EB=93=9C=20=EC=B6=94=EA=B0=80=20(#570)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: 순공 시간 도메인 정책 가이드 추가 - 타이머 실행, 시간 검증, 날짜별 누적 정책을 코드 기준으로 문서화 - 랭킹 캐시 갱신과 초기화 스케줄러의 원본 데이터 보존 기준을 정리 - 문서 claim 중 시간 불일치 처리와 랭킹 노출/초기화 정책을 단위 테스트로 고정 * test: PR 리뷰 피드백 반영 - 빈 순공 시간 통합 테스트 placeholder를 제거해 불필요한 테스트 클래스를 남기지 않음 - 개인 랭킹 한 글자 이름 노출 분기를 테스트로 고정하고 불필요한 Mockito 상속을 제거 - StudyTimer auditing 필드 설정을 fixture로 분리해 타이머 테스트 재사용성을 높임 * test: 순공 시간 랭킹 테스트 fixture 정리 - RankingType 생성 보일러플레이트를 fixture로 분리해 테스트 본문이 검증 의도에 집중하도록 정리 - DTO와 스케줄러 테스트에 반복되던 하위 클래스와 ReflectionTestUtils 사용을 제거 --- .../gg/agit/konect/domain/studytime/AGENTS.md | 260 ++++++++++++++++++ .../domain/studytime/StudyTimeApiTest.java | 0 .../support/fixture/RankingTypeFixture.java | 17 ++ .../support/fixture/StudyTimerFixture.java | 18 ++ .../dto/StudyTimeRankingResponseTest.java | 90 ++++++ .../StudyTimeSchedulerServiceTest.java | 66 +++++ .../service/StudyTimerServiceTest.java | 116 ++++++++ 7 files changed, 567 insertions(+) create mode 100644 src/main/java/gg/agit/konect/domain/studytime/AGENTS.md delete mode 100644 src/test/java/gg/agit/konect/integration/domain/studytime/StudyTimeApiTest.java create mode 100644 src/test/java/gg/agit/konect/support/fixture/RankingTypeFixture.java create mode 100644 src/test/java/gg/agit/konect/support/fixture/StudyTimerFixture.java create mode 100644 src/test/java/gg/agit/konect/unit/domain/studytime/dto/StudyTimeRankingResponseTest.java create mode 100644 src/test/java/gg/agit/konect/unit/domain/studytime/service/StudyTimeSchedulerServiceTest.java create mode 100644 src/test/java/gg/agit/konect/unit/domain/studytime/service/StudyTimerServiceTest.java diff --git a/src/main/java/gg/agit/konect/domain/studytime/AGENTS.md b/src/main/java/gg/agit/konect/domain/studytime/AGENTS.md new file mode 100644 index 000000000..f4d71cfa2 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/studytime/AGENTS.md @@ -0,0 +1,260 @@ +# 순공 시간 도메인 가이드 + +## 이 도메인은 무엇을 하는가 + +순공 시간 도메인은 사용자의 공부 타이머, 일별 공부 시간 누적, 랭킹 캐시, 랭킹 초기화 스케줄러를 관리하는 도메인이다. + +이 도메인에서 중요한 것은 단순히 초 단위 시간을 더하는 것이 아니라 아래 상태가 같은 기준으로 움직이는 것이다. + +- 실행 중인 타이머(`StudyTimer`) +- 날짜별 누적 시간(`StudyTimeDaily`) +- 개인/동아리/학번 랭킹 캐시(`StudyTimeRanking`) +- 랭킹 타입(`RankingType`) +- 공부 시간 누적 이벤트(`StudyTimeAccumulatedEvent`) +- 일간/월간 랭킹 초기화 스케줄러 + +순공 시간 관련 작업을 할 때는 항상 "이 변경이 타이머 단일 실행, 서버/클라이언트 시간 검증, 자정 분할 누적, 랭킹 캐시 갱신, 초기화 스케줄러까지 같이 맞는가"를 먼저 확인해야 한다. + +## 주요 상태 + +### `StudyTimer` + +- 실행 중인 타이머를 나타낸다. +- 사용자당 동시에 하나만 존재할 수 있다. +- DB의 `study_timer.user_id`는 unique 제약을 가진다. +- `startedAt`은 마지막으로 누적 반영된 시각이다. +- `createdAt`은 현재 타이머 세션의 최초 시작 시각이며, 서버 기준 총 경과 시간 계산에 쓰인다. +- sync가 성공하면 `startedAt`만 현재 시각으로 갱신된다. +- stop이 성공하면 타이머 row는 삭제된다. + +### `StudyTimeDaily` + +- 사용자별, 날짜별 누적 공부 시간을 저장한다. +- DB의 `(user_id, study_date)`는 unique 제약을 가진다. +- 자정을 넘긴 세션은 날짜별 구간으로 나뉘어 각 날짜 row에 더해진다. +- 현재 구현은 일간 테이블을 기준으로 일간, 월간, 전체 누적 시간을 모두 계산한다. +- 마이그레이션에는 `study_time_monthly`, `study_time_total` 테이블도 있지만 현재 서비스 로직은 이 테이블들을 직접 사용하지 않는다. + +### `StudyTimeRanking` + +- 랭킹 조회를 빠르게 하기 위한 캐시성 테이블이다. +- 기본 키는 `(ranking_type_id, university_id, target_id)`다. +- 랭킹 타입은 `CLUB`, `STUDENT_NUMBER`, `PERSONAL` 세 가지다. +- `dailySeconds`와 `monthlySeconds`를 함께 저장한다. +- 랭킹 캐시는 공부 시간이 누적된 뒤 발행되는 이벤트 리스너에서 갱신된다. + +## 기능이 실제로 어떻게 동작해야 하는가 + +### 타이머 시작 + +- 사용자당 실행 중인 타이머는 하나만 허용된다. +- 서비스는 먼저 `existsByUserId`로 실행 중 타이머를 확인한다. +- 동시에 두 요청이 들어와도 DB unique 제약과 flush 시점의 `DataIntegrityViolationException`을 통해 `ALREADY_RUNNING_STUDY_TIMER`로 정리한다. +- 타이머 시작 시점에는 공부 시간이 누적되지 않고, 이벤트도 발행되지 않는다. + +### 타이머 sync + +- sync는 실행 중인 타이머가 있어야 가능하다. +- 서버 기준 경과 시간은 `createdAt`부터 현재 시각까지다. +- 클라이언트가 보낸 `totalSeconds`와 서버 기준 경과 시간이 3초 이상 차이나면 실패한다. +- 시간 불일치가 발생하면 타이머를 삭제하고 `STUDY_TIMER_TIME_MISMATCH`를 던진다. +- 시간 불일치 예외는 트랜잭션 rollback 대상에서 제외되어야 한다. 그래야 잘못된 타이머 삭제가 유지된다. +- sync 성공 시 마지막 sync 이후 구간만 `StudyTimeDaily`에 누적한다. +- sync 성공 후 `StudyTimeAccumulatedEvent`를 발행하고 `startedAt`을 현재 시각으로 갱신한다. + +### 타이머 stop + +- stop은 실행 중인 타이머가 있어야 가능하다. +- 서버/클라이언트 시간 차이가 3초 이상이면 sync와 동일하게 타이머를 삭제하고 실패한다. +- stop 성공 시 마지막 sync 이후 구간을 누적하고 `StudyTimeAccumulatedEvent`를 발행한다. +- stop 성공 후 실행 중인 타이머 row를 삭제한다. +- 응답의 `sessionSeconds`는 현재 세션의 서버 기준 총 경과 시간이다. +- 응답의 `dailySeconds`, `monthlySeconds`, `totalSeconds`는 누적 반영 후 다시 조회한 값이다. + +### 자정 분할 누적 + +- 공부 시간이 자정을 넘기면 하나의 총합으로 저장하지 않고 날짜별로 분할한다. +- 구간 시작일이 종료일보다 이전이면 해당 날짜의 자정까지를 먼저 누적한다. +- 마지막 날짜는 실제 종료 시각까지 누적한다. +- 0초 이하 구간은 저장하지 않는다. +- 이 정책을 바꾸면 일간 조회뿐 아니라 월간/전체 합계와 랭킹 갱신 결과도 함께 바뀐다. + +### 요약 조회 + +- 일간 공부 시간은 오늘 날짜의 `StudyTimeDaily` row를 조회한다. +- 월간 공부 시간은 이번 달 1일부터 오늘까지의 `StudyTimeDaily.totalSeconds` 합계다. +- 전체 공부 시간은 사용자의 모든 `StudyTimeDaily.totalSeconds` 합계다. +- 실행 중인 타이머의 아직 sync되지 않은 시간은 요약 조회에 포함되지 않는다. + +### 랭킹 갱신 + +- 공부 시간이 실제로 누적된 뒤 `StudyTimeAccumulatedEvent`가 발행된다. +- 랭킹 갱신 리스너는 `AFTER_COMMIT`에 실행된다. +- 리스너는 별도 트랜잭션(`REQUIRES_NEW`)으로 랭킹 캐시를 갱신한다. +- 즉 공부 시간 누적 트랜잭션이 rollback되면 랭킹 갱신도 실행되지 않는다. +- 랭킹 갱신은 개인, 사용자가 속한 동아리, 사용자의 학번 연도 랭킹을 함께 갱신한다. + +### 개인 랭킹 + +- 개인 랭킹의 target id는 user id다. +- target name은 사용자 이름이다. +- 랭킹 목록 응답에서 개인 이름은 마스킹된다. +- 한 글자 이름은 그대로, 두 글자 이름은 첫 글자와 `*`, 세 글자 이상은 첫 글자와 마지막 글자만 노출한다. + +### 동아리 랭킹 + +- 동아리 랭킹은 사용자가 속한 각 동아리에 대해 갱신된다. +- target id는 club id다. +- target name은 동아리 이름이다. +- 동아리 공부 시간은 현재 동아리 회원들의 일간/월간 공부 시간 합계다. +- 사용자의 공부 시간이 누적되면 사용자가 속한 동아리들의 랭킹만 갱신된다. +- 동아리 회원 구성 변경 자체가 순공 시간 이벤트를 발행하지는 않는다. 회원 변경 후 랭킹 정합성을 요구한다면 별도 갱신 지점을 확인해야 한다. + +### 학번 랭킹 + +- 학번 랭킹은 사용자의 `studentNumberYear` 기준으로 묶는다. +- target name은 학번 연도 문자열이다. +- 랭킹 목록 응답에서는 학번 연도의 뒤 두 자리만 노출한다. +- 학번 랭킹은 target name으로 기존 row를 찾는다. +- 새 학번 랭킹 row가 필요하면 같은 랭킹 타입과 대학 안에서 max target id를 찾아 다음 id를 부여한다. +- 따라서 학번 랭킹은 target id가 학번 값이 아니라 내부 순번이라는 점을 놓치면 안 된다. + +### 랭킹 조회 + +- 랭킹은 로그인한 사용자의 대학 기준으로만 조회된다. +- `type`은 `CLUB`, `STUDENT_NUMBER`, `PERSONAL`만 허용한다. +- `type`은 대소문자를 구분하지 않고 조회하지만, 요청 검증의 허용 값은 세 타입으로 제한된다. +- `page` 기본값은 1, `limit` 기본값은 20, `sort` 기본값은 `MONTHLY`다. +- `limit`은 1 이상 100 이하만 허용한다. +- 일간 정렬은 `dailySeconds DESC`, `monthlySeconds DESC`, `targetId ASC` 순서다. +- 월간 정렬은 `monthlySeconds DESC`, `dailySeconds DESC`, `targetId ASC` 순서다. +- 페이지 응답의 rank는 페이지 시작 번호 기준으로 계산된다. +- 내 랭킹 조회는 동아리 랭킹 목록, 학번 랭킹, 개인 랭킹을 함께 반환한다. +- 내 동아리 랭킹은 존재하는 랭킹 row만 반환하고 rank 오름차순으로 정렬한다. +- 학번/개인 랭킹 row가 아직 없으면 해당 응답 필드는 `null`일 수 있다. + +### 랭킹 초기화 스케줄러 + +- 매일 00:00에 모든 랭킹의 `dailySeconds`를 0으로 초기화한다. +- 매월 1일 00:00에 모든 랭킹의 `monthlySeconds`를 0으로 초기화한다. +- 초기화 대상은 `study_time_ranking` 캐시 테이블이다. +- `StudyTimeDaily` 원본 누적 데이터는 초기화하지 않는다. +- 스케줄러는 예외를 잡아 로그로 남기며, 예외를 외부로 다시 던지지 않는다. + +## 절대 놓치면 안 되는 정책 + +- 실행 중 타이머는 사용자당 하나뿐이다. 서비스 선검사와 DB unique 제약을 함께 봐야 한다. +- `startedAt`과 `createdAt`은 역할이 다르다. `startedAt`은 마지막 누적 지점, `createdAt`은 세션 전체 경과 시간 기준이다. +- 서버/클라이언트 시간 차이가 3초 이상이면 타이머를 삭제하고 실패한다. +- 시간 불일치 실패에서 타이머 삭제가 rollback되면 안 된다. +- sync/stop은 마지막 sync 이후 구간만 누적한다. +- 자정을 넘긴 세션은 날짜별로 분할 누적해야 한다. +- 요약 조회는 저장된 `StudyTimeDaily`만 본다. 실행 중 타이머의 미반영 시간은 포함하지 않는다. +- 랭킹 갱신은 공부 시간 누적 트랜잭션 commit 이후 별도 트랜잭션에서 실행된다. +- 랭킹 캐시는 원본 누적 데이터가 아니다. 일간/월간 초기화는 랭킹 캐시에만 적용된다. +- 개인 이름과 학번 연도는 랭킹 목록 응답에서 노출 정책이 다르다. +- 학번 랭킹 target id는 학번 자체가 아니라 내부 순번이다. +- 동아리 회원 변경은 순공 시간 랭킹 갱신 이벤트를 자동으로 만들지 않는다. + +## 수정 시 함께 확인해야 하는 것 + +### 타이머 시작/종료 정책을 바꿀 때 + +- `study_timer.user_id` unique 제약 +- 중복 시작 시 `ALREADY_RUNNING_STUDY_TIMER` +- `createdAt` 기준 서버 경과 시간 계산 +- `startedAt` 기준 마지막 누적 구간 계산 +- 시간 불일치 시 타이머 삭제 유지 +- sync/stop 성공 시 `StudyTimeAccumulatedEvent` 발행 + +### 시간 누적 로직을 바꿀 때 + +- 자정 분할 누적 +- `StudyTimeDaily`의 `(user_id, study_date)` unique 제약 +- 일간/월간/전체 조회 쿼리 +- 0초 이하 구간 무시 +- 랭킹 갱신에 쓰이는 일간/월간 집계 기준 + +### 랭킹 정책을 바꿀 때 + +- `RankingType` seed 값 (`CLUB`, `STUDENT_NUMBER`, `PERSONAL`) +- 대학별 랭킹 격리 +- 일간/월간 정렬 tie-breaker +- 개인 이름 마스킹 +- 학번 연도 뒤 두 자리 표시 +- 학번 랭킹 target id 생성 규칙 +- 내 랭킹 조회에서 null 허용 필드 + +### 스케줄러를 바꿀 때 + +- 매일 00:00 일간 랭킹 초기화 +- 매월 1일 00:00 월간 랭킹 초기화 +- 원본 `StudyTimeDaily`를 초기화하지 않는 정책 +- `scheduler.studytime` 로거 설정 +- 예외를 잡아 스케줄러 실행 흐름을 유지하는 정책 + +### 동아리/유저 도메인과 함께 바꿀 때 + +- 동아리 회원 목록 기반 동아리 랭킹 합산 +- 회원 탈퇴 또는 동아리 탈퇴 후 랭킹 캐시 정합성 +- 사용자 이름 변경 시 개인 랭킹 target name 갱신 여부 +- 학번 변경 가능성이 생길 경우 학번 랭킹 target name과 target id 정합성 +- 사용자 대학 변경 가능성이 생길 경우 대학별 랭킹 격리 + +## 주요 클래스와 책임 + +### `StudyTimerService` + +- 타이머 시작, sync, stop을 담당한다. +- 시간 불일치 검증, 날짜별 누적, 이벤트 발행이 모여 있는 중심 서비스다. + +### `StudyTimeQueryService` + +- 일간, 월간, 전체 누적 공부 시간 조회를 담당한다. +- 현재 구현은 `StudyTimeDaily` 합계만 사용한다. + +### `StudyTimeRankingUpdateService` + +- 공부 시간 누적 이후 개인/동아리/학번 랭킹 캐시를 갱신한다. +- 동아리 회원 합산과 학번 연도 합산 정책을 바꿀 때 가장 먼저 봐야 한다. + +### `StudyTimeRankingService` + +- 랭킹 목록 조회와 내 랭킹 조회를 담당한다. +- 정렬 기준, rank 계산, 이름/학번 노출 정책이 응답으로 나가는 지점이다. + +### `StudyTimeRankingUpdateListener` + +- `StudyTimeAccumulatedEvent`를 commit 이후 받아 랭킹 갱신을 실행한다. +- 트랜잭션 전파 방식을 바꾸면 누적 성공과 랭킹 갱신의 결합도가 달라진다. + +### `StudyTimeSchedulerService` / `StudyTimeScheduler` + +- 일간/월간 랭킹 캐시 초기화를 담당한다. +- 원본 누적 데이터가 아니라 랭킹 캐시만 초기화한다는 점을 유지해야 한다. + +## 테스트 전략 + +현재 `StudyTimeApiTest`는 비어 있으므로 API 통합 흐름은 아직 충분히 고정되어 있지 않다. 다만 핵심 단위 정책은 `gg.agit.konect.unit.domain.studytime` 아래에서 먼저 고정한다. + +이미 고정한 회귀 테스트는 아래와 같다. + +- 중복 타이머 시작은 `ALREADY_RUNNING_STUDY_TIMER`로 실패한다. +- 시간 불일치 sync/stop은 타이머를 삭제하고 공부 시간/랭킹 후속 효과를 만들지 않는다. +- 개인 랭킹 이름과 학번 랭킹 이름은 노출 정책에 맞게 마스킹된다. +- 랭킹 초기화 스케줄러는 `StudyTimeRanking`의 일간/월간 캐시만 초기화한다. + +이 도메인의 정책을 바꾸거나 가이드 claim을 강화한다면 추가로 아래 회귀 테스트를 보강하는 것이 좋다. + +- 자정을 넘긴 stop은 두 날짜의 `StudyTimeDaily`로 분할 누적한다. +- sync 후 stop은 이미 sync된 구간을 다시 더하지 않는다. +- 공부 시간 누적 commit 이후 개인/동아리/학번 랭킹 캐시가 갱신된다. +- 랭킹 목록은 일간/월간 정렬 tie-breaker와 이름/학번 노출 정책을 지킨다. + +검증할 때는 최소한 아래 테스트를 실행한다. + +```bash +CI=true ./gradlew test --tests 'gg.agit.konect.unit.domain.studytime.*' +``` + +API 통합 테스트를 추가한 뒤에는 `gg.agit.konect.integration.domain.studytime.*` 필터도 별도로 실행 가능하게 유지해야 한다. diff --git a/src/test/java/gg/agit/konect/integration/domain/studytime/StudyTimeApiTest.java b/src/test/java/gg/agit/konect/integration/domain/studytime/StudyTimeApiTest.java deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/test/java/gg/agit/konect/support/fixture/RankingTypeFixture.java b/src/test/java/gg/agit/konect/support/fixture/RankingTypeFixture.java new file mode 100644 index 000000000..e120a3300 --- /dev/null +++ b/src/test/java/gg/agit/konect/support/fixture/RankingTypeFixture.java @@ -0,0 +1,17 @@ +package gg.agit.konect.support.fixture; + +import org.springframework.test.util.ReflectionTestUtils; + +import gg.agit.konect.domain.studytime.model.RankingType; + +public class RankingTypeFixture { + + public static RankingType createWithId(Integer id) { + RankingType rankingType = new TestRankingType(); + ReflectionTestUtils.setField(rankingType, "id", id); + return rankingType; + } + + private static class TestRankingType extends RankingType { + } +} diff --git a/src/test/java/gg/agit/konect/support/fixture/StudyTimerFixture.java b/src/test/java/gg/agit/konect/support/fixture/StudyTimerFixture.java new file mode 100644 index 000000000..4e35bcacc --- /dev/null +++ b/src/test/java/gg/agit/konect/support/fixture/StudyTimerFixture.java @@ -0,0 +1,18 @@ +package gg.agit.konect.support.fixture; + +import java.time.LocalDateTime; + +import org.springframework.test.util.ReflectionTestUtils; + +import gg.agit.konect.domain.studytime.model.StudyTimer; +import gg.agit.konect.domain.user.model.User; + +public class StudyTimerFixture { + + public static StudyTimer createStartedTimer(User user, LocalDateTime startedAt) { + StudyTimer studyTimer = StudyTimer.of(user, startedAt); + ReflectionTestUtils.setField(studyTimer, "createdAt", startedAt); + ReflectionTestUtils.setField(studyTimer, "updatedAt", startedAt); + return studyTimer; + } +} diff --git a/src/test/java/gg/agit/konect/unit/domain/studytime/dto/StudyTimeRankingResponseTest.java b/src/test/java/gg/agit/konect/unit/domain/studytime/dto/StudyTimeRankingResponseTest.java new file mode 100644 index 000000000..6753caf32 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/studytime/dto/StudyTimeRankingResponseTest.java @@ -0,0 +1,90 @@ +package gg.agit.konect.unit.domain.studytime.dto; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import gg.agit.konect.domain.studytime.dto.StudyTimeRankingResponse; +import gg.agit.konect.domain.studytime.model.RankingType; +import gg.agit.konect.domain.studytime.model.StudyTimeRanking; +import gg.agit.konect.domain.university.model.University; +import gg.agit.konect.support.fixture.RankingTypeFixture; +import gg.agit.konect.support.fixture.UniversityFixture; + +class StudyTimeRankingResponseTest { + + @Test + @DisplayName("한 글자 개인 이름은 마스킹하지 않는다") + void fromKeepsSingleLetterPersonalRankingName() { + // given + StudyTimeRanking ranking = createRanking("김"); + + // when + StudyTimeRankingResponse response = StudyTimeRankingResponse.from(ranking, 1, "PERSONAL"); + + // then + assertThat(response.name()).isEqualTo("김"); + assertThat(response.rank()).isEqualTo(1); + } + + @Test + @DisplayName("개인 랭킹 이름은 첫 글자와 마지막 글자만 남기고 마스킹한다") + void fromMasksPersonalRankingName() { + // given + StudyTimeRanking ranking = createRanking("김민수"); + + // when + StudyTimeRankingResponse response = StudyTimeRankingResponse.from(ranking, 1, "PERSONAL"); + + // then + assertThat(response.name()).isEqualTo("김*수"); + } + + @Test + @DisplayName("두 글자 개인 이름은 첫 글자만 남기고 마스킹한다") + void fromMasksTwoLetterPersonalRankingName() { + // given + StudyTimeRanking ranking = createRanking("길동"); + + // when + StudyTimeRankingResponse response = StudyTimeRankingResponse.from(ranking, 1, "PERSONAL"); + + // then + assertThat(response.name()).isEqualTo("길*"); + } + + @Test + @DisplayName("학번 랭킹 이름은 입학연도 뒤 두 자리만 노출한다") + void fromDisplaysLastTwoDigitsForStudentNumberRanking() { + // given + StudyTimeRanking ranking = createRanking("2024"); + + // when + StudyTimeRankingResponse response = StudyTimeRankingResponse.from(ranking, 3, "STUDENT_NUMBER"); + + // then + assertThat(response.name()).isEqualTo("24"); + assertThat(response.rank()).isEqualTo(3); + } + + @Test + @DisplayName("동아리 랭킹 이름은 원문을 유지한다") + void fromKeepsClubRankingName() { + // given + StudyTimeRanking ranking = createRanking("BCSD Lab"); + + // when + StudyTimeRankingResponse response = StudyTimeRankingResponse.from(ranking, 2, "CLUB"); + + // then + assertThat(response.name()).isEqualTo("BCSD Lab"); + } + + private StudyTimeRanking createRanking(String targetName) { + RankingType rankingType = RankingTypeFixture.createWithId(1); + University university = UniversityFixture.createWithId(1); + + return StudyTimeRanking.of(rankingType, university, 10, targetName, 100L, 1000L); + } +} diff --git a/src/test/java/gg/agit/konect/unit/domain/studytime/service/StudyTimeSchedulerServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/studytime/service/StudyTimeSchedulerServiceTest.java new file mode 100644 index 000000000..63a7c9996 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/studytime/service/StudyTimeSchedulerServiceTest.java @@ -0,0 +1,66 @@ +package gg.agit.konect.unit.domain.studytime.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; + +import java.util.List; + +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.studytime.model.RankingType; +import gg.agit.konect.domain.studytime.model.StudyTimeRanking; +import gg.agit.konect.domain.studytime.repository.StudyTimeRankingRepository; +import gg.agit.konect.domain.studytime.service.StudyTimeSchedulerService; +import gg.agit.konect.domain.university.model.University; +import gg.agit.konect.support.ServiceTestSupport; +import gg.agit.konect.support.fixture.RankingTypeFixture; +import gg.agit.konect.support.fixture.UniversityFixture; + +class StudyTimeSchedulerServiceTest extends ServiceTestSupport { + + @Mock + private StudyTimeRankingRepository studyTimeRankingRepository; + + @InjectMocks + private StudyTimeSchedulerService studyTimeSchedulerService; + + @Test + @DisplayName("일간 랭킹 초기화는 dailySeconds만 0으로 만들고 monthlySeconds는 유지한다") + void resetStudyTimeRankingDailyKeepsMonthlySeconds() { + // given + StudyTimeRanking ranking = createRanking(120L, 3600L); + given(studyTimeRankingRepository.findAll()).willReturn(List.of(ranking)); + + // when + studyTimeSchedulerService.resetStudyTimeRankingDaily(); + + // then + assertThat(ranking.getDailySeconds()).isZero(); + assertThat(ranking.getMonthlySeconds()).isEqualTo(3600L); + } + + @Test + @DisplayName("월간 랭킹 초기화는 monthlySeconds만 0으로 만들고 dailySeconds는 유지한다") + void resetStudyTimeRankingMonthlyKeepsDailySeconds() { + // given + StudyTimeRanking ranking = createRanking(120L, 3600L); + given(studyTimeRankingRepository.findAll()).willReturn(List.of(ranking)); + + // when + studyTimeSchedulerService.resetStudyTimeRankingMonthly(); + + // then + assertThat(ranking.getDailySeconds()).isEqualTo(120L); + assertThat(ranking.getMonthlySeconds()).isZero(); + } + + private StudyTimeRanking createRanking(Long dailySeconds, Long monthlySeconds) { + RankingType rankingType = RankingTypeFixture.createWithId(1); + University university = UniversityFixture.createWithId(1); + + return StudyTimeRanking.of(rankingType, university, 1, "BCSD Lab", dailySeconds, monthlySeconds); + } +} diff --git a/src/test/java/gg/agit/konect/unit/domain/studytime/service/StudyTimerServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/studytime/service/StudyTimerServiceTest.java new file mode 100644 index 000000000..10dfa11c4 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/studytime/service/StudyTimerServiceTest.java @@ -0,0 +1,116 @@ +package gg.agit.konect.unit.domain.studytime.service; + +import static gg.agit.konect.global.code.ApiResponseCode.ALREADY_RUNNING_STUDY_TIMER; +import static gg.agit.konect.global.code.ApiResponseCode.STUDY_TIMER_TIME_MISMATCH; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +import java.time.LocalDateTime; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.context.ApplicationEventPublisher; + +import gg.agit.konect.domain.studytime.dto.StudyTimerStopRequest; +import gg.agit.konect.domain.studytime.dto.StudyTimerSyncRequest; +import gg.agit.konect.domain.studytime.model.StudyTimer; +import gg.agit.konect.domain.studytime.repository.StudyTimeDailyRepository; +import gg.agit.konect.domain.studytime.repository.StudyTimerRepository; +import gg.agit.konect.domain.studytime.service.StudyTimeQueryService; +import gg.agit.konect.domain.studytime.service.StudyTimerService; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.domain.user.repository.UserRepository; +import gg.agit.konect.global.exception.CustomException; +import gg.agit.konect.support.ServiceTestSupport; +import gg.agit.konect.support.fixture.StudyTimerFixture; +import gg.agit.konect.support.fixture.UserFixture; +import jakarta.persistence.EntityManager; + +class StudyTimerServiceTest extends ServiceTestSupport { + + @Mock + private StudyTimeQueryService studyTimeQueryService; + + @Mock + private StudyTimerRepository studyTimerRepository; + + @Mock + private StudyTimeDailyRepository studyTimeDailyRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private EntityManager entityManager; + + @Mock + private ApplicationEventPublisher eventPublisher; + + @InjectMocks + private StudyTimerService studyTimerService; + + @Test + @DisplayName("이미 실행 중인 타이머가 있으면 새 타이머를 시작하지 않는다") + void startRejectsAlreadyRunningTimer() { + // given + given(studyTimerRepository.existsByUserId(1)).willReturn(true); + + // when & then + assertThatThrownBy(() -> studyTimerService.start(1)) + .isInstanceOf(CustomException.class) + .satisfies(exception -> assertThat(((CustomException)exception).getErrorCode()) + .isEqualTo(ALREADY_RUNNING_STUDY_TIMER)); + + verifyNoInteractions(userRepository, entityManager, eventPublisher); + verify(studyTimerRepository, never()).save(any()); + } + + @Test + @DisplayName("sync 시간 불일치가 3초 이상이면 타이머를 삭제하고 공부 시간을 누적하지 않는다") + void syncDeletesTimerWhenElapsedTimeMismatches() { + // given + StudyTimer studyTimer = createTimerStartedOneHourAgo(); + given(studyTimerRepository.getByUserId(1)).willReturn(studyTimer); + + // when & then + assertThatThrownBy(() -> studyTimerService.sync(1, new StudyTimerSyncRequest(0L))) + .isInstanceOf(CustomException.class) + .satisfies(exception -> assertThat(((CustomException)exception).getErrorCode()) + .isEqualTo(STUDY_TIMER_TIME_MISMATCH)); + + verify(studyTimerRepository).delete(studyTimer); + verifyNoInteractions(studyTimeDailyRepository, studyTimeQueryService); + verify(eventPublisher, never()).publishEvent(any()); + } + + @Test + @DisplayName("stop 시간 불일치가 3초 이상이면 타이머를 삭제하고 요약을 만들지 않는다") + void stopDeletesTimerWhenElapsedTimeMismatches() { + // given + StudyTimer studyTimer = createTimerStartedOneHourAgo(); + given(studyTimerRepository.getByUserId(1)).willReturn(studyTimer); + + // when & then + assertThatThrownBy(() -> studyTimerService.stop(1, new StudyTimerStopRequest(0L))) + .isInstanceOf(CustomException.class) + .satisfies(exception -> assertThat(((CustomException)exception).getErrorCode()) + .isEqualTo(STUDY_TIMER_TIME_MISMATCH)); + + verify(studyTimerRepository).delete(studyTimer); + verifyNoInteractions(studyTimeDailyRepository, studyTimeQueryService); + verify(eventPublisher, never()).publishEvent(any()); + } + + private StudyTimer createTimerStartedOneHourAgo() { + User user = UserFixture.createUserWithId(1, "2021136001"); + LocalDateTime startedAt = LocalDateTime.now().minusHours(1); + return StudyTimerFixture.createStartedTimer(user, startedAt); + } +} From b7da0ab4aed7b51a876ebef55a5bd6e0b77249cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Sat, 25 Apr 2026 15:58:16 +0900 Subject: [PATCH 19/50] =?UTF-8?q?docs:=20=EC=95=8C=EB=A6=BC=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EC=A0=95=EC=B1=85=20=EA=B0=80=EC=9D=B4?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80=20(#571)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: 알림 도메인 정책 가이드 추가 - push token, 채팅 알림 필터, 인앱 알림, SSE, Expo 발송 정책을 코드 기준으로 문서화 - 동아리 지원 알림의 inbox/SSE/push 후속 효과와 AFTER_COMMIT 이벤트 경계를 정리 - 문서 claim 중 token 형식 허용과 push token 없는 동아리 지원 알림 경로를 회귀 테스트로 고정 * test: 알림 리뷰 피드백 반영 - 동아리 지원 알림 3종에서 push token이 없어도 inbox 저장과 SSE payload가 유지되는지 검증 - 발송용 token 조회와 내 token 조회의 실패 정책을 문서에서 분리해 오해를 줄임 - 리뷰가 지적한 문서 claim과 테스트 커버리지 불일치를 회귀 테스트로 고정 --- .../agit/konect/domain/notification/AGENTS.md | 266 ++++++++++++++++++ .../service/NotificationServiceTest.java | 76 +++++ 2 files changed, 342 insertions(+) create mode 100644 src/main/java/gg/agit/konect/domain/notification/AGENTS.md diff --git a/src/main/java/gg/agit/konect/domain/notification/AGENTS.md b/src/main/java/gg/agit/konect/domain/notification/AGENTS.md new file mode 100644 index 000000000..61a43c307 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/notification/AGENTS.md @@ -0,0 +1,266 @@ +# 알림 도메인 가이드 + +## 이 도메인은 무엇을 하는가 + +알림 도메인은 Expo push token 관리, 채팅 푸시 알림, 동아리 지원 관련 인앱 알림, SSE 실시간 전달, 알림함 읽음 상태를 관리하는 도메인이다. + +이 도메인에서 중요한 것은 단순히 외부 push API를 호출하는 것이 아니라 아래 상태와 후속 효과가 같은 정책을 바라보는 것이다. + +- 사용자별 Expo device token (`NotificationDeviceToken`) +- 인앱 알림함 (`NotificationInbox`) +- 채팅방별 mute 설정 (`NotificationMuteSetting`) +- 현재 채팅방 접속 상태 (`ChatPresenceService`) +- SSE emitter 연결 상태 (`NotificationInboxSseService`) +- Expo push 발송과 재시도 (`ExpoPushClient`) +- 동아리 지원 이벤트 리스너 (`ClubApplicationNotificationListener`) + +알림 관련 작업을 할 때는 항상 "이 변경이 push token, 인앱 알림 저장, SSE 전달, 채팅방 접속/뮤트 필터, 이벤트 commit 이후 발송까지 같이 맞는가"를 먼저 확인해야 한다. + +## 주요 상태 + +### `NotificationDeviceToken` + +- 사용자별 Expo push token을 저장한다. +- DB의 `notification_device_token.user_id`는 unique 제약을 가진다. +- 현재 구현 기준으로 한 사용자에게 활성 token row는 하나만 존재한다. +- token 문자열은 `ExponentPushToken[...]` 또는 `ExpoPushToken[...]` 형식만 허용한다. +- 내 토큰 조회 API 등 `getByUserId()`를 사용하는 조회는 row가 없으면 `NOT_FOUND_NOTIFICATION_TOKEN`으로 실패한다. +- 푸시 발송 경로의 `findTokensByUserId()` 조회는 row가 없으면 빈 리스트를 반환하며, 이 경우 푸시 발송만 생략한다. +- token 삭제는 요청한 userId와 token이 정확히 일치할 때만 삭제하고, 없으면 조용히 끝난다. + +### `NotificationInbox` + +- 인앱 알림함에 보이는 알림이다. +- 생성 시 `isRead`는 항상 `false`다. +- 알림 타입, 제목, 본문, 이동 path를 함께 저장한다. +- 단건 읽음 처리는 `notificationId + userId`로 찾은 본인 알림만 읽음 처리한다. +- 전체 읽음 처리는 채팅 관련 타입을 제외한 알림만 대상으로 한다. + +### `NotificationMuteSetting` + +- 현재 mute 대상 타입은 `CHAT_ROOM`뿐이다. +- unique 기준은 `(target_type, target_id, user_id)`다. +- `isMuted`가 null로 들어오면 false로 저장된다. +- `toggleMute()`는 현재 값을 반전한다. +- 채팅 알림 발송에서는 `isMuted = true`인 사용자만 제외한다. + +### `NotificationInboxSseService` + +- 사용자별 SSE emitter를 메모리 맵에 보관한다. +- 같은 사용자가 다시 구독하면 기존 emitter를 완료하고 새 emitter로 교체한다. +- 구독 성공 시 `connect` 이벤트로 `connected` 데이터를 보낸다. +- timeout, completion, error가 발생하면 현재 emitter와 일치할 때만 맵에서 제거한다. +- 알림 전송 실패가 `IOException` 또는 `IllegalStateException`이면 emitter를 제거한다. + +## 기능이 실제로 어떻게 동작해야 하는가 + +### Push token 등록과 삭제 + +- token 등록은 먼저 활성 사용자를 조회한다. +- Expo token 형식이 아니면 `INVALID_NOTIFICATION_TOKEN`이다. +- 기존 token row가 있으면 새 token 값으로 갱신한다. +- 기존 token row가 없으면 새 `NotificationDeviceToken`을 저장한다. +- 삭제는 `userId + token`으로 찾은 row만 삭제한다. +- 삭제 대상이 없으면 예외를 던지지 않는다. + +### 단일 채팅 푸시 알림 + +- `sendChatNotification`은 비동기(`notificationTaskExecutor`)로 실행된다. +- 수신자가 해당 채팅방에 접속 중이면 푸시를 보내지 않는다. +- 해당 채팅방을 mute한 사용자에게는 푸시를 보내지 않는다. +- 수신자의 push token이 없으면 푸시를 보내지 않는다. +- 메시지 본문 preview는 Unicode code point 기준 최대 30자다. +- 30자를 넘으면 앞 30 code point 뒤에 `...`를 붙인다. +- 메시지가 null이면 빈 문자열 preview를 쓴다. +- push payload의 path는 `chats/{roomId}`다. +- 이 흐름에서 발생한 예외는 잡아서 로그로 남기고 호출 흐름으로 전파하지 않는다. + +### 그룹 채팅 푸시 알림 + +- `sendGroupChatNotification`도 비동기(`notificationTaskExecutor`)로 실행된다. +- 수신자 목록에서 발신자를 먼저 제외한다. +- 남은 수신자가 없으면 푸시를 보내지 않는다. +- 채팅방에 접속 중인 사용자와 mute 사용자를 제외한다. +- 최종 대상이 없으면 푸시를 보내지 않는다. +- 최종 대상 사용자들의 token을 조회하고, token별 Expo batch message를 만든다. +- title은 동아리 이름, body는 `senderName + ": " + preview` 형식이다. +- payload path는 `chats/{roomId}`다. +- batch message가 비어 있지 않을 때만 Expo batch 발송을 호출한다. +- 이 흐름에서 발생한 예외는 잡아서 로그로 남기고 호출 흐름으로 전파하지 않는다. + +### 동아리 지원 알림 + +- 동아리 지원 알림은 push만 보내는 것이 아니라 인앱 알림 저장, SSE, push를 함께 수행한다. +- 지원 제출 알림: + - type: `CLUB_APPLICATION_SUBMITTED` + - title: 동아리 이름 + - body: `{applicantName}님이 동아리 가입을 신청했어요.` + - path: `mypage/manager/{clubId}/applications/{applicationId}` +- 지원 승인 알림: + - type: `CLUB_APPLICATION_APPROVED` + - title: 동아리 이름 + - body: `동아리 지원이 승인되었어요.` + - path: `clubs/{clubId}` +- 지원 거절 알림: + - type: `CLUB_APPLICATION_REJECTED` + - title: 동아리 이름 + - body: `동아리 지원이 거절되었어요.` + - path: `clubs/{clubId}` +- 각 알림은 `NotificationInbox`를 저장한 뒤 저장된 값을 `NotificationInboxResponse`로 바꿔 SSE로 보낸다. +- push token이 없으면 인앱 알림과 SSE는 유지하고 push만 생략한다. + +### 동아리 이벤트 리스너 + +- `ClubApplicationNotificationListener`는 동아리 지원 이벤트를 `AFTER_COMMIT`에 처리한다. +- 원 트랜잭션이 rollback되면 알림 후속 작업도 실행되지 않는다. +- 승인/거절 이벤트는 단일 수신자에게 알림을 보낸다. +- 제출 이벤트는 `receiverIds`의 각 사용자에게 개별 알림을 보낸다. +- 이벤트 리스너를 바꿀 때는 동아리 도메인의 이벤트 발행 시점과 트랜잭션 경계를 함께 확인해야 한다. + +### 인앱 알림 목록과 읽음 처리 + +- 목록 조회는 page 기본값 1, page size 20이다. +- 정렬은 `createdAt DESC, id DESC`다. +- 목록 조회와 미읽음 개수, 전체 읽음 처리는 채팅 관련 타입을 제외한다. +- 제외되는 채팅 관련 타입은 `CHAT_MESSAGE`, `GROUP_CHAT_MESSAGE`, `UNREAD_CHAT_COUNT`다. +- 단건 읽음은 본인 알림만 가능하고, 다른 사용자의 알림 id는 찾지 못한 알림으로 처리된다. +- 전체 읽음은 채팅 관련 타입을 제외한 미읽음 알림만 읽음 처리한다. + +### SSE 구독과 전송 + +- SSE timeout은 30분이다. +- 구독 직후 `connect` 이벤트를 보낸다. +- 한 사용자에게는 최신 emitter 하나만 유지한다. +- 이전 emitter의 completion callback이 늦게 실행되더라도 새 emitter를 제거하면 안 된다. +- 알림 전송 시 emitter가 없으면 조용히 종료한다. +- 전송 중 emitter가 이미 완료되어 있거나 IO 오류가 나면 맵에서 제거한다. +- SSE 실패는 인앱 알림 저장이나 push 발송 정책과 분리해서 생각해야 한다. + +### Expo push client + +- Expo push endpoint는 `https://exp.host/--/api/v2/push/send`다. +- 단건 사용자 발송도 token 목록을 message 목록으로 변환해서 Expo API를 호출한다. +- batch 발송은 100개씩 나눠 보낸다. +- HTTP 상태가 2xx가 아니거나 응답 body/data가 없으면 실패로 본다. +- Expo ticket의 status가 `ok`가 아니면 해당 token 실패를 error 로그로 남긴다. +- `@Retryable(maxAttempts = 2)`로 재시도한다. +- HTTP 오류, 연결 오류, 비정상 응답, 기타 RestClient 오류는 recover 메서드에서 로그로 남긴다. + +## 절대 놓치면 안 되는 정책 + +- 사용자별 device token은 하나만 유지한다. 새 token 등록은 row 추가가 아니라 기존 row 갱신일 수 있다. +- token 형식 검증은 Expo token 문자열 형식 기준이다. +- 채팅 push는 사용자가 이미 채팅방에 있거나 mute한 경우 보내면 안 된다. +- 그룹 채팅 push는 발신자를 수신자에서 제외해야 한다. +- 채팅 preview는 Java 문자열 길이가 아니라 Unicode code point 기준 30자를 사용한다. +- 채팅 알림 실패는 메시지 저장이나 채팅 흐름을 실패시키면 안 된다. +- 동아리 지원 알림은 인앱 저장, SSE, push가 함께 움직인다. +- 동아리 지원 이벤트는 commit 이후에만 알림으로 이어져야 한다. +- 인앱 알림 목록/미읽음/전체 읽음은 채팅 관련 타입을 제외한다. +- SSE는 사용자당 최신 연결 하나만 유지한다. +- Expo push 실패 ticket은 전체 요청 성공 여부와 별도로 token별 실패 로그를 확인해야 한다. + +## 수정 시 함께 확인해야 하는 것 + +### Push token 정책을 바꿀 때 + +- `notification_device_token.user_id` unique 제약 +- Expo token 정규식 +- 기존 token 갱신과 신규 저장 분기 +- 삭제 요청의 userId/token 일치 기준 +- 탈퇴 사용자 token 조회 제외 여부 + +### 채팅 알림을 바꿀 때 + +- `ChatPresenceService` 접속 상태 필터 +- `NotificationMuteSetting`의 `CHAT_ROOM` mute 필터 +- 발신자 제외 정책 +- Unicode code point 기준 preview 길이 +- payload path (`chats/{roomId}`) +- 예외를 삼키고 로그로 남기는 비동기 경계 + +### 동아리 지원 알림을 바꿀 때 + +- 동아리 이벤트 발행 시점 +- `AFTER_COMMIT` 리스너 유지 여부 +- inbox type/title/body/path +- SSE 전송 대상과 응답 DTO +- push token이 없을 때 인앱 알림을 유지하는 정책 + +### 인앱 알림함을 바꿀 때 + +- 채팅 관련 타입 제외 집합 +- page size 20 +- `createdAt DESC, id DESC` 정렬 +- 단건 읽음의 userId 소유권 조건 +- 전체 읽음에서 채팅 관련 타입 제외 + +### SSE를 바꿀 때 + +- 사용자별 emitter 교체 정책 +- completion/timeout/error callback의 조건부 제거 +- 최초 connect 이벤트 +- send 실패 시 emitter 제거 +- 이미 완료된 emitter 처리 + +### Expo push client를 바꿀 때 + +- batch size 100 +- channel id `default_notifications` +- 2xx 상태와 body/data 검증 +- ticket별 실패 로그 +- retry/recover 메서드 시그니처 + +## 주요 클래스와 책임 + +### `NotificationService` + +- push token 등록/삭제, 채팅 push, 동아리 지원 알림 발송을 담당한다. +- presence, mute, token 조회, inbox 저장, SSE, Expo push가 만나는 중심 서비스다. + +### `NotificationInboxService` + +- 인앱 알림 저장, 목록 조회, 미읽음 개수, 읽음 처리를 담당한다. +- 채팅 관련 알림을 알림함 목록/카운트/전체 읽음에서 제외하는 정책이 있다. + +### `NotificationInboxSseService` + +- 사용자별 SSE emitter 생명주기를 관리한다. +- 같은 사용자 재구독, connect 이벤트, 실패 시 emitter 제거 정책을 바꾸면 이 클래스를 먼저 확인해야 한다. + +### `ExpoPushClient` + +- Expo push API 호출, batch 분할, retry/recover, ticket 실패 로그를 담당한다. +- 외부 API 실패를 비즈니스 알림 흐름과 어떻게 분리할지 판단하는 지점이다. + +### `ClubApplicationNotificationListener` + +- 동아리 지원 이벤트를 commit 이후 알림 발송으로 연결한다. +- 동아리 도메인의 이벤트 payload가 바뀌면 이 리스너와 알림 path/body를 같이 확인해야 한다. + +## 테스트 전략 + +이미 단위 테스트는 아래 정책을 일부 고정한다. + +- token 등록/갱신/삭제와 잘못된 Expo token 거부 +- `ExpoPushToken[...]`과 `ExponentPushToken[...]` 형식 허용 +- 채팅방 접속 중 사용자와 mute 사용자 push 제외 +- 그룹 채팅의 발신자, 접속 중, mute 사용자 필터 +- Unicode emoji를 포함한 preview 30 code point 처리 +- 동아리 지원 알림의 inbox, SSE, push 호출 +- push token이 없어도 동아리 지원 인앱 알림과 SSE를 유지하는 정책 +- SSE 재구독과 완료된 emitter 정리 +- 인앱 알림 save/saveAll/read 처리 + +이 도메인의 정책을 바꾸거나 가이드 claim을 강화한다면 추가로 아래 회귀 테스트를 보강하는 것이 좋다. + +- 동아리 지원 이벤트가 rollback되면 알림이 생성되지 않는 `AFTER_COMMIT` 통합 테스트 +- 인앱 알림 목록/미읽음/전체 읽음에서 채팅 관련 타입이 제외되는 repository 통합 테스트 +- Expo push ticket 일부 실패가 전체 예외로 전파되지 않고 로그만 남는 테스트 +- 그룹 채팅 token 수와 대상 사용자 수가 다를 때 현재 정책을 명확히 고정하는 테스트 + +검증할 때는 최소한 아래 테스트를 실행한다. + +```bash +CI=true ./gradlew test --tests 'gg.agit.konect.unit.domain.notification.*' --tests 'gg.agit.konect.integration.domain.notification.*' +``` diff --git a/src/test/java/gg/agit/konect/unit/domain/notification/service/NotificationServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/notification/service/NotificationServiceTest.java index da8a16510..7281419e1 100644 --- a/src/test/java/gg/agit/konect/unit/domain/notification/service/NotificationServiceTest.java +++ b/src/test/java/gg/agit/konect/unit/domain/notification/service/NotificationServiceTest.java @@ -118,6 +118,24 @@ void registerTokenRejectsInvalidExpoToken() { verify(notificationDeviceTokenRepository, never()).findByUserId(any()); } + @Test + @DisplayName("registerToken은 ExponentPushToken 형식도 허용한다") + void registerTokenAcceptsExponentPushTokenFormat() { + // given + User user = createUser(1, "2021136001"); + String exponentToken = "ExponentPushToken[valid-token]"; + given(userRepository.getById(1)).willReturn(user); + given(notificationDeviceTokenRepository.findByUserId(1)).willReturn(Optional.empty()); + + // when + notificationService.registerToken(1, new NotificationTokenRegisterRequest(exponentToken)); + + // then + verify(notificationDeviceTokenRepository).save(argThat(token -> + token.getUser().equals(user) && token.getToken().equals(exponentToken) + )); + } + @Test @DisplayName("deleteToken은 일치하는 토큰이 있을 때만 삭제한다") void deleteTokenDeletesOnlyMatchingToken() { @@ -295,6 +313,39 @@ void sendClubApplicationApprovedNotificationSendsInboxSseAndPush() { ); } + @Test + @DisplayName("지원 제출 알림은 푸시 토큰이 없어도 인앱 알림과 SSE를 유지한다") + void sendClubApplicationSubmittedNotificationKeepsInboxAndSseWhenPushTokenMissing() { + assertClubApplicationNotificationKeepsInboxAndSseWithoutPush( + NotificationInboxType.CLUB_APPLICATION_SUBMITTED, + "홍길동님이 동아리 가입을 신청했어요.", + "mypage/manager/7/applications/1", + () -> notificationService.sendClubApplicationSubmittedNotification(3, 1, 7, "KONECT", "홍길동") + ); + } + + @Test + @DisplayName("지원 승인 알림은 푸시 토큰이 없어도 인앱 알림과 SSE를 유지한다") + void sendClubApplicationApprovedNotificationKeepsInboxAndSseWhenPushTokenMissing() { + assertClubApplicationNotificationKeepsInboxAndSseWithoutPush( + NotificationInboxType.CLUB_APPLICATION_APPROVED, + "동아리 지원이 승인되었어요.", + "clubs/7", + () -> notificationService.sendClubApplicationApprovedNotification(3, 7, "KONECT") + ); + } + + @Test + @DisplayName("지원 거절 알림은 푸시 토큰이 없어도 인앱 알림과 SSE를 유지한다") + void sendClubApplicationRejectedNotificationKeepsInboxAndSseWhenPushTokenMissing() { + assertClubApplicationNotificationKeepsInboxAndSseWithoutPush( + NotificationInboxType.CLUB_APPLICATION_REJECTED, + "동아리 지원이 거절되었어요.", + "clubs/7", + () -> notificationService.sendClubApplicationRejectedNotification(3, 7, "KONECT") + ); + } + @Test @DisplayName("registerToken은 null 토큰 값에 대해 NullPointerException을 발생시킨다") void registerTokenThrowsExceptionForNullToken() { @@ -777,4 +828,29 @@ private User createUser(Integer id, String studentNumber) { .imageUrl("https://example.com/profile-" + id + ".png") .build(); } + + private void assertClubApplicationNotificationKeepsInboxAndSseWithoutPush( + NotificationInboxType type, + String body, + String path, + Runnable notificationSender + ) { + User user = createUser(3, "2021136003"); + NotificationInbox inbox = NotificationInbox.of(user, type, "KONECT", body, path); + given(notificationInboxService.save(3, type, "KONECT", body, path)).willReturn(inbox); + given(notificationDeviceTokenRepository.findTokensByUserId(3)).willReturn(List.of()); + + notificationSender.run(); + + verify(notificationInboxService).save(3, type, "KONECT", body, path); + ArgumentCaptor responseCaptor = + ArgumentCaptor.forClass(NotificationInboxResponse.class); + verify(notificationInboxService).sendSse(eq(3), responseCaptor.capture()); + NotificationInboxResponse response = responseCaptor.getValue(); + assertThat(response.type()).isEqualTo(type); + assertThat(response.title()).isEqualTo("KONECT"); + assertThat(response.body()).isEqualTo(body); + assertThat(response.path()).isEqualTo(path); + verify(expoPushClient, never()).sendNotification(any(), any(), any(), any(), any()); + } } From 65ae8f713f013a87a64da43ad58a9d0f4f56f820 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Sat, 25 Apr 2026 16:50:50 +0900 Subject: [PATCH 20/50] =?UTF-8?q?docs:=20=EA=B3=B5=EC=A7=80=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EC=A0=95=EC=B1=85=20=EA=B0=80=EC=9D=B4?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80=20(#572)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 공지 목록, 상세 조회, 읽음 이력, 생성/수정/삭제 정책을 코드 기준으로 정리 - notice 작업 시 대학별 공지 격리와 read history 중복 방지, unread count 연계를 함께 확인하도록 기준화 - 문서 claim 중 목록 조회가 읽음 이력을 만들지 않는 정책을 통합 테스트로 고정 --- .../gg/agit/konect/domain/notice/AGENTS.md | 211 ++++++++++++++++++ .../domain/notice/NoticeApiTest.java | 41 +++- 2 files changed, 242 insertions(+), 10 deletions(-) create mode 100644 src/main/java/gg/agit/konect/domain/notice/AGENTS.md diff --git a/src/main/java/gg/agit/konect/domain/notice/AGENTS.md b/src/main/java/gg/agit/konect/domain/notice/AGENTS.md new file mode 100644 index 000000000..5ffee0a1c --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/notice/AGENTS.md @@ -0,0 +1,211 @@ +# 공지 도메인 가이드 + +## 이 도메인은 무엇을 하는가 + +공지 도메인은 총동아리연합회 공지사항의 목록/상세 조회, 생성/수정/삭제, +사용자별 읽음 이력을 관리하는 도메인이다. + +이 도메인에서 중요한 것은 단순 게시글 CRUD가 아니라 +아래 상태가 같은 정책을 바라보는 것이다. + +- 총동아리연합회 공지 (`CouncilNotice`) +- 사용자별 공지 읽음 이력 (`CouncilNoticeReadHistory`) +- 사용자의 대학과 해당 대학의 총동아리연합회 (`User.university`, `Council`) +- 마이페이지의 읽지 않은 공지 수 (`UserInfoResponse.unreadCouncilNoticeCount`) + +공지 관련 작업을 할 때는 항상 "이 변경이 대학별 공지 격리, 상세 조회 시 읽음 처리, +중복 읽음 이력 방지, 사용자 정보의 unread count까지 같이 맞는가"를 먼저 확인해야 한다. + +## 주요 상태 + +### `CouncilNotice` + +- 총동아리연합회 공지사항이다. +- `title`과 `content`는 null일 수 없다. +- `title`은 요청 DTO 기준 최대 255자다. +- `content`는 `TEXT` 컬럼이다. +- 각 공지는 하나의 `Council`에 속한다. +- DB 마이그레이션 기준으로 `council_notice.council_id`는 `council.id`를 참조한다. + +### `CouncilNoticeReadHistory` + +- 사용자별 공지 읽음 기록이다. +- `(user_id, council_notice_id)` unique 제약을 가진다. +- 같은 사용자가 같은 공지를 여러 번 조회해도 읽음 이력은 하나만 유지해야 한다. +- 읽음 여부는 notice row의 상태가 아니라 read history 존재 여부로 판단한다. + +### `Council` + +- 목록 조회는 로그인 사용자의 대학으로 `Council`을 찾은 뒤, 그 council의 공지만 조회한다. +- 현재 생성 API는 로그인 사용자 대학이 아니라 `councilRepository.getById(1)`로 + council id 1을 고정 사용한다. +- 생성 정책을 바꿀 때는 기존 테스트의 `insertCouncilWithIdOne` 전제와 + 운영 데이터의 기본 council 전제를 함께 확인해야 한다. + +## 기능이 실제로 어떻게 동작해야 하는가 + +### 공지 목록 조회 + +- endpoint는 `GET /councils/notices`다. +- `page` 기본값은 1이고, 1 이상이어야 한다. +- `limit` 기본값은 10이고, 1 이상이어야 한다. +- 서비스는 `PageRequest.of(page - 1, limit, createdAt DESC)`로 조회한다. +- 로그인 사용자의 대학으로 council을 찾고, 해당 council id의 공지만 조회한다. +- 다른 대학 council의 공지는 목록에 포함되면 안 된다. +- 목록 응답의 `isRead`는 현재 페이지에 포함된 공지 id 중 read history가 있는지로 계산한다. +- 목록 응답은 `totalCount`, `currentCount`, `totalPage`, `currentPage`, `councilNotices`를 포함한다. +- 목록의 공지 날짜는 `createdAt.toLocalDate()`이며 JSON 형식은 `yyyy.MM.dd`다. + +### 공지 상세 조회와 읽음 처리 + +- endpoint는 `GET /councils/notices/{id}`다. +- 공지가 없으면 `NOT_FOUND_COUNCIL_NOTICE`다. +- 공지의 council university와 로그인 사용자 university가 다르면 `FORBIDDEN_COUNCIL_NOTICE_ACCESS`다. +- 상세 조회는 read transaction이 아니라 쓰기 transaction이다. +- 상세 조회는 읽음 이력을 만들 수 있기 때문이다. +- 같은 대학 공지를 상세 조회하면 read history가 없을 때만 `CouncilNoticeReadHistory`를 저장한다. +- 이미 read history가 있으면 새로 저장하지 않는다. +- 상세 응답은 `id`, `title`, `content`, `createdAt`, `updatedAt`를 반환한다. +- 상세 응답의 날짜/시간 JSON 형식은 `yyyy.MM.dd HH:mm:ss`다. + +### 공지 생성 + +- endpoint는 `POST /councils/notices`다. +- 요청의 `title`과 `content`는 blank일 수 없다. +- `title`은 최대 255자다. +- 현재 서비스는 `councilRepository.getById(1)`로 가져온 council에 공지를 생성한다. +- council id 1이 없으면 `NOT_FOUND_COUNCIL`이다. +- 생성 성공 응답은 200 OK이고 body는 없다. +- 생성 흐름을 로그인 사용자 대학 기준으로 바꾸려면 + 기존 id 1 전제와 API 권한 정책을 함께 재정의해야 한다. + +### 공지 수정 + +- endpoint는 `PUT /councils/notices/{id}`다. +- 공지가 없으면 `NOT_FOUND_COUNCIL_NOTICE`다. +- 요청의 `title`과 `content`는 blank일 수 없다. +- `title`은 최대 255자다. +- 수정은 기존 엔티티의 `title`, `content`만 교체한다. +- 공지의 council, 생성 시각, 읽음 이력은 수정하지 않는다. +- 수정 성공 응답은 200 OK이고 body는 없다. + +### 공지 삭제 + +- endpoint는 `DELETE /councils/notices/{id}`다. +- 공지가 없으면 `NOT_FOUND_COUNCIL_NOTICE`다. +- 삭제는 `deleteById`로 공지 row를 삭제한다. +- 삭제 성공 응답은 204 No Content다. + +### 읽지 않은 공지 수 + +- 사용자 정보 조회는 `CouncilNoticeReadRepository.countUnreadNoticesByUserId(userId)`를 사용한다. +- 이 쿼리는 read history가 없는 `CouncilNotice` 수를 센다. +- 현재 쿼리는 사용자의 대학 council로 범위를 제한하지 않는다. +- unread count 정책을 대학별로 바꾸려면 `UserService.getUserInfo`, repository query, + notice 목록/상세 테스트를 함께 확인해야 한다. + +## 절대 놓치면 안 되는 정책 + +- 공지 목록은 로그인 사용자 대학의 council 기준으로 격리되어야 한다. +- 공지 상세 조회는 다른 대학 공지를 읽을 수 없어야 한다. +- 다른 대학 공지를 상세 조회할 때 read history를 만들면 안 된다. +- read history는 `(userId, councilNoticeId)` 기준으로 중복 저장되면 안 된다. +- 목록의 `isRead`는 현재 페이지의 공지 id와 사용자별 read history를 매칭해서 만든다. +- 상세 조회만 읽음 처리한다. 목록 조회 자체는 읽음 이력을 만들지 않는다. +- 생성 API는 현재 council id 1 전제를 가진다. +- unread count는 현재 구현상 전체 `CouncilNotice` 기준이라는 점을 알고 수정해야 한다. + +## 수정 시 함께 확인해야 하는 것 + +### 목록 조회를 바꿀 때 + +- 로그인 사용자의 대학 조회 +- `CouncilRepository.getByUniversity` +- `CouncilNoticeRepository.findByCouncilId` +- `createdAt DESC` 정렬 +- page가 1-based로 들어와 `page - 1`로 변환되는 지점 +- 현재 페이지 공지 id만 read history 조회에 쓰는지 + +### 상세 조회와 읽음 처리를 바꿀 때 + +- `CouncilNoticeRepository.getById` +- 공지 council university와 사용자 university 비교 +- `FORBIDDEN_COUNCIL_NOTICE_ACCESS` +- `existsByUserIdAndCouncilNoticeId` 선검사 +- `(user_id, council_notice_id)` unique 제약 +- 같은 공지 반복 조회 시 read history 중복 저장 방지 + +### 생성/수정/삭제를 바꿀 때 + +- 요청 DTO의 `@NotBlank`, `@Size(max = 255)` +- 현재 생성 경로의 council id 1 고정 전제 +- 수정 시 `title`, `content`만 바꾸는 정책 +- 삭제 후 읽음 이력을 함께 정리해야 하는지에 대한 DB/JPA 정책 +- API 응답 status (생성/수정 200, 삭제 204) + +### 유저 도메인과 함께 바꿀 때 + +- `UserService.getUserInfo`의 `unreadCouncilNoticeCount` +- `countUnreadNoticesByUserId` 쿼리의 대학 범위 여부 +- 사용자 대학 변경 가능성이 생길 경우 read history와 unread count의 의미 +- 유저 삭제 시 read history cascade 삭제 + +## 주요 클래스와 책임 + +### `NoticeService` + +- 공지 목록/상세/생성/수정/삭제 정책이 모여 있는 중심 서비스다. +- 목록에서는 사용자 대학의 council 공지만 조회한다. +- 상세에서는 다른 대학 접근을 막고 읽음 이력을 생성한다. + +### `CouncilNoticeRepository` + +- 공지 조회와 저장/삭제를 담당한다. +- `getById`는 공지가 없으면 `NOT_FOUND_COUNCIL_NOTICE`를 던진다. + +### `CouncilNoticeReadRepository` + +- 읽음 이력 존재 여부, 현재 페이지 공지의 읽음 이력 목록, unread count를 담당한다. +- `countUnreadNoticesByUserId`는 마이페이지 unread count와 연결된다. + +### `CouncilNoticeResponse` + +- 공지 상세 응답 DTO다. +- 날짜/시간은 `yyyy.MM.dd HH:mm:ss` 형식으로 내려간다. + +### `CouncilNoticesResponse` + +- 공지 목록 응답 DTO다. +- 페이지 메타데이터와 목록 아이템의 `isRead`를 함께 내려준다. +- 목록 아이템 날짜는 `yyyy.MM.dd` 형식이다. + +## 테스트 전략 + +이미 통합 테스트는 아래 정책을 일부 고정한다. + +- 공지 목록 조회와 `isRead` 반영 +- 공지 목록 조회가 read history를 만들지 않는 정책 +- page가 1 미만이면 400 응답 +- 다른 대학 공지 목록 제외 +- 다른 대학 공지 상세 조회 403 +- 같은 공지 반복 상세 조회 시 read history 중복 방지 +- 사용자별 read history 격리 +- 공지 생성, 수정, 삭제 +- 생성 시 council id 1이 없으면 404 +- 생성 요청 title blank 검증 +- 수정/삭제 대상 공지가 없으면 404 + +이 도메인의 정책을 바꾸거나 가이드 claim을 강화한다면 +추가로 아래 회귀 테스트를 보강하는 것이 좋다. + +- `limit`이 1 미만이면 400을 반환하는 테스트 +- 생성/수정 요청의 title 255자 초과 검증 테스트 +- 공지 삭제 시 read history 정리 정책을 명확히 고정하는 테스트 +- `unreadCouncilNoticeCount`가 현재 전체 공지 기준인지, + 대학별 공지 기준인지 명확히 고정하는 테스트 + +검증할 때는 최소한 아래 테스트를 실행한다. + +```bash +CI=true ./gradlew test --tests 'gg.agit.konect.integration.domain.notice.*' +``` diff --git a/src/test/java/gg/agit/konect/integration/domain/notice/NoticeApiTest.java b/src/test/java/gg/agit/konect/integration/domain/notice/NoticeApiTest.java index e2edd977b..511bc92c3 100644 --- a/src/test/java/gg/agit/konect/integration/domain/notice/NoticeApiTest.java +++ b/src/test/java/gg/agit/konect/integration/domain/notice/NoticeApiTest.java @@ -1,5 +1,6 @@ package gg.agit.konect.integration.domain.notice; +import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.hasSize; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -91,6 +92,23 @@ void getNoticesExcludesOtherUniversityNotices() throws Exception { .andExpect(jsonPath("$.councilNotices", hasSize(1))) .andExpect(jsonPath("$.councilNotices[0].title").value("우리 대학 공지")); } + + @Test + @DisplayName("공지 목록 조회는 읽음 이력을 생성하지 않는다") + void getNoticesDoesNotCreateReadHistory() throws Exception { + // given + Council council = persist(CouncilFixture.create(university)); + CouncilNotice notice = persist(CouncilNoticeFixture.create(council)); + clearPersistenceContext(); + + // when + performGet(NOTICES_ENDPOINT + "?page=1&limit=10") + .andExpect(status().isOk()); + + // then + Number readHistoryCount = countReadHistory(user.getId(), notice.getId()); + assertThat(readHistoryCount.longValue()).isZero(); + } } @Nested @@ -127,16 +145,8 @@ void getNoticeDoesNotDuplicateReadHistory() throws Exception { .andExpect(status().isOk()); // then - Number readHistoryCount = (Number)entityManager.createNativeQuery(""" - select count(*) - from council_notice_read_history - where user_id = ? and council_notice_id = ? - """) - .setParameter(1, user.getId()) - .setParameter(2, notice.getId()) - .getSingleResult(); - - org.assertj.core.api.Assertions.assertThat(readHistoryCount.longValue()).isEqualTo(1L); + Number readHistoryCount = countReadHistory(user.getId(), notice.getId()); + assertThat(readHistoryCount.longValue()).isEqualTo(1L); } @Test @@ -292,4 +302,15 @@ insert into council ( .setParameter(11, universityId) .executeUpdate(); } + + private Number countReadHistory(Integer userId, Integer noticeId) { + return (Number)entityManager.createNativeQuery(""" + select count(*) + from council_notice_read_history + where user_id = ? and council_notice_id = ? + """) + .setParameter(1, userId) + .setParameter(2, noticeId) + .getSingleResult(); + } } From bc98f3aa443cb0bed950e9f7ba3bafa46f7a0697 Mon Sep 17 00:00:00 2001 From: JanooGwan <103417427+JanooGwan@users.noreply.github.com> Date: Sun, 26 Apr 2026 20:26:37 +0900 Subject: [PATCH 21/50] =?UTF-8?q?refactor:=20=EC=8A=A4=EC=BC=80=EC=A4=84?= =?UTF-8?q?=EB=9F=AC=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 스케줄러 정리 작업 통합 * fix: OAuth 계정 정리 경계 조건 보정 * test: OAuth 정리 작업 실행 순서 검증 * refactor: 스터디 랭킹 리셋 진입점 단일화 --- .../StudyTimeRankingRepository.java | 16 ++++ .../scheduler/StudyTimeScheduler.java | 24 ++--- .../service/StudyTimeSchedulerService.java | 17 ++-- .../UserOAuthAccountRepository.java | 11 ++- .../UserOAuthAccountCleanupScheduler.java | 28 ------ .../domain/user/scheduler/UserScheduler.java | 17 ++-- .../user/service/UserOAuthAccountService.java | 5 +- .../user/service/UserSchedulerService.java | 33 +++---- .../StudyTimeSchedulerServiceTest.java | 48 ++++------ .../service/UserOAuthAccountServiceTest.java | 11 ++- .../service/UserSchedulerServiceTest.java | 93 +++++++++++++++++++ 11 files changed, 188 insertions(+), 115 deletions(-) delete mode 100644 src/main/java/gg/agit/konect/domain/user/scheduler/UserOAuthAccountCleanupScheduler.java create mode 100644 src/test/java/gg/agit/konect/unit/domain/user/service/UserSchedulerServiceTest.java diff --git a/src/main/java/gg/agit/konect/domain/studytime/repository/StudyTimeRankingRepository.java b/src/main/java/gg/agit/konect/domain/studytime/repository/StudyTimeRankingRepository.java index 03eb32a3f..1eb752a0b 100644 --- a/src/main/java/gg/agit/konect/domain/studytime/repository/StudyTimeRankingRepository.java +++ b/src/main/java/gg/agit/konect/domain/studytime/repository/StudyTimeRankingRepository.java @@ -5,6 +5,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.Repository; import org.springframework.data.repository.query.Param; @@ -132,4 +133,19 @@ Integer findMaxTargetId( List findAll(); void save(StudyTimeRanking studyTimeRanking); + + @Modifying + @Query(""" + UPDATE StudyTimeRanking r + SET r.dailySeconds = 0 + """) + int resetDailySeconds(); + + @Modifying + @Query(""" + UPDATE StudyTimeRanking r + SET r.dailySeconds = 0, + r.monthlySeconds = 0 + """) + int resetDailyAndMonthlySeconds(); } diff --git a/src/main/java/gg/agit/konect/domain/studytime/scheduler/StudyTimeScheduler.java b/src/main/java/gg/agit/konect/domain/studytime/scheduler/StudyTimeScheduler.java index b2a4e18c2..7ff1e43c6 100644 --- a/src/main/java/gg/agit/konect/domain/studytime/scheduler/StudyTimeScheduler.java +++ b/src/main/java/gg/agit/konect/domain/studytime/scheduler/StudyTimeScheduler.java @@ -1,5 +1,7 @@ package gg.agit.konect.domain.studytime.scheduler; +import java.time.LocalDate; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.scheduling.annotation.Scheduled; @@ -17,24 +19,14 @@ public class StudyTimeScheduler { private final StudyTimeSchedulerService studyTimeSchedulerService; @Scheduled(cron = "0 0 0 * * *") - public void resetStudyTimeRankingDaily() { - try { - SCHEDULER_LOGGER.info("일일 공부 시간 랭킹 초기화 시작"); - studyTimeSchedulerService.resetStudyTimeRankingDaily(); - SCHEDULER_LOGGER.info("일일 공부 시간 랭킹 초기화 완료"); - } catch (Exception e) { - SCHEDULER_LOGGER.error("일일 공부 시간 랭킹 초기화 과정에서 오류가 발생했습니다.", e); - } - } - - @Scheduled(cron = "0 0 0 1 * *") - public void resetStudyTimeRankingMonthly() { + public void resetStudyTimeRanking() { try { - SCHEDULER_LOGGER.info("월간 공부 시간 랭킹 초기화 시작"); - studyTimeSchedulerService.resetStudyTimeRankingMonthly(); - SCHEDULER_LOGGER.info("월간 공부 시간 랭킹 초기화 완료"); + LocalDate today = LocalDate.now(); + SCHEDULER_LOGGER.info("스터디 시간 랭킹 초기화를 시작합니다. targetDate={}", today); + int updatedCount = studyTimeSchedulerService.resetStudyTimeRanking(today); + SCHEDULER_LOGGER.info("스터디 시간 랭킹 초기화를 완료했습니다. targetDate={}, updatedCount={}", today, updatedCount); } catch (Exception e) { - SCHEDULER_LOGGER.error("월간 공부 시간 랭킹 초기화 과정에서 오류가 발생했습니다.", e); + SCHEDULER_LOGGER.error("스터디 시간 랭킹 초기화 중 오류가 발생했습니다.", e); } } } diff --git a/src/main/java/gg/agit/konect/domain/studytime/service/StudyTimeSchedulerService.java b/src/main/java/gg/agit/konect/domain/studytime/service/StudyTimeSchedulerService.java index b8c8f9288..49f92bd83 100644 --- a/src/main/java/gg/agit/konect/domain/studytime/service/StudyTimeSchedulerService.java +++ b/src/main/java/gg/agit/konect/domain/studytime/service/StudyTimeSchedulerService.java @@ -1,11 +1,10 @@ package gg.agit.konect.domain.studytime.service; -import java.util.List; +import java.time.LocalDate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import gg.agit.konect.domain.studytime.model.StudyTimeRanking; import gg.agit.konect.domain.studytime.repository.StudyTimeRankingRepository; import lombok.RequiredArgsConstructor; @@ -17,14 +16,10 @@ public class StudyTimeSchedulerService { private final StudyTimeRankingRepository studyTimeRankingRepository; @Transactional - public void resetStudyTimeRankingDaily() { - List studyTimeRankings = studyTimeRankingRepository.findAll(); - studyTimeRankings.forEach(ranking -> ranking.updateSeconds(0L, ranking.getMonthlySeconds())); - } - - @Transactional - public void resetStudyTimeRankingMonthly() { - List studyTimeRankings = studyTimeRankingRepository.findAll(); - studyTimeRankings.forEach(ranking -> ranking.updateSeconds(ranking.getDailySeconds(), 0L)); + public int resetStudyTimeRanking(LocalDate targetDate) { + if (targetDate.getDayOfMonth() == 1) { + return studyTimeRankingRepository.resetDailyAndMonthlySeconds(); + } + return studyTimeRankingRepository.resetDailySeconds(); } } diff --git a/src/main/java/gg/agit/konect/domain/user/repository/UserOAuthAccountRepository.java b/src/main/java/gg/agit/konect/domain/user/repository/UserOAuthAccountRepository.java index d244d6095..e6e2acf23 100644 --- a/src/main/java/gg/agit/konect/domain/user/repository/UserOAuthAccountRepository.java +++ b/src/main/java/gg/agit/konect/domain/user/repository/UserOAuthAccountRepository.java @@ -101,8 +101,15 @@ Optional findByUserIdAndProvider( FROM UserOAuthAccount uoa WHERE uoa.user.deletedAt IS NOT NULL AND uoa.user.deletedAt <= :expiredAt + AND ( + uoa.provider <> :appleProvider + OR uoa.appleRefreshToken IS NULL + ) """) - int deleteAllByWithdrawnUsersBefore(@Param("expiredAt") LocalDateTime expiredAt); + int deleteRevokedExpiredWithdrawnOAuthAccountsBefore( + @Param("expiredAt") LocalDateTime expiredAt, + @Param("appleProvider") Provider appleProvider + ); @Query(""" SELECT (COUNT(uoa) > 0) @@ -122,7 +129,7 @@ boolean existsByProviderAndProviderId( JOIN FETCH uoa.user user WHERE uoa.provider = :provider AND user.deletedAt IS NOT NULL - AND user.deletedAt < :threshold + AND user.deletedAt <= :threshold AND uoa.appleRefreshToken IS NOT NULL """) List findAppleAccountsToRevoke( diff --git a/src/main/java/gg/agit/konect/domain/user/scheduler/UserOAuthAccountCleanupScheduler.java b/src/main/java/gg/agit/konect/domain/user/scheduler/UserOAuthAccountCleanupScheduler.java deleted file mode 100644 index 8e3d3f5a6..000000000 --- a/src/main/java/gg/agit/konect/domain/user/scheduler/UserOAuthAccountCleanupScheduler.java +++ /dev/null @@ -1,28 +0,0 @@ -package gg.agit.konect.domain.user.scheduler; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; - -import gg.agit.konect.domain.user.service.UserOAuthAccountService; -import lombok.RequiredArgsConstructor; - -@Component -@RequiredArgsConstructor -public class UserOAuthAccountCleanupScheduler { - - private static final Logger SCHEDULER_LOGGER = LoggerFactory.getLogger("scheduler.user-oauth-account"); - - private final UserOAuthAccountService userOAuthAccountService; - - @Scheduled(cron = "0 10 0 * * *") - public void cleanupExpiredWithdrawnUserOAuthAccounts() { - try { - int deletedCount = userOAuthAccountService.cleanupExpiredWithdrawnUserOAuthAccounts(); - SCHEDULER_LOGGER.info("탈퇴 유예기간 경과 OAuth 계정 정리 완료. deletedCount={}", deletedCount); - } catch (Exception e) { - SCHEDULER_LOGGER.error("탈퇴 유예기간 경과 OAuth 계정 정리 중 오류가 발생했습니다.", e); - } - } -} diff --git a/src/main/java/gg/agit/konect/domain/user/scheduler/UserScheduler.java b/src/main/java/gg/agit/konect/domain/user/scheduler/UserScheduler.java index 328123c03..7a97b8c87 100644 --- a/src/main/java/gg/agit/konect/domain/user/scheduler/UserScheduler.java +++ b/src/main/java/gg/agit/konect/domain/user/scheduler/UserScheduler.java @@ -14,19 +14,14 @@ public class UserScheduler { private final UserSchedulerService userSchedulerService; - /** - * 매일 자정(서버 기본 시간대 기준 00:00)에 실행되어 7일 경과한 Apple 사용자 토큰을 revoke합니다. - * cron 표현식: 초 분 시 일 월 요일 - * 0 0 0 * * *: 매일 00:00:00 실행 - */ - @Scheduled(cron = "0 0 0 * * *") - public void revokeAppleTokensAfterRestoreWindow() { + @Scheduled(cron = "0 10 0 * * *") + public void cleanupExpiredWithdrawnOAuthAccountsAfterRestoreWindow() { try { - log.info("Starting Apple token revocation task for users withdrawn more than 7 days ago"); - userSchedulerService.revokeAppleTokensAfterRestoreWindow(); - log.info("Successfully completed Apple token revocation task"); + log.info("Starting expired withdrawn OAuth cleanup task"); + userSchedulerService.cleanupExpiredWithdrawnOAuthAccountsAfterRestoreWindow(); + log.info("Successfully completed expired withdrawn OAuth cleanup task"); } catch (Exception e) { - log.error("Failed to revoke Apple tokens for withdrawn users", e); + log.error("Failed to cleanup expired withdrawn OAuth accounts", e); } } } diff --git a/src/main/java/gg/agit/konect/domain/user/service/UserOAuthAccountService.java b/src/main/java/gg/agit/konect/domain/user/service/UserOAuthAccountService.java index df2d1ef3b..e8098a6ae 100644 --- a/src/main/java/gg/agit/konect/domain/user/service/UserOAuthAccountService.java +++ b/src/main/java/gg/agit/konect/domain/user/service/UserOAuthAccountService.java @@ -82,7 +82,10 @@ public int cleanupExpiredWithdrawnUserOAuthAccounts() { @Transactional public int cleanupExpiredWithdrawnUserOAuthAccounts(LocalDateTime now) { LocalDateTime expiredAt = now.minusDays(RESTORE_WINDOW_DAYS); - int deletedCount = userOAuthAccountRepository.deleteAllByWithdrawnUsersBefore(expiredAt); + int deletedCount = userOAuthAccountRepository.deleteRevokedExpiredWithdrawnOAuthAccountsBefore( + expiredAt, + Provider.APPLE + ); userOAuthAccountRepository.flush(); return deletedCount; } diff --git a/src/main/java/gg/agit/konect/domain/user/service/UserSchedulerService.java b/src/main/java/gg/agit/konect/domain/user/service/UserSchedulerService.java index 78a7a6e82..cd399c855 100644 --- a/src/main/java/gg/agit/konect/domain/user/service/UserSchedulerService.java +++ b/src/main/java/gg/agit/konect/domain/user/service/UserSchedulerService.java @@ -5,8 +5,8 @@ import org.springframework.stereotype.Service; -import gg.agit.konect.domain.user.model.UserOAuthAccount; import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.domain.user.model.UserOAuthAccount; import gg.agit.konect.infrastructure.oauth.AppleTokenRevocationService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -16,24 +16,19 @@ @RequiredArgsConstructor public class UserSchedulerService { - private static final int REVOKE_AFTER_DAYS = 7; + private static final int RESTORE_WINDOW_DAYS = 7; private final UserSchedulerTxService userSchedulerTxService; + private final UserOAuthAccountService userOAuthAccountService; private final AppleTokenRevocationService appleTokenRevocationService; - /** - * 7일 이상 경과한 Apple 사용자의 토큰을 revoke합니다. - * - 7일 복구 정책: 탈퇴 후 7일 이내 복구 가능하므로 즉시 revoke하지 않음 - * - 7일 경과 후: 복구 불가 시점이므로 Apple 토큰 영구 폐기 - */ - public void revokeAppleTokensAfterRestoreWindow() { - LocalDateTime threshold = LocalDateTime.now().minusDays(REVOKE_AFTER_DAYS); - List accountsToRevoke = userSchedulerTxService.findAccountsToRevoke(threshold); + public void cleanupExpiredWithdrawnOAuthAccountsAfterRestoreWindow() { + cleanupExpiredWithdrawnOAuthAccountsAfterRestoreWindow(LocalDateTime.now()); + } - if (accountsToRevoke.isEmpty()) { - log.info("No Apple users to revoke (threshold={})", threshold); - return; - } + public void cleanupExpiredWithdrawnOAuthAccountsAfterRestoreWindow(LocalDateTime now) { + LocalDateTime threshold = now.minusDays(RESTORE_WINDOW_DAYS); + List accountsToRevoke = userSchedulerTxService.findAccountsToRevoke(threshold); int successCount = 0; int failureCount = 0; @@ -52,9 +47,15 @@ public void revokeAppleTokensAfterRestoreWindow() { } } + int deletedCount = userOAuthAccountService.cleanupExpiredWithdrawnUserOAuthAccounts(now); + log.info( - "Apple token revoke task finished: total={}, success={}, failure={}" - , accountsToRevoke.size(), successCount, failureCount + "Expired withdrawn OAuth cleanup task finished: revokeTotal={}, revokeSuccess={}, revokeFailure={}, " + + "deleted={}", + accountsToRevoke.size(), + successCount, + failureCount, + deletedCount ); } } diff --git a/src/test/java/gg/agit/konect/unit/domain/studytime/service/StudyTimeSchedulerServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/studytime/service/StudyTimeSchedulerServiceTest.java index 63a7c9996..6e97c00c7 100644 --- a/src/test/java/gg/agit/konect/unit/domain/studytime/service/StudyTimeSchedulerServiceTest.java +++ b/src/test/java/gg/agit/konect/unit/domain/studytime/service/StudyTimeSchedulerServiceTest.java @@ -1,23 +1,20 @@ package gg.agit.konect.unit.domain.studytime.service; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; -import java.util.List; +import java.time.LocalDate; 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.studytime.model.RankingType; -import gg.agit.konect.domain.studytime.model.StudyTimeRanking; import gg.agit.konect.domain.studytime.repository.StudyTimeRankingRepository; import gg.agit.konect.domain.studytime.service.StudyTimeSchedulerService; -import gg.agit.konect.domain.university.model.University; import gg.agit.konect.support.ServiceTestSupport; -import gg.agit.konect.support.fixture.RankingTypeFixture; -import gg.agit.konect.support.fixture.UniversityFixture; class StudyTimeSchedulerServiceTest extends ServiceTestSupport { @@ -28,39 +25,34 @@ class StudyTimeSchedulerServiceTest extends ServiceTestSupport { private StudyTimeSchedulerService studyTimeSchedulerService; @Test - @DisplayName("일간 랭킹 초기화는 dailySeconds만 0으로 만들고 monthlySeconds는 유지한다") - void resetStudyTimeRankingDailyKeepsMonthlySeconds() { + @DisplayName("월초에는 일간과 월간 랭킹을 한 번에 초기화한다") + void resetStudyTimeRankingResetsDailyAndMonthlyOnFirstDayOfMonth() { // given - StudyTimeRanking ranking = createRanking(120L, 3600L); - given(studyTimeRankingRepository.findAll()).willReturn(List.of(ranking)); + LocalDate firstDayOfMonth = LocalDate.of(2026, 5, 1); + when(studyTimeRankingRepository.resetDailyAndMonthlySeconds()).thenReturn(10); // when - studyTimeSchedulerService.resetStudyTimeRankingDaily(); + int updatedCount = studyTimeSchedulerService.resetStudyTimeRanking(firstDayOfMonth); // then - assertThat(ranking.getDailySeconds()).isZero(); - assertThat(ranking.getMonthlySeconds()).isEqualTo(3600L); + assertThat(updatedCount).isEqualTo(10); + verify(studyTimeRankingRepository).resetDailyAndMonthlySeconds(); + verify(studyTimeRankingRepository, never()).resetDailySeconds(); } @Test - @DisplayName("월간 랭킹 초기화는 monthlySeconds만 0으로 만들고 dailySeconds는 유지한다") - void resetStudyTimeRankingMonthlyKeepsDailySeconds() { + @DisplayName("월초가 아니면 일간 랭킹만 초기화한다") + void resetStudyTimeRankingResetsOnlyDailyOnNonFirstDayOfMonth() { // given - StudyTimeRanking ranking = createRanking(120L, 3600L); - given(studyTimeRankingRepository.findAll()).willReturn(List.of(ranking)); + LocalDate nonFirstDayOfMonth = LocalDate.of(2026, 5, 2); + when(studyTimeRankingRepository.resetDailySeconds()).thenReturn(7); // when - studyTimeSchedulerService.resetStudyTimeRankingMonthly(); + int updatedCount = studyTimeSchedulerService.resetStudyTimeRanking(nonFirstDayOfMonth); // then - assertThat(ranking.getDailySeconds()).isEqualTo(120L); - assertThat(ranking.getMonthlySeconds()).isZero(); - } - - private StudyTimeRanking createRanking(Long dailySeconds, Long monthlySeconds) { - RankingType rankingType = RankingTypeFixture.createWithId(1); - University university = UniversityFixture.createWithId(1); - - return StudyTimeRanking.of(rankingType, university, 1, "BCSD Lab", dailySeconds, monthlySeconds); + assertThat(updatedCount).isEqualTo(7); + verify(studyTimeRankingRepository).resetDailySeconds(); + verify(studyTimeRankingRepository, never()).resetDailyAndMonthlySeconds(); } } diff --git a/src/test/java/gg/agit/konect/unit/domain/user/service/UserOAuthAccountServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/user/service/UserOAuthAccountServiceTest.java index e65917f43..f1207c227 100644 --- a/src/test/java/gg/agit/konect/unit/domain/user/service/UserOAuthAccountServiceTest.java +++ b/src/test/java/gg/agit/konect/unit/domain/user/service/UserOAuthAccountServiceTest.java @@ -246,14 +246,21 @@ void linkPrimaryOAuthAccountDeletesExpiredWithdrawnAccountBeforeSavingReplacemen void cleanupExpiredWithdrawnUserOAuthAccountsDeletesUsingThreshold() { // given LocalDateTime now = LocalDateTime.of(2026, 4, 10, 9, 30); - given(userOAuthAccountRepository.deleteAllByWithdrawnUsersBefore(now.minusDays(7))).willReturn(3); + given(userOAuthAccountRepository.deleteRevokedExpiredWithdrawnOAuthAccountsBefore( + now.minusDays(7), + Provider.APPLE + )) + .willReturn(3); // when int deletedCount = userOAuthAccountService.cleanupExpiredWithdrawnUserOAuthAccounts(now); // then assertThat(deletedCount).isEqualTo(3); - verify(userOAuthAccountRepository).deleteAllByWithdrawnUsersBefore(now.minusDays(7)); + verify(userOAuthAccountRepository).deleteRevokedExpiredWithdrawnOAuthAccountsBefore( + now.minusDays(7), + Provider.APPLE + ); verify(userOAuthAccountRepository).flush(); } diff --git a/src/test/java/gg/agit/konect/unit/domain/user/service/UserSchedulerServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/user/service/UserSchedulerServiceTest.java new file mode 100644 index 000000000..89d4b21a0 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/user/service/UserSchedulerServiceTest.java @@ -0,0 +1,93 @@ +package gg.agit.konect.unit.domain.user.service; + +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InOrder; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import gg.agit.konect.domain.user.enums.Provider; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.domain.user.model.UserOAuthAccount; +import gg.agit.konect.domain.user.service.UserOAuthAccountService; +import gg.agit.konect.domain.user.service.UserSchedulerService; +import gg.agit.konect.domain.user.service.UserSchedulerTxService; +import gg.agit.konect.infrastructure.oauth.AppleTokenRevocationService; +import gg.agit.konect.support.ServiceTestSupport; +import gg.agit.konect.support.fixture.UserFixture; + +class UserSchedulerServiceTest extends ServiceTestSupport { + + @Mock + private UserSchedulerTxService userSchedulerTxService; + + @Mock + private UserOAuthAccountService userOAuthAccountService; + + @Mock + private AppleTokenRevocationService appleTokenRevocationService; + + @InjectMocks + private UserSchedulerService userSchedulerService; + + @Test + @DisplayName("Apple 토큰 revoke 후 같은 실행에서 만료 OAuth 계정을 정리한다") + void cleanupExpiredWithdrawnOAuthAccountsRevokesAppleTokensBeforeCleanup() { + // given + LocalDateTime now = LocalDateTime.of(2026, 4, 24, 0, 10); + User user = UserFixture.createWithdrawnUser(1, "2021136001", now.minusDays(8)); + UserOAuthAccount account = UserOAuthAccount.of( + user, + Provider.APPLE, + "apple-provider-id", + "apple@konect.test", + "apple-refresh-token" + ); + given(userSchedulerTxService.findAccountsToRevoke(now.minusDays(7))).willReturn(List.of(account)); + given(userOAuthAccountService.cleanupExpiredWithdrawnUserOAuthAccounts(now)).willReturn(1); + + // when + userSchedulerService.cleanupExpiredWithdrawnOAuthAccountsAfterRestoreWindow(now); + + // then + InOrder inOrder = inOrder(appleTokenRevocationService, userSchedulerTxService, userOAuthAccountService); + inOrder.verify(appleTokenRevocationService).revoke("apple-refresh-token"); + inOrder.verify(userSchedulerTxService).clearAppleRefreshTokenIfMatches(account.getId(), "apple-refresh-token"); + inOrder.verify(userOAuthAccountService).cleanupExpiredWithdrawnUserOAuthAccounts(now); + } + + @Test + @DisplayName("Apple 토큰 revoke가 실패해도 정리는 진행하되 토큰 제거는 하지 않는다") + void cleanupExpiredWithdrawnOAuthAccountsKeepsFailedAppleTokenForRetry() { + // given + LocalDateTime now = LocalDateTime.of(2026, 4, 24, 0, 10); + User user = UserFixture.createWithdrawnUser(1, "2021136001", now.minusDays(8)); + UserOAuthAccount account = UserOAuthAccount.of( + user, + Provider.APPLE, + "apple-provider-id", + "apple@konect.test", + "apple-refresh-token" + ); + given(userSchedulerTxService.findAccountsToRevoke(now.minusDays(7))).willReturn(List.of(account)); + given(userOAuthAccountService.cleanupExpiredWithdrawnUserOAuthAccounts(now)).willReturn(0); + willThrow(new IllegalStateException("Apple revoke failed")) + .given(appleTokenRevocationService).revoke("apple-refresh-token"); + + // when + userSchedulerService.cleanupExpiredWithdrawnOAuthAccountsAfterRestoreWindow(now); + + // then + verify(userSchedulerTxService, never()).clearAppleRefreshTokenIfMatches(account.getId(), "apple-refresh-token"); + verify(userOAuthAccountService).cleanupExpiredWithdrawnUserOAuthAccounts(now); + } +} From e6f4317c212241c574e2c1454e41037738c61a3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Mon, 27 Apr 2026 09:50:53 +0900 Subject: [PATCH 22/50] =?UTF-8?q?docs:=20=EB=AC=B8=EC=9D=98=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EC=A0=95=EC=B1=85=20=EA=B0=80=EC=9D=B4?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80=20(#576)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 문의 API가 저장 없이 이벤트를 발행하고 Slack 알림으로 이어지는 경계를 문서화했습니다. - 공개 API 검증, 원문 이벤트 발행, AFTER_COMMIT Slack 위임 정책을 분리해 채팅 문의방 정책과 혼동되지 않도록 했습니다. - 문서에서 고정한 이벤트 발행과 Slack 리스너 위임 경로를 단위 테스트로 보강했습니다. --- .../gg/agit/konect/domain/inquiry/AGENTS.md | 170 ++++++++++++++++++ .../inquiry/service/InquiryServiceTest.java | 39 ++++ .../listener/InquirySlackListenerTest.java | 35 ++++ 3 files changed, 244 insertions(+) create mode 100644 src/main/java/gg/agit/konect/domain/inquiry/AGENTS.md create mode 100644 src/test/java/gg/agit/konect/unit/domain/inquiry/service/InquiryServiceTest.java create mode 100644 src/test/java/gg/agit/konect/unit/infrastructure/slack/listener/InquirySlackListenerTest.java diff --git a/src/main/java/gg/agit/konect/domain/inquiry/AGENTS.md b/src/main/java/gg/agit/konect/domain/inquiry/AGENTS.md new file mode 100644 index 000000000..dec774eef --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/inquiry/AGENTS.md @@ -0,0 +1,170 @@ +# 문의 도메인 가이드 + +## 이 도메인은 무엇을 하는가 + +문의 도메인은 사용자가 어드민에게 보내는 일반 문의를 접수하고, +트랜잭션 commit 이후 Slack 알림으로 전달하는 도메인이다. + +이 도메인에서 중요한 것은 문의 내용을 DB에 저장하는 것이 아니라 +아래 경계가 같은 정책을 바라보는 것이다. + +- 공개 문의 API (`POST /inquiries`) +- 문의 요청 DTO (`InquiryRequest`) +- 문의 제출 이벤트 (`InquirySubmittedEvent`) +- Slack 문의 알림 리스너 (`InquirySlackListener`) +- Slack 메시지 포맷과 event webhook (`SlackNotificationService.notifyInquiry`) + +문의 관련 작업을 할 때는 항상 "이 변경이 공개 API 검증, 이벤트 발행, +commit 이후 Slack 알림, 채팅 문의방 정책과의 분리까지 같이 맞는가"를 먼저 확인해야 한다. + +## 주요 상태 + +### `InquiryRequest` + +- 문의 API의 요청 DTO다. +- `content`는 blank일 수 없다. +- 현재 요청 DTO에는 최대 길이 제한이 없다. +- `@NotBlank` 외의 trimming, sanitizing, masking 정책은 없다. + +### `InquirySubmittedEvent` + +- 문의 내용을 후속 알림으로 넘기는 이벤트다. +- 현재 이벤트 payload는 `content` 하나뿐이다. +- 사용자 id, 이메일, 요청 시각, 저장 id는 포함하지 않는다. +- 서비스는 전달받은 content를 그대로 이벤트에 담는다. + +### Slack 알림 + +- `InquirySlackListener`는 `InquirySubmittedEvent`를 `AFTER_COMMIT`에 처리한다. +- 리스너는 `slackTaskExecutor`로 비동기 실행된다. +- Slack 메시지는 `SlackMessageTemplate.INQUIRY` 형식으로 만든다. +- Slack 전송 대상은 event webhook (`slackProperties.webhooks().event()`)이다. + +## 기능이 실제로 어떻게 동작해야 하는가 + +### 문의 전송 + +- endpoint는 `POST /inquiries`다. +- `@PublicApi`이므로 로그인 없이 호출할 수 있다. +- 요청 body는 JSON이어야 하고 `content`를 포함해야 한다. +- `content`가 blank면 400 응답이며 `INVALID_REQUEST_BODY`다. +- 요청 body가 없거나 JSON 형식이 아니면 400 응답이며 `INVALID_JSON_FORMAT`이다. +- 성공 응답은 200 OK이고 body는 없다. +- 컨트롤러는 `request.content()`를 그대로 서비스에 넘긴다. + +### 이벤트 발행 + +- `InquiryService.submitInquiry`는 별도 저장 없이 `InquirySubmittedEvent`를 발행한다. +- 이벤트 content는 서비스에 들어온 content와 같아야 한다. +- 현재 서비스는 문의 내용을 trim하거나 마스킹하지 않는다. +- 문의 접수를 영속 데이터로 남기는 정책을 추가하려면 이벤트 payload와 저장 실패/알림 실패 정책을 함께 재정의해야 한다. + +### Slack 후속 처리 + +- Slack 알림은 원 트랜잭션이 commit된 뒤 실행되어야 한다. +- 원 트랜잭션이 rollback되면 Slack 알림도 실행되면 안 된다. +- 리스너는 이벤트 content를 그대로 `SlackNotificationService.notifyInquiry`에 위임한다. +- Slack 전송 실패를 문의 API 응답 정책으로 바꾸려면 비동기 리스너, 예외 처리, 재시도 정책을 함께 확인해야 한다. + +## 절대 놓치면 안 되는 정책 + +- 문의 API는 공개 API다. 인증 사용자 전제를 넣으면 안 된다. +- 문의 내용은 현재 DB에 저장하지 않는다. +- `content` blank만 막고, 최대 길이 제한은 없다. +- 서비스는 content를 그대로 이벤트로 발행한다. +- Slack 알림은 `AFTER_COMMIT` 이후 실행된다. +- 이 도메인은 채팅 도메인의 `SYSTEM_ADMIN` 문의방 정책과 다르다. +- 채팅 문의방 생성, 채팅방 reopen, 마지막 메시지 갱신 정책을 이 도메인에 섞으면 안 된다. +- 사용자 입력이 Slack으로 전달되므로, 로그나 에러 응답에 content를 불필요하게 노출하지 않는다. + +## 수정 시 함께 확인해야 하는 것 + +### API 검증을 바꿀 때 + +- `InquiryRequest.content`의 Bean Validation +- `InquiryApi.submitInquiry`의 `@Valid @RequestBody` +- blank content 400 응답 코드 +- body 누락 또는 JSON 형식 오류 응답 코드 +- 공개 API 유지 여부 + +### 이벤트 payload를 바꿀 때 + +- `InquirySubmittedEvent` +- `InquiryService.submitInquiry` +- `InquirySlackListener.handleInquirySubmitted` +- Slack 메시지 템플릿의 인자 순서 +- 새 payload가 개인정보를 포함할 경우 로그와 Slack 노출 범위 + +### Slack 알림 정책을 바꿀 때 + +- `@TransactionalEventListener(phase = AFTER_COMMIT)` 유지 여부 +- `@Async("slackTaskExecutor")` 유지 여부 +- `SlackNotificationService.notifyInquiry` +- `SlackMessageTemplate.INQUIRY` +- event webhook과 error webhook 중 어느 채널을 써야 하는지 +- Slack 실패가 API 성공 여부에 영향을 줘야 하는지 + +### 채팅 문의 정책과 함께 바꿀 때 + +- 채팅 도메인의 `SYSTEM_ADMIN` 직접 문의방 정책 +- 채팅방 reopen 정책 +- 채팅방 마지막 메시지 메타데이터 갱신 정책 +- Slack 문의 알림과 채팅 문의방 생성의 사용자 경험 차이 + +## 주요 클래스와 책임 + +### `InquiryApi` + +- 문의 API 스펙과 공개 API 여부를 정의한다. +- 요청 DTO 검증은 이 인터페이스의 `@Valid @RequestBody` 조합에 걸려 있다. + +### `InquiryController` + +- HTTP 요청을 서비스 호출로 넘기고 성공 시 200 OK를 반환한다. +- 별도 응답 body를 만들지 않는다. + +### `InquiryService` + +- 문의 접수의 도메인 경계다. +- 현재 책임은 문의 이벤트 발행뿐이다. + +### `InquirySubmittedEvent` + +- 문의 내용을 Slack 알림으로 넘기는 이벤트 payload다. +- 현재 content 외 상태를 갖지 않는다. + +### `InquirySlackListener` + +- 문의 이벤트를 commit 이후 비동기로 처리한다. +- Slack 알림 서비스에 content를 위임한다. + +### `SlackNotificationService` + +- 문의 Slack 메시지를 템플릿으로 포맷하고 event webhook으로 전송한다. + +## 테스트 전략 + +이미 통합 테스트는 아래 정책을 고정한다. + +- `POST /inquiries` 성공 시 200 OK +- blank content는 400과 `INVALID_REQUEST_BODY` +- 요청 body 누락은 400과 `INVALID_JSON_FORMAT` + +단위 테스트는 아래 정책을 고정한다. + +- `InquiryService.submitInquiry`가 content를 그대로 `InquirySubmittedEvent`로 발행한다. +- `InquirySlackListener`가 이벤트 content를 Slack 알림 서비스에 그대로 위임한다. + +이 도메인의 정책을 바꾸거나 가이드 claim을 강화한다면 +추가로 아래 회귀 테스트를 보강하는 것이 좋다. + +- content 최대 길이 제한을 추가할 경우 경계값 테스트 +- content trimming 또는 sanitizing 정책을 추가할 경우 원문/가공 결과 테스트 +- Slack 실패가 문의 API 응답에 영향을 주도록 바꿀 경우 실패 전파 테스트 +- 이벤트 payload에 사용자 정보를 추가할 경우 인증/비인증 호출 정책 테스트 + +검증할 때는 최소한 아래 테스트를 실행한다. + +```bash +CI=true ./gradlew test --tests 'gg.agit.konect.integration.domain.inquiry.*' --tests 'gg.agit.konect.unit.domain.inquiry.*' --tests 'gg.agit.konect.unit.infrastructure.slack.listener.InquirySlackListenerTest' +``` diff --git a/src/test/java/gg/agit/konect/unit/domain/inquiry/service/InquiryServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/inquiry/service/InquiryServiceTest.java new file mode 100644 index 000000000..9fc7a9d37 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/inquiry/service/InquiryServiceTest.java @@ -0,0 +1,39 @@ +package gg.agit.konect.unit.domain.inquiry.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.context.ApplicationEventPublisher; + +import gg.agit.konect.domain.inquiry.event.InquirySubmittedEvent; +import gg.agit.konect.domain.inquiry.service.InquiryService; +import gg.agit.konect.support.ServiceTestSupport; + +class InquiryServiceTest extends ServiceTestSupport { + + @Mock + private ApplicationEventPublisher applicationEventPublisher; + + @InjectMocks + private InquiryService inquiryService; + + @Test + @DisplayName("문의 내용을 원문 그대로 이벤트로 발행한다") + void submitInquiryPublishesEventWithOriginalContent() { + // given + String content = " 앱 사용 중 오류가 발생했습니다. "; + + // when + inquiryService.submitInquiry(content); + + // then + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(InquirySubmittedEvent.class); + verify(applicationEventPublisher).publishEvent(eventCaptor.capture()); + assertThat(eventCaptor.getValue().content()).isEqualTo(content); + } +} diff --git a/src/test/java/gg/agit/konect/unit/infrastructure/slack/listener/InquirySlackListenerTest.java b/src/test/java/gg/agit/konect/unit/infrastructure/slack/listener/InquirySlackListenerTest.java new file mode 100644 index 000000000..8e339684f --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/infrastructure/slack/listener/InquirySlackListenerTest.java @@ -0,0 +1,35 @@ +package gg.agit.konect.unit.infrastructure.slack.listener; + +import static org.mockito.Mockito.verify; + +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.inquiry.event.InquirySubmittedEvent; +import gg.agit.konect.infrastructure.slack.listener.InquirySlackListener; +import gg.agit.konect.infrastructure.slack.service.SlackNotificationService; +import gg.agit.konect.support.ServiceTestSupport; + +class InquirySlackListenerTest extends ServiceTestSupport { + + @Mock + private SlackNotificationService slackNotificationService; + + @InjectMocks + private InquirySlackListener inquirySlackListener; + + @Test + @DisplayName("문의 이벤트의 내용을 Slack 알림 서비스에 위임한다") + void handleInquirySubmittedDelegatesContentToSlackService() { + // given + InquirySubmittedEvent event = InquirySubmittedEvent.from("앱 사용 중 오류가 발생했습니다."); + + // when + inquirySlackListener.handleInquirySubmitted(event); + + // then + verify(slackNotificationService).notifyInquiry(event.content()); + } +} From 991454eac362c7ef112960ed56cb4abf4dcf6339 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Mon, 27 Apr 2026 10:27:52 +0900 Subject: [PATCH 23/50] =?UTF-8?q?docs:=20=EC=9D=BC=EC=A0=95=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EC=A0=95=EC=B1=85=20=EA=B0=80=EC=9D=B4?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80=20(#575)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: 일정 도메인 정책 가이드 추가 - 일정 조회와 관리자 생성/수정/삭제가 공유하는 Schedule, UniversitySchedule 정책을 코드 기준으로 정리 - 대학별 일정 격리, 월 경계 포함, 검색어 정규화, D-Day 계산을 함께 확인하도록 기준화 - 문서 claim 중 D-Day 계산과 query trim/대소문자 무시 정책을 통합 테스트로 고정 * test: 일정 D-Day 테스트 자정 경계 안정화 - 리뷰에서 지적된 자정 경계 플레이크 가능성을 줄이기 위해 테스트 픽스처를 오늘 근처 날짜에서 충분히 떨어진 날짜로 조정했습니다. - 테스트 목적이 정확한 D-Day 산술이 아니라 시작 전 일정의 dDay 노출 여부이므로 exact 값 대신 양수 조건을 검증하도록 했습니다. - 진행 중 일정의 종료일도 넉넉히 뒤로 두어 요청 처리 중 날짜가 바뀌어도 upcoming 목록에서 제외되지 않게 했습니다. * test: 일정 리뷰 피드백 반영 - D-Day가 없는 일정은 현재 직렬화 정책에 맞춰 null 응답을 명시적으로 검증하도록 정리했습니다. - 공백이 포함된 검색어는 URL 문자열 대신 request parameter helper로 전달해 URI 처리 방식에 흔들리지 않게 했습니다. - 다가오는 일정 조회 문서의 종료 시각 경계 표현을 repository의 이상 조건과 일치시켰습니다. * test: 일정 D-Day 기대값 검증 강화 - 시작 전 일정의 D-Day를 양수 여부가 아니라 시작일까지 남은 정확한 일수로 검증하도록 했습니다. - D-Day 계산의 off-by-one이나 시작일/종료일 기준 혼동이 테스트를 통과하지 못하도록 회귀 방어력을 높였습니다. --- .../gg/agit/konect/domain/schedule/AGENTS.md | 231 ++++++++++++++++++ .../domain/schedule/ScheduleApiTest.java | 69 ++++++ 2 files changed, 300 insertions(+) create mode 100644 src/main/java/gg/agit/konect/domain/schedule/AGENTS.md diff --git a/src/main/java/gg/agit/konect/domain/schedule/AGENTS.md b/src/main/java/gg/agit/konect/domain/schedule/AGENTS.md new file mode 100644 index 000000000..953d227a3 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/schedule/AGENTS.md @@ -0,0 +1,231 @@ +# 일정 도메인 가이드 + +## 이 도메인은 무엇을 하는가 + +일정 도메인은 사용자의 대학 기준 학사/행사 일정을 조회하고, +관리자가 같은 일정 모델을 생성/수정/삭제하는 도메인이다. + +이 도메인에서 중요한 것은 단순 날짜 조회가 아니라 +아래 상태가 같은 정책을 바라보는 것이다. + +- 공통 일정 본문 (`Schedule`) +- 대학별 일정 연결 (`UniversitySchedule`) +- 로그인 사용자의 대학 (`User.university`) +- 일정 타입 (`ScheduleType`) +- 월 범위 검색과 D-Day 계산 +- 관리자 일정 생성/수정/삭제 API + +일정 관련 작업을 할 때는 항상 "이 변경이 대학별 일정 격리, 월 경계 포함 규칙, +검색어 정규화, D-Day 계산, 관리자 upsert/delete 정책까지 같이 맞는가"를 먼저 확인해야 한다. + +## 주요 상태 + +### `Schedule` + +- 일정의 제목, 시작 일시, 종료 일시, 타입을 저장하는 공통 엔티티다. +- `title`, `startedAt`, `endedAt`, `scheduleType`은 null일 수 없다. +- `scheduleType`은 문자열 enum으로 저장된다. +- 현재 타입은 `UNIVERSITY`, `CLUB`, `COUNCIL`, `DORM`이다. +- 생성과 수정 모두 `startedAt <= endedAt`이어야 한다. +- `startedAt`이 `endedAt`보다 늦으면 `INVALID_DATE_TIME`이다. +- `startedAt == endedAt`인 단일 시점 일정은 허용된다. +- `calculateDDay(today)`는 오늘이 시작일 전이면 남은 일수를 반환하고, + 오늘이 시작일 당일이거나 이후이면 `null`을 반환한다. + +### `UniversitySchedule` + +- 특정 대학에 속한 일정 연결 엔티티다. +- `@MapsId` 기반으로 `Schedule`과 같은 id를 공유한다. +- `university_schedule.id`는 `schedule.id`를 참조한다. +- 조회와 관리자 수정/삭제는 항상 로그인 사용자의 university id로 + `UniversitySchedule`을 제한해야 한다. +- 다른 대학의 일정 id가 들어오면 존재하지 않는 일정처럼 `NOT_FOUND_SCHEDULE`로 처리된다. + +## 기능이 실제로 어떻게 동작해야 하는가 + +### 다가오는 일정 조회 + +- endpoint는 `GET /schedules/upcoming`이다. +- 로그인 사용자의 대학 id로 `UniversitySchedule`을 제한한다. +- 오늘 00:00 이상에 끝나는 일정만 조회한다. +- 즉 오늘 00:00에 끝나는 일정도 포함되며, 이미 시작했지만 오늘 이후까지 진행 중인 일정도 포함될 수 있다. +- 최대 3개만 반환한다. +- 정렬은 `startedAt ASC`다. +- 다른 대학의 일정은 포함되면 안 된다. +- 응답의 `dDay`는 시작일 전 일정에만 값이 있고, + 진행 중이거나 당일 시작 일정은 `null`이다. + +### 월별 일정 조회 + +- endpoint는 `GET /schedules`다. +- `year`는 필수이며 2000 이상 2100 이하만 허용한다. +- `month`는 필수이며 1 이상 12 이하만 허용한다. +- `query`가 null이면 빈 문자열로 바뀐다. +- 조회 월의 시작은 해당 월 1일 00:00이다. +- 조회 월의 끝은 해당 월 마지막 날 `LocalTime.MAX`다. +- 일정 포함 조건은 `startedAt < monthEnd AND endedAt > monthStart`다. +- 따라서 조회 월과 조금이라도 겹치는 일정은 포함된다. +- 정렬은 `startedAt ASC`다. +- `query`가 비어 있으면 전체 월별 조회를 사용한다. +- `query`가 있으면 trim 후 lower-case로 바꾸고, 일정 제목도 lower-case로 비교한다. +- 검색은 제목 contains 조건이다. + +### 일정 응답 + +- 응답 루트는 `schedules` 배열이다. +- 각 일정은 `title`, `startedAt`, `endedAt`, `dDay`, `scheduleCategory`를 포함한다. +- `startedAt`, `endedAt` JSON 형식은 `yyyy.MM.dd HH:mm`이다. +- `scheduleCategory`는 `ScheduleType.name()`이다. +- `dDay`는 요청 기준 날짜가 아니라 서버의 `LocalDate.now()` 기준이다. + +### 관리자 일정 생성 + +- endpoint는 `POST /admin/schedules`다. +- `ADMIN` 권한이 필요하다. +- 요청의 `title`은 blank일 수 없다. +- `startedAt`, `endedAt`, `scheduleType`은 null일 수 없다. +- 날짜 범위는 `Schedule` 엔티티에서 검증한다. +- 생성 시 먼저 `Schedule`을 저장하고, + 저장된 schedule과 로그인 사용자의 대학으로 `UniversitySchedule`을 저장한다. +- 생성 성공 응답은 200 OK이고 body는 없다. + +### 관리자 일정 일괄 생성/수정 + +- endpoint는 `PUT /admin/schedules/batch`다. +- `ADMIN` 권한이 필요하다. +- 요청의 `schedules`는 비어 있을 수 없다. +- 각 item의 `scheduleId`가 null이면 새 일정을 생성한다. +- 각 item의 `scheduleId`가 있으면 로그인 사용자 대학에 속한 + `UniversitySchedule`을 찾아 기존 `Schedule`을 수정한다. +- 수정 대상이 없거나 다른 대학 일정이면 `NOT_FOUND_SCHEDULE`이다. +- 생성과 수정은 한 transaction에서 순차 처리된다. +- 항목 중 하나라도 검증이나 수정 대상 조회에 실패하면 전체 요청이 실패해야 한다. +- 일정 50개 규모의 batch도 현재 테스트에서 고정한다. + +### 관리자 일정 삭제 + +- endpoint는 `DELETE /admin/schedules/{scheduleId}`다. +- `ADMIN` 권한이 필요하다. +- 로그인 사용자 대학에 속한 `UniversitySchedule`만 삭제할 수 있다. +- 다른 대학 일정이거나 이미 삭제된 일정이면 `NOT_FOUND_SCHEDULE`이다. +- 삭제는 `UniversitySchedule`을 먼저 삭제하고 연결된 `Schedule`도 삭제한다. +- 삭제 성공 응답은 200 OK이고 body는 없다. + +## 절대 놓치면 안 되는 정책 + +- 일반 조회와 관리자 수정/삭제 모두 로그인 사용자의 대학 기준으로 격리되어야 한다. +- 월별 일정은 시작일이 해당 월에 있는 일정만 보는 것이 아니라 + 월 범위와 겹치는 일정을 본다. +- 월 경계 조건은 `startedAt < monthEnd AND endedAt > monthStart`다. +- 다가오는 일정은 종료 시각이 오늘 00:00 이후인 일정이다. +- D-Day는 시작일 전일 때만 내려가고, 당일/진행 중 일정은 `null`이다. +- 검색어는 trim + lower-case 처리 후 제목 contains로 비교한다. +- 관리자 batch upsert는 일부 성공을 허용하지 않는 하나의 transaction이다. +- `UniversitySchedule`은 `Schedule`과 id를 공유하므로, + schedule id와 university schedule id를 같은 값으로 다룬다. +- 다른 대학 일정 수정/삭제 시 권한 오류가 아니라 `NOT_FOUND_SCHEDULE`로 숨긴다. + +## 수정 시 함께 확인해야 하는 것 + +### 조회 조건을 바꿀 때 + +- `ScheduleRepository.findUpcomingSchedules` +- `ScheduleRepository.findSchedulesByMonth` +- `ScheduleQueryRepository.findSchedulesByMonthAndQuery` +- 대학별 `UniversitySchedule` 조인 조건 +- 진행 중 일정의 upcoming 포함 여부 +- 월 경계에 걸친 일정 포함 여부 +- `startedAt ASC` 정렬 + +### 검색 정책을 바꿀 때 + +- `ScheduleCondition`의 null query 기본값 +- query trim 처리 +- query lower-case 처리 +- 제목 lower-case contains 조건 +- 빈 문자열 query와 공백-only query의 동작 + +### 날짜와 D-Day를 바꿀 때 + +- `Schedule.validateDateTimeRange` +- `startedAt == endedAt` 허용 여부 +- `Schedule.calculateDDay` +- `SchedulesResponse.InnerScheduleResponse.from` +- 서버 `LocalDate.now()` 기준 사용 여부 +- 응답 날짜 형식 `yyyy.MM.dd HH:mm` + +### 관리자 생성/수정/삭제를 바꿀 때 + +- `@Auth(roles = {UserRole.ADMIN})` +- 요청 DTO의 `@NotBlank`, `@NotNull`, `@NotEmpty` +- `AdminScheduleService.createUniversitySchedule` +- batch upsert의 transaction 경계 +- 다른 대학 일정 수정/삭제 차단 +- `UniversitySchedule` 삭제와 `Schedule` 삭제 순서 + +## 주요 클래스와 책임 + +### `ScheduleService` + +- 일반 사용자 일정 조회를 담당한다. +- 로그인 사용자 대학 기준으로 upcoming/monthly 일정을 조회하고 응답 DTO로 변환한다. + +### `ScheduleRepository` + +- query가 없는 upcoming/monthly 조회를 담당한다. +- 대학별 일정 격리와 월 범위 겹침 조건이 들어 있다. + +### `ScheduleQueryRepository` + +- query가 있는 월별 검색을 담당한다. +- QueryDSL로 대학, 월 범위, 제목 contains 조건을 조합한다. + +### `AdminScheduleService` + +- 관리자 일정 생성, batch upsert, 삭제를 담당한다. +- 같은 `Schedule`/`UniversitySchedule` 모델을 쓰므로 일반 조회 정책과 함께 봐야 한다. + +### `Schedule` + +- 일정 날짜 범위 검증과 D-Day 계산을 담당한다. +- 날짜 정책을 바꾸면 조회 응답과 관리자 생성/수정 검증이 함께 바뀐다. + +### `UniversitySchedule` + +- 일정과 대학을 연결한다. +- `Schedule`과 같은 id를 공유하는 구조이므로 + id 의미를 바꾸면 조회와 관리자 API가 같이 깨진다. + +## 테스트 전략 + +이미 통합 테스트는 아래 정책을 일부 고정한다. + +- 다가오는 일정 최대 3개 조회 +- 종료된 일정 upcoming 제외 +- 다른 대학 일정 조회 제외 +- 진행 중인 일정의 `dDay` null과 시작 전 일정의 `dDay` 계산 +- 특정 월 일정 조회 +- query 검색 +- query의 대소문자 무시와 trim 처리 +- 월을 걸치는 일정 조회 +- 관리자 일정 생성과 validation 실패 +- 시작/종료가 같은 일정 생성 허용 +- 관리자 batch 생성/수정/혼합 처리 +- batch 요청에서 하나라도 실패하면 전체 rollback +- 다른 대학 일정 수정/삭제 차단 +- 관리자 일정 삭제 시 `Schedule`과 `UniversitySchedule` 함께 삭제 +- 일반 사용자 관리자 API 접근 차단 + +이 도메인의 정책을 바꾸거나 가이드 claim을 강화한다면 +추가로 아래 회귀 테스트를 보강하는 것이 좋다. + +- `year`, `month` 범위 검증 테스트 +- 공백-only query가 현재 전체 조회와 같은지 명확히 고정하는 테스트 + +검증할 때는 최소한 아래 테스트를 실행한다. + +```bash +CI=true ./gradlew test \ + --tests 'gg.agit.konect.integration.domain.schedule.*' \ + --tests 'gg.agit.konect.integration.admin.schedule.*' +``` diff --git a/src/test/java/gg/agit/konect/integration/domain/schedule/ScheduleApiTest.java b/src/test/java/gg/agit/konect/integration/domain/schedule/ScheduleApiTest.java index d5354e3b9..ea1241870 100644 --- a/src/test/java/gg/agit/konect/integration/domain/schedule/ScheduleApiTest.java +++ b/src/test/java/gg/agit/konect/integration/domain/schedule/ScheduleApiTest.java @@ -1,15 +1,18 @@ package gg.agit.konect.integration.domain.schedule; import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.nullValue; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import java.time.LocalDate; import java.time.LocalDateTime; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.springframework.util.LinkedMultiValueMap; import gg.agit.konect.domain.schedule.model.Schedule; import gg.agit.konect.domain.university.model.University; @@ -129,6 +132,38 @@ void otherUniversitySchedulesNotIncluded() throws Exception { .andExpect(jsonPath("$.schedules", hasSize(1))) .andExpect(jsonPath("$.schedules[0].title").value("우리대학 일정")); } + + @Test + @DisplayName("다가오는 일정은 시작 전 일정만 D-Day를 반환한다") + void getUpcomingSchedulesReturnsDDayOnlyBeforeStartDate() throws Exception { + // given + LocalDate today = LocalDate.now(); + Schedule ongoingSchedule = persist(ScheduleFixture.createUniversity( + "진행 중 일정", + today.minusDays(1).atStartOfDay(), + today.plusDays(DAYS_10).atStartOfDay() + )); + Schedule futureSchedule = persist(ScheduleFixture.createUniversity( + "미래 시작 일정", + today.plusDays(DAYS_30).atStartOfDay(), + today.plusDays(DAYS_35).atStartOfDay() + )); + + persist(ScheduleFixture.createUniversitySchedule(ongoingSchedule, university)); + persist(ScheduleFixture.createUniversitySchedule(futureSchedule, university)); + clearPersistenceContext(); + + mockLoginUser(user.getId()); + + // when & then + performGet("/schedules/upcoming") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.schedules", hasSize(2))) + .andExpect(jsonPath("$.schedules[0].title").value("진행 중 일정")) + .andExpect(jsonPath("$.schedules[0].dDay").value(nullValue())) + .andExpect(jsonPath("$.schedules[1].title").value("미래 시작 일정")) + .andExpect(jsonPath("$.schedules[1].dDay").value(DAYS_30)); + } } @Nested @@ -189,6 +224,40 @@ void getSchedulesWithQuery() throws Exception { .andExpect(jsonPath("$.schedules[0].title").value("수강신청 기간")); } + @Test + @DisplayName("검색어는 앞뒤 공백과 대소문자를 무시한다") + void getSchedulesWithTrimmedCaseInsensitiveQuery() throws Exception { + // given + LocalDateTime marchStart = LocalDateTime.of(TEST_YEAR, MARCH, 1, 0, 0); + + Schedule examSchedule = persist(ScheduleFixture.createUniversity( + "Final EXAM", + marchStart.plusDays(1), + marchStart.plusDays(EXPECTED_SCHEDULE_COUNT) + )); + Schedule registrationSchedule = persist(ScheduleFixture.createUniversity( + "수강신청 기간", + marchStart.plusDays(DAYS_10), + marchStart.plusDays(DAYS_15) + )); + + persist(ScheduleFixture.createUniversitySchedule(examSchedule, university)); + persist(ScheduleFixture.createUniversitySchedule(registrationSchedule, university)); + clearPersistenceContext(); + + mockLoginUser(user.getId()); + LinkedMultiValueMap params = new LinkedMultiValueMap<>(); + params.add("year", String.valueOf(TEST_YEAR)); + params.add("month", String.valueOf(MARCH)); + params.add("query", " exam "); + + // when & then + performGet("/schedules", params) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.schedules", hasSize(1))) + .andExpect(jsonPath("$.schedules[0].title").value("Final EXAM")); + } + @Test @DisplayName("월을 걸치는 일정도 조회된다") void getSchedulesSpanningMonths() throws Exception { From f4c8028edf0b3a4f175e49a8950ecab2dc4c9315 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Mon, 27 Apr 2026 10:49:17 +0900 Subject: [PATCH 24/50] =?UTF-8?q?chore:=20Copilot=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EC=A7=80=EC=B9=A8=EC=9D=84=20KONECT=20=EA=B8=B0=EC=A4=80?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EA=B5=AC=EC=B2=B4=ED=99=94=20(#578)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: Copilot 리뷰 지침을 KONECT 기준으로 구체화 - 리뷰 심각도와 코멘트 기준을 명시해 단순 취향성 지적을 줄인다 - 권한, DTO 계약, QueryDSL, Flyway, 운영 설정 등 KONECT에서 회귀 위험이 큰 영역을 우선 확인하도록 정리한다 - 외부 리뷰 제안을 맹목적으로 반영하지 않고 기존 동작과 호환성을 검증하도록 기준을 추가한다 * chore: Copilot 리뷰 지침 오타 수정 --- .github/copilot-instructions.md | 62 ++++++++++++++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 84ef95947..72b9ad1d7 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1 +1,61 @@ -Always respond in Korean. +# Copilot 코드 리뷰 지침 + +모든 리뷰 코멘트는 한국어로 작성한다. + +## 리뷰 우선순위 + +- 정확성, 보안, 권한 검증, 데이터 무결성, 트랜잭션 경계, API 응답 계약, 동시성 문제, 테스트 누락을 우선 확인한다. +- 변경된 코드에서 실제로 발생 가능한 문제만 지적한다. 단순 취향, 스타일 선호, 광범위한 리팩터링, 근거가 약한 추측은 리뷰 코멘트로 남기지 않는다. +- 보안 관련 변경에서는 인증/인가 우회, 사용자 입력 검증 누락, 민감정보 노출, 운영 환경 설정 노출 가능성을 우선 확인한다. +- 데이터베이스 변경에서는 마이그레이션 순서, 기존 데이터 호환성, nullable/default 처리, 롤백 난이도, 인덱스 필요성을 확인한다. +- 외부 API, OAuth, Slack, Claude, MCP, 파일 저장소처럼 외부 시스템과 연결되는 코드는 장애 전파, timeout, 예외 처리, 설정값 누락 시 동작을 확인한다. + +## 심각도 판단 + +- P0: 데이터 손실, 보안 취약점, 인증/인가 우회, 운영 장애처럼 반드시 병합 전에 고쳐야 하는 문제만 해당한다. +- P1: 정상 사용 흐름에서 기능이 깨지거나 API 계약이 바뀌거나 기존 데이터와 호환되지 않는 문제다. +- P2: 특정 조건에서 재현되는 버그, 성능 저하, 유지보수 위험, 테스트 공백처럼 수정 가치가 분명한 문제다. +- P3: 영향이 작고 선택적인 개선이다. 단순 취향이면 코멘트하지 않는다. +- nitpick을 P0/P1처럼 과장하지 말고, 문제의 실제 영향과 재현 가능성에 맞춰 판단한다. + +## 프로젝트 관례 + +- 이 저장소의 기존 Spring Boot, JPA, QueryDSL, Bean Validation, 예외 처리, 테스트 패턴을 우선 따른다. +- 요청 범위를 벗어난 추상화, 확장 포인트, 방어 코드, unrelated cleanup을 제안하지 않는다. +- Java 코드에서 import로 해결할 수 있는 경우 FQCN(Full Qualified Class Name)을 사용하지 않도록 지적한다. +- 기존 스타일과 일치하지 않는 코드가 실제 유지보수 위험을 만들 때만 지적한다. 단순히 다른 스타일도 가능하다는 이유로 코멘트를 남기지 않는다. +- 도메인별 `AGENTS.md`가 있는 경로를 수정하는 PR에서는 해당 문서의 정책, 역할, 생명주기, 불변식과 구현이 어긋나지 않는지 확인한다. +- 변경 범위 밖의 오래된 죽은 코드, 이름 변경, 포맷 정리는 현재 PR의 버그나 회귀와 직접 연결될 때만 언급한다. + +## 주석 기준 + +- 조건이 2개 이상 결합된 비즈니스 규칙, 권한 조건, soft delete 제외, 중복 제거, fallback 우선순위, 대표값 선택, DTO 변환, count 쿼리 분리, fetch join 선택 이유처럼 코드만으로 의도가 숨겨지는 지점에는 주석을 권장한다. +- 주석은 코드가 무엇을 하는지보다 왜 그렇게 해야 하는지를 설명해야 한다. +- 단순 생성자 호출, 필드 매핑, 컬렉션 반환, 이름만으로 명확한 분기에는 주석을 요구하지 않는다. + +## 테스트 기준 + +- 버그 수정, 검증 로직, 권한 정책, 마이그레이션, API 응답 변경, 회귀 위험이 있는 변경은 실패를 재현하는 테스트 또는 정책을 고정하는 테스트가 있는지 확인한다. +- 리팩터링 PR에서는 새 기능 테스트보다 기존 동작이 유지되는지 확인한다. +- 테스트는 동작과 정책을 검증해야 한다. private 메서드 호출 여부, 내부 구현 순서, 과도한 mock 호출 횟수처럼 brittle한 구현 세부사항을 강제하지 않는다. +- 이미 컨트롤러/서비스/레포지토리 테스트 패턴이 있는 영역에서는 같은 계층의 기존 테스트 스타일을 따르도록 제안한다. +- mock 호출 횟수 검증은 해당 호출 자체가 외부 부작용, 이벤트 발행, 저장, 알림 전송 같은 비즈니스 결과일 때만 요구한다. +- 테스트가 성공 경로만 확인하고 실패/권한/경계값을 놓친 경우에는 어떤 입력이 빠졌는지 구체적으로 제안한다. + +## KONECT 특화 체크포인트 + +- 권한 로직은 관리자 우회, 요청자와 대상자 관계, 클럽/채팅방/공지/일정의 소속 검증이 빠지지 않았는지 확인한다. +- soft delete, 탈퇴 사용자, 차단/제외 조건, 중복 제거가 필요한 조회에서는 응답에 노출되면 안 되는 데이터가 포함되는지 확인한다. +- DTO 응답 변경은 기존 클라이언트가 기대하는 필드명, nullability, enum/string 값, 정렬 순서를 깨지 않는지 확인한다. +- JPA/QueryDSL 조회 변경은 N+1, 잘못된 fetch join, count 쿼리 왜곡, pagination 깨짐, distinct 누락을 확인한다. +- Flyway 마이그레이션은 파일명 버전 순서, 기존 운영 데이터 backfill, NOT NULL 추가 순서, 기본값 처리까지 확인한다. +- 운영 설정 변경은 prod/stage/local 프로파일 차이, Swagger/OpenAPI 노출, secret/env 누락 시 실패 방식을 확인한다. + +## 리뷰 코멘트 작성 방식 + +- 가장 작은 관련 라인 범위에 코멘트를 단다. +- 첫 문장에 문제와 영향을 바로 쓰고, 가능하면 `이 입력/상태에서 이런 잘못된 결과가 난다` 형태로 실패 모드를 설명한다. +- 수정 제안은 가장 작은 변경 단위로 제시한다. +- 문제가 없으면 구현을 다시 설명하는 코멘트를 남기지 않는다. +- 확신이 낮은 경우 단정하지 말고 어떤 전제가 맞을 때 문제가 되는지 명시한다. +- 리뷰어 제안이 기존 동작, 호환성, 사용자 결정, YAGNI 원칙과 충돌할 수 있으면 무조건 수정하라고 하지 말고 확인 질문이나 근거 확인으로 표현한다. From 69c5de450e6b8cada2c82dd5540a04c9aa5e3d2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Mon, 27 Apr 2026 11:42:25 +0900 Subject: [PATCH 25/50] =?UTF-8?q?test:=20=EB=AC=B8=EC=9D=98=EB=B0=A9=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=EA=B8=B0=EC=A4=80=EC=84=A0=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80=20(#585)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 채팅 리팩토링을 작은 PR 단위로 진행하기 위해 admin 문의방 검색 정책을 먼저 회귀 테스트로 고정 - 관리자가 문의방 멤버가 아니어도 사용자 문의방을 검색할 수 있어야 하는 기존 접근 정책을 명시 - 후속 QueryRepository 분리 과정에서 admin direct 조회 조건이 누락되는 회귀를 막기 위한 안전망을 추가 --- .../integration/domain/chat/ChatApiTest.java | 34 +++++++++++++++++++ 1 file changed, 34 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 dd147fec6..cacd341fd 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 @@ -1278,6 +1278,40 @@ void searchChatsMatchesDefaultNameEvenWithCustomRoomName() throws Exception { .andExpect(jsonPath("$.roomMatches.rooms[0].roomName").value("내 메모")); } + @Test + @DisplayName("관리자는 멤버가 아니어도 사용자 문의방을 검색할 수 있다") + void searchChatsIncludesInquiryRoomForAdminWithoutMembership() throws Exception { + // given + User anotherAdmin = persist(UserFixture.createAdmin(university)); + clearPersistenceContext(); + + mockLoginUser(normalUser.getId()); + int chatRoomId = parseChatRoomId( + performPost("/chats/rooms/admin") + .andExpect(status().isOk()) + .andReturn() + ); + performPost("/chats/rooms/" + chatRoomId + "/messages", + new ChatMessageSendRequest("관리자 검색 문의")) + .andExpect(status().isOk()); + + clearPersistenceContext(); + mockLoginUser(anotherAdmin.getId()); + + // 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(chatRoomId)) + .andExpect(jsonPath("$.roomMatches.rooms[0].roomName").value("일반유저")) + .andExpect(jsonPath("$.messageMatches.totalCount").value(0)); + + assertThat(chatRoomMemberRepository.findByChatRoomId(chatRoomId)) + .extracting(ChatRoomMember::getUserId) + .containsExactlyInAnyOrder(SYSTEM_ADMIN_ID, normalUser.getId()); + } + @Test @DisplayName("채팅방 검색 결과에 페이지네이션을 적용한다") void searchChatsAppliesPaginationToRoomMatches() throws Exception { From 4550eb42bbc733182f6c9dad3623c3a89f2c8eb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Mon, 27 Apr 2026 13:05:02 +0900 Subject: [PATCH 26/50] =?UTF-8?q?refactor:=20=EB=A9=94=EC=8B=9C=EC=A7=80?= =?UTF-8?q?=20=EA=B2=80=EC=83=89=20=EC=A1=B0=ED=9A=8C=EB=A5=BC=20QueryRepo?= =?UTF-8?q?sitory=EB=A1=9C=20=EB=B6=84=EB=A6=AC=20(#586)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 메시지 검색 조회를 QueryRepository로 분리 - 채팅 리팩토링을 작은 PR 단위로 진행하기 위해 메시지 검색 조회만 먼저 QueryDSL 전용 리포지토리로 이동 - ChatMessageRepository는 엔티티 저장소 역할에 가깝게 유지하고 검색 read model 조회 책임을 분리 - LOCATE 기반 검색 조건을 유지해 LIKE 특수문자를 리터럴로 처리하는 기존 검색 정책을 보존 * fix: 검색 키워드 lower 처리 위치 보존 - QueryRepository 분리 과정에서 Java lowercasing으로 바뀐 키워드 처리를 기존 JPQL처럼 DB LOWER 처리로 되돌림 - 검색 결과가 DB collation과 Java Locale 차이에 의해 달라질 가능성을 줄임 - repository 직접 호출 시 keyword null 실패 형태가 명확하도록 requireNonNull을 추가 --- .../ChatMessageQueryRepository.java | 65 +++++++++++++++++++ .../repository/ChatMessageRepository.java | 21 ------ .../domain/chat/service/ChatService.java | 4 +- .../domain/chat/service/ChatServiceTest.java | 4 ++ 4 files changed, 72 insertions(+), 22 deletions(-) create mode 100644 src/main/java/gg/agit/konect/domain/chat/repository/ChatMessageQueryRepository.java diff --git a/src/main/java/gg/agit/konect/domain/chat/repository/ChatMessageQueryRepository.java b/src/main/java/gg/agit/konect/domain/chat/repository/ChatMessageQueryRepository.java new file mode 100644 index 000000000..41f898fbb --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/chat/repository/ChatMessageQueryRepository.java @@ -0,0 +1,65 @@ +package gg.agit.konect.domain.chat.repository; + +import static gg.agit.konect.domain.chat.model.QChatMessage.chatMessage; +import static gg.agit.konect.domain.chat.model.QChatRoom.chatRoom; + +import java.util.List; +import java.util.Objects; + +import org.springframework.stereotype.Repository; + +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.Expressions; +import com.querydsl.jpa.JPAExpressions; +import com.querydsl.jpa.impl.JPAQueryFactory; + +import gg.agit.konect.domain.chat.model.ChatMessage; +import gg.agit.konect.domain.chat.model.QChatMessage; +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class ChatMessageQueryRepository { + + private final JPAQueryFactory jpaQueryFactory; + + public List searchLatestMatchingMessagesByChatRoomIds( + List roomIds, + String keyword + ) { + Objects.requireNonNull(keyword, "keyword must not be null"); + + if (roomIds.isEmpty()) { + return List.of(); + } + + QChatMessage innerMessage = new QChatMessage("innerMessage"); + + return jpaQueryFactory + .selectFrom(chatMessage) + .join(chatMessage.chatRoom, chatRoom).fetchJoin() + .where( + chatRoom.id.in(roomIds), + containsKeyword(chatMessage, keyword), + chatMessage.id.eq( + JPAExpressions + .select(innerMessage.id.max()) + .from(innerMessage) + .where( + innerMessage.chatRoom.id.eq(chatRoom.id), + containsKeyword(innerMessage, keyword) + ) + ) + ) + .orderBy(chatMessage.createdAt.desc(), chatMessage.id.desc()) + .fetch(); + } + + private BooleanExpression containsKeyword(QChatMessage message, String keyword) { + return Expressions.booleanTemplate( + "LOCATE(LOWER({0}), LOWER({1})) > 0", + keyword, + message.content + ); + } +} 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 921513d14..c5d796c5f 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 @@ -122,27 +122,6 @@ 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 LOCATE(LOWER(:keyword), LOWER(cm.content)) > 0 - AND cm.id = ( - SELECT MAX(innerCm.id) - FROM ChatMessage innerCm - WHERE innerCm.chatRoom.id = cr.id - AND LOCATE(LOWER(:keyword), LOWER(innerCm.content)) > 0 - ) - ORDER BY cm.createdAt DESC, cm.id DESC - """ - ) - List searchLatestMatchingMessagesByChatRoomIds( - @Param("roomIds") List roomIds, - @Param("keyword") String keyword - ); - @Query("SELECT cm FROM ChatMessage cm JOIN FETCH cm.chatRoom WHERE cm.id = :messageId") Optional findByIdWithChatRoom(@Param("messageId") Integer messageId); 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 8b43f73ba..18e681abe 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 @@ -46,6 +46,7 @@ import gg.agit.konect.domain.chat.model.ChatRoom; import gg.agit.konect.domain.chat.model.ChatRoomMember; import gg.agit.konect.domain.chat.repository.ChatInviteQueryRepository; +import gg.agit.konect.domain.chat.repository.ChatMessageQueryRepository; import gg.agit.konect.domain.chat.repository.ChatMessageRepository; import gg.agit.konect.domain.chat.repository.ChatRoomMemberRepository; import gg.agit.konect.domain.chat.repository.ChatRoomRepository; @@ -79,6 +80,7 @@ public class ChatService { private final NotificationMuteSettingRepository notificationMuteSettingRepository; private final ClubMemberRepository clubMemberRepository; private final ChatInviteQueryRepository chatInviteQueryRepository; + private final ChatMessageQueryRepository chatMessageQueryRepository; private final UserRepository userRepository; private final ChatPresenceService chatPresenceService; private final ChatRoomMembershipService chatRoomMembershipService; @@ -1016,7 +1018,7 @@ private ChatMessageMatchesResponse searchByMessageContent( .toList(); Map visibleMessageFromMap = getVisibleMessageFromMap(directRoomIds, userId); - List matchedMessages = chatMessageRepository + List matchedMessages = chatMessageQueryRepository .searchLatestMatchingMessagesByChatRoomIds(roomIds, keyword) .stream() .filter(message -> isVisibleMessageMatch(message, roomMap, visibleMessageFromMap)) 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 e28c3856e..7a4b0b349 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 @@ -47,6 +47,7 @@ import gg.agit.konect.domain.chat.model.ChatRoom; import gg.agit.konect.domain.chat.model.ChatRoomMember; import gg.agit.konect.domain.chat.repository.ChatInviteQueryRepository; +import gg.agit.konect.domain.chat.repository.ChatMessageQueryRepository; import gg.agit.konect.domain.chat.repository.ChatMessageRepository; import gg.agit.konect.domain.chat.repository.ChatRoomMemberRepository; import gg.agit.konect.domain.chat.repository.ChatRoomRepository; @@ -91,6 +92,9 @@ class ChatServiceTest extends ServiceTestSupport { @Mock private ChatInviteQueryRepository chatInviteQueryRepository; + @Mock + private ChatMessageQueryRepository chatMessageQueryRepository; + @Mock private UserRepository userRepository; From 21b5c625b2a07bc14c13dda4a669e20e1b6aa94a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Mon, 27 Apr 2026 13:26:08 +0900 Subject: [PATCH 27/50] =?UTF-8?q?refactor:=20=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=20=EB=AC=B8=EC=9D=98=EB=B0=A9=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=EB=A5=BC=20QueryRepository=EB=A1=9C=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=20(#587)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 관리자 문의방 목록 조회를 QueryRepository로 분리 - 채팅 조회 분리 작업을 작은 단위로 유지하기 위해 관리자 문의방 목록 projection 조회만 QueryDSL 전용 리포지토리로 이동 - ChatRoomRepository는 엔티티 저장소 역할에 가깝게 유지하고 목록 read model 조회 책임을 분리 - 관리자가 문의방 멤버가 아니거나 나간 뒤 새 메시지가 온 경우에도 목록에서 볼 수 있는 기존 정책을 보존 * docs: 관리자 채팅방 projection 설명 정리 - 관리자 문의방 조회가 QueryDSL constructor projection으로 이동했으므로 JPQL에 한정된 설명을 제거 - projection 필드 순서와 타입이 조회 쿼리 생성자 인자와 맞아야 한다는 제약은 그대로 보존 - 이후 조회 구현 방식과 문서 표현이 어긋나는 혼선을 방지 * fix: 관리자 문의방 목록 조회 조건 정리 - 관리자 문의방 목록에서 탈퇴 사용자의 이름과 프로필이 노출되지 않도록 soft delete 조건을 추가 - leftJoin 미매칭 케이스를 이미 포함하는 leftAt IS NULL 조건을 기준으로 가시성 OR 조건을 단순화 - QueryDSL 조회 분리 후에도 기존 문의방 노출 정책과 사용자 상태 필터가 함께 유지되도록 정리 --- .../chat/dto/AdminChatRoomProjection.java | 2 +- .../repository/ChatRoomQueryRepository.java | 98 +++++++++++++++++++ .../chat/repository/ChatRoomRepository.java | 58 ----------- .../domain/chat/service/ChatService.java | 4 +- .../domain/chat/service/ChatServiceTest.java | 4 + 5 files changed, 106 insertions(+), 60 deletions(-) create mode 100644 src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomQueryRepository.java diff --git a/src/main/java/gg/agit/konect/domain/chat/dto/AdminChatRoomProjection.java b/src/main/java/gg/agit/konect/domain/chat/dto/AdminChatRoomProjection.java index a277a19a6..96e871757 100644 --- a/src/main/java/gg/agit/konect/domain/chat/dto/AdminChatRoomProjection.java +++ b/src/main/java/gg/agit/konect/domain/chat/dto/AdminChatRoomProjection.java @@ -4,7 +4,7 @@ /** * 관리자용 1:1 채팅방 목록 조회를 위한 Projection DTO - * 필드 순서와 타입이 JPQL SELECT 절과 정확히 일치해야 합니다. + * 필드 순서와 타입이 조회 쿼리의 constructor projection과 정확히 일치해야 합니다. */ public record AdminChatRoomProjection( Integer roomId, diff --git a/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomQueryRepository.java b/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomQueryRepository.java new file mode 100644 index 000000000..36e3e6d73 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomQueryRepository.java @@ -0,0 +1,98 @@ +package gg.agit.konect.domain.chat.repository; + +import static gg.agit.konect.domain.chat.model.QChatMessage.chatMessage; +import static gg.agit.konect.domain.chat.model.QChatRoom.chatRoom; + +import java.util.List; + +import org.springframework.stereotype.Repository; + +import com.querydsl.core.types.Projections; +import com.querydsl.jpa.JPAExpressions; +import com.querydsl.jpa.impl.JPAQueryFactory; + +import gg.agit.konect.domain.chat.dto.AdminChatRoomProjection; +import gg.agit.konect.domain.chat.enums.ChatType; +import gg.agit.konect.domain.chat.model.QChatMessage; +import gg.agit.konect.domain.chat.model.QChatRoomMember; +import gg.agit.konect.domain.user.enums.UserRole; +import gg.agit.konect.domain.user.model.QUser; +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class ChatRoomQueryRepository { + + private final JPAQueryFactory jpaQueryFactory; + + public List findAdminChatRoomsOptimized( + Integer systemAdminId, + Integer viewerAdminId, + UserRole adminRole, + ChatType roomType + ) { + QChatRoomMember roomMember = new QChatRoomMember("roomMember"); + QChatRoomMember systemAdminMember = new QChatRoomMember("systemAdminMember"); + QChatRoomMember viewerAdminMember = new QChatRoomMember("viewerAdminMember"); + QUser nonAdminUser = new QUser("nonAdminUser"); + QChatMessage userReply = new QChatMessage("userReply"); + QUser userReplySender = new QUser("userReplySender"); + + return jpaQueryFactory + .select(Projections.constructor( + AdminChatRoomProjection.class, + chatRoom.id, + chatRoom.lastMessageContent, + chatRoom.lastMessageSentAt, + chatRoom.createdAt, + nonAdminUser.id, + nonAdminUser.name, + nonAdminUser.imageUrl, + chatMessage.count() + )) + .from(chatRoom) + .join(roomMember).on(roomMember.id.chatRoomId.eq(chatRoom.id)) + .join(roomMember.user, nonAdminUser) + .join(systemAdminMember).on( + systemAdminMember.id.chatRoomId.eq(chatRoom.id), + systemAdminMember.id.userId.eq(systemAdminId) + ) + .leftJoin(viewerAdminMember).on( + viewerAdminMember.id.chatRoomId.eq(chatRoom.id), + viewerAdminMember.id.userId.eq(viewerAdminId) + ) + .leftJoin(chatMessage).on( + chatMessage.chatRoom.id.eq(chatRoom.id), + chatMessage.sender.id.ne(systemAdminId), + chatMessage.createdAt.gt(systemAdminMember.lastReadAt) + ) + .where( + chatRoom.roomType.eq(roomType), + nonAdminUser.role.ne(adminRole), + nonAdminUser.deletedAt.isNull(), + // 관리자는 문의방 멤버가 아니거나 나갔어도 새 메시지가 있으면 목록에서 다시 볼 수 있다. + viewerAdminMember.leftAt.isNull() + .or(chatRoom.lastMessageSentAt.gt(viewerAdminMember.visibleMessageFrom)), + JPAExpressions + .selectOne() + .from(userReply) + .join(userReply.sender, userReplySender) + .where( + userReply.chatRoom.id.eq(chatRoom.id), + userReplySender.role.ne(adminRole) + ) + .exists() + ) + .groupBy( + chatRoom.id, + chatRoom.lastMessageContent, + chatRoom.lastMessageSentAt, + chatRoom.createdAt, + nonAdminUser.id, + nonAdminUser.name, + nonAdminUser.imageUrl + ) + .orderBy(chatRoom.lastMessageSentAt.coalesce(chatRoom.createdAt).desc()) + .fetch(); + } +} diff --git a/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomRepository.java b/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomRepository.java index f2789c553..078ad36a3 100644 --- a/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomRepository.java +++ b/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomRepository.java @@ -11,7 +11,6 @@ import org.springframework.data.repository.Repository; import org.springframework.data.repository.query.Param; -import gg.agit.konect.domain.chat.dto.AdminChatRoomProjection; import gg.agit.konect.domain.chat.model.ChatRoom; import gg.agit.konect.domain.chat.enums.ChatType; import gg.agit.konect.domain.user.enums.UserRole; @@ -171,61 +170,4 @@ List findAllSystemAdminDirectRooms( @Param("roomType") ChatType roomType ); - /** - * 관리자용 1:1 채팅방 목록을 Projection DTO로 최적화 조회 - *

- * 사용자가 응답한 채팅방만 필터링하고, 필요한 필드만 한 번에 조회합니다. - * 이 메소드는 다음과 같은 최적화를 제공합니다: - *

    - *
  • ChatRoom 엔티티 전체 로딩 대신 필요한 필드만 Projection
  • - *
  • 읽지 않은 메시지 수를 DB에서 직접 계산 (COUNT 서브쿼리)
  • - *
  • 상대방 사용자 정보를 JOIN으로 한 번에 조회
  • - *
- */ - @Query(""" - SELECT new gg.agit.konect.domain.chat.dto.AdminChatRoomProjection( - cr.id, - cr.lastMessageContent, - cr.lastMessageSentAt, - cr.createdAt, - u.id, - u.name, - u.imageUrl, - COUNT(cm) - ) - FROM ChatRoom cr - JOIN ChatRoomMember crm ON crm.id.chatRoomId = cr.id - JOIN User u ON u.id = crm.id.userId - JOIN ChatRoomMember systemAdminCrm ON systemAdminCrm.id.chatRoomId = cr.id - AND systemAdminCrm.id.userId = :systemAdminId - LEFT JOIN ChatRoomMember viewerAdminCrm ON viewerAdminCrm.id.chatRoomId = cr.id - AND viewerAdminCrm.id.userId = :viewerAdminId - LEFT JOIN ChatMessage cm ON cm.chatRoom.id = cr.id - AND cm.sender.id <> :systemAdminId - AND cm.createdAt > systemAdminCrm.lastReadAt - WHERE cr.roomType = :roomType - AND u.role != :adminRole - AND ( - viewerAdminCrm.leftAt IS NULL - OR viewerAdminCrm.id.userId IS NULL - OR ( - viewerAdminCrm.leftAt IS NOT NULL - AND cr.lastMessageSentAt > viewerAdminCrm.visibleMessageFrom - ) - ) - AND EXISTS ( - SELECT 1 FROM ChatMessage userReply - JOIN userReply.sender userSender - WHERE userReply.chatRoom.id = cr.id - AND userSender.role != :adminRole - ) - GROUP BY cr.id, cr.lastMessageContent, cr.lastMessageSentAt, cr.createdAt, u.id, u.name, u.imageUrl - ORDER BY COALESCE(cr.lastMessageSentAt, cr.createdAt) DESC - """) - List findAdminChatRoomsOptimized( - @Param("systemAdminId") Integer systemAdminId, - @Param("viewerAdminId") Integer viewerAdminId, - @Param("adminRole") UserRole adminRole, - @Param("roomType") ChatType roomType - ); } 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 18e681abe..039a652eb 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 @@ -49,6 +49,7 @@ import gg.agit.konect.domain.chat.repository.ChatMessageQueryRepository; import gg.agit.konect.domain.chat.repository.ChatMessageRepository; import gg.agit.konect.domain.chat.repository.ChatRoomMemberRepository; +import gg.agit.konect.domain.chat.repository.ChatRoomQueryRepository; import gg.agit.konect.domain.chat.repository.ChatRoomRepository; import gg.agit.konect.domain.chat.repository.RoomUnreadCountProjection; import gg.agit.konect.domain.club.model.ClubMember; @@ -75,6 +76,7 @@ public class ChatService { private static final String DEFAULT_GROUP_ROOM_NAME = "그룹 채팅"; private final ChatRoomRepository chatRoomRepository; + private final ChatRoomQueryRepository chatRoomQueryRepository; private final ChatMessageRepository chatMessageRepository; private final ChatRoomMemberRepository chatRoomMemberRepository; private final NotificationMuteSettingRepository notificationMuteSettingRepository; @@ -550,7 +552,7 @@ private List getDirectChatRooms(Integer userId) { } private List getAdminDirectChatRooms(Integer adminUserId) { - List projections = chatRoomRepository.findAdminChatRoomsOptimized( + List projections = chatRoomQueryRepository.findAdminChatRoomsOptimized( SYSTEM_ADMIN_ID, adminUserId, UserRole.ADMIN, ChatType.DIRECT ); 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 7a4b0b349..01d8af408 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 @@ -50,6 +50,7 @@ import gg.agit.konect.domain.chat.repository.ChatMessageQueryRepository; import gg.agit.konect.domain.chat.repository.ChatMessageRepository; import gg.agit.konect.domain.chat.repository.ChatRoomMemberRepository; +import gg.agit.konect.domain.chat.repository.ChatRoomQueryRepository; import gg.agit.konect.domain.chat.repository.ChatRoomRepository; import gg.agit.konect.domain.chat.service.ChatPresenceService; import gg.agit.konect.domain.chat.service.ChatRoomMembershipService; @@ -77,6 +78,9 @@ class ChatServiceTest extends ServiceTestSupport { @Mock private ChatRoomRepository chatRoomRepository; + @Mock + private ChatRoomQueryRepository chatRoomQueryRepository; + @Mock private ChatMessageRepository chatMessageRepository; From 097fd5d388ebf37db02c0dd5496dec1a857cf955 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:19:29 +0900 Subject: [PATCH 28/50] =?UTF-8?q?refactor:=20=EC=B1=84=ED=8C=85=EB=B0=A9?= =?UTF-8?q?=20=EC=84=A4=EC=A0=95=20=ED=95=A9=EC=84=B1=20=EC=B1=85=EC=9E=84?= =?UTF-8?q?=20=EB=B6=84=EB=A6=AC=20(#588)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 채팅방 설정 합성 책임 분리 - 채팅방 목록 요약 분리 작업을 작은 PR 단위로 진행하기 위해 이름과 뮤트 설정 합성만 먼저 분리 - 목록 조회와 검색 조회가 같은 설정 적용 규칙을 쓰도록 ChatRoomSettingsService로 책임을 모음 - custom room name과 mute 상태가 기존 응답 필드에 동일하게 반영되는지 단위 테스트로 고정 * refactor: 채팅방 목록 요약 조합 책임 분리 - 채팅방 목록 요약 책임을 단계적으로 분리하기 위해 방 타입별 목록을 합치고 정렬하는 흐름을 ChatRoomSummaryService로 이동 - 목록 조회와 검색 조회가 각각 기존 정렬 기준을 유지하도록 분리된 메서드로 보존 - 검색에서 커스텀 이름과 기본 이름을 함께 비교할 수 있도록 기본 방 이름 맵 생성을 명시적으로 테스트 * refactor: 검색 가능 방 목록 잔여 코드 제거 - 채팅방 요약 책임 분리 후 더 이상 사용하지 않는 roomIds 생성 코드를 제거 - 검색 가능한 방 목록 조합 흐름에서 실제로 필요한 기본 방 이름 맵과 요약 목록만 남김 - 미사용 변수가 남아 이후 조회 로직으로 오해되는 상황을 방지 * refactor: 채팅 검색 응답 조립 책임 분리 - 채팅 검색 책임 분리 작업을 작은 단위로 진행하기 위해 방 이름 검색과 메시지 내용 검색 조립 흐름을 ChatSearchService로 이동 - ChatService는 접근 가능한 방 목록을 준비한 뒤 검색 서비스에 위임하도록 축소 - direct visibleMessageFrom 필터링과 기본 방 이름 검색 정책은 기존 검색 통합 테스트로 유지 --- .../chat/service/ChatRoomSettingsService.java | 87 ++++++ .../chat/service/ChatRoomSummaryService.java | 70 +++++ .../chat/service/ChatSearchService.java | 167 +++++++++++ .../domain/chat/service/ChatService.java | 276 ++---------------- .../service/ChatRoomSettingsServiceTest.java | 94 ++++++ .../service/ChatRoomSummaryServiceTest.java | 97 ++++++ .../domain/chat/service/ChatServiceTest.java | 12 +- 7 files changed, 540 insertions(+), 263 deletions(-) create mode 100644 src/main/java/gg/agit/konect/domain/chat/service/ChatRoomSettingsService.java create mode 100644 src/main/java/gg/agit/konect/domain/chat/service/ChatRoomSummaryService.java create mode 100644 src/main/java/gg/agit/konect/domain/chat/service/ChatSearchService.java create mode 100644 src/test/java/gg/agit/konect/unit/domain/chat/service/ChatRoomSettingsServiceTest.java create mode 100644 src/test/java/gg/agit/konect/unit/domain/chat/service/ChatRoomSummaryServiceTest.java diff --git a/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomSettingsService.java b/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomSettingsService.java new file mode 100644 index 000000000..69dd33f46 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomSettingsService.java @@ -0,0 +1,87 @@ +package gg.agit.konect.domain.chat.service; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import gg.agit.konect.domain.chat.dto.ChatRoomSummaryResponse; +import gg.agit.konect.domain.chat.model.ChatRoomMember; +import gg.agit.konect.domain.chat.repository.ChatRoomMemberRepository; +import gg.agit.konect.domain.notification.enums.NotificationTargetType; +import gg.agit.konect.domain.notification.model.NotificationMuteSetting; +import gg.agit.konect.domain.notification.repository.NotificationMuteSettingRepository; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class ChatRoomSettingsService { + + private final NotificationMuteSettingRepository notificationMuteSettingRepository; + private final ChatRoomMemberRepository chatRoomMemberRepository; + + public List applyUserSettings( + List rooms, + Integer userId + ) { + List roomIds = rooms.stream() + .map(ChatRoomSummaryResponse::roomId) + .toList(); + Map muteMap = getMuteMap(roomIds, userId); + Map customRoomNameMap = getCustomRoomNameMap(roomIds, userId); + + return rooms.stream() + .map(room -> applyRoomSettings(room, muteMap, customRoomNameMap)) + .toList(); + } + + private ChatRoomSummaryResponse applyRoomSettings( + ChatRoomSummaryResponse room, + Map muteMap, + Map customRoomNameMap + ) { + return new ChatRoomSummaryResponse( + room.roomId(), + room.chatType(), + customRoomNameMap.getOrDefault(room.roomId(), room.roomName()), + room.roomImageUrl(), + room.lastMessage(), + room.lastSentAt(), + room.createdAt(), + room.unreadCount(), + muteMap.getOrDefault(room.roomId(), false) + ); + } + + private Map getMuteMap(List roomIds, Integer userId) { + if (roomIds.isEmpty()) { + return Map.of(); + } + + List settings = notificationMuteSettingRepository + .findByTargetTypeAndTargetIdsAndUserId(NotificationTargetType.CHAT_ROOM, roomIds, userId); + + Map muteMap = new HashMap<>(); + for (NotificationMuteSetting setting : settings) { + Integer targetId = setting.getTargetId(); + if (targetId != null) { + muteMap.put(targetId, setting.getIsMuted()); + } + } + + return muteMap; + } + + private Map getCustomRoomNameMap(List roomIds, Integer userId) { + if (roomIds.isEmpty()) { + return Map.of(); + } + + return chatRoomMemberRepository.findByChatRoomIdsAndUserId(roomIds, userId).stream() + .filter(member -> StringUtils.hasText(member.getCustomRoomName())) + .collect(Collectors.toMap(ChatRoomMember::getChatRoomId, ChatRoomMember::getCustomRoomName)); + } +} 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..1b8ef0650 --- /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.ChatMessageQueryRepository; +import gg.agit.konect.domain.chat.repository.ChatRoomMemberRepository; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class ChatSearchService { + + private final ChatMessageQueryRepository chatMessageQueryRepository; + 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 = chatMessageQueryRepository + .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 039a652eb..e46f44067 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; @@ -46,7 +42,6 @@ import gg.agit.konect.domain.chat.model.ChatRoom; import gg.agit.konect.domain.chat.model.ChatRoomMember; import gg.agit.konect.domain.chat.repository.ChatInviteQueryRepository; -import gg.agit.konect.domain.chat.repository.ChatMessageQueryRepository; import gg.agit.konect.domain.chat.repository.ChatMessageRepository; import gg.agit.konect.domain.chat.repository.ChatRoomMemberRepository; import gg.agit.konect.domain.chat.repository.ChatRoomQueryRepository; @@ -82,10 +77,11 @@ public class ChatService { private final NotificationMuteSettingRepository notificationMuteSettingRepository; private final ClubMemberRepository clubMemberRepository; private final ChatInviteQueryRepository chatInviteQueryRepository; - private final ChatMessageQueryRepository chatMessageQueryRepository; private final UserRepository userRepository; private final ChatPresenceService chatPresenceService; private final ChatRoomMembershipService chatRoomMembershipService; + private final ChatRoomSummaryService chatRoomSummaryService; + private final ChatSearchService chatSearchService; private final NotificationService notificationService; private final ApplicationEventPublisher eventPublisher; @@ -216,75 +212,20 @@ public ChatRoomsSummaryResponse getChatRooms(Integer userId) { List clubRooms = getClubChatRooms(userId); List groupRooms = getGroupChatRooms(userId); - List roomIds = new ArrayList<>(); - roomIds.addAll(directRooms.stream().map(ChatRoomSummaryResponse::roomId).toList()); - roomIds.addAll(clubRooms.stream().map(ChatRoomSummaryResponse::roomId).toList()); - roomIds.addAll(groupRooms.stream().map(ChatRoomSummaryResponse::roomId).toList()); - - Map muteMap = getMuteMap(roomIds, userId); - Map customRoomNameMap = getCustomRoomNameMap(roomIds, userId); - List rooms = new ArrayList<>(); - - directRooms.forEach(room -> rooms.add(new ChatRoomSummaryResponse( - room.roomId(), - room.chatType(), - resolveRoomName(room.roomId(), room.roomName(), customRoomNameMap), - room.roomImageUrl(), - room.lastMessage(), - room.lastSentAt(), - room.createdAt(), - room.unreadCount(), - muteMap.getOrDefault(room.roomId(), false) - ))); - - clubRooms.forEach(room -> rooms.add(new ChatRoomSummaryResponse( - room.roomId(), - room.chatType(), - resolveRoomName(room.roomId(), room.roomName(), customRoomNameMap), - room.roomImageUrl(), - room.lastMessage(), - room.lastSentAt(), - room.createdAt(), - room.unreadCount(), - muteMap.getOrDefault(room.roomId(), false) - ))); - - groupRooms.forEach(room -> rooms.add(new ChatRoomSummaryResponse( - room.roomId(), - room.chatType(), - resolveRoomName(room.roomId(), room.roomName(), customRoomNameMap), - room.roomImageUrl(), - room.lastMessage(), - room.lastSentAt(), - room.createdAt(), - room.unreadCount(), - muteMap.getOrDefault(room.roomId(), false) - ))); - - 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( @@ -948,199 +889,16 @@ 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 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))); - - rooms.sort( - Comparator.comparing(ChatRoomSummaryResponse::lastSentAt, - Comparator.nullsLast(Comparator.reverseOrder())) - .thenComparing(ChatRoomSummaryResponse::roomId) + Map defaultRoomNameMap = chatRoomSummaryService.getDefaultRoomNameMap( + directRooms, + clubRooms ); - return new AccessibleChatRooms(rooms, defaultRoomNameMap); - } - - private ChatRoomSummaryResponse applyRoomSettings( - ChatRoomSummaryResponse room, - Map muteMap, - Map customRoomNameMap - ) { - return new ChatRoomSummaryResponse( - room.roomId(), - room.chatType(), - resolveRoomName(room.roomId(), room.roomName(), customRoomNameMap), - room.roomImageUrl(), - room.lastMessage(), - room.lastSentAt(), - room.createdAt(), - room.unreadCount(), - muteMap.getOrDefault(room.roomId(), false) + List rooms = chatRoomSummaryService.summarizeSearchableRooms( + userId, + directRooms, + clubRooms ); - } - - 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 = chatMessageQueryRepository - .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 getMuteMap(List roomIds, Integer userId) { - if (roomIds.isEmpty()) { - return Map.of(); - } - - List settings = notificationMuteSettingRepository - .findByTargetTypeAndTargetIdsAndUserId(NotificationTargetType.CHAT_ROOM, roomIds, userId); - - Map muteMap = new HashMap<>(); - for (NotificationMuteSetting setting : settings) { - Integer targetId = setting.getTargetId(); - if (targetId != null) { - muteMap.put(targetId, setting.getIsMuted()); - } - } - - 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(); - } - - return chatRoomMemberRepository.findByChatRoomIdsAndUserId(roomIds, userId).stream() - .filter(member -> StringUtils.hasText(member.getCustomRoomName())) - .collect(Collectors.toMap(ChatRoomMember::getChatRoomId, ChatRoomMember::getCustomRoomName)); - } - - private String resolveRoomName(Integer roomId, String - defaultRoomName, Map customRoomNameMap) { - return customRoomNameMap.getOrDefault(roomId, defaultRoomName); - } - - 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); + return new AccessibleChatRooms(rooms, defaultRoomNameMap); } private ChatRoom getDirectRoom(Integer roomId) { diff --git a/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatRoomSettingsServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatRoomSettingsServiceTest.java new file mode 100644 index 000000000..1bec16abd --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatRoomSettingsServiceTest.java @@ -0,0 +1,94 @@ +package gg.agit.konect.unit.domain.chat.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; + +import java.time.LocalDateTime; +import java.util.List; + +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.model.ChatRoomMember; +import gg.agit.konect.domain.chat.repository.ChatRoomMemberRepository; +import gg.agit.konect.domain.chat.service.ChatRoomSettingsService; +import gg.agit.konect.domain.notification.enums.NotificationTargetType; +import gg.agit.konect.domain.notification.model.NotificationMuteSetting; +import gg.agit.konect.domain.notification.repository.NotificationMuteSettingRepository; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.support.ServiceTestSupport; + +class ChatRoomSettingsServiceTest extends ServiceTestSupport { + + @Mock + private NotificationMuteSettingRepository notificationMuteSettingRepository; + + @Mock + private ChatRoomMemberRepository chatRoomMemberRepository; + + @InjectMocks + private ChatRoomSettingsService chatRoomSettingsService; + + @Test + @DisplayName("applyUserSettings는 커스텀 방 이름과 뮤트 설정을 목록 응답에 합성한다") + void applyUserSettingsAppliesCustomNameAndMute() { + // given + Integer userId = 10; + ChatRoomSummaryResponse room = createRoomSummary(1, "기본 이름"); + ChatRoomMember member = mock(ChatRoomMember.class); + given(member.getChatRoomId()).willReturn(room.roomId()); + given(member.getCustomRoomName()).willReturn("내 방 이름"); + given(notificationMuteSettingRepository.findByTargetTypeAndTargetIdsAndUserId( + NotificationTargetType.CHAT_ROOM, + List.of(room.roomId()), + userId + )).willReturn(List.of(NotificationMuteSetting.of( + NotificationTargetType.CHAT_ROOM, + room.roomId(), + mock(User.class), + true + ))); + given(chatRoomMemberRepository.findByChatRoomIdsAndUserId(List.of(room.roomId()), userId)) + .willReturn(List.of(member)); + + // when + List result = chatRoomSettingsService.applyUserSettings(List.of(room), userId); + + // then + assertThat(result).hasSize(1); + assertThat(result.get(0).roomName()).isEqualTo("내 방 이름"); + assertThat(result.get(0).isMuted()).isTrue(); + assertThat(result.get(0).lastMessage()).isEqualTo(room.lastMessage()); + } + + @Test + @DisplayName("applyUserSettings는 빈 목록이면 설정 조회를 생략한다") + void applyUserSettingsSkipsLookupForEmptyRooms() { + // when + List result = chatRoomSettingsService.applyUserSettings(List.of(), 10); + + // then + assertThat(result).isEmpty(); + verifyNoInteractions(notificationMuteSettingRepository, chatRoomMemberRepository); + } + + private ChatRoomSummaryResponse createRoomSummary(Integer roomId, String roomName) { + return new ChatRoomSummaryResponse( + roomId, + ChatType.DIRECT, + roomName, + "https://example.com/image.png", + "마지막 메시지", + LocalDateTime.of(2026, 4, 27, 11, 0), + LocalDateTime.of(2026, 4, 27, 10, 0), + 3, + false + ); + } +} 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 01d8af408..d465f26bb 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 @@ -47,13 +47,14 @@ import gg.agit.konect.domain.chat.model.ChatRoom; import gg.agit.konect.domain.chat.model.ChatRoomMember; import gg.agit.konect.domain.chat.repository.ChatInviteQueryRepository; -import gg.agit.konect.domain.chat.repository.ChatMessageQueryRepository; import gg.agit.konect.domain.chat.repository.ChatMessageRepository; import gg.agit.konect.domain.chat.repository.ChatRoomMemberRepository; import gg.agit.konect.domain.chat.repository.ChatRoomQueryRepository; 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.ChatRoomSummaryService; +import gg.agit.konect.domain.chat.service.ChatSearchService; import gg.agit.konect.domain.chat.service.ChatService; import gg.agit.konect.domain.club.model.Club; import gg.agit.konect.domain.club.model.ClubMember; @@ -96,9 +97,6 @@ class ChatServiceTest extends ServiceTestSupport { @Mock private ChatInviteQueryRepository chatInviteQueryRepository; - @Mock - private ChatMessageQueryRepository chatMessageQueryRepository; - @Mock private UserRepository userRepository; @@ -108,6 +106,12 @@ class ChatServiceTest extends ServiceTestSupport { @Mock private ChatRoomMembershipService chatRoomMembershipService; + @Mock + private ChatRoomSummaryService chatRoomSummaryService; + + @Mock + private ChatSearchService chatSearchService; + @Mock private NotificationService notificationService; From 3bc3f4e85d3ed4db3c2ba54f8055b48f596cb414 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:19:52 +0900 Subject: [PATCH 29/50] =?UTF-8?q?refactor:=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EB=A7=88=EC=A7=80=EB=A7=89=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=20=EB=A9=94=ED=83=80=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=ED=99=9C=EC=9A=A9=20(#591)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 채팅방 설정 합성 책임 분리 - 채팅방 목록 요약 분리 작업을 작은 PR 단위로 진행하기 위해 이름과 뮤트 설정 합성만 먼저 분리 - 목록 조회와 검색 조회가 같은 설정 적용 규칙을 쓰도록 ChatRoomSettingsService로 책임을 모음 - custom room name과 mute 상태가 기존 응답 필드에 동일하게 반영되는지 단위 테스트로 고정 * refactor: 채팅방 목록 요약 조합 책임 분리 - 채팅방 목록 요약 책임을 단계적으로 분리하기 위해 방 타입별 목록을 합치고 정렬하는 흐름을 ChatRoomSummaryService로 이동 - 목록 조회와 검색 조회가 각각 기존 정렬 기준을 유지하도록 분리된 메서드로 보존 - 검색에서 커스텀 이름과 기본 이름을 함께 비교할 수 있도록 기본 방 이름 맵 생성을 명시적으로 테스트 * refactor: 목록 조회 마지막 메시지 메타데이터 활용 - 채팅방 목록 응답에서 club/group 방의 마지막 메시지를 이미 유지되는 chat_room 메타데이터로 읽도록 변경 - 목록 조회마다 chat_message에서 최신 메시지를 다시 조회하던 배치 쿼리를 제거해 반복 조회 비용을 줄임 - direct 방은 나간 뒤 가시성 정책이 별도로 필요하므로 기존 visibleMessageFrom 기반 처리를 유지 * refactor: 채팅방 목록 요약 조합 책임 분리 - 채팅방 목록 요약 책임을 단계적으로 분리하기 위해 방 타입별 목록을 합치고 정렬하는 흐름을 ChatRoomSummaryService로 이동 - 목록 조회와 검색 조회가 각각 기존 정렬 기준을 유지하도록 분리된 메서드로 보존 - 검색에서 커스텀 이름과 기본 이름을 함께 비교할 수 있도록 기본 방 이름 맵 생성을 명시적으로 테스트 * refactor: 검색 가능 방 목록 잔여 코드 제거 - 채팅방 요약 책임 분리 후 더 이상 사용하지 않는 roomIds 생성 코드를 제거 - 검색 가능한 방 목록 조합 흐름에서 실제로 필요한 기본 방 이름 맵과 요약 목록만 남김 - 미사용 변수가 남아 이후 조회 로직으로 오해되는 상황을 방지 * refactor: 채팅 검색 응답 조립 책임 분리 - 채팅 검색 책임 분리 작업을 작은 단위로 진행하기 위해 방 이름 검색과 메시지 내용 검색 조립 흐름을 ChatSearchService로 이동 - ChatService는 접근 가능한 방 목록을 준비한 뒤 검색 서비스에 위임하도록 축소 - direct visibleMessageFrom 필터링과 기본 방 이름 검색 정책은 기존 검색 통합 테스트로 유지 --- .../repository/ChatMessageRepository.java | 13 ---- .../domain/chat/service/ChatService.java | 61 +++++++------------ 2 files changed, 22 insertions(+), 52 deletions(-) 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 c5d796c5f..d06ddb8ce 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 @@ -109,19 +109,6 @@ List findRoomIdsWithUserReplyByRoomIds( @Param("adminRole") UserRole adminRole ); - @Query(""" - SELECT m - FROM ChatMessage m - JOIN FETCH m.sender - WHERE m.id IN ( - SELECT MAX(m2.id) - FROM ChatMessage m2 - WHERE m2.chatRoom.id IN :roomIds - GROUP BY m2.chatRoom.id - ) - """) - List findLatestMessagesByRoomIds(@Param("roomIds") List roomIds); - @Query("SELECT cm FROM ChatMessage cm JOIN FETCH cm.chatRoom WHERE cm.id = :messageId") Optional findByIdWithChatRoom(@Param("messageId") Integer messageId); 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 e46f44067..d638ac7a1 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 @@ -528,24 +528,20 @@ private List getClubChatRooms(Integer userId) { .toList(); List roomIds = rooms.stream().map(ChatRoom::getId).toList(); - Map lastMessageMap = getLastMessageMap(roomIds); Map unreadCountMap = getRoomUnreadCountMap(roomIds, userId); return rooms.stream() - .map(room -> { - ChatMessage lastMessage = lastMessageMap.get(room.getId()); - return new ChatRoomSummaryResponse( - room.getId(), - ChatType.CLUB_GROUP, - room.getClub().getName(), - room.getClub().getImageUrl(), - lastMessage != null ? lastMessage.getContent() : null, - lastMessage != null ? lastMessage.getCreatedAt() : null, - room.getCreatedAt(), - unreadCountMap.getOrDefault(room.getId(), 0), - false - ); - }) + .map(room -> new ChatRoomSummaryResponse( + room.getId(), + ChatType.CLUB_GROUP, + room.getClub().getName(), + room.getClub().getImageUrl(), + room.getLastMessageContent(), + room.getLastMessageSentAt(), + room.getCreatedAt(), + unreadCountMap.getOrDefault(room.getId(), 0), + false + )) .toList(); } @@ -556,24 +552,20 @@ private List getGroupChatRooms(Integer userId) { } List roomIds = rooms.stream().map(ChatRoom::getId).toList(); - Map lastMessageMap = getLastMessageMap(roomIds); Map unreadCountMap = getRoomUnreadCountMap(roomIds, userId); return rooms.stream() - .map(room -> { - ChatMessage lastMessage = lastMessageMap.get(room.getId()); - return new ChatRoomSummaryResponse( - room.getId(), - ChatType.GROUP, - DEFAULT_GROUP_ROOM_NAME, - null, - lastMessage != null ? lastMessage.getContent() : null, - lastMessage != null ? lastMessage.getCreatedAt() : null, - room.getCreatedAt(), - unreadCountMap.getOrDefault(room.getId(), 0), - false - ); - }) + .map(room -> new ChatRoomSummaryResponse( + room.getId(), + ChatType.GROUP, + DEFAULT_GROUP_ROOM_NAME, + null, + room.getLastMessageContent(), + room.getLastMessageSentAt(), + room.getCreatedAt(), + unreadCountMap.getOrDefault(room.getId(), 0), + false + )) .toList(); } @@ -1129,15 +1121,6 @@ private int countUnreadSince(LocalDateTime messageCreatedAt, List return left; } - private Map getLastMessageMap(List roomIds) { - if (roomIds.isEmpty()) { - return Map.of(); - } - - return chatMessageRepository.findLatestMessagesByRoomIds(roomIds).stream() - .collect(Collectors.toMap(message -> message.getChatRoom().getId(), message -> message)); - } - private Map getRoomUnreadCountMap(List roomIds, Integer userId) { if (roomIds.isEmpty()) { return Map.of(); From abbc6ffc9b8d68107b861a3a41a044f54268a445 Mon Sep 17 00:00:00 2001 From: JanooGwan <103417427+JanooGwan@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:22:20 +0900 Subject: [PATCH 30/50] =?UTF-8?q?refactor:=20=EC=99=B8=EB=B6=80=20?= =?UTF-8?q?=ED=98=B8=EC=B6=9C=20=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=20?= =?UTF-8?q?=EB=B2=94=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 * refactor: 외부 호출 트랜잭션 범위 축소 * fix: 시트 등록 트랜잭션 경계 보장 * fix: 시트 매핑 등록 예외 처리 강화 * fix: 시트 매핑 직렬화 실패 로그 보강 * fix: 시트 ID 등록 검증 순서 조정 * fix: 시트 등록 예외 추적성 보강 * fix: 시트 등록 중복 조회 제거 --- .../club/repository/ClubRepository.java | 17 +++ .../club/service/ClubMemberSheetService.java | 41 ++---- .../service/ClubSheetRegistrationService.java | 60 ++++++++ .../club/service/SheetSyncExecutor.java | 2 - .../service/NotificationService.java | 11 +- ...bMemberSheetServicePackagePrivateTest.java | 74 ++++++++++ .../service/ClubMemberSheetServiceTest.java | 70 ++++++--- .../ClubSheetRegistrationServiceTest.java | 138 ++++++++++++++++++ 8 files changed, 352 insertions(+), 61 deletions(-) create mode 100644 src/main/java/gg/agit/konect/domain/club/service/ClubSheetRegistrationService.java create mode 100644 src/test/java/gg/agit/konect/domain/club/service/ClubMemberSheetServicePackagePrivateTest.java create mode 100644 src/test/java/gg/agit/konect/unit/domain/club/service/ClubSheetRegistrationServiceTest.java diff --git a/src/main/java/gg/agit/konect/domain/club/repository/ClubRepository.java b/src/main/java/gg/agit/konect/domain/club/repository/ClubRepository.java index 5dbab27af..94ff955f4 100644 --- a/src/main/java/gg/agit/konect/domain/club/repository/ClubRepository.java +++ b/src/main/java/gg/agit/konect/domain/club/repository/ClubRepository.java @@ -3,6 +3,7 @@ import java.util.List; import java.util.Optional; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.Repository; import org.springframework.data.repository.query.Param; @@ -42,6 +43,22 @@ default Club getByIdWithUniversity(Integer id) { List findAll(); + boolean existsById(Integer id); + + @Modifying(flushAutomatically = true) + @Query(value = """ + UPDATE Club c + SET c.googleSheetId = :googleSheetId, + c.sheetColumnMapping = :sheetColumnMapping, + c.updatedAt = CURRENT_TIMESTAMP + WHERE c.id = :clubId + """) + int updateSheetRegistration( + @Param(value = "clubId") Integer clubId, + @Param(value = "googleSheetId") String googleSheetId, + @Param(value = "sheetColumnMapping") String sheetColumnMapping + ); + Club save(Club club); @Query("SELECT COUNT(c) FROM Club c") diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java b/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java index f92c9b22d..823af30e2 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java @@ -3,10 +3,6 @@ import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_CLUB_SHEET_ID; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; import gg.agit.konect.domain.club.dto.ClubMemberSheetSyncResponse; import gg.agit.konect.domain.club.dto.ClubSheetIdUpdateRequest; @@ -15,11 +11,10 @@ import gg.agit.konect.domain.club.repository.ClubMemberRepository; import gg.agit.konect.domain.club.repository.ClubPreMemberRepository; import gg.agit.konect.domain.club.repository.ClubRepository; +import gg.agit.konect.global.code.ApiResponseCode; import gg.agit.konect.global.exception.CustomException; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -@Slf4j @Service @RequiredArgsConstructor public class ClubMemberSheetService { @@ -30,55 +25,39 @@ public class ClubMemberSheetService { private final ClubPermissionValidator clubPermissionValidator; private final SheetSyncExecutor sheetSyncExecutor; private final SheetHeaderMapper sheetHeaderMapper; - private final ObjectMapper objectMapper; + private final ClubSheetRegistrationService clubSheetRegistrationService; - @Transactional public void updateSheetId( Integer clubId, Integer requesterId, ClubSheetIdUpdateRequest request ) { - Club club = clubRepository.getById(clubId); - clubPermissionValidator.validateManagerAccess(clubId, requesterId); - + validateClubExists(clubId); String spreadsheetId = SpreadsheetUrlParser.extractId(request.spreadsheetUrl()); + clubPermissionValidator.validateManagerAccess(clubId, requesterId); SheetHeaderMapper.SheetAnalysisResult result = sheetHeaderMapper.analyzeAllSheets(spreadsheetId); - applySheetRegistration(club, spreadsheetId, result); + clubSheetRegistrationService.updateSheetRegistration(clubId, spreadsheetId, result); } - @Transactional void updateSheetId( Integer clubId, Integer requesterId, String spreadsheetId, SheetHeaderMapper.SheetAnalysisResult result ) { - Club club = clubRepository.getById(clubId); + validateClubExists(clubId); clubPermissionValidator.validateManagerAccess(clubId, requesterId); - applySheetRegistration(club, spreadsheetId, result); + clubSheetRegistrationService.updateSheetRegistration(clubId, spreadsheetId, result); } - private void applySheetRegistration( - Club club, - String spreadsheetId, - SheetHeaderMapper.SheetAnalysisResult result - ) { - String mappingJson = null; - try { - mappingJson = objectMapper.writeValueAsString(result.memberListMapping().toMap()); - } catch (JsonProcessingException e) { - log.warn("Failed to serialize mapping, skipping. cause={}", e.getMessage()); - } - - club.updateGoogleSheetId(spreadsheetId); - if (mappingJson != null) { - club.updateSheetColumnMapping(mappingJson); + private void validateClubExists(Integer clubId) { + if (!clubRepository.existsById(clubId)) { + throw CustomException.of(ApiResponseCode.NOT_FOUND_CLUB); } } - @Transactional(readOnly = true) public ClubMemberSheetSyncResponse syncMembersToSheet( Integer clubId, Integer requesterId, diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubSheetRegistrationService.java b/src/main/java/gg/agit/konect/domain/club/service/ClubSheetRegistrationService.java new file mode 100644 index 000000000..5bc34a39b --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubSheetRegistrationService.java @@ -0,0 +1,60 @@ +package gg.agit.konect.domain.club.service; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import gg.agit.konect.domain.club.model.SheetColumnMapping; +import gg.agit.konect.domain.club.repository.ClubRepository; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ClubSheetRegistrationService { + + private final ClubRepository clubRepository; + private final ObjectMapper objectMapper; + + @Transactional + public void updateSheetRegistration( + Integer clubId, + String spreadsheetId, + SheetHeaderMapper.SheetAnalysisResult result + ) { + String mappingJson = serializeMemberListMapping(clubId, spreadsheetId, result); + + int updatedCount = clubRepository.updateSheetRegistration(clubId, spreadsheetId, mappingJson); + if (updatedCount == 0) { + throw CustomException.of(ApiResponseCode.NOT_FOUND_CLUB); + } + } + + private String serializeMemberListMapping( + Integer clubId, + String spreadsheetId, + SheetHeaderMapper.SheetAnalysisResult result + ) { + SheetColumnMapping memberListMapping = result.memberListMapping(); + if (memberListMapping == null) { + throw CustomException.of(ApiResponseCode.CLUB_SHEET_ANALYSIS_REQUIRED); + } + + try { + return objectMapper.writeValueAsString(memberListMapping.toMap()); + } catch (JsonProcessingException e) { + log.warn( + "Failed to serialize sheet column mapping. clubId={}, spreadsheetId={}", + clubId, + spreadsheetId, + e + ); + throw CustomException.of(ApiResponseCode.FAILED_SYNC_GOOGLE_SHEET); + } + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java b/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java index 31309e4e9..7479d3691 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java @@ -11,7 +11,6 @@ import org.springframework.context.ApplicationEventPublisher; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; @@ -64,7 +63,6 @@ public class SheetSyncExecutor { private final ApplicationEventPublisher applicationEventPublisher; @Async("sheetSyncTaskExecutor") - @Transactional(readOnly = true) public void executeWithSort(Integer clubId, ClubSheetSortKey sortKey, boolean ascending) { Club club = clubRepository.getById(clubId); String spreadsheetId = club.getGoogleSheetId(); diff --git a/src/main/java/gg/agit/konect/domain/notification/service/NotificationService.java b/src/main/java/gg/agit/konect/domain/notification/service/NotificationService.java index 15bd7ae82..c242829f0 100644 --- a/src/main/java/gg/agit/konect/domain/notification/service/NotificationService.java +++ b/src/main/java/gg/agit/konect/domain/notification/service/NotificationService.java @@ -10,6 +10,7 @@ import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import gg.agit.konect.domain.chat.service.ChatPresenceService; @@ -74,7 +75,7 @@ public void deleteToken(Integer userId, NotificationTokenDeleteRequest request) } @Async("notificationTaskExecutor") - @Transactional + @Transactional(propagation = Propagation.NOT_SUPPORTED) public void sendChatNotification(Integer receiverId, Integer roomId, String senderName, String messageContent) { try { if (chatPresenceService.isUserInChatRoom(roomId, receiverId)) { @@ -114,7 +115,7 @@ public void sendChatNotification(Integer receiverId, Integer roomId, String send } @Async("notificationTaskExecutor") - @Transactional + @Transactional(propagation = Propagation.NOT_SUPPORTED) public void sendGroupChatNotification( Integer roomId, Integer senderId, @@ -191,7 +192,7 @@ public void sendGroupChatNotification( } @Async("notificationTaskExecutor") - @Transactional + @Transactional(propagation = Propagation.NOT_SUPPORTED) public void sendClubApplicationSubmittedNotification( Integer receiverId, Integer applicationId, @@ -208,7 +209,7 @@ public void sendClubApplicationSubmittedNotification( } @Async("notificationTaskExecutor") - @Transactional + @Transactional(propagation = Propagation.NOT_SUPPORTED) public void sendClubApplicationApprovedNotification(Integer receiverId, Integer clubId, String clubName) { String body = "동아리 지원이 승인되었어요."; String path = "clubs/" + clubId; @@ -219,7 +220,7 @@ public void sendClubApplicationApprovedNotification(Integer receiverId, Integer } @Async("notificationTaskExecutor") - @Transactional + @Transactional(propagation = Propagation.NOT_SUPPORTED) public void sendClubApplicationRejectedNotification(Integer receiverId, Integer clubId, String clubName) { String body = "동아리 지원이 거절되었어요."; String path = "clubs/" + clubId; diff --git a/src/test/java/gg/agit/konect/domain/club/service/ClubMemberSheetServicePackagePrivateTest.java b/src/test/java/gg/agit/konect/domain/club/service/ClubMemberSheetServicePackagePrivateTest.java new file mode 100644 index 000000000..3aefcb12e --- /dev/null +++ b/src/test/java/gg/agit/konect/domain/club/service/ClubMemberSheetServicePackagePrivateTest.java @@ -0,0 +1,74 @@ +package gg.agit.konect.domain.club.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verifyNoInteractions; + +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.club.repository.ClubMemberRepository; +import gg.agit.konect.domain.club.repository.ClubPreMemberRepository; +import gg.agit.konect.domain.club.repository.ClubRepository; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; +import gg.agit.konect.support.ServiceTestSupport; + +class ClubMemberSheetServicePackagePrivateTest extends ServiceTestSupport { + + @Mock + private ClubRepository clubRepository; + + @Mock + private ClubMemberRepository clubMemberRepository; + + @Mock + private ClubPreMemberRepository clubPreMemberRepository; + + @Mock + private ClubPermissionValidator clubPermissionValidator; + + @Mock + private SheetSyncExecutor sheetSyncExecutor; + + @Mock + private SheetHeaderMapper sheetHeaderMapper; + + @Mock + private ClubSheetRegistrationService clubSheetRegistrationService; + + @InjectMocks + private ClubMemberSheetService clubMemberSheetService; + + @Test + @DisplayName("분석 결과를 받은 updateSheetId도 동아리가 없으면 권한 검증 전에 실패한다") + void updateSheetIdWithAnalysisThrowsNotFoundClubBeforePermissionCheck() { + // given + Integer clubId = 1; + Integer requesterId = 2; + String spreadsheetId = "spreadsheet-id"; + SheetHeaderMapper.SheetAnalysisResult analysisResult = new SheetHeaderMapper.SheetAnalysisResult( + null, + null, + null + ); + + given(clubRepository.existsById(clubId)).willReturn(false); + + // when & then + assertThatThrownBy(() -> clubMemberSheetService.updateSheetId( + clubId, + requesterId, + spreadsheetId, + analysisResult + )) + .isInstanceOf(CustomException.class) + .satisfies(exception -> assertThat(((CustomException)exception).getErrorCode()).isEqualTo( + ApiResponseCode.NOT_FOUND_CLUB)); + + verifyNoInteractions(clubPermissionValidator, clubSheetRegistrationService); + } +} diff --git a/src/test/java/gg/agit/konect/unit/domain/club/service/ClubMemberSheetServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/club/service/ClubMemberSheetServiceTest.java index 4935b4b06..2eb2c9fb1 100644 --- a/src/test/java/gg/agit/konect/unit/domain/club/service/ClubMemberSheetServiceTest.java +++ b/src/test/java/gg/agit/konect/unit/domain/club/service/ClubMemberSheetServiceTest.java @@ -3,28 +3,30 @@ import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_CLUB_SHEET_ID; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mockito.InjectMocks; import org.mockito.Mock; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; - import gg.agit.konect.domain.club.dto.ClubMemberSheetSyncResponse; import gg.agit.konect.domain.club.dto.ClubSheetIdUpdateRequest; import gg.agit.konect.domain.club.enums.ClubSheetSortKey; import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.club.model.SheetColumnMapping; import gg.agit.konect.domain.club.repository.ClubMemberRepository; import gg.agit.konect.domain.club.repository.ClubPreMemberRepository; import gg.agit.konect.domain.club.repository.ClubRepository; import gg.agit.konect.domain.club.service.ClubMemberSheetService; import gg.agit.konect.domain.club.service.ClubPermissionValidator; +import gg.agit.konect.domain.club.service.ClubSheetRegistrationService; import gg.agit.konect.domain.club.service.SheetHeaderMapper; import gg.agit.konect.domain.club.service.SheetSyncExecutor; +import gg.agit.konect.global.code.ApiResponseCode; import gg.agit.konect.global.exception.CustomException; import gg.agit.konect.support.ServiceTestSupport; import gg.agit.konect.support.fixture.ClubFixture; @@ -51,13 +53,13 @@ class ClubMemberSheetServiceTest extends ServiceTestSupport { private SheetHeaderMapper sheetHeaderMapper; @Mock - private ObjectMapper objectMapper; + private ClubSheetRegistrationService clubSheetRegistrationService; @InjectMocks private ClubMemberSheetService clubMemberSheetService; @Test - @DisplayName("시트 동기화 수에 사전 회원도 포함한다") + @DisplayName("시트 동기화 응답에 사전 회원 수를 포함한다") void syncMembersToSheetIncludesPreMembersInCount() { // given Integer clubId = 1; @@ -87,24 +89,22 @@ void syncMembersToSheetIncludesPreMembersInCount() { } @Test - @DisplayName("updateSheetId는 정상 동작한다") - void updateSheetIdWorksNormally() throws JsonProcessingException { + @DisplayName("시트 ID를 분석한 뒤 등록 서비스에 위임한다") + void updateSheetIdWorksNormally() { // given Integer clubId = 1; Integer requesterId = 2; String spreadsheetUrl = "https://docs.google.com/spreadsheets/d/test-sheet-id/edit"; - Club club = ClubFixture.create(UniversityFixture.create()); ClubSheetIdUpdateRequest request = new ClubSheetIdUpdateRequest(spreadsheetUrl); - gg.agit.konect.domain.club.model.SheetColumnMapping mapping = gg.agit.konect.domain.club.model.SheetColumnMapping.defaultMapping(); + SheetColumnMapping mapping = SheetColumnMapping.defaultMapping(); SheetHeaderMapper.SheetAnalysisResult analysisResult = new SheetHeaderMapper.SheetAnalysisResult( mapping, null, null ); - given(clubRepository.getById(clubId)).willReturn(club); + given(clubRepository.existsById(clubId)).willReturn(true); given(sheetHeaderMapper.analyzeAllSheets("test-sheet-id")).willReturn(analysisResult); - given(objectMapper.writeValueAsString(analysisResult.memberListMapping().toMap())).willReturn("{}"); // when clubMemberSheetService.updateSheetId(clubId, requesterId, request); @@ -112,12 +112,31 @@ void updateSheetIdWorksNormally() throws JsonProcessingException { // then verify(clubPermissionValidator).validateManagerAccess(clubId, requesterId); verify(sheetHeaderMapper).analyzeAllSheets("test-sheet-id"); - assertThat(club.getGoogleSheetId()).isEqualTo("test-sheet-id"); - assertThat(club.getSheetColumnMapping()).isEqualTo("{}"); + verify(clubSheetRegistrationService).updateSheetRegistration(clubId, "test-sheet-id", analysisResult); + } + + @Test + @DisplayName("updateSheetId는 동아리가 없으면 외부 분석을 호출하지 않는다") + void updateSheetIdThrowsNotFoundClubBeforeSheetAnalysis() { + // given + Integer clubId = 1; + Integer requesterId = 2; + String spreadsheetUrl = "invalid-sheet-url"; + ClubSheetIdUpdateRequest request = new ClubSheetIdUpdateRequest(spreadsheetUrl); + + given(clubRepository.existsById(clubId)).willReturn(false); + + // when & then + assertThatThrownBy(() -> clubMemberSheetService.updateSheetId(clubId, requesterId, request)) + .isInstanceOf(CustomException.class) + .satisfies(exception -> assertThat(((CustomException)exception).getErrorCode()).isEqualTo( + ApiResponseCode.NOT_FOUND_CLUB)); + + verifyNoInteractions(clubPermissionValidator, sheetHeaderMapper, clubSheetRegistrationService); } @Test - @DisplayName("syncMembersToSheet는 sheetId가 null인 경우 NOT_FOUND_CLUB_SHEET_ID 예외를 던진다") + @DisplayName("syncMembersToSheet는 sheetId가 null이면 예외를 던진다") void syncMembersToSheetThrowsNotFoundClubSheetIdWhenSheetIdIsNull() { // given Integer clubId = 1; @@ -139,7 +158,7 @@ void syncMembersToSheetThrowsNotFoundClubSheetIdWhenSheetIdIsNull() { } @Test - @DisplayName("syncMembersToSheet는 sheetId가 blank인 경우 NOT_FOUND_CLUB_SHEET_ID 예외를 던진다") + @DisplayName("syncMembersToSheet는 sheetId가 blank이면 예외를 던진다") void syncMembersToSheetThrowsNotFoundClubSheetIdWhenSheetIdIsBlank() { // given Integer clubId = 1; @@ -162,7 +181,7 @@ void syncMembersToSheetThrowsNotFoundClubSheetIdWhenSheetIdIsBlank() { } @Test - @DisplayName("syncMembersToSheet는 빈 동아리(멤버 0명)에 대해 정상 동작한다") + @DisplayName("syncMembersToSheet는 빈 동아리도 정상 처리한다") void syncMembersToSheetHandlesEmptyClub() { // given Integer clubId = 1; @@ -192,13 +211,12 @@ void syncMembersToSheetHandlesEmptyClub() { } @Test - @DisplayName("updateSheetId는 null memberListMapping 분석 결과 시 NullPointerException이 발생한다") - void updateSheetIdThrowsNpeWhenMemberListMappingIsNull() throws JsonProcessingException { + @DisplayName("updateSheetId는 null memberListMapping 분석 결과를 등록 서비스로 전달한다") + void updateSheetIdDelegatesNullMemberListMapping() { // given Integer clubId = 1; Integer requesterId = 2; String spreadsheetUrl = "https://docs.google.com/spreadsheets/d/test-sheet-id/edit"; - Club club = ClubFixture.create(UniversityFixture.create()); ClubSheetIdUpdateRequest request = new ClubSheetIdUpdateRequest(spreadsheetUrl); SheetHeaderMapper.SheetAnalysisResult analysisResult = new SheetHeaderMapper.SheetAnalysisResult( null, @@ -206,11 +224,17 @@ void updateSheetIdThrowsNpeWhenMemberListMappingIsNull() throws JsonProcessingEx null ); - given(clubRepository.getById(clubId)).willReturn(club); + given(clubRepository.existsById(clubId)).willReturn(true); given(sheetHeaderMapper.analyzeAllSheets("test-sheet-id")).willReturn(analysisResult); - // when & then - assertThatThrownBy(() -> clubMemberSheetService.updateSheetId(clubId, requesterId, request)) - .isInstanceOf(NullPointerException.class); + // when + clubMemberSheetService.updateSheetId(clubId, requesterId, request); + + // then + verify(clubSheetRegistrationService).updateSheetRegistration( + eq(clubId), + eq("test-sheet-id"), + eq(analysisResult) + ); } } diff --git a/src/test/java/gg/agit/konect/unit/domain/club/service/ClubSheetRegistrationServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/club/service/ClubSheetRegistrationServiceTest.java new file mode 100644 index 000000000..046c35b85 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/club/service/ClubSheetRegistrationServiceTest.java @@ -0,0 +1,138 @@ +package gg.agit.konect.unit.domain.club.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import gg.agit.konect.domain.club.model.SheetColumnMapping; +import gg.agit.konect.domain.club.repository.ClubRepository; +import gg.agit.konect.domain.club.service.ClubSheetRegistrationService; +import gg.agit.konect.domain.club.service.SheetHeaderMapper; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; +import gg.agit.konect.support.ServiceTestSupport; + +class ClubSheetRegistrationServiceTest extends ServiceTestSupport { + + @Mock + private ClubRepository clubRepository; + + @Mock + private ObjectMapper objectMapper; + + @InjectMocks + private ClubSheetRegistrationService clubSheetRegistrationService; + + @Test + @DisplayName("시트 등록 정보를 트랜잭션 서비스에서 저장한다") + void updateSheetRegistrationUpdatesClubSheetInfo() throws Exception { + // given + Integer clubId = 1; + String spreadsheetId = "spreadsheet-id"; + SheetColumnMapping mapping = SheetColumnMapping.defaultMapping(); + SheetHeaderMapper.SheetAnalysisResult analysisResult = new SheetHeaderMapper.SheetAnalysisResult( + mapping, + null, + null + ); + + given(objectMapper.writeValueAsString(mapping.toMap())).willReturn("{}"); + given(clubRepository.updateSheetRegistration(clubId, spreadsheetId, "{}")).willReturn(1); + + // when + clubSheetRegistrationService.updateSheetRegistration(clubId, spreadsheetId, analysisResult); + + // then + verify(clubRepository).updateSheetRegistration(clubId, spreadsheetId, "{}"); + } + + @Test + @DisplayName("회원 목록 매핑이 null이면 시트 정보를 저장하지 않는다") + void updateSheetRegistrationThrowsWhenMemberListMappingIsNull() { + // given + Integer clubId = 1; + String spreadsheetId = "spreadsheet-id"; + SheetHeaderMapper.SheetAnalysisResult analysisResult = new SheetHeaderMapper.SheetAnalysisResult( + null, + null, + null + ); + + // when & then + assertThatThrownBy(() -> clubSheetRegistrationService.updateSheetRegistration( + clubId, + spreadsheetId, + analysisResult + )) + .isInstanceOf(CustomException.class) + .satisfies(exception -> assertThat(((CustomException)exception).getErrorCode()).isEqualTo( + ApiResponseCode.CLUB_SHEET_ANALYSIS_REQUIRED)); + + verify(clubRepository, never()).updateSheetRegistration(clubId, spreadsheetId, null); + } + + @Test + @DisplayName("매핑 직렬화에 실패하면 시트 정보를 저장하지 않는다") + void updateSheetRegistrationThrowsWhenMappingSerializationFails() throws Exception { + // given + Integer clubId = 1; + String spreadsheetId = "spreadsheet-id"; + SheetColumnMapping mapping = SheetColumnMapping.defaultMapping(); + SheetHeaderMapper.SheetAnalysisResult analysisResult = new SheetHeaderMapper.SheetAnalysisResult( + mapping, + null, + null + ); + + given(objectMapper.writeValueAsString(mapping.toMap())).willThrow(new JsonProcessingException("boom") {}); + + // when & then + assertThatThrownBy(() -> clubSheetRegistrationService.updateSheetRegistration( + clubId, + spreadsheetId, + analysisResult + )) + .isInstanceOf(CustomException.class) + .satisfies(exception -> assertThat(((CustomException)exception).getErrorCode()).isEqualTo( + ApiResponseCode.FAILED_SYNC_GOOGLE_SHEET)); + + verify(clubRepository, never()).updateSheetRegistration(clubId, spreadsheetId, null); + } + + @Test + @DisplayName("시트 등록 update 대상 동아리가 없으면 NOT_FOUND_CLUB 예외를 던진다") + void updateSheetRegistrationThrowsWhenClubIsMissing() throws Exception { + // given + Integer clubId = 1; + String spreadsheetId = "spreadsheet-id"; + SheetColumnMapping mapping = SheetColumnMapping.defaultMapping(); + SheetHeaderMapper.SheetAnalysisResult analysisResult = new SheetHeaderMapper.SheetAnalysisResult( + mapping, + null, + null + ); + + given(objectMapper.writeValueAsString(mapping.toMap())).willReturn("{}"); + given(clubRepository.updateSheetRegistration(clubId, spreadsheetId, "{}")).willReturn(0); + + // when & then + assertThatThrownBy(() -> clubSheetRegistrationService.updateSheetRegistration( + clubId, + spreadsheetId, + analysisResult + )) + .isInstanceOf(CustomException.class) + .satisfies(exception -> assertThat(((CustomException)exception).getErrorCode()).isEqualTo( + ApiResponseCode.NOT_FOUND_CLUB)); + } +} From 12fc544f07139a07428afafc7e1f204eb9a8eb7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:27:15 +0900 Subject: [PATCH 31/50] =?UTF-8?q?refactor:=20=EB=A9=94=EC=8B=9C=EC=A7=80?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B3=84?= =?UTF-8?q?=EC=82=B0=20=EC=B1=85=EC=9E=84=20=EB=B6=84=EB=A6=AC=20(#593)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 메시지 조회 페이지 계산 책임 분리 - ChatService에 남아 있던 messageId 접근 검증과 페이지 계산을 별도 서비스로 분리 - 권한 없음과 메시지 미존재를 같은 404로 처리하는 조회 정책을 한곳에 모음 - 기존 ChatService 단위 테스트가 실제 resolver를 사용하도록 구성해 기존 정책 검증을 유지 * test: 메시지 페이지 계산 커버리지 보강 - ChatMessagePageResolver의 방 타입별 접근 검증과 페이지 계산 분기를 단위 테스트로 보강 - direct visibleMessageFrom, system admin 문의방, club membership 예외 변환 정책을 직접 검증 - messageId 조회 전에 권한 없는 요청을 차단하는 오라클 방지 흐름을 유지 * refactor: SYSTEM_ADMIN 방 판별 책임 공통화 - 리뷰에서 지적된 ChatMessagePageResolver의 중복 멤버십 조회를 제거하기 위해 접근 검증 결과를 재사용 - SYSTEM_ADMIN 방 판별과 시스템 관리자 멤버 선택을 공용 서비스로 분리해 ChatService와 MembershipService의 중복 기준을 제거 - 관련 단위 테스트를 갱신하고 공용 서비스 테스트를 추가해 문의방 조회 정책을 유지 * refactor: 문의방 판별 조회 비용 축소 - SYSTEM_ADMIN 방 판별은 멤버 목록 조회 대신 존재 여부 쿼리로 처리해 불필요한 로딩을 줄임 - direct room 멤버십이 이미 확인된 경우 SYSTEM_ADMIN 방 판별을 건너뛰어 일반 admin 조회 경로의 추가 쿼리를 방지 - 동일 예외만 던지던 직접방 멤버 조회 분기를 단순화해 조건 드리프트 여지를 제거 * refactor: 문의방 접근 컨텍스트 표현 정리 - direct room 멤버가 없는 admin 문의방 경로에서 빈 멤버십 상태를 명시해 조건 추적을 줄임 - 이미 검증된 SYSTEM_ADMIN 방 접근 여부만 컨텍스트에 담아 기존 조회 정책은 그대로 유지 --- .../chat/service/ChatMessagePageResolver.java | 120 +++++++ .../service/ChatRoomMembershipService.java | 14 +- .../service/ChatRoomSystemAdminService.java | 31 ++ .../domain/chat/service/ChatService.java | 114 +------ .../service/ChatMessagePageResolverTest.java | 310 ++++++++++++++++++ .../ChatRoomMembershipServiceTest.java | 10 +- .../ChatRoomSystemAdminServiceTest.java | 60 ++++ .../domain/chat/service/ChatServiceTest.java | 58 +++- 8 files changed, 582 insertions(+), 135 deletions(-) create mode 100644 src/main/java/gg/agit/konect/domain/chat/service/ChatMessagePageResolver.java create mode 100644 src/main/java/gg/agit/konect/domain/chat/service/ChatRoomSystemAdminService.java create mode 100644 src/test/java/gg/agit/konect/unit/domain/chat/service/ChatMessagePageResolverTest.java create mode 100644 src/test/java/gg/agit/konect/unit/domain/chat/service/ChatRoomSystemAdminServiceTest.java diff --git a/src/main/java/gg/agit/konect/domain/chat/service/ChatMessagePageResolver.java b/src/main/java/gg/agit/konect/domain/chat/service/ChatMessagePageResolver.java new file mode 100644 index 000000000..3acd7187c --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/chat/service/ChatMessagePageResolver.java @@ -0,0 +1,120 @@ +package gg.agit.konect.domain.chat.service; + +import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_CHAT_ROOM; +import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_CLUB_MEMBER; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import gg.agit.konect.domain.chat.model.ChatMessage; +import gg.agit.konect.domain.chat.model.ChatRoom; +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 gg.agit.konect.domain.club.repository.ClubMemberRepository; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.global.exception.CustomException; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ChatMessagePageResolver { + + private final ChatMessageRepository chatMessageRepository; + private final ChatRoomMemberRepository chatRoomMemberRepository; + private final ClubMemberRepository clubMemberRepository; + private final ChatRoomSystemAdminService chatRoomSystemAdminService; + + public int resolvePageForMessage( + Integer roomId, + Integer messageId, + ChatRoom room, + User user, + int limit + ) { + AccessContext accessContext = ensureMessageLookupAccess(room, user); + + ChatMessage targetMessage = chatMessageRepository.findByIdWithChatRoom(messageId) + .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); + + if (!targetMessage.getChatRoom().getId().equals(roomId)) { + throw CustomException.of(NOT_FOUND_CHAT_ROOM); + } + + LocalDateTime visibleMessageFrom = resolveVisibleMessageFrom(room, user, accessContext); + if (visibleMessageFrom != null && !targetMessage.getCreatedAt().isAfter(visibleMessageFrom)) { + throw CustomException.of(NOT_FOUND_CHAT_ROOM); + } + + long newerCount = chatMessageRepository.countNewerMessagesByChatRoomId( + roomId, messageId, targetMessage.getCreatedAt(), visibleMessageFrom + ); + return (int)(newerCount / limit) + 1; + } + + /** + * messageId 조회 전 방 접근 권한을 검증한다. + * 권한 없음과 메시지 미존재를 구분할 수 없게 NOT_FOUND_CHAT_ROOM으로 통일한다. + */ + private AccessContext ensureMessageLookupAccess(ChatRoom room, User user) { + if (room.isDirectRoom()) { + Optional member = chatRoomMemberRepository + .findByChatRoomIdAndUserId(room.getId(), user.getId()); + if (member.isPresent()) { + return new AccessContext(member, false); + } + + boolean isAdminViewingSystemRoom = user.isAdmin() + && chatRoomSystemAdminService.isSystemAdminRoom(room.getId()); + if (!isAdminViewingSystemRoom) { + throw CustomException.of(NOT_FOUND_CHAT_ROOM); + } + return new AccessContext(Optional.empty(), true); + } + + if (room.isClubGroupRoom()) { + try { + clubMemberRepository.getByClubIdAndUserId(room.getClub().getId(), user.getId()); + } catch (CustomException e) { + if (e.getErrorCode() == NOT_FOUND_CLUB_MEMBER) { + throw CustomException.of(NOT_FOUND_CHAT_ROOM); + } + throw e; + } + return AccessContext.none(); + } + + ChatRoomMember member = chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), user.getId()) + .filter(roomMember -> !roomMember.hasLeft()) + .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); + return new AccessContext(Optional.of(member), false); + } + + private LocalDateTime resolveVisibleMessageFrom(ChatRoom room, User user, AccessContext accessContext) { + if (!room.isDirectRoom()) { + return null; + } + + if (user.isAdmin() && accessContext.isAdminViewingSystemRoom()) { + List members = chatRoomMemberRepository.findByChatRoomId(room.getId()); + ChatRoomMember systemAdminMember = chatRoomSystemAdminService.findSystemAdminMember(members); + return systemAdminMember != null ? systemAdminMember.getVisibleMessageFrom() : null; + } + + return accessContext.member() + .map(ChatRoomMember::getVisibleMessageFrom) + .orElse(null); + } + + private record AccessContext(Optional member, boolean isAdminViewingSystemRoom) { + + private static AccessContext none() { + return new AccessContext(Optional.empty(), false); + } + } +} diff --git a/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMembershipService.java b/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMembershipService.java index 8d8687c36..ee6b3211a 100644 --- a/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMembershipService.java +++ b/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMembershipService.java @@ -41,6 +41,7 @@ public class ChatRoomMembershipService { private final ChatRoomMemberRepository chatRoomMemberRepository; private final ClubMemberRepository clubMemberRepository; private final UserRepository userRepository; + private final ChatRoomSystemAdminService chatRoomSystemAdminService; @Transactional(readOnly = true) public ChatRoomMembersResponse getChatRoomMembers(Integer chatRoomId, Integer currentUserId) { @@ -63,7 +64,7 @@ public ChatRoomMembersResponse getChatRoomMembers(Integer chatRoomId, Integer cu private void validateMembership(ChatRoom chatRoom, User currentUser) { // 어드민은 시스템 어드민 방의 멤버를 조회할 수 있음 - if (currentUser.isAdmin() && isSystemAdminRoom(chatRoom.getId())) { + if (currentUser.isAdmin() && chatRoomSystemAdminService.isSystemAdminRoom(chatRoom.getId())) { return; } @@ -110,7 +111,7 @@ public void updateLastReadAt(Integer roomId, Integer userId, LocalDateTime readA @Transactional(propagation = Propagation.REQUIRES_NEW) public void updateDirectRoomLastReadAt(Integer roomId, User user, LocalDateTime readAt, ChatRoom room) { // 어드민이 SYSTEM_ADMIN 방의 메시지를 읽으면 SYSTEM_ADMIN의 lastReadAt을 업데이트 - if (user.isAdmin() && isSystemAdminRoom(roomId)) { + if (user.isAdmin() && chatRoomSystemAdminService.isSystemAdminRoom(roomId)) { chatRoomMemberRepository.updateLastReadAtIfOlder(roomId, SYSTEM_ADMIN_ID, readAt); return; } @@ -176,20 +177,13 @@ private void ensureDirectRoomMemberExists(ChatRoom room, User user, LocalDateTim // 어드민은 SYSTEM_ADMIN 방의 메시지를 조회할 수 있지만, 멤버로 추가되지는 않는다 // (멤버가 추가되면 findByTwoUsers에서 해당 방을 찾지 못해 채팅방이 중복 생성됨) - if (user.isAdmin() && isSystemAdminRoom(room.getId())) { + if (user.isAdmin() && chatRoomSystemAdminService.isSystemAdminRoom(room.getId())) { return; } throw CustomException.of(FORBIDDEN_CHAT_ROOM_ACCESS); } - private boolean isSystemAdminRoom(Integer roomId) { - List memberIds = chatRoomMemberRepository.findRoomMemberIdsByChatRoomIds(List.of(roomId)); - return memberIds.stream() - .map(row -> (Integer)row[1]) - .anyMatch(userId -> userId.equals(SYSTEM_ADMIN_ID)); - } - private boolean isDuplicateKeyException(DataIntegrityViolationException e) { if (e instanceof DuplicateKeyException) { return true; diff --git a/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomSystemAdminService.java b/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomSystemAdminService.java new file mode 100644 index 000000000..f7116d8f7 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomSystemAdminService.java @@ -0,0 +1,31 @@ +package gg.agit.konect.domain.chat.service; + +import static gg.agit.konect.domain.chat.service.ChatRoomMembershipService.SYSTEM_ADMIN_ID; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import gg.agit.konect.domain.chat.model.ChatRoomMember; +import gg.agit.konect.domain.chat.repository.ChatRoomMemberRepository; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ChatRoomSystemAdminService { + + private final ChatRoomMemberRepository chatRoomMemberRepository; + + public boolean isSystemAdminRoom(Integer roomId) { + return chatRoomMemberRepository.existsByChatRoomIdAndUserId(roomId, SYSTEM_ADMIN_ID); + } + + public ChatRoomMember findSystemAdminMember(List members) { + return members.stream() + .filter(member -> member.getUserId().equals(SYSTEM_ADMIN_ID)) + .findFirst() + .orElse(null); + } +} 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 d638ac7a1..9ba264b6e 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 @@ -82,6 +82,8 @@ public class ChatService { private final ChatRoomMembershipService chatRoomMembershipService; private final ChatRoomSummaryService chatRoomSummaryService; private final ChatSearchService chatSearchService; + private final ChatMessagePageResolver chatMessagePageResolver; + private final ChatRoomSystemAdminService chatRoomSystemAdminService; private final NotificationService notificationService; private final ApplicationEventPublisher eventPublisher; @@ -346,14 +348,14 @@ public ChatMessagePageResponse getMessages( User user = userRepository.getById(userId); if (messageId != null) { - ensureMessageLookupAccess(room, user, userId); - page = resolvePageForMessage(roomId, messageId, room, user, limit); + page = chatMessagePageResolver.resolvePageForMessage(roomId, messageId, room, user, limit); } LocalDateTime readAt = LocalDateTime.now(); if (room.isDirectRoom()) { - boolean isAdminViewingSystemRoom = user.isAdmin() && isSystemAdminRoom(room); + boolean isAdminViewingSystemRoom = user.isAdmin() + && chatRoomSystemAdminService.isSystemAdminRoom(room.getId()); if (isAdminViewingSystemRoom) { chatRoomMembershipService.updateLastReadAt(roomId, SYSTEM_ADMIN_ID, readAt); recordPresenceSafely(roomId, userId); @@ -406,7 +408,7 @@ public ChatMuteResponse toggleMute(Integer userId, Integer roomId) { } else if (room.isDirectRoom()) { // 어드민이 SYSTEM_ADMIN 방에 접근하는 경우는 멤버십 체크를 건너뜀 boolean isAdminAccessingSystemAdminRoom = user.isAdmin() - && isSystemAdminRoom(room); + && chatRoomSystemAdminService.isSystemAdminRoom(room.getId()); if (!isAdminAccessingSystemAdminRoom) { getAccessibleDirectRoomMember(room, user); } @@ -662,7 +664,7 @@ private ChatMessageDetailResponse sendDirectMessage( // 어드민이 SYSTEM_ADMIN 방에 메시지를 보내는 경우 boolean isAdminSendingToSystemAdminRoom = sender.isAdmin() - && isSystemAdminRoom(chatRoom); + && chatRoomSystemAdminService.isSystemAdminRoom(chatRoom.getId()); ChatRoomMember senderMember = null; boolean senderHadLeft = false; @@ -1036,7 +1038,7 @@ private void ensureDirectRoomRequester(ChatRoom room, User user, LocalDateTime j private boolean shouldSkipSystemAdminMembership(ChatRoom room, User user) { // 문의방은 SYSTEM_ADMIN + 일반 사용자 2인 구조를 전제로 재사용(findByTwoUsers)되므로, // 생성/재오픈 경로에서도 일반 ADMIN을 멤버로 추가하면 안 된다. - return user.isAdmin() && isSystemAdminRoom(room); + return user.isAdmin() && chatRoomSystemAdminService.isSystemAdminRoom(room.getId()); } private String normalizeCustomRoomName(String roomName) { @@ -1147,13 +1149,7 @@ private Map getRoomUnreadCountMap(List roomIds, Integ private ChatRoomMember getOrCreateDirectRoomMember(ChatRoom chatRoom, User user) { return chatRoomMemberRepository.findByChatRoomIdAndUserId(chatRoom.getId(), user.getId()) - .orElseGet(() -> { - // 어드민은 SYSTEM_ADMIN 방에 멤버로 추가되지 않음 - if (user.isAdmin() && isSystemAdminRoom(chatRoom)) { - throw CustomException.of(FORBIDDEN_CHAT_ROOM_ACCESS); - } - throw CustomException.of(FORBIDDEN_CHAT_ROOM_ACCESS); - }); + .orElseThrow(() -> CustomException.of(FORBIDDEN_CHAT_ROOM_ACCESS)); } private ChatRoomMember getAccessibleDirectRoomMember(ChatRoom chatRoom, User user) { @@ -1169,7 +1165,7 @@ private LocalDateTime prepareDirectRoomAccess(ChatRoomMember member, ChatRoom ch } private LocalDateTime resolveAdminSystemRoomVisibleMessageFrom(List members) { - ChatRoomMember systemAdminMember = findRoomMember(members, SYSTEM_ADMIN_ID); + ChatRoomMember systemAdminMember = chatRoomSystemAdminService.findSystemAdminMember(members); return systemAdminMember != null ? systemAdminMember.getVisibleMessageFrom() : null; } @@ -1189,96 +1185,6 @@ private void restoreDirectRoomIfVisible(ChatRoomMember member, ChatRoom chatRoom member.restoreDirectRoom(); } - private boolean isSystemAdminRoom(ChatRoom chatRoom) { - List memberIds = chatRoomMemberRepository.findRoomMemberIdsByChatRoomIds( - List.of(chatRoom.getId()) - ); - List userIds = memberIds.stream() - .map(row -> (Integer)row[1]) - .toList(); - - return userIds.contains(SYSTEM_ADMIN_ID); - } - - /** - * messageId 조회 전 방 접근 권한을 검증한다. - * 권한 없음과 메시지 미존재를 구분할 수 없게 NOT_FOUND_CHAT_ROOM으로 통일하여 - * 메시지 존재 여부 오라클을 방지한다. - */ - private void ensureMessageLookupAccess(ChatRoom room, User user, Integer userId) { - if (room.isDirectRoom()) { - boolean isMember = chatRoomMemberRepository - .findByChatRoomIdAndUserId(room.getId(), userId) - .isPresent(); - if (!isMember && !(user.isAdmin() && isSystemAdminRoom(room))) { - throw CustomException.of(NOT_FOUND_CHAT_ROOM); - } - } else if (room.isClubGroupRoom()) { - try { - clubMemberRepository.getByClubIdAndUserId(room.getClub().getId(), userId); - } catch (CustomException e) { - // 동아리 멤버십 없음만 404로 변환, 다른 예외는 그대로 전파 - if (e.getErrorCode() == NOT_FOUND_CLUB_MEMBER) { - throw CustomException.of(NOT_FOUND_CHAT_ROOM); - } - throw e; - } - } else { - chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), userId) - .filter(member -> !member.hasLeft()) - .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); - } - } - - /** - * messageId가 가리키는 메시지가 포함된 페이지 번호를 계산한다. - * 가시성 검증 및 정보 누출 방지를 위해 동일한 에러 코드를 사용한다. - */ - private int resolvePageForMessage( - Integer roomId, Integer messageId, ChatRoom room, User user, int limit - ) { - ChatMessage targetMessage = chatMessageRepository.findByIdWithChatRoom(messageId) - .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); - - // 정보 누출 방지를 위해 동일한 에러 코드 사용 - if (!targetMessage.getChatRoom().getId().equals(roomId)) { - throw CustomException.of(NOT_FOUND_CHAT_ROOM); - } - - LocalDateTime visibleMessageFrom = resolveVisibleMessageFromPure(room, user); - - if (visibleMessageFrom != null && !targetMessage.getCreatedAt().isAfter(visibleMessageFrom)) { - throw CustomException.of(NOT_FOUND_CHAT_ROOM); - } - - // NOTE: count와 fetch 사이에 새 메시지가 삽입될 수 있으나, - // 호출부(getMessages)에서 응답에 타겟 메시지가 없으면 1회 재계산함 - long newerCount = chatMessageRepository.countNewerMessagesByChatRoomId( - roomId, messageId, targetMessage.getCreatedAt(), visibleMessageFrom - ); - return (int)(newerCount / limit) + 1; - } - - /** - * 채팅방 타입에 따른 메시지 가시성 기준 시간을 조회한다. - * 기존 getMessages() 흐름의 가시성 로직과 동일한 값을 반환하되, - * 방 복원 등 부수효과는 발생시키지 않는다. - */ - private LocalDateTime resolveVisibleMessageFromPure(ChatRoom room, User user) { - if (!room.isDirectRoom()) { - return null; - } - - if (user.isAdmin() && isSystemAdminRoom(room)) { - List members = chatRoomMemberRepository.findByChatRoomId(room.getId()); - return resolveAdminSystemRoomVisibleMessageFrom(members); - } - - return chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), user.getId()) - .map(ChatRoomMember::getVisibleMessageFrom) - .orElse(null); - } - private boolean shouldDisplayAsOwnMessage( User currentUser, ChatMessage message, diff --git a/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatMessagePageResolverTest.java b/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatMessagePageResolverTest.java new file mode 100644 index 000000000..57e4d0e96 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatMessagePageResolverTest.java @@ -0,0 +1,310 @@ +package gg.agit.konect.unit.domain.chat.service; + +import static gg.agit.konect.domain.chat.service.ChatRoomMembershipService.SYSTEM_ADMIN_ID; +import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_CHAT_ROOM; +import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_CLUB_MEMBER; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import org.assertj.core.api.ThrowableAssert.ThrowingCallable; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.test.util.ReflectionTestUtils; + +import gg.agit.konect.domain.chat.enums.ChatType; +import gg.agit.konect.domain.chat.model.ChatMessage; +import gg.agit.konect.domain.chat.model.ChatRoom; +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 gg.agit.konect.domain.chat.service.ChatMessagePageResolver; +import gg.agit.konect.domain.chat.service.ChatRoomSystemAdminService; +import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.club.repository.ClubMemberRepository; +import gg.agit.konect.domain.user.enums.UserRole; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; +import gg.agit.konect.support.ServiceTestSupport; +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; + +class ChatMessagePageResolverTest extends ServiceTestSupport { + + private static final LocalDateTime BASE_TIME = LocalDateTime.of(2026, 4, 27, 10, 0); + private static final LocalDateTime TARGET_TIME = LocalDateTime.of(2026, 4, 27, 14, 0); + + @Mock + private ChatMessageRepository chatMessageRepository; + + @Mock + private ChatRoomMemberRepository chatRoomMemberRepository; + + @Mock + private ClubMemberRepository clubMemberRepository; + + @Mock + private ChatRoomSystemAdminService chatRoomSystemAdminService; + + @InjectMocks + private ChatMessagePageResolver chatMessagePageResolver; + + @Test + @DisplayName("group room 접근 권한 확인 후 messageId 페이지를 계산한다") + void resolvePageForGroupRoomMessage() { + User user = user(10, UserRole.USER); + ChatRoom room = room(1, ChatType.GROUP); + ChatMessage message = message(50, room, user, TARGET_TIME); + stubActiveMember(room, user); + stubTargetMessage(message); + given(chatMessageRepository.countNewerMessagesByChatRoomId(room.getId(), message.getId(), TARGET_TIME, null)) + .willReturn(25L); + + int page = chatMessagePageResolver.resolvePageForMessage(room.getId(), message.getId(), room, user, 20); + + assertThat(page).isEqualTo(2); + } + + @Test + @DisplayName("group room 비회원이면 messageId 조회 전에 404를 던진다") + void resolvePageForGroupRoomRejectsNonMemberBeforeMessageLookup() { + User user = user(99, UserRole.USER); + ChatRoom room = room(1, ChatType.GROUP); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), user.getId())) + .willReturn(Optional.empty()); + + assertNotFound(() -> chatMessagePageResolver.resolvePageForMessage(room.getId(), 50, room, user, 20)); + verify(chatMessageRepository, never()).findByIdWithChatRoom(50); + } + + @Test + @DisplayName("group room에서 나간 멤버를 404로 거부한다") + void resolvePageForGroupRoomRejectsLeftMember() { + User user = user(10, UserRole.USER); + ChatRoom room = room(1, ChatType.GROUP); + ChatRoomMember member = member(room, user); + ReflectionTestUtils.setField(member, "leftAt", BASE_TIME.plusHours(1)); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), user.getId())) + .willReturn(Optional.of(member)); + + assertNotFound(() -> chatMessagePageResolver.resolvePageForMessage(room.getId(), 50, room, user, 20)); + verify(chatMessageRepository, never()).findByIdWithChatRoom(50); + } + + @Test + @DisplayName("direct room은 visibleMessageFrom 이후 메시지만 페이지 계산한다") + void resolvePageForDirectRoomUsesVisibleMessageFrom() { + User user = user(10, UserRole.USER); + ChatRoom room = room(1, ChatType.DIRECT); + LocalDateTime visibleMessageFrom = BASE_TIME.plusHours(2); + ChatMessage message = message(50, room, user(20, UserRole.USER), BASE_TIME.plusHours(3)); + stubDirectMember(room, user, visibleMessageFrom); + stubTargetMessage(message); + given(chatMessageRepository.countNewerMessagesByChatRoomId( + room.getId(), message.getId(), message.getCreatedAt(), visibleMessageFrom + )).willReturn(0L); + + int page = chatMessagePageResolver.resolvePageForMessage(room.getId(), message.getId(), room, user, 20); + + assertThat(page).isEqualTo(1); + } + + @Test + @DisplayName("direct room의 visibleMessageFrom 이전 messageId를 404로 숨긴다") + void resolvePageForDirectRoomRejectsHiddenMessage() { + User user = user(10, UserRole.USER); + ChatRoom room = room(1, ChatType.DIRECT); + LocalDateTime visibleMessageFrom = BASE_TIME.plusHours(2); + ChatMessage message = message(50, room, user(20, UserRole.USER), BASE_TIME.plusHours(1)); + stubDirectMember(room, user, visibleMessageFrom); + stubTargetMessage(message); + + assertNotFound(() -> chatMessagePageResolver.resolvePageForMessage(room.getId(), 50, room, user, 20)); + verify(chatMessageRepository, never()).countNewerMessagesByChatRoomId( + room.getId(), message.getId(), message.getCreatedAt(), visibleMessageFrom + ); + } + + @Test + @DisplayName("direct room 비회원 일반 사용자를 404로 거부한다") + void resolvePageForDirectRoomRejectsNonMemberUser() { + User user = user(10, UserRole.USER); + ChatRoom room = room(1, ChatType.DIRECT); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), user.getId())) + .willReturn(Optional.empty()); + + assertNotFound(() -> chatMessagePageResolver.resolvePageForMessage(room.getId(), 50, room, user, 20)); + verify(chatMessageRepository, never()).findByIdWithChatRoom(50); + } + + @Test + @DisplayName("admin의 system admin 방 조회는 SYSTEM_ADMIN 가시 범위를 사용한다") + void resolvePageForAdminSystemRoomUsesSystemAdminVisibility() { + User admin = user(99, UserRole.ADMIN); + ChatRoom room = room(1, ChatType.DIRECT); + LocalDateTime visibleMessageFrom = BASE_TIME.plusHours(2); + ChatMessage message = message(50, room, admin, BASE_TIME.plusHours(3)); + stubSystemAdminRoom(room); + stubSystemAdminMember(room, visibleMessageFrom); + stubTargetMessage(message); + given(chatMessageRepository.countNewerMessagesByChatRoomId( + room.getId(), message.getId(), message.getCreatedAt(), visibleMessageFrom + )).willReturn(0L); + + int page = chatMessagePageResolver.resolvePageForMessage(room.getId(), message.getId(), room, admin, 20); + + assertThat(page).isEqualTo(1); + } + + @Test + @DisplayName("system admin 멤버가 없으면 admin 조회 가시 범위를 null로 계산한다") + void resolvePageForAdminSystemRoomAllowsMissingSystemAdminMember() { + User admin = user(99, UserRole.ADMIN); + ChatRoom room = room(1, ChatType.DIRECT); + ChatMessage message = message(50, room, admin, BASE_TIME.plusHours(3)); + stubSystemAdminRoom(room); + given(chatRoomMemberRepository.findByChatRoomId(room.getId())).willReturn(List.of()); + stubTargetMessage(message); + given(chatMessageRepository.countNewerMessagesByChatRoomId( + room.getId(), message.getId(), message.getCreatedAt(), null + )).willReturn(0L); + + int page = chatMessagePageResolver.resolvePageForMessage(room.getId(), message.getId(), room, admin, 20); + + assertThat(page).isEqualTo(1); + } + + @Test + @DisplayName("club group 동아리 멤버십 없음만 404로 변환한다") + void resolvePageForClubRoomConvertsMissingClubMemberToNotFoundRoom() { + User user = user(10, UserRole.USER); + ChatRoom room = clubRoom(1); + given(clubMemberRepository.getByClubIdAndUserId(room.getClub().getId(), user.getId())) + .willThrow(CustomException.of(NOT_FOUND_CLUB_MEMBER)); + + assertNotFound(() -> chatMessagePageResolver.resolvePageForMessage(room.getId(), 50, room, user, 20)); + verify(chatMessageRepository, never()).findByIdWithChatRoom(50); + } + + @Test + @DisplayName("club group 멤버십 조회 예외가 404가 아니면 그대로 전파한다") + void resolvePageForClubRoomPropagatesNonMembershipException() { + User user = user(10, UserRole.USER); + ChatRoom room = clubRoom(1); + CustomException exception = CustomException.of(ApiResponseCode.NOT_FOUND_USER); + given(clubMemberRepository.getByClubIdAndUserId(room.getClub().getId(), user.getId())) + .willThrow(exception); + + assertThatThrownBy(() -> chatMessagePageResolver.resolvePageForMessage(room.getId(), 50, room, user, 20)) + .isSameAs(exception); + verify(chatMessageRepository, never()).findByIdWithChatRoom(50); + } + + @Test + @DisplayName("club group 접근 가능 사용자의 messageId 페이지를 계산한다") + void resolvePageForClubRoomMessage() { + User user = user(10, UserRole.USER); + ChatRoom room = clubRoom(1); + ChatMessage message = message(50, room, user, TARGET_TIME); + given(clubMemberRepository.getByClubIdAndUserId(room.getClub().getId(), user.getId())) + .willReturn(ClubMemberFixture.createMember(room.getClub(), user)); + stubTargetMessage(message); + given(chatMessageRepository.countNewerMessagesByChatRoomId(room.getId(), message.getId(), TARGET_TIME, null)) + .willReturn(0L); + + int page = chatMessagePageResolver.resolvePageForMessage(room.getId(), message.getId(), room, user, 20); + + assertThat(page).isEqualTo(1); + } + + @Test + @DisplayName("다른 방의 messageId를 404로 숨긴다") + void resolvePageForMessageRejectsMessageFromOtherRoom() { + User user = user(10, UserRole.USER); + ChatRoom room = room(1, ChatType.GROUP); + ChatMessage message = message(50, room(2, ChatType.GROUP), user, TARGET_TIME); + stubActiveMember(room, user); + stubTargetMessage(message); + + assertNotFound(() -> chatMessagePageResolver.resolvePageForMessage(room.getId(), 50, room, user, 20)); + } + + private void stubActiveMember(ChatRoom room, User user) { + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), user.getId())) + .willReturn(Optional.of(member(room, user))); + } + + private void stubDirectMember(ChatRoom room, User user, LocalDateTime visibleMessageFrom) { + ChatRoomMember member = member(room, user); + ReflectionTestUtils.setField(member, "visibleMessageFrom", visibleMessageFrom); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), user.getId())) + .willReturn(Optional.of(member)); + } + + private void stubSystemAdminRoom(ChatRoom room) { + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), 99)).willReturn(Optional.empty()); + given(chatRoomSystemAdminService.isSystemAdminRoom(room.getId())).willReturn(true); + } + + private void stubSystemAdminMember(ChatRoom room, LocalDateTime visibleMessageFrom) { + ChatRoomMember member = member(room, user(SYSTEM_ADMIN_ID, UserRole.ADMIN)); + ReflectionTestUtils.setField(member, "visibleMessageFrom", visibleMessageFrom); + given(chatRoomMemberRepository.findByChatRoomId(room.getId())).willReturn(List.of(member)); + given(chatRoomSystemAdminService.findSystemAdminMember(List.of(member))).willReturn(member); + } + + private void stubTargetMessage(ChatMessage message) { + given(chatMessageRepository.findByIdWithChatRoom(message.getId())).willReturn(Optional.of(message)); + } + + private User user(Integer id, UserRole role) { + return UserFixture.createUserWithId(UniversityFixture.createWithId(1), id, "사용자" + id, + "2024" + String.format("%04d", id), role); + } + + private ChatRoom room(Integer id, ChatType type) { + ChatRoom room = type == ChatType.DIRECT ? ChatRoom.directOf() : ChatRoom.groupOf(); + ReflectionTestUtils.setField(room, "id", id); + ReflectionTestUtils.setField(room, "createdAt", BASE_TIME); + return room; + } + + private ChatRoom clubRoom(Integer id) { + Club club = ClubFixture.createWithId(UniversityFixture.createWithId(1), 77, "BCSD"); + ChatRoom room = ChatRoom.clubGroupOf(club); + ReflectionTestUtils.setField(room, "id", id); + ReflectionTestUtils.setField(room, "createdAt", BASE_TIME); + return room; + } + + private ChatRoomMember member(ChatRoom room, User user) { + ChatRoomMember member = ChatRoomMember.of(room, user, BASE_TIME); + ReflectionTestUtils.setField(member, "createdAt", BASE_TIME); + return member; + } + + private ChatMessage message(Integer id, ChatRoom room, User sender, LocalDateTime createdAt) { + ChatMessage message = ChatMessage.of(room, sender, "메시지"); + ReflectionTestUtils.setField(message, "id", id); + ReflectionTestUtils.setField(message, "createdAt", createdAt); + return message; + } + + private void assertNotFound(ThrowingCallable callable) { + assertThatThrownBy(callable) + .isInstanceOf(CustomException.class) + .satisfies(exception -> + assertThat(((CustomException)exception).getErrorCode()).isEqualTo(NOT_FOUND_CHAT_ROOM)); + } +} diff --git a/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatRoomMembershipServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatRoomMembershipServiceTest.java index ff999145f..c8eed3436 100644 --- a/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatRoomMembershipServiceTest.java +++ b/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatRoomMembershipServiceTest.java @@ -32,6 +32,7 @@ import gg.agit.konect.domain.chat.repository.ChatRoomMemberRepository; import gg.agit.konect.domain.chat.repository.ChatRoomRepository; import gg.agit.konect.domain.chat.service.ChatRoomMembershipService; +import gg.agit.konect.domain.chat.service.ChatRoomSystemAdminService; import gg.agit.konect.domain.club.model.Club; import gg.agit.konect.domain.club.model.ClubMember; import gg.agit.konect.domain.club.repository.ClubMemberRepository; @@ -60,6 +61,9 @@ class ChatRoomMembershipServiceTest extends ServiceTestSupport { @Mock private UserRepository userRepository; + @Mock + private ChatRoomSystemAdminService chatRoomSystemAdminService; + @InjectMocks private ChatRoomMembershipService chatRoomMembershipService; @@ -316,8 +320,7 @@ void updateDirectRoomLastReadAtUpdatesSystemAdminForAdminReader() { User admin = createUser(99, "관리자", UserRole.ADMIN); ChatRoom room = createRoom(roomId, ChatType.DIRECT, LocalDateTime.of(2026, 4, 11, 10, 0)); LocalDateTime readAt = LocalDateTime.of(2026, 4, 11, 10, 5); - given(chatRoomMemberRepository.findRoomMemberIdsByChatRoomIds(List.of(roomId))) - .willReturn(List.of(new Object[] {roomId, SYSTEM_ADMIN_ID, readAt})); + given(chatRoomSystemAdminService.isSystemAdminRoom(roomId)).willReturn(true); // when chatRoomMembershipService.updateDirectRoomLastReadAt(roomId, admin, readAt, room); @@ -371,8 +374,7 @@ void updateDirectRoomLastReadAtSkipsAdminMembershipCreationInSystemAdminRoom() { User admin = createUser(99, "관리자", UserRole.ADMIN); ChatRoom room = createRoom(roomId, ChatType.DIRECT, LocalDateTime.of(2026, 4, 11, 10, 0)); LocalDateTime readAt = LocalDateTime.of(2026, 4, 11, 10, 5); - given(chatRoomMemberRepository.findRoomMemberIdsByChatRoomIds(List.of(roomId))) - .willReturn(List.of(new Object[] {roomId, SYSTEM_ADMIN_ID, readAt})); + given(chatRoomSystemAdminService.isSystemAdminRoom(roomId)).willReturn(true); // when chatRoomMembershipService.updateDirectRoomLastReadAt(roomId, admin, readAt, room); diff --git a/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatRoomSystemAdminServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatRoomSystemAdminServiceTest.java new file mode 100644 index 000000000..dacd4d7cd --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatRoomSystemAdminServiceTest.java @@ -0,0 +1,60 @@ +package gg.agit.konect.unit.domain.chat.service; + +import static gg.agit.konect.domain.chat.service.ChatRoomMembershipService.SYSTEM_ADMIN_ID; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; + +import java.time.LocalDateTime; +import java.util.List; + +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.model.ChatRoom; +import gg.agit.konect.domain.chat.model.ChatRoomMember; +import gg.agit.konect.domain.chat.repository.ChatRoomMemberRepository; +import gg.agit.konect.domain.chat.service.ChatRoomSystemAdminService; +import gg.agit.konect.domain.user.enums.UserRole; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.support.ServiceTestSupport; +import gg.agit.konect.support.fixture.UniversityFixture; +import gg.agit.konect.support.fixture.UserFixture; + +class ChatRoomSystemAdminServiceTest extends ServiceTestSupport { + + @Mock + private ChatRoomMemberRepository chatRoomMemberRepository; + + @InjectMocks + private ChatRoomSystemAdminService chatRoomSystemAdminService; + + @Test + @DisplayName("isSystemAdminRoom은 SYSTEM_ADMIN 멤버가 있으면 true를 반환한다") + void isSystemAdminRoomReturnsTrueWhenSystemAdminMemberExists() { + given(chatRoomMemberRepository.existsByChatRoomIdAndUserId(1, SYSTEM_ADMIN_ID)).willReturn(true); + + boolean result = chatRoomSystemAdminService.isSystemAdminRoom(1); + + assertThat(result).isTrue(); + } + + @Test + @DisplayName("findSystemAdminMember는 멤버 목록에서 SYSTEM_ADMIN 멤버를 반환한다") + void findSystemAdminMemberReturnsSystemAdminMember() { + ChatRoom room = ChatRoom.directOf(); + User systemAdmin = UserFixture.createUserWithId( + UniversityFixture.createWithId(1), + SYSTEM_ADMIN_ID, + "시스템관리자", + "20240001", + UserRole.ADMIN + ); + ChatRoomMember member = ChatRoomMember.of(room, systemAdmin, LocalDateTime.now()); + + ChatRoomMember result = chatRoomSystemAdminService.findSystemAdminMember(List.of(member)); + + assertThat(result).isSameAs(member); + } +} 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 d465f26bb..571223581 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 @@ -25,10 +25,10 @@ import java.util.Optional; import org.assertj.core.api.ThrowableAssert.ThrowingCallable; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; -import org.mockito.InjectMocks; import org.mockito.Mock; import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.PageImpl; @@ -55,6 +55,8 @@ import gg.agit.konect.domain.chat.service.ChatRoomMembershipService; import gg.agit.konect.domain.chat.service.ChatRoomSummaryService; import gg.agit.konect.domain.chat.service.ChatSearchService; +import gg.agit.konect.domain.chat.service.ChatMessagePageResolver; +import gg.agit.konect.domain.chat.service.ChatRoomSystemAdminService; import gg.agit.konect.domain.chat.service.ChatService; import gg.agit.konect.domain.club.model.Club; import gg.agit.konect.domain.club.model.ClubMember; @@ -112,15 +114,47 @@ class ChatServiceTest extends ServiceTestSupport { @Mock private ChatSearchService chatSearchService; + @Mock + private ChatRoomSystemAdminService chatRoomSystemAdminService; + @Mock private NotificationService notificationService; @Mock private ApplicationEventPublisher eventPublisher; - @InjectMocks + private ChatMessagePageResolver chatMessagePageResolver; + private ChatService chatService; + @BeforeEach + void setUp() { + chatMessagePageResolver = new ChatMessagePageResolver( + chatMessageRepository, + chatRoomMemberRepository, + clubMemberRepository, + chatRoomSystemAdminService + ); + chatService = new ChatService( + chatRoomRepository, + chatRoomQueryRepository, + chatMessageRepository, + chatRoomMemberRepository, + notificationMuteSettingRepository, + clubMemberRepository, + chatInviteQueryRepository, + userRepository, + chatPresenceService, + chatRoomMembershipService, + chatRoomSummaryService, + chatSearchService, + chatMessagePageResolver, + chatRoomSystemAdminService, + notificationService, + eventPublisher + ); + } + @Test @DisplayName("createOrGetChatRoom은 자기 자신과의 direct room 생성을 거부한다") void createOrGetChatRoomRejectsSelfChat() { @@ -193,11 +227,7 @@ void createOrGetChatRoomUsesSystemAdminRoomForAdminToUser() { .willReturn(Optional.empty()); given(chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), targetUserId)) .willReturn(Optional.empty()); - given(chatRoomMemberRepository.findRoomMemberIdsByChatRoomIds(List.of(room.getId()))) - .willReturn(List.of( - new Object[] {room.getId(), SYSTEM_ADMIN_ID, room.getCreatedAt()}, - new Object[] {room.getId(), targetUserId, room.getCreatedAt()} - )); + given(chatRoomSystemAdminService.isSystemAdminRoom(room.getId())).willReturn(true); // when ChatRoomResponse response = chatService.createOrGetChatRoom(adminUserId, @@ -781,11 +811,9 @@ void getMessagesReturnsAdminSystemRoomMessages() { given(chatRoomRepository.findById(systemAdminRoom.getId())).willReturn(Optional.of(systemAdminRoom)); given(userRepository.getById(adminId)).willReturn(admin); - given(chatRoomMemberRepository.findRoomMemberIdsByChatRoomIds(List.of(systemAdminRoom.getId()))) - .willReturn(List.of( - new Object[] {systemAdminRoom.getId(), SYSTEM_ADMIN_ID, systemAdminRoom.getCreatedAt()}, - new Object[] {systemAdminRoom.getId(), 20, systemAdminRoom.getCreatedAt()} - )); + given(chatRoomSystemAdminService.isSystemAdminRoom(systemAdminRoom.getId())).willReturn(true); + given(chatRoomSystemAdminService.findSystemAdminMember(List.of(systemAdminMember, targetMember))) + .willReturn(systemAdminMember); given(chatRoomMemberRepository.findByChatRoomId(systemAdminRoom.getId())) .willReturn(List.of(systemAdminMember, targetMember)); given(chatMessageRepository.findByChatRoomId(eq(systemAdminRoom.getId()), nullable(LocalDateTime.class), @@ -1023,11 +1051,7 @@ void sendMessageAdminBypassesMembershipInSystemAdminRoom() { given(chatRoomRepository.findById(systemAdminRoom.getId())).willReturn(Optional.of(systemAdminRoom)); given(userRepository.getById(adminId)).willReturn(admin); - given(chatRoomMemberRepository.findRoomMemberIdsByChatRoomIds(List.of(systemAdminRoom.getId()))) - .willReturn(List.of( - new Object[] {systemAdminRoom.getId(), SYSTEM_ADMIN_ID, systemAdminRoom.getCreatedAt()}, - new Object[] {systemAdminRoom.getId(), targetUserId, systemAdminRoom.getCreatedAt()} - )); + given(chatRoomSystemAdminService.isSystemAdminRoom(systemAdminRoom.getId())).willReturn(true); given(chatRoomMemberRepository.findByChatRoomId(systemAdminRoom.getId())) .willReturn(List.of(systemAdminMember, targetMember)); given(chatMessageRepository.save(any(ChatMessage.class))).willReturn(savedMessage); From 38eebb34f64ce9d004c20edc04c24fb64a965d82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Tue, 28 Apr 2026 09:12:14 +0900 Subject: [PATCH 32/50] =?UTF-8?q?refactor:=20direct=20room=20=EC=A0=91?= =?UTF-8?q?=EA=B7=BC=20=EB=B3=B5=EC=9B=90=20=EC=B1=85=EC=9E=84=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=20(#595)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: direct room 접근 복원 책임 분리 - direct room의 멤버 접근 검증과 나간 방 복원 판단을 전용 서비스로 분리해 ChatService의 조회/전송 흐름 부담을 낮춤 - visibleMessageFrom을 복원 전 기준으로 반환하는 기존 정책을 유지해 메시지 조회 가시 범위가 바뀌지 않도록 함 - 분리한 정책의 단위 테스트를 추가해 나간 방 복원과 접근 거부 조건을 고정 * test: direct room 접근 테스트 구성 단순화 - setUp에서만 쓰는 보조 서비스 인스턴스를 지역 변수로 좁혀 테스트 상태를 줄임 - 복원 테스트는 반환 객체보다 변경 대상 멤버 상태를 직접 검증해 의도를 명확히 함 * fix: direct room 복원 트랜잭션 경계 보장 - 메시지 조회는 lastReadAt 갱신과 direct room 복원을 수행하므로 readOnly 트랜잭션에서 제외 - direct room 접근 서비스가 나간 방 복원 상태를 변경할 수 있음을 트랜잭션 선언에 반영 - 조회 흐름에서 leftAt 해제가 영속화되지 않는 회귀를 방지 * refactor: sendDirectMessage에서 senderHadLeft dead code 제거 Agent-Logs-Url: https://github.com/BCSDLab/KONECT_BACK_END/sessions/91332fd5-a703-4c88-a058-cdb3b9ed4ea8 Co-authored-by: dh2906 <64298482+dh2906@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- .../service/ChatDirectRoomAccessService.java | 57 ++++++++ .../domain/chat/service/ChatService.java | 56 ++------ .../ChatDirectRoomAccessServiceTest.java | 125 ++++++++++++++++++ .../domain/chat/service/ChatServiceTest.java | 10 +- 4 files changed, 196 insertions(+), 52 deletions(-) create mode 100644 src/main/java/gg/agit/konect/domain/chat/service/ChatDirectRoomAccessService.java create mode 100644 src/test/java/gg/agit/konect/unit/domain/chat/service/ChatDirectRoomAccessServiceTest.java diff --git a/src/main/java/gg/agit/konect/domain/chat/service/ChatDirectRoomAccessService.java b/src/main/java/gg/agit/konect/domain/chat/service/ChatDirectRoomAccessService.java new file mode 100644 index 000000000..cfc663571 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/chat/service/ChatDirectRoomAccessService.java @@ -0,0 +1,57 @@ +package gg.agit.konect.domain.chat.service; + +import static gg.agit.konect.global.code.ApiResponseCode.FORBIDDEN_CHAT_ROOM_ACCESS; + +import java.time.LocalDateTime; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import gg.agit.konect.domain.chat.model.ChatRoom; +import gg.agit.konect.domain.chat.model.ChatRoomMember; +import gg.agit.konect.domain.chat.repository.ChatRoomMemberRepository; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.global.exception.CustomException; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional +public class ChatDirectRoomAccessService { + + private final ChatRoomMemberRepository chatRoomMemberRepository; + + public ChatRoomMember getAccessibleMember(ChatRoom chatRoom, User user) { + ChatRoomMember member = getMember(chatRoom, user); + restoreIfVisible(member, chatRoom); + return member; + } + + public LocalDateTime prepareAccessAndGetVisibleMessageFrom(ChatRoom chatRoom, User user) { + ChatRoomMember member = getMember(chatRoom, user); + LocalDateTime visibleMessageFrom = member.getVisibleMessageFrom(); + restoreIfVisible(member, chatRoom); + return visibleMessageFrom; + } + + private ChatRoomMember getMember(ChatRoom chatRoom, User user) { + return chatRoomMemberRepository.findByChatRoomIdAndUserId(chatRoom.getId(), user.getId()) + .orElseThrow(() -> CustomException.of(FORBIDDEN_CHAT_ROOM_ACCESS)); + } + + /** + * direct 채팅방에서 나간 사용자가 다시 볼 수 있는 상태인지 확인하고, + * 새 메시지가 이미 존재하면 나간 상태를 해제한다. + */ + private void restoreIfVisible(ChatRoomMember member, ChatRoom chatRoom) { + if (!member.hasLeft()) { + return; + } + + if (!member.hasVisibleMessages(chatRoom)) { + throw CustomException.of(FORBIDDEN_CHAT_ROOM_ACCESS); + } + + member.restoreDirectRoom(); + } +} 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 9ba264b6e..6b55a0351 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 @@ -84,6 +84,7 @@ public class ChatService { private final ChatSearchService chatSearchService; private final ChatMessagePageResolver chatMessagePageResolver; private final ChatRoomSystemAdminService chatRoomSystemAdminService; + private final ChatDirectRoomAccessService chatDirectRoomAccessService; private final NotificationService notificationService; private final ApplicationEventPublisher eventPublisher; @@ -334,12 +335,12 @@ record SectionKey(Integer clubId, String clubName) { return ChatInvitableUsersResponse.forClubSort(pagedInvitableUsers, sections); } - @Transactional(readOnly = true) + @Transactional public ChatMessagePageResponse getMessages(Integer userId, Integer roomId, Integer page, Integer limit) { return getMessages(userId, roomId, page, limit, null); } - @Transactional(readOnly = true) + @Transactional public ChatMessagePageResponse getMessages( Integer userId, Integer roomId, Integer page, Integer limit, Integer messageId ) { @@ -410,7 +411,7 @@ public ChatMuteResponse toggleMute(Integer userId, Integer roomId) { boolean isAdminAccessingSystemAdminRoom = user.isAdmin() && chatRoomSystemAdminService.isSystemAdminRoom(room.getId()); if (!isAdminAccessingSystemAdminRoom) { - getAccessibleDirectRoomMember(room, user); + chatDirectRoomAccessService.getAccessibleMember(room, user); } } else { getAccessibleRoomMember(room, userId); @@ -627,8 +628,8 @@ private ChatMessagePageResponse getDirectChatRoomMessages( ChatRoom chatRoom = getDirectRoom(roomId); User user = userRepository.getById(userId); List members = chatRoomMemberRepository.findByChatRoomId(roomId); - LocalDateTime visibleMessageFrom = prepareDirectRoomAccess(getOrCreateDirectRoomMember(chatRoom, user), - chatRoom); + LocalDateTime visibleMessageFrom = + chatDirectRoomAccessService.prepareAccessAndGetVisibleMessageFrom(chatRoom, user); List sortedReadBaselines = toSortedReadBaselines(members); @@ -666,12 +667,8 @@ private ChatMessageDetailResponse sendDirectMessage( boolean isAdminSendingToSystemAdminRoom = sender.isAdmin() && chatRoomSystemAdminService.isSystemAdminRoom(chatRoom.getId()); - ChatRoomMember senderMember = null; - boolean senderHadLeft = false; - if (!isAdminSendingToSystemAdminRoom) { - senderMember = getAccessibleDirectRoomMember(chatRoom, sender); - senderHadLeft = senderMember.hasLeft(); + chatDirectRoomAccessService.getAccessibleMember(chatRoom, sender); } List members = chatRoomMemberRepository.findByChatRoomId(roomId); @@ -681,10 +678,6 @@ private ChatMessageDetailResponse sendDirectMessage( ChatMessage.of(chatRoom, sender, request.content()) ); - if (senderHadLeft && senderMember != null) { - senderMember.restoreDirectRoom(); - } - syncLastMessage(chatRoom, chatMessage); members.stream() .filter(member -> !member.getUserId().equals(userId)) @@ -994,7 +987,7 @@ private ChatRoomMember getAccessibleRoomMember(ChatRoom room, Integer userId) { if (room.isDirectRoom()) { User user = userRepository.getById(userId); - return getAccessibleDirectRoomMember(room, user); + return chatDirectRoomAccessService.getAccessibleMember(room, user); } ChatRoomMember member = getRoomMember(room.getId(), userId); @@ -1147,44 +1140,11 @@ private Map getRoomUnreadCountMap(List roomIds, Integ return unreadCountMap; } - private ChatRoomMember getOrCreateDirectRoomMember(ChatRoom chatRoom, User user) { - return chatRoomMemberRepository.findByChatRoomIdAndUserId(chatRoom.getId(), user.getId()) - .orElseThrow(() -> CustomException.of(FORBIDDEN_CHAT_ROOM_ACCESS)); - } - - private ChatRoomMember getAccessibleDirectRoomMember(ChatRoom chatRoom, User user) { - ChatRoomMember member = getOrCreateDirectRoomMember(chatRoom, user); - restoreDirectRoomIfVisible(member, chatRoom); - return member; - } - - private LocalDateTime prepareDirectRoomAccess(ChatRoomMember member, ChatRoom chatRoom) { - LocalDateTime visibleMessageFrom = member.getVisibleMessageFrom(); - restoreDirectRoomIfVisible(member, chatRoom); - return visibleMessageFrom; - } - private LocalDateTime resolveAdminSystemRoomVisibleMessageFrom(List members) { ChatRoomMember systemAdminMember = chatRoomSystemAdminService.findSystemAdminMember(members); return systemAdminMember != null ? systemAdminMember.getVisibleMessageFrom() : null; } - /** - * direct 채팅방에서 나간 사용자가 다시 볼 수 있는 상태인지 확인하고, - * 새 메시지가 이미 존재하면 나간 상태를 해제한다. - */ - private void restoreDirectRoomIfVisible(ChatRoomMember member, ChatRoom chatRoom) { - if (!member.hasLeft()) { - return; - } - - if (!member.hasVisibleMessages(chatRoom)) { - throw CustomException.of(FORBIDDEN_CHAT_ROOM_ACCESS); - } - - member.restoreDirectRoom(); - } - private boolean shouldDisplayAsOwnMessage( User currentUser, ChatMessage message, diff --git a/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatDirectRoomAccessServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatDirectRoomAccessServiceTest.java new file mode 100644 index 000000000..ddff5a46e --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatDirectRoomAccessServiceTest.java @@ -0,0 +1,125 @@ +package gg.agit.konect.unit.domain.chat.service; + +import static gg.agit.konect.global.code.ApiResponseCode.FORBIDDEN_CHAT_ROOM_ACCESS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; + +import java.time.LocalDateTime; +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.test.util.ReflectionTestUtils; + +import gg.agit.konect.domain.chat.model.ChatRoom; +import gg.agit.konect.domain.chat.model.ChatRoomMember; +import gg.agit.konect.domain.chat.repository.ChatRoomMemberRepository; +import gg.agit.konect.domain.chat.service.ChatDirectRoomAccessService; +import gg.agit.konect.domain.user.enums.UserRole; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.global.exception.CustomException; +import gg.agit.konect.support.ServiceTestSupport; +import gg.agit.konect.support.fixture.UniversityFixture; +import gg.agit.konect.support.fixture.UserFixture; + +class ChatDirectRoomAccessServiceTest extends ServiceTestSupport { + + private static final LocalDateTime BASE_TIME = LocalDateTime.of(2026, 4, 27, 10, 0); + + @Mock + private ChatRoomMemberRepository chatRoomMemberRepository; + + @InjectMocks + private ChatDirectRoomAccessService chatDirectRoomAccessService; + + @Test + @DisplayName("접근 가능한 direct room 멤버를 반환한다") + void getAccessibleMemberReturnsMember() { + User user = user(10); + ChatRoom room = room(1); + ChatRoomMember member = member(room, user); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), user.getId())) + .willReturn(Optional.of(member)); + + ChatRoomMember result = chatDirectRoomAccessService.getAccessibleMember(room, user); + + assertThat(result).isSameAs(member); + } + + @Test + @DisplayName("나간 direct room에 새 메시지가 있으면 접근 시 나간 상태를 해제한다") + void getAccessibleMemberRestoresLeftMemberWhenVisibleMessageExists() { + User user = user(10); + ChatRoom room = room(1); + room.updateLastMessage("새 메시지", BASE_TIME.plusHours(2)); + ChatRoomMember member = member(room, user); + member.leaveDirectRoom(BASE_TIME.plusHours(1)); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), user.getId())) + .willReturn(Optional.of(member)); + + chatDirectRoomAccessService.getAccessibleMember(room, user); + + assertThat(member.hasLeft()).isFalse(); + } + + @Test + @DisplayName("나간 direct room에 새 메시지가 없으면 접근을 거부한다") + void getAccessibleMemberRejectsLeftMemberWithoutVisibleMessage() { + User user = user(10); + ChatRoom room = room(1); + ChatRoomMember member = member(room, user); + member.leaveDirectRoom(BASE_TIME.plusHours(1)); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), user.getId())) + .willReturn(Optional.of(member)); + + assertThatThrownBy(() -> chatDirectRoomAccessService.getAccessibleMember(room, user)) + .isInstanceOf(CustomException.class) + .satisfies(exception -> + assertThat(((CustomException)exception).getErrorCode()).isEqualTo(FORBIDDEN_CHAT_ROOM_ACCESS)); + } + + @Test + @DisplayName("접근 준비는 복원 전 visibleMessageFrom을 반환한다") + void prepareAccessAndGetVisibleMessageFromReturnsPreviousVisibilityBoundary() { + User user = user(10); + ChatRoom room = room(1); + room.updateLastMessage("새 메시지", BASE_TIME.plusHours(2)); + ChatRoomMember member = member(room, user); + LocalDateTime visibleMessageFrom = BASE_TIME.plusHours(1); + ReflectionTestUtils.setField(member, "leftAt", visibleMessageFrom); + ReflectionTestUtils.setField(member, "visibleMessageFrom", visibleMessageFrom); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), user.getId())) + .willReturn(Optional.of(member)); + + LocalDateTime result = chatDirectRoomAccessService.prepareAccessAndGetVisibleMessageFrom(room, user); + + assertThat(result).isEqualTo(visibleMessageFrom); + assertThat(member.hasLeft()).isFalse(); + } + + private User user(Integer id) { + return UserFixture.createUserWithId( + UniversityFixture.createWithId(1), + id, + "사용자" + id, + "2024" + String.format("%04d", id), + UserRole.USER + ); + } + + private ChatRoom room(Integer id) { + ChatRoom room = ChatRoom.directOf(); + ReflectionTestUtils.setField(room, "id", id); + ReflectionTestUtils.setField(room, "createdAt", BASE_TIME); + return room; + } + + private ChatRoomMember member(ChatRoom room, User user) { + ChatRoomMember member = ChatRoomMember.of(room, user, BASE_TIME); + ReflectionTestUtils.setField(member, "createdAt", BASE_TIME); + return member; + } +} 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 571223581..dba00c648 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 @@ -51,11 +51,12 @@ import gg.agit.konect.domain.chat.repository.ChatRoomMemberRepository; import gg.agit.konect.domain.chat.repository.ChatRoomQueryRepository; import gg.agit.konect.domain.chat.repository.ChatRoomRepository; +import gg.agit.konect.domain.chat.service.ChatDirectRoomAccessService; +import gg.agit.konect.domain.chat.service.ChatMessagePageResolver; import gg.agit.konect.domain.chat.service.ChatPresenceService; import gg.agit.konect.domain.chat.service.ChatRoomMembershipService; import gg.agit.konect.domain.chat.service.ChatRoomSummaryService; import gg.agit.konect.domain.chat.service.ChatSearchService; -import gg.agit.konect.domain.chat.service.ChatMessagePageResolver; import gg.agit.konect.domain.chat.service.ChatRoomSystemAdminService; import gg.agit.konect.domain.chat.service.ChatService; import gg.agit.konect.domain.club.model.Club; @@ -123,13 +124,13 @@ class ChatServiceTest extends ServiceTestSupport { @Mock private ApplicationEventPublisher eventPublisher; - private ChatMessagePageResolver chatMessagePageResolver; - private ChatService chatService; @BeforeEach void setUp() { - chatMessagePageResolver = new ChatMessagePageResolver( + ChatDirectRoomAccessService chatDirectRoomAccessService = + new ChatDirectRoomAccessService(chatRoomMemberRepository); + ChatMessagePageResolver chatMessagePageResolver = new ChatMessagePageResolver( chatMessageRepository, chatRoomMemberRepository, clubMemberRepository, @@ -150,6 +151,7 @@ void setUp() { chatSearchService, chatMessagePageResolver, chatRoomSystemAdminService, + chatDirectRoomAccessService, notificationService, eventPublisher ); From 09e60b1e2fe34bfc2c7fb182a4ee68e43fcab5d6 Mon Sep 17 00:00:00 2001 From: JanooGwan <103417427+JanooGwan@users.noreply.github.com> Date: Tue, 28 Apr 2026 10:32:43 +0900 Subject: [PATCH 33/50] =?UTF-8?q?chore:=20DB=20=EB=B0=B1=EC=97=85=EC=9D=84?= =?UTF-8?q?=20SSM=20=EB=AA=85=EB=A0=B9=20=EC=8B=A4=ED=96=89=20=EB=B0=A9?= =?UTF-8?q?=EC=8B=9D=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: DB 백업을 SSM 명령 실행 방식으로 변경 * chore: SSM 백업 로그 출력 범위 제한 --- .github/workflows/deploy-prod.yml | 84 ++++++++++++++++++++---------- .github/workflows/deploy-stage.yml | 84 ++++++++++++++++++++---------- 2 files changed, 112 insertions(+), 56 deletions(-) diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml index bbaf92299..c6ea6dd3b 100644 --- a/.github/workflows/deploy-prod.yml +++ b/.github/workflows/deploy-prod.yml @@ -5,6 +5,10 @@ on: branches: - main +permissions: + contents: read + id-token: write + jobs: deploy: runs-on: ubuntu-latest @@ -68,35 +72,59 @@ jobs: cache-from: type=gha cache-to: type=gha,mode=max - - name: Backup prod MySQL before deploy - uses: appleboy/ssh-action@v1.2.0 + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@ff717079ee2060e4bcee96c4779b553acc87447c # v4 with: - host: ${{ secrets.DB_SERVER_IP }} - username: ${{ secrets.DB_SERVER_USER }} - key: ${{ secrets.DB_SERVER_SSH_KEY }} - port: ${{ secrets.DB_SERVER_PORT }} - script: | - set -euo pipefail - START_TIME=$(date +%s) - - WORK_DIR="/home/ubuntu/konect/prod-db-compose" - MYSQL_CONTAINER="mysql-prod" - - set -a - source "$WORK_DIR/.env" - set +a - - BACKUP_DIR="$WORK_DIR/db-backups/" - mkdir -p "$BACKUP_DIR" - - DUMP_FILE="$BACKUP_DIR/$(date '+%Y%m%d_%H%M%S').sql" - docker exec -e MYSQL_PWD="$MYSQL_PASSWORD" "$MYSQL_CONTAINER" \ - mysqldump --no-tablespaces -u"$MYSQL_USERNAME" "$MYSQL_DATABASE" > "$DUMP_FILE" - - find "$BACKUP_DIR" -type f -name '*.sql' -mtime +5 -delete - - END_TIME=$(date +%s) - echo "Prod MySQL backup completed in $((END_TIME - START_TIME))s" + role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME }} + aws-region: ${{ secrets.AWS_REGION }} + + - name: Backup prod MySQL before deploy + env: + DB_INSTANCE_ID: ${{ secrets.DB_INSTANCE_ID }} + run: | + set -euo pipefail + + COMMAND_ID=$(aws ssm send-command \ + --instance-ids "$DB_INSTANCE_ID" \ + --document-name "AWS-RunShellScript" \ + --comment "KONECT prod MySQL backup before deploy" \ + --parameters commands='[ + "bash -lc '\''START_TIME=$(date +%s); bash /home/ubuntu/konect/prod-db-compose/backup-db.sh; END_TIME=$(date +%s); echo \"Prod MySQL backup completed in $((END_TIME - START_TIME))s\"'\''" + ]' \ + --query "Command.CommandId" \ + --output text) + + set +e + aws ssm wait command-executed \ + --command-id "$COMMAND_ID" \ + --instance-id "$DB_INSTANCE_ID" + WAITER_EXIT=$? + set -e + + BACKUP_STATUS=$(aws ssm get-command-invocation \ + --command-id "$COMMAND_ID" \ + --instance-id "$DB_INSTANCE_ID" \ + --query "Status" \ + --output text) + + BACKUP_STATUS_DETAILS=$(aws ssm get-command-invocation \ + --command-id "$COMMAND_ID" \ + --instance-id "$DB_INSTANCE_ID" \ + --query "StatusDetails" \ + --output text) + + BACKUP_RESPONSE_CODE=$(aws ssm get-command-invocation \ + --command-id "$COMMAND_ID" \ + --instance-id "$DB_INSTANCE_ID" \ + --query "ResponseCode" \ + --output text) + + echo "Prod MySQL backup status: $BACKUP_STATUS (details: $BACKUP_STATUS_DETAILS, response-code: $BACKUP_RESPONSE_CODE)" + + if [ "$WAITER_EXIT" -ne 0 ] || [ "$BACKUP_STATUS" != "Success" ] || [ "$BACKUP_RESPONSE_CODE" != "0" ]; then + echo "Prod MySQL backup failed. Remote stdout/stderr is intentionally not printed to avoid leaking sensitive information." + exit 1 + fi - name: Deploy to prod server uses: appleboy/ssh-action@v1.2.0 diff --git a/.github/workflows/deploy-stage.yml b/.github/workflows/deploy-stage.yml index cf8d1b549..c50e4ac76 100644 --- a/.github/workflows/deploy-stage.yml +++ b/.github/workflows/deploy-stage.yml @@ -5,6 +5,10 @@ on: branches: - develop +permissions: + contents: read + id-token: write + jobs: deploy: runs-on: ubuntu-latest @@ -68,35 +72,59 @@ jobs: cache-from: type=gha cache-to: type=gha,mode=max - - name: Backup stage MySQL before deploy - uses: appleboy/ssh-action@v1.2.0 + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@ff717079ee2060e4bcee96c4779b553acc87447c # v4 with: - host: ${{ secrets.DB_SERVER_IP }} - username: ${{ secrets.DB_SERVER_USER }} - key: ${{ secrets.DB_SERVER_SSH_KEY }} - port: ${{ secrets.DB_SERVER_PORT }} - script: | - set -euo pipefail - START_TIME=$(date +%s) - - WORK_DIR="/home/ubuntu/konect/stage-db-compose" - MYSQL_CONTAINER="mysql-stage" - - set -a - source "$WORK_DIR/.env" - set +a - - BACKUP_DIR="$WORK_DIR/db-backups/" - mkdir -p "$BACKUP_DIR" - - DUMP_FILE="$BACKUP_DIR/$(date '+%Y%m%d_%H%M%S').sql" - docker exec -e MYSQL_PWD="$MYSQL_PASSWORD" "$MYSQL_CONTAINER" \ - mysqldump --no-tablespaces -u"$MYSQL_USERNAME" "$MYSQL_DATABASE" > "$DUMP_FILE" - - find "$BACKUP_DIR" -type f -name '*.sql' -mtime +5 -delete - - END_TIME=$(date +%s) - echo "Stage MySQL backup completed in $((END_TIME - START_TIME))s" + role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME }} + aws-region: ${{ secrets.AWS_REGION }} + + - name: Backup stage MySQL before deploy + env: + DB_INSTANCE_ID: ${{ secrets.DB_INSTANCE_ID }} + run: | + set -euo pipefail + + COMMAND_ID=$(aws ssm send-command \ + --instance-ids "$DB_INSTANCE_ID" \ + --document-name "AWS-RunShellScript" \ + --comment "KONECT stage MySQL backup before deploy" \ + --parameters commands='[ + "bash -lc '\''START_TIME=$(date +%s); bash /home/ubuntu/konect/stage-db-compose/backup-db.sh; END_TIME=$(date +%s); echo \"Stage MySQL backup completed in $((END_TIME - START_TIME))s\"'\''" + ]' \ + --query "Command.CommandId" \ + --output text) + + set +e + aws ssm wait command-executed \ + --command-id "$COMMAND_ID" \ + --instance-id "$DB_INSTANCE_ID" + WAITER_EXIT=$? + set -e + + BACKUP_STATUS=$(aws ssm get-command-invocation \ + --command-id "$COMMAND_ID" \ + --instance-id "$DB_INSTANCE_ID" \ + --query "Status" \ + --output text) + + BACKUP_STATUS_DETAILS=$(aws ssm get-command-invocation \ + --command-id "$COMMAND_ID" \ + --instance-id "$DB_INSTANCE_ID" \ + --query "StatusDetails" \ + --output text) + + BACKUP_RESPONSE_CODE=$(aws ssm get-command-invocation \ + --command-id "$COMMAND_ID" \ + --instance-id "$DB_INSTANCE_ID" \ + --query "ResponseCode" \ + --output text) + + echo "Stage MySQL backup status: $BACKUP_STATUS (details: $BACKUP_STATUS_DETAILS, response-code: $BACKUP_RESPONSE_CODE)" + + if [ "$WAITER_EXIT" -ne 0 ] || [ "$BACKUP_STATUS" != "Success" ] || [ "$BACKUP_RESPONSE_CODE" != "0" ]; then + echo "Stage MySQL backup failed. Remote stdout/stderr is intentionally not printed to avoid leaking sensitive information." + exit 1 + fi - name: Deploy to stage server uses: appleboy/ssh-action@v1.2.0 From aef886cb50fac50a75e99427788b13e7fd49b0db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Tue, 28 Apr 2026 11:10:56 +0900 Subject: [PATCH 34/50] =?UTF-8?q?refactor:=20=EC=B4=88=EB=8C=80=20?= =?UTF-8?q?=ED=9B=84=EB=B3=B4=20=EC=A1=B0=ED=9A=8C=20=EC=B1=85=EC=9E=84=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=20(#602)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 초대 후보 조회 책임 분리 - ChatService에 남아 있던 초대 후보 조회와 응답 섹션 조립 책임을 ChatInviteService로 이동 - 메시지/방 상태 변경 중심 서비스에서 조회 전용 흐름을 분리해 후속 채팅 리팩토링 범위를 줄임 - 이름 정렬과 동아리 정렬 응답 조립 테스트를 추가해 기존 API 응답 구조를 보존 * test: 초대 후보 조회 테스트 계약 보강 - 초대 후보 응답의 페이징 메타데이터를 테스트로 고정해 리팩터링 중 API 계약이 흔들리지 않도록 함 - 동아리 정렬에서 후보가 여러 공유 동아리에 속한 경우 첫 대표 동아리 섹션으로 유지되는 경계를 검증함 - 공유 동아리가 없는 후보는 기타 섹션으로 분리되는 기존 정책을 함께 보장함 --- .../chat/service/ChatInviteService.java | 137 +++++++++++++++++ .../domain/chat/service/ChatService.java | 102 +------------ .../chat/service/ChatInviteServiceTest.java | 144 ++++++++++++++++++ .../domain/chat/service/ChatServiceTest.java | 10 +- 4 files changed, 288 insertions(+), 105 deletions(-) create mode 100644 src/main/java/gg/agit/konect/domain/chat/service/ChatInviteService.java create mode 100644 src/test/java/gg/agit/konect/unit/domain/chat/service/ChatInviteServiceTest.java diff --git a/src/main/java/gg/agit/konect/domain/chat/service/ChatInviteService.java b/src/main/java/gg/agit/konect/domain/chat/service/ChatInviteService.java new file mode 100644 index 000000000..199fc5f42 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/chat/service/ChatInviteService.java @@ -0,0 +1,137 @@ +package gg.agit.konect.domain.chat.service; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +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 org.springframework.transaction.annotation.Transactional; + +import gg.agit.konect.domain.chat.dto.ChatInvitableUsersResponse; +import gg.agit.konect.domain.chat.enums.ChatInviteSortBy; +import gg.agit.konect.domain.chat.repository.ChatInviteQueryRepository; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ChatInviteService { + + private static final String ETC_SECTION_NAME = "기타"; + + private final ChatInviteQueryRepository chatInviteQueryRepository; + private final UserRepository userRepository; + + public ChatInvitableUsersResponse getInvitableUsers( + Integer userId, + String query, + ChatInviteSortBy sortBy, + Integer page, + Integer limit + ) { + userRepository.getById(userId); + PageRequest pageRequest = PageRequest.of(page - 1, limit); + + if (sortBy == ChatInviteSortBy.CLUB) { + return getInvitableUsersGroupedByClub(userId, query, pageRequest); + } + + Page filteredUserEntitiesPage = chatInviteQueryRepository.findInvitableUsers(userId, query, pageRequest); + + // 응답 DTO는 채팅 초대 화면에서 바로 쓰는 최소 필드만 유지한다. + List filteredUsers = filteredUserEntitiesPage.getContent().stream() + .map(ChatInvitableUsersResponse.InvitableUser::from) + .toList(); + + // 응답 메타(total/current page 정보)는 유지하면서 내용만 DTO로 치환한다. + Page filteredUsersPage = new PageImpl<>( + filteredUsers, + pageRequest, + filteredUserEntitiesPage.getTotalElements() + ); + + return ChatInvitableUsersResponse.forNameSort(filteredUsersPage); + } + + private ChatInvitableUsersResponse getInvitableUsersGroupedByClub( + Integer userId, + String query, + PageRequest pageRequest + ) { + // CLUB 정렬은 DB가 현재 페이지에 들어갈 userId까지 잘라 오고, + // 서비스는 그 결과를 섹션 응답으로만 복원한다. + Page pagedUserIds = chatInviteQueryRepository.findInvitableUserIdsGroupedByClub( + userId, + query, + pageRequest + ); + + if (pagedUserIds.isEmpty()) { + return ChatInvitableUsersResponse.forClubSort( + new PageImpl<>(List.of(), pageRequest, pagedUserIds.getTotalElements()), + List.of() + ); + } + + // IN 조회는 정렬 순서를 보장하지 않으므로, DB가 정한 userId 페이지 순서대로 다시 조립한다. + Map pagedUserMap = userRepository.findAllByIdIn(pagedUserIds.getContent()).stream() + .collect(Collectors.toMap(User::getId, user -> user)); + + List pagedUsers = pagedUserIds.getContent().stream() + .map(pagedUserMap::get) + .filter(Objects::nonNull) + .map(ChatInvitableUsersResponse.InvitableUser::from) + .toList(); + + Page pagedInvitableUsers = new PageImpl<>( + pagedUsers, + pageRequest, + pagedUserIds.getTotalElements() + ); + + record SectionKey(Integer clubId, String clubName) { + } + + Map representativeClubByUserId = new HashMap<>(); + Map representativeClubNames = new HashMap<>(); + // 현재 페이지 사용자에 대해서만 대표 동아리를 다시 구해도, + // userId 자체는 이미 대표 동아리 기준으로 정렬돼 있으므로 페이지 경계는 유지된다. + chatInviteQueryRepository.findSharedClubMemberships(userId, pagedUserIds.getContent()).stream() + .forEach(clubMember -> { + representativeClubNames.putIfAbsent(clubMember.getClub().getId(), clubMember.getClub().getName()); + representativeClubByUserId.putIfAbsent(clubMember.getUser().getId(), clubMember.getClub().getId()); + }); + + // 대표 동아리가 없는 사용자는 기타 섹션으로 떨어지고, + // 같은 대표 동아리를 가진 사용자끼리만 현재 페이지 sections[]로 묶는다. + Map> sectionMap = new LinkedHashMap<>(); + pagedUsers.forEach(user -> { + Integer representativeClubId = representativeClubByUserId.get(user.userId()); + String clubName = representativeClubId == null + ? ETC_SECTION_NAME + : representativeClubNames.get(representativeClubId); + SectionKey key = new SectionKey(representativeClubId, clubName); + sectionMap.computeIfAbsent(key, ignored -> new ArrayList<>()) + .add(user); + }); + + List sections = sectionMap.entrySet().stream() + .map(entry -> new ChatInvitableUsersResponse.InvitableSection( + entry.getKey().clubId(), + entry.getKey().clubName(), + entry.getValue() + )) + .toList(); + + return ChatInvitableUsersResponse.forClubSort(pagedInvitableUsers, sections); + } +} 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 6b55a0351..b57c96976 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 @@ -7,7 +7,6 @@ import java.util.ArrayList; import java.util.Comparator; import java.util.HashMap; -import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; @@ -16,7 +15,6 @@ 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; @@ -41,7 +39,6 @@ import gg.agit.konect.domain.chat.model.ChatMessage; import gg.agit.konect.domain.chat.model.ChatRoom; import gg.agit.konect.domain.chat.model.ChatRoomMember; -import gg.agit.konect.domain.chat.repository.ChatInviteQueryRepository; import gg.agit.konect.domain.chat.repository.ChatMessageRepository; import gg.agit.konect.domain.chat.repository.ChatRoomMemberRepository; import gg.agit.konect.domain.chat.repository.ChatRoomQueryRepository; @@ -67,7 +64,6 @@ @Transactional(readOnly = true) public class ChatService { - private static final String ETC_SECTION_NAME = "기타"; private static final String DEFAULT_GROUP_ROOM_NAME = "그룹 채팅"; private final ChatRoomRepository chatRoomRepository; @@ -76,12 +72,12 @@ public class ChatService { private final ChatRoomMemberRepository chatRoomMemberRepository; private final NotificationMuteSettingRepository notificationMuteSettingRepository; private final ClubMemberRepository clubMemberRepository; - private final ChatInviteQueryRepository chatInviteQueryRepository; private final UserRepository userRepository; private final ChatPresenceService chatPresenceService; private final ChatRoomMembershipService chatRoomMembershipService; private final ChatRoomSummaryService chatRoomSummaryService; private final ChatSearchService chatSearchService; + private final ChatInviteService chatInviteService; private final ChatMessagePageResolver chatMessagePageResolver; private final ChatRoomSystemAdminService chatRoomSystemAdminService; private final ChatDirectRoomAccessService chatDirectRoomAccessService; @@ -238,101 +234,7 @@ public ChatInvitableUsersResponse getInvitableUsers( Integer page, Integer limit ) { - userRepository.getById(userId); - PageRequest pageRequest = PageRequest.of(page - 1, limit); - - if (sortBy == ChatInviteSortBy.CLUB) { - return getInvitableUsersGroupedByClub(userId, query, pageRequest); - } - - Page filteredUserEntitiesPage = chatInviteQueryRepository.findInvitableUsers(userId, query, pageRequest); - - // 응답 DTO는 채팅 초대 화면에서 바로 쓰는 최소 필드만 유지한다. - List filteredUsers = filteredUserEntitiesPage.getContent().stream() - .map(ChatInvitableUsersResponse.InvitableUser::from) - .toList(); - - // 응답 메타(total/current page 정보)는 유지하면서 내용만 DTO로 치환한다. - Page filteredUsersPage = new PageImpl<>( - filteredUsers, - pageRequest, - filteredUserEntitiesPage.getTotalElements() - ); - - return ChatInvitableUsersResponse.forNameSort(filteredUsersPage); - } - - private ChatInvitableUsersResponse getInvitableUsersGroupedByClub( - Integer userId, - String query, - PageRequest pageRequest - ) { - // CLUB 정렬은 DB가 현재 페이지에 들어갈 userId까지 잘라 오고, - // 서비스는 그 결과를 섹션 응답으로만 복원한다. - Page pagedUserIds = chatInviteQueryRepository.findInvitableUserIdsGroupedByClub( - userId, - query, - pageRequest - ); - - if (pagedUserIds.isEmpty()) { - return ChatInvitableUsersResponse.forClubSort( - new PageImpl<>(List.of(), pageRequest, pagedUserIds.getTotalElements()), - List.of() - ); - } - - // IN 조회는 정렬 순서를 보장하지 않으므로, DB가 정한 userId 페이지 순서대로 다시 조립한다. - Map pagedUserMap = userRepository.findAllByIdIn(pagedUserIds.getContent()).stream() - .collect(Collectors.toMap(User::getId, user -> user)); - - List pagedUsers = pagedUserIds.getContent().stream() - .map(pagedUserMap::get) - .filter(Objects::nonNull) - .map(ChatInvitableUsersResponse.InvitableUser::from) - .toList(); - - Page pagedInvitableUsers = new PageImpl<>( - pagedUsers, - pageRequest, - pagedUserIds.getTotalElements() - ); - - record SectionKey(Integer clubId, String clubName) { - } - - Map representativeClubByUserId = new HashMap<>(); - Map representativeClubNames = new HashMap<>(); - // 현재 페이지 사용자에 대해서만 대표 동아리를 다시 구해도, - // userId 자체는 이미 대표 동아리 기준으로 정렬돼 있으므로 페이지 경계는 유지된다. - chatInviteQueryRepository.findSharedClubMemberships(userId, pagedUserIds.getContent()).stream() - .forEach(clubMember -> { - representativeClubNames.putIfAbsent(clubMember.getClub().getId(), clubMember.getClub().getName()); - representativeClubByUserId.putIfAbsent(clubMember.getUser().getId(), clubMember.getClub().getId()); - }); - - // 대표 동아리가 없는 사용자는 기타 섹션으로 떨어지고, - // 같은 대표 동아리를 가진 사용자끼리만 현재 페이지 sections[]로 묶는다. - Map> sectionMap = new LinkedHashMap<>(); - pagedUsers.forEach(user -> { - Integer representativeClubId = representativeClubByUserId.get(user.userId()); - String clubName = representativeClubId == null - ? ETC_SECTION_NAME - : representativeClubNames.get(representativeClubId); - SectionKey key = new SectionKey(representativeClubId, clubName); - sectionMap.computeIfAbsent(key, ignored -> new ArrayList<>()) - .add(user); - }); - - List sections = sectionMap.entrySet().stream() - .map(entry -> new ChatInvitableUsersResponse.InvitableSection( - entry.getKey().clubId(), - entry.getKey().clubName(), - entry.getValue() - )) - .toList(); - - return ChatInvitableUsersResponse.forClubSort(pagedInvitableUsers, sections); + return chatInviteService.getInvitableUsers(userId, query, sortBy, page, limit); } @Transactional diff --git a/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatInviteServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatInviteServiceTest.java new file mode 100644 index 000000000..c1c7bf5f5 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatInviteServiceTest.java @@ -0,0 +1,144 @@ +package gg.agit.konect.unit.domain.chat.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; + +import gg.agit.konect.domain.chat.dto.ChatInvitableUsersResponse; +import gg.agit.konect.domain.chat.enums.ChatInviteSortBy; +import gg.agit.konect.domain.chat.repository.ChatInviteQueryRepository; +import gg.agit.konect.domain.chat.service.ChatInviteService; +import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.club.model.ClubMember; +import gg.agit.konect.domain.user.enums.UserRole; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.domain.user.repository.UserRepository; +import gg.agit.konect.support.ServiceTestSupport; +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; + +class ChatInviteServiceTest extends ServiceTestSupport { + + @Mock + private ChatInviteQueryRepository chatInviteQueryRepository; + + @Mock + private UserRepository userRepository; + + @Test + @DisplayName("이름 정렬은 초대 후보를 단일 사용자 목록으로 변환한다") + void getInvitableUsersReturnsNameSortedUsers() { + // given + Integer userId = 10; + User requester = createUser(userId, "요청자"); + User candidate = createUser(20, "후보"); + PageRequest pageRequest = PageRequest.of(0, 20); + ChatInviteService service = new ChatInviteService(chatInviteQueryRepository, userRepository); + + given(userRepository.getById(userId)).willReturn(requester); + given(chatInviteQueryRepository.findInvitableUsers(userId, "후보", pageRequest)) + .willReturn(new PageImpl<>(List.of(candidate), pageRequest, 1)); + + // when + ChatInvitableUsersResponse response = service.getInvitableUsers( + userId, + "후보", + ChatInviteSortBy.NAME, + 1, + 20 + ); + + // then + assertThat(response.totalCount()).isEqualTo(1L); + assertThat(response.currentCount()).isEqualTo(1); + assertThat(response.totalPage()).isEqualTo(1); + assertThat(response.currentPage()).isEqualTo(1); + assertThat(response.sortBy()).isEqualTo(ChatInviteSortBy.NAME); + assertThat(response.grouped()).isFalse(); + assertThat(response.users()) + .extracting(ChatInvitableUsersResponse.InvitableUser::userId) + .containsExactly(candidate.getId()); + assertThat(response.sections()).isEmpty(); + } + + @Test + @DisplayName("동아리 정렬은 현재 페이지 후보만 대표 동아리 섹션으로 묶는다") + void getInvitableUsersReturnsClubSections() { + // given + Integer userId = 10; + User requester = createUser(userId, "요청자"); + User bcsdUser = createUser(20, "BCSD 후보"); + User dualSharedUser = createUser(30, "복수 공유 후보"); + User etcUser = createUser(40, "기타 후보"); + Club bcsd = ClubFixture.createWithId(UniversityFixture.create(), 1, "BCSD"); + Club seminar = ClubFixture.createWithId(UniversityFixture.create(), 2, "Seminar"); + ClubMember bcsdMembership = ClubMemberFixture.createMember(bcsd, bcsdUser); + ClubMember dualBcsdMembership = ClubMemberFixture.createMember(bcsd, dualSharedUser); + ClubMember dualSeminarMembership = ClubMemberFixture.createMember(seminar, dualSharedUser); + PageRequest pageRequest = PageRequest.of(0, 20); + ChatInviteService service = new ChatInviteService(chatInviteQueryRepository, userRepository); + + given(userRepository.getById(userId)).willReturn(requester); + given(chatInviteQueryRepository.findInvitableUserIdsGroupedByClub(userId, null, pageRequest)) + .willReturn(new PageImpl<>( + List.of(bcsdUser.getId(), dualSharedUser.getId(), etcUser.getId()), + pageRequest, + 3 + )); + given(userRepository.findAllByIdIn(List.of(bcsdUser.getId(), dualSharedUser.getId(), etcUser.getId()))) + .willReturn(List.of(etcUser, dualSharedUser, bcsdUser)); + given(chatInviteQueryRepository.findSharedClubMemberships( + userId, + List.of(bcsdUser.getId(), dualSharedUser.getId(), etcUser.getId()) + )) + .willReturn(List.of(bcsdMembership, dualBcsdMembership, dualSeminarMembership)); + + // when + ChatInvitableUsersResponse response = service.getInvitableUsers( + userId, + null, + ChatInviteSortBy.CLUB, + 1, + 20 + ); + + // then + assertThat(response.totalCount()).isEqualTo(3L); + assertThat(response.currentCount()).isEqualTo(3); + assertThat(response.totalPage()).isEqualTo(1); + assertThat(response.currentPage()).isEqualTo(1); + assertThat(response.sortBy()).isEqualTo(ChatInviteSortBy.CLUB); + assertThat(response.grouped()).isTrue(); + assertThat(response.users()).isEmpty(); + assertThat(response.sections()) + .extracting(ChatInvitableUsersResponse.InvitableSection::clubName) + .containsExactly("BCSD", "기타"); + assertThat(response.sections().get(0).users()) + .extracting(ChatInvitableUsersResponse.InvitableUser::userId) + .containsExactly(bcsdUser.getId(), dualSharedUser.getId()); + assertThat(response.sections().get(1).users()) + .extracting(ChatInvitableUsersResponse.InvitableUser::userId) + .containsExactly(etcUser.getId()); + verify(userRepository).findAllByIdIn(List.of(bcsdUser.getId(), dualSharedUser.getId(), etcUser.getId())); + } + + private User createUser(Integer id, String name) { + return UserFixture.createUserWithId( + UniversityFixture.create(), + id, + name, + "2024" + String.format("%04d", id), + UserRole.USER + ); + } +} 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 dba00c648..63285edb5 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 @@ -46,12 +46,12 @@ import gg.agit.konect.domain.chat.model.ChatMessage; import gg.agit.konect.domain.chat.model.ChatRoom; import gg.agit.konect.domain.chat.model.ChatRoomMember; -import gg.agit.konect.domain.chat.repository.ChatInviteQueryRepository; import gg.agit.konect.domain.chat.repository.ChatMessageRepository; import gg.agit.konect.domain.chat.repository.ChatRoomMemberRepository; import gg.agit.konect.domain.chat.repository.ChatRoomQueryRepository; import gg.agit.konect.domain.chat.repository.ChatRoomRepository; import gg.agit.konect.domain.chat.service.ChatDirectRoomAccessService; +import gg.agit.konect.domain.chat.service.ChatInviteService; import gg.agit.konect.domain.chat.service.ChatMessagePageResolver; import gg.agit.konect.domain.chat.service.ChatPresenceService; import gg.agit.konect.domain.chat.service.ChatRoomMembershipService; @@ -97,9 +97,6 @@ class ChatServiceTest extends ServiceTestSupport { @Mock private ClubMemberRepository clubMemberRepository; - @Mock - private ChatInviteQueryRepository chatInviteQueryRepository; - @Mock private UserRepository userRepository; @@ -115,6 +112,9 @@ class ChatServiceTest extends ServiceTestSupport { @Mock private ChatSearchService chatSearchService; + @Mock + private ChatInviteService chatInviteService; + @Mock private ChatRoomSystemAdminService chatRoomSystemAdminService; @@ -143,12 +143,12 @@ void setUp() { chatRoomMemberRepository, notificationMuteSettingRepository, clubMemberRepository, - chatInviteQueryRepository, userRepository, chatPresenceService, chatRoomMembershipService, chatRoomSummaryService, chatSearchService, + chatInviteService, chatMessagePageResolver, chatRoomSystemAdminService, chatDirectRoomAccessService, From f663bc7e0dc7a76a84e3f66547344e4052a1ab76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Tue, 28 Apr 2026 11:17:01 +0900 Subject: [PATCH 35/50] =?UTF-8?q?refactor:=20=EB=A9=94=EC=8B=9C=EC=A7=80?= =?UTF-8?q?=20=EC=A0=84=EC=86=A1=20=EC=B1=85=EC=9E=84=20=EB=B6=84=EB=A6=AC?= =?UTF-8?q?=20(#604)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 메시지 전송 책임 분리 - ChatService에 남아 있던 direct, club group, group 메시지 전송 흐름을 ChatMessageSendService로 이동 - 마지막 메시지 메타데이터 갱신, 알림 발송, SYSTEM_ADMIN 문의 이벤트 발행 정책을 기존 흐름 그대로 보존 - ChatService는 메시지 전송 요청을 위임하도록 줄여 후속 조회/생성 책임 분리 범위를 명확히 함 * test: 문의방 메시지 이벤트 발행 정책 고정 - 메시지 전송 책임 분리 후에도 일반 사용자의 SYSTEM_ADMIN 문의 메시지가 AdminChatReceivedEvent를 발행하는지 검증 - 관리자가 같은 문의방에 메시지를 보낼 때는 운영 알림 이벤트를 중복 발행하지 않도록 기존 예외 정책을 테스트로 고정 - 리뷰 피드백이 지적한 이벤트 누락과 중복 발행 회귀를 단위 테스트에서 바로 잡을 수 있게 함 --- .../chat/service/ChatMessageSendService.java | 327 ++++++++++++++++++ .../domain/chat/service/ChatService.java | 251 +------------- .../domain/chat/service/ChatServiceTest.java | 62 +++- 3 files changed, 389 insertions(+), 251 deletions(-) create mode 100644 src/main/java/gg/agit/konect/domain/chat/service/ChatMessageSendService.java diff --git a/src/main/java/gg/agit/konect/domain/chat/service/ChatMessageSendService.java b/src/main/java/gg/agit/konect/domain/chat/service/ChatMessageSendService.java new file mode 100644 index 000000000..f44d13f49 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/chat/service/ChatMessageSendService.java @@ -0,0 +1,327 @@ +package gg.agit.konect.domain.chat.service; + +import static gg.agit.konect.domain.chat.service.ChatRoomMembershipService.SYSTEM_ADMIN_ID; +import static gg.agit.konect.global.code.ApiResponseCode.FORBIDDEN_CHAT_ROOM_ACCESS; +import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_CHAT_ROOM; + +import java.time.LocalDateTime; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.springframework.context.ApplicationEventPublisher; +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.ChatMessageSendRequest; +import gg.agit.konect.domain.chat.event.AdminChatReceivedEvent; +import gg.agit.konect.domain.chat.model.ChatMessage; +import gg.agit.konect.domain.chat.model.ChatRoom; +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 gg.agit.konect.domain.chat.repository.ChatRoomRepository; +import gg.agit.konect.domain.club.model.ClubMember; +import gg.agit.konect.domain.club.repository.ClubMemberRepository; +import gg.agit.konect.domain.notification.service.NotificationService; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.domain.user.repository.UserRepository; +import gg.agit.konect.global.exception.CustomException; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional +public class ChatMessageSendService { + + private static final String DEFAULT_GROUP_ROOM_NAME = "그룹 채팅"; + + private final ChatRoomRepository chatRoomRepository; + private final ChatMessageRepository chatMessageRepository; + private final ChatRoomMemberRepository chatRoomMemberRepository; + private final ClubMemberRepository clubMemberRepository; + private final UserRepository userRepository; + private final ChatRoomSystemAdminService chatRoomSystemAdminService; + private final ChatDirectRoomAccessService chatDirectRoomAccessService; + private final NotificationService notificationService; + private final ApplicationEventPublisher eventPublisher; + + public ChatMessageDetailResponse sendMessage(Integer userId, Integer roomId, ChatMessageSendRequest request) { + ChatRoom room = chatRoomRepository.findById(roomId) + .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); + + if (room.isDirectRoom()) { + return sendDirectMessage(userId, room, request); + } + + if (room.isClubGroupRoom()) { + return sendClubMessageByRoomId(room, userId, request.content()); + } + + return sendGroupMessageByRoomId(room, userId, request.content()); + } + + private ChatMessageDetailResponse sendDirectMessage( + Integer userId, + ChatRoom chatRoom, + ChatMessageSendRequest request + ) { + Integer roomId = chatRoom.getId(); + User sender = userRepository.getById(userId); + + // 어드민이 SYSTEM_ADMIN 방에 메시지를 보내는 경우 + boolean isAdminSendingToSystemAdminRoom = sender.isAdmin() + && chatRoomSystemAdminService.isSystemAdminRoom(chatRoom.getId()); + + if (!isAdminSendingToSystemAdminRoom) { + chatDirectRoomAccessService.getAccessibleMember(chatRoom, sender); + } + + List members = chatRoomMemberRepository.findByChatRoomId(roomId); + User receiver = resolveDirectMessageReceiver(members, sender); + + ChatMessage chatMessage = chatMessageRepository.save( + ChatMessage.of(chatRoom, sender, request.content()) + ); + + syncLastMessage(chatRoom, chatMessage); + members.stream() + .filter(member -> !member.getUserId().equals(userId)) + .filter(ChatRoomMember::hasLeft) + .forEach(member -> member.restoreDirectRoomFromIncomingMessage(chatMessage.getCreatedAt())); + + // 어드민이 보낸 경우는 lastReadAt 업데이트하지 않음 (멤버가 아니므로) + if (!isAdminSendingToSystemAdminRoom) { + updateLastReadAtOrEnsureMember(roomId, userId, chatMessage.getCreatedAt()); + } + + List sortedReadBaselines = toSortedReadBaselines(members); + + notificationService.sendChatNotification(receiver.getId(), roomId, sender.getName(), request.content()); + + boolean isSystemAdminRoom = members.stream() + .map(ChatRoomMember::getUserId) + .anyMatch(memberUserId -> memberUserId.equals(SYSTEM_ADMIN_ID)); + publishAdminChatEventIfNeeded(isSystemAdminRoom, sender, request.content()); + + return new ChatMessageDetailResponse( + chatMessage.getId(), + chatMessage.getSender().getId(), + null, + chatMessage.getContent(), + chatMessage.getCreatedAt(), + true, + countUnreadSince(chatMessage.getCreatedAt(), sortedReadBaselines), + true + ); + } + + private ChatMessageDetailResponse sendClubMessageByRoomId(ChatRoom room, Integer userId, String content) { + Integer roomId = room.getId(); + ClubMember member = clubMemberRepository.getByClubIdAndUserId(room.getClub().getId(), userId); + User sender = member.getUser(); + + ensureRoomMember(room, sender, member.getCreatedAt()); + + ChatMessage message = chatMessageRepository.save(ChatMessage.of(room, sender, content)); + syncLastMessage(room, message); + updateLastReadAtOrEnsureMember(roomId, userId, message.getCreatedAt()); + + List members = chatRoomMemberRepository.findByChatRoomId(roomId); + List recipientUserIds = members.stream().map(ChatRoomMember::getUserId).toList(); + List sortedReadBaselines = toSortedReadBaselines(members); + + notificationService.sendGroupChatNotification( + roomId, + sender.getId(), + room.getClub().getName(), + sender.getName(), + message.getContent(), + recipientUserIds + ); + + return new ChatMessageDetailResponse( + message.getId(), + sender.getId(), + sender.getName(), + message.getContent(), + message.getCreatedAt(), + null, + countUnreadSince(message.getCreatedAt(), sortedReadBaselines), + true + ); + } + + private ChatMessageDetailResponse sendGroupMessageByRoomId(ChatRoom room, Integer userId, String content) { + Integer roomId = room.getId(); + User sender = userRepository.getById(userId); + + ChatRoomMember senderMember = getRoomMember(roomId, userId); + if (senderMember.hasLeft()) { + throw CustomException.of(FORBIDDEN_CHAT_ROOM_ACCESS); + } + + ChatMessage message = chatMessageRepository.save(ChatMessage.of(room, sender, content)); + syncLastMessage(room, message); + updateLastReadAt(roomId, userId, message.getCreatedAt()); + + List members = chatRoomMemberRepository.findByChatRoomId(roomId); + List recipientUserIds = members.stream() + .map(ChatRoomMember::getUserId) + .filter(id -> !id.equals(userId)) + .toList(); + List sortedReadBaselines = toSortedReadBaselines(members); + + notificationService.sendGroupChatNotification( + roomId, + sender.getId(), + DEFAULT_GROUP_ROOM_NAME, + sender.getName(), + message.getContent(), + recipientUserIds + ); + + return new ChatMessageDetailResponse( + message.getId(), + sender.getId(), + sender.getName(), + message.getContent(), + message.getCreatedAt(), + null, + countUnreadSince(message.getCreatedAt(), sortedReadBaselines), + true + ); + } + + private ChatRoomMember getRoomMember(Integer roomId, Integer userId) { + return chatRoomMemberRepository.findByChatRoomIdAndUserId(roomId, userId) + .orElseThrow(() -> CustomException.of(FORBIDDEN_CHAT_ROOM_ACCESS)); + } + + private void ensureRoomMember(ChatRoom room, User user, LocalDateTime joinedAt) { + chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), user.getId()) + .ifPresentOrElse(member -> { + LocalDateTime lastReadAt = member.getLastReadAt(); + if (lastReadAt == null || lastReadAt.isBefore(joinedAt)) { + member.updateLastReadAt(joinedAt); + } + }, () -> chatRoomMemberRepository.save(ChatRoomMember.of(room, user, joinedAt))); + } + + private void updateLastReadAtOrEnsureMember(Integer roomId, Integer userId, LocalDateTime lastReadAt) { + int updated = chatRoomMemberRepository.updateLastReadAtIfOlder(roomId, userId, lastReadAt); + if (updated == 0) { + ChatRoom room = chatRoomRepository.findById(roomId) + .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); + User user = userRepository.getById(userId); + ensureRoomMember(room, user, lastReadAt); + } + } + + private void updateLastReadAt(Integer roomId, Integer userId, LocalDateTime lastReadAt) { + chatRoomMemberRepository.updateLastReadAtIfOlder(roomId, userId, lastReadAt); + } + + private List toSortedReadBaselines(List members) { + return members.stream() + .map(ChatRoomMember::getLastReadAt) + .sorted() + .toList(); + } + + private int countUnreadSince(LocalDateTime messageCreatedAt, List sortedReadBaselines) { + int left = 0; + int right = sortedReadBaselines.size(); + + while (left < right) { + int mid = (left + right) >>> 1; + LocalDateTime baseline = sortedReadBaselines.get(mid); + + if (baseline.isBefore(messageCreatedAt)) { + left = mid + 1; + } else { + right = mid; + } + } + + return left; + } + + private void syncLastMessage(ChatRoom room, ChatMessage message) { + // 채팅방 목록은 chat_room.last_message_*를 직접 조회하므로 + // 동시 전송에서도 가장 최신 메시지만 메타데이터를 덮어쓰도록 DB 조건을 같이 건다. + int updated = chatRoomRepository.updateLastMessageIfLatest( + room.getId(), + message.getId(), + message.getContent(), + message.getCreatedAt() + ); + if (updated > 0) { + room.updateLastMessage(message.getContent(), message.getCreatedAt()); + } + } + + private void publishAdminChatEventIfNeeded(boolean isSystemAdminRoom, User sender, String content) { + if (isSystemAdminRoom && !sender.isAdmin()) { + eventPublisher.publishEvent(AdminChatReceivedEvent.of(sender.getId(), sender.getName(), content)); + } + } + + private User resolveDirectMessageReceiver(List members, User sender) { + Map userMap = members.stream() + .collect(Collectors.toMap( + ChatRoomMember::getUserId, + ChatRoomMember::getUser, + (existing, replacement) -> existing + )); + List memberInfos = members.stream() + .map(member -> new MemberInfo(member.getUserId(), member.getCreatedAt())) + .toList(); + return resolveMessageReceiverFromMemberInfo(sender, memberInfos, userMap); + } + + private User findDirectPartnerFromMemberInfo( + List memberInfos, + Integer userId, + Map userMap + ) { + return memberInfos.stream() + .filter(info -> !info.userId().equals(userId)) + .min(Comparator.comparing(MemberInfo::createdAt)) + .map(info -> userMap.get(info.userId())) + .orElse(null); + } + + private User findNonAdminUserFromMemberInfo(List memberInfos, Map userMap) { + return memberInfos.stream() + .sorted(Comparator.comparing(MemberInfo::createdAt)) + .map(info -> userMap.get(info.userId())) + .filter(user -> user != null && !user.isAdmin()) + .findFirst() + .orElse(null); + } + + private User resolveMessageReceiverFromMemberInfo( + User sender, + List memberInfos, + Map userMap + ) { + if (sender.isAdmin()) { + User nonAdminUser = findNonAdminUserFromMemberInfo(memberInfos, userMap); + if (nonAdminUser != null) { + return nonAdminUser; + } + } + + User partner = findDirectPartnerFromMemberInfo(memberInfos, sender.getId(), userMap); + if (partner == null) { + throw CustomException.of(FORBIDDEN_CHAT_ROOM_ACCESS); + } + return partner; + } + + private record MemberInfo(Integer userId, LocalDateTime createdAt) { + } +} 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 b57c96976..4d1b3b2be 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 @@ -13,7 +13,6 @@ import java.util.Set; import java.util.stream.Collectors; -import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; @@ -35,7 +34,6 @@ import gg.agit.konect.domain.chat.dto.UnreadMessageCount; import gg.agit.konect.domain.chat.enums.ChatInviteSortBy; import gg.agit.konect.domain.chat.enums.ChatType; -import gg.agit.konect.domain.chat.event.AdminChatReceivedEvent; import gg.agit.konect.domain.chat.model.ChatMessage; import gg.agit.konect.domain.chat.model.ChatRoom; import gg.agit.konect.domain.chat.model.ChatRoomMember; @@ -49,7 +47,6 @@ import gg.agit.konect.domain.notification.enums.NotificationTargetType; import gg.agit.konect.domain.notification.model.NotificationMuteSetting; import gg.agit.konect.domain.notification.repository.NotificationMuteSettingRepository; -import gg.agit.konect.domain.notification.service.NotificationService; import gg.agit.konect.domain.user.enums.UserRole; import gg.agit.konect.domain.user.model.User; import gg.agit.konect.domain.user.repository.UserRepository; @@ -81,8 +78,7 @@ public class ChatService { private final ChatMessagePageResolver chatMessagePageResolver; private final ChatRoomSystemAdminService chatRoomSystemAdminService; private final ChatDirectRoomAccessService chatDirectRoomAccessService; - private final NotificationService notificationService; - private final ApplicationEventPublisher eventPublisher; + private final ChatMessageSendService chatMessageSendService; @Transactional public ChatRoomResponse createOrGetChatRoom(Integer currentUserId, ChatRoomCreateRequest request) { @@ -285,18 +281,7 @@ public ChatMessagePageResponse getMessages( @Transactional public ChatMessageDetailResponse sendMessage(Integer userId, Integer roomId, ChatMessageSendRequest request) { - ChatRoom room = chatRoomRepository.findById(roomId) - .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); - - if (room.isDirectRoom()) { - return sendDirectMessage(userId, roomId, request); - } - - if (room.isClubGroupRoom()) { - return sendClubMessageByRoomId(roomId, userId, request.content()); - } - - return sendGroupMessageByRoomId(roomId, userId, request.content()); + return chatMessageSendService.sendMessage(userId, roomId, request); } @Transactional @@ -557,61 +542,6 @@ private ChatMessagePageResponse getAdminSystemDirectChatRoomMessages( visibleMessageFrom, sortedReadBaselines, maskedAdminId); } - private ChatMessageDetailResponse sendDirectMessage( - Integer userId, - Integer roomId, - ChatMessageSendRequest request - ) { - ChatRoom chatRoom = getDirectRoom(roomId); - User sender = userRepository.getById(userId); - - // 어드민이 SYSTEM_ADMIN 방에 메시지를 보내는 경우 - boolean isAdminSendingToSystemAdminRoom = sender.isAdmin() - && chatRoomSystemAdminService.isSystemAdminRoom(chatRoom.getId()); - - if (!isAdminSendingToSystemAdminRoom) { - chatDirectRoomAccessService.getAccessibleMember(chatRoom, sender); - } - - List members = chatRoomMemberRepository.findByChatRoomId(roomId); - User receiver = resolveDirectMessageReceiver(members, sender); - - ChatMessage chatMessage = chatMessageRepository.save( - ChatMessage.of(chatRoom, sender, request.content()) - ); - - syncLastMessage(chatRoom, chatMessage); - members.stream() - .filter(member -> !member.getUserId().equals(userId)) - .filter(ChatRoomMember::hasLeft) - .forEach(member -> member.restoreDirectRoomFromIncomingMessage(chatMessage.getCreatedAt())); - - // 어드민이 보낸 경우는 lastReadAt 업데이트하지 않음 (멤버가 아니므로) - if (!isAdminSendingToSystemAdminRoom) { - updateMemberLastReadAt(roomId, userId, chatMessage.getCreatedAt()); - } - - List sortedReadBaselines = toSortedReadBaselines(members); - - notificationService.sendChatNotification(receiver.getId(), roomId, sender.getName(), request.content()); - - boolean isSystemAdminRoom = members.stream() - .map(ChatRoomMember::getUserId) - .anyMatch(memberUserId -> memberUserId.equals(SYSTEM_ADMIN_ID)); - publishAdminChatEventIfNeeded(isSystemAdminRoom, sender, request.content()); - - return new ChatMessageDetailResponse( - chatMessage.getId(), - chatMessage.getSender().getId(), - null, - chatMessage.getContent(), - chatMessage.getCreatedAt(), - true, - countUnreadSince(chatMessage.getCreatedAt(), sortedReadBaselines), - true - ); - } - private ChatMessagePageResponse getClubMessagesByRoomId( Integer roomId, Integer userId, @@ -654,42 +584,6 @@ private ChatMessagePageResponse getClubMessagesByRoomId( ); } - private ChatMessageDetailResponse sendClubMessageByRoomId(Integer roomId, Integer userId, String content) { - ChatRoom room = getClubRoom(roomId); - ClubMember member = clubMemberRepository.getByClubIdAndUserId(room.getClub().getId(), userId); - User sender = member.getUser(); - - ensureRoomMember(room, sender, member.getCreatedAt()); - - ChatMessage message = chatMessageRepository.save(ChatMessage.of(room, sender, content)); - syncLastMessage(room, message); - updateClubMessageLastReadAt(roomId, userId, message.getCreatedAt()); - - List members = chatRoomMemberRepository.findByChatRoomId(roomId); - List recipientUserIds = members.stream().map(ChatRoomMember::getUserId).toList(); - List sortedReadBaselines = toSortedReadBaselines(members); - - notificationService.sendGroupChatNotification( - roomId, - sender.getId(), - room.getClub().getName(), - sender.getName(), - message.getContent(), - recipientUserIds - ); - - return new ChatMessageDetailResponse( - message.getId(), - sender.getId(), - sender.getName(), - message.getContent(), - message.getCreatedAt(), - null, - countUnreadSince(message.getCreatedAt(), sortedReadBaselines), - true - ); - } - private ChatMessagePageResponse getGroupMessagesByRoomId( Integer roomId, Integer userId, @@ -732,48 +626,6 @@ private ChatMessagePageResponse getGroupMessagesByRoomId( ); } - private ChatMessageDetailResponse sendGroupMessageByRoomId(Integer roomId, Integer userId, String content) { - ChatRoom room = chatRoomRepository.findById(roomId) - .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); - User sender = userRepository.getById(userId); - - ChatRoomMember senderMember = getRoomMember(roomId, userId); - if (senderMember.hasLeft()) { - throw CustomException.of(FORBIDDEN_CHAT_ROOM_ACCESS); - } - - ChatMessage message = chatMessageRepository.save(ChatMessage.of(room, sender, content)); - syncLastMessage(room, message); - updateLastReadAt(roomId, userId, message.getCreatedAt()); - - List members = chatRoomMemberRepository.findByChatRoomId(roomId); - List recipientUserIds = members.stream() - .map(ChatRoomMember::getUserId) - .filter(id -> !id.equals(userId)) - .toList(); - List sortedReadBaselines = toSortedReadBaselines(members); - - notificationService.sendGroupChatNotification( - roomId, - sender.getId(), - DEFAULT_GROUP_ROOM_NAME, - sender.getName(), - message.getContent(), - recipientUserIds - ); - - return new ChatMessageDetailResponse( - message.getId(), - sender.getId(), - sender.getName(), - message.getContent(), - message.getCreatedAt(), - null, - countUnreadSince(message.getCreatedAt(), sortedReadBaselines), - true - ); - } - private AccessibleChatRooms getAccessibleChatRooms(Integer userId) { List directRooms = getDirectChatRooms(userId); List clubRooms = getClubChatRooms(userId); @@ -855,26 +707,6 @@ private Integer getMaskedAdminId(User user, ChatRoom chatRoom) { return null; } - private void publishAdminChatEventIfNeeded(boolean isSystemAdminRoom, User sender, String content) { - if (isSystemAdminRoom && !sender.isAdmin()) { - eventPublisher.publishEvent(AdminChatReceivedEvent.of(sender.getId(), sender.getName(), content)); - } - } - - private void syncLastMessage(ChatRoom room, ChatMessage message) { - // 채팅방 목록은 chat_room.last_message_*를 직접 조회하므로 - // 동시 전송에서도 가장 최신 메시지만 메타데이터를 덮어쓰도록 DB 조건을 같이 건다. - int updated = chatRoomRepository.updateLastMessageIfLatest( - room.getId(), - message.getId(), - message.getContent(), - message.getCreatedAt() - ); - if (updated > 0) { - room.updateLastMessage(message.getContent(), message.getCreatedAt()); - } - } - private ChatRoomMember getRoomMember(Integer roomId, Integer userId) { return chatRoomMemberRepository.findByChatRoomIdAndUserId(roomId, userId) .orElseThrow(() -> CustomException.of(FORBIDDEN_CHAT_ROOM_ACCESS)); @@ -944,30 +776,6 @@ private String normalizeCustomRoomName(String roomName) { return roomName.trim(); } - private void updateMemberLastReadAt(Integer roomId, Integer userId, LocalDateTime lastReadAt) { - int updated = chatRoomMemberRepository.updateLastReadAtIfOlder(roomId, userId, lastReadAt); - if (updated == 0) { - ChatRoom room = chatRoomRepository.findById(roomId) - .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); - User user = userRepository.getById(userId); - ensureRoomMember(room, user, lastReadAt); - } - } - - private void updateLastReadAt(Integer roomId, Integer userId, LocalDateTime lastReadAt) { - chatRoomMemberRepository.updateLastReadAtIfOlder(roomId, userId, lastReadAt); - } - - private void updateClubMessageLastReadAt(Integer roomId, Integer userId, LocalDateTime lastReadAt) { - int updated = chatRoomMemberRepository.updateLastReadAtIfOlder(roomId, userId, lastReadAt); - if (updated == 0) { - ChatRoom room = chatRoomRepository.findById(roomId) - .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); - User user = userRepository.getById(userId); - ensureRoomMember(room, user, lastReadAt); - } - } - private List toSortedReadBaselines(List members) { return members.stream() .map(ChatRoomMember::getLastReadAt) @@ -1143,32 +951,6 @@ private User resolveDirectChatPartner(List members, Integer user return findDirectPartner(members, userId); } - private User findNonAdminUser(List members) { - Map userMap = members.stream() - .collect(Collectors.toMap( - ChatRoomMember::getUserId, - ChatRoomMember::getUser, - (existing, replacement) -> existing - )); - List memberInfos = members.stream() - .map(m -> new MemberInfo(m.getUserId(), m.getCreatedAt())) - .toList(); - return findNonAdminUserFromMemberInfo(memberInfos, userMap); - } - - private User resolveDirectMessageReceiver(List members, User sender) { - Map userMap = members.stream() - .collect(Collectors.toMap( - ChatRoomMember::getUserId, - ChatRoomMember::getUser, - (existing, replacement) -> existing - )); - List memberInfos = members.stream() - .map(m -> new MemberInfo(m.getUserId(), m.getCreatedAt())) - .toList(); - return resolveMessageReceiverFromMemberInfo(sender, memberInfos, userMap); - } - private User findDirectPartnerFromMemberInfo( List memberInfos, Integer userId, @@ -1196,35 +978,6 @@ private User resolveDirectChatPartner( return findDirectPartnerFromMemberInfo(memberInfos, userId, userMap); } - private User findNonAdminUserFromMemberInfo(List memberInfos, Map userMap) { - return memberInfos.stream() - .sorted(Comparator.comparing(MemberInfo::createdAt)) - .map(info -> userMap.get(info.userId())) - .filter(Objects::nonNull) - .filter(user -> !user.isAdmin()) - .findFirst() - .orElse(null); - } - - private User resolveMessageReceiverFromMemberInfo( - User sender, - List memberInfos, - Map userMap - ) { - if (sender.isAdmin()) { - User nonAdminUser = findNonAdminUserFromMemberInfo(memberInfos, userMap); - if (nonAdminUser != null) { - return nonAdminUser; - } - } - - User partner = findDirectPartnerFromMemberInfo(memberInfos, sender.getId(), userMap); - if (partner == null) { - throw CustomException.of(FORBIDDEN_CHAT_ROOM_ACCESS); - } - return partner; - } - private void validateGroupRoomForKick(ChatRoom room) { if (!room.isGroupRoom() || room.isClubGroupRoom()) { throw CustomException.of(CANNOT_KICK_IN_NON_GROUP_ROOM); 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 63285edb5..9f7380bb8 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 @@ -43,6 +43,7 @@ import gg.agit.konect.domain.chat.dto.ChatRoomNameUpdateRequest; import gg.agit.konect.domain.chat.dto.ChatRoomResponse; import gg.agit.konect.domain.chat.enums.ChatType; +import gg.agit.konect.domain.chat.event.AdminChatReceivedEvent; import gg.agit.konect.domain.chat.model.ChatMessage; import gg.agit.konect.domain.chat.model.ChatRoom; import gg.agit.konect.domain.chat.model.ChatRoomMember; @@ -52,6 +53,7 @@ import gg.agit.konect.domain.chat.repository.ChatRoomRepository; import gg.agit.konect.domain.chat.service.ChatDirectRoomAccessService; import gg.agit.konect.domain.chat.service.ChatInviteService; +import gg.agit.konect.domain.chat.service.ChatMessageSendService; import gg.agit.konect.domain.chat.service.ChatMessagePageResolver; import gg.agit.konect.domain.chat.service.ChatPresenceService; import gg.agit.konect.domain.chat.service.ChatRoomMembershipService; @@ -136,6 +138,17 @@ void setUp() { clubMemberRepository, chatRoomSystemAdminService ); + ChatMessageSendService chatMessageSendService = new ChatMessageSendService( + chatRoomRepository, + chatMessageRepository, + chatRoomMemberRepository, + clubMemberRepository, + userRepository, + chatRoomSystemAdminService, + chatDirectRoomAccessService, + notificationService, + eventPublisher + ); chatService = new ChatService( chatRoomRepository, chatRoomQueryRepository, @@ -152,8 +165,7 @@ void setUp() { chatMessagePageResolver, chatRoomSystemAdminService, chatDirectRoomAccessService, - notificationService, - eventPublisher + chatMessageSendService ); } @@ -1034,6 +1046,51 @@ void sendMessageInClubRoomSavesMessageAndSendsGroupNotification() { ); } + @Test + @DisplayName("sendMessage는 일반 사용자가 SYSTEM_ADMIN 방에 보내면 관리자 문의 이벤트를 발행한다") + void sendMessageByUserInSystemAdminRoomPublishesAdminChatEvent() { + // given + Integer senderId = 20; + String content = "문의합니다"; + User sender = createUser(senderId, "사용자", UserRole.USER); + User systemAdmin = createUser(SYSTEM_ADMIN_ID, "시스템관리자", UserRole.ADMIN); + ChatRoom systemAdminRoom = createRoom(1, ChatType.DIRECT, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember senderMember = createRoomMember(systemAdminRoom, sender, false, + LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember systemAdminMember = createRoomMember(systemAdminRoom, systemAdmin, false, + LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatMessage savedMessage = createMessage(100, systemAdminRoom, sender, content, + LocalDateTime.of(2026, 4, 11, 10, 1)); + + given(chatRoomRepository.findById(systemAdminRoom.getId())).willReturn(Optional.of(systemAdminRoom)); + given(userRepository.getById(senderId)).willReturn(sender); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(systemAdminRoom.getId(), senderId)) + .willReturn(Optional.of(senderMember)); + given(chatRoomMemberRepository.findByChatRoomId(systemAdminRoom.getId())) + .willReturn(List.of(systemAdminMember, senderMember)); + given(chatMessageRepository.save(any(ChatMessage.class))).willReturn(savedMessage); + given(chatRoomRepository.updateLastMessageIfLatest( + systemAdminRoom.getId(), savedMessage.getId(), savedMessage.getContent(), savedMessage.getCreatedAt() + )).willReturn(1); + given(chatRoomMemberRepository.updateLastReadAtIfOlder(eq(systemAdminRoom.getId()), eq(senderId), + any(LocalDateTime.class))) + .willReturn(1); + + // when + chatService.sendMessage(senderId, systemAdminRoom.getId(), new ChatMessageSendRequest(content)); + + // then + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(AdminChatReceivedEvent.class); + verify(eventPublisher).publishEvent(eventCaptor.capture()); + assertThat(eventCaptor.getValue()) + .extracting( + AdminChatReceivedEvent::senderId, + AdminChatReceivedEvent::senderName, + AdminChatReceivedEvent::content + ) + .containsExactly(senderId, sender.getName(), content); + } + @Test @DisplayName("sendMessage는 admin이 system admin room에 보내면 멤버십 체크를 건너뛰고 lastReadAt 업데이트도 하지 않는다") void sendMessageAdminBypassesMembershipInSystemAdminRoom() { @@ -1076,6 +1133,7 @@ void sendMessageAdminBypassesMembershipInSystemAdminRoom() { // 비관리자에게 알림이 전송되어야 한다 verify(notificationService).sendChatNotification(eq(targetUserId), eq(systemAdminRoom.getId()), eq("관리자"), eq("문의")); + verify(eventPublisher, never()).publishEvent(any(AdminChatReceivedEvent.class)); } // ===== toggleMute additional ===== From dba0ed561d8f40e67cd9936599d423e0a86bb170 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Tue, 28 Apr 2026 12:15:31 +0900 Subject: [PATCH 36/50] =?UTF-8?q?refactor:=20=EC=B1=84=ED=8C=85=EB=B0=A9?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1=20=EC=9E=AC=EC=82=AC=EC=9A=A9=20=EC=B1=85?= =?UTF-8?q?=EC=9E=84=20=EB=B6=84=EB=A6=AC=20(#605)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChatService에 남아 있던 direct 생성/재사용, SYSTEM_ADMIN 문의방 재사용, group 생성 흐름을 ChatRoomCreationService로 이동 - direct 재오픈과 SYSTEM_ADMIN 멤버십 예외 정책을 생성 서비스 안에서 보존 - ChatService는 생성 요청을 위임하도록 줄여 메시지/목록/멤버 명령 책임과의 경계를 명확히 함 --- .../chat/service/ChatRoomCreationService.java | 121 ++++++++++++++++++ .../service/ChatRoomMembershipService.java | 29 ++++- .../domain/chat/service/ChatService.java | 105 +-------------- .../domain/chat/service/ChatServiceTest.java | 15 +++ 4 files changed, 168 insertions(+), 102 deletions(-) create mode 100644 src/main/java/gg/agit/konect/domain/chat/service/ChatRoomCreationService.java diff --git a/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomCreationService.java b/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomCreationService.java new file mode 100644 index 000000000..8034a3311 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomCreationService.java @@ -0,0 +1,121 @@ +package gg.agit.konect.domain.chat.service; + +import static gg.agit.konect.domain.chat.service.ChatRoomMembershipService.SYSTEM_ADMIN_ID; +import static gg.agit.konect.global.code.ApiResponseCode.CANNOT_CREATE_CHAT_ROOM_WITH_SELF; +import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_USER; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import gg.agit.konect.domain.chat.dto.ChatRoomCreateRequest; +import gg.agit.konect.domain.chat.dto.ChatRoomResponse; +import gg.agit.konect.domain.chat.enums.ChatType; +import gg.agit.konect.domain.chat.model.ChatRoom; +import gg.agit.konect.domain.chat.model.ChatRoomMember; +import gg.agit.konect.domain.chat.repository.ChatRoomMemberRepository; +import gg.agit.konect.domain.chat.repository.ChatRoomRepository; +import gg.agit.konect.domain.user.enums.UserRole; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.domain.user.repository.UserRepository; +import gg.agit.konect.global.exception.CustomException; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional +public class ChatRoomCreationService { + + private final ChatRoomRepository chatRoomRepository; + private final ChatRoomMemberRepository chatRoomMemberRepository; + private final UserRepository userRepository; + private final ChatRoomMembershipService chatRoomMembershipService; + + public ChatRoomResponse createOrGetChatRoom(Integer currentUserId, ChatRoomCreateRequest request) { + User currentUser = userRepository.getById(currentUserId); + User targetUser = userRepository.getById(request.userId()); + + if (currentUser.getId().equals(targetUser.getId())) { + throw CustomException.of(CANNOT_CREATE_CHAT_ROOM_WITH_SELF); + } + + if (currentUser.isAdmin() && !targetUser.isAdmin()) { + return getOrCreateSystemAdminChatRoomForUser(targetUser, currentUser); + } + + ChatRoom chatRoom = chatRoomRepository.findByTwoUsers( + currentUser.getId(), + targetUser.getId(), + ChatType.DIRECT + ) + .orElseGet(() -> chatRoomRepository.save(ChatRoom.directOf())); + + LocalDateTime joinedAt = Objects.requireNonNull(chatRoom.getCreatedAt(), "chatRoom.createdAt must not be null"); + chatRoomMembershipService.ensureDirectRoomRequester(chatRoom, currentUser, joinedAt); + chatRoomMembershipService.ensureMember(chatRoom, targetUser, joinedAt); + + return ChatRoomResponse.from(chatRoom); + } + + public ChatRoomResponse createOrGetAdminChatRoom(Integer currentUserId) { + User adminUser = userRepository.findFirstByRoleAndDeletedAtIsNullOrderByIdAsc(UserRole.ADMIN) + .orElseThrow(() -> CustomException.of(NOT_FOUND_USER)); + + return createOrGetChatRoom(currentUserId, new ChatRoomCreateRequest(adminUser.getId())); + } + + public ChatRoomResponse createGroupChatRoom(Integer currentUserId, ChatRoomCreateRequest.Group request) { + User creator = userRepository.getById(currentUserId); + + List distinctUserIds = request.userIds().stream() + .distinct() + .filter(id -> !id.equals(currentUserId)) + .toList(); + + if (distinctUserIds.isEmpty()) { + throw CustomException.of(CANNOT_CREATE_CHAT_ROOM_WITH_SELF); + } + + List invitees = userRepository.findAllByIdIn(distinctUserIds); + if (invitees.size() != distinctUserIds.size()) { + throw CustomException.of(NOT_FOUND_USER); + } + + ChatRoom chatRoom = chatRoomRepository.save(ChatRoom.groupOf()); + LocalDateTime joinedAt = Objects.requireNonNull( + chatRoom.getCreatedAt(), "chatRoom.createdAt must not be null" + ); + + List members = new ArrayList<>(); + members.add(ChatRoomMember.ofOwner(chatRoom, creator, joinedAt)); + invitees.forEach(user -> members.add(ChatRoomMember.of(chatRoom, user, joinedAt))); + chatRoomMemberRepository.saveAll(members); + + return ChatRoomResponse.from(chatRoom); + } + + private ChatRoomResponse getOrCreateSystemAdminChatRoomForUser(User targetUser, User adminUser) { + ChatRoom chatRoom = chatRoomRepository.findByTwoUsers(SYSTEM_ADMIN_ID, targetUser.getId(), ChatType.DIRECT) + .orElseGet(() -> { + ChatRoom newRoom = chatRoomRepository.save(ChatRoom.directOf()); + User systemAdmin = userRepository.getById(SYSTEM_ADMIN_ID); + LocalDateTime joinedAt = Objects.requireNonNull( + newRoom.getCreatedAt(), "chatRoom.createdAt must not be null" + ); + chatRoomMembershipService.ensureMember(newRoom, systemAdmin, joinedAt); + chatRoomMembershipService.ensureMember(newRoom, targetUser, joinedAt); + return newRoom; + }); + + LocalDateTime joinedAt = Objects.requireNonNull( + chatRoom.getCreatedAt(), "chatRoom.createdAt must not be null" + ); + chatRoomMembershipService.ensureDirectRoomRequester(chatRoom, adminUser, joinedAt); + + return ChatRoomResponse.from(chatRoom); + } +} diff --git a/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMembershipService.java b/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMembershipService.java index ee6b3211a..012e11df5 100644 --- a/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMembershipService.java +++ b/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMembershipService.java @@ -148,7 +148,8 @@ private ChatRoom findOrCreateClubRoom(Club club) { }); } - private void ensureMember(ChatRoom room, User user, LocalDateTime baseline) { + @Transactional + public void ensureMember(ChatRoom room, User user, LocalDateTime baseline) { chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), user.getId()) .ifPresentOrElse(member -> { LocalDateTime lastReadAt = member.getLastReadAt(); @@ -158,6 +159,26 @@ private void ensureMember(ChatRoom room, User user, LocalDateTime baseline) { }, () -> saveRoomMemberIgnoringDuplicate(room, user, baseline)); } + @Transactional + public void ensureDirectRoomRequester(ChatRoom room, User user, LocalDateTime joinedAt) { + if (shouldSkipSystemAdminMembership(room, user)) { + return; + } + + chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), user.getId()) + .ifPresentOrElse(member -> { + if (member.hasLeft()) { + member.reopenDirectRoom(LocalDateTime.now()); + return; + } + + LocalDateTime lastReadAt = member.getLastReadAt(); + if (lastReadAt == null || lastReadAt.isBefore(joinedAt)) { + member.updateLastReadAt(joinedAt); + } + }, () -> saveRoomMemberIgnoringDuplicate(room, user, joinedAt)); + } + private void saveRoomMemberIgnoringDuplicate(ChatRoom room, User user, LocalDateTime baseline) { try { chatRoomMemberRepository.save(ChatRoomMember.of(room, user, baseline)); @@ -184,6 +205,12 @@ private void ensureDirectRoomMemberExists(ChatRoom room, User user, LocalDateTim throw CustomException.of(FORBIDDEN_CHAT_ROOM_ACCESS); } + private boolean shouldSkipSystemAdminMembership(ChatRoom room, User user) { + // 문의방은 SYSTEM_ADMIN + 일반 사용자 2인 구조를 전제로 재사용(findByTwoUsers)되므로, + // 생성/재오픈 경로에서도 일반 ADMIN을 멤버로 추가하면 안 된다. + return user.isAdmin() && chatRoomSystemAdminService.isSystemAdminRoom(room.getId()); + } + private boolean isDuplicateKeyException(DataIntegrityViolationException e) { if (e instanceof DuplicateKeyException) { return true; 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 4d1b3b2be..6a35671a0 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.List; import java.util.Map; -import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; @@ -76,95 +75,24 @@ public class ChatService { private final ChatSearchService chatSearchService; private final ChatInviteService chatInviteService; private final ChatMessagePageResolver chatMessagePageResolver; + private final ChatRoomCreationService chatRoomCreationService; private final ChatRoomSystemAdminService chatRoomSystemAdminService; private final ChatDirectRoomAccessService chatDirectRoomAccessService; private final ChatMessageSendService chatMessageSendService; @Transactional public ChatRoomResponse createOrGetChatRoom(Integer currentUserId, ChatRoomCreateRequest request) { - User currentUser = userRepository.getById(currentUserId); - User targetUser = userRepository.getById(request.userId()); - - if (currentUser.getId().equals(targetUser.getId())) { - throw CustomException.of(CANNOT_CREATE_CHAT_ROOM_WITH_SELF); - } - - if (currentUser.isAdmin() && !targetUser.isAdmin()) { - return getOrCreateSystemAdminChatRoomForUser(targetUser, currentUser); - } - - ChatRoom chatRoom = chatRoomRepository.findByTwoUsers( - currentUser.getId(), - targetUser.getId(), - ChatType.DIRECT - ) - .orElseGet(() -> chatRoomRepository.save(ChatRoom.directOf())); - - LocalDateTime joinedAt = Objects.requireNonNull(chatRoom.getCreatedAt(), "chatRoom.createdAt must not be null"); - ensureDirectRoomRequester(chatRoom, currentUser, joinedAt); - ensureRoomMember(chatRoom, targetUser, joinedAt); - - return ChatRoomResponse.from(chatRoom); - } - - private ChatRoomResponse getOrCreateSystemAdminChatRoomForUser(User targetUser, User adminUser) { - ChatRoom chatRoom = chatRoomRepository.findByTwoUsers(SYSTEM_ADMIN_ID, targetUser.getId(), ChatType.DIRECT) - .orElseGet(() -> { - ChatRoom newRoom = chatRoomRepository.save(ChatRoom.directOf()); - User systemAdmin = userRepository.getById(SYSTEM_ADMIN_ID); - LocalDateTime joinedAt = Objects.requireNonNull( - newRoom.getCreatedAt(), "chatRoom.createdAt must not be null" - ); - ensureRoomMember(newRoom, systemAdmin, joinedAt); - ensureRoomMember(newRoom, targetUser, joinedAt); - return newRoom; - }); - - LocalDateTime joinedAt = Objects.requireNonNull( - chatRoom.getCreatedAt(), "chatRoom.createdAt must not be null" - ); - ensureDirectRoomRequester(chatRoom, adminUser, joinedAt); - - return ChatRoomResponse.from(chatRoom); + return chatRoomCreationService.createOrGetChatRoom(currentUserId, request); } @Transactional public ChatRoomResponse createOrGetAdminChatRoom(Integer currentUserId) { - User adminUser = userRepository.findFirstByRoleAndDeletedAtIsNullOrderByIdAsc(UserRole.ADMIN) - .orElseThrow(() -> CustomException.of(NOT_FOUND_USER)); - - return createOrGetChatRoom(currentUserId, new ChatRoomCreateRequest(adminUser.getId())); + return chatRoomCreationService.createOrGetAdminChatRoom(currentUserId); } @Transactional public ChatRoomResponse createGroupChatRoom(Integer currentUserId, ChatRoomCreateRequest.Group request) { - User creator = userRepository.getById(currentUserId); - - List distinctUserIds = request.userIds().stream() - .distinct() - .filter(id -> !id.equals(currentUserId)) - .toList(); - - if (distinctUserIds.isEmpty()) { - throw CustomException.of(CANNOT_CREATE_CHAT_ROOM_WITH_SELF); - } - - List invitees = userRepository.findAllByIdIn(distinctUserIds); - if (invitees.size() != distinctUserIds.size()) { - throw CustomException.of(NOT_FOUND_USER); - } - - ChatRoom chatRoom = chatRoomRepository.save(ChatRoom.groupOf()); - LocalDateTime joinedAt = Objects.requireNonNull( - chatRoom.getCreatedAt(), "chatRoom.createdAt must not be null" - ); - - List members = new ArrayList<>(); - members.add(ChatRoomMember.ofOwner(chatRoom, creator, joinedAt)); - invitees.forEach(user -> members.add(ChatRoomMember.of(chatRoom, user, joinedAt))); - chatRoomMemberRepository.saveAll(members); - - return ChatRoomResponse.from(chatRoom); + return chatRoomCreationService.createGroupChatRoom(currentUserId, request); } @Transactional @@ -743,31 +671,6 @@ private void ensureRoomMember(ChatRoom room, User user, LocalDateTime joinedAt) }, () -> chatRoomMemberRepository.save(ChatRoomMember.of(room, user, joinedAt))); } - private void ensureDirectRoomRequester(ChatRoom room, User user, LocalDateTime joinedAt) { - if (shouldSkipSystemAdminMembership(room, user)) { - return; - } - - chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), user.getId()) - .ifPresentOrElse(member -> { - if (member.hasLeft()) { - member.reopenDirectRoom(LocalDateTime.now()); - return; - } - - LocalDateTime lastReadAt = member.getLastReadAt(); - if (lastReadAt == null || lastReadAt.isBefore(joinedAt)) { - member.updateLastReadAt(joinedAt); - } - }, () -> chatRoomMemberRepository.save(ChatRoomMember.of(room, user, joinedAt))); - } - - private boolean shouldSkipSystemAdminMembership(ChatRoom room, User user) { - // 문의방은 SYSTEM_ADMIN + 일반 사용자 2인 구조를 전제로 재사용(findByTwoUsers)되므로, - // 생성/재오픈 경로에서도 일반 ADMIN을 멤버로 추가하면 안 된다. - return user.isAdmin() && chatRoomSystemAdminService.isSystemAdminRoom(room.getId()); - } - private String normalizeCustomRoomName(String roomName) { if (!StringUtils.hasText(roomName)) { return null; 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 9f7380bb8..30747c3a4 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 @@ -56,6 +56,7 @@ import gg.agit.konect.domain.chat.service.ChatMessageSendService; import gg.agit.konect.domain.chat.service.ChatMessagePageResolver; import gg.agit.konect.domain.chat.service.ChatPresenceService; +import gg.agit.konect.domain.chat.service.ChatRoomCreationService; import gg.agit.konect.domain.chat.service.ChatRoomMembershipService; import gg.agit.konect.domain.chat.service.ChatRoomSummaryService; import gg.agit.konect.domain.chat.service.ChatSearchService; @@ -138,6 +139,19 @@ void setUp() { clubMemberRepository, chatRoomSystemAdminService ); + ChatRoomMembershipService chatRoomMembershipForCreation = new ChatRoomMembershipService( + chatRoomRepository, + chatRoomMemberRepository, + clubMemberRepository, + userRepository, + chatRoomSystemAdminService + ); + ChatRoomCreationService chatRoomCreationService = new ChatRoomCreationService( + chatRoomRepository, + chatRoomMemberRepository, + userRepository, + chatRoomMembershipForCreation + ); ChatMessageSendService chatMessageSendService = new ChatMessageSendService( chatRoomRepository, chatMessageRepository, @@ -163,6 +177,7 @@ void setUp() { chatSearchService, chatInviteService, chatMessagePageResolver, + chatRoomCreationService, chatRoomSystemAdminService, chatDirectRoomAccessService, chatMessageSendService From fc51a1a4b06a37dd7d9746e1567aff278526802a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Tue, 28 Apr 2026 14:43:49 +0900 Subject: [PATCH 37/50] =?UTF-8?q?refactor:=20=EC=B1=84=ED=8C=85=EB=B0=A9?= =?UTF-8?q?=20=EB=A9=A4=EB=B2=84=20=EB=AA=85=EB=A0=B9=20=EC=B1=85=EC=9E=84?= =?UTF-8?q?=20=EB=B6=84=EB=A6=AC=20(#609)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 채팅방 멤버 명령 책임 분리 - ChatService에 남아 있던 방 나가기와 멤버 강퇴 명령 흐름을 ChatRoomMemberCommandService로 이동 - direct 나가기는 leftAt 상태 변경으로 유지하고 group 나가기는 멤버 삭제 정책을 그대로 둠 - club group 나가기 거부, 비그룹 강퇴 거부, 자기 자신과 방장 강퇴 거부 정책을 기존 응답 코드와 함께 보존 * refactor: 채팅방 멤버 조회 정책 중복 제거 - ChatService와 ChatRoomMemberCommandService가 같은 멤버 조회 실패 정책을 재사용하도록 공용 helper로 분리 - Repository 기본 메서드는 Mockito 단위 테스트 stubbing과 맞지 않아 피하고, 기존 조회 쿼리 호출 경로를 유지 - 접근 정책/에러코드 변경 시 두 서비스가 서로 다른 예외를 던지는 회귀를 방지 * refactor: 채팅방 멤버 조회 helper 이름 정리 - 공용 조회 helper 이름을 서비스 호출부의 도메인 용어와 맞춰 읽기 쉽게 조정 - 조회 실패 시 FORBIDDEN_CHAT_ROOM_ACCESS로 변환하는 기존 정책은 그대로 유지 - 기능 변경 없이 리뷰 대응 코드의 의도를 더 직접적으로 드러냄 --- .../service/ChatRoomMemberCommandService.java | 90 +++++++++++++++++++ .../chat/service/ChatRoomMemberLookup.java | 22 +++++ .../domain/chat/service/ChatService.java | 57 +----------- .../domain/chat/service/ChatServiceTest.java | 6 ++ 4 files changed, 122 insertions(+), 53 deletions(-) create mode 100644 src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMemberCommandService.java create mode 100644 src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMemberLookup.java diff --git a/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMemberCommandService.java b/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMemberCommandService.java new file mode 100644 index 000000000..38d646965 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMemberCommandService.java @@ -0,0 +1,90 @@ +package gg.agit.konect.domain.chat.service; + +import static gg.agit.konect.global.code.ApiResponseCode.CANNOT_KICK_IN_NON_GROUP_ROOM; +import static gg.agit.konect.global.code.ApiResponseCode.CANNOT_KICK_ROOM_OWNER; +import static gg.agit.konect.global.code.ApiResponseCode.CANNOT_KICK_SELF; +import static gg.agit.konect.global.code.ApiResponseCode.CANNOT_LEAVE_GROUP_CHAT_ROOM; +import static gg.agit.konect.global.code.ApiResponseCode.FORBIDDEN_CHAT_ROOM_KICK; +import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_CHAT_ROOM; + +import java.time.LocalDateTime; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import gg.agit.konect.domain.chat.model.ChatRoom; +import gg.agit.konect.domain.chat.model.ChatRoomMember; +import gg.agit.konect.domain.chat.repository.ChatRoomMemberRepository; +import gg.agit.konect.domain.chat.repository.ChatRoomRepository; +import gg.agit.konect.global.exception.CustomException; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional +public class ChatRoomMemberCommandService { + + private final ChatRoomRepository chatRoomRepository; + private final ChatRoomMemberRepository chatRoomMemberRepository; + + public void leaveChatRoom(Integer userId, Integer roomId) { + ChatRoom room = chatRoomRepository.findById(roomId) + .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); + + if (room.isClubGroupRoom()) { + throw CustomException.of(CANNOT_LEAVE_GROUP_CHAT_ROOM); + } + + ChatRoomMember member = getRoomMember(roomId, userId); + if (room.isDirectRoom()) { + member.leaveDirectRoom(LocalDateTime.now()); + return; + } + + chatRoomMemberRepository.deleteByChatRoomIdAndUserId(roomId, userId); + } + + public void kickMember(Integer requesterId, Integer roomId, Integer targetUserId) { + ChatRoom room = chatRoomRepository.findById(roomId) + .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); + + validateGroupRoomForKick(room); + validateNotSelfKick(requesterId, targetUserId); + + ChatRoomMember requester = getRoomMember(roomId, requesterId); + validateKickAuthority(requester); + + ChatRoomMember target = getRoomMember(roomId, targetUserId); + validateNotOwnerTarget(target); + + chatRoomMemberRepository.deleteByChatRoomIdAndUserId(roomId, targetUserId); + } + + private ChatRoomMember getRoomMember(Integer roomId, Integer userId) { + return ChatRoomMemberLookup.getRoomMember(chatRoomMemberRepository, roomId, userId); + } + + private void validateGroupRoomForKick(ChatRoom room) { + if (!room.isGroupRoom() || room.isClubGroupRoom()) { + throw CustomException.of(CANNOT_KICK_IN_NON_GROUP_ROOM); + } + } + + private void validateNotSelfKick(Integer requesterId, Integer targetUserId) { + if (requesterId.equals(targetUserId)) { + throw CustomException.of(CANNOT_KICK_SELF); + } + } + + private void validateKickAuthority(ChatRoomMember requester) { + if (!requester.isOwner()) { + throw CustomException.of(FORBIDDEN_CHAT_ROOM_KICK); + } + } + + private void validateNotOwnerTarget(ChatRoomMember target) { + if (target.isOwner()) { + throw CustomException.of(CANNOT_KICK_ROOM_OWNER); + } + } +} diff --git a/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMemberLookup.java b/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMemberLookup.java new file mode 100644 index 000000000..238abb8b7 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMemberLookup.java @@ -0,0 +1,22 @@ +package gg.agit.konect.domain.chat.service; + +import static gg.agit.konect.global.code.ApiResponseCode.FORBIDDEN_CHAT_ROOM_ACCESS; + +import gg.agit.konect.domain.chat.model.ChatRoomMember; +import gg.agit.konect.domain.chat.repository.ChatRoomMemberRepository; +import gg.agit.konect.global.exception.CustomException; + +final class ChatRoomMemberLookup { + + private ChatRoomMemberLookup() { + } + + static ChatRoomMember getRoomMember( + ChatRoomMemberRepository chatRoomMemberRepository, + Integer roomId, + Integer userId + ) { + return chatRoomMemberRepository.findByChatRoomIdAndUserId(roomId, userId) + .orElseThrow(() -> CustomException.of(FORBIDDEN_CHAT_ROOM_ACCESS)); + } +} diff --git a/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java b/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java index 6a35671a0..8f54a4680 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 @@ -71,6 +71,7 @@ public class ChatService { private final UserRepository userRepository; private final ChatPresenceService chatPresenceService; private final ChatRoomMembershipService chatRoomMembershipService; + private final ChatRoomMemberCommandService chatRoomMemberCommandService; private final ChatRoomSummaryService chatRoomSummaryService; private final ChatSearchService chatSearchService; private final ChatInviteService chatInviteService; @@ -97,37 +98,12 @@ public ChatRoomResponse createGroupChatRoom(Integer currentUserId, ChatRoomCreat @Transactional public void leaveChatRoom(Integer userId, Integer roomId) { - ChatRoom room = chatRoomRepository.findById(roomId) - .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); - - if (room.isClubGroupRoom()) { - throw CustomException.of(CANNOT_LEAVE_GROUP_CHAT_ROOM); - } - - ChatRoomMember member = getRoomMember(roomId, userId); - if (room.isDirectRoom()) { - member.leaveDirectRoom(LocalDateTime.now()); - return; - } - - chatRoomMemberRepository.deleteByChatRoomIdAndUserId(roomId, userId); + chatRoomMemberCommandService.leaveChatRoom(userId, roomId); } @Transactional public void kickMember(Integer requesterId, Integer roomId, Integer targetUserId) { - ChatRoom room = chatRoomRepository.findById(roomId) - .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); - - validateGroupRoomForKick(room); - validateNotSelfKick(requesterId, targetUserId); - - ChatRoomMember requester = getRoomMember(roomId, requesterId); - validateKickAuthority(requester); - - ChatRoomMember target = getRoomMember(roomId, targetUserId); - validateNotOwnerTarget(target); - - chatRoomMemberRepository.deleteByChatRoomIdAndUserId(roomId, targetUserId); + chatRoomMemberCommandService.kickMember(requesterId, roomId, targetUserId); } public ChatRoomsSummaryResponse getChatRooms(Integer userId) { @@ -636,8 +612,7 @@ private Integer getMaskedAdminId(User user, ChatRoom chatRoom) { } private ChatRoomMember getRoomMember(Integer roomId, Integer userId) { - return chatRoomMemberRepository.findByChatRoomIdAndUserId(roomId, userId) - .orElseThrow(() -> CustomException.of(FORBIDDEN_CHAT_ROOM_ACCESS)); + return ChatRoomMemberLookup.getRoomMember(chatRoomMemberRepository, roomId, userId); } private ChatRoomMember getAccessibleRoomMember(ChatRoom room, Integer userId) { @@ -881,30 +856,6 @@ private User resolveDirectChatPartner( return findDirectPartnerFromMemberInfo(memberInfos, userId, userMap); } - private void validateGroupRoomForKick(ChatRoom room) { - if (!room.isGroupRoom() || room.isClubGroupRoom()) { - throw CustomException.of(CANNOT_KICK_IN_NON_GROUP_ROOM); - } - } - - private void validateNotSelfKick(Integer requesterId, Integer targetUserId) { - if (requesterId.equals(targetUserId)) { - throw CustomException.of(CANNOT_KICK_SELF); - } - } - - private void validateKickAuthority(ChatRoomMember requester) { - if (!requester.isOwner()) { - throw CustomException.of(FORBIDDEN_CHAT_ROOM_KICK); - } - } - - private void validateNotOwnerTarget(ChatRoomMember target) { - if (target.isOwner()) { - throw CustomException.of(CANNOT_KICK_ROOM_OWNER); - } - } - private void recordPresenceSafely(Integer roomId, Integer userId) { try { chatPresenceService.recordPresence(roomId, userId); 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 30747c3a4..347a5864f 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 @@ -57,6 +57,7 @@ import gg.agit.konect.domain.chat.service.ChatMessagePageResolver; import gg.agit.konect.domain.chat.service.ChatPresenceService; import gg.agit.konect.domain.chat.service.ChatRoomCreationService; +import gg.agit.konect.domain.chat.service.ChatRoomMemberCommandService; import gg.agit.konect.domain.chat.service.ChatRoomMembershipService; import gg.agit.konect.domain.chat.service.ChatRoomSummaryService; import gg.agit.konect.domain.chat.service.ChatSearchService; @@ -139,6 +140,10 @@ void setUp() { clubMemberRepository, chatRoomSystemAdminService ); + ChatRoomMemberCommandService chatRoomMemberCommandService = new ChatRoomMemberCommandService( + chatRoomRepository, + chatRoomMemberRepository + ); ChatRoomMembershipService chatRoomMembershipForCreation = new ChatRoomMembershipService( chatRoomRepository, chatRoomMemberRepository, @@ -173,6 +178,7 @@ void setUp() { userRepository, chatPresenceService, chatRoomMembershipService, + chatRoomMemberCommandService, chatRoomSummaryService, chatSearchService, chatInviteService, From bdc749b77dbbfb7dcf9774f942b20af0cfcd7914 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Tue, 28 Apr 2026 14:45:56 +0900 Subject: [PATCH 38/50] =?UTF-8?q?refactor:=20=EB=A9=94=EC=8B=9C=EC=A7=80?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20=EC=9D=91=EB=8B=B5=20=EC=A1=B0=EB=A6=BD?= =?UTF-8?q?=20=EC=B1=85=EC=9E=84=20=EB=B6=84=EB=A6=AC=20(#607)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 메시지 조회 응답 조립 책임 분리 - ChatService에 남아 있던 direct, SYSTEM_ADMIN, club group, group 메시지 응답 조립을 ChatMessageReadService로 이동 - readAt 갱신과 presence 기록 순서는 기존 서비스 흐름에 남겨 조회 side effect 순서를 유지 - 메시지 조회 단위/통합 테스트를 재실행해 가시 범위와 페이징 응답 동작을 보존 * chore: 코드 포맷팅 * Revert "chore: 코드 포맷팅" This reverts commit 2a3b87ddce50af8d75c545fefff385711b2ebc5a. * refactor: SYSTEM_ADMIN 마스킹 반환 흐름 단순화 - members 기반 SYSTEM_ADMIN 포함 여부 판단은 유지하면서 반환 흐름만 한 줄로 정리 - 리뷰 반영 코드의 의도를 바꾸지 않고 중복 분기만 줄임 * fix: 문의방 메시지 조회 응답 조립 단일화 - ChatService에 남아 있던 메시지 응답 조립 메서드를 제거해 ChatMessageReadService가 단일 구현을 갖도록 정리 - 일반 사용자 문의방 조회에서도 관리자 발신자가 SYSTEM_ADMIN으로 보이도록 masking 기준을 read service에서 적용 - 일반 사용자와 관리자 문의방 조회 테스트로 가시 범위, unreadCount, sender 표시 정책 회귀를 막음 - 전체 테스트는 통과했으며 checkstyleTest는 기존 club/notification/chat 테스트 장문 라인 위반이 남아 있어 별도 정리가 필요함 * fix: direct unread 기준에 가시 범위 반영 - direct 계열 메시지의 unread baseline을 lastReadAt 단독 기준에서 visibleMessageFrom을 함께 보는 기준으로 정리 - 나갔다가 복원된 멤버가 볼 수 없는 과거 메시지가 unreadCount에 포함되지 않도록 방지 - 문의방 조회 테스트에 visibleMessageFrom이 lastReadAt보다 늦은 멤버를 포함해 회귀를 검증 --- .../chat/service/ChatMessageReadService.java | 286 +++++++++++++++++ .../domain/chat/service/ChatService.java | 294 +----------------- .../domain/chat/service/ChatServiceTest.java | 93 +++++- 3 files changed, 379 insertions(+), 294 deletions(-) create mode 100644 src/main/java/gg/agit/konect/domain/chat/service/ChatMessageReadService.java diff --git a/src/main/java/gg/agit/konect/domain/chat/service/ChatMessageReadService.java b/src/main/java/gg/agit/konect/domain/chat/service/ChatMessageReadService.java new file mode 100644 index 000000000..2d1eab08f --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/chat/service/ChatMessageReadService.java @@ -0,0 +1,286 @@ +package gg.agit.konect.domain.chat.service; + +import static gg.agit.konect.domain.chat.service.ChatRoomMembershipService.SYSTEM_ADMIN_ID; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +import org.springframework.data.domain.Page; +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.ChatMessagePageResponse; +import gg.agit.konect.domain.chat.model.ChatMessage; +import gg.agit.konect.domain.chat.model.ChatRoom; +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 gg.agit.konect.domain.user.model.User; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ChatMessageReadService { + + private final ChatMessageRepository chatMessageRepository; + private final ChatRoomMemberRepository chatRoomMemberRepository; + private final ChatRoomSystemAdminService chatRoomSystemAdminService; + private final ChatDirectRoomAccessService chatDirectRoomAccessService; + + @Transactional + public ChatMessagePageResponse getDirectChatRoomMessages( + User user, + ChatRoom chatRoom, + Integer page, + Integer limit, + LocalDateTime readAt + ) { + Integer roomId = chatRoom.getId(); + List members = chatRoomMemberRepository.findByChatRoomId(roomId); + LocalDateTime visibleMessageFrom = + chatDirectRoomAccessService.prepareAccessAndGetVisibleMessageFrom(chatRoom, user); + + List sortedReadBaselines = toSortedReadBaselines(members); + Integer maskedAdminId = getMaskedAdminId(user, members); + + return buildDirectChatRoomMessages(user, roomId, page, limit, readAt, + visibleMessageFrom, sortedReadBaselines, maskedAdminId); + } + + public ChatMessagePageResponse getAdminSystemDirectChatRoomMessages( + User user, + ChatRoom chatRoom, + Integer page, + Integer limit, + LocalDateTime readAt + ) { + Integer roomId = chatRoom.getId(); + List members = chatRoomMemberRepository.findByChatRoomId(roomId); + LocalDateTime visibleMessageFrom = resolveAdminSystemRoomVisibleMessageFrom(members); + + List sortedReadBaselines = toAdminChatReadBaselines(members); + Integer maskedAdminId = getMaskedAdminId(user, members); + + return buildDirectChatRoomMessages(user, roomId, page, limit, readAt, + visibleMessageFrom, sortedReadBaselines, maskedAdminId); + } + + public ChatMessagePageResponse getClubMessagesByRoom( + ChatRoom room, + Integer userId, + Integer page, + Integer limit + ) { + Integer roomId = room.getId(); + PageRequest pageable = PageRequest.of(page - 1, limit); + long totalCount = chatMessageRepository.countByChatRoomId(roomId, null); + Page messagePage = chatMessageRepository.findByChatRoomId(roomId, null, pageable); + List messages = messagePage.getContent(); + List members = chatRoomMemberRepository.findByChatRoomId(roomId); + List sortedReadBaselines = toSortedReadBaselines(members); + + List responseMessages = messages.stream() + .map(message -> { + int unreadCount = countUnreadSince(message.getCreatedAt(), sortedReadBaselines); + return new ChatMessageDetailResponse( + message.getId(), + message.getSender().getId(), + message.getSender().getName(), + message.getContent(), + message.getCreatedAt(), + null, + unreadCount, + message.isSentBy(userId) + ); + }) + .toList(); + + int totalPage = limit > 0 ? (int)Math.ceil((double)totalCount / (double)limit) : 0; + return new ChatMessagePageResponse( + totalCount, + responseMessages.size(), + totalPage, + page, + room.getClub().getId(), + responseMessages + ); + } + + public ChatMessagePageResponse getGroupMessagesByRoom( + Integer roomId, + Integer userId, + Integer page, + Integer limit + ) { + PageRequest pageable = PageRequest.of(page - 1, limit); + long totalCount = chatMessageRepository.countByChatRoomId(roomId, null); + Page messagePage = chatMessageRepository.findByChatRoomId(roomId, null, pageable); + List messages = messagePage.getContent(); + List members = chatRoomMemberRepository.findByChatRoomId(roomId); + List sortedReadBaselines = toSortedReadBaselines(members); + + List responseMessages = messages.stream() + .map(message -> { + int unreadCount = countUnreadSince(message.getCreatedAt(), sortedReadBaselines); + return new ChatMessageDetailResponse( + message.getId(), + message.getSender().getId(), + message.getSender().getName(), + message.getContent(), + message.getCreatedAt(), + null, + unreadCount, + message.isSentBy(userId) + ); + }) + .toList(); + + int totalPage = limit > 0 ? (int)Math.ceil((double)totalCount / (double)limit) : 0; + return new ChatMessagePageResponse( + totalCount, + responseMessages.size(), + totalPage, + page, + null, + responseMessages + ); + } + + private ChatMessagePageResponse buildDirectChatRoomMessages( + User user, + Integer roomId, + Integer page, + Integer limit, + LocalDateTime readAt, + LocalDateTime visibleMessageFrom, + List sortedReadBaselines, + Integer maskedAdminId + ) { + PageRequest pageable = PageRequest.of(page - 1, limit); + Page messages = chatMessageRepository.findByChatRoomId(roomId, visibleMessageFrom, pageable); + + List responseMessages = messages.getContent().stream() + .map(message -> { + Integer senderId = maskedAdminId != null + ? resolveDirectSenderId(message, maskedAdminId) + : message.getSender().getId(); + boolean isMine = message.isSentBy(user.getId()); + boolean isRead = isMine || !message.getCreatedAt().isAfter(readAt); + int unreadCount = countUnreadSince(message.getCreatedAt(), sortedReadBaselines); + return new ChatMessageDetailResponse( + message.getId(), + senderId, + null, + message.getContent(), + message.getCreatedAt(), + isRead, + unreadCount, + isMine + ); + }) + .toList(); + + return new ChatMessagePageResponse( + messages.getTotalElements(), + messages.getNumberOfElements(), + messages.getTotalPages(), + messages.getNumber() + 1, + null, + responseMessages + ); + } + + private List toSortedReadBaselines(List members) { + return members.stream() + .map(this::resolveUnreadBaseline) + .sorted() + .toList(); + } + + private List toAdminChatReadBaselines(List members) { + LocalDateTime adminLastReadAt = null; + LocalDateTime userLastReadAt = null; + + for (ChatRoomMember member : members) { + LocalDateTime unreadBaseline = resolveUnreadBaseline(member); + if (member.getUser().isAdmin()) { + if (adminLastReadAt == null || unreadBaseline.isAfter(adminLastReadAt)) { + adminLastReadAt = unreadBaseline; + } + } else { + userLastReadAt = unreadBaseline; + } + } + + List baselines = new ArrayList<>(); + if (adminLastReadAt != null) { + baselines.add(adminLastReadAt); + } + if (userLastReadAt != null) { + baselines.add(userLastReadAt); + } + baselines.sort(Comparator.naturalOrder()); + return baselines; + } + + private LocalDateTime resolveUnreadBaseline(ChatRoomMember member) { + LocalDateTime lastReadAt = member.getLastReadAt(); + LocalDateTime visibleMessageFrom = member.getVisibleMessageFrom(); + + // direct 방에서 다시 보이기 시작한 시각 이전 메시지는 unreadCount에도 포함하지 않는다. + if (visibleMessageFrom == null) { + return lastReadAt; + } + if (lastReadAt == null) { + return visibleMessageFrom; + } + return lastReadAt.isAfter(visibleMessageFrom) ? lastReadAt : visibleMessageFrom; + } + + private int countUnreadSince(LocalDateTime messageCreatedAt, List sortedReadBaselines) { + int left = 0; + int right = sortedReadBaselines.size(); + + while (left < right) { + int mid = (left + right) >>> 1; + LocalDateTime baseline = sortedReadBaselines.get(mid); + + if (baseline.isBefore(messageCreatedAt)) { + left = mid + 1; + } else { + right = mid; + } + } + + return left; + } + + private LocalDateTime resolveAdminSystemRoomVisibleMessageFrom(List members) { + ChatRoomMember systemAdminMember = chatRoomSystemAdminService.findSystemAdminMember(members); + return systemAdminMember != null ? systemAdminMember.getVisibleMessageFrom() : null; + } + + private Integer resolveDirectSenderId(ChatMessage message, Integer maskedAdminId) { + if (maskedAdminId != null && message.getSender().isAdmin()) { + return maskedAdminId; + } + return message.getSender().getId(); + } + + private Integer getMaskedAdminId(User user, List members) { + if (user.isAdmin()) { + return null; + } + + boolean hasSystemAdmin = members.stream() + .map(ChatRoomMember::getUserId) + .anyMatch(memberUserId -> memberUserId.equals(SYSTEM_ADMIN_ID)); + + return hasSystemAdmin ? SYSTEM_ADMIN_ID : null; + } +} 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 8f54a4680..67c2d27b1 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 @@ -12,8 +12,6 @@ import java.util.Set; import java.util.stream.Collectors; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; @@ -33,7 +31,6 @@ import gg.agit.konect.domain.chat.dto.UnreadMessageCount; import gg.agit.konect.domain.chat.enums.ChatInviteSortBy; import gg.agit.konect.domain.chat.enums.ChatType; -import gg.agit.konect.domain.chat.model.ChatMessage; import gg.agit.konect.domain.chat.model.ChatRoom; import gg.agit.konect.domain.chat.model.ChatRoomMember; import gg.agit.konect.domain.chat.repository.ChatMessageRepository; @@ -75,6 +72,7 @@ public class ChatService { private final ChatRoomSummaryService chatRoomSummaryService; private final ChatSearchService chatSearchService; private final ChatInviteService chatInviteService; + private final ChatMessageReadService chatMessageReadService; private final ChatMessagePageResolver chatMessagePageResolver; private final ChatRoomCreationService chatRoomCreationService; private final ChatRoomSystemAdminService chatRoomSystemAdminService; @@ -162,25 +160,25 @@ public ChatMessagePageResponse getMessages( if (isAdminViewingSystemRoom) { chatRoomMembershipService.updateLastReadAt(roomId, SYSTEM_ADMIN_ID, readAt); recordPresenceSafely(roomId, userId); - return getAdminSystemDirectChatRoomMessages(user, room, roomId, page, limit, readAt); + return chatMessageReadService.getAdminSystemDirectChatRoomMessages(user, room, page, limit, readAt); } chatRoomMembershipService.updateDirectRoomLastReadAt(roomId, user, readAt, room); recordPresenceSafely(roomId, userId); - return getDirectChatRoomMessages(userId, roomId, page, limit, readAt); + return chatMessageReadService.getDirectChatRoomMessages(user, room, page, limit, readAt); } if (room.isClubGroupRoom()) { chatRoomMembershipService.ensureClubRoomMember(roomId, userId); chatRoomMembershipService.updateLastReadAt(roomId, userId, readAt); recordPresenceSafely(roomId, userId); - return getClubMessagesByRoomId(roomId, userId, page, limit); + return chatMessageReadService.getClubMessagesByRoom(room, userId, page, limit); } getAccessibleRoomMember(room, userId); chatRoomMembershipService.updateLastReadAt(roomId, userId, readAt); recordPresenceSafely(roomId, userId); - return getGroupMessagesByRoomId(roomId, userId, page, limit); + return chatMessageReadService.getGroupMessagesByRoom(roomId, userId, page, limit); } @Transactional @@ -363,173 +361,6 @@ private List getGroupChatRooms(Integer userId) { .toList(); } - private ChatMessagePageResponse buildDirectChatRoomMessages( - User user, - Integer roomId, - Integer page, - Integer limit, - LocalDateTime readAt, - LocalDateTime visibleMessageFrom, - List sortedReadBaselines, - Integer maskedAdminId - ) { - PageRequest pageable = PageRequest.of(page - 1, limit); - Page messages = chatMessageRepository.findByChatRoomId(roomId, visibleMessageFrom, pageable); - - List responseMessages = messages.getContent().stream() - .map(message -> { - Integer senderId = maskedAdminId != null - ? resolveDirectSenderId(message, maskedAdminId) - : message.getSender().getId(); - boolean isMine = maskedAdminId != null - ? shouldDisplayAsOwnMessage(user, message, true) - : message.isSentBy(user.getId()); - boolean isRead = isMine || !message.getCreatedAt().isAfter(readAt); - int unreadCount = countUnreadSince(message.getCreatedAt(), sortedReadBaselines); - return new ChatMessageDetailResponse( - message.getId(), - senderId, - null, - message.getContent(), - message.getCreatedAt(), - isRead, - unreadCount, - isMine - ); - }) - .toList(); - - return new ChatMessagePageResponse( - messages.getTotalElements(), - messages.getNumberOfElements(), - messages.getTotalPages(), - messages.getNumber() + 1, - null, - responseMessages - ); - } - - private ChatMessagePageResponse getDirectChatRoomMessages( - Integer userId, - Integer roomId, - Integer page, - Integer limit, - LocalDateTime readAt - ) { - ChatRoom chatRoom = getDirectRoom(roomId); - User user = userRepository.getById(userId); - List members = chatRoomMemberRepository.findByChatRoomId(roomId); - LocalDateTime visibleMessageFrom = - chatDirectRoomAccessService.prepareAccessAndGetVisibleMessageFrom(chatRoom, user); - - List sortedReadBaselines = toSortedReadBaselines(members); - - return buildDirectChatRoomMessages(user, roomId, page, limit, readAt, - visibleMessageFrom, sortedReadBaselines, null); - } - - private ChatMessagePageResponse getAdminSystemDirectChatRoomMessages( - User user, - ChatRoom chatRoom, - Integer roomId, - Integer page, - Integer limit, - LocalDateTime readAt - ) { - List members = chatRoomMemberRepository.findByChatRoomId(roomId); - LocalDateTime visibleMessageFrom = resolveAdminSystemRoomVisibleMessageFrom(members); - - List sortedReadBaselines = toAdminChatReadBaselines(members); - Integer maskedAdminId = getMaskedAdminId(user, chatRoom); - - return buildDirectChatRoomMessages(user, roomId, page, limit, readAt, - visibleMessageFrom, sortedReadBaselines, maskedAdminId); - } - - private ChatMessagePageResponse getClubMessagesByRoomId( - Integer roomId, - Integer userId, - Integer page, - Integer limit - ) { - ChatRoom room = getClubRoom(roomId); - - PageRequest pageable = PageRequest.of(page - 1, limit); - long totalCount = chatMessageRepository.countByChatRoomId(roomId, null); - Page messagePage = chatMessageRepository.findByChatRoomId(roomId, null, pageable); - List messages = messagePage.getContent(); - List members = chatRoomMemberRepository.findByChatRoomId(roomId); - List sortedReadBaselines = toSortedReadBaselines(members); - - List responseMessages = messages.stream() - .map(message -> { - int unreadCount = countUnreadSince(message.getCreatedAt(), sortedReadBaselines); - return new ChatMessageDetailResponse( - message.getId(), - message.getSender().getId(), - message.getSender().getName(), - message.getContent(), - message.getCreatedAt(), - null, - unreadCount, - message.isSentBy(userId) - ); - }) - .toList(); - - int totalPage = limit > 0 ? (int)Math.ceil((double)totalCount / (double)limit) : 0; - return new ChatMessagePageResponse( - totalCount, - responseMessages.size(), - totalPage, - page, - room.getClub().getId(), - responseMessages - ); - } - - private ChatMessagePageResponse getGroupMessagesByRoomId( - Integer roomId, - Integer userId, - Integer page, - Integer limit - ) { - chatRoomRepository.getById(roomId); - - PageRequest pageable = PageRequest.of(page - 1, limit); - long totalCount = chatMessageRepository.countByChatRoomId(roomId, null); - Page messagePage = chatMessageRepository.findByChatRoomId(roomId, null, pageable); - List messages = messagePage.getContent(); - List members = chatRoomMemberRepository.findByChatRoomId(roomId); - List sortedReadBaselines = toSortedReadBaselines(members); - - List responseMessages = messages.stream() - .map(message -> { - int unreadCount = countUnreadSince(message.getCreatedAt(), sortedReadBaselines); - return new ChatMessageDetailResponse( - message.getId(), - message.getSender().getId(), - message.getSender().getName(), - message.getContent(), - message.getCreatedAt(), - null, - unreadCount, - message.isSentBy(userId) - ); - }) - .toList(); - - int totalPage = limit > 0 ? (int)Math.ceil((double)totalCount / (double)limit) : 0; - return new ChatMessagePageResponse( - totalCount, - responseMessages.size(), - totalPage, - page, - null, - responseMessages - ); - } - private AccessibleChatRooms getAccessibleChatRooms(Integer userId) { List directRooms = getDirectChatRooms(userId); List clubRooms = getClubChatRooms(userId); @@ -546,26 +377,6 @@ private AccessibleChatRooms getAccessibleChatRooms(Integer userId) { return new AccessibleChatRooms(rooms, defaultRoomNameMap); } - private ChatRoom getDirectRoom(Integer roomId) { - ChatRoom chatRoom = chatRoomRepository.findById(roomId) - .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); - - if (!chatRoom.isDirectRoom()) { - throw CustomException.of(NOT_FOUND_CHAT_ROOM); - } - - return chatRoom; - } - - private ChatRoom getClubRoom(Integer roomId) { - ChatRoom room = chatRoomRepository.findById(roomId) - .orElseThrow(() -> CustomException.of(ApiResponseCode.NOT_FOUND_CHAT_ROOM)); - if (!room.isClubGroupRoom()) { - throw CustomException.of(ApiResponseCode.NOT_FOUND_GROUP_CHAT_ROOM); - } - return room; - } - private List extractChatRoomIds(List chatRooms) { return chatRooms.stream() .map(ChatRoom::getId) @@ -589,28 +400,6 @@ private Map getUnreadCountMap(List chatRoomIds, Integ )); } - private Integer getMaskedAdminId(User user, ChatRoom chatRoom) { - if (user.isAdmin()) { - return null; - } - - List memberResults = chatRoomMemberRepository.findRoomMemberIdsByChatRoomIds( - List.of(chatRoom.getId()) - ); - List memberInfos = memberResults.stream() - .map(row -> new MemberInfo((Integer)row[1], (LocalDateTime)row[2])) - .toList(); - - boolean hasSystemAdmin = memberInfos.stream() - .anyMatch(info -> info.userId().equals(SYSTEM_ADMIN_ID)); - - if (hasSystemAdmin) { - return SYSTEM_ADMIN_ID; - } - - return null; - } - private ChatRoomMember getRoomMember(Integer roomId, Integer userId) { return ChatRoomMemberLookup.getRoomMember(chatRoomMemberRepository, roomId, userId); } @@ -654,56 +443,6 @@ private String normalizeCustomRoomName(String roomName) { return roomName.trim(); } - private List toSortedReadBaselines(List members) { - return members.stream() - .map(ChatRoomMember::getLastReadAt) - .sorted() - .toList(); - } - - private List toAdminChatReadBaselines(List members) { - LocalDateTime adminLastReadAt = null; - LocalDateTime userLastReadAt = null; - - for (ChatRoomMember member : members) { - if (member.getUser().isAdmin()) { - if (adminLastReadAt == null || member.getLastReadAt().isAfter(adminLastReadAt)) { - adminLastReadAt = member.getLastReadAt(); - } - } else { - userLastReadAt = member.getLastReadAt(); - } - } - - List baselines = new ArrayList<>(); - if (adminLastReadAt != null) { - baselines.add(adminLastReadAt); - } - if (userLastReadAt != null) { - baselines.add(userLastReadAt); - } - baselines.sort(Comparator.naturalOrder()); - return baselines; - } - - private int countUnreadSince(LocalDateTime messageCreatedAt, List sortedReadBaselines) { - int left = 0; - int right = sortedReadBaselines.size(); - - while (left < right) { - int mid = (left + right) >>> 1; - LocalDateTime baseline = sortedReadBaselines.get(mid); - - if (baseline.isBefore(messageCreatedAt)) { - left = mid + 1; - } else { - right = mid; - } - } - - return left; - } - private Map getRoomUnreadCountMap(List roomIds, Integer userId) { if (roomIds.isEmpty()) { return Map.of(); @@ -728,29 +467,6 @@ private Map getRoomUnreadCountMap(List roomIds, Integ return unreadCountMap; } - private LocalDateTime resolveAdminSystemRoomVisibleMessageFrom(List members) { - ChatRoomMember systemAdminMember = chatRoomSystemAdminService.findSystemAdminMember(members); - return systemAdminMember != null ? systemAdminMember.getVisibleMessageFrom() : null; - } - - private boolean shouldDisplayAsOwnMessage( - User currentUser, - ChatMessage message, - boolean isAdminViewingSystemRoom - ) { - if (isAdminViewingSystemRoom) { - return message.getSender().isAdmin(); - } - return message.isSentBy(currentUser.getId()); - } - - private Integer resolveDirectSenderId(ChatMessage message, Integer maskedAdminId) { - if (maskedAdminId != null && message.getSender().isAdmin()) { - return maskedAdminId; - } - return message.getSender().getId(); - } - private ChatRoomMember findRoomMember(List members, Integer userId) { return members.stream() .filter(member -> member.getUserId().equals(userId)) 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 347a5864f..beb6c9623 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 @@ -12,6 +12,7 @@ import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_USER; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.tuple; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.nullable; @@ -53,6 +54,7 @@ import gg.agit.konect.domain.chat.repository.ChatRoomRepository; import gg.agit.konect.domain.chat.service.ChatDirectRoomAccessService; import gg.agit.konect.domain.chat.service.ChatInviteService; +import gg.agit.konect.domain.chat.service.ChatMessageReadService; import gg.agit.konect.domain.chat.service.ChatMessageSendService; import gg.agit.konect.domain.chat.service.ChatMessagePageResolver; import gg.agit.konect.domain.chat.service.ChatPresenceService; @@ -168,6 +170,12 @@ void setUp() { notificationService, eventPublisher ); + ChatMessageReadService chatMessageReadService = new ChatMessageReadService( + chatMessageRepository, + chatRoomMemberRepository, + chatRoomSystemAdminService, + chatDirectRoomAccessService + ); chatService = new ChatService( chatRoomRepository, chatRoomQueryRepository, @@ -182,6 +190,7 @@ void setUp() { chatRoomSummaryService, chatSearchService, chatInviteService, + chatMessageReadService, chatMessagePageResolver, chatRoomCreationService, chatRoomSystemAdminService, @@ -841,8 +850,16 @@ void getMessagesReturnsAdminSystemRoomMessages() { LocalDateTime.of(2026, 4, 11, 10, 0)); ChatRoomMember targetMember = createRoomMember(systemAdminRoom, targetUser, false, LocalDateTime.of(2026, 4, 11, 10, 0)); - ChatMessage message = createMessage(100, systemAdminRoom, admin, "문의", - LocalDateTime.of(2026, 4, 11, 10, 1)); + ReflectionTestUtils.setField(systemAdminMember, "visibleMessageFrom", + LocalDateTime.of(2026, 4, 11, 10, 5)); + ReflectionTestUtils.setField(targetMember, "visibleMessageFrom", + LocalDateTime.of(2026, 4, 11, 10, 7)); + systemAdminMember.updateLastReadAt(LocalDateTime.of(2026, 4, 11, 10, 7)); + targetMember.updateLastReadAt(LocalDateTime.of(2026, 4, 11, 10, 3)); + ChatMessage adminMessage = createMessage(100, systemAdminRoom, admin, "관리자 답변", + LocalDateTime.of(2026, 4, 11, 10, 6)); + ChatMessage userMessage = createMessage(101, systemAdminRoom, targetUser, "사용자 문의", + LocalDateTime.of(2026, 4, 11, 10, 8)); given(chatRoomRepository.findById(systemAdminRoom.getId())).willReturn(Optional.of(systemAdminRoom)); given(userRepository.getById(adminId)).willReturn(admin); @@ -851,20 +868,86 @@ void getMessagesReturnsAdminSystemRoomMessages() { .willReturn(systemAdminMember); given(chatRoomMemberRepository.findByChatRoomId(systemAdminRoom.getId())) .willReturn(List.of(systemAdminMember, targetMember)); - given(chatMessageRepository.findByChatRoomId(eq(systemAdminRoom.getId()), nullable(LocalDateTime.class), + given(chatMessageRepository.findByChatRoomId(eq(systemAdminRoom.getId()), + eq(systemAdminMember.getVisibleMessageFrom()), eq(PageRequest.of(0, 20)))) - .willReturn(new PageImpl<>(List.of(message), PageRequest.of(0, 20), 1)); + .willReturn(new PageImpl<>(List.of(adminMessage, userMessage), PageRequest.of(0, 20), 2)); // when ChatMessagePageResponse response = chatService.getMessages(adminId, systemAdminRoom.getId(), 1, 20); // then - assertThat(response.messages()).hasSize(1); + assertThat(response.messages()) + .extracting( + ChatMessageDetailResponse::senderId, + ChatMessageDetailResponse::content, + ChatMessageDetailResponse::unreadCount, + ChatMessageDetailResponse::isMine + ) + .containsExactly( + tuple(adminId, "관리자 답변", 0, true), + tuple(targetUser.getId(), "사용자 문의", 2, false) + ); verify(chatRoomMembershipService).updateLastReadAt(eq(systemAdminRoom.getId()), eq(SYSTEM_ADMIN_ID), any(LocalDateTime.class)); verify(chatPresenceService).recordPresence(systemAdminRoom.getId(), adminId); } + @Test + @DisplayName("getMessages는 일반 사용자의 system admin 방 조회에서 가시 범위와 sender masking을 적용한다") + void getMessagesAppliesVisibilityAndSenderMaskingForUserInSystemAdminRoom() { + // given + Integer userId = 20; + Integer adminId = 99; + User user = createUser(userId, "사용자", UserRole.USER); + User admin = createUser(adminId, "관리자", UserRole.ADMIN); + User systemAdmin = createUser(SYSTEM_ADMIN_ID, "시스템관리자", UserRole.ADMIN); + ChatRoom systemAdminRoom = createRoom(1, ChatType.DIRECT, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember userMember = createRoomMember(systemAdminRoom, user, false, + LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember systemAdminMember = createRoomMember(systemAdminRoom, systemAdmin, false, + LocalDateTime.of(2026, 4, 11, 10, 0)); + ReflectionTestUtils.setField(userMember, "visibleMessageFrom", LocalDateTime.of(2026, 4, 11, 10, 5)); + ReflectionTestUtils.setField(systemAdminMember, "visibleMessageFrom", + LocalDateTime.of(2026, 4, 11, 10, 7)); + userMember.updateLastReadAt(LocalDateTime.of(2026, 4, 11, 10, 7)); + systemAdminMember.updateLastReadAt(LocalDateTime.of(2026, 4, 11, 10, 3)); + ChatMessage adminMessage = createMessage(100, systemAdminRoom, admin, "관리자 답변", + LocalDateTime.of(2026, 4, 11, 10, 6)); + ChatMessage userMessage = createMessage(101, systemAdminRoom, user, "사용자 문의", + LocalDateTime.of(2026, 4, 11, 10, 8)); + + given(chatRoomRepository.findById(systemAdminRoom.getId())).willReturn(Optional.of(systemAdminRoom)); + given(userRepository.getById(userId)).willReturn(user); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(systemAdminRoom.getId(), userId)) + .willReturn(Optional.of(userMember)); + given(chatRoomMemberRepository.findByChatRoomId(systemAdminRoom.getId())) + .willReturn(List.of(systemAdminMember, userMember)); + given(chatMessageRepository.findByChatRoomId(eq(systemAdminRoom.getId()), + eq(userMember.getVisibleMessageFrom()), + eq(PageRequest.of(0, 20)))) + .willReturn(new PageImpl<>(List.of(adminMessage, userMessage), PageRequest.of(0, 20), 2)); + + // when + ChatMessagePageResponse response = chatService.getMessages(userId, systemAdminRoom.getId(), 1, 20); + + // then + assertThat(response.messages()) + .extracting( + ChatMessageDetailResponse::senderId, + ChatMessageDetailResponse::content, + ChatMessageDetailResponse::unreadCount, + ChatMessageDetailResponse::isMine + ) + .containsExactly( + tuple(SYSTEM_ADMIN_ID, "관리자 답변", 0, false), + tuple(userId, "사용자 문의", 2, true) + ); + verify(chatRoomMembershipService).updateDirectRoomLastReadAt(eq(systemAdminRoom.getId()), eq(user), + any(LocalDateTime.class), eq(systemAdminRoom)); + verify(chatPresenceService).recordPresence(systemAdminRoom.getId(), userId); + } + // ===== sendMessage ===== @Test From d33d0a223d90531b3a8a3f36fb54672f533ed8de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:00:38 +0900 Subject: [PATCH 39/50] =?UTF-8?q?refactor:=20=EC=B1=84=ED=8C=85=EB=B0=A9?= =?UTF-8?q?=20=EC=A0=91=EA=B7=BC=20=EA=B2=80=EC=A6=9D=20=EC=B1=85=EC=9E=84?= =?UTF-8?q?=20=EC=A0=95=EB=A6=AC=20(#608)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 채팅방 접근 검증 책임 정리 - ChatService에 남아 있던 direct, club group, group 접근 검증 흐름을 ChatRoomAccessService로 이동 - direct SYSTEM_ADMIN 문의방 예외와 club group 멤버 보장 정책을 기존 경로 그대로 유지 - 뮤트, 방 이름 변경, 메시지 조회 접근 검증을 한 서비스로 모아 후속 멤버 명령 분리 범위를 줄임 * refactor: 채팅방 접근 검증 사용자 조회 재사용 - 뮤트 접근 검증에서 이미 조회한 User를 재사용해 불필요한 getById 호출을 피함 - 일반 room 접근 검증은 userId 기반으로 유지해 기존 테스트 스텁과 정책을 보존 - group 방 이름 변경 경로에서 불필요한 사용자 조회가 발생하지 않도록 접근 검증 분기를 정리 * refactor: User 기반 접근 검증에서 재조회 제거 - 이미 받은 User 객체로 direct 방 접근 검증을 수행해 중복 사용자 조회를 피함 - club/group 접근 검증은 기존 userId 기반 정책과 예외 처리를 동일하게 유지 - 뮤트 접근 검증에서 사용자 조회 재사용 의도를 코드 경로에 직접 반영 * chore: 코드 포맷팅 * refactor: 접근 가능 멤버 검증 중복 제거 - userId 기반 호출과 User 기반 호출이 같은 방 유형 분기를 공유하도록 정리 - direct 방에서만 User 재사용 여부가 달라지는 조건을 helper 안으로 모아 불필요한 중복 분기를 줄임 - 기존 접근 검증 정책과 direct 방 사용자 재조회 회피 동작은 그대로 유지 * refactor: 클럽 채팅방 접근 검증 재조회 제거 - 이미 조회한 ChatRoom을 가진 접근 검증 경로에서는 roomId 기반 재조회 대신 ChatRoom 기반 멤버 보장 경로를 사용합니다. - 기존 roomId 기반 API는 유지해 다른 호출부 호환성을 보존합니다. - 재조회 없이 멤버 보장이 이뤄지는 회귀 테스트를 추가해 같은 성능 회귀가 반복되지 않게 했습니다. * refactor: 채팅방 접근 검증 분기 단순화 - nullable User 인자로 직접 채팅방 재조회 여부를 표현하던 흐름을 제거했습니다. - direct 방과 non-direct 방 분기를 public 진입점에서 명확히 나눠 읽기 쉽게 정리했습니다. - 기존 접근 검증 정책과 재조회 제거 동작은 유지했습니다. --- .../chat/service/ChatRoomAccessService.java | 69 +++++++++++++++++++ .../service/ChatRoomMembershipService.java | 5 ++ .../domain/chat/service/ChatService.java | 40 ++--------- .../ChatRoomMembershipServiceTest.java | 22 ++++++ .../domain/chat/service/ChatServiceTest.java | 14 +++- 5 files changed, 112 insertions(+), 38 deletions(-) create mode 100644 src/main/java/gg/agit/konect/domain/chat/service/ChatRoomAccessService.java diff --git a/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomAccessService.java b/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomAccessService.java new file mode 100644 index 000000000..d7dc78b05 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomAccessService.java @@ -0,0 +1,69 @@ +package gg.agit.konect.domain.chat.service; + +import static gg.agit.konect.global.code.ApiResponseCode.FORBIDDEN_CHAT_ROOM_ACCESS; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import gg.agit.konect.domain.chat.model.ChatRoom; +import gg.agit.konect.domain.chat.model.ChatRoomMember; +import gg.agit.konect.domain.chat.repository.ChatRoomMemberRepository; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.domain.user.repository.UserRepository; +import gg.agit.konect.global.exception.CustomException; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ChatRoomAccessService { + + private final ChatRoomMemberRepository chatRoomMemberRepository; + private final UserRepository userRepository; + private final ChatRoomMembershipService chatRoomMembershipService; + private final ChatRoomSystemAdminService chatRoomSystemAdminService; + private final ChatDirectRoomAccessService chatDirectRoomAccessService; + + public ChatRoomMember getAccessibleMember(ChatRoom room, Integer userId) { + if (room.isDirectRoom()) { + User user = userRepository.getById(userId); + return chatDirectRoomAccessService.getAccessibleMember(room, user); + } + + return getAccessibleNonDirectMember(room, userId); + } + + public ChatRoomMember getAccessibleMember(ChatRoom room, User user) { + if (room.isDirectRoom()) { + return chatDirectRoomAccessService.getAccessibleMember(room, user); + } + + return getAccessibleNonDirectMember(room, user.getId()); + } + + private ChatRoomMember getAccessibleNonDirectMember(ChatRoom room, Integer userId) { + if (room.isClubGroupRoom()) { + chatRoomMembershipService.ensureClubRoomMember(room, userId); + return getRoomMember(room.getId(), userId); + } + + ChatRoomMember member = getRoomMember(room.getId(), userId); + if (member.hasLeft()) { + throw CustomException.of(FORBIDDEN_CHAT_ROOM_ACCESS); + } + return member; + } + + public void ensureMuteAccess(ChatRoom room, User user) { + if (room.isDirectRoom() && user.isAdmin() && chatRoomSystemAdminService.isSystemAdminRoom(room.getId())) { + return; + } + + getAccessibleMember(room, user); + } + + private ChatRoomMember getRoomMember(Integer roomId, Integer userId) { + return chatRoomMemberRepository.findByChatRoomIdAndUserId(roomId, userId) + .orElseThrow(() -> CustomException.of(FORBIDDEN_CHAT_ROOM_ACCESS)); + } +} diff --git a/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMembershipService.java b/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMembershipService.java index 012e11df5..987f13866 100644 --- a/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMembershipService.java +++ b/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMembershipService.java @@ -125,6 +125,11 @@ public void updateDirectRoomLastReadAt(Integer roomId, User user, LocalDateTime public void ensureClubRoomMember(Integer roomId, Integer userId) { ChatRoom room = chatRoomRepository.findById(roomId) .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); + ensureClubRoomMember(room, userId); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void ensureClubRoomMember(ChatRoom room, Integer userId) { if (!room.isGroupRoom() || room.getClub() == null) { throw CustomException.of(NOT_FOUND_CHAT_ROOM); } 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 67c2d27b1..f647730ae 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 @@ -74,6 +74,7 @@ public class ChatService { private final ChatInviteService chatInviteService; private final ChatMessageReadService chatMessageReadService; private final ChatMessagePageResolver chatMessagePageResolver; + private final ChatRoomAccessService chatRoomAccessService; private final ChatRoomCreationService chatRoomCreationService; private final ChatRoomSystemAdminService chatRoomSystemAdminService; private final ChatDirectRoomAccessService chatDirectRoomAccessService; @@ -175,7 +176,7 @@ public ChatMessagePageResponse getMessages( return chatMessageReadService.getClubMessagesByRoom(room, userId, page, limit); } - getAccessibleRoomMember(room, userId); + chatRoomAccessService.getAccessibleMember(room, userId); chatRoomMembershipService.updateLastReadAt(roomId, userId, readAt); recordPresenceSafely(roomId, userId); return chatMessageReadService.getGroupMessagesByRoom(roomId, userId, page, limit); @@ -192,19 +193,7 @@ public ChatMuteResponse toggleMute(Integer userId, Integer roomId) { .orElseThrow(() -> CustomException.of(ApiResponseCode.NOT_FOUND_CHAT_ROOM)); User user = userRepository.getById(userId); - if (room.isClubGroupRoom()) { - ClubMember member = clubMemberRepository.getByClubIdAndUserId(room.getClub().getId(), userId); - ensureRoomMember(room, member.getUser(), member.getCreatedAt()); - } else if (room.isDirectRoom()) { - // 어드민이 SYSTEM_ADMIN 방에 접근하는 경우는 멤버십 체크를 건너뜀 - boolean isAdminAccessingSystemAdminRoom = user.isAdmin() - && chatRoomSystemAdminService.isSystemAdminRoom(room.getId()); - if (!isAdminAccessingSystemAdminRoom) { - chatDirectRoomAccessService.getAccessibleMember(room, user); - } - } else { - getAccessibleRoomMember(room, userId); - } + chatRoomAccessService.ensureMuteAccess(room, user); Boolean isMuted = notificationMuteSettingRepository.findByTargetTypeAndTargetIdAndUserId( NotificationTargetType.CHAT_ROOM, roomId, @@ -233,7 +222,7 @@ public void updateChatRoomName(Integer userId, Integer roomId, ChatRoomNameUpdat ChatRoom room = chatRoomRepository.findById(roomId) .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); - ChatRoomMember roomMember = getAccessibleRoomMember(room, userId); + ChatRoomMember roomMember = chatRoomAccessService.getAccessibleMember(room, userId); roomMember.updateCustomRoomName(normalizeCustomRoomName(request.roomName())); } @@ -404,27 +393,6 @@ private ChatRoomMember getRoomMember(Integer roomId, Integer userId) { return ChatRoomMemberLookup.getRoomMember(chatRoomMemberRepository, roomId, userId); } - private ChatRoomMember getAccessibleRoomMember(ChatRoom room, Integer userId) { - if (room.isClubGroupRoom()) { - ClubMember member = clubMemberRepository.getByClubIdAndUserId(room.getClub().getId(), userId); - ensureRoomMember(room, member.getUser(), member.getCreatedAt()); - return getRoomMember(room.getId(), userId); - } - - if (room.isDirectRoom()) { - User user = userRepository.getById(userId); - return chatDirectRoomAccessService.getAccessibleMember(room, user); - } - - ChatRoomMember member = getRoomMember(room.getId(), userId); - - if (member.hasLeft()) { - throw CustomException.of(FORBIDDEN_CHAT_ROOM_ACCESS); - } - - return member; - } - private void ensureRoomMember(ChatRoom room, User user, LocalDateTime joinedAt) { chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), user.getId()) .ifPresentOrElse(member -> { diff --git a/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatRoomMembershipServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatRoomMembershipServiceTest.java index c8eed3436..41afc706d 100644 --- a/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatRoomMembershipServiceTest.java +++ b/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatRoomMembershipServiceTest.java @@ -418,6 +418,28 @@ void ensureClubRoomMemberCreatesOrUpdatesMemberFromClubMemberBaseline() { verify(chatRoomMemberRepository).save(any(ChatRoomMember.class)); } + @Test + @DisplayName("ensureClubRoomMember는 이미 조회한 club group room을 재조회하지 않고 멤버를 보장한다") + void ensureClubRoomMemberUsesProvidedRoomWithoutRefetch() { + // given + Club club = createClub(10); + ChatRoom room = createRoom(30, ChatType.CLUB_GROUP, LocalDateTime.of(2026, 4, 11, 9, 0)); + ReflectionTestUtils.setField(room, "club", club); + User user = createUser(20, "동아리원", UserRole.USER); + ClubMember clubMember = createClubMember(club, user, LocalDateTime.of(2026, 4, 11, 10, 0)); + + given(clubMemberRepository.getByClubIdAndUserId(club.getId(), user.getId())).willReturn(clubMember); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), user.getId())) + .willReturn(Optional.empty()); + + // when + chatRoomMembershipService.ensureClubRoomMember(room, user.getId()); + + // then + verify(chatRoomRepository, never()).findById(any()); + verify(chatRoomMemberRepository).save(any(ChatRoomMember.class)); + } + @Test @DisplayName("updateLastReadAt는 저장된 값이 더 오래된 경우에만 갱신 쿼리를 위임한다") void updateLastReadAtDelegatesConditionalUpdate() { 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 beb6c9623..f8c7e6a68 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 @@ -55,15 +55,16 @@ import gg.agit.konect.domain.chat.service.ChatDirectRoomAccessService; import gg.agit.konect.domain.chat.service.ChatInviteService; import gg.agit.konect.domain.chat.service.ChatMessageReadService; -import gg.agit.konect.domain.chat.service.ChatMessageSendService; import gg.agit.konect.domain.chat.service.ChatMessagePageResolver; +import gg.agit.konect.domain.chat.service.ChatMessageSendService; import gg.agit.konect.domain.chat.service.ChatPresenceService; +import gg.agit.konect.domain.chat.service.ChatRoomAccessService; import gg.agit.konect.domain.chat.service.ChatRoomCreationService; import gg.agit.konect.domain.chat.service.ChatRoomMemberCommandService; import gg.agit.konect.domain.chat.service.ChatRoomMembershipService; import gg.agit.konect.domain.chat.service.ChatRoomSummaryService; -import gg.agit.konect.domain.chat.service.ChatSearchService; import gg.agit.konect.domain.chat.service.ChatRoomSystemAdminService; +import gg.agit.konect.domain.chat.service.ChatSearchService; import gg.agit.konect.domain.chat.service.ChatService; import gg.agit.konect.domain.club.model.Club; import gg.agit.konect.domain.club.model.ClubMember; @@ -142,6 +143,13 @@ void setUp() { clubMemberRepository, chatRoomSystemAdminService ); + ChatRoomAccessService chatRoomAccessService = new ChatRoomAccessService( + chatRoomMemberRepository, + userRepository, + chatRoomMembershipService, + chatRoomSystemAdminService, + chatDirectRoomAccessService + ); ChatRoomMemberCommandService chatRoomMemberCommandService = new ChatRoomMemberCommandService( chatRoomRepository, chatRoomMemberRepository @@ -192,6 +200,7 @@ void setUp() { chatInviteService, chatMessageReadService, chatMessagePageResolver, + chatRoomAccessService, chatRoomCreationService, chatRoomSystemAdminService, chatDirectRoomAccessService, @@ -502,6 +511,7 @@ void toggleMuteTogglesFromUnmutedToMuted() { // then assertThat(response.isMuted()).isTrue(); assertThat(setting.getIsMuted()).isTrue(); + verify(userRepository, times(1)).getById(userId); verify(notificationMuteSettingRepository).save(setting); } From 6b4a3c59e7892c72b405d660b282515b9b4ae09f Mon Sep 17 00:00:00 2001 From: JanooGwan Date: Tue, 28 Apr 2026 20:34:17 +0900 Subject: [PATCH 40/50] =?UTF-8?q?chore:=20develop=20=EB=B0=B0=ED=8F=AC=20?= =?UTF-8?q?=EB=B9=8C=EB=93=9C=EC=97=90=EC=84=9C=20Java=20=EC=97=90?= =?UTF-8?q?=EC=9D=B4=EC=A0=84=ED=8A=B8=20=EC=98=B5=EC=85=98=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy-prod.yml | 1 + .github/workflows/deploy-stage.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml index c6ea6dd3b..3cb97b6f6 100644 --- a/.github/workflows/deploy-prod.yml +++ b/.github/workflows/deploy-prod.yml @@ -41,6 +41,7 @@ jobs: set -a source .env.example set +a + unset JAVA_TOOL_OPTIONS ./gradlew bootJar -x test --build-cache -Dspring.profiles.active=prod - name: Set up Docker Buildx diff --git a/.github/workflows/deploy-stage.yml b/.github/workflows/deploy-stage.yml index c50e4ac76..fd99bd0c7 100644 --- a/.github/workflows/deploy-stage.yml +++ b/.github/workflows/deploy-stage.yml @@ -41,6 +41,7 @@ jobs: set -a source .env.example set +a + unset JAVA_TOOL_OPTIONS ./gradlew bootJar -x test --build-cache -Dspring.profiles.active=stage - name: Set up Docker Buildx From f2240c14b17fac53daf790d5345907a6f310bb96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Tue, 28 Apr 2026 22:28:43 +0900 Subject: [PATCH 41/50] =?UTF-8?q?perf:=20=EC=9A=94=EC=B2=AD=20=EB=A1=9C?= =?UTF-8?q?=EA=B9=85=EC=9D=98=20=EB=B6=88=ED=95=84=EC=9A=94=ED=95=9C=20?= =?UTF-8?q?=EC=BA=90=EC=8B=B1=20=EB=B9=84=EC=9A=A9=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 요청/응답 본문을 기록하지 않는 로깅 경로에서 응답 캐싱 래퍼와 본문 복사 비용을 제거합니다. 예외 처리 디버그 로그가 기존처럼 요청 본문을 확인할 수 있도록 요청 캐싱 래퍼는 유지하고, 관련 테스트로 동작을 고정합니다. --- .../global/logging/RequestLoggingFilter.java | 13 +++--- src/main/resources/logback-spring.xml | 2 +- .../logging/RequestLoggingFilterTest.java | 41 ++++++++++++++++++- 3 files changed, 46 insertions(+), 10 deletions(-) diff --git a/src/main/java/gg/agit/konect/global/logging/RequestLoggingFilter.java b/src/main/java/gg/agit/konect/global/logging/RequestLoggingFilter.java index 653c762e3..d676ab8cd 100644 --- a/src/main/java/gg/agit/konect/global/logging/RequestLoggingFilter.java +++ b/src/main/java/gg/agit/konect/global/logging/RequestLoggingFilter.java @@ -16,7 +16,6 @@ import org.springframework.web.cors.CorsUtils; import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.web.util.ContentCachingRequestWrapper; -import org.springframework.web.util.ContentCachingResponseWrapper; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; @@ -50,8 +49,9 @@ public void doFilterInternal(HttpServletRequest request, HttpServletResponse res return; } - var cachedRequest = new ContentCachingRequestWrapper(request); - var cachedResponse = new ContentCachingResponseWrapper(response); + HttpServletRequest wrappedRequest = request instanceof ContentCachingRequestWrapper + ? request + : new ContentCachingRequestWrapper(request); StopWatch stopWatch = new StopWatch(); String requestId = getRequestId(httpRequest); String method = httpRequest.getMethod(); @@ -59,16 +59,15 @@ public void doFilterInternal(HttpServletRequest request, HttpServletResponse res try { MDC.put(REQUEST_ID, requestId); - cachedResponse.setHeader(REQUEST_ID_HEADER, requestId); + response.setHeader(REQUEST_ID_HEADER, requestId); stopWatch.start(); log.info("request start [requestId: {}, uri: {} {}]", requestId, method, uri); - chain.doFilter(cachedRequest, cachedResponse); + chain.doFilter(wrappedRequest, response); } finally { stopWatch.stop(); log.info("request end [requestId: {}, uri: {} {}, time: {}ms, status: {}]", - requestId, method, uri, stopWatch.getTotalTimeMillis(), cachedResponse.getStatus()); + requestId, method, uri, stopWatch.getTotalTimeMillis(), response.getStatus()); MDC.remove(REQUEST_ID); - cachedResponse.copyBodyToResponse(); } } diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml index 4d19fd7c2..688f2a925 100644 --- a/src/main/resources/logback-spring.xml +++ b/src/main/resources/logback-spring.xml @@ -1,5 +1,5 @@ - + diff --git a/src/test/java/gg/agit/konect/unit/global/logging/RequestLoggingFilterTest.java b/src/test/java/gg/agit/konect/unit/global/logging/RequestLoggingFilterTest.java index cc874210b..7c8b3fd7c 100644 --- a/src/test/java/gg/agit/konect/unit/global/logging/RequestLoggingFilterTest.java +++ b/src/test/java/gg/agit/konect/unit/global/logging/RequestLoggingFilterTest.java @@ -9,13 +9,17 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.support.StaticListableBeanFactory; -import org.springframework.util.AntPathMatcher; -import org.springframework.util.PathMatcher; import org.springframework.mock.web.MockFilterChain; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.util.AntPathMatcher; +import org.springframework.util.PathMatcher; +import org.springframework.web.util.ContentCachingRequestWrapper; +import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; import jakarta.servlet.http.HttpServlet; import gg.agit.konect.global.logging.LoggingProperties; @@ -91,6 +95,22 @@ void generatesRequestIdForInvalidHeader() throws ServletException, IOException { .matches("[A-Za-z0-9._-]{1,128}"); } + @Test + @DisplayName("요청 본문 캐시는 유지하되 원본 response를 그대로 전달한다") + void wrapsRequestOnlyForExceptionBodyLogging() throws ServletException, IOException { + // given + RequestLoggingFilter filter = createFilter(); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/notifications/inbox"); + MockHttpServletResponse response = new MockHttpServletResponse(); + RequestWrapperResponseAssertFilterChain chain = new RequestWrapperResponseAssertFilterChain(response); + + // when + filter.doFilter(request, response, chain); + + // then + assertThat(chain.invoked).isTrue(); + } + private RequestLoggingFilter createFilter() { StaticListableBeanFactory beanFactory = new StaticListableBeanFactory(); beanFactory.addBean("pathMatcher", new AntPathMatcher()); @@ -106,4 +126,21 @@ protected void service(jakarta.servlet.http.HttpServletRequest req, resp.setStatus(MockHttpServletResponse.SC_OK); } } + + private static class RequestWrapperResponseAssertFilterChain implements FilterChain { + + private final ServletResponse expectedResponse; + private boolean invoked; + + private RequestWrapperResponseAssertFilterChain(ServletResponse expectedResponse) { + this.expectedResponse = expectedResponse; + } + + @Override + public void doFilter(ServletRequest request, ServletResponse response) { + invoked = true; + assertThat(request).isInstanceOf(ContentCachingRequestWrapper.class); + assertThat(response).isSameAs(expectedResponse); + } + } } From 626a0a26a4f520ef4344053b4a82f3e396233212 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Wed, 29 Apr 2026 13:40:18 +0900 Subject: [PATCH 42/50] =?UTF-8?q?feat:=20GROUP=20=EC=B1=84=ED=8C=85?= =?UTF-8?q?=EB=B0=A9=20=EB=A9=A4=EB=B2=84=20=EC=B4=88=EB=8C=80=20API=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#619)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: GROUP 채팅방 멤버 초대 API 추가 - 기존 GROUP 채팅방의 active 멤버라면 방장 여부와 관계없이 초대할 수 있도록 권한 기준을 명확히 합니다. - DIRECT와 CLUB_GROUP은 초대 대상에서 제외해 타입별 멤버십 정책이 섞이지 않게 합니다. - 요청 DTO와 에러 코드를 분리해 기존 그룹 채팅방 초대 계약을 생성 API와 구분합니다. - 기존 서비스 테스트 fixture는 새 명령 서비스 의존성에 맞춰 컴파일 가능한 상태를 유지합니다. * test: GROUP 채팅방 멤버 초대 검증 추가 - active 멤버가 여러 사용자를 초대하는 성공 흐름을 통합 테스트로 고정합니다. - 방장이 아닌 멤버도 초대할 수 있는 정책을 검증해 권한 기준이 강퇴 정책과 섞이지 않게 합니다. - 비멤버, 비-GROUP 방, 존재하지 않는 사용자 입력을 실패 케이스로 고정합니다. - 중복 입력과 기존 멤버, 요청자 자신을 무시하는 수렴 동작을 단위 테스트로 보강합니다. * chore: 코드 포맷팅 * refactor: 그룹 채팅방 초대 명령 가독성 정리 - 중복 userId 제거를 컬렉션 변환 대신 stream distinct 흐름으로 표현해 의도를 바로 드러냅니다. - 단위 테스트가 새 멤버만 추가되는지 직접 검증하도록 바꿔 self와 기존 멤버 제외 정책을 더 명확히 고정합니다. - 초대 API의 동작은 유지하면서 최근 변경 범위의 읽기 쉬운 구조만 정리합니다. * fix: 채팅방 초대 리뷰 피드백 반영 - 초대 요청의 userIds 원소에 null 검증을 추가해 잘못된 입력을 API 경계에서 차단합니다. - 기존 active 멤버 여부를 초대 대상별 반복 조회하지 않고 IN 쿼리로 한 번에 조회해 초대 인원 증가 시 쿼리 반복을 막습니다. - 단위 테스트에서 실제 추가 대상과 호출 횟수를 고정해 self, 기존 멤버, 중복 입력 제외 정책을 더 정확히 검증합니다. - CLUB_GROUP 초대 금지와 null userId validation 회귀 테스트를 추가합니다. * refactor: 채팅방 초대 대상 필터링 표현 정리 - 중복 제거된 요청 ID라는 의미가 드러나도록 변수명을 정리합니다. - 순서 보존이 필요 없는 active 멤버 ID 집합은 Set.copyOf로 구성해 필터링 의도를 단순하게 표현합니다. - 초대 동작은 유지하면서 리뷰 피드백 반영 코드의 읽기 쉬운 형태만 다듬습니다. --- .../domain/chat/controller/ChatApi.java | 27 ++- .../chat/controller/ChatController.java | 11 + .../dto/ChatRoomMembersInviteRequest.java | 16 ++ .../repository/ChatRoomMemberRepository.java | 12 ++ .../service/ChatRoomMemberCommandService.java | 46 +++++ .../domain/chat/service/ChatService.java | 6 + .../konect/global/code/ApiResponseCode.java | 1 + .../integration/domain/chat/ChatApiTest.java | 161 +++++++++++++++ .../ChatRoomMemberCommandServiceTest.java | 195 ++++++++++++++++++ .../domain/chat/service/ChatServiceTest.java | 4 +- 10 files changed, 476 insertions(+), 3 deletions(-) create mode 100644 src/main/java/gg/agit/konect/domain/chat/dto/ChatRoomMembersInviteRequest.java create mode 100644 src/test/java/gg/agit/konect/unit/domain/chat/service/ChatRoomMemberCommandServiceTest.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 d91bca18c..39061aa43 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 @@ -16,6 +16,7 @@ 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.ChatRoomMembersInviteRequest; import gg.agit.konect.domain.chat.dto.ChatRoomMembersResponse; import gg.agit.konect.domain.chat.dto.ChatRoomNameUpdateRequest; import gg.agit.konect.domain.chat.dto.ChatRoomResponse; @@ -252,6 +253,28 @@ ResponseEntity kickMember( @UserId Integer userId ); + @Operation(summary = "그룹 채팅방에 멤버를 초대한다.", description = """ + ## 설명 + - 일반 그룹 채팅방의 기존 멤버가 여러 유저를 추가 초대합니다. + + ## 로직 + - 방장 여부와 관계없이 현재 참여 중인 멤버라면 초대할 수 있습니다. + - 1:1 채팅방과 동아리 채팅방에는 초대할 수 없습니다. + - 이미 참여 중인 멤버, 요청자 자신, 중복 userId는 무시합니다. + + ## 에러 + - NOT_FOUND_CHAT_ROOM (404): 채팅방을 찾을 수 없습니다. + - FORBIDDEN_CHAT_ROOM_ACCESS (403): 채팅방에 접근할 권한이 없습니다. + - CANNOT_INVITE_IN_NON_GROUP_ROOM (400): 일반 그룹 채팅방에서만 초대할 수 있습니다. + - NOT_FOUND_USER (404): 유저를 찾을 수 없습니다. + """) + @PostMapping("/rooms/{chatRoomId}/members") + ResponseEntity inviteMembers( + @PathVariable(value = "chatRoomId") Integer chatRoomId, + @Valid @RequestBody ChatRoomMembersInviteRequest request, + @UserId Integer userId + ); + @Operation(summary = "그룹 채팅방을 생성한다.", description = """ ## 설명 - 여러 유저를 초대하여 그룹 채팅방을 생성합니다. @@ -273,12 +296,12 @@ ResponseEntity createGroupChatRoom( @Operation(summary = "채팅방 멤버 목록 조회", description = """ ## 설명 - 특정 채팅방의 모든 멤버 목록을 조회합니다. - + ## 로직 - 채팅방에 참여 중인 멤버만 조회할 수 있습니다. - 나간 멤버(leftAt이 설정된 멤버)는 목록에 포함되지 않습니다. - 각 멤버의 userId, 이름, 프로필 이미지, 방장 여부, 참여 시간을 반환합니다. - + ## 에러 - FORBIDDEN_CHAT_ROOM_ACCESS (403): 채팅방에 접근할 권한이 없습니다. - NOT_FOUND_CHAT_ROOM (404): 채팅방을 찾을 수 없습니다. 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 10a18059f..e42244d25 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.ChatMessageSendRequest; import gg.agit.konect.domain.chat.dto.ChatMuteResponse; import gg.agit.konect.domain.chat.dto.ChatRoomCreateRequest; +import gg.agit.konect.domain.chat.dto.ChatRoomMembersInviteRequest; import gg.agit.konect.domain.chat.dto.ChatRoomMembersResponse; import gg.agit.konect.domain.chat.dto.ChatRoomNameUpdateRequest; import gg.agit.konect.domain.chat.dto.ChatRoomResponse; @@ -143,6 +144,16 @@ public ResponseEntity kickMember( return ResponseEntity.noContent().build(); } + @Override + public ResponseEntity inviteMembers( + @PathVariable(value = "chatRoomId") Integer chatRoomId, + @Valid @RequestBody ChatRoomMembersInviteRequest request, + @UserId Integer userId + ) { + chatService.inviteMembers(userId, chatRoomId, request); + return ResponseEntity.noContent().build(); + } + @Override public ResponseEntity createGroupChatRoom( @Valid @RequestBody ChatRoomCreateRequest.Group request, diff --git a/src/main/java/gg/agit/konect/domain/chat/dto/ChatRoomMembersInviteRequest.java b/src/main/java/gg/agit/konect/domain/chat/dto/ChatRoomMembersInviteRequest.java new file mode 100644 index 000000000..5bbb1a04d --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/chat/dto/ChatRoomMembersInviteRequest.java @@ -0,0 +1,16 @@ +package gg.agit.konect.domain.chat.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; + +public record ChatRoomMembersInviteRequest( + @NotEmpty(message = "초대할 유저 ID 목록은 필수입니다.") + @Schema(description = "초대할 유저 ID 목록", example = "[10, 11, 12]", requiredMode = REQUIRED) + List<@NotNull Integer> userIds +) { +} diff --git a/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomMemberRepository.java b/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomMemberRepository.java index 05135e668..86a64ba62 100644 --- a/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomMemberRepository.java +++ b/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomMemberRepository.java @@ -132,6 +132,18 @@ boolean existsActiveByChatRoomIdAndUserId( @Param("userId") Integer userId ); + @Query(""" + SELECT crm.id.userId + FROM ChatRoomMember crm + WHERE crm.id.chatRoomId = :chatRoomId + AND crm.id.userId IN :userIds + AND crm.leftAt IS NULL + """) + List findActiveUserIdsByChatRoomIdAndUserIdIn( + @Param("chatRoomId") Integer chatRoomId, + @Param("userIds") List userIds + ); + @Query(""" SELECT crm FROM ChatRoomMember crm diff --git a/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMemberCommandService.java b/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMemberCommandService.java index 38d646965..c4559a705 100644 --- a/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMemberCommandService.java +++ b/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMemberCommandService.java @@ -3,11 +3,16 @@ import static gg.agit.konect.global.code.ApiResponseCode.CANNOT_KICK_IN_NON_GROUP_ROOM; import static gg.agit.konect.global.code.ApiResponseCode.CANNOT_KICK_ROOM_OWNER; import static gg.agit.konect.global.code.ApiResponseCode.CANNOT_KICK_SELF; +import static gg.agit.konect.global.code.ApiResponseCode.CANNOT_INVITE_IN_NON_GROUP_ROOM; import static gg.agit.konect.global.code.ApiResponseCode.CANNOT_LEAVE_GROUP_CHAT_ROOM; +import static gg.agit.konect.global.code.ApiResponseCode.FORBIDDEN_CHAT_ROOM_ACCESS; import static gg.agit.konect.global.code.ApiResponseCode.FORBIDDEN_CHAT_ROOM_KICK; import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_CHAT_ROOM; +import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_USER; import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -16,6 +21,8 @@ import gg.agit.konect.domain.chat.model.ChatRoomMember; import gg.agit.konect.domain.chat.repository.ChatRoomMemberRepository; import gg.agit.konect.domain.chat.repository.ChatRoomRepository; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.domain.user.repository.UserRepository; import gg.agit.konect.global.exception.CustomException; import lombok.RequiredArgsConstructor; @@ -26,6 +33,8 @@ public class ChatRoomMemberCommandService { private final ChatRoomRepository chatRoomRepository; private final ChatRoomMemberRepository chatRoomMemberRepository; + private final UserRepository userRepository; + private final ChatRoomMembershipService chatRoomMembershipService; public void leaveChatRoom(Integer userId, Integer roomId) { ChatRoom room = chatRoomRepository.findById(roomId) @@ -60,6 +69,31 @@ public void kickMember(Integer requesterId, Integer roomId, Integer targetUserId chatRoomMemberRepository.deleteByChatRoomIdAndUserId(roomId, targetUserId); } + public void inviteMembers(Integer requesterId, Integer roomId, List userIds) { + ChatRoom room = chatRoomRepository.findById(roomId) + .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); + + validateGroupRoomForInvite(room); + validateActiveRequester(roomId, requesterId); + + List distinctUserIds = userIds.stream() + .distinct() + .toList(); + List requestedUsers = userRepository.findAllByIdIn(distinctUserIds); + if (requestedUsers.size() != distinctUserIds.size()) { + throw CustomException.of(NOT_FOUND_USER); + } + + Set existingActiveUserIds = Set.copyOf( + chatRoomMemberRepository.findActiveUserIdsByChatRoomIdAndUserIdIn(roomId, distinctUserIds) + ); + LocalDateTime joinedAt = LocalDateTime.now(); + requestedUsers.stream() + .filter(user -> !user.getId().equals(requesterId)) + .filter(user -> !existingActiveUserIds.contains(user.getId())) + .forEach(user -> chatRoomMembershipService.ensureMember(room, user, joinedAt)); + } + private ChatRoomMember getRoomMember(Integer roomId, Integer userId) { return ChatRoomMemberLookup.getRoomMember(chatRoomMemberRepository, roomId, userId); } @@ -70,6 +104,18 @@ private void validateGroupRoomForKick(ChatRoom room) { } } + private void validateGroupRoomForInvite(ChatRoom room) { + if (!room.isGroupRoom() || room.isClubGroupRoom()) { + throw CustomException.of(CANNOT_INVITE_IN_NON_GROUP_ROOM); + } + } + + private void validateActiveRequester(Integer roomId, Integer requesterId) { + if (!chatRoomMemberRepository.existsActiveByChatRoomIdAndUserId(roomId, requesterId)) { + throw CustomException.of(FORBIDDEN_CHAT_ROOM_ACCESS); + } + } + private void validateNotSelfKick(Integer requesterId, Integer targetUserId) { if (requesterId.equals(targetUserId)) { throw CustomException.of(CANNOT_KICK_SELF); 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 f647730ae..2ed68ed66 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 @@ -23,6 +23,7 @@ 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.ChatRoomMembersInviteRequest; import gg.agit.konect.domain.chat.dto.ChatRoomNameUpdateRequest; import gg.agit.konect.domain.chat.dto.ChatRoomResponse; import gg.agit.konect.domain.chat.dto.ChatRoomSummaryResponse; @@ -105,6 +106,11 @@ public void kickMember(Integer requesterId, Integer roomId, Integer targetUserId chatRoomMemberCommandService.kickMember(requesterId, roomId, targetUserId); } + @Transactional + public void inviteMembers(Integer requesterId, Integer roomId, ChatRoomMembersInviteRequest request) { + chatRoomMemberCommandService.inviteMembers(requesterId, roomId, request.userIds()); + } + public ChatRoomsSummaryResponse getChatRooms(Integer userId) { List directRooms = getDirectChatRooms(userId); List clubRooms = getClubChatRooms(userId); diff --git a/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java b/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java index b8530ba10..e1273f77a 100644 --- a/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java +++ b/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java @@ -25,6 +25,7 @@ public enum ApiResponseCode { CANNOT_KICK_SELF(HttpStatus.BAD_REQUEST, "자기 자신을 강퇴할 수 없습니다."), CANNOT_KICK_ROOM_OWNER(HttpStatus.BAD_REQUEST, "방장은 강퇴할 수 없습니다."), CANNOT_KICK_IN_NON_GROUP_ROOM(HttpStatus.BAD_REQUEST, "그룹 채팅방에서만 강퇴할 수 있습니다."), + CANNOT_INVITE_IN_NON_GROUP_ROOM(HttpStatus.BAD_REQUEST, "일반 그룹 채팅방에서만 초대할 수 있습니다."), INVALID_CHAT_ROOM_CREATE_REQUEST(HttpStatus.BAD_REQUEST, "clubId 또는 targetUserId 중 하나만 전달해야 합니다."), CANNOT_CHANGE_OWN_POSITION(HttpStatus.BAD_REQUEST, "자기 자신의 직책은 변경할 수 없습니다."), CANNOT_DELETE_CLUB_PRESIDENT(HttpStatus.BAD_REQUEST, "동아리 회장인 경우 회장을 양도하고 탈퇴해야 합니다."), 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 cacd341fd..753fa4951 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 @@ -32,6 +32,7 @@ import gg.agit.konect.domain.chat.dto.ChatMessageSendRequest; import gg.agit.konect.domain.chat.dto.ChatRoomCreateRequest; +import gg.agit.konect.domain.chat.dto.ChatRoomMembersInviteRequest; import gg.agit.konect.domain.chat.dto.ChatRoomNameUpdateRequest; import gg.agit.konect.domain.chat.enums.ChatType; import gg.agit.konect.domain.chat.model.ChatMessage; @@ -1605,6 +1606,166 @@ void canSendMessageToCreatedGroupChatRoom() throws Exception { } } + @Nested + @DisplayName("POST /chats/rooms/{chatRoomId}/members - 그룹 채팅방 멤버 초대") + class InviteMembers { + + private User ownerUser; + private User memberUser; + private User newMemberA; + private User newMemberB; + private ChatRoom groupRoom; + + @BeforeEach + void setUpInviteFixture() { + ownerUser = createUser("초대방장", "2021136101"); + memberUser = createUser("초대멤버", "2021136102"); + newMemberA = createUser("새멤버A", "2021136103"); + newMemberB = createUser("새멤버B", "2021136104"); + groupRoom = createGroupChatRoomWithOwner(ownerUser, memberUser); + clearPersistenceContext(); + } + + @Test + @DisplayName("GROUP active 멤버가 여러 명을 초대한다") + void inviteMembersSuccess() throws Exception { + mockLoginUser(memberUser.getId()); + + performPost( + "/chats/rooms/" + groupRoom.getId() + "/members", + new ChatRoomMembersInviteRequest(List.of(newMemberA.getId(), newMemberB.getId())) + ) + .andExpect(status().isNoContent()); + + clearPersistenceContext(); + assertThat(chatRoomMemberRepository.findByChatRoomId(groupRoom.getId())) + .extracting(ChatRoomMember::getUserId) + .containsExactlyInAnyOrder( + ownerUser.getId(), + memberUser.getId(), + newMemberA.getId(), + newMemberB.getId() + ); + } + + @Test + @DisplayName("방장이 아닌 active 멤버도 초대할 수 있다") + void nonOwnerMemberCanInviteMembers() throws Exception { + mockLoginUser(memberUser.getId()); + + performPost( + "/chats/rooms/" + groupRoom.getId() + "/members", + new ChatRoomMembersInviteRequest(List.of(newMemberA.getId())) + ) + .andExpect(status().isNoContent()); + + clearPersistenceContext(); + ChatRoomMember invitedMember = chatRoomMemberRepository + .findByChatRoomIdAndUserId(groupRoom.getId(), newMemberA.getId()) + .orElseThrow(); + assertThat(invitedMember.isOwner()).isFalse(); + } + + @Test + @DisplayName("채팅방 멤버가 아니면 초대할 수 없다") + void inviteMembersByOutsiderFails() throws Exception { + User outsider = createUser("외부사용자", "2021136105"); + mockLoginUser(outsider.getId()); + + performPost( + "/chats/rooms/" + groupRoom.getId() + "/members", + new ChatRoomMembersInviteRequest(List.of(newMemberA.getId())) + ) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("FORBIDDEN_CHAT_ROOM_ACCESS")); + } + + @Test + @DisplayName("DIRECT 채팅방에는 멤버를 초대할 수 없다") + void inviteMembersToDirectRoomFails() throws Exception { + ChatRoom directRoom = createDirectChatRoom(ownerUser, memberUser); + mockLoginUser(ownerUser.getId()); + + performPost( + "/chats/rooms/" + directRoom.getId() + "/members", + new ChatRoomMembersInviteRequest(List.of(newMemberA.getId())) + ) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("CANNOT_INVITE_IN_NON_GROUP_ROOM")); + } + + @Test + @DisplayName("CLUB_GROUP 채팅방에는 멤버를 초대할 수 없다") + void inviteMembersToClubGroupRoomFails() throws Exception { + Club inviteClub = persist(ClubFixture.create(university, "초대 테스트 동아리")); + ChatRoom clubGroupRoom = persist(ChatRoom.clubGroupOf(inviteClub)); + addRoomMember(clubGroupRoom, ownerUser); + mockLoginUser(ownerUser.getId()); + + performPost( + "/chats/rooms/" + clubGroupRoom.getId() + "/members", + new ChatRoomMembersInviteRequest(List.of(newMemberA.getId())) + ) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("CANNOT_INVITE_IN_NON_GROUP_ROOM")); + } + + @Test + @DisplayName("존재하지 않는 userId가 포함되면 전체 요청을 실패시킨다") + void inviteMembersWithMissingUserFails() throws Exception { + mockLoginUser(memberUser.getId()); + + performPost( + "/chats/rooms/" + groupRoom.getId() + "/members", + new ChatRoomMembersInviteRequest(List.of(newMemberA.getId(), 99999)) + ) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("NOT_FOUND_USER")); + + clearPersistenceContext(); + assertThat(chatRoomMemberRepository.findByChatRoomIdAndUserId( + groupRoom.getId(), newMemberA.getId() + )).isEmpty(); + } + + @Test + @DisplayName("userIds에 null이 포함되면 validation 에러를 반환한다") + void inviteMembersWithNullUserIdFails() throws Exception { + mockLoginUser(memberUser.getId()); + List userIds = new ArrayList<>(); + userIds.add(null); + + performPost( + "/chats/rooms/" + groupRoom.getId() + "/members", + new ChatRoomMembersInviteRequest(userIds) + ) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_REQUEST_BODY")); + } + + @Test + @DisplayName("이미 참여 중인 멤버와 자기 자신과 중복 userId는 무시한다") + void inviteMembersIgnoresExistingSelfAndDuplicateUsers() throws Exception { + mockLoginUser(memberUser.getId()); + + performPost( + "/chats/rooms/" + groupRoom.getId() + "/members", + new ChatRoomMembersInviteRequest(List.of( + memberUser.getId(), + ownerUser.getId(), + newMemberA.getId(), + newMemberA.getId() + )) + ) + .andExpect(status().isNoContent()); + + clearPersistenceContext(); + assertThat(chatRoomMemberRepository.findByChatRoomId(groupRoom.getId())) + .extracting(ChatRoomMember::getUserId) + .containsExactlyInAnyOrder(ownerUser.getId(), memberUser.getId(), newMemberA.getId()); + } + } + @Nested @DisplayName("DELETE /chats/rooms/{chatRoomId}/members/{targetUserId} - 멤버 강퇴") class KickMember { diff --git a/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatRoomMemberCommandServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatRoomMemberCommandServiceTest.java new file mode 100644 index 000000000..ac6dac046 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatRoomMemberCommandServiceTest.java @@ -0,0 +1,195 @@ +package gg.agit.konect.unit.domain.chat.service; + +import static gg.agit.konect.global.code.ApiResponseCode.CANNOT_INVITE_IN_NON_GROUP_ROOM; +import static gg.agit.konect.global.code.ApiResponseCode.FORBIDDEN_CHAT_ROOM_ACCESS; +import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_USER; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import org.assertj.core.api.ThrowableAssert.ThrowingCallable; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.test.util.ReflectionTestUtils; + +import gg.agit.konect.domain.chat.enums.ChatType; +import gg.agit.konect.domain.chat.model.ChatRoom; +import gg.agit.konect.domain.chat.repository.ChatRoomMemberRepository; +import gg.agit.konect.domain.chat.repository.ChatRoomRepository; +import gg.agit.konect.domain.chat.service.ChatRoomMemberCommandService; +import gg.agit.konect.domain.chat.service.ChatRoomMembershipService; +import gg.agit.konect.domain.user.enums.UserRole; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.domain.user.repository.UserRepository; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; +import gg.agit.konect.support.ServiceTestSupport; +import gg.agit.konect.support.fixture.ClubFixture; +import gg.agit.konect.support.fixture.UniversityFixture; +import gg.agit.konect.support.fixture.UserFixture; + +class ChatRoomMemberCommandServiceTest extends ServiceTestSupport { + + @Mock + private ChatRoomRepository chatRoomRepository; + + @Mock + private ChatRoomMemberRepository chatRoomMemberRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private ChatRoomMembershipService chatRoomMembershipService; + + @InjectMocks + private ChatRoomMemberCommandService chatRoomMemberCommandService; + + @Test + @DisplayName("초대 요청은 중복 userId와 요청자 자신과 기존 멤버를 제외하고 새 멤버만 추가한다") + void inviteMembersAddsOnlyNewUsers() { + // given + Integer roomId = 10; + Integer requesterId = 100; + User requester = createUser(requesterId, "요청자"); + User existingMember = createUser(200, "기존멤버"); + User newMember = createUser(300, "새멤버"); + ChatRoom room = createRoom(roomId, ChatType.GROUP); + + given(chatRoomRepository.findById(roomId)).willReturn(Optional.of(room)); + given(chatRoomMemberRepository.existsActiveByChatRoomIdAndUserId(roomId, requesterId)).willReturn(true); + given(userRepository.findAllByIdIn(List.of(requesterId, existingMember.getId(), newMember.getId()))) + .willReturn(List.of(requester, existingMember, newMember)); + given(chatRoomMemberRepository.findActiveUserIdsByChatRoomIdAndUserIdIn( + roomId, + List.of(requesterId, existingMember.getId(), newMember.getId()) + )).willReturn(List.of(requesterId, existingMember.getId())); + + // when + chatRoomMemberCommandService.inviteMembers( + requesterId, + roomId, + List.of(requesterId, existingMember.getId(), newMember.getId(), newMember.getId()) + ); + + // then + verify(chatRoomMembershipService, times(1)) + .ensureMember(eq(room), eq(newMember), any(LocalDateTime.class)); + verify(chatRoomMembershipService, never()).ensureMember(eq(room), eq(requester), any(LocalDateTime.class)); + verify(chatRoomMembershipService, never()).ensureMember(eq(room), eq(existingMember), any(LocalDateTime.class)); + } + + @Test + @DisplayName("일반 GROUP이 아니면 초대할 수 없다") + void inviteMembersToNonGroupRoomFails() { + // given + Integer roomId = 10; + ChatRoom room = createRoom(roomId, ChatType.DIRECT); + given(chatRoomRepository.findById(roomId)).willReturn(Optional.of(room)); + + // when & then + assertErrorCode( + () -> chatRoomMemberCommandService.inviteMembers(100, roomId, List.of(200)), + CANNOT_INVITE_IN_NON_GROUP_ROOM + ); + + verify(userRepository, never()).findAllByIdIn(any()); + } + + @Test + @DisplayName("CLUB_GROUP 채팅방에는 초대할 수 없다") + void inviteMembersToClubGroupRoomFails() { + // given + Integer roomId = 10; + ChatRoom room = createRoom(roomId, ChatType.CLUB_GROUP); + given(chatRoomRepository.findById(roomId)).willReturn(Optional.of(room)); + + // when & then + assertErrorCode( + () -> chatRoomMemberCommandService.inviteMembers(100, roomId, List.of(200)), + CANNOT_INVITE_IN_NON_GROUP_ROOM + ); + + verify(userRepository, never()).findAllByIdIn(any()); + } + + @Test + @DisplayName("요청자가 active 멤버가 아니면 초대할 수 없다") + void inviteMembersByInactiveRequesterFails() { + // given + Integer roomId = 10; + Integer requesterId = 100; + ChatRoom room = createRoom(roomId, ChatType.GROUP); + given(chatRoomRepository.findById(roomId)).willReturn(Optional.of(room)); + given(chatRoomMemberRepository.existsActiveByChatRoomIdAndUserId(roomId, requesterId)).willReturn(false); + + // when & then + assertErrorCode( + () -> chatRoomMemberCommandService.inviteMembers(requesterId, roomId, List.of(200)), + FORBIDDEN_CHAT_ROOM_ACCESS + ); + + verify(userRepository, never()).findAllByIdIn(any()); + } + + @Test + @DisplayName("존재하지 않는 사용자가 있으면 전체 요청을 실패시킨다") + void inviteMembersWithMissingUserFails() { + // given + Integer roomId = 10; + Integer requesterId = 100; + ChatRoom room = createRoom(roomId, ChatType.GROUP); + User foundUser = createUser(200, "존재사용자"); + given(chatRoomRepository.findById(roomId)).willReturn(Optional.of(room)); + given(chatRoomMemberRepository.existsActiveByChatRoomIdAndUserId(roomId, requesterId)).willReturn(true); + given(userRepository.findAllByIdIn(List.of(foundUser.getId(), 99999))) + .willReturn(List.of(foundUser)); + + // when & then + assertErrorCode( + () -> chatRoomMemberCommandService.inviteMembers(requesterId, roomId, List.of(foundUser.getId(), 99999)), + NOT_FOUND_USER + ); + + verify(chatRoomMembershipService, never()).ensureMember(any(), any(), any()); + } + + private ChatRoom createRoom(Integer id, ChatType type) { + ChatRoom room = switch (type) { + case DIRECT -> ChatRoom.directOf(); + case GROUP -> ChatRoom.groupOf(); + case CLUB_GROUP -> ChatRoom.clubGroupOf( + ClubFixture.createWithId(UniversityFixture.createWithId(1), 77, "BCSD") + ); + }; + ReflectionTestUtils.setField(room, "id", id); + return room; + } + + private User createUser(Integer id, String name) { + return UserFixture.createUserWithId( + UniversityFixture.create(), + id, + name, + "2024" + String.format("%04d", id), + UserRole.USER + ); + } + + private void assertErrorCode(ThrowingCallable callable, ApiResponseCode errorCode) { + assertThatThrownBy(callable) + .isInstanceOf(CustomException.class) + .satisfies(exception -> assertThat(((CustomException)exception).getErrorCode()).isEqualTo(errorCode)); + } +} 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 f8c7e6a68..6c95646a2 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 @@ -152,7 +152,9 @@ void setUp() { ); ChatRoomMemberCommandService chatRoomMemberCommandService = new ChatRoomMemberCommandService( chatRoomRepository, - chatRoomMemberRepository + chatRoomMemberRepository, + userRepository, + chatRoomMembershipService ); ChatRoomMembershipService chatRoomMembershipForCreation = new ChatRoomMembershipService( chatRoomRepository, From c6b6dcce54a9091002f07064a9994a76d308797d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Wed, 29 Apr 2026 14:09:02 +0900 Subject: [PATCH 43/50] =?UTF-8?q?fix:=20=EC=98=88=EC=83=81=20=EB=AA=BB?= =?UTF-8?q?=ED=95=9C=20=EC=98=88=EC=99=B8=EC=9D=98=20=EC=9A=94=EC=B2=AD=20?= =?UTF-8?q?=EB=94=94=EB=B2=84=EA=B7=B8=20=EB=A1=9C=EA=B7=B8=20=EC=A0=95?= =?UTF-8?q?=EB=A0=AC=20(#617)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 예상 못한 예외의 요청 디버그 로그 정렬 - 예상하지 못한 예외 처리에서도 요청 메서드, 헤더, 쿼리, 본문을 DEBUG 로그 경로로 확인할 수 있게 한다. - ERROR/Slack 알림에는 요청 본문을 넣지 않아 민감 정보 노출 범위를 늘리지 않는다. - 기존 요청 본문 캐싱 흐름과 함께 동작하도록 단위 테스트로 회귀를 고정한다. * fix: 요청 디버그 로그의 비용과 민감 헤더 노출 방지 - DEBUG 로그가 꺼져 있을 때 요청 헤더와 본문 계산을 건너뛰어 예외 경로의 불필요한 I/O와 메모리 사용을 막는다. - Authorization, Cookie, Set-Cookie, Proxy-Authorization 헤더는 DEBUG 로그에서도 마스킹해 토큰 노출 위험을 줄인다. - 예상하지 못한 예외의 요청 본문 로깅 테스트에 민감 헤더 마스킹과 DEBUG 비활성화 회귀 검증을 추가한다. * fix: 요청 쿼리 디버그 로그의 민감값 마스킹 - DEBUG 요청 로그가 access_token, code, token 같은 민감 쿼리 값을 그대로 남기지 않도록 마스킹한다. - 반복 파라미터와 URL 인코딩된 키를 처리해 민감값이 디버그 로그로 노출되는 경로를 막는다. - 민감 헤더, 민감 쿼리, DEBUG 비활성화 경로를 단위 테스트로 함께 고정한다. --- .../exception/GlobalExceptionHandler.java | 81 +++++++++++++- .../exception/GlobalExceptionHandlerTest.java | 103 ++++++++++++++++++ 2 files changed, 180 insertions(+), 4 deletions(-) create mode 100644 src/test/java/gg/agit/konect/unit/global/exception/GlobalExceptionHandlerTest.java diff --git a/src/main/java/gg/agit/konect/global/exception/GlobalExceptionHandler.java b/src/main/java/gg/agit/konect/global/exception/GlobalExceptionHandler.java index f0df6e537..1a5c8d2d9 100644 --- a/src/main/java/gg/agit/konect/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/gg/agit/konect/global/exception/GlobalExceptionHandler.java @@ -1,11 +1,14 @@ package gg.agit.konect.global.exception; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; import java.time.DateTimeException; import java.util.Enumeration; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.StringJoiner; import java.util.UUID; import org.apache.catalina.connector.ClientAbortException; @@ -41,6 +44,21 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { private static final Logger RUNTIME_ERROR_LOGGER = LoggerFactory.getLogger("runtime.error"); + private static final String MASKED_HEADER_VALUE = "***"; + private static final List SENSITIVE_HEADER_NAMES = List.of( + "authorization", + "cookie", + "proxy-authorization", + "set-cookie" + ); + private static final List SENSITIVE_QUERY_PARAMETER_NAMES = List.of( + "access_token", + "client_secret", + "code", + "password", + "refresh_token", + "token" + ); @ExceptionHandler(CustomException.class) public ResponseEntity handleCustomException( @@ -203,6 +221,7 @@ public ResponseEntity handleException(HttpServletRequest request, Except ); RUNTIME_ERROR_LOGGER.error(slackMessage); + requestDebugLogging(request); return buildErrorResponse(ApiResponseCode.UNEXPECTED_SERVER_ERROR); } @@ -253,28 +272,82 @@ private void requestLogging( String errorTraceId ) { log.warn("[{}] {} | errorTraceId={}", httpStatus, errorMessage, errorTraceId); + requestDebugLogging(request); + } + + private void requestDebugLogging(HttpServletRequest request) { + if (!log.isDebugEnabled()) { + return; + } log.debug("Request: {} {}", request.getMethod(), request.getRequestURI()); - log.debug("Headers: {}", getHeaders(request)); + log.debug("Headers: {}", getLoggableHeaders(request)); log.debug("Query String: {}", getQueryString(request)); log.debug("Body: {}", getRequestBody(request)); } - private Map getHeaders(HttpServletRequest request) { + private Map getLoggableHeaders(HttpServletRequest request) { Map headerMap = new HashMap<>(); Enumeration headerArray = request.getHeaderNames(); while (headerArray.hasMoreElements()) { String headerName = headerArray.nextElement(); - headerMap.put(headerName, request.getHeader(headerName)); + headerMap.put(headerName, getLoggableHeaderValue(request, headerName)); } return headerMap; } + private String getLoggableHeaderValue(HttpServletRequest request, String headerName) { + if (isSensitiveHeader(headerName)) { + return MASKED_HEADER_VALUE; + } + return request.getHeader(headerName); + } + + private boolean isSensitiveHeader(String headerName) { + return SENSITIVE_HEADER_NAMES.stream() + .anyMatch(sensitiveHeaderName -> sensitiveHeaderName.equalsIgnoreCase(headerName)); + } + private String getQueryString(HttpServletRequest httpRequest) { String queryString = httpRequest.getQueryString(); if (queryString == null) { return " - "; } - return queryString; + return getLoggableQueryString(queryString); + } + + private String getLoggableQueryString(String queryString) { + StringJoiner loggableQueryString = new StringJoiner("&"); + for (String parameter : queryString.split("&", -1)) { + loggableQueryString.add(getLoggableQueryParameter(parameter)); + } + return loggableQueryString.toString(); + } + + private String getLoggableQueryParameter(String parameter) { + int delimiterIndex = parameter.indexOf('='); + String parameterName = delimiterIndex >= 0 + ? parameter.substring(0, delimiterIndex) + : parameter; + + if (!isSensitiveQueryParameter(parameterName)) { + return parameter; + } + + return parameterName + "=" + MASKED_HEADER_VALUE; + } + + private boolean isSensitiveQueryParameter(String parameterName) { + String decodedParameterName = decodeQueryParameterName(parameterName); + return SENSITIVE_QUERY_PARAMETER_NAMES.stream() + .anyMatch(sensitiveParameterName -> sensitiveParameterName.equalsIgnoreCase(decodedParameterName)); + } + + private String decodeQueryParameterName(String parameterName) { + try { + return URLDecoder.decode(parameterName, StandardCharsets.UTF_8); + } catch (IllegalArgumentException e) { + return parameterName; + } } private String getRequestBody(HttpServletRequest request) { diff --git a/src/test/java/gg/agit/konect/unit/global/exception/GlobalExceptionHandlerTest.java b/src/test/java/gg/agit/konect/unit/global/exception/GlobalExceptionHandlerTest.java new file mode 100644 index 000000000..5b39ec07e --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/global/exception/GlobalExceptionHandlerTest.java @@ -0,0 +1,103 @@ +package gg.agit.konect.unit.global.exception; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.slf4j.LoggerFactory; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; +import org.springframework.http.HttpStatus; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.web.util.ContentCachingRequestWrapper; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; + +import gg.agit.konect.global.exception.GlobalExceptionHandler; + +@ExtendWith(OutputCaptureExtension.class) +class GlobalExceptionHandlerTest { + + private final Logger exceptionHandlerLogger = (Logger)LoggerFactory.getLogger(GlobalExceptionHandler.class); + private Level originalLevel; + + @BeforeEach + void setUp() { + originalLevel = exceptionHandlerLogger.getLevel(); + exceptionHandlerLogger.setLevel(Level.DEBUG); + } + + @AfterEach + void tearDown() { + exceptionHandlerLogger.setLevel(originalLevel); + } + + @Test + @DisplayName("예상하지 못한 예외도 디버그 로그에서 요청 본문을 확인할 수 있다") + void logsRequestBodyAtDebugLevelForUnexpectedException(CapturedOutput output) { + // given + GlobalExceptionHandler handler = new GlobalExceptionHandler(); + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/clubs"); + request.setContentType("application/json"); + request.addHeader("Authorization", "Bearer secret-token"); + request.addHeader("Cookie", "session=secret-cookie"); + request.addHeader("X-Request-ID", "request-1"); + request.setQueryString( + "access%5Ftoken=encoded-query-secret&access_token=query-secret&code=oauth-code&name=KONECT" + + "&token=repeat-secret-one&token=repeat-secret-two" + ); + request.setContent(""" + {"name":"KONECT"} + """.getBytes()); + ContentCachingRequestWrapper wrappedRequest = new ContentCachingRequestWrapper(request); + + // when + var response = handler.handleException(wrappedRequest, new RuntimeException("boom")); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + assertThat(output) + .contains("Request: POST /clubs") + .contains("Authorization=***") + .contains("Cookie=***") + .contains("X-Request-ID=request-1") + .doesNotContain("secret-token") + .doesNotContain("secret-cookie") + .contains( + "Query String: access%5Ftoken=***&access_token=***&code=***&name=KONECT&token=***&token=***" + ) + .doesNotContain("encoded-query-secret") + .doesNotContain("query-secret") + .doesNotContain("oauth-code") + .doesNotContain("repeat-secret-one") + .doesNotContain("repeat-secret-two") + .contains("Body: {\"name\":\"KONECT\"}"); + } + + @Test + @DisplayName("DEBUG 로그가 꺼져 있으면 요청 상세 정보를 계산하지 않는다") + void skipsRequestDetailLoggingWhenDebugIsDisabled(CapturedOutput output) { + // given + exceptionHandlerLogger.setLevel(Level.INFO); + GlobalExceptionHandler handler = new GlobalExceptionHandler(); + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/clubs"); + request.setContentType("application/json"); + request.setContent(""" + {"name":"KONECT"} + """.getBytes()); + ContentCachingRequestWrapper wrappedRequest = new ContentCachingRequestWrapper(request); + + // when + var response = handler.handleException(wrappedRequest, new RuntimeException("boom")); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + assertThat(output) + .doesNotContain("Request: POST /clubs") + .doesNotContain("Body: {\"name\":\"KONECT\"}"); + } +} From 91c8547a75fdcc41d6bf6d8865180f78ef687a00 Mon Sep 17 00:00:00 2001 From: JanooGwan Date: Thu, 30 Apr 2026 11:32:25 +0900 Subject: [PATCH 44/50] =?UTF-8?q?fix:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=EB=AC=B8=EC=9D=98=EB=B0=A9=20=EB=A9=94=EC=8B=9C=EC=A7=80=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C=20=EB=B0=A9=ED=96=A5=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat/service/ChatMessageReadService.java | 16 +- .../service/ChatMessageReadServiceTest.java | 180 ++++++++++++++++++ 2 files changed, 192 insertions(+), 4 deletions(-) create mode 100644 src/test/java/gg/agit/konect/unit/domain/chat/service/ChatMessageReadServiceTest.java diff --git a/src/main/java/gg/agit/konect/domain/chat/service/ChatMessageReadService.java b/src/main/java/gg/agit/konect/domain/chat/service/ChatMessageReadService.java index 2d1eab08f..1a8d05299 100644 --- a/src/main/java/gg/agit/konect/domain/chat/service/ChatMessageReadService.java +++ b/src/main/java/gg/agit/konect/domain/chat/service/ChatMessageReadService.java @@ -49,7 +49,7 @@ public ChatMessagePageResponse getDirectChatRoomMessages( Integer maskedAdminId = getMaskedAdminId(user, members); return buildDirectChatRoomMessages(user, roomId, page, limit, readAt, - visibleMessageFrom, sortedReadBaselines, maskedAdminId); + visibleMessageFrom, sortedReadBaselines, maskedAdminId, false); } public ChatMessagePageResponse getAdminSystemDirectChatRoomMessages( @@ -67,7 +67,7 @@ public ChatMessagePageResponse getAdminSystemDirectChatRoomMessages( Integer maskedAdminId = getMaskedAdminId(user, members); return buildDirectChatRoomMessages(user, roomId, page, limit, readAt, - visibleMessageFrom, sortedReadBaselines, maskedAdminId); + visibleMessageFrom, sortedReadBaselines, maskedAdminId, true); } public ChatMessagePageResponse getClubMessagesByRoom( @@ -159,7 +159,8 @@ private ChatMessagePageResponse buildDirectChatRoomMessages( LocalDateTime readAt, LocalDateTime visibleMessageFrom, List sortedReadBaselines, - Integer maskedAdminId + Integer maskedAdminId, + boolean isAdminViewingSystemRoom ) { PageRequest pageable = PageRequest.of(page - 1, limit); Page messages = chatMessageRepository.findByChatRoomId(roomId, visibleMessageFrom, pageable); @@ -169,7 +170,7 @@ private ChatMessagePageResponse buildDirectChatRoomMessages( Integer senderId = maskedAdminId != null ? resolveDirectSenderId(message, maskedAdminId) : message.getSender().getId(); - boolean isMine = message.isSentBy(user.getId()); + boolean isMine = isOwnDirectMessage(user, message, isAdminViewingSystemRoom); boolean isRead = isMine || !message.getCreatedAt().isAfter(readAt); int unreadCount = countUnreadSince(message.getCreatedAt(), sortedReadBaselines); return new ChatMessageDetailResponse( @@ -195,6 +196,13 @@ private ChatMessagePageResponse buildDirectChatRoomMessages( ); } + private boolean isOwnDirectMessage(User user, ChatMessage message, boolean isAdminViewingSystemRoom) { + if (isAdminViewingSystemRoom && user.isAdmin()) { + return message.getSender().isAdmin(); + } + return message.isSentBy(user.getId()); + } + private List toSortedReadBaselines(List members) { return members.stream() .map(this::resolveUnreadBaseline) diff --git a/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatMessageReadServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatMessageReadServiceTest.java new file mode 100644 index 000000000..903defbdf --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatMessageReadServiceTest.java @@ -0,0 +1,180 @@ +package gg.agit.konect.unit.domain.chat.service; + +import static gg.agit.konect.domain.chat.service.ChatRoomMembershipService.SYSTEM_ADMIN_ID; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.nullable; +import static org.mockito.BDDMockito.given; + +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.util.ReflectionTestUtils; + +import gg.agit.konect.domain.chat.dto.ChatMessageDetailResponse; +import gg.agit.konect.domain.chat.dto.ChatMessagePageResponse; +import gg.agit.konect.domain.chat.enums.ChatType; +import gg.agit.konect.domain.chat.model.ChatMessage; +import gg.agit.konect.domain.chat.model.ChatRoom; +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 gg.agit.konect.domain.chat.service.ChatDirectRoomAccessService; +import gg.agit.konect.domain.chat.service.ChatMessageReadService; +import gg.agit.konect.domain.chat.service.ChatRoomSystemAdminService; +import gg.agit.konect.domain.user.enums.UserRole; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.support.ServiceTestSupport; +import gg.agit.konect.support.fixture.ClubFixture; +import gg.agit.konect.support.fixture.UniversityFixture; +import gg.agit.konect.support.fixture.UserFixture; + +class ChatMessageReadServiceTest extends ServiceTestSupport { + + @Mock + private ChatMessageRepository chatMessageRepository; + + @Mock + private ChatRoomMemberRepository chatRoomMemberRepository; + + @Mock + private ChatRoomSystemAdminService chatRoomSystemAdminService; + + @Mock + private ChatDirectRoomAccessService chatDirectRoomAccessService; + + private ChatMessageReadService chatMessageReadService; + + @BeforeEach + void setUp() { + chatMessageReadService = new ChatMessageReadService( + chatMessageRepository, + chatRoomMemberRepository, + chatRoomSystemAdminService, + chatDirectRoomAccessService + ); + } + + @Test + @DisplayName("Admin viewer sees other admin messages in a system admin room as mine") + void getAdminSystemDirectChatRoomMessagesMarksOtherAdminMessagesAsMine() { + // given + User viewerAdmin = createUser(99, "viewer admin", UserRole.ADMIN); + User otherAdmin = createUser(88, "other admin", UserRole.ADMIN); + User systemAdmin = createUser(SYSTEM_ADMIN_ID, "system admin", UserRole.ADMIN); + User normalUser = createUser(20, "normal user", UserRole.USER); + + ChatRoom room = createRoom(1, ChatType.DIRECT, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember systemAdminMember = createRoomMember(room, systemAdmin, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember normalUserMember = createRoomMember(room, normalUser, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatMessage otherAdminMessage = createMessage( + 100, + room, + otherAdmin, + "reply", + LocalDateTime.of(2026, 4, 11, 10, 1) + ); + PageRequest pageable = PageRequest.of(0, 20); + + given(chatRoomMemberRepository.findByChatRoomId(room.getId())) + .willReturn(List.of(systemAdminMember, normalUserMember)); + given(chatRoomSystemAdminService.findSystemAdminMember(List.of(systemAdminMember, normalUserMember))) + .willReturn(systemAdminMember); + given(chatMessageRepository.findByChatRoomId(eq(room.getId()), nullable(LocalDateTime.class), eq(pageable))) + .willReturn(new PageImpl<>(List.of(otherAdminMessage), pageable, 1)); + + // when + ChatMessagePageResponse response = chatMessageReadService.getAdminSystemDirectChatRoomMessages( + viewerAdmin, + room, + 1, + 20, + LocalDateTime.of(2026, 4, 11, 10, 2) + ); + + // then + ChatMessageDetailResponse message = response.messages().getFirst(); + assertThat(message.senderId()).isEqualTo(otherAdmin.getId()); + assertThat(message.isMine()).isTrue(); + assertThat(message.isRead()).isTrue(); + } + + @Test + @DisplayName("Normal user still sees admin messages in a direct room as opponent messages") + void getDirectChatRoomMessagesKeepsAdminMessagesAsOpponentForNormalUser() { + // given + User normalUser = createUser(20, "normal user", UserRole.USER); + User systemAdmin = createUser(SYSTEM_ADMIN_ID, "system admin", UserRole.ADMIN); + User otherAdmin = createUser(88, "other admin", UserRole.ADMIN); + + ChatRoom room = createRoom(1, ChatType.DIRECT, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember systemAdminMember = createRoomMember(room, systemAdmin, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember normalUserMember = createRoomMember(room, normalUser, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatMessage otherAdminMessage = createMessage( + 100, + room, + otherAdmin, + "reply", + LocalDateTime.of(2026, 4, 11, 10, 1) + ); + PageRequest pageable = PageRequest.of(0, 20); + + given(chatRoomMemberRepository.findByChatRoomId(room.getId())) + .willReturn(List.of(systemAdminMember, normalUserMember)); + given(chatDirectRoomAccessService.prepareAccessAndGetVisibleMessageFrom(room, normalUser)) + .willReturn(null); + given(chatMessageRepository.findByChatRoomId(eq(room.getId()), nullable(LocalDateTime.class), eq(pageable))) + .willReturn(new PageImpl<>(List.of(otherAdminMessage), pageable, 1)); + + // when + ChatMessagePageResponse response = chatMessageReadService.getDirectChatRoomMessages( + normalUser, + room, + 1, + 20, + LocalDateTime.of(2026, 4, 11, 10, 2) + ); + + // then + ChatMessageDetailResponse message = response.messages().getFirst(); + assertThat(message.senderId()).isEqualTo(SYSTEM_ADMIN_ID); + assertThat(message.isMine()).isFalse(); + assertThat(message.isRead()).isTrue(); + } + + private User createUser(Integer id, String name, UserRole role) { + return UserFixture.createUserWithId(UniversityFixture.createWithId(1), id, name, + "2024" + String.format("%04d", id), role); + } + + private ChatRoom createRoom(Integer id, ChatType type, LocalDateTime createdAt) { + ChatRoom room = switch (type) { + case DIRECT -> ChatRoom.directOf(); + case GROUP -> ChatRoom.groupOf(); + case CLUB_GROUP -> ChatRoom.clubGroupOf(ClubFixture.createWithId(UniversityFixture.createWithId(1), 77)); + default -> throw new IllegalArgumentException("Unsupported ChatType: " + type); + }; + ReflectionTestUtils.setField(room, "id", id); + ReflectionTestUtils.setField(room, "createdAt", createdAt); + return room; + } + + private ChatRoomMember createRoomMember(ChatRoom room, User user, LocalDateTime lastReadAt) { + ChatRoomMember member = ChatRoomMember.of(room, user, lastReadAt); + ReflectionTestUtils.setField(member, "createdAt", lastReadAt); + return member; + } + + private ChatMessage createMessage(Integer id, ChatRoom room, User sender, String content, LocalDateTime createdAt) { + ChatMessage message = ChatMessage.of(room, sender, content); + ReflectionTestUtils.setField(message, "id", id); + ReflectionTestUtils.setField(message, "createdAt", createdAt); + return message; + } +} From 3903a53a9db11d33f79ea260c15eb0fd5c9f0f3d Mon Sep 17 00:00:00 2001 From: JanooGwan Date: Thu, 30 Apr 2026 11:52:48 +0900 Subject: [PATCH 45/50] =?UTF-8?q?docs:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=EB=AC=B8=EC=9D=98=EB=B0=A9=20isMine=20=EC=9D=91=EB=8B=B5=20?= =?UTF-8?q?=EA=B3=84=EC=95=BD=20=EB=AC=B8=EC=84=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/gg/agit/konect/domain/chat/controller/ChatApi.java | 1 + .../konect/domain/chat/dto/ChatMessageDetailResponse.java | 6 +++++- .../domain/chat/service/ChatMessageReadServiceTest.java | 4 ++-- 3 files changed, 8 insertions(+), 3 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 39061aa43..d83a897af 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 @@ -139,6 +139,7 @@ ResponseEntity getInvitableUsers( - 채팅방에 진입하면 읽지 않은 메시지를 자동으로 읽음 처리합니다. - 최신 메시지가 먼저 오도록 정렬됩니다 (DESC). - isMine 필드로 내가 보낸 메시지인지 구분할 수 있습니다. + - SYSTEM_ADMIN 문의방을 admin이 조회하는 경우 admin 발신 메시지는 모두 isMine=true로 반환됩니다. - 채팅방 참여자만 메시지를 조회할 수 있습니다. - 일반 유저는 자신이 참여한 채팅방만 조회할 수 있습니다. - 어드민은 모든 어드민 채팅방을 조회할 수 있습니다. diff --git a/src/main/java/gg/agit/konect/domain/chat/dto/ChatMessageDetailResponse.java b/src/main/java/gg/agit/konect/domain/chat/dto/ChatMessageDetailResponse.java index aa5906633..9f2960667 100644 --- a/src/main/java/gg/agit/konect/domain/chat/dto/ChatMessageDetailResponse.java +++ b/src/main/java/gg/agit/konect/domain/chat/dto/ChatMessageDetailResponse.java @@ -32,7 +32,11 @@ public record ChatMessageDetailResponse( @Schema(description = "미확인 인원 수(그룹채팅에서 제공)", example = "3", requiredMode = NOT_REQUIRED) Integer unreadCount, - @Schema(description = "내가 보낸 메시지 여부", example = "true", requiredMode = REQUIRED) + @Schema( + description = "내가 보낸 메시지 여부(SYSTEM_ADMIN 문의방을 admin이 조회할 때는 admin 발신 메시지를 모두 true로 반환)", + example = "true", + requiredMode = REQUIRED + ) Boolean isMine ) { } diff --git a/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatMessageReadServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatMessageReadServiceTest.java index 903defbdf..be1707690 100644 --- a/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatMessageReadServiceTest.java +++ b/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatMessageReadServiceTest.java @@ -62,7 +62,7 @@ void setUp() { } @Test - @DisplayName("Admin viewer sees other admin messages in a system admin room as mine") + @DisplayName("어드민은 SYSTEM_ADMIN 문의방에서 다른 어드민 메시지도 내 메시지로 조회한다") void getAdminSystemDirectChatRoomMessagesMarksOtherAdminMessagesAsMine() { // given User viewerAdmin = createUser(99, "viewer admin", UserRole.ADMIN); @@ -106,7 +106,7 @@ void getAdminSystemDirectChatRoomMessagesMarksOtherAdminMessagesAsMine() { } @Test - @DisplayName("Normal user still sees admin messages in a direct room as opponent messages") + @DisplayName("일반 사용자는 direct 방의 어드민 메시지를 상대 메시지로 조회한다") void getDirectChatRoomMessagesKeepsAdminMessagesAsOpponentForNormalUser() { // given User normalUser = createUser(20, "normal user", UserRole.USER); From 208e6e03c23d4a8b0e7a318b30b9eb2624c2a9bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Mon, 4 May 2026 13:46:06 +0900 Subject: [PATCH 46/50] =?UTF-8?q?fix:=20=EC=9A=94=EC=B2=AD=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=20=EB=A7=A4=EC=B9=AD=EC=9D=84=20=EC=9C=84=ED=95=B4=20?= =?UTF-8?q?requestId=EB=A5=BC=20=EB=AA=85=EC=8B=9C=20(#621)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 요청 로그 매칭을 위해 requestId를 명시 - 런타임 에러 Slack 알림과 DEBUG 요청 로그가 같은 요청을 가리키는지 확정할 수 있도록 requestId를 함께 남긴다. - runtime.error 로거가 별도 Slack appender만 사용해 콘솔/파일 로그 패턴의 MDC만으로는 매칭이 어려운 구조를 보완한다. - DEBUG 요청 상세 로그에도 같은 requestId를 포함해 동시 요청 상황에서도 에러와 요청 본문을 구분할 수 있게 한다. * fix: 예외 처리 중 빈 스택트레이스 방어 - 예외 핸들러가 스택트레이스 첫 요소에 바로 접근하면 2차 예외로 장애 분석 로그와 응답 생성이 깨질 수 있어 길이 검사를 추가한다. - 스택트레이스가 비어 있을 때는 고정된 기본 위치를 남겨 Slack 알림 포맷을 유지한다. - DEBUG 비활성화 테스트는 현재 Body 로그 포맷을 기준으로 민감 정보 로깅 회귀를 검출하도록 정리한다. * fix: 예외 위치 추출의 null 스택트레이스 방어 - 예외 타입이 스택트레이스를 null로 반환해도 예외 핸들러가 2차 예외를 만들지 않도록 기본 위치를 사용한다. - 빈 스택트레이스와 같은 fallback 포맷을 유지해 Slack 알림과 에러 응답 흐름을 안정적으로 보존한다. - null 스택트레이스 회귀 테스트를 추가해 예외 처리 경계 조건을 고정한다. --- .../exception/GlobalExceptionHandler.java | 42 +++++++++---- .../exception/GlobalExceptionHandlerTest.java | 59 +++++++++++++++++-- 2 files changed, 85 insertions(+), 16 deletions(-) diff --git a/src/main/java/gg/agit/konect/global/exception/GlobalExceptionHandler.java b/src/main/java/gg/agit/konect/global/exception/GlobalExceptionHandler.java index 1a5c8d2d9..992ec5fb1 100644 --- a/src/main/java/gg/agit/konect/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/gg/agit/konect/global/exception/GlobalExceptionHandler.java @@ -13,6 +13,7 @@ import org.apache.catalina.connector.ClientAbortException; import org.slf4j.Logger; +import org.slf4j.MDC; import org.springframework.web.multipart.MaxUploadSizeExceededException; import org.slf4j.LoggerFactory; import org.springframework.http.HttpHeaders; @@ -44,6 +45,7 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { private static final Logger RUNTIME_ERROR_LOGGER = LoggerFactory.getLogger("runtime.error"); + private static final String REQUEST_ID_MDC_KEY = "requestId"; private static final String MASKED_HEADER_VALUE = "***"; private static final List SENSITIVE_HEADER_NAMES = List.of( "authorization", @@ -203,29 +205,39 @@ protected ResponseEntity handleHttpMessageNotReadable( @ExceptionHandler(Exception.class) public ResponseEntity handleException(HttpServletRequest request, Exception e) { - StackTraceElement origin = e.getStackTrace()[0]; - String uri = String.format("%s %s", request.getMethod(), request.getRequestURI()); String exception = e.getClass().getSimpleName(); - String location = String.format("%s:%d", origin.getFileName(), origin.getLineNumber()); + String location = getExceptionLocation(e); String message = e.getMessage(); + String requestId = getRequestId(); String slackMessage = String.format( """ + Request ID: `%s` URI: `%s` Location: `%s` Exception: `%s` ```%s``` """, - uri, location, exception, message + requestId, uri, location, exception, message ); RUNTIME_ERROR_LOGGER.error(slackMessage); - requestDebugLogging(request); + requestDebugLogging(request, requestId); return buildErrorResponse(ApiResponseCode.UNEXPECTED_SERVER_ERROR); } + private String getExceptionLocation(Exception e) { + StackTraceElement[] stackTrace = e.getStackTrace(); + if (stackTrace == null || stackTrace.length == 0) { + return "unknown:0"; + } + + StackTraceElement origin = stackTrace[0]; + return String.format("%s:%d", origin.getFileName(), origin.getLineNumber()); + } + private ResponseEntity buildErrorResponse(ApiResponseCode errorCode) { String errorTraceId = UUID.randomUUID().toString(); @@ -272,17 +284,25 @@ private void requestLogging( String errorTraceId ) { log.warn("[{}] {} | errorTraceId={}", httpStatus, errorMessage, errorTraceId); - requestDebugLogging(request); + requestDebugLogging(request, getRequestId()); } - private void requestDebugLogging(HttpServletRequest request) { + private void requestDebugLogging(HttpServletRequest request, String requestId) { if (!log.isDebugEnabled()) { return; } - log.debug("Request: {} {}", request.getMethod(), request.getRequestURI()); - log.debug("Headers: {}", getLoggableHeaders(request)); - log.debug("Query String: {}", getQueryString(request)); - log.debug("Body: {}", getRequestBody(request)); + log.debug("Request [requestId: {}]: {} {}", requestId, request.getMethod(), request.getRequestURI()); + log.debug("Headers [requestId: {}]: {}", requestId, getLoggableHeaders(request)); + log.debug("Query String [requestId: {}]: {}", requestId, getQueryString(request)); + log.debug("Body [requestId: {}]: {}", requestId, getRequestBody(request)); + } + + private String getRequestId() { + String requestId = MDC.get(REQUEST_ID_MDC_KEY); + if (requestId == null) { + return " - "; + } + return requestId; } private Map getLoggableHeaders(HttpServletRequest request) { diff --git a/src/test/java/gg/agit/konect/unit/global/exception/GlobalExceptionHandlerTest.java b/src/test/java/gg/agit/konect/unit/global/exception/GlobalExceptionHandlerTest.java index 5b39ec07e..febaad45e 100644 --- a/src/test/java/gg/agit/konect/unit/global/exception/GlobalExceptionHandlerTest.java +++ b/src/test/java/gg/agit/konect/unit/global/exception/GlobalExceptionHandlerTest.java @@ -8,6 +8,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.slf4j.LoggerFactory; +import org.slf4j.MDC; import org.springframework.boot.test.system.CapturedOutput; import org.springframework.boot.test.system.OutputCaptureExtension; import org.springframework.http.HttpStatus; @@ -34,12 +35,14 @@ void setUp() { @AfterEach void tearDown() { exceptionHandlerLogger.setLevel(originalLevel); + MDC.clear(); } @Test @DisplayName("예상하지 못한 예외도 디버그 로그에서 요청 본문을 확인할 수 있다") void logsRequestBodyAtDebugLevelForUnexpectedException(CapturedOutput output) { // given + MDC.put("requestId", "request-123"); GlobalExceptionHandler handler = new GlobalExceptionHandler(); MockHttpServletRequest request = new MockHttpServletRequest("POST", "/clubs"); request.setContentType("application/json"); @@ -61,21 +64,23 @@ void logsRequestBodyAtDebugLevelForUnexpectedException(CapturedOutput output) { // then assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); assertThat(output) - .contains("Request: POST /clubs") + .contains("Request ID: `request-123`") + .contains("Request [requestId: request-123]: POST /clubs") .contains("Authorization=***") .contains("Cookie=***") .contains("X-Request-ID=request-1") .doesNotContain("secret-token") .doesNotContain("secret-cookie") .contains( - "Query String: access%5Ftoken=***&access_token=***&code=***&name=KONECT&token=***&token=***" + "Query String [requestId: request-123]: " + + "access%5Ftoken=***&access_token=***&code=***&name=KONECT&token=***&token=***" ) .doesNotContain("encoded-query-secret") .doesNotContain("query-secret") .doesNotContain("oauth-code") .doesNotContain("repeat-secret-one") .doesNotContain("repeat-secret-two") - .contains("Body: {\"name\":\"KONECT\"}"); + .contains("Body [requestId: request-123]: {\"name\":\"KONECT\"}"); } @Test @@ -97,7 +102,51 @@ void skipsRequestDetailLoggingWhenDebugIsDisabled(CapturedOutput output) { // then assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); assertThat(output) - .doesNotContain("Request: POST /clubs") - .doesNotContain("Body: {\"name\":\"KONECT\"}"); + .doesNotContain("Request [requestId:") + .doesNotContain("Body [requestId:"); + } + + @Test + @DisplayName("스택트레이스가 비어 있는 예외도 2차 예외 없이 처리한다") + void handlesUnexpectedExceptionWithoutStackTrace(CapturedOutput output) { + // given + MDC.put("requestId", "request-empty-stack"); + GlobalExceptionHandler handler = new GlobalExceptionHandler(); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/clubs"); + RuntimeException exception = new RuntimeException("boom"); + exception.setStackTrace(new StackTraceElement[0]); + + // when + var response = handler.handleException(request, exception); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + assertThat(output) + .contains("Request ID: `request-empty-stack`") + .contains("Location: `unknown:0`"); + } + + @Test + @DisplayName("스택트레이스가 null인 예외도 2차 예외 없이 처리한다") + void handlesUnexpectedExceptionWithNullStackTrace(CapturedOutput output) { + // given + MDC.put("requestId", "request-null-stack"); + GlobalExceptionHandler handler = new GlobalExceptionHandler(); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/clubs"); + RuntimeException exception = new RuntimeException("boom") { + @Override + public StackTraceElement[] getStackTrace() { + return null; + } + }; + + // when + var response = handler.handleException(request, exception); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + assertThat(output) + .contains("Request ID: `request-null-stack`") + .contains("Location: `unknown:0`"); } } From c73509f137533c965e137ff179aa32fdc682f82e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Thu, 14 May 2026 15:41:18 +0900 Subject: [PATCH 47/50] =?UTF-8?q?feat:=20=EC=9B=B9=EC=82=AC=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20=EA=B3=B5=EA=B0=9C=20=EC=A0=95=EB=B3=B4=20API=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#623)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 웹사이트 공개 정보 API 추가 - 앱 로그인 흐름과 분리된 /website 공개 조회 API를 추가해 정적 웹사이트에서 학교와 동아리 정보를 볼 수 있게 한다 - 메인 화면의 대학명 검색과 지역 필터를 지원하기 위해 대학 지역 필드와 마이그레이션을 함께 둔다 - 대학별 동아리 목록, 동아리 상세, 최근 본 동아리 조회를 웹 전용 응답으로 분리해 기존 앱 API 계약을 건드리지 않는다 - 공개 접근 경로와 통합 테스트를 함께 추가해 인증 인터셉터에 막히지 않는 조회 동작을 고정한다 * fix: 웹사이트 공개 API 리뷰 피드백 반영 - 최근 본 동아리 조회의 clubIds 크기를 API 계약으로 제한해 과도한 요청이 서비스와 DB로 전달되지 않도록 했습니다. - nullable 기본값을 사용하는 페이지 조건은 Swagger 필수 표시를 낮춰 실제 런타임 동작과 문서가 어긋나지 않도록 했습니다. - 대학 수 응답 설명은 검색 결과 기준임을 명시해 기존 응답 필드명을 유지하면서 클라이언트 혼동을 줄였습니다. - 동아리 회원 수 집계에서 탈퇴 사용자를 제외해 공개 API가 활성 회원 기준 값을 반환하도록 했습니다. * fix: 웹사이트 검색 조건 리뷰 피드백 반영 - 공백 검색어는 조건을 만들지 않도록 먼저 차단해 불필요한 LIKE 조건이 생성되지 않게 했습니다. - 정규화된 검색어를 한 번만 계산해 대학명과 동아리명 검색 조건에서 같은 기준을 재사용했습니다. - 최근 본 동아리 조회의 최소 clubIds 계약을 빈 값과 누락 케이스 테스트로 고정했습니다. --- .../university/enums/UniversityRegion.java | 21 ++ .../domain/university/model/University.java | 9 +- .../domain/website/controller/WebsiteApi.java | 63 +++++ .../website/controller/WebsiteController.java | 62 +++++ .../dto/WebsiteClubDetailResponse.java | 119 ++++++++++ .../website/dto/WebsiteClubListCondition.java | 34 +++ .../website/dto/WebsiteClubsResponse.java | 156 ++++++++++++ .../website/dto/WebsiteHomeResponse.java | 58 +++++ .../model/WebsiteUniversitySummary.java | 13 + .../repository/WebsiteQueryRepository.java | 195 +++++++++++++++ .../website/service/WebsiteService.java | 94 ++++++++ .../konect/global/config/SecurityPaths.java | 3 +- .../V72__add_region_to_university.sql | 2 + .../domain/website/WebsiteApiTest.java | 224 ++++++++++++++++++ .../support/fixture/UniversityFixture.java | 6 + .../ClubMemberManagementServiceBatchTest.java | 2 + 16 files changed, 1059 insertions(+), 2 deletions(-) create mode 100644 src/main/java/gg/agit/konect/domain/university/enums/UniversityRegion.java create mode 100644 src/main/java/gg/agit/konect/domain/website/controller/WebsiteApi.java create mode 100644 src/main/java/gg/agit/konect/domain/website/controller/WebsiteController.java create mode 100644 src/main/java/gg/agit/konect/domain/website/dto/WebsiteClubDetailResponse.java create mode 100644 src/main/java/gg/agit/konect/domain/website/dto/WebsiteClubListCondition.java create mode 100644 src/main/java/gg/agit/konect/domain/website/dto/WebsiteClubsResponse.java create mode 100644 src/main/java/gg/agit/konect/domain/website/dto/WebsiteHomeResponse.java create mode 100644 src/main/java/gg/agit/konect/domain/website/model/WebsiteUniversitySummary.java create mode 100644 src/main/java/gg/agit/konect/domain/website/repository/WebsiteQueryRepository.java create mode 100644 src/main/java/gg/agit/konect/domain/website/service/WebsiteService.java create mode 100644 src/main/resources/db/migration/V72__add_region_to_university.sql create mode 100644 src/test/java/gg/agit/konect/integration/domain/website/WebsiteApiTest.java diff --git a/src/main/java/gg/agit/konect/domain/university/enums/UniversityRegion.java b/src/main/java/gg/agit/konect/domain/university/enums/UniversityRegion.java new file mode 100644 index 000000000..e58653a72 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/university/enums/UniversityRegion.java @@ -0,0 +1,21 @@ +package gg.agit.konect.domain.university.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum UniversityRegion { + + SEOUL("서울"), + GYEONGGI("경기도"), + CHUNGCHEONG("충청도"), + JEOLLA("전라도"), + GYEONGSANG("경상도"), + GANGWON("강원도"), + JEJU("제주도"), + UNKNOWN("지역 미지정"), + ; + + private final String displayName; +} diff --git a/src/main/java/gg/agit/konect/domain/university/model/University.java b/src/main/java/gg/agit/konect/domain/university/model/University.java index 020505ac4..09bb48f5d 100644 --- a/src/main/java/gg/agit/konect/domain/university/model/University.java +++ b/src/main/java/gg/agit/konect/domain/university/model/University.java @@ -5,6 +5,7 @@ import static lombok.AccessLevel.PROTECTED; import gg.agit.konect.domain.university.enums.Campus; +import gg.agit.konect.domain.university.enums.UniversityRegion; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.Enumerated; @@ -44,10 +45,16 @@ public class University { @Column(name = "campus", nullable = false) private Campus campus; + @NotNull + @Enumerated(value = STRING) + @Column(name = "region", nullable = false) + private UniversityRegion region; + @Builder - private University(Integer id, String koreanName, Campus campus) { + private University(Integer id, String koreanName, Campus campus, UniversityRegion region) { this.id = id; this.koreanName = koreanName; this.campus = campus; + this.region = region; } } diff --git a/src/main/java/gg/agit/konect/domain/website/controller/WebsiteApi.java b/src/main/java/gg/agit/konect/domain/website/controller/WebsiteApi.java new file mode 100644 index 000000000..70b7ba068 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/website/controller/WebsiteApi.java @@ -0,0 +1,63 @@ +package gg.agit.konect.domain.website.controller; + +import java.util.List; + +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import gg.agit.konect.domain.university.enums.UniversityRegion; +import gg.agit.konect.domain.website.dto.WebsiteClubDetailResponse; +import gg.agit.konect.domain.website.dto.WebsiteClubListCondition; +import gg.agit.konect.domain.website.dto.WebsiteClubsResponse; +import gg.agit.konect.domain.website.dto.WebsiteHomeResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Size; + +@Validated +@Tag(name = "(Public) Website: 웹사이트 공개 정보") +@RequestMapping("/website") +public interface WebsiteApi { + + @Operation(summary = "웹사이트 메인 화면 정보를 조회한다.", description = """ + 로그인 없이 접근 가능한 웹사이트 메인 정보입니다. + 대학 검색 결과와 대학별 등록 동아리 수를 반환합니다. + """) + @GetMapping("/home") + ResponseEntity getHome( + @RequestParam(name = "query", required = false) String query, + @RequestParam(name = "region", required = false) UniversityRegion region + ); + + @Operation(summary = "웹사이트 대학별 동아리 목록을 조회한다.", description = """ + 로그인 없이 접근 가능한 대학별 동아리 목록입니다. + 동아리명 검색, 분과 필터, 페이지네이션을 지원합니다. + """) + @GetMapping("/universities/{universityId}/clubs") + ResponseEntity getUniversityClubs( + @PathVariable(name = "universityId") Integer universityId, + @Valid @ParameterObject @ModelAttribute WebsiteClubListCondition condition + ); + + @Operation(summary = "웹사이트 동아리 상세 정보를 조회한다.") + @GetMapping("/clubs/{clubId}") + ResponseEntity getClubDetail( + @PathVariable(name = "clubId") Integer clubId + ); + + @Operation(summary = "최근 본 동아리 카드 정보를 조회한다.", description = """ + 프론트엔드가 로컬에 보관한 동아리 ID 목록을 전달하면 카드 표시용 정보를 반환합니다. + 반환 순서는 요청한 clubIds 순서를 따릅니다. + """) + @GetMapping("/clubs/recent") + ResponseEntity getRecentClubs( + @RequestParam(name = "clubIds") @Size(min = 1, max = 100) List clubIds + ); +} diff --git a/src/main/java/gg/agit/konect/domain/website/controller/WebsiteController.java b/src/main/java/gg/agit/konect/domain/website/controller/WebsiteController.java new file mode 100644 index 000000000..4764e0573 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/website/controller/WebsiteController.java @@ -0,0 +1,62 @@ +package gg.agit.konect.domain.website.controller; + +import java.util.List; + +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import gg.agit.konect.domain.university.enums.UniversityRegion; +import gg.agit.konect.domain.website.dto.WebsiteClubDetailResponse; +import gg.agit.konect.domain.website.dto.WebsiteClubListCondition; +import gg.agit.konect.domain.website.dto.WebsiteClubsResponse; +import gg.agit.konect.domain.website.dto.WebsiteHomeResponse; +import gg.agit.konect.domain.website.service.WebsiteService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@Validated +@RequiredArgsConstructor +public class WebsiteController implements WebsiteApi { + + private final WebsiteService websiteService; + + @Override + public ResponseEntity getHome( + @RequestParam(name = "query", required = false) String query, + @RequestParam(name = "region", required = false) UniversityRegion region + ) { + WebsiteHomeResponse response = websiteService.getHome(query, region); + return ResponseEntity.ok(response); + } + + @Override + public ResponseEntity getUniversityClubs( + @PathVariable(name = "universityId") Integer universityId, + @Valid @ParameterObject @ModelAttribute WebsiteClubListCondition condition + ) { + WebsiteClubsResponse response = websiteService.getUniversityClubs(universityId, condition); + return ResponseEntity.ok(response); + } + + @Override + public ResponseEntity getClubDetail( + @PathVariable(name = "clubId") Integer clubId + ) { + WebsiteClubDetailResponse response = websiteService.getClubDetail(clubId); + return ResponseEntity.ok(response); + } + + @Override + public ResponseEntity getRecentClubs( + @RequestParam(name = "clubIds") List clubIds + ) { + WebsiteClubsResponse response = websiteService.getRecentClubs(clubIds); + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/gg/agit/konect/domain/website/dto/WebsiteClubDetailResponse.java b/src/main/java/gg/agit/konect/domain/website/dto/WebsiteClubDetailResponse.java new file mode 100644 index 000000000..75783697e --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/website/dto/WebsiteClubDetailResponse.java @@ -0,0 +1,119 @@ +package gg.agit.konect.domain.website.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.time.LocalDateTime; + +import gg.agit.konect.domain.club.enums.ClubCategory; +import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.club.model.ClubRecruitment; +import gg.agit.konect.domain.university.enums.UniversityRegion; +import io.swagger.v3.oas.annotations.media.Schema; + +public record WebsiteClubDetailResponse( + @Schema(description = "동아리 고유 ID", example = "1", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "동아리명", example = "BCSD Lab", requiredMode = REQUIRED) + String name, + + @Schema(description = "동아리 로고 이미지 URL", requiredMode = REQUIRED) + String imageUrl, + + @Schema(description = "분과 코드", example = "ACADEMIC", requiredMode = REQUIRED) + ClubCategory category, + + @Schema(description = "분과명", example = "학술", requiredMode = REQUIRED) + String categoryName, + + @Schema(description = "한 줄 소개", example = "테스트 동아리 소개", requiredMode = REQUIRED) + String description, + + @Schema(description = "상세 소개", requiredMode = REQUIRED) + String introduce, + + @Schema(description = "활동 위치", example = "학생회관 101호", requiredMode = REQUIRED) + String location, + + @Schema(description = "등록 회원 수", example = "31", requiredMode = REQUIRED) + Long memberCount, + + @Schema(description = "대학 정보", requiredMode = REQUIRED) + University university, + + @Schema(description = "모집 정보", requiredMode = REQUIRED) + Recruitment recruitment +) { + + public record University( + @Schema(description = "대학 고유 ID", example = "1", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "대학명", example = "한국기술교육대학교", requiredMode = REQUIRED) + String name, + + @Schema(description = "캠퍼스명", example = "본교", requiredMode = REQUIRED) + String campusName, + + @Schema(description = "지역 코드", example = "CHUNGCHEONG", requiredMode = REQUIRED) + UniversityRegion region, + + @Schema(description = "지역명", example = "충청도", requiredMode = REQUIRED) + String regionName + ) { + } + + public record Recruitment( + @Schema(description = "모집 활성화 여부", example = "true", requiredMode = REQUIRED) + Boolean isRecruitmentEnabled, + + @Schema(description = "상시 모집 여부", example = "false", requiredMode = REQUIRED) + Boolean isAlwaysRecruiting, + + @Schema(description = "모집 시작 일시", requiredMode = REQUIRED) + LocalDateTime startAt, + + @Schema(description = "모집 마감 일시", requiredMode = REQUIRED) + LocalDateTime endAt, + + @Schema(description = "모집 공고 내용", requiredMode = REQUIRED) + String content + ) { + private static Recruitment from(Club club) { + ClubRecruitment recruitment = club.getClubRecruitment(); + if (recruitment == null) { + return new Recruitment(club.getIsRecruitmentEnabled(), false, null, null, null); + } + + return new Recruitment( + club.getIsRecruitmentEnabled(), + recruitment.getIsAlwaysRecruiting(), + recruitment.getStartAt(), + recruitment.getEndAt(), + recruitment.getContent() + ); + } + } + + public static WebsiteClubDetailResponse of(Club club, Long memberCount) { + return new WebsiteClubDetailResponse( + club.getId(), + club.getName(), + club.getImageUrl(), + club.getClubCategory(), + club.getClubCategory().getDescription(), + club.getDescription(), + club.getIntroduce(), + club.getLocation(), + memberCount, + new University( + club.getUniversity().getId(), + club.getUniversity().getKoreanName(), + club.getUniversity().getCampus().getDisplayName(), + club.getUniversity().getRegion(), + club.getUniversity().getRegion().getDisplayName() + ), + Recruitment.from(club) + ); + } +} diff --git a/src/main/java/gg/agit/konect/domain/website/dto/WebsiteClubListCondition.java b/src/main/java/gg/agit/konect/domain/website/dto/WebsiteClubListCondition.java new file mode 100644 index 000000000..f6f5c4678 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/website/dto/WebsiteClubListCondition.java @@ -0,0 +1,34 @@ +package gg.agit.konect.domain.website.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; + +import gg.agit.konect.domain.club.enums.ClubCategory; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; + +public record WebsiteClubListCondition( + @Schema(description = "페이지 번호", example = "1", requiredMode = NOT_REQUIRED) + @Min(1) + Integer page, + + @Schema(description = "페이지 크기", example = "12", requiredMode = NOT_REQUIRED) + @Min(1) + @Max(100) + Integer limit, + + @Schema(description = "동아리명 검색어", example = "BCSD", requiredMode = NOT_REQUIRED) + String query, + + @Schema(description = "동아리 분과", example = "ACADEMIC", requiredMode = NOT_REQUIRED) + ClubCategory category +) { + + private static final int DEFAULT_PAGE = 1; + private static final int DEFAULT_LIMIT = 12; + + public WebsiteClubListCondition { + page = page == null ? DEFAULT_PAGE : page; + limit = limit == null ? DEFAULT_LIMIT : limit; + } +} diff --git a/src/main/java/gg/agit/konect/domain/website/dto/WebsiteClubsResponse.java b/src/main/java/gg/agit/konect/domain/website/dto/WebsiteClubsResponse.java new file mode 100644 index 000000000..22023f985 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/website/dto/WebsiteClubsResponse.java @@ -0,0 +1,156 @@ +package gg.agit.konect.domain.website.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import org.springframework.data.domain.Page; + +import gg.agit.konect.domain.club.enums.ClubCategory; +import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.university.enums.UniversityRegion; +import gg.agit.konect.domain.university.model.University; +import io.swagger.v3.oas.annotations.media.Schema; + +public record WebsiteClubsResponse( + @Schema(description = "대학 정보", requiredMode = REQUIRED) + UniversityResponse university, + + @Schema(description = "전체 동아리 수", example = "28", requiredMode = REQUIRED) + Long totalCount, + + @Schema(description = "전체 페이지 수", example = "3", requiredMode = REQUIRED) + Integer totalPage, + + @Schema(description = "현재 페이지 번호", example = "1", requiredMode = REQUIRED) + Integer currentPage, + + @Schema(description = "분과별 동아리 수", requiredMode = REQUIRED) + List categories, + + @Schema(description = "동아리 목록", requiredMode = REQUIRED) + List clubs +) { + + public record UniversityResponse( + @Schema(description = "대학 고유 ID", example = "1", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "대학명", example = "한국기술교육대학교", requiredMode = REQUIRED) + String name, + + @Schema(description = "캠퍼스명", example = "본교", requiredMode = REQUIRED) + String campusName, + + @Schema(description = "지역 코드", example = "CHUNGCHEONG", requiredMode = REQUIRED) + UniversityRegion region, + + @Schema(description = "지역명", example = "충청도", requiredMode = REQUIRED) + String regionName + ) { + public static UniversityResponse from(University university) { + if (university == null) { + return null; + } + + return new UniversityResponse( + university.getId(), + university.getKoreanName(), + university.getCampus().getDisplayName(), + university.getRegion(), + university.getRegion().getDisplayName() + ); + } + } + + public record CategoryCountResponse( + @Schema(description = "분과 코드", example = "ACADEMIC", requiredMode = REQUIRED) + ClubCategory category, + + @Schema(description = "분과명", example = "학술", requiredMode = REQUIRED) + String categoryName, + + @Schema(description = "동아리 수", example = "5", requiredMode = REQUIRED) + Long count + ) { + public static CategoryCountResponse of(ClubCategory category, Long count) { + return new CategoryCountResponse(category, category.getDescription(), count); + } + } + + public record ClubResponse( + @Schema(description = "동아리 고유 ID", example = "1", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "동아리명", example = "BCSD Lab", requiredMode = REQUIRED) + String name, + + @Schema(description = "동아리 로고 이미지 URL", requiredMode = REQUIRED) + String imageUrl, + + @Schema(description = "분과 코드", example = "ACADEMIC", requiredMode = REQUIRED) + ClubCategory category, + + @Schema(description = "분과명", example = "학술", requiredMode = REQUIRED) + String categoryName, + + @Schema(description = "한 줄 소개", example = "테스트 동아리 소개", requiredMode = REQUIRED) + String description, + + @Schema(description = "등록 회원 수", example = "31", requiredMode = REQUIRED) + Long memberCount + ) { + public static ClubResponse of(Club club, Long memberCount) { + return new ClubResponse( + club.getId(), + club.getName(), + club.getImageUrl(), + club.getClubCategory(), + club.getClubCategory().getDescription(), + club.getDescription(), + memberCount + ); + } + } + + public static WebsiteClubsResponse of( + University university, + Page page, + Map memberCounts, + Map categoryCounts + ) { + return new WebsiteClubsResponse( + UniversityResponse.from(university), + page.getTotalElements(), + page.getTotalPages(), + page.getNumber() + 1, + createCategories(categoryCounts), + createClubs(page.getContent(), memberCounts) + ); + } + + public static WebsiteClubsResponse recent(List clubs, Map memberCounts) { + return new WebsiteClubsResponse( + null, + (long)clubs.size(), + 1, + 1, + List.of(), + createClubs(clubs, memberCounts) + ); + } + + private static List createCategories(Map categoryCounts) { + return Arrays.stream(ClubCategory.values()) + .map(category -> CategoryCountResponse.of(category, categoryCounts.getOrDefault(category, 0L))) + .toList(); + } + + private static List createClubs(List clubs, Map memberCounts) { + return clubs.stream() + .map(club -> ClubResponse.of(club, memberCounts.getOrDefault(club.getId(), 0L))) + .toList(); + } +} diff --git a/src/main/java/gg/agit/konect/domain/website/dto/WebsiteHomeResponse.java b/src/main/java/gg/agit/konect/domain/website/dto/WebsiteHomeResponse.java new file mode 100644 index 000000000..a0fbcd97d --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/website/dto/WebsiteHomeResponse.java @@ -0,0 +1,58 @@ +package gg.agit.konect.domain.website.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; + +import gg.agit.konect.domain.university.enums.UniversityRegion; +import gg.agit.konect.domain.website.model.WebsiteUniversitySummary; +import io.swagger.v3.oas.annotations.media.Schema; + +public record WebsiteHomeResponse( + @Schema(description = "검색 결과 대학 수", example = "28", requiredMode = REQUIRED) + Integer totalUniversityCount, + + @Schema(description = "대학 목록", requiredMode = REQUIRED) + List universities +) { + + public record UniversityResponse( + @Schema(description = "대학 고유 ID", example = "1", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "대학명", example = "한국기술교육대학교", requiredMode = REQUIRED) + String name, + + @Schema(description = "캠퍼스명", example = "본교", requiredMode = REQUIRED) + String campusName, + + @Schema(description = "지역 코드", example = "CHUNGCHEONG", requiredMode = REQUIRED) + UniversityRegion region, + + @Schema(description = "지역명", example = "충청도", requiredMode = REQUIRED) + String regionName, + + @Schema(description = "등록 동아리 수", example = "31", requiredMode = REQUIRED) + Long clubCount + ) { + public static UniversityResponse from(WebsiteUniversitySummary summary) { + return new UniversityResponse( + summary.id(), + summary.name(), + summary.campusName(), + summary.region(), + summary.regionName(), + summary.clubCount() + ); + } + } + + public static WebsiteHomeResponse from(List summaries) { + return new WebsiteHomeResponse( + summaries.size(), + summaries.stream() + .map(UniversityResponse::from) + .toList() + ); + } +} diff --git a/src/main/java/gg/agit/konect/domain/website/model/WebsiteUniversitySummary.java b/src/main/java/gg/agit/konect/domain/website/model/WebsiteUniversitySummary.java new file mode 100644 index 000000000..220fb8f98 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/website/model/WebsiteUniversitySummary.java @@ -0,0 +1,13 @@ +package gg.agit.konect.domain.website.model; + +import gg.agit.konect.domain.university.enums.UniversityRegion; + +public record WebsiteUniversitySummary( + Integer id, + String name, + String campusName, + UniversityRegion region, + String regionName, + Long clubCount +) { +} diff --git a/src/main/java/gg/agit/konect/domain/website/repository/WebsiteQueryRepository.java b/src/main/java/gg/agit/konect/domain/website/repository/WebsiteQueryRepository.java new file mode 100644 index 000000000..b3e2b3e07 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/website/repository/WebsiteQueryRepository.java @@ -0,0 +1,195 @@ +package gg.agit.konect.domain.website.repository; + +import static gg.agit.konect.domain.club.model.QClub.club; +import static gg.agit.konect.domain.club.model.QClubMember.clubMember; +import static gg.agit.konect.domain.club.model.QClubRecruitment.clubRecruitment; +import static gg.agit.konect.domain.university.model.QUniversity.university; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Repository; + +import com.querydsl.core.BooleanBuilder; +import com.querydsl.core.Tuple; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.NumberExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; + +import gg.agit.konect.domain.club.enums.ClubCategory; +import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.university.enums.UniversityRegion; +import gg.agit.konect.domain.university.model.University; +import gg.agit.konect.domain.website.model.WebsiteUniversitySummary; +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class WebsiteQueryRepository { + + private final JPAQueryFactory jpaQueryFactory; + + public List findUniversitySummaries(String query, UniversityRegion region) { + BooleanBuilder condition = new BooleanBuilder(); + addUniversitySearchCondition(condition, query); + addUniversityRegionCondition(condition, region); + NumberExpression clubCount = club.id.countDistinct(); + + List rows = jpaQueryFactory + .select(university.id, university.koreanName, university.campus, university.region, clubCount) + .from(university) + .leftJoin(club).on(club.university.id.eq(university.id)) + .where(condition) + .groupBy(university.id, university.koreanName, university.campus, university.region) + .orderBy(university.koreanName.asc(), university.campus.asc()) + .fetch(); + + return rows.stream() + .map(row -> new WebsiteUniversitySummary( + row.get(university.id), + row.get(university.koreanName), + row.get(university.campus).getDisplayName(), + row.get(university.region), + row.get(university.region).getDisplayName(), + row.get(clubCount) + )) + .toList(); + } + + public Optional findUniversity(Integer universityId) { + return Optional.ofNullable(jpaQueryFactory + .selectFrom(university) + .where(university.id.eq(universityId)) + .fetchOne()); + } + + public Page findClubs( + Integer universityId, + String query, + ClubCategory category, + PageRequest pageable + ) { + BooleanBuilder condition = createClubCondition(universityId, query, category); + + List clubs = jpaQueryFactory + .selectFrom(club) + .join(club.university, university).fetchJoin() + .leftJoin(club.clubRecruitment, clubRecruitment).fetchJoin() + .where(condition) + .orderBy(club.name.asc(), club.id.asc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + Long total = jpaQueryFactory + .select(club.count()) + .from(club) + .where(condition) + .fetchOne(); + + return new PageImpl<>(clubs, pageable, total == null ? 0 : total); + } + + public Map countClubCategories(Integer universityId, String query) { + BooleanBuilder condition = createClubCondition(universityId, query, null); + NumberExpression clubCount = club.count(); + + List rows = jpaQueryFactory + .select(club.clubCategory, clubCount) + .from(club) + .where(condition) + .groupBy(club.clubCategory) + .fetch(); + + Map categoryCounts = new LinkedHashMap<>(); + rows.forEach(row -> categoryCounts.put(row.get(club.clubCategory), row.get(clubCount))); + return categoryCounts; + } + + public Optional findClub(Integer clubId) { + return Optional.ofNullable(jpaQueryFactory + .selectFrom(club) + .join(club.university, university).fetchJoin() + .leftJoin(club.clubRecruitment, clubRecruitment).fetchJoin() + .where(club.id.eq(clubId)) + .fetchOne()); + } + + public List findClubs(List clubIds) { + if (clubIds.isEmpty()) { + return List.of(); + } + + return jpaQueryFactory + .selectFrom(club) + .join(club.university, university).fetchJoin() + .leftJoin(club.clubRecruitment, clubRecruitment).fetchJoin() + .where(club.id.in(clubIds)) + .fetch(); + } + + public Map countMembersByClubIds(List clubIds) { + if (clubIds.isEmpty()) { + return Map.of(); + } + NumberExpression memberCount = clubMember.count(); + + List rows = jpaQueryFactory + .select(clubMember.club.id, memberCount) + .from(clubMember) + .where( + clubMember.club.id.in(clubIds), + clubMember.user.deletedAt.isNull() + ) + .groupBy(clubMember.club.id) + .fetch(); + + Map memberCounts = new LinkedHashMap<>(); + rows.forEach(row -> memberCounts.put(row.get(clubMember.club.id), row.get(memberCount))); + return memberCounts; + } + + private BooleanBuilder createClubCondition(Integer universityId, String query, ClubCategory category) { + BooleanBuilder condition = new BooleanBuilder(); + + condition.and(club.university.id.eq(universityId)); + addClubSearchCondition(condition, query); + if (category != null) { + condition.and(club.clubCategory.eq(category)); + } + + return condition; + } + + private void addUniversitySearchCondition(BooleanBuilder condition, String query) { + if (query == null || query.isBlank()) { + return; + } + + String normalizedQuery = query.trim().toLowerCase(); + condition.and(university.koreanName.lower().contains(normalizedQuery)); + } + + private void addUniversityRegionCondition(BooleanBuilder condition, UniversityRegion region) { + if (region == null) { + return; + } + + condition.and(university.region.eq(region)); + } + + private void addClubSearchCondition(BooleanBuilder condition, String query) { + if (query == null || query.isBlank()) { + return; + } + + String normalizedQuery = query.trim().toLowerCase(); + BooleanExpression nameContains = club.name.lower().contains(normalizedQuery); + condition.and(nameContains); + } +} diff --git a/src/main/java/gg/agit/konect/domain/website/service/WebsiteService.java b/src/main/java/gg/agit/konect/domain/website/service/WebsiteService.java new file mode 100644 index 000000000..69b384658 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/website/service/WebsiteService.java @@ -0,0 +1,94 @@ +package gg.agit.konect.domain.website.service; + +import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_CLUB; +import static gg.agit.konect.global.code.ApiResponseCode.UNIVERSITY_NOT_FOUND; + +import java.util.Comparator; +import java.util.List; +import java.util.Map; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.university.enums.UniversityRegion; +import gg.agit.konect.domain.university.model.University; +import gg.agit.konect.domain.website.dto.WebsiteClubDetailResponse; +import gg.agit.konect.domain.website.dto.WebsiteClubListCondition; +import gg.agit.konect.domain.website.dto.WebsiteClubsResponse; +import gg.agit.konect.domain.website.dto.WebsiteHomeResponse; +import gg.agit.konect.domain.website.model.WebsiteUniversitySummary; +import gg.agit.konect.domain.website.repository.WebsiteQueryRepository; +import gg.agit.konect.global.exception.CustomException; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class WebsiteService { + + private final WebsiteQueryRepository websiteQueryRepository; + + public WebsiteHomeResponse getHome(String query, UniversityRegion region) { + List summaries = websiteQueryRepository.findUniversitySummaries(query, region); + return WebsiteHomeResponse.from(summaries); + } + + public WebsiteClubsResponse getUniversityClubs(Integer universityId, WebsiteClubListCondition condition) { + University university = websiteQueryRepository.findUniversity(universityId) + .orElseThrow(() -> CustomException.of(UNIVERSITY_NOT_FOUND)); + PageRequest pageable = PageRequest.of(condition.page() - 1, condition.limit()); + + Page clubs = websiteQueryRepository.findClubs( + universityId, + condition.query(), + condition.category(), + pageable + ); + List clubIds = clubs.getContent().stream() + .map(Club::getId) + .toList(); + + return WebsiteClubsResponse.of( + university, + clubs, + websiteQueryRepository.countMembersByClubIds(clubIds), + websiteQueryRepository.countClubCategories(universityId, condition.query()) + ); + } + + public WebsiteClubDetailResponse getClubDetail(Integer clubId) { + Club club = websiteQueryRepository.findClub(clubId) + .orElseThrow(() -> CustomException.of(NOT_FOUND_CLUB)); + Long memberCount = websiteQueryRepository.countMembersByClubIds(List.of(clubId)).getOrDefault(clubId, 0L); + + return WebsiteClubDetailResponse.of(club, memberCount); + } + + public WebsiteClubsResponse getRecentClubs(List clubIds) { + List distinctClubIds = clubIds.stream() + .distinct() + .toList(); + List clubs = websiteQueryRepository.findClubs(distinctClubIds); + Map order = createOrder(clubIds); + + List sortedClubs = clubs.stream() + .sorted(Comparator.comparingInt(club -> order.getOrDefault(club.getId(), Integer.MAX_VALUE))) + .toList(); + + return WebsiteClubsResponse.recent( + sortedClubs, + websiteQueryRepository.countMembersByClubIds(distinctClubIds) + ); + } + + private Map createOrder(List clubIds) { + Map order = new java.util.LinkedHashMap<>(); + for (int i = 0; i < clubIds.size(); i++) { + order.putIfAbsent(clubIds.get(i), i); + } + return order; + } +} diff --git a/src/main/java/gg/agit/konect/global/config/SecurityPaths.java b/src/main/java/gg/agit/konect/global/config/SecurityPaths.java index 814b02cfe..c78c79636 100644 --- a/src/main/java/gg/agit/konect/global/config/SecurityPaths.java +++ b/src/main/java/gg/agit/konect/global/config/SecurityPaths.java @@ -11,7 +11,8 @@ public final class SecurityPaths { "/swagger-resources/**", "/error", "/slack/events", - "/auth/oauth/google/drive/callback" + "/auth/oauth/google/drive/callback", + "/website/**" }; public static final String[] DENY_PATHS = {}; diff --git a/src/main/resources/db/migration/V72__add_region_to_university.sql b/src/main/resources/db/migration/V72__add_region_to_university.sql new file mode 100644 index 000000000..ddee0600b --- /dev/null +++ b/src/main/resources/db/migration/V72__add_region_to_university.sql @@ -0,0 +1,2 @@ +ALTER TABLE university + ADD COLUMN region VARCHAR(50) NOT NULL DEFAULT 'UNKNOWN' AFTER campus; diff --git a/src/test/java/gg/agit/konect/integration/domain/website/WebsiteApiTest.java b/src/test/java/gg/agit/konect/integration/domain/website/WebsiteApiTest.java new file mode 100644 index 000000000..b14cf0408 --- /dev/null +++ b/src/test/java/gg/agit/konect/integration/domain/website/WebsiteApiTest.java @@ -0,0 +1,224 @@ +package gg.agit.konect.integration.domain.website; + +import static org.hamcrest.Matchers.hasSize; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.time.LocalDateTime; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import gg.agit.konect.domain.club.enums.ClubCategory; +import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.university.enums.Campus; +import gg.agit.konect.domain.university.enums.UniversityRegion; +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.ClubMemberFixture; +import gg.agit.konect.support.fixture.ClubRecruitmentFixture; +import gg.agit.konect.support.fixture.UniversityFixture; +import gg.agit.konect.support.fixture.UserFixture; + +class WebsiteApiTest extends IntegrationTestSupport { + + @Nested + @DisplayName("GET /website/home - 웹사이트 메인") + class GetHome { + + @Test + @DisplayName("로그인 없이 대학 목록과 등록 동아리 수를 조회한다") + void getHomeWithoutLogin() throws Exception { + // given + University koreatech = persist(UniversityFixture.create( + "한국기술교육대학교", + Campus.MAIN, + UniversityRegion.CHUNGCHEONG + )); + University seoul = persist(UniversityFixture.create( + "서울대학교", + Campus.MAIN, + UniversityRegion.SEOUL + )); + persist(createClub(koreatech, "BCSD Lab", ClubCategory.ACADEMIC)); + persist(createClub(koreatech, "COK", ClubCategory.SPORTS)); + persist(createClub(seoul, "서울 동아리", ClubCategory.HOBBY)); + clearPersistenceContext(); + + // when & then + performGet("/website/home?query=한국®ion=CHUNGCHEONG") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalUniversityCount").value(1)) + .andExpect(jsonPath("$.universities[0].name").value("한국기술교육대학교")) + .andExpect(jsonPath("$.universities[0].campusName").value("본교")) + .andExpect(jsonPath("$.universities[0].region").value("CHUNGCHEONG")) + .andExpect(jsonPath("$.universities[0].regionName").value("충청도")) + .andExpect(jsonPath("$.universities[0].clubCount").value(2)); + + verify(loginCheckInterceptor, never()).preHandle(any(), any(), any()); + verify(authorizationInterceptor, never()).preHandle(any(), any(), any()); + } + } + + @Nested + @DisplayName("GET /website/universities/{universityId}/clubs - 대학별 동아리") + class GetUniversityClubs { + + @Test + @DisplayName("검색어와 분과로 동아리 목록을 조회하고 분과별 개수를 함께 반환한다") + void getUniversityClubsWithFilters() throws Exception { + // given + University university = persist(UniversityFixture.create( + "한국기술교육대학교", + Campus.MAIN, + UniversityRegion.CHUNGCHEONG + )); + Club bcsd = persist(createClub(university, "BCSD Lab", ClubCategory.ACADEMIC)); + Club study = persist(createClub(university, "경영전략연구회", ClubCategory.ACADEMIC)); + persist(createClub(university, "ZEST", ClubCategory.PERFORMANCE)); + persistMember(bcsd, "회원1", "2024000001"); + persistMember(bcsd, "회원2", "2024000002"); + withdraw(persistMember(bcsd, "탈퇴회원", "2024000004")); + persistMember(study, "회원3", "2024000003"); + clearPersistenceContext(); + + // when & then + performGet("/website/universities/" + university.getId() + + "/clubs?page=1&limit=10&query=BCSD&category=ACADEMIC") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.university.name").value("한국기술교육대학교")) + .andExpect(jsonPath("$.totalCount").value(1)) + .andExpect(jsonPath("$.clubs", hasSize(1))) + .andExpect(jsonPath("$.clubs[0].name").value("BCSD Lab")) + .andExpect(jsonPath("$.clubs[0].memberCount").value(2)) + .andExpect(jsonPath("$.categories[0].category").value("ACADEMIC")) + .andExpect(jsonPath("$.categories[0].count").value(1)); + } + + @Test + @DisplayName("존재하지 않는 대학이면 404를 반환한다") + void getUniversityClubsNotFound() throws Exception { + // when & then + performGet("/website/universities/99999/clubs") + .andExpect(status().isNotFound()); + } + } + + @Nested + @DisplayName("GET /website/clubs/{clubId} - 동아리 상세") + class GetClubDetail { + + @Test + @DisplayName("동아리 상세 소개와 모집 정보를 조회한다") + void getClubDetailSuccess() throws Exception { + // given + University university = persist(UniversityFixture.create( + "한국기술교육대학교", + Campus.MAIN, + UniversityRegion.CHUNGCHEONG + )); + Club club = persist(createClub(university, "ZEST", ClubCategory.PERFORMANCE)); + persist(ClubRecruitmentFixture.createAlwaysRecruiting(club)); + persistMember(club, "회장", "2024000004"); + clearPersistenceContext(); + + // when & then + performGet("/website/clubs/" + club.getId()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("ZEST")) + .andExpect(jsonPath("$.categoryName").value("공연")) + .andExpect(jsonPath("$.university.name").value("한국기술교육대학교")) + .andExpect(jsonPath("$.university.region").value("CHUNGCHEONG")) + .andExpect(jsonPath("$.memberCount").value(1)) + .andExpect(jsonPath("$.recruitment.isAlwaysRecruiting").value(true)) + .andExpect(jsonPath("$.recruitment.content").value("상시 모집 공고 내용입니다.")); + } + } + + @Nested + @DisplayName("GET /website/clubs/recent - 최근 본 동아리") + class GetRecentClubs { + + @Test + @DisplayName("요청한 동아리 ID 순서대로 카드 정보를 반환한다") + void getRecentClubsKeepsRequestOrder() throws Exception { + // given + University university = persist(UniversityFixture.create( + "한국기술교육대학교", + Campus.MAIN, + UniversityRegion.CHUNGCHEONG + )); + Club first = persist(createClub(university, "첫 번째", ClubCategory.ACADEMIC)); + Club second = persist(createClub(university, "두 번째", ClubCategory.SPORTS)); + clearPersistenceContext(); + + // when & then + performGet("/website/clubs/recent?clubIds=" + second.getId() + "," + first.getId()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.clubs", hasSize(2))) + .andExpect(jsonPath("$.clubs[0].name").value("두 번째")) + .andExpect(jsonPath("$.clubs[1].name").value("첫 번째")); + } + + @Test + @DisplayName("최근 본 동아리 ID가 100개를 초과하면 400을 반환한다") + void getRecentClubsRejectsTooManyClubIds() throws Exception { + // given + String clubIds = IntStream.rangeClosed(1, 101) + .mapToObj(String::valueOf) + .collect(Collectors.joining(",")); + + // when & then + performGet("/website/clubs/recent?clubIds=" + clubIds) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("최근 본 동아리 ID가 비어 있으면 400을 반환한다") + void getRecentClubsRejectsEmptyClubIds() throws Exception { + // when & then + performGet("/website/clubs/recent?clubIds=") + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("최근 본 동아리 ID가 없으면 400을 반환한다") + void getRecentClubsRejectsMissingClubIds() throws Exception { + // when & then + performGet("/website/clubs/recent") + .andExpect(status().isBadRequest()); + } + } + + private Club createClub(University university, String name, ClubCategory category) { + return Club.builder() + .university(university) + .name(name) + .description("한 줄 소개") + .introduce("상세 소개입니다.") + .imageUrl("https://example.com/" + name + ".png") + .location("학생회관 101호") + .clubCategory(category) + .isRecruitmentEnabled(false) + .isApplicationEnabled(false) + .isFeeRequired(false) + .build(); + } + + private User persistMember(Club club, String name, String studentNumber) { + User user = persist(UserFixture.createUser(club.getUniversity(), name, studentNumber)); + persist(ClubMemberFixture.createMember(club, user)); + return user; + } + + private void withdraw(User user) { + user.withdraw(LocalDateTime.now()); + } +} diff --git a/src/test/java/gg/agit/konect/support/fixture/UniversityFixture.java b/src/test/java/gg/agit/konect/support/fixture/UniversityFixture.java index 1b6e14593..7c529c33c 100644 --- a/src/test/java/gg/agit/konect/support/fixture/UniversityFixture.java +++ b/src/test/java/gg/agit/konect/support/fixture/UniversityFixture.java @@ -3,6 +3,7 @@ import org.springframework.test.util.ReflectionTestUtils; import gg.agit.konect.domain.university.enums.Campus; +import gg.agit.konect.domain.university.enums.UniversityRegion; import gg.agit.konect.domain.university.model.University; public class UniversityFixture { @@ -12,9 +13,14 @@ public static University create() { } public static University create(String koreanName, Campus campus) { + return create(koreanName, campus, UniversityRegion.CHUNGCHEONG); + } + + public static University create(String koreanName, Campus campus, UniversityRegion region) { return University.builder() .koreanName(koreanName) .campus(campus) + .region(region) .build(); } diff --git a/src/test/java/gg/agit/konect/unit/domain/club/service/ClubMemberManagementServiceBatchTest.java b/src/test/java/gg/agit/konect/unit/domain/club/service/ClubMemberManagementServiceBatchTest.java index 44d2666b2..a5d7e8114 100644 --- a/src/test/java/gg/agit/konect/unit/domain/club/service/ClubMemberManagementServiceBatchTest.java +++ b/src/test/java/gg/agit/konect/unit/domain/club/service/ClubMemberManagementServiceBatchTest.java @@ -27,6 +27,7 @@ import gg.agit.konect.domain.club.service.ClubMemberManagementService; import gg.agit.konect.domain.club.service.ClubPermissionValidator; import gg.agit.konect.domain.university.enums.Campus; +import gg.agit.konect.domain.university.enums.UniversityRegion; import gg.agit.konect.domain.university.model.University; import gg.agit.konect.domain.user.repository.UserRepository; import gg.agit.konect.global.code.ApiResponseCode; @@ -70,6 +71,7 @@ void setUp() { .id(1) .koreanName("Test University") .campus(Campus.MAIN) + .region(UniversityRegion.CHUNGCHEONG) .build(); club = Club.builder() From c44a053932564f5220df178a62957a4982d2a840 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Thu, 14 May 2026 16:17:20 +0900 Subject: [PATCH 48/50] =?UTF-8?q?fix:=20=EA=B3=B5=EA=B0=9C=20API=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=EB=A5=BC=20konect=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20(#624)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 프론트엔드가 사용할 공개 API prefix를 /website에서 /konect로 정리했습니다. - 인증 예외 경로도 새 prefix에 맞춰 로그인 없이 접근 가능한 계약을 유지했습니다. - 통합 테스트 요청 URL을 새 경로로 바꿔 라우팅 회귀를 검증하도록 했습니다. --- .../domain/website/controller/WebsiteApi.java | 2 +- .../konect/global/config/SecurityPaths.java | 2 +- .../domain/website/WebsiteApiTest.java | 24 +++++++++---------- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/main/java/gg/agit/konect/domain/website/controller/WebsiteApi.java b/src/main/java/gg/agit/konect/domain/website/controller/WebsiteApi.java index 70b7ba068..4cde81b7c 100644 --- a/src/main/java/gg/agit/konect/domain/website/controller/WebsiteApi.java +++ b/src/main/java/gg/agit/konect/domain/website/controller/WebsiteApi.java @@ -23,7 +23,7 @@ @Validated @Tag(name = "(Public) Website: 웹사이트 공개 정보") -@RequestMapping("/website") +@RequestMapping("/konect") public interface WebsiteApi { @Operation(summary = "웹사이트 메인 화면 정보를 조회한다.", description = """ diff --git a/src/main/java/gg/agit/konect/global/config/SecurityPaths.java b/src/main/java/gg/agit/konect/global/config/SecurityPaths.java index c78c79636..c6c154e5f 100644 --- a/src/main/java/gg/agit/konect/global/config/SecurityPaths.java +++ b/src/main/java/gg/agit/konect/global/config/SecurityPaths.java @@ -12,7 +12,7 @@ public final class SecurityPaths { "/error", "/slack/events", "/auth/oauth/google/drive/callback", - "/website/**" + "/konect/**" }; public static final String[] DENY_PATHS = {}; diff --git a/src/test/java/gg/agit/konect/integration/domain/website/WebsiteApiTest.java b/src/test/java/gg/agit/konect/integration/domain/website/WebsiteApiTest.java index b14cf0408..e9b347533 100644 --- a/src/test/java/gg/agit/konect/integration/domain/website/WebsiteApiTest.java +++ b/src/test/java/gg/agit/konect/integration/domain/website/WebsiteApiTest.java @@ -30,7 +30,7 @@ class WebsiteApiTest extends IntegrationTestSupport { @Nested - @DisplayName("GET /website/home - 웹사이트 메인") + @DisplayName("GET /konect/home - 웹사이트 메인") class GetHome { @Test @@ -53,7 +53,7 @@ void getHomeWithoutLogin() throws Exception { clearPersistenceContext(); // when & then - performGet("/website/home?query=한국®ion=CHUNGCHEONG") + performGet("/konect/home?query=한국®ion=CHUNGCHEONG") .andExpect(status().isOk()) .andExpect(jsonPath("$.totalUniversityCount").value(1)) .andExpect(jsonPath("$.universities[0].name").value("한국기술교육대학교")) @@ -68,7 +68,7 @@ void getHomeWithoutLogin() throws Exception { } @Nested - @DisplayName("GET /website/universities/{universityId}/clubs - 대학별 동아리") + @DisplayName("GET /konect/universities/{universityId}/clubs - 대학별 동아리") class GetUniversityClubs { @Test @@ -90,7 +90,7 @@ void getUniversityClubsWithFilters() throws Exception { clearPersistenceContext(); // when & then - performGet("/website/universities/" + university.getId() + performGet("/konect/universities/" + university.getId() + "/clubs?page=1&limit=10&query=BCSD&category=ACADEMIC") .andExpect(status().isOk()) .andExpect(jsonPath("$.university.name").value("한국기술교육대학교")) @@ -106,13 +106,13 @@ void getUniversityClubsWithFilters() throws Exception { @DisplayName("존재하지 않는 대학이면 404를 반환한다") void getUniversityClubsNotFound() throws Exception { // when & then - performGet("/website/universities/99999/clubs") + performGet("/konect/universities/99999/clubs") .andExpect(status().isNotFound()); } } @Nested - @DisplayName("GET /website/clubs/{clubId} - 동아리 상세") + @DisplayName("GET /konect/clubs/{clubId} - 동아리 상세") class GetClubDetail { @Test @@ -130,7 +130,7 @@ void getClubDetailSuccess() throws Exception { clearPersistenceContext(); // when & then - performGet("/website/clubs/" + club.getId()) + performGet("/konect/clubs/" + club.getId()) .andExpect(status().isOk()) .andExpect(jsonPath("$.name").value("ZEST")) .andExpect(jsonPath("$.categoryName").value("공연")) @@ -143,7 +143,7 @@ void getClubDetailSuccess() throws Exception { } @Nested - @DisplayName("GET /website/clubs/recent - 최근 본 동아리") + @DisplayName("GET /konect/clubs/recent - 최근 본 동아리") class GetRecentClubs { @Test @@ -160,7 +160,7 @@ void getRecentClubsKeepsRequestOrder() throws Exception { clearPersistenceContext(); // when & then - performGet("/website/clubs/recent?clubIds=" + second.getId() + "," + first.getId()) + performGet("/konect/clubs/recent?clubIds=" + second.getId() + "," + first.getId()) .andExpect(status().isOk()) .andExpect(jsonPath("$.clubs", hasSize(2))) .andExpect(jsonPath("$.clubs[0].name").value("두 번째")) @@ -176,7 +176,7 @@ void getRecentClubsRejectsTooManyClubIds() throws Exception { .collect(Collectors.joining(",")); // when & then - performGet("/website/clubs/recent?clubIds=" + clubIds) + performGet("/konect/clubs/recent?clubIds=" + clubIds) .andExpect(status().isBadRequest()); } @@ -184,7 +184,7 @@ void getRecentClubsRejectsTooManyClubIds() throws Exception { @DisplayName("최근 본 동아리 ID가 비어 있으면 400을 반환한다") void getRecentClubsRejectsEmptyClubIds() throws Exception { // when & then - performGet("/website/clubs/recent?clubIds=") + performGet("/konect/clubs/recent?clubIds=") .andExpect(status().isBadRequest()); } @@ -192,7 +192,7 @@ void getRecentClubsRejectsEmptyClubIds() throws Exception { @DisplayName("최근 본 동아리 ID가 없으면 400을 반환한다") void getRecentClubsRejectsMissingClubIds() throws Exception { // when & then - performGet("/website/clubs/recent") + performGet("/konect/clubs/recent") .andExpect(status().isBadRequest()); } } From a378ea48832de5af087564c3687ec70f1205dfd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Mon, 18 May 2026 14:12:59 +0900 Subject: [PATCH 49/50] =?UTF-8?q?feat:=20=EC=9B=B9=EC=82=AC=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20=EB=8C=80=ED=95=99=20=EC=9D=91=EB=8B=B5=EC=97=90=20?= =?UTF-8?q?=EB=A1=9C=EA=B3=A0=20URL=20=ED=8F=AC=ED=95=A8=20(#625)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 대학 로고 URL 컬럼 추가 - 웹사이트에서 대학 로고를 제공할 수 있도록 university 테이블에 logo_image_url 컬럼을 추가 - 기존 대학 데이터가 바로 마이그레이션될 수 있도록 컬럼은 nullable로 두고 필요한 대학부터 값을 채울 수 있게 선택 - JPA 엔티티에 동일한 필드를 추가해 이후 응답 매핑에서 DB 값을 그대로 사용할 수 있게 정리 * feat: 웹사이트 대학 응답에 로고 URL 포함 - GET /konect/home 대학 목록에서 logoImageUrl을 함께 반환하도록 projection과 응답 DTO를 확장 - GET /konect/universities/{universityId}/clubs 대학 정보에도 logoImageUrl을 포함해 상세 목록 화면에서 같은 로고 값을 사용할 수 있게 정리 - 통합 테스트에 두 API의 로고 URL 응답 검증을 추가해 필드 누락 회귀를 막음 * fix: 대학 이미지 URL 필드명을 imageUrl로 정리 - 대학 이미지 컬럼명을 기존 이미지 필드 컨벤션에 맞춰 image_url로 단순화 - 웹사이트 응답 필드도 imageUrl로 맞춰 동아리 이미지 응답과 같은 이름 규칙을 사용 - 통합 테스트 기대값을 새 응답 계약에 맞춰 갱신해 필드명 회귀를 방지 * fix: 대학 이미지 URL을 필수 컬럼으로 변경 - university.image_url이 null로 저장되지 않도록 마이그레이션에서 기존 행을 백필한 뒤 NOT NULL 제약을 적용 - JPA 엔티티에도 nullable false와 NotNull을 명시해 DB 계약과 도메인 모델을 맞춤 - 테스트 fixture는 기본 이미지 URL을 넣어 필수 컬럼 제약을 깨지 않게 정리 * docs: 홈 API 응답 예시에 동아리 수 추가 - Swagger 응답 예시에 clubCount를 명시해 홈 API 계약을 문서에서 바로 확인할 수 있게 정리 - imageUrl까지 포함한 실제 대학 목록 응답 형태를 예시로 제공해 프론트 구현 시 필드 누락 혼선을 줄임 * fix: 대학 이미지 기본값을 샘플 로고로 지정 - 기존 대학 데이터의 image_url 백필값을 운영 가능한 샘플 로고 URL로 지정 - 테스트 fixture 기본값도 같은 URL을 사용해 필수 이미지 계약과 테스트 데이터가 일치하도록 정리 * docs: 홈 API 응답 예시 하드코딩 제거 - 홈 API 문서에 고정된 clubCount 응답 예시를 두지 않도록 전체 예시 어노테이션을 제거 - 응답 필드 설명은 DTO 스키마에 맡겨 실제 데이터와 다른 숫자가 문서에 남지 않게 정리 * fix: 웹사이트 대학 응답 스키마 이름 충돌 방지 - 홈 응답과 대학별 동아리 응답의 내부 UniversityResponse 스키마 이름을 분리 - springdoc이 같은 단순 클래스명 스키마를 재사용해 홈 응답 예시에서 clubCount가 빠지는 문제를 방지 - 응답 예시 값을 하드코딩하지 않고 DTO 스키마 해석이 올바른 컴포넌트를 참조하도록 정리 --- .../domain/university/model/University.java | 7 ++++++- .../website/dto/WebsiteClubsResponse.java | 13 +++++++++++-- .../website/dto/WebsiteHomeResponse.java | 9 +++++++++ .../model/WebsiteUniversitySummary.java | 1 + .../repository/WebsiteQueryRepository.java | 18 ++++++++++++++++-- .../V73__add_image_url_to_university.sql | 9 +++++++++ .../domain/website/WebsiteApiTest.java | 8 ++++++-- .../support/fixture/UniversityFixture.java | 8 ++++++++ 8 files changed, 66 insertions(+), 7 deletions(-) create mode 100644 src/main/resources/db/migration/V73__add_image_url_to_university.sql diff --git a/src/main/java/gg/agit/konect/domain/university/model/University.java b/src/main/java/gg/agit/konect/domain/university/model/University.java index 09bb48f5d..1574b3b8a 100644 --- a/src/main/java/gg/agit/konect/domain/university/model/University.java +++ b/src/main/java/gg/agit/konect/domain/university/model/University.java @@ -50,11 +50,16 @@ public class University { @Column(name = "region", nullable = false) private UniversityRegion region; + @NotNull + @Column(name = "image_url", nullable = false) + private String imageUrl; + @Builder - private University(Integer id, String koreanName, Campus campus, UniversityRegion region) { + private University(Integer id, String koreanName, Campus campus, UniversityRegion region, String imageUrl) { this.id = id; this.koreanName = koreanName; this.campus = campus; this.region = region; + this.imageUrl = imageUrl; } } diff --git a/src/main/java/gg/agit/konect/domain/website/dto/WebsiteClubsResponse.java b/src/main/java/gg/agit/konect/domain/website/dto/WebsiteClubsResponse.java index 22023f985..7ec84e588 100644 --- a/src/main/java/gg/agit/konect/domain/website/dto/WebsiteClubsResponse.java +++ b/src/main/java/gg/agit/konect/domain/website/dto/WebsiteClubsResponse.java @@ -34,6 +34,7 @@ public record WebsiteClubsResponse( List clubs ) { + @Schema(name = "WebsiteClubsUniversityResponse") public record UniversityResponse( @Schema(description = "대학 고유 ID", example = "1", requiredMode = REQUIRED) Integer id, @@ -48,7 +49,14 @@ public record UniversityResponse( UniversityRegion region, @Schema(description = "지역명", example = "충청도", requiredMode = REQUIRED) - String regionName + String regionName, + + @Schema( + description = "대학 로고 이미지 URL", + example = "https://example.com/koreatech-logo.png", + requiredMode = REQUIRED + ) + String imageUrl ) { public static UniversityResponse from(University university) { if (university == null) { @@ -60,7 +68,8 @@ public static UniversityResponse from(University university) { university.getKoreanName(), university.getCampus().getDisplayName(), university.getRegion(), - university.getRegion().getDisplayName() + university.getRegion().getDisplayName(), + university.getImageUrl() ); } } diff --git a/src/main/java/gg/agit/konect/domain/website/dto/WebsiteHomeResponse.java b/src/main/java/gg/agit/konect/domain/website/dto/WebsiteHomeResponse.java index a0fbcd97d..8de1d2616 100644 --- a/src/main/java/gg/agit/konect/domain/website/dto/WebsiteHomeResponse.java +++ b/src/main/java/gg/agit/konect/domain/website/dto/WebsiteHomeResponse.java @@ -16,6 +16,7 @@ public record WebsiteHomeResponse( List universities ) { + @Schema(name = "WebsiteHomeUniversityResponse") public record UniversityResponse( @Schema(description = "대학 고유 ID", example = "1", requiredMode = REQUIRED) Integer id, @@ -32,6 +33,13 @@ public record UniversityResponse( @Schema(description = "지역명", example = "충청도", requiredMode = REQUIRED) String regionName, + @Schema( + description = "대학 로고 이미지 URL", + example = "https://example.com/koreatech-logo.png", + requiredMode = REQUIRED + ) + String imageUrl, + @Schema(description = "등록 동아리 수", example = "31", requiredMode = REQUIRED) Long clubCount ) { @@ -42,6 +50,7 @@ public static UniversityResponse from(WebsiteUniversitySummary summary) { summary.campusName(), summary.region(), summary.regionName(), + summary.imageUrl(), summary.clubCount() ); } diff --git a/src/main/java/gg/agit/konect/domain/website/model/WebsiteUniversitySummary.java b/src/main/java/gg/agit/konect/domain/website/model/WebsiteUniversitySummary.java index 220fb8f98..f97f23af9 100644 --- a/src/main/java/gg/agit/konect/domain/website/model/WebsiteUniversitySummary.java +++ b/src/main/java/gg/agit/konect/domain/website/model/WebsiteUniversitySummary.java @@ -8,6 +8,7 @@ public record WebsiteUniversitySummary( String campusName, UniversityRegion region, String regionName, + String imageUrl, Long clubCount ) { } diff --git a/src/main/java/gg/agit/konect/domain/website/repository/WebsiteQueryRepository.java b/src/main/java/gg/agit/konect/domain/website/repository/WebsiteQueryRepository.java index b3e2b3e07..ea142261b 100644 --- a/src/main/java/gg/agit/konect/domain/website/repository/WebsiteQueryRepository.java +++ b/src/main/java/gg/agit/konect/domain/website/repository/WebsiteQueryRepository.java @@ -41,11 +41,24 @@ public List findUniversitySummaries(String query, Univ NumberExpression clubCount = club.id.countDistinct(); List rows = jpaQueryFactory - .select(university.id, university.koreanName, university.campus, university.region, clubCount) + .select( + university.id, + university.koreanName, + university.campus, + university.region, + university.imageUrl, + clubCount + ) .from(university) .leftJoin(club).on(club.university.id.eq(university.id)) .where(condition) - .groupBy(university.id, university.koreanName, university.campus, university.region) + .groupBy( + university.id, + university.koreanName, + university.campus, + university.region, + university.imageUrl + ) .orderBy(university.koreanName.asc(), university.campus.asc()) .fetch(); @@ -56,6 +69,7 @@ public List findUniversitySummaries(String query, Univ row.get(university.campus).getDisplayName(), row.get(university.region), row.get(university.region).getDisplayName(), + row.get(university.imageUrl), row.get(clubCount) )) .toList(); diff --git a/src/main/resources/db/migration/V73__add_image_url_to_university.sql b/src/main/resources/db/migration/V73__add_image_url_to_university.sql new file mode 100644 index 000000000..5e76ee148 --- /dev/null +++ b/src/main/resources/db/migration/V73__add_image_url_to_university.sql @@ -0,0 +1,9 @@ +ALTER TABLE university + ADD COLUMN image_url VARCHAR(255) NULL AFTER region; + +UPDATE university +SET image_url = 'https://stage-static.koreatech.in/konect/user/university_logo_sample.png' +WHERE image_url IS NULL; + +ALTER TABLE university + MODIFY COLUMN image_url VARCHAR(255) NOT NULL AFTER region; diff --git a/src/test/java/gg/agit/konect/integration/domain/website/WebsiteApiTest.java b/src/test/java/gg/agit/konect/integration/domain/website/WebsiteApiTest.java index e9b347533..ab7a54b5c 100644 --- a/src/test/java/gg/agit/konect/integration/domain/website/WebsiteApiTest.java +++ b/src/test/java/gg/agit/konect/integration/domain/website/WebsiteApiTest.java @@ -40,7 +40,8 @@ void getHomeWithoutLogin() throws Exception { University koreatech = persist(UniversityFixture.create( "한국기술교육대학교", Campus.MAIN, - UniversityRegion.CHUNGCHEONG + UniversityRegion.CHUNGCHEONG, + "https://example.com/koreatech-logo.png" )); University seoul = persist(UniversityFixture.create( "서울대학교", @@ -60,6 +61,7 @@ void getHomeWithoutLogin() throws Exception { .andExpect(jsonPath("$.universities[0].campusName").value("본교")) .andExpect(jsonPath("$.universities[0].region").value("CHUNGCHEONG")) .andExpect(jsonPath("$.universities[0].regionName").value("충청도")) + .andExpect(jsonPath("$.universities[0].imageUrl").value("https://example.com/koreatech-logo.png")) .andExpect(jsonPath("$.universities[0].clubCount").value(2)); verify(loginCheckInterceptor, never()).preHandle(any(), any(), any()); @@ -78,7 +80,8 @@ void getUniversityClubsWithFilters() throws Exception { University university = persist(UniversityFixture.create( "한국기술교육대학교", Campus.MAIN, - UniversityRegion.CHUNGCHEONG + UniversityRegion.CHUNGCHEONG, + "https://example.com/koreatech-logo.png" )); Club bcsd = persist(createClub(university, "BCSD Lab", ClubCategory.ACADEMIC)); Club study = persist(createClub(university, "경영전략연구회", ClubCategory.ACADEMIC)); @@ -94,6 +97,7 @@ void getUniversityClubsWithFilters() throws Exception { + "/clubs?page=1&limit=10&query=BCSD&category=ACADEMIC") .andExpect(status().isOk()) .andExpect(jsonPath("$.university.name").value("한국기술교육대학교")) + .andExpect(jsonPath("$.university.imageUrl").value("https://example.com/koreatech-logo.png")) .andExpect(jsonPath("$.totalCount").value(1)) .andExpect(jsonPath("$.clubs", hasSize(1))) .andExpect(jsonPath("$.clubs[0].name").value("BCSD Lab")) diff --git a/src/test/java/gg/agit/konect/support/fixture/UniversityFixture.java b/src/test/java/gg/agit/konect/support/fixture/UniversityFixture.java index 7c529c33c..192ae5f18 100644 --- a/src/test/java/gg/agit/konect/support/fixture/UniversityFixture.java +++ b/src/test/java/gg/agit/konect/support/fixture/UniversityFixture.java @@ -8,6 +8,9 @@ public class UniversityFixture { + private static final String DEFAULT_IMAGE_URL = + "https://stage-static.koreatech.in/konect/user/university_logo_sample.png"; + public static University create() { return create("한국기술교육대학교", Campus.MAIN); } @@ -17,10 +20,15 @@ public static University create(String koreanName, Campus campus) { } public static University create(String koreanName, Campus campus, UniversityRegion region) { + return create(koreanName, campus, region, DEFAULT_IMAGE_URL); + } + + public static University create(String koreanName, Campus campus, UniversityRegion region, String imageUrl) { return University.builder() .koreanName(koreanName) .campus(campus) .region(region) + .imageUrl(imageUrl) .build(); } From 0ee35e41af34903b94718bd9ec34d18eb10b8d61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Mon, 18 May 2026 14:29:23 +0900 Subject: [PATCH 50/50] =?UTF-8?q?feat:=20=EB=8C=80=ED=95=99=EA=B5=90=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=97=85=EB=A1=9C=EB=93=9C=20tar?= =?UTF-8?q?get=20=EC=B6=94=EA=B0=80=20(#626)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 대학교 이미지도 공통 업로드 API를 사용할 수 있도록 UNIVERSITY target을 추가한다 - 기존 target 기반 key 생성 방식을 유지해 university 저장 경로가 다른 도메인과 같은 규칙을 따르도록 한다 - API 문서와 업로드 테스트에 새 target을 반영해 클라이언트 계약 누락과 저장 경로 회귀를 막는다 --- .../domain/upload/controller/UploadApi.java | 2 +- .../domain/upload/enums/UploadTarget.java | 1 + .../domain/upload/UploadApiTest.java | 20 +++++++++++++++++++ .../upload/service/UploadServiceTest.java | 20 +++++++++++++++++++ 4 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/main/java/gg/agit/konect/domain/upload/controller/UploadApi.java b/src/main/java/gg/agit/konect/domain/upload/controller/UploadApi.java index f070652aa..16f25548b 100644 --- a/src/main/java/gg/agit/konect/domain/upload/controller/UploadApi.java +++ b/src/main/java/gg/agit/konect/domain/upload/controller/UploadApi.java @@ -21,7 +21,7 @@ public interface UploadApi { @Operation(summary = "이미지 파일을 업로드한다.", description = """ 서버가 multipart 파일을 받아 S3에 업로드합니다. - - target 쿼리파라미터로 이미지 저장 대상 도메인을 지정합니다. (CLUB, BANK, COUNCIL, USER) + - target 쿼리파라미터로 이미지 저장 대상 도메인을 지정합니다. (CLUB, BANK, COUNCIL, USER, UNIVERSITY) - 응답의 fileUrl을 기존 도메인 API의 imageUrl로 사용합니다. ## 에러 diff --git a/src/main/java/gg/agit/konect/domain/upload/enums/UploadTarget.java b/src/main/java/gg/agit/konect/domain/upload/enums/UploadTarget.java index 6dd3d8697..ec0fb3d03 100644 --- a/src/main/java/gg/agit/konect/domain/upload/enums/UploadTarget.java +++ b/src/main/java/gg/agit/konect/domain/upload/enums/UploadTarget.java @@ -10,6 +10,7 @@ public enum UploadTarget { BANK("은행"), COUNCIL("총학생회"), USER("사용자"), + UNIVERSITY("대학교"), ; private final String description; diff --git a/src/test/java/gg/agit/konect/integration/domain/upload/UploadApiTest.java b/src/test/java/gg/agit/konect/integration/domain/upload/UploadApiTest.java index dbb7c6370..dd5e6b5fe 100644 --- a/src/test/java/gg/agit/konect/integration/domain/upload/UploadApiTest.java +++ b/src/test/java/gg/agit/konect/integration/domain/upload/UploadApiTest.java @@ -79,6 +79,26 @@ void uploadImageSuccess() throws Exception { assertThat(requestCaptor.getValue().contentType()).isEqualTo("image/png"); } + @Test + @DisplayName("대학교 이미지를 업로드하면 university 경로에 저장한다") + void uploadUniversityImageSuccess() throws Exception { + // given + byte[] pngBytes = createPngBytes(8, 8); + MockMultipartFile file = imageFile("university.png", "image/png", pngBytes); + + // when + MvcResult result = uploadImage(file, UploadTarget.UNIVERSITY) + .andExpect(status().isOk()) + .andReturn(); + + // then + String responseBody = result.getResponse().getContentAsString(); + String key = JsonPath.read(responseBody, "$.key"); + + assertThat(key).startsWith("test/university/"); + assertThat(key).endsWith(".png"); + } + @Test @DisplayName("jpeg 이미지를 업로드하면 원본 형태로 저장한다") void uploadJpegImageSuccess() throws Exception { diff --git a/src/test/java/gg/agit/konect/unit/domain/upload/service/UploadServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/upload/service/UploadServiceTest.java index 9c9801e54..d30c74a79 100644 --- a/src/test/java/gg/agit/konect/unit/domain/upload/service/UploadServiceTest.java +++ b/src/test/java/gg/agit/konect/unit/domain/upload/service/UploadServiceTest.java @@ -104,6 +104,26 @@ void uploadImageBuildsKeyWithoutPrefixWhenPrefixBlank() { assertThat(response.key()).matches("bank/\\d{4}-\\d{2}-\\d{2}-[0-9a-f\\-]{36}\\.png"); } + @Test + @DisplayName("uploadImage는 UNIVERSITY target을 university 경로로 저장한다") + void uploadImageBuildsUniversityKeyPath() { + // given + MockMultipartFile file = new MockMultipartFile( + "file", + "university.png", + "image/png", + "png-data".getBytes(StandardCharsets.UTF_8) + ); + + // when + ImageUploadResponse response = uploadService.uploadImage(file, UploadTarget.UNIVERSITY); + + // then + assertThat(response.key()).matches( + "konect/university/\\d{4}-\\d{2}-\\d{2}-[0-9a-f\\-]{36}\\.png" + ); + } + @Test @DisplayName("uploadImage는 leading slash prefix를 제거하고 trailing slash를 보정한다") void uploadImageNormalizesPrefixWithLeadingSlash() {