diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index bb64aaea..8aae03af 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -35,6 +35,8 @@ import multiConnectionRoutes from "./routes/multiConnectionRoutes"; import dbTypeCategoryRoutes from "./routes/dbTypeCategoryRoutes"; import ddlRoutes from "./routes/ddlRoutes"; import entityReferenceRoutes from "./routes/entityReferenceRoutes"; +import externalCallRoutes from "./routes/externalCallRoutes"; +import externalCallConfigRoutes from "./routes/externalCallConfigRoutes"; // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석 // import userRoutes from './routes/userRoutes'; @@ -88,7 +90,7 @@ app.use( // Rate Limiting (개발 환경에서는 완화) const limiter = rateLimit({ windowMs: 1 * 60 * 1000, // 1분 - max: config.nodeEnv === "development" ? 10000 : 100, // 개발환경에서는 10000으로 증가, 운영환경에서는 100 + max: config.nodeEnv === "development" ? 10000 : 1000, // 개발환경에서는 10000으로 증가, 운영환경에서는 100 message: { error: "너무 많은 요청이 발생했습니다. 잠시 후 다시 시도해주세요.", }, @@ -142,6 +144,8 @@ app.use("/api/multi-connection", multiConnectionRoutes); app.use("/api/db-type-categories", dbTypeCategoryRoutes); app.use("/api/ddl", ddlRoutes); app.use("/api/entity-reference", entityReferenceRoutes); +app.use("/api/external-calls", externalCallRoutes); +app.use("/api/external-call-configs", externalCallConfigRoutes); // app.use("/api/collections", collectionRoutes); // 임시 주석 // app.use("/api/batch", batchRoutes); // 임시 주석 // app.use('/api/users', userRoutes); diff --git a/backend-node/src/services/externalCallService.ts b/backend-node/src/services/externalCallService.ts index 703c1b2c..54c0cbf9 100644 --- a/backend-node/src/services/externalCallService.ts +++ b/backend-node/src/services/externalCallService.ts @@ -180,10 +180,57 @@ export class ExternalCallService { body = this.processTemplate(body, templateData); } + // 기본 헤더 준비 + const headers = { ...(settings.headers || {}) }; + + // 인증 정보 처리 + if (settings.authentication) { + switch (settings.authentication.type) { + case "api-key": + if (settings.authentication.apiKey) { + headers["X-API-Key"] = settings.authentication.apiKey; + } + break; + case "basic": + if ( + settings.authentication.username && + settings.authentication.password + ) { + const credentials = Buffer.from( + `${settings.authentication.username}:${settings.authentication.password}` + ).toString("base64"); + headers["Authorization"] = `Basic ${credentials}`; + } + break; + case "bearer": + if (settings.authentication.token) { + headers["Authorization"] = + `Bearer ${settings.authentication.token}`; + } + break; + case "custom": + if ( + settings.authentication.headerName && + settings.authentication.headerValue + ) { + headers[settings.authentication.headerName] = + settings.authentication.headerValue; + } + break; + // 'none' 타입은 아무것도 하지 않음 + } + } + + console.log(`🔐 [ExternalCallService] 인증 처리 완료:`, { + authType: settings.authentication?.type || "none", + hasAuthHeader: !!headers["Authorization"], + headers: Object.keys(headers), + }); + return await this.makeHttpRequest({ url: settings.url, method: settings.method, - headers: settings.headers || {}, + headers: headers, body: body, timeout: settings.timeout || this.DEFAULT_TIMEOUT, }); @@ -213,17 +260,36 @@ export class ExternalCallService { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), options.timeout); - const response = await fetch(options.url, { + // GET, HEAD 메서드는 body를 가질 수 없음 + const method = options.method.toUpperCase(); + const requestOptions: RequestInit = { method: options.method, headers: options.headers, - body: options.body, signal: controller.signal, - }); + }; + + // GET, HEAD 메서드가 아닌 경우에만 body 추가 + if (method !== "GET" && method !== "HEAD" && options.body) { + requestOptions.body = options.body; + } + + const response = await fetch(options.url, requestOptions); clearTimeout(timeoutId); const responseText = await response.text(); + // 디버깅을 위한 로그 추가 + console.log(`🔍 [ExternalCallService] HTTP 응답:`, { + url: options.url, + method: options.method, + status: response.status, + statusText: response.statusText, + ok: response.ok, + headers: Object.fromEntries(response.headers.entries()), + responseText: responseText.substring(0, 500), // 처음 500자만 로그 + }); + return { success: response.ok, statusCode: response.status, diff --git a/backend-node/src/types/externalCallTypes.ts b/backend-node/src/types/externalCallTypes.ts index 47f52411..4e8edd4c 100644 --- a/backend-node/src/types/externalCallTypes.ts +++ b/backend-node/src/types/externalCallTypes.ts @@ -53,14 +53,26 @@ export interface DiscordSettings extends ExternalCallConfig { avatarUrl?: string; } +// 인증 설정 타입 +export interface AuthenticationSettings { + type: "none" | "api-key" | "basic" | "bearer" | "custom"; + apiKey?: string; + username?: string; + password?: string; + token?: string; + headerName?: string; + headerValue?: string; +} + // 일반 REST API 설정 export interface GenericApiSettings extends ExternalCallConfig { callType: "rest-api"; apiType: "generic"; url: string; - method: "GET" | "POST" | "PUT" | "DELETE"; + method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "HEAD"; headers?: Record; body?: string; + authentication?: AuthenticationSettings; } // 이메일 설정 diff --git a/frontend/components/dataflow/ConnectionSetupModal.tsx b/frontend/components/dataflow/ConnectionSetupModal.tsx index 7c70f09e..2136fcbe 100644 --- a/frontend/components/dataflow/ConnectionSetupModal.tsx +++ b/frontend/components/dataflow/ConnectionSetupModal.tsx @@ -23,6 +23,7 @@ import { SimpleExternalCallSettings, ConnectionSetupModalProps, } from "@/types/connectionTypes"; +import { ExternalCallConfig } from "@/types/external-call/ExternalCallTypes"; import { isConditionalConnection } from "@/utils/connectionUtils"; import { useConditionManager } from "@/hooks/useConditionManager"; import { ConditionalSettings } from "./condition/ConditionalSettings"; @@ -30,6 +31,7 @@ import { ConnectionTypeSelector } from "./connection/ConnectionTypeSelector"; import { SimpleKeySettings as SimpleKeySettingsComponent } from "./connection/SimpleKeySettings"; import { DataSaveSettings as DataSaveSettingsComponent } from "./connection/DataSaveSettings"; import { SimpleExternalCallSettings as ExternalCallSettingsComponent } from "./connection/SimpleExternalCallSettings"; +import ExternalCallPanel from "./external-call/ExternalCallPanel"; import { toast } from "sonner"; export const ConnectionSetupModal: React.FC = ({ @@ -60,6 +62,9 @@ export const ConnectionSetupModal: React.FC = ({ message: "", }); + // 새로운 외부호출 설정 상태 (분리된 컴포넌트용) + const [externalCallConfig, setExternalCallConfig] = useState(null); + // 테이블 및 컬럼 선택을 위한 상태들 const [availableTables, setAvailableTables] = useState([]); const [selectedFromTable, setSelectedFromTable] = useState(""); @@ -390,14 +395,21 @@ export const ConnectionSetupModal: React.FC = ({ } break; case "external-call": - // 외부 호출은 plan에 저장 - plan = { - externalCall: { - configId: externalCallSettings.configId, - configName: externalCallSettings.configName, - message: externalCallSettings.message, - }, - }; + // 새로운 외부호출 설정을 plan에 저장 + if (externalCallConfig) { + plan = { + externalCall: externalCallConfig, + }; + } else { + // 기존 설정 호환성 유지 + plan = { + externalCall: { + configId: externalCallSettings.configId, + configName: externalCallSettings.configName, + message: externalCallSettings.message, + }, + }; + } settings = {}; // 외부 호출은 settings에 저장하지 않음 break; } @@ -507,6 +519,9 @@ export const ConnectionSetupModal: React.FC = ({ // 연결 종류별 설정 패널 렌더링 const renderConnectionTypeSettings = () => { + console.log("🔍 [ConnectionSetupModal] renderConnectionTypeSettings - connectionType:", config.connectionType); + console.log("🔍 [ConnectionSetupModal] externalCallConfig:", externalCallConfig); + switch (config.connectionType) { case "simple-key": return ( @@ -540,8 +555,13 @@ export const ConnectionSetupModal: React.FC = ({ ); case "external-call": + console.log("🚀 [ConnectionSetupModal] Rendering ExternalCallPanel"); return ( - + ); default: @@ -631,8 +651,12 @@ export const ConnectionSetupModal: React.FC = ({ return !hasActions || !allActionsHaveMappings || !allMappingsComplete || !allRequiredConditionsMet; case "external-call": - // 외부 호출: 설정 ID와 메시지가 있어야 함 - return !externalCallSettings.configId || !externalCallSettings.message?.trim(); + // 외부 호출: 새로운 설정이 있으면 API URL 검증, 없으면 기존 설정 검증 + if (externalCallConfig) { + return !externalCallConfig.restApiSettings?.apiUrl?.trim(); + } else { + return !externalCallSettings.configId || !externalCallSettings.message?.trim(); + } default: return false; diff --git a/frontend/components/dataflow/connection/ConnectionTypeSelector.tsx b/frontend/components/dataflow/connection/ConnectionTypeSelector.tsx index d572e7e2..1959582a 100644 --- a/frontend/components/dataflow/connection/ConnectionTypeSelector.tsx +++ b/frontend/components/dataflow/connection/ConnectionTypeSelector.tsx @@ -47,7 +47,10 @@ export const ConnectionTypeSelector: React.FC = ({ ? "border-orange-500 bg-orange-50" : "border-gray-200 hover:border-gray-300" }`} - onClick={() => onConfigChange({ ...config, connectionType: "external-call" })} + onClick={() => { + console.log("🔄 [ConnectionTypeSelector] External call selected"); + onConfigChange({ ...config, connectionType: "external-call" }); + }} >
외부 호출
diff --git a/frontend/components/dataflow/connection/redesigned/DataConnectionDesigner.tsx b/frontend/components/dataflow/connection/redesigned/DataConnectionDesigner.tsx index 8eafb51d..00ab5487 100644 --- a/frontend/components/dataflow/connection/redesigned/DataConnectionDesigner.tsx +++ b/frontend/components/dataflow/connection/redesigned/DataConnectionDesigner.tsx @@ -114,6 +114,9 @@ const DataConnectionDesigner: React.FC = ({ controlConditions: initialData.controlConditions || prev.controlConditions, fieldMappings: initialData.fieldMappings || prev.fieldMappings, + // 🔧 외부호출 설정 로드 + externalCallConfig: initialData.externalCallConfig || prev.externalCallConfig, + // 🔧 액션 그룹 데이터 로드 (기존 호환성 포함) actionGroups: initialData.actionGroups || @@ -155,6 +158,7 @@ const DataConnectionDesigner: React.FC = ({ const actions: DataConnectionActions = { // 연결 타입 설정 setConnectionType: useCallback((type: "data_save" | "external_call") => { + console.log("🔄 [DataConnectionDesigner] setConnectionType 호출됨:", type); setState((prev) => ({ ...prev, connectionType: type, @@ -376,6 +380,15 @@ const DataConnectionDesigner: React.FC = ({ toast.success("제어 조건이 삭제되었습니다."); }, []), + // 외부호출 설정 업데이트 + updateExternalCallConfig: useCallback((config: any) => { + console.log("🔄 외부호출 설정 업데이트:", config); + setState((prev) => ({ + ...prev, + externalCallConfig: config, + })); + }, []), + // 액션 설정 관리 setActionType: useCallback((type: "insert" | "update" | "delete" | "upsert") => { setState((prev) => ({ @@ -534,6 +547,15 @@ const DataConnectionDesigner: React.FC = ({ return; } + // 외부호출인 경우 API URL만 확인 (테이블 검증 제외) + if (state.connectionType === "external_call") { + if (!state.externalCallConfig?.restApiSettings?.apiUrl) { + toast.error("API URL을 입력해주세요."); + return; + } + // 외부호출은 테이블 정보 검증 건너뛰기 + } + // 중복 체크 (수정 모드가 아닌 경우에만) if (!diagramId) { try { @@ -558,22 +580,62 @@ const DataConnectionDesigner: React.FC = ({ relationshipName: state.relationshipName, description: state.description, connectionType: state.connectionType, - fromConnection: state.fromConnection, - toConnection: state.toConnection, - fromTable: state.fromTable, - toTable: state.toTable, + // 외부호출인 경우 테이블 정보는 선택사항 + fromConnection: state.connectionType === "external_call" ? null : state.fromConnection, + toConnection: state.connectionType === "external_call" ? null : state.toConnection, + fromTable: state.connectionType === "external_call" ? null : state.fromTable, + toTable: state.connectionType === "external_call" ? null : state.toTable, // 🔧 멀티 액션 그룹 데이터 포함 - actionGroups: state.actionGroups, + actionGroups: state.connectionType === "external_call" ? [] : state.actionGroups, groupsLogicalOperator: state.groupsLogicalOperator, + // 외부호출 설정 포함 + externalCallConfig: state.externalCallConfig, // 기존 호환성을 위한 필드들 (첫 번째 액션 그룹의 첫 번째 액션에서 추출) - actionType: state.actionGroups[0]?.actions[0]?.actionType || state.actionType || "insert", - controlConditions: state.controlConditions, - actionConditions: state.actionGroups[0]?.actions[0]?.conditions || state.actionConditions || [], - fieldMappings: state.actionGroups[0]?.actions[0]?.fieldMappings || state.fieldMappings || [], + actionType: + state.connectionType === "external_call" + ? "external_call" + : state.actionGroups[0]?.actions[0]?.actionType || state.actionType || "insert", + controlConditions: state.connectionType === "external_call" ? [] : state.controlConditions, + actionConditions: + state.connectionType === "external_call" + ? [] + : state.actionGroups[0]?.actions[0]?.conditions || state.actionConditions || [], + fieldMappings: + state.connectionType === "external_call" + ? [] + : state.actionGroups[0]?.actions[0]?.fieldMappings || state.fieldMappings || [], }; console.log("💾 직접 저장 시작:", { saveData, diagramId, isEdit: !!diagramId }); + // 외부호출인 경우 external-call-configs에 설정 저장 + if (state.connectionType === "external_call" && state.externalCallConfig) { + try { + const { ExternalCallConfigAPI } = await import("@/lib/api/externalCallConfig"); + + const configData = { + config_name: state.relationshipName || "외부호출 설정", + call_type: "rest-api", + api_type: "generic", + config_data: state.externalCallConfig.restApiSettings, + description: state.description || "", + company_code: "*", // 기본값 + }; + + const configResult = await ExternalCallConfigAPI.createConfig(configData); + + if (!configResult.success) { + throw new Error(configResult.error || "외부호출 설정 저장 실패"); + } + + console.log("✅ 외부호출 설정 저장 완료:", configResult.data); + } catch (configError) { + console.error("❌ 외부호출 설정 저장 실패:", configError); + // 외부호출 설정 저장 실패해도 관계는 저장하도록 함 + toast.error("외부호출 설정 저장에 실패했지만 관계는 저장되었습니다."); + } + } + // 백엔드 API 호출 (수정 모드인 경우 diagramId 전달) const result = await saveDataflowRelationship(saveData, diagramId); @@ -641,15 +703,15 @@ const DataConnectionDesigner: React.FC = ({ )} {/* 메인 컨텐츠 - 좌우 분할 레이아웃 */} -
- {/* 좌측 패널 (30%) */} +
+ {/* 좌측 패널 (30%) - 항상 표시 */}
{/* 우측 패널 (70%) */}
- +
diff --git a/frontend/components/dataflow/connection/redesigned/LeftPanel/ConnectionTypeSelector.tsx b/frontend/components/dataflow/connection/redesigned/LeftPanel/ConnectionTypeSelector.tsx index 80f7d284..4002a408 100644 --- a/frontend/components/dataflow/connection/redesigned/LeftPanel/ConnectionTypeSelector.tsx +++ b/frontend/components/dataflow/connection/redesigned/LeftPanel/ConnectionTypeSelector.tsx @@ -35,7 +35,10 @@ const ConnectionTypeSelector: React.FC = ({ selecte onTypeChange(value as "data_save" | "external_call")} + onValueChange={(value) => { + console.log("🔘 [ConnectionTypeSelector] 라디오 버튼 변경:", value); + onTypeChange(value as "data_save" | "external_call"); + }} className="space-y-3" > {connectionTypes.map((type) => ( diff --git a/frontend/components/dataflow/connection/redesigned/LeftPanel/LeftPanel.tsx b/frontend/components/dataflow/connection/redesigned/LeftPanel/LeftPanel.tsx index 32ab354b..e574fefa 100644 --- a/frontend/components/dataflow/connection/redesigned/LeftPanel/LeftPanel.tsx +++ b/frontend/components/dataflow/connection/redesigned/LeftPanel/LeftPanel.tsx @@ -30,54 +30,70 @@ const LeftPanel: React.FC = ({ state, actions }) => { - + {/* 외부호출이 아닐 때만 매핑과 액션 설정 표시 */} + {state.connectionType !== "external_call" && ( + <> + - {/* 매핑 상세 목록 */} - {(() => { - // 액션 그룹에서 모든 매핑 수집 - const allMappings = state.actionGroups.flatMap((group) => - group.actions.flatMap((action) => action.fieldMappings || []), - ); + {/* 매핑 상세 목록 */} + {(() => { + // 액션 그룹에서 모든 매핑 수집 + const allMappings = state.actionGroups.flatMap((group) => + group.actions.flatMap((action) => action.fieldMappings || []), + ); - // 기존 fieldMappings와 병합 (중복 제거) - const combinedMappings = [...state.fieldMappings, ...allMappings]; - const uniqueMappings = combinedMappings.filter( - (mapping, index, arr) => arr.findIndex((m) => m.id === mapping.id) === index, - ); + // 기존 fieldMappings와 병합 (중복 제거) + const combinedMappings = [...state.fieldMappings, ...allMappings]; + const uniqueMappings = combinedMappings.filter( + (mapping, index, arr) => arr.findIndex((m) => m.id === mapping.id) === index, + ); - console.log("🔍 LeftPanel - 매핑 데이터 수집:", { - stateFieldMappings: state.fieldMappings, - actionGroupMappings: allMappings, - combinedMappings: uniqueMappings, - }); + console.log("🔍 LeftPanel - 매핑 데이터 수집:", { + stateFieldMappings: state.fieldMappings, + actionGroupMappings: allMappings, + combinedMappings: uniqueMappings, + }); - return ( - uniqueMappings.length > 0 && ( - <> -
-

매핑 상세 목록

- { - // TODO: 선택된 매핑 상태 업데이트 - }} - onUpdateMapping={actions.updateMapping} - onDeleteMapping={actions.deleteMapping} - /> -
+ return ( + uniqueMappings.length > 0 && ( + <> +
+

매핑 상세 목록

+ { + // TODO: 선택된 매핑 상태 업데이트 + }} + onUpdateMapping={actions.updateMapping} + onDeleteMapping={actions.deleteMapping} + /> +
- - - ) - ); - })()} + + + ) + ); + })()} - {/* 액션 설정 요약 */} -
-

액션 설정

- -
+ {/* 액션 설정 요약 */} +
+

액션 설정

+ +
+ + )} + + {/* 외부호출일 때는 간단한 설명만 표시 */} + {state.connectionType === "external_call" && ( + <> + +
+

외부 호출 모드

+

우측 패널에서 REST API 설정을 구성하세요.

+
+ + )} diff --git a/frontend/components/dataflow/connection/redesigned/RightPanel/RightPanel.tsx b/frontend/components/dataflow/connection/redesigned/RightPanel/RightPanel.tsx index f3d9b8ba..570256fc 100644 --- a/frontend/components/dataflow/connection/redesigned/RightPanel/RightPanel.tsx +++ b/frontend/components/dataflow/connection/redesigned/RightPanel/RightPanel.tsx @@ -1,7 +1,9 @@ "use client"; -import React from "react"; +import React, { useEffect } from "react"; import { Card } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Globe } from "lucide-react"; // 타입 import import { RightPanelProps } from "../types/redesigned"; @@ -14,6 +16,7 @@ import FieldMappingStep from "./FieldMappingStep"; import ControlConditionStep from "./ControlConditionStep"; import ActionConfigStep from "./ActionConfigStep"; import MultiActionConfigStep from "./MultiActionConfigStep"; +import ExternalCallPanel from "../../../external-call/ExternalCallPanel"; /** * 🎯 우측 패널 (70% 너비) @@ -22,6 +25,12 @@ import MultiActionConfigStep from "./MultiActionConfigStep"; * - 시각적 매핑 영역 */ const RightPanel: React.FC = ({ state, actions }) => { + console.log("🔄 [RightPanel] 컴포넌트 렌더링 - connectionType:", state.connectionType); + + // connectionType 변경 감지 + useEffect(() => { + console.log("🔄 [RightPanel] connectionType 변경됨:", state.connectionType); + }, [state.connectionType]); // 완료된 단계 계산 const completedSteps: number[] = []; @@ -52,104 +61,225 @@ const RightPanel: React.FC = ({ state, actions }) => { } const renderCurrentStep = () => { - switch (state.currentStep) { - case 1: + try { + // 외부호출인 경우 단계 무시하고 바로 외부호출 설정 화면 표시 + console.log("🔍 [RightPanel] renderCurrentStep - connectionType:", state.connectionType); + if (state.connectionType === "external_call") { + console.log("✅ [RightPanel] 외부호출 화면 렌더링"); return ( - actions.goToStep(2)} - /> - ); +
+ {/* 헤더 */} +
+
+ +

외부 호출 설정

+
+

+ REST API 호출을 통해 외부 시스템에 데이터를 전송하거나 알림을 보낼 수 있습니다. +

+
- case 2: - return ( - actions.goToStep(3)} // 3단계(제어 조건)로 - onBack={() => actions.goToStep(1)} - /> - ); + {/* 관계명 및 설명 입력 */} +
+
+
+ + actions.setRelationshipName(e.target.value)} + placeholder="외부호출 관계의 이름을 입력하세요" + className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none" + /> +
+
+ +