diff --git a/backend-node/src/controllers/batchManagementController.ts b/backend-node/src/controllers/batchManagementController.ts index 05aece84..134e9177 100644 --- a/backend-node/src/controllers/batchManagementController.ts +++ b/backend-node/src/controllers/batchManagementController.ts @@ -1,7 +1,7 @@ // 배치관리 전용 컨트롤러 (기존 소스와 완전 분리) // 작성일: 2024-12-24 -import { Response } from "express"; +import { Request, Response } from "express"; import { AuthenticatedRequest } from "../types/auth"; import { BatchManagementService, @@ -13,6 +13,7 @@ import { BatchService } from "../services/batchService"; import { BatchSchedulerService } from "../services/batchSchedulerService"; import { BatchExternalDbService } from "../services/batchExternalDbService"; import { CreateBatchConfigRequest, BatchConfig } from "../types/batchTypes"; +import { query } from "../database/db"; export class BatchManagementController { /** @@ -422,6 +423,8 @@ export class BatchManagementController { paramValue, paramSource, requestBody, + authServiceName, // DB에서 토큰 가져올 서비스명 + dataArrayPath, // 데이터 배열 경로 (예: response, data.items) } = req.body; // apiUrl, endpoint는 항상 필수 @@ -432,15 +435,36 @@ export class BatchManagementController { }); } - // GET 요청일 때만 API Key 필수 (POST/PUT/DELETE는 선택) - if ((!method || method === "GET") && !apiKey) { + // 토큰 결정: authServiceName이 있으면 DB에서 조회, 없으면 apiKey 사용 + let finalApiKey = apiKey || ""; + if (authServiceName) { + // DB에서 토큰 조회 + const tokenResult = await query<{ access_token: string }>( + `SELECT access_token FROM auth_tokens + WHERE service_name = $1 + ORDER BY created_date DESC LIMIT 1`, + [authServiceName] + ); + if (tokenResult.length > 0 && tokenResult[0].access_token) { + finalApiKey = tokenResult[0].access_token; + console.log(`auth_tokens에서 토큰 조회 성공: ${authServiceName}`); + } else { + return res.status(400).json({ + success: false, + message: `서비스 '${authServiceName}'의 토큰을 찾을 수 없습니다. 먼저 토큰 저장 배치를 실행하세요.`, + }); + } + } + + // 토큰이 없으면 에러 (직접 입력도 안 하고 DB 선택도 안 한 경우) + if (!finalApiKey) { return res.status(400).json({ success: false, - message: "GET 메서드에서는 API Key가 필요합니다.", + message: "인증 토큰이 필요합니다. 직접 입력하거나 DB에서 선택하세요.", }); } - console.log("🔍 REST API 미리보기 요청:", { + console.log("REST API 미리보기 요청:", { apiUrl, endpoint, method, @@ -449,6 +473,8 @@ export class BatchManagementController { paramValue, paramSource, requestBody: requestBody ? "Included" : "None", + authServiceName: authServiceName || "직접 입력", + dataArrayPath: dataArrayPath || "전체 응답", }); // RestApiConnector 사용하여 데이터 조회 @@ -456,7 +482,7 @@ export class BatchManagementController { const connector = new RestApiConnector({ baseUrl: apiUrl, - apiKey: apiKey || "", + apiKey: finalApiKey, timeout: 30000, }); @@ -511,8 +537,50 @@ export class BatchManagementController { result.rows && result.rows.length > 0 ? result.rows[0] : "no data", }); - const data = result.rows.slice(0, 5); // 최대 5개 샘플만 - console.log(`[previewRestApiData] 슬라이스된 데이터:`, data); + // 데이터 배열 추출 헬퍼 함수 + const getValueByPath = (obj: any, path: string): any => { + if (!path) return obj; + const keys = path.split("."); + let current = obj; + for (const key of keys) { + if (current === null || current === undefined) return undefined; + current = current[key]; + } + return current; + }; + + // dataArrayPath가 있으면 해당 경로에서 배열 추출 + let extractedData: any[] = []; + if (dataArrayPath) { + // result.rows가 단일 객체일 수 있음 (API 응답 전체) + const rawData = result.rows.length === 1 ? result.rows[0] : result.rows; + const arrayData = getValueByPath(rawData, dataArrayPath); + + if (Array.isArray(arrayData)) { + extractedData = arrayData; + console.log( + `[previewRestApiData] '${dataArrayPath}' 경로에서 ${arrayData.length}개 항목 추출` + ); + } else { + console.warn( + `[previewRestApiData] '${dataArrayPath}' 경로가 배열이 아님:`, + typeof arrayData + ); + // 배열이 아니면 단일 객체로 처리 + if (arrayData) { + extractedData = [arrayData]; + } + } + } else { + // dataArrayPath가 없으면 기존 로직 사용 + extractedData = result.rows; + } + + const data = extractedData.slice(0, 5); // 최대 5개 샘플만 + console.log( + `[previewRestApiData] 슬라이스된 데이터 (${extractedData.length}개 중 ${data.length}개):`, + data + ); if (data.length > 0) { // 첫 번째 객체에서 필드명 추출 @@ -524,9 +592,9 @@ export class BatchManagementController { data: { fields: fields, samples: data, - totalCount: result.rowCount || data.length, + totalCount: extractedData.length, }, - message: `${fields.length}개 필드, ${result.rowCount || data.length}개 레코드를 조회했습니다.`, + message: `${fields.length}개 필드, ${extractedData.length}개 레코드를 조회했습니다.`, }); } else { return res.json({ @@ -554,8 +622,17 @@ export class BatchManagementController { */ static async saveRestApiBatch(req: AuthenticatedRequest, res: Response) { try { - const { batchName, batchType, cronSchedule, description, apiMappings } = - req.body; + const { + batchName, + batchType, + cronSchedule, + description, + apiMappings, + authServiceName, + dataArrayPath, + saveMode, + conflictKey, + } = req.body; if ( !batchName || @@ -576,6 +653,10 @@ export class BatchManagementController { cronSchedule, description, apiMappings, + authServiceName, + dataArrayPath, + saveMode, + conflictKey, }); // 🔐 멀티테넌시: 현재 사용자 회사 코드 사용 (프론트에서 받지 않음) @@ -589,6 +670,10 @@ export class BatchManagementController { cronSchedule: cronSchedule, isActive: "Y", companyCode, + authServiceName: authServiceName || undefined, + dataArrayPath: dataArrayPath || undefined, + saveMode: saveMode || "INSERT", + conflictKey: conflictKey || undefined, mappings: apiMappings, }; @@ -625,4 +710,31 @@ export class BatchManagementController { }); } } + + /** + * 인증 토큰 서비스명 목록 조회 + */ + static async getAuthServiceNames(req: Request, res: Response) { + try { + const result = await query<{ service_name: string }>( + `SELECT DISTINCT service_name + FROM auth_tokens + WHERE service_name IS NOT NULL + ORDER BY service_name` + ); + + const serviceNames = result.map((row) => row.service_name); + + return res.json({ + success: true, + data: serviceNames, + }); + } catch (error) { + console.error("인증 서비스 목록 조회 오류:", error); + return res.status(500).json({ + success: false, + message: "인증 서비스 목록 조회 중 오류가 발생했습니다.", + }); + } + } } diff --git a/backend-node/src/routes/batchManagementRoutes.ts b/backend-node/src/routes/batchManagementRoutes.ts index d6adf4c5..50ee1ea0 100644 --- a/backend-node/src/routes/batchManagementRoutes.ts +++ b/backend-node/src/routes/batchManagementRoutes.ts @@ -79,4 +79,10 @@ router.post("/rest-api/preview", authenticateToken, BatchManagementController.pr */ router.post("/rest-api/save", authenticateToken, BatchManagementController.saveRestApiBatch); +/** + * GET /api/batch-management/auth-services + * 인증 토큰 서비스명 목록 조회 + */ +router.get("/auth-services", authenticateToken, BatchManagementController.getAuthServiceNames); + export default router; diff --git a/backend-node/src/services/batchSchedulerService.ts b/backend-node/src/services/batchSchedulerService.ts index ee849ae2..c425703b 100644 --- a/backend-node/src/services/batchSchedulerService.ts +++ b/backend-node/src/services/batchSchedulerService.ts @@ -2,6 +2,7 @@ import cron, { ScheduledTask } from "node-cron"; import { BatchService } from "./batchService"; import { BatchExecutionLogService } from "./batchExecutionLogService"; import { logger } from "../utils/logger"; +import { query } from "../database/db"; export class BatchSchedulerService { private static scheduledTasks: Map = new Map(); @@ -214,9 +215,16 @@ export class BatchSchedulerService { } // 테이블별로 매핑을 그룹화 + // 고정값 매핑(mapping_type === 'fixed')은 별도 그룹으로 분리하지 않고 나중에 처리 const tableGroups = new Map(); + const fixedMappingsGlobal: typeof config.batch_mappings = []; for (const mapping of config.batch_mappings) { + // 고정값 매핑은 별도로 모아둠 (FROM 소스가 필요 없음) + if (mapping.mapping_type === "fixed") { + fixedMappingsGlobal.push(mapping); + continue; + } const key = `${mapping.from_connection_type}:${mapping.from_connection_id || "internal"}:${mapping.from_table_name}`; if (!tableGroups.has(key)) { tableGroups.set(key, []); @@ -224,6 +232,14 @@ export class BatchSchedulerService { tableGroups.get(key)!.push(mapping); } + // 고정값 매핑만 있고 일반 매핑이 없는 경우 처리 + if (tableGroups.size === 0 && fixedMappingsGlobal.length > 0) { + logger.warn( + `일반 매핑이 없고 고정값 매핑만 있습니다. 고정값만으로는 배치를 실행할 수 없습니다.` + ); + return { totalRecords, successRecords, failedRecords }; + } + // 각 테이블 그룹별로 처리 for (const [tableKey, mappings] of tableGroups) { try { @@ -244,10 +260,31 @@ export class BatchSchedulerService { "./batchExternalDbService" ); + // auth_service_name이 설정된 경우 auth_tokens에서 토큰 조회 + let apiKey = firstMapping.from_api_key || ""; + if (config.auth_service_name) { + const tokenResult = await query<{ access_token: string }>( + `SELECT access_token FROM auth_tokens + WHERE service_name = $1 + ORDER BY created_date DESC LIMIT 1`, + [config.auth_service_name] + ); + if (tokenResult.length > 0 && tokenResult[0].access_token) { + apiKey = tokenResult[0].access_token; + logger.info( + `auth_tokens에서 토큰 조회 성공: ${config.auth_service_name}` + ); + } else { + logger.warn( + `auth_tokens에서 토큰을 찾을 수 없음: ${config.auth_service_name}` + ); + } + } + // 👇 Body 파라미터 추가 (POST 요청 시) const apiResult = await BatchExternalDbService.getDataFromRestApi( firstMapping.from_api_url!, - firstMapping.from_api_key!, + apiKey, firstMapping.from_table_name, (firstMapping.from_api_method as | "GET" @@ -266,7 +303,36 @@ export class BatchSchedulerService { ); if (apiResult.success && apiResult.data) { - fromData = apiResult.data; + // 데이터 배열 경로가 설정되어 있으면 해당 경로에서 배열 추출 + if (config.data_array_path) { + const extractArrayByPath = (obj: any, path: string): any[] => { + if (!path) return Array.isArray(obj) ? obj : [obj]; + const keys = path.split("."); + let current = obj; + for (const key of keys) { + if (current === null || current === undefined) return []; + current = current[key]; + } + return Array.isArray(current) + ? current + : current + ? [current] + : []; + }; + + // apiResult.data가 단일 객체인 경우 (API 응답 전체) + const rawData = + Array.isArray(apiResult.data) && apiResult.data.length === 1 + ? apiResult.data[0] + : apiResult.data; + + fromData = extractArrayByPath(rawData, config.data_array_path); + logger.info( + `데이터 배열 경로 '${config.data_array_path}'에서 ${fromData.length}개 레코드 추출` + ); + } else { + fromData = apiResult.data; + } } else { throw new Error(`REST API 데이터 조회 실패: ${apiResult.message}`); } @@ -298,6 +364,11 @@ export class BatchSchedulerService { const mappedData = fromData.map((row) => { const mappedRow: any = {}; for (const mapping of mappings) { + // 고정값 매핑은 이미 분리되어 있으므로 여기서는 처리하지 않음 + if (mapping.mapping_type === "fixed") { + continue; + } + // DB → REST API 배치인지 확인 if ( firstMapping.to_connection_type === "restapi" && @@ -315,6 +386,13 @@ export class BatchSchedulerService { } } + // 고정값 매핑 적용 (전역으로 분리된 fixedMappingsGlobal 사용) + for (const fixedMapping of fixedMappingsGlobal) { + // from_column_name에 고정값이 저장되어 있음 + mappedRow[fixedMapping.to_column_name] = + fixedMapping.from_column_name; + } + // 멀티테넌시: TO가 DB일 때 company_code 자동 주입 // - 배치 설정에 company_code가 있고 // - 매핑에서 company_code를 명시적으로 다루지 않은 경우만 @@ -384,12 +462,14 @@ export class BatchSchedulerService { insertResult = { successCount: 0, failedCount: 0 }; } } else { - // DB에 데이터 삽입 + // DB에 데이터 삽입 (save_mode, conflict_key 지원) insertResult = await BatchService.insertDataToTable( firstMapping.to_table_name, mappedData, firstMapping.to_connection_type as "internal" | "external", - firstMapping.to_connection_id || undefined + firstMapping.to_connection_id || undefined, + (config.save_mode as "INSERT" | "UPSERT") || "INSERT", + config.conflict_key || undefined ); } diff --git a/backend-node/src/services/batchService.ts b/backend-node/src/services/batchService.ts index 2aefc98b..41b79b29 100644 --- a/backend-node/src/services/batchService.ts +++ b/backend-node/src/services/batchService.ts @@ -176,8 +176,8 @@ export class BatchService { // 배치 설정 생성 const batchConfigResult = await client.query( `INSERT INTO batch_configs - (batch_name, description, cron_schedule, is_active, company_code, created_by, created_date, updated_date) - VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW()) + (batch_name, description, cron_schedule, is_active, company_code, save_mode, conflict_key, auth_service_name, data_array_path, created_by, created_date, updated_date) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW()) RETURNING *`, [ data.batchName, @@ -185,6 +185,10 @@ export class BatchService { data.cronSchedule, data.isActive || "Y", data.companyCode, + data.saveMode || "INSERT", + data.conflictKey || null, + data.authServiceName || null, + data.dataArrayPath || null, userId, ] ); @@ -201,37 +205,38 @@ export class BatchService { 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_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, $25, $26, NOW()) + to_api_url, to_api_key, to_api_method, to_api_body, mapping_order, mapping_type, 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, $25, $26, $27, NOW()) RETURNING *`, [ - 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, - ] + 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, + mapping.mapping_type || "direct", // 매핑 타입: direct 또는 fixed + userId, + ] ); mappings.push(mappingResult.rows[0]); } @@ -311,6 +316,18 @@ export class BatchService { updateFields.push(`is_active = $${paramIndex++}`); updateValues.push(data.isActive); } + if (data.saveMode !== undefined) { + updateFields.push(`save_mode = $${paramIndex++}`); + updateValues.push(data.saveMode); + } + if (data.conflictKey !== undefined) { + updateFields.push(`conflict_key = $${paramIndex++}`); + updateValues.push(data.conflictKey || null); + } + if (data.authServiceName !== undefined) { + updateFields.push(`auth_service_name = $${paramIndex++}`); + updateValues.push(data.authServiceName || null); + } // 배치 설정 업데이트 const batchConfigResult = await client.query( @@ -339,8 +356,8 @@ export class BatchService { 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_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, $25, $26, NOW()) + to_api_url, to_api_key, to_api_method, to_api_body, mapping_order, mapping_type, 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, $25, $26, $27, NOW()) RETURNING *`, [ id, @@ -368,6 +385,7 @@ export class BatchService { mapping.to_api_method, mapping.to_api_body, mapping.mapping_order || index + 1, + mapping.mapping_type || "direct", // 매핑 타입: direct 또는 fixed userId, ] ); @@ -554,9 +572,7 @@ export class BatchService { try { if (connectionType === "internal") { // 내부 DB 데이터 조회 - const data = await query( - `SELECT * FROM ${tableName} LIMIT 10` - ); + const data = await query(`SELECT * FROM ${tableName} LIMIT 10`); return { success: true, data, @@ -729,19 +745,27 @@ export class BatchService { /** * 테이블에 데이터 삽입 (연결 타입에 따라 내부/외부 DB 구분) + * @param tableName 테이블명 + * @param data 삽입할 데이터 배열 + * @param connectionType 연결 타입 (internal/external) + * @param connectionId 외부 연결 ID + * @param saveMode 저장 모드 (INSERT/UPSERT) + * @param conflictKey UPSERT 시 충돌 기준 컬럼명 */ static async insertDataToTable( tableName: string, data: any[], connectionType: "internal" | "external" = "internal", - connectionId?: number + connectionId?: number, + saveMode: "INSERT" | "UPSERT" = "INSERT", + conflictKey?: string ): Promise<{ successCount: number; failedCount: number; }> { try { console.log( - `[BatchService] 테이블에 데이터 삽입: ${tableName} (${connectionType}${connectionId ? `:${connectionId}` : ""}), ${data.length}개 레코드` + `[BatchService] 테이블에 데이터 ${saveMode}: ${tableName} (${connectionType}${connectionId ? `:${connectionId}` : ""}), ${data.length}개 레코드${conflictKey ? `, 충돌키: ${conflictKey}` : ""}` ); if (!data || data.length === 0) { @@ -753,24 +777,45 @@ export class BatchService { let successCount = 0; let failedCount = 0; - // 각 레코드를 개별적으로 삽입 (UPSERT 방식으로 중복 처리) + // 각 레코드를 개별적으로 삽입 for (const record of data) { try { const columns = Object.keys(record); const values = Object.values(record); - const placeholders = values - .map((_, i) => `$${i + 1}`) - .join(", "); + const placeholders = values.map((_, i) => `$${i + 1}`).join(", "); - const queryStr = `INSERT INTO ${tableName} (${columns.join( - ", " - )}) VALUES (${placeholders})`; + let queryStr: string; + + if (saveMode === "UPSERT" && conflictKey) { + // UPSERT 모드: ON CONFLICT DO UPDATE + // 충돌 키를 제외한 컬럼들만 UPDATE + const updateColumns = columns.filter( + (col) => col !== conflictKey + ); + const updateSet = updateColumns + .map((col) => `${col} = EXCLUDED.${col}`) + .join(", "); + + // updated_date 컬럼이 있으면 현재 시간으로 업데이트 + const hasUpdatedDate = columns.includes("updated_date"); + const finalUpdateSet = hasUpdatedDate + ? `${updateSet}, updated_date = NOW()` + : updateSet; + + queryStr = `INSERT INTO ${tableName} (${columns.join(", ")}) + VALUES (${placeholders}) + ON CONFLICT (${conflictKey}) + DO UPDATE SET ${finalUpdateSet}`; + } else { + // INSERT 모드: 기존 방식 + queryStr = `INSERT INTO ${tableName} (${columns.join(", ")}) VALUES (${placeholders})`; + } await query(queryStr, values); successCount++; } catch (insertError) { console.error( - `내부 DB 데이터 삽입 실패 (${tableName}):`, + `내부 DB 데이터 ${saveMode} 실패 (${tableName}):`, insertError ); failedCount++; @@ -779,7 +824,13 @@ export class BatchService { return { successCount, failedCount }; } else if (connectionType === "external" && connectionId) { - // 외부 DB에 데이터 삽입 + // 외부 DB에 데이터 삽입 (UPSERT는 내부 DB만 지원) + if (saveMode === "UPSERT") { + console.warn( + `[BatchService] 외부 DB는 UPSERT를 지원하지 않습니다. INSERT로 실행합니다.` + ); + } + const result = await BatchExternalDbService.insertDataToTable( connectionId, tableName, @@ -799,7 +850,7 @@ export class BatchService { ); } } catch (error) { - console.error(`데이터 삽입 오류 (${tableName}):`, error); + console.error(`데이터 ${saveMode} 오류 (${tableName}):`, error); return { successCount: 0, failedCount: data ? data.length : 0 }; } } diff --git a/backend-node/src/types/batchTypes.ts b/backend-node/src/types/batchTypes.ts index 15efd003..a6404036 100644 --- a/backend-node/src/types/batchTypes.ts +++ b/backend-node/src/types/batchTypes.ts @@ -32,7 +32,7 @@ export interface TableInfo { // 연결 정보 타입 export interface ConnectionInfo { - type: 'internal' | 'external'; + type: "internal" | "external"; id?: number; name: string; db_type?: string; @@ -52,27 +52,27 @@ export interface BatchMapping { id?: number; batch_config_id?: number; company_code?: string; - from_connection_type: 'internal' | 'external' | 'restapi'; + from_connection_type: "internal" | "external" | "restapi"; from_connection_id?: number; from_table_name: string; from_column_name: string; from_column_type?: string; from_api_url?: string; from_api_key?: string; - from_api_method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; - from_api_param_type?: 'url' | 'query'; + from_api_method?: "GET" | "POST" | "PUT" | "DELETE"; + from_api_param_type?: "url" | "query"; from_api_param_name?: string; from_api_param_value?: string; - from_api_param_source?: 'static' | 'dynamic'; + from_api_param_source?: "static" | "dynamic"; from_api_body?: string; - to_connection_type: 'internal' | 'external' | 'restapi'; + to_connection_type: "internal" | "external" | "restapi"; to_connection_id?: number; to_table_name: string; to_column_name: string; to_column_type?: string; to_api_url?: string; to_api_key?: string; - to_api_method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; + to_api_method?: "GET" | "POST" | "PUT" | "DELETE"; to_api_body?: string; mapping_order?: number; created_by?: string; @@ -85,8 +85,12 @@ export interface BatchConfig { batch_name: string; description?: string; cron_schedule: string; - is_active: 'Y' | 'N'; + is_active: "Y" | "N"; company_code?: string; + save_mode?: "INSERT" | "UPSERT"; // 저장 모드 (기본: INSERT) + conflict_key?: string; // UPSERT 시 충돌 기준 컬럼명 + auth_service_name?: string; // REST API 인증에 사용할 토큰 서비스명 + data_array_path?: string; // REST API 응답에서 데이터 배열 경로 (예: response, data.items) created_by?: string; created_date?: Date; updated_by?: string; @@ -95,7 +99,7 @@ export interface BatchConfig { } export interface BatchConnectionInfo { - type: 'internal' | 'external'; + type: "internal" | "external"; id?: number; name: string; db_type?: string; @@ -109,38 +113,43 @@ export interface BatchColumnInfo { } export interface BatchMappingRequest { - from_connection_type: 'internal' | 'external' | 'restapi'; + from_connection_type: "internal" | "external" | "restapi" | "fixed"; from_connection_id?: number; from_table_name: string; from_column_name: string; from_column_type?: string; from_api_url?: string; from_api_key?: string; - from_api_method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; - from_api_param_type?: 'url' | 'query'; // API 파라미터 타입 + from_api_method?: "GET" | "POST" | "PUT" | "DELETE"; + 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'; // 파라미터 소스 타입 + from_api_param_source?: "static" | "dynamic"; // 파라미터 소스 타입 // REST API Body 추가 (FROM - REST API에서 POST 요청 시 필요) - from_api_body?: string; - to_connection_type: 'internal' | 'external' | 'restapi'; + from_api_body?: string; + to_connection_type: "internal" | "external" | "restapi"; to_connection_id?: number; to_table_name: string; to_column_name: string; to_column_type?: string; to_api_url?: string; to_api_key?: string; - to_api_method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; + to_api_method?: "GET" | "POST" | "PUT" | "DELETE"; to_api_body?: string; // Request Body 템플릿 (DB → REST API 배치용) mapping_order?: number; + mapping_type?: "direct" | "fixed"; // 매핑 타입: direct (API 필드) 또는 fixed (고정값) } export interface CreateBatchConfigRequest { batchName: string; description?: string; cronSchedule: string; - isActive: 'Y' | 'N'; + isActive: "Y" | "N"; companyCode: string; + saveMode?: "INSERT" | "UPSERT"; + conflictKey?: string; + authServiceName?: string; + dataArrayPath?: string; // REST API 응답에서 데이터 배열 경로 mappings: BatchMappingRequest[]; } @@ -148,7 +157,11 @@ export interface UpdateBatchConfigRequest { batchName?: string; description?: string; cronSchedule?: string; - isActive?: 'Y' | 'N'; + isActive?: "Y" | "N"; + saveMode?: "INSERT" | "UPSERT"; + conflictKey?: string; + authServiceName?: string; + dataArrayPath?: string; // REST API 응답에서 데이터 배열 경로 mappings?: BatchMappingRequest[]; } diff --git a/frontend/app/(main)/admin/batch-management-new/page.tsx b/frontend/app/(main)/admin/batch-management-new/page.tsx index 2046ed3e..29f36270 100644 --- a/frontend/app/(main)/admin/batch-management-new/page.tsx +++ b/frontend/app/(main)/admin/batch-management-new/page.tsx @@ -8,12 +8,12 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Trash2, Plus, ArrowRight, Save, RefreshCw, Globe, Database, Eye } from "lucide-react"; +import { Trash2, Plus, ArrowLeft, Save, RefreshCw, Globe, Database, Eye } from "lucide-react"; import { toast } from "sonner"; import { BatchManagementAPI } from "@/lib/api/batchManagement"; // 타입 정의 -type BatchType = 'db-to-restapi' | 'restapi-to-db' | 'restapi-to-restapi'; +type BatchType = "db-to-restapi" | "restapi-to-db" | "restapi-to-restapi"; interface BatchTypeOption { value: BatchType; @@ -33,18 +33,21 @@ interface BatchColumnInfo { is_nullable: string; } +// 통합 매핑 아이템 타입 +interface MappingItem { + id: string; + dbColumn: string; + sourceType: "api" | "fixed"; + apiField: string; + fixedValue: string; +} + interface RestApiToDbMappingCardProps { fromApiFields: string[]; toColumns: BatchColumnInfo[]; fromApiData: any[]; - apiFieldMappings: Record; - setApiFieldMappings: React.Dispatch< - React.SetStateAction> - >; - apiFieldPathOverrides: Record; - setApiFieldPathOverrides: React.Dispatch< - React.SetStateAction> - >; + mappingList: MappingItem[]; + setMappingList: React.Dispatch>; } interface DbToRestApiMappingCardProps { @@ -52,20 +55,23 @@ interface DbToRestApiMappingCardProps { selectedColumns: string[]; toApiFields: string[]; dbToApiFieldMapping: Record; - setDbToApiFieldMapping: React.Dispatch< - React.SetStateAction> - >; + setDbToApiFieldMapping: React.Dispatch>>; setToApiBody: (body: string) => void; } export default function BatchManagementNewPage() { const router = useRouter(); - + // 기본 상태 const [batchName, setBatchName] = useState(""); const [cronSchedule, setCronSchedule] = useState("0 12 * * *"); const [description, setDescription] = useState(""); + // 인증 토큰 설정 + const [authTokenMode, setAuthTokenMode] = useState<"direct" | "db">("direct"); // 직접입력 / DB에서 선택 + const [authServiceName, setAuthServiceName] = useState(""); + const [authServiceNames, setAuthServiceNames] = useState([]); + // 연결 정보 const [connections, setConnections] = useState([]); const [toConnection, setToConnection] = useState(null); @@ -77,14 +83,15 @@ export default function BatchManagementNewPage() { const [fromApiUrl, setFromApiUrl] = useState(""); const [fromApiKey, setFromApiKey] = useState(""); const [fromEndpoint, setFromEndpoint] = useState(""); - const [fromApiMethod, setFromApiMethod] = useState<'GET' | 'POST' | 'PUT' | 'DELETE'>('GET'); + const [fromApiMethod, setFromApiMethod] = useState<"GET" | "POST" | "PUT" | "DELETE">("GET"); const [fromApiBody, setFromApiBody] = useState(""); // Request Body (JSON) - + const [dataArrayPath, setDataArrayPath] = useState(""); // 데이터 배열 경로 (예: response, data.items) + // REST API 파라미터 설정 - const [apiParamType, setApiParamType] = useState<'none' | 'url' | 'query'>('none'); + const [apiParamType, setApiParamType] = useState<"none" | "url" | "query">("none"); const [apiParamName, setApiParamName] = useState(""); // 파라미터명 (예: userId, id) const [apiParamValue, setApiParamValue] = useState(""); // 파라미터 값 또는 템플릿 - const [apiParamSource, setApiParamSource] = useState<'static' | 'dynamic'>('static'); // 정적 값 또는 동적 값 + const [apiParamSource, setApiParamSource] = useState<"static" | "dynamic">("static"); // 정적 값 또는 동적 값 // DB → REST API용 상태 const [fromConnection, setFromConnection] = useState(null); @@ -93,13 +100,13 @@ export default function BatchManagementNewPage() { const [fromColumns, setFromColumns] = useState([]); const [selectedColumns, setSelectedColumns] = useState([]); // 선택된 컬럼들 const [dbToApiFieldMapping, setDbToApiFieldMapping] = useState>({}); // DB 컬럼 → API 필드 매핑 - + // REST API 대상 설정 (DB → REST API용) const [toApiUrl, setToApiUrl] = useState(""); const [toApiKey, setToApiKey] = useState(""); const [toEndpoint, setToEndpoint] = useState(""); - const [toApiMethod, setToApiMethod] = useState<'POST' | 'PUT' | 'DELETE'>('POST'); - const [toApiBody, setToApiBody] = useState(''); // Request Body 템플릿 + const [toApiMethod, setToApiMethod] = useState<"POST" | "PUT" | "DELETE">("POST"); + const [toApiBody, setToApiBody] = useState(""); // Request Body 템플릿 const [toApiFields, setToApiFields] = useState([]); // TO API 필드 목록 const [urlPathColumn, setUrlPathColumn] = useState(""); // URL 경로에 사용할 컬럼 (PUT/DELETE용) @@ -107,38 +114,51 @@ export default function BatchManagementNewPage() { const [fromApiData, setFromApiData] = useState([]); const [fromApiFields, setFromApiFields] = useState([]); - // API 필드 → DB 컬럼 매핑 - const [apiFieldMappings, setApiFieldMappings] = useState>({}); - // API 필드별 JSON 경로 오버라이드 (예: "response.access_token") - const [apiFieldPathOverrides, setApiFieldPathOverrides] = useState>({}); + // 통합 매핑 리스트 + const [mappingList, setMappingList] = useState([]); + + // INSERT/UPSERT 설정 + const [saveMode, setSaveMode] = useState<"INSERT" | "UPSERT">("INSERT"); + const [conflictKey, setConflictKey] = useState(""); // 배치 타입 상태 - const [batchType, setBatchType] = useState('restapi-to-db'); + const [batchType, setBatchType] = useState("restapi-to-db"); // 배치 타입 옵션 const batchTypeOptions: BatchTypeOption[] = [ { - value: 'restapi-to-db', - label: 'REST API → DB', - description: 'REST API에서 데이터베이스로 데이터 수집' + value: "restapi-to-db", + label: "REST API → DB", + description: "REST API에서 데이터베이스로 데이터 수집", }, { - value: 'db-to-restapi', - label: 'DB → REST API', - description: '데이터베이스에서 REST API로 데이터 전송' - } + value: "db-to-restapi", + label: "DB → REST API", + description: "데이터베이스에서 REST API로 데이터 전송", + }, ]; // 초기 데이터 로드 useEffect(() => { loadConnections(); + loadAuthServiceNames(); }, []); + // 인증 서비스명 목록 로드 + const loadAuthServiceNames = async () => { + try { + const serviceNames = await BatchManagementAPI.getAuthServiceNames(); + setAuthServiceNames(serviceNames); + } catch (error) { + console.error("인증 서비스 목록 로드 실패:", error); + } + }; + // 배치 타입 변경 시 상태 초기화 useEffect(() => { // 공통 초기화 - setApiFieldMappings({}); - + setMappingList([]); + // REST API → DB 관련 초기화 setToConnection(null); setToTables([]); @@ -149,7 +169,7 @@ export default function BatchManagementNewPage() { setFromEndpoint(""); setFromApiData([]); setFromApiFields([]); - + // DB → REST API 관련 초기화 setFromConnection(null); setFromTables([]); @@ -164,7 +184,6 @@ export default function BatchManagementNewPage() { setToApiFields([]); }, [batchType]); - // 연결 목록 로드 const loadConnections = async () => { try { @@ -179,26 +198,26 @@ export default function BatchManagementNewPage() { // TO 연결 변경 핸들러 const handleToConnectionChange = async (connectionValue: string) => { let connection: BatchConnectionInfo | null = null; - - if (connectionValue === 'internal') { + + if (connectionValue === "internal") { // 내부 데이터베이스 선택 - connection = connections.find(conn => conn.type === 'internal') || null; + connection = connections.find((conn) => conn.type === "internal") || null; } else { // 외부 데이터베이스 선택 const connectionId = parseInt(connectionValue); - connection = connections.find(conn => conn.id === connectionId) || null; + connection = connections.find((conn) => conn.id === connectionId) || null; } - + setToConnection(connection); setToTable(""); setToColumns([]); if (connection) { try { - const connectionType = connection.type === 'internal' ? 'internal' : 'external'; + const connectionType = connection.type === "internal" ? "internal" : "external"; const result = await BatchManagementAPI.getTablesFromConnection(connectionType, connection.id); - const tableNames = Array.isArray(result) - ? result.map((table: any) => typeof table === 'string' ? table : table.table_name || String(table)) + const tableNames = Array.isArray(result) + ? result.map((table: any) => (typeof table === "string" ? table : table.table_name || String(table))) : []; setToTables(tableNames); } catch (error) { @@ -215,7 +234,7 @@ export default function BatchManagementNewPage() { if (toConnection && tableName) { try { - const connectionType = toConnection.type === 'internal' ? 'internal' : 'external'; + const connectionType = toConnection.type === "internal" ? "internal" : "external"; const result = await BatchManagementAPI.getTableColumns(connectionType, tableName, toConnection.id); if (result && result.length > 0) { setToColumns(result); @@ -233,11 +252,11 @@ export default function BatchManagementNewPage() { // FROM 연결 변경 핸들러 (DB → REST API용) const handleFromConnectionChange = async (connectionValue: string) => { let connection: BatchConnectionInfo | null = null; - if (connectionValue === 'internal') { - connection = connections.find(conn => conn.type === 'internal') || null; + if (connectionValue === "internal") { + connection = connections.find((conn) => conn.type === "internal") || null; } else { const connectionId = parseInt(connectionValue); - connection = connections.find(conn => conn.id === connectionId) || null; + connection = connections.find((conn) => conn.id === connectionId) || null; } setFromConnection(connection); setFromTable(""); @@ -245,10 +264,10 @@ export default function BatchManagementNewPage() { if (connection) { try { - const connectionType = connection.type === 'internal' ? 'internal' : 'external'; + const connectionType = connection.type === "internal" ? "internal" : "external"; const result = await BatchManagementAPI.getTablesFromConnection(connectionType, connection.id); const tableNames = Array.isArray(result) - ? result.map((table: any) => typeof table === 'string' ? table : table.table_name || String(table)) + ? result.map((table: any) => (typeof table === "string" ? table : table.table_name || String(table))) : []; setFromTables(tableNames); } catch (error) { @@ -267,7 +286,7 @@ export default function BatchManagementNewPage() { if (fromConnection && tableName) { try { - const connectionType = fromConnection.type === 'internal' ? 'internal' : 'external'; + const connectionType = fromConnection.type === "internal" ? "internal" : "external"; const result = await BatchManagementAPI.getTableColumns(connectionType, tableName, fromConnection.id); if (result && result.length > 0) { setFromColumns(result); @@ -294,7 +313,7 @@ export default function BatchManagementNewPage() { toApiUrl, toApiKey, toEndpoint, - 'GET' // 미리보기는 항상 GET으로 + "GET", // 미리보기는 항상 GET으로 ); if (result.fields && result.fields.length > 0) { @@ -319,27 +338,39 @@ export default function BatchManagementNewPage() { return; } - // GET 메서드일 때만 API 키 필수 - if (fromApiMethod === "GET" && !fromApiKey) { - toast.error("GET 메서드에서는 API 키를 입력해주세요."); + // 직접 입력 모드일 때만 토큰 검증 + if (authTokenMode === "direct" && !fromApiKey) { + toast.error("인증 토큰을 입력해주세요."); + return; + } + + // DB 선택 모드일 때 서비스명 검증 + if (authTokenMode === "db" && !authServiceName) { + toast.error("인증 토큰 서비스를 선택해주세요."); return; } try { const result = await BatchManagementAPI.previewRestApiData( fromApiUrl, - fromApiKey || "", + authTokenMode === "direct" ? fromApiKey : "", // 직접 입력일 때만 API 키 전달 fromEndpoint, fromApiMethod, // 파라미터 정보 추가 - apiParamType !== 'none' ? { - paramType: apiParamType, - paramName: apiParamName, - paramValue: apiParamValue, - paramSource: apiParamSource - } : undefined, + apiParamType !== "none" + ? { + paramType: apiParamType, + paramName: apiParamName, + paramValue: apiParamValue, + paramSource: apiParamSource, + } + : undefined, // Request Body 추가 (POST/PUT/DELETE) - (fromApiMethod === 'POST' || fromApiMethod === 'PUT' || fromApiMethod === 'DELETE') ? fromApiBody : undefined + fromApiMethod === "POST" || fromApiMethod === "PUT" || fromApiMethod === "DELETE" ? fromApiBody : undefined, + // DB 선택 모드일 때 서비스명 전달 + authTokenMode === "db" ? authServiceName : undefined, + // 데이터 배열 경로 전달 + dataArrayPath || undefined, ); if (result.fields && result.fields.length > 0) { @@ -351,7 +382,7 @@ export default function BatchManagementNewPage() { const extractedFields = Object.keys(result.samples[0]); setFromApiFields(extractedFields); setFromApiData(result.samples); - + toast.success(`API 데이터 미리보기 완료! ${extractedFields.length}개 필드, ${result.samples.length}개 레코드`); } else { setFromApiFields([]); @@ -374,55 +405,45 @@ export default function BatchManagementNewPage() { } // 배치 타입별 검증 및 저장 - if (batchType === 'restapi-to-db') { - const mappedFields = Object.keys(apiFieldMappings).filter( - (field) => apiFieldMappings[field] + if (batchType === "restapi-to-db") { + // 유효한 매핑만 필터링 (DB 컬럼이 선택되고, API 필드 또는 고정값이 있는 것) + const validMappings = mappingList.filter( + (m) => m.dbColumn && (m.sourceType === "api" ? m.apiField : m.fixedValue), ); - if (mappedFields.length === 0) { - toast.error("최소 하나의 API 필드를 DB 컬럼에 매핑해주세요."); + + if (validMappings.length === 0) { + toast.error("최소 하나의 매핑을 설정해주세요."); return; } - - // API 필드 매핑을 배치 매핑 형태로 변환 - 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(); - } + // UPSERT 모드일 때 conflict key 검증 + if (saveMode === "UPSERT" && !conflictKey) { + toast.error("UPSERT 모드에서는 충돌 기준 컬럼을 선택해주세요."); + return; + } - 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, - }; - }); + // 통합 매핑 리스트를 배치 매핑 형태로 변환 + // 고정값 매핑도 동일한 from_connection_type을 사용해야 같은 그룹으로 처리됨 + const apiMappings = validMappings.map((mapping) => ({ + from_connection_type: "restapi" as const, // 고정값도 동일한 소스 타입 사용 + from_table_name: fromEndpoint, + from_column_name: mapping.sourceType === "api" ? mapping.apiField : mapping.fixedValue, + from_api_url: fromApiUrl, + from_api_key: authTokenMode === "direct" ? fromApiKey : "", + from_api_method: fromApiMethod, + from_api_body: + fromApiMethod === "POST" || fromApiMethod === "PUT" || fromApiMethod === "DELETE" ? fromApiBody : undefined, + 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: mapping.dbColumn, + mapping_type: mapping.sourceType === "fixed" ? ("fixed" as const) : ("direct" as const), + fixed_value: mapping.sourceType === "fixed" ? mapping.fixedValue : undefined, + })); // 실제 API 호출 try { @@ -431,13 +452,17 @@ export default function BatchManagementNewPage() { batchType, cronSchedule, description, - apiMappings + apiMappings, + authServiceName: authTokenMode === "db" ? authServiceName : undefined, + dataArrayPath: dataArrayPath || undefined, + saveMode, + conflictKey: saveMode === "UPSERT" ? conflictKey : undefined, }); if (result.success) { toast.success(result.message || "REST API 배치 설정이 저장되었습니다."); setTimeout(() => { - router.push('/admin/batchmng'); + router.push("/admin/batchmng"); }, 1000); } else { toast.error(result.message || "배치 저장에 실패했습니다."); @@ -447,67 +472,67 @@ export default function BatchManagementNewPage() { toast.error("배치 저장 중 오류가 발생했습니다."); } return; - } else if (batchType === 'db-to-restapi') { + } else if (batchType === "db-to-restapi") { // DB → REST API 배치 검증 if (!fromConnection || !fromTable || selectedColumns.length === 0) { toast.error("소스 데이터베이스, 테이블, 컬럼을 선택해주세요."); return; } - + if (!toApiUrl || !toApiKey || !toEndpoint) { toast.error("대상 API URL, API Key, 엔드포인트를 입력해주세요."); return; } - if ((toApiMethod === 'POST' || toApiMethod === 'PUT') && !toApiBody) { + if ((toApiMethod === "POST" || toApiMethod === "PUT") && !toApiBody) { toast.error("POST/PUT 메서드의 경우 Request Body 템플릿을 입력해주세요."); return; } // DELETE의 경우 빈 Request Body라도 템플릿 로직을 위해 "{}" 설정 let finalToApiBody = toApiBody; - if (toApiMethod === 'DELETE' && !finalToApiBody.trim()) { - finalToApiBody = '{}'; + if (toApiMethod === "DELETE" && !finalToApiBody.trim()) { + finalToApiBody = "{}"; } // DB → REST API 매핑 생성 (선택된 컬럼만) - const selectedColumnObjects = fromColumns.filter(column => selectedColumns.includes(column.column_name)); + const selectedColumnObjects = fromColumns.filter((column) => selectedColumns.includes(column.column_name)); const dbMappings = selectedColumnObjects.map((column, index) => ({ - from_connection_type: fromConnection.type === 'internal' ? 'internal' : 'external', - from_connection_id: fromConnection.type === 'internal' ? undefined : fromConnection.id, + from_connection_type: fromConnection.type === "internal" ? "internal" : "external", + from_connection_id: fromConnection.type === "internal" ? undefined : fromConnection.id, from_table_name: fromTable, from_column_name: column.column_name, from_column_type: column.data_type, - to_connection_type: 'restapi' as const, + to_connection_type: "restapi" as const, to_table_name: toEndpoint, // API 엔드포인트 to_column_name: dbToApiFieldMapping[column.column_name] || column.column_name, // 매핑된 API 필드명 to_api_url: toApiUrl, to_api_key: toApiKey, to_api_method: toApiMethod, to_api_body: finalToApiBody, // Request Body 템플릿 - mapping_type: 'template' as const, - mapping_order: index + 1 + mapping_type: "template" as const, + mapping_order: index + 1, })); // URL 경로 파라미터 매핑 추가 (PUT/DELETE용) - if ((toApiMethod === 'PUT' || toApiMethod === 'DELETE') && urlPathColumn) { - const urlPathColumnObject = fromColumns.find(col => col.column_name === urlPathColumn); + if ((toApiMethod === "PUT" || toApiMethod === "DELETE") && urlPathColumn) { + const urlPathColumnObject = fromColumns.find((col) => col.column_name === urlPathColumn); if (urlPathColumnObject) { dbMappings.push({ - from_connection_type: fromConnection.type === 'internal' ? 'internal' : 'external', - from_connection_id: fromConnection.type === 'internal' ? undefined : fromConnection.id, + from_connection_type: fromConnection.type === "internal" ? "internal" : "external", + from_connection_id: fromConnection.type === "internal" ? undefined : fromConnection.id, from_table_name: fromTable, from_column_name: urlPathColumn, from_column_type: urlPathColumnObject.data_type, - to_connection_type: 'restapi' as const, + to_connection_type: "restapi" as const, to_table_name: toEndpoint, - to_column_name: 'URL_PATH_PARAM', // 특별한 식별자 + to_column_name: "URL_PATH_PARAM", // 특별한 식별자 to_api_url: toApiUrl, to_api_key: toApiKey, to_api_method: toApiMethod, to_api_body: finalToApiBody, - mapping_type: 'url_path' as const, - mapping_order: 999 // 마지막 순서 + mapping_type: "url_path" as const, + mapping_order: 999, // 마지막 순서 }); } } @@ -519,13 +544,14 @@ export default function BatchManagementNewPage() { batchType, cronSchedule, description, - apiMappings: dbMappings + apiMappings: dbMappings, + authServiceName: authServiceName || undefined, }); if (result.success) { toast.success(result.message || "DB → REST API 배치 설정이 저장되었습니다."); setTimeout(() => { - router.push('/admin/batchmng'); + router.push("/admin/batchmng"); }, 1000); } else { toast.error(result.message || "배치 저장에 실패했습니다."); @@ -541,16 +567,16 @@ export default function BatchManagementNewPage() { }; return ( -
+

고급 배치 생성

@@ -565,26 +591,24 @@ export default function BatchManagementNewPage() { {/* 배치 타입 선택 */}
-
+
{batchTypeOptions.map((option) => (
setBatchType(option.value)} >
- {option.value === 'restapi-to-db' ? ( - + {option.value === "restapi-to-db" ? ( + ) : ( - + )}
-
{option.label}
-
{option.description}
+
{option.label}
+
{option.description}
@@ -592,7 +616,7 @@ export default function BatchManagementNewPage() {
-
+
- {batchType === 'restapi-to-db' ? ( + {batchType === "restapi-to-db" ? ( <> - + FROM: REST API (소스) ) : ( <> - + FROM: 데이터베이스 (소스) )} @@ -644,9 +668,9 @@ export default function BatchManagementNewPage() { {/* REST API 설정 (REST API → DB) */} - {batchType === 'restapi-to-db' && ( + {batchType === "restapi-to-db" && (
-
+
- - setFromApiKey(e.target.value)} - placeholder="ak_your_api_key_here" - /> -

- GET 메서드에서만 필수이며, POST/PUT/DELETE일 때는 선택 사항입니다. + + {/* 토큰 설정 방식 선택 */} +

+ + +
+ {/* 직접 입력 모드 */} + {authTokenMode === "direct" && ( + setFromApiKey(e.target.value)} + placeholder="Bearer eyJhbGciOiJIUzI1NiIs..." + className="mt-2" + /> + )} + {/* DB 선택 모드 */} + {authTokenMode === "db" && ( + + )} +

+ {authTokenMode === "direct" + ? "API 호출 시 Authorization 헤더에 사용할 토큰을 입력하세요." + : "auth_tokens 테이블에서 선택한 서비스의 최신 토큰을 사용합니다."}

-
+
+
+ {/* 데이터 배열 경로 */} +
+ + setDataArrayPath(e.target.value)} + placeholder="response (예: data.items, results)" + /> +

+ API 응답에서 배열 데이터가 있는 경로를 입력하세요. 비워두면 응답 전체를 사용합니다. +
+ 예시: response, data.items, result.list +

{/* Request Body (POST/PUT/DELETE용) */} - {(fromApiMethod === 'POST' || fromApiMethod === 'PUT' || fromApiMethod === 'DELETE') && ( + {(fromApiMethod === "POST" || fromApiMethod === "PUT" || fromApiMethod === "DELETE") && (