From e409101fbffd449b14c956d3f50e77feb939eb2f Mon Sep 17 00:00:00 2001 From: dengqichen Date: Thu, 20 Nov 2025 12:13:53 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=9E=84=E5=BB=BA=E9=80=9A?= =?UTF-8?q?=E7=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../delegate/HttpRequestNodeDelegate.java | 243 ++++++++++++++++++ .../inputmapping/HttpRequestInputMapping.java | 116 +++++++++ .../dto/outputs/HttpRequestOutputs.java | 52 ++++ .../backend/workflow/enums/NodeTypeEnums.java | 7 + .../backend/workflow/util/BpmnConverter.java | 2 + .../Workflow/Design/nodes/HttpRequestNode.tsx | 205 +++++++++++++++ 6 files changed, 625 insertions(+) create mode 100644 backend/src/main/java/com/qqchen/deploy/backend/workflow/delegate/HttpRequestNodeDelegate.java create mode 100644 backend/src/main/java/com/qqchen/deploy/backend/workflow/dto/inputmapping/HttpRequestInputMapping.java create mode 100644 backend/src/main/java/com/qqchen/deploy/backend/workflow/dto/outputs/HttpRequestOutputs.java create mode 100644 frontend/src/pages/Workflow/Design/nodes/HttpRequestNode.tsx diff --git a/backend/src/main/java/com/qqchen/deploy/backend/workflow/delegate/HttpRequestNodeDelegate.java b/backend/src/main/java/com/qqchen/deploy/backend/workflow/delegate/HttpRequestNodeDelegate.java new file mode 100644 index 00000000..578fd75c --- /dev/null +++ b/backend/src/main/java/com/qqchen/deploy/backend/workflow/delegate/HttpRequestNodeDelegate.java @@ -0,0 +1,243 @@ +package com.qqchen.deploy.backend.workflow.delegate; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.qqchen.deploy.backend.framework.utils.JsonUtils; +import com.qqchen.deploy.backend.workflow.dto.inputmapping.HttpRequestInputMapping; +import com.qqchen.deploy.backend.workflow.dto.outputs.HttpRequestOutputs; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.flowable.engine.delegate.DelegateExecution; +import org.springframework.http.*; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.HttpServerErrorException; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * HTTP请求节点委托 + * + * @author qqchen + * @since 2025-11-20 + */ +@Slf4j +@Component("httpRequestDelegate") +public class HttpRequestNodeDelegate extends BaseNodeDelegate { + + @Resource + private ObjectMapper objectMapper; + + @Override + protected void executeInternal(DelegateExecution execution, + Map configs, + HttpRequestInputMapping input) { + long startTime = System.currentTimeMillis(); + + try { + logInfo(String.format("开始HTTP请求: %s %s (超时: %dms)", + input.getMethod(), input.getUrl(), input.getTimeout())); + + // 1. 创建专属的RestTemplate,使用节点配置的超时时间 + RestTemplate restTemplate = createRestTemplate(input.getTimeout()); + + // 2. 构建请求 + HttpHeaders headers = buildHeaders(input.getHeadersAsMap()); + HttpEntity entity = new HttpEntity<>(input.getBody(), headers); + + // 3. 构建完整URL(带查询参数) + String fullUrl = buildUrl(input.getUrl(), input.getQueryParamsAsMap()); + + logInfo(String.format("请求URL: %s", fullUrl)); + if (input.getBody() != null) { + logInfo(String.format("请求体: %s", JsonUtils.toJson(input.getBody()))); + } + + // 4. 发送HTTP请求 + ResponseEntity response = restTemplate.exchange( + fullUrl, + input.getMethod(), + entity, + String.class + ); + + // 4. 解析响应 + long responseTime = System.currentTimeMillis() - startTime; + parseResponse(response, responseTime, input.getResponseBodyType()); + + logInfo(String.format("HTTP请求成功: 状态码=%d, 耗时=%dms, 响应大小=%d bytes", + output.getStatusCode(), output.getResponseTime(), output.getResponseSize())); + + } catch (HttpClientErrorException | HttpServerErrorException e) { + // HTTP错误(4xx, 5xx) + handleHttpError(e, System.currentTimeMillis() - startTime); + } catch (Exception e) { + // 其他错误(网络超时、连接失败等) + handleGeneralError(e); + } + } + + /** + * 解析响应 + */ + private void parseResponse(ResponseEntity response, long responseTime, + HttpRequestInputMapping.ResponseBodyType type) { + output.setStatusCode(response.getStatusCode().value()); + output.setResponseTime(responseTime); + output.setIsSuccess(response.getStatusCode().is2xxSuccessful()); + + // 解析响应头 + Map headerMap = new HashMap<>(); + response.getHeaders().forEach((k, v) -> + headerMap.put(k, String.join(", ", v)) + ); + output.setResponseHeaders(headerMap); + + // 解析响应体 + String body = response.getBody(); + if (body != null) { + output.setResponseSize((long) body.length()); + + switch (type) { + case JSON -> { + try { + // 尝试解析为JSON对象或数组 + Object jsonBody = parseJsonBody(body); + output.setResponseBody(jsonBody); + logInfo(String.format("响应体(JSON): %s", body.length() > 500 ? + body.substring(0, 500) + "..." : body)); + } catch (Exception e) { + log.warn("JSON解析失败,返回原始文本: {}", e.getMessage()); + output.setResponseBody(body); + } + } + case TEXT -> { + output.setResponseBody(body); + logInfo(String.format("响应体(TEXT): %s", body.length() > 200 ? + body.substring(0, 200) + "..." : body)); + } + case XML -> output.setResponseBody(body); // 简化实现,直接返回文本 + case BINARY -> output.setResponseBody(body.getBytes()); + } + } + } + + /** + * 解析JSON响应体 + */ + private Object parseJsonBody(String body) throws Exception { + // 尝试解析为Map或List + if (body.trim().startsWith("{")) { + return objectMapper.readValue(body, Map.class); + } else if (body.trim().startsWith("[")) { + return objectMapper.readValue(body, List.class); + } else { + return body; + } + } + + /** + * 处理HTTP错误 + */ + private void handleHttpError(Exception e, long responseTime) { + HttpStatusCode statusCode = null; + String responseBody = null; + + if (e instanceof HttpClientErrorException) { + HttpClientErrorException clientError = (HttpClientErrorException) e; + statusCode = clientError.getStatusCode(); + responseBody = clientError.getResponseBodyAsString(); + } else if (e instanceof HttpServerErrorException) { + HttpServerErrorException serverError = (HttpServerErrorException) e; + statusCode = serverError.getStatusCode(); + responseBody = serverError.getResponseBodyAsString(); + } + + output.setStatusCode(statusCode != null ? statusCode.value() : 0); + output.setResponseTime(responseTime); + output.setIsSuccess(false); + output.setErrorMessage(e.getMessage()); + + if (responseBody != null && !responseBody.isEmpty()) { + output.setResponseBody(responseBody); + output.setResponseSize((long) responseBody.length()); + } + + String errorMsg = String.format("HTTP请求失败: 状态码=%d, 错误=%s", + output.getStatusCode(), e.getMessage()); + + // 不在这里记录日志,由BaseNodeDelegate统一处理(避免重复记录) + // 直接抛出异常,由BaseNodeDelegate统一处理continueOnFailure逻辑 + throw new RuntimeException(errorMsg, e); + } + + /** + * 处理一般错误(网络超时、连接失败等) + */ + private void handleGeneralError(Exception e) { + output.setIsSuccess(false); + output.setErrorMessage(e.getMessage()); + + String errorMsg = String.format("HTTP请求异常: %s - %s", + e.getClass().getSimpleName(), e.getMessage()); + + // 不在这里记录日志,由BaseNodeDelegate统一处理(避免重复记录) + // 直接抛出异常,由BaseNodeDelegate统一处理continueOnFailure逻辑 + throw new RuntimeException(errorMsg, e); + } + + /** + * 创建RestTemplate(使用节点配置的超时时间) + */ + private RestTemplate createRestTemplate(Integer timeout) { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + + // 使用节点配置的超时时间 + int timeoutMs = (timeout != null && timeout > 0) ? timeout : 30000; + + // 连接超时:建立TCP连接的超时时间 + factory.setConnectTimeout(timeoutMs); + + // 读取超时:等待服务器响应的超时时间 + factory.setReadTimeout(timeoutMs); + + RestTemplate restTemplate = new RestTemplate(factory); + return restTemplate; + } + + /** + * 构建请求头 + */ + private HttpHeaders buildHeaders(Map headersMap) { + HttpHeaders headers = new HttpHeaders(); + + // 默认设置 Content-Type 为 JSON(如果有body且未指定) + if (currentInputMapping.getBody() != null && + (headersMap == null || !headersMap.containsKey("Content-Type"))) { + headers.setContentType(MediaType.APPLICATION_JSON); + } + + if (headersMap != null) { + headersMap.forEach(headers::set); + } + + return headers; + } + + /** + * 构建完整URL(带查询参数) + */ + private String buildUrl(String baseUrl, Map queryParams) { + if (queryParams == null || queryParams.isEmpty()) { + return baseUrl; + } + + UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(baseUrl); + queryParams.forEach(builder::queryParam); + return builder.toUriString(); + } +} diff --git a/backend/src/main/java/com/qqchen/deploy/backend/workflow/dto/inputmapping/HttpRequestInputMapping.java b/backend/src/main/java/com/qqchen/deploy/backend/workflow/dto/inputmapping/HttpRequestInputMapping.java new file mode 100644 index 00000000..d4d2b544 --- /dev/null +++ b/backend/src/main/java/com/qqchen/deploy/backend/workflow/dto/inputmapping/HttpRequestInputMapping.java @@ -0,0 +1,116 @@ +package com.qqchen.deploy.backend.workflow.dto.inputmapping; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Data; +import jakarta.validation.constraints.NotBlank; +import org.springframework.http.HttpMethod; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * HTTP请求节点输入映射 + * + * @author qqchen + * @since 2025-11-20 + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class HttpRequestInputMapping extends BaseNodeInputMapping { + + /** + * 请求URL(支持表达式) + */ + @NotBlank(message = "请求URL不能为空") + private String url; + + /** + * HTTP方法 + */ + private HttpMethod method = HttpMethod.GET; + + /** + * 请求头(数组格式,前端 KeyValueEditor 传入) + */ + private List headers; + + /** + * 请求体(自动序列化为JSON) + */ + private Object body; + + /** + * 查询参数(数组格式,前端 KeyValueEditor 传入) + */ + private List queryParams; + + /** + * 超时时间(毫秒),默认30秒 + */ + private Integer timeout = 30000; + + /** + * 响应体解析类型 + */ + private ResponseBodyType responseBodyType = ResponseBodyType.JSON; + + /** + * 是否跟随重定向 + */ + private Boolean followRedirects = true; + + /** + * SSL证书验证(生产环境建议true) + */ + private Boolean verifySsl = true; + + /** + * 键值对内部类(用于前端 KeyValueEditor 组件) + */ + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class KeyValue { + private String key; + private String value; + } + + /** + * 将请求头数组转换为Map(供HTTP客户端使用) + */ + public Map getHeadersAsMap() { + if (headers == null || headers.isEmpty()) { + return Collections.emptyMap(); + } + return headers.stream() + .filter(kv -> kv.getKey() != null && !kv.getKey().isEmpty()) + .collect(Collectors.toMap(KeyValue::getKey, KeyValue::getValue)); + } + + /** + * 将查询参数数组转换为Map(供URL构建使用) + */ + public Map getQueryParamsAsMap() { + if (queryParams == null || queryParams.isEmpty()) { + return Collections.emptyMap(); + } + return queryParams.stream() + .filter(kv -> kv.getKey() != null && !kv.getKey().isEmpty()) + .collect(Collectors.toMap(KeyValue::getKey, KeyValue::getValue)); + } + + /** + * 响应体解析类型枚举 + */ + public enum ResponseBodyType { + /** JSON格式(自动解析为Map/List) */ + JSON, + /** 纯文本 */ + TEXT, + /** XML格式 */ + XML, + /** 二进制数据 */ + BINARY + } +} diff --git a/backend/src/main/java/com/qqchen/deploy/backend/workflow/dto/outputs/HttpRequestOutputs.java b/backend/src/main/java/com/qqchen/deploy/backend/workflow/dto/outputs/HttpRequestOutputs.java new file mode 100644 index 00000000..7bb22a49 --- /dev/null +++ b/backend/src/main/java/com/qqchen/deploy/backend/workflow/dto/outputs/HttpRequestOutputs.java @@ -0,0 +1,52 @@ +package com.qqchen.deploy.backend.workflow.dto.outputs; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.util.Map; + +/** + * HTTP请求节点输出 + * + * @author qqchen + * @since 2025-11-20 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class HttpRequestOutputs extends BaseNodeOutputs { + + /** + * HTTP状态码 + */ + private Integer statusCode; + + /** + * 响应体(自动解析,JSON格式解析为Map/List) + */ + private Object responseBody; + + /** + * 响应头 + */ + private Map responseHeaders; + + /** + * 请求耗时(毫秒) + */ + private Long responseTime; + + /** + * 响应大小(字节) + */ + private Long responseSize; + + /** + * 是否成功(2xx状态码) + */ + private Boolean isSuccess; + + /** + * 错误信息 + */ + private String errorMessage; +} diff --git a/backend/src/main/java/com/qqchen/deploy/backend/workflow/enums/NodeTypeEnums.java b/backend/src/main/java/com/qqchen/deploy/backend/workflow/enums/NodeTypeEnums.java index b38a0985..31e9658e 100644 --- a/backend/src/main/java/com/qqchen/deploy/backend/workflow/enums/NodeTypeEnums.java +++ b/backend/src/main/java/com/qqchen/deploy/backend/workflow/enums/NodeTypeEnums.java @@ -74,6 +74,13 @@ public enum NodeTypeEnums { NodeCategoryEnums.TASK, "通知节点" ), + HTTP_REQUEST( + "HTTP_REQUEST", + "HTTP节点", + BpmnNodeTypeEnums.SERVICE_TASK, + NodeCategoryEnums.TASK, + "HTTP节点" + ), APPROVAL( "APPROVAL", "审批节点", diff --git a/backend/src/main/java/com/qqchen/deploy/backend/workflow/util/BpmnConverter.java b/backend/src/main/java/com/qqchen/deploy/backend/workflow/util/BpmnConverter.java index 2ec1f400..c49e6673 100644 --- a/backend/src/main/java/com/qqchen/deploy/backend/workflow/util/BpmnConverter.java +++ b/backend/src/main/java/com/qqchen/deploy/backend/workflow/util/BpmnConverter.java @@ -429,6 +429,8 @@ public class BpmnConverter { return "${approvalDelegate}"; case "DEPLOY_NODE": return "${deployDelegate}"; + case "HTTP_REQUEST": + return "${httpRequestDelegate}"; default: log.warn("未知的节点类型: {}, 将不设置 delegateExpression", nodeCode); return null; diff --git a/frontend/src/pages/Workflow/Design/nodes/HttpRequestNode.tsx b/frontend/src/pages/Workflow/Design/nodes/HttpRequestNode.tsx new file mode 100644 index 00000000..77055fcc --- /dev/null +++ b/frontend/src/pages/Workflow/Design/nodes/HttpRequestNode.tsx @@ -0,0 +1,205 @@ +import {ConfigurableNodeDefinition, NodeType, NodeCategory, defineNodeOutputs} from './types'; + +/** + * HTTP请求节点定义 + * 用于发送HTTP/HTTPS请求并解析响应,支持REST API调用、Webhook触发等场景 + */ +export const HttpRequestNodeDefinition: ConfigurableNodeDefinition = { + nodeCode: "HTTP_REQUEST", + nodeName: "HTTP请求", + nodeType: NodeType.HTTP_REQUEST, + category: NodeCategory.TASK, + description: "发送HTTP/HTTPS请求并解析响应", + + // 渲染配置 + renderConfig: { + shape: 'rounded-rect', + size: {width: 140, height: 48}, + icon: { + type: 'emoji', + content: '🌐', + size: 32 + }, + theme: { + primary: '#10b981', + secondary: '#059669', + selectedBorder: '#3b82f6', + hoverBorder: '#10b981', + gradient: ['#ffffff', '#d1fae5'] + }, + handles: { + input: true, + output: true + }, + features: { + showBadge: true, + showHoverMenu: true + } + }, + + // 输入配置Schema + inputMappingSchema: { + type: "object", + title: "HTTP请求配置", + description: "配置HTTP请求的URL、方法、参数等", + properties: { + continueOnFailure: { + type: "boolean", + title: "失败后继续", + description: "当节点执行失败时,是否继续执行后续节点。true: 节点失败时标记为 FAILURE,但流程继续执行后续节点;false: 节点失败时抛出 BpmnError,终止流程", + default: false + }, + url: { + type: "string", + title: "请求URL", + description: "HTTP请求的完整URL地址,支持变量表达式,如:${apiUrl}/status", + "x-allow-variable": true, + pattern: "^https?://.*", + minLength: 1 + }, + method: { + type: "string", + title: "HTTP方法", + description: "选择HTTP请求方法", + enum: ["GET", "POST", "PUT", "DELETE", "PATCH"], + enumNames: ["GET", "POST", "PUT", "DELETE", "PATCH"], + default: "GET" + }, + headers: { + type: "array", + title: "请求头", + description: "自定义HTTP请求头,例如:Authorization, Content-Type", + "x-component": "KeyValueEditor", + items: { + type: "object", + properties: { + key: { + type: "string", + title: "Header名称" + }, + value: { + type: "string", + title: "Header值" + } + }, + required: ["key", "value"] + }, + default: [] + }, + queryParams: { + type: "array", + title: "查询参数", + description: "URL查询参数,会自动拼接到URL后面", + "x-component": "KeyValueEditor", + items: { + type: "object", + properties: { + key: { + type: "string", + title: "参数名" + }, + value: { + type: "string", + title: "参数值" + } + }, + required: ["key", "value"] + }, + default: [] + }, + body: { + type: "string", + title: "请求体(JSON)", + description: "HTTP请求体内容,支持变量表达式", + format: "textarea", + "x-allow-variable": true, + "x-condition": { + field: "method", + operator: "in", + value: ["POST", "PUT", "PATCH"] + } + }, + timeout: { + type: "number", + title: "超时时间(毫秒)", + description: "请求超时时间,0表示不限制", + minimum: 1000, + maximum: 300000, + default: 30000 + }, + responseBodyType: { + type: "string", + title: "响应体类型", + description: "响应内容的解析方式", + enum: ["JSON", "TEXT", "XML", "BINARY"], + enumNames: ["JSON", "纯文本", "XML", "二进制"], + default: "JSON" + }, + verifySsl: { + type: "boolean", + title: "SSL证书验证", + description: "是否验证HTTPS证书,开发环境可关闭", + default: true + }, + followRedirects: { + type: "boolean", + title: "跟随重定向", + description: "是否自动跟随HTTP重定向(3xx状态码)", + default: true + } + }, + required: ["url", "method"] + }, + + // 输出字段定义 + outputs: defineNodeOutputs( + { + name: "statusCode", + title: "HTTP状态码", + type: "number", + description: "响应的HTTP状态码(如 200, 404, 500)", + example: 200, + required: true + }, + { + name: "isSuccess", + title: "是否成功", + type: "boolean", + description: "状态码为2xx时返回true", + example: true, + required: true + }, + { + name: "responseBody", + title: "响应体", + type: "object", + description: "响应内容,JSON格式会自动解析为对象", + example: { status: 'success', data: {} }, + required: false + }, + { + name: "responseTime", + title: "响应时间(毫秒)", + type: "number", + description: "请求耗时(毫秒)", + example: 150, + required: true + }, + { + name: "errorMessage", + title: "错误信息", + type: "string", + description: "失败时的错误描述", + example: "Connection timeout", + required: false + }, + { + name: "responseHeaders", + title: "响应头", + type: "object", + description: "HTTP响应头信息", + example: { "content-type": "application/json" }, + required: false + } + ) +};