diff --git a/backend/routes/admin.js b/backend/routes/admin.js index 047f6d2..1c89234 100644 --- a/backend/routes/admin.js +++ b/backend/routes/admin.js @@ -1,7 +1,7 @@ import { Router } from 'express'; import multer from 'multer'; import { Image, Menu } from '../models/index.js'; -import { convertAndUpload, deleteFromS3 } from '../services/image.js'; +import { convertAndUpload, safeDelete } from '../services/image.js'; import { getPublicUrl } from '../lib/s3.js'; import { sequelize } from '../lib/db.js'; import bossCrystalRouter from './admin/boss-crystal.js'; @@ -165,13 +165,7 @@ router.post('/images/delete', async (req, res) => { try { const images = await Image.findAll({ where: { id: ids } }); - await Promise.all( - images.map((img) => - deleteFromS3(img.path).catch((err) => - console.warn(`S3 삭제 실패 (${img.path}):`, err.message) - ) - ) - ); + await Promise.all(images.map((img) => safeDelete(img.path))); await Image.destroy({ where: { id: ids } }); res.json({ success: true, deleted: images.length }); diff --git a/backend/routes/admin/boss-crystal.js b/backend/routes/admin/boss-crystal.js index 3c4764c..c3ec330 100644 --- a/backend/routes/admin/boss-crystal.js +++ b/backend/routes/admin/boss-crystal.js @@ -1,7 +1,10 @@ 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 { convertAndUploadTo, safeDelete } from '../../services/image.js'; + +const BOSS_IMAGE_PREFIX = 'crystal/boss'; +const bossImagePath = (name) => `${BOSS_IMAGE_PREFIX}/${name}.webp`; import { getPublicUrl } from '../../lib/s3.js'; import { sequelize } from '../../lib/db.js'; import { UPLOAD_FILE_SIZE_LIMIT, PARTY_SIZE, DIFFICULTIES } from '../../constants.js'; @@ -101,7 +104,7 @@ router.post('/bosses', upload.single('image'), async (req, res) => { if (existing) return res.status(400).json({ error: '같은 이름의 보스가 이미 존재합니다' }); // 이미지 업로드 - const imagePath = await uploadBossImage(req.file.buffer, trimmedName); + const { path: imagePath } = await convertAndUploadTo(req.file.buffer, bossImagePath(trimmedName)); // 마지막 정렬 순서 const max = await BossCrystalBoss.max('sort_order') || 0; @@ -155,15 +158,14 @@ router.patch('/bosses/:id', upload.single('image'), async (req, res) => { // 새 이미지 업로드 또는 이름 변경 시 이미지 재업로드 if (req.file) { const oldPath = boss.image_path; - newImagePath = await uploadBossImage(req.file.buffer, newName); + const uploaded = await convertAndUploadTo(req.file.buffer, bossImagePath(newName)); + newImagePath = uploaded.path; if (oldPath && oldPath !== newImagePath) { - await deleteBossImage(oldPath); + await safeDelete(oldPath); } } else if (newName !== boss.name && boss.image_path) { - // 이름만 변경 - 기존 이미지를 새 경로로 복사하는 대신 이름 기반 경로 업데이트 - // 간단하게 처리: 기존 키를 새 키로 교체할 수 없으니, 이미지가 없으면 그대로, 있으면 path만 갱신 - newImagePath = `crystal/boss/${newName}.webp`; - // 실제로 이름이 바뀌면 이미지를 다시 올려달라고 하는 게 안전. 현재는 path만 업데이트하고 추후 다음 업로드 시 새 경로로 저장됨 + // 이름 변경 시 path만 갱신 (실제 파일은 다음 이미지 업로드 때 새 경로로 저장됨) + newImagePath = bossImagePath(newName); } await sequelize.transaction(async (tx) => { @@ -196,7 +198,7 @@ router.delete('/bosses/:id', async (req, res) => { if (!boss) return res.status(404).json({ error: '보스를 찾을 수 없습니다' }); if (boss.image_path) { - await deleteBossImage(boss.image_path); + await safeDelete(boss.image_path); } await boss.destroy(); res.json({ success: true }); diff --git a/backend/routes/admin/symbol.js b/backend/routes/admin/symbol.js index bac5273..290b9e8 100644 --- a/backend/routes/admin/symbol.js +++ b/backend/routes/admin/symbol.js @@ -1,7 +1,7 @@ import { Router } from 'express'; import multer from 'multer'; import { Symbol, SymbolLevel } from '../../models/index.js'; -import { convertAndUploadTo, deleteFromS3 } from '../../services/image.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'; @@ -167,7 +167,7 @@ router.patch('/symbols/:id', upload.single('image'), async (req, res) => { 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 */ } + await safeDelete(row.image); } } else if (basic.type !== row.type || basic.region !== row.region) { // 이름/종류 변경 시 새 경로로 rename 대체 불가 → 기존 키 유지 @@ -211,7 +211,7 @@ router.delete('/symbols/:id', async (req, res) => { if (!row) return res.status(404).json({ error: '심볼을 찾을 수 없습니다' }); const key = row.image; await row.destroy(); - if (key) { try { await deleteFromS3(key); } catch { /* ignore */ } } + if (key) await safeDelete(key); res.json({ success: true }); } catch (err) { console.error('심볼 삭제 오류:', err.message); diff --git a/backend/services/boss-crystal/image.js b/backend/services/boss-crystal/image.js deleted file mode 100644 index f382c77..0000000 --- a/backend/services/boss-crystal/image.js +++ /dev/null @@ -1,26 +0,0 @@ -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/backend/services/image.js b/backend/services/image.js index b620ffb..83e4025 100644 --- a/backend/services/image.js +++ b/backend/services/image.js @@ -30,6 +30,16 @@ export async function deleteFromS3(path) { await deleteObject(path); } +// 삭제 실패해도 흐름을 끊지 않는 버전 (이전 이미지 정리 등에 사용) +export async function safeDelete(path) { + if (!path) return; + try { + await deleteObject(path); + } catch (err) { + console.warn(`S3 삭제 실패 (${path}):`, err.message); + } +} + /** * 지정한 경로로 webp 변환 후 업로드 (덮어쓰기) * @param {Buffer} buffer - 원본 이미지 버퍼