feat: 아이콘 업로드 개선 - 여러 모드 동시 지원, 사전 검증

- 여러 ZIP 파일 동시 업로드 지원
- 번역본이 없는 모드는 에러로 거부
- input multiple 속성 추가
- 결과/에러 정보 개선
This commit is contained in:
caadiq 2025-12-26 19:59:08 +09:00
parent 10c27eecba
commit 7c2b887884
2 changed files with 136 additions and 93 deletions

View file

@ -881,12 +881,13 @@ router.get("/icons", requireAdmin, async (req, res) => {
}); });
/** /**
* POST /api/admin/icons/upload - 아이콘 ZIP 업로드 * POST /api/admin/icons/upload - 아이콘 ZIP 업로드 (여러 파일 지원)
* IconExporter로 생성된 ZIP 파일을 업로드하여 RustFS에 저장 * IconExporter로 생성된 ZIP 파일을 업로드하여 RustFS에 저장
* 번역본이 먼저 업로드되어 있어야
*/ */
router.post( router.post(
"/icons/upload", "/icons/upload",
express.raw({ type: "multipart/form-data", limit: "100mb" }), express.raw({ type: "multipart/form-data", limit: "200mb" }),
async (req, res) => { async (req, res) => {
try { try {
const boundary = req.headers["content-type"]?.split("boundary=")[1]; const boundary = req.headers["content-type"]?.split("boundary=")[1];
@ -897,116 +898,145 @@ router.post(
const body = req.body.toString("binary"); const body = req.body.toString("binary");
const parts = body.split(`--${boundary}`); const parts = body.split(`--${boundary}`);
let fileData = null; // 여러 ZIP 파일 수집
let fileName = ""; const zipFiles = [];
// multipart 파싱
for (const part of parts) { for (const part of parts) {
if (part.includes('name="file"')) { if (part.includes('name="file"') || part.includes('name="files"')) {
const headerEnd = part.indexOf("\r\n\r\n"); const headerEnd = part.indexOf("\r\n\r\n");
const header = Buffer.from( const header = Buffer.from(
part.slice(0, headerEnd), part.slice(0, headerEnd),
"binary" "binary"
).toString("utf8"); ).toString("utf8");
const match = header.match(/filename="([^"]+)"/); const match = header.match(/filename="([^"]+)"/);
if (match) fileName = match[1]; if (match && match[1].endsWith(".zip")) {
const dataStart = headerEnd + 4;
const dataStart = headerEnd + 4; const dataEnd = part.lastIndexOf("\r\n");
const dataEnd = part.lastIndexOf("\r\n"); const fileData = Buffer.from(
fileData = Buffer.from(part.slice(dataStart, dataEnd), "binary"); part.slice(dataStart, dataEnd),
"binary"
);
zipFiles.push({ name: match[1], data: fileData });
}
} }
} }
if (!fileData || !fileName.endsWith(".zip")) { if (zipFiles.length === 0) {
return res.status(400).json({ error: "ZIP 파일이 필요합니다" }); return res.status(400).json({ error: "ZIP 파일이 필요합니다" });
} }
// ZIP 파일 처리
const AdmZip = (await import("adm-zip")).default; const AdmZip = (await import("adm-zip")).default;
const zip = new AdmZip(fileData);
const entries = zip.getEntries();
const { uploadToS3 } = await import("../lib/s3.js"); const { uploadToS3 } = await import("../lib/s3.js");
// metadata.json에서 mod_id 읽기 const results = [];
let modId = null; const errors = [];
const metadataEntry = entries.find(
(e) => e.entryName === "metadata.json" // 각 ZIP 파일 처리
); for (const zipFile of zipFiles) {
if (metadataEntry) { const zip = new AdmZip(zipFile.data);
try { const entries = zip.getEntries();
const metadata = JSON.parse(metadataEntry.getData().toString("utf8"));
modId = metadata.mod_id; // metadata.json에서 mod_id 읽기
console.log( let modId = null;
`[Admin] ZIP metadata: mod_id=${modId}, icon_size=${metadata.icon_size}` const metadataEntry = entries.find(
); (e) => e.entryName === "metadata.json"
} catch (e) { );
console.warn("[Admin] metadata.json 파싱 실패:", e.message); if (metadataEntry) {
try {
const metadata = JSON.parse(
metadataEntry.getData().toString("utf8")
);
modId = metadata.mod_id;
} catch (e) {
errors.push({
file: zipFile.name,
error: "metadata.json 파싱 실패",
});
continue;
}
} }
}
if (!modId) { if (!modId) {
return res errors.push({
.status(400) file: zipFile.name,
.json({ error: "metadata.json에 mod_id가 없습니다" }); error: "metadata.json에 mod_id가 없습니다",
} });
let uploadedCount = 0;
let skippedCount = 0;
let updatedCount = 0;
for (const entry of entries) {
// 디렉토리 스킵
if (entry.isDirectory) continue;
// PNG 파일만 처리
if (!entry.entryName.endsWith(".png")) continue;
// 새 ZIP 구조: items/<name>.png
const pathParts = entry.entryName.split("/");
// items/brass_casing.png -> brass_casing
const itemId = pathParts[pathParts.length - 1].replace(".png", "");
// icons/items에 저장: modid_name.png
const s3Key = `icons/items/${modId}_${itemId}.png`;
// 파일 업로드
const imageData = entry.getData();
try {
await uploadToS3("minecraft", s3Key, imageData, "image/png");
} catch (s3Error) {
console.error(
`[Admin] S3 업로드 실패: ${s3Key} - ${s3Error.message}`
);
skippedCount++;
continue; continue;
} }
// items 테이블의 icon 컬럼 업데이트 (block과 item 모두) // 해당 모드가 DB에 있는지 확인
const iconUrl = `https://s3.caadiq.co.kr/minecraft/${s3Key}`; const [modCheck] = await pool.query(
`SELECT COUNT(*) as count FROM items WHERE mod_id = ?`,
const [result] = await pool.query( [modId]
`UPDATE items SET icon = ? WHERE mod_id = ? AND name = ?`,
[iconUrl, modId, itemId]
); );
if (modCheck[0].count === 0) {
if (result.affectedRows > 0) { errors.push({
updatedCount++; file: zipFile.name,
error: `모드 '${modId}'의 번역본이 먼저 업로드되어야 합니다`,
});
continue;
} }
uploadedCount++; let uploadedCount = 0;
let updatedCount = 0;
let skippedCount = 0;
for (const entry of entries) {
if (entry.isDirectory) continue;
if (!entry.entryName.endsWith(".png")) continue;
const pathParts = entry.entryName.split("/");
const itemId = pathParts[pathParts.length - 1].replace(".png", "");
const s3Key = `icons/items/${modId}_${itemId}.png`;
const imageData = entry.getData();
try {
await uploadToS3("minecraft", s3Key, imageData, "image/png");
} catch (s3Error) {
console.error(
`[Admin] S3 업로드 실패: ${s3Key} - ${s3Error.message}`
);
skippedCount++;
continue;
}
const iconUrl = `https://s3.caadiq.co.kr/minecraft/${s3Key}`;
const [result] = await pool.query(
`UPDATE items SET icon = ? WHERE mod_id = ? AND name = ?`,
[iconUrl, modId, itemId]
);
if (result.affectedRows > 0) {
updatedCount++;
}
uploadedCount++;
}
console.log(
`[Admin] 아이콘 업로드 완료 [${modId}]: ${uploadedCount}개 업로드, ${updatedCount}개 DB 업데이트`
);
results.push({
modId,
uploaded: uploadedCount,
updated: updatedCount,
skipped: skippedCount,
});
} }
console.log( // 번역 캐시 새로고침
`[Admin] 아이콘 업로드 완료 [${modId}]: ${uploadedCount}개 업로드, ${updatedCount}개 DB 업데이트, ${skippedCount}개 스킵` await loadTranslations();
);
if (errors.length > 0 && results.length === 0) {
return res.status(400).json({
success: false,
errors,
});
}
res.json({ res.json({
success: true, success: true,
uploaded: uploadedCount, results,
updated: updatedCount, errors: errors.length > 0 ? errors : undefined,
skipped: skippedCount,
modId: modId,
}); });
} catch (error) { } catch (error) {
console.error("[Admin] 아이콘 업로드 오류:", error); console.error("[Admin] 아이콘 업로드 오류:", error);

View file

@ -324,9 +324,12 @@ export default function Admin({ isMobile = false }) {
} }
}, []); }, []);
// ZIP // ZIP ( )
const handleIconZipUpload = async (file) => { const handleIconZipUpload = async (files) => {
if (!file || !file.name.endsWith('.zip')) { const fileArray = Array.isArray(files) ? files : [files];
const zipFiles = fileArray.filter(f => f && f.name.endsWith('.zip'));
if (zipFiles.length === 0) {
setToast('ZIP 파일만 업로드 가능합니다', true); setToast('ZIP 파일만 업로드 가능합니다', true);
return; return;
} }
@ -335,7 +338,7 @@ export default function Admin({ isMobile = false }) {
try { try {
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); zipFiles.forEach(file => formData.append('files', file));
const res = await fetch('/api/admin/icons/upload', { const res = await fetch('/api/admin/icons/upload', {
method: 'POST', method: 'POST',
@ -345,10 +348,19 @@ export default function Admin({ isMobile = false }) {
const data = await res.json(); const data = await res.json();
if (data.success) { if (data.success) {
setToast(`아이콘 업로드 완료: ${data.uploaded}개 업로드, ${data.updated}개 DB 업데이트`); const totalUploaded = data.results?.reduce((sum, r) => sum + r.uploaded, 0) || 0;
const totalUpdated = data.results?.reduce((sum, r) => sum + r.updated, 0) || 0;
const modIds = data.results?.map(r => r.modId).join(', ') || '';
setToast(`아이콘 업로드 완료: ${modIds} (${totalUploaded}개 업로드, ${totalUpdated}개 DB 업데이트)`);
//
if (data.errors?.length > 0) {
console.warn('아이콘 업로드 일부 실패:', data.errors);
}
fetchIconMods(); fetchIconMods();
} else { } else {
setToast(data.error || '업로드 실패', true); const errorMsg = data.errors?.map(e => `${e.file}: ${e.error}`).join('\n') || data.error || '업로드 실패';
setToast(errorMsg, true);
} }
} catch (error) { } catch (error) {
console.error('아이콘 업로드 실패:', error); console.error('아이콘 업로드 실패:', error);
@ -2130,17 +2142,18 @@ export default function Admin({ isMobile = false }) {
onDrop={(e) => { onDrop={(e) => {
e.preventDefault(); e.preventDefault();
setIsIconDragging(false); setIsIconDragging(false);
const file = e.dataTransfer.files[0]; const files = Array.from(e.dataTransfer.files);
if (file) handleIconZipUpload(file); if (files.length > 0) handleIconZipUpload(files);
}} }}
> >
<input <input
type="file" type="file"
accept=".zip" accept=".zip"
multiple
className="hidden" className="hidden"
onChange={(e) => { onChange={(e) => {
const file = e.target.files[0]; const files = Array.from(e.target.files);
if (file) handleIconZipUpload(file); if (files.length > 0) handleIconZipUpload(files);
e.target.value = ''; e.target.value = '';
}} }}
/> />