437 lines
12 KiB
TypeScript
437 lines
12 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
}
|