feat: 배치 관리 시스템 구현
✨ 주요 기능: - 배치 설정 관리 (생성/수정/삭제/실행) - 배치 실행 로그 관리 및 모니터링 - 배치 스케줄러 자동 실행 (cron 기반) - 외부 DB 연결을 통한 데이터 동기화 - Oracle, MSSQL, MariaDB 커넥터 지원 🔧 백엔드 구현: - BatchManagementController: 배치 설정 CRUD - BatchExecutionLogController: 실행 로그 관리 - BatchSchedulerService: 자동 스케줄링 - BatchExternalDbService: 외부 DB 연동 - 배치 관련 테이블 스키마 추가 🎨 프론트엔드 구현: - 배치 관리 대시보드 UI - 배치 생성/수정 폼 - 실행 로그 모니터링 화면 - 수동 실행 및 상태 관리 🛡️ 안전성: - 기존 시스템과 독립적 구현 - 트랜잭션 기반 안전한 데이터 처리 - 에러 핸들링 및 로깅 강화
This commit is contained in:
299
backend-node/src/services/batchExecutionLogService.ts
Normal file
299
backend-node/src/services/batchExecutionLogService.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
// 배치 실행 로그 서비스
|
||||
// 작성일: 2024-12-24
|
||||
|
||||
import prisma from "../config/database";
|
||||
import {
|
||||
BatchExecutionLog,
|
||||
CreateBatchExecutionLogRequest,
|
||||
UpdateBatchExecutionLogRequest,
|
||||
BatchExecutionLogFilter,
|
||||
BatchExecutionLogWithConfig
|
||||
} from "../types/batchExecutionLogTypes";
|
||||
import { ApiResponse } from "../types/batchTypes";
|
||||
|
||||
export class BatchExecutionLogService {
|
||||
/**
|
||||
* 배치 실행 로그 목록 조회
|
||||
*/
|
||||
static async getExecutionLogs(
|
||||
filter: BatchExecutionLogFilter = {}
|
||||
): Promise<ApiResponse<BatchExecutionLogWithConfig[]>> {
|
||||
try {
|
||||
const {
|
||||
batch_config_id,
|
||||
execution_status,
|
||||
start_date,
|
||||
end_date,
|
||||
page = 1,
|
||||
limit = 50
|
||||
} = filter;
|
||||
|
||||
const skip = (page - 1) * limit;
|
||||
const take = limit;
|
||||
|
||||
// WHERE 조건 구성
|
||||
const where: any = {};
|
||||
|
||||
if (batch_config_id) {
|
||||
where.batch_config_id = batch_config_id;
|
||||
}
|
||||
|
||||
if (execution_status) {
|
||||
where.execution_status = execution_status;
|
||||
}
|
||||
|
||||
if (start_date || end_date) {
|
||||
where.start_time = {};
|
||||
if (start_date) {
|
||||
where.start_time.gte = start_date;
|
||||
}
|
||||
if (end_date) {
|
||||
where.start_time.lte = end_date;
|
||||
}
|
||||
}
|
||||
|
||||
// 로그 조회
|
||||
const [logs, total] = await Promise.all([
|
||||
prisma.batch_execution_logs.findMany({
|
||||
where,
|
||||
include: {
|
||||
batch_config: {
|
||||
select: {
|
||||
id: true,
|
||||
batch_name: true,
|
||||
description: true,
|
||||
cron_schedule: true,
|
||||
is_active: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: { start_time: 'desc' },
|
||||
skip,
|
||||
take
|
||||
}),
|
||||
prisma.batch_execution_logs.count({ where })
|
||||
]);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: logs as BatchExecutionLogWithConfig[],
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit)
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("배치 실행 로그 조회 실패:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "배치 실행 로그 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 실행 로그 생성
|
||||
*/
|
||||
static async createExecutionLog(
|
||||
data: CreateBatchExecutionLogRequest
|
||||
): Promise<ApiResponse<BatchExecutionLog>> {
|
||||
try {
|
||||
const log = await prisma.batch_execution_logs.create({
|
||||
data: {
|
||||
batch_config_id: data.batch_config_id,
|
||||
execution_status: data.execution_status,
|
||||
start_time: data.start_time || new Date(),
|
||||
end_time: data.end_time,
|
||||
duration_ms: data.duration_ms,
|
||||
total_records: data.total_records || 0,
|
||||
success_records: data.success_records || 0,
|
||||
failed_records: data.failed_records || 0,
|
||||
error_message: data.error_message,
|
||||
error_details: data.error_details,
|
||||
server_name: data.server_name || process.env.HOSTNAME || 'unknown',
|
||||
process_id: data.process_id || process.pid?.toString()
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: log as BatchExecutionLog,
|
||||
message: "배치 실행 로그가 생성되었습니다."
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("배치 실행 로그 생성 실패:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "배치 실행 로그 생성 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 실행 로그 업데이트
|
||||
*/
|
||||
static async updateExecutionLog(
|
||||
id: number,
|
||||
data: UpdateBatchExecutionLogRequest
|
||||
): Promise<ApiResponse<BatchExecutionLog>> {
|
||||
try {
|
||||
const log = await prisma.batch_execution_logs.update({
|
||||
where: { id },
|
||||
data: {
|
||||
execution_status: data.execution_status,
|
||||
end_time: data.end_time,
|
||||
duration_ms: data.duration_ms,
|
||||
total_records: data.total_records,
|
||||
success_records: data.success_records,
|
||||
failed_records: data.failed_records,
|
||||
error_message: data.error_message,
|
||||
error_details: data.error_details
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: log as BatchExecutionLog,
|
||||
message: "배치 실행 로그가 업데이트되었습니다."
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("배치 실행 로그 업데이트 실패:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "배치 실행 로그 업데이트 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 실행 로그 삭제
|
||||
*/
|
||||
static async deleteExecutionLog(id: number): Promise<ApiResponse<void>> {
|
||||
try {
|
||||
await prisma.batch_execution_logs.delete({
|
||||
where: { id }
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "배치 실행 로그가 삭제되었습니다."
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("배치 실행 로그 삭제 실패:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "배치 실행 로그 삭제 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 배치의 최신 실행 로그 조회
|
||||
*/
|
||||
static async getLatestExecutionLog(
|
||||
batchConfigId: number
|
||||
): Promise<ApiResponse<BatchExecutionLog | null>> {
|
||||
try {
|
||||
const log = await prisma.batch_execution_logs.findFirst({
|
||||
where: { batch_config_id: batchConfigId },
|
||||
orderBy: { start_time: 'desc' }
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: log as BatchExecutionLog | null
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("최신 배치 실행 로그 조회 실패:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "최신 배치 실행 로그 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 실행 통계 조회
|
||||
*/
|
||||
static async getExecutionStats(
|
||||
batchConfigId?: number,
|
||||
startDate?: Date,
|
||||
endDate?: Date
|
||||
): Promise<ApiResponse<{
|
||||
total_executions: number;
|
||||
success_count: number;
|
||||
failed_count: number;
|
||||
success_rate: number;
|
||||
average_duration_ms: number;
|
||||
total_records_processed: number;
|
||||
}>> {
|
||||
try {
|
||||
const where: any = {};
|
||||
|
||||
if (batchConfigId) {
|
||||
where.batch_config_id = batchConfigId;
|
||||
}
|
||||
|
||||
if (startDate || endDate) {
|
||||
where.start_time = {};
|
||||
if (startDate) {
|
||||
where.start_time.gte = startDate;
|
||||
}
|
||||
if (endDate) {
|
||||
where.start_time.lte = endDate;
|
||||
}
|
||||
}
|
||||
|
||||
const logs = await prisma.batch_execution_logs.findMany({
|
||||
where,
|
||||
select: {
|
||||
execution_status: true,
|
||||
duration_ms: true,
|
||||
total_records: true
|
||||
}
|
||||
});
|
||||
|
||||
const total_executions = logs.length;
|
||||
const success_count = logs.filter((log: any) => log.execution_status === 'SUCCESS').length;
|
||||
const failed_count = logs.filter((log: any) => log.execution_status === 'FAILED').length;
|
||||
const success_rate = total_executions > 0 ? (success_count / total_executions) * 100 : 0;
|
||||
|
||||
const validDurations = logs
|
||||
.filter((log: any) => log.duration_ms !== null)
|
||||
.map((log: any) => log.duration_ms!);
|
||||
const average_duration_ms = validDurations.length > 0
|
||||
? validDurations.reduce((sum: number, duration: number) => sum + duration, 0) / validDurations.length
|
||||
: 0;
|
||||
|
||||
const total_records_processed = logs
|
||||
.filter((log: any) => log.total_records !== null)
|
||||
.reduce((sum: number, log: any) => sum + (log.total_records || 0), 0);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
total_executions,
|
||||
success_count,
|
||||
failed_count,
|
||||
success_rate,
|
||||
average_duration_ms,
|
||||
total_records_processed
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("배치 실행 통계 조회 실패:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "배치 실행 통계 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
686
backend-node/src/services/batchExternalDbService.ts
Normal file
686
backend-node/src/services/batchExternalDbService.ts
Normal file
@@ -0,0 +1,686 @@
|
||||
// 배치관리 전용 외부 DB 서비스
|
||||
// 기존 ExternalDbConnectionService와 분리하여 배치관리 시스템에 특화된 기능 제공
|
||||
// 작성일: 2024-12-24
|
||||
|
||||
import prisma from "../config/database";
|
||||
import { PasswordEncryption } from "../utils/passwordEncryption";
|
||||
import { DatabaseConnectorFactory } from "../database/DatabaseConnectorFactory";
|
||||
import { ApiResponse, ColumnInfo, TableInfo } from "../types/batchTypes";
|
||||
|
||||
export class BatchExternalDbService {
|
||||
/**
|
||||
* 배치관리용 외부 DB 연결 목록 조회
|
||||
*/
|
||||
static async getAvailableConnections(): Promise<ApiResponse<Array<{
|
||||
type: 'internal' | 'external';
|
||||
id?: number;
|
||||
name: string;
|
||||
db_type?: string;
|
||||
}>>> {
|
||||
try {
|
||||
const connections: Array<{
|
||||
type: 'internal' | 'external';
|
||||
id?: number;
|
||||
name: string;
|
||||
db_type?: string;
|
||||
}> = [];
|
||||
|
||||
// 내부 DB 추가
|
||||
connections.push({
|
||||
type: 'internal',
|
||||
name: '내부 데이터베이스 (PostgreSQL)',
|
||||
db_type: 'postgresql'
|
||||
});
|
||||
|
||||
// 활성화된 외부 DB 연결 조회
|
||||
const externalConnections = await prisma.external_db_connections.findMany({
|
||||
where: { is_active: 'Y' },
|
||||
select: {
|
||||
id: true,
|
||||
connection_name: true,
|
||||
db_type: true,
|
||||
description: true
|
||||
},
|
||||
orderBy: { connection_name: 'asc' }
|
||||
});
|
||||
|
||||
// 외부 DB 연결 추가
|
||||
externalConnections.forEach(conn => {
|
||||
connections.push({
|
||||
type: 'external',
|
||||
id: conn.id,
|
||||
name: `${conn.connection_name} (${conn.db_type?.toUpperCase()})`,
|
||||
db_type: conn.db_type || undefined
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: connections,
|
||||
message: `${connections.length}개의 연결을 조회했습니다.`
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("배치관리 연결 목록 조회 실패:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "연결 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치관리용 테이블 목록 조회
|
||||
*/
|
||||
static async getTablesFromConnection(
|
||||
connectionType: 'internal' | 'external',
|
||||
connectionId?: number
|
||||
): Promise<ApiResponse<TableInfo[]>> {
|
||||
try {
|
||||
let tables: TableInfo[] = [];
|
||||
|
||||
if (connectionType === 'internal') {
|
||||
// 내부 DB 테이블 조회
|
||||
const result = await prisma.$queryRaw<Array<{ table_name: string }>>`
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_type = 'BASE TABLE'
|
||||
ORDER BY table_name
|
||||
`;
|
||||
|
||||
tables = result.map(row => ({
|
||||
table_name: row.table_name,
|
||||
columns: []
|
||||
}));
|
||||
} else if (connectionType === 'external' && connectionId) {
|
||||
// 외부 DB 테이블 조회
|
||||
const tablesResult = await this.getExternalTables(connectionId);
|
||||
if (tablesResult.success && tablesResult.data) {
|
||||
tables = tablesResult.data;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: tables,
|
||||
message: `${tables.length}개의 테이블을 조회했습니다.`
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("배치관리 테이블 목록 조회 실패:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "테이블 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치관리용 테이블 컬럼 정보 조회
|
||||
*/
|
||||
static async getTableColumns(
|
||||
connectionType: 'internal' | 'external',
|
||||
connectionId: number | undefined,
|
||||
tableName: string
|
||||
): Promise<ApiResponse<ColumnInfo[]>> {
|
||||
try {
|
||||
console.log(`[BatchExternalDbService] getTableColumns 호출:`, {
|
||||
connectionType,
|
||||
connectionId,
|
||||
tableName
|
||||
});
|
||||
|
||||
let columns: ColumnInfo[] = [];
|
||||
|
||||
if (connectionType === 'internal') {
|
||||
// 내부 DB 컬럼 조회
|
||||
console.log(`[BatchExternalDbService] 내부 DB 컬럼 조회 시작: ${tableName}`);
|
||||
|
||||
const result = await prisma.$queryRaw<Array<{
|
||||
column_name: string;
|
||||
data_type: string;
|
||||
is_nullable: string;
|
||||
column_default: string | null
|
||||
}>>`
|
||||
SELECT
|
||||
column_name,
|
||||
data_type,
|
||||
is_nullable,
|
||||
column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = ${tableName}
|
||||
ORDER BY ordinal_position
|
||||
`;
|
||||
|
||||
console.log(`[BatchExternalDbService] 내부 DB 컬럼 조회 결과:`, result);
|
||||
|
||||
columns = result.map(row => ({
|
||||
column_name: row.column_name,
|
||||
data_type: row.data_type,
|
||||
is_nullable: row.is_nullable,
|
||||
column_default: row.column_default,
|
||||
}));
|
||||
} else if (connectionType === 'external' && connectionId) {
|
||||
// 외부 DB 컬럼 조회
|
||||
console.log(`[BatchExternalDbService] 외부 DB 컬럼 조회 시작: connectionId=${connectionId}, tableName=${tableName}`);
|
||||
|
||||
const columnsResult = await this.getExternalTableColumns(connectionId, tableName);
|
||||
|
||||
console.log(`[BatchExternalDbService] 외부 DB 컬럼 조회 결과:`, columnsResult);
|
||||
|
||||
if (columnsResult.success && columnsResult.data) {
|
||||
columns = columnsResult.data;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[BatchExternalDbService] 최종 컬럼 목록:`, columns);
|
||||
return {
|
||||
success: true,
|
||||
data: columns,
|
||||
message: `${columns.length}개의 컬럼을 조회했습니다.`
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[BatchExternalDbService] 컬럼 정보 조회 오류:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "컬럼 정보 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 외부 DB 테이블 목록 조회 (내부 구현)
|
||||
*/
|
||||
private static async getExternalTables(connectionId: number): Promise<ApiResponse<TableInfo[]>> {
|
||||
try {
|
||||
// 연결 정보 조회
|
||||
const connection = await prisma.external_db_connections.findUnique({
|
||||
where: { id: connectionId }
|
||||
});
|
||||
|
||||
if (!connection) {
|
||||
return {
|
||||
success: false,
|
||||
message: "연결 정보를 찾을 수 없습니다."
|
||||
};
|
||||
}
|
||||
|
||||
// 비밀번호 복호화
|
||||
const decryptedPassword = PasswordEncryption.decrypt(connection.password);
|
||||
if (!decryptedPassword) {
|
||||
return {
|
||||
success: false,
|
||||
message: "비밀번호 복호화에 실패했습니다."
|
||||
};
|
||||
}
|
||||
|
||||
// 연결 설정 준비
|
||||
const config = {
|
||||
host: connection.host,
|
||||
port: connection.port,
|
||||
database: connection.database_name,
|
||||
user: connection.username,
|
||||
password: decryptedPassword,
|
||||
connectionTimeoutMillis: connection.connection_timeout != null ? connection.connection_timeout * 1000 : undefined,
|
||||
queryTimeoutMillis: connection.query_timeout != null ? connection.query_timeout * 1000 : undefined,
|
||||
ssl: connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false
|
||||
};
|
||||
|
||||
// DatabaseConnectorFactory를 통한 테이블 목록 조회
|
||||
const connector = await DatabaseConnectorFactory.createConnector(connection.db_type, config, connectionId);
|
||||
const tables = await connector.getTables();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "테이블 목록을 조회했습니다.",
|
||||
data: tables
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("외부 DB 테이블 목록 조회 오류:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "테이블 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 외부 DB 테이블 컬럼 정보 조회 (내부 구현)
|
||||
*/
|
||||
private static async getExternalTableColumns(connectionId: number, tableName: string): Promise<ApiResponse<ColumnInfo[]>> {
|
||||
try {
|
||||
console.log(`[BatchExternalDbService] getExternalTableColumns 호출: connectionId=${connectionId}, tableName=${tableName}`);
|
||||
|
||||
// 연결 정보 조회
|
||||
const connection = await prisma.external_db_connections.findUnique({
|
||||
where: { id: connectionId }
|
||||
});
|
||||
|
||||
if (!connection) {
|
||||
console.log(`[BatchExternalDbService] 연결 정보를 찾을 수 없음: connectionId=${connectionId}`);
|
||||
return {
|
||||
success: false,
|
||||
message: "연결 정보를 찾을 수 없습니다."
|
||||
};
|
||||
}
|
||||
|
||||
console.log(`[BatchExternalDbService] 연결 정보 조회 성공:`, {
|
||||
id: connection.id,
|
||||
connection_name: connection.connection_name,
|
||||
db_type: connection.db_type,
|
||||
host: connection.host,
|
||||
port: connection.port,
|
||||
database_name: connection.database_name
|
||||
});
|
||||
|
||||
// 비밀번호 복호화
|
||||
const decryptedPassword = PasswordEncryption.decrypt(connection.password);
|
||||
|
||||
// 연결 설정 준비
|
||||
const config = {
|
||||
host: connection.host,
|
||||
port: connection.port,
|
||||
database: connection.database_name,
|
||||
user: connection.username,
|
||||
password: decryptedPassword,
|
||||
connectionTimeoutMillis: connection.connection_timeout != null ? connection.connection_timeout * 1000 : undefined,
|
||||
queryTimeoutMillis: connection.query_timeout != null ? connection.query_timeout * 1000 : undefined,
|
||||
ssl: connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false
|
||||
};
|
||||
|
||||
console.log(`[BatchExternalDbService] 커넥터 생성 시작: db_type=${connection.db_type}`);
|
||||
|
||||
// 데이터베이스 타입에 따른 커넥터 생성
|
||||
const connector = await DatabaseConnectorFactory.createConnector(connection.db_type, config, connectionId);
|
||||
|
||||
console.log(`[BatchExternalDbService] 커넥터 생성 완료, 컬럼 조회 시작: tableName=${tableName}`);
|
||||
|
||||
// 컬럼 정보 조회
|
||||
console.log(`[BatchExternalDbService] connector.getColumns 호출 전`);
|
||||
const columns = await connector.getColumns(tableName);
|
||||
|
||||
console.log(`[BatchExternalDbService] 원본 컬럼 조회 결과:`, columns);
|
||||
console.log(`[BatchExternalDbService] 원본 컬럼 개수:`, columns ? columns.length : 'null/undefined');
|
||||
|
||||
// 각 데이터베이스 커넥터의 반환 구조가 다르므로 통일된 구조로 변환
|
||||
const standardizedColumns: ColumnInfo[] = columns.map((col: any) => {
|
||||
console.log(`[BatchExternalDbService] 컬럼 변환 중:`, col);
|
||||
|
||||
// MySQL/MariaDB 구조: {name, dataType, isNullable, defaultValue} (MySQLConnector만)
|
||||
if (col.name && col.dataType !== undefined) {
|
||||
const result = {
|
||||
column_name: col.name,
|
||||
data_type: col.dataType,
|
||||
is_nullable: col.isNullable ? 'YES' : 'NO',
|
||||
column_default: col.defaultValue || null,
|
||||
};
|
||||
console.log(`[BatchExternalDbService] MySQL/MariaDB 구조로 변환:`, result);
|
||||
return result;
|
||||
}
|
||||
// PostgreSQL/Oracle/MSSQL/MariaDB 구조: {column_name, data_type, is_nullable, column_default}
|
||||
else {
|
||||
const result = {
|
||||
column_name: col.column_name || col.COLUMN_NAME,
|
||||
data_type: col.data_type || col.DATA_TYPE,
|
||||
is_nullable: col.is_nullable || col.IS_NULLABLE || (col.nullable === 'Y' ? 'YES' : 'NO'),
|
||||
column_default: col.column_default || col.COLUMN_DEFAULT || null,
|
||||
};
|
||||
console.log(`[BatchExternalDbService] 표준 구조로 변환:`, result);
|
||||
return result;
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`[BatchExternalDbService] 표준화된 컬럼 목록:`, standardizedColumns);
|
||||
|
||||
// 빈 배열인 경우 경고 로그
|
||||
if (!standardizedColumns || standardizedColumns.length === 0) {
|
||||
console.warn(`[BatchExternalDbService] 컬럼이 비어있음: connectionId=${connectionId}, tableName=${tableName}`);
|
||||
console.warn(`[BatchExternalDbService] 연결 정보:`, {
|
||||
db_type: connection.db_type,
|
||||
host: connection.host,
|
||||
port: connection.port,
|
||||
database_name: connection.database_name,
|
||||
username: connection.username
|
||||
});
|
||||
|
||||
// 테이블 존재 여부 확인
|
||||
console.warn(`[BatchExternalDbService] 테이블 존재 여부 확인을 위해 테이블 목록 조회 시도`);
|
||||
try {
|
||||
const tables = await connector.getTables();
|
||||
console.warn(`[BatchExternalDbService] 사용 가능한 테이블 목록:`, tables.map(t => t.table_name));
|
||||
|
||||
// 테이블명이 정확한지 확인
|
||||
const tableExists = tables.some(t => t.table_name.toLowerCase() === tableName.toLowerCase());
|
||||
console.warn(`[BatchExternalDbService] 테이블 존재 여부: ${tableExists}`);
|
||||
|
||||
// 정확한 테이블명 찾기
|
||||
const exactTable = tables.find(t => t.table_name.toLowerCase() === tableName.toLowerCase());
|
||||
if (exactTable) {
|
||||
console.warn(`[BatchExternalDbService] 정확한 테이블명: ${exactTable.table_name}`);
|
||||
}
|
||||
|
||||
// 모든 테이블명 출력
|
||||
console.warn(`[BatchExternalDbService] 모든 테이블명:`, tables.map(t => `"${t.table_name}"`));
|
||||
|
||||
// 테이블명 비교
|
||||
console.warn(`[BatchExternalDbService] 요청된 테이블명: "${tableName}"`);
|
||||
console.warn(`[BatchExternalDbService] 테이블명 비교 결과:`, tables.map(t => ({
|
||||
table_name: t.table_name,
|
||||
matches: t.table_name.toLowerCase() === tableName.toLowerCase(),
|
||||
exact_match: t.table_name === tableName
|
||||
})));
|
||||
|
||||
// 정확한 테이블명으로 다시 시도
|
||||
if (exactTable && exactTable.table_name !== tableName) {
|
||||
console.warn(`[BatchExternalDbService] 정확한 테이블명으로 다시 시도: ${exactTable.table_name}`);
|
||||
try {
|
||||
const correctColumns = await connector.getColumns(exactTable.table_name);
|
||||
console.warn(`[BatchExternalDbService] 정확한 테이블명으로 조회한 컬럼:`, correctColumns);
|
||||
} catch (correctError) {
|
||||
console.error(`[BatchExternalDbService] 정확한 테이블명으로 조회 실패:`, correctError);
|
||||
}
|
||||
}
|
||||
} catch (tableError) {
|
||||
console.error(`[BatchExternalDbService] 테이블 목록 조회 실패:`, tableError);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: standardizedColumns,
|
||||
message: "컬럼 정보를 조회했습니다."
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[BatchExternalDbService] 외부 DB 컬럼 정보 조회 오류:", error);
|
||||
console.error("[BatchExternalDbService] 오류 스택:", error instanceof Error ? error.stack : 'No stack trace');
|
||||
return {
|
||||
success: false,
|
||||
message: "컬럼 정보 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 외부 DB 테이블에서 데이터 조회
|
||||
*/
|
||||
static async getDataFromTable(
|
||||
connectionId: number,
|
||||
tableName: string,
|
||||
limit: number = 100
|
||||
): Promise<ApiResponse<any[]>> {
|
||||
try {
|
||||
console.log(`[BatchExternalDbService] 외부 DB 데이터 조회: connectionId=${connectionId}, tableName=${tableName}`);
|
||||
|
||||
// 외부 DB 연결 정보 조회
|
||||
const connection = await prisma.external_db_connections.findUnique({
|
||||
where: { id: connectionId }
|
||||
});
|
||||
|
||||
if (!connection) {
|
||||
return {
|
||||
success: false,
|
||||
message: "외부 DB 연결을 찾을 수 없습니다."
|
||||
};
|
||||
}
|
||||
|
||||
// 패스워드 복호화
|
||||
const decryptedPassword = PasswordEncryption.decrypt(connection.password);
|
||||
|
||||
// DB 연결 설정
|
||||
const config = {
|
||||
host: connection.host,
|
||||
port: connection.port,
|
||||
user: connection.username,
|
||||
password: decryptedPassword,
|
||||
database: connection.database_name,
|
||||
};
|
||||
|
||||
// DB 커넥터 생성
|
||||
const connector = await DatabaseConnectorFactory.createConnector(
|
||||
connection.db_type || 'postgresql',
|
||||
config,
|
||||
connectionId
|
||||
);
|
||||
|
||||
// 데이터 조회 (DB 타입에 따라 쿼리 구문 변경)
|
||||
let query: string;
|
||||
const dbType = connection.db_type?.toLowerCase() || 'postgresql';
|
||||
|
||||
if (dbType === 'oracle') {
|
||||
query = `SELECT * FROM ${tableName} WHERE ROWNUM <= ${limit}`;
|
||||
} else {
|
||||
query = `SELECT * FROM ${tableName} LIMIT ${limit}`;
|
||||
}
|
||||
|
||||
console.log(`[BatchExternalDbService] 실행할 쿼리: ${query}`);
|
||||
const result = await connector.executeQuery(query);
|
||||
|
||||
console.log(`[BatchExternalDbService] 외부 DB 데이터 조회 완료: ${result.rows.length}개 레코드`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result.rows
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`외부 DB 데이터 조회 오류 (connectionId: ${connectionId}, table: ${tableName}):`, error);
|
||||
return {
|
||||
success: false,
|
||||
message: "외부 DB 데이터 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 외부 DB 테이블에서 특정 컬럼들만 조회
|
||||
*/
|
||||
static async getDataFromTableWithColumns(
|
||||
connectionId: number,
|
||||
tableName: string,
|
||||
columns: string[],
|
||||
limit: number = 100
|
||||
): Promise<ApiResponse<any[]>> {
|
||||
try {
|
||||
console.log(`[BatchExternalDbService] 외부 DB 특정 컬럼 조회: connectionId=${connectionId}, tableName=${tableName}, columns=[${columns.join(', ')}]`);
|
||||
|
||||
// 외부 DB 연결 정보 조회
|
||||
const connection = await prisma.external_db_connections.findUnique({
|
||||
where: { id: connectionId }
|
||||
});
|
||||
|
||||
if (!connection) {
|
||||
return {
|
||||
success: false,
|
||||
message: "외부 DB 연결을 찾을 수 없습니다."
|
||||
};
|
||||
}
|
||||
|
||||
// 패스워드 복호화
|
||||
const decryptedPassword = PasswordEncryption.decrypt(connection.password);
|
||||
|
||||
// DB 연결 설정
|
||||
const config = {
|
||||
host: connection.host,
|
||||
port: connection.port,
|
||||
user: connection.username,
|
||||
password: decryptedPassword,
|
||||
database: connection.database_name,
|
||||
};
|
||||
|
||||
// DB 커넥터 생성
|
||||
const connector = await DatabaseConnectorFactory.createConnector(
|
||||
connection.db_type || 'postgresql',
|
||||
config,
|
||||
connectionId
|
||||
);
|
||||
|
||||
// 데이터 조회 (DB 타입에 따라 쿼리 구문 변경)
|
||||
let query: string;
|
||||
const dbType = connection.db_type?.toLowerCase() || 'postgresql';
|
||||
const columnList = columns.join(', ');
|
||||
|
||||
if (dbType === 'oracle') {
|
||||
query = `SELECT ${columnList} FROM ${tableName} WHERE ROWNUM <= ${limit}`;
|
||||
} else {
|
||||
query = `SELECT ${columnList} FROM ${tableName} LIMIT ${limit}`;
|
||||
}
|
||||
|
||||
console.log(`[BatchExternalDbService] 실행할 쿼리: ${query}`);
|
||||
const result = await connector.executeQuery(query);
|
||||
|
||||
console.log(`[BatchExternalDbService] 외부 DB 특정 컬럼 조회 완료: ${result.rows.length}개 레코드`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result.rows
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`외부 DB 특정 컬럼 조회 오류 (connectionId: ${connectionId}, table: ${tableName}):`, error);
|
||||
return {
|
||||
success: false,
|
||||
message: "외부 DB 특정 컬럼 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 외부 DB 테이블에 데이터 삽입
|
||||
*/
|
||||
static async insertDataToTable(
|
||||
connectionId: number,
|
||||
tableName: string,
|
||||
data: any[]
|
||||
): Promise<ApiResponse<{ successCount: number; failedCount: number }>> {
|
||||
try {
|
||||
console.log(`[BatchExternalDbService] 외부 DB 데이터 삽입: connectionId=${connectionId}, tableName=${tableName}, ${data.length}개 레코드`);
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
data: { successCount: 0, failedCount: 0 }
|
||||
};
|
||||
}
|
||||
|
||||
// 외부 DB 연결 정보 조회
|
||||
const connection = await prisma.external_db_connections.findUnique({
|
||||
where: { id: connectionId }
|
||||
});
|
||||
|
||||
if (!connection) {
|
||||
return {
|
||||
success: false,
|
||||
message: "외부 DB 연결을 찾을 수 없습니다."
|
||||
};
|
||||
}
|
||||
|
||||
// 패스워드 복호화
|
||||
const decryptedPassword = PasswordEncryption.decrypt(connection.password);
|
||||
|
||||
// DB 연결 설정
|
||||
const config = {
|
||||
host: connection.host,
|
||||
port: connection.port,
|
||||
user: connection.username,
|
||||
password: decryptedPassword,
|
||||
database: connection.database_name,
|
||||
};
|
||||
|
||||
// DB 커넥터 생성
|
||||
const connector = await DatabaseConnectorFactory.createConnector(
|
||||
connection.db_type || 'postgresql',
|
||||
config,
|
||||
connectionId
|
||||
);
|
||||
|
||||
let successCount = 0;
|
||||
let failedCount = 0;
|
||||
|
||||
// 각 레코드를 개별적으로 삽입 (UPSERT 방식으로 중복 처리)
|
||||
for (const record of data) {
|
||||
try {
|
||||
const columns = Object.keys(record);
|
||||
const values = Object.values(record);
|
||||
|
||||
// 값들을 SQL 문자열로 변환 (타입별 처리)
|
||||
const formattedValues = values.map(value => {
|
||||
if (value === null || value === undefined) {
|
||||
return 'NULL';
|
||||
} else if (value instanceof Date) {
|
||||
// Date 객체를 MySQL/MariaDB 형식으로 변환
|
||||
return `'${value.toISOString().slice(0, 19).replace('T', ' ')}'`;
|
||||
} else if (typeof value === 'string') {
|
||||
// 문자열이 날짜 형식인지 확인
|
||||
const dateRegex = /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{2}\s+\d{4}\s+\d{2}:\d{2}:\d{2}/;
|
||||
if (dateRegex.test(value)) {
|
||||
// JavaScript Date 문자열을 MySQL 형식으로 변환
|
||||
const date = new Date(value);
|
||||
return `'${date.toISOString().slice(0, 19).replace('T', ' ')}'`;
|
||||
} else {
|
||||
return `'${value.replace(/'/g, "''")}'`; // SQL 인젝션 방지를 위한 간단한 이스케이프
|
||||
}
|
||||
} else if (typeof value === 'number') {
|
||||
return String(value);
|
||||
} else if (typeof value === 'boolean') {
|
||||
return value ? '1' : '0';
|
||||
} else {
|
||||
// 기타 객체는 문자열로 변환
|
||||
return `'${String(value).replace(/'/g, "''")}'`;
|
||||
}
|
||||
}).join(', ');
|
||||
|
||||
// Primary Key 컬럼 추정
|
||||
const primaryKeyColumn = columns.includes('id') ? 'id' :
|
||||
columns.includes('user_id') ? 'user_id' :
|
||||
columns[0];
|
||||
|
||||
// UPDATE SET 절 생성 (Primary Key 제외)
|
||||
const updateColumns = columns.filter(col => col !== primaryKeyColumn);
|
||||
|
||||
let query: string;
|
||||
const dbType = connection.db_type?.toLowerCase() || 'mysql';
|
||||
|
||||
if (dbType === 'mysql' || dbType === 'mariadb') {
|
||||
// MySQL/MariaDB: ON DUPLICATE KEY UPDATE 사용
|
||||
if (updateColumns.length > 0) {
|
||||
const updateSet = updateColumns.map(col => `${col} = VALUES(${col})`).join(', ');
|
||||
query = `INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${formattedValues})
|
||||
ON DUPLICATE KEY UPDATE ${updateSet}`;
|
||||
} else {
|
||||
// Primary Key만 있는 경우 IGNORE 사용
|
||||
query = `INSERT IGNORE INTO ${tableName} (${columns.join(', ')}) VALUES (${formattedValues})`;
|
||||
}
|
||||
} else {
|
||||
// 다른 DB는 기본 INSERT 사용
|
||||
query = `INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${formattedValues})`;
|
||||
}
|
||||
|
||||
await connector.executeQuery(query);
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
console.error(`외부 DB 레코드 UPSERT 실패:`, error);
|
||||
failedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[BatchExternalDbService] 외부 DB 데이터 삽입 완료: 성공 ${successCount}개, 실패 ${failedCount}개`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { successCount, failedCount }
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`외부 DB 데이터 삽입 오류 (connectionId: ${connectionId}, table: ${tableName}):`, error);
|
||||
return {
|
||||
success: false,
|
||||
message: "외부 DB 데이터 삽입 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
370
backend-node/src/services/batchManagementService.ts
Normal file
370
backend-node/src/services/batchManagementService.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
// 배치관리 전용 서비스 (기존 소스와 완전 분리)
|
||||
// 작성일: 2024-12-24
|
||||
|
||||
import prisma from "../config/database";
|
||||
import { PasswordEncryption } from "../utils/passwordEncryption";
|
||||
import { DatabaseConnectorFactory } from "../database/DatabaseConnectorFactory";
|
||||
|
||||
// 배치관리 전용 타입 정의
|
||||
export interface BatchConnectionInfo {
|
||||
type: 'internal' | 'external';
|
||||
id?: number;
|
||||
name: string;
|
||||
db_type?: string;
|
||||
}
|
||||
|
||||
export interface BatchTableInfo {
|
||||
table_name: string;
|
||||
columns: BatchColumnInfo[];
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
export interface BatchColumnInfo {
|
||||
column_name: string;
|
||||
data_type: string;
|
||||
is_nullable?: string;
|
||||
column_default?: string | null;
|
||||
}
|
||||
|
||||
export interface BatchApiResponse<T = unknown> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export class BatchManagementService {
|
||||
/**
|
||||
* 배치관리용 연결 목록 조회
|
||||
*/
|
||||
static async getAvailableConnections(): Promise<BatchApiResponse<BatchConnectionInfo[]>> {
|
||||
try {
|
||||
const connections: BatchConnectionInfo[] = [];
|
||||
|
||||
// 내부 DB 추가
|
||||
connections.push({
|
||||
type: 'internal',
|
||||
name: '내부 데이터베이스 (PostgreSQL)',
|
||||
db_type: 'postgresql'
|
||||
});
|
||||
|
||||
// 활성화된 외부 DB 연결 조회
|
||||
const externalConnections = await prisma.external_db_connections.findMany({
|
||||
where: { is_active: 'Y' },
|
||||
select: {
|
||||
id: true,
|
||||
connection_name: true,
|
||||
db_type: true,
|
||||
description: true
|
||||
},
|
||||
orderBy: { connection_name: 'asc' }
|
||||
});
|
||||
|
||||
// 외부 DB 연결 추가
|
||||
externalConnections.forEach(conn => {
|
||||
connections.push({
|
||||
type: 'external',
|
||||
id: conn.id,
|
||||
name: `${conn.connection_name} (${conn.db_type?.toUpperCase()})`,
|
||||
db_type: conn.db_type || undefined
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: connections,
|
||||
message: `${connections.length}개의 연결을 조회했습니다.`
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("배치관리 연결 목록 조회 실패:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "연결 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치관리용 테이블 목록 조회
|
||||
*/
|
||||
static async getTablesFromConnection(
|
||||
connectionType: 'internal' | 'external',
|
||||
connectionId?: number
|
||||
): Promise<BatchApiResponse<BatchTableInfo[]>> {
|
||||
try {
|
||||
let tables: BatchTableInfo[] = [];
|
||||
|
||||
if (connectionType === 'internal') {
|
||||
// 내부 DB 테이블 조회
|
||||
const result = await prisma.$queryRaw<Array<{ table_name: string }>>`
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_type = 'BASE TABLE'
|
||||
ORDER BY table_name
|
||||
`;
|
||||
|
||||
tables = result.map(row => ({
|
||||
table_name: row.table_name,
|
||||
columns: []
|
||||
}));
|
||||
} else if (connectionType === 'external' && connectionId) {
|
||||
// 외부 DB 테이블 조회
|
||||
const tablesResult = await this.getExternalTables(connectionId);
|
||||
if (tablesResult.success && tablesResult.data) {
|
||||
tables = tablesResult.data;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: tables,
|
||||
message: `${tables.length}개의 테이블을 조회했습니다.`
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("배치관리 테이블 목록 조회 실패:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "테이블 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치관리용 테이블 컬럼 정보 조회
|
||||
*/
|
||||
static async getTableColumns(
|
||||
connectionType: 'internal' | 'external',
|
||||
connectionId: number | undefined,
|
||||
tableName: string
|
||||
): Promise<BatchApiResponse<BatchColumnInfo[]>> {
|
||||
try {
|
||||
console.log(`[BatchManagementService] getTableColumns 호출:`, {
|
||||
connectionType,
|
||||
connectionId,
|
||||
tableName
|
||||
});
|
||||
|
||||
let columns: BatchColumnInfo[] = [];
|
||||
|
||||
if (connectionType === 'internal') {
|
||||
// 내부 DB 컬럼 조회
|
||||
console.log(`[BatchManagementService] 내부 DB 컬럼 조회 시작: ${tableName}`);
|
||||
|
||||
const result = await prisma.$queryRaw<Array<{
|
||||
column_name: string;
|
||||
data_type: string;
|
||||
is_nullable: string;
|
||||
column_default: string | null
|
||||
}>>`
|
||||
SELECT
|
||||
column_name,
|
||||
data_type,
|
||||
is_nullable,
|
||||
column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = ${tableName}
|
||||
ORDER BY ordinal_position
|
||||
`;
|
||||
|
||||
console.log(`[BatchManagementService] 내부 DB 컬럼 조회 결과:`, result);
|
||||
|
||||
columns = result.map(row => ({
|
||||
column_name: row.column_name,
|
||||
data_type: row.data_type,
|
||||
is_nullable: row.is_nullable,
|
||||
column_default: row.column_default,
|
||||
}));
|
||||
} else if (connectionType === 'external' && connectionId) {
|
||||
// 외부 DB 컬럼 조회
|
||||
console.log(`[BatchManagementService] 외부 DB 컬럼 조회 시작: connectionId=${connectionId}, tableName=${tableName}`);
|
||||
|
||||
const columnsResult = await this.getExternalTableColumns(connectionId, tableName);
|
||||
|
||||
console.log(`[BatchManagementService] 외부 DB 컬럼 조회 결과:`, columnsResult);
|
||||
|
||||
if (columnsResult.success && columnsResult.data) {
|
||||
columns = columnsResult.data;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[BatchManagementService] 최종 컬럼 목록:`, columns);
|
||||
return {
|
||||
success: true,
|
||||
data: columns,
|
||||
message: `${columns.length}개의 컬럼을 조회했습니다.`
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[BatchManagementService] 컬럼 정보 조회 오류:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "컬럼 정보 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 외부 DB 테이블 목록 조회 (내부 구현)
|
||||
*/
|
||||
private static async getExternalTables(connectionId: number): Promise<BatchApiResponse<BatchTableInfo[]>> {
|
||||
try {
|
||||
// 연결 정보 조회
|
||||
const connection = await prisma.external_db_connections.findUnique({
|
||||
where: { id: connectionId }
|
||||
});
|
||||
|
||||
if (!connection) {
|
||||
return {
|
||||
success: false,
|
||||
message: "연결 정보를 찾을 수 없습니다."
|
||||
};
|
||||
}
|
||||
|
||||
// 비밀번호 복호화
|
||||
const decryptedPassword = PasswordEncryption.decrypt(connection.password);
|
||||
if (!decryptedPassword) {
|
||||
return {
|
||||
success: false,
|
||||
message: "비밀번호 복호화에 실패했습니다."
|
||||
};
|
||||
}
|
||||
|
||||
// 연결 설정 준비
|
||||
const config = {
|
||||
host: connection.host,
|
||||
port: connection.port,
|
||||
database: connection.database_name,
|
||||
user: connection.username,
|
||||
password: decryptedPassword,
|
||||
connectionTimeoutMillis: connection.connection_timeout != null ? connection.connection_timeout * 1000 : undefined,
|
||||
queryTimeoutMillis: connection.query_timeout != null ? connection.query_timeout * 1000 : undefined,
|
||||
ssl: connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false
|
||||
};
|
||||
|
||||
// DatabaseConnectorFactory를 통한 테이블 목록 조회
|
||||
const connector = await DatabaseConnectorFactory.createConnector(connection.db_type, config, connectionId);
|
||||
const tables = await connector.getTables();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "테이블 목록을 조회했습니다.",
|
||||
data: tables
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("외부 DB 테이블 목록 조회 오류:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "테이블 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 외부 DB 테이블 컬럼 정보 조회 (내부 구현)
|
||||
*/
|
||||
private static async getExternalTableColumns(connectionId: number, tableName: string): Promise<BatchApiResponse<BatchColumnInfo[]>> {
|
||||
try {
|
||||
console.log(`[BatchManagementService] getExternalTableColumns 호출: connectionId=${connectionId}, tableName=${tableName}`);
|
||||
|
||||
// 연결 정보 조회
|
||||
const connection = await prisma.external_db_connections.findUnique({
|
||||
where: { id: connectionId }
|
||||
});
|
||||
|
||||
if (!connection) {
|
||||
console.log(`[BatchManagementService] 연결 정보를 찾을 수 없음: connectionId=${connectionId}`);
|
||||
return {
|
||||
success: false,
|
||||
message: "연결 정보를 찾을 수 없습니다."
|
||||
};
|
||||
}
|
||||
|
||||
console.log(`[BatchManagementService] 연결 정보 조회 성공:`, {
|
||||
id: connection.id,
|
||||
connection_name: connection.connection_name,
|
||||
db_type: connection.db_type,
|
||||
host: connection.host,
|
||||
port: connection.port,
|
||||
database_name: connection.database_name
|
||||
});
|
||||
|
||||
// 비밀번호 복호화
|
||||
const decryptedPassword = PasswordEncryption.decrypt(connection.password);
|
||||
|
||||
// 연결 설정 준비
|
||||
const config = {
|
||||
host: connection.host,
|
||||
port: connection.port,
|
||||
database: connection.database_name,
|
||||
user: connection.username,
|
||||
password: decryptedPassword,
|
||||
connectionTimeoutMillis: connection.connection_timeout != null ? connection.connection_timeout * 1000 : undefined,
|
||||
queryTimeoutMillis: connection.query_timeout != null ? connection.query_timeout * 1000 : undefined,
|
||||
ssl: connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false
|
||||
};
|
||||
|
||||
console.log(`[BatchManagementService] 커넥터 생성 시작: db_type=${connection.db_type}`);
|
||||
|
||||
// 데이터베이스 타입에 따른 커넥터 생성
|
||||
const connector = await DatabaseConnectorFactory.createConnector(connection.db_type, config, connectionId);
|
||||
|
||||
console.log(`[BatchManagementService] 커넥터 생성 완료, 컬럼 조회 시작: tableName=${tableName}`);
|
||||
|
||||
// 컬럼 정보 조회
|
||||
console.log(`[BatchManagementService] connector.getColumns 호출 전`);
|
||||
const columns = await connector.getColumns(tableName);
|
||||
|
||||
console.log(`[BatchManagementService] 원본 컬럼 조회 결과:`, columns);
|
||||
console.log(`[BatchManagementService] 원본 컬럼 개수:`, columns ? columns.length : 'null/undefined');
|
||||
|
||||
// 각 데이터베이스 커넥터의 반환 구조가 다르므로 통일된 구조로 변환
|
||||
const standardizedColumns: BatchColumnInfo[] = columns.map((col: any) => {
|
||||
console.log(`[BatchManagementService] 컬럼 변환 중:`, col);
|
||||
|
||||
// MySQL/MariaDB 구조: {name, dataType, isNullable, defaultValue} (MySQLConnector만)
|
||||
if (col.name && col.dataType !== undefined) {
|
||||
const result = {
|
||||
column_name: col.name,
|
||||
data_type: col.dataType,
|
||||
is_nullable: col.isNullable ? 'YES' : 'NO',
|
||||
column_default: col.defaultValue || null,
|
||||
};
|
||||
console.log(`[BatchManagementService] MySQL/MariaDB 구조로 변환:`, result);
|
||||
return result;
|
||||
}
|
||||
// PostgreSQL/Oracle/MSSQL/MariaDB 구조: {column_name, data_type, is_nullable, column_default}
|
||||
else {
|
||||
const result = {
|
||||
column_name: col.column_name || col.COLUMN_NAME,
|
||||
data_type: col.data_type || col.DATA_TYPE,
|
||||
is_nullable: col.is_nullable || col.IS_NULLABLE || (col.nullable === 'Y' ? 'YES' : 'NO'),
|
||||
column_default: col.column_default || col.COLUMN_DEFAULT || null,
|
||||
};
|
||||
console.log(`[BatchManagementService] 표준 구조로 변환:`, result);
|
||||
return result;
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`[BatchManagementService] 표준화된 컬럼 목록:`, standardizedColumns);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: standardizedColumns,
|
||||
message: "컬럼 정보를 조회했습니다."
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[BatchManagementService] 외부 DB 컬럼 정보 조회 오류:", error);
|
||||
console.error("[BatchManagementService] 오류 스택:", error instanceof Error ? error.stack : 'No stack trace');
|
||||
return {
|
||||
success: false,
|
||||
message: "컬럼 정보 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
397
backend-node/src/services/batchSchedulerService.ts
Normal file
397
backend-node/src/services/batchSchedulerService.ts
Normal file
@@ -0,0 +1,397 @@
|
||||
// 배치 스케줄러 서비스
|
||||
// 작성일: 2024-12-24
|
||||
|
||||
import * as cron from 'node-cron';
|
||||
import prisma from '../config/database';
|
||||
import { BatchService } from './batchService';
|
||||
import { BatchExecutionLogService } from './batchExecutionLogService';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
export class BatchSchedulerService {
|
||||
private static scheduledTasks: Map<number, cron.ScheduledTask> = new Map();
|
||||
private static isInitialized = false;
|
||||
|
||||
/**
|
||||
* 스케줄러 초기화
|
||||
*/
|
||||
static async initialize() {
|
||||
if (this.isInitialized) {
|
||||
logger.info('배치 스케줄러가 이미 초기화되었습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info('배치 스케줄러 초기화 시작...');
|
||||
|
||||
// 활성화된 배치 설정들을 로드하여 스케줄 등록
|
||||
await this.loadActiveBatchConfigs();
|
||||
|
||||
this.isInitialized = true;
|
||||
logger.info('배치 스케줄러 초기화 완료');
|
||||
} catch (error) {
|
||||
logger.error('배치 스케줄러 초기화 실패:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성화된 배치 설정들을 로드하여 스케줄 등록
|
||||
*/
|
||||
private static async loadActiveBatchConfigs() {
|
||||
try {
|
||||
const activeConfigs = await prisma.batch_configs.findMany({
|
||||
where: {
|
||||
is_active: 'Y'
|
||||
},
|
||||
include: {
|
||||
batch_mappings: true
|
||||
}
|
||||
});
|
||||
|
||||
logger.info(`활성화된 배치 설정 ${activeConfigs.length}개 발견`);
|
||||
|
||||
for (const config of activeConfigs) {
|
||||
await this.scheduleBatchConfig(config);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('활성화된 배치 설정 로드 실패:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 설정을 스케줄에 등록
|
||||
*/
|
||||
static async scheduleBatchConfig(config: any) {
|
||||
try {
|
||||
const { id, batch_name, cron_schedule } = config;
|
||||
|
||||
// 기존 스케줄이 있다면 제거
|
||||
if (this.scheduledTasks.has(id)) {
|
||||
this.scheduledTasks.get(id)?.stop();
|
||||
this.scheduledTasks.delete(id);
|
||||
}
|
||||
|
||||
// cron 스케줄 유효성 검사
|
||||
if (!cron.validate(cron_schedule)) {
|
||||
logger.error(`잘못된 cron 스케줄: ${cron_schedule} (배치 ID: ${id})`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 새로운 스케줄 등록
|
||||
const task = cron.schedule(cron_schedule, async () => {
|
||||
await this.executeBatchConfig(config);
|
||||
});
|
||||
|
||||
this.scheduledTasks.set(id, task);
|
||||
logger.info(`배치 스케줄 등록 완료: ${batch_name} (ID: ${id}, Schedule: ${cron_schedule})`);
|
||||
} catch (error) {
|
||||
logger.error(`배치 스케줄 등록 실패 (ID: ${config.id}):`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 설정 스케줄 제거
|
||||
*/
|
||||
static async unscheduleBatchConfig(batchConfigId: number) {
|
||||
try {
|
||||
if (this.scheduledTasks.has(batchConfigId)) {
|
||||
this.scheduledTasks.get(batchConfigId)?.stop();
|
||||
this.scheduledTasks.delete(batchConfigId);
|
||||
logger.info(`배치 스케줄 제거 완료 (ID: ${batchConfigId})`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`배치 스케줄 제거 실패 (ID: ${batchConfigId}):`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 설정 업데이트 시 스케줄 재등록
|
||||
*/
|
||||
static async updateBatchSchedule(configId: number) {
|
||||
try {
|
||||
// 기존 스케줄 제거
|
||||
await this.unscheduleBatchConfig(configId);
|
||||
|
||||
// 업데이트된 배치 설정 조회
|
||||
const config = await prisma.batch_configs.findUnique({
|
||||
where: { id: configId },
|
||||
include: { batch_mappings: true }
|
||||
});
|
||||
|
||||
if (!config) {
|
||||
logger.warn(`배치 설정을 찾을 수 없습니다: ID ${configId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 활성화된 배치만 다시 스케줄 등록
|
||||
if (config.is_active === 'Y') {
|
||||
await this.scheduleBatchConfig(config);
|
||||
logger.info(`배치 스케줄 업데이트 완료: ${config.batch_name} (ID: ${configId})`);
|
||||
} else {
|
||||
logger.info(`비활성화된 배치 스케줄 제거: ${config.batch_name} (ID: ${configId})`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`배치 스케줄 업데이트 실패: ID ${configId}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 설정 실행
|
||||
*/
|
||||
private static async executeBatchConfig(config: any) {
|
||||
const startTime = new Date();
|
||||
let executionLog: any = null;
|
||||
|
||||
try {
|
||||
logger.info(`배치 실행 시작: ${config.batch_name} (ID: ${config.id})`);
|
||||
|
||||
// 실행 로그 생성
|
||||
const executionLogResponse = await BatchExecutionLogService.createExecutionLog({
|
||||
batch_config_id: config.id,
|
||||
execution_status: 'RUNNING',
|
||||
start_time: startTime,
|
||||
total_records: 0,
|
||||
success_records: 0,
|
||||
failed_records: 0
|
||||
});
|
||||
|
||||
if (!executionLogResponse.success || !executionLogResponse.data) {
|
||||
logger.error(`배치 실행 로그 생성 실패: ${config.batch_name}`, executionLogResponse.message);
|
||||
return;
|
||||
}
|
||||
|
||||
executionLog = executionLogResponse.data;
|
||||
|
||||
// 실제 배치 실행 로직 (수동 실행과 동일한 로직 사용)
|
||||
const result = await this.executeBatchMappings(config);
|
||||
|
||||
// 실행 로그 업데이트 (성공)
|
||||
await BatchExecutionLogService.updateExecutionLog(executionLog.id, {
|
||||
execution_status: 'SUCCESS',
|
||||
end_time: new Date(),
|
||||
duration_ms: Date.now() - startTime.getTime(),
|
||||
total_records: result.totalRecords,
|
||||
success_records: result.successRecords,
|
||||
failed_records: result.failedRecords
|
||||
});
|
||||
|
||||
logger.info(`배치 실행 완료: ${config.batch_name} (처리된 레코드: ${result.totalRecords})`);
|
||||
} catch (error) {
|
||||
logger.error(`배치 실행 실패: ${config.batch_name}`, error);
|
||||
|
||||
// 실행 로그 업데이트 (실패)
|
||||
if (executionLog) {
|
||||
await BatchExecutionLogService.updateExecutionLog(executionLog.id, {
|
||||
execution_status: 'FAILED',
|
||||
end_time: new Date(),
|
||||
duration_ms: Date.now() - startTime.getTime(),
|
||||
error_message: error instanceof Error ? error.message : '알 수 없는 오류',
|
||||
error_details: error instanceof Error ? error.stack : String(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 매핑 실행 (수동 실행과 동일한 로직)
|
||||
*/
|
||||
private static async executeBatchMappings(config: any) {
|
||||
let totalRecords = 0;
|
||||
let successRecords = 0;
|
||||
let failedRecords = 0;
|
||||
|
||||
if (!config.batch_mappings || config.batch_mappings.length === 0) {
|
||||
logger.warn(`배치 매핑이 없습니다: ${config.batch_name}`);
|
||||
return { totalRecords, successRecords, failedRecords };
|
||||
}
|
||||
|
||||
// 테이블별로 매핑을 그룹화
|
||||
const tableGroups = new Map<string, typeof config.batch_mappings>();
|
||||
|
||||
for (const mapping of config.batch_mappings) {
|
||||
const key = `${mapping.from_connection_type}:${mapping.from_connection_id || 'internal'}:${mapping.from_table_name}`;
|
||||
if (!tableGroups.has(key)) {
|
||||
tableGroups.set(key, []);
|
||||
}
|
||||
tableGroups.get(key)!.push(mapping);
|
||||
}
|
||||
|
||||
// 각 테이블 그룹별로 처리
|
||||
for (const [tableKey, mappings] of tableGroups) {
|
||||
try {
|
||||
const firstMapping = mappings[0];
|
||||
logger.info(`테이블 처리 시작: ${tableKey} -> ${mappings.length}개 컬럼 매핑`);
|
||||
|
||||
// FROM 테이블에서 매핑된 컬럼들만 조회
|
||||
const fromColumns = mappings.map((m: any) => m.from_column_name);
|
||||
const fromData = await BatchService.getDataFromTableWithColumns(
|
||||
firstMapping.from_table_name,
|
||||
fromColumns,
|
||||
firstMapping.from_connection_type as 'internal' | 'external',
|
||||
firstMapping.from_connection_id || undefined
|
||||
);
|
||||
totalRecords += fromData.length;
|
||||
|
||||
// 컬럼 매핑 적용하여 TO 테이블 형식으로 변환
|
||||
const mappedData = fromData.map(row => {
|
||||
const mappedRow: any = {};
|
||||
for (const mapping of mappings) {
|
||||
mappedRow[mapping.to_column_name] = row[mapping.from_column_name];
|
||||
}
|
||||
return mappedRow;
|
||||
});
|
||||
|
||||
// TO 테이블에 데이터 삽입
|
||||
const insertResult = await BatchService.insertDataToTable(
|
||||
firstMapping.to_table_name,
|
||||
mappedData,
|
||||
firstMapping.to_connection_type as 'internal' | 'external',
|
||||
firstMapping.to_connection_id || undefined
|
||||
);
|
||||
successRecords += insertResult.successCount;
|
||||
failedRecords += insertResult.failedCount;
|
||||
|
||||
logger.info(`테이블 처리 완료: ${insertResult.successCount}개 성공, ${insertResult.failedCount}개 실패`);
|
||||
} catch (error) {
|
||||
logger.error(`테이블 처리 실패: ${tableKey}`, error);
|
||||
failedRecords += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { totalRecords, successRecords, failedRecords };
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 매핑 처리 (기존 메서드 - 사용 안 함)
|
||||
*/
|
||||
private static async processBatchMappings(config: any) {
|
||||
const { batch_mappings } = config;
|
||||
let totalRecords = 0;
|
||||
let successRecords = 0;
|
||||
let failedRecords = 0;
|
||||
|
||||
if (!batch_mappings || batch_mappings.length === 0) {
|
||||
logger.warn(`배치 매핑이 없습니다: ${config.batch_name}`);
|
||||
return { totalRecords, successRecords, failedRecords };
|
||||
}
|
||||
|
||||
for (const mapping of batch_mappings) {
|
||||
try {
|
||||
logger.info(`매핑 처리 시작: ${mapping.from_table_name} -> ${mapping.to_table_name}`);
|
||||
|
||||
// FROM 테이블에서 데이터 조회
|
||||
const fromData = await this.getDataFromSource(mapping);
|
||||
totalRecords += fromData.length;
|
||||
|
||||
// TO 테이블에 데이터 삽입
|
||||
const insertResult = await this.insertDataToTarget(mapping, fromData);
|
||||
successRecords += insertResult.successCount;
|
||||
failedRecords += insertResult.failedCount;
|
||||
|
||||
logger.info(`매핑 처리 완료: ${insertResult.successCount}개 성공, ${insertResult.failedCount}개 실패`);
|
||||
} catch (error) {
|
||||
logger.error(`매핑 처리 실패: ${mapping.from_table_name} -> ${mapping.to_table_name}`, error);
|
||||
failedRecords += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { totalRecords, successRecords, failedRecords };
|
||||
}
|
||||
|
||||
/**
|
||||
* FROM 테이블에서 데이터 조회
|
||||
*/
|
||||
private static async getDataFromSource(mapping: any) {
|
||||
try {
|
||||
if (mapping.from_connection_type === 'internal') {
|
||||
// 내부 DB에서 조회
|
||||
const result = await prisma.$queryRawUnsafe(
|
||||
`SELECT * FROM ${mapping.from_table_name}`
|
||||
);
|
||||
return result as any[];
|
||||
} else {
|
||||
// 외부 DB에서 조회 (구현 필요)
|
||||
logger.warn('외부 DB 조회는 아직 구현되지 않았습니다.');
|
||||
return [];
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`FROM 테이블 데이터 조회 실패: ${mapping.from_table_name}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TO 테이블에 데이터 삽입
|
||||
*/
|
||||
private static async insertDataToTarget(mapping: any, data: any[]) {
|
||||
let successCount = 0;
|
||||
let failedCount = 0;
|
||||
|
||||
try {
|
||||
if (mapping.to_connection_type === 'internal') {
|
||||
// 내부 DB에 삽입
|
||||
for (const record of data) {
|
||||
try {
|
||||
// 매핑된 컬럼만 추출
|
||||
const mappedData = this.mapColumns(record, mapping);
|
||||
|
||||
await prisma.$executeRawUnsafe(
|
||||
`INSERT INTO ${mapping.to_table_name} (${Object.keys(mappedData).join(', ')}) VALUES (${Object.values(mappedData).map(() => '?').join(', ')})`,
|
||||
...Object.values(mappedData)
|
||||
);
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
logger.error(`레코드 삽입 실패:`, error);
|
||||
failedCount++;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 외부 DB에 삽입 (구현 필요)
|
||||
logger.warn('외부 DB 삽입은 아직 구현되지 않았습니다.');
|
||||
failedCount = data.length;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`TO 테이블 데이터 삽입 실패: ${mapping.to_table_name}`, error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return { successCount, failedCount };
|
||||
}
|
||||
|
||||
/**
|
||||
* 컬럼 매핑
|
||||
*/
|
||||
private static mapColumns(record: any, mapping: any) {
|
||||
const mappedData: any = {};
|
||||
|
||||
// 단순한 컬럼 매핑 (실제로는 더 복잡한 로직 필요)
|
||||
mappedData[mapping.to_column_name] = record[mapping.from_column_name];
|
||||
|
||||
return mappedData;
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 스케줄 중지
|
||||
*/
|
||||
static async stopAllSchedules() {
|
||||
try {
|
||||
for (const [id, task] of this.scheduledTasks) {
|
||||
task.stop();
|
||||
logger.info(`배치 스케줄 중지: ID ${id}`);
|
||||
}
|
||||
this.scheduledTasks.clear();
|
||||
this.isInitialized = false;
|
||||
logger.info('모든 배치 스케줄이 중지되었습니다.');
|
||||
} catch (error) {
|
||||
logger.error('배치 스케줄 중지 실패:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 등록된 스케줄 목록 조회
|
||||
*/
|
||||
static getScheduledTasks() {
|
||||
return Array.from(this.scheduledTasks.keys());
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// 배치관리 서비스
|
||||
// 작성일: 2024-12-24
|
||||
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import prisma from "../config/database";
|
||||
import {
|
||||
BatchConfig,
|
||||
BatchMapping,
|
||||
@@ -12,12 +12,12 @@ import {
|
||||
ConnectionInfo,
|
||||
TableInfo,
|
||||
ColumnInfo,
|
||||
CreateBatchConfigRequest,
|
||||
UpdateBatchConfigRequest,
|
||||
} from "../types/batchTypes";
|
||||
import { ExternalDbConnectionService } from "./externalDbConnectionService";
|
||||
import { BatchExternalDbService } from "./batchExternalDbService";
|
||||
import { DbConnectionManager } from "./dbConnectionManager";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export class BatchService {
|
||||
/**
|
||||
* 배치 설정 목록 조회
|
||||
@@ -55,17 +55,32 @@ export class BatchService {
|
||||
];
|
||||
}
|
||||
|
||||
const batchConfigs = await prisma.batch_configs.findMany({
|
||||
where,
|
||||
include: {
|
||||
batch_mappings: true,
|
||||
},
|
||||
orderBy: [{ is_active: "desc" }, { batch_name: "asc" }],
|
||||
});
|
||||
const page = filter.page || 1;
|
||||
const limit = filter.limit || 10;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const [batchConfigs, total] = await Promise.all([
|
||||
prisma.batch_configs.findMany({
|
||||
where,
|
||||
include: {
|
||||
batch_mappings: true,
|
||||
},
|
||||
orderBy: [{ is_active: "desc" }, { batch_name: "asc" }],
|
||||
skip,
|
||||
take: limit,
|
||||
}),
|
||||
prisma.batch_configs.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: batchConfigs as BatchConfig[],
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("배치 설정 목록 조회 오류:", error);
|
||||
@@ -122,28 +137,18 @@ export class BatchService {
|
||||
* 배치 설정 생성
|
||||
*/
|
||||
static async createBatchConfig(
|
||||
data: BatchMappingRequest,
|
||||
data: CreateBatchConfigRequest,
|
||||
userId?: string
|
||||
): Promise<ApiResponse<BatchConfig>> {
|
||||
try {
|
||||
// 매핑 유효성 검사
|
||||
const validation = await this.validateBatchMappings(data.mappings);
|
||||
if (!validation.isValid) {
|
||||
return {
|
||||
success: false,
|
||||
message: "매핑 유효성 검사 실패",
|
||||
error: validation.errors.join(", "),
|
||||
};
|
||||
}
|
||||
|
||||
// 트랜잭션으로 배치 설정과 매핑 생성
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
// 배치 설정 생성
|
||||
const batchConfig = await tx.batch_configs.create({
|
||||
data: {
|
||||
batch_name: data.batch_name,
|
||||
batch_name: data.batchName,
|
||||
description: data.description,
|
||||
cron_schedule: data.cron_schedule,
|
||||
cron_schedule: data.cronSchedule,
|
||||
created_by: userId,
|
||||
updated_by: userId,
|
||||
},
|
||||
@@ -198,7 +203,7 @@ export class BatchService {
|
||||
*/
|
||||
static async updateBatchConfig(
|
||||
id: number,
|
||||
data: Partial<BatchMappingRequest>,
|
||||
data: UpdateBatchConfigRequest,
|
||||
userId?: string
|
||||
): Promise<ApiResponse<BatchConfig>> {
|
||||
try {
|
||||
@@ -215,18 +220,6 @@ export class BatchService {
|
||||
};
|
||||
}
|
||||
|
||||
// 매핑이 제공된 경우 유효성 검사
|
||||
if (data.mappings) {
|
||||
const validation = await this.validateBatchMappings(data.mappings);
|
||||
if (!validation.isValid) {
|
||||
return {
|
||||
success: false,
|
||||
message: "매핑 유효성 검사 실패",
|
||||
error: validation.errors.join(", "),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 트랜잭션으로 업데이트
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
// 배치 설정 업데이트
|
||||
@@ -234,9 +227,10 @@ export class BatchService {
|
||||
updated_by: userId,
|
||||
};
|
||||
|
||||
if (data.batch_name) updateData.batch_name = data.batch_name;
|
||||
if (data.batchName) updateData.batch_name = data.batchName;
|
||||
if (data.description !== undefined) updateData.description = data.description;
|
||||
if (data.cron_schedule) updateData.cron_schedule = data.cron_schedule;
|
||||
if (data.cronSchedule) updateData.cron_schedule = data.cronSchedule;
|
||||
if (data.isActive !== undefined) updateData.is_active = data.isActive;
|
||||
|
||||
const batchConfig = await tx.batch_configs.update({
|
||||
where: { id },
|
||||
@@ -354,16 +348,14 @@ export class BatchService {
|
||||
});
|
||||
|
||||
// 외부 DB 연결 조회
|
||||
const externalConnections = await ExternalDbConnectionService.getConnections({
|
||||
is_active: 'Y',
|
||||
});
|
||||
const externalConnections = await BatchExternalDbService.getAvailableConnections();
|
||||
|
||||
if (externalConnections.success && externalConnections.data) {
|
||||
externalConnections.data.forEach((conn) => {
|
||||
connections.push({
|
||||
type: 'external',
|
||||
id: conn.id,
|
||||
name: conn.connection_name,
|
||||
name: conn.name,
|
||||
db_type: conn.db_type,
|
||||
});
|
||||
});
|
||||
@@ -389,9 +381,9 @@ export class BatchService {
|
||||
static async getTablesFromConnection(
|
||||
connectionType: 'internal' | 'external',
|
||||
connectionId?: number
|
||||
): Promise<ApiResponse<string[]>> {
|
||||
): Promise<ApiResponse<TableInfo[]>> {
|
||||
try {
|
||||
let tables: string[] = [];
|
||||
let tables: TableInfo[] = [];
|
||||
|
||||
if (connectionType === 'internal') {
|
||||
// 내부 DB 테이블 조회
|
||||
@@ -402,10 +394,13 @@ export class BatchService {
|
||||
AND table_type = 'BASE TABLE'
|
||||
ORDER BY table_name
|
||||
`;
|
||||
tables = result.map(row => row.table_name);
|
||||
tables = result.map(row => ({
|
||||
table_name: row.table_name,
|
||||
columns: []
|
||||
}));
|
||||
} else if (connectionType === 'external' && connectionId) {
|
||||
// 외부 DB 테이블 조회
|
||||
const tablesResult = await ExternalDbConnectionService.getTables(connectionId);
|
||||
const tablesResult = await BatchExternalDbService.getTablesFromConnection(connectionType, connectionId);
|
||||
if (tablesResult.success && tablesResult.data) {
|
||||
tables = tablesResult.data;
|
||||
}
|
||||
@@ -430,14 +425,22 @@ export class BatchService {
|
||||
*/
|
||||
static async getTableColumns(
|
||||
connectionType: 'internal' | 'external',
|
||||
tableName: string,
|
||||
connectionId?: number
|
||||
connectionId: number | undefined,
|
||||
tableName: string
|
||||
): Promise<ApiResponse<ColumnInfo[]>> {
|
||||
try {
|
||||
console.log(`[BatchService] getTableColumns 호출:`, {
|
||||
connectionType,
|
||||
connectionId,
|
||||
tableName
|
||||
});
|
||||
|
||||
let columns: ColumnInfo[] = [];
|
||||
|
||||
|
||||
if (connectionType === 'internal') {
|
||||
// 내부 DB 컬럼 조회
|
||||
console.log(`[BatchService] 내부 DB 컬럼 조회 시작: ${tableName}`);
|
||||
|
||||
const result = await prisma.$queryRaw<Array<{
|
||||
column_name: string;
|
||||
data_type: string;
|
||||
@@ -455,26 +458,31 @@ export class BatchService {
|
||||
ORDER BY ordinal_position
|
||||
`;
|
||||
|
||||
console.log(`[BatchService] 내부 DB 컬럼 조회 결과:`, result);
|
||||
|
||||
columns = result.map(row => ({
|
||||
column_name: row.column_name,
|
||||
data_type: row.data_type,
|
||||
is_nullable: row.is_nullable === 'YES',
|
||||
is_nullable: row.is_nullable,
|
||||
column_default: row.column_default,
|
||||
}));
|
||||
} else if (connectionType === 'external' && connectionId) {
|
||||
// 외부 DB 컬럼 조회
|
||||
const columnsResult = await ExternalDbConnectionService.getTableColumns(
|
||||
console.log(`[BatchService] 외부 DB 컬럼 조회 시작: connectionId=${connectionId}, tableName=${tableName}`);
|
||||
|
||||
const columnsResult = await BatchExternalDbService.getTableColumns(
|
||||
connectionType,
|
||||
connectionId,
|
||||
tableName
|
||||
);
|
||||
|
||||
console.log(`[BatchService] 외부 DB 컬럼 조회 결과:`, columnsResult);
|
||||
|
||||
if (columnsResult.success && columnsResult.data) {
|
||||
columns = columnsResult.data.map(col => ({
|
||||
column_name: col.column_name,
|
||||
data_type: col.data_type,
|
||||
is_nullable: col.is_nullable,
|
||||
column_default: col.column_default,
|
||||
}));
|
||||
columns = columnsResult.data;
|
||||
}
|
||||
|
||||
console.log(`[BatchService] 외부 DB 컬럼:`, columns);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -491,6 +499,228 @@ export class BatchService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 실행 로그 생성
|
||||
*/
|
||||
static async createExecutionLog(data: {
|
||||
batch_config_id: number;
|
||||
execution_status: string;
|
||||
start_time: Date;
|
||||
total_records: number;
|
||||
success_records: number;
|
||||
failed_records: number;
|
||||
}): Promise<any> {
|
||||
try {
|
||||
const executionLog = await prisma.batch_execution_logs.create({
|
||||
data: {
|
||||
batch_config_id: data.batch_config_id,
|
||||
execution_status: data.execution_status,
|
||||
start_time: data.start_time,
|
||||
total_records: data.total_records,
|
||||
success_records: data.success_records,
|
||||
failed_records: data.failed_records,
|
||||
},
|
||||
});
|
||||
|
||||
return executionLog;
|
||||
} catch (error) {
|
||||
console.error("배치 실행 로그 생성 오류:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 실행 로그 업데이트
|
||||
*/
|
||||
static async updateExecutionLog(
|
||||
id: number,
|
||||
data: {
|
||||
execution_status?: string;
|
||||
end_time?: Date;
|
||||
duration_ms?: number;
|
||||
total_records?: number;
|
||||
success_records?: number;
|
||||
failed_records?: number;
|
||||
error_message?: string;
|
||||
}
|
||||
): Promise<void> {
|
||||
try {
|
||||
await prisma.batch_execution_logs.update({
|
||||
where: { id },
|
||||
data,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("배치 실행 로그 업데이트 오류:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블에서 데이터 조회 (연결 타입에 따라 내부/외부 DB 구분)
|
||||
*/
|
||||
static async getDataFromTable(
|
||||
tableName: string,
|
||||
connectionType: 'internal' | 'external' = 'internal',
|
||||
connectionId?: number
|
||||
): Promise<any[]> {
|
||||
try {
|
||||
console.log(`[BatchService] 테이블에서 데이터 조회: ${tableName} (${connectionType}${connectionId ? `:${connectionId}` : ''})`);
|
||||
|
||||
if (connectionType === 'internal') {
|
||||
// 내부 DB에서 데이터 조회
|
||||
const result = await prisma.$queryRawUnsafe(`SELECT * FROM ${tableName} LIMIT 100`);
|
||||
console.log(`[BatchService] 내부 DB 데이터 조회 결과: ${Array.isArray(result) ? result.length : 0}개 레코드`);
|
||||
return result as any[];
|
||||
} else if (connectionType === 'external' && connectionId) {
|
||||
// 외부 DB에서 데이터 조회
|
||||
const result = await BatchExternalDbService.getDataFromTable(connectionId, tableName);
|
||||
if (result.success && result.data) {
|
||||
console.log(`[BatchService] 외부 DB 데이터 조회 결과: ${result.data.length}개 레코드`);
|
||||
return result.data;
|
||||
} else {
|
||||
console.error(`외부 DB 데이터 조회 실패: ${result.message}`);
|
||||
return [];
|
||||
}
|
||||
} else {
|
||||
throw new Error(`잘못된 연결 타입 또는 연결 ID: ${connectionType}, ${connectionId}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`테이블 데이터 조회 오류 (${tableName}):`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블에서 특정 컬럼들만 조회 (연결 타입에 따라 내부/외부 DB 구분)
|
||||
*/
|
||||
static async getDataFromTableWithColumns(
|
||||
tableName: string,
|
||||
columns: string[],
|
||||
connectionType: 'internal' | 'external' = 'internal',
|
||||
connectionId?: number
|
||||
): Promise<any[]> {
|
||||
try {
|
||||
console.log(`[BatchService] 테이블에서 특정 컬럼 데이터 조회: ${tableName} (${columns.join(', ')}) (${connectionType}${connectionId ? `:${connectionId}` : ''})`);
|
||||
|
||||
if (connectionType === 'internal') {
|
||||
// 내부 DB에서 특정 컬럼만 조회
|
||||
const columnList = columns.join(', ');
|
||||
const result = await prisma.$queryRawUnsafe(`SELECT ${columnList} FROM ${tableName} LIMIT 100`);
|
||||
console.log(`[BatchService] 내부 DB 특정 컬럼 조회 결과: ${Array.isArray(result) ? result.length : 0}개 레코드`);
|
||||
return result as any[];
|
||||
} else if (connectionType === 'external' && connectionId) {
|
||||
// 외부 DB에서 특정 컬럼만 조회
|
||||
const result = await BatchExternalDbService.getDataFromTableWithColumns(connectionId, tableName, columns);
|
||||
if (result.success && result.data) {
|
||||
console.log(`[BatchService] 외부 DB 특정 컬럼 조회 결과: ${result.data.length}개 레코드`);
|
||||
return result.data;
|
||||
} else {
|
||||
console.error(`외부 DB 특정 컬럼 조회 실패: ${result.message}`);
|
||||
return [];
|
||||
}
|
||||
} else {
|
||||
throw new Error(`잘못된 연결 타입 또는 연결 ID: ${connectionType}, ${connectionId}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`테이블 특정 컬럼 조회 오류 (${tableName}):`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블에 데이터 삽입 (연결 타입에 따라 내부/외부 DB 구분)
|
||||
*/
|
||||
static async insertDataToTable(
|
||||
tableName: string,
|
||||
data: any[],
|
||||
connectionType: 'internal' | 'external' = 'internal',
|
||||
connectionId?: number
|
||||
): Promise<{
|
||||
successCount: number;
|
||||
failedCount: number;
|
||||
}> {
|
||||
try {
|
||||
console.log(`[BatchService] 테이블에 데이터 삽입: ${tableName} (${connectionType}${connectionId ? `:${connectionId}` : ''}), ${data.length}개 레코드`);
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return { successCount: 0, failedCount: 0 };
|
||||
}
|
||||
|
||||
if (connectionType === 'internal') {
|
||||
// 내부 DB에 데이터 삽입
|
||||
let successCount = 0;
|
||||
let failedCount = 0;
|
||||
|
||||
// 각 레코드를 개별적으로 삽입 (UPSERT 방식으로 중복 처리)
|
||||
for (const record of data) {
|
||||
try {
|
||||
// 동적 UPSERT 쿼리 생성 (PostgreSQL ON CONFLICT 사용)
|
||||
const columns = Object.keys(record);
|
||||
const values = Object.values(record).map(value => {
|
||||
// Date 객체를 ISO 문자열로 변환 (PostgreSQL이 자동으로 파싱)
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString();
|
||||
}
|
||||
// JavaScript Date 문자열을 Date 객체로 변환 후 ISO 문자열로
|
||||
if (typeof value === 'string') {
|
||||
const dateRegex = /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{2}\s+\d{4}\s+\d{2}:\d{2}:\d{2}/;
|
||||
if (dateRegex.test(value)) {
|
||||
return new Date(value).toISOString();
|
||||
}
|
||||
}
|
||||
return value;
|
||||
});
|
||||
const placeholders = values.map((_, index) => `$${index + 1}`).join(', ');
|
||||
|
||||
// Primary Key 컬럼 추정 (일반적으로 id 또는 첫 번째 컬럼)
|
||||
const primaryKeyColumn = columns.includes('id') ? 'id' :
|
||||
columns.includes('user_id') ? 'user_id' :
|
||||
columns[0];
|
||||
|
||||
// UPDATE SET 절 생성 (Primary Key 제외)
|
||||
const updateColumns = columns.filter(col => col !== primaryKeyColumn);
|
||||
const updateSet = updateColumns.map(col => `${col} = EXCLUDED.${col}`).join(', ');
|
||||
|
||||
let query: string;
|
||||
if (updateSet) {
|
||||
// UPSERT: 중복 시 업데이트
|
||||
query = `INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${placeholders})
|
||||
ON CONFLICT (${primaryKeyColumn}) DO UPDATE SET ${updateSet}`;
|
||||
} else {
|
||||
// Primary Key만 있는 경우 중복 시 무시
|
||||
query = `INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${placeholders})
|
||||
ON CONFLICT (${primaryKeyColumn}) DO NOTHING`;
|
||||
}
|
||||
|
||||
await prisma.$executeRawUnsafe(query, ...values);
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
console.error(`레코드 UPSERT 실패:`, error);
|
||||
failedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[BatchService] 내부 DB 데이터 삽입 완료: 성공 ${successCount}개, 실패 ${failedCount}개`);
|
||||
return { successCount, failedCount };
|
||||
} else if (connectionType === 'external' && connectionId) {
|
||||
// 외부 DB에 데이터 삽입
|
||||
const result = await BatchExternalDbService.insertDataToTable(connectionId, tableName, data);
|
||||
if (result.success && result.data) {
|
||||
console.log(`[BatchService] 외부 DB 데이터 삽입 완료: 성공 ${result.data.successCount}개, 실패 ${result.data.failedCount}개`);
|
||||
return result.data;
|
||||
} else {
|
||||
console.error(`외부 DB 데이터 삽입 실패: ${result.message}`);
|
||||
return { successCount: 0, failedCount: data.length };
|
||||
}
|
||||
} else {
|
||||
throw new Error(`잘못된 연결 타입 또는 연결 ID: ${connectionType}, ${connectionId}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`테이블 데이터 삽입 오류 (${tableName}):`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 매핑 유효성 검사
|
||||
*/
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// 외부 DB 연결 서비스
|
||||
// 작성일: 2024-12-17
|
||||
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import prisma from "../config/database";
|
||||
import {
|
||||
ExternalDbConnection,
|
||||
ExternalDbConnectionFilter,
|
||||
@@ -9,9 +9,7 @@ import {
|
||||
TableInfo,
|
||||
} from "../types/externalDbTypes";
|
||||
import { PasswordEncryption } from "../utils/passwordEncryption";
|
||||
import { DbConnectionManager } from "./dbConnectionManager";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
import { DatabaseConnectorFactory } from "../database/DatabaseConnectorFactory";
|
||||
|
||||
export class ExternalDbConnectionService {
|
||||
/**
|
||||
@@ -81,6 +79,93 @@ export class ExternalDbConnectionService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DB 타입별로 그룹화된 외부 DB 연결 목록 조회
|
||||
*/
|
||||
static async getConnectionsGroupedByType(
|
||||
filter: ExternalDbConnectionFilter = {}
|
||||
): Promise<ApiResponse<Record<string, ExternalDbConnection[]>>> {
|
||||
try {
|
||||
// 기본 연결 목록 조회
|
||||
const connectionsResult = await this.getConnections(filter);
|
||||
|
||||
if (!connectionsResult.success || !connectionsResult.data) {
|
||||
return {
|
||||
success: false,
|
||||
message: "연결 목록 조회에 실패했습니다."
|
||||
};
|
||||
}
|
||||
|
||||
// DB 타입 카테고리 정보 조회
|
||||
const categories = await prisma.db_type_categories.findMany({
|
||||
where: { is_active: true },
|
||||
orderBy: [
|
||||
{ sort_order: 'asc' },
|
||||
{ display_name: 'asc' }
|
||||
]
|
||||
});
|
||||
|
||||
// DB 타입별로 그룹화
|
||||
const groupedConnections: Record<string, any> = {};
|
||||
|
||||
// 카테고리 정보를 포함한 그룹 초기화
|
||||
categories.forEach((category: any) => {
|
||||
groupedConnections[category.type_code] = {
|
||||
category: {
|
||||
type_code: category.type_code,
|
||||
display_name: category.display_name,
|
||||
icon: category.icon,
|
||||
color: category.color,
|
||||
sort_order: category.sort_order
|
||||
},
|
||||
connections: []
|
||||
};
|
||||
});
|
||||
|
||||
// 연결을 해당 타입 그룹에 배치
|
||||
connectionsResult.data.forEach(connection => {
|
||||
if (groupedConnections[connection.db_type]) {
|
||||
groupedConnections[connection.db_type].connections.push(connection);
|
||||
} else {
|
||||
// 카테고리에 없는 DB 타입인 경우 기타 그룹에 추가
|
||||
if (!groupedConnections['other']) {
|
||||
groupedConnections['other'] = {
|
||||
category: {
|
||||
type_code: 'other',
|
||||
display_name: '기타',
|
||||
icon: 'database',
|
||||
color: '#6B7280',
|
||||
sort_order: 999
|
||||
},
|
||||
connections: []
|
||||
};
|
||||
}
|
||||
groupedConnections['other'].connections.push(connection);
|
||||
}
|
||||
});
|
||||
|
||||
// 연결이 없는 빈 그룹 제거
|
||||
Object.keys(groupedConnections).forEach(key => {
|
||||
if (groupedConnections[key].connections.length === 0) {
|
||||
delete groupedConnections[key];
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: groupedConnections,
|
||||
message: `DB 타입별로 그룹화된 연결 목록을 조회했습니다.`
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("그룹화된 연결 목록 조회 실패:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "그룹화된 연결 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 외부 DB 연결 조회
|
||||
*/
|
||||
@@ -239,13 +324,40 @@ export class ExternalDbConnectionService {
|
||||
}
|
||||
}
|
||||
|
||||
// 비밀번호가 변경되는 경우, 연결 테스트 먼저 수행
|
||||
if (data.password && data.password !== "***ENCRYPTED***") {
|
||||
// 임시 연결 설정으로 테스트
|
||||
const testConfig = {
|
||||
host: data.host || existingConnection.host,
|
||||
port: data.port || existingConnection.port,
|
||||
database: data.database_name || existingConnection.database_name,
|
||||
user: data.username || existingConnection.username,
|
||||
password: data.password, // 새로 입력된 비밀번호로 테스트
|
||||
connectionTimeoutMillis: data.connection_timeout != null ? data.connection_timeout * 1000 : undefined,
|
||||
queryTimeoutMillis: data.query_timeout != null ? data.query_timeout * 1000 : undefined,
|
||||
ssl: (data.ssl_enabled || existingConnection.ssl_enabled) === "Y" ? { rejectUnauthorized: false } : false
|
||||
};
|
||||
|
||||
// 연결 테스트 수행
|
||||
const connector = await DatabaseConnectorFactory.createConnector(existingConnection.db_type, testConfig, id);
|
||||
const testResult = await connector.testConnection();
|
||||
|
||||
if (!testResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "새로운 연결 정보로 테스트에 실패했습니다. 수정할 수 없습니다.",
|
||||
error: testResult.error ? `${testResult.error.code}: ${testResult.error.details}` : undefined
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 업데이트 데이터 준비
|
||||
const updateData: any = {
|
||||
...data,
|
||||
updated_date: new Date(),
|
||||
};
|
||||
|
||||
// 비밀번호가 변경된 경우 암호화
|
||||
// 비밀번호가 변경된 경우 암호화 (연결 테스트 통과 후)
|
||||
if (data.password && data.password !== "***ENCRYPTED***") {
|
||||
updateData.password = PasswordEncryption.encrypt(data.password);
|
||||
} else {
|
||||
@@ -320,7 +432,8 @@ export class ExternalDbConnectionService {
|
||||
* 데이터베이스 연결 테스트 (ID 기반)
|
||||
*/
|
||||
static async testConnectionById(
|
||||
id: number
|
||||
id: number,
|
||||
testData?: { password?: string }
|
||||
): Promise<import("../types/externalDbTypes").ConnectionTestResult> {
|
||||
try {
|
||||
// 저장된 연결 정보 조회
|
||||
@@ -339,9 +452,17 @@ export class ExternalDbConnectionService {
|
||||
};
|
||||
}
|
||||
|
||||
// 비밀번호 복호화
|
||||
const decryptedPassword = await this.getDecryptedPassword(id);
|
||||
if (!decryptedPassword) {
|
||||
// 비밀번호 결정 (테스트용 비밀번호가 제공된 경우 그것을 사용, 아니면 저장된 비밀번호 복호화)
|
||||
let password: string | null;
|
||||
if (testData?.password) {
|
||||
password = testData.password;
|
||||
console.log(`🔍 [연결테스트] 새로 입력된 비밀번호 사용: ${password.substring(0, 3)}***`);
|
||||
} else {
|
||||
password = await this.getDecryptedPassword(id);
|
||||
console.log(`🔍 [연결테스트] 저장된 비밀번호 사용: ${password ? password.substring(0, 3) + '***' : 'null'}`);
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
return {
|
||||
success: false,
|
||||
message: "비밀번호 복호화에 실패했습니다.",
|
||||
@@ -358,14 +479,46 @@ export class ExternalDbConnectionService {
|
||||
port: connection.port,
|
||||
database: connection.database_name,
|
||||
user: connection.username,
|
||||
password: decryptedPassword,
|
||||
password: password,
|
||||
connectionTimeoutMillis: connection.connection_timeout != null ? connection.connection_timeout * 1000 : undefined,
|
||||
queryTimeoutMillis: connection.query_timeout != null ? connection.query_timeout * 1000 : undefined,
|
||||
ssl: connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false
|
||||
};
|
||||
|
||||
// DbConnectionManager를 통한 연결 테스트
|
||||
return await DbConnectionManager.testConnection(id, connection.db_type, config);
|
||||
// 연결 테스트용 임시 커넥터 생성 (캐시 사용하지 않음)
|
||||
let connector: any;
|
||||
switch (connection.db_type.toLowerCase()) {
|
||||
case 'postgresql':
|
||||
const { PostgreSQLConnector } = await import('../database/PostgreSQLConnector');
|
||||
connector = new PostgreSQLConnector(config);
|
||||
break;
|
||||
case 'oracle':
|
||||
const { OracleConnector } = await import('../database/OracleConnector');
|
||||
connector = new OracleConnector(config);
|
||||
break;
|
||||
case 'mariadb':
|
||||
case 'mysql':
|
||||
const { MariaDBConnector } = await import('../database/MariaDBConnector');
|
||||
connector = new MariaDBConnector(config);
|
||||
break;
|
||||
case 'mssql':
|
||||
const { MSSQLConnector } = await import('../database/MSSQLConnector');
|
||||
connector = new MSSQLConnector(config);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`지원하지 않는 데이터베이스 타입: ${connection.db_type}`);
|
||||
}
|
||||
|
||||
console.log(`🔍 [연결테스트] 새 커넥터로 DB 연결 시도 - Host: ${config.host}, DB: ${config.database}, User: ${config.user}`);
|
||||
|
||||
const testResult = await connector.testConnection();
|
||||
console.log(`🔍 [연결테스트] 결과 - Success: ${testResult.success}, Message: ${testResult.message}`);
|
||||
|
||||
return {
|
||||
success: testResult.success,
|
||||
message: testResult.message,
|
||||
details: testResult.details
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
@@ -416,7 +569,7 @@ export class ExternalDbConnectionService {
|
||||
}
|
||||
|
||||
// DB 타입 유효성 검사
|
||||
const validDbTypes = ["mysql", "postgresql", "oracle", "mssql", "sqlite"];
|
||||
const validDbTypes = ["mysql", "postgresql", "oracle", "mssql", "sqlite", "mariadb"];
|
||||
if (!validDbTypes.includes(data.db_type)) {
|
||||
throw new Error("지원하지 않는 DB 타입입니다.");
|
||||
}
|
||||
@@ -487,8 +640,9 @@ export class ExternalDbConnectionService {
|
||||
ssl: connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false
|
||||
};
|
||||
|
||||
// DbConnectionManager를 통한 쿼리 실행
|
||||
const result = await DbConnectionManager.executeQuery(id, connection.db_type, config, query);
|
||||
// DatabaseConnectorFactory를 통한 쿼리 실행
|
||||
const connector = await DatabaseConnectorFactory.createConnector(connection.db_type, config, id);
|
||||
const result = await connector.executeQuery(query);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -595,8 +749,9 @@ export class ExternalDbConnectionService {
|
||||
ssl: connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false
|
||||
};
|
||||
|
||||
// DbConnectionManager를 통한 테이블 목록 조회
|
||||
const tables = await DbConnectionManager.getTables(id, connection.db_type, config);
|
||||
// DatabaseConnectorFactory를 통한 테이블 목록 조회
|
||||
const connector = await DatabaseConnectorFactory.createConnector(connection.db_type, config, id);
|
||||
const tables = await connector.getTables();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -676,4 +831,58 @@ export class ExternalDbConnectionService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 테이블의 컬럼 정보 조회
|
||||
*/
|
||||
static async getTableColumns(connectionId: number, tableName: string): Promise<ApiResponse<any[]>> {
|
||||
let client: any = null;
|
||||
|
||||
try {
|
||||
const connection = await this.getConnectionById(connectionId);
|
||||
if (!connection.success || !connection.data) {
|
||||
return {
|
||||
success: false,
|
||||
message: "연결 정보를 찾을 수 없습니다."
|
||||
};
|
||||
}
|
||||
|
||||
const connectionData = connection.data;
|
||||
|
||||
// 비밀번호 복호화
|
||||
const decryptedPassword = PasswordEncryption.decrypt(connectionData.password);
|
||||
|
||||
// 연결 설정 준비
|
||||
const config = {
|
||||
host: connectionData.host,
|
||||
port: connectionData.port,
|
||||
database: connectionData.database_name,
|
||||
user: connectionData.username, // ConnectionConfig에서는 user 사용
|
||||
password: decryptedPassword,
|
||||
connectionTimeoutMillis: connectionData.connection_timeout != null ? connectionData.connection_timeout * 1000 : undefined,
|
||||
queryTimeoutMillis: connectionData.query_timeout != null ? connectionData.query_timeout * 1000 : undefined,
|
||||
ssl: connectionData.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false
|
||||
};
|
||||
|
||||
// 데이터베이스 타입에 따른 커넥터 생성
|
||||
const connector = await DatabaseConnectorFactory.createConnector(connectionData.db_type, config, connectionId);
|
||||
|
||||
// 컬럼 정보 조회
|
||||
const columns = await connector.getColumns(tableName);
|
||||
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: columns,
|
||||
message: "컬럼 정보를 조회했습니다."
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("컬럼 정보 조회 오류:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "컬럼 정보 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user