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 3600ab216..8b94b854e 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 @@ -66,29 +66,16 @@ public void updateLastReadAt(Integer roomId, Integer userId, LocalDateTime readA } @Transactional(propagation = Propagation.REQUIRES_NEW) - public void updateDirectRoomLastReadAt(Integer roomId, Integer userId, LocalDateTime readAt) { - User user = userRepository.getById(userId); - ChatRoom room = chatRoomRepository.findById(roomId) - .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); + public void updateDirectRoomLastReadAt(Integer roomId, User user, LocalDateTime readAt, ChatRoom room) { + // 어드민이 SYSTEM_ADMIN 방의 메시지를 읽으면 SYSTEM_ADMIN의 lastReadAt을 업데이트 + if (user.getRole() == UserRole.ADMIN && isSystemAdminRoom(roomId)) { + chatRoomMemberRepository.updateLastReadAtIfOlder(roomId, SYSTEM_ADMIN_ID, readAt); + return; + } ensureDirectRoomMemberExists(room, user, readAt); - if (user.getRole() == UserRole.ADMIN) { - List members = chatRoomMemberRepository.findByChatRoomId(roomId); - boolean isSystemAdmin = members.stream() - .anyMatch(member -> Objects.equals(member.getUserId(), SYSTEM_ADMIN_ID)); - - if (isSystemAdmin) { - for (ChatRoomMember member : members) { - if (member.getUser().getRole() == UserRole.ADMIN) { - chatRoomMemberRepository.updateLastReadAtIfOlder(roomId, member.getUserId(), readAt); - } - } - return; - } - } - - chatRoomMemberRepository.updateLastReadAtIfOlder(roomId, userId, readAt); + chatRoomMemberRepository.updateLastReadAtIfOlder(roomId, user.getId(), readAt); } @Transactional(propagation = Propagation.REQUIRES_NEW) @@ -145,8 +132,9 @@ private void ensureDirectRoomMemberExists(ChatRoom room, User user, LocalDateTim return; } + // 어드민은 SYSTEM_ADMIN 방의 메시지를 조회할 수 있지만, 멤버로 추가되지는 않는다 + // (멤버가 추가되면 findByTwoUsers에서 해당 방을 찾지 못해 채팅방이 중복 생성됨) if (user.getRole() == UserRole.ADMIN && isSystemAdminRoom(room.getId())) { - saveRoomMemberIgnoringDuplicate(room, user, readAt); return; } 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 e8a18af98..794c114ae 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 @@ -19,7 +19,6 @@ import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; @@ -388,15 +387,23 @@ record SectionKey(Integer clubId, String clubName) { return ChatInvitableUsersResponse.forClubSort(pagedInvitableUsers, sections); } - @Transactional(propagation = Propagation.NOT_SUPPORTED) + @Transactional(readOnly = true) public ChatMessagePageResponse getMessages(Integer userId, Integer roomId, Integer page, Integer limit) { ChatRoom room = chatRoomRepository.findById(roomId) .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); + User user = userRepository.getById(userId); LocalDateTime readAt = LocalDateTime.now(); if (room.isDirectRoom()) { - chatRoomMembershipService.updateDirectRoomLastReadAt(roomId, userId, readAt); + boolean isAdminViewingSystemRoom = user.getRole() == UserRole.ADMIN && isSystemAdminRoom(room); + if (isAdminViewingSystemRoom) { + chatRoomMembershipService.updateLastReadAt(roomId, SYSTEM_ADMIN_ID, readAt); + recordPresenceSafely(roomId, userId); + return getAdminSystemDirectChatRoomMessages(user, room, roomId, page, limit, readAt); + } + + chatRoomMembershipService.updateDirectRoomLastReadAt(roomId, user, readAt, room); recordPresenceSafely(roomId, userId); return getDirectChatRoomMessages(userId, roomId, page, limit, readAt); } @@ -440,7 +447,12 @@ public ChatMuteResponse toggleMute(Integer userId, Integer roomId) { ClubMember member = clubMemberRepository.getByClubIdAndUserId(room.getClub().getId(), userId); ensureRoomMember(room, member.getUser(), member.getCreatedAt()); } else if (room.isDirectRoom()) { - getAccessibleDirectRoomMember(room, user); + // 어드민이 SYSTEM_ADMIN 방에 접근하는 경우는 멤버십 체크를 건너뜀 + boolean isAdminAccessingSystemAdminRoom = user.getRole() == UserRole.ADMIN + && isSystemAdminRoom(room); + if (!isAdminAccessingSystemAdminRoom) { + getAccessibleDirectRoomMember(room, user); + } } else { getAccessibleRoomMember(room, userId); } @@ -617,24 +629,63 @@ private ChatMessagePageResponse getDirectChatRoomMessages( ) { ChatRoom chatRoom = getDirectRoom(roomId); User user = userRepository.getById(userId); - ChatRoomMember member = getOrCreateDirectRoomMember(chatRoom, user); - LocalDateTime visibleMessageFrom = prepareDirectRoomAccess(member, chatRoom); - - boolean isAdminViewingSystemRoom = user.getRole() == UserRole.ADMIN && isSystemAdminRoom(chatRoom); + List members = chatRoomMemberRepository.findByChatRoomId(roomId); + LocalDateTime visibleMessageFrom = prepareDirectRoomAccess(getOrCreateDirectRoomMember(chatRoom, user), + chatRoom); PageRequest pageable = PageRequest.of(page - 1, limit); Page messages = chatMessageRepository.findByChatRoomId(roomId, visibleMessageFrom, pageable); + + List sortedReadBaselines = toSortedReadBaselines(members); + + List responseMessages = messages.getContent().stream() + .map(message -> { + boolean isRead = message.isSentBy(userId) || !message.getCreatedAt().isAfter(readAt); + int unreadCount = countUnreadSince(message.getCreatedAt(), sortedReadBaselines); + return new ChatMessageDetailResponse( + message.getId(), + message.getSender().getId(), + null, + message.getContent(), + message.getCreatedAt(), + isRead, + unreadCount, + message.isSentBy(userId) + ); + }) + .toList(); + + return new ChatMessagePageResponse( + messages.getTotalElements(), + messages.getNumberOfElements(), + messages.getTotalPages(), + messages.getNumber() + 1, + null, + responseMessages + ); + } + + 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 = isAdminViewingSystemRoom - ? toAdminChatReadBaselines(members) - : toSortedReadBaselines(members); + PageRequest pageable = PageRequest.of(page - 1, limit); + Page messages = chatMessageRepository.findByChatRoomId(roomId, visibleMessageFrom, pageable); + + List sortedReadBaselines = toAdminChatReadBaselines(members); Integer maskedAdminId = getMaskedAdminId(user, chatRoom); List responseMessages = messages.getContent().stream() .map(message -> { Integer senderId = resolveDirectSenderId(message, maskedAdminId); - boolean isMine = shouldDisplayAsOwnMessage(user, message, isAdminViewingSystemRoom); + boolean isMine = shouldDisplayAsOwnMessage(user, message, true); boolean isRead = isMine || !message.getCreatedAt().isAfter(readAt); int unreadCount = countUnreadSince(message.getCreatedAt(), sortedReadBaselines); return new ChatMessageDetailResponse( @@ -667,19 +718,37 @@ private ChatMessageDetailResponse sendDirectMessage( ) { ChatRoom chatRoom = getDirectRoom(roomId); User sender = userRepository.getById(userId); - ChatRoomMember senderMember = getAccessibleDirectRoomMember(chatRoom, sender); - boolean senderHadLeft = senderMember.hasLeft(); + + // 어드민이 SYSTEM_ADMIN 방에 메시지를 보내는 경우 + boolean isAdminSendingToSystemAdminRoom = sender.getRole() == UserRole.ADMIN + && isSystemAdminRoom(chatRoom); + + ChatRoomMember senderMember = null; + boolean senderHadLeft = false; + + if (!isAdminSendingToSystemAdminRoom) { + senderMember = getAccessibleDirectRoomMember(chatRoom, sender); + senderHadLeft = senderMember.hasLeft(); + } + List members = chatRoomMemberRepository.findByChatRoomId(roomId); - User receiver = resolveDirectChatPartner(members, userId); + User receiver = resolveDirectMessageReceiver(members, sender); ChatMessage chatMessage = chatMessageRepository.save( ChatMessage.of(chatRoom, sender, request.content()) ); - if (senderHadLeft) { + + if (senderHadLeft && senderMember != null) { senderMember.restoreDirectRoom(); } + chatRoom.updateLastMessage(chatMessage.getContent(), chatMessage.getCreatedAt()); - updateMemberLastReadAt(roomId, userId, chatMessage.getCreatedAt()); + + // 어드민이 보낸 경우는 lastReadAt 업데이트하지 않음 (멤버가 아니므로) + if (!isAdminSendingToSystemAdminRoom) { + updateMemberLastReadAt(roomId, userId, chatMessage.getCreatedAt()); + } + List sortedReadBaselines = toSortedReadBaselines(members); notificationService.sendChatNotification(receiver.getId(), roomId, sender.getName(), request.content()); @@ -1170,6 +1239,10 @@ private void ensureRoomMember(ChatRoom room, User user, LocalDateTime 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()) { @@ -1184,6 +1257,12 @@ private void ensureDirectRoomRequester(ChatRoom room, User user, LocalDateTime j }, () -> chatRoomMemberRepository.save(ChatRoomMember.of(room, user, joinedAt))); } + private boolean shouldSkipSystemAdminMembership(ChatRoom room, User user) { + // 문의방은 SYSTEM_ADMIN + 일반 사용자 2인 구조를 전제로 재사용(findByTwoUsers)되므로, + // 생성/재오픈 경로에서도 일반 ADMIN을 멤버로 추가하면 안 된다. + return user.getRole() == UserRole.ADMIN && isSystemAdminRoom(room); + } + private String normalizeCustomRoomName(String roomName) { if (!StringUtils.hasText(roomName)) { return null; @@ -1303,9 +1382,9 @@ 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.getRole() == UserRole.ADMIN && isSystemAdminRoom(chatRoom)) { - LocalDateTime joinedAt = LocalDateTime.now(); - return chatRoomMemberRepository.save(ChatRoomMember.of(chatRoom, user, joinedAt)); + throw CustomException.of(FORBIDDEN_CHAT_ROOM_ACCESS); } throw CustomException.of(FORBIDDEN_CHAT_ROOM_ACCESS); }); @@ -1323,6 +1402,11 @@ private LocalDateTime prepareDirectRoomAccess(ChatRoomMember member, ChatRoom ch return visibleMessageFrom; } + private LocalDateTime resolveAdminSystemRoomVisibleMessageFrom(List members) { + ChatRoomMember systemAdminMember = findRoomMember(members, SYSTEM_ADMIN_ID); + return systemAdminMember != null ? systemAdminMember.getVisibleMessageFrom() : null; + } + /** * direct 채팅방에서 나간 사용자가 다시 볼 수 있는 상태인지 확인하고, * 새 메시지가 이미 존재하면 나간 상태를 해제한다. @@ -1446,6 +1530,29 @@ private User resolveDirectChatPartner(List members, Integer user return findDirectPartner(members, userId); } + private User findNonAdminUser(List members) { + return members.stream() + .map(ChatRoomMember::getUser) + .filter(memberUser -> memberUser.getRole() != UserRole.ADMIN) + .findFirst() + .orElse(null); + } + + private User resolveDirectMessageReceiver(List members, User sender) { + if (sender.getRole() == UserRole.ADMIN) { + User nonAdminUser = findNonAdminUser(members); + if (nonAdminUser != null) { + return nonAdminUser; + } + } + + User partner = resolveDirectChatPartner(members, sender.getId()); + if (partner == null) { + throw CustomException.of(FORBIDDEN_CHAT_ROOM_ACCESS); + } + return partner; + } + private User findDirectPartnerFromMemberInfo( List memberInfos, Integer userId, diff --git a/src/main/resources/db/migration/V68__remove_admin_members_from_system_admin_rooms.sql b/src/main/resources/db/migration/V68__remove_admin_members_from_system_admin_rooms.sql new file mode 100644 index 000000000..3ff8619f9 --- /dev/null +++ b/src/main/resources/db/migration/V68__remove_admin_members_from_system_admin_rooms.sql @@ -0,0 +1,18 @@ +-- SYSTEM_ADMIN(1번)이 있는 DIRECT 채팅방에서 다른 어드민 멤버십 제거 +-- 이유: 어드민이 멤버로 추가되면 findByTwoUsers에서 해당 방을 찾지 못해 중복 생성됨 +-- 참고: https://github.com/BCSDLab/KONECT_BACK_END/issues/503 + +DELETE FROM chat_room_member +WHERE user_id IN ( + SELECT u.id + FROM users u + WHERE u.role = 'ADMIN' + AND u.id != 1 -- SYSTEM_ADMIN(1번)은 제외 +) +AND chat_room_id IN ( + SELECT DISTINCT crm.chat_room_id + FROM chat_room_member crm + JOIN chat_room cr ON crm.chat_room_id = cr.id + WHERE crm.user_id = 1 -- SYSTEM_ADMIN(1번)이 있는 방 + AND cr.room_type = 'DIRECT' -- DIRECT 타입 방만 +); 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 bcc14fb71..d34f8ffdb 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 @@ -1,6 +1,8 @@ package gg.agit.konect.integration.domain.chat; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.clearInvocations; +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; @@ -303,6 +305,67 @@ void createAdminChatRoomAndGetRoomsSuccess() throws Exception { .andExpect(jsonPath("$.rooms[0].isMuted").value(false)); } + @Test + @DisplayName("관리자가 문의방을 다시 열어도 관리자 멤버는 추가되지 않는다") + void adminCreateOrGetInquiryRoomDoesNotAddAdminMember() throws Exception { + User anotherAdmin = persist(UserFixture.createAdmin(university)); + clearPersistenceContext(); + + mockLoginUser(normalUser.getId()); + var createResult = performPost("/chats/rooms/admin") + .andExpect(status().isOk()) + .andReturn(); + + int chatRoomId = parseChatRoomId(createResult); + + mockLoginUser(anotherAdmin.getId()); + performPost("/chats/rooms", new ChatRoomCreateRequest(normalUser.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.chatRoomId").value(chatRoomId)); + + clearPersistenceContext(); + + assertThat(chatRoomRepository.findByTwoUsers(SYSTEM_ADMIN_ID, normalUser.getId(), ChatType.DIRECT)) + .isPresent() + .get() + .extracting(ChatRoom::getId) + .isEqualTo(chatRoomId); + assertThat(chatRoomMemberRepository.findByChatRoomId(chatRoomId)) + .extracting(ChatRoomMember::getUserId) + .containsExactlyInAnyOrder(SYSTEM_ADMIN_ID, normalUser.getId()); + } + + @Test + @DisplayName("관리자는 멤버가 아니어도 문의방 메시지를 조회할 수 있다") + void adminCanReadInquiryRoomMessagesWithoutMembership() throws Exception { + 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()); + performGet("/chats/rooms/" + chatRoomId + "?page=1&limit=20") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalCount").value(1)) + .andExpect(jsonPath("$.messages[0].content").value("문의 내용입니다")) + .andExpect(jsonPath("$.messages[0].isMine").value(false)); + + assertThat(chatRoomMemberRepository.findByChatRoomId(chatRoomId)) + .extracting(ChatRoomMember::getUserId) + .containsExactlyInAnyOrder(SYSTEM_ADMIN_ID, normalUser.getId()); + } + @Test @DisplayName("어드민이 나간 문의 채팅방에 사용자가 새 메시지를 보내 어드민 목록에 다시 노출된다") @DirtiesContext(methodMode = DirtiesContext.MethodMode.AFTER_METHOD) @@ -631,6 +694,31 @@ void sendMessageSuccess() throws Exception { .containsExactly("안녕하세요"); } + @Test + @DisplayName("관리자가 문의방에 답변하면 실제 문의 사용자에게 알림을 보낸다") + void adminReplySendsNotificationToInquiryUser() throws Exception { + User anotherAdmin = persist(UserFixture.createAdmin(university)); + clearPersistenceContext(); + + mockLoginUser(normalUser.getId()); + int roomId = objectMapper.readTree( + performPost("/chats/rooms/admin") + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString() + ).get("chatRoomId").asInt(); + + clearInvocations(notificationService); + + mockLoginUser(anotherAdmin.getId()); + performPost("/chats/rooms/" + roomId + "/messages", new ChatMessageSendRequest("관리자 답변입니다")) + .andExpect(status().isOk()); + + verify(notificationService) + .sendChatNotification(normalUser.getId(), roomId, anotherAdmin.getName(), "관리자 답변입니다"); + } + @Test @DisplayName("빈 메시지를 전송하면 400을 반환한다") void sendBlankMessageFails() throws Exception {