외부호출 노드들

This commit is contained in:
kjs
2025-12-09 12:13:30 +09:00
parent cf73ce6ebb
commit bb98e9319f
12 changed files with 2671 additions and 5 deletions

View File

@@ -32,6 +32,9 @@ export type NodeType =
| "updateAction"
| "deleteAction"
| "upsertAction"
| "emailAction" // 이메일 발송 액션
| "scriptAction" // 스크립트 실행 액션
| "httpRequestAction" // HTTP 요청 액션
| "comment"
| "log";
@@ -547,6 +550,15 @@ export class NodeFlowExecutionService {
case "condition":
return this.executeCondition(node, inputData, context);
case "emailAction":
return this.executeEmailAction(node, inputData, context);
case "scriptAction":
return this.executeScriptAction(node, inputData, context);
case "httpRequestAction":
return this.executeHttpRequestAction(node, inputData, context);
case "comment":
case "log":
// 로그/코멘트는 실행 없이 통과
@@ -3379,4 +3391,463 @@ export class NodeFlowExecutionService {
return filteredResults;
}
// ===================================================================
// 외부 연동 액션 노드들
// ===================================================================
/**
* 이메일 발송 액션 노드 실행
*/
private static async executeEmailAction(
node: FlowNode,
inputData: any,
context: ExecutionContext
): Promise<any> {
const {
from,
to,
cc,
bcc,
subject,
body,
bodyType,
isHtml, // 레거시 지원
accountId: nodeAccountId, // 프론트엔드에서 선택한 계정 ID
smtpConfigId, // 레거시 지원
attachments,
templateVariables,
} = node.data;
logger.info(`📧 이메일 발송 노드 실행: ${node.data.displayName || node.id}`);
// 입력 데이터를 배열로 정규화
const dataArray = Array.isArray(inputData) ? inputData : inputData ? [inputData] : [{}];
const results: any[] = [];
// 동적 임포트로 순환 참조 방지
const { mailSendSimpleService } = await import("./mailSendSimpleService");
const { mailAccountFileService } = await import("./mailAccountFileService");
// 계정 ID 우선순위: nodeAccountId > smtpConfigId > 첫 번째 활성 계정
let accountId = nodeAccountId || smtpConfigId;
if (!accountId) {
const accounts = await mailAccountFileService.getAccounts();
const activeAccount = accounts.find((acc: any) => acc.status === "active");
if (activeAccount) {
accountId = activeAccount.id;
logger.info(`📧 자동 선택된 메일 계정: ${activeAccount.name} (${activeAccount.email})`);
} else {
throw new Error("활성화된 메일 계정이 없습니다. 메일 계정을 먼저 설정해주세요.");
}
}
// HTML 여부 판단 (bodyType 우선, isHtml 레거시 지원)
const useHtml = bodyType === "html" || isHtml === true;
for (const data of dataArray) {
try {
// 템플릿 변수 치환
const processedSubject = this.replaceTemplateVariables(subject || "", data);
const processedBody = this.replaceTemplateVariables(body || "", data);
const processedTo = this.replaceTemplateVariables(to || "", data);
const processedCc = cc ? this.replaceTemplateVariables(cc, data) : undefined;
const processedBcc = bcc ? this.replaceTemplateVariables(bcc, data) : undefined;
// 수신자 파싱 (쉼표로 구분)
const toList = processedTo.split(",").map((email: string) => email.trim()).filter((email: string) => email);
const ccList = processedCc ? processedCc.split(",").map((email: string) => email.trim()).filter((email: string) => email) : undefined;
const bccList = processedBcc ? processedBcc.split(",").map((email: string) => email.trim()).filter((email: string) => email) : undefined;
if (toList.length === 0) {
throw new Error("수신자 이메일 주소가 지정되지 않았습니다.");
}
// 메일 발송 요청
const sendResult = await mailSendSimpleService.sendMail({
accountId,
to: toList,
cc: ccList,
bcc: bccList,
subject: processedSubject,
customHtml: useHtml ? processedBody : `<pre>${processedBody}</pre>`,
attachments: attachments?.map((att: any) => ({
filename: att.type === "dataField" ? data[att.value] : att.value,
path: att.type === "dataField" ? data[att.value] : att.value,
})),
});
if (sendResult.success) {
logger.info(`✅ 이메일 발송 성공: ${toList.join(", ")}`);
results.push({
success: true,
to: toList,
messageId: sendResult.messageId,
});
} else {
logger.error(`❌ 이메일 발송 실패: ${sendResult.error}`);
results.push({
success: false,
to: toList,
error: sendResult.error,
});
}
} catch (error: any) {
logger.error(`❌ 이메일 발송 오류:`, error);
results.push({
success: false,
error: error.message,
});
}
}
const successCount = results.filter((r) => r.success).length;
const failedCount = results.filter((r) => !r.success).length;
logger.info(`📧 이메일 발송 완료: 성공 ${successCount}건, 실패 ${failedCount}`);
return {
action: "emailAction",
totalCount: results.length,
successCount,
failedCount,
results,
};
}
/**
* 스크립트 실행 액션 노드 실행
*/
private static async executeScriptAction(
node: FlowNode,
inputData: any,
context: ExecutionContext
): Promise<any> {
const {
scriptType,
scriptPath,
arguments: scriptArgs,
workingDirectory,
environmentVariables,
timeout,
captureOutput,
} = node.data;
logger.info(`🖥️ 스크립트 실행 노드 실행: ${node.data.displayName || node.id}`);
logger.info(` 스크립트 타입: ${scriptType}, 경로: ${scriptPath}`);
if (!scriptPath) {
throw new Error("스크립트 경로가 지정되지 않았습니다.");
}
// 입력 데이터를 배열로 정규화
const dataArray = Array.isArray(inputData) ? inputData : inputData ? [inputData] : [{}];
const results: any[] = [];
// child_process 모듈 동적 임포트
const { spawn } = await import("child_process");
const path = await import("path");
for (const data of dataArray) {
try {
// 인자 처리
const processedArgs: string[] = [];
if (scriptArgs && Array.isArray(scriptArgs)) {
for (const arg of scriptArgs) {
if (arg.type === "dataField") {
// 데이터 필드 참조
const value = this.replaceTemplateVariables(arg.value, data);
processedArgs.push(value);
} else {
processedArgs.push(arg.value);
}
}
}
// 환경 변수 처리
const env = {
...process.env,
...(environmentVariables || {}),
};
// 스크립트 타입에 따른 명령어 결정
let command: string;
let args: string[];
switch (scriptType) {
case "python":
command = "python3";
args = [scriptPath, ...processedArgs];
break;
case "shell":
command = "bash";
args = [scriptPath, ...processedArgs];
break;
case "executable":
command = scriptPath;
args = processedArgs;
break;
default:
throw new Error(`지원하지 않는 스크립트 타입: ${scriptType}`);
}
logger.info(` 실행 명령: ${command} ${args.join(" ")}`);
// 스크립트 실행 (Promise로 래핑)
const result = await new Promise<{
exitCode: number | null;
stdout: string;
stderr: string;
}>((resolve, reject) => {
const childProcess = spawn(command, args, {
cwd: workingDirectory || process.cwd(),
env,
timeout: timeout || 60000, // 기본 60초
});
let stdout = "";
let stderr = "";
if (captureOutput !== false) {
childProcess.stdout?.on("data", (data) => {
stdout += data.toString();
});
childProcess.stderr?.on("data", (data) => {
stderr += data.toString();
});
}
childProcess.on("close", (code) => {
resolve({ exitCode: code, stdout, stderr });
});
childProcess.on("error", (error) => {
reject(error);
});
});
if (result.exitCode === 0) {
logger.info(`✅ 스크립트 실행 성공 (종료 코드: ${result.exitCode})`);
results.push({
success: true,
exitCode: result.exitCode,
stdout: result.stdout,
stderr: result.stderr,
data,
});
} else {
logger.warn(`⚠️ 스크립트 실행 완료 (종료 코드: ${result.exitCode})`);
results.push({
success: false,
exitCode: result.exitCode,
stdout: result.stdout,
stderr: result.stderr,
data,
});
}
} catch (error: any) {
logger.error(`❌ 스크립트 실행 오류:`, error);
results.push({
success: false,
error: error.message,
data,
});
}
}
const successCount = results.filter((r) => r.success).length;
const failedCount = results.filter((r) => !r.success).length;
logger.info(`🖥️ 스크립트 실행 완료: 성공 ${successCount}건, 실패 ${failedCount}`);
return {
action: "scriptAction",
scriptType,
scriptPath,
totalCount: results.length,
successCount,
failedCount,
results,
};
}
/**
* HTTP 요청 액션 노드 실행
*/
private static async executeHttpRequestAction(
node: FlowNode,
inputData: any,
context: ExecutionContext
): Promise<any> {
const {
url,
method,
headers,
bodyTemplate,
bodyType,
authentication,
timeout,
retryCount,
responseMapping,
} = node.data;
logger.info(`🌐 HTTP 요청 노드 실행: ${node.data.displayName || node.id}`);
logger.info(` 메서드: ${method}, URL: ${url}`);
if (!url) {
throw new Error("HTTP 요청 URL이 지정되지 않았습니다.");
}
// 입력 데이터를 배열로 정규화
const dataArray = Array.isArray(inputData) ? inputData : inputData ? [inputData] : [{}];
const results: any[] = [];
for (const data of dataArray) {
let currentRetry = 0;
const maxRetries = retryCount || 0;
while (currentRetry <= maxRetries) {
try {
// URL 템플릿 변수 치환
const processedUrl = this.replaceTemplateVariables(url, data);
// 헤더 처리
const processedHeaders: Record<string, string> = {};
if (headers && Array.isArray(headers)) {
for (const header of headers) {
const headerValue =
header.valueType === "dataField"
? this.replaceTemplateVariables(header.value, data)
: header.value;
processedHeaders[header.name] = headerValue;
}
}
// 인증 헤더 추가
if (authentication) {
switch (authentication.type) {
case "basic":
if (authentication.username && authentication.password) {
const credentials = Buffer.from(
`${authentication.username}:${authentication.password}`
).toString("base64");
processedHeaders["Authorization"] = `Basic ${credentials}`;
}
break;
case "bearer":
if (authentication.token) {
processedHeaders["Authorization"] = `Bearer ${authentication.token}`;
}
break;
case "apikey":
if (authentication.apiKey) {
if (authentication.apiKeyLocation === "query") {
// 쿼리 파라미터로 추가 (URL에 추가)
const paramName = authentication.apiKeyQueryParam || "api_key";
const separator = processedUrl.includes("?") ? "&" : "?";
// URL은 이미 처리되었으므로 여기서는 결과에 포함
} else {
// 헤더로 추가
const headerName = authentication.apiKeyHeader || "X-API-Key";
processedHeaders[headerName] = authentication.apiKey;
}
}
break;
}
}
// Content-Type 기본값
if (!processedHeaders["Content-Type"] && ["POST", "PUT", "PATCH"].includes(method)) {
processedHeaders["Content-Type"] =
bodyType === "json" ? "application/json" : "text/plain";
}
// 바디 처리
let processedBody: string | undefined;
if (["POST", "PUT", "PATCH"].includes(method) && bodyTemplate) {
processedBody = this.replaceTemplateVariables(bodyTemplate, data);
}
logger.info(` 요청 URL: ${processedUrl}`);
logger.info(` 요청 헤더: ${JSON.stringify(processedHeaders)}`);
if (processedBody) {
logger.info(` 요청 바디: ${processedBody.substring(0, 200)}...`);
}
// HTTP 요청 실행
const response = await axios({
method: method.toLowerCase() as any,
url: processedUrl,
headers: processedHeaders,
data: processedBody,
timeout: timeout || 30000,
validateStatus: () => true, // 모든 상태 코드 허용
});
logger.info(` 응답 상태: ${response.status} ${response.statusText}`);
// 응답 데이터 처리
let responseData = response.data;
// 응답 매핑 적용
if (responseMapping && responseData) {
const paths = responseMapping.split(".");
for (const path of paths) {
if (responseData && typeof responseData === "object" && path in responseData) {
responseData = responseData[path];
} else {
logger.warn(`⚠️ 응답 매핑 경로를 찾을 수 없습니다: ${responseMapping}`);
break;
}
}
}
const isSuccess = response.status >= 200 && response.status < 300;
if (isSuccess) {
logger.info(`✅ HTTP 요청 성공`);
results.push({
success: true,
statusCode: response.status,
data: responseData,
inputData: data,
});
break; // 성공 시 재시도 루프 종료
} else {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
} catch (error: any) {
currentRetry++;
if (currentRetry > maxRetries) {
logger.error(`❌ HTTP 요청 실패 (재시도 ${currentRetry - 1}/${maxRetries}):`, error.message);
results.push({
success: false,
error: error.message,
inputData: data,
});
} else {
logger.warn(`⚠️ HTTP 요청 재시도 (${currentRetry}/${maxRetries}): ${error.message}`);
// 재시도 전 잠시 대기
await new Promise((resolve) => setTimeout(resolve, 1000 * currentRetry));
}
}
}
}
const successCount = results.filter((r) => r.success).length;
const failedCount = results.filter((r) => !r.success).length;
logger.info(`🌐 HTTP 요청 완료: 성공 ${successCount}건, 실패 ${failedCount}`);
return {
action: "httpRequestAction",
method,
url,
totalCount: results.length,
successCount,
failedCount,
results,
};
}
}