From 54fe3d36e78833598e5401534377f9a0ce851e0a Mon Sep 17 00:00:00 2001 From: ttipo Date: Tue, 23 Jun 2026 20:48:51 +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=204=20=E2=80=94=20=EB=B0=A9=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC(=EB=AA=A9=EB=A1=9D=C2=B7=EC=83=9D=EC=84=B1=C2=B7?= =?UTF-8?q?=EC=98=88=EC=95=BD=C2=B7=EC=B0=B8=EC=97=AC=20=EC=A0=9C=ED=95=9C?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 암묵적 방 생성을 명시적 방 관리로 확장한다. (기능 완성 + 임시 UI) 백엔드 - meeting_rooms 에 scheduled_at(예약), allowed_user_ids(참여 허용 allow-list) 추가 + 마이그레이션 - GET /meetings/rooms(목록: 접근 가능/진행자 여부 포함) - POST /meetings/rooms(생성: name+예약+허용멤버, 생성자=진행자, 이름 중복 409) - DELETE /meetings/rooms/:id(진행자만, 아니면 403) - 토큰 발급 시 참여 제한 enforcement: 비허용자는 AUTH_003(403). 방이 없으면 자동 생성(공개)으로 직접 링크 입장은 유지. 프론트 - useMeeting: listRooms/createRoom/removeRoom + types/meeting.ts - MeetingPage 로비: 방 목록(입장/삭제) + 생성 폼(예약시각·참여 제한 멤버 피커, useUser 검색 재사용) + 빠른 입장 - 화상회의_화면명세.md 단계 4 반영(범위/로비/데이터 계약/엔드포인트) 권한 모델은 기존 결정대로 "누구나 생성=진행자" 유지(전역 admin 없음). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../1782800000000-AddMeetingRoomFields.ts | 25 + .../meeting/dto/create-meeting-room.dto.ts | 43 ++ .../meeting/entities/meeting-room.entity.ts | 9 + .../src/modules/meeting/meeting.controller.ts | 57 ++- .../src/modules/meeting/meeting.service.ts | 160 +++++- frontend/src/composables/useMeeting.ts | 26 +- frontend/src/pages/relay/MeetingPage.vue | 473 +++++++++++++++++- frontend/src/types/meeting.ts | 23 + 화상회의_화면명세.md | 39 +- 9 files changed, 792 insertions(+), 63 deletions(-) create mode 100644 backend/src/migrations/1782800000000-AddMeetingRoomFields.ts create mode 100644 backend/src/modules/meeting/dto/create-meeting-room.dto.ts create mode 100644 frontend/src/types/meeting.ts diff --git a/backend/src/migrations/1782800000000-AddMeetingRoomFields.ts b/backend/src/migrations/1782800000000-AddMeetingRoomFields.ts new file mode 100644 index 0000000..22a0680 --- /dev/null +++ b/backend/src/migrations/1782800000000-AddMeetingRoomFields.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +// 화상회의 방에 예약 시각/참여 허용 사용자 컬럼 추가 (단계 4: 방 관리). +// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다. +export class AddMeetingRoomFields1782800000000 implements MigrationInterface { + name = 'AddMeetingRoomFields1782800000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "meeting_rooms" ADD "scheduled_at" TIMESTAMP`, + ); + await queryRunner.query( + `ALTER TABLE "meeting_rooms" ADD "allowed_user_ids" text`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "meeting_rooms" DROP COLUMN "allowed_user_ids"`, + ); + await queryRunner.query( + `ALTER TABLE "meeting_rooms" DROP COLUMN "scheduled_at"`, + ); + } +} diff --git a/backend/src/modules/meeting/dto/create-meeting-room.dto.ts b/backend/src/modules/meeting/dto/create-meeting-room.dto.ts new file mode 100644 index 0000000..991d66c --- /dev/null +++ b/backend/src/modules/meeting/dto/create-meeting-room.dto.ts @@ -0,0 +1,43 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + ArrayMaxSize, + IsArray, + IsISO8601, + IsOptional, + IsString, + IsUUID, + Length, + Matches, +} from 'class-validator'; + +// 화상회의 방 생성 요청 +export class CreateMeetingRoomDto { + @ApiProperty({ + description: '방 이름(영문/숫자/-/_ 만 허용, 고유)', + example: 'weekly-sync', + }) + @IsString() + @Length(1, 64, { message: '방 이름은 1~64자여야 합니다.' }) + @Matches(/^[A-Za-z0-9_-]+$/, { + message: '방 이름은 영문, 숫자, 하이픈(-), 밑줄(_)만 사용할 수 있습니다.', + }) + name!: string; + + @ApiPropertyOptional({ + description: '예약 시각(ISO 8601). 미지정 시 즉시 사용 가능한 방', + example: '2026-07-01T10:00:00.000Z', + }) + @IsOptional() + @IsISO8601({}, { message: '예약 시각 형식이 올바르지 않습니다.' }) + scheduledAt?: string; + + @ApiPropertyOptional({ + description: '참여 허용 사용자 ID 목록. 비우면 전체 공개', + type: [String], + }) + @IsOptional() + @IsArray() + @ArrayMaxSize(160, { message: '참여 허용 인원은 최대 160명입니다.' }) + @IsUUID('4', { each: true, message: '사용자 ID 형식이 올바르지 않습니다.' }) + allowedUserIds?: string[]; +} diff --git a/backend/src/modules/meeting/entities/meeting-room.entity.ts b/backend/src/modules/meeting/entities/meeting-room.entity.ts index 1067b81..204130a 100644 --- a/backend/src/modules/meeting/entities/meeting-room.entity.ts +++ b/backend/src/modules/meeting/entities/meeting-room.entity.ts @@ -28,6 +28,15 @@ export class MeetingRoom { @JoinColumn({ name: 'host_id' }) host!: Relation | null; + // 예약 시각(선택) — 표시용. 현재는 시간 전 입장을 막지 않는다. + @Column({ name: 'scheduled_at', type: 'timestamp', nullable: true }) + scheduledAt!: Date | null; + + // 참여 허용 사용자 ID 목록(선택) — 비어있으면(null) 전체 공개. + // 값이 있으면 진행자 + 목록에 포함된 사용자만 입장 가능. + @Column({ name: 'allowed_user_ids', type: 'simple-array', nullable: true }) + allowedUserIds!: string[] | null; + @CreateDateColumn({ name: 'created_at' }) createdAt!: Date; } diff --git a/backend/src/modules/meeting/meeting.controller.ts b/backend/src/modules/meeting/meeting.controller.ts index 39fa87a..c805eb3 100644 --- a/backend/src/modules/meeting/meeting.controller.ts +++ b/backend/src/modules/meeting/meeting.controller.ts @@ -1,12 +1,33 @@ -import { Body, Controller, Post, UseGuards } from '@nestjs/common'; -import { ApiOperation, ApiResponse, ApiTags, ApiBearerAuth } from '@nestjs/swagger'; +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseUUIDPipe, + 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 { + MeetingService, + type MeetingTokenResult, + type MeetingRoomResponse, +} from './meeting.service'; import { CreateMeetingTokenDto } from './dto/create-meeting-token.dto'; +import { CreateMeetingRoomDto } from './dto/create-meeting-room.dto'; -// 화상회의 컨트롤러 — 인증 사용자에게 LiveKit 입장 토큰을 발급한다. +// 화상회의 컨트롤러 — 방 관리 + LiveKit 입장 토큰 발급(인증 필요). @ApiTags('화상회의') @ApiBearerAuth() @UseGuards(JwtAuthGuard) @@ -14,6 +35,34 @@ import { CreateMeetingTokenDto } from './dto/create-meeting-token.dto'; export class MeetingController { constructor(private readonly meetingService: MeetingService) {} + @Get('rooms') + @ApiOperation({ summary: '회의 방 목록' }) + @ApiResponse({ status: 200, description: '목록 조회 성공' }) + listRooms(@CurrentUser() user: PublicUser): Promise { + return this.meetingService.listRooms(user.id); + } + + @Post('rooms') + @ApiOperation({ summary: '회의 방 생성(생성자=진행자)' }) + @ApiResponse({ status: 201, description: '생성 성공' }) + createRoom( + @CurrentUser() user: PublicUser, + @Body() dto: CreateMeetingRoomDto, + ): Promise { + return this.meetingService.createRoom(user.id, dto); + } + + @Delete('rooms/:roomId') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: '회의 방 삭제(진행자만)' }) + @ApiResponse({ status: 204, description: '삭제 성공' }) + async deleteRoom( + @CurrentUser() user: PublicUser, + @Param('roomId', ParseUUIDPipe) roomId: string, + ): Promise { + await this.meetingService.deleteRoom(user.id, roomId); + } + @Post('token') @ApiOperation({ summary: 'LiveKit 입장 토큰 발급' }) @ApiResponse({ status: 201, description: '토큰 발급 성공' }) diff --git a/backend/src/modules/meeting/meeting.service.ts b/backend/src/modules/meeting/meeting.service.ts index f3ad184..c7d53f7 100644 --- a/backend/src/modules/meeting/meeting.service.ts +++ b/backend/src/modules/meeting/meeting.service.ts @@ -4,13 +4,26 @@ 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 { UserService, type PublicUser } from '../user/user.service'; import { User } from '../user/entities/user.entity'; import { MeetingRoom } from './entities/meeting-room.entity'; +import { CreateMeetingRoomDto } from './dto/create-meeting-room.dto'; // 회의 내 역할 — 진행자(방 생성자) / 일반 참여자 export type MeetingRole = 'host' | 'participant'; +// 방 목록/생성 응답 — 진행자·예약·참여제한·접근 가능 여부 포함 +export interface MeetingRoomResponse { + id: string; + name: string; + host: PublicUser | null; + scheduledAt: string | null; + restricted: boolean; // 참여 제한(allow-list) 적용 여부 + isHost: boolean; // 현재 사용자가 진행자인지 + canJoin: boolean; // 현재 사용자가 입장 가능한지(공개 OR 진행자 OR 허용목록 포함) + createdAt: string; +} + // 화상회의 토큰 발급 결과 — 브라우저는 자체 VITE_LIVEKIT_URL 로 접속하므로 URL 은 반환하지 않는다. export interface MeetingTokenResult { token: string; // LiveKit 입장 JWT @@ -62,7 +75,7 @@ export class MeetingService { ); } - const role = await this.resolveRole(user.id, roomName); + const role = await this.resolveAccess(user.id, roomName); const isHost = role === 'host'; const at = new AccessToken(this.apiKey, this.apiSecret, { @@ -84,35 +97,134 @@ export class MeetingService { return { token, roomName, identity: user.id, role }; } - // 방의 역할 결정 — 방이 없으면 이 사용자를 진행자로 생성, 있으면 생성자 일치 여부로 판정. - private async resolveRole( + // 입장 권한 판정 + 역할 결정. + // · 방이 없으면 이 사용자를 진행자로 생성(공개 방) — 직접 링크 입장 호환. + // · 방이 있으면 참여 제한(allow-list)을 검사하고, 생성자 일치로 역할을 판정. + private async resolveAccess( userId: string, roomName: string, ): Promise { - const existing = await this.roomRepo.findOne({ + let room = 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'; + if (!room) { + // 방이 없으면 생성 시도 — 이 사용자가 진행자가 된다. + try { + await this.roomRepo.save( + this.roomRepo.create({ + name: roomName, + host: { id: userId } as User, + }), + ); + return 'host'; + } catch { + // 동시 생성 경쟁(name unique 충돌) — 재조회로 판정. + room = await this.roomRepo.findOne({ + where: { name: roomName }, + relations: ['host'], + }); + } } + if (!room) return 'participant'; + + const isHost = room.host?.id === userId; + if ( + !isHost && + this.isRestricted(room) && + !room.allowedUserIds!.includes(userId) + ) { + throw new BusinessException( + 'AUTH_003', + '이 회의에 참여할 권한이 없습니다.', + HttpStatus.FORBIDDEN, + ); + } + return isHost ? 'host' : 'participant'; + } + + // 명시적 방 생성 — 생성자가 진행자. 예약 시각/참여 제한을 함께 설정. + async createRoom( + userId: string, + dto: CreateMeetingRoomDto, + ): Promise { + const existing = await this.roomRepo.findOne({ where: { name: dto.name } }); + if (existing) { + throw new BusinessException( + 'BIZ_001', + '이미 존재하는 방 이름입니다. 다른 이름을 사용해 주세요.', + HttpStatus.CONFLICT, + ); + } + const saved = await this.roomRepo.save( + this.roomRepo.create({ + name: dto.name, + host: { id: userId } as User, + scheduledAt: dto.scheduledAt ? new Date(dto.scheduledAt) : null, + allowedUserIds: dto.allowedUserIds?.length ? dto.allowedUserIds : null, + }), + ); + const room = await this.roomRepo.findOne({ + where: { id: saved.id }, + relations: ['host'], + }); + return this.toRoomResponse(room!, userId); + } + + // 방 목록 — 전체 방(최근 생성순). 각 방의 접근 가능 여부/진행자 여부를 포함. + async listRooms(userId: string): Promise { + const rows = await this.roomRepo.find({ + relations: ['host'], + order: { createdAt: 'DESC' }, + }); + return rows.map((r) => this.toRoomResponse(r, userId)); + } + + // 방 삭제 — 진행자(생성자)만 가능. + async deleteRoom(userId: string, roomId: string): Promise { + const room = await this.roomRepo.findOne({ + where: { id: roomId }, + relations: ['host'], + }); + if (!room) { + throw new BusinessException( + 'RES_001', + '방을 찾을 수 없습니다.', + HttpStatus.NOT_FOUND, + ); + } + if (room.host?.id !== userId) { + throw new BusinessException( + 'AUTH_003', + '방 삭제 권한이 없습니다.', + HttpStatus.FORBIDDEN, + ); + } + await this.roomRepo.remove(room); + } + + private isRestricted(room: MeetingRoom): boolean { + return !!(room.allowedUserIds && room.allowedUserIds.length > 0); + } + + private toRoomResponse( + room: MeetingRoom, + userId: string, + ): MeetingRoomResponse { + const isHost = room.host?.id === userId; + const restricted = this.isRestricted(room); + const canJoin = + isHost || !restricted || room.allowedUserIds!.includes(userId); + return { + id: room.id, + name: room.name, + host: room.host ? UserService.toPublic(room.host) : null, + scheduledAt: room.scheduledAt ? room.scheduledAt.toISOString() : null, + restricted, + isHost, + canJoin, + createdAt: room.createdAt.toISOString(), + }; } } diff --git a/frontend/src/composables/useMeeting.ts b/frontend/src/composables/useMeeting.ts index 548b545..34e8407 100644 --- a/frontend/src/composables/useMeeting.ts +++ b/frontend/src/composables/useMeeting.ts @@ -1,4 +1,8 @@ import { useApi } from './useApi' +import type { + ApiMeetingRoom, + CreateMeetingRoomPayload, +} from '@/types/meeting' // 회의 내 역할 — 진행자(방 생성자) / 일반 참여자 export type MeetingRole = 'host' | 'participant' @@ -22,5 +26,25 @@ export function useMeeting() { })) as unknown as MeetingToken } - return { getToken } + // 방 목록 + async function listRooms(): Promise { + return (await api.get('/meetings/rooms')) as unknown as ApiMeetingRoom[] + } + + // 방 생성(생성자=진행자) + async function createRoom( + payload: CreateMeetingRoomPayload, + ): Promise { + return (await api.post( + '/meetings/rooms', + payload, + )) as unknown as ApiMeetingRoom + } + + // 방 삭제(진행자만) + async function removeRoom(roomId: string): Promise { + await api.delete(`/meetings/rooms/${roomId}`) + } + + return { getToken, listRooms, createRoom, removeRoom } } diff --git a/frontend/src/pages/relay/MeetingPage.vue b/frontend/src/pages/relay/MeetingPage.vue index 8570976..6fe3c04 100644 --- a/frontend/src/pages/relay/MeetingPage.vue +++ b/frontend/src/pages/relay/MeetingPage.vue @@ -1,13 +1,17 @@