diff --git a/backend/index.js b/backend/index.js index 3c1c4a8..e77c046 100644 --- a/backend/index.js +++ b/backend/index.js @@ -74,6 +74,7 @@ app.use(async (req, res, next) => { await ensureData() next() } catch (e) { + console.error('[backend] db init failed', e) res.status(500).json({ error: 'db_init_failed' }) } }) diff --git a/backend/src/db.js b/backend/src/db.js index b2f9782..e50d950 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -261,31 +261,9 @@ async function closePool() { async function ensureSchema() { if (initPromise) return initPromise initPromise = (async () => { - if ((await tableExists('games')) && !(await tableExists('topics'))) { - await query('RENAME TABLE games TO topics') - } - if ((await tableExists('game_items')) && !(await tableExists('topic_items'))) { - await query('RENAME TABLE game_items TO topic_items') - } - if ((await tableExists('favorite_games')) && !(await tableExists('favorite_topics'))) { - await query('RENAME TABLE favorite_games TO favorite_topics') - } - - if ((await tableExists('tierlists')) && (await columnExists('tierlists', 'game_id')) && !(await columnExists('tierlists', 'topic_id'))) { - await query('ALTER TABLE tierlists CHANGE COLUMN game_id topic_id VARCHAR(120) NOT NULL') - } - if ((await tableExists('topic_items')) && (await columnExists('topic_items', 'game_id')) && !(await columnExists('topic_items', 'topic_id'))) { - await query('ALTER TABLE topic_items CHANGE COLUMN game_id topic_id VARCHAR(120) NOT NULL') - } - if ((await tableExists('favorite_topics')) && (await columnExists('favorite_topics', 'game_id')) && !(await columnExists('favorite_topics', 'topic_id'))) { - await query('ALTER TABLE favorite_topics CHANGE COLUMN game_id topic_id VARCHAR(120) NOT NULL') - } - if ((await tableExists('template_requests')) && (await columnExists('template_requests', 'source_game_id')) && !(await columnExists('template_requests', 'source_topic_id'))) { - await query('ALTER TABLE template_requests CHANGE COLUMN source_game_id source_topic_id VARCHAR(120) NOT NULL') - } - if ((await tableExists('template_requests')) && (await columnExists('template_requests', 'target_game_id')) && !(await columnExists('template_requests', 'target_topic_id'))) { - await query("ALTER TABLE template_requests CHANGE COLUMN target_game_id target_topic_id VARCHAR(120) NOT NULL DEFAULT ''") - } + const legacyGamesExists = await tableExists('games') + const legacyGameItemsExists = await tableExists('game_items') + const legacyFavoriteGamesExists = await tableExists('favorite_games') await query(` CREATE TABLE IF NOT EXISTS users ( @@ -321,6 +299,14 @@ async function ensureSchema() { await query('ALTER TABLE topics ADD COLUMN display_rank INT NULL DEFAULT NULL AFTER thumbnail_src') } + if (legacyGamesExists) { + await query(` + INSERT IGNORE INTO topics (id, name, thumbnail_src, is_public, display_rank, created_at) + SELECT id, name, thumbnail_src, COALESCE(is_public, 1), display_rank, created_at + FROM games + `) + } + await query(` CREATE TABLE IF NOT EXISTS topic_items ( id VARCHAR(64) PRIMARY KEY, @@ -339,6 +325,17 @@ async function ensureSchema() { await query('ALTER TABLE topic_items ADD COLUMN display_order INT NULL DEFAULT NULL AFTER label') } + if (legacyGameItemsExists) { + const legacyItemDisplayOrderColumns = await query("SHOW COLUMNS FROM game_items LIKE 'display_order'") + await query( + ` + INSERT IGNORE INTO topic_items (id, topic_id, src, label, display_order, created_at) + SELECT id, game_id, src, label, ${legacyItemDisplayOrderColumns.length ? 'display_order' : 'NULL'}, created_at + FROM game_items + ` + ) + } + await query(` CREATE TABLE IF NOT EXISTS custom_items ( id VARCHAR(64) PRIMARY KEY, @@ -401,6 +398,14 @@ async function ensureSchema() { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `) + if (legacyFavoriteGamesExists) { + await query(` + INSERT IGNORE INTO favorite_topics (user_id, topic_id, created_at) + SELECT user_id, game_id, created_at + FROM favorite_games + `) + } + await query(` CREATE TABLE IF NOT EXISTS image_assets ( id VARCHAR(64) PRIMARY KEY, @@ -473,10 +478,16 @@ async function ensureSchema() { const templateRequestSourceGameColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'source_topic_id'") if (!templateRequestSourceGameColumns.length) { await query("ALTER TABLE template_requests ADD COLUMN source_topic_id VARCHAR(120) NOT NULL DEFAULT 'freeform' AFTER source_tierlist_id") + if (await columnExists('template_requests', 'source_game_id')) { + await query('UPDATE template_requests SET source_topic_id = source_game_id WHERE source_topic_id = ?', [FREEFORM_TOPIC_ID]) + } } const templateRequestTargetGameColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'target_topic_id'") if (!templateRequestTargetGameColumns.length) { await query("ALTER TABLE template_requests ADD COLUMN target_topic_id VARCHAR(120) NOT NULL DEFAULT '' AFTER source_topic_id") + if (await columnExists('template_requests', 'target_game_id')) { + await query("UPDATE template_requests SET target_topic_id = target_game_id WHERE target_topic_id = ''") + } } const templateRequestStatusColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'status'") if (!templateRequestStatusColumns.length) { @@ -499,6 +510,13 @@ async function ensureSchema() { if (!tierListThumbnailColumns.length) { await query("ALTER TABLE tierlists ADD COLUMN thumbnail_src VARCHAR(255) NOT NULL DEFAULT '' AFTER title") } + const tierListTopicIdColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'topic_id'") + if (!tierListTopicIdColumns.length) { + await query("ALTER TABLE tierlists ADD COLUMN topic_id VARCHAR(120) NOT NULL DEFAULT 'freeform' AFTER author_id") + if (await columnExists('tierlists', 'game_id')) { + await query('UPDATE tierlists SET topic_id = game_id WHERE topic_id = ?', [FREEFORM_TOPIC_ID]) + } + } const tierListShowNamesColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'show_character_names'") if (!tierListShowNamesColumns.length) { await query("ALTER TABLE tierlists ADD COLUMN show_character_names TINYINT(1) NOT NULL DEFAULT 0 AFTER is_public") diff --git a/docs/history.md b/docs/history.md index a5e7d2e..2fb2d8c 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,9 @@ # 의사결정 이력 +## 2026-04-02 v1.4.14 +- 기존 `/games` 주소 호환은 alias보다 redirect가 더 맞다고 판단했다. 이번 단계에선 주소는 유지하되 라우트 파라미터 의미는 항상 `topicId`로 정규화해 Vue Router 경고와 내부 분기를 함께 줄였다. +- 운영 DB에 직접 `RENAME TABLE`과 컬럼 `CHANGE`를 거는 방식은 실제 환경에서 실패 여지가 커서, 마지막 스키마 전환도 새 topic 스키마를 먼저 만들고 기존 game 데이터를 복사하는 비파괴 마이그레이션이 더 안전하다고 정리했다. + ## 2026-04-02 v1.4.13 - 사용자 표면과 API 이름층까지 `topic/template`로 옮긴 뒤에는, DB 스키마도 실제로 따라오게 해야 이후 유지보수 비용이 덜 쌓이므로 `games` 계열 실명을 `topics` 계열로 마이그레이션하는 편이 맞다고 판단했다. - 다만 한 번에 응답 키까지 완전히 끊으면 프런트와 관리자 흐름이 너무 크게 흔들릴 수 있으므로, 이번 단계에서는 실제 저장 스키마는 `topic`으로 옮기고 응답의 `gameId / gameName`은 호환 키로 잠시 함께 유지하는 점진 마감이 가장 안전하다고 정리했다. diff --git a/docs/todo.md b/docs/todo.md index e1601e8..4b23fd1 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -1,6 +1,8 @@ # 할 일 및 이슈 ## 단기 확인 +- `v1.4.14`부터는 DB 마이그레이션이 rename 대신 복사 기반으로 바뀌었으므로, 실제 운영 DB에서 서버 재시작 후 `topics` 계열 테이블과 `tierlists.topic_id`, `template_requests.source_topic_id/target_topic_id`가 기대대로 채워지는지 먼저 확인한다. +- 레거시 `/games/...`와 `/editor/:gameId/...`는 redirect로 남겼으므로, 오래된 북마크 진입 후 주소가 `/topics/...`, `/editor/:topicId/...`로 자연스럽게 정규화되는지 한 번 더 QA한다. - `v1.4.13`부터 DB 실명도 `topics / topic_items / favorite_topics / topic_id` 기준으로 옮겼으므로, 기존 운영 DB에서 서버 재시작 후 자동 마이그레이션이 한 번만 자연스럽게 수행되는지 먼저 확인한다. - 백엔드 응답은 현재 `topicId / topicName`과 `gameId / gameName`을 함께 내려주고 있으므로, 다음 단계에서는 실제 프런트/관리자에서 더 이상 `gameId` fallback이 필요 없는 지점을 확인해 호환 키 제거 순서를 정한다. - 티어표 공개 목록, 관리자 전체 티어표 관리, 저장/요청 API는 `topicId`를 우선 받도록 바꿨으므로, 실제 브라우저에서 검색/저장/공유/관리자 필터가 모두 같은 파라미터 체계로 자연스럽게 이어지는지 한 번 더 QA한다. diff --git a/docs/update.md b/docs/update.md index 5708a65..00bb4ea 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,10 @@ # 업데이트 로그 +## 2026-04-02 v1.4.14 +- `/games/:gameId`, `/editor/:gameId/...` 레거시 주소는 Vue Router alias 대신 redirect로 정리해, `topicId` 기준 라우트와 섞일 때 뜨던 param mismatch 경고를 제거했다. +- 운영 DB에서 바로 `RENAME/CHANGE`를 치던 초기 마이그레이션은 위험도가 높아, `topics / topic_items / favorite_topics / topic_id` 스키마를 안전하게 만들고 기존 `games` 계열 데이터를 복사해 오는 방식으로 바꿨다. +- DB 초기화 실패 시 원인을 바로 확인할 수 있도록 백엔드에서 `db_init_failed` 응답 전에 실제 에러를 서버 로그에 남기도록 보강했다. + ## 2026-04-02 v1.4.13 - DB 실명 변경 마지막 단계로 `games / game_items / favorite_games`를 `topics / topic_items / favorite_topics` 기준으로 자동 마이그레이션하도록 정리하고, `tierlists.game_id`, `template_requests.source_game_id/target_game_id`도 각각 `topic_id`, `source_topic_id/target_topic_id`로 옮기게 했다. - 백엔드 저장/조회 쿼리는 이제 새 topic 스키마를 기준으로 동작하고, 응답에는 `topicId / topicName`을 기본으로 내려주되 기존 프런트가 바로 깨지지 않도록 `gameId / gameName`도 잠시 함께 유지했다. diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index bbc2693..d6d745a 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -16,9 +16,15 @@ export function createRouter() { history: createWebHistory(), routes: [ { path: '/', name: 'home', component: HomeView }, - { path: '/topics/:topicId', alias: ['/games/:gameId'], name: 'topicHub', component: GameHubView }, - { path: '/editor/:topicId/new', alias: ['/editor/:gameId/new'], name: 'newEditor', component: TierEditorView }, - { path: '/editor/:topicId/:tierListId', alias: ['/editor/:gameId/:tierListId'], name: 'editEditor', component: TierEditorView }, + { path: '/games/:gameId', redirect: (to) => `/topics/${encodeURIComponent(String(to.params.gameId || ''))}` }, + { path: '/topics/:topicId', name: 'topicHub', component: GameHubView }, + { path: '/editor/:gameId/new', redirect: (to) => `/editor/${encodeURIComponent(String(to.params.gameId || ''))}/new` }, + { path: '/editor/:topicId/new', name: 'newEditor', component: TierEditorView }, + { + path: '/editor/:gameId/:tierListId', + redirect: (to) => `/editor/${encodeURIComponent(String(to.params.gameId || ''))}/${encodeURIComponent(String(to.params.tierListId || ''))}`, + }, + { path: '/editor/:topicId/:tierListId', name: 'editEditor', component: TierEditorView }, { path: '/login', name: 'login', component: LoginView }, { path: '/me', name: 'me', component: MyTierListsView }, { path: '/favorites', name: 'favorites', component: FavoriteTierListsView },