maplestory/backend/routes/admin/boss-crystal.js
caadiq b3907ec48f 공용 상수 backend/constants.js로 추출
DIFFICULTIES(난이도 enum), PARTY_SIZE(1~6), SYMBOL_MASTER_LEVEL(2~99),
UPLOAD_FILE_SIZE_LIMIT(10MB)를 backend/constants.js로 추출하고 admin.js,
boss-crystal.js, symbol.js, BossDifficulty 모델에서 참조.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 20:31:43 +09:00

229 lines
8 KiB
JavaScript

import { Router } from 'express';
import multer from 'multer';
import { BossCrystalBoss, BossCrystalBossDifficulty } from '../../models/index.js';
import { uploadBossImage, deleteBossImage } from '../../services/boss-crystal/image.js';
import { getPublicUrl } from '../../lib/s3.js';
import { sequelize } from '../../lib/db.js';
import { UPLOAD_FILE_SIZE_LIMIT, PARTY_SIZE, DIFFICULTIES } from '../../constants.js';
const router = Router();
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: UPLOAD_FILE_SIZE_LIMIT },
});
function serialize(boss) {
const json = boss.toJSON();
return {
id: json.id,
name: json.name,
image_url: json.image_path ? getPublicUrl(json.image_path) : null,
image_path: json.image_path,
max_party_size: json.max_party_size,
sort_order: json.sort_order,
difficulties: (json.difficulties || []).map((d) => ({
id: d.id,
difficulty: d.difficulty,
crystal_price: Number(d.crystal_price),
})),
};
}
function parseDifficulties(raw) {
if (!raw) return [];
let arr;
try {
arr = typeof raw === 'string' ? JSON.parse(raw) : raw;
} catch {
throw new Error('난이도 정보 형식이 잘못되었습니다');
}
if (!Array.isArray(arr) || arr.length === 0) {
throw new Error('하나 이상의 난이도가 필요합니다');
}
return arr.map((d) => {
if (!DIFFICULTIES.includes(d.difficulty)) throw new Error(`잘못된 난이도: ${d.difficulty}`);
const price = Number(d.crystal_price);
if (isNaN(price) || price <= 0) throw new Error(`잘못된 가격: ${d.difficulty}`);
return { difficulty: d.difficulty, crystal_price: price };
});
}
function parseMaxParty(raw) {
const n = Number(raw);
if (isNaN(n) || n < PARTY_SIZE.min || n > PARTY_SIZE.max) {
throw new Error(`인원수는 ${PARTY_SIZE.min}~${PARTY_SIZE.max}이어야 합니다`);
}
return n;
}
// 목록
router.get('/bosses', async (_req, res) => {
try {
const bosses = await BossCrystalBoss.findAll({
order: [['sort_order', 'ASC'], ['id', 'ASC']],
include: [{ model: BossCrystalBossDifficulty, as: 'difficulties' }],
});
res.json(bosses.map(serialize));
} catch (err) {
console.error('보스 목록 조회 오류:', err.message);
res.status(500).json({ error: '보스 목록 조회 실패' });
}
});
// 단일 조회
router.get('/bosses/:id', async (req, res) => {
try {
const boss = await BossCrystalBoss.findByPk(req.params.id, {
include: [{ model: BossCrystalBossDifficulty, as: 'difficulties' }],
});
if (!boss) return res.status(404).json({ error: '보스를 찾을 수 없습니다' });
res.json(serialize(boss));
} catch (err) {
console.error('보스 조회 오류:', err.message);
res.status(500).json({ error: '보스 조회 실패' });
}
});
// 생성
router.post('/bosses', upload.single('image'), async (req, res) => {
const { name } = req.body;
if (!name?.trim()) return res.status(400).json({ error: '보스 이름을 입력해주세요' });
if (!req.file) return res.status(400).json({ error: '보스 이미지를 업로드해주세요' });
const trimmedName = name.trim();
try {
const difficulties = parseDifficulties(req.body.difficulties);
const maxPartySize = parseMaxParty(req.body.max_party_size);
// 중복 체크
const existing = await BossCrystalBoss.findOne({ where: { name: trimmedName } });
if (existing) return res.status(400).json({ error: '같은 이름의 보스가 이미 존재합니다' });
// 이미지 업로드
const imagePath = await uploadBossImage(req.file.buffer, trimmedName);
// 마지막 정렬 순서
const max = await BossCrystalBoss.max('sort_order') || 0;
const boss = await sequelize.transaction(async (tx) => {
const created = await BossCrystalBoss.create({
name: trimmedName,
image_path: imagePath,
max_party_size: maxPartySize,
sort_order: max + 1,
}, { transaction: tx });
await BossCrystalBossDifficulty.bulkCreate(
difficulties.map((d) => ({ ...d, boss_id: created.id })),
{ transaction: tx }
);
return created;
});
const fresh = await BossCrystalBoss.findByPk(boss.id, {
include: [{ model: BossCrystalBossDifficulty, as: 'difficulties' }],
});
res.json(serialize(fresh));
} catch (err) {
console.error('보스 생성 오류:', err.message);
res.status(500).json({ error: err.message || '보스 생성 실패' });
}
});
// 수정
router.patch('/bosses/:id', upload.single('image'), async (req, res) => {
try {
const boss = await BossCrystalBoss.findByPk(req.params.id);
if (!boss) return res.status(404).json({ error: '보스를 찾을 수 없습니다' });
const newName = req.body.name?.trim();
if (!newName) return res.status(400).json({ error: '보스 이름을 입력해주세요' });
// 이름 중복 체크 (자기 자신 제외)
if (newName !== boss.name) {
const dup = await BossCrystalBoss.findOne({ where: { name: newName } });
if (dup) return res.status(400).json({ error: '같은 이름의 보스가 이미 존재합니다' });
}
const difficulties = parseDifficulties(req.body.difficulties);
const maxPartySize = parseMaxParty(req.body.max_party_size);
let newImagePath = boss.image_path;
// 새 이미지 업로드 또는 이름 변경 시 이미지 재업로드
if (req.file) {
const oldPath = boss.image_path;
newImagePath = await uploadBossImage(req.file.buffer, newName);
if (oldPath && oldPath !== newImagePath) {
await deleteBossImage(oldPath);
}
} else if (newName !== boss.name && boss.image_path) {
// 이름만 변경 - 기존 이미지를 새 경로로 복사하는 대신 이름 기반 경로 업데이트
// 간단하게 처리: 기존 키를 새 키로 교체할 수 없으니, 이미지가 없으면 그대로, 있으면 path만 갱신
newImagePath = `crystal/boss/${newName}.webp`;
// 실제로 이름이 바뀌면 이미지를 다시 올려달라고 하는 게 안전. 현재는 path만 업데이트하고 추후 다음 업로드 시 새 경로로 저장됨
}
await sequelize.transaction(async (tx) => {
boss.name = newName;
boss.image_path = newImagePath;
boss.max_party_size = maxPartySize;
await boss.save({ transaction: tx });
await BossCrystalBossDifficulty.destroy({ where: { boss_id: boss.id }, transaction: tx });
await BossCrystalBossDifficulty.bulkCreate(
difficulties.map((d) => ({ ...d, boss_id: boss.id })),
{ transaction: tx }
);
});
const fresh = await BossCrystalBoss.findByPk(boss.id, {
include: [{ model: BossCrystalBossDifficulty, as: 'difficulties' }],
});
res.json(serialize(fresh));
} catch (err) {
console.error('보스 수정 오류:', err.message);
res.status(500).json({ error: err.message || '보스 수정 실패' });
}
});
// 삭제
router.delete('/bosses/:id', async (req, res) => {
try {
const boss = await BossCrystalBoss.findByPk(req.params.id);
if (!boss) return res.status(404).json({ error: '보스를 찾을 수 없습니다' });
if (boss.image_path) {
await deleteBossImage(boss.image_path);
}
await boss.destroy();
res.json({ success: true });
} catch (err) {
console.error('보스 삭제 오류:', err.message);
res.status(500).json({ error: '보스 삭제 실패' });
}
});
// 정렬 변경
router.post('/bosses/reorder', async (req, res) => {
const { ids } = req.body;
if (!Array.isArray(ids) || ids.length === 0) {
return res.status(400).json({ error: '정렬할 보스 ID 목록이 필요합니다' });
}
try {
await sequelize.transaction(async (tx) => {
for (let i = 0; i < ids.length; i++) {
await BossCrystalBoss.update({ sort_order: i }, { where: { id: ids[i] }, transaction: tx });
}
});
res.json({ success: true });
} catch (err) {
console.error('보스 정렬 변경 오류:', err.message);
res.status(500).json({ error: '보스 정렬 변경 실패' });
}
});
export default router;