From 097144844af9de39a8ea47ed5235e9312cf7ece4 Mon Sep 17 00:00:00 2001 From: junyong Date: Wed, 29 Apr 2026 00:50:26 +0900 Subject: [PATCH 1/9] =?UTF-8?q?Feat:=20=ED=9A=8C=EC=9D=98=20=EC=98=A4?= =?UTF-8?q?=EB=94=94=EC=98=A4=20=EB=85=B9=EC=9D=8C=20=EB=B0=8F=20=EB=B0=98?= =?UTF-8?q?=ED=99=98=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../meeting/controller/MeetingController.java | 2 +- .../domain/meeting/dto/MeetingResponse.java | 7 +- .../server/domain/meeting/entity/Meeting.java | 13 +- .../MeetingAudioNotReadyException.java | 10 ++ .../meeting/exception/MeetingErrorCode.java | 1 + .../meeting/service/LiveKitTokenService.java | 67 +++++++++ .../service/MeetingAudioFileService.java | 47 +++++++ .../service/MeetingCleanupService.java | 4 +- .../service/MeetingCommandService.java | 22 +++ .../meeting/service/MeetingQueryService.java | 45 +++++- .../meeting/service/MeetingRtcService.java | 69 ++++----- .../meeting/service/MeetingUseCase.java | 10 ++ .../whylog/server/global/config/S3Config.java | 15 ++ .../external/livekit/LiveKitEgressClient.java | 131 ++++++++++++++++++ .../server/global/external/s3/S3Client.java | 57 ++++++++ src/main/resources/application.yaml | 1 - 16 files changed, 444 insertions(+), 57 deletions(-) create mode 100644 src/main/java/com/whylog/server/domain/meeting/exception/MeetingAudioNotReadyException.java create mode 100644 src/main/java/com/whylog/server/domain/meeting/service/LiveKitTokenService.java create mode 100644 src/main/java/com/whylog/server/domain/meeting/service/MeetingAudioFileService.java create mode 100644 src/main/java/com/whylog/server/global/external/livekit/LiveKitEgressClient.java diff --git a/src/main/java/com/whylog/server/domain/meeting/controller/MeetingController.java b/src/main/java/com/whylog/server/domain/meeting/controller/MeetingController.java index 293c0cf..b0f174f 100644 --- a/src/main/java/com/whylog/server/domain/meeting/controller/MeetingController.java +++ b/src/main/java/com/whylog/server/domain/meeting/controller/MeetingController.java @@ -202,7 +202,7 @@ public ApiResponse getAnalysisResult( }) public ApiResponse getAudio( @PathVariable Long meetingId) { - return ApiResponse.onSuccess(null); + return ApiResponse.onSuccess(meetingQueryService.getMeetingAudio(meetingId)); } // @GetMapping("/meetings/{meetingId}/applications") diff --git a/src/main/java/com/whylog/server/domain/meeting/dto/MeetingResponse.java b/src/main/java/com/whylog/server/domain/meeting/dto/MeetingResponse.java index 4b7f528..706e72e 100644 --- a/src/main/java/com/whylog/server/domain/meeting/dto/MeetingResponse.java +++ b/src/main/java/com/whylog/server/domain/meeting/dto/MeetingResponse.java @@ -226,13 +226,12 @@ public static class AnalysisResultDTO { @Builder @Schema(description = "회의 오디오 응답") public static class AudioDTO { - - @Schema(description = "오디오 ID", example = "1") - private Long audioId; - @Schema(description = "회의 ID", example = "1") private Long meetingId; + @Schema(description = "오디오 저장 키", example = "recordings/meeting-1/audio.mp4") + private String audioKey; + @Schema(description = "오디오 URL", example = "https://example.com/audio/meeting-1.mp3") private String audioUrl; } 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 6cd9063..99fd50f 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 @@ -13,10 +13,7 @@ import java.util.ArrayList; import java.util.List; -import lombok.AccessLevel; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; +import lombok.*; @Entity @Getter @@ -42,6 +39,14 @@ public class Meeting extends BaseEntity { @Column(name = "end_date_time") private LocalDateTime endDateTime; + @Setter + @Column(name = "audio_key", length = 255) + private String audioKey; + + @Setter + @Column(name = "audio_egress_id", length = 100) + private String audioEgressId; + @Builder private Meeting(String name, Team team) { this.name = name; diff --git a/src/main/java/com/whylog/server/domain/meeting/exception/MeetingAudioNotReadyException.java b/src/main/java/com/whylog/server/domain/meeting/exception/MeetingAudioNotReadyException.java new file mode 100644 index 0000000..0bca396 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/meeting/exception/MeetingAudioNotReadyException.java @@ -0,0 +1,10 @@ +package com.whylog.server.domain.meeting.exception; + +import com.whylog.server.global.apiPayload.exception.GeneralException; + +public class MeetingAudioNotReadyException extends GeneralException { + + public MeetingAudioNotReadyException() { + super(MeetingErrorCode.MEETING_AUDIO_NOT_READY); + } +} 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 77f2d32..b6c0cd9 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 @@ -14,6 +14,7 @@ public enum MeetingErrorCode implements BaseErrorCode { MEETING_ALREADY_ENDED(HttpStatus.CONFLICT, "MEETING_409", "이미 종료된 회의입니다."), MEETING_INVALID_MEMBER(HttpStatus.CONFLICT, "MEETING_410", "회의에 소속된 참여자가 아닙니다."), MEETING_NOT_OWNER(HttpStatus.FORBIDDEN, "MEETING_403", "회의 삭제 권한이 없습니다."), + MEETING_AUDIO_NOT_READY(HttpStatus.CONFLICT, "MEETING_411", "회의 녹음본이 아직 준비되지 않았습니다."), ; private final HttpStatus httpStatus; 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 new file mode 100644 index 0000000..7ca6706 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/meeting/service/LiveKitTokenService.java @@ -0,0 +1,67 @@ +package com.whylog.server.domain.meeting.service; + +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import jakarta.validation.constraints.NotBlank; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; + +@Service +public class LiveKitTokenService { + + private final String liveKitApiKey; + private final String liveKitApiSecret; + + public LiveKitTokenService( + @Value("${livekit.api-key}") @NotBlank String liveKitApiKey, + @Value("${livekit.api-secret}") @NotBlank String liveKitApiSecret + ) { + this.liveKitApiKey = liveKitApiKey; + this.liveKitApiSecret = liveKitApiSecret; + } + + public String createJoinToken(String identity, String name, String roomName) { + Map videoGrant = new LinkedHashMap<>(); + videoGrant.put("roomJoin", true); + videoGrant.put("room", roomName); + videoGrant.put("canPublish", true); + videoGrant.put("canSubscribe", true); + videoGrant.put("canPublishData", true); + + return createToken(identity, name, videoGrant); + } + + public String createRoomRecordToken(String identity, String roomName) { + Map videoGrant = new LinkedHashMap<>(); + videoGrant.put("roomRecord", true); + videoGrant.put("room", roomName); + + return createToken(identity, "recording-bot", videoGrant); + } + + public String createRoomCreateToken(String identity) { + Map videoGrant = new LinkedHashMap<>(); + videoGrant.put("roomCreate", true); + + return createToken(identity, "room-admin", videoGrant); + } + + private String createToken(String identity, String name, Map videoGrant) { + return Jwts.builder() + .setIssuer(liveKitApiKey) + .setSubject(identity) + .claim("name", name) + .claim("video", videoGrant) + .signWith(getSigningKey()) + .compact(); + } + + private SecretKey getSigningKey() { + return Keys.hmacShaKeyFor(liveKitApiSecret.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/src/main/java/com/whylog/server/domain/meeting/service/MeetingAudioFileService.java b/src/main/java/com/whylog/server/domain/meeting/service/MeetingAudioFileService.java new file mode 100644 index 0000000..96a71c0 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/meeting/service/MeetingAudioFileService.java @@ -0,0 +1,47 @@ +package com.whylog.server.domain.meeting.service; + +import java.util.Locale; +import org.springframework.stereotype.Component; + +@Component +public class MeetingAudioFileService { + + public static final String AUDIO_FILE_SUFFIX = "-audio"; + public static final String RECORDING_PREFIX = "recordings/meeting-"; + public static final String MP4_EXTENSION = ".mp4"; + public static final String OGG_EXTENSION = ".ogg"; + + public String buildRecordingKey(Long meetingId) { + return RECORDING_PREFIX + meetingId + AUDIO_FILE_SUFFIX + MP4_EXTENSION; + } + + public String alternateKey(String audioKey) { + if (audioKey == null || audioKey.isBlank()) { + return null; + } + + String lowerCase = audioKey.toLowerCase(Locale.ROOT); + if (lowerCase.endsWith(MP4_EXTENSION)) { + return audioKey.substring(0, audioKey.length() - MP4_EXTENSION.length()) + OGG_EXTENSION; + } + if (lowerCase.endsWith(OGG_EXTENSION)) { + return audioKey.substring(0, audioKey.length() - OGG_EXTENSION.length()) + MP4_EXTENSION; + } + return audioKey; + } + + public String resolveResponseContentType(String audioKey) { + if (audioKey == null || audioKey.isBlank()) { + return null; + } + + String lowerCase = audioKey.toLowerCase(Locale.ROOT); + if (lowerCase.endsWith(OGG_EXTENSION)) { + return "audio/ogg"; + } + if (lowerCase.endsWith(MP4_EXTENSION)) { + return "audio/mp4"; + } + return null; + } +} diff --git a/src/main/java/com/whylog/server/domain/meeting/service/MeetingCleanupService.java b/src/main/java/com/whylog/server/domain/meeting/service/MeetingCleanupService.java index e3a2117..ac190c5 100644 --- a/src/main/java/com/whylog/server/domain/meeting/service/MeetingCleanupService.java +++ b/src/main/java/com/whylog/server/domain/meeting/service/MeetingCleanupService.java @@ -25,7 +25,7 @@ public class MeetingCleanupService { @Transactional public List deleteByTeamId(Long teamId) { List meetingIds = meetingRepository.findIdsByTeamId(teamId); - deleteChildrenByTeamId(teamId); + deleteChildrenByTeamId(teamId, meetingIds); meetingRepository.deleteByTeamId(teamId); return meetingIds; } @@ -36,7 +36,7 @@ public void deleteByMeetingId(Long meetingId) { meetingRepository.deleteById(meetingId); } - private void deleteChildrenByTeamId(Long teamId) { + private void deleteChildrenByTeamId(Long teamId, List meetingIds) { applicationRepository.deleteByTeamId(teamId); decisionRepository.deleteByTeamId(teamId); meetingAnalysisRepository.deleteByTeamId(teamId); 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 fd6fb00..ccbd0fa 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 @@ -13,10 +13,12 @@ import com.whylog.server.domain.meeting.repository.MeetingRepository; import com.whylog.server.domain.meeting.socket.MeetingSocketRoomService; import com.whylog.server.domain.meeting.service.MeetingCleanupService; +import com.whylog.server.domain.meeting.service.LiveKitTokenService; import com.whylog.server.domain.team.entity.Team; import com.whylog.server.domain.team.service.TeamUseCase; import com.whylog.server.domain.user.entity.Member; import com.whylog.server.domain.user.service.MemberUseCase; +import com.whylog.server.global.external.livekit.LiveKitEgressClient; import com.whylog.server.global.apiPayload.exception.handler.ErrorHandler; import com.whylog.server.global.apiPayload.exception.ParameterRequiredException; import lombok.RequiredArgsConstructor; @@ -34,6 +36,9 @@ public class MeetingCommandService { private final MeetingMemberRepository meetingMemberRepository; private final MeetingRepository meetingRepository; private final MeetingCleanupService meetingCleanupService; + private final MeetingAudioFileService meetingAudioFileService; + private final LiveKitTokenService liveKitTokenService; + private final LiveKitEgressClient liveKitEgressClient; private final MemberUseCase memberUseCase; private final TeamUseCase teamUseCase; @@ -63,6 +68,9 @@ public MeetingResponse.MeetingCreateResponseDTO makeMeetingRoom(Long memberId, L // Meeting, MeetingMember 같이 저장 Meeting meeting = Meeting.create(requestDTO, team); Meeting savedMeeting = meetingRepository.save(meeting); + String audioKey = meetingAudioFileService.buildRecordingKey(savedMeeting.getId()); + savedMeeting.setAudioKey(audioKey); + meetingRepository.save(savedMeeting); MeetingMember meetingMember = MeetingMember.create(savedMeeting, member, MeetingRole.OWNER); meetingMemberRepository.save(meetingMember); @@ -102,6 +110,7 @@ public MeetingResponse.MeetingEndResponseDTO endMeeting(Long memberId, Long meet // 웹소켓 메시지 전송 meetingSocketRoomService.broadcastMeetingEnded(meetingId, endDateTime); // 회의 참여한 사람들에게 알림 + stopRecording(meeting); meetingSocketRoomService.closeRoom(meetingId); // 메모리 내의 실시간 회의 정보 제거 // TODO: 회의 종료 후 분석 비동기 작업 시작 @@ -121,6 +130,9 @@ public MeetingResponse.MeetingDeleteResponseDTO deleteMeeting(Long memberId, Lon meetingMemberRepository.findOwnerMeetingMember(memberId, meetingId, MeetingRole.OWNER) .orElseThrow(() -> new ErrorHandler(MeetingErrorCode.MEETING_NOT_OWNER)); + Meeting meeting = meetingRepository.findById(meetingId) + .orElseThrow(MeetingNotFoundException::new); + stopRecording(meeting); meetingCleanupService.deleteByMeetingId(meetingId); scheduleAfterCommit(() -> meetingSocketRoomService.closeRoom(meetingId)); @@ -144,4 +156,14 @@ public void afterCommit() { task.run(); } + private void stopRecording(Meeting meeting) { + if (meeting == null || meeting.getAudioEgressId() == null || meeting.getAudioEgressId().isBlank()) { + return; + } + + String roomName = "meeting-" + meeting.getId(); + String egressToken = liveKitTokenService.createRoomRecordToken("recording-" + meeting.getId(), roomName); + liveKitEgressClient.stopEgress(egressToken, meeting.getAudioEgressId()); + } + } 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 088db8a..bd951e7 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 @@ -3,22 +3,26 @@ import com.whylog.server.domain.meeting.dto.MeetingResponse; import com.whylog.server.domain.meeting.entity.Meeting; import com.whylog.server.domain.meeting.enums.MeetingStatus; -import com.whylog.server.domain.meeting.exception.MeetingNotFoundException; +import com.whylog.server.domain.meeting.exception.MeetingAudioNotReadyException; import com.whylog.server.domain.meeting.repository.MeetingRepository; import com.whylog.server.domain.user.entity.Member; +import com.whylog.server.global.external.s3.S3Client; import com.whylog.server.global.apiPayload.exception.ParameterRequiredException; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; +import java.time.Duration; @Service @RequiredArgsConstructor public class MeetingQueryService { - private final MeetingRepository meetingRepository; private final MeetingUseCase meetingUseCase; + private final MeetingAudioFileService meetingAudioFileService; + private final S3Client s3Client; + private final MeetingRepository meetingRepository; // 미팅 목록 조회 @Transactional(readOnly = true) @@ -49,8 +53,7 @@ public MeetingResponse.MeetingDetailDTO getMeetingDefaultInfo(Long meetingId){ throw new ParameterRequiredException(); } - Meeting meeting = meetingRepository.findWithMembers(meetingId) - .orElseThrow(MeetingNotFoundException::new); + Meeting meeting = meetingUseCase.findMeetingById(meetingId); return MeetingResponse.MeetingDetailDTO.builder() .meetingId(meeting.getId()) @@ -77,4 +80,38 @@ private boolean checkMeetingStatus(Meeting meeting, MeetingStatus status){ return meeting.getStatus() == status; } + @Transactional(readOnly = true) + public MeetingResponse.AudioDTO getMeetingAudio(Long meetingId) { + Meeting meeting = meetingUseCase.findMeetingById(meetingId); + String audioKey = resolveAudioKey(meeting); + + return MeetingResponse.AudioDTO.builder() + .meetingId(meeting.getId()) + .audioKey(audioKey) + .audioUrl(s3Client.getPresignedFileUrl( + audioKey, + Duration.ofMinutes(10), + meetingAudioFileService.resolveResponseContentType(audioKey) + )) + .build(); + } + + private String resolveAudioKey(Meeting meeting) { + String audioKey = meeting.getAudioKey(); + if (isPlayableAudioKey(audioKey)) { + return audioKey; + } + + String alternateAudioKey = meetingAudioFileService.alternateKey(audioKey); + if (isPlayableAudioKey(alternateAudioKey)) { + return alternateAudioKey; + } + + throw new MeetingAudioNotReadyException(); + } + + private boolean isPlayableAudioKey(String audioKey) { + return audioKey != null && !audioKey.isBlank() && s3Client.exists(audioKey); + } + } diff --git a/src/main/java/com/whylog/server/domain/meeting/service/MeetingRtcService.java b/src/main/java/com/whylog/server/domain/meeting/service/MeetingRtcService.java index fa24e8c..8771df8 100644 --- a/src/main/java/com/whylog/server/domain/meeting/service/MeetingRtcService.java +++ b/src/main/java/com/whylog/server/domain/meeting/service/MeetingRtcService.java @@ -9,45 +9,39 @@ import com.whylog.server.domain.meeting.repository.MeetingRepository; import com.whylog.server.domain.user.entity.Member; import com.whylog.server.domain.user.service.MemberUseCase; -import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.security.Keys; +import com.whylog.server.global.external.livekit.LiveKitEgressClient; import jakarta.validation.constraints.NotBlank; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import javax.crypto.SecretKey; -import java.nio.charset.StandardCharsets; -import java.util.Date; -import java.util.Map; - @Service public class MeetingRtcService { private final MeetingRepository meetingRepository; private final MeetingMemberRepository meetingMemberRepository; private final MemberUseCase memberUseCase; + private final MeetingAudioFileService meetingAudioFileService; + private final LiveKitTokenService liveKitTokenService; + private final LiveKitEgressClient liveKitEgressClient; private final String liveKitUrl; - private final String liveKitApiKey; - private final String liveKitApiSecret; - private final long liveKitTokenExpireTime; public MeetingRtcService( MeetingRepository meetingRepository, MeetingMemberRepository meetingMemberRepository, MemberUseCase memberUseCase, - @Value("${livekit.url}") @NotBlank String liveKitUrl, - @Value("${livekit.api-key}") @NotBlank String liveKitApiKey, - @Value("${livekit.api-secret}") @NotBlank String liveKitApiSecret, - @Value("${livekit.token-expire-time}") long liveKitTokenExpireTime + MeetingAudioFileService meetingAudioFileService, + LiveKitTokenService liveKitTokenService, + LiveKitEgressClient liveKitEgressClient, + @Value("${livekit.url}") @NotBlank String liveKitUrl ) { this.meetingRepository = meetingRepository; this.meetingMemberRepository = meetingMemberRepository; this.memberUseCase = memberUseCase; + this.meetingAudioFileService = meetingAudioFileService; + this.liveKitTokenService = liveKitTokenService; + this.liveKitEgressClient = liveKitEgressClient; this.liveKitUrl = liveKitUrl; - this.liveKitApiKey = liveKitApiKey; - this.liveKitApiSecret = liveKitApiSecret; - this.liveKitTokenExpireTime = liveKitTokenExpireTime; } @Transactional @@ -58,8 +52,9 @@ public MeetingResponse.MeetingRtcTokenDTO issueRtcToken(Long memberId, Long meet Member member = memberUseCase.findMemberById(memberId); ensureMeetingParticipant(meeting, member); String roomName = buildRoomName(meeting); + startRecordingIfAbsent(meeting, roomName); String identity = String.valueOf(member.getId()); - String token = createJoinToken(identity, member.getName(), roomName); + String token = liveKitTokenService.createJoinToken(identity, member.getName(), roomName); return MeetingResponse.MeetingRtcTokenDTO.builder() .meetingId(meetingId) @@ -80,31 +75,23 @@ private String buildRoomName(Meeting meeting) { return "meeting-" + meeting.getId(); } - private String createJoinToken(String identity, String name, String roomName) { - Date now = new Date(); - Date expiration = new Date(now.getTime() + liveKitTokenExpireTime); + private void startRecordingIfAbsent(Meeting meeting, String roomName) { + if (meeting.getAudioEgressId() != null && !meeting.getAudioEgressId().isBlank()) { + return; + } - Map videoGrant = Map.of( - "roomJoin", true, - "room", roomName, - "canPublish", true, - "canSubscribe", true, - "canPublishData", true - ); + String audioKey = meeting.getAudioKey(); + if (audioKey == null || audioKey.isBlank()) { + audioKey = meetingAudioFileService.buildRecordingKey(meeting.getId()); + meeting.setAudioKey(audioKey); + } - return Jwts.builder() - .setIssuer(liveKitApiKey) - .setSubject(identity) - .setIssuedAt(now) - .setNotBefore(now) - .setExpiration(expiration) - .claim("name", name) - .claim("video", videoGrant) - .signWith(getSigningKey()) - .compact(); - } + String roomAdminToken = liveKitTokenService.createRoomCreateToken("room-admin"); + liveKitEgressClient.createRoom(roomAdminToken, roomName); - private SecretKey getSigningKey() { - return Keys.hmacShaKeyFor(liveKitApiSecret.getBytes(StandardCharsets.UTF_8)); + String egressToken = liveKitTokenService.createRoomRecordToken("recording-" + meeting.getId(), roomName); + String egressId = liveKitEgressClient.startRoomAudioEgress(egressToken, roomName, audioKey); + meeting.setAudioEgressId(egressId); + meetingRepository.save(meeting); } } 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 4b2c897..8d875aa 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 @@ -2,6 +2,7 @@ import com.whylog.server.domain.meeting.entity.Meeting; import com.whylog.server.domain.meeting.entity.MeetingMember; +import com.whylog.server.domain.meeting.exception.MeetingNotFoundException; import com.whylog.server.domain.meeting.repository.MeetingRepository; import com.whylog.server.domain.user.entity.Member; import lombok.RequiredArgsConstructor; @@ -15,6 +16,15 @@ public class MeetingUseCase { private final MeetingRepository meetingRepository; + public Meeting findMeetingById(Long id){ + return meetingRepository.findById(id) + .orElseThrow(MeetingNotFoundException::new); + } + + public List findMeetingByTeamId(Long teamId){ + return meetingRepository.findByTeamId(teamId); + } + // 회의 참여자 수 public int getMeetingMemberCount(Meeting meeting){ return meeting.getMeetingMembers().size(); diff --git a/src/main/java/com/whylog/server/global/config/S3Config.java b/src/main/java/com/whylog/server/global/config/S3Config.java index 07774cd..cc4b353 100644 --- a/src/main/java/com/whylog/server/global/config/S3Config.java +++ b/src/main/java/com/whylog/server/global/config/S3Config.java @@ -6,6 +6,7 @@ import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; @Configuration public class S3Config { @@ -23,4 +24,18 @@ public software.amazon.awssdk.services.s3.S3Client amazonS3Client( )) .build(); } + + @Bean + public S3Presigner s3Presigner( + @Value("${aws.s3.region}") String region, + @Value("${aws.s3.access-key}") String accessKey, + @Value("${aws.s3.secret-key}") String secretKey + ) { + return S3Presigner.builder() + .region(Region.of(region)) + .credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create(accessKey, secretKey) + )) + .build(); + } } 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 new file mode 100644 index 0000000..8010dd4 --- /dev/null +++ b/src/main/java/com/whylog/server/global/external/livekit/LiveKitEgressClient.java @@ -0,0 +1,131 @@ +package com.whylog.server.global.external.livekit; + +import com.whylog.server.global.util.json.JsonConverter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.HttpClientErrorException; + +import java.net.URI; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Slf4j +@Component +@RequiredArgsConstructor +public class LiveKitEgressClient { + + @Value("${livekit.url}") + private String liveKitUrl; + + @Value("${aws.s3.bucket}") + private String bucket; + + @Value("${aws.s3.region}") + private String region; + + @Value("${aws.s3.access-key}") + private String accessKey; + + @Value("${aws.s3.secret-key}") + private String secretKey; + + @Value("${aws.s3.endpoint:}") + private String s3Endpoint; + + @Value("${aws.s3.force-path-style:false}") + private boolean forcePathStyle; + + private final RestClient restClient = RestClient.create(); + + public String startRoomAudioEgress(String egressToken, String roomName, String recordingKey) { + Map request = new LinkedHashMap<>(); + request.put("room_name", roomName); + request.put("audio_only", true); + request.put("file_outputs", List.of(buildFileOutput(recordingKey))); + + Map response = post("/twirp/livekit.Egress/StartRoomCompositeEgress", egressToken, request); + Object egressId = response.get("egress_id"); + if (egressId == null) { + throw new IllegalStateException("LiveKit egress response did not include egress_id"); + } + return egressId.toString(); + } + + public void createRoom(String roomCreateToken, String roomName) { + Map request = new LinkedHashMap<>(); + request.put("name", roomName); + + try { + post("/twirp/livekit.RoomService/CreateRoom", roomCreateToken, request); + } catch (HttpClientErrorException.Conflict e) { + log.info("LiveKit room already exists: roomName={}", roomName); + } + } + + public void stopEgress(String egressToken, String egressId) { + Map request = Map.of("egress_id", egressId); + post("/twirp/livekit.Egress/StopEgress", egressToken, request); + } + + @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); + log.info("LiveKit egress request: {}", requestJson); + + Map response = restClient.post() + .uri(uri) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + egressToken) + .body(body) + .retrieve() + .body(Map.class); + + if (response == null) { + throw new IllegalStateException("LiveKit egress response is empty"); + } + + log.info("LiveKit egress response: {}", JsonConverter.toJson(response)); + return response; + } + + private Map buildFileOutput(String recordingKey) { + Map s3 = new LinkedHashMap<>(); + s3.put("access_key", accessKey); + s3.put("secret", secretKey); + s3.put("bucket", bucket); + s3.put("region", region); + if (StringUtils.hasText(s3Endpoint)) { + s3.put("endpoint", s3Endpoint); + } + s3.put("force_path_style", forcePathStyle); + + Map fileOutput = new LinkedHashMap<>(); + fileOutput.put("filepath", recordingKey); + fileOutput.put("s3", s3); + return fileOutput; + } + + private String toHttpsBaseUrl(String wsUrl) { + if (wsUrl.startsWith("wss://")) { + return "https://" + wsUrl.substring("wss://".length()); + } + if (wsUrl.startsWith("ws://")) { + return "http://" + wsUrl.substring("ws://".length()); + } + if (wsUrl.startsWith("https://") || wsUrl.startsWith("http://")) { + return wsUrl; + } + return "https://" + wsUrl; + } + +} diff --git a/src/main/java/com/whylog/server/global/external/s3/S3Client.java b/src/main/java/com/whylog/server/global/external/s3/S3Client.java index d1d5a32..93bbf44 100644 --- a/src/main/java/com/whylog/server/global/external/s3/S3Client.java +++ b/src/main/java/com/whylog/server/global/external/s3/S3Client.java @@ -3,16 +3,23 @@ import java.io.IOException; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; +import java.time.Duration; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import org.springframework.web.multipart.MultipartFile; +import com.whylog.server.domain.meeting.service.MeetingAudioFileService; import software.amazon.awssdk.core.exception.SdkClientException; import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.model.HeadObjectRequest; import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; +import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest; @Slf4j @Component @@ -20,6 +27,8 @@ public class S3Client { private final software.amazon.awssdk.services.s3.S3Client s3Client; + private final S3Presigner s3Presigner; + private final MeetingAudioFileService meetingAudioFileService; @Value("${aws.s3.bucket}") private String bucket; @@ -75,6 +84,54 @@ public String getFileUrl(String fileName) { return "https://" + bucket + ".s3." + region + ".amazonaws.com/" + encodedFileName; } + public String getPresignedFileUrl(String fileName, Duration duration) { + return getPresignedFileUrl(fileName, duration, meetingAudioFileService.resolveResponseContentType(fileName)); + } + + public String getPresignedFileUrl(String fileName, Duration duration, String responseContentType) { + if (!StringUtils.hasText(fileName)) { + return null; + } + + GetObjectRequest.Builder getObjectRequest = GetObjectRequest.builder() + .bucket(bucket) + .key(fileName); + + if (StringUtils.hasText(responseContentType)) { + getObjectRequest.responseContentType(responseContentType); + } + + GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() + .signatureDuration(duration) + .getObjectRequest(getObjectRequest.build()) + .build(); + + PresignedGetObjectRequest presignedRequest = s3Presigner.presignGetObject(presignRequest); + return presignedRequest.url().toString(); + } + + public boolean exists(String fileName) { + if (!StringUtils.hasText(fileName)) { + return false; + } + + try { + s3Client.headObject(HeadObjectRequest.builder() + .bucket(bucket) + .key(fileName) + .build()); + return true; + } catch (software.amazon.awssdk.services.s3.model.S3Exception e) { + if (e.statusCode() == 404 || e.statusCode() == 403) { + return false; + } + throw e; + } catch (SdkClientException e) { + log.error("S3 존재 확인 에러 발생: {}", e.getMessage()); + return false; + } + } + public void deleteFile(String fileName) { if (!StringUtils.hasText(fileName)) { return; diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 90aab4c..9fed69f 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -49,7 +49,6 @@ livekit: url: ${LIVEKIT_URL:wss://example.livekit.invalid} api-key: ${LIVEKIT_API_KEY:devkey} api-secret: ${LIVEKIT_API_SECRET:01234567890123456789012345678901} - token-expire-time: ${LIVEKIT_TOKEN_EXPIRE_TIME:3600000} github: token: From 9e43e31359c81c529e7de0f190f2d86217b0046a Mon Sep 17 00:00:00 2001 From: junyong Date: Wed, 29 Apr 2026 01:03:19 +0900 Subject: [PATCH 2/9] =?UTF-8?q?Feat:=20=ED=9A=8C=EC=9D=98=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4,=20=EB=85=B9=EC=9D=8C=EB=B3=B8=20=EC=A0=95=EB=B3=B4?= =?UTF-8?q?=20=EC=9D=91=EB=8B=B5=EC=97=90=20=EC=9E=AC=EC=83=9D=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/meeting/dto/MeetingResponse.java | 6 ++ .../service/MeetingAudioDurationService.java | 87 +++++++++++++++++++ .../meeting/service/MeetingQueryService.java | 10 +++ .../server/global/external/s3/S3Client.java | 2 +- 4 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/whylog/server/domain/meeting/service/MeetingAudioDurationService.java diff --git a/src/main/java/com/whylog/server/domain/meeting/dto/MeetingResponse.java b/src/main/java/com/whylog/server/domain/meeting/dto/MeetingResponse.java index 706e72e..f7b94f5 100644 --- a/src/main/java/com/whylog/server/domain/meeting/dto/MeetingResponse.java +++ b/src/main/java/com/whylog/server/domain/meeting/dto/MeetingResponse.java @@ -153,6 +153,9 @@ public static class MeetingDetailDTO { @Schema(description = "회의 참여자 목록", example = "[1, 2, 3]") private List members; + + @Schema(description = "녹음 재생 시간(초)", example = "120") + private Integer audioDuration; } @Getter @@ -234,6 +237,9 @@ public static class AudioDTO { @Schema(description = "오디오 URL", example = "https://example.com/audio/meeting-1.mp3") private String audioUrl; + + @Schema(description = "녹음 재생 시간(초)", example = "120") + private Integer audioDuration; } @Getter diff --git a/src/main/java/com/whylog/server/domain/meeting/service/MeetingAudioDurationService.java b/src/main/java/com/whylog/server/domain/meeting/service/MeetingAudioDurationService.java new file mode 100644 index 0000000..7a34486 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/meeting/service/MeetingAudioDurationService.java @@ -0,0 +1,87 @@ +package com.whylog.server.domain.meeting.service; + +import com.whylog.server.global.external.s3.S3Client; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.TimeUnit; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class MeetingAudioDurationService { + + private static final String FFPROBE_COMMAND = "/opt/homebrew/bin/ffprobe"; + + private final S3Client s3Client; + + public Integer resolveAudioDurationSeconds(String audioKey) { + if (audioKey == null || audioKey.isBlank()) { + return null; + } + + String presignedUrl = s3Client.getPresignedFileUrl(audioKey, java.time.Duration.ofMinutes(5)); + if (presignedUrl == null || presignedUrl.isBlank()) { + return null; + } + + return probeDurationSeconds(presignedUrl); + } + + private Integer probeDurationSeconds(String audioUrl) { + Process process; + try { + process = new ProcessBuilder( + FFPROBE_COMMAND, + "-v", "error", + "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", + audioUrl + ).redirectErrorStream(true).start(); + } catch (IOException e) { + log.warn("ffprobe 실행 실패: {}", e.getMessage()); + return null; + } + + String output; + try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { + output = reader.readLine(); + } catch (IOException e) { + log.warn("ffprobe 출력 읽기 실패: {}", e.getMessage()); + process.destroyForcibly(); + return null; + } + + try { + if (!process.waitFor(10, TimeUnit.SECONDS)) { + log.warn("ffprobe 시간 초과"); + process.destroyForcibly(); + return null; + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.warn("ffprobe 대기 중 인터럽트 발생"); + process.destroyForcibly(); + return null; + } + + if (process.exitValue() != 0 || output == null || output.isBlank()) { + return null; + } + + try { + double seconds = Double.parseDouble(output.trim()); + if (seconds <= 0) { + return null; + } + return (int) Math.round(seconds); + } catch (NumberFormatException e) { + log.warn("오디오 길이 파싱 실패: {}", e.getMessage()); + return null; + } + } +} 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 bd951e7..f5b86ff 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 @@ -21,6 +21,7 @@ public class MeetingQueryService { private final MeetingUseCase meetingUseCase; private final MeetingAudioFileService meetingAudioFileService; + private final MeetingAudioDurationService meetingAudioDurationService; private final S3Client s3Client; private final MeetingRepository meetingRepository; @@ -63,6 +64,7 @@ public MeetingResponse.MeetingDetailDTO getMeetingDefaultInfo(Long meetingId){ .duration(meeting.getDuration()) .memberCount( meetingUseCase.getMeetingMemberCount(meeting) ) .members( memberToParticipantsInfo(meetingUseCase.getParticipantsInfo(meeting)) ) + .audioDuration(resolveAudioDuration(resolveAudioKey(meeting))) .build(); } @@ -93,6 +95,7 @@ public MeetingResponse.AudioDTO getMeetingAudio(Long meetingId) { Duration.ofMinutes(10), meetingAudioFileService.resolveResponseContentType(audioKey) )) + .audioDuration(resolveAudioDuration(audioKey)) .build(); } @@ -114,4 +117,11 @@ private boolean isPlayableAudioKey(String audioKey) { return audioKey != null && !audioKey.isBlank() && s3Client.exists(audioKey); } + private Integer resolveAudioDuration(String audioKey) { + if (audioKey == null || audioKey.isBlank()) { + return null; + } + return meetingAudioDurationService.resolveAudioDurationSeconds(audioKey); + } + } diff --git a/src/main/java/com/whylog/server/global/external/s3/S3Client.java b/src/main/java/com/whylog/server/global/external/s3/S3Client.java index 93bbf44..ef0879c 100644 --- a/src/main/java/com/whylog/server/global/external/s3/S3Client.java +++ b/src/main/java/com/whylog/server/global/external/s3/S3Client.java @@ -6,11 +6,11 @@ import java.time.Duration; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import com.whylog.server.domain.meeting.service.MeetingAudioFileService; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import org.springframework.web.multipart.MultipartFile; -import com.whylog.server.domain.meeting.service.MeetingAudioFileService; import software.amazon.awssdk.core.exception.SdkClientException; import software.amazon.awssdk.core.sync.RequestBody; import software.amazon.awssdk.services.s3.model.HeadObjectRequest; From c04f593c4b25b310496e1c0b037d79be4b833de1 Mon Sep 17 00:00:00 2001 From: junyong Date: Wed, 29 Apr 2026 01:12:25 +0900 Subject: [PATCH 3/9] =?UTF-8?q?Docs:=20=ED=9A=8C=EC=9D=98=20=EC=98=A4?= =?UTF-8?q?=EB=94=94=EC=98=A4=20=ED=94=8C=EB=A0=88=EC=9D=B4=20api=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../meeting/controller/MeetingController.java | 12 +++++++++-- .../domain/meeting/dto/MeetingResponse.java | 21 ++++++++++++++----- .../meeting/exception/MeetingErrorCode.java | 2 +- .../meeting/service/MeetingQueryService.java | 20 ++++++++++++++++-- 4 files changed, 45 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/whylog/server/domain/meeting/controller/MeetingController.java b/src/main/java/com/whylog/server/domain/meeting/controller/MeetingController.java index b0f174f..838d70d 100644 --- a/src/main/java/com/whylog/server/domain/meeting/controller/MeetingController.java +++ b/src/main/java/com/whylog/server/domain/meeting/controller/MeetingController.java @@ -194,11 +194,19 @@ public ApiResponse getAnalysisResult( } @GetMapping("/meetings/{meetingId}/audio") - @Operation(summary = "오디오 리플레이 API", description = "회의 오디오 파일을 리플레이하는 API입니다.") + @Operation(summary = "오디오 리플레이 API", description = """ + 회의 녹음본을 재생할 수 있는 정보를 조회하는 API입니다. + + 응답의 `audioUrl`은 10분짜리 presigned URL입니다. + 클라인트는 이 URL을 `