diff --git a/backend-node/prisma/schema.prisma b/backend-node/prisma/schema.prisma index ee964c70..e88081f0 100644 --- a/backend-node/prisma/schema.prisma +++ b/backend-node/prisma/schema.prisma @@ -102,11 +102,18 @@ model batch_mappings { from_table_name String @db.VarChar(100) from_column_name String @db.VarChar(100) from_column_type String? @db.VarChar(50) + from_api_url String? @db.VarChar(500) + from_api_key String? @db.VarChar(200) + from_api_method String? @db.VarChar(10) to_connection_type String @db.VarChar(20) to_connection_id Int? to_table_name String @db.VarChar(100) to_column_name String @db.VarChar(100) to_column_type String? @db.VarChar(50) + to_api_url String? @db.VarChar(500) + to_api_key String? @db.VarChar(200) + to_api_method String? @db.VarChar(10) + to_api_body String? @db.Text mapping_order Int? @default(1) created_date DateTime? @default(now()) @db.Timestamp(6) created_by String? @db.VarChar(50) @@ -117,6 +124,8 @@ model batch_mappings { @@index([batch_config_id], map: "idx_batch_mappings_config") @@index([from_connection_type, from_connection_id], map: "idx_batch_mappings_from") @@index([to_connection_type, to_connection_id], map: "idx_batch_mappings_to") + @@index([from_connection_type, from_api_url], map: "idx_batch_mappings_from_api") + @@index([to_connection_type, to_api_url], map: "idx_batch_mappings_to_api") } model batch_execution_logs { diff --git a/backend-node/src/controllers/batchManagementController.ts b/backend-node/src/controllers/batchManagementController.ts index 15de2e35..4381a340 100644 --- a/backend-node/src/controllers/batchManagementController.ts +++ b/backend-node/src/controllers/batchManagementController.ts @@ -6,6 +6,7 @@ import { AuthenticatedRequest } from "../types/auth"; import { BatchManagementService, BatchConnectionInfo, BatchTableInfo, BatchColumnInfo } from "../services/batchManagementService"; import { BatchService } from "../services/batchService"; import { BatchSchedulerService } from "../services/batchSchedulerService"; +import { BatchExternalDbService } from "../services/batchExternalDbService"; import { CreateBatchConfigRequest, BatchConfig } from "../types/batchTypes"; export class BatchManagementController { @@ -132,6 +133,40 @@ export class BatchManagementController { } } + /** + * 특정 배치 설정 조회 + * GET /api/batch-management/batch-configs/:id + */ + static async getBatchConfigById(req: AuthenticatedRequest, res: Response) { + try { + const { id } = req.params; + console.log("🔍 배치 설정 조회 요청:", id); + + const result = await BatchService.getBatchConfigById(Number(id)); + + if (!result.success) { + return res.status(404).json({ + success: false, + message: result.message || "배치 설정을 찾을 수 없습니다." + }); + } + + console.log("📋 조회된 배치 설정:", result.data); + + return res.json({ + success: true, + data: result.data + }); + } catch (error) { + console.error("❌ 배치 설정 조회 오류:", error); + return res.status(500).json({ + success: false, + message: "배치 설정 조회에 실패했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }); + } + } + /** * 배치 설정 목록 조회 * GET /api/batch-management/batch-configs @@ -228,32 +263,131 @@ export class BatchManagementController { const firstMapping = mappings[0]; console.log(`테이블 처리 시작: ${tableKey} -> ${mappings.length}개 컬럼 매핑`); - // FROM 테이블에서 매핑된 컬럼들만 조회 - const fromColumns = mappings.map(m => m.from_column_name); - const fromData = await BatchService.getDataFromTableWithColumns( - firstMapping.from_table_name, - fromColumns, - firstMapping.from_connection_type as 'internal' | 'external', - firstMapping.from_connection_id || undefined - ); + 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) { - mappedRow[mapping.to_column_name] = row[mapping.from_column_name]; + // 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 테이블에 데이터 삽입 - const insertResult = await BatchService.insertDataToTable( - firstMapping.to_table_name, - mappedData, - firstMapping.to_connection_type as 'internal' | 'external', - firstMapping.to_connection_id || undefined - ); + // 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; @@ -342,4 +476,144 @@ export class BatchManagementController { }); } } + + /** + * REST API 데이터 미리보기 + */ + static async previewRestApiData(req: AuthenticatedRequest, res: Response) { + try { + const { apiUrl, apiKey, endpoint, method = 'GET' } = req.body; + + if (!apiUrl || !apiKey || !endpoint) { + return res.status(400).json({ + success: false, + message: "API URL, API Key, 엔드포인트는 필수입니다." + }); + } + + // RestApiConnector 사용하여 데이터 조회 + const { RestApiConnector } = await import('../database/RestApiConnector'); + + const connector = new RestApiConnector({ + baseUrl: apiUrl, + apiKey: apiKey, + timeout: 30000 + }); + + // 연결 테스트 + await connector.connect(); + + // 데이터 조회 (최대 5개만) - GET 메서드만 지원 + const result = await connector.executeQuery(endpoint, method); + console.log(`[previewRestApiData] executeQuery 결과:`, { + rowCount: result.rowCount, + rowsLength: result.rows ? result.rows.length : 'undefined', + firstRow: result.rows && result.rows.length > 0 ? result.rows[0] : 'no data' + }); + + const data = result.rows.slice(0, 5); // 최대 5개 샘플만 + console.log(`[previewRestApiData] 슬라이스된 데이터:`, data); + + if (data.length > 0) { + // 첫 번째 객체에서 필드명 추출 + const fields = Object.keys(data[0]); + console.log(`[previewRestApiData] 추출된 필드:`, fields); + + return res.json({ + success: true, + data: { + fields: fields, + samples: data, + totalCount: result.rowCount || data.length + }, + message: `${fields.length}개 필드, ${result.rowCount || data.length}개 레코드를 조회했습니다.` + }); + } else { + return res.json({ + success: true, + data: { + fields: [], + samples: [], + totalCount: 0 + }, + message: "API에서 데이터를 가져올 수 없습니다." + }); + } + } catch (error) { + console.error("REST API 미리보기 오류:", error); + return res.status(500).json({ + success: false, + message: "REST API 데이터 미리보기 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }); + } + } + + /** + * REST API 배치 설정 저장 + */ + static async saveRestApiBatch(req: AuthenticatedRequest, res: Response) { + try { + const { + batchName, + batchType, + cronSchedule, + description, + apiMappings + } = req.body; + + if (!batchName || !batchType || !cronSchedule || !apiMappings || apiMappings.length === 0) { + return res.status(400).json({ + success: false, + message: "필수 필드가 누락되었습니다." + }); + } + + console.log("REST API 배치 저장 요청:", { + batchName, + batchType, + cronSchedule, + description, + apiMappings + }); + + // BatchService를 사용하여 배치 설정 저장 + const batchConfig: CreateBatchConfigRequest = { + batchName: batchName, + description: description || '', + cronSchedule: cronSchedule, + mappings: apiMappings + }; + + const result = await BatchService.createBatchConfig(batchConfig); + + if (result.success && result.data) { + // 스케줄러에 자동 등록 ✅ + try { + await BatchSchedulerService.scheduleBatchConfig(result.data); + console.log(`✅ 새로운 배치가 스케줄러에 등록되었습니다: ${batchName} (ID: ${result.data.id})`); + } catch (schedulerError) { + console.error(`❌ 스케줄러 등록 실패: ${batchName}`, schedulerError); + // 스케줄러 등록 실패해도 배치 저장은 성공으로 처리 + } + + return res.json({ + success: true, + message: "REST API 배치가 성공적으로 저장되었습니다.", + data: result.data + }); + } else { + return res.status(500).json({ + success: false, + message: result.message || "배치 저장에 실패했습니다." + }); + } + } catch (error) { + console.error("REST API 배치 저장 오류:", error); + return res.status(500).json({ + success: false, + message: "배치 저장 중 오류가 발생했습니다." + }); + } + } } diff --git a/backend-node/src/database/DatabaseConnectorFactory.ts b/backend-node/src/database/DatabaseConnectorFactory.ts index f54c0e2e..e83bb566 100644 --- a/backend-node/src/database/DatabaseConnectorFactory.ts +++ b/backend-node/src/database/DatabaseConnectorFactory.ts @@ -2,6 +2,7 @@ import { DatabaseConnector, ConnectionConfig } from '../interfaces/DatabaseConne import { PostgreSQLConnector } from './PostgreSQLConnector'; import { OracleConnector } from './OracleConnector'; import { MariaDBConnector } from './MariaDBConnector'; +import { RestApiConnector, RestApiConfig } from './RestApiConnector'; export class DatabaseConnectorFactory { private static connectors = new Map(); @@ -28,6 +29,9 @@ export class DatabaseConnectorFactory { case 'mariadb': connector = new MariaDBConnector(config); break; + case 'restapi': + connector = new RestApiConnector(config as RestApiConfig); + break; // Add other database types here default: throw new Error(`지원하지 않는 데이터베이스 타입: ${type}`); diff --git a/backend-node/src/database/RestApiConnector.ts b/backend-node/src/database/RestApiConnector.ts new file mode 100644 index 00000000..98da0eb3 --- /dev/null +++ b/backend-node/src/database/RestApiConnector.ts @@ -0,0 +1,261 @@ +import axios, { AxiosInstance, AxiosResponse } from 'axios'; +import { DatabaseConnector, ConnectionConfig, QueryResult } from '../interfaces/DatabaseConnector'; +import { ConnectionTestResult, TableInfo } from '../types/externalDbTypes'; + +export interface RestApiConfig { + baseUrl: string; + apiKey: string; + timeout?: number; + // ConnectionConfig 호환성을 위한 더미 필드들 (사용하지 않음) + host?: string; + port?: number; + database?: string; + user?: string; + password?: string; +} + +export class RestApiConnector implements DatabaseConnector { + private httpClient: AxiosInstance; + private config: RestApiConfig; + + constructor(config: RestApiConfig) { + this.config = config; + + // Axios 인스턴스 생성 + this.httpClient = axios.create({ + baseURL: config.baseUrl, + timeout: config.timeout || 30000, + headers: { + 'Content-Type': 'application/json', + 'X-API-Key': config.apiKey, + 'Accept': 'application/json' + } + }); + + // 요청/응답 인터셉터 설정 + this.setupInterceptors(); + } + + private setupInterceptors() { + // 요청 인터셉터 + this.httpClient.interceptors.request.use( + (config) => { + console.log(`[RestApiConnector] 요청: ${config.method?.toUpperCase()} ${config.url}`); + return config; + }, + (error) => { + console.error('[RestApiConnector] 요청 오류:', error); + return Promise.reject(error); + } + ); + + // 응답 인터셉터 + this.httpClient.interceptors.response.use( + (response) => { + console.log(`[RestApiConnector] 응답: ${response.status} ${response.statusText}`); + return response; + }, + (error) => { + console.error('[RestApiConnector] 응답 오류:', error.response?.status, error.response?.statusText); + return Promise.reject(error); + } + ); + } + + async connect(): Promise { + try { + // 연결 테스트 - 기본 엔드포인트 호출 + await this.httpClient.get('/health', { timeout: 5000 }); + console.log(`[RestApiConnector] 연결 성공: ${this.config.baseUrl}`); + } catch (error) { + // health 엔드포인트가 없을 수 있으므로 404는 정상으로 처리 + if (axios.isAxiosError(error) && error.response?.status === 404) { + console.log(`[RestApiConnector] 연결 성공 (health 엔드포인트 없음): ${this.config.baseUrl}`); + return; + } + console.error(`[RestApiConnector] 연결 실패: ${this.config.baseUrl}`, error); + throw new Error(`REST API 연결 실패: ${error instanceof Error ? error.message : '알 수 없는 오류'}`); + } + } + + async disconnect(): Promise { + // REST API는 연결 해제가 필요 없음 + console.log(`[RestApiConnector] 연결 해제: ${this.config.baseUrl}`); + } + + async testConnection(): Promise { + try { + await this.connect(); + return { + success: true, + message: 'REST API 연결이 성공했습니다.', + details: { + response_time: Date.now() + } + }; + } catch (error) { + return { + success: false, + message: error instanceof Error ? error.message : 'REST API 연결에 실패했습니다.', + details: { + response_time: Date.now() + } + }; + } + } + + async executeQuery(endpoint: string, method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET', data?: any): Promise { + try { + const startTime = Date.now(); + let response: AxiosResponse; + + // HTTP 메서드에 따른 요청 실행 + switch (method.toUpperCase()) { + case 'GET': + response = await this.httpClient.get(endpoint); + break; + case 'POST': + response = await this.httpClient.post(endpoint, data); + break; + case 'PUT': + response = await this.httpClient.put(endpoint, data); + break; + case 'DELETE': + response = await this.httpClient.delete(endpoint); + break; + default: + throw new Error(`지원하지 않는 HTTP 메서드: ${method}`); + } + + const executionTime = Date.now() - startTime; + const responseData = response.data; + + console.log(`[RestApiConnector] 원본 응답 데이터:`, { + type: typeof responseData, + isArray: Array.isArray(responseData), + keys: typeof responseData === 'object' ? Object.keys(responseData) : 'not object', + responseData: responseData + }); + + // 응답 데이터 처리 + let rows: any[]; + if (Array.isArray(responseData)) { + rows = responseData; + } else if (responseData && responseData.data && Array.isArray(responseData.data)) { + // API 응답이 {success: true, data: [...]} 형태인 경우 + rows = responseData.data; + } else if (responseData && responseData.data && typeof responseData.data === 'object') { + // API 응답이 {success: true, data: {...}} 형태인 경우 (단일 객체) + rows = [responseData.data]; + } else if (responseData && typeof responseData === 'object' && !Array.isArray(responseData)) { + // 단일 객체 응답인 경우 + rows = [responseData]; + } else { + rows = []; + } + + console.log(`[RestApiConnector] 처리된 rows:`, { + rowsLength: rows.length, + firstRow: rows.length > 0 ? rows[0] : 'no data', + allRows: rows + }); + + console.log(`[RestApiConnector] API 호출 결과:`, { + endpoint, + method, + status: response.status, + rowCount: rows.length, + executionTime: `${executionTime}ms` + }); + + return { + rows: rows, + rowCount: rows.length, + fields: rows.length > 0 ? Object.keys(rows[0]).map(key => ({ name: key, type: 'string' })) : [] + }; + } catch (error) { + console.error(`[RestApiConnector] API 호출 오류 (${method} ${endpoint}):`, error); + + if (axios.isAxiosError(error)) { + throw new Error(`REST API 호출 실패: ${error.response?.status} ${error.response?.statusText}`); + } + + throw new Error(`REST API 호출 실패: ${error instanceof Error ? error.message : '알 수 없는 오류'}`); + } + } + + async getTables(): Promise { + // REST API의 경우 "테이블"은 사용 가능한 엔드포인트를 의미 + // 일반적인 REST API 엔드포인트들을 반환 + return [ + { + table_name: '/api/users', + columns: [], + description: '사용자 정보 API' + }, + { + table_name: '/api/data', + columns: [], + description: '기본 데이터 API' + }, + { + table_name: '/api/custom', + columns: [], + description: '사용자 정의 엔드포인트' + } + ]; + } + + async getTableList(): Promise { + return this.getTables(); + } + + async getColumns(endpoint: string): Promise { + try { + // GET 요청으로 샘플 데이터를 가져와서 필드 구조 파악 + const result = await this.executeQuery(endpoint, 'GET'); + + if (result.rows.length > 0) { + const sampleRow = result.rows[0]; + return Object.keys(sampleRow).map(key => ({ + column_name: key, + data_type: typeof sampleRow[key], + is_nullable: 'YES', + column_default: null, + description: `${key} 필드` + })); + } + + return []; + } catch (error) { + console.error(`[RestApiConnector] 컬럼 정보 조회 오류 (${endpoint}):`, error); + return []; + } + } + + async getTableColumns(endpoint: string): Promise { + return this.getColumns(endpoint); + } + + // REST API 전용 메서드들 + async getData(endpoint: string, params?: Record): Promise { + const queryString = params ? '?' + new URLSearchParams(params).toString() : ''; + const result = await this.executeQuery(endpoint + queryString, 'GET'); + return result.rows; + } + + async postData(endpoint: string, data: any): Promise { + const result = await this.executeQuery(endpoint, 'POST', data); + return result.rows[0]; + } + + async putData(endpoint: string, data: any): Promise { + const result = await this.executeQuery(endpoint, 'PUT', data); + return result.rows[0]; + } + + async deleteData(endpoint: string): Promise { + const result = await this.executeQuery(endpoint, 'DELETE'); + return result.rows[0]; + } +} diff --git a/backend-node/src/routes/batchManagementRoutes.ts b/backend-node/src/routes/batchManagementRoutes.ts index e7a43fff..d6adf4c5 100644 --- a/backend-node/src/routes/batchManagementRoutes.ts +++ b/backend-node/src/routes/batchManagementRoutes.ts @@ -11,7 +11,7 @@ const router = Router(); * GET /api/batch-management/connections * 사용 가능한 커넥션 목록 조회 */ -router.get("/connections", BatchManagementController.getAvailableConnections); +router.get("/connections", authenticateToken, BatchManagementController.getAvailableConnections); /** * GET /api/batch-management/connections/:type/tables @@ -49,6 +49,12 @@ router.post("/batch-configs", authenticateToken, BatchManagementController.creat */ router.get("/batch-configs", authenticateToken, BatchManagementController.getBatchConfigs); +/** + * GET /api/batch-management/batch-configs/:id + * 특정 배치 설정 조회 + */ +router.get("/batch-configs/:id", authenticateToken, BatchManagementController.getBatchConfigById); + /** * PUT /api/batch-management/batch-configs/:id * 배치 설정 업데이트 @@ -61,4 +67,16 @@ router.put("/batch-configs/:id", authenticateToken, BatchManagementController.up */ router.post("/batch-configs/:id/execute", authenticateToken, BatchManagementController.executeBatchConfig); +/** + * POST /api/batch-management/rest-api/preview + * REST API 데이터 미리보기 + */ +router.post("/rest-api/preview", authenticateToken, BatchManagementController.previewRestApiData); + +/** + * POST /api/batch-management/rest-api/save + * REST API 배치 저장 + */ +router.post("/rest-api/save", authenticateToken, BatchManagementController.saveRestApiBatch); + export default router; diff --git a/backend-node/src/services/batchExternalDbService.ts b/backend-node/src/services/batchExternalDbService.ts index 6fd4d006..470c3b75 100644 --- a/backend-node/src/services/batchExternalDbService.ts +++ b/backend-node/src/services/batchExternalDbService.ts @@ -5,6 +5,7 @@ import prisma from "../config/database"; import { PasswordEncryption } from "../utils/passwordEncryption"; import { DatabaseConnectorFactory } from "../database/DatabaseConnectorFactory"; +import { RestApiConnector } from "../database/RestApiConnector"; import { ApiResponse, ColumnInfo, TableInfo } from "../types/batchTypes"; export class BatchExternalDbService { @@ -686,4 +687,226 @@ export class BatchExternalDbService { }; } } + + /** + * REST API에서 데이터 조회 + */ + static async getDataFromRestApi( + apiUrl: string, + apiKey: string, + endpoint: string, + method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET', + columns?: string[], + limit: number = 100 + ): Promise> { + try { + console.log(`[BatchExternalDbService] REST API 데이터 조회: ${apiUrl}${endpoint}`); + + // REST API 커넥터 생성 + const connector = new RestApiConnector({ + baseUrl: apiUrl, + apiKey: apiKey, + timeout: 30000 + }); + + // 연결 테스트 + await connector.connect(); + + // 데이터 조회 + const result = await connector.executeQuery(endpoint, method); + let data = result.rows; + + // 컬럼 필터링 (지정된 컬럼만 추출) + if (columns && columns.length > 0) { + data = data.map(row => { + const filteredRow: any = {}; + columns.forEach(col => { + if (row.hasOwnProperty(col)) { + filteredRow[col] = row[col]; + } + }); + return filteredRow; + }); + } + + // 제한 개수 적용 + if (limit > 0) { + data = data.slice(0, limit); + } + + console.log(`[BatchExternalDbService] REST API 데이터 조회 완료: ${data.length}개 레코드`); + + return { + success: true, + data: data + }; + } catch (error) { + console.error(`[BatchExternalDbService] REST API 데이터 조회 오류 (${apiUrl}${endpoint}):`, error); + return { + success: false, + message: "REST API 데이터 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }; + } + } + + /** + * 템플릿 기반 REST API로 데이터 전송 (DB → REST API 배치용) + */ + static async sendDataToRestApiWithTemplate( + apiUrl: string, + apiKey: string, + endpoint: string, + method: 'POST' | 'PUT' | 'DELETE' = 'POST', + templateBody: string, + data: any[], + urlPathColumn?: string // URL 경로에 사용할 컬럼명 (PUT/DELETE용) + ): Promise> { + try { + console.log(`[BatchExternalDbService] 템플릿 기반 REST API 데이터 전송: ${apiUrl}${endpoint}, ${data.length}개 레코드`); + console.log(`[BatchExternalDbService] Request Body 템플릿:`, templateBody); + + // REST API 커넥터 생성 + const connector = new RestApiConnector({ + baseUrl: apiUrl, + apiKey: apiKey, + timeout: 30000 + }); + + // 연결 테스트 + await connector.connect(); + + let successCount = 0; + let failedCount = 0; + + // 각 레코드를 개별적으로 전송 + for (const record of data) { + try { + // 템플릿 처리: {{컬럼명}} → 실제 값으로 치환 + let processedBody = templateBody; + for (const [key, value] of Object.entries(record)) { + const placeholder = `{{${key}}}`; + let stringValue = ''; + + if (value !== null && value !== undefined) { + // Date 객체인 경우 다양한 포맷으로 변환 + if (value instanceof Date) { + // ISO 형식: 2025-09-25T07:22:52.000Z + stringValue = value.toISOString(); + + // 다른 포맷이 필요한 경우 여기서 처리 + // 예: YYYY-MM-DD 형식 + // stringValue = value.toISOString().split('T')[0]; + + // 예: YYYY-MM-DD HH:mm:ss 형식 + // stringValue = value.toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, ''); + } else { + stringValue = String(value); + } + } + + processedBody = processedBody.replace(new RegExp(placeholder.replace(/[{}]/g, '\\$&'), 'g'), stringValue); + } + + console.log(`[BatchExternalDbService] 원본 레코드:`, record); + console.log(`[BatchExternalDbService] 처리된 Request Body:`, processedBody); + + // JSON 파싱하여 객체로 변환 + let requestData; + try { + requestData = JSON.parse(processedBody); + } catch (parseError) { + console.error(`[BatchExternalDbService] JSON 파싱 오류:`, parseError); + throw new Error(`Request Body JSON 파싱 실패: ${parseError}`); + } + + // URL 경로 파라미터 처리 (PUT/DELETE용) + let finalEndpoint = endpoint; + if ((method === 'PUT' || method === 'DELETE') && urlPathColumn && record[urlPathColumn]) { + // /api/users → /api/users/user123 + finalEndpoint = `${endpoint}/${record[urlPathColumn]}`; + } + + console.log(`[BatchExternalDbService] 실행할 API 호출: ${method} ${finalEndpoint}`); + console.log(`[BatchExternalDbService] 전송할 데이터:`, requestData); + + await connector.executeQuery(finalEndpoint, method, requestData); + successCount++; + } catch (error) { + console.error(`REST API 레코드 전송 실패:`, error); + failedCount++; + } + } + + console.log(`[BatchExternalDbService] 템플릿 기반 REST API 데이터 전송 완료: 성공 ${successCount}개, 실패 ${failedCount}개`); + + return { + success: true, + data: { successCount, failedCount } + }; + } catch (error) { + console.error(`[BatchExternalDbService] 템플릿 기반 REST API 데이터 전송 오류:`, error); + return { + success: false, + message: `REST API 데이터 전송 실패: ${error}`, + data: { successCount: 0, failedCount: 0 } + }; + } + } + + /** + * REST API로 데이터 전송 (기존 메서드) + */ + static async sendDataToRestApi( + apiUrl: string, + apiKey: string, + endpoint: string, + method: 'POST' | 'PUT' = 'POST', + data: any[] + ): Promise> { + try { + console.log(`[BatchExternalDbService] REST API 데이터 전송: ${apiUrl}${endpoint}, ${data.length}개 레코드`); + + // REST API 커넥터 생성 + const connector = new RestApiConnector({ + baseUrl: apiUrl, + apiKey: apiKey, + timeout: 30000 + }); + + // 연결 테스트 + await connector.connect(); + + let successCount = 0; + let failedCount = 0; + + // 각 레코드를 개별적으로 전송 + for (const record of data) { + try { + console.log(`[BatchExternalDbService] 실행할 API 호출: ${method} ${endpoint}`); + console.log(`[BatchExternalDbService] 전송할 데이터:`, record); + + await connector.executeQuery(endpoint, method, record); + successCount++; + } catch (error) { + console.error(`REST API 레코드 전송 실패:`, error); + failedCount++; + } + } + + console.log(`[BatchExternalDbService] REST API 데이터 전송 완료: 성공 ${successCount}개, 실패 ${failedCount}개`); + + return { + success: true, + data: { successCount, failedCount } + }; + } catch (error) { + console.error(`[BatchExternalDbService] REST API 데이터 전송 오류 (${apiUrl}${endpoint}):`, error); + return { + success: false, + message: "REST API 데이터 전송 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }; + } + } } diff --git a/backend-node/src/services/batchManagementService.ts b/backend-node/src/services/batchManagementService.ts index 01b90743..1b082209 100644 --- a/backend-node/src/services/batchManagementService.ts +++ b/backend-node/src/services/batchManagementService.ts @@ -170,6 +170,8 @@ export class BatchManagementService { ORDER BY ordinal_position `; + console.log(`[BatchManagementService] 쿼리 결과:`, result); + console.log(`[BatchManagementService] 내부 DB 컬럼 조회 결과:`, result); columns = result.map(row => ({ diff --git a/backend-node/src/services/batchSchedulerService.ts b/backend-node/src/services/batchSchedulerService.ts index de03df33..3d032291 100644 --- a/backend-node/src/services/batchSchedulerService.ts +++ b/backend-node/src/services/batchSchedulerService.ts @@ -80,11 +80,15 @@ export class BatchSchedulerService { // 새로운 스케줄 등록 const task = cron.schedule(cron_schedule, async () => { + logger.info(`🔄 스케줄 배치 실행 시작: ${batch_name} (ID: ${id})`); await this.executeBatchConfig(config); }); + // 스케줄 시작 (기본적으로 시작되지만 명시적으로 호출) + task.start(); + this.scheduledTasks.set(id, task); - logger.info(`배치 스케줄 등록 완료: ${batch_name} (ID: ${id}, Schedule: ${cron_schedule})`); + logger.info(`배치 스케줄 등록 완료: ${batch_name} (ID: ${id}, Schedule: ${cron_schedule}) - 스케줄 시작됨`); } catch (error) { logger.error(`배치 스케줄 등록 실패 (ID: ${config.id}):`, error); } @@ -223,32 +227,115 @@ export class BatchSchedulerService { const firstMapping = mappings[0]; logger.info(`테이블 처리 시작: ${tableKey} -> ${mappings.length}개 컬럼 매핑`); - // FROM 테이블에서 매핑된 컬럼들만 조회 - const fromColumns = mappings.map((m: any) => m.from_column_name); - const fromData = await BatchService.getDataFromTableWithColumns( - firstMapping.from_table_name, - fromColumns, - firstMapping.from_connection_type as 'internal' | 'external', - firstMapping.from_connection_id || undefined - ); + let fromData: any[] = []; + + // FROM 데이터 조회 (DB 또는 REST API) + if (firstMapping.from_connection_type === 'restapi') { + // REST API에서 데이터 조회 + logger.info(`REST API에서 데이터 조회: ${firstMapping.from_api_url}${firstMapping.from_table_name}`); + const { BatchExternalDbService } = await import('./batchExternalDbService'); + 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: any) => m.from_column_name) + ); + + if (apiResult.success && apiResult.data) { + fromData = apiResult.data; + } else { + throw new Error(`REST API 데이터 조회 실패: ${apiResult.message}`); + } + } else { + // DB에서 데이터 조회 + const fromColumns = mappings.map((m: any) => 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) { - mappedRow[mapping.to_column_name] = row[mapping.from_column_name]; + // 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 테이블에 데이터 삽입 - const insertResult = await BatchService.insertDataToTable( - firstMapping.to_table_name, - mappedData, - firstMapping.to_connection_type as 'internal' | 'external', - firstMapping.to_connection_id || undefined - ); + // TO 테이블에 데이터 삽입 (DB 또는 REST API) + let insertResult: { successCount: number; failedCount: number }; + + if (firstMapping.to_connection_type === 'restapi') { + // REST API로 데이터 전송 + logger.info(`REST API로 데이터 전송: ${firstMapping.to_api_url}${firstMapping.to_table_name}`); + const { BatchExternalDbService } = await import('./batchExternalDbService'); + + // DB → REST API 배치인지 확인 (to_api_body가 있으면 템플릿 기반) + const hasTemplate = mappings.some((m: any) => m.to_api_body); + + if (hasTemplate) { + // 템플릿 기반 REST API 전송 (DB → REST API 배치) + const templateBody = firstMapping.to_api_body || '{}'; + logger.info(`템플릿 기반 REST API 전송, Request Body 템플릿: ${templateBody}`); + + // URL 경로 컬럼 찾기 (PUT/DELETE용) + const urlPathColumn = mappings.find((m: any) => 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; diff --git a/backend-node/src/services/batchService.ts b/backend-node/src/services/batchService.ts index bfdac8ac..f9c65aed 100644 --- a/backend-node/src/services/batchService.ts +++ b/backend-node/src/services/batchService.ts @@ -165,11 +165,18 @@ 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, 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, // Request Body 템플릿 추가 mapping_order: mapping.mapping_order || index + 1, created_by: userId, }, @@ -311,12 +318,14 @@ export class BatchService { }; } - await prisma.batch_configs.update({ - where: { id }, - data: { - is_active: "N", - updated_by: userId, - }, + // 배치 매핑 먼저 삭제 (외래키 제약) + await prisma.batch_mappings.deleteMany({ + where: { batch_config_id: id } + }); + + // 배치 설정 삭제 + await prisma.batch_configs.delete({ + where: { id } }); return { @@ -730,6 +739,7 @@ export class BatchService { return { successCount: 0, failedCount: data.length }; } } else { + console.log(`[BatchService] 연결 정보 디버그:`, { connectionType, connectionId }); throw new Error(`잘못된 연결 타입 또는 연결 ID: ${connectionType}, ${connectionId}`); } } catch (error) { diff --git a/backend-node/src/types/batchTypes.ts b/backend-node/src/types/batchTypes.ts index 2cba6ace..e2a676ef 100644 --- a/backend-node/src/types/batchTypes.ts +++ b/backend-node/src/types/batchTypes.ts @@ -1,6 +1,15 @@ // 배치관리 타입 정의 // 작성일: 2024-12-24 +// 배치 타입 정의 +export type BatchType = 'db-to-db' | 'db-to-restapi' | 'restapi-to-db' | 'restapi-to-restapi'; + +export interface BatchTypeOption { + value: BatchType; + label: string; + description: string; +} + export interface BatchConfig { id?: number; batch_name: string; @@ -20,18 +29,25 @@ export interface BatchMapping { batch_config_id?: number; // FROM 정보 - from_connection_type: 'internal' | 'external'; + from_connection_type: 'internal' | 'external' | 'restapi'; from_connection_id?: number; - from_table_name: string; - from_column_name: string; + from_table_name: string; // DB: 테이블명, REST API: 엔드포인트 + from_column_name: string; // DB: 컬럼명, REST API: JSON 필드명 from_column_type?: string; + from_api_method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; // REST API 전용 + from_api_url?: string; // REST API 서버 URL + from_api_key?: string; // REST API 키 // TO 정보 - to_connection_type: 'internal' | 'external'; + to_connection_type: 'internal' | 'external' | 'restapi'; to_connection_id?: number; - to_table_name: string; - to_column_name: string; + to_table_name: string; // DB: 테이블명, REST API: 엔드포인트 + to_column_name: string; // DB: 컬럼명, REST API: JSON 필드명 to_column_type?: string; + to_api_method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; // REST API 전용 + to_api_url?: string; // REST API 서버 URL + to_api_key?: string; // REST API 키 + to_api_body?: string; // Request Body 템플릿 (DB → REST API 배치용) mapping_order?: number; created_date?: Date; @@ -68,16 +84,23 @@ export interface ColumnInfo { } export interface BatchMappingRequest { - from_connection_type: 'internal' | 'external'; + from_connection_type: 'internal' | 'external' | 'restapi'; from_connection_id?: number; from_table_name: string; from_column_name: string; from_column_type?: string; - to_connection_type: 'internal' | 'external'; + from_api_url?: string; + from_api_key?: string; + from_api_method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; + to_connection_type: 'internal' | 'external' | 'restapi'; to_connection_id?: number; to_table_name: string; to_column_name: string; to_column_type?: string; + to_api_url?: string; + to_api_key?: string; + to_api_method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; + to_api_body?: string; // Request Body 템플릿 (DB → REST API 배치용) mapping_order?: number; } diff --git a/frontend/app/(main)/admin/batch-management-new/page.tsx b/frontend/app/(main)/admin/batch-management-new/page.tsx index 47ed2b87..53a840ff 100644 --- a/frontend/app/(main)/admin/batch-management-new/page.tsx +++ b/frontend/app/(main)/admin/batch-management-new/page.tsx @@ -1,270 +1,520 @@ "use client"; import React, { useState, useEffect } from "react"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { useRouter } from "next/navigation"; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Badge } from "@/components/ui/badge"; -import { Trash2, Plus, ArrowRight, Save, RefreshCw } from "lucide-react"; +import { Trash2, Plus, ArrowRight, Save, RefreshCw, Globe, Database, Eye } from "lucide-react"; import { toast } from "sonner"; -import { - BatchManagementAPI, - BatchConnectionInfo, - BatchColumnInfo, -} from "@/lib/api/batchManagement"; +import { BatchManagementAPI } from "@/lib/api/batchManagement"; -interface MappingState { - from: { - connection: BatchConnectionInfo | null; - table: string; - column: BatchColumnInfo | null; - } | null; - to: { - connection: BatchConnectionInfo | null; - table: string; - column: BatchColumnInfo | null; - } | null; +// 타입 정의 +type BatchType = 'db-to-restapi' | 'restapi-to-db' | 'restapi-to-restapi'; + +interface BatchTypeOption { + value: BatchType; + label: string; + description: string; +} + +interface BatchConnectionInfo { + id: number; + name: string; + type: string; +} + +interface BatchColumnInfo { + column_name: string; + data_type: string; + is_nullable: string; } export default function BatchManagementNewPage() { + const router = useRouter(); + // 기본 상태 const [batchName, setBatchName] = useState(""); const [cronSchedule, setCronSchedule] = useState("0 12 * * *"); const [description, setDescription] = useState(""); - - // 커넥션 및 테이블 데이터 + + // 연결 정보 const [connections, setConnections] = useState([]); - const [fromTables, setFromTables] = useState([]); - const [toTables, setToTables] = useState([]); - const [fromColumns, setFromColumns] = useState([]); - const [toColumns, setToColumns] = useState([]); - - // 선택된 상태 - const [fromConnection, setFromConnection] = useState(null); const [toConnection, setToConnection] = useState(null); - const [fromTable, setFromTable] = useState(""); + const [toTables, setToTables] = useState([]); const [toTable, setToTable] = useState(""); - const [selectedFromColumn, setSelectedFromColumn] = useState(null); + const [toColumns, setToColumns] = useState([]); + + // REST API 설정 (REST API → DB용) + const [fromApiUrl, setFromApiUrl] = useState(""); + const [fromApiKey, setFromApiKey] = useState(""); + const [fromEndpoint, setFromEndpoint] = useState(""); + const [fromApiMethod, setFromApiMethod] = useState<'GET'>('GET'); // GET만 지원 + + // DB → REST API용 상태 + const [fromConnection, setFromConnection] = useState(null); + const [fromTables, setFromTables] = useState([]); + const [fromTable, setFromTable] = useState(""); + const [fromColumns, setFromColumns] = useState([]); + const [selectedColumns, setSelectedColumns] = useState([]); // 선택된 컬럼들 + const [dbToApiFieldMapping, setDbToApiFieldMapping] = useState>({}); // DB 컬럼 → API 필드 매핑 - // 매핑 상태 - const [mappings, setMappings] = useState([]); + // REST API 대상 설정 (DB → REST API용) + const [toApiUrl, setToApiUrl] = useState(""); + const [toApiKey, setToApiKey] = useState(""); + const [toEndpoint, setToEndpoint] = useState(""); + const [toApiMethod, setToApiMethod] = useState<'POST' | 'PUT' | 'DELETE'>('POST'); + const [toApiBody, setToApiBody] = useState(''); // Request Body 템플릿 + const [toApiFields, setToApiFields] = useState([]); // TO API 필드 목록 + const [urlPathColumn, setUrlPathColumn] = useState(""); // URL 경로에 사용할 컬럼 (PUT/DELETE용) + + // API 데이터 미리보기 + const [fromApiData, setFromApiData] = useState([]); + const [fromApiFields, setFromApiFields] = useState([]); + + // API 필드 → DB 컬럼 매핑 + const [apiFieldMappings, setApiFieldMappings] = useState>({}); + + // 배치 타입 상태 + const [batchType, setBatchType] = useState('restapi-to-db'); + + // 배치 타입 옵션 + const batchTypeOptions: BatchTypeOption[] = [ + { + value: 'restapi-to-db', + label: 'REST API → DB', + description: 'REST API에서 데이터베이스로 데이터 수집' + }, + { + value: 'db-to-restapi', + label: 'DB → REST API', + description: '데이터베이스에서 REST API로 데이터 전송' + } + ]; // 초기 데이터 로드 useEffect(() => { loadConnections(); }, []); - // 커넥션 목록 로드 - const loadConnections = async () => { - try { - const data = await BatchManagementAPI.getAvailableConnections(); - setConnections(Array.isArray(data) ? data : []); - } catch (error) { - console.error("커넥션 목록 로드 오류:", error); - toast.error("커넥션 목록을 불러오는데 실패했습니다."); - setConnections([]); // 오류 시 빈 배열로 설정 - } - }; - - // FROM 커넥션 변경 시 테이블 로드 - const handleFromConnectionChange = async (connectionId: string) => { - if (connectionId === 'unknown') return; + // 배치 타입 변경 시 상태 초기화 + useEffect(() => { + // 공통 초기화 + setApiFieldMappings({}); - const connection = connections.find((c: BatchConnectionInfo) => - c.type === 'internal' ? c.type === connectionId : c.id?.toString() === connectionId - ); + // REST API → DB 관련 초기화 + setToConnection(null); + setToTables([]); + setToTable(""); + setToColumns([]); + setFromApiUrl(""); + setFromApiKey(""); + setFromEndpoint(""); + setFromApiData([]); + setFromApiFields([]); - if (!connection) return; - - setFromConnection(connection); + // DB → REST API 관련 초기화 + setFromConnection(null); + setFromTables([]); setFromTable(""); setFromColumns([]); - setSelectedFromColumn(null); - + setSelectedColumns([]); + setDbToApiFieldMapping({}); + setToApiUrl(""); + setToApiKey(""); + setToEndpoint(""); + setToApiBody(""); + setToApiFields([]); + }, [batchType]); + + + // 연결 목록 로드 + const loadConnections = async () => { try { - const tables = await BatchManagementAPI.getTablesFromConnection( - connection.type, - connection.id - ); - setFromTables(Array.isArray(tables) ? tables : []); + const result = await BatchManagementAPI.getAvailableConnections(); + setConnections(result || []); } catch (error) { - console.error("FROM 테이블 목록 로드 오류:", error); - toast.error("테이블 목록을 불러오는데 실패했습니다."); - setFromTables([]); // 오류 시 빈 배열로 설정 + console.error("연결 목록 로드 오류:", error); + toast.error("연결 목록을 불러오는데 실패했습니다."); } }; - // TO 커넥션 변경 시 테이블 로드 - const handleToConnectionChange = async (connectionId: string) => { - if (connectionId === 'unknown') return; + // TO 연결 변경 핸들러 + const handleToConnectionChange = async (connectionValue: string) => { + let connection: BatchConnectionInfo | null = null; - const connection = connections.find((c: BatchConnectionInfo) => - c.type === 'internal' ? c.type === connectionId : c.id?.toString() === connectionId - ); - - if (!connection) return; + if (connectionValue === 'internal') { + // 내부 데이터베이스 선택 + connection = connections.find(conn => conn.type === 'internal') || null; + } else { + // 외부 데이터베이스 선택 + const connectionId = parseInt(connectionValue); + connection = connections.find(conn => conn.id === connectionId) || null; + } setToConnection(connection); setToTable(""); setToColumns([]); - - try { - const tables = await BatchManagementAPI.getTablesFromConnection( - connection.type, - connection.id - ); - setToTables(Array.isArray(tables) ? tables : []); - } catch (error) { - console.error("TO 테이블 목록 로드 오류:", error); - toast.error("테이블 목록을 불러오는데 실패했습니다."); - setToTables([]); // 오류 시 빈 배열로 설정 - } - }; - // FROM 테이블 변경 시 컬럼 로드 - const handleFromTableChange = async (tableName: string) => { - if (!fromConnection) return; - - setFromTable(tableName); - setSelectedFromColumn(null); - - try { - const columns = await BatchManagementAPI.getTableColumns( - fromConnection.type, - tableName, - fromConnection.id - ); - setFromColumns(Array.isArray(columns) ? columns : []); - } catch (error) { - console.error("FROM 컬럼 목록 로드 오류:", error); - toast.error("컬럼 목록을 불러오는데 실패했습니다."); - setFromColumns([]); // 오류 시 빈 배열로 설정 - } - }; - - // TO 테이블 변경 시 컬럼 로드 - const handleToTableChange = async (tableName: string) => { - if (!toConnection) return; - - console.log("TO 테이블 변경:", { - tableName, - connectionType: toConnection.type, - connectionId: toConnection.id - }); - - setToTable(tableName); - - try { - const columns = await BatchManagementAPI.getTableColumns( - toConnection.type, - tableName, - toConnection.id - ); - console.log("TO 컬럼 목록 로드 성공:", columns); - setToColumns(Array.isArray(columns) ? columns : []); - } catch (error) { - console.error("TO 컬럼 목록 로드 오류:", error); - toast.error("컬럼 목록을 불러오는데 실패했습니다."); - setToColumns([]); // 오류 시 빈 배열로 설정 - } - }; - - // FROM 컬럼 클릭 - const handleFromColumnClick = (column: BatchColumnInfo) => { - setSelectedFromColumn(column); - }; - - // TO 컬럼 클릭 (매핑 생성) - const handleToColumnClick = (column: BatchColumnInfo) => { - if (!selectedFromColumn || !fromConnection || !toConnection) { - toast.error("FROM 컬럼을 먼저 선택해주세요."); - return; - } - - // N:1 매핑 방지 (여러 FROM 컬럼이 같은 TO 컬럼에 매핑되는 것 방지) - const isAlreadyMapped = mappings.some(mapping => - mapping.to?.connection?.type === toConnection.type && - mapping.to?.connection?.id === toConnection.id && - mapping.to?.table === toTable && - mapping.to?.column?.column_name === column.column_name - ); - - if (isAlreadyMapped) { - toast.error("이미 매핑된 TO 컬럼입니다. N:1 매핑은 허용되지 않습니다."); - return; - } - - // 새 매핑 추가 - const newMapping: MappingState = { - from: { - connection: fromConnection, - table: fromTable, - column: selectedFromColumn - }, - to: { - connection: toConnection, - table: toTable, - column: column + if (connection) { + try { + const connectionType = connection.type === 'internal' ? 'internal' : 'external'; + const result = await BatchManagementAPI.getTablesFromConnection(connectionType, connection.id); + const tableNames = Array.isArray(result) + ? result.map((table: any) => typeof table === 'string' ? table : table.table_name || String(table)) + : []; + setToTables(tableNames); + } catch (error) { + console.error("테이블 목록 로드 오류:", error); + toast.error("테이블 목록을 불러오는데 실패했습니다."); } - }; - - setMappings([...mappings, newMapping]); - setSelectedFromColumn(null); - toast.success("매핑이 추가되었습니다."); + } }; - // 매핑 삭제 - const removeMapping = (index: number) => { - setMappings(mappings.filter((_, i) => i !== index)); - toast.success("매핑이 삭제되었습니다."); + // TO 테이블 변경 핸들러 + const handleToTableChange = async (tableName: string) => { + console.log("🔍 테이블 변경:", { tableName, toConnection }); + setToTable(tableName); + setToColumns([]); + + if (toConnection && tableName) { + try { + const connectionType = toConnection.type === 'internal' ? 'internal' : 'external'; + console.log("🔍 컬럼 조회 시작:", { connectionType, connectionId: toConnection.id, tableName }); + + const result = await BatchManagementAPI.getTableColumns(connectionType, tableName, toConnection.id); + console.log("🔍 컬럼 조회 결과:", result); + + if (result && result.length > 0) { + setToColumns(result); + console.log("✅ 컬럼 설정 완료:", result.length, "개"); + } else { + setToColumns([]); + console.log("⚠️ 컬럼이 없음"); + } + } catch (error) { + console.error("❌ 컬럼 목록 로드 오류:", error); + toast.error("컬럼 목록을 불러오는데 실패했습니다."); + setToColumns([]); + } + } }; - // 컬럼이 이미 매핑되었는지 확인 - const isColumnMapped = ( - connectionType: 'internal' | 'external', - connectionId: number | undefined, - tableName: string, - columnName: string - ): boolean => { - return mappings.some(mapping => - mapping.to?.connection?.type === connectionType && - mapping.to?.connection?.id === connectionId && - mapping.to?.table === tableName && - mapping.to?.column?.column_name === columnName - ); + // FROM 연결 변경 핸들러 (DB → REST API용) + const handleFromConnectionChange = async (connectionValue: string) => { + let connection: BatchConnectionInfo | null = null; + if (connectionValue === 'internal') { + connection = connections.find(conn => conn.type === 'internal') || null; + } else { + const connectionId = parseInt(connectionValue); + connection = connections.find(conn => conn.id === connectionId) || null; + } + setFromConnection(connection); + setFromTable(""); + setFromColumns([]); + + if (connection) { + try { + const connectionType = connection.type === 'internal' ? 'internal' : 'external'; + const result = await BatchManagementAPI.getTablesFromConnection(connectionType, connection.id); + const tableNames = Array.isArray(result) + ? result.map((table: any) => typeof table === 'string' ? table : table.table_name || String(table)) + : []; + setFromTables(tableNames); + } catch (error) { + console.error("테이블 목록 로드 오류:", error); + toast.error("테이블 목록을 불러오는데 실패했습니다."); + } + } + }; + + // FROM 테이블 변경 핸들러 (DB → REST API용) + const handleFromTableChange = async (tableName: string) => { + console.log("🔍 FROM 테이블 변경:", { tableName, fromConnection }); + setFromTable(tableName); + setFromColumns([]); + setSelectedColumns([]); // 선택된 컬럼도 초기화 + setDbToApiFieldMapping({}); // 매핑도 초기화 + + if (fromConnection && tableName) { + try { + const connectionType = fromConnection.type === 'internal' ? 'internal' : 'external'; + console.log("🔍 FROM 컬럼 조회 시작:", { connectionType, connectionId: fromConnection.id, tableName }); + + const result = await BatchManagementAPI.getTableColumns(connectionType, tableName, fromConnection.id); + console.log("🔍 FROM 컬럼 조회 결과:", result); + + if (result && result.length > 0) { + setFromColumns(result); + console.log("✅ FROM 컬럼 설정 완료:", result.length, "개"); + } else { + setFromColumns([]); + console.log("⚠️ FROM 컬럼이 없음"); + } + } catch (error) { + console.error("❌ FROM 컬럼 목록 로드 오류:", error); + toast.error("컬럼 목록을 불러오는데 실패했습니다."); + setFromColumns([]); + } + } + }; + + // TO API 미리보기 (DB → REST API용) + const previewToApiData = async () => { + if (!toApiUrl || !toApiKey || !toEndpoint) { + toast.error("API URL, API Key, 엔드포인트를 모두 입력해주세요."); + return; + } + + try { + console.log("🔍 TO API 미리보기 시작:", { toApiUrl, toApiKey, toEndpoint, toApiMethod }); + + const result = await BatchManagementAPI.previewRestApiData( + toApiUrl, + toApiKey, + toEndpoint, + 'GET' // 미리보기는 항상 GET으로 + ); + + console.log("🔍 TO API 미리보기 결과:", result); + + if (result.fields && result.fields.length > 0) { + setToApiFields(result.fields); + toast.success(`TO API 필드 ${result.fields.length}개를 조회했습니다.`); + } else { + setToApiFields([]); + toast.warning("TO API에서 필드를 찾을 수 없습니다."); + } + } catch (error) { + console.error("❌ TO API 미리보기 오류:", error); + toast.error("TO API 미리보기에 실패했습니다."); + setToApiFields([]); + } + }; + + // REST API 데이터 미리보기 + const previewRestApiData = async () => { + if (!fromApiUrl || !fromApiKey || !fromEndpoint) { + toast.error("API URL, API Key, 엔드포인트를 모두 입력해주세요."); + return; + } + + try { + console.log("REST API 데이터 미리보기 시작..."); + + const result = await BatchManagementAPI.previewRestApiData( + fromApiUrl, + fromApiKey, + fromEndpoint, + fromApiMethod + ); + + console.log("API 미리보기 결과:", result); + console.log("result.fields:", result.fields); + console.log("result.samples:", result.samples); + console.log("result.totalCount:", result.totalCount); + + if (result.fields && result.fields.length > 0) { + console.log("✅ 백엔드에서 fields 제공됨:", result.fields); + setFromApiFields(result.fields); + setFromApiData(result.samples); + + console.log("추출된 필드:", result.fields); + toast.success(`API 데이터 미리보기 완료! ${result.fields.length}개 필드, ${result.totalCount}개 레코드`); + } else if (result.samples && result.samples.length > 0) { + // 백엔드에서 fields를 제대로 보내지 않은 경우, 프론트엔드에서 직접 추출 + console.log("⚠️ 백엔드에서 fields가 없어서 프론트엔드에서 추출"); + const extractedFields = Object.keys(result.samples[0]); + console.log("프론트엔드에서 추출한 필드:", extractedFields); + + setFromApiFields(extractedFields); + setFromApiData(result.samples); + + toast.success(`API 데이터 미리보기 완료! ${extractedFields.length}개 필드, ${result.samples.length}개 레코드`); + } else { + console.log("❌ 데이터가 없음"); + setFromApiFields([]); + setFromApiData([]); + toast.warning("API에서 데이터를 가져올 수 없습니다."); + } + } catch (error) { + console.error("REST API 미리보기 오류:", error); + toast.error("API 데이터 미리보기에 실패했습니다."); + setFromApiFields([]); + setFromApiData([]); + } }; // 배치 설정 저장 - const handleSave = () => { + const handleSave = async () => { if (!batchName.trim()) { toast.error("배치명을 입력해주세요."); return; } - if (mappings.length === 0) { - toast.error("최소 하나의 매핑을 설정해주세요."); + // 배치 타입별 검증 및 저장 + if (batchType === 'restapi-to-db') { + const mappedFields = Object.keys(apiFieldMappings).filter(field => apiFieldMappings[field]); + if (mappedFields.length === 0) { + toast.error("최소 하나의 API 필드를 DB 컬럼에 매핑해주세요."); + return; + } + + // API 필드 매핑을 배치 매핑 형태로 변환 + const apiMappings = mappedFields.map(apiField => ({ + from_connection_type: 'restapi' as const, + from_table_name: fromEndpoint, // API 엔드포인트 + from_column_name: apiField, // API 필드명 + from_api_url: fromApiUrl, + from_api_key: fromApiKey, + from_api_method: fromApiMethod, + to_connection_type: toConnection?.type === 'internal' ? 'internal' : 'external', + to_connection_id: toConnection?.type === 'internal' ? undefined : toConnection?.id, + to_table_name: toTable, + to_column_name: apiFieldMappings[apiField], // 매핑된 DB 컬럼 + mapping_type: 'direct' as const + })); + + console.log("REST API 배치 설정 저장:", { + batchName, + batchType, + cronSchedule, + description, + apiMappings + }); + + // 실제 API 호출 + try { + const result = await BatchManagementAPI.saveRestApiBatch({ + batchName, + batchType, + cronSchedule, + description, + apiMappings + }); + + if (result.success) { + toast.success(result.message || "REST API 배치 설정이 저장되었습니다."); + setTimeout(() => { + router.push('/admin/batchmng'); + }, 1000); + } else { + toast.error(result.message || "배치 저장에 실패했습니다."); + } + } catch (error) { + console.error("배치 저장 오류:", error); + toast.error("배치 저장 중 오류가 발생했습니다."); + } + return; + } else if (batchType === 'db-to-restapi') { + // DB → REST API 배치 검증 + if (!fromConnection || !fromTable || selectedColumns.length === 0) { + toast.error("소스 데이터베이스, 테이블, 컬럼을 선택해주세요."); + return; + } + + if (!toApiUrl || !toApiKey || !toEndpoint) { + toast.error("대상 API URL, API Key, 엔드포인트를 입력해주세요."); + return; + } + + if ((toApiMethod === 'POST' || toApiMethod === 'PUT') && !toApiBody) { + toast.error("POST/PUT 메서드의 경우 Request Body 템플릿을 입력해주세요."); + return; + } + + // DELETE의 경우 빈 Request Body라도 템플릿 로직을 위해 "{}" 설정 + let finalToApiBody = toApiBody; + if (toApiMethod === 'DELETE' && !finalToApiBody.trim()) { + finalToApiBody = '{}'; + } + + // DB → REST API 매핑 생성 (선택된 컬럼만) + const selectedColumnObjects = fromColumns.filter(column => selectedColumns.includes(column.column_name)); + const dbMappings = selectedColumnObjects.map((column, index) => ({ + from_connection_type: fromConnection.type === 'internal' ? 'internal' : 'external', + from_connection_id: fromConnection.type === 'internal' ? undefined : fromConnection.id, + from_table_name: fromTable, + from_column_name: column.column_name, + from_column_type: column.data_type, + to_connection_type: 'restapi' as const, + to_table_name: toEndpoint, // API 엔드포인트 + to_column_name: dbToApiFieldMapping[column.column_name] || column.column_name, // 매핑된 API 필드명 + to_api_url: toApiUrl, + to_api_key: toApiKey, + to_api_method: toApiMethod, + to_api_body: finalToApiBody, // Request Body 템플릿 + mapping_type: 'template' as const, + mapping_order: index + 1 + })); + + // URL 경로 파라미터 매핑 추가 (PUT/DELETE용) + if ((toApiMethod === 'PUT' || toApiMethod === 'DELETE') && urlPathColumn) { + const urlPathColumnObject = fromColumns.find(col => col.column_name === urlPathColumn); + if (urlPathColumnObject) { + dbMappings.push({ + from_connection_type: fromConnection.type === 'internal' ? 'internal' : 'external', + from_connection_id: fromConnection.type === 'internal' ? undefined : fromConnection.id, + from_table_name: fromTable, + from_column_name: urlPathColumn, + from_column_type: urlPathColumnObject.data_type, + to_connection_type: 'restapi' as const, + to_table_name: toEndpoint, + to_column_name: 'URL_PATH_PARAM', // 특별한 식별자 + to_api_url: toApiUrl, + to_api_key: toApiKey, + to_api_method: toApiMethod, + to_api_body: finalToApiBody, + mapping_type: 'url_path' as const, + mapping_order: 999 // 마지막 순서 + }); + } + } + + console.log("DB → REST API 배치 설정 저장:", { + batchName, + batchType, + cronSchedule, + description, + dbMappings + }); + + // 실제 API 호출 (기존 saveRestApiBatch 재사용) + try { + const result = await BatchManagementAPI.saveRestApiBatch({ + batchName, + batchType, + cronSchedule, + description, + apiMappings: dbMappings + }); + + if (result.success) { + toast.success(result.message || "DB → REST API 배치 설정이 저장되었습니다."); + setTimeout(() => { + router.push('/admin/batchmng'); + }, 1000); + } else { + toast.error(result.message || "배치 저장에 실패했습니다."); + } + } catch (error) { + console.error("배치 저장 오류:", error); + toast.error("배치 저장 중 오류가 발생했습니다."); + } return; } - // TODO: 실제 저장 로직 구현 - console.log("배치 설정 저장:", { - batchName, - cronSchedule, - description, - mappings - }); - - toast.success("배치 설정이 저장되었습니다."); + toast.error("지원하지 않는 배치 타입입니다."); }; return (
-

배치관리 시스템 (새 버전)

-
+

고급 배치 생성

+