Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 46 additions & 9 deletions src/main/java/com/dmu/debug_visual/collab/service/RoomService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
/**
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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<ParticipantInfo> 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, WebSocketRoom> activeRooms = new ConcurrentHashMap<>();

/**
* 새로운 협업 방을 생성합니다.
* @param ownerId 방을 생성하는 사용자의 ID
* @return 생성된 방의 정보
*/
// key: sessionId, value: Set of userIds
private final Map<String, Set<String>> 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();
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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
Expand All @@ -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<String, Object> 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);
Expand All @@ -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<ParticipantInfo> 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);
}
}