diff --git a/.gitignore b/.gitignore index 8acf6c54..23c0c0a8 100644 --- a/.gitignore +++ b/.gitignore @@ -280,7 +280,6 @@ backend-node/uploads/ uploads/ *.jpg *.jpeg -*.png *.gif *.pdf *.doc diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index d3b53f33..3c8974d4 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -52,7 +52,20 @@ import { BatchSchedulerService } from "./services/batchSchedulerService"; const app = express(); // 기본 미들웨어 -app.use(helmet()); +app.use( + helmet({ + contentSecurityPolicy: { + directives: { + ...helmet.contentSecurityPolicy.getDefaultDirectives(), + "frame-ancestors": [ + "'self'", + "http://localhost:9771", + "http://localhost:3000", + ], // 프론트엔드 도메인 허용 + }, + }, + }) +); app.use(compression()); app.use(express.json({ limit: "10mb" })); app.use(express.urlencoded({ extended: true, limit: "10mb" })); @@ -97,7 +110,7 @@ app.use( // Rate Limiting (개발 환경에서는 완화) const limiter = rateLimit({ windowMs: 1 * 60 * 1000, // 1분 - max: config.nodeEnv === "development" ? 10000 : 100, // 개발환경에서는 10000으로 증가, 운영환경에서는 100 + max: config.nodeEnv === "development" ? 10000 : 10000, // 개발환경에서는 10000으로 증가, 운영환경에서는 100 message: { error: "너무 많은 요청이 발생했습니다. 잠시 후 다시 시도해주세요.", }, @@ -184,7 +197,7 @@ app.listen(PORT, HOST, async () => { logger.info(`📊 Environment: ${config.nodeEnv}`); logger.info(`🔗 Health check: http://${HOST}:${PORT}/health`); logger.info(`🌐 External access: http://39.117.244.52:${PORT}/health`); - + // 배치 스케줄러 초기화 try { await BatchSchedulerService.initialize(); diff --git a/backend-node/src/controllers/commonCodeController.ts b/backend-node/src/controllers/commonCodeController.ts index 482ac6d1..b75160fc 100644 --- a/backend-node/src/controllers/commonCodeController.ts +++ b/backend-node/src/controllers/commonCodeController.ts @@ -65,12 +65,26 @@ export class CommonCodeController { // 프론트엔드가 기대하는 형식으로 데이터 변환 const transformedData = result.data.map((code: any) => ({ + // 새로운 필드명 (카멜케이스) codeValue: code.code_value, codeName: code.code_name, + codeNameEng: code.code_name_eng, description: code.description, sortOrder: code.sort_order, - isActive: code.is_active === "Y", + isActive: code.is_active, useYn: code.is_active, + + // 기존 필드명도 유지 (하위 호환성) + code_category: code.code_category, + code_value: code.code_value, + code_name: code.code_name, + code_name_eng: code.code_name_eng, + sort_order: code.sort_order, + is_active: code.is_active, + created_date: code.created_date, + created_by: code.created_by, + updated_date: code.updated_date, + updated_by: code.updated_by, })); return res.json({ diff --git a/backend-node/src/controllers/fileController.ts b/backend-node/src/controllers/fileController.ts index 60251f58..2528d3f1 100644 --- a/backend-node/src/controllers/fileController.ts +++ b/backend-node/src/controllers/fileController.ts @@ -8,6 +8,9 @@ import { generateUUID } from "../utils/generateId"; const prisma = new PrismaClient(); +// 임시 토큰 저장소 (메모리 기반, 실제 운영에서는 Redis 사용 권장) +const tempTokens = new Map(); + // 업로드 디렉토리 설정 (회사별로 분리) const baseUploadDir = path.join(process.cwd(), "uploads"); @@ -266,9 +269,7 @@ export const uploadFiles = async ( // 회사코드가 *인 경우 company_*로 변환 const actualCompanyCode = companyCode === "*" ? "company_*" : companyCode; - const relativePath = `/${actualCompanyCode}/${dateFolder}/${file.filename}`; - const fullFilePath = `/uploads${relativePath}`; - + // 임시 파일을 최종 위치로 이동 const tempFilePath = file.path; // Multer가 저장한 임시 파일 경로 const finalUploadDir = getCompanyUploadDir(companyCode, dateFolder); @@ -277,6 +278,10 @@ export const uploadFiles = async ( // 파일 이동 fs.renameSync(tempFilePath, finalFilePath); + // DB에 저장할 경로 (실제 파일 위치와 일치) + const relativePath = `/${actualCompanyCode}/${dateFolder}/${file.filename}`; + const fullFilePath = `/uploads${relativePath}`; + // attach_file_info 테이블에 저장 const fileRecord = await prisma.attach_file_info.create({ data: { @@ -485,6 +490,133 @@ export const getFileList = async ( } }; +/** + * 컴포넌트의 템플릿 파일과 데이터 파일을 모두 조회 + */ +export const getComponentFiles = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { screenId, componentId, tableName, recordId, columnName } = req.query; + + console.log("📂 [getComponentFiles] API 호출:", { + screenId, + componentId, + tableName, + recordId, + columnName, + user: req.user?.userId + }); + + 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 }); + + // 모든 파일 조회해서 실제 저장된 target_objid 패턴 확인 + const allFiles = await prisma.attach_file_info.findMany({ + where: { + status: "ACTIVE", + }, + select: { + target_objid: true, + real_file_name: true, + regdate: true, + }, + orderBy: { + regdate: "desc", + }, + take: 10, + }); + console.log("🗂️ [getComponentFiles] 최근 저장된 파일들의 target_objid:", allFiles.map(f => ({ target_objid: f.target_objid, name: f.real_file_name }))); + + const templateFiles = await prisma.attach_file_info.findMany({ + where: { + target_objid: templateTargetObjid, + status: "ACTIVE", + }, + orderBy: { + regdate: "desc", + }, + }); + + console.log("📁 [getComponentFiles] 템플릿 파일 결과:", templateFiles.length); + + // 2. 데이터 파일 조회 (실제 레코드와 연결된 파일들) + let dataFiles: any[] = []; + if (tableName && recordId && columnName) { + const dataTargetObjid = `${tableName}:${recordId}:${columnName}`; + dataFiles = await prisma.attach_file_info.findMany({ + where: { + target_objid: dataTargetObjid, + status: "ACTIVE", + }, + orderBy: { + regdate: "desc", + }, + }); + } + + // 파일 정보 포맷팅 함수 + 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, // 템플릿 파일 여부 표시 + }); + + 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 : "알 수 없는 오류", + }); + } +}; + /** * 파일 미리보기 (이미지 등) */ @@ -512,7 +644,13 @@ export const previewFile = async ( // 파일 경로에서 회사코드와 날짜 폴더 추출 const filePathParts = fileRecord.file_path!.split("/"); - const companyCode = filePathParts[2] || "DEFAULT"; + let companyCode = filePathParts[2] || "DEFAULT"; + + // company_* 처리 (실제 회사 코드로 변환) + if (companyCode === "company_*") { + companyCode = "company_*"; // 실제 디렉토리명 유지 + } + const fileName = fileRecord.saved_file_name!; // 파일 경로에 날짜 구조가 있는지 확인 (YYYY/MM/DD) @@ -527,6 +665,17 @@ export const previewFile = async ( ); 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({ @@ -615,7 +764,13 @@ export const downloadFile = async ( // 파일 경로에서 회사코드와 날짜 폴더 추출 (예: /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_* 추출 + let companyCode = filePathParts[2] || "DEFAULT"; // /uploads/company_*/2025/09/05/filename.ext에서 company_* 추출 + + // company_* 처리 (실제 회사 코드로 변환) + if (companyCode === "company_*") { + companyCode = "company_*"; // 실제 디렉토리명 유지 + } + const fileName = fileRecord.saved_file_name!; // 파일 경로에 날짜 구조가 있는지 확인 (YYYY/MM/DD) @@ -631,6 +786,17 @@ export const downloadFile = async ( ); 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({ @@ -660,5 +826,178 @@ export const downloadFile = async ( } }; +/** + * 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 prisma.attach_file_info.findUnique({ + where: { objid: 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 prisma.attach_file_info.findUnique({ + where: { objid: 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: "파일 접근에 실패했습니다.", + }); + } +}; + // Multer 미들웨어 export export const uploadMiddleware = upload.array("files", 10); // 최대 10개 파일 diff --git a/backend-node/src/routes/fileRoutes.ts b/backend-node/src/routes/fileRoutes.ts index b7b4c975..e62d479a 100644 --- a/backend-node/src/routes/fileRoutes.ts +++ b/backend-node/src/routes/fileRoutes.ts @@ -3,15 +3,26 @@ import { uploadFiles, deleteFile, getFileList, + getComponentFiles, downloadFile, previewFile, getLinkedFiles, uploadMiddleware, + generateTempToken, + getFileByToken, } from "../controllers/fileController"; import { authenticateToken } from "../middleware/authMiddleware"; const router = Router(); +// 공개 접근 라우트 (인증 불필요) +/** + * @route GET /api/files/public/:token + * @desc 임시 토큰으로 파일 접근 (Google Docs Viewer용) + * @access Public + */ +router.get("/public/:token", getFileByToken); + // 모든 파일 API는 인증 필요 router.use(authenticateToken); @@ -30,6 +41,14 @@ router.post("/upload", uploadMiddleware, uploadFiles); */ router.get("/", getFileList); +/** + * @route GET /api/files/component-files + * @desc 컴포넌트의 템플릿 파일과 데이터 파일 모두 조회 + * @query screenId, componentId, tableName, recordId, columnName + * @access Private + */ +router.get("/component-files", getComponentFiles); + /** * @route GET /api/files/linked/:tableName/:recordId * @desc 테이블 연결된 파일 조회 @@ -58,4 +77,11 @@ router.get("/preview/:objid", previewFile); */ router.get("/download/:objid", downloadFile); +/** + * @route POST /api/files/temp-token/:objid + * @desc Google Docs Viewer용 임시 공개 토큰 생성 + * @access Private + */ +router.post("/temp-token/:objid", generateTempToken); + export default router; diff --git a/backend-node/src/services/enhancedDataflowControlService.ts b/backend-node/src/services/enhancedDataflowControlService.ts index 6aae33da..862feda0 100644 --- a/backend-node/src/services/enhancedDataflowControlService.ts +++ b/backend-node/src/services/enhancedDataflowControlService.ts @@ -11,15 +11,15 @@ import { import { MultiConnectionQueryService } from "./multiConnectionQueryService"; import { logger } from "../utils/logger"; -export interface EnhancedControlAction extends ControlAction { - // 🆕 기본 ControlAction 속성들 (상속됨) - id?: number; - actionType?: string; +export interface EnhancedControlAction + extends Omit { + // 🆕 기본 ControlAction 속성들 (일부 재정의) + id: string; // ControlAction과 호환성을 위해 string 타입 유지 fromTable: string; - // 🆕 추가 속성들 - conditions?: ControlCondition[]; - fieldMappings?: any[]; + // 🆕 추가 속성들 (선택적으로 재정의) + conditions: ControlCondition[]; // 필수 속성으로 변경 + fieldMappings: any[]; // 필수 속성으로 변경 // 🆕 UPDATE 액션 관련 필드 updateConditions?: UpdateCondition[]; @@ -166,16 +166,16 @@ export class EnhancedDataflowControlService extends DataflowControlService { let actionResult: any; // 커넥션 ID 추출 - const sourceConnectionId = enhancedAction.fromConnection?.connectionId || enhancedAction.fromConnection?.id || 0; - const targetConnectionId = enhancedAction.toConnection?.connectionId || enhancedAction.toConnection?.id || 0; + const sourceConnectionId = enhancedAction.fromConnection?.id || 0; + const targetConnectionId = enhancedAction.toConnection?.id || 0; switch (enhancedAction.actionType) { case "insert": - actionResult = await this.executeMultiConnectionInsert( + actionResult = await this.executeEnhancedMultiConnectionInsert( enhancedAction, sourceData, enhancedAction.fromTable, - enhancedAction.targetTable, + enhancedAction.targetTable || enhancedAction.fromTable, sourceConnectionId, targetConnectionId, null @@ -183,11 +183,11 @@ export class EnhancedDataflowControlService extends DataflowControlService { break; case "update": - actionResult = await this.executeMultiConnectionUpdate( + actionResult = await this.executeEnhancedMultiConnectionUpdate( enhancedAction, sourceData, enhancedAction.fromTable, - enhancedAction.targetTable, + enhancedAction.targetTable || enhancedAction.fromTable, sourceConnectionId, targetConnectionId, null @@ -195,11 +195,11 @@ export class EnhancedDataflowControlService extends DataflowControlService { break; case "delete": - actionResult = await this.executeMultiConnectionDelete( + actionResult = await this.executeEnhancedMultiConnectionDelete( enhancedAction, sourceData, enhancedAction.fromTable, - enhancedAction.targetTable, + enhancedAction.targetTable || enhancedAction.fromTable, sourceConnectionId, targetConnectionId, null @@ -247,8 +247,8 @@ export class EnhancedDataflowControlService extends DataflowControlService { /** * 🆕 다중 커넥션 INSERT 실행 */ - async executeMultiConnectionInsert( - action: EnhancedControlAction, + async executeEnhancedMultiConnectionInsert( + action: ControlAction, sourceData: Record, sourceTable: string, targetTable: string, @@ -257,16 +257,17 @@ export class EnhancedDataflowControlService extends DataflowControlService { multiConnService: any ): Promise { try { - logger.info(`다중 커넥션 INSERT 실행: action=${action.action}`); + const enhancedAction = action as EnhancedControlAction; + logger.info(`다중 커넥션 INSERT 실행: action=${action.id}`); // 커넥션 ID 결정 - const fromConnId = fromConnectionId || action.fromConnection?.connectionId || 0; - const toConnId = toConnectionId || action.toConnection?.connectionId || 0; + const fromConnId = fromConnectionId || action.fromConnection?.id || 0; + const toConnId = toConnectionId || action.toConnection?.id || 0; // FROM 테이블에서 소스 데이터 조회 (조건이 있는 경우) let fromData = sourceData; if ( - action.fromTable && + enhancedAction.fromTable && action.conditions && action.conditions.length > 0 ) { @@ -277,7 +278,7 @@ export class EnhancedDataflowControlService extends DataflowControlService { const fromResults = await this.multiConnectionService.fetchDataFromConnection( fromConnId, - action.fromTable, + enhancedAction.fromTable, queryConditions ); @@ -302,7 +303,7 @@ export class EnhancedDataflowControlService extends DataflowControlService { const insertResult = await this.multiConnectionService.insertDataToConnection( toConnId, - action.targetTable, + action.targetTable || enhancedAction.fromTable, mappedData ); @@ -317,8 +318,8 @@ export class EnhancedDataflowControlService extends DataflowControlService { /** * 🆕 다중 커넥션 UPDATE 실행 */ - async executeMultiConnectionUpdate( - action: EnhancedControlAction, + async executeEnhancedMultiConnectionUpdate( + action: ControlAction, sourceData: Record, sourceTable: string, targetTable: string, @@ -327,26 +328,30 @@ export class EnhancedDataflowControlService extends DataflowControlService { multiConnService: any ): Promise { try { - logger.info(`다중 커넥션 UPDATE 실행: action=${action.action}`); + const enhancedAction = action as EnhancedControlAction; + logger.info(`다중 커넥션 UPDATE 실행: action=${action.id}`); // 커넥션 ID 결정 - const fromConnId = fromConnectionId || action.fromConnection?.connectionId || 0; - const toConnId = toConnectionId || action.toConnection?.connectionId || 0; + const fromConnId = fromConnectionId || action.fromConnection?.id || 0; + const toConnId = toConnectionId || action.toConnection?.id || 0; // UPDATE 조건 확인 - if (!action.updateConditions || action.updateConditions.length === 0) { + if ( + !enhancedAction.updateConditions || + enhancedAction.updateConditions.length === 0 + ) { throw new Error("UPDATE 작업에는 업데이트 조건이 필요합니다."); } // FROM 테이블에서 업데이트 조건 확인 const updateConditions = this.buildUpdateConditions( - action.updateConditions, + enhancedAction.updateConditions, sourceData ); const fromResults = await this.multiConnectionService.fetchDataFromConnection( fromConnId, - action.fromTable || action.targetTable, + enhancedAction.fromTable || action.targetTable || "default_table", updateConditions ); @@ -360,13 +365,13 @@ export class EnhancedDataflowControlService extends DataflowControlService { // 업데이트 필드 매핑 적용 const updateData = this.applyUpdateFieldMappings( - action.updateFields || [], + enhancedAction.updateFields || [], fromResults[0] ); // WHERE 조건 구성 (TO 테이블 대상) const whereConditions = this.buildWhereConditions( - action.updateFields || [], + enhancedAction.updateFields || [], fromResults[0] ); @@ -374,7 +379,7 @@ export class EnhancedDataflowControlService extends DataflowControlService { const updateResult = await this.multiConnectionService.updateDataToConnection( toConnId, - action.targetTable, + action.targetTable || enhancedAction.fromTable, updateData, whereConditions ); @@ -390,8 +395,8 @@ export class EnhancedDataflowControlService extends DataflowControlService { /** * 🆕 다중 커넥션 DELETE 실행 */ - async executeMultiConnectionDelete( - action: EnhancedControlAction, + async executeEnhancedMultiConnectionDelete( + action: ControlAction, sourceData: Record, sourceTable: string, targetTable: string, @@ -400,28 +405,30 @@ export class EnhancedDataflowControlService extends DataflowControlService { multiConnService: any ): Promise { try { - logger.info(`다중 커넥션 DELETE 실행: action=${action.action}`); + const enhancedAction = action as EnhancedControlAction; + logger.info(`다중 커넥션 DELETE 실행: action=${action.id}`); // 커넥션 ID 결정 - const fromConnId = - fromConnectionId || action.fromConnection?.connectionId || 0; - const toConnId = - toConnectionId || action.toConnection?.connectionId || 0; + const fromConnId = fromConnectionId || action.fromConnection?.id || 0; + const toConnId = toConnectionId || action.toConnection?.id || 0; // DELETE 조건 확인 - if (!action.deleteConditions || action.deleteConditions.length === 0) { + if ( + !enhancedAction.deleteConditions || + enhancedAction.deleteConditions.length === 0 + ) { throw new Error("DELETE 작업에는 삭제 조건이 필요합니다."); } // FROM 테이블에서 삭제 트리거 조건 확인 const deleteConditions = this.buildDeleteConditions( - action.deleteConditions, + enhancedAction.deleteConditions, sourceData ); const fromResults = await this.multiConnectionService.fetchDataFromConnection( fromConnId, - action.fromTable || action.targetTable, + enhancedAction.fromTable || action.targetTable || "default_table", deleteConditions ); @@ -432,7 +439,7 @@ export class EnhancedDataflowControlService extends DataflowControlService { // WHERE 조건 구성 (TO 테이블 대상) const whereConditions = this.buildDeleteWhereConditions( - action.deleteWhereConditions || [], + enhancedAction.deleteWhereConditions || [], fromResults[0] ); @@ -441,14 +448,14 @@ export class EnhancedDataflowControlService extends DataflowControlService { } // 안전장치 적용 - const maxDeleteCount = action.maxDeleteCount || 100; + const maxDeleteCount = enhancedAction.maxDeleteCount || 100; // Dry Run 실행 (선택사항) - if (action.dryRunFirst) { + if (enhancedAction.dryRunFirst) { const countResult = await this.multiConnectionService.fetchDataFromConnection( toConnId, - action.targetTable, + action.targetTable || enhancedAction.fromTable, whereConditions ); @@ -465,13 +472,13 @@ export class EnhancedDataflowControlService extends DataflowControlService { const deleteResult = await this.multiConnectionService.deleteDataFromConnection( toConnId, - action.targetTable, + action.targetTable || enhancedAction.fromTable, whereConditions, maxDeleteCount ); // 삭제 로그 기록 (선택사항) - if (action.logAllDeletes) { + if (enhancedAction.logAllDeletes) { logger.info( `삭제 실행 로그: ${JSON.stringify({ action: action.id, diff --git a/frontend/app/(main)/admin/page.tsx b/frontend/app/(main)/admin/page.tsx index 7914f412..81790abf 100644 --- a/frontend/app/(main)/admin/page.tsx +++ b/frontend/app/(main)/admin/page.tsx @@ -2,6 +2,7 @@ import { Users, Shield, Settings, BarChart3, Palette, Layout, Database, Package } from "lucide-react"; import Link from "next/link"; +import { GlobalFileViewer } from "@/components/GlobalFileViewer"; /** * 관리자 메인 페이지 @@ -199,6 +200,16 @@ export default function AdminPage() { + + {/* 전역 파일 관리 */} +
+
+

