feat: 화상회의 단계 2 — 권한 분기(생성자=진행자)
방을 처음 만든 사용자를 진행자로 보고, 역할별로 LiveKit 권한을 차등 부여한다.
백엔드
- meeting_rooms 엔티티/마이그레이션: 방 이름(고유)→진행자(host) 보관
- 토큰 발급 시 역할 결정: 방이 없으면 생성자=진행자, 있으면 생성자 일치로 판정
(동시 생성 경쟁은 name unique 충돌 후 재조회로 처리)
- grant 차등: 진행자 roomAdmin+발행/구독, 일반 발행/구독
- 역할을 토큰 metadata({"role":...})에 실어 다른 참여자도 진행자 식별
- 응답에 role 추가
프론트
- useMeeting: role 반환
- ParticipantTile: metadata 파싱 → 진행자 타일에 "진행자" 배지
- MeetingPage: 본인이 진행자면 헤더에 역할 칩 표시
방 목록/예약/참여 제한 UI 는 단계 4 로 분리.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
// 화상회의 방(meeting_rooms) 테이블 추가 — 방 이름(고유)과 진행자(host) 보관.
|
||||
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
|
||||
export class AddMeetingRooms1782700000000 implements MigrationInterface {
|
||||
name = 'AddMeetingRooms1782700000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "meeting_rooms" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "host_id" uuid, CONSTRAINT "PK_meeting_rooms" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "IDX_meeting_rooms_name" ON "meeting_rooms" ("name")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "meeting_rooms" ADD CONSTRAINT "FK_meeting_rooms_host" FOREIGN KEY ("host_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "meeting_rooms" DROP CONSTRAINT "FK_meeting_rooms_host"`,
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX "IDX_meeting_rooms_name"`);
|
||||
await queryRunner.query(`DROP TABLE "meeting_rooms"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
type Relation,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../user/entities/user.entity';
|
||||
|
||||
// 화상회의 방 — 방 이름과 진행자(생성자)를 보관한다.
|
||||
// 단계 2(경량): 방 이름으로 토큰을 처음 발급받은 사용자가 진행자(host)가 되고,
|
||||
// 이후 입장자는 일반 참여자가 된다. (방 목록/예약/참여 제한 UI 는 단계 4)
|
||||
@Entity('meeting_rooms')
|
||||
export class MeetingRoom {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
// 방 이름(고유) — 같은 이름으로 들어온 사람끼리 연결된다
|
||||
@Index({ unique: true })
|
||||
@Column()
|
||||
name!: string;
|
||||
|
||||
// 진행자(생성자). 사용자 삭제 시에도 방 기록은 유지(SET NULL)
|
||||
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
|
||||
@JoinColumn({ name: 'host_id' })
|
||||
host!: Relation<User> | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { MeetingController } from './meeting.controller';
|
||||
import { MeetingService } from './meeting.service';
|
||||
import { MeetingRoom } from './entities/meeting-room.entity';
|
||||
|
||||
// 화상회의 모듈(단계 1) — LiveKit 입장 토큰 발급.
|
||||
// 화상회의 모듈 — LiveKit 입장 토큰 발급 + 방/진행자(생성자) 보관.
|
||||
// ConfigModule 은 전역(app.module)에서 등록되어 있어 별도 import 불필요.
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([MeetingRoom])],
|
||||
controllers: [MeetingController],
|
||||
providers: [MeetingService],
|
||||
exports: [MeetingService],
|
||||
|
||||
@@ -1,25 +1,38 @@
|
||||
import { HttpStatus, Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { AccessToken } from 'livekit-server-sdk';
|
||||
import { BusinessException } from '../../common/exceptions/business.exception';
|
||||
import type { PublicUser } from '../user/user.service';
|
||||
import { User } from '../user/entities/user.entity';
|
||||
import { MeetingRoom } from './entities/meeting-room.entity';
|
||||
|
||||
// 회의 내 역할 — 진행자(방 생성자) / 일반 참여자
|
||||
export type MeetingRole = 'host' | 'participant';
|
||||
|
||||
// 화상회의 토큰 발급 결과 — 브라우저는 자체 VITE_LIVEKIT_URL 로 접속하므로 URL 은 반환하지 않는다.
|
||||
export interface MeetingTokenResult {
|
||||
token: string; // LiveKit 입장 JWT
|
||||
roomName: string;
|
||||
identity: string; // 참여자 식별자(사용자 ID)
|
||||
role: MeetingRole; // 이번 입장자의 역할
|
||||
}
|
||||
|
||||
// 화상회의 서비스(단계 1: 인프라 토대) — LiveKit 입장 토큰(JWT) 발급을 담당한다.
|
||||
// 미디어 전송은 LiveKit(SFU)에 위임하고, 여기서는 인증된 사용자에게 권한이 담긴 토큰만 만든다.
|
||||
// 화상회의 서비스(단계 2: 권한 분기) — LiveKit 입장 토큰(JWT) 발급을 담당한다.
|
||||
// 미디어 전송은 LiveKit(SFU)에 위임하고, 여기서는 인증된 사용자에게 역할별 권한이 담긴 토큰을 만든다.
|
||||
// 역할: 방을 처음 만든 사용자가 진행자(host), 이후 입장자는 일반 참여자(participant).
|
||||
@Injectable()
|
||||
export class MeetingService {
|
||||
private readonly logger = new Logger(MeetingService.name);
|
||||
private readonly apiKey: string;
|
||||
private readonly apiSecret: string;
|
||||
|
||||
constructor(config: ConfigService) {
|
||||
constructor(
|
||||
config: ConfigService,
|
||||
@InjectRepository(MeetingRoom)
|
||||
private readonly roomRepo: Repository<MeetingRoom>,
|
||||
) {
|
||||
this.apiKey = (config.get<string>('LIVEKIT_API_KEY') ?? '').trim();
|
||||
this.apiSecret = (config.get<string>('LIVEKIT_API_SECRET') ?? '').trim();
|
||||
if (!this.enabled) {
|
||||
@@ -33,9 +46,10 @@ export class MeetingService {
|
||||
return this.apiKey.length > 0 && this.apiSecret.length > 0;
|
||||
}
|
||||
|
||||
// LiveKit 입장 토큰 발급
|
||||
// 단계 1 에서는 모든 참여자에게 발행(publish)·구독(subscribe) 권한을 부여한다.
|
||||
// (관리자/일반 사용자 역할에 따른 권한 분기는 단계 2에서 구현)
|
||||
// LiveKit 입장 토큰 발급 — 역할에 따라 권한(grant)을 차등 부여한다.
|
||||
// · 진행자(host): roomAdmin + 발행/구독 (단계 3의 화면 승격 등 진행자 기능 토대)
|
||||
// · 일반(participant): 발행/구독
|
||||
// 역할은 토큰 metadata 에도 실어 다른 참여자가 진행자를 식별할 수 있게 한다.
|
||||
async issueToken(
|
||||
user: PublicUser,
|
||||
roomName: string,
|
||||
@@ -48,10 +62,14 @@ export class MeetingService {
|
||||
);
|
||||
}
|
||||
|
||||
const role = await this.resolveRole(user.id, roomName);
|
||||
const isHost = role === 'host';
|
||||
|
||||
const at = new AccessToken(this.apiKey, this.apiSecret, {
|
||||
identity: user.id,
|
||||
name: user.name,
|
||||
ttl: '1h',
|
||||
metadata: JSON.stringify({ role }),
|
||||
});
|
||||
at.addGrant({
|
||||
roomJoin: true,
|
||||
@@ -59,9 +77,42 @@ export class MeetingService {
|
||||
canPublish: true,
|
||||
canSubscribe: true,
|
||||
canPublishData: true,
|
||||
roomAdmin: isHost,
|
||||
});
|
||||
|
||||
const token = await at.toJwt();
|
||||
return { token, roomName, identity: user.id };
|
||||
return { token, roomName, identity: user.id, role };
|
||||
}
|
||||
|
||||
// 방의 역할 결정 — 방이 없으면 이 사용자를 진행자로 생성, 있으면 생성자 일치 여부로 판정.
|
||||
private async resolveRole(
|
||||
userId: string,
|
||||
roomName: string,
|
||||
): Promise<MeetingRole> {
|
||||
const existing = await this.roomRepo.findOne({
|
||||
where: { name: roomName },
|
||||
relations: ['host'],
|
||||
});
|
||||
if (existing) {
|
||||
return existing.host?.id === userId ? 'host' : 'participant';
|
||||
}
|
||||
|
||||
// 방이 없으면 생성 시도 — 이 사용자가 진행자가 된다.
|
||||
try {
|
||||
await this.roomRepo.save(
|
||||
this.roomRepo.create({
|
||||
name: roomName,
|
||||
host: { id: userId } as User,
|
||||
}),
|
||||
);
|
||||
return 'host';
|
||||
} catch {
|
||||
// 동시 생성 경쟁(name unique 충돌) — 먼저 만든 사람이 진행자. 재조회로 판정.
|
||||
const room = await this.roomRepo.findOne({
|
||||
where: { name: roomName },
|
||||
relations: ['host'],
|
||||
});
|
||||
return room?.host?.id === userId ? 'host' : 'participant';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user