From 7c2b887884baca48b493a1abbd60c885f8b58565 Mon Sep 17 00:00:00 2001 From: caadiq Date: Fri, 26 Dec 2025 19:59:08 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=95=84=EC=9D=B4=EC=BD=98=20=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20=EA=B0=9C=EC=84=A0=20-=20=EC=97=AC?= =?UTF-8?q?=EB=9F=AC=20=EB=AA=A8=EB=93=9C=20=EB=8F=99=EC=8B=9C=20=EC=A7=80?= =?UTF-8?q?=EC=9B=90,=20=EC=82=AC=EC=A0=84=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 여러 ZIP 파일 동시 업로드 지원 - 번역본이 없는 모드는 에러로 거부 - input multiple 속성 추가 - 결과/에러 정보 개선 --- backend/routes/admin.js | 196 ++++++++++++++++++++--------------- frontend/src/pages/Admin.jsx | 33 ++++-- 2 files changed, 136 insertions(+), 93 deletions(-) diff --git a/backend/routes/admin.js b/backend/routes/admin.js index 97f11db..632e746 100644 --- a/backend/routes/admin.js +++ b/backend/routes/admin.js @@ -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에 저장 + * 번역본이 먼저 업로드되어 있어야 함 */ router.post( "/icons/upload", - express.raw({ type: "multipart/form-data", limit: "100mb" }), + express.raw({ type: "multipart/form-data", limit: "200mb" }), async (req, res) => { try { const boundary = req.headers["content-type"]?.split("boundary=")[1]; @@ -897,116 +898,145 @@ router.post( const body = req.body.toString("binary"); const parts = body.split(`--${boundary}`); - let fileData = null; - let fileName = ""; - - // multipart 파싱 + // 여러 ZIP 파일 수집 + const zipFiles = []; 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 header = Buffer.from( part.slice(0, headerEnd), "binary" ).toString("utf8"); const match = header.match(/filename="([^"]+)"/); - if (match) fileName = match[1]; - - const dataStart = headerEnd + 4; - const dataEnd = part.lastIndexOf("\r\n"); - fileData = Buffer.from(part.slice(dataStart, dataEnd), "binary"); + if (match && match[1].endsWith(".zip")) { + const dataStart = headerEnd + 4; + const dataEnd = part.lastIndexOf("\r\n"); + const fileData = Buffer.from( + 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 파일이 필요합니다" }); } - // ZIP 파일 처리 const AdmZip = (await import("adm-zip")).default; - const zip = new AdmZip(fileData); - const entries = zip.getEntries(); - const { uploadToS3 } = await import("../lib/s3.js"); - // metadata.json에서 mod_id 읽기 - let modId = null; - const metadataEntry = entries.find( - (e) => e.entryName === "metadata.json" - ); - if (metadataEntry) { - try { - const metadata = JSON.parse(metadataEntry.getData().toString("utf8")); - modId = metadata.mod_id; - console.log( - `[Admin] ZIP metadata: mod_id=${modId}, icon_size=${metadata.icon_size}` - ); - } catch (e) { - console.warn("[Admin] metadata.json 파싱 실패:", e.message); + const results = []; + const errors = []; + + // 각 ZIP 파일 처리 + for (const zipFile of zipFiles) { + const zip = new AdmZip(zipFile.data); + const entries = zip.getEntries(); + + // metadata.json에서 mod_id 읽기 + let modId = null; + const metadataEntry = entries.find( + (e) => e.entryName === "metadata.json" + ); + 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) { - return res - .status(400) - .json({ 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/.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++; + if (!modId) { + errors.push({ + file: zipFile.name, + error: "metadata.json에 mod_id가 없습니다", + }); continue; } - // items 테이블의 icon 컬럼 업데이트 (block과 item 모두) - 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] + // 해당 모드가 DB에 있는지 확인 + const [modCheck] = await pool.query( + `SELECT COUNT(*) as count FROM items WHERE mod_id = ?`, + [modId] ); - - if (result.affectedRows > 0) { - updatedCount++; + if (modCheck[0].count === 0) { + errors.push({ + 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({ success: true, - uploaded: uploadedCount, - updated: updatedCount, - skipped: skippedCount, - modId: modId, + results, + errors: errors.length > 0 ? errors : undefined, }); } catch (error) { console.error("[Admin] 아이콘 업로드 오류:", error); diff --git a/frontend/src/pages/Admin.jsx b/frontend/src/pages/Admin.jsx index 165be26..a588d58 100644 --- a/frontend/src/pages/Admin.jsx +++ b/frontend/src/pages/Admin.jsx @@ -324,9 +324,12 @@ export default function Admin({ isMobile = false }) { } }, []); - // 아이콘 ZIP 업로드 - const handleIconZipUpload = async (file) => { - if (!file || !file.name.endsWith('.zip')) { + // 아이콘 ZIP 업로드 (여러 파일 지원) + const handleIconZipUpload = async (files) => { + const fileArray = Array.isArray(files) ? files : [files]; + const zipFiles = fileArray.filter(f => f && f.name.endsWith('.zip')); + + if (zipFiles.length === 0) { setToast('ZIP 파일만 업로드 가능합니다', true); return; } @@ -335,7 +338,7 @@ export default function Admin({ isMobile = false }) { try { const token = localStorage.getItem('token'); const formData = new FormData(); - formData.append('file', file); + zipFiles.forEach(file => formData.append('files', file)); const res = await fetch('/api/admin/icons/upload', { method: 'POST', @@ -345,10 +348,19 @@ export default function Admin({ isMobile = false }) { const data = await res.json(); 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(); } else { - setToast(data.error || '업로드 실패', true); + const errorMsg = data.errors?.map(e => `${e.file}: ${e.error}`).join('\n') || data.error || '업로드 실패'; + setToast(errorMsg, true); } } catch (error) { console.error('아이콘 업로드 실패:', error); @@ -2130,17 +2142,18 @@ export default function Admin({ isMobile = false }) { onDrop={(e) => { e.preventDefault(); setIsIconDragging(false); - const file = e.dataTransfer.files[0]; - if (file) handleIconZipUpload(file); + const files = Array.from(e.dataTransfer.files); + if (files.length > 0) handleIconZipUpload(files); }} > { - const file = e.target.files[0]; - if (file) handleIconZipUpload(file); + const files = Array.from(e.target.files); + if (files.length > 0) handleIconZipUpload(files); e.target.value = ''; }} />