import { Request, Response } from "express"; import { AuthenticatedRequest } from "../types/auth"; import multer from "multer"; import path from "path"; import fs from "fs"; import { generateUUID } from "../utils/generateId"; import { query, queryOne } from "../database/db"; // 임시 토큰 저장소 (메모리 기반, 실제 운영에서는 Redis 사용 권장) const tempTokens = new Map(); // 업로드 디렉토리 설정 (회사별로 분리) const baseUploadDir = path.join(process.cwd(), "uploads"); // 디렉토리 생성 함수 (에러 핸들링 포함) const ensureUploadDir = () => { try { if (!fs.existsSync(baseUploadDir)) { fs.mkdirSync(baseUploadDir, { recursive: true }); } } catch (error) { console.warn( `업로드 디렉토리 생성 실패: ${error}. 기존 디렉토리를 사용합니다.` ); } }; // 초기화 시 디렉토리 확인 ensureUploadDir(); // 회사별 + 날짜별 디렉토리 생성 함수 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 }); } cb(null, tempDir); }, filename: (req, file, cb) => { // 타임스탬프_원본파일명 형태로 저장 (회사코드는 디렉토리로 분리됨) const timestamp = Date.now(); console.log("📁 파일명 처리:", { originalname: file.originalname, encoding: file.encoding, mimetype: file.mimetype, }); // UTF-8 인코딩 문제 해결: Buffer를 통한 올바른 디코딩 let decodedName; try { // 파일명이 깨진 경우 Buffer를 통해 올바르게 디코딩 const buffer = Buffer.from(file.originalname, "latin1"); decodedName = buffer.toString("utf8"); console.log("📁 파일명 디코딩:", { original: file.originalname, decoded: decodedName, }); } catch (error) { // 디코딩 실패 시 원본 사용 decodedName = file.originalname; console.log("📁 파일명 디코딩 실패, 원본 사용:", file.originalname); } // 한국어를 포함한 유니코드 문자 보존하면서 안전한 파일명 생성 // 위험한 문자만 제거: / \ : * ? " < > | const sanitizedName = decodedName .replace(/[\/\\:*?"<>|]/g, "_") // 파일시스템에서 금지된 문자만 치환 .replace(/\s+/g, "_") // 공백을 언더스코어로 치환 .replace(/_{2,}/g, "_"); // 연속된 언더스코어를 하나로 축약 const savedFileName = `${timestamp}_${sanitizedName}`; console.log("📁 파일명 변환:", { original: file.originalname, sanitized: sanitizedName, saved: 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; // 프론트엔드에서 */* 또는 * 허용한 경우 모든 파일 허용 if ( acceptHeader && (acceptHeader.includes("*/*") || acceptHeader.includes("*")) ) { cb(null, true); return; } // 기본 허용 파일 타입 const defaultAllowedTypes = [ // 이미지 파일 "image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml", // 텍스트 파일 "text/html", "text/plain", "text/markdown", "text/csv", "application/json", "application/xml", // PDF 파일 "application/pdf", // Microsoft Office 파일 "application/msword", // .doc "application/vnd.openxmlformats-officedocument.wordprocessingml.document", // .docx "application/vnd.ms-excel", // .xls "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", // .xlsx "application/vnd.ms-powerpoint", // .ppt "application/vnd.openxmlformats-officedocument.presentationml.presentation", // .pptx // 한컴오피스 파일 "application/x-hwp", // .hwp (한글) "application/haansofthwp", // .hwp (다른 MIME 타입) "application/vnd.hancom.hwp", // .hwp (또 다른 MIME 타입) "application/vnd.hancom.hwpx", // .hwpx (한글 2014+) "application/x-hwpml", // .hwpml (한글 XML) "application/vnd.hancom.hcdt", // .hcdt (한셀) "application/vnd.hancom.hpt", // .hpt (한쇼) "application/octet-stream", // .hwp, .hwpx (일반적인 바이너리 파일) // 압축 파일 "application/zip", "application/x-zip-compressed", "application/x-rar-compressed", "application/x-7z-compressed", // 미디어 파일 "video/mp4", "video/webm", "video/ogg", "audio/mp3", "audio/mpeg", "audio/wav", "audio/ogg", // Apple/맥 파일 "application/vnd.apple.pages", // .pages (Pages) "application/vnd.apple.numbers", // .numbers (Numbers) "application/vnd.apple.keynote", // .keynote (Keynote) "application/x-iwork-pages-sffpages", // .pages (다른 MIME) "application/x-iwork-numbers-sffnumbers", // .numbers (다른 MIME) "application/x-iwork-keynote-sffkey", // .keynote (다른 MIME) "application/vnd.apple.installer+xml", // .pkg (맥 설치 파일) "application/x-apple-diskimage", // .dmg (맥 디스크 이미지) // 기타 문서 "application/rtf", // .rtf "application/vnd.oasis.opendocument.text", // .odt "application/vnd.oasis.opendocument.spreadsheet", // .ods "application/vnd.oasis.opendocument.presentation", // .odp ]; if (defaultAllowedTypes.includes(file.mimetype)) { cb(null, true); } else { cb(new Error("허용되지 않는 파일 타입입니다.")); } }, }); /** * 파일 업로드 및 attach_file_info 테이블에 저장 */ export const uploadFiles = async ( req: AuthenticatedRequest, res: Response ): Promise => { try { 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[]; 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; // 🔑 템플릿 파일(screen_files:)이나 temp_ 파일은 autoLink 무시 const isTemplateFile = targetObjid && (targetObjid.startsWith('screen_files:') || targetObjid.startsWith('temp_')); if (!isTemplateFile && autoLink === "true" && linkedTable && recordId) { // 가상 파일 컬럼의 경우 컬럼명도 포함한 target_objid 생성 if (isVirtualFileColumn === "true" && columnName) { finalTargetObjid = `${linkedTable}:${recordId}:${columnName}`; } else { finalTargetObjid = `${linkedTable}:${recordId}`; } } const savedFiles = []; for (const file of files) { // 파일명 디코딩 (파일 저장 시와 동일한 로직) let decodedOriginalName; try { const buffer = Buffer.from(file.originalname, "latin1"); decodedOriginalName = buffer.toString("utf8"); console.log("💾 DB 저장용 파일명 디코딩:", { original: file.originalname, decoded: decodedOriginalName, }); } catch (error) { decodedOriginalName = file.originalname; console.log( "💾 DB 저장용 파일명 디코딩 실패, 원본 사용:", file.originalname ); } // 파일 확장자 추출 const fileExt = path .extname(decodedOriginalName) .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 tempFilePath = file.path; // Multer가 저장한 임시 파일 경로 const finalUploadDir = getCompanyUploadDir(companyCode, dateFolder); const finalFilePath = path.join(finalUploadDir, file.filename); // 파일 이동 fs.renameSync(tempFilePath, finalFilePath); // DB에 저장할 경로 (실제 파일 위치와 일치) const relativePath = `/${actualCompanyCode}/${dateFolder}/${file.filename}`; const fullFilePath = `/uploads${relativePath}`; // attach_file_info 테이블에 저장 const objidValue = parseInt( generateUUID().replace(/-/g, "").substring(0, 15), 16 ); const [fileRecord] = await query( `INSERT INTO attach_file_info ( objid, target_objid, saved_file_name, real_file_name, doc_type, doc_type_name, file_size, file_ext, file_path, company_code, writer, regdate, status, parent_target_objid ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING *`, [ objidValue, finalTargetObjid, file.filename, decodedOriginalName, docType, docTypeName, file.size, fileExt, fullFilePath, companyCode, writer, new Date(), "ACTIVE", parentTargetObjid, ] ); 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, }); } 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; // 🔒 멀티테넌시: 현재 사용자의 회사 코드 const companyCode = req.user?.companyCode; // 파일 정보 조회 const fileRecord = await queryOne( `SELECT * FROM attach_file_info WHERE objid = $1`, [parseInt(objid)] ); if (!fileRecord) { res.status(404).json({ success: false, message: "파일을 찾을 수 없습니다.", }); return; } // 🔒 멀티테넌시: 회사 코드 일치 여부 확인 (최고 관리자 제외) if (companyCode !== "*" && fileRecord.company_code !== companyCode) { console.warn("⚠️ 다른 회사 파일 삭제 시도:", { userId: req.user?.userId, userCompanyCode: companyCode, fileCompanyCode: fileRecord.company_code, objid, }); res.status(403).json({ success: false, message: "접근 권한이 없습니다.", }); return; } // 파일 상태를 DELETED로 변경 (논리적 삭제) await query( "UPDATE attach_file_info SET status = $1 WHERE objid = $2", ["DELETED", parseInt(objid)] ); 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; // target_objid 생성 (테이블명:레코드ID 형식) const baseTargetObjid = `${tableName}:${recordId}`; // 기본 target_objid와 파일 컬럼 패턴 모두 조회 (tableName:recordId% 패턴) const files = await query( `SELECT * FROM attach_file_info WHERE target_objid LIKE $1 AND status = $2 ORDER BY regdate DESC`, [`${baseTargetObjid}%`, "ACTIVE"] ); 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, 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; const whereConditions: string[] = ["status = $1"]; const queryParams: any[] = ["ACTIVE"]; let paramIndex = 2; if (targetObjid) { whereConditions.push(`target_objid = $${paramIndex}`); queryParams.push(targetObjid as string); paramIndex++; } if (docType) { whereConditions.push(`doc_type = $${paramIndex}`); queryParams.push(docType as string); paramIndex++; } const files = await query( `SELECT * FROM attach_file_info WHERE ${whereConditions.join(" AND ")} ORDER BY regdate DESC`, queryParams ); 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 getComponentFiles = async ( req: AuthenticatedRequest, res: Response ): Promise => { try { const { screenId, componentId, tableName, recordId, columnName } = req.query; // 🔒 멀티테넌시: 현재 사용자의 회사 코드 가져오기 const companyCode = req.user?.companyCode; console.log("📂 [getComponentFiles] API 호출:", { screenId, componentId, tableName, recordId, columnName, user: req.user?.userId, companyCode, // 🔒 멀티테넌시 로그 }); if (!screenId || !componentId) { console.log("❌ [getComponentFiles] 필수 파라미터 누락"); res.status(400).json({ success: false, message: "screenId와 componentId가 필요합니다.", }); return; } // 1. 템플릿 파일 조회 (화면 설계 시 업로드한 파일들) const templateTargetObjid = `screen_files:${screenId}:${componentId}:${columnName || "field_1"}`; console.log("🔍 [getComponentFiles] 템플릿 파일 조회:", { templateTargetObjid, }); // 🔒 멀티테넌시: 회사별 필터링 추가 const templateFiles = await query( `SELECT * FROM attach_file_info WHERE target_objid = $1 AND status = $2 AND company_code = $3 ORDER BY regdate DESC`, [templateTargetObjid, "ACTIVE", companyCode] ); console.log( "📁 [getComponentFiles] 템플릿 파일 결과 (회사별 필터링):", templateFiles.length ); // 2. 데이터 파일 조회 (실제 레코드와 연결된 파일들) let dataFiles: any[] = []; if (tableName && recordId && columnName) { const dataTargetObjid = `${tableName}:${recordId}:${columnName}`; // 🔒 멀티테넌시: 회사별 필터링 추가 dataFiles = await query( `SELECT * FROM attach_file_info WHERE target_objid = $1 AND status = $2 AND company_code = $3 ORDER BY regdate DESC`, [dataTargetObjid, "ACTIVE", companyCode] ); } // 파일 정보 포맷팅 함수 const formatFileInfo = (file: any, isTemplate: boolean = false) => ({ 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, isTemplate, // 템플릿 파일 여부 표시 isRepresentative: file.is_representative || false, // 대표 파일 여부 }); const formattedTemplateFiles = templateFiles.map((file) => formatFileInfo(file, true) ); const formattedDataFiles = dataFiles.map((file) => formatFileInfo(file, false) ); // 3. 전체 파일 목록 (데이터 파일 우선, 없으면 템플릿 파일 표시) const totalFiles = formattedDataFiles.length > 0 ? formattedDataFiles : formattedTemplateFiles; res.json({ success: true, templateFiles: formattedTemplateFiles, dataFiles: formattedDataFiles, totalFiles, summary: { templateCount: formattedTemplateFiles.length, dataCount: formattedDataFiles.length, totalCount: totalFiles.length, templateTargetObjid, dataTargetObjid: tableName && recordId && columnName ? `${tableName}:${recordId}:${columnName}` : null, }, }); } catch (error) { console.error("컴포넌트 파일 조회 오류:", error); res.status(500).json({ success: false, message: "컴포넌트 파일 조회 중 오류가 발생했습니다.", error: error instanceof Error ? error.message : "알 수 없는 오류", }); } }; /** * 파일 미리보기 (이미지 등) */ export const previewFile = async ( req: AuthenticatedRequest, res: Response ): Promise => { try { const { objid } = req.params; const { serverFilename } = req.query; // 🔒 멀티테넌시: 현재 사용자의 회사 코드 const companyCode = req.user?.companyCode; const fileRecord = await queryOne( "SELECT * FROM attach_file_info WHERE objid = $1 LIMIT 1", [parseInt(objid)] ); if (!fileRecord || fileRecord.status !== "ACTIVE") { res.status(404).json({ success: false, message: "파일을 찾을 수 없습니다.", }); return; } // 🔒 멀티테넌시: 회사 코드 일치 여부 확인 (최고 관리자 제외) if (companyCode !== "*" && fileRecord.company_code !== companyCode) { console.warn("⚠️ 다른 회사 파일 접근 시도:", { userId: req.user?.userId, userCompanyCode: companyCode, fileCompanyCode: fileRecord.company_code, objid, }); res.status(403).json({ success: false, message: "접근 권한이 없습니다.", }); return; } // 파일 경로에서 회사코드와 날짜 폴더 추출 const filePathParts = fileRecord.file_path!.split("/"); let fileCompanyCode = filePathParts[2] || "DEFAULT"; // company_* 처리 (실제 회사 코드로 변환) if (fileCompanyCode === "company_*") { fileCompanyCode = "company_*"; // 실제 디렉토리명 유지 } const fileName = fileRecord.saved_file_name!; // 파일 경로에 날짜 구조가 있는지 확인 (YYYY/MM/DD) let dateFolder = ""; if (filePathParts.length >= 6) { dateFolder = `${filePathParts[3]}/${filePathParts[4]}/${filePathParts[5]}`; } const companyUploadDir = getCompanyUploadDir( fileCompanyCode, dateFolder || undefined ); const filePath = path.join(companyUploadDir, fileName); console.log("🔍 파일 미리보기 경로 확인:", { objid: objid, filePathFromDB: fileRecord.file_path, companyCode: companyCode, dateFolder: dateFolder, fileName: fileName, companyUploadDir: companyUploadDir, finalFilePath: filePath, fileExists: fs.existsSync(filePath), }); if (!fs.existsSync(filePath)) { console.error("❌ 파일 없음:", filePath); res.status(404).json({ success: false, message: `실제 파일을 찾을 수 없습니다: ${filePath}`, }); return; } // MIME 타입 설정 const ext = path.extname(fileName).toLowerCase(); let mimeType = "application/octet-stream"; switch (ext) { case ".jpg": case ".jpeg": mimeType = "image/jpeg"; break; case ".png": mimeType = "image/png"; break; case ".gif": mimeType = "image/gif"; break; case ".webp": mimeType = "image/webp"; break; case ".pdf": mimeType = "application/pdf"; break; default: mimeType = "application/octet-stream"; } // CORS 헤더 설정 (credentials 모드에서는 구체적인 origin 필요) const origin = req.headers.origin || "http://localhost:9771"; res.setHeader("Access-Control-Allow-Origin", origin); res.setHeader( "Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS" ); res.setHeader( "Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With, Accept, Origin" ); res.setHeader("Access-Control-Allow-Credentials", "true"); // 캐시 헤더 설정 res.setHeader("Cache-Control", "public, max-age=3600"); res.setHeader("Content-Type", mimeType); // 파일 스트림으로 전송 const fileStream = fs.createReadStream(filePath); fileStream.pipe(res); } catch (error) { console.error("파일 미리보기 오류:", error); res.status(500).json({ success: false, message: "파일 미리보기 중 오류가 발생했습니다.", }); } }; /** * 파일 다운로드 */ export const downloadFile = async ( req: AuthenticatedRequest, res: Response ): Promise => { try { const { objid } = req.params; // 🔒 멀티테넌시: 현재 사용자의 회사 코드 const companyCode = req.user?.companyCode; const fileRecord = await queryOne( `SELECT * FROM attach_file_info WHERE objid = $1`, [parseInt(objid)] ); if (!fileRecord || fileRecord.status !== "ACTIVE") { res.status(404).json({ success: false, message: "파일을 찾을 수 없습니다.", }); return; } // 🔒 멀티테넌시: 회사 코드 일치 여부 확인 (최고 관리자 제외) if (companyCode !== "*" && fileRecord.company_code !== companyCode) { console.warn("⚠️ 다른 회사 파일 다운로드 시도:", { userId: req.user?.userId, userCompanyCode: companyCode, fileCompanyCode: fileRecord.company_code, objid, }); res.status(403).json({ success: false, message: "접근 권한이 없습니다.", }); return; } // 파일 경로에서 회사코드와 날짜 폴더 추출 (예: /uploads/company_*/2025/09/05/timestamp_filename.ext) const filePathParts = fileRecord.file_path!.split("/"); let fileCompanyCode = filePathParts[2] || "DEFAULT"; // /uploads/company_*/2025/09/05/filename.ext에서 company_* 추출 // company_* 처리 (실제 회사 코드로 변환) if (fileCompanyCode === "company_*") { fileCompanyCode = "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( fileCompanyCode, dateFolder || undefined ); const filePath = path.join(companyUploadDir, fileName); console.log("🔍 파일 다운로드 경로 확인:", { objid: objid, filePathFromDB: fileRecord.file_path, companyCode: companyCode, dateFolder: dateFolder, fileName: fileName, companyUploadDir: companyUploadDir, finalFilePath: filePath, fileExists: fs.existsSync(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); } catch (error) { console.error("파일 다운로드 오류:", error); res.status(500).json({ success: false, message: "파일 다운로드 중 오류가 발생했습니다.", error: error instanceof Error ? error.message : "알 수 없는 오류", }); } }; /** * Google Docs Viewer용 임시 공개 토큰 생성 */ export const generateTempToken = async ( req: AuthenticatedRequest, res: Response ) => { try { const { objid } = req.params; if (!objid) { res.status(400).json({ success: false, message: "파일 ID가 필요합니다.", }); return; } // 파일 존재 확인 const fileRecord = await queryOne( "SELECT * FROM attach_file_info WHERE objid = $1 LIMIT 1", [objid] ); if (!fileRecord) { res.status(404).json({ success: false, message: "파일을 찾을 수 없습니다.", }); return; } // 임시 토큰 생성 (30분 유효) const token = generateUUID(); const expires = Date.now() + 30 * 60 * 1000; // 30분 tempTokens.set(token, { objid: objid, expires: expires, }); // 만료된 토큰 정리 (메모리 누수 방지) const now = Date.now(); for (const [key, value] of tempTokens.entries()) { if (value.expires < now) { tempTokens.delete(key); } } res.json({ success: true, data: { token: token, publicUrl: `${req.protocol}://${req.get("host")}/api/files/public/${token}`, expires: new Date(expires).toISOString(), }, }); } catch (error) { console.error("❌ 임시 토큰 생성 오류:", error); res.status(500).json({ success: false, message: "임시 토큰 생성에 실패했습니다.", }); } }; /** * 임시 토큰으로 파일 접근 (인증 불필요) */ export const getFileByToken = async (req: Request, res: Response) => { try { const { token } = req.params; if (!token) { res.status(400).json({ success: false, message: "토큰이 필요합니다.", }); return; } // 토큰 확인 const tokenData = tempTokens.get(token); if (!tokenData) { res.status(404).json({ success: false, message: "유효하지 않은 토큰입니다.", }); return; } // 토큰 만료 확인 if (tokenData.expires < Date.now()) { tempTokens.delete(token); res.status(410).json({ success: false, message: "토큰이 만료되었습니다.", }); return; } // 파일 정보 조회 const fileRecord = await queryOne( "SELECT * FROM attach_file_info WHERE objid = $1 LIMIT 1", [tokenData.objid] ); if (!fileRecord) { res.status(404).json({ success: false, message: "파일을 찾을 수 없습니다.", }); return; } // 파일 경로 구성 const filePathParts = fileRecord.file_path!.split("/"); let companyCode = filePathParts[2] || "DEFAULT"; if (companyCode === "company_*") { companyCode = "company_*"; // 실제 디렉토리명 유지 } const fileName = fileRecord.saved_file_name!; let dateFolder = ""; if (filePathParts.length >= 6) { dateFolder = `${filePathParts[3]}/${filePathParts[4]}/${filePathParts[5]}`; } const companyUploadDir = getCompanyUploadDir( companyCode, dateFolder || undefined ); const filePath = path.join(companyUploadDir, fileName); // 파일 존재 확인 if (!fs.existsSync(filePath)) { res.status(404).json({ success: false, message: "실제 파일을 찾을 수 없습니다.", }); return; } // MIME 타입 설정 const ext = path.extname(fileName).toLowerCase(); let contentType = "application/octet-stream"; const mimeTypes: { [key: string]: string } = { ".pdf": "application/pdf", ".doc": "application/msword", ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", ".xls": "application/vnd.ms-excel", ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ".ppt": "application/vnd.ms-powerpoint", ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".gif": "image/gif", ".txt": "text/plain", }; if (mimeTypes[ext]) { contentType = mimeTypes[ext]; } // 파일 헤더 설정 res.setHeader("Content-Type", contentType); res.setHeader( "Content-Disposition", `inline; filename="${encodeURIComponent(fileRecord.real_file_name!)}"` ); res.setHeader("Cache-Control", "public, max-age=300"); // 5분 캐시 // 파일 스트림 전송 const fileStream = fs.createReadStream(filePath); fileStream.pipe(res); } catch (error) { console.error("❌ 토큰 파일 접근 오류:", error); res.status(500).json({ success: false, message: "파일 접근에 실패했습니다.", }); } }; /** * 대표 파일 설정 */ export const setRepresentativeFile = async ( req: AuthenticatedRequest, res: Response ): Promise => { try { const { objid } = req.params; const companyCode = req.user?.companyCode; // 파일 존재 여부 및 권한 확인 const fileRecord = await queryOne( `SELECT * FROM attach_file_info WHERE objid = $1 AND status = $2`, [parseInt(objid), "ACTIVE"] ); if (!fileRecord) { res.status(404).json({ success: false, message: "파일을 찾을 수 없습니다.", }); return; } // 멀티테넌시: 회사 코드 확인 if (companyCode !== "*" && fileRecord.company_code !== companyCode) { res.status(403).json({ success: false, message: "접근 권한이 없습니다.", }); return; } // 같은 target_objid의 다른 파일들의 is_representative를 false로 설정 await query( `UPDATE attach_file_info SET is_representative = false WHERE target_objid = $1 AND objid != $2`, [fileRecord.target_objid, parseInt(objid)] ); // 선택한 파일을 대표 파일로 설정 await query( `UPDATE attach_file_info SET is_representative = true WHERE objid = $1`, [parseInt(objid)] ); res.json({ success: true, message: "대표 파일이 설정되었습니다.", }); } catch (error) { console.error("대표 파일 설정 오류:", error); res.status(500).json({ success: false, message: "대표 파일 설정 중 오류가 발생했습니다.", }); } }; // Multer 미들웨어 export export const uploadMiddleware = upload.array("files", 10); // 최대 10개 파일