summaryrefslogtreecommitdiff
path: root/app/api/swp/upload/route.ts
blob: bd4d91b5e4c961bf26e9a664b4f65afb0a9ce500 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
import { NextRequest, NextResponse } from "next/server";
import * as fs from "fs/promises";
import * as path from "path";
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { debugLog, debugError, debugSuccess } from "@/lib/debug-utils";
import formidable, { Fields, Files, File as FormidableFile } from "formidable";
import { Readable } from "stream";

// API Route 설정
export const runtime = "nodejs";
export const maxDuration = 3600; // 1시간 타임아웃 (대용량 파일 업로드 대응)

// Next.js 15 API Route body parsing 비활성화 (스트리밍 처리를 위해)
export const dynamic = 'force-dynamic';

/**
 * formidable을 사용하여 스트리밍 방식으로 파일 파싱
 * 메모리에 전체 파일을 올리지 않고 chunk 단위로 처리
 */
async function parseFormWithFormidable(req: NextRequest): Promise<{ fields: Fields; files: Files }> {
  const uploadDir = process.env.TEMP_UPLOAD_DIR || "/tmp/swp-upload";
  
  const form = formidable({
    maxFileSize: 1024 * 1024 * 1024, // 1GB 제한
    maxFieldsSize: 20 * 1024 * 1024, // 20MB 메타데이터 제한
    allowEmptyFiles: false,
    multiples: true, // 여러 파일 업로드 허용
    keepExtensions: true,
    // 임시 디렉토리에 파일 저장 (스트리밍 방식)
    uploadDir,
    filename: (_name, _ext, part) => {
      // 원본 파일명 유지
      return part.originalFilename || `upload_${Date.now()}`;
    },
  });

  // 임시 디렉토리 생성
  await fs.mkdir(uploadDir, { recursive: true });

  // Next.js Request를 Node.js IncomingMessage 형태로 변환
  // formidable은 headers 정보가 필요함 (특히 content-length, content-type)
  
  // ReadableStream을 AsyncIterable로 변환
  async function* streamToAsyncIterable(stream: ReadableStream<Uint8Array>) {
    const reader = stream.getReader();
    try {
      while (true) {
        const { done, value } = await reader.read();
        if (done) break;
        yield value;
      }
    } finally {
      reader.releaseLock();
    }
  }
  
  const bodyStream = req.body 
    ? Readable.from(streamToAsyncIterable(req.body as ReadableStream<Uint8Array>))
    : Readable.from([]);
  
  // headers 정보 추가 (formidable이 필요로 함)
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  const mockIncomingMessage: any = Object.assign(bodyStream, {
    headers: {
      'content-type': req.headers.get('content-type') || '',
      'content-length': req.headers.get('content-length') || '0',
    },
    method: req.method,
    url: req.url,
  });

  return new Promise((resolve, reject) => {
    form.parse(mockIncomingMessage, (err, fields, files) => {
      if (err) {
        reject(err);
        return;
      }
      resolve({ fields, files });
    });
  });
}

interface InBoxFileInfo {
  CPY_CD: string;
  FILE_NM: string;
  OFDC_NO: string | null;
  PROJ_NO: string;
  OWN_DOC_NO: string;
  REV_NO: string;
  STAGE: string;
  STAT: string;
  FILE_SZ: string;
  FLD_PATH: string;
}

/**
 * 파일명 파싱: [OWN_DOC_NO]_[REV_NO]_[STAGE].[확장자] 또는 [OWN_DOC_NO]_[REV_NO]_[STAGE]_[자유-파일명].[확장자]
 * 자유 파일명은 선택사항이며, 포함될 경우 언더스코어를 포함할 수 있음
 */
function parseFileName(fileName: string) {
  // 경로 순회 공격 방지 (Path Traversal Attack)
  if (fileName.includes("..") || fileName.includes("/") || fileName.includes("\\")) {
    throw new Error(`잘못된 파일명입니다 (경로 문자 포함): ${fileName}`);
  }
  
  const lastDotIndex = fileName.lastIndexOf(".");
  
  // 확장자 검증
  if (lastDotIndex === -1) {
    throw new Error(`파일 확장자가 없습니다: ${fileName}`);
  }
  
  const extension = fileName.substring(lastDotIndex + 1);
  const nameWithoutExt = fileName.substring(0, lastDotIndex);

  const parts = nameWithoutExt.split("_");
  
  // 최소 3개 파트 필요: ownDocNo, revNo, stage (fileName은 선택사항)
  if (parts.length < 3) {
    throw new Error(
      `잘못된 파일명 형식입니다: ${fileName}. ` +
      `형식: [OWN_DOC_NO]_[REV_NO]_[STAGE].[확장자] (언더스코어 최소 2개 필요)`
    );
  }

  // 앞에서부터 3개는 고정: ownDocNo, revNo, stage
  const ownDocNo = parts[0];
  const revNo = parts[1];
  const stage = parts[2];
  
  // 나머지는 자유 파일명 (선택사항, 언더스코어 포함 가능)
  const customFileName = parts.length > 3 ? parts.slice(3).join("_") : "";

  // 필수 항목이 비어있지 않은지 확인
  if (!ownDocNo || ownDocNo.trim() === "") {
    throw new Error(`문서번호(OWN_DOC_NO)가 비어있습니다: ${fileName}`);
  }

  if (!revNo || revNo.trim() === "") {
    throw new Error(`리비전 번호(REV_NO)가 비어있습니다: ${fileName}`);
  }

  if (!stage || stage.trim() === "") {
    throw new Error(`스테이지(STAGE)가 비어있습니다: ${fileName}`);
  }

  return { 
    ownDocNo: ownDocNo.trim(), 
    revNo: revNo.trim(), 
    stage: stage.trim(), 
    fileName: customFileName.trim(), 
    extension 
  };
}

/**
 * 현재 시간을 YYYYMMDDhhmmss 형식으로 반환
 */
function generateTimestamp(): string {
  const now = new Date();
  const year = now.getFullYear().toString();
  const month = (now.getMonth() + 1).toString().padStart(2, "0");
  const day = now.getDate().toString().padStart(2, "0");
  const hours = now.getHours().toString().padStart(2, "0");
  const minutes = now.getMinutes().toString().padStart(2, "0");
  const seconds = now.getSeconds().toString().padStart(2, "0");
  
  return `${year}${month}${day}${hours}${minutes}${seconds}`;
}

/**
 * 디스크 공간 검사 (간단한 휴리스틱 방식)
 * 디렉토리에 쓰기 권한이 있는지 확인
 * 
 * 참고: 실제 디스크 공간 체크를 위해서는 'check-disk-space' 라이브러리 사용으로 변경하기
 */
async function checkDiskWritable(directory: string): Promise<boolean> {
  try {
    // 테스트 쓰기로 권한 검증
    await fs.mkdir(directory, { recursive: true });
    const testFile = path.join(directory, `.disk-check-${Date.now()}`);
    await fs.writeFile(testFile, "test");
    await fs.unlink(testFile);
    return true;
  } catch (error) {
    console.error(`[checkDiskWritable] 디스크 쓰기 권한 검사 실패: ${error}`);
    return false;
  }
}


/**
 * SaveInBoxList API 호출
 */
async function callSaveInBoxList(fileInfos: InBoxFileInfo[], crter: string, crteremail: string): Promise<void> {
  const ddcUrl = process.env.DDC_BASE_URL || "http://60.100.99.217/DDC/Services/WebService.svc";
  const url = `${ddcUrl}/SaveInBoxList`;

  const request = { 
    externalInboxLists: fileInfos,
    crter: crter,
    crteremail: crteremail
  };

  console.log("[callSaveInBoxList] 요청:", JSON.stringify(request, null, 2));

  const response = await fetch(url, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Accept: "application/json",
    },
    body: JSON.stringify(request),
  });

  if (!response.ok) {
    const errorText = await response.text();
    throw new Error(`SaveInBoxList API 호출 실패: ${response.statusText} - ${errorText}`);
  }

  const data = await response.json();
  console.log("[callSaveInBoxList] 응답:", JSON.stringify(data, null, 2));

  // SaveInBoxListResult는 성공한 FLD_PATH들을 쉼표로 구분한 문자열
  // 예: "\\\\ProjNo\\\\CpyCd\\\\YYYYMMDDhhmmss, \\\\ProjNo\\\\CpyCd\\\\YYYYMMDDhhmmss"
  if (!data.SaveInBoxListResult) {
    throw new Error("SaveInBoxList API 실패: 응답에 SaveInBoxListResult가 없습니다.");
  }

  const result = data.SaveInBoxListResult;
  
  // 문자열 응답인 경우 (정상)
  if (typeof result === "string") {
    if (result.trim().length === 0) {
      throw new Error("SaveInBoxList API 실패: 빈 응답이 반환되었습니다.");
    }
    // 성공한 FLD_PATH 개수 로깅
    const successPaths = result.split(",").map(p => p.trim()).filter(p => p.length > 0);
    console.log(`[callSaveInBoxList] 성공: ${successPaths.length}개 파일 등록 완료`);
    return;
  }
  
  // 객체 응답인 경우 (레거시 또는 에러)
  if (typeof result === "object" && result !== null) {
    const objResult = result as { success?: boolean; message?: string };
    if (objResult.success === false) {
      throw new Error(
        `SaveInBoxList API 실패: ${objResult.message || "알 수 없는 오류"}`
      );
    }
  }
}

/**
 * POST /api/swp/upload
 * 스트리밍 방식으로 파일 업로드 (대용량 파일 지원)
 */
export async function POST(request: NextRequest) {
  try {
    // 환경 변수 검증
    const swpMountDir = process.env.SWP_MOUNT_DIR;
    const ddcBaseUrl = process.env.DDC_BASE_URL;
    
    if (!swpMountDir) {
      return NextResponse.json(
        { success: false, message: "서버 설정 오류: SWP_MOUNT_DIR 환경 변수가 설정되지 않았습니다." },
        { status: 500 }
      );
    }
    
    if (!ddcBaseUrl) {
      return NextResponse.json(
        { success: false, message: "서버 설정 오류: DDC_BASE_URL 환경 변수가 설정되지 않았습니다." },
        { status: 500 }
      );
    }
    
    // 세션에서 사용자 ID와 이메일 가져오기
    const session = await getServerSession(authOptions);
    
    if (!session?.user?.id) {
      return NextResponse.json(
        { success: false, message: "인증되지 않은 사용자입니다." },
        { status: 401 }
      );
    }
    
    const crter = String(session.user.id); // 사용자 ID를 문자열로 변환
    const crteremail = session.user.email || ""; // 사용자 이메일
    console.log(`[upload] 사용자 ID (crter): ${crter}, 이메일 (crteremail): ${crteremail}`);

    // 스트리밍 방식으로 FormData 파싱
    debugLog("[upload] 스트리밍 파싱 시작...");
    const { fields, files } = await parseFormWithFormidable(request);
    debugSuccess("[upload] 스트리밍 파싱 완료");
    
    // 필드 추출
    const projNoArray = fields.projNo;
    const vndrCdArray = fields.vndrCd;
    
    const projNo = Array.isArray(projNoArray) ? projNoArray[0] : projNoArray;
    const vndrCd = Array.isArray(vndrCdArray) ? vndrCdArray[0] : vndrCdArray;
    
    if (!projNo || !vndrCd) {
      return NextResponse.json(
        { success: false, message: "projNo와 vndrCd는 필수입니다." },
        { status: 400 }
      );
    }

    // vndrCd를 CPY_CD로 사용
    console.log(`[upload] vndrCd를 CPY_CD로 사용: ${vndrCd}`);

    // 파일 배열 추출 (formidable은 files.files로 저장)
    const uploadedFiles = files.files;
    
    if (!uploadedFiles || (Array.isArray(uploadedFiles) && uploadedFiles.length === 0)) {
      return NextResponse.json(
        { success: false, message: "업로드할 파일이 없습니다." },
        { status: 400 }
      );
    }

    // 단일 파일인 경우 배열로 변환
    const fileArray = Array.isArray(uploadedFiles) ? uploadedFiles : [uploadedFiles];

    const result = {
      successCount: 0,
      failedCount: 0,
      details: [] as Array<{ fileName: string; success: boolean; error?: string; networkPath?: string }>,
    };

    const inBoxFileInfos: InBoxFileInfo[] = [];
    
    // 업로드 시점의 timestamp 생성 (모든 파일에 동일한 timestamp 사용)
    const uploadTimestamp = generateTimestamp();
    console.log(`[upload] 업로드 타임스탬프 생성: ${uploadTimestamp}`);
    
    // 디스크 쓰기 권한 사전 검사
    const targetDirectory = path.join(swpMountDir, projNo, vndrCd, uploadTimestamp);
    const isWritable = await checkDiskWritable(targetDirectory);
    if (!isWritable) {
      return NextResponse.json(
        { success: false, message: "파일 저장 경로에 쓰기 권한이 없습니다." },
        { status: 500 }
      );
    }

    // 임시 파일 경로 저장 (정리용)
    const tempFilesToClean: string[] = [];
    // 성공적으로 저장된 파일 경로 (롤백용)
    const savedFiles: string[] = [];

    for (const file of fileArray) {
      try {
        const formidableFile = file as FormidableFile;
        const originalFileName = formidableFile.originalFilename || "unknown";
        
        // 파일명 파싱
        const parsed = parseFileName(originalFileName);
        debugLog(`[upload] 파일명 파싱:`, { originalFileName, parsed });

        // 네트워크 경로 생성 (timestamp를 경로에만 사용)
        const networkPath = path.join(swpMountDir, projNo, vndrCd, uploadTimestamp, originalFileName);

        // 파일 중복 체크
        try {
          await fs.access(networkPath, fs.constants.F_OK);
          result.failedCount++;
          result.details.push({
            fileName: originalFileName,
            success: false,
            error: "파일이 이미 존재합니다.",
          });
          console.warn(`[upload] 파일 중복: ${networkPath}`);
          
          // 임시 파일 정리
          tempFilesToClean.push(formidableFile.filepath);
          continue;
        } catch {
          // 파일이 존재하지 않음 (정상)
        }

        // 디렉토리 생성
        const directory = path.dirname(networkPath);
        await fs.mkdir(directory, { recursive: true });

        // 🚀 스트리밍 방식: 임시 파일을 최종 경로로 이동 (메모리 복사 없음)
        const tempFilePath = formidableFile.filepath;
        const fileSize = formidableFile.size;
        
        debugLog(`[upload] 파일 이동 시작: ${originalFileName}`, {
          tempPath: tempFilePath,
          finalPath: networkPath,
          size: fileSize
        });
        
        // 파일 이동 (rename이 더 빠르지만, 같은 파일시스템이 아니면 실패하므로 copyFile 사용)
        // 리네임은 같은 시스템 안에서, 파일 메타데이터만 변경해서 빠른데 swp는 외부시스템이므로 항상 캐치문에 걸릴것임
        try {
          await fs.rename(tempFilePath, networkPath);
          debugSuccess(`[upload] 파일 이동 완료 (rename): ${networkPath}`);
        } catch (renameError) {
          // rename 실패 시 copyFile + unlink 사용
          debugLog(`[upload] rename 실패(네트워크경로 - 정상임), copyFile 사용: ${originalFileName}`);
          await fs.copyFile(tempFilePath, networkPath);
          tempFilesToClean.push(tempFilePath);
          debugSuccess(`[upload] 파일 복사 완료: ${networkPath}`);
        }
        
        // 저장된 파일 검증
        const savedFileStats = await fs.stat(networkPath);
        debugLog(`[upload] 저장된 파일 검증`, {
          fileName: originalFileName,
          savedSize: savedFileStats.size,
          expectedSize: fileSize,
          sizeMatch: savedFileStats.size === fileSize
        });
        
        if (savedFileStats.size !== fileSize) {
          throw new Error(`파일 크기 불일치: 예상 ${fileSize} bytes, 실제 ${savedFileStats.size} bytes`);
        }

        // InBox 파일 정보 준비 (FLD_PATH에 업로드 timestamp 사용)
        // 시스템 요구사항: 중간 구분자만 이스케이프 (\\\\)
        const fldPath = `\\${projNo}\\${vndrCd}\\\\${uploadTimestamp}`;

        inBoxFileInfos.push({
          CPY_CD: vndrCd,
          FILE_NM: originalFileName,
          OFDC_NO: null,
          PROJ_NO: projNo,
          OWN_DOC_NO: parsed.ownDocNo,
          REV_NO: parsed.revNo,
          STAGE: parsed.stage,
          STAT: "SCW01",
          FILE_SZ: String(fileSize),
          FLD_PATH: fldPath,
        });

        // 성공한 파일 경로 저장 (롤백용)
        savedFiles.push(networkPath);
        
        result.successCount++;
        result.details.push({
          fileName: originalFileName,
          success: true,
          networkPath,
        });
      } catch (error) {
        const formidableFile = file as FormidableFile;
        const originalFileName = formidableFile.originalFilename || "unknown";
        
        result.failedCount++;
        result.details.push({
          fileName: originalFileName,
          success: false,
          error: error instanceof Error ? error.message : "알 수 없는 오류",
        });
        console.error(`[upload] 파일 처리 실패: ${originalFileName}`, error);
        debugError(`[upload] 파일 처리 실패: ${originalFileName}`, {
          error: error instanceof Error ? error.message : String(error),
          stack: error instanceof Error ? error.stack : undefined
        });
        
        // 임시 파일 정리
        if (formidableFile.filepath) {
          tempFilesToClean.push(formidableFile.filepath);
        }
      }
    }

    // 임시 파일 정리
    for (const tempFile of tempFilesToClean) {
      try {
        await fs.unlink(tempFile);
        debugLog(`[upload] 임시 파일 삭제: ${tempFile}`);
      } catch (cleanError) {
        console.warn(`[upload] 임시 파일 삭제 실패 (무시): ${tempFile}`, cleanError);
      }
    }

    // SaveInBoxList API 호출 (트랜잭션 방식)
    if (inBoxFileInfos.length > 0) {
      console.log(`[upload] SaveInBoxList API 호출: ${inBoxFileInfos.length}개 파일`);
      try {
        await callSaveInBoxList(inBoxFileInfos, crter, crteremail);
      } catch (apiError) {
        // API 호출 실패 시 롤백: 저장된 파일 삭제
        console.error(`[upload] SaveInBoxList API 실패, 롤백 시작...`, apiError);
        debugError(`[upload] SaveInBoxList API 실패, 롤백 시작`, {
          error: apiError instanceof Error ? apiError.message : String(apiError),
          filesCount: savedFiles.length
        });
        
        for (const filePath of savedFiles) {
          try {
            await fs.unlink(filePath);
            console.log(`[upload] 롤백: 파일 삭제 - ${filePath}`);
          } catch (unlinkError) {
            console.warn(`[upload] 롤백 중 파일 삭제 실패 (무시): ${filePath}`, unlinkError);
          }
        }
        
        // 클라이언트에게 에러 반환
        return NextResponse.json(
          {
            success: false,
            message: `파일 등록 API 호출 실패: ${apiError instanceof Error ? apiError.message : "알 수 없는 오류"}`,
            rollback: true,
          },
          { status: 500 }
        );
      }
    }

    // ⚠️ Full API 방식으로 전환했으므로 로컬 DB 동기화는 불필요
    // 업로드 성공 시 SaveInBoxList API 호출만으로 충분 (이미 위에서 완료)

    // 결과 메시지 생성
    let message: string;
    let success: boolean;

    if (result.failedCount === 0) {
      success = true;
      message = `${result.successCount}개 파일이 성공적으로 업로드되었습니다.`;
    } else if (result.successCount === 0) {
      success = false;
      message = `모든 파일 업로드에 실패했습니다. (${result.failedCount}개)`;
    } else {
      success = true;
      message = `${result.successCount}개 파일 업로드 성공, ${result.failedCount}개 실패`;
    }

    console.log(`[upload] 완료:`, { success, message, result });

    return NextResponse.json({
      success,
      message,
      successCount: result.successCount,
      failedCount: result.failedCount,
      details: result.details,
      uploadTimestamp: new Date().toISOString(),
      affectedVndrCd: vndrCd,
    });
  } catch (error) {
    console.error("[upload] 오류:", error);
    debugError(`[upload] 전체 프로세스 실패`, {
      error: error instanceof Error ? error.message : String(error),
      stack: error instanceof Error ? error.stack : undefined
    });
    return NextResponse.json(
      {
        success: false,
        message: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.",
      },
      { status: 500 }
    );
  }
}