diff options
| -rw-r--r-- | app/api/dolce/upload-files/route.ts | 425 | ||||
| -rw-r--r-- | lib/dolce/actions.ts | 14 | ||||
| -rw-r--r-- | lib/dolce/components/file-upload-progress-list.tsx | 2 | ||||
| -rw-r--r-- | lib/dolce/dialogs/add-detail-drawing-dialog.tsx | 2 | ||||
| -rw-r--r-- | lib/dolce/dialogs/b4-bulk-upload-dialog.tsx | 172 | ||||
| -rw-r--r-- | lib/dolce/dialogs/upload-files-to-detail-dialog.tsx | 2 | ||||
| -rw-r--r-- | lib/dolce/hooks/use-file-upload.ts | 107 | ||||
| -rw-r--r-- | lib/dolce/utils/upload-with-progress.ts | 74 |
8 files changed, 488 insertions, 310 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, diff --git a/lib/dolce/actions.ts b/lib/dolce/actions.ts index a9cda76a..77de430f 100644 --- a/lib/dolce/actions.ts +++ b/lib/dolce/actions.ts @@ -275,6 +275,10 @@ export async function fetchFileInfoList(uploadId: string): Promise<FileInfoItem[ /** * 4. 상세도면 추가/수정 + * + * 참고: DetailDwgReceiptMmgtEditResult는 실제 성공 건수를 정확히 반영하지 않음 + * (1개 추가되어도 0을 반환하는 경우 있음) + * API 호출이 성공하면 요청한 건수가 처리된 것으로 간주 */ export async function editDetailDwgReceipt(params: { dwgList: DetailDwgEditRequest[]; @@ -294,7 +298,15 @@ export async function editDetailDwgReceipt(params: { EMAIL: params.email, }); - return response.DetailDwgReceiptMmgtEditResult; + // 응답값이 신뢰할 수 없으므로 로그만 남김 + if (response.DetailDwgReceiptMmgtEditResult !== params.dwgList.length) { + console.warn( + `[DOLCE API] DetailDwgReceiptMmgtEditResult 불일치: 요청=${params.dwgList.length}, 응답=${response.DetailDwgReceiptMmgtEditResult}` + ); + } + + // API 호출 성공 시 요청한 건수 반환 (응답값 무시) + return params.dwgList.length; } catch (error) { console.error("상세도면 수정 실패:", error); throw error; diff --git a/lib/dolce/components/file-upload-progress-list.tsx b/lib/dolce/components/file-upload-progress-list.tsx index e016402d..d54e9eaa 100644 --- a/lib/dolce/components/file-upload-progress-list.tsx +++ b/lib/dolce/components/file-upload-progress-list.tsx @@ -18,7 +18,7 @@ export function FileUploadProgressList({ fileProgresses }: FileUploadProgressLis <h4 className="text-sm font-medium"> 파일 업로드 진행 상황 ({fileProgresses.length}개) </h4> - <div className="max-h-64 overflow-auto space-y-2"> + <div className="max-h-64 overflow-y-auto space-y-2"> {fileProgresses.map((fileProgress, index) => ( <FileUploadProgressItem key={index} fileProgress={fileProgress} /> ))} diff --git a/lib/dolce/dialogs/add-detail-drawing-dialog.tsx b/lib/dolce/dialogs/add-detail-drawing-dialog.tsx index 34d06368..2f7f15a7 100644 --- a/lib/dolce/dialogs/add-detail-drawing-dialog.tsx +++ b/lib/dolce/dialogs/add-detail-drawing-dialog.tsx @@ -366,7 +366,7 @@ export function AddDetailDrawingDialog({ 전체 제거 </Button> </div> - <div className="max-h-48 overflow-auto space-y-2"> + <div className="max-h-60 overflow-y-auto space-y-2"> {files.map((file, index) => ( <div key={index} diff --git a/lib/dolce/dialogs/b4-bulk-upload-dialog.tsx b/lib/dolce/dialogs/b4-bulk-upload-dialog.tsx index f4816328..cd336e92 100644 --- a/lib/dolce/dialogs/b4-bulk-upload-dialog.tsx +++ b/lib/dolce/dialogs/b4-bulk-upload-dialog.tsx @@ -29,10 +29,11 @@ import { } from "./b4-upload-validation-dialog"; import { checkB4MappingStatus, - bulkUploadB4Files, + editDetailDwgReceipt, type MappingCheckItem, type B4BulkUploadResult, } from "../actions"; +import { v4 as uuidv4 } from "uuid"; interface B4BulkUploadDialogProps { open: boolean; @@ -266,62 +267,139 @@ export function B4BulkUploadDialog({ setCurrentStep("uploading"); setShowValidationDialog(false); - // 진행률 시뮬레이션 - const progressInterval = setInterval(() => { - setUploadProgress((prev) => { - if (prev >= 90) { - clearInterval(progressInterval); - return prev; + try { + console.log(`[B4 일괄 업로드] 시작: ${validFiles.length}개 파일`); + + // 파일을 DrawingNo + RevNo로 그룹화 + const uploadGroups = new Map< + string, + Array<{ + file: File; + drawingNo: string; + revNo: string; + fileName: string; + registerGroupId: number; + }> + >(); + + validFiles.forEach((fileResult) => { + const groupKey = `${fileResult.parsed!.drawingNo}_${fileResult.parsed!.revNo}`; + if (!uploadGroups.has(groupKey)) { + uploadGroups.set(groupKey, []); } - return prev + 10; + uploadGroups.get(groupKey)!.push({ + file: fileResult.file, + drawingNo: fileResult.parsed!.drawingNo, + revNo: fileResult.parsed!.revNo, + fileName: fileResult.file.name, + registerGroupId: fileResult.registerGroupId || 0, + }); }); - }, 500); - try { - // FormData 생성 - const formData = new FormData(); - formData.append("projectNo", projectNo); - formData.append("userId", userId); - formData.append("userName", userName); - formData.append("userEmail", userEmail); - formData.append("vendorCode", vendorCode); - formData.append("registerKind", registerKind); // RegisterKind 추가 - - // 파일 및 메타데이터 추가 - validFiles.forEach((fileResult, index) => { - formData.append(`file_${index}`, fileResult.file); - formData.append(`drawingNo_${index}`, fileResult.parsed!.drawingNo); - formData.append(`revNo_${index}`, fileResult.parsed!.revNo); - formData.append(`fileName_${index}`, fileResult.file.name); - formData.append( - `registerGroupId_${index}`, - String(fileResult.registerGroupId || 0) - ); - }); + console.log(`[B4 일괄 업로드] ${uploadGroups.size}개 그룹으로 묶임`); + + let successCount = 0; + let failCount = 0; + let completedGroups = 0; + + // 각 그룹별로 순차 처리 + for (const [groupKey, files] of uploadGroups.entries()) { + const { drawingNo, revNo, registerGroupId } = files[0]; + + try { + console.log(`[B4 업로드] 그룹 ${groupKey}: ${files.length}개 파일`); + + // 1. UploadId 생성 + const uploadId = uuidv4(); + + // 2. 파일 업로드 (공통 API 사용) + const formData = new FormData(); + formData.append("uploadId", uploadId); + formData.append("userId", userId); + formData.append("fileCount", String(files.length)); + + files.forEach((fileInfo, index) => { + formData.append(`file_${index}`, fileInfo.file); + }); + + const uploadResponse = await fetch("/api/dolce/upload-files", { + method: "POST", + body: formData, + }); + + if (!uploadResponse.ok) { + throw new Error(`파일 업로드 실패: ${uploadResponse.status}`); + } + + const uploadResult = await uploadResponse.json(); + + if (!uploadResult.success) { + throw new Error(uploadResult.error || "파일 업로드 실패"); + } + + console.log(`[B4 업로드] 그룹 ${groupKey} 파일 업로드 완료`); + + // 3. 상세도면 등록 + await editDetailDwgReceipt({ + dwgList: [ + { + Mode: "ADD", + Status: "Draft", + RegisterId: 0, + ProjectNo: projectNo, + Discipline: "", + DrawingKind: "B4", + DrawingNo: drawingNo, + DrawingName: "", + RegisterGroupId: registerGroupId, + RegisterSerialNo: 0, + RegisterKind: registerKind, + DrawingRevNo: revNo, + Category: "TS", + Receiver: null, + Manager: "", + RegisterDesc: "", + UploadId: uploadId, + RegCompanyCode: vendorCode, + }, + ], + userId, + userNm: userName, + vendorCode, + email: userEmail, + }); + + console.log(`[B4 업로드] 그룹 ${groupKey} 상세도면 등록 완료`); + + successCount += files.length; + } catch (error) { + console.error(`[B4 업로드] 그룹 ${groupKey} 실패:`, error); + failCount += files.length; + } - formData.append("fileCount", String(validFiles.length)); + // 진행도 업데이트 + completedGroups++; + const progress = Math.round((completedGroups / uploadGroups.size) * 100); + setUploadProgress(progress); + } - // 서버 액션 호출 - const result: B4BulkUploadResult = await bulkUploadB4Files(formData); + console.log(`[B4 일괄 업로드] ✅ 완료: 성공 ${successCount}, 실패 ${failCount}`); - clearInterval(progressInterval); - setUploadProgress(100); - setUploadResult(result); + const result: B4BulkUploadResult = { + success: true, + successCount, + failCount, + }; - if (result.success) { - setCurrentStep("complete"); - toast.success( - `${result.successCount}/${validFiles.length}개 파일 업로드 완료` - ); - } else { - setCurrentStep("files"); - toast.error(result.error || "업로드 실패"); - } + setUploadResult(result); + setCurrentStep("complete"); + toast.success(`${successCount}/${validFiles.length}개 파일 업로드 완료`); } catch (error) { - console.error("업로드 실패:", error); + console.error("[B4 일괄 업로드] 실패:", error); toast.error( error instanceof Error ? error.message : "업로드 중 오류가 발생했습니다" ); + setCurrentStep("files"); } finally { setIsUploading(false); } @@ -460,7 +538,7 @@ export function B4BulkUploadDialog({ 전체 제거 </Button> </div> - <div className="max-h-48 overflow-auto space-y-2"> + <div className="max-h-60 overflow-y-auto space-y-2"> {selectedFiles.map((file, index) => ( <div key={index} diff --git a/lib/dolce/dialogs/upload-files-to-detail-dialog.tsx b/lib/dolce/dialogs/upload-files-to-detail-dialog.tsx index af73aea6..431a2c77 100644 --- a/lib/dolce/dialogs/upload-files-to-detail-dialog.tsx +++ b/lib/dolce/dialogs/upload-files-to-detail-dialog.tsx @@ -181,7 +181,7 @@ export function UploadFilesToDetailDialog({ 전체 제거 </Button> </div> - <div className="max-h-48 overflow-auto space-y-2"> + <div className="max-h-60 overflow-y-auto space-y-2"> {selectedFiles.map((file, index) => ( <div key={index} diff --git a/lib/dolce/hooks/use-file-upload.ts b/lib/dolce/hooks/use-file-upload.ts deleted file mode 100644 index 38556cb9..00000000 --- a/lib/dolce/hooks/use-file-upload.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { useState, useCallback } from "react"; -import { useDropzone, FileRejection } from "react-dropzone"; -import { toast } from "sonner"; - -interface UseFileUploadOptions { - onFilesAdded?: (files: File[]) => void; -} - -export function useFileUpload(options: UseFileUploadOptions = {}) { - const [files, setFiles] = useState<File[]>([]); - - // 파일 검증 - const validateFiles = useCallback((filesToValidate: File[]): { valid: File[]; invalid: string[] } => { - const MAX_FILE_SIZE = 1024 * 1024 * 1024; // 1GB - const FORBIDDEN_EXTENSIONS = ['exe', 'com', 'dll', 'vbs', 'js', 'asp', 'aspx', 'bat', 'cmd']; - - const validFiles: File[] = []; - const invalidFiles: string[] = []; - - filesToValidate.forEach((file) => { - // 크기 검증 - if (file.size > MAX_FILE_SIZE) { - invalidFiles.push(`${file.name}: 파일 크기가 1GB를 초과합니다`); - return; - } - - // 확장자 검증 (블랙리스트) - const extension = file.name.split('.').pop()?.toLowerCase(); - if (extension && FORBIDDEN_EXTENSIONS.includes(extension)) { - invalidFiles.push(`${file.name}: 금지된 파일 형식입니다 (.${extension})`); - return; - } - - validFiles.push(file); - }); - - return { valid: validFiles, invalid: invalidFiles }; - }, []); - - // 파일 드롭 핸들러 - const onDrop = useCallback((acceptedFiles: File[], rejectedFiles: FileRejection[]) => { - const { valid: validFiles, invalid: invalidMessages } = validateFiles(acceptedFiles); - - // 거부된 파일 처리 - if (rejectedFiles.length > 0) { - rejectedFiles.forEach((rejected) => { - const errorMsg = rejected.errors?.[0]?.message || "파일이 거부되었습니다"; - toast.error(`${rejected.file.name}: ${errorMsg}`); - }); - } - - // 유효하지 않은 파일 메시지 표시 - if (invalidMessages.length > 0) { - invalidMessages.forEach((msg) => toast.error(msg)); - } - - if (validFiles.length > 0) { - // 중복 제거 - const existingNames = new Set(files.map((f) => f.name)); - const newFiles = validFiles.filter((f) => !existingNames.has(f.name)); - - if (newFiles.length === 0) { - toast.error("이미 선택된 파일입니다"); - return; - } - - setFiles((prev) => { - const updated = [...prev, ...newFiles]; - options.onFilesAdded?.(updated); - return updated; - }); - toast.success(`${newFiles.length}개 파일이 선택되었습니다`); - } - }, [files, validateFiles, options]); - - const { getRootProps, getInputProps, isDragActive } = useDropzone({ - onDrop, - multiple: true, - maxSize: 1024 * 1024 * 1024, // 1GB - }); - - // 파일 제거 - const removeFile = useCallback((index: number) => { - setFiles((prev) => prev.filter((_, i) => i !== index)); - }, []); - - // 전체 파일 제거 - const clearFiles = useCallback(() => { - setFiles([]); - }, []); - - // 파일 배열 직접 설정 - const setFileList = useCallback((newFiles: File[]) => { - setFiles(newFiles); - }, []); - - return { - files, - setFiles: setFileList, - removeFile, - clearFiles, - getRootProps, - getInputProps, - isDragActive, - }; -} - diff --git a/lib/dolce/utils/upload-with-progress.ts b/lib/dolce/utils/upload-with-progress.ts index 8e36afe4..1204bf36 100644 --- a/lib/dolce/utils/upload-with-progress.ts +++ b/lib/dolce/utils/upload-with-progress.ts @@ -40,24 +40,30 @@ export async function uploadFilesWithProgress({ }); const xhr = new XMLHttpRequest(); + + // 타임아웃 설정 (1시간) + xhr.timeout = 3600000; // 1시간 (밀리초) - // 전체 업로드 진행도 (단순화: 전체 진행도를 각 파일에 분배) + // 전체 업로드 진행도 + // 주의: xhr.upload.progress는 클라이언트→서버 전송만 추적 + // 서버에서 DOLCE API로 재업로드하는 과정은 별도 (추적 불가) xhr.upload.addEventListener("progress", (event) => { if (event.lengthComputable) { - const totalProgress = (event.loaded / event.total) * 100; + // 전송 완료 = 서버에 도착 (실제 처리 시작) + // 서버 처리를 위해 최대 95%까지만 표시 + const totalProgress = Math.min((event.loaded / event.total) * 95, 95); // 현재 업로드 중인 파일 인덱스 추정 - const filesCompleted = Math.floor((totalProgress / 100) * files.length); + const filesCompleted = Math.floor((totalProgress / 95) * files.length); const currentFileIndex = Math.min(filesCompleted, files.length - 1); // 각 파일별 진행도 계산 files.forEach((_, index) => { if (index < filesCompleted) { - callbacks.onProgress(index, 100); - callbacks.onFileComplete(index); + callbacks.onProgress(index, 95); } else if (index === currentFileIndex) { - const fileProgress = ((totalProgress / 100) * files.length - filesCompleted) * 100; - callbacks.onProgress(index, Math.min(fileProgress, 99)); + const fileProgress = ((totalProgress / 95) * files.length - filesCompleted) * 95; + callbacks.onProgress(index, Math.min(fileProgress, 94)); } else { callbacks.onProgress(index, 0); } @@ -70,15 +76,35 @@ export async function uploadFilesWithProgress({ try { const response = JSON.parse(xhr.responseText); - // 모든 파일 완료 처리 - files.forEach((_, index) => { - callbacks.onProgress(index, 100); - callbacks.onFileComplete(index); - }); + // 서버 응답 검증 + if (response.success) { + console.log(`[업로드 클라이언트] 서버 처리 완료: ${response.uploadedCount}개 파일`); + + // 서버에서 실제 처리 완료 시에만 100% + files.forEach((_, index) => { + callbacks.onProgress(index, 100); + callbacks.onFileComplete(index); + }); - resolve(response); + resolve(response); + } else { + // 서버에서 에러 응답 + const errorMsg = response.error || "서버에서 업로드 실패"; + console.error(`[업로드 클라이언트] 서버 에러:`, errorMsg); + + files.forEach((_, index) => { + callbacks.onFileError(index, errorMsg); + }); + + resolve({ + success: false, + error: errorMsg, + }); + } } catch (error) { - const errorMsg = "응답 파싱 실패"; + const errorMsg = `응답 파싱 실패: ${xhr.responseText?.substring(0, 100)}`; + console.error(`[업로드 클라이언트] 파싱 에러:`, error, xhr.responseText); + files.forEach((_, index) => { callbacks.onFileError(index, errorMsg); }); @@ -89,6 +115,8 @@ export async function uploadFilesWithProgress({ } } else { const errorMsg = `업로드 실패: ${xhr.status} ${xhr.statusText}`; + console.error(`[업로드 클라이언트] HTTP 에러:`, errorMsg, xhr.responseText); + files.forEach((_, index) => { callbacks.onFileError(index, errorMsg); }); @@ -101,6 +129,8 @@ export async function uploadFilesWithProgress({ xhr.addEventListener("error", () => { const errorMsg = "네트워크 오류"; + console.error(`[업로드 클라이언트] 네트워크 에러`); + files.forEach((_, index) => { callbacks.onFileError(index, errorMsg); }); @@ -112,6 +142,21 @@ export async function uploadFilesWithProgress({ xhr.addEventListener("abort", () => { const errorMsg = "업로드가 취소되었습니다"; + console.warn(`[업로드 클라이언트] 업로드 취소됨`); + + files.forEach((_, index) => { + callbacks.onFileError(index, errorMsg); + }); + resolve({ + success: false, + error: errorMsg, + }); + }); + + xhr.addEventListener("timeout", () => { + const errorMsg = "업로드 타임아웃 (1시간 초과)"; + console.error(`[업로드 클라이언트] 타임아웃 발생 (1시간 초과)`); + files.forEach((_, index) => { callbacks.onFileError(index, errorMsg); }); @@ -121,6 +166,7 @@ export async function uploadFilesWithProgress({ }); }); + console.log(`[업로드 클라이언트] 시작: ${files.length}개 파일`); xhr.open("POST", "/api/dolce/upload-files"); xhr.send(formData); }); |
