import { NextRequest, NextResponse } from "next/server"; import formidable from "formidable"; import { Readable } from "stream"; import type { IncomingMessage } from "http"; const DOLCE_API_URL = process.env.DOLCE_API_URL || "http://60.100.99.217:1111"; // Next.js API Route 설정 export const maxDuration = 3600; // 1시간 (대용량 파일 업로드 지원, 느린 연결 대응) export const dynamic = "force-dynamic"; // body parser 설정 (대용량 파일 지원) export const config = { api: { bodyParser: false, // formidable이 직접 파싱하도록 비활성화 responseLimit: false, // 응답 크기 제한 없음 }, }; /** * NextRequest를 Node.js IncomingMessage 스타일로 변환 * Next.js의 request.body를 직접 스트림으로 사용 (메모리 로드 없음) */ function convertToNodeRequest(request: NextRequest) { // Next.js의 ReadableStream을 Node.js Readable로 변환 const webReadableStream = request.body; if (!webReadableStream) { throw new Error("Request body is null"); } // Web ReadableStream을 Node.js Readable로 변환 // Node.js 17+의 Readable.fromWeb 사용 // @ts-expect-error - Web Streams API와 Node.js Streams의 타입 차이 const nodeReadable = Readable.fromWeb(webReadableStream); // Node.js IncomingMessage 필수 속성 추가 const nodeRequest = nodeReadable as Readable & { headers: Record; method: string; url: string; httpVersion: string; httpVersionMajor: number; httpVersionMinor: number; }; nodeRequest.headers = Object.fromEntries(request.headers.entries()); nodeRequest.method = request.method; nodeRequest.url = request.url; nodeRequest.httpVersion = "1.1"; nodeRequest.httpVersionMajor = 1; nodeRequest.httpVersionMinor = 1; return nodeRequest; } /** * 스트림을 DOLCE API로 즉시 전송 (임시 저장 없음) */ async function uploadStreamToDolce( fileStream: NodeJS.ReadableStream, fileName: string, fileSize: number, uploadId: string, fileId: string ): Promise { const uploadUrl = `${DOLCE_API_URL}/PWPUploadService.ashx?UploadId=${uploadId}&FileId=${fileId}`; const startTime = Date.now(); console.log(`[Proxy] 파일 스트리밍 시작: ${fileName} (${(fileSize / 1024 / 1024).toFixed(2)}MB)`); let bytesUploaded = 0; const logInterval = setInterval(() => { const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); const progress = fileSize > 0 ? ((bytesUploaded / fileSize) * 100).toFixed(1) : "0.0"; console.log(`[Proxy] 진행 중: ${fileName} - ${progress}% (${elapsed}초)`); }, 10000); // 10초마다 로그 // 진행도 추적을 위한 PassThrough 스트림 const { PassThrough } = await import("stream"); const progressStream = new PassThrough(); fileStream.on("data", (chunk: Buffer) => { bytesUploaded += chunk.length; }); fileStream.pipe(progressStream); // Web ReadableStream으로 변환 const webStream = new ReadableStream({ start(controller) { progressStream.on("data", (chunk: Buffer) => { controller.enqueue(new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength)); }); progressStream.on("end", () => { clearInterval(logInterval); const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); console.log(`[Proxy] 스트림 전송 완료: ${fileName} (${elapsed}초)`); controller.close(); }); progressStream.on("error", (error) => { clearInterval(logInterval); console.error(`[Proxy] 스트림 에러: ${fileName}`, error); controller.error(error); }); }, cancel() { clearInterval(logInterval); progressStream.destroy(); // fileStream 정리 if (fileStream && "destroy" in fileStream && typeof fileStream.destroy === "function") { fileStream.destroy(); } }, }); try { const controller = new AbortController(); const timeoutId = setTimeout(() => { controller.abort(); }, 3600000); // 1시간 타임아웃 const uploadResponse = await fetch(uploadUrl, { method: "POST", headers: { "Content-Type": "application/octet-stream", "Content-Length": fileSize.toString(), }, body: webStream as unknown as BodyInit, signal: controller.signal, // @ts-expect-error - duplex is required for streaming uploads with ReadableStream duplex: "half", }); clearTimeout(timeoutId); const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); console.log(`[Proxy] DOLCE API 응답: ${fileName} (${elapsed}초, HTTP ${uploadResponse.status})`); if (!uploadResponse.ok) { const errorText = await uploadResponse.text(); console.error(`[Proxy] DOLCE API 실패 (${fileName}): HTTP ${uploadResponse.status}`, errorText); throw new Error( `파일 업로드 실패 (${fileName}): ${uploadResponse.status} ${uploadResponse.statusText}` ); } const fileRelativePath = await uploadResponse.text(); if (!fileRelativePath || fileRelativePath.trim() === "") { console.error(`[Proxy] DOLCE API 빈 경로 반환 (${fileName})`); throw new Error(`파일 업로드 실패: 빈 경로 반환 (${fileName})`); } const totalElapsed = ((Date.now() - startTime) / 1000).toFixed(1); const speed = (fileSize / 1024 / 1024 / (Date.now() - startTime) * 1000).toFixed(2); console.log(`[Proxy] 업로드 완료: ${fileName} (${totalElapsed}초, ${speed}MB/s) → ${fileRelativePath}`); return fileRelativePath; } catch (error) { clearInterval(logInterval); const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); if (error instanceof Error && error.name === "AbortError") { console.error(`[Proxy] 타임아웃 (${elapsed}초): ${fileName}`); throw new Error(`업로드 타임아웃 (1시간 초과): ${fileName}`); } console.error(`[Proxy] 업로드 에러 (${elapsed}초): ${fileName}`, error); throw error; } } /** * 상세도면 파일 업로드 API (스트리밍 Proxy) * * 진정한 Proxy 모드: * - 클라이언트로부터 받는 즉시 DOLCE API로 전달 * - 임시 파일 저장 없음 (메모리 효율) * - 진행도가 실시간으로 정확하게 반영 */ export async function POST(request: NextRequest) { try { console.log("[Proxy] 업로드 요청 수신"); // NextRequest를 Node.js request로 변환 (스트리밍) const nodeRequest = convertToNodeRequest(request); // formidable 설정 (스트리밍 모드) const form = formidable({ maxFileSize: 1024 * 1024 * 1024, // 1GB maxTotalFileSize: 10 * 1024 * 1024 * 1024, // 10GB allowEmptyFiles: false, // 파일을 디스크에 저장하지 않음 enabledPlugins: [], }); // 파일 메타데이터 수집 const uploadResults: Array<{ FileId: string; UploadId: string; FileSeq: number; FileName: string; FileRelativePath: string; FileSize: number; FileCreateDT: string; FileWriteDT: string; OwnerUserId: string; }> = []; let uploadId: string | null = null; let userId: string | null = null; let currentFileIndex = 0; let startSeq = 1; // formidable 이벤트 기반 처리 await new Promise((resolve, reject) => { // 필드(메타데이터) 처리 form.on("field", (name, value) => { if (name === "uploadId") uploadId = value; if (name === "userId") userId = value; }); // 파일 스트림 처리 (받는 즉시 DOLCE로 전송) form.on("fileBegin", (formName, file) => { console.log(`[Proxy] 파일 수신 시작: ${file.originalFilename || "unknown"} (${formName})`); }); form.on("file", async (formName, file) => { try { if (!uploadId || !userId) { throw new Error("uploadId 또는 userId가 누락되었습니다"); } // 첫 파일일 때 기존 파일 조회 if (currentFileIndex === 0) { try { const existingFilesResponse = await fetch( `${DOLCE_API_URL}/PorjectWebProxyService.ashx?service=FileInfoList`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ uploadId }), } ); if (existingFilesResponse.ok) { const existingFilesData = await existingFilesResponse.json(); startSeq = (existingFilesData.FileInfoListResult?.length || 0) + 1; console.log(`[Proxy] 기존 파일=${startSeq - 1}개, 새 파일 시작 Seq=${startSeq}`); } else { console.warn(`[Proxy] FileInfoList 조회 실패, startSeq=1로 시작`); } } catch (error) { console.warn(`[Proxy] FileInfoList 조회 에러:`, error); } } const fileId = crypto.randomUUID(); const fileName = file.originalFilename || `file_${currentFileIndex}`; const fileSize = file.size; console.log(`[Proxy] 파일 ${currentFileIndex + 1}: ${fileName} (${(fileSize / 1024 / 1024).toFixed(2)}MB)`); // 이 시점에서 파일은 이미 디스크에 저장되어 있음 (formidable 기본 동작) // 파일을 읽어서 DOLCE API로 전송 const fs = await import("fs"); const fileStream = fs.createReadStream(file.filepath); const fileRelativePath = await uploadStreamToDolce( fileStream, fileName, fileSize, uploadId, fileId ); uploadResults.push({ FileId: fileId, UploadId: uploadId, FileSeq: startSeq + currentFileIndex, FileName: fileName, FileRelativePath: fileRelativePath, FileSize: fileSize, FileCreateDT: new Date().toISOString(), FileWriteDT: new Date().toISOString(), OwnerUserId: userId, }); // 임시 파일 삭제 try { await fs.promises.unlink(file.filepath); } catch (error) { console.error(`[Proxy] 임시 파일 삭제 실패: ${file.filepath}`, error); } currentFileIndex++; } catch (error) { reject(error); } }); form.on("error", (err) => { console.error("[Proxy] formidable 에러:", err); reject(err); }); form.on("end", () => { console.log("[Proxy] formidable 파싱 완료"); resolve(); }); // 파싱 시작 (타입 캐스팅: formidable은 실제로는 Readable 스트림만 필요) form.parse(nodeRequest as unknown as IncomingMessage); }); if (!uploadId || !userId) { return NextResponse.json( { success: false, error: "필수 파라미터가 누락되었습니다" }, { status: 400 } ); } if (uploadResults.length === 0) { return NextResponse.json( { success: false, error: "업로드된 파일이 없습니다" }, { status: 400 } ); } // 업로드 완료 통지 console.log(`[Proxy] PWPUploadResultService 호출: ${uploadResults.length}개 파일 메타데이터 전송`); const resultServiceUrl = `${DOLCE_API_URL}/PWPUploadResultService.ashx?`; const resultResponse = await fetch(resultServiceUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(uploadResults), }); if (!resultResponse.ok) { const errorText = await resultResponse.text(); console.error(`[Proxy] PWPUploadResultService 실패: HTTP ${resultResponse.status}`, errorText); throw new Error(`업로드 완료 통지 실패: ${resultResponse.status}`); } const resultText = await resultResponse.text(); console.log(`[Proxy] PWPUploadResultService 응답:`, resultText); if (resultText !== "Success") { console.error(`[Proxy] PWPUploadResultService 예상치 못한 응답: "${resultText}"`); throw new Error(`업로드 완료 통지 실패: ${resultText}`); } console.log(`[Proxy] ✅ 완료: ${uploadResults.length}개 파일 업로드 성공`); return NextResponse.json({ success: true, uploadedCount: uploadResults.length, }); } catch (error) { console.error("[Proxy] ❌ 업로드 실패:", error); if (error instanceof Error) { console.error("[Proxy] 에러 스택:", error.stack); } return NextResponse.json( { success: false, error: error instanceof Error ? error.message : "업로드 중 오류가 발생했습니다", }, { status: 500 } ); } }