diff options
Diffstat (limited to 'app/api/dolce/upload-files')
| -rw-r--r-- | app/api/dolce/upload-files/route.ts | 325 |
1 files changed, 179 insertions, 146 deletions
diff --git a/app/api/dolce/upload-files/route.ts b/app/api/dolce/upload-files/route.ts index 1d302cb2..898f9b2a 100644 --- a/app/api/dolce/upload-files/route.ts +++ b/app/api/dolce/upload-files/route.ts @@ -1,114 +1,204 @@ import { NextRequest, NextResponse } from "next/server"; -import fs from "fs/promises"; -import { createReadStream } from "fs"; -import path from "path"; -import os from "os"; 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"; + /** - * 임시 파일 저장 및 정리 헬퍼 + * 파일을 DOLCE API로 업로드 */ -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}`); - - const arrayBuffer = await file.arrayBuffer(); - await fs.writeFile(tempFilePath, Buffer.from(arrayBuffer)); - - return { - filepath: tempFilePath, - cleanup: async () => { - try { - await fs.unlink(tempFilePath); - } catch (error) { - console.error(`임시 파일 삭제 실패: ${tempFilePath}`, error); +async function uploadFileToDolce( + file: File, + uploadId: string, + fileId: string +): Promise<string> { + const uploadUrl = `${DOLCE_API_URL}/PWPUploadService.ashx?UploadId=${uploadId}&FileId=${fileId}`; + const startTime = Date.now(); + + console.log(`[Proxy] 파일 업로드 시작: ${file.name} (${(file.size / 1024 / 1024).toFixed(2)}MB)`); + + 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": file.size.toString(), + }, + body: file, // File 객체를 바로 전송 (자동으로 스트리밍됨) + signal: controller.signal, + // @ts-expect-error - duplex is required for streaming uploads + duplex: "half", + }); + + clearTimeout(timeoutId); + + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); + console.log(`[Proxy] DOLCE API 응답: ${file.name} (${elapsed}초, HTTP ${uploadResponse.status})`); + + if (!uploadResponse.ok) { + const errorText = await uploadResponse.text(); + console.error(`[Proxy] DOLCE API 실패 (${file.name}): HTTP ${uploadResponse.status}`, errorText); + throw new Error( + `파일 업로드 실패 (${file.name}): ${uploadResponse.status} ${uploadResponse.statusText}` + ); + } + + const fileRelativePath = await uploadResponse.text(); + + if (!fileRelativePath || fileRelativePath.trim() === "") { + console.error(`[Proxy] DOLCE API 빈 경로 반환 (${file.name})`); + throw new Error(`파일 업로드 실패: 빈 경로 반환 (${file.name})`); + } + + const totalElapsed = ((Date.now() - startTime) / 1000).toFixed(1); + const speed = (file.size / 1024 / 1024 / (Date.now() - startTime) * 1000).toFixed(2); + console.log(`[Proxy] 업로드 완료: ${file.name} (${totalElapsed}초, ${speed}MB/s) → ${fileRelativePath}`); + + return fileRelativePath; + } catch (error) { + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); + + if (error instanceof Error && error.name === "AbortError") { + console.error(`[Proxy] 타임아웃 (${elapsed}초): ${file.name}`); + throw new Error(`업로드 타임아웃 (1시간 초과): ${file.name}`); + } + + console.error(`[Proxy] 업로드 에러 (${elapsed}초): ${file.name}`, error); + throw error; + } +} + +/** + * 기존 파일 목록 조회 + */ +async function getExistingFileSeq(uploadId: string): Promise<number> { + try { + const response = await fetch( + `${DOLCE_API_URL}/PorjectWebProxyService.ashx?service=FileInfoList`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ uploadId }), } - }, - }; + ); + + if (response.ok) { + const data = await response.json(); + const existingCount = data.FileInfoListResult?.length || 0; + console.log(`[Proxy] 기존 파일=${existingCount}개, 새 파일 시작 Seq=${existingCount + 1}`); + return existingCount + 1; + } else { + console.warn(`[Proxy] FileInfoList 조회 실패, startSeq=1로 시작`); + return 1; + } + } catch (error) { + console.warn(`[Proxy] FileInfoList 조회 에러:`, error); + return 1; + } } /** - * 스트리밍 방식으로 파일 업로드 - * Node.js ReadableStream을 Web ReadableStream으로 변환하여 fetch 사용 + * 업로드 완료 통지 (DB 저장) */ -async function uploadFileStream( - filepath: string, - uploadId: string, - fileId: string, - fileSize: number -): Promise<string> { - const uploadUrl = `${DOLCE_API_URL}/PWPUploadService.ashx?UploadId=${uploadId}&FileId=${fileId}`; - - // Node.js ReadableStream 생성 - const nodeStream = createReadStream(filepath, { - highWaterMark: 64 * 1024, // 64KB 청크로 읽기 - }); +async function notifyUploadComplete(uploadResults: Array<{ + FileId: string; + UploadId: string; + FileSeq: number; + FileName: string; + FileRelativePath: string; + FileSize: number; + FileCreateDT: string; + FileWriteDT: string; + OwnerUserId: string; +}>): Promise<void> { + console.log(`\n[Proxy] ========================================`); + console.log(`[Proxy] 업로드 완료 통지 시작 (DB 저장)`); + console.log(`[Proxy] PWPUploadResultService 호출: ${uploadResults.length}개 파일 메타데이터 전송`); + console.log(`[Proxy] 전송 데이터:`, JSON.stringify(uploadResults, null, 2)); + + const resultServiceUrl = `${DOLCE_API_URL}/PWPUploadResultService.ashx?`; - // Node.js Stream을 Web ReadableStream으로 변환 - const webStream = new ReadableStream({ - start(controller) { - nodeStream.on("data", (chunk: Buffer) => { - controller.enqueue(new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength)); - }); - - nodeStream.on("end", () => { - controller.close(); - }); - - nodeStream.on("error", (error) => { - controller.error(error); - }); - }, - cancel() { - nodeStream.destroy(); - }, - }); + console.log(`[Proxy] 요청 URL: ${resultServiceUrl}`); - // 스트리밍 업로드 - const uploadResponse = await fetch(uploadUrl, { + const resultResponse = await fetch(resultServiceUrl, { method: "POST", - headers: { - "Content-Type": "application/octet-stream", - "Content-Length": fileSize.toString(), - }, - body: webStream as unknown as BodyInit, - // @ts-expect-error - duplex is required for streaming uploads with ReadableStream - duplex: "half", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(uploadResults), }); - - if (!uploadResponse.ok) { - throw new Error( - `파일 업로드 실패: ${uploadResponse.status} ${uploadResponse.statusText}` - ); + + console.log(`[Proxy] PWPUploadResultService HTTP 상태: ${resultResponse.status}`); + + if (!resultResponse.ok) { + const errorText = await resultResponse.text(); + console.error(`[Proxy] PWPUploadResultService 실패: HTTP ${resultResponse.status}`, errorText); + throw new Error(`업로드 완료 통지 실패: ${resultResponse.status}`); } - - const fileRelativePath = await uploadResponse.text(); - return fileRelativePath; + + 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] ✅ DB 저장 완료!`); + console.log(`[Proxy] ========================================\n`); } /** * 상세도면 파일 업로드 API - * 스트리밍 처리로 메모리 효율적 업로드 + * + * 간단하고 효율적인 구현: + * - Next.js의 네이티브 formData API 사용 + * - File 객체를 바로 DOLCE API로 전송 (자동 스트리밍) + * - 복잡한 이벤트 핸들링 없음 */ export async function POST(request: NextRequest) { - const tempFiles: Array<{ filepath: string; cleanup: () => Promise<void> }> = []; - try { - // FormData 파싱 + console.log("[Proxy] 업로드 요청 수신"); + + // FormData 파싱 (Next.js 네이티브) 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); - if (!uploadId || !userId || !fileCount) { + if (!uploadId || !userId) { return NextResponse.json( { success: false, error: "필수 파라미터가 누락되었습니다" }, { status: 400 } ); } + // 파일 수집 + const files: File[] = []; + for (const [, value] of formData.entries()) { + if (value instanceof File) { + files.push(value); + } + } + + if (files.length === 0) { + return NextResponse.json( + { success: false, error: "업로드된 파일이 없습니다" }, + { status: 400 } + ); + } + + console.log(`[Proxy] 총 ${files.length}개 파일 업로드 시작`); + + // 기존 파일 Seq 조회 + const startSeq = await getExistingFileSeq(uploadId); + + // 파일 업로드 결과 const uploadResults: Array<{ FileId: string; UploadId: string; @@ -121,52 +211,14 @@ 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, - }), - } - ); - - if (!existingFilesResponse.ok) { - throw new Error("기존 파일 조회 실패"); - } - - const existingFilesData = await existingFilesResponse.json(); - const startSeq = (existingFilesData.FileInfoListResult?.length || 0) + 1; - - // 파일 수집 - const files: File[] = []; - for (let i = 0; i < fileCount; i++) { - const file = formData.get(`file_${i}`) as File; - if (file) { - files.push(file); - } - } - - // 각 파일을 임시 디렉터리에 저장 후 스트리밍 업로드 + // 순차 업로드 for (let i = 0; i < files.length; i++) { const file = files[i]; const fileId = crypto.randomUUID(); - // 임시 파일로 저장 (메모리 압박 감소) - const tempFile = await saveToTempFile(file); - tempFiles.push(tempFile); + console.log(`[Proxy] 파일 ${i + 1}/${files.length}: ${file.name} (${(file.size / 1024 / 1024).toFixed(2)}MB)`); - // 스트리밍 방식으로 DOLCE API에 업로드 - const fileRelativePath = await uploadFileStream( - tempFile.filepath, - uploadId, - fileId, - file.size - ); + const fileRelativePath = await uploadFileToDolce(file, uploadId, fileId); uploadResults.push({ FileId: fileId, @@ -179,45 +231,26 @@ export async function POST(request: NextRequest) { FileWriteDT: new Date().toISOString(), OwnerUserId: userId, }); - - // 처리 완료된 임시 파일 즉시 삭제 - await tempFile.cleanup(); } - // 업로드 완료 통지 - 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) { - throw new Error( - `업로드 완료 통지 실패: ${resultResponse.status} ${resultResponse.statusText}` - ); - } + console.log(`\n[Proxy] 모든 파일 업로드 완료, DB 저장 시작...`); + + // 업로드 완료 통지 (DB 저장) + await notifyUploadComplete(uploadResults); - const resultText = await resultResponse.text(); - if (resultText !== "Success") { - throw new Error(`업로드 완료 통지 실패: ${resultText}`); - } + console.log(`[Proxy] ✅ 전체 프로세스 완료: ${uploadResults.length}개 파일 업로드 및 DB 저장 성공`); 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, |
