From f155bc284d3f5ecef9dfbe5da81fdc63a9de3580 Mon Sep 17 00:00:00 2001 From: stageluo <979175267@qq.com> Date: Tue, 25 Nov 2025 09:27:06 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9Ehttp=E5=88=86=E6=94=AF?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E6=B5=81=E8=8A=82=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../workflow/workflow/WfNodeFactory.java | 4 + .../node/httpRequest/HttpRequestNode.java | 437 ++++++++++++++++++ .../httpRequest/HttpRequestNodeConfig.java | 113 +++++ 3 files changed, 554 insertions(+) create mode 100644 ruoyi-modules-api/ruoyi-workflow-api/src/main/java/org/ruoyi/workflow/workflow/node/httpRequest/HttpRequestNode.java create mode 100644 ruoyi-modules-api/ruoyi-workflow-api/src/main/java/org/ruoyi/workflow/workflow/node/httpRequest/HttpRequestNodeConfig.java diff --git a/ruoyi-modules-api/ruoyi-workflow-api/src/main/java/org/ruoyi/workflow/workflow/WfNodeFactory.java b/ruoyi-modules-api/ruoyi-workflow-api/src/main/java/org/ruoyi/workflow/workflow/WfNodeFactory.java index a6328e64..59b135e7 100644 --- a/ruoyi-modules-api/ruoyi-workflow-api/src/main/java/org/ruoyi/workflow/workflow/WfNodeFactory.java +++ b/ruoyi-modules-api/ruoyi-workflow-api/src/main/java/org/ruoyi/workflow/workflow/WfNodeFactory.java @@ -5,7 +5,9 @@ import org.ruoyi.workflow.entity.WorkflowNode; import org.ruoyi.workflow.workflow.node.AbstractWfNode; import org.ruoyi.workflow.workflow.node.EndNode; import org.ruoyi.workflow.workflow.node.answer.LLMAnswerNode; +import org.ruoyi.workflow.workflow.node.httpRequest.HttpRequestNode; import org.ruoyi.workflow.workflow.node.keywordExtractor.KeywordExtractorNode; +import org.ruoyi.workflow.workflow.node.knowledgeRetrieval.KnowledgeRetrievalNode; import org.ruoyi.workflow.workflow.node.mailSend.MailSendNode; import org.ruoyi.workflow.workflow.node.start.StartNode; import org.ruoyi.workflow.workflow.node.switcher.SwitcherNode; @@ -18,8 +20,10 @@ public class WfNodeFactory { case START -> wfNode = new StartNode(wfComponent, nodeDefinition, wfState, nodeState); case LLM_ANSWER -> wfNode = new LLMAnswerNode(wfComponent, nodeDefinition, wfState, nodeState); case KEYWORD_EXTRACTOR -> wfNode = new KeywordExtractorNode(wfComponent, nodeDefinition, wfState, nodeState); + case KNOWLEDGE_RETRIEVER -> wfNode = new KnowledgeRetrievalNode(wfComponent, nodeDefinition, wfState, nodeState); case END -> wfNode = new EndNode(wfComponent, nodeDefinition, wfState, nodeState); case MAIL_SEND -> wfNode = new MailSendNode(wfComponent, nodeDefinition, wfState, nodeState); + case HTTP_REQUEST -> wfNode = new HttpRequestNode(wfComponent, nodeDefinition, wfState, nodeState); case SWITCHER -> wfNode = new SwitcherNode(wfComponent, nodeDefinition, wfState, nodeState); default -> { } diff --git a/ruoyi-modules-api/ruoyi-workflow-api/src/main/java/org/ruoyi/workflow/workflow/node/httpRequest/HttpRequestNode.java b/ruoyi-modules-api/ruoyi-workflow-api/src/main/java/org/ruoyi/workflow/workflow/node/httpRequest/HttpRequestNode.java new file mode 100644 index 00000000..0df35b9b --- /dev/null +++ b/ruoyi-modules-api/ruoyi-workflow-api/src/main/java/org/ruoyi/workflow/workflow/node/httpRequest/HttpRequestNode.java @@ -0,0 +1,437 @@ +package org.ruoyi.workflow.workflow.node.httpRequest; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.jsoup.Jsoup; +import org.ruoyi.workflow.entity.WorkflowComponent; +import org.ruoyi.workflow.entity.WorkflowNode; +import org.ruoyi.workflow.workflow.NodeProcessResult; +import org.ruoyi.workflow.workflow.WfNodeState; +import org.ruoyi.workflow.workflow.WfState; +import org.ruoyi.workflow.workflow.data.NodeIOData; +import org.ruoyi.workflow.workflow.node.AbstractWfNode; +import org.springframework.http.*; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +import java.util.*; +import java.util.concurrent.TimeUnit; + +/** + * HTTP 请求节点 + */ +@Slf4j +public class HttpRequestNode extends AbstractWfNode { + + public HttpRequestNode(WorkflowComponent wfComponent, WorkflowNode nodeDef, WfState wfState, WfNodeState nodeState) { + super(wfComponent, nodeDef, wfState, nodeState); + } + + @Override + public NodeProcessResult onProcess() { + try { + HttpRequestNodeConfig config = checkAndGetConfig(HttpRequestNodeConfig.class); + List inputs = state.getInputs(); + + // 渲染 URL(支持变量替换) + String url = renderTemplate(config.getUrl(), inputs); + if (StringUtils.isBlank(url)) { + throw new IllegalArgumentException("请求 URL 不能为空"); + } + + // 添加 Query 参数 + url = buildUrlWithParams(url, config.getParams(), inputs); + + // 构建请求头 + HttpHeaders headers = buildHeaders(config.getHeaders(), inputs); + + // 构建请求体 + Object requestBody = buildRequestBody(config, inputs); + + // 执行 HTTP 请求(支持重试) + String response = executeHttpRequest(url, config.getMethod(), headers, requestBody, config); + + // 清除 HTML 标签(如果需要) + if (Boolean.TRUE.equals(config.getClearHtml()) && StringUtils.isNotBlank(response)) { + response = Jsoup.parse(response).text(); + } + + // 构造输出 + List outputs = new ArrayList<>(); + outputs.add(NodeIOData.createByText("output", "HTTP响应", response)); + + return NodeProcessResult.builder().content(outputs).build(); + + } catch (Exception e) { + log.error("HTTP 请求失败 in node: {}", node.getId(), e); + + // 异常时返回错误信息 + List errorOutputs = new ArrayList<>(); + errorOutputs.add(NodeIOData.createByText("output", "错误", "")); + errorOutputs.add(NodeIOData.createByText("error", "HTTP请求错误", e.getMessage())); + + return NodeProcessResult.builder().content(errorOutputs).build(); + } + } + + /** + * 渲染模板(支持变量替换) + * 支持格式: + * 1. {var_01} - 直接替换整个变量值 + * 2. {var_01.name} - 从 JSON 中提取 name 字段 + * 3. {var_01.user.email} - 支持嵌套路径 + */ + private String renderTemplate(String template, List inputs) { + if (StringUtils.isBlank(template)) { + return ""; + } + return renderTemplateWithJsonPath(template, inputs); + } + + /** + * 增强的模板渲染,支持 JSON 路径提取 + */ + private String renderTemplateWithJsonPath(String template, List inputs) { + String result = template; + ObjectMapper mapper = new ObjectMapper(); + + for (NodeIOData input : inputs) { + if (input == null || input.getName() == null) { + continue; + } + + String varName = input.getName(); + String varValue = input.valueToString(); + + // 1. 处理简单变量替换 {var_01} + result = result.replace("{" + varName + "}", varValue != null ? varValue : ""); + + // 2. 处理 JSON 路径提取 {var_01.field} 或 {var_01.user.name} + // 尝试解析为 JSON + Map jsonMap = tryParseJson(varValue, mapper); + if (jsonMap != null) { + result = replaceJsonPaths(result, varName, jsonMap); + } + } + + return result; + } + + /** + * 尝试将字符串解析为 JSON Map + */ + private Map tryParseJson(String value, ObjectMapper mapper) { + if (StringUtils.isBlank(value)) { + return null; + } + + value = value.trim(); + if (!value.startsWith("{") && !value.startsWith("[")) { + return null; + } + + try { + return mapper.readValue(value, new TypeReference>() {}); + } catch (Exception e) { + log.debug("无法解析为 JSON: {}", value); + return null; + } + } + + /** + * 替换 JSON 路径变量,如 {var_01.name} 或 {var_01.user.email} + */ + private String replaceJsonPaths(String template, String varName, Map jsonMap) { + String result = template; + + // 查找所有 {varName.xxx} 格式的占位符 + String pattern = "\\{" + varName + "\\.([\\.\\w]+)\\}"; + java.util.regex.Pattern p = java.util.regex.Pattern.compile(pattern); + java.util.regex.Matcher m = p.matcher(template); + + while (m.find()) { + String fullMatch = m.group(0); // 如 {var_01.name} + String jsonPath = m.group(1); // 如 name 或 user.email + + Object value = extractJsonValue(jsonMap, jsonPath); + String replacement = value != null ? value.toString() : ""; + + result = result.replace(fullMatch, replacement); + } + + return result; + } + + /** + * 从 JSON Map 中提取嵌套路径的值 + * 例如:path = "user.email" 会提取 map.get("user").get("email") + */ + @SuppressWarnings("unchecked") + private Object extractJsonValue(Map map, String path) { + String[] parts = path.split("\\."); + Object current = map; + + for (String part : parts) { + if (current instanceof Map) { + current = ((Map) current).get(part); + } else { + return null; + } + } + + return current; + } + + /** + * 构建带参数的 URL + */ + private String buildUrlWithParams(String baseUrl, List params, List inputs) { + if (params == null || params.isEmpty()) { + return baseUrl; + } + + StringBuilder urlBuilder = new StringBuilder(baseUrl); + boolean hasQuery = baseUrl.contains("?"); + + for (HttpRequestNodeConfig.ParamItem param : params) { + if (StringUtils.isBlank(param.getName())) { + continue; + } + + String name = renderTemplate(param.getName(), inputs); + String value = renderTemplate(param.getValue(), inputs); + + if (hasQuery) { + urlBuilder.append("&"); + } else { + urlBuilder.append("?"); + hasQuery = true; + } + + urlBuilder.append(name).append("=").append(value); + } + + return urlBuilder.toString(); + } + + /** + * 构建请求头 + */ + private HttpHeaders buildHeaders(List headerItems, List inputs) { + HttpHeaders headers = new HttpHeaders(); + + if (headerItems != null) { + for (HttpRequestNodeConfig.HeaderItem item : headerItems) { + if (StringUtils.isNotBlank(item.getName())) { + String name = renderTemplate(item.getName(), inputs); + String value = renderTemplate(item.getValue(), inputs); + headers.add(name, value); + } + } + } + + return headers; + } + + /** + * 构建请求体 + */ + private Object buildRequestBody(HttpRequestNodeConfig config, List inputs) { + String method = config.getMethod(); + if ("GET".equalsIgnoreCase(method) || "DELETE".equalsIgnoreCase(method) || "HEAD".equalsIgnoreCase(method)) { + return null; + } + + String contentType = config.getContentType(); + + // JSON Body + if ("application/json".equalsIgnoreCase(contentType)) { + if (config.getJsonBody() != null && !config.getJsonBody().isEmpty()) { + return renderJsonBody(config.getJsonBody(), inputs); + } + } + + // Form Data + if ("multipart/form-data".equalsIgnoreCase(contentType)) { + return buildFormData(config.getFormDataBody(), inputs); + } + + // Form URL Encoded + if ("application/x-www-form-urlencoded".equalsIgnoreCase(contentType)) { + return buildFormUrlEncoded(config.getFormUrlencodedBody(), inputs); + } + + // Text Body + if (StringUtils.isNotBlank(config.getTextBody())) { + return renderTemplate(config.getTextBody(), inputs); + } + + return null; + } + + /** + * 渲染 JSON 请求体 + * 支持三种模式: + * 1. 普通字段替换:{"name": "{var_01.name}"} + * 2. 整体 JSON 合并:{"$merge": "{var_01}"} - 将整个 JSON 对象合并进来 + * 3. 智能合并:如果值是 {var_01} 且是有效 JSON,自动展开合并 + */ + private Map renderJsonBody(Map jsonBody, List inputs) { + Map rendered = new HashMap<>(); + ObjectMapper mapper = new ObjectMapper(); + + for (Map.Entry entry : jsonBody.entrySet()) { + String key = entry.getKey(); + Object value = entry.getValue(); + + // 处理特殊的 $merge 指令 + if ("$merge".equals(key) && value instanceof String) { + String varRef = (String) value; + Map mergeData = resolveVariableAsJson(varRef, inputs, mapper); + if (mergeData != null) { + rendered.putAll(mergeData); + } + continue; + } + + if (value instanceof String) { + String strValue = (String) value; + + // 检查是否是单纯的变量引用(如 {var_01}) + if (strValue.matches("^\\{\\w+\\}$")) { + // 尝试解析为 JSON 对象 + Map jsonValue = resolveVariableAsJson(strValue, inputs, mapper); + if (jsonValue != null) { + // 如果是 JSON 对象,合并所有字段 + rendered.putAll(jsonValue); + } else { + // 否则作为普通字符串处理 + rendered.put(key, renderTemplate(strValue, inputs)); + } + } else { + // 普通字符串或包含多个变量的模板 + rendered.put(key, renderTemplate(strValue, inputs)); + } + } else if (value instanceof Map) { + // 递归处理嵌套的 Map + @SuppressWarnings("unchecked") + Map nestedMap = (Map) value; + rendered.put(key, renderJsonBody(nestedMap, inputs)); + } else { + // 其他类型直接保留 + rendered.put(key, value); + } + } + return rendered; + } + + /** + * 解析变量引用为 JSON 对象 + * 例如:{var_01} -> 尝试解析 var_01 的值为 JSON Map + */ + private Map resolveVariableAsJson(String varRef, List inputs, ObjectMapper mapper) { + // 提取变量名(去掉 {}) + String varName = varRef.replaceAll("[{}]", ""); + + // 查找对应的输入变量 + for (NodeIOData input : inputs) { + if (input != null && varName.equals(input.getName())) { + String varValue = input.valueToString(); + return tryParseJson(varValue, mapper); + } + } + + return null; + } + + /** + * 构建 Form Data + */ + private MultiValueMap buildFormData(List formItems, List inputs) { + MultiValueMap formData = new LinkedMultiValueMap<>(); + if (formItems != null) { + for (HttpRequestNodeConfig.FormItem item : formItems) { + if (StringUtils.isNotBlank(item.getName())) { + String name = renderTemplate(item.getName(), inputs); + String value = renderTemplate(item.getValue(), inputs); + formData.add(name, value); + } + } + } + return formData; + } + + /** + * 构建 Form URL Encoded + */ + private MultiValueMap buildFormUrlEncoded(List formItems, List inputs) { + return buildFormData(formItems, inputs); + } + + /** + * 执行 HTTP 请求(支持重试) + */ + private String executeHttpRequest(String url, String method, HttpHeaders headers, Object body, HttpRequestNodeConfig config) { + RestTemplate restTemplate = createRestTemplate(config.getTimeout()); + + int maxRetries = config.getRetryTimes() != null ? config.getRetryTimes() : 0; + int attempt = 0; + Exception lastException = null; + + while (attempt <= maxRetries) { + try { + // 设置 Content-Type + if (StringUtils.isNotBlank(config.getContentType())) { + headers.setContentType(MediaType.parseMediaType(config.getContentType())); + } + + HttpEntity requestEntity = new HttpEntity<>(body, headers); + HttpMethod httpMethod = HttpMethod.valueOf(method.toUpperCase()); + + ResponseEntity response = restTemplate.exchange(url, httpMethod, requestEntity, String.class); + + if (response.getStatusCode().is2xxSuccessful()) { + return response.getBody(); + } else { + throw new RuntimeException("HTTP 请求失败,状态码: " + response.getStatusCode()); + } + + } catch (Exception e) { + lastException = e; + attempt++; + + if (attempt <= maxRetries) { + log.warn("HTTP 请求失败,正在重试 ({}/{}): {}", attempt, maxRetries, e.getMessage()); + try { + TimeUnit.SECONDS.sleep(1); // 重试前等待 1 秒 + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + throw new RuntimeException("重试等待被中断", ie); + } + } + } + } + + throw new RuntimeException("HTTP 请求失败,已重试 " + maxRetries + " 次", lastException); + } + + /** + * 创建 RestTemplate(设置超时) + */ + private RestTemplate createRestTemplate(Integer timeoutSeconds) { + RestTemplate restTemplate = new RestTemplate(); + + // 设置超时时间 + int timeout = (timeoutSeconds != null ? timeoutSeconds : 10) * 1000; + org.springframework.http.client.SimpleClientHttpRequestFactory requestFactory = + new org.springframework.http.client.SimpleClientHttpRequestFactory(); + requestFactory.setConnectTimeout(timeout); + requestFactory.setReadTimeout(timeout); + restTemplate.setRequestFactory(requestFactory); + + return restTemplate; + } +} diff --git a/ruoyi-modules-api/ruoyi-workflow-api/src/main/java/org/ruoyi/workflow/workflow/node/httpRequest/HttpRequestNodeConfig.java b/ruoyi-modules-api/ruoyi-workflow-api/src/main/java/org/ruoyi/workflow/workflow/node/httpRequest/HttpRequestNodeConfig.java new file mode 100644 index 00000000..15bcd9a1 --- /dev/null +++ b/ruoyi-modules-api/ruoyi-workflow-api/src/main/java/org/ruoyi/workflow/workflow/node/httpRequest/HttpRequestNodeConfig.java @@ -0,0 +1,113 @@ +package org.ruoyi.workflow.workflow.node.httpRequest; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.util.List; +import java.util.Map; + +/** + * HTTP 请求节点配置 + */ +@Data +public class HttpRequestNodeConfig { + + /** + * HTTP 请求方法 + */ + private String method = "GET"; + + /** + * 请求 URL + */ + private String url; + + /** + * Content-Type + */ + @JsonProperty("content_type") + private String contentType = "text/plain"; + + /** + * 请求头列表 + */ + private List headers; + + /** + * Query 参数列表 + */ + private List params; + + /** + * 纯文本请求体 + */ + @JsonProperty("text_body") + private String textBody; + + /** + * JSON 请求体 + */ + @JsonProperty("json_body") + private Map jsonBody; + + /** + * Form Data 请求体 + */ + @JsonProperty("form_data_body") + private List formDataBody; + + /** + * Form URL Encoded 请求体 + */ + @JsonProperty("form_urlencoded_body") + private List formUrlencodedBody; + + /** + * 请求体(通用) + */ + private Map body; + + /** + * 超时时间(秒) + */ + private Integer timeout = 10; + + /** + * 重试次数 + */ + @JsonProperty("retry_times") + private Integer retryTimes = 0; + + /** + * 是否清除 HTML 标签 + */ + @JsonProperty("clear_html") + private Boolean clearHtml = false; + + /** + * 请求头项 + */ + @Data + public static class HeaderItem { + private String name; + private String value; + } + + /** + * Query 参数项 + */ + @Data + public static class ParamItem { + private String name; + private String value; + } + + /** + * Form 表单项 + */ + @Data + public static class FormItem { + private String name; + private String value; + } +}