import { NextRequest, NextResponse } from "next/server"; import * as fs from "fs/promises"; import * as path from "path"; import { getServerSession } from "next-auth"; import { authOptions } from "@/app/api/auth/[...nextauth]/route"; import { debugLog, debugError, debugSuccess } from "@/lib/debug-utils"; import formidable, { Fields, Files, File as FormidableFile } from "formidable"; import { Readable } from "stream"; // API Route 설정 export const runtime = "nodejs"; export const maxDuration = 3600; // 1시간 타임아웃 (대용량 파일 업로드 대응) // Next.js 15 API Route body parsing 비활성화 (스트리밍 처리를 위해) export const dynamic = 'force-dynamic'; /** * formidable을 사용하여 스트리밍 방식으로 파일 파싱 * 메모리에 전체 파일을 올리지 않고 chunk 단위로 처리 */ async function parseFormWithFormidable(req: NextRequest): Promise<{ fields: Fields; files: Files }> { const uploadDir = process.env.TEMP_UPLOAD_DIR || "/tmp/swp-upload"; const form = formidable({ maxFileSize: 1024 * 1024 * 1024, // 1GB 제한 maxFieldsSize: 20 * 1024 * 1024, // 20MB 메타데이터 제한 allowEmptyFiles: false, multiples: true, // 여러 파일 업로드 허용 keepExtensions: true, // 임시 디렉토리에 파일 저장 (스트리밍 방식) uploadDir, filename: (_name, _ext, part) => { // 원본 파일명 유지 return part.originalFilename || `upload_${Date.now()}`; }, }); // 임시 디렉토리 생성 await fs.mkdir(uploadDir, { recursive: true }); // Next.js Request를 Node.js IncomingMessage 형태로 변환 // formidable은 headers 정보가 필요함 (특히 content-length, content-type) // ReadableStream을 AsyncIterable로 변환 async function* streamToAsyncIterable(stream: ReadableStream) { const reader = stream.getReader(); try { while (true) { const { done, value } = await reader.read(); if (done) break; yield value; } } finally { reader.releaseLock(); } } const bodyStream = req.body ? Readable.from(streamToAsyncIterable(req.body as ReadableStream)) : Readable.from([]); // headers 정보 추가 (formidable이 필요로 함) // eslint-disable-next-line @typescript-eslint/no-explicit-any const mockIncomingMessage: any = Object.assign(bodyStream, { headers: { 'content-type': req.headers.get('content-type') || '', 'content-length': req.headers.get('content-length') || '0', }, method: req.method, url: req.url, }); return new Promise((resolve, reject) => { form.parse(mockIncomingMessage, (err, fields, files) => { if (err) { reject(err); return; } resolve({ fields, files }); }); }); } interface InBoxFileInfo { CPY_CD: string; FILE_NM: string; OFDC_NO: string | null; PROJ_NO: string; OWN_DOC_NO: string; REV_NO: string; STAGE: string; STAT: string; FILE_SZ: string; FLD_PATH: string; } /** * 파일명 파싱: [OWN_DOC_NO]_[REV_NO]_[STAGE].[확장자] 또는 [OWN_DOC_NO]_[REV_NO]_[STAGE]_[자유-파일명].[확장자] * 자유 파일명은 선택사항이며, 포함될 경우 언더스코어를 포함할 수 있음 */ function parseFileName(fileName: string) { // 경로 순회 공격 방지 (Path Traversal Attack) if (fileName.includes("..") || fileName.includes("/") || fileName.includes("\\")) { throw new Error(`잘못된 파일명입니다 (경로 문자 포함): ${fileName}`); } const lastDotIndex = fileName.lastIndexOf("."); // 확장자 검증 if (lastDotIndex === -1) { throw new Error(`파일 확장자가 없습니다: ${fileName}`); } const extension = fileName.substring(lastDotIndex + 1); const nameWithoutExt = fileName.substring(0, lastDotIndex); const parts = nameWithoutExt.split("_"); // 최소 3개 파트 필요: ownDocNo, revNo, stage (fileName은 선택사항) if (parts.length < 3) { throw new Error( `잘못된 파일명 형식입니다: ${fileName}. ` + `형식: [OWN_DOC_NO]_[REV_NO]_[STAGE].[확장자] (언더스코어 최소 2개 필요)` ); } // 앞에서부터 3개는 고정: ownDocNo, revNo, stage const ownDocNo = parts[0]; const revNo = parts[1]; const stage = parts[2]; // 나머지는 자유 파일명 (선택사항, 언더스코어 포함 가능) const customFileName = parts.length > 3 ? parts.slice(3).join("_") : ""; // 필수 항목이 비어있지 않은지 확인 if (!ownDocNo || ownDocNo.trim() === "") { throw new Error(`문서번호(OWN_DOC_NO)가 비어있습니다: ${fileName}`); } if (!revNo || revNo.trim() === "") { throw new Error(`리비전 번호(REV_NO)가 비어있습니다: ${fileName}`); } if (!stage || stage.trim() === "") { throw new Error(`스테이지(STAGE)가 비어있습니다: ${fileName}`); } return { ownDocNo: ownDocNo.trim(), revNo: revNo.trim(), stage: stage.trim(), fileName: customFileName.trim(), extension }; } /** * 현재 시간을 YYYYMMDDhhmmss 형식으로 반환 */ function generateTimestamp(): string { const now = new Date(); const year = now.getFullYear().toString(); const month = (now.getMonth() + 1).toString().padStart(2, "0"); const day = now.getDate().toString().padStart(2, "0"); const hours = now.getHours().toString().padStart(2, "0"); const minutes = now.getMinutes().toString().padStart(2, "0"); const seconds = now.getSeconds().toString().padStart(2, "0"); return `${year}${month}${day}${hours}${minutes}${seconds}`; } /** * 디스크 공간 검사 (간단한 휴리스틱 방식) * 디렉토리에 쓰기 권한이 있는지 확인 * * 참고: 실제 디스크 공간 체크를 위해서는 'check-disk-space' 라이브러리 사용으로 변경하기 */ async function checkDiskWritable(directory: string): Promise { try { // 테스트 쓰기로 권한 검증 await fs.mkdir(directory, { recursive: true }); const testFile = path.join(directory, `.disk-check-${Date.now()}`); await fs.writeFile(testFile, "test"); await fs.unlink(testFile); return true; } catch (error) { console.error(`[checkDiskWritable] 디스크 쓰기 권한 검사 실패: ${error}`); return false; } } /** * SaveInBoxList API 호출 */ async function callSaveInBoxList(fileInfos: InBoxFileInfo[], crter: string, crteremail: string): Promise { const ddcUrl = process.env.DDC_BASE_URL || "http://60.100.99.217/DDC/Services/WebService.svc"; const url = `${ddcUrl}/SaveInBoxList`; const request = { externalInboxLists: fileInfos, crter: crter, crteremail: crteremail }; console.log("[callSaveInBoxList] 요청:", JSON.stringify(request, null, 2)); const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json", }, body: JSON.stringify(request), }); if (!response.ok) { const errorText = await response.text(); throw new Error(`SaveInBoxList API 호출 실패: ${response.statusText} - ${errorText}`); } const data = await response.json(); console.log("[callSaveInBoxList] 응답:", JSON.stringify(data, null, 2)); // SaveInBoxListResult는 성공한 FLD_PATH들을 쉼표로 구분한 문자열 // 예: "\\\\ProjNo\\\\CpyCd\\\\YYYYMMDDhhmmss, \\\\ProjNo\\\\CpyCd\\\\YYYYMMDDhhmmss" if (!data.SaveInBoxListResult) { throw new Error("SaveInBoxList API 실패: 응답에 SaveInBoxListResult가 없습니다."); } const result = data.SaveInBoxListResult; // 문자열 응답인 경우 (정상) if (typeof result === "string") { if (result.trim().length === 0) { throw new Error("SaveInBoxList API 실패: 빈 응답이 반환되었습니다."); } // 성공한 FLD_PATH 개수 로깅 const successPaths = result.split(",").map(p => p.trim()).filter(p => p.length > 0); console.log(`[callSaveInBoxList] 성공: ${successPaths.length}개 파일 등록 완료`); return; } // 객체 응답인 경우 (레거시 또는 에러) if (typeof result === "object" && result !== null) { const objResult = result as { success?: boolean; message?: string }; if (objResult.success === false) { throw new Error( `SaveInBoxList API 실패: ${objResult.message || "알 수 없는 오류"}` ); } } } /** * POST /api/swp/upload * 스트리밍 방식으로 파일 업로드 (대용량 파일 지원) */ export async function POST(request: NextRequest) { try { // 환경 변수 검증 const swpMountDir = process.env.SWP_MOUNT_DIR; const ddcBaseUrl = process.env.DDC_BASE_URL; if (!swpMountDir) { return NextResponse.json( { success: false, message: "서버 설정 오류: SWP_MOUNT_DIR 환경 변수가 설정되지 않았습니다." }, { status: 500 } ); } if (!ddcBaseUrl) { return NextResponse.json( { success: false, message: "서버 설정 오류: DDC_BASE_URL 환경 변수가 설정되지 않았습니다." }, { status: 500 } ); } // 세션에서 사용자 ID와 이메일 가져오기 const session = await getServerSession(authOptions); if (!session?.user?.id) { return NextResponse.json( { success: false, message: "인증되지 않은 사용자입니다." }, { status: 401 } ); } const crter = String(session.user.id); // 사용자 ID를 문자열로 변환 const crteremail = session.user.email || ""; // 사용자 이메일 console.log(`[upload] 사용자 ID (crter): ${crter}, 이메일 (crteremail): ${crteremail}`); // 스트리밍 방식으로 FormData 파싱 debugLog("[upload] 스트리밍 파싱 시작..."); const { fields, files } = await parseFormWithFormidable(request); debugSuccess("[upload] 스트리밍 파싱 완료"); // 필드 추출 const projNoArray = fields.projNo; const vndrCdArray = fields.vndrCd; const projNo = Array.isArray(projNoArray) ? projNoArray[0] : projNoArray; const vndrCd = Array.isArray(vndrCdArray) ? vndrCdArray[0] : vndrCdArray; if (!projNo || !vndrCd) { return NextResponse.json( { success: false, message: "projNo와 vndrCd는 필수입니다." }, { status: 400 } ); } // vndrCd를 CPY_CD로 사용 console.log(`[upload] vndrCd를 CPY_CD로 사용: ${vndrCd}`); // 파일 배열 추출 (formidable은 files.files로 저장) const uploadedFiles = files.files; if (!uploadedFiles || (Array.isArray(uploadedFiles) && uploadedFiles.length === 0)) { return NextResponse.json( { success: false, message: "업로드할 파일이 없습니다." }, { status: 400 } ); } // 단일 파일인 경우 배열로 변환 const fileArray = Array.isArray(uploadedFiles) ? uploadedFiles : [uploadedFiles]; const result = { successCount: 0, failedCount: 0, details: [] as Array<{ fileName: string; success: boolean; error?: string; networkPath?: string }>, }; const inBoxFileInfos: InBoxFileInfo[] = []; // 업로드 시점의 timestamp 생성 (모든 파일에 동일한 timestamp 사용) const uploadTimestamp = generateTimestamp(); console.log(`[upload] 업로드 타임스탬프 생성: ${uploadTimestamp}`); // 디스크 쓰기 권한 사전 검사 const targetDirectory = path.join(swpMountDir, projNo, vndrCd, uploadTimestamp); const isWritable = await checkDiskWritable(targetDirectory); if (!isWritable) { return NextResponse.json( { success: false, message: "파일 저장 경로에 쓰기 권한이 없습니다." }, { status: 500 } ); } // 임시 파일 경로 저장 (정리용) const tempFilesToClean: string[] = []; // 성공적으로 저장된 파일 경로 (롤백용) const savedFiles: string[] = []; for (const file of fileArray) { try { const formidableFile = file as FormidableFile; const originalFileName = formidableFile.originalFilename || "unknown"; // 파일명 파싱 const parsed = parseFileName(originalFileName); debugLog(`[upload] 파일명 파싱:`, { originalFileName, parsed }); // 네트워크 경로 생성 (timestamp를 경로에만 사용) const networkPath = path.join(swpMountDir, projNo, vndrCd, uploadTimestamp, originalFileName); // 파일 중복 체크 try { await fs.access(networkPath, fs.constants.F_OK); result.failedCount++; result.details.push({ fileName: originalFileName, success: false, error: "파일이 이미 존재합니다.", }); console.warn(`[upload] 파일 중복: ${networkPath}`); // 임시 파일 정리 tempFilesToClean.push(formidableFile.filepath); continue; } catch { // 파일이 존재하지 않음 (정상) } // 디렉토리 생성 const directory = path.dirname(networkPath); await fs.mkdir(directory, { recursive: true }); // 🚀 스트리밍 방식: 임시 파일을 최종 경로로 이동 (메모리 복사 없음) const tempFilePath = formidableFile.filepath; const fileSize = formidableFile.size; debugLog(`[upload] 파일 이동 시작: ${originalFileName}`, { tempPath: tempFilePath, finalPath: networkPath, size: fileSize }); // 파일 이동 (rename이 더 빠르지만, 같은 파일시스템이 아니면 실패하므로 copyFile 사용) // 리네임은 같은 시스템 안에서, 파일 메타데이터만 변경해서 빠른데 swp는 외부시스템이므로 항상 캐치문에 걸릴것임 try { await fs.rename(tempFilePath, networkPath); debugSuccess(`[upload] 파일 이동 완료 (rename): ${networkPath}`); } catch (renameError) { // rename 실패 시 copyFile + unlink 사용 debugLog(`[upload] rename 실패(네트워크경로 - 정상임), copyFile 사용: ${originalFileName}`); await fs.copyFile(tempFilePath, networkPath); tempFilesToClean.push(tempFilePath); debugSuccess(`[upload] 파일 복사 완료: ${networkPath}`); } // 저장된 파일 검증 const savedFileStats = await fs.stat(networkPath); debugLog(`[upload] 저장된 파일 검증`, { fileName: originalFileName, savedSize: savedFileStats.size, expectedSize: fileSize, sizeMatch: savedFileStats.size === fileSize }); if (savedFileStats.size !== fileSize) { throw new Error(`파일 크기 불일치: 예상 ${fileSize} bytes, 실제 ${savedFileStats.size} bytes`); } // InBox 파일 정보 준비 (FLD_PATH에 업로드 timestamp 사용) // 시스템 요구사항: 중간 구분자만 이스케이프 (\\\\) const fldPath = `\\${projNo}\\${vndrCd}\\\\${uploadTimestamp}`; inBoxFileInfos.push({ CPY_CD: vndrCd, FILE_NM: originalFileName, OFDC_NO: null, PROJ_NO: projNo, OWN_DOC_NO: parsed.ownDocNo, REV_NO: parsed.revNo, STAGE: parsed.stage, STAT: "SCW01", FILE_SZ: String(fileSize), FLD_PATH: fldPath, }); // 성공한 파일 경로 저장 (롤백용) savedFiles.push(networkPath); result.successCount++; result.details.push({ fileName: originalFileName, success: true, networkPath, }); } catch (error) { const formidableFile = file as FormidableFile; const originalFileName = formidableFile.originalFilename || "unknown"; result.failedCount++; result.details.push({ fileName: originalFileName, success: false, error: error instanceof Error ? error.message : "알 수 없는 오류", }); console.error(`[upload] 파일 처리 실패: ${originalFileName}`, error); debugError(`[upload] 파일 처리 실패: ${originalFileName}`, { error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined }); // 임시 파일 정리 if (formidableFile.filepath) { tempFilesToClean.push(formidableFile.filepath); } } } // 임시 파일 정리 for (const tempFile of tempFilesToClean) { try { await fs.unlink(tempFile); debugLog(`[upload] 임시 파일 삭제: ${tempFile}`); } catch (cleanError) { console.warn(`[upload] 임시 파일 삭제 실패 (무시): ${tempFile}`, cleanError); } } // SaveInBoxList API 호출 (트랜잭션 방식) if (inBoxFileInfos.length > 0) { console.log(`[upload] SaveInBoxList API 호출: ${inBoxFileInfos.length}개 파일`); try { await callSaveInBoxList(inBoxFileInfos, crter, crteremail); } catch (apiError) { // API 호출 실패 시 롤백: 저장된 파일 삭제 console.error(`[upload] SaveInBoxList API 실패, 롤백 시작...`, apiError); debugError(`[upload] SaveInBoxList API 실패, 롤백 시작`, { error: apiError instanceof Error ? apiError.message : String(apiError), filesCount: savedFiles.length }); for (const filePath of savedFiles) { try { await fs.unlink(filePath); console.log(`[upload] 롤백: 파일 삭제 - ${filePath}`); } catch (unlinkError) { console.warn(`[upload] 롤백 중 파일 삭제 실패 (무시): ${filePath}`, unlinkError); } } // 클라이언트에게 에러 반환 return NextResponse.json( { success: false, message: `파일 등록 API 호출 실패: ${apiError instanceof Error ? apiError.message : "알 수 없는 오류"}`, rollback: true, }, { status: 500 } ); } } // ⚠️ Full API 방식으로 전환했으므로 로컬 DB 동기화는 불필요 // 업로드 성공 시 SaveInBoxList API 호출만으로 충분 (이미 위에서 완료) // 결과 메시지 생성 let message: string; let success: boolean; if (result.failedCount === 0) { success = true; message = `${result.successCount}개 파일이 성공적으로 업로드되었습니다.`; } else if (result.successCount === 0) { success = false; message = `모든 파일 업로드에 실패했습니다. (${result.failedCount}개)`; } else { success = true; message = `${result.successCount}개 파일 업로드 성공, ${result.failedCount}개 실패`; } console.log(`[upload] 완료:`, { success, message, result }); return NextResponse.json({ success, message, successCount: result.successCount, failedCount: result.failedCount, details: result.details, uploadTimestamp: new Date().toISOString(), affectedVndrCd: vndrCd, }); } catch (error) { console.error("[upload] 오류:", error); debugError(`[upload] 전체 프로세스 실패`, { error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined }); return NextResponse.json( { success: false, message: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.", }, { status: 500 } ); } }