From acaa3414d275d3df1bdc3f477d75b5bb71797c09 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 4 Nov 2025 17:57:28 +0900 Subject: [PATCH] =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=ED=9A=8C=EC=82=AC=EB=B3=84=EB=A1=9C=20=EB=B3=B4?= =?UTF-8?q?=EC=9D=B4=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/fileController.ts | 126 +++++++++++++----- frontend/lib/api/file.ts | 2 + .../file-upload/FileUploadComponent.tsx | 113 ++++++++-------- 3 files changed, 154 insertions(+), 87 deletions(-) diff --git a/backend-node/src/controllers/fileController.ts b/backend-node/src/controllers/fileController.ts index d138bce3..dfceca89 100644 --- a/backend-node/src/controllers/fileController.ts +++ b/backend-node/src/controllers/fileController.ts @@ -232,13 +232,20 @@ export const uploadFiles = async ( // 자동 연결 로직 - target_objid 자동 생성 let finalTargetObjid = targetObjid; - if (autoLink === "true" && linkedTable && recordId) { + + // 🔑 템플릿 파일(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}`; } + console.log("📎 autoLink 적용:", { original: targetObjid, final: finalTargetObjid }); + } else if (isTemplateFile) { + console.log("🎨 템플릿 파일이므로 targetObjid 유지:", targetObjid); } const savedFiles = []; @@ -363,6 +370,38 @@ export const deleteFile = async ( 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", @@ -510,6 +549,9 @@ export const getComponentFiles = async ( const { screenId, componentId, tableName, recordId, columnName } = req.query; + // 🔒 멀티테넌시: 현재 사용자의 회사 코드 가져오기 + const companyCode = req.user?.companyCode; + console.log("📂 [getComponentFiles] API 호출:", { screenId, componentId, @@ -517,6 +559,7 @@ export const getComponentFiles = async ( recordId, columnName, user: req.user?.userId, + companyCode, // 🔒 멀티테넌시 로그 }); if (!screenId || !componentId) { @@ -534,32 +577,16 @@ export const getComponentFiles = async ( templateTargetObjid, }); - // 모든 파일 조회해서 실제 저장된 target_objid 패턴 확인 - const allFiles = await query( - `SELECT target_objid, real_file_name, regdate - FROM attach_file_info - WHERE status = $1 - ORDER BY regdate DESC - LIMIT 10`, - ["ACTIVE"] - ); - console.log( - "🗂️ [getComponentFiles] 최근 저장된 파일들의 target_objid:", - allFiles.map((f) => ({ - target_objid: f.target_objid, - name: f.real_file_name, - })) - ); - + // 🔒 멀티테넌시: 회사별 필터링 추가 const templateFiles = await query( `SELECT * FROM attach_file_info - WHERE target_objid = $1 AND status = $2 + WHERE target_objid = $1 AND status = $2 AND company_code = $3 ORDER BY regdate DESC`, - [templateTargetObjid, "ACTIVE"] + [templateTargetObjid, "ACTIVE", companyCode] ); console.log( - "📁 [getComponentFiles] 템플릿 파일 결과:", + "📁 [getComponentFiles] 템플릿 파일 결과 (회사별 필터링):", templateFiles.length ); @@ -567,11 +594,12 @@ export const getComponentFiles = async ( 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 + WHERE target_objid = $1 AND status = $2 AND company_code = $3 ORDER BY regdate DESC`, - [dataTargetObjid, "ACTIVE"] + [dataTargetObjid, "ACTIVE", companyCode] ); } @@ -643,6 +671,9 @@ export const previewFile = async ( 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)] @@ -656,13 +687,28 @@ export const previewFile = async ( 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 companyCode = filePathParts[2] || "DEFAULT"; + let fileCompanyCode = filePathParts[2] || "DEFAULT"; // company_* 처리 (실제 회사 코드로 변환) - if (companyCode === "company_*") { - companyCode = "company_*"; // 실제 디렉토리명 유지 + if (fileCompanyCode === "company_*") { + fileCompanyCode = "company_*"; // 실제 디렉토리명 유지 } const fileName = fileRecord.saved_file_name!; @@ -674,7 +720,7 @@ export const previewFile = async ( } const companyUploadDir = getCompanyUploadDir( - companyCode, + fileCompanyCode, dateFolder || undefined ); const filePath = path.join(companyUploadDir, fileName); @@ -762,6 +808,9 @@ export const downloadFile = async ( try { const { objid } = req.params; + // 🔒 멀티테넌시: 현재 사용자의 회사 코드 + const companyCode = req.user?.companyCode; + const fileRecord = await queryOne( `SELECT * FROM attach_file_info WHERE objid = $1`, [parseInt(objid)] @@ -775,13 +824,28 @@ export const downloadFile = async ( 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 companyCode = filePathParts[2] || "DEFAULT"; // /uploads/company_*/2025/09/05/filename.ext에서 company_* 추출 + let fileCompanyCode = filePathParts[2] || "DEFAULT"; // /uploads/company_*/2025/09/05/filename.ext에서 company_* 추출 // company_* 처리 (실제 회사 코드로 변환) - if (companyCode === "company_*") { - companyCode = "company_*"; // 실제 디렉토리명 유지 + if (fileCompanyCode === "company_*") { + fileCompanyCode = "company_*"; // 실제 디렉토리명 유지 } const fileName = fileRecord.saved_file_name!; @@ -794,7 +858,7 @@ export const downloadFile = async ( } const companyUploadDir = getCompanyUploadDir( - companyCode, + fileCompanyCode, dateFolder || undefined ); const filePath = path.join(companyUploadDir, fileName); diff --git a/frontend/lib/api/file.ts b/frontend/lib/api/file.ts index 70564f5b..8cba4e60 100644 --- a/frontend/lib/api/file.ts +++ b/frontend/lib/api/file.ts @@ -42,6 +42,7 @@ export const uploadFiles = async (params: { autoLink?: boolean; columnName?: string; isVirtualFileColumn?: boolean; + companyCode?: string; // 🔒 멀티테넌시: 회사 코드 }): Promise => { const formData = new FormData(); @@ -64,6 +65,7 @@ export const uploadFiles = async (params: { if (params.autoLink !== undefined) formData.append("autoLink", params.autoLink.toString()); if (params.columnName) formData.append("columnName", params.columnName); if (params.isVirtualFileColumn !== undefined) formData.append("isVirtualFileColumn", params.isVirtualFileColumn.toString()); + if (params.companyCode) formData.append("companyCode", params.companyCode); // 🔒 멀티테넌시 const response = await apiClient.post("/files/upload", formData, { headers: { diff --git a/frontend/lib/registry/components/file-upload/FileUploadComponent.tsx b/frontend/lib/registry/components/file-upload/FileUploadComponent.tsx index b68cf529..f79bb12b 100644 --- a/frontend/lib/registry/components/file-upload/FileUploadComponent.tsx +++ b/frontend/lib/registry/components/file-upload/FileUploadComponent.tsx @@ -204,24 +204,37 @@ const FileUploadComponent: React.FC = ({ // 템플릿 파일과 데이터 파일을 조회하는 함수 const loadComponentFiles = useCallback(async () => { - if (!component?.id) return; + if (!component?.id) return false; try { - let screenId = - formData?.screenId || - (typeof window !== "undefined" && window.location.pathname.includes("/screens/") - ? parseInt(window.location.pathname.split("/screens/")[1]) - : null); - - // 디자인 모드인 경우 기본 화면 ID 사용 - if (!screenId && isDesignMode) { - screenId = 40; // 기본 화면 ID - console.log("📂 디자인 모드: 기본 화면 ID 사용 (40)"); + // 1. formData에서 screenId 가져오기 + let screenId = formData?.screenId; + + // 2. URL에서 screenId 추출 (/screens/:id 패턴) + if (!screenId && typeof window !== "undefined") { + const pathname = window.location.pathname; + const screenMatch = pathname.match(/\/screens\/(\d+)/); + if (screenMatch) { + screenId = parseInt(screenMatch[1]); + console.log("📂 URL에서 화면 ID 추출:", screenId); + } } + // 3. 디자인 모드인 경우 임시 화면 ID 사용 + if (!screenId && isDesignMode) { + screenId = 999999; // 디자인 모드 임시 ID + console.log("📂 디자인 모드: 임시 화면 ID 사용 (999999)"); + } + + // 4. 화면 ID가 없으면 컴포넌트 ID만으로 조회 시도 if (!screenId) { - console.log("📂 화면 ID 없음, 기존 파일 로직 사용"); - return false; // 기존 로직 사용 + console.warn("⚠️ 화면 ID 없음, 컴포넌트 ID만으로 파일 조회:", { + componentId: component.id, + pathname: window.location.pathname, + formData: formData, + }); + // screenId를 0으로 설정하여 컴포넌트 ID로만 조회 + screenId = 0; } const params = { @@ -229,7 +242,7 @@ const FileUploadComponent: React.FC = ({ componentId: component.id, tableName: formData?.tableName || component.tableName, recordId: formData?.id, - columnName: component.columnName, + columnName: component.columnName || component.id, // 🔑 columnName이 없으면 component.id 사용 }; console.log("📂 컴포넌트 파일 조회:", params); @@ -319,7 +332,7 @@ const FileUploadComponent: React.FC = ({ return false; // 기존 로직 사용 }, [component.id, component.tableName, component.columnName, formData?.screenId, formData?.tableName, formData?.id]); - // 컴포넌트 파일 동기화 + // 컴포넌트 파일 동기화 (DB 우선, localStorage는 보조) useEffect(() => { const componentFiles = (component as any)?.uploadedFiles || []; const lastUpdate = (component as any)?.lastFileUpdate; @@ -332,15 +345,15 @@ const FileUploadComponent: React.FC = ({ currentUploadedFiles: uploadedFiles.length, }); - // 먼저 새로운 템플릿 파일 조회 시도 - loadComponentFiles().then((useNewLogic) => { - if (useNewLogic) { - console.log("✅ 새로운 템플릿 파일 로직 사용"); - return; // 새로운 로직이 성공했으면 기존 로직 스킵 + // 🔒 항상 DB에서 최신 파일 목록을 조회 (멀티테넌시 격리) + loadComponentFiles().then((dbLoadSuccess) => { + if (dbLoadSuccess) { + console.log("✅ DB에서 파일 로드 성공 (멀티테넌시 적용)"); + return; // DB 로드 성공 시 localStorage 무시 } - // 기존 로직 사용 - console.log("📂 기존 파일 로직 사용"); + // DB 로드 실패 시에만 기존 로직 사용 (하위 호환성) + console.log("📂 DB 로드 실패, 기존 로직 사용"); // 전역 상태에서 최신 파일 정보 가져오기 const globalFileState = typeof window !== "undefined" ? (window as any).globalFileState || {} : {}; @@ -358,34 +371,6 @@ const FileUploadComponent: React.FC = ({ lastUpdate: lastUpdate, }); - // localStorage에서 백업 파일 복원 (새로고침 시 중요!) - try { - const backupKey = `fileUpload_${component.id}`; - const backupFiles = localStorage.getItem(backupKey); - if (backupFiles) { - const parsedFiles = JSON.parse(backupFiles); - if (parsedFiles.length > 0 && currentFiles.length === 0) { - console.log("🔄 localStorage에서 파일 복원:", { - componentId: component.id, - restoredFiles: parsedFiles.length, - files: parsedFiles.map((f: any) => ({ objid: f.objid, name: f.realFileName })), - }); - setUploadedFiles(parsedFiles); - - // 전역 상태에도 복원 - if (typeof window !== "undefined") { - (window as any).globalFileState = { - ...(window as any).globalFileState, - [component.id]: parsedFiles, - }; - } - return; - } - } - } catch (e) { - console.warn("localStorage 백업 복원 실패:", e); - } - // 최신 파일과 현재 파일 비교 if (JSON.stringify(currentFiles) !== JSON.stringify(uploadedFiles)) { console.log("🔄 useEffect에서 파일 목록 변경 감지:", { @@ -535,24 +520,39 @@ const FileUploadComponent: React.FC = ({ // targetObjid 생성 - 템플릿 vs 데이터 파일 구분 const tableName = formData?.tableName || component.tableName || "default_table"; const recordId = formData?.id; - const screenId = formData?.screenId; const columnName = component.columnName || component.id; + + // screenId 추출 (우선순위: formData > URL) + let screenId = formData?.screenId; + if (!screenId && typeof window !== "undefined") { + const pathname = window.location.pathname; + const screenMatch = pathname.match(/\/screens\/(\d+)/); + if (screenMatch) { + screenId = parseInt(screenMatch[1]); + } + } let targetObjid; - if (recordId && tableName) { - // 실제 데이터 파일 + // 우선순위: 1) 실제 데이터 (recordId가 숫자/문자열이고 temp_가 아님) > 2) 템플릿 (screenId) > 3) 기본값 + const isRealRecord = recordId && typeof recordId !== 'undefined' && !String(recordId).startsWith('temp_'); + + if (isRealRecord && tableName) { + // 실제 데이터 파일 (진짜 레코드 ID가 있을 때만) targetObjid = `${tableName}:${recordId}:${columnName}`; console.log("📁 실제 데이터 파일 업로드:", targetObjid); } else if (screenId) { - // 템플릿 파일 - targetObjid = `screen_${screenId}:${component.id}`; - console.log("🎨 템플릿 파일 업로드:", targetObjid); + // 🔑 템플릿 파일 (백엔드 조회 형식과 동일하게) + targetObjid = `screen_files:${screenId}:${component.id}:${columnName}`; + console.log("🎨 템플릿 파일 업로드:", { targetObjid, screenId, componentId: component.id, columnName }); } else { // 기본값 (화면관리에서 사용) targetObjid = `temp_${component.id}`; console.log("📝 기본 파일 업로드:", targetObjid); } + // 🔒 현재 사용자의 회사 코드 가져오기 (멀티테넌시 격리) + const userCompanyCode = (window as any).__user__?.companyCode; + const uploadData = { // 🎯 formData에서 백엔드 API 설정 가져오기 autoLink: formData?.autoLink || true, @@ -562,6 +562,7 @@ const FileUploadComponent: React.FC = ({ isVirtualFileColumn: formData?.isVirtualFileColumn || true, docType: component.fileConfig?.docType || "DOCUMENT", docTypeName: component.fileConfig?.docTypeName || "일반 문서", + companyCode: userCompanyCode, // 🔒 멀티테넌시: 회사 코드 명시적 전달 // 호환성을 위한 기존 필드들 tableName: tableName, fieldName: columnName,