diff --git a/.cursor/rules/table-list-component-guide.mdc b/.cursor/rules/table-list-component-guide.mdc new file mode 100644 index 00000000..5d3f0e1f --- /dev/null +++ b/.cursor/rules/table-list-component-guide.mdc @@ -0,0 +1,310 @@ +# TableListComponent 개발 가이드 + +## 개요 + +`TableListComponent`는 ERP 시스템의 핵심 데이터 그리드 컴포넌트입니다. DevExpress DataGrid 스타일의 고급 기능들을 구현하고 있습니다. + +**파일 위치**: `frontend/lib/registry/components/table-list/TableListComponent.tsx` + +--- + +## 핵심 기능 목록 + +### 1. 인라인 편집 (Inline Editing) + +- 셀 더블클릭 또는 F2 키로 편집 모드 진입 +- 직접 타이핑으로도 편집 모드 진입 가능 +- Enter로 저장, Escape로 취소 +- **컬럼별 편집 가능 여부 설정** (`editable` 속성) + +```typescript +// ColumnConfig에서 editable 속성 사용 +interface ColumnConfig { + editable?: boolean; // false면 해당 컬럼 인라인 편집 불가 +} +``` + +**편집 불가 컬럼 체크 필수 위치**: +1. `handleCellDoubleClick` - 더블클릭 편집 +2. `onKeyDown` F2 케이스 - 키보드 편집 +3. `onKeyDown` default 케이스 - 직접 타이핑 편집 +4. 컨텍스트 메뉴 "셀 편집" 옵션 + +### 2. 배치 편집 (Batch Editing) + +- 여러 셀 수정 후 일괄 저장/취소 +- `pendingChanges` Map으로 변경사항 추적 +- 저장 전 유효성 검증 + +### 3. 데이터 유효성 검증 (Validation) + +```typescript +type ValidationRule = { + required?: boolean; + min?: number; + max?: number; + minLength?: number; + maxLength?: number; + pattern?: RegExp; + customMessage?: string; + validate?: (value: any, row: any) => string | null; +}; +``` + +### 4. 컬럼 헤더 필터 (Header Filter) + +- 각 컬럼 헤더에 필터 아이콘 +- 고유값 목록에서 다중 선택 필터링 +- `headerFilters` Map으로 필터 상태 관리 + +### 5. 필터 빌더 (Filter Builder) + +```typescript +interface FilterCondition { + id: string; + column: string; + operator: "equals" | "notEquals" | "contains" | "notContains" | + "startsWith" | "endsWith" | "greaterThan" | "lessThan" | + "greaterOrEqual" | "lessOrEqual" | "isEmpty" | "isNotEmpty"; + value: string; +} + +interface FilterGroup { + id: string; + logic: "AND" | "OR"; + conditions: FilterCondition[]; +} +``` + +### 6. 검색 패널 (Search Panel) + +- 전체 데이터 검색 +- 검색어 하이라이팅 +- `searchHighlights` Map으로 하이라이트 위치 관리 + +### 7. 엑셀 내보내기 (Excel Export) + +- `xlsx` 라이브러리 사용 +- 현재 표시 데이터 또는 전체 데이터 내보내기 + +```typescript +import * as XLSX from "xlsx"; + +// 사용 예시 +const worksheet = XLSX.utils.json_to_sheet(exportData); +const workbook = XLSX.utils.book_new(); +XLSX.utils.book_append_sheet(workbook, worksheet, "Sheet1"); +XLSX.writeFile(workbook, `${tableName}_${timestamp}.xlsx`); +``` + +### 8. 클립보드 복사 (Copy to Clipboard) + +- 선택된 행 또는 전체 데이터 복사 +- 탭 구분자로 엑셀 붙여넣기 호환 + +### 9. 컨텍스트 메뉴 (Context Menu) + +- 우클릭으로 메뉴 표시 +- 셀 편집, 행 복사, 행 삭제 등 옵션 +- 편집 불가 컬럼은 "(잠김)" 표시 + +### 10. 키보드 네비게이션 + +| 키 | 동작 | +|---|---| +| Arrow Keys | 셀 이동 | +| Tab | 다음 셀 | +| Shift+Tab | 이전 셀 | +| F2 | 편집 모드 | +| Enter | 저장 후 아래로 이동 | +| Escape | 편집 취소 | +| Ctrl+C | 복사 | +| Delete | 셀 값 삭제 | + +### 11. 컬럼 리사이징 + +- 컬럼 헤더 경계 드래그로 너비 조절 +- `columnWidths` 상태로 관리 +- localStorage에 저장 + +### 12. 컬럼 순서 변경 + +- 드래그 앤 드롭으로 컬럼 순서 변경 +- `columnOrder` 상태로 관리 +- localStorage에 저장 + +### 13. 상태 영속성 (State Persistence) + +```typescript +// localStorage 키 패턴 +const stateKey = `tableState_${tableName}_${userId}`; + +// 저장되는 상태 +interface TableState { + columnWidths: Record; + columnOrder: string[]; + sortBy: string; + sortOrder: "asc" | "desc"; + frozenColumns: string[]; + columnVisibility: Record; +} +``` + +### 14. 그룹화 및 그룹 소계 + +```typescript +interface GroupedData { + groupKey: string; + groupValues: Record; + items: any[]; + count: number; + summary?: Record; +} +``` + +### 15. 총계 요약 (Total Summary) + +- 숫자 컬럼의 합계, 평균, 개수 표시 +- 테이블 하단에 요약 행 렌더링 + +--- + +## 캐싱 전략 + +```typescript +// 테이블 컬럼 캐시 +const tableColumnCache = new Map(); +const TABLE_CACHE_TTL = 5 * 60 * 1000; // 5분 + +// API 호출 디바운싱 +const debouncedApiCall = ( + key: string, + fn: (...args: T) => Promise, + delay: number = 300 +) => { ... }; +``` + +--- + +## 필수 Import + +```typescript +import React, { useState, useEffect, useMemo, useCallback, useRef } from "react"; +import { TableListConfig, ColumnConfig } from "./types"; +import { tableTypeApi } from "@/lib/api/screen"; +import { entityJoinApi } from "@/lib/api/entityJoin"; +import { codeCache } from "@/lib/caching/codeCache"; +import * as XLSX from "xlsx"; +import { toast } from "sonner"; +``` + +--- + +## 주요 상태 (State) + +```typescript +// 데이터 관련 +const [tableData, setTableData] = useState([]); +const [filteredData, setFilteredData] = useState([]); +const [loading, setLoading] = useState(false); + +// 편집 관련 +const [editingCell, setEditingCell] = useState<{ + rowIndex: number; + colIndex: number; + columnName: string; + originalValue: any; +} | null>(null); +const [editingValue, setEditingValue] = useState(""); +const [pendingChanges, setPendingChanges] = useState>>(new Map()); +const [validationErrors, setValidationErrors] = useState>>(new Map()); + +// 필터 관련 +const [headerFilters, setHeaderFilters] = useState>>(new Map()); +const [filterGroups, setFilterGroups] = useState([]); +const [globalSearchText, setGlobalSearchText] = useState(""); +const [searchHighlights, setSearchHighlights] = useState>(new Map()); + +// 컬럼 관련 +const [columnWidths, setColumnWidths] = useState>({}); +const [columnOrder, setColumnOrder] = useState([]); +const [columnVisibility, setColumnVisibility] = useState>({}); +const [frozenColumns, setFrozenColumns] = useState([]); + +// 선택 관련 +const [selectedRows, setSelectedRows] = useState>(new Set()); +const [focusedCell, setFocusedCell] = useState<{ rowIndex: number; colIndex: number } | null>(null); + +// 정렬 관련 +const [sortBy, setSortBy] = useState(""); +const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc"); + +// 페이지네이션 +const [currentPage, setCurrentPage] = useState(1); +const [pageSize, setPageSize] = useState(20); +const [totalCount, setTotalCount] = useState(0); +``` + +--- + +## 편집 불가 컬럼 구현 체크리스트 + +새로운 편집 진입점을 추가할 때 반드시 다음을 확인하세요: + +- [ ] `column.editable === false` 체크 추가 +- [ ] 편집 불가 시 `toast.warning()` 메시지 표시 +- [ ] `return` 또는 `break`로 편집 모드 진입 방지 + +```typescript +// 표준 편집 불가 체크 패턴 +const column = visibleColumns.find((col) => col.columnName === columnName); +if (column?.editable === false) { + toast.warning(`'${column.displayName || columnName}' 컬럼은 편집할 수 없습니다.`); + return; +} +``` + +--- + +## 시각적 표시 + +### 편집 불가 컬럼 표시 + +```tsx +// 헤더에 잠금 아이콘 +{column.editable === false && ( + +)} + +// 셀 배경색 +className={cn( + column.editable === false && "bg-gray-50 dark:bg-gray-900/30" +)} +``` + +--- + +## 성능 최적화 + +1. **useMemo 사용**: `visibleColumns`, `filteredData`, `paginatedData` 등 계산 비용이 큰 값 +2. **useCallback 사용**: 이벤트 핸들러 함수들 +3. **디바운싱**: API 호출, 검색, 필터링 +4. **캐싱**: 테이블 컬럼 정보, 코드 데이터 + +--- + +## 주의사항 + +1. **visibleColumns 정의 순서**: `columnOrder`, `columnVisibility` 상태 이후에 정의해야 함 +2. **editInputRef 타입 체크**: `select()` 호출 전 `instanceof HTMLInputElement` 확인 +3. **localStorage 키**: `tableName`과 `userId`를 조합하여 고유하게 생성 +4. **멀티테넌시**: 모든 API 호출에 `company_code` 필터링 적용 (백엔드에서 자동 처리) + +--- + +## 관련 파일 + +- `frontend/lib/registry/components/table-list/types.ts` - 타입 정의 +- `frontend/lib/registry/components/table-list/TableListConfigPanel.tsx` - 설정 패널 +- `frontend/components/common/TableOptionsModal.tsx` - 옵션 모달 +- `frontend/lib/registry/components/table-list/SingleTableWithSticky.tsx` - 스티키 헤더 테이블 diff --git a/backend-node/src/controllers/DashboardController.ts b/backend-node/src/controllers/DashboardController.ts index e324c332..a03478b9 100644 --- a/backend-node/src/controllers/DashboardController.ts +++ b/backend-node/src/controllers/DashboardController.ts @@ -632,6 +632,9 @@ export class DashboardController { validateStatus: () => true, // 모든 상태 코드 허용 (에러도 응답으로 처리) }; + // 연결 정보 (응답에 포함용) + let connectionInfo: { saveToHistory?: boolean } | null = null; + // 외부 커넥션 ID가 있는 경우, 해당 커넥션의 인증 정보(DB 토큰 등)를 적용 if (externalConnectionId) { try { @@ -652,6 +655,11 @@ export class DashboardController { if (connectionResult.success && connectionResult.data) { const connection = connectionResult.data; + // 연결 정보 저장 (응답에 포함) + connectionInfo = { + saveToHistory: connection.save_to_history === "Y", + }; + // 인증 헤더 생성 (DB 토큰 등) const authHeaders = await ExternalRestApiConnectionService.getAuthHeaders( @@ -709,9 +717,9 @@ export class DashboardController { } // 기상청 API 등 EUC-KR 인코딩을 사용하는 경우 arraybuffer로 받아서 디코딩 - const isKmaApi = urlObj.hostname.includes('kma.go.kr'); + const isKmaApi = urlObj.hostname.includes("kma.go.kr"); if (isKmaApi) { - requestConfig.responseType = 'arraybuffer'; + requestConfig.responseType = "arraybuffer"; } const response = await axios(requestConfig); @@ -727,18 +735,22 @@ export class DashboardController { // 기상청 API 인코딩 처리 (UTF-8 우선, 실패 시 EUC-KR) if (isKmaApi && Buffer.isBuffer(data)) { - const iconv = require('iconv-lite'); + const iconv = require("iconv-lite"); const buffer = Buffer.from(data); - const utf8Text = buffer.toString('utf-8'); - + const utf8Text = buffer.toString("utf-8"); + // UTF-8로 정상 디코딩되었는지 확인 - if (utf8Text.includes('특보') || utf8Text.includes('경보') || utf8Text.includes('주의보') || - (utf8Text.includes('#START7777') && !utf8Text.includes('�'))) { - data = { text: utf8Text, contentType, encoding: 'utf-8' }; + if ( + utf8Text.includes("특보") || + utf8Text.includes("경보") || + utf8Text.includes("주의보") || + (utf8Text.includes("#START7777") && !utf8Text.includes("�")) + ) { + data = { text: utf8Text, contentType, encoding: "utf-8" }; } else { // EUC-KR로 디코딩 - const eucKrText = iconv.decode(buffer, 'EUC-KR'); - data = { text: eucKrText, contentType, encoding: 'euc-kr' }; + const eucKrText = iconv.decode(buffer, "EUC-KR"); + data = { text: eucKrText, contentType, encoding: "euc-kr" }; } } // 텍스트 응답인 경우 포맷팅 @@ -749,6 +761,7 @@ export class DashboardController { res.status(200).json({ success: true, data, + connectionInfo, // 외부 연결 정보 (saveToHistory 등) }); } catch (error: any) { const status = error.response?.status || 500; diff --git a/backend-node/src/controllers/batchManagementController.ts b/backend-node/src/controllers/batchManagementController.ts index 05aece84..bdd9e869 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,47 @@ export class BatchManagementController { }); } - // GET 요청일 때만 API Key 필수 (POST/PUT/DELETE는 선택) - if ((!method || method === "GET") && !apiKey) { - return res.status(400).json({ - success: false, - message: "GET 메서드에서는 API Key가 필요합니다.", - }); + // 토큰 결정: authServiceName이 있으면 DB에서 조회, 없으면 apiKey 사용 + let finalApiKey = apiKey || ""; + if (authServiceName) { + const companyCode = req.user?.companyCode; + + // DB에서 토큰 조회 (멀티테넌시: company_code 필터링) + let tokenQuery: string; + let tokenParams: any[]; + + if (companyCode === "*") { + // 최고 관리자: 모든 회사 토큰 조회 가능 + tokenQuery = `SELECT access_token FROM auth_tokens + WHERE service_name = $1 + ORDER BY created_date DESC LIMIT 1`; + tokenParams = [authServiceName]; + } else { + // 일반 회사: 자신의 회사 토큰만 조회 + tokenQuery = `SELECT access_token FROM auth_tokens + WHERE service_name = $1 AND company_code = $2 + ORDER BY created_date DESC LIMIT 1`; + tokenParams = [authServiceName, companyCode]; + } + + const tokenResult = await query<{ access_token: string }>( + tokenQuery, + tokenParams + ); + 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}'의 토큰을 찾을 수 없습니다. 먼저 토큰 저장 배치를 실행하세요.`, + }); + } } - console.log("🔍 REST API 미리보기 요청:", { + // 토큰이 없어도 공개 API 호출 가능 (토큰 검증 제거) + + console.log("REST API 미리보기 요청:", { apiUrl, endpoint, method, @@ -449,6 +484,8 @@ export class BatchManagementController { paramValue, paramSource, requestBody: requestBody ? "Included" : "None", + authServiceName: authServiceName || "직접 입력", + dataArrayPath: dataArrayPath || "전체 응답", }); // RestApiConnector 사용하여 데이터 조회 @@ -456,7 +493,7 @@ export class BatchManagementController { const connector = new RestApiConnector({ baseUrl: apiUrl, - apiKey: apiKey || "", + apiKey: finalApiKey, timeout: 30000, }); @@ -511,8 +548,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 +603,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 +633,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 +664,10 @@ export class BatchManagementController { cronSchedule, description, apiMappings, + authServiceName, + dataArrayPath, + saveMode, + conflictKey, }); // 🔐 멀티테넌시: 현재 사용자 회사 코드 사용 (프론트에서 받지 않음) @@ -589,6 +681,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 +721,51 @@ export class BatchManagementController { }); } } + + /** + * 인증 토큰 서비스명 목록 조회 + */ + static async getAuthServiceNames(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user?.companyCode; + + // 멀티테넌시: company_code 필터링 + let queryText: string; + let queryParams: any[] = []; + + if (companyCode === "*") { + // 최고 관리자: 모든 서비스 조회 + queryText = `SELECT DISTINCT service_name + FROM auth_tokens + WHERE service_name IS NOT NULL + ORDER BY service_name`; + } else { + // 일반 회사: 자신의 회사 서비스만 조회 + queryText = `SELECT DISTINCT service_name + FROM auth_tokens + WHERE service_name IS NOT NULL + AND company_code = $1 + ORDER BY service_name`; + queryParams = [companyCode]; + } + + const result = await query<{ service_name: string }>( + queryText, + queryParams + ); + + 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/controllers/dynamicFormController.ts b/backend-node/src/controllers/dynamicFormController.ts index 30364189..97cd2cc1 100644 --- a/backend-node/src/controllers/dynamicFormController.ts +++ b/backend-node/src/controllers/dynamicFormController.ts @@ -492,7 +492,7 @@ export const saveLocationHistory = async ( res: Response ): Promise => { try { - const { companyCode, userId } = req.user as any; + const { companyCode, userId: loginUserId } = req.user as any; const { latitude, longitude, @@ -508,10 +508,17 @@ export const saveLocationHistory = async ( destinationName, recordedAt, vehicleId, + userId: requestUserId, // 프론트엔드에서 보낸 userId (차량 번호판 등) } = req.body; + // 프론트엔드에서 보낸 userId가 있으면 그것을 사용 (차량 번호판 등) + // 없으면 로그인한 사용자의 userId 사용 + const userId = requestUserId || loginUserId; + console.log("📍 [saveLocationHistory] 요청:", { userId, + requestUserId, + loginUserId, companyCode, latitude, longitude, 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..743c0386 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,46 @@ export class BatchSchedulerService { "./batchExternalDbService" ); + // auth_service_name이 설정된 경우 auth_tokens에서 토큰 조회 (멀티테넌시 적용) + let apiKey = firstMapping.from_api_key || ""; + if (config.auth_service_name) { + let tokenQuery: string; + let tokenParams: any[]; + + if (config.company_code === "*") { + // 최고 관리자 배치: 모든 회사 토큰 조회 가능 + tokenQuery = `SELECT access_token FROM auth_tokens + WHERE service_name = $1 + ORDER BY created_date DESC LIMIT 1`; + tokenParams = [config.auth_service_name]; + } else { + // 일반 회사 배치: 자신의 회사 토큰만 조회 + tokenQuery = `SELECT access_token FROM auth_tokens + WHERE service_name = $1 AND company_code = $2 + ORDER BY created_date DESC LIMIT 1`; + tokenParams = [config.auth_service_name, config.company_code]; + } + + const tokenResult = await query<{ access_token: string }>( + tokenQuery, + tokenParams + ); + 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 +318,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 +379,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 +401,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 +477,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..31ee2001 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,22 @@ 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); + } + if (data.dataArrayPath !== undefined) { + updateFields.push(`data_array_path = $${paramIndex++}`); + updateValues.push(data.dataArrayPath || null); + } // 배치 설정 업데이트 const batchConfigResult = await client.query( @@ -339,8 +360,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 +389,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 +576,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 +749,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 +781,54 @@ 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 + ); + + // 업데이트할 컬럼이 없으면 DO NOTHING 사용 + if (updateColumns.length === 0) { + queryStr = `INSERT INTO ${tableName} (${columns.join(", ")}) + VALUES (${placeholders}) + ON CONFLICT (${conflictKey}) + DO NOTHING`; + } else { + 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 +837,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 +863,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/services/externalRestApiConnectionService.ts b/backend-node/src/services/externalRestApiConnectionService.ts index 2632a6e6..6f0b1239 100644 --- a/backend-node/src/services/externalRestApiConnectionService.ts +++ b/backend-node/src/services/externalRestApiConnectionService.ts @@ -209,8 +209,8 @@ export class ExternalRestApiConnectionService { connection_name, description, base_url, endpoint_path, default_headers, default_method, default_request_body, auth_type, auth_config, timeout, retry_count, retry_delay, - company_code, is_active, created_by - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) + company_code, is_active, created_by, save_to_history + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) RETURNING * `; @@ -230,6 +230,7 @@ export class ExternalRestApiConnectionService { data.company_code || "*", data.is_active || "Y", data.created_by || "system", + data.save_to_history || "N", ]; // 디버깅: 저장하려는 데이터 로깅 @@ -377,6 +378,12 @@ export class ExternalRestApiConnectionService { paramIndex++; } + if (data.save_to_history !== undefined) { + updateFields.push(`save_to_history = $${paramIndex}`); + params.push(data.save_to_history); + paramIndex++; + } + if (data.updated_by !== undefined) { updateFields.push(`updated_by = $${paramIndex}`); params.push(data.updated_by); 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/backend-node/src/types/externalRestApiTypes.ts b/backend-node/src/types/externalRestApiTypes.ts index 8d95a4a6..416cbe6f 100644 --- a/backend-node/src/types/externalRestApiTypes.ts +++ b/backend-node/src/types/externalRestApiTypes.ts @@ -53,6 +53,9 @@ export interface ExternalRestApiConnection { retry_delay?: number; company_code: string; is_active: string; + + // 위치 이력 저장 설정 (지도 위젯용) + save_to_history?: string; // 'Y' 또는 'N' - REST API에서 가져온 위치 데이터를 vehicle_location_history에 저장 created_date?: Date; created_by?: string; updated_date?: Date; diff --git a/docker/deploy/docker-compose.yml b/docker/deploy/docker-compose.yml index 86b3b323..b3cc4996 100644 --- a/docker/deploy/docker-compose.yml +++ b/docker/deploy/docker-compose.yml @@ -12,7 +12,7 @@ services: NODE_ENV: production PORT: "3001" HOST: 0.0.0.0 - DATABASE_URL: postgresql://postgres:ph0909!!@39.117.244.52:11132/plm + DATABASE_URL: postgresql://postgres:vexplor0909!!@211.115.91.141:11134/plm JWT_SECRET: ilshin-plm-super-secret-jwt-key-2024 JWT_EXPIRES_IN: 24h CORS_ORIGIN: https://v1.vexplor.com,https://api.vexplor.com diff --git a/frontend/app/(main)/admin/batch-management-new/page.tsx b/frontend/app/(main)/admin/batch-management-new/page.tsx index 2046ed3e..3093ed10 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,19 +567,10 @@ export default function BatchManagementNewPage() { }; return ( -
-
+
+ {/* 페이지 헤더 */} +

