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 672f1db..52abc0f 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 @@ -5,6 +5,7 @@ import com.whylog.server.domain.meeting.enums.MeetingStatus; import com.whylog.server.domain.meeting.service.MeetingCommandService; import com.whylog.server.domain.meeting.service.MeetingQueryService; +import com.whylog.server.domain.meeting.service.MeetingRtcService; import com.whylog.server.global.apiPayload.ApiResponse; import com.whylog.server.global.auth.annotation.CurrentMember; import io.swagger.v3.oas.annotations.Operation; @@ -31,6 +32,7 @@ public class MeetingController { private final MeetingCommandService meetingCommandService; private final MeetingQueryService meetingQueryService; + private final MeetingRtcService meetingRtcService; @GetMapping("/teams/{teamId}/meetings") @Operation(summary = "회의 목록 조회 API", description = """ @@ -66,6 +68,8 @@ public ApiResponse> getMeetings( 새로운 회의를 생성하는 API입니다. 생성하면 실시긴 회의방이 하나 생성됩니다. 해당 API는 방 생성만 담당합니다. 회의 참여를 위해서는 웹소켓 연결이 필요합니다. + 웹소켓은 JWT 인증, 참여자 상태, WebRTC 시그널링 처리만 담당합니다. + 실제 실시간 음성 전달은 WebRTC/SFU 경로를 사용합니다. """) public ApiResponse createMeeting( @@ -76,10 +80,23 @@ public ApiResponse createMeeting( return ApiResponse.onSuccess(result); } + @GetMapping("/meetings/{meetingId}/rtc-token") + @Operation(summary = "회의 SFU 접속 토큰 발급 API", description = """ + 현재 로그인한 사용자가 해당 회의의 LiveKit SFU room에 접속할 수 있도록 join token을 발급합니다. + 프론트는 이 토큰과 serverUrl을 사용해 WebRTC 음성 연결을 수립합니다. + """) + public ApiResponse issueRtcToken( + @Parameter(hidden = true) @CurrentMember Long memberId, + @PathVariable Long meetingId + ) { + return ApiResponse.onSuccess(meetingRtcService.issueRtcToken(memberId, meetingId)); + } + @PatchMapping("/meetings/{meetingId}/end") @Operation(summary = "회의 종료 API", description = """ 진행 중인 회의를 종료하는 API입니다. 회의 종료 시 실시간 회의 참여자들에게 종료를 알리는 웹소켓 메시지를 전송합니다. + 종료 이후의 음성 파일 처리, STT, 회의 분석은 비동기 후처리 파이프라인에서 수행합니다. """) public ApiResponse endMeeting( @Parameter(hidden= true) @CurrentMember Long memberId, 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 5d4b537..6e8fe23 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 @@ -92,6 +92,26 @@ public static class MeetingEndResponseDTO { private LocalDateTime endDateTime; } + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + @Schema(description = "회의 SFU 접속 토큰 응답") + public static class MeetingRtcTokenDTO { + + @Schema(description = "회의 ID", example = "1") + private Long meetingId; + + @Schema(description = "LiveKit room name", example = "meeting-1") + private String roomName; + + @Schema(description = "LiveKit server URL", example = "wss://livekit.example.com") + private String serverUrl; + + @Schema(description = "LiveKit join token") + private String token; + } + @Getter @NoArgsConstructor @AllArgsConstructor 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 55fca3d..1b47318 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 @@ -98,7 +98,7 @@ public MeetingResponse.MeetingEndResponseDTO endMeeting(Long memberId, Long meet meetingSocketRoomService.broadcastMeetingEnded(meetingId, endDateTime); // 회의 참여한 사람들에게 알림 meetingSocketRoomService.closeRoom(meetingId); // 메모리 내의 실시간 회의 정보 제거 - // TODO: 회의 종료 후 AI 분석 시작 + // TODO: 회의 종료 후 분석 비동기 작업 시작 return MeetingResponse.MeetingEndResponseDTO.builder() .meetingId(meeting.getId()) 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 new file mode 100644 index 0000000..6618df1 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/meeting/service/MeetingRtcService.java @@ -0,0 +1,111 @@ +package com.whylog.server.domain.meeting.service; + +import com.whylog.server.domain.meeting.dto.MeetingResponse; +import com.whylog.server.domain.meeting.entity.Meeting; +import com.whylog.server.domain.meeting.entity.MeetingMember; +import com.whylog.server.domain.meeting.enums.MeetingRole; +import com.whylog.server.domain.meeting.exception.MeetingNotFoundException; +import com.whylog.server.domain.meeting.repository.MeetingMemberRepository; +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 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 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 + ) { + this.meetingRepository = meetingRepository; + this.meetingMemberRepository = meetingMemberRepository; + this.memberUseCase = memberUseCase; + this.liveKitUrl = liveKitUrl; + this.liveKitApiKey = liveKitApiKey; + this.liveKitApiSecret = liveKitApiSecret; + this.liveKitTokenExpireTime = liveKitTokenExpireTime; + } + + @Transactional(readOnly = true) + public MeetingResponse.MeetingRtcTokenDTO issueRtcToken(Long memberId, Long meetingId) { + Meeting meeting = meetingRepository.findById(meetingId) + .orElseThrow(MeetingNotFoundException::new); + + Member member = memberUseCase.findMemberById(memberId); + ensureMeetingParticipant(meeting, member); + String roomName = buildRoomName(meeting); + String identity = String.valueOf(member.getId()); + String token = createJoinToken(identity, member.getName(), roomName); + + return MeetingResponse.MeetingRtcTokenDTO.builder() + .meetingId(meetingId) + .roomName(roomName) + .serverUrl(liveKitUrl) + .token(token) + .build(); + } + + private void ensureMeetingParticipant(Meeting meeting, Member member) { + if (meetingMemberRepository.existsByMemberIdAndMeetingId(member.getId(), meeting.getId())) { + return; + } + + meetingMemberRepository.save(MeetingMember.create(meeting, member, MeetingRole.GENERAL)); + } + + 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); + + Map videoGrant = Map.of( + "roomJoin", true, + "room", roomName, + "canPublish", true, + "canSubscribe", true, + "canPublishData", true + ); + + return Jwts.builder() + .setIssuer(liveKitApiKey) + .setSubject(identity) + .setIssuedAt(now) + .setNotBefore(now) + .setExpiration(expiration) + .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/socket/MeetingSocketHandler.java b/src/main/java/com/whylog/server/domain/meeting/socket/MeetingSocketHandler.java index 73c19b4..6199ed0 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 @@ -13,7 +13,6 @@ import org.springframework.web.socket.WebSocketSession; import org.springframework.web.socket.handler.BinaryWebSocketHandler; -import java.nio.ByteBuffer; import java.time.Instant; import java.util.List; import java.util.Optional; @@ -98,16 +97,10 @@ protected void handleTextMessage(@NonNull WebSocketSession session, @NonNull Tex } } - // 클라이언트가 보낸 오디오 청크를 발신자 memberId와 함께 다른 참가자들에게 중계합니다. + // 실시간 오디오는 WebRTC/SFU 경로로 전달하고, 웹소켓 바이너리 프레임은 더 이상 중계하지 않습니다. @Override protected void handleBinaryMessage(@NonNull WebSocketSession session, BinaryMessage message) throws Exception { - MeetingParticipant participant = createParticipant(session); - ByteBuffer outbound = ByteBuffer.allocate(Long.BYTES + message.getPayloadLength()); - outbound.putLong(participant.memberId()); - outbound.put(message.getPayload().asReadOnlyBuffer()); - outbound.flip(); - - meetingSocketRoomService.broadcastAudio(participant.meetingId(), participant.sessionId(), outbound); + sendError(session, "Binary audio relay over WebSocket is not supported. Use WebRTC transport for live audio."); } // 정상 종료된 세션을 회의방에서 제거하고 퇴장 이벤트를 전파합니다. 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 e6f747c..683af8a 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 @@ -10,12 +10,10 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.socket.BinaryMessage; import org.springframework.web.socket.TextMessage; import org.springframework.web.socket.WebSocketMessage; import java.io.IOException; -import java.nio.ByteBuffer; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Comparator; @@ -87,15 +85,6 @@ public void broadcastText(Long meetingId, String payload) { participant -> false); } - // 발신자를 제외한 나머지 참가자들에게 오디오 바이너리 청크를 전달합니다. - public void broadcastAudio(Long meetingId, String senderSessionId, ByteBuffer payload) { - broadcast( - meetingId, - participant -> new BinaryMessage(payload.asReadOnlyBuffer()), - participant -> participant.sessionId().equals(senderSessionId) - ); - } - // 특정 대상 참가자 한 명에게만 시그널링 메시지를 전달합니다. public void sendToMember(Long meetingId, Long targetMemberId, String payload) { MeetingRoomRepository room = getRoom(meetingId); diff --git a/src/main/java/com/whylog/server/domain/team/repository/TeamMemberRepository.java b/src/main/java/com/whylog/server/domain/team/repository/TeamMemberRepository.java index c542960..1cf2528 100644 --- a/src/main/java/com/whylog/server/domain/team/repository/TeamMemberRepository.java +++ b/src/main/java/com/whylog/server/domain/team/repository/TeamMemberRepository.java @@ -5,4 +5,5 @@ import org.springframework.data.jpa.repository.JpaRepository; public interface TeamMemberRepository extends JpaRepository { + boolean existsByMemberIdAndTeamIdAndActiveTrue(Long memberId, Long teamId); } diff --git a/src/main/java/com/whylog/server/domain/user/entity/Member.java b/src/main/java/com/whylog/server/domain/user/entity/Member.java index ad6327f..4729058 100644 --- a/src/main/java/com/whylog/server/domain/user/entity/Member.java +++ b/src/main/java/com/whylog/server/domain/user/entity/Member.java @@ -52,11 +52,11 @@ private Member(String name, String email, String password, String profileImage, this.role = role; } - public static Member create(AuthRequest.SignUpDTO dto, Role role) { + public static Member create(AuthRequest.SignUpDTO dto, String password, Role role) { return Member.builder() .name(dto.getName()) .email(dto.getEmail()) - .password(dto.getPassword()) + .password(password) .role(role) .build(); } diff --git a/src/main/java/com/whylog/server/domain/user/service/LocalLoginService.java b/src/main/java/com/whylog/server/domain/user/service/LocalLoginService.java index aedd0e9..9b03571 100644 --- a/src/main/java/com/whylog/server/domain/user/service/LocalLoginService.java +++ b/src/main/java/com/whylog/server/domain/user/service/LocalLoginService.java @@ -26,7 +26,8 @@ public AuthResponse.LoginResponseDTO signUp(AuthRequest.SignUpDTO request) { throw new ErrorHandler(AuthErrorStatus.EMAIL_ALREADY_EXISTS); } - Member member = memberRepository.save(Member.create(request, Role.USER)); + String encodedPassword = passwordEncoder.encode(request.getPassword()); + Member member = memberRepository.save(Member.create(request, encodedPassword, Role.USER)); return authenticationService.generateLoginResponse(member); } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 93652a7..ee8d47f 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -44,3 +44,9 @@ jwt: secret: ${JWT_SECRET} access-token-expire-time: ${JWT_ACCESS_TOKEN_EXPIRE_TIME:3600000} refresh-token-expire-time: ${JWT_REFRESH_TOKEN_EXPIRE_TIME:1209600000} + +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}