import { ExternalCallConfig, ExternalCallResult, ExternalCallRequest, SlackSettings, KakaoTalkSettings, DiscordSettings, GenericApiSettings, EmailSettings, SupportedExternalCallSettings, TemplateOptions, } from "../types/externalCallTypes"; import { DataMappingService } from "./dataMappingService"; import { DataMappingConfig, DataMappingResult, } from "../types/dataMappingTypes"; /** * 외부 호출 서비스 * REST API, 웹훅, 이메일 등 다양한 외부 시스템 호출을 처리 */ export class ExternalCallService { private readonly DEFAULT_TIMEOUT = 30000; // 30초 private readonly DEFAULT_RETRY_COUNT = 3; private dataMappingService: DataMappingService; private readonly DEFAULT_RETRY_DELAY = 1000; // 1초 constructor() { this.dataMappingService = new DataMappingService(); } /** * 데이터 매핑과 함께 외부 호출 실행 */ async executeWithDataMapping( config: ExternalCallConfig, dataMappingConfig?: DataMappingConfig, triggerData?: any ): Promise<{ callResult: ExternalCallResult; mappingResult?: DataMappingResult; }> { const startTime = Date.now(); console.log(`🚀 [ExternalCallService] 데이터 매핑 포함 외부 호출 시작:`, { callType: config.callType, hasMappingConfig: !!dataMappingConfig, mappingDirection: dataMappingConfig?.direction, }); try { let requestData = config; // Outbound 매핑 처리 (내부 → 외부) if ( dataMappingConfig?.direction === "outbound" && dataMappingConfig.outboundMapping ) { console.log(`📤 [ExternalCallService] Outbound 매핑 처리 시작`); const outboundData = await this.dataMappingService.processOutboundData( dataMappingConfig.outboundMapping, triggerData ); // API 요청 바디에 매핑된 데이터 포함 if (config.callType === "rest-api") { // GenericApiSettings로 타입 캐스팅 const apiConfig = config as GenericApiSettings; const bodyTemplate = apiConfig.body || "{}"; // 템플릿에 데이터 삽입 const processedBody = this.processTemplate(bodyTemplate, { mappedData: outboundData, triggerData, ...outboundData, }); requestData = { ...config, body: processedBody, } as GenericApiSettings; } } // 외부 호출 실행 const callRequest: ExternalCallRequest = { diagramId: 0, // 임시값 relationshipId: "data-mapping", // 임시값 settings: requestData, templateData: triggerData, }; const callResult = await this.executeExternalCall(callRequest); let mappingResult: DataMappingResult | undefined; // Inbound 매핑 처리 (외부 → 내부) if ( callResult.success && dataMappingConfig?.direction === "inbound" && dataMappingConfig.inboundMapping ) { console.log(`📥 [ExternalCallService] Inbound 매핑 처리 시작`); try { // 응답 데이터 파싱 let responseData = callResult.response; if (typeof responseData === "string") { try { responseData = JSON.parse(responseData); } catch { console.warn( `⚠️ [ExternalCallService] 응답 데이터 JSON 파싱 실패, 문자열로 처리` ); } } mappingResult = await this.dataMappingService.processInboundData( responseData, dataMappingConfig.inboundMapping ); console.log(`✅ [ExternalCallService] Inbound 매핑 완료:`, { recordsProcessed: mappingResult.recordsProcessed, recordsInserted: mappingResult.recordsInserted, }); } catch (error) { console.error(`❌ [ExternalCallService] Inbound 매핑 실패:`, error); mappingResult = { success: false, direction: "inbound", errors: [error instanceof Error ? error.message : String(error)], executionTime: Date.now() - startTime, timestamp: new Date().toISOString(), }; } } // 양방향 매핑 처리 if (dataMappingConfig?.direction === "bidirectional") { // 필요한 경우 양방향 매핑 로직 구현 console.log(`🔄 [ExternalCallService] 양방향 매핑은 향후 구현 예정`); } const result = { callResult, mappingResult, }; console.log(`✅ [ExternalCallService] 데이터 매핑 포함 외부 호출 완료:`, { callSuccess: callResult.success, mappingSuccess: mappingResult?.success, totalExecutionTime: Date.now() - startTime, }); return result; } catch (error) { console.error( `❌ [ExternalCallService] 데이터 매핑 포함 외부 호출 실패:`, error ); throw error; } } /** * 기존 외부 호출 실행 (매핑 없음) */ 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); } // 기본 헤더 준비 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: 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); // GET, HEAD 메서드는 body를 가질 수 없음 const method = options.method.toUpperCase(); const requestOptions: RequestInit = { method: options.method, headers: options.headers, 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, 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, }; } }