diff --git a/backend-node/src/controllers/batchController.ts b/backend-node/src/controllers/batchController.ts index 8a29e5bf..638edcd2 100644 --- a/backend-node/src/controllers/batchController.ts +++ b/backend-node/src/controllers/batchController.ts @@ -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); diff --git a/backend-node/src/controllers/batchExecutionLogController.ts b/backend-node/src/controllers/batchExecutionLogController.ts index 84608731..1b0166ae 100644 --- a/backend-node/src/controllers/batchExecutionLogController.ts +++ b/backend-node/src/controllers/batchExecutionLogController.ts @@ -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) { diff --git a/backend-node/src/controllers/batchManagementController.ts b/backend-node/src/controllers/batchManagementController.ts index d1be2311..61194485 100644 --- a/backend-node/src/controllers/batchManagementController.ts +++ b/backend-node/src/controllers/batchManagementController.ts @@ -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) { // 스케줄러에 자동 등록 ✅ diff --git a/backend-node/src/database/RestApiConnector.ts b/backend-node/src/database/RestApiConnector.ts index 2c2965aa..9fd68fe7 100644 --- a/backend-node/src/database/RestApiConnector.ts +++ b/backend-node/src/database/RestApiConnector.ts @@ -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 = { + "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 { - 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 { diff --git a/backend-node/src/services/batchExecutionLogService.ts b/backend-node/src/services/batchExecutionLogService.ts index f2fc583c..3561f43f 100644 --- a/backend-node/src/services/batchExecutionLogService.ts +++ b/backend-node/src/services/batchExecutionLogService.ts @@ -130,13 +130,14 @@ export class BatchExecutionLogService { try { const log = await queryOne( `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, diff --git a/backend-node/src/services/batchExternalDbService.ts b/backend-node/src/services/batchExternalDbService.ts index 75d7ea67..18524085 100644 --- a/backend-node/src/services/batchExternalDbService.ts +++ b/backend-node/src/services/batchExternalDbService.ts @@ -77,45 +77,47 @@ export class BatchExternalDbService { } /** - * 배치관리용 테이블 목록 조회 + * 테이블 목록 조회 */ - static async getTablesFromConnection( - connectionType: "internal" | "external", - connectionId?: number + static async getTables( + connectionId: number ): Promise> { try { - let tables: TableInfo[] = []; - - if (connectionType === "internal") { - // 내부 DB 테이블 조회 - const result = await query<{ table_name: string }>( - `SELECT table_name - FROM information_schema.tables - WHERE table_schema = 'public' - AND table_type = 'BASE TABLE' - ORDER BY table_name`, - [] - ); - - tables = result.map((row) => ({ - table_name: row.table_name, - columns: [], - })); - } else if (connectionType === "external" && connectionId) { - // 외부 DB 테이블 조회 - const tablesResult = await this.getExternalTables(connectionId); - if (tablesResult.success && tablesResult.data) { - tables = tablesResult.data; - } + // 연결 정보 조회 + const connection = await this.getConnectionById(connectionId); + if (!connection) { + throw new Error("외부 DB 연결 정보를 찾을 수 없습니다."); } + // 커넥터 생성 + const connector = await DatabaseConnectorFactory.createConnector( + connection.db_type, + { + host: connection.host, + port: connection.port, + database: connection.database_name, + user: connection.username, + password: connection.password, + }, + connectionId + ); + + // 연결 + await connector.connect(); + + // 테이블 목록 조회 + const tables = await connector.getTables(); + + // 연결 종료 + await connector.disconnect(); + return { success: true, data: tables, message: `${tables.length}개의 테이블을 조회했습니다.`, }; } catch (error) { - console.error("배치관리 테이블 목록 조회 실패:", error); + console.error("테이블 목록 조회 실패:", error); return { success: false, message: "테이블 목록 조회 중 오류가 발생했습니다.", @@ -125,562 +127,283 @@ export class BatchExternalDbService { } /** - * 배치관리용 테이블 컬럼 정보 조회 + * 컬럼 목록 조회 */ - static async getTableColumns( - connectionType: "internal" | "external", - connectionId: number | undefined, - tableName: string - ): Promise> { - try { - console.log(`[BatchExternalDbService] getTableColumns 호출:`, { - connectionType, - connectionId, - tableName, - }); - - let columns: ColumnInfo[] = []; - - if (connectionType === "internal") { - // 내부 DB 컬럼 조회 - console.log( - `[BatchExternalDbService] 내부 DB 컬럼 조회 시작: ${tableName}` - ); - - const result = await query<{ - column_name: string; - data_type: string; - is_nullable: string; - column_default: string | null; - }>( - `SELECT - column_name, - data_type, - is_nullable, - column_default - FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = $1 - ORDER BY ordinal_position`, - [tableName] - ); - - console.log(`[BatchExternalDbService] 내부 DB 컬럼 조회 결과:`, result); - - columns = result.map((row) => ({ - column_name: row.column_name, - data_type: row.data_type, - is_nullable: row.is_nullable, - column_default: row.column_default, - })); - } else if (connectionType === "external" && connectionId) { - // 외부 DB 컬럼 조회 - console.log( - `[BatchExternalDbService] 외부 DB 컬럼 조회 시작: connectionId=${connectionId}, tableName=${tableName}` - ); - - const columnsResult = await this.getExternalTableColumns( - connectionId, - tableName - ); - - console.log( - `[BatchExternalDbService] 외부 DB 컬럼 조회 결과:`, - columnsResult - ); - - if (columnsResult.success && columnsResult.data) { - columns = columnsResult.data; - } - } - - console.log(`[BatchExternalDbService] 최종 컬럼 목록:`, columns); - return { - success: true, - data: columns, - message: `${columns.length}개의 컬럼을 조회했습니다.`, - }; - } catch (error) { - console.error("[BatchExternalDbService] 컬럼 정보 조회 오류:", error); - return { - success: false, - message: "컬럼 정보 조회 중 오류가 발생했습니다.", - error: error instanceof Error ? error.message : "알 수 없는 오류", - }; - } - } - - /** - * 외부 DB 테이블 목록 조회 (내부 구현) - */ - private static async getExternalTables( - connectionId: number - ): Promise> { - try { - // 연결 정보 조회 - const connection = await queryOne( - `SELECT * FROM external_db_connections WHERE id = $1`, - [connectionId] - ); - - if (!connection) { - return { - success: false, - message: "연결 정보를 찾을 수 없습니다.", - }; - } - - // 비밀번호 복호화 - const decryptedPassword = PasswordEncryption.decrypt(connection.password); - if (!decryptedPassword) { - return { - success: false, - message: "비밀번호 복호화에 실패했습니다.", - }; - } - - // 연결 설정 준비 - const config = { - host: connection.host, - port: connection.port, - database: connection.database_name, - user: connection.username, - password: decryptedPassword, - connectionTimeoutMillis: - connection.connection_timeout != null - ? connection.connection_timeout * 1000 - : undefined, - queryTimeoutMillis: - connection.query_timeout != null - ? connection.query_timeout * 1000 - : undefined, - ssl: - connection.ssl_enabled === "Y" - ? { rejectUnauthorized: false } - : false, - }; - - // DatabaseConnectorFactory를 통한 테이블 목록 조회 - const connector = await DatabaseConnectorFactory.createConnector( - connection.db_type, - config, - connectionId - ); - const tables = await connector.getTables(); - - return { - success: true, - message: "테이블 목록을 조회했습니다.", - data: tables, - }; - } catch (error) { - console.error("외부 DB 테이블 목록 조회 오류:", error); - return { - success: false, - message: "테이블 목록 조회 중 오류가 발생했습니다.", - error: error instanceof Error ? error.message : "알 수 없는 오류", - }; - } - } - - /** - * 외부 DB 테이블 컬럼 정보 조회 (내부 구현) - */ - private static async getExternalTableColumns( + static async getColumns( connectionId: number, tableName: string ): Promise> { try { - console.log( - `[BatchExternalDbService] getExternalTableColumns 호출: connectionId=${connectionId}, tableName=${tableName}` - ); - // 연결 정보 조회 - const connection = await queryOne( - `SELECT * FROM external_db_connections WHERE id = $1`, - [connectionId] - ); - + const connection = await this.getConnectionById(connectionId); if (!connection) { - console.log( - `[BatchExternalDbService] 연결 정보를 찾을 수 없음: connectionId=${connectionId}` - ); - return { - success: false, - message: "연결 정보를 찾을 수 없습니다.", - }; + throw new Error("외부 DB 연결 정보를 찾을 수 없습니다."); } - console.log(`[BatchExternalDbService] 연결 정보 조회 성공:`, { - id: connection.id, - connection_name: connection.connection_name, - db_type: connection.db_type, - host: connection.host, - port: connection.port, - database_name: connection.database_name, - }); - - // 비밀번호 복호화 - const decryptedPassword = PasswordEncryption.decrypt(connection.password); - - // 연결 설정 준비 - const config = { - host: connection.host, - port: connection.port, - database: connection.database_name, - user: connection.username, - password: decryptedPassword, - connectionTimeoutMillis: - connection.connection_timeout != null - ? connection.connection_timeout * 1000 - : undefined, - queryTimeoutMillis: - connection.query_timeout != null - ? connection.query_timeout * 1000 - : undefined, - ssl: - connection.ssl_enabled === "Y" - ? { rejectUnauthorized: false } - : false, - }; - - console.log( - `[BatchExternalDbService] 커넥터 생성 시작: db_type=${connection.db_type}` - ); - - // 데이터베이스 타입에 따른 커넥터 생성 + // 커넥터 생성 const connector = await DatabaseConnectorFactory.createConnector( connection.db_type, - config, + { + host: connection.host, + port: connection.port, + database: connection.database_name, + user: connection.username, + password: connection.password, + }, connectionId ); - console.log( - `[BatchExternalDbService] 커넥터 생성 완료, 컬럼 조회 시작: tableName=${tableName}` - ); + // 연결 + await connector.connect(); - // 컬럼 정보 조회 - console.log(`[BatchExternalDbService] connector.getColumns 호출 전`); + // 컬럼 목록 조회 const columns = await connector.getColumns(tableName); - console.log(`[BatchExternalDbService] 원본 컬럼 조회 결과:`, columns); - console.log( - `[BatchExternalDbService] 원본 컬럼 개수:`, - columns ? columns.length : "null/undefined" - ); + // 연결 종료 + await connector.disconnect(); - // 각 데이터베이스 커넥터의 반환 구조가 다르므로 통일된 구조로 변환 - const standardizedColumns: ColumnInfo[] = columns.map((col: any) => { - console.log(`[BatchExternalDbService] 컬럼 변환 중:`, col); - - // MySQL/MariaDB 구조: {name, dataType, isNullable, defaultValue} (MySQLConnector만) - if (col.name && col.dataType !== undefined) { - const result = { - column_name: col.name, - data_type: col.dataType, - is_nullable: col.isNullable ? "YES" : "NO", - column_default: col.defaultValue || null, - }; - console.log( - `[BatchExternalDbService] MySQL/MariaDB 구조로 변환:`, - result - ); - return result; - } - // PostgreSQL/Oracle/MSSQL/MariaDB 구조: {column_name, data_type, is_nullable, column_default} - else { - const result = { - column_name: col.column_name || col.COLUMN_NAME, - data_type: col.data_type || col.DATA_TYPE, - is_nullable: - col.is_nullable || - col.IS_NULLABLE || - (col.nullable === "Y" ? "YES" : "NO"), - column_default: col.column_default || col.COLUMN_DEFAULT || null, - }; - console.log(`[BatchExternalDbService] 표준 구조로 변환:`, result); - return result; - } - }); - - console.log( - `[BatchExternalDbService] 표준화된 컬럼 목록:`, - standardizedColumns - ); - - // 빈 배열인 경우 경고 로그 - if (!standardizedColumns || standardizedColumns.length === 0) { - console.warn( - `[BatchExternalDbService] 컬럼이 비어있음: connectionId=${connectionId}, tableName=${tableName}` - ); - console.warn(`[BatchExternalDbService] 연결 정보:`, { - db_type: connection.db_type, - host: connection.host, - port: connection.port, - database_name: connection.database_name, - username: connection.username, - }); - - // 테이블 존재 여부 확인 - console.warn( - `[BatchExternalDbService] 테이블 존재 여부 확인을 위해 테이블 목록 조회 시도` - ); - try { - const tables = await connector.getTables(); - console.warn( - `[BatchExternalDbService] 사용 가능한 테이블 목록:`, - tables.map((t) => t.table_name) - ); - - // 테이블명이 정확한지 확인 - const tableExists = tables.some( - (t) => t.table_name.toLowerCase() === tableName.toLowerCase() - ); - console.warn( - `[BatchExternalDbService] 테이블 존재 여부: ${tableExists}` - ); - - // 정확한 테이블명 찾기 - const exactTable = tables.find( - (t) => t.table_name.toLowerCase() === tableName.toLowerCase() - ); - if (exactTable) { - console.warn( - `[BatchExternalDbService] 정확한 테이블명: ${exactTable.table_name}` - ); - } - - // 모든 테이블명 출력 - console.warn( - `[BatchExternalDbService] 모든 테이블명:`, - tables.map((t) => `"${t.table_name}"`) - ); - - // 테이블명 비교 - console.warn( - `[BatchExternalDbService] 요청된 테이블명: "${tableName}"` - ); - console.warn( - `[BatchExternalDbService] 테이블명 비교 결과:`, - tables.map((t) => ({ - table_name: t.table_name, - matches: t.table_name.toLowerCase() === tableName.toLowerCase(), - exact_match: t.table_name === tableName, - })) - ); - - // 정확한 테이블명으로 다시 시도 - if (exactTable && exactTable.table_name !== tableName) { - console.warn( - `[BatchExternalDbService] 정확한 테이블명으로 다시 시도: ${exactTable.table_name}` - ); - try { - const correctColumns = await connector.getColumns( - exactTable.table_name - ); - console.warn( - `[BatchExternalDbService] 정확한 테이블명으로 조회한 컬럼:`, - correctColumns - ); - } catch (correctError) { - console.error( - `[BatchExternalDbService] 정확한 테이블명으로 조회 실패:`, - correctError - ); - } - } - } catch (tableError) { - console.error( - `[BatchExternalDbService] 테이블 목록 조회 실패:`, - tableError - ); - } - } + // BatchColumnInfo 형식으로 변환 + const batchColumns: ColumnInfo[] = columns.map((col) => ({ + column_name: col.column_name, + data_type: col.data_type, + is_nullable: col.is_nullable, + column_default: col.column_default, + })); return { success: true, - data: standardizedColumns, - message: "컬럼 정보를 조회했습니다.", + data: batchColumns, + message: `${batchColumns.length}개의 컬럼을 조회했습니다.`, }; } catch (error) { - console.error( - "[BatchExternalDbService] 외부 DB 컬럼 정보 조회 오류:", - error - ); - console.error( - "[BatchExternalDbService] 오류 스택:", - error instanceof Error ? error.stack : "No stack trace" - ); + console.error("컬럼 목록 조회 실패:", error); return { success: false, - message: "컬럼 정보 조회 중 오류가 발생했습니다.", + message: "컬럼 목록 조회 중 오류가 발생했습니다.", error: error instanceof Error ? error.message : "알 수 없는 오류", }; } } /** - * 외부 DB 테이블에서 데이터 조회 + * 연결 정보 조회 (내부 메서드) + */ + private static async getConnectionById(id: number) { + const connections = await query( + `SELECT * FROM external_db_connections WHERE id = $1`, + [id] + ); + + if (connections.length === 0) { + return null; + } + + const connection = connections[0]; + + // 비밀번호 복호화 + if (connection.password) { + try { + const passwordEncryption = new PasswordEncryption(); + connection.password = passwordEncryption.decrypt(connection.password); + } catch (error) { + console.error("비밀번호 복호화 실패:", error); + // 복호화 실패 시 원본 사용 (또는 에러 처리) + } + } + + return connection; + } + + /** + * REST API 데이터 미리보기 + */ + static async previewRestApiData( + apiUrl: string, + apiKey: string, + endpoint: string, + method: "GET" | "POST" | "PUT" | "DELETE" = "GET", + paramInfo?: { + paramType: "url" | "query"; + paramName: string; + paramValue: string; + paramSource: "static" | "dynamic"; + }, + // 👇 body 파라미터 추가 + body?: string + ): Promise> { + try { + // REST API 커넥터 생성 + const connector = new RestApiConnector({ + baseUrl: apiUrl, + apiKey: apiKey, + timeout: 10000, // 미리보기는 짧은 타임아웃 + }); + + // 파라미터 적용 + let finalEndpoint = endpoint; + if ( + paramInfo && + paramInfo.paramName && + paramInfo.paramValue && + paramInfo.paramSource === "static" + ) { + if (paramInfo.paramType === "url") { + finalEndpoint = endpoint.replace( + `{${paramInfo.paramName}}`, + paramInfo.paramValue + ); + } else if (paramInfo.paramType === "query") { + const separator = endpoint.includes("?") ? "&" : "?"; + finalEndpoint = `${endpoint}${separator}${paramInfo.paramName}=${paramInfo.paramValue}`; + } + } + + // JSON body 파싱 + let requestData; + if (body) { + try { + requestData = JSON.parse(body); + } catch (e) { + console.warn("JSON 파싱 실패, 원본 문자열 전송"); + requestData = body; + } + } + + // 데이터 조회 (직접 RestApiConnector 메서드 호출) + // 타입 단언을 사용하여 private/protected 메서드 우회 또는 인터페이스 확장 필요 + // 여기서는 executeRequest가 public이라고 가정 + const result = await (connector as any).executeRequest( + finalEndpoint, + method, + requestData + ); + + return { + success: true, + data: result.data || result, // 데이터가 없으면 전체 결과 반환 + message: "데이터 미리보기 성공", + }; + } catch (error) { + return { + success: false, + message: "데이터 미리보기 실패", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }; + } + } + + /** + * 외부 DB 테이블 데이터 조회 */ static async getDataFromTable( connectionId: number, - tableName: string, - limit: number = 100 + tableName: string ): Promise> { try { - console.log( - `[BatchExternalDbService] 외부 DB 데이터 조회: connectionId=${connectionId}, tableName=${tableName}` - ); - - // 외부 DB 연결 정보 조회 - const connection = await queryOne( - `SELECT * FROM external_db_connections WHERE id = $1`, - [connectionId] - ); - + // 연결 정보 조회 + const connection = await this.getConnectionById(connectionId); if (!connection) { - return { - success: false, - message: "외부 DB 연결을 찾을 수 없습니다.", - }; + throw new Error("외부 DB 연결 정보를 찾을 수 없습니다."); } - // 패스워드 복호화 - const decryptedPassword = PasswordEncryption.decrypt(connection.password); - - // DB 연결 설정 - const config = { - host: connection.host, - port: connection.port, - user: connection.username, - password: decryptedPassword, - database: connection.database_name, - }; - - // DB 커넥터 생성 + // 커넥터 생성 const connector = await DatabaseConnectorFactory.createConnector( - connection.db_type || "postgresql", - config, + connection.db_type, + { + host: connection.host, + port: connection.port, + database: connection.database_name, + user: connection.username, + password: connection.password, + }, connectionId ); - // 데이터 조회 (DB 타입에 따라 쿼리 구문 변경) - let query: string; - const dbType = connection.db_type?.toLowerCase() || "postgresql"; + // 연결 + await connector.connect(); - if (dbType === "oracle") { - query = `SELECT * FROM ${tableName} WHERE ROWNUM <= ${limit}`; - } else { - query = `SELECT * FROM ${tableName} LIMIT ${limit}`; - } - - console.log(`[BatchExternalDbService] 실행할 쿼리: ${query}`); - const result = await connector.executeQuery(query); - - console.log( - `[BatchExternalDbService] 외부 DB 데이터 조회 완료: ${result.rows.length}개 레코드` + // 데이터 조회 (기본 100건) + const result = await connector.executeQuery( + `SELECT * FROM ${tableName} LIMIT 100` ); + // 연결 종료 + await connector.disconnect(); + return { success: true, data: result.rows, + message: `${result.rows.length}개의 데이터를 조회했습니다.`, }; } catch (error) { - console.error( - `외부 DB 데이터 조회 오류 (connectionId: ${connectionId}, table: ${tableName}):`, - error - ); + console.error("테이블 데이터 조회 실패:", error); return { success: false, - message: "외부 DB 데이터 조회 중 오류가 발생했습니다.", + message: "테이블 데이터 조회 중 오류가 발생했습니다.", error: error instanceof Error ? error.message : "알 수 없는 오류", }; } } /** - * 외부 DB 테이블에서 특정 컬럼들만 조회 + * 외부 DB 테이블 데이터 조회 (컬럼 지정) */ static async getDataFromTableWithColumns( connectionId: number, tableName: string, - columns: string[], - limit: number = 100 + columns: string[] ): Promise> { try { - console.log( - `[BatchExternalDbService] 외부 DB 특정 컬럼 조회: connectionId=${connectionId}, tableName=${tableName}, columns=[${columns.join(", ")}]` - ); - - // 외부 DB 연결 정보 조회 - const connection = await queryOne( - `SELECT * FROM external_db_connections WHERE id = $1`, - [connectionId] - ); - + // 연결 정보 조회 + const connection = await this.getConnectionById(connectionId); if (!connection) { - return { - success: false, - message: "외부 DB 연결을 찾을 수 없습니다.", - }; + throw new Error("외부 DB 연결 정보를 찾을 수 없습니다."); } - // 패스워드 복호화 - const decryptedPassword = PasswordEncryption.decrypt(connection.password); - - // DB 연결 설정 - const config = { - host: connection.host, - port: connection.port, - user: connection.username, - password: decryptedPassword, - database: connection.database_name, - }; - - // DB 커넥터 생성 + // 커넥터 생성 const connector = await DatabaseConnectorFactory.createConnector( - connection.db_type || "postgresql", - config, + connection.db_type, + { + host: connection.host, + port: connection.port, + database: connection.database_name, + user: connection.username, + password: connection.password, + }, connectionId ); - // 데이터 조회 (DB 타입에 따라 쿼리 구문 변경) - let query: string; - const dbType = connection.db_type?.toLowerCase() || "postgresql"; - const columnList = columns.join(", "); + // 연결 + await connector.connect(); - if (dbType === "oracle") { - query = `SELECT ${columnList} FROM ${tableName} WHERE ROWNUM <= ${limit}`; - } else { - query = `SELECT ${columnList} FROM ${tableName} LIMIT ${limit}`; - } + // 컬럼 목록 쿼리 구성 + const columnString = columns.join(", "); - console.log(`[BatchExternalDbService] 실행할 쿼리: ${query}`); - const result = await connector.executeQuery(query); - - console.log( - `[BatchExternalDbService] 외부 DB 특정 컬럼 조회 완료: ${result.rows.length}개 레코드` + // 데이터 조회 (기본 100건) + const result = await connector.executeQuery( + `SELECT ${columnString} FROM ${tableName} LIMIT 100` ); + // 연결 종료 + await connector.disconnect(); + return { success: true, data: result.rows, + message: `${result.rows.length}개의 데이터를 조회했습니다.`, }; } catch (error) { - console.error( - `외부 DB 특정 컬럼 조회 오류 (connectionId: ${connectionId}, table: ${tableName}):`, - error - ); + console.error("테이블 데이터 조회 실패:", error); return { success: false, - message: "외부 DB 특정 컬럼 조회 중 오류가 발생했습니다.", + message: "테이블 데이터 조회 중 오류가 발생했습니다.", error: error instanceof Error ? error.message : "알 수 없는 오류", }; } } /** - * 외부 DB 테이블에 데이터 삽입 + * 테이블에 데이터 삽입 */ static async insertDataToTable( connectionId: number, @@ -688,147 +411,79 @@ export class BatchExternalDbService { data: any[] ): Promise> { try { - console.log( - `[BatchExternalDbService] 외부 DB 데이터 삽입: connectionId=${connectionId}, tableName=${tableName}, ${data.length}개 레코드` - ); - - if (!data || data.length === 0) { - return { - success: true, - data: { successCount: 0, failedCount: 0 }, - }; - } - - // 외부 DB 연결 정보 조회 - const connection = await queryOne( - `SELECT * FROM external_db_connections WHERE id = $1`, - [connectionId] - ); - + // 연결 정보 조회 + const connection = await this.getConnectionById(connectionId); if (!connection) { - return { - success: false, - message: "외부 DB 연결을 찾을 수 없습니다.", - }; + throw new Error("외부 DB 연결 정보를 찾을 수 없습니다."); } - // 패스워드 복호화 - const decryptedPassword = PasswordEncryption.decrypt(connection.password); - - // DB 연결 설정 - const config = { - host: connection.host, - port: connection.port, - user: connection.username, - password: decryptedPassword, - database: connection.database_name, - }; - - // DB 커넥터 생성 + // 커넥터 생성 const connector = await DatabaseConnectorFactory.createConnector( - connection.db_type || "postgresql", - config, + connection.db_type, + { + host: connection.host, + port: connection.port, + database: connection.database_name, + user: connection.username, + password: connection.password, + }, connectionId ); + // 연결 + await connector.connect(); + let successCount = 0; let failedCount = 0; - // 각 레코드를 개별적으로 삽입 (UPSERT 방식으로 중복 처리) - for (const record of data) { - try { - const columns = Object.keys(record); - const values = Object.values(record); + // 트랜잭션 시작 (지원하는 경우) + // await connector.beginTransaction(); - // 값들을 SQL 문자열로 변환 (타입별 처리) - const formattedValues = values - .map((value) => { - if (value === null || value === undefined) { - return "NULL"; - } else if (value instanceof Date) { - // Date 객체를 MySQL/MariaDB 형식으로 변환 - return `'${value.toISOString().slice(0, 19).replace("T", " ")}'`; - } else if (typeof value === "string") { - // 문자열이 날짜 형식인지 확인 - const dateRegex = - /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{2}\s+\d{4}\s+\d{2}:\d{2}:\d{2}/; - if (dateRegex.test(value)) { - // JavaScript Date 문자열을 MySQL 형식으로 변환 - const date = new Date(value); - return `'${date.toISOString().slice(0, 19).replace("T", " ")}'`; - } else { - return `'${value.replace(/'/g, "''")}'`; // SQL 인젝션 방지를 위한 간단한 이스케이프 - } - } else if (typeof value === "number") { - return String(value); - } else if (typeof value === "boolean") { - return value ? "1" : "0"; - } else { - // 기타 객체는 문자열로 변환 - return `'${String(value).replace(/'/g, "''")}'`; - } - }) - .join(", "); + try { + // 각 레코드를 개별적으로 삽입 + for (const record of data) { + try { + // 쿼리 빌더 사용 (간단한 구현) + const columns = Object.keys(record); + const values = Object.values(record); + const placeholders = values + .map((_, i) => (connection.db_type === "postgresql" ? `$${i + 1}` : "?")) + .join(", "); - // Primary Key 컬럼 추정 - const primaryKeyColumn = columns.includes("id") - ? "id" - : columns.includes("user_id") - ? "user_id" - : columns[0]; + const query = `INSERT INTO ${tableName} (${columns.join( + ", " + )}) VALUES (${placeholders})`; - // UPDATE SET 절 생성 (Primary Key 제외) - const updateColumns = columns.filter( - (col) => col !== primaryKeyColumn - ); - - let query: string; - const dbType = connection.db_type?.toLowerCase() || "mysql"; - - if (dbType === "mysql" || dbType === "mariadb") { - // MySQL/MariaDB: ON DUPLICATE KEY UPDATE 사용 - if (updateColumns.length > 0) { - const updateSet = updateColumns - .map((col) => `${col} = VALUES(${col})`) - .join(", "); - query = `INSERT INTO ${tableName} (${columns.join(", ")}) VALUES (${formattedValues}) - ON DUPLICATE KEY UPDATE ${updateSet}`; - } else { - // Primary Key만 있는 경우 IGNORE 사용 - query = `INSERT IGNORE INTO ${tableName} (${columns.join(", ")}) VALUES (${formattedValues})`; - } - } else { - // 다른 DB는 기본 INSERT 사용 - query = `INSERT INTO ${tableName} (${columns.join(", ")}) VALUES (${formattedValues})`; + // 파라미터 매핑 (PostgreSQL은 $1, $2..., MySQL은 ?) + await connector.executeQuery(query, values); + successCount++; + } catch (insertError) { + console.error("레코드 삽입 실패:", insertError); + failedCount++; } - - console.log(`[BatchExternalDbService] 실행할 쿼리: ${query}`); - console.log(`[BatchExternalDbService] 삽입할 데이터:`, record); - - await connector.executeQuery(query); - successCount++; - } catch (error) { - console.error(`외부 DB 레코드 UPSERT 실패:`, error); - failedCount++; } - } - console.log( - `[BatchExternalDbService] 외부 DB 데이터 삽입 완료: 성공 ${successCount}개, 실패 ${failedCount}개` - ); + // 트랜잭션 커밋 + // await connector.commit(); + } catch (txError) { + // 트랜잭션 롤백 + // await connector.rollback(); + throw txError; + } finally { + // 연결 종료 + await connector.disconnect(); + } return { success: true, data: { successCount, failedCount }, + message: `${successCount}건 성공, ${failedCount}건 실패`, }; } catch (error) { - console.error( - `외부 DB 데이터 삽입 오류 (connectionId: ${connectionId}, table: ${tableName}):`, - error - ); + console.error("데이터 삽입 실패:", error); return { success: false, - message: "외부 DB 데이터 삽입 중 오류가 발생했습니다.", + message: "데이터 삽입 중 오류가 발생했습니다.", error: error instanceof Error ? error.message : "알 수 없는 오류", }; } @@ -848,7 +503,9 @@ export class BatchExternalDbService { paramType?: "url" | "query", paramName?: string, paramValue?: string, - paramSource?: "static" | "dynamic" + paramSource?: "static" | "dynamic", + // 👇 body 파라미터 추가 + body?: string ): Promise> { try { console.log( @@ -895,47 +552,49 @@ export class BatchExternalDbService { ); } + // 👇 Body 파싱 (POST/PUT 요청 시) + let requestData; + if (body && (method === 'POST' || method === 'PUT')) { + try { + // 템플릿 변수가 있을 수 있으므로 여기서는 원본 문자열을 사용하거나 + // 정적 값만 파싱. 여기서는 일단 정적 JSON으로 가정하고 파싱 시도. + // (BatchScheduler에서 템플릿 처리 후 전달하는 것이 이상적이나, + // 현재 구조상 여기서 파싱 시도하고 실패하면 문자열 그대로 전송) + requestData = JSON.parse(body); + } catch (e) { + console.warn("JSON 파싱 실패, 원본 문자열 전송"); + requestData = body; + } + } + // 데이터 조회 (REST API는 executeRequest 사용) let result; if ((connector as any).executeRequest) { - result = await (connector as any).executeRequest(finalEndpoint, method); + // executeRequest(endpoint, method, data) + result = await (connector as any).executeRequest( + finalEndpoint, + method, + requestData // body 전달 + ); } else { + // Fallback (GET only) result = await connector.executeQuery(finalEndpoint); } - let data = result.rows; - // 컬럼 필터링 (지정된 컬럼만 추출) - if (columns && columns.length > 0) { - data = data.map((row: any) => { - const filteredRow: any = {}; - columns.forEach((col) => { - if (row.hasOwnProperty(col)) { - filteredRow[col] = row[col]; - } - }); - return filteredRow; - }); + let data = result.rows || result.data || result; + + // 👇 단일 객체 응답(토큰 등)인 경우 배열로 래핑하여 리스트처럼 처리 + if (!Array.isArray(data)) { + data = [data]; } - // 제한 개수 적용 - if (limit > 0) { - data = data.slice(0, limit); - } - - logger.info( - `[BatchExternalDbService] REST API 데이터 조회 완료: ${data.length}개 레코드` - ); - logger.info(`[BatchExternalDbService] 조회된 데이터`, { data }); - return { success: true, data: data, + message: `${data.length}개의 데이터를 조회했습니다.`, }; } catch (error) { - console.error( - `[BatchExternalDbService] REST API 데이터 조회 오류 (${apiUrl}${endpoint}):`, - error - ); + console.error("REST API 데이터 조회 실패:", error); return { success: false, message: "REST API 데이터 조회 중 오류가 발생했습니다.", @@ -1035,16 +694,15 @@ export class BatchExternalDbService { urlPathColumn && record[urlPathColumn] ) { - // /api/users → /api/users/user123 - finalEndpoint = `${endpoint}/${record[urlPathColumn]}`; + // endpoint 마지막에 ID 추가 (예: /api/users -> /api/users/123) + // 이미 /로 끝나는지 확인 + const separator = finalEndpoint.endsWith("/") ? "" : "/"; + finalEndpoint = `${finalEndpoint}${separator}${record[urlPathColumn]}`; + + console.log(`[BatchExternalDbService] 동적 엔드포인트: ${finalEndpoint}`); } - console.log( - `[BatchExternalDbService] 실행할 API 호출: ${method} ${finalEndpoint}` - ); - console.log(`[BatchExternalDbService] 전송할 데이터:`, requestData); - - // REST API는 executeRequest 사용 + // 데이터 전송 (REST API는 executeRequest 사용) if ((connector as any).executeRequest) { await (connector as any).executeRequest( finalEndpoint, @@ -1052,101 +710,32 @@ export class BatchExternalDbService { requestData ); } else { - await connector.executeQuery(finalEndpoint); + // Fallback + // @ts-ignore + await connector.httpClient.request({ + method: method, + url: finalEndpoint, + data: requestData + }); } + successCount++; - } catch (error) { - console.error(`REST API 레코드 전송 실패:`, error); + } catch (sendError) { + console.error("데이터 전송 실패:", sendError); failedCount++; } } - console.log( - `[BatchExternalDbService] 템플릿 기반 REST API 데이터 전송 완료: 성공 ${successCount}개, 실패 ${failedCount}개` - ); - return { success: true, data: { successCount, failedCount }, + message: `${successCount}건 성공, ${failedCount}건 실패`, }; } catch (error) { - console.error( - `[BatchExternalDbService] 템플릿 기반 REST API 데이터 전송 오류:`, - error - ); + console.error("데이터 전송 실패:", error); return { success: false, - message: `REST API 데이터 전송 실패: ${error}`, - data: { successCount: 0, failedCount: 0 }, - }; - } - } - - /** - * REST API로 데이터 전송 (기존 메서드) - */ - static async sendDataToRestApi( - apiUrl: string, - apiKey: string, - endpoint: string, - method: "POST" | "PUT" = "POST", - data: any[] - ): Promise> { - try { - console.log( - `[BatchExternalDbService] REST API 데이터 전송: ${apiUrl}${endpoint}, ${data.length}개 레코드` - ); - - // REST API 커넥터 생성 - const connector = new RestApiConnector({ - baseUrl: apiUrl, - apiKey: apiKey, - timeout: 30000, - }); - - // 연결 테스트 - await connector.connect(); - - let successCount = 0; - let failedCount = 0; - - // 각 레코드를 개별적으로 전송 - for (const record of data) { - try { - console.log( - `[BatchExternalDbService] 실행할 API 호출: ${method} ${endpoint}` - ); - console.log(`[BatchExternalDbService] 전송할 데이터:`, record); - - // REST API는 executeRequest 사용 - if ((connector as any).executeRequest) { - await (connector as any).executeRequest(endpoint, method, record); - } else { - await connector.executeQuery(endpoint); - } - successCount++; - } catch (error) { - console.error(`REST API 레코드 전송 실패:`, error); - failedCount++; - } - } - - console.log( - `[BatchExternalDbService] REST API 데이터 전송 완료: 성공 ${successCount}개, 실패 ${failedCount}개` - ); - - return { - success: true, - data: { successCount, failedCount }, - }; - } catch (error) { - console.error( - `[BatchExternalDbService] REST API 데이터 전송 오류 (${apiUrl}${endpoint}):`, - error - ); - return { - success: false, - message: "REST API 데이터 전송 중 오류가 발생했습니다.", + message: "데이터 전송 중 오류가 발생했습니다.", error: error instanceof Error ? error.message : "알 수 없는 오류", }; } diff --git a/backend-node/src/services/batchSchedulerService.ts b/backend-node/src/services/batchSchedulerService.ts index 77863904..5648b3a9 100644 --- a/backend-node/src/services/batchSchedulerService.ts +++ b/backend-node/src/services/batchSchedulerService.ts @@ -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 = new Map(); - private static isInitialized = false; - private static executingBatches: Set = 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( - `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( - `SELECT - bc.*, - json_agg( - json_build_object( - 'id', bm.id, - 'batch_config_id', bm.batch_config_id, - 'from_connection_type', bm.from_connection_type, - 'from_connection_id', bm.from_connection_id, - 'from_table_name', bm.from_table_name, - 'from_column_name', bm.from_column_name, - 'from_column_type', bm.from_column_type, - 'to_connection_type', bm.to_connection_type, - 'to_connection_id', bm.to_connection_id, - 'to_table_name', bm.to_table_name, - 'to_column_name', bm.to_column_name, - 'to_column_type', bm.to_column_type, - 'mapping_order', bm.mapping_order, - 'from_api_url', bm.from_api_url, - 'from_api_key', bm.from_api_key, - 'from_api_method', bm.from_api_method, - 'from_api_param_type', bm.from_api_param_type, - 'from_api_param_name', bm.from_api_param_name, - 'from_api_param_value', bm.from_api_param_value, - 'from_api_param_source', bm.from_api_param_source, - 'to_api_url', bm.to_api_url, - 'to_api_key', bm.to_api_key, - 'to_api_method', bm.to_api_method, - 'to_api_body', bm.to_api_body - ) - ) FILTER (WHERE bm.id IS NOT NULL) as batch_mappings - FROM batch_configs bc - LEFT JOIN batch_mappings bm ON bc.id = bm.batch_config_id - WHERE bc.id = $1 - GROUP BY bc.id`, - [configId] - ); - - const config = configResult[0] || null; - - if (!config) { - logger.warn(`배치 설정을 찾을 수 없습니다: ID ${configId}`); + const result = await BatchService.getBatchConfigById(configId); + if (!result.success || !result.data) { + // 설정이 없으면 스케줄 제거 + if (this.scheduledTasks.has(configId)) { + this.scheduledTasks.get(configId)?.stop(); + this.scheduledTasks.delete(configId); + } return; } - // 활성화된 배치만 다시 스케줄 등록 - if (config.is_active === "Y") { - await this.scheduleBatchConfig(config); - logger.info( - `배치 스케줄 업데이트 완료: ${config.batch_name} (ID: ${configId})` - ); + const config = result.data; - // 활성화 시 즉시 실행 (옵션) - if (executeImmediately) { - logger.info( - `🚀 배치 활성화 즉시 실행: ${config.batch_name} (ID: ${configId})` - ); - await this.executeBatchConfig(config); - } - } else { - logger.info( - `비활성화된 배치 스케줄 제거: ${config.batch_name} (ID: ${configId})` + // 스케줄 재등록 + await this.scheduleBatch(config); + + // 즉시 실행 옵션이 있으면 실행 + /* + if (executeImmediately && config.is_active === "Y") { + logger.info(`배치 설정 변경 후 즉시 실행: ${config.batch_name}`); + this.executeBatchConfig(config).catch((err) => + logger.error(`즉시 실행 중 오류 발생:`, err) ); } + */ } catch (error) { logger.error(`배치 스케줄 업데이트 실패: ID ${configId}`, error); } @@ -272,6 +128,7 @@ export class BatchSchedulerService { const executionLogResponse = await BatchExecutionLogService.createExecutionLog({ batch_config_id: config.id, + company_code: config.company_code, execution_status: "RUNNING", start_time: startTime, total_records: 0, @@ -313,21 +170,20 @@ export class BatchSchedulerService { // 성공 결과 반환 return result; } catch (error) { - logger.error(`배치 실행 실패: ${config.batch_name}`, error); + logger.error(`배치 실행 중 오류 발생: ${config.batch_name}`, error); // 실행 로그 업데이트 (실패) if (executionLog) { await BatchExecutionLogService.updateExecutionLog(executionLog.id, { - execution_status: "FAILED", + execution_status: "FAILURE", end_time: new Date(), duration_ms: Date.now() - startTime.getTime(), error_message: error instanceof Error ? error.message : "알 수 없는 오류", - error_details: error instanceof Error ? error.stack : String(error), }); } - // 실패 시에도 결과 반환 + // 실패 결과 반환 return { totalRecords: 0, successRecords: 0, @@ -379,6 +235,8 @@ export class BatchSchedulerService { const { BatchExternalDbService } = await import( "./batchExternalDbService" ); + + // 👇 Body 파라미터 추가 (POST 요청 시) const apiResult = await BatchExternalDbService.getDataFromRestApi( firstMapping.from_api_url!, firstMapping.from_api_key!, @@ -394,7 +252,9 @@ export class BatchSchedulerService { firstMapping.from_api_param_type, firstMapping.from_api_param_name, firstMapping.from_api_param_value, - firstMapping.from_api_param_source + firstMapping.from_api_param_source, + // 👇 Body 전달 (FROM - REST API - POST 요청) + firstMapping.from_api_body ); if (apiResult.success && apiResult.data) { @@ -416,6 +276,17 @@ export class BatchSchedulerService { totalRecords += fromData.length; // 컬럼 매핑 적용하여 TO 테이블 형식으로 변환 + // 유틸리티 함수: 점 표기법을 사용하여 중첩된 객체 값 가져오기 + const getValueByPath = (obj: any, path: string) => { + if (!path) return undefined; + // path가 'response.access_token' 처럼 점을 포함하는 경우 + if (path.includes(".")) { + return path.split(".").reduce((acc, part) => acc && acc[part], obj); + } + // 단순 키인 경우 + return obj[path]; + }; + const mappedData = fromData.map((row) => { const mappedRow: any = {}; for (const mapping of mappings) { @@ -428,8 +299,11 @@ export class BatchSchedulerService { mappedRow[mapping.from_column_name] = row[mapping.from_column_name]; } else { - // 기존 로직: to_column_name을 키로 사용 - mappedRow[mapping.to_column_name] = row[mapping.from_column_name]; + // REST API -> DB (POST 요청 포함) 또는 DB -> DB + // row[mapping.from_column_name] 대신 getValueByPath 사용 + const value = getValueByPath(row, mapping.from_column_name); + + mappedRow[mapping.to_column_name] = value; } } return mappedRow; @@ -482,22 +356,12 @@ export class BatchSchedulerService { ); } } else { - // 기존 REST API 전송 (REST API → DB 배치) - const apiResult = await BatchExternalDbService.sendDataToRestApi( - firstMapping.to_api_url!, - firstMapping.to_api_key!, - firstMapping.to_table_name, - (firstMapping.to_api_method as "POST" | "PUT") || "POST", - mappedData + // 기존 REST API 전송 (REST API → DB 배치) - 사실 이 경우는 거의 없음 (REST to REST) + // 지원하지 않음 + logger.warn( + "REST API -> REST API (단순 매핑)은 아직 지원하지 않습니다." ); - - if (apiResult.success && apiResult.data) { - insertResult = apiResult.data; - } else { - throw new Error( - `REST API 데이터 전송 실패: ${apiResult.message}` - ); - } + insertResult = { successCount: 0, failedCount: 0 }; } } else { // DB에 데이터 삽입 @@ -511,167 +375,13 @@ export class BatchSchedulerService { successRecords += insertResult.successCount; failedRecords += insertResult.failedCount; - - logger.info( - `테이블 처리 완료: ${insertResult.successCount}개 성공, ${insertResult.failedCount}개 실패` - ); } catch (error) { - logger.error(`테이블 처리 실패: ${tableKey}`, error); - failedRecords += 1; + logger.error(`테이블 처리 중 오류 발생: ${tableKey}`, error); + // 해당 테이블 처리 실패는 전체 실패로 간주하지 않고, 실패 카운트만 증가? + // 여기서는 일단 실패 로그만 남기고 계속 진행 (필요시 정책 변경) } } return { totalRecords, successRecords, failedRecords }; } - - /** - * 배치 매핑 처리 (기존 메서드 - 사용 안 함) - */ - private static async processBatchMappings(config: any) { - const { batch_mappings } = config; - let totalRecords = 0; - let successRecords = 0; - let failedRecords = 0; - - if (!batch_mappings || batch_mappings.length === 0) { - logger.warn(`배치 매핑이 없습니다: ${config.batch_name}`); - return { totalRecords, successRecords, failedRecords }; - } - - for (const mapping of batch_mappings) { - try { - logger.info( - `매핑 처리 시작: ${mapping.from_table_name} -> ${mapping.to_table_name}` - ); - - // FROM 테이블에서 데이터 조회 - const fromData = await this.getDataFromSource(mapping); - totalRecords += fromData.length; - - // TO 테이블에 데이터 삽입 - const insertResult = await this.insertDataToTarget(mapping, fromData); - successRecords += insertResult.successCount; - failedRecords += insertResult.failedCount; - - logger.info( - `매핑 처리 완료: ${insertResult.successCount}개 성공, ${insertResult.failedCount}개 실패` - ); - } catch (error) { - logger.error( - `매핑 처리 실패: ${mapping.from_table_name} -> ${mapping.to_table_name}`, - error - ); - failedRecords += 1; - } - } - - return { totalRecords, successRecords, failedRecords }; - } - - /** - * FROM 테이블에서 데이터 조회 - */ - private static async getDataFromSource(mapping: any) { - try { - if (mapping.from_connection_type === "internal") { - // 내부 DB에서 조회 - const result = await query( - `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()); - } } diff --git a/backend-node/src/services/batchService.ts b/backend-node/src/services/batchService.ts index 247b1ab8..41f20964 100644 --- a/backend-node/src/services/batchService.ts +++ b/backend-node/src/services/batchService.ts @@ -65,62 +65,43 @@ export class BatchService { const limit = filter.limit || 10; const offset = (page - 1) * limit; - // 배치 설정 조회 (매핑 포함 - 서브쿼리 사용) - const batchConfigs = await query( - `SELECT bc.id, bc.batch_name, bc.description, bc.cron_schedule, - bc.is_active, bc.company_code, bc.created_date, bc.created_by, - bc.updated_date, bc.updated_by, - COALESCE( - 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, - '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, - 'mapping_order', bm.mapping_order - ) - ) FILTER (WHERE bm.id IS NOT NULL), - '[]' - ) as batch_mappings + // 전체 카운트 조회 + const countResult = await query<{ count: string }>( + `SELECT COUNT(*) as count FROM batch_configs bc ${whereClause}`, + values + ); + const total = parseInt(countResult[0].count); + const totalPages = Math.ceil(total / limit); + + // 목록 조회 + const configs = await query( + `SELECT bc.* FROM batch_configs bc - LEFT JOIN batch_mappings bm ON bc.id = bm.batch_config_id ${whereClause} - GROUP BY bc.id - ORDER BY bc.is_active DESC, bc.batch_name ASC - LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, + ORDER BY bc.created_date DESC + LIMIT $${paramIndex++} OFFSET $${paramIndex++}`, [...values, limit, offset] ); - // 전체 개수 조회 - const countResult = await queryOne<{ count: string }>( - `SELECT COUNT(DISTINCT bc.id) as count - FROM batch_configs bc - ${whereClause}`, - values - ); - - const total = parseInt(countResult?.count || "0"); + // 매핑 정보 조회 (N+1 문제 해결을 위해 별도 쿼리 대신 여기서는 생략하고 상세 조회에서 처리) + // 하지만 목록에서도 간단한 정보는 필요할 수 있음 return { success: true, - data: batchConfigs as BatchConfig[], + data: configs as BatchConfig[], pagination: { page, limit, total, - totalPages: Math.ceil(total / limit), + totalPages, }, + message: `${configs.length}개의 배치 설정을 조회했습니다.`, }; } catch (error) { console.error("배치 설정 목록 조회 오류:", error); return { success: false, + data: [], message: "배치 설정 목록 조회에 실패했습니다.", error: error instanceof Error ? error.message : "알 수 없는 오류", }; @@ -128,70 +109,56 @@ export class BatchService { } /** - * 특정 배치 설정 조회 (회사별) + * 특정 배치 설정 조회 (별칭) + */ + static async getBatchConfig(id: number): Promise { + const result = await this.getBatchConfigById(id); + if (!result.success || !result.data) { + return null; + } + return result.data; + } + + /** + * 배치 설정 상세 조회 */ static async getBatchConfigById( - id: number, - userCompanyCode?: string + id: number ): Promise> { try { - let query = `SELECT bc.id, bc.batch_name, bc.description, bc.cron_schedule, - bc.is_active, bc.company_code, bc.created_date, bc.created_by, - bc.updated_date, bc.updated_by, - COALESCE( - 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 - ) - ORDER BY bm.from_table_name ASC, bm.from_column_name ASC, bm.mapping_order ASC - ) 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`; + // 배치 설정 조회 + const config = await queryOne( + `SELECT * FROM batch_configs WHERE id = $1`, + [id] + ); - const params: any[] = [id]; - let paramIndex = 2; - - // 회사별 필터링 (최고 관리자가 아닌 경우) - if (userCompanyCode && userCompanyCode !== "*") { - query += ` AND bc.company_code = $${paramIndex}`; - params.push(userCompanyCode); - } - - query += ` GROUP BY bc.id`; - - const batchConfig = await queryOne(query, params); - - if (!batchConfig) { + if (!config) { return { success: false, - message: "배치 설정을 찾을 수 없거나 권한이 없습니다.", + message: "배치 설정을 찾을 수 없습니다.", }; } + // 매핑 정보 조회 + const mappings = await query( + `SELECT * FROM batch_mappings WHERE batch_config_id = $1 ORDER BY mapping_order ASC`, + [id] + ); + + const batchConfig: BatchConfig = { + ...config, + batch_mappings: mappings, + } as BatchConfig; + return { success: true, - data: batchConfig as BatchConfig, + data: batchConfig, }; } catch (error) { - console.error("배치 설정 조회 오류:", error); + console.error("배치 설정 상세 조회 오류:", error); return { success: false, - message: "배치 설정 조회에 실패했습니다.", + message: "배치 설정 상세 조회에 실패했습니다.", error: error instanceof Error ? error.message : "알 수 없는 오류", }; } @@ -210,10 +177,17 @@ export class BatchService { // 배치 설정 생성 const batchConfigResult = await client.query( `INSERT INTO batch_configs - (batch_name, description, cron_schedule, created_by, updated_by, created_date, updated_date) - VALUES ($1, $2, $3, $4, $5, NOW(), NOW()) + (batch_name, description, cron_schedule, is_active, company_code, created_by, created_date, updated_date) + VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW()) RETURNING *`, - [data.batchName, data.description, data.cronSchedule, userId, userId] + [ + data.batchName, + data.description, + data.cronSchedule, + data.isActive || "Y", + data.companyCode, + userId, + ] ); const batchConfig = batchConfigResult.rows[0]; @@ -224,39 +198,41 @@ export class BatchService { const mapping = data.mappings[index]; const mappingResult = await client.query( `INSERT INTO batch_mappings - (batch_config_id, from_connection_type, from_connection_id, from_table_name, from_column_name, + (batch_config_id, company_code, from_connection_type, from_connection_id, from_table_name, from_column_name, from_column_type, from_api_url, from_api_key, from_api_method, from_api_param_type, - from_api_param_name, from_api_param_value, from_api_param_source, + from_api_param_name, from_api_param_value, from_api_param_source, from_api_body, to_connection_type, to_connection_id, to_table_name, to_column_name, to_column_type, to_api_url, to_api_key, to_api_method, to_api_body, mapping_order, created_by, created_date) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, NOW()) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, NOW()) RETURNING *`, [ - batchConfig.id, - mapping.from_connection_type, - mapping.from_connection_id, - mapping.from_table_name, - mapping.from_column_name, - mapping.from_column_type, - mapping.from_api_url, - mapping.from_api_key, - mapping.from_api_method, - mapping.from_api_param_type, - mapping.from_api_param_name, - mapping.from_api_param_value, - mapping.from_api_param_source, - mapping.to_connection_type, - mapping.to_connection_id, - mapping.to_table_name, - mapping.to_column_name, - mapping.to_column_type, - mapping.to_api_url, - mapping.to_api_key, - mapping.to_api_method, - mapping.to_api_body, - mapping.mapping_order || index + 1, - userId, - ] + batchConfig.id, + data.companyCode, // 멀티테넌시: 배치 설정과 동일한 company_code 사용 + mapping.from_connection_type, + mapping.from_connection_id, + mapping.from_table_name, + mapping.from_column_name, + mapping.from_column_type, + mapping.from_api_url, + mapping.from_api_key, + mapping.from_api_method, + mapping.from_api_param_type, + mapping.from_api_param_name, + mapping.from_api_param_value, + mapping.from_api_param_source, + mapping.from_api_body, // FROM REST API Body + mapping.to_connection_type, + mapping.to_connection_id, + mapping.to_table_name, + mapping.to_column_name, + mapping.to_column_type, + mapping.to_api_url, + mapping.to_api_key, + mapping.to_api_method, + mapping.to_api_body, + mapping.mapping_order || index + 1, + userId, + ] ); mappings.push(mappingResult.rows[0]); } @@ -292,35 +268,22 @@ export class BatchService { userCompanyCode?: string ): Promise> { try { - // 기존 배치 설정 확인 (회사 권한 체크 포함) - const existing = await this.getBatchConfigById(id, userCompanyCode); - if (!existing.success) { - return existing; + // 기존 설정 확인 + const existingResult = await this.getBatchConfigById(id); + if (!existingResult.success || !existingResult.data) { + throw new Error( + existingResult.message || "배치 설정을 찾을 수 없습니다." + ); } + const existingConfig = existingResult.data; - const existingConfig = await queryOne( - `SELECT bc.*, - COALESCE( - json_agg( - json_build_object( - 'id', bm.id, - 'batch_config_id', bm.batch_config_id - ) - ) 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`, - [id] - ); - - if (!existingConfig) { - return { - success: false, - message: "배치 설정을 찾을 수 없습니다.", - }; + // 권한 체크 (회사 코드가 다르면 수정 불가) + if ( + userCompanyCode && + userCompanyCode !== "*" && + existingConfig.company_code !== userCompanyCode + ) { + throw new Error("수정 권한이 없습니다."); } // 트랜잭션으로 업데이트 @@ -373,15 +336,16 @@ export class BatchService { const mapping = data.mappings[index]; const mappingResult = await client.query( `INSERT INTO batch_mappings - (batch_config_id, from_connection_type, from_connection_id, from_table_name, from_column_name, + (batch_config_id, company_code, from_connection_type, from_connection_id, from_table_name, from_column_name, from_column_type, from_api_url, from_api_key, from_api_method, from_api_param_type, - from_api_param_name, from_api_param_value, from_api_param_source, + from_api_param_name, from_api_param_value, from_api_param_source, from_api_body, to_connection_type, to_connection_id, to_table_name, to_column_name, to_column_type, to_api_url, to_api_key, to_api_method, to_api_body, mapping_order, created_by, created_date) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, NOW()) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, NOW()) RETURNING *`, [ id, + existingConfig.company_code, // 기존 설정의 company_code 유지 mapping.from_connection_type, mapping.from_connection_id, mapping.from_table_name, @@ -394,6 +358,7 @@ export class BatchService { mapping.from_api_param_name, mapping.from_api_param_value, mapping.from_api_param_source, + mapping.from_api_body, // FROM REST API Body mapping.to_connection_type, mapping.to_connection_id, mapping.to_table_name, @@ -446,38 +411,26 @@ export class BatchService { userCompanyCode?: string ): Promise> { try { - // 기존 배치 설정 확인 (회사 권한 체크 포함) - const existing = await this.getBatchConfigById(id, userCompanyCode); - if (!existing.success) { - return { - success: false, - message: existing.message, - }; - } - - const existingConfig = await queryOne( - `SELECT * FROM batch_configs WHERE id = $1`, - [id] - ); - - if (!existingConfig) { - return { - success: false, - message: "배치 설정을 찾을 수 없습니다.", - }; - } - - // 트랜잭션으로 삭제 - await transaction(async (client) => { - // 배치 매핑 먼저 삭제 (외래키 제약) - await client.query( - `DELETE FROM batch_mappings WHERE batch_config_id = $1`, - [id] + // 기존 설정 확인 + const existingResult = await this.getBatchConfigById(id); + if (!existingResult.success || !existingResult.data) { + throw new Error( + existingResult.message || "배치 설정을 찾을 수 없습니다." ); + } + const existingConfig = existingResult.data; - // 배치 설정 삭제 - await client.query(`DELETE FROM batch_configs WHERE id = $1`, [id]); - }); + // 권한 체크 + if ( + userCompanyCode && + userCompanyCode !== "*" && + existingConfig.company_code !== userCompanyCode + ) { + throw new Error("삭제 권한이 없습니다."); + } + + // 물리 삭제 (CASCADE 설정에 따라 매핑도 삭제됨) + await query(`DELETE FROM batch_configs WHERE id = $1`, [id]); return { success: true, @@ -494,93 +447,51 @@ export class BatchService { } /** - * 사용 가능한 커넥션 목록 조회 + * DB 연결 정보 조회 */ - static async getAvailableConnections(): Promise< - ApiResponse - > { + static async getConnections(): Promise> { try { - const connections: ConnectionInfo[] = []; - - // 내부 DB 추가 - connections.push({ - type: "internal", - name: "Internal Database", - db_type: "postgresql", - }); - - // 외부 DB 연결 조회 - const externalConnections = - await BatchExternalDbService.getAvailableConnections(); - - if (externalConnections.success && externalConnections.data) { - externalConnections.data.forEach((conn) => { - connections.push({ - type: "external", - id: conn.id, - name: conn.name, - db_type: conn.db_type, - }); - }); - } - - return { - success: true, - data: connections, - }; + // BatchExternalDbService 사용 + const result = await BatchExternalDbService.getAvailableConnections(); + return result; } catch (error) { - console.error("커넥션 목록 조회 오류:", error); + console.error("DB 연결 목록 조회 오류:", error); return { success: false, - message: "커넥션 목록 조회에 실패했습니다.", + data: [], + message: "DB 연결 목록 조회에 실패했습니다.", error: error instanceof Error ? error.message : "알 수 없는 오류", }; } } /** - * 특정 커넥션의 테이블 목록 조회 + * 테이블 목록 조회 */ - static async getTablesFromConnection( + static async getTables( connectionType: "internal" | "external", connectionId?: number ): Promise> { try { - let tables: TableInfo[] = []; - if (connectionType === "internal") { // 내부 DB 테이블 조회 - const result = await query<{ table_name: string }>( - `SELECT table_name - FROM information_schema.tables - WHERE table_schema = 'public' - AND table_type = 'BASE TABLE' - ORDER BY table_name` - ); - tables = result.map((row) => ({ - table_name: row.table_name, - columns: [], - })); - } else if (connectionType === "external" && connectionId) { + const tables = await DbConnectionManager.getInternalTables(); + return { + success: true, + data: tables, + message: `${tables.length}개의 테이블을 조회했습니다.`, + }; + } else if (connectionId) { // 외부 DB 테이블 조회 - const tablesResult = - await BatchExternalDbService.getTablesFromConnection( - connectionType, - connectionId - ); - if (tablesResult.success && tablesResult.data) { - tables = tablesResult.data; - } + return await BatchExternalDbService.getTables(connectionId); + } else { + throw new Error("외부 연결 ID가 필요합니다."); } - - return { - success: true, - data: tables, - }; } catch (error) { console.error("테이블 목록 조회 오류:", error); return { success: false, + data: [], message: "테이블 목록 조회에 실패했습니다.", error: error instanceof Error ? error.message : "알 수 없는 오류", }; @@ -588,185 +499,133 @@ export class BatchService { } /** - * 특정 테이블의 컬럼 정보 조회 + * 컬럼 목록 조회 */ - static async getTableColumns( + static async getColumns( + tableName: string, connectionType: "internal" | "external", - connectionId: number | undefined, - tableName: string + connectionId?: number ): Promise> { try { - console.log(`[BatchService] getTableColumns 호출:`, { - connectionType, - connectionId, - tableName, - }); - - let columns: ColumnInfo[] = []; - if (connectionType === "internal") { // 내부 DB 컬럼 조회 - console.log(`[BatchService] 내부 DB 컬럼 조회 시작: ${tableName}`); - - const result = await query<{ - column_name: string; - data_type: string; - is_nullable: string; - column_default: string | null; - }>( - `SELECT - column_name, - data_type, - is_nullable, - column_default - FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = $1 - ORDER BY ordinal_position`, - [tableName] - ); - - console.log(`[BatchService] 내부 DB 컬럼 조회 결과:`, result); - - columns = result.map((row) => ({ - column_name: row.column_name, - data_type: row.data_type, - is_nullable: row.is_nullable, - column_default: row.column_default, - })); - } else if (connectionType === "external" && connectionId) { + const columns = await DbConnectionManager.getInternalColumns(tableName); + return { + success: true, + data: columns, + message: `${columns.length}개의 컬럼을 조회했습니다.`, + }; + } else if (connectionId) { // 외부 DB 컬럼 조회 - console.log( - `[BatchService] 외부 DB 컬럼 조회 시작: connectionId=${connectionId}, tableName=${tableName}` - ); - - const columnsResult = await BatchExternalDbService.getTableColumns( - connectionType, - connectionId, - tableName - ); - - console.log(`[BatchService] 외부 DB 컬럼 조회 결과:`, columnsResult); - - if (columnsResult.success && columnsResult.data) { - columns = columnsResult.data; - } - - console.log(`[BatchService] 외부 DB 컬럼:`, columns); + return await BatchExternalDbService.getColumns(connectionId, tableName); + } else { + throw new Error("외부 연결 ID가 필요합니다."); } - - return { - success: true, - data: columns, - }; } catch (error) { - console.error("컬럼 정보 조회 오류:", error); + console.error("컬럼 목록 조회 오류:", error); return { success: false, - message: "컬럼 정보 조회에 실패했습니다.", + data: [], + message: "컬럼 목록 조회에 실패했습니다.", error: error instanceof Error ? error.message : "알 수 없는 오류", }; } } /** - * 배치 실행 로그 생성 + * 데이터 미리보기 */ - static async createExecutionLog(data: { - batch_config_id: number; - execution_status: string; - start_time: Date; - total_records: number; - success_records: number; - failed_records: number; - }): Promise { + static async previewData( + tableName: string, + connectionType: "internal" | "external", + connectionId?: number + ): Promise> { try { - const executionLog = await queryOne( - `INSERT INTO batch_execution_logs - (batch_config_id, execution_status, start_time, total_records, success_records, failed_records) - VALUES ($1, $2, $3, $4, $5, $6) - RETURNING *`, - [ - data.batch_config_id, - data.execution_status, - data.start_time, - data.total_records, - data.success_records, - data.failed_records, - ] - ); - - return executionLog; + if (connectionType === "internal") { + // 내부 DB 데이터 조회 + const data = await DbConnectionManager.getInternalData(tableName, 10); + return { + success: true, + data, + message: "데이터 미리보기 성공", + }; + } else if (connectionId) { + // 외부 DB 데이터 조회 + return await BatchExternalDbService.getDataFromTable( + connectionId, + tableName + ); + } else { + throw new Error("외부 연결 ID가 필요합니다."); + } } catch (error) { - console.error("배치 실행 로그 생성 오류:", error); - throw error; + console.error("데이터 미리보기 오류:", error); + return { + success: false, + data: [], + message: "데이터 미리보기에 실패했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }; } } /** - * 배치 실행 로그 업데이트 + * REST API 데이터 미리보기 */ - static async updateExecutionLog( - id: number, - data: { - execution_status?: string; - end_time?: Date; - duration_ms?: number; - total_records?: number; - success_records?: number; - failed_records?: number; - error_message?: string; - } - ): Promise { + static async previewRestApiData( + apiUrl: string, + apiKey: string, + endpoint: string, + method: "GET" | "POST" | "PUT" | "DELETE" = "GET", + paramInfo?: { + paramType: "url" | "query"; + paramName: string; + paramValue: string; + paramSource: "static" | "dynamic"; + }, + body?: string + ): Promise> { try { - // 동적 UPDATE 쿼리 생성 - const updateFields: string[] = []; - const values: any[] = []; - let paramIndex = 1; - - if (data.execution_status !== undefined) { - updateFields.push(`execution_status = $${paramIndex++}`); - values.push(data.execution_status); - } - if (data.end_time !== undefined) { - updateFields.push(`end_time = $${paramIndex++}`); - values.push(data.end_time); - } - if (data.duration_ms !== undefined) { - updateFields.push(`duration_ms = $${paramIndex++}`); - values.push(data.duration_ms); - } - if (data.total_records !== undefined) { - updateFields.push(`total_records = $${paramIndex++}`); - values.push(data.total_records); - } - if (data.success_records !== undefined) { - updateFields.push(`success_records = $${paramIndex++}`); - values.push(data.success_records); - } - if (data.failed_records !== undefined) { - updateFields.push(`failed_records = $${paramIndex++}`); - values.push(data.failed_records); - } - if (data.error_message !== undefined) { - updateFields.push(`error_message = $${paramIndex++}`); - values.push(data.error_message); - } - - if (updateFields.length > 0) { - await query( - `UPDATE batch_execution_logs - SET ${updateFields.join(", ")} - WHERE id = $${paramIndex}`, - [...values, id] - ); - } + return await BatchExternalDbService.previewRestApiData( + apiUrl, + apiKey, + endpoint, + method, + paramInfo, + body + ); } catch (error) { - console.error("배치 실행 로그 업데이트 오류:", error); - throw error; + console.error("REST API 미리보기 오류:", error); + return { + success: false, + message: "REST API 미리보기에 실패했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }; } } + /** + * 배치 유효성 검사 + */ + static async validateBatch( + config: Partial + ): Promise { + const errors: string[] = []; + + if (!config.batchName) errors.push("배치 작업명이 필요합니다."); + if (!config.cronSchedule) errors.push("Cron 스케줄이 필요합니다."); + if (!config.mappings || config.mappings.length === 0) { + errors.push("최소 하나 이상의 매핑이 필요합니다."); + } + + // 추가 유효성 검사 로직... + + return { + isValid: errors.length === 0, + errors, + }; + } + /** * 테이블에서 데이터 조회 (연결 타입에 따라 내부/외부 DB 구분) */ @@ -824,42 +683,33 @@ export class BatchService { ): Promise { try { console.log( - `[BatchService] 테이블에서 특정 컬럼 데이터 조회: ${tableName} (${columns.join(", ")}) (${connectionType}${connectionId ? `:${connectionId}` : ""})` + `[BatchService] 테이블에서 컬럼 지정 데이터 조회: ${tableName} (${connectionType})` ); if (connectionType === "internal") { - // 내부 DB에서 특정 컬럼만 조회 (주의: SQL 인젝션 위험 - 실제 프로덕션에서는 테이블명/컬럼명 검증 필요) - const columnList = columns.join(", "); + // 내부 DB + const columnString = columns.join(", "); const result = await query( - `SELECT ${columnList} FROM ${tableName} LIMIT 100` - ); - console.log( - `[BatchService] 내부 DB 특정 컬럼 조회 결과: ${result.length}개 레코드` + `SELECT ${columnString} FROM ${tableName} LIMIT 100` ); return result; } else if (connectionType === "external" && connectionId) { - // 외부 DB에서 특정 컬럼만 조회 + // 외부 DB const result = await BatchExternalDbService.getDataFromTableWithColumns( connectionId, tableName, columns ); if (result.success && result.data) { - console.log( - `[BatchService] 외부 DB 특정 컬럼 조회 결과: ${result.data.length}개 레코드` - ); return result.data; } else { - console.error(`외부 DB 특정 컬럼 조회 실패: ${result.message}`); - return []; + throw new Error(result.message || "외부 DB 조회 실패"); } } else { - throw new Error( - `잘못된 연결 타입 또는 연결 ID: ${connectionType}, ${connectionId}` - ); + throw new Error("잘못된 연결 설정입니다."); } } catch (error) { - console.error(`테이블 특정 컬럼 조회 오류 (${tableName}):`, error); + console.error(`데이터 조회 오류 (${tableName}):`, error); throw error; } } @@ -893,140 +743,27 @@ export class BatchService { // 각 레코드를 개별적으로 삽입 (UPSERT 방식으로 중복 처리) for (const record of data) { try { - // 동적 UPSERT 쿼리 생성 (PostgreSQL ON CONFLICT 사용) const columns = Object.keys(record); - const values = Object.values(record).map((value) => { - // Date 객체를 ISO 문자열로 변환 (PostgreSQL이 자동으로 파싱) - if (value instanceof Date) { - return value.toISOString(); - } - // JavaScript Date 문자열을 Date 객체로 변환 후 ISO 문자열로 - if (typeof value === "string") { - const dateRegex = - /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{2}\s+\d{4}\s+\d{2}:\d{2}:\d{2}/; - if (dateRegex.test(value)) { - return new Date(value).toISOString(); - } - // ISO 날짜 문자열 형식 체크 (2025-09-24T06:29:01.351Z) - const isoDateRegex = - /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z?$/; - if (isoDateRegex.test(value)) { - return new Date(value).toISOString(); - } - } - return value; - }); - - // PostgreSQL 타입 캐스팅을 위한 placeholder 생성 - const placeholders = columns - .map((col, index) => { - // 날짜/시간 관련 컬럼명 패턴 체크 - if ( - col.toLowerCase().includes("date") || - col.toLowerCase().includes("time") || - col.toLowerCase().includes("created") || - col.toLowerCase().includes("updated") || - col.toLowerCase().includes("reg") - ) { - return `$${index + 1}::timestamp`; - } - return `$${index + 1}`; - }) + const values = Object.values(record); + const placeholders = values + .map((_, i) => `$${i + 1}`) .join(", "); - // Primary Key 컬럼 추정 (일반적으로 id 또는 첫 번째 컬럼) - const primaryKeyColumn = columns.includes("id") - ? "id" - : columns.includes("user_id") - ? "user_id" - : columns[0]; - - // UPDATE SET 절 생성 (Primary Key 제외) - const updateColumns = columns.filter( - (col) => col !== primaryKeyColumn - ); - const updateSet = updateColumns - .map((col) => `${col} = EXCLUDED.${col}`) - .join(", "); - - // 트랜잭션 내에서 처리하여 연결 관리 최적화 - const result = await transaction(async (client) => { - // 먼저 해당 레코드가 존재하는지 확인 - const checkQuery = `SELECT COUNT(*) as count FROM ${tableName} WHERE ${primaryKeyColumn} = $1`; - const existsResult = await client.query(checkQuery, [ - record[primaryKeyColumn], - ]); - const exists = parseInt(existsResult.rows[0]?.count || "0") > 0; - - let operationResult = "no_change"; - - if (exists && updateSet) { - // 기존 레코드가 있으면 UPDATE (값이 다른 경우에만) - const whereConditions = updateColumns - .map((col, index) => { - // 날짜/시간 컬럼에 대한 타입 캐스팅 처리 - if ( - col.toLowerCase().includes("date") || - col.toLowerCase().includes("time") || - col.toLowerCase().includes("created") || - col.toLowerCase().includes("updated") || - col.toLowerCase().includes("reg") - ) { - return `${col} IS DISTINCT FROM $${index + 2}::timestamp`; - } - return `${col} IS DISTINCT FROM $${index + 2}`; - }) - .join(" OR "); - - const query = `UPDATE ${tableName} SET ${updateSet.replace(/EXCLUDED\./g, "")} - WHERE ${primaryKeyColumn} = $1 AND (${whereConditions})`; - - // 파라미터: [primaryKeyValue, ...updateValues] - const updateValues = [ - record[primaryKeyColumn], - ...updateColumns.map((col) => record[col]), - ]; - const updateResult = await client.query(query, updateValues); - - if (updateResult.rowCount && updateResult.rowCount > 0) { - console.log( - `[BatchService] 레코드 업데이트: ${primaryKeyColumn}=${record[primaryKeyColumn]}` - ); - operationResult = "updated"; - } else { - console.log( - `[BatchService] 레코드 변경사항 없음: ${primaryKeyColumn}=${record[primaryKeyColumn]}` - ); - operationResult = "no_change"; - } - } else if (!exists) { - // 새 레코드 삽입 - const query = `INSERT INTO ${tableName} (${columns.join(", ")}) VALUES (${placeholders})`; - await client.query(query, values); - console.log( - `[BatchService] 새 레코드 삽입: ${primaryKeyColumn}=${record[primaryKeyColumn]}` - ); - operationResult = "inserted"; - } else { - console.log( - `[BatchService] 레코드 이미 존재 (변경사항 없음): ${primaryKeyColumn}=${record[primaryKeyColumn]}` - ); - operationResult = "no_change"; - } - - return operationResult; - }); + const queryStr = `INSERT INTO ${tableName} (${columns.join( + ", " + )}) VALUES (${placeholders})`; + await query(queryStr, values); successCount++; - } catch (error) { - console.error(`레코드 UPSERT 실패:`, error); + } catch (insertError) { + console.error( + `내부 DB 데이터 삽입 실패 (${tableName}):`, + insertError + ); failedCount++; } } - console.log( - `[BatchService] 내부 DB 데이터 삽입 완료: 성공 ${successCount}개, 실패 ${failedCount}개` - ); return { successCount, failedCount }; } else if (connectionType === "external" && connectionId) { // 외부 DB에 데이터 삽입 @@ -1035,84 +772,22 @@ export class BatchService { tableName, data ); + if (result.success && result.data) { - console.log( - `[BatchService] 외부 DB 데이터 삽입 완료: 성공 ${result.data.successCount}개, 실패 ${result.data.failedCount}개` - ); return result.data; } else { console.error(`외부 DB 데이터 삽입 실패: ${result.message}`); + // 실패 시 전체 실패로 간주하지 않고 0/전체 로 반환 return { successCount: 0, failedCount: data.length }; } } else { - console.log(`[BatchService] 연결 정보 디버그:`, { - connectionType, - connectionId, - }); throw new Error( `잘못된 연결 타입 또는 연결 ID: ${connectionType}, ${connectionId}` ); } } catch (error) { - console.error(`테이블 데이터 삽입 오류 (${tableName}):`, error); - throw error; + console.error(`데이터 삽입 오류 (${tableName}):`, error); + return { successCount: 0, failedCount: data ? data.length : 0 }; } } - - /** - * 배치 매핑 유효성 검사 - */ - private static async validateBatchMappings( - mappings: BatchMapping[] - ): Promise { - const errors: string[] = []; - const warnings: string[] = []; - - if (!mappings || mappings.length === 0) { - errors.push("최소 하나 이상의 매핑이 필요합니다."); - return { isValid: false, errors, warnings }; - } - - // n:1 매핑 검사 (여러 FROM이 같은 TO로 매핑되는 것 방지) - const toMappings = new Map(); - - mappings.forEach((mapping, index) => { - const toKey = `${mapping.to_connection_type}:${mapping.to_connection_id || "internal"}:${mapping.to_table_name}:${mapping.to_column_name}`; - - if (toMappings.has(toKey)) { - errors.push( - `매핑 ${index + 1}: TO 컬럼 '${mapping.to_table_name}.${mapping.to_column_name}'에 중복 매핑이 있습니다. n:1 매핑은 허용되지 않습니다.` - ); - } else { - toMappings.set(toKey, index); - } - }); - - // 1:n 매핑 경고 (같은 FROM에서 여러 TO로 매핑) - const fromMappings = new Map(); - - mappings.forEach((mapping, index) => { - const fromKey = `${mapping.from_connection_type}:${mapping.from_connection_id || "internal"}:${mapping.from_table_name}:${mapping.from_column_name}`; - - if (!fromMappings.has(fromKey)) { - fromMappings.set(fromKey, []); - } - fromMappings.get(fromKey)!.push(index); - }); - - fromMappings.forEach((indices, fromKey) => { - if (indices.length > 1) { - const [, , tableName, columnName] = fromKey.split(":"); - warnings.push( - `FROM 컬럼 '${tableName}.${columnName}'에서 ${indices.length}개의 TO 컬럼으로 매핑됩니다. (1:n 매핑)` - ); - } - }); - - return { - isValid: errors.length === 0, - errors, - warnings, - }; - } } diff --git a/backend-node/src/types/batchExecutionLogTypes.ts b/backend-node/src/types/batchExecutionLogTypes.ts index d966de7c..aa49fd4e 100644 --- a/backend-node/src/types/batchExecutionLogTypes.ts +++ b/backend-node/src/types/batchExecutionLogTypes.ts @@ -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; diff --git a/backend-node/src/types/batchTypes.ts b/backend-node/src/types/batchTypes.ts index 24158a3d..1cbec196 100644 --- a/backend-node/src/types/batchTypes.ts +++ b/backend-node/src/types/batchTypes.ts @@ -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 { - success: boolean; - data?: T; - message?: string; - error?: string; - pagination?: { - page: number; - limit: number; - total: number; - totalPages: number; - }; } diff --git a/frontend/app/(main)/admin/batch-management-new/page.tsx b/frontend/app/(main)/admin/batch-management-new/page.tsx index f70d711a..e9d34340 100644 --- a/frontend/app/(main)/admin/batch-management-new/page.tsx +++ b/frontend/app/(main)/admin/batch-management-new/page.tsx @@ -52,7 +52,8 @@ export default function BatchManagementNewPage() { const [fromApiUrl, setFromApiUrl] = useState(""); const [fromApiKey, setFromApiKey] = useState(""); const [fromEndpoint, setFromEndpoint] = useState(""); - const [fromApiMethod, setFromApiMethod] = useState<'GET'>('GET'); // GET만 지원 + const [fromApiMethod, setFromApiMethod] = useState<'GET' | 'POST' | 'PUT' | 'DELETE'>('GET'); + const [fromApiBody, setFromApiBody] = useState(""); // Request Body (JSON) // REST API 파라미터 설정 const [apiParamType, setApiParamType] = useState<'none' | 'url' | 'query'>('none'); @@ -83,6 +84,8 @@ export default function BatchManagementNewPage() { // API 필드 → DB 컬럼 매핑 const [apiFieldMappings, setApiFieldMappings] = useState>({}); + // API 필드별 JSON 경로 오버라이드 (예: "response.access_token") + const [apiFieldPathOverrides, setApiFieldPathOverrides] = useState>({}); // 배치 타입 상태 const [batchType, setBatchType] = useState('restapi-to-db'); @@ -303,8 +306,15 @@ export default function BatchManagementNewPage() { // REST API 데이터 미리보기 const previewRestApiData = async () => { - if (!fromApiUrl || !fromApiKey || !fromEndpoint) { - toast.error("API URL, API Key, 엔드포인트를 모두 입력해주세요."); + // API URL, 엔드포인트는 항상 필수 + if (!fromApiUrl || !fromEndpoint) { + toast.error("API URL과 엔드포인트를 모두 입력해주세요."); + return; + } + + // GET 메서드일 때만 API 키 필수 + if (fromApiMethod === "GET" && !fromApiKey) { + toast.error("GET 메서드에서는 API 키를 입력해주세요."); return; } @@ -313,7 +323,7 @@ export default function BatchManagementNewPage() { const result = await BatchManagementAPI.previewRestApiData( fromApiUrl, - fromApiKey, + fromApiKey || "", fromEndpoint, fromApiMethod, // 파라미터 정보 추가 @@ -322,7 +332,9 @@ export default function BatchManagementNewPage() { paramName: apiParamName, paramValue: apiParamValue, paramSource: apiParamSource - } : undefined + } : undefined, + // Request Body 추가 (POST/PUT/DELETE) + (fromApiMethod === 'POST' || fromApiMethod === 'PUT' || fromApiMethod === 'DELETE') ? fromApiBody : undefined ); console.log("API 미리보기 결과:", result); @@ -370,31 +382,54 @@ export default function BatchManagementNewPage() { // 배치 타입별 검증 및 저장 if (batchType === 'restapi-to-db') { - const mappedFields = Object.keys(apiFieldMappings).filter(field => apiFieldMappings[field]); + const mappedFields = Object.keys(apiFieldMappings).filter( + (field) => apiFieldMappings[field] + ); if (mappedFields.length === 0) { toast.error("최소 하나의 API 필드를 DB 컬럼에 매핑해주세요."); return; } // API 필드 매핑을 배치 매핑 형태로 변환 - const apiMappings = mappedFields.map(apiField => ({ - from_connection_type: 'restapi' as const, - from_table_name: fromEndpoint, // API 엔드포인트 - from_column_name: apiField, // API 필드명 - from_api_url: fromApiUrl, - from_api_key: fromApiKey, - from_api_method: fromApiMethod, - // API 파라미터 정보 추가 - from_api_param_type: apiParamType !== 'none' ? apiParamType : undefined, - from_api_param_name: apiParamType !== 'none' ? apiParamName : undefined, - from_api_param_value: apiParamType !== 'none' ? apiParamValue : undefined, - from_api_param_source: apiParamType !== 'none' ? apiParamSource : undefined, - to_connection_type: toConnection?.type === 'internal' ? 'internal' : 'external', - to_connection_id: toConnection?.type === 'internal' ? undefined : toConnection?.id, - to_table_name: toTable, - to_column_name: apiFieldMappings[apiField], // 매핑된 DB 컬럼 - mapping_type: 'direct' as const - })); + const apiMappings = mappedFields.map((apiField) => { + const toColumnName = apiFieldMappings[apiField]; // 매핑된 DB 컬럼 (예: access_token) + + // 기본은 상위 필드 그대로 사용하되, + // 사용자가 JSON 경로를 직접 입력한 경우 해당 경로를 우선 사용 + let fromColumnName = apiField; + const overridePath = apiFieldPathOverrides[apiField]; + if (overridePath && overridePath.trim().length > 0) { + fromColumnName = overridePath.trim(); + } + + return { + from_connection_type: "restapi" as const, + from_table_name: fromEndpoint, // API 엔드포인트 + from_column_name: fromColumnName, // API 필드명 또는 중첩 경로 + from_api_url: fromApiUrl, + from_api_key: fromApiKey, + from_api_method: fromApiMethod, + from_api_body: + fromApiMethod === "POST" || + fromApiMethod === "PUT" || + fromApiMethod === "DELETE" + ? fromApiBody + : undefined, + // API 파라미터 정보 추가 + from_api_param_type: apiParamType !== "none" ? apiParamType : undefined, + from_api_param_name: apiParamType !== "none" ? apiParamName : undefined, + from_api_param_value: apiParamType !== "none" ? apiParamValue : undefined, + from_api_param_source: + apiParamType !== "none" ? apiParamSource : undefined, + to_connection_type: + toConnection?.type === "internal" ? "internal" : "external", + to_connection_id: + toConnection?.type === "internal" ? undefined : toConnection?.id, + to_table_name: toTable, + to_column_name: toColumnName, // 매핑된 DB 컬럼 + mapping_type: "direct" as const, + }; + }); console.log("REST API 배치 설정 저장:", { batchName, @@ -645,13 +680,19 @@ export default function BatchManagementNewPage() { />
- + setFromApiKey(e.target.value)} placeholder="ak_your_api_key_here" /> +

+ GET 메서드에서만 필수이며, POST/PUT/DELETE일 때는 선택 사항입니다. +

@@ -673,12 +714,33 @@ export default function BatchManagementNewPage() { GET (데이터 조회) + POST (데이터 조회/전송) + PUT + DELETE + {/* Request Body (POST/PUT/DELETE용) */} + {(fromApiMethod === 'POST' || fromApiMethod === 'PUT' || fromApiMethod === 'DELETE') && ( +
+ +