Merge feature/video-meeting-phase2: 화상회의 단계 2(권한 분기)
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';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,18 @@ const videoEl = ref<HTMLVideoElement | null>(null)
|
||||
const audioEl = ref<HTMLAudioElement | null>(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 ? '🎙' : '🔇' }}</span>
|
||||
<span class="name">{{ displayName }}{{ props.isLocal ? ' (나)' : '' }}</span>
|
||||
<span
|
||||
v-if="isHost"
|
||||
class="host-badge"
|
||||
title="진행자"
|
||||
>진행자</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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 를 언래핑
|
||||
|
||||
@@ -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<string | null>(null)
|
||||
// 본인 역할(진행자/일반) — 입장 시 토큰 응답에서 받음
|
||||
const myRole = ref<MeetingRole | null>(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(() => {
|
||||
<div class="room-bar">
|
||||
<div class="room-info">
|
||||
<span class="room-name">{{ roomName }}</span>
|
||||
<span
|
||||
v-if="myRole === 'host'"
|
||||
class="role-chip"
|
||||
>진행자</span>
|
||||
<span class="room-count">참여자 {{ participantCount }}명</span>
|
||||
</div>
|
||||
<div class="controls">
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user