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); + } +}