From 258bd8020145a2cfc0a3d1b34fd57827846d0c9d Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 2 Oct 2025 17:51:15 +0900 Subject: [PATCH] =?UTF-8?q?=EC=95=A1=EC=85=98=20=EB=85=B8=EB=93=9C?= =?UTF-8?q?=EB=93=A4=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/database/OracleConnector.ts | 23 +- backend-node/src/database/RestApiConnector.ts | 188 ++- .../src/interfaces/DatabaseConnector.ts | 8 +- .../src/services/batchExternalDbService.ts | 29 +- .../src/services/nodeFlowExecutionService.ts | 1124 ++++++++++++++++- docs/node-action-target-selection-plan.md | 332 +++++ .../dataflow/node-editor/FlowEditor.tsx | 24 +- .../properties/DeleteActionProperties.tsx | 535 +++++++- .../properties/InsertActionProperties.tsx | 937 +++++++++++--- .../properties/UpdateActionProperties.tsx | 841 +++++++++--- .../properties/UpsertActionProperties.tsx | 587 ++++++++- frontend/docs/REST_API_UI_PATTERN.md | 251 ++++ frontend/types/node-editor.ts | 150 ++- 13 files changed, 4504 insertions(+), 525 deletions(-) create mode 100644 docs/node-action-target-selection-plan.md create mode 100644 frontend/docs/REST_API_UI_PATTERN.md diff --git a/backend-node/src/database/OracleConnector.ts b/backend-node/src/database/OracleConnector.ts index 6ce169ac..a4e97333 100644 --- a/backend-node/src/database/OracleConnector.ts +++ b/backend-node/src/database/OracleConnector.ts @@ -103,15 +103,34 @@ export class OracleConnector implements DatabaseConnector { try { const startTime = Date.now(); - // 쿼리 타입 확인 (DML인지 SELECT인지) + // 쿼리 타입 확인 const isDML = /^\s*(INSERT|UPDATE|DELETE|MERGE)/i.test(query); + const isCOMMIT = /^\s*COMMIT/i.test(query); + const isROLLBACK = /^\s*ROLLBACK/i.test(query); + + // 🔥 COMMIT/ROLLBACK 명령은 직접 실행 + if (isCOMMIT || isROLLBACK) { + if (isCOMMIT) { + await this.connection!.commit(); + console.log("✅ Oracle COMMIT 실행됨"); + } else { + await this.connection!.rollback(); + console.log("⚠️ Oracle ROLLBACK 실행됨"); + } + return { + rows: [], + rowCount: 0, + fields: [], + affectedRows: 0, + }; + } // Oracle XE 21c 쿼리 실행 옵션 const options: any = { outFormat: (oracledb as any).OUT_FORMAT_OBJECT, // OBJECT format maxRows: 10000, // XE 제한 고려 fetchArraySize: 100, - autoCommit: isDML, // ✅ DML 쿼리는 자동 커밋 + autoCommit: false, // 🔥 수동으로 COMMIT 제어하도록 변경 }; console.log("Oracle 쿼리 실행:", { diff --git a/backend-node/src/database/RestApiConnector.ts b/backend-node/src/database/RestApiConnector.ts index 4ce0039e..2c2965aa 100644 --- a/backend-node/src/database/RestApiConnector.ts +++ b/backend-node/src/database/RestApiConnector.ts @@ -1,6 +1,10 @@ -import axios, { AxiosInstance, AxiosResponse } from 'axios'; -import { DatabaseConnector, ConnectionConfig, QueryResult } from '../interfaces/DatabaseConnector'; -import { ConnectionTestResult, TableInfo } from '../types/externalDbTypes'; +import axios, { AxiosInstance, AxiosResponse } from "axios"; +import { + DatabaseConnector, + ConnectionConfig, + QueryResult, +} from "../interfaces/DatabaseConnector"; +import { ConnectionTestResult, TableInfo } from "../types/externalDbTypes"; export interface RestApiConfig { baseUrl: string; @@ -20,16 +24,16 @@ export class RestApiConnector implements DatabaseConnector { constructor(config: RestApiConfig) { this.config = config; - + // Axios 인스턴스 생성 this.httpClient = axios.create({ baseURL: config.baseUrl, timeout: config.timeout || 30000, headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${config.apiKey}`, - 'Accept': 'application/json' - } + "Content-Type": "application/json", + Authorization: `Bearer ${config.apiKey}`, + Accept: "application/json", + }, }); // 요청/응답 인터셉터 설정 @@ -40,11 +44,13 @@ export class RestApiConnector implements DatabaseConnector { // 요청 인터셉터 this.httpClient.interceptors.request.use( (config) => { - console.log(`[RestApiConnector] 요청: ${config.method?.toUpperCase()} ${config.url}`); + console.log( + `[RestApiConnector] 요청: ${config.method?.toUpperCase()} ${config.url}` + ); return config; }, (error) => { - console.error('[RestApiConnector] 요청 오류:', error); + console.error("[RestApiConnector] 요청 오류:", error); return Promise.reject(error); } ); @@ -52,11 +58,17 @@ export class RestApiConnector implements DatabaseConnector { // 응답 인터셉터 this.httpClient.interceptors.response.use( (response) => { - console.log(`[RestApiConnector] 응답: ${response.status} ${response.statusText}`); + console.log( + `[RestApiConnector] 응답: ${response.status} ${response.statusText}` + ); return response; }, (error) => { - console.error('[RestApiConnector] 응답 오류:', error.response?.status, error.response?.statusText); + console.error( + "[RestApiConnector] 응답 오류:", + error.response?.status, + error.response?.statusText + ); return Promise.reject(error); } ); @@ -65,16 +77,23 @@ export class RestApiConnector implements DatabaseConnector { async connect(): Promise { try { // 연결 테스트 - 기본 엔드포인트 호출 - await this.httpClient.get('/health', { timeout: 5000 }); + 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}`); + 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 : '알 수 없는 오류'}`); + console.error( + `[RestApiConnector] 연결 실패: ${this.config.baseUrl}`, + error + ); + throw new Error( + `REST API 연결 실패: ${error instanceof Error ? error.message : "알 수 없는 오류"}` + ); } } @@ -88,39 +107,55 @@ export class RestApiConnector implements DatabaseConnector { await this.connect(); return { success: true, - message: 'REST API 연결이 성공했습니다.', + message: "REST API 연결이 성공했습니다.", details: { - response_time: Date.now() - } + response_time: Date.now(), + }, }; } catch (error) { return { success: false, - message: error instanceof Error ? error.message : 'REST API 연결에 실패했습니다.', + message: + error instanceof Error + ? error.message + : "REST API 연결에 실패했습니다.", details: { - response_time: Date.now() - } + response_time: Date.now(), + }, }; } } - async executeQuery(endpoint: string, method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET', data?: any): Promise { + // 🔥 DatabaseConnector 인터페이스 호환용 executeQuery (사용하지 않음) + async executeQuery(query: string, params?: any[]): Promise { + // REST API는 executeRequest를 사용해야 함 + throw new Error( + "REST API Connector는 executeQuery를 지원하지 않습니다. executeRequest를 사용하세요." + ); + } + + // 🔥 실제 REST API 요청을 위한 메서드 + async executeRequest( + 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': + case "GET": response = await this.httpClient.get(endpoint); break; - case 'POST': + case "POST": response = await this.httpClient.post(endpoint, data); break; - case 'PUT': + case "PUT": response = await this.httpClient.put(endpoint, data); break; - case 'DELETE': + case "DELETE": response = await this.httpClient.delete(endpoint); break; default: @@ -133,21 +168,36 @@ export class RestApiConnector implements DatabaseConnector { console.log(`[RestApiConnector] 원본 응답 데이터:`, { type: typeof responseData, isArray: Array.isArray(responseData), - keys: typeof responseData === 'object' ? Object.keys(responseData) : 'not object', - responseData: 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)) { + } 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') { + } 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)) { + } else if ( + responseData && + typeof responseData === "object" && + !Array.isArray(responseData) + ) { // 단일 객체 응답인 경우 rows = [responseData]; } else { @@ -156,8 +206,8 @@ export class RestApiConnector implements DatabaseConnector { console.log(`[RestApiConnector] 처리된 rows:`, { rowsLength: rows.length, - firstRow: rows.length > 0 ? rows[0] : 'no data', - allRows: rows + firstRow: rows.length > 0 ? rows[0] : "no data", + allRows: rows, }); console.log(`[RestApiConnector] API 호출 결과:`, { @@ -165,22 +215,32 @@ export class RestApiConnector implements DatabaseConnector { method, status: response.status, rowCount: rows.length, - executionTime: `${executionTime}ms` + executionTime: `${executionTime}ms`, }); return { rows: rows, rowCount: rows.length, - fields: rows.length > 0 ? Object.keys(rows[0]).map(key => ({ name: key, type: 'string' })) : [] + fields: + rows.length > 0 + ? Object.keys(rows[0]).map((key) => ({ name: key, type: "string" })) + : [], }; } catch (error) { - console.error(`[RestApiConnector] API 호출 오류 (${method} ${endpoint}):`, 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.response?.status} ${error.response?.statusText}` + ); } - - throw new Error(`REST API 호출 실패: ${error instanceof Error ? error.message : '알 수 없는 오류'}`); + + throw new Error( + `REST API 호출 실패: ${error instanceof Error ? error.message : "알 수 없는 오류"}` + ); } } @@ -189,20 +249,20 @@ export class RestApiConnector implements DatabaseConnector { // 일반적인 REST API 엔드포인트들을 반환 return [ { - table_name: '/api/users', + table_name: "/api/users", columns: [], - description: '사용자 정보 API' + description: "사용자 정보 API", }, { - table_name: '/api/data', + table_name: "/api/data", columns: [], - description: '기본 데이터 API' + description: "기본 데이터 API", }, { - table_name: '/api/custom', + table_name: "/api/custom", columns: [], - description: '사용자 정의 엔드포인트' - } + description: "사용자 정의 엔드포인트", + }, ]; } @@ -213,22 +273,25 @@ export class RestApiConnector implements DatabaseConnector { async getColumns(endpoint: string): Promise { try { // GET 요청으로 샘플 데이터를 가져와서 필드 구조 파악 - const result = await this.executeQuery(endpoint, 'GET'); - + const result = await this.executeRequest(endpoint, "GET"); + if (result.rows.length > 0) { const sampleRow = result.rows[0]; - return Object.keys(sampleRow).map(key => ({ + return Object.keys(sampleRow).map((key) => ({ column_name: key, data_type: typeof sampleRow[key], - is_nullable: 'YES', + is_nullable: "YES", column_default: null, - description: `${key} 필드` + description: `${key} 필드`, })); } return []; } catch (error) { - console.error(`[RestApiConnector] 컬럼 정보 조회 오류 (${endpoint}):`, error); + console.error( + `[RestApiConnector] 컬럼 정보 조회 오류 (${endpoint}):`, + error + ); return []; } } @@ -238,24 +301,29 @@ export class RestApiConnector implements DatabaseConnector { } // REST API 전용 메서드들 - async getData(endpoint: string, params?: Record): Promise { - const queryString = params ? '?' + new URLSearchParams(params).toString() : ''; - const result = await this.executeQuery(endpoint + queryString, 'GET'); + async getData( + endpoint: string, + params?: Record + ): Promise { + const queryString = params + ? "?" + new URLSearchParams(params).toString() + : ""; + const result = await this.executeRequest(endpoint + queryString, "GET"); return result.rows; } async postData(endpoint: string, data: any): Promise { - const result = await this.executeQuery(endpoint, 'POST', data); + const result = await this.executeRequest(endpoint, "POST", data); return result.rows[0]; } async putData(endpoint: string, data: any): Promise { - const result = await this.executeQuery(endpoint, 'PUT', data); + const result = await this.executeRequest(endpoint, "PUT", data); return result.rows[0]; } async deleteData(endpoint: string): Promise { - const result = await this.executeQuery(endpoint, 'DELETE'); + const result = await this.executeRequest(endpoint, "DELETE"); return result.rows[0]; } } diff --git a/backend-node/src/interfaces/DatabaseConnector.ts b/backend-node/src/interfaces/DatabaseConnector.ts index c8980eef..e617090c 100644 --- a/backend-node/src/interfaces/DatabaseConnector.ts +++ b/backend-node/src/interfaces/DatabaseConnector.ts @@ -1,4 +1,4 @@ -import { ConnectionTestResult, TableInfo } from '../types/externalDbTypes'; +import { ConnectionTestResult, TableInfo } from "../types/externalDbTypes"; export interface ConnectionConfig { host: string; @@ -15,13 +15,15 @@ export interface QueryResult { rows: any[]; rowCount?: number; fields?: any[]; + affectedRows?: number; // MySQL/MariaDB용 + length?: number; // 배열 형태로 반환되는 경우 } export interface DatabaseConnector { connect(): Promise; disconnect(): Promise; testConnection(): Promise; - executeQuery(query: string): Promise; + executeQuery(query: string, params?: any[]): Promise; // params 추가 getTables(): Promise; getColumns(tableName: string): Promise; // 특정 테이블의 컬럼 정보 조회 -} \ No newline at end of file +} diff --git a/backend-node/src/services/batchExternalDbService.ts b/backend-node/src/services/batchExternalDbService.ts index eab6920c..75d7ea67 100644 --- a/backend-node/src/services/batchExternalDbService.ts +++ b/backend-node/src/services/batchExternalDbService.ts @@ -895,13 +895,18 @@ export class BatchExternalDbService { ); } - // 데이터 조회 - const result = await connector.executeQuery(finalEndpoint, method); + // 데이터 조회 (REST API는 executeRequest 사용) + let result; + if ((connector as any).executeRequest) { + result = await (connector as any).executeRequest(finalEndpoint, method); + } else { + result = await connector.executeQuery(finalEndpoint); + } let data = result.rows; // 컬럼 필터링 (지정된 컬럼만 추출) if (columns && columns.length > 0) { - data = data.map((row) => { + data = data.map((row: any) => { const filteredRow: any = {}; columns.forEach((col) => { if (row.hasOwnProperty(col)) { @@ -1039,7 +1044,16 @@ export class BatchExternalDbService { ); console.log(`[BatchExternalDbService] 전송할 데이터:`, requestData); - await connector.executeQuery(finalEndpoint, method, requestData); + // REST API는 executeRequest 사용 + if ((connector as any).executeRequest) { + await (connector as any).executeRequest( + finalEndpoint, + method, + requestData + ); + } else { + await connector.executeQuery(finalEndpoint); + } successCount++; } catch (error) { console.error(`REST API 레코드 전송 실패:`, error); @@ -1104,7 +1118,12 @@ export class BatchExternalDbService { ); console.log(`[BatchExternalDbService] 전송할 데이터:`, record); - await connector.executeQuery(endpoint, method, record); + // REST API는 executeRequest 사용 + if ((connector as any).executeRequest) { + await (connector as any).executeRequest(endpoint, method, record); + } else { + await connector.executeQuery(endpoint); + } successCount++; } catch (error) { console.error(`REST API 레코드 전송 실패:`, error); diff --git a/backend-node/src/services/nodeFlowExecutionService.ts b/backend-node/src/services/nodeFlowExecutionService.ts index f0510623..43cee38a 100644 --- a/backend-node/src/services/nodeFlowExecutionService.ts +++ b/backend-node/src/services/nodeFlowExecutionService.ts @@ -490,6 +490,34 @@ export class NodeFlowExecutionService { node: FlowNode, inputData: any, context: ExecutionContext + ): Promise { + const { targetType } = node.data; + + // 🔥 타겟 타입별 분기 + switch (targetType) { + case "internal": + return this.executeInternalInsert(node, inputData, context); + + case "external": + return this.executeExternalInsert(node, inputData, context); + + case "api": + return this.executeApiInsert(node, inputData, context); + + default: + // 하위 호환성: targetType이 없으면 internal로 간주 + logger.warn(`⚠️ targetType이 설정되지 않음, internal로 간주`); + return this.executeInternalInsert(node, inputData, context); + } + } + + /** + * 내부 DB INSERT 실행 + */ + private static async executeInternalInsert( + node: FlowNode, + inputData: any, + context: ExecutionContext ): Promise { const { targetTable, fieldMappings } = node.data; @@ -500,7 +528,6 @@ export class NodeFlowExecutionService { for (const data of dataArray) { const fields: string[] = []; const values: any[] = []; - let paramIndex = 1; fieldMappings.forEach((mapping: any) => { fields.push(mapping.targetField); @@ -520,12 +547,316 @@ export class NodeFlowExecutionService { insertedCount++; } - logger.info(`✅ INSERT 완료: ${targetTable}, ${insertedCount}건`); + logger.info( + `✅ INSERT 완료 (내부 DB): ${targetTable}, ${insertedCount}건` + ); return { insertedCount }; }); } + /** + * 외부 DB INSERT 실행 + */ + private static async executeExternalInsert( + node: FlowNode, + inputData: any, + context: ExecutionContext + ): Promise { + const { + externalConnectionId, + externalDbType, + externalTargetTable, + fieldMappings, + } = node.data; + + if (!externalConnectionId || !externalTargetTable) { + throw new Error("외부 DB 커넥션 또는 테이블이 설정되지 않았습니다."); + } + + logger.info( + `🔌 외부 DB INSERT 시작: ${externalDbType} - ${externalTargetTable}` + ); + + // 외부 DB 커넥터 생성 + const connector = await this.createExternalConnector( + externalConnectionId, + externalDbType + ); + + try { + const dataArray = Array.isArray(inputData) ? inputData : [inputData]; + let insertedCount = 0; + + // 🔥 Oracle의 경우 autoCommit을 false로 설정하여 트랜잭션 제어 + const isOracle = externalDbType.toLowerCase() === "oracle"; + + for (const data of dataArray) { + const fields: string[] = []; + const values: any[] = []; + + fieldMappings.forEach((mapping: any) => { + fields.push(mapping.targetField); + const value = + mapping.staticValue !== undefined + ? mapping.staticValue + : data[mapping.sourceField]; + values.push(value); + }); + + // 외부 DB별 SQL 문법 차이 처리 + let sql: string; + let params: any[]; + + if (isOracle) { + // Oracle: :1, :2, ... 형식 + const placeholders = fields.map((_, i) => `:${i + 1}`).join(", "); + sql = `INSERT INTO ${externalTargetTable} (${fields.join(", ")}) VALUES (${placeholders})`; + params = values; + } else if ( + ["mysql", "mariadb"].includes(externalDbType.toLowerCase()) + ) { + // MySQL/MariaDB: ? 형식 + const placeholders = fields.map(() => "?").join(", "); + sql = `INSERT INTO ${externalTargetTable} (${fields.join(", ")}) VALUES (${placeholders})`; + params = values; + } else if (externalDbType.toLowerCase() === "mssql") { + // MSSQL: @p1, @p2, ... 형식 + const placeholders = fields.map((_, i) => `@p${i + 1}`).join(", "); + sql = `INSERT INTO ${externalTargetTable} (${fields.join(", ")}) VALUES (${placeholders})`; + params = values; + } else { + // PostgreSQL: $1, $2, ... 형식 (기본) + const placeholders = fields.map((_, i) => `$${i + 1}`).join(", "); + sql = `INSERT INTO ${externalTargetTable} (${fields.join(", ")}) VALUES (${placeholders})`; + params = values; + } + + await connector.executeQuery(sql, params); + insertedCount++; + } + + // 🔥 Oracle의 경우 명시적 COMMIT + await this.commitExternalTransaction( + connector, + externalDbType, + insertedCount + ); + + logger.info( + `✅ INSERT 완료 (외부 DB): ${externalTargetTable}, ${insertedCount}건` + ); + + return { insertedCount }; + } catch (error) { + // 🔥 Oracle의 경우 오류 시 ROLLBACK + await this.rollbackExternalTransaction(connector, externalDbType); + throw error; + } finally { + // 연결 해제 + await connector.disconnect(); + } + } + + /** + * REST API INSERT 실행 (POST 요청) + */ + private static async executeApiInsert( + node: FlowNode, + inputData: any, + context: ExecutionContext + ): Promise { + const { + apiEndpoint, + apiMethod, + apiAuthType, + apiAuthConfig, + apiHeaders, + apiBodyTemplate, + fieldMappings, + } = node.data; + + if (!apiEndpoint) { + throw new Error("API 엔드포인트가 설정되지 않았습니다."); + } + + logger.info(`🌐 REST API INSERT 시작: ${apiMethod} ${apiEndpoint}`); + + const axios = require("axios"); + const dataArray = Array.isArray(inputData) ? inputData : [inputData]; + const results: any[] = []; + + for (const data of dataArray) { + // 헤더 설정 + const headers: any = { ...apiHeaders }; + + // 인증 헤더 추가 + if (apiAuthType === "bearer" && apiAuthConfig?.token) { + headers["Authorization"] = `Bearer ${apiAuthConfig.token}`; + } else if ( + apiAuthType === "basic" && + apiAuthConfig?.username && + apiAuthConfig?.password + ) { + const credentials = Buffer.from( + `${apiAuthConfig.username}:${apiAuthConfig.password}` + ).toString("base64"); + headers["Authorization"] = `Basic ${credentials}`; + } else if (apiAuthType === "apikey" && apiAuthConfig?.apiKey) { + const headerName = apiAuthConfig.apiKeyHeader || "X-API-Key"; + headers[headerName] = apiAuthConfig.apiKey; + } + + // Content-Type 기본값 설정 + if (!headers["Content-Type"]) { + headers["Content-Type"] = "application/json"; + } + + // 바디 생성 (템플릿 또는 필드 매핑) + let body: any; + + if (apiBodyTemplate) { + // 템플릿 변수 치환 + body = this.replaceTemplateVariables(apiBodyTemplate, data); + } else if (fieldMappings && fieldMappings.length > 0) { + // 필드 매핑 사용 + body = {}; + fieldMappings.forEach((mapping: any) => { + const value = + mapping.staticValue !== undefined + ? mapping.staticValue + : data[mapping.sourceField]; + body[mapping.targetField] = value; + }); + } else { + // 전체 데이터 전송 + body = data; + } + + try { + const response = await axios({ + method: apiMethod || "POST", + url: apiEndpoint, + headers, + data: body, + timeout: 30000, // 30초 타임아웃 + }); + + results.push({ + status: response.status, + data: response.data, + }); + } catch (error: any) { + logger.error( + `❌ API 요청 실패: ${error.response?.status || error.message}` + ); + throw error; + } + } + + logger.info(`✅ REST API INSERT 완료: ${results.length}건`); + + return { results }; + } + + /** + * 템플릿 변수 치환 ({{variable}} 형식) + */ + private static replaceTemplateVariables(template: string, data: any): string { + let result = template; + + // {{variable}} 형식의 변수를 찾아서 치환 + const matches = template.match(/\{\{([^}]+)\}\}/g); + if (matches) { + matches.forEach((match) => { + const key = match.replace(/\{\{|\}\}/g, "").trim(); + const value = this.getNestedValue(data, key); + result = result.replace(match, value !== undefined ? value : ""); + }); + } + + return result; + } + + /** + * 중첩된 객체 값 가져오기 (예: "user.name") + */ + private static getNestedValue(obj: any, path: string): any { + return path.split(".").reduce((current, key) => current?.[key], obj); + } + + /** + * 외부 DB 커넥터 생성 (공통 로직) + */ + private static async createExternalConnector( + connectionId: number, + dbType: string + ): Promise { + // 외부 DB 커넥션 정보 조회 + const connectionData: any = await queryOne( + "SELECT * FROM external_db_connections WHERE id = $1", + [connectionId] + ); + + if (!connectionData) { + throw new Error(`외부 DB 커넥션을 찾을 수 없습니다: ${connectionId}`); + } + + // 패스워드 복호화 + const { EncryptUtil } = await import("../utils/encryptUtil"); + const decryptedPassword = EncryptUtil.decrypt(connectionData.password); + + const config = { + host: connectionData.host, + port: connectionData.port, + database: connectionData.database_name, + user: connectionData.username, + password: decryptedPassword, + }; + + // DatabaseConnectorFactory를 사용하여 외부 DB 연결 + const { DatabaseConnectorFactory } = await import( + "../database/DatabaseConnectorFactory" + ); + + return await DatabaseConnectorFactory.createConnector( + dbType, + config, + connectionId + ); + } + + /** + * 외부 DB 트랜잭션 커밋 (Oracle 전용) + */ + private static async commitExternalTransaction( + connector: any, + dbType: string, + count: number + ): Promise { + if (dbType.toLowerCase() === "oracle" && count > 0) { + await connector.executeQuery("COMMIT"); + logger.info(`✅ Oracle COMMIT 실행: ${count}건`); + } + } + + /** + * 외부 DB 트랜잭션 롤백 (Oracle 전용) + */ + private static async rollbackExternalTransaction( + connector: any, + dbType: string + ): Promise { + if (dbType.toLowerCase() === "oracle") { + try { + await connector.executeQuery("ROLLBACK"); + logger.info(`⚠️ Oracle ROLLBACK 실행 (오류 발생)`); + } catch (rollbackError) { + logger.error(`❌ Oracle ROLLBACK 실패:`, rollbackError); + } + } + } + /** * UPDATE 액션 노드 실행 */ @@ -533,6 +864,34 @@ export class NodeFlowExecutionService { node: FlowNode, inputData: any, context: ExecutionContext + ): Promise { + const { targetType } = node.data; + + // 🔥 타겟 타입별 분기 + switch (targetType) { + case "internal": + return this.executeInternalUpdate(node, inputData, context); + + case "external": + return this.executeExternalUpdate(node, inputData, context); + + case "api": + return this.executeApiUpdate(node, inputData, context); + + default: + // 하위 호환성: targetType이 없으면 internal로 간주 + logger.warn(`⚠️ targetType이 설정되지 않음, internal로 간주`); + return this.executeInternalUpdate(node, inputData, context); + } + } + + /** + * 내부 DB UPDATE 실행 + */ + private static async executeInternalUpdate( + node: FlowNode, + inputData: any, + context: ExecutionContext ): Promise { const { targetTable, fieldMappings, whereConditions } = node.data; @@ -571,12 +930,235 @@ export class NodeFlowExecutionService { updatedCount += result.rowCount || 0; } - logger.info(`✅ UPDATE 완료: ${targetTable}, ${updatedCount}건`); + logger.info( + `✅ UPDATE 완료 (내부 DB): ${targetTable}, ${updatedCount}건` + ); return { updatedCount }; }); } + /** + * 외부 DB UPDATE 실행 + */ + private static async executeExternalUpdate( + node: FlowNode, + inputData: any, + context: ExecutionContext + ): Promise { + const { + externalConnectionId, + externalDbType, + externalTargetTable, + fieldMappings, + whereConditions, + } = node.data; + + if (!externalConnectionId || !externalTargetTable) { + throw new Error("외부 DB 커넥션 또는 테이블이 설정되지 않았습니다."); + } + + logger.info( + `🔌 외부 DB UPDATE 시작: ${externalDbType} - ${externalTargetTable}` + ); + + // 외부 DB 커넥터 생성 + const connector = await this.createExternalConnector( + externalConnectionId, + externalDbType + ); + + try { + const dataArray = Array.isArray(inputData) ? inputData : [inputData]; + let updatedCount = 0; + + for (const data of dataArray) { + const setClauses: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + fieldMappings.forEach((mapping: any) => { + const value = + mapping.staticValue !== undefined + ? mapping.staticValue + : data[mapping.sourceField]; + + // DB별 플레이스홀더 + if (externalDbType.toLowerCase() === "oracle") { + setClauses.push(`${mapping.targetField} = :${paramIndex}`); + } else if ( + ["mysql", "mariadb"].includes(externalDbType.toLowerCase()) + ) { + setClauses.push(`${mapping.targetField} = ?`); + } else if (externalDbType.toLowerCase() === "mssql") { + setClauses.push(`${mapping.targetField} = @p${paramIndex}`); + } else { + setClauses.push(`${mapping.targetField} = $${paramIndex}`); + } + + values.push(value); + paramIndex++; + }); + + // WHERE 조건 생성 + const whereClauses: string[] = []; + whereConditions?.forEach((condition: any) => { + const condValue = data[condition.field]; + + if (condition.operator === "IS NULL") { + whereClauses.push(`${condition.field} IS NULL`); + } else if (condition.operator === "IS NOT NULL") { + whereClauses.push(`${condition.field} IS NOT NULL`); + } else { + if (externalDbType.toLowerCase() === "oracle") { + whereClauses.push( + `${condition.field} ${condition.operator} :${paramIndex}` + ); + } else if ( + ["mysql", "mariadb"].includes(externalDbType.toLowerCase()) + ) { + whereClauses.push(`${condition.field} ${condition.operator} ?`); + } else if (externalDbType.toLowerCase() === "mssql") { + whereClauses.push( + `${condition.field} ${condition.operator} @p${paramIndex}` + ); + } else { + whereClauses.push( + `${condition.field} ${condition.operator} $${paramIndex}` + ); + } + values.push(condValue); + paramIndex++; + } + }); + + const whereClause = + whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : ""; + + const sql = `UPDATE ${externalTargetTable} SET ${setClauses.join(", ")} ${whereClause}`; + + const result = await connector.executeQuery(sql, values); + updatedCount += result.rowCount || result.affectedRows || 0; + } + + // 🔥 Oracle의 경우 명시적 COMMIT + await this.commitExternalTransaction( + connector, + externalDbType, + updatedCount + ); + + logger.info( + `✅ UPDATE 완료 (외부 DB): ${externalTargetTable}, ${updatedCount}건` + ); + + return { updatedCount }; + } catch (error) { + // 🔥 Oracle의 경우 오류 시 ROLLBACK + await this.rollbackExternalTransaction(connector, externalDbType); + throw error; + } finally { + await connector.disconnect(); + } + } + + /** + * REST API UPDATE 실행 (PUT/PATCH 요청) + */ + private static async executeApiUpdate( + node: FlowNode, + inputData: any, + context: ExecutionContext + ): Promise { + const { + apiEndpoint, + apiMethod, + apiAuthType, + apiAuthConfig, + apiHeaders, + apiBodyTemplate, + fieldMappings, + } = node.data; + + if (!apiEndpoint) { + throw new Error("API 엔드포인트가 설정되지 않았습니다."); + } + + logger.info(`🌐 REST API UPDATE 시작: ${apiMethod} ${apiEndpoint}`); + + const axios = require("axios"); + const dataArray = Array.isArray(inputData) ? inputData : [inputData]; + const results: any[] = []; + + for (const data of dataArray) { + // 헤더 설정 + const headers: any = { ...apiHeaders }; + + // 인증 헤더 추가 + if (apiAuthType === "bearer" && apiAuthConfig?.token) { + headers["Authorization"] = `Bearer ${apiAuthConfig.token}`; + } else if ( + apiAuthType === "basic" && + apiAuthConfig?.username && + apiAuthConfig?.password + ) { + const credentials = Buffer.from( + `${apiAuthConfig.username}:${apiAuthConfig.password}` + ).toString("base64"); + headers["Authorization"] = `Basic ${credentials}`; + } else if (apiAuthType === "apikey" && apiAuthConfig?.apiKey) { + const headerName = apiAuthConfig.apiKeyHeader || "X-API-Key"; + headers[headerName] = apiAuthConfig.apiKey; + } + + if (!headers["Content-Type"]) { + headers["Content-Type"] = "application/json"; + } + + // 바디 생성 + let body: any; + + if (apiBodyTemplate) { + body = this.replaceTemplateVariables(apiBodyTemplate, data); + } else if (fieldMappings && fieldMappings.length > 0) { + body = {}; + fieldMappings.forEach((mapping: any) => { + const value = + mapping.staticValue !== undefined + ? mapping.staticValue + : data[mapping.sourceField]; + body[mapping.targetField] = value; + }); + } else { + body = data; + } + + try { + const response = await axios({ + method: apiMethod || "PUT", + url: apiEndpoint, + headers, + data: body, + timeout: 30000, + }); + + results.push({ + status: response.status, + data: response.data, + }); + } catch (error: any) { + logger.error( + `❌ API 요청 실패: ${error.response?.status || error.message}` + ); + throw error; + } + } + + logger.info(`✅ REST API UPDATE 완료: ${results.length}건`); + + return { results }; + } + /** * DELETE 액션 노드 실행 */ @@ -584,6 +1166,34 @@ export class NodeFlowExecutionService { node: FlowNode, inputData: any, context: ExecutionContext + ): Promise { + const { targetType } = node.data; + + // 🔥 타겟 타입별 분기 + switch (targetType) { + case "internal": + return this.executeInternalDelete(node, inputData, context); + + case "external": + return this.executeExternalDelete(node, inputData, context); + + case "api": + return this.executeApiDelete(node, inputData, context); + + default: + // 하위 호환성: targetType이 없으면 internal로 간주 + logger.warn(`⚠️ targetType이 설정되지 않음, internal로 간주`); + return this.executeInternalDelete(node, inputData, context); + } + } + + /** + * 내부 DB DELETE 실행 + */ + private static async executeInternalDelete( + node: FlowNode, + inputData: any, + context: ExecutionContext ): Promise { const { targetTable, whereConditions } = node.data; @@ -600,20 +1210,225 @@ export class NodeFlowExecutionService { deletedCount += result.rowCount || 0; } - logger.info(`✅ DELETE 완료: ${targetTable}, ${deletedCount}건`); + logger.info( + `✅ DELETE 완료 (내부 DB): ${targetTable}, ${deletedCount}건` + ); return { deletedCount }; }); } /** - * UPSERT 액션 노드 실행 (로직 기반) - * DB 제약 조건 없이 SELECT → UPDATE or INSERT 방식으로 구현 + * 외부 DB DELETE 실행 + */ + private static async executeExternalDelete( + node: FlowNode, + inputData: any, + context: ExecutionContext + ): Promise { + const { + externalConnectionId, + externalDbType, + externalTargetTable, + whereConditions, + } = node.data; + + if (!externalConnectionId || !externalTargetTable) { + throw new Error("외부 DB 커넥션 또는 테이블이 설정되지 않았습니다."); + } + + logger.info( + `🔌 외부 DB DELETE 시작: ${externalDbType} - ${externalTargetTable}` + ); + + // 외부 DB 커넥터 생성 + const connector = await this.createExternalConnector( + externalConnectionId, + externalDbType + ); + + try { + const dataArray = Array.isArray(inputData) ? inputData : [inputData]; + let deletedCount = 0; + + for (const data of dataArray) { + const whereClauses: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + // WHERE 조건 생성 + whereConditions?.forEach((condition: any) => { + const condValue = data[condition.field]; + + if (condition.operator === "IS NULL") { + whereClauses.push(`${condition.field} IS NULL`); + } else if (condition.operator === "IS NOT NULL") { + whereClauses.push(`${condition.field} IS NOT NULL`); + } else { + if (externalDbType.toLowerCase() === "oracle") { + whereClauses.push( + `${condition.field} ${condition.operator} :${paramIndex}` + ); + } else if ( + ["mysql", "mariadb"].includes(externalDbType.toLowerCase()) + ) { + whereClauses.push(`${condition.field} ${condition.operator} ?`); + } else if (externalDbType.toLowerCase() === "mssql") { + whereClauses.push( + `${condition.field} ${condition.operator} @p${paramIndex}` + ); + } else { + whereClauses.push( + `${condition.field} ${condition.operator} $${paramIndex}` + ); + } + values.push(condValue); + paramIndex++; + } + }); + + const whereClause = + whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : ""; + + if (!whereClause) { + throw new Error( + "DELETE 작업에 WHERE 조건이 필요합니다. (전체 삭제 방지)" + ); + } + + const sql = `DELETE FROM ${externalTargetTable} ${whereClause}`; + + const result = await connector.executeQuery(sql, values); + deletedCount += result.rowCount || result.affectedRows || 0; + } + + // 🔥 Oracle의 경우 명시적 COMMIT + await this.commitExternalTransaction( + connector, + externalDbType, + deletedCount + ); + + logger.info( + `✅ DELETE 완료 (외부 DB): ${externalTargetTable}, ${deletedCount}건` + ); + + return { deletedCount }; + } catch (error) { + // 🔥 Oracle의 경우 오류 시 ROLLBACK + await this.rollbackExternalTransaction(connector, externalDbType); + throw error; + } finally { + await connector.disconnect(); + } + } + + /** + * REST API DELETE 실행 (DELETE 요청) + */ + private static async executeApiDelete( + node: FlowNode, + inputData: any, + context: ExecutionContext + ): Promise { + const { apiEndpoint, apiAuthType, apiAuthConfig, apiHeaders } = node.data; + + if (!apiEndpoint) { + throw new Error("API 엔드포인트가 설정되지 않았습니다."); + } + + logger.info(`🌐 REST API DELETE 시작: ${apiEndpoint}`); + + const axios = require("axios"); + const dataArray = Array.isArray(inputData) ? inputData : [inputData]; + const results: any[] = []; + + for (const data of dataArray) { + // 헤더 설정 + const headers: any = { ...apiHeaders }; + + // 인증 헤더 추가 + if (apiAuthType === "bearer" && apiAuthConfig?.token) { + headers["Authorization"] = `Bearer ${apiAuthConfig.token}`; + } else if ( + apiAuthType === "basic" && + apiAuthConfig?.username && + apiAuthConfig?.password + ) { + const credentials = Buffer.from( + `${apiAuthConfig.username}:${apiAuthConfig.password}` + ).toString("base64"); + headers["Authorization"] = `Basic ${credentials}`; + } else if (apiAuthType === "apikey" && apiAuthConfig?.apiKey) { + const headerName = apiAuthConfig.apiKeyHeader || "X-API-Key"; + headers[headerName] = apiAuthConfig.apiKey; + } + + // DELETE는 일반적으로 URL 파라미터 또는 경로에 ID 포함 + // 템플릿 변수 치환 지원 (예: /api/users/{{id}}) + const url = this.replaceTemplateVariables(apiEndpoint, data); + + try { + const response = await axios({ + method: "DELETE", + url, + headers, + timeout: 30000, + }); + + results.push({ + status: response.status, + data: response.data, + }); + } catch (error: any) { + logger.error( + `❌ API 요청 실패: ${error.response?.status || error.message}` + ); + throw error; + } + } + + logger.info(`✅ REST API DELETE 완료: ${results.length}건`); + + return { results }; + } + + /** + * UPSERT 액션 노드 실행 */ private static async executeUpsertAction( node: FlowNode, inputData: any, context: ExecutionContext + ): Promise { + const { targetType } = node.data; + + // 🔥 타겟 타입별 분기 + switch (targetType) { + case "internal": + return this.executeInternalUpsert(node, inputData, context); + + case "external": + return this.executeExternalUpsert(node, inputData, context); + + case "api": + return this.executeApiUpsert(node, inputData, context); + + default: + // 하위 호환성: targetType이 없으면 internal로 간주 + logger.warn(`⚠️ targetType이 설정되지 않음, internal로 간주`); + return this.executeInternalUpsert(node, inputData, context); + } + } + + /** + * 내부 DB UPSERT 실행 (로직 기반) + * DB 제약 조건 없이 SELECT → UPDATE or INSERT 방식으로 구현 + */ + private static async executeInternalUpsert( + node: FlowNode, + inputData: any, + context: ExecutionContext ): Promise { const { targetTable, fieldMappings, conflictKeys } = node.data; @@ -733,7 +1548,7 @@ export class NodeFlowExecutionService { } logger.info( - `✅ UPSERT 완료: ${targetTable}, INSERT ${insertedCount}건, UPDATE ${updatedCount}건` + `✅ UPSERT 완료 (내부 DB): ${targetTable}, INSERT ${insertedCount}건, UPDATE ${updatedCount}건` ); return { @@ -744,6 +1559,301 @@ export class NodeFlowExecutionService { }); } + /** + * 외부 DB UPSERT 실행 (로직 기반) + */ + private static async executeExternalUpsert( + node: FlowNode, + inputData: any, + context: ExecutionContext + ): Promise { + const { + externalConnectionId, + externalDbType, + externalTargetTable, + fieldMappings, + conflictKeys, + } = node.data; + + if (!externalConnectionId || !externalTargetTable) { + throw new Error("외부 DB 커넥션 또는 테이블이 설정되지 않았습니다."); + } + + if (!fieldMappings || fieldMappings.length === 0) { + throw new Error("UPSERT 액션에 필수 설정이 누락되었습니다."); + } + + if (!conflictKeys || conflictKeys.length === 0) { + throw new Error("UPSERT 액션에 충돌 키(Conflict Keys)가 필요합니다."); + } + + logger.info( + `🔌 외부 DB UPSERT 시작: ${externalDbType} - ${externalTargetTable}` + ); + + // 외부 DB 커넥터 생성 + const connector = await this.createExternalConnector( + externalConnectionId, + externalDbType + ); + + try { + const dataArray = Array.isArray(inputData) ? inputData : [inputData]; + let insertedCount = 0; + let updatedCount = 0; + + for (const data of dataArray) { + // 1. 충돌 키 값 추출 + const conflictKeyValues: Record = {}; + conflictKeys.forEach((key: string) => { + const mapping = fieldMappings.find((m: any) => m.targetField === key); + if (mapping) { + conflictKeyValues[key] = + mapping.staticValue !== undefined + ? mapping.staticValue + : data[mapping.sourceField]; + } + }); + + // 2. 존재 여부 확인 (SELECT) + const whereClauses: string[] = []; + const whereValues: any[] = []; + let paramIndex = 1; + + conflictKeys.forEach((key: string) => { + if (externalDbType.toLowerCase() === "oracle") { + whereClauses.push(`${key} = :${paramIndex}`); + } else if ( + ["mysql", "mariadb"].includes(externalDbType.toLowerCase()) + ) { + whereClauses.push(`${key} = ?`); + } else if (externalDbType.toLowerCase() === "mssql") { + whereClauses.push(`${key} = @p${paramIndex}`); + } else { + whereClauses.push(`${key} = $${paramIndex}`); + } + whereValues.push(conflictKeyValues[key]); + paramIndex++; + }); + + const checkSql = `SELECT * FROM ${externalTargetTable} WHERE ${whereClauses.join(" AND ")} LIMIT 1`; + const existingRow = await connector.executeQuery(checkSql, whereValues); + + const hasExistingRow = + existingRow.rows?.length > 0 || existingRow.length > 0; + + if (hasExistingRow) { + // 3-A. 존재하면 UPDATE + const setClauses: string[] = []; + const updateValues: any[] = []; + paramIndex = 1; + + fieldMappings.forEach((mapping: any) => { + if (!conflictKeys.includes(mapping.targetField)) { + const value = + mapping.staticValue !== undefined + ? mapping.staticValue + : data[mapping.sourceField]; + + if (externalDbType.toLowerCase() === "oracle") { + setClauses.push(`${mapping.targetField} = :${paramIndex}`); + } else if ( + ["mysql", "mariadb"].includes(externalDbType.toLowerCase()) + ) { + setClauses.push(`${mapping.targetField} = ?`); + } else if (externalDbType.toLowerCase() === "mssql") { + setClauses.push(`${mapping.targetField} = @p${paramIndex}`); + } else { + setClauses.push(`${mapping.targetField} = $${paramIndex}`); + } + + updateValues.push(value); + paramIndex++; + } + }); + + // WHERE 조건 생성 + const updateWhereClauses: string[] = []; + conflictKeys.forEach((key: string) => { + if (externalDbType.toLowerCase() === "oracle") { + updateWhereClauses.push(`${key} = :${paramIndex}`); + } else if ( + ["mysql", "mariadb"].includes(externalDbType.toLowerCase()) + ) { + updateWhereClauses.push(`${key} = ?`); + } else if (externalDbType.toLowerCase() === "mssql") { + updateWhereClauses.push(`${key} = @p${paramIndex}`); + } else { + updateWhereClauses.push(`${key} = $${paramIndex}`); + } + updateValues.push(conflictKeyValues[key]); + paramIndex++; + }); + + const updateSql = `UPDATE ${externalTargetTable} SET ${setClauses.join(", ")} WHERE ${updateWhereClauses.join(" AND ")}`; + + await connector.executeQuery(updateSql, updateValues); + updatedCount++; + } else { + // 3-B. 없으면 INSERT + const columns: string[] = []; + const values: any[] = []; + + fieldMappings.forEach((mapping: any) => { + const value = + mapping.staticValue !== undefined + ? mapping.staticValue + : data[mapping.sourceField]; + columns.push(mapping.targetField); + values.push(value); + }); + + let insertSql: string; + if (externalDbType.toLowerCase() === "oracle") { + const placeholders = columns.map((_, i) => `:${i + 1}`).join(", "); + insertSql = `INSERT INTO ${externalTargetTable} (${columns.join(", ")}) VALUES (${placeholders})`; + } else if ( + ["mysql", "mariadb"].includes(externalDbType.toLowerCase()) + ) { + const placeholders = columns.map(() => "?").join(", "); + insertSql = `INSERT INTO ${externalTargetTable} (${columns.join(", ")}) VALUES (${placeholders})`; + } else if (externalDbType.toLowerCase() === "mssql") { + const placeholders = columns.map((_, i) => `@p${i + 1}`).join(", "); + insertSql = `INSERT INTO ${externalTargetTable} (${columns.join(", ")}) VALUES (${placeholders})`; + } else { + const placeholders = columns.map((_, i) => `$${i + 1}`).join(", "); + insertSql = `INSERT INTO ${externalTargetTable} (${columns.join(", ")}) VALUES (${placeholders})`; + } + + await connector.executeQuery(insertSql, values); + insertedCount++; + } + } + + // 🔥 Oracle의 경우 명시적 COMMIT + await this.commitExternalTransaction( + connector, + externalDbType, + insertedCount + updatedCount + ); + + logger.info( + `✅ UPSERT 완료 (외부 DB): ${externalTargetTable}, INSERT ${insertedCount}건, UPDATE ${updatedCount}건` + ); + + return { + insertedCount, + updatedCount, + totalCount: insertedCount + updatedCount, + }; + } catch (error) { + // 🔥 Oracle의 경우 오류 시 ROLLBACK + await this.rollbackExternalTransaction(connector, externalDbType); + throw error; + } finally { + await connector.disconnect(); + } + } + + /** + * REST API UPSERT 실행 (POST/PUT 요청) + * API 응답에 따라 INSERT/UPDATE 판단 + */ + private static async executeApiUpsert( + node: FlowNode, + inputData: any, + context: ExecutionContext + ): Promise { + const { + apiEndpoint, + apiMethod, + apiAuthType, + apiAuthConfig, + apiHeaders, + apiBodyTemplate, + fieldMappings, + } = node.data; + + if (!apiEndpoint) { + throw new Error("API 엔드포인트가 설정되지 않았습니다."); + } + + logger.info(`🌐 REST API UPSERT 시작: ${apiMethod} ${apiEndpoint}`); + + const axios = require("axios"); + const dataArray = Array.isArray(inputData) ? inputData : [inputData]; + const results: any[] = []; + + for (const data of dataArray) { + // 헤더 설정 + const headers: any = { ...apiHeaders }; + + // 인증 헤더 추가 + if (apiAuthType === "bearer" && apiAuthConfig?.token) { + headers["Authorization"] = `Bearer ${apiAuthConfig.token}`; + } else if ( + apiAuthType === "basic" && + apiAuthConfig?.username && + apiAuthConfig?.password + ) { + const credentials = Buffer.from( + `${apiAuthConfig.username}:${apiAuthConfig.password}` + ).toString("base64"); + headers["Authorization"] = `Basic ${credentials}`; + } else if (apiAuthType === "apikey" && apiAuthConfig?.apiKey) { + const headerName = apiAuthConfig.apiKeyHeader || "X-API-Key"; + headers[headerName] = apiAuthConfig.apiKey; + } + + if (!headers["Content-Type"]) { + headers["Content-Type"] = "application/json"; + } + + // 바디 생성 + let body: any; + + if (apiBodyTemplate) { + body = this.replaceTemplateVariables(apiBodyTemplate, data); + } else if (fieldMappings && fieldMappings.length > 0) { + body = {}; + fieldMappings.forEach((mapping: any) => { + const value = + mapping.staticValue !== undefined + ? mapping.staticValue + : data[mapping.sourceField]; + body[mapping.targetField] = value; + }); + } else { + body = data; + } + + try { + // UPSERT는 일반적으로 PUT 메서드 사용 (멱등성) + const response = await axios({ + method: apiMethod || "PUT", + url: apiEndpoint, + headers, + data: body, + timeout: 30000, + }); + + results.push({ + status: response.status, + data: response.data, + }); + } catch (error: any) { + logger.error( + `❌ API 요청 실패: ${error.response?.status || error.message}` + ); + throw error; + } + } + + logger.info(`✅ REST API UPSERT 완료: ${results.length}건`); + + return { results }; + } + /** * 조건 노드 실행 */ diff --git a/docs/node-action-target-selection-plan.md b/docs/node-action-target-selection-plan.md new file mode 100644 index 00000000..342fc8ec --- /dev/null +++ b/docs/node-action-target-selection-plan.md @@ -0,0 +1,332 @@ +# 액션 노드 타겟 선택 시스템 개선 계획 + +## 📋 현재 문제점 + +### 1. 타겟 타입 구분 부재 +- INSERT/UPDATE/DELETE/UPSERT 액션 노드가 타겟 테이블만 선택 가능 +- 내부 DB인지, 외부 DB인지, REST API인지 구분 없음 +- 실행 시 항상 내부 DB로만 동작 + +### 2. 외부 시스템 연동 불가 +- 외부 DB에 데이터 저장 불가 +- 외부 REST API 호출 불가 +- 하이브리드 플로우 구성 불가 (내부 → 외부 데이터 전송) + +--- + +## 🎯 개선 목표 + +액션 노드에서 다음 3가지 타겟 타입을 선택할 수 있도록 개선: + +### 1. 내부 데이터베이스 (Internal DB) +- 현재 시스템의 PostgreSQL +- 기존 동작 유지 + +### 2. 외부 데이터베이스 (External DB) +- 외부 커넥션 관리에서 설정한 DB +- PostgreSQL, MySQL, Oracle, MSSQL, MariaDB 지원 + +### 3. REST API +- 외부 REST API 호출 +- HTTP 메서드: POST, PUT, PATCH, DELETE +- 인증: None, Basic, Bearer Token, API Key + +--- + +## 📐 타입 정의 확장 + +### TargetType 추가 +```typescript +export type TargetType = "internal" | "external" | "api"; + +export interface BaseActionNodeData { + displayName: string; + targetType: TargetType; // 🔥 새로 추가 + + // targetType === "internal" + targetTable?: string; + targetTableLabel?: string; + + // targetType === "external" + externalConnectionId?: number; + externalConnectionName?: string; + externalDbType?: string; + externalTargetTable?: string; + externalTargetSchema?: string; + + // targetType === "api" + apiEndpoint?: string; + apiMethod?: "POST" | "PUT" | "PATCH" | "DELETE"; + apiAuthType?: "none" | "basic" | "bearer" | "apikey"; + apiAuthConfig?: { + username?: string; + password?: string; + token?: string; + apiKey?: string; + apiKeyHeader?: string; + }; + apiHeaders?: Record; + apiBodyTemplate?: string; // JSON 템플릿 +} +``` + +--- + +## 🎨 UI 설계 + +### 1. 타겟 타입 선택 (공통) +``` +┌─────────────────────────────────────┐ +│ 타겟 타입 │ +│ ○ 내부 데이터베이스 (기본) │ +│ ○ 외부 데이터베이스 │ +│ ○ REST API │ +└─────────────────────────────────────┘ +``` + +### 2-A. 내부 DB 선택 시 (기존 UI 유지) +``` +┌─────────────────────────────────────┐ +│ 테이블 선택: [검색 가능 Combobox] │ +│ 필드 매핑: │ +│ • source_field → target_field │ +│ • ... │ +└─────────────────────────────────────┘ +``` + +### 2-B. 외부 DB 선택 시 +``` +┌─────────────────────────────────────┐ +│ 외부 커넥션: [🐘 PostgreSQL - 운영DB]│ +│ 스키마: [public ▼] │ +│ 테이블: [users ▼] │ +│ 필드 매핑: │ +│ • source_field → target_field │ +└─────────────────────────────────────┘ +``` + +### 2-C. REST API 선택 시 +``` +┌─────────────────────────────────────┐ +│ API 엔드포인트: │ +│ [https://api.example.com/users] │ +│ │ +│ HTTP 메서드: [POST ▼] │ +│ │ +│ 인증 타입: [Bearer Token ▼] │ +│ Token: [••••••••••••••] │ +│ │ +│ 헤더 (선택): │ +│ Content-Type: application/json │ +│ + 헤더 추가 │ +│ │ +│ 바디 템플릿: │ +│ { │ +│ "name": "{{source.name}}", │ +│ "email": "{{source.email}}" │ +│ } │ +└─────────────────────────────────────┘ +``` + +--- + +## 🔧 구현 단계 + +### Phase 1: 타입 정의 및 기본 UI (1-2시간) +- [ ] `types/node-editor.ts`에 `TargetType` 추가 +- [ ] `InsertActionNodeData` 등 인터페이스 확장 +- [ ] 속성 패널에 타겟 타입 선택 라디오 버튼 추가 + +### Phase 2: 내부 DB 타입 (기존 유지) +- [ ] `targetType === "internal"` 처리 +- [ ] 기존 로직 그대로 유지 +- [ ] 기본값으로 설정 + +### Phase 3: 외부 DB 타입 (2-3시간) +- [ ] 외부 커넥션 선택 UI +- [ ] 외부 테이블/컬럼 로드 (기존 API 재사용) +- [ ] 백엔드: `nodeFlowExecutionService.ts`에 외부 DB 실행 로직 추가 +- [ ] `DatabaseConnectorFactory` 활용 + +### Phase 4: REST API 타입 (3-4시간) +- [ ] API 엔드포인트 설정 UI +- [ ] HTTP 메서드 선택 +- [ ] 인증 타입별 설정 UI +- [ ] 바디 템플릿 에디터 (변수 치환 지원) +- [ ] 백엔드: Axios를 사용한 API 호출 로직 +- [ ] 응답 처리 및 에러 핸들링 + +### Phase 5: 노드 시각화 개선 (1시간) +- [ ] 노드에 타겟 타입 아이콘 표시 + - 내부 DB: 💾 + - 외부 DB: 🔌 + DB 타입 아이콘 + - REST API: 🌐 +- [ ] 노드 색상 구분 + +### Phase 6: 검증 및 테스트 (2시간) +- [ ] 타겟 타입별 필수 값 검증 +- [ ] 연결 규칙 업데이트 +- [ ] 통합 테스트 + +--- + +## 🔍 백엔드 실행 로직 + +### nodeFlowExecutionService.ts +```typescript +private static async executeInsertAction( + node: FlowNode, + inputData: any[], + context: ExecutionContext +): Promise { + const { targetType } = node.data; + + switch (targetType) { + case "internal": + return this.executeInternalInsert(node, inputData); + + case "external": + return this.executeExternalInsert(node, inputData); + + case "api": + return this.executeApiInsert(node, inputData); + + default: + throw new Error(`지원하지 않는 타겟 타입: ${targetType}`); + } +} + +// 🔥 외부 DB INSERT +private static async executeExternalInsert( + node: FlowNode, + inputData: any[] +): Promise { + const { externalConnectionId, externalTargetTable, fieldMappings } = node.data; + + const connector = await DatabaseConnectorFactory.getConnector( + externalConnectionId!, + node.data.externalDbType! + ); + + const results = []; + for (const row of inputData) { + const values = fieldMappings.map(m => row[m.sourceField]); + const columns = fieldMappings.map(m => m.targetField); + + const result = await connector.executeQuery( + `INSERT INTO ${externalTargetTable} (${columns.join(", ")}) VALUES (${...})`, + values + ); + results.push(result); + } + + await connector.disconnect(); + return results; +} + +// 🔥 REST API INSERT (POST) +private static async executeApiInsert( + node: FlowNode, + inputData: any[] +): Promise { + const { + apiEndpoint, + apiMethod, + apiAuthType, + apiAuthConfig, + apiHeaders, + apiBodyTemplate + } = node.data; + + const axios = require("axios"); + const headers = { ...apiHeaders }; + + // 인증 헤더 추가 + if (apiAuthType === "bearer" && apiAuthConfig?.token) { + headers["Authorization"] = `Bearer ${apiAuthConfig.token}`; + } else if (apiAuthType === "apikey" && apiAuthConfig?.apiKey) { + headers[apiAuthConfig.apiKeyHeader || "X-API-Key"] = apiAuthConfig.apiKey; + } + + const results = []; + for (const row of inputData) { + // 템플릿 변수 치환 + const body = this.replaceTemplateVariables(apiBodyTemplate, row); + + const response = await axios({ + method: apiMethod || "POST", + url: apiEndpoint, + headers, + data: JSON.parse(body), + }); + + results.push(response.data); + } + + return results; +} +``` + +--- + +## 📊 우선순위 + +### High Priority +1. **Phase 1**: 타입 정의 및 기본 UI +2. **Phase 2**: 내부 DB 타입 (기존 유지) +3. **Phase 3**: 외부 DB 타입 + +### Medium Priority +4. **Phase 4**: REST API 타입 +5. **Phase 5**: 노드 시각화 + +### Low Priority +6. **Phase 6**: 고급 기능 (재시도, 배치 처리 등) + +--- + +## 🎯 예상 효과 + +### 1. 유연성 증가 +- 다양한 시스템 간 데이터 연동 가능 +- 하이브리드 플로우 구성 (내부 → 외부 → API) + +### 2. 사용 사례 확장 +``` +[사례 1] 내부 DB → 외부 DB 동기화 +TableSource(내부) + → DataTransform + → INSERT(외부 DB) + +[사례 2] 내부 DB → REST API 전송 +TableSource(내부) + → DataTransform + → INSERT(REST API) + +[사례 3] 복합 플로우 +TableSource(내부) + → INSERT(외부 DB) + → INSERT(REST API - 알림) +``` + +### 3. 기존 기능과의 호환 +- 기본값: `targetType = "internal"` +- 기존 플로우 마이그레이션 불필요 + +--- + +## ⚠️ 주의사항 + +### 1. 보안 +- API 인증 정보 암호화 저장 +- 민감 데이터 로깅 방지 + +### 2. 에러 핸들링 +- 외부 시스템 타임아웃 처리 +- 재시도 로직 (선택적) +- 부분 실패 처리 (이미 구현됨) + +### 3. 성능 +- 외부 DB 연결 풀 관리 (이미 구현됨) +- REST API Rate Limiting 고려 + diff --git a/frontend/components/dataflow/node-editor/FlowEditor.tsx b/frontend/components/dataflow/node-editor/FlowEditor.tsx index 224a1f42..42a88aff 100644 --- a/frontend/components/dataflow/node-editor/FlowEditor.tsx +++ b/frontend/components/dataflow/node-editor/FlowEditor.tsx @@ -111,13 +111,31 @@ function FlowEditorInner() { y: event.clientY, }); + // 🔥 노드 타입별 기본 데이터 설정 + const defaultData: any = { + displayName: `새 ${type} 노드`, + }; + + // 액션 노드의 경우 targetType 기본값 설정 + if (["insertAction", "updateAction", "deleteAction", "upsertAction"].includes(type)) { + defaultData.targetType = "internal"; // 기본값: 내부 DB + defaultData.fieldMappings = []; + defaultData.options = {}; + + if (type === "updateAction" || type === "deleteAction") { + defaultData.whereConditions = []; + } + + if (type === "upsertAction") { + defaultData.conflictKeys = []; + } + } + const newNode: any = { id: `node_${Date.now()}`, type, position, - data: { - displayName: `새 ${type} 노드`, - }, + data: defaultData, }; addNode(newNode); diff --git a/frontend/components/dataflow/node-editor/panels/properties/DeleteActionProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/DeleteActionProperties.tsx index a9f8066f..29e4e86b 100644 --- a/frontend/components/dataflow/node-editor/panels/properties/DeleteActionProperties.tsx +++ b/frontend/components/dataflow/node-editor/panels/properties/DeleteActionProperties.tsx @@ -5,14 +5,20 @@ */ import { useEffect, useState } from "react"; -import { Plus, Trash2, AlertTriangle } from "lucide-react"; +import { Plus, Trash2, AlertTriangle, Database, Globe, Link2, Check, ChevronsUpDown } from "lucide-react"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; import { useFlowEditorStore } from "@/lib/stores/flowEditorStore"; +import { tableTypeApi } from "@/lib/api/screen"; +import { getTestedExternalConnections, getExternalTables, getExternalColumns } from "@/lib/api/nodeExternalConnections"; import type { DeleteActionNodeData } from "@/types/node-editor"; +import type { ExternalConnection, ExternalTable, ExternalColumn } from "@/lib/api/nodeExternalConnections"; interface DeleteActionPropertiesProps { nodeId: string; @@ -29,18 +35,157 @@ const OPERATORS = [ ] as const; export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesProps) { - const { updateNode } = useFlowEditorStore(); + const { updateNode, getExternalConnectionsCache } = useFlowEditorStore(); + + // 🔥 타겟 타입 상태 + const [targetType, setTargetType] = useState<"internal" | "external" | "api">(data.targetType || "internal"); const [displayName, setDisplayName] = useState(data.displayName || `${data.targetTable} 삭제`); const [targetTable, setTargetTable] = useState(data.targetTable); const [whereConditions, setWhereConditions] = useState(data.whereConditions || []); + // 🔥 외부 DB 관련 상태 + const [externalConnections, setExternalConnections] = useState([]); + const [externalConnectionsLoading, setExternalConnectionsLoading] = useState(false); + const [selectedExternalConnectionId, setSelectedExternalConnectionId] = useState( + data.externalConnectionId, + ); + const [externalTables, setExternalTables] = useState([]); + const [externalTablesLoading, setExternalTablesLoading] = useState(false); + const [externalTargetTable, setExternalTargetTable] = useState(data.externalTargetTable); + const [externalColumns, setExternalColumns] = useState([]); + const [externalColumnsLoading, setExternalColumnsLoading] = useState(false); + + // 🔥 REST API 관련 상태 (DELETE는 요청 바디 없음) + const [apiEndpoint, setApiEndpoint] = useState(data.apiEndpoint || ""); + const [apiAuthType, setApiAuthType] = useState<"none" | "basic" | "bearer" | "apikey">(data.apiAuthType || "none"); + const [apiAuthConfig, setApiAuthConfig] = useState(data.apiAuthConfig || {}); + const [apiHeaders, setApiHeaders] = useState>(data.apiHeaders || {}); + + // 🔥 내부 DB 테이블 관련 상태 + const [tables, setTables] = useState([]); + const [tablesLoading, setTablesLoading] = useState(false); + const [tablesOpen, setTablesOpen] = useState(false); + const [selectedTableLabel, setSelectedTableLabel] = useState(data.targetTable); + useEffect(() => { setDisplayName(data.displayName || `${data.targetTable} 삭제`); setTargetTable(data.targetTable); setWhereConditions(data.whereConditions || []); }, [data]); + // 🔥 내부 DB 테이블 목록 로딩 + useEffect(() => { + if (targetType === "internal") { + loadTables(); + } + }, [targetType]); + + // 🔥 외부 커넥션 로딩 + useEffect(() => { + if (targetType === "external") { + loadExternalConnections(); + } + }, [targetType]); + + // 🔥 외부 테이블 로딩 + useEffect(() => { + if (targetType === "external" && selectedExternalConnectionId) { + loadExternalTables(selectedExternalConnectionId); + } + }, [targetType, selectedExternalConnectionId]); + + // 🔥 외부 컬럼 로딩 + useEffect(() => { + if (targetType === "external" && selectedExternalConnectionId && externalTargetTable) { + loadExternalColumns(selectedExternalConnectionId, externalTargetTable); + } + }, [targetType, selectedExternalConnectionId, externalTargetTable]); + + const loadExternalConnections = async () => { + try { + setExternalConnectionsLoading(true); + + const cached = getExternalConnectionsCache(); + if (cached) { + setExternalConnections(cached); + return; + } + + const data = await getTestedExternalConnections(); + setExternalConnections(data); + } catch (error) { + console.error("외부 커넥션 로딩 실패:", error); + } finally { + setExternalConnectionsLoading(false); + } + }; + + const loadExternalTables = async (connectionId: number) => { + try { + setExternalTablesLoading(true); + const data = await getExternalTables(connectionId); + setExternalTables(data); + } catch (error) { + console.error("외부 테이블 로딩 실패:", error); + } finally { + setExternalTablesLoading(false); + } + }; + + const loadExternalColumns = async (connectionId: number, tableName: string) => { + try { + setExternalColumnsLoading(true); + const data = await getExternalColumns(connectionId, tableName); + setExternalColumns(data); + } catch (error) { + console.error("외부 컬럼 로딩 실패:", error); + } finally { + setExternalColumnsLoading(false); + } + }; + + const handleTargetTypeChange = (newType: "internal" | "external" | "api") => { + setTargetType(newType); + updateNode(nodeId, { + targetType: newType, + targetTable: newType === "internal" ? targetTable : undefined, + externalConnectionId: newType === "external" ? selectedExternalConnectionId : undefined, + externalTargetTable: newType === "external" ? externalTargetTable : undefined, + apiEndpoint: newType === "api" ? apiEndpoint : undefined, + apiAuthType: newType === "api" ? apiAuthType : undefined, + apiAuthConfig: newType === "api" ? apiAuthConfig : undefined, + apiHeaders: newType === "api" ? apiHeaders : undefined, + }); + }; + + // 🔥 테이블 목록 로딩 + const loadTables = async () => { + try { + setTablesLoading(true); + const tableList = await tableTypeApi.getTables(); + setTables(tableList); + } catch (error) { + console.error("테이블 목록 로딩 실패:", error); + } finally { + setTablesLoading(false); + } + }; + + const handleTableSelect = (tableName: string) => { + const selectedTable = tables.find((t: any) => t.tableName === tableName); + const label = (selectedTable as any)?.tableLabel || selectedTable?.displayName || tableName; + + setTargetTable(tableName); + setSelectedTableLabel(label); + setTablesOpen(false); + + updateNode(nodeId, { + targetTable: tableName, + displayName: label, + }); + }; + const handleAddCondition = () => { setWhereConditions([ ...whereConditions, @@ -103,17 +248,385 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP /> + {/* 🔥 타겟 타입 선택 */}
- - setTargetTable(e.target.value)} - className="mt-1" - /> + +
+ + + + + +
+ + {/* 내부 DB: 타겟 테이블 Combobox */} + {targetType === "internal" && ( +
+ + + + + + + + + 테이블을 찾을 수 없습니다. + + + {tables.map((table: any) => ( + handleTableSelect(table.tableName)} + > + +
+ {table.tableLabel || table.displayName} + {table.tableName} +
+
+ ))} +
+
+
+
+
+
+ )} + + {/* 🔥 외부 DB 설정 */} + {targetType === "external" && ( + <> +
+ + +
+ + {selectedExternalConnectionId && ( +
+ + +
+ )} + + {externalTargetTable && externalColumns.length > 0 && ( +
+ +
+ {externalColumns.map((col) => ( +
+ {col.column_name} + {col.data_type} +
+ ))} +
+
+ )} + + )} + + {/* 🔥 REST API 설정 (DELETE는 간단함) */} + {targetType === "api" && ( +
+
+ + { + setApiEndpoint(e.target.value); + updateNode(nodeId, { apiEndpoint: e.target.value }); + }} + className="h-8 text-xs" + /> +
+ +
+ + +
+ + {apiAuthType !== "none" && ( +
+ + + {apiAuthType === "bearer" && ( + { + const newConfig = { token: e.target.value }; + setApiAuthConfig(newConfig); + updateNode(nodeId, { apiAuthConfig: newConfig }); + }} + className="h-8 text-xs" + /> + )} + + {apiAuthType === "basic" && ( +
+ { + const newConfig = { ...(apiAuthConfig as any), username: e.target.value }; + setApiAuthConfig(newConfig); + updateNode(nodeId, { apiAuthConfig: newConfig }); + }} + className="h-8 text-xs" + /> + { + const newConfig = { ...(apiAuthConfig as any), password: e.target.value }; + setApiAuthConfig(newConfig); + updateNode(nodeId, { apiAuthConfig: newConfig }); + }} + className="h-8 text-xs" + /> +
+ )} + + {apiAuthType === "apikey" && ( +
+ { + const newConfig = { ...(apiAuthConfig as any), headerName: e.target.value }; + setApiAuthConfig(newConfig); + updateNode(nodeId, { apiAuthConfig: newConfig }); + }} + className="h-8 text-xs" + /> + { + const newConfig = { ...(apiAuthConfig as any), apiKey: e.target.value }; + setApiAuthConfig(newConfig); + updateNode(nodeId, { apiAuthConfig: newConfig }); + }} + className="h-8 text-xs" + /> +
+ )} +
+ )} + +
+ +
+ {Object.entries(apiHeaders).map(([key, value], index) => ( +
+ { + const newHeaders = { ...apiHeaders }; + delete newHeaders[key]; + newHeaders[e.target.value] = value; + setApiHeaders(newHeaders); + updateNode(nodeId, { apiHeaders: newHeaders }); + }} + className="h-7 flex-1 text-xs" + /> + { + const newHeaders = { ...apiHeaders, [key]: e.target.value }; + setApiHeaders(newHeaders); + updateNode(nodeId, { apiHeaders: newHeaders }); + }} + className="h-7 flex-1 text-xs" + /> + +
+ ))} + +
+
+
+ )} diff --git a/frontend/components/dataflow/node-editor/panels/properties/InsertActionProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/InsertActionProperties.tsx index b28aa3f8..a73b410b 100644 --- a/frontend/components/dataflow/node-editor/panels/properties/InsertActionProperties.tsx +++ b/frontend/components/dataflow/node-editor/panels/properties/InsertActionProperties.tsx @@ -5,7 +5,7 @@ */ import { useEffect, useState } from "react"; -import { Plus, Trash2, Check, ChevronsUpDown, ArrowRight } from "lucide-react"; +import { Plus, Trash2, Check, ChevronsUpDown, ArrowRight, Database, Globe, Link2 } from "lucide-react"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; @@ -17,7 +17,9 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { cn } from "@/lib/utils"; import { useFlowEditorStore } from "@/lib/stores/flowEditorStore"; import { tableTypeApi } from "@/lib/api/screen"; +import { getTestedExternalConnections, getExternalTables, getExternalColumns } from "@/lib/api/nodeExternalConnections"; import type { InsertActionNodeData } from "@/types/node-editor"; +import type { ExternalConnection, ExternalTable, ExternalColumn } from "@/lib/api/nodeExternalConnections"; interface InsertActionPropertiesProps { nodeId: string; @@ -39,7 +41,10 @@ interface ColumnInfo { } export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesProps) { - const { updateNode, nodes, edges } = useFlowEditorStore(); + const { updateNode, nodes, edges, getExternalConnectionsCache } = useFlowEditorStore(); + + // 🔥 타겟 타입 상태 + const [targetType, setTargetType] = useState<"internal" | "external" | "api">(data.targetType || "internal"); const [displayName, setDisplayName] = useState(data.displayName || data.targetTable); const [targetTable, setTargetTable] = useState(data.targetTable); @@ -48,7 +53,7 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP const [ignoreErrors, setIgnoreErrors] = useState(data.options?.ignoreErrors || false); const [ignoreDuplicates, setIgnoreDuplicates] = useState(data.options?.ignoreDuplicates || false); - // 테이블 관련 상태 + // 내부 DB 테이블 관련 상태 const [tables, setTables] = useState([]); const [tablesLoading, setTablesLoading] = useState(false); const [tablesOpen, setTablesOpen] = useState(false); @@ -60,6 +65,26 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP // 소스 필드 목록 (연결된 입력 노드에서 가져오기) const [sourceFields, setSourceFields] = useState>([]); + // 🔥 외부 DB 관련 상태 + const [externalConnections, setExternalConnections] = useState([]); + const [externalConnectionsLoading, setExternalConnectionsLoading] = useState(false); + const [selectedExternalConnectionId, setSelectedExternalConnectionId] = useState( + data.externalConnectionId, + ); + const [externalTables, setExternalTables] = useState([]); + const [externalTablesLoading, setExternalTablesLoading] = useState(false); + const [externalTargetTable, setExternalTargetTable] = useState(data.externalTargetTable); + const [externalColumns, setExternalColumns] = useState([]); + const [externalColumnsLoading, setExternalColumnsLoading] = useState(false); + + // 🔥 REST API 관련 상태 + const [apiEndpoint, setApiEndpoint] = useState(data.apiEndpoint || ""); + const [apiMethod, setApiMethod] = useState<"POST" | "PUT" | "PATCH">(data.apiMethod || "POST"); + const [apiAuthType, setApiAuthType] = useState<"none" | "basic" | "bearer" | "apikey">(data.apiAuthType || "none"); + const [apiAuthConfig, setApiAuthConfig] = useState(data.apiAuthConfig || {}); + const [apiHeaders, setApiHeaders] = useState>(data.apiHeaders || {}); + const [apiBodyTemplate, setApiBodyTemplate] = useState(data.apiBodyTemplate || ""); + // 데이터 변경 시 로컬 상태 업데이트 useEffect(() => { setDisplayName(data.displayName || data.targetTable); @@ -70,17 +95,40 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP setIgnoreDuplicates(data.options?.ignoreDuplicates || false); }, [data]); - // 테이블 목록 로딩 + // 내부 DB 테이블 목록 로딩 useEffect(() => { - loadTables(); - }, []); + if (targetType === "internal") { + loadTables(); + } + }, [targetType]); - // 타겟 테이블 변경 시 컬럼 로딩 + // 타겟 테이블 변경 시 컬럼 로딩 (내부 DB) useEffect(() => { - if (targetTable) { + if (targetType === "internal" && targetTable) { loadColumns(targetTable); } - }, [targetTable]); + }, [targetType, targetTable]); + + // 🔥 외부 커넥션 로드 (캐시 우선) + useEffect(() => { + if (targetType === "external") { + loadExternalConnections(); + } + }, [targetType]); + + // 🔥 외부 커넥션 변경 시 테이블 로드 + useEffect(() => { + if (targetType === "external" && selectedExternalConnectionId) { + loadExternalTables(selectedExternalConnectionId); + } + }, [targetType, selectedExternalConnectionId]); + + // 🔥 외부 테이블 변경 시 컬럼 로드 + useEffect(() => { + if (targetType === "external" && selectedExternalConnectionId && externalTargetTable) { + loadExternalColumns(selectedExternalConnectionId, externalTargetTable); + } + }, [targetType, selectedExternalConnectionId, externalTargetTable]); // 연결된 소스 노드에서 필드 가져오기 (재귀적으로 모든 상위 노드 탐색) useEffect(() => { @@ -239,6 +287,65 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP } }; + // 🔥 외부 커넥션 로드 (캐시 우선) + const loadExternalConnections = async () => { + try { + // 캐시 확인 + const cachedData = getExternalConnectionsCache(); + if (cachedData) { + console.log("✅ 캐시된 외부 커넥션 사용:", cachedData.length); + setExternalConnections(cachedData); + return; + } + + setExternalConnectionsLoading(true); + console.log("🔍 외부 커넥션 조회 중..."); + + const connections = await getTestedExternalConnections(); + setExternalConnections(connections); + console.log(`✅ 외부 커넥션 ${connections.length}개 로딩 완료`); + } catch (error) { + console.error("❌ 외부 커넥션 로딩 실패:", error); + setExternalConnections([]); + } finally { + setExternalConnectionsLoading(false); + } + }; + + // 🔥 외부 테이블 로드 + const loadExternalTables = async (connectionId: number) => { + try { + setExternalTablesLoading(true); + console.log(`🔍 외부 테이블 조회 중: connection ${connectionId}`); + + const tables = await getExternalTables(connectionId); + setExternalTables(tables); + console.log(`✅ 외부 테이블 ${tables.length}개 로딩 완료`); + } catch (error) { + console.error("❌ 외부 테이블 로딩 실패:", error); + setExternalTables([]); + } finally { + setExternalTablesLoading(false); + } + }; + + // 🔥 외부 컬럼 로드 + const loadExternalColumns = async (connectionId: number, tableName: string) => { + try { + setExternalColumnsLoading(true); + console.log(`🔍 외부 컬럼 조회 중: ${tableName}`); + + const columns = await getExternalColumns(connectionId, tableName); + setExternalColumns(columns); + console.log(`✅ 외부 컬럼 ${columns.length}개 로딩 완료`); + } catch (error) { + console.error("❌ 외부 컬럼 로딩 실패:", error); + setExternalColumns([]); + } finally { + setExternalColumnsLoading(false); + } + }; + /** * 테이블 선택 핸들러 */ @@ -334,9 +441,96 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP const selectedTableLabel = tables.find((t) => t.tableName === targetTable)?.label || targetTable; + // 🔥 타겟 타입 변경 핸들러 + const handleTargetTypeChange = (newType: "internal" | "external" | "api") => { + setTargetType(newType); + + // 타입 변경 시 관련 필드 초기화 + const updates: any = { + targetType: newType, + displayName, + }; + + // 이전 타입의 데이터 유지 + if (newType === "internal") { + updates.targetTable = targetTable; + updates.targetTableLabel = data.targetTableLabel; + } else if (newType === "external") { + updates.externalConnectionId = data.externalConnectionId; + updates.externalTargetTable = data.externalTargetTable; + } else if (newType === "api") { + updates.apiEndpoint = data.apiEndpoint; + updates.apiMethod = data.apiMethod || "POST"; + } + + updates.fieldMappings = fieldMappings; + updates.options = { + batchSize: batchSize ? parseInt(batchSize) : undefined, + ignoreErrors, + ignoreDuplicates, + }; + + updateNode(nodeId, updates); + }; + return (
+ {/* 🔥 타겟 타입 선택 */} +
+ +
+ + + + + +
+
+ {/* 기본 정보 */}

기본 정보

@@ -355,201 +549,566 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP />
- {/* 타겟 테이블 Combobox */} -
- - - - + + + + + + 검색 결과가 없습니다. + + + {tables.map((table) => ( + handleTableSelect(table.tableName)} + className="cursor-pointer" + > + +
+ {table.label} + {table.label !== table.tableName && ( + {table.tableName} + )} + {table.description && ( + {table.description} + )} +
+
+ ))} +
+
+
+
+
+
+ {targetTable && selectedTableLabel !== targetTable && ( +

+ 실제 테이블명: {targetTable} +

+ )} +
+ + )} + + {/* 🔥 외부 DB 타입 UI */} + {targetType === "external" && ( + <> + {/* 외부 커넥션 선택 */} +
+ + + {externalConnectionsLoading &&

로딩 중...

} + {externalConnections.length === 0 && !externalConnectionsLoading && ( +

⚠️ 테스트에 성공한 외부 커넥션이 없습니다.

+ )} +
+ + {/* 외부 테이블 선택 */} + {selectedExternalConnectionId && ( +
+ + + {externalTablesLoading &&

로딩 중...

} +
+ )} + + {/* 외부 컬럼 정보 표시 */} + {selectedExternalConnectionId && externalTargetTable && externalColumns.length > 0 && ( +
+

타겟 컬럼 ({externalColumns.length}개)

+
+ {externalColumns.map((col) => ( +
+ {col.column_name} + {col.data_type} +
+ ))} +
+
+ )} + + )} + + {/* 🔥 REST API 타입 UI (추후 구현) */} + {targetType === "api" && ( +
+ {/* API 엔드포인트 */} +
+ + { + setApiEndpoint(e.target.value); + updateNode(nodeId, { apiEndpoint: e.target.value }); + }} + className="h-8 text-xs" + /> +
+ + {/* HTTP 메서드 */} +
+ + +
+ + {/* 인증 타입 */} +
+ + +
+ + {/* 인증 설정 */} + {apiAuthType !== "none" && ( +
+ + + {apiAuthType === "bearer" && ( + { + const newConfig = { token: e.target.value }; + setApiAuthConfig(newConfig); + updateNode(nodeId, { apiAuthConfig: newConfig }); + }} + className="h-8 text-xs" + /> )} - - - - - - - - 검색 결과가 없습니다. - - - {tables.map((table) => ( - handleTableSelect(table.tableName)} - className="cursor-pointer" - > - -
- {table.label} - {table.label !== table.tableName && ( - {table.tableName} - )} - {table.description && ( - {table.description} - )} -
-
- ))} -
-
-
-
-
- - {targetTable && selectedTableLabel !== targetTable && ( -

- 실제 테이블명: {targetTable} -

- )} -
-
-
- {/* 필드 매핑 */} -
-
-

필드 매핑

- -
+ {apiAuthType === "basic" && ( +
+ { + const newConfig = { ...(apiAuthConfig as any), username: e.target.value }; + setApiAuthConfig(newConfig); + updateNode(nodeId, { apiAuthConfig: newConfig }); + }} + className="h-8 text-xs" + /> + { + const newConfig = { ...(apiAuthConfig as any), password: e.target.value }; + setApiAuthConfig(newConfig); + updateNode(nodeId, { apiAuthConfig: newConfig }); + }} + className="h-8 text-xs" + /> +
+ )} - {columnsLoading && ( -
컬럼 목록을 불러오는 중...
- )} + {apiAuthType === "apikey" && ( +
+ { + const newConfig = { ...(apiAuthConfig as any), headerName: e.target.value }; + setApiAuthConfig(newConfig); + updateNode(nodeId, { apiAuthConfig: newConfig }); + }} + className="h-8 text-xs" + /> + { + const newConfig = { ...(apiAuthConfig as any), apiKey: e.target.value }; + setApiAuthConfig(newConfig); + updateNode(nodeId, { apiAuthConfig: newConfig }); + }} + className="h-8 text-xs" + /> +
+ )} +
+ )} - {!targetTable && !columnsLoading && ( -
- ⚠️ 먼저 타겟 테이블을 선택하세요 -
- )} - - {targetTable && !columnsLoading && targetColumns.length === 0 && ( -
- ❌ 컬럼 정보를 불러올 수 없습니다 -
- )} - - {targetTable && targetColumns.length > 0 && ( - <> - {fieldMappings.length > 0 ? ( -
- {fieldMappings.map((mapping, index) => ( -
-
- 매핑 #{index + 1} + {/* 커스텀 헤더 */} +
+ +
+ {Object.entries(apiHeaders).map(([key, value], index) => ( +
+ { + const newHeaders = { ...apiHeaders }; + delete newHeaders[key]; + newHeaders[e.target.value] = value; + setApiHeaders(newHeaders); + updateNode(nodeId, { apiHeaders: newHeaders }); + }} + className="h-7 flex-1 text-xs" + /> + { + const newHeaders = { ...apiHeaders, [key]: e.target.value }; + setApiHeaders(newHeaders); + updateNode(nodeId, { apiHeaders: newHeaders }); + }} + className="h-7 flex-1 text-xs" + />
- -
- {/* 소스 필드 드롭다운 */} -
- - -
- -
- -
- - {/* 타겟 필드 드롭다운 */} -
- - -
- - {/* 정적 값 */} -
- - handleMappingChange(index, "staticValue", e.target.value || undefined)} - placeholder="소스 필드 대신 고정 값 사용" - className="mt-1 h-8 text-xs" - /> -

소스 필드가 비어있을 때만 사용됩니다

-
-
-
- ))} + ))} + +
- ) : ( -
- 필드 매핑이 없습니다. "추가" 버튼을 클릭하세요. + + {/* 요청 바디 설정 */} +
+ +