Merge feature/meeting-room-code: 화상회의 방 고유 입장 코드 도입

This commit is contained in:
2026-06-26 17:46:58 +09:00
12 changed files with 172 additions and 116 deletions
@@ -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);
}
}
+47 -53
View File
@@ -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,
@@ -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 = {
@@ -24,6 +24,8 @@ export type MeetingIconName =
| 'lock'
| 'trash'
| 'edit'
| 'link'
| 'check'
</script>
<script setup lang="ts">
@@ -177,5 +179,11 @@ defineProps<{ name: MeetingIconName }>()
<template v-else-if="name === 'edit'">
<path d="M12 20h9" /><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z" />
</template>
<template v-else-if="name === 'link'">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" /><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</template>
<template v-else-if="name === 'check'">
<path d="M20 6 9 17l-5-5" />
</template>
</svg>
</template>
@@ -14,13 +14,15 @@ interface Props {
}
const props = defineProps<Props>()
const emit = defineEmits<{
(e: 'join', name: string): void
(e: 'join', room: ApiMeetingRoom): void
(e: 'created'): void
(e: 'delete', room: ApiMeetingRoom): void
(e: 'clear-error'): void
}>()
const showForm = ref(false)
// 방금 초대 링크를 복사한 방 코드(잠깐 "복사됨" 표시)
const copiedCode = ref<string | null>(null)
// 편집 대상 방 — 설정되면 생성 폼이 편집 모드로 열린다(null 이면 생성 모드).
const editRoom = ref<ApiMeetingRoom | null>(null)
const keyword = ref('')
@@ -66,6 +68,20 @@ function fmtSchedule(iso: string): string {
timeStyle: 'short',
})
}
// 초대 링크 복사 — 입장 코드 기반 URL(/meeting/<code>). 이름이 바뀌어도 링크는 유지된다.
async function copyInvite(room: ApiMeetingRoom) {
const url = `${window.location.origin}/meeting/${room.code}`
try {
await navigator.clipboard.writeText(url)
copiedCode.value = room.code
window.setTimeout(() => {
if (copiedCode.value === room.code) copiedCode.value = null
}, 1500)
} catch {
// 클립보드 권한이 없으면 무시한다(사용자가 직접 복사)
}
}
</script>
<template>
@@ -110,7 +126,6 @@ function fmtSchedule(iso: string): string {
<MeetingCreateForm
v-if="showForm || editRoom"
:rooms="rooms"
:edit-room="editRoom"
@created="onFormDone"
@cancel="closeForm"
@@ -153,11 +168,20 @@ function fmtSchedule(iso: string): string {
</div>
</div>
<div class="rr-actions">
<button
type="button"
class="btn rr-invite"
:class="{ copied: copiedCode === r.code }"
:title="copiedCode === r.code ? '복사됨!' : '초대 링크 복사'"
@click="copyInvite(r)"
>
<MeetingIcon :name="copiedCode === r.code ? 'check' : 'link'" />
</button>
<button
v-if="r.canJoin"
type="button"
class="btn primary rr-join"
@click="emit('join', r.name)"
@click="emit('join', r)"
>
입장
</button>
@@ -359,21 +383,29 @@ function fmtSchedule(iso: string): string {
.rr-noaccess {
color: var(--text-3);
}
.rr-invite,
.rr-edit,
.rr-del {
width: 2.25rem;
padding: 0;
justify-content: center;
}
.rr-invite svg,
.rr-edit svg,
.rr-del svg {
width: 0.9375rem;
height: 0.9375rem;
}
.rr-invite:hover,
.rr-edit:hover {
border-color: var(--border-strong);
color: var(--text);
}
/* 초대 링크 복사 완료 — 잠깐 초록 강조 */
.rr-invite.copied {
color: var(--green);
border-color: var(--green);
}
/* 버튼 기본/primary 는 전역 .btn 사용. 여기선 danger·비활성·사이즈 변형만 보강. */
.btn.danger {
+3 -3
View File
@@ -20,10 +20,10 @@ export interface MeetingToken {
export function useMeeting() {
const api = useApi()
// LiveKit 입장 토큰 발급
async function getToken(roomName: string): Promise<MeetingToken> {
// LiveKit 입장 토큰 발급 — 방 입장 코드로 요청한다.
async function getToken(code: string): Promise<MeetingToken> {
return (await api.post('/meetings/token', {
roomName,
code,
})) as unknown as MeetingToken
}
+25 -38
View File
@@ -35,10 +35,9 @@ const {
sendChat,
} = useLiveKitRoom()
const ROOM_PATTERN = /^[A-Za-z0-9가-힣ㄱ-ㅎㅏ-ㅣ_-]{1,64}$/
// 프리조인 대상 방 정보(표시용)
// 프리조인 대상 방 정보(표시용 + 입장 코드)
interface PrejoinTarget {
code: string
name: string
hostName: string | null
restricted: boolean
@@ -74,51 +73,34 @@ async function loadRooms() {
}
}
// 로비에서 방 선택/입력 → 프리조인으로
function onJoin(name: string) {
const n = (name || '').trim()
if (!n) {
joinError.value = '방 이름을 입력해 주세요.'
return
}
if (!ROOM_PATTERN.test(n)) {
joinError.value =
'방 이름은 한글·영문·숫자·-·_ 1~64자만 가능합니다. (공백 불가)'
return
}
const room = rooms.value.find((r) => r.name === n)
if (room && room.restricted && !room.canJoin) {
joinError.value = `'${n}' 방은 참여가 제한되어 있습니다. 진행자에게 허용을 요청하세요.`
// 로비에서 방 선택 → 프리조인으로 (방은 명시적으로 생성된 것만 입장 가능)
function onJoin(room: ApiMeetingRoom) {
if (room.restricted && !room.canJoin) {
joinError.value = `'${room.name}' 방은 참여가 제한되어 있습니다. 진행자에게 허용을 요청하세요.`
return
}
joinError.value = null
target.value = room
? {
name: room.name,
hostName: room.host?.name ?? null,
restricted: room.restricted,
isHost: room.isHost,
}
: {
name: n,
hostName: myName.value, // 새 방은 입장자가 진행자가 된다
restricted: false,
isHost: true,
}
target.value = {
code: room.code,
name: room.name,
hostName: room.host?.name ?? null,
restricted: room.restricted,
isHost: room.isHost,
}
phase.value = 'prejoin'
}
// 프리조인 → 입장(토큰 발급 + 연결)
async function onEnter(opts: { mic: boolean; cam: boolean }) {
const name = target.value?.name
if (!name) return
const t = target.value
if (!t) return
entering.value = true
try {
const { token, role } = await meetingApi.getToken(name)
const { token, role } = await meetingApi.getToken(t.code)
myRole.value = role
await connect(token, opts)
if (route.params.room !== name) {
void router.replace(`/meeting/${name}`)
if (route.params.code !== t.code) {
void router.replace(`/meeting/${t.code}`)
}
} catch {
joinError.value = '입장에 실패했습니다. 잠시 후 다시 시도해 주세요.'
@@ -168,8 +150,13 @@ function onCreated() {
// 진입 시 방 목록 로드. 공유 링크(/meeting/:room)면 그 방 프리조인으로 바로 이동.
onMounted(async () => {
await loadRooms()
const param = route.params.room
if (typeof param === 'string' && param) onJoin(param)
// 공유/초대 링크(/meeting/:code) — 코드로 방을 찾아 프리조인. 없으면 안내한다.
const param = route.params.code
if (typeof param === 'string' && param) {
const room = rooms.value.find((r) => r.code === param)
if (room) onJoin(room)
else joinError.value = '회의 방을 찾을 수 없습니다. 링크를 확인해 주세요.'
}
})
// 화면 이탈 시 연결 정리
+2 -2
View File
@@ -109,8 +109,8 @@ const routes: RouteRecordRaw[] = [
meta: { requiresAuth: true },
},
{
// 화상회의 — LiveKit 기반(단계 1: 방 입장/연결). :room 으로 공유 링크 가능
path: '/meeting/:room?',
// 화상회의 — LiveKit 기반. :code 로 공유/초대 링크 가능(/meeting/<입장코드>)
path: '/meeting/:code?',
name: 'meeting',
component: () => import('@/pages/relay/MeetingPage.vue'),
meta: { requiresAuth: true },
+1
View File
@@ -7,6 +7,7 @@ export type MeetingRole = 'host' | 'participant'
// 회의 방(API 원시) — 백엔드 MeetingRoomResponse 와 1:1
export interface ApiMeetingRoom {
id: string
code: string // 입장 코드(불변) — 입장 URL·초대 링크의 기준
name: string
host: { id: string; name: string; avatarUrl: string | null } | null
scheduledAt: string | null // ISO, 예약 시각(없으면 null)