From 43541a12c97a96db6f4d0fa777e3b99c5b0b9427 Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 6 Feb 2026 12:10:07 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20API=20URL=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 프로덕션 URL에서 /api를 제거하는 로직을 수정하여, 호스트명의 /api까지 제거되는 버그를 방지하였습니다. - API_BASE_URL 및 NEXT_PUBLIC_API_URL에서 문자열 끝의 /api만 제거하도록 정규 표현식을 사용하였습니다. - FileViewerModal 컴포넌트에서 다운로드 URL 생성 시에도 동일한 수정이 적용되었습니다. --- frontend/lib/api/client.ts | 5 ++++- frontend/lib/api/file.ts | 4 +++- .../lib/registry/components/file-upload/FileViewerModal.tsx | 4 +++- .../registry/components/v2-file-upload/FileViewerModal.tsx | 4 +++- 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/frontend/lib/api/client.ts b/frontend/lib/api/client.ts index 9b5b7aea..8867f96f 100644 --- a/frontend/lib/api/client.ts +++ b/frontend/lib/api/client.ts @@ -62,7 +62,10 @@ export const getFullImageUrl = (imagePath: string): string => { } // SSR 또는 알 수 없는 환경에서는 API_BASE_URL 사용 (fallback) - const baseUrl = API_BASE_URL.replace("/api", ""); + // 주의: 프로덕션 URL이 https://api.vexplor.com/api 이므로 + // 단순 .replace("/api", "")는 호스트명의 /api까지 제거하는 버그 발생 + // 반드시 문자열 끝의 /api만 제거해야 함 + const baseUrl = API_BASE_URL.replace(/\/api$/, ""); if (baseUrl.startsWith("http://") || baseUrl.startsWith("https://")) { return `${baseUrl}${imagePath}`; } diff --git a/frontend/lib/api/file.ts b/frontend/lib/api/file.ts index f848c7e6..042c555c 100644 --- a/frontend/lib/api/file.ts +++ b/frontend/lib/api/file.ts @@ -274,7 +274,9 @@ export const getDirectFileUrl = (filePath: string): string => { } // SSR 또는 알 수 없는 환경에서는 환경변수 사용 (fallback) - const baseUrl = process.env.NEXT_PUBLIC_API_URL?.replace("/api", "") || ""; + // 주의: 프로덕션 URL이 https://api.vexplor.com/api 이므로 + // 단순 .replace("/api", "")는 호스트명의 /api까지 제거하는 버그 발생 + const baseUrl = process.env.NEXT_PUBLIC_API_URL?.replace(/\/api$/, "") || ""; if (baseUrl.startsWith("http://") || baseUrl.startsWith("https://")) { return `${baseUrl}${filePath}`; } diff --git a/frontend/lib/registry/components/file-upload/FileViewerModal.tsx b/frontend/lib/registry/components/file-upload/FileViewerModal.tsx index 9eb0edeb..36e37044 100644 --- a/frontend/lib/registry/components/file-upload/FileViewerModal.tsx +++ b/frontend/lib/registry/components/file-upload/FileViewerModal.tsx @@ -284,7 +284,9 @@ export const FileViewerModal: React.FC = ({ file, isOpen, } } else { // 기타 파일은 다운로드 URL 사용 - const url = `${API_BASE_URL.replace("/api", "")}/api/files/download/${file.objid}`; + // 주의: 프로덕션 URL이 https://api.vexplor.com/api 이므로 + // 끝의 /api만 제거해야 호스트명이 깨지지 않음 + const url = `${API_BASE_URL.replace(/\/api$/, "")}/api/files/download/${file.objid}`; setPreviewUrl(url); } } else { diff --git a/frontend/lib/registry/components/v2-file-upload/FileViewerModal.tsx b/frontend/lib/registry/components/v2-file-upload/FileViewerModal.tsx index 9eb0edeb..36e37044 100644 --- a/frontend/lib/registry/components/v2-file-upload/FileViewerModal.tsx +++ b/frontend/lib/registry/components/v2-file-upload/FileViewerModal.tsx @@ -284,7 +284,9 @@ export const FileViewerModal: React.FC = ({ file, isOpen, } } else { // 기타 파일은 다운로드 URL 사용 - const url = `${API_BASE_URL.replace("/api", "")}/api/files/download/${file.objid}`; + // 주의: 프로덕션 URL이 https://api.vexplor.com/api 이므로 + // 끝의 /api만 제거해야 호스트명이 깨지지 않음 + const url = `${API_BASE_URL.replace(/\/api$/, "")}/api/files/download/${file.objid}`; setPreviewUrl(url); } } else { From 8c3eca8129b29b14882708df5882b2993e84d698 Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 6 Feb 2026 14:08:20 +0900 Subject: [PATCH 2/2] =?UTF-8?q?refactor:=20=ED=8C=8C=EC=9D=BC=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20API=20=EB=B0=8F=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - deleteFile, previewFile, downloadFile, setRepresentativeFile 함수에서 objid 파라미터의 타입을 정수에서 문자열로 변경하여 일관성을 높였습니다. - getComponentFiles 함수에 레코드의 컬럼 값으로 파일을 직접 조회하는 로직을 추가하여, 파일 로드 시 유연성을 개선했습니다. - FileUploadComponent에서 localStorage 파일 캐시 정리 로직을 추가하여, 등록 후 재등록 시 이전 파일이 남아있지 않도록 처리했습니다. - v2-file-upload/FileUploadComponent에서 등록 모드 시 이전 파일 로드를 스킵하도록 개선하여, 불필요한 파일 로드를 방지했습니다. --- .../src/controllers/fileController.ts | 50 +++++++++++++--- .../src/services/tableManagementService.ts | 16 +++-- .../file-upload/FileUploadComponent.tsx | 60 ++++++++++++++++++- .../v2-file-upload/FileUploadComponent.tsx | 11 ++++ 4 files changed, 122 insertions(+), 15 deletions(-) diff --git a/backend-node/src/controllers/fileController.ts b/backend-node/src/controllers/fileController.ts index 28a46232..66418099 100644 --- a/backend-node/src/controllers/fileController.ts +++ b/backend-node/src/controllers/fileController.ts @@ -431,7 +431,7 @@ export const deleteFile = async ( // 파일 정보 조회 const fileRecord = await queryOne( `SELECT * FROM attach_file_info WHERE objid = $1`, - [parseInt(objid)] + [objid] ); if (!fileRecord) { @@ -460,7 +460,7 @@ export const deleteFile = async ( // 파일 상태를 DELETED로 변경 (논리적 삭제) await query( "UPDATE attach_file_info SET status = $1 WHERE objid = $2", - ["DELETED", parseInt(objid)] + ["DELETED", objid] ); // 🆕 레코드 모드: 해당 행의 attachments 컬럼 자동 업데이트 @@ -708,6 +708,40 @@ export const getComponentFiles = async ( ); } + // 3. 레코드의 컬럼 값으로 파일 직접 조회 (수정 모달에서 기존 파일 로드) + // target_objid 매칭이 안 될 때, 테이블 레코드의 컬럼 값(파일 objid)으로 직접 찾기 + if (dataFiles.length === 0 && templateFiles.length === 0 && tableName && recordId && columnName) { + try { + // 레코드에서 해당 컬럼 값 조회 (파일 objid가 저장되어 있을 수 있음) + const safeTable = String(tableName).replace(/[^a-zA-Z0-9_]/g, ""); + const safeColumn = String(columnName).replace(/[^a-zA-Z0-9_]/g, ""); + const recordResult = await query( + `SELECT "${safeColumn}" FROM "${safeTable}" WHERE id = $1 LIMIT 1`, + [recordId] + ); + + if (recordResult.length > 0 && recordResult[0][safeColumn]) { + const columnValue = String(recordResult[0][safeColumn]); + // 숫자값인 경우 파일 objid로 간주하고 조회 + if (/^\d+$/.test(columnValue)) { + console.log("🔍 [getComponentFiles] 레코드 컬럼 값으로 파일 조회:", { table: safeTable, column: safeColumn, fileObjid: columnValue }); + const directFiles = await query( + `SELECT * FROM attach_file_info + WHERE objid = $1 AND status = $2 + ORDER BY regdate DESC`, + [columnValue, "ACTIVE"] + ); + if (directFiles.length > 0) { + console.log("✅ [getComponentFiles] 레코드 컬럼 값으로 파일 찾음:", directFiles.length, "건"); + dataFiles = directFiles; + } + } + } + } catch (lookupError) { + console.warn("⚠️ [getComponentFiles] 레코드 컬럼 값 조회 실패:", lookupError); + } + } + // 파일 정보 포맷팅 함수 const formatFileInfo = (file: any, isTemplate: boolean = false) => ({ objid: file.objid.toString(), @@ -782,7 +816,7 @@ export const previewFile = async ( const fileRecord = await queryOne( "SELECT * FROM attach_file_info WHERE objid = $1 LIMIT 1", - [parseInt(objid)] + [objid] ); if (!fileRecord || fileRecord.status !== "ACTIVE") { @@ -921,7 +955,7 @@ export const downloadFile = async ( const fileRecord = await queryOne( `SELECT * FROM attach_file_info WHERE objid = $1`, - [parseInt(objid)] + [objid] ); if (!fileRecord || fileRecord.status !== "ACTIVE") { @@ -1212,7 +1246,7 @@ export const setRepresentativeFile = async ( // 파일 존재 여부 및 권한 확인 const fileRecord = await queryOne( `SELECT * FROM attach_file_info WHERE objid = $1 AND status = $2`, - [parseInt(objid), "ACTIVE"] + [objid, "ACTIVE"] ); if (!fileRecord) { @@ -1237,7 +1271,7 @@ export const setRepresentativeFile = async ( `UPDATE attach_file_info SET is_representative = false WHERE target_objid = $1 AND objid != $2`, - [fileRecord.target_objid, parseInt(objid)] + [fileRecord.target_objid, objid] ); // 선택한 파일을 대표 파일로 설정 @@ -1245,7 +1279,7 @@ export const setRepresentativeFile = async ( `UPDATE attach_file_info SET is_representative = true WHERE objid = $1`, - [parseInt(objid)] + [objid] ); res.json({ @@ -1281,7 +1315,7 @@ export const getFileInfo = async (req: Request, res: Response) => { `SELECT objid, real_file_name, file_size, file_ext, file_path, regdate, is_representative FROM attach_file_info WHERE objid = $1 AND status = 'ACTIVE'`, - [parseInt(objid)] + [objid] ); if (!fileRecord) { diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 5fe2f242..6e62a541 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -2273,6 +2273,9 @@ export class TableManagementService { const safeSortOrder = sortOrder.toLowerCase() === "desc" ? "DESC" : "ASC"; orderClause = `ORDER BY ${safeSortBy} ${safeSortOrder}`; + } else { + // sortBy가 없으면 created_date DESC를 기본 정렬로 사용 (신규 데이터가 최상단에 표시) + orderClause = `ORDER BY main.created_date DESC`; } // 안전한 테이블명 검증 @@ -3185,9 +3188,10 @@ export class TableManagementService { } // ORDER BY 절 구성 + // sortBy가 없으면 created_date DESC를 기본 정렬로 사용 (신규 데이터가 최상단에 표시) const orderBy = options.sortBy - ? `main.${options.sortBy} ${options.sortOrder === "desc" ? "DESC" : "ASC"}` - : ""; + ? `main."${options.sortBy}" ${options.sortOrder === "desc" ? "DESC" : "ASC"}` + : `main."created_date" DESC`; // 페이징 계산 const offset = (options.page - 1) * options.size; @@ -3403,8 +3407,8 @@ export class TableManagementService { selectColumns, "", // WHERE 절은 나중에 추가 options.sortBy - ? `main.${options.sortBy} ${options.sortOrder || "ASC"}` - : undefined, + ? `main."${options.sortBy}" ${options.sortOrder || "ASC"}` + : `main."created_date" DESC`, options.size, (options.page - 1) * options.size ); @@ -3591,8 +3595,8 @@ export class TableManagementService { const whereClause = whereConditions.join(" AND "); const orderBy = options.sortBy - ? `main.${options.sortBy} ${options.sortOrder === "desc" ? "DESC" : "ASC"}` - : ""; + ? `main."${options.sortBy}" ${options.sortOrder === "desc" ? "DESC" : "ASC"}` + : `main."created_date" DESC`; // 페이징 계산 const offset = (options.page - 1) * options.size; diff --git a/frontend/lib/registry/components/file-upload/FileUploadComponent.tsx b/frontend/lib/registry/components/file-upload/FileUploadComponent.tsx index dd4f4c6b..f655ebe3 100644 --- a/frontend/lib/registry/components/file-upload/FileUploadComponent.tsx +++ b/frontend/lib/registry/components/file-upload/FileUploadComponent.tsx @@ -177,10 +177,18 @@ const FileUploadComponent: React.FC = ({ // 🔑 레코드별 고유 키 사용 const backupKey = getUniqueKey(); const backupFiles = localStorage.getItem(backupKey); + console.log("🔎 [DEBUG-MOUNT] localStorage 확인:", { + backupKey, + hasBackup: !!backupFiles, + componentId: component.id, + recordId: recordId, + formDataId: formData?.id, + stackTrace: new Error().stack?.split('\n').slice(1, 4).join(' <- '), + }); if (backupFiles) { const parsedFiles = JSON.parse(backupFiles); if (parsedFiles.length > 0) { - console.log("🚀 컴포넌트 마운트 시 파일 즉시 복원:", { + console.log("🚀 [DEBUG-MOUNT] 파일 즉시 복원:", { uniqueKey: backupKey, componentId: component.id, recordId: recordId, @@ -203,6 +211,50 @@ const FileUploadComponent: React.FC = ({ } }, [component.id, getUniqueKey, recordId]); // 레코드별 고유 키 변경 시 재실행 + // 🆕 모달 닫힘/저장 성공 시 localStorage 파일 캐시 정리 (등록 후 재등록 시 이전 파일 잔존 방지) + useEffect(() => { + const handleClearFileCache = (event: Event) => { + const backupKey = getUniqueKey(); + const eventType = event.type; + console.log("🧹 [DEBUG-CLEAR] 파일 캐시 정리 이벤트 수신:", { + eventType, + backupKey, + componentId: component.id, + currentFiles: uploadedFiles.length, + hasLocalStorage: !!localStorage.getItem(backupKey), + }); + try { + localStorage.removeItem(backupKey); + setUploadedFiles([]); + setRepresentativeImageUrl(null); + if (typeof window !== "undefined") { + const globalFileState = (window as any).globalFileState || {}; + delete globalFileState[backupKey]; + (window as any).globalFileState = globalFileState; + } + console.log("🧹 [DEBUG-CLEAR] 정리 완료:", backupKey); + } catch (e) { + console.warn("파일 캐시 정리 실패:", e); + } + }; + + // EditModal 닫힘, ScreenModal 연속 등록 저장 성공, 일반 저장 성공 모두 처리 + window.addEventListener("closeEditModal", handleClearFileCache); + window.addEventListener("saveSuccess", handleClearFileCache); + window.addEventListener("saveSuccessInModal", handleClearFileCache); + + console.log("🔎 [DEBUG-CLEAR] 이벤트 리스너 등록 완료:", { + componentId: component.id, + backupKey: getUniqueKey(), + }); + + return () => { + window.removeEventListener("closeEditModal", handleClearFileCache); + window.removeEventListener("saveSuccess", handleClearFileCache); + window.removeEventListener("saveSuccessInModal", handleClearFileCache); + }; + }, [getUniqueKey]); + // 🎯 화면설계 모드에서 실제 화면으로의 실시간 동기화 이벤트 리스너 useEffect(() => { const handleDesignModeFileChange = (event: CustomEvent) => { @@ -363,6 +415,12 @@ const FileUploadComponent: React.FC = ({ console.warn("파일 병합 중 오류:", e); } + console.log("🔎 [DEBUG-LOAD] API 응답 후 파일 설정:", { + componentId: component.id, + serverFiles: formattedFiles.length, + finalFiles: finalFiles.length, + files: finalFiles.map((f: any) => ({ objid: f.objid, name: f.realFileName })), + }); setUploadedFiles(finalFiles); // 전역 상태에도 저장 (레코드별 고유 키 사용) diff --git a/frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx b/frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx index 1f8232d8..8b9671c8 100644 --- a/frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx +++ b/frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx @@ -199,6 +199,12 @@ const FileUploadComponent: React.FC = ({ if (imageObjidFromFormData && /^\d+$/.test(String(imageObjidFromFormData))) { const objidStr = String(imageObjidFromFormData); + // 🆕 등록 모드(formData.id가 없는 경우)에서는 이전 파일 로드 스킵 + // 연속 등록 시 이전 저장의 image 값이 남아있어 다시 로드되는 것을 방지 + if (!formData?.id) { + return; + } + // 이미 같은 objid의 파일이 로드되어 있으면 스킵 const alreadyLoaded = uploadedFiles.some(f => String(f.objid) === objidStr); if (alreadyLoaded) { @@ -431,6 +437,11 @@ const FileUploadComponent: React.FC = ({ return; // DB 로드 성공 시 localStorage 무시 } + // 🆕 등록 모드(새 레코드)인 경우 fallback 로드도 스킵 - 항상 빈 상태 유지 + if (!isRecordMode || !recordId) { + return; + } + // DB 로드 실패 시에만 기존 로직 사용 (하위 호환성) // 전역 상태에서 최신 파일 정보 가져오기 (🆕 고유 키 사용)