diff --git a/backend-node/src/controllers/flowController.ts b/backend-node/src/controllers/flowController.ts index 5dd7dc21..c8041749 100644 --- a/backend-node/src/controllers/flowController.ts +++ b/backend-node/src/controllers/flowController.ts @@ -9,6 +9,7 @@ import { FlowStepService } from "../services/flowStepService"; import { FlowConnectionService } from "../services/flowConnectionService"; import { FlowExecutionService } from "../services/flowExecutionService"; import { FlowDataMoveService } from "../services/flowDataMoveService"; +import { FlowProcedureService } from "../services/flowProcedureService"; export class FlowController { private flowDefinitionService: FlowDefinitionService; @@ -16,6 +17,7 @@ export class FlowController { private flowConnectionService: FlowConnectionService; private flowExecutionService: FlowExecutionService; private flowDataMoveService: FlowDataMoveService; + private flowProcedureService: FlowProcedureService; constructor() { this.flowDefinitionService = new FlowDefinitionService(); @@ -23,6 +25,7 @@ export class FlowController { this.flowConnectionService = new FlowConnectionService(); this.flowExecutionService = new FlowExecutionService(); this.flowDataMoveService = new FlowDataMoveService(); + this.flowProcedureService = new FlowProcedureService(); } // ==================== 플로우 정의 ==================== @@ -936,4 +939,94 @@ export class FlowController { }); } }; + + // ==================== 프로시저/함수 ==================== + + /** + * 프로시저/함수 목록 조회 + */ + listProcedures = async (req: Request, res: Response): Promise => { + try { + const dbSource = (req.query.dbSource as string) || "internal"; + const connectionId = req.query.connectionId + ? parseInt(req.query.connectionId as string) + : undefined; + const schema = req.query.schema as string | undefined; + + if (dbSource !== "internal" && dbSource !== "external") { + res.status(400).json({ + success: false, + message: "dbSource는 internal 또는 external이어야 합니다", + }); + return; + } + + if (dbSource === "external" && !connectionId) { + res.status(400).json({ + success: false, + message: "외부 DB 조회 시 connectionId가 필요합니다", + }); + return; + } + + const procedures = await this.flowProcedureService.listProcedures( + dbSource, + connectionId, + schema + ); + + res.json({ success: true, data: procedures }); + } catch (error: any) { + console.error("프로시저 목록 조회 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "프로시저 목록 조회에 실패했습니다", + }); + } + }; + + /** + * 프로시저/함수 파라미터 조회 + */ + getProcedureParameters = async (req: Request, res: Response): Promise => { + try { + const { name } = req.params; + const dbSource = (req.query.dbSource as string) || "internal"; + const connectionId = req.query.connectionId + ? parseInt(req.query.connectionId as string) + : undefined; + const schema = req.query.schema as string | undefined; + + if (!name) { + res.status(400).json({ + success: false, + message: "프로시저 이름이 필요합니다", + }); + return; + } + + if (dbSource !== "internal" && dbSource !== "external") { + res.status(400).json({ + success: false, + message: "dbSource는 internal 또는 external이어야 합니다", + }); + return; + } + + const parameters = await this.flowProcedureService.getProcedureParameters( + name, + dbSource as "internal" | "external", + connectionId, + schema + ); + + res.json({ success: true, data: parameters }); + } catch (error: any) { + console.error("프로시저 파라미터 조회 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "프로시저 파라미터 조회에 실패했습니다", + }); + } + }; } diff --git a/backend-node/src/routes/flowRoutes.ts b/backend-node/src/routes/flowRoutes.ts index e33afac2..0f4518e7 100644 --- a/backend-node/src/routes/flowRoutes.ts +++ b/backend-node/src/routes/flowRoutes.ts @@ -50,4 +50,8 @@ router.put("/:flowId/step/:stepId/data/:recordId", flowController.updateStepData router.get("/audit/:flowId/:recordId", flowController.getAuditLogs); router.get("/audit/:flowId", flowController.getFlowAuditLogs); +// ==================== 프로시저/함수 ==================== +router.get("/procedures", flowController.listProcedures); +router.get("/procedures/:name/parameters", flowController.getProcedureParameters); + export default router; diff --git a/backend-node/src/services/flowDataMoveService.ts b/backend-node/src/services/flowDataMoveService.ts index bec1d4d8..9fe0bbdb 100644 --- a/backend-node/src/services/flowDataMoveService.ts +++ b/backend-node/src/services/flowDataMoveService.ts @@ -26,16 +26,20 @@ import { buildSelectQuery, } from "./dbQueryBuilder"; import { FlowConditionParser } from "./flowConditionParser"; +import { FlowProcedureService } from "./flowProcedureService"; +import { FlowProcedureConfig } from "../types/flow"; export class FlowDataMoveService { private flowDefinitionService: FlowDefinitionService; private flowStepService: FlowStepService; private externalDbIntegrationService: FlowExternalDbIntegrationService; + private flowProcedureService: FlowProcedureService; constructor() { this.flowDefinitionService = new FlowDefinitionService(); this.flowStepService = new FlowStepService(); this.externalDbIntegrationService = new FlowExternalDbIntegrationService(); + this.flowProcedureService = new FlowProcedureService(); } /** @@ -90,6 +94,64 @@ export class FlowDataMoveService { let sourceTable = fromStep.tableName; let targetTable = toStep.tableName || fromStep.tableName; + // 1.5. 프로시저 호출 (스텝 이동 전 실행, 실패 시 전체 롤백) + if ( + toStep.integrationType === "procedure" && + toStep.integrationConfig && + (toStep.integrationConfig as FlowProcedureConfig).type === "procedure" + ) { + const procConfig = toStep.integrationConfig as FlowProcedureConfig; + // 레코드 데이터 조회 (파라미터 매핑용) + let recordData: Record = {}; + try { + const recordTable = FlowConditionParser.sanitizeTableName( + sourceTable || flowDefinition.tableName + ); + const recordResult = await client.query( + `SELECT * FROM ${recordTable} WHERE id = $1 LIMIT 1`, + [dataId] + ); + if (recordResult.rows && recordResult.rows.length > 0) { + recordData = recordResult.rows[0]; + } + } catch (err: any) { + console.warn("프로시저 파라미터용 레코드 조회 실패:", err.message); + } + + console.log(`프로시저 호출 시작: ${procConfig.procedureName}`, { + flowId, + fromStepId, + toStepId, + dataId, + dbSource: procConfig.dbSource, + }); + + const procResult = await this.flowProcedureService.executeProcedure( + procConfig, + recordData, + procConfig.dbSource === "internal" ? client : undefined + ); + + console.log(`프로시저 호출 완료: ${procConfig.procedureName}`, { + success: procResult.success, + }); + + // 프로시저 실행 로그 기록 + await this.logIntegration( + flowId, + toStep.id, + dataId, + "procedure", + procConfig.connectionId, + procConfig, + procResult.result, + "success", + undefined, + 0, + userId + ); + } + // 2. 이동 방식에 따라 처리 switch (toStep.moveType || "status") { case "status": @@ -603,18 +665,19 @@ export class FlowDataMoveService { } break; + case "procedure": + // 프로시저는 데이터 이동 전에 이미 실행됨 (step 1.5) + break; + case "rest_api": - // REST API 연동 (추후 구현) console.warn("REST API 연동은 아직 구현되지 않았습니다"); break; case "webhook": - // Webhook 연동 (추후 구현) console.warn("Webhook 연동은 아직 구현되지 않았습니다"); break; case "hybrid": - // 복합 연동 (추후 구현) console.warn("복합 연동은 아직 구현되지 않았습니다"); break; @@ -716,6 +779,40 @@ export class FlowDataMoveService { let sourceTable = fromStep.tableName; let targetTable = toStep.tableName || fromStep.tableName; + // 1.5. 프로시저 호출 (외부 DB 경로 - 스텝 이동 전) + if ( + toStep.integrationType === "procedure" && + toStep.integrationConfig && + (toStep.integrationConfig as FlowProcedureConfig).type === "procedure" + ) { + const procConfig = toStep.integrationConfig as FlowProcedureConfig; + let recordData: Record = {}; + try { + const recordTable = FlowConditionParser.sanitizeTableName( + sourceTable || "" + ); + if (recordTable) { + const placeholder = getPlaceholder(dbType, 1); + const recordResult = await externalClient.query( + `SELECT * FROM ${recordTable} WHERE id = ${placeholder}`, + [dataId] + ); + const rows = recordResult.rows || recordResult; + if (Array.isArray(rows) && rows.length > 0) { + recordData = rows[0]; + } + } + } catch (err: any) { + console.warn("프로시저 파라미터용 레코드 조회 실패 (외부):", err.message); + } + + await this.flowProcedureService.executeProcedure( + procConfig, + recordData, + procConfig.dbSource === "external" ? undefined : undefined + ); + } + // 2. 이동 방식에 따라 처리 switch (toStep.moveType || "status") { case "status": diff --git a/backend-node/src/services/flowProcedureService.ts b/backend-node/src/services/flowProcedureService.ts new file mode 100644 index 00000000..f1b9b66a --- /dev/null +++ b/backend-node/src/services/flowProcedureService.ts @@ -0,0 +1,429 @@ +/** + * 플로우 프로시저 호출 서비스 + * 내부/외부 DB의 프로시저/함수 목록 조회, 파라미터 조회, 실행을 담당 + */ + +import db from "../database/db"; +import { + getExternalPool, + executeExternalQuery, +} from "./externalDbHelper"; +import { getPlaceholder } from "./dbQueryBuilder"; +import { + FlowProcedureConfig, + FlowProcedureParam, + ProcedureListItem, + ProcedureParameterInfo, +} from "../types/flow"; + +export class FlowProcedureService { + /** + * 프로시저/함수 목록 조회 + * information_schema.routines에서 사용 가능한 프로시저/함수를 가져온다 + */ + async listProcedures( + dbSource: "internal" | "external", + connectionId?: number, + schema?: string + ): Promise { + if (dbSource === "external" && connectionId) { + return this.listExternalProcedures(connectionId, schema); + } + return this.listInternalProcedures(schema); + } + + private async listInternalProcedures( + schema?: string + ): Promise { + const targetSchema = schema || "public"; + // 트리거 함수(data_type='trigger')는 직접 호출 대상이 아니므로 제외 + const query = ` + SELECT + routine_name AS name, + routine_schema AS schema, + routine_type AS type, + data_type AS return_type + FROM information_schema.routines + WHERE routine_schema = $1 + AND routine_type IN ('PROCEDURE', 'FUNCTION') + AND data_type != 'trigger' + ORDER BY routine_type, routine_name + `; + const rows = await db.query(query, [targetSchema]); + return rows.map((r: any) => ({ + name: r.name, + schema: r.schema, + type: r.type as "PROCEDURE" | "FUNCTION", + returnType: r.return_type || undefined, + })); + } + + private async listExternalProcedures( + connectionId: number, + schema?: string + ): Promise { + const poolInfo = await getExternalPool(connectionId); + const dbType = poolInfo.dbType.toLowerCase(); + + let query: string; + let params: any[]; + + switch (dbType) { + case "postgresql": { + const targetSchema = schema || "public"; + query = ` + SELECT + routine_name AS name, + routine_schema AS schema, + routine_type AS type, + data_type AS return_type + FROM information_schema.routines + WHERE routine_schema = $1 + AND routine_type IN ('PROCEDURE', 'FUNCTION') + AND data_type != 'trigger' + ORDER BY routine_type, routine_name + `; + params = [targetSchema]; + break; + } + case "mysql": + case "mariadb": { + query = ` + SELECT + ROUTINE_NAME AS name, + ROUTINE_SCHEMA AS \`schema\`, + ROUTINE_TYPE AS type, + DATA_TYPE AS return_type + FROM information_schema.ROUTINES + WHERE ROUTINE_SCHEMA = DATABASE() + AND ROUTINE_TYPE IN ('PROCEDURE', 'FUNCTION') + ORDER BY ROUTINE_TYPE, ROUTINE_NAME + `; + params = []; + break; + } + case "mssql": { + query = ` + SELECT + ROUTINE_NAME AS name, + ROUTINE_SCHEMA AS [schema], + ROUTINE_TYPE AS type, + DATA_TYPE AS return_type + FROM INFORMATION_SCHEMA.ROUTINES + WHERE ROUTINE_TYPE IN ('PROCEDURE', 'FUNCTION') + ORDER BY ROUTINE_TYPE, ROUTINE_NAME + `; + params = []; + break; + } + default: + throw new Error(`프로시저 목록 조회 미지원 DB: ${dbType}`); + } + + const result = await executeExternalQuery(connectionId, query, params); + return (result.rows || []).map((r: any) => ({ + name: r.name || r.NAME, + schema: r.schema || r.SCHEMA || "", + type: (r.type || r.TYPE || "FUNCTION").toUpperCase() as "PROCEDURE" | "FUNCTION", + returnType: r.return_type || r.RETURN_TYPE || undefined, + })); + } + + /** + * 프로시저/함수 파라미터 정보 조회 + */ + async getProcedureParameters( + procedureName: string, + dbSource: "internal" | "external", + connectionId?: number, + schema?: string + ): Promise { + if (dbSource === "external" && connectionId) { + return this.getExternalProcedureParameters( + connectionId, + procedureName, + schema + ); + } + return this.getInternalProcedureParameters(procedureName, schema); + } + + private async getInternalProcedureParameters( + procedureName: string, + schema?: string + ): Promise { + const targetSchema = schema || "public"; + // PostgreSQL의 specific_name은 routine_name + OID 형태이므로 서브쿼리로 매칭 + const query = ` + SELECT + p.parameter_name AS name, + p.ordinal_position AS position, + p.data_type, + p.parameter_mode AS mode, + p.parameter_default AS default_value + FROM information_schema.parameters p + WHERE p.specific_schema = $1 + AND p.specific_name IN ( + SELECT r.specific_name FROM information_schema.routines r + WHERE r.routine_schema = $1 AND r.routine_name = $2 + LIMIT 1 + ) + AND p.parameter_name IS NOT NULL + ORDER BY p.ordinal_position + `; + const rows = await db.query(query, [targetSchema, procedureName]); + return rows.map((r: any) => ({ + name: r.name, + position: parseInt(r.position, 10), + dataType: r.data_type, + mode: this.normalizeParamMode(r.mode), + defaultValue: r.default_value || undefined, + })); + } + + private async getExternalProcedureParameters( + connectionId: number, + procedureName: string, + schema?: string + ): Promise { + const poolInfo = await getExternalPool(connectionId); + const dbType = poolInfo.dbType.toLowerCase(); + + let query: string; + let params: any[]; + + switch (dbType) { + case "postgresql": { + const targetSchema = schema || "public"; + query = ` + SELECT + p.parameter_name AS name, + p.ordinal_position AS position, + p.data_type, + p.parameter_mode AS mode, + p.parameter_default AS default_value + FROM information_schema.parameters p + WHERE p.specific_schema = $1 + AND p.specific_name IN ( + SELECT r.specific_name FROM information_schema.routines r + WHERE r.routine_schema = $1 AND r.routine_name = $2 + LIMIT 1 + ) + AND p.parameter_name IS NOT NULL + ORDER BY p.ordinal_position + `; + params = [targetSchema, procedureName]; + break; + } + case "mysql": + case "mariadb": { + query = ` + SELECT + PARAMETER_NAME AS name, + ORDINAL_POSITION AS position, + DATA_TYPE AS data_type, + PARAMETER_MODE AS mode, + '' AS default_value + FROM information_schema.PARAMETERS + WHERE SPECIFIC_SCHEMA = DATABASE() + AND SPECIFIC_NAME = ? + AND PARAMETER_NAME IS NOT NULL + ORDER BY ORDINAL_POSITION + `; + params = [procedureName]; + break; + } + case "mssql": { + query = ` + SELECT + PARAMETER_NAME AS name, + ORDINAL_POSITION AS position, + DATA_TYPE AS data_type, + PARAMETER_MODE AS mode, + '' AS default_value + FROM INFORMATION_SCHEMA.PARAMETERS + WHERE SPECIFIC_NAME = @p1 + AND PARAMETER_NAME IS NOT NULL + ORDER BY ORDINAL_POSITION + `; + params = [procedureName]; + break; + } + default: + throw new Error(`파라미터 조회 미지원 DB: ${dbType}`); + } + + const result = await executeExternalQuery(connectionId, query, params); + return (result.rows || []).map((r: any) => ({ + name: (r.name || r.NAME || "").replace(/^@/, ""), + position: parseInt(r.position || r.POSITION || "0", 10), + dataType: r.data_type || r.DATA_TYPE || "unknown", + mode: this.normalizeParamMode(r.mode || r.MODE), + defaultValue: r.default_value || r.DEFAULT_VALUE || undefined, + })); + } + + /** + * 프로시저/함수 실행 + * 내부 DB는 기존 트랜잭션 client를 사용, 외부 DB는 별도 연결 + */ + async executeProcedure( + config: FlowProcedureConfig, + recordData: Record, + client?: any + ): Promise<{ success: boolean; result?: any; error?: string }> { + const paramValues = this.resolveParameters(config.parameters, recordData); + + if (config.dbSource === "internal") { + return this.executeInternalProcedure(config, paramValues, client); + } + + if (!config.connectionId) { + throw new Error("외부 DB 프로시저 호출에 connectionId가 필요합니다"); + } + return this.executeExternalProcedure(config, paramValues); + } + + /** + * 내부 DB 프로시저 실행 (트랜잭션 client 공유) + */ + private async executeInternalProcedure( + config: FlowProcedureConfig, + paramValues: any[], + client?: any + ): Promise<{ success: boolean; result?: any; error?: string }> { + const schema = config.procedureSchema || "public"; + const safeName = this.sanitizeName(config.procedureName); + const safeSchema = this.sanitizeName(schema); + const qualifiedName = `${safeSchema}.${safeName}`; + + const placeholders = paramValues.map((_, i) => `$${i + 1}`).join(", "); + + let sql: string; + if (config.callType === "function") { + // SELECT * FROM fn()을 사용하여 OUT 파라미터를 개별 컬럼으로 반환 + sql = `SELECT * FROM ${qualifiedName}(${placeholders})`; + } else { + sql = `CALL ${qualifiedName}(${placeholders})`; + } + + try { + const executor = client || db; + const result = client + ? await client.query(sql, paramValues) + : await db.query(sql, paramValues); + + const rows = client ? result.rows : result; + return { success: true, result: rows }; + } catch (error: any) { + throw new Error( + `프로시저 실행 실패 [${qualifiedName}]: ${error.message}` + ); + } + } + + /** + * 외부 DB 프로시저 실행 + */ + private async executeExternalProcedure( + config: FlowProcedureConfig, + paramValues: any[] + ): Promise<{ success: boolean; result?: any; error?: string }> { + const connectionId = config.connectionId!; + const poolInfo = await getExternalPool(connectionId); + const dbType = poolInfo.dbType.toLowerCase(); + const safeName = this.sanitizeName(config.procedureName); + const safeSchema = config.procedureSchema + ? this.sanitizeName(config.procedureSchema) + : null; + + let sql: string; + + switch (dbType) { + case "postgresql": { + const qualifiedName = safeSchema + ? `${safeSchema}.${safeName}` + : safeName; + const placeholders = paramValues.map((_, i) => `$${i + 1}`).join(", "); + sql = + config.callType === "function" + ? `SELECT * FROM ${qualifiedName}(${placeholders})` + : `CALL ${qualifiedName}(${placeholders})`; + break; + } + case "mysql": + case "mariadb": { + const placeholders = paramValues.map(() => "?").join(", "); + sql = `CALL ${safeName}(${placeholders})`; + break; + } + case "mssql": { + const paramList = paramValues + .map((_, i) => `@p${i + 1}`) + .join(", "); + sql = `EXEC ${safeName} ${paramList}`; + break; + } + default: + throw new Error(`프로시저 실행 미지원 DB: ${dbType}`); + } + + try { + const result = await executeExternalQuery(connectionId, sql, paramValues); + return { success: true, result: result.rows }; + } catch (error: any) { + throw new Error( + `외부 프로시저 실행 실패 [${safeName}]: ${error.message}` + ); + } + } + + /** + * 설정된 파라미터 매핑에서 실제 값을 추출 + */ + private resolveParameters( + params: FlowProcedureParam[], + recordData: Record + ): any[] { + const inParams = params.filter((p) => p.mode === "IN" || p.mode === "INOUT"); + return inParams.map((param) => { + switch (param.source) { + case "record_field": + if (!param.field) { + throw new Error(`파라미터 ${param.name}: 레코드 필드가 지정되지 않았습니다`); + } + return recordData[param.field] ?? null; + + case "static": + return param.value ?? null; + + case "step_variable": + return recordData[param.field || param.name] ?? param.value ?? null; + + default: + return null; + } + }); + } + + /** + * 이름(스키마/프로시저) SQL Injection 방지용 검증 + */ + private sanitizeName(name: string): string { + if (!/^[a-zA-Z0-9_]+$/.test(name)) { + throw new Error(`유효하지 않은 이름: ${name}`); + } + return name; + } + + /** + * 파라미터 모드 정규화 + */ + private normalizeParamMode(mode: string | null): "IN" | "OUT" | "INOUT" { + if (!mode) return "IN"; + const upper = mode.toUpperCase(); + if (upper === "OUT") return "OUT"; + if (upper === "INOUT") return "INOUT"; + return "IN"; + } +} diff --git a/backend-node/src/services/nodeFlowExecutionService.ts b/backend-node/src/services/nodeFlowExecutionService.ts index a5abe410..6f0848e2 100644 --- a/backend-node/src/services/nodeFlowExecutionService.ts +++ b/backend-node/src/services/nodeFlowExecutionService.ts @@ -11,6 +11,7 @@ import { query, queryOne, transaction } from "../database/db"; import { logger } from "../utils/logger"; import axios from "axios"; +import { FlowProcedureService } from "./flowProcedureService"; // ===== 타입 정의 ===== @@ -36,6 +37,7 @@ export type NodeType = | "emailAction" // 이메일 발송 액션 | "scriptAction" // 스크립트 실행 액션 | "httpRequestAction" // HTTP 요청 액션 + | "procedureCallAction" // 프로시저/함수 호출 액션 | "comment" | "log"; @@ -663,6 +665,9 @@ export class NodeFlowExecutionService { case "httpRequestAction": return this.executeHttpRequestAction(node, inputData, context); + case "procedureCallAction": + return this.executeProcedureCallAction(node, inputData, context, client); + case "comment": case "log": // 로그/코멘트는 실행 없이 통과 @@ -4856,4 +4861,105 @@ export class NodeFlowExecutionService { ); } } + + /** + * 프로시저/함수 호출 액션 노드 실행 + */ + private static async executeProcedureCallAction( + node: FlowNode, + inputData: any, + context: ExecutionContext, + client?: any + ): Promise { + const { + dbSource = "internal", + connectionId, + procedureName, + procedureSchema = "public", + callType = "function", + parameters = [], + } = node.data; + + logger.info( + `🔧 프로시저 호출 노드 실행: ${node.data.displayName || node.id}` + ); + logger.info( + ` 프로시저: ${procedureSchema}.${procedureName} (${callType}), DB: ${dbSource}` + ); + + if (!procedureName) { + throw new Error("프로시저/함수가 선택되지 않았습니다."); + } + + const dataArray = Array.isArray(inputData) + ? inputData + : inputData + ? [inputData] + : [{}]; + + const procedureService = new FlowProcedureService(); + const results: any[] = []; + + const config = { + type: "procedure" as const, + dbSource: dbSource as "internal" | "external", + connectionId, + procedureName, + procedureSchema, + callType: callType as "procedure" | "function", + parameters: parameters.map((p: any) => ({ + name: p.name, + dataType: p.dataType, + mode: p.mode || "IN", + source: p.source || "static", + field: p.field, + value: p.value, + })), + }; + + for (const record of dataArray) { + try { + logger.info(` 입력 레코드 키: ${Object.keys(record).join(", ")}`); + + const execResult = await procedureService.executeProcedure( + config, + record, + dbSource === "internal" ? client : undefined + ); + + logger.info(` ✅ 프로시저 실행 성공: ${procedureName}`); + + // 프로시저 반환값을 레코드에 평탄화하여 다음 노드에서 필드로 참조 가능하게 함 + let flatResult: Record = {}; + if (Array.isArray(execResult.result) && execResult.result.length > 0) { + const row = execResult.result[0]; + for (const [key, val] of Object.entries(row)) { + // 함수명과 동일한 키(SELECT fn() 결과)는 _procedureReturn으로 매핑 + if (key === procedureName) { + flatResult["_procedureReturn"] = val; + } else { + flatResult[key] = val; + } + } + logger.info(` 반환 필드: ${Object.keys(flatResult).join(", ")}`); + } + + results.push({ + ...record, + ...flatResult, + _procedureResult: execResult.result, + _procedureSuccess: true, + }); + } catch (error: any) { + logger.error(` ❌ 프로시저 실행 실패: ${error.message}`); + throw error; + } + } + + logger.info( + `🔧 프로시저 호출 완료: ${results.length}건 처리` + ); + + return results; + } } diff --git a/backend-node/src/types/flow.ts b/backend-node/src/types/flow.ts index 7cf9b9de..179eb26f 100644 --- a/backend-node/src/types/flow.ts +++ b/backend-node/src/types/flow.ts @@ -278,6 +278,7 @@ export interface SqlWhereResult { export type FlowIntegrationType = | "internal" // 내부 DB (기본값) | "external_db" // 외부 DB + | "procedure" // 프로시저/함수 호출 | "rest_api" // REST API (추후 구현) | "webhook" // Webhook (추후 구현) | "hybrid"; // 복합 연동 (추후 구현) @@ -341,8 +342,48 @@ export interface FlowExternalDbIntegrationConfig { customQuery?: string; // operation이 'custom'인 경우 사용 } +// 프로시저 호출 파라미터 정의 +export interface FlowProcedureParam { + name: string; + dataType: string; + mode: "IN" | "OUT" | "INOUT"; + source: "record_field" | "static" | "step_variable"; + field?: string; // source가 record_field인 경우: 레코드 컬럼명 + value?: string; // source가 static인 경우: 고정값 +} + +// 프로시저 호출 설정 (integration_config JSON) +export interface FlowProcedureConfig { + type: "procedure"; + dbSource: "internal" | "external"; + connectionId?: number; // 외부 DB인 경우 external_db_connections.id + procedureName: string; + procedureSchema?: string; // 스키마명 (기본: public) + callType: "procedure" | "function"; // CALL vs SELECT + parameters: FlowProcedureParam[]; +} + +// 프로시저/함수 목록 항목 +export interface ProcedureListItem { + name: string; + schema: string; + type: "PROCEDURE" | "FUNCTION"; + returnType?: string; +} + +// 프로시저 파라미터 정보 +export interface ProcedureParameterInfo { + name: string; + position: number; + dataType: string; + mode: "IN" | "OUT" | "INOUT"; + defaultValue?: string; +} + // 연동 설정 통합 타입 -export type FlowIntegrationConfig = FlowExternalDbIntegrationConfig; // 나중에 다른 타입 추가 +export type FlowIntegrationConfig = + | FlowExternalDbIntegrationConfig + | FlowProcedureConfig; // 연동 실행 컨텍스트 export interface FlowIntegrationContext { diff --git a/frontend/app/(main)/admin/automaticMng/flowMgmtList/[id]/page.tsx b/frontend/app/(main)/admin/automaticMng/flowMgmtList/[id]/page.tsx index b8d14e19..c492a526 100644 --- a/frontend/app/(main)/admin/automaticMng/flowMgmtList/[id]/page.tsx +++ b/frontend/app/(main)/admin/automaticMng/flowMgmtList/[id]/page.tsx @@ -130,6 +130,8 @@ export default function FlowEditorPage() { tableName: step.tableName, count: stepCounts[step.id] || 0, condition: step.conditionJson, + integrationType: (step as any).integrationType, + procedureName: (step as any).integrationConfig?.procedureName, }, })); diff --git a/frontend/components/dataflow/node-editor/FlowEditor.tsx b/frontend/components/dataflow/node-editor/FlowEditor.tsx index 07531249..60a92803 100644 --- a/frontend/components/dataflow/node-editor/FlowEditor.tsx +++ b/frontend/components/dataflow/node-editor/FlowEditor.tsx @@ -32,6 +32,7 @@ import { LogNode } from "./nodes/LogNode"; import { EmailActionNode } from "./nodes/EmailActionNode"; import { ScriptActionNode } from "./nodes/ScriptActionNode"; import { HttpRequestActionNode } from "./nodes/HttpRequestActionNode"; +import { ProcedureCallActionNode } from "./nodes/ProcedureCallActionNode"; import { validateFlow } from "@/lib/utils/flowValidation"; import type { FlowValidation } from "@/lib/utils/flowValidation"; @@ -55,6 +56,7 @@ const nodeTypes = { emailAction: EmailActionNode, scriptAction: ScriptActionNode, httpRequestAction: HttpRequestActionNode, + procedureCallAction: ProcedureCallActionNode, // 유틸리티 comment: CommentNode, log: LogNode, diff --git a/frontend/components/dataflow/node-editor/nodes/ProcedureCallActionNode.tsx b/frontend/components/dataflow/node-editor/nodes/ProcedureCallActionNode.tsx new file mode 100644 index 00000000..d54478a9 --- /dev/null +++ b/frontend/components/dataflow/node-editor/nodes/ProcedureCallActionNode.tsx @@ -0,0 +1,121 @@ +"use client"; + +/** + * 프로시저/함수 호출 액션 노드 + * 내부 또는 외부 DB의 프로시저/함수를 호출하는 노드 + */ + +import { memo } from "react"; +import { Handle, Position, NodeProps } from "reactflow"; +import { Database, Workflow } from "lucide-react"; +import type { ProcedureCallActionNodeData } from "@/types/node-editor"; + +export const ProcedureCallActionNode = memo( + ({ data, selected }: NodeProps) => { + const hasProcedure = !!data.procedureName; + const inParams = data.parameters?.filter((p) => p.mode === "IN" || p.mode === "INOUT") ?? []; + const outParams = data.parameters?.filter((p) => p.mode === "OUT" || p.mode === "INOUT") ?? []; + + return ( +
+ {/* 입력 핸들 */} + + + {/* 헤더 */} +
+ +
+
+ {data.displayName || "프로시저 호출"} +
+
+
+ + {/* 본문 */} +
+ {/* DB 소스 */} +
+ + + {data.dbSource === "external" ? ( + + {data.connectionName || "외부 DB"} + + ) : ( + + 내부 DB + + )} + + + {data.callType === "function" ? "FUNCTION" : "PROCEDURE"} + +
+ + {/* 프로시저명 */} +
+ + {hasProcedure ? ( + + {data.procedureSchema && data.procedureSchema !== "public" + ? `${data.procedureSchema}.` + : ""} + {data.procedureName}() + + ) : ( + 프로시저 선택 필요 + )} +
+ + {/* 파라미터 수 */} + {hasProcedure && inParams.length > 0 && ( +
+ 입력 파라미터: {inParams.length}개 +
+ )} + + {/* 반환 필드 */} + {hasProcedure && outParams.length > 0 && ( +
+
+ 반환 필드: +
+ {outParams.map((p) => ( +
+ {p.name} + {p.dataType} +
+ ))} +
+ )} +
+ + {/* 출력 핸들 */} + +
+ ); + } +); + +ProcedureCallActionNode.displayName = "ProcedureCallActionNode"; diff --git a/frontend/components/dataflow/node-editor/panels/PropertiesPanel.tsx b/frontend/components/dataflow/node-editor/panels/PropertiesPanel.tsx index e62bab9f..316f93b0 100644 --- a/frontend/components/dataflow/node-editor/panels/PropertiesPanel.tsx +++ b/frontend/components/dataflow/node-editor/panels/PropertiesPanel.tsx @@ -23,6 +23,7 @@ import { LogProperties } from "./properties/LogProperties"; import { EmailActionProperties } from "./properties/EmailActionProperties"; import { ScriptActionProperties } from "./properties/ScriptActionProperties"; import { HttpRequestActionProperties } from "./properties/HttpRequestActionProperties"; +import { ProcedureCallActionProperties } from "./properties/ProcedureCallActionProperties"; import type { NodeType } from "@/types/node-editor"; export function PropertiesPanel() { @@ -147,6 +148,9 @@ function NodePropertiesRenderer({ node }: { node: any }) { case "httpRequestAction": return ; + case "procedureCallAction": + return ; + default: return (
@@ -185,6 +189,7 @@ function getNodeTypeLabel(type: NodeType): string { emailAction: "메일 발송", scriptAction: "스크립트 실행", httpRequestAction: "HTTP 요청", + procedureCallAction: "프로시저 호출", comment: "주석", log: "로그", }; diff --git a/frontend/components/dataflow/node-editor/panels/properties/InsertActionProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/InsertActionProperties.tsx index c68ff8d4..513f0338 100644 --- a/frontend/components/dataflow/node-editor/panels/properties/InsertActionProperties.tsx +++ b/frontend/components/dataflow/node-editor/panels/properties/InsertActionProperties.tsx @@ -20,6 +20,7 @@ import { tableTypeApi } from "@/lib/api/screen"; import { getTestedExternalConnections, getExternalTables, getExternalColumns } from "@/lib/api/nodeExternalConnections"; import { getNumberingRules } from "@/lib/api/numberingRule"; import type { NumberingRuleConfig } from "@/types/numbering-rule"; +import { getFlowProcedureParameters } from "@/lib/api/flow"; import type { InsertActionNodeData } from "@/types/node-editor"; import type { ExternalConnection, ExternalTable, ExternalColumn } from "@/lib/api/nodeExternalConnections"; @@ -171,10 +172,19 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP // 연결된 소스 노드에서 필드 가져오기 (재귀적으로 모든 상위 노드 탐색) useEffect(() => { + // 프로시저 노드 정보를 수집하여 비동기 파라미터 조회에 사용 + const procedureNodes: Array<{ + procedureName: string; + dbSource: "internal" | "external"; + connectionId?: number; + schema?: string; + sourcePath: string[]; + }> = []; + const getAllSourceFields = ( targetNodeId: string, visitedNodes = new Set(), - sourcePath: string[] = [], // 🔥 소스 경로 추적 + sourcePath: string[] = [], ): { fields: Array<{ name: string; label?: string; sourcePath?: string[] }>; hasRestAPI: boolean } => { if (visitedNodes.has(targetNodeId)) { console.log(`⚠️ 순환 참조 감지: ${targetNodeId} (이미 방문함)`); @@ -366,7 +376,48 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP foundRestAPI = foundRestAPI || upperResult.hasRestAPI; } } - // 5️⃣ 통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색 + // 5️⃣ 프로시저 호출 노드: 상위 필드 + OUT 파라미터(반환 필드) 추가 + else if (node.type === "procedureCallAction") { + console.log("✅ 프로시저 호출 노드 발견"); + const upperResult = getAllSourceFields(node.id, visitedNodes, currentPath); + fields.push(...upperResult.fields); + foundRestAPI = foundRestAPI || upperResult.hasRestAPI; + + const nodeData = node.data as any; + const procParams = nodeData.parameters; + let hasOutParams = false; + + if (Array.isArray(procParams)) { + for (const p of procParams) { + if (p.mode === "OUT" || p.mode === "INOUT") { + hasOutParams = true; + fields.push({ + name: p.name, + label: `${p.name} (프로시저 반환)`, + sourcePath: currentPath, + }); + } + } + } + + // OUT 파라미터가 저장되어 있지 않으면 API로 동적 조회 예약 + if (!hasOutParams && nodeData.procedureName) { + procedureNodes.push({ + procedureName: nodeData.procedureName, + dbSource: nodeData.dbSource || "internal", + connectionId: nodeData.connectionId, + schema: nodeData.procedureSchema || "public", + sourcePath: currentPath, + }); + } + + fields.push({ + name: "_procedureReturn", + label: "프로시저 반환값", + sourcePath: currentPath, + }); + } + // 6️⃣ 통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색 else { console.log(`✅ 통과 노드 (${node.type}) → 상위 노드로 계속 탐색`); const upperResult = getAllSourceFields(node.id, visitedNodes, currentPath); @@ -386,31 +437,66 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP console.log(` - 총 필드 수: ${result.fields.length}개`); console.log(` - REST API 포함: ${result.hasRestAPI}`); - // 🔥 중복 제거 개선: 필드명이 같아도 소스가 다르면 모두 표시 - const fieldMap = new Map(); - const duplicateFields = new Set(); + const applyFields = (allFields: typeof result.fields) => { + const fieldMap = new Map(); + const duplicateFields = new Set(); - result.fields.forEach((field) => { - const key = `${field.name}`; - if (fieldMap.has(key)) { - duplicateFields.add(field.name); + allFields.forEach((field) => { + const key = `${field.name}`; + if (fieldMap.has(key)) { + duplicateFields.add(field.name); + } + fieldMap.set(key, field); + }); + + if (duplicateFields.size > 0) { + console.warn(`⚠️ 중복 필드명 감지: ${Array.from(duplicateFields).join(", ")}`); } - // 중복이면 마지막 값으로 덮어씀 (기존 동작 유지) - fieldMap.set(key, field); - }); - if (duplicateFields.size > 0) { - console.warn(`⚠️ 중복 필드명 감지: ${Array.from(duplicateFields).join(", ")}`); - console.warn(" → 마지막으로 발견된 필드만 표시됩니다."); - console.warn(" → 다중 소스 사용 시 필드명이 겹치지 않도록 주의하세요!"); + const uniqueFields = Array.from(fieldMap.values()); + setSourceFields(uniqueFields); + setHasRestAPISource(result.hasRestAPI); + console.log("✅ 최종 소스 필드 목록:", uniqueFields); + }; + + // 프로시저 노드에 OUT 파라미터가 저장되지 않은 경우, API로 동적 조회 + if (procedureNodes.length > 0) { + console.log(`🔄 프로시저 ${procedureNodes.length}개의 반환 필드를 API로 조회`); + applyFields(result.fields); + + Promise.all( + procedureNodes.map(async (pn) => { + try { + const res = await getFlowProcedureParameters( + pn.procedureName, + pn.dbSource, + pn.connectionId, + pn.schema + ); + if (res.success && res.data) { + return res.data + .filter((p: any) => p.mode === "OUT" || p.mode === "INOUT") + .map((p: any) => ({ + name: p.name, + label: `${p.name} (프로시저 반환)`, + sourcePath: pn.sourcePath, + })); + } + } catch (e) { + console.error("프로시저 파라미터 조회 실패:", e); + } + return []; + }) + ).then((extraFieldArrays) => { + const extraFields = extraFieldArrays.flat(); + if (extraFields.length > 0) { + console.log(`✅ 프로시저 반환 필드 ${extraFields.length}개 추가 발견`); + applyFields([...result.fields, ...extraFields]); + } + }); + } else { + applyFields(result.fields); } - - const uniqueFields = Array.from(fieldMap.values()); - - setSourceFields(uniqueFields); - setHasRestAPISource(result.hasRestAPI); - console.log("✅ 최종 소스 필드 목록:", uniqueFields); - console.log("✅ REST API 소스 연결:", result.hasRestAPI); }, [nodeId, nodes, edges]); /** diff --git a/frontend/components/dataflow/node-editor/panels/properties/ProcedureCallActionProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/ProcedureCallActionProperties.tsx new file mode 100644 index 00000000..ac7bd438 --- /dev/null +++ b/frontend/components/dataflow/node-editor/panels/properties/ProcedureCallActionProperties.tsx @@ -0,0 +1,641 @@ +"use client"; + +/** + * 프로시저/함수 호출 노드 속성 편집 + */ + +import { useEffect, useState, useCallback } from "react"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Database, Workflow, RefreshCw, Loader2 } from "lucide-react"; +import { useFlowEditorStore } from "@/lib/stores/flowEditorStore"; +import { + getFlowProcedures, + getFlowProcedureParameters, +} from "@/lib/api/flow"; +import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection"; +import type { ProcedureCallActionNodeData } from "@/types/node-editor"; +import type { ProcedureListItem, ProcedureParameterInfo } from "@/types/flowExternalDb"; + +interface ExternalConnection { + id: number; + connection_name: string; + db_type: string; +} + +interface ProcedureCallActionPropertiesProps { + nodeId: string; + data: ProcedureCallActionNodeData; +} + +export function ProcedureCallActionProperties({ + nodeId, + data, +}: ProcedureCallActionPropertiesProps) { + const { updateNode, nodes, edges } = useFlowEditorStore(); + + const [displayName, setDisplayName] = useState( + data.displayName || "프로시저 호출" + ); + const [dbSource, setDbSource] = useState<"internal" | "external">( + data.dbSource || "internal" + ); + const [connectionId, setConnectionId] = useState( + data.connectionId + ); + const [procedureName, setProcedureName] = useState( + data.procedureName || "" + ); + const [procedureSchema, setProcedureSchema] = useState( + data.procedureSchema || "public" + ); + const [callType, setCallType] = useState<"procedure" | "function">( + data.callType || "function" + ); + const [parameters, setParameters] = useState(data.parameters || []); + + const [connections, setConnections] = useState([]); + const [procedures, setProcedures] = useState([]); + const [loadingProcedures, setLoadingProcedures] = useState(false); + const [loadingParams, setLoadingParams] = useState(false); + const [sourceFields, setSourceFields] = useState< + Array<{ name: string; label?: string }> + >([]); + + // 이전 노드에서 소스 필드 목록 수집 (재귀) + useEffect(() => { + const getUpstreamFields = ( + targetId: string, + visited = new Set() + ): Array<{ name: string; label?: string }> => { + if (visited.has(targetId)) return []; + visited.add(targetId); + + const inEdges = edges.filter((e) => e.target === targetId); + const parentNodes = nodes.filter((n) => + inEdges.some((e) => e.source === n.id) + ); + const fields: Array<{ name: string; label?: string }> = []; + + for (const pNode of parentNodes) { + if ( + pNode.type === "tableSource" || + pNode.type === "externalDBSource" + ) { + const nodeFields = + (pNode.data as any).fields || + (pNode.data as any).outputFields || + []; + if (Array.isArray(nodeFields)) { + for (const f of nodeFields) { + const name = + typeof f === "string" + ? f + : f.name || f.columnName || f.field; + if (name) { + fields.push({ + name, + label: f.label || f.columnLabel || name, + }); + } + } + } + } else if (pNode.type === "dataTransform") { + const upper = getUpstreamFields(pNode.id, visited); + fields.push(...upper); + const transforms = (pNode.data as any).transformations; + if (Array.isArray(transforms)) { + for (const t of transforms) { + if (t.targetField) { + fields.push({ + name: t.targetField, + label: t.targetFieldLabel || t.targetField, + }); + } + } + } + } else if (pNode.type === "formulaTransform") { + const upper = getUpstreamFields(pNode.id, visited); + fields.push(...upper); + const transforms = (pNode.data as any).transformations; + if (Array.isArray(transforms)) { + for (const t of transforms) { + if (t.outputField) { + fields.push({ + name: t.outputField, + label: t.outputFieldLabel || t.outputField, + }); + } + } + } + } else { + fields.push(...getUpstreamFields(pNode.id, visited)); + } + } + + return fields; + }; + + const collected = getUpstreamFields(nodeId); + const unique = Array.from( + new Map(collected.map((f) => [f.name, f])).values() + ); + setSourceFields(unique); + }, [nodeId, nodes, edges]); + + useEffect(() => { + setDisplayName(data.displayName || "프로시저 호출"); + setDbSource(data.dbSource || "internal"); + setConnectionId(data.connectionId); + setProcedureName(data.procedureName || ""); + setProcedureSchema(data.procedureSchema || "public"); + setCallType(data.callType || "function"); + setParameters(data.parameters || []); + }, [data]); + + // 외부 DB 연결 목록 조회 + useEffect(() => { + if (dbSource === "external") { + ExternalDbConnectionAPI.getConnections({ is_active: "true" }) + .then((list) => + setConnections( + list.map((c: any) => ({ + id: c.id, + connection_name: c.connection_name, + db_type: c.db_type, + })) + ) + ) + .catch(console.error); + } + }, [dbSource]); + + const updateNodeData = useCallback( + (updates: Partial) => { + updateNode(nodeId, { ...data, ...updates }); + }, + [nodeId, data, updateNode] + ); + + // 프로시저 목록 조회 + const fetchProcedures = useCallback(async () => { + if (dbSource === "external" && !connectionId) return; + setLoadingProcedures(true); + try { + const res = await getFlowProcedures( + dbSource, + connectionId, + procedureSchema || undefined + ); + if (res.success && res.data) { + setProcedures(res.data); + } + } catch (e) { + console.error("프로시저 목록 조회 실패:", e); + } finally { + setLoadingProcedures(false); + } + }, [dbSource, connectionId, procedureSchema]); + + // dbSource/connectionId 변경 시 프로시저 목록 자동 조회 + useEffect(() => { + if (dbSource === "internal" || (dbSource === "external" && connectionId)) { + fetchProcedures(); + } + }, [dbSource, connectionId, fetchProcedures]); + + // 프로시저 선택 시 파라미터 조회 + const handleProcedureSelect = useCallback( + async (name: string) => { + setProcedureName(name); + + const selected = procedures.find((p) => p.name === name); + const newCallType = + selected?.type === "PROCEDURE" ? "procedure" : "function"; + setCallType(newCallType); + + updateNodeData({ + procedureName: name, + callType: newCallType, + procedureSchema, + }); + + setLoadingParams(true); + try { + const res = await getFlowProcedureParameters( + name, + dbSource, + connectionId, + procedureSchema || undefined + ); + if (res.success && res.data) { + const newParams = res.data.map((p: ProcedureParameterInfo) => ({ + name: p.name, + dataType: p.dataType, + mode: p.mode, + source: "record_field" as const, + field: "", + value: "", + })); + setParameters(newParams); + updateNodeData({ + procedureName: name, + callType: newCallType, + procedureSchema, + parameters: newParams, + }); + } + } catch (e) { + console.error("파라미터 조회 실패:", e); + } finally { + setLoadingParams(false); + } + }, + [dbSource, connectionId, procedureSchema, procedures, updateNodeData] + ); + + const handleParamChange = ( + index: number, + field: string, + value: string + ) => { + const newParams = [...parameters]; + (newParams[index] as any)[field] = value; + setParameters(newParams); + updateNodeData({ parameters: newParams }); + }; + + return ( +
+ {/* 표시명 */} +
+ + { + setDisplayName(e.target.value); + updateNodeData({ displayName: e.target.value }); + }} + placeholder="프로시저 호출" + className="h-8 text-sm" + /> +
+ + {/* DB 소스 */} +
+ + +
+ + {/* 외부 DB 연결 선택 */} + {dbSource === "external" && ( +
+ + +
+ )} + + {/* 스키마 */} +
+ +
+ setProcedureSchema(e.target.value)} + onBlur={() => { + updateNodeData({ procedureSchema }); + fetchProcedures(); + }} + placeholder="public" + className="h-8 text-sm" + /> + +
+
+ + {/* 프로시저 선택 */} +
+ + {loadingProcedures ? ( +
+ + 목록 조회 중... +
+ ) : ( + + )} +
+ + {/* 호출 타입 */} + {procedureName && ( +
+ + +
+ )} + + {/* 파라미터 매핑 */} + {procedureName && parameters.length > 0 && ( +
+ {loadingParams ? ( +
+ + 파라미터 조회 중... +
+ ) : ( + <> + {/* IN 파라미터 */} + {parameters.filter((p) => p.mode === "IN" || p.mode === "INOUT") + .length > 0 && ( +
+ +
+ {parameters.map((param, idx) => { + if (param.mode !== "IN" && param.mode !== "INOUT") + return null; + return ( + + +
+ + {param.name} + + + {param.dataType} + +
+ + {param.source === "record_field" && + (sourceFields.length > 0 ? ( + + ) : ( + + handleParamChange( + idx, + "field", + e.target.value + ) + } + placeholder="컬럼명 (이전 노드를 먼저 연결하세요)" + className="h-7 text-xs" + /> + ))} + {param.source === "static" && ( + + handleParamChange( + idx, + "value", + e.target.value + ) + } + placeholder="고정값 입력" + className="h-7 text-xs" + /> + )} + {param.source === "step_variable" && ( + + handleParamChange( + idx, + "field", + e.target.value + ) + } + placeholder="변수명" + className="h-7 text-xs" + /> + )} +
+
+ ); + })} +
+
+ )} + + {/* OUT 파라미터 (반환 필드) */} + {parameters.filter((p) => p.mode === "OUT" || p.mode === "INOUT") + .length > 0 && ( +
+ +
+
+ {parameters + .filter( + (p) => p.mode === "OUT" || p.mode === "INOUT" + ) + .map((param, idx) => ( +
+ + {param.name} + + + {param.dataType} + +
+ ))} +
+
+
+ )} + + )} +
+ )} + + {/* 안내 메시지 */} + + +
+ + 프로시저 실행 안내 +
+

