플로우 외부db연결

This commit is contained in:
kjs 2025-10-20 17:50:27 +09:00
parent 7d8abc0449
commit 1f12df2f79
16 changed files with 3711 additions and 40 deletions

View File

@ -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);
});

View File

@ -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");

View File

@ -58,6 +58,7 @@ import mapDataRoutes from "./routes/mapDataRoutes"; // 지도 데이터 관리
import yardLayoutRoutes from "./routes/yardLayoutRoutes"; // 야드 관리 3D import yardLayoutRoutes from "./routes/yardLayoutRoutes"; // 야드 관리 3D
//import materialRoutes from "./routes/materialRoutes"; // 자재 관리 //import materialRoutes from "./routes/materialRoutes"; // 자재 관리
import flowRoutes from "./routes/flowRoutes"; // 플로우 관리 import flowRoutes from "./routes/flowRoutes"; // 플로우 관리
import flowExternalDbConnectionRoutes from "./routes/flowExternalDbConnectionRoutes"; // 플로우 전용 외부 DB 연결
import { BatchSchedulerService } from "./services/batchSchedulerService"; import { BatchSchedulerService } from "./services/batchSchedulerService";
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석
@ -209,7 +210,8 @@ app.use("/api/bookings", bookingRoutes); // 예약 요청 관리
app.use("/api/map-data", mapDataRoutes); // 지도 데이터 조회 app.use("/api/map-data", mapDataRoutes); // 지도 데이터 조회
app.use("/api/yard-layouts", yardLayoutRoutes); // 야드 관리 3D app.use("/api/yard-layouts", yardLayoutRoutes); // 야드 관리 3D
// app.use("/api/materials", materialRoutes); // 자재 관리 (임시 주석) // 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/collections", collectionRoutes); // 임시 주석
// app.use("/api/batch", batchRoutes); // 임시 주석 // app.use("/api/batch", batchRoutes); // 임시 주석
// app.use('/api/users', userRoutes); // app.use('/api/users', userRoutes);

View File

@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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,
});
}
}
}

View File

@ -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;

View File

@ -6,17 +6,20 @@
*/ */
import db from "../database/db"; import db from "../database/db";
import { FlowAuditLog } from "../types/flow"; import { FlowAuditLog, FlowIntegrationContext } from "../types/flow";
import { FlowDefinitionService } from "./flowDefinitionService"; import { FlowDefinitionService } from "./flowDefinitionService";
import { FlowStepService } from "./flowStepService"; import { FlowStepService } from "./flowStepService";
import { FlowExternalDbIntegrationService } from "./flowExternalDbIntegrationService";
export class FlowDataMoveService { export class FlowDataMoveService {
private flowDefinitionService: FlowDefinitionService; private flowDefinitionService: FlowDefinitionService;
private flowStepService: FlowStepService; private flowStepService: FlowStepService;
private externalDbIntegrationService: FlowExternalDbIntegrationService;
constructor() { constructor() {
this.flowDefinitionService = new FlowDefinitionService(); this.flowDefinitionService = new FlowDefinitionService();
this.flowStepService = new FlowStepService(); 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, { await this.logDataMove(client, {
flowId, flowId,
fromStepId, fromStepId,
@ -435,4 +454,140 @@ export class FlowDataMoveService {
statusTo: row.status_to, statusTo: row.status_to,
})); }));
} }
/**
* DB
*/
private async executeExternalIntegration(
toStep: any,
flowId: number,
dataId: any,
tableName: string | undefined,
userId: string,
additionalData?: Record<string, any>
): Promise<void> {
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<void> {
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,
]);
}
} }

View File

@ -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<number, Pool> = 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<FlowExternalDbConnection> {
// 비밀번호 암호화
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<FlowExternalDbConnection | null> {
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<FlowExternalDbConnection[]> {
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<FlowExternalDbConnection | null> {
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<boolean> {
// 연결 풀 정리
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<Pool> {
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,
};
}
}

View File

@ -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<FlowIntegrationResult> {
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<any> {
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<void> {
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);
}
}

View File

@ -24,9 +24,11 @@ export class FlowStepService {
const query = ` const query = `
INSERT INTO flow_step ( INSERT INTO flow_step (
flow_definition_id, step_name, step_order, table_name, condition_json, 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 * RETURNING *
`; `;
@ -39,6 +41,16 @@ export class FlowStepService {
request.color || "#3B82F6", request.color || "#3B82F6",
request.positionX || 0, request.positionX || 0,
request.positionY || 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]); return this.mapToFlowStep(result[0]);
@ -79,6 +91,13 @@ export class FlowStepService {
id: number, id: number,
request: UpdateFlowStepRequest request: UpdateFlowStepRequest
): Promise<FlowStep | null> { ): Promise<FlowStep | null> {
console.log("🔧 FlowStepService.update called with:", {
id,
statusColumn: request.statusColumn,
statusValue: request.statusValue,
fullRequest: JSON.stringify(request),
});
// 조건 검증 // 조건 검증
if (request.conditionJson) { if (request.conditionJson) {
FlowConditionParser.validateConditionGroup(request.conditionJson); FlowConditionParser.validateConditionGroup(request.conditionJson);
@ -132,6 +151,64 @@ export class FlowStepService {
paramIndex++; 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) { if (fields.length === 0) {
return this.findById(id); return this.findById(id);
} }
@ -202,6 +279,9 @@ export class FlowStepService {
targetTable: row.target_table || undefined, targetTable: row.target_table || undefined,
fieldMappings: row.field_mappings || undefined, fieldMappings: row.field_mappings || undefined,
requiredFields: row.required_fields || undefined, requiredFields: row.required_fields || undefined,
// 외부 연동 필드
integrationType: row.integration_type || "internal",
integrationConfig: row.integration_config || undefined,
createdAt: row.created_at, createdAt: row.created_at,
updatedAt: row.updated_at, updatedAt: row.updated_at,
}; };

View File

@ -80,6 +80,9 @@ export interface FlowStep {
targetTable?: string; // 타겟 테이블명 (테이블 이동 방식) targetTable?: string; // 타겟 테이블명 (테이블 이동 방식)
fieldMappings?: Record<string, string>; // 필드 매핑 정보 fieldMappings?: Record<string, string>; // 필드 매핑 정보
requiredFields?: string[]; // 필수 입력 필드 requiredFields?: string[]; // 필수 입력 필드
// 외부 연동 필드
integrationType?: FlowIntegrationType; // 연동 타입 (기본값: internal)
integrationConfig?: FlowIntegrationConfig; // 연동 설정 (JSONB)
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} }
@ -101,6 +104,9 @@ export interface CreateFlowStepRequest {
targetTable?: string; targetTable?: string;
fieldMappings?: Record<string, string>; fieldMappings?: Record<string, string>;
requiredFields?: string[]; requiredFields?: string[];
// 외부 연동 필드
integrationType?: FlowIntegrationType;
integrationConfig?: FlowIntegrationConfig;
} }
// 플로우 단계 수정 요청 // 플로우 단계 수정 요청
@ -119,6 +125,9 @@ export interface UpdateFlowStepRequest {
targetTable?: string; targetTable?: string;
fieldMappings?: Record<string, string>; fieldMappings?: Record<string, string>;
requiredFields?: string[]; requiredFields?: string[];
// 외부 연동 필드
integrationType?: FlowIntegrationType;
integrationConfig?: FlowIntegrationConfig;
} }
// 플로우 단계 연결 // 플로우 단계 연결
@ -208,3 +217,129 @@ export interface SqlWhereResult {
where: string; where: string;
params: any[]; 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<string, any>;
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<string, any>;
}
// 외부 DB 연결 수정 요청
export interface UpdateFlowExternalDbConnectionRequest {
name?: string;
description?: string;
host?: string;
port?: number;
databaseName?: string;
username?: string;
password?: string; // 평문 비밀번호 (저장 시 암호화)
sslEnabled?: boolean;
connectionOptions?: Record<string, any>;
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<string, any>; // 업데이트할 필드 (템플릿 변수 지원)
whereCondition?: Record<string, any>; // 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<string, any>; // 템플릿 변수 ({{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<string, any>;
responsePayload?: Record<string, any>;
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;
}

View File

@ -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;
}
}

View File

@ -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<IntegrationResult>;
validate(config: any): ValidationResult;
rollback(context: IntegrationContext): Promise<void>;
}
// 실행 컨텍스트
interface IntegrationContext {
flowId: number;
stepId: number;
dataId: string | number;
tableName?: string;
currentUser: string;
variables: Record<string, any>; // 템플릿 변수
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<number, any> = new Map();
async execute(context: IntegrationContext): Promise<IntegrationResult> {
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<void> {
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<number, AxiosInstance> = new Map();
async execute(context: IntegrationContext): Promise<IntegrationResult> {
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<AxiosResponse> {
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<IntegrationResult> {
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<void> {
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에 추가
// 연동 타입 선택
<Select value={integrationType} onValueChange={setIntegrationType}>
<SelectItem value="internal">내부 DB</SelectItem>
<SelectItem value="external_db">외부 DB</SelectItem>
<SelectItem value="rest_api">REST API</SelectItem>
<SelectItem value="webhook">Webhook</SelectItem>
<SelectItem value="hybrid">복합 연동</SelectItem>
</Select>;
// 연동 타입별 설정 UI
{
integrationType === "external_db" && (
<ExternalDbConfigPanel
config={integrationConfig}
onChange={setIntegrationConfig}
/>
);
}
{
integrationType === "rest_api" && (
<RestApiConfigPanel
config={integrationConfig}
onChange={setIntegrationConfig}
/>
);
}
```
### 4.2 외부 DB 설정 패널
```typescript
export function ExternalDbConfigPanel({ config, onChange }) {
return (
<div className="space-y-4">
{/* 연결 선택 */}
<Select value={config.connectionId}>
<SelectLabel>외부 DB 연결</SelectLabel>
{externalConnections.map((conn) => (
<SelectItem key={conn.id} value={conn.id}>
{conn.name} ({conn.dbType})
</SelectItem>
))}
</Select>
{/* 작업 타입 */}
<Select value={config.operation}>
<SelectItem value="update">업데이트</SelectItem>
<SelectItem value="insert">삽입</SelectItem>
<SelectItem value="delete">삭제</SelectItem>
<SelectItem value="custom">커스텀 쿼리</SelectItem>
</Select>
{/* 테이블명 */}
<Input
label="테이블명"
value={config.tableName}
onChange={(e) => onChange({ ...config, tableName: e.target.value })}
/>
{/* 업데이트 필드 */}
<FieldMapper
fields={config.updateFields}
onChange={(fields) => onChange({ ...config, updateFields: fields })}
/>
{/* WHERE 조건 */}
<ConditionBuilder
conditions={config.whereCondition}
onChange={(conditions) =>
onChange({ ...config, whereCondition: conditions })
}
/>
</div>
);
}
```
### 4.3 REST API 설정 패널
```typescript
export function RestApiConfigPanel({ config, onChange }) {
return (
<div className="space-y-4">
{/* API 연결 선택 */}
<Select value={config.connectionId}>
<SelectLabel>API 연결</SelectLabel>
{apiConnections.map((conn) => (
<SelectItem key={conn.id} value={conn.id}>
{conn.name} ({conn.baseUrl})
</SelectItem>
))}
</Select>
{/* HTTP 메서드 */}
<Select value={config.method}>
<SelectItem value="GET">GET</SelectItem>
<SelectItem value="POST">POST</SelectItem>
<SelectItem value="PUT">PUT</SelectItem>
<SelectItem value="PATCH">PATCH</SelectItem>
<SelectItem value="DELETE">DELETE</SelectItem>
</Select>
{/* 엔드포인트 */}
<Input
label="엔드포인트"
placeholder="/api/orders/{{dataId}}/approve"
value={config.endpoint}
onChange={(e) => onChange({ ...config, endpoint: e.target.value })}
/>
{/* 헤더 */}
<KeyValueEditor
label="헤더"
data={config.headers}
onChange={(headers) => onChange({ ...config, headers })}
/>
{/* 요청 본문 */}
<JsonEditor
label="요청 본문"
value={config.body}
onChange={(body) => onChange({ ...config, body })}
/>
{/* 성공 조건 */}
<SuccessConditionEditor
condition={config.successCondition}
onChange={(condition) =>
onChange({ ...config, successCondition: condition })
}
/>
</div>
);
}
```
---
## 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. 참고사항
- 외부 연동 설정은 관리자 권한 필요
- 모든 외부 호출은 로그 기록
- 타임아웃 및 재시도 정책 필수 설정
- 정기적인 연결 상태 모니터링 필요
- 보안을 위해 자격 증명은 반드시 암호화

View File

@ -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<FlowExternalDbConnection[]>([]);
const [loading, setLoading] = useState(true);
const [showDialog, setShowDialog] = useState(false);
const [editingConnection, setEditingConnection] = useState<FlowExternalDbConnection | null>(null);
const [testingId, setTestingId] = useState<number | null>(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 (
<div className="container mx-auto py-6">
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold"> DB </h1>
<p className="text-muted-foreground mt-1 text-sm"> </p>
</div>
<Button onClick={handleCreate}>
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
) : connections.length === 0 ? (
<div className="bg-muted/50 rounded-lg border py-12 text-center">
<p className="text-muted-foreground"> DB </p>
<Button onClick={handleCreate} className="mt-4">
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
) : (
<div className="rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead>DB </TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{connections.map((conn) => (
<TableRow key={conn.id}>
<TableCell className="font-medium">
<div>
<div>{conn.name}</div>
{conn.description && <div className="text-muted-foreground text-xs">{conn.description}</div>}
</div>
</TableCell>
<TableCell>
<Badge variant="outline">{getDbTypeLabel(conn.dbType)}</Badge>
</TableCell>
<TableCell className="font-mono text-sm">
{conn.host}:{conn.port}
</TableCell>
<TableCell className="font-mono text-sm">{conn.databaseName}</TableCell>
<TableCell>
<Badge variant={conn.isActive ? "default" : "secondary"}>{conn.isActive ? "활성" : "비활성"}</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleTestConnection(conn.id, conn.name)}
disabled={testingId === conn.id}
>
{testingId === conn.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<TestTube className="h-4 w-4" />
)}
</Button>
<Button variant="ghost" size="sm" onClick={() => handleEdit(conn)}>
<Pencil className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => handleDelete(conn.id, conn.name)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{/* 생성/수정 다이얼로그 */}
<Dialog open={showDialog} onOpenChange={setShowDialog}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{editingConnection ? "외부 DB 연결 수정" : "새 외부 DB 연결 추가"}</DialogTitle>
<DialogDescription> </DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2">
<Label htmlFor="name"> *</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="예: 운영_PostgreSQL"
/>
</div>
<div className="col-span-2">
<Label htmlFor="description"></Label>
<Input
id="description"
value={formData.description || ""}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="연결에 대한 설명"
/>
</div>
<div>
<Label htmlFor="dbType">DB *</Label>
<Select
value={formData.dbType}
onValueChange={(value: any) => setFormData({ ...formData, dbType: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{DB_TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-end gap-2">
<div className="flex-1">
<Label htmlFor="host"> *</Label>
<Input
id="host"
value={formData.host}
onChange={(e) => setFormData({ ...formData, host: e.target.value })}
placeholder="localhost"
/>
</div>
<div className="w-24">
<Label htmlFor="port"> *</Label>
<Input
id="port"
type="number"
value={formData.port}
onChange={(e) => setFormData({ ...formData, port: parseInt(e.target.value) || 0 })}
/>
</div>
</div>
<div className="col-span-2">
<Label htmlFor="databaseName"> *</Label>
<Input
id="databaseName"
value={formData.databaseName}
onChange={(e) => setFormData({ ...formData, databaseName: e.target.value })}
placeholder="mydb"
/>
</div>
<div>
<Label htmlFor="username"> *</Label>
<Input
id="username"
value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
placeholder="dbuser"
/>
</div>
<div>
<Label htmlFor="password"> {editingConnection && "(변경 시에만 입력)"}</Label>
<Input
id="password"
type="password"
value={formData.password || ""}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
placeholder={editingConnection ? "변경하지 않으려면 비워두세요" : "비밀번호"}
/>
</div>
<div className="col-span-2 flex items-center gap-2">
<Switch
id="sslEnabled"
checked={formData.sslEnabled}
onCheckedChange={(checked) => setFormData({ ...formData, sslEnabled: checked })}
/>
<Label htmlFor="sslEnabled">SSL </Label>
</div>
{editingConnection && (
<div className="col-span-2 flex items-center gap-2">
<Switch
id="isActive"
checked={(formData as UpdateFlowExternalDbConnectionRequest).isActive ?? true}
onCheckedChange={(checked) => setFormData({ ...formData, isActive: checked })}
/>
<Label htmlFor="isActive"></Label>
</div>
)}
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setShowDialog(false)}>
</Button>
<Button onClick={handleSave}>{editingConnection ? "수정" : "생성"}</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -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 { X, Trash2, Save, Check, ChevronsUpDown } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@ -18,6 +18,15 @@ import { FlowStep } from "@/types/flow";
import { FlowConditionBuilder } from "./FlowConditionBuilder"; import { FlowConditionBuilder } from "./FlowConditionBuilder";
import { tableManagementApi, getTableColumns } from "@/lib/api/tableManagement"; import { tableManagementApi, getTableColumns } from "@/lib/api/tableManagement";
import { cn } from "@/lib/utils"; 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 { interface FlowStepPanelProps {
step: FlowStep; step: FlowStep;
@ -39,17 +48,29 @@ export function FlowStepPanel({ step, flowId, onClose, onUpdate }: FlowStepPanel
statusValue: step.statusValue || "", statusValue: step.statusValue || "",
targetTable: step.targetTable || "", targetTable: step.targetTable || "",
fieldMappings: step.fieldMappings || {}, fieldMappings: step.fieldMappings || {},
// 외부 연동 필드
integrationType: step.integrationType || "internal",
integrationConfig: step.integrationConfig,
}); });
const [tableList, setTableList] = useState<any[]>([]); const [tableList, setTableList] = useState<any[]>([]);
const [loadingTables, setLoadingTables] = useState(true); const [loadingTables, setLoadingTables] = useState(true);
const [openTableCombobox, setOpenTableCombobox] = useState(false); const [openTableCombobox, setOpenTableCombobox] = useState(false);
// 외부 DB 테이블 목록
const [externalTableList, setExternalTableList] = useState<string[]>([]);
const [loadingExternalTables, setLoadingExternalTables] = useState(false);
const [selectedDbSource, setSelectedDbSource] = useState<"internal" | number>("internal"); // "internal" 또는 외부 DB connection ID
// 컬럼 목록 (상태 컬럼 선택용) // 컬럼 목록 (상태 컬럼 선택용)
const [columns, setColumns] = useState<any[]>([]); const [columns, setColumns] = useState<any[]>([]);
const [loadingColumns, setLoadingColumns] = useState(false); const [loadingColumns, setLoadingColumns] = useState(false);
const [openStatusColumnCombobox, setOpenStatusColumnCombobox] = useState(false); const [openStatusColumnCombobox, setOpenStatusColumnCombobox] = useState(false);
// 외부 DB 연결 목록
const [externalConnections, setExternalConnections] = useState<FlowExternalDbConnection[]>([]);
const [loadingConnections, setLoadingConnections] = useState(false);
// 테이블 목록 조회 // 테이블 목록 조회
useEffect(() => { useEffect(() => {
const loadTables = async () => { const loadTables = async () => {
@ -68,8 +89,135 @@ export function FlowStepPanel({ step, flowId, onClose, onUpdate }: FlowStepPanel
loadTables(); loadTables();
}, []); }, []);
// 외부 DB 연결 목록 조회 (JWT 토큰 사용)
useEffect(() => { 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, stepName: step.stepName,
tableName: step.tableName || "", tableName: step.tableName || "",
conditionJson: step.conditionJson, conditionJson: step.conditionJson,
@ -79,8 +227,14 @@ export function FlowStepPanel({ step, flowId, onClose, onUpdate }: FlowStepPanel
statusValue: step.statusValue || "", statusValue: step.statusValue || "",
targetTable: step.targetTable || "", targetTable: step.targetTable || "",
fieldMappings: step.fieldMappings || {}, fieldMappings: step.fieldMappings || {},
}); // 외부 연동 필드
}, [step]); integrationType: step.integrationType || "internal",
integrationConfig: step.integrationConfig,
};
console.log("✅ Setting formData:", newFormData);
setFormData(newFormData);
}, [step.id]); // step 전체가 아닌 step.id만 의존성으로 설정
// 테이블 선택 시 컬럼 로드 // 테이블 선택 시 컬럼 로드
useEffect(() => { useEffect(() => {
@ -114,10 +268,21 @@ export function FlowStepPanel({ step, flowId, onClose, onUpdate }: FlowStepPanel
loadColumns(); loadColumns();
}, [formData.tableName]); }, [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 { try {
const response = await updateFlowStep(step.id, formData); const response = await updateFlowStep(step.id, currentFormData);
console.log("📡 API response:", response);
if (response.success) { if (response.success) {
toast({ toast({
title: "저장 완료", title: "저장 완료",
@ -139,7 +304,7 @@ export function FlowStepPanel({ step, flowId, onClose, onUpdate }: FlowStepPanel
variant: "destructive", variant: "destructive",
}); });
} }
}; }, [step.id, onUpdate, onClose, toast]);
// 삭제 // 삭제
const handleDelete = async () => { const handleDelete = async () => {
@ -203,6 +368,34 @@ export function FlowStepPanel({ step, flowId, onClose, onUpdate }: FlowStepPanel
<Input value={step.stepOrder} disabled /> <Input value={step.stepOrder} disabled />
</div> </div>
{/* DB 소스 선택 */}
<div>
<Label> </Label>
<Select
value={selectedDbSource.toString()}
onValueChange={(value) => {
const dbSource = value === "internal" ? "internal" : parseInt(value);
setSelectedDbSource(dbSource);
// DB 소스 변경 시 테이블 선택 초기화
setFormData({ ...formData, tableName: "" });
}}
>
<SelectTrigger>
<SelectValue placeholder="데이터베이스 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="internal"> </SelectItem>
{externalConnections.map((conn: any) => (
<SelectItem key={conn.id} value={conn.id.toString()}>
{conn.connection_name} ({conn.db_type?.toUpperCase()})
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-1 text-xs text-gray-500"> </p>
</div>
{/* 테이블 선택 */}
<div> <div>
<Label> </Label> <Label> </Label>
<Popover open={openTableCombobox} onOpenChange={setOpenTableCombobox}> <Popover open={openTableCombobox} onOpenChange={setOpenTableCombobox}>
@ -212,50 +405,79 @@ export function FlowStepPanel({ step, flowId, onClose, onUpdate }: FlowStepPanel
role="combobox" role="combobox"
aria-expanded={openTableCombobox} aria-expanded={openTableCombobox}
className="w-full justify-between" className="w-full justify-between"
disabled={loadingTables} disabled={loadingTables || (selectedDbSource !== "internal" && loadingExternalTables)}
> >
{formData.tableName {formData.tableName
? tableList.find((table) => table.tableName === formData.tableName)?.displayName || ? selectedDbSource === "internal"
formData.tableName ? tableList.find((table) => table.tableName === formData.tableName)?.displayName ||
: loadingTables formData.tableName
: formData.tableName
: loadingTables || loadingExternalTables
? "로딩 중..." ? "로딩 중..."
: "테이블 선택"} : "테이블 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-[400px] p-0"> <PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command> <Command>
<CommandInput placeholder="테이블 검색..." /> <CommandInput placeholder="테이블 검색..." />
<CommandList> <CommandList>
<CommandEmpty> .</CommandEmpty> <CommandEmpty> .</CommandEmpty>
<CommandGroup> <CommandGroup>
{tableList.map((table) => ( {selectedDbSource === "internal"
<CommandItem ? // 내부 DB 테이블 목록
key={table.tableName} tableList.map((table) => (
value={table.tableName} <CommandItem
onSelect={(currentValue) => { key={table.tableName}
setFormData({ ...formData, tableName: currentValue }); value={table.tableName}
setOpenTableCombobox(false); onSelect={(currentValue) => {
}} setFormData({ ...formData, tableName: currentValue });
> setOpenTableCombobox(false);
<Check }}
className={cn( >
"mr-2 h-4 w-4", <Check
formData.tableName === table.tableName ? "opacity-100" : "opacity-0", className={cn(
)} "mr-2 h-4 w-4",
/> formData.tableName === table.tableName ? "opacity-100" : "opacity-0",
<div className="flex flex-col"> )}
<span className="font-medium">{table.displayName || table.tableName}</span> />
{table.description && <span className="text-xs text-gray-500">{table.description}</span>} <div className="flex flex-col">
</div> <span className="font-medium">{table.displayName || table.tableName}</span>
</CommandItem> {table.description && (
))} <span className="text-xs text-gray-500">{table.description}</span>
)}
</div>
</CommandItem>
))
: // 외부 DB 테이블 목록 (문자열 배열)
externalTableList.map((tableName, index) => (
<CommandItem
key={`external-${selectedDbSource}-${tableName}-${index}`}
value={tableName}
onSelect={(currentValue) => {
setFormData({ ...formData, tableName: currentValue });
setOpenTableCombobox(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
formData.tableName === tableName ? "opacity-100" : "opacity-0",
)}
/>
<div>{tableName}</div>
</CommandItem>
))}
</CommandGroup> </CommandGroup>
</CommandList> </CommandList>
</Command> </Command>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
<p className="mt-1 text-xs text-gray-500"> </p> <p className="mt-1 text-xs text-gray-500">
{selectedDbSource === "internal"
? "이 단계에서 조건을 적용할 테이블을 선택합니다"
: "외부 데이터베이스의 테이블을 선택합니다"}
</p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@ -382,7 +604,12 @@ export function FlowStepPanel({ step, flowId, onClose, onUpdate }: FlowStepPanel
<Label> </Label> <Label> </Label>
<Input <Input
value={formData.statusValue} value={formData.statusValue}
onChange={(e) => 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" placeholder="예: approved"
/> />
<p className="mt-1 text-xs text-gray-500"> </p> <p className="mt-1 text-xs text-gray-500"> </p>
@ -423,6 +650,228 @@ export function FlowStepPanel({ step, flowId, onClose, onUpdate }: FlowStepPanel
</CardContent> </CardContent>
</Card> </Card>
{/* 외부 DB 연동 설정 */}
<Card>
<CardHeader>
<CardTitle> DB </CardTitle>
<CardDescription> </CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label> </Label>
<Select
value={formData.integrationType}
onValueChange={(value: any) => {
setFormData({ ...formData, integrationType: value });
// 타입 변경 시 config 초기화
if (value === "internal") {
setFormData((prev) => ({ ...prev, integrationConfig: undefined }));
}
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{INTEGRATION_TYPE_OPTIONS.map((opt) => (
<SelectItem
key={opt.value}
value={opt.value}
disabled={opt.value !== "internal" && opt.value !== "external_db"}
>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 외부 DB 연동 설정 */}
{formData.integrationType === "external_db" && (
<div className="space-y-4 rounded-lg border p-4">
{externalConnections.length === 0 ? (
<div className="rounded-md bg-yellow-50 p-3">
<p className="text-sm text-yellow-900">
DB . DB .
</p>
</div>
) : (
<>
<div>
<Label> DB </Label>
<Select
value={formData.integrationConfig?.connectionId?.toString() || ""}
onValueChange={(value) => {
const connectionId = parseInt(value);
setFormData({
...formData,
integrationConfig: {
type: "external_db",
connectionId,
operation: "update",
tableName: "",
updateFields: {},
whereCondition: {},
},
});
}}
>
<SelectTrigger>
<SelectValue placeholder="연결 선택" />
</SelectTrigger>
<SelectContent>
{externalConnections.map((conn) => (
<SelectItem key={conn.id} value={conn.id.toString()}>
{conn.name} ({conn.dbType})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{formData.integrationConfig?.connectionId && (
<>
<div>
<Label> </Label>
<Select
value={formData.integrationConfig.operation}
onValueChange={(value: any) =>
setFormData({
...formData,
integrationConfig: {
...formData.integrationConfig!,
operation: value,
},
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{OPERATION_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label></Label>
<Input
value={formData.integrationConfig.tableName}
onChange={(e) =>
setFormData({
...formData,
integrationConfig: {
...formData.integrationConfig!,
tableName: e.target.value,
},
})
}
placeholder="예: orders"
/>
</div>
{formData.integrationConfig.operation === "custom" ? (
<div>
<Label> </Label>
<Textarea
value={formData.integrationConfig.customQuery || ""}
onChange={(e) =>
setFormData({
...formData,
integrationConfig: {
...formData.integrationConfig!,
customQuery: e.target.value,
},
})
}
placeholder="UPDATE orders SET status = 'approved' WHERE id = {{dataId}}"
rows={4}
className="font-mono text-sm"
/>
<p className="mt-1 text-xs text-gray-500">
릿 : {`{{dataId}}, {{currentUser}}, {{currentTimestamp}}`}
</p>
</div>
) : (
<>
{(formData.integrationConfig.operation === "update" ||
formData.integrationConfig.operation === "insert") && (
<div>
<Label> (JSON)</Label>
<Textarea
value={JSON.stringify(formData.integrationConfig.updateFields || {}, null, 2)}
onChange={(e) => {
try {
const parsed = JSON.parse(e.target.value);
setFormData({
...formData,
integrationConfig: {
...formData.integrationConfig!,
updateFields: parsed,
},
});
} catch (err) {
// JSON 파싱 실패 시 무시
}
}}
placeholder='{"status": "approved", "updated_by": "{{currentUser}}"}'
rows={4}
className="font-mono text-sm"
/>
</div>
)}
{(formData.integrationConfig.operation === "update" ||
formData.integrationConfig.operation === "delete") && (
<div>
<Label>WHERE (JSON)</Label>
<Textarea
value={JSON.stringify(formData.integrationConfig.whereCondition || {}, null, 2)}
onChange={(e) => {
try {
const parsed = JSON.parse(e.target.value);
setFormData({
...formData,
integrationConfig: {
...formData.integrationConfig!,
whereCondition: parsed,
},
});
} catch (err) {
// JSON 파싱 실패 시 무시
}
}}
placeholder='{"id": "{{dataId}}"}'
rows={3}
className="font-mono text-sm"
/>
</div>
)}
</>
)}
<div className="rounded-md bg-blue-50 p-3">
<p className="text-sm text-blue-900">
💡 릿 :
<br /> {`{{dataId}}`} - ID
<br /> {`{{currentUser}}`} -
<br /> {`{{currentTimestamp}}`} -
</p>
</div>
</>
)}
</>
)}
</div>
)}
</CardContent>
</Card>
{/* 액션 버튼 */} {/* 액션 버튼 */}
<div className="flex gap-2"> <div className="flex gap-2">
<Button className="flex-1" onClick={handleSave}> <Button className="flex-1" onClick={handleSave}>

View File

@ -0,0 +1,139 @@
/**
* DB API
*/
import {
FlowExternalDbConnection,
CreateFlowExternalDbConnectionRequest,
UpdateFlowExternalDbConnectionRequest,
FlowExternalDbConnectionListResponse,
FlowExternalDbConnectionResponse,
FlowExternalDbConnectionTestResponse,
} from "@/types/flowExternalDb";
const API_BASE = "/api/flow-external-db";
/**
* DB API
*/
export const flowExternalDbApi = {
/**
* DB
*/
async getAll(activeOnly: boolean = false): Promise<FlowExternalDbConnectionListResponse> {
const query = activeOnly ? "?activeOnly=true" : "";
try {
const response = await fetch(`${API_BASE}${query}`, {
credentials: "include",
});
if (!response.ok) {
// 오류 발생 시 빈 목록 반환 (조용히 실패)
console.warn(`외부 DB 연결 목록 조회 실패: ${response.status} ${response.statusText}`);
return { success: false, data: [] };
}
return response.json();
} catch (error) {
// 네트워크 오류 등 예외 발생 시에도 빈 목록 반환
console.error("외부 DB 연결 목록 조회 오류:", error);
return { success: false, data: [] };
}
},
/**
* DB
*/
async getById(id: number): Promise<FlowExternalDbConnectionResponse> {
const response = await fetch(`${API_BASE}/${id}`, {
credentials: "include",
});
if (!response.ok) {
throw new Error(`외부 DB 연결 조회 실패: ${response.statusText}`);
}
return response.json();
},
/**
* DB
*/
async create(request: CreateFlowExternalDbConnectionRequest): Promise<FlowExternalDbConnectionResponse> {
const response = await fetch(API_BASE, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify(request),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || "외부 DB 연결 생성 실패");
}
return response.json();
},
/**
* DB
*/
async update(id: number, request: UpdateFlowExternalDbConnectionRequest): Promise<FlowExternalDbConnectionResponse> {
const response = await fetch(`${API_BASE}/${id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify(request),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || "외부 DB 연결 수정 실패");
}
return response.json();
},
/**
* DB
*/
async delete(id: number): Promise<{ success: boolean; message: string }> {
const response = await fetch(`${API_BASE}/${id}`, {
method: "DELETE",
credentials: "include",
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || "외부 DB 연결 삭제 실패");
}
return response.json();
},
/**
* DB
*/
async testConnection(id: number): Promise<FlowExternalDbConnectionTestResponse> {
const response = await fetch(`${API_BASE}/${id}/test`, {
method: "POST",
credentials: "include",
});
const result = await response.json();
if (!response.ok) {
return {
success: false,
message: result.message || "연결 테스트 실패",
};
}
return result;
},
};

View File

@ -0,0 +1,149 @@
/**
* DB
* ( DB와 )
*/
// ==================== 연동 타입 ====================
export type FlowIntegrationType = "internal" | "external_db" | "rest_api" | "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<string, any>;
isActive: boolean;
createdBy?: string;
updatedBy?: string;
createdAt: string;
updatedAt: string;
}
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<string, any>;
}
export interface UpdateFlowExternalDbConnectionRequest {
name?: string;
description?: string;
host?: string;
port?: number;
databaseName?: string;
username?: string;
password?: string; // 평문 비밀번호 (변경 시에만)
sslEnabled?: boolean;
connectionOptions?: Record<string, any>;
isActive?: boolean;
}
// ==================== 외부 DB 연동 설정 ====================
export interface FlowExternalDbIntegrationConfig {
type: "external_db";
connectionId: number; // 연결 ID
operation: "update" | "insert" | "delete" | "custom";
tableName: string;
updateFields?: Record<string, any>; // 업데이트할 필드
whereCondition?: Record<string, any>; // WHERE 조건
customQuery?: string; // 커스텀 쿼리
}
// 연동 설정 통합 타입
export type FlowIntegrationConfig = FlowExternalDbIntegrationConfig;
// ==================== 연동 로그 ====================
export interface FlowIntegrationLog {
id: number;
flowDefinitionId: number;
stepId: number;
dataId?: string;
integrationType: string;
connectionId?: number;
requestPayload?: Record<string, any>;
responsePayload?: Record<string, any>;
status: "success" | "failed" | "timeout" | "rollback";
errorMessage?: string;
executionTimeMs?: number;
executedBy?: string;
executedAt: string;
}
// ==================== API 응답 ====================
export interface FlowExternalDbConnectionListResponse {
success: boolean;
data: FlowExternalDbConnection[];
message?: string;
}
export interface FlowExternalDbConnectionResponse {
success: boolean;
data?: FlowExternalDbConnection;
message?: string;
error?: string;
}
export interface FlowExternalDbConnectionTestResponse {
success: boolean;
message: string;
}
// ==================== UI 관련 ====================
export const DB_TYPE_OPTIONS = [
{ value: "postgresql", label: "PostgreSQL" },
{ value: "mysql", label: "MySQL" },
{ value: "mssql", label: "MS SQL Server" },
{ value: "oracle", label: "Oracle" },
] as const;
export const OPERATION_OPTIONS = [
{ value: "update", label: "업데이트 (UPDATE)" },
{ value: "insert", label: "삽입 (INSERT)" },
{ value: "delete", label: "삭제 (DELETE)" },
{ value: "custom", label: "커스텀 쿼리" },
] as const;
export const INTEGRATION_TYPE_OPTIONS = [
{ value: "internal", label: "내부 DB (기본)" },
{ value: "external_db", label: "외부 DB 연동" },
{ value: "rest_api", label: "REST API (추후 지원)" },
{ value: "webhook", label: "Webhook (추후 지원)" },
{ value: "hybrid", label: "복합 연동 (추후 지원)" },
] as const;
// ==================== 헬퍼 함수 ====================
export function getDbTypeLabel(dbType: string): string {
const option = DB_TYPE_OPTIONS.find((opt) => opt.value === dbType);
return option?.label || dbType;
}
export function getOperationLabel(operation: string): string {
const option = OPERATION_OPTIONS.find((opt) => opt.value === operation);
return option?.label || operation;
}
export function getIntegrationTypeLabel(type: string): string {
const option = INTEGRATION_TYPE_OPTIONS.find((opt) => opt.value === type);
return option?.label || type;
}