Skip to content

Commit b8fd5aa

Browse files
committed
feat: 실시간 참여자 목록 동기화 및 세션 관리 API 연동(#36)
1 parent d2d509f commit b8fd5aa

File tree

6 files changed

+268
-131
lines changed

6 files changed

+268
-131
lines changed
Lines changed: 86 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.dmu.debug_visual.collab.rest;
22

3+
import com.dmu.debug_visual.collab.domain.entity.CodeSession.SessionStatus;
34
import com.dmu.debug_visual.security.CustomUserDetails;
45
import com.dmu.debug_visual.collab.rest.dto.CreateRoomRequest;
56
import com.dmu.debug_visual.collab.rest.dto.CreateSessionRequest;
@@ -18,48 +19,109 @@
1819
import org.springframework.security.core.annotation.AuthenticationPrincipal;
1920
import org.springframework.web.bind.annotation.*;
2021

21-
@Tag(name = "협업 방 및 세션 관리 API", description = "실시간 협업을 위한 방과 코드 세션을 생성하고 관리합니다.")
22+
import java.util.Map;
23+
24+
@Tag(name = "협업 방 및 세션 관리 API", description = "실시간 협업을 위한 방 생성, 세션 관리, 권한 부여, 강퇴 등 모든 REST API를 제공합니다.")
2225
@RestController
23-
@RequestMapping("/api/collab-rooms")
26+
@RequestMapping("/api/collab")
2427
@RequiredArgsConstructor
2528
public class RoomController {
2629

27-
private final RoomService roomService; // DB 관련 서비스
30+
private final RoomService roomService;
2831

29-
@Operation(summary = "새로운 협업 방 생성", description = "DB에 새로운 협업 방을 생성하고, 방장(owner)을 첫 참여자로 자동 등록합니다.")
32+
// --- 방 관리 ---
33+
@Operation(summary = "새로운 협업 방 생성", description = "DB에 새로운 협업 방을 생성하고, 방장을 첫 참여자로 자동 등록합니다.")
3034
@ApiResponses({
31-
@ApiResponse(responseCode = "200", description = "방 생성 성공",
32-
content = @Content(mediaType = "application/json", schema = @Schema(implementation = RoomResponse.class))),
35+
@ApiResponse(responseCode = "200", description = "방 생성 성공", content = @Content(schema = @Schema(implementation = RoomResponse.class))),
3336
@ApiResponse(responseCode = "401", description = "인증 실패", content = @Content)
3437
})
35-
@PostMapping
36-
public ResponseEntity<RoomResponse> createRoom(
37-
@RequestBody CreateRoomRequest request,
38-
@AuthenticationPrincipal CustomUserDetails userDetails
39-
) {
38+
@PostMapping("/rooms")
39+
public ResponseEntity<RoomResponse> createRoom(@RequestBody CreateRoomRequest request, @AuthenticationPrincipal CustomUserDetails userDetails) {
4040
String ownerUserId = userDetails.getUsername();
4141
RoomResponse response = roomService.createRoom(request, ownerUserId);
4242
return ResponseEntity.ok(response);
4343
}
4444

45-
@Operation(summary = "방 안에 새 코드 세션 생성", description = "기존에 생성된 협업 방 안에 독립적인 새 코드 편집 세션을 생성합니다.")
45+
@Operation(summary = "방에서 참가자 강퇴 (방장 전용)", description = "방장이 특정 참가자를 방에서 영구적으로 제외시킵니다. 강퇴된 참가자는 해당 방의 모든 세션에서도 제거됩니다.")
4646
@ApiResponses({
47-
@ApiResponse(responseCode = "200", description = "세션 생성 성공",
48-
content = @Content(mediaType = "application/json", schema = @Schema(implementation = SessionResponse.class))),
49-
@ApiResponse(responseCode = "401", description = "인증 실패", content = @Content),
50-
@ApiResponse(responseCode = "403", description = "해당 방의 참여자가 아님", content = @Content),
51-
@ApiResponse(responseCode = "404", description = "존재하지 않는 방", content = @Content)
47+
@ApiResponse(responseCode = "200", description = "강퇴 성공"),
48+
@ApiResponse(responseCode = "401", description = "인증 실패"),
49+
@ApiResponse(responseCode = "403", description = "권한 없음 (방장이 아님)"),
50+
@ApiResponse(responseCode = "404", description = "방 또는 참가자를 찾을 수 없음")
5251
})
53-
@PostMapping("/{roomId}/sessions")
54-
public ResponseEntity<SessionResponse> createSession(
55-
@Parameter(description = "세션을 생성할 방의 고유 ID (UUID)", required = true)
56-
@PathVariable String roomId,
52+
@DeleteMapping("/rooms/{roomId}/participants/{targetUserId}")
53+
public ResponseEntity<Void> kickParticipant(
54+
@Parameter(description = "대상 방의 고유 ID") @PathVariable String roomId,
55+
@Parameter(description = "강퇴시킬 사용자의 ID") @PathVariable String targetUserId,
56+
@AuthenticationPrincipal CustomUserDetails userDetails) {
57+
roomService.kickParticipant(roomId, userDetails.getUsername(), targetUserId);
58+
return ResponseEntity.ok().build();
59+
}
5760

61+
// --- 세션 관리 ---
62+
@Operation(summary = "방 안에 새 코드 세션 생성 (방송 시작)", description = "기존 방 안에 독립적인 새 코드 편집 세션을 생성합니다. 생성자는 자동으로 쓰기 권한을 가집니다.")
63+
@ApiResponses({
64+
@ApiResponse(responseCode = "200", description = "세션 생성 성공", content = @Content(schema = @Schema(implementation = SessionResponse.class))),
65+
@ApiResponse(responseCode = "401", description = "인증 실패"),
66+
@ApiResponse(responseCode = "404", description = "존재하지 않는 방")
67+
})
68+
@PostMapping("/rooms/{roomId}/sessions")
69+
public ResponseEntity<SessionResponse> createSession(
70+
@Parameter(description = "세션을 생성할 방의 고유 ID") @PathVariable String roomId,
5871
@RequestBody CreateSessionRequest request,
59-
@AuthenticationPrincipal CustomUserDetails userDetails
60-
) {
72+
@AuthenticationPrincipal CustomUserDetails userDetails) {
6173
String creatorUserId = userDetails.getUsername();
6274
SessionResponse response = roomService.createCodeSessionInRoom(roomId, request, creatorUserId);
6375
return ResponseEntity.ok(response);
6476
}
65-
}
77+
78+
@Operation(summary = "세션 상태 변경 (방송 켜기/끄기, 세션 생성자 전용)", description = "세션의 상태를 'ACTIVE' 또는 'INACTIVE'로 변경합니다.")
79+
@ApiResponses({
80+
@ApiResponse(responseCode = "200", description = "상태 변경 성공"),
81+
@ApiResponse(responseCode = "401", description = "인증 실패"),
82+
@ApiResponse(responseCode = "403", description = "권한 없음 (세션 생성자가 아님)"),
83+
@ApiResponse(responseCode = "404", description = "세션을 찾을 수 없음")
84+
})
85+
@PatchMapping("/sessions/{sessionId}/status")
86+
public ResponseEntity<Void> updateSessionStatus(
87+
@Parameter(description = "상태를 변경할 세션의 고유 ID") @PathVariable String sessionId,
88+
@RequestBody Map<String, SessionStatus> statusMap,
89+
@AuthenticationPrincipal CustomUserDetails userDetails) {
90+
roomService.updateSessionStatus(sessionId, userDetails.getUsername(), statusMap.get("status"));
91+
return ResponseEntity.ok().build();
92+
}
93+
94+
// --- 세션 권한 관리 ---
95+
@Operation(summary = "세션 내 쓰기 권한 부여 (세션 생성자 전용)", description = "세션 생성자가 특정 참가자에게 해당 세션의 쓰기 권한을 부여합니다.")
96+
@ApiResponses({
97+
@ApiResponse(responseCode = "200", description = "권한 부여 성공"),
98+
@ApiResponse(responseCode = "401", description = "인증 실패"),
99+
@ApiResponse(responseCode = "403", description = "권한 없음 (세션 생성자가 아님)"),
100+
@ApiResponse(responseCode = "404", description = "세션 또는 참가자를 찾을 수 없음")
101+
})
102+
@PostMapping("/sessions/{sessionId}/permissions/{targetUserId}")
103+
public ResponseEntity<Void> grantWritePermission(
104+
@Parameter(description = "권한을 부여할 세션의 고유 ID") @PathVariable String sessionId,
105+
@Parameter(description = "권한을 부여받을 사용자의 ID") @PathVariable String targetUserId,
106+
@AuthenticationPrincipal CustomUserDetails userDetails) {
107+
roomService.grantWritePermissionInSession(sessionId, userDetails.getUsername(), targetUserId);
108+
return ResponseEntity.ok().build();
109+
}
110+
111+
@Operation(summary = "세션 내 쓰기 권한 회수 (세션 생성자 전용)", description = "세션 생성자가 특정 참가자의 쓰기 권한을 회수합니다.")
112+
@ApiResponses({
113+
@ApiResponse(responseCode = "200", description = "권한 회수 성공"),
114+
@ApiResponse(responseCode = "401", description = "인증 실패"),
115+
@ApiResponse(responseCode = "403", description = "권한 없음 (세션 생성자가 아님)"),
116+
@ApiResponse(responseCode = "404", description = "세션 또는 참가자를 찾을 수 없음")
117+
})
118+
@DeleteMapping("/sessions/{sessionId}/permissions/{targetUserId}")
119+
public ResponseEntity<Void> revokeWritePermission(
120+
@Parameter(description = "권한을 회수할 세션의 고유 ID") @PathVariable String sessionId,
121+
@Parameter(description = "권한을 회수당할 사용자의 ID") @PathVariable String targetUserId,
122+
@AuthenticationPrincipal CustomUserDetails userDetails) {
123+
roomService.revokeWritePermissionInSession(sessionId, userDetails.getUsername(), targetUserId);
124+
return ResponseEntity.ok().build();
125+
}
126+
}
127+
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package com.dmu.debug_visual.collab.rest.dto;
2+
3+
import lombok.Builder;
4+
import lombok.Getter;
5+
6+
/**
7+
* 참여자 한 명의 정보를 담는 DTO
8+
*/
9+
@Getter
10+
@Builder
11+
public class ParticipantInfo {
12+
private String userId;
13+
private String userName;
14+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package com.dmu.debug_visual.collab.rest.dto;
2+
3+
import lombok.Builder;
4+
import lombok.Getter;
5+
6+
import java.util.List;
7+
8+
/**
9+
* 방의 최신 상태(방 이름, 방장, 참여자 목록) 정보를 담는 DTO.
10+
* 사용자가 입장/퇴장할 때마다 이 객체가 /topic/room/{roomId}/system 으로 전송됩니다.
11+
*/
12+
@Getter
13+
@Builder
14+
public class RoomStateUpdate {
15+
private String roomName;
16+
private ParticipantInfo owner; // 방장 정보
17+
private List<ParticipantInfo> participants; // 참여자 목록 (방장을 제외한 나머지)
18+
}

src/main/java/com/dmu/debug_visual/collab/service/WebSocketRoomService.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@ public WebSocketRoom activateRoom(String roomId, String ownerId) {
3535
* @param roomId 찾으려는 방의 ID
3636
* @return 찾아낸 방의 정보 (없으면 null)
3737
*/
38-
// ✨ 이 메소드 이름을 수정해주세요!
3938
public WebSocketRoom findActiveRoomById(String roomId) {
4039
return activeRooms.get(roomId);
4140
}
@@ -66,4 +65,11 @@ public void addParticipant(String roomId, String userId) {
6665
webSocketRoom.addParticipant(userId);
6766
}
6867
}
68+
69+
public void removeParticipant(String roomId, String userId) {
70+
WebSocketRoom activeRoom = findActiveRoomById(roomId);
71+
if (activeRoom != null) {
72+
activeRoom.getParticipants().remove(userId);
73+
}
74+
}
6975
}

src/main/java/com/dmu/debug_visual/config/WebSocketEventListener.java

Lines changed: 56 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22

33
import com.dmu.debug_visual.collab.domain.entity.Room;
44
import com.dmu.debug_visual.collab.domain.repository.RoomRepository;
5-
import com.dmu.debug_visual.collab.rest.dto.SystemMessage;
5+
import com.dmu.debug_visual.collab.rest.dto.ParticipantInfo;
6+
import com.dmu.debug_visual.collab.rest.dto.RoomStateUpdate;
67
import com.dmu.debug_visual.collab.service.WebSocketRoomService;
7-
import com.dmu.debug_visual.collab.websocket.dto.WebSocketRoom;
88
import com.dmu.debug_visual.security.CustomUserDetails;
99
import lombok.RequiredArgsConstructor;
1010
import lombok.extern.slf4j.Slf4j;
@@ -18,8 +18,10 @@
1818
import org.springframework.web.socket.messaging.SessionSubscribeEvent;
1919

2020
import java.security.Principal;
21+
import java.util.List;
2122
import java.util.Map;
2223
import java.util.Objects;
24+
import java.util.stream.Collectors;
2325

2426
@Slf4j
2527
@Component
@@ -28,52 +30,33 @@ public class WebSocketEventListener {
2830

2931
private final SimpMessageSendingOperations messagingTemplate;
3032
private final WebSocketRoomService webSocketRoomService;
31-
private final RoomRepository roomRepository; // ✨ DB 조회를 위해 RoomRepository 주입
33+
private final RoomRepository roomRepository;
3234

3335
@EventListener
3436
@Transactional
3537
public void handleWebSocketSubscribeListener(SessionSubscribeEvent event) {
3638
StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage());
3739
Principal userPrincipal = headerAccessor.getUser();
38-
39-
if (userPrincipal == null) {
40-
log.warn("Cannot process subscribe: user not authenticated.");
41-
return;
42-
}
40+
if (userPrincipal == null) return;
4341

4442
String destination = headerAccessor.getDestination();
4543
if (destination != null && destination.contains("/topic/room/")) {
4644
try {
4745
String roomId = destination.split("/")[3];
4846

49-
// ✨ 1. 메모리에 활성화된 방이 있는지 확인
50-
WebSocketRoom activeRoom = webSocketRoomService.findActiveRoomById(roomId);
51-
if (activeRoom == null) {
52-
// ✨ 2. 없다면, DB에서 방 정보를 가져와서 "파티 시작" (메모리에 활성화)
53-
log.info("Activating room {} in memory.", roomId);
54-
Room dbRoom = roomRepository.findByRoomId(roomId)
55-
.orElseThrow(() -> new RuntimeException("Subscribing to a non-existent room: " + roomId));
56-
// WebSocket 서비스에 방을 활성화하도록 요청 (방장 정보와 함께)
57-
webSocketRoomService.activateRoom(roomId, dbRoom.getOwner().getUserId());
58-
}
59-
60-
// ✨ 3. 이제 사용자를 활성화된 방에 참여시킴
47+
// --- 사용자 정보 가져오기 및 메모리에 사용자 추가 ---
6148
Authentication authentication = (Authentication) userPrincipal;
6249
CustomUserDetails userDetails = (CustomUserDetails) authentication.getPrincipal();
6350
String userId = userDetails.getUsername();
64-
String userName = userDetails.getUser().getName();
65-
6651
webSocketRoomService.addParticipant(roomId, userId);
6752

68-
// ✨ 4. 퇴장 이벤트를 위해 세션에 정보 저장
53+
// --- 퇴장 이벤트를 위해 세션에 정보 저장 ---
6954
Map<String, Object> sessionAttributes = Objects.requireNonNull(headerAccessor.getSessionAttributes());
7055
sessionAttributes.put("roomId", roomId);
71-
sessionAttributes.put("userName", userName);
56+
sessionAttributes.put("userId", userId);
7257

73-
log.info("[입장] 사용자: {}, 방: {}", userName, roomId);
74-
SystemMessage systemMessage = SystemMessage.builder().content(userName + "님이 입장했습니다.").build();
75-
76-
messagingTemplate.convertAndSend("/topic/room/" + roomId + "/system", systemMessage);
58+
// --- 방 전체에 최신 상태 브로드캐스팅 ---
59+
broadcastRoomState(roomId);
7760

7861
} catch (Exception e) {
7962
log.error("Error handling subscribe event: ", e);
@@ -82,19 +65,59 @@ public void handleWebSocketSubscribeListener(SessionSubscribeEvent event) {
8265
}
8366

8467
@EventListener
68+
@Transactional
8569
public void handleWebSocketDisconnectListener(SessionDisconnectEvent event) {
8670
StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage());
8771
Map<String, Object> sessionAttributes = headerAccessor.getSessionAttributes();
8872

8973
if (sessionAttributes != null) {
90-
String userName = (String) sessionAttributes.get("userName");
9174
String roomId = (String) sessionAttributes.get("roomId");
75+
String userId = (String) sessionAttributes.get("userId");
9276

93-
if (userName != null && roomId != null) {
94-
log.info("[퇴장] 사용자: {}, 방: {}", userName, roomId);
95-
SystemMessage systemMessage = SystemMessage.builder().content(userName + "님이 퇴장했습니다.").build();
96-
messagingTemplate.convertAndSend("/topic/room/" + roomId + "/system", systemMessage);
77+
if (roomId != null && userId != null) {
78+
log.info("[퇴장] 사용자: {}, 방: {}", userId, roomId);
79+
80+
// 메모리에서 사용자 제거 (WebSocketRoomService에 removeParticipant 메소드 필요)
81+
webSocketRoomService.removeParticipant(roomId, userId);
82+
83+
// --- 방 전체에 최신 상태 브로드캐스팅 ---
84+
broadcastRoomState(roomId);
9785
}
9886
}
9987
}
88+
89+
/**
90+
* 특정 방의 최신 상태(방 이름, 방장, 참여자 목록)를 조회하여
91+
* 해당 방의 시스템 채널로 브로드캐스팅하는 헬퍼 메소드
92+
*/
93+
private void broadcastRoomState(String roomId) {
94+
Room dbRoom = roomRepository.findByRoomId(roomId)
95+
.orElseThrow(() -> new RuntimeException("Room not found during state broadcast: " + roomId));
96+
97+
// 1. 방장 정보 DTO 생성
98+
ParticipantInfo ownerInfo = ParticipantInfo.builder()
99+
.userId(dbRoom.getOwner().getUserId())
100+
.userName(dbRoom.getOwner().getName())
101+
.build();
102+
103+
// 2. 참여자(방장 제외) 목록 DTO 생성
104+
List<ParticipantInfo> participantInfos = dbRoom.getParticipants().stream()
105+
.filter(p -> !p.getUser().getUserId().equals(dbRoom.getOwner().getUserId()))
106+
.map(p -> ParticipantInfo.builder()
107+
.userId(p.getUser().getUserId())
108+
.userName(p.getUser().getName())
109+
.build())
110+
.collect(Collectors.toList());
111+
112+
// 3. 최종 업데이트 DTO 생성
113+
RoomStateUpdate roomStateUpdate = RoomStateUpdate.builder()
114+
.roomName(dbRoom.getName())
115+
.owner(ownerInfo)
116+
.participants(participantInfos)
117+
.build();
118+
119+
// 4. 시스템 채널로 브로드캐스팅
120+
messagingTemplate.convertAndSend("/topic/room/" + roomId + "/system", roomStateUpdate);
121+
log.info("Broadcasted room state update for room: {}", roomId);
122+
}
100123
}

0 commit comments

Comments
 (0)