배치관리시스템 (DB, RestAPI)
This commit is contained in:
parent
5921a84581
commit
3333429928
|
|
@ -102,11 +102,18 @@ model batch_mappings {
|
|||
from_table_name String @db.VarChar(100)
|
||||
from_column_name String @db.VarChar(100)
|
||||
from_column_type String? @db.VarChar(50)
|
||||
from_api_url String? @db.VarChar(500)
|
||||
from_api_key String? @db.VarChar(200)
|
||||
from_api_method String? @db.VarChar(10)
|
||||
to_connection_type String @db.VarChar(20)
|
||||
to_connection_id Int?
|
||||
to_table_name String @db.VarChar(100)
|
||||
to_column_name String @db.VarChar(100)
|
||||
to_column_type String? @db.VarChar(50)
|
||||
to_api_url String? @db.VarChar(500)
|
||||
to_api_key String? @db.VarChar(200)
|
||||
to_api_method String? @db.VarChar(10)
|
||||
to_api_body String? @db.Text
|
||||
mapping_order Int? @default(1)
|
||||
created_date DateTime? @default(now()) @db.Timestamp(6)
|
||||
created_by String? @db.VarChar(50)
|
||||
|
|
@ -117,6 +124,8 @@ model batch_mappings {
|
|||
@@index([batch_config_id], map: "idx_batch_mappings_config")
|
||||
@@index([from_connection_type, from_connection_id], map: "idx_batch_mappings_from")
|
||||
@@index([to_connection_type, to_connection_id], map: "idx_batch_mappings_to")
|
||||
@@index([from_connection_type, from_api_url], map: "idx_batch_mappings_from_api")
|
||||
@@index([to_connection_type, to_api_url], map: "idx_batch_mappings_to_api")
|
||||
}
|
||||
|
||||
model batch_execution_logs {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { AuthenticatedRequest } from "../types/auth";
|
|||
import { BatchManagementService, BatchConnectionInfo, BatchTableInfo, BatchColumnInfo } from "../services/batchManagementService";
|
||||
import { BatchService } from "../services/batchService";
|
||||
import { BatchSchedulerService } from "../services/batchSchedulerService";
|
||||
import { BatchExternalDbService } from "../services/batchExternalDbService";
|
||||
import { CreateBatchConfigRequest, BatchConfig } from "../types/batchTypes";
|
||||
|
||||
export class BatchManagementController {
|
||||
|
|
@ -132,6 +133,40 @@ export class BatchManagementController {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 배치 설정 조회
|
||||
* GET /api/batch-management/batch-configs/:id
|
||||
*/
|
||||
static async getBatchConfigById(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
console.log("🔍 배치 설정 조회 요청:", id);
|
||||
|
||||
const result = await BatchService.getBatchConfigById(Number(id));
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: result.message || "배치 설정을 찾을 수 없습니다."
|
||||
});
|
||||
}
|
||||
|
||||
console.log("📋 조회된 배치 설정:", result.data);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.data
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("❌ 배치 설정 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 설정 조회에 실패했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 설정 목록 조회
|
||||
* GET /api/batch-management/batch-configs
|
||||
|
|
@ -228,32 +263,131 @@ export class BatchManagementController {
|
|||
const firstMapping = mappings[0];
|
||||
console.log(`테이블 처리 시작: ${tableKey} -> ${mappings.length}개 컬럼 매핑`);
|
||||
|
||||
// FROM 테이블에서 매핑된 컬럼들만 조회
|
||||
const fromColumns = mappings.map(m => 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
|
||||
);
|
||||
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) {
|
||||
mappedRow[mapping.to_column_name] = row[mapping.from_column_name];
|
||||
// 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 테이블에 데이터 삽입
|
||||
const insertResult = await BatchService.insertDataToTable(
|
||||
firstMapping.to_table_name,
|
||||
mappedData,
|
||||
firstMapping.to_connection_type as 'internal' | 'external',
|
||||
firstMapping.to_connection_id || undefined
|
||||
);
|
||||
// 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;
|
||||
|
||||
|
|
@ -342,4 +476,144 @@ export class BatchManagementController {
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* REST API 데이터 미리보기
|
||||
*/
|
||||
static async previewRestApiData(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { apiUrl, apiKey, endpoint, method = 'GET' } = req.body;
|
||||
|
||||
if (!apiUrl || !apiKey || !endpoint) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "API URL, API Key, 엔드포인트는 필수입니다."
|
||||
});
|
||||
}
|
||||
|
||||
// RestApiConnector 사용하여 데이터 조회
|
||||
const { RestApiConnector } = await import('../database/RestApiConnector');
|
||||
|
||||
const connector = new RestApiConnector({
|
||||
baseUrl: apiUrl,
|
||||
apiKey: apiKey,
|
||||
timeout: 30000
|
||||
});
|
||||
|
||||
// 연결 테스트
|
||||
await connector.connect();
|
||||
|
||||
// 데이터 조회 (최대 5개만) - GET 메서드만 지원
|
||||
const result = await connector.executeQuery(endpoint, method);
|
||||
console.log(`[previewRestApiData] executeQuery 결과:`, {
|
||||
rowCount: result.rowCount,
|
||||
rowsLength: result.rows ? result.rows.length : 'undefined',
|
||||
firstRow: result.rows && result.rows.length > 0 ? result.rows[0] : 'no data'
|
||||
});
|
||||
|
||||
const data = result.rows.slice(0, 5); // 최대 5개 샘플만
|
||||
console.log(`[previewRestApiData] 슬라이스된 데이터:`, data);
|
||||
|
||||
if (data.length > 0) {
|
||||
// 첫 번째 객체에서 필드명 추출
|
||||
const fields = Object.keys(data[0]);
|
||||
console.log(`[previewRestApiData] 추출된 필드:`, fields);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
fields: fields,
|
||||
samples: data,
|
||||
totalCount: result.rowCount || data.length
|
||||
},
|
||||
message: `${fields.length}개 필드, ${result.rowCount || data.length}개 레코드를 조회했습니다.`
|
||||
});
|
||||
} else {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
fields: [],
|
||||
samples: [],
|
||||
totalCount: 0
|
||||
},
|
||||
message: "API에서 데이터를 가져올 수 없습니다."
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("REST API 미리보기 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "REST API 데이터 미리보기 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* REST API 배치 설정 저장
|
||||
*/
|
||||
static async saveRestApiBatch(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const {
|
||||
batchName,
|
||||
batchType,
|
||||
cronSchedule,
|
||||
description,
|
||||
apiMappings
|
||||
} = req.body;
|
||||
|
||||
if (!batchName || !batchType || !cronSchedule || !apiMappings || apiMappings.length === 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 필드가 누락되었습니다."
|
||||
});
|
||||
}
|
||||
|
||||
console.log("REST API 배치 저장 요청:", {
|
||||
batchName,
|
||||
batchType,
|
||||
cronSchedule,
|
||||
description,
|
||||
apiMappings
|
||||
});
|
||||
|
||||
// BatchService를 사용하여 배치 설정 저장
|
||||
const batchConfig: CreateBatchConfigRequest = {
|
||||
batchName: batchName,
|
||||
description: description || '',
|
||||
cronSchedule: cronSchedule,
|
||||
mappings: apiMappings
|
||||
};
|
||||
|
||||
const result = await BatchService.createBatchConfig(batchConfig);
|
||||
|
||||
if (result.success && result.data) {
|
||||
// 스케줄러에 자동 등록 ✅
|
||||
try {
|
||||
await BatchSchedulerService.scheduleBatchConfig(result.data);
|
||||
console.log(`✅ 새로운 배치가 스케줄러에 등록되었습니다: ${batchName} (ID: ${result.data.id})`);
|
||||
} catch (schedulerError) {
|
||||
console.error(`❌ 스케줄러 등록 실패: ${batchName}`, schedulerError);
|
||||
// 스케줄러 등록 실패해도 배치 저장은 성공으로 처리
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "REST API 배치가 성공적으로 저장되었습니다.",
|
||||
data: result.data
|
||||
});
|
||||
} else {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: result.message || "배치 저장에 실패했습니다."
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("REST API 배치 저장 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 저장 중 오류가 발생했습니다."
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { DatabaseConnector, ConnectionConfig } from '../interfaces/DatabaseConne
|
|||
import { PostgreSQLConnector } from './PostgreSQLConnector';
|
||||
import { OracleConnector } from './OracleConnector';
|
||||
import { MariaDBConnector } from './MariaDBConnector';
|
||||
import { RestApiConnector, RestApiConfig } from './RestApiConnector';
|
||||
|
||||
export class DatabaseConnectorFactory {
|
||||
private static connectors = new Map<string, DatabaseConnector>();
|
||||
|
|
@ -28,6 +29,9 @@ export class DatabaseConnectorFactory {
|
|||
case 'mariadb':
|
||||
connector = new MariaDBConnector(config);
|
||||
break;
|
||||
case 'restapi':
|
||||
connector = new RestApiConnector(config as RestApiConfig);
|
||||
break;
|
||||
// Add other database types here
|
||||
default:
|
||||
throw new Error(`지원하지 않는 데이터베이스 타입: ${type}`);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,261 @@
|
|||
import axios, { AxiosInstance, AxiosResponse } from 'axios';
|
||||
import { DatabaseConnector, ConnectionConfig, QueryResult } from '../interfaces/DatabaseConnector';
|
||||
import { ConnectionTestResult, TableInfo } from '../types/externalDbTypes';
|
||||
|
||||
export interface RestApiConfig {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
timeout?: number;
|
||||
// ConnectionConfig 호환성을 위한 더미 필드들 (사용하지 않음)
|
||||
host?: string;
|
||||
port?: number;
|
||||
database?: string;
|
||||
user?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export class RestApiConnector implements DatabaseConnector {
|
||||
private httpClient: AxiosInstance;
|
||||
private config: RestApiConfig;
|
||||
|
||||
constructor(config: RestApiConfig) {
|
||||
this.config = config;
|
||||
|
||||
// Axios 인스턴스 생성
|
||||
this.httpClient = axios.create({
|
||||
baseURL: config.baseUrl,
|
||||
timeout: config.timeout || 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': config.apiKey,
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
// 요청/응답 인터셉터 설정
|
||||
this.setupInterceptors();
|
||||
}
|
||||
|
||||
private setupInterceptors() {
|
||||
// 요청 인터셉터
|
||||
this.httpClient.interceptors.request.use(
|
||||
(config) => {
|
||||
console.log(`[RestApiConnector] 요청: ${config.method?.toUpperCase()} ${config.url}`);
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
console.error('[RestApiConnector] 요청 오류:', error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// 응답 인터셉터
|
||||
this.httpClient.interceptors.response.use(
|
||||
(response) => {
|
||||
console.log(`[RestApiConnector] 응답: ${response.status} ${response.statusText}`);
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
console.error('[RestApiConnector] 응답 오류:', error.response?.status, error.response?.statusText);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
try {
|
||||
// 연결 테스트 - 기본 엔드포인트 호출
|
||||
await this.httpClient.get('/health', { timeout: 5000 });
|
||||
console.log(`[RestApiConnector] 연결 성공: ${this.config.baseUrl}`);
|
||||
} catch (error) {
|
||||
// health 엔드포인트가 없을 수 있으므로 404는 정상으로 처리
|
||||
if (axios.isAxiosError(error) && error.response?.status === 404) {
|
||||
console.log(`[RestApiConnector] 연결 성공 (health 엔드포인트 없음): ${this.config.baseUrl}`);
|
||||
return;
|
||||
}
|
||||
console.error(`[RestApiConnector] 연결 실패: ${this.config.baseUrl}`, error);
|
||||
throw new Error(`REST API 연결 실패: ${error instanceof Error ? error.message : '알 수 없는 오류'}`);
|
||||
}
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
// REST API는 연결 해제가 필요 없음
|
||||
console.log(`[RestApiConnector] 연결 해제: ${this.config.baseUrl}`);
|
||||
}
|
||||
|
||||
async testConnection(): Promise<ConnectionTestResult> {
|
||||
try {
|
||||
await this.connect();
|
||||
return {
|
||||
success: true,
|
||||
message: 'REST API 연결이 성공했습니다.',
|
||||
details: {
|
||||
response_time: Date.now()
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'REST API 연결에 실패했습니다.',
|
||||
details: {
|
||||
response_time: Date.now()
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async executeQuery(endpoint: string, method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET', data?: any): Promise<QueryResult> {
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
let response: AxiosResponse;
|
||||
|
||||
// HTTP 메서드에 따른 요청 실행
|
||||
switch (method.toUpperCase()) {
|
||||
case 'GET':
|
||||
response = await this.httpClient.get(endpoint);
|
||||
break;
|
||||
case 'POST':
|
||||
response = await this.httpClient.post(endpoint, data);
|
||||
break;
|
||||
case 'PUT':
|
||||
response = await this.httpClient.put(endpoint, data);
|
||||
break;
|
||||
case 'DELETE':
|
||||
response = await this.httpClient.delete(endpoint);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`지원하지 않는 HTTP 메서드: ${method}`);
|
||||
}
|
||||
|
||||
const executionTime = Date.now() - startTime;
|
||||
const responseData = response.data;
|
||||
|
||||
console.log(`[RestApiConnector] 원본 응답 데이터:`, {
|
||||
type: typeof responseData,
|
||||
isArray: Array.isArray(responseData),
|
||||
keys: typeof responseData === 'object' ? Object.keys(responseData) : 'not object',
|
||||
responseData: responseData
|
||||
});
|
||||
|
||||
// 응답 데이터 처리
|
||||
let rows: any[];
|
||||
if (Array.isArray(responseData)) {
|
||||
rows = responseData;
|
||||
} else if (responseData && responseData.data && Array.isArray(responseData.data)) {
|
||||
// API 응답이 {success: true, data: [...]} 형태인 경우
|
||||
rows = responseData.data;
|
||||
} else if (responseData && responseData.data && typeof responseData.data === 'object') {
|
||||
// API 응답이 {success: true, data: {...}} 형태인 경우 (단일 객체)
|
||||
rows = [responseData.data];
|
||||
} else if (responseData && typeof responseData === 'object' && !Array.isArray(responseData)) {
|
||||
// 단일 객체 응답인 경우
|
||||
rows = [responseData];
|
||||
} else {
|
||||
rows = [];
|
||||
}
|
||||
|
||||
console.log(`[RestApiConnector] 처리된 rows:`, {
|
||||
rowsLength: rows.length,
|
||||
firstRow: rows.length > 0 ? rows[0] : 'no data',
|
||||
allRows: rows
|
||||
});
|
||||
|
||||
console.log(`[RestApiConnector] API 호출 결과:`, {
|
||||
endpoint,
|
||||
method,
|
||||
status: response.status,
|
||||
rowCount: rows.length,
|
||||
executionTime: `${executionTime}ms`
|
||||
});
|
||||
|
||||
return {
|
||||
rows: rows,
|
||||
rowCount: rows.length,
|
||||
fields: rows.length > 0 ? Object.keys(rows[0]).map(key => ({ name: key, type: 'string' })) : []
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`[RestApiConnector] API 호출 오류 (${method} ${endpoint}):`, error);
|
||||
|
||||
if (axios.isAxiosError(error)) {
|
||||
throw new Error(`REST API 호출 실패: ${error.response?.status} ${error.response?.statusText}`);
|
||||
}
|
||||
|
||||
throw new Error(`REST API 호출 실패: ${error instanceof Error ? error.message : '알 수 없는 오류'}`);
|
||||
}
|
||||
}
|
||||
|
||||
async getTables(): Promise<TableInfo[]> {
|
||||
// REST API의 경우 "테이블"은 사용 가능한 엔드포인트를 의미
|
||||
// 일반적인 REST API 엔드포인트들을 반환
|
||||
return [
|
||||
{
|
||||
table_name: '/api/users',
|
||||
columns: [],
|
||||
description: '사용자 정보 API'
|
||||
},
|
||||
{
|
||||
table_name: '/api/data',
|
||||
columns: [],
|
||||
description: '기본 데이터 API'
|
||||
},
|
||||
{
|
||||
table_name: '/api/custom',
|
||||
columns: [],
|
||||
description: '사용자 정의 엔드포인트'
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
async getTableList(): Promise<TableInfo[]> {
|
||||
return this.getTables();
|
||||
}
|
||||
|
||||
async getColumns(endpoint: string): Promise<any[]> {
|
||||
try {
|
||||
// GET 요청으로 샘플 데이터를 가져와서 필드 구조 파악
|
||||
const result = await this.executeQuery(endpoint, 'GET');
|
||||
|
||||
if (result.rows.length > 0) {
|
||||
const sampleRow = result.rows[0];
|
||||
return Object.keys(sampleRow).map(key => ({
|
||||
column_name: key,
|
||||
data_type: typeof sampleRow[key],
|
||||
is_nullable: 'YES',
|
||||
column_default: null,
|
||||
description: `${key} 필드`
|
||||
}));
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error(`[RestApiConnector] 컬럼 정보 조회 오류 (${endpoint}):`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getTableColumns(endpoint: string): Promise<any[]> {
|
||||
return this.getColumns(endpoint);
|
||||
}
|
||||
|
||||
// REST API 전용 메서드들
|
||||
async getData(endpoint: string, params?: Record<string, any>): Promise<any[]> {
|
||||
const queryString = params ? '?' + new URLSearchParams(params).toString() : '';
|
||||
const result = await this.executeQuery(endpoint + queryString, 'GET');
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
async postData(endpoint: string, data: any): Promise<any> {
|
||||
const result = await this.executeQuery(endpoint, 'POST', data);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
async putData(endpoint: string, data: any): Promise<any> {
|
||||
const result = await this.executeQuery(endpoint, 'PUT', data);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
async deleteData(endpoint: string): Promise<any> {
|
||||
const result = await this.executeQuery(endpoint, 'DELETE');
|
||||
return result.rows[0];
|
||||
}
|
||||
}
|
||||
|
|
@ -11,7 +11,7 @@ const router = Router();
|
|||
* GET /api/batch-management/connections
|
||||
* 사용 가능한 커넥션 목록 조회
|
||||
*/
|
||||
router.get("/connections", BatchManagementController.getAvailableConnections);
|
||||
router.get("/connections", authenticateToken, BatchManagementController.getAvailableConnections);
|
||||
|
||||
/**
|
||||
* GET /api/batch-management/connections/:type/tables
|
||||
|
|
@ -49,6 +49,12 @@ router.post("/batch-configs", authenticateToken, BatchManagementController.creat
|
|||
*/
|
||||
router.get("/batch-configs", authenticateToken, BatchManagementController.getBatchConfigs);
|
||||
|
||||
/**
|
||||
* GET /api/batch-management/batch-configs/:id
|
||||
* 특정 배치 설정 조회
|
||||
*/
|
||||
router.get("/batch-configs/:id", authenticateToken, BatchManagementController.getBatchConfigById);
|
||||
|
||||
/**
|
||||
* PUT /api/batch-management/batch-configs/:id
|
||||
* 배치 설정 업데이트
|
||||
|
|
@ -61,4 +67,16 @@ router.put("/batch-configs/:id", authenticateToken, BatchManagementController.up
|
|||
*/
|
||||
router.post("/batch-configs/:id/execute", authenticateToken, BatchManagementController.executeBatchConfig);
|
||||
|
||||
/**
|
||||
* POST /api/batch-management/rest-api/preview
|
||||
* REST API 데이터 미리보기
|
||||
*/
|
||||
router.post("/rest-api/preview", authenticateToken, BatchManagementController.previewRestApiData);
|
||||
|
||||
/**
|
||||
* POST /api/batch-management/rest-api/save
|
||||
* REST API 배치 저장
|
||||
*/
|
||||
router.post("/rest-api/save", authenticateToken, BatchManagementController.saveRestApiBatch);
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
import prisma from "../config/database";
|
||||
import { PasswordEncryption } from "../utils/passwordEncryption";
|
||||
import { DatabaseConnectorFactory } from "../database/DatabaseConnectorFactory";
|
||||
import { RestApiConnector } from "../database/RestApiConnector";
|
||||
import { ApiResponse, ColumnInfo, TableInfo } from "../types/batchTypes";
|
||||
|
||||
export class BatchExternalDbService {
|
||||
|
|
@ -686,4 +687,226 @@ export class BatchExternalDbService {
|
|||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* REST API에서 데이터 조회
|
||||
*/
|
||||
static async getDataFromRestApi(
|
||||
apiUrl: string,
|
||||
apiKey: string,
|
||||
endpoint: string,
|
||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET',
|
||||
columns?: string[],
|
||||
limit: number = 100
|
||||
): Promise<ApiResponse<any[]>> {
|
||||
try {
|
||||
console.log(`[BatchExternalDbService] REST API 데이터 조회: ${apiUrl}${endpoint}`);
|
||||
|
||||
// REST API 커넥터 생성
|
||||
const connector = new RestApiConnector({
|
||||
baseUrl: apiUrl,
|
||||
apiKey: apiKey,
|
||||
timeout: 30000
|
||||
});
|
||||
|
||||
// 연결 테스트
|
||||
await connector.connect();
|
||||
|
||||
// 데이터 조회
|
||||
const result = await connector.executeQuery(endpoint, method);
|
||||
let data = result.rows;
|
||||
|
||||
// 컬럼 필터링 (지정된 컬럼만 추출)
|
||||
if (columns && columns.length > 0) {
|
||||
data = data.map(row => {
|
||||
const filteredRow: any = {};
|
||||
columns.forEach(col => {
|
||||
if (row.hasOwnProperty(col)) {
|
||||
filteredRow[col] = row[col];
|
||||
}
|
||||
});
|
||||
return filteredRow;
|
||||
});
|
||||
}
|
||||
|
||||
// 제한 개수 적용
|
||||
if (limit > 0) {
|
||||
data = data.slice(0, limit);
|
||||
}
|
||||
|
||||
console.log(`[BatchExternalDbService] REST API 데이터 조회 완료: ${data.length}개 레코드`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: data
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`[BatchExternalDbService] REST API 데이터 조회 오류 (${apiUrl}${endpoint}):`, error);
|
||||
return {
|
||||
success: false,
|
||||
message: "REST API 데이터 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿 기반 REST API로 데이터 전송 (DB → REST API 배치용)
|
||||
*/
|
||||
static async sendDataToRestApiWithTemplate(
|
||||
apiUrl: string,
|
||||
apiKey: string,
|
||||
endpoint: string,
|
||||
method: 'POST' | 'PUT' | 'DELETE' = 'POST',
|
||||
templateBody: string,
|
||||
data: any[],
|
||||
urlPathColumn?: string // URL 경로에 사용할 컬럼명 (PUT/DELETE용)
|
||||
): Promise<ApiResponse<{ successCount: number; failedCount: number }>> {
|
||||
try {
|
||||
console.log(`[BatchExternalDbService] 템플릿 기반 REST API 데이터 전송: ${apiUrl}${endpoint}, ${data.length}개 레코드`);
|
||||
console.log(`[BatchExternalDbService] Request Body 템플릿:`, templateBody);
|
||||
|
||||
// REST API 커넥터 생성
|
||||
const connector = new RestApiConnector({
|
||||
baseUrl: apiUrl,
|
||||
apiKey: apiKey,
|
||||
timeout: 30000
|
||||
});
|
||||
|
||||
// 연결 테스트
|
||||
await connector.connect();
|
||||
|
||||
let successCount = 0;
|
||||
let failedCount = 0;
|
||||
|
||||
// 각 레코드를 개별적으로 전송
|
||||
for (const record of data) {
|
||||
try {
|
||||
// 템플릿 처리: {{컬럼명}} → 실제 값으로 치환
|
||||
let processedBody = templateBody;
|
||||
for (const [key, value] of Object.entries(record)) {
|
||||
const placeholder = `{{${key}}}`;
|
||||
let stringValue = '';
|
||||
|
||||
if (value !== null && value !== undefined) {
|
||||
// Date 객체인 경우 다양한 포맷으로 변환
|
||||
if (value instanceof Date) {
|
||||
// ISO 형식: 2025-09-25T07:22:52.000Z
|
||||
stringValue = value.toISOString();
|
||||
|
||||
// 다른 포맷이 필요한 경우 여기서 처리
|
||||
// 예: YYYY-MM-DD 형식
|
||||
// stringValue = value.toISOString().split('T')[0];
|
||||
|
||||
// 예: YYYY-MM-DD HH:mm:ss 형식
|
||||
// stringValue = value.toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, '');
|
||||
} else {
|
||||
stringValue = String(value);
|
||||
}
|
||||
}
|
||||
|
||||
processedBody = processedBody.replace(new RegExp(placeholder.replace(/[{}]/g, '\\$&'), 'g'), stringValue);
|
||||
}
|
||||
|
||||
console.log(`[BatchExternalDbService] 원본 레코드:`, record);
|
||||
console.log(`[BatchExternalDbService] 처리된 Request Body:`, processedBody);
|
||||
|
||||
// JSON 파싱하여 객체로 변환
|
||||
let requestData;
|
||||
try {
|
||||
requestData = JSON.parse(processedBody);
|
||||
} catch (parseError) {
|
||||
console.error(`[BatchExternalDbService] JSON 파싱 오류:`, parseError);
|
||||
throw new Error(`Request Body JSON 파싱 실패: ${parseError}`);
|
||||
}
|
||||
|
||||
// URL 경로 파라미터 처리 (PUT/DELETE용)
|
||||
let finalEndpoint = endpoint;
|
||||
if ((method === 'PUT' || method === 'DELETE') && urlPathColumn && record[urlPathColumn]) {
|
||||
// /api/users → /api/users/user123
|
||||
finalEndpoint = `${endpoint}/${record[urlPathColumn]}`;
|
||||
}
|
||||
|
||||
console.log(`[BatchExternalDbService] 실행할 API 호출: ${method} ${finalEndpoint}`);
|
||||
console.log(`[BatchExternalDbService] 전송할 데이터:`, requestData);
|
||||
|
||||
await connector.executeQuery(finalEndpoint, method, requestData);
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
console.error(`REST API 레코드 전송 실패:`, error);
|
||||
failedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[BatchExternalDbService] 템플릿 기반 REST API 데이터 전송 완료: 성공 ${successCount}개, 실패 ${failedCount}개`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { successCount, failedCount }
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`[BatchExternalDbService] 템플릿 기반 REST API 데이터 전송 오류:`, error);
|
||||
return {
|
||||
success: false,
|
||||
message: `REST API 데이터 전송 실패: ${error}`,
|
||||
data: { successCount: 0, failedCount: 0 }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* REST API로 데이터 전송 (기존 메서드)
|
||||
*/
|
||||
static async sendDataToRestApi(
|
||||
apiUrl: string,
|
||||
apiKey: string,
|
||||
endpoint: string,
|
||||
method: 'POST' | 'PUT' = 'POST',
|
||||
data: any[]
|
||||
): Promise<ApiResponse<{ successCount: number; failedCount: number }>> {
|
||||
try {
|
||||
console.log(`[BatchExternalDbService] REST API 데이터 전송: ${apiUrl}${endpoint}, ${data.length}개 레코드`);
|
||||
|
||||
// REST API 커넥터 생성
|
||||
const connector = new RestApiConnector({
|
||||
baseUrl: apiUrl,
|
||||
apiKey: apiKey,
|
||||
timeout: 30000
|
||||
});
|
||||
|
||||
// 연결 테스트
|
||||
await connector.connect();
|
||||
|
||||
let successCount = 0;
|
||||
let failedCount = 0;
|
||||
|
||||
// 각 레코드를 개별적으로 전송
|
||||
for (const record of data) {
|
||||
try {
|
||||
console.log(`[BatchExternalDbService] 실행할 API 호출: ${method} ${endpoint}`);
|
||||
console.log(`[BatchExternalDbService] 전송할 데이터:`, record);
|
||||
|
||||
await connector.executeQuery(endpoint, method, record);
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
console.error(`REST API 레코드 전송 실패:`, error);
|
||||
failedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[BatchExternalDbService] REST API 데이터 전송 완료: 성공 ${successCount}개, 실패 ${failedCount}개`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { successCount, failedCount }
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`[BatchExternalDbService] REST API 데이터 전송 오류 (${apiUrl}${endpoint}):`, error);
|
||||
return {
|
||||
success: false,
|
||||
message: "REST API 데이터 전송 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -170,6 +170,8 @@ export class BatchManagementService {
|
|||
ORDER BY ordinal_position
|
||||
`;
|
||||
|
||||
console.log(`[BatchManagementService] 쿼리 결과:`, result);
|
||||
|
||||
console.log(`[BatchManagementService] 내부 DB 컬럼 조회 결과:`, result);
|
||||
|
||||
columns = result.map(row => ({
|
||||
|
|
|
|||
|
|
@ -80,11 +80,15 @@ export class BatchSchedulerService {
|
|||
|
||||
// 새로운 스케줄 등록
|
||||
const task = cron.schedule(cron_schedule, async () => {
|
||||
logger.info(`🔄 스케줄 배치 실행 시작: ${batch_name} (ID: ${id})`);
|
||||
await this.executeBatchConfig(config);
|
||||
});
|
||||
|
||||
// 스케줄 시작 (기본적으로 시작되지만 명시적으로 호출)
|
||||
task.start();
|
||||
|
||||
this.scheduledTasks.set(id, task);
|
||||
logger.info(`배치 스케줄 등록 완료: ${batch_name} (ID: ${id}, Schedule: ${cron_schedule})`);
|
||||
logger.info(`배치 스케줄 등록 완료: ${batch_name} (ID: ${id}, Schedule: ${cron_schedule}) - 스케줄 시작됨`);
|
||||
} catch (error) {
|
||||
logger.error(`배치 스케줄 등록 실패 (ID: ${config.id}):`, error);
|
||||
}
|
||||
|
|
@ -223,32 +227,115 @@ export class BatchSchedulerService {
|
|||
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
|
||||
);
|
||||
let fromData: any[] = [];
|
||||
|
||||
// FROM 데이터 조회 (DB 또는 REST API)
|
||||
if (firstMapping.from_connection_type === 'restapi') {
|
||||
// REST API에서 데이터 조회
|
||||
logger.info(`REST API에서 데이터 조회: ${firstMapping.from_api_url}${firstMapping.from_table_name}`);
|
||||
const { BatchExternalDbService } = await import('./batchExternalDbService');
|
||||
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: any) => m.from_column_name)
|
||||
);
|
||||
|
||||
if (apiResult.success && apiResult.data) {
|
||||
fromData = apiResult.data;
|
||||
} else {
|
||||
throw new Error(`REST API 데이터 조회 실패: ${apiResult.message}`);
|
||||
}
|
||||
} else {
|
||||
// DB에서 데이터 조회
|
||||
const fromColumns = mappings.map((m: any) => 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) {
|
||||
mappedRow[mapping.to_column_name] = row[mapping.from_column_name];
|
||||
// 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 테이블에 데이터 삽입
|
||||
const insertResult = await BatchService.insertDataToTable(
|
||||
firstMapping.to_table_name,
|
||||
mappedData,
|
||||
firstMapping.to_connection_type as 'internal' | 'external',
|
||||
firstMapping.to_connection_id || undefined
|
||||
);
|
||||
// TO 테이블에 데이터 삽입 (DB 또는 REST API)
|
||||
let insertResult: { successCount: number; failedCount: number };
|
||||
|
||||
if (firstMapping.to_connection_type === 'restapi') {
|
||||
// REST API로 데이터 전송
|
||||
logger.info(`REST API로 데이터 전송: ${firstMapping.to_api_url}${firstMapping.to_table_name}`);
|
||||
const { BatchExternalDbService } = await import('./batchExternalDbService');
|
||||
|
||||
// DB → REST API 배치인지 확인 (to_api_body가 있으면 템플릿 기반)
|
||||
const hasTemplate = mappings.some((m: any) => m.to_api_body);
|
||||
|
||||
if (hasTemplate) {
|
||||
// 템플릿 기반 REST API 전송 (DB → REST API 배치)
|
||||
const templateBody = firstMapping.to_api_body || '{}';
|
||||
logger.info(`템플릿 기반 REST API 전송, Request Body 템플릿: ${templateBody}`);
|
||||
|
||||
// URL 경로 컬럼 찾기 (PUT/DELETE용)
|
||||
const urlPathColumn = mappings.find((m: any) => 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;
|
||||
|
||||
|
|
|
|||
|
|
@ -165,11 +165,18 @@ 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,
|
||||
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, // Request Body 템플릿 추가
|
||||
mapping_order: mapping.mapping_order || index + 1,
|
||||
created_by: userId,
|
||||
},
|
||||
|
|
@ -311,12 +318,14 @@ export class BatchService {
|
|||
};
|
||||
}
|
||||
|
||||
await prisma.batch_configs.update({
|
||||
where: { id },
|
||||
data: {
|
||||
is_active: "N",
|
||||
updated_by: userId,
|
||||
},
|
||||
// 배치 매핑 먼저 삭제 (외래키 제약)
|
||||
await prisma.batch_mappings.deleteMany({
|
||||
where: { batch_config_id: id }
|
||||
});
|
||||
|
||||
// 배치 설정 삭제
|
||||
await prisma.batch_configs.delete({
|
||||
where: { id }
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
@ -730,6 +739,7 @@ export class BatchService {
|
|||
return { successCount: 0, failedCount: data.length };
|
||||
}
|
||||
} else {
|
||||
console.log(`[BatchService] 연결 정보 디버그:`, { connectionType, connectionId });
|
||||
throw new Error(`잘못된 연결 타입 또는 연결 ID: ${connectionType}, ${connectionId}`);
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,15 @@
|
|||
// 배치관리 타입 정의
|
||||
// 작성일: 2024-12-24
|
||||
|
||||
// 배치 타입 정의
|
||||
export type BatchType = 'db-to-db' | 'db-to-restapi' | 'restapi-to-db' | 'restapi-to-restapi';
|
||||
|
||||
export interface BatchTypeOption {
|
||||
value: BatchType;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface BatchConfig {
|
||||
id?: number;
|
||||
batch_name: string;
|
||||
|
|
@ -20,18 +29,25 @@ export interface BatchMapping {
|
|||
batch_config_id?: number;
|
||||
|
||||
// FROM 정보
|
||||
from_connection_type: 'internal' | 'external';
|
||||
from_connection_type: 'internal' | 'external' | 'restapi';
|
||||
from_connection_id?: number;
|
||||
from_table_name: string;
|
||||
from_column_name: string;
|
||||
from_table_name: string; // DB: 테이블명, REST API: 엔드포인트
|
||||
from_column_name: string; // DB: 컬럼명, REST API: JSON 필드명
|
||||
from_column_type?: string;
|
||||
from_api_method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; // REST API 전용
|
||||
from_api_url?: string; // REST API 서버 URL
|
||||
from_api_key?: string; // REST API 키
|
||||
|
||||
// TO 정보
|
||||
to_connection_type: 'internal' | 'external';
|
||||
to_connection_type: 'internal' | 'external' | 'restapi';
|
||||
to_connection_id?: number;
|
||||
to_table_name: string;
|
||||
to_column_name: string;
|
||||
to_table_name: string; // DB: 테이블명, REST API: 엔드포인트
|
||||
to_column_name: string; // DB: 컬럼명, REST API: JSON 필드명
|
||||
to_column_type?: string;
|
||||
to_api_method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; // REST API 전용
|
||||
to_api_url?: string; // REST API 서버 URL
|
||||
to_api_key?: string; // REST API 키
|
||||
to_api_body?: string; // Request Body 템플릿 (DB → REST API 배치용)
|
||||
|
||||
mapping_order?: number;
|
||||
created_date?: Date;
|
||||
|
|
@ -68,16 +84,23 @@ export interface ColumnInfo {
|
|||
}
|
||||
|
||||
export interface BatchMappingRequest {
|
||||
from_connection_type: 'internal' | 'external';
|
||||
from_connection_type: 'internal' | 'external' | 'restapi';
|
||||
from_connection_id?: number;
|
||||
from_table_name: string;
|
||||
from_column_name: string;
|
||||
from_column_type?: string;
|
||||
to_connection_type: 'internal' | 'external';
|
||||
from_api_url?: string;
|
||||
from_api_key?: string;
|
||||
from_api_method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||
to_connection_type: 'internal' | 'external' | 'restapi';
|
||||
to_connection_id?: number;
|
||||
to_table_name: string;
|
||||
to_column_name: string;
|
||||
to_column_type?: string;
|
||||
to_api_url?: string;
|
||||
to_api_key?: string;
|
||||
to_api_method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||
to_api_body?: string; // Request Body 템플릿 (DB → REST API 배치용)
|
||||
mapping_order?: number;
|
||||
}
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -34,13 +35,17 @@ import {
|
|||
Trash2,
|
||||
Play,
|
||||
RefreshCw,
|
||||
BarChart3
|
||||
BarChart3,
|
||||
ArrowRight,
|
||||
Database,
|
||||
Globe
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { BatchAPI, BatchJob } from "@/lib/api/batch";
|
||||
import BatchJobModal from "@/components/admin/BatchJobModal";
|
||||
|
||||
export default function BatchManagementPage() {
|
||||
const router = useRouter();
|
||||
const [jobs, setJobs] = useState<BatchJob[]>([]);
|
||||
const [filteredJobs, setFilteredJobs] = useState<BatchJob[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
|
@ -52,6 +57,7 @@ export default function BatchManagementPage() {
|
|||
// 모달 상태
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedJob, setSelectedJob] = useState<BatchJob | null>(null);
|
||||
const [isBatchTypeModalOpen, setIsBatchTypeModalOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadJobs();
|
||||
|
|
@ -109,8 +115,23 @@ export default function BatchManagementPage() {
|
|||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setSelectedJob(null);
|
||||
setIsModalOpen(true);
|
||||
setIsBatchTypeModalOpen(true);
|
||||
};
|
||||
|
||||
const handleBatchTypeSelect = (type: 'db-to-db' | 'restapi-to-db') => {
|
||||
console.log("배치 타입 선택:", type);
|
||||
setIsBatchTypeModalOpen(false);
|
||||
|
||||
if (type === 'db-to-db') {
|
||||
// 기존 배치 생성 모달 열기
|
||||
console.log("DB → DB 배치 모달 열기");
|
||||
setSelectedJob(null);
|
||||
setIsModalOpen(true);
|
||||
} else if (type === 'restapi-to-db') {
|
||||
// 새로운 REST API 배치 페이지로 이동
|
||||
console.log("REST API → DB 페이지로 이동:", '/admin/batch-management-new');
|
||||
router.push('/admin/batch-management-new');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (job: BatchJob) => {
|
||||
|
|
@ -421,6 +442,61 @@ export default function BatchManagementPage() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 배치 타입 선택 모달 */}
|
||||
{isBatchTypeModalOpen && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<Card className="w-full max-w-2xl mx-4">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-center">배치 타입 선택</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* DB → DB */}
|
||||
<div
|
||||
className="p-6 border rounded-lg cursor-pointer transition-all hover:border-blue-500 hover:bg-blue-50"
|
||||
onClick={() => handleBatchTypeSelect('db-to-db')}
|
||||
>
|
||||
<div className="flex items-center justify-center mb-4">
|
||||
<Database className="w-8 h-8 text-blue-600 mr-2" />
|
||||
<ArrowRight className="w-6 h-6 text-gray-400 mr-2" />
|
||||
<Database className="w-8 h-8 text-blue-600" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="font-medium text-lg mb-2">DB → DB</div>
|
||||
<div className="text-sm text-gray-500">데이터베이스 간 데이터 동기화</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* REST API → DB */}
|
||||
<div
|
||||
className="p-6 border rounded-lg cursor-pointer transition-all hover:border-green-500 hover:bg-green-50"
|
||||
onClick={() => handleBatchTypeSelect('restapi-to-db')}
|
||||
>
|
||||
<div className="flex items-center justify-center mb-4">
|
||||
<Globe className="w-8 h-8 text-green-600 mr-2" />
|
||||
<ArrowRight className="w-6 h-6 text-gray-400 mr-2" />
|
||||
<Database className="w-8 h-8 text-green-600" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="font-medium text-lg mb-2">REST API → DB</div>
|
||||
<div className="text-sm text-gray-500">REST API에서 데이터베이스로 데이터 수집</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsBatchTypeModalOpen(false)}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 배치 작업 모달 */}
|
||||
<BatchJobModal
|
||||
isOpen={isModalOpen}
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ export default function BatchCreatePage() {
|
|||
setSelectedFromColumn(null);
|
||||
|
||||
try {
|
||||
const tables = await BatchAPI.getTablesFromConnection(connection.type, connection.id);
|
||||
const tables = await BatchAPI.getTablesFromConnection(connection);
|
||||
setFromTables(Array.isArray(tables) ? tables : []);
|
||||
} catch (error) {
|
||||
console.error("FROM 테이블 목록 로드 실패:", error);
|
||||
|
|
@ -112,7 +112,7 @@ export default function BatchCreatePage() {
|
|||
setToColumns([]);
|
||||
|
||||
try {
|
||||
const tables = await BatchAPI.getTablesFromConnection(connection.type, connection.id);
|
||||
const tables = await BatchAPI.getTablesFromConnection(connection);
|
||||
setToTables(Array.isArray(tables) ? tables : []);
|
||||
} catch (error) {
|
||||
console.error("TO 테이블 목록 로드 실패:", error);
|
||||
|
|
@ -129,7 +129,7 @@ export default function BatchCreatePage() {
|
|||
if (!fromConnection || !tableName) return;
|
||||
|
||||
try {
|
||||
const columns = await BatchAPI.getTableColumns(fromConnection.type, fromConnection.id, tableName);
|
||||
const columns = await BatchAPI.getTableColumns(fromConnection, tableName);
|
||||
setFromColumns(Array.isArray(columns) ? columns : []);
|
||||
} catch (error) {
|
||||
console.error("FROM 컬럼 목록 로드 실패:", error);
|
||||
|
|
@ -145,7 +145,7 @@ export default function BatchCreatePage() {
|
|||
if (!toConnection || !tableName) return;
|
||||
|
||||
try {
|
||||
const columns = await BatchAPI.getTableColumns(toConnection.type, toConnection.id, tableName);
|
||||
const columns = await BatchAPI.getTableColumns(toConnection, tableName);
|
||||
setToColumns(Array.isArray(columns) ? columns : []);
|
||||
} catch (error) {
|
||||
console.error("TO 컬럼 목록 로드 실패:", error);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,833 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { RefreshCw, Save, ArrowLeft, Plus, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { BatchAPI, BatchConfig, BatchMapping, ConnectionInfo } from "@/lib/api/batch";
|
||||
|
||||
interface BatchColumnInfo {
|
||||
column_name: string;
|
||||
data_type: string;
|
||||
is_nullable: string;
|
||||
}
|
||||
|
||||
// 배치 타입 감지 함수
|
||||
const detectBatchType = (mapping: BatchMapping): 'db-to-db' | 'restapi-to-db' | 'db-to-restapi' => {
|
||||
const fromType = mapping.from_connection_type;
|
||||
const toType = mapping.to_connection_type;
|
||||
|
||||
if (fromType === 'restapi' && (toType === 'internal' || toType === 'external')) {
|
||||
return 'restapi-to-db';
|
||||
} else if ((fromType === 'internal' || fromType === 'external') && toType === 'restapi') {
|
||||
return 'db-to-restapi';
|
||||
} else {
|
||||
return 'db-to-db';
|
||||
}
|
||||
};
|
||||
|
||||
export default function BatchEditPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const batchId = parseInt(params.id as string);
|
||||
|
||||
// 기본 상태
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [batchConfig, setBatchConfig] = useState<BatchConfig | null>(null);
|
||||
const [batchName, setBatchName] = useState("");
|
||||
const [cronSchedule, setCronSchedule] = useState("0 12 * * *");
|
||||
const [description, setDescription] = useState("");
|
||||
const [isActive, setIsActive] = useState("Y");
|
||||
|
||||
// 연결 정보
|
||||
const [connections, setConnections] = useState<ConnectionInfo[]>([]);
|
||||
const [fromConnection, setFromConnection] = useState<ConnectionInfo | null>(null);
|
||||
const [toConnection, setToConnection] = useState<ConnectionInfo | null>(null);
|
||||
|
||||
// 테이블 및 컬럼 정보
|
||||
const [fromTables, setFromTables] = useState<string[]>([]);
|
||||
const [toTables, setToTables] = useState<string[]>([]);
|
||||
const [fromTable, setFromTable] = useState("");
|
||||
const [toTable, setToTable] = useState("");
|
||||
const [fromColumns, setFromColumns] = useState<BatchColumnInfo[]>([]);
|
||||
const [toColumns, setToColumns] = useState<BatchColumnInfo[]>([]);
|
||||
|
||||
// 매핑 정보
|
||||
const [mappings, setMappings] = useState<BatchMapping[]>([]);
|
||||
|
||||
// 배치 타입 감지
|
||||
const [batchType, setBatchType] = useState<'db-to-db' | 'restapi-to-db' | 'db-to-restapi' | null>(null);
|
||||
|
||||
// 페이지 로드 시 배치 정보 조회
|
||||
useEffect(() => {
|
||||
if (batchId) {
|
||||
loadBatchConfig();
|
||||
loadConnections();
|
||||
}
|
||||
}, [batchId]);
|
||||
|
||||
// 연결 정보가 로드된 후 배치 설정의 연결 정보 설정
|
||||
useEffect(() => {
|
||||
if (batchConfig && connections.length > 0 && batchConfig.batch_mappings && batchConfig.batch_mappings.length > 0) {
|
||||
const firstMapping = batchConfig.batch_mappings[0];
|
||||
console.log("🔗 연결 정보 설정 시작:", firstMapping);
|
||||
|
||||
// FROM 연결 정보 설정
|
||||
if (firstMapping.from_connection_type === 'internal') {
|
||||
setFromConnection({ type: 'internal', name: '내부 DB' });
|
||||
// 내부 DB 테이블 목록 로드
|
||||
BatchAPI.getTablesFromConnection({ type: 'internal', name: '내부 DB' }).then(tables => {
|
||||
console.log("📋 FROM 테이블 목록:", tables);
|
||||
setFromTables(tables);
|
||||
|
||||
// 컬럼 정보도 로드
|
||||
if (firstMapping.from_table_name) {
|
||||
BatchAPI.getTableColumns({ type: 'internal', name: '내부 DB' }, firstMapping.from_table_name).then(columns => {
|
||||
console.log("📊 FROM 컬럼 목록:", columns);
|
||||
setFromColumns(columns);
|
||||
});
|
||||
}
|
||||
});
|
||||
} else if (firstMapping.from_connection_id) {
|
||||
const fromConn = connections.find(c => c.id === firstMapping.from_connection_id);
|
||||
if (fromConn) {
|
||||
setFromConnection(fromConn);
|
||||
// 외부 DB 테이블 목록 로드
|
||||
BatchAPI.getTablesFromConnection(fromConn).then(tables => {
|
||||
console.log("📋 FROM 테이블 목록:", tables);
|
||||
setFromTables(tables);
|
||||
|
||||
// 컬럼 정보도 로드
|
||||
if (firstMapping.from_table_name) {
|
||||
BatchAPI.getTableColumns(fromConn, firstMapping.from_table_name).then(columns => {
|
||||
console.log("📊 FROM 컬럼 목록:", columns);
|
||||
setFromColumns(columns);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// TO 연결 정보 설정
|
||||
if (firstMapping.to_connection_type === 'internal') {
|
||||
setToConnection({ type: 'internal', name: '내부 DB' });
|
||||
// 내부 DB 테이블 목록 로드
|
||||
BatchAPI.getTablesFromConnection({ type: 'internal', name: '내부 DB' }).then(tables => {
|
||||
console.log("📋 TO 테이블 목록:", tables);
|
||||
setToTables(tables);
|
||||
|
||||
// 컬럼 정보도 로드
|
||||
if (firstMapping.to_table_name) {
|
||||
BatchAPI.getTableColumns({ type: 'internal', name: '내부 DB' }, firstMapping.to_table_name).then(columns => {
|
||||
console.log("📊 TO 컬럼 목록:", columns);
|
||||
setToColumns(columns);
|
||||
});
|
||||
}
|
||||
});
|
||||
} else if (firstMapping.to_connection_id) {
|
||||
const toConn = connections.find(c => c.id === firstMapping.to_connection_id);
|
||||
if (toConn) {
|
||||
setToConnection(toConn);
|
||||
// 외부 DB 테이블 목록 로드
|
||||
BatchAPI.getTablesFromConnection(toConn).then(tables => {
|
||||
console.log("📋 TO 테이블 목록:", tables);
|
||||
setToTables(tables);
|
||||
|
||||
// 컬럼 정보도 로드
|
||||
if (firstMapping.to_table_name) {
|
||||
BatchAPI.getTableColumns(toConn, firstMapping.to_table_name).then(columns => {
|
||||
console.log("📊 TO 컬럼 목록:", columns);
|
||||
setToColumns(columns);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [batchConfig, connections]);
|
||||
|
||||
// 배치 설정 조회
|
||||
const loadBatchConfig = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
console.log("🔍 배치 설정 조회 시작:", batchId);
|
||||
|
||||
const config = await BatchAPI.getBatchConfig(batchId);
|
||||
console.log("📋 조회된 배치 설정:", config);
|
||||
|
||||
setBatchConfig(config);
|
||||
setBatchName(config.batch_name);
|
||||
setCronSchedule(config.cron_schedule);
|
||||
setDescription(config.description || "");
|
||||
setIsActive(config.is_active || "Y");
|
||||
|
||||
if (config.batch_mappings && config.batch_mappings.length > 0) {
|
||||
console.log("📊 매핑 정보:", config.batch_mappings);
|
||||
console.log("📊 매핑 개수:", config.batch_mappings.length);
|
||||
config.batch_mappings.forEach((mapping, idx) => {
|
||||
console.log(`📊 매핑 #${idx + 1}:`, {
|
||||
from: `${mapping.from_column_name} (${mapping.from_column_type})`,
|
||||
to: `${mapping.to_column_name} (${mapping.to_column_type})`,
|
||||
type: mapping.mapping_type
|
||||
});
|
||||
});
|
||||
setMappings(config.batch_mappings);
|
||||
|
||||
// 첫 번째 매핑에서 연결 및 테이블 정보 추출
|
||||
const firstMapping = config.batch_mappings[0];
|
||||
setFromTable(firstMapping.from_table_name);
|
||||
setToTable(firstMapping.to_table_name);
|
||||
|
||||
// 배치 타입 감지
|
||||
const detectedBatchType = detectBatchType(firstMapping);
|
||||
setBatchType(detectedBatchType);
|
||||
console.log("🎯 감지된 배치 타입:", detectedBatchType);
|
||||
|
||||
// FROM 연결 정보 설정
|
||||
if (firstMapping.from_connection_type === 'internal') {
|
||||
setFromConnection({ type: 'internal', name: '내부 DB' });
|
||||
} else if (firstMapping.from_connection_id) {
|
||||
// 외부 연결은 connections 로드 후 설정
|
||||
setTimeout(() => {
|
||||
const fromConn = connections.find(c => c.id === firstMapping.from_connection_id);
|
||||
if (fromConn) {
|
||||
setFromConnection(fromConn);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// TO 연결 정보 설정
|
||||
if (firstMapping.to_connection_type === 'internal') {
|
||||
setToConnection({ type: 'internal', name: '내부 DB' });
|
||||
} else if (firstMapping.to_connection_id) {
|
||||
// 외부 연결은 connections 로드 후 설정
|
||||
setTimeout(() => {
|
||||
const toConn = connections.find(c => c.id === firstMapping.to_connection_id);
|
||||
if (toConn) {
|
||||
setToConnection(toConn);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
console.log("🔗 테이블 정보 설정:", {
|
||||
fromTable: firstMapping.from_table_name,
|
||||
toTable: firstMapping.to_table_name,
|
||||
fromConnectionType: firstMapping.from_connection_type,
|
||||
toConnectionType: firstMapping.to_connection_type
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("❌ 배치 설정 조회 오류:", error);
|
||||
toast.error("배치 설정을 불러오는데 실패했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 연결 정보 조회
|
||||
const loadConnections = async () => {
|
||||
try {
|
||||
const connectionList = await BatchAPI.getConnections();
|
||||
setConnections(connectionList);
|
||||
} catch (error) {
|
||||
console.error("연결 정보 조회 오류:", error);
|
||||
toast.error("연결 정보를 불러오는데 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// FROM 연결 변경 시
|
||||
const handleFromConnectionChange = async (connectionId: string) => {
|
||||
const connection = connections.find(c => c.id?.toString() === connectionId) ||
|
||||
(connectionId === 'internal' ? { type: 'internal' as const, name: '내부 DB' } : null);
|
||||
|
||||
if (connection) {
|
||||
setFromConnection(connection);
|
||||
|
||||
try {
|
||||
const tables = await BatchAPI.getTablesFromConnection(connection);
|
||||
setFromTables(tables);
|
||||
setFromTable("");
|
||||
setFromColumns([]);
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 조회 오류:", error);
|
||||
toast.error("테이블 목록을 불러오는데 실패했습니다.");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// TO 연결 변경 시
|
||||
const handleToConnectionChange = async (connectionId: string) => {
|
||||
const connection = connections.find(c => c.id?.toString() === connectionId) ||
|
||||
(connectionId === 'internal' ? { type: 'internal' as const, name: '내부 DB' } : null);
|
||||
|
||||
if (connection) {
|
||||
setToConnection(connection);
|
||||
|
||||
try {
|
||||
const tables = await BatchAPI.getTablesFromConnection(connection);
|
||||
setToTables(tables);
|
||||
setToTable("");
|
||||
setToColumns([]);
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 조회 오류:", error);
|
||||
toast.error("테이블 목록을 불러오는데 실패했습니다.");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// FROM 테이블 변경 시
|
||||
const handleFromTableChange = async (tableName: string) => {
|
||||
setFromTable(tableName);
|
||||
|
||||
if (fromConnection && tableName) {
|
||||
try {
|
||||
const columns = await BatchAPI.getTableColumns(fromConnection, tableName);
|
||||
setFromColumns(columns);
|
||||
} catch (error) {
|
||||
console.error("컬럼 정보 조회 오류:", error);
|
||||
toast.error("컬럼 정보를 불러오는데 실패했습니다.");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// TO 테이블 변경 시
|
||||
const handleToTableChange = async (tableName: string) => {
|
||||
setToTable(tableName);
|
||||
|
||||
if (toConnection && tableName) {
|
||||
try {
|
||||
const columns = await BatchAPI.getTableColumns(toConnection, tableName);
|
||||
setToColumns(columns);
|
||||
} catch (error) {
|
||||
console.error("컬럼 정보 조회 오류:", error);
|
||||
toast.error("컬럼 정보를 불러오는데 실패했습니다.");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 매핑 추가
|
||||
const addMapping = () => {
|
||||
const newMapping: BatchMapping = {
|
||||
from_connection_type: fromConnection?.type === 'internal' ? 'internal' : 'external',
|
||||
from_connection_id: fromConnection?.type === 'internal' ? undefined : fromConnection?.id,
|
||||
from_table_name: fromTable,
|
||||
from_column_name: '',
|
||||
from_column_type: '',
|
||||
to_connection_type: toConnection?.type === 'internal' ? 'internal' : 'external',
|
||||
to_connection_id: toConnection?.type === 'internal' ? undefined : toConnection?.id,
|
||||
to_table_name: toTable,
|
||||
to_column_name: '',
|
||||
to_column_type: '',
|
||||
mapping_type: 'direct',
|
||||
mapping_order: mappings.length + 1
|
||||
};
|
||||
|
||||
setMappings([...mappings, newMapping]);
|
||||
};
|
||||
|
||||
// 매핑 삭제
|
||||
const removeMapping = (index: number) => {
|
||||
const updatedMappings = mappings.filter((_, i) => i !== index);
|
||||
setMappings(updatedMappings);
|
||||
};
|
||||
|
||||
// 매핑 업데이트
|
||||
const updateMapping = (index: number, field: keyof BatchMapping, value: any) => {
|
||||
const updatedMappings = [...mappings];
|
||||
updatedMappings[index] = { ...updatedMappings[index], [field]: value };
|
||||
setMappings(updatedMappings);
|
||||
};
|
||||
|
||||
// 배치 설정 저장
|
||||
const saveBatchConfig = async () => {
|
||||
if (!batchName || !cronSchedule || mappings.length === 0) {
|
||||
toast.error("필수 항목을 모두 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
await BatchAPI.updateBatchConfig(batchId, {
|
||||
batchName,
|
||||
description,
|
||||
cronSchedule,
|
||||
isActive,
|
||||
mappings
|
||||
});
|
||||
|
||||
toast.success("배치 설정이 성공적으로 수정되었습니다.");
|
||||
router.push("/admin/batchmng");
|
||||
|
||||
} catch (error) {
|
||||
console.error("배치 설정 수정 실패:", error);
|
||||
toast.error("배치 설정 수정에 실패했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading && !batchConfig) {
|
||||
return (
|
||||
<div className="container mx-auto p-6">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<RefreshCw className="w-8 h-8 animate-spin" />
|
||||
<span className="ml-2">배치 설정을 불러오는 중...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push("/admin/batchmng")}
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
목록으로
|
||||
</Button>
|
||||
<h1 className="text-3xl font-bold">배치 설정 수정</h1>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<Button onClick={loadBatchConfig} variant="outline" disabled={loading}>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
새로고침
|
||||
</Button>
|
||||
<Button onClick={saveBatchConfig} disabled={loading}>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 기본 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="batchName">배치명 *</Label>
|
||||
<Input
|
||||
id="batchName"
|
||||
value={batchName}
|
||||
onChange={(e) => setBatchName(e.target.value)}
|
||||
placeholder="배치명을 입력하세요"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="cronSchedule">실행 스케줄 (Cron) *</Label>
|
||||
<Input
|
||||
id="cronSchedule"
|
||||
value={cronSchedule}
|
||||
onChange={(e) => setCronSchedule(e.target.value)}
|
||||
placeholder="0 12 * * *"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="description">설명</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="배치에 대한 설명을 입력하세요"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="isActive"
|
||||
checked={isActive === 'Y'}
|
||||
onCheckedChange={(checked) => setIsActive(checked ? 'Y' : 'N')}
|
||||
/>
|
||||
<Label htmlFor="isActive">활성화</Label>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 배치 타입 표시 */}
|
||||
{batchType && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<span>배치 타입</span>
|
||||
<Badge variant="outline">
|
||||
{batchType === 'db-to-db' && 'DB → DB'}
|
||||
{batchType === 'restapi-to-db' && 'REST API → DB'}
|
||||
{batchType === 'db-to-restapi' && 'DB → REST API'}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 연결 설정 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>연결 설정</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{batchType === 'db-to-db' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* FROM 설정 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">FROM (소스)</h3>
|
||||
|
||||
<div>
|
||||
<Label>연결</Label>
|
||||
<Select
|
||||
value={fromConnection?.type === 'internal' ? 'internal' : fromConnection?.id?.toString() || ''}
|
||||
onValueChange={handleFromConnectionChange}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="소스 연결을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="internal">내부 DB</SelectItem>
|
||||
{connections.filter(conn => conn.id).map((conn) => (
|
||||
<SelectItem key={conn.id} value={conn.id!.toString()}>
|
||||
{conn.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>테이블</Label>
|
||||
<Select value={fromTable} onValueChange={handleFromTableChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="소스 테이블을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{fromTables.map((table) => (
|
||||
<SelectItem key={table} value={table}>
|
||||
{table}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TO 설정 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">TO (대상)</h3>
|
||||
|
||||
<div>
|
||||
<Label>연결</Label>
|
||||
<Select
|
||||
value={toConnection?.type === 'internal' ? 'internal' : toConnection?.id?.toString() || ''}
|
||||
onValueChange={handleToConnectionChange}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="대상 연결을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="internal">내부 DB</SelectItem>
|
||||
{connections.filter(conn => conn.id).map((conn) => (
|
||||
<SelectItem key={conn.id} value={conn.id!.toString()}>
|
||||
{conn.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>테이블</Label>
|
||||
<Select value={toTable} onValueChange={handleToTableChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="대상 테이블을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{toTables.map((table) => (
|
||||
<SelectItem key={table} value={table}>
|
||||
{table}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{batchType === 'restapi-to-db' && (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center py-4 bg-blue-50 rounded-lg">
|
||||
<h3 className="text-lg font-semibold text-blue-800">REST API → DB 배치</h3>
|
||||
<p className="text-sm text-blue-600">외부 REST API에서 데이터를 가져와 데이터베이스에 저장합니다.</p>
|
||||
</div>
|
||||
|
||||
{mappings.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>API URL</Label>
|
||||
<Input value={mappings[0]?.from_api_url || ''} readOnly />
|
||||
</div>
|
||||
<div>
|
||||
<Label>API 엔드포인트</Label>
|
||||
<Input value={mappings[0]?.from_table_name || ''} readOnly />
|
||||
</div>
|
||||
<div>
|
||||
<Label>HTTP 메서드</Label>
|
||||
<Input value={mappings[0]?.from_api_method || 'GET'} readOnly />
|
||||
</div>
|
||||
<div>
|
||||
<Label>대상 테이블</Label>
|
||||
<Input value={mappings[0]?.to_table_name || ''} readOnly />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{batchType === 'db-to-restapi' && (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center py-4 bg-green-50 rounded-lg">
|
||||
<h3 className="text-lg font-semibold text-green-800">DB → REST API 배치</h3>
|
||||
<p className="text-sm text-green-600">데이터베이스에서 데이터를 가져와 외부 REST API로 전송합니다.</p>
|
||||
</div>
|
||||
|
||||
{mappings.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>소스 테이블</Label>
|
||||
<Input value={mappings[0]?.from_table_name || ''} readOnly />
|
||||
</div>
|
||||
<div>
|
||||
<Label>API URL</Label>
|
||||
<Input value={mappings[0]?.to_api_url || ''} readOnly />
|
||||
</div>
|
||||
<div>
|
||||
<Label>API 엔드포인트</Label>
|
||||
<Input value={mappings[0]?.to_table_name || ''} readOnly />
|
||||
</div>
|
||||
<div>
|
||||
<Label>HTTP 메서드</Label>
|
||||
<Input value={mappings[0]?.to_api_method || 'POST'} readOnly />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 컬럼 매핑 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
{batchType === 'db-to-db' && '컬럼 매핑'}
|
||||
{batchType === 'restapi-to-db' && 'API 필드 → DB 컬럼 매핑'}
|
||||
{batchType === 'db-to-restapi' && 'DB 컬럼 → API 필드 매핑'}
|
||||
{batchType === 'db-to-db' && (
|
||||
<Button onClick={addMapping} size="sm">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
매핑 추가
|
||||
</Button>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{mappings.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
{batchType === 'db-to-db' && '매핑을 추가해주세요.'}
|
||||
{batchType === 'restapi-to-db' && 'API 필드와 DB 컬럼 매핑 정보가 없습니다.'}
|
||||
{batchType === 'db-to-restapi' && 'DB 컬럼과 API 필드 매핑 정보가 없습니다.'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{batchType === 'db-to-db' && mappings.map((mapping, index) => (
|
||||
<div key={index} className="border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h4 className="font-medium">매핑 #{index + 1}</h4>
|
||||
{mapping.from_column_name && mapping.to_column_name && (
|
||||
<p className="text-sm text-gray-600">
|
||||
{mapping.from_column_name} → {mapping.to_column_name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => removeMapping(index)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>FROM 컬럼</Label>
|
||||
<Select
|
||||
value={mapping.from_column_name || ''}
|
||||
onValueChange={(value) => {
|
||||
console.log(`📝 FROM 컬럼 변경: ${value}`);
|
||||
updateMapping(index, 'from_column_name', value);
|
||||
// 컬럼 타입도 함께 업데이트
|
||||
const selectedColumn = fromColumns.find(col => col.column_name === value);
|
||||
if (selectedColumn) {
|
||||
updateMapping(index, 'from_column_type', selectedColumn.data_type);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="소스 컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{fromColumns.map((column) => (
|
||||
<SelectItem key={column.column_name} value={column.column_name}>
|
||||
{column.column_name} ({column.data_type})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{fromColumns.length === 0 && (
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
소스 테이블을 선택하면 컬럼 목록이 표시됩니다.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>TO 컬럼</Label>
|
||||
<Select
|
||||
value={mapping.to_column_name || ''}
|
||||
onValueChange={(value) => {
|
||||
console.log(`📝 TO 컬럼 변경: ${value}`);
|
||||
updateMapping(index, 'to_column_name', value);
|
||||
// 컬럼 타입도 함께 업데이트
|
||||
const selectedColumn = toColumns.find(col => col.column_name === value);
|
||||
if (selectedColumn) {
|
||||
updateMapping(index, 'to_column_type', selectedColumn.data_type);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="대상 컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{toColumns.map((column) => (
|
||||
<SelectItem key={column.column_name} value={column.column_name}>
|
||||
{column.column_name} ({column.data_type})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{toColumns.length === 0 && (
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
대상 테이블을 선택하면 컬럼 목록이 표시됩니다.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{batchType === 'restapi-to-db' && mappings.map((mapping, index) => (
|
||||
<div key={index} className="border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h4 className="font-medium">매핑 #{index + 1}</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
API 필드: {mapping.from_column_name} → DB 컬럼: {mapping.to_column_name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>API 필드명</Label>
|
||||
<Input value={mapping.from_column_name || ''} readOnly />
|
||||
</div>
|
||||
<div>
|
||||
<Label>DB 컬럼명</Label>
|
||||
<Input value={mapping.to_column_name || ''} readOnly />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{batchType === 'db-to-restapi' && mappings.map((mapping, index) => (
|
||||
<div key={index} className="border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h4 className="font-medium">매핑 #{index + 1}</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
DB 컬럼: {mapping.from_column_name} → API 필드: {mapping.to_column_name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>DB 컬럼명</Label>
|
||||
<Input value={mapping.from_column_name || ''} readOnly />
|
||||
</div>
|
||||
<div>
|
||||
<Label>API 필드명</Label>
|
||||
<Input value={mapping.to_column_name || ''} readOnly />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mapping.to_api_body && (
|
||||
<div className="mt-4">
|
||||
<Label>Request Body 템플릿</Label>
|
||||
<Textarea
|
||||
value={mapping.to_api_body}
|
||||
readOnly
|
||||
rows={3}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 저장 버튼 */}
|
||||
<div className="flex justify-end space-x-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push("/admin/batchmng")}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={saveBatchConfig}
|
||||
disabled={loading || mappings.length === 0}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
{loading ? (
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-4 w-4" />
|
||||
)}
|
||||
<span>{loading ? "저장 중..." : "배치 설정 저장"}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -23,7 +23,8 @@ import {
|
|||
RefreshCw,
|
||||
Clock,
|
||||
Database,
|
||||
ArrowRight
|
||||
ArrowRight,
|
||||
Globe
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
|
@ -43,6 +44,7 @@ export default function BatchManagementPage() {
|
|||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [executingBatch, setExecutingBatch] = useState<number | null>(null);
|
||||
const [isBatchTypeModalOpen, setIsBatchTypeModalOpen] = useState(false);
|
||||
|
||||
// 페이지 로드 시 배치 목록 조회
|
||||
useEffect(() => {
|
||||
|
|
@ -96,13 +98,19 @@ export default function BatchManagementPage() {
|
|||
|
||||
// 배치 활성화/비활성화 토글
|
||||
const toggleBatchStatus = async (batchId: number, currentStatus: string) => {
|
||||
console.log("🔄 배치 상태 변경 시작:", { batchId, currentStatus });
|
||||
|
||||
try {
|
||||
const newStatus = currentStatus === 'Y' ? 'N' : 'Y';
|
||||
await BatchAPI.updateBatchConfig(batchId, { isActive: newStatus });
|
||||
console.log("📝 새로운 상태:", newStatus);
|
||||
|
||||
const result = await BatchAPI.updateBatchConfig(batchId, { isActive: newStatus });
|
||||
console.log("✅ API 호출 성공:", result);
|
||||
|
||||
toast.success(`배치가 ${newStatus === 'Y' ? '활성화' : '비활성화'}되었습니다.`);
|
||||
loadBatchConfigs(); // 목록 새로고침
|
||||
} catch (error) {
|
||||
console.error("배치 상태 변경 실패:", error);
|
||||
console.error("❌ 배치 상태 변경 실패:", error);
|
||||
toast.error("배치 상태 변경에 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
|
@ -148,6 +156,34 @@ export default function BatchManagementPage() {
|
|||
return summaries.join(", ");
|
||||
};
|
||||
|
||||
// 배치 추가 버튼 클릭 핸들러
|
||||
const handleCreateBatch = () => {
|
||||
setIsBatchTypeModalOpen(true);
|
||||
};
|
||||
|
||||
// 배치 타입 선택 핸들러
|
||||
const handleBatchTypeSelect = (type: 'db-to-db' | 'restapi-to-db') => {
|
||||
console.log("배치 타입 선택:", type);
|
||||
setIsBatchTypeModalOpen(false);
|
||||
|
||||
if (type === 'db-to-db') {
|
||||
// 기존 DB → DB 배치 생성 페이지로 이동
|
||||
console.log("DB → DB 페이지로 이동:", '/admin/batchmng/create');
|
||||
router.push('/admin/batchmng/create');
|
||||
} else if (type === 'restapi-to-db') {
|
||||
// 새로운 REST API 배치 페이지로 이동
|
||||
console.log("REST API → DB 페이지로 이동:", '/admin/batch-management-new');
|
||||
try {
|
||||
router.push('/admin/batch-management-new');
|
||||
console.log("라우터 push 실행 완료");
|
||||
} catch (error) {
|
||||
console.error("라우터 push 오류:", error);
|
||||
// 대안: window.location 사용
|
||||
window.location.href = '/admin/batch-management-new';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6 space-y-6">
|
||||
{/* 헤더 */}
|
||||
|
|
@ -157,7 +193,7 @@ export default function BatchManagementPage() {
|
|||
<p className="text-muted-foreground">데이터베이스 간 배치 작업을 관리합니다.</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => router.push("/admin/batchmng/create")}
|
||||
onClick={handleCreateBatch}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
|
|
@ -209,7 +245,7 @@ export default function BatchManagementPage() {
|
|||
</p>
|
||||
{!searchTerm && (
|
||||
<Button
|
||||
onClick={() => router.push("/admin/batchmng/create")}
|
||||
onClick={handleCreateBatch}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
|
|
@ -264,7 +300,10 @@ export default function BatchManagementPage() {
|
|||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => toggleBatchStatus(batch.id, batch.is_active)}
|
||||
onClick={() => {
|
||||
console.log("🖱️ 비활성화/활성화 버튼 클릭:", { batchId: batch.id, currentStatus: batch.is_active });
|
||||
toggleBatchStatus(batch.id, batch.is_active);
|
||||
}}
|
||||
className="flex items-center space-x-1"
|
||||
>
|
||||
{batch.is_active === 'Y' ? (
|
||||
|
|
@ -351,6 +390,61 @@ export default function BatchManagementPage() {
|
|||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 배치 타입 선택 모달 */}
|
||||
{isBatchTypeModalOpen && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<Card className="w-full max-w-2xl mx-4">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-center">배치 타입 선택</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* DB → DB */}
|
||||
<div
|
||||
className="p-6 border rounded-lg cursor-pointer transition-all hover:border-blue-500 hover:bg-blue-50"
|
||||
onClick={() => handleBatchTypeSelect('db-to-db')}
|
||||
>
|
||||
<div className="flex items-center justify-center mb-4">
|
||||
<Database className="w-8 h-8 text-blue-600 mr-2" />
|
||||
<ArrowRight className="w-6 h-6 text-gray-400 mr-2" />
|
||||
<Database className="w-8 h-8 text-blue-600" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="font-medium text-lg mb-2">DB → DB</div>
|
||||
<div className="text-sm text-gray-500">데이터베이스 간 데이터 동기화</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* REST API → DB */}
|
||||
<div
|
||||
className="p-6 border rounded-lg cursor-pointer transition-all hover:border-green-500 hover:bg-green-50"
|
||||
onClick={() => handleBatchTypeSelect('restapi-to-db')}
|
||||
>
|
||||
<div className="flex items-center justify-center mb-4">
|
||||
<Globe className="w-8 h-8 text-green-600 mr-2" />
|
||||
<ArrowRight className="w-6 h-6 text-gray-400 mr-2" />
|
||||
<Database className="w-8 h-8 text-green-600" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="font-medium text-lg mb-2">REST API → DB</div>
|
||||
<div className="text-sm text-gray-500">REST API에서 데이터베이스로 데이터 수집</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsBatchTypeModalOpen(false)}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -23,7 +23,7 @@ import { Switch } from "@/components/ui/switch";
|
|||
import { Badge } from "@/components/ui/badge";
|
||||
import { toast } from "sonner";
|
||||
import { BatchAPI, BatchJob } from "@/lib/api/batch";
|
||||
import { CollectionAPI } from "@/lib/api/collection";
|
||||
// import { CollectionAPI } from "@/lib/api/collection"; // 사용하지 않는 import 제거
|
||||
|
||||
interface BatchJobModalProps {
|
||||
isOpen: boolean;
|
||||
|
|
@ -101,12 +101,14 @@ export default function BatchJobModal({
|
|||
|
||||
const loadCollectionConfigs = async () => {
|
||||
try {
|
||||
const configs = await CollectionAPI.getCollectionConfigs({
|
||||
// 배치 설정 조회로 대체
|
||||
const configs = await BatchAPI.getBatchConfigs({
|
||||
is_active: "Y",
|
||||
});
|
||||
setCollectionConfigs(configs);
|
||||
setCollectionConfigs(configs.data || []);
|
||||
} catch (error) {
|
||||
console.error("수집 설정 조회 오류:", error);
|
||||
console.error("배치 설정 조회 오류:", error);
|
||||
setCollectionConfigs([]);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -47,6 +47,20 @@ export interface BatchConfigFilter {
|
|||
search?: string;
|
||||
}
|
||||
|
||||
export interface BatchJob {
|
||||
id: number;
|
||||
job_name: string;
|
||||
job_type: string;
|
||||
description?: string;
|
||||
cron_schedule: string;
|
||||
is_active: string;
|
||||
last_execution?: Date;
|
||||
next_execution?: Date;
|
||||
status?: string;
|
||||
created_date?: Date;
|
||||
created_by?: string;
|
||||
}
|
||||
|
||||
export interface ConnectionInfo {
|
||||
type: 'internal' | 'external';
|
||||
id?: number;
|
||||
|
|
@ -83,7 +97,7 @@ export interface ApiResponse<T> {
|
|||
}
|
||||
|
||||
export class BatchAPI {
|
||||
private static readonly BASE_PATH = "/batch-management";
|
||||
private static readonly BASE_PATH = "";
|
||||
|
||||
/**
|
||||
* 배치 설정 목록 조회
|
||||
|
|
@ -118,7 +132,7 @@ export class BatchAPI {
|
|||
totalPages: number;
|
||||
};
|
||||
message?: string;
|
||||
}>(`${this.BASE_PATH}/batch-configs?${params.toString()}`);
|
||||
}>(`/batch-configs?${params.toString()}`);
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
|
|
@ -131,13 +145,20 @@ export class BatchAPI {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 배치 설정 조회 (별칭)
|
||||
*/
|
||||
static async getBatchConfig(id: number): Promise<BatchConfig> {
|
||||
return this.getBatchConfigById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 배치 설정 조회
|
||||
*/
|
||||
static async getBatchConfigById(id: number): Promise<BatchConfig> {
|
||||
try {
|
||||
const response = await apiClient.get<ApiResponse<BatchConfig>>(
|
||||
`${this.BASE_PATH}/batch-configs/${id}`,
|
||||
`/batch-management/batch-configs/${id}`,
|
||||
);
|
||||
|
||||
if (!response.data.success) {
|
||||
|
|
@ -161,7 +182,7 @@ export class BatchAPI {
|
|||
static async createBatchConfig(data: BatchMappingRequest): Promise<BatchConfig> {
|
||||
try {
|
||||
const response = await apiClient.post<ApiResponse<BatchConfig>>(
|
||||
`${this.BASE_PATH}/batch-configs`,
|
||||
`/batch-configs`,
|
||||
data,
|
||||
);
|
||||
|
||||
|
|
@ -189,7 +210,7 @@ export class BatchAPI {
|
|||
): Promise<BatchConfig> {
|
||||
try {
|
||||
const response = await apiClient.put<ApiResponse<BatchConfig>>(
|
||||
`${this.BASE_PATH}/batch-configs/${id}`,
|
||||
`/batch-management/batch-configs/${id}`,
|
||||
data,
|
||||
);
|
||||
|
||||
|
|
@ -214,7 +235,7 @@ export class BatchAPI {
|
|||
static async deleteBatchConfig(id: number): Promise<void> {
|
||||
try {
|
||||
const response = await apiClient.delete<ApiResponse<void>>(
|
||||
`${this.BASE_PATH}/batch-configs/${id}`,
|
||||
`/batch-configs/${id}`,
|
||||
);
|
||||
|
||||
if (!response.data.success) {
|
||||
|
|
@ -232,10 +253,10 @@ export class BatchAPI {
|
|||
static async getConnections(): Promise<ConnectionInfo[]> {
|
||||
try {
|
||||
console.log("[BatchAPI] getAvailableConnections 호출 시작");
|
||||
console.log("[BatchAPI] API URL:", `${this.BASE_PATH}/connections`);
|
||||
console.log("[BatchAPI] API URL:", `/batch-management/connections`);
|
||||
|
||||
const response = await apiClient.get<ApiResponse<ConnectionInfo[]>>(
|
||||
`${this.BASE_PATH}/connections`,
|
||||
`/batch-management/connections`,
|
||||
);
|
||||
|
||||
console.log("[BatchAPI] API 응답:", response);
|
||||
|
|
@ -263,13 +284,12 @@ export class BatchAPI {
|
|||
* 특정 커넥션의 테이블 목록 조회
|
||||
*/
|
||||
static async getTablesFromConnection(
|
||||
connectionType: 'internal' | 'external',
|
||||
connectionId?: number
|
||||
connection: ConnectionInfo
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
let url = `${this.BASE_PATH}/connections/${connectionType}`;
|
||||
if (connectionType === 'external' && connectionId) {
|
||||
url += `/${connectionId}`;
|
||||
let url = `/batch-management/connections/${connection.type}`;
|
||||
if (connection.type === 'external' && connection.id) {
|
||||
url += `/${connection.id}`;
|
||||
}
|
||||
url += '/tables';
|
||||
|
||||
|
|
@ -292,14 +312,13 @@ export class BatchAPI {
|
|||
* 특정 테이블의 컬럼 정보 조회
|
||||
*/
|
||||
static async getTableColumns(
|
||||
connectionType: 'internal' | 'external',
|
||||
connectionId: number | undefined,
|
||||
connection: ConnectionInfo,
|
||||
tableName: string
|
||||
): Promise<ColumnInfo[]> {
|
||||
try {
|
||||
let url = `${this.BASE_PATH}/connections/${connectionType}`;
|
||||
if (connectionType === 'external' && connectionId) {
|
||||
url += `/${connectionId}`;
|
||||
let url = `/batch-management/connections/${connection.type}`;
|
||||
if (connection.type === 'external' && connection.id) {
|
||||
url += `/${connection.id}`;
|
||||
}
|
||||
url += `/tables/${tableName}/columns`;
|
||||
|
||||
|
|
@ -316,6 +335,24 @@ export class BatchAPI {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 작업 목록 조회
|
||||
*/
|
||||
static async getBatchJobs(): Promise<BatchJob[]> {
|
||||
try {
|
||||
const response = await apiClient.get<ApiResponse<BatchJob[]>>('/batch-management/jobs');
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.message || "배치 작업 목록 조회에 실패했습니다.");
|
||||
}
|
||||
|
||||
return response.data.data || [];
|
||||
} catch (error) {
|
||||
console.error("배치 작업 목록 조회 오류:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 수동 실행
|
||||
*/
|
||||
|
|
@ -341,7 +378,7 @@ export class BatchAPI {
|
|||
failedRecords: number;
|
||||
duration: number;
|
||||
};
|
||||
}>(`${this.BASE_PATH}/batch-configs/${batchId}/execute`);
|
||||
}>(`/batch-management/batch-configs/${batchId}/execute`);
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
|
|
@ -350,3 +387,6 @@ export class BatchAPI {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BatchJob export 추가 (이미 위에서 interface로 정의됨)
|
||||
export { BatchJob };
|
||||
|
|
|
|||
|
|
@ -31,8 +31,8 @@ export interface BatchApiResponse<T = unknown> {
|
|||
error?: string;
|
||||
}
|
||||
|
||||
export const BatchManagementAPI = {
|
||||
BASE_PATH: "/api/batch-management",
|
||||
class BatchManagementAPIClass {
|
||||
private static readonly BASE_PATH = "/batch-management";
|
||||
|
||||
/**
|
||||
* 사용 가능한 커넥션 목록 조회
|
||||
|
|
@ -52,7 +52,7 @@ export const BatchManagementAPI = {
|
|||
console.error("커넥션 목록 조회 오류:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 커넥션의 테이블 목록 조회
|
||||
|
|
@ -68,20 +68,18 @@ export const BatchManagementAPI = {
|
|||
}
|
||||
url += '/tables';
|
||||
|
||||
const response = await apiClient.get<BatchApiResponse<BatchTableInfo[]>>(url);
|
||||
const response = await apiClient.get<BatchApiResponse<string[]>>(url);
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.message || "테이블 목록 조회에 실패했습니다.");
|
||||
}
|
||||
|
||||
// BatchTableInfo[]에서 table_name만 추출하여 string[]로 변환
|
||||
const tables = response.data.data || [];
|
||||
return tables.map(table => table.table_name);
|
||||
return response.data.data || [];
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 조회 오류:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 테이블의 컬럼 정보 조회
|
||||
|
|
@ -96,19 +94,85 @@ export const BatchManagementAPI = {
|
|||
if (connectionType === 'external' && connectionId) {
|
||||
url += `/${connectionId}`;
|
||||
}
|
||||
url += `/tables/${tableName}/columns`;
|
||||
url += `/tables/${encodeURIComponent(tableName)}/columns`;
|
||||
|
||||
console.log("🔍 컬럼 조회 API 호출:", { url, connectionType, connectionId, tableName });
|
||||
|
||||
const response = await apiClient.get<BatchApiResponse<BatchColumnInfo[]>>(url);
|
||||
|
||||
console.log("🔍 컬럼 조회 API 응답:", response.data);
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.message || "컬럼 정보 조회에 실패했습니다.");
|
||||
}
|
||||
|
||||
return response.data.data || [];
|
||||
} catch (error) {
|
||||
console.error("컬럼 정보 조회 오류:", error);
|
||||
console.error("❌ 컬럼 정보 조회 오류:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* REST API 데이터 미리보기
|
||||
*/
|
||||
static async previewRestApiData(
|
||||
apiUrl: string,
|
||||
apiKey: string,
|
||||
endpoint: string,
|
||||
method: 'GET' = 'GET'
|
||||
): 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`, {
|
||||
apiUrl,
|
||||
apiKey,
|
||||
endpoint,
|
||||
method
|
||||
});
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.message || "REST API 미리보기에 실패했습니다.");
|
||||
}
|
||||
|
||||
return response.data.data || { fields: [], samples: [], totalCount: 0 };
|
||||
} catch (error) {
|
||||
console.error("REST API 미리보기 오류:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* REST API 배치 저장
|
||||
*/
|
||||
static async saveRestApiBatch(batchData: {
|
||||
batchName: string;
|
||||
batchType: string;
|
||||
cronSchedule: string;
|
||||
description?: string;
|
||||
apiMappings: any[];
|
||||
}): Promise<{ success: boolean; message: string; data?: any; }> {
|
||||
try {
|
||||
const response = await apiClient.post<BatchApiResponse<any>>(
|
||||
`${this.BASE_PATH}/rest-api/save`, batchData
|
||||
);
|
||||
return {
|
||||
success: response.data.success,
|
||||
message: response.data.message || "",
|
||||
data: response.data.data
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("REST API 배치 저장 오류:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const BatchManagementAPI = BatchManagementAPIClass;
|
||||
Loading…
Reference in New Issue