diff options
Diffstat (limited to 'app')
| -rw-r--r-- | app/api/dolce/upload-files/route.ts | 425 |
1 files changed, 287 insertions, 138 deletions
diff --git a/app/api/dolce/upload-files/route.ts b/app/api/dolce/upload-files/route.ts index 1d302cb2..e547f217 100644 --- a/app/api/dolce/upload-files/route.ts +++ b/app/api/dolce/upload-files/route.ts @@ -1,114 +1,203 @@ import { NextRequest, NextResponse } from "next/server"; -import fs from "fs/promises"; -import { createReadStream } from "fs"; -import path from "path"; -import os from "os"; +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를 직접 스트림으로 사용 (메모리 로드 없음) */ -async function saveToTempFile(file: File): Promise<{ filepath: string; cleanup: () => Promise<void> }> { - const tempDir = os.tmpdir(); - const tempFilePath = path.join(tempDir, `upload-${Date.now()}-${file.name}`); +function convertToNodeRequest(request: NextRequest) { + // Next.js의 ReadableStream을 Node.js Readable로 변환 + const webReadableStream = request.body; - const arrayBuffer = await file.arrayBuffer(); - await fs.writeFile(tempFilePath, Buffer.from(arrayBuffer)); + 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); - return { - filepath: tempFilePath, - cleanup: async () => { - try { - await fs.unlink(tempFilePath); - } catch (error) { - console.error(`임시 파일 삭제 실패: ${tempFilePath}`, error); - } - }, + // Node.js IncomingMessage 필수 속성 추가 + const nodeRequest = nodeReadable as Readable & { + headers: Record<string, string>; + 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; } /** - * 스트리밍 방식으로 파일 업로드 - * Node.js ReadableStream을 Web ReadableStream으로 변환하여 fetch 사용 + * 스트림을 DOLCE API로 즉시 전송 (임시 저장 없음) */ -async function uploadFileStream( - filepath: string, +async function uploadStreamToDolce( + fileStream: NodeJS.ReadableStream, + fileName: string, + fileSize: number, uploadId: string, - fileId: string, - fileSize: number + fileId: string ): Promise<string> { 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(); - // Node.js ReadableStream 생성 - const nodeStream = createReadStream(filepath, { - highWaterMark: 64 * 1024, // 64KB 청크로 읽기 + fileStream.on("data", (chunk: Buffer) => { + bytesUploaded += chunk.length; }); - - // Node.js Stream을 Web ReadableStream으로 변환 + + fileStream.pipe(progressStream); + + // Web ReadableStream으로 변환 const webStream = new ReadableStream({ start(controller) { - nodeStream.on("data", (chunk: Buffer) => { + progressStream.on("data", (chunk: Buffer) => { controller.enqueue(new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength)); }); - - nodeStream.on("end", () => { + + progressStream.on("end", () => { + clearInterval(logInterval); + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); + console.log(`[Proxy] 스트림 전송 완료: ${fileName} (${elapsed}초)`); controller.close(); }); - - nodeStream.on("error", (error) => { + + progressStream.on("error", (error) => { + clearInterval(logInterval); + console.error(`[Proxy] 스트림 에러: ${fileName}`, error); controller.error(error); }); }, cancel() { - nodeStream.destroy(); - }, - }); - - // 스트리밍 업로드 - const uploadResponse = await fetch(uploadUrl, { - method: "POST", - headers: { - "Content-Type": "application/octet-stream", - "Content-Length": fileSize.toString(), + clearInterval(logInterval); + progressStream.destroy(); + // fileStream 정리 + if (fileStream && "destroy" in fileStream && typeof fileStream.destroy === "function") { + fileStream.destroy(); + } }, - body: webStream as unknown as BodyInit, - // @ts-expect-error - duplex is required for streaming uploads with ReadableStream - duplex: "half", }); - - if (!uploadResponse.ok) { - throw new Error( - `파일 업로드 실패: ${uploadResponse.status} ${uploadResponse.statusText}` - ); + + 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; } - - const fileRelativePath = await uploadResponse.text(); - return fileRelativePath; } /** - * 상세도면 파일 업로드 API - * 스트리밍 처리로 메모리 효율적 업로드 + * 상세도면 파일 업로드 API (스트리밍 Proxy) + * + * 진정한 Proxy 모드: + * - 클라이언트로부터 받는 즉시 DOLCE API로 전달 + * - 임시 파일 저장 없음 (메모리 효율) + * - 진행도가 실시간으로 정확하게 반영 */ export async function POST(request: NextRequest) { - const tempFiles: Array<{ filepath: string; cleanup: () => Promise<void> }> = []; - try { - // FormData 파싱 - const formData = await request.formData(); - - const uploadId = formData.get("uploadId") as string; - const userId = formData.get("userId") as string; - const fileCount = parseInt(formData.get("fileCount") as string); + console.log("[Proxy] 업로드 요청 수신"); - if (!uploadId || !userId || !fileCount) { - return NextResponse.json( - { success: false, error: "필수 파라미터가 누락되었습니다" }, - { status: 400 } - ); - } + // 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; @@ -121,103 +210,163 @@ export async function POST(request: NextRequest) { OwnerUserId: string; }> = []; - // 기존 파일 개수 조회 - const existingFilesResponse = await fetch( - `${DOLCE_API_URL}/PorjectWebProxyService.ashx?service=FileInfoList`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - uploadId: uploadId, - }), - } - ); + let uploadId: string | null = null; + let userId: string | null = null; + let currentFileIndex = 0; + let startSeq = 1; - if (!existingFilesResponse.ok) { - throw new Error("기존 파일 조회 실패"); - } + // formidable 이벤트 기반 처리 + await new Promise<void>((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})`); + }); - const existingFilesData = await existingFilesResponse.json(); - const startSeq = (existingFilesData.FileInfoListResult?.length || 0) + 1; + form.on("file", async (formName, file) => { + try { + if (!uploadId || !userId) { + throw new Error("uploadId 또는 userId가 누락되었습니다"); + } - // 파일 수집 - const files: File[] = []; - for (let i = 0; i < fileCount; i++) { - const file = formData.get(`file_${i}`) as File; - if (file) { - files.push(file); - } - } + // 첫 파일일 때 기존 파일 조회 + 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 }), + } + ); - // 각 파일을 임시 디렉터리에 저장 후 스트리밍 업로드 - for (let i = 0; i < files.length; i++) { - const file = files[i]; - const fileId = crypto.randomUUID(); - - // 임시 파일로 저장 (메모리 압박 감소) - const tempFile = await saveToTempFile(file); - tempFiles.push(tempFile); - - // 스트리밍 방식으로 DOLCE API에 업로드 - const fileRelativePath = await uploadFileStream( - tempFile.filepath, - uploadId, - fileId, - file.size - ); + 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); + } + }); - uploadResults.push({ - FileId: fileId, - UploadId: uploadId, - FileSeq: startSeq + i, - FileName: file.name, - FileRelativePath: fileRelativePath, - FileSize: file.size, - FileCreateDT: new Date().toISOString(), - FileWriteDT: new Date().toISOString(), - OwnerUserId: userId, + form.on("error", (err) => { + console.error("[Proxy] formidable 에러:", err); + reject(err); }); - // 처리 완료된 임시 파일 즉시 삭제 - await tempFile.cleanup(); + 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 } + ); } // 업로드 완료 통지 - const resultServiceUrl = `${DOLCE_API_URL}/PWPUploadResultService.ashx?`; + 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", - }, + headers: { "Content-Type": "application/json" }, body: JSON.stringify(uploadResults), }); if (!resultResponse.ok) { - throw new Error( - `업로드 완료 통지 실패: ${resultResponse.status} ${resultResponse.statusText}` - ); + 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("파일 업로드 실패:", error); + console.error("[Proxy] ❌ 업로드 실패:", error); - // 에러 발생 시 남아있는 임시 파일 모두 정리 - for (const tempFile of tempFiles) { - await tempFile.cleanup(); + if (error instanceof Error) { + console.error("[Proxy] 에러 스택:", error.stack); } - + return NextResponse.json( { success: false, |
