From f519723260c03e2ee311f38b9dfd21673229cbf9 Mon Sep 17 00:00:00 2001 From: ttipo Date: Tue, 23 Jun 2026 17:51:21 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=99=94=EC=83=81=ED=9A=8C=EC=9D=98=20?= =?UTF-8?q?=EB=8B=A8=EA=B3=84=202=20=E2=80=94=20=EA=B6=8C=ED=95=9C=20?= =?UTF-8?q?=EB=B6=84=EA=B8=B0(=EC=83=9D=EC=84=B1=EC=9E=90=3D=EC=A7=84?= =?UTF-8?q?=ED=96=89=EC=9E=90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 방을 처음 만든 사용자를 진행자로 보고, 역할별로 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) --- .../1782700000000-AddMeetingRooms.ts | 27 ++++++++ .../meeting/entities/meeting-room.entity.ts | 33 ++++++++++ backend/src/modules/meeting/meeting.module.ts | 5 +- .../src/modules/meeting/meeting.service.ts | 65 +++++++++++++++++-- .../components/meeting/ParticipantTile.vue | 28 ++++++++ frontend/src/composables/useMeeting.ts | 4 ++ frontend/src/pages/relay/MeetingPage.vue | 21 +++++- 7 files changed, 173 insertions(+), 10 deletions(-) create mode 100644 backend/src/migrations/1782700000000-AddMeetingRooms.ts create mode 100644 backend/src/modules/meeting/entities/meeting-room.entity.ts diff --git a/backend/src/migrations/1782700000000-AddMeetingRooms.ts b/backend/src/migrations/1782700000000-AddMeetingRooms.ts new file mode 100644 index 0000000..a77e4c9 --- /dev/null +++ b/backend/src/migrations/1782700000000-AddMeetingRooms.ts @@ -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 { + 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 { + 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"`); + } +} diff --git a/backend/src/modules/meeting/entities/meeting-room.entity.ts b/backend/src/modules/meeting/entities/meeting-room.entity.ts new file mode 100644 index 0000000..1067b81 --- /dev/null +++ b/backend/src/modules/meeting/entities/meeting-room.entity.ts @@ -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 | null; + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; +} diff --git a/backend/src/modules/meeting/meeting.module.ts b/backend/src/modules/meeting/meeting.module.ts index 468d651..8958160 100644 --- a/backend/src/modules/meeting/meeting.module.ts +++ b/backend/src/modules/meeting/meeting.module.ts @@ -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], diff --git a/backend/src/modules/meeting/meeting.service.ts b/backend/src/modules/meeting/meeting.service.ts index d516366..f3ad184 100644 --- a/backend/src/modules/meeting/meeting.service.ts +++ b/backend/src/modules/meeting/meeting.service.ts @@ -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, + ) { this.apiKey = (config.get('LIVEKIT_API_KEY') ?? '').trim(); this.apiSecret = (config.get('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 { + 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'; + } } } diff --git a/frontend/src/components/meeting/ParticipantTile.vue b/frontend/src/components/meeting/ParticipantTile.vue index e915f99..861b0d8 100644 --- a/frontend/src/components/meeting/ParticipantTile.vue +++ b/frontend/src/components/meeting/ParticipantTile.vue @@ -10,6 +10,18 @@ const videoEl = ref(null) const audioEl = ref(null) const hasVideo = ref(false) const micOn = ref(false) +// 진행자 여부 — 토큰 metadata({"role":"host"})에서 파생 +const isHost = ref(false) + +// 참여자 metadata 문자열에서 진행자 여부 파싱 +function parseHost(metadata: string | undefined): boolean { + if (!metadata) return false + try { + return (JSON.parse(metadata) as { role?: string }).role === 'host' + } catch { + return false + } +} // 표시 이름 — 토큰에 담은 name, 없으면 식별자 const displayName = ref(props.participant.name || props.participant.identity) @@ -19,6 +31,7 @@ function sync() { const p = props.participant displayName.value = p.name || p.identity micOn.value = p.isMicrophoneEnabled + isHost.value = parseHost(p.metadata) const camPub = p.getTrackPublication(Track.Source.Camera) if (camPub?.track && videoEl.value) { @@ -88,6 +101,11 @@ onBeforeUnmount(() => { :title="micOn ? '마이크 켜짐' : '마이크 꺼짐'" >{{ micOn ? '🎙' : '🔇' }} {{ displayName }}{{ props.isLocal ? ' (나)' : '' }} + 진행자 @@ -161,4 +179,14 @@ onBeforeUnmount(() => { .meta .mic.off { opacity: 0.6; } +.host-badge { + flex-shrink: 0; + padding: 0.0625rem 0.375rem; + border-radius: 0.25rem; + background: var(--accent); + color: #fff; + font-size: 0.625rem; + font-weight: 700; + line-height: 1.4; +} diff --git a/frontend/src/composables/useMeeting.ts b/frontend/src/composables/useMeeting.ts index 0f172b8..548b545 100644 --- a/frontend/src/composables/useMeeting.ts +++ b/frontend/src/composables/useMeeting.ts @@ -1,10 +1,14 @@ import { useApi } from './useApi' +// 회의 내 역할 — 진행자(방 생성자) / 일반 참여자 +export type MeetingRole = 'host' | 'participant' + // 화상회의 토큰 응답 — 백엔드 MeetingTokenResult 와 1:1 export interface MeetingToken { token: string // LiveKit 입장 JWT roomName: string identity: string // 참여자 식별자(사용자 ID) + role: MeetingRole // 이번 입장자의 역할 } // 화상회의 도메인 API Composable — 인터셉터가 success/data 를 언래핑 diff --git a/frontend/src/pages/relay/MeetingPage.vue b/frontend/src/pages/relay/MeetingPage.vue index 22c5014..a4c975c 100644 --- a/frontend/src/pages/relay/MeetingPage.vue +++ b/frontend/src/pages/relay/MeetingPage.vue @@ -5,7 +5,7 @@ import { computed, onBeforeUnmount, ref } from 'vue' import { useRoute, useRouter } from 'vue-router' import AppShell from '@/layouts/AppShell.vue' import ParticipantTile from '@/components/meeting/ParticipantTile.vue' -import { useMeeting } from '@/composables/useMeeting' +import { useMeeting, type MeetingRole } from '@/composables/useMeeting' import { useLiveKitRoom } from '@/composables/useLiveKitRoom' const route = useRoute() @@ -29,6 +29,8 @@ const roomName = ref( typeof route.params.room === 'string' ? route.params.room : '', ) const joinError = ref(null) +// 본인 역할(진행자/일반) — 입장 시 토큰 응답에서 받음 +const myRole = ref(null) const ROOM_PATTERN = /^[A-Za-z0-9_-]+$/ @@ -45,7 +47,8 @@ async function onJoin() { } joinError.value = null try { - const { token } = await meetingApi.getToken(name) + const { token, role } = await meetingApi.getToken(name) + myRole.value = role await connect(token) // 주소창을 공유 가능한 형태로 갱신(히스토리 오염 방지 위해 replace) if (route.params.room !== name) { @@ -60,6 +63,7 @@ async function onJoin() { // 나가기 — 연결 해제 후 로비로 async function onLeave() { await disconnect() + myRole.value = null void router.replace('/meeting') } @@ -131,6 +135,10 @@ onBeforeUnmount(() => {
{{ roomName }} + 진행자 참여자 {{ participantCount }}명
@@ -295,6 +303,15 @@ onBeforeUnmount(() => { font-size: 1.125rem; font-weight: 700; } +.role-chip { + padding: 0.0625rem 0.4375rem; + border-radius: 0.25rem; + background: var(--accent-weak); + color: var(--accent); + border: 1px solid var(--accent-border); + font-size: 0.6875rem; + font-weight: 700; +} .room-count { font-size: 0.781rem; color: var(--text-3);