외부호출 노드들
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user