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:
2026-06-26 17:46:57 +09:00
parent 5ecc1d7be4
commit 2d1026466f
12 changed files with 172 additions and 116 deletions
@@ -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 {