From 24c15a08effeb925383ae6ecb2ef5e815426e59b Mon Sep 17 00:00:00 2001 From: 1anminJ Date: Sun, 19 Oct 2025 17:09:49 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=84=B8=EC=85=98=20=EC=9E=90=EB=8F=99?= =?UTF-8?q?=20=EB=B9=84=ED=99=9C=EC=84=B1=ED=99=94=20=EB=B0=8F=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EB=B8=8C=EB=A1=9C=EB=93=9C=EC=BA=90=EC=8A=A4?= =?UTF-8?q?=ED=8C=85=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../collab/service/RoomService.java | 55 +++++++++-- .../collab/service/WebSocketRoomService.java | 91 ++++++++++--------- .../config/WebSocketEventListener.java | 85 +++++++---------- 3 files changed, 128 insertions(+), 103 deletions(-) diff --git a/src/main/java/com/dmu/debug_visual/collab/service/RoomService.java b/src/main/java/com/dmu/debug_visual/collab/service/RoomService.java index 09e0a84..81cc4e6 100644 --- a/src/main/java/com/dmu/debug_visual/collab/service/RoomService.java +++ b/src/main/java/com/dmu/debug_visual/collab/service/RoomService.java @@ -5,24 +5,27 @@ import com.dmu.debug_visual.collab.domain.entity.SessionParticipant; import com.dmu.debug_visual.collab.domain.repository.CodeSessionRepository; import com.dmu.debug_visual.collab.domain.repository.SessionParticipantRepository; +import com.dmu.debug_visual.collab.rest.dto.*; import com.dmu.debug_visual.user.User; import com.dmu.debug_visual.user.UserRepository; import com.dmu.debug_visual.collab.domain.repository.RoomParticipantRepository; import com.dmu.debug_visual.collab.domain.repository.RoomRepository; -import com.dmu.debug_visual.collab.rest.dto.CreateRoomRequest; -import com.dmu.debug_visual.collab.rest.dto.CreateSessionRequest; -import com.dmu.debug_visual.collab.rest.dto.RoomResponse; -import com.dmu.debug_visual.collab.rest.dto.SessionResponse; import com.dmu.debug_visual.collab.domain.entity.Room; import com.dmu.debug_visual.collab.domain.entity.RoomParticipant; import jakarta.persistence.EntityNotFoundException; +import lombok.extern.slf4j.Slf4j; import lombok.RequiredArgsConstructor; +import org.springframework.messaging.simp.SimpMessageSendingOperations; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.List; +import java.util.stream.Collectors; + /** * 협업 방과 세션의 생성, 관리, 권한 부여 등 핵심 비즈니스 로직을 처리하는 서비스 */ +@Slf4j @Service @RequiredArgsConstructor public class RoomService { @@ -32,6 +35,7 @@ public class RoomService { private final RoomParticipantRepository roomParticipantRepository; private final SessionParticipantRepository sessionParticipantRepository; private final CodeSessionRepository codeSessionRepository; + private final SimpMessageSendingOperations messagingTemplate; // 1. 방 관리 (Room Management) /** @@ -83,13 +87,13 @@ public void kickParticipant(String roomId, String ownerId, String targetUserId) throw new IllegalArgumentException("Owner cannot kick themselves."); } - // 1. 방 참여자 목록에서 삭제 RoomParticipant participantToRemove = roomParticipantRepository.findByRoomAndUser_UserId(room, targetUserId) .orElseThrow(() -> new EntityNotFoundException("Participant not found in this room.")); roomParticipantRepository.delete(participantToRemove); - // 2. 해당 방의 모든 세션 참여자 목록에서도 삭제 sessionParticipantRepository.deleteAllByRoomIdAndUserId(roomId, targetUserId); + + broadcastRoomState(roomId); // ✨ 강퇴 후 방송! } // 2. 세션 관리 (Session Management) @@ -233,19 +237,52 @@ public void joinRoom(String roomId, String userId) { User user = userRepository.findByUserId(userId) .orElseThrow(() -> new EntityNotFoundException("User not found: " + userId)); - // 💡 이미 참여자인지 확인하여 중복 등록을 방지합니다. boolean isAlreadyParticipant = roomParticipantRepository.existsByRoomAndUser(room, user); if (isAlreadyParticipant) { - // 이미 참여자이면 아무것도 하지 않고 성공으로 간주 + broadcastRoomState(roomId); // 이미 참여자여도 최신 상태를 한번 보내줌 return; } - // 새로운 참여자로 등록 (기본 권한은 READ_ONLY) RoomParticipant newParticipant = RoomParticipant.builder() .room(room) .user(user) .permission(RoomParticipant.Permission.READ_ONLY) .build(); roomParticipantRepository.save(newParticipant); + + broadcastRoomState(roomId); // ✨ 참여자 추가 후 방송! + } + + /** + * 특정 방의 최신 상태(방 이름, 방장, 참여자 목록)를 조회하여 + * 해당 방의 시스템 채널로 브로드캐스팅합니다. + * @param roomId 상태를 방송할 방의 ID + */ + @Transactional(readOnly = true) + public void broadcastRoomState(String roomId) { + Room dbRoom = roomRepository.findByRoomId(roomId) + .orElseThrow(() -> new RuntimeException("Room not found during state broadcast: " + roomId)); + + ParticipantInfo ownerInfo = ParticipantInfo.builder() + .userId(dbRoom.getOwner().getUserId()) + .userName(dbRoom.getOwner().getName()) + .build(); + + List participantInfos = dbRoom.getParticipants().stream() + .filter(p -> !p.getUser().getUserId().equals(dbRoom.getOwner().getUserId())) + .map(p -> ParticipantInfo.builder() + .userId(p.getUser().getUserId()) + .userName(p.getUser().getName()) + .build()) + .collect(Collectors.toList()); + + RoomStateUpdate roomStateUpdate = RoomStateUpdate.builder() + .roomName(dbRoom.getName()) + .owner(ownerInfo) + .participants(participantInfos) + .build(); + + messagingTemplate.convertAndSend("/topic/room/" + roomId + "/system", roomStateUpdate); + log.info("Broadcasted room state update for room: {}", roomId); } } \ No newline at end of file diff --git a/src/main/java/com/dmu/debug_visual/collab/service/WebSocketRoomService.java b/src/main/java/com/dmu/debug_visual/collab/service/WebSocketRoomService.java index 2f606be..0d71313 100644 --- a/src/main/java/com/dmu/debug_visual/collab/service/WebSocketRoomService.java +++ b/src/main/java/com/dmu/debug_visual/collab/service/WebSocketRoomService.java @@ -4,72 +4,77 @@ import org.springframework.stereotype.Service; import java.util.Map; -import java.util.UUID; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; @Service public class WebSocketRoomService { - // 현재 생성된 모든 방의 정보를 서버 메모리에 저장합니다. + // key: roomId, value: WebSocketRoom (방의 기본 정보) private final Map activeRooms = new ConcurrentHashMap<>(); - /** - * 새로운 협업 방을 생성합니다. - * @param ownerId 방을 생성하는 사용자의 ID - * @return 생성된 방의 정보 - */ + // key: sessionId, value: Set of userIds + private final Map> sessionParticipants = new ConcurrentHashMap<>(); + + // --- 방(Room) 관련 메소드 --- + public WebSocketRoom activateRoom(String roomId, String ownerId) { - if(activeRooms.containsKey(roomId)) { - return activeRooms.get(roomId); - } - WebSocketRoom webSocketRoom = WebSocketRoom.builder() - .roomId(roomId) + // computeIfAbsent를 사용하면 if문 없이 더 간결하게 코드를 작성할 수 있습니다. + return activeRooms.computeIfAbsent(roomId, k -> WebSocketRoom.builder() + .roomId(k) .ownerId(ownerId) - .build(); - activeRooms.put(roomId, webSocketRoom); - return webSocketRoom; + .build()); } - /** - * ID로 활성화된 방을 찾습니다. - * @param roomId 찾으려는 방의 ID - * @return 찾아낸 방의 정보 (없으면 null) - */ public WebSocketRoom findActiveRoomById(String roomId) { return activeRooms.get(roomId); } - /** - * 특정 사용자가 특정 방에서 쓰기 권한을 가지고 있는지 확인합니다. - * @param roomId 확인할 방의 ID - * @param userId 확인할 사용자의 ID - * @return 쓰기 권한이 있으면 true - */ - public boolean hasWritePermission(String roomId, String userId) { - WebSocketRoom webSocketRoom = findActiveRoomById(roomId); - if (webSocketRoom == null) { - return false; - } - WebSocketRoom.Permission permission = webSocketRoom.getParticipants().get(userId); - return WebSocketRoom.Permission.READ_WRITE.equals(permission); - } - - /** - * 특정 방에 새로운 참여자를 추가합니다. - * @param roomId 참여할 방의 ID - * @param userId 새로운 참여자의 ID - */ public void addParticipant(String roomId, String userId) { - WebSocketRoom webSocketRoom = findActiveRoomById(roomId); - if (webSocketRoom != null) { - webSocketRoom.addParticipant(userId); + WebSocketRoom activeRoom = findActiveRoomById(roomId); + if (activeRoom != null) { + activeRoom.addParticipant(userId); } } public void removeParticipant(String roomId, String userId) { WebSocketRoom activeRoom = findActiveRoomById(roomId); if (activeRoom != null) { + // Map에서 참여자를 제거합니다. activeRoom.getParticipants().remove(userId); } } + + // --- 세션(Session) 관련 메소드 --- + + /** + * 특정 세션에 실시간 참여자를 추가합니다. + * @param sessionId 참여할 세션 ID + * @param userId 참여하는 사용자 ID + */ + public void addSessionParticipant(String sessionId, String userId) { + // computeIfAbsent를 사용하여 sessionId가 없으면 새로 Set을 만들고, 있으면 기존 Set에 userId를 추가합니다. + sessionParticipants.computeIfAbsent(sessionId, k -> ConcurrentHashMap.newKeySet()).add(userId); + } + + /** + * 특정 세션에서 실시간 참여자를 제거합니다. + * @param sessionId 나가는 세션 ID + * @param userId 나가는 사용자 ID + */ + public void removeSessionParticipant(String sessionId, String userId) { + if (sessionParticipants.containsKey(sessionId)) { + sessionParticipants.get(sessionId).remove(userId); + } + } + + /** + * 특정 세션이 비어있는지 (아무도 접속해있지 않은지) 확인합니다. + * @param sessionId 확인할 세션 ID + * @return 세션이 비어있거나 존재하지 않으면 true + */ + public boolean isSessionEmpty(String sessionId) { + // sessionId에 해당하는 참여자 목록이 없거나, 있더라도 비어있으면 true를 반환합니다. + return !sessionParticipants.containsKey(sessionId) || sessionParticipants.get(sessionId).isEmpty(); + } } \ No newline at end of file diff --git a/src/main/java/com/dmu/debug_visual/config/WebSocketEventListener.java b/src/main/java/com/dmu/debug_visual/config/WebSocketEventListener.java index 3f4cc38..c641ed7 100644 --- a/src/main/java/com/dmu/debug_visual/config/WebSocketEventListener.java +++ b/src/main/java/com/dmu/debug_visual/config/WebSocketEventListener.java @@ -1,15 +1,13 @@ package com.dmu.debug_visual.config; -import com.dmu.debug_visual.collab.domain.entity.Room; -import com.dmu.debug_visual.collab.domain.repository.RoomRepository; -import com.dmu.debug_visual.collab.rest.dto.ParticipantInfo; -import com.dmu.debug_visual.collab.rest.dto.RoomStateUpdate; +import com.dmu.debug_visual.collab.domain.entity.CodeSession; +import com.dmu.debug_visual.collab.domain.repository.CodeSessionRepository; +import com.dmu.debug_visual.collab.service.RoomService; import com.dmu.debug_visual.collab.service.WebSocketRoomService; import com.dmu.debug_visual.security.CustomUserDetails; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.event.EventListener; -import org.springframework.messaging.simp.SimpMessageSendingOperations; import org.springframework.messaging.simp.stomp.StompHeaderAccessor; import org.springframework.security.core.Authentication; import org.springframework.stereotype.Component; @@ -18,19 +16,17 @@ import org.springframework.web.socket.messaging.SessionSubscribeEvent; import java.security.Principal; -import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.stream.Collectors; @Slf4j @Component @RequiredArgsConstructor public class WebSocketEventListener { - private final SimpMessageSendingOperations messagingTemplate; private final WebSocketRoomService webSocketRoomService; - private final RoomRepository roomRepository; + private final RoomService roomService; + private final CodeSessionRepository codeSessionRepository; @EventListener @Transactional @@ -44,19 +40,25 @@ public void handleWebSocketSubscribeListener(SessionSubscribeEvent event) { try { String roomId = destination.split("/")[3]; - // --- 사용자 정보 가져오기 및 메모리에 사용자 추가 --- Authentication authentication = (Authentication) userPrincipal; CustomUserDetails userDetails = (CustomUserDetails) authentication.getPrincipal(); String userId = userDetails.getUsername(); + webSocketRoomService.addParticipant(roomId, userId); - // --- 퇴장 이벤트를 위해 세션에 정보 저장 --- Map sessionAttributes = Objects.requireNonNull(headerAccessor.getSessionAttributes()); sessionAttributes.put("roomId", roomId); sessionAttributes.put("userId", userId); - // --- 방 전체에 최신 상태 브로드캐스팅 --- - broadcastRoomState(roomId); + // 만약 구독 주소에 "/session/"이 포함되어 있다면, 세션 참여자로도 등록합니다. + if (destination.contains("/session/")) { + String sessionId = destination.split("/")[5]; + sessionAttributes.put("sessionId", sessionId); // 퇴장 시 사용하기 위해 세션 ID 저장 + webSocketRoomService.addSessionParticipant(sessionId, userId); + log.info("User {} joined session {}", userId, sessionId); + } + + roomService.broadcastRoomState(roomId); } catch (Exception e) { log.error("Error handling subscribe event: ", e); @@ -73,51 +75,32 @@ public void handleWebSocketDisconnectListener(SessionDisconnectEvent event) { if (sessionAttributes != null) { String roomId = (String) sessionAttributes.get("roomId"); String userId = (String) sessionAttributes.get("userId"); + String sessionId = (String) sessionAttributes.get("sessionId"); if (roomId != null && userId != null) { log.info("[퇴장] 사용자: {}, 방: {}", userId, roomId); - // 메모리에서 사용자 제거 (WebSocketRoomService에 removeParticipant 메소드 필요) webSocketRoomService.removeParticipant(roomId, userId); - // --- 방 전체에 최신 상태 브로드캐스팅 --- - broadcastRoomState(roomId); + // --- 세션 자동 비활성화 로직 --- + if (sessionId != null) { + webSocketRoomService.removeSessionParticipant(sessionId, userId); + + // 방금 나간 사람이 마지막 참여자였는지 확인 + if (webSocketRoomService.isSessionEmpty(sessionId)) { + log.info("Last user left session {}. Deactivating session.", sessionId); + + // DB에서 세션을 찾아 상태를 INACTIVE로 변경 + codeSessionRepository.findBySessionId(sessionId).ifPresent(session -> { + session.updateStatus(CodeSession.SessionStatus.INACTIVE); + log.info("Session {} status updated to INACTIVE in DB.", sessionId); + }); + } + } + + // 변경된 방 상태(참여자 감소)를 모두에게 알림 + roomService.broadcastRoomState(roomId); } } } - - /** - * 특정 방의 최신 상태(방 이름, 방장, 참여자 목록)를 조회하여 - * 해당 방의 시스템 채널로 브로드캐스팅하는 헬퍼 메소드 - */ - private void broadcastRoomState(String roomId) { - Room dbRoom = roomRepository.findByRoomId(roomId) - .orElseThrow(() -> new RuntimeException("Room not found during state broadcast: " + roomId)); - - // 1. 방장 정보 DTO 생성 - ParticipantInfo ownerInfo = ParticipantInfo.builder() - .userId(dbRoom.getOwner().getUserId()) - .userName(dbRoom.getOwner().getName()) - .build(); - - // 2. 참여자(방장 제외) 목록 DTO 생성 - List participantInfos = dbRoom.getParticipants().stream() - .filter(p -> !p.getUser().getUserId().equals(dbRoom.getOwner().getUserId())) - .map(p -> ParticipantInfo.builder() - .userId(p.getUser().getUserId()) - .userName(p.getUser().getName()) - .build()) - .collect(Collectors.toList()); - - // 3. 최종 업데이트 DTO 생성 - RoomStateUpdate roomStateUpdate = RoomStateUpdate.builder() - .roomName(dbRoom.getName()) - .owner(ownerInfo) - .participants(participantInfos) - .build(); - - // 4. 시스템 채널로 브로드캐스팅 - messagingTemplate.convertAndSend("/topic/room/" + roomId + "/system", roomStateUpdate); - log.info("Broadcasted room state update for room: {}", roomId); - } } \ No newline at end of file