REST API→DB 토큰 배치 및 auth_tokens 저장 구현
This commit is contained in:
parent
ed56e14aa2
commit
707328e765
|
|
@ -169,22 +169,18 @@ export class BatchController {
|
|||
static async getBatchConfigById(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
const batchConfig = await BatchService.getBatchConfigById(
|
||||
Number(id),
|
||||
userCompanyCode
|
||||
);
|
||||
const result = await BatchService.getBatchConfigById(Number(id));
|
||||
|
||||
if (!batchConfig) {
|
||||
if (!result.success || !result.data) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "배치 설정을 찾을 수 없습니다.",
|
||||
message: result.message || "배치 설정을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: batchConfig,
|
||||
data: result.data,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("배치 설정 조회 오류:", error);
|
||||
|
|
|
|||
|
|
@ -62,6 +62,11 @@ export class BatchExecutionLogController {
|
|||
try {
|
||||
const data: CreateBatchExecutionLogRequest = req.body;
|
||||
|
||||
// 멀티테넌시: company_code가 없으면 현재 사용자 회사 코드로 설정
|
||||
if (!data.company_code) {
|
||||
data.company_code = req.user?.companyCode || "*";
|
||||
}
|
||||
|
||||
const result = await BatchExecutionLogService.createExecutionLog(data);
|
||||
|
||||
if (result.success) {
|
||||
|
|
|
|||
|
|
@ -265,8 +265,12 @@ export class BatchManagementController {
|
|||
|
||||
try {
|
||||
// 실행 로그 생성
|
||||
executionLog = await BatchService.createExecutionLog({
|
||||
const { BatchExecutionLogService } = await import(
|
||||
"../services/batchExecutionLogService"
|
||||
);
|
||||
const logResult = await BatchExecutionLogService.createExecutionLog({
|
||||
batch_config_id: Number(id),
|
||||
company_code: batchConfig.company_code,
|
||||
execution_status: "RUNNING",
|
||||
start_time: startTime,
|
||||
total_records: 0,
|
||||
|
|
@ -274,6 +278,14 @@ export class BatchManagementController {
|
|||
failed_records: 0,
|
||||
});
|
||||
|
||||
if (!logResult.success || !logResult.data) {
|
||||
throw new Error(
|
||||
logResult.message || "배치 실행 로그를 생성할 수 없습니다."
|
||||
);
|
||||
}
|
||||
|
||||
executionLog = logResult.data;
|
||||
|
||||
// BatchSchedulerService의 executeBatchConfig 메서드 사용 (중복 로직 제거)
|
||||
const { BatchSchedulerService } = await import(
|
||||
"../services/batchSchedulerService"
|
||||
|
|
@ -290,7 +302,7 @@ export class BatchManagementController {
|
|||
const duration = endTime.getTime() - startTime.getTime();
|
||||
|
||||
// 실행 로그 업데이트 (성공)
|
||||
await BatchService.updateExecutionLog(executionLog.id, {
|
||||
await BatchExecutionLogService.updateExecutionLog(executionLog.id, {
|
||||
execution_status: "SUCCESS",
|
||||
end_time: endTime,
|
||||
duration_ms: duration,
|
||||
|
|
@ -406,22 +418,34 @@ export class BatchManagementController {
|
|||
paramName,
|
||||
paramValue,
|
||||
paramSource,
|
||||
requestBody,
|
||||
} = req.body;
|
||||
|
||||
if (!apiUrl || !apiKey || !endpoint) {
|
||||
// apiUrl, endpoint는 항상 필수
|
||||
if (!apiUrl || !endpoint) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "API URL, API Key, 엔드포인트는 필수입니다.",
|
||||
message: "API URL과 엔드포인트는 필수입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// GET 요청일 때만 API Key 필수 (POST/PUT/DELETE는 선택)
|
||||
if ((!method || method === "GET") && !apiKey) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "GET 메서드에서는 API Key가 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
console.log("🔍 REST API 미리보기 요청:", {
|
||||
apiUrl,
|
||||
endpoint,
|
||||
method,
|
||||
paramType,
|
||||
paramName,
|
||||
paramValue,
|
||||
paramSource,
|
||||
requestBody: requestBody ? "Included" : "None",
|
||||
});
|
||||
|
||||
// RestApiConnector 사용하여 데이터 조회
|
||||
|
|
@ -429,7 +453,7 @@ export class BatchManagementController {
|
|||
|
||||
const connector = new RestApiConnector({
|
||||
baseUrl: apiUrl,
|
||||
apiKey: apiKey,
|
||||
apiKey: apiKey || "",
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
|
|
@ -456,9 +480,28 @@ export class BatchManagementController {
|
|||
|
||||
console.log("🔗 최종 엔드포인트:", finalEndpoint);
|
||||
|
||||
// 데이터 조회 (최대 5개만) - GET 메서드만 지원
|
||||
const result = await connector.executeQuery(finalEndpoint, method);
|
||||
console.log(`[previewRestApiData] executeQuery 결과:`, {
|
||||
// Request Body 파싱
|
||||
let parsedBody = undefined;
|
||||
if (requestBody && typeof requestBody === "string") {
|
||||
try {
|
||||
parsedBody = JSON.parse(requestBody);
|
||||
} catch (e) {
|
||||
console.warn("Request Body JSON 파싱 실패:", e);
|
||||
// 파싱 실패 시 원본 문자열 사용하거나 무시 (상황에 따라 결정, 여기선 undefined로 처리하거나 에러 반환 가능)
|
||||
// 여기서는 경고 로그 남기고 진행
|
||||
}
|
||||
} else if (requestBody) {
|
||||
parsedBody = requestBody;
|
||||
}
|
||||
|
||||
// 데이터 조회 - executeRequest 사용 (POST/PUT/DELETE 지원)
|
||||
const result = await connector.executeRequest(
|
||||
finalEndpoint,
|
||||
method as "GET" | "POST" | "PUT" | "DELETE",
|
||||
parsedBody
|
||||
);
|
||||
|
||||
console.log(`[previewRestApiData] executeRequest 결과:`, {
|
||||
rowCount: result.rowCount,
|
||||
rowsLength: result.rows ? result.rows.length : "undefined",
|
||||
firstRow:
|
||||
|
|
@ -532,15 +575,21 @@ export class BatchManagementController {
|
|||
apiMappings,
|
||||
});
|
||||
|
||||
// 🔐 멀티테넌시: 현재 사용자 회사 코드 사용 (프론트에서 받지 않음)
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const userId = req.user?.userId;
|
||||
|
||||
// BatchService를 사용하여 배치 설정 저장
|
||||
const batchConfig: CreateBatchConfigRequest = {
|
||||
batchName: batchName,
|
||||
description: description || "",
|
||||
cronSchedule: cronSchedule,
|
||||
isActive: "Y",
|
||||
companyCode,
|
||||
mappings: apiMappings,
|
||||
};
|
||||
|
||||
const result = await BatchService.createBatchConfig(batchConfig);
|
||||
const result = await BatchService.createBatchConfig(batchConfig, userId);
|
||||
|
||||
if (result.success && result.data) {
|
||||
// 스케줄러에 자동 등록 ✅
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import axios, { AxiosInstance, AxiosResponse } from "axios";
|
||||
import https from "https";
|
||||
import {
|
||||
DatabaseConnector,
|
||||
ConnectionConfig,
|
||||
|
|
@ -24,16 +25,26 @@ export class RestApiConnector implements DatabaseConnector {
|
|||
|
||||
constructor(config: RestApiConfig) {
|
||||
this.config = config;
|
||||
|
||||
// Axios 인스턴스 생성
|
||||
// 🔐 apiKey가 없을 수도 있으므로 Authorization 헤더는 선택적으로만 추가
|
||||
const defaultHeaders: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
};
|
||||
|
||||
if (config.apiKey) {
|
||||
defaultHeaders["Authorization"] = `Bearer ${config.apiKey}`;
|
||||
}
|
||||
|
||||
this.httpClient = axios.create({
|
||||
baseURL: config.baseUrl,
|
||||
timeout: config.timeout || 30000,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${config.apiKey}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
headers: defaultHeaders,
|
||||
// ⚠️ 외부 API 중 자체 서명 인증서를 사용하는 경우가 있어서
|
||||
// 인증서 검증을 끈 HTTPS 에이전트를 사용한다.
|
||||
// 내부망/신뢰된 시스템 전용으로 사용해야 하며,
|
||||
// 공개 인터넷용 API에는 적용하면 안 된다.
|
||||
httpsAgent: new https.Agent({ rejectUnauthorized: false }),
|
||||
});
|
||||
|
||||
// 요청/응답 인터셉터 설정
|
||||
|
|
@ -75,26 +86,16 @@ export class RestApiConnector implements DatabaseConnector {
|
|||
}
|
||||
|
||||
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 : "알 수 없는 오류"}`
|
||||
);
|
||||
}
|
||||
// 기존에는 /health 엔드포인트를 호출해서 미리 연결을 검사했지만,
|
||||
// 일반 외부 API들은 /health가 없거나 401/500을 반환하는 경우가 많아
|
||||
// 불필요하게 예외가 나면서 미리보기/배치 실행이 막히는 문제가 있었다.
|
||||
//
|
||||
// 따라서 여기서는 "연결 준비 완료" 정도만 로그로 남기고
|
||||
// 실제 호출 실패 여부는 executeRequest 단계에서만 판단하도록 한다.
|
||||
console.log(
|
||||
`[RestApiConnector] 연결 준비 완료 (사전 헬스체크 생략): ${this.config.baseUrl}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -130,13 +130,14 @@ export class BatchExecutionLogService {
|
|||
try {
|
||||
const log = await queryOne<BatchExecutionLog>(
|
||||
`INSERT INTO batch_execution_logs (
|
||||
batch_config_id, execution_status, start_time, end_time,
|
||||
batch_config_id, company_code, execution_status, start_time, end_time,
|
||||
duration_ms, total_records, success_records, failed_records,
|
||||
error_message, error_details, server_name, process_id
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
RETURNING *`,
|
||||
[
|
||||
data.batch_config_id,
|
||||
data.company_code,
|
||||
data.execution_status,
|
||||
data.start_time || new Date(),
|
||||
data.end_time,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,258 +1,114 @@
|
|||
// 배치 스케줄러 서비스
|
||||
// 작성일: 2024-12-24
|
||||
|
||||
import * as cron from "node-cron";
|
||||
import { query, queryOne } from "../database/db";
|
||||
import cron from "node-cron";
|
||||
import { BatchService } from "./batchService";
|
||||
import { BatchExecutionLogService } from "./batchExecutionLogService";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
export class BatchSchedulerService {
|
||||
private static scheduledTasks: Map<number, cron.ScheduledTask> = new Map();
|
||||
private static isInitialized = false;
|
||||
private static executingBatches: Set<number> = new Set(); // 실행 중인 배치 추적
|
||||
|
||||
/**
|
||||
* 스케줄러 초기화
|
||||
* 모든 활성 배치의 스케줄링 초기화
|
||||
*/
|
||||
static async initialize() {
|
||||
static async initializeScheduler() {
|
||||
try {
|
||||
logger.info("배치 스케줄러 초기화 시작...");
|
||||
logger.info("배치 스케줄러 초기화 시작");
|
||||
|
||||
// 기존 모든 스케줄 정리 (중복 방지)
|
||||
this.clearAllSchedules();
|
||||
const batchConfigsResponse = await BatchService.getBatchConfigs({
|
||||
is_active: "Y",
|
||||
});
|
||||
|
||||
// 활성화된 배치 설정들을 로드하여 스케줄 등록
|
||||
await this.loadActiveBatchConfigs();
|
||||
|
||||
this.isInitialized = true;
|
||||
logger.info("배치 스케줄러 초기화 완료");
|
||||
} catch (error) {
|
||||
logger.error("배치 스케줄러 초기화 실패:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 스케줄 정리
|
||||
*/
|
||||
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("모든 스케줄 정리 완료");
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성화된 배치 설정들을 로드하여 스케줄 등록
|
||||
*/
|
||||
private static async loadActiveBatchConfigs() {
|
||||
try {
|
||||
const activeConfigs = await query<any>(
|
||||
`SELECT
|
||||
bc.*,
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'id', bm.id,
|
||||
'batch_config_id', bm.batch_config_id,
|
||||
'from_connection_type', bm.from_connection_type,
|
||||
'from_connection_id', bm.from_connection_id,
|
||||
'from_table_name', bm.from_table_name,
|
||||
'from_column_name', bm.from_column_name,
|
||||
'from_column_type', bm.from_column_type,
|
||||
'to_connection_type', bm.to_connection_type,
|
||||
'to_connection_id', bm.to_connection_id,
|
||||
'to_table_name', bm.to_table_name,
|
||||
'to_column_name', bm.to_column_name,
|
||||
'to_column_type', bm.to_column_type,
|
||||
'mapping_order', bm.mapping_order,
|
||||
'from_api_url', bm.from_api_url,
|
||||
'from_api_key', bm.from_api_key,
|
||||
'from_api_method', bm.from_api_method,
|
||||
'from_api_param_type', bm.from_api_param_type,
|
||||
'from_api_param_name', bm.from_api_param_name,
|
||||
'from_api_param_value', bm.from_api_param_value,
|
||||
'from_api_param_source', bm.from_api_param_source,
|
||||
'to_api_url', bm.to_api_url,
|
||||
'to_api_key', bm.to_api_key,
|
||||
'to_api_method', bm.to_api_method,
|
||||
'to_api_body', bm.to_api_body
|
||||
)
|
||||
) FILTER (WHERE bm.id IS NOT NULL) as batch_mappings
|
||||
FROM batch_configs bc
|
||||
LEFT JOIN batch_mappings bm ON bc.id = bm.batch_config_id
|
||||
WHERE bc.is_active = 'Y'
|
||||
GROUP BY bc.id`,
|
||||
[]
|
||||
);
|
||||
|
||||
logger.info(`활성화된 배치 설정 ${activeConfigs.length}개 발견`);
|
||||
|
||||
for (const config of activeConfigs) {
|
||||
await this.scheduleBatchConfig(config);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("활성화된 배치 설정 로드 실패:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 설정을 스케줄에 등록
|
||||
*/
|
||||
static async scheduleBatchConfig(config: any) {
|
||||
try {
|
||||
const { id, batch_name, cron_schedule } = config;
|
||||
|
||||
// 기존 스케줄이 있다면 제거
|
||||
if (this.scheduledTasks.has(id)) {
|
||||
this.scheduledTasks.get(id)?.stop();
|
||||
this.scheduledTasks.delete(id);
|
||||
}
|
||||
|
||||
// cron 스케줄 유효성 검사
|
||||
if (!cron.validate(cron_schedule)) {
|
||||
logger.error(`잘못된 cron 스케줄: ${cron_schedule} (배치 ID: ${id})`);
|
||||
if (!batchConfigsResponse.success || !batchConfigsResponse.data) {
|
||||
logger.warn("스케줄링할 활성 배치 설정이 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 새로운 스케줄 등록
|
||||
const task = cron.schedule(cron_schedule, async () => {
|
||||
// 중복 실행 방지 체크
|
||||
if (this.executingBatches.has(id)) {
|
||||
logger.warn(
|
||||
`⚠️ 배치가 이미 실행 중입니다. 건너뜀: ${batch_name} (ID: ${id})`
|
||||
);
|
||||
return;
|
||||
}
|
||||
const batchConfigs = batchConfigsResponse.data;
|
||||
logger.info(`${batchConfigs.length}개의 배치 설정 스케줄링 등록`);
|
||||
|
||||
logger.info(`🔄 스케줄 배치 실행 시작: ${batch_name} (ID: ${id})`);
|
||||
for (const config of batchConfigs) {
|
||||
await this.scheduleBatch(config);
|
||||
}
|
||||
|
||||
// 실행 중 플래그 설정
|
||||
this.executingBatches.add(id);
|
||||
logger.info("배치 스케줄러 초기화 완료");
|
||||
} catch (error) {
|
||||
logger.error("배치 스케줄러 초기화 중 오류 발생:", error);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await this.executeBatchConfig(config);
|
||||
} finally {
|
||||
// 실행 완료 후 플래그 제거
|
||||
this.executingBatches.delete(id);
|
||||
}
|
||||
/**
|
||||
* 개별 배치 작업 스케줄링
|
||||
*/
|
||||
static async scheduleBatch(config: any) {
|
||||
try {
|
||||
// 기존 스케줄이 있으면 제거
|
||||
if (this.scheduledTasks.has(config.id)) {
|
||||
this.scheduledTasks.get(config.id)?.stop();
|
||||
this.scheduledTasks.delete(config.id);
|
||||
}
|
||||
|
||||
if (config.is_active !== "Y") {
|
||||
logger.info(
|
||||
`배치 스케줄링 건너뜀 (비활성 상태): ${config.batch_name} (ID: ${config.id})`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!cron.validate(config.cron_schedule)) {
|
||||
logger.error(
|
||||
`유효하지 않은 Cron 표현식: ${config.cron_schedule} (Batch ID: ${config.id})`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`배치 스케줄 등록: ${config.batch_name} (ID: ${config.id}, Cron: ${config.cron_schedule})`
|
||||
);
|
||||
|
||||
const task = cron.schedule(config.cron_schedule, async () => {
|
||||
logger.info(
|
||||
`스케줄에 의한 배치 실행 시작: ${config.batch_name} (ID: ${config.id})`
|
||||
);
|
||||
await this.executeBatchConfig(config);
|
||||
});
|
||||
|
||||
// 스케줄 시작 (기본적으로 시작되지만 명시적으로 호출)
|
||||
task.start();
|
||||
|
||||
this.scheduledTasks.set(id, task);
|
||||
logger.info(
|
||||
`배치 스케줄 등록 완료: ${batch_name} (ID: ${id}, Schedule: ${cron_schedule}) - 스케줄 시작됨`
|
||||
);
|
||||
this.scheduledTasks.set(config.id, task);
|
||||
} catch (error) {
|
||||
logger.error(`배치 스케줄 등록 실패 (ID: ${config.id}):`, error);
|
||||
logger.error(`배치 스케줄링 중 오류 발생 (ID: ${config.id}):`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 설정 스케줄 제거
|
||||
*/
|
||||
static async unscheduleBatchConfig(batchConfigId: number) {
|
||||
try {
|
||||
if (this.scheduledTasks.has(batchConfigId)) {
|
||||
this.scheduledTasks.get(batchConfigId)?.stop();
|
||||
this.scheduledTasks.delete(batchConfigId);
|
||||
logger.info(`배치 스케줄 제거 완료 (ID: ${batchConfigId})`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`배치 스케줄 제거 실패 (ID: ${batchConfigId}):`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 설정 업데이트 시 스케줄 재등록
|
||||
* 배치 스케줄 업데이트 (설정 변경 시 호출)
|
||||
*/
|
||||
static async updateBatchSchedule(
|
||||
configId: number,
|
||||
executeImmediately: boolean = true
|
||||
) {
|
||||
try {
|
||||
// 기존 스케줄 제거
|
||||
await this.unscheduleBatchConfig(configId);
|
||||
|
||||
// 업데이트된 배치 설정 조회
|
||||
const configResult = await query<any>(
|
||||
`SELECT
|
||||
bc.*,
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'id', bm.id,
|
||||
'batch_config_id', bm.batch_config_id,
|
||||
'from_connection_type', bm.from_connection_type,
|
||||
'from_connection_id', bm.from_connection_id,
|
||||
'from_table_name', bm.from_table_name,
|
||||
'from_column_name', bm.from_column_name,
|
||||
'from_column_type', bm.from_column_type,
|
||||
'to_connection_type', bm.to_connection_type,
|
||||
'to_connection_id', bm.to_connection_id,
|
||||
'to_table_name', bm.to_table_name,
|
||||
'to_column_name', bm.to_column_name,
|
||||
'to_column_type', bm.to_column_type,
|
||||
'mapping_order', bm.mapping_order,
|
||||
'from_api_url', bm.from_api_url,
|
||||
'from_api_key', bm.from_api_key,
|
||||
'from_api_method', bm.from_api_method,
|
||||
'from_api_param_type', bm.from_api_param_type,
|
||||
'from_api_param_name', bm.from_api_param_name,
|
||||
'from_api_param_value', bm.from_api_param_value,
|
||||
'from_api_param_source', bm.from_api_param_source,
|
||||
'to_api_url', bm.to_api_url,
|
||||
'to_api_key', bm.to_api_key,
|
||||
'to_api_method', bm.to_api_method,
|
||||
'to_api_body', bm.to_api_body
|
||||
)
|
||||
) FILTER (WHERE bm.id IS NOT NULL) as batch_mappings
|
||||
FROM batch_configs bc
|
||||
LEFT JOIN batch_mappings bm ON bc.id = bm.batch_config_id
|
||||
WHERE bc.id = $1
|
||||
GROUP BY bc.id`,
|
||||
[configId]
|
||||
);
|
||||
|
||||
const config = configResult[0] || null;
|
||||
|
||||
if (!config) {
|
||||
logger.warn(`배치 설정을 찾을 수 없습니다: ID ${configId}`);
|
||||
const result = await BatchService.getBatchConfigById(configId);
|
||||
if (!result.success || !result.data) {
|
||||
// 설정이 없으면 스케줄 제거
|
||||
if (this.scheduledTasks.has(configId)) {
|
||||
this.scheduledTasks.get(configId)?.stop();
|
||||
this.scheduledTasks.delete(configId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 활성화된 배치만 다시 스케줄 등록
|
||||
if (config.is_active === "Y") {
|
||||
await this.scheduleBatchConfig(config);
|
||||
logger.info(
|
||||
`배치 스케줄 업데이트 완료: ${config.batch_name} (ID: ${configId})`
|
||||
);
|
||||
const config = result.data;
|
||||
|
||||
// 활성화 시 즉시 실행 (옵션)
|
||||
if (executeImmediately) {
|
||||
logger.info(
|
||||
`🚀 배치 활성화 즉시 실행: ${config.batch_name} (ID: ${configId})`
|
||||
);
|
||||
await this.executeBatchConfig(config);
|
||||
}
|
||||
} else {
|
||||
logger.info(
|
||||
`비활성화된 배치 스케줄 제거: ${config.batch_name} (ID: ${configId})`
|
||||
// 스케줄 재등록
|
||||
await this.scheduleBatch(config);
|
||||
|
||||
// 즉시 실행 옵션이 있으면 실행
|
||||
/*
|
||||
if (executeImmediately && config.is_active === "Y") {
|
||||
logger.info(`배치 설정 변경 후 즉시 실행: ${config.batch_name}`);
|
||||
this.executeBatchConfig(config).catch((err) =>
|
||||
logger.error(`즉시 실행 중 오류 발생:`, err)
|
||||
);
|
||||
}
|
||||
*/
|
||||
} catch (error) {
|
||||
logger.error(`배치 스케줄 업데이트 실패: ID ${configId}`, error);
|
||||
}
|
||||
|
|
@ -272,6 +128,7 @@ export class BatchSchedulerService {
|
|||
const executionLogResponse =
|
||||
await BatchExecutionLogService.createExecutionLog({
|
||||
batch_config_id: config.id,
|
||||
company_code: config.company_code,
|
||||
execution_status: "RUNNING",
|
||||
start_time: startTime,
|
||||
total_records: 0,
|
||||
|
|
@ -313,21 +170,20 @@ export class BatchSchedulerService {
|
|||
// 성공 결과 반환
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error(`배치 실행 실패: ${config.batch_name}`, error);
|
||||
logger.error(`배치 실행 중 오류 발생: ${config.batch_name}`, error);
|
||||
|
||||
// 실행 로그 업데이트 (실패)
|
||||
if (executionLog) {
|
||||
await BatchExecutionLogService.updateExecutionLog(executionLog.id, {
|
||||
execution_status: "FAILED",
|
||||
execution_status: "FAILURE",
|
||||
end_time: new Date(),
|
||||
duration_ms: Date.now() - startTime.getTime(),
|
||||
error_message:
|
||||
error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
error_details: error instanceof Error ? error.stack : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
// 실패 시에도 결과 반환
|
||||
// 실패 결과 반환
|
||||
return {
|
||||
totalRecords: 0,
|
||||
successRecords: 0,
|
||||
|
|
@ -379,6 +235,8 @@ export class BatchSchedulerService {
|
|||
const { BatchExternalDbService } = await import(
|
||||
"./batchExternalDbService"
|
||||
);
|
||||
|
||||
// 👇 Body 파라미터 추가 (POST 요청 시)
|
||||
const apiResult = await BatchExternalDbService.getDataFromRestApi(
|
||||
firstMapping.from_api_url!,
|
||||
firstMapping.from_api_key!,
|
||||
|
|
@ -394,7 +252,9 @@ export class BatchSchedulerService {
|
|||
firstMapping.from_api_param_type,
|
||||
firstMapping.from_api_param_name,
|
||||
firstMapping.from_api_param_value,
|
||||
firstMapping.from_api_param_source
|
||||
firstMapping.from_api_param_source,
|
||||
// 👇 Body 전달 (FROM - REST API - POST 요청)
|
||||
firstMapping.from_api_body
|
||||
);
|
||||
|
||||
if (apiResult.success && apiResult.data) {
|
||||
|
|
@ -416,6 +276,17 @@ export class BatchSchedulerService {
|
|||
totalRecords += fromData.length;
|
||||
|
||||
// 컬럼 매핑 적용하여 TO 테이블 형식으로 변환
|
||||
// 유틸리티 함수: 점 표기법을 사용하여 중첩된 객체 값 가져오기
|
||||
const getValueByPath = (obj: any, path: string) => {
|
||||
if (!path) return undefined;
|
||||
// path가 'response.access_token' 처럼 점을 포함하는 경우
|
||||
if (path.includes(".")) {
|
||||
return path.split(".").reduce((acc, part) => acc && acc[part], obj);
|
||||
}
|
||||
// 단순 키인 경우
|
||||
return obj[path];
|
||||
};
|
||||
|
||||
const mappedData = fromData.map((row) => {
|
||||
const mappedRow: any = {};
|
||||
for (const mapping of mappings) {
|
||||
|
|
@ -428,8 +299,11 @@ export class BatchSchedulerService {
|
|||
mappedRow[mapping.from_column_name] =
|
||||
row[mapping.from_column_name];
|
||||
} else {
|
||||
// 기존 로직: to_column_name을 키로 사용
|
||||
mappedRow[mapping.to_column_name] = row[mapping.from_column_name];
|
||||
// REST API -> DB (POST 요청 포함) 또는 DB -> DB
|
||||
// row[mapping.from_column_name] 대신 getValueByPath 사용
|
||||
const value = getValueByPath(row, mapping.from_column_name);
|
||||
|
||||
mappedRow[mapping.to_column_name] = value;
|
||||
}
|
||||
}
|
||||
return mappedRow;
|
||||
|
|
@ -482,22 +356,12 @@ export class BatchSchedulerService {
|
|||
);
|
||||
}
|
||||
} 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
|
||||
// 기존 REST API 전송 (REST API → DB 배치) - 사실 이 경우는 거의 없음 (REST to REST)
|
||||
// 지원하지 않음
|
||||
logger.warn(
|
||||
"REST API -> REST API (단순 매핑)은 아직 지원하지 않습니다."
|
||||
);
|
||||
|
||||
if (apiResult.success && apiResult.data) {
|
||||
insertResult = apiResult.data;
|
||||
} else {
|
||||
throw new Error(
|
||||
`REST API 데이터 전송 실패: ${apiResult.message}`
|
||||
);
|
||||
}
|
||||
insertResult = { successCount: 0, failedCount: 0 };
|
||||
}
|
||||
} else {
|
||||
// DB에 데이터 삽입
|
||||
|
|
@ -511,167 +375,13 @@ export class BatchSchedulerService {
|
|||
|
||||
successRecords += insertResult.successCount;
|
||||
failedRecords += insertResult.failedCount;
|
||||
|
||||
logger.info(
|
||||
`테이블 처리 완료: ${insertResult.successCount}개 성공, ${insertResult.failedCount}개 실패`
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(`테이블 처리 실패: ${tableKey}`, error);
|
||||
failedRecords += 1;
|
||||
logger.error(`테이블 처리 중 오류 발생: ${tableKey}`, error);
|
||||
// 해당 테이블 처리 실패는 전체 실패로 간주하지 않고, 실패 카운트만 증가?
|
||||
// 여기서는 일단 실패 로그만 남기고 계속 진행 (필요시 정책 변경)
|
||||
}
|
||||
}
|
||||
|
||||
return { totalRecords, successRecords, failedRecords };
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 매핑 처리 (기존 메서드 - 사용 안 함)
|
||||
*/
|
||||
private static async processBatchMappings(config: any) {
|
||||
const { batch_mappings } = config;
|
||||
let totalRecords = 0;
|
||||
let successRecords = 0;
|
||||
let failedRecords = 0;
|
||||
|
||||
if (!batch_mappings || batch_mappings.length === 0) {
|
||||
logger.warn(`배치 매핑이 없습니다: ${config.batch_name}`);
|
||||
return { totalRecords, successRecords, failedRecords };
|
||||
}
|
||||
|
||||
for (const mapping of batch_mappings) {
|
||||
try {
|
||||
logger.info(
|
||||
`매핑 처리 시작: ${mapping.from_table_name} -> ${mapping.to_table_name}`
|
||||
);
|
||||
|
||||
// FROM 테이블에서 데이터 조회
|
||||
const fromData = await this.getDataFromSource(mapping);
|
||||
totalRecords += fromData.length;
|
||||
|
||||
// TO 테이블에 데이터 삽입
|
||||
const insertResult = await this.insertDataToTarget(mapping, fromData);
|
||||
successRecords += insertResult.successCount;
|
||||
failedRecords += insertResult.failedCount;
|
||||
|
||||
logger.info(
|
||||
`매핑 처리 완료: ${insertResult.successCount}개 성공, ${insertResult.failedCount}개 실패`
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`매핑 처리 실패: ${mapping.from_table_name} -> ${mapping.to_table_name}`,
|
||||
error
|
||||
);
|
||||
failedRecords += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { totalRecords, successRecords, failedRecords };
|
||||
}
|
||||
|
||||
/**
|
||||
* FROM 테이블에서 데이터 조회
|
||||
*/
|
||||
private static async getDataFromSource(mapping: any) {
|
||||
try {
|
||||
if (mapping.from_connection_type === "internal") {
|
||||
// 내부 DB에서 조회
|
||||
const result = await query<any>(
|
||||
`SELECT * FROM ${mapping.from_table_name}`,
|
||||
[]
|
||||
);
|
||||
return result;
|
||||
} else {
|
||||
// 외부 DB에서 조회 (구현 필요)
|
||||
logger.warn("외부 DB 조회는 아직 구현되지 않았습니다.");
|
||||
return [];
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`FROM 테이블 데이터 조회 실패: ${mapping.from_table_name}`,
|
||||
error
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TO 테이블에 데이터 삽입
|
||||
*/
|
||||
private static async insertDataToTarget(mapping: any, data: any[]) {
|
||||
let successCount = 0;
|
||||
let failedCount = 0;
|
||||
|
||||
try {
|
||||
if (mapping.to_connection_type === "internal") {
|
||||
// 내부 DB에 삽입
|
||||
for (const record of data) {
|
||||
try {
|
||||
// 매핑된 컬럼만 추출
|
||||
const mappedData = this.mapColumns(record, mapping);
|
||||
|
||||
const columns = Object.keys(mappedData);
|
||||
const values = Object.values(mappedData);
|
||||
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
|
||||
|
||||
await query(
|
||||
`INSERT INTO ${mapping.to_table_name} (${columns.join(", ")}) VALUES (${placeholders})`,
|
||||
values
|
||||
);
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
logger.error(`레코드 삽입 실패:`, error);
|
||||
failedCount++;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 외부 DB에 삽입 (구현 필요)
|
||||
logger.warn("외부 DB 삽입은 아직 구현되지 않았습니다.");
|
||||
failedCount = data.length;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`TO 테이블 데이터 삽입 실패: ${mapping.to_table_name}`,
|
||||
error
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return { successCount, failedCount };
|
||||
}
|
||||
|
||||
/**
|
||||
* 컬럼 매핑
|
||||
*/
|
||||
private static mapColumns(record: any, mapping: any) {
|
||||
const mappedData: any = {};
|
||||
|
||||
// 단순한 컬럼 매핑 (실제로는 더 복잡한 로직 필요)
|
||||
mappedData[mapping.to_column_name] = record[mapping.from_column_name];
|
||||
|
||||
return mappedData;
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 스케줄 중지
|
||||
*/
|
||||
static async stopAllSchedules() {
|
||||
try {
|
||||
for (const [id, task] of this.scheduledTasks) {
|
||||
task.stop();
|
||||
logger.info(`배치 스케줄 중지: ID ${id}`);
|
||||
}
|
||||
this.scheduledTasks.clear();
|
||||
this.isInitialized = false;
|
||||
logger.info("모든 배치 스케줄이 중지되었습니다.");
|
||||
} catch (error) {
|
||||
logger.error("배치 스케줄 중지 실패:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 등록된 스케줄 목록 조회
|
||||
*/
|
||||
static getScheduledTasks() {
|
||||
return Array.from(this.scheduledTasks.keys());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -4,6 +4,7 @@
|
|||
export interface BatchExecutionLog {
|
||||
id?: number;
|
||||
batch_config_id: number;
|
||||
company_code?: string;
|
||||
execution_status: 'RUNNING' | 'SUCCESS' | 'FAILED' | 'CANCELLED';
|
||||
start_time: Date;
|
||||
end_time?: Date | null;
|
||||
|
|
@ -19,6 +20,7 @@ export interface BatchExecutionLog {
|
|||
|
||||
export interface CreateBatchExecutionLogRequest {
|
||||
batch_config_id: number;
|
||||
company_code?: string;
|
||||
execution_status: 'RUNNING' | 'SUCCESS' | 'FAILED' | 'CANCELLED';
|
||||
start_time?: Date;
|
||||
end_time?: Date | null;
|
||||
|
|
|
|||
|
|
@ -1,86 +1,13 @@
|
|||
// 배치관리 타입 정의
|
||||
// 작성일: 2024-12-24
|
||||
import { ApiResponse, ColumnInfo } from './batchTypes';
|
||||
|
||||
// 배치 타입 정의
|
||||
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;
|
||||
description?: string;
|
||||
cron_schedule: string;
|
||||
is_active?: string;
|
||||
company_code?: string;
|
||||
created_date?: Date;
|
||||
created_by?: string;
|
||||
updated_date?: Date;
|
||||
updated_by?: string;
|
||||
batch_mappings?: BatchMapping[];
|
||||
}
|
||||
|
||||
export interface BatchMapping {
|
||||
id?: number;
|
||||
batch_config_id?: number;
|
||||
|
||||
// FROM 정보
|
||||
from_connection_type: 'internal' | 'external' | 'restapi';
|
||||
from_connection_id?: number;
|
||||
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 키
|
||||
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';
|
||||
to_connection_id?: number;
|
||||
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;
|
||||
created_by?: string;
|
||||
}
|
||||
|
||||
export interface BatchConfigFilter {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
batch_name?: string;
|
||||
is_active?: string;
|
||||
company_code?: string;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export interface ConnectionInfo {
|
||||
export interface BatchConnectionInfo {
|
||||
type: 'internal' | 'external';
|
||||
id?: number;
|
||||
name: string;
|
||||
db_type?: string;
|
||||
}
|
||||
|
||||
export interface TableInfo {
|
||||
table_name: string;
|
||||
columns: ColumnInfo[];
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
export interface ColumnInfo {
|
||||
export interface BatchColumnInfo {
|
||||
column_name: string;
|
||||
data_type: string;
|
||||
is_nullable?: string;
|
||||
|
|
@ -100,6 +27,8 @@ export interface BatchMappingRequest {
|
|||
from_api_param_name?: string; // API 파라미터명
|
||||
from_api_param_value?: string; // API 파라미터 값 또는 템플릿
|
||||
from_api_param_source?: 'static' | 'dynamic'; // 파라미터 소스 타입
|
||||
// 👇 REST API Body 추가 (FROM - REST API에서 POST 요청 시 필요)
|
||||
from_api_body?: string;
|
||||
to_connection_type: 'internal' | 'external' | 'restapi';
|
||||
to_connection_id?: number;
|
||||
to_table_name: string;
|
||||
|
|
@ -116,6 +45,8 @@ export interface CreateBatchConfigRequest {
|
|||
batchName: string;
|
||||
description?: string;
|
||||
cronSchedule: string;
|
||||
isActive: 'Y' | 'N';
|
||||
companyCode: string;
|
||||
mappings: BatchMappingRequest[];
|
||||
}
|
||||
|
||||
|
|
@ -123,25 +54,11 @@ export interface UpdateBatchConfigRequest {
|
|||
batchName?: string;
|
||||
description?: string;
|
||||
cronSchedule?: string;
|
||||
isActive?: 'Y' | 'N';
|
||||
mappings?: BatchMappingRequest[];
|
||||
isActive?: string;
|
||||
}
|
||||
|
||||
export interface BatchValidationResult {
|
||||
isValid: boolean;
|
||||
errors: string[];
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
message?: string;
|
||||
error?: string;
|
||||
pagination?: {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,7 +52,8 @@ export default function BatchManagementNewPage() {
|
|||
const [fromApiUrl, setFromApiUrl] = useState("");
|
||||
const [fromApiKey, setFromApiKey] = useState("");
|
||||
const [fromEndpoint, setFromEndpoint] = useState("");
|
||||
const [fromApiMethod, setFromApiMethod] = useState<'GET'>('GET'); // GET만 지원
|
||||
const [fromApiMethod, setFromApiMethod] = useState<'GET' | 'POST' | 'PUT' | 'DELETE'>('GET');
|
||||
const [fromApiBody, setFromApiBody] = useState(""); // Request Body (JSON)
|
||||
|
||||
// REST API 파라미터 설정
|
||||
const [apiParamType, setApiParamType] = useState<'none' | 'url' | 'query'>('none');
|
||||
|
|
@ -83,6 +84,8 @@ export default function BatchManagementNewPage() {
|
|||
|
||||
// API 필드 → DB 컬럼 매핑
|
||||
const [apiFieldMappings, setApiFieldMappings] = useState<Record<string, string>>({});
|
||||
// API 필드별 JSON 경로 오버라이드 (예: "response.access_token")
|
||||
const [apiFieldPathOverrides, setApiFieldPathOverrides] = useState<Record<string, string>>({});
|
||||
|
||||
// 배치 타입 상태
|
||||
const [batchType, setBatchType] = useState<BatchType>('restapi-to-db');
|
||||
|
|
@ -303,8 +306,15 @@ export default function BatchManagementNewPage() {
|
|||
|
||||
// REST API 데이터 미리보기
|
||||
const previewRestApiData = async () => {
|
||||
if (!fromApiUrl || !fromApiKey || !fromEndpoint) {
|
||||
toast.error("API URL, API Key, 엔드포인트를 모두 입력해주세요.");
|
||||
// API URL, 엔드포인트는 항상 필수
|
||||
if (!fromApiUrl || !fromEndpoint) {
|
||||
toast.error("API URL과 엔드포인트를 모두 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
// GET 메서드일 때만 API 키 필수
|
||||
if (fromApiMethod === "GET" && !fromApiKey) {
|
||||
toast.error("GET 메서드에서는 API 키를 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -313,7 +323,7 @@ export default function BatchManagementNewPage() {
|
|||
|
||||
const result = await BatchManagementAPI.previewRestApiData(
|
||||
fromApiUrl,
|
||||
fromApiKey,
|
||||
fromApiKey || "",
|
||||
fromEndpoint,
|
||||
fromApiMethod,
|
||||
// 파라미터 정보 추가
|
||||
|
|
@ -322,7 +332,9 @@ export default function BatchManagementNewPage() {
|
|||
paramName: apiParamName,
|
||||
paramValue: apiParamValue,
|
||||
paramSource: apiParamSource
|
||||
} : undefined
|
||||
} : undefined,
|
||||
// Request Body 추가 (POST/PUT/DELETE)
|
||||
(fromApiMethod === 'POST' || fromApiMethod === 'PUT' || fromApiMethod === 'DELETE') ? fromApiBody : undefined
|
||||
);
|
||||
|
||||
console.log("API 미리보기 결과:", result);
|
||||
|
|
@ -370,31 +382,54 @@ export default function BatchManagementNewPage() {
|
|||
|
||||
// 배치 타입별 검증 및 저장
|
||||
if (batchType === 'restapi-to-db') {
|
||||
const mappedFields = Object.keys(apiFieldMappings).filter(field => apiFieldMappings[field]);
|
||||
const mappedFields = Object.keys(apiFieldMappings).filter(
|
||||
(field) => apiFieldMappings[field]
|
||||
);
|
||||
if (mappedFields.length === 0) {
|
||||
toast.error("최소 하나의 API 필드를 DB 컬럼에 매핑해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
// API 필드 매핑을 배치 매핑 형태로 변환
|
||||
const apiMappings = mappedFields.map(apiField => ({
|
||||
from_connection_type: 'restapi' as const,
|
||||
from_table_name: fromEndpoint, // API 엔드포인트
|
||||
from_column_name: apiField, // API 필드명
|
||||
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,
|
||||
to_column_name: apiFieldMappings[apiField], // 매핑된 DB 컬럼
|
||||
mapping_type: 'direct' as const
|
||||
}));
|
||||
const apiMappings = mappedFields.map((apiField) => {
|
||||
const toColumnName = apiFieldMappings[apiField]; // 매핑된 DB 컬럼 (예: access_token)
|
||||
|
||||
// 기본은 상위 필드 그대로 사용하되,
|
||||
// 사용자가 JSON 경로를 직접 입력한 경우 해당 경로를 우선 사용
|
||||
let fromColumnName = apiField;
|
||||
const overridePath = apiFieldPathOverrides[apiField];
|
||||
if (overridePath && overridePath.trim().length > 0) {
|
||||
fromColumnName = overridePath.trim();
|
||||
}
|
||||
|
||||
return {
|
||||
from_connection_type: "restapi" as const,
|
||||
from_table_name: fromEndpoint, // API 엔드포인트
|
||||
from_column_name: fromColumnName, // API 필드명 또는 중첩 경로
|
||||
from_api_url: fromApiUrl,
|
||||
from_api_key: fromApiKey,
|
||||
from_api_method: fromApiMethod,
|
||||
from_api_body:
|
||||
fromApiMethod === "POST" ||
|
||||
fromApiMethod === "PUT" ||
|
||||
fromApiMethod === "DELETE"
|
||||
? fromApiBody
|
||||
: undefined,
|
||||
// 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,
|
||||
to_column_name: toColumnName, // 매핑된 DB 컬럼
|
||||
mapping_type: "direct" as const,
|
||||
};
|
||||
});
|
||||
|
||||
console.log("REST API 배치 설정 저장:", {
|
||||
batchName,
|
||||
|
|
@ -645,13 +680,19 @@ export default function BatchManagementNewPage() {
|
|||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="fromApiKey">API 키 *</Label>
|
||||
<Label htmlFor="fromApiKey">
|
||||
API 키
|
||||
{fromApiMethod === "GET" && <span className="text-red-500 ml-0.5">*</span>}
|
||||
</Label>
|
||||
<Input
|
||||
id="fromApiKey"
|
||||
value={fromApiKey}
|
||||
onChange={(e) => setFromApiKey(e.target.value)}
|
||||
placeholder="ak_your_api_key_here"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
GET 메서드에서만 필수이며, POST/PUT/DELETE일 때는 선택 사항입니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -673,12 +714,33 @@ export default function BatchManagementNewPage() {
|
|||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="GET">GET (데이터 조회)</SelectItem>
|
||||
<SelectItem value="POST">POST (데이터 조회/전송)</SelectItem>
|
||||
<SelectItem value="PUT">PUT</SelectItem>
|
||||
<SelectItem value="DELETE">DELETE</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Request Body (POST/PUT/DELETE용) */}
|
||||
{(fromApiMethod === 'POST' || fromApiMethod === 'PUT' || fromApiMethod === 'DELETE') && (
|
||||
<div>
|
||||
<Label htmlFor="fromApiBody">Request Body (JSON)</Label>
|
||||
<Textarea
|
||||
id="fromApiBody"
|
||||
value={fromApiBody}
|
||||
onChange={(e) => setFromApiBody(e.target.value)}
|
||||
placeholder='{"username": "myuser", "token": "abc"}'
|
||||
className="min-h-[100px]"
|
||||
rows={5}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
API 호출 시 함께 전송할 JSON 데이터를 입력하세요.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API 파라미터 설정 */}
|
||||
<div className="space-y-4">
|
||||
<div className="border-t pt-4">
|
||||
|
|
@ -771,7 +833,10 @@ export default function BatchManagementNewPage() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{fromApiUrl && fromApiKey && fromEndpoint && (
|
||||
{/* API URL + 엔드포인트는 필수, GET일 때만 API 키 필수 */}
|
||||
{fromApiUrl &&
|
||||
fromEndpoint &&
|
||||
(fromApiMethod !== "GET" || fromApiKey) && (
|
||||
<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>
|
||||
|
|
@ -786,7 +851,11 @@ export default function BatchManagementNewPage() {
|
|||
: ''
|
||||
}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">Headers: X-API-Key: {fromApiKey.substring(0, 10)}...</div>
|
||||
{fromApiKey && (
|
||||
<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' ? '고정값' : '동적값'})
|
||||
|
|
@ -993,10 +1062,28 @@ export default function BatchManagementNewPage() {
|
|||
<div className="flex-1">
|
||||
<div className="font-medium text-sm">{apiField}</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{fromApiData.length > 0 && fromApiData[0][apiField] !== undefined
|
||||
? `예: ${String(fromApiData[0][apiField]).substring(0, 30)}${String(fromApiData[0][apiField]).length > 30 ? '...' : ''}`
|
||||
: 'API 필드'
|
||||
}
|
||||
{fromApiData.length > 0 && fromApiData[0][apiField] !== undefined
|
||||
? `예: ${String(fromApiData[0][apiField]).substring(0, 30)}${
|
||||
String(fromApiData[0][apiField]).length > 30 ? "..." : ""
|
||||
}`
|
||||
: "API 필드"}
|
||||
</div>
|
||||
{/* JSON 경로 오버라이드 입력 */}
|
||||
<div className="mt-1.5">
|
||||
<Input
|
||||
value={apiFieldPathOverrides[apiField] || ""}
|
||||
onChange={(e) =>
|
||||
setApiFieldPathOverrides((prev) => ({
|
||||
...prev,
|
||||
[apiField]: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="JSON 경로 (예: response.access_token)"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
<p className="text-[11px] text-gray-500 mt-0.5">
|
||||
비워두면 "{apiField}" 필드 전체를 사용하고, 입력하면 해당 경로의 값을 사용합니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -580,22 +580,60 @@ export default function BatchEditPage() {
|
|||
</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 className="space-y-4">
|
||||
<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>
|
||||
|
||||
{/* Request Body (JSON) 편집 UI */}
|
||||
<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 />
|
||||
<Label>Request Body (JSON)</Label>
|
||||
<Textarea
|
||||
rows={5}
|
||||
className="font-mono text-sm"
|
||||
placeholder='{"id": "wace", "pwd": "wace!$%Pwdmo^^"}'
|
||||
value={mappings[0]?.from_api_body || ""}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setMappings((prev) => {
|
||||
if (prev.length === 0) return prev;
|
||||
const updated = [...prev];
|
||||
updated[0] = {
|
||||
...updated[0],
|
||||
from_api_body: value,
|
||||
} as any;
|
||||
return updated;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1.5">
|
||||
토큰 발급 등 POST 요청에 사용할 JSON Request Body를 수정할 수 있습니다.
|
||||
배치가 실행될 때 이 내용이 그대로 전송됩니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,423 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
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 { Switch } from "@/components/ui/switch";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { toast } from "sonner";
|
||||
import { BatchAPI, BatchJob, BatchConfig } from "@/lib/api/batch";
|
||||
import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection";
|
||||
|
||||
// BatchJobModal에서 사용하던 config_json 구조 확장
|
||||
interface RestApiConfigJson {
|
||||
sourceConnectionId?: number;
|
||||
targetConnectionId?: number;
|
||||
targetTable?: string;
|
||||
// REST API 관련 설정
|
||||
apiUrl?: string;
|
||||
apiKey?: string;
|
||||
endpoint?: string;
|
||||
httpMethod?: string;
|
||||
apiBody?: string; // POST 요청용 Body
|
||||
// 매핑 정보 등
|
||||
mappings?: any[];
|
||||
}
|
||||
|
||||
interface AdvancedBatchModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
job?: BatchJob | null;
|
||||
initialType?: "rest_to_db" | "db_to_rest"; // 초기 진입 시 타입 지정
|
||||
}
|
||||
|
||||
export default function AdvancedBatchModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSave,
|
||||
job,
|
||||
initialType = "rest_to_db",
|
||||
}: AdvancedBatchModalProps) {
|
||||
// 기본 BatchJob 정보 관리
|
||||
const [formData, setFormData] = useState<Partial<BatchJob>>({
|
||||
job_name: "",
|
||||
description: "",
|
||||
job_type: initialType === "rest_to_db" ? "rest_to_db" : "db_to_rest",
|
||||
schedule_cron: "",
|
||||
is_active: "Y",
|
||||
config_json: {},
|
||||
});
|
||||
|
||||
// 상세 설정 (config_json 내부 값) 관리
|
||||
const [configData, setConfigData] = useState<RestApiConfigJson>({
|
||||
httpMethod: "GET", // 기본값
|
||||
apiBody: "",
|
||||
});
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [connections, setConnections] = useState<any[]>([]); // 내부/외부 DB 연결 목록
|
||||
const [targetTables, setTargetTables] = useState<string[]>([]); // 대상 테이블 목록 (DB가 타겟일 때)
|
||||
const [schedulePresets, setSchedulePresets] = useState<Array<{ value: string; label: string }>>([]);
|
||||
|
||||
// 모달 열릴 때 초기화
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadConnections();
|
||||
loadSchedulePresets();
|
||||
|
||||
if (job) {
|
||||
// 수정 모드
|
||||
setFormData({
|
||||
...job,
|
||||
config_json: job.config_json || {},
|
||||
});
|
||||
// 기존 config_json 내용을 상태로 복원
|
||||
const savedConfig = job.config_json as RestApiConfigJson;
|
||||
setConfigData({
|
||||
...savedConfig,
|
||||
httpMethod: savedConfig.httpMethod || "GET",
|
||||
apiBody: savedConfig.apiBody || "",
|
||||
});
|
||||
|
||||
// 타겟 연결이 있으면 테이블 목록 로드
|
||||
if (savedConfig.targetConnectionId) {
|
||||
loadTables(savedConfig.targetConnectionId);
|
||||
}
|
||||
} else {
|
||||
// 생성 모드
|
||||
setFormData({
|
||||
job_name: "",
|
||||
description: "",
|
||||
job_type: initialType === "rest_to_db" ? "rest_to_db" : "db_to_rest", // props로 받은 타입 우선
|
||||
schedule_cron: "",
|
||||
is_active: "Y",
|
||||
config_json: {},
|
||||
});
|
||||
setConfigData({
|
||||
httpMethod: "GET",
|
||||
apiBody: "",
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [isOpen, job, initialType]);
|
||||
|
||||
const loadConnections = async () => {
|
||||
try {
|
||||
// 외부 DB 연결 목록 조회 (내부 DB 포함)
|
||||
const list = await ExternalDbConnectionAPI.getConnections({ is_active: "Y" });
|
||||
setConnections(list);
|
||||
} catch (error) {
|
||||
console.error("연결 목록 조회 오류:", error);
|
||||
toast.error("연결 목록을 불러오는데 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
const loadTables = async (connectionId: number) => {
|
||||
try {
|
||||
const result = await ExternalDbConnectionAPI.getTables(connectionId);
|
||||
if (result.success && result.data) {
|
||||
setTargetTables(result.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 조회 오류:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadSchedulePresets = async () => {
|
||||
try {
|
||||
const presets = await BatchAPI.getSchedulePresets();
|
||||
setSchedulePresets(presets);
|
||||
} catch (error) {
|
||||
console.error("스케줄 프리셋 조회 오류:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// 폼 제출 핸들러
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.job_name) {
|
||||
toast.error("배치명을 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
// REST API URL 필수 체크
|
||||
if (!configData.apiUrl) {
|
||||
toast.error("API 서버 URL을 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 타겟 DB 연결 필수 체크 (REST -> DB 인 경우)
|
||||
if (formData.job_type === "rest_to_db" && !configData.targetConnectionId) {
|
||||
toast.error("데이터를 저장할 대상 DB 연결을 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// 최종 저장할 데이터 조립
|
||||
const finalJobData = {
|
||||
...formData,
|
||||
config_json: {
|
||||
...configData,
|
||||
// 추가적인 메타데이터가 필요하다면 여기에 포함
|
||||
},
|
||||
};
|
||||
|
||||
if (job?.id) {
|
||||
await BatchAPI.updateBatchJob(job.id, finalJobData);
|
||||
toast.success("배치 작업이 수정되었습니다.");
|
||||
} else {
|
||||
await BatchAPI.createBatchJob(finalJobData as BatchJob);
|
||||
toast.success("배치 작업이 생성되었습니다.");
|
||||
}
|
||||
onSave();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("배치 저장 오류:", error);
|
||||
toast.error(error instanceof Error ? error.message : "저장에 실패했습니다.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[800px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>고급 배치 생성</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6 py-2">
|
||||
{/* 1. 기본 정보 섹션 */}
|
||||
<div className="space-y-4 border rounded-md p-4 bg-slate-50">
|
||||
<h3 className="text-sm font-semibold text-slate-900">기본 정보</h3>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-xs">배치 타입 *</Label>
|
||||
<div className="mt-1 p-2 bg-white border rounded text-sm font-medium text-slate-600">
|
||||
{formData.job_type === "rest_to_db" ? "🌐 REST API → 💾 DB" : "💾 DB → 🌐 REST API"}
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 mt-1">
|
||||
{formData.job_type === "rest_to_db"
|
||||
? "REST API에서 데이터를 가져와 데이터베이스에 저장합니다."
|
||||
: "데이터베이스의 데이터를 REST API로 전송합니다."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="schedule_cron" className="text-xs">실행 스케줄 *</Label>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<Input
|
||||
id="schedule_cron"
|
||||
value={formData.schedule_cron || ""}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, schedule_cron: e.target.value }))}
|
||||
placeholder="예: 0 12 * * *"
|
||||
className="text-sm"
|
||||
/>
|
||||
<Select onValueChange={(val) => setFormData(prev => ({ ...prev, schedule_cron: val }))}>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue placeholder="프리셋" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{schedulePresets.map(p => (
|
||||
<SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<Label htmlFor="job_name" className="text-xs">배치명 *</Label>
|
||||
<Input
|
||||
id="job_name"
|
||||
value={formData.job_name || ""}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, job_name: e.target.value }))}
|
||||
placeholder="배치명을 입력하세요"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<Label htmlFor="description" className="text-xs">설명</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description || ""}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
||||
placeholder="배치에 대한 설명을 입력하세요"
|
||||
className="mt-1 min-h-[60px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. REST API 설정 섹션 (Source) */}
|
||||
<div className="space-y-4 border rounded-md p-4 bg-white">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">🌐</span>
|
||||
<h3 className="text-sm font-semibold text-slate-900">
|
||||
{formData.job_type === "rest_to_db" ? "FROM: REST API (소스)" : "TO: REST API (대상)"}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="sm:col-span-2">
|
||||
<Label htmlFor="api_url" className="text-xs">API 서버 URL *</Label>
|
||||
<Input
|
||||
id="api_url"
|
||||
value={configData.apiUrl || ""}
|
||||
onChange={(e) => setConfigData(prev => ({ ...prev, apiUrl: e.target.value }))}
|
||||
placeholder="https://api.example.com"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<Label htmlFor="api_key" className="text-xs">API 키 (선택)</Label>
|
||||
<Input
|
||||
id="api_key"
|
||||
type="password"
|
||||
value={configData.apiKey || ""}
|
||||
onChange={(e) => setConfigData(prev => ({ ...prev, apiKey: e.target.value }))}
|
||||
placeholder="인증에 필요한 API Key가 있다면 입력하세요"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="endpoint" className="text-xs">엔드포인트 *</Label>
|
||||
<Input
|
||||
id="endpoint"
|
||||
value={configData.endpoint || ""}
|
||||
onChange={(e) => setConfigData(prev => ({ ...prev, endpoint: e.target.value }))}
|
||||
placeholder="/api/token"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="http_method" className="text-xs">HTTP 메서드</Label>
|
||||
<Select
|
||||
value={configData.httpMethod || "GET"}
|
||||
onValueChange={(val) => setConfigData(prev => ({ ...prev, httpMethod: val }))}
|
||||
>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="GET">GET (데이터 조회)</SelectItem>
|
||||
<SelectItem value="POST">POST (데이터 생성/요청)</SelectItem>
|
||||
<SelectItem value="PUT">PUT (데이터 수정)</SelectItem>
|
||||
<SelectItem value="DELETE">DELETE (데이터 삭제)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* POST/PUT 일 때 Body 입력창 노출 */}
|
||||
{(configData.httpMethod === "POST" || configData.httpMethod === "PUT") && (
|
||||
<div className="sm:col-span-2 animate-in fade-in slide-in-from-top-2 duration-200">
|
||||
<Label htmlFor="api_body" className="text-xs">Request Body (JSON)</Label>
|
||||
<Textarea
|
||||
id="api_body"
|
||||
value={configData.apiBody || ""}
|
||||
onChange={(e) => setConfigData(prev => ({ ...prev, apiBody: e.target.value }))}
|
||||
placeholder='{"username": "myuser", "password": "mypassword"}'
|
||||
className="mt-1 font-mono text-xs min-h-[100px]"
|
||||
/>
|
||||
<p className="text-[10px] text-slate-500 mt-1">
|
||||
* 토큰 발급 요청 시 인증 정보를 JSON 형식으로 입력하세요.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3. 데이터베이스 설정 섹션 (Target) */}
|
||||
<div className="space-y-4 border rounded-md p-4 bg-white">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">💾</span>
|
||||
<h3 className="text-sm font-semibold text-slate-900">
|
||||
{formData.job_type === "rest_to_db" ? "TO: 데이터베이스 (대상)" : "FROM: 데이터베이스 (소스)"}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-xs">데이터베이스 커넥션 선택</Label>
|
||||
<Select
|
||||
value={configData.targetConnectionId?.toString() || ""}
|
||||
onValueChange={(val) => {
|
||||
const connId = parseInt(val);
|
||||
setConfigData(prev => ({ ...prev, targetConnectionId: connId }));
|
||||
loadTables(connId); // 테이블 목록 로드
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="커넥션을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{connections.map(conn => (
|
||||
<SelectItem key={conn.id} value={conn.id.toString()}>
|
||||
{conn.connection_name || conn.name} ({conn.db_type})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs">테이블 선택</Label>
|
||||
<Select
|
||||
value={configData.targetTable || ""}
|
||||
onValueChange={(val) => setConfigData(prev => ({ ...prev, targetTable: val }))}
|
||||
disabled={!configData.targetConnectionId}
|
||||
>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="테이블을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{targetTables.length > 0 ? (
|
||||
targetTables.map(table => (
|
||||
<SelectItem key={table} value={table}>{table}</SelectItem>
|
||||
))
|
||||
) : (
|
||||
<div className="p-2 text-xs text-center text-slate-400">테이블 없음</div>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
취소
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading ? "저장 중..." : "저장"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -361,59 +361,59 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
|||
// Area가 없으면 기존 평면 리스트 유지
|
||||
if (areaObjects.length === 0) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{filteredObjects.map((obj) => {
|
||||
let typeLabel = obj.type;
|
||||
if (obj.type === "location-bed") typeLabel = "베드(BED)";
|
||||
else if (obj.type === "location-stp") typeLabel = "정차포인트(STP)";
|
||||
else if (obj.type === "location-temp") typeLabel = "임시베드(TMP)";
|
||||
else if (obj.type === "location-dest") typeLabel = "지정착지(DES)";
|
||||
else if (obj.type === "crane-mobile") typeLabel = "크레인";
|
||||
else if (obj.type === "area") typeLabel = "Area";
|
||||
else if (obj.type === "rack") typeLabel = "랙";
|
||||
<div className="space-y-2">
|
||||
{filteredObjects.map((obj) => {
|
||||
let typeLabel = obj.type;
|
||||
if (obj.type === "location-bed") typeLabel = "베드(BED)";
|
||||
else if (obj.type === "location-stp") typeLabel = "정차포인트(STP)";
|
||||
else if (obj.type === "location-temp") typeLabel = "임시베드(TMP)";
|
||||
else if (obj.type === "location-dest") typeLabel = "지정착지(DES)";
|
||||
else if (obj.type === "crane-mobile") typeLabel = "크레인";
|
||||
else if (obj.type === "area") typeLabel = "Area";
|
||||
else if (obj.type === "rack") typeLabel = "랙";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={obj.id}
|
||||
onClick={() => handleObjectClick(obj.id)}
|
||||
className={`bg-background hover:bg-accent cursor-pointer rounded-lg border p-3 transition-all ${
|
||||
selectedObject?.id === obj.id ? "ring-primary bg-primary/5 ring-2" : "hover:shadow-sm"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">{obj.name}</p>
|
||||
<div className="text-muted-foreground mt-1 flex items-center gap-2 text-xs">
|
||||
<span
|
||||
className="inline-block h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: obj.color }}
|
||||
/>
|
||||
<span>{typeLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 space-y-1">
|
||||
{obj.areaKey && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Area: <span className="font-medium">{obj.areaKey}</span>
|
||||
</p>
|
||||
)}
|
||||
{obj.locaKey && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Location: <span className="font-medium">{obj.locaKey}</span>
|
||||
</p>
|
||||
)}
|
||||
{obj.materialCount !== undefined && obj.materialCount > 0 && (
|
||||
<p className="text-xs text-yellow-600">
|
||||
자재: <span className="font-semibold">{obj.materialCount}개</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
return (
|
||||
<div
|
||||
key={obj.id}
|
||||
onClick={() => handleObjectClick(obj.id)}
|
||||
className={`bg-background hover:bg-accent cursor-pointer rounded-lg border p-3 transition-all ${
|
||||
selectedObject?.id === obj.id ? "ring-primary bg-primary/5 ring-2" : "hover:shadow-sm"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">{obj.name}</p>
|
||||
<div className="text-muted-foreground mt-1 flex items-center gap-2 text-xs">
|
||||
<span
|
||||
className="inline-block h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: obj.color }}
|
||||
/>
|
||||
<span>{typeLabel}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 space-y-1">
|
||||
{obj.areaKey && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Area: <span className="font-medium">{obj.areaKey}</span>
|
||||
</p>
|
||||
)}
|
||||
{obj.locaKey && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Location: <span className="font-medium">{obj.locaKey}</span>
|
||||
</p>
|
||||
)}
|
||||
{obj.materialCount !== undefined && obj.materialCount > 0 && (
|
||||
<p className="text-xs text-yellow-600">
|
||||
자재: <span className="font-semibold">{obj.materialCount}개</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Area가 있는 경우: Area → Location 계층 아코디언
|
||||
|
|
|
|||
|
|
@ -131,13 +131,13 @@ export default function HierarchyConfigPanel({
|
|||
try {
|
||||
await Promise.all(
|
||||
tablesToFetch.map(async (tableName) => {
|
||||
try {
|
||||
const columns = await onLoadColumns(tableName);
|
||||
const normalized = normalizeColumns(columns);
|
||||
setColumnsCache((prev) => ({ ...prev, [tableName]: normalized }));
|
||||
} catch (error) {
|
||||
console.error(`컬럼 로드 실패 (${tableName}):`, error);
|
||||
}
|
||||
try {
|
||||
const columns = await onLoadColumns(tableName);
|
||||
const normalized = normalizeColumns(columns);
|
||||
setColumnsCache((prev) => ({ ...prev, [tableName]: normalized }));
|
||||
} catch (error) {
|
||||
console.error(`컬럼 로드 실패 (${tableName}):`, error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -120,13 +120,14 @@ class BatchManagementAPIClass {
|
|||
apiUrl: string,
|
||||
apiKey: string,
|
||||
endpoint: string,
|
||||
method: 'GET' = 'GET',
|
||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET',
|
||||
paramInfo?: {
|
||||
paramType: 'url' | 'query';
|
||||
paramName: string;
|
||||
paramValue: string;
|
||||
paramSource: 'static' | 'dynamic';
|
||||
}
|
||||
},
|
||||
requestBody?: string
|
||||
): Promise<{
|
||||
fields: string[];
|
||||
samples: any[];
|
||||
|
|
@ -137,7 +138,8 @@ class BatchManagementAPIClass {
|
|||
apiUrl,
|
||||
apiKey,
|
||||
endpoint,
|
||||
method
|
||||
method,
|
||||
requestBody
|
||||
};
|
||||
|
||||
// 파라미터 정보가 있으면 추가
|
||||
|
|
|
|||
Loading…
Reference in New Issue