354 lines
9.9 KiB
TypeScript
354 lines
9.9 KiB
TypeScript
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);
|
|
}
|
|
}
|