summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/api/dolce/upload-files/route.ts425
-rw-r--r--lib/dolce/actions.ts14
-rw-r--r--lib/dolce/components/file-upload-progress-list.tsx2
-rw-r--r--lib/dolce/dialogs/add-detail-drawing-dialog.tsx2
-rw-r--r--lib/dolce/dialogs/b4-bulk-upload-dialog.tsx172
-rw-r--r--lib/dolce/dialogs/upload-files-to-detail-dialog.tsx2
-rw-r--r--lib/dolce/hooks/use-file-upload.ts107
-rw-r--r--lib/dolce/utils/upload-with-progress.ts74
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);
});