feat: 아이콘/번역 삭제 시 S3 파일도 삭제, UI 용어 통일

- S3 deleteFromS3, deleteByPrefix 함수 추가
- 아이콘 삭제 시 S3 파일도 삭제
- 번역 삭제 시 아이콘(S3 파일)도 함께 삭제
- 다이얼로그 '초기화' → '삭제'로 용어 통일
- 토스트 메시지 개선
This commit is contained in:
caadiq 2025-12-26 20:14:43 +09:00
parent b511f374b5
commit 15709c4fb8
3 changed files with 96 additions and 14 deletions

View file

@ -3,6 +3,8 @@ import {
PutObjectCommand, PutObjectCommand,
GetObjectCommand, GetObjectCommand,
HeadObjectCommand, HeadObjectCommand,
DeleteObjectCommand,
ListObjectsV2Command,
} from "@aws-sdk/client-s3"; } from "@aws-sdk/client-s3";
// S3 설정 (RustFS) - 환경변수에서 로드 // S3 설정 (RustFS) - 환경변수에서 로드
@ -87,4 +89,47 @@ async function checkS3Exists(bucket, key) {
} }
} }
export { s3Config, uploadToS3, downloadFromS3, checkS3Exists }; /**
* S3(RustFS)에서 파일 삭제
*/
async function deleteFromS3(bucket, key) {
const command = new DeleteObjectCommand({
Bucket: bucket,
Key: key,
});
await s3Client.send(command);
}
/**
* S3(RustFS)에서 prefix로 시작하는 모든 파일 삭제
*/
async function deleteByPrefix(bucket, prefix) {
const listCommand = new ListObjectsV2Command({
Bucket: bucket,
Prefix: prefix,
});
const listResult = await s3Client.send(listCommand);
const objects = listResult.Contents || [];
let deletedCount = 0;
for (const obj of objects) {
try {
await deleteFromS3(bucket, obj.Key);
deletedCount++;
} catch (err) {
console.error(`[S3] 파일 삭제 실패: ${obj.Key} - ${err.message}`);
}
}
return deletedCount;
}
export {
s3Config,
uploadToS3,
downloadFromS3,
checkS3Exists,
deleteFromS3,
deleteByPrefix,
};

View file

