보스 결정 관리 개선

- 최대 인원수를 보스 단위로 통합 (난이도별 → 보스 공통)
- 가격 입력 시 쉼표 자동 표시 (text + inputMode=numeric)
- registry 캐싱으로 sub-route 변경 시 화면 갱신 안되던 버그 수정

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-04-13 16:01:04 +09:00
parent 39cda0d958
commit b885f464c3
9 changed files with 353 additions and 42 deletions

View file

@ -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'] }],
});

View file

@ -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'] },
],
});

View file

@ -1,7 +1,17 @@
import { Image } from './Image.js'; import { Image } from './Image.js';
import { Menu } from './Menu.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' }); 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 };

View file

@ -4,6 +4,7 @@ import { Image, Menu } from '../models/index.js';
import { convertAndUpload, deleteFromS3 } from '../services/image.js'; import { convertAndUpload, deleteFromS3 } from '../services/image.js';
import { getPublicUrl } from '../lib/s3.js'; import { getPublicUrl } from '../lib/s3.js';
import { sequelize } from '../lib/db.js'; import { sequelize } from '../lib/db.js';
import bossCrystalRouter from './admin/boss-crystal.js';
const router = Router(); const router = Router();
const upload = multer({ const upload = multer({
@ -32,6 +33,9 @@ router.post('/verify', (req, res) => {
// 이하 모든 라우트는 인증 필요 // 이하 모든 라우트는 인증 필요
router.use(requireAdmin); router.use(requireAdmin);
// 기능별 sub-router
router.use('/boss-crystal', bossCrystalRouter);
/* ── 이미지 관리 ── */ /* ── 이미지 관리 ── */
// 전체 이미지 이름 (중복 체크용) // 전체 이미지 이름 (중복 체크용)

View file

@ -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;

View file

@ -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<string>} 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);
}
}

View file

@ -29,7 +29,7 @@ const inputCls = 'w-full rounded-lg border border-white/10 bg-gray-950 px-3 py-2
function emptyDifficultyState() { function emptyDifficultyState() {
const obj = {} const obj = {}
DIFFICULTIES.forEach((d) => { DIFFICULTIES.forEach((d) => {
obj[d.key] = { enabled: false, crystal_price: '', max_party_size: 6 } obj[d.key] = { enabled: false, crystal_price: '' }
}) })
return obj return obj
} }
@ -42,6 +42,7 @@ export default function BossForm() {
const fileInputRef = useRef(null) const fileInputRef = useRef(null)
const [name, setName] = useState('') const [name, setName] = useState('')
const [maxPartySize, setMaxPartySize] = useState(6)
const [imageFile, setImageFile] = useState(null) const [imageFile, setImageFile] = useState(null)
const [imagePreview, setImagePreview] = useState(null) const [imagePreview, setImagePreview] = useState(null)
const [existingImageUrl, setExistingImageUrl] = useState(null) const [existingImageUrl, setExistingImageUrl] = useState(null)
@ -59,6 +60,7 @@ export default function BossForm() {
useEffect(() => { useEffect(() => {
if (!isEdit) { if (!isEdit) {
setName('') setName('')
setMaxPartySize(6)
setImageFile(null) setImageFile(null)
setImagePreview(null) setImagePreview(null)
setExistingImageUrl(null) setExistingImageUrl(null)
@ -67,6 +69,7 @@ export default function BossForm() {
} }
if (bossData) { if (bossData) {
setName(bossData.name || '') setName(bossData.name || '')
setMaxPartySize(bossData.max_party_size || 6)
setExistingImageUrl(bossData.image_url || null) setExistingImageUrl(bossData.image_url || null)
setImagePreview(null) setImagePreview(null)
setImageFile(null) setImageFile(null)
@ -76,7 +79,6 @@ export default function BossForm() {
next[d.difficulty] = { next[d.difficulty] = {
enabled: true, enabled: true,
crystal_price: String(d.crystal_price), crystal_price: String(d.crystal_price),
max_party_size: d.max_party_size,
} }
}) })
setDifficulties(next) setDifficulties(next)
@ -121,6 +123,7 @@ export default function BossForm() {
mutationFn: async () => { mutationFn: async () => {
const formData = new FormData() const formData = new FormData()
formData.append('name', name.trim()) formData.append('name', name.trim())
formData.append('max_party_size', String(maxPartySize))
if (imageFile) formData.append('image', imageFile) if (imageFile) formData.append('image', imageFile)
const diffsPayload = DIFFICULTIES const diffsPayload = DIFFICULTIES
@ -128,7 +131,6 @@ export default function BossForm() {
.map((d) => ({ .map((d) => ({
difficulty: d.key, difficulty: d.key,
crystal_price: Number(difficulties[d.key].crystal_price), crystal_price: Number(difficulties[d.key].crystal_price),
max_party_size: Number(difficulties[d.key].max_party_size),
})) }))
formData.append('difficulties', JSON.stringify(diffsPayload)) formData.append('difficulties', JSON.stringify(diffsPayload))
@ -179,16 +181,26 @@ export default function BossForm() {
</div> </div>
<form onSubmit={handleSubmit} className="space-y-5 rounded-2xl border border-white/5 bg-gray-900/40 p-6"> <form onSubmit={handleSubmit} className="space-y-5 rounded-2xl border border-white/5 bg-gray-900/40 p-6">
{/* 이름 */} {/* 이름 + 최대 인원 */}
<Field label="보스 이름" required error={errors.name}> <div className="grid grid-cols-[1fr_auto] gap-3">
<input <Field label="보스 이름" required error={errors.name}>
type="text" <input
value={name} type="text"
onChange={(e) => setName(e.target.value)} value={name}
placeholder="예: 검은 마법사" onChange={(e) => setName(e.target.value)}
className={inputCls} placeholder="예: 검은 마법사"
/> className={inputCls}
</Field> />
</Field>
<Field label="최대 인원">
<Select
value={maxPartySize}
onChange={setMaxPartySize}
options={PARTY_OPTIONS}
className="w-24"
/>
</Field>
</div>
{/* 이미지 */} {/* 이미지 */}
<Field label="보스 이미지" required={!isEdit} error={errors.image}> <Field label="보스 이미지" required={!isEdit} error={errors.image}>
@ -260,9 +272,13 @@ export default function BossForm() {
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="relative"> <div className="relative">
<input <input
type="number" type="text"
value={v.crystal_price} inputMode="numeric"
onChange={(e) => updateDifficulty(d.key, { crystal_price: e.target.value })} 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} disabled={!v.enabled}
placeholder="결정 가격" 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 ${ 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() {
)} )}
</div> </div>
</div> </div>
{/* 최대 인원 */}
<Select
value={v.max_party_size}
onChange={(val) => updateDifficulty(d.key, { max_party_size: val })}
options={PARTY_OPTIONS}
disabled={!v.enabled}
className="w-20 shrink-0"
align="right"
/>
</div> </div>
</div> </div>
) )

View file

@ -61,7 +61,7 @@ export default function BossList() {
{DIFFICULTIES.filter((d) => boss.difficulties?.some((bd) => bd.difficulty === d.key)).map((d) => { {DIFFICULTIES.filter((d) => boss.difficulties?.some((bd) => bd.difficulty === d.key)).map((d) => {
const bd = boss.difficulties.find((x) => x.difficulty === d.key) const bd = boss.difficulties.find((x) => x.difficulty === d.key)
return ( return (
<span key={d.key} className={`text-[10px] px-1.5 py-0.5 rounded border ${d.color}`} title={`${formatMeso(bd.crystal_price)} / ${bd.max_party_size}`}> <span key={d.key} className={`text-[10px] px-1.5 py-0.5 rounded border ${d.color}`} title={`${formatMeso(bd.crystal_price)} / ${boss.max_party_size}`}>
{d.label} {d.label}
</span> </span>
) )

View file

@ -21,27 +21,33 @@ function slugToPascal(slug) {
.join('') .join('')
} }
// 컴포넌트 캐시 - 동일 slug에 대해 항상 같은 컴포넌트 인스턴스 반환
// (매 렌더마다 새 lazy() 생성하면 React가 unmount/remount하면서 화면 갱신이 깨짐)
const userCache = new Map()
const adminCache = new Map()
function loadCached(cache, slug, suffix) {
if (cache.has(slug)) return cache.get(slug)
const pascal = slugToPascal(slug)
const path = `./${slug}/${pascal}${suffix}.jsx`
const loader = userPages[path]
const component = loader ? lazy(loader) : null
cache.set(slug, component)
return component
}
/** /**
* slug에 해당하는 사용자 페이지 컴포넌트 반환 * slug에 해당하는 사용자 페이지 컴포넌트 반환
* @returns {React.LazyExoticComponent | null}
*/ */
export function getUserComponent(slug) { export function getUserComponent(slug) {
const pascal = slugToPascal(slug) return loadCached(userCache, slug, '')
const path = `./${slug}/${pascal}.jsx`
const loader = userPages[path]
if (!loader) return null
return lazy(loader)
} }
/** /**
* slug에 해당하는 관리자 페이지 컴포넌트 반환 * slug에 해당하는 관리자 페이지 컴포넌트 반환
*/ */
export function getAdminComponent(slug) { export function getAdminComponent(slug) {
const pascal = slugToPascal(slug) return loadCached(adminCache, slug, 'Admin')
const path = `./${slug}/${pascal}Admin.jsx`
const loader = userPages[path]
if (!loader) return null
return lazy(loader)
} }
/** /**
@ -50,6 +56,5 @@ export function getAdminComponent(slug) {
export function hasAdminPage(slug) { export function hasAdminPage(slug) {
if (!slug) return false if (!slug) return false
const cleaned = slug.replace(/^\/+/, '').split('/')[0] const cleaned = slug.replace(/^\/+/, '').split('/')[0]
const pascal = slugToPascal(cleaned) return getAdminComponent(cleaned) !== null
return !!userPages[`./${cleaned}/${pascal}Admin.jsx`]
} }