플로우 외부db연결
This commit is contained in:
parent
7d8abc0449
commit
1f12df2f79
|
|
@ -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);
|
||||||
|
});
|
||||||
|
|
@ -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");
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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. 참고사항
|
||||||
|
|
||||||
|
- 외부 연동 설정은 관리자 권한 필요
|
||||||
|
- 모든 외부 호출은 로그 기록
|
||||||
|
- 타임아웃 및 재시도 정책 필수 설정
|
||||||
|
- 정기적인 연결 상태 모니터링 필요
|
||||||
|
- 보안을 위해 자격 증명은 반드시 암호화
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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}>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue