diff --git a/backend-node/prisma/schema.prisma b/backend-node/prisma/schema.prisma index 3e8812ab..7cd671d2 100644 --- a/backend-node/prisma/schema.prisma +++ b/backend-node/prisma/schema.prisma @@ -111,6 +111,10 @@ model batch_mappings { from_api_url String? @db.VarChar(500) from_api_key String? @db.VarChar(200) from_api_method String? @db.VarChar(10) + from_api_param_type String? @db.VarChar(10) // 'url' 또는 'query' + from_api_param_name String? @db.VarChar(100) // 파라미터명 + from_api_param_value String? @db.VarChar(500) // 파라미터 값 또는 템플릿 + from_api_param_source String? @db.VarChar(10) // 'static' 또는 'dynamic' to_connection_type String @db.VarChar(20) to_connection_id Int? to_table_name String @db.VarChar(100) diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index d3b53f33..6339213e 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -52,7 +52,14 @@ 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" })); diff --git a/backend-node/src/controllers/batchController.ts b/backend-node/src/controllers/batchController.ts index ba270f41..0858cc37 100644 --- a/backend-node/src/controllers/batchController.ts +++ b/backend-node/src/controllers/batchController.ts @@ -3,6 +3,7 @@ import { Request, Response } from "express"; import { BatchService } from "../services/batchService"; +import { BatchSchedulerService } from "../services/batchSchedulerService"; import { BatchConfigFilter, CreateBatchConfigRequest, UpdateBatchConfigRequest } from "../types/batchTypes"; export interface AuthenticatedRequest extends Request { @@ -190,6 +191,11 @@ export class BatchController { cronSchedule, mappings } as CreateBatchConfigRequest); + + // 생성된 배치가 활성화 상태라면 스케줄러에 등록 (즉시 실행 비활성화) + if (batchConfig.data && batchConfig.data.is_active === 'Y' && batchConfig.data.id) { + await BatchSchedulerService.updateBatchSchedule(batchConfig.data.id, false); + } return res.status(201).json({ success: true, @@ -235,6 +241,9 @@ export class BatchController { message: "배치 설정을 찾을 수 없습니다." }); } + + // 스케줄러에서 배치 스케줄 업데이트 (즉시 실행 비활성화) + await BatchSchedulerService.updateBatchSchedule(Number(id), false); return res.json({ success: true, diff --git a/backend-node/src/controllers/batchManagementController.ts b/backend-node/src/controllers/batchManagementController.ts index 4381a340..71640577 100644 --- a/backend-node/src/controllers/batchManagementController.ts +++ b/backend-node/src/controllers/batchManagementController.ts @@ -224,18 +224,15 @@ export class BatchManagementController { } const batchConfig = batchConfigResult.data as BatchConfig; - - // 배치 실행 로직 (간단한 버전) const startTime = new Date(); - let totalRecords = 0; - let successRecords = 0; - let failedRecords = 0; + console.log(`배치 수동 실행 시작: ${batchConfig.batch_name} (ID: ${id})`); + + let executionLog: any = null; + try { - console.log(`배치 실행 시작: ${batchConfig.batch_name} (ID: ${id})`); - // 실행 로그 생성 - const executionLog = await BatchService.createExecutionLog({ + executionLog = await BatchService.createExecutionLog({ batch_config_id: Number(id), execution_status: 'RUNNING', start_time: startTime, @@ -244,199 +241,74 @@ export class BatchManagementController { failed_records: 0 }); - // 실제 배치 실행 (매핑이 있는 경우) - if (batchConfig.batch_mappings && batchConfig.batch_mappings.length > 0) { - // 테이블별로 매핑을 그룹화 - const tableGroups = new Map(); - - for (const mapping of batchConfig.batch_mappings) { - const key = `${mapping.from_connection_type}:${mapping.from_connection_id || 'internal'}:${mapping.from_table_name}`; - if (!tableGroups.has(key)) { - tableGroups.set(key, []); - } - tableGroups.get(key)!.push(mapping); - } + // BatchSchedulerService의 executeBatchConfig 메서드 사용 (중복 로직 제거) + const { BatchSchedulerService } = await import('../services/batchSchedulerService'); + const result = await BatchSchedulerService.executeBatchConfig(batchConfig); - // 각 테이블 그룹별로 처리 - for (const [tableKey, mappings] of tableGroups) { - try { - const firstMapping = mappings[0]; - console.log(`테이블 처리 시작: ${tableKey} -> ${mappings.length}개 컬럼 매핑`); - - let fromData: any[] = []; - - // FROM 데이터 조회 (DB 또는 REST API) - if (firstMapping.from_connection_type === 'restapi') { - // REST API에서 데이터 조회 - console.log(`REST API에서 데이터 조회: ${firstMapping.from_api_url}${firstMapping.from_table_name}`); - console.log(`API 설정:`, { - url: firstMapping.from_api_url, - key: firstMapping.from_api_key ? '***' : 'null', - method: firstMapping.from_api_method, - endpoint: firstMapping.from_table_name - }); - - try { - const apiResult = await BatchExternalDbService.getDataFromRestApi( - firstMapping.from_api_url!, - firstMapping.from_api_key!, - firstMapping.from_table_name, - firstMapping.from_api_method as 'GET' | 'POST' | 'PUT' | 'DELETE' || 'GET', - mappings.map(m => m.from_column_name) - ); - - console.log(`API 조회 결과:`, { - success: apiResult.success, - dataCount: apiResult.data ? apiResult.data.length : 0, - message: apiResult.message - }); - - if (apiResult.success && apiResult.data) { - fromData = apiResult.data; - } else { - throw new Error(`REST API 데이터 조회 실패: ${apiResult.message}`); - } - } catch (error) { - console.error(`REST API 조회 오류:`, error); - throw error; - } - } else { - // DB에서 데이터 조회 - const fromColumns = mappings.map(m => m.from_column_name); - fromData = await BatchService.getDataFromTableWithColumns( - firstMapping.from_table_name, - fromColumns, - firstMapping.from_connection_type as 'internal' | 'external', - firstMapping.from_connection_id || undefined - ); - } - - totalRecords += fromData.length; - - // 컬럼 매핑 적용하여 TO 테이블 형식으로 변환 - const mappedData = fromData.map(row => { - const mappedRow: any = {}; - for (const mapping of mappings) { - // DB → REST API 배치인지 확인 - if (firstMapping.to_connection_type === 'restapi' && mapping.to_api_body) { - // DB → REST API: 원본 컬럼명을 키로 사용 (템플릿 처리용) - mappedRow[mapping.from_column_name] = row[mapping.from_column_name]; - } else { - // 기존 로직: to_column_name을 키로 사용 - mappedRow[mapping.to_column_name] = row[mapping.from_column_name]; - } - } - return mappedRow; - }); - - // TO 테이블에 데이터 삽입 (DB 또는 REST API) - let insertResult: { successCount: number; failedCount: number }; - - if (firstMapping.to_connection_type === 'restapi') { - // REST API로 데이터 전송 - console.log(`REST API로 데이터 전송: ${firstMapping.to_api_url}${firstMapping.to_table_name}`); - - // DB → REST API 배치인지 확인 (to_api_body가 있으면 템플릿 기반) - const hasTemplate = mappings.some(m => m.to_api_body); - - if (hasTemplate) { - // 템플릿 기반 REST API 전송 (DB → REST API 배치) - const templateBody = firstMapping.to_api_body || '{}'; - console.log(`템플릿 기반 REST API 전송, Request Body 템플릿: ${templateBody}`); - - // URL 경로 컬럼 찾기 (PUT/DELETE용) - const urlPathColumn = mappings.find(m => m.to_column_name === 'URL_PATH_PARAM')?.from_column_name; - - const apiResult = await BatchExternalDbService.sendDataToRestApiWithTemplate( - firstMapping.to_api_url!, - firstMapping.to_api_key!, - firstMapping.to_table_name, - firstMapping.to_api_method as 'POST' | 'PUT' | 'DELETE' || 'POST', - templateBody, - mappedData, - urlPathColumn - ); - - if (apiResult.success && apiResult.data) { - insertResult = apiResult.data; - } else { - throw new Error(`템플릿 기반 REST API 데이터 전송 실패: ${apiResult.message}`); - } - } else { - // 기존 REST API 전송 (REST API → DB 배치) - const apiResult = await BatchExternalDbService.sendDataToRestApi( - firstMapping.to_api_url!, - firstMapping.to_api_key!, - firstMapping.to_table_name, - firstMapping.to_api_method as 'POST' | 'PUT' || 'POST', - mappedData - ); - - if (apiResult.success && apiResult.data) { - insertResult = apiResult.data; - } else { - throw new Error(`REST API 데이터 전송 실패: ${apiResult.message}`); - } - } - } else { - // DB에 데이터 삽입 - insertResult = await BatchService.insertDataToTable( - firstMapping.to_table_name, - mappedData, - firstMapping.to_connection_type as 'internal' | 'external', - firstMapping.to_connection_id || undefined - ); - } - - successRecords += insertResult.successCount; - failedRecords += insertResult.failedCount; - - console.log(`테이블 처리 완료: ${insertResult.successCount}개 성공, ${insertResult.failedCount}개 실패`); - } catch (error) { - console.error(`테이블 처리 실패: ${tableKey}`, error); - failedRecords += 1; - } - } - } else { - console.log("매핑이 없어서 데이터 처리를 건너뜁니다."); + // result가 undefined인 경우 처리 + if (!result) { + throw new Error('배치 실행 결과를 받을 수 없습니다.'); } + const endTime = new Date(); + const duration = endTime.getTime() - startTime.getTime(); + // 실행 로그 업데이트 (성공) await BatchService.updateExecutionLog(executionLog.id, { execution_status: 'SUCCESS', - end_time: new Date(), - duration_ms: Date.now() - startTime.getTime(), - total_records: totalRecords, - success_records: successRecords, - failed_records: failedRecords + end_time: endTime, + duration_ms: duration, + total_records: result.totalRecords, + success_records: result.successRecords, + failed_records: result.failedRecords }); return res.json({ success: true, - message: "배치가 성공적으로 실행되었습니다.", data: { - batchId: id, - totalRecords, - successRecords, - failedRecords, - duration: Date.now() - startTime.getTime() - } + batchName: batchConfig.batch_name, + totalRecords: result.totalRecords, + successRecords: result.successRecords, + failedRecords: result.failedRecords, + executionTime: duration + }, + message: "배치가 성공적으로 실행되었습니다." }); - } catch (error) { - console.error(`배치 실행 실패: ${batchConfig.batch_name}`, error); + + } catch (batchError) { + console.error(`배치 실행 실패: ${batchConfig.batch_name}`, batchError); + // 실행 로그 업데이트 (실패) - executionLog가 생성되었을 경우에만 + try { + const endTime = new Date(); + const duration = endTime.getTime() - startTime.getTime(); + + // executionLog가 정의되어 있는지 확인 + if (typeof executionLog !== 'undefined') { + await BatchService.updateExecutionLog(executionLog.id, { + execution_status: 'FAILED', + end_time: endTime, + duration_ms: duration, + error_message: batchError instanceof Error ? batchError.message : "알 수 없는 오류" + }); + } + } catch (logError) { + console.error('실행 로그 업데이트 실패:', logError); + } + return res.status(500).json({ success: false, message: "배치 실행에 실패했습니다.", - error: error instanceof Error ? error.message : "알 수 없는 오류" + error: batchError instanceof Error ? batchError.message : "알 수 없는 오류" }); } + } catch (error) { - console.error("배치 실행 오류:", error); + console.error(`배치 실행 오류 (ID: ${req.params.id}):`, error); return res.status(500).json({ success: false, message: "배치 실행 중 오류가 발생했습니다.", - error: error instanceof Error ? error.message : "알 수 없는 오류" + error: error instanceof Error ? error.message : "Unknown error" }); } } @@ -459,8 +331,8 @@ export class BatchManagementController { const batchConfig = await BatchService.updateBatchConfig(Number(id), updateData); - // 스케줄러에서 배치 스케줄 업데이트 - await BatchSchedulerService.updateBatchSchedule(Number(id)); + // 스케줄러에서 배치 스케줄 업데이트 (즉시 실행 비활성화) + await BatchSchedulerService.updateBatchSchedule(Number(id), false); return res.json({ success: true, @@ -482,7 +354,16 @@ export class BatchManagementController { */ static async previewRestApiData(req: AuthenticatedRequest, res: Response) { try { - const { apiUrl, apiKey, endpoint, method = 'GET' } = req.body; + const { + apiUrl, + apiKey, + endpoint, + method = 'GET', + paramType, + paramName, + paramValue, + paramSource + } = req.body; if (!apiUrl || !apiKey || !endpoint) { return res.status(400).json({ @@ -491,6 +372,15 @@ export class BatchManagementController { }); } + console.log("🔍 REST API 미리보기 요청:", { + apiUrl, + endpoint, + paramType, + paramName, + paramValue, + paramSource + }); + // RestApiConnector 사용하여 데이터 조회 const { RestApiConnector } = await import('../database/RestApiConnector'); @@ -503,8 +393,28 @@ export class BatchManagementController { // 연결 테스트 await connector.connect(); + // 파라미터가 있는 경우 엔드포인트 수정 + let finalEndpoint = endpoint; + if (paramType && paramName && paramValue) { + if (paramType === 'url') { + // URL 파라미터: /api/users/{userId} → /api/users/123 + if (endpoint.includes(`{${paramName}}`)) { + finalEndpoint = endpoint.replace(`{${paramName}}`, paramValue); + } else { + // 엔드포인트에 {paramName}이 없으면 뒤에 추가 + finalEndpoint = `${endpoint}/${paramValue}`; + } + } else if (paramType === 'query') { + // 쿼리 파라미터: /api/users?userId=123 + const separator = endpoint.includes('?') ? '&' : '?'; + finalEndpoint = `${endpoint}${separator}${paramName}=${paramValue}`; + } + } + + console.log("🔗 최종 엔드포인트:", finalEndpoint); + // 데이터 조회 (최대 5개만) - GET 메서드만 지원 - const result = await connector.executeQuery(endpoint, method); + const result = await connector.executeQuery(finalEndpoint, method); console.log(`[previewRestApiData] executeQuery 결과:`, { rowCount: result.rowCount, rowsLength: result.rows ? result.rows.length : 'undefined', 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/database/RestApiConnector.ts b/backend-node/src/database/RestApiConnector.ts index 98da0eb3..4ce0039e 100644 --- a/backend-node/src/database/RestApiConnector.ts +++ b/backend-node/src/database/RestApiConnector.ts @@ -27,7 +27,7 @@ export class RestApiConnector implements DatabaseConnector { timeout: config.timeout || 30000, headers: { 'Content-Type': 'application/json', - 'X-API-Key': config.apiKey, + 'Authorization': `Bearer ${config.apiKey}`, 'Accept': 'application/json' } }); 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/batchExternalDbService.ts b/backend-node/src/services/batchExternalDbService.ts index 470c3b75..d5670f04 100644 --- a/backend-node/src/services/batchExternalDbService.ts +++ b/backend-node/src/services/batchExternalDbService.ts @@ -697,7 +697,12 @@ export class BatchExternalDbService { endpoint: string, method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET', columns?: string[], - limit: number = 100 + limit: number = 100, + // 파라미터 정보 추가 + paramType?: 'url' | 'query', + paramName?: string, + paramValue?: string, + paramSource?: 'static' | 'dynamic' ): Promise> { try { console.log(`[BatchExternalDbService] REST API 데이터 조회: ${apiUrl}${endpoint}`); @@ -712,8 +717,33 @@ export class BatchExternalDbService { // 연결 테스트 await connector.connect(); + // 파라미터가 있는 경우 엔드포인트 수정 + const { logger } = await import('../utils/logger'); + logger.info(`[BatchExternalDbService] 파라미터 정보`, { + paramType, paramName, paramValue, paramSource + }); + + let finalEndpoint = endpoint; + if (paramType && paramName && paramValue) { + if (paramType === 'url') { + // URL 파라미터: /api/users/{userId} → /api/users/123 + if (endpoint.includes(`{${paramName}}`)) { + finalEndpoint = endpoint.replace(`{${paramName}}`, paramValue); + } else { + // 엔드포인트에 {paramName}이 없으면 뒤에 추가 + finalEndpoint = `${endpoint}/${paramValue}`; + } + } else if (paramType === 'query') { + // 쿼리 파라미터: /api/users?userId=123 + const separator = endpoint.includes('?') ? '&' : '?'; + finalEndpoint = `${endpoint}${separator}${paramName}=${paramValue}`; + } + + logger.info(`[BatchExternalDbService] 파라미터 적용된 엔드포인트: ${finalEndpoint}`); + } + // 데이터 조회 - const result = await connector.executeQuery(endpoint, method); + const result = await connector.executeQuery(finalEndpoint, method); let data = result.rows; // 컬럼 필터링 (지정된 컬럼만 추출) @@ -734,7 +764,8 @@ export class BatchExternalDbService { data = data.slice(0, limit); } - console.log(`[BatchExternalDbService] REST API 데이터 조회 완료: ${data.length}개 레코드`); + logger.info(`[BatchExternalDbService] REST API 데이터 조회 완료: ${data.length}개 레코드`); + logger.info(`[BatchExternalDbService] 조회된 데이터`, { data }); return { success: true, diff --git a/backend-node/src/services/batchSchedulerService.ts b/backend-node/src/services/batchSchedulerService.ts index 3d032291..ea2f7f89 100644 --- a/backend-node/src/services/batchSchedulerService.ts +++ b/backend-node/src/services/batchSchedulerService.ts @@ -10,19 +10,18 @@ import { logger } from '../utils/logger'; export class BatchSchedulerService { private static scheduledTasks: Map = new Map(); private static isInitialized = false; + private static executingBatches: Set = new Set(); // 실행 중인 배치 추적 /** * 스케줄러 초기화 */ static async initialize() { - if (this.isInitialized) { - logger.info('배치 스케줄러가 이미 초기화되었습니다.'); - return; - } - try { logger.info('배치 스케줄러 초기화 시작...'); + // 기존 모든 스케줄 정리 (중복 방지) + this.clearAllSchedules(); + // 활성화된 배치 설정들을 로드하여 스케줄 등록 await this.loadActiveBatchConfigs(); @@ -34,6 +33,27 @@ export class BatchSchedulerService { } } + /** + * 모든 스케줄 정리 + */ + private static clearAllSchedules() { + logger.info(`기존 스케줄 ${this.scheduledTasks.size}개 정리 중...`); + + for (const [id, task] of this.scheduledTasks) { + try { + task.stop(); + task.destroy(); + logger.info(`스케줄 정리 완료: ID ${id}`); + } catch (error) { + logger.error(`스케줄 정리 실패: ID ${id}`, error); + } + } + + this.scheduledTasks.clear(); + this.isInitialized = false; + logger.info('모든 스케줄 정리 완료'); + } + /** * 활성화된 배치 설정들을 로드하여 스케줄 등록 */ @@ -80,8 +100,23 @@ export class BatchSchedulerService { // 새로운 스케줄 등록 const task = cron.schedule(cron_schedule, async () => { + // 중복 실행 방지 체크 + if (this.executingBatches.has(id)) { + logger.warn(`⚠️ 배치가 이미 실행 중입니다. 건너뜀: ${batch_name} (ID: ${id})`); + return; + } + logger.info(`🔄 스케줄 배치 실행 시작: ${batch_name} (ID: ${id})`); - await this.executeBatchConfig(config); + + // 실행 중 플래그 설정 + this.executingBatches.add(id); + + try { + await this.executeBatchConfig(config); + } finally { + // 실행 완료 후 플래그 제거 + this.executingBatches.delete(id); + } }); // 스케줄 시작 (기본적으로 시작되지만 명시적으로 호출) @@ -112,7 +147,7 @@ export class BatchSchedulerService { /** * 배치 설정 업데이트 시 스케줄 재등록 */ - static async updateBatchSchedule(configId: number) { + static async updateBatchSchedule(configId: number, executeImmediately: boolean = true) { try { // 기존 스케줄 제거 await this.unscheduleBatchConfig(configId); @@ -132,6 +167,12 @@ export class BatchSchedulerService { if (config.is_active === 'Y') { await this.scheduleBatchConfig(config); logger.info(`배치 스케줄 업데이트 완료: ${config.batch_name} (ID: ${configId})`); + + // 활성화 시 즉시 실행 (옵션) + if (executeImmediately) { + logger.info(`🚀 배치 활성화 즉시 실행: ${config.batch_name} (ID: ${configId})`); + await this.executeBatchConfig(config); + } } else { logger.info(`비활성화된 배치 스케줄 제거: ${config.batch_name} (ID: ${configId})`); } @@ -143,7 +184,7 @@ export class BatchSchedulerService { /** * 배치 설정 실행 */ - private static async executeBatchConfig(config: any) { + static async executeBatchConfig(config: any) { const startTime = new Date(); let executionLog: any = null; @@ -162,7 +203,11 @@ export class BatchSchedulerService { if (!executionLogResponse.success || !executionLogResponse.data) { logger.error(`배치 실행 로그 생성 실패: ${config.batch_name}`, executionLogResponse.message); - return; + return { + totalRecords: 0, + successRecords: 0, + failedRecords: 1 + }; } executionLog = executionLogResponse.data; @@ -181,6 +226,10 @@ export class BatchSchedulerService { }); logger.info(`배치 실행 완료: ${config.batch_name} (처리된 레코드: ${result.totalRecords})`); + + // 성공 결과 반환 + return result; + } catch (error) { logger.error(`배치 실행 실패: ${config.batch_name}`, error); @@ -194,6 +243,13 @@ export class BatchSchedulerService { error_details: error instanceof Error ? error.stack : String(error) }); } + + // 실패 시에도 결과 반환 + return { + totalRecords: 0, + successRecords: 0, + failedRecords: 1 + }; } } @@ -239,7 +295,13 @@ export class BatchSchedulerService { firstMapping.from_api_key!, firstMapping.from_table_name, firstMapping.from_api_method as 'GET' | 'POST' | 'PUT' | 'DELETE' || 'GET', - mappings.map((m: any) => m.from_column_name) + mappings.map((m: any) => m.from_column_name), + 100, // limit + // 파라미터 정보 전달 + firstMapping.from_api_param_type, + firstMapping.from_api_param_name, + firstMapping.from_api_param_value, + firstMapping.from_api_param_source ); if (apiResult.success && apiResult.data) { diff --git a/backend-node/src/services/batchService.ts b/backend-node/src/services/batchService.ts index edac1629..80cd9064 100644 --- a/backend-node/src/services/batchService.ts +++ b/backend-node/src/services/batchService.ts @@ -168,6 +168,10 @@ export class BatchService { from_api_url: mapping.from_api_url, from_api_key: mapping.from_api_key, from_api_method: mapping.from_api_method, + from_api_param_type: mapping.from_api_param_type, + from_api_param_name: mapping.from_api_param_name, + from_api_param_value: mapping.from_api_param_value, + from_api_param_source: mapping.from_api_param_source, to_connection_type: mapping.to_connection_type, to_connection_id: mapping.to_connection_id, to_table_name: mapping.to_table_name, @@ -176,7 +180,7 @@ export class BatchService { to_api_url: mapping.to_api_url, to_api_key: mapping.to_api_key, to_api_method: mapping.to_api_method, - // to_api_body: mapping.to_api_body, // Request Body 템플릿 추가 - 임시 주석 처리 + to_api_body: mapping.to_api_body, mapping_order: mapping.mapping_order || index + 1, created_by: userId, }, @@ -260,11 +264,22 @@ export class BatchService { from_table_name: mapping.from_table_name, from_column_name: mapping.from_column_name, from_column_type: mapping.from_column_type, + from_api_url: mapping.from_api_url, + from_api_key: mapping.from_api_key, + from_api_method: mapping.from_api_method, + from_api_param_type: mapping.from_api_param_type, + from_api_param_name: mapping.from_api_param_name, + from_api_param_value: mapping.from_api_param_value, + from_api_param_source: mapping.from_api_param_source, to_connection_type: mapping.to_connection_type, to_connection_id: mapping.to_connection_id, to_table_name: mapping.to_table_name, to_column_name: mapping.to_column_name, to_column_type: mapping.to_column_type, + to_api_url: mapping.to_api_url, + to_api_key: mapping.to_api_key, + to_api_method: mapping.to_api_method, + to_api_body: mapping.to_api_body, mapping_order: mapping.mapping_order || index + 1, created_by: userId, }, @@ -707,18 +722,57 @@ export class BatchService { const updateColumns = columns.filter(col => col !== primaryKeyColumn); const updateSet = updateColumns.map(col => `${col} = EXCLUDED.${col}`).join(', '); - let query: string; - if (updateSet) { - // UPSERT: 중복 시 업데이트 - query = `INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${placeholders}) - ON CONFLICT (${primaryKeyColumn}) DO UPDATE SET ${updateSet}`; - } else { - // Primary Key만 있는 경우 중복 시 무시 - query = `INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${placeholders}) - ON CONFLICT (${primaryKeyColumn}) DO NOTHING`; - } + // 트랜잭션 내에서 처리하여 연결 관리 최적화 + const result = await prisma.$transaction(async (tx) => { + // 먼저 해당 레코드가 존재하는지 확인 + const checkQuery = `SELECT COUNT(*) as count FROM ${tableName} WHERE ${primaryKeyColumn} = $1`; + const existsResult = await tx.$queryRawUnsafe(checkQuery, record[primaryKeyColumn]); + const exists = (existsResult as any)[0]?.count > 0; + + let operationResult = 'no_change'; + + if (exists && updateSet) { + // 기존 레코드가 있으면 UPDATE (값이 다른 경우에만) + const whereConditions = updateColumns.map((col, index) => { + // 날짜/시간 컬럼에 대한 타입 캐스팅 처리 + if (col.toLowerCase().includes('date') || + col.toLowerCase().includes('time') || + col.toLowerCase().includes('created') || + col.toLowerCase().includes('updated') || + col.toLowerCase().includes('reg')) { + return `${col} IS DISTINCT FROM $${index + 2}::timestamp`; + } + return `${col} IS DISTINCT FROM $${index + 2}`; + }).join(' OR '); + + const query = `UPDATE ${tableName} SET ${updateSet.replace(/EXCLUDED\./g, '')} + WHERE ${primaryKeyColumn} = $1 AND (${whereConditions})`; + + // 파라미터: [primaryKeyValue, ...updateValues] + const updateValues = [record[primaryKeyColumn], ...updateColumns.map(col => record[col])]; + const updateResult = await tx.$executeRawUnsafe(query, ...updateValues); + + if (updateResult > 0) { + console.log(`[BatchService] 레코드 업데이트: ${primaryKeyColumn}=${record[primaryKeyColumn]}`); + operationResult = 'updated'; + } else { + console.log(`[BatchService] 레코드 변경사항 없음: ${primaryKeyColumn}=${record[primaryKeyColumn]}`); + operationResult = 'no_change'; + } + } else if (!exists) { + // 새 레코드 삽입 + const query = `INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${placeholders})`; + await tx.$executeRawUnsafe(query, ...values); + console.log(`[BatchService] 새 레코드 삽입: ${primaryKeyColumn}=${record[primaryKeyColumn]}`); + operationResult = 'inserted'; + } else { + console.log(`[BatchService] 레코드 이미 존재 (변경사항 없음): ${primaryKeyColumn}=${record[primaryKeyColumn]}`); + operationResult = 'no_change'; + } + + return operationResult; + }); - await prisma.$executeRawUnsafe(query, ...values); successCount++; } catch (error) { console.error(`레코드 UPSERT 실패:`, error); diff --git a/backend-node/src/types/batchTypes.ts b/backend-node/src/types/batchTypes.ts index e2a676ef..24158a3d 100644 --- a/backend-node/src/types/batchTypes.ts +++ b/backend-node/src/types/batchTypes.ts @@ -37,6 +37,10 @@ export interface BatchMapping { from_api_method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; // REST API 전용 from_api_url?: string; // REST API 서버 URL from_api_key?: string; // REST API 키 + from_api_param_type?: 'url' | 'query'; // API 파라미터 타입 + from_api_param_name?: string; // API 파라미터명 + from_api_param_value?: string; // API 파라미터 값 또는 템플릿 + from_api_param_source?: 'static' | 'dynamic'; // 파라미터 소스 타입 // TO 정보 to_connection_type: 'internal' | 'external' | 'restapi'; @@ -92,6 +96,10 @@ export interface BatchMappingRequest { from_api_url?: string; from_api_key?: string; from_api_method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; + from_api_param_type?: 'url' | 'query'; // API 파라미터 타입 + from_api_param_name?: string; // API 파라미터명 + from_api_param_value?: string; // API 파라미터 값 또는 템플릿 + from_api_param_source?: 'static' | 'dynamic'; // 파라미터 소스 타입 to_connection_type: 'internal' | 'external' | 'restapi'; to_connection_id?: number; to_table_name: string; diff --git a/frontend/app/(main)/admin/batch-management-new/page.tsx b/frontend/app/(main)/admin/batch-management-new/page.tsx index 53a840ff..f70d711a 100644 --- a/frontend/app/(main)/admin/batch-management-new/page.tsx +++ b/frontend/app/(main)/admin/batch-management-new/page.tsx @@ -53,6 +53,12 @@ export default function BatchManagementNewPage() { const [fromApiKey, setFromApiKey] = useState(""); const [fromEndpoint, setFromEndpoint] = useState(""); const [fromApiMethod, setFromApiMethod] = useState<'GET'>('GET'); // GET만 지원 + + // REST API 파라미터 설정 + const [apiParamType, setApiParamType] = useState<'none' | 'url' | 'query'>('none'); + const [apiParamName, setApiParamName] = useState(""); // 파라미터명 (예: userId, id) + const [apiParamValue, setApiParamValue] = useState(""); // 파라미터 값 또는 템플릿 + const [apiParamSource, setApiParamSource] = useState<'static' | 'dynamic'>('static'); // 정적 값 또는 동적 값 // DB → REST API용 상태 const [fromConnection, setFromConnection] = useState(null); @@ -309,7 +315,14 @@ export default function BatchManagementNewPage() { fromApiUrl, fromApiKey, fromEndpoint, - fromApiMethod + fromApiMethod, + // 파라미터 정보 추가 + apiParamType !== 'none' ? { + paramType: apiParamType, + paramName: apiParamName, + paramValue: apiParamValue, + paramSource: apiParamSource + } : undefined ); console.log("API 미리보기 결과:", result); @@ -371,6 +384,11 @@ export default function BatchManagementNewPage() { from_api_url: fromApiUrl, from_api_key: fromApiKey, from_api_method: fromApiMethod, + // API 파라미터 정보 추가 + from_api_param_type: apiParamType !== 'none' ? apiParamType : undefined, + from_api_param_name: apiParamType !== 'none' ? apiParamName : undefined, + from_api_param_value: apiParamType !== 'none' ? apiParamValue : undefined, + from_api_param_source: apiParamType !== 'none' ? apiParamSource : undefined, to_connection_type: toConnection?.type === 'internal' ? 'internal' : 'external', to_connection_id: toConnection?.type === 'internal' ? undefined : toConnection?.id, to_table_name: toTable, @@ -661,14 +679,119 @@ export default function BatchManagementNewPage() { + {/* API 파라미터 설정 */} +
+
+ +

특정 사용자나 조건으로 데이터를 조회할 때 사용합니다.

+
+ +
+ + +
+ + {apiParamType !== 'none' && ( + <> +
+
+ + setApiParamName(e.target.value)} + placeholder="userId, id, email 등" + /> +
+
+ + +
+
+ +
+ + setApiParamValue(e.target.value)} + placeholder={ + apiParamSource === 'static' + ? "123, john@example.com 등" + : "{{user_id}}, {{email}} 등 (실행 시 치환됨)" + } + /> + {apiParamSource === 'dynamic' && ( +

+ 동적값은 배치 실행 시 설정된 값으로 치환됩니다. 예: {`{{user_id}}`} → 실제 사용자 ID +

+ )} +
+ + {apiParamType === 'url' && ( +
+
URL 파라미터 예시
+
+ 엔드포인트: /api/users/{`{${apiParamName || 'userId'}}`} +
+
+ 실제 호출: /api/users/{apiParamValue || '123'} +
+
+ )} + + {apiParamType === 'query' && ( +
+
쿼리 파라미터 예시
+
+ 실제 호출: {fromEndpoint || '/api/users'}?{apiParamName || 'userId'}={apiParamValue || '123'} +
+
+ )} + + )} +
+ {fromApiUrl && fromApiKey && fromEndpoint && (
API 호출 미리보기
- {fromApiMethod} {fromApiUrl}{fromEndpoint} + {fromApiMethod} {fromApiUrl} + {apiParamType === 'url' && apiParamName && apiParamValue + ? fromEndpoint.replace(`{${apiParamName}}`, apiParamValue) || fromEndpoint + `/${apiParamValue}` + : fromEndpoint + } + {apiParamType === 'query' && apiParamName && apiParamValue + ? `?${apiParamName}=${apiParamValue}` + : '' + }
Headers: X-API-Key: {fromApiKey.substring(0, 10)}...
+ {apiParamType !== 'none' && apiParamName && apiParamValue && ( +
+ 파라미터: {apiParamName} = {apiParamValue} ({apiParamSource === 'static' ? '고정값' : '동적값'}) +
+ )}
+ + {/* 전역 파일 관리 */} +
+
+

전역 파일 관리

+

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

+
+ +
+ ); diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index 395a2be8..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, 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