전역 파일 관리

+

모든 페이지에서 업로드된 파일들을 관리합니다

+
+ +
+ ); diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index dd48479d..93111ba8 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -13,6 +13,7 @@ import { useRouter } from "next/navigation"; import { toast } from "sonner"; import { initializeComponents } from "@/lib/registry/components"; import { EditModal } from "@/components/screen/EditModal"; +import { isFileComponent, getComponentWebType } from "@/lib/utils/componentTypeUtils"; // import { ResponsiveScreenContainer } from "@/components/screen/ResponsiveScreenContainer"; // 컨테이너 제거 export default function ScreenViewPage() { @@ -116,10 +117,10 @@ export default function ScreenViewPage() { if (loading) { return ( -
-
- -

화면을 불러오는 중...

+
+
+ +

화면을 불러오는 중...

); @@ -127,14 +128,14 @@ export default function ScreenViewPage() { if (error || !screen) { return ( -
-
-
- ⚠️ +
+
+
+ ⚠️
-

화면을 찾을 수 없습니다

-

{error || "요청하신 화면이 존재하지 않습니다."}

-
@@ -147,17 +148,17 @@ export default function ScreenViewPage() { const screenHeight = layout?.screenResolution?.height || 800; return ( -
+
{layout && layout.components.length > 0 ? ( // 캔버스 컴포넌트들을 정확한 해상도로 표시
{layout.components @@ -177,15 +178,16 @@ export default function ScreenViewPage() { width: `${component.size.width}px`, height: `${component.size.height}px`, zIndex: component.position.z || 1, - backgroundColor: (component as any).backgroundColor || "rgba(59, 130, 246, 0.1)", - border: (component as any).border || "2px dashed #3b82f6", - borderRadius: (component as any).borderRadius || "8px", - padding: "16px", + backgroundColor: (component as any).backgroundColor || "rgba(59, 130, 246, 0.05)", + border: (component as any).border || "1px solid rgba(59, 130, 246, 0.2)", + borderRadius: (component as any).borderRadius || "12px", + padding: "20px", + boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)", }} > {/* 그룹 제목 */} {(component as any).title && ( -
{(component as any).title}
+
{(component as any).title}
)} {/* 그룹 내 자식 컴포넌트들 렌더링 */} @@ -324,7 +326,19 @@ export default function ScreenViewPage() { /> ) : ( { + // 유틸리티 함수로 파일 컴포넌트 감지 + if (isFileComponent(component)) { + console.log(`🎯 page.tsx - 파일 컴포넌트 감지 → webType: "file"`, { + componentId: component.id, + componentType: component.type, + originalWebType: component.webType + }); + return "file"; + } + // 다른 컴포넌트는 유틸리티 함수로 webType 결정 + return getComponentWebType(component) || "text"; + })()} config={component.webTypeConfig} props={{ component: component, @@ -338,13 +352,13 @@ export default function ScreenViewPage() { }, onFormDataChange: (fieldName, value) => { console.log(`🎯 page.tsx onFormDataChange 호출: ${fieldName} = "${value}"`); - console.log(`📋 현재 formData:`, formData); + console.log("📋 현재 formData:", formData); setFormData((prev) => { const newFormData = { ...prev, [fieldName]: value, }; - console.log(`📝 업데이트된 formData:`, newFormData); + console.log("📝 업데이트된 formData:", newFormData); return newFormData; }); }, diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index a94cd613..ba067c97 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -45,9 +45,9 @@ export default function RootLayout({
{children} + + - -
diff --git a/frontend/components/GlobalFileViewer.tsx b/frontend/components/GlobalFileViewer.tsx new file mode 100644 index 00000000..6e7789d8 --- /dev/null +++ b/frontend/components/GlobalFileViewer.tsx @@ -0,0 +1,303 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { GlobalFileManager, GlobalFileInfo } from "@/lib/api/globalFile"; +import { downloadFile } from "@/lib/api/file"; +import { FileViewerModal } from "@/lib/registry/components/file-upload/FileViewerModal"; +import { formatFileSize } from "@/lib/utils"; +import { toast } from "sonner"; +import { + File, + FileText, + Image, + Video, + Music, + Archive, + Download, + Eye, + Search, + Trash2, + Clock, + MapPin, + Monitor, + RefreshCw, + Info, +} from "lucide-react"; + +interface GlobalFileViewerProps { + showControls?: boolean; + maxHeight?: string; + className?: string; +} + +export const GlobalFileViewer: React.FC = ({ + showControls = true, + maxHeight = "600px", + className = "", +}) => { + const [allFiles, setAllFiles] = useState([]); + const [filteredFiles, setFilteredFiles] = useState([]); + const [searchQuery, setSearchQuery] = useState(""); + const [selectedTab, setSelectedTab] = useState("all"); + const [viewerFile, setViewerFile] = useState(null); + const [isViewerOpen, setIsViewerOpen] = useState(false); + const [registryInfo, setRegistryInfo] = useState({ + totalFiles: 0, + accessibleFiles: 0, + pages: [] as string[], + screens: [] as number[], + }); + + // 파일 아이콘 가져오기 + const getFileIcon = (fileName: string, size: number = 16) => { + const extension = fileName.split('.').pop()?.toLowerCase() || ''; + const iconProps = { size, className: "text-gray-600" }; + + if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'].includes(extension)) { + return ; + } + if (['mp4', 'avi', 'mov', 'wmv', 'flv', 'webm'].includes(extension)) { + return