import { Router } from 'express'; import multer from 'multer'; import { Symbol, SymbolLevel } from '../../models/index.js'; import { convertAndUploadTo, safeDelete } from '../../services/image.js'; import { getPublicUrl } from '../../lib/s3.js'; import { sequelize } from '../../lib/db.js'; import { UPLOAD_FILE_SIZE_LIMIT, SYMBOL_MASTER_LEVEL } from '../../constants.js'; const router = Router(); const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: UPLOAD_FILE_SIZE_LIMIT }, }); 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 < SYMBOL_MASTER_LEVEL.min || ml > SYMBOL_MASTER_LEVEL.max) { throw new Error(`만렙은 ${SYMBOL_MASTER_LEVEL.min}~${SYMBOL_MASTER_LEVEL.max} 사이여야 합니다`); } 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) { await safeDelete(row.image); } } 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) await safeDelete(key); 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;