@ -833,21 +833,40 @@ router.post("/modtranslations", requireAdmin, async (req, res) => {
/** /**
* DELETE /api/admin/modtranslations/:modId - 모드 번역 삭제 * DELETE /api/admin/modtranslations/:modId - 모드 번역 삭제
* 번역 데이터와 함께 아이콘(S3 파일) 삭제
*/ */
router.delete("/modtranslations/:modId", requireAdmin, async (req, res) => { router.delete("/modtranslations/:modId", requireAdmin, async (req, res) => {
const { modId } = req.params; const { modId } = req.params;
try { try {
await pool.query(`DELETE FROM items WHERE mod_id = ?`, [modId]); const { deleteByPrefix } = await import("../lib/s3.js");
console.log(`[Admin] 모드 번역 삭제: ${modId}`); // S3에서 해당 모드의 아이콘 파일 삭제
const s3DeletedCount = await deleteByPrefix(
"minecraft",
`icons/items/${modId}_`
);
console.log(`[Admin] S3 아이콘 파일 삭제: ${modId} (${s3DeletedCount}개)`);
// items 테이블에서 해당 모드 삭제
const [result] = await pool.query(`DELETE FROM items WHERE mod_id = ?`, [
modId,
]);
console.log(
`[Admin] 모드 삭제: ${modId} (DB ${result.affectedRows}개, S3 ${s3DeletedCount}개)`
);
// 번역 캐시 새로고침 // 번역 캐시 새로고침
await loadTranslations(); await loadTranslations();
res.json({ success: true }); res.json({
success: true,
deleted: result.affectedRows,
s3Deleted: s3DeletedCount,
});
} catch (error) { } catch (error) {
console.error("[Admin] 모드 번역 삭제 오류:", error); console.error("[Admin] 모드 삭제 오류:", error);
res.status(500).json({ error: "삭제 실패" }); res.status(500).json({ error: "삭제 실패" });
} }
}); });
@ -1046,24 +1065,42 @@ router.post(
); );
/** /**
* DELETE /api/admin/icons/:modId - 특정 모드 아이콘 초기화 * DELETE /api/admin/icons/:modId - 특정 모드 아이콘 삭제
* blocks/items 테이블의 icon 컬럼을 NULL로 설정 * items 테이블의 icon 컬럼을 NULL로 설정하고 S3에서 파일 삭제
*/ */
router.delete("/icons/:modId", requireAdmin, async (req, res) => { router.delete("/icons/:modId", requireAdmin, async (req, res) => {
const { modId } = req.params; const { modId } = req.params;
try { try {
const { deleteByPrefix } = await import("../lib/s3.js");
// S3에서 해당 모드의 아이콘 파일 삭제
const s3DeletedCount = await deleteByPrefix(
"minecraft",
`icons/items/${modId}_`
);
console.log(`[Admin] S3 아이콘 파일 삭제: ${modId} (${s3DeletedCount}개)`);
// items 테이블의 icon 컬럼 NULL로 업데이트 // items 테이블의 icon 컬럼 NULL로 업데이트
const [result] = await pool.query( const [result] = await pool.query(
`UPDATE items SET icon = NULL WHERE mod_id = ? AND icon IS NOT NULL`, `UPDATE items SET icon = NULL WHERE mod_id = ? AND icon IS NOT NULL`,
[modId] [modId]
); );
console.log(`[Admin] 아이콘 초기화: ${modId} (${result.affectedRows}개)`); // 번역 캐시 새로고침
await loadTranslations();
res.json({ success: true, deleted: result.affectedRows }); console.log(
`[Admin] 아이콘 삭제: ${modId} (DB ${result.affectedRows}개, S3 ${s3DeletedCount}개)`
);
res.json({
success: true,
deleted: result.affectedRows,
s3Deleted: s3DeletedCount,
});
} catch (error) { } catch (error) {
console.error("[Admin] 아이콘 초기화 오류:", error); console.error("[Admin] 아이콘 삭제 오류:", error);
res.status(500).json({ error: "삭제 실패" }); res.status(500).json({ error: "삭제 실패" });
} }
}); });

View file

@ -422,7 +422,7 @@ export default function Admin({ isMobile = false }) {
const data = await res.json(); const data = await res.json();
if (data.success) { if (data.success) {
setToast(`${modId} 모드 아이콘 ${data.deleted} 초기화`); setToast(`${modId} 모드 아이콘 삭제 완료 (DB ${data.deleted}, S3 ${data.s3Deleted}개)`);
fetchIconMods(); fetchIconMods();
} else { } else {
setToast(data.error || '삭제 실패', true); setToast(data.error || '삭제 실패', true);
@ -2770,9 +2770,9 @@ export default function Admin({ isMobile = false }) {
className="bg-zinc-900 border border-zinc-800 rounded-2xl p-6 w-full max-w-sm mx-4" className="bg-zinc-900 border border-zinc-800 rounded-2xl p-6 w-full max-w-sm mx-4"
onClick={e => e.stopPropagation()} onClick={e => e.stopPropagation()}
> >
<h3 className="text-white text-lg font-medium mb-2">아이콘 초기화</h3> <h3 className="text-white text-lg font-medium mb-2">아이콘 삭제</h3>
<p className="text-zinc-400 text-sm mb-6"> <p className="text-zinc-400 text-sm mb-6">
<span className="text-emerald-400 font-medium">{deleteIconDialog.modId}</span> 모드의 아이콘을 초기화하시겠습니까? <span className="text-emerald-400 font-medium">{deleteIconDialog.modId}</span> 모드의 아이콘을 삭제하시겠습니까?
</p> </p>
<div className="flex gap-3"> <div className="flex gap-3">
<button <button
@ -2788,7 +2788,7 @@ export default function Admin({ isMobile = false }) {
}} }}
className="flex-1 py-2.5 bg-red-600 hover:bg-red-500 text-white font-medium rounded-xl transition-colors" className="flex-1 py-2.5 bg-red-600 hover:bg-red-500 text-white font-medium rounded-xl transition-colors"
> >
초기화 삭제
</button> </button>
</div> </div>
</motion.div> </motion.div>