feat: 화상회의 방에 고유 입장 코드 도입(이름과 식별자 분리)
방 식별을 이름이 아닌 불변 코드로 전환 — 이름을 바꿔도 링크·세션이 유지되고 초대 링크의 안정적 기준이 된다. - 백엔드: meeting_rooms.code(고유·불변) 추가, name 고유 해제(표시용 라벨, 중복 허용). 마이그레이션 AddMeetingRoomCode(코드 백필+name 고유 인덱스 제거). 토큰 발급은 code 로 방 조회(자동 생성 제거 — 명시 생성된 방만 입장, 없으면 404). LiveKit 방=code. createRoom 이 고유 코드 발급. MeetingRoomResponse 에 code 추가. - 프론트: 입장/라우팅을 code 기준으로(/meeting/:code), 로비 입장은 방 객체 전달, '초대 링크 복사' 버튼(코드 기반 URL) 추가. 생성 폼 이름 중복 검사 제거. 검증: 백엔드 build, 프론트 type-check·lint 통과. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<void> {
|
||||
// 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<void> {
|
||||
// 주의: 이름 중복 데이터가 있으면 고유 인덱스 복원이 실패할 수 있다.
|
||||
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"`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -18,9 +18,14 @@ export class MeetingRoom {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
// 방 이름(고유) — 같은 이름으로 들어온 사람끼리 연결된다
|
||||
// 방 입장 코드(고유·불변) — 입장 URL·LiveKit 방 식별자·초대 링크의 기준.
|
||||
// 이름과 분리해, 이름을 바꿔도 링크/세션이 유지된다.
|
||||
@Index({ unique: true })
|
||||
@Column()
|
||||
code!: string;
|
||||
|
||||
// 방 이름(표시용 라벨) — 고유하지 않으며 진행자가 자유롭게 변경할 수 있다.
|
||||
@Column()
|
||||
name!: string;
|
||||
|
||||
// 진행자(생성자). 사용자 삭제 시에도 방 기록은 유지(SET NULL)
|
||||
|
||||
@@ -83,6 +83,6 @@ export class MeetingController {
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Body() dto: CreateMeetingTokenDto,
|
||||
): Promise<MeetingTokenResult> {
|
||||
return this.meetingService.issueToken(user, dto.roomName);
|
||||
return this.meetingService.issueToken(user, dto.code);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<MeetingTokenResult> {
|
||||
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<MeetingRole> {
|
||||
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<string> {
|
||||
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<MeetingRoomResponse> {
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user