Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management
This commit is contained in:
@@ -169,22 +169,18 @@ export class BatchController {
|
||||
static async getBatchConfigById(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
const batchConfig = await BatchService.getBatchConfigById(
|
||||
Number(id),
|
||||
userCompanyCode
|
||||
);
|
||||
const result = await BatchService.getBatchConfigById(Number(id));
|
||||
|
||||
if (!batchConfig) {
|
||||
if (!result.success || !result.data) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "배치 설정을 찾을 수 없습니다.",
|
||||
message: result.message || "배치 설정을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: batchConfig,
|
||||
data: result.data,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("배치 설정 조회 오류:", error);
|
||||
|
||||
@@ -62,6 +62,11 @@ export class BatchExecutionLogController {
|
||||
try {
|
||||
const data: CreateBatchExecutionLogRequest = req.body;
|
||||
|
||||
// 멀티테넌시: company_code가 없으면 현재 사용자 회사 코드로 설정
|
||||
if (!data.company_code) {
|
||||
data.company_code = req.user?.companyCode || "*";
|
||||
}
|
||||
|
||||
const result = await BatchExecutionLogService.createExecutionLog(data);
|
||||
|
||||
if (result.success) {
|
||||
|
||||
@@ -265,8 +265,12 @@ export class BatchManagementController {
|
||||
|
||||
try {
|
||||
// 실행 로그 생성
|
||||
executionLog = await BatchService.createExecutionLog({
|
||||
const { BatchExecutionLogService } = await import(
|
||||
"../services/batchExecutionLogService"
|
||||
);
|
||||
const logResult = await BatchExecutionLogService.createExecutionLog({
|
||||
batch_config_id: Number(id),
|
||||
company_code: batchConfig.company_code,
|
||||
execution_status: "RUNNING",
|
||||
start_time: startTime,
|
||||
total_records: 0,
|
||||
@@ -274,6 +278,14 @@ export class BatchManagementController {
|
||||
failed_records: 0,
|
||||
});
|
||||
|
||||
if (!logResult.success || !logResult.data) {
|
||||
throw new Error(
|
||||
logResult.message || "배치 실행 로그를 생성할 수 없습니다."
|
||||
);
|
||||
}
|
||||
|
||||
executionLog = logResult.data;
|
||||
|
||||
// BatchSchedulerService의 executeBatchConfig 메서드 사용 (중복 로직 제거)
|
||||
const { BatchSchedulerService } = await import(
|
||||
"../services/batchSchedulerService"
|
||||
@@ -290,7 +302,7 @@ export class BatchManagementController {
|
||||
const duration = endTime.getTime() - startTime.getTime();
|
||||
|
||||
// 실행 로그 업데이트 (성공)
|
||||
await BatchService.updateExecutionLog(executionLog.id, {
|
||||
await BatchExecutionLogService.updateExecutionLog(executionLog.id, {
|
||||
execution_status: "SUCCESS",
|
||||
end_time: endTime,
|
||||
duration_ms: duration,
|
||||
@@ -406,22 +418,34 @@ export class BatchManagementController {
|
||||
paramName,
|
||||
paramValue,
|
||||
paramSource,
|
||||
requestBody,
|
||||
} = req.body;
|
||||
|
||||
if (!apiUrl || !apiKey || !endpoint) {
|
||||
// apiUrl, endpoint는 항상 필수
|
||||
if (!apiUrl || !endpoint) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "API URL, API Key, 엔드포인트는 필수입니다.",
|
||||
message: "API URL과 엔드포인트는 필수입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// GET 요청일 때만 API Key 필수 (POST/PUT/DELETE는 선택)
|
||||
if ((!method || method === "GET") && !apiKey) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "GET 메서드에서는 API Key가 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
console.log("🔍 REST API 미리보기 요청:", {
|
||||
apiUrl,
|
||||
endpoint,
|
||||
method,
|
||||
paramType,
|
||||
paramName,
|
||||
paramValue,
|
||||
paramSource,
|
||||
requestBody: requestBody ? "Included" : "None",
|
||||
});
|
||||
|
||||
// RestApiConnector 사용하여 데이터 조회
|
||||
@@ -429,7 +453,7 @@ export class BatchManagementController {
|
||||
|
||||
const connector = new RestApiConnector({
|
||||
baseUrl: apiUrl,
|
||||
apiKey: apiKey,
|
||||
apiKey: apiKey || "",
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
@@ -456,9 +480,28 @@ export class BatchManagementController {
|
||||
|
||||
console.log("🔗 최종 엔드포인트:", finalEndpoint);
|
||||
|
||||
// 데이터 조회 (최대 5개만) - GET 메서드만 지원
|
||||
const result = await connector.executeQuery(finalEndpoint, method);
|
||||
console.log(`[previewRestApiData] executeQuery 결과:`, {
|
||||
// Request Body 파싱
|
||||
let parsedBody = undefined;
|
||||
if (requestBody && typeof requestBody === "string") {
|
||||
try {
|
||||
parsedBody = JSON.parse(requestBody);
|
||||
} catch (e) {
|
||||
console.warn("Request Body JSON 파싱 실패:", e);
|
||||
// 파싱 실패 시 원본 문자열 사용하거나 무시 (상황에 따라 결정, 여기선 undefined로 처리하거나 에러 반환 가능)
|
||||
// 여기서는 경고 로그 남기고 진행
|
||||
}
|
||||
} else if (requestBody) {
|
||||
parsedBody = requestBody;
|
||||
}
|
||||
|
||||
// 데이터 조회 - executeRequest 사용 (POST/PUT/DELETE 지원)
|
||||
const result = await connector.executeRequest(
|
||||
finalEndpoint,
|
||||
method as "GET" | "POST" | "PUT" | "DELETE",
|
||||
parsedBody
|
||||
);
|
||||
|
||||
console.log(`[previewRestApiData] executeRequest 결과:`, {
|
||||
rowCount: result.rowCount,
|
||||
rowsLength: result.rows ? result.rows.length : "undefined",
|
||||
firstRow:
|
||||
@@ -532,15 +575,21 @@ export class BatchManagementController {
|
||||
apiMappings,
|
||||
});
|
||||
|
||||
// 🔐 멀티테넌시: 현재 사용자 회사 코드 사용 (프론트에서 받지 않음)
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const userId = req.user?.userId;
|
||||
|
||||
// BatchService를 사용하여 배치 설정 저장
|
||||
const batchConfig: CreateBatchConfigRequest = {
|
||||
batchName: batchName,
|
||||
description: description || "",
|
||||
cronSchedule: cronSchedule,
|
||||
isActive: "Y",
|
||||
companyCode,
|
||||
mappings: apiMappings,
|
||||
};
|
||||
|
||||
const result = await BatchService.createBatchConfig(batchConfig);
|
||||
const result = await BatchService.createBatchConfig(batchConfig, userId);
|
||||
|
||||
if (result.success && result.data) {
|
||||
// 스케줄러에 자동 등록 ✅
|
||||
|
||||
@@ -161,3 +161,4 @@ export const createMappingTemplate = async (
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import axios, { AxiosInstance, AxiosResponse } from "axios";
|
||||
import https from "https";
|
||||
import {
|
||||
DatabaseConnector,
|
||||
ConnectionConfig,
|
||||
@@ -24,16 +25,26 @@ export class RestApiConnector implements DatabaseConnector {
|
||||
|
||||
constructor(config: RestApiConfig) {
|
||||
this.config = config;
|
||||
|
||||
// Axios 인스턴스 생성
|
||||
// 🔐 apiKey가 없을 수도 있으므로 Authorization 헤더는 선택적으로만 추가
|
||||
const defaultHeaders: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
};
|
||||
|
||||
if (config.apiKey) {
|
||||
defaultHeaders["Authorization"] = `Bearer ${config.apiKey}`;
|
||||
}
|
||||
|
||||
this.httpClient = axios.create({
|
||||
baseURL: config.baseUrl,
|
||||
timeout: config.timeout || 30000,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${config.apiKey}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
headers: defaultHeaders,
|
||||
// ⚠️ 외부 API 중 자체 서명 인증서를 사용하는 경우가 있어서
|
||||
// 인증서 검증을 끈 HTTPS 에이전트를 사용한다.
|
||||
// 내부망/신뢰된 시스템 전용으로 사용해야 하며,
|
||||
// 공개 인터넷용 API에는 적용하면 안 된다.
|
||||
httpsAgent: new https.Agent({ rejectUnauthorized: false }),
|
||||
});
|
||||
|
||||
// 요청/응답 인터셉터 설정
|
||||
@@ -75,26 +86,16 @@ export class RestApiConnector implements DatabaseConnector {
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
try {
|
||||
// 연결 테스트 - 기본 엔드포인트 호출
|
||||
await this.httpClient.get("/health", { timeout: 5000 });
|
||||
console.log(`[RestApiConnector] 연결 성공: ${this.config.baseUrl}`);
|
||||
} catch (error) {
|
||||
// health 엔드포인트가 없을 수 있으므로 404는 정상으로 처리
|
||||
if (axios.isAxiosError(error) && error.response?.status === 404) {
|
||||
console.log(
|
||||
`[RestApiConnector] 연결 성공 (health 엔드포인트 없음): ${this.config.baseUrl}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
console.error(
|
||||
`[RestApiConnector] 연결 실패: ${this.config.baseUrl}`,
|
||||
error
|
||||
);
|
||||
throw new Error(
|
||||
`REST API 연결 실패: ${error instanceof Error ? error.message : "알 수 없는 오류"}`
|
||||
);
|
||||
}
|
||||
// 기존에는 /health 엔드포인트를 호출해서 미리 연결을 검사했지만,
|
||||
// 일반 외부 API들은 /health가 없거나 401/500을 반환하는 경우가 많아
|
||||
// 불필요하게 예외가 나면서 미리보기/배치 실행이 막히는 문제가 있었다.
|
||||
//
|
||||
// 따라서 여기서는 "연결 준비 완료" 정도만 로그로 남기고
|
||||
// 실제 호출 실패 여부는 executeRequest 단계에서만 판단하도록 한다.
|
||||
console.log(
|
||||
`[RestApiConnector] 연결 준비 완료 (사전 헬스체크 생략): ${this.config.baseUrl}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
|
||||
@@ -213,7 +213,10 @@ router.post(
|
||||
}
|
||||
|
||||
const result =
|
||||
await ExternalRestApiConnectionService.testConnection(testRequest);
|
||||
await ExternalRestApiConnectionService.testConnection(
|
||||
testRequest,
|
||||
req.user?.companyCode
|
||||
);
|
||||
|
||||
return res.status(200).json(result);
|
||||
} catch (error) {
|
||||
@@ -264,4 +267,46 @@ router.post(
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/external-rest-api-connections/:id/fetch
|
||||
* REST API 데이터 조회 (화면관리용 프록시)
|
||||
*/
|
||||
router.post(
|
||||
"/:id/fetch",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
|
||||
if (isNaN(id)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "유효하지 않은 ID입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const { endpoint, jsonPath } = req.body;
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
|
||||
logger.info(`REST API 데이터 조회 요청: 연결 ID=${id}, endpoint=${endpoint}, jsonPath=${jsonPath}`);
|
||||
|
||||
const result = await ExternalRestApiConnectionService.fetchData(
|
||||
id,
|
||||
endpoint,
|
||||
jsonPath,
|
||||
userCompanyCode
|
||||
);
|
||||
|
||||
return res.status(result.success ? 200 : 400).json(result);
|
||||
} catch (error) {
|
||||
logger.error("REST API 데이터 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "서버 내부 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -170,3 +170,4 @@ export class DigitalTwinTemplateService {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -130,13 +130,14 @@ export class BatchExecutionLogService {
|
||||
try {
|
||||
const log = await queryOne<BatchExecutionLog>(
|
||||
`INSERT INTO batch_execution_logs (
|
||||
batch_config_id, execution_status, start_time, end_time,
|
||||
batch_config_id, company_code, execution_status, start_time, end_time,
|
||||
duration_ms, total_records, success_records, failed_records,
|
||||
error_message, error_details, server_name, process_id
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
RETURNING *`,
|
||||
[
|
||||
data.batch_config_id,
|
||||
data.company_code,
|
||||
data.execution_status,
|
||||
data.start_time || new Date(),
|
||||
data.end_time,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,258 +1,114 @@
|
||||
// 배치 스케줄러 서비스
|
||||
// 작성일: 2024-12-24
|
||||
|
||||
import * as cron from "node-cron";
|
||||
import { query, queryOne } from "../database/db";
|
||||
import cron from "node-cron";
|
||||
import { BatchService } from "./batchService";
|
||||
import { BatchExecutionLogService } from "./batchExecutionLogService";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
export class BatchSchedulerService {
|
||||
private static scheduledTasks: Map<number, cron.ScheduledTask> = new Map();
|
||||
private static isInitialized = false;
|
||||
private static executingBatches: Set<number> = new Set(); // 실행 중인 배치 추적
|
||||
|
||||
/**
|
||||
* 스케줄러 초기화
|
||||
* 모든 활성 배치의 스케줄링 초기화
|
||||
*/
|
||||
static async initialize() {
|
||||
static async initializeScheduler() {
|
||||
try {
|
||||
logger.info("배치 스케줄러 초기화 시작...");
|
||||
logger.info("배치 스케줄러 초기화 시작");
|
||||
|
||||
// 기존 모든 스케줄 정리 (중복 방지)
|
||||
this.clearAllSchedules();
|
||||
const batchConfigsResponse = await BatchService.getBatchConfigs({
|
||||
is_active: "Y",
|
||||
});
|
||||
|
||||
// 활성화된 배치 설정들을 로드하여 스케줄 등록
|
||||
await this.loadActiveBatchConfigs();
|
||||
|
||||
this.isInitialized = true;
|
||||
logger.info("배치 스케줄러 초기화 완료");
|
||||
} catch (error) {
|
||||
logger.error("배치 스케줄러 초기화 실패:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 스케줄 정리
|
||||
*/
|
||||
private static clearAllSchedules() {
|
||||
logger.info(`기존 스케줄 ${this.scheduledTasks.size}개 정리 중...`);
|
||||
|
||||
for (const [id, task] of this.scheduledTasks) {
|
||||
try {
|
||||
task.stop();
|
||||
task.destroy();
|
||||
logger.info(`스케줄 정리 완료: ID ${id}`);
|
||||
} catch (error) {
|
||||
logger.error(`스케줄 정리 실패: ID ${id}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
this.scheduledTasks.clear();
|
||||
this.isInitialized = false;
|
||||
logger.info("모든 스케줄 정리 완료");
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성화된 배치 설정들을 로드하여 스케줄 등록
|
||||
*/
|
||||
private static async loadActiveBatchConfigs() {
|
||||
try {
|
||||
const activeConfigs = await query<any>(
|
||||
`SELECT
|
||||
bc.*,
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'id', bm.id,
|
||||
'batch_config_id', bm.batch_config_id,
|
||||
'from_connection_type', bm.from_connection_type,
|
||||
'from_connection_id', bm.from_connection_id,
|
||||
'from_table_name', bm.from_table_name,
|
||||
'from_column_name', bm.from_column_name,
|
||||
'from_column_type', bm.from_column_type,
|
||||
'to_connection_type', bm.to_connection_type,
|
||||
'to_connection_id', bm.to_connection_id,
|
||||
'to_table_name', bm.to_table_name,
|
||||
'to_column_name', bm.to_column_name,
|
||||
'to_column_type', bm.to_column_type,
|
||||
'mapping_order', bm.mapping_order,
|
||||
'from_api_url', bm.from_api_url,
|
||||
'from_api_key', bm.from_api_key,
|
||||
'from_api_method', bm.from_api_method,
|
||||
'from_api_param_type', bm.from_api_param_type,
|
||||
'from_api_param_name', bm.from_api_param_name,
|
||||
'from_api_param_value', bm.from_api_param_value,
|
||||
'from_api_param_source', bm.from_api_param_source,
|
||||
'to_api_url', bm.to_api_url,
|
||||
'to_api_key', bm.to_api_key,
|
||||
'to_api_method', bm.to_api_method,
|
||||
'to_api_body', bm.to_api_body
|
||||
)
|
||||
) FILTER (WHERE bm.id IS NOT NULL) as batch_mappings
|
||||
FROM batch_configs bc
|
||||
LEFT JOIN batch_mappings bm ON bc.id = bm.batch_config_id
|
||||
WHERE bc.is_active = 'Y'
|
||||
GROUP BY bc.id`,
|
||||
[]
|
||||
);
|
||||
|
||||
logger.info(`활성화된 배치 설정 ${activeConfigs.length}개 발견`);
|
||||
|
||||
for (const config of activeConfigs) {
|
||||
await this.scheduleBatchConfig(config);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("활성화된 배치 설정 로드 실패:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 설정을 스케줄에 등록
|
||||
*/
|
||||
static async scheduleBatchConfig(config: any) {
|
||||
try {
|
||||
const { id, batch_name, cron_schedule } = config;
|
||||
|
||||
// 기존 스케줄이 있다면 제거
|
||||
if (this.scheduledTasks.has(id)) {
|
||||
this.scheduledTasks.get(id)?.stop();
|
||||
this.scheduledTasks.delete(id);
|
||||
}
|
||||
|
||||
// cron 스케줄 유효성 검사
|
||||
if (!cron.validate(cron_schedule)) {
|
||||
logger.error(`잘못된 cron 스케줄: ${cron_schedule} (배치 ID: ${id})`);
|
||||
if (!batchConfigsResponse.success || !batchConfigsResponse.data) {
|
||||
logger.warn("스케줄링할 활성 배치 설정이 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 새로운 스케줄 등록
|
||||
const task = cron.schedule(cron_schedule, async () => {
|
||||
// 중복 실행 방지 체크
|
||||
if (this.executingBatches.has(id)) {
|
||||
logger.warn(
|
||||
`⚠️ 배치가 이미 실행 중입니다. 건너뜀: ${batch_name} (ID: ${id})`
|
||||
);
|
||||
return;
|
||||
}
|
||||
const batchConfigs = batchConfigsResponse.data;
|
||||
logger.info(`${batchConfigs.length}개의 배치 설정 스케줄링 등록`);
|
||||
|
||||
logger.info(`🔄 스케줄 배치 실행 시작: ${batch_name} (ID: ${id})`);
|
||||
for (const config of batchConfigs) {
|
||||
await this.scheduleBatch(config);
|
||||
}
|
||||
|
||||
// 실행 중 플래그 설정
|
||||
this.executingBatches.add(id);
|
||||
logger.info("배치 스케줄러 초기화 완료");
|
||||
} catch (error) {
|
||||
logger.error("배치 스케줄러 초기화 중 오류 발생:", error);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await this.executeBatchConfig(config);
|
||||
} finally {
|
||||
// 실행 완료 후 플래그 제거
|
||||
this.executingBatches.delete(id);
|
||||
}
|
||||
/**
|
||||
* 개별 배치 작업 스케줄링
|
||||
*/
|
||||
static async scheduleBatch(config: any) {
|
||||
try {
|
||||
// 기존 스케줄이 있으면 제거
|
||||
if (this.scheduledTasks.has(config.id)) {
|
||||
this.scheduledTasks.get(config.id)?.stop();
|
||||
this.scheduledTasks.delete(config.id);
|
||||
}
|
||||
|
||||
if (config.is_active !== "Y") {
|
||||
logger.info(
|
||||
`배치 스케줄링 건너뜀 (비활성 상태): ${config.batch_name} (ID: ${config.id})`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!cron.validate(config.cron_schedule)) {
|
||||
logger.error(
|
||||
`유효하지 않은 Cron 표현식: ${config.cron_schedule} (Batch ID: ${config.id})`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`배치 스케줄 등록: ${config.batch_name} (ID: ${config.id}, Cron: ${config.cron_schedule})`
|
||||
);
|
||||
|
||||
const task = cron.schedule(config.cron_schedule, async () => {
|
||||
logger.info(
|
||||
`스케줄에 의한 배치 실행 시작: ${config.batch_name} (ID: ${config.id})`
|
||||
);
|
||||
await this.executeBatchConfig(config);
|
||||
});
|
||||
|
||||
// 스케줄 시작 (기본적으로 시작되지만 명시적으로 호출)
|
||||
task.start();
|
||||
|
||||
this.scheduledTasks.set(id, task);
|
||||
logger.info(
|
||||
`배치 스케줄 등록 완료: ${batch_name} (ID: ${id}, Schedule: ${cron_schedule}) - 스케줄 시작됨`
|
||||
);
|
||||
this.scheduledTasks.set(config.id, task);
|
||||
} catch (error) {
|
||||
logger.error(`배치 스케줄 등록 실패 (ID: ${config.id}):`, error);
|
||||
logger.error(`배치 스케줄링 중 오류 발생 (ID: ${config.id}):`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 설정 스케줄 제거
|
||||
*/
|
||||
static async unscheduleBatchConfig(batchConfigId: number) {
|
||||
try {
|
||||
if (this.scheduledTasks.has(batchConfigId)) {
|
||||
this.scheduledTasks.get(batchConfigId)?.stop();
|
||||
this.scheduledTasks.delete(batchConfigId);
|
||||
logger.info(`배치 스케줄 제거 완료 (ID: ${batchConfigId})`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`배치 스케줄 제거 실패 (ID: ${batchConfigId}):`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 설정 업데이트 시 스케줄 재등록
|
||||
* 배치 스케줄 업데이트 (설정 변경 시 호출)
|
||||
*/
|
||||
static async updateBatchSchedule(
|
||||
configId: number,
|
||||
executeImmediately: boolean = true
|
||||
) {
|
||||
try {
|
||||
// 기존 스케줄 제거
|
||||
await this.unscheduleBatchConfig(configId);
|
||||
|
||||
// 업데이트된 배치 설정 조회
|
||||
const configResult = await query<any>(
|
||||
`SELECT
|
||||
bc.*,
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'id', bm.id,
|
||||
'batch_config_id', bm.batch_config_id,
|
||||
'from_connection_type', bm.from_connection_type,
|
||||
'from_connection_id', bm.from_connection_id,
|
||||
'from_table_name', bm.from_table_name,
|
||||
'from_column_name', bm.from_column_name,
|
||||
'from_column_type', bm.from_column_type,
|
||||
'to_connection_type', bm.to_connection_type,
|
||||
'to_connection_id', bm.to_connection_id,
|
||||
'to_table_name', bm.to_table_name,
|
||||
'to_column_name', bm.to_column_name,
|
||||
'to_column_type', bm.to_column_type,
|
||||
'mapping_order', bm.mapping_order,
|
||||
'from_api_url', bm.from_api_url,
|
||||
'from_api_key', bm.from_api_key,
|
||||
'from_api_method', bm.from_api_method,
|
||||
'from_api_param_type', bm.from_api_param_type,
|
||||
'from_api_param_name', bm.from_api_param_name,
|
||||
'from_api_param_value', bm.from_api_param_value,
|
||||
'from_api_param_source', bm.from_api_param_source,
|
||||
'to_api_url', bm.to_api_url,
|
||||
'to_api_key', bm.to_api_key,
|
||||
'to_api_method', bm.to_api_method,
|
||||
'to_api_body', bm.to_api_body
|
||||
)
|
||||
) FILTER (WHERE bm.id IS NOT NULL) as batch_mappings
|
||||
FROM batch_configs bc
|
||||
LEFT JOIN batch_mappings bm ON bc.id = bm.batch_config_id
|
||||
WHERE bc.id = $1
|
||||
GROUP BY bc.id`,
|
||||
[configId]
|
||||
);
|
||||
|
||||
const config = configResult[0] || null;
|
||||
|
||||
if (!config) {
|
||||
logger.warn(`배치 설정을 찾을 수 없습니다: ID ${configId}`);
|
||||
const result = await BatchService.getBatchConfigById(configId);
|
||||
if (!result.success || !result.data) {
|
||||
// 설정이 없으면 스케줄 제거
|
||||
if (this.scheduledTasks.has(configId)) {
|
||||
this.scheduledTasks.get(configId)?.stop();
|
||||
this.scheduledTasks.delete(configId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 활성화된 배치만 다시 스케줄 등록
|
||||
if (config.is_active === "Y") {
|
||||
await this.scheduleBatchConfig(config);
|
||||
logger.info(
|
||||
`배치 스케줄 업데이트 완료: ${config.batch_name} (ID: ${configId})`
|
||||
);
|
||||
const config = result.data;
|
||||
|
||||
// 활성화 시 즉시 실행 (옵션)
|
||||
if (executeImmediately) {
|
||||
logger.info(
|
||||
`🚀 배치 활성화 즉시 실행: ${config.batch_name} (ID: ${configId})`
|
||||
);
|
||||
await this.executeBatchConfig(config);
|
||||
}
|
||||
} else {
|
||||
logger.info(
|
||||
`비활성화된 배치 스케줄 제거: ${config.batch_name} (ID: ${configId})`
|
||||
// 스케줄 재등록
|
||||
await this.scheduleBatch(config);
|
||||
|
||||
// 즉시 실행 옵션이 있으면 실행
|
||||
/*
|
||||
if (executeImmediately && config.is_active === "Y") {
|
||||
logger.info(`배치 설정 변경 후 즉시 실행: ${config.batch_name}`);
|
||||
this.executeBatchConfig(config).catch((err) =>
|
||||
logger.error(`즉시 실행 중 오류 발생:`, err)
|
||||
);
|
||||
}
|
||||
*/
|
||||
} catch (error) {
|
||||
logger.error(`배치 스케줄 업데이트 실패: ID ${configId}`, error);
|
||||
}
|
||||
@@ -272,6 +128,7 @@ export class BatchSchedulerService {
|
||||
const executionLogResponse =
|
||||
await BatchExecutionLogService.createExecutionLog({
|
||||
batch_config_id: config.id,
|
||||
company_code: config.company_code,
|
||||
execution_status: "RUNNING",
|
||||
start_time: startTime,
|
||||
total_records: 0,
|
||||
@@ -313,21 +170,20 @@ export class BatchSchedulerService {
|
||||
// 성공 결과 반환
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error(`배치 실행 실패: ${config.batch_name}`, error);
|
||||
logger.error(`배치 실행 중 오류 발생: ${config.batch_name}`, error);
|
||||
|
||||
// 실행 로그 업데이트 (실패)
|
||||
if (executionLog) {
|
||||
await BatchExecutionLogService.updateExecutionLog(executionLog.id, {
|
||||
execution_status: "FAILED",
|
||||
execution_status: "FAILURE",
|
||||
end_time: new Date(),
|
||||
duration_ms: Date.now() - startTime.getTime(),
|
||||
error_message:
|
||||
error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
error_details: error instanceof Error ? error.stack : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
// 실패 시에도 결과 반환
|
||||
// 실패 결과 반환
|
||||
return {
|
||||
totalRecords: 0,
|
||||
successRecords: 0,
|
||||
@@ -379,6 +235,8 @@ export class BatchSchedulerService {
|
||||
const { BatchExternalDbService } = await import(
|
||||
"./batchExternalDbService"
|
||||
);
|
||||
|
||||
// 👇 Body 파라미터 추가 (POST 요청 시)
|
||||
const apiResult = await BatchExternalDbService.getDataFromRestApi(
|
||||
firstMapping.from_api_url!,
|
||||
firstMapping.from_api_key!,
|
||||
@@ -394,7 +252,9 @@ export class BatchSchedulerService {
|
||||
firstMapping.from_api_param_type,
|
||||
firstMapping.from_api_param_name,
|
||||
firstMapping.from_api_param_value,
|
||||
firstMapping.from_api_param_source
|
||||
firstMapping.from_api_param_source,
|
||||
// 👇 Body 전달 (FROM - REST API - POST 요청)
|
||||
firstMapping.from_api_body
|
||||
);
|
||||
|
||||
if (apiResult.success && apiResult.data) {
|
||||
@@ -416,6 +276,17 @@ export class BatchSchedulerService {
|
||||
totalRecords += fromData.length;
|
||||
|
||||
// 컬럼 매핑 적용하여 TO 테이블 형식으로 변환
|
||||
// 유틸리티 함수: 점 표기법을 사용하여 중첩된 객체 값 가져오기
|
||||
const getValueByPath = (obj: any, path: string) => {
|
||||
if (!path) return undefined;
|
||||
// path가 'response.access_token' 처럼 점을 포함하는 경우
|
||||
if (path.includes(".")) {
|
||||
return path.split(".").reduce((acc, part) => acc && acc[part], obj);
|
||||
}
|
||||
// 단순 키인 경우
|
||||
return obj[path];
|
||||
};
|
||||
|
||||
const mappedData = fromData.map((row) => {
|
||||
const mappedRow: any = {};
|
||||
for (const mapping of mappings) {
|
||||
@@ -428,10 +299,25 @@ export class BatchSchedulerService {
|
||||
mappedRow[mapping.from_column_name] =
|
||||
row[mapping.from_column_name];
|
||||
} else {
|
||||
// 기존 로직: to_column_name을 키로 사용
|
||||
mappedRow[mapping.to_column_name] = row[mapping.from_column_name];
|
||||
// REST API -> DB (POST 요청 포함) 또는 DB -> DB
|
||||
// row[mapping.from_column_name] 대신 getValueByPath 사용
|
||||
const value = getValueByPath(row, mapping.from_column_name);
|
||||
|
||||
mappedRow[mapping.to_column_name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// 멀티테넌시: TO가 DB일 때 company_code 자동 주입
|
||||
// - 배치 설정에 company_code가 있고
|
||||
// - 매핑에서 company_code를 명시적으로 다루지 않은 경우만
|
||||
if (
|
||||
firstMapping.to_connection_type !== "restapi" &&
|
||||
config.company_code &&
|
||||
mappedRow.company_code === undefined
|
||||
) {
|
||||
mappedRow.company_code = config.company_code;
|
||||
}
|
||||
|
||||
return mappedRow;
|
||||
});
|
||||
|
||||
@@ -482,22 +368,12 @@ export class BatchSchedulerService {
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// 기존 REST API 전송 (REST API → DB 배치)
|
||||
const apiResult = await BatchExternalDbService.sendDataToRestApi(
|
||||
firstMapping.to_api_url!,
|
||||
firstMapping.to_api_key!,
|
||||
firstMapping.to_table_name,
|
||||
(firstMapping.to_api_method as "POST" | "PUT") || "POST",
|
||||
mappedData
|
||||
// 기존 REST API 전송 (REST API → DB 배치) - 사실 이 경우는 거의 없음 (REST to REST)
|
||||
// 지원하지 않음
|
||||
logger.warn(
|
||||
"REST API -> REST API (단순 매핑)은 아직 지원하지 않습니다."
|
||||
);
|
||||
|
||||
if (apiResult.success && apiResult.data) {
|
||||
insertResult = apiResult.data;
|
||||
} else {
|
||||
throw new Error(
|
||||
`REST API 데이터 전송 실패: ${apiResult.message}`
|
||||
);
|
||||
}
|
||||
insertResult = { successCount: 0, failedCount: 0 };
|
||||
}
|
||||
} else {
|
||||
// DB에 데이터 삽입
|
||||
@@ -511,167 +387,13 @@ export class BatchSchedulerService {
|
||||
|
||||
successRecords += insertResult.successCount;
|
||||
failedRecords += insertResult.failedCount;
|
||||
|
||||
logger.info(
|
||||
`테이블 처리 완료: ${insertResult.successCount}개 성공, ${insertResult.failedCount}개 실패`
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(`테이블 처리 실패: ${tableKey}`, error);
|
||||
failedRecords += 1;
|
||||
logger.error(`테이블 처리 중 오류 발생: ${tableKey}`, error);
|
||||
// 해당 테이블 처리 실패는 전체 실패로 간주하지 않고, 실패 카운트만 증가?
|
||||
// 여기서는 일단 실패 로그만 남기고 계속 진행 (필요시 정책 변경)
|
||||
}
|
||||
}
|
||||
|
||||
return { totalRecords, successRecords, failedRecords };
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 매핑 처리 (기존 메서드 - 사용 안 함)
|
||||
*/
|
||||
private static async processBatchMappings(config: any) {
|
||||
const { batch_mappings } = config;
|
||||
let totalRecords = 0;
|
||||
let successRecords = 0;
|
||||
let failedRecords = 0;
|
||||
|
||||
if (!batch_mappings || batch_mappings.length === 0) {
|
||||
logger.warn(`배치 매핑이 없습니다: ${config.batch_name}`);
|
||||
return { totalRecords, successRecords, failedRecords };
|
||||
}
|
||||
|
||||
for (const mapping of batch_mappings) {
|
||||
try {
|
||||
logger.info(
|
||||
`매핑 처리 시작: ${mapping.from_table_name} -> ${mapping.to_table_name}`
|
||||
);
|
||||
|
||||
// FROM 테이블에서 데이터 조회
|
||||
const fromData = await this.getDataFromSource(mapping);
|
||||
totalRecords += fromData.length;
|
||||
|
||||
// TO 테이블에 데이터 삽입
|
||||
const insertResult = await this.insertDataToTarget(mapping, fromData);
|
||||
successRecords += insertResult.successCount;
|
||||
failedRecords += insertResult.failedCount;
|
||||
|
||||
logger.info(
|
||||
`매핑 처리 완료: ${insertResult.successCount}개 성공, ${insertResult.failedCount}개 실패`
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`매핑 처리 실패: ${mapping.from_table_name} -> ${mapping.to_table_name}`,
|
||||
error
|
||||
);
|
||||
failedRecords += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { totalRecords, successRecords, failedRecords };
|
||||
}
|
||||
|
||||
/**
|
||||
* FROM 테이블에서 데이터 조회
|
||||
*/
|
||||
private static async getDataFromSource(mapping: any) {
|
||||
try {
|
||||
if (mapping.from_connection_type === "internal") {
|
||||
// 내부 DB에서 조회
|
||||
const result = await query<any>(
|
||||
`SELECT * FROM ${mapping.from_table_name}`,
|
||||
[]
|
||||
);
|
||||
return result;
|
||||
} else {
|
||||
// 외부 DB에서 조회 (구현 필요)
|
||||
logger.warn("외부 DB 조회는 아직 구현되지 않았습니다.");
|
||||
return [];
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`FROM 테이블 데이터 조회 실패: ${mapping.from_table_name}`,
|
||||
error
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TO 테이블에 데이터 삽입
|
||||
*/
|
||||
private static async insertDataToTarget(mapping: any, data: any[]) {
|
||||
let successCount = 0;
|
||||
let failedCount = 0;
|
||||
|
||||
try {
|
||||
if (mapping.to_connection_type === "internal") {
|
||||
// 내부 DB에 삽입
|
||||
for (const record of data) {
|
||||
try {
|
||||
// 매핑된 컬럼만 추출
|
||||
const mappedData = this.mapColumns(record, mapping);
|
||||
|
||||
const columns = Object.keys(mappedData);
|
||||
const values = Object.values(mappedData);
|
||||
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
|
||||
|
||||
await query(
|
||||
`INSERT INTO ${mapping.to_table_name} (${columns.join(", ")}) VALUES (${placeholders})`,
|
||||
values
|
||||
);
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
logger.error(`레코드 삽입 실패:`, error);
|
||||
failedCount++;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 외부 DB에 삽입 (구현 필요)
|
||||
logger.warn("외부 DB 삽입은 아직 구현되지 않았습니다.");
|
||||
failedCount = data.length;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`TO 테이블 데이터 삽입 실패: ${mapping.to_table_name}`,
|
||||
error
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return { successCount, failedCount };
|
||||
}
|
||||
|
||||
/**
|
||||
* 컬럼 매핑
|
||||
*/
|
||||
private static mapColumns(record: any, mapping: any) {
|
||||
const mappedData: any = {};
|
||||
|
||||
// 단순한 컬럼 매핑 (실제로는 더 복잡한 로직 필요)
|
||||
mappedData[mapping.to_column_name] = record[mapping.from_column_name];
|
||||
|
||||
return mappedData;
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 스케줄 중지
|
||||
*/
|
||||
static async stopAllSchedules() {
|
||||
try {
|
||||
for (const [id, task] of this.scheduledTasks) {
|
||||
task.stop();
|
||||
logger.info(`배치 스케줄 중지: ID ${id}`);
|
||||
}
|
||||
this.scheduledTasks.clear();
|
||||
this.isInitialized = false;
|
||||
logger.info("모든 배치 스케줄이 중지되었습니다.");
|
||||
} catch (error) {
|
||||
logger.error("배치 스케줄 중지 실패:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 등록된 스케줄 목록 조회
|
||||
*/
|
||||
static getScheduledTasks() {
|
||||
return Array.from(this.scheduledTasks.keys());
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,6 @@
|
||||
import { Pool, QueryResult } from "pg";
|
||||
import axios, { AxiosResponse } from "axios";
|
||||
import https from "https";
|
||||
import { getPool } from "../database/db";
|
||||
import logger from "../utils/logger";
|
||||
import {
|
||||
@@ -30,6 +32,10 @@ export class ExternalRestApiConnectionService {
|
||||
let query = `
|
||||
SELECT
|
||||
id, connection_name, description, base_url, endpoint_path, default_headers,
|
||||
default_method,
|
||||
-- DB 스키마의 컬럼명은 default_request_body 기준이고
|
||||
-- 코드에서는 default_body 필드로 사용하기 위해 alias 처리
|
||||
default_request_body AS default_body,
|
||||
auth_type, auth_config, timeout, retry_count, retry_delay,
|
||||
company_code, is_active, created_date, created_by,
|
||||
updated_date, updated_by, last_test_date, last_test_result, last_test_message
|
||||
@@ -129,6 +135,8 @@ export class ExternalRestApiConnectionService {
|
||||
let query = `
|
||||
SELECT
|
||||
id, connection_name, description, base_url, endpoint_path, default_headers,
|
||||
default_method,
|
||||
default_request_body AS default_body,
|
||||
auth_type, auth_config, timeout, retry_count, retry_delay,
|
||||
company_code, is_active, created_date, created_by,
|
||||
updated_date, updated_by, last_test_date, last_test_result, last_test_message
|
||||
@@ -158,6 +166,9 @@ export class ExternalRestApiConnectionService {
|
||||
? this.decryptSensitiveData(connection.auth_config)
|
||||
: null;
|
||||
|
||||
// 디버깅: 조회된 연결 정보 로깅
|
||||
logger.info(`REST API 연결 조회 결과 (ID: ${id}): connection_name=${connection.connection_name}, default_method=${connection.default_method}, endpoint_path=${connection.endpoint_path}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: connection,
|
||||
@@ -194,9 +205,10 @@ export class ExternalRestApiConnectionService {
|
||||
const query = `
|
||||
INSERT INTO external_rest_api_connections (
|
||||
connection_name, description, base_url, endpoint_path, default_headers,
|
||||
default_method, default_request_body,
|
||||
auth_type, auth_config, timeout, retry_count, retry_delay,
|
||||
company_code, is_active, created_by
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
@@ -206,6 +218,8 @@ export class ExternalRestApiConnectionService {
|
||||
data.base_url,
|
||||
data.endpoint_path || null,
|
||||
JSON.stringify(data.default_headers || {}),
|
||||
data.default_method || "GET",
|
||||
data.default_body || null,
|
||||
data.auth_type,
|
||||
encryptedAuthConfig ? JSON.stringify(encryptedAuthConfig) : null,
|
||||
data.timeout || 30000,
|
||||
@@ -216,6 +230,15 @@ export class ExternalRestApiConnectionService {
|
||||
data.created_by || "system",
|
||||
];
|
||||
|
||||
// 디버깅: 저장하려는 데이터 로깅
|
||||
logger.info(`REST API 연결 생성 요청 데이터:`, {
|
||||
connection_name: data.connection_name,
|
||||
default_method: data.default_method,
|
||||
endpoint_path: data.endpoint_path,
|
||||
base_url: data.base_url,
|
||||
default_body: data.default_body ? "있음" : "없음",
|
||||
});
|
||||
|
||||
const result: QueryResult<any> = await pool.query(query, params);
|
||||
|
||||
logger.info(`REST API 연결 생성 성공: ${data.connection_name}`);
|
||||
@@ -301,6 +324,20 @@ export class ExternalRestApiConnectionService {
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.default_method !== undefined) {
|
||||
updateFields.push(`default_method = $${paramIndex}`);
|
||||
params.push(data.default_method);
|
||||
paramIndex++;
|
||||
logger.info(`수정 요청 - default_method: ${data.default_method}`);
|
||||
}
|
||||
|
||||
if (data.default_body !== undefined) {
|
||||
updateFields.push(`default_request_body = $${paramIndex}`);
|
||||
params.push(data.default_body); // null이면 DB에서 NULL로 저장됨
|
||||
paramIndex++;
|
||||
logger.info(`수정 요청 - default_body: ${data.default_body ? "있음" : "삭제(null)"}`);
|
||||
}
|
||||
|
||||
if (data.auth_type !== undefined) {
|
||||
updateFields.push(`auth_type = $${paramIndex}`);
|
||||
params.push(data.auth_type);
|
||||
@@ -441,7 +478,8 @@ export class ExternalRestApiConnectionService {
|
||||
* REST API 연결 테스트 (테스트 요청 데이터 기반)
|
||||
*/
|
||||
static async testConnection(
|
||||
testRequest: RestApiTestRequest
|
||||
testRequest: RestApiTestRequest,
|
||||
userCompanyCode?: string
|
||||
): Promise<RestApiTestResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
@@ -450,7 +488,78 @@ export class ExternalRestApiConnectionService {
|
||||
const headers = { ...testRequest.headers };
|
||||
|
||||
// 인증 헤더 추가
|
||||
if (
|
||||
if (testRequest.auth_type === "db-token") {
|
||||
const cfg = testRequest.auth_config || {};
|
||||
const {
|
||||
dbTableName,
|
||||
dbValueColumn,
|
||||
dbWhereColumn,
|
||||
dbWhereValue,
|
||||
dbHeaderName,
|
||||
dbHeaderTemplate,
|
||||
} = cfg;
|
||||
|
||||
if (!dbTableName || !dbValueColumn) {
|
||||
throw new Error("DB 토큰 설정이 올바르지 않습니다.");
|
||||
}
|
||||
|
||||
if (!userCompanyCode) {
|
||||
throw new Error("DB 토큰 모드에서는 회사 코드가 필요합니다.");
|
||||
}
|
||||
|
||||
const hasWhereColumn = !!dbWhereColumn;
|
||||
const hasWhereValue =
|
||||
dbWhereValue !== undefined && dbWhereValue !== null && dbWhereValue !== "";
|
||||
|
||||
// where 컬럼/값은 둘 다 비우거나 둘 다 채워야 함
|
||||
if (hasWhereColumn !== hasWhereValue) {
|
||||
throw new Error(
|
||||
"DB 토큰 설정에서 조건 컬럼과 조건 값은 둘 다 비우거나 둘 다 입력해야 합니다."
|
||||
);
|
||||
}
|
||||
|
||||
// 식별자 검증 (간단한 화이트리스트)
|
||||
const identifierRegex = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
||||
if (
|
||||
!identifierRegex.test(dbTableName) ||
|
||||
!identifierRegex.test(dbValueColumn) ||
|
||||
(hasWhereColumn && !identifierRegex.test(dbWhereColumn as string))
|
||||
) {
|
||||
throw new Error(
|
||||
"DB 토큰 설정에 유효하지 않은 테이블 또는 컬럼명이 포함되어 있습니다."
|
||||
);
|
||||
}
|
||||
|
||||
let sql = `
|
||||
SELECT ${dbValueColumn} AS token_value
|
||||
FROM ${dbTableName}
|
||||
WHERE company_code = $1
|
||||
`;
|
||||
|
||||
const params: any[] = [userCompanyCode];
|
||||
|
||||
if (hasWhereColumn && hasWhereValue) {
|
||||
sql += ` AND ${dbWhereColumn} = $2`;
|
||||
params.push(dbWhereValue);
|
||||
}
|
||||
|
||||
sql += `
|
||||
ORDER BY updated_date DESC
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
const tokenResult: QueryResult<any> = await pool.query(sql, params);
|
||||
|
||||
if (tokenResult.rowCount === 0) {
|
||||
throw new Error("DB에서 토큰을 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
const tokenValue = tokenResult.rows[0]["token_value"];
|
||||
const headerName = dbHeaderName || "Authorization";
|
||||
const template = dbHeaderTemplate || "Bearer {{value}}";
|
||||
|
||||
headers[headerName] = template.replace("{{value}}", tokenValue);
|
||||
} else if (
|
||||
testRequest.auth_type === "bearer" &&
|
||||
testRequest.auth_config?.token
|
||||
) {
|
||||
@@ -493,25 +602,84 @@ export class ExternalRestApiConnectionService {
|
||||
`REST API 연결 테스트: ${testRequest.method || "GET"} ${url}`
|
||||
);
|
||||
|
||||
// HTTP 요청 실행
|
||||
const response = await fetch(url, {
|
||||
method: testRequest.method || "GET",
|
||||
headers,
|
||||
signal: AbortSignal.timeout(testRequest.timeout || 30000),
|
||||
});
|
||||
// Body 처리
|
||||
let body: any = undefined;
|
||||
if (testRequest.body) {
|
||||
// 이미 문자열이면 그대로, 객체면 JSON 문자열로 변환
|
||||
if (typeof testRequest.body === "string") {
|
||||
body = testRequest.body;
|
||||
} else {
|
||||
body = JSON.stringify(testRequest.body);
|
||||
}
|
||||
|
||||
const responseTime = Date.now() - startTime;
|
||||
let responseData = null;
|
||||
|
||||
try {
|
||||
responseData = await response.json();
|
||||
} catch {
|
||||
// JSON 파싱 실패는 무시 (텍스트 응답일 수 있음)
|
||||
// Content-Type 헤더가 없으면 기본적으로 application/json 추가
|
||||
const hasContentType = Object.keys(headers).some(
|
||||
(k) => k.toLowerCase() === "content-type"
|
||||
);
|
||||
if (!hasContentType) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
}
|
||||
|
||||
// HTTP 요청 실행
|
||||
// [인수인계 중요] 2024-11-27 추가
|
||||
// 특정 레거시/내부망 API(예: thiratis.com)의 경우 SSL 인증서 체인 문제로 인해
|
||||
// Node.js 레벨에서 검증 실패(UNABLE_TO_VERIFY_LEAF_SIGNATURE)가 발생합니다.
|
||||
//
|
||||
// 원래는 인프라(OS/Docker)에 루트 CA를 등록하는 것이 정석이나,
|
||||
// 유지보수 및 설정 편의성을 위해 코드 레벨에서 '특정 도메인'에 한해서만
|
||||
// SSL 검증을 우회하도록 예외 처리를 해두었습니다.
|
||||
//
|
||||
// ※ 보안 주의: 여기에 모르는 도메인을 함부로 추가하면 중간자 공격(MITM)에 취약해질 수 있습니다.
|
||||
// 꼭 필요한 신뢰할 수 있는 도메인만 추가하세요.
|
||||
const bypassDomains = ["thiratis.com"];
|
||||
const shouldBypassTls = bypassDomains.some((domain) =>
|
||||
url.includes(domain)
|
||||
);
|
||||
|
||||
const httpsAgent = new https.Agent({
|
||||
// bypassDomains에 포함된 URL이면 검증을 무시(false), 아니면 정상 검증(true)
|
||||
rejectUnauthorized: !shouldBypassTls,
|
||||
});
|
||||
|
||||
const requestConfig = {
|
||||
url,
|
||||
method: (testRequest.method || "GET") as any,
|
||||
headers,
|
||||
data: body,
|
||||
httpsAgent,
|
||||
timeout: testRequest.timeout || 30000,
|
||||
// 4xx/5xx 도 예외가 아니라 응답 객체로 처리
|
||||
validateStatus: () => true,
|
||||
};
|
||||
|
||||
// 요청 상세 로그 (민감 정보는 최소화)
|
||||
logger.info(
|
||||
`REST API 연결 테스트 요청 상세: ${JSON.stringify({
|
||||
method: requestConfig.method,
|
||||
url: requestConfig.url,
|
||||
headers: {
|
||||
...requestConfig.headers,
|
||||
// Authorization 헤더는 마스킹
|
||||
Authorization: requestConfig.headers?.Authorization
|
||||
? "***masked***"
|
||||
: undefined,
|
||||
},
|
||||
hasBody: !!body,
|
||||
})}`
|
||||
);
|
||||
|
||||
const response: AxiosResponse = await axios.request(requestConfig);
|
||||
|
||||
const responseTime = Date.now() - startTime;
|
||||
// axios는 response.data에 이미 파싱된 응답 본문을 담아준다.
|
||||
// JSON이 아니어도 그대로 내려보내서 프론트에서 확인할 수 있게 한다.
|
||||
const responseData = response.data ?? null;
|
||||
|
||||
return {
|
||||
success: response.ok,
|
||||
message: response.ok
|
||||
success: response.status >= 200 && response.status < 300,
|
||||
message:
|
||||
response.status >= 200 && response.status < 300
|
||||
? "연결 성공"
|
||||
: `연결 실패 (${response.status} ${response.statusText})`,
|
||||
response_time: responseTime,
|
||||
@@ -552,17 +720,27 @@ export class ExternalRestApiConnectionService {
|
||||
|
||||
const connection = connectionResult.data;
|
||||
|
||||
// 리스트에서 endpoint를 넘기지 않으면,
|
||||
// 저장된 endpoint_path를 기본 엔드포인트로 사용
|
||||
const effectiveEndpoint =
|
||||
endpoint || connection.endpoint_path || undefined;
|
||||
|
||||
const testRequest: RestApiTestRequest = {
|
||||
id: connection.id,
|
||||
base_url: connection.base_url,
|
||||
endpoint,
|
||||
endpoint: effectiveEndpoint,
|
||||
method: (connection.default_method as any) || "GET", // 기본 메서드 적용
|
||||
headers: connection.default_headers,
|
||||
body: connection.default_body, // 기본 바디 적용
|
||||
auth_type: connection.auth_type,
|
||||
auth_config: connection.auth_config,
|
||||
timeout: connection.timeout,
|
||||
};
|
||||
|
||||
const result = await this.testConnection(testRequest);
|
||||
const result = await this.testConnection(
|
||||
testRequest,
|
||||
connection.company_code
|
||||
);
|
||||
|
||||
// 테스트 결과 저장
|
||||
await pool.query(
|
||||
@@ -580,11 +758,34 @@ export class ExternalRestApiConnectionService {
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error("REST API 연결 테스트 (ID) 오류:", error);
|
||||
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "알 수 없는 오류";
|
||||
|
||||
// 예외가 발생한 경우에도 마지막 테스트 결과를 실패로 기록
|
||||
try {
|
||||
await pool.query(
|
||||
`
|
||||
UPDATE external_rest_api_connections
|
||||
SET
|
||||
last_test_date = NOW(),
|
||||
last_test_result = $1,
|
||||
last_test_message = $2
|
||||
WHERE id = $3
|
||||
`,
|
||||
["N", errorMessage, id]
|
||||
);
|
||||
} catch (updateError) {
|
||||
logger.error(
|
||||
"REST API 연결 테스트 (ID) 오류 기록 실패:",
|
||||
updateError
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: "연결 테스트에 실패했습니다.",
|
||||
error_details:
|
||||
error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
error_details: errorMessage,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -683,6 +884,166 @@ export class ExternalRestApiConnectionService {
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* REST API 데이터 조회 (화면관리용 프록시)
|
||||
* 저장된 연결 정보를 사용하여 외부 REST API를 호출하고 데이터를 반환
|
||||
*/
|
||||
static async fetchData(
|
||||
connectionId: number,
|
||||
endpoint?: string,
|
||||
jsonPath?: string,
|
||||
userCompanyCode?: string
|
||||
): Promise<ApiResponse<any>> {
|
||||
try {
|
||||
// 연결 정보 조회
|
||||
const connectionResult = await this.getConnectionById(connectionId, userCompanyCode);
|
||||
|
||||
if (!connectionResult.success || !connectionResult.data) {
|
||||
return {
|
||||
success: false,
|
||||
message: "REST API 연결을 찾을 수 없습니다.",
|
||||
error: {
|
||||
code: "CONNECTION_NOT_FOUND",
|
||||
details: `연결 ID ${connectionId}를 찾을 수 없습니다.`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const connection = connectionResult.data;
|
||||
|
||||
// 비활성화된 연결인지 확인
|
||||
if (connection.is_active !== "Y") {
|
||||
return {
|
||||
success: false,
|
||||
message: "비활성화된 REST API 연결입니다.",
|
||||
error: {
|
||||
code: "CONNECTION_INACTIVE",
|
||||
details: "연결이 비활성화 상태입니다.",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 엔드포인트 결정 (파라미터 > 저장된 값)
|
||||
const effectiveEndpoint = endpoint || connection.endpoint_path || "";
|
||||
|
||||
// API 호출을 위한 테스트 요청 생성
|
||||
const testRequest: RestApiTestRequest = {
|
||||
id: connection.id,
|
||||
base_url: connection.base_url,
|
||||
endpoint: effectiveEndpoint,
|
||||
method: (connection.default_method as any) || "GET",
|
||||
headers: connection.default_headers,
|
||||
body: connection.default_body,
|
||||
auth_type: connection.auth_type,
|
||||
auth_config: connection.auth_config,
|
||||
timeout: connection.timeout,
|
||||
};
|
||||
|
||||
// API 호출
|
||||
const result = await this.testConnection(testRequest, connection.company_code);
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: result.message || "REST API 호출에 실패했습니다.",
|
||||
error: {
|
||||
code: "API_CALL_FAILED",
|
||||
details: result.error_details,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 응답 데이터에서 jsonPath로 데이터 추출
|
||||
let extractedData = result.response_data;
|
||||
|
||||
logger.info(`REST API 원본 응답 데이터 타입: ${typeof result.response_data}`);
|
||||
logger.info(`REST API 원본 응답 데이터 (일부): ${JSON.stringify(result.response_data)?.substring(0, 500)}`);
|
||||
|
||||
if (jsonPath && result.response_data) {
|
||||
try {
|
||||
// jsonPath로 데이터 추출 (예: "data", "data.items", "result.list")
|
||||
const pathParts = jsonPath.split(".");
|
||||
logger.info(`JSON Path 파싱: ${jsonPath} -> [${pathParts.join(", ")}]`);
|
||||
|
||||
for (const part of pathParts) {
|
||||
if (extractedData && typeof extractedData === "object") {
|
||||
extractedData = (extractedData as any)[part];
|
||||
logger.info(`JSON Path '${part}' 추출 결과 타입: ${typeof extractedData}, 배열?: ${Array.isArray(extractedData)}`);
|
||||
} else {
|
||||
logger.warn(`JSON Path '${part}' 추출 실패: extractedData가 객체가 아님`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (pathError) {
|
||||
logger.warn(`JSON Path 추출 실패: ${jsonPath}`, pathError);
|
||||
// 추출 실패 시 원본 데이터 반환
|
||||
extractedData = result.response_data;
|
||||
}
|
||||
}
|
||||
|
||||
// 데이터가 배열이 아닌 경우 배열로 변환
|
||||
// null이나 undefined인 경우 빈 배열로 처리
|
||||
let dataArray: any[] = [];
|
||||
if (extractedData === null || extractedData === undefined) {
|
||||
logger.warn("추출된 데이터가 null/undefined입니다. 원본 응답 데이터를 사용합니다.");
|
||||
// jsonPath 추출 실패 시 원본 데이터에서 직접 컬럼 추출 시도
|
||||
if (result.response_data && typeof result.response_data === "object") {
|
||||
dataArray = Array.isArray(result.response_data) ? result.response_data : [result.response_data];
|
||||
}
|
||||
} else {
|
||||
dataArray = Array.isArray(extractedData) ? extractedData : [extractedData];
|
||||
}
|
||||
|
||||
logger.info(`최종 데이터 배열 길이: ${dataArray.length}`);
|
||||
if (dataArray.length > 0) {
|
||||
logger.info(`첫 번째 데이터 항목: ${JSON.stringify(dataArray[0])?.substring(0, 300)}`);
|
||||
}
|
||||
|
||||
// 컬럼 정보 추출 (첫 번째 유효한 데이터 기준)
|
||||
let columns: Array<{ columnName: string; columnLabel: string; dataType: string }> = [];
|
||||
|
||||
// 첫 번째 유효한 객체 찾기
|
||||
const firstValidItem = dataArray.find(item => item && typeof item === "object" && !Array.isArray(item));
|
||||
|
||||
if (firstValidItem) {
|
||||
columns = Object.keys(firstValidItem).map((key) => ({
|
||||
columnName: key,
|
||||
columnLabel: key,
|
||||
dataType: typeof firstValidItem[key],
|
||||
}));
|
||||
logger.info(`추출된 컬럼 수: ${columns.length}, 컬럼명: [${columns.map(c => c.columnName).join(", ")}]`);
|
||||
} else {
|
||||
logger.warn("유효한 데이터 항목을 찾을 수 없어 컬럼을 추출할 수 없습니다.");
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
rows: dataArray,
|
||||
columns,
|
||||
total: dataArray.length,
|
||||
connectionInfo: {
|
||||
connectionId: connection.id,
|
||||
connectionName: connection.connection_name,
|
||||
baseUrl: connection.base_url,
|
||||
endpoint: effectiveEndpoint,
|
||||
},
|
||||
},
|
||||
message: `${dataArray.length}개의 데이터를 조회했습니다.`,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("REST API 데이터 조회 오류:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "REST API 데이터 조회에 실패했습니다.",
|
||||
error: {
|
||||
code: "FETCH_ERROR",
|
||||
details: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 연결 데이터 유효성 검증
|
||||
*/
|
||||
@@ -709,6 +1070,7 @@ export class ExternalRestApiConnectionService {
|
||||
"bearer",
|
||||
"basic",
|
||||
"oauth2",
|
||||
"db-token",
|
||||
];
|
||||
if (!validAuthTypes.includes(data.auth_type)) {
|
||||
throw new Error("올바르지 않은 인증 타입입니다.");
|
||||
|
||||
@@ -334,9 +334,12 @@ class MailSendSimpleService {
|
||||
if (variables) {
|
||||
buttonText = this.replaceVariables(buttonText, variables);
|
||||
}
|
||||
// styles 객체 또는 직접 속성에서 색상 가져오기
|
||||
const buttonBgColor = component.styles?.backgroundColor || component.backgroundColor || '#007bff';
|
||||
const buttonTextColor = component.styles?.color || component.textColor || '#fff';
|
||||
// 버튼은 왼쪽 정렬 (text-align 제거)
|
||||
html += `<div style="margin: 30px 0; text-align: left;">
|
||||
<a href="${component.url || '#'}" style="display: inline-block; padding: 14px 28px; background-color: ${component.backgroundColor || '#007bff'}; color: ${component.textColor || '#fff'}; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 15px;">${buttonText}</a>
|
||||
<a href="${component.url || '#'}" style="display: inline-block; padding: 14px 28px; background-color: ${buttonBgColor}; color: ${buttonTextColor}; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 15px;">${buttonText}</a>
|
||||
</div>`;
|
||||
break;
|
||||
case 'image':
|
||||
@@ -348,6 +351,89 @@ class MailSendSimpleService {
|
||||
case 'spacer':
|
||||
html += `<div style="height: ${component.height || '20px'};"></div>`;
|
||||
break;
|
||||
case 'header':
|
||||
html += `
|
||||
<div style="padding: 20px; background-color: ${component.headerBgColor || '#f8f9fa'}; border-radius: 8px; margin-bottom: 20px;">
|
||||
<table style="width: 100%;">
|
||||
<tr>
|
||||
<td style="vertical-align: middle;">
|
||||
${component.logoSrc ? `<img src="${component.logoSrc}" alt="로고" style="height: 40px; margin-right: 12px;">` : ''}
|
||||
<span style="font-size: 18px; font-weight: bold;">${component.brandName || ''}</span>
|
||||
</td>
|
||||
<td style="text-align: right; color: #6b7280; font-size: 14px;">
|
||||
${component.sendDate || ''}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
break;
|
||||
case 'infoTable':
|
||||
html += `
|
||||
<div style="border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden; margin: 16px 0;">
|
||||
${component.tableTitle ? `<div style="background-color: #f9fafb; padding: 12px 16px; font-weight: 600; border-bottom: 1px solid #e5e7eb;">${component.tableTitle}</div>` : ''}
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
${(component.rows || []).map((row: any, i: number) => `
|
||||
<tr style="background-color: ${i % 2 === 0 ? '#ffffff' : '#f9fafb'};">
|
||||
<td style="padding: 12px 16px; font-weight: 500; color: #4b5563; width: 35%; border-right: 1px solid #e5e7eb;">${row.label}</td>
|
||||
<td style="padding: 12px 16px;">${row.value}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
break;
|
||||
case 'alertBox':
|
||||
const alertColors: Record<string, { bg: string; border: string; text: string }> = {
|
||||
info: { bg: '#eff6ff', border: '#3b82f6', text: '#1e40af' },
|
||||
warning: { bg: '#fffbeb', border: '#f59e0b', text: '#92400e' },
|
||||
danger: { bg: '#fef2f2', border: '#ef4444', text: '#991b1b' },
|
||||
success: { bg: '#ecfdf5', border: '#10b981', text: '#065f46' }
|
||||
};
|
||||
const colors = alertColors[component.alertType || 'info'];
|
||||
html += `
|
||||
<div style="padding: 16px; background-color: ${colors.bg}; border-left: 4px solid ${colors.border}; border-radius: 4px; margin: 16px 0; color: ${colors.text};">
|
||||
${component.alertTitle ? `<div style="font-weight: bold; margin-bottom: 8px;">${component.alertTitle}</div>` : ''}
|
||||
<div>${component.content || ''}</div>
|
||||
</div>
|
||||
`;
|
||||
break;
|
||||
case 'divider':
|
||||
html += `<hr style="border: none; border-top: ${component.height || 1}px solid #e5e7eb; margin: 20px 0;">`;
|
||||
break;
|
||||
case 'footer':
|
||||
html += `
|
||||
<div style="text-align: center; padding: 24px 16px; background-color: #f9fafb; border-top: 1px solid #e5e7eb; font-size: 14px; color: #6b7280;">
|
||||
${component.companyName ? `<div style="font-weight: 600; color: #374151; margin-bottom: 8px;">${component.companyName}</div>` : ''}
|
||||
${(component.ceoName || component.businessNumber) ? `
|
||||
<div style="margin-bottom: 4px;">
|
||||
${component.ceoName ? `대표: ${component.ceoName}` : ''}
|
||||
${component.ceoName && component.businessNumber ? ' | ' : ''}
|
||||
${component.businessNumber ? `사업자등록번호: ${component.businessNumber}` : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
${component.address ? `<div style="margin-bottom: 4px;">${component.address}</div>` : ''}
|
||||
${(component.phone || component.email) ? `
|
||||
<div style="margin-bottom: 4px;">
|
||||
${component.phone ? `Tel: ${component.phone}` : ''}
|
||||
${component.phone && component.email ? ' | ' : ''}
|
||||
${component.email ? `Email: ${component.email}` : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
${component.copyright ? `<div style="margin-top: 12px; font-size: 12px; color: #9ca3af;">${component.copyright}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
break;
|
||||
case 'numberedList':
|
||||
html += `
|
||||
<div style="padding: 16px;">
|
||||
${component.listTitle ? `<div style="font-weight: 600; margin-bottom: 12px;">${component.listTitle}</div>` : ''}
|
||||
<ol style="margin: 0; padding-left: 20px;">
|
||||
${(component.listItems || []).map((item: string) => `<li style="margin-bottom: 8px;">${item}</li>`).join('')}
|
||||
</ol>
|
||||
</div>
|
||||
`;
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -4,13 +4,35 @@ import path from "path";
|
||||
// MailComponent 인터페이스 정의
|
||||
export interface MailComponent {
|
||||
id: string;
|
||||
type: "text" | "button" | "image" | "spacer";
|
||||
type: "text" | "button" | "image" | "spacer" | "header" | "infoTable" | "alertBox" | "divider" | "footer" | "numberedList";
|
||||
content?: string;
|
||||
text?: string;
|
||||
url?: string;
|
||||
src?: string;
|
||||
height?: number;
|
||||
styles?: Record<string, string>;
|
||||
// 헤더 컴포넌트용
|
||||
logoSrc?: string;
|
||||
brandName?: string;
|
||||
sendDate?: string;
|
||||
headerBgColor?: string;
|
||||
// 정보 테이블용
|
||||
rows?: Array<{ label: string; value: string }>;
|
||||
tableTitle?: string;
|
||||
// 강조 박스용
|
||||
alertType?: "info" | "warning" | "danger" | "success";
|
||||
alertTitle?: string;
|
||||
// 푸터용
|
||||
companyName?: string;
|
||||
ceoName?: string;
|
||||
businessNumber?: string;
|
||||
address?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
copyright?: string;
|
||||
// 번호 리스트용
|
||||
listItems?: string[];
|
||||
listTitle?: string;
|
||||
}
|
||||
|
||||
// QueryConfig 인터페이스 정의 (사용하지 않지만 타입 호환성 유지)
|
||||
@@ -236,6 +258,89 @@ class MailTemplateFileService {
|
||||
case "spacer":
|
||||
html += `<div style="height: ${comp.height || 20}px;"></div>`;
|
||||
break;
|
||||
case "header":
|
||||
html += `
|
||||
<div style="padding: 20px; background-color: ${comp.headerBgColor || '#f8f9fa'}; border-radius: 8px; margin-bottom: 20px;">
|
||||
<table style="width: 100%;">
|
||||
<tr>
|
||||
<td style="vertical-align: middle;">
|
||||
${comp.logoSrc ? `<img src="${comp.logoSrc}" alt="로고" style="height: 40px; margin-right: 12px;">` : ''}
|
||||
<span style="font-size: 18px; font-weight: bold;">${comp.brandName || ''}</span>
|
||||
</td>
|
||||
<td style="text-align: right; color: #6b7280; font-size: 14px;">
|
||||
${comp.sendDate || ''}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
break;
|
||||
case "infoTable":
|
||||
html += `
|
||||
<div style="border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden; margin: 16px 0;">
|
||||
${comp.tableTitle ? `<div style="background-color: #f9fafb; padding: 12px 16px; font-weight: 600; border-bottom: 1px solid #e5e7eb;">${comp.tableTitle}</div>` : ''}
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
${(comp.rows || []).map((row, i) => `
|
||||
<tr style="background-color: ${i % 2 === 0 ? '#ffffff' : '#f9fafb'};">
|
||||
<td style="padding: 12px 16px; font-weight: 500; color: #4b5563; width: 35%; border-right: 1px solid #e5e7eb;">${row.label}</td>
|
||||
<td style="padding: 12px 16px;">${row.value}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
break;
|
||||
case "alertBox":
|
||||
const alertColors: Record<string, { bg: string; border: string; text: string }> = {
|
||||
info: { bg: '#eff6ff', border: '#3b82f6', text: '#1e40af' },
|
||||
warning: { bg: '#fffbeb', border: '#f59e0b', text: '#92400e' },
|
||||
danger: { bg: '#fef2f2', border: '#ef4444', text: '#991b1b' },
|
||||
success: { bg: '#ecfdf5', border: '#10b981', text: '#065f46' }
|
||||
};
|
||||
const colors = alertColors[comp.alertType || 'info'];
|
||||
html += `
|
||||
<div style="padding: 16px; background-color: ${colors.bg}; border-left: 4px solid ${colors.border}; border-radius: 4px; margin: 16px 0; color: ${colors.text};">
|
||||
${comp.alertTitle ? `<div style="font-weight: bold; margin-bottom: 8px;">${comp.alertTitle}</div>` : ''}
|
||||
<div>${comp.content || ''}</div>
|
||||
</div>
|
||||
`;
|
||||
break;
|
||||
case "divider":
|
||||
html += `<hr style="border: none; border-top: ${comp.height || 1}px solid #e5e7eb; margin: 20px 0;">`;
|
||||
break;
|
||||
case "footer":
|
||||
html += `
|
||||
<div style="text-align: center; padding: 24px 16px; background-color: #f9fafb; border-top: 1px solid #e5e7eb; font-size: 14px; color: #6b7280;">
|
||||
${comp.companyName ? `<div style="font-weight: 600; color: #374151; margin-bottom: 8px;">${comp.companyName}</div>` : ''}
|
||||
${(comp.ceoName || comp.businessNumber) ? `
|
||||
<div style="margin-bottom: 4px;">
|
||||
${comp.ceoName ? `대표: ${comp.ceoName}` : ''}
|
||||
${comp.ceoName && comp.businessNumber ? ' | ' : ''}
|
||||
${comp.businessNumber ? `사업자등록번호: ${comp.businessNumber}` : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
${comp.address ? `<div style="margin-bottom: 4px;">${comp.address}</div>` : ''}
|
||||
${(comp.phone || comp.email) ? `
|
||||
<div style="margin-bottom: 4px;">
|
||||
${comp.phone ? `Tel: ${comp.phone}` : ''}
|
||||
${comp.phone && comp.email ? ' | ' : ''}
|
||||
${comp.email ? `Email: ${comp.email}` : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
${comp.copyright ? `<div style="margin-top: 12px; font-size: 12px; color: #9ca3af;">${comp.copyright}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
break;
|
||||
case "numberedList":
|
||||
html += `
|
||||
<div style="padding: 16px; ${styles}">
|
||||
${comp.listTitle ? `<div style="font-weight: 600; margin-bottom: 12px;">${comp.listTitle}</div>` : ''}
|
||||
<ol style="margin: 0; padding-left: 20px;">
|
||||
${(comp.listItems || []).map(item => `<li style="margin-bottom: 8px;">${item}</li>`).join('')}
|
||||
</ol>
|
||||
</div>
|
||||
`;
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -70,12 +70,13 @@ export class ScreenManagementService {
|
||||
throw new Error("이미 존재하는 화면 코드입니다.");
|
||||
}
|
||||
|
||||
// 화면 생성 (Raw Query)
|
||||
// 화면 생성 (Raw Query) - REST API 지원 추가
|
||||
const [screen] = await query<any>(
|
||||
`INSERT INTO screen_definitions (
|
||||
screen_name, screen_code, table_name, company_code, description, created_by,
|
||||
db_source_type, db_connection_id
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
db_source_type, db_connection_id, data_source_type, rest_api_connection_id,
|
||||
rest_api_endpoint, rest_api_json_path
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
RETURNING *`,
|
||||
[
|
||||
screenData.screenName,
|
||||
@@ -86,6 +87,10 @@ export class ScreenManagementService {
|
||||
screenData.createdBy,
|
||||
screenData.dbSourceType || "internal",
|
||||
screenData.dbConnectionId || null,
|
||||
(screenData as any).dataSourceType || "database",
|
||||
(screenData as any).restApiConnectionId || null,
|
||||
(screenData as any).restApiEndpoint || null,
|
||||
(screenData as any).restApiJsonPath || "data",
|
||||
]
|
||||
);
|
||||
|
||||
@@ -1977,6 +1982,11 @@ export class ScreenManagementService {
|
||||
updatedBy: data.updated_by,
|
||||
dbSourceType: data.db_source_type || "internal",
|
||||
dbConnectionId: data.db_connection_id || undefined,
|
||||
// REST API 관련 필드
|
||||
dataSourceType: data.data_source_type || "database",
|
||||
restApiConnectionId: data.rest_api_connection_id || undefined,
|
||||
restApiEndpoint: data.rest_api_endpoint || undefined,
|
||||
restApiJsonPath: data.rest_api_json_path || "data",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
export interface BatchExecutionLog {
|
||||
id?: number;
|
||||
batch_config_id: number;
|
||||
company_code?: string;
|
||||
execution_status: 'RUNNING' | 'SUCCESS' | 'FAILED' | 'CANCELLED';
|
||||
start_time: Date;
|
||||
end_time?: Date | null;
|
||||
@@ -19,6 +20,7 @@ export interface BatchExecutionLog {
|
||||
|
||||
export interface CreateBatchExecutionLogRequest {
|
||||
batch_config_id: number;
|
||||
company_code?: string;
|
||||
execution_status: 'RUNNING' | 'SUCCESS' | 'FAILED' | 'CANCELLED';
|
||||
start_time?: Date;
|
||||
end_time?: Date | null;
|
||||
|
||||
@@ -1,86 +1,13 @@
|
||||
// 배치관리 타입 정의
|
||||
// 작성일: 2024-12-24
|
||||
import { ApiResponse, ColumnInfo } from './batchTypes';
|
||||
|
||||
// 배치 타입 정의
|
||||
export type BatchType = 'db-to-db' | 'db-to-restapi' | 'restapi-to-db' | 'restapi-to-restapi';
|
||||
|
||||
export interface BatchTypeOption {
|
||||
value: BatchType;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface BatchConfig {
|
||||
id?: number;
|
||||
batch_name: string;
|
||||
description?: string;
|
||||
cron_schedule: string;
|
||||
is_active?: string;
|
||||
company_code?: string;
|
||||
created_date?: Date;
|
||||
created_by?: string;
|
||||
updated_date?: Date;
|
||||
updated_by?: string;
|
||||
batch_mappings?: BatchMapping[];
|
||||
}
|
||||
|
||||
export interface BatchMapping {
|
||||
id?: number;
|
||||
batch_config_id?: number;
|
||||
|
||||
// FROM 정보
|
||||
from_connection_type: 'internal' | 'external' | 'restapi';
|
||||
from_connection_id?: number;
|
||||
from_table_name: string; // DB: 테이블명, REST API: 엔드포인트
|
||||
from_column_name: string; // DB: 컬럼명, REST API: JSON 필드명
|
||||
from_column_type?: string;
|
||||
from_api_method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; // REST API 전용
|
||||
from_api_url?: string; // REST API 서버 URL
|
||||
from_api_key?: string; // REST API 키
|
||||
from_api_param_type?: 'url' | 'query'; // API 파라미터 타입
|
||||
from_api_param_name?: string; // API 파라미터명
|
||||
from_api_param_value?: string; // API 파라미터 값 또는 템플릿
|
||||
from_api_param_source?: 'static' | 'dynamic'; // 파라미터 소스 타입
|
||||
|
||||
// TO 정보
|
||||
to_connection_type: 'internal' | 'external' | 'restapi';
|
||||
to_connection_id?: number;
|
||||
to_table_name: string; // DB: 테이블명, REST API: 엔드포인트
|
||||
to_column_name: string; // DB: 컬럼명, REST API: JSON 필드명
|
||||
to_column_type?: string;
|
||||
to_api_method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; // REST API 전용
|
||||
to_api_url?: string; // REST API 서버 URL
|
||||
to_api_key?: string; // REST API 키
|
||||
to_api_body?: string; // Request Body 템플릿 (DB → REST API 배치용)
|
||||
|
||||
mapping_order?: number;
|
||||
created_date?: Date;
|
||||
created_by?: string;
|
||||
}
|
||||
|
||||
export interface BatchConfigFilter {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
batch_name?: string;
|
||||
is_active?: string;
|
||||
company_code?: string;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export interface ConnectionInfo {
|
||||
export interface BatchConnectionInfo {
|
||||
type: 'internal' | 'external';
|
||||
id?: number;
|
||||
name: string;
|
||||
db_type?: string;
|
||||
}
|
||||
|
||||
export interface TableInfo {
|
||||
table_name: string;
|
||||
columns: ColumnInfo[];
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
export interface ColumnInfo {
|
||||
export interface BatchColumnInfo {
|
||||
column_name: string;
|
||||
data_type: string;
|
||||
is_nullable?: string;
|
||||
@@ -100,6 +27,8 @@ export interface BatchMappingRequest {
|
||||
from_api_param_name?: string; // API 파라미터명
|
||||
from_api_param_value?: string; // API 파라미터 값 또는 템플릿
|
||||
from_api_param_source?: 'static' | 'dynamic'; // 파라미터 소스 타입
|
||||
// 👇 REST API Body 추가 (FROM - REST API에서 POST 요청 시 필요)
|
||||
from_api_body?: string;
|
||||
to_connection_type: 'internal' | 'external' | 'restapi';
|
||||
to_connection_id?: number;
|
||||
to_table_name: string;
|
||||
@@ -116,6 +45,8 @@ export interface CreateBatchConfigRequest {
|
||||
batchName: string;
|
||||
description?: string;
|
||||
cronSchedule: string;
|
||||
isActive: 'Y' | 'N';
|
||||
companyCode: string;
|
||||
mappings: BatchMappingRequest[];
|
||||
}
|
||||
|
||||
@@ -123,25 +54,11 @@ export interface UpdateBatchConfigRequest {
|
||||
batchName?: string;
|
||||
description?: string;
|
||||
cronSchedule?: string;
|
||||
isActive?: 'Y' | 'N';
|
||||
mappings?: BatchMappingRequest[];
|
||||
isActive?: string;
|
||||
}
|
||||
|
||||
export interface BatchValidationResult {
|
||||
isValid: boolean;
|
||||
errors: string[];
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
message?: string;
|
||||
error?: string;
|
||||
pagination?: {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
// 외부 REST API 연결 관리 타입 정의
|
||||
|
||||
export type AuthType = "none" | "api-key" | "bearer" | "basic" | "oauth2";
|
||||
export type AuthType =
|
||||
| "none"
|
||||
| "api-key"
|
||||
| "bearer"
|
||||
| "basic"
|
||||
| "oauth2"
|
||||
| "db-token";
|
||||
|
||||
export interface ExternalRestApiConnection {
|
||||
id?: number;
|
||||
@@ -9,6 +15,11 @@ export interface ExternalRestApiConnection {
|
||||
base_url: string;
|
||||
endpoint_path?: string;
|
||||
default_headers: Record<string, string>;
|
||||
|
||||
// 기본 메서드 및 바디 추가
|
||||
default_method?: string;
|
||||
default_body?: string;
|
||||
|
||||
auth_type: AuthType;
|
||||
auth_config?: {
|
||||
// API Key
|
||||
@@ -28,6 +39,14 @@ export interface ExternalRestApiConnection {
|
||||
clientSecret?: string;
|
||||
tokenUrl?: string;
|
||||
accessToken?: string;
|
||||
|
||||
// DB 기반 토큰 모드
|
||||
dbTableName?: string;
|
||||
dbValueColumn?: string;
|
||||
dbWhereColumn?: string;
|
||||
dbWhereValue?: string;
|
||||
dbHeaderName?: string;
|
||||
dbHeaderTemplate?: string;
|
||||
};
|
||||
timeout?: number;
|
||||
retry_count?: number;
|
||||
@@ -54,8 +73,9 @@ export interface RestApiTestRequest {
|
||||
id?: number;
|
||||
base_url: string;
|
||||
endpoint?: string;
|
||||
method?: "GET" | "POST" | "PUT" | "DELETE";
|
||||
method?: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
|
||||
headers?: Record<string, string>;
|
||||
body?: any; // 테스트 요청 바디 추가
|
||||
auth_type?: AuthType;
|
||||
auth_config?: any;
|
||||
timeout?: number;
|
||||
@@ -76,4 +96,5 @@ export const AUTH_TYPE_OPTIONS = [
|
||||
{ value: "bearer", label: "Bearer Token" },
|
||||
{ value: "basic", label: "Basic Auth" },
|
||||
{ value: "oauth2", label: "OAuth 2.0" },
|
||||
{ value: "db-token", label: "DB 토큰" },
|
||||
];
|
||||
|
||||
@@ -154,6 +154,11 @@ export interface ScreenDefinition {
|
||||
updatedBy?: string;
|
||||
dbSourceType?: "internal" | "external";
|
||||
dbConnectionId?: number;
|
||||
// REST API 관련 필드
|
||||
dataSourceType?: "database" | "restapi";
|
||||
restApiConnectionId?: number;
|
||||
restApiEndpoint?: string;
|
||||
restApiJsonPath?: string;
|
||||
}
|
||||
|
||||
// 화면 생성 요청
|
||||
@@ -166,6 +171,11 @@ export interface CreateScreenRequest {
|
||||
createdBy?: string;
|
||||
dbSourceType?: "internal" | "external";
|
||||
dbConnectionId?: number;
|
||||
// REST API 관련 필드
|
||||
dataSourceType?: "database" | "restapi";
|
||||
restApiConnectionId?: number;
|
||||
restApiEndpoint?: string;
|
||||
restApiJsonPath?: string;
|
||||
}
|
||||
|
||||
// 화면 수정 요청
|
||||
|
||||
Reference in New Issue
Block a user