From 1f12df2f79425dbf907db175837210fdf2738608 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 20 Oct 2025 17:50:27 +0900 Subject: [PATCH] =?UTF-8?q?=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EC=99=B8?= =?UTF-8?q?=EB=B6=80db=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scripts/add-external-db-connection.ts | 174 ++++ backend-node/scripts/encrypt-password.ts | 16 + backend-node/src/app.ts | 4 +- .../flowExternalDbConnectionController.ts | 328 ++++++++ .../routes/flowExternalDbConnectionRoutes.ts | 48 ++ .../src/services/flowDataMoveService.ts | 159 +++- .../flowExternalDbConnectionService.ts | 436 ++++++++++ .../flowExternalDbIntegrationService.ts | 353 ++++++++ backend-node/src/services/flowStepService.ts | 84 +- backend-node/src/types/flow.ts | 135 ++++ .../src/utils/credentialEncryption.ts | 61 ++ docs/FLOW_EXTERNAL_INTEGRATION_PLAN.md | 762 ++++++++++++++++++ .../(main)/admin/flow-external-db/page.tsx | 384 +++++++++ frontend/components/flow/FlowStepPanel.tsx | 519 +++++++++++- frontend/lib/api/flowExternalDb.ts | 139 ++++ frontend/types/flowExternalDb.ts | 149 ++++ 16 files changed, 3711 insertions(+), 40 deletions(-) create mode 100644 backend-node/scripts/add-external-db-connection.ts create mode 100644 backend-node/scripts/encrypt-password.ts create mode 100644 backend-node/src/controllers/flowExternalDbConnectionController.ts create mode 100644 backend-node/src/routes/flowExternalDbConnectionRoutes.ts create mode 100644 backend-node/src/services/flowExternalDbConnectionService.ts create mode 100644 backend-node/src/services/flowExternalDbIntegrationService.ts create mode 100644 backend-node/src/utils/credentialEncryption.ts create mode 100644 docs/FLOW_EXTERNAL_INTEGRATION_PLAN.md create mode 100644 frontend/app/(main)/admin/flow-external-db/page.tsx create mode 100644 frontend/lib/api/flowExternalDb.ts create mode 100644 frontend/types/flowExternalDb.ts diff --git a/backend-node/scripts/add-external-db-connection.ts b/backend-node/scripts/add-external-db-connection.ts new file mode 100644 index 00000000..b595168a --- /dev/null +++ b/backend-node/scripts/add-external-db-connection.ts @@ -0,0 +1,174 @@ +/** + * 외부 DB 연결 정보 추가 스크립트 + * 비밀번호를 암호화하여 안전하게 저장 + */ + +import { Pool } from "pg"; +import { CredentialEncryption } from "../src/utils/credentialEncryption"; + +async function addExternalDbConnection() { + const pool = new Pool({ + host: process.env.DB_HOST || "localhost", + port: parseInt(process.env.DB_PORT || "5432"), + database: process.env.DB_NAME || "plm", + user: process.env.DB_USER || "postgres", + password: process.env.DB_PASSWORD || "ph0909!!", + }); + + // 환경 변수에서 암호화 키 가져오기 (없으면 기본값 사용) + const encryptionKey = + process.env.ENCRYPTION_SECRET_KEY || "default-secret-key-for-development"; + const encryption = new CredentialEncryption(encryptionKey); + + try { + // 외부 DB 연결 정보 (실제 사용할 외부 DB 정보를 여기에 입력) + const externalDbConnections = [ + { + name: "운영_외부_PostgreSQL", + description: "운영용 외부 PostgreSQL 데이터베이스", + dbType: "postgresql", + host: "39.117.244.52", + port: 11132, + databaseName: "plm", + username: "postgres", + password: "ph0909!!", // 이 값은 암호화되어 저장됩니다 + sslEnabled: false, + isActive: true, + }, + // 필요한 경우 추가 외부 DB 연결 정보를 여기에 추가 + // { + // name: "테스트_MySQL", + // description: "테스트용 MySQL 데이터베이스", + // dbType: "mysql", + // host: "test-mysql.example.com", + // port: 3306, + // databaseName: "testdb", + // username: "testuser", + // password: "testpass", + // sslEnabled: true, + // isActive: true, + // }, + ]; + + for (const conn of externalDbConnections) { + // 비밀번호 암호화 + const encryptedPassword = encryption.encrypt(conn.password); + + // 중복 체크 (이름 기준) + const existingResult = await pool.query( + "SELECT id FROM flow_external_db_connection WHERE name = $1", + [conn.name] + ); + + if (existingResult.rows.length > 0) { + console.log( + `⚠️ 이미 존재하는 연결: ${conn.name} (ID: ${existingResult.rows[0].id})` + ); + + // 기존 연결 업데이트 + await pool.query( + `UPDATE flow_external_db_connection + SET description = $1, + db_type = $2, + host = $3, + port = $4, + database_name = $5, + username = $6, + password_encrypted = $7, + ssl_enabled = $8, + is_active = $9, + updated_at = NOW(), + updated_by = 'system' + WHERE name = $10`, + [ + conn.description, + conn.dbType, + conn.host, + conn.port, + conn.databaseName, + conn.username, + encryptedPassword, + conn.sslEnabled, + conn.isActive, + conn.name, + ] + ); + console.log(`✅ 연결 정보 업데이트 완료: ${conn.name}`); + } else { + // 새 연결 추가 + const result = await pool.query( + `INSERT INTO flow_external_db_connection ( + name, + description, + db_type, + host, + port, + database_name, + username, + password_encrypted, + ssl_enabled, + is_active, + created_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'system') + RETURNING id`, + [ + conn.name, + conn.description, + conn.dbType, + conn.host, + conn.port, + conn.databaseName, + conn.username, + encryptedPassword, + conn.sslEnabled, + conn.isActive, + ] + ); + console.log( + `✅ 새 연결 추가 완료: ${conn.name} (ID: ${result.rows[0].id})` + ); + } + + // 연결 테스트 + console.log(`🔍 연결 테스트 중: ${conn.name}...`); + const testPool = new Pool({ + host: conn.host, + port: conn.port, + database: conn.databaseName, + user: conn.username, + password: conn.password, + ssl: conn.sslEnabled, + connectionTimeoutMillis: 5000, + }); + + try { + const client = await testPool.connect(); + await client.query("SELECT 1"); + client.release(); + console.log(`✅ 연결 테스트 성공: ${conn.name}`); + } catch (testError: any) { + console.error(`❌ 연결 테스트 실패: ${conn.name}`, testError.message); + } finally { + await testPool.end(); + } + } + + console.log("\n✅ 모든 외부 DB 연결 정보 처리 완료"); + } catch (error) { + console.error("❌ 외부 DB 연결 정보 추가 오류:", error); + throw error; + } finally { + await pool.end(); + } +} + +// 스크립트 실행 +addExternalDbConnection() + .then(() => { + console.log("✅ 스크립트 완료"); + process.exit(0); + }) + .catch((error) => { + console.error("❌ 스크립트 실패:", error); + process.exit(1); + }); diff --git a/backend-node/scripts/encrypt-password.ts b/backend-node/scripts/encrypt-password.ts new file mode 100644 index 00000000..178de1ad --- /dev/null +++ b/backend-node/scripts/encrypt-password.ts @@ -0,0 +1,16 @@ +/** + * 비밀번호 암호화 유틸리티 + */ + +import { CredentialEncryption } from "../src/utils/credentialEncryption"; + +const encryptionKey = + process.env.ENCRYPTION_SECRET_KEY || "default-secret-key-for-development"; +const encryption = new CredentialEncryption(encryptionKey); +const password = process.argv[2] || "ph0909!!"; + +const encrypted = encryption.encrypt(password); +console.log("\n원본 비밀번호:", password); +console.log("암호화된 비밀번호:", encrypted); +console.log("\n복호화 테스트:", encryption.decrypt(encrypted)); +console.log("✅ 암호화/복호화 성공\n"); diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 6595217a..f8c096e4 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -58,6 +58,7 @@ import mapDataRoutes from "./routes/mapDataRoutes"; // 지도 데이터 관리 import yardLayoutRoutes from "./routes/yardLayoutRoutes"; // 야드 관리 3D //import materialRoutes from "./routes/materialRoutes"; // 자재 관리 import flowRoutes from "./routes/flowRoutes"; // 플로우 관리 +import flowExternalDbConnectionRoutes from "./routes/flowExternalDbConnectionRoutes"; // 플로우 전용 외부 DB 연결 import { BatchSchedulerService } from "./services/batchSchedulerService"; // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석 @@ -209,7 +210,8 @@ app.use("/api/bookings", bookingRoutes); // 예약 요청 관리 app.use("/api/map-data", mapDataRoutes); // 지도 데이터 조회 app.use("/api/yard-layouts", yardLayoutRoutes); // 야드 관리 3D // app.use("/api/materials", materialRoutes); // 자재 관리 (임시 주석) -app.use("/api/flow", flowRoutes); // 플로우 관리 +app.use("/api/flow-external-db", flowExternalDbConnectionRoutes); // 플로우 전용 외부 DB 연결 +app.use("/api/flow", flowRoutes); // 플로우 관리 (마지막에 등록하여 다른 라우트와 충돌 방지) // app.use("/api/collections", collectionRoutes); // 임시 주석 // app.use("/api/batch", batchRoutes); // 임시 주석 // app.use('/api/users', userRoutes); diff --git a/backend-node/src/controllers/flowExternalDbConnectionController.ts b/backend-node/src/controllers/flowExternalDbConnectionController.ts new file mode 100644 index 00000000..ed0c1232 --- /dev/null +++ b/backend-node/src/controllers/flowExternalDbConnectionController.ts @@ -0,0 +1,328 @@ +import { Request, Response } from "express"; +import { FlowExternalDbConnectionService } from "../services/flowExternalDbConnectionService"; +import { + CreateFlowExternalDbConnectionRequest, + UpdateFlowExternalDbConnectionRequest, +} from "../types/flow"; +import logger from "../utils/logger"; + +/** + * 플로우 전용 외부 DB 연결 컨트롤러 + */ +export class FlowExternalDbConnectionController { + private service: FlowExternalDbConnectionService; + + constructor() { + this.service = new FlowExternalDbConnectionService(); + } + + /** + * GET /api/flow/external-db-connections + * 모든 외부 DB 연결 목록 조회 + */ + async getAll(req: Request, res: Response): Promise { + try { + const activeOnly = req.query.activeOnly === "true"; + const connections = await this.service.findAll(activeOnly); + + res.json({ + success: true, + data: connections, + message: `${connections.length}개의 외부 DB 연결을 조회했습니다`, + }); + } catch (error: any) { + logger.error("외부 DB 연결 목록 조회 오류:", error); + res.status(500).json({ + success: false, + message: "외부 DB 연결 목록 조회 중 오류가 발생했습니다", + error: error.message, + }); + } + } + + /** + * GET /api/flow/external-db-connections/:id + * 특정 외부 DB 연결 조회 + */ + async getById(req: Request, res: Response): Promise { + try { + const id = parseInt(req.params.id); + if (isNaN(id)) { + res.status(400).json({ + success: false, + message: "유효하지 않은 연결 ID입니다", + }); + return; + } + + const connection = await this.service.findById(id); + + if (!connection) { + res.status(404).json({ + success: false, + message: "외부 DB 연결을 찾을 수 없습니다", + }); + return; + } + + res.json({ + success: true, + data: connection, + }); + } catch (error: any) { + logger.error("외부 DB 연결 조회 오류:", error); + res.status(500).json({ + success: false, + message: "외부 DB 연결 조회 중 오류가 발생했습니다", + error: error.message, + }); + } + } + + /** + * POST /api/flow/external-db-connections + * 새 외부 DB 연결 생성 + */ + async create(req: Request, res: Response): Promise { + try { + const request: CreateFlowExternalDbConnectionRequest = req.body; + + // 필수 필드 검증 + if ( + !request.name || + !request.dbType || + !request.host || + !request.port || + !request.databaseName || + !request.username || + !request.password + ) { + res.status(400).json({ + success: false, + message: "필수 필드가 누락되었습니다", + }); + return; + } + + const userId = (req as any).user?.userId || "system"; + const connection = await this.service.create(request, userId); + + logger.info( + `외부 DB 연결 생성: ${connection.name} (ID: ${connection.id})` + ); + + res.status(201).json({ + success: true, + data: connection, + message: "외부 DB 연결이 생성되었습니다", + }); + } catch (error: any) { + logger.error("외부 DB 연결 생성 오류:", error); + res.status(500).json({ + success: false, + message: "외부 DB 연결 생성 중 오류가 발생했습니다", + error: error.message, + }); + } + } + + /** + * PUT /api/flow/external-db-connections/:id + * 외부 DB 연결 수정 + */ + async update(req: Request, res: Response): Promise { + try { + const id = parseInt(req.params.id); + if (isNaN(id)) { + res.status(400).json({ + success: false, + message: "유효하지 않은 연결 ID입니다", + }); + return; + } + + const request: UpdateFlowExternalDbConnectionRequest = req.body; + const userId = (req as any).user?.userId || "system"; + + const connection = await this.service.update(id, request, userId); + + if (!connection) { + res.status(404).json({ + success: false, + message: "외부 DB 연결을 찾을 수 없습니다", + }); + return; + } + + logger.info(`외부 DB 연결 수정: ${connection.name} (ID: ${id})`); + + res.json({ + success: true, + data: connection, + message: "외부 DB 연결이 수정되었습니다", + }); + } catch (error: any) { + logger.error("외부 DB 연결 수정 오류:", error); + res.status(500).json({ + success: false, + message: "외부 DB 연결 수정 중 오류가 발생했습니다", + error: error.message, + }); + } + } + + /** + * DELETE /api/flow/external-db-connections/:id + * 외부 DB 연결 삭제 + */ + async delete(req: Request, res: Response): Promise { + try { + const id = parseInt(req.params.id); + if (isNaN(id)) { + res.status(400).json({ + success: false, + message: "유효하지 않은 연결 ID입니다", + }); + return; + } + + const success = await this.service.delete(id); + + if (!success) { + res.status(404).json({ + success: false, + message: "외부 DB 연결을 찾을 수 없습니다", + }); + return; + } + + logger.info(`외부 DB 연결 삭제: ID ${id}`); + + res.json({ + success: true, + message: "외부 DB 연결이 삭제되었습니다", + }); + } catch (error: any) { + logger.error("외부 DB 연결 삭제 오류:", error); + res.status(500).json({ + success: false, + message: "외부 DB 연결 삭제 중 오류가 발생했습니다", + error: error.message, + }); + } + } + + /** + * POST /api/flow/external-db-connections/:id/test + * 외부 DB 연결 테스트 + */ + async testConnection(req: Request, res: Response): Promise { + try { + const id = parseInt(req.params.id); + if (isNaN(id)) { + res.status(400).json({ + success: false, + message: "유효하지 않은 연결 ID입니다", + }); + return; + } + + const result = await this.service.testConnection(id); + + if (result.success) { + logger.info(`외부 DB 연결 테스트 성공: ID ${id}`); + res.json({ + success: true, + message: result.message, + }); + } else { + logger.warn(`외부 DB 연결 테스트 실패: ID ${id} - ${result.message}`); + res.status(400).json({ + success: false, + message: result.message, + }); + } + } catch (error: any) { + logger.error("외부 DB 연결 테스트 오류:", error); + res.status(500).json({ + success: false, + message: "외부 DB 연결 테스트 중 오류가 발생했습니다", + error: error.message, + }); + } + } + + /** + * GET /api/flow/external-db-connections/:id/tables + * 외부 DB의 테이블 목록 조회 + */ + async getTables(req: Request, res: Response): Promise { + try { + const id = parseInt(req.params.id); + if (isNaN(id)) { + res.status(400).json({ + success: false, + message: "유효하지 않은 연결 ID입니다", + }); + return; + } + + const result = await this.service.getTables(id); + + if (result.success) { + res.json(result); + } else { + res.status(400).json(result); + } + } catch (error: any) { + logger.error("외부 DB 테이블 목록 조회 오류:", error); + res.status(500).json({ + success: false, + message: "외부 DB 테이블 목록 조회 중 오류가 발생했습니다", + error: error.message, + }); + } + } + + /** + * GET /api/flow/external-db-connections/:id/tables/:tableName/columns + * 외부 DB 특정 테이블의 컬럼 목록 조회 + */ + async getTableColumns(req: Request, res: Response): Promise { + try { + const id = parseInt(req.params.id); + const tableName = req.params.tableName; + + if (isNaN(id)) { + res.status(400).json({ + success: false, + message: "유효하지 않은 연결 ID입니다", + }); + return; + } + + if (!tableName) { + res.status(400).json({ + success: false, + message: "테이블명이 필요합니다", + }); + return; + } + + const result = await this.service.getTableColumns(id, tableName); + + if (result.success) { + res.json(result); + } else { + res.status(400).json(result); + } + } catch (error: any) { + logger.error("외부 DB 컬럼 목록 조회 오류:", error); + res.status(500).json({ + success: false, + message: "외부 DB 컬럼 목록 조회 중 오류가 발생했습니다", + error: error.message, + }); + } + } +} diff --git a/backend-node/src/routes/flowExternalDbConnectionRoutes.ts b/backend-node/src/routes/flowExternalDbConnectionRoutes.ts new file mode 100644 index 00000000..c58c6de0 --- /dev/null +++ b/backend-node/src/routes/flowExternalDbConnectionRoutes.ts @@ -0,0 +1,48 @@ +import { Router } from "express"; +import { FlowExternalDbConnectionController } from "../controllers/flowExternalDbConnectionController"; +import { authenticateToken } from "../middleware/authMiddleware"; + +const router = Router(); +const controller = new FlowExternalDbConnectionController(); + +/** + * 플로우 전용 외부 DB 연결 라우트 + * 기존 제어관리 외부 DB 연결과 별도 + */ + +// 모든 외부 DB 연결 목록 조회 (읽기 전용 - 인증 불필요) +// 민감한 정보(비밀번호)는 반환하지 않으므로 안전 +router.get("/", (req, res) => controller.getAll(req, res)); + +// 특정 외부 DB 연결 조회 +router.get("/:id", authenticateToken, (req, res) => + controller.getById(req, res) +); + +// 새 외부 DB 연결 생성 +router.post("/", authenticateToken, (req, res) => controller.create(req, res)); + +// 외부 DB 연결 수정 +router.put("/:id", authenticateToken, (req, res) => + controller.update(req, res) +); + +// 외부 DB 연결 삭제 +router.delete("/:id", authenticateToken, (req, res) => + controller.delete(req, res) +); + +// 외부 DB 연결 테스트 +router.post("/:id/test", authenticateToken, (req, res) => + controller.testConnection(req, res) +); + +// 외부 DB의 테이블 목록 조회 (읽기 전용 - 인증 불필요) +router.get("/:id/tables", (req, res) => controller.getTables(req, res)); + +// 외부 DB의 특정 테이블의 컬럼 목록 조회 (읽기 전용 - 인증 불필요) +router.get("/:id/tables/:tableName/columns", (req, res) => + controller.getTableColumns(req, res) +); + +export default router; diff --git a/backend-node/src/services/flowDataMoveService.ts b/backend-node/src/services/flowDataMoveService.ts index 242bd56b..9ed99548 100644 --- a/backend-node/src/services/flowDataMoveService.ts +++ b/backend-node/src/services/flowDataMoveService.ts @@ -6,17 +6,20 @@ */ import db from "../database/db"; -import { FlowAuditLog } from "../types/flow"; +import { FlowAuditLog, FlowIntegrationContext } from "../types/flow"; import { FlowDefinitionService } from "./flowDefinitionService"; import { FlowStepService } from "./flowStepService"; +import { FlowExternalDbIntegrationService } from "./flowExternalDbIntegrationService"; export class FlowDataMoveService { private flowDefinitionService: FlowDefinitionService; private flowStepService: FlowStepService; + private externalDbIntegrationService: FlowExternalDbIntegrationService; constructor() { this.flowDefinitionService = new FlowDefinitionService(); this.flowStepService = new FlowStepService(); + this.externalDbIntegrationService = new FlowExternalDbIntegrationService(); } /** @@ -104,7 +107,23 @@ export class FlowDataMoveService { ); } - // 4. 감사 로그 기록 + // 4. 외부 DB 연동 실행 (설정된 경우) + if ( + toStep.integrationType && + toStep.integrationType !== "internal" && + toStep.integrationConfig + ) { + await this.executeExternalIntegration( + toStep, + flowId, + targetDataId, + sourceTable, + userId, + additionalData + ); + } + + // 5. 감사 로그 기록 await this.logDataMove(client, { flowId, fromStepId, @@ -435,4 +454,140 @@ export class FlowDataMoveService { statusTo: row.status_to, })); } + + /** + * 외부 DB 연동 실행 + */ + private async executeExternalIntegration( + toStep: any, + flowId: number, + dataId: any, + tableName: string | undefined, + userId: string, + additionalData?: Record + ): Promise { + const startTime = Date.now(); + + try { + // 연동 컨텍스트 구성 + const context: FlowIntegrationContext = { + flowId, + stepId: toStep.id, + dataId, + tableName, + currentUser: userId, + variables: { + ...additionalData, + stepName: toStep.stepName, + stepId: toStep.id, + }, + }; + + // 연동 타입별 처리 + switch (toStep.integrationType) { + case "external_db": + const result = await this.externalDbIntegrationService.execute( + context, + toStep.integrationConfig + ); + + // 연동 로그 기록 + await this.logIntegration( + flowId, + toStep.id, + dataId, + toStep.integrationType, + toStep.integrationConfig.connectionId, + toStep.integrationConfig, + result.data, + result.success ? "success" : "failed", + result.error?.message, + Date.now() - startTime, + userId + ); + + if (!result.success) { + throw new Error( + `외부 DB 연동 실패: ${result.error?.message || "알 수 없는 오류"}` + ); + } + break; + + case "rest_api": + // REST API 연동 (추후 구현) + console.warn("REST API 연동은 아직 구현되지 않았습니다"); + break; + + case "webhook": + // Webhook 연동 (추후 구현) + console.warn("Webhook 연동은 아직 구현되지 않았습니다"); + break; + + case "hybrid": + // 복합 연동 (추후 구현) + console.warn("복합 연동은 아직 구현되지 않았습니다"); + break; + + default: + throw new Error(`지원하지 않는 연동 타입: ${toStep.integrationType}`); + } + } catch (error: any) { + console.error("외부 연동 실행 실패:", error); + // 연동 실패 로그 기록 + await this.logIntegration( + flowId, + toStep.id, + dataId, + toStep.integrationType, + toStep.integrationConfig?.connectionId, + toStep.integrationConfig, + null, + "failed", + error.message, + Date.now() - startTime, + userId + ); + throw error; + } + } + + /** + * 외부 연동 로그 기록 + */ + private async logIntegration( + flowId: number, + stepId: number, + dataId: any, + integrationType: string, + connectionId: number | undefined, + requestPayload: any, + responsePayload: any, + status: "success" | "failed" | "timeout" | "rollback", + errorMessage: string | undefined, + executionTimeMs: number, + userId: string + ): Promise { + const query = ` + INSERT INTO flow_integration_log ( + flow_definition_id, step_id, data_id, integration_type, connection_id, + request_payload, response_payload, status, error_message, + execution_time_ms, executed_by, executed_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW()) + `; + + await db.query(query, [ + flowId, + stepId, + String(dataId), + integrationType, + connectionId || null, + requestPayload ? JSON.stringify(requestPayload) : null, + responsePayload ? JSON.stringify(responsePayload) : null, + status, + errorMessage || null, + executionTimeMs, + userId, + ]); + } } diff --git a/backend-node/src/services/flowExternalDbConnectionService.ts b/backend-node/src/services/flowExternalDbConnectionService.ts new file mode 100644 index 00000000..e12a81a2 --- /dev/null +++ b/backend-node/src/services/flowExternalDbConnectionService.ts @@ -0,0 +1,436 @@ +import db from "../database/db"; +import { + FlowExternalDbConnection, + CreateFlowExternalDbConnectionRequest, + UpdateFlowExternalDbConnectionRequest, +} from "../types/flow"; +import { CredentialEncryption } from "../utils/credentialEncryption"; +import { Pool } from "pg"; +// import mysql from 'mysql2/promise'; // MySQL용 (추후) +// import { ConnectionPool } from 'mssql'; // MSSQL용 (추후) + +/** + * 플로우 전용 외부 DB 연결 관리 서비스 + * (기존 제어관리 외부 DB 연결과 별도) + */ +export class FlowExternalDbConnectionService { + private encryption: CredentialEncryption; + private connectionPools: Map = new Map(); + + constructor() { + // 환경 변수에서 SECRET_KEY를 가져오거나 기본값 설정 + const secretKey = + process.env.SECRET_KEY || "flow-external-db-secret-key-2025"; + this.encryption = new CredentialEncryption(secretKey); + } + + /** + * 외부 DB 연결 생성 + */ + async create( + request: CreateFlowExternalDbConnectionRequest, + userId: string = "system" + ): Promise { + // 비밀번호 암호화 + const encryptedPassword = this.encryption.encrypt(request.password); + + const query = ` + INSERT INTO flow_external_db_connection ( + name, description, db_type, host, port, database_name, username, + password_encrypted, ssl_enabled, connection_options, created_by, updated_by + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + RETURNING * + `; + + const result = await db.query(query, [ + request.name, + request.description || null, + request.dbType, + request.host, + request.port, + request.databaseName, + request.username, + encryptedPassword, + request.sslEnabled || false, + request.connectionOptions + ? JSON.stringify(request.connectionOptions) + : null, + userId, + userId, + ]); + + return this.mapToFlowExternalDbConnection(result[0]); + } + + /** + * ID로 외부 DB 연결 조회 + */ + async findById(id: number): Promise { + const query = "SELECT * FROM flow_external_db_connection WHERE id = $1"; + const result = await db.query(query, [id]); + + if (result.length === 0) { + return null; + } + + return this.mapToFlowExternalDbConnection(result[0]); + } + + /** + * 모든 외부 DB 연결 조회 + */ + async findAll( + activeOnly: boolean = false + ): Promise { + let query = "SELECT * FROM flow_external_db_connection"; + if (activeOnly) { + query += " WHERE is_active = true"; + } + query += " ORDER BY name ASC"; + + const result = await db.query(query); + return result.map((row) => this.mapToFlowExternalDbConnection(row)); + } + + /** + * 외부 DB 연결 수정 + */ + async update( + id: number, + request: UpdateFlowExternalDbConnectionRequest, + userId: string = "system" + ): Promise { + const fields: string[] = []; + const params: any[] = []; + let paramIndex = 1; + + if (request.name !== undefined) { + fields.push(`name = $${paramIndex}`); + params.push(request.name); + paramIndex++; + } + + if (request.description !== undefined) { + fields.push(`description = $${paramIndex}`); + params.push(request.description); + paramIndex++; + } + + if (request.host !== undefined) { + fields.push(`host = $${paramIndex}`); + params.push(request.host); + paramIndex++; + } + + if (request.port !== undefined) { + fields.push(`port = $${paramIndex}`); + params.push(request.port); + paramIndex++; + } + + if (request.databaseName !== undefined) { + fields.push(`database_name = $${paramIndex}`); + params.push(request.databaseName); + paramIndex++; + } + + if (request.username !== undefined) { + fields.push(`username = $${paramIndex}`); + params.push(request.username); + paramIndex++; + } + + if (request.password !== undefined) { + const encryptedPassword = this.encryption.encrypt(request.password); + fields.push(`password_encrypted = $${paramIndex}`); + params.push(encryptedPassword); + paramIndex++; + } + + if (request.sslEnabled !== undefined) { + fields.push(`ssl_enabled = $${paramIndex}`); + params.push(request.sslEnabled); + paramIndex++; + } + + if (request.connectionOptions !== undefined) { + fields.push(`connection_options = $${paramIndex}`); + params.push( + request.connectionOptions + ? JSON.stringify(request.connectionOptions) + : null + ); + paramIndex++; + } + + if (request.isActive !== undefined) { + fields.push(`is_active = $${paramIndex}`); + params.push(request.isActive); + paramIndex++; + } + + if (fields.length === 0) { + return this.findById(id); + } + + fields.push(`updated_by = $${paramIndex}`); + params.push(userId); + paramIndex++; + + fields.push(`updated_at = NOW()`); + + const query = ` + UPDATE flow_external_db_connection + SET ${fields.join(", ")} + WHERE id = $${paramIndex} + RETURNING * + `; + params.push(id); + + const result = await db.query(query, params); + + if (result.length === 0) { + return null; + } + + // 연결 풀 갱신 (비밀번호 변경 시) + if (request.password !== undefined || request.host !== undefined) { + this.closeConnection(id); + } + + return this.mapToFlowExternalDbConnection(result[0]); + } + + /** + * 외부 DB 연결 삭제 + */ + async delete(id: number): Promise { + // 연결 풀 정리 + this.closeConnection(id); + + const query = + "DELETE FROM flow_external_db_connection WHERE id = $1 RETURNING id"; + const result = await db.query(query, [id]); + return result.length > 0; + } + + /** + * 연결 테스트 + */ + async testConnection( + id: number + ): Promise<{ success: boolean; message: string }> { + try { + const connection = await this.findById(id); + if (!connection) { + return { success: false, message: "연결 정보를 찾을 수 없습니다." }; + } + + const pool = await this.getConnectionPool(connection); + + // 간단한 쿼리로 연결 테스트 + const client = await pool.connect(); + try { + await client.query("SELECT 1"); + return { success: true, message: "연결 성공" }; + } finally { + client.release(); + } + } catch (error: any) { + return { success: false, message: error.message }; + } + } + + /** + * 외부 DB의 테이블 목록 조회 + */ + async getTables( + id: number + ): Promise<{ success: boolean; data?: string[]; message?: string }> { + try { + const connection = await this.findById(id); + if (!connection) { + return { success: false, message: "연결 정보를 찾을 수 없습니다." }; + } + + const pool = await this.getConnectionPool(connection); + const client = await pool.connect(); + + try { + let query: string; + switch (connection.dbType) { + case "postgresql": + query = + "SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename"; + break; + case "mysql": + query = `SELECT table_name as tablename FROM information_schema.tables WHERE table_schema = '${connection.databaseName}' ORDER BY table_name`; + break; + default: + return { + success: false, + message: `지원하지 않는 DB 타입: ${connection.dbType}`, + }; + } + + const result = await client.query(query); + const tables = result.rows.map((row: any) => row.tablename); + + return { success: true, data: tables }; + } finally { + client.release(); + } + } catch (error: any) { + return { success: false, message: error.message }; + } + } + + /** + * 외부 DB의 특정 테이블 컬럼 목록 조회 + */ + async getTableColumns( + id: number, + tableName: string + ): Promise<{ + success: boolean; + data?: { column_name: string; data_type: string }[]; + message?: string; + }> { + try { + const connection = await this.findById(id); + if (!connection) { + return { success: false, message: "연결 정보를 찾을 수 없습니다." }; + } + + const pool = await this.getConnectionPool(connection); + const client = await pool.connect(); + + try { + let query: string; + switch (connection.dbType) { + case "postgresql": + query = `SELECT column_name, data_type + FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = $1 + ORDER BY ordinal_position`; + break; + case "mysql": + query = `SELECT column_name, data_type + FROM information_schema.columns + WHERE table_schema = '${connection.databaseName}' AND table_name = ? + ORDER BY ordinal_position`; + break; + default: + return { + success: false, + message: `지원하지 않는 DB 타입: ${connection.dbType}`, + }; + } + + const result = await client.query(query, [tableName]); + + return { success: true, data: result.rows }; + } finally { + client.release(); + } + } catch (error: any) { + return { success: false, message: error.message }; + } + } + + /** + * 연결 풀 가져오기 (캐싱) + */ + async getConnectionPool(connection: FlowExternalDbConnection): Promise { + if (this.connectionPools.has(connection.id)) { + return this.connectionPools.get(connection.id)!; + } + + // 비밀번호 복호화 + const decryptedPassword = this.encryption.decrypt( + connection.passwordEncrypted + ); + + let pool: Pool; + switch (connection.dbType) { + case "postgresql": + pool = new Pool({ + host: connection.host, + port: connection.port, + database: connection.databaseName, + user: connection.username, + password: decryptedPassword, + ssl: connection.sslEnabled, + // 연결 풀 설정 (고갈 방지) + max: 10, // 최대 연결 수 + min: 2, // 최소 연결 수 + idleTimeoutMillis: 30000, // 30초 유휴 시간 후 연결 해제 + connectionTimeoutMillis: 10000, // 10초 연결 타임아웃 + ...(connection.connectionOptions || {}), + }); + + // 에러 핸들러 등록 + pool.on("error", (err) => { + console.error(`외부 DB 연결 풀 오류 (ID: ${connection.id}):`, err); + }); + break; + // case "mysql": + // pool = mysql.createPool({ ... }); + // break; + // case "mssql": + // pool = new ConnectionPool({ ... }); + // break; + default: + throw new Error(`지원하지 않는 DB 타입: ${connection.dbType}`); + } + + this.connectionPools.set(connection.id, pool); + return pool; + } + + /** + * 연결 풀 정리 + */ + closeConnection(id: number): void { + const pool = this.connectionPools.get(id); + if (pool) { + pool.end(); + this.connectionPools.delete(id); + } + } + + /** + * 모든 연결 풀 정리 + */ + closeAllConnections(): void { + for (const [id, pool] of this.connectionPools.entries()) { + pool.end(); + } + this.connectionPools.clear(); + } + + /** + * DB row를 FlowExternalDbConnection으로 매핑 + */ + private mapToFlowExternalDbConnection(row: any): FlowExternalDbConnection { + return { + id: row.id, + name: row.name, + description: row.description || undefined, + dbType: row.db_type, + host: row.host, + port: row.port, + databaseName: row.database_name, + username: row.username, + passwordEncrypted: row.password_encrypted, + sslEnabled: row.ssl_enabled, + connectionOptions: row.connection_options || undefined, + isActive: row.is_active, + createdBy: row.created_by || undefined, + updatedBy: row.updated_by || undefined, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; + } +} diff --git a/backend-node/src/services/flowExternalDbIntegrationService.ts b/backend-node/src/services/flowExternalDbIntegrationService.ts new file mode 100644 index 00000000..80c8e4b1 --- /dev/null +++ b/backend-node/src/services/flowExternalDbIntegrationService.ts @@ -0,0 +1,353 @@ +import { + FlowExternalDbIntegrationConfig, + FlowIntegrationContext, + FlowIntegrationResult, +} from "../types/flow"; +import { FlowExternalDbConnectionService } from "./flowExternalDbConnectionService"; +import { Pool } from "pg"; + +/** + * 플로우 외부 DB 연동 실행 서비스 + * 외부 데이터베이스에 대한 작업(INSERT, UPDATE, DELETE, CUSTOM QUERY) 수행 + */ +export class FlowExternalDbIntegrationService { + private connectionService: FlowExternalDbConnectionService; + + constructor() { + this.connectionService = new FlowExternalDbConnectionService(); + } + + /** + * 외부 DB 연동 실행 + */ + async execute( + context: FlowIntegrationContext, + config: FlowExternalDbIntegrationConfig + ): Promise { + const startTime = Date.now(); + + try { + // 1. 연결 정보 조회 + const connection = await this.connectionService.findById( + config.connectionId + ); + if (!connection) { + return { + success: false, + error: { + code: "CONNECTION_NOT_FOUND", + message: `외부 DB 연결 정보를 찾을 수 없습니다 (ID: ${config.connectionId})`, + }, + }; + } + + if (!connection.isActive) { + return { + success: false, + error: { + code: "CONNECTION_INACTIVE", + message: `외부 DB 연결이 비활성화 상태입니다 (${connection.name})`, + }, + }; + } + + // 2. 쿼리 생성 (템플릿 변수 치환) + const query = this.buildQuery(config, context); + + // 3. 실행 + const pool = await this.connectionService.getConnectionPool(connection); + const result = await this.executeQuery(pool, query); + + const executionTime = Date.now() - startTime; + + return { + success: true, + message: `외부 DB 작업 성공 (${config.operation}, ${executionTime}ms)`, + data: result, + rollbackInfo: { + query: this.buildRollbackQuery(config, context, result), + connectionId: config.connectionId, + }, + }; + } catch (error: any) { + const executionTime = Date.now() - startTime; + + return { + success: false, + error: { + code: "EXTERNAL_DB_ERROR", + message: error.message || "외부 DB 작업 실패", + details: { + operation: config.operation, + tableName: config.tableName, + executionTime, + originalError: error, + }, + }, + }; + } + } + + /** + * 쿼리 실행 + */ + private async executeQuery( + pool: Pool, + query: { sql: string; params: any[] } + ): Promise { + const client = await pool.connect(); + try { + const result = await client.query(query.sql, query.params); + return result.rows; + } finally { + client.release(); + } + } + + /** + * 쿼리 빌드 (템플릿 변수 치환 포함) + */ + private buildQuery( + config: FlowExternalDbIntegrationConfig, + context: FlowIntegrationContext + ): { sql: string; params: any[] } { + let sql = ""; + const params: any[] = []; + let paramIndex = 1; + + switch (config.operation) { + case "update": + return this.buildUpdateQuery(config, context, paramIndex); + case "insert": + return this.buildInsertQuery(config, context, paramIndex); + case "delete": + return this.buildDeleteQuery(config, context, paramIndex); + case "custom": + return this.buildCustomQuery(config, context); + default: + throw new Error(`지원하지 않는 작업: ${config.operation}`); + } + } + + /** + * UPDATE 쿼리 빌드 + */ + private buildUpdateQuery( + config: FlowExternalDbIntegrationConfig, + context: FlowIntegrationContext, + startIndex: number + ): { sql: string; params: any[] } { + if (!config.updateFields || Object.keys(config.updateFields).length === 0) { + throw new Error("UPDATE 작업에는 updateFields가 필요합니다"); + } + + if ( + !config.whereCondition || + Object.keys(config.whereCondition).length === 0 + ) { + throw new Error("UPDATE 작업에는 whereCondition이 필요합니다"); + } + + const setClauses: string[] = []; + const params: any[] = []; + let paramIndex = startIndex; + + // SET 절 생성 + for (const [key, value] of Object.entries(config.updateFields)) { + setClauses.push(`${key} = $${paramIndex}`); + params.push(this.replaceVariables(value, context)); + paramIndex++; + } + + // WHERE 절 생성 + const whereClauses: string[] = []; + for (const [key, value] of Object.entries(config.whereCondition)) { + whereClauses.push(`${key} = $${paramIndex}`); + params.push(this.replaceVariables(value, context)); + paramIndex++; + } + + const sql = `UPDATE ${config.tableName} SET ${setClauses.join(", ")} WHERE ${whereClauses.join(" AND ")}`; + + return { sql, params }; + } + + /** + * INSERT 쿼리 빌드 + */ + private buildInsertQuery( + config: FlowExternalDbIntegrationConfig, + context: FlowIntegrationContext, + startIndex: number + ): { sql: string; params: any[] } { + if (!config.updateFields || Object.keys(config.updateFields).length === 0) { + throw new Error("INSERT 작업에는 updateFields가 필요합니다"); + } + + const columns: string[] = []; + const placeholders: string[] = []; + const params: any[] = []; + let paramIndex = startIndex; + + for (const [key, value] of Object.entries(config.updateFields)) { + columns.push(key); + placeholders.push(`$${paramIndex}`); + params.push(this.replaceVariables(value, context)); + paramIndex++; + } + + const sql = `INSERT INTO ${config.tableName} (${columns.join(", ")}) VALUES (${placeholders.join(", ")}) RETURNING *`; + + return { sql, params }; + } + + /** + * DELETE 쿼리 빌드 + */ + private buildDeleteQuery( + config: FlowExternalDbIntegrationConfig, + context: FlowIntegrationContext, + startIndex: number + ): { sql: string; params: any[] } { + if ( + !config.whereCondition || + Object.keys(config.whereCondition).length === 0 + ) { + throw new Error("DELETE 작업에는 whereCondition이 필요합니다"); + } + + const whereClauses: string[] = []; + const params: any[] = []; + let paramIndex = startIndex; + + for (const [key, value] of Object.entries(config.whereCondition)) { + whereClauses.push(`${key} = $${paramIndex}`); + params.push(this.replaceVariables(value, context)); + paramIndex++; + } + + const sql = `DELETE FROM ${config.tableName} WHERE ${whereClauses.join(" AND ")}`; + + return { sql, params }; + } + + /** + * CUSTOM 쿼리 빌드 + */ + private buildCustomQuery( + config: FlowExternalDbIntegrationConfig, + context: FlowIntegrationContext + ): { sql: string; params: any[] } { + if (!config.customQuery) { + throw new Error("CUSTOM 작업에는 customQuery가 필요합니다"); + } + + // 템플릿 변수 치환 + const sql = this.replaceVariables(config.customQuery, context); + + // 커스텀 쿼리는 파라미터를 직접 관리 + // 보안을 위해 가능하면 파라미터 바인딩 사용 권장 + return { sql, params: [] }; + } + + /** + * 템플릿 변수 치환 + */ + private replaceVariables(value: any, context: FlowIntegrationContext): any { + if (typeof value !== "string") { + return value; + } + + let result = value; + + // {{dataId}} 치환 + result = result.replace(/\{\{dataId\}\}/g, String(context.dataId)); + + // {{currentUser}} 치환 + result = result.replace(/\{\{currentUser\}\}/g, context.currentUser); + + // {{currentTimestamp}} 치환 + result = result.replace( + /\{\{currentTimestamp\}\}/g, + new Date().toISOString() + ); + + // {{flowId}} 치환 + result = result.replace(/\{\{flowId\}\}/g, String(context.flowId)); + + // {{stepId}} 치환 + result = result.replace(/\{\{stepId\}\}/g, String(context.stepId)); + + // {{tableName}} 치환 + if (context.tableName) { + result = result.replace(/\{\{tableName\}\}/g, context.tableName); + } + + // context.variables의 커스텀 변수 치환 + for (const [key, val] of Object.entries(context.variables)) { + const regex = new RegExp(`\\{\\{${key}\\}\\}`, "g"); + result = result.replace(regex, String(val)); + } + + // NOW() 같은 SQL 함수는 그대로 반환 + if (result === "NOW()" || result.startsWith("CURRENT_")) { + return result; + } + + return result; + } + + /** + * 롤백 쿼리 생성 + */ + private buildRollbackQuery( + config: FlowExternalDbIntegrationConfig, + context: FlowIntegrationContext, + result: any + ): { sql: string; params: any[] } | null { + // 롤백 쿼리 생성 로직 (복잡하므로 실제 구현 시 상세 설계 필요) + // 예: INSERT -> DELETE, UPDATE -> 이전 값으로 UPDATE + + switch (config.operation) { + case "insert": + // INSERT를 롤백하려면 삽입된 레코드를 DELETE + if (result && result[0] && result[0].id) { + return { + sql: `DELETE FROM ${config.tableName} WHERE id = $1`, + params: [result[0].id], + }; + } + break; + case "delete": + // DELETE 롤백은 매우 어려움 (원본 데이터 필요) + console.warn("DELETE 작업의 롤백은 지원하지 않습니다"); + break; + case "update": + // UPDATE 롤백을 위해서는 이전 값을 저장해야 함 + console.warn("UPDATE 작업의 롤백은 현재 구현되지 않았습니다"); + break; + default: + break; + } + + return null; + } + + /** + * 롤백 실행 + */ + async rollback( + connectionId: number, + rollbackQuery: { sql: string; params: any[] } + ): Promise { + const connection = await this.connectionService.findById(connectionId); + if (!connection) { + throw new Error( + `롤백 실패: 연결 정보를 찾을 수 없습니다 (ID: ${connectionId})` + ); + } + + const pool = await this.connectionService.getConnectionPool(connection); + await this.executeQuery(pool, rollbackQuery); + } +} diff --git a/backend-node/src/services/flowStepService.ts b/backend-node/src/services/flowStepService.ts index 6c55bfbe..e8cf1fb9 100644 --- a/backend-node/src/services/flowStepService.ts +++ b/backend-node/src/services/flowStepService.ts @@ -24,9 +24,11 @@ export class FlowStepService { const query = ` INSERT INTO flow_step ( flow_definition_id, step_name, step_order, table_name, condition_json, - color, position_x, position_y + color, position_x, position_y, move_type, status_column, status_value, + target_table, field_mappings, required_fields, + integration_type, integration_config ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) RETURNING * `; @@ -39,6 +41,16 @@ export class FlowStepService { request.color || "#3B82F6", request.positionX || 0, request.positionY || 0, + request.moveType || null, + request.statusColumn || null, + request.statusValue || null, + request.targetTable || null, + request.fieldMappings ? JSON.stringify(request.fieldMappings) : null, + request.requiredFields ? JSON.stringify(request.requiredFields) : null, + request.integrationType || "internal", + request.integrationConfig + ? JSON.stringify(request.integrationConfig) + : null, ]); return this.mapToFlowStep(result[0]); @@ -79,6 +91,13 @@ export class FlowStepService { id: number, request: UpdateFlowStepRequest ): Promise { + console.log("🔧 FlowStepService.update called with:", { + id, + statusColumn: request.statusColumn, + statusValue: request.statusValue, + fullRequest: JSON.stringify(request), + }); + // 조건 검증 if (request.conditionJson) { FlowConditionParser.validateConditionGroup(request.conditionJson); @@ -132,6 +151,64 @@ export class FlowStepService { paramIndex++; } + // 하이브리드 플로우 필드 + if (request.moveType !== undefined) { + fields.push(`move_type = $${paramIndex}`); + params.push(request.moveType); + paramIndex++; + } + + if (request.statusColumn !== undefined) { + fields.push(`status_column = $${paramIndex}`); + params.push(request.statusColumn); + paramIndex++; + } + + if (request.statusValue !== undefined) { + fields.push(`status_value = $${paramIndex}`); + params.push(request.statusValue); + paramIndex++; + } + + if (request.targetTable !== undefined) { + fields.push(`target_table = $${paramIndex}`); + params.push(request.targetTable); + paramIndex++; + } + + if (request.fieldMappings !== undefined) { + fields.push(`field_mappings = $${paramIndex}`); + params.push( + request.fieldMappings ? JSON.stringify(request.fieldMappings) : null + ); + paramIndex++; + } + + if (request.requiredFields !== undefined) { + fields.push(`required_fields = $${paramIndex}`); + params.push( + request.requiredFields ? JSON.stringify(request.requiredFields) : null + ); + paramIndex++; + } + + // 외부 연동 필드 + if (request.integrationType !== undefined) { + fields.push(`integration_type = $${paramIndex}`); + params.push(request.integrationType); + paramIndex++; + } + + if (request.integrationConfig !== undefined) { + fields.push(`integration_config = $${paramIndex}`); + params.push( + request.integrationConfig + ? JSON.stringify(request.integrationConfig) + : null + ); + paramIndex++; + } + if (fields.length === 0) { return this.findById(id); } @@ -202,6 +279,9 @@ export class FlowStepService { targetTable: row.target_table || undefined, fieldMappings: row.field_mappings || undefined, requiredFields: row.required_fields || undefined, + // 외부 연동 필드 + integrationType: row.integration_type || "internal", + integrationConfig: row.integration_config || undefined, createdAt: row.created_at, updatedAt: row.updated_at, }; diff --git a/backend-node/src/types/flow.ts b/backend-node/src/types/flow.ts index 9c5a8270..3483b617 100644 --- a/backend-node/src/types/flow.ts +++ b/backend-node/src/types/flow.ts @@ -80,6 +80,9 @@ export interface FlowStep { targetTable?: string; // 타겟 테이블명 (테이블 이동 방식) fieldMappings?: Record; // 필드 매핑 정보 requiredFields?: string[]; // 필수 입력 필드 + // 외부 연동 필드 + integrationType?: FlowIntegrationType; // 연동 타입 (기본값: internal) + integrationConfig?: FlowIntegrationConfig; // 연동 설정 (JSONB) createdAt: Date; updatedAt: Date; } @@ -101,6 +104,9 @@ export interface CreateFlowStepRequest { targetTable?: string; fieldMappings?: Record; requiredFields?: string[]; + // 외부 연동 필드 + integrationType?: FlowIntegrationType; + integrationConfig?: FlowIntegrationConfig; } // 플로우 단계 수정 요청 @@ -119,6 +125,9 @@ export interface UpdateFlowStepRequest { targetTable?: string; fieldMappings?: Record; requiredFields?: string[]; + // 외부 연동 필드 + integrationType?: FlowIntegrationType; + integrationConfig?: FlowIntegrationConfig; } // 플로우 단계 연결 @@ -208,3 +217,129 @@ export interface SqlWhereResult { where: string; params: any[]; } + +// ==================== 플로우 외부 연동 타입 ==================== + +// 연동 타입 +export type FlowIntegrationType = + | "internal" // 내부 DB (기본값) + | "external_db" // 외부 DB + | "rest_api" // REST API (추후 구현) + | "webhook" // Webhook (추후 구현) + | "hybrid"; // 복합 연동 (추후 구현) + +// 플로우 전용 외부 DB 연결 정보 +export interface FlowExternalDbConnection { + id: number; + name: string; + description?: string; + dbType: "postgresql" | "mysql" | "mssql" | "oracle"; + host: string; + port: number; + databaseName: string; + username: string; + passwordEncrypted: string; // 암호화된 비밀번호 + sslEnabled: boolean; + connectionOptions?: Record; + isActive: boolean; + createdBy?: string; + updatedBy?: string; + createdAt: Date; + updatedAt: Date; +} + +// 외부 DB 연결 생성 요청 +export interface CreateFlowExternalDbConnectionRequest { + name: string; + description?: string; + dbType: "postgresql" | "mysql" | "mssql" | "oracle"; + host: string; + port: number; + databaseName: string; + username: string; + password: string; // 평문 비밀번호 (저장 시 암호화) + sslEnabled?: boolean; + connectionOptions?: Record; +} + +// 외부 DB 연결 수정 요청 +export interface UpdateFlowExternalDbConnectionRequest { + name?: string; + description?: string; + host?: string; + port?: number; + databaseName?: string; + username?: string; + password?: string; // 평문 비밀번호 (저장 시 암호화) + sslEnabled?: boolean; + connectionOptions?: Record; + isActive?: boolean; +} + +// 외부 DB 연동 설정 (integration_config JSON) +export interface FlowExternalDbIntegrationConfig { + type: "external_db"; + connectionId: number; // flow_external_db_connection.id + operation: "update" | "insert" | "delete" | "custom"; + tableName: string; + updateFields?: Record; // 업데이트할 필드 (템플릿 변수 지원) + whereCondition?: Record; // WHERE 조건 (템플릿 변수 지원) + customQuery?: string; // operation이 'custom'인 경우 사용 +} + +// 연동 설정 통합 타입 +export type FlowIntegrationConfig = FlowExternalDbIntegrationConfig; // 나중에 다른 타입 추가 + +// 연동 실행 컨텍스트 +export interface FlowIntegrationContext { + flowId: number; + stepId: number; + dataId: string | number; + tableName?: string; + currentUser: string; + variables: Record; // 템플릿 변수 ({{dataId}}, {{currentUser}} 등) + transactionId?: string; +} + +// 연동 실행 결과 +export interface FlowIntegrationResult { + success: boolean; + message?: string; + data?: any; + error?: { + code: string; + message: string; + details?: any; + }; + rollbackInfo?: any; // 롤백을 위한 정보 +} + +// 외부 연동 실행 로그 +export interface FlowIntegrationLog { + id: number; + flowDefinitionId: number; + stepId: number; + dataId?: string; + integrationType: string; + connectionId?: number; + requestPayload?: Record; + responsePayload?: Record; + status: "success" | "failed" | "timeout" | "rollback"; + errorMessage?: string; + executionTimeMs?: number; + executedBy?: string; + executedAt: Date; +} + +// 외부 연결 권한 +export interface FlowExternalConnectionPermission { + id: number; + connectionId: number; + userId?: number; + roleName?: string; + canView: boolean; + canUse: boolean; + canEdit: boolean; + canDelete: boolean; + createdAt: Date; +} diff --git a/backend-node/src/utils/credentialEncryption.ts b/backend-node/src/utils/credentialEncryption.ts new file mode 100644 index 00000000..89a79b02 --- /dev/null +++ b/backend-node/src/utils/credentialEncryption.ts @@ -0,0 +1,61 @@ +import crypto from "crypto"; + +/** + * 자격 증명 암호화 유틸리티 + * AES-256-GCM 알고리즘 사용 + */ +export class CredentialEncryption { + private algorithm = "aes-256-gcm"; + private key: Buffer; + + constructor(secretKey: string) { + // scrypt로 안전한 키 생성 + this.key = crypto.scryptSync(secretKey, "salt", 32); + } + + /** + * 평문을 암호화 + */ + encrypt(text: string): string { + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv( + this.algorithm, + this.key, + iv + ) as crypto.CipherGCM; + + let encrypted = cipher.update(text, "utf8", "hex"); + encrypted += cipher.final("hex"); + + const authTag = cipher.getAuthTag(); + + // IV:AuthTag:EncryptedText 형식으로 반환 + return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted}`; + } + + /** + * 암호문을 복호화 + */ + decrypt(encrypted: string): string { + const [ivHex, authTagHex, encryptedText] = encrypted.split(":"); + + if (!ivHex || !authTagHex || !encryptedText) { + throw new Error("Invalid encrypted string format"); + } + + const iv = Buffer.from(ivHex, "hex"); + const authTag = Buffer.from(authTagHex, "hex"); + const decipher = crypto.createDecipheriv( + this.algorithm, + this.key, + iv + ) as crypto.DecipherGCM; + + decipher.setAuthTag(authTag); + + let decrypted = decipher.update(encryptedText, "hex", "utf8"); + decrypted += decipher.final("utf8"); + + return decrypted; + } +} diff --git a/docs/FLOW_EXTERNAL_INTEGRATION_PLAN.md b/docs/FLOW_EXTERNAL_INTEGRATION_PLAN.md new file mode 100644 index 00000000..a8bdc0da --- /dev/null +++ b/docs/FLOW_EXTERNAL_INTEGRATION_PLAN.md @@ -0,0 +1,762 @@ +# 플로우 관리 시스템 - 외부 연동 확장 계획 + +## 개요 + +현재 플로우 관리 시스템은 내부 데이터베이스의 상태 변경만 지원합니다. +실제 업무 환경에서는 다음과 같은 외부 연동이 필요합니다: + +1. **외부 데이터베이스**: 다른 DB 서버의 데이터 상태 변경 +2. **REST API 호출**: 외부 시스템 API를 통한 상태 업데이트 +3. **Webhook**: 외부 시스템으로 이벤트 전송 +4. **복합 연동**: 내부 DB + 외부 API 동시 처리 + +--- + +## 1. 데이터베이스 스키마 확장 + +### 1.1 플로우 단계 설정 확장 + +```sql +-- flow_step 테이블에 외부 연동 설정 추가 +ALTER TABLE flow_step ADD COLUMN integration_type VARCHAR(50); +-- 값: 'internal' | 'external_db' | 'rest_api' | 'webhook' | 'hybrid' + +ALTER TABLE flow_step ADD COLUMN integration_config JSONB; +-- 외부 연동 상세 설정 (JSON) + +COMMENT ON COLUMN flow_step.integration_type IS '연동 타입: internal/external_db/rest_api/webhook/hybrid'; +COMMENT ON COLUMN flow_step.integration_config IS '외부 연동 설정 (JSON 형식)'; +``` + +### 1.2 외부 연결 정보 관리 테이블 + +```sql +-- 외부 데이터베이스 연결 정보 +CREATE TABLE external_db_connection ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL UNIQUE, + description TEXT, + db_type VARCHAR(50) NOT NULL, -- 'postgresql' | 'mysql' | 'mssql' | 'oracle' + host VARCHAR(255) NOT NULL, + port INTEGER NOT NULL, + database_name VARCHAR(100) NOT NULL, + username VARCHAR(100) NOT NULL, + password_encrypted TEXT NOT NULL, -- 암호화된 비밀번호 + ssl_enabled BOOLEAN DEFAULT false, + connection_options JSONB, -- 추가 연결 옵션 + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +COMMENT ON TABLE external_db_connection IS '외부 데이터베이스 연결 정보'; + +-- 외부 API 연결 정보 +CREATE TABLE external_api_connection ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL UNIQUE, + description TEXT, + base_url VARCHAR(500) NOT NULL, + auth_type VARCHAR(50), -- 'none' | 'basic' | 'bearer' | 'api_key' | 'oauth2' + auth_config JSONB, -- 인증 설정 (암호화된 토큰/키 포함) + default_headers JSONB, -- 기본 헤더 + timeout_ms INTEGER DEFAULT 30000, + retry_count INTEGER DEFAULT 3, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +COMMENT ON TABLE external_api_connection IS '외부 REST API 연결 정보'; +``` + +--- + +## 2. integration_config JSON 스키마 + +### 2.1 External DB 설정 + +```json +{ + "type": "external_db", + "connectionId": 5, // external_db_connection.id + "operation": "update", // 'update' | 'insert' | 'delete' | 'custom' + "tableName": "external_orders", + "updateFields": { + "status": "approved", + "approved_at": "NOW()", + "approved_by": "{{currentUser}}" + }, + "whereCondition": { + "id": "{{dataId}}", + "company_code": "{{companyCode}}" + }, + "customQuery": null // operation이 'custom'인 경우 사용 +} +``` + +### 2.2 REST API 설정 + +```json +{ + "type": "rest_api", + "connectionId": 3, // external_api_connection.id + "method": "POST", // 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' + "endpoint": "/api/orders/{{dataId}}/approve", + "headers": { + "Content-Type": "application/json", + "X-Request-ID": "{{generateUUID}}" + }, + "body": { + "status": "approved", + "approvedBy": "{{currentUser}}", + "approvedAt": "{{currentTimestamp}}", + "notes": "{{notes}}" + }, + "successCondition": { + "statusCode": [200, 201], + "responseField": "success", + "expectedValue": true + }, + "errorHandling": { + "onFailure": "rollback", // 'rollback' | 'continue' | 'retry' + "maxRetries": 3, + "retryDelay": 1000 + } +} +``` + +### 2.3 Webhook 설정 + +```json +{ + "type": "webhook", + "url": "https://external-system.com/webhooks/flow-status-change", + "method": "POST", + "headers": { + "Content-Type": "application/json", + "Authorization": "Bearer {{webhookToken}}" + }, + "payload": { + "event": "flow.status.changed", + "flowId": "{{flowId}}", + "stepId": "{{stepId}}", + "dataId": "{{dataId}}", + "previousStatus": "{{previousStatus}}", + "currentStatus": "{{currentStatus}}", + "changedBy": "{{currentUser}}", + "changedAt": "{{currentTimestamp}}" + }, + "async": true, // 비동기 처리 여부 + "timeout": 5000 +} +``` + +### 2.4 Hybrid (복합) 설정 + +```json +{ + "type": "hybrid", + "steps": [ + { + "order": 1, + "name": "internal_db_update", + "type": "internal", + "config": { + "tableName": "orders", + "statusColumn": "order_status", + "statusValue": "approved" + }, + "onError": "rollback" + }, + { + "order": 2, + "name": "notify_external_system", + "type": "rest_api", + "config": { + "connectionId": 3, + "method": "POST", + "endpoint": "/api/notifications/order-approved", + "body": { + "orderId": "{{dataId}}", + "status": "approved" + } + }, + "onError": "log" // API 실패해도 계속 진행 + }, + { + "order": 3, + "name": "update_warehouse_system", + "type": "external_db", + "config": { + "connectionId": 5, + "operation": "update", + "tableName": "warehouse_orders", + "updateFields": { + "status": "ready_to_ship" + }, + "whereCondition": { + "order_ref": "{{dataId}}" + } + }, + "onError": "rollback" + } + ], + "transactionMode": "sequential", // 'sequential' | 'parallel' + "rollbackStrategy": "all" // 'all' | 'completed_only' | 'none' +} +``` + +--- + +## 3. 백엔드 서비스 구조 + +### 3.1 서비스 계층 구조 + +``` +flowDataMoveService (기존) + └── FlowIntegrationService (신규) + ├── InternalDbIntegration + ├── ExternalDbIntegration + ├── RestApiIntegration + ├── WebhookIntegration + └── HybridIntegration +``` + +### 3.2 주요 인터페이스 + +```typescript +// 통합 인터페이스 +interface FlowIntegration { + execute(context: IntegrationContext): Promise; + validate(config: any): ValidationResult; + rollback(context: IntegrationContext): Promise; +} + +// 실행 컨텍스트 +interface IntegrationContext { + flowId: number; + stepId: number; + dataId: string | number; + tableName?: string; + currentUser: string; + variables: Record; // 템플릿 변수 + transactionId?: string; +} + +// 실행 결과 +interface IntegrationResult { + success: boolean; + message?: string; + data?: any; + error?: { + code: string; + message: string; + details?: any; + }; + rollbackInfo?: any; // 롤백을 위한 정보 +} +``` + +### 3.3 외부 DB 연동 서비스 + +```typescript +export class ExternalDbIntegration implements FlowIntegration { + private connectionPool: Map = new Map(); + + async execute(context: IntegrationContext): Promise { + const config = context.step.integrationConfig; + + // 1. 연결 정보 조회 + const connection = await this.getConnection(config.connectionId); + + // 2. 쿼리 생성 (템플릿 변수 치환) + const query = this.buildQuery(config, context); + + // 3. 실행 + try { + const result = await this.executeQuery(connection, query); + + return { + success: true, + data: result, + rollbackInfo: { + query: this.buildRollbackQuery(config, context), + connection: config.connectionId, + }, + }; + } catch (error) { + return { + success: false, + error: { + code: "EXTERNAL_DB_ERROR", + message: error.message, + details: error, + }, + }; + } + } + + async getConnection(connectionId: number) { + // 연결 풀에서 가져오거나 새로 생성 + if (this.connectionPool.has(connectionId)) { + return this.connectionPool.get(connectionId); + } + + const connInfo = await this.loadConnectionInfo(connectionId); + const connection = await this.createConnection(connInfo); + this.connectionPool.set(connectionId, connection); + + return connection; + } + + private buildQuery(config: any, context: IntegrationContext): string { + // 템플릿 변수 치환 + const replacedConfig = this.replaceVariables(config, context); + + switch (config.operation) { + case "update": + return this.buildUpdateQuery(replacedConfig); + case "insert": + return this.buildInsertQuery(replacedConfig); + case "delete": + return this.buildDeleteQuery(replacedConfig); + case "custom": + return replacedConfig.customQuery; + default: + throw new Error(`Unsupported operation: ${config.operation}`); + } + } + + async rollback(context: IntegrationContext): Promise { + const rollbackInfo = context.rollbackInfo; + const connection = await this.getConnection(rollbackInfo.connection); + await this.executeQuery(connection, rollbackInfo.query); + } +} +``` + +### 3.4 REST API 연동 서비스 + +```typescript +export class RestApiIntegration implements FlowIntegration { + private axiosInstances: Map = new Map(); + + async execute(context: IntegrationContext): Promise { + const config = context.step.integrationConfig; + + // 1. API 클라이언트 생성 + const client = await this.getApiClient(config.connectionId); + + // 2. 요청 구성 (템플릿 변수 치환) + const request = this.buildRequest(config, context); + + // 3. API 호출 + try { + const response = await this.executeRequest(client, request); + + // 4. 성공 조건 검증 + const isSuccess = this.validateSuccess(response, config.successCondition); + + if (isSuccess) { + return { + success: true, + data: response.data, + rollbackInfo: { + compensatingRequest: this.buildCompensatingRequest( + config, + context, + response + ), + }, + }; + } else { + throw new Error("API call succeeded but validation failed"); + } + } catch (error) { + // 에러 처리 및 재시도 + return this.handleError(error, config, context); + } + } + + private async executeRequest( + client: AxiosInstance, + request: any + ): Promise { + const { method, endpoint, headers, body, timeout } = request; + + return await client.request({ + method, + url: endpoint, + headers, + data: body, + timeout: timeout || 30000, + }); + } + + private async handleError( + error: any, + config: any, + context: IntegrationContext + ): Promise { + const errorHandling = config.errorHandling; + + if (errorHandling.onFailure === "retry") { + // 재시도 로직 + for (let i = 0; i < errorHandling.maxRetries; i++) { + await this.delay(errorHandling.retryDelay); + try { + return await this.execute(context); + } catch (retryError) { + if (i === errorHandling.maxRetries - 1) { + throw retryError; + } + } + } + } + + return { + success: false, + error: { + code: "REST_API_ERROR", + message: error.message, + details: error.response?.data, + }, + }; + } + + async rollback(context: IntegrationContext): Promise { + const rollbackInfo = context.rollbackInfo; + if (rollbackInfo.compensatingRequest) { + const client = await this.getApiClient(rollbackInfo.connectionId); + await this.executeRequest(client, rollbackInfo.compensatingRequest); + } + } +} +``` + +--- + +## 4. 프론트엔드 UI 확장 + +### 4.1 플로우 단계 설정 패널 확장 + +```typescript +// FlowStepPanel.tsx에 추가 + +// 연동 타입 선택 +; + +// 연동 타입별 설정 UI +{ + integrationType === "external_db" && ( + + ); +} + +{ + integrationType === "rest_api" && ( + + ); +} +``` + +### 4.2 외부 DB 설정 패널 + +```typescript +export function ExternalDbConfigPanel({ config, onChange }) { + return ( +
+ {/* 연결 선택 */} + + + {/* 작업 타입 */} + + + {/* 테이블명 */} + onChange({ ...config, tableName: e.target.value })} + /> + + {/* 업데이트 필드 */} + onChange({ ...config, updateFields: fields })} + /> + + {/* WHERE 조건 */} + + onChange({ ...config, whereCondition: conditions }) + } + /> +
+ ); +} +``` + +### 4.3 REST API 설정 패널 + +```typescript +export function RestApiConfigPanel({ config, onChange }) { + return ( +
+ {/* API 연결 선택 */} + + + {/* HTTP 메서드 */} + + + {/* 엔드포인트 */} + onChange({ ...config, endpoint: e.target.value })} + /> + + {/* 헤더 */} + onChange({ ...config, headers })} + /> + + {/* 요청 본문 */} + onChange({ ...config, body })} + /> + + {/* 성공 조건 */} + + onChange({ ...config, successCondition: condition }) + } + /> +
+ ); +} +``` + +--- + +## 5. 보안 고려사항 + +### 5.1 자격 증명 암호화 + +```typescript +// 비밀번호/토큰 암호화 +import crypto from "crypto"; + +export class CredentialEncryption { + private algorithm = "aes-256-gcm"; + private key: Buffer; + + constructor(secretKey: string) { + this.key = crypto.scryptSync(secretKey, "salt", 32); + } + + encrypt(text: string): string { + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv(this.algorithm, this.key, iv); + + let encrypted = cipher.update(text, "utf8", "hex"); + encrypted += cipher.final("hex"); + + const authTag = cipher.getAuthTag(); + + return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted}`; + } + + decrypt(encrypted: string): string { + const [ivHex, authTagHex, encryptedText] = encrypted.split(":"); + + const iv = Buffer.from(ivHex, "hex"); + const authTag = Buffer.from(authTagHex, "hex"); + const decipher = crypto.createDecipheriv(this.algorithm, this.key, iv); + + decipher.setAuthTag(authTag); + + let decrypted = decipher.update(encryptedText, "hex", "utf8"); + decrypted += decipher.final("utf8"); + + return decrypted; + } +} +``` + +### 5.2 권한 관리 + +```sql +-- 외부 연결 접근 권한 +CREATE TABLE external_connection_permission ( + id SERIAL PRIMARY KEY, + connection_type VARCHAR(50) NOT NULL, -- 'db' | 'api' + connection_id INTEGER NOT NULL, + user_id INTEGER, + role_id INTEGER, + can_view BOOLEAN DEFAULT false, + can_use BOOLEAN DEFAULT false, + can_edit BOOLEAN DEFAULT false, + can_delete BOOLEAN DEFAULT false, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +``` + +--- + +## 6. 모니터링 및 로깅 + +### 6.1 외부 연동 로그 + +```sql +CREATE TABLE flow_integration_log ( + id SERIAL PRIMARY KEY, + flow_definition_id INTEGER NOT NULL, + step_id INTEGER NOT NULL, + data_id VARCHAR(100), + integration_type VARCHAR(50) NOT NULL, + connection_id INTEGER, + request_payload JSONB, + response_payload JSONB, + status VARCHAR(50) NOT NULL, -- 'success' | 'failed' | 'timeout' | 'rollback' + error_message TEXT, + execution_time_ms INTEGER, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_flow_integration_log_flow ON flow_integration_log(flow_definition_id); +CREATE INDEX idx_flow_integration_log_status ON flow_integration_log(status); +CREATE INDEX idx_flow_integration_log_created ON flow_integration_log(created_at); +``` + +--- + +## 7. 구현 우선순위 + +### Phase 1: 외부 DB 연동 (2-3주) + +1. 외부 DB 연결 정보 관리 UI +2. ExternalDbIntegration 서비스 구현 +3. 플로우 단계 설정에서 외부 DB 선택 기능 +4. 테스트 및 검증 + +### Phase 2: REST API 연동 (2-3주) + +1. API 연결 정보 관리 UI +2. RestApiIntegration 서비스 구현 +3. 템플릿 변수 시스템 구축 +4. 재시도 및 에러 처리 + +### Phase 3: 복합 연동 (2주) + +1. HybridIntegration 서비스 구현 +2. 트랜잭션 관리 및 롤백 +3. UI에서 복합 시나리오 구성 + +### Phase 4: 모니터링 및 최적화 (1-2주) + +1. 로깅 시스템 구축 +2. 성능 모니터링 대시보드 +3. 알림 시스템 + +--- + +## 8. 사용 예시 + +### 예시 1: 주문 승인 시 외부 ERP 시스템 업데이트 + +``` +플로우: 주문 승인 프로세스 + ↓ +검토중 단계 + ↓ +승인됨 단계 (외부 연동) + - 내부 DB: orders.status = 'approved' + - 외부 ERP API: POST /api/orders/approve + { + "orderId": "{{dataId}}", + "approvedBy": "{{currentUser}}", + "approvedAt": "{{timestamp}}" + } + - Webhook: 회계 시스템에 승인 알림 +``` + +### 예시 2: 재고 이동 시 창고 관리 DB 업데이트 + +``` +플로우: 재고 이동 프로세스 + ↓ +이동 요청 단계 + ↓ +이동 완료 단계 (외부 DB 연동) + - 내부 DB: inventory_transfer.status = 'completed' + - 외부 창고 DB: + UPDATE warehouse_stock + SET quantity = quantity - {{transferQty}} + WHERE product_id = {{productId}} + AND warehouse_id = {{fromWarehouse}} +``` + +--- + +## 9. 기대 효과 + +1. **시스템 통합**: 여러 시스템 간 데이터 동기화 자동화 +2. **업무 효율**: 수동 데이터 입력 감소 +3. **실시간 연동**: 상태 변경 즉시 외부 시스템에 반영 +4. **확장성**: 새로운 외부 시스템 쉽게 추가 +5. **트랜잭션 보장**: 롤백 기능으로 데이터 일관성 유지 + +--- + +## 10. 참고사항 + +- 외부 연동 설정은 관리자 권한 필요 +- 모든 외부 호출은 로그 기록 +- 타임아웃 및 재시도 정책 필수 설정 +- 정기적인 연결 상태 모니터링 필요 +- 보안을 위해 자격 증명은 반드시 암호화 diff --git a/frontend/app/(main)/admin/flow-external-db/page.tsx b/frontend/app/(main)/admin/flow-external-db/page.tsx new file mode 100644 index 00000000..2edf9911 --- /dev/null +++ b/frontend/app/(main)/admin/flow-external-db/page.tsx @@ -0,0 +1,384 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Badge } from "@/components/ui/badge"; +import { useToast } from "@/hooks/use-toast"; +import { flowExternalDbApi } from "@/lib/api/flowExternalDb"; +import { + FlowExternalDbConnection, + CreateFlowExternalDbConnectionRequest, + UpdateFlowExternalDbConnectionRequest, + DB_TYPE_OPTIONS, + getDbTypeLabel, +} from "@/types/flowExternalDb"; +import { Plus, Pencil, Trash2, TestTube, Loader2 } from "lucide-react"; +import { Switch } from "@/components/ui/switch"; + +export default function FlowExternalDbPage() { + const { toast } = useToast(); + const [connections, setConnections] = useState([]); + const [loading, setLoading] = useState(true); + const [showDialog, setShowDialog] = useState(false); + const [editingConnection, setEditingConnection] = useState(null); + const [testingId, setTestingId] = useState(null); + + // 폼 상태 + const [formData, setFormData] = useState< + CreateFlowExternalDbConnectionRequest | UpdateFlowExternalDbConnectionRequest + >({ + name: "", + description: "", + dbType: "postgresql", + host: "", + port: 5432, + databaseName: "", + username: "", + password: "", + sslEnabled: false, + }); + + useEffect(() => { + loadConnections(); + }, []); + + const loadConnections = async () => { + try { + setLoading(true); + const response = await flowExternalDbApi.getAll(); + if (response.success) { + setConnections(response.data); + } + } catch (error: any) { + toast({ + title: "오류", + description: error.message || "외부 DB 연결 목록 조회 실패", + variant: "destructive", + }); + } finally { + setLoading(false); + } + }; + + const handleCreate = () => { + setEditingConnection(null); + setFormData({ + name: "", + description: "", + dbType: "postgresql", + host: "", + port: 5432, + databaseName: "", + username: "", + password: "", + sslEnabled: false, + }); + setShowDialog(true); + }; + + const handleEdit = (connection: FlowExternalDbConnection) => { + setEditingConnection(connection); + setFormData({ + name: connection.name, + description: connection.description, + host: connection.host, + port: connection.port, + databaseName: connection.databaseName, + username: connection.username, + password: "", // 비밀번호는 비워둠 + sslEnabled: connection.sslEnabled, + isActive: connection.isActive, + }); + setShowDialog(true); + }; + + const handleSave = async () => { + try { + if (editingConnection) { + // 수정 + await flowExternalDbApi.update(editingConnection.id, formData); + toast({ title: "성공", description: "외부 DB 연결이 수정되었습니다" }); + } else { + // 생성 + await flowExternalDbApi.create(formData as CreateFlowExternalDbConnectionRequest); + toast({ title: "성공", description: "외부 DB 연결이 생성되었습니다" }); + } + setShowDialog(false); + loadConnections(); + } catch (error: any) { + toast({ + title: "오류", + description: error.message, + variant: "destructive", + }); + } + }; + + const handleDelete = async (id: number, name: string) => { + if (!confirm(`"${name}" 연결을 삭제하시겠습니까?`)) { + return; + } + + try { + await flowExternalDbApi.delete(id); + toast({ title: "성공", description: "외부 DB 연결이 삭제되었습니다" }); + loadConnections(); + } catch (error: any) { + toast({ + title: "오류", + description: error.message, + variant: "destructive", + }); + } + }; + + const handleTestConnection = async (id: number, name: string) => { + setTestingId(id); + try { + const result = await flowExternalDbApi.testConnection(id); + toast({ + title: result.success ? "연결 성공" : "연결 실패", + description: result.message, + variant: result.success ? "default" : "destructive", + }); + } catch (error: any) { + toast({ + title: "오류", + description: error.message, + variant: "destructive", + }); + } finally { + setTestingId(null); + } + }; + + return ( +
+
+
+

외부 DB 연결 관리

+

플로우에서 사용할 외부 데이터베이스 연결을 관리합니다

+
+ +
+ + {loading ? ( +
+ +
+ ) : connections.length === 0 ? ( +
+

등록된 외부 DB 연결이 없습니다

+ +
+ ) : ( +
+ + + + 이름 + DB 타입 + 호스트 + 데이터베이스 + 상태 + 작업 + + + + {connections.map((conn) => ( + + +
+
{conn.name}
+ {conn.description &&
{conn.description}
} +
+
+ + {getDbTypeLabel(conn.dbType)} + + + {conn.host}:{conn.port} + + {conn.databaseName} + + {conn.isActive ? "활성" : "비활성"} + + +
+ + + +
+
+
+ ))} +
+
+
+ )} + + {/* 생성/수정 다이얼로그 */} + + + + {editingConnection ? "외부 DB 연결 수정" : "새 외부 DB 연결 추가"} + 외부 데이터베이스 연결 정보를 입력하세요 + + +
+
+
+ + setFormData({ ...formData, name: e.target.value })} + placeholder="예: 운영_PostgreSQL" + /> +
+ +
+ + setFormData({ ...formData, description: e.target.value })} + placeholder="연결에 대한 설명" + /> +
+ +
+ + +
+ +
+
+ + setFormData({ ...formData, host: e.target.value })} + placeholder="localhost" + /> +
+
+ + setFormData({ ...formData, port: parseInt(e.target.value) || 0 })} + /> +
+
+ +
+ + setFormData({ ...formData, databaseName: e.target.value })} + placeholder="mydb" + /> +
+ +
+ + setFormData({ ...formData, username: e.target.value })} + placeholder="dbuser" + /> +
+ +
+ + setFormData({ ...formData, password: e.target.value })} + placeholder={editingConnection ? "변경하지 않으려면 비워두세요" : "비밀번호"} + /> +
+ +
+ setFormData({ ...formData, sslEnabled: checked })} + /> + +
+ + {editingConnection && ( +
+ setFormData({ ...formData, isActive: checked })} + /> + +
+ )} +
+ +
+ + +
+
+
+
+
+ ); +} diff --git a/frontend/components/flow/FlowStepPanel.tsx b/frontend/components/flow/FlowStepPanel.tsx index 35b61532..013a52aa 100644 --- a/frontend/components/flow/FlowStepPanel.tsx +++ b/frontend/components/flow/FlowStepPanel.tsx @@ -3,7 +3,7 @@ * 선택된 단계의 속성 편집 */ -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback, useRef } from "react"; import { X, Trash2, Save, Check, ChevronsUpDown } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -18,6 +18,15 @@ import { FlowStep } from "@/types/flow"; import { FlowConditionBuilder } from "./FlowConditionBuilder"; import { tableManagementApi, getTableColumns } from "@/lib/api/tableManagement"; import { cn } from "@/lib/utils"; +import { flowExternalDbApi } from "@/lib/api/flowExternalDb"; +import { + FlowExternalDbConnection, + FlowExternalDbIntegrationConfig, + INTEGRATION_TYPE_OPTIONS, + OPERATION_OPTIONS, +} from "@/types/flowExternalDb"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Textarea } from "@/components/ui/textarea"; interface FlowStepPanelProps { step: FlowStep; @@ -39,17 +48,29 @@ export function FlowStepPanel({ step, flowId, onClose, onUpdate }: FlowStepPanel statusValue: step.statusValue || "", targetTable: step.targetTable || "", fieldMappings: step.fieldMappings || {}, + // 외부 연동 필드 + integrationType: step.integrationType || "internal", + integrationConfig: step.integrationConfig, }); const [tableList, setTableList] = useState([]); const [loadingTables, setLoadingTables] = useState(true); const [openTableCombobox, setOpenTableCombobox] = useState(false); + // 외부 DB 테이블 목록 + const [externalTableList, setExternalTableList] = useState([]); + const [loadingExternalTables, setLoadingExternalTables] = useState(false); + const [selectedDbSource, setSelectedDbSource] = useState<"internal" | number>("internal"); // "internal" 또는 외부 DB connection ID + // 컬럼 목록 (상태 컬럼 선택용) const [columns, setColumns] = useState([]); const [loadingColumns, setLoadingColumns] = useState(false); const [openStatusColumnCombobox, setOpenStatusColumnCombobox] = useState(false); + // 외부 DB 연결 목록 + const [externalConnections, setExternalConnections] = useState([]); + const [loadingConnections, setLoadingConnections] = useState(false); + // 테이블 목록 조회 useEffect(() => { const loadTables = async () => { @@ -68,8 +89,135 @@ export function FlowStepPanel({ step, flowId, onClose, onUpdate }: FlowStepPanel loadTables(); }, []); + // 외부 DB 연결 목록 조회 (JWT 토큰 사용) useEffect(() => { - setFormData({ + const loadConnections = async () => { + try { + setLoadingConnections(true); + + // localStorage에서 JWT 토큰 가져오기 + const token = localStorage.getItem("authToken"); + if (!token) { + console.warn("토큰이 없습니다. 외부 DB 연결 목록을 조회할 수 없습니다."); + setExternalConnections([]); + return; + } + + const response = await fetch("/api/external-db-connections/control/active", { + credentials: "include", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }).catch((err) => { + console.warn("외부 DB 연결 목록 fetch 실패:", err); + return null; + }); + + if (response && response.ok) { + const result = await response.json(); + if (result.success && result.data) { + // 메인 DB 제외하고 외부 DB만 필터링 + const externalOnly = result.data.filter((conn: any) => conn.id !== 0); + setExternalConnections(externalOnly); + } else { + setExternalConnections([]); + } + } else { + // 401 오류 시 빈 배열로 처리 (리다이렉트 방지) + console.warn("외부 DB 연결 목록 조회 실패:", response?.status || "네트워크 오류"); + setExternalConnections([]); + } + } catch (error: any) { + console.error("Failed to load external connections:", error); + setExternalConnections([]); + } finally { + setLoadingConnections(false); + } + }; + loadConnections(); + }, []); + + // 외부 DB 선택 시 해당 DB의 테이블 목록 조회 (JWT 토큰 사용) + useEffect(() => { + const loadExternalTables = async () => { + console.log("🔍 loadExternalTables triggered, selectedDbSource:", selectedDbSource); + + if (selectedDbSource === "internal" || typeof selectedDbSource !== "number") { + console.log("⚠️ Skipping external table load (internal or not a number)"); + setExternalTableList([]); + return; + } + + console.log("📡 Loading external tables for connection ID:", selectedDbSource); + + try { + setLoadingExternalTables(true); + + // localStorage에서 JWT 토큰 가져오기 + const token = localStorage.getItem("authToken"); + if (!token) { + console.warn("토큰이 없습니다. 외부 DB 테이블 목록을 조회할 수 없습니다."); + setExternalTableList([]); + return; + } + + // 기존 multi-connection API 사용 (JWT 토큰 포함) + const response = await fetch(`/api/multi-connection/connections/${selectedDbSource}/tables`, { + credentials: "include", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }).catch((err) => { + console.warn("외부 DB 테이블 목록 fetch 실패:", err); + return null; + }); + + if (response && response.ok) { + const result = await response.json(); + console.log("✅ External tables API response:", result); + console.log("📊 result.data type:", typeof result.data, "isArray:", Array.isArray(result.data)); + console.log("📊 result.data:", JSON.stringify(result.data, null, 2)); + + if (result.success && result.data) { + // 데이터 형식이 다를 수 있으므로 변환 + const tableNames = result.data.map((t: any) => { + console.log("🔍 Processing item:", t, "type:", typeof t); + // tableName (camelCase), table_name, tablename, name 모두 지원 + return typeof t === "string" ? t : t.tableName || t.table_name || t.tablename || t.name; + }); + console.log("📋 Processed table names:", tableNames); + setExternalTableList(tableNames); + } else { + console.warn("❌ No data in response or success=false"); + setExternalTableList([]); + } + } else { + // 인증 오류 시에도 빈 배열로 처리 (리다이렉트 방지) + console.warn(`외부 DB 테이블 목록 조회 실패: ${response?.status || "네트워크 오류"}`); + setExternalTableList([]); + } + } catch (error) { + console.error("외부 DB 테이블 목록 조회 오류:", error); + setExternalTableList([]); + } finally { + setLoadingExternalTables(false); + } + }; + + loadExternalTables(); + }, [selectedDbSource]); + + useEffect(() => { + console.log("🔄 Initializing formData from step:", { + id: step.id, + stepName: step.stepName, + statusColumn: step.statusColumn, + statusValue: step.statusValue, + }); + + const newFormData = { stepName: step.stepName, tableName: step.tableName || "", conditionJson: step.conditionJson, @@ -79,8 +227,14 @@ export function FlowStepPanel({ step, flowId, onClose, onUpdate }: FlowStepPanel statusValue: step.statusValue || "", targetTable: step.targetTable || "", fieldMappings: step.fieldMappings || {}, - }); - }, [step]); + // 외부 연동 필드 + integrationType: step.integrationType || "internal", + integrationConfig: step.integrationConfig, + }; + + console.log("✅ Setting formData:", newFormData); + setFormData(newFormData); + }, [step.id]); // step 전체가 아닌 step.id만 의존성으로 설정 // 테이블 선택 시 컬럼 로드 useEffect(() => { @@ -114,10 +268,21 @@ export function FlowStepPanel({ step, flowId, onClose, onUpdate }: FlowStepPanel loadColumns(); }, [formData.tableName]); + // formData의 최신 값을 항상 참조하기 위한 ref + const formDataRef = useRef(formData); + + // formData가 변경될 때마다 ref 업데이트 + useEffect(() => { + formDataRef.current = formData; + }, [formData]); + // 저장 - const handleSave = async () => { + const handleSave = useCallback(async () => { + const currentFormData = formDataRef.current; + console.log("🚀 handleSave called, formData:", JSON.stringify(currentFormData, null, 2)); try { - const response = await updateFlowStep(step.id, formData); + const response = await updateFlowStep(step.id, currentFormData); + console.log("📡 API response:", response); if (response.success) { toast({ title: "저장 완료", @@ -139,7 +304,7 @@ export function FlowStepPanel({ step, flowId, onClose, onUpdate }: FlowStepPanel variant: "destructive", }); } - }; + }, [step.id, onUpdate, onClose, toast]); // 삭제 const handleDelete = async () => { @@ -203,6 +368,34 @@ export function FlowStepPanel({ step, flowId, onClose, onUpdate }: FlowStepPanel + {/* DB 소스 선택 */} +
+ + +

조회할 데이터베이스를 선택합니다

+
+ + {/* 테이블 선택 */}
@@ -212,50 +405,79 @@ export function FlowStepPanel({ step, flowId, onClose, onUpdate }: FlowStepPanel role="combobox" aria-expanded={openTableCombobox} className="w-full justify-between" - disabled={loadingTables} + disabled={loadingTables || (selectedDbSource !== "internal" && loadingExternalTables)} > {formData.tableName - ? tableList.find((table) => table.tableName === formData.tableName)?.displayName || - formData.tableName - : loadingTables + ? selectedDbSource === "internal" + ? tableList.find((table) => table.tableName === formData.tableName)?.displayName || + formData.tableName + : formData.tableName + : loadingTables || loadingExternalTables ? "로딩 중..." : "테이블 선택"} - + 테이블을 찾을 수 없습니다. - {tableList.map((table) => ( - { - setFormData({ ...formData, tableName: currentValue }); - setOpenTableCombobox(false); - }} - > - -
- {table.displayName || table.tableName} - {table.description && {table.description}} -
-
- ))} + {selectedDbSource === "internal" + ? // 내부 DB 테이블 목록 + tableList.map((table) => ( + { + setFormData({ ...formData, tableName: currentValue }); + setOpenTableCombobox(false); + }} + > + +
+ {table.displayName || table.tableName} + {table.description && ( + {table.description} + )} +
+
+ )) + : // 외부 DB 테이블 목록 (문자열 배열) + externalTableList.map((tableName, index) => ( + { + setFormData({ ...formData, tableName: currentValue }); + setOpenTableCombobox(false); + }} + > + +
{tableName}
+
+ ))}
-

이 단계에서 조건을 적용할 테이블을 선택합니다

+

+ {selectedDbSource === "internal" + ? "이 단계에서 조건을 적용할 테이블을 선택합니다" + : "외부 데이터베이스의 테이블을 선택합니다"} +

@@ -382,7 +604,12 @@ export function FlowStepPanel({ step, flowId, onClose, onUpdate }: FlowStepPanel setFormData({ ...formData, statusValue: e.target.value })} + onChange={(e) => { + const newValue = e.target.value; + console.log("💡 statusValue onChange:", newValue); + setFormData({ ...formData, statusValue: newValue }); + console.log("✅ Updated formData:", { ...formData, statusValue: newValue }); + }} placeholder="예: approved" />

이 단계에 있을 때의 상태값

@@ -423,6 +650,228 @@ export function FlowStepPanel({ step, flowId, onClose, onUpdate }: FlowStepPanel + {/* 외부 DB 연동 설정 */} + + + 외부 DB 연동 설정 + 데이터 이동 시 외부 시스템과의 연동을 설정합니다 + + +
+ + +
+ + {/* 외부 DB 연동 설정 */} + {formData.integrationType === "external_db" && ( +
+ {externalConnections.length === 0 ? ( +
+

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

+
+ ) : ( + <> +
+ + +
+ + {formData.integrationConfig?.connectionId && ( + <> +
+ + +
+ +
+ + + setFormData({ + ...formData, + integrationConfig: { + ...formData.integrationConfig!, + tableName: e.target.value, + }, + }) + } + placeholder="예: orders" + /> +
+ + {formData.integrationConfig.operation === "custom" ? ( +
+ +