diff --git a/backend/routes/admin.js b/backend/routes/admin.js index 859efc2..52de130 100644 --- a/backend/routes/admin.js +++ b/backend/routes/admin.js @@ -5,6 +5,7 @@ import { convertAndUpload, deleteFromS3 } from '../services/image.js'; import { getPublicUrl } from '../lib/s3.js'; import { sequelize } from '../lib/db.js'; import bossCrystalRouter from './admin/boss-crystal.js'; +import symbolRouter from './admin/symbol.js'; const router = Router(); const upload = multer({ @@ -35,6 +36,7 @@ router.use(requireAdmin); // 기능별 sub-router router.use('/boss-crystal', bossCrystalRouter); +router.use('/symbol', symbolRouter); /* ── 이미지 관리 ── */ diff --git a/backend/routes/admin/symbol.js b/backend/routes/admin/symbol.js new file mode 100644 index 0000000..8d79217 --- /dev/null +++ b/backend/routes/admin/symbol.js @@ -0,0 +1,237 @@ +import { Router } from 'express'; +import multer from 'multer'; +import { Symbol, SymbolLevel } from '../../models/index.js'; +import { convertAndUploadTo, deleteFromS3 } from '../../services/image.js'; +import { getPublicUrl } from '../../lib/s3.js'; +import { sequelize } from '../../lib/db.js'; + +const router = Router(); +const upload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 10 * 1024 * 1024 }, +}); + +const VALID_TYPES = ['아케인', '어센틱', '그랜드 어센틱']; + +function imagePath(type, region) { + return `symbol/${type}심볼(${region}).webp`; +} + +function serialize(symbol) { + const json = symbol.toJSON(); + return { + id: json.id, + type: json.type, + region: json.region, + image: json.image, + image_url: json.image ? getPublicUrl(json.image) : null, + max_level: json.max_level, + daily_default: json.daily_default, + weekly_default: json.weekly_default, + sort_order: json.sort_order, + levels: (json.levels || []) + .sort((a, b) => a.level - b.level) + .map((l) => ({ + level: l.level, + required_count: l.required_count, + meso_cost: Number(l.meso_cost), + })), + }; +} + +function parseLevels(raw, maxLevel) { + if (!raw) return []; + let arr; + try { + arr = typeof raw === 'string' ? JSON.parse(raw) : raw; + } catch { + throw new Error('레벨 정보 형식이 잘못되었습니다'); + } + if (!Array.isArray(arr)) throw new Error('레벨 정보는 배열이어야 합니다'); + return arr.map((l) => { + const level = Number(l.level); + const required_count = Number(l.required_count); + const meso_cost = Number(l.meso_cost); + if (!level || level < 1 || level >= maxLevel) { + throw new Error(`잘못된 레벨: ${l.level}`); + } + if (isNaN(required_count) || required_count < 0) { + throw new Error(`잘못된 필요 개수: Lv.${level}`); + } + if (isNaN(meso_cost) || meso_cost < 0) { + throw new Error(`잘못된 메소: Lv.${level}`); + } + return { level, required_count, meso_cost }; + }); +} + +function validateBasic({ type, region, max_level }) { + if (!VALID_TYPES.includes(type)) throw new Error('잘못된 심볼 종류입니다'); + const r = String(region || '').trim(); + if (!r) throw new Error('지역 이름을 입력해주세요'); + const ml = Number(max_level); + if (!ml || ml < 2 || ml > 99) throw new Error('만렙은 2~99 사이여야 합니다'); + return { type, region: r, max_level: ml }; +} + +// 목록 +router.get('/symbols', async (_req, res) => { + try { + const rows = await Symbol.findAll({ + order: [['sort_order', 'ASC'], ['id', 'ASC']], + include: [{ model: SymbolLevel, as: 'levels' }], + }); + res.json(rows.map(serialize)); + } catch (err) { + console.error('심볼 목록 조회 오류:', err.message); + res.status(500).json({ error: '심볼 목록 조회 실패' }); + } +}); + +// 단건 +router.get('/symbols/:id', async (req, res) => { + try { + const row = await Symbol.findByPk(req.params.id, { + include: [{ model: SymbolLevel, as: 'levels' }], + }); + if (!row) return res.status(404).json({ error: '심볼을 찾을 수 없습니다' }); + res.json(serialize(row)); + } catch (err) { + console.error('심볼 조회 오류:', err.message); + res.status(500).json({ error: '심볼 조회 실패' }); + } +}); + +// 생성 +router.post('/symbols', upload.single('image'), async (req, res) => { + const t = await sequelize.transaction(); + try { + const basic = validateBasic(req.body); + const levels = parseLevels(req.body.levels, basic.max_level); + const daily_default = Number(req.body.daily_default) || 0; + const weekly_default = Number(req.body.weekly_default) || 0; + + if (!req.file) throw new Error('심볼 이미지를 업로드해주세요'); + const key = imagePath(basic.type, basic.region); + await convertAndUploadTo(req.file.buffer, key); + + const maxOrder = (await Symbol.max('sort_order')) || 0; + const created = await Symbol.create({ + type: basic.type, + region: basic.region, + image: key, + max_level: basic.max_level, + daily_default, + weekly_default, + sort_order: maxOrder + 1, + }, { transaction: t }); + + if (levels.length) { + await SymbolLevel.bulkCreate( + levels.map((l) => ({ symbol_id: created.id, ...l })), + { transaction: t } + ); + } + + await t.commit(); + const full = await Symbol.findByPk(created.id, { include: [{ model: SymbolLevel, as: 'levels' }] }); + res.status(201).json(serialize(full)); + } catch (err) { + await t.rollback(); + console.error('심볼 생성 오류:', err.message); + if (err.name === 'SequelizeUniqueConstraintError') { + return res.status(409).json({ error: '이미 등록된 종류+지역 조합입니다' }); + } + res.status(400).json({ error: err.message || '심볼 생성 실패' }); + } +}); + +// 수정 +router.patch('/symbols/:id', upload.single('image'), async (req, res) => { + const t = await sequelize.transaction(); + try { + const row = await Symbol.findByPk(req.params.id); + if (!row) { await t.rollback(); return res.status(404).json({ error: '심볼을 찾을 수 없습니다' }); } + + const basic = validateBasic({ + type: req.body.type ?? row.type, + region: req.body.region ?? row.region, + max_level: req.body.max_level ?? row.max_level, + }); + + let imageKey = row.image; + if (req.file) { + imageKey = imagePath(basic.type, basic.region); + await convertAndUploadTo(req.file.buffer, imageKey); + if (row.image && row.image !== imageKey) { + try { await deleteFromS3(row.image); } catch { /* ignore */ } + } + } else if (basic.type !== row.type || basic.region !== row.region) { + // 이름/종류 변경 시 새 경로로 rename 대체 불가 → 기존 키 유지 + imageKey = row.image; + } + + await row.update({ + type: basic.type, + region: basic.region, + max_level: basic.max_level, + image: imageKey, + daily_default: Number(req.body.daily_default) || 0, + weekly_default: Number(req.body.weekly_default) || 0, + }, { transaction: t }); + + if (req.body.levels !== undefined) { + const levels = parseLevels(req.body.levels, basic.max_level); + await SymbolLevel.destroy({ where: { symbol_id: row.id }, transaction: t }); + if (levels.length) { + await SymbolLevel.bulkCreate( + levels.map((l) => ({ symbol_id: row.id, ...l })), + { transaction: t } + ); + } + } + + await t.commit(); + const full = await Symbol.findByPk(row.id, { include: [{ model: SymbolLevel, as: 'levels' }] }); + res.json(serialize(full)); + } catch (err) { + await t.rollback(); + console.error('심볼 수정 오류:', err.message); + res.status(400).json({ error: err.message || '심볼 수정 실패' }); + } +}); + +// 삭제 +router.delete('/symbols/:id', async (req, res) => { + try { + const row = await Symbol.findByPk(req.params.id); + if (!row) return res.status(404).json({ error: '심볼을 찾을 수 없습니다' }); + const key = row.image; + await row.destroy(); + if (key) { try { await deleteFromS3(key); } catch { /* ignore */ } } + res.json({ success: true }); + } catch (err) { + console.error('심볼 삭제 오류:', err.message); + res.status(500).json({ error: '심볼 삭제 실패' }); + } +}); + +// 순서 변경 +router.post('/symbols/reorder', async (req, res) => { + const { ids } = req.body; + if (!Array.isArray(ids)) return res.status(400).json({ error: 'ids 배열이 필요합니다' }); + const t = await sequelize.transaction(); + try { + for (let i = 0; i < ids.length; i++) { + await Symbol.update({ sort_order: i + 1 }, { where: { id: ids[i] }, transaction: t }); + } + await t.commit(); + res.json({ success: true }); + } catch (err) { + await t.rollback(); + console.error('심볼 순서 변경 오류:', err.message); + res.status(500).json({ error: '순서 변경 실패' }); + } +}); + +export default router; diff --git a/backend/routes/symbol.js b/backend/routes/symbol.js new file mode 100644 index 0000000..d31315c --- /dev/null +++ b/backend/routes/symbol.js @@ -0,0 +1,39 @@ +import { Router } from 'express'; +import { Symbol, SymbolLevel } from '../models/index.js'; +import { getPublicUrl } from '../lib/s3.js'; + +const router = Router(); + +router.get('/', async (_req, res) => { + try { + const rows = await Symbol.findAll({ + order: [['sort_order', 'ASC'], ['id', 'ASC']], + include: [{ model: SymbolLevel, as: 'levels' }], + }); + res.json(rows.map((s) => { + const j = s.toJSON(); + return { + id: j.id, + type: j.type, + region: j.region, + image_url: j.image ? getPublicUrl(j.image) : null, + max_level: j.max_level, + daily_default: j.daily_default, + weekly_default: j.weekly_default, + sort_order: j.sort_order, + levels: (j.levels || []) + .sort((a, b) => a.level - b.level) + .map((l) => ({ + level: l.level, + required_count: l.required_count, + meso_cost: Number(l.meso_cost), + })), + }; + })); + } catch (err) { + console.error('심볼 목록 조회 오류:', err.message); + res.status(500).json({ error: '심볼 목록 조회 실패' }); + } +}); + +export default router; diff --git a/backend/server.js b/backend/server.js index 25cd9e5..538e876 100644 --- a/backend/server.js +++ b/backend/server.js @@ -6,6 +6,7 @@ import noticeRoutes from './routes/notices.js'; import bossCrystalRoutes from './routes/boss-crystal.js'; import characterRoutes from './routes/character.js'; import imageRoutes from './routes/images.js'; +import symbolRoutes from './routes/symbol.js'; import { sequelize } from './lib/db.js'; import './models/index.js'; @@ -25,6 +26,7 @@ app.use('/api/notices', noticeRoutes); app.use('/api/boss-crystal', bossCrystalRoutes); app.use('/api/character', characterRoutes); app.use('/api/images', imageRoutes); +app.use('/api/symbols', symbolRoutes); app.use('/api/admin', adminRoutes); app.get('/api/health', (_req, res) => { diff --git a/backend/services/image.js b/backend/services/image.js index 1c3e966..b620ffb 100644 --- a/backend/services/image.js +++ b/backend/services/image.js @@ -29,3 +29,14 @@ export async function convertAndUpload(buffer) { export async function deleteFromS3(path) { await deleteObject(path); } + +/** + * 지정한 경로로 webp 변환 후 업로드 (덮어쓰기) + * @param {Buffer} buffer - 원본 이미지 버퍼 + * @param {string} path - S3 키 (확장자 포함). 예: 'symbol/아케인심볼(소멸의 여로).webp' + */ +export async function convertAndUploadTo(buffer, path) { + const webpBuffer = await sharp(buffer).webp({ quality: 90 }).toBuffer(); + await uploadObject(path, webpBuffer, 'image/webp'); + return { path, size: webpBuffer.length }; +} diff --git a/frontend/src/features/symbol/Symbol.jsx b/frontend/src/features/symbol/Symbol.jsx index 9fced6c..1fce00c 100644 --- a/frontend/src/features/symbol/Symbol.jsx +++ b/frontend/src/features/symbol/Symbol.jsx @@ -1,8 +1,11 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useMemo } from 'react' import { useQuery, useMutation } from '@tanstack/react-query' import { api } from '../../api/client' import { useLayout } from '../../components/Layout' -import { SYMBOL_TABS, SYMBOLS } from './data' +import Select from '../../components/Select' + + +const TYPE_ORDER = ['아케인', '어센틱', '그랜드 어센틱'] function CharacterCard({ char, active, onSelect, onRemove }) { return ( @@ -54,26 +57,17 @@ function CharacterCard({ char, active, onSelect, onRemove }) { ) } -function TabImage({ name }) { - const { data } = useQuery({ - queryKey: ['image', name], - queryFn: () => api('/api/images/' + encodeURIComponent(name)).catch(() => null), - staleTime: Infinity, - }) - if (!data?.url) return
- return등록된 심볼이 없습니다
diff --git a/frontend/src/features/symbol/data.js b/frontend/src/features/symbol/data.js deleted file mode 100644 index d565f07..0000000 --- a/frontend/src/features/symbol/data.js +++ /dev/null @@ -1,30 +0,0 @@ -export const SYMBOL_TABS = [ - { key: 'arcane', label: '아케인 심볼', imageName: '아케인심볼 : 소멸의 여로', maxLevel: 20 }, - { key: 'authentic', label: '어센틱 심볼', imageName: '어센틱심볼 : 세르니움', maxLevel: 11 }, - { key: 'grand', label: '그랜드 어센틱 심볼', imageName: '그랜드 어센틱심볼 : 탈라하트', maxLevel: 11 }, -] - -const BASE = 'https://s3.caadiq.co.kr/maplestory/symbol' - -export const SYMBOLS = { - arcane: [ - { key: 'yeoro', name: '소멸의 여로', image: `${BASE}/아케인심볼(소멸의 여로).webp` }, - { key: 'chuchu', name: '츄츄 아일랜드', image: `${BASE}/아케인심볼(츄츄 아일랜드).webp` }, - { key: 'lachelein', name: '레헬른', image: `${BASE}/아케인심볼(레헬른).webp` }, - { key: 'arcana', name: '아르카나', image: `${BASE}/아케인심볼(아르카나).webp` }, - { key: 'morass', name: '모라스', image: `${BASE}/아케인심볼(모라스).webp` }, - { key: 'esfera', name: '에스페라', image: `${BASE}/아케인심볼(에스페라).webp` }, - ], - authentic: [ - { key: 'cernium', name: '세르니움', image: `${BASE}/어센틱심볼(세르니움).webp` }, - { key: 'arcs', name: '아르크스', image: `${BASE}/어센틱심볼(아르크스).webp` }, - { key: 'odium', name: '오디움', image: `${BASE}/어센틱심볼(오디움).webp` }, - { key: 'dowongyeong', name: '도원경', image: `${BASE}/어센틱심볼(도원경).webp` }, - { key: 'arteria', name: '아르테리아', image: `${BASE}/어센틱심볼(아르테리아).webp` }, - { key: 'carcion', name: '카르시온', image: `${BASE}/어센틱심볼(카르시온).webp` }, - ], - grand: [ - { key: 'talahart', name: '탈라하트', image: `${BASE}/그랜드 어센틱심볼(탈라하트).webp` }, - { key: 'geardrock', name: '기어드락', image: `${BASE}/그랜드 어센틱심볼(기어드락).webp` }, - ], -}