고급 배치 생성

-
- - -
{/* 기본 정보 */} @@ -565,26 +582,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 +607,7 @@ export default function BatchManagementNewPage() {
-
+
- {/* FROM 설정 */} - - - - {batchType === 'restapi-to-db' ? ( - <> - - FROM: REST API (소스) - - ) : ( - <> - - FROM: 데이터베이스 (소스) - - )} - - - - {/* REST API 설정 (REST API → DB) */} - {batchType === 'restapi-to-db' && ( -
-
+ {/* FROM/TO 설정 - 가로 배치 */} +
+ {/* FROM 설정 */} + + + + {batchType === "restapi-to-db" ? ( + <> + + FROM: REST API (소스) + + ) : ( + <> + + FROM: 데이터베이스 (소스) + + )} + + + + {/* REST API 설정 (REST API → DB) */} + {batchType === "restapi-to-db" && ( +
+ {/* API 서버 URL */}
+ + {/* 인증 토큰 설정 */}
- - 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 테이블에서 선택한 서비스의 최신 토큰을 사용합니다."}

-
-
+ {/* 엔드포인트 */}
+ + {/* HTTP 메서드 */}
-
- - {/* Request Body (POST/PUT/DELETE용) */} - {(fromApiMethod === 'POST' || fromApiMethod === 'PUT' || fromApiMethod === 'DELETE') && ( + {/* 데이터 배열 경로 */}
- -