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
Binary file modified .DS_Store
Binary file not shown.
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-websocket'
implementation 'org.springframework.boot:spring-boot-starter-aop'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.mysql:mysql-connector-j'
annotationProcessor 'org.projectlombok:lombok'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package com.whylog.server.admin.controller;

import com.whylog.server.admin.dto.AdminResponse;
import com.whylog.server.admin.service.AdminMeetingRoomService;
import com.whylog.server.global.apiPayload.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@Tag(name = "Admin", description = "관리자 기능")
@RestController
@RequestMapping("/api/admin")
@RequiredArgsConstructor
public class AdminController {

private final AdminMeetingRoomService adminMeetingRoomService;

@GetMapping("/livekit/rooms")
@Operation(summary = "LiveKit 열린 room 목록 조회", description = "LiveKit에 현재 열려 있는 room 목록을 조회합니다.")
public ApiResponse<AdminResponse.LiveKitRoomListDTO> listLiveKitRooms() {
return ApiResponse.onSuccess(adminMeetingRoomService.listLiveKitRooms());
}

@DeleteMapping("/livekit/rooms/{roomName}")
@Operation(summary = "LiveKit room 삭제", description = "지정한 LiveKit room을 삭제하고 연결된 참여자를 모두 종료합니다.")
public ApiResponse<AdminResponse.LiveKitRoomDeleteDTO> deleteLiveKitRoom(
@PathVariable String roomName
) {
return ApiResponse.onSuccess(adminMeetingRoomService.deleteLiveKitRoom(roomName));
}

@GetMapping("/livekit/rooms/{roomName}/participants")
@Operation(summary = "LiveKit room 참여자 목록 조회", description = "지정한 LiveKit room에 현재 참여 중인 사용자 목록을 조회합니다.")
public ApiResponse<AdminResponse.LiveKitParticipantListDTO> listLiveKitParticipants(
@PathVariable String roomName
) {
return ApiResponse.onSuccess(adminMeetingRoomService.listLiveKitParticipants(roomName));
}

@DeleteMapping("/meeting-rooms/{meetingId}/participants/{memberId}")
@Operation(summary = "관리자용 참여자 강제 제거", description = "지정한 미팅룸에서 특정 참여자를 LiveKit room과 웹소켓 방에서 제거합니다.")
public ApiResponse<AdminResponse.KickParticipantResponseDTO> removeParticipant(
@PathVariable Long meetingId,
@PathVariable Long memberId
) {
return ApiResponse.onSuccess(adminMeetingRoomService.removeParticipant(meetingId, memberId));
}

@GetMapping("/meeting-rooms/{meetingId}/websocket-sessions")
@Operation(summary = "회의 웹소켓 세션 정보 조회", description = "지정한 meetingId에 연결된 웹소켓 세션 정보를 조회합니다.")
public ApiResponse<AdminResponse.WebSocketSessionListDTO> listWebSocketSessions(
@PathVariable Long meetingId
) {
return ApiResponse.onSuccess(adminMeetingRoomService.listWebSocketSessions(meetingId));
}
}
85 changes: 85 additions & 0 deletions src/main/java/com/whylog/server/admin/dto/AdminResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package com.whylog.server.admin.dto;

import io.swagger.v3.oas.annotations.media.Schema;

import java.util.List;

