diff --git a/backend/src/db.js b/backend/src/db.js index defcfd3..2fd1198 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -1,6 +1,7 @@ const fs = require('fs/promises') const path = require('path') const mysql = require('mysql2/promise') +const { nanoid } = require('nanoid') const DB_HOST = process.env.DB_HOST || '127.0.0.1' const DB_PORT = process.env.DB_PORT ? Number(process.env.DB_PORT) : 3306 @@ -9,6 +10,7 @@ const DB_PASSWORD = process.env.DB_PASSWORD || '' const DB_NAME = process.env.DB_NAME || 'tier_cursor' const DB_CONNECTION_LIMIT = process.env.DB_CONNECTION_LIMIT ? Number(process.env.DB_CONNECTION_LIMIT) : 10 const FREEFORM_TOPIC_ID = 'freeform' +const TOPIC_SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/ let poolPromise = null let initPromise = null @@ -30,6 +32,20 @@ function serializeJson(value) { return JSON.stringify(value || []) } +function normalizeTopicSlug(value) { + return String(value || '').trim().toLowerCase() +} + +function assertTopicSlug(slug) { + const normalized = normalizeTopicSlug(slug) + if (!normalized || normalized.length > 120 || !TOPIC_SLUG_PATTERN.test(normalized)) { + const err = new Error('topic_slug_invalid') + err.code = 'TOPIC_SLUG_INVALID' + throw err + } + return normalized +} + function collectUploadSrcsFromItems(items, bucket) { for (const item of items || []) { if (typeof item?.src === 'string' && item.src.startsWith('/uploads/')) { @@ -75,8 +91,10 @@ function mapTopicRow(row) { if (!row) return null return { id: row.id, + slug: row.slug || row.id, name: row.name, topicId: row.id, + topicSlug: row.slug || row.id, topicName: row.name, thumbnailSrc: row.thumbnail_src || '', isPublic: row.is_public == null ? true : !!row.is_public, @@ -138,6 +156,7 @@ function mapTierListRow(row) { authorAccountName: getUserAccountName(row), authorAvatarSrc: row.avatar_src || '', topicId: row.topic_id, + topicSlug: row.topic_slug || row.topic_id, topicName: row.topic_name || '', title: row.title, thumbnailSrc: row.thumbnail_src || '', @@ -169,11 +188,13 @@ function mapTemplateRequestRow(row) { requesterAvatarSrc: row.requester_avatar_src || '', sourceTierListId: row.source_tierlist_id || '', sourceTopicId: row.source_topic_id, + sourceTopicSlug: row.source_topic_slug || row.source_topic_id, sourceTopicName: row.source_topic_name || '', sourceTierListTitle: row.title_snapshot || '', sourceDescription: row.description_snapshot || '', thumbnailSrc: row.thumbnail_src_snapshot || '', targetTopicId: row.target_topic_id || '', + targetTopicSlug: row.target_topic_slug || row.target_topic_id || '', targetTopicName: row.target_topic_name || '', status: row.status, items: parseJson(row.items_json, []), @@ -254,32 +275,6 @@ async function query(sql, params = []) { return rows } -async function tableExists(name) { - const rows = await query( - ` - SELECT TABLE_NAME - FROM information_schema.TABLES - WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? - LIMIT 1 - `, - [DB_NAME, name] - ) - return rows.length > 0 -} - -async function columnExists(tableName, columnName) { - const rows = await query( - ` - SELECT COLUMN_NAME - FROM information_schema.COLUMNS - WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND COLUMN_NAME = ? - LIMIT 1 - `, - [DB_NAME, tableName, columnName] - ) - return rows.length > 0 -} - async function closePool() { if (!poolPromise) return const pool = await poolPromise @@ -305,18 +300,6 @@ async function ensureSchema() { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `) - const userEmailVerifiedColumns = await query("SHOW COLUMNS FROM users LIKE 'email_verified'") - if (!userEmailVerifiedColumns.length) { - await query('ALTER TABLE users ADD COLUMN email_verified TINYINT(1) NOT NULL DEFAULT 1 AFTER password_hash') - await query('UPDATE users SET email_verified = 1 WHERE email_verified IS NULL') - } - - const userLastLoginColumns = await query("SHOW COLUMNS FROM users LIKE 'last_login_at'") - if (!userLastLoginColumns.length) { - await query('ALTER TABLE users ADD COLUMN last_login_at BIGINT NOT NULL DEFAULT 0 AFTER avatar_src') - await query('UPDATE users SET last_login_at = created_at WHERE last_login_at = 0') - } - await query(` CREATE TABLE IF NOT EXISTS email_verification_tokens ( id VARCHAR(64) PRIMARY KEY, @@ -348,25 +331,16 @@ async function ensureSchema() { await query(` CREATE TABLE IF NOT EXISTS topics ( id VARCHAR(120) PRIMARY KEY, + slug VARCHAR(120) NOT NULL, name VARCHAR(120) NOT NULL, thumbnail_src VARCHAR(255) NOT NULL DEFAULT '', is_public TINYINT(1) NOT NULL DEFAULT 1, display_rank INT NULL DEFAULT NULL, - created_at BIGINT NOT NULL + created_at BIGINT NOT NULL, + UNIQUE KEY uq_topics_slug (slug) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `) - const topicIsPublicColumns = await query("SHOW COLUMNS FROM topics LIKE 'is_public'") - if (!topicIsPublicColumns.length) { - await query('ALTER TABLE topics ADD COLUMN is_public TINYINT(1) NOT NULL DEFAULT 1 AFTER thumbnail_src') - await query('UPDATE topics SET is_public = 1 WHERE is_public IS NULL') - } - - const displayRankColumns = await query("SHOW COLUMNS FROM topics LIKE 'display_rank'") - if (!displayRankColumns.length) { - await query('ALTER TABLE topics ADD COLUMN display_rank INT NULL DEFAULT NULL AFTER thumbnail_src') - } - await query(` CREATE TABLE IF NOT EXISTS topic_items ( id VARCHAR(64) PRIMARY KEY, @@ -380,11 +354,6 @@ async function ensureSchema() { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `) - const topicItemDisplayOrderColumns = await query("SHOW COLUMNS FROM topic_items LIKE 'display_order'") - if (!topicItemDisplayOrderColumns.length) { - await query('ALTER TABLE topic_items ADD COLUMN display_order INT NULL DEFAULT NULL AFTER label') - } - await query(` CREATE TABLE IF NOT EXISTS custom_items ( id VARCHAR(64) PRIMARY KEY, @@ -479,11 +448,6 @@ async function ensureSchema() { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `) - const imageAssetLabelColumns = await query("SHOW COLUMNS FROM image_assets LIKE 'label_override'") - if (!imageAssetLabelColumns.length) { - await query("ALTER TABLE image_assets ADD COLUMN label_override VARCHAR(120) NOT NULL DEFAULT '' AFTER src") - } - await query(` CREATE TABLE IF NOT EXISTS image_optimization_jobs ( id VARCHAR(64) PRIMARY KEY, @@ -524,125 +488,14 @@ async function ensureSchema() { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `) - const templateRequestSourceTierListColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'source_tierlist_id'") - if (templateRequestSourceTierListColumns[0]?.Null !== 'YES') { - await query('ALTER TABLE template_requests MODIFY source_tierlist_id VARCHAR(64) NULL') - } - const templateRequestTypeColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'request_type'") - if (!templateRequestTypeColumns.length) { - await query("ALTER TABLE template_requests ADD COLUMN request_type VARCHAR(20) NOT NULL DEFAULT 'create' AFTER id") - } - const hasSourceTopicId = await columnExists('template_requests', 'source_topic_id') - if (!hasSourceTopicId) { - await query("ALTER TABLE template_requests ADD COLUMN source_topic_id VARCHAR(120) NOT NULL DEFAULT 'freeform' AFTER source_tierlist_id") - } - const hasTargetTopicId = await columnExists('template_requests', 'target_topic_id') - if (!hasTargetTopicId) { - await query("ALTER TABLE template_requests ADD COLUMN target_topic_id VARCHAR(120) NOT NULL DEFAULT '' AFTER source_topic_id") - } - const templateRequestStatusColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'status'") - if (!templateRequestStatusColumns.length) { - await query("ALTER TABLE template_requests ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'pending' AFTER target_topic_id") - } - const templateRequestGroupsColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'groups_json'") - if (!templateRequestGroupsColumns.length) { - await query("ALTER TABLE template_requests ADD COLUMN groups_json LONGTEXT NOT NULL AFTER items_json") - } - const templateRequestBoardItemsColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'board_items_json'") - if (!templateRequestBoardItemsColumns.length) { - await query("ALTER TABLE template_requests ADD COLUMN board_items_json LONGTEXT NOT NULL AFTER groups_json") - } - const templateRequestShowNamesColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'show_character_names_snapshot'") - if (!templateRequestShowNamesColumns.length) { - await query("ALTER TABLE template_requests ADD COLUMN show_character_names_snapshot TINYINT(1) NOT NULL DEFAULT 0 AFTER board_items_json") - } - - const tierListThumbnailColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'thumbnail_src'") - 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") - } - 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") - } - const tierListFeaturedColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'is_featured'") - if (!tierListFeaturedColumns.length) { - await query("ALTER TABLE tierlists ADD COLUMN is_featured TINYINT(1) NOT NULL DEFAULT 0 AFTER is_public") - } - const tierListFeaturedAtColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'featured_at'") - if (!tierListFeaturedAtColumns.length) { - await query("ALTER TABLE tierlists ADD COLUMN featured_at BIGINT NOT NULL DEFAULT 0 AFTER is_featured") - } - const tierListFeaturedByColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'featured_by'") - if (!tierListFeaturedByColumns.length) { - await query("ALTER TABLE tierlists ADD COLUMN featured_by VARCHAR(64) NOT NULL DEFAULT '' AFTER featured_at") - } - const tierListIconSizeColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'icon_size'") - if (!tierListIconSizeColumns.length) { - await query("ALTER TABLE tierlists ADD COLUMN icon_size INT NOT NULL DEFAULT 80 AFTER show_character_names") - } - const tierListSourceIdColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'source_tierlist_id'") - if (!tierListSourceIdColumns.length) { - await query("ALTER TABLE tierlists ADD COLUMN source_tierlist_id VARCHAR(64) NULL DEFAULT NULL AFTER icon_size") - } else if (tierListSourceIdColumns[0]?.Null !== 'YES') { - await query('ALTER TABLE tierlists MODIFY source_tierlist_id VARCHAR(64) NULL DEFAULT NULL') - } - const tierListSourceTitleColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'source_snapshot_title'") - if (!tierListSourceTitleColumns.length) { - await query("ALTER TABLE tierlists ADD COLUMN source_snapshot_title VARCHAR(120) NOT NULL DEFAULT '' AFTER source_tierlist_id") - } - const tierListSourceAuthorColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'source_snapshot_author'") - if (!tierListSourceAuthorColumns.length) { - await query("ALTER TABLE tierlists ADD COLUMN source_snapshot_author VARCHAR(120) NOT NULL DEFAULT '' AFTER source_snapshot_title") - } - await query( ` - INSERT INTO topics (id, name, thumbnail_src, created_at) - VALUES (?, ?, ?, ?) - ON DUPLICATE KEY UPDATE name = VALUES(name) + INSERT INTO topics (id, slug, name, thumbnail_src, is_public, created_at) + VALUES (?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE slug = VALUES(slug), name = VALUES(name), is_public = VALUES(is_public) `, - [FREEFORM_TOPIC_ID, '직접 티어표 만들기', '', now()] + [FREEFORM_TOPIC_ID, FREEFORM_TOPIC_ID, '직접 티어표 만들기', '', 1, now()] ) - - const countRows = await query('SELECT COUNT(*) AS count FROM topics') - if (Number(countRows[0]?.count || 0) <= 1) { - const createdAt = now() - await query( - ` - INSERT INTO topics (id, name, thumbnail_src, created_at) - VALUES - (?, ?, ?, ?), - (?, ?, ?, ?) - `, - ['example-topic', '예시 주제', '', createdAt, 'another-topic', '다른 예시 주제', '', createdAt] - ) - - await query( - ` - INSERT INTO topic_items (id, topic_id, src, label, created_at) - VALUES - (?, ?, ?, ?, ?), - (?, ?, ?, ?, ?) - `, - [ - 'img-1', - 'example-topic', - '/uploads/seeds/example1.png', - '샘플 1', - createdAt, - 'img-2', - 'example-topic', - '/uploads/seeds/example2.png', - '샘플 2', - createdAt, - ] - ) - } })() return initPromise @@ -954,7 +807,7 @@ async function listTopics(currentUserId = '', options = {}) { const includePrivate = !!options.includePrivate const rows = await query( ` - SELECT id, name, thumbnail_src, is_public, display_rank, created_at + SELECT id, slug, name, thumbnail_src, is_public, display_rank, created_at FROM topics WHERE id <> ? ${includePrivate ? '' : 'AND is_public = 1'} @@ -978,7 +831,39 @@ async function listTopics(currentUserId = '', options = {}) { } async function findTopicById(id) { - const rows = await query('SELECT id, name, thumbnail_src, is_public, display_rank, created_at FROM topics WHERE id = ? LIMIT 1', [id]) + const rows = await query( + 'SELECT id, slug, name, thumbnail_src, is_public, display_rank, created_at FROM topics WHERE id = ? LIMIT 1', + [id] + ) + return mapTopicRow(rows[0]) +} + +async function findTopicBySlug(slug) { + const normalizedSlug = normalizeTopicSlug(slug) + if (!normalizedSlug) return null + const rows = await query( + 'SELECT id, slug, name, thumbnail_src, is_public, display_rank, created_at FROM topics WHERE slug = ? LIMIT 1', + [normalizedSlug] + ) + return mapTopicRow(rows[0]) +} + +async function findTopicByIdentifier(topicRef) { + const rawRef = String(topicRef || '').trim() + if (!rawRef) return null + if (rawRef === FREEFORM_TOPIC_ID) return findTopicById(FREEFORM_TOPIC_ID) + + const rows = await query( + ` + SELECT id, slug, name, thumbnail_src, is_public, display_rank, created_at + FROM topics + WHERE id = ? OR slug = ? + ORDER BY + CASE WHEN id = ? THEN 0 ELSE 1 END ASC + LIMIT 1 + `, + [rawRef, normalizeTopicSlug(rawRef), rawRef] + ) return mapTopicRow(rows[0]) } @@ -1004,23 +889,37 @@ async function findTopicItemById(itemId) { return mapTopicItemRow(rows[0]) } -async function getTopicDetail(topicId) { - const topic = await findTopicById(topicId) +async function getTopicDetail(topicRef) { + const topic = await findTopicByIdentifier(topicRef) if (!topic) return null - const items = await listTopicItems(topicId) + const items = await listTopicItems(topic.id) return { topic, template: topic, items } } -async function createTopic({ id, name, isPublic = true }) { - await query('INSERT INTO topics (id, name, thumbnail_src, is_public, display_rank, created_at) VALUES (?, ?, ?, ?, ?, ?)', [ - id, +async function createTopic({ slug, name, isPublic = true }) { + const topicId = nanoid() + const topicSlug = assertTopicSlug(slug) + await query('INSERT INTO topics (id, slug, name, thumbnail_src, is_public, display_rank, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)', [ + topicId, + topicSlug, name, '', isPublic ? 1 : 0, null, now(), ]) - return findTopicById(id) + return findTopicById(topicId) +} + +async function updateTopicMeta(topicId, { slug, name, isPublic }) { + const topicSlug = assertTopicSlug(slug) + await query('UPDATE topics SET slug = ?, name = ?, is_public = ? WHERE id = ?', [ + topicSlug, + name, + isPublic ? 1 : 0, + topicId, + ]) + return findTopicById(topicId) } async function updateTopicThumbnail(topicId, thumbnailSrc) { @@ -2106,8 +2005,15 @@ async function listPublicTierLists(topicId, currentUserId = '', queryText = '') const params = [] let whereClause = 'WHERE t.is_public = 1' if (topicId) { + const topic = await findTopicByIdentifier(topicId) + if (!topic) { + return { + featuredTierLists: [], + tierLists: [], + } + } whereClause += ' AND t.topic_id = ?' - params.push(topicId) + params.push(topic.id) } if ((queryText || '').trim()) { const search = `%${queryText.trim()}%` @@ -2120,6 +2026,8 @@ async function listPublicTierLists(topicId, currentUserId = '', queryText = '') SELECT t.id, t.topic_id, + tp.slug AS topic_slug, + tp.name AS topic_name, t.title, t.thumbnail_src, t.is_featured, @@ -2133,6 +2041,7 @@ async function listPublicTierLists(topicId, currentUserId = '', queryText = '') u.avatar_src FROM tierlists t INNER JOIN users u ON u.id = t.author_id + INNER JOIN topics tp ON tp.id = t.topic_id ${whereClause} ORDER BY t.is_featured DESC, t.featured_at DESC, t.updated_at DESC LIMIT 200 @@ -2143,6 +2052,8 @@ async function listPublicTierLists(topicId, currentUserId = '', queryText = '') const tierLists = rows.map((row) => ({ id: row.id, topicId: row.topic_id, + topicSlug: row.topic_slug || row.topic_id, + topicName: row.topic_name || '', title: row.title, thumbnailSrc: row.thumbnail_src || '', isFeatured: !!row.is_featured, @@ -2202,6 +2113,7 @@ async function listFavoriteTierLists(userId, { queryText = '', sort = 'favorited t.id, t.author_id, t.topic_id, + tp.slug AS topic_slug, tp.name AS topic_name, t.title, t.thumbnail_src, @@ -2252,6 +2164,8 @@ async function listUserTierLists(userId) { SELECT t.id, t.topic_id, + tp.slug AS topic_slug, + tp.name AS topic_name, t.title, t.thumbnail_src, t.created_at, @@ -2262,6 +2176,7 @@ async function listUserTierLists(userId) { u.avatar_src FROM tierlists t INNER JOIN users u ON u.id = t.author_id + INNER JOIN topics tp ON tp.id = t.topic_id WHERE t.author_id = ? ORDER BY updated_at DESC `, @@ -2271,6 +2186,8 @@ async function listUserTierLists(userId) { const tierLists = rows.map((row) => ({ id: row.id, topicId: row.topic_id, + topicSlug: row.topic_slug || row.topic_id, + topicName: row.topic_name || '', title: row.title, thumbnailSrc: row.thumbnail_src || '', createdAt: Number(row.created_at), @@ -2369,6 +2286,7 @@ async function listPublicTierListsByAuthor(authorId, currentUserId = '', queryTe SELECT t.id, t.topic_id, + tp.slug AS topic_slug, tp.name AS topic_name, t.title, t.thumbnail_src, @@ -2395,6 +2313,7 @@ async function listPublicTierListsByAuthor(authorId, currentUserId = '', queryTe const tierLists = rows.map((row) => ({ id: row.id, topicId: row.topic_id, + topicSlug: row.topic_slug || row.topic_id, topicName: row.topic_name || '', title: row.title, thumbnailSrc: row.thumbnail_src || '', @@ -2430,6 +2349,7 @@ async function listFollowingTierLists(userId, queryText = '') { SELECT t.id, t.topic_id, + tp.slug AS topic_slug, tp.name AS topic_name, t.title, t.thumbnail_src, @@ -2458,6 +2378,7 @@ async function listFollowingTierLists(userId, queryText = '') { const tierLists = rows.map((row) => ({ id: row.id, topicId: row.topic_id, + topicSlug: row.topic_slug || row.topic_id, topicName: row.topic_name || '', title: row.title, thumbnailSrc: row.thumbnail_src || '', @@ -2536,11 +2457,12 @@ async function listAdminTierLists({ whereParts.push(`( t.title LIKE ? OR tp.name LIKE ? + OR tp.slug LIKE ? OR tp.id LIKE ? OR u.email LIKE ? OR u.nickname LIKE ? )`) - params.push(search, search, search, search, search) + params.push(search, search, search, search, search, search) } const whereClause = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : '' @@ -2551,6 +2473,7 @@ async function listAdminTierLists({ t.id, t.author_id, t.topic_id, + tp.slug AS topic_slug, tp.name AS topic_name, t.title, t.thumbnail_src, @@ -2648,11 +2571,12 @@ async function summarizeAdminTierLists({ queryText = '', topicId = '', minFavori whereParts.push(`( t.title LIKE ? OR tp.name LIKE ? + OR tp.slug LIKE ? OR tp.id LIKE ? OR u.email LIKE ? OR u.nickname LIKE ? )`) - params.push(search, search, search, search, search) + params.push(search, search, search, search, search, search) } const whereClause = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : '' @@ -2693,6 +2617,7 @@ async function findTierListById(id, currentUserId = '') { t.id, t.author_id, t.topic_id, + tp.slug AS topic_slug, tp.name AS topic_name, t.title, t.thumbnail_src, @@ -2831,7 +2756,9 @@ async function findTemplateRequestById(id) { u.nickname, u.email, u.avatar_src AS requester_avatar_src, + sg.slug AS source_topic_slug, sg.name AS source_topic_name, + tg.slug AS target_topic_slug, tg.name AS target_topic_name FROM template_requests tr INNER JOIN users u ON u.id = tr.requester_id @@ -2873,7 +2800,9 @@ async function listAdminTemplateRequests({ status = 'pending', statuses = [] } = u.nickname, u.email, u.avatar_src AS requester_avatar_src, + sg.slug AS source_topic_slug, sg.name AS source_topic_name, + tg.slug AS target_topic_slug, tg.name AS target_topic_name FROM template_requests tr INNER JOIN users u ON u.id = tr.requester_id @@ -3084,10 +3013,13 @@ module.exports = { adminDeleteUser, listTopics, findTopicById, + findTopicBySlug, + findTopicByIdentifier, listTopicItems, findTopicItemById, getTopicDetail, createTopic, + updateTopicMeta, updateTopicThumbnail, updateTopicVisibility, findImageAssetByHash, diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index 92db87e..755a575 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -10,13 +10,14 @@ const { findUserByEmail, findUserByNickname, findTopicById, + findTopicBySlug, findTopicItemById, listTopicItems, findImageAssetById, createTopic, listTopics, + updateTopicMeta, updateTopicThumbnail, - updateTopicVisibility, createTopicItem, updateTopicItemLabel, updateTopicItemDisplayOrder, @@ -119,16 +120,23 @@ function canManageAdminRole(actingUser, primaryAdmin) { router.post('/templates', requireAdmin, async (req, res) => { const schema = z.object({ - id: z.string().min(1), + slug: z.string().trim().min(1).max(120).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/), name: z.string().min(1).max(60), isPublic: z.boolean().optional().default(false), thumbnailSrc: z.string().max(255).optional().default(''), }) const parsed = schema.safeParse(req.body) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) - const exists = await findTopicById(parsed.data.id) - if (exists) return res.status(409).json({ error: 'topic_id_taken' }) - const template = await createTopic({ id: parsed.data.id, name: parsed.data.name, isPublic: parsed.data.isPublic }) + const exists = await findTopicBySlug(parsed.data.slug) + if (exists) return res.status(409).json({ error: 'topic_slug_taken' }) + let template + try { + template = await createTopic({ slug: parsed.data.slug, name: parsed.data.name, isPublic: parsed.data.isPublic }) + } catch (error) { + if (error?.code === 'TOPIC_SLUG_INVALID') return res.status(400).json({ error: 'topic_slug_invalid' }) + if (error?.code === 'ER_DUP_ENTRY') return res.status(409).json({ error: 'topic_slug_taken' }) + throw error + } if (parsed.data.thumbnailSrc) { const copiedThumb = await copyUploadIntoTopicAsset(parsed.data.thumbnailSrc) await updateTopicThumbnail(template.id, copiedThumb) @@ -139,7 +147,9 @@ router.post('/templates', requireAdmin, async (req, res) => { router.patch('/templates/:templateId', requireAdmin, async (req, res) => { const schema = z.object({ - isPublic: z.boolean(), + slug: z.string().trim().min(1).max(120).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).optional(), + name: z.string().trim().min(1).max(60).optional(), + isPublic: z.boolean().optional(), }) const parsed = schema.safeParse(req.body) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) @@ -148,8 +158,21 @@ router.patch('/templates/:templateId', requireAdmin, async (req, res) => { const template = await findTopicById(templateId) if (!template) return res.status(404).json({ error: 'not_found' }) - const updated = await updateTopicVisibility(template.id, parsed.data.isPublic) - res.json({ template: updated }) + try { + const updated = + typeof parsed.data.name === 'string' || typeof parsed.data.slug === 'string' || typeof parsed.data.isPublic === 'boolean' + ? await updateTopicMeta(template.id, { + slug: parsed.data.slug || template.slug, + name: parsed.data.name || template.name, + isPublic: typeof parsed.data.isPublic === 'boolean' ? parsed.data.isPublic : template.isPublic, + }) + : await findTopicById(template.id) + return res.json({ template: updated }) + } catch (error) { + if (error?.code === 'TOPIC_SLUG_INVALID') return res.status(400).json({ error: 'topic_slug_invalid' }) + if (error?.code === 'ER_DUP_ENTRY') return res.status(409).json({ error: 'topic_slug_taken' }) + throw error + } }) router.patch('/templates/display-order', requireAdmin, async (req, res) => { @@ -632,11 +655,11 @@ function pickTemplateRequestItems(templateRequest, itemIds = [], itemLabels = {} }) } -async function createTemplateFromTierList({ tierList, templateId, templateName }) { - await createTopic({ id: templateId, name: templateName, isPublic: false }) +async function createTemplateFromTierList({ tierList, templateSlug, templateName }) { + const template = await createTopic({ slug: templateSlug, name: templateName, isPublic: false }) if (tierList.thumbnailSrc) { const copiedThumb = await copyUploadIntoTopicAsset(tierList.thumbnailSrc) - await updateTopicThumbnail(templateId, copiedThumb) + await updateTopicThumbnail(template.id, copiedThumb) } const createdItems = [] @@ -645,30 +668,30 @@ async function createTemplateFromTierList({ tierList, templateId, templateName } createdItems.push( await createTopicItem({ id: nanoid(), - topicId: templateId, + topicId: template.id, src: copiedSrc, label: item.label, }) ) } - return { template: await findTopicById(templateId), items: createdItems } + return { template: await findTopicById(template.id), items: createdItems } } -async function createTemplateFromRequest({ templateRequest, templateId, templateName }) { - await createTopic({ id: templateId, name: templateName, isPublic: false }) +async function createTemplateFromRequest({ templateRequest, templateSlug, templateName }) { + const template = await createTopic({ slug: templateSlug, name: templateName, isPublic: false }) if (templateRequest.thumbnailSrc) { const copiedThumb = await copyUploadIntoTopicAsset(templateRequest.thumbnailSrc) - await updateTopicThumbnail(templateId, copiedThumb) + await updateTopicThumbnail(template.id, copiedThumb) } const items = await promoteSnapshotItemsToTemplate({ items: templateRequest.items || [], - templateId, + templateId: template.id, }) - return { template: await findTopicById(templateId), items } + return { template: await findTopicById(template.id), items } } router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => { @@ -761,28 +784,34 @@ router.post('/tierlists/:tierListId/promote-items', requireAdmin, async (req, re router.post('/tierlists/:tierListId/create-template', requireAdmin, async (req, res) => { const schema = z.object({ - topicId: z.string().trim().min(1).max(120), + slug: z.string().trim().min(1).max(120).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/), name: z.string().trim().min(1).max(120), itemIds: z.array(z.string().min(1)).optional().default([]), }) const parsed = schema.safeParse(req.body) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) - const exists = await findTopicById(parsed.data.topicId) - if (exists) return res.status(409).json({ error: 'topic_id_taken' }) + const exists = await findTopicBySlug(parsed.data.slug) + if (exists) return res.status(409).json({ error: 'topic_slug_taken' }) const tierList = await findTierListById(req.params.tierListId) if (!tierList) return res.status(404).json({ error: 'not_found' }) - const result = await createTemplateFromTierList({ - tierList: { - ...tierList, - pool: parsed.data.itemIds.length ? (tierList.pool || []).filter((item) => parsed.data.itemIds.includes(item.id)) : tierList.pool, - }, - templateId: parsed.data.topicId, - templateName: parsed.data.name, - }) - res.json(result) + try { + const result = await createTemplateFromTierList({ + tierList: { + ...tierList, + pool: parsed.data.itemIds.length ? (tierList.pool || []).filter((item) => parsed.data.itemIds.includes(item.id)) : tierList.pool, + }, + templateSlug: parsed.data.slug, + templateName: parsed.data.name, + }) + return res.json(result) + } catch (error) { + if (error?.code === 'TOPIC_SLUG_INVALID') return res.status(400).json({ error: 'topic_slug_invalid' }) + if (error?.code === 'ER_DUP_ENTRY') return res.status(409).json({ error: 'topic_slug_taken' }) + throw error + } }) router.patch('/tierlists/:tierListId', requireAdmin, async (req, res) => { @@ -832,20 +861,27 @@ router.post('/template-requests/:requestId/approve', requireAdmin, async (req, r } const schema = z.object({ - topicId: z.string().trim().min(1).max(120), + slug: z.string().trim().min(1).max(120).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/), name: z.string().trim().min(1).max(120), }) const parsed = schema.safeParse(req.body) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) - const exists = await findTopicById(parsed.data.topicId) - if (exists) return res.status(409).json({ error: 'topic_id_taken' }) + const exists = await findTopicBySlug(parsed.data.slug) + if (exists) return res.status(409).json({ error: 'topic_slug_taken' }) - const result = await createTemplateFromRequest({ - templateRequest, - templateId: parsed.data.topicId, - templateName: parsed.data.name, - }) + let result + try { + result = await createTemplateFromRequest({ + templateRequest, + templateSlug: parsed.data.slug, + templateName: parsed.data.name, + }) + } catch (error) { + if (error?.code === 'TOPIC_SLUG_INVALID') return res.status(400).json({ error: 'topic_slug_invalid' }) + if (error?.code === 'ER_DUP_ENTRY') return res.status(409).json({ error: 'topic_slug_taken' }) + throw error + } const request = await updateTemplateRequestStatus({ id: templateRequest.id, status: 'approved' }) res.json({ request, ...result }) }) diff --git a/backend/src/routes/tierlists.js b/backend/src/routes/tierlists.js index 87a93cf..373e8d5 100644 --- a/backend/src/routes/tierlists.js +++ b/backend/src/routes/tierlists.js @@ -12,6 +12,7 @@ const { createCustomItem, createTemplateRequest, findUserById, + findTopicByIdentifier, favoriteTierList, unfavoriteTierList, duplicateTierListForUser, @@ -234,14 +235,15 @@ router.post('/template-request', requireAuth, async (req, res) => { if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) const payload = parsed.data - const topicId = payload.topicId + const topic = await findTopicByIdentifier(payload.topicId) + if (!topic) return res.status(404).json({ error: 'not_found' }) const normalizedBoardItems = payload.boardItems.map(normalizePoolItem) const customItems = normalizedBoardItems.filter((item) => item?.origin === 'custom') if (!customItems.length) return res.status(400).json({ error: 'custom_items_required' }) if (payload.type === 'create') { - if (topicId !== FREEFORM_TOPIC_ID) return res.status(400).json({ error: 'freeform_required' }) - } else if (topicId === FREEFORM_TOPIC_ID) { + if (topic.id !== FREEFORM_TOPIC_ID) return res.status(400).json({ error: 'freeform_required' }) + } else if (topic.id === FREEFORM_TOPIC_ID) { return res.status(400).json({ error: 'topic_template_required' }) } @@ -260,8 +262,8 @@ router.post('/template-request', requireAuth, async (req, res) => { type: payload.type, requesterId: req.session.userId, sourceTierListId: sourceTierList?.id || '', - sourceTopicId: topicId, - targetTopicId: payload.type === 'update' ? topicId : '', + sourceTopicId: topic.id, + targetTopicId: payload.type === 'update' ? topic.id : '', title: payload.requestTitle, description: payload.requestDescription, thumbnailSrc: payload.thumbnailSrc || '', @@ -283,7 +285,8 @@ router.post('/', requireAuth, async (req, res) => { const parsed = tierListUpsertSchema.safeParse(req.body) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) const payload = parsed.data - const topicId = payload.topicId + const topic = await findTopicByIdentifier(payload.topicId) + if (!topic) return res.status(404).json({ error: 'not_found' }) const normalizedPool = payload.pool.map(normalizePoolItem) let existing = null @@ -313,7 +316,7 @@ router.post('/', requireAuth, async (req, res) => { const created = await saveTierList({ id: nanoid(), authorId: req.session.userId, - topicId, + topicId: topic.id, title: payload.title, thumbnailSrc: payload.thumbnailSrc || '', description: payload.description || '', diff --git a/backend/src/routes/topics.js b/backend/src/routes/topics.js index bb21b72..64fb51f 100644 --- a/backend/src/routes/topics.js +++ b/backend/src/routes/topics.js @@ -1,5 +1,5 @@ const express = require('express') -const { listTopics, getTopicDetail, findTopicById, favoriteTopic, unfavoriteTopic } = require('../db') +const { listTopics, getTopicDetail, findTopicByIdentifier, favoriteTopic, unfavoriteTopic } = require('../db') const { requireAuth } = require('../middleware/auth') const router = express.Router() @@ -10,7 +10,7 @@ router.get('/', async (req, res) => { }) router.post('/:topicId/favorite', requireAuth, async (req, res) => { - const topic = await findTopicById(req.params.topicId) + const topic = await findTopicByIdentifier(req.params.topicId) if (!topic || topic.id === 'freeform') return res.status(404).json({ error: 'not_found' }) await favoriteTopic({ userId: req.session.userId, topicId: topic.id }) const topics = await listTopics(req.session.userId) @@ -19,7 +19,7 @@ router.post('/:topicId/favorite', requireAuth, async (req, res) => { }) router.delete('/:topicId/favorite', requireAuth, async (req, res) => { - const topic = await findTopicById(req.params.topicId) + const topic = await findTopicByIdentifier(req.params.topicId) if (!topic || topic.id === 'freeform') return res.status(404).json({ error: 'not_found' }) await unfavoriteTopic({ userId: req.session.userId, topicId: topic.id }) const topics = await listTopics(req.session.userId) diff --git a/docs/history.md b/docs/history.md index f55533c..0058415 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,10 @@ # 의사결정 이력 +## 2026-04-03 v1.4.61 +- 운영자가 쓰는 템플릿 주소를 `topics.id` 자체로 두면 나중에 이름/URL을 다듬고 싶을 때 참조 FK와 기존 링크까지 같이 흔들릴 수 있으므로, 내부 참조용 랜덤 `id`와 공개/관리용 `slug`를 분리하는 구조가 더 안전하다고 판단했다. +- 운영 DB와 로컬 DB를 모두 새로 시작할 수 있는 상황이라면 예전 `id -> slug` 백필이나 레거시 호환 코드를 남기는 편이 오히려 유지보수 비용만 늘리므로, 이번 변경은 새 스키마 기준으로 깔끔하게 정리하고 기존 데이터 호환 마이그레이션은 두지 않기로 했다. +- 빈 DB 초기화 시 예시 템플릿 2개가 자동 생성되면 운영자가 “진짜 운영 데이터인지 샘플인지”를 매번 구분해야 하므로, 시스템 필수 `freeform`만 남기고 빈 예시 템플릿 시드는 제거하는 편이 맞다고 정리했다. + ## 2026-04-03 v1.4.60 - 신규 업로드만 샤딩 저장하고 기존 평면 `assets` 파일을 그대로 두면 운영자가 파일 구조를 볼 때 두 방식이 오래 섞여 보여 정리성이 떨어지므로, 기존 평면 자산도 같은 규칙으로 옮기는 일회성 마이그레이션 스크립트를 제공하는 편이 맞다고 판단했다. - 기존 파일을 재인코딩해서 새 자산으로 다시 만드는 방식은 해시 중복 처리와 품질/메타 차이가 다시 얽힐 수 있으므로, 이번 샤딩 정리는 실제 파일 rename과 경로 참조 치환만 수행해 이미지 내용 자체는 건드리지 않는 쪽으로 정리했다. diff --git a/docs/map.md b/docs/map.md index 7a1f21b..ceef292 100644 --- a/docs/map.md +++ b/docs/map.md @@ -7,12 +7,12 @@ ## `/topics/:topicId` - 화면 파일: `frontend/src/views/TopicHubView.vue` -- 역할: 선택한 주제 정보 표시, 관리자 추천 티어표 상단 강조 섹션과 일반 공개 티어표 목록 분리 표시, 제목/작성자 검색, 티어표별 `상단 썸네일 / 제목+좋아요 / 작성자+최종 수정일` 카드 표시, 새 티어표 작성은 우측 하단 CTA로 진입 +- 역할: 선택한 주제 slug 기준 정보 표시, 관리자 추천 티어표 상단 강조 섹션과 일반 공개 티어표 목록 분리 표시, 제목/작성자 검색, 티어표별 `상단 썸네일 / 제목+좋아요 / 작성자+최종 수정일` 카드 표시, 새 티어표 작성은 우측 하단 CTA로 진입 - 연동 API: `GET /api/topics/:topicId`, `GET /api/tierlists/public`, `POST /api/tierlists/:id/favorite`, `DELETE /api/tierlists/:id/favorite` ## `/editor/:topicId/new`, `/editor/:topicId/:tierListId` - 화면 파일: `frontend/src/views/TierEditorView.vue` -- 역할: 티어 그룹 편집, 티어 행 추가/삭제, 보드 옆 아이템 풀에서 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 공통 오른쪽 레일 안에 직접 배치되는 우측 편집 섹션에서 티어표 제목/설명/대표 썸네일/공개 여부/저장 제어와 커스텀 아이템 이름 정리, 즐겨찾기 토글, PNG 다운로드, 저장된 티어표 기준 템플릿 등록/업데이트 요청, `?preview=1` 진입 시 공통 앱 셸은 유지한 채 중앙 본문에서 완성본 프리뷰 렌더링 +- 역할: 주제 slug 기반 에디터 진입, 티어 그룹 편집, 티어 행 추가/삭제, 보드 옆 아이템 풀에서 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 공통 오른쪽 레일 안에 직접 배치되는 우측 편집 섹션에서 티어표 제목/설명/대표 썸네일/공개 여부/저장 제어와 커스텀 아이템 이름 정리, 즐겨찾기 토글, PNG 다운로드, 저장된 티어표 기준 템플릿 등록/업데이트 요청, `?preview=1` 진입 시 공통 앱 셸은 유지한 채 중앙 본문에서 완성본 프리뷰 렌더링 - 연동 API: `GET /api/topics/:topicId`, `GET /api/tierlists/:id`, `POST /api/tierlists/:id/favorite`, `DELETE /api/tierlists/:id/favorite`, `POST /api/tierlists/thumbnail`, `POST /api/tierlists/custom-items`, `POST /api/tierlists`, `POST /api/tierlists/template-request` ## `/login` @@ -47,7 +47,7 @@ ## `/admin` - 화면 파일: `frontend/src/views/AdminView.vue` -- 역할: 공통 우측 패널 대신 전용 `320px` 운영 패널을 사용해 `템플릿 관리 / 아이템 관리 / 티어표 관리 / 회원 관리` 탭과 검색·필터·빠른 작업을 우측에 배치하고, 중앙에는 선택된 템플릿 상세, 커스텀 아이템 목록, 템플릿 요청/전체 티어표, 회원 카드 등 실제 관리 대상만 표시, 기본 아이템 다중 드래그 앤 드롭 업로드, 기본 아이템 이름 수정, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 사용자 커스텀 아이템의 기본 템플릿 승격, 전체 티어표 검색/페이지네이션/공개 여부 확인/받은 즐겨찾기 수 표시/인기순 정렬/최소 즐겨찾기 필터/추천 지정 토글/썸네일 클릭 기반 완성본 보기, 티어표의 추가 커스텀 아이템을 모달 기반으로 기존 템플릿 또는 새 템플릿에 가져오기, freeform 티어표의 템플릿화, 사용자 템플릿 등록/업데이트 요청 승인·반려와 일반 완성본과 같은 보드 문법의 요청 미리보기, 회원의 작성 티어표·팔로워·받은 즐겨찾기·최근 콘텐츠 활동·마지막 접속일 확인과 회원 정보·권한 수정 및 공개 프로필 보기, 파일 입력 초기화, 아이템 삭제, 템플릿 삭제 +- 역할: 공통 우측 패널 대신 전용 `320px` 운영 패널을 사용해 `템플릿 관리 / 아이템 관리 / 티어표 관리 / 회원 관리` 탭과 검색·필터·빠른 작업을 우측에 배치하고, 중앙에는 선택된 템플릿 상세, 커스텀 아이템 목록, 템플릿 요청/전체 티어표, 회원 카드 등 실제 관리 대상만 표시, 템플릿 이름/slug 수정, 기본 아이템 다중 드래그 앤 드롭 업로드, 기본 아이템 이름 수정, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 사용자 커스텀 아이템의 기본 템플릿 승격, 전체 티어표 검색/페이지네이션/공개 여부 확인/받은 즐겨찾기 수 표시/인기순 정렬/최소 즐겨찾기 필터/추천 지정 토글/썸네일 클릭 기반 완성본 보기, 티어표의 추가 커스텀 아이템을 모달 기반으로 기존 템플릿 또는 새 템플릿에 가져오기, freeform 티어표의 템플릿화, 사용자 템플릿 등록/업데이트 요청 승인·반려와 일반 완성본과 같은 보드 문법의 요청 미리보기, 회원의 작성 티어표·팔로워·받은 즐겨찾기·최근 콘텐츠 활동·마지막 접속일 확인과 회원 정보·권한 수정 및 공개 프로필 보기, 파일 입력 초기화, 아이템 삭제, 템플릿 삭제 - 연동 API: `POST /api/admin/templates`, `POST /api/admin/templates/:templateId/thumbnail`, `POST /api/admin/templates/:templateId/images`, `PATCH /api/admin/templates/:templateId/items/:itemId`, `GET /api/admin/custom-items`, `POST /api/admin/custom-items/:itemId/promote`, `DELETE /api/admin/custom-items/:itemId`, `DELETE /api/admin/custom-items`, `GET /api/admin/tierlists`, `GET /api/admin/tierlists/stats`, `PATCH /api/admin/tierlists/:tierListId/featured`, `GET /api/admin/template-requests`, `POST /api/admin/template-requests/:requestId/approve`, `POST /api/admin/template-requests/:requestId/reject`, `POST /api/admin/template-requests/:requestId/link-template`, `POST /api/admin/tierlists/:tierListId/promote-items`, `POST /api/admin/tierlists/:tierListId/create-template`, `GET /api/admin/users`, `PATCH /api/admin/users/:userId`, `PATCH /api/admin/users/:userId/password`, `DELETE /api/admin/users/:userId`, `DELETE /api/admin/templates/:templateId/items/:itemId`, `DELETE /api/admin/templates/:templateId` ## `/profile` diff --git a/docs/spec.md b/docs/spec.md index 7f38241..4f180f1 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -86,17 +86,23 @@ - `expiresAt`: number - `consumedAt`: number - `createdAt`: number -- `games` +- `topics` - `id`: string + - 서버가 자동 생성하는 내부 참조용 랜덤 ID이며, 공개 URL 노출값으로 직접 사용하지 않는다. + - `slug`: string + - 운영자가 지정/수정하는 공개 주소용 식별자이며, 영문 소문자/숫자/하이픈 조합만 허용한다. - `name`: string - `thumbnailSrc`: string + - `isPublic`: boolean + - `displayRank`: number | null - `createdAt`: number - - 시스템 전용 `freeform` 레코드는 홈 화면의 `직접 티어표 만들기` 저장 대상이며 일반 게임 목록에서는 숨긴다. -- `gameItems` + - 시스템 전용 `freeform` 레코드는 홈 화면의 `직접 티어표 만들기` 저장 대상이며 일반 주제 목록에서는 숨긴다. 신규 빈 DB 초기화 시 자동 생성되는 템플릿은 이 `freeform` 한 건만 유지한다. +- `topicItems` - `id`: string - - `gameId`: string + - `topicId`: string - `src`: string - `label`: string + - `displayOrder`: number | null - `createdAt`: number - `customItems` - `id`: string @@ -107,7 +113,8 @@ - `tierLists` - `id`: string - `authorId`: string - - `gameId`: string + - `topicId`: string + - DB에는 내부 `topics.id`를 저장하고, API 응답에는 공개 경로용 `topicSlug`도 함께 내려준다. - `title`: string - `thumbnailSrc`: string - 사용자가 직접 지정하지 않으면 저장 시 티어표 대표 아이템 이미지로 자동 채운다. @@ -128,9 +135,9 @@ - `followerId`: string - `followingId`: string - `createdAt`: number -- `gameSuggestions` - - `id`: string - - `name`: string +- `favoriteTopics` + - `userId`: string + - `topicId`: string - `createdAt`: number ## 주요 API @@ -159,10 +166,11 @@ - 주제 - `GET /api/topics` - `GET /api/topics/:topicId` + - `:topicId`는 공개 URL에서는 보통 `slug`를 받지만, 내부 ID를 넘겨도 같은 템플릿을 찾을 수 있게 서버가 레코드를 해석한다. - 티어표 - `GET /api/tierlists/public` - `featuredTierLists`와 일반 공개 `tierLists`를 분리해서 반환한다. - - `topicId` 없이 `q`만 전달하면 전체 공개 티어표 검색에 사용한다. + - `topicId`에는 주제 `slug`를 우선 전달하며, `topicId` 없이 `q`만 전달하면 전체 공개 티어표 검색에 사용한다. - `GET /api/tierlists/me` - `GET /api/tierlists/favorites/me` - `GET /api/tierlists/:id` @@ -184,6 +192,9 @@ - `DELETE /api/users/:userId/follow` - 관리자 - `POST /api/admin/templates` + - 요청 본문은 `slug`, `name`, `isPublic`, `thumbnailSrc`를 사용하고, 내부 `topics.id`는 서버가 자동 생성한다. + - `PATCH /api/admin/templates/:templateId` + - 내부 ID로 템플릿을 찾아 `name`, `slug`, `isPublic`을 수정한다. - `POST /api/admin/templates/:templateId/thumbnail` - `POST /api/admin/templates/:templateId/images` - 여러 이미지를 한 번에 최대 `100개`까지 업로드할 수 있고, 별도 라벨이 없으면 파일명 기준으로 기본 아이템 이름을 만든다. @@ -231,7 +242,7 @@ - `전체 티어표 관리` 카드에는 받은 즐겨찾기 수를 함께 보여주며, 우측 운영 패널에서 즐겨찾기 많은 순 정렬과 최소 즐겨찾기 수 필터로 추천 후보를 좁힐 수 있다. - `티어표 관리` 탭의 추가 아이템은 작은 그리드 카드로 표시하고, 클릭 시 `기존 템플릿에 추가 / 새 템플릿 만들기` 모달을 통해 목적지를 선택한다. - `티어표 관리` 탭에서는 티어표 안의 커스텀 아이템을 개별 또는 일괄로 기존 게임 템플릿에 복제할 수 있다. -- `freeform` 티어표는 관리자 화면에서 새 게임 ID/이름을 입력해 새로운 게임 템플릿으로 복제 생성할 수 있다. +- `freeform` 티어표는 관리자 화면에서 새 템플릿 slug/이름을 입력해 새로운 템플릿으로 복제 생성할 수 있다. 내부 ID는 서버가 자동 생성하므로 운영자가 직접 입력하지 않는다. - 관리자 티어표 관리 상단에는 사용자가 보낸 템플릿 등록/업데이트 요청 목록이 별도로 표시되며, 여기서 승인/반려를 바로 처리할 수 있다. - 관리자 템플릿 요청 목록에서 `반려 후 숨김`을 누르면 해당 요청은 pending 목록에서 즉시 제외된다. - 관리자 화면에서는 회원 이메일/닉네임/권한 수정, 비밀번호 초기화, 계정 삭제가 가능하다. diff --git a/docs/todo.md b/docs/todo.md index 83ff451..2fe3437 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -1,6 +1,9 @@ # 할 일 및 이슈 ## 단기 확인 +- `v1.4.61`에서 템플릿 공개 주소를 `slug`로 분리했으므로, 홈 카드/주제 상세/나의 티어표/즐겨찾기/검색 결과/팔로우 피드/사용자 프로필에서 열리는 URL이 `/topics/:slug`, `/editor/:slug/...` 형태로 바뀌고, 실제 화면 내용도 같은 주제 템플릿으로 정확히 열리는지 QA한다. +- 관리자 템플릿 생성/설정은 이제 내부 ID가 아니라 `slug + 이름`만 입력하므로, 새 템플릿 생성, 기존 템플릿 이름/slug 저장, 중복 slug 입력, 대문자/특수문자 slug 입력, 공개/비공개 토글, 썸네일/기본 아이템 관리가 모두 같은 템플릿에 정상 반영되는지 확인한다. +- 신규 빈 DB 초기화 시 `topics`에 `freeform` 한 건만 생성되고 `example-topic`, `another-topic` 같은 예시 템플릿이 더 이상 자동으로 생기지 않는지 운영/로컬 재배포 후 확인한다. - `v1.4.60`에서 추가한 `npm --prefix backend run images:shard-assets`를 로컬/운영에 적용할 때는 먼저 백업을 확보한 뒤 실행하고, 평면 `/uploads/assets/<파일명>.webp` 파일이 샤딩 폴더로 이동하면서 `image_assets.src`와 각 참조 컬럼/JSON이 모두 새 경로로 바뀌었는지 확인한다. - `v1.4.59`에서 `thumbnail/avatar` 필터를 실제 DB 참조 역할 기준으로 다시 판별하도록 바꿨으므로, 최근 업로드처럼 `/uploads/assets/<파일명>.webp` 또는 `/uploads/assets/<앞2글자>/<파일명>.webp` 경로여도 썸네일 이미지/프로필 이미지 필터에서 빠지지 않는지 확인한다. - 신규 업로드 이미지는 `/uploads/assets/<앞2글자>/<파일명>.webp`로 저장되므로, 템플릿 썸네일/티어표 썸네일/프로필 아바타/아이템 업로드를 각각 새로 올린 뒤 실제 파일이 샤딩 폴더에 생성되고, 브라우저 표시·삭제·중복 재사용이 모두 기존처럼 동작하는지 QA한다. diff --git a/docs/update.md b/docs/update.md index 8de2c29..aa51f30 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,12 @@ # 업데이트 로그 +## 2026-04-03 v1.4.61 +- 템플릿 공개 URL과 내부 참조를 분리해, `topics.id`는 서버가 자동 생성하는 랜덤 내부 ID로 두고 운영자가 직접 관리하는 공개 주소는 `topics.slug`로 저장하도록 바꿨다. +- 공개 주제/에디터 경로는 `slug`를 우선 사용하고, 백엔드는 `/api/topics/:topicId`, `/api/tierlists/public?topicId=...`, 티어표 저장/템플릿 요청의 `topicId` 입력을 `slug` 또는 내부 ID에서 실제 템플릿 레코드로 해석한 뒤 내부 `topic_id`를 저장하도록 정리했다. +- 관리자 템플릿 생성 모달과 템플릿 설정 카드에서 내부 ID 대신 `템플릿 이름 + slug`를 입력/수정할 수 있게 바꾸고, `slug` 중복/형식 오류는 `이미 사용 중인 템플릿 slug입니다.`, `slug는 영문 소문자, 숫자, 하이픈만 사용할 수 있어요.`처럼 원인 문구를 분리했다. +- 새 DB를 처음 만들 때는 시스템 전용 `freeform` 템플릿만 생성하고, 예전에 기본 시드로 넣던 빈 예시 템플릿 `example-topic`, `another-topic`과 샘플 아이템은 더 이상 자동 생성하지 않도록 제거했다. +- 로컬 MariaDB를 한 번 비운 뒤 새 스키마로 `ensureData()`를 실행해, 초기 `topics`가 `[{ id: "freeform", slug: "freeform", name: "직접 티어표 만들기" }]` 한 건만 생성되는 상태까지 확인했다. + ## 2026-04-03 v1.4.60 - 샤딩 구조가 생기기 전에 이미 `/uploads/assets/<파일명>.webp`로 평면 저장된 기존 최적화 이미지도 `/uploads/assets/<앞2글자>/<파일명>.webp`로 옮길 수 있도록 일회성 마이그레이션 스크립트 `backend/scripts/migrate-flat-assets-to-sharded.js`를 추가했다. - 이 스크립트는 `backend/uploads/assets` 루트에 남아 있는 실제 평면 파일을 기준으로 샤딩 폴더로 이동하고, `image_assets.src`와 사용자 아바타/주제 썸네일/템플릿 아이템/사용자 아이템/티어표 JSON/템플릿 요청 JSON 참조도 같은 새 경로로 일괄 치환한다. diff --git a/frontend/src/components/admin/AdminFeaturedSection.vue b/frontend/src/components/admin/AdminFeaturedSection.vue index 9b7bc52..b355b1f 100644 --- a/frontend/src/components/admin/AdminFeaturedSection.vue +++ b/frontend/src/components/admin/AdminFeaturedSection.vue @@ -31,7 +31,7 @@ const props = defineProps({ {{ index + 1 }}
{{ template.name }}
-
{{ template.id }}
+
{{ template.slug || template.id }}
@@ -55,7 +55,7 @@ const props = defineProps({ @click="props.addFeaturedTemplate(template.id)" > {{ template.name }} - {{ template.id }} + {{ template.slug || template.id }}
diff --git a/frontend/src/components/admin/AdminTemplatesSection.vue b/frontend/src/components/admin/AdminTemplatesSection.vue index 4312cb4..98b9f13 100644 --- a/frontend/src/components/admin/AdminTemplatesSection.vue +++ b/frontend/src/components/admin/AdminTemplatesSection.vue @@ -13,6 +13,11 @@ const props = defineProps({ hasSelectedTemplate: { type: Boolean, required: true }, selectedTemplate: { type: Object, default: null }, displayThumbnailUrl: { type: String, default: '' }, + templateMetaDraftName: { type: String, default: '' }, + templateMetaDraftSlug: { type: String, default: '' }, + templateMetaSaving: { type: Boolean, required: true }, + canSaveTemplateMeta: { type: Boolean, required: true }, + saveTemplateMeta: { type: Function, required: true }, canApplyThumbnail: { type: Boolean, required: true }, templateVisibilitySaving: { type: Boolean, required: true }, thumbFileInputRef: { type: Function, required: true }, @@ -47,6 +52,8 @@ const props = defineProps({ selectedTemplateId: { type: String, default: '' }, }) +defineEmits(['update:templateMetaDraftName', 'update:templateMetaDraftSlug']) + function setTemplateItemListElement(el) { props.templateItemListRef(el) } @@ -131,13 +138,40 @@ function setThumbFileElement(el) {
템플릿 설정
-
{{ props.selectedTemplate.template.name }} · {{ props.selectedTemplate.template.id }}
+
+ + +
+
공개 URL: /topics/{{ props.selectedTemplate.template.slug || props.selectedTemplate.template.id }}
+
@@ -239,3 +273,25 @@ function setThumbFileElement(el) {
+ + diff --git a/frontend/src/composables/useAdminTemplateManager.js b/frontend/src/composables/useAdminTemplateManager.js index 072069b..c3cb594 100644 --- a/frontend/src/composables/useAdminTemplateManager.js +++ b/frontend/src/composables/useAdminTemplateManager.js @@ -152,7 +152,10 @@ export function useAdminTemplateManager({ } async function createTemplate(options = {}) { - const nextTopicId = typeof options.topicId === 'string' ? options.topicId.trim() : newTemplateId.value.trim() + const nextTopicSlug = + typeof options.topicId === 'string' + ? options.topicId.trim().toLowerCase() + : newTemplateId.value.trim().toLowerCase() const nextTopicName = typeof options.topicName === 'string' ? options.topicName.trim() : newTemplateName.value.trim() const preserveUploadState = !!options.preserveUploadState resetMessages() @@ -162,15 +165,18 @@ export function useAdminTemplateManager({ credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - id: nextTopicId, + slug: nextTopicSlug, name: nextTopicName, isPublic: !!newTemplateIsPublic.value, thumbnailSrc: activeTemplateRequest.value?.type === 'create' ? (activeTemplateRequest.value?.thumbnailSrc || '') : '', }), }) - if (!res.ok) throw new Error('failed') - const data = await res.json() + if (!res.ok) { + const requestError = new Error('failed') + requestError.data = data + throw requestError + } const createdTemplate = data.template || {} if (activeTemplateRequest.value?.type === 'create' && activeTemplateRequest.value?.id) { const linkData = await api.linkAdminTemplateRequestTemplate(activeTemplateRequest.value.id, { @@ -201,7 +207,16 @@ export function useAdminTemplateManager({ } success.value = '템플릿이 생성됐어요. 이어서 썸네일과 기본 아이템을 관리할 수 있어요.' } catch (e) { - error.value = '템플릿 생성 실패(관리자 권한/중복 ID 확인)' + const errorCode = e?.data?.error || '' + if (errorCode === 'topic_slug_taken') { + error.value = '이미 사용 중인 템플릿 주소(slug)입니다.' + return + } + if (errorCode === 'topic_slug_invalid') { + error.value = '템플릿 주소(slug)는 영문 소문자, 숫자, 하이픈만 사용할 수 있어요.' + return + } + error.value = '템플릿 생성 실패(관리자 권한/템플릿 주소 중복 확인)' } } diff --git a/frontend/src/composables/useAdminTemplateRequests.js b/frontend/src/composables/useAdminTemplateRequests.js index 004c342..6834fbf 100644 --- a/frontend/src/composables/useAdminTemplateRequests.js +++ b/frontend/src/composables/useAdminTemplateRequests.js @@ -26,8 +26,10 @@ export function useAdminTemplateRequests({ draftTopicIsPublic: !!request.draftTopicIsPublic, sourceTierListId: request.sourceTierListId || '', sourceTopicId: request.sourceTopicId || '', + sourceTopicSlug: request.sourceTopicSlug || '', sourceTierListTitle: request.sourceTierListTitle || '', targetTopicId: request.targetTopicId || '', + targetTopicSlug: request.targetTopicSlug || '', targetTopicName: request.targetTopicName || '', requesterName: request.requesterName || '', } @@ -38,8 +40,9 @@ export function useAdminTemplateRequests({ } function templateRequestSourceUrl(request) { - if (!request?.sourceTopicId || !request?.sourceTierListId) return '' - return editorPath(request.sourceTopicId, request.sourceTierListId, { preview: true }) + const topicRef = request?.sourceTopicSlug || request?.sourceTopicId || '' + if (!topicRef || !request?.sourceTierListId) return '' + return editorPath(topicRef, request.sourceTierListId, { preview: true }) } function templateRequestReviewHint(request) { diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index 20cd86c..a89f953 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -110,6 +110,9 @@ const success = ref('') const newTemplateId = ref('') const newTemplateName = ref('') const newTemplateIsPublic = ref(false) +const templateMetaDraftName = ref('') +const templateMetaDraftSlug = ref('') +const templateMetaSaving = ref(false) const templateVisibilitySaving = ref(false) const uploadFiles = ref([]) @@ -177,6 +180,17 @@ function normalizeAdminSrc(src) { } const hasSelectedTemplate = computed(() => !!selectedTemplate.value?.template?.id) +const canSaveTemplateMeta = computed(() => { + const template = selectedTemplate.value?.template + if (!template?.id) return false + const nextName = templateMetaDraftName.value.trim() + const nextSlug = templateMetaDraftSlug.value.trim() + return ( + !!nextName && + !!nextSlug && + (nextName !== (template.name || '') || nextSlug !== (template.slug || template.id || '')) + ) +}) const canApplyThumbnail = computed(() => !!thumbFile.value && !!selectedTemplateId.value) const canAddItem = computed(() => uploadItemDrafts.value.length > 0 && uploadItemDrafts.value.every((item) => !!item.label.trim()) && !!selectedTemplateId.value) const stagedRequestDraftCount = computed(() => uploadItemDrafts.value.filter((item) => item.kind === 'request').length) @@ -203,7 +217,7 @@ const filteredTemplatePickerTemplates = computed(() => { const query = templatePickerQuery.value.trim().toLowerCase() const list = templates.value.filter((template) => { if (!query) return true - const haystack = `${template.name || ''} ${template.id || ''}`.toLowerCase() + const haystack = `${template.name || ''} ${template.slug || ''} ${template.id || ''}`.toLowerCase() return haystack.includes(query) }) @@ -462,6 +476,8 @@ watch( watch( () => selectedTemplate.value?.template?.id || '', async (templateId) => { + templateMetaDraftName.value = selectedTemplate.value?.template?.name || '' + templateMetaDraftSlug.value = selectedTemplate.value?.template?.slug || selectedTemplate.value?.template?.id || '' await refreshSelectedTemplateTierListStats(templateId) }, { immediate: true } @@ -893,7 +909,7 @@ async function refreshTemplateRequests() { draftTopicId: request.type === 'create' ? ('tmpl-' + String(request.id || 'request').replace(/[^a-zA-Z0-9]+/g, '').slice(0, 12).toLowerCase()) - : request.targetTopicId || request.sourceTopicId || '', + : request.targetTopicSlug || request.sourceTopicSlug || request.targetTopicId || request.sourceTopicId || '', draftTopicName: request.type === 'create' ? `${request.sourceTierListTitle || request.sourceTopicName || '새 템플릿'}` @@ -1212,6 +1228,44 @@ async function saveTemplateVisibility() { } } +async function saveTemplateMeta() { + if (!selectedTemplate.value?.template?.id || templateMetaSaving.value || !canSaveTemplateMeta.value) return + + try { + templateMetaSaving.value = true + const data = await api.updateAdminTemplate(selectedTemplate.value.template.id, { + name: templateMetaDraftName.value.trim(), + slug: templateMetaDraftSlug.value.trim().toLowerCase(), + isPublic: !!selectedTemplate.value.template.isPublic, + }) + const nextTemplate = data.template || {} + selectedTemplate.value = { + ...selectedTemplate.value, + template: { + ...selectedTemplate.value.template, + ...nextTemplate, + }, + } + templateMetaDraftName.value = nextTemplate.name || selectedTemplate.value.template.name || '' + templateMetaDraftSlug.value = nextTemplate.slug || selectedTemplate.value.template.slug || selectedTemplate.value.template.id || '' + await refreshTemplates() + success.value = '템플릿 이름과 slug를 저장했어요.' + } catch (e) { + const errorCode = e?.data?.error || '' + if (errorCode === 'topic_slug_taken') { + error.value = '이미 사용 중인 템플릿 slug입니다.' + return + } + if (errorCode === 'topic_slug_invalid') { + error.value = 'slug는 영문 소문자, 숫자, 하이픈만 사용할 수 있어요.' + return + } + error.value = '템플릿 이름/slug를 저장하지 못했어요.' + } finally { + templateMetaSaving.value = false + } +} + async function toggleSelectedTemplateVisibility(nextValue) { if (!selectedTemplate.value?.template?.id || templateVisibilitySaving.value) return const previous = !!selectedTemplate.value.template.isPublic @@ -1603,8 +1657,9 @@ function closePreviewModal() { } function previewTierListUrl(tierList) { - if (!tierList?.topicId || !tierList?.id) return '' - return editorPath(tierList.topicId, tierList.id, { preview: true }) + const topicRef = tierList?.topicSlug || tierList?.topicId || '' + if (!topicRef || !tierList?.id) return '' + return editorPath(topicRef, tierList.id, { preview: true }) } function openTierListImportModal(tierList, items) { @@ -1619,9 +1674,10 @@ function openTierListImportModal(tierList, items) { importModalItems.value = nextItems importModalMode.value = 'existing' importModalTargetTemplateId.value = '' - importModalNewTemplateId.value = tierList.topicId === 'freeform' ? '' : `${tierList.topicId}-copy` + const baseSlug = tierList.topicSlug || tierList.topicId || '' + importModalNewTemplateId.value = baseSlug === 'freeform' ? '' : `${baseSlug}-copy` importModalNewTemplateName.value = - tierList.topicId === 'freeform' ? `${tierList.title} 템플릿` : `${tierList.topicName || tierList.topicId} 파생 템플릿` + baseSlug === 'freeform' ? `${tierList.title} 템플릿` : `${tierList.topicName || baseSlug} 파생 템플릿` importModalOpen.value = true } @@ -1655,15 +1711,15 @@ async function confirmTierListImport() { if (selectedTemplateId.value === importModalTargetTemplateId.value) await loadTemplate() success.value = `${data.items?.length || 0}개의 아이템을 기존 템플릿에 추가했어요.` } else { - const nextTopicId = (importModalNewTemplateId.value || '').trim() + const nextTopicId = (importModalNewTemplateId.value || '').trim().toLowerCase() const nextTopicName = (importModalNewTemplateName.value || '').trim() if (!nextTopicId || !nextTopicName) { - error.value = '새 템플릿 ID와 이름을 모두 입력해주세요.' + error.value = '새 템플릿 slug와 이름을 모두 입력해주세요.' return } const data = await api.createAdminTemplateFromTierList(tierList.id, { - topicId: nextTopicId, + slug: nextTopicId, name: nextTopicName, itemIds, }) @@ -1684,11 +1740,11 @@ function templateRequestTypeLabel(request) { function templateRequestTargetLabel(request) { if (request.type === 'create') { if (request.targetTopicName || request.targetTopicId) { - return `연결된 템플릿 · ${request.targetTopicName || request.targetTopicId}` + return `연결된 템플릿 · ${request.targetTopicName || request.targetTopicSlug || request.targetTopicId}` } return '연결된 템플릿 없음' } - return request.targetTopicName || request.targetTopicId || request.sourceTopicName + return request.targetTopicName || request.targetTopicSlug || request.targetTopicId || request.sourceTopicName } const displayThumbnailUrl = computed(() => { @@ -1768,6 +1824,11 @@ function openUserProfile(user) { :has-selected-template="hasSelectedTemplate" :selected-template="selectedTemplate" :display-thumbnail-url="displayThumbnailUrl" + v-model:template-meta-draft-name="templateMetaDraftName" + v-model:template-meta-draft-slug="templateMetaDraftSlug" + :template-meta-saving="templateMetaSaving" + :can-save-template-meta="canSaveTemplateMeta" + :save-template-meta="saveTemplateMeta" :can-apply-thumbnail="canApplyThumbnail" :template-visibility-saving="templateVisibilitySaving" :thumb-file-input-ref="setThumbFileInputRef" @@ -1868,9 +1929,9 @@ function openUserProfile(user) { />
-