diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index dba6dc4d..5bb5c902 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -28,6 +28,7 @@ import templateStandardRoutes from "./routes/templateStandardRoutes"; import componentStandardRoutes from "./routes/componentStandardRoutes"; import layoutRoutes from "./routes/layoutRoutes"; import dataRoutes from "./routes/dataRoutes"; +import externalCallRoutes from "./routes/externalCallRoutes"; // import userRoutes from './routes/userRoutes'; // import menuRoutes from './routes/menuRoutes'; @@ -119,6 +120,7 @@ app.use("/api/admin/component-standards", componentStandardRoutes); app.use("/api/layouts", layoutRoutes); app.use("/api/screen", screenStandardRoutes); app.use("/api/data", dataRoutes); +app.use("/api/external-calls", externalCallRoutes); // app.use('/api/users', userRoutes); // app.use('/api/menus', menuRoutes); diff --git a/backend-node/src/routes/externalCallRoutes.ts b/backend-node/src/routes/externalCallRoutes.ts new file mode 100644 index 00000000..2fd3f0f4 --- /dev/null +++ b/backend-node/src/routes/externalCallRoutes.ts @@ -0,0 +1,192 @@ +import { Router, Request, Response } from "express"; +import { ExternalCallService } from "../services/externalCallService"; +import { + ExternalCallRequest, + SupportedExternalCallSettings, +} from "../types/externalCallTypes"; + +const router = Router(); +const externalCallService = new ExternalCallService(); + +/** + * 외부 호출 테스트 + * POST /api/external-calls/test + */ +router.post("/test", async (req: Request, res: Response) => { + try { + const { settings, templateData } = req.body; + + if (!settings) { + return res.status(400).json({ + success: false, + error: "외부 호출 설정이 필요합니다.", + }); + } + + // 설정 검증 + const validation = externalCallService.validateSettings( + settings as SupportedExternalCallSettings + ); + if (!validation.valid) { + return res.status(400).json({ + success: false, + error: "설정 검증 실패", + details: validation.errors, + }); + } + + // 테스트 요청 생성 + const testRequest: ExternalCallRequest = { + diagramId: 0, // 테스트용 + relationshipId: "test", + settings: settings as SupportedExternalCallSettings, + templateData: templateData || { + recordCount: 5, + tableName: "test_table", + timestamp: new Date().toISOString(), + }, + }; + + // 외부 호출 실행 + const result = await externalCallService.executeExternalCall(testRequest); + + return res.json({ + success: true, + result, + }); + } catch (error) { + console.error("외부 호출 테스트 실패:", error); + return res.status(500).json({ + success: false, + error: + error instanceof Error + ? error.message + : "알 수 없는 오류가 발생했습니다.", + }); + } +}); + +/** + * 외부 호출 실행 + * POST /api/external-calls/execute + */ +router.post("/execute", async (req: Request, res: Response) => { + try { + const { diagramId, relationshipId, settings, templateData } = req.body; + + if (!diagramId || !relationshipId || !settings) { + return res.status(400).json({ + success: false, + error: + "필수 파라미터가 누락되었습니다. (diagramId, relationshipId, settings)", + }); + } + + // 설정 검증 + const validation = externalCallService.validateSettings( + settings as SupportedExternalCallSettings + ); + if (!validation.valid) { + return res.status(400).json({ + success: false, + error: "설정 검증 실패", + details: validation.errors, + }); + } + + // 외부 호출 요청 생성 + const callRequest: ExternalCallRequest = { + diagramId: parseInt(diagramId), + relationshipId, + settings: settings as SupportedExternalCallSettings, + templateData, + }; + + // 외부 호출 실행 + const result = await externalCallService.executeExternalCall(callRequest); + + // TODO: 호출 결과를 데이터베이스에 로그로 저장 (향후 구현) + + return res.json({ + success: true, + result, + }); + } catch (error) { + console.error("외부 호출 실행 실패:", error); + return res.status(500).json({ + success: false, + error: + error instanceof Error + ? error.message + : "알 수 없는 오류가 발생했습니다.", + }); + } +}); + +/** + * 지원되는 외부 호출 타입 목록 조회 + * GET /api/external-calls/types + */ +router.get("/types", (req: Request, res: Response) => { + res.json({ + success: true, + supportedTypes: { + "rest-api": { + name: "REST API 호출", + subtypes: { + slack: "슬랙 웹훅", + "kakao-talk": "카카오톡 알림", + discord: "디스코드 웹훅", + generic: "일반 REST API", + }, + }, + email: { + name: "이메일 전송", + status: "구현 예정", + }, + ftp: { + name: "FTP 업로드", + status: "구현 예정", + }, + queue: { + name: "메시지 큐", + status: "구현 예정", + }, + }, + }); +}); + +/** + * 외부 호출 설정 검증 + * POST /api/external-calls/validate + */ +router.post("/validate", (req: Request, res: Response) => { + try { + const { settings } = req.body; + + if (!settings) { + return res.status(400).json({ + success: false, + error: "검증할 설정이 필요합니다.", + }); + } + + const validation = externalCallService.validateSettings( + settings as SupportedExternalCallSettings + ); + + return res.json({ + success: true, + validation, + }); + } catch (error) { + console.error("설정 검증 실패:", error); + return res.status(500).json({ + success: false, + error: + error instanceof Error ? error.message : "검증 중 오류가 발생했습니다.", + }); + } +}); + +export default router; diff --git a/backend-node/src/services/externalCallService.ts b/backend-node/src/services/externalCallService.ts new file mode 100644 index 00000000..703c1b2c --- /dev/null +++ b/backend-node/src/services/externalCallService.ts @@ -0,0 +1,324 @@ +import { + ExternalCallConfig, + ExternalCallResult, + ExternalCallRequest, + SlackSettings, + KakaoTalkSettings, + DiscordSettings, + GenericApiSettings, + EmailSettings, + SupportedExternalCallSettings, + TemplateOptions, +} from "../types/externalCallTypes"; + +/** + * 외부 호출 서비스 + * REST API, 웹훅, 이메일 등 다양한 외부 시스템 호출을 처리 + */ +export class ExternalCallService { + private readonly DEFAULT_TIMEOUT = 30000; // 30초 + private readonly DEFAULT_RETRY_COUNT = 3; + private readonly DEFAULT_RETRY_DELAY = 1000; // 1초 + + /** + * 외부 호출 실행 + */ + async executeExternalCall( + request: ExternalCallRequest + ): Promise { + const startTime = Date.now(); + + try { + let result: ExternalCallResult; + + switch (request.settings.callType) { + case "rest-api": + result = await this.executeRestApiCall(request); + break; + case "email": + result = await this.executeEmailCall(request); + break; + case "ftp": + throw new Error("FTP 호출은 아직 구현되지 않았습니다."); + case "queue": + throw new Error("메시지 큐 호출은 아직 구현되지 않았습니다."); + default: + throw new Error( + `지원되지 않는 호출 타입: ${request.settings.callType}` + ); + } + + result.executionTime = Date.now() - startTime; + result.timestamp = new Date(); + + return result; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + executionTime: Date.now() - startTime, + timestamp: new Date(), + }; + } + } + + /** + * REST API 호출 실행 + */ + private async executeRestApiCall( + request: ExternalCallRequest + ): Promise { + const settings = request.settings as any; // 임시로 any 사용 + + switch (settings.apiType) { + case "slack": + return await this.executeSlackWebhook( + settings as SlackSettings, + request.templateData + ); + case "kakao-talk": + return await this.executeKakaoTalkApi( + settings as KakaoTalkSettings, + request.templateData + ); + case "discord": + return await this.executeDiscordWebhook( + settings as DiscordSettings, + request.templateData + ); + case "generic": + default: + return await this.executeGenericApi( + settings as GenericApiSettings, + request.templateData + ); + } + } + + /** + * 슬랙 웹훅 실행 + */ + private async executeSlackWebhook( + settings: SlackSettings, + templateData?: Record + ): Promise { + const payload = { + text: this.processTemplate(settings.message, templateData), + channel: settings.channel, + username: settings.username || "DataFlow Bot", + icon_emoji: settings.iconEmoji || ":robot_face:", + }; + + return await this.makeHttpRequest({ + url: settings.webhookUrl, + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + timeout: settings.timeout || this.DEFAULT_TIMEOUT, + }); + } + + /** + * 카카오톡 API 실행 + */ + private async executeKakaoTalkApi( + settings: KakaoTalkSettings, + templateData?: Record + ): Promise { + const payload = { + object_type: "text", + text: this.processTemplate(settings.message, templateData), + link: { + web_url: "https://developers.kakao.com", + mobile_web_url: "https://developers.kakao.com", + }, + }; + + return await this.makeHttpRequest({ + url: "https://kapi.kakao.com/v2/api/talk/memo/default/send", + method: "POST", + headers: { + Authorization: `Bearer ${settings.accessToken}`, + "Content-Type": "application/x-www-form-urlencoded", + }, + body: `template_object=${encodeURIComponent(JSON.stringify(payload))}`, + timeout: settings.timeout || this.DEFAULT_TIMEOUT, + }); + } + + /** + * 디스코드 웹훅 실행 + */ + private async executeDiscordWebhook( + settings: DiscordSettings, + templateData?: Record + ): Promise { + const payload = { + content: this.processTemplate(settings.message, templateData), + username: settings.username || "시스템 알리미", + avatar_url: settings.avatarUrl, + }; + + return await this.makeHttpRequest({ + url: settings.webhookUrl, + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + timeout: settings.timeout || this.DEFAULT_TIMEOUT, + }); + } + + /** + * 일반 REST API 실행 + */ + private async executeGenericApi( + settings: GenericApiSettings, + templateData?: Record + ): Promise { + let body = settings.body; + if (body && templateData) { + body = this.processTemplate(body, templateData); + } + + return await this.makeHttpRequest({ + url: settings.url, + method: settings.method, + headers: settings.headers || {}, + body: body, + timeout: settings.timeout || this.DEFAULT_TIMEOUT, + }); + } + + /** + * 이메일 호출 실행 (향후 구현) + */ + private async executeEmailCall( + request: ExternalCallRequest + ): Promise { + // TODO: 이메일 발송 구현 (Java MailUtil 연동) + throw new Error("이메일 발송 기능은 아직 구현되지 않았습니다."); + } + + /** + * HTTP 요청 실행 (공통) + */ + private async makeHttpRequest(options: { + url: string; + method: string; + headers?: Record; + body?: string; + timeout: number; + }): Promise { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), options.timeout); + + const response = await fetch(options.url, { + method: options.method, + headers: options.headers, + body: options.body, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + const responseText = await response.text(); + + return { + success: response.ok, + statusCode: response.status, + response: responseText, + executionTime: 0, // 상위에서 설정됨 + timestamp: new Date(), + }; + } catch (error) { + if (error instanceof Error) { + if (error.name === "AbortError") { + throw new Error(`요청 시간 초과 (${options.timeout}ms)`); + } + throw error; + } + throw new Error(`HTTP 요청 실패: ${String(error)}`); + } + } + + /** + * 템플릿 문자열 처리 + */ + private processTemplate( + template: string, + data?: Record, + options: TemplateOptions = {} + ): string { + if (!data || Object.keys(data).length === 0) { + return template; + } + + const startDelimiter = options.startDelimiter || "{{"; + const endDelimiter = options.endDelimiter || "}}"; + + let result = template; + + Object.entries(data).forEach(([key, value]) => { + const placeholder = `${startDelimiter}${key}${endDelimiter}`; + const replacement = String(value ?? ""); + result = result.replace(new RegExp(placeholder, "g"), replacement); + }); + + return result; + } + + /** + * 외부 호출 설정 검증 + */ + validateSettings(settings: SupportedExternalCallSettings): { + valid: boolean; + errors: string[]; + } { + const errors: string[] = []; + + if (settings.callType === "rest-api") { + switch (settings.apiType) { + case "slack": + const slackSettings = settings as SlackSettings; + if (!slackSettings.webhookUrl) + errors.push("슬랙 웹훅 URL이 필요합니다."); + if (!slackSettings.message) errors.push("슬랙 메시지가 필요합니다."); + break; + + case "kakao-talk": + const kakaoSettings = settings as KakaoTalkSettings; + if (!kakaoSettings.accessToken) + errors.push("카카오톡 액세스 토큰이 필요합니다."); + if (!kakaoSettings.message) + errors.push("카카오톡 메시지가 필요합니다."); + break; + + case "discord": + const discordSettings = settings as DiscordSettings; + if (!discordSettings.webhookUrl) + errors.push("디스코드 웹훅 URL이 필요합니다."); + if (!discordSettings.message) + errors.push("디스코드 메시지가 필요합니다."); + break; + + case "generic": + default: + const genericSettings = settings as GenericApiSettings; + if (!genericSettings.url) errors.push("API URL이 필요합니다."); + if (!genericSettings.method) errors.push("HTTP 메서드가 필요합니다."); + break; + } + } else if (settings.callType === "email") { + const emailSettings = settings as EmailSettings; + if (!emailSettings.smtpHost) errors.push("SMTP 호스트가 필요합니다."); + if (!emailSettings.toEmail) errors.push("수신 이메일이 필요합니다."); + if (!emailSettings.subject) errors.push("이메일 제목이 필요합니다."); + } + + return { + valid: errors.length === 0, + errors, + }; + } +} diff --git a/backend-node/src/types/externalCallTypes.ts b/backend-node/src/types/externalCallTypes.ts new file mode 100644 index 00000000..4df12628 --- /dev/null +++ b/backend-node/src/types/externalCallTypes.ts @@ -0,0 +1,127 @@ +/** + * 외부 호출 관련 타입 정의 + */ + +// 기본 외부 호출 설정 +export interface ExternalCallConfig { + callType: "rest-api" | "email" | "ftp" | "queue"; + apiType?: "slack" | "kakao-talk" | "discord" | "generic"; + + // 공통 설정 + timeout?: number; // ms + retryCount?: number; + retryDelay?: number; // ms +} + +// REST API 공통 설정 +export interface RestApiConfig extends ExternalCallConfig { + callType: "rest-api"; + url: string; + method: "GET" | "POST" | "PUT" | "DELETE"; + headers?: Record; + body?: string; +} + +// 슬랙 웹훅 설정 +export interface SlackSettings extends ExternalCallConfig { + callType: "rest-api"; + apiType: "slack"; + webhookUrl: string; + channel?: string; + message: string; + username?: string; + iconEmoji?: string; +} + +// 카카오톡 API 설정 +export interface KakaoTalkSettings extends ExternalCallConfig { + callType: "rest-api"; + apiType: "kakao-talk"; + accessToken: string; + message: string; + templateId?: string; + phoneNumber?: string; +} + +// 디스코드 웹훅 설정 +export interface DiscordSettings extends ExternalCallConfig { + callType: "rest-api"; + apiType: "discord"; + webhookUrl: string; + message: string; + username?: string; + avatarUrl?: string; +} + +// 일반 REST API 설정 +export interface GenericApiSettings extends ExternalCallConfig { + callType: "rest-api"; + apiType: "generic"; + url: string; + method: "GET" | "POST" | "PUT" | "DELETE"; + headers?: Record; + body?: string; +} + +// 이메일 설정 +export interface EmailSettings extends ExternalCallConfig { + callType: "email"; + smtpHost: string; + smtpPort: number; + smtpUser: string; + smtpPass: string; + fromEmail: string; + toEmail: string; + subject: string; + body: string; +} + +// 외부 호출 실행 결과 +export interface ExternalCallResult { + success: boolean; + statusCode?: number; + response?: string; + error?: string; + executionTime: number; // ms + timestamp: Date; +} + +// 외부 호출 실행 요청 +export interface ExternalCallRequest { + diagramId: number; + relationshipId: string; + settings: ExternalCallConfig; + templateData?: Record; // 템플릿 변수 데이터 +} + +// 템플릿 처리 옵션 +export interface TemplateOptions { + startDelimiter?: string; // 기본값: "{{" + endDelimiter?: string; // 기본값: "}}" + escapeHtml?: boolean; // 기본값: false +} + +// 외부 호출 로그 (향후 구현) +export interface ExternalCallLog { + id: number; + diagramId: number; + relationshipId: string; + callType: string; + apiType?: string; + targetUrl: string; + requestPayload?: string; + responseStatus?: number; + responseBody?: string; + errorMessage?: string; + executionTimeMs: number; + createdAt: Date; +} + +// 지원되는 외부 호출 타입들의 Union 타입 +export type SupportedExternalCallSettings = + | SlackSettings + | KakaoTalkSettings + | DiscordSettings + | GenericApiSettings + | EmailSettings; + diff --git a/frontend/components/dataflow/connection/ExternalCallSettings.tsx b/frontend/components/dataflow/connection/ExternalCallSettings.tsx index f320585d..b109a645 100644 --- a/frontend/components/dataflow/connection/ExternalCallSettings.tsx +++ b/frontend/components/dataflow/connection/ExternalCallSettings.tsx @@ -5,6 +5,9 @@ import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Button } from "@/components/ui/button"; +import { ExternalCallAPI } from "@/lib/api/externalCall"; +import { toast } from "sonner"; import { Globe } from "lucide-react"; import { ExternalCallSettings as ExternalCallSettingsType } from "@/types/connectionTypes"; @@ -13,12 +16,128 @@ interface ExternalCallSettingsProps { onSettingsChange: (settings: ExternalCallSettingsType) => void; } +const handleTestExternalCall = async (settings: ExternalCallSettingsType) => { + let loadingToastId: string | number | undefined; + + try { + // 설정을 백엔드 형식으로 변환 + const backendSettings: Record = { + callType: settings.callType, + timeout: 10000, // 10초 타임아웃 설정 + }; + + if (settings.callType === "rest-api") { + backendSettings.apiType = settings.apiType; + + switch (settings.apiType) { + case "slack": + backendSettings.webhookUrl = settings.slackWebhookUrl; + backendSettings.message = + settings.slackMessage || "테스트 메시지: {{recordCount}}건의 데이터가 처리되었습니다."; + backendSettings.channel = settings.slackChannel; + break; + case "kakao-talk": + backendSettings.accessToken = settings.kakaoAccessToken; + backendSettings.message = + settings.kakaoMessage || "테스트 메시지: {{recordCount}}건의 데이터가 처리되었습니다."; + break; + case "discord": + backendSettings.webhookUrl = settings.discordWebhookUrl; + backendSettings.message = + settings.discordMessage || "테스트 메시지: {{recordCount}}건의 데이터가 처리되었습니다."; + backendSettings.username = settings.discordUsername; + break; + case "generic": + default: + backendSettings.url = settings.apiUrl; + backendSettings.method = settings.httpMethod || "POST"; + try { + backendSettings.headers = settings.headers ? JSON.parse(settings.headers) : {}; + } catch (error) { + console.warn("Headers JSON 파싱 실패, 기본값 사용:", error); + backendSettings.headers = {}; + } + backendSettings.body = settings.bodyTemplate || "{}"; + break; + } + } + + // 로딩 토스트 시작 + loadingToastId = toast.loading("외부 호출 테스트 중...", { + duration: 12000, // 12초 후 자동으로 사라짐 + }); + + // 타임아웃을 위한 Promise.race 사용 + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error("테스트 요청이 10초 내에 완료되지 않았습니다.")), 10000); + }); + + const testPromise = ExternalCallAPI.testExternalCall({ + settings: backendSettings, + templateData: { + recordCount: 5, + tableName: "test_table", + timestamp: new Date().toISOString(), + message: "데이터플로우 테스트 실행", + }, + }); + + const result = await Promise.race([testPromise, timeoutPromise]); + + // 로딩 토스트 제거 + if (loadingToastId) { + toast.dismiss(loadingToastId); + } + + if (result.success && result.result?.success) { + toast.success("외부 호출 테스트 성공!", { + description: `응답 시간: ${result.result.executionTime}ms`, + duration: 4000, + }); + } else { + toast.error("외부 호출 테스트 실패", { + description: result.result?.error || result.error || "알 수 없는 오류", + duration: 6000, + }); + } + } catch (error) { + console.error("테스트 실행 중 오류:", error); + + // 로딩 토스트 제거 + if (loadingToastId) { + toast.dismiss(loadingToastId); + } + + if (error instanceof Error) { + toast.error("테스트 실행 중 오류가 발생했습니다.", { + description: error.message, + duration: 6000, + }); + } else { + toast.error("테스트 실행 중 알 수 없는 오류가 발생했습니다.", { + duration: 6000, + }); + } + } +}; + export const ExternalCallSettings: React.FC = ({ settings, onSettingsChange }) => { return (
-
- - 외부 호출 설정 +
+
+ + 외부 호출 설정 +
+
@@ -147,7 +266,7 @@ export const ExternalCallSettings: React.FC = ({ sett <>
= ({ sett className="text-sm" />
+
+ + onSettingsChange({ ...settings, discordUsername: e.target.value })} + placeholder="ERP 시스템" + className="text-sm" + /> +