diff options
Diffstat (limited to 'app')
| -rw-r--r-- | app/[lng]/partners/(partners)/dolce-upload/page.tsx | 4 | ||||
| -rw-r--r-- | app/api/dolce/upload-files/route.ts | 418 |
2 files changed, 153 insertions, 269 deletions
diff --git a/app/[lng]/partners/(partners)/dolce-upload/page.tsx b/app/[lng]/partners/(partners)/dolce-upload/page.tsx index d44e71b6..97582819 100644 --- a/app/[lng]/partners/(partners)/dolce-upload/page.tsx +++ b/app/[lng]/partners/(partners)/dolce-upload/page.tsx @@ -5,8 +5,8 @@ import DolceUploadPage from "./dolce-upload-page"; import { Shell } from "@/components/shell"; export const metadata = { - title: "DOLCE 업로드", - description: "설계문서 업로드 및 관리", + title: "조선 벤더문서 업로드(DOLCE)", + description: "조선 설계문서 업로드 및 관리", }; // ============================================================================ diff --git a/app/api/dolce/upload-files/route.ts b/app/api/dolce/upload-files/route.ts index e547f217..898f9b2a 100644 --- a/app/api/dolce/upload-files/route.ts +++ b/app/api/dolce/upload-files/route.ts @@ -1,120 +1,23 @@ 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 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<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; -} - /** - * 스트림을 DOLCE API로 즉시 전송 (임시 저장 없음) + * 파일을 DOLCE API로 업로드 */ -async function uploadStreamToDolce( - fileStream: NodeJS.ReadableStream, - fileName: string, - fileSize: number, +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] 파일 스트리밍 시작: ${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(); - } - }, - }); + console.log(`[Proxy] 파일 업로드 시작: ${file.name} (${(file.size / 1024 / 1024).toFixed(2)}MB)`); try { const controller = new AbortController(); @@ -126,195 +29,147 @@ async function uploadStreamToDolce( method: "POST", headers: { "Content-Type": "application/octet-stream", - "Content-Length": fileSize.toString(), + "Content-Length": file.size.toString(), }, - body: webStream as unknown as BodyInit, + body: file, // File 객체를 바로 전송 (자동으로 스트리밍됨) signal: controller.signal, - // @ts-expect-error - duplex is required for streaming uploads with ReadableStream + // @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 응답: ${fileName} (${elapsed}초, HTTP ${uploadResponse.status})`); + console.log(`[Proxy] DOLCE API 응답: ${file.name} (${elapsed}초, HTTP ${uploadResponse.status})`); if (!uploadResponse.ok) { const errorText = await uploadResponse.text(); - console.error(`[Proxy] DOLCE API 실패 (${fileName}): HTTP ${uploadResponse.status}`, errorText); + console.error(`[Proxy] DOLCE API 실패 (${file.name}): HTTP ${uploadResponse.status}`, errorText); throw new Error( - `파일 업로드 실패 (${fileName}): ${uploadResponse.status} ${uploadResponse.statusText}` + `파일 업로드 실패 (${file.name}): ${uploadResponse.status} ${uploadResponse.statusText}` ); } const fileRelativePath = await uploadResponse.text(); if (!fileRelativePath || fileRelativePath.trim() === "") { - console.error(`[Proxy] DOLCE API 빈 경로 반환 (${fileName})`); - throw new Error(`파일 업로드 실패: 빈 경로 반환 (${fileName})`); + console.error(`[Proxy] DOLCE API 빈 경로 반환 (${file.name})`); + throw new Error(`파일 업로드 실패: 빈 경로 반환 (${file.name})`); } 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}`); + 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) { - 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}초): ${file.name}`); + throw new Error(`업로드 타임아웃 (1시간 초과): ${file.name}`); } - console.error(`[Proxy] 업로드 에러 (${elapsed}초): ${fileName}`, error); + console.error(`[Proxy] 업로드 에러 (${elapsed}초): ${file.name}`, error); throw error; } } /** - * 상세도면 파일 업로드 API (스트리밍 Proxy) - * - * 진정한 Proxy 모드: - * - 클라이언트로부터 받는 즉시 DOLCE API로 전달 - * - 임시 파일 저장 없음 (메모리 효율) - * - 진행도가 실시간으로 정확하게 반영 + * 기존 파일 목록 조회 */ -export async function POST(request: NextRequest) { +async function getExistingFileSeq(uploadId: string): Promise<number> { try { - console.log("[Proxy] 업로드 요청 수신"); + const response = await fetch( + `${DOLCE_API_URL}/PorjectWebProxyService.ashx?service=FileInfoList`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ uploadId }), + } + ); - // NextRequest를 Node.js request로 변환 (스트리밍) - const nodeRequest = convertToNodeRequest(request); + 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; + } +} - // formidable 설정 (스트리밍 모드) - const form = formidable({ - maxFileSize: 1024 * 1024 * 1024, // 1GB - maxTotalFileSize: 10 * 1024 * 1024 * 1024, // 10GB - allowEmptyFiles: false, - // 파일을 디스크에 저장하지 않음 - enabledPlugins: [], - }); +/** + * 업로드 완료 통지 (DB 저장) + */ +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?`; + + console.log(`[Proxy] 요청 URL: ${resultServiceUrl}`); + + const resultResponse = await fetch(resultServiceUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(uploadResults), + }); - // 파일 메타데이터 수집 - const uploadResults: Array<{ - FileId: string; - UploadId: string; - FileSeq: number; - FileName: string; - FileRelativePath: string; - FileSize: number; - FileCreateDT: string; - FileWriteDT: string; - OwnerUserId: string; - }> = []; + console.log(`[Proxy] PWPUploadResultService HTTP 상태: ${resultResponse.status}`); - let uploadId: string | null = null; - let userId: string | null = null; - let currentFileIndex = 0; - let startSeq = 1; - - // formidable 이벤트 기반 처리 - await new Promise<void>((resolve, reject) => { - // 필드(메타데이터) 처리 - form.on("field", (name, value) => { - if (name === "uploadId") uploadId = value; - if (name === "userId") userId = value; - }); + if (!resultResponse.ok) { + const errorText = await resultResponse.text(); + console.error(`[Proxy] PWPUploadResultService 실패: HTTP ${resultResponse.status}`, errorText); + throw new Error(`업로드 완료 통지 실패: ${resultResponse.status}`); + } - // 파일 스트림 처리 (받는 즉시 DOLCE로 전송) - form.on("fileBegin", (formName, file) => { - console.log(`[Proxy] 파일 수신 시작: ${file.originalFilename || "unknown"} (${formName})`); - }); + const resultText = await resultResponse.text(); + console.log(`[Proxy] PWPUploadResultService 응답: "${resultText}"`); - 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); - } - }); + if (resultText !== "Success") { + console.error(`[Proxy] PWPUploadResultService 예상치 못한 응답: "${resultText}"`); + throw new Error(`업로드 완료 통지 실패: ${resultText}`); + } - form.on("error", (err) => { - console.error("[Proxy] formidable 에러:", err); - reject(err); - }); + console.log(`[Proxy] ✅ DB 저장 완료!`); + console.log(`[Proxy] ========================================\n`); +} - form.on("end", () => { - console.log("[Proxy] formidable 파싱 완료"); - resolve(); - }); +/** + * 상세도면 파일 업로드 API + * + * 간단하고 효율적인 구현: + * - Next.js의 네이티브 formData API 사용 + * - File 객체를 바로 DOLCE API로 전송 (자동 스트리밍) + * - 복잡한 이벤트 핸들링 없음 + */ +export async function POST(request: NextRequest) { + try { + console.log("[Proxy] 업로드 요청 수신"); - // 파싱 시작 (타입 캐스팅: formidable은 실제로는 Readable 스트림만 필요) - form.parse(nodeRequest as unknown as IncomingMessage); - }); + // FormData 파싱 (Next.js 네이티브) + const formData = await request.formData(); + + const uploadId = formData.get("uploadId") as string; + const userId = formData.get("userId") as string; if (!uploadId || !userId) { return NextResponse.json( @@ -323,38 +178,67 @@ export async function POST(request: NextRequest) { ); } - if (uploadResults.length === 0) { + // 파일 수집 + 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] PWPUploadResultService 호출: ${uploadResults.length}개 파일 메타데이터 전송`); + console.log(`[Proxy] 총 ${files.length}개 파일 업로드 시작`); - const resultServiceUrl = `${DOLCE_API_URL}/PWPUploadResultService.ashx?`; - const resultResponse = await fetch(resultServiceUrl, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(uploadResults), - }); + // 기존 파일 Seq 조회 + const startSeq = await getExistingFileSeq(uploadId); - 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); + // 파일 업로드 결과 + const uploadResults: Array<{ + FileId: string; + UploadId: string; + FileSeq: number; + FileName: string; + FileRelativePath: string; + FileSize: number; + FileCreateDT: string; + FileWriteDT: string; + OwnerUserId: string; + }> = []; - if (resultText !== "Success") { - console.error(`[Proxy] PWPUploadResultService 예상치 못한 응답: "${resultText}"`); - throw new Error(`업로드 완료 통지 실패: ${resultText}`); + // 순차 업로드 + for (let i = 0; i < files.length; i++) { + const file = files[i]; + const fileId = crypto.randomUUID(); + + console.log(`[Proxy] 파일 ${i + 1}/${files.length}: ${file.name} (${(file.size / 1024 / 1024).toFixed(2)}MB)`); + + const fileRelativePath = await uploadFileToDolce(file, uploadId, fileId); + + 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, + }); } - console.log(`[Proxy] ✅ 완료: ${uploadResults.length}개 파일 업로드 성공`); + console.log(`\n[Proxy] 모든 파일 업로드 완료, DB 저장 시작...`); + + // 업로드 완료 통지 (DB 저장) + await notifyUploadComplete(uploadResults); + + console.log(`[Proxy] ✅ 전체 프로세스 완료: ${uploadResults.length}개 파일 업로드 및 DB 저장 성공`); return NextResponse.json({ success: true, |
