feat: 아이콘 업로드 개선 - 여러 모드 동시 지원, 사전 검증
- 여러 ZIP 파일 동시 업로드 지원 - 번역본이 없는 모드는 에러로 거부 - input multiple 속성 추가 - 결과/에러 정보 개선
This commit is contained in:
parent
10c27eecba
commit
7c2b887884
2 changed files with 136 additions and 93 deletions
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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 = '';
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue