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:
2026-06-23 17:51:21 +09:00
parent 85b6b65e24
commit f519723260
7 changed files with 173 additions and 10 deletions
@@ -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],
+58 -7
View File
@@ -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';
}
}
}