From 7a651e112465147b68a9f3845077a275268601d8 Mon Sep 17 00:00:00 2001 From: junyong Date: Thu, 30 Apr 2026 16:58:18 +0900 Subject: [PATCH 1/9] =?UTF-8?q?Feat:=20livekit=20=EB=B0=A9=20=EC=A0=9C?= =?UTF-8?q?=EC=96=B4=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84(=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=EC=9E=90=EC=9A=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/controller/AdminController.java | 61 +++++ .../server/admin/dto/AdminResponse.java | 85 +++++++ .../service/AdminMeetingRoomService.java | 234 ++++++++++++++++++ .../meeting/service/LiveKitTokenService.java | 15 ++ .../external/livekit/LiveKitEgressClient.java | 73 +++++- 5 files changed, 467 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/whylog/server/admin/controller/AdminController.java create mode 100644 src/main/java/com/whylog/server/admin/dto/AdminResponse.java create mode 100644 src/main/java/com/whylog/server/admin/service/AdminMeetingRoomService.java diff --git a/src/main/java/com/whylog/server/admin/controller/AdminController.java b/src/main/java/com/whylog/server/admin/controller/AdminController.java new file mode 100644 index 0000000..a34b811 --- /dev/null +++ b/src/main/java/com/whylog/server/admin/controller/AdminController.java @@ -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 listLiveKitRooms() { + return ApiResponse.onSuccess(adminMeetingRoomService.listLiveKitRooms()); + } + + @DeleteMapping("/livekit/rooms/{roomName}") + @Operation(summary = "LiveKit room 삭제", description = "지정한 LiveKit room을 삭제하고 연결된 참여자를 모두 종료합니다.") + public ApiResponse deleteLiveKitRoom( + @PathVariable String roomName + ) { + return ApiResponse.onSuccess(adminMeetingRoomService.deleteLiveKitRoom(roomName)); + } + + @GetMapping("/livekit/rooms/{roomName}/participants") + @Operation(summary = "LiveKit room 참여자 목록 조회", description = "지정한 LiveKit room에 현재 참여 중인 사용자 목록을 조회합니다.") + public ApiResponse listLiveKitParticipants( + @PathVariable String roomName + ) { + return ApiResponse.onSuccess(adminMeetingRoomService.listLiveKitParticipants(roomName)); + } + + @DeleteMapping("/meeting-rooms/{meetingId}/participants/{memberId}") + @Operation(summary = "관리자용 참여자 강제 제거", description = "지정한 미팅룸에서 특정 참여자를 LiveKit room과 웹소켓 방에서 제거합니다.") + public ApiResponse 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 listWebSocketSessions( + @PathVariable Long meetingId + ) { + return ApiResponse.onSuccess(adminMeetingRoomService.listWebSocketSessions(meetingId)); + } +} diff --git a/src/main/java/com/whylog/server/admin/dto/AdminResponse.java b/src/main/java/com/whylog/server/admin/dto/AdminResponse.java new file mode 100644 index 0000000..efba38d --- /dev/null +++ b/src/main/java/com/whylog/server/admin/dto/AdminResponse.java @@ -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 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 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 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 + ) { + } +} diff --git a/src/main/java/com/whylog/server/admin/service/AdminMeetingRoomService.java b/src/main/java/com/whylog/server/admin/service/AdminMeetingRoomService.java new file mode 100644 index 0000000..7fa0120 --- /dev/null +++ b/src/main/java/com/whylog/server/admin/service/AdminMeetingRoomService.java @@ -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 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 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 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 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 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 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 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 participants = meetingSocketRoomService.listParticipants(meetingId); + if (!participants.isEmpty()) { + meetingSocketRoomService.broadcastText( + meetingId, + JsonConverter.toJson(RosterMessage.create(meetingId, participants)) + ); + } + } + + private void closeRemovedSessions(List 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()); + } +} diff --git a/src/main/java/com/whylog/server/domain/meeting/service/LiveKitTokenService.java b/src/main/java/com/whylog/server/domain/meeting/service/LiveKitTokenService.java index 7ca6706..f19a23c 100644 --- a/src/main/java/com/whylog/server/domain/meeting/service/LiveKitTokenService.java +++ b/src/main/java/com/whylog/server/domain/meeting/service/LiveKitTokenService.java @@ -51,6 +51,21 @@ public String createRoomCreateToken(String identity) { return createToken(identity, "room-admin", videoGrant); } + public String createRoomListToken(String identity) { + Map videoGrant = new LinkedHashMap<>(); + videoGrant.put("roomList", true); + + return createToken(identity, "room-admin", videoGrant); + } + + public String createRoomAdminToken(String identity, String roomName) { + Map videoGrant = new LinkedHashMap<>(); + videoGrant.put("roomAdmin", true); + videoGrant.put("room", roomName); + + return createToken(identity, "room-admin", videoGrant); + } + private String createToken(String identity, String name, Map videoGrant) { return Jwts.builder() .setIssuer(liveKitApiKey) diff --git a/src/main/java/com/whylog/server/global/external/livekit/LiveKitEgressClient.java b/src/main/java/com/whylog/server/global/external/livekit/LiveKitEgressClient.java index 8010dd4..66cdccc 100644 --- a/src/main/java/com/whylog/server/global/external/livekit/LiveKitEgressClient.java +++ b/src/main/java/com/whylog/server/global/external/livekit/LiveKitEgressClient.java @@ -73,13 +73,54 @@ public void stopEgress(String egressToken, String egressId) { post("/twirp/livekit.Egress/StopEgress", egressToken, request); } + public void removeParticipant(String roomAdminToken, String roomName, String identity) { + Map request = new LinkedHashMap<>(); + request.put("room", roomName); + request.put("identity", identity); + post("/twirp/livekit.RoomService/RemoveParticipant", roomAdminToken, request); + } + + @SuppressWarnings("unchecked") + public List> listParticipants(String roomAdminToken, String roomName) { + Map response = post("/twirp/livekit.RoomService/ListParticipants", roomAdminToken, Map.of("room", roomName)); + Object participants = response.get("participants"); + if (participants instanceof List participantList) { + return participantList.stream() + .filter(Map.class::isInstance) + .map(item -> (Map) item) + .toList(); + } + + return List.of(); + } + + public void deleteRoom(String roomCreateToken, String roomName) { + Map request = new LinkedHashMap<>(); + request.put("room", roomName); + post("/twirp/livekit.RoomService/DeleteRoom", roomCreateToken, request); + } + + @SuppressWarnings("unchecked") + public List> listRooms(String roomListToken) { + Map response = post("/twirp/livekit.RoomService/ListRooms", roomListToken, Map.of()); + Object rooms = response.get("rooms"); + if (rooms instanceof List roomList) { + return roomList.stream() + .filter(Map.class::isInstance) + .map(item -> (Map) item) + .toList(); + } + + return List.of(); + } + @SuppressWarnings("unchecked") private Map post(String path, String egressToken, Map request) { String apiBaseUrl = toHttpsBaseUrl(liveKitUrl); URI uri = URI.create(apiBaseUrl + path); Object body = request; - String requestJson = JsonConverter.toJson(request); + String requestJson = JsonConverter.toJson(redactForLogging(request)); log.info("LiveKit egress request: {}", requestJson); Map response = restClient.post() @@ -98,6 +139,36 @@ private Map post(String path, String egressToken, Map redactForLogging(Map request) { + Map sanitized = new LinkedHashMap<>(); + request.forEach((key, value) -> sanitized.put(key, redactValue(key, value))); + return sanitized; + } + + private Object redactValue(String key, Object value) { + if ("access_key".equals(key) || "secret".equals(key)) { + return "***"; + } + + if (value instanceof Map map) { + Map sanitized = new LinkedHashMap<>(); + map.forEach((nestedKey, nestedValue) -> + sanitized.put(String.valueOf(nestedKey), redactValue(String.valueOf(nestedKey), nestedValue)) + ); + return sanitized; + } + + if (value instanceof List list) { + return list.stream() + .map(item -> item instanceof Map mapItem + ? redactForLogging((Map) mapItem) + : item) + .toList(); + } + + return value; + } + private Map buildFileOutput(String recordingKey) { Map s3 = new LinkedHashMap<>(); s3.put("access_key", accessKey); From b7c9c699442dc2d09c597fdfe66221ef40c6abc1 Mon Sep 17 00:00:00 2001 From: junyong Date: Thu, 30 Apr 2026 16:59:51 +0900 Subject: [PATCH 2/9] =?UTF-8?q?Fix:=20=ED=9A=8C=EC=9D=98=EB=B0=A9=20?= =?UTF-8?q?=EC=B0=B8=EC=97=AC=EC=9E=90=200=EB=AA=85=EC=9D=B4=EB=A9=B4=20?= =?UTF-8?q?=ED=9A=8C=EC=9D=98=20=EC=9E=90=EB=8F=99=20=EC=A2=85=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .DS_Store | Bin 6148 -> 6148 bytes .../service/MeetingCommandService.java | 51 ++++++++++++------ .../meeting/socket/MeetingSocketHandler.java | 6 +++ .../repository/MeetingRoomRepository.java | 19 +++++++ .../MeetingSocketRoomRepository.java | 7 +++ 5 files changed, 67 insertions(+), 16 deletions(-) diff --git a/.DS_Store b/.DS_Store index 5008ddfcf53c02e82d7eee2e57c38e5672ef89f6..35fae2c184d5ee01fcdbaf963b52bc97dae6a764 100644 GIT binary patch literal 6148 zcmeHKzb`{k6h2p58bTVA0eK0tV8Qmtv)MYMtrkt%RQ%ZA;tvoc7{qQi*bEX2gUP>O zC9(U?y;pm#*Jl)wdy;#<*Yo3@uiw*qJR(v{2h|y(0ukl07_an|6Fl+%#F(ar<=#CwFj+(sF(e_XUP7!wG#L5#b0Gx zMah8Z`t5sMav+Ll?Xz(1Az7s5NFGFY%p*g4;IZK_$Q|}}A}PIXT}oFQb@~lnalhX( z%xt#&fI+`|1-t@YfwlsCJ_J~dp~cvsK02`JR{&rZZf&^MvjZ5g0~lJ24Z;IcrW9yO zm3_rfrX2pj=7knxgQlF6?u`4`&dR=_n8^iypwmf(2L0|8@CvvJWX#6`&;RB0_kXv@ zfAR`=1^$%+DjOA}C4412TN7W7XKjF0!eZmR*q|=KX2-F<;88q+r46xwFMy%N*dRPG P|Brx{!EauHUsd1}E^gc) delta 65 zcmZoMXfc=|#>AjHF;Q%yo+1YW5HK<@2y7PQ5M$X`FpGIJI|n}pP#!4ooq009h$187 SWK$94$^JYXn`1;)FarR`U=BS1 diff --git a/src/main/java/com/whylog/server/domain/meeting/service/MeetingCommandService.java b/src/main/java/com/whylog/server/domain/meeting/service/MeetingCommandService.java index a2fd781..9fea6e7 100644 --- a/src/main/java/com/whylog/server/domain/meeting/service/MeetingCommandService.java +++ b/src/main/java/com/whylog/server/domain/meeting/service/MeetingCommandService.java @@ -109,25 +109,21 @@ public MeetingResponse.MeetingEndResponseDTO endMeeting(Long memberId, Long meet throw new MeetingAlreadyEndedException(); } - // 정보 갱신 - LocalDateTime endDateTime = meeting.endMeeting(); - meetingRepository.save(meeting); + return finishMeeting(meeting, true); + } - // 웹소켓 메시지 전송 - meetingSocketRoomService.broadcastMeetingEnded(meetingId, endDateTime); // 회의 참여한 사람들에게 알림 - stopRecording(meeting); - meetingSocketRoomService.closeRoom(meetingId); // 메모리 내의 실시간 회의 정보 제거 + @Transactional + public void autoEndMeetingIfEmpty(Long meetingId) { + if (!meetingSocketRoomService.listParticipants(meetingId).isEmpty()) { + return; + } - scheduleAfterCommit(() -> CompletableFuture.runAsync(() -> meetingAnalysisService.analyzeMeetingAudio(meeting.getId())) - .exceptionally(ex -> { - log.error("회의 오디오 분석 실패: meetingId={}", meeting.getId(), ex); - return null; - })); + Meeting meeting = meetingRepository.findById(meetingId).orElse(null); + if (meeting == null || !meeting.isOngoing()) { + return; + } - return MeetingResponse.MeetingEndResponseDTO.builder() - .meetingId(meeting.getId()) - .endDateTime(endDateTime) - .build(); + finishMeeting(meeting, false); } @Transactional @@ -175,4 +171,27 @@ private void stopRecording(Meeting meeting) { liveKitEgressClient.stopEgress(egressToken, meeting.getAudioEgressId()); } + private MeetingResponse.MeetingEndResponseDTO finishMeeting(Meeting meeting, boolean broadcastEnded) { + LocalDateTime endDateTime = meeting.endMeeting(); + meetingRepository.save(meeting); + + if (broadcastEnded) { + meetingSocketRoomService.broadcastMeetingEnded(meeting.getId(), endDateTime); + } + + stopRecording(meeting); + meetingSocketRoomService.closeRoom(meeting.getId()); + + scheduleAfterCommit(() -> CompletableFuture.runAsync(() -> meetingAnalysisService.analyzeMeetingAudio(meeting.getId())) + .exceptionally(ex -> { + log.error("회의 오디오 분석 실패: meetingId={}", meeting.getId(), ex); + return null; + })); + + return MeetingResponse.MeetingEndResponseDTO.builder() + .meetingId(meeting.getId()) + .endDateTime(endDateTime) + .build(); + } + } diff --git a/src/main/java/com/whylog/server/domain/meeting/socket/MeetingSocketHandler.java b/src/main/java/com/whylog/server/domain/meeting/socket/MeetingSocketHandler.java index c347ede..dc8e55a 100644 --- a/src/main/java/com/whylog/server/domain/meeting/socket/MeetingSocketHandler.java +++ b/src/main/java/com/whylog/server/domain/meeting/socket/MeetingSocketHandler.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.whylog.server.domain.meeting.socket.message.*; +import com.whylog.server.domain.meeting.service.MeetingCommandService; import com.whylog.server.global.util.json.JsonConverter; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -29,6 +30,7 @@ public class MeetingSocketHandler extends BinaryWebSocketHandler { private static final int SESSION_BUFFER_SIZE_LIMIT_BYTES = 512 * 1024; private final MeetingSocketRoomService meetingSocketRoomService; + private final MeetingCommandService meetingCommandService; // 웹소켓 연결 직후 참가자를 방에 등록하고 현재 참여자 목록과 입장 이벤트를 전파합니다. @Override @@ -173,6 +175,10 @@ private void removeParticipant(WebSocketSession session) { now() ))); broadcastRoster(meetingId); + + if (meetingSocketRoomService.listParticipants(meetingId).isEmpty()) { + meetingCommandService.autoEndMeetingIfEmpty(meetingId); + } } // 현재 회의 참가자 목록을 모든 클라이언트에 전파합니다. diff --git a/src/main/java/com/whylog/server/domain/meeting/socket/repository/MeetingRoomRepository.java b/src/main/java/com/whylog/server/domain/meeting/socket/repository/MeetingRoomRepository.java index b781d90..c949468 100644 --- a/src/main/java/com/whylog/server/domain/meeting/socket/repository/MeetingRoomRepository.java +++ b/src/main/java/com/whylog/server/domain/meeting/socket/repository/MeetingRoomRepository.java @@ -2,6 +2,7 @@ import com.whylog.server.domain.meeting.socket.MeetingParticipant; +import java.util.ArrayList; import java.util.Collection; import java.util.List; import java.util.Map; @@ -40,6 +41,24 @@ public MeetingParticipant removeParticipant(String sessionId) { return removed; } + // 특정 멤버의 모든 연결 세션을 회의방에서 제거합니다. + public List removeParticipantsByMemberId(Long memberId) { + Set sessionIds = sessionIdsByMemberId.remove(memberId); + if (sessionIds == null || sessionIds.isEmpty()) { + return List.of(); + } + + List removedParticipants = new ArrayList<>(); + for (String sessionId : new ArrayList<>(sessionIds)) { + MeetingParticipant removed = participantsBySessionId.remove(sessionId); + if (removed != null) { + removedParticipants.add(removed); + } + } + + return removedParticipants; + } + // 현재 회의방에 연결된 전체 참가자 세션 목록을 반환합니다. public Collection participants() { return participantsBySessionId.values(); diff --git a/src/main/java/com/whylog/server/domain/meeting/socket/repository/MeetingSocketRoomRepository.java b/src/main/java/com/whylog/server/domain/meeting/socket/repository/MeetingSocketRoomRepository.java index 354279b..a935bc5 100644 --- a/src/main/java/com/whylog/server/domain/meeting/socket/repository/MeetingSocketRoomRepository.java +++ b/src/main/java/com/whylog/server/domain/meeting/socket/repository/MeetingSocketRoomRepository.java @@ -2,6 +2,8 @@ import org.springframework.stereotype.Repository; +import java.util.ArrayList; +import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -26,4 +28,9 @@ public MeetingRoomRepository findByMeetingId(Long meetingId) { public void delete(Long meetingId) { rooms.remove(meetingId); } + + // 현재 메모리에 존재하는 회의방 id 목록을 반환합니다. + public List findAllMeetingIds() { + return new ArrayList<>(rooms.keySet()); + } } From 827f144de0bddf4c117940fd3833a0e18b73feb4 Mon Sep 17 00:00:00 2001 From: junyong Date: Thu, 30 Apr 2026 17:16:54 +0900 Subject: [PATCH 3/9] =?UTF-8?q?Chore:=20=ED=95=9C=EA=B5=AD=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=EC=9C=BC=EB=A1=9C=20=EB=B0=98=ED=99=98=20=EB=B0=8F=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../git/service/GitCommandServiceImpl.java | 2 +- .../meeting/socket/util/WebSocketTimeUtil.java | 9 +++++++-- .../server/global/config/TimeZoneConfig.java | 17 +++++++++++++++++ src/main/resources/application.yaml | 2 ++ 4 files changed, 27 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/whylog/server/global/config/TimeZoneConfig.java diff --git a/src/main/java/com/whylog/server/domain/git/service/GitCommandServiceImpl.java b/src/main/java/com/whylog/server/domain/git/service/GitCommandServiceImpl.java index 97c991d..4748bb8 100644 --- a/src/main/java/com/whylog/server/domain/git/service/GitCommandServiceImpl.java +++ b/src/main/java/com/whylog/server/domain/git/service/GitCommandServiceImpl.java @@ -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); // 동기화 시간 업데이트 diff --git a/src/main/java/com/whylog/server/domain/meeting/socket/util/WebSocketTimeUtil.java b/src/main/java/com/whylog/server/domain/meeting/socket/util/WebSocketTimeUtil.java index 527d211..9c2a08a 100644 --- a/src/main/java/com/whylog/server/domain/meeting/socket/util/WebSocketTimeUtil.java +++ b/src/main/java/com/whylog/server/domain/meeting/socket/util/WebSocketTimeUtil.java @@ -1,13 +1,18 @@ package com.whylog.server.domain.meeting.socket.util; -import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.time.ZonedDateTime; // 웹소켓 연산 담당 클래스 public class WebSocketTimeUtil { + private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ISO_OFFSET_DATE_TIME; + // 웹소켓 메시지에 사용할 현재 시각 문자열을 생성합니다. public static String now() { - return Instant.now().toString(); + return ZonedDateTime.now(ZoneId.systemDefault()) + .format(FORMATTER); } } diff --git a/src/main/java/com/whylog/server/global/config/TimeZoneConfig.java b/src/main/java/com/whylog/server/global/config/TimeZoneConfig.java new file mode 100644 index 0000000..6f49826 --- /dev/null +++ b/src/main/java/com/whylog/server/global/config/TimeZoneConfig.java @@ -0,0 +1,17 @@ +package com.whylog.server.global.config; + +import jakarta.annotation.PostConstruct; +import org.springframework.context.annotation.Configuration; + +import java.util.TimeZone; + +@Configuration +public class TimeZoneConfig { + + private static final String KOREA_TIME_ZONE = "Asia/Seoul"; + + @PostConstruct + public void setDefaultTimeZone() { + TimeZone.setDefault(TimeZone.getTimeZone(KOREA_TIME_ZONE)); + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index b9ec353..df25158 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -7,6 +7,7 @@ spring: jackson: property-naming-strategy: SNAKE_CASE + time-zone: Asia/Seoul datasource: driver-class-name: com.mysql.cj.jdbc.Driver @@ -30,6 +31,7 @@ spring: hibernate: dialect: org.hibernate.dialect.MySQL8Dialect format_sql: true + jdbc.time_zone: Asia/Seoul data: redis: From 25632235d41dd3971625f8a466d5cc7bc9a19c3e Mon Sep 17 00:00:00 2001 From: junyong Date: Thu, 30 Apr 2026 17:18:44 +0900 Subject: [PATCH 4/9] =?UTF-8?q?Fix:=20=ED=9A=8C=EC=9D=98=EB=B0=A9=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=B5=9C=EC=8B=A0=EC=88=9C=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/domain/meeting/repository/MeetingRepository.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/whylog/server/domain/meeting/repository/MeetingRepository.java b/src/main/java/com/whylog/server/domain/meeting/repository/MeetingRepository.java index 6b2804f..b4f27cb 100644 --- a/src/main/java/com/whylog/server/domain/meeting/repository/MeetingRepository.java +++ b/src/main/java/com/whylog/server/domain/meeting/repository/MeetingRepository.java @@ -15,6 +15,7 @@ public interface MeetingRepository extends JpaRepository { SELECT m FROM Meeting m WHERE m.team.id = :teamId + ORDER BY m.startDateTime DESC """) List findByTeamId(@Param("teamId") Long teamId); From 5be89db0753fbdfc680a8c855baa3c6dc54a8c2b Mon Sep 17 00:00:00 2001 From: junyong Date: Thu, 30 Apr 2026 17:38:44 +0900 Subject: [PATCH 5/9] =?UTF-8?q?Chore:=20api=20=ED=98=B8=EC=B6=9C=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=20=EC=B6=9C=EB=A0=A5=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 1 + .../server/global/aop/ApiLoggingAspect.java | 99 +++++++++++++++++++ .../server/global/aop/LoggingMessages.java | 19 ++++ .../server/global/aop/LoggingWriter.java | 93 +++++++++++++++++ .../global/aop/MethodLoggingAspect.java | 86 ++++++++++++++++ .../whylog/server/global/aop/Pointcuts.java | 19 ++++ .../global/aop/annotation/LogExecution.java | 13 +++ 7 files changed, 330 insertions(+) create mode 100644 src/main/java/com/whylog/server/global/aop/ApiLoggingAspect.java create mode 100644 src/main/java/com/whylog/server/global/aop/LoggingMessages.java create mode 100644 src/main/java/com/whylog/server/global/aop/LoggingWriter.java create mode 100644 src/main/java/com/whylog/server/global/aop/MethodLoggingAspect.java create mode 100644 src/main/java/com/whylog/server/global/aop/Pointcuts.java create mode 100644 src/main/java/com/whylog/server/global/aop/annotation/LogExecution.java diff --git a/build.gradle b/build.gradle index 0af49f5..cf40a0b 100644 --- a/build.gradle +++ b/build.gradle @@ -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' diff --git a/src/main/java/com/whylog/server/global/aop/ApiLoggingAspect.java b/src/main/java/com/whylog/server/global/aop/ApiLoggingAspect.java new file mode 100644 index 0000000..885b837 --- /dev/null +++ b/src/main/java/com/whylog/server/global/aop/ApiLoggingAspect.java @@ -0,0 +1,99 @@ +package com.whylog.server.global.aop; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.Signature; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.core.annotation.Order; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestAttributes; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Slf4j +@Aspect +@Component +@Order(0) +@RequiredArgsConstructor +public class ApiLoggingAspect { + + private static final String REQUEST_TRACE_ID_ATTRIBUTE = "apiRequestTraceId"; + + private final LoggingWriter loggingWriter; + + @Around("com.whylog.server.global.aop.Pointcuts.restControllerMethods()") + public Object logApiCall(ProceedingJoinPoint joinPoint) throws Throwable { + ServletRequestAttributes requestAttributes = currentRequestAttributes(); + if (requestAttributes == null) { + return joinPoint.proceed(); + } + + HttpServletRequest request = requestAttributes.getRequest(); + HttpServletResponse response = requestAttributes.getResponse(); + + String requestTraceId = UUID.randomUUID().toString(); + request.setAttribute(REQUEST_TRACE_ID_ATTRIBUTE, requestTraceId); + + LocalDateTime startAt = LocalDateTime.now(); + long startNano = System.nanoTime(); + String memberInfo = resolveMemberInfo(); + String apiName = resolveApiName(joinPoint); + String httpMethod = request.getMethod(); + String requestUri = request.getRequestURI(); + + loggingWriter.logApiStart(requestTraceId, memberInfo, apiName, httpMethod, requestUri, startAt); + + Throwable failure = null; + try { + return joinPoint.proceed(); + } catch (Throwable ex) { + failure = ex; + throw ex; + } finally { + LocalDateTime endAt = LocalDateTime.now(); + long elapsedMs = (System.nanoTime() - startNano) / 1_000_000; + String outcome = failure == null ? "SUCCESS" : "FAILURE"; + Integer status = failure == null + ? (response != null ? response.getStatus() : null) + : HttpServletResponse.SC_INTERNAL_SERVER_ERROR; + + loggingWriter.logApiEnd(requestTraceId, memberInfo, apiName, httpMethod, requestUri, endAt, elapsedMs, status, outcome); + } + } + + private ServletRequestAttributes currentRequestAttributes() { + RequestAttributes attributes = RequestContextHolder.getRequestAttributes(); + if (attributes instanceof ServletRequestAttributes servletRequestAttributes) { + return servletRequestAttributes; + } + return null; + } + + private String resolveApiName(ProceedingJoinPoint joinPoint) { + Signature signature = joinPoint.getSignature(); + if (signature instanceof MethodSignature methodSignature) { + return methodSignature.getDeclaringType().getSimpleName() + "." + methodSignature.getName(); + } + return signature.toShortString(); + } + + private String resolveMemberInfo() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !authentication.isAuthenticated()) { + return "anonymous"; + } + + Object principal = authentication.getPrincipal(); + return principal == null ? "anonymous" : principal.toString(); + } +} diff --git a/src/main/java/com/whylog/server/global/aop/LoggingMessages.java b/src/main/java/com/whylog/server/global/aop/LoggingMessages.java new file mode 100644 index 0000000..81d2924 --- /dev/null +++ b/src/main/java/com/whylog/server/global/aop/LoggingMessages.java @@ -0,0 +1,19 @@ +package com.whylog.server.global.aop; + +public enum LoggingMessages { + + API_START("[API START] requestId={} member={} api={} method={} uri={} startedAt={}"), + API_END("[API END] requestId={} member={} api={} method={} uri={} endedAt={} durationMs={} status={} outcome={}"), + METHOD_START("[METHOD START] requestId={} methodId={} member={} method={} startedAt={}"), + METHOD_END("[METHOD END] requestId={} methodId={} member={} method={} endedAt={} durationMs={} outcome={}"); + + private final String template; + + LoggingMessages(String template) { + this.template = template; + } + + public String template() { + return template; + } +} diff --git a/src/main/java/com/whylog/server/global/aop/LoggingWriter.java b/src/main/java/com/whylog/server/global/aop/LoggingWriter.java new file mode 100644 index 0000000..c74a65f --- /dev/null +++ b/src/main/java/com/whylog/server/global/aop/LoggingWriter.java @@ -0,0 +1,93 @@ +package com.whylog.server.global.aop; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; + +@Slf4j +@Component +public class LoggingWriter { + + public void logApiStart( + String requestId, + String memberInfo, + String apiName, + String httpMethod, + String requestUri, + LocalDateTime startAt + ) { + log.info( + LoggingMessages.API_START.template(), + requestId, + memberInfo, + apiName, + httpMethod, + requestUri, + startAt + ); + } + + public void logApiEnd( + String requestId, + String memberInfo, + String apiName, + String httpMethod, + String requestUri, + LocalDateTime endAt, + long durationMs, + Integer status, + String outcome + ) { + log.info( + LoggingMessages.API_END.template(), + requestId, + memberInfo, + apiName, + httpMethod, + requestUri, + endAt, + durationMs, + status, + outcome + ); + } + + public void logMethodStart( + String requestId, + String methodId, + String memberInfo, + String methodName, + LocalDateTime startAt + ) { + log.info( + LoggingMessages.METHOD_START.template(), + requestId, + methodId, + memberInfo, + methodName, + startAt + ); + } + + public void logMethodEnd( + String requestId, + String methodId, + String memberInfo, + String methodName, + LocalDateTime endAt, + long durationMs, + String outcome + ) { + log.info( + LoggingMessages.METHOD_END.template(), + requestId, + methodId, + memberInfo, + methodName, + endAt, + durationMs, + outcome + ); + } +} diff --git a/src/main/java/com/whylog/server/global/aop/MethodLoggingAspect.java b/src/main/java/com/whylog/server/global/aop/MethodLoggingAspect.java new file mode 100644 index 0000000..b70bf80 --- /dev/null +++ b/src/main/java/com/whylog/server/global/aop/MethodLoggingAspect.java @@ -0,0 +1,86 @@ +package com.whylog.server.global.aop; + +import com.whylog.server.global.aop.annotation.LogExecution; +import lombok.RequiredArgsConstructor; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.Signature; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.core.annotation.Order; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestAttributes; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Aspect +@Component +@Order(1) +@RequiredArgsConstructor +public class MethodLoggingAspect { + + private static final String REQUEST_TRACE_ID_ATTRIBUTE = "apiRequestTraceId"; + + private final LoggingWriter loggingWriter; + + @Around("com.whylog.server.global.aop.Pointcuts.logExecutionMethods(logExecution)") + public Object logMethodCall(ProceedingJoinPoint joinPoint, LogExecution logExecution) throws Throwable { + String methodTraceId = UUID.randomUUID().toString(); + String requestTraceId = resolveRequestTraceId(); + LocalDateTime startAt = LocalDateTime.now(); + long startNano = System.nanoTime(); + + String memberInfo = resolveMemberInfo(); + String methodName = resolveMethodName(joinPoint); + + loggingWriter.logMethodStart(requestTraceId, methodTraceId, memberInfo, methodName, startAt); + + Throwable failure = null; + try { + return joinPoint.proceed(); + } catch (Throwable ex) { + failure = ex; + throw ex; + } finally { + LocalDateTime endAt = LocalDateTime.now(); + long elapsedMs = (System.nanoTime() - startNano) / 1_000_000; + String outcome = failure == null ? "SUCCESS" : "FAILURE"; + + loggingWriter.logMethodEnd(requestTraceId, methodTraceId, memberInfo, methodName, endAt, elapsedMs, outcome); + } + } + + private String resolveRequestTraceId() { + RequestAttributes attributes = RequestContextHolder.getRequestAttributes(); + if (attributes instanceof ServletRequestAttributes servletRequestAttributes) { + Object requestTraceId = servletRequestAttributes.getRequest().getAttribute(REQUEST_TRACE_ID_ATTRIBUTE); + if (requestTraceId != null) { + return requestTraceId.toString(); + } + } + return "-"; + } + + private String resolveMethodName(ProceedingJoinPoint joinPoint) { + Signature signature = joinPoint.getSignature(); + if (signature instanceof MethodSignature methodSignature) { + return methodSignature.getDeclaringType().getSimpleName() + "." + methodSignature.getName(); + } + return signature.toShortString(); + } + + private String resolveMemberInfo() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !authentication.isAuthenticated()) { + return "anonymous"; + } + + Object principal = authentication.getPrincipal(); + return principal == null ? "anonymous" : principal.toString(); + } +} diff --git a/src/main/java/com/whylog/server/global/aop/Pointcuts.java b/src/main/java/com/whylog/server/global/aop/Pointcuts.java new file mode 100644 index 0000000..e0fbc3f --- /dev/null +++ b/src/main/java/com/whylog/server/global/aop/Pointcuts.java @@ -0,0 +1,19 @@ +package com.whylog.server.global.aop; + +import com.whylog.server.global.aop.annotation.LogExecution; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Pointcut; +import org.springframework.stereotype.Component; + +@Aspect +@Component +public class Pointcuts { + + @Pointcut("within(@org.springframework.web.bind.annotation.RestController *)") + public void restControllerMethods() { + } + + @Pointcut("@annotation(logExecution)") + public void logExecutionMethods(LogExecution logExecution) { + } +} diff --git a/src/main/java/com/whylog/server/global/aop/annotation/LogExecution.java b/src/main/java/com/whylog/server/global/aop/annotation/LogExecution.java new file mode 100644 index 0000000..c619346 --- /dev/null +++ b/src/main/java/com/whylog/server/global/aop/annotation/LogExecution.java @@ -0,0 +1,13 @@ +package com.whylog.server.global.aop.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface LogExecution { +} From fb91e482791e988c3be11be40fe94a0145706654 Mon Sep 17 00:00:00 2001 From: junyong Date: Thu, 30 Apr 2026 18:12:49 +0900 Subject: [PATCH 6/9] =?UTF-8?q?Refactor:=20=ED=9A=8C=EC=9D=98=EB=B0=A9=20?= =?UTF-8?q?=EC=BF=BC=EB=A6=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../meeting/repository/MeetingAnalysisRepository.java | 9 --------- .../domain/meeting/repository/MeetingRepository.java | 10 +++++++++- .../domain/meeting/service/MeetingQueryService.java | 5 ++--- .../server/domain/meeting/service/MeetingUseCase.java | 9 ++++----- 4 files changed, 15 insertions(+), 18 deletions(-) diff --git a/src/main/java/com/whylog/server/domain/meeting/repository/MeetingAnalysisRepository.java b/src/main/java/com/whylog/server/domain/meeting/repository/MeetingAnalysisRepository.java index 6842bed..3b3d6fc 100644 --- a/src/main/java/com/whylog/server/domain/meeting/repository/MeetingAnalysisRepository.java +++ b/src/main/java/com/whylog/server/domain/meeting/repository/MeetingAnalysisRepository.java @@ -6,8 +6,6 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; -import java.util.Optional; - public interface MeetingAnalysisRepository extends JpaRepository { @Modifying @@ -17,11 +15,4 @@ public interface MeetingAnalysisRepository extends JpaRepository findByMeetingId(@Param("meetingId") Long meetingId); } diff --git a/src/main/java/com/whylog/server/domain/meeting/repository/MeetingRepository.java b/src/main/java/com/whylog/server/domain/meeting/repository/MeetingRepository.java index b4f27cb..d8912a8 100644 --- a/src/main/java/com/whylog/server/domain/meeting/repository/MeetingRepository.java +++ b/src/main/java/com/whylog/server/domain/meeting/repository/MeetingRepository.java @@ -14,10 +14,11 @@ public interface MeetingRepository extends JpaRepository { @Query(""" SELECT m FROM Meeting m + LEFT JOIN FETCH m.meetingAnalysis WHERE m.team.id = :teamId ORDER BY m.startDateTime DESC """) - List findByTeamId(@Param("teamId") Long teamId); + List findWithAnalysis(@Param("teamId") Long teamId); @Query(""" SELECT DISTINCT m @@ -42,5 +43,12 @@ public interface MeetingRepository extends JpaRepository { """) void deleteByTeamId(@Param("teamId") Long teamId); + @Query(""" + SELECT m FROM Meeting m + LEFT JOIN FETCH m.meetingAnalysis ma + WHERE m.id = :meetingId + """) + Optional findByMeetingId(@Param("meetingId") Long meetingId); + } diff --git a/src/main/java/com/whylog/server/domain/meeting/service/MeetingQueryService.java b/src/main/java/com/whylog/server/domain/meeting/service/MeetingQueryService.java index 132c2f7..f2a1495 100644 --- a/src/main/java/com/whylog/server/domain/meeting/service/MeetingQueryService.java +++ b/src/main/java/com/whylog/server/domain/meeting/service/MeetingQueryService.java @@ -85,10 +85,9 @@ public MeetingResponse.AudioDTO getMeetingAudio(Long meetingId) { @Transactional(readOnly = true) public MeetingResponse.AnalysisResultDTO getAnalysis(Long meetingId) { - meetingUseCase.findMeetingById(meetingId); // 없으면 그거에 따른 예외 발생 + Meeting meeting = meetingUseCase.findWithAnalysisByMeetingId(meetingId); - MeetingAnalysis meetingAnalysis = meetingUseCase.findAnalysisByMeetingId(meetingId) - .orElse(null); + MeetingAnalysis meetingAnalysis = meeting.getMeetingAnalysis(); if(meetingAnalysis == null) // null이면 isAnalyzed = false인 응답 반환 return MeetingResponse.AnalysisResultDTO.createFalse(meetingId); diff --git a/src/main/java/com/whylog/server/domain/meeting/service/MeetingUseCase.java b/src/main/java/com/whylog/server/domain/meeting/service/MeetingUseCase.java index 63c7cde..5a972fa 100644 --- a/src/main/java/com/whylog/server/domain/meeting/service/MeetingUseCase.java +++ b/src/main/java/com/whylog/server/domain/meeting/service/MeetingUseCase.java @@ -1,7 +1,6 @@ package com.whylog.server.domain.meeting.service; import com.whylog.server.domain.meeting.entity.Meeting; -import com.whylog.server.domain.meeting.entity.MeetingAnalysis; import com.whylog.server.domain.meeting.entity.MeetingMember; import com.whylog.server.domain.meeting.exception.MeetingNotFoundException; import com.whylog.server.domain.meeting.repository.MeetingAnalysisRepository; @@ -11,7 +10,6 @@ import org.springframework.stereotype.Service; import java.util.List; -import java.util.Optional; @Service @RequiredArgsConstructor @@ -31,7 +29,7 @@ public Meeting findMeetingWithMembersById(Long id){ } public List findMeetingByTeamId(Long teamId){ - return meetingRepository.findByTeamId(teamId); + return meetingRepository.findWithAnalysis(teamId); } // 회의 참여자 수 @@ -46,8 +44,9 @@ public List getParticipantsInfo(Meeting meeting){ .toList(); } - public Optional findAnalysisByMeetingId(Long meetingId) { - return meetingAnalysisRepository.findByMeetingId(meetingId); + public Meeting findWithAnalysisByMeetingId(Long meetingId) { + return meetingRepository.findByMeetingId(meetingId) + .orElseThrow(MeetingNotFoundException::new); } } From d9c730933b523406233cecd005401b88a1bc0404 Mon Sep 17 00:00:00 2001 From: junyong Date: Thu, 30 Apr 2026 18:59:18 +0900 Subject: [PATCH 7/9] =?UTF-8?q?Fix:=20=ED=9A=8C=EC=9D=98=20=EC=8B=A4?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=20=EC=A4=91=EB=B3=B5=20=EC=B0=B8=EC=97=AC=20?= =?UTF-8?q?=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MeetingAlreadyParticipatingException.java | 10 ++++++++++ .../domain/meeting/exception/MeetingErrorCode.java | 1 + .../meeting/socket/MeetingSocketAuthInterceptor.java | 2 ++ .../domain/meeting/socket/MeetingSocketHandler.java | 11 ++++++++++- .../meeting/socket/MeetingSocketRoomService.java | 10 ++++++++++ .../meeting/socket/message/MeetingMessageType.java | 1 + 6 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/whylog/server/domain/meeting/exception/MeetingAlreadyParticipatingException.java diff --git a/src/main/java/com/whylog/server/domain/meeting/exception/MeetingAlreadyParticipatingException.java b/src/main/java/com/whylog/server/domain/meeting/exception/MeetingAlreadyParticipatingException.java new file mode 100644 index 0000000..6dea462 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/meeting/exception/MeetingAlreadyParticipatingException.java @@ -0,0 +1,10 @@ +package com.whylog.server.domain.meeting.exception; + +import com.whylog.server.global.apiPayload.exception.GeneralException; + +public class MeetingAlreadyParticipatingException extends GeneralException { + + public MeetingAlreadyParticipatingException() { + super(MeetingErrorCode.MEETING_ALREADY_PARTICIPATING); + } +} diff --git a/src/main/java/com/whylog/server/domain/meeting/exception/MeetingErrorCode.java b/src/main/java/com/whylog/server/domain/meeting/exception/MeetingErrorCode.java index 3b1d0c5..31b10ff 100644 --- a/src/main/java/com/whylog/server/domain/meeting/exception/MeetingErrorCode.java +++ b/src/main/java/com/whylog/server/domain/meeting/exception/MeetingErrorCode.java @@ -15,6 +15,7 @@ public enum MeetingErrorCode implements BaseErrorCode { MEETING_INVALID_MEMBER(HttpStatus.CONFLICT, "MEETING_410", "회의에 소속된 참여자가 아닙니다."), MEETING_NOT_OWNER(HttpStatus.FORBIDDEN, "MEETING_403", "회의 삭제 권한이 없습니다."), MEETING_AUDIO_NOT_READY(HttpStatus.CONFLICT, "MEETING_411", "회의 녹음본이 아직 생성되지 않았거나 업로드가 완료되지 않았습니다."), + MEETING_ALREADY_PARTICIPATING(HttpStatus.CONFLICT, "MEETING_412", "이미 실시간으로 참여 중인 회의입니다."), ; private final HttpStatus httpStatus; diff --git a/src/main/java/com/whylog/server/domain/meeting/socket/MeetingSocketAuthInterceptor.java b/src/main/java/com/whylog/server/domain/meeting/socket/MeetingSocketAuthInterceptor.java index 43c69fd..3ff1cff 100644 --- a/src/main/java/com/whylog/server/domain/meeting/socket/MeetingSocketAuthInterceptor.java +++ b/src/main/java/com/whylog/server/domain/meeting/socket/MeetingSocketAuthInterceptor.java @@ -2,6 +2,7 @@ import com.whylog.server.domain.user.entity.Member; import com.whylog.server.domain.user.service.MemberUseCase; +import com.whylog.server.domain.meeting.socket.MeetingSocketRoomService; import com.whylog.server.global.auth.jwt.provider.JwtTokenProvider; import com.whylog.server.global.auth.jwt.provider.JwtValidationType; import jakarta.servlet.http.HttpServletResponse; @@ -32,6 +33,7 @@ public class MeetingSocketAuthInterceptor implements HandshakeInterceptor { private final JwtTokenProvider jwtTokenProvider; private final MemberUseCase memberUseCase; + private final MeetingSocketRoomService meetingSocketRoomService; // 웹소켓 연결 전에 meetingId, accessToken, 표시 이름을 확인하고 세션 속성을 초기화합니다. @Override diff --git a/src/main/java/com/whylog/server/domain/meeting/socket/MeetingSocketHandler.java b/src/main/java/com/whylog/server/domain/meeting/socket/MeetingSocketHandler.java index dc8e55a..3ccb5cf 100644 --- a/src/main/java/com/whylog/server/domain/meeting/socket/MeetingSocketHandler.java +++ b/src/main/java/com/whylog/server/domain/meeting/socket/MeetingSocketHandler.java @@ -41,6 +41,11 @@ public void afterConnectionEstablished(@NonNull WebSocketSession session) throws return; } + if (meetingSocketRoomService.existsParticipant(participant.meetingId(), participant.memberId())) { + sendError(session, MeetingMessageType.PARTICIPANT_ALREADY_JOINED, "이미 실시간으로 참여 중인 회의입니다."); + session.close(CloseStatus.NORMAL); + return; + } meetingSocketRoomService.join(participant); ConnectedMessage connectedMessage = ConnectedMessage.create( @@ -208,11 +213,15 @@ private MeetingParticipant createParticipant(WebSocketSession session, WebSocket // 잘못된 요청이나 지원하지 않는 타입에 대한 에러 메시지를 클라이언트에 보냅니다. private void sendError(WebSocketSession session, String message) { + sendError(session, MeetingMessageType.ERROR, message); + } + + private void sendError(WebSocketSession session, MeetingMessageType type, String message) { try { session.sendMessage(new TextMessage( JsonConverter.toJson( new ErrorMessage( - MeetingMessageType.ERROR, message + type, message ) ))); } catch (Exception exception) { diff --git a/src/main/java/com/whylog/server/domain/meeting/socket/MeetingSocketRoomService.java b/src/main/java/com/whylog/server/domain/meeting/socket/MeetingSocketRoomService.java index cf5cd52..ad8cb45 100644 --- a/src/main/java/com/whylog/server/domain/meeting/socket/MeetingSocketRoomService.java +++ b/src/main/java/com/whylog/server/domain/meeting/socket/MeetingSocketRoomService.java @@ -74,6 +74,16 @@ public MeetingParticipant leave(Long meetingId, String sessionId) { return removed; } + // 특정 멤버가 회의방에 이미 연결되어 있는지 확인합니다. + public boolean existsParticipant(Long meetingId, Long memberId) { + MeetingRoomRepository room = getRoom(meetingId); + if (room == null) { + return false; + } + + return !room.participantsByMemberId(memberId).isEmpty(); + } + // 클라이언트에 보여 줄 현재 참가자 목록을 이름순으로 반환합니다. public List listParticipants(Long meetingId) { MeetingRoomRepository room = getRoom(meetingId); diff --git a/src/main/java/com/whylog/server/domain/meeting/socket/message/MeetingMessageType.java b/src/main/java/com/whylog/server/domain/meeting/socket/message/MeetingMessageType.java index de2d1e6..892c0a7 100644 --- a/src/main/java/com/whylog/server/domain/meeting/socket/message/MeetingMessageType.java +++ b/src/main/java/com/whylog/server/domain/meeting/socket/message/MeetingMessageType.java @@ -13,6 +13,7 @@ public enum MeetingMessageType { PARTICIPANT_LEFT("participant_left"), ROSTER("roster"), ERROR("error"), + PARTICIPANT_ALREADY_JOINED("error_participant_already_joined"), CHAT("chat"), SPEECH("speech"), AUDIO_TEXT("audio_text"), From d4da80aacdd3d3b5669d3cf3874a37ed3493d88d Mon Sep 17 00:00:00 2001 From: junyong Date: Thu, 30 Apr 2026 19:13:48 +0900 Subject: [PATCH 8/9] =?UTF-8?q?Fix:=20=ED=9A=8C=EC=9D=98=20=EC=A0=95?= =?UTF-8?q?=EC=83=81=EC=A2=85=EB=A3=8C=20=EA=B5=AC=EB=B6=84=20=EB=B0=8F=20?= =?UTF-8?q?=EB=B9=84=EC=A0=95=EC=83=81=EC=A2=85=EB=A3=8C=20=ED=95=84?= =?UTF-8?q?=ED=84=B0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/domain/meeting/entity/Meeting.java | 8 ++++- .../meeting/repository/MeetingRepository.java | 11 ++++++- .../MeetingStartupRecoveryService.java | 32 +++++++++++++++++++ 3 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/whylog/server/domain/meeting/service/MeetingStartupRecoveryService.java diff --git a/src/main/java/com/whylog/server/domain/meeting/entity/Meeting.java b/src/main/java/com/whylog/server/domain/meeting/entity/Meeting.java index 3d578fa..99b50a7 100644 --- a/src/main/java/com/whylog/server/domain/meeting/entity/Meeting.java +++ b/src/main/java/com/whylog/server/domain/meeting/entity/Meeting.java @@ -19,7 +19,8 @@ @Getter @Table(name = "Meeting") @NoArgsConstructor(access = AccessLevel.PROTECTED) -public class Meeting extends BaseEntity { +public class +Meeting extends BaseEntity { @Id @Column(name = "meeting_id") @@ -47,10 +48,14 @@ public class Meeting extends BaseEntity { @Column(name = "audio_egress_id", length = 100) private String audioEgressId; + @Column(name = "is_normally_ended", nullable = false) + private Boolean isNormallyEnded; + @Builder private Meeting(String name, Team team) { this.name = name; this.team = team; + this.isNormallyEnded = false; this.startDateTime = LocalDateTime.now(); this.endDateTime = null; } @@ -72,6 +77,7 @@ public boolean isOngoing(){ public LocalDateTime endMeeting() { this.endDateTime = LocalDateTime.now(); + this.isNormallyEnded = true; return this.endDateTime; } diff --git a/src/main/java/com/whylog/server/domain/meeting/repository/MeetingRepository.java b/src/main/java/com/whylog/server/domain/meeting/repository/MeetingRepository.java index d8912a8..d6ee164 100644 --- a/src/main/java/com/whylog/server/domain/meeting/repository/MeetingRepository.java +++ b/src/main/java/com/whylog/server/domain/meeting/repository/MeetingRepository.java @@ -8,6 +8,7 @@ import java.util.List; import java.util.Optional; +import java.time.LocalDateTime; public interface MeetingRepository extends JpaRepository { @@ -15,7 +16,7 @@ public interface MeetingRepository extends JpaRepository { SELECT m FROM Meeting m LEFT JOIN FETCH m.meetingAnalysis - WHERE m.team.id = :teamId + WHERE m.team.id = :teamId AND m.isNormallyEnded IS TRUE ORDER BY m.startDateTime DESC """) List findWithAnalysis(@Param("teamId") Long teamId); @@ -50,5 +51,13 @@ public interface MeetingRepository extends JpaRepository { """) Optional findByMeetingId(@Param("meetingId") Long meetingId); + @Modifying + @Query(""" + UPDATE Meeting m + SET m.endDateTime = :endedAt + WHERE m.endDateTime IS NULL + """) + int markAllOngoingMeetingsAsEnded(@Param("endedAt") LocalDateTime endedAt); + } diff --git a/src/main/java/com/whylog/server/domain/meeting/service/MeetingStartupRecoveryService.java b/src/main/java/com/whylog/server/domain/meeting/service/MeetingStartupRecoveryService.java new file mode 100644 index 0000000..97d50a3 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/meeting/service/MeetingStartupRecoveryService.java @@ -0,0 +1,32 @@ +package com.whylog.server.domain.meeting.service; + +import com.whylog.server.domain.meeting.repository.MeetingRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +@Component +@RequiredArgsConstructor +@Slf4j +public class MeetingStartupRecoveryService implements ApplicationRunner { + + private final MeetingRepository meetingRepository; + + @Override + @Transactional + public void run(ApplicationArguments args) { + LocalDateTime recoveryTime = LocalDateTime.now(); + int updatedCount = meetingRepository.markAllOngoingMeetingsAsEnded(recoveryTime); + + if (updatedCount > 0) { + log.warn("Recovered ongoing meetings on startup: count={}, endedAt={}", updatedCount, recoveryTime); + } else { + log.info("No ongoing meetings needed recovery on startup."); + } + } +} From efae8530972ec71a8d1f5e2cc336ad7166c9f3b8 Mon Sep 17 00:00:00 2001 From: junyong Date: Thu, 30 Apr 2026 19:16:11 +0900 Subject: [PATCH 9/9] =?UTF-8?q?Docs:=20=EC=8A=A4=EC=9B=A8=EA=B1=B0=20?= =?UTF-8?q?=EC=83=88=EB=A1=9C=EA=B3=A0=EC=B9=A8=20=EC=8B=9C=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0=20=EC=9C=A0=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index df25158..64eaeb8 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -71,3 +71,7 @@ audio: fast: api: base-url: ${FAST_API_BASE_URL} + +springdoc: + swagger-ui: + persist-authorization: true \ No newline at end of file