feat: 화상회의 단계 1 — LiveKit 기반 인프라 토대
LiveKit(SFU)에 미디어 전송을 위임하고, 앱은 입장 토큰 발급 + 회의 화면만 담당하는 구조로 단계 1(기본 연결)을 구현한다. 백엔드 - meeting 모듈: POST /api/meetings/token (JWT 인증) livekit-server-sdk 로 입장 JWT 발급(identity=userId, name, grants: roomJoin/publish/subscribe/publishData, TTL 1h). 단계 2에서 역할별 분기 예정. 프론트 - useMeeting(토큰 발급), useLiveKitRoom(연결/참여자/컨트롤 상태) - ParticipantTile(카메라/마이크 트랙 attach, 본인 거울·음소거, 이니셜 플레이스홀더) - MeetingPage(로비 + 영상 그리드), 라우트 /meeting/:room?, 사이드바 네비 추가 - VITE_LIVEKIT_URL 주입(build arg + dev 런타임 env) 인프라/설정 - 개발 미디어 서버는 LiveKit Cloud 사용(로컬 self-host 는 WSL2/Docker Desktop WebRTC 네트워킹 제약으로 제외). env 만으로 운영 전환 가능. - livekit/livekit.yaml 은 단계 6 셀프호스팅 참고용으로 보관. - 화상회의_화면명세.md: 현재 화면 기능/구조/데이터 계약 문서(UI 개편 참고). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,7 @@ import { NotificationModule } from './modules/notification/notification.module';
|
||||
import { AiModule } from './modules/ai/ai.module';
|
||||
import { ProjectModule } from './modules/project/project.module';
|
||||
import { ScheduleModule } from './modules/schedule/schedule.module';
|
||||
import { MeetingModule } from './modules/meeting/meeting.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -100,6 +101,7 @@ import { ScheduleModule } from './modules/schedule/schedule.module';
|
||||
AiModule,
|
||||
ProjectModule,
|
||||
ScheduleModule,
|
||||
MeetingModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString, Length, Matches } from 'class-validator';
|
||||
|
||||
// 화상회의 입장 토큰 발급 요청 — 참여할 방 이름을 전달한다.
|
||||
export class CreateMeetingTokenDto {
|
||||
@ApiProperty({
|
||||
description: '참여할 회의 방 이름(영문/숫자/-/_ 만 허용)',
|
||||
example: 'team-standup',
|
||||
})
|
||||
@IsString()
|
||||
@Length(1, 64, { message: '방 이름은 1~64자여야 합니다.' })
|
||||
@Matches(/^[A-Za-z0-9_-]+$/, {
|
||||
message: '방 이름은 영문, 숫자, 하이픈(-), 밑줄(_)만 사용할 수 있습니다.',
|
||||
})
|
||||
roomName!: string;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
|
||||
import { ApiOperation, ApiResponse, ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import type { PublicUser } from '../user/user.service';
|
||||
import { MeetingService, type MeetingTokenResult } from './meeting.service';
|
||||
import { CreateMeetingTokenDto } from './dto/create-meeting-token.dto';
|
||||
|
||||
// 화상회의 컨트롤러 — 인증 사용자에게 LiveKit 입장 토큰을 발급한다.
|
||||
@ApiTags('화상회의')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('meetings')
|
||||
export class MeetingController {
|
||||
constructor(private readonly meetingService: MeetingService) {}
|
||||
|
||||
@Post('token')
|
||||
@ApiOperation({ summary: 'LiveKit 입장 토큰 발급' })
|
||||
@ApiResponse({ status: 201, description: '토큰 발급 성공' })
|
||||
issueToken(
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Body() dto: CreateMeetingTokenDto,
|
||||
): Promise<MeetingTokenResult> {
|
||||
return this.meetingService.issueToken(user, dto.roomName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MeetingController } from './meeting.controller';
|
||||
import { MeetingService } from './meeting.service';
|
||||
|
||||
// 화상회의 모듈(단계 1) — LiveKit 입장 토큰 발급.
|
||||
// ConfigModule 은 전역(app.module)에서 등록되어 있어 별도 import 불필요.
|
||||
@Module({
|
||||
controllers: [MeetingController],
|
||||
providers: [MeetingService],
|
||||
exports: [MeetingService],
|
||||
})
|
||||
export class MeetingModule {}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { HttpStatus, Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AccessToken } from 'livekit-server-sdk';
|
||||
import { BusinessException } from '../../common/exceptions/business.exception';
|
||||
import type { PublicUser } from '../user/user.service';
|
||||
|
||||
// 화상회의 토큰 발급 결과 — 브라우저는 자체 VITE_LIVEKIT_URL 로 접속하므로 URL 은 반환하지 않는다.
|
||||
export interface MeetingTokenResult {
|
||||
token: string; // LiveKit 입장 JWT
|
||||
roomName: string;
|
||||
identity: string; // 참여자 식별자(사용자 ID)
|
||||
}
|
||||
|
||||
// 화상회의 서비스(단계 1: 인프라 토대) — LiveKit 입장 토큰(JWT) 발급을 담당한다.
|
||||
// 미디어 전송은 LiveKit(SFU)에 위임하고, 여기서는 인증된 사용자에게 권한이 담긴 토큰만 만든다.
|
||||
@Injectable()
|
||||
export class MeetingService {
|
||||
private readonly logger = new Logger(MeetingService.name);
|
||||
private readonly apiKey: string;
|
||||
private readonly apiSecret: string;
|
||||
|
||||
constructor(config: ConfigService) {
|
||||
this.apiKey = (config.get<string>('LIVEKIT_API_KEY') ?? '').trim();
|
||||
this.apiSecret = (config.get<string>('LIVEKIT_API_SECRET') ?? '').trim();
|
||||
if (!this.enabled) {
|
||||
this.logger.warn(
|
||||
'LIVEKIT_API_KEY/LIVEKIT_API_SECRET 미설정 — 화상회의 토큰 발급 비활성화',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
get enabled(): boolean {
|
||||
return this.apiKey.length > 0 && this.apiSecret.length > 0;
|
||||
}
|
||||
|
||||
// LiveKit 입장 토큰 발급
|
||||
// 단계 1 에서는 모든 참여자에게 발행(publish)·구독(subscribe) 권한을 부여한다.
|
||||
// (관리자/일반 사용자 역할에 따른 권한 분기는 단계 2에서 구현)
|
||||
async issueToken(
|
||||
user: PublicUser,
|
||||
roomName: string,
|
||||
): Promise<MeetingTokenResult> {
|
||||
if (!this.enabled) {
|
||||
throw new BusinessException(
|
||||
'SYS_001',
|
||||
'화상회의 서비스가 아직 설정되지 않았습니다. 잠시 후 다시 시도해 주세요.',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
|
||||
const at = new AccessToken(this.apiKey, this.apiSecret, {
|
||||
identity: user.id,
|
||||
name: user.name,
|
||||
ttl: '1h',
|
||||
});
|
||||
at.addGrant({
|
||||
roomJoin: true,
|
||||
room: roomName,
|
||||
canPublish: true,
|
||||
canSubscribe: true,
|
||||
canPublishData: true,
|
||||
});
|
||||
|
||||
const token = await at.toJwt();
|
||||
return { token, roomName, identity: user.id };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user