From f50dd520ae7d2514b67b0f19760f5f60fa0d607f Mon Sep 17 00:00:00 2001 From: hyeonsu Date: Fri, 12 Sep 2025 10:05:25 +0900 Subject: [PATCH] =?UTF-8?q?=EB=B0=B1=EC=97=94=EB=93=9C=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../conditionalConnectionController.ts | 146 +++++ .../src/controllers/dataflowController.ts | 1 + backend-node/src/routes/dataflowRoutes.ts | 18 + .../src/services/dynamicFormService.ts | 61 +- .../src/services/eventTriggerService.ts | 581 ++++++++++++++++++ 5 files changed, 806 insertions(+), 1 deletion(-) create mode 100644 backend-node/src/controllers/conditionalConnectionController.ts create mode 100644 backend-node/src/services/eventTriggerService.ts diff --git a/backend-node/src/controllers/conditionalConnectionController.ts b/backend-node/src/controllers/conditionalConnectionController.ts new file mode 100644 index 00000000..a032ba6d --- /dev/null +++ b/backend-node/src/controllers/conditionalConnectionController.ts @@ -0,0 +1,146 @@ +import { Request, Response } from "express"; +import { logger } from "../utils/logger"; +import { AuthenticatedRequest } from "../types/auth"; +import { ApiResponse } from "../types/common"; +import { EventTriggerService } from "../services/eventTriggerService"; + +/** + * 조건부 연결 조건 테스트 + */ +export async function testConditionalConnection( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + logger.info("=== 조건부 연결 조건 테스트 시작 ==="); + + const { diagramId } = req.params; + const { testData } = req.body; + const companyCode = req.user?.company_code; + + if (!companyCode) { + const response: ApiResponse = { + success: false, + message: "회사 코드가 필요합니다.", + error: { + code: "MISSING_COMPANY_CODE", + details: "인증된 사용자의 회사 코드를 찾을 수 없습니다.", + }, + }; + res.status(400).json(response); + return; + } + + if (!diagramId || !testData) { + const response: ApiResponse = { + success: false, + message: "다이어그램 ID와 테스트 데이터가 필요합니다.", + error: { + code: "MISSING_REQUIRED_FIELDS", + details: "diagramId와 testData가 필요합니다.", + }, + }; + res.status(400).json(response); + return; + } + + const result = await EventTriggerService.testConditionalConnection( + parseInt(diagramId), + testData, + companyCode + ); + + const response: ApiResponse = { + success: true, + message: "조건부 연결 테스트를 성공적으로 완료했습니다.", + data: result, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("조건부 연결 테스트 실패:", error); + const response: ApiResponse = { + success: false, + message: "조건부 연결 테스트에 실패했습니다.", + error: { + code: "CONDITIONAL_CONNECTION_TEST_FAILED", + details: + error instanceof Error + ? error.message + : "알 수 없는 오류가 발생했습니다.", + }, + }; + res.status(500).json(response); + } +} + +/** + * 조건부 연결 액션 수동 실행 + */ +export async function executeConditionalActions( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + logger.info("=== 조건부 연결 액션 수동 실행 시작 ==="); + + const { diagramId } = req.params; + const { triggerType, tableName, data } = req.body; + const companyCode = req.user?.company_code; + + if (!companyCode) { + const response: ApiResponse = { + success: false, + message: "회사 코드가 필요합니다.", + error: { + code: "MISSING_COMPANY_CODE", + details: "인증된 사용자의 회사 코드를 찾을 수 없습니다.", + }, + }; + res.status(400).json(response); + return; + } + + if (!diagramId || !triggerType || !tableName || !data) { + const response: ApiResponse = { + success: false, + message: "필수 필드가 누락되었습니다.", + error: { + code: "MISSING_REQUIRED_FIELDS", + details: "diagramId, triggerType, tableName, data가 모두 필요합니다.", + }, + }; + res.status(400).json(response); + return; + } + + const results = await EventTriggerService.executeEventTriggers( + triggerType, + tableName, + data, + companyCode + ); + + const response: ApiResponse = { + success: true, + message: "조건부 연결 액션을 성공적으로 실행했습니다.", + data: results, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("조건부 연결 액션 실행 실패:", error); + const response: ApiResponse = { + success: false, + message: "조건부 연결 액션 실행에 실패했습니다.", + error: { + code: "CONDITIONAL_ACTION_EXECUTION_FAILED", + details: + error instanceof Error + ? error.message + : "알 수 없는 오류가 발생했습니다.", + }, + }; + res.status(500).json(response); + } +} diff --git a/backend-node/src/controllers/dataflowController.ts b/backend-node/src/controllers/dataflowController.ts index c9a4a426..3b17bb6a 100644 --- a/backend-node/src/controllers/dataflowController.ts +++ b/backend-node/src/controllers/dataflowController.ts @@ -3,6 +3,7 @@ import { logger } from "../utils/logger"; import { AuthenticatedRequest } from "../types/auth"; import { ApiResponse } from "../types/common"; import { DataflowService } from "../services/dataflowService"; +import { EventTriggerService } from "../services/eventTriggerService"; /** * 테이블 관계 생성 diff --git a/backend-node/src/routes/dataflowRoutes.ts b/backend-node/src/routes/dataflowRoutes.ts index 983ac181..fc6c235d 100644 --- a/backend-node/src/routes/dataflowRoutes.ts +++ b/backend-node/src/routes/dataflowRoutes.ts @@ -17,6 +17,10 @@ import { copyDiagram, deleteDiagram, } from "../controllers/dataflowController"; +import { + testConditionalConnection, + executeConditionalActions, +} from "../controllers/conditionalConnectionController"; const router = express.Router(); @@ -128,4 +132,18 @@ router.get( getDiagramRelationshipsByRelationshipId ); +// ==================== 조건부 연결 관리 라우트 ==================== + +/** + * 조건부 연결 조건 테스트 + * POST /api/dataflow/diagrams/:diagramId/test-conditions + */ +router.post("/diagrams/:diagramId/test-conditions", testConditionalConnection); + +/** + * 조건부 연결 액션 수동 실행 + * POST /api/dataflow/diagrams/:diagramId/execute-actions + */ +router.post("/diagrams/:diagramId/execute-actions", executeConditionalActions); + export default router; diff --git a/backend-node/src/services/dynamicFormService.ts b/backend-node/src/services/dynamicFormService.ts index b048d498..6be11f94 100644 --- a/backend-node/src/services/dynamicFormService.ts +++ b/backend-node/src/services/dynamicFormService.ts @@ -1,5 +1,6 @@ import prisma from "../config/database"; import { Prisma } from "@prisma/client"; +import { EventTriggerService } from "./eventTriggerService"; export interface FormDataResult { id: number; @@ -247,6 +248,22 @@ export class DynamicFormService { // 결과를 표준 형식으로 변환 const insertedRecord = Array.isArray(result) ? result[0] : result; + // 🔥 조건부 연결 실행 (INSERT 트리거) + try { + if (company_code) { + await EventTriggerService.executeEventTriggers( + "insert", + tableName, + insertedRecord as Record, + company_code + ); + console.log("🚀 조건부 연결 트리거 실행 완료 (INSERT)"); + } + } catch (triggerError) { + console.error("⚠️ 조건부 연결 트리거 실행 오류:", triggerError); + // 트리거 오류는 로그만 남기고 메인 저장 프로세스는 계속 진행 + } + return { id: insertedRecord.id || insertedRecord.objid || 0, screenId: screenId, @@ -343,6 +360,22 @@ export class DynamicFormService { const updatedRecord = Array.isArray(result) ? result[0] : result; + // 🔥 조건부 연결 실행 (UPDATE 트리거) + try { + if (company_code) { + await EventTriggerService.executeEventTriggers( + "update", + tableName, + updatedRecord as Record, + company_code + ); + console.log("🚀 조건부 연결 트리거 실행 완료 (UPDATE)"); + } + } catch (triggerError) { + console.error("⚠️ 조건부 연결 트리거 실행 오류:", triggerError); + // 트리거 오류는 로그만 남기고 메인 업데이트 프로세스는 계속 진행 + } + return { id: updatedRecord.id || updatedRecord.objid || id, screenId: 0, // 실제 테이블에는 screenId가 없으므로 0으로 설정 @@ -362,7 +395,11 @@ export class DynamicFormService { /** * 폼 데이터 삭제 (실제 테이블에서 직접 삭제) */ - async deleteFormData(id: number, tableName: string): Promise { + async deleteFormData( + id: number, + tableName: string, + companyCode?: string + ): Promise { try { console.log("🗑️ 서비스: 실제 테이블에서 폼 데이터 삭제 시작:", { id, @@ -382,6 +419,28 @@ export class DynamicFormService { const result = await prisma.$queryRawUnsafe(deleteQuery, id); console.log("✅ 서비스: 실제 테이블 삭제 성공:", result); + + // 🔥 조건부 연결 실행 (DELETE 트리거) + try { + if ( + companyCode && + result && + Array.isArray(result) && + result.length > 0 + ) { + const deletedRecord = result[0] as Record; + await EventTriggerService.executeEventTriggers( + "delete", + tableName, + deletedRecord, + companyCode + ); + console.log("🚀 조건부 연결 트리거 실행 완료 (DELETE)"); + } + } catch (triggerError) { + console.error("⚠️ 조건부 연결 트리거 실행 오류:", triggerError); + // 트리거 오류는 로그만 남기고 메인 삭제 프로세스는 계속 진행 + } } catch (error) { console.error("❌ 서비스: 실제 테이블 삭제 실패:", error); throw new Error(`실제 테이블 삭제 실패: ${error}`); diff --git a/backend-node/src/services/eventTriggerService.ts b/backend-node/src/services/eventTriggerService.ts new file mode 100644 index 00000000..10e3651e --- /dev/null +++ b/backend-node/src/services/eventTriggerService.ts @@ -0,0 +1,581 @@ +import { PrismaClient } from "@prisma/client"; +import { logger } from "../config/logger.js"; + +const prisma = new PrismaClient(); + +// 조건 노드 타입 정의 +interface ConditionNode { + type: "group" | "condition"; + operator?: "AND" | "OR"; + children?: ConditionNode[]; + field?: string; + operator_type?: + | "=" + | "!=" + | ">" + | "<" + | ">=" + | "<=" + | "LIKE" + | "NOT_LIKE" + | "CONTAINS" + | "STARTS_WITH" + | "ENDS_WITH" + | "IN" + | "NOT_IN" + | "IS_NULL" + | "IS_NOT_NULL" + | "BETWEEN" + | "NOT_BETWEEN"; + value?: any; + dataType?: string; +} + +// 조건 제어 정보 +interface ConditionControl { + triggerType: "insert" | "update" | "delete" | "insert_update"; + conditionTree: ConditionNode | null; +} + +// 연결 카테고리 정보 +interface ConnectionCategory { + type: "simple-key" | "data-save" | "external-call" | "conditional-link"; + rollbackOnError?: boolean; + enableLogging?: boolean; + maxRetryCount?: number; +} + +// 대상 액션 +interface TargetAction { + id: string; + actionType: "insert" | "update" | "delete" | "upsert"; + targetTable: string; + enabled: boolean; + fieldMappings: FieldMapping[]; + conditions?: ConditionNode; + description?: string; +} + +// 필드 매핑 +interface FieldMapping { + sourceField: string; + targetField: string; + transformFunction?: string; + defaultValue?: string; +} + +// 실행 계획 +interface ExecutionPlan { + sourceTable: string; + targetActions: TargetAction[]; +} + +// 실행 결과 +interface ExecutionResult { + success: boolean; + executedActions: number; + failedActions: number; + errors: string[]; + executionTime: number; +} + +/** + * 조건부 연결 실행을 위한 이벤트 트리거 서비스 + */ +export class EventTriggerService { + /** + * 특정 테이블에 대한 이벤트 트리거 실행 + */ + static async executeEventTriggers( + triggerType: "insert" | "update" | "delete", + tableName: string, + data: Record, + companyCode: string + ): Promise { + const startTime = Date.now(); + const results: ExecutionResult[] = []; + + try { + // 해당 테이블과 트리거 타입에 맞는 조건부 연결들 조회 + const diagrams = await prisma.dataflow_diagrams.findMany({ + where: { + company_code: companyCode, + control: { + path: ["triggerType"], + equals: + triggerType === "insert" + ? "insert" + : triggerType === "update" + ? ["update", "insert_update"] + : triggerType === "delete" + ? "delete" + : triggerType, + }, + plan: { + path: ["sourceTable"], + equals: tableName, + }, + }, + }); + + logger.info( + `Found ${diagrams.length} conditional connections for table ${tableName} with trigger ${triggerType}` + ); + + // 각 다이어그램에 대해 조건부 연결 실행 + for (const diagram of diagrams) { + try { + const result = await this.executeDiagramTrigger( + diagram, + data, + companyCode + ); + results.push(result); + } catch (error) { + logger.error(`Error executing diagram ${diagram.diagram_id}:`, error); + results.push({ + success: false, + executedActions: 0, + failedActions: 1, + errors: [error instanceof Error ? error.message : "Unknown error"], + executionTime: Date.now() - startTime, + }); + } + } + + return results; + } catch (error) { + logger.error("Error in executeEventTriggers:", error); + throw error; + } + } + + /** + * 단일 다이어그램의 트리거 실행 + */ + private static async executeDiagramTrigger( + diagram: any, + data: Record, + companyCode: string + ): Promise { + const startTime = Date.now(); + let executedActions = 0; + let failedActions = 0; + const errors: string[] = []; + + try { + const control = diagram.control as ConditionControl; + const category = diagram.category as ConnectionCategory; + const plan = diagram.plan as ExecutionPlan; + + logger.info( + `Executing diagram ${diagram.diagram_id} (${diagram.diagram_name})` + ); + + // 조건 평가 + if (control.conditionTree) { + const conditionMet = await this.evaluateCondition( + control.conditionTree, + data + ); + if (!conditionMet) { + logger.info( + `Conditions not met for diagram ${diagram.diagram_id}, skipping execution` + ); + return { + success: true, + executedActions: 0, + failedActions: 0, + errors: [], + executionTime: Date.now() - startTime, + }; + } + } + + // 대상 액션들 실행 + for (const action of plan.targetActions) { + if (!action.enabled) { + continue; + } + + try { + await this.executeTargetAction(action, data, companyCode); + executedActions++; + + if (category.enableLogging) { + logger.info( + `Successfully executed action ${action.id} on table ${action.targetTable}` + ); + } + } catch (error) { + failedActions++; + const errorMsg = + error instanceof Error ? error.message : "Unknown error"; + errors.push(`Action ${action.id}: ${errorMsg}`); + + logger.error(`Failed to execute action ${action.id}:`, error); + + // 오류 시 롤백 처리 + if (category.rollbackOnError) { + logger.warn(`Rolling back due to error in action ${action.id}`); + // TODO: 롤백 로직 구현 + break; + } + } + } + + return { + success: failedActions === 0, + executedActions, + failedActions, + errors, + executionTime: Date.now() - startTime, + }; + } catch (error) { + logger.error(`Error executing diagram ${diagram.diagram_id}:`, error); + return { + success: false, + executedActions: 0, + failedActions: 1, + errors: [error instanceof Error ? error.message : "Unknown error"], + executionTime: Date.now() - startTime, + }; + } + } + + /** + * 조건 평가 + */ + private static async evaluateCondition( + condition: ConditionNode, + data: Record + ): Promise { + if (condition.type === "group") { + if (!condition.children || condition.children.length === 0) { + return true; + } + + const results = await Promise.all( + condition.children.map((child) => this.evaluateCondition(child, data)) + ); + + if (condition.operator === "OR") { + return results.some((result) => result); + } else { + // AND + return results.every((result) => result); + } + } else if (condition.type === "condition") { + return this.evaluateSingleCondition(condition, data); + } + + return false; + } + + /** + * 단일 조건 평가 + */ + private static evaluateSingleCondition( + condition: ConditionNode, + data: Record + ): boolean { + const { field, operator_type, value } = condition; + + if (!field || !operator_type) { + return false; + } + + const fieldValue = data[field]; + + switch (operator_type) { + case "=": + return fieldValue == value; + case "!=": + return fieldValue != value; + case ">": + return Number(fieldValue) > Number(value); + case "<": + return Number(fieldValue) < Number(value); + case ">=": + return Number(fieldValue) >= Number(value); + case "<=": + return Number(fieldValue) <= Number(value); + case "LIKE": + return String(fieldValue).includes(String(value)); + case "NOT_LIKE": + return !String(fieldValue).includes(String(value)); + case "CONTAINS": + return String(fieldValue) + .toLowerCase() + .includes(String(value).toLowerCase()); + case "STARTS_WITH": + return String(fieldValue).startsWith(String(value)); + case "ENDS_WITH": + return String(fieldValue).endsWith(String(value)); + case "IN": + return Array.isArray(value) && value.includes(fieldValue); + case "NOT_IN": + return Array.isArray(value) && !value.includes(fieldValue); + case "IS_NULL": + return fieldValue == null || fieldValue === undefined; + case "IS_NOT_NULL": + return fieldValue != null && fieldValue !== undefined; + case "BETWEEN": + if (Array.isArray(value) && value.length === 2) { + const numValue = Number(fieldValue); + return numValue >= Number(value[0]) && numValue <= Number(value[1]); + } + return false; + case "NOT_BETWEEN": + if (Array.isArray(value) && value.length === 2) { + const numValue = Number(fieldValue); + return !( + numValue >= Number(value[0]) && numValue <= Number(value[1]) + ); + } + return false; + default: + return false; + } + } + + /** + * 대상 액션 실행 + */ + private static async executeTargetAction( + action: TargetAction, + sourceData: Record, + companyCode: string + ): Promise { + // 필드 매핑을 통해 대상 데이터 생성 + const targetData: Record = {}; + + for (const mapping of action.fieldMappings) { + let value = sourceData[mapping.sourceField]; + + // 변환 함수 적용 + if (mapping.transformFunction) { + value = this.applyTransformFunction(value, mapping.transformFunction); + } + + // 기본값 설정 + if (value === undefined || value === null) { + value = mapping.defaultValue; + } + + targetData[mapping.targetField] = value; + } + + // 회사 코드 추가 + targetData.company_code = companyCode; + + // 액션 타입별 실행 + switch (action.actionType) { + case "insert": + await this.executeInsertAction(action.targetTable, targetData); + break; + case "update": + await this.executeUpdateAction( + action.targetTable, + targetData, + action.conditions + ); + break; + case "delete": + await this.executeDeleteAction( + action.targetTable, + targetData, + action.conditions + ); + break; + case "upsert": + await this.executeUpsertAction(action.targetTable, targetData); + break; + default: + throw new Error(`Unsupported action type: ${action.actionType}`); + } + } + + /** + * INSERT 액션 실행 + */ + private static async executeInsertAction( + tableName: string, + data: Record + ): Promise { + // 동적 테이블 INSERT 실행 + const sql = `INSERT INTO ${tableName} (${Object.keys(data).join(", ")}) VALUES (${Object.keys( + data + ) + .map(() => "?") + .join(", ")})`; + + await prisma.$executeRawUnsafe(sql, ...Object.values(data)); + logger.info(`Inserted data into ${tableName}:`, data); + } + + /** + * UPDATE 액션 실행 + */ + private static async executeUpdateAction( + tableName: string, + data: Record, + conditions?: ConditionNode + ): Promise { + // 조건이 없으면 실행하지 않음 (안전장치) + if (!conditions) { + throw new Error( + "UPDATE action requires conditions to prevent accidental mass updates" + ); + } + + // 동적 테이블 UPDATE 실행 + const setClause = Object.keys(data) + .map((key) => `${key} = ?`) + .join(", "); + const whereClause = this.buildWhereClause(conditions); + + const sql = `UPDATE ${tableName} SET ${setClause} WHERE ${whereClause}`; + + await prisma.$executeRawUnsafe(sql, ...Object.values(data)); + logger.info(`Updated data in ${tableName}:`, data); + } + + /** + * DELETE 액션 실행 + */ + private static async executeDeleteAction( + tableName: string, + data: Record, + conditions?: ConditionNode + ): Promise { + // 조건이 없으면 실행하지 않음 (안전장치) + if (!conditions) { + throw new Error( + "DELETE action requires conditions to prevent accidental mass deletions" + ); + } + + // 동적 테이블 DELETE 실행 + const whereClause = this.buildWhereClause(conditions); + const sql = `DELETE FROM ${tableName} WHERE ${whereClause}`; + + await prisma.$executeRawUnsafe(sql); + logger.info(`Deleted data from ${tableName} with conditions`); + } + + /** + * UPSERT 액션 실행 + */ + private static async executeUpsertAction( + tableName: string, + data: Record + ): Promise { + // PostgreSQL UPSERT 구현 + const columns = Object.keys(data); + const values = Object.values(data); + const conflictColumns = ["id", "company_code"]; // 기본 충돌 컬럼 + + const sql = ` + INSERT INTO ${tableName} (${columns.join(", ")}) + VALUES (${columns.map(() => "?").join(", ")}) + ON CONFLICT (${conflictColumns.join(", ")}) + DO UPDATE SET ${columns.map((col) => `${col} = EXCLUDED.${col}`).join(", ")} + `; + + await prisma.$executeRawUnsafe(sql, ...values); + logger.info(`Upserted data into ${tableName}:`, data); + } + + /** + * WHERE 절 구성 + */ + private static buildWhereClause(conditions: ConditionNode): string { + // 간단한 WHERE 절 구성 (실제 구현에서는 더 복잡한 로직 필요) + if ( + conditions.type === "condition" && + conditions.field && + conditions.operator_type + ) { + return `${conditions.field} ${conditions.operator_type} '${conditions.value}'`; + } + + return "1=1"; // 기본값 + } + + /** + * 변환 함수 적용 + */ + private static applyTransformFunction( + value: any, + transformFunction: string + ): any { + try { + // 안전한 변환 함수들만 허용 + switch (transformFunction) { + case "UPPER": + return String(value).toUpperCase(); + case "LOWER": + return String(value).toLowerCase(); + case "TRIM": + return String(value).trim(); + case "NOW": + return new Date(); + case "UUID": + return require("crypto").randomUUID(); + default: + logger.warn(`Unknown transform function: ${transformFunction}`); + return value; + } + } catch (error) { + logger.error( + `Error applying transform function ${transformFunction}:`, + error + ); + return value; + } + } + + /** + * 조건부 연결 테스트 (개발/디버깅용) + */ + static async testConditionalConnection( + diagramId: number, + testData: Record, + companyCode: string + ): Promise<{ conditionMet: boolean; result?: ExecutionResult }> { + try { + const diagram = await prisma.dataflow_diagrams.findUnique({ + where: { diagram_id: diagramId }, + }); + + if (!diagram) { + throw new Error(`Diagram ${diagramId} not found`); + } + + const control = diagram.control as ConditionControl; + + // 조건 평가만 수행 + const conditionMet = control.conditionTree + ? await this.evaluateCondition(control.conditionTree, testData) + : true; + + if (conditionMet) { + // 실제 실행 (테스트 모드) + const result = await this.executeDiagramTrigger( + diagram, + testData, + companyCode + ); + return { conditionMet: true, result }; + } + + return { conditionMet: false }; + } catch (error) { + logger.error("Error testing conditional connection:", error); + throw error; + } + } +} + +export default EventTriggerService;