From 2f2637763cdcb132f05c6d73cab978004b62202c Mon Sep 17 00:00:00 2001 From: dengqichen Date: Tue, 4 Nov 2025 18:17:36 +0800 Subject: [PATCH] =?UTF-8?q?=E6=89=93=E5=8D=B0=E4=BA=86JENKINS=E8=8A=82?= =?UTF-8?q?=E7=82=B9=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../framework/utils/NestedMapUtils.java | 541 ------------------ .../utils/SpelExpressionResolver.java | 484 ++++++++++++++++ .../adapter/INotificationChannelAdapter.java | 9 + .../adapter/impl/EmailChannelAdapter.java | 59 ++ .../adapter/impl/WeworkChannelAdapter.java | 43 ++ .../impl/NotificationChannelServiceImpl.java | 25 +- .../api/WorkflowCategoryApiController.java | 17 +- .../delegate/ApprovalTaskListener.java | 39 +- .../workflow/delegate/BaseNodeDelegate.java | 6 +- .../GlobalNodeExecutionListener.java | 96 ++-- .../service/impl/ApprovalTaskServiceImpl.java | 6 + .../db/changelog/changes/v1.0.0-data.sql | 15 +- .../utils/SpelExpressionResolverTest.java | 374 ++++++++++++ 13 files changed, 1102 insertions(+), 612 deletions(-) delete mode 100644 backend/src/main/java/com/qqchen/deploy/backend/framework/utils/NestedMapUtils.java create mode 100644 backend/src/main/java/com/qqchen/deploy/backend/framework/utils/SpelExpressionResolver.java create mode 100644 backend/src/test/java/com/qqchen/deploy/backend/framework/utils/SpelExpressionResolverTest.java diff --git a/backend/src/main/java/com/qqchen/deploy/backend/framework/utils/NestedMapUtils.java b/backend/src/main/java/com/qqchen/deploy/backend/framework/utils/NestedMapUtils.java deleted file mode 100644 index 7011b843..00000000 --- a/backend/src/main/java/com/qqchen/deploy/backend/framework/utils/NestedMapUtils.java +++ /dev/null @@ -1,541 +0,0 @@ -package com.qqchen.deploy.backend.framework.utils; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import lombok.extern.slf4j.Slf4j; -import org.flowable.engine.delegate.DelegateExecution; -import org.flowable.task.service.delegate.DelegateTask; - -import java.util.Map; - -/** - * 嵌套 Map 工具类 - * 使用 Jackson JsonPointer (RFC 6901) 标准解析嵌套路径 - * - * @author qqchen - * @since 2025-11-04 - */ -@Slf4j -public class NestedMapUtils { - - private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - - /** - * 从嵌套 Map 中获取值 - * 支持多层嵌套路径访问,例如:outputs.status、a.b.c.d - * - *

示例: - *

-     * Map map = Map.of("outputs", Map.of("status", "SUCCESS"));
-     * Object value = NestedMapUtils.getValue(map, "outputs.status");
-     * // 返回: "SUCCESS"
-     * 
- * - * @param map 源 Map - * @param fieldPath 字段路径(点号分隔,如:outputs.status) - * @return 字段值,如果路径不存在则返回 null - */ - public static Object getValue(Map map, String fieldPath) { - if (fieldPath == null || fieldPath.isEmpty() || map == null) { - return null; - } - - try { - // 1. 将 Map 转换为 JsonNode - JsonNode rootNode = OBJECT_MAPPER.valueToTree(map); - - // 2. 将点号路径转换为 JsonPointer 路径格式 - // 例如:outputs.status -> /outputs/status - String jsonPointerPath = "/" + fieldPath.replace(".", "/"); - - // 3. 使用 JsonPointer 访问嵌套路径 - JsonNode resultNode = rootNode.at(jsonPointerPath); - - // 4. 检查节点是否存在 - if (resultNode.isMissingNode()) { - log.debug("嵌套路径不存在: {}", fieldPath); - return null; - } - - // 5. 将 JsonNode 转换回 Java 对象 - return OBJECT_MAPPER.convertValue(resultNode, Object.class); - - } catch (Exception e) { - log.warn("嵌套路径解析异常: {} - {}", fieldPath, e.getMessage()); - return null; - } - } - - /** - * 从嵌套 Map 中获取字符串值 - * - * @param map 源 Map - * @param fieldPath 字段路径(点号分隔) - * @return 字符串值,如果路径不存在或值为 null 则返回 null - */ - public static String getString(Map map, String fieldPath) { - Object value = getValue(map, fieldPath); - return value != null ? value.toString() : null; - } - - /** - * 从嵌套 Map 中获取字符串值(带默认值) - * - * @param map 源 Map - * @param fieldPath 字段路径(点号分隔) - * @param defaultValue 默认值 - * @return 字符串值,如果路径不存在则返回默认值 - */ - public static String getString(Map map, String fieldPath, String defaultValue) { - String value = getString(map, fieldPath); - return value != null ? value : defaultValue; - } - - /** - * 从嵌套 Map 中获取 Integer 值 - * - * @param map 源 Map - * @param fieldPath 字段路径(点号分隔) - * @return Integer 值,如果路径不存在或转换失败则返回 null - */ - public static Integer getInteger(Map map, String fieldPath) { - Object value = getValue(map, fieldPath); - if (value == null) { - return null; - } - if (value instanceof Integer) { - return (Integer) value; - } - try { - return Integer.valueOf(value.toString()); - } catch (NumberFormatException e) { - log.warn("无法将值转换为 Integer: {} = {}", fieldPath, value); - return null; - } - } - - /** - * 从嵌套 Map 中获取 Long 值 - * - * @param map 源 Map - * @param fieldPath 字段路径(点号分隔) - * @return Long 值,如果路径不存在或转换失败则返回 null - */ - public static Long getLong(Map map, String fieldPath) { - Object value = getValue(map, fieldPath); - if (value == null) { - return null; - } - if (value instanceof Long) { - return (Long) value; - } - if (value instanceof Integer) { - return ((Integer) value).longValue(); - } - try { - return Long.valueOf(value.toString()); - } catch (NumberFormatException e) { - log.warn("无法将值转换为 Long: {} = {}", fieldPath, value); - return null; - } - } - - /** - * 从嵌套 Map 中获取 Boolean 值 - * - * @param map 源 Map - * @param fieldPath 字段路径(点号分隔) - * @return Boolean 值,如果路径不存在则返回 null - */ - public static Boolean getBoolean(Map map, String fieldPath) { - Object value = getValue(map, fieldPath); - if (value == null) { - return null; - } - if (value instanceof Boolean) { - return (Boolean) value; - } - return Boolean.valueOf(value.toString()); - } - - /** - * 从嵌套 Map 中获取指定类型的值 - * - * @param map 源 Map - * @param fieldPath 字段路径(点号分隔) - * @param targetClass 目标类型 - * @param 返回值类型 - * @return 指定类型的值,如果路径不存在或转换失败则返回 null - */ - @SuppressWarnings("unchecked") - public static T getValue(Map map, String fieldPath, Class targetClass) { - Object value = getValue(map, fieldPath); - if (value == null) { - return null; - } - - try { - if (targetClass.isInstance(value)) { - return (T) value; - } - return OBJECT_MAPPER.convertValue(value, targetClass); - } catch (Exception e) { - log.warn("无法将值转换为 {}: {} = {}", targetClass.getSimpleName(), fieldPath, value); - return null; - } - } - - /** - * 检查路径是否存在 - * - * @param map 源 Map - * @param fieldPath 字段路径(点号分隔) - * @return true 如果路径存在,否则返回 false - */ - public static boolean hasPath(Map map, String fieldPath) { - if (fieldPath == null || fieldPath.isEmpty() || map == null) { - return false; - } - - try { - JsonNode rootNode = OBJECT_MAPPER.valueToTree(map); - String jsonPointerPath = "/" + fieldPath.replace(".", "/"); - JsonNode resultNode = rootNode.at(jsonPointerPath); - return !resultNode.isMissingNode(); - } catch (Exception e) { - return false; - } - } - - // ==================== Flowable 集成方法 ==================== - - /** - * 从 Flowable 执行上下文中解析变量表达式 - * 支持嵌套路径,例如:approval.userIds、sid_xxx.outputs.status - * - *

示例: - *

-     * // 流程变量中有:approval = {required: true, userIds: "admin"}
-     * Object value = NestedMapUtils.getValueFromExecution(execution, "approval.userIds");
-     * // 返回: "admin"
-     * 
- * - * @param execution Flowable 执行上下文 - * @param variablePath 变量路径(点号分隔,如:approval.userIds) - * @return 变量值,如果路径不存在则返回 null - */ - public static Object getValueFromExecution(DelegateExecution execution, String variablePath) { - if (execution == null || variablePath == null || variablePath.isEmpty()) { - return null; - } - - // 1. 切分路径,获取顶层变量名 - String[] parts = variablePath.split("\\.", 2); - String topLevelVar = parts[0]; - - // 2. 从 execution 中获取顶层变量 - Object value = execution.getVariable(topLevelVar); - - // 3. 如果没有嵌套路径,直接返回 - if (parts.length == 1) { - return value; - } - - // 4. 如果有嵌套路径,继续解析 - if (value instanceof Map) { - @SuppressWarnings("unchecked") - Map map = (Map) value; - return getValue(map, parts[1]); - } else { - log.debug("变量 {} 不是 Map 类型,无法解析嵌套路径: {}", topLevelVar, variablePath); - return null; - } - } - - /** - * 从 Flowable 任务上下文中解析变量表达式 - * 支持嵌套路径,例如:approval.userIds - * - *

示例: - *

-     * // 流程变量中有:approval = {required: true, userIds: "admin"}
-     * Object value = NestedMapUtils.getValueFromTask(task, "approval.userIds");
-     * // 返回: "admin"
-     * 
- * - * @param task Flowable 任务上下文 - * @param variablePath 变量路径(点号分隔,如:approval.userIds) - * @return 变量值,如果路径不存在则返回 null - */ - public static Object getValueFromTask(DelegateTask task, String variablePath) { - if (task == null || variablePath == null || variablePath.isEmpty()) { - return null; - } - - // 1. 切分路径,获取顶层变量名 - String[] parts = variablePath.split("\\.", 2); - String topLevelVar = parts[0]; - - // 2. 从 task 中获取顶层变量 - Object value = task.getVariable(topLevelVar); - - // 3. 如果没有嵌套路径,直接返回 - if (parts.length == 1) { - return value; - } - - // 4. 如果有嵌套路径,继续解析 - if (value instanceof Map) { - @SuppressWarnings("unchecked") - Map map = (Map) value; - return getValue(map, parts[1]); - } else { - log.debug("变量 {} 不是 Map 类型,无法解析嵌套路径: {}", topLevelVar, variablePath); - return null; - } - } - - /** - * 从 Flowable 上下文中解析变量表达式(支持 ${...} 格式) - * 自动去除表达式包装,支持嵌套路径 - * - *

示例: - *

-     * Object value1 = NestedMapUtils.resolveExpression(task, "${approval.userIds}");
-     * Object value2 = NestedMapUtils.resolveExpression(task, "approval.userIds");
-     * // 两种格式都能正确解析
-     * 
- * - * @param task Flowable 任务上下文 - * @param expression 表达式(可以带 ${} 也可以不带) - * @return 变量值,如果路径不存在则返回 null - */ - public static Object resolveExpression(DelegateTask task, String expression) { - if (expression == null || expression.isEmpty()) { - return null; - } - - // 去除 ${} 包装 - String variablePath = expression; - if (expression.startsWith("${") && expression.endsWith("}")) { - variablePath = expression.substring(2, expression.length() - 1); - } - - return getValueFromTask(task, variablePath); - } - - /** - * 从 Flowable 执行上下文中解析变量表达式(支持 ${...} 格式) - * - * @param execution Flowable 执行上下文 - * @param expression 表达式(可以带 ${} 也可以不带) - * @return 变量值,如果路径不存在则返回 null - */ - public static Object resolveExpression(DelegateExecution execution, String expression) { - if (expression == null || expression.isEmpty()) { - return null; - } - - // 去除 ${} 包装 - String variablePath = expression; - if (expression.startsWith("${") && expression.endsWith("}")) { - variablePath = expression.substring(2, expression.length() - 1); - } - - return getValueFromExecution(execution, variablePath); - } - - // ==================== 统一解析入口(推荐使用) ==================== - - /** - * 统一解析方法 - 智能处理所有类型的输入 - * - *

自动识别并处理三种情况: - *

    - *
  • 纯文本: "hello" → "hello"
  • - *
  • 纯表达式: "${user.name}" → Object (原始类型,可能是 String/Integer/Map 等)
  • - *
  • 模板字符串: "Hello ${user.name}, age: ${user.age}" → "Hello John, age: 30"
  • - *
- * - *

使用示例: - *

-     * // 纯表达式 - 返回原始类型
-     * Object status = NestedMapUtils.resolve(execution, "${jenkins.status}");
-     * // 返回: NodeExecutionStatusEnum.SUCCESS (枚举类型)
-     * 
-     * // 模板字符串 - 返回替换后的字符串
-     * String message = (String) NestedMapUtils.resolve(execution, "构建${jenkins.status}, 编号: ${jenkins.buildNumber}");
-     * // 返回: "构建SUCCESS, 编号: 123"
-     * 
-     * // 纯文本 - 直接返回
-     * String text = (String) NestedMapUtils.resolve(execution, "纯文本");
-     * // 返回: "纯文本"
-     * 
- * - * @param execution Flowable 执行上下文 - * @param input 输入字符串(可能包含表达式) - * @return 解析后的值(可能是任意类型) - */ - public static Object resolve(DelegateExecution execution, String input) { - if (input == null || !input.contains("${")) { - return input; - } - - // 判断是否是纯表达式(整个字符串就是一个 ${xxx}) - if (isPureExpression(input)) { - // 纯表达式:返回原始类型(可能是 String/Integer/Enum/Map 等) - return resolveExpression(execution, input); - } else { - // 模板字符串:返回替换后的字符串 - return resolveTemplateString(execution, input); - } - } - - /** - * 统一解析方法 - DelegateTask 版本 - * - * @param task Flowable 任务上下文 - * @param input 输入字符串 - * @return 解析后的值 - */ - public static Object resolve(DelegateTask task, String input) { - if (input == null || !input.contains("${")) { - return input; - } - - if (isPureExpression(input)) { - return resolveExpression(task, input); - } else { - return resolveTemplateString(task, input); - } - } - - /** - * 判断是否是纯表达式 - * 纯表达式定义:整个字符串就是一个 ${xxx},前后没有其他内容 - * - * @param input 输入字符串 - * @return true 如果是纯表达式 - */ - private static boolean isPureExpression(String input) { - if (!input.startsWith("${") || !input.endsWith("}")) { - return false; - } - - // 检查是否只有一个表达式(没有第二个 ${) - int secondExprStart = input.indexOf("${", 2); - return secondExprStart == -1; - } - - // ==================== 内部实现方法 ==================== - - /** - * 解析模板字符串(支持多个嵌入式表达式) - * - *

内部方法,推荐使用 {@link #resolve(DelegateExecution, String)} 代替 - * - * @param execution Flowable 执行上下文 - * @param template 模板字符串 - * @return 替换后的字符串 - */ - private static String resolveTemplateString(DelegateExecution execution, String template) { - if (template == null || !template.contains("${")) { - return template; - } - - StringBuilder result = new StringBuilder(); - int currentIndex = 0; - - while (currentIndex < template.length()) { - int startIndex = template.indexOf("${", currentIndex); - - // 没有更多的表达式了 - if (startIndex == -1) { - result.append(template.substring(currentIndex)); - break; - } - - // 添加表达式前的普通文本 - if (startIndex > currentIndex) { - result.append(template.substring(currentIndex, startIndex)); - } - - // 查找表达式的结束位置 - int endIndex = template.indexOf("}", startIndex); - if (endIndex == -1) { - // 没有找到闭合的 },说明格式错误,保留原样 - log.warn("未找到闭合的 }} 符号,保留原样: {}", template.substring(startIndex)); - result.append(template.substring(startIndex)); - break; - } - - // 提取并解析表达式 - String expression = template.substring(startIndex, endIndex + 1); - try { - Object value = resolveExpression(execution, expression); - result.append(value != null ? value.toString() : ""); - log.debug(" 替换表达式: {} -> {}", expression, value); - } catch (Exception e) { - log.warn(" 表达式解析失败: {},保留原样", expression, e); - result.append(expression); - } - - currentIndex = endIndex + 1; - } - - return result.toString(); - } - - /** - * 解析模板字符串(支持多个嵌入式表达式)- DelegateTask 版本 - * - *

内部方法,推荐使用 {@link #resolve(DelegateTask, String)} 代替 - * - * @param task Flowable 任务上下文 - * @param template 模板字符串 - * @return 替换后的字符串 - */ - private static String resolveTemplateString(DelegateTask task, String template) { - if (template == null || !template.contains("${")) { - return template; - } - - StringBuilder result = new StringBuilder(); - int currentIndex = 0; - - while (currentIndex < template.length()) { - int startIndex = template.indexOf("${", currentIndex); - - if (startIndex == -1) { - result.append(template.substring(currentIndex)); - break; - } - - if (startIndex > currentIndex) { - result.append(template.substring(currentIndex, startIndex)); - } - - int endIndex = template.indexOf("}", startIndex); - if (endIndex == -1) { - log.warn("未找到闭合的 }} 符号,保留原样: {}", template.substring(startIndex)); - result.append(template.substring(startIndex)); - break; - } - - String expression = template.substring(startIndex, endIndex + 1); - try { - Object value = resolveExpression(task, expression); - result.append(value != null ? value.toString() : ""); - log.debug(" 替换表达式: {} -> {}", expression, value); - } catch (Exception e) { - log.warn(" 表达式解析失败: {},保留原样", expression, e); - result.append(expression); - } - - currentIndex = endIndex + 1; - } - - return result.toString(); - } -} - diff --git a/backend/src/main/java/com/qqchen/deploy/backend/framework/utils/SpelExpressionResolver.java b/backend/src/main/java/com/qqchen/deploy/backend/framework/utils/SpelExpressionResolver.java new file mode 100644 index 00000000..305719d0 --- /dev/null +++ b/backend/src/main/java/com/qqchen/deploy/backend/framework/utils/SpelExpressionResolver.java @@ -0,0 +1,484 @@ +package com.qqchen.deploy.backend.framework.utils; + +import lombok.extern.slf4j.Slf4j; +import org.flowable.engine.delegate.DelegateExecution; +import org.flowable.task.service.delegate.DelegateTask; +import org.springframework.expression.Expression; +import org.springframework.expression.ExpressionParser; +import org.springframework.expression.PropertyAccessor; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.SimpleEvaluationContext; +import org.springframework.expression.spel.support.StandardEvaluationContext; +import org.springframework.expression.EvaluationContext; +import org.springframework.expression.AccessException; +import org.springframework.expression.TypedValue; + +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Spring EL 表达式解析工具 + * + *

支持三种表达式格式: + *

    + *
  • 变量引用: ${deploy.applicationName} → "SSO管理后台"
  • + *
  • 条件判断: ${deploy.status == 'SUCCESS' ? '成功' : '失败'} → "成功"
  • + *
  • 复杂运算: ${deploy.duration > 60 ? '耗时较长' : '正常'} → "耗时较长"
  • + *
  • 模板字符串: "应用 ${deploy.applicationName} 部署${deploy.status == 'SUCCESS' ? '成功' : '失败'}"
  • + *
+ * + *

使用示例: + *

{@code
+ * Map variables = new HashMap<>();
+ * Map deploy = new HashMap<>();
+ * deploy.put("applicationName", "SSO管理后台");
+ * deploy.put("status", "SUCCESS");
+ * deploy.put("duration", 120);
+ * variables.put("deploy", deploy);
+ * 
+ * // 简单变量
+ * String result1 = SpelExpressionResolver.resolve("${deploy.applicationName}需要部署", variables);
+ * // 结果: "SSO管理后台需要部署"
+ * 
+ * // 条件判断
+ * String result2 = SpelExpressionResolver.resolve(
+ *     "部署${deploy.status == 'SUCCESS' ? '成功' : '失败'}", variables);
+ * // 结果: "部署成功"
+ * 
+ * // 复杂表达式
+ * String result3 = SpelExpressionResolver.resolve(
+ *     "应用 ${deploy.applicationName} 部署耗时 ${deploy.duration > 60 ? '较长' : '正常'}", variables);
+ * // 结果: "应用 SSO管理后台 部署耗时 较长"
+ * }
+ * + * @author qqchen + * @date 2025-11-04 + */ +@Slf4j +public class SpelExpressionResolver { + + /** + * 表达式解析器(线程安全,可复用) + */ + private static final ExpressionParser PARSER = new SpelExpressionParser(); + + /** + * 表达式匹配正则:${...} + * 使用非贪婪匹配,支持嵌套表达式 + */ + private static final Pattern EXPRESSION_PATTERN = Pattern.compile("\\$\\{([^}]+)\\}"); + + /** + * 解析包含 EL 表达式的字符串模板 + * + * @param template 模板字符串,可包含多个 ${...} 表达式 + * @param variables 上下文变量(支持嵌套 Map) + * @return 解析后的字符串 + */ + public static String resolve(String template, Map variables) { + if (template == null || template.isEmpty()) { + return template; + } + + // 如果不包含表达式,直接返回 + if (!template.contains("${")) { + return template; + } + + try { + // 创建表达式上下文,支持 Map 的属性式访问 + StandardEvaluationContext context = createContext(variables); + + // 解析模板中的所有表达式 + Matcher matcher = EXPRESSION_PATTERN.matcher(template); + StringBuffer result = new StringBuffer(); + + while (matcher.find()) { + String expression = matcher.group(1); // 提取 ${} 中的表达式 + + try { + // 解析表达式 + Expression exp = PARSER.parseExpression(expression); + Object value = exp.getValue(context); + + // 替换表达式为值 + String replacement = value != null ? value.toString() : ""; + matcher.appendReplacement(result, Matcher.quoteReplacement(replacement)); + + log.debug("解析表达式: ${{{}}}} -> {}", expression, value); + } catch (Exception e) { + log.warn("表达式解析失败: ${{{}}}, 保留原样, 错误: {}", expression, e.getMessage()); + // 解析失败时保留原始表达式 + matcher.appendReplacement(result, Matcher.quoteReplacement("${" + expression + "}")); + } + } + matcher.appendTail(result); + + return result.toString(); + } catch (Exception e) { + log.error("模板解析失败: {}", template, e); + return template; // 失败时返回原始模板 + } + } + + /** + * 解析单个纯表达式(返回原始类型) + * + *

示例: + *

{@code
+     * // 返回 Boolean
+     * Boolean result = SpelExpressionResolver.evaluateExpression(
+     *     "deploy.status == 'SUCCESS'", variables, Boolean.class);
+     * 
+     * // 返回 Integer
+     * Integer duration = SpelExpressionResolver.evaluateExpression(
+     *     "deploy.duration", variables, Integer.class);
+     * }
+ * + * @param expression 纯表达式(不需要 ${} 包裹) + * @param variables 上下文变量 + * @param resultType 期望的返回类型 + * @param 返回类型 + * @return 表达式计算结果 + */ + public static T evaluateExpression(String expression, Map variables, Class 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> items = new java.util.ArrayList<>(); + + Map item1 = new HashMap<>(); + item1.put("name", "iPhone 15 Pro"); + item1.put("price", 7999); + item1.put("quantity", 1); + + Map item2 = new HashMap<>(); + item2.put("name", "MacBook Pro"); + item2.put("price", 14999); + item2.put("quantity", 2); + + items.add(item1); + items.add(item2); + shoppingCart.put("items", items); + shoppingCart.put("total", 3); + variables.put("shoppingCart", shoppingCart); + + // 测试列表索引访问 + String template1 = "第一个商品: ${shoppingCart.items[0].name}"; + String result1 = SpelExpressionResolver.resolve(template1, variables); + assertEquals("第一个商品: iPhone 15 Pro", result1); + + // 测试嵌套访问和运算 + String template2 = "第二个商品 ${shoppingCart.items[1].name} 单价 ${shoppingCart.items[1].price} 元,数量 ${shoppingCart.items[1].quantity} 件"; + String result2 = SpelExpressionResolver.resolve(template2, variables); + assertEquals("第二个商品 MacBook Pro 单价 14999 元,数量 2 件", result2); + + // 测试列表大小访问 + String template3 = "购物车共有 ${shoppingCart.items.size()} 件商品"; + String result3 = SpelExpressionResolver.resolve(template3, variables); + assertEquals("购物车共有 2 件商品", result3); + + // 测试复杂表达式:索引 + 条件判断 + String template4 = "第一个商品价格${shoppingCart.items[0].price > 5000 ? '较高' : '合理'}"; + String result4 = SpelExpressionResolver.resolve(template4, variables); + assertEquals("第一个商品价格较高", result4); + } + + /** + * 测试:数组访问 + */ + @Test + void testArrayAccess() { + Map variables = new HashMap<>(); + + String[] fruits = {"苹果", "香蕉", "橙子"}; + variables.put("fruits", fruits); + + int[] numbers = {10, 20, 30, 40, 50}; + variables.put("numbers", numbers); + + // 字符串数组访问 + String template1 = "第一个水果: ${fruits[0]},第三个水果: ${fruits[2]}"; + String result1 = SpelExpressionResolver.resolve(template1, variables); + assertEquals("第一个水果: 苹果,第三个水果: 橙子", result1); + + // 数字数组访问和运算 + String template2 = "第二个数字是 ${numbers[1]},总和是 ${numbers[0] + numbers[1] + numbers[2]}"; + String result2 = SpelExpressionResolver.resolve(template2, variables); + assertEquals("第二个数字是 20,总和是 60", result2); + + // 数组长度访问 + String template3 = "水果数组长度: ${fruits.length},数字数组长度: ${numbers.length}"; + String result3 = SpelExpressionResolver.resolve(template3, variables); + assertEquals("水果数组长度: 3,数字数组长度: 5", result3); + } + + /** + * 测试:Map 键访问(使用方括号语法) + */ + @Test + void testMapKeyAccess() { + Map variables = new HashMap<>(); + + Map config = new HashMap<>(); + config.put("max-connections", 100); + config.put("timeout-seconds", 30); + config.put("enable-ssl", true); + variables.put("config", config); + + // Map 键访问(带特殊字符的键需要使用方括号) + String template1 = "最大连接数: ${config['max-connections']}"; + String result1 = SpelExpressionResolver.resolve(template1, variables); + assertEquals("最大连接数: 100", result1); + + // 多个 Map 键访问 + String template2 = "超时时间 ${config['timeout-seconds']} 秒,SSL${config['enable-ssl'] ? '已启用' : '未启用'}"; + String result2 = SpelExpressionResolver.resolve(template2, variables); + assertEquals("超时时间 30 秒,SSL已启用", result2); + } +} +