Merge pull request 'feature/batch-testing-updates' (#72) from feature/batch-testing-updates into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/72
This commit is contained in:
commit
153fb18288
|
|
@ -111,6 +111,10 @@ model batch_mappings {
|
|||
from_api_url String? @db.VarChar(500)
|
||||
from_api_key String? @db.VarChar(200)
|
||||
from_api_method String? @db.VarChar(10)
|
||||
from_api_param_type String? @db.VarChar(10) // 'url' 또는 'query'
|
||||
from_api_param_name String? @db.VarChar(100) // 파라미터명
|
||||
from_api_param_value String? @db.VarChar(500) // 파라미터 값 또는 템플릿
|
||||
from_api_param_source String? @db.VarChar(10) // 'static' 또는 'dynamic'
|
||||
to_connection_type String @db.VarChar(20)
|
||||
to_connection_id Int?
|
||||
to_table_name String @db.VarChar(100)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
|
||||
import { Request, Response } from "express";
|
||||
import { BatchService } from "../services/batchService";
|
||||
import { BatchSchedulerService } from "../services/batchSchedulerService";
|
||||
import { BatchConfigFilter, CreateBatchConfigRequest, UpdateBatchConfigRequest } from "../types/batchTypes";
|
||||
|
||||
export interface AuthenticatedRequest extends Request {
|
||||
|
|
@ -191,6 +192,11 @@ export class BatchController {
|
|||
mappings
|
||||
} as CreateBatchConfigRequest);
|
||||
|
||||
// 생성된 배치가 활성화 상태라면 스케줄러에 등록 (즉시 실행 비활성화)
|
||||
if (batchConfig.data && batchConfig.data.is_active === 'Y' && batchConfig.data.id) {
|
||||
await BatchSchedulerService.updateBatchSchedule(batchConfig.data.id, false);
|
||||
}
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: batchConfig,
|
||||
|
|
@ -236,6 +242,9 @@ export class BatchController {
|
|||
});
|
||||
}
|
||||
|
||||
// 스케줄러에서 배치 스케줄 업데이트 (즉시 실행 비활성화)
|
||||
await BatchSchedulerService.updateBatchSchedule(Number(id), false);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: batchConfig,
|
||||
|
|
|
|||
|
|
@ -224,18 +224,15 @@ export class BatchManagementController {
|
|||
}
|
||||
|
||||
const batchConfig = batchConfigResult.data as BatchConfig;
|
||||
|
||||
// 배치 실행 로직 (간단한 버전)
|
||||
const startTime = new Date();
|
||||
let totalRecords = 0;
|
||||
let successRecords = 0;
|
||||
let failedRecords = 0;
|
||||
|
||||
console.log(`배치 수동 실행 시작: ${batchConfig.batch_name} (ID: ${id})`);
|
||||
|
||||
let executionLog: any = null;
|
||||
|
||||
try {
|
||||
console.log(`배치 실행 시작: ${batchConfig.batch_name} (ID: ${id})`);
|
||||
|
||||
// 실행 로그 생성
|
||||
const executionLog = await BatchService.createExecutionLog({
|
||||
executionLog = await BatchService.createExecutionLog({
|
||||
batch_config_id: Number(id),
|
||||
execution_status: 'RUNNING',
|
||||
start_time: startTime,
|
||||
|
|
@ -244,199 +241,74 @@ export class BatchManagementController {
|
|||
failed_records: 0
|
||||
});
|
||||
|
||||
// 실제 배치 실행 (매핑이 있는 경우)
|
||||
if (batchConfig.batch_mappings && batchConfig.batch_mappings.length > 0) {
|
||||
// 테이블별로 매핑을 그룹화
|
||||
const tableGroups = new Map<string, typeof batchConfig.batch_mappings>();
|
||||
// BatchSchedulerService의 executeBatchConfig 메서드 사용 (중복 로직 제거)
|
||||
const { BatchSchedulerService } = await import('../services/batchSchedulerService');
|
||||
const result = await BatchSchedulerService.executeBatchConfig(batchConfig);
|
||||
|
||||
for (const mapping of batchConfig.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];
|
||||
console.log(`테이블 처리 시작: ${tableKey} -> ${mappings.length}개 컬럼 매핑`);
|
||||
|
||||
let fromData: any[] = [];
|
||||
|
||||
// FROM 데이터 조회 (DB 또는 REST API)
|
||||
if (firstMapping.from_connection_type === 'restapi') {
|
||||
// REST API에서 데이터 조회
|
||||
console.log(`REST API에서 데이터 조회: ${firstMapping.from_api_url}${firstMapping.from_table_name}`);
|
||||
console.log(`API 설정:`, {
|
||||
url: firstMapping.from_api_url,
|
||||
key: firstMapping.from_api_key ? '***' : 'null',
|
||||
method: firstMapping.from_api_method,
|
||||
endpoint: firstMapping.from_table_name
|
||||
});
|
||||
|
||||
try {
|
||||
const apiResult = await BatchExternalDbService.getDataFromRestApi(
|
||||
firstMapping.from_api_url!,
|
||||
firstMapping.from_api_key!,
|
||||
firstMapping.from_table_name,
|
||||
firstMapping.from_api_method as 'GET' | 'POST' | 'PUT' | 'DELETE' || 'GET',
|
||||
mappings.map(m => m.from_column_name)
|
||||
);
|
||||
|
||||
console.log(`API 조회 결과:`, {
|
||||
success: apiResult.success,
|
||||
dataCount: apiResult.data ? apiResult.data.length : 0,
|
||||
message: apiResult.message
|
||||
});
|
||||
|
||||
if (apiResult.success && apiResult.data) {
|
||||
fromData = apiResult.data;
|
||||
} else {
|
||||
throw new Error(`REST API 데이터 조회 실패: ${apiResult.message}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`REST API 조회 오류:`, error);
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
// DB에서 데이터 조회
|
||||
const fromColumns = mappings.map(m => m.from_column_name);
|
||||
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) {
|
||||
// DB → REST API 배치인지 확인
|
||||
if (firstMapping.to_connection_type === 'restapi' && mapping.to_api_body) {
|
||||
// DB → REST API: 원본 컬럼명을 키로 사용 (템플릿 처리용)
|
||||
mappedRow[mapping.from_column_name] = row[mapping.from_column_name];
|
||||
} else {
|
||||
// 기존 로직: to_column_name을 키로 사용
|
||||
mappedRow[mapping.to_column_name] = row[mapping.from_column_name];
|
||||
}
|
||||
}
|
||||
return mappedRow;
|
||||
});
|
||||
|
||||
// TO 테이블에 데이터 삽입 (DB 또는 REST API)
|
||||
let insertResult: { successCount: number; failedCount: number };
|
||||
|
||||
if (firstMapping.to_connection_type === 'restapi') {
|
||||
// REST API로 데이터 전송
|
||||
console.log(`REST API로 데이터 전송: ${firstMapping.to_api_url}${firstMapping.to_table_name}`);
|
||||
|
||||
// DB → REST API 배치인지 확인 (to_api_body가 있으면 템플릿 기반)
|
||||
const hasTemplate = mappings.some(m => m.to_api_body);
|
||||
|
||||
if (hasTemplate) {
|
||||
// 템플릿 기반 REST API 전송 (DB → REST API 배치)
|
||||
const templateBody = firstMapping.to_api_body || '{}';
|
||||
console.log(`템플릿 기반 REST API 전송, Request Body 템플릿: ${templateBody}`);
|
||||
|
||||
// URL 경로 컬럼 찾기 (PUT/DELETE용)
|
||||
const urlPathColumn = mappings.find(m => m.to_column_name === 'URL_PATH_PARAM')?.from_column_name;
|
||||
|
||||
const apiResult = await BatchExternalDbService.sendDataToRestApiWithTemplate(
|
||||
firstMapping.to_api_url!,
|
||||
firstMapping.to_api_key!,
|
||||
firstMapping.to_table_name,
|
||||
firstMapping.to_api_method as 'POST' | 'PUT' | 'DELETE' || 'POST',
|
||||
templateBody,
|
||||
mappedData,
|
||||
urlPathColumn
|
||||
);
|
||||
|
||||
if (apiResult.success && apiResult.data) {
|
||||
insertResult = apiResult.data;
|
||||
} else {
|
||||
throw new Error(`템플릿 기반 REST API 데이터 전송 실패: ${apiResult.message}`);
|
||||
}
|
||||
} else {
|
||||
// 기존 REST API 전송 (REST API → DB 배치)
|
||||
const apiResult = await BatchExternalDbService.sendDataToRestApi(
|
||||
firstMapping.to_api_url!,
|
||||
firstMapping.to_api_key!,
|
||||
firstMapping.to_table_name,
|
||||
firstMapping.to_api_method as 'POST' | 'PUT' || 'POST',
|
||||
mappedData
|
||||
);
|
||||
|
||||
if (apiResult.success && apiResult.data) {
|
||||
insertResult = apiResult.data;
|
||||
} else {
|
||||
throw new Error(`REST API 데이터 전송 실패: ${apiResult.message}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// DB에 데이터 삽입
|
||||
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;
|
||||
|
||||
console.log(`테이블 처리 완료: ${insertResult.successCount}개 성공, ${insertResult.failedCount}개 실패`);
|
||||
} catch (error) {
|
||||
console.error(`테이블 처리 실패: ${tableKey}`, error);
|
||||
failedRecords += 1;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log("매핑이 없어서 데이터 처리를 건너뜁니다.");
|
||||
// result가 undefined인 경우 처리
|
||||
if (!result) {
|
||||
throw new Error('배치 실행 결과를 받을 수 없습니다.');
|
||||
}
|
||||
|
||||
const endTime = new Date();
|
||||
const duration = endTime.getTime() - startTime.getTime();
|
||||
|
||||
// 실행 로그 업데이트 (성공)
|
||||
await BatchService.updateExecutionLog(executionLog.id, {
|
||||
execution_status: 'SUCCESS',
|
||||
end_time: new Date(),
|
||||
duration_ms: Date.now() - startTime.getTime(),
|
||||
total_records: totalRecords,
|
||||
success_records: successRecords,
|
||||
failed_records: failedRecords
|
||||
end_time: endTime,
|
||||
duration_ms: duration,
|
||||
total_records: result.totalRecords,
|
||||
success_records: result.successRecords,
|
||||
failed_records: result.failedRecords
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "배치가 성공적으로 실행되었습니다.",
|
||||
data: {
|
||||
batchId: id,
|
||||
totalRecords,
|
||||
successRecords,
|
||||
failedRecords,
|
||||
duration: Date.now() - startTime.getTime()
|
||||
}
|
||||
batchName: batchConfig.batch_name,
|
||||
totalRecords: result.totalRecords,
|
||||
successRecords: result.successRecords,
|
||||
failedRecords: result.failedRecords,
|
||||
executionTime: duration
|
||||
},
|
||||
message: "배치가 성공적으로 실행되었습니다."
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`배치 실행 실패: ${batchConfig.batch_name}`, error);
|
||||
|
||||
} catch (batchError) {
|
||||
console.error(`배치 실행 실패: ${batchConfig.batch_name}`, batchError);
|
||||
|
||||
// 실행 로그 업데이트 (실패) - executionLog가 생성되었을 경우에만
|
||||
try {
|
||||
const endTime = new Date();
|
||||
const duration = endTime.getTime() - startTime.getTime();
|
||||
|
||||
// executionLog가 정의되어 있는지 확인
|
||||
if (typeof executionLog !== 'undefined') {
|
||||
await BatchService.updateExecutionLog(executionLog.id, {
|
||||
execution_status: 'FAILED',
|
||||
end_time: endTime,
|
||||
duration_ms: duration,
|
||||
error_message: batchError instanceof Error ? batchError.message : "알 수 없는 오류"
|
||||
});
|
||||
}
|
||||
} catch (logError) {
|
||||
console.error('실행 로그 업데이트 실패:', logError);
|
||||
}
|
||||
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 실행에 실패했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
error: batchError instanceof Error ? batchError.message : "알 수 없는 오류"
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("배치 실행 오류:", error);
|
||||
console.error(`배치 실행 오류 (ID: ${req.params.id}):`, error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 실행 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
error: error instanceof Error ? error.message : "Unknown error"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -459,8 +331,8 @@ export class BatchManagementController {
|
|||
|
||||
const batchConfig = await BatchService.updateBatchConfig(Number(id), updateData);
|
||||
|
||||
// 스케줄러에서 배치 스케줄 업데이트
|
||||
await BatchSchedulerService.updateBatchSchedule(Number(id));
|
||||
// 스케줄러에서 배치 스케줄 업데이트 (즉시 실행 비활성화)
|
||||
await BatchSchedulerService.updateBatchSchedule(Number(id), false);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
|
|
@ -482,7 +354,16 @@ export class BatchManagementController {
|
|||
*/
|
||||
static async previewRestApiData(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { apiUrl, apiKey, endpoint, method = 'GET' } = req.body;
|
||||
const {
|
||||
apiUrl,
|
||||
apiKey,
|
||||
endpoint,
|
||||
method = 'GET',
|
||||
paramType,
|
||||
paramName,
|
||||
paramValue,
|
||||
paramSource
|
||||
} = req.body;
|
||||
|
||||
if (!apiUrl || !apiKey || !endpoint) {
|
||||
return res.status(400).json({
|
||||
|
|
@ -491,6 +372,15 @@ export class BatchManagementController {
|
|||
});
|
||||
}
|
||||
|
||||
console.log("🔍 REST API 미리보기 요청:", {
|
||||
apiUrl,
|
||||
endpoint,
|
||||
paramType,
|
||||
paramName,
|
||||
paramValue,
|
||||
paramSource
|
||||
});
|
||||
|
||||
// RestApiConnector 사용하여 데이터 조회
|
||||
const { RestApiConnector } = await import('../database/RestApiConnector');
|
||||
|
||||
|
|
@ -503,8 +393,28 @@ export class BatchManagementController {
|
|||
// 연결 테스트
|
||||
await connector.connect();
|
||||
|
||||
// 파라미터가 있는 경우 엔드포인트 수정
|
||||
let finalEndpoint = endpoint;
|
||||
if (paramType && paramName && paramValue) {
|
||||
if (paramType === 'url') {
|
||||
// URL 파라미터: /api/users/{userId} → /api/users/123
|
||||
if (endpoint.includes(`{${paramName}}`)) {
|
||||
finalEndpoint = endpoint.replace(`{${paramName}}`, paramValue);
|
||||
} else {
|
||||
// 엔드포인트에 {paramName}이 없으면 뒤에 추가
|
||||
finalEndpoint = `${endpoint}/${paramValue}`;
|
||||
}
|
||||
} else if (paramType === 'query') {
|
||||
// 쿼리 파라미터: /api/users?userId=123
|
||||
const separator = endpoint.includes('?') ? '&' : '?';
|
||||
finalEndpoint = `${endpoint}${separator}${paramName}=${paramValue}`;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("🔗 최종 엔드포인트:", finalEndpoint);
|
||||
|
||||
// 데이터 조회 (최대 5개만) - GET 메서드만 지원
|
||||
const result = await connector.executeQuery(endpoint, method);
|
||||
const result = await connector.executeQuery(finalEndpoint, method);
|
||||
console.log(`[previewRestApiData] executeQuery 결과:`, {
|
||||
rowCount: result.rowCount,
|
||||
rowsLength: result.rows ? result.rows.length : 'undefined',
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ export class RestApiConnector implements DatabaseConnector {
|
|||
timeout: config.timeout || 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': config.apiKey,
|
||||
'Authorization': `Bearer ${config.apiKey}`,
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -697,7 +697,12 @@ export class BatchExternalDbService {
|
|||
endpoint: string,
|
||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET',
|
||||
columns?: string[],
|
||||
limit: number = 100
|
||||
limit: number = 100,
|
||||
// 파라미터 정보 추가
|
||||
paramType?: 'url' | 'query',
|
||||
paramName?: string,
|
||||
paramValue?: string,
|
||||
paramSource?: 'static' | 'dynamic'
|
||||
): Promise<ApiResponse<any[]>> {
|
||||
try {
|
||||
console.log(`[BatchExternalDbService] REST API 데이터 조회: ${apiUrl}${endpoint}`);
|
||||
|
|
@ -712,8 +717,33 @@ export class BatchExternalDbService {
|
|||
// 연결 테스트
|
||||
await connector.connect();
|
||||
|
||||
// 파라미터가 있는 경우 엔드포인트 수정
|
||||
const { logger } = await import('../utils/logger');
|
||||
logger.info(`[BatchExternalDbService] 파라미터 정보`, {
|
||||
paramType, paramName, paramValue, paramSource
|
||||
});
|
||||
|
||||
let finalEndpoint = endpoint;
|
||||
if (paramType && paramName && paramValue) {
|
||||
if (paramType === 'url') {
|
||||
// URL 파라미터: /api/users/{userId} → /api/users/123
|
||||
if (endpoint.includes(`{${paramName}}`)) {
|
||||
finalEndpoint = endpoint.replace(`{${paramName}}`, paramValue);
|
||||
} else {
|
||||
// 엔드포인트에 {paramName}이 없으면 뒤에 추가
|
||||
finalEndpoint = `${endpoint}/${paramValue}`;
|
||||
}
|
||||
} else if (paramType === 'query') {
|
||||
// 쿼리 파라미터: /api/users?userId=123
|
||||
const separator = endpoint.includes('?') ? '&' : '?';
|
||||
finalEndpoint = `${endpoint}${separator}${paramName}=${paramValue}`;
|
||||
}
|
||||
|
||||
logger.info(`[BatchExternalDbService] 파라미터 적용된 엔드포인트: ${finalEndpoint}`);
|
||||
}
|
||||
|
||||
// 데이터 조회
|
||||
const result = await connector.executeQuery(endpoint, method);
|
||||
const result = await connector.executeQuery(finalEndpoint, method);
|
||||
let data = result.rows;
|
||||
|
||||
// 컬럼 필터링 (지정된 컬럼만 추출)
|
||||
|
|
@ -734,7 +764,8 @@ export class BatchExternalDbService {
|
|||
data = data.slice(0, limit);
|
||||
}
|
||||
|
||||
console.log(`[BatchExternalDbService] REST API 데이터 조회 완료: ${data.length}개 레코드`);
|
||||
logger.info(`[BatchExternalDbService] REST API 데이터 조회 완료: ${data.length}개 레코드`);
|
||||
logger.info(`[BatchExternalDbService] 조회된 데이터`, { data });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
|
|
|||
|
|
@ -10,19 +10,18 @@ import { logger } from '../utils/logger';
|
|||
export class BatchSchedulerService {
|
||||
private static scheduledTasks: Map<number, cron.ScheduledTask> = new Map();
|
||||
private static isInitialized = false;
|
||||
private static executingBatches: Set<number> = new Set(); // 실행 중인 배치 추적
|
||||
|
||||
/**
|
||||
* 스케줄러 초기화
|
||||
*/
|
||||
static async initialize() {
|
||||
if (this.isInitialized) {
|
||||
logger.info('배치 스케줄러가 이미 초기화되었습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info('배치 스케줄러 초기화 시작...');
|
||||
|
||||
// 기존 모든 스케줄 정리 (중복 방지)
|
||||
this.clearAllSchedules();
|
||||
|
||||
// 활성화된 배치 설정들을 로드하여 스케줄 등록
|
||||
await this.loadActiveBatchConfigs();
|
||||
|
||||
|
|
@ -34,6 +33,27 @@ export class BatchSchedulerService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 스케줄 정리
|
||||
*/
|
||||
private static clearAllSchedules() {
|
||||
logger.info(`기존 스케줄 ${this.scheduledTasks.size}개 정리 중...`);
|
||||
|
||||
for (const [id, task] of this.scheduledTasks) {
|
||||
try {
|
||||
task.stop();
|
||||
task.destroy();
|
||||
logger.info(`스케줄 정리 완료: ID ${id}`);
|
||||
} catch (error) {
|
||||
logger.error(`스케줄 정리 실패: ID ${id}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
this.scheduledTasks.clear();
|
||||
this.isInitialized = false;
|
||||
logger.info('모든 스케줄 정리 완료');
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성화된 배치 설정들을 로드하여 스케줄 등록
|
||||
*/
|
||||
|
|
@ -80,8 +100,23 @@ export class BatchSchedulerService {
|
|||
|
||||
// 새로운 스케줄 등록
|
||||
const task = cron.schedule(cron_schedule, async () => {
|
||||
// 중복 실행 방지 체크
|
||||
if (this.executingBatches.has(id)) {
|
||||
logger.warn(`⚠️ 배치가 이미 실행 중입니다. 건너뜀: ${batch_name} (ID: ${id})`);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`🔄 스케줄 배치 실행 시작: ${batch_name} (ID: ${id})`);
|
||||
await this.executeBatchConfig(config);
|
||||
|
||||
// 실행 중 플래그 설정
|
||||
this.executingBatches.add(id);
|
||||
|
||||
try {
|
||||
await this.executeBatchConfig(config);
|
||||
} finally {
|
||||
// 실행 완료 후 플래그 제거
|
||||
this.executingBatches.delete(id);
|
||||
}
|
||||
});
|
||||
|
||||
// 스케줄 시작 (기본적으로 시작되지만 명시적으로 호출)
|
||||
|
|
@ -112,7 +147,7 @@ export class BatchSchedulerService {
|
|||
/**
|
||||
* 배치 설정 업데이트 시 스케줄 재등록
|
||||
*/
|
||||
static async updateBatchSchedule(configId: number) {
|
||||
static async updateBatchSchedule(configId: number, executeImmediately: boolean = true) {
|
||||
try {
|
||||
// 기존 스케줄 제거
|
||||
await this.unscheduleBatchConfig(configId);
|
||||
|
|
@ -132,6 +167,12 @@ export class BatchSchedulerService {
|
|||
if (config.is_active === 'Y') {
|
||||
await this.scheduleBatchConfig(config);
|
||||
logger.info(`배치 스케줄 업데이트 완료: ${config.batch_name} (ID: ${configId})`);
|
||||
|
||||
// 활성화 시 즉시 실행 (옵션)
|
||||
if (executeImmediately) {
|
||||
logger.info(`🚀 배치 활성화 즉시 실행: ${config.batch_name} (ID: ${configId})`);
|
||||
await this.executeBatchConfig(config);
|
||||
}
|
||||
} else {
|
||||
logger.info(`비활성화된 배치 스케줄 제거: ${config.batch_name} (ID: ${configId})`);
|
||||
}
|
||||
|
|
@ -143,7 +184,7 @@ export class BatchSchedulerService {
|
|||
/**
|
||||
* 배치 설정 실행
|
||||
*/
|
||||
private static async executeBatchConfig(config: any) {
|
||||
static async executeBatchConfig(config: any) {
|
||||
const startTime = new Date();
|
||||
let executionLog: any = null;
|
||||
|
||||
|
|
@ -162,7 +203,11 @@ export class BatchSchedulerService {
|
|||
|
||||
if (!executionLogResponse.success || !executionLogResponse.data) {
|
||||
logger.error(`배치 실행 로그 생성 실패: ${config.batch_name}`, executionLogResponse.message);
|
||||
return;
|
||||
return {
|
||||
totalRecords: 0,
|
||||
successRecords: 0,
|
||||
failedRecords: 1
|
||||
};
|
||||
}
|
||||
|
||||
executionLog = executionLogResponse.data;
|
||||
|
|
@ -181,6 +226,10 @@ export class BatchSchedulerService {
|
|||
});
|
||||
|
||||
logger.info(`배치 실행 완료: ${config.batch_name} (처리된 레코드: ${result.totalRecords})`);
|
||||
|
||||
// 성공 결과 반환
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
logger.error(`배치 실행 실패: ${config.batch_name}`, error);
|
||||
|
||||
|
|
@ -194,6 +243,13 @@ export class BatchSchedulerService {
|
|||
error_details: error instanceof Error ? error.stack : String(error)
|
||||
});
|
||||
}
|
||||
|
||||
// 실패 시에도 결과 반환
|
||||
return {
|
||||
totalRecords: 0,
|
||||
successRecords: 0,
|
||||
failedRecords: 1
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -239,7 +295,13 @@ export class BatchSchedulerService {
|
|||
firstMapping.from_api_key!,
|
||||
firstMapping.from_table_name,
|
||||
firstMapping.from_api_method as 'GET' | 'POST' | 'PUT' | 'DELETE' || 'GET',
|
||||
mappings.map((m: any) => m.from_column_name)
|
||||
mappings.map((m: any) => m.from_column_name),
|
||||
100, // limit
|
||||
// 파라미터 정보 전달
|
||||
firstMapping.from_api_param_type,
|
||||
firstMapping.from_api_param_name,
|
||||
firstMapping.from_api_param_value,
|
||||
firstMapping.from_api_param_source
|
||||
);
|
||||
|
||||
if (apiResult.success && apiResult.data) {
|
||||
|
|
|
|||
|
|
@ -168,6 +168,10 @@ export class BatchService {
|
|||
from_api_url: mapping.from_api_url,
|
||||
from_api_key: mapping.from_api_key,
|
||||
from_api_method: mapping.from_api_method,
|
||||
from_api_param_type: mapping.from_api_param_type,
|
||||
from_api_param_name: mapping.from_api_param_name,
|
||||
from_api_param_value: mapping.from_api_param_value,
|
||||
from_api_param_source: mapping.from_api_param_source,
|
||||
to_connection_type: mapping.to_connection_type,
|
||||
to_connection_id: mapping.to_connection_id,
|
||||
to_table_name: mapping.to_table_name,
|
||||
|
|
@ -176,7 +180,7 @@ export class BatchService {
|
|||
to_api_url: mapping.to_api_url,
|
||||
to_api_key: mapping.to_api_key,
|
||||
to_api_method: mapping.to_api_method,
|
||||
// to_api_body: mapping.to_api_body, // Request Body 템플릿 추가 - 임시 주석 처리
|
||||
to_api_body: mapping.to_api_body,
|
||||
mapping_order: mapping.mapping_order || index + 1,
|
||||
created_by: userId,
|
||||
},
|
||||
|
|
@ -260,11 +264,22 @@ export class BatchService {
|
|||
from_table_name: mapping.from_table_name,
|
||||
from_column_name: mapping.from_column_name,
|
||||
from_column_type: mapping.from_column_type,
|
||||
from_api_url: mapping.from_api_url,
|
||||
from_api_key: mapping.from_api_key,
|
||||
from_api_method: mapping.from_api_method,
|
||||
from_api_param_type: mapping.from_api_param_type,
|
||||
from_api_param_name: mapping.from_api_param_name,
|
||||
from_api_param_value: mapping.from_api_param_value,
|
||||
from_api_param_source: mapping.from_api_param_source,
|
||||
to_connection_type: mapping.to_connection_type,
|
||||
to_connection_id: mapping.to_connection_id,
|
||||
to_table_name: mapping.to_table_name,
|
||||
to_column_name: mapping.to_column_name,
|
||||
to_column_type: mapping.to_column_type,
|
||||
to_api_url: mapping.to_api_url,
|
||||
to_api_key: mapping.to_api_key,
|
||||
to_api_method: mapping.to_api_method,
|
||||
to_api_body: mapping.to_api_body,
|
||||
mapping_order: mapping.mapping_order || index + 1,
|
||||
created_by: userId,
|
||||
},
|
||||
|
|
@ -707,18 +722,57 @@ export class BatchService {
|
|||
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`;
|
||||
}
|
||||
// 트랜잭션 내에서 처리하여 연결 관리 최적화
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
// 먼저 해당 레코드가 존재하는지 확인
|
||||
const checkQuery = `SELECT COUNT(*) as count FROM ${tableName} WHERE ${primaryKeyColumn} = $1`;
|
||||
const existsResult = await tx.$queryRawUnsafe(checkQuery, record[primaryKeyColumn]);
|
||||
const exists = (existsResult as any)[0]?.count > 0;
|
||||
|
||||
let operationResult = 'no_change';
|
||||
|
||||
if (exists && updateSet) {
|
||||
// 기존 레코드가 있으면 UPDATE (값이 다른 경우에만)
|
||||
const whereConditions = updateColumns.map((col, index) => {
|
||||
// 날짜/시간 컬럼에 대한 타입 캐스팅 처리
|
||||
if (col.toLowerCase().includes('date') ||
|
||||
col.toLowerCase().includes('time') ||
|
||||
col.toLowerCase().includes('created') ||
|
||||
col.toLowerCase().includes('updated') ||
|
||||
col.toLowerCase().includes('reg')) {
|
||||
return `${col} IS DISTINCT FROM $${index + 2}::timestamp`;
|
||||
}
|
||||
return `${col} IS DISTINCT FROM $${index + 2}`;
|
||||
}).join(' OR ');
|
||||
|
||||
const query = `UPDATE ${tableName} SET ${updateSet.replace(/EXCLUDED\./g, '')}
|
||||
WHERE ${primaryKeyColumn} = $1 AND (${whereConditions})`;
|
||||
|
||||
// 파라미터: [primaryKeyValue, ...updateValues]
|
||||
const updateValues = [record[primaryKeyColumn], ...updateColumns.map(col => record[col])];
|
||||
const updateResult = await tx.$executeRawUnsafe(query, ...updateValues);
|
||||
|
||||
if (updateResult > 0) {
|
||||
console.log(`[BatchService] 레코드 업데이트: ${primaryKeyColumn}=${record[primaryKeyColumn]}`);
|
||||
operationResult = 'updated';
|
||||
} else {
|
||||
console.log(`[BatchService] 레코드 변경사항 없음: ${primaryKeyColumn}=${record[primaryKeyColumn]}`);
|
||||
operationResult = 'no_change';
|
||||
}
|
||||
} else if (!exists) {
|
||||
// 새 레코드 삽입
|
||||
const query = `INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${placeholders})`;
|
||||
await tx.$executeRawUnsafe(query, ...values);
|
||||
console.log(`[BatchService] 새 레코드 삽입: ${primaryKeyColumn}=${record[primaryKeyColumn]}`);
|
||||
operationResult = 'inserted';
|
||||
} else {
|
||||
console.log(`[BatchService] 레코드 이미 존재 (변경사항 없음): ${primaryKeyColumn}=${record[primaryKeyColumn]}`);
|
||||
operationResult = 'no_change';
|
||||
}
|
||||
|
||||
return operationResult;
|
||||
});
|
||||
|
||||
await prisma.$executeRawUnsafe(query, ...values);
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
console.error(`레코드 UPSERT 실패:`, error);
|
||||
|
|
|
|||
|
|
@ -37,6 +37,10 @@ export interface BatchMapping {
|
|||
from_api_method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; // REST API 전용
|
||||
from_api_url?: string; // REST API 서버 URL
|
||||
from_api_key?: string; // REST API 키
|
||||
from_api_param_type?: 'url' | 'query'; // API 파라미터 타입
|
||||
from_api_param_name?: string; // API 파라미터명
|
||||
from_api_param_value?: string; // API 파라미터 값 또는 템플릿
|
||||
from_api_param_source?: 'static' | 'dynamic'; // 파라미터 소스 타입
|
||||
|
||||
// TO 정보
|
||||
to_connection_type: 'internal' | 'external' | 'restapi';
|
||||
|
|
@ -92,6 +96,10 @@ export interface BatchMappingRequest {
|
|||
from_api_url?: string;
|
||||
from_api_key?: string;
|
||||
from_api_method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||
from_api_param_type?: 'url' | 'query'; // API 파라미터 타입
|
||||
from_api_param_name?: string; // API 파라미터명
|
||||
from_api_param_value?: string; // API 파라미터 값 또는 템플릿
|
||||
from_api_param_source?: 'static' | 'dynamic'; // 파라미터 소스 타입
|
||||
to_connection_type: 'internal' | 'external' | 'restapi';
|
||||
to_connection_id?: number;
|
||||
to_table_name: string;
|
||||
|
|
|
|||
|
|
@ -54,6 +54,12 @@ export default function BatchManagementNewPage() {
|
|||
const [fromEndpoint, setFromEndpoint] = useState("");
|
||||
const [fromApiMethod, setFromApiMethod] = useState<'GET'>('GET'); // GET만 지원
|
||||
|
||||
// REST API 파라미터 설정
|
||||
const [apiParamType, setApiParamType] = useState<'none' | 'url' | 'query'>('none');
|
||||
const [apiParamName, setApiParamName] = useState(""); // 파라미터명 (예: userId, id)
|
||||
const [apiParamValue, setApiParamValue] = useState(""); // 파라미터 값 또는 템플릿
|
||||
const [apiParamSource, setApiParamSource] = useState<'static' | 'dynamic'>('static'); // 정적 값 또는 동적 값
|
||||
|
||||
// DB → REST API용 상태
|
||||
const [fromConnection, setFromConnection] = useState<BatchConnectionInfo | null>(null);
|
||||
const [fromTables, setFromTables] = useState<string[]>([]);
|
||||
|
|
@ -309,7 +315,14 @@ export default function BatchManagementNewPage() {
|
|||
fromApiUrl,
|
||||
fromApiKey,
|
||||
fromEndpoint,
|
||||
fromApiMethod
|
||||
fromApiMethod,
|
||||
// 파라미터 정보 추가
|
||||
apiParamType !== 'none' ? {
|
||||
paramType: apiParamType,
|
||||
paramName: apiParamName,
|
||||
paramValue: apiParamValue,
|
||||
paramSource: apiParamSource
|
||||
} : undefined
|
||||
);
|
||||
|
||||
console.log("API 미리보기 결과:", result);
|
||||
|
|
@ -371,6 +384,11 @@ export default function BatchManagementNewPage() {
|
|||
from_api_url: fromApiUrl,
|
||||
from_api_key: fromApiKey,
|
||||
from_api_method: fromApiMethod,
|
||||
// API 파라미터 정보 추가
|
||||
from_api_param_type: apiParamType !== 'none' ? apiParamType : undefined,
|
||||
from_api_param_name: apiParamType !== 'none' ? apiParamName : undefined,
|
||||
from_api_param_value: apiParamType !== 'none' ? apiParamValue : undefined,
|
||||
from_api_param_source: apiParamType !== 'none' ? apiParamSource : undefined,
|
||||
to_connection_type: toConnection?.type === 'internal' ? 'internal' : 'external',
|
||||
to_connection_id: toConnection?.type === 'internal' ? undefined : toConnection?.id,
|
||||
to_table_name: toTable,
|
||||
|
|
@ -661,14 +679,119 @@ export default function BatchManagementNewPage() {
|
|||
|
||||
</div>
|
||||
|
||||
{/* API 파라미터 설정 */}
|
||||
<div className="space-y-4">
|
||||
<div className="border-t pt-4">
|
||||
<Label className="text-base font-medium">API 파라미터 설정</Label>
|
||||
<p className="text-sm text-gray-600 mt-1">특정 사용자나 조건으로 데이터를 조회할 때 사용합니다.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>파라미터 타입</Label>
|
||||
<Select value={apiParamType} onValueChange={(value: any) => setApiParamType(value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">파라미터 없음</SelectItem>
|
||||
<SelectItem value="url">URL 파라미터 (/api/users/{`{userId}`})</SelectItem>
|
||||
<SelectItem value="query">쿼리 파라미터 (/api/users?userId=123)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{apiParamType !== 'none' && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="apiParamName">파라미터명 *</Label>
|
||||
<Input
|
||||
id="apiParamName"
|
||||
value={apiParamName}
|
||||
onChange={(e) => setApiParamName(e.target.value)}
|
||||
placeholder="userId, id, email 등"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>파라미터 소스</Label>
|
||||
<Select value={apiParamSource} onValueChange={(value: any) => setApiParamSource(value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="static">고정값</SelectItem>
|
||||
<SelectItem value="dynamic">동적값 (실행 시 결정)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="apiParamValue">
|
||||
{apiParamSource === 'static' ? '파라미터 값' : '파라미터 템플릿'} *
|
||||
</Label>
|
||||
<Input
|
||||
id="apiParamValue"
|
||||
value={apiParamValue}
|
||||
onChange={(e) => setApiParamValue(e.target.value)}
|
||||
placeholder={
|
||||
apiParamSource === 'static'
|
||||
? "123, john@example.com 등"
|
||||
: "{{user_id}}, {{email}} 등 (실행 시 치환됨)"
|
||||
}
|
||||
/>
|
||||
{apiParamSource === 'dynamic' && (
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
동적값은 배치 실행 시 설정된 값으로 치환됩니다. 예: {`{{user_id}}`} → 실제 사용자 ID
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{apiParamType === 'url' && (
|
||||
<div className="p-3 bg-blue-50 rounded-lg">
|
||||
<div className="text-sm font-medium text-blue-800">URL 파라미터 예시</div>
|
||||
<div className="text-sm text-blue-700 mt-1">
|
||||
엔드포인트: /api/users/{`{${apiParamName || 'userId'}}`}
|
||||
</div>
|
||||
<div className="text-sm text-blue-700">
|
||||
실제 호출: /api/users/{apiParamValue || '123'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{apiParamType === 'query' && (
|
||||
<div className="p-3 bg-green-50 rounded-lg">
|
||||
<div className="text-sm font-medium text-green-800">쿼리 파라미터 예시</div>
|
||||
<div className="text-sm text-green-700 mt-1">
|
||||
실제 호출: {fromEndpoint || '/api/users'}?{apiParamName || 'userId'}={apiParamValue || '123'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{fromApiUrl && fromApiKey && fromEndpoint && (
|
||||
<div className="space-y-3">
|
||||
<div className="p-3 bg-gray-50 rounded-lg">
|
||||
<div className="text-sm font-medium text-gray-700">API 호출 미리보기</div>
|
||||
<div className="text-sm text-gray-600 mt-1">
|
||||
{fromApiMethod} {fromApiUrl}{fromEndpoint}
|
||||
{fromApiMethod} {fromApiUrl}
|
||||
{apiParamType === 'url' && apiParamName && apiParamValue
|
||||
? fromEndpoint.replace(`{${apiParamName}}`, apiParamValue) || fromEndpoint + `/${apiParamValue}`
|
||||
: fromEndpoint
|
||||
}
|
||||
{apiParamType === 'query' && apiParamName && apiParamValue
|
||||
? `?${apiParamName}=${apiParamValue}`
|
||||
: ''
|
||||
}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">Headers: X-API-Key: {fromApiKey.substring(0, 10)}...</div>
|
||||
{apiParamType !== 'none' && apiParamName && apiParamValue && (
|
||||
<div className="text-xs text-blue-600 mt-1">
|
||||
파라미터: {apiParamName} = {apiParamValue} ({apiParamSource === 'static' ? '고정값' : '동적값'})
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button onClick={previewRestApiData} variant="outline" className="w-full">
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ export default function BatchEditPage() {
|
|||
// 배치 타입 감지
|
||||
const [batchType, setBatchType] = useState<'db-to-db' | 'restapi-to-db' | 'db-to-restapi' | null>(null);
|
||||
|
||||
|
||||
// 페이지 로드 시 배치 정보 조회
|
||||
useEffect(() => {
|
||||
if (batchId) {
|
||||
|
|
@ -342,9 +343,11 @@ export default function BatchEditPage() {
|
|||
|
||||
// 매핑 업데이트
|
||||
const updateMapping = (index: number, field: keyof BatchMapping, value: any) => {
|
||||
const updatedMappings = [...mappings];
|
||||
updatedMappings[index] = { ...updatedMappings[index], [field]: value };
|
||||
setMappings(updatedMappings);
|
||||
setMappings(prevMappings => {
|
||||
const updatedMappings = [...prevMappings];
|
||||
updatedMappings[index] = { ...updatedMappings[index], [field]: value };
|
||||
return updatedMappings;
|
||||
});
|
||||
};
|
||||
|
||||
// 배치 설정 저장
|
||||
|
|
|
|||
|
|
@ -120,23 +120,39 @@ class BatchManagementAPIClass {
|
|||
apiUrl: string,
|
||||
apiKey: string,
|
||||
endpoint: string,
|
||||
method: 'GET' = 'GET'
|
||||
method: 'GET' = 'GET',
|
||||
paramInfo?: {
|
||||
paramType: 'url' | 'query';
|
||||
paramName: string;
|
||||
paramValue: string;
|
||||
paramSource: 'static' | 'dynamic';
|
||||
}
|
||||
): Promise<{
|
||||
fields: string[];
|
||||
samples: any[];
|
||||
totalCount: number;
|
||||
}> {
|
||||
try {
|
||||
const response = await apiClient.post<BatchApiResponse<{
|
||||
fields: string[];
|
||||
samples: any[];
|
||||
totalCount: number;
|
||||
}>>(`${this.BASE_PATH}/rest-api/preview`, {
|
||||
const requestData: any = {
|
||||
apiUrl,
|
||||
apiKey,
|
||||
endpoint,
|
||||
method
|
||||
});
|
||||
};
|
||||
|
||||
// 파라미터 정보가 있으면 추가
|
||||
if (paramInfo) {
|
||||
requestData.paramType = paramInfo.paramType;
|
||||
requestData.paramName = paramInfo.paramName;
|
||||
requestData.paramValue = paramInfo.paramValue;
|
||||
requestData.paramSource = paramInfo.paramSource;
|
||||
}
|
||||
|
||||
const response = await apiClient.post<BatchApiResponse<{
|
||||
fields: string[];
|
||||
samples: any[];
|
||||
totalCount: number;
|
||||
}>>(`${this.BASE_PATH}/rest-api/preview`, requestData);
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.message || "REST API 미리보기에 실패했습니다.");
|
||||
|
|
|
|||
Loading…
Reference in New Issue