Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 = """
Expand Down Expand Up @@ -66,6 +68,8 @@ public ApiResponse<List<MeetingResponse.MeetingListDTO>> getMeetings(

새로운 회의를 생성하는 API입니다. 생성하면 실시긴 회의방이 하나 생성됩니다.
해당 API는 방 생성만 담당합니다. 회의 참여를 위해서는 웹소켓 연결이 필요합니다.
웹소켓은 JWT 인증, 참여자 상태, WebRTC 시그널링 처리만 담당합니다.
실제 실시간 음성 전달은 WebRTC/SFU 경로를 사용합니다.

""")
public ApiResponse<MeetingResponse.MeetingCreateResponseDTO> createMeeting(
Expand All @@ -76,10 +80,23 @@ public ApiResponse<MeetingResponse.MeetingCreateResponseDTO> createMeeting(
return ApiResponse.onSuccess(result);
}

@GetMapping("/meetings/{meetingId}/rtc-token")
@Operation(summary = "회의 SFU 접속 토큰 발급 API", description = """
현재 로그인한 사용자가 해당 회의의 LiveKit SFU room에 접속할 수 있도록 join token을 발급합니다.
프론트는 이 토큰과 serverUrl을 사용해 WebRTC 음성 연결을 수립합니다.
""")
public ApiResponse<MeetingResponse.MeetingRtcTokenDTO> 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<MeetingResponse.MeetingEndResponseDTO> endMeeting(
@Parameter(hidden= true) @CurrentMember Long memberId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, Object> 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));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.");
}

// 정상 종료된 세션을 회의방에서 제거하고 퇴장 이벤트를 전파합니다.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@
import org.springframework.data.jpa.repository.JpaRepository;

public interface TeamMemberRepository extends JpaRepository<TeamMember, TeamMemberId> {
boolean existsByMemberIdAndTeamIdAndActiveTrue(Long memberId, Long teamId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
6 changes: 6 additions & 0 deletions src/main/resources/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Loading