const path = require('path') const mysql = require('mysql2/promise') const { Low } = require('lowdb') const { JSONFile } = require('lowdb/node') const DB_CLIENT = process.env.DB_CLIENT || 'lowdb' const DB_HOST = process.env.DB_HOST || '127.0.0.1' const DB_PORT = process.env.DB_PORT ? Number(process.env.DB_PORT) : 3306 const DB_USER = process.env.DB_USER || 'root' 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 LOWDB_PATH = path.join(__dirname, '..', 'data', 'db.json') let lowdbPromise = null let poolPromise = null let initPromise = null function now() { return Date.now() } function defaultData() { return { users: [], games: [ { id: 'example-game', name: '예시 게임', thumbnailSrc: '', createdAt: now() }, { id: 'another-game', name: '다른 예시 게임', thumbnailSrc: '', createdAt: now() }, ], gameImages: [ { id: 'img-1', gameId: 'example-game', src: '/uploads/seeds/example1.png', label: '샘플 1', createdAt: now() }, { id: 'img-2', gameId: 'example-game', src: '/uploads/seeds/example2.png', label: '샘플 2', createdAt: now() }, ], customItems: [], tierLists: [], gameSuggestions: [], } } function parseJson(value, fallback) { if (!value) return fallback try { return JSON.parse(value) } catch (e) { return fallback } } function serializeJson(value) { return JSON.stringify(value || []) } function mapUserRow(row) { if (!row) return null return { id: row.id, email: row.email, nickname: row.nickname || '', isAdmin: !!row.is_admin, avatarSrc: row.avatar_src || '', createdAt: Number(row.created_at), } } function mapGameRow(row) { if (!row) return null return { id: row.id, name: row.name, thumbnailSrc: row.thumbnail_src || '', createdAt: Number(row.created_at), } } function mapGameItemRow(row) { if (!row) return null return { id: row.id, gameId: row.game_id, src: row.src, label: row.label, createdAt: Number(row.created_at), } } function mapTierListRow(row) { if (!row) return null return { id: row.id, authorId: row.author_id, gameId: row.game_id, title: row.title, description: row.description || '', isPublic: !!row.is_public, groups: parseJson(row.groups_json, []), pool: parseJson(row.pool_json, []), createdAt: Number(row.created_at), updatedAt: Number(row.updated_at), } } async function getLowdb() { if (lowdbPromise) return lowdbPromise lowdbPromise = (async () => { const adapter = new JSONFile(LOWDB_PATH) const db = new Low(adapter, defaultData()) await db.read() db.data ||= defaultData() db.data.users ||= [] db.data.games ||= [] db.data.gameImages ||= [] db.data.customItems ||= [] db.data.tierLists ||= [] db.data.gameSuggestions ||= [] await db.write() return db })() return lowdbPromise } async function createPool() { const rootConnection = await mysql.createConnection({ host: DB_HOST, port: DB_PORT, user: DB_USER, password: DB_PASSWORD, multipleStatements: true, }) await rootConnection.query( `CREATE DATABASE IF NOT EXISTS \`${DB_NAME}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci` ) await rootConnection.end() return mysql.createPool({ host: DB_HOST, port: DB_PORT, user: DB_USER, password: DB_PASSWORD, database: DB_NAME, connectionLimit: DB_CONNECTION_LIMIT, charset: 'utf8mb4', }) } async function getPool() { if (!poolPromise) { poolPromise = createPool() } return poolPromise } async function query(sql, params = []) { const pool = await getPool() const [rows] = await pool.execute(sql, params) return rows } async function ensureMariaSchema() { if (initPromise) return initPromise initPromise = (async () => { await query(` CREATE TABLE IF NOT EXISTS users ( id VARCHAR(64) PRIMARY KEY, email VARCHAR(255) NOT NULL UNIQUE, nickname VARCHAR(80) NOT NULL DEFAULT '', password_hash VARCHAR(255) NOT NULL, is_admin TINYINT(1) NOT NULL DEFAULT 0, avatar_src VARCHAR(255) NOT NULL DEFAULT '', created_at BIGINT NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `) await query(` CREATE TABLE IF NOT EXISTS games ( id VARCHAR(120) PRIMARY KEY, name VARCHAR(120) NOT NULL, thumbnail_src VARCHAR(255) NOT NULL DEFAULT '', created_at BIGINT NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `) await query(` CREATE TABLE IF NOT EXISTS game_items ( id VARCHAR(64) PRIMARY KEY, game_id VARCHAR(120) NOT NULL, src VARCHAR(255) NOT NULL, label VARCHAR(120) NOT NULL, created_at BIGINT NOT NULL, INDEX idx_game_items_game_id (game_id), CONSTRAINT fk_game_items_game FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `) await query(` CREATE TABLE IF NOT EXISTS custom_items ( id VARCHAR(64) PRIMARY KEY, owner_id VARCHAR(64) NOT NULL, src VARCHAR(255) NOT NULL, label VARCHAR(120) NOT NULL, created_at BIGINT NOT NULL, INDEX idx_custom_items_owner_id (owner_id), CONSTRAINT fk_custom_items_owner FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `) await query(` CREATE TABLE IF NOT EXISTS tierlists ( id VARCHAR(64) PRIMARY KEY, author_id VARCHAR(64) NOT NULL, game_id VARCHAR(120) NOT NULL, title VARCHAR(120) NOT NULL, description TEXT NOT NULL, is_public TINYINT(1) NOT NULL DEFAULT 0, groups_json LONGTEXT NOT NULL, pool_json LONGTEXT NOT NULL, created_at BIGINT NOT NULL, updated_at BIGINT NOT NULL, INDEX idx_tierlists_author_id (author_id), INDEX idx_tierlists_game_id (game_id), INDEX idx_tierlists_public_game_updated (is_public, game_id, updated_at), CONSTRAINT fk_tierlists_author FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE CASCADE, CONSTRAINT fk_tierlists_game FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `) await query(` CREATE TABLE IF NOT EXISTS game_suggestions ( id VARCHAR(64) PRIMARY KEY, name VARCHAR(120) NOT NULL, created_at BIGINT NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `) const countRows = await query('SELECT COUNT(*) AS count FROM games') if (Number(countRows[0]?.count || 0) === 0) { const createdAt = now() await query( ` INSERT INTO games (id, name, thumbnail_src, created_at) VALUES (?, ?, ?, ?), (?, ?, ?, ?) `, ['example-game', '예시 게임', '', createdAt, 'another-game', '다른 예시 게임', '', createdAt] ) await query( ` INSERT INTO game_items (id, game_id, src, label, created_at) VALUES (?, ?, ?, ?, ?), (?, ?, ?, ?, ?) `, [ 'img-1', 'example-game', '/uploads/seeds/example1.png', '샘플 1', createdAt, 'img-2', 'example-game', '/uploads/seeds/example2.png', '샘플 2', createdAt, ] ) } })() return initPromise } async function ensureData() { if (DB_CLIENT === 'mariadb') { await ensureMariaSchema() return } await getLowdb() } async function countUsers() { if (DB_CLIENT === 'mariadb') { const rows = await query('SELECT COUNT(*) AS count FROM users') return Number(rows[0]?.count || 0) } const db = await getLowdb() return db.data.users.length } async function findUserByEmail(email) { if (DB_CLIENT === 'mariadb') { const rows = await query( 'SELECT id, email, nickname, password_hash, is_admin, avatar_src, created_at FROM users WHERE email = ? LIMIT 1', [email] ) const row = rows[0] if (!row) return null return { ...mapUserRow(row), passwordHash: row.password_hash } } const db = await getLowdb() return db.data.users.find((user) => user.email === email) || null } async function findUserById(id) { if (DB_CLIENT === 'mariadb') { const rows = await query( 'SELECT id, email, nickname, is_admin, avatar_src, created_at FROM users WHERE id = ? LIMIT 1', [id] ) return mapUserRow(rows[0]) } const db = await getLowdb() const user = db.data.users.find((entry) => entry.id === id) if (!user) return null return { id: user.id, email: user.email, nickname: user.nickname || '', isAdmin: !!user.isAdmin, avatarSrc: user.avatarSrc || '', createdAt: Number(user.createdAt), } } async function createUser({ id, email, nickname, passwordHash, isAdmin }) { if (DB_CLIENT === 'mariadb') { const createdAt = now() await query( ` INSERT INTO users (id, email, nickname, password_hash, is_admin, avatar_src, created_at) VALUES (?, ?, ?, ?, ?, ?, ?) `, [id, email, nickname || '', passwordHash, isAdmin ? 1 : 0, '', createdAt] ) return findUserById(id) } const db = await getLowdb() const user = { id, email, nickname: nickname || '', passwordHash, isAdmin: !!isAdmin, avatarSrc: '', createdAt: now(), } db.data.users.push(user) await db.write() return findUserById(id) } async function updateUserProfile({ id, nickname, avatarSrc }) { if (DB_CLIENT === 'mariadb') { if (typeof avatarSrc === 'string') { await query('UPDATE users SET nickname = ?, avatar_src = ? WHERE id = ?', [nickname || '', avatarSrc, id]) } else { await query('UPDATE users SET nickname = ? WHERE id = ?', [nickname || '', id]) } return findUserById(id) } const db = await getLowdb() const user = db.data.users.find((entry) => entry.id === id) if (!user) return null user.nickname = nickname || '' if (typeof avatarSrc === 'string') user.avatarSrc = avatarSrc await db.write() return findUserById(id) } async function listGames() { if (DB_CLIENT === 'mariadb') { const rows = await query('SELECT id, name, thumbnail_src, created_at FROM games ORDER BY created_at ASC, name ASC') return rows.map(mapGameRow) } const db = await getLowdb() return db.data.games.map((game) => ({ id: game.id, name: game.name, thumbnailSrc: game.thumbnailSrc || '', createdAt: Number(game.createdAt), })) } async function findGameById(id) { if (DB_CLIENT === 'mariadb') { const rows = await query('SELECT id, name, thumbnail_src, created_at FROM games WHERE id = ? LIMIT 1', [id]) return mapGameRow(rows[0]) } const db = await getLowdb() const game = db.data.games.find((entry) => entry.id === id) if (!game) return null return { id: game.id, name: game.name, thumbnailSrc: game.thumbnailSrc || '', createdAt: Number(game.createdAt), } } async function listGameItems(gameId) { if (DB_CLIENT === 'mariadb') { const rows = await query( 'SELECT id, game_id, src, label, created_at FROM game_items WHERE game_id = ? ORDER BY created_at ASC', [gameId] ) return rows.map(mapGameItemRow) } const db = await getLowdb() return db.data.gameImages .filter((item) => item.gameId === gameId) .map((item) => ({ id: item.id, gameId: item.gameId, src: item.src, label: item.label, createdAt: Number(item.createdAt), })) } async function getGameDetail(gameId) { const game = await findGameById(gameId) if (!game) return null const items = await listGameItems(gameId) return { game, items } } async function createGame({ id, name }) { if (DB_CLIENT === 'mariadb') { await query('INSERT INTO games (id, name, thumbnail_src, created_at) VALUES (?, ?, ?, ?)', [id, name, '', now()]) return findGameById(id) } const db = await getLowdb() db.data.games.push({ id, name, thumbnailSrc: '', createdAt: now() }) await db.write() return findGameById(id) } async function updateGameThumbnail(gameId, thumbnailSrc) { if (DB_CLIENT === 'mariadb') { await query('UPDATE games SET thumbnail_src = ? WHERE id = ?', [thumbnailSrc, gameId]) return findGameById(gameId) } const db = await getLowdb() const game = db.data.games.find((entry) => entry.id === gameId) if (!game) return null game.thumbnailSrc = thumbnailSrc await db.write() return findGameById(gameId) } async function createGameItem({ id, gameId, src, label }) { if (DB_CLIENT === 'mariadb') { const createdAt = now() await query('INSERT INTO game_items (id, game_id, src, label, created_at) VALUES (?, ?, ?, ?, ?)', [ id, gameId, src, label, createdAt, ]) const rows = await query('SELECT id, game_id, src, label, created_at FROM game_items WHERE id = ? LIMIT 1', [id]) return mapGameItemRow(rows[0]) } const db = await getLowdb() const item = { id, gameId, src, label, createdAt: now() } db.data.gameImages.push(item) await db.write() return { id: item.id, gameId: item.gameId, src: item.src, label: item.label, createdAt: Number(item.createdAt), } } async function createCustomItem({ id, ownerId, src, label }) { if (DB_CLIENT === 'mariadb') { const createdAt = now() await query('INSERT INTO custom_items (id, owner_id, src, label, created_at) VALUES (?, ?, ?, ?, ?)', [ id, ownerId, src, label, createdAt, ]) return { id, ownerId, src, label, origin: 'custom', createdAt } } const db = await getLowdb() const item = { id, ownerId, src, label, origin: 'custom', createdAt: now() } db.data.customItems.push(item) await db.write() return item } async function createGameSuggestion({ id, name }) { if (DB_CLIENT === 'mariadb') { const createdAt = now() await query('INSERT INTO game_suggestions (id, name, created_at) VALUES (?, ?, ?)', [id, name, createdAt]) return { id, name, createdAt } } const db = await getLowdb() const suggestion = { id, name, createdAt: now() } db.data.gameSuggestions.push(suggestion) await db.write() return suggestion } async function listPublicTierLists(gameId) { if (DB_CLIENT === 'mariadb') { const params = [] let whereClause = 'WHERE t.is_public = 1' if (gameId) { whereClause += ' AND t.game_id = ?' params.push(gameId) } const rows = await query( ` SELECT t.id, t.game_id, t.title, t.created_at, t.updated_at, t.author_id, u.nickname, u.email FROM tierlists t INNER JOIN users u ON u.id = t.author_id ${whereClause} ORDER BY t.updated_at DESC LIMIT 50 `, params ) return rows.map((row) => ({ id: row.id, gameId: row.game_id, title: row.title, createdAt: Number(row.created_at), updatedAt: Number(row.updated_at), authorId: row.author_id, authorName: row.nickname || row.email, })) } const db = await getLowdb() return db.data.tierLists .filter((tierList) => tierList.isPublic && (!gameId || tierList.gameId === gameId)) .sort((a, b) => Number(b.updatedAt) - Number(a.updatedAt)) .slice(0, 50) .map((tierList) => { const author = db.data.users.find((user) => user.id === tierList.authorId) return { id: tierList.id, gameId: tierList.gameId, title: tierList.title, createdAt: Number(tierList.createdAt), updatedAt: Number(tierList.updatedAt), authorId: tierList.authorId, authorName: author?.nickname || author?.email || '알 수 없음', } }) } async function listUserTierLists(userId) { if (DB_CLIENT === 'mariadb') { const rows = await query( ` SELECT id, game_id, title, created_at, updated_at, is_public FROM tierlists WHERE author_id = ? ORDER BY updated_at DESC `, [userId] ) return rows.map((row) => ({ id: row.id, gameId: row.game_id, title: row.title, createdAt: Number(row.created_at), updatedAt: Number(row.updated_at), isPublic: !!row.is_public, })) } const db = await getLowdb() return db.data.tierLists .filter((tierList) => tierList.authorId === userId) .sort((a, b) => Number(b.updatedAt) - Number(a.updatedAt)) .map((tierList) => ({ id: tierList.id, gameId: tierList.gameId, title: tierList.title, createdAt: Number(tierList.createdAt), updatedAt: Number(tierList.updatedAt), isPublic: !!tierList.isPublic, })) } async function findTierListById(id) { if (DB_CLIENT === 'mariadb') { const rows = await query( ` SELECT id, author_id, game_id, title, description, is_public, groups_json, pool_json, created_at, updated_at FROM tierlists WHERE id = ? LIMIT 1 `, [id] ) return mapTierListRow(rows[0]) } const db = await getLowdb() const tierList = db.data.tierLists.find((entry) => entry.id === id) if (!tierList) return null return { id: tierList.id, authorId: tierList.authorId, gameId: tierList.gameId, title: tierList.title, description: tierList.description || '', isPublic: !!tierList.isPublic, groups: Array.isArray(tierList.groups) ? tierList.groups : [], pool: Array.isArray(tierList.pool) ? tierList.pool : [], createdAt: Number(tierList.createdAt), updatedAt: Number(tierList.updatedAt), } } async function saveTierList({ id, authorId, gameId, title, description, isPublic, groups, pool }) { if (DB_CLIENT === 'mariadb') { const existing = id ? await findTierListById(id) : null if (existing) { await query( ` UPDATE tierlists SET title = ?, description = ?, is_public = ?, groups_json = ?, pool_json = ?, updated_at = ? WHERE id = ? `, [title, description || '', isPublic ? 1 : 0, serializeJson(groups), serializeJson(pool), now(), existing.id] ) return findTierListById(existing.id) } const createdAt = now() await query( ` INSERT INTO tierlists ( id, author_id, game_id, title, description, is_public, groups_json, pool_json, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, [id, authorId, gameId, title, description || '', isPublic ? 1 : 0, serializeJson(groups), serializeJson(pool), createdAt, createdAt] ) return findTierListById(id) } const db = await getLowdb() const existing = db.data.tierLists.find((entry) => entry.id === id) if (existing) { existing.title = title existing.description = description || '' existing.isPublic = !!isPublic existing.groups = groups existing.pool = pool existing.updatedAt = now() await db.write() return findTierListById(existing.id) } const tierList = { id, authorId, gameId, title, description: description || '', isPublic: !!isPublic, groups, pool, createdAt: now(), updatedAt: now(), } db.data.tierLists.push(tierList) await db.write() return findTierListById(id) } module.exports = { DB_CLIENT, DB_NAME, ensureData, countUsers, findUserByEmail, findUserById, createUser, updateUserProfile, listGames, findGameById, listGameItems, getGameDetail, createGame, updateGameThumbnail, createGameItem, createCustomItem, createGameSuggestion, listPublicTierLists, listUserTierLists, findTierListById, saveTierList, }