import { NextRequest, NextResponse } from "next/server"; import * as fs from "fs/promises"; import * as path from "path"; import { fetchGetVDRDocumentList, fetchGetExternalInboxList } from "@/lib/swp/api-client"; import { debugLog, debugError, debugSuccess } from "@/lib/debug-utils"; // API Route 설정 export const runtime = "nodejs"; export const maxDuration = 300; // 5분 타임아웃 (대용량 파일 업로드 대응) 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; } /** * 파일명 파싱: [DOC_NO]_[REV_NO]_[STAGE].[확장자] 또는 [DOC_NO]_[REV_NO]_[STAGE]_[자유-파일명].[확장자] * 자유 파일명은 선택사항이며, 포함될 경우 언더스코어를 포함할 수 있음 */ function parseFileName(fileName: string) { 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개 파트 필요: docNo, revNo, stage (fileName은 선택사항) if (parts.length < 3) { throw new Error( `잘못된 파일명 형식입니다: ${fileName}. ` + `형식: [DOC_NO]_[REV_NO]_[STAGE].[확장자] (언더스코어 최소 2개 필요)` ); } // 앞에서부터 3개는 고정: docNo, 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(`문서번호(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}`; } /** * CPY_CD 조회 (API 기반) * GetVDRDocumentList API를 호출하여 해당 프로젝트/벤더의 CPY_CD를 조회 */ async function getCpyCdForVendor(projNo: string, vndrCd: string): Promise { try { console.log(`[getCpyCdForVendor] API 조회 시작: projNo=${projNo}, vndrCd=${vndrCd}`); // GetVDRDocumentList API 호출 (벤더 필터 적용) const documents = await fetchGetVDRDocumentList({ proj_no: projNo, doc_gb: "V", vndrCd: vndrCd, }); console.log(`[getCpyCdForVendor] API 조회 완료: ${documents.length}개 문서`); if (!documents || documents.length === 0) { throw new Error( `프로젝트 ${projNo}에서 벤더 코드 ${vndrCd}에 할당된 문서가 없습니다.` ); } // 첫 번째 문서에서 CPY_CD 추출 const cpyCd = documents[0].CPY_CD; if (!cpyCd) { throw new Error( `프로젝트 ${projNo}에서 벤더 코드 ${vndrCd}에 해당하는 회사 코드(CPY_CD)를 찾을 수 없습니다.` ); } console.log(`[getCpyCdForVendor] CPY_CD 확인: ${cpyCd}`); return cpyCd; } catch (error) { console.error("[getCpyCdForVendor] 오류:", error); throw error; } } /** * SaveInBoxList API 호출 */ async function callSaveInBoxList(fileInfos: InBoxFileInfo[]): 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 }; 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 * FormData로 파일 업로드 */ export async function POST(request: NextRequest) { try { const formData = await request.formData(); const projNo = formData.get("projNo") as string; const vndrCd = formData.get("vndrCd") as string; if (!projNo || !vndrCd) { return NextResponse.json( { success: false, message: "projNo와 vndrCd는 필수입니다." }, { status: 400 } ); } // CPY_CD 조회 console.log(`[upload] CPY_CD 조회: projNo=${projNo}, vndrCd=${vndrCd}`); const cpyCd = await getCpyCdForVendor(projNo, vndrCd); console.log(`[upload] CPY_CD: ${cpyCd}`); const files = formData.getAll("files") as File[]; if (!files || files.length === 0) { return NextResponse.json( { success: false, message: "업로드할 파일이 없습니다." }, { status: 400 } ); } const result = { successCount: 0, failedCount: 0, details: [] as Array<{ fileName: string; success: boolean; error?: string; networkPath?: string }>, }; const inBoxFileInfos: InBoxFileInfo[] = []; const swpMountDir = process.env.SWP_MOUNT_DIR || "/mnt/swp-smb-dir/"; // 업로드 시점의 timestamp 생성 (모든 파일에 동일한 timestamp 사용) const uploadTimestamp = generateTimestamp(); console.log(`[upload] 업로드 타임스탬프 생성: ${uploadTimestamp}`); for (const file of files) { try { // 파일명 파싱 const parsed = parseFileName(file.name); console.log(`[upload] 파일명 파싱:`, parsed); // 네트워크 경로 생성 (timestamp를 경로에만 사용) const networkPath = path.join(swpMountDir, projNo, cpyCd, uploadTimestamp, file.name); // 파일 중복 체크 try { await fs.access(networkPath, fs.constants.F_OK); result.failedCount++; result.details.push({ fileName: file.name, success: false, error: "파일이 이미 존재합니다.", }); console.warn(`[upload] 파일 중복: ${networkPath}`); continue; } catch { // 파일이 존재하지 않음 (정상) } // 디렉토리 생성 const directory = path.dirname(networkPath); await fs.mkdir(directory, { recursive: true }); // 파일 저장 (스트리밍 방식) const arrayBuffer = await file.arrayBuffer(); debugLog(`[upload] ArrayBuffer 변환 완료: ${file.name}`, { arrayBufferSize: arrayBuffer.byteLength, fileType: file.type, originalSize: file.size }); const buffer = Buffer.from(arrayBuffer); debugLog(`[upload] Buffer 생성 완료: ${file.name}`, { bufferLength: buffer.length, bufferType: typeof buffer, isBuffer: Buffer.isBuffer(buffer), first20Bytes: buffer.slice(0, 20).toString('hex') }); console.log(`[upload] 파일 저장: ${file.name} (${buffer.length} bytes)`); // 저장 전 buffer 상태 확인 debugLog(`[upload] 저장 직전 buffer 상태`, { constructor: buffer.constructor.name, isBuffer: Buffer.isBuffer(buffer), jsonStringified: JSON.stringify(buffer).substring(0, 100) + '...' }); await fs.writeFile(networkPath, buffer); debugSuccess(`[upload] 파일 저장 완료: ${networkPath}`); // 저장된 파일 검증 const savedFileStats = await fs.stat(networkPath); debugLog(`[upload] 저장된 파일 정보`, { size: savedFileStats.size, expectedSize: buffer.length, sizeMatch: savedFileStats.size === buffer.length }); // 저장된 파일 첫 부분 읽어서 검증 const verifyBuffer = await fs.readFile(networkPath); debugLog(`[upload] 저장된 파일 검증`, { readSize: verifyBuffer.length, first20Bytes: verifyBuffer.slice(0, 20).toString('hex'), isBuffer: Buffer.isBuffer(verifyBuffer), matchesOriginal: buffer.slice(0, 20).equals(verifyBuffer.slice(0, 20)) }); // InBox 파일 정보 준비 (FLD_PATH에 업로드 timestamp 사용) const fldPath = `\\${projNo}\\${cpyCd}\\\\${uploadTimestamp}`; inBoxFileInfos.push({ CPY_CD: cpyCd, FILE_NM: file.name, OFDC_NO: null, PROJ_NO: projNo, OWN_DOC_NO: parsed.ownDocNo, REV_NO: parsed.revNo, STAGE: parsed.stage, STAT: "SCW01", FILE_SZ: String(buffer.length), FLD_PATH: fldPath, }); result.successCount++; result.details.push({ fileName: file.name, success: true, networkPath, }); } catch (error) { result.failedCount++; result.details.push({ fileName: file.name, success: false, error: error instanceof Error ? error.message : "알 수 없는 오류", }); console.error(`[upload] 파일 처리 실패: ${file.name}`, error); debugError(`[upload] 파일 처리 실패: ${file.name}`, { error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined }); } } // SaveInBoxList API 호출 if (inBoxFileInfos.length > 0) { console.log(`[upload] SaveInBoxList API 호출: ${inBoxFileInfos.length}개 파일`); await callSaveInBoxList(inBoxFileInfos); } // ⚠️ 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 } ); } }