public class AdminResponse {

@Schema(description = "LiveKit 열린 room 목록 응답")
public record LiveKitRoomListDTO(
List<LiveKitRoomDTO> rooms
) {
}

@Schema(description = "LiveKit room 정보")
public record LiveKitRoomDTO(
String sid,
String name,
Integer numParticipants,
Boolean activeRecording,
String creationTime,
String metadata
) {
}

@Schema(description = "LiveKit room 삭제 응답")
public record LiveKitRoomDeleteDTO(
String roomName,
Boolean deleted
) {
}

@Schema(description = "LiveKit room 참여자 목록 응답")
public record LiveKitParticipantListDTO(
String roomName,
List<LiveKitParticipantDTO> participants
) {
}

@Schema(description = "LiveKit room 참여자 정보")
public record LiveKitParticipantDTO(
String sid,
String identity,
String name,
String state,
String joinedAt,
Boolean isPublisher,
String metadata
) {
}

@Schema(description = "관리자용 미팅 참여자 정보")
public record ParticipantDTO(
Long memberId,
String name,
String sessionId
) {
}

@Schema(description = "회의 웹소켓 세션 목록 응답")
public record WebSocketSessionListDTO(
Long meetingId,
List<WebSocketSessionDTO> sessions
) {
}

@Schema(description = "회의 웹소켓 세션 정보")
public record WebSocketSessionDTO(
String sessionId,
Long memberId,
String name,
Boolean open
) {
}

@Schema(description = "관리자용 참여자 강제 제거 응답")
public record KickParticipantResponseDTO(
Long meetingId,
Long memberId,
Boolean removed,
Integer removedSessionCount,
Boolean liveKitRemoved
) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
package com.whylog.server.admin.service;

import com.whylog.server.admin.dto.AdminResponse;
import com.whylog.server.domain.meeting.entity.Meeting;
import com.whylog.server.domain.meeting.repository.MeetingRepository;
import com.whylog.server.domain.meeting.service.MeetingCommandService;
import com.whylog.server.domain.meeting.service.LiveKitTokenService;
import com.whylog.server.domain.meeting.socket.MeetingParticipant;
import com.whylog.server.domain.meeting.socket.MeetingSocketRoomService;
import com.whylog.server.domain.meeting.socket.message.ParticipantLeftMessage;
import com.whylog.server.domain.meeting.socket.message.MeetingMessageType;
import com.whylog.server.domain.meeting.socket.message.ParticipantSummary;
import com.whylog.server.domain.meeting.socket.message.RosterMessage;
import com.whylog.server.domain.meeting.socket.repository.MeetingRoomRepository;
import com.whylog.server.domain.meeting.socket.repository.MeetingSocketRoomRepository;
import com.whylog.server.global.external.livekit.LiveKitEgressClient;
import com.whylog.server.global.util.json.JsonConverter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.socket.CloseStatus;

import java.io.IOException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.Map;

@Slf4j
@Service
@RequiredArgsConstructor
public class AdminMeetingRoomService {

private final MeetingRepository meetingRepository;
private final MeetingSocketRoomRepository meetingSocketRoomRepository;
private final MeetingSocketRoomService meetingSocketRoomService;
private final MeetingCommandService meetingCommandService;
private final LiveKitTokenService liveKitTokenService;
private final LiveKitEgressClient liveKitEgressClient;

@Transactional(readOnly = true)
public AdminResponse.LiveKitRoomListDTO listLiveKitRooms() {
String roomListToken = liveKitTokenService.createRoomListToken("room-admin");
List<AdminResponse.LiveKitRoomDTO> rooms = liveKitEgressClient.listRooms(roomListToken).stream()
.map(this::toLiveKitRoomDTO)
.filter(Objects::nonNull)
.sorted(Comparator.comparing(AdminResponse.LiveKitRoomDTO::name))
.toList();

return new AdminResponse.LiveKitRoomListDTO(rooms);
}

public AdminResponse.LiveKitRoomDeleteDTO deleteLiveKitRoom(String roomName) {
String roomCreateToken = liveKitTokenService.createRoomCreateToken("room-admin");
liveKitEgressClient.deleteRoom(roomCreateToken, roomName);
return new AdminResponse.LiveKitRoomDeleteDTO(roomName, true);
}

public AdminResponse.LiveKitParticipantListDTO listLiveKitParticipants(String roomName) {
String roomAdminToken = liveKitTokenService.createRoomAdminToken("room-admin", roomName);
List<AdminResponse.LiveKitParticipantDTO> participants = liveKitEgressClient.listParticipants(roomAdminToken, roomName).stream()
.map(this::toLiveKitParticipantDTO)
.filter(Objects::nonNull)
.sorted(Comparator.comparing(AdminResponse.LiveKitParticipantDTO::identity, Comparator.nullsLast(String::compareTo)))
.toList();

return new AdminResponse.LiveKitParticipantListDTO(roomName, participants);
}

public AdminResponse.WebSocketSessionListDTO listWebSocketSessions(Long meetingId) {
MeetingRoomRepository room = meetingSocketRoomRepository.findByMeetingId(meetingId);
List<AdminResponse.WebSocketSessionDTO> sessions = new ArrayList<>();
if (room != null) {
sessions = room.participants().stream()
.map(this::toWebSocketSessionDTO)
.sorted(Comparator.comparing(AdminResponse.WebSocketSessionDTO::sessionId))
.toList();
}

return new AdminResponse.WebSocketSessionListDTO(meetingId, sessions);
}

@Transactional
public AdminResponse.KickParticipantResponseDTO removeParticipant(Long meetingId, Long memberId) {
MeetingRoomRepository room = meetingSocketRoomRepository.findByMeetingId(meetingId);
if (room == null) {
return new AdminResponse.KickParticipantResponseDTO(meetingId, memberId, false, 0, false);
}

List<MeetingParticipant> removedParticipants = room.removeParticipantsByMemberId(memberId);
if (removedParticipants.isEmpty()) {
return new AdminResponse.KickParticipantResponseDTO(meetingId, memberId, false, 0, false);
}

boolean liveKitRemoved = false;
try {
String roomName = buildRoomName(meetingId);
String roomAdminToken = liveKitTokenService.createRoomAdminToken("room-admin", roomName);
liveKitEgressClient.removeParticipant(roomAdminToken, roomName, String.valueOf(memberId));
liveKitRemoved = true;
} catch (RuntimeException exception) {
log.warn("LiveKit participant removal failed: meetingId={}, memberId={}", meetingId, memberId, exception);
}

broadcastParticipantRemoval(meetingId, removedParticipants);
closeRemovedSessions(removedParticipants);

if (room.isEmpty()) {
meetingSocketRoomRepository.delete(meetingId);
meetingCommandService.autoEndMeetingIfEmpty(meetingId);
}

return new AdminResponse.KickParticipantResponseDTO(
meetingId,
memberId,
true,
removedParticipants.size(),
liveKitRemoved
);
}

private AdminResponse.LiveKitRoomDTO toLiveKitRoomDTO(Map<String, Object> room) {
if (room == null) {
return null;
}

return new AdminResponse.LiveKitRoomDTO(
stringValue(room.get("sid")),
stringValue(room.get("name")),
intValue(room.get("num_participants")),
booleanValue(room.get("active_recording")),
stringValue(room.get("creation_time")),
stringValue(room.get("metadata"))
);
}

private AdminResponse.LiveKitParticipantDTO toLiveKitParticipantDTO(Map<String, Object> participant) {
if (participant == null) {
return null;
}

return new AdminResponse.LiveKitParticipantDTO(
stringValue(participant.get("sid")),
stringValue(participant.get("identity")),
stringValue(participant.get("name")),
stringValue(participant.get("state")),
stringValue(participant.get("joined_at")),
booleanValue(participant.get("is_publisher")),
stringValue(participant.get("metadata"))
);
}

private AdminResponse.WebSocketSessionDTO toWebSocketSessionDTO(MeetingParticipant participant) {
return new AdminResponse.WebSocketSessionDTO(
participant.sessionId(),
participant.memberId(),
participant.name(),
participant.socketSession() != null && participant.socketSession().isOpen()
);
}

private void broadcastParticipantRemoval(Long meetingId, List<MeetingParticipant> removedParticipants) {
if (removedParticipants.isEmpty()) {
return;
}

MeetingParticipant removedParticipant = removedParticipants.get(0);
meetingSocketRoomService.broadcastText(
meetingId,
JsonConverter.toJson(new ParticipantLeftMessage(
MeetingMessageType.PARTICIPANT_LEFT,
meetingId,
removedParticipant.memberId(),
removedParticipant.name(),
java.time.Instant.now().toString()
))
);

List<ParticipantSummary> participants = meetingSocketRoomService.listParticipants(meetingId);
if (!participants.isEmpty()) {
meetingSocketRoomService.broadcastText(
meetingId,
JsonConverter.toJson(RosterMessage.create(meetingId, participants))
);
}
}

private void closeRemovedSessions(List<MeetingParticipant> removedParticipants) {
for (MeetingParticipant participant : removedParticipants) {
try {
if (participant.socketSession().isOpen()) {
participant.socketSession().close(CloseStatus.NORMAL);
}
} catch (IOException exception) {
log.warn("Failed to close kicked participant session: meetingId={}, memberId={}, sessionId={}",
participant.meetingId(), participant.memberId(), participant.sessionId(), exception);
}
}
}

private String buildRoomName(Long meetingId) {
return "meeting-" + meetingId;
}

private String stringValue(Object value) {
return value == null ? null : value.toString();
}

private Integer intValue(Object value) {
if (value instanceof Number number) {
return number.intValue();
}
if (value == null) {
return null;
}
try {
return Integer.parseInt(value.toString());
} catch (NumberFormatException exception) {
return null;
}
}

private Boolean booleanValue(Object value) {
if (value instanceof Boolean bool) {
return bool;
}
if (value == null) {
return null;
}
return Boolean.parseBoolean(value.toString());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ public GitResponse.RepositorySyncResponseDTO syncRepository(Long memberId, Long
GHRepository ghRepository = gitHub.getRepository(repoPath);

// 마지막 동기화 시간 이후의 커밋만 저장
LocalDateTime lastSyncedAt = repository.getLastSyncedAt();
var lastSyncedAt = repository.getLastSyncedAt();
syncCommits(ghRepository, repository, lastSyncedAt);

// 동기화 시간 업데이트
Expand Down
Loading
Loading