이미지 서비스 통합: boss-crystal/image.js 제거 + safeDelete 헬퍼 추가

- services/boss-crystal/image.js의 uploadBossImage/deleteBossImage는
  services/image.js의 convertAndUploadTo와 safeDelete로 대체 가능해서 제거
- services/image.js에 safeDelete(path) 헬퍼 추가 (삭제 실패해도 흐름을
  끊지 않고 warn 로그). 기존에 try/catch 인라인으로 흩어져있던 세 곳 통일
- routes/admin/boss-crystal.js에 BOSS_IMAGE_PREFIX 상수 인라인

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-04-21 20:33:58 +09:00
parent b3907ec48f
commit c072cccf44
5 changed files with 26 additions and 46 deletions

View file

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

View file

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

View file

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

View file

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

@ -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 - 원본 이미지 버퍼