From b35cea51a42598a2538370ec22689d33354cd7a1 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, 31 Mar 2026 00:16:10 +0900 Subject: [PATCH 1/9] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85=EB=B0=A9=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=20=EB=B0=8F=20=ED=83=88=ED=87=B4=20=EC=BB=AC?= =?UTF-8?q?=EB=9F=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../konect/domain/chat/model/ChatRoom.java | 29 +++++++++++-- .../domain/chat/model/ChatRoomMember.java | 42 +++++++++++++++++++ ...__add_chat_room_type_and_leave_columns.sql | 21 ++++++++++ 3 files changed, 88 insertions(+), 4 deletions(-) create mode 100644 src/main/resources/db/migration/V64__add_chat_room_type_and_leave_columns.sql diff --git a/src/main/java/gg/agit/konect/domain/chat/model/ChatRoom.java b/src/main/java/gg/agit/konect/domain/chat/model/ChatRoom.java index f437e57f4..6e5a8faf6 100644 --- a/src/main/java/gg/agit/konect/domain/chat/model/ChatRoom.java +++ b/src/main/java/gg/agit/konect/domain/chat/model/ChatRoom.java @@ -7,12 +7,15 @@ import java.time.LocalDateTime; +import gg.agit.konect.domain.chat.enums.ChatType; import gg.agit.konect.domain.club.model.Club; import gg.agit.konect.domain.user.model.User; import gg.agit.konect.global.exception.CustomException; import gg.agit.konect.global.model.BaseEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.GeneratedValue; import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; @@ -39,26 +42,40 @@ public class ChatRoom extends BaseEntity { @Column(name = "last_message_sent_at") private LocalDateTime lastMessageSentAt; + @Enumerated(EnumType.STRING) + @Column(name = "room_type", nullable = false, length = 20) + private ChatType roomType; + @ManyToOne(fetch = LAZY) @JoinColumn(name = "club_id") private Club club; @Builder - private ChatRoom(Integer id, Club club) { + private ChatRoom(Integer id, ChatType roomType, Club club) { this.id = id; + this.roomType = roomType; this.club = club; } public static ChatRoom directOf() { - return ChatRoom.builder().build(); + return ChatRoom.builder() + .roomType(ChatType.DIRECT) + .build(); } public static ChatRoom groupOf(Club club) { return ChatRoom.builder() + .roomType(ChatType.GROUP) .club(club) .build(); } + public static ChatRoom groupOf() { + return ChatRoom.builder() + .roomType(ChatType.GROUP) + .build(); + } + public static void validateIsNotSameParticipant(User sender, User receiver) { if (sender.getId().equals(receiver.getId())) { throw CustomException.of(CANNOT_CREATE_CHAT_ROOM_WITH_SELF); @@ -66,11 +83,15 @@ public static void validateIsNotSameParticipant(User sender, User receiver) { } public boolean isDirectRoom() { - return club == null; + return roomType == ChatType.DIRECT; } public boolean isGroupRoom() { - return club != null; + return roomType == ChatType.GROUP; + } + + public boolean isClubGroupRoom() { + return roomType == ChatType.GROUP && club != null; } public void updateLastMessage(String lastMessageContent, LocalDateTime lastMessageSentAt) { diff --git a/src/main/java/gg/agit/konect/domain/chat/model/ChatRoomMember.java b/src/main/java/gg/agit/konect/domain/chat/model/ChatRoomMember.java index 3ef6a9f08..6ca10d935 100644 --- a/src/main/java/gg/agit/konect/domain/chat/model/ChatRoomMember.java +++ b/src/main/java/gg/agit/konect/domain/chat/model/ChatRoomMember.java @@ -40,6 +40,12 @@ public class ChatRoomMember extends BaseEntity { @Column(name = "last_read_at", nullable = false) private LocalDateTime lastReadAt; + @Column(name = "visible_message_from") + private LocalDateTime visibleMessageFrom; + + @Column(name = "left_at") + private LocalDateTime leftAt; + @Column(name = "custom_room_name", length = 30) private String customRoomName; @@ -49,12 +55,16 @@ private ChatRoomMember( ChatRoom chatRoom, User user, LocalDateTime lastReadAt, + LocalDateTime visibleMessageFrom, + LocalDateTime leftAt, String customRoomName ) { this.id = id; this.chatRoom = chatRoom; this.user = user; this.lastReadAt = lastReadAt; + this.visibleMessageFrom = visibleMessageFrom; + this.leftAt = leftAt; this.customRoomName = customRoomName; } @@ -88,4 +98,36 @@ public void updateLastReadAt(LocalDateTime lastReadAt) { public void updateCustomRoomName(String customRoomName) { this.customRoomName = customRoomName; } + + public boolean hasLeft() { + return leftAt != null; + } + + public void leaveDirectRoom(LocalDateTime leftAt) { + this.leftAt = leftAt; + this.visibleMessageFrom = leftAt; + updateLastReadAt(leftAt); + } + + public void restoreDirectRoom() { + this.leftAt = null; + } + + public void reopenDirectRoom(LocalDateTime visibleMessageFrom) { + this.leftAt = null; + this.visibleMessageFrom = visibleMessageFrom; + updateLastReadAt(visibleMessageFrom); + } + + public boolean hasVisibleMessages(ChatRoom room) { + if (room.getLastMessageSentAt() == null) { + return false; + } + + if (visibleMessageFrom == null) { + return true; + } + + return room.getLastMessageSentAt().isAfter(visibleMessageFrom); + } } diff --git a/src/main/resources/db/migration/V64__add_chat_room_type_and_leave_columns.sql b/src/main/resources/db/migration/V64__add_chat_room_type_and_leave_columns.sql new file mode 100644 index 000000000..8f84b1911 --- /dev/null +++ b/src/main/resources/db/migration/V64__add_chat_room_type_and_leave_columns.sql @@ -0,0 +1,21 @@ +ALTER TABLE chat_room + ADD COLUMN room_type VARCHAR(20) NULL AFTER last_message_sent_at; + +UPDATE chat_room +SET room_type = CASE + WHEN club_id IS NULL THEN 'DIRECT' + ELSE 'GROUP' +END; + +ALTER TABLE chat_room + MODIFY COLUMN room_type VARCHAR(20) NOT NULL; + +ALTER TABLE chat_room_member + ADD COLUMN visible_message_from TIMESTAMP NULL AFTER last_read_at, + ADD COLUMN left_at TIMESTAMP NULL AFTER visible_message_from; + +CREATE INDEX idx_chat_room_member_user_left_at + ON chat_room_member (user_id, left_at); + +CREATE INDEX idx_chat_room_room_type + ON chat_room (room_type); From b0d3ba3b97773d8b4af68db48b5ad4e25f407015 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, 31 Mar 2026 00:16:35 +0900 Subject: [PATCH 2/9] =?UTF-8?q?feat:=201:1=20=EC=B1=84=ED=8C=85=EB=B0=A9?= =?UTF-8?q?=20=ED=83=88=ED=87=B4=20=EB=B0=8F=20=EB=A9=94=EC=8B=9C=EC=A7=80?= =?UTF-8?q?=20=EA=B0=80=EC=8B=9C=EC=84=B1=20=EB=A1=9C=EC=A7=81=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/chat/controller/ChatApi.java | 23 +++ .../chat/controller/ChatController.java | 9 + .../repository/ChatMessageRepository.java | 24 ++- .../chat/repository/ChatRoomRepository.java | 33 ++-- .../domain/chat/service/ChatService.java | 183 ++++++++++++++---- .../domain/user/service/UserService.java | 7 +- .../konect/global/code/ApiResponseCode.java | 1 + 7 files changed, 218 insertions(+), 62 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 04c3a6cec..e87b862e8 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 @@ -1,6 +1,7 @@ package gg.agit.konect.domain.chat.controller; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -145,4 +146,26 @@ ResponseEntity updateChatRoomName( @Valid @RequestBody ChatRoomNameUpdateRequest request, @UserId Integer userId ); + + @Operation(summary = "채팅방에서 나간다.", description = """ + ## 설명 + - 동아리 채팅방은 나갈 수 없습니다. + - 1:1 채팅방은 소프트 딜리트 방식으로 나갑니다. + - 향후 일반 그룹 채팅방은 멤버십 제거 방식으로 나갈 수 있도록 설계합니다. + + ## 로직 + - 1:1 채팅방에서 나간 사용자는 기존 메시지를 숨기고 채팅방 목록에서도 제거됩니다. + - 상대방이 이후 새 메시지를 보내면 나간 사용자는 새 대화처럼 그 메시지부터 다시 보게 됩니다. + - 사용자가 다시 1:1 채팅을 열면 이전 대화가 아니라 새로 시작한 것처럼 보입니다. + + ## 에러 + - CANNOT_LEAVE_GROUP_CHAT_ROOM (400): 동아리 채팅방은 나갈 수 없습니다. + - FORBIDDEN_CHAT_ROOM_ACCESS (403): 채팅방에 접근할 권한이 없습니다. + - NOT_FOUND_CHAT_ROOM (404): 채팅방을 찾을 수 없습니다. + """) + @DeleteMapping("/rooms/{chatRoomId}") + ResponseEntity leaveChatRoom( + @PathVariable(value = "chatRoomId") 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 3a42bb012..dc4a79b0f 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 @@ -92,4 +92,13 @@ public ResponseEntity updateChatRoomName( chatService.updateChatRoomName(userId, chatRoomId, request); return ResponseEntity.ok().build(); } + + @Override + public ResponseEntity leaveChatRoom( + @PathVariable(value = "chatRoomId") Integer chatRoomId, + @UserId Integer userId + ) { + chatService.leaveChatRoom(userId, chatRoomId); + return ResponseEntity.noContent().build(); + } } diff --git a/src/main/java/gg/agit/konect/domain/chat/repository/ChatMessageRepository.java b/src/main/java/gg/agit/konect/domain/chat/repository/ChatMessageRepository.java index dc7876acd..c8c6493c1 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 @@ -39,9 +39,25 @@ List countUnreadMessagesByChatRoomIdsAndUserId( FROM ChatMessage cm JOIN FETCH cm.sender WHERE cm.chatRoom.id = :chatRoomId + AND (:visibleMessageFrom IS NULL OR cm.createdAt > :visibleMessageFrom) ORDER BY cm.createdAt DESC """) - Page findByChatRoomId(@Param("chatRoomId") Integer chatRoomId, Pageable pageable); + Page findByChatRoomId( + @Param("chatRoomId") Integer chatRoomId, + @Param("visibleMessageFrom") java.time.LocalDateTime visibleMessageFrom, + Pageable pageable + ); + + @Query(""" + SELECT COUNT(m) + FROM ChatMessage m + WHERE m.chatRoom.id = :chatRoomId + AND (:visibleMessageFrom IS NULL OR m.createdAt > :visibleMessageFrom) + """) + long countByChatRoomId( + @Param("chatRoomId") Integer chatRoomId, + @Param("visibleMessageFrom") java.time.LocalDateTime visibleMessageFrom + ); @Query(""" SELECT new gg.agit.konect.domain.chat.dto.UnreadMessageCount( @@ -102,10 +118,4 @@ SELECT MAX(m2.id) """) List findLatestMessagesByRoomIds(@Param("roomIds") List roomIds); - @Query(""" - SELECT COUNT(m) - FROM ChatMessage m - WHERE m.chatRoom.id = :chatRoomId - """) - long countByChatRoomId(@Param("chatRoomId") Integer chatRoomId); } 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 6cc9f7f02..bc96ac82a 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 @@ -9,6 +9,7 @@ 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; public interface ChatRoomRepository extends Repository { @@ -21,10 +22,10 @@ public interface ChatRoomRepository extends Repository { JOIN ChatRoomMember crm ON crm.id.chatRoomId = cr.id LEFT JOIN FETCH cr.club WHERE crm.id.userId = :userId - AND cr.club IS NULL + AND cr.roomType = :roomType ORDER BY cr.lastMessageSentAt DESC NULLS LAST, cr.id """) - List findByUserId(@Param("userId") Integer userId); + List findByUserId(@Param("userId") Integer userId, @Param("roomType") ChatType roomType); @Query(""" SELECT cr @@ -39,13 +40,17 @@ public interface ChatRoomRepository extends Repository { FROM ChatRoom cr JOIN ChatRoomMember crm ON crm.id.chatRoomId = cr.id - WHERE cr.club IS NULL + WHERE cr.roomType = :roomType GROUP BY cr HAVING COUNT(crm) = 2 AND SUM(CASE WHEN crm.id.userId = :userId1 THEN 1 ELSE 0 END) = 1 AND SUM(CASE WHEN crm.id.userId = :userId2 THEN 1 ELSE 0 END) = 1 """) - Optional findByTwoUsers(@Param("userId1") Integer userId1, @Param("userId2") Integer userId2); + Optional findByTwoUsers( + @Param("userId1") Integer userId1, + @Param("userId2") Integer userId2, + @Param("roomType") ChatType roomType + ); @Query(""" SELECT cr @@ -69,15 +74,16 @@ AND SUM(CASE WHEN crm.id.userId = :userId2 THEN 1 ELSE 0 END) = 1 JOIN ChatRoomMember crm ON crm.id.chatRoomId = cr.id LEFT JOIN FETCH cr.club WHERE crm.id.userId = :userId + AND cr.roomType = :roomType AND cr.club IS NOT NULL ORDER BY cr.lastMessageSentAt DESC NULLS LAST, cr.id """) - List findGroupRoomsByUserId(@Param("userId") Integer userId); + List findGroupRoomsByUserId(@Param("userId") Integer userId, @Param("roomType") ChatType roomType); @Query(""" SELECT DISTINCT cr FROM ChatRoom cr - WHERE cr.club IS NULL + WHERE cr.roomType = :roomType AND EXISTS ( SELECT 1 FROM ChatRoomMember adminMember JOIN adminMember.user adminUser @@ -92,12 +98,15 @@ AND EXISTS ( ) ORDER BY cr.lastMessageSentAt DESC NULLS LAST, cr.id """) - List findAllAdminUserDirectRooms(@Param("adminRole") UserRole adminRole); + List findAllAdminUserDirectRooms( + @Param("adminRole") UserRole adminRole, + @Param("roomType") ChatType roomType + ); @Query(""" SELECT DISTINCT cr FROM ChatRoom cr - WHERE cr.club IS NULL + WHERE cr.roomType = :roomType AND EXISTS ( SELECT 1 FROM ChatRoomMember systemAdminMember WHERE systemAdminMember.id.chatRoomId = cr.id @@ -113,7 +122,8 @@ AND EXISTS ( """) List findAllSystemAdminDirectRooms( @Param("systemAdminId") Integer systemAdminId, - @Param("adminRole") UserRole adminRole + @Param("adminRole") UserRole adminRole, + @Param("roomType") ChatType roomType ); /** @@ -145,7 +155,7 @@ List findAllSystemAdminDirectRooms( LEFT JOIN ChatMessage cm ON cm.chatRoom.id = cr.id AND cm.sender.id <> :systemAdminId AND cm.createdAt > adminCrm.lastReadAt - WHERE cr.club IS NULL + WHERE cr.roomType = :roomType AND u.role != :adminRole AND EXISTS ( SELECT 1 FROM ChatMessage userReply @@ -158,6 +168,7 @@ AND EXISTS ( """) List findAdminChatRoomsOptimized( @Param("systemAdminId") Integer systemAdminId, - @Param("adminRole") UserRole adminRole + @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 17e46ffba..8d5cdbd10 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 @@ -1,6 +1,7 @@ package gg.agit.konect.domain.chat.service; import static gg.agit.konect.global.code.ApiResponseCode.CANNOT_CREATE_CHAT_ROOM_WITH_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_ACCESS; import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_CHAT_ROOM; import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_USER; @@ -86,18 +87,22 @@ public ChatRoomResponse createOrGetChatRoom(Integer currentUserId, ChatRoomCreat return getOrCreateSystemAdminChatRoomForUser(targetUser, currentUser); } - ChatRoom chatRoom = chatRoomRepository.findByTwoUsers(currentUser.getId(), targetUser.getId()) + 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"); - ensureRoomMember(chatRoom, currentUser, joinedAt); + 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()) + 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); @@ -112,7 +117,7 @@ private ChatRoomResponse getOrCreateSystemAdminChatRoomForUser(User targetUser, LocalDateTime joinedAt = Objects.requireNonNull( chatRoom.getCreatedAt(), "chatRoom.createdAt must not be null" ); - ensureRoomMember(chatRoom, adminUser, joinedAt); + ensureDirectRoomRequester(chatRoom, adminUser, joinedAt); return ChatRoomResponse.from(chatRoom); } @@ -125,6 +130,24 @@ public ChatRoomResponse createOrGetAdminChatRoom(Integer currentUserId) { return createOrGetChatRoom(currentUserId, new ChatRoomCreateRequest(adminUser.getId())); } + @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); + } + @Transactional public ChatRoomsSummaryResponse getChatRooms(Integer userId) { List directRooms = getDirectChatRooms(userId); @@ -202,7 +225,7 @@ public ChatMuteResponse toggleMute(Integer userId, Integer roomId) { ClubMember member = clubMemberRepository.getByClubIdAndUserId(room.getClub().getId(), userId); ensureRoomMember(room, member.getUser(), member.getCreatedAt()); } else { - getOrCreateDirectRoomMember(room, user); + getAccessibleDirectRoomMember(room, user); } Boolean isMuted = notificationMuteSettingRepository.findByTargetTypeAndTargetIdAndUserId( NotificationTargetType.CHAT_ROOM, @@ -244,24 +267,18 @@ private List getDirectChatRooms(Integer userId) { } List roomSummaries = new ArrayList<>(); - List personalChatRooms = chatRoomRepository.findByUserId(userId); - Map> roomMemberInfoMap = getRoomMemberInfoMap(personalChatRooms); + List personalChatRooms = chatRoomRepository.findByUserId(userId, ChatType.DIRECT); + Map> roomMembersMap = getRoomMembersMap(personalChatRooms); Map personalUnreadCountMap = getUnreadCountMap(extractChatRoomIds(personalChatRooms), userId); - List allUserIds = roomMemberInfoMap.values().stream() - .flatMap(List::stream) - .map(MemberInfo::userId) - .distinct() - .toList(); - - Map userMap = allUserIds.isEmpty() - ? Map.of() - : userRepository.findAllByIdIn(allUserIds).stream() - .collect(Collectors.toMap(User::getId, u -> u)); - for (ChatRoom chatRoom : personalChatRooms) { - List memberInfos = roomMemberInfoMap.getOrDefault(chatRoom.getId(), List.of()); - User chatPartner = resolveDirectChatPartner(memberInfos, user.getId(), userMap); + List members = roomMembersMap.getOrDefault(chatRoom.getId(), List.of()); + ChatRoomMember currentMember = findRoomMember(members, userId); + if (currentMember == null || !isDirectRoomVisibleToUser(chatRoom, currentMember)) { + continue; + } + + User chatPartner = resolveDirectChatPartner(members, user.getId()); if (chatPartner == null) { continue; } @@ -271,8 +288,8 @@ private List getDirectChatRooms(Integer userId) { ChatType.DIRECT, chatPartner.getName(), chatPartner.getImageUrl(), - chatRoom.getLastMessageContent(), - chatRoom.getLastMessageSentAt(), + getVisibleLastMessageContent(chatRoom, currentMember), + getVisibleLastMessageSentAt(chatRoom, currentMember), personalUnreadCountMap.getOrDefault(chatRoom.getId(), 0), false )); @@ -290,7 +307,7 @@ private List getDirectChatRooms(Integer userId) { private List getAdminDirectChatRooms() { List projections = chatRoomRepository.findAdminChatRoomsOptimized( - SYSTEM_ADMIN_ID, UserRole.ADMIN + SYSTEM_ADMIN_ID, UserRole.ADMIN, ChatType.DIRECT ); return projections.stream() @@ -316,6 +333,7 @@ private ChatMessagePageResponse getDirectChatRoomMessages( ChatRoom chatRoom = getDirectRoom(roomId); User user = userRepository.getById(userId); ChatRoomMember member = getOrCreateDirectRoomMember(chatRoom, user); + LocalDateTime visibleMessageFrom = prepareDirectRoomAccess(member, chatRoom); LocalDateTime readAt = LocalDateTime.now(); chatPresenceService.recordPresence(roomId, userId); @@ -323,7 +341,7 @@ private ChatMessagePageResponse getDirectChatRoomMessages( boolean isAdminViewingSystemRoom = user.getRole() == UserRole.ADMIN && isSystemAdminRoom(chatRoom); PageRequest pageable = PageRequest.of(page - 1, limit); - Page messages = chatMessageRepository.findByChatRoomId(roomId, pageable); + Page messages = chatMessageRepository.findByChatRoomId(roomId, visibleMessageFrom, pageable); List members = chatRoomMemberRepository.findByChatRoomId(roomId); if (isAdminViewingSystemRoom) { @@ -373,31 +391,26 @@ private ChatMessageDetailResponse sendDirectMessage( ) { ChatRoom chatRoom = getDirectRoom(roomId); User sender = userRepository.getById(userId); - getOrCreateDirectRoomMember(chatRoom, sender); - - List memberResults = chatRoomMemberRepository.findRoomMemberIdsByChatRoomIds(List.of(roomId)); - List memberInfos = memberResults.stream() - .map(row -> new MemberInfo((Integer)row[1], (LocalDateTime)row[2])) - .toList(); - - List memberUserIds = memberInfos.stream().map(MemberInfo::userId).toList(); - Map userMap = userRepository.findAllByIdIn(memberUserIds).stream() - .collect(Collectors.toMap(User::getId, u -> u)); - - User receiver = resolveMessageReceiverFromMemberInfo(sender, memberInfos, userMap); + ChatRoomMember senderMember = getAccessibleDirectRoomMember(chatRoom, sender); + boolean senderHadLeft = senderMember.hasLeft(); + List members = chatRoomMemberRepository.findByChatRoomId(roomId); + User receiver = resolveMessageReceiver(sender, members); ChatMessage chatMessage = chatMessageRepository.save( ChatMessage.of(chatRoom, sender, request.content()) ); + if (senderHadLeft) { + senderMember.restoreDirectRoom(); + } chatRoom.updateLastMessage(chatMessage.getContent(), chatMessage.getCreatedAt()); updateMemberLastReadAt(roomId, userId, chatMessage.getCreatedAt()); - List members = chatRoomMemberRepository.findByChatRoomId(roomId); List sortedReadBaselines = toSortedReadBaselines(members); notificationService.sendChatNotification(receiver.getId(), roomId, sender.getName(), request.content()); - boolean isSystemAdminRoom = memberInfos.stream() - .anyMatch(info -> info.userId().equals(SYSTEM_ADMIN_ID)); + boolean isSystemAdminRoom = members.stream() + .map(ChatRoomMember::getUserId) + .anyMatch(memberUserId -> memberUserId.equals(SYSTEM_ADMIN_ID)); publishAdminChatEventIfNeeded(isSystemAdminRoom, sender, request.content()); return new ChatMessageDetailResponse( @@ -459,8 +472,8 @@ private ChatMessagePageResponse getClubMessagesByRoomId( updateLastReadAt(roomId, userId, LocalDateTime.now()); PageRequest pageable = PageRequest.of(page - 1, limit); - long totalCount = chatMessageRepository.countByChatRoomId(roomId); - Page messagePage = chatMessageRepository.findByChatRoomId(roomId, pageable); + 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); @@ -575,7 +588,7 @@ private ChatRoom getDirectRoom(Integer roomId) { private ChatRoom getClubRoom(Integer roomId) { ChatRoom room = chatRoomRepository.findById(roomId) .orElseThrow(() -> CustomException.of(ApiResponseCode.NOT_FOUND_CHAT_ROOM)); - if (!room.isGroupRoom() || room.getClub() == null) { + if (!room.isClubGroupRoom()) { throw CustomException.of(ApiResponseCode.NOT_FOUND_GROUP_CHAT_ROOM); } return room; @@ -719,7 +732,7 @@ private ChatRoomMember getAccessibleRoomMember(ChatRoom room, Integer userId) { } User user = userRepository.getById(userId); - return getOrCreateDirectRoomMember(room, user); + return getAccessibleDirectRoomMember(room, user); } private void ensureRoomMember(ChatRoom room, User user, LocalDateTime joinedAt) { @@ -732,6 +745,21 @@ 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) { + 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 String normalizeCustomRoomName(String roomName) { if (!StringUtils.hasText(roomName)) { return null; @@ -862,6 +890,34 @@ private ChatRoomMember getOrCreateDirectRoomMember(ChatRoom chatRoom, User user) }); } + private ChatRoomMember getAccessibleDirectRoomMember(ChatRoom chatRoom, User user) { + ChatRoomMember member = getOrCreateDirectRoomMember(chatRoom, user); + if (!member.hasLeft()) { + return member; + } + + if (!member.hasVisibleMessages(chatRoom)) { + throw CustomException.of(FORBIDDEN_CHAT_ROOM_ACCESS); + } + + member.restoreDirectRoom(); + return member; + } + + private LocalDateTime prepareDirectRoomAccess(ChatRoomMember member, ChatRoom chatRoom) { + LocalDateTime visibleMessageFrom = member.getVisibleMessageFrom(); + if (!member.hasLeft()) { + return visibleMessageFrom; + } + + if (member.hasVisibleMessages(chatRoom)) { + member.restoreDirectRoom(); + return visibleMessageFrom; + } + + throw CustomException.of(FORBIDDEN_CHAT_ROOM_ACCESS); + } + private boolean isSystemAdminRoom(ChatRoom chatRoom) { List memberIds = chatRoomMemberRepository.findRoomMemberIdsByChatRoomIds( List.of(chatRoom.getId()) @@ -891,6 +947,31 @@ private Integer resolveDirectSenderId(ChatMessage message, Integer maskedAdminId return message.getSender().getId(); } + private ChatRoomMember findRoomMember(List members, Integer userId) { + return members.stream() + .filter(member -> member.getUserId().equals(userId)) + .findFirst() + .orElse(null); + } + + private boolean isDirectRoomVisibleToUser(ChatRoom room, ChatRoomMember member) { + return !member.hasLeft() || member.hasVisibleMessages(room); + } + + private String getVisibleLastMessageContent(ChatRoom room, ChatRoomMember member) { + if (!member.hasVisibleMessages(room)) { + return null; + } + return room.getLastMessageContent(); + } + + private LocalDateTime getVisibleLastMessageSentAt(ChatRoom room, ChatRoomMember member) { + if (!member.hasVisibleMessages(room)) { + return null; + } + return room.getLastMessageSentAt(); + } + private Map> getRoomMembersMap(List rooms) { if (rooms.isEmpty()) { return Map.of(); @@ -931,6 +1012,22 @@ private User findDirectPartner(List members, Integer userId) { .orElse(null); } + private User resolveDirectChatPartner(List members, Integer userId) { + boolean hasSystemAdmin = members.stream() + .map(ChatRoomMember::getUserId) + .anyMatch(memberUserId -> memberUserId.equals(SYSTEM_ADMIN_ID)); + + if (hasSystemAdmin) { + return members.stream() + .map(ChatRoomMember::getUser) + .filter(memberUser -> memberUser.getId().equals(SYSTEM_ADMIN_ID)) + .findFirst() + .orElse(null); + } + + return findDirectPartner(members, userId); + } + private User findDirectPartnerFromMemberInfo( List memberInfos, Integer userId, 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 baa48c07d..aa3edc2e2 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 @@ -13,6 +13,7 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; +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.repository.ChatMessageRepository; @@ -116,7 +117,11 @@ private void sendWelcomeMessage(User newUser) { return; } ChatRoom.validateIsNotSameParticipant(operator, newUser); - ChatRoom chatRoom = chatRoomRepository.findByTwoUsers(operator.getId(), newUser.getId()) + ChatRoom chatRoom = chatRoomRepository.findByTwoUsers( + operator.getId(), + newUser.getId(), + ChatType.DIRECT + ) .orElseGet(() -> chatRoomRepository.save(ChatRoom.directOf())); LocalDateTime joinedAt = Objects.requireNonNull( chatRoom.getCreatedAt(), 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 1d5c7d2b3..a39b6598c 100644 --- a/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java +++ b/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java @@ -19,6 +19,7 @@ public enum ApiResponseCode { FAILED_EXTRACT_EMAIL(HttpStatus.BAD_REQUEST, "OAuth 로그인 과정에서 이메일 정보를 가져올 수 없습니다."), FAILED_EXTRACT_PROVIDER_ID(HttpStatus.BAD_REQUEST, "OAuth 로그인 과정에서 제공자 식별자를 가져올 수 없습니다."), CANNOT_CREATE_CHAT_ROOM_WITH_SELF(HttpStatus.BAD_REQUEST, "자기 자신과는 채팅방을 만들 수 없습니다."), + CANNOT_LEAVE_GROUP_CHAT_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, "동아리 회장인 경우 회장을 양도하고 탈퇴해야 합니다."), From e8db78dae8f706463f8c2188fcdcf8aa37814294 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, 31 Mar 2026 00:16:44 +0900 Subject: [PATCH 3/9] =?UTF-8?q?test:=201:1=20=EB=B0=8F=20=EA=B7=B8?= =?UTF-8?q?=EB=A3=B9=20=EC=B1=84=ED=8C=85=EB=B0=A9=20=ED=83=88=ED=87=B4=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BC=80=EC=9D=B4=EC=8A=A4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../integration/domain/chat/ChatApiTest.java | 165 +++++++++++++++++- 1 file changed, 161 insertions(+), 4 deletions(-) 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 aa3a8cf33..ddbc120ab 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 @@ -18,6 +18,7 @@ import gg.agit.konect.domain.chat.dto.ChatMessageSendRequest; import gg.agit.konect.domain.chat.dto.ChatRoomCreateRequest; import gg.agit.konect.domain.chat.dto.ChatRoomNameUpdateRequest; +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; @@ -25,12 +26,14 @@ import gg.agit.konect.domain.chat.repository.ChatRoomMemberRepository; import gg.agit.konect.domain.chat.repository.ChatRoomRepository; import gg.agit.konect.domain.chat.service.ChatPresenceService; +import gg.agit.konect.domain.club.model.Club; import gg.agit.konect.domain.notification.enums.NotificationTargetType; import gg.agit.konect.domain.notification.repository.NotificationMuteSettingRepository; import gg.agit.konect.domain.notification.service.NotificationService; import gg.agit.konect.domain.university.model.University; import gg.agit.konect.domain.user.model.User; import gg.agit.konect.support.IntegrationTestSupport; +import gg.agit.konect.support.fixture.ClubFixture; import gg.agit.konect.support.fixture.UniversityFixture; import gg.agit.konect.support.fixture.UserFixture; @@ -90,7 +93,8 @@ void createDirectChatRoomSuccess() throws Exception { .andExpect(jsonPath("$.chatRoomId").isNumber()); clearPersistenceContext(); - assertThat(chatRoomRepository.findByTwoUsers(normalUser.getId(), targetUser.getId())).isPresent(); + assertThat(chatRoomRepository.findByTwoUsers(normalUser.getId(), targetUser.getId(), ChatType.DIRECT)) + .isPresent(); assertThat(countDirectRoomsBetween(normalUser, targetUser)).isEqualTo(beforeCount + 1); } @@ -132,7 +136,7 @@ void createDirectChatRoomReturnsExistingRoom() throws Exception { .andExpect(jsonPath("$.chatRoomId").value(existingRoom.getId())); clearPersistenceContext(); - assertThat(chatRoomRepository.findByTwoUsers(normalUser.getId(), targetUser.getId())) + assertThat(chatRoomRepository.findByTwoUsers(normalUser.getId(), targetUser.getId(), ChatType.DIRECT)) .isPresent() .get() .extracting(ChatRoom::getId) @@ -202,7 +206,9 @@ void sendMessageSuccess() throws Exception { .andExpect(jsonPath("$.isMine").value(true)); clearPersistenceContext(); - assertThat(chatMessageRepository.findByChatRoomId(chatRoom.getId(), PageRequest.of(0, 20)).getContent()) + assertThat( + chatMessageRepository.findByChatRoomId(chatRoom.getId(), null, PageRequest.of(0, 20)).getContent() + ) .hasSize(1) .extracting(ChatMessage::getContent) .containsExactly("안녕하세요"); @@ -320,6 +326,157 @@ void updateChatRoomNameForbidden() throws Exception { } } + @Nested + @DisplayName("DELETE /chats/rooms/{chatRoomId} - 채팅방 나가기") + class LeaveChatRoom { + + @BeforeEach + void setUpLeaveFixture() { + targetUser = createUser("상대유저", "2021136002"); + clearPersistenceContext(); + } + + @Test + @DisplayName("1:1 채팅방을 나가면 목록에서 숨겨지고 새 메시지부터 다시 보인다") + void leaveDirectChatRoomAndShowOnlyNewMessages() throws Exception { + ChatRoom chatRoom = createDirectChatRoom(normalUser, targetUser); + + mockLoginUser(normalUser.getId()); + performPost("/chats/rooms/" + chatRoom.getId() + "/messages", new ChatMessageSendRequest("첫 메시지")) + .andExpect(status().isOk()); + + performDelete("/chats/rooms/" + chatRoom.getId()) + .andExpect(status().isNoContent()); + + clearPersistenceContext(); + ChatRoomMember leftMember = chatRoomMemberRepository + .findByChatRoomIdAndUserId(chatRoom.getId(), normalUser.getId()) + .orElseThrow(); + assertThat(leftMember.hasLeft()).isTrue(); + + mockLoginUser(normalUser.getId()); + performGet("/chats/rooms") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.rooms").isEmpty()); + + performGet("/chats/rooms/" + chatRoom.getId() + "?page=1&limit=20") + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("FORBIDDEN_CHAT_ROOM_ACCESS")); + + mockLoginUser(targetUser.getId()); + performPost("/chats/rooms/" + chatRoom.getId() + "/messages", new ChatMessageSendRequest("다시 안녕")) + .andExpect(status().isOk()); + + mockLoginUser(normalUser.getId()); + performGet("/chats/rooms") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.rooms[0].roomId").value(chatRoom.getId())) + .andExpect(jsonPath("$.rooms[0].lastMessage").value("다시 안녕")) + .andExpect(jsonPath("$.rooms[0].unreadCount").value(1)); + + performGet("/chats/rooms/" + chatRoom.getId() + "?page=1&limit=20") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalCount").value(1)) + .andExpect(jsonPath("$.messages[0].content").value("다시 안녕")); + + mockLoginUser(targetUser.getId()); + performGet("/chats/rooms/" + chatRoom.getId() + "?page=1&limit=20") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalCount").value(2)) + .andExpect(jsonPath("$.messages[0].content").value("다시 안녕")) + .andExpect(jsonPath("$.messages[1].content").value("첫 메시지")); + } + + @Test + @DisplayName("나간 뒤 다시 채팅방을 열면 처음 대화하는 것처럼 빈 메시지 목록을 본다") + void createOrGetChatRoomAfterLeaveStartsFresh() throws Exception { + ChatRoom chatRoom = createDirectChatRoom(normalUser, targetUser); + + mockLoginUser(normalUser.getId()); + performPost("/chats/rooms/" + chatRoom.getId() + "/messages", new ChatMessageSendRequest("첫 메시지")) + .andExpect(status().isOk()); + + performDelete("/chats/rooms/" + chatRoom.getId()) + .andExpect(status().isNoContent()); + + performPost("/chats/rooms", new ChatRoomCreateRequest(targetUser.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.chatRoomId").value(chatRoom.getId())); + + performGet("/chats/rooms") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.rooms[0].roomId").value(chatRoom.getId())) + .andExpect(jsonPath("$.rooms[0].lastMessage").doesNotExist()); + + performGet("/chats/rooms/" + chatRoom.getId() + "?page=1&limit=20") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalCount").value(0)) + .andExpect(jsonPath("$.messages").isEmpty()); + } + + @Test + @DisplayName("나간 뒤 새 메시지가 오기 전에는 방 조작이 불가능하다") + void cannotOperateHiddenDirectRoomBeforeNewMessage() throws Exception { + ChatRoom chatRoom = createDirectChatRoom(normalUser, targetUser); + + mockLoginUser(normalUser.getId()); + performDelete("/chats/rooms/" + chatRoom.getId()) + .andExpect(status().isNoContent()); + + performPost("/chats/rooms/" + chatRoom.getId() + "/messages", new ChatMessageSendRequest("몰래 보내기")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("FORBIDDEN_CHAT_ROOM_ACCESS")); + + performPatch("/chats/rooms/" + chatRoom.getId() + "/name", new ChatRoomNameUpdateRequest("숨김방")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("FORBIDDEN_CHAT_ROOM_ACCESS")); + + performPost("/chats/rooms/" + chatRoom.getId() + "/mute") + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("FORBIDDEN_CHAT_ROOM_ACCESS")); + } + + @Test + @DisplayName("동아리 채팅방은 나갈 수 없다") + void leaveGroupChatRoomFails() throws Exception { + Club club = persist(ClubFixture.create(university)); + ChatRoom groupRoom = persist(ChatRoom.groupOf(club)); + ChatRoom managedGroupRoom = entityManager.getReference(ChatRoom.class, groupRoom.getId()); + User managedNormalUser = entityManager.getReference(User.class, normalUser.getId()); + persist(ChatRoomMember.of(managedGroupRoom, managedNormalUser, groupRoom.getCreatedAt())); + clearPersistenceContext(); + + mockLoginUser(normalUser.getId()); + + performDelete("/chats/rooms/" + groupRoom.getId()) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("CANNOT_LEAVE_GROUP_CHAT_ROOM")); + } + + @Test + @DisplayName("일반 그룹 채팅방은 멤버십 삭제 방식으로 나갈 수 있다") + void leaveOpenGroupChatRoomDeletesMembership() throws Exception { + ChatRoom openGroupRoom = persist(ChatRoom.groupOf()); + ChatRoom managedOpenGroupRoom = entityManager.getReference(ChatRoom.class, openGroupRoom.getId()); + User managedNormalUser = entityManager.getReference(User.class, normalUser.getId()); + User managedTargetUser = entityManager.getReference(User.class, targetUser.getId()); + persist(ChatRoomMember.of(managedOpenGroupRoom, managedNormalUser, openGroupRoom.getCreatedAt())); + persist(ChatRoomMember.of(managedOpenGroupRoom, managedTargetUser, openGroupRoom.getCreatedAt())); + clearPersistenceContext(); + + mockLoginUser(normalUser.getId()); + + performDelete("/chats/rooms/" + openGroupRoom.getId()) + .andExpect(status().isNoContent()); + + clearPersistenceContext(); + assertThat(chatRoomMemberRepository.findByChatRoomIdAndUserId(openGroupRoom.getId(), normalUser.getId())) + .isEmpty(); + assertThat(chatRoomMemberRepository.findByChatRoomIdAndUserId(openGroupRoom.getId(), targetUser.getId())) + .isPresent(); + } + } + @Nested @DisplayName("GET /chats/rooms/{chatRoomId} - 채팅방 메시지 조회 실패") class GetMessagesFail { @@ -410,7 +567,7 @@ private User createUser(String name, String studentId) { } private long countDirectRoomsBetween(User firstUser, User secondUser) { - return chatRoomRepository.findByUserId(firstUser.getId()).stream() + return chatRoomRepository.findByUserId(firstUser.getId(), ChatType.DIRECT).stream() .map(ChatRoom::getId) .filter(roomId -> isDirectRoomBetween(roomId, firstUser.getId(), secondUser.getId())) .count(); From 12f6be3ce4a9657bc5e1b0068c837780245929ca 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, 31 Mar 2026 00:17:02 +0900 Subject: [PATCH 4/9] =?UTF-8?q?chore:=20=EC=BD=94=EB=93=9C=20=ED=8F=AC?= =?UTF-8?q?=EB=A7=B7=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/gg/agit/konect/domain/chat/controller/ChatApi.java | 4 ++-- 1 file changed, 2 insertions(+), 2 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 e87b862e8..3f906215a 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 @@ -152,12 +152,12 @@ ResponseEntity updateChatRoomName( - 동아리 채팅방은 나갈 수 없습니다. - 1:1 채팅방은 소프트 딜리트 방식으로 나갑니다. - 향후 일반 그룹 채팅방은 멤버십 제거 방식으로 나갈 수 있도록 설계합니다. - + ## 로직 - 1:1 채팅방에서 나간 사용자는 기존 메시지를 숨기고 채팅방 목록에서도 제거됩니다. - 상대방이 이후 새 메시지를 보내면 나간 사용자는 새 대화처럼 그 메시지부터 다시 보게 됩니다. - 사용자가 다시 1:1 채팅을 열면 이전 대화가 아니라 새로 시작한 것처럼 보입니다. - + ## 에러 - CANNOT_LEAVE_GROUP_CHAT_ROOM (400): 동아리 채팅방은 나갈 수 없습니다. - FORBIDDEN_CHAT_ROOM_ACCESS (403): 채팅방에 접근할 권한이 없습니다. From 2664660f0ba232c195100dbe1e54d41dd8ff64d1 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, 31 Mar 2026 00:37:08 +0900 Subject: [PATCH 5/9] =?UTF-8?q?chore:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=9D=B8=EB=8D=B1=EC=8A=A4=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=EC=BF=BC=EB=A6=AC=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../db/migration/V64__add_chat_room_type_and_leave_columns.sql | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/main/resources/db/migration/V64__add_chat_room_type_and_leave_columns.sql b/src/main/resources/db/migration/V64__add_chat_room_type_and_leave_columns.sql index 8f84b1911..0c62d38eb 100644 --- a/src/main/resources/db/migration/V64__add_chat_room_type_and_leave_columns.sql +++ b/src/main/resources/db/migration/V64__add_chat_room_type_and_leave_columns.sql @@ -16,6 +16,3 @@ ALTER TABLE chat_room_member CREATE INDEX idx_chat_room_member_user_left_at ON chat_room_member (user_id, left_at); - -CREATE INDEX idx_chat_room_room_type - ON chat_room (room_type); From 8768acac53210331827ff048899efc2cd9272165 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, 31 Mar 2026 00:38:57 +0900 Subject: [PATCH 6/9] =?UTF-8?q?refactor:=20LocalDateTime=20import=EB=A1=9C?= =?UTF-8?q?=20=EC=BD=94=EB=93=9C=20=EA=B0=80=EB=8F=85=EC=84=B1=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../konect/domain/chat/repository/ChatMessageRepository.java | 5 +++-- 1 file changed, 3 insertions(+), 2 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 c8c6493c1..e796654b6 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 @@ -1,5 +1,6 @@ package gg.agit.konect.domain.chat.repository; +import java.time.LocalDateTime; import java.util.List; import org.springframework.data.domain.Page; @@ -44,7 +45,7 @@ List countUnreadMessagesByChatRoomIdsAndUserId( """) Page findByChatRoomId( @Param("chatRoomId") Integer chatRoomId, - @Param("visibleMessageFrom") java.time.LocalDateTime visibleMessageFrom, + @Param("visibleMessageFrom") LocalDateTime visibleMessageFrom, Pageable pageable ); @@ -56,7 +57,7 @@ SELECT COUNT(m) """) long countByChatRoomId( @Param("chatRoomId") Integer chatRoomId, - @Param("visibleMessageFrom") java.time.LocalDateTime visibleMessageFrom + @Param("visibleMessageFrom") LocalDateTime visibleMessageFrom ); @Query(""" From 64a119df83931fc22e2ab1a371c67cdce3d90308 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, 31 Mar 2026 10:02:39 +0900 Subject: [PATCH 7/9] =?UTF-8?q?chore:=20=EC=B1=84=ED=8C=85=EB=B0=A9=20?= =?UTF-8?q?=EB=B3=B5=EA=B5=AC=20=EB=A1=9C=EC=A7=81=EC=97=90=20=EC=A3=BC?= =?UTF-8?q?=EC=84=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../konect/domain/chat/model/ChatRoomMember.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/main/java/gg/agit/konect/domain/chat/model/ChatRoomMember.java b/src/main/java/gg/agit/konect/domain/chat/model/ChatRoomMember.java index 6ca10d935..7925fdabe 100644 --- a/src/main/java/gg/agit/konect/domain/chat/model/ChatRoomMember.java +++ b/src/main/java/gg/agit/konect/domain/chat/model/ChatRoomMember.java @@ -109,10 +109,22 @@ public void leaveDirectRoom(LocalDateTime leftAt) { updateLastReadAt(leftAt); } + /** + * 탈퇴 이후 새 메시지가 생겨 다시 볼 수 있을 때 사용한다. + *

+ * 나간 상태만 해제하고, 기존 {@code visibleMessageFrom}은 유지한다. + * 그래서 탈퇴 이후 도착한 메시지부터 계속 보인다. + */ public void restoreDirectRoom() { this.leftAt = null; } + /** + * 사용자가 채팅방을 다시 열어 새 대화를 시작할 때 사용한다. + *

+ * 나간 상태를 해제하고 {@code visibleMessageFrom}도 새로 갱신한다. + * 그래서 전달한 시점 이후 메시지부터 새 대화처럼 보인다. + */ public void reopenDirectRoom(LocalDateTime visibleMessageFrom) { this.leftAt = null; this.visibleMessageFrom = visibleMessageFrom; From 684f8f0e2b5c33436dee10f1cf6e8f2be1bce7d4 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, 31 Mar 2026 10:20:52 +0900 Subject: [PATCH 8/9] =?UTF-8?q?refactor:=20=EB=82=98=EA=B0=84=20=EC=B1=84?= =?UTF-8?q?=ED=8C=85=EB=B0=A9=20=EB=B3=B5=EA=B5=AC=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=20=EB=B0=8F=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/chat/service/ChatService.java | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) 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 8d5cdbd10..da2c27320 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 @@ -892,30 +892,30 @@ private ChatRoomMember getOrCreateDirectRoomMember(ChatRoom chatRoom, User user) private ChatRoomMember getAccessibleDirectRoomMember(ChatRoom chatRoom, User user) { ChatRoomMember member = getOrCreateDirectRoomMember(chatRoom, user); - if (!member.hasLeft()) { - return member; - } - - if (!member.hasVisibleMessages(chatRoom)) { - throw CustomException.of(FORBIDDEN_CHAT_ROOM_ACCESS); - } - - member.restoreDirectRoom(); + restoreDirectRoomIfVisible(member, chatRoom); return member; } private LocalDateTime prepareDirectRoomAccess(ChatRoomMember member, ChatRoom chatRoom) { LocalDateTime visibleMessageFrom = member.getVisibleMessageFrom(); + restoreDirectRoomIfVisible(member, chatRoom); + return visibleMessageFrom; + } + + /** + * direct 채팅방에서 나간 사용자가 다시 볼 수 있는 상태인지 확인하고, + * 새 메시지가 이미 존재하면 나간 상태를 해제한다. + */ + private void restoreDirectRoomIfVisible(ChatRoomMember member, ChatRoom chatRoom) { if (!member.hasLeft()) { - return visibleMessageFrom; + return; } - if (member.hasVisibleMessages(chatRoom)) { - member.restoreDirectRoom(); - return visibleMessageFrom; + if (!member.hasVisibleMessages(chatRoom)) { + throw CustomException.of(FORBIDDEN_CHAT_ROOM_ACCESS); } - throw CustomException.of(FORBIDDEN_CHAT_ROOM_ACCESS); + member.restoreDirectRoom(); } private boolean isSystemAdminRoom(ChatRoom chatRoom) { From ee8862351437d0f92004a0befa28c92fdbdc27b8 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, 31 Mar 2026 10:20:57 +0900 Subject: [PATCH 9/9] =?UTF-8?q?chore:=20=EC=B1=84=ED=8C=85=EB=B0=A9=20?= =?UTF-8?q?=EB=B3=B5=EA=B5=AC=20=EC=A3=BC=EC=84=9D=20=ED=91=9C=ED=98=84=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 --- .../java/gg/agit/konect/domain/chat/model/ChatRoomMember.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/gg/agit/konect/domain/chat/model/ChatRoomMember.java b/src/main/java/gg/agit/konect/domain/chat/model/ChatRoomMember.java index 7925fdabe..79dd6cde0 100644 --- a/src/main/java/gg/agit/konect/domain/chat/model/ChatRoomMember.java +++ b/src/main/java/gg/agit/konect/domain/chat/model/ChatRoomMember.java @@ -110,10 +110,10 @@ public void leaveDirectRoom(LocalDateTime leftAt) { } /** - * 탈퇴 이후 새 메시지가 생겨 다시 볼 수 있을 때 사용한다. + * 나간 이후 새 메시지가 생겨 다시 볼 수 있을 때 사용한다. *

* 나간 상태만 해제하고, 기존 {@code visibleMessageFrom}은 유지한다. - * 그래서 탈퇴 이후 도착한 메시지부터 계속 보인다. + * 그래서 나간 이후 도착한 메시지부터 계속 보인다. */ public void restoreDirectRoom() { this.leftAt = null;