diff --git a/backend/src/migrations/1782900000000-AddMeetingRoomCode.ts b/backend/src/migrations/1782900000000-AddMeetingRoomCode.ts new file mode 100644 index 0000000..f940b13 --- /dev/null +++ b/backend/src/migrations/1782900000000-AddMeetingRoomCode.ts @@ -0,0 +1,36 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +// 화상회의 방에 입장 코드(code) 추가 — 이름과 식별자를 분리한다. +// code = 입장 URL·LiveKit 방·초대 링크의 안정적 키(불변). 이름은 표시용 라벨로 강등(고유 해제). +// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다. +export class AddMeetingRoomCode1782900000000 implements MigrationInterface { + name = 'AddMeetingRoomCode1782900000000'; + + public async up(queryRunner: QueryRunner): Promise { + // 1) 코드 컬럼 추가(우선 nullable) → 기존 행 백필 → NOT NULL 승격 + await queryRunner.query( + `ALTER TABLE "meeting_rooms" ADD "code" character varying`, + ); + // 기존 방에 고유 코드 백필(md5(random) 12자리 16진수 — 충돌 사실상 없음) + await queryRunner.query( + `UPDATE "meeting_rooms" SET "code" = substr(md5(random()::text || "id"::text), 1, 12) WHERE "code" IS NULL`, + ); + await queryRunner.query( + `ALTER TABLE "meeting_rooms" ALTER COLUMN "code" SET NOT NULL`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_meeting_rooms_code" ON "meeting_rooms" ("code")`, + ); + // 2) 이름은 더 이상 고유가 아니다(표시용 라벨) → 고유 인덱스 제거 + await queryRunner.query(`DROP INDEX "IDX_meeting_rooms_name"`); + } + + public async down(queryRunner: QueryRunner): Promise { + // 주의: 이름 중복 데이터가 있으면 고유 인덱스 복원이 실패할 수 있다. + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_meeting_rooms_name" ON "meeting_rooms" ("name")`, + ); + await queryRunner.query(`DROP INDEX "IDX_meeting_rooms_code"`); + await queryRunner.query(`ALTER TABLE "meeting_rooms" DROP COLUMN "code"`); + } +} diff --git a/backend/src/modules/meeting/dto/create-meeting-token.dto.ts b/backend/src/modules/meeting/dto/create-meeting-token.dto.ts index bf4cc5a..812ceab 100644 --- a/backend/src/modules/meeting/dto/create-meeting-token.dto.ts +++ b/backend/src/modules/meeting/dto/create-meeting-token.dto.ts @@ -1,17 +1,16 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsString, Length, Matches } from 'class-validator'; -// 화상회의 입장 토큰 발급 요청 — 참여할 방 이름을 전달한다. +// 화상회의 입장 토큰 발급 요청 — 참여할 방의 입장 코드를 전달한다. export class CreateMeetingTokenDto { @ApiProperty({ - description: '참여할 회의 방 이름(한글/영문/숫자/-/_ 허용, 공백 불가)', - example: '주간-회의', + description: '참여할 회의 방 입장 코드(영문/숫자)', + example: 'a1b2c3d4e5', }) @IsString() - @Length(1, 64, { message: '방 이름은 1~64자여야 합니다.' }) - @Matches(/^[A-Za-z0-9가-힣ㄱ-ㅎㅏ-ㅣ_-]+$/, { - message: - '방 이름은 한글, 영문, 숫자, 하이픈(-), 밑줄(_)만 사용할 수 있습니다. (공백 불가)', + @Length(6, 32, { message: '입장 코드 형식이 올바르지 않습니다.' }) + @Matches(/^[A-Za-z0-9]+$/, { + message: '입장 코드 형식이 올바르지 않습니다.', }) - roomName!: string; + code!: string; } diff --git a/backend/src/modules/meeting/entities/meeting-room.entity.ts b/backend/src/modules/meeting/entities/meeting-room.entity.ts index 204130a..e026e45 100644 --- a/backend/src/modules/meeting/entities/meeting-room.entity.ts +++ b/backend/src/modules/meeting/entities/meeting-room.entity.ts @@ -18,9 +18,14 @@ export class MeetingRoom { @PrimaryGeneratedColumn('uuid') id!: string; - // 방 이름(고유) — 같은 이름으로 들어온 사람끼리 연결된다 + // 방 입장 코드(고유·불변) — 입장 URL·LiveKit 방 식별자·초대 링크의 기준. + // 이름과 분리해, 이름을 바꿔도 링크/세션이 유지된다. @Index({ unique: true }) @Column() + code!: string; + + // 방 이름(표시용 라벨) — 고유하지 않으며 진행자가 자유롭게 변경할 수 있다. + @Column() name!: string; // 진행자(생성자). 사용자 삭제 시에도 방 기록은 유지(SET NULL) diff --git a/backend/src/modules/meeting/meeting.controller.ts b/backend/src/modules/meeting/meeting.controller.ts index e6b46a5..164c322 100644 --- a/backend/src/modules/meeting/meeting.controller.ts +++ b/backend/src/modules/meeting/meeting.controller.ts @@ -83,6 +83,6 @@ export class MeetingController { @CurrentUser() user: PublicUser, @Body() dto: CreateMeetingTokenDto, ): Promise { - return this.meetingService.issueToken(user, dto.roomName); + return this.meetingService.issueToken(user, dto.code); } } diff --git a/backend/src/modules/meeting/meeting.service.ts b/backend/src/modules/meeting/meeting.service.ts index 52f794c..fec461b 100644 --- a/backend/src/modules/meeting/meeting.service.ts +++ b/backend/src/modules/meeting/meeting.service.ts @@ -1,3 +1,4 @@ +import { randomBytes } from 'crypto'; import { HttpStatus, Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { InjectRepository } from '@nestjs/typeorm'; @@ -16,6 +17,7 @@ export type MeetingRole = 'host' | 'participant'; // 방 목록/생성 응답 — 진행자·예약·참여제한·접근 가능 여부 포함 export interface MeetingRoomResponse { id: string; + code: string; // 입장 코드(불변) — 입장 URL·초대 링크의 기준 name: string; host: PublicUser | null; scheduledAt: string | null; @@ -70,7 +72,7 @@ export class MeetingService { // 역할은 토큰 metadata 에도 실어 다른 참여자가 진행자를 식별할 수 있게 한다. async issueToken( user: PublicUser, - roomName: string, + code: string, ): Promise { if (!this.enabled) { throw new BusinessException( @@ -80,7 +82,7 @@ export class MeetingService { ); } - const role = await this.resolveAccess(user.id, roomName); + const { room, role } = await this.resolveRoomAccess(user.id, code); const isHost = role === 'host'; const at = new AccessToken(this.apiKey, this.apiSecret, { @@ -93,7 +95,8 @@ export class MeetingService { }); at.addGrant({ roomJoin: true, - room: roomName, + // LiveKit 방 식별자 = 코드(이름이 아니라 불변 코드라 이름을 바꿔도 같은 방으로 연결). + room: room.code, canPublish: true, canSubscribe: true, canPublishData: true, @@ -101,41 +104,26 @@ export class MeetingService { }); const token = await at.toJwt(); - return { token, roomName, identity: user.id, role }; + return { token, roomName: room.name, identity: user.id, role }; } - // 입장 권한 판정 + 역할 결정. - // · 방이 없으면 이 사용자를 진행자로 생성(공개 방) — 직접 링크 입장 호환. - // · 방이 있으면 참여 제한(allow-list)을 검사하고, 생성자 일치로 역할을 판정. - private async resolveAccess( + // 코드로 방을 찾아 입장 권한을 판정한다(자동 생성 없음 — 명시적으로 생성된 방만 입장 가능). + // · 방이 없으면 404. · 참여 제한(allow-list) 검사 후 생성자 일치로 역할을 판정. + private async resolveRoomAccess( userId: string, - roomName: string, - ): Promise { - let room = await this.roomRepo.findOne({ - where: { name: roomName }, + code: string, + ): Promise<{ room: MeetingRoom; role: MeetingRole }> { + const room = await this.roomRepo.findOne({ + where: { code }, relations: ['host'], }); - 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'], - }); - } + throw new BusinessException( + 'RES_001', + '회의 방을 찾을 수 없습니다. 링크를 확인해 주세요.', + HttpStatus.NOT_FOUND, + ); } - if (!room) return 'participant'; - const isHost = room.host?.id === userId; if ( !isHost && @@ -148,24 +136,39 @@ export class MeetingService { HttpStatus.FORBIDDEN, ); } - return isHost ? 'host' : 'participant'; + return { room, role: isHost ? 'host' : 'participant' }; } - // 명시적 방 생성 — 생성자가 진행자. 예약 시각/참여 제한을 함께 설정. + // URL-safe 입장 코드(base62, 10자) 생성. + private generateCode(len = 10): string { + const ALPHA = + '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; + const bytes = randomBytes(len); + let out = ''; + for (let i = 0; i < len; i++) out += ALPHA[bytes[i] % 62]; + return out; + } + + // 충돌하지 않는 고유 입장 코드 — 드문 충돌 시 재시도, 그래도 충돌하면 길이를 늘린다. + private async generateUniqueCode(): Promise { + for (let i = 0; i < 5; i++) { + const code = this.generateCode(); + const taken = await this.roomRepo.count({ where: { code } }); + if (!taken) return code; + } + return this.generateCode(14); + } + + // 명시적 방 생성 — 생성자가 진행자. 고유 코드 발급 + 예약 시각/참여 제한 설정. + // 이름은 표시용 라벨이라 중복을 허용한다(식별은 코드로 한다). 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 code = await this.generateUniqueCode(); const saved = await this.roomRepo.save( this.roomRepo.create({ + code, name: dto.name, host: { id: userId } as User, scheduledAt: dto.scheduledAt ? new Date(dto.scheduledAt) : null, @@ -212,18 +215,8 @@ export class MeetingService { HttpStatus.FORBIDDEN, ); } - // 이름 변경 시 다른 방과의 중복을 검사한다(자기 자신 제외). - if (dto.name && dto.name !== room.name) { - const dup = await this.roomRepo.findOne({ where: { name: dto.name } }); - if (dup && dup.id !== room.id) { - throw new BusinessException( - 'BIZ_001', - '이미 존재하는 방 이름입니다. 다른 이름을 사용해 주세요.', - HttpStatus.CONFLICT, - ); - } - room.name = dto.name; - } + // 이름은 표시용 라벨이라 중복 검사 없이 변경한다(코드는 불변). + if (dto.name) room.name = dto.name; room.scheduledAt = dto.scheduledAt ? new Date(dto.scheduledAt) : null; room.allowedUserIds = dto.allowedUserIds?.length ? dto.allowedUserIds : null; await this.roomRepo.save(room); @@ -267,6 +260,7 @@ export class MeetingService { isHost || !restricted || room.allowedUserIds!.includes(userId); return { id: room.id, + code: room.code, name: room.name, host: room.host ? UserService.toPublic(room.host) : null, scheduledAt: room.scheduledAt ? room.scheduledAt.toISOString() : null, diff --git a/frontend/src/components/meeting/MeetingCreateForm.vue b/frontend/src/components/meeting/MeetingCreateForm.vue index 8b6ebdd..71ecec0 100644 --- a/frontend/src/components/meeting/MeetingCreateForm.vue +++ b/frontend/src/components/meeting/MeetingCreateForm.vue @@ -12,8 +12,6 @@ import type { ApiMember } from '@/types/repo' import type { ApiMeetingRoom } from '@/types/meeting' interface Props { - // 이름 중복 즉시 검사용 현재 방 목록 - rooms: ApiMeetingRoom[] // 편집 모드 — 전달되면 해당 방의 현재 값으로 채우고 수정한다(없으면 생성 모드). editRoom?: ApiMeetingRoom | null } @@ -89,11 +87,7 @@ async function submit() { error.value = '방 이름은 한글·영문·숫자·-·_ 1~64자만 가능합니다.' return } - // 이름 중복 검사 — 편집 중인 방 자신은 제외한다. - if (props.rooms.some((r) => r.name === n && r.id !== props.editRoom?.id)) { - error.value = '이미 같은 이름의 방이 있습니다.' - return - } + // 이름은 표시용 라벨이라 중복을 허용한다(식별은 코드로). 별도 중복 검사 없음. error.value = '' submitting.value = true const payload = { diff --git a/frontend/src/components/meeting/MeetingIcon.vue b/frontend/src/components/meeting/MeetingIcon.vue index 947b103..09db3f3 100644 --- a/frontend/src/components/meeting/MeetingIcon.vue +++ b/frontend/src/components/meeting/MeetingIcon.vue @@ -24,6 +24,8 @@ export type MeetingIconName = | 'lock' | 'trash' | 'edit' + | 'link' + | 'check'