Merge pull request '이알솔루션 rest api 연결' (#225) from common/feat/dashboard-map into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/225
This commit is contained in:
commit
ab734268a4
42
PLAN.MD
42
PLAN.MD
|
|
@ -1,28 +1,36 @@
|
|||
# 프로젝트: Digital Twin 에디터 안정화
|
||||
# 프로젝트: 외부 REST API 커넥션 관리 확장 (POST/Body 지원)
|
||||
|
||||
## 개요
|
||||
|
||||
Digital Twin 에디터(`DigitalTwinEditor.tsx`)에서 발생한 런타임 에러(`TypeError: Cannot read properties of undefined`)를 수정하고, 전반적인 안정성을 확보합니다.
|
||||
현재 GET 방식 위주로 구현된 외부 REST API 커넥션 관리 기능을 확장하여, POST, PUT, DELETE 등 다양한 HTTP 메서드와 JSON Request Body를 설정하고 테스트할 수 있도록 개선합니다. 이를 통해 토큰 발급 API나 데이터 전송 API 등 다양한 외부 시스템과의 연동을 지원합니다.
|
||||
|
||||
## 핵심 기능
|
||||
|
||||
1. `DigitalTwinEditor` 버그 수정
|
||||
2. 비동기 함수 입력값 유효성 검증 강화
|
||||
3. 외부 DB 연결 상태에 따른 방어 코드 추가
|
||||
1. **DB 스키마 확장**: `external_rest_api_connections` 테이블에 `default_method`, `default_body` 컬럼 추가
|
||||
2. **백엔드 로직 개선**:
|
||||
- 커넥션 생성/수정 시 메서드와 바디 정보 저장
|
||||
- 연결 테스트 시 설정된 메서드와 바디를 사용하여 요청 수행
|
||||
- SSL 인증서 검증 우회 옵션 적용 (내부망/테스트망 지원)
|
||||
3. **프론트엔드 UI 개선**:
|
||||
- 커넥션 설정 모달에 HTTP 메서드 선택(Select) 및 Body 입력(Textarea/JSON Editor) 필드 추가
|
||||
- 테스트 기능에서 Body 데이터 포함하여 요청 전송
|
||||
|
||||
## 테스트 계획
|
||||
### 1단계: 기본 기능 및 DB 마이그레이션
|
||||
- [x] DB 마이그레이션 스크립트 작성 및 실행
|
||||
- [x] 백엔드 타입 정의 수정 (`default_method`, `default_body` 추가)
|
||||
|
||||
### 1단계: 긴급 버그 수정
|
||||
### 2단계: 백엔드 로직 구현
|
||||
- [x] 커넥션 생성/수정 API 수정 (필드 추가)
|
||||
- [x] 커넥션 상세 조회 API 확인
|
||||
- [x] 연결 테스트 API 수정 (Method, Body 반영하여 요청 전송)
|
||||
|
||||
- [x] `loadMaterialCountsForLocations` 함수에서 `locaKeys` undefined 체크 추가 (완료)
|
||||
- [ ] 에디터 로드 및 객체 조작 시 에러 발생 여부 확인
|
||||
### 3단계: 프론트엔드 구현
|
||||
- [x] 커넥션 관리 리스트/모달 UI 수정
|
||||
- [x] 연결 테스트 UI 수정 및 기능 확인
|
||||
|
||||
### 2단계: 잠재적 문제 점검
|
||||
|
||||
- [ ] `loadLayout` 등 주요 로딩 함수의 데이터 유효성 검사
|
||||
- [ ] `handleToolDragStart`, `handleCanvasDrop` 등 인터랙션 함수의 예외 처리
|
||||
## 에러 처리 계획
|
||||
- **JSON 파싱 에러**: Body 입력값이 유효한 JSON이 아닐 경우 에러 처리
|
||||
- **API 호출 에러**: 외부 API 호출 실패 시 상세 로그 기록 및 클라이언트에 에러 메시지 전달
|
||||
- **SSL 인증 에러**: `rejectUnauthorized: false` 옵션으로 처리 (기존 `RestApiConnector` 활용)
|
||||
|
||||
## 진행 상태
|
||||
|
||||
- [진행중] 1단계 긴급 버그 수정 완료 후 사용자 피드백 대기 중
|
||||
|
||||
- [완료] 모든 단계 구현 완료
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
// 스케줄러에 자동 등록 ✅
|
||||
|
|
|
|||
|
|
@ -161,3 +161,4 @@ export const createMappingTemplate = async (
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -213,7 +213,10 @@ router.post(
|
|||
}
|
||||
|
||||
const result =
|
||||
await ExternalRestApiConnectionService.testConnection(testRequest);
|
||||
await ExternalRestApiConnectionService.testConnection(
|
||||
testRequest,
|
||||
req.user?.companyCode
|
||||
);
|
||||
|
||||
return res.status(200).json(result);
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -170,3 +170,4 @@ export class DigitalTwinTemplateService {
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,10 +299,25 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
// 멀티테넌시: TO가 DB일 때 company_code 자동 주입
|
||||
// - 배치 설정에 company_code가 있고
|
||||
// - 매핑에서 company_code를 명시적으로 다루지 않은 경우만
|
||||
if (
|
||||
firstMapping.to_connection_type !== "restapi" &&
|
||||
config.company_code &&
|
||||
mappedRow.company_code === undefined
|
||||
) {
|
||||
mappedRow.company_code = config.company_code;
|
||||
}
|
||||
|
||||
return mappedRow;
|
||||
});
|
||||
|
||||
|
|
@ -482,22 +368,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 +387,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
|
|
@ -1,4 +1,6 @@
|
|||
import { Pool, QueryResult } from "pg";
|
||||
import axios, { AxiosResponse } from "axios";
|
||||
import https from "https";
|
||||
import { getPool } from "../database/db";
|
||||
import logger from "../utils/logger";
|
||||
import {
|
||||
|
|
@ -30,6 +32,10 @@ export class ExternalRestApiConnectionService {
|
|||
let query = `
|
||||
SELECT
|
||||
id, connection_name, description, base_url, endpoint_path, default_headers,
|
||||
default_method,
|
||||
-- DB 스키마의 컬럼명은 default_request_body 기준이고
|
||||
-- 코드에서는 default_body 필드로 사용하기 위해 alias 처리
|
||||
default_request_body AS default_body,
|
||||
auth_type, auth_config, timeout, retry_count, retry_delay,
|
||||
company_code, is_active, created_date, created_by,
|
||||
updated_date, updated_by, last_test_date, last_test_result, last_test_message
|
||||
|
|
@ -129,6 +135,8 @@ export class ExternalRestApiConnectionService {
|
|||
let query = `
|
||||
SELECT
|
||||
id, connection_name, description, base_url, endpoint_path, default_headers,
|
||||
default_method,
|
||||
default_request_body AS default_body,
|
||||
auth_type, auth_config, timeout, retry_count, retry_delay,
|
||||
company_code, is_active, created_date, created_by,
|
||||
updated_date, updated_by, last_test_date, last_test_result, last_test_message
|
||||
|
|
@ -194,9 +202,10 @@ export class ExternalRestApiConnectionService {
|
|||
const query = `
|
||||
INSERT INTO external_rest_api_connections (
|
||||
connection_name, description, base_url, endpoint_path, default_headers,
|
||||
default_method, default_request_body,
|
||||
auth_type, auth_config, timeout, retry_count, retry_delay,
|
||||
company_code, is_active, created_by
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
|
|
@ -206,6 +215,8 @@ export class ExternalRestApiConnectionService {
|
|||
data.base_url,
|
||||
data.endpoint_path || null,
|
||||
JSON.stringify(data.default_headers || {}),
|
||||
data.default_method || "GET",
|
||||
data.default_body || null,
|
||||
data.auth_type,
|
||||
encryptedAuthConfig ? JSON.stringify(encryptedAuthConfig) : null,
|
||||
data.timeout || 30000,
|
||||
|
|
@ -301,6 +312,18 @@ export class ExternalRestApiConnectionService {
|
|||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.default_method !== undefined) {
|
||||
updateFields.push(`default_method = $${paramIndex}`);
|
||||
params.push(data.default_method);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.default_body !== undefined) {
|
||||
updateFields.push(`default_request_body = $${paramIndex}`);
|
||||
params.push(data.default_body);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.auth_type !== undefined) {
|
||||
updateFields.push(`auth_type = $${paramIndex}`);
|
||||
params.push(data.auth_type);
|
||||
|
|
@ -441,7 +464,8 @@ export class ExternalRestApiConnectionService {
|
|||
* REST API 연결 테스트 (테스트 요청 데이터 기반)
|
||||
*/
|
||||
static async testConnection(
|
||||
testRequest: RestApiTestRequest
|
||||
testRequest: RestApiTestRequest,
|
||||
userCompanyCode?: string
|
||||
): Promise<RestApiTestResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
|
|
@ -450,7 +474,78 @@ export class ExternalRestApiConnectionService {
|
|||
const headers = { ...testRequest.headers };
|
||||
|
||||
// 인증 헤더 추가
|
||||
if (
|
||||
if (testRequest.auth_type === "db-token") {
|
||||
const cfg = testRequest.auth_config || {};
|
||||
const {
|
||||
dbTableName,
|
||||
dbValueColumn,
|
||||
dbWhereColumn,
|
||||
dbWhereValue,
|
||||
dbHeaderName,
|
||||
dbHeaderTemplate,
|
||||
} = cfg;
|
||||
|
||||
if (!dbTableName || !dbValueColumn) {
|
||||
throw new Error("DB 토큰 설정이 올바르지 않습니다.");
|
||||
}
|
||||
|
||||
if (!userCompanyCode) {
|
||||
throw new Error("DB 토큰 모드에서는 회사 코드가 필요합니다.");
|
||||
}
|
||||
|
||||
const hasWhereColumn = !!dbWhereColumn;
|
||||
const hasWhereValue =
|
||||
dbWhereValue !== undefined && dbWhereValue !== null && dbWhereValue !== "";
|
||||
|
||||
// where 컬럼/값은 둘 다 비우거나 둘 다 채워야 함
|
||||
if (hasWhereColumn !== hasWhereValue) {
|
||||
throw new Error(
|
||||
"DB 토큰 설정에서 조건 컬럼과 조건 값은 둘 다 비우거나 둘 다 입력해야 합니다."
|
||||
);
|
||||
}
|
||||
|
||||
// 식별자 검증 (간단한 화이트리스트)
|
||||
const identifierRegex = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
||||
if (
|
||||
!identifierRegex.test(dbTableName) ||
|
||||
!identifierRegex.test(dbValueColumn) ||
|
||||
(hasWhereColumn && !identifierRegex.test(dbWhereColumn as string))
|
||||
) {
|
||||
throw new Error(
|
||||
"DB 토큰 설정에 유효하지 않은 테이블 또는 컬럼명이 포함되어 있습니다."
|
||||
);
|
||||
}
|
||||
|
||||
let sql = `
|
||||
SELECT ${dbValueColumn} AS token_value
|
||||
FROM ${dbTableName}
|
||||
WHERE company_code = $1
|
||||
`;
|
||||
|
||||
const params: any[] = [userCompanyCode];
|
||||
|
||||
if (hasWhereColumn && hasWhereValue) {
|
||||
sql += ` AND ${dbWhereColumn} = $2`;
|
||||
params.push(dbWhereValue);
|
||||
}
|
||||
|
||||
sql += `
|
||||
ORDER BY updated_date DESC
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
const tokenResult: QueryResult<any> = await pool.query(sql, params);
|
||||
|
||||
if (tokenResult.rowCount === 0) {
|
||||
throw new Error("DB에서 토큰을 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
const tokenValue = tokenResult.rows[0]["token_value"];
|
||||
const headerName = dbHeaderName || "Authorization";
|
||||
const template = dbHeaderTemplate || "Bearer {{value}}";
|
||||
|
||||
headers[headerName] = template.replace("{{value}}", tokenValue);
|
||||
} else if (
|
||||
testRequest.auth_type === "bearer" &&
|
||||
testRequest.auth_config?.token
|
||||
) {
|
||||
|
|
@ -493,25 +588,84 @@ export class ExternalRestApiConnectionService {
|
|||
`REST API 연결 테스트: ${testRequest.method || "GET"} ${url}`
|
||||
);
|
||||
|
||||
// HTTP 요청 실행
|
||||
const response = await fetch(url, {
|
||||
method: testRequest.method || "GET",
|
||||
headers,
|
||||
signal: AbortSignal.timeout(testRequest.timeout || 30000),
|
||||
});
|
||||
// Body 처리
|
||||
let body: any = undefined;
|
||||
if (testRequest.body) {
|
||||
// 이미 문자열이면 그대로, 객체면 JSON 문자열로 변환
|
||||
if (typeof testRequest.body === "string") {
|
||||
body = testRequest.body;
|
||||
} else {
|
||||
body = JSON.stringify(testRequest.body);
|
||||
}
|
||||
|
||||
const responseTime = Date.now() - startTime;
|
||||
let responseData = null;
|
||||
|
||||
try {
|
||||
responseData = await response.json();
|
||||
} catch {
|
||||
// JSON 파싱 실패는 무시 (텍스트 응답일 수 있음)
|
||||
// Content-Type 헤더가 없으면 기본적으로 application/json 추가
|
||||
const hasContentType = Object.keys(headers).some(
|
||||
(k) => k.toLowerCase() === "content-type"
|
||||
);
|
||||
if (!hasContentType) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
}
|
||||
|
||||
// HTTP 요청 실행
|
||||
// [인수인계 중요] 2024-11-27 추가
|
||||
// 특정 레거시/내부망 API(예: thiratis.com)의 경우 SSL 인증서 체인 문제로 인해
|
||||
// Node.js 레벨에서 검증 실패(UNABLE_TO_VERIFY_LEAF_SIGNATURE)가 발생합니다.
|
||||
//
|
||||
// 원래는 인프라(OS/Docker)에 루트 CA를 등록하는 것이 정석이나,
|
||||
// 유지보수 및 설정 편의성을 위해 코드 레벨에서 '특정 도메인'에 한해서만
|
||||
// SSL 검증을 우회하도록 예외 처리를 해두었습니다.
|
||||
//
|
||||
// ※ 보안 주의: 여기에 모르는 도메인을 함부로 추가하면 중간자 공격(MITM)에 취약해질 수 있습니다.
|
||||
// 꼭 필요한 신뢰할 수 있는 도메인만 추가하세요.
|
||||
const bypassDomains = ["thiratis.com"];
|
||||
const shouldBypassTls = bypassDomains.some((domain) =>
|
||||
url.includes(domain)
|
||||
);
|
||||
|
||||
const httpsAgent = new https.Agent({
|
||||
// bypassDomains에 포함된 URL이면 검증을 무시(false), 아니면 정상 검증(true)
|
||||
rejectUnauthorized: !shouldBypassTls,
|
||||
});
|
||||
|
||||
const requestConfig = {
|
||||
url,
|
||||
method: (testRequest.method || "GET") as any,
|
||||
headers,
|
||||
data: body,
|
||||
httpsAgent,
|
||||
timeout: testRequest.timeout || 30000,
|
||||
// 4xx/5xx 도 예외가 아니라 응답 객체로 처리
|
||||
validateStatus: () => true,
|
||||
};
|
||||
|
||||
// 요청 상세 로그 (민감 정보는 최소화)
|
||||
logger.info(
|
||||
`REST API 연결 테스트 요청 상세: ${JSON.stringify({
|
||||
method: requestConfig.method,
|
||||
url: requestConfig.url,
|
||||
headers: {
|
||||
...requestConfig.headers,
|
||||
// Authorization 헤더는 마스킹
|
||||
Authorization: requestConfig.headers?.Authorization
|
||||
? "***masked***"
|
||||
: undefined,
|
||||
},
|
||||
hasBody: !!body,
|
||||
})}`
|
||||
);
|
||||
|
||||
const response: AxiosResponse = await axios.request(requestConfig);
|
||||
|
||||
const responseTime = Date.now() - startTime;
|
||||
// axios는 response.data에 이미 파싱된 응답 본문을 담아준다.
|
||||
// JSON이 아니어도 그대로 내려보내서 프론트에서 확인할 수 있게 한다.
|
||||
const responseData = response.data ?? null;
|
||||
|
||||
return {
|
||||
success: response.ok,
|
||||
message: response.ok
|
||||
success: response.status >= 200 && response.status < 300,
|
||||
message:
|
||||
response.status >= 200 && response.status < 300
|
||||
? "연결 성공"
|
||||
: `연결 실패 (${response.status} ${response.statusText})`,
|
||||
response_time: responseTime,
|
||||
|
|
@ -552,17 +706,27 @@ export class ExternalRestApiConnectionService {
|
|||
|
||||
const connection = connectionResult.data;
|
||||
|
||||
// 리스트에서 endpoint를 넘기지 않으면,
|
||||
// 저장된 endpoint_path를 기본 엔드포인트로 사용
|
||||
const effectiveEndpoint =
|
||||
endpoint || connection.endpoint_path || undefined;
|
||||
|
||||
const testRequest: RestApiTestRequest = {
|
||||
id: connection.id,
|
||||
base_url: connection.base_url,
|
||||
endpoint,
|
||||
endpoint: effectiveEndpoint,
|
||||
method: (connection.default_method as any) || "GET", // 기본 메서드 적용
|
||||
headers: connection.default_headers,
|
||||
body: connection.default_body, // 기본 바디 적용
|
||||
auth_type: connection.auth_type,
|
||||
auth_config: connection.auth_config,
|
||||
timeout: connection.timeout,
|
||||
};
|
||||
|
||||
const result = await this.testConnection(testRequest);
|
||||
const result = await this.testConnection(
|
||||
testRequest,
|
||||
connection.company_code
|
||||
);
|
||||
|
||||
// 테스트 결과 저장
|
||||
await pool.query(
|
||||
|
|
@ -580,11 +744,34 @@ export class ExternalRestApiConnectionService {
|
|||
return result;
|
||||
} catch (error) {
|
||||
logger.error("REST API 연결 테스트 (ID) 오류:", error);
|
||||
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "알 수 없는 오류";
|
||||
|
||||
// 예외가 발생한 경우에도 마지막 테스트 결과를 실패로 기록
|
||||
try {
|
||||
await pool.query(
|
||||
`
|
||||
UPDATE external_rest_api_connections
|
||||
SET
|
||||
last_test_date = NOW(),
|
||||
last_test_result = $1,
|
||||
last_test_message = $2
|
||||
WHERE id = $3
|
||||
`,
|
||||
["N", errorMessage, id]
|
||||
);
|
||||
} catch (updateError) {
|
||||
logger.error(
|
||||
"REST API 연결 테스트 (ID) 오류 기록 실패:",
|
||||
updateError
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: "연결 테스트에 실패했습니다.",
|
||||
error_details:
|
||||
error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
error_details: errorMessage,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -709,6 +896,7 @@ export class ExternalRestApiConnectionService {
|
|||
"bearer",
|
||||
"basic",
|
||||
"oauth2",
|
||||
"db-token",
|
||||
];
|
||||
if (!validAuthTypes.includes(data.auth_type)) {
|
||||
throw new Error("올바르지 않은 인증 타입입니다.");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
// 외부 REST API 연결 관리 타입 정의
|
||||
|
||||
export type AuthType = "none" | "api-key" | "bearer" | "basic" | "oauth2";
|
||||
export type AuthType =
|
||||
| "none"
|
||||
| "api-key"
|
||||
| "bearer"
|
||||
| "basic"
|
||||
| "oauth2"
|
||||
| "db-token";
|
||||
|
||||
export interface ExternalRestApiConnection {
|
||||
id?: number;
|
||||
|
|
@ -9,6 +15,11 @@ export interface ExternalRestApiConnection {
|
|||
base_url: string;
|
||||
endpoint_path?: string;
|
||||
default_headers: Record<string, string>;
|
||||
|
||||
// 기본 메서드 및 바디 추가
|
||||
default_method?: string;
|
||||
default_body?: string;
|
||||
|
||||
auth_type: AuthType;
|
||||
auth_config?: {
|
||||
// API Key
|
||||
|
|
@ -28,6 +39,14 @@ export interface ExternalRestApiConnection {
|
|||
clientSecret?: string;
|
||||
tokenUrl?: string;
|
||||
accessToken?: string;
|
||||
|
||||
// DB 기반 토큰 모드
|
||||
dbTableName?: string;
|
||||
dbValueColumn?: string;
|
||||
dbWhereColumn?: string;
|
||||
dbWhereValue?: string;
|
||||
dbHeaderName?: string;
|
||||
dbHeaderTemplate?: string;
|
||||
};
|
||||
timeout?: number;
|
||||
retry_count?: number;
|
||||
|
|
@ -54,8 +73,9 @@ export interface RestApiTestRequest {
|
|||
id?: number;
|
||||
base_url: string;
|
||||
endpoint?: string;
|
||||
method?: "GET" | "POST" | "PUT" | "DELETE";
|
||||
method?: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
|
||||
headers?: Record<string, string>;
|
||||
body?: any; // 테스트 요청 바디 추가
|
||||
auth_type?: AuthType;
|
||||
auth_config?: any;
|
||||
timeout?: number;
|
||||
|
|
@ -76,4 +96,5 @@ export const AUTH_TYPE_OPTIONS = [
|
|||
{ value: "bearer", label: "Bearer Token" },
|
||||
{ value: "basic", label: "Basic Auth" },
|
||||
{ value: "oauth2", label: "OAuth 2.0" },
|
||||
{ value: "db-token", label: "DB 토큰" },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import React, { useState, useEffect, useMemo, memo } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -33,6 +33,31 @@ interface BatchColumnInfo {
|
|||
is_nullable: string;
|
||||
}
|
||||
|
||||
interface RestApiToDbMappingCardProps {
|
||||
fromApiFields: string[];
|
||||
toColumns: BatchColumnInfo[];
|
||||
fromApiData: any[];
|
||||
apiFieldMappings: Record<string, string>;
|
||||
setApiFieldMappings: React.Dispatch<
|
||||
React.SetStateAction<Record<string, string>>
|
||||
>;
|
||||
apiFieldPathOverrides: Record<string, string>;
|
||||
setApiFieldPathOverrides: React.Dispatch<
|
||||
React.SetStateAction<Record<string, string>>
|
||||
>;
|
||||
}
|
||||
|
||||
interface DbToRestApiMappingCardProps {
|
||||
fromColumns: BatchColumnInfo[];
|
||||
selectedColumns: string[];
|
||||
toApiFields: string[];
|
||||
dbToApiFieldMapping: Record<string, string>;
|
||||
setDbToApiFieldMapping: React.Dispatch<
|
||||
React.SetStateAction<Record<string, string>>
|
||||
>;
|
||||
setToApiBody: (body: string) => void;
|
||||
}
|
||||
|
||||
export default function BatchManagementNewPage() {
|
||||
const router = useRouter();
|
||||
|
||||
|
|
@ -52,7 +77,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 +109,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');
|
||||
|
|
@ -182,24 +210,17 @@ export default function BatchManagementNewPage() {
|
|||
|
||||
// TO 테이블 변경 핸들러
|
||||
const handleToTableChange = async (tableName: string) => {
|
||||
console.log("🔍 테이블 변경:", { tableName, toConnection });
|
||||
setToTable(tableName);
|
||||
setToColumns([]);
|
||||
|
||||
if (toConnection && tableName) {
|
||||
try {
|
||||
const connectionType = toConnection.type === 'internal' ? 'internal' : 'external';
|
||||
console.log("🔍 컬럼 조회 시작:", { connectionType, connectionId: toConnection.id, tableName });
|
||||
|
||||
const result = await BatchManagementAPI.getTableColumns(connectionType, tableName, toConnection.id);
|
||||
console.log("🔍 컬럼 조회 결과:", result);
|
||||
|
||||
if (result && result.length > 0) {
|
||||
setToColumns(result);
|
||||
console.log("✅ 컬럼 설정 완료:", result.length, "개");
|
||||
} else {
|
||||
setToColumns([]);
|
||||
console.log("⚠️ 컬럼이 없음");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ 컬럼 목록 로드 오류:", error);
|
||||
|
|
@ -239,7 +260,6 @@ export default function BatchManagementNewPage() {
|
|||
|
||||
// FROM 테이블 변경 핸들러 (DB → REST API용)
|
||||
const handleFromTableChange = async (tableName: string) => {
|
||||
console.log("🔍 FROM 테이블 변경:", { tableName, fromConnection });
|
||||
setFromTable(tableName);
|
||||
setFromColumns([]);
|
||||
setSelectedColumns([]); // 선택된 컬럼도 초기화
|
||||
|
|
@ -248,17 +268,11 @@ export default function BatchManagementNewPage() {
|
|||
if (fromConnection && tableName) {
|
||||
try {
|
||||
const connectionType = fromConnection.type === 'internal' ? 'internal' : 'external';
|
||||
console.log("🔍 FROM 컬럼 조회 시작:", { connectionType, connectionId: fromConnection.id, tableName });
|
||||
|
||||
const result = await BatchManagementAPI.getTableColumns(connectionType, tableName, fromConnection.id);
|
||||
console.log("🔍 FROM 컬럼 조회 결과:", result);
|
||||
|
||||
if (result && result.length > 0) {
|
||||
setFromColumns(result);
|
||||
console.log("✅ FROM 컬럼 설정 완료:", result.length, "개");
|
||||
} else {
|
||||
setFromColumns([]);
|
||||
console.log("⚠️ FROM 컬럼이 없음");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ FROM 컬럼 목록 로드 오류:", error);
|
||||
|
|
@ -276,8 +290,6 @@ export default function BatchManagementNewPage() {
|
|||
}
|
||||
|
||||
try {
|
||||
console.log("🔍 TO API 미리보기 시작:", { toApiUrl, toApiKey, toEndpoint, toApiMethod });
|
||||
|
||||
const result = await BatchManagementAPI.previewRestApiData(
|
||||
toApiUrl,
|
||||
toApiKey,
|
||||
|
|
@ -285,8 +297,6 @@ export default function BatchManagementNewPage() {
|
|||
'GET' // 미리보기는 항상 GET으로
|
||||
);
|
||||
|
||||
console.log("🔍 TO API 미리보기 결과:", result);
|
||||
|
||||
if (result.fields && result.fields.length > 0) {
|
||||
setToApiFields(result.fields);
|
||||
toast.success(`TO API 필드 ${result.fields.length}개를 조회했습니다.`);
|
||||
|
|
@ -303,17 +313,22 @@ 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;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("REST API 데이터 미리보기 시작...");
|
||||
|
||||
const result = await BatchManagementAPI.previewRestApiData(
|
||||
fromApiUrl,
|
||||
fromApiKey,
|
||||
fromApiKey || "",
|
||||
fromEndpoint,
|
||||
fromApiMethod,
|
||||
// 파라미터 정보 추가
|
||||
|
|
@ -322,33 +337,23 @@ 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);
|
||||
console.log("result.fields:", result.fields);
|
||||
console.log("result.samples:", result.samples);
|
||||
console.log("result.totalCount:", result.totalCount);
|
||||
|
||||
if (result.fields && result.fields.length > 0) {
|
||||
console.log("✅ 백엔드에서 fields 제공됨:", result.fields);
|
||||
setFromApiFields(result.fields);
|
||||
setFromApiData(result.samples);
|
||||
|
||||
console.log("추출된 필드:", result.fields);
|
||||
toast.success(`API 데이터 미리보기 완료! ${result.fields.length}개 필드, ${result.totalCount}개 레코드`);
|
||||
} else if (result.samples && result.samples.length > 0) {
|
||||
// 백엔드에서 fields를 제대로 보내지 않은 경우, 프론트엔드에서 직접 추출
|
||||
console.log("⚠️ 백엔드에서 fields가 없어서 프론트엔드에서 추출");
|
||||
const extractedFields = Object.keys(result.samples[0]);
|
||||
console.log("프론트엔드에서 추출한 필드:", extractedFields);
|
||||
|
||||
setFromApiFields(extractedFields);
|
||||
setFromApiData(result.samples);
|
||||
|
||||
toast.success(`API 데이터 미리보기 완료! ${extractedFields.length}개 필드, ${result.samples.length}개 레코드`);
|
||||
} else {
|
||||
console.log("❌ 데이터가 없음");
|
||||
setFromApiFields([]);
|
||||
setFromApiData([]);
|
||||
toast.warning("API에서 데이터를 가져올 수 없습니다.");
|
||||
|
|
@ -370,38 +375,53 @@ 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)
|
||||
|
||||
console.log("REST API 배치 설정 저장:", {
|
||||
batchName,
|
||||
batchType,
|
||||
cronSchedule,
|
||||
description,
|
||||
apiMappings
|
||||
// 기본은 상위 필드 그대로 사용하되,
|
||||
// 사용자가 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,
|
||||
};
|
||||
});
|
||||
|
||||
// 실제 API 호출
|
||||
|
|
@ -492,14 +512,6 @@ export default function BatchManagementNewPage() {
|
|||
}
|
||||
}
|
||||
|
||||
console.log("DB → REST API 배치 설정 저장:", {
|
||||
batchName,
|
||||
batchType,
|
||||
cronSchedule,
|
||||
description,
|
||||
dbMappings
|
||||
});
|
||||
|
||||
// 실제 API 호출 (기존 saveRestApiBatch 재사용)
|
||||
try {
|
||||
const result = await BatchManagementAPI.saveRestApiBatch({
|
||||
|
|
@ -645,13 +657,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 +691,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 +810,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 +828,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' ? '고정값' : '동적값'})
|
||||
|
|
@ -980,172 +1026,33 @@ export default function BatchManagementNewPage() {
|
|||
|
||||
{/* 매핑 UI - 배치 타입별 동적 렌더링 */}
|
||||
{/* REST API → DB 매핑 */}
|
||||
{batchType === 'restapi-to-db' && fromApiFields.length > 0 && toColumns.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>API 필드 → DB 컬럼 매핑</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3 max-h-96 overflow-y-auto border rounded-lg p-4">
|
||||
{fromApiFields.map((apiField) => (
|
||||
<div key={apiField} className="flex items-center space-x-4 p-3 bg-gray-50 rounded-lg">
|
||||
{/* API 필드 정보 */}
|
||||
<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 필드'
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 화살표 */}
|
||||
<div className="text-gray-400">
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</div>
|
||||
|
||||
{/* DB 컬럼 선택 */}
|
||||
<div className="flex-1">
|
||||
<Select
|
||||
value={apiFieldMappings[apiField] || "none"}
|
||||
onValueChange={(value) => {
|
||||
setApiFieldMappings(prev => ({
|
||||
...prev,
|
||||
[apiField]: value === "none" ? "" : value
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="DB 컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">선택 안함</SelectItem>
|
||||
{toColumns.map((column) => (
|
||||
<SelectItem key={column.column_name} value={column.column_name}>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{column.column_name.toUpperCase()}</span>
|
||||
<span className="text-xs text-gray-500">{column.data_type}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{fromApiData.length > 0 && (
|
||||
<div className="mt-3 p-3 bg-gray-50 rounded-lg">
|
||||
<div className="text-sm font-medium text-gray-700 mb-2">샘플 데이터 (최대 3개)</div>
|
||||
<div className="space-y-2 max-h-40 overflow-y-auto">
|
||||
{fromApiData.slice(0, 3).map((item, index) => (
|
||||
<div key={index} className="text-xs bg-white p-2 rounded border">
|
||||
<pre className="whitespace-pre-wrap">{JSON.stringify(item, null, 2)}</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{batchType === "restapi-to-db" &&
|
||||
fromApiFields.length > 0 &&
|
||||
toColumns.length > 0 && (
|
||||
<RestApiToDbMappingCard
|
||||
fromApiFields={fromApiFields}
|
||||
toColumns={toColumns}
|
||||
fromApiData={fromApiData}
|
||||
apiFieldMappings={apiFieldMappings}
|
||||
setApiFieldMappings={setApiFieldMappings}
|
||||
apiFieldPathOverrides={apiFieldPathOverrides}
|
||||
setApiFieldPathOverrides={setApiFieldPathOverrides}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* DB → REST API 매핑 */}
|
||||
{batchType === 'db-to-restapi' && selectedColumns.length > 0 && toApiFields.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>DB 컬럼 → API 필드 매핑</CardTitle>
|
||||
<CardDescription>
|
||||
DB 컬럼 값을 REST API Request Body에 매핑하세요. Request Body 템플릿에서 {`{{컬럼명}}`} 형태로 사용됩니다.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3 max-h-96 overflow-y-auto border rounded-lg p-4">
|
||||
{fromColumns.filter(column => selectedColumns.includes(column.column_name)).map((column) => (
|
||||
<div key={column.column_name} className="flex items-center space-x-4 p-3 bg-gray-50 rounded-lg">
|
||||
{/* DB 컬럼 정보 */}
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-sm">{column.column_name}</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
타입: {column.data_type} | NULL: {column.is_nullable ? 'Y' : 'N'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 화살표 */}
|
||||
<div className="text-gray-400">→</div>
|
||||
|
||||
{/* API 필드 선택 드롭다운 */}
|
||||
<div className="flex-1">
|
||||
<Select
|
||||
value={dbToApiFieldMapping[column.column_name] || ''}
|
||||
onValueChange={(value) => {
|
||||
setDbToApiFieldMapping(prev => ({
|
||||
...prev,
|
||||
[column.column_name]: value === 'none' ? '' : value
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="API 필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">선택 안함</SelectItem>
|
||||
{toApiFields.map((apiField) => (
|
||||
<SelectItem key={apiField} value={apiField}>
|
||||
{apiField}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="custom">직접 입력...</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 직접 입력 모드 */}
|
||||
{dbToApiFieldMapping[column.column_name] === 'custom' && (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="API 필드명을 직접 입력하세요"
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 mt-2"
|
||||
onChange={(e) => {
|
||||
setDbToApiFieldMapping(prev => ({
|
||||
...prev,
|
||||
[column.column_name]: e.target.value
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
{dbToApiFieldMapping[column.column_name]
|
||||
? `매핑: ${column.column_name} → ${dbToApiFieldMapping[column.column_name]}`
|
||||
: `기본값: ${column.column_name} (DB 컬럼명 사용)`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 템플릿 미리보기 */}
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-mono bg-white p-2 rounded border">
|
||||
{`{{${column.column_name}}}`}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
실제 DB 값으로 치환됩니다
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<div className="text-sm font-medium text-blue-800">매핑 사용 예시</div>
|
||||
<div className="text-xs text-blue-600 mt-1 font-mono">
|
||||
{`{"id": "{{id}}", "name": "{{user_name}}", "email": "{{email}}"}`}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{batchType === "db-to-restapi" &&
|
||||
selectedColumns.length > 0 &&
|
||||
toApiFields.length > 0 && (
|
||||
<DbToRestApiMappingCard
|
||||
fromColumns={fromColumns}
|
||||
selectedColumns={selectedColumns}
|
||||
toApiFields={toApiFields}
|
||||
dbToApiFieldMapping={dbToApiFieldMapping}
|
||||
setDbToApiFieldMapping={setDbToApiFieldMapping}
|
||||
setToApiBody={setToApiBody}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* TO 설정 */}
|
||||
<Card>
|
||||
|
|
@ -1349,3 +1256,277 @@ export default function BatchManagementNewPage() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const RestApiToDbMappingCard = memo(function RestApiToDbMappingCard({
|
||||
fromApiFields,
|
||||
toColumns,
|
||||
fromApiData,
|
||||
apiFieldMappings,
|
||||
setApiFieldMappings,
|
||||
apiFieldPathOverrides,
|
||||
setApiFieldPathOverrides,
|
||||
}: RestApiToDbMappingCardProps) {
|
||||
// 샘플 JSON 문자열은 의존 데이터가 바뀔 때만 계산
|
||||
const sampleJsonList = useMemo(
|
||||
() =>
|
||||
fromApiData.slice(0, 3).map((item) => JSON.stringify(item, null, 2)),
|
||||
[fromApiData]
|
||||
);
|
||||
|
||||
const firstSample = fromApiData[0] || null;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>API 필드 → DB 컬럼 매핑</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3 max-h-96 overflow-y-auto border rounded-lg p-4">
|
||||
{fromApiFields.map((apiField) => (
|
||||
<div
|
||||
key={apiField}
|
||||
className="flex items-center space-x-4 p-3 bg-gray-50 rounded-lg"
|
||||
>
|
||||
{/* API 필드 정보 */}
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-sm">{apiField}</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{firstSample && firstSample[apiField] !== undefined
|
||||
? `예: ${String(firstSample[apiField]).substring(0, 30)}${
|
||||
String(firstSample[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>
|
||||
|
||||
{/* 화살표 */}
|
||||
<div className="text-gray-400">
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</div>
|
||||
|
||||
{/* DB 컬럼 선택 */}
|
||||
<div className="flex-1">
|
||||
<Select
|
||||
value={apiFieldMappings[apiField] || "none"}
|
||||
onValueChange={(value) => {
|
||||
setApiFieldMappings((prev) => ({
|
||||
...prev,
|
||||
[apiField]: value === "none" ? "" : value,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="DB 컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">선택 안함</SelectItem>
|
||||
{toColumns.map((column) => (
|
||||
<SelectItem
|
||||
key={column.column_name}
|
||||
value={column.column_name}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">
|
||||
{column.column_name.toUpperCase()}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{column.data_type}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{sampleJsonList.length > 0 && (
|
||||
<div className="mt-3 p-3 bg-gray-50 rounded-lg">
|
||||
<div className="text-sm font-medium text-gray-700 mb-2">
|
||||
샘플 데이터 (최대 3개)
|
||||
</div>
|
||||
<div className="space-y-2 max-h-40 overflow-y-auto">
|
||||
{sampleJsonList.map((json, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="text-xs bg-white p-2 rounded border"
|
||||
>
|
||||
<pre className="whitespace-pre-wrap">{json}</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
|
||||
const DbToRestApiMappingCard = memo(function DbToRestApiMappingCard({
|
||||
fromColumns,
|
||||
selectedColumns,
|
||||
toApiFields,
|
||||
dbToApiFieldMapping,
|
||||
setDbToApiFieldMapping,
|
||||
setToApiBody,
|
||||
}: DbToRestApiMappingCardProps) {
|
||||
const selectedColumnObjects = useMemo(
|
||||
() =>
|
||||
fromColumns.filter((column) =>
|
||||
selectedColumns.includes(column.column_name)
|
||||
),
|
||||
[fromColumns, selectedColumns]
|
||||
);
|
||||
|
||||
const autoJsonPreview = useMemo(() => {
|
||||
if (selectedColumns.length === 0) {
|
||||
return "";
|
||||
}
|
||||
const obj = selectedColumns.reduce((acc, col) => {
|
||||
const apiField = dbToApiFieldMapping[col] || col;
|
||||
acc[apiField] = `{{${col}}}`;
|
||||
return acc;
|
||||
}, {} as Record<string, string>);
|
||||
return JSON.stringify(obj, null, 2);
|
||||
}, [selectedColumns, dbToApiFieldMapping]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>DB 컬럼 → API 필드 매핑</CardTitle>
|
||||
<CardDescription>
|
||||
DB 컬럼 값을 REST API Request Body에 매핑하세요. Request Body
|
||||
템플릿에서 {`{{컬럼명}}`} 형태로 사용됩니다.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3 max-h-96 overflow-y-auto border rounded-lg p-4">
|
||||
{selectedColumnObjects.map((column) => (
|
||||
<div
|
||||
key={column.column_name}
|
||||
className="flex items-center space-x-4 p-3 bg-gray-50 rounded-lg"
|
||||
>
|
||||
{/* DB 컬럼 정보 */}
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-sm">{column.column_name}</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
타입: {column.data_type} | NULL: {column.is_nullable ? "Y" : "N"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 화살표 */}
|
||||
<div className="text-gray-400">→</div>
|
||||
|
||||
{/* API 필드 선택 드롭다운 */}
|
||||
<div className="flex-1">
|
||||
<Select
|
||||
value={dbToApiFieldMapping[column.column_name] || ""}
|
||||
onValueChange={(value) => {
|
||||
setDbToApiFieldMapping((prev) => ({
|
||||
...prev,
|
||||
[column.column_name]: value === "none" ? "" : value,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="API 필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">선택 안함</SelectItem>
|
||||
{toApiFields.map((apiField) => (
|
||||
<SelectItem key={apiField} value={apiField}>
|
||||
{apiField}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="custom">직접 입력...</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 직접 입력 모드 */}
|
||||
{dbToApiFieldMapping[column.column_name] === "custom" && (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="API 필드명을 직접 입력하세요"
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 mt-2"
|
||||
onChange={(e) => {
|
||||
setDbToApiFieldMapping((prev) => ({
|
||||
...prev,
|
||||
[column.column_name]: e.target.value,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
{dbToApiFieldMapping[column.column_name]
|
||||
? `매핑: ${column.column_name} → ${dbToApiFieldMapping[column.column_name]}`
|
||||
: `기본값: ${column.column_name} (DB 컬럼명 사용)`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 템플릿 미리보기 */}
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-mono bg-white p-2 rounded border">
|
||||
{`{{${column.column_name}}}`}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
실제 DB 값으로 치환됩니다
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedColumns.length > 0 && (
|
||||
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<div className="text-sm font-medium text-blue-800">
|
||||
자동 생성된 JSON 구조
|
||||
</div>
|
||||
<pre className="mt-1 text-xs text-blue-600 font-mono overflow-x-auto">
|
||||
{autoJsonPreview}
|
||||
</pre>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setToApiBody(autoJsonPreview);
|
||||
toast.success(
|
||||
"Request Body에 자동 생성된 JSON이 적용되었습니다."
|
||||
);
|
||||
}}
|
||||
className="mt-2 px-3 py-1 bg-blue-600 text-white text-xs rounded hover:bg-blue-700"
|
||||
>
|
||||
Request Body에 적용
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<div className="text-sm font-medium text-blue-800">매핑 사용 예시</div>
|
||||
<div className="text-xs text-blue-600 mt-1 font-mono">
|
||||
{`{"id": "{{id}}", "name": "{{user_name}}", "email": "{{email}}"}`}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
|
|
@ -7,12 +7,24 @@ 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 {
|
||||
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";
|
||||
import {
|
||||
BatchAPI,
|
||||
BatchConfig,
|
||||
BatchMapping,
|
||||
ConnectionInfo,
|
||||
} from "@/lib/api/batch";
|
||||
import { BatchManagementAPI } from "@/lib/api/batchManagement";
|
||||
|
||||
interface BatchColumnInfo {
|
||||
column_name: string;
|
||||
|
|
@ -66,6 +78,9 @@ export default function BatchEditPage() {
|
|||
// 배치 타입 감지
|
||||
const [batchType, setBatchType] = useState<'db-to-db' | 'restapi-to-db' | 'db-to-restapi' | null>(null);
|
||||
|
||||
// REST API 미리보기 상태
|
||||
const [apiPreviewData, setApiPreviewData] = useState<any[]>([]);
|
||||
|
||||
|
||||
// 페이지 로드 시 배치 정보 조회
|
||||
useEffect(() => {
|
||||
|
|
@ -335,6 +350,86 @@ export default function BatchEditPage() {
|
|||
setMappings([...mappings, newMapping]);
|
||||
};
|
||||
|
||||
// REST API → DB 매핑 추가
|
||||
const addRestapiToDbMapping = () => {
|
||||
if (!batchConfig || !batchConfig.batch_mappings || batchConfig.batch_mappings.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const first = batchConfig.batch_mappings[0] as any;
|
||||
|
||||
const newMapping: BatchMapping = {
|
||||
// FROM: REST API (기존 설정 그대로 복사)
|
||||
from_connection_type: "restapi" as any,
|
||||
from_connection_id: first.from_connection_id,
|
||||
from_table_name: first.from_table_name,
|
||||
from_column_name: "",
|
||||
from_column_type: "",
|
||||
// TO: DB (기존 설정 그대로 복사)
|
||||
to_connection_type: first.to_connection_type as any,
|
||||
to_connection_id: first.to_connection_id,
|
||||
to_table_name: first.to_table_name,
|
||||
to_column_name: "",
|
||||
to_column_type: "",
|
||||
mapping_type: (first.mapping_type as any) || "direct",
|
||||
mapping_order: mappings.length + 1,
|
||||
};
|
||||
|
||||
setMappings((prev) => [...prev, newMapping]);
|
||||
};
|
||||
|
||||
// REST API 데이터 미리보기 (수정 화면용)
|
||||
const previewRestApiData = async () => {
|
||||
if (!mappings || mappings.length === 0) {
|
||||
toast.error("미리보기할 REST API 매핑이 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
const first: any = mappings[0];
|
||||
|
||||
if (!first.from_api_url || !first.from_table_name) {
|
||||
toast.error("API URL과 엔드포인트 정보가 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const method =
|
||||
(first.from_api_method as "GET" | "POST" | "PUT" | "DELETE") || "GET";
|
||||
|
||||
const paramInfo =
|
||||
first.from_api_param_type &&
|
||||
first.from_api_param_name &&
|
||||
first.from_api_param_value
|
||||
? {
|
||||
paramType: first.from_api_param_type as "url" | "query",
|
||||
paramName: first.from_api_param_name as string,
|
||||
paramValue: first.from_api_param_value as string,
|
||||
paramSource:
|
||||
(first.from_api_param_source as "static" | "dynamic") ||
|
||||
"static",
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const result = await BatchManagementAPI.previewRestApiData(
|
||||
first.from_api_url,
|
||||
first.from_api_key || "",
|
||||
first.from_table_name,
|
||||
method,
|
||||
paramInfo,
|
||||
first.from_api_body || undefined
|
||||
);
|
||||
|
||||
setApiPreviewData(result.samples || []);
|
||||
|
||||
toast.success(
|
||||
`API 데이터 미리보기 완료! ${result.fields.length}개 필드, ${result.samples.length}개 레코드`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("REST API 미리보기 오류:", error);
|
||||
toast.error("API 데이터 미리보기에 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// 매핑 삭제
|
||||
const removeMapping = (index: number) => {
|
||||
const updatedMappings = mappings.filter((_, i) => i !== index);
|
||||
|
|
@ -404,14 +499,16 @@ export default function BatchEditPage() {
|
|||
<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
|
||||
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>
|
||||
|
||||
|
|
@ -580,22 +677,91 @@ 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 />
|
||||
<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>
|
||||
<Label>HTTP 메서드</Label>
|
||||
<Input value={mappings[0]?.from_api_method || 'GET'} readOnly />
|
||||
</div>
|
||||
<div>
|
||||
<Label>대상 테이블</Label>
|
||||
<Input value={mappings[0]?.to_table_name || ''} readOnly />
|
||||
|
||||
{/* API 데이터 미리보기 */}
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={previewRestApiData}
|
||||
className="mt-2"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
API 데이터 미리보기
|
||||
</Button>
|
||||
|
||||
{apiPreviewData.length > 0 && (
|
||||
<div className="mt-2 rounded-lg border bg-muted p-3">
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
샘플 데이터 (최대 3개)
|
||||
</p>
|
||||
<div className="mt-2 space-y-2 max-h-60 overflow-y-auto">
|
||||
{apiPreviewData.slice(0, 3).map((item, index) => (
|
||||
<pre
|
||||
key={index}
|
||||
className="whitespace-pre-wrap rounded border bg-background p-2 text-xs font-mono"
|
||||
>
|
||||
{JSON.stringify(item, null, 2)}
|
||||
</pre>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -647,6 +813,12 @@ export default function BatchEditPage() {
|
|||
매핑 추가
|
||||
</Button>
|
||||
)}
|
||||
{batchType === 'restapi-to-db' && (
|
||||
<Button onClick={addRestapiToDbMapping} size="sm">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
매핑 추가
|
||||
</Button>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
|
@ -751,20 +923,73 @@ export default function BatchEditPage() {
|
|||
<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>
|
||||
{mapping.from_column_name && mapping.to_column_name && (
|
||||
<p className="text-sm text-gray-600">
|
||||
API 필드: {mapping.from_column_name} → DB 컬럼: {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>API 필드명</Label>
|
||||
<Input value={mapping.from_column_name || ''} readOnly />
|
||||
<Label>API 필드명 (JSON 경로)</Label>
|
||||
<Input
|
||||
value={mapping.from_column_name || ""}
|
||||
onChange={(e) =>
|
||||
updateMapping(
|
||||
index,
|
||||
"from_column_name",
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
placeholder="response.access_token"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>DB 컬럼명</Label>
|
||||
<Input value={mapping.to_column_name || ''} readOnly />
|
||||
<Select
|
||||
value={mapping.to_column_name || ""}
|
||||
onValueChange={(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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -42,6 +42,7 @@ export function AuthenticationConfig({
|
|||
<SelectItem value="bearer">Bearer Token</SelectItem>
|
||||
<SelectItem value="basic">Basic Auth</SelectItem>
|
||||
<SelectItem value="oauth2">OAuth 2.0</SelectItem>
|
||||
<SelectItem value="db-token">DB 토큰</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
|
@ -192,6 +193,94 @@ export function AuthenticationConfig({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{authType === "db-token" && (
|
||||
<div className="space-y-4 rounded-md border bg-gray-50 p-4">
|
||||
<h4 className="text-sm font-medium">DB 기반 토큰 설정</h4>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="db-table-name">테이블명</Label>
|
||||
<Input
|
||||
id="db-table-name"
|
||||
type="text"
|
||||
value={authConfig.dbTableName || ""}
|
||||
onChange={(e) => updateAuthConfig("dbTableName", e.target.value)}
|
||||
placeholder="예: auth_tokens"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="db-value-column">값 컬럼명</Label>
|
||||
<Input
|
||||
id="db-value-column"
|
||||
type="text"
|
||||
value={authConfig.dbValueColumn || ""}
|
||||
onChange={(e) =>
|
||||
updateAuthConfig("dbValueColumn", e.target.value)
|
||||
}
|
||||
placeholder="예: access_token"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="db-where-column">조건 컬럼명</Label>
|
||||
<Input
|
||||
id="db-where-column"
|
||||
type="text"
|
||||
value={authConfig.dbWhereColumn || ""}
|
||||
onChange={(e) =>
|
||||
updateAuthConfig("dbWhereColumn", e.target.value)
|
||||
}
|
||||
placeholder="예: service_name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="db-where-value">조건 값</Label>
|
||||
<Input
|
||||
id="db-where-value"
|
||||
type="text"
|
||||
value={authConfig.dbWhereValue || ""}
|
||||
onChange={(e) =>
|
||||
updateAuthConfig("dbWhereValue", e.target.value)
|
||||
}
|
||||
placeholder="예: kakao"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="db-header-name">헤더 이름 (선택)</Label>
|
||||
<Input
|
||||
id="db-header-name"
|
||||
type="text"
|
||||
value={authConfig.dbHeaderName || ""}
|
||||
onChange={(e) =>
|
||||
updateAuthConfig("dbHeaderName", e.target.value)
|
||||
}
|
||||
placeholder="기본값: Authorization"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="db-header-template">
|
||||
헤더 템플릿 (선택, {{value}} 치환)
|
||||
</Label>
|
||||
<Input
|
||||
id="db-header-template"
|
||||
type="text"
|
||||
value={authConfig.dbHeaderTemplate || ""}
|
||||
onChange={(e) =>
|
||||
updateAuthConfig("dbHeaderTemplate", e.target.value)
|
||||
}
|
||||
placeholder='기본값: "Bearer {{value}}"'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-500">
|
||||
company_code는 현재 로그인한 사용자의 회사 코드로 자동 필터링됩니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{authType === "none" && (
|
||||
<div className="rounded-md border border-dashed p-4 text-center text-sm text-gray-500">
|
||||
인증이 필요하지 않은 공개 API입니다.
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ const AUTH_TYPE_LABELS: Record<string, string> = {
|
|||
bearer: "Bearer",
|
||||
basic: "Basic Auth",
|
||||
oauth2: "OAuth 2.0",
|
||||
"db-token": "DB 토큰",
|
||||
};
|
||||
|
||||
// 활성 상태 옵션
|
||||
|
|
@ -158,6 +159,22 @@ export function RestApiConnectionList() {
|
|||
|
||||
setTestResults((prev) => new Map(prev).set(connection.id!, result.success));
|
||||
|
||||
// 현재 행의 "마지막 테스트" 정보만 낙관적으로 업데이트하여
|
||||
// 전체 목록 리로딩 없이도 UI를 즉시 반영한다.
|
||||
const nowIso = new Date().toISOString();
|
||||
setConnections((prev) =>
|
||||
prev.map((c) =>
|
||||
c.id === connection.id
|
||||
? {
|
||||
...c,
|
||||
last_test_date: nowIso as any,
|
||||
last_test_result: result.success ? "Y" : "N",
|
||||
last_test_message: result.message,
|
||||
}
|
||||
: c
|
||||
)
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
toast({
|
||||
title: "연결 성공",
|
||||
|
|
|
|||
|
|
@ -21,10 +21,13 @@ import {
|
|||
ExternalRestApiConnection,
|
||||
AuthType,
|
||||
RestApiTestResult,
|
||||
RestApiTestRequest,
|
||||
} from "@/lib/api/externalRestApiConnection";
|
||||
import { HeadersManager } from "./HeadersManager";
|
||||
import { AuthenticationConfig } from "./AuthenticationConfig";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
interface RestApiConnectionModalProps {
|
||||
isOpen: boolean;
|
||||
|
|
@ -42,6 +45,8 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
const [baseUrl, setBaseUrl] = useState("");
|
||||
const [endpointPath, setEndpointPath] = useState("");
|
||||
const [defaultHeaders, setDefaultHeaders] = useState<Record<string, string>>({});
|
||||
const [defaultMethod, setDefaultMethod] = useState("GET");
|
||||
const [defaultBody, setDefaultBody] = useState("");
|
||||
const [authType, setAuthType] = useState<AuthType>("none");
|
||||
const [authConfig, setAuthConfig] = useState<any>({});
|
||||
const [timeout, setTimeout] = useState(30000);
|
||||
|
|
@ -52,6 +57,8 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
// UI 상태
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [testEndpoint, setTestEndpoint] = useState("");
|
||||
const [testMethod, setTestMethod] = useState("GET");
|
||||
const [testBody, setTestBody] = useState("");
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<RestApiTestResult | null>(null);
|
||||
const [testRequestUrl, setTestRequestUrl] = useState<string>("");
|
||||
|
|
@ -65,12 +72,19 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
setBaseUrl(connection.base_url);
|
||||
setEndpointPath(connection.endpoint_path || "");
|
||||
setDefaultHeaders(connection.default_headers || {});
|
||||
setDefaultMethod(connection.default_method || "GET");
|
||||
setDefaultBody(connection.default_body || "");
|
||||
setAuthType(connection.auth_type);
|
||||
setAuthConfig(connection.auth_config || {});
|
||||
setTimeout(connection.timeout || 30000);
|
||||
setRetryCount(connection.retry_count || 0);
|
||||
setRetryDelay(connection.retry_delay || 1000);
|
||||
setIsActive(connection.is_active === "Y");
|
||||
|
||||
// 테스트 초기값 설정
|
||||
setTestEndpoint("");
|
||||
setTestMethod(connection.default_method || "GET");
|
||||
setTestBody(connection.default_body || "");
|
||||
} else {
|
||||
// 초기화
|
||||
setConnectionName("");
|
||||
|
|
@ -78,16 +92,22 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
setBaseUrl("");
|
||||
setEndpointPath("");
|
||||
setDefaultHeaders({ "Content-Type": "application/json" });
|
||||
setDefaultMethod("GET");
|
||||
setDefaultBody("");
|
||||
setAuthType("none");
|
||||
setAuthConfig({});
|
||||
setTimeout(30000);
|
||||
setRetryCount(0);
|
||||
setRetryDelay(1000);
|
||||
setIsActive(true);
|
||||
|
||||
// 테스트 초기값 설정
|
||||
setTestEndpoint("");
|
||||
setTestMethod("GET");
|
||||
setTestBody("");
|
||||
}
|
||||
|
||||
setTestResult(null);
|
||||
setTestEndpoint("");
|
||||
setTestRequestUrl("");
|
||||
}, [connection, isOpen]);
|
||||
|
||||
|
|
@ -111,14 +131,18 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
setTestRequestUrl(fullUrl);
|
||||
|
||||
try {
|
||||
const result = await ExternalRestApiConnectionAPI.testConnection({
|
||||
const testRequest: RestApiTestRequest = {
|
||||
base_url: baseUrl,
|
||||
endpoint: testEndpoint || undefined,
|
||||
method: testMethod as any,
|
||||
headers: defaultHeaders,
|
||||
body: testBody ? JSON.parse(testBody) : undefined,
|
||||
auth_type: authType,
|
||||
auth_config: authConfig,
|
||||
timeout,
|
||||
});
|
||||
};
|
||||
|
||||
const result = await ExternalRestApiConnectionAPI.testConnection(testRequest);
|
||||
|
||||
setTestResult(result);
|
||||
|
||||
|
|
@ -178,6 +202,20 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
return;
|
||||
}
|
||||
|
||||
// JSON 유효성 검증
|
||||
if (defaultBody && defaultMethod !== "GET" && defaultMethod !== "DELETE") {
|
||||
try {
|
||||
JSON.parse(defaultBody);
|
||||
} catch {
|
||||
toast({
|
||||
title: "입력 오류",
|
||||
description: "기본 Body가 올바른 JSON 형식이 아닙니다.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
|
|
@ -187,6 +225,8 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
base_url: baseUrl,
|
||||
endpoint_path: endpointPath || undefined,
|
||||
default_headers: defaultHeaders,
|
||||
default_method: defaultMethod,
|
||||
default_body: defaultBody || undefined,
|
||||
auth_type: authType,
|
||||
auth_config: authType === "none" ? undefined : authConfig,
|
||||
timeout,
|
||||
|
|
@ -262,12 +302,28 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
<Label htmlFor="base-url">
|
||||
기본 URL <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="base-url"
|
||||
value={baseUrl}
|
||||
onChange={(e) => setBaseUrl(e.target.value)}
|
||||
placeholder="https://api.example.com"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Select value={defaultMethod} onValueChange={setDefaultMethod}>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue placeholder="Method" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="GET">GET</SelectItem>
|
||||
<SelectItem value="POST">POST</SelectItem>
|
||||
<SelectItem value="PUT">PUT</SelectItem>
|
||||
<SelectItem value="DELETE">DELETE</SelectItem>
|
||||
<SelectItem value="PATCH">PATCH</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
id="base-url"
|
||||
value={baseUrl}
|
||||
onChange={(e) => setBaseUrl(e.target.value)}
|
||||
placeholder="https://api.example.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
도메인 부분만 입력하세요 (예: https://apihub.kma.go.kr)
|
||||
</p>
|
||||
|
|
@ -286,6 +342,21 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
</p>
|
||||
</div>
|
||||
|
||||
{/* 기본 Body (POST, PUT, PATCH일 때만 표시) */}
|
||||
{(defaultMethod === "POST" || defaultMethod === "PUT" || defaultMethod === "PATCH") && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="default-body">기본 Request Body (JSON)</Label>
|
||||
<Textarea
|
||||
id="default-body"
|
||||
value={defaultBody}
|
||||
onChange={(e) => setDefaultBody(e.target.value)}
|
||||
placeholder='{"key": "value"}'
|
||||
className="font-mono text-xs"
|
||||
rows={5}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch id="is-active" checked={isActive} onCheckedChange={setIsActive} />
|
||||
<Label htmlFor="is-active" className="cursor-pointer">
|
||||
|
|
@ -370,13 +441,45 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
<h3 className="text-sm font-semibold">연결 테스트</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="test-endpoint">테스트 엔드포인트 (선택)</Label>
|
||||
<Input
|
||||
id="test-endpoint"
|
||||
value={testEndpoint}
|
||||
onChange={(e) => setTestEndpoint(e.target.value)}
|
||||
placeholder="엔드포인트 또는 빈칸(기본 URL만 테스트)"
|
||||
/>
|
||||
<Label htmlFor="test-endpoint">테스트 설정</Label>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<Select value={testMethod} onValueChange={setTestMethod}>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue placeholder="Method" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="GET">GET</SelectItem>
|
||||
<SelectItem value="POST">POST</SelectItem>
|
||||
<SelectItem value="PUT">PUT</SelectItem>
|
||||
<SelectItem value="DELETE">DELETE</SelectItem>
|
||||
<SelectItem value="PATCH">PATCH</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
id="test-endpoint"
|
||||
value={testEndpoint}
|
||||
onChange={(e) => setTestEndpoint(e.target.value)}
|
||||
placeholder="엔드포인트 (예: /users/1)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(testMethod === "POST" || testMethod === "PUT" || testMethod === "PATCH") && (
|
||||
<div className="mt-2">
|
||||
<Label htmlFor="test-body" className="text-xs text-muted-foreground mb-1 block">
|
||||
Test Request Body (JSON)
|
||||
</Label>
|
||||
<Textarea
|
||||
id="test-body"
|
||||
value={testBody}
|
||||
onChange={(e) => setTestBody(e.target.value)}
|
||||
placeholder='{"test": "data"}'
|
||||
className="font-mono text-xs"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button type="button" variant="outline" onClick={handleTest} disabled={testing}>
|
||||
|
|
@ -388,10 +491,22 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
{testRequestUrl && (
|
||||
<div className="bg-muted/30 space-y-3 rounded-md border p-3">
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-1 text-xs font-medium">테스트 요청 URL</div>
|
||||
<code className="text-foreground block text-xs break-all">GET {testRequestUrl}</code>
|
||||
<div className="text-muted-foreground mb-1 text-xs font-medium">테스트 요청</div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Badge variant="outline">{testMethod}</Badge>
|
||||
<code className="text-foreground text-xs break-all">{testRequestUrl}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{testBody && (testMethod === "POST" || testMethod === "PUT" || testMethod === "PATCH") && (
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-1 text-xs font-medium">Request Body</div>
|
||||
<pre className="bg-muted p-2 rounded text-xs overflow-auto max-h-[100px]">
|
||||
{testBody}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{Object.keys(defaultHeaders).length > 0 && (
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-1 text-xs font-medium">요청 헤더</div>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowLeft, Save, Loader2, Grid3x3, Move, Box, Package, Truck, Check } from "lucide-react";
|
||||
import { ArrowLeft, Save, Loader2, Grid3x3, Move, Box, Package, Truck, Check, ParkingCircle } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
|
@ -39,6 +39,77 @@ import {
|
|||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
// 성능 최적화를 위한 디바운스/Blur 처리된 Input 컴포넌트
|
||||
const DebouncedInput = ({
|
||||
value,
|
||||
onChange,
|
||||
onCommit,
|
||||
type = "text",
|
||||
debounce = 0,
|
||||
...props
|
||||
}: React.InputHTMLAttributes<HTMLInputElement> & {
|
||||
onCommit?: (value: any) => void;
|
||||
debounce?: number;
|
||||
}) => {
|
||||
const [localValue, setLocalValue] = useState(value);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditing) {
|
||||
setLocalValue(value);
|
||||
}
|
||||
}, [value, isEditing]);
|
||||
|
||||
// 색상 입력 등을 위한 디바운스 커밋
|
||||
useEffect(() => {
|
||||
if (debounce > 0 && isEditing && onCommit) {
|
||||
const timer = setTimeout(() => {
|
||||
onCommit(type === "number" ? parseFloat(localValue as string) : localValue);
|
||||
}, debounce);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [localValue, debounce, isEditing, onCommit, type]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setLocalValue(e.target.value);
|
||||
if (onChange) onChange(e);
|
||||
};
|
||||
|
||||
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
|
||||
setIsEditing(false);
|
||||
if (onCommit && debounce === 0) {
|
||||
// 값이 변경되었을 때만 커밋하도록 하면 좋겠지만,
|
||||
// 부모 상태와 비교하기 어려우므로 항상 커밋 (handleObjectUpdate 내부에서 처리됨)
|
||||
onCommit(type === "number" ? parseFloat(localValue as string) : localValue);
|
||||
}
|
||||
if (props.onBlur) props.onBlur(e);
|
||||
};
|
||||
|
||||
const handleFocus = (e: React.FocusEvent<HTMLInputElement>) => {
|
||||
setIsEditing(true);
|
||||
if (props.onFocus) props.onFocus(e);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
if (props.onKeyDown) props.onKeyDown(e);
|
||||
};
|
||||
|
||||
return (
|
||||
<Input
|
||||
{...props}
|
||||
type={type}
|
||||
value={localValue}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// 백엔드 DB 객체 타입 (snake_case)
|
||||
interface DbObject {
|
||||
id: number;
|
||||
|
|
@ -550,10 +621,11 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
areaKey: obj.area_key,
|
||||
locaKey: obj.loca_key,
|
||||
locType: obj.loc_type,
|
||||
materialCount: obj.material_count,
|
||||
materialPreview: obj.material_preview_height
|
||||
? { height: parseFloat(obj.material_preview_height) }
|
||||
: undefined,
|
||||
materialCount: obj.loc_type === "STP" ? undefined : obj.material_count,
|
||||
materialPreview:
|
||||
obj.loc_type === "STP" || !obj.material_preview_height
|
||||
? undefined
|
||||
: { height: parseFloat(obj.material_preview_height) },
|
||||
parentId: obj.parent_id,
|
||||
displayOrder: obj.display_order,
|
||||
locked: obj.locked,
|
||||
|
|
@ -761,12 +833,9 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
// 기본 크기 설정
|
||||
let objectSize = defaults.size || { x: 5, y: 5, z: 5 };
|
||||
|
||||
// Location 배치 시 자재 개수에 따라 높이 자동 설정
|
||||
// Location 배치 시 자재 개수에 따라 높이 자동 설정 (BED/TMP/DES만 대상, STP는 자재 미적재)
|
||||
if (
|
||||
(draggedTool === "location-bed" ||
|
||||
draggedTool === "location-stp" ||
|
||||
draggedTool === "location-temp" ||
|
||||
draggedTool === "location-dest") &&
|
||||
(draggedTool === "location-bed" || draggedTool === "location-temp" || draggedTool === "location-dest") &&
|
||||
locaKey &&
|
||||
selectedDbConnection &&
|
||||
hierarchyConfig?.material
|
||||
|
|
@ -877,12 +946,9 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
setDraggedAreaData(null);
|
||||
setDraggedLocationData(null);
|
||||
|
||||
// Location 배치 시 자재 개수 로드
|
||||
// Location 배치 시 자재 개수 로드 (BED/TMP/DES만 대상, STP는 자재 미적재)
|
||||
if (
|
||||
(draggedTool === "location-bed" ||
|
||||
draggedTool === "location-stp" ||
|
||||
draggedTool === "location-temp" ||
|
||||
draggedTool === "location-dest") &&
|
||||
(draggedTool === "location-bed" || draggedTool === "location-temp" || draggedTool === "location-dest") &&
|
||||
locaKey
|
||||
) {
|
||||
// 새 객체 추가 후 자재 개수 로드 (약간의 딜레이를 두어 state 업데이트 완료 후 실행)
|
||||
|
|
@ -965,13 +1031,10 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
loadLocationsForArea(obj.areaKey);
|
||||
setShowMaterialPanel(false);
|
||||
}
|
||||
// Location을 클릭한 경우, 해당 Location의 자재 목록 로드
|
||||
// Location을 클릭한 경우, 해당 Location의 자재 목록 로드 (STP는 자재 미적재이므로 제외)
|
||||
else if (
|
||||
obj &&
|
||||
(obj.type === "location-bed" ||
|
||||
obj.type === "location-stp" ||
|
||||
obj.type === "location-temp" ||
|
||||
obj.type === "location-dest") &&
|
||||
(obj.type === "location-bed" || obj.type === "location-temp" || obj.type === "location-dest") &&
|
||||
obj.locaKey &&
|
||||
selectedDbConnection
|
||||
) {
|
||||
|
|
@ -988,9 +1051,15 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
try {
|
||||
const response = await getMaterialCounts(selectedDbConnection, selectedTables.material, locaKeys);
|
||||
if (response.success && response.data) {
|
||||
// 각 Location 객체에 자재 개수 업데이트
|
||||
// 각 Location 객체에 자재 개수 업데이트 (STP는 자재 미적재이므로 제외)
|
||||
setPlacedObjects((prev) =>
|
||||
prev.map((obj) => {
|
||||
if (
|
||||
!obj.locaKey ||
|
||||
obj.type === "location-stp" // STP는 자재 없음
|
||||
) {
|
||||
return obj;
|
||||
}
|
||||
const materialCount = response.data?.find((mc) => mc.LOCAKEY === obj.locaKey);
|
||||
if (materialCount) {
|
||||
return {
|
||||
|
|
@ -1278,7 +1347,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
const oldSize = actualObject.size;
|
||||
const newSize = { ...oldSize, ...updates.size };
|
||||
|
||||
// W, D를 5 단위로 스냅
|
||||
// W, D를 5 단위로 스냅 (STP 포함)
|
||||
newSize.x = Math.max(5, Math.round(newSize.x / 5) * 5);
|
||||
newSize.z = Math.max(5, Math.round(newSize.z / 5) * 5);
|
||||
|
||||
|
|
@ -1391,10 +1460,11 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
areaKey: obj.area_key,
|
||||
locaKey: obj.loca_key,
|
||||
locType: obj.loc_type,
|
||||
materialCount: obj.material_count,
|
||||
materialPreview: obj.material_preview_height
|
||||
? { height: parseFloat(obj.material_preview_height) }
|
||||
: undefined,
|
||||
materialCount: obj.loc_type === "STP" ? undefined : obj.material_count,
|
||||
materialPreview:
|
||||
obj.loc_type === "STP" || !obj.material_preview_height
|
||||
? undefined
|
||||
: { height: parseFloat(obj.material_preview_height) },
|
||||
parentId: obj.parent_id,
|
||||
displayOrder: obj.display_order,
|
||||
locked: obj.locked,
|
||||
|
|
@ -1798,6 +1868,8 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
</div>
|
||||
{isLocationPlaced ? (
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
) : locationType === "location-stp" ? (
|
||||
<ParkingCircle className="text-muted-foreground h-4 w-4" />
|
||||
) : (
|
||||
<Package className="text-muted-foreground h-4 w-4" />
|
||||
)}
|
||||
|
|
@ -2069,10 +2141,10 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
<Label htmlFor="object-name" className="text-sm">
|
||||
이름
|
||||
</Label>
|
||||
<Input
|
||||
<DebouncedInput
|
||||
id="object-name"
|
||||
value={selectedObject.name || ""}
|
||||
onChange={(e) => handleObjectUpdate({ name: e.target.value })}
|
||||
onCommit={(val) => handleObjectUpdate({ name: val })}
|
||||
className="mt-1.5 h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -2085,15 +2157,15 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
<Label htmlFor="pos-x" className="text-muted-foreground text-xs">
|
||||
X
|
||||
</Label>
|
||||
<Input
|
||||
<DebouncedInput
|
||||
id="pos-x"
|
||||
type="number"
|
||||
value={(selectedObject.position?.x || 0).toFixed(1)}
|
||||
onChange={(e) =>
|
||||
onCommit={(val) =>
|
||||
handleObjectUpdate({
|
||||
position: {
|
||||
...selectedObject.position,
|
||||
x: parseFloat(e.target.value),
|
||||
x: val,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -2104,15 +2176,15 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
<Label htmlFor="pos-z" className="text-muted-foreground text-xs">
|
||||
Z
|
||||
</Label>
|
||||
<Input
|
||||
<DebouncedInput
|
||||
id="pos-z"
|
||||
type="number"
|
||||
value={(selectedObject.position?.z || 0).toFixed(1)}
|
||||
onChange={(e) =>
|
||||
onCommit={(val) =>
|
||||
handleObjectUpdate({
|
||||
position: {
|
||||
...selectedObject.position,
|
||||
z: parseFloat(e.target.value),
|
||||
z: val,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -2130,17 +2202,17 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
<Label htmlFor="size-x" className="text-muted-foreground text-xs">
|
||||
W (5 단위)
|
||||
</Label>
|
||||
<Input
|
||||
<DebouncedInput
|
||||
id="size-x"
|
||||
type="number"
|
||||
step="5"
|
||||
min="5"
|
||||
value={selectedObject.size?.x || 5}
|
||||
onChange={(e) =>
|
||||
onCommit={(val) =>
|
||||
handleObjectUpdate({
|
||||
size: {
|
||||
...selectedObject.size,
|
||||
x: parseFloat(e.target.value),
|
||||
x: val,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -2151,15 +2223,15 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
<Label htmlFor="size-y" className="text-muted-foreground text-xs">
|
||||
H
|
||||
</Label>
|
||||
<Input
|
||||
<DebouncedInput
|
||||
id="size-y"
|
||||
type="number"
|
||||
value={selectedObject.size?.y || 5}
|
||||
onChange={(e) =>
|
||||
onCommit={(val) =>
|
||||
handleObjectUpdate({
|
||||
size: {
|
||||
...selectedObject.size,
|
||||
y: parseFloat(e.target.value),
|
||||
y: val,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -2170,17 +2242,17 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
<Label htmlFor="size-z" className="text-muted-foreground text-xs">
|
||||
D (5 단위)
|
||||
</Label>
|
||||
<Input
|
||||
<DebouncedInput
|
||||
id="size-z"
|
||||
type="number"
|
||||
step="5"
|
||||
min="5"
|
||||
value={selectedObject.size?.z || 5}
|
||||
onChange={(e) =>
|
||||
onCommit={(val) =>
|
||||
handleObjectUpdate({
|
||||
size: {
|
||||
...selectedObject.size,
|
||||
z: parseFloat(e.target.value),
|
||||
z: val,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -2195,11 +2267,12 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
<Label htmlFor="object-color" className="text-sm">
|
||||
색상
|
||||
</Label>
|
||||
<Input
|
||||
<DebouncedInput
|
||||
id="object-color"
|
||||
type="color"
|
||||
debounce={100}
|
||||
value={selectedObject.color || "#3b82f6"}
|
||||
onChange={(e) => handleObjectUpdate({ color: e.target.value })}
|
||||
onCommit={(val) => handleObjectUpdate({ color: val })}
|
||||
className="mt-1.5 h-9"
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { Loader2, Search, X, Grid3x3, Package } from "lucide-react";
|
||||
import { Loader2, Search, X, Grid3x3, Package, ParkingCircle } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -87,10 +87,11 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
|||
areaKey: obj.area_key,
|
||||
locaKey: obj.loca_key,
|
||||
locType: obj.loc_type,
|
||||
materialCount: obj.material_count,
|
||||
materialPreview: obj.material_preview_height
|
||||
? { height: parseFloat(obj.material_preview_height) }
|
||||
: undefined,
|
||||
materialCount: obj.loc_type === "STP" ? undefined : obj.material_count,
|
||||
materialPreview:
|
||||
obj.loc_type === "STP" || !obj.material_preview_height
|
||||
? undefined
|
||||
: { height: parseFloat(obj.material_preview_height) },
|
||||
parentId: obj.parent_id,
|
||||
displayOrder: obj.display_order,
|
||||
locked: obj.locked,
|
||||
|
|
@ -166,13 +167,10 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
|||
const obj = placedObjects.find((o) => o.id === objectId);
|
||||
setSelectedObject(obj || null);
|
||||
|
||||
// Location을 클릭한 경우, 자재 정보 표시
|
||||
// Location을 클릭한 경우, 자재 정보 표시 (STP는 자재 미적재이므로 제외)
|
||||
if (
|
||||
obj &&
|
||||
(obj.type === "location-bed" ||
|
||||
obj.type === "location-stp" ||
|
||||
obj.type === "location-temp" ||
|
||||
obj.type === "location-dest") &&
|
||||
(obj.type === "location-bed" || obj.type === "location-temp" || obj.type === "location-dest") &&
|
||||
obj.locaKey &&
|
||||
externalDbConnectionId
|
||||
) {
|
||||
|
|
@ -363,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 계층 아코디언
|
||||
|
|
@ -471,7 +469,11 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
|||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Package className="h-3 w-3" />
|
||||
{locationObj.type === "location-stp" ? (
|
||||
<ParkingCircle className="h-3 w-3" />
|
||||
) : (
|
||||
<Package className="h-3 w-3" />
|
||||
)}
|
||||
<span className="text-xs font-medium">{locationObj.name}</span>
|
||||
</div>
|
||||
<span
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -593,52 +593,58 @@ function MaterialBox({
|
|||
);
|
||||
|
||||
case "location-stp":
|
||||
// 정차포인트(STP): 주황색 낮은 플랫폼
|
||||
return (
|
||||
<>
|
||||
<Box args={[boxWidth, boxHeight, boxDepth]}>
|
||||
<meshStandardMaterial
|
||||
color={placement.color}
|
||||
roughness={0.6}
|
||||
metalness={0.2}
|
||||
emissive={isSelected ? placement.color : "#000000"}
|
||||
emissiveIntensity={isSelected ? glowIntensity * 0.8 : 0}
|
||||
/>
|
||||
</Box>
|
||||
// 정차포인트(STP): 회색 타원형 플랫폼 + 'P' 마크 (자재 미적재 영역)
|
||||
{
|
||||
const baseRadius = 0.5; // 스케일로 실제 W/D를 반영 (타원형)
|
||||
const labelFontSize = Math.min(boxWidth, boxDepth) * 0.15;
|
||||
const iconFontSize = Math.min(boxWidth, boxDepth) * 0.3;
|
||||
|
||||
{/* Location 이름 */}
|
||||
{placement.name && (
|
||||
return (
|
||||
<>
|
||||
{/* 타원형 플랫폼: 단위 실린더를 W/D로 스케일 */}
|
||||
<mesh scale={[boxWidth, 1, boxDepth]}>
|
||||
<cylinderGeometry args={[baseRadius, baseRadius, boxHeight, 32]} />
|
||||
<meshStandardMaterial
|
||||
color={placement.color}
|
||||
roughness={0.6}
|
||||
metalness={0.2}
|
||||
emissive={isSelected ? placement.color : "#000000"}
|
||||
emissiveIntensity={isSelected ? glowIntensity * 0.8 : 0}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
{/* 상단 'P' 마크 (주차 아이콘 역할) */}
|
||||
<Text
|
||||
position={[0, boxHeight / 2 + 0.3, 0]}
|
||||
position={[0, boxHeight / 2 + 0.05, 0]}
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
fontSize={Math.min(boxWidth, boxDepth) * 0.15}
|
||||
fontSize={iconFontSize}
|
||||
color="#ffffff"
|
||||
anchorX="center"
|
||||
anchorY="middle"
|
||||
outlineWidth={0.03}
|
||||
outlineWidth={0.08}
|
||||
outlineColor="#000000"
|
||||
>
|
||||
{placement.name}
|
||||
P
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* 자재 개수 (STP는 정차포인트라 자재가 없을 수 있음) */}
|
||||
{placement.material_count !== undefined && placement.material_count > 0 && (
|
||||
<Text
|
||||
position={[0, boxHeight / 2 + 0.6, 0]}
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
fontSize={Math.min(boxWidth, boxDepth) * 0.12}
|
||||
color="#fbbf24"
|
||||
anchorX="center"
|
||||
anchorY="middle"
|
||||
outlineWidth={0.03}
|
||||
outlineColor="#000000"
|
||||
>
|
||||
{`자재: ${placement.material_count}개`}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
{/* Location 이름 */}
|
||||
{placement.name && (
|
||||
<Text
|
||||
position={[0, boxHeight / 2 + 0.4, 0]}
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
fontSize={labelFontSize}
|
||||
color="#ffffff"
|
||||
anchorX="center"
|
||||
anchorY="middle"
|
||||
outlineWidth={0.03}
|
||||
outlineColor="#000000"
|
||||
>
|
||||
{placement.name}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// case "gantry-crane":
|
||||
// // 겐트리 크레인: 기둥 2개 + 상단 빔
|
||||
|
|
@ -1098,10 +1104,12 @@ function Scene({
|
|||
orbitControlsRef={orbitControlsRef}
|
||||
/>
|
||||
|
||||
{/* 조명 */}
|
||||
<ambientLight intensity={0.5} />
|
||||
<directionalLight position={[10, 10, 5]} intensity={1} />
|
||||
<directionalLight position={[-10, -10, -5]} intensity={0.3} />
|
||||
{/* 조명 - 전체적으로 밝게 조정 */}
|
||||
<ambientLight intensity={0.9} />
|
||||
<directionalLight position={[10, 20, 10]} intensity={1.2} />
|
||||
<directionalLight position={[-10, 20, -10]} intensity={0.8} />
|
||||
<directionalLight position={[0, 20, 0]} intensity={0.5} />
|
||||
<hemisphereLight args={["#ffffff", "#bbbbbb", 0.8]} />
|
||||
|
||||
{/* 배경색 */}
|
||||
<color attach="background" args={["#f3f4f6"]} />
|
||||
|
|
|
|||
|
|
@ -164,3 +164,4 @@ export function getAllDescendants(
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
||||
// 파라미터 정보가 있으면 추가
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { apiClient } from "./client";
|
||||
|
||||
export type AuthType = "none" | "api-key" | "bearer" | "basic" | "oauth2";
|
||||
export type AuthType = "none" | "api-key" | "bearer" | "basic" | "oauth2" | "db-token";
|
||||
|
||||
export interface ExternalRestApiConnection {
|
||||
id?: number;
|
||||
|
|
@ -11,18 +11,34 @@ export interface ExternalRestApiConnection {
|
|||
base_url: string;
|
||||
endpoint_path?: string;
|
||||
default_headers: Record<string, string>;
|
||||
// 기본 메서드 및 바디 추가
|
||||
default_method?: string;
|
||||
default_body?: string;
|
||||
|
||||
auth_type: AuthType;
|
||||
auth_config?: {
|
||||
// API Key
|
||||
keyLocation?: "header" | "query";
|
||||
keyName?: string;
|
||||
keyValue?: string;
|
||||
// Bearer Token
|
||||
token?: string;
|
||||
// Basic Auth
|
||||
username?: string;
|
||||
password?: string;
|
||||
// OAuth2
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
tokenUrl?: string;
|
||||
accessToken?: string;
|
||||
|
||||
// DB 기반 토큰 모드
|
||||
dbTableName?: string;
|
||||
dbValueColumn?: string;
|
||||
dbWhereColumn?: string;
|
||||
dbWhereValue?: string;
|
||||
dbHeaderName?: string;
|
||||
dbHeaderTemplate?: string;
|
||||
};
|
||||
timeout?: number;
|
||||
retry_count?: number;
|
||||
|
|
@ -49,9 +65,11 @@ export interface RestApiTestRequest {
|
|||
id?: number;
|
||||
base_url: string;
|
||||
endpoint?: string;
|
||||
method?: "GET" | "POST" | "PUT" | "DELETE";
|
||||
method?: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
|
||||
headers?: Record<string, string>;
|
||||
body?: unknown; // 테스트 요청 바디 추가
|
||||
auth_type?: AuthType;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
auth_config?: any;
|
||||
timeout?: number;
|
||||
}
|
||||
|
|
@ -61,7 +79,7 @@ export interface RestApiTestResult {
|
|||
message: string;
|
||||
response_time?: number;
|
||||
status_code?: number;
|
||||
response_data?: any;
|
||||
response_data?: unknown;
|
||||
error_details?: string;
|
||||
}
|
||||
|
||||
|
|
@ -71,7 +89,7 @@ export interface ApiResponse<T> {
|
|||
message?: string;
|
||||
error?: {
|
||||
code: string;
|
||||
details?: any;
|
||||
details?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -184,6 +202,7 @@ export class ExternalRestApiConnectionAPI {
|
|||
{ value: "bearer", label: "Bearer Token" },
|
||||
{ value: "basic", label: "Basic Auth" },
|
||||
{ value: "oauth2", label: "OAuth 2.0" },
|
||||
{ value: "db-token", label: "DB 토큰" },
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue