v4.0.0: 회원·덱 API, 인증 UI, 프로필 및 레이아웃 통일
Express/SQLite API와 Docker 구성을 추가하고 로그인·덱 저장·공개 덱·관리자·프로필 화면을 구현했다. 결과 화면 저장 UX, 헤더 프로필, 애드센스용 app-shell 레이아웃(1280px) 및 문서를 반영했다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
3
api/.gitignore
vendored
Normal file
3
api/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
data/
|
||||
.env
|
||||
18
api/Dockerfile
Normal file
18
api/Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
||||
FROM node:22-alpine
|
||||
|
||||
RUN apk add --no-cache python3 make g++
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json ./
|
||||
RUN npm install --omit=dev
|
||||
|
||||
COPY src ./src
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV DATAS_PATH=/datas
|
||||
ENV DB_PATH=/data/uadeck.db
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "src/index.js"]
|
||||
18
api/package.json
Normal file
18
api/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "uadeck-api",
|
||||
"version": "4.0.0",
|
||||
"type": "module",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "node --watch src/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^3.0.2",
|
||||
"better-sqlite3": "^11.9.1",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.5.0",
|
||||
"express": "^5.1.0",
|
||||
"jsonwebtoken": "^9.0.2"
|
||||
}
|
||||
}
|
||||
102
api/src/db.js
Normal file
102
api/src/db.js
Normal file
@@ -0,0 +1,102 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* SQLite DB 연결 및 스키마 초기화
|
||||
* @param {string} dbPath
|
||||
* @returns {import('better-sqlite3').Database}
|
||||
*/
|
||||
export function initDb(dbPath) {
|
||||
const dir = path.dirname(dbPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
const db = new Database(dbPath);
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
display_name TEXT,
|
||||
avatar_url TEXT,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS decks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
share_code TEXT NOT NULL UNIQUE,
|
||||
share_type TEXT NOT NULL DEFAULT 'user',
|
||||
content_hash TEXT,
|
||||
user_id INTEGER,
|
||||
series TEXT NOT NULL,
|
||||
series_name TEXT,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
cards_json TEXT NOT NULL,
|
||||
is_public INTEGER NOT NULL DEFAULT 1,
|
||||
view_count INTEGER NOT NULL DEFAULT 0,
|
||||
expires_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_decks_user_id ON decks(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_decks_content_hash ON decks(content_hash);
|
||||
`);
|
||||
|
||||
const deckCols = db.prepare('PRAGMA table_info(decks)').all();
|
||||
if (!deckCols.some((c) => c.name === 'preview_image')) {
|
||||
db.exec('ALTER TABLE decks ADD COLUMN preview_image TEXT');
|
||||
}
|
||||
|
||||
const userCols = db.prepare('PRAGMA table_info(users)').all();
|
||||
if (!userCols.some((c) => c.name === 'default_deck_public')) {
|
||||
db.exec('ALTER TABLE users ADD COLUMN default_deck_public INTEGER NOT NULL DEFAULT 1');
|
||||
}
|
||||
if (!userCols.some((c) => c.name === 'skip_deck_public_prompt')) {
|
||||
db.exec('ALTER TABLE users ADD COLUMN skip_deck_public_prompt INTEGER NOT NULL DEFAULT 0');
|
||||
}
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
/**
|
||||
* 환경변수 기준 관리자 계정 시드
|
||||
* ADMIN_EMAIL로 이미 가입한 경우 역할만 admin으로 승격한다.
|
||||
* @param {import('better-sqlite3').Database} db
|
||||
*/
|
||||
export function seedAdmin(db) {
|
||||
const email = process.env.ADMIN_EMAIL?.trim().toLowerCase();
|
||||
const password = process.env.ADMIN_PASSWORD;
|
||||
const username = process.env.ADMIN_USERNAME || 'admin';
|
||||
|
||||
if (!email || !password) return;
|
||||
|
||||
const existingAdmin = db.prepare('SELECT id FROM users WHERE role = ?').get('admin');
|
||||
if (existingAdmin) return;
|
||||
|
||||
const existingUser = db.prepare('SELECT id FROM users WHERE email = ?').get(email);
|
||||
if (existingUser) {
|
||||
db.prepare(`
|
||||
UPDATE users SET role = 'admin', updated_at = datetime('now')
|
||||
WHERE id = ?
|
||||
`).run(existingUser.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const hash = bcrypt.hashSync(password, 12);
|
||||
db.prepare(`
|
||||
INSERT INTO users (email, username, password_hash, display_name, role)
|
||||
VALUES (?, ?, ?, ?, 'admin')
|
||||
`).run(email, username, hash, '관리자');
|
||||
}
|
||||
48
api/src/index.js
Normal file
48
api/src/index.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import 'dotenv/config';
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { initDb, seedAdmin } from './db.js';
|
||||
import { createAuthRouter } from './routes/auth.js';
|
||||
import { createDecksRouter } from './routes/decks.js';
|
||||
import { createAdminRouter } from './routes/admin.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PORT = Number(process.env.PORT) || 3000;
|
||||
const DB_PATH = process.env.DB_PATH || path.join(__dirname, '..', 'data', 'uadeck.db');
|
||||
|
||||
if (!process.env.DATAS_PATH) {
|
||||
process.env.DATAS_PATH = path.join(__dirname, '..', '..', 'datas');
|
||||
}
|
||||
|
||||
const db = initDb(DB_PATH);
|
||||
seedAdmin(db);
|
||||
|
||||
const app = express();
|
||||
|
||||
const corsOrigins = process.env.CORS_ORIGINS
|
||||
? process.env.CORS_ORIGINS.split(',').map((o) => o.trim())
|
||||
: true;
|
||||
|
||||
app.use(cors({ origin: corsOrigins, credentials: true }));
|
||||
app.use(express.json({ limit: '15mb' }));
|
||||
|
||||
app.get('/api/v1/health', (_req, res) => {
|
||||
res.json({ ok: true, version: '4.0.0' });
|
||||
});
|
||||
|
||||
app.use('/api/v1/auth', createAuthRouter(db));
|
||||
app.use('/api/v1/decks', createDecksRouter(db));
|
||||
app.use('/api/v1/admin', createAdminRouter(db));
|
||||
|
||||
app.use((err, _req, res, _next) => {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: '서버 오류가 발생했습니다.' });
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`UADECK API v4.0.0 listening on :${PORT}`);
|
||||
console.log(`DB: ${DB_PATH}`);
|
||||
console.log(`DATAS: ${process.env.DATAS_PATH || '(default ../datas)'}`);
|
||||
});
|
||||
91
api/src/middleware/auth.js
Normal file
91
api/src/middleware/auth.js
Normal file
@@ -0,0 +1,91 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
/**
|
||||
* JWT 시크릿
|
||||
* @returns {string}
|
||||
*/
|
||||
function getSecret() {
|
||||
const secret = process.env.JWT_SECRET;
|
||||
if (!secret) {
|
||||
throw new Error('JWT_SECRET 환경변수가 설정되지 않았습니다.');
|
||||
}
|
||||
return secret;
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT 토큰 발급
|
||||
* @param {object} user
|
||||
* @returns {string}
|
||||
*/
|
||||
export function signToken(user) {
|
||||
return jwt.sign(
|
||||
{ sub: user.id, role: user.role },
|
||||
getSecret(),
|
||||
{ expiresIn: process.env.JWT_EXPIRES || '7d' },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택적 인증 — 토큰 없으면 req.user = null
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
* @param {import('express').NextFunction} next
|
||||
*/
|
||||
export function optionalAuth(req, res, next) {
|
||||
const header = req.headers.authorization;
|
||||
if (!header?.startsWith('Bearer ')) {
|
||||
req.user = null;
|
||||
return next();
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = jwt.verify(header.slice(7), getSecret());
|
||||
req.user = { id: payload.sub, role: payload.role };
|
||||
} catch {
|
||||
req.user = null;
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* 필수 인증
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
* @param {import('express').NextFunction} next
|
||||
*/
|
||||
export function requireAuth(req, res, next) {
|
||||
optionalAuth(req, res, () => {
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ error: '로그인이 필요합니다.' });
|
||||
}
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 전용 — JWT가 아닌 DB의 최신 role을 확인한다.
|
||||
* @param {import('better-sqlite3').Database} db
|
||||
* @returns {import('express').RequestHandler}
|
||||
*/
|
||||
export function createRequireAdmin(db) {
|
||||
return (req, res, next) => {
|
||||
requireAuth(req, res, () => {
|
||||
const row = db.prepare('SELECT role FROM users WHERE id = ? AND is_active = 1').get(req.user.id);
|
||||
if (!row || row.role !== 'admin') {
|
||||
return res.status(403).json({ error: '관리자 권한이 필요합니다.' });
|
||||
}
|
||||
req.user.role = row.role;
|
||||
next();
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated createRequireAdmin(db) 사용 권장
|
||||
*/
|
||||
export function requireAdmin(req, res, next) {
|
||||
if (req.user?.role !== 'admin') {
|
||||
return res.status(403).json({ error: '관리자 권한이 필요합니다.' });
|
||||
}
|
||||
next();
|
||||
}
|
||||
250
api/src/routes/admin.js
Normal file
250
api/src/routes/admin.js
Normal file
@@ -0,0 +1,250 @@
|
||||
import { Router } from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { createRequireAdmin } from '../middleware/auth.js';
|
||||
import { contentHash, validateCards, nowIso } from '../utils.js';
|
||||
import { resolveDeckCards } from '../services/cardResolver.js';
|
||||
import { getPreviewUrl } from '../services/previewImage.js';
|
||||
import { getAvatarUrl, saveAvatarImage, deleteAvatarImage } from '../services/avatarImage.js';
|
||||
|
||||
/**
|
||||
* 관리자 라우트
|
||||
* @param {import('better-sqlite3').Database} db
|
||||
* @returns {import('express').Router}
|
||||
*/
|
||||
export function createAdminRouter(db) {
|
||||
const router = Router();
|
||||
router.use(createRequireAdmin(db));
|
||||
|
||||
router.get('/users', (req, res) => {
|
||||
const { q, page = 1, limit = 20 } = req.query;
|
||||
const offset = (Number(page) - 1) * Number(limit);
|
||||
let rows;
|
||||
let total;
|
||||
|
||||
if (q) {
|
||||
const like = `%${q}%`;
|
||||
total = db.prepare(`
|
||||
SELECT COUNT(*) AS c FROM users
|
||||
WHERE email LIKE ? OR username LIKE ? OR display_name LIKE ?
|
||||
`).get(like, like, like).c;
|
||||
rows = db.prepare(`
|
||||
SELECT id, email, username, display_name AS displayName, avatar_url AS avatarUrl,
|
||||
role, is_active AS isActive, created_at AS createdAt, updated_at AS updatedAt
|
||||
FROM users
|
||||
WHERE email LIKE ? OR username LIKE ? OR display_name LIKE ?
|
||||
ORDER BY created_at DESC LIMIT ? OFFSET ?
|
||||
`).all(like, like, like, Number(limit), offset);
|
||||
} else {
|
||||
total = db.prepare('SELECT COUNT(*) AS c FROM users').get().c;
|
||||
rows = db.prepare(`
|
||||
SELECT id, email, username, display_name AS displayName, avatar_url AS avatarUrl,
|
||||
role, is_active AS isActive, created_at AS createdAt, updated_at AS updatedAt
|
||||
FROM users ORDER BY created_at DESC LIMIT ? OFFSET ?
|
||||
`).all(Number(limit), offset);
|
||||
}
|
||||
|
||||
res.json({
|
||||
users: rows.map((u) => ({
|
||||
...u,
|
||||
avatarUrl: getAvatarUrl(u.id) || u.avatarUrl,
|
||||
})),
|
||||
total,
|
||||
page: Number(page),
|
||||
limit: Number(limit),
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/users/:id', (req, res) => {
|
||||
const user = db.prepare(`
|
||||
SELECT id, email, username, display_name AS displayName, avatar_url AS avatarUrl,
|
||||
role, is_active AS isActive, created_at AS createdAt, updated_at AS updatedAt
|
||||
FROM users WHERE id = ?
|
||||
`).get(req.params.id);
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: '사용자를 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
const deckCount = db.prepare('SELECT COUNT(*) AS c FROM decks WHERE user_id = ?').get(user.id).c;
|
||||
user.avatarUrl = getAvatarUrl(user.id) || user.avatarUrl;
|
||||
res.json({ user, deckCount });
|
||||
});
|
||||
|
||||
router.put('/users/:id', (req, res) => {
|
||||
const targetId = Number(req.params.id);
|
||||
const user = db.prepare('SELECT id, role FROM users WHERE id = ?').get(targetId);
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: '사용자를 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
const isSelf = req.user.id === targetId;
|
||||
const { email, username, role, isActive, avatarImage, removeAvatar } = req.body || {};
|
||||
|
||||
if (isSelf) {
|
||||
if (role === 'user') {
|
||||
return res.status(400).json({ error: '자신의 관리자 권한은 해제할 수 없습니다.' });
|
||||
}
|
||||
if (isActive === false) {
|
||||
return res.status(400).json({ error: '자신의 계정은 비활성화할 수 없습니다.' });
|
||||
}
|
||||
}
|
||||
|
||||
let nextAvatarUrl;
|
||||
if (avatarImage) {
|
||||
const saved = saveAvatarImage(targetId, avatarImage);
|
||||
if (!saved) {
|
||||
return res.status(400).json({ error: '프로필 이미지 저장에 실패했습니다.' });
|
||||
}
|
||||
nextAvatarUrl = getAvatarUrl(targetId);
|
||||
} else if (removeAvatar === true) {
|
||||
deleteAvatarImage(targetId);
|
||||
nextAvatarUrl = null;
|
||||
}
|
||||
|
||||
const trimmedUsername = username?.trim() ?? null;
|
||||
const trimmedEmail = email?.trim().toLowerCase() ?? null;
|
||||
|
||||
try {
|
||||
const fields = ['updated_at = ?'];
|
||||
const values = [nowIso()];
|
||||
|
||||
if (trimmedEmail) {
|
||||
fields.push('email = ?');
|
||||
values.push(trimmedEmail);
|
||||
}
|
||||
if (trimmedUsername) {
|
||||
fields.push('username = ?', 'display_name = ?');
|
||||
values.push(trimmedUsername, trimmedUsername);
|
||||
}
|
||||
if (!isSelf && role) {
|
||||
fields.push('role = ?');
|
||||
values.push(role);
|
||||
}
|
||||
if (!isSelf && isActive !== undefined) {
|
||||
fields.push('is_active = ?');
|
||||
values.push(isActive ? 1 : 0);
|
||||
}
|
||||
if (nextAvatarUrl !== undefined) {
|
||||
fields.push('avatar_url = ?');
|
||||
values.push(nextAvatarUrl);
|
||||
}
|
||||
|
||||
values.push(targetId);
|
||||
db.prepare(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`).run(...values);
|
||||
} catch (err) {
|
||||
if (String(err.message).includes('UNIQUE')) {
|
||||
return res.status(409).json({ error: '이메일 또는 사용자명이 이미 사용 중입니다.' });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const updated = db.prepare(`
|
||||
SELECT id, email, username, display_name AS displayName, avatar_url AS avatarUrl,
|
||||
role, is_active AS isActive, created_at AS createdAt, updated_at AS updatedAt
|
||||
FROM users WHERE id = ?
|
||||
`).get(targetId);
|
||||
updated.avatarUrl = getAvatarUrl(updated.id) || updated.avatarUrl;
|
||||
|
||||
res.json({ user: updated });
|
||||
});
|
||||
|
||||
router.post('/users/:id/reset-password', (req, res) => {
|
||||
const user = db.prepare('SELECT id FROM users WHERE id = ?').get(req.params.id);
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: '사용자를 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
const { newPassword } = req.body || {};
|
||||
if (!newPassword || newPassword.length < 8) {
|
||||
return res.status(400).json({ error: '새 비밀번호는 8자 이상이어야 합니다.' });
|
||||
}
|
||||
|
||||
const hash = bcrypt.hashSync(newPassword, 12);
|
||||
db.prepare('UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?').run(hash, nowIso(), req.params.id);
|
||||
|
||||
res.json({ ok: true, message: '비밀번호가 초기화되었습니다.' });
|
||||
});
|
||||
|
||||
router.post('/decks/preset', async (req, res, next) => {
|
||||
try {
|
||||
const { series, seriesName, name, cards, isPublic } = req.body || {};
|
||||
if (!series || !cards || !name) {
|
||||
return res.status(400).json({ error: 'series, name, cards는 필수입니다.' });
|
||||
}
|
||||
|
||||
const normalized = validateCards(cards);
|
||||
const hash = contentHash(normalized);
|
||||
const existing = db.prepare('SELECT share_code FROM decks WHERE content_hash = ? AND share_type = ?').get(hash, 'preset');
|
||||
|
||||
if (existing) {
|
||||
return res.status(409).json({
|
||||
error: '동일한 구성의 프리셋이 이미 존재합니다.',
|
||||
shareCode: existing.share_code,
|
||||
});
|
||||
}
|
||||
|
||||
const cardsJson = JSON.stringify(normalized);
|
||||
db.prepare(`
|
||||
INSERT INTO decks (share_code, share_type, content_hash, user_id, series, series_name, name, cards_json, is_public)
|
||||
VALUES (?, 'preset', ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
hash,
|
||||
hash,
|
||||
req.user.id,
|
||||
series,
|
||||
seriesName || series,
|
||||
name,
|
||||
cardsJson,
|
||||
isPublic === false ? 0 : 1,
|
||||
);
|
||||
|
||||
const row = db.prepare('SELECT * FROM decks WHERE share_code = ?').get(hash);
|
||||
const { cards: resolved, totalCards } = await resolveDeckCards(series, normalized);
|
||||
|
||||
res.status(201).json({
|
||||
v: 1,
|
||||
shareCode: row.share_code,
|
||||
shareType: 'preset',
|
||||
series: row.series,
|
||||
seriesName: row.series_name,
|
||||
name: row.name,
|
||||
totalCards,
|
||||
cards: resolved,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.message?.includes('카드') || err.message?.includes('덱')) {
|
||||
return res.status(400).json({ error: err.message });
|
||||
}
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/decks', (req, res) => {
|
||||
const rows = db.prepare(`
|
||||
SELECT d.id, d.share_code AS shareCode, d.share_type AS shareType, d.series, d.series_name AS seriesName,
|
||||
d.name, d.is_public AS isPublic, d.view_count AS viewCount, d.created_at AS createdAt,
|
||||
d.preview_image AS previewImage, d.updated_at AS updatedAt,
|
||||
u.username, u.id AS userId
|
||||
FROM decks d LEFT JOIN users u ON d.user_id = u.id
|
||||
ORDER BY d.created_at DESC LIMIT 100
|
||||
`).all();
|
||||
|
||||
res.json({
|
||||
decks: rows.map((row) => ({
|
||||
...row,
|
||||
preview_url: getPreviewUrl(row.shareCode, row.previewImage),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
router.delete('/decks/:shareCode', (req, res) => {
|
||||
const row = db.prepare('SELECT id FROM decks WHERE share_code = ?').get(req.params.shareCode);
|
||||
if (!row) {
|
||||
return res.status(404).json({ error: '덱을 찾을 수 없습니다.' });
|
||||
}
|
||||
db.prepare('DELETE FROM decks WHERE id = ?').run(row.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
212
api/src/routes/auth.js
Normal file
212
api/src/routes/auth.js
Normal file
@@ -0,0 +1,212 @@
|
||||
import { Router } from 'express';
|
||||
import path from 'path';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { signToken, requireAuth } from '../middleware/auth.js';
|
||||
import { nowIso } from '../utils.js';
|
||||
import { getAvatarFilePath, getAvatarUrl, saveAvatarImage, deleteAvatarImage } from '../services/avatarImage.js';
|
||||
|
||||
/**
|
||||
* DB 사용자 행을 API 응답 형식으로 변환
|
||||
* @param {object} user
|
||||
* @returns {object}
|
||||
*/
|
||||
function toUserResponse(user) {
|
||||
if (!user) return null;
|
||||
return {
|
||||
...user,
|
||||
displayName: user.username,
|
||||
avatarUrl: getAvatarUrl(user.id) || user.avatarUrl || null,
|
||||
defaultDeckPublic: (user.default_deck_public ?? user.defaultDeckPublic ?? 1) !== 0,
|
||||
skipDeckPublicPrompt: Boolean(user.skip_deck_public_prompt ?? user.skipDeckPublicPrompt),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증 라우트
|
||||
* @param {import('better-sqlite3').Database} db
|
||||
* @returns {import('express').Router}
|
||||
*/
|
||||
export function createAuthRouter(db) {
|
||||
const router = Router();
|
||||
|
||||
router.get('/avatar/:userId', (req, res) => {
|
||||
const filePath = getAvatarFilePath(req.params.userId);
|
||||
if (!filePath) {
|
||||
return res.status(404).json({ error: '아바타가 없습니다.' });
|
||||
}
|
||||
res.setHeader('Content-Type', 'image/png');
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400');
|
||||
res.sendFile(path.resolve(filePath), (err) => {
|
||||
if (err && !res.headersSent) {
|
||||
res.status(404).json({ error: '아바타가 없습니다.' });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/register', (req, res) => {
|
||||
const { email, username, password } = req.body || {};
|
||||
|
||||
if (!email || !username || !password) {
|
||||
return res.status(400).json({ error: '이메일, 사용자명, 비밀번호는 필수입니다.' });
|
||||
}
|
||||
if (password.length < 8) {
|
||||
return res.status(400).json({ error: '비밀번호는 8자 이상이어야 합니다.' });
|
||||
}
|
||||
|
||||
const hash = bcrypt.hashSync(password, 12);
|
||||
|
||||
try {
|
||||
const trimmedUsername = username.trim();
|
||||
const result = db.prepare(`
|
||||
INSERT INTO users (email, username, password_hash, display_name)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(email.trim().toLowerCase(), trimmedUsername, hash, trimmedUsername);
|
||||
|
||||
const user = db.prepare(`
|
||||
SELECT id, email, username, role, created_at AS createdAt, updated_at AS updatedAt
|
||||
FROM users WHERE id = ?
|
||||
`).get(result.lastInsertRowid);
|
||||
|
||||
const safeUser = toUserResponse(user);
|
||||
const token = signToken(safeUser);
|
||||
res.status(201).json({ token, user: safeUser });
|
||||
} catch (err) {
|
||||
if (String(err.message).includes('UNIQUE')) {
|
||||
return res.status(409).json({ error: '이미 사용 중인 이메일 또는 사용자명입니다.' });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/login', (req, res) => {
|
||||
const { email, password } = req.body || {};
|
||||
if (!email || !password) {
|
||||
return res.status(400).json({ error: '이메일과 비밀번호를 입력하세요.' });
|
||||
}
|
||||
|
||||
const user = db.prepare(`
|
||||
SELECT id, email, username, password_hash, display_name, avatar_url, role, is_active
|
||||
FROM users WHERE email = ?
|
||||
`).get(email.trim().toLowerCase());
|
||||
|
||||
if (!user || !user.is_active) {
|
||||
return res.status(401).json({ error: '이메일 또는 비밀번호가 올바르지 않습니다.' });
|
||||
}
|
||||
|
||||
if (!bcrypt.compareSync(password, user.password_hash)) {
|
||||
return res.status(401).json({ error: '이메일 또는 비밀번호가 올바르지 않습니다.' });
|
||||
}
|
||||
|
||||
const row = db.prepare(`
|
||||
SELECT id, email, username, avatar_url, role, updated_at AS updatedAt
|
||||
FROM users WHERE id = ?
|
||||
`).get(user.id);
|
||||
|
||||
const safeUser = toUserResponse(row);
|
||||
res.json({ token: signToken(safeUser), user: safeUser });
|
||||
});
|
||||
|
||||
router.get('/me', requireAuth, (req, res) => {
|
||||
const user = db.prepare(`
|
||||
SELECT id, email, username, avatar_url AS avatarUrl, role,
|
||||
default_deck_public AS defaultDeckPublic,
|
||||
skip_deck_public_prompt AS skipDeckPublicPrompt,
|
||||
created_at AS createdAt, updated_at AS updatedAt
|
||||
FROM users WHERE id = ? AND is_active = 1
|
||||
`).get(req.user.id);
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: '사용자를 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
res.json({ user: toUserResponse(user) });
|
||||
});
|
||||
|
||||
router.put('/me', requireAuth, (req, res) => {
|
||||
const { email, username, avatarImage, removeAvatar, defaultDeckPublic, skipDeckPublicPrompt } =
|
||||
req.body || {};
|
||||
const fields = ['updated_at = ?'];
|
||||
const values = [nowIso()];
|
||||
|
||||
const trimmedEmail = email?.trim().toLowerCase();
|
||||
const trimmedUsername = username?.trim();
|
||||
|
||||
if (trimmedEmail) {
|
||||
fields.push('email = ?');
|
||||
values.push(trimmedEmail);
|
||||
}
|
||||
if (trimmedUsername) {
|
||||
fields.push('username = ?', 'display_name = ?');
|
||||
values.push(trimmedUsername, trimmedUsername);
|
||||
}
|
||||
|
||||
if (avatarImage) {
|
||||
const saved = saveAvatarImage(req.user.id, avatarImage);
|
||||
if (!saved) {
|
||||
return res.status(400).json({ error: '프로필 이미지 저장에 실패했습니다.' });
|
||||
}
|
||||
const avatarUrl = getAvatarUrl(req.user.id);
|
||||
fields.push('avatar_url = ?');
|
||||
values.push(avatarUrl);
|
||||
} else if (removeAvatar === true) {
|
||||
deleteAvatarImage(req.user.id);
|
||||
fields.push('avatar_url = ?');
|
||||
values.push(null);
|
||||
}
|
||||
|
||||
if (typeof defaultDeckPublic === 'boolean') {
|
||||
fields.push('default_deck_public = ?');
|
||||
values.push(defaultDeckPublic ? 1 : 0);
|
||||
}
|
||||
if (typeof skipDeckPublicPrompt === 'boolean') {
|
||||
fields.push('skip_deck_public_prompt = ?');
|
||||
values.push(skipDeckPublicPrompt ? 1 : 0);
|
||||
}
|
||||
|
||||
try {
|
||||
values.push(req.user.id);
|
||||
db.prepare(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`).run(...values);
|
||||
} catch (err) {
|
||||
if (String(err.message).includes('UNIQUE')) {
|
||||
return res.status(409).json({ error: '이미 사용 중인 이메일 또는 사용자명입니다.' });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const user = db.prepare(`
|
||||
SELECT id, email, username, avatar_url AS avatarUrl, role,
|
||||
default_deck_public AS defaultDeckPublic,
|
||||
skip_deck_public_prompt AS skipDeckPublicPrompt,
|
||||
created_at AS createdAt, updated_at AS updatedAt
|
||||
FROM users WHERE id = ?
|
||||
`).get(req.user.id);
|
||||
|
||||
res.json({ user: toUserResponse(user) });
|
||||
});
|
||||
|
||||
router.post('/me/password', requireAuth, (req, res) => {
|
||||
const { currentPassword, newPassword } = req.body || {};
|
||||
if (!currentPassword || !newPassword) {
|
||||
return res.status(400).json({ error: '현재 비밀번호와 새 비밀번호를 입력하세요.' });
|
||||
}
|
||||
if (newPassword.length < 8) {
|
||||
return res.status(400).json({ error: '새 비밀번호는 8자 이상이어야 합니다.' });
|
||||
}
|
||||
|
||||
const row = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(req.user.id);
|
||||
if (!bcrypt.compareSync(currentPassword, row.password_hash)) {
|
||||
return res.status(401).json({ error: '현재 비밀번호가 올바르지 않습니다.' });
|
||||
}
|
||||
|
||||
const hash = bcrypt.hashSync(newPassword, 12);
|
||||
db.prepare('UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?').run(
|
||||
hash,
|
||||
nowIso(),
|
||||
req.user.id,
|
||||
);
|
||||
|
||||
res.json({ ok: true, message: '비밀번호가 변경되었습니다.' });
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
290
api/src/routes/decks.js
Normal file
290
api/src/routes/decks.js
Normal file
@@ -0,0 +1,290 @@
|
||||
import { Router } from 'express';
|
||||
import path from 'path';
|
||||
import { requireAuth, optionalAuth } from '../middleware/auth.js';
|
||||
import { resolveDeckCards } from '../services/cardResolver.js';
|
||||
import { generateShareCode, validateCards, nowIso } from '../utils.js';
|
||||
import { savePreviewImage, getPreviewFilePath, getPreviewUrl } from '../services/previewImage.js';
|
||||
|
||||
/**
|
||||
* 덱 행을 API 응답 형식으로 변환
|
||||
* @param {object} row
|
||||
* @param {object|null} author
|
||||
* @param {object[]} resolvedCards
|
||||
* @param {number} totalCards
|
||||
* @returns {object}
|
||||
*/
|
||||
function toDeckResponse(row, author, resolvedCards, totalCards) {
|
||||
return {
|
||||
v: 1,
|
||||
shareCode: row.share_code,
|
||||
shareType: row.share_type,
|
||||
series: row.series,
|
||||
seriesName: row.series_name,
|
||||
name: row.name,
|
||||
totalCards,
|
||||
cards: resolvedCards,
|
||||
author,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
viewCount: row.view_count,
|
||||
isPublic: Boolean(row.is_public),
|
||||
expiresAt: row.expires_at,
|
||||
previewUrl: getPreviewUrl(row.share_code, row.preview_image),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 작성자 정보 조회
|
||||
* @param {import('better-sqlite3').Database} db
|
||||
* @param {number|null} userId
|
||||
* @returns {object|null}
|
||||
*/
|
||||
function getAuthor(db, userId) {
|
||||
if (!userId) return null;
|
||||
const user = db.prepare(`
|
||||
SELECT id, username, avatar_url AS avatarUrl
|
||||
FROM users WHERE id = ? AND is_active = 1
|
||||
`).get(userId);
|
||||
if (!user) return null;
|
||||
return { ...user, displayName: user.username };
|
||||
}
|
||||
|
||||
/**
|
||||
* 덱 라우트
|
||||
* @param {import('better-sqlite3').Database} db
|
||||
* @returns {import('express').Router}
|
||||
*/
|
||||
export function createDecksRouter(db) {
|
||||
const router = Router();
|
||||
|
||||
router.get('/presets', async (_req, res, next) => {
|
||||
try {
|
||||
const rows = db.prepare(`
|
||||
SELECT d.share_code, d.series, d.series_name, d.name, d.view_count,
|
||||
d.preview_image, d.created_at, d.updated_at,
|
||||
u.display_name, u.username
|
||||
FROM decks d
|
||||
LEFT JOIN users u ON d.user_id = u.id
|
||||
WHERE d.share_type = 'user' AND d.is_public = 1
|
||||
AND (d.expires_at IS NULL OR d.expires_at > datetime('now'))
|
||||
ORDER BY d.updated_at DESC
|
||||
`).all();
|
||||
|
||||
res.json({
|
||||
presets: rows.map((row) => ({
|
||||
share_code: row.share_code,
|
||||
series: row.series,
|
||||
series_name: row.series_name,
|
||||
name: row.name,
|
||||
view_count: row.view_count,
|
||||
author: row.username || '익명',
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
preview_url: getPreviewUrl(row.share_code, row.preview_image),
|
||||
})),
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:shareCode/preview', (req, res) => {
|
||||
const filePath = getPreviewFilePath(req.params.shareCode);
|
||||
if (!filePath) {
|
||||
return res.status(404).json({ error: '미리보기 이미지가 없습니다.' });
|
||||
}
|
||||
res.setHeader('Content-Type', 'image/png');
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400');
|
||||
res.sendFile(path.resolve(filePath), (err) => {
|
||||
if (err && !res.headersSent) {
|
||||
res.status(404).json({ error: '미리보기 이미지가 없습니다.' });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/:shareCode', optionalAuth, async (req, res, next) => {
|
||||
try {
|
||||
const row = db.prepare('SELECT * FROM decks WHERE share_code = ?').get(req.params.shareCode);
|
||||
if (!row) {
|
||||
return res.status(404).json({ error: '덱을 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
if (row.expires_at && new Date(row.expires_at) < new Date()) {
|
||||
return res.status(410).json({ error: '만료된 공유 링크입니다.' });
|
||||
}
|
||||
|
||||
const isOwner = req.user && row.user_id === req.user.id;
|
||||
const isAdmin = req.user?.role === 'admin';
|
||||
|
||||
if (!row.is_public && !isOwner && !isAdmin) {
|
||||
return res.status(403).json({ error: '비공개 덱입니다.' });
|
||||
}
|
||||
|
||||
const cards = JSON.parse(row.cards_json);
|
||||
const { cards: resolved, totalCards } = await resolveDeckCards(row.series, cards);
|
||||
const author = getAuthor(db, row.user_id);
|
||||
|
||||
db.prepare('UPDATE decks SET view_count = view_count + 1 WHERE id = ?').run(row.id);
|
||||
row.view_count += 1;
|
||||
|
||||
res.json(toDeckResponse(row, author, resolved, totalCards));
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const rows = db.prepare(`
|
||||
SELECT share_code, share_type, series, series_name, name, is_public, view_count,
|
||||
preview_image, created_at, updated_at
|
||||
FROM decks WHERE user_id = ? AND share_type = 'user'
|
||||
ORDER BY updated_at DESC
|
||||
`).all(req.user.id);
|
||||
|
||||
res.json({
|
||||
decks: rows.map((row) => ({
|
||||
share_code: row.share_code,
|
||||
share_type: row.share_type,
|
||||
series: row.series,
|
||||
series_name: row.series_name,
|
||||
name: row.name,
|
||||
is_public: row.is_public,
|
||||
view_count: row.view_count,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
preview_url: getPreviewUrl(row.share_code, row.preview_image),
|
||||
})),
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/', optionalAuth, async (req, res, next) => {
|
||||
try {
|
||||
const { series, seriesName, name, cards, isPublic, previewImage } = req.body || {};
|
||||
if (!series || !cards) {
|
||||
return res.status(400).json({ error: 'series와 cards는 필수입니다.' });
|
||||
}
|
||||
|
||||
const normalized = validateCards(cards);
|
||||
const cardsJson = JSON.stringify(normalized);
|
||||
const shareCode = generateShareCode();
|
||||
const userId = req.user?.id ?? null;
|
||||
const expiresAt = userId ? null : new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString();
|
||||
const isPublicValue = userId ? (isPublic === true ? 1 : 0) : 1;
|
||||
let previewFile = null;
|
||||
if (previewImage) {
|
||||
previewFile = savePreviewImage(shareCode, previewImage);
|
||||
if (!previewFile) {
|
||||
console.warn(`[preview] 저장 실패: ${shareCode} (dataUrl 길이 ${previewImage.length})`);
|
||||
}
|
||||
}
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO decks (share_code, share_type, user_id, series, series_name, name, cards_json, is_public, expires_at, preview_image)
|
||||
VALUES (?, 'user', ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
shareCode,
|
||||
userId,
|
||||
series,
|
||||
seriesName || series,
|
||||
name || '',
|
||||
cardsJson,
|
||||
isPublicValue,
|
||||
expiresAt,
|
||||
previewFile,
|
||||
);
|
||||
|
||||
const row = db.prepare('SELECT * FROM decks WHERE share_code = ?').get(shareCode);
|
||||
const { cards: resolved, totalCards } = await resolveDeckCards(series, normalized);
|
||||
const author = getAuthor(db, userId);
|
||||
|
||||
res.status(201).json(toDeckResponse(row, author, resolved, totalCards));
|
||||
} catch (err) {
|
||||
if (err.message?.includes('카드') || err.message?.includes('덱')) {
|
||||
return res.status(400).json({ error: err.message });
|
||||
}
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/:shareCode', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const row = db.prepare('SELECT * FROM decks WHERE share_code = ?').get(req.params.shareCode);
|
||||
if (!row) {
|
||||
return res.status(404).json({ error: '덱을 찾을 수 없습니다.' });
|
||||
}
|
||||
if (row.user_id !== req.user.id && req.user.role !== 'admin') {
|
||||
return res.status(403).json({ error: '수정 권한이 없습니다.' });
|
||||
}
|
||||
if (row.share_type === 'preset' && req.user.role !== 'admin') {
|
||||
return res.status(403).json({ error: '레거시 프리셋 덱은 관리자만 수정할 수 있습니다.' });
|
||||
}
|
||||
|
||||
const { name, cards, isPublic, seriesName, previewImage } = req.body || {};
|
||||
let cardsJson = row.cards_json;
|
||||
let series = row.series;
|
||||
let previewFile = row.preview_image;
|
||||
|
||||
if (cards) {
|
||||
const normalized = validateCards(cards);
|
||||
cardsJson = JSON.stringify(normalized);
|
||||
}
|
||||
if (req.body?.series) {
|
||||
series = req.body.series;
|
||||
}
|
||||
if (previewImage) {
|
||||
previewFile = savePreviewImage(row.share_code, previewImage);
|
||||
}
|
||||
|
||||
db.prepare(`
|
||||
UPDATE decks SET
|
||||
name = COALESCE(?, name),
|
||||
series_name = COALESCE(?, series_name),
|
||||
series = ?,
|
||||
cards_json = ?,
|
||||
is_public = COALESCE(?, is_public),
|
||||
preview_image = COALESCE(?, preview_image),
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
name ?? null,
|
||||
seriesName ?? null,
|
||||
series,
|
||||
cardsJson,
|
||||
isPublic === undefined ? null : (isPublic ? 1 : 0),
|
||||
previewFile,
|
||||
nowIso(),
|
||||
row.id,
|
||||
);
|
||||
|
||||
const updated = db.prepare('SELECT * FROM decks WHERE id = ?').get(row.id);
|
||||
const parsed = JSON.parse(updated.cards_json);
|
||||
const { cards: resolved, totalCards } = await resolveDeckCards(updated.series, parsed);
|
||||
|
||||
res.json(toDeckResponse(updated, getAuthor(db, updated.user_id), resolved, totalCards));
|
||||
} catch (err) {
|
||||
if (err.message?.includes('카드') || err.message?.includes('덱')) {
|
||||
return res.status(400).json({ error: err.message });
|
||||
}
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:shareCode', requireAuth, (req, res) => {
|
||||
const row = db.prepare('SELECT * FROM decks WHERE share_code = ?').get(req.params.shareCode);
|
||||
if (!row) {
|
||||
return res.status(404).json({ error: '덱을 찾을 수 없습니다.' });
|
||||
}
|
||||
if (row.user_id !== req.user.id && req.user.role !== 'admin') {
|
||||
return res.status(403).json({ error: '삭제 권한이 없습니다.' });
|
||||
}
|
||||
|
||||
db.prepare('DELETE FROM decks WHERE id = ?').run(row.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
65
api/src/services/avatarImage.js
Normal file
65
api/src/services/avatarImage.js
Normal file
@@ -0,0 +1,65 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/**
|
||||
* 아바타 이미지 저장 디렉터리
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getAvatarsDir() {
|
||||
const dir = process.env.AVATARS_PATH || path.join(__dirname, '..', '..', 'data', 'avatars');
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number|string} userId
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getAvatarFilePath(userId) {
|
||||
const filePath = path.join(getAvatarsDir(), `${userId}.png`);
|
||||
return fs.existsSync(filePath) ? filePath : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number|string} userId
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function getAvatarUrl(userId) {
|
||||
return getAvatarFilePath(userId) ? `/api/v1/auth/avatar/${userId}` : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* base64 data URL을 PNG로 저장
|
||||
* @param {number|string} userId
|
||||
* @param {string} dataUrl
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function saveAvatarImage(userId, dataUrl) {
|
||||
if (!dataUrl || !dataUrl.startsWith('data:image')) {
|
||||
return null;
|
||||
}
|
||||
const base64 = dataUrl.replace(/^data:image\/\w+;base64,/, '');
|
||||
if (!base64) {
|
||||
return null;
|
||||
}
|
||||
const fileName = `${userId}.png`;
|
||||
const filePath = path.join(getAvatarsDir(), fileName);
|
||||
fs.writeFileSync(filePath, Buffer.from(base64, 'base64'));
|
||||
return fs.existsSync(filePath) ? fileName : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 아바타 이미지 삭제
|
||||
* @param {number|string} userId
|
||||
*/
|
||||
export function deleteAvatarImage(userId) {
|
||||
const filePath = path.join(getAvatarsDir(), `${userId}.png`);
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
}
|
||||
65
api/src/services/cardResolver.js
Normal file
65
api/src/services/cardResolver.js
Normal file
@@ -0,0 +1,65 @@
|
||||
import { pathToFileURL } from 'url';
|
||||
import path from 'path';
|
||||
|
||||
/** @type {Map<string, Map<string, object>>} */
|
||||
const cache = new Map();
|
||||
|
||||
/**
|
||||
* 시리즈 카드 맵 로드
|
||||
* @param {string} seriesKey
|
||||
* @returns {Promise<Map<string, object>>}
|
||||
*/
|
||||
export async function getCardMap(seriesKey) {
|
||||
if (cache.has(seriesKey)) {
|
||||
return cache.get(seriesKey);
|
||||
}
|
||||
|
||||
const datasPath = process.env.DATAS_PATH || path.join(process.cwd(), '..', 'datas');
|
||||
const filePath = path.join(datasPath, `${seriesKey}.js`);
|
||||
|
||||
let mod;
|
||||
try {
|
||||
mod = await import(pathToFileURL(filePath).href);
|
||||
} catch {
|
||||
throw new Error(`시리즈 데이터를 찾을 수 없습니다: ${seriesKey}`);
|
||||
}
|
||||
|
||||
const byNumber = new Map();
|
||||
for (const card of mod.cardList) {
|
||||
byNumber.set(card.Number, card);
|
||||
}
|
||||
|
||||
cache.set(seriesKey, byNumber);
|
||||
return byNumber;
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장된 덱 카드를 풀 카드 객체로 합성
|
||||
* @param {string} seriesKey
|
||||
* @param {Array<{number: string, count: number, key: boolean}>} cards
|
||||
* @returns {Promise<{cards: object[], totalCards: number}>}
|
||||
*/
|
||||
export async function resolveDeckCards(seriesKey, cards) {
|
||||
const map = await getCardMap(seriesKey);
|
||||
const resolved = [];
|
||||
let totalCards = 0;
|
||||
|
||||
for (const item of cards) {
|
||||
const base = map.get(item.number);
|
||||
if (!base) continue;
|
||||
|
||||
totalCards += item.count;
|
||||
resolved.push({
|
||||
number: base.Number,
|
||||
count: item.count,
|
||||
key: item.key,
|
||||
imgSrc: base.imgSrc,
|
||||
color: base.color,
|
||||
rarity: base.rarity,
|
||||
trigger: base.trigger ?? null,
|
||||
parallel: Boolean(base.parallel),
|
||||
});
|
||||
}
|
||||
|
||||
return { cards: resolved, totalCards };
|
||||
}
|
||||
55
api/src/services/previewImage.js
Normal file
55
api/src/services/previewImage.js
Normal file
@@ -0,0 +1,55 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/**
|
||||
* 미리보기 이미지 저장 디렉터리
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getPreviewsDir() {
|
||||
const dir = process.env.PREVIEWS_PATH || path.join(__dirname, '..', '..', 'data', 'previews');
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
/**
|
||||
* base64 data URL을 PNG 파일로 저장
|
||||
* @param {string} shareCode
|
||||
* @param {string} dataUrl
|
||||
* @returns {string} 파일명
|
||||
*/
|
||||
export function savePreviewImage(shareCode, dataUrl) {
|
||||
if (!dataUrl || !dataUrl.startsWith('data:image')) {
|
||||
return null;
|
||||
}
|
||||
const base64 = dataUrl.replace(/^data:image\/\w+;base64,/, '');
|
||||
const fileName = `${shareCode}.png`;
|
||||
fs.writeFileSync(path.join(getPreviewsDir(), fileName), Buffer.from(base64, 'base64'));
|
||||
return fileName;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} shareCode
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function getPreviewFilePath(shareCode) {
|
||||
const filePath = path.join(getPreviewsDir(), `${shareCode}.png`);
|
||||
return fs.existsSync(filePath) ? filePath : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} shareCode
|
||||
* @param {string|null} [previewImageRecord] DB preview_image 컬럼
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function getPreviewUrl(shareCode, previewImageRecord = null) {
|
||||
const hasFile = Boolean(getPreviewFilePath(shareCode));
|
||||
if (hasFile || previewImageRecord) {
|
||||
return `/api/v1/decks/${shareCode}/preview`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
85
api/src/utils.js
Normal file
85
api/src/utils.js
Normal file
@@ -0,0 +1,85 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
const SHARE_ALPHABET = '0123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
||||
|
||||
/**
|
||||
* URL-safe 공유 코드 생성
|
||||
* @param {number} [length=8]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function generateShareCode(length = 8) {
|
||||
const bytes = crypto.randomBytes(length);
|
||||
let code = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
code += SHARE_ALPHABET[bytes[i] % SHARE_ALPHABET.length];
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
/**
|
||||
* 덱 본문 정규화 (해시용)
|
||||
* @param {Array<{number: string, count: number, key: boolean}>} cards
|
||||
* @returns {string}
|
||||
*/
|
||||
export function normalizeCards(cards) {
|
||||
const sorted = [...cards]
|
||||
.map((c) => ({
|
||||
number: String(c.number).trim(),
|
||||
count: Number(c.count),
|
||||
key: Boolean(c.key),
|
||||
}))
|
||||
.sort((a, b) => a.number.localeCompare(b.number));
|
||||
return JSON.stringify(sorted);
|
||||
}
|
||||
|
||||
/**
|
||||
* 프리셋 덱용 내용 해시 (8자)
|
||||
* @param {Array<{number: string, count: number, key: boolean}>} cards
|
||||
* @returns {string}
|
||||
*/
|
||||
export function contentHash(cards) {
|
||||
const hash = crypto.createHash('sha256').update(normalizeCards(cards)).digest('hex');
|
||||
return hash.slice(0, 8);
|
||||
}
|
||||
|
||||
/**
|
||||
* 덱 카드 배열 검증
|
||||
* @param {unknown} cards
|
||||
* @returns {Array<{number: string, count: number, key: boolean}>}
|
||||
*/
|
||||
export function validateCards(cards) {
|
||||
if (!Array.isArray(cards) || cards.length === 0) {
|
||||
throw new Error('카드 목록이 비어 있습니다.');
|
||||
}
|
||||
|
||||
let total = 0;
|
||||
const normalized = cards.map((c) => {
|
||||
if (!c.number || c.count == null) {
|
||||
throw new Error('카드 형식이 올바르지 않습니다.');
|
||||
}
|
||||
const count = Number(c.count);
|
||||
if (!Number.isInteger(count) || count < 1 || count > 4) {
|
||||
throw new Error(`잘못된 수량: ${c.number}`);
|
||||
}
|
||||
total += count;
|
||||
return {
|
||||
number: String(c.number).trim(),
|
||||
count,
|
||||
key: Boolean(c.key),
|
||||
};
|
||||
});
|
||||
|
||||
if (total > 50) {
|
||||
throw new Error('덱은 최대 50장까지 가능합니다.');
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* ISO 현재 시각
|
||||
* @returns {string}
|
||||
*/
|
||||
export function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
Reference in New Issue
Block a user