From b885f464c3ced9547a7370966b61db94e17d5e34 Mon Sep 17 00:00:00 2001 From: caadiq Date: Mon, 13 Apr 2026 16:01:04 +0900 Subject: [PATCH] =?UTF-8?q?=EB=B3=B4=EC=8A=A4=20=EA=B2=B0=EC=A0=95=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 최대 인원수를 보스 단위로 통합 (난이도별 → 보스 공통) - 가격 입력 시 쉼표 자동 표시 (text + inputMode=numeric) - registry 캐싱으로 sub-route 변경 시 화면 갱신 안되던 버그 수정 Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/models/boss-crystal/Boss.js | 14 ++ backend/models/boss-crystal/BossDifficulty.js | 19 ++ backend/models/index.js | 14 +- backend/routes/admin.js | 4 + backend/routes/admin/boss-crystal.js | 227 ++++++++++++++++++ backend/services/boss-crystal/image.js | 26 ++ .../features/boss-crystal/admin/BossForm.jsx | 58 +++-- .../features/boss-crystal/admin/BossList.jsx | 2 +- frontend/src/features/registry.js | 31 ++- 9 files changed, 353 insertions(+), 42 deletions(-) create mode 100644 backend/models/boss-crystal/Boss.js create mode 100644 backend/models/boss-crystal/BossDifficulty.js create mode 100644 backend/routes/admin/boss-crystal.js create mode 100644 backend/services/boss-crystal/image.js diff --git a/backend/models/boss-crystal/Boss.js b/backend/models/boss-crystal/Boss.js new file mode 100644 index 0000000..341dfa8 --- /dev/null +++ b/backend/models/boss-crystal/Boss.js @@ -0,0 +1,14 @@ +import { DataTypes } from 'sequelize'; +import { sequelize } from '../../lib/db.js'; + +export const BossCrystalBoss = sequelize.define('BossCrystalBoss', { + id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true }, + name: { type: DataTypes.STRING(50), allowNull: false, unique: true }, + image_path: { type: DataTypes.STRING(255), allowNull: true }, // S3 키 (예: crystal/boss/검은마법사.webp) + max_party_size: { type: DataTypes.TINYINT, allowNull: false, defaultValue: 6 }, + sort_order: { type: DataTypes.INTEGER, defaultValue: 0 }, +}, { + tableName: 'bc_bosses', + underscored: true, + indexes: [{ fields: ['sort_order'] }], +}); diff --git a/backend/models/boss-crystal/BossDifficulty.js b/backend/models/boss-crystal/BossDifficulty.js new file mode 100644 index 0000000..ccc9be5 --- /dev/null +++ b/backend/models/boss-crystal/BossDifficulty.js @@ -0,0 +1,19 @@ +import { DataTypes } from 'sequelize'; +import { sequelize } from '../../lib/db.js'; + +export const BossCrystalBossDifficulty = sequelize.define('BossCrystalBossDifficulty', { + id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true }, + boss_id: { type: DataTypes.INTEGER, allowNull: false }, + difficulty: { + type: DataTypes.ENUM('easy', 'normal', 'hard', 'chaos', 'extreme'), + allowNull: false, + }, + crystal_price: { type: DataTypes.BIGINT, allowNull: false }, +}, { + tableName: 'bc_boss_difficulties', + underscored: true, + timestamps: false, + indexes: [ + { unique: true, fields: ['boss_id', 'difficulty'] }, + ], +}); diff --git a/backend/models/index.js b/backend/models/index.js index 0c18e09..2adedb7 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -1,7 +1,17 @@ import { Image } from './Image.js'; import { Menu } from './Menu.js'; +import { BossCrystalBoss } from './boss-crystal/Boss.js'; +import { BossCrystalBossDifficulty } from './boss-crystal/BossDifficulty.js'; -// Menu <-> Image (선택적 FK) +// Menu <-> Image Menu.belongsTo(Image, { foreignKey: 'image_id', as: 'image', onDelete: 'SET NULL' }); -export { Image, Menu }; +// BossCrystal Boss <-> Difficulty +BossCrystalBoss.hasMany(BossCrystalBossDifficulty, { + foreignKey: 'boss_id', + as: 'difficulties', + onDelete: 'CASCADE', +}); +BossCrystalBossDifficulty.belongsTo(BossCrystalBoss, { foreignKey: 'boss_id', as: 'boss' }); + +export { Image, Menu, BossCrystalBoss, BossCrystalBossDifficulty }; diff --git a/backend/routes/admin.js b/backend/routes/admin.js index 7344570..859efc2 100644 --- a/backend/routes/admin.js +++ b/backend/routes/admin.js @@ -4,6 +4,7 @@ import { Image, Menu } from '../models/index.js'; 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'; const router = Router(); const upload = multer({ @@ -32,6 +33,9 @@ router.post('/verify', (req, res) => { // 이하 모든 라우트는 인증 필요 router.use(requireAdmin); +// 기능별 sub-router +router.use('/boss-crystal', bossCrystalRouter); + /* ── 이미지 관리 ── */ // 전체 이미지 이름 (중복 체크용) diff --git a/backend/routes/admin/boss-crystal.js b/backend/routes/admin/boss-crystal.js new file mode 100644 index 0000000..ea10f50 --- /dev/null +++ b/backend/routes/admin/boss-crystal.js @@ -0,0 +1,227 @@ +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'; + +const router = Router(); +const upload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 10 * 1024 * 1024 }, +}); + +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('하나 이상의 난이도가 필요합니다'); + } + const valid = ['easy', 'normal', 'hard', 'chaos', 'extreme']; + return arr.map((d) => { + if (!valid.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 < 1 || n > 6) throw new Error('인원수는 1~6이어야 합니다'); + 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; diff --git a/backend/services/boss-crystal/image.js b/backend/services/boss-crystal/image.js new file mode 100644 index 0000000..f382c77 --- /dev/null +++ b/backend/services/boss-crystal/image.js @@ -0,0 +1,26 @@ +import sharp from 'sharp'; +import { uploadObject, deleteObject } from '../../lib/s3.js'; + +const BOSS_IMAGE_PREFIX = 'crystal/boss'; + +/** + * 보스 이미지를 webp로 변환하고 RustFS의 crystal/boss/{name}.webp에 업로드 + * @param {Buffer} buffer + * @param {string} bossName + * @returns {Promise} S3 키 (예: crystal/boss/검은마법사.webp) + */ +export async function uploadBossImage(buffer, bossName) { + const webp = await sharp(buffer).webp({ quality: 90 }).toBuffer(); + const path = `${BOSS_IMAGE_PREFIX}/${bossName}.webp`; + await uploadObject(path, webp, 'image/webp'); + return path; +} + +export async function deleteBossImage(path) { + if (!path) return; + try { + await deleteObject(path); + } catch (err) { + console.warn(`보스 이미지 삭제 실패 (${path}):`, err.message); + } +} diff --git a/frontend/src/features/boss-crystal/admin/BossForm.jsx b/frontend/src/features/boss-crystal/admin/BossForm.jsx index 4380008..38eeeff 100644 --- a/frontend/src/features/boss-crystal/admin/BossForm.jsx +++ b/frontend/src/features/boss-crystal/admin/BossForm.jsx @@ -29,7 +29,7 @@ const inputCls = 'w-full rounded-lg border border-white/10 bg-gray-950 px-3 py-2 function emptyDifficultyState() { const obj = {} DIFFICULTIES.forEach((d) => { - obj[d.key] = { enabled: false, crystal_price: '', max_party_size: 6 } + obj[d.key] = { enabled: false, crystal_price: '' } }) return obj } @@ -42,6 +42,7 @@ export default function BossForm() { const fileInputRef = useRef(null) const [name, setName] = useState('') + const [maxPartySize, setMaxPartySize] = useState(6) const [imageFile, setImageFile] = useState(null) const [imagePreview, setImagePreview] = useState(null) const [existingImageUrl, setExistingImageUrl] = useState(null) @@ -59,6 +60,7 @@ export default function BossForm() { useEffect(() => { if (!isEdit) { setName('') + setMaxPartySize(6) setImageFile(null) setImagePreview(null) setExistingImageUrl(null) @@ -67,6 +69,7 @@ export default function BossForm() { } if (bossData) { setName(bossData.name || '') + setMaxPartySize(bossData.max_party_size || 6) setExistingImageUrl(bossData.image_url || null) setImagePreview(null) setImageFile(null) @@ -76,7 +79,6 @@ export default function BossForm() { next[d.difficulty] = { enabled: true, crystal_price: String(d.crystal_price), - max_party_size: d.max_party_size, } }) setDifficulties(next) @@ -121,6 +123,7 @@ export default function BossForm() { mutationFn: async () => { const formData = new FormData() formData.append('name', name.trim()) + formData.append('max_party_size', String(maxPartySize)) if (imageFile) formData.append('image', imageFile) const diffsPayload = DIFFICULTIES @@ -128,7 +131,6 @@ export default function BossForm() { .map((d) => ({ difficulty: d.key, crystal_price: Number(difficulties[d.key].crystal_price), - max_party_size: Number(difficulties[d.key].max_party_size), })) formData.append('difficulties', JSON.stringify(diffsPayload)) @@ -179,16 +181,26 @@ export default function BossForm() {
- {/* 이름 */} - - setName(e.target.value)} - placeholder="예: 검은 마법사" - className={inputCls} - /> - + {/* 이름 + 최대 인원 */} +
+ + setName(e.target.value)} + placeholder="예: 검은 마법사" + className={inputCls} + /> + + + updateDifficulty(d.key, { crystal_price: e.target.value })} + type="text" + inputMode="numeric" + value={v.crystal_price ? Number(v.crystal_price).toLocaleString() : ''} + onChange={(e) => { + const digits = e.target.value.replace(/[^\d]/g, '') + updateDifficulty(d.key, { crystal_price: digits }) + }} disabled={!v.enabled} placeholder="결정 가격" className={`w-full rounded-lg border bg-gray-900 pl-4 pr-28 py-2 text-sm outline-none focus:border-emerald-500/50 disabled:opacity-50 disabled:cursor-not-allowed transition ${ @@ -276,16 +292,6 @@ export default function BossForm() { )}
- - {/* 최대 인원 */} -