import { Request, Response } from "express"; import { AuthenticatedRequest } from "../types/auth"; import multer from "multer"; import path from "path"; import fs from "fs"; import { PrismaClient } from "@prisma/client"; import { generateUUID } from "../utils/generateId"; const prisma = new PrismaClient(); // 업로드 디렉토리 설정 (회사별로 분리) const baseUploadDir = path.join(process.cwd(), "uploads"); if (!fs.existsSync(baseUploadDir)) { fs.mkdirSync(baseUploadDir, { recursive: true }); } // 회사별 + 날짜별 디렉토리 생성 함수 const getCompanyUploadDir = (companyCode: string, dateFolder?: string) => { // 회사코드가 *인 경우 company_*로 변환 const actualCompanyCode = companyCode === "*" ? "company_*" : companyCode; // 날짜 폴더가 제공되지 않은 경우 오늘 날짜 사용 (YYYY/MM/DD 형식) if (!dateFolder) { const today = new Date(); const year = today.getFullYear(); const month = String(today.getMonth() + 1).padStart(2, "0"); const day = String(today.getDate()).padStart(2, "0"); dateFolder = `${year}/${month}/${day}`; } const companyDir = path.join(baseUploadDir, actualCompanyCode, dateFolder); if (!fs.existsSync(companyDir)) { fs.mkdirSync(companyDir, { recursive: true }); } return companyDir; }; // Multer 설정 const storage = multer.diskStorage({ destination: (req, file, cb) => { // 임시 디렉토리에 저장 (나중에 올바른 위치로 이동) const tempDir = path.join(baseUploadDir, "temp"); if (!fs.existsSync(tempDir)) { fs.mkdirSync(tempDir, { recursive: true }); } console.log(`📁 임시 업로드 디렉토리: ${tempDir}`); cb(null, tempDir); }, filename: (req, file, cb) => { // 타임스탬프_원본파일명 형태로 저장 (회사코드는 디렉토리로 분리됨) const timestamp = Date.now(); const sanitizedName = file.originalname.replace(/[^a-zA-Z0-9.-]/g, "_"); const savedFileName = `${timestamp}_${sanitizedName}`; console.log(`📄 저장 파일명: ${savedFileName}`); cb(null, savedFileName); }, }); const upload = multer({ storage: storage, limits: { fileSize: 50 * 1024 * 1024, // 50MB 제한 }, fileFilter: (req, file, cb) => { // 프론트엔드에서 전송된 accept 정보 확인 const acceptHeader = req.body?.accept; console.log("🔍 파일 타입 검증:", { fileName: file.originalname, mimeType: file.mimetype, acceptFromFrontend: acceptHeader, }); // 프론트엔드에서 */* 또는 * 허용한 경우 모든 파일 허용 if ( acceptHeader && (acceptHeader.includes("*/*") || acceptHeader.includes("*")) ) { console.log("✅ 와일드카드 허용: 모든 파일 타입 허용"); cb(null, true); return; } // 기본 허용 파일 타입 const defaultAllowedTypes = [ "image/jpeg", "image/png", "image/gif", "text/html", // HTML 파일 추가 "text/plain", // 텍스트 파일 추가 "application/pdf", "application/msword", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "application/vnd.ms-excel", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "application/zip", // ZIP 파일 추가 "application/x-zip-compressed", // ZIP 파일 (다른 MIME 타입) ]; if (defaultAllowedTypes.includes(file.mimetype)) { console.log("✅ 기본 허용 파일 타입:", file.mimetype); cb(null, true); } else { console.log("❌ 허용되지 않는 파일 타입:", file.mimetype); cb(new Error("허용되지 않는 파일 타입입니다.")); } }, }); /** * 파일 업로드 및 attach_file_info 테이블에 저장 */ export const uploadFiles = async ( req: AuthenticatedRequest, res: Response ): Promise => { try { console.log("📤 파일 업로드 요청 수신:", { body: req.body, companyCode: req.body.companyCode, writer: req.body.writer, docType: req.body.docType, user: req.user ? { userId: req.user.userId, companyCode: req.user.companyCode, deptCode: req.user.deptCode, } : "no user", files: req.files ? (req.files as Express.Multer.File[]).map((f) => f.originalname) : "none", }); if (!req.files || (req.files as Express.Multer.File[]).length === 0) { res.status(400).json({ success: false, message: "업로드할 파일이 없습니다.", }); return; } const files = req.files as Express.Multer.File[]; // 파라미터 확인 및 로깅 console.log("📤 파일 업로드 요청 수신:", { filesCount: files?.length || 0, bodyKeys: Object.keys(req.body), fullBody: req.body, // 전체 body 내용 확인 }); const { docType = "DOCUMENT", docTypeName = "일반 문서", targetObjid, parentTargetObjid, // 테이블 연결 정보 (새로 추가) linkedTable, linkedField, recordId, autoLink, // 가상 파일 컬럼 정보 columnName, isVirtualFileColumn, } = req.body; // 회사코드와 작성자 정보 결정 (우선순위: 요청 body > 사용자 토큰 정보 > 기본값) const companyCode = req.body.companyCode || (req.user as any)?.companyCode || "DEFAULT"; const writer = req.body.writer || (req.user as any)?.userId || "system"; // 자동 연결 로직 - target_objid 자동 생성 let finalTargetObjid = targetObjid; if (autoLink === "true" && linkedTable && recordId) { // 가상 파일 컬럼의 경우 컬럼명도 포함한 target_objid 생성 if (isVirtualFileColumn === "true" && columnName) { finalTargetObjid = `${linkedTable}:${recordId}:${columnName}`; } else { finalTargetObjid = `${linkedTable}:${recordId}`; } console.log("🔗 자동 연결 활성화:", { linkedTable, linkedField, recordId, columnName, isVirtualFileColumn, generatedTargetObjid: finalTargetObjid, }); } console.log("🔍 사용자 정보 결정:", { bodyCompanyCode: req.body.companyCode, userCompanyCode: (req.user as any)?.companyCode, finalCompanyCode: companyCode, bodyWriter: req.body.writer, userWriter: (req.user as any)?.userId, finalWriter: writer, }); const savedFiles = []; for (const file of files) { // 파일 확장자 추출 const fileExt = path .extname(file.originalname) .toLowerCase() .replace(".", ""); // 파일 경로 설정 (회사별 + 날짜별 디렉토리 구조 반영) const today = new Date(); const year = today.getFullYear(); const month = String(today.getMonth() + 1).padStart(2, "0"); const day = String(today.getDate()).padStart(2, "0"); const dateFolder = `${year}/${month}/${day}`; // 회사코드가 *인 경우 company_*로 변환 const actualCompanyCode = companyCode === "*" ? "company_*" : companyCode; const relativePath = `/${actualCompanyCode}/${dateFolder}/${file.filename}`; const fullFilePath = `/uploads${relativePath}`; console.log("📂 파일 경로 설정:", { companyCode, filename: file.filename, relativePath, fullFilePath, }); // 임시 파일을 최종 위치로 이동 const tempFilePath = file.path; // Multer가 저장한 임시 파일 경로 const finalUploadDir = getCompanyUploadDir(companyCode, dateFolder); const finalFilePath = path.join(finalUploadDir, file.filename); console.log("📦 파일 이동:", { from: tempFilePath, to: finalFilePath, }); // 파일 이동 fs.renameSync(tempFilePath, finalFilePath); // attach_file_info 테이블에 저장 const fileRecord = await prisma.attach_file_info.create({ data: { objid: parseInt( generateUUID().replace(/-/g, "").substring(0, 15), 16 ), target_objid: finalTargetObjid, saved_file_name: file.filename, real_file_name: file.originalname, doc_type: docType, doc_type_name: docTypeName, file_size: file.size, file_ext: fileExt, file_path: fullFilePath, // 회사별 디렉토리 포함된 경로 company_code: companyCode, // 회사코드 추가 writer: writer, regdate: new Date(), status: "ACTIVE", parent_target_objid: parentTargetObjid, }, }); console.log("💾 파일 정보 DB 저장 완료:", { objid: fileRecord.objid.toString(), saved_file_name: fileRecord.saved_file_name, real_file_name: fileRecord.real_file_name, file_size: fileRecord.file_size?.toString(), }); savedFiles.push({ objid: fileRecord.objid.toString(), savedFileName: fileRecord.saved_file_name, realFileName: fileRecord.real_file_name, fileSize: Number(fileRecord.file_size), fileExt: fileRecord.file_ext, filePath: fileRecord.file_path, docType: fileRecord.doc_type, docTypeName: fileRecord.doc_type_name, targetObjid: fileRecord.target_objid, parentTargetObjid: fileRecord.parent_target_objid, companyCode: companyCode, // 실제 전달받은 회사코드 writer: fileRecord.writer, regdate: fileRecord.regdate?.toISOString(), status: fileRecord.status, }); console.log("✅ 파일 저장 결과:", { objid: fileRecord.objid.toString(), company_code: companyCode, file_path: fileRecord.file_path, writer: fileRecord.writer, }); } res.json({ success: true, message: `${files.length}개 파일 업로드 완료`, files: savedFiles, }); } catch (error) { console.error("파일 업로드 오류:", error); res.status(500).json({ success: false, message: "파일 업로드 중 오류가 발생했습니다.", error: error instanceof Error ? error.message : "알 수 없는 오류", }); } }; /** * 파일 삭제 (논리적 삭제) */ export const deleteFile = async ( req: AuthenticatedRequest, res: Response ): Promise => { try { const { objid } = req.params; const { writer = "system" } = req.body; console.log("🗑️ 파일 삭제 요청:", { objid, writer }); // 파일 상태를 DELETED로 변경 (논리적 삭제) const deletedFile = await prisma.attach_file_info.update({ where: { objid: parseInt(objid), }, data: { status: "DELETED", }, }); console.log("✅ 파일 삭제 완료 (논리적):", { objid: deletedFile.objid.toString(), status: deletedFile.status, }); res.json({ success: true, message: "파일이 삭제되었습니다.", }); } catch (error) { console.error("파일 삭제 오류:", error); res.status(500).json({ success: false, message: "파일 삭제 중 오류가 발생했습니다.", error: error instanceof Error ? error.message : "알 수 없는 오류", }); } }; /** * 테이블 연결된 파일 조회 */ export const getLinkedFiles = async ( req: AuthenticatedRequest, res: Response ): Promise => { try { const { tableName, recordId } = req.params; console.log("📎 연결된 파일 조회 요청:", { tableName, recordId, }); // target_objid 생성 (테이블명:레코드ID 형식) const baseTargetObjid = `${tableName}:${recordId}`; console.log("🔍 파일 조회 쿼리:", { tableName, recordId, baseTargetObjid, queryPattern: `${baseTargetObjid}%`, }); // 기본 target_objid와 파일 컬럼 패턴 모두 조회 (tableName:recordId% 패턴) const files = await prisma.attach_file_info.findMany({ where: { target_objid: { startsWith: baseTargetObjid, // tableName:recordId로 시작하는 모든 파일 }, status: "ACTIVE", }, orderBy: { regdate: "desc", }, }); console.log("📁 조회된 파일 목록:", { foundFiles: files.length, targetObjids: files.map((f) => f.target_objid), }); const fileList = files.map((file: any) => ({ objid: file.objid.toString(), savedFileName: file.saved_file_name, realFileName: file.real_file_name, fileSize: Number(file.file_size), fileExt: file.file_ext, filePath: file.file_path, docType: file.doc_type, docTypeName: file.doc_type_name, targetObjid: file.target_objid, parentTargetObjid: file.parent_target_objid, writer: file.writer, regdate: file.regdate?.toISOString(), status: file.status, })); console.log("✅ 연결된 파일 조회 완료:", { baseTargetObjid, fileCount: fileList.length, }); res.json({ success: true, files: fileList, totalCount: fileList.length, targetObjid: baseTargetObjid, // 기준 target_objid 반환 }); } catch (error) { console.error("연결된 파일 조회 오류:", error); res.status(500).json({ success: false, message: "연결된 파일 조회 중 오류가 발생했습니다.", error: error instanceof Error ? error.message : "알 수 없는 오류", }); } }; /** * 파일 목록 조회 */ export const getFileList = async ( req: AuthenticatedRequest, res: Response ): Promise => { try { const { targetObjid, docType, companyCode } = req.query; console.log("📋 파일 목록 조회 요청:", { targetObjid, docType, companyCode, }); const where: any = { status: "ACTIVE", }; if (targetObjid) { where.target_objid = targetObjid as string; } if (docType) { where.doc_type = docType as string; } const files = await prisma.attach_file_info.findMany({ where, orderBy: { regdate: "desc", }, }); const fileList = files.map((file: any) => ({ objid: file.objid.toString(), savedFileName: file.saved_file_name, realFileName: file.real_file_name, fileSize: Number(file.file_size), fileExt: file.file_ext, filePath: file.file_path, docType: file.doc_type, docTypeName: file.doc_type_name, targetObjid: file.target_objid, parentTargetObjid: file.parent_target_objid, writer: file.writer, regdate: file.regdate?.toISOString(), status: file.status, })); res.json({ success: true, files: fileList, }); } catch (error) { console.error("파일 목록 조회 오류:", error); res.status(500).json({ success: false, message: "파일 목록 조회 중 오류가 발생했습니다.", error: error instanceof Error ? error.message : "알 수 없는 오류", }); } }; /** * 파일 다운로드 */ export const downloadFile = async ( req: AuthenticatedRequest, res: Response ): Promise => { try { const { objid } = req.params; console.log("📥 파일 다운로드 요청:", { objid }); const fileRecord = await prisma.attach_file_info.findUnique({ where: { objid: parseInt(objid), }, }); if (!fileRecord || fileRecord.status !== "ACTIVE") { res.status(404).json({ success: false, message: "파일을 찾을 수 없습니다.", }); return; } // 파일 경로에서 회사코드와 날짜 폴더 추출 (예: /uploads/company_*/2025/09/05/timestamp_filename.ext) const filePathParts = fileRecord.file_path!.split("/"); const companyCode = filePathParts[2] || "DEFAULT"; // /uploads/company_*/2025/09/05/filename.ext에서 company_* 추출 const fileName = fileRecord.saved_file_name!; // 파일 경로에 날짜 구조가 있는지 확인 (YYYY/MM/DD) let dateFolder = ""; if (filePathParts.length >= 6) { // /uploads/company_*/2025/09/05/filename.ext 형태 dateFolder = `${filePathParts[3]}/${filePathParts[4]}/${filePathParts[5]}`; } const companyUploadDir = getCompanyUploadDir( companyCode, dateFolder || undefined ); const filePath = path.join(companyUploadDir, fileName); console.log("📥 파일 다운로드 경로 확인:", { stored_file_path: fileRecord.file_path, company_code: companyCode, company_upload_dir: companyUploadDir, final_file_path: filePath, }); if (!fs.existsSync(filePath)) { console.error("❌ 파일 없음:", filePath); res.status(404).json({ success: false, message: `실제 파일을 찾을 수 없습니다: ${filePath}`, }); return; } // 파일 다운로드 헤더 설정 res.setHeader( "Content-Disposition", `attachment; filename="${encodeURIComponent(fileRecord.real_file_name!)}"` ); res.setHeader("Content-Type", "application/octet-stream"); // 파일 스트림 전송 const fileStream = fs.createReadStream(filePath); fileStream.pipe(res); console.log("✅ 파일 다운로드 시작:", { objid: fileRecord.objid.toString(), real_file_name: fileRecord.real_file_name, }); } catch (error) { console.error("파일 다운로드 오류:", error); res.status(500).json({ success: false, message: "파일 다운로드 중 오류가 발생했습니다.", error: error instanceof Error ? error.message : "알 수 없는 오류", }); } }; // Multer 미들웨어 export export const uploadMiddleware = upload.array("files", 10); // 최대 10개 파일