resultType) {
+ if (expression == null || expression.isEmpty()) {
+ return null;
+ }
+
+ try {
+ // 创建支持 Map 属性访问的上下文
+ StandardEvaluationContext context = createContext(variables);
+
+ Expression exp = PARSER.parseExpression(expression);
+ return exp.getValue(context, resultType);
+ } catch (Exception e) {
+ log.error("表达式计算失败: {}", expression, e);
+ throw new RuntimeException("表达式计算失败: " + expression, e);
+ }
+ }
+
+ /**
+ * 创建支持 Map 属性式访问的 SpEL 上下文
+ *
+ * 关键:添加自定义 PropertyAccessor,允许像访问对象属性一样访问 Map 的 key
+ * 这样可以直接使用 `deploy.xxx` 语法,而不需要 `#deploy.xxx` 或 `['deploy'].xxx`
+ *
+ * @param variables 变量 Map
+ * @return SpEL 执行上下文
+ */
+ private static StandardEvaluationContext createContext(Map variables) {
+ StandardEvaluationContext context = new StandardEvaluationContext(variables);
+
+ // 添加自定义的 Map 属性访问器
+ context.addPropertyAccessor(new MapPropertyAccessor());
+
+ return context;
+ }
+
+ /**
+ * 自定义 Map 属性访问器
+ *
+ * 允许像访问对象属性一样访问 Map 的 key,支持嵌套访问
+ */
+ private static class MapPropertyAccessor implements PropertyAccessor {
+
+ @Override
+ public Class>[] getSpecificTargetClasses() {
+ return new Class>[]{Map.class};
+ }
+
+ @Override
+ public boolean canRead(EvaluationContext context, Object target, String name) throws AccessException {
+ return target instanceof Map && ((Map, ?>) target).containsKey(name);
+ }
+
+ @Override
+ public TypedValue read(EvaluationContext context, Object target, String name) throws AccessException {
+ if (target instanceof Map) {
+ Object value = ((Map, ?>) target).get(name);
+ return new TypedValue(value);
+ }
+ return TypedValue.NULL;
+ }
+
+ @Override
+ public boolean canWrite(EvaluationContext context, Object target, String name) throws AccessException {
+ return false; // 只读
+ }
+
+ @Override
+ public void write(EvaluationContext context, Object target, String name, Object newValue) throws AccessException {
+ throw new UnsupportedOperationException("不支持写入操作");
+ }
+ }
+
+ /**
+ * 批量解析模板字段
+ *
+ * @param templates 模板字符串数组
+ * @param variables 上下文变量
+ * @return 解析后的字符串数组
+ */
+ public static String[] resolveAll(String[] templates, Map variables) {
+ if (templates == null) {
+ return null;
+ }
+
+ String[] results = new String[templates.length];
+ for (int i = 0; i < templates.length; i++) {
+ results[i] = resolve(templates[i], variables);
+ }
+ return results;
+ }
+
+ /**
+ * 检查字符串是否包含 EL 表达式
+ *
+ * @param text 文本
+ * @return true 如果包含 ${...} 表达式
+ */
+ public static boolean containsExpression(String text) {
+ return text != null && text.contains("${");
+ }
+
+ /**
+ * 通用方法:自动解析对象中所有包含 EL 表达式的字符串字段
+ *
+ * 使用反射遍历对象的所有字段,自动识别并解析包含 ${} 的字符串字段。
+ * 避免了硬编码字段名,支持动态扩展。
+ *
+ *
使用示例:
+ *
{@code
+ * ApprovalTaskDTO dto = new ApprovalTaskDTO();
+ * dto.setTaskDescription("${deploy.applicationName}需要部署");
+ * dto.setApprovalTitle("部署${deploy.status == 'SUCCESS' ? '成功' : '失败'}");
+ *
+ * // 自动解析 dto 中所有包含表达式的字段
+ * SpelExpressionResolver.resolveObject(dto, variables);
+ *
+ * // dto 的字段已被自动解析:
+ * // taskDescription: "SSO管理后台需要部署"
+ * // approvalTitle: "部署成功"
+ * }
+ *
+ * @param target 目标对象(会直接修改其字段值)
+ * @param variables 上下文变量
+ */
+ public static void resolveObject(Object target, Map variables) {
+ if (target == null) {
+ return;
+ }
+
+ Class> clazz = target.getClass();
+
+ // 遍历所有字段(包括父类字段)
+ while (clazz != null && clazz != Object.class) {
+ for (java.lang.reflect.Field field : clazz.getDeclaredFields()) {
+ // 只处理 String 类型字段
+ if (field.getType() != String.class) {
+ continue;
+ }
+
+ try {
+ field.setAccessible(true);
+ String value = (String) field.get(target);
+
+ // 如果字段值包含表达式,则解析
+ if (containsExpression(value)) {
+ String resolved = resolve(value, variables);
+ field.set(target, resolved);
+ log.debug("自动解析字段 {}.{}: {} -> {}",
+ clazz.getSimpleName(), field.getName(), value, resolved);
+ }
+ } catch (IllegalAccessException e) {
+ log.warn("无法访问字段 {}.{}: {}",
+ clazz.getSimpleName(), field.getName(), e.getMessage());
+ }
+ }
+
+ // 处理父类字段
+ clazz = clazz.getSuperclass();
+ }
+ }
+
+ // ==================== Flowable 集成方法 ====================
+
+ /**
+ * 解析包含 EL 表达式的字符串模板(Flowable DelegateExecution 版本)
+ *
+ * 自动从 Flowable 执行上下文中提取变量,支持复杂表达式:
+ *
+ * - 简单变量:
${deploy.applicationName}
+ * - 条件判断:
${deploy.status == 'SUCCESS' ? '成功' : '失败'}
+ * - 复杂运算:
${deploy.duration > 60 ? '耗时较长' : '正常'}
+ *
+ *
+ * 使用示例:
+ *
{@code
+ * // 在 Flowable Delegate 中使用
+ * String message = SpelExpressionResolver.resolve(execution,
+ * "应用 ${deploy.applicationName} 部署${deploy.status == 'SUCCESS' ? '成功' : '失败'}");
+ * }
+ *
+ * @param execution Flowable 执行上下文
+ * @param template 模板字符串,可包含多个 ${...} 表达式
+ * @return 解析后的字符串
+ */
+ public static String resolve(DelegateExecution execution, String template) {
+ if (execution == null || template == null) {
+ return template;
+ }
+
+ // 从 Flowable 执行上下文中获取所有变量
+ Map variables = execution.getVariables();
+
+ // 委托给通用解析方法
+ return resolve(template, variables);
+ }
+
+ /**
+ * 解析包含 EL 表达式的字符串模板(Flowable DelegateTask 版本)
+ *
+ * @param task Flowable 任务上下文
+ * @param template 模板字符串
+ * @return 解析后的字符串
+ */
+ public static String resolve(DelegateTask task, String template) {
+ if (task == null || template == null) {
+ return template;
+ }
+
+ // 从 Flowable 任务上下文中获取所有变量
+ Map variables = task.getVariables();
+
+ // 委托给通用解析方法
+ return resolve(template, variables);
+ }
+
+ /**
+ * 解析单个纯表达式(返回原始类型)- Flowable DelegateExecution 版本
+ *
+ * @param execution Flowable 执行上下文
+ * @param expression 纯表达式(不需要 ${} 包裹)
+ * @param resultType 期望的返回类型
+ * @param 返回类型
+ * @return 表达式计算结果
+ */
+ public static T evaluateExpression(DelegateExecution execution, String expression, Class resultType) {
+ if (execution == null || expression == null) {
+ return null;
+ }
+
+ Map variables = execution.getVariables();
+ return evaluateExpression(expression, variables, resultType);
+ }
+
+ /**
+ * 解析单个纯表达式(返回原始类型)- Flowable DelegateTask 版本
+ *
+ * @param task Flowable 任务上下文
+ * @param expression 纯表达式(不需要 ${} 包裹)
+ * @param resultType 期望的返回类型
+ * @param 返回类型
+ * @return 表达式计算结果
+ */
+ public static T evaluateExpression(DelegateTask task, String expression, Class resultType) {
+ if (task == null || expression == null) {
+ return null;
+ }
+
+ Map variables = task.getVariables();
+ return evaluateExpression(expression, variables, resultType);
+ }
+
+ /**
+ * 解析表达式并返回原始类型 - 自动去除 ${} 包装(Flowable DelegateExecution 版本)
+ *
+ * 便捷方法,自动处理带或不带 ${} 的表达式
+ *
+ *
使用示例:
+ *
{@code
+ * // 自动去除 ${},返回原始类型
+ * Object userIds = SpelExpressionResolver.evaluateAuto(execution, "${approval.userIds}", Object.class);
+ * // 如果是列表:userIds instanceof List = true
+ *
+ * // 也支持不带 ${} 的表达式
+ * Object userIds2 = SpelExpressionResolver.evaluateAuto(execution, "approval.userIds", Object.class);
+ * }
+ *
+ * @param execution Flowable 执行上下文
+ * @param expression 表达式(可以带 ${} 也可以不带)
+ * @param resultType 期望的返回类型
+ * @param 返回类型
+ * @return 表达式计算结果(原始类型)
+ */
+ public static T evaluateAuto(DelegateExecution execution, String expression, Class resultType) {
+ if (execution == null || expression == null) {
+ return null;
+ }
+
+ // 自动去除 ${} 包装
+ String cleanExpression = expression.trim();
+ if (cleanExpression.startsWith("${") && cleanExpression.endsWith("}")) {
+ cleanExpression = cleanExpression.substring(2, cleanExpression.length() - 1).trim();
+ }
+
+ return evaluateExpression(execution, cleanExpression, resultType);
+ }
+
+ /**
+ * 解析表达式并返回原始类型 - 自动去除 ${} 包装(Flowable DelegateTask 版本)
+ *
+ * @param task Flowable 任务上下文
+ * @param expression 表达式(可以带 ${} 也可以不带)
+ * @param resultType 期望的返回类型
+ * @param 返回类型
+ * @return 表达式计算结果(原始类型)
+ */
+ public static T evaluateAuto(DelegateTask task, String expression, Class resultType) {
+ if (task == null || expression == null) {
+ return null;
+ }
+
+ // 自动去除 ${} 包装
+ String cleanExpression = expression.trim();
+ if (cleanExpression.startsWith("${") && cleanExpression.endsWith("}")) {
+ cleanExpression = cleanExpression.substring(2, cleanExpression.length() - 1).trim();
+ }
+
+ return evaluateExpression(task, cleanExpression, resultType);
+ }
+
+ /**
+ * 通用方法:自动解析对象中所有包含 EL 表达式的字符串字段 - Flowable DelegateExecution 版本
+ *
+ * @param target 目标对象(会直接修改其字段值)
+ * @param execution Flowable 执行上下文
+ */
+ public static void resolveObject(Object target, DelegateExecution execution) {
+ if (execution == null) {
+ return;
+ }
+
+ Map variables = execution.getVariables();
+ resolveObject(target, variables);
+ }
+
+ /**
+ * 通用方法:自动解析对象中所有包含 EL 表达式的字符串字段 - Flowable DelegateTask 版本
+ *
+ * @param target 目标对象(会直接修改其字段值)
+ * @param task Flowable 任务上下文
+ */
+ public static void resolveObject(Object target, DelegateTask task) {
+ if (task == null) {
+ return;
+ }
+
+ Map variables = task.getVariables();
+ resolveObject(target, variables);
+ }
+}
+
diff --git a/backend/src/main/java/com/qqchen/deploy/backend/notification/adapter/INotificationChannelAdapter.java b/backend/src/main/java/com/qqchen/deploy/backend/notification/adapter/INotificationChannelAdapter.java
index de4aea9d..0d4b95e5 100644
--- a/backend/src/main/java/com/qqchen/deploy/backend/notification/adapter/INotificationChannelAdapter.java
+++ b/backend/src/main/java/com/qqchen/deploy/backend/notification/adapter/INotificationChannelAdapter.java
@@ -38,5 +38,14 @@ public interface INotificationChannelAdapter {
default String validateConfig(Map config) {
return "配置有效";
}
+
+ /**
+ * 测试连接
+ * 发送一条测试消息或测试连接是否正常
+ *
+ * @param config 渠道配置
+ * @throws Exception 测试失败时抛出异常
+ */
+ void testConnection(Map config) throws Exception;
}
diff --git a/backend/src/main/java/com/qqchen/deploy/backend/notification/adapter/impl/EmailChannelAdapter.java b/backend/src/main/java/com/qqchen/deploy/backend/notification/adapter/impl/EmailChannelAdapter.java
index d742b82c..b27d9261 100644
--- a/backend/src/main/java/com/qqchen/deploy/backend/notification/adapter/impl/EmailChannelAdapter.java
+++ b/backend/src/main/java/com/qqchen/deploy/backend/notification/adapter/impl/EmailChannelAdapter.java
@@ -97,6 +97,65 @@ public class EmailChannelAdapter implements INotificationChannelAdapter {
}
}
+ @Override
+ public void testConnection(Map config) throws Exception {
+ // 1. 解析配置
+ EmailNotificationConfig emailConfig = objectMapper.convertValue(config, EmailNotificationConfig.class);
+
+ // 2. 校验配置
+ validateEmailConfig(emailConfig);
+
+ // 3. 创建JavaMailSender
+ JavaMailSenderImpl mailSender = createMailSender(emailConfig);
+
+ // 4. 测试SMTP连接
+ log.info("测试SMTP连接 - 服务器: {}:{}", emailConfig.getSmtpHost(), emailConfig.getSmtpPort());
+
+ try {
+ // 测试连接
+ mailSender.testConnection();
+ log.info("SMTP连接测试成功");
+ } catch (Exception e) {
+ log.error("SMTP连接测试失败: {}", e.getMessage());
+ throw new Exception("SMTP连接失败: " + e.getMessage(), e);
+ }
+
+ // 5. 发送测试邮件(可选:如果配置了默认收件人)
+ if (!CollectionUtils.isEmpty(emailConfig.getDefaultReceivers())) {
+ try {
+ MimeMessage mimeMessage = mailSender.createMimeMessage();
+ MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, true, "UTF-8");
+
+ // 设置发件人
+ if (emailConfig.getFromName() != null && !emailConfig.getFromName().isEmpty()) {
+ helper.setFrom(new InternetAddress(emailConfig.getFrom(), emailConfig.getFromName(), "UTF-8"));
+ } else {
+ helper.setFrom(emailConfig.getFrom());
+ }
+
+ // 设置收件人(使用配置的默认收件人)
+ helper.setTo(emailConfig.getDefaultReceivers().toArray(new String[0]));
+
+ // 设置主题
+ helper.setSubject("【测试邮件】Deploy Ease Platform 邮件通知测试");
+
+ // 设置内容
+ String content = "这是一条来自 Deploy Ease Platform 的测试邮件。\n\n" +
+ "如果您收到此邮件,说明邮件通知渠道配置正常!\n\n" +
+ "发送时间:" + java.time.LocalDateTime.now();
+ helper.setText(content, false);
+
+ // 发送邮件
+ log.info("发送测试邮件到: {}", emailConfig.getDefaultReceivers());
+ mailSender.send(mimeMessage);
+ log.info("测试邮件发送成功");
+ } catch (Exception e) {
+ log.error("测试邮件发送失败: {}", e.getMessage());
+ throw new Exception("测试邮件发送失败: " + e.getMessage(), e);
+ }
+ }
+ }
+
/**
* 校验邮件配置
*/
diff --git a/backend/src/main/java/com/qqchen/deploy/backend/notification/adapter/impl/WeworkChannelAdapter.java b/backend/src/main/java/com/qqchen/deploy/backend/notification/adapter/impl/WeworkChannelAdapter.java
index 665bfd66..c3777a3c 100644
--- a/backend/src/main/java/com/qqchen/deploy/backend/notification/adapter/impl/WeworkChannelAdapter.java
+++ b/backend/src/main/java/com/qqchen/deploy/backend/notification/adapter/impl/WeworkChannelAdapter.java
@@ -120,6 +120,49 @@ public class WeworkChannelAdapter implements INotificationChannelAdapter {
}
}
+ @Override
+ public void testConnection(Map config) throws Exception {
+ // 1. 解析配置
+ WeworkNotificationConfig weworkConfig = objectMapper.convertValue(config, WeworkNotificationConfig.class);
+
+ if (weworkConfig.getWebhookUrl() == null || weworkConfig.getWebhookUrl().isEmpty()) {
+ throw new IllegalArgumentException("企业微信Webhook URL未配置");
+ }
+
+ // 2. 构建测试消息
+ Map messageBody = new HashMap<>();
+ messageBody.put("msgtype", "text");
+
+ Map textContent = new HashMap<>();
+ textContent.put("content", "【测试消息】\n这是一条来自 Deploy Ease Platform 的测试消息,您的企业微信通知渠道配置正常!");
+ messageBody.put("text", textContent);
+
+ // 3. 发送测试请求
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.APPLICATION_JSON);
+
+ String jsonBody = objectMapper.writeValueAsString(messageBody);
+ HttpEntity entity = new HttpEntity<>(jsonBody, headers);
+
+ log.info("测试企业微信连接 - URL: {}", weworkConfig.getWebhookUrl());
+
+ String response = restTemplate.exchange(
+ weworkConfig.getWebhookUrl(),
+ HttpMethod.POST,
+ entity,
+ String.class
+ ).getBody();
+
+ log.info("企业微信连接测试成功 - 响应: {}", response);
+
+ // 4. 检查响应
+ if (response != null && response.contains("\"errcode\":0")) {
+ log.info("企业微信通知渠道测试成功");
+ } else {
+ throw new Exception("企业微信响应异常: " + response);
+ }
+ }
+
/**
* 构建消息内容
*/
diff --git a/backend/src/main/java/com/qqchen/deploy/backend/notification/service/impl/NotificationChannelServiceImpl.java b/backend/src/main/java/com/qqchen/deploy/backend/notification/service/impl/NotificationChannelServiceImpl.java
index ed92a924..b4befdb5 100644
--- a/backend/src/main/java/com/qqchen/deploy/backend/notification/service/impl/NotificationChannelServiceImpl.java
+++ b/backend/src/main/java/com/qqchen/deploy/backend/notification/service/impl/NotificationChannelServiceImpl.java
@@ -56,13 +56,32 @@ public class NotificationChannelServiceImpl
@Override
@Transactional
public boolean testConnection(Long id) {
+ // 1. 查询渠道配置
NotificationChannel channel = notificationChannelRepository.findById(id)
.orElseThrow(() -> new BusinessException(ResponseCode.DATA_NOT_FOUND));
- // TODO: 实现实际的连接测试逻辑
- log.info("测试通知渠道连接: id={}, type={}", id, channel.getChannelType());
+ log.info("开始测试通知渠道连接: id={}, name={}, type={}",
+ id, channel.getName(), channel.getChannelType());
- return true;
+ // 2. 获取对应的适配器
+ INotificationChannelAdapter adapter;
+ try {
+ adapter = adapterFactory.getAdapter(channel.getChannelType());
+ } catch (IllegalArgumentException e) {
+ log.error("获取通知渠道适配器失败: {}", e.getMessage());
+ throw new BusinessException(ResponseCode.ERROR, new Object[]{"不支持的渠道类型: " + channel.getChannelType()});
+ }
+
+ // 3. 执行连接测试
+ try {
+ adapter.testConnection(channel.getConfig());
+ log.info("通知渠道连接测试成功: id={}, name={}", id, channel.getName());
+ return true;
+ } catch (Exception e) {
+ log.error("通知渠道连接测试失败: id={}, name={}, 错误: {}",
+ id, channel.getName(), e.getMessage(), e);
+ throw new BusinessException(ResponseCode.ERROR, new Object[]{"连接测试失败: " + e.getMessage()});
+ }
}
@Override
diff --git a/backend/src/main/java/com/qqchen/deploy/backend/workflow/api/WorkflowCategoryApiController.java b/backend/src/main/java/com/qqchen/deploy/backend/workflow/api/WorkflowCategoryApiController.java
index 64bf07fe..cf878258 100644
--- a/backend/src/main/java/com/qqchen/deploy/backend/workflow/api/WorkflowCategoryApiController.java
+++ b/backend/src/main/java/com/qqchen/deploy/backend/workflow/api/WorkflowCategoryApiController.java
@@ -9,6 +9,9 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@@ -29,23 +32,23 @@ public class WorkflowCategoryApiController extends BaseController create(WorkflowCategoryDTO dto) {
+ public Response create(@Validated @RequestBody WorkflowCategoryDTO dto) {
return super.create(dto);
}
@Override
- public Response update(Long aLong, WorkflowCategoryDTO dto) {
- return super.update(aLong, dto);
+ public Response update(@PathVariable Long id, @Validated @RequestBody WorkflowCategoryDTO dto) {
+ return super.update(id, dto);
}
@Override
- public Response delete(Long aLong) {
- return super.delete(aLong);
+ public Response delete(@PathVariable Long id) {
+ return super.delete(id);
}
@Override
- public Response findById(Long aLong) {
- return super.findById(aLong);
+ public Response findById(@PathVariable Long id) {
+ return super.findById(id);
}
@Override
diff --git a/backend/src/main/java/com/qqchen/deploy/backend/workflow/delegate/ApprovalTaskListener.java b/backend/src/main/java/com/qqchen/deploy/backend/workflow/delegate/ApprovalTaskListener.java
index f6ea7a3c..0753636a 100644
--- a/backend/src/main/java/com/qqchen/deploy/backend/workflow/delegate/ApprovalTaskListener.java
+++ b/backend/src/main/java/com/qqchen/deploy/backend/workflow/delegate/ApprovalTaskListener.java
@@ -1,5 +1,6 @@
package com.qqchen.deploy.backend.workflow.delegate;
+import com.qqchen.deploy.backend.framework.utils.SpelExpressionResolver;
import com.qqchen.deploy.backend.workflow.dto.inputmapping.ApprovalInputMapping;
import com.qqchen.deploy.backend.workflow.dto.outputs.ApprovalOutputs;
import com.qqchen.deploy.backend.workflow.enums.ApprovalModeEnum;
@@ -16,7 +17,7 @@ import java.util.Map;
/**
* 审批任务监听器
* 在 UserTask 创建时被调用,负责配置审批人和任务基本信息
- *
+ *
* @author qqchen
* @since 2025-10-23
*/
@@ -31,10 +32,10 @@ public class ApprovalTaskListener extends BaseTaskListener 0) {
java.util.Date dueDate = java.sql.Timestamp.valueOf(
@@ -50,10 +51,10 @@ public class ApprovalTaskListener extends BaseTaskListener implements JavaDelegate {
if (value instanceof String) {
String strValue = (String) value;
try {
- // ✅ 使用统一的 resolve 方法,自动智能处理所有情况
- Object resolvedValue = NestedMapUtils.resolve(execution, strValue);
+ // ✅ 使用 SpelExpressionResolver 统一解析方法,支持复杂表达式
+ String resolvedValue = SpelExpressionResolver.resolve(execution, strValue);
log.debug("解析字段: {} = {} -> {}", entry.getKey(), strValue, resolvedValue);
resolvedMap.put(entry.getKey(), resolvedValue);
} catch (Exception e) {
diff --git a/backend/src/main/java/com/qqchen/deploy/backend/workflow/listener/flowable/execution/GlobalNodeExecutionListener.java b/backend/src/main/java/com/qqchen/deploy/backend/workflow/listener/flowable/execution/GlobalNodeExecutionListener.java
index e9f7d9f6..732f7e5d 100644
--- a/backend/src/main/java/com/qqchen/deploy/backend/workflow/listener/flowable/execution/GlobalNodeExecutionListener.java
+++ b/backend/src/main/java/com/qqchen/deploy/backend/workflow/listener/flowable/execution/GlobalNodeExecutionListener.java
@@ -2,7 +2,9 @@ package com.qqchen.deploy.backend.workflow.listener.flowable.execution;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
+import com.qqchen.deploy.backend.framework.utils.SpelExpressionResolver;
import com.qqchen.deploy.backend.workflow.constants.WorkFlowConstants;
+import com.qqchen.deploy.backend.workflow.enums.NodeExecutionStatusEnum;
import com.qqchen.deploy.backend.workflow.enums.WorkflowNodeInstanceStatusEnums;
import com.qqchen.deploy.backend.workflow.dto.event.WorkflowNodeInstanceStatusChangeEvent;
import lombok.extern.slf4j.Slf4j;
@@ -23,6 +25,22 @@ import java.util.*;
@Component("globalNodeExecutionListener")
public class GlobalNodeExecutionListener implements ExecutionListener {
+ /**
+ * 不产生 outputs 的节点类型(事件/网关节点)
+ * 这些节点执行后不会设置 outputs.status,直接视为成功
+ */
+ private static final Set NODES_WITHOUT_OUTPUTS = Set.of(
+ "StartEvent", // 开始事件
+ "EndEvent", // 结束事件
+ "BoundaryEvent", // 边界事件
+ "IntermediateCatchEvent", // 中间捕获事件
+ "IntermediateThrowEvent", // 中间抛出事件
+ "ExclusiveGateway", // 排他网关
+ "ParallelGateway", // 并行网关
+ "InclusiveGateway", // 包容网关
+ "EventBasedGateway" // 事件网关
+ );
+
@Resource
@Lazy
private ApplicationEventPublisher eventPublisher;
@@ -47,7 +65,7 @@ public class GlobalNodeExecutionListener implements ExecutionListener {
LocalDateTime endTime = null;
String variablesJson = null;
String errorMessage = null;
-
+
switch (eventName) {
case ExecutionListener.EVENTNAME_START:
status = WorkflowNodeInstanceStatusEnums.RUNNING;
@@ -55,22 +73,18 @@ public class GlobalNodeExecutionListener implements ExecutionListener {
break;
case ExecutionListener.EVENTNAME_END:
// 从节点的 outputs.status 读取执行状态
- com.qqchen.deploy.backend.workflow.enums.NodeExecutionStatusEnum nodeExecutionStatus = getNodeOutputStatus(execution, nodeId);
-
- if (nodeExecutionStatus == com.qqchen.deploy.backend.workflow.enums.NodeExecutionStatusEnum.FAILURE) {
+ NodeExecutionStatusEnum nodeExecutionStatus = getNodeOutputStatus(execution, nodeId, nodeType);
+ if (nodeExecutionStatus == NodeExecutionStatusEnum.FAILURE) {
status = WorkflowNodeInstanceStatusEnums.FAILED;
+ // ✅ 失败时提取错误信息
+ errorMessage = extractErrorMessage(execution, nodeId);
} else {
status = WorkflowNodeInstanceStatusEnums.COMPLETED;
}
-
+
// ✅ 收集节点执行变量
- variablesJson = collectNodeVariables(execution, nodeId, status);
-
- // ✅ 如果失败,提取错误信息
- if (status == WorkflowNodeInstanceStatusEnums.FAILED) {
- errorMessage = extractErrorMessage(execution, nodeId);
- }
-
+ variablesJson = collectNodeVariables(execution, nodeId);
+
endTime = now;
break;
default:
@@ -93,25 +107,33 @@ public class GlobalNodeExecutionListener implements ExecutionListener {
/**
* 从节点的 outputs.status 获取执行状态
- *
+ *
* @param execution Flowable执行上下文
- * @param nodeId 节点ID
- * @return 节点执行状态枚举,如果没有则返回 null
+ * @param nodeId 节点ID
+ * @param nodeType 节点类型(ServiceTask/StartEvent/Gateway等)
+ * @return 节点执行状态枚举
*/
- private com.qqchen.deploy.backend.workflow.enums.NodeExecutionStatusEnum getNodeOutputStatus(DelegateExecution execution, String nodeId) {
+ private NodeExecutionStatusEnum getNodeOutputStatus(DelegateExecution execution, String nodeId, String nodeType) {
+ // ✅ 第一层防护:事件/网关节点不产生 outputs,直接返回成功
+ if (NODES_WITHOUT_OUTPUTS.contains(nodeType)) {
+ log.debug("节点 {} 类型为 {},不产生 outputs,直接返回 SUCCESS", nodeId, nodeType);
+ return NodeExecutionStatusEnum.SUCCESS;
+ }
+
+ // ✅ 第二层:执行型节点,查找 outputs.status
try {
- // 使用工具类从节点变量中获取 outputs.status
- Object statusObj = com.qqchen.deploy.backend.framework.utils.NestedMapUtils
- .getValueFromExecution(execution, nodeId + ".outputs.status");
-
+ // 使用 SpelExpressionResolver 从节点变量中获取 outputs.status(支持复杂表达式)
+ String expression = nodeId + ".outputs.status";
+ Object statusObj = SpelExpressionResolver.evaluateExpression(execution, expression, Object.class);
+
if (statusObj != null) {
// 如果已经是枚举类型,直接返回
- if (statusObj instanceof com.qqchen.deploy.backend.workflow.enums.NodeExecutionStatusEnum) {
- return (com.qqchen.deploy.backend.workflow.enums.NodeExecutionStatusEnum) statusObj;
+ if (statusObj instanceof NodeExecutionStatusEnum) {
+ return (NodeExecutionStatusEnum) statusObj;
}
// 如果是字符串,转换为枚举
try {
- return com.qqchen.deploy.backend.workflow.enums.NodeExecutionStatusEnum.valueOf(statusObj.toString());
+ return NodeExecutionStatusEnum.valueOf(statusObj.toString());
} catch (IllegalArgumentException e) {
log.warn("无法将 {} 转换为 NodeExecutionStatusEnum", statusObj);
}
@@ -119,29 +141,29 @@ public class GlobalNodeExecutionListener implements ExecutionListener {
} catch (Exception e) {
log.debug("无法获取节点 {} 的 outputs.status: {}", nodeId, e.getMessage());
}
- return null;
+
+ // ✅ 其他情况:默认失败(后续可优化为严格模式)
+ return NodeExecutionStatusEnum.FAILURE;
}
/**
* 收集节点执行变量
- *
+ *
* @param execution Flowable执行上下文
- * @param nodeId 节点ID
- * @param status 节点执行状态
+ * @param nodeId 节点ID
* @return JSON格式的变量字符串
*/
- private String collectNodeVariables(DelegateExecution execution, String nodeId,
- WorkflowNodeInstanceStatusEnums status) {
+ private String collectNodeVariables(DelegateExecution execution, String nodeId) {
try {
// ✅ 直接获取所有流程变量
Map allVariables = execution.getVariables();
-
+
if (allVariables == null || allVariables.isEmpty()) {
return null;
}
-
+
return objectMapper.writeValueAsString(allVariables);
-
+
} catch (Exception e) {
log.error("Failed to collect node variables for nodeId: {}", nodeId, e);
return null;
@@ -150,9 +172,9 @@ public class GlobalNodeExecutionListener implements ExecutionListener {
/**
* 提取错误信息
- *
+ *
* @param execution Flowable执行上下文
- * @param nodeId 节点ID
+ * @param nodeId 节点ID
* @return 错误信息字符串
*/
private String extractErrorMessage(DelegateExecution execution, String nodeId) {
@@ -161,9 +183,9 @@ public class GlobalNodeExecutionListener implements ExecutionListener {
if (nodeOutput instanceof Map) {
@SuppressWarnings("unchecked")
Map outputMap = (Map) nodeOutput;
- Object errorMsg = outputMap.get("errorMessage");
- if (errorMsg != null) {
- return errorMsg.toString();
+ Object message = outputMap.get("message");
+ if (message != null) {
+ return message.toString();
}
}
} catch (Exception e) {
diff --git a/backend/src/main/java/com/qqchen/deploy/backend/workflow/service/impl/ApprovalTaskServiceImpl.java b/backend/src/main/java/com/qqchen/deploy/backend/workflow/service/impl/ApprovalTaskServiceImpl.java
index fe9e9e5c..498cdb36 100644
--- a/backend/src/main/java/com/qqchen/deploy/backend/workflow/service/impl/ApprovalTaskServiceImpl.java
+++ b/backend/src/main/java/com/qqchen/deploy/backend/workflow/service/impl/ApprovalTaskServiceImpl.java
@@ -3,6 +3,7 @@ package com.qqchen.deploy.backend.workflow.service.impl;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.qqchen.deploy.backend.framework.exception.BusinessException;
import com.qqchen.deploy.backend.framework.enums.ResponseCode;
+import com.qqchen.deploy.backend.framework.utils.SpelExpressionResolver;
import com.qqchen.deploy.backend.workflow.dto.inputmapping.ApprovalInputMapping;
import com.qqchen.deploy.backend.workflow.dto.outputs.ApprovalOutputs;
import com.qqchen.deploy.backend.workflow.dto.request.ApprovalTaskRequest;
@@ -178,6 +179,11 @@ public class ApprovalTaskServiceImpl implements IApprovalTaskService {
dto.setRequireComment((Boolean) variables.get("requireComment"));
}
+ // ✅ 通用解析:自动解析 DTO 中所有包含 EL 表达式的字符串字段
+ // 无需硬编码字段名,支持未来扩展
+ // 会自动处理:taskDescription, approvalTitle, approvalContent 等所有 String 字段
+ SpelExpressionResolver.resolveObject(dto, variables);
+
return dto;
}
}
diff --git a/backend/src/main/resources/db/changelog/changes/v1.0.0-data.sql b/backend/src/main/resources/db/changelog/changes/v1.0.0-data.sql
index 6b25f25e..2a629222 100644
--- a/backend/src/main/resources/db/changelog/changes/v1.0.0-data.sql
+++ b/backend/src/main/resources/db/changelog/changes/v1.0.0-data.sql
@@ -75,6 +75,8 @@ VALUES
(203, '定时任务管理', '/deploy/schedule-jobs', 'Deploy/ScheduleJob/List', 'ClockCircleOutlined', 'deploy:schedule-job', 2, 200, 3, FALSE, TRUE, 'system', '2024-01-01 00:00:00', 0, FALSE),
-- 环境管理
(204, '环境管理', '/deploy/environments', 'Deploy/Environment/List', 'CloudOutlined', 'deploy:environment', 2, 200, 4, FALSE, TRUE, 'system', '2024-01-01 00:00:00', 0, FALSE),
+-- 消息中心
+(205, '消息中心', '/deploy/notification-channels', 'Deploy/NotificationChannel/List', 'BellOutlined', 'deploy:notification-channel', 2, 200, 5, FALSE, TRUE, 'system', '2024-01-01 00:00:00', 0, FALSE),
-- 资源管理
(300, '资源管理', '/resource', NULL, 'DatabaseOutlined', NULL, 1, NULL, 3, FALSE, TRUE, 'system', '2024-01-01 00:00:00', 0, FALSE),
@@ -140,7 +142,7 @@ SELECT 1, id FROM sys_menu; -- 管理员拥有所有菜单权限
INSERT INTO sys_role_menu (role_id, menu_id)
VALUES
-(2, 200), (2, 201), (2, 202), (2, 203), (2, 204), (2, 300), (2, 301), (2, 302), (2, 303), (2, 304), -- 运维拥有运维管理和资源管理权限
+(2, 200), (2, 201), (2, 202), (2, 203), (2, 204), (2, 205), (2, 300), (2, 301), (2, 302), (2, 303), (2, 304), -- 运维拥有运维管理和资源管理权限
(3, 100), (3, 101), (3, 102), (3, 104), (3, 200), (3, 202); -- 开发拥有工作流和应用管理权限
-- ==================== 初始化权限数据 ====================
@@ -222,6 +224,17 @@ DELETE FROM sys_permission WHERE id < 10000;
-- (144, NOW(), 204, 'deploy:environment:update', '环境修改', 'FUNCTION', 4),
-- (145, NOW(), 204, 'deploy:environment:delete', '环境删除', 'FUNCTION', 5),
--
+-- -- 消息中心 (menu_id=205)
+-- (146, NOW(), 205, 'deploy:notification-channel:list', '通知渠道查询', 'FUNCTION', 1),
+-- (147, NOW(), 205, 'deploy:notification-channel:view', '通知渠道详情', 'FUNCTION', 2),
+-- (148, NOW(), 205, 'deploy:notification-channel:create', '通知渠道创建', 'FUNCTION', 3),
+-- (149, NOW(), 205, 'deploy:notification-channel:update', '通知渠道修改', 'FUNCTION', 4),
+-- (150, NOW(), 205, 'deploy:notification-channel:delete', '通知渠道删除', 'FUNCTION', 5),
+-- (151, NOW(), 205, 'deploy:notification-channel:test', '测试连接', 'FUNCTION', 6),
+-- (152, NOW(), 205, 'deploy:notification-channel:enable', '启用渠道', 'FUNCTION', 7),
+-- (153, NOW(), 205, 'deploy:notification-channel:disable', '禁用渠道', 'FUNCTION', 8),
+-- (154, NOW(), 205, 'deploy:notification-channel:send', '发送通知', 'FUNCTION', 9),
+--
-- -- 团队配置管理 (无对应菜单,menu_id=NULL)
-- (151, NOW(), NULL, 'deploy:team-config:list', '团队配置查询', 'FUNCTION', 11),
-- (152, NOW(), NULL, 'deploy:team-config:view', '团队配置详情', 'FUNCTION', 12),
diff --git a/backend/src/test/java/com/qqchen/deploy/backend/framework/utils/SpelExpressionResolverTest.java b/backend/src/test/java/com/qqchen/deploy/backend/framework/utils/SpelExpressionResolverTest.java
new file mode 100644
index 00000000..fadb9007
--- /dev/null
+++ b/backend/src/test/java/com/qqchen/deploy/backend/framework/utils/SpelExpressionResolverTest.java
@@ -0,0 +1,374 @@
+package com.qqchen.deploy.backend.framework.utils;
+
+import lombok.Data;
+import org.junit.jupiter.api.Test;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+/**
+ * Spring EL 表达式解析工具测试
+ *
+ * @author qqchen
+ * @date 2025-11-04
+ */
+class SpelExpressionResolverTest {
+
+ /**
+ * 测试用的 DTO
+ */
+ @Data
+ static class TestDTO {
+ private String taskDescription;
+ private String approvalTitle;
+ private String approvalContent;
+ private String statusMessage;
+ private Integer version; // 非字符串字段,不应被解析
+ }
+
+ /**
+ * 测试:简单变量引用
+ */
+ @Test
+ void testSimpleVariableReference() {
+ Map variables = new HashMap<>();
+ Map deploy = new HashMap<>();
+ deploy.put("applicationName", "SSO管理后台");
+ variables.put("deploy", deploy);
+
+ String template = "${deploy.applicationName}需要部署";
+ String result = SpelExpressionResolver.resolve(template, variables);
+
+ assertEquals("SSO管理后台需要部署", result);
+ }
+
+ /**
+ * 测试:条件判断(三元运算符)
+ */
+ @Test
+ void testConditionalExpression() {
+ Map variables = new HashMap<>();
+ Map deploy = new HashMap<>();
+ deploy.put("status", "SUCCESS");
+ variables.put("deploy", deploy);
+
+ String template = "部署${deploy.status == 'SUCCESS' ? '成功' : '失败'}";
+ String result = SpelExpressionResolver.resolve(template, variables);
+
+ assertEquals("部署成功", result);
+ }
+
+ /**
+ * 测试:复杂条件判断
+ */
+ @Test
+ void testComplexConditionalExpression() {
+ Map variables = new HashMap<>();
+ Map deploy = new HashMap<>();
+ deploy.put("duration", 120);
+ variables.put("deploy", deploy);
+
+ String template = "部署耗时${deploy.duration > 60 ? '较长(' + deploy.duration + '秒)' : '正常'}";
+ String result = SpelExpressionResolver.resolve(template, variables);
+
+ assertEquals("部署耗时较长(120秒)", result);
+ }
+
+ /**
+ * 测试:多表达式混合
+ */
+ @Test
+ void testMultipleExpressions() {
+ Map variables = new HashMap<>();
+ Map deploy = new HashMap<>();
+ deploy.put("applicationName", "SSO管理后台");
+ deploy.put("environment", "生产环境");
+ deploy.put("status", "SUCCESS");
+ variables.put("deploy", deploy);
+
+ String template = "【部署通知】应用 ${deploy.applicationName} 在 ${deploy.environment} 部署${deploy.status == 'SUCCESS' ? '成功' : '失败'}";
+ String result = SpelExpressionResolver.resolve(template, variables);
+
+ assertEquals("【部署通知】应用 SSO管理后台 在 生产环境 部署成功", result);
+ }
+
+ /**
+ * 测试:嵌套对象访问
+ */
+ @Test
+ void testNestedObjectAccess() {
+ Map variables = new HashMap<>();
+ Map user = new HashMap<>();
+ Map department = new HashMap<>();
+ department.put("name", "研发部");
+ user.put("name", "张三");
+ user.put("department", department);
+ variables.put("user", user);
+
+ String template = "用户 ${user.name} 来自 ${user.department.name}";
+ String result = SpelExpressionResolver.resolve(template, variables);
+
+ assertEquals("用户 张三 来自 研发部", result);
+ }
+
+ /**
+ * 测试:数学运算
+ */
+ @Test
+ void testMathematicalExpression() {
+ Map variables = new HashMap<>();
+ variables.put("count", 5);
+ variables.put("price", 10.5);
+
+ String template = "总价:${count * price} 元";
+ String result = SpelExpressionResolver.resolve(template, variables);
+
+ assertEquals("总价:52.5 元", result);
+ }
+
+ /**
+ * 测试:布尔逻辑运算
+ */
+ @Test
+ void testBooleanLogic() {
+ Map variables = new HashMap<>();
+ Map deploy = new HashMap<>();
+ deploy.put("status", "SUCCESS");
+ deploy.put("requireApproval", true);
+ variables.put("deploy", deploy);
+
+ String template = "${deploy.status == 'SUCCESS' && deploy.requireApproval ? '审批通过并部署成功' : '等待审批或部署失败'}";
+ String result = SpelExpressionResolver.resolve(template, variables);
+
+ assertEquals("审批通过并部署成功", result);
+ }
+
+ /**
+ * 测试:evaluateExpression - 返回原始类型
+ */
+ @Test
+ void testEvaluateExpression() {
+ Map variables = new HashMap<>();
+ Map deploy = new HashMap<>();
+ deploy.put("status", "SUCCESS");
+ deploy.put("duration", 120);
+ variables.put("deploy", deploy);
+
+ // 返回布尔值
+ Boolean isSuccess = SpelExpressionResolver.evaluateExpression(
+ "deploy.status == 'SUCCESS'", variables, Boolean.class);
+ assertEquals(true, isSuccess);
+
+ // 返回整数
+ Integer duration = SpelExpressionResolver.evaluateExpression(
+ "deploy.duration", variables, Integer.class);
+ assertEquals(120, duration);
+
+ // 返回字符串
+ String message = SpelExpressionResolver.evaluateExpression(
+ "deploy.status == 'SUCCESS' ? '成功' : '失败'", variables, String.class);
+ assertEquals("成功", message);
+ }
+
+ /**
+ * 测试:resolveObject - 自动解析对象中的所有字段(核心功能)
+ */
+ @Test
+ void testResolveObject() {
+ // 准备测试数据
+ Map variables = new HashMap<>();
+ Map deploy = new HashMap<>();
+ deploy.put("applicationName", "SSO管理后台");
+ deploy.put("environment", "生产环境");
+ deploy.put("status", "SUCCESS");
+ deploy.put("duration", 150);
+ variables.put("deploy", deploy);
+
+ // 创建包含表达式的 DTO
+ TestDTO dto = new TestDTO();
+ dto.setTaskDescription("${deploy.applicationName}需要部署到${deploy.environment}");
+ dto.setApprovalTitle("部署审批");
+ dto.setApprovalContent("应用 ${deploy.applicationName} 部署${deploy.status == 'SUCCESS' ? '成功' : '失败'}");
+ dto.setStatusMessage("耗时${deploy.duration > 60 ? '较长' : '正常'}");
+ dto.setVersion(1); // 非字符串字段
+
+ // ✅ 自动解析所有包含表达式的字符串字段
+ SpelExpressionResolver.resolveObject(dto, variables);
+
+ // 验证结果
+ assertEquals("SSO管理后台需要部署到生产环境", dto.getTaskDescription());
+ assertEquals("部署审批", dto.getApprovalTitle()); // 无表达式,保持原样
+ assertEquals("应用 SSO管理后台 部署成功", dto.getApprovalContent());
+ assertEquals("耗时较长", dto.getStatusMessage());
+ assertEquals(1, dto.getVersion()); // 非字符串字段不受影响
+ }
+
+ /**
+ * 测试:表达式解析失败时的处理
+ */
+ @Test
+ void testExpressionResolutionFailure() {
+ Map variables = new HashMap<>();
+ variables.put("deploy", new HashMap<>());
+
+ // 访问不存在的字段
+ String template = "应用 ${deploy.nonExistentField} 部署完成";
+ String result = SpelExpressionResolver.resolve(template, variables);
+
+ // 应该保留原样或返回空值
+ assertNotNull(result);
+ System.out.println("解析失败时的结果: " + result);
+ }
+
+ /**
+ * 测试:无表达式的字符串
+ */
+ @Test
+ void testNoExpression() {
+ Map variables = new HashMap<>();
+
+ String template = "这是一个普通字符串,没有表达式";
+ String result = SpelExpressionResolver.resolve(template, variables);
+
+ assertEquals("这是一个普通字符串,没有表达式", result);
+ }
+
+ /**
+ * 测试:null 值处理
+ */
+ @Test
+ void testNullHandling() {
+ String result = SpelExpressionResolver.resolve(null, new HashMap<>());
+ assertEquals(null, result);
+ }
+
+ /**
+ * 测试:批量解析
+ */
+ @Test
+ void testResolveAll() {
+ Map variables = new HashMap<>();
+ Map deploy = new HashMap<>();
+ deploy.put("applicationName", "SSO管理后台");
+ deploy.put("status", "SUCCESS");
+ variables.put("deploy", deploy);
+
+ String[] templates = {
+ "${deploy.applicationName}需要部署",
+ "部署${deploy.status == 'SUCCESS' ? '成功' : '失败'}",
+ "普通文本"
+ };
+
+ String[] results = SpelExpressionResolver.resolveAll(templates, variables);
+
+ assertEquals(3, results.length);
+ assertEquals("SSO管理后台需要部署", results[0]);
+ assertEquals("部署成功", results[1]);
+ assertEquals("普通文本", results[2]);
+ }
+
+ /**
+ * 测试:集合访问(数组/列表索引)
+ */
+ @Test
+ void testCollectionAccess() {
+ Map variables = new HashMap<>();
+
+ // 购物车示例
+ Map shoppingCart = new HashMap<>();
+ java.util.List