-
Notifications
You must be signed in to change notification settings - Fork 1
[FIX] 방 관리 기능 개선 및 버그 수정 (+ 채팅 refactor) #93
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
The head ref may contain hidden characters: "fix/#91/\uBC29-\uAD00\uB9AC-\uAE30\uB2A5-\uAC1C\uC120-\uBC0F-\uBC84\uADF8-\uC218\uC815"
Changes from 10 commits
92b4722
9a72c6f
5e8d971
d99c0e9
20e4356
a6ff163
678453a
b7cf5a8
62aeb59
9a668dc
66a1704
579bcd0
1c0583e
9d8afeb
5ce9ead
db34317
b408a75
47affd1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| package com.project.dorumdorum.domain.chat.application.dto.response; | ||
|
|
||
| public record NotificationMessage( | ||
| String type, | ||
| String roomNo, | ||
| String chatRoomNo | ||
| ) { | ||
| public static NotificationMessage roomDeleted(String roomNo, String chatRoomNo) { | ||
| return new NotificationMessage("ROOM_DELETED", roomNo, chatRoomNo); | ||
| } | ||
|
|
||
| public static NotificationMessage kicked(String roomNo, String chatRoomNo) { | ||
| return new NotificationMessage("KICKED_FROM_ROOM", roomNo, chatRoomNo); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,76 @@ | ||
| package com.project.dorumdorum.domain.chat.application.event; | ||
|
|
||
| import com.project.dorumdorum.domain.chat.application.dto.response.NotificationMessage; | ||
| import com.project.dorumdorum.domain.chat.domain.entity.ChatRoom; | ||
| import com.project.dorumdorum.domain.chat.domain.entity.ChatRoomType; | ||
| import com.project.dorumdorum.domain.chat.domain.service.ChatMessageService; | ||
| import com.project.dorumdorum.domain.chat.domain.service.ChatRoomMemberService; | ||
| import com.project.dorumdorum.domain.chat.domain.service.ChatRoomService; | ||
| import com.project.dorumdorum.domain.room.application.event.RoomDeletedEvent; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.springframework.messaging.simp.SimpMessagingTemplate; | ||
| import org.springframework.stereotype.Component; | ||
| import org.springframework.transaction.event.TransactionPhase; | ||
| import org.springframework.transaction.event.TransactionalEventListener; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| @Slf4j | ||
| @Component | ||
| @RequiredArgsConstructor | ||
| public class RoomDeletedEventListener { | ||
|
|
||
| private final ChatRoomService chatRoomService; | ||
| private final ChatRoomMemberService chatRoomMemberService; | ||
| private final ChatMessageService chatMessageService; | ||
| private final SimpMessagingTemplate messagingTemplate; | ||
|
|
||
| /** | ||
| * 방 삭제(RoomDeletedEvent) → GROUP·DIRECT 채팅방 전체 삭제 처리 | ||
| * 발행: DeleteRoomUseCase (room 도메인 담당자) | ||
| * | ||
| * BEFORE_COMMIT: 부모 트랜잭션(DeleteRoomUseCase)에 참여하여 방 삭제 + 채팅방 삭제를 하나의 트랜잭션으로 처리. | ||
| * WebSocket 브로드캐스트는 트랜잭션 외부 작업이므로 실패가 TX에 영향을 주지 않도록 독립 처리. | ||
| */ | ||
| @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) | ||
| public void handle(RoomDeletedEvent event) { | ||
| List<ChatRoom> chatRooms = chatRoomService.findAllByRoomNo(event.roomNo()); | ||
| for (ChatRoom chatRoom : chatRooms) { | ||
| notifyAndClean(chatRoom, event.roomNo()); | ||
| } | ||
| } | ||
|
|
||
| private void notifyAndClean(ChatRoom chatRoom, String roomNo) { | ||
| NotificationMessage notification = NotificationMessage.roomDeleted(roomNo, chatRoom.getChatRoomNo()); | ||
|
|
||
| // 채팅방 열려 있는 유저에게 브로드캐스트 (/topic/) | ||
| broadcastDeletionSafely(chatRoom, notification); | ||
|
|
||
| // DIRECT 채팅방은 지원자가 다른 화면에 있어도 받을 수 있도록 개인 큐로 추가 전송 (/queue/) | ||
| if (ChatRoomType.DIRECT.equals(chatRoom.getChatRoomType()) | ||
| && chatRoom.getApplicantUserNo() != null) { | ||
| notifyUserSafely(chatRoom.getApplicantUserNo(), notification); | ||
| } | ||
|
|
||
| chatMessageService.deleteAllByChatRoom(chatRoom.getChatRoomNo()); | ||
| chatRoomMemberService.deleteAllByChatRoom(chatRoom); | ||
| chatRoomService.deleteByChatRoomNo(chatRoom.getChatRoomNo()); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 삭제 알림은 커밋 이후에 보내도록 분리해 주세요. Line 48과 Line 53에서 WebSocket 알림을 먼저 보낸 뒤 Line 56-58의 DB 삭제가 실패하면, 트랜잭션은 롤백되지만 이미 발송된 “방 삭제” 알림은 되돌릴 수 없습니다. 채팅 데이터 정리는 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| private void broadcastDeletionSafely(ChatRoom chatRoom, NotificationMessage notification) { | ||
| try { | ||
| messagingTemplate.convertAndSend("/topic/chat-room/" + chatRoom.getChatRoomNo(), notification); | ||
| } catch (Exception e) { | ||
| log.warn("[Chat] 방 삭제 WebSocket 브로드캐스트 실패. chatRoomNo={}", chatRoom.getChatRoomNo(), e); | ||
| } | ||
| } | ||
|
|
||
| private void notifyUserSafely(String userNo, NotificationMessage notification) { | ||
| try { | ||
| messagingTemplate.convertAndSendToUser(userNo, "/queue/notification", notification); | ||
| } catch (Exception e) { | ||
| log.warn("[Chat] 개인 알림 전송 실패. userNo={}", userNo, e); | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,7 @@ | ||
| package com.project.dorumdorum.domain.chat.application.event; | ||
|
|
||
| import com.project.dorumdorum.domain.chat.application.dto.response.ChatMessageResponse; | ||
| import com.project.dorumdorum.domain.chat.application.dto.response.NotificationMessage; | ||
| import com.project.dorumdorum.domain.chat.domain.entity.ChatMessage; | ||
| import com.project.dorumdorum.domain.chat.domain.entity.ChatRoom; | ||
| import com.project.dorumdorum.domain.chat.domain.entity.ChatRoomMember; | ||
|
|
@@ -41,25 +42,29 @@ public class RoommateKickedEventListener { | |
| */ | ||
| @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) | ||
| public void handle(RoommateKickedEvent event) { | ||
| chatRoomService.findByRoomNo(event.roomNo()).ifPresent(chatRoom -> { | ||
| if (chatRoomMemberService.isMember(chatRoom, event.kickedUserNo())) { | ||
| ChatRoomMember member = chatRoomMemberService.findByChatRoomAndUserNo(chatRoom, event.kickedUserNo()); | ||
| LocalDateTime fromTime = member.getLastReadAt() != null | ||
| ? member.getLastReadAt() | ||
| : member.getJoinedAt(); | ||
| chatMessageService.decreaseUnreadCount(chatRoom.getChatRoomNo(), fromTime, event.kickedUserNo()); | ||
| chatRoomMemberService.leave(member); | ||
| User kicked = userService.findById(event.kickedUserNo()); | ||
| String displayName = (kicked.getNickname() != null && !kicked.getNickname().isBlank()) | ||
| ? kicked.getNickname() : kicked.getName(); | ||
| String content = displayName + "가 퇴장했습니다."; | ||
| ChatMessage message = chatMessageService.save(chatRoom, "SYSTEM", content, MessageType.SYSTEM, 0); | ||
| ChatMessageResponse response = new ChatMessageResponse( | ||
| message.getMessageNo(), chatRoom.getChatRoomNo(), | ||
| "SYSTEM", null, content, MessageType.SYSTEM.name(), message.getCreatedAt(), message.getUnreadCount()); | ||
| broadcastSafely(chatRoom, response); | ||
| } | ||
| }); | ||
| chatRoomService.findByRoomNo(event.roomNo()).ifPresent(chatRoom -> | ||
| chatRoomMemberService.findOptionalByChatRoomAndUserNo(chatRoom, event.kickedUserNo()) | ||
| .ifPresent(member -> processKick(chatRoom, member, event)) | ||
| ); | ||
| } | ||
|
|
||
| private void processKick(ChatRoom chatRoom, ChatRoomMember member, RoommateKickedEvent event) { | ||
| LocalDateTime fromTime = member.getLastReadAt() != null | ||
| ? member.getLastReadAt() | ||
| : member.getJoinedAt(); | ||
| chatMessageService.decreaseUnreadCount(chatRoom.getChatRoomNo(), fromTime, event.kickedUserNo()); | ||
| chatRoomMemberService.leave(member); | ||
| User kicked = userService.findById(event.kickedUserNo()); | ||
| String displayName = (kicked.getNickname() != null && !kicked.getNickname().isBlank()) | ||
| ? kicked.getNickname() : kicked.getName(); | ||
| String content = displayName + "가 퇴장했습니다."; | ||
| ChatMessage message = chatMessageService.save(chatRoom, "SYSTEM", content, MessageType.SYSTEM, 0); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| ChatMessageResponse response = new ChatMessageResponse( | ||
| message.getMessageNo(), chatRoom.getChatRoomNo(), | ||
| "SYSTEM", null, content, MessageType.SYSTEM.name(), message.getCreatedAt(), message.getUnreadCount()); | ||
| broadcastSafely(chatRoom, response); | ||
| notifyUserSafely(event.kickedUserNo(), | ||
| NotificationMessage.kicked(event.roomNo(), chatRoom.getChatRoomNo())); | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. BEFORE_COMMIT 단계에서의 WebSocket 알림 순서에 대한 검토 요청드립니다. 현재
DB 정합성은 BEFORE_COMMIT에 유지하되, 외부로 나가는 브로드캐스트/개인 알림만 🤖 Prompt for AI Agents |
||
|
|
||
| private void broadcastSafely(ChatRoom chatRoom, ChatMessageResponse response) { | ||
|
|
@@ -69,4 +74,12 @@ private void broadcastSafely(ChatRoom chatRoom, ChatMessageResponse response) { | |
| log.warn("[Chat] WebSocket 브로드캐스트 실패. chatRoomNo={}", chatRoom.getChatRoomNo(), e); | ||
| } | ||
| } | ||
|
|
||
| private void notifyUserSafely(String userNo, NotificationMessage notification) { | ||
| try { | ||
| messagingTemplate.convertAndSendToUser(userNo, "/queue/notification", notification); | ||
| } catch (Exception e) { | ||
| log.warn("[Chat] 개인 알림 전송 실패. userNo={}", userNo, e); | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| package com.project.dorumdorum.domain.room.application.event; | ||
|
|
||
| /** | ||
| * 방 삭제 → 채팅방 삭제 트리거 | ||
| * 발행 책임: DeleteRoomUseCase (room 도메인 담당자) | ||
| */ | ||
| public record RoomDeletedEvent( | ||
| String roomNo | ||
| ) { | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,64 @@ | ||||||||
| package com.project.dorumdorum.domain.room.application.usecase; | ||||||||
|
|
||||||||
| import com.project.dorumdorum.domain.checklist.domain.service.RoomRuleService; | ||||||||
| import com.project.dorumdorum.domain.room.application.event.RoomDeletedEvent; | ||||||||
| import com.project.dorumdorum.domain.room.domain.entity.Room; | ||||||||
| import com.project.dorumdorum.domain.room.domain.repository.RoomLikeRepository; | ||||||||
| import com.project.dorumdorum.domain.room.domain.service.RoomRequestService; | ||||||||
| import com.project.dorumdorum.domain.room.domain.service.RoomService; | ||||||||
| import com.project.dorumdorum.domain.roommate.domain.service.RoommateService; | ||||||||
| import com.project.dorumdorum.global.exception.RestApiException; | ||||||||
| import lombok.RequiredArgsConstructor; | ||||||||
| import org.springframework.context.ApplicationEventPublisher; | ||||||||
| import org.springframework.stereotype.Service; | ||||||||
| import org.springframework.transaction.annotation.Transactional; | ||||||||
|
|
||||||||
| import static com.project.dorumdorum.global.exception.code.status.RoomErrorStatus.CANNOT_DELETE_COMPLETED_ROOM; | ||||||||
| import static com.project.dorumdorum.global.exception.code.status.RoomErrorStatus.NO_PERMISSION_ON_ROOM; | ||||||||
| import static com.project.dorumdorum.global.exception.code.status.RoomErrorStatus.ROOM_HAS_MEMBERS; | ||||||||
|
|
||||||||
| @Service | ||||||||
| @Transactional | ||||||||
| @RequiredArgsConstructor | ||||||||
| public class DeleteRoomUseCase { | ||||||||
|
|
||||||||
| private final RoomService roomService; | ||||||||
| private final RoommateService roommateService; | ||||||||
| private final RoomRequestService roomRequestService; | ||||||||
| private final RoomRuleService roomRuleService; | ||||||||
| private final RoomLikeRepository roomLikeRepository; | ||||||||
| private final ApplicationEventPublisher eventPublisher; | ||||||||
|
|
||||||||
| /** | ||||||||
| * 방 삭제 | ||||||||
| * - 방장 권한 검증 | ||||||||
| * - 방장 외 룸메이트 존재 시 삭제 불가 | ||||||||
| * - RoomRequest, RoomLike cascade 삭제 후 방 소프트 삭제 | ||||||||
|
||||||||
| * - RoomRequest, RoomLike cascade 삭제 후 방 소프트 삭제 | |
| * - 방 소프트 삭제 후 방장 퇴실 처리 및 flush 수행 | |
| * - RoomRequest, RoomRule, RoomLike 연관 데이터 삭제 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Description: Roommate 존재 여부를 실제 row 기준으로 확인할 수 있는 서비스/리포지토리 메서드가 있는지 점검합니다.
rg -n -C3 'count.*Room|exists.*Room|find.*Roommate|currentMateCount|getCurrentMateCount|RoommateService|RoommateRepository' src/main/java src/test/javaRepository: DorumDorum/BE
Length of output: 50370
🏁 Script executed:
cat src/main/java/com/project/dorumdorum/domain/roommate/domain/repository/RoommateRepository.javaRepository: DorumDorum/BE
Length of output: 1310
🏁 Script executed:
cat src/main/java/com/project/dorumdorum/domain/roommate/domain/service/RoommateService.javaRepository: DorumDorum/BE
Length of output: 2945
🏁 Script executed:
cat src/main/java/com/project/dorumdorum/domain/room/application/usecase/DeleteRoomUseCase.javaRepository: DorumDorum/BE
Length of output: 2596
방장 외 룸메이트 존재 여부를 실제 Roommate 데이터 기준으로 강화해 주세요.
라인 50에서 currentMateCount만 믿고 삭제 가능 여부를 판단하고 있는데, 인원수 동기화 버그가 이 PR의 대상인 만큼 카운트가 실제 Roommate row와 어긋난 경우 비방장 룸메이트가 남은 채 방이 삭제될 수 있습니다.
라인 50의 검증 조건을 보강해서 roommateService.findByRoom(room)으로 실제 Roommate 레코드를 조회한 후, 방장을 제외한 다른 룸메이트가 없는지 확인하는 로직을 추가해 주세요. 혹은 RoommateService에 "방장 외 룸메이트 존재 여부"를 직접 확인하는 전용 쿼리 메서드를 추가하는 것도 좋은 방식입니다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@src/main/java/com/project/dorumdorum/domain/room/application/usecase/DeleteRoomUseCase.java`
around lines 50 - 55, The deletion currently trusts room.getCurrentMateCount()
which can be out of sync; update DeleteRoomUseCase to verify using actual
Roommate rows by calling roommateService.findByRoom(room) (or add a new
roommateService.existsNonOwnerInRoom(room) method) and throw
RestApiException(ROOM_HAS_MEMBERS) if any roommate other than the owner is
returned, only then call room.delete() and
roommateService.leaveRoom(requesterNo, roomNo).
Copilot
AI
Apr 18, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
PR 설명에서는 room.delete() 직후 roomService.flush()로 deletedAt을 먼저 DB에 반영한 뒤 leaveRoom()을 수행하는 것으로 되어 있는데, 현재 코드는 leaveRoom() 이후에 flush()가 호출됩니다. 의도한 트랜잭션 정합성/재진입 차단 목적이 flush 선행을 전제한 것이라면 구현 순서를 맞추고, 반대로 현재 순서가 의도라면 PR 설명(및 관련 주석)을 현재 동작에 맞게 업데이트해 주세요.
Uh oh!
There was an error while loading. Please reload this page.