+ 이 노드에 연결된 이전 노드의 데이터가 프로시저의 입력 파라미터로 + 전달됩니다. 프로시저 실행이 실패하면 전체 트랜잭션이 롤백됩니다. +

+
+
+
+ ); +} diff --git a/frontend/components/dataflow/node-editor/panels/properties/UpdateActionProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/UpdateActionProperties.tsx index 1fd6b723..573d6fdf 100644 --- a/frontend/components/dataflow/node-editor/panels/properties/UpdateActionProperties.tsx +++ b/frontend/components/dataflow/node-editor/panels/properties/UpdateActionProperties.tsx @@ -17,6 +17,7 @@ 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 { getFlowProcedureParameters } from "@/lib/api/flow"; import type { UpdateActionNodeData } from "@/types/node-editor"; import type { ExternalConnection, ExternalTable, ExternalColumn } from "@/lib/api/nodeExternalConnections"; @@ -165,6 +166,13 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP // 연결된 소스 노드에서 필드 가져오기 (재귀적으로 모든 상위 노드 탐색) useEffect(() => { + const procedureNodes: Array<{ + procedureName: string; + dbSource: "internal" | "external"; + connectionId?: number; + schema?: string; + }> = []; + const getAllSourceFields = ( targetNodeId: string, visitedNodes = new Set(), @@ -310,7 +318,33 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP foundRestAPI = foundRestAPI || upperResult.hasRestAPI; } } - // 5️⃣ 통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색 + // 5️⃣ 프로시저 호출 노드: 상위 필드 + OUT 파라미터 추가 + else if (node.type === "procedureCallAction") { + const upperResult = getAllSourceFields(node.id, visitedNodes); + fields.push(...upperResult.fields); + foundRestAPI = foundRestAPI || upperResult.hasRestAPI; + const nodeData = node.data as any; + const procParams = nodeData.parameters; + let hasOutParams = false; + if (Array.isArray(procParams)) { + for (const p of procParams) { + if (p.mode === "OUT" || p.mode === "INOUT") { + hasOutParams = true; + fields.push({ name: p.name, label: `${p.name} (프로시저 반환)` }); + } + } + } + if (!hasOutParams && nodeData.procedureName) { + procedureNodes.push({ + procedureName: nodeData.procedureName, + dbSource: nodeData.dbSource || "internal", + connectionId: nodeData.connectionId, + schema: nodeData.procedureSchema || "public", + }); + } + fields.push({ name: "_procedureReturn", label: "프로시저 반환값" }); + } + // 6️⃣ 통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색 else { const upperResult = getAllSourceFields(node.id, visitedNodes); fields.push(...upperResult.fields); @@ -323,11 +357,33 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP const result = getAllSourceFields(nodeId); - // 중복 제거 - const uniqueFields = Array.from(new Map(result.fields.map((field) => [field.name, field])).values()); + const applyFields = (allFields: typeof result.fields) => { + const uniqueFields = Array.from(new Map(allFields.map((field) => [field.name, field])).values()); + setSourceFields(uniqueFields); + setHasRestAPISource(result.hasRestAPI); + }; - setSourceFields(uniqueFields); - setHasRestAPISource(result.hasRestAPI); + if (procedureNodes.length > 0) { + applyFields(result.fields); + Promise.all( + procedureNodes.map(async (pn) => { + try { + const res = await getFlowProcedureParameters(pn.procedureName, pn.dbSource, pn.connectionId, pn.schema); + if (res.success && res.data) { + return res.data + .filter((p: any) => p.mode === "OUT" || p.mode === "INOUT") + .map((p: any) => ({ name: p.name, label: `${p.name} (프로시저 반환)` })); + } + } catch (e) { console.error("프로시저 파라미터 조회 실패:", e); } + return []; + }) + ).then((extraFieldArrays) => { + const extraFields = extraFieldArrays.flat(); + if (extraFields.length > 0) applyFields([...result.fields, ...extraFields]); + }); + } else { + applyFields(result.fields); + } }, [nodeId, nodes, edges]); const loadTables = async () => { diff --git a/frontend/components/dataflow/node-editor/panels/properties/UpsertActionProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/UpsertActionProperties.tsx index 283640d1..bcb52500 100644 --- a/frontend/components/dataflow/node-editor/panels/properties/UpsertActionProperties.tsx +++ b/frontend/components/dataflow/node-editor/panels/properties/UpsertActionProperties.tsx @@ -17,6 +17,7 @@ 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 { getFlowProcedureParameters } from "@/lib/api/flow"; import type { UpsertActionNodeData } from "@/types/node-editor"; import type { ExternalConnection, ExternalTable, ExternalColumn } from "@/lib/api/nodeExternalConnections"; @@ -148,6 +149,13 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP // 연결된 소스 노드에서 필드 가져오기 (재귀적으로 모든 상위 노드 탐색) useEffect(() => { + const procedureNodes: Array<{ + procedureName: string; + dbSource: "internal" | "external"; + connectionId?: number; + schema?: string; + }> = []; + const getAllSourceFields = ( targetNodeId: string, visitedNodes = new Set(), @@ -293,7 +301,33 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP foundRestAPI = foundRestAPI || upperResult.hasRestAPI; } } - // 5️⃣ 통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색 + // 5️⃣ 프로시저 호출 노드: 상위 필드 + OUT 파라미터 추가 + else if (node.type === "procedureCallAction") { + const upperResult = getAllSourceFields(node.id, visitedNodes); + fields.push(...upperResult.fields); + foundRestAPI = foundRestAPI || upperResult.hasRestAPI; + const nodeData = node.data as any; + const procParams = nodeData.parameters; + let hasOutParams = false; + if (Array.isArray(procParams)) { + for (const p of procParams) { + if (p.mode === "OUT" || p.mode === "INOUT") { + hasOutParams = true; + fields.push({ name: p.name, label: `${p.name} (프로시저 반환)` }); + } + } + } + if (!hasOutParams && nodeData.procedureName) { + procedureNodes.push({ + procedureName: nodeData.procedureName, + dbSource: nodeData.dbSource || "internal", + connectionId: nodeData.connectionId, + schema: nodeData.procedureSchema || "public", + }); + } + fields.push({ name: "_procedureReturn", label: "프로시저 반환값" }); + } + // 6️⃣ 통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색 else { const upperResult = getAllSourceFields(node.id, visitedNodes); fields.push(...upperResult.fields); @@ -306,11 +340,33 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP const result = getAllSourceFields(nodeId); - // 중복 제거 - const uniqueFields = Array.from(new Map(result.fields.map((field) => [field.name, field])).values()); + const applyFields = (allFields: typeof result.fields) => { + const uniqueFields = Array.from(new Map(allFields.map((field) => [field.name, field])).values()); + setSourceFields(uniqueFields); + setHasRestAPISource(result.hasRestAPI); + }; - setSourceFields(uniqueFields); - setHasRestAPISource(result.hasRestAPI); + if (procedureNodes.length > 0) { + applyFields(result.fields); + Promise.all( + procedureNodes.map(async (pn) => { + try { + const res = await getFlowProcedureParameters(pn.procedureName, pn.dbSource, pn.connectionId, pn.schema); + if (res.success && res.data) { + return res.data + .filter((p: any) => p.mode === "OUT" || p.mode === "INOUT") + .map((p: any) => ({ name: p.name, label: `${p.name} (프로시저 반환)` })); + } + } catch (e) { console.error("프로시저 파라미터 조회 실패:", e); } + return []; + }) + ).then((extraFieldArrays) => { + const extraFields = extraFieldArrays.flat(); + if (extraFields.length > 0) applyFields([...result.fields, ...extraFields]); + }); + } else { + applyFields(result.fields); + } }, [nodeId, nodes, edges]); // 🔥 외부 커넥션 로딩 함수 diff --git a/frontend/components/dataflow/node-editor/sidebar/nodePaletteConfig.ts b/frontend/components/dataflow/node-editor/sidebar/nodePaletteConfig.ts index 334d150e..05cbc525 100644 --- a/frontend/components/dataflow/node-editor/sidebar/nodePaletteConfig.ts +++ b/frontend/components/dataflow/node-editor/sidebar/nodePaletteConfig.ts @@ -132,6 +132,14 @@ export const NODE_PALETTE: NodePaletteItem[] = [ category: "external", color: "#06B6D4", // 시안 }, + { + type: "procedureCallAction", + label: "프로시저 호출", + icon: "", + description: "DB 프로시저/함수를 호출합니다", + category: "external", + color: "#8B5CF6", // 보라색 + }, // ======================================================================== // 유틸리티 diff --git a/frontend/components/flow/FlowNodeComponent.tsx b/frontend/components/flow/FlowNodeComponent.tsx index 8482442d..d52291bb 100644 --- a/frontend/components/flow/FlowNodeComponent.tsx +++ b/frontend/components/flow/FlowNodeComponent.tsx @@ -63,6 +63,11 @@ export const FlowNodeComponent = memo(({ data }: NodeProps) => { 단계 {data.stepOrder} + {data.integrationType === "procedure" && ( + + SP + + )}
{data.label}
@@ -75,6 +80,13 @@ export const FlowNodeComponent = memo(({ data }: NodeProps) => { )} + {/* 프로시저 정보 */} + {data.integrationType === "procedure" && data.procedureName && ( +
+ {data.procedureName}() +
+ )} + {/* 데이터 건수 */} {data.count !== undefined && ( diff --git a/frontend/components/flow/FlowStepPanel.tsx b/frontend/components/flow/FlowStepPanel.tsx index d861f97b..311357f1 100644 --- a/frontend/components/flow/FlowStepPanel.tsx +++ b/frontend/components/flow/FlowStepPanel.tsx @@ -13,7 +13,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { useToast } from "@/hooks/use-toast"; -import { updateFlowStep, deleteFlowStep } from "@/lib/api/flow"; +import { updateFlowStep, deleteFlowStep, getFlowProcedures, getFlowProcedureParameters } from "@/lib/api/flow"; import { FlowStep } from "@/types/flow"; import { FlowConditionBuilder } from "./FlowConditionBuilder"; import { tableManagementApi, getTableColumns } from "@/lib/api/tableManagement"; @@ -23,6 +23,10 @@ import { flowExternalDbApi } from "@/lib/api/flowExternalDb"; import { FlowExternalDbConnection, FlowExternalDbIntegrationConfig, + FlowProcedureConfig, + FlowProcedureParam, + ProcedureListItem, + ProcedureParameterInfo, INTEGRATION_TYPE_OPTIONS, OPERATION_OPTIONS, } from "@/types/flowExternalDb"; @@ -118,6 +122,13 @@ export function FlowStepPanel({ const [availableColumns, setAvailableColumns] = useState([]); const [loadingAvailableColumns, setLoadingAvailableColumns] = useState(false); + // 프로시저 관련 상태 + const [procedureList, setProcedureList] = useState([]); + const [loadingProcedures, setLoadingProcedures] = useState(false); + const [procedureParams, setProcedureParams] = useState([]); + const [loadingProcedureParams, setLoadingProcedureParams] = useState(false); + const [openProcedureCombobox, setOpenProcedureCombobox] = useState(false); + // 테이블 목록 조회 useEffect(() => { const loadTables = async () => { @@ -943,7 +954,7 @@ export function FlowStepPanel({ {opt.label} @@ -1262,6 +1273,370 @@ export function FlowStepPanel({ )} )} + + {/* 프로시저/함수 호출 설정 */} + {formData.integrationType === "procedure" && ( +
+ {/* DB 소스 선택 */} +
+ + +
+ + {/* 외부 DB 연결 선택 */} + {(formData.integrationConfig as FlowProcedureConfig)?.dbSource === "external" && ( +
+ + {externalConnections.length === 0 ? ( +
+

+ 등록된 외부 DB 연결이 없습니다. 먼저 외부 DB 연결을 추가해주세요. +

+
+ ) : ( + + )} +
+ )} + + {/* 프로시저 선택 */} + {((formData.integrationConfig as FlowProcedureConfig)?.dbSource === "internal" || + (formData.integrationConfig as FlowProcedureConfig)?.connectionId) && ( + <> +
+ + {loadingProcedures ? ( +
로딩 중...
+ ) : ( + + + + + + + + + + 프로시저를 찾을 수 없습니다. + + + {procedureList.map((proc) => ( + { + const procConfig = formData.integrationConfig as FlowProcedureConfig; + const newConfig: FlowProcedureConfig = { + ...procConfig, + procedureName: proc.name, + procedureSchema: proc.schema, + callType: proc.type === "PROCEDURE" ? "procedure" : "function", + parameters: [], + }; + setFormData({ ...formData, integrationConfig: newConfig }); + setOpenProcedureCombobox(false); + + setLoadingProcedureParams(true); + try { + const res = await getFlowProcedureParameters( + proc.name, + procConfig.dbSource, + procConfig.connectionId, + proc.schema, + ); + if (res.success && res.data) { + setProcedureParams(res.data); + const mappedParams: FlowProcedureParam[] = res.data.map((p) => ({ + name: p.name, + dataType: p.dataType, + mode: p.mode, + source: "record_field" as const, + field: "", + value: "", + })); + setFormData((prev) => ({ + ...prev, + integrationConfig: { + ...(prev.integrationConfig as FlowProcedureConfig), + procedureName: proc.name, + procedureSchema: proc.schema, + callType: proc.type === "PROCEDURE" ? "procedure" : "function", + parameters: mappedParams, + }, + })); + } + } catch (e) { + console.error("파라미터 조회 실패:", e); + } finally { + setLoadingProcedureParams(false); + } + }} + className="text-xs sm:text-sm" + > + +
+ {proc.name} + + {proc.type} | {proc.schema} + {proc.returnType ? ` | 반환: ${proc.returnType}` : ""} + +
+
+ ))} +
+
+
+
+
+ )} + {procedureList.length === 0 && !loadingProcedures && ( + + )} +
+ + {/* 호출 타입 */} + {(formData.integrationConfig as FlowProcedureConfig)?.procedureName && ( +
+ + +
+ )} + + {/* 파라미터 매핑 테이블 */} + {(formData.integrationConfig as FlowProcedureConfig)?.procedureName && ( +
+ + {loadingProcedureParams ? ( +
파라미터 로딩 중...
+ ) : (formData.integrationConfig as FlowProcedureConfig)?.parameters?.length === 0 ? ( +
+

파라미터가 없는 프로시저입니다.

+
+ ) : ( +
+ {(formData.integrationConfig as FlowProcedureConfig)?.parameters?.map((param, idx) => ( +
+
+ {param.name} + + {param.dataType} | {param.mode} + +
+ {param.mode !== "OUT" && ( +
+
+ + +
+
+ + { + const params = [...(formData.integrationConfig as FlowProcedureConfig).parameters]; + if (param.source === "static") { + params[idx] = { ...params[idx], value: e.target.value }; + } else { + params[idx] = { ...params[idx], field: e.target.value }; + } + setFormData({ + ...formData, + integrationConfig: { + ...(formData.integrationConfig as FlowProcedureConfig), + parameters: params, + }, + }); + }} + /> +
+
+ )} +
+ ))} +
+ )} +
+ )} + +
+

+ 프로시저는 데이터 이동 전에 실행됩니다. +
실패 시 데이터 이동도 함께 롤백됩니다. +

+
+ + )} +
+ )} diff --git a/frontend/lib/api/flow.ts b/frontend/lib/api/flow.ts index 3cb835cd..c6c69a22 100644 --- a/frontend/lib/api/flow.ts +++ b/frontend/lib/api/flow.ts @@ -561,3 +561,61 @@ export async function updateFlowStepData( }; } } + +// ============================================ +// 프로시저/함수 API +// ============================================ + +import type { ProcedureListItem, ProcedureParameterInfo } from "@/types/flowExternalDb"; + +/** + * 프로시저/함수 목록 조회 + */ +export async function getFlowProcedures( + dbSource: "internal" | "external", + connectionId?: number, + schema?: string, +): Promise> { + try { + const params = new URLSearchParams({ dbSource }); + if (connectionId) params.set("connectionId", String(connectionId)); + if (schema) params.set("schema", schema); + + const response = await fetch(`${API_BASE}/flow/procedures?${params.toString()}`, { + headers: getAuthHeaders(), + credentials: "include", + }); + + return await response.json(); + } catch (error: any) { + return { success: false, error: error.message }; + } +} + +/** + * 프로시저/함수 파라미터 조회 + */ +export async function getFlowProcedureParameters( + name: string, + dbSource: "internal" | "external", + connectionId?: number, + schema?: string, +): Promise> { + try { + const params = new URLSearchParams({ dbSource }); + if (connectionId) params.set("connectionId", String(connectionId)); + if (schema) params.set("schema", schema); + + const response = await fetch( + `${API_BASE}/flow/procedures/${encodeURIComponent(name)}/parameters?${params.toString()}`, + { + headers: getAuthHeaders(), + credentials: "include", + }, + ); + + return await response.json(); + } catch (error: any) { + return { success: false, error: error.message }; + } +} diff --git a/frontend/types/flow.ts b/frontend/types/flow.ts index 878f8b35..995eaf6f 100644 --- a/frontend/types/flow.ts +++ b/frontend/types/flow.ts @@ -269,6 +269,8 @@ export interface FlowNodeData { tableName?: string; count?: number; condition?: FlowConditionGroup; + integrationType?: string; + procedureName?: string; } export interface FlowEdgeData { diff --git a/frontend/types/flowExternalDb.ts b/frontend/types/flowExternalDb.ts index 4d8469fe..a0106104 100644 --- a/frontend/types/flowExternalDb.ts +++ b/frontend/types/flowExternalDb.ts @@ -5,7 +5,7 @@ // ==================== 연동 타입 ==================== -export type FlowIntegrationType = "internal" | "external_db" | "rest_api" | "webhook" | "hybrid"; +export type FlowIntegrationType = "internal" | "external_db" | "procedure" | "rest_api" | "webhook" | "hybrid"; // ==================== 외부 DB 연결 ==================== @@ -66,8 +66,48 @@ export interface FlowExternalDbIntegrationConfig { customQuery?: string; // 커스텀 쿼리 } +// 프로시저 호출 파라미터 정의 +export interface FlowProcedureParam { + name: string; + dataType: string; + mode: "IN" | "OUT" | "INOUT"; + source: "record_field" | "static" | "step_variable"; + field?: string; + value?: string; +} + +// 프로시저 호출 설정 +export interface FlowProcedureConfig { + type: "procedure"; + dbSource: "internal" | "external"; + connectionId?: number; + procedureName: string; + procedureSchema?: string; + callType: "procedure" | "function"; + parameters: FlowProcedureParam[]; +} + +// 프로시저/함수 목록 항목 +export interface ProcedureListItem { + name: string; + schema: string; + type: "PROCEDURE" | "FUNCTION"; + returnType?: string; +} + +// 프로시저 파라미터 정보 +export interface ProcedureParameterInfo { + name: string; + position: number; + dataType: string; + mode: "IN" | "OUT" | "INOUT"; + defaultValue?: string; +} + // 연동 설정 통합 타입 -export type FlowIntegrationConfig = FlowExternalDbIntegrationConfig; +export type FlowIntegrationConfig = + | FlowExternalDbIntegrationConfig + | FlowProcedureConfig; // ==================== 연동 로그 ==================== @@ -126,6 +166,7 @@ export const OPERATION_OPTIONS = [ export const INTEGRATION_TYPE_OPTIONS = [ { value: "internal", label: "내부 DB (기본)" }, { value: "external_db", label: "외부 DB 연동" }, + { value: "procedure", label: "프로시저/함수 호출" }, { value: "rest_api", label: "REST API 연동" }, { value: "webhook", label: "Webhook (추후 지원)" }, { value: "hybrid", label: "복합 연동 (추후 지원)" }, diff --git a/frontend/types/node-editor.ts b/frontend/types/node-editor.ts index c298b82f..0485a9e3 100644 --- a/frontend/types/node-editor.ts +++ b/frontend/types/node-editor.ts @@ -23,6 +23,7 @@ export type NodeType = | "emailAction" // 메일 발송 액션 | "scriptAction" // 스크립트 실행 액션 | "httpRequestAction" // HTTP 요청 액션 + | "procedureCallAction" // 프로시저/함수 호출 액션 | "comment" // 주석 | "log"; // 로그 @@ -705,6 +706,31 @@ export interface HttpRequestActionNodeData { }; } +// ============================================================================ +// 프로시저/함수 호출 노드 +// ============================================================================ + +export interface ProcedureCallActionNodeData { + displayName?: string; + + dbSource: "internal" | "external"; + connectionId?: number; + connectionName?: string; + + procedureName?: string; + procedureSchema?: string; + callType: "procedure" | "function"; + + parameters?: { + name: string; + dataType: string; + mode: "IN" | "OUT" | "INOUT"; + source: "record_field" | "static" | "step_variable"; + field?: string; + value?: string; + }[]; +} + // ============================================================================ // 통합 노드 데이터 타입 // ============================================================================ @@ -725,6 +751,7 @@ export type NodeData = | EmailActionNodeData | ScriptActionNodeData | HttpRequestActionNodeData + | ProcedureCallActionNodeData | CommentNodeData | LogNodeData;