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 지원)
|
||||||
|
|
||||||
## 개요
|
## 개요
|
||||||
|
현재 GET 방식 위주로 구현된 외부 REST API 커넥션 관리 기능을 확장하여, POST, PUT, DELETE 등 다양한 HTTP 메서드와 JSON Request Body를 설정하고 테스트할 수 있도록 개선합니다. 이를 통해 토큰 발급 API나 데이터 전송 API 등 다양한 외부 시스템과의 연동을 지원합니다.
|
||||||
Digital Twin 에디터(`DigitalTwinEditor.tsx`)에서 발생한 런타임 에러(`TypeError: Cannot read properties of undefined`)를 수정하고, 전반적인 안정성을 확보합니다.
|
|
||||||
|
|
||||||
## 핵심 기능
|
## 핵심 기능
|
||||||
|
1. **DB 스키마 확장**: `external_rest_api_connections` 테이블에 `default_method`, `default_body` 컬럼 추가
|
||||||
1. `DigitalTwinEditor` 버그 수정
|
2. **백엔드 로직 개선**:
|
||||||
2. 비동기 함수 입력값 유효성 검증 강화
|
- 커넥션 생성/수정 시 메서드와 바디 정보 저장
|
||||||
3. 외부 DB 연결 상태에 따른 방어 코드 추가
|
- 연결 테스트 시 설정된 메서드와 바디를 사용하여 요청 수행
|
||||||
|
- 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단계: 잠재적 문제 점검
|
## 에러 처리 계획
|
||||||
|
- **JSON 파싱 에러**: Body 입력값이 유효한 JSON이 아닐 경우 에러 처리
|
||||||
- [ ] `loadLayout` 등 주요 로딩 함수의 데이터 유효성 검사
|
- **API 호출 에러**: 외부 API 호출 실패 시 상세 로그 기록 및 클라이언트에 에러 메시지 전달
|
||||||
- [ ] `handleToolDragStart`, `handleCanvasDrop` 등 인터랙션 함수의 예외 처리
|
- **SSL 인증 에러**: `rejectUnauthorized: false` 옵션으로 처리 (기존 `RestApiConnector` 활용)
|
||||||
|
|
||||||
## 진행 상태
|
## 진행 상태
|
||||||
|
- [완료] 모든 단계 구현 완료
|
||||||
- [진행중] 1단계 긴급 버그 수정 완료 후 사용자 피드백 대기 중
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -169,22 +169,18 @@ export class BatchController {
|
||||||
static async getBatchConfigById(req: AuthenticatedRequest, res: Response) {
|
static async getBatchConfigById(req: AuthenticatedRequest, res: Response) {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const userCompanyCode = req.user?.companyCode;
|
const result = await BatchService.getBatchConfigById(Number(id));
|
||||||
const batchConfig = await BatchService.getBatchConfigById(
|
|
||||||
Number(id),
|
|
||||||
userCompanyCode
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!batchConfig) {
|
if (!result.success || !result.data) {
|
||||||
return res.status(404).json({
|
return res.status(404).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: "배치 설정을 찾을 수 없습니다.",
|
message: result.message || "배치 설정을 찾을 수 없습니다.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: batchConfig,
|
data: result.data,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("배치 설정 조회 오류:", error);
|
console.error("배치 설정 조회 오류:", error);
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,11 @@ export class BatchExecutionLogController {
|
||||||
try {
|
try {
|
||||||
const data: CreateBatchExecutionLogRequest = req.body;
|
const data: CreateBatchExecutionLogRequest = req.body;
|
||||||
|
|
||||||
|
// 멀티테넌시: company_code가 없으면 현재 사용자 회사 코드로 설정
|
||||||
|
if (!data.company_code) {
|
||||||
|
data.company_code = req.user?.companyCode || "*";
|
||||||
|
}
|
||||||
|
|
||||||
const result = await BatchExecutionLogService.createExecutionLog(data);
|
const result = await BatchExecutionLogService.createExecutionLog(data);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
|
|
||||||
|
|
@ -265,8 +265,12 @@ export class BatchManagementController {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 실행 로그 생성
|
// 실행 로그 생성
|
||||||
executionLog = await BatchService.createExecutionLog({
|
const { BatchExecutionLogService } = await import(
|
||||||
|
"../services/batchExecutionLogService"
|
||||||
|
);
|
||||||
|
const logResult = await BatchExecutionLogService.createExecutionLog({
|
||||||
batch_config_id: Number(id),
|
batch_config_id: Number(id),
|
||||||
|
company_code: batchConfig.company_code,
|
||||||
execution_status: "RUNNING",
|
execution_status: "RUNNING",
|
||||||
start_time: startTime,
|
start_time: startTime,
|
||||||
total_records: 0,
|
total_records: 0,
|
||||||
|
|
@ -274,6 +278,14 @@ export class BatchManagementController {
|
||||||
failed_records: 0,
|
failed_records: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!logResult.success || !logResult.data) {
|
||||||
|
throw new Error(
|
||||||
|
logResult.message || "배치 실행 로그를 생성할 수 없습니다."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
executionLog = logResult.data;
|
||||||
|
|
||||||
// BatchSchedulerService의 executeBatchConfig 메서드 사용 (중복 로직 제거)
|
// BatchSchedulerService의 executeBatchConfig 메서드 사용 (중복 로직 제거)
|
||||||
const { BatchSchedulerService } = await import(
|
const { BatchSchedulerService } = await import(
|
||||||
"../services/batchSchedulerService"
|
"../services/batchSchedulerService"
|
||||||
|
|
@ -290,7 +302,7 @@ export class BatchManagementController {
|
||||||
const duration = endTime.getTime() - startTime.getTime();
|
const duration = endTime.getTime() - startTime.getTime();
|
||||||
|
|
||||||
// 실행 로그 업데이트 (성공)
|
// 실행 로그 업데이트 (성공)
|
||||||
await BatchService.updateExecutionLog(executionLog.id, {
|
await BatchExecutionLogService.updateExecutionLog(executionLog.id, {
|
||||||
execution_status: "SUCCESS",
|
execution_status: "SUCCESS",
|
||||||
end_time: endTime,
|
end_time: endTime,
|
||||||
duration_ms: duration,
|
duration_ms: duration,
|
||||||
|
|
@ -406,22 +418,34 @@ export class BatchManagementController {
|
||||||
paramName,
|
paramName,
|
||||||
paramValue,
|
paramValue,
|
||||||
paramSource,
|
paramSource,
|
||||||
|
requestBody,
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
if (!apiUrl || !apiKey || !endpoint) {
|
// apiUrl, endpoint는 항상 필수
|
||||||
|
if (!apiUrl || !endpoint) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
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 미리보기 요청:", {
|
console.log("🔍 REST API 미리보기 요청:", {
|
||||||
apiUrl,
|
apiUrl,
|
||||||
endpoint,
|
endpoint,
|
||||||
|
method,
|
||||||
paramType,
|
paramType,
|
||||||
paramName,
|
paramName,
|
||||||
paramValue,
|
paramValue,
|
||||||
paramSource,
|
paramSource,
|
||||||
|
requestBody: requestBody ? "Included" : "None",
|
||||||
});
|
});
|
||||||
|
|
||||||
// RestApiConnector 사용하여 데이터 조회
|
// RestApiConnector 사용하여 데이터 조회
|
||||||
|
|
@ -429,7 +453,7 @@ export class BatchManagementController {
|
||||||
|
|
||||||
const connector = new RestApiConnector({
|
const connector = new RestApiConnector({
|
||||||
baseUrl: apiUrl,
|
baseUrl: apiUrl,
|
||||||
apiKey: apiKey,
|
apiKey: apiKey || "",
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -456,9 +480,28 @@ export class BatchManagementController {
|
||||||
|
|
||||||
console.log("🔗 최종 엔드포인트:", finalEndpoint);
|
console.log("🔗 최종 엔드포인트:", finalEndpoint);
|
||||||
|
|
||||||
// 데이터 조회 (최대 5개만) - GET 메서드만 지원
|
// Request Body 파싱
|
||||||
const result = await connector.executeQuery(finalEndpoint, method);
|
let parsedBody = undefined;
|
||||||
console.log(`[previewRestApiData] executeQuery 결과:`, {
|
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,
|
rowCount: result.rowCount,
|
||||||
rowsLength: result.rows ? result.rows.length : "undefined",
|
rowsLength: result.rows ? result.rows.length : "undefined",
|
||||||
firstRow:
|
firstRow:
|
||||||
|
|
@ -532,15 +575,21 @@ export class BatchManagementController {
|
||||||
apiMappings,
|
apiMappings,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 🔐 멀티테넌시: 현재 사용자 회사 코드 사용 (프론트에서 받지 않음)
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
const userId = req.user?.userId;
|
||||||
|
|
||||||
// BatchService를 사용하여 배치 설정 저장
|
// BatchService를 사용하여 배치 설정 저장
|
||||||
const batchConfig: CreateBatchConfigRequest = {
|
const batchConfig: CreateBatchConfigRequest = {
|
||||||
batchName: batchName,
|
batchName: batchName,
|
||||||
description: description || "",
|
description: description || "",
|
||||||
cronSchedule: cronSchedule,
|
cronSchedule: cronSchedule,
|
||||||
|
isActive: "Y",
|
||||||
|
companyCode,
|
||||||
mappings: apiMappings,
|
mappings: apiMappings,
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await BatchService.createBatchConfig(batchConfig);
|
const result = await BatchService.createBatchConfig(batchConfig, userId);
|
||||||
|
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
// 스케줄러에 자동 등록 ✅
|
// 스케줄러에 자동 등록 ✅
|
||||||
|
|
|
||||||
|
|
@ -161,3 +161,4 @@ export const createMappingTemplate = async (
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import axios, { AxiosInstance, AxiosResponse } from "axios";
|
import axios, { AxiosInstance, AxiosResponse } from "axios";
|
||||||
|
import https from "https";
|
||||||
import {
|
import {
|
||||||
DatabaseConnector,
|
DatabaseConnector,
|
||||||
ConnectionConfig,
|
ConnectionConfig,
|
||||||
|
|
@ -24,16 +25,26 @@ export class RestApiConnector implements DatabaseConnector {
|
||||||
|
|
||||||
constructor(config: RestApiConfig) {
|
constructor(config: RestApiConfig) {
|
||||||
this.config = config;
|
this.config = config;
|
||||||
|
|
||||||
// Axios 인스턴스 생성
|
// 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({
|
this.httpClient = axios.create({
|
||||||
baseURL: config.baseUrl,
|
baseURL: config.baseUrl,
|
||||||
timeout: config.timeout || 30000,
|
timeout: config.timeout || 30000,
|
||||||
headers: {
|
headers: defaultHeaders,
|
||||||
"Content-Type": "application/json",
|
// ⚠️ 외부 API 중 자체 서명 인증서를 사용하는 경우가 있어서
|
||||||
Authorization: `Bearer ${config.apiKey}`,
|
// 인증서 검증을 끈 HTTPS 에이전트를 사용한다.
|
||||||
Accept: "application/json",
|
// 내부망/신뢰된 시스템 전용으로 사용해야 하며,
|
||||||
},
|
// 공개 인터넷용 API에는 적용하면 안 된다.
|
||||||
|
httpsAgent: new https.Agent({ rejectUnauthorized: false }),
|
||||||
});
|
});
|
||||||
|
|
||||||
// 요청/응답 인터셉터 설정
|
// 요청/응답 인터셉터 설정
|
||||||
|
|
@ -75,26 +86,16 @@ export class RestApiConnector implements DatabaseConnector {
|
||||||
}
|
}
|
||||||
|
|
||||||
async connect(): Promise<void> {
|
async connect(): Promise<void> {
|
||||||
try {
|
// 기존에는 /health 엔드포인트를 호출해서 미리 연결을 검사했지만,
|
||||||
// 연결 테스트 - 기본 엔드포인트 호출
|
// 일반 외부 API들은 /health가 없거나 401/500을 반환하는 경우가 많아
|
||||||
await this.httpClient.get("/health", { timeout: 5000 });
|
// 불필요하게 예외가 나면서 미리보기/배치 실행이 막히는 문제가 있었다.
|
||||||
console.log(`[RestApiConnector] 연결 성공: ${this.config.baseUrl}`);
|
//
|
||||||
} catch (error) {
|
// 따라서 여기서는 "연결 준비 완료" 정도만 로그로 남기고
|
||||||
// health 엔드포인트가 없을 수 있으므로 404는 정상으로 처리
|
// 실제 호출 실패 여부는 executeRequest 단계에서만 판단하도록 한다.
|
||||||
if (axios.isAxiosError(error) && error.response?.status === 404) {
|
console.log(
|
||||||
console.log(
|
`[RestApiConnector] 연결 준비 완료 (사전 헬스체크 생략): ${this.config.baseUrl}`
|
||||||
`[RestApiConnector] 연결 성공 (health 엔드포인트 없음): ${this.config.baseUrl}`
|
);
|
||||||
);
|
return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.error(
|
|
||||||
`[RestApiConnector] 연결 실패: ${this.config.baseUrl}`,
|
|
||||||
error
|
|
||||||
);
|
|
||||||
throw new Error(
|
|
||||||
`REST API 연결 실패: ${error instanceof Error ? error.message : "알 수 없는 오류"}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async disconnect(): Promise<void> {
|
async disconnect(): Promise<void> {
|
||||||
|
|
|
||||||
|
|
@ -213,7 +213,10 @@ router.post(
|
||||||
}
|
}
|
||||||
|
|
||||||
const result =
|
const result =
|
||||||
await ExternalRestApiConnectionService.testConnection(testRequest);
|
await ExternalRestApiConnectionService.testConnection(
|
||||||
|
testRequest,
|
||||||
|
req.user?.companyCode
|
||||||
|
);
|
||||||
|
|
||||||
return res.status(200).json(result);
|
return res.status(200).json(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -170,3 +170,4 @@ export class DigitalTwinTemplateService {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -130,13 +130,14 @@ export class BatchExecutionLogService {
|
||||||
try {
|
try {
|
||||||
const log = await queryOne<BatchExecutionLog>(
|
const log = await queryOne<BatchExecutionLog>(
|
||||||
`INSERT INTO batch_execution_logs (
|
`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,
|
duration_ms, total_records, success_records, failed_records,
|
||||||
error_message, error_details, server_name, process_id
|
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 *`,
|
RETURNING *`,
|
||||||
[
|
[
|
||||||
data.batch_config_id,
|
data.batch_config_id,
|
||||||
|
data.company_code,
|
||||||
data.execution_status,
|
data.execution_status,
|
||||||
data.start_time || new Date(),
|
data.start_time || new Date(),
|
||||||
data.end_time,
|
data.end_time,
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,258 +1,114 @@
|
||||||
// 배치 스케줄러 서비스
|
import cron from "node-cron";
|
||||||
// 작성일: 2024-12-24
|
|
||||||
|
|
||||||
import * as cron from "node-cron";
|
|
||||||
import { query, queryOne } from "../database/db";
|
|
||||||
import { BatchService } from "./batchService";
|
import { BatchService } from "./batchService";
|
||||||
import { BatchExecutionLogService } from "./batchExecutionLogService";
|
import { BatchExecutionLogService } from "./batchExecutionLogService";
|
||||||
import { logger } from "../utils/logger";
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
export class BatchSchedulerService {
|
export class BatchSchedulerService {
|
||||||
private static scheduledTasks: Map<number, cron.ScheduledTask> = new Map();
|
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 {
|
try {
|
||||||
logger.info("배치 스케줄러 초기화 시작...");
|
logger.info("배치 스케줄러 초기화 시작");
|
||||||
|
|
||||||
// 기존 모든 스케줄 정리 (중복 방지)
|
const batchConfigsResponse = await BatchService.getBatchConfigs({
|
||||||
this.clearAllSchedules();
|
is_active: "Y",
|
||||||
|
});
|
||||||
|
|
||||||
// 활성화된 배치 설정들을 로드하여 스케줄 등록
|
if (!batchConfigsResponse.success || !batchConfigsResponse.data) {
|
||||||
await this.loadActiveBatchConfigs();
|
logger.warn("스케줄링할 활성 배치 설정이 없습니다.");
|
||||||
|
|
||||||
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})`);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 새로운 스케줄 등록
|
const batchConfigs = batchConfigsResponse.data;
|
||||||
const task = cron.schedule(cron_schedule, async () => {
|
logger.info(`${batchConfigs.length}개의 배치 설정 스케줄링 등록`);
|
||||||
// 중복 실행 방지 체크
|
|
||||||
if (this.executingBatches.has(id)) {
|
|
||||||
logger.warn(
|
|
||||||
`⚠️ 배치가 이미 실행 중입니다. 건너뜀: ${batch_name} (ID: ${id})`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`🔄 스케줄 배치 실행 시작: ${batch_name} (ID: ${id})`);
|
for (const config of batchConfigs) {
|
||||||
|
await this.scheduleBatch(config);
|
||||||
|
}
|
||||||
|
|
||||||
// 실행 중 플래그 설정
|
logger.info("배치 스케줄러 초기화 완료");
|
||||||
this.executingBatches.add(id);
|
} catch (error) {
|
||||||
|
logger.error("배치 스케줄러 초기화 중 오류 발생:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
/**
|
||||||
await this.executeBatchConfig(config);
|
* 개별 배치 작업 스케줄링
|
||||||
} finally {
|
*/
|
||||||
// 실행 완료 후 플래그 제거
|
static async scheduleBatch(config: any) {
|
||||||
this.executingBatches.delete(id);
|
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);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 스케줄 시작 (기본적으로 시작되지만 명시적으로 호출)
|
this.scheduledTasks.set(config.id, task);
|
||||||
task.start();
|
|
||||||
|
|
||||||
this.scheduledTasks.set(id, task);
|
|
||||||
logger.info(
|
|
||||||
`배치 스케줄 등록 완료: ${batch_name} (ID: ${id}, Schedule: ${cron_schedule}) - 스케줄 시작됨`
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} 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(
|
static async updateBatchSchedule(
|
||||||
configId: number,
|
configId: number,
|
||||||
executeImmediately: boolean = true
|
executeImmediately: boolean = true
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
// 기존 스케줄 제거
|
const result = await BatchService.getBatchConfigById(configId);
|
||||||
await this.unscheduleBatchConfig(configId);
|
if (!result.success || !result.data) {
|
||||||
|
// 설정이 없으면 스케줄 제거
|
||||||
// 업데이트된 배치 설정 조회
|
if (this.scheduledTasks.has(configId)) {
|
||||||
const configResult = await query<any>(
|
this.scheduledTasks.get(configId)?.stop();
|
||||||
`SELECT
|
this.scheduledTasks.delete(configId);
|
||||||
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}`);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 활성화된 배치만 다시 스케줄 등록
|
const config = result.data;
|
||||||
if (config.is_active === "Y") {
|
|
||||||
await this.scheduleBatchConfig(config);
|
|
||||||
logger.info(
|
|
||||||
`배치 스케줄 업데이트 완료: ${config.batch_name} (ID: ${configId})`
|
|
||||||
);
|
|
||||||
|
|
||||||
// 활성화 시 즉시 실행 (옵션)
|
// 스케줄 재등록
|
||||||
if (executeImmediately) {
|
await this.scheduleBatch(config);
|
||||||
logger.info(
|
|
||||||
`🚀 배치 활성화 즉시 실행: ${config.batch_name} (ID: ${configId})`
|
// 즉시 실행 옵션이 있으면 실행
|
||||||
);
|
/*
|
||||||
await this.executeBatchConfig(config);
|
if (executeImmediately && config.is_active === "Y") {
|
||||||
}
|
logger.info(`배치 설정 변경 후 즉시 실행: ${config.batch_name}`);
|
||||||
} else {
|
this.executeBatchConfig(config).catch((err) =>
|
||||||
logger.info(
|
logger.error(`즉시 실행 중 오류 발생:`, err)
|
||||||
`비활성화된 배치 스케줄 제거: ${config.batch_name} (ID: ${configId})`
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`배치 스케줄 업데이트 실패: ID ${configId}`, error);
|
logger.error(`배치 스케줄 업데이트 실패: ID ${configId}`, error);
|
||||||
}
|
}
|
||||||
|
|
@ -272,6 +128,7 @@ export class BatchSchedulerService {
|
||||||
const executionLogResponse =
|
const executionLogResponse =
|
||||||
await BatchExecutionLogService.createExecutionLog({
|
await BatchExecutionLogService.createExecutionLog({
|
||||||
batch_config_id: config.id,
|
batch_config_id: config.id,
|
||||||
|
company_code: config.company_code,
|
||||||
execution_status: "RUNNING",
|
execution_status: "RUNNING",
|
||||||
start_time: startTime,
|
start_time: startTime,
|
||||||
total_records: 0,
|
total_records: 0,
|
||||||
|
|
@ -313,21 +170,20 @@ export class BatchSchedulerService {
|
||||||
// 성공 결과 반환
|
// 성공 결과 반환
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`배치 실행 실패: ${config.batch_name}`, error);
|
logger.error(`배치 실행 중 오류 발생: ${config.batch_name}`, error);
|
||||||
|
|
||||||
// 실행 로그 업데이트 (실패)
|
// 실행 로그 업데이트 (실패)
|
||||||
if (executionLog) {
|
if (executionLog) {
|
||||||
await BatchExecutionLogService.updateExecutionLog(executionLog.id, {
|
await BatchExecutionLogService.updateExecutionLog(executionLog.id, {
|
||||||
execution_status: "FAILED",
|
execution_status: "FAILURE",
|
||||||
end_time: new Date(),
|
end_time: new Date(),
|
||||||
duration_ms: Date.now() - startTime.getTime(),
|
duration_ms: Date.now() - startTime.getTime(),
|
||||||
error_message:
|
error_message:
|
||||||
error instanceof Error ? error.message : "알 수 없는 오류",
|
error instanceof Error ? error.message : "알 수 없는 오류",
|
||||||
error_details: error instanceof Error ? error.stack : String(error),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 실패 시에도 결과 반환
|
// 실패 결과 반환
|
||||||
return {
|
return {
|
||||||
totalRecords: 0,
|
totalRecords: 0,
|
||||||
successRecords: 0,
|
successRecords: 0,
|
||||||
|
|
@ -379,6 +235,8 @@ export class BatchSchedulerService {
|
||||||
const { BatchExternalDbService } = await import(
|
const { BatchExternalDbService } = await import(
|
||||||
"./batchExternalDbService"
|
"./batchExternalDbService"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 👇 Body 파라미터 추가 (POST 요청 시)
|
||||||
const apiResult = await BatchExternalDbService.getDataFromRestApi(
|
const apiResult = await BatchExternalDbService.getDataFromRestApi(
|
||||||
firstMapping.from_api_url!,
|
firstMapping.from_api_url!,
|
||||||
firstMapping.from_api_key!,
|
firstMapping.from_api_key!,
|
||||||
|
|
@ -394,7 +252,9 @@ export class BatchSchedulerService {
|
||||||
firstMapping.from_api_param_type,
|
firstMapping.from_api_param_type,
|
||||||
firstMapping.from_api_param_name,
|
firstMapping.from_api_param_name,
|
||||||
firstMapping.from_api_param_value,
|
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) {
|
if (apiResult.success && apiResult.data) {
|
||||||
|
|
@ -416,6 +276,17 @@ export class BatchSchedulerService {
|
||||||
totalRecords += fromData.length;
|
totalRecords += fromData.length;
|
||||||
|
|
||||||
// 컬럼 매핑 적용하여 TO 테이블 형식으로 변환
|
// 컬럼 매핑 적용하여 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 mappedData = fromData.map((row) => {
|
||||||
const mappedRow: any = {};
|
const mappedRow: any = {};
|
||||||
for (const mapping of mappings) {
|
for (const mapping of mappings) {
|
||||||
|
|
@ -428,10 +299,25 @@ export class BatchSchedulerService {
|
||||||
mappedRow[mapping.from_column_name] =
|
mappedRow[mapping.from_column_name] =
|
||||||
row[mapping.from_column_name];
|
row[mapping.from_column_name];
|
||||||
} else {
|
} else {
|
||||||
// 기존 로직: to_column_name을 키로 사용
|
// REST API -> DB (POST 요청 포함) 또는 DB -> DB
|
||||||
mappedRow[mapping.to_column_name] = row[mapping.from_column_name];
|
// 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;
|
return mappedRow;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -482,22 +368,12 @@ export class BatchSchedulerService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 기존 REST API 전송 (REST API → DB 배치)
|
// 기존 REST API 전송 (REST API → DB 배치) - 사실 이 경우는 거의 없음 (REST to REST)
|
||||||
const apiResult = await BatchExternalDbService.sendDataToRestApi(
|
// 지원하지 않음
|
||||||
firstMapping.to_api_url!,
|
logger.warn(
|
||||||
firstMapping.to_api_key!,
|
"REST API -> REST API (단순 매핑)은 아직 지원하지 않습니다."
|
||||||
firstMapping.to_table_name,
|
|
||||||
(firstMapping.to_api_method as "POST" | "PUT") || "POST",
|
|
||||||
mappedData
|
|
||||||
);
|
);
|
||||||
|
insertResult = { successCount: 0, failedCount: 0 };
|
||||||
if (apiResult.success && apiResult.data) {
|
|
||||||
insertResult = apiResult.data;
|
|
||||||
} else {
|
|
||||||
throw new Error(
|
|
||||||
`REST API 데이터 전송 실패: ${apiResult.message}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// DB에 데이터 삽입
|
// DB에 데이터 삽입
|
||||||
|
|
@ -511,167 +387,13 @@ export class BatchSchedulerService {
|
||||||
|
|
||||||
successRecords += insertResult.successCount;
|
successRecords += insertResult.successCount;
|
||||||
failedRecords += insertResult.failedCount;
|
failedRecords += insertResult.failedCount;
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`테이블 처리 완료: ${insertResult.successCount}개 성공, ${insertResult.failedCount}개 실패`
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`테이블 처리 실패: ${tableKey}`, error);
|
logger.error(`테이블 처리 중 오류 발생: ${tableKey}`, error);
|
||||||
failedRecords += 1;
|
// 해당 테이블 처리 실패는 전체 실패로 간주하지 않고, 실패 카운트만 증가?
|
||||||
|
// 여기서는 일단 실패 로그만 남기고 계속 진행 (필요시 정책 변경)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { totalRecords, successRecords, failedRecords };
|
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 { Pool, QueryResult } from "pg";
|
||||||
|
import axios, { AxiosResponse } from "axios";
|
||||||
|
import https from "https";
|
||||||
import { getPool } from "../database/db";
|
import { getPool } from "../database/db";
|
||||||
import logger from "../utils/logger";
|
import logger from "../utils/logger";
|
||||||
import {
|
import {
|
||||||
|
|
@ -30,6 +32,10 @@ export class ExternalRestApiConnectionService {
|
||||||
let query = `
|
let query = `
|
||||||
SELECT
|
SELECT
|
||||||
id, connection_name, description, base_url, endpoint_path, default_headers,
|
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,
|
auth_type, auth_config, timeout, retry_count, retry_delay,
|
||||||
company_code, is_active, created_date, created_by,
|
company_code, is_active, created_date, created_by,
|
||||||
updated_date, updated_by, last_test_date, last_test_result, last_test_message
|
updated_date, updated_by, last_test_date, last_test_result, last_test_message
|
||||||
|
|
@ -129,6 +135,8 @@ export class ExternalRestApiConnectionService {
|
||||||
let query = `
|
let query = `
|
||||||
SELECT
|
SELECT
|
||||||
id, connection_name, description, base_url, endpoint_path, default_headers,
|
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,
|
auth_type, auth_config, timeout, retry_count, retry_delay,
|
||||||
company_code, is_active, created_date, created_by,
|
company_code, is_active, created_date, created_by,
|
||||||
updated_date, updated_by, last_test_date, last_test_result, last_test_message
|
updated_date, updated_by, last_test_date, last_test_result, last_test_message
|
||||||
|
|
@ -194,9 +202,10 @@ export class ExternalRestApiConnectionService {
|
||||||
const query = `
|
const query = `
|
||||||
INSERT INTO external_rest_api_connections (
|
INSERT INTO external_rest_api_connections (
|
||||||
connection_name, description, base_url, endpoint_path, default_headers,
|
connection_name, description, base_url, endpoint_path, default_headers,
|
||||||
|
default_method, default_request_body,
|
||||||
auth_type, auth_config, timeout, retry_count, retry_delay,
|
auth_type, auth_config, timeout, retry_count, retry_delay,
|
||||||
company_code, is_active, created_by
|
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 *
|
RETURNING *
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
@ -206,6 +215,8 @@ export class ExternalRestApiConnectionService {
|
||||||
data.base_url,
|
data.base_url,
|
||||||
data.endpoint_path || null,
|
data.endpoint_path || null,
|
||||||
JSON.stringify(data.default_headers || {}),
|
JSON.stringify(data.default_headers || {}),
|
||||||
|
data.default_method || "GET",
|
||||||
|
data.default_body || null,
|
||||||
data.auth_type,
|
data.auth_type,
|
||||||
encryptedAuthConfig ? JSON.stringify(encryptedAuthConfig) : null,
|
encryptedAuthConfig ? JSON.stringify(encryptedAuthConfig) : null,
|
||||||
data.timeout || 30000,
|
data.timeout || 30000,
|
||||||
|
|
@ -301,6 +312,18 @@ export class ExternalRestApiConnectionService {
|
||||||
paramIndex++;
|
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) {
|
if (data.auth_type !== undefined) {
|
||||||
updateFields.push(`auth_type = $${paramIndex}`);
|
updateFields.push(`auth_type = $${paramIndex}`);
|
||||||
params.push(data.auth_type);
|
params.push(data.auth_type);
|
||||||
|
|
@ -441,7 +464,8 @@ export class ExternalRestApiConnectionService {
|
||||||
* REST API 연결 테스트 (테스트 요청 데이터 기반)
|
* REST API 연결 테스트 (테스트 요청 데이터 기반)
|
||||||
*/
|
*/
|
||||||
static async testConnection(
|
static async testConnection(
|
||||||
testRequest: RestApiTestRequest
|
testRequest: RestApiTestRequest,
|
||||||
|
userCompanyCode?: string
|
||||||
): Promise<RestApiTestResult> {
|
): Promise<RestApiTestResult> {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
|
@ -450,7 +474,78 @@ export class ExternalRestApiConnectionService {
|
||||||
const headers = { ...testRequest.headers };
|
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_type === "bearer" &&
|
||||||
testRequest.auth_config?.token
|
testRequest.auth_config?.token
|
||||||
) {
|
) {
|
||||||
|
|
@ -493,25 +588,84 @@ export class ExternalRestApiConnectionService {
|
||||||
`REST API 연결 테스트: ${testRequest.method || "GET"} ${url}`
|
`REST API 연결 테스트: ${testRequest.method || "GET"} ${url}`
|
||||||
);
|
);
|
||||||
|
|
||||||
// HTTP 요청 실행
|
// Body 처리
|
||||||
const response = await fetch(url, {
|
let body: any = undefined;
|
||||||
method: testRequest.method || "GET",
|
if (testRequest.body) {
|
||||||
headers,
|
// 이미 문자열이면 그대로, 객체면 JSON 문자열로 변환
|
||||||
signal: AbortSignal.timeout(testRequest.timeout || 30000),
|
if (typeof testRequest.body === "string") {
|
||||||
});
|
body = testRequest.body;
|
||||||
|
} else {
|
||||||
|
body = JSON.stringify(testRequest.body);
|
||||||
|
}
|
||||||
|
|
||||||
const responseTime = Date.now() - startTime;
|
// Content-Type 헤더가 없으면 기본적으로 application/json 추가
|
||||||
let responseData = null;
|
const hasContentType = Object.keys(headers).some(
|
||||||
|
(k) => k.toLowerCase() === "content-type"
|
||||||
try {
|
);
|
||||||
responseData = await response.json();
|
if (!hasContentType) {
|
||||||
} catch {
|
headers["Content-Type"] = "application/json";
|
||||||
// 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 {
|
return {
|
||||||
success: response.ok,
|
success: response.status >= 200 && response.status < 300,
|
||||||
message: response.ok
|
message:
|
||||||
|
response.status >= 200 && response.status < 300
|
||||||
? "연결 성공"
|
? "연결 성공"
|
||||||
: `연결 실패 (${response.status} ${response.statusText})`,
|
: `연결 실패 (${response.status} ${response.statusText})`,
|
||||||
response_time: responseTime,
|
response_time: responseTime,
|
||||||
|
|
@ -552,17 +706,27 @@ export class ExternalRestApiConnectionService {
|
||||||
|
|
||||||
const connection = connectionResult.data;
|
const connection = connectionResult.data;
|
||||||
|
|
||||||
|
// 리스트에서 endpoint를 넘기지 않으면,
|
||||||
|
// 저장된 endpoint_path를 기본 엔드포인트로 사용
|
||||||
|
const effectiveEndpoint =
|
||||||
|
endpoint || connection.endpoint_path || undefined;
|
||||||
|
|
||||||
const testRequest: RestApiTestRequest = {
|
const testRequest: RestApiTestRequest = {
|
||||||
id: connection.id,
|
id: connection.id,
|
||||||
base_url: connection.base_url,
|
base_url: connection.base_url,
|
||||||
endpoint,
|
endpoint: effectiveEndpoint,
|
||||||
|
method: (connection.default_method as any) || "GET", // 기본 메서드 적용
|
||||||
headers: connection.default_headers,
|
headers: connection.default_headers,
|
||||||
|
body: connection.default_body, // 기본 바디 적용
|
||||||
auth_type: connection.auth_type,
|
auth_type: connection.auth_type,
|
||||||
auth_config: connection.auth_config,
|
auth_config: connection.auth_config,
|
||||||
timeout: connection.timeout,
|
timeout: connection.timeout,
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await this.testConnection(testRequest);
|
const result = await this.testConnection(
|
||||||
|
testRequest,
|
||||||
|
connection.company_code
|
||||||
|
);
|
||||||
|
|
||||||
// 테스트 결과 저장
|
// 테스트 결과 저장
|
||||||
await pool.query(
|
await pool.query(
|
||||||
|
|
@ -580,11 +744,34 @@ export class ExternalRestApiConnectionService {
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("REST API 연결 테스트 (ID) 오류:", 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 {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: "연결 테스트에 실패했습니다.",
|
message: "연결 테스트에 실패했습니다.",
|
||||||
error_details:
|
error_details: errorMessage,
|
||||||
error instanceof Error ? error.message : "알 수 없는 오류",
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -709,6 +896,7 @@ export class ExternalRestApiConnectionService {
|
||||||
"bearer",
|
"bearer",
|
||||||
"basic",
|
"basic",
|
||||||
"oauth2",
|
"oauth2",
|
||||||
|
"db-token",
|
||||||
];
|
];
|
||||||
if (!validAuthTypes.includes(data.auth_type)) {
|
if (!validAuthTypes.includes(data.auth_type)) {
|
||||||
throw new Error("올바르지 않은 인증 타입입니다.");
|
throw new Error("올바르지 않은 인증 타입입니다.");
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
export interface BatchExecutionLog {
|
export interface BatchExecutionLog {
|
||||||
id?: number;
|
id?: number;
|
||||||
batch_config_id: number;
|
batch_config_id: number;
|
||||||
|
company_code?: string;
|
||||||
execution_status: 'RUNNING' | 'SUCCESS' | 'FAILED' | 'CANCELLED';
|
execution_status: 'RUNNING' | 'SUCCESS' | 'FAILED' | 'CANCELLED';
|
||||||
start_time: Date;
|
start_time: Date;
|
||||||
end_time?: Date | null;
|
end_time?: Date | null;
|
||||||
|
|
@ -19,6 +20,7 @@ export interface BatchExecutionLog {
|
||||||
|
|
||||||
export interface CreateBatchExecutionLogRequest {
|
export interface CreateBatchExecutionLogRequest {
|
||||||
batch_config_id: number;
|
batch_config_id: number;
|
||||||
|
company_code?: string;
|
||||||
execution_status: 'RUNNING' | 'SUCCESS' | 'FAILED' | 'CANCELLED';
|
execution_status: 'RUNNING' | 'SUCCESS' | 'FAILED' | 'CANCELLED';
|
||||||
start_time?: Date;
|
start_time?: Date;
|
||||||
end_time?: Date | null;
|
end_time?: Date | null;
|
||||||
|
|
|
||||||
|
|
@ -1,86 +1,13 @@
|
||||||
// 배치관리 타입 정의
|
import { ApiResponse, ColumnInfo } from './batchTypes';
|
||||||
// 작성일: 2024-12-24
|
|
||||||
|
|
||||||
// 배치 타입 정의
|
export interface BatchConnectionInfo {
|
||||||
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 {
|
|
||||||
type: 'internal' | 'external';
|
type: 'internal' | 'external';
|
||||||
id?: number;
|
id?: number;
|
||||||
name: string;
|
name: string;
|
||||||
db_type?: string;
|
db_type?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TableInfo {
|
export interface BatchColumnInfo {
|
||||||
table_name: string;
|
|
||||||
columns: ColumnInfo[];
|
|
||||||
description?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ColumnInfo {
|
|
||||||
column_name: string;
|
column_name: string;
|
||||||
data_type: string;
|
data_type: string;
|
||||||
is_nullable?: string;
|
is_nullable?: string;
|
||||||
|
|
@ -100,6 +27,8 @@ export interface BatchMappingRequest {
|
||||||
from_api_param_name?: string; // API 파라미터명
|
from_api_param_name?: string; // API 파라미터명
|
||||||
from_api_param_value?: string; // API 파라미터 값 또는 템플릿
|
from_api_param_value?: string; // API 파라미터 값 또는 템플릿
|
||||||
from_api_param_source?: 'static' | 'dynamic'; // 파라미터 소스 타입
|
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_type: 'internal' | 'external' | 'restapi';
|
||||||
to_connection_id?: number;
|
to_connection_id?: number;
|
||||||
to_table_name: string;
|
to_table_name: string;
|
||||||
|
|
@ -116,6 +45,8 @@ export interface CreateBatchConfigRequest {
|
||||||
batchName: string;
|
batchName: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
cronSchedule: string;
|
cronSchedule: string;
|
||||||
|
isActive: 'Y' | 'N';
|
||||||
|
companyCode: string;
|
||||||
mappings: BatchMappingRequest[];
|
mappings: BatchMappingRequest[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -123,25 +54,11 @@ export interface UpdateBatchConfigRequest {
|
||||||
batchName?: string;
|
batchName?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
cronSchedule?: string;
|
cronSchedule?: string;
|
||||||
|
isActive?: 'Y' | 'N';
|
||||||
mappings?: BatchMappingRequest[];
|
mappings?: BatchMappingRequest[];
|
||||||
isActive?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BatchValidationResult {
|
export interface BatchValidationResult {
|
||||||
isValid: boolean;
|
isValid: boolean;
|
||||||
errors: string[];
|
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 연결 관리 타입 정의
|
// 외부 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 {
|
export interface ExternalRestApiConnection {
|
||||||
id?: number;
|
id?: number;
|
||||||
|
|
@ -9,6 +15,11 @@ export interface ExternalRestApiConnection {
|
||||||
base_url: string;
|
base_url: string;
|
||||||
endpoint_path?: string;
|
endpoint_path?: string;
|
||||||
default_headers: Record<string, string>;
|
default_headers: Record<string, string>;
|
||||||
|
|
||||||
|
// 기본 메서드 및 바디 추가
|
||||||
|
default_method?: string;
|
||||||
|
default_body?: string;
|
||||||
|
|
||||||
auth_type: AuthType;
|
auth_type: AuthType;
|
||||||
auth_config?: {
|
auth_config?: {
|
||||||
// API Key
|
// API Key
|
||||||
|
|
@ -28,6 +39,14 @@ export interface ExternalRestApiConnection {
|
||||||
clientSecret?: string;
|
clientSecret?: string;
|
||||||
tokenUrl?: string;
|
tokenUrl?: string;
|
||||||
accessToken?: string;
|
accessToken?: string;
|
||||||
|
|
||||||
|
// DB 기반 토큰 모드
|
||||||
|
dbTableName?: string;
|
||||||
|
dbValueColumn?: string;
|
||||||
|
dbWhereColumn?: string;
|
||||||
|
dbWhereValue?: string;
|
||||||
|
dbHeaderName?: string;
|
||||||
|
dbHeaderTemplate?: string;
|
||||||
};
|
};
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
retry_count?: number;
|
retry_count?: number;
|
||||||
|
|
@ -54,8 +73,9 @@ export interface RestApiTestRequest {
|
||||||
id?: number;
|
id?: number;
|
||||||
base_url: string;
|
base_url: string;
|
||||||
endpoint?: string;
|
endpoint?: string;
|
||||||
method?: "GET" | "POST" | "PUT" | "DELETE";
|
method?: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
|
body?: any; // 테스트 요청 바디 추가
|
||||||
auth_type?: AuthType;
|
auth_type?: AuthType;
|
||||||
auth_config?: any;
|
auth_config?: any;
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
|
|
@ -76,4 +96,5 @@ export const AUTH_TYPE_OPTIONS = [
|
||||||
{ value: "bearer", label: "Bearer Token" },
|
{ value: "bearer", label: "Bearer Token" },
|
||||||
{ value: "basic", label: "Basic Auth" },
|
{ value: "basic", label: "Basic Auth" },
|
||||||
{ value: "oauth2", label: "OAuth 2.0" },
|
{ value: "oauth2", label: "OAuth 2.0" },
|
||||||
|
{ value: "db-token", label: "DB 토큰" },
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect, useMemo, memo } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -33,6 +33,31 @@ interface BatchColumnInfo {
|
||||||
is_nullable: string;
|
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() {
|
export default function BatchManagementNewPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
|
@ -52,7 +77,8 @@ export default function BatchManagementNewPage() {
|
||||||
const [fromApiUrl, setFromApiUrl] = useState("");
|
const [fromApiUrl, setFromApiUrl] = useState("");
|
||||||
const [fromApiKey, setFromApiKey] = useState("");
|
const [fromApiKey, setFromApiKey] = useState("");
|
||||||
const [fromEndpoint, setFromEndpoint] = 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 파라미터 설정
|
// REST API 파라미터 설정
|
||||||
const [apiParamType, setApiParamType] = useState<'none' | 'url' | 'query'>('none');
|
const [apiParamType, setApiParamType] = useState<'none' | 'url' | 'query'>('none');
|
||||||
|
|
@ -83,6 +109,8 @@ export default function BatchManagementNewPage() {
|
||||||
|
|
||||||
// API 필드 → DB 컬럼 매핑
|
// API 필드 → DB 컬럼 매핑
|
||||||
const [apiFieldMappings, setApiFieldMappings] = useState<Record<string, string>>({});
|
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');
|
const [batchType, setBatchType] = useState<BatchType>('restapi-to-db');
|
||||||
|
|
@ -182,24 +210,17 @@ export default function BatchManagementNewPage() {
|
||||||
|
|
||||||
// TO 테이블 변경 핸들러
|
// TO 테이블 변경 핸들러
|
||||||
const handleToTableChange = async (tableName: string) => {
|
const handleToTableChange = async (tableName: string) => {
|
||||||
console.log("🔍 테이블 변경:", { tableName, toConnection });
|
|
||||||
setToTable(tableName);
|
setToTable(tableName);
|
||||||
setToColumns([]);
|
setToColumns([]);
|
||||||
|
|
||||||
if (toConnection && tableName) {
|
if (toConnection && tableName) {
|
||||||
try {
|
try {
|
||||||
const connectionType = toConnection.type === 'internal' ? 'internal' : 'external';
|
const connectionType = toConnection.type === 'internal' ? 'internal' : 'external';
|
||||||
console.log("🔍 컬럼 조회 시작:", { connectionType, connectionId: toConnection.id, tableName });
|
|
||||||
|
|
||||||
const result = await BatchManagementAPI.getTableColumns(connectionType, tableName, toConnection.id);
|
const result = await BatchManagementAPI.getTableColumns(connectionType, tableName, toConnection.id);
|
||||||
console.log("🔍 컬럼 조회 결과:", result);
|
|
||||||
|
|
||||||
if (result && result.length > 0) {
|
if (result && result.length > 0) {
|
||||||
setToColumns(result);
|
setToColumns(result);
|
||||||
console.log("✅ 컬럼 설정 완료:", result.length, "개");
|
|
||||||
} else {
|
} else {
|
||||||
setToColumns([]);
|
setToColumns([]);
|
||||||
console.log("⚠️ 컬럼이 없음");
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ 컬럼 목록 로드 오류:", error);
|
console.error("❌ 컬럼 목록 로드 오류:", error);
|
||||||
|
|
@ -239,7 +260,6 @@ export default function BatchManagementNewPage() {
|
||||||
|
|
||||||
// FROM 테이블 변경 핸들러 (DB → REST API용)
|
// FROM 테이블 변경 핸들러 (DB → REST API용)
|
||||||
const handleFromTableChange = async (tableName: string) => {
|
const handleFromTableChange = async (tableName: string) => {
|
||||||
console.log("🔍 FROM 테이블 변경:", { tableName, fromConnection });
|
|
||||||
setFromTable(tableName);
|
setFromTable(tableName);
|
||||||
setFromColumns([]);
|
setFromColumns([]);
|
||||||
setSelectedColumns([]); // 선택된 컬럼도 초기화
|
setSelectedColumns([]); // 선택된 컬럼도 초기화
|
||||||
|
|
@ -248,17 +268,11 @@ export default function BatchManagementNewPage() {
|
||||||
if (fromConnection && tableName) {
|
if (fromConnection && tableName) {
|
||||||
try {
|
try {
|
||||||
const connectionType = fromConnection.type === 'internal' ? 'internal' : 'external';
|
const connectionType = fromConnection.type === 'internal' ? 'internal' : 'external';
|
||||||
console.log("🔍 FROM 컬럼 조회 시작:", { connectionType, connectionId: fromConnection.id, tableName });
|
|
||||||
|
|
||||||
const result = await BatchManagementAPI.getTableColumns(connectionType, tableName, fromConnection.id);
|
const result = await BatchManagementAPI.getTableColumns(connectionType, tableName, fromConnection.id);
|
||||||
console.log("🔍 FROM 컬럼 조회 결과:", result);
|
|
||||||
|
|
||||||
if (result && result.length > 0) {
|
if (result && result.length > 0) {
|
||||||
setFromColumns(result);
|
setFromColumns(result);
|
||||||
console.log("✅ FROM 컬럼 설정 완료:", result.length, "개");
|
|
||||||
} else {
|
} else {
|
||||||
setFromColumns([]);
|
setFromColumns([]);
|
||||||
console.log("⚠️ FROM 컬럼이 없음");
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ FROM 컬럼 목록 로드 오류:", error);
|
console.error("❌ FROM 컬럼 목록 로드 오류:", error);
|
||||||
|
|
@ -276,8 +290,6 @@ export default function BatchManagementNewPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log("🔍 TO API 미리보기 시작:", { toApiUrl, toApiKey, toEndpoint, toApiMethod });
|
|
||||||
|
|
||||||
const result = await BatchManagementAPI.previewRestApiData(
|
const result = await BatchManagementAPI.previewRestApiData(
|
||||||
toApiUrl,
|
toApiUrl,
|
||||||
toApiKey,
|
toApiKey,
|
||||||
|
|
@ -285,8 +297,6 @@ export default function BatchManagementNewPage() {
|
||||||
'GET' // 미리보기는 항상 GET으로
|
'GET' // 미리보기는 항상 GET으로
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log("🔍 TO API 미리보기 결과:", result);
|
|
||||||
|
|
||||||
if (result.fields && result.fields.length > 0) {
|
if (result.fields && result.fields.length > 0) {
|
||||||
setToApiFields(result.fields);
|
setToApiFields(result.fields);
|
||||||
toast.success(`TO API 필드 ${result.fields.length}개를 조회했습니다.`);
|
toast.success(`TO API 필드 ${result.fields.length}개를 조회했습니다.`);
|
||||||
|
|
@ -303,17 +313,22 @@ export default function BatchManagementNewPage() {
|
||||||
|
|
||||||
// REST API 데이터 미리보기
|
// REST API 데이터 미리보기
|
||||||
const previewRestApiData = async () => {
|
const previewRestApiData = async () => {
|
||||||
if (!fromApiUrl || !fromApiKey || !fromEndpoint) {
|
// API URL, 엔드포인트는 항상 필수
|
||||||
toast.error("API URL, API Key, 엔드포인트를 모두 입력해주세요.");
|
if (!fromApiUrl || !fromEndpoint) {
|
||||||
|
toast.error("API URL과 엔드포인트를 모두 입력해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET 메서드일 때만 API 키 필수
|
||||||
|
if (fromApiMethod === "GET" && !fromApiKey) {
|
||||||
|
toast.error("GET 메서드에서는 API 키를 입력해주세요.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log("REST API 데이터 미리보기 시작...");
|
|
||||||
|
|
||||||
const result = await BatchManagementAPI.previewRestApiData(
|
const result = await BatchManagementAPI.previewRestApiData(
|
||||||
fromApiUrl,
|
fromApiUrl,
|
||||||
fromApiKey,
|
fromApiKey || "",
|
||||||
fromEndpoint,
|
fromEndpoint,
|
||||||
fromApiMethod,
|
fromApiMethod,
|
||||||
// 파라미터 정보 추가
|
// 파라미터 정보 추가
|
||||||
|
|
@ -322,33 +337,23 @@ export default function BatchManagementNewPage() {
|
||||||
paramName: apiParamName,
|
paramName: apiParamName,
|
||||||
paramValue: apiParamValue,
|
paramValue: apiParamValue,
|
||||||
paramSource: apiParamSource
|
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) {
|
if (result.fields && result.fields.length > 0) {
|
||||||
console.log("✅ 백엔드에서 fields 제공됨:", result.fields);
|
|
||||||
setFromApiFields(result.fields);
|
setFromApiFields(result.fields);
|
||||||
setFromApiData(result.samples);
|
setFromApiData(result.samples);
|
||||||
|
|
||||||
console.log("추출된 필드:", result.fields);
|
|
||||||
toast.success(`API 데이터 미리보기 완료! ${result.fields.length}개 필드, ${result.totalCount}개 레코드`);
|
toast.success(`API 데이터 미리보기 완료! ${result.fields.length}개 필드, ${result.totalCount}개 레코드`);
|
||||||
} else if (result.samples && result.samples.length > 0) {
|
} else if (result.samples && result.samples.length > 0) {
|
||||||
// 백엔드에서 fields를 제대로 보내지 않은 경우, 프론트엔드에서 직접 추출
|
// 백엔드에서 fields를 제대로 보내지 않은 경우, 프론트엔드에서 직접 추출
|
||||||
console.log("⚠️ 백엔드에서 fields가 없어서 프론트엔드에서 추출");
|
|
||||||
const extractedFields = Object.keys(result.samples[0]);
|
const extractedFields = Object.keys(result.samples[0]);
|
||||||
console.log("프론트엔드에서 추출한 필드:", extractedFields);
|
|
||||||
|
|
||||||
setFromApiFields(extractedFields);
|
setFromApiFields(extractedFields);
|
||||||
setFromApiData(result.samples);
|
setFromApiData(result.samples);
|
||||||
|
|
||||||
toast.success(`API 데이터 미리보기 완료! ${extractedFields.length}개 필드, ${result.samples.length}개 레코드`);
|
toast.success(`API 데이터 미리보기 완료! ${extractedFields.length}개 필드, ${result.samples.length}개 레코드`);
|
||||||
} else {
|
} else {
|
||||||
console.log("❌ 데이터가 없음");
|
|
||||||
setFromApiFields([]);
|
setFromApiFields([]);
|
||||||
setFromApiData([]);
|
setFromApiData([]);
|
||||||
toast.warning("API에서 데이터를 가져올 수 없습니다.");
|
toast.warning("API에서 데이터를 가져올 수 없습니다.");
|
||||||
|
|
@ -370,38 +375,53 @@ export default function BatchManagementNewPage() {
|
||||||
|
|
||||||
// 배치 타입별 검증 및 저장
|
// 배치 타입별 검증 및 저장
|
||||||
if (batchType === 'restapi-to-db') {
|
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) {
|
if (mappedFields.length === 0) {
|
||||||
toast.error("최소 하나의 API 필드를 DB 컬럼에 매핑해주세요.");
|
toast.error("최소 하나의 API 필드를 DB 컬럼에 매핑해주세요.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// API 필드 매핑을 배치 매핑 형태로 변환
|
// API 필드 매핑을 배치 매핑 형태로 변환
|
||||||
const apiMappings = mappedFields.map(apiField => ({
|
const apiMappings = mappedFields.map((apiField) => {
|
||||||
from_connection_type: 'restapi' as const,
|
const toColumnName = apiFieldMappings[apiField]; // 매핑된 DB 컬럼 (예: access_token)
|
||||||
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
|
|
||||||
}));
|
|
||||||
|
|
||||||
console.log("REST API 배치 설정 저장:", {
|
// 기본은 상위 필드 그대로 사용하되,
|
||||||
batchName,
|
// 사용자가 JSON 경로를 직접 입력한 경우 해당 경로를 우선 사용
|
||||||
batchType,
|
let fromColumnName = apiField;
|
||||||
cronSchedule,
|
const overridePath = apiFieldPathOverrides[apiField];
|
||||||
description,
|
if (overridePath && overridePath.trim().length > 0) {
|
||||||
apiMappings
|
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 호출
|
// 실제 API 호출
|
||||||
|
|
@ -492,14 +512,6 @@ export default function BatchManagementNewPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("DB → REST API 배치 설정 저장:", {
|
|
||||||
batchName,
|
|
||||||
batchType,
|
|
||||||
cronSchedule,
|
|
||||||
description,
|
|
||||||
dbMappings
|
|
||||||
});
|
|
||||||
|
|
||||||
// 실제 API 호출 (기존 saveRestApiBatch 재사용)
|
// 실제 API 호출 (기존 saveRestApiBatch 재사용)
|
||||||
try {
|
try {
|
||||||
const result = await BatchManagementAPI.saveRestApiBatch({
|
const result = await BatchManagementAPI.saveRestApiBatch({
|
||||||
|
|
@ -645,13 +657,19 @@ export default function BatchManagementNewPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="fromApiKey">API 키 *</Label>
|
<Label htmlFor="fromApiKey">
|
||||||
|
API 키
|
||||||
|
{fromApiMethod === "GET" && <span className="text-red-500 ml-0.5">*</span>}
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="fromApiKey"
|
id="fromApiKey"
|
||||||
value={fromApiKey}
|
value={fromApiKey}
|
||||||
onChange={(e) => setFromApiKey(e.target.value)}
|
onChange={(e) => setFromApiKey(e.target.value)}
|
||||||
placeholder="ak_your_api_key_here"
|
placeholder="ak_your_api_key_here"
|
||||||
/>
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
GET 메서드에서만 필수이며, POST/PUT/DELETE일 때는 선택 사항입니다.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -673,12 +691,33 @@ export default function BatchManagementNewPage() {
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="GET">GET (데이터 조회)</SelectItem>
|
<SelectItem value="GET">GET (데이터 조회)</SelectItem>
|
||||||
|
<SelectItem value="POST">POST (데이터 조회/전송)</SelectItem>
|
||||||
|
<SelectItem value="PUT">PUT</SelectItem>
|
||||||
|
<SelectItem value="DELETE">DELETE</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</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 파라미터 설정 */}
|
{/* API 파라미터 설정 */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="border-t pt-4">
|
<div className="border-t pt-4">
|
||||||
|
|
@ -771,7 +810,10 @@ export default function BatchManagementNewPage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{fromApiUrl && fromApiKey && fromEndpoint && (
|
{/* API URL + 엔드포인트는 필수, GET일 때만 API 키 필수 */}
|
||||||
|
{fromApiUrl &&
|
||||||
|
fromEndpoint &&
|
||||||
|
(fromApiMethod !== "GET" || fromApiKey) && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="p-3 bg-gray-50 rounded-lg">
|
<div className="p-3 bg-gray-50 rounded-lg">
|
||||||
<div className="text-sm font-medium text-gray-700">API 호출 미리보기</div>
|
<div className="text-sm font-medium text-gray-700">API 호출 미리보기</div>
|
||||||
|
|
@ -786,7 +828,11 @@ export default function BatchManagementNewPage() {
|
||||||
: ''
|
: ''
|
||||||
}
|
}
|
||||||
</div>
|
</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 && (
|
{apiParamType !== 'none' && apiParamName && apiParamValue && (
|
||||||
<div className="text-xs text-blue-600 mt-1">
|
<div className="text-xs text-blue-600 mt-1">
|
||||||
파라미터: {apiParamName} = {apiParamValue} ({apiParamSource === 'static' ? '고정값' : '동적값'})
|
파라미터: {apiParamName} = {apiParamValue} ({apiParamSource === 'static' ? '고정값' : '동적값'})
|
||||||
|
|
@ -980,172 +1026,33 @@ export default function BatchManagementNewPage() {
|
||||||
|
|
||||||
{/* 매핑 UI - 배치 타입별 동적 렌더링 */}
|
{/* 매핑 UI - 배치 타입별 동적 렌더링 */}
|
||||||
{/* REST API → DB 매핑 */}
|
{/* REST API → DB 매핑 */}
|
||||||
{batchType === 'restapi-to-db' && fromApiFields.length > 0 && toColumns.length > 0 && (
|
{batchType === "restapi-to-db" &&
|
||||||
<Card>
|
fromApiFields.length > 0 &&
|
||||||
<CardHeader>
|
toColumns.length > 0 && (
|
||||||
<CardTitle>API 필드 → DB 컬럼 매핑</CardTitle>
|
<RestApiToDbMappingCard
|
||||||
</CardHeader>
|
fromApiFields={fromApiFields}
|
||||||
<CardContent>
|
toColumns={toColumns}
|
||||||
<div className="space-y-3 max-h-96 overflow-y-auto border rounded-lg p-4">
|
fromApiData={fromApiData}
|
||||||
{fromApiFields.map((apiField) => (
|
apiFieldMappings={apiFieldMappings}
|
||||||
<div key={apiField} className="flex items-center space-x-4 p-3 bg-gray-50 rounded-lg">
|
setApiFieldMappings={setApiFieldMappings}
|
||||||
{/* API 필드 정보 */}
|
apiFieldPathOverrides={apiFieldPathOverrides}
|
||||||
<div className="flex-1">
|
setApiFieldPathOverrides={setApiFieldPathOverrides}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* DB → REST API 매핑 */}
|
{/* DB → REST API 매핑 */}
|
||||||
{batchType === 'db-to-restapi' && selectedColumns.length > 0 && toApiFields.length > 0 && (
|
{batchType === "db-to-restapi" &&
|
||||||
<Card>
|
selectedColumns.length > 0 &&
|
||||||
<CardHeader>
|
toApiFields.length > 0 && (
|
||||||
<CardTitle>DB 컬럼 → API 필드 매핑</CardTitle>
|
<DbToRestApiMappingCard
|
||||||
<CardDescription>
|
fromColumns={fromColumns}
|
||||||
DB 컬럼 값을 REST API Request Body에 매핑하세요. Request Body 템플릿에서 {`{{컬럼명}}`} 형태로 사용됩니다.
|
selectedColumns={selectedColumns}
|
||||||
</CardDescription>
|
toApiFields={toApiFields}
|
||||||
</CardHeader>
|
dbToApiFieldMapping={dbToApiFieldMapping}
|
||||||
<CardContent>
|
setDbToApiFieldMapping={setDbToApiFieldMapping}
|
||||||
<div className="space-y-3 max-h-96 overflow-y-auto border rounded-lg p-4">
|
setToApiBody={setToApiBody}
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* TO 설정 */}
|
{/* TO 설정 */}
|
||||||
<Card>
|
<Card>
|
||||||
|
|
@ -1348,4 +1255,278 @@ export default function BatchManagementNewPage() {
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</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 { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
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 { Badge } from "@/components/ui/badge";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { RefreshCw, Save, ArrowLeft, Plus, Trash2 } from "lucide-react";
|
import { RefreshCw, Save, ArrowLeft, Plus, Trash2 } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
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 {
|
interface BatchColumnInfo {
|
||||||
column_name: string;
|
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);
|
const [batchType, setBatchType] = useState<'db-to-db' | 'restapi-to-db' | 'db-to-restapi' | null>(null);
|
||||||
|
|
||||||
|
// REST API 미리보기 상태
|
||||||
|
const [apiPreviewData, setApiPreviewData] = useState<any[]>([]);
|
||||||
|
|
||||||
|
|
||||||
// 페이지 로드 시 배치 정보 조회
|
// 페이지 로드 시 배치 정보 조회
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -335,6 +350,86 @@ export default function BatchEditPage() {
|
||||||
setMappings([...mappings, newMapping]);
|
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 removeMapping = (index: number) => {
|
||||||
const updatedMappings = mappings.filter((_, i) => i !== index);
|
const updatedMappings = mappings.filter((_, i) => i !== index);
|
||||||
|
|
@ -404,14 +499,16 @@ export default function BatchEditPage() {
|
||||||
<h1 className="text-3xl font-bold">배치 설정 수정</h1>
|
<h1 className="text-3xl font-bold">배치 설정 수정</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex space-x-2">
|
<div className="flex space-x-2">
|
||||||
<Button onClick={loadBatchConfig} variant="outline" disabled={loading}>
|
<Button
|
||||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
onClick={loadBatchConfig}
|
||||||
|
variant="outline"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
className={`w-4 h-4 mr-2 ${loading ? "animate-spin" : ""}`}
|
||||||
|
/>
|
||||||
새로고침
|
새로고침
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={saveBatchConfig} disabled={loading}>
|
|
||||||
<Save className="w-4 h-4 mr-2" />
|
|
||||||
저장
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -580,22 +677,91 @@ export default function BatchEditPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{mappings.length > 0 && (
|
{mappings.length > 0 && (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<Label>API URL</Label>
|
<div>
|
||||||
<Input value={mappings[0]?.from_api_url || ''} readOnly />
|
<Label>API URL</Label>
|
||||||
|
<Input value={mappings[0]?.from_api_url || ""} readOnly />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>API 엔드포인트</Label>
|
||||||
|
<Input
|
||||||
|
value={mappings[0]?.from_table_name || ""}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>HTTP 메서드</Label>
|
||||||
|
<Input
|
||||||
|
value={mappings[0]?.from_api_method || "GET"}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>대상 테이블</Label>
|
||||||
|
<Input
|
||||||
|
value={mappings[0]?.to_table_name || ""}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Request Body (JSON) 편집 UI */}
|
||||||
<div>
|
<div>
|
||||||
<Label>API 엔드포인트</Label>
|
<Label>Request Body (JSON)</Label>
|
||||||
<Input value={mappings[0]?.from_table_name || ''} readOnly />
|
<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>
|
||||||
<div>
|
|
||||||
<Label>HTTP 메서드</Label>
|
{/* API 데이터 미리보기 */}
|
||||||
<Input value={mappings[0]?.from_api_method || 'GET'} readOnly />
|
<div className="space-y-3">
|
||||||
</div>
|
<Button
|
||||||
<div>
|
variant="outline"
|
||||||
<Label>대상 테이블</Label>
|
size="sm"
|
||||||
<Input value={mappings[0]?.to_table_name || ''} readOnly />
|
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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -647,6 +813,12 @@ export default function BatchEditPage() {
|
||||||
매핑 추가
|
매핑 추가
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{batchType === 'restapi-to-db' && (
|
||||||
|
<Button onClick={addRestapiToDbMapping} size="sm">
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
매핑 추가
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
|
@ -751,20 +923,73 @@ export default function BatchEditPage() {
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-medium">매핑 #{index + 1}</h4>
|
<h4 className="font-medium">매핑 #{index + 1}</h4>
|
||||||
<p className="text-sm text-gray-600">
|
{mapping.from_column_name && mapping.to_column_name && (
|
||||||
API 필드: {mapping.from_column_name} → DB 컬럼: {mapping.to_column_name}
|
<p className="text-sm text-gray-600">
|
||||||
</p>
|
API 필드: {mapping.from_column_name} → DB 컬럼: {mapping.to_column_name}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeMapping(index)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<Label>API 필드명</Label>
|
<Label>API 필드명 (JSON 경로)</Label>
|
||||||
<Input value={mapping.from_column_name || ''} readOnly />
|
<Input
|
||||||
|
value={mapping.from_column_name || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateMapping(
|
||||||
|
index,
|
||||||
|
"from_column_name",
|
||||||
|
e.target.value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
placeholder="response.access_token"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label>DB 컬럼명</Label>
|
<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>
|
</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="bearer">Bearer Token</SelectItem>
|
||||||
<SelectItem value="basic">Basic Auth</SelectItem>
|
<SelectItem value="basic">Basic Auth</SelectItem>
|
||||||
<SelectItem value="oauth2">OAuth 2.0</SelectItem>
|
<SelectItem value="oauth2">OAuth 2.0</SelectItem>
|
||||||
|
<SelectItem value="db-token">DB 토큰</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -192,6 +193,94 @@ export function AuthenticationConfig({
|
||||||
</div>
|
</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" && (
|
{authType === "none" && (
|
||||||
<div className="rounded-md border border-dashed p-4 text-center text-sm text-gray-500">
|
<div className="rounded-md border border-dashed p-4 text-center text-sm text-gray-500">
|
||||||
인증이 필요하지 않은 공개 API입니다.
|
인증이 필요하지 않은 공개 API입니다.
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ const AUTH_TYPE_LABELS: Record<string, string> = {
|
||||||
bearer: "Bearer",
|
bearer: "Bearer",
|
||||||
basic: "Basic Auth",
|
basic: "Basic Auth",
|
||||||
oauth2: "OAuth 2.0",
|
oauth2: "OAuth 2.0",
|
||||||
|
"db-token": "DB 토큰",
|
||||||
};
|
};
|
||||||
|
|
||||||
// 활성 상태 옵션
|
// 활성 상태 옵션
|
||||||
|
|
@ -158,6 +159,22 @@ export function RestApiConnectionList() {
|
||||||
|
|
||||||
setTestResults((prev) => new Map(prev).set(connection.id!, result.success));
|
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) {
|
if (result.success) {
|
||||||
toast({
|
toast({
|
||||||
title: "연결 성공",
|
title: "연결 성공",
|
||||||
|
|
|
||||||
|
|
@ -21,10 +21,13 @@ import {
|
||||||
ExternalRestApiConnection,
|
ExternalRestApiConnection,
|
||||||
AuthType,
|
AuthType,
|
||||||
RestApiTestResult,
|
RestApiTestResult,
|
||||||
|
RestApiTestRequest,
|
||||||
} from "@/lib/api/externalRestApiConnection";
|
} from "@/lib/api/externalRestApiConnection";
|
||||||
import { HeadersManager } from "./HeadersManager";
|
import { HeadersManager } from "./HeadersManager";
|
||||||
import { AuthenticationConfig } from "./AuthenticationConfig";
|
import { AuthenticationConfig } from "./AuthenticationConfig";
|
||||||
import { Badge } from "@/components/ui/badge";
|
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 {
|
interface RestApiConnectionModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -42,6 +45,8 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
||||||
const [baseUrl, setBaseUrl] = useState("");
|
const [baseUrl, setBaseUrl] = useState("");
|
||||||
const [endpointPath, setEndpointPath] = useState("");
|
const [endpointPath, setEndpointPath] = useState("");
|
||||||
const [defaultHeaders, setDefaultHeaders] = useState<Record<string, string>>({});
|
const [defaultHeaders, setDefaultHeaders] = useState<Record<string, string>>({});
|
||||||
|
const [defaultMethod, setDefaultMethod] = useState("GET");
|
||||||
|
const [defaultBody, setDefaultBody] = useState("");
|
||||||
const [authType, setAuthType] = useState<AuthType>("none");
|
const [authType, setAuthType] = useState<AuthType>("none");
|
||||||
const [authConfig, setAuthConfig] = useState<any>({});
|
const [authConfig, setAuthConfig] = useState<any>({});
|
||||||
const [timeout, setTimeout] = useState(30000);
|
const [timeout, setTimeout] = useState(30000);
|
||||||
|
|
@ -52,6 +57,8 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
||||||
// UI 상태
|
// UI 상태
|
||||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||||
const [testEndpoint, setTestEndpoint] = useState("");
|
const [testEndpoint, setTestEndpoint] = useState("");
|
||||||
|
const [testMethod, setTestMethod] = useState("GET");
|
||||||
|
const [testBody, setTestBody] = useState("");
|
||||||
const [testing, setTesting] = useState(false);
|
const [testing, setTesting] = useState(false);
|
||||||
const [testResult, setTestResult] = useState<RestApiTestResult | null>(null);
|
const [testResult, setTestResult] = useState<RestApiTestResult | null>(null);
|
||||||
const [testRequestUrl, setTestRequestUrl] = useState<string>("");
|
const [testRequestUrl, setTestRequestUrl] = useState<string>("");
|
||||||
|
|
@ -65,12 +72,19 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
||||||
setBaseUrl(connection.base_url);
|
setBaseUrl(connection.base_url);
|
||||||
setEndpointPath(connection.endpoint_path || "");
|
setEndpointPath(connection.endpoint_path || "");
|
||||||
setDefaultHeaders(connection.default_headers || {});
|
setDefaultHeaders(connection.default_headers || {});
|
||||||
|
setDefaultMethod(connection.default_method || "GET");
|
||||||
|
setDefaultBody(connection.default_body || "");
|
||||||
setAuthType(connection.auth_type);
|
setAuthType(connection.auth_type);
|
||||||
setAuthConfig(connection.auth_config || {});
|
setAuthConfig(connection.auth_config || {});
|
||||||
setTimeout(connection.timeout || 30000);
|
setTimeout(connection.timeout || 30000);
|
||||||
setRetryCount(connection.retry_count || 0);
|
setRetryCount(connection.retry_count || 0);
|
||||||
setRetryDelay(connection.retry_delay || 1000);
|
setRetryDelay(connection.retry_delay || 1000);
|
||||||
setIsActive(connection.is_active === "Y");
|
setIsActive(connection.is_active === "Y");
|
||||||
|
|
||||||
|
// 테스트 초기값 설정
|
||||||
|
setTestEndpoint("");
|
||||||
|
setTestMethod(connection.default_method || "GET");
|
||||||
|
setTestBody(connection.default_body || "");
|
||||||
} else {
|
} else {
|
||||||
// 초기화
|
// 초기화
|
||||||
setConnectionName("");
|
setConnectionName("");
|
||||||
|
|
@ -78,16 +92,22 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
||||||
setBaseUrl("");
|
setBaseUrl("");
|
||||||
setEndpointPath("");
|
setEndpointPath("");
|
||||||
setDefaultHeaders({ "Content-Type": "application/json" });
|
setDefaultHeaders({ "Content-Type": "application/json" });
|
||||||
|
setDefaultMethod("GET");
|
||||||
|
setDefaultBody("");
|
||||||
setAuthType("none");
|
setAuthType("none");
|
||||||
setAuthConfig({});
|
setAuthConfig({});
|
||||||
setTimeout(30000);
|
setTimeout(30000);
|
||||||
setRetryCount(0);
|
setRetryCount(0);
|
||||||
setRetryDelay(1000);
|
setRetryDelay(1000);
|
||||||
setIsActive(true);
|
setIsActive(true);
|
||||||
|
|
||||||
|
// 테스트 초기값 설정
|
||||||
|
setTestEndpoint("");
|
||||||
|
setTestMethod("GET");
|
||||||
|
setTestBody("");
|
||||||
}
|
}
|
||||||
|
|
||||||
setTestResult(null);
|
setTestResult(null);
|
||||||
setTestEndpoint("");
|
|
||||||
setTestRequestUrl("");
|
setTestRequestUrl("");
|
||||||
}, [connection, isOpen]);
|
}, [connection, isOpen]);
|
||||||
|
|
||||||
|
|
@ -111,14 +131,18 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
||||||
setTestRequestUrl(fullUrl);
|
setTestRequestUrl(fullUrl);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await ExternalRestApiConnectionAPI.testConnection({
|
const testRequest: RestApiTestRequest = {
|
||||||
base_url: baseUrl,
|
base_url: baseUrl,
|
||||||
endpoint: testEndpoint || undefined,
|
endpoint: testEndpoint || undefined,
|
||||||
|
method: testMethod as any,
|
||||||
headers: defaultHeaders,
|
headers: defaultHeaders,
|
||||||
|
body: testBody ? JSON.parse(testBody) : undefined,
|
||||||
auth_type: authType,
|
auth_type: authType,
|
||||||
auth_config: authConfig,
|
auth_config: authConfig,
|
||||||
timeout,
|
timeout,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
const result = await ExternalRestApiConnectionAPI.testConnection(testRequest);
|
||||||
|
|
||||||
setTestResult(result);
|
setTestResult(result);
|
||||||
|
|
||||||
|
|
@ -178,6 +202,20 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// JSON 유효성 검증
|
||||||
|
if (defaultBody && defaultMethod !== "GET" && defaultMethod !== "DELETE") {
|
||||||
|
try {
|
||||||
|
JSON.parse(defaultBody);
|
||||||
|
} catch {
|
||||||
|
toast({
|
||||||
|
title: "입력 오류",
|
||||||
|
description: "기본 Body가 올바른 JSON 형식이 아닙니다.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -187,6 +225,8 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
||||||
base_url: baseUrl,
|
base_url: baseUrl,
|
||||||
endpoint_path: endpointPath || undefined,
|
endpoint_path: endpointPath || undefined,
|
||||||
default_headers: defaultHeaders,
|
default_headers: defaultHeaders,
|
||||||
|
default_method: defaultMethod,
|
||||||
|
default_body: defaultBody || undefined,
|
||||||
auth_type: authType,
|
auth_type: authType,
|
||||||
auth_config: authType === "none" ? undefined : authConfig,
|
auth_config: authType === "none" ? undefined : authConfig,
|
||||||
timeout,
|
timeout,
|
||||||
|
|
@ -262,12 +302,28 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
||||||
<Label htmlFor="base-url">
|
<Label htmlFor="base-url">
|
||||||
기본 URL <span className="text-destructive">*</span>
|
기본 URL <span className="text-destructive">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<div className="flex gap-2">
|
||||||
id="base-url"
|
<Select value={defaultMethod} onValueChange={setDefaultMethod}>
|
||||||
value={baseUrl}
|
<SelectTrigger className="w-[100px]">
|
||||||
onChange={(e) => setBaseUrl(e.target.value)}
|
<SelectValue placeholder="Method" />
|
||||||
placeholder="https://api.example.com"
|
</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">
|
<p className="text-muted-foreground text-xs">
|
||||||
도메인 부분만 입력하세요 (예: https://apihub.kma.go.kr)
|
도메인 부분만 입력하세요 (예: https://apihub.kma.go.kr)
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -286,6 +342,21 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<div className="flex items-center space-x-2">
|
||||||
<Switch id="is-active" checked={isActive} onCheckedChange={setIsActive} />
|
<Switch id="is-active" checked={isActive} onCheckedChange={setIsActive} />
|
||||||
<Label htmlFor="is-active" className="cursor-pointer">
|
<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>
|
<h3 className="text-sm font-semibold">연결 테스트</h3>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="test-endpoint">테스트 엔드포인트 (선택)</Label>
|
<Label htmlFor="test-endpoint">테스트 설정</Label>
|
||||||
<Input
|
<div className="flex gap-2 mb-2">
|
||||||
id="test-endpoint"
|
<Select value={testMethod} onValueChange={setTestMethod}>
|
||||||
value={testEndpoint}
|
<SelectTrigger className="w-[100px]">
|
||||||
onChange={(e) => setTestEndpoint(e.target.value)}
|
<SelectValue placeholder="Method" />
|
||||||
placeholder="엔드포인트 또는 빈칸(기본 URL만 테스트)"
|
</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>
|
</div>
|
||||||
|
|
||||||
<Button type="button" variant="outline" onClick={handleTest} disabled={testing}>
|
<Button type="button" variant="outline" onClick={handleTest} disabled={testing}>
|
||||||
|
|
@ -388,10 +491,22 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
||||||
{testRequestUrl && (
|
{testRequestUrl && (
|
||||||
<div className="bg-muted/30 space-y-3 rounded-md border p-3">
|
<div className="bg-muted/30 space-y-3 rounded-md border p-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-muted-foreground mb-1 text-xs font-medium">테스트 요청 URL</div>
|
<div className="text-muted-foreground mb-1 text-xs font-medium">테스트 요청</div>
|
||||||
<code className="text-foreground block text-xs break-all">GET {testRequestUrl}</code>
|
<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>
|
</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 && (
|
{Object.keys(defaultHeaders).length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<div className="text-muted-foreground mb-1 text-xs font-medium">요청 헤더</div>
|
<div className="text-muted-foreground mb-1 text-xs font-medium">요청 헤더</div>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { useState, useEffect, useMemo } from "react";
|
import { useState, useEffect, useMemo } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
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 { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
|
@ -39,6 +39,77 @@ import {
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} 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)
|
// 백엔드 DB 객체 타입 (snake_case)
|
||||||
interface DbObject {
|
interface DbObject {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -550,10 +621,11 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
||||||
areaKey: obj.area_key,
|
areaKey: obj.area_key,
|
||||||
locaKey: obj.loca_key,
|
locaKey: obj.loca_key,
|
||||||
locType: obj.loc_type,
|
locType: obj.loc_type,
|
||||||
materialCount: obj.material_count,
|
materialCount: obj.loc_type === "STP" ? undefined : obj.material_count,
|
||||||
materialPreview: obj.material_preview_height
|
materialPreview:
|
||||||
? { height: parseFloat(obj.material_preview_height) }
|
obj.loc_type === "STP" || !obj.material_preview_height
|
||||||
: undefined,
|
? undefined
|
||||||
|
: { height: parseFloat(obj.material_preview_height) },
|
||||||
parentId: obj.parent_id,
|
parentId: obj.parent_id,
|
||||||
displayOrder: obj.display_order,
|
displayOrder: obj.display_order,
|
||||||
locked: obj.locked,
|
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 };
|
let objectSize = defaults.size || { x: 5, y: 5, z: 5 };
|
||||||
|
|
||||||
// Location 배치 시 자재 개수에 따라 높이 자동 설정
|
// Location 배치 시 자재 개수에 따라 높이 자동 설정 (BED/TMP/DES만 대상, STP는 자재 미적재)
|
||||||
if (
|
if (
|
||||||
(draggedTool === "location-bed" ||
|
(draggedTool === "location-bed" || draggedTool === "location-temp" || draggedTool === "location-dest") &&
|
||||||
draggedTool === "location-stp" ||
|
|
||||||
draggedTool === "location-temp" ||
|
|
||||||
draggedTool === "location-dest") &&
|
|
||||||
locaKey &&
|
locaKey &&
|
||||||
selectedDbConnection &&
|
selectedDbConnection &&
|
||||||
hierarchyConfig?.material
|
hierarchyConfig?.material
|
||||||
|
|
@ -877,12 +946,9 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
||||||
setDraggedAreaData(null);
|
setDraggedAreaData(null);
|
||||||
setDraggedLocationData(null);
|
setDraggedLocationData(null);
|
||||||
|
|
||||||
// Location 배치 시 자재 개수 로드
|
// Location 배치 시 자재 개수 로드 (BED/TMP/DES만 대상, STP는 자재 미적재)
|
||||||
if (
|
if (
|
||||||
(draggedTool === "location-bed" ||
|
(draggedTool === "location-bed" || draggedTool === "location-temp" || draggedTool === "location-dest") &&
|
||||||
draggedTool === "location-stp" ||
|
|
||||||
draggedTool === "location-temp" ||
|
|
||||||
draggedTool === "location-dest") &&
|
|
||||||
locaKey
|
locaKey
|
||||||
) {
|
) {
|
||||||
// 새 객체 추가 후 자재 개수 로드 (약간의 딜레이를 두어 state 업데이트 완료 후 실행)
|
// 새 객체 추가 후 자재 개수 로드 (약간의 딜레이를 두어 state 업데이트 완료 후 실행)
|
||||||
|
|
@ -965,13 +1031,10 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
||||||
loadLocationsForArea(obj.areaKey);
|
loadLocationsForArea(obj.areaKey);
|
||||||
setShowMaterialPanel(false);
|
setShowMaterialPanel(false);
|
||||||
}
|
}
|
||||||
// Location을 클릭한 경우, 해당 Location의 자재 목록 로드
|
// Location을 클릭한 경우, 해당 Location의 자재 목록 로드 (STP는 자재 미적재이므로 제외)
|
||||||
else if (
|
else if (
|
||||||
obj &&
|
obj &&
|
||||||
(obj.type === "location-bed" ||
|
(obj.type === "location-bed" || obj.type === "location-temp" || obj.type === "location-dest") &&
|
||||||
obj.type === "location-stp" ||
|
|
||||||
obj.type === "location-temp" ||
|
|
||||||
obj.type === "location-dest") &&
|
|
||||||
obj.locaKey &&
|
obj.locaKey &&
|
||||||
selectedDbConnection
|
selectedDbConnection
|
||||||
) {
|
) {
|
||||||
|
|
@ -988,9 +1051,15 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
||||||
try {
|
try {
|
||||||
const response = await getMaterialCounts(selectedDbConnection, selectedTables.material, locaKeys);
|
const response = await getMaterialCounts(selectedDbConnection, selectedTables.material, locaKeys);
|
||||||
if (response.success && response.data) {
|
if (response.success && response.data) {
|
||||||
// 각 Location 객체에 자재 개수 업데이트
|
// 각 Location 객체에 자재 개수 업데이트 (STP는 자재 미적재이므로 제외)
|
||||||
setPlacedObjects((prev) =>
|
setPlacedObjects((prev) =>
|
||||||
prev.map((obj) => {
|
prev.map((obj) => {
|
||||||
|
if (
|
||||||
|
!obj.locaKey ||
|
||||||
|
obj.type === "location-stp" // STP는 자재 없음
|
||||||
|
) {
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
const materialCount = response.data?.find((mc) => mc.LOCAKEY === obj.locaKey);
|
const materialCount = response.data?.find((mc) => mc.LOCAKEY === obj.locaKey);
|
||||||
if (materialCount) {
|
if (materialCount) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -1278,7 +1347,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
||||||
const oldSize = actualObject.size;
|
const oldSize = actualObject.size;
|
||||||
const newSize = { ...oldSize, ...updates.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.x = Math.max(5, Math.round(newSize.x / 5) * 5);
|
||||||
newSize.z = Math.max(5, Math.round(newSize.z / 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,
|
areaKey: obj.area_key,
|
||||||
locaKey: obj.loca_key,
|
locaKey: obj.loca_key,
|
||||||
locType: obj.loc_type,
|
locType: obj.loc_type,
|
||||||
materialCount: obj.material_count,
|
materialCount: obj.loc_type === "STP" ? undefined : obj.material_count,
|
||||||
materialPreview: obj.material_preview_height
|
materialPreview:
|
||||||
? { height: parseFloat(obj.material_preview_height) }
|
obj.loc_type === "STP" || !obj.material_preview_height
|
||||||
: undefined,
|
? undefined
|
||||||
|
: { height: parseFloat(obj.material_preview_height) },
|
||||||
parentId: obj.parent_id,
|
parentId: obj.parent_id,
|
||||||
displayOrder: obj.display_order,
|
displayOrder: obj.display_order,
|
||||||
locked: obj.locked,
|
locked: obj.locked,
|
||||||
|
|
@ -1798,6 +1868,8 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
||||||
</div>
|
</div>
|
||||||
{isLocationPlaced ? (
|
{isLocationPlaced ? (
|
||||||
<Check className="h-4 w-4 text-green-500" />
|
<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" />
|
<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 htmlFor="object-name" className="text-sm">
|
||||||
이름
|
이름
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<DebouncedInput
|
||||||
id="object-name"
|
id="object-name"
|
||||||
value={selectedObject.name || ""}
|
value={selectedObject.name || ""}
|
||||||
onChange={(e) => handleObjectUpdate({ name: e.target.value })}
|
onCommit={(val) => handleObjectUpdate({ name: val })}
|
||||||
className="mt-1.5 h-9 text-sm"
|
className="mt-1.5 h-9 text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -2085,15 +2157,15 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
||||||
<Label htmlFor="pos-x" className="text-muted-foreground text-xs">
|
<Label htmlFor="pos-x" className="text-muted-foreground text-xs">
|
||||||
X
|
X
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<DebouncedInput
|
||||||
id="pos-x"
|
id="pos-x"
|
||||||
type="number"
|
type="number"
|
||||||
value={(selectedObject.position?.x || 0).toFixed(1)}
|
value={(selectedObject.position?.x || 0).toFixed(1)}
|
||||||
onChange={(e) =>
|
onCommit={(val) =>
|
||||||
handleObjectUpdate({
|
handleObjectUpdate({
|
||||||
position: {
|
position: {
|
||||||
...selectedObject.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">
|
<Label htmlFor="pos-z" className="text-muted-foreground text-xs">
|
||||||
Z
|
Z
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<DebouncedInput
|
||||||
id="pos-z"
|
id="pos-z"
|
||||||
type="number"
|
type="number"
|
||||||
value={(selectedObject.position?.z || 0).toFixed(1)}
|
value={(selectedObject.position?.z || 0).toFixed(1)}
|
||||||
onChange={(e) =>
|
onCommit={(val) =>
|
||||||
handleObjectUpdate({
|
handleObjectUpdate({
|
||||||
position: {
|
position: {
|
||||||
...selectedObject.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">
|
<Label htmlFor="size-x" className="text-muted-foreground text-xs">
|
||||||
W (5 단위)
|
W (5 단위)
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<DebouncedInput
|
||||||
id="size-x"
|
id="size-x"
|
||||||
type="number"
|
type="number"
|
||||||
step="5"
|
step="5"
|
||||||
min="5"
|
min="5"
|
||||||
value={selectedObject.size?.x || 5}
|
value={selectedObject.size?.x || 5}
|
||||||
onChange={(e) =>
|
onCommit={(val) =>
|
||||||
handleObjectUpdate({
|
handleObjectUpdate({
|
||||||
size: {
|
size: {
|
||||||
...selectedObject.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">
|
<Label htmlFor="size-y" className="text-muted-foreground text-xs">
|
||||||
H
|
H
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<DebouncedInput
|
||||||
id="size-y"
|
id="size-y"
|
||||||
type="number"
|
type="number"
|
||||||
value={selectedObject.size?.y || 5}
|
value={selectedObject.size?.y || 5}
|
||||||
onChange={(e) =>
|
onCommit={(val) =>
|
||||||
handleObjectUpdate({
|
handleObjectUpdate({
|
||||||
size: {
|
size: {
|
||||||
...selectedObject.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">
|
<Label htmlFor="size-z" className="text-muted-foreground text-xs">
|
||||||
D (5 단위)
|
D (5 단위)
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<DebouncedInput
|
||||||
id="size-z"
|
id="size-z"
|
||||||
type="number"
|
type="number"
|
||||||
step="5"
|
step="5"
|
||||||
min="5"
|
min="5"
|
||||||
value={selectedObject.size?.z || 5}
|
value={selectedObject.size?.z || 5}
|
||||||
onChange={(e) =>
|
onCommit={(val) =>
|
||||||
handleObjectUpdate({
|
handleObjectUpdate({
|
||||||
size: {
|
size: {
|
||||||
...selectedObject.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 htmlFor="object-color" className="text-sm">
|
||||||
색상
|
색상
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<DebouncedInput
|
||||||
id="object-color"
|
id="object-color"
|
||||||
type="color"
|
type="color"
|
||||||
|
debounce={100}
|
||||||
value={selectedObject.color || "#3b82f6"}
|
value={selectedObject.color || "#3b82f6"}
|
||||||
onChange={(e) => handleObjectUpdate({ color: e.target.value })}
|
onCommit={(val) => handleObjectUpdate({ color: val })}
|
||||||
className="mt-1.5 h-9"
|
className="mt-1.5 h-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useMemo } from "react";
|
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 { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -87,10 +87,11 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
||||||
areaKey: obj.area_key,
|
areaKey: obj.area_key,
|
||||||
locaKey: obj.loca_key,
|
locaKey: obj.loca_key,
|
||||||
locType: obj.loc_type,
|
locType: obj.loc_type,
|
||||||
materialCount: obj.material_count,
|
materialCount: obj.loc_type === "STP" ? undefined : obj.material_count,
|
||||||
materialPreview: obj.material_preview_height
|
materialPreview:
|
||||||
? { height: parseFloat(obj.material_preview_height) }
|
obj.loc_type === "STP" || !obj.material_preview_height
|
||||||
: undefined,
|
? undefined
|
||||||
|
: { height: parseFloat(obj.material_preview_height) },
|
||||||
parentId: obj.parent_id,
|
parentId: obj.parent_id,
|
||||||
displayOrder: obj.display_order,
|
displayOrder: obj.display_order,
|
||||||
locked: obj.locked,
|
locked: obj.locked,
|
||||||
|
|
@ -166,13 +167,10 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
||||||
const obj = placedObjects.find((o) => o.id === objectId);
|
const obj = placedObjects.find((o) => o.id === objectId);
|
||||||
setSelectedObject(obj || null);
|
setSelectedObject(obj || null);
|
||||||
|
|
||||||
// Location을 클릭한 경우, 자재 정보 표시
|
// Location을 클릭한 경우, 자재 정보 표시 (STP는 자재 미적재이므로 제외)
|
||||||
if (
|
if (
|
||||||
obj &&
|
obj &&
|
||||||
(obj.type === "location-bed" ||
|
(obj.type === "location-bed" || obj.type === "location-temp" || obj.type === "location-dest") &&
|
||||||
obj.type === "location-stp" ||
|
|
||||||
obj.type === "location-temp" ||
|
|
||||||
obj.type === "location-dest") &&
|
|
||||||
obj.locaKey &&
|
obj.locaKey &&
|
||||||
externalDbConnectionId
|
externalDbConnectionId
|
||||||
) {
|
) {
|
||||||
|
|
@ -363,59 +361,59 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
||||||
// Area가 없으면 기존 평면 리스트 유지
|
// Area가 없으면 기존 평면 리스트 유지
|
||||||
if (areaObjects.length === 0) {
|
if (areaObjects.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{filteredObjects.map((obj) => {
|
{filteredObjects.map((obj) => {
|
||||||
let typeLabel = obj.type;
|
let typeLabel = obj.type;
|
||||||
if (obj.type === "location-bed") typeLabel = "베드(BED)";
|
if (obj.type === "location-bed") typeLabel = "베드(BED)";
|
||||||
else if (obj.type === "location-stp") typeLabel = "정차포인트(STP)";
|
else if (obj.type === "location-stp") typeLabel = "정차포인트(STP)";
|
||||||
else if (obj.type === "location-temp") typeLabel = "임시베드(TMP)";
|
else if (obj.type === "location-temp") typeLabel = "임시베드(TMP)";
|
||||||
else if (obj.type === "location-dest") typeLabel = "지정착지(DES)";
|
else if (obj.type === "location-dest") typeLabel = "지정착지(DES)";
|
||||||
else if (obj.type === "crane-mobile") typeLabel = "크레인";
|
else if (obj.type === "crane-mobile") typeLabel = "크레인";
|
||||||
else if (obj.type === "area") typeLabel = "Area";
|
else if (obj.type === "area") typeLabel = "Area";
|
||||||
else if (obj.type === "rack") typeLabel = "랙";
|
else if (obj.type === "rack") typeLabel = "랙";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={obj.id}
|
key={obj.id}
|
||||||
onClick={() => handleObjectClick(obj.id)}
|
onClick={() => handleObjectClick(obj.id)}
|
||||||
className={`bg-background hover:bg-accent cursor-pointer rounded-lg border p-3 transition-all ${
|
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"
|
selectedObject?.id === obj.id ? "ring-primary bg-primary/5 ring-2" : "hover:shadow-sm"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="text-sm font-medium">{obj.name}</p>
|
<p className="text-sm font-medium">{obj.name}</p>
|
||||||
<div className="text-muted-foreground mt-1 flex items-center gap-2 text-xs">
|
<div className="text-muted-foreground mt-1 flex items-center gap-2 text-xs">
|
||||||
<span
|
<span
|
||||||
className="inline-block h-2 w-2 rounded-full"
|
className="inline-block h-2 w-2 rounded-full"
|
||||||
style={{ backgroundColor: obj.color }}
|
style={{ backgroundColor: obj.color }}
|
||||||
/>
|
/>
|
||||||
<span>{typeLabel}</span>
|
<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>
|
||||||
);
|
</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>
|
||||||
);
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Area가 있는 경우: Area → Location 계층 아코디언
|
// 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 justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<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>
|
<span className="text-xs font-medium">{locationObj.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
|
|
|
||||||
|
|
@ -131,13 +131,13 @@ export default function HierarchyConfigPanel({
|
||||||
try {
|
try {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
tablesToFetch.map(async (tableName) => {
|
tablesToFetch.map(async (tableName) => {
|
||||||
try {
|
try {
|
||||||
const columns = await onLoadColumns(tableName);
|
const columns = await onLoadColumns(tableName);
|
||||||
const normalized = normalizeColumns(columns);
|
const normalized = normalizeColumns(columns);
|
||||||
setColumnsCache((prev) => ({ ...prev, [tableName]: normalized }));
|
setColumnsCache((prev) => ({ ...prev, [tableName]: normalized }));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`컬럼 로드 실패 (${tableName}):`, error);
|
console.error(`컬럼 로드 실패 (${tableName}):`, error);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -593,52 +593,58 @@ function MaterialBox({
|
||||||
);
|
);
|
||||||
|
|
||||||
case "location-stp":
|
case "location-stp":
|
||||||
// 정차포인트(STP): 주황색 낮은 플랫폼
|
// 정차포인트(STP): 회색 타원형 플랫폼 + 'P' 마크 (자재 미적재 영역)
|
||||||
return (
|
{
|
||||||
<>
|
const baseRadius = 0.5; // 스케일로 실제 W/D를 반영 (타원형)
|
||||||
<Box args={[boxWidth, boxHeight, boxDepth]}>
|
const labelFontSize = Math.min(boxWidth, boxDepth) * 0.15;
|
||||||
<meshStandardMaterial
|
const iconFontSize = Math.min(boxWidth, boxDepth) * 0.3;
|
||||||
color={placement.color}
|
|
||||||
roughness={0.6}
|
|
||||||
metalness={0.2}
|
|
||||||
emissive={isSelected ? placement.color : "#000000"}
|
|
||||||
emissiveIntensity={isSelected ? glowIntensity * 0.8 : 0}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Location 이름 */}
|
return (
|
||||||
{placement.name && (
|
<>
|
||||||
|
{/* 타원형 플랫폼: 단위 실린더를 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
|
<Text
|
||||||
position={[0, boxHeight / 2 + 0.3, 0]}
|
position={[0, boxHeight / 2 + 0.05, 0]}
|
||||||
rotation={[-Math.PI / 2, 0, 0]}
|
rotation={[-Math.PI / 2, 0, 0]}
|
||||||
fontSize={Math.min(boxWidth, boxDepth) * 0.15}
|
fontSize={iconFontSize}
|
||||||
color="#ffffff"
|
color="#ffffff"
|
||||||
anchorX="center"
|
anchorX="center"
|
||||||
anchorY="middle"
|
anchorY="middle"
|
||||||
outlineWidth={0.03}
|
outlineWidth={0.08}
|
||||||
outlineColor="#000000"
|
outlineColor="#000000"
|
||||||
>
|
>
|
||||||
{placement.name}
|
P
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 자재 개수 (STP는 정차포인트라 자재가 없을 수 있음) */}
|
{/* Location 이름 */}
|
||||||
{placement.material_count !== undefined && placement.material_count > 0 && (
|
{placement.name && (
|
||||||
<Text
|
<Text
|
||||||
position={[0, boxHeight / 2 + 0.6, 0]}
|
position={[0, boxHeight / 2 + 0.4, 0]}
|
||||||
rotation={[-Math.PI / 2, 0, 0]}
|
rotation={[-Math.PI / 2, 0, 0]}
|
||||||
fontSize={Math.min(boxWidth, boxDepth) * 0.12}
|
fontSize={labelFontSize}
|
||||||
color="#fbbf24"
|
color="#ffffff"
|
||||||
anchorX="center"
|
anchorX="center"
|
||||||
anchorY="middle"
|
anchorY="middle"
|
||||||
outlineWidth={0.03}
|
outlineWidth={0.03}
|
||||||
outlineColor="#000000"
|
outlineColor="#000000"
|
||||||
>
|
>
|
||||||
{`자재: ${placement.material_count}개`}
|
{placement.name}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// case "gantry-crane":
|
// case "gantry-crane":
|
||||||
// // 겐트리 크레인: 기둥 2개 + 상단 빔
|
// // 겐트리 크레인: 기둥 2개 + 상단 빔
|
||||||
|
|
@ -1098,10 +1104,12 @@ function Scene({
|
||||||
orbitControlsRef={orbitControlsRef}
|
orbitControlsRef={orbitControlsRef}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 조명 */}
|
{/* 조명 - 전체적으로 밝게 조정 */}
|
||||||
<ambientLight intensity={0.5} />
|
<ambientLight intensity={0.9} />
|
||||||
<directionalLight position={[10, 10, 5]} intensity={1} />
|
<directionalLight position={[10, 20, 10]} intensity={1.2} />
|
||||||
<directionalLight position={[-10, -10, -5]} intensity={0.3} />
|
<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"]} />
|
<color attach="background" args={["#f3f4f6"]} />
|
||||||
|
|
|
||||||
|
|
@ -164,3 +164,4 @@ export function getAllDescendants(
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -120,13 +120,14 @@ class BatchManagementAPIClass {
|
||||||
apiUrl: string,
|
apiUrl: string,
|
||||||
apiKey: string,
|
apiKey: string,
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
method: 'GET' = 'GET',
|
method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET',
|
||||||
paramInfo?: {
|
paramInfo?: {
|
||||||
paramType: 'url' | 'query';
|
paramType: 'url' | 'query';
|
||||||
paramName: string;
|
paramName: string;
|
||||||
paramValue: string;
|
paramValue: string;
|
||||||
paramSource: 'static' | 'dynamic';
|
paramSource: 'static' | 'dynamic';
|
||||||
}
|
},
|
||||||
|
requestBody?: string
|
||||||
): Promise<{
|
): Promise<{
|
||||||
fields: string[];
|
fields: string[];
|
||||||
samples: any[];
|
samples: any[];
|
||||||
|
|
@ -137,7 +138,8 @@ class BatchManagementAPIClass {
|
||||||
apiUrl,
|
apiUrl,
|
||||||
apiKey,
|
apiKey,
|
||||||
endpoint,
|
endpoint,
|
||||||
method
|
method,
|
||||||
|
requestBody
|
||||||
};
|
};
|
||||||
|
|
||||||
// 파라미터 정보가 있으면 추가
|
// 파라미터 정보가 있으면 추가
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { apiClient } from "./client";
|
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 {
|
export interface ExternalRestApiConnection {
|
||||||
id?: number;
|
id?: number;
|
||||||
|
|
@ -11,18 +11,34 @@ export interface ExternalRestApiConnection {
|
||||||
base_url: string;
|
base_url: string;
|
||||||
endpoint_path?: string;
|
endpoint_path?: string;
|
||||||
default_headers: Record<string, string>;
|
default_headers: Record<string, string>;
|
||||||
|
// 기본 메서드 및 바디 추가
|
||||||
|
default_method?: string;
|
||||||
|
default_body?: string;
|
||||||
|
|
||||||
auth_type: AuthType;
|
auth_type: AuthType;
|
||||||
auth_config?: {
|
auth_config?: {
|
||||||
|
// API Key
|
||||||
keyLocation?: "header" | "query";
|
keyLocation?: "header" | "query";
|
||||||
keyName?: string;
|
keyName?: string;
|
||||||
keyValue?: string;
|
keyValue?: string;
|
||||||
|
// Bearer Token
|
||||||
token?: string;
|
token?: string;
|
||||||
|
// Basic Auth
|
||||||
username?: string;
|
username?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
|
// OAuth2
|
||||||
clientId?: string;
|
clientId?: string;
|
||||||
clientSecret?: string;
|
clientSecret?: string;
|
||||||
tokenUrl?: string;
|
tokenUrl?: string;
|
||||||
accessToken?: string;
|
accessToken?: string;
|
||||||
|
|
||||||
|
// DB 기반 토큰 모드
|
||||||
|
dbTableName?: string;
|
||||||
|
dbValueColumn?: string;
|
||||||
|
dbWhereColumn?: string;
|
||||||
|
dbWhereValue?: string;
|
||||||
|
dbHeaderName?: string;
|
||||||
|
dbHeaderTemplate?: string;
|
||||||
};
|
};
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
retry_count?: number;
|
retry_count?: number;
|
||||||
|
|
@ -49,9 +65,11 @@ export interface RestApiTestRequest {
|
||||||
id?: number;
|
id?: number;
|
||||||
base_url: string;
|
base_url: string;
|
||||||
endpoint?: string;
|
endpoint?: string;
|
||||||
method?: "GET" | "POST" | "PUT" | "DELETE";
|
method?: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
|
body?: unknown; // 테스트 요청 바디 추가
|
||||||
auth_type?: AuthType;
|
auth_type?: AuthType;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
auth_config?: any;
|
auth_config?: any;
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
}
|
}
|
||||||
|
|
@ -61,7 +79,7 @@ export interface RestApiTestResult {
|
||||||
message: string;
|
message: string;
|
||||||
response_time?: number;
|
response_time?: number;
|
||||||
status_code?: number;
|
status_code?: number;
|
||||||
response_data?: any;
|
response_data?: unknown;
|
||||||
error_details?: string;
|
error_details?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -71,7 +89,7 @@ export interface ApiResponse<T> {
|
||||||
message?: string;
|
message?: string;
|
||||||
error?: {
|
error?: {
|
||||||
code: string;
|
code: string;
|
||||||
details?: any;
|
details?: unknown;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -184,6 +202,7 @@ export class ExternalRestApiConnectionAPI {
|
||||||
{ value: "bearer", label: "Bearer Token" },
|
{ value: "bearer", label: "Bearer Token" },
|
||||||
{ value: "basic", label: "Basic Auth" },
|
{ value: "basic", label: "Basic Auth" },
|
||||||
{ value: "oauth2", label: "OAuth 2.0" },
|
{ value: "oauth2", label: "OAuth 2.0" },
|
||||||
|
{ value: "db-token", label: "DB 토큰" },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue