增加构建通知

This commit is contained in:
dengqichen 2025-11-20 12:13:53 +08:00
parent 97f6e8c8e2
commit e409101fbf
6 changed files with 625 additions and 0 deletions

View File

@ -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<HttpRequestInputMapping, HttpRequestOutputs> {
@Resource
private ObjectMapper objectMapper;
@Override
protected void executeInternal(DelegateExecution execution,
Map<String, Object> 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<String> 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<String> response, long responseTime,
HttpRequestInputMapping.ResponseBodyType type) {
output.setStatusCode(response.getStatusCode().value());
output.setResponseTime(responseTime);
output.setIsSuccess(response.getStatusCode().is2xxSuccessful());
// 解析响应头
Map<String, String> 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<String, String> 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<String, String> queryParams) {
if (queryParams == null || queryParams.isEmpty()) {
return baseUrl;
}
UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(baseUrl);
queryParams.forEach(builder::queryParam);
return builder.toUriString();
}
}

View File

@ -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<KeyValue> headers;
/**
* 请求体自动序列化为JSON
*/
private Object body;
/**
* 查询参数数组格式前端 KeyValueEditor 传入
*/
private List<KeyValue> 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<String, String> 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<String, String> 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
}
}

View File

@ -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<String, String> responseHeaders;
/**
* 请求耗时毫秒
*/
private Long responseTime;
/**
* 响应大小字节
*/
private Long responseSize;
/**
* 是否成功2xx状态码
*/
private Boolean isSuccess;
/**
* 错误信息
*/
private String errorMessage;
}

View File

@ -74,6 +74,13 @@ public enum NodeTypeEnums {
NodeCategoryEnums.TASK,
"通知节点"
),
HTTP_REQUEST(
"HTTP_REQUEST",
"HTTP节点",
BpmnNodeTypeEnums.SERVICE_TASK,
NodeCategoryEnums.TASK,
"HTTP节点"
),
APPROVAL(
"APPROVAL",
"审批节点",

View File

@ -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;

View File

@ -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
}
)
};