diff --git a/ruoyi-modules-api/ruoyi-workflow-api/src/main/java/org/ruoyi/workflow/workflow/NodeProcessResult.java b/ruoyi-modules-api/ruoyi-workflow-api/src/main/java/org/ruoyi/workflow/workflow/NodeProcessResult.java index d78e1c4b..a62d4218 100644 --- a/ruoyi-modules-api/ruoyi-workflow-api/src/main/java/org/ruoyi/workflow/workflow/NodeProcessResult.java +++ b/ruoyi-modules-api/ruoyi-workflow-api/src/main/java/org/ruoyi/workflow/workflow/NodeProcessResult.java @@ -21,4 +21,14 @@ public class NodeProcessResult { * 条件执行时使用 */ private String nextNodeUuid; + + /** + * 是否发生错误 + */ + private boolean error = false; + + /** + * 错误或提示信息 + */ + private String message; } diff --git a/ruoyi-modules-api/ruoyi-workflow-api/src/main/java/org/ruoyi/workflow/workflow/WfNodeFactory.java b/ruoyi-modules-api/ruoyi-workflow-api/src/main/java/org/ruoyi/workflow/workflow/WfNodeFactory.java index 92095f0e..a6328e64 100644 --- a/ruoyi-modules-api/ruoyi-workflow-api/src/main/java/org/ruoyi/workflow/workflow/WfNodeFactory.java +++ b/ruoyi-modules-api/ruoyi-workflow-api/src/main/java/org/ruoyi/workflow/workflow/WfNodeFactory.java @@ -5,7 +5,10 @@ import org.ruoyi.workflow.entity.WorkflowNode; import org.ruoyi.workflow.workflow.node.AbstractWfNode; import org.ruoyi.workflow.workflow.node.EndNode; import org.ruoyi.workflow.workflow.node.answer.LLMAnswerNode; +import org.ruoyi.workflow.workflow.node.keywordExtractor.KeywordExtractorNode; +import org.ruoyi.workflow.workflow.node.mailSend.MailSendNode; import org.ruoyi.workflow.workflow.node.start.StartNode; +import org.ruoyi.workflow.workflow.node.switcher.SwitcherNode; public class WfNodeFactory { public static AbstractWfNode create(WorkflowComponent wfComponent, WorkflowNode nodeDefinition, @@ -14,7 +17,10 @@ public class WfNodeFactory { switch (WfComponentNameEnum.getByName(wfComponent.getName())) { case START -> wfNode = new StartNode(wfComponent, nodeDefinition, wfState, nodeState); case LLM_ANSWER -> wfNode = new LLMAnswerNode(wfComponent, nodeDefinition, wfState, nodeState); + case KEYWORD_EXTRACTOR -> wfNode = new KeywordExtractorNode(wfComponent, nodeDefinition, wfState, nodeState); case END -> wfNode = new EndNode(wfComponent, nodeDefinition, wfState, nodeState); + case MAIL_SEND -> wfNode = new MailSendNode(wfComponent, nodeDefinition, wfState, nodeState); + case SWITCHER -> wfNode = new SwitcherNode(wfComponent, nodeDefinition, wfState, nodeState); default -> { } } diff --git a/ruoyi-modules-api/ruoyi-workflow-api/src/main/java/org/ruoyi/workflow/workflow/WorkflowUtil.java b/ruoyi-modules-api/ruoyi-workflow-api/src/main/java/org/ruoyi/workflow/workflow/WorkflowUtil.java index adbd581b..7f9e89c3 100644 --- a/ruoyi-modules-api/ruoyi-workflow-api/src/main/java/org/ruoyi/workflow/workflow/WorkflowUtil.java +++ b/ruoyi-modules-api/ruoyi-workflow-api/src/main/java/org/ruoyi/workflow/workflow/WorkflowUtil.java @@ -20,12 +20,10 @@ import org.ruoyi.workflow.workflow.data.NodeIOData; import org.ruoyi.workflow.workflow.data.NodeIODataContent; import org.ruoyi.workflow.workflow.def.WfNodeParamRef; import org.springframework.stereotype.Component; - import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Set; - import static org.ruoyi.workflow.cosntant.AdiConstant.WorkflowConstant.DEFAULT_OUTPUT_PARAM_NAME; @Slf4j @@ -35,22 +33,48 @@ public class WorkflowUtil { @Resource private ChatServiceFactory chatServiceFactory; - @SuppressWarnings("unchecked") public static String renderTemplate(String template, List values) { + // 🔒 关键修复:如果 template 为 null,直接返回 null 或空字符串 + if (template == null) { + return null; // 或 return ""; 根据业务需求 + } + String result = template; + + // 防御 values 为 null + if (values == null) { + return result; + } + for (NodeIOData next : values) { + if (next == null || next.getName() == null) { + continue; + } + String name = next.getName(); NodeIODataContent dataContent = next.getContent(); - if (dataContent.getType().equals(WfIODataTypeEnum.FILES.getValue())) { - List value = (List) dataContent.getValue(); - result = result.replace("{" + name + "}", String.join(",", value)); - } else if (dataContent.getType().equals(WfIODataTypeEnum.OPTIONS.getValue())) { - Map value = (Map) dataContent.getValue(); - result = result.replace("{" + name + "}", value.toString()); - } else { - result = result.replace("{" + name + "}", dataContent.getValue().toString()); + if (dataContent == null || dataContent.getValue() == null) { + // 变量值为 null,替换为空字符串 + result = result.replace("{" + name + "}", ""); + continue; } + + String replacement; + if (dataContent.getType().equals(WfIODataTypeEnum.FILES.getValue())) { + @SuppressWarnings("unchecked") + List value = (List) dataContent.getValue(); + replacement = String.join(",", value); + } else if (dataContent.getType().equals(WfIODataTypeEnum.OPTIONS.getValue())) { + @SuppressWarnings("unchecked") + Map value = (Map) dataContent.getValue(); + replacement = value.toString(); + } else { + replacement = dataContent.getValue().toString(); + } + + result = result.replace("{" + name + "}", replacement); } + return result; } @@ -81,8 +105,15 @@ public class WorkflowUtil { .mapResult(response -> { String responseTxt = response.aiMessage().text(); log.info("llm response:{}", responseTxt); - NodeIOData output = NodeIOData.createByText(DEFAULT_OUTPUT_PARAM_NAME, "", responseTxt); - wfState.getNodeStateByNodeUuid(node.getUuid()).ifPresent(item -> item.getOutputs().add(output)); + + // 传递所有输入数据 + 添加 LLM 输出 + wfState.getNodeStateByNodeUuid(node.getUuid()).ifPresent(item -> { + List outputs = new ArrayList<>(item.getInputs()); + NodeIOData output = NodeIOData.createByText(DEFAULT_OUTPUT_PARAM_NAME, "", responseTxt); + outputs.add(output); + item.setOutputs(outputs); + }); + return Map.of("completeResult", response.aiMessage().text()); }) .startingNode(node.getUuid()) @@ -141,9 +172,10 @@ public class WorkflowUtil { * @return */ private Message getMessage(String role, Object value) { + log.info("Creating message with role: {}, content: {}", role, value); // 🔥 Message message = new Message(); - message.setContent(String.valueOf(value)); message.setRole(role); + message.setContent(value); return message; } @@ -154,9 +186,13 @@ public class WorkflowUtil { * @param messages */ private void addSystemMessage(List systemMessage, List messages) { + log.info("addSystemMessage received: {}", systemMessage); // 🔥 加这一行 + if (CollUtil.isEmpty(systemMessage)) { return; } - systemMessage.stream().map(userMsg -> getMessage("system", userMsg.singleText())).forEach(messages::add); + systemMessage.stream() + .map(userMsg -> getMessage("system", userMsg.singleText())) + .forEach(messages::add); } } diff --git a/ruoyi-modules-api/ruoyi-workflow-api/src/main/java/org/ruoyi/workflow/workflow/node/AbstractWfNode.java b/ruoyi-modules-api/ruoyi-workflow-api/src/main/java/org/ruoyi/workflow/workflow/node/AbstractWfNode.java index d6119e5c..9deeb5e8 100644 --- a/ruoyi-modules-api/ruoyi-workflow-api/src/main/java/org/ruoyi/workflow/workflow/node/AbstractWfNode.java +++ b/ruoyi-modules-api/ruoyi-workflow-api/src/main/java/org/ruoyi/workflow/workflow/node/AbstractWfNode.java @@ -166,17 +166,40 @@ public abstract class AbstractWfNode { protected abstract NodeProcessResult onProcess(); protected String getFirstInputText() { + // 检查输入是否为空 + if (state.getInputs() == null || state.getInputs().isEmpty()) { + log.warn("No inputs available for node: {}", state.getUuid()); + return ""; + } + + // 优先查找 output 参数(LLM 节点的输出) + Optional outputParam = state.getInputs() + .stream() + .filter(item -> DEFAULT_OUTPUT_PARAM_NAME.equals(item.getName())) + .map(NodeIOData::valueToString) + .findFirst(); + + if (outputParam.isPresent()) { + log.debug("Found output parameter for node: {}", state.getUuid()); + return outputParam.get(); + } + + // 如果没有 output,查找其他文本类型参数(排除 input) String firstInputText; if (state.getInputs().size() > 1) { firstInputText = state.getInputs() .stream() - .filter(item -> WfIODataTypeEnum.TEXT.getValue().equals(item.getContent().getType()) && !DEFAULT_INPUT_PARAM_NAME.equals(item.getName())) + .filter(item -> WfIODataTypeEnum.TEXT.getValue().equals(item.getContent().getType()) + && !DEFAULT_INPUT_PARAM_NAME.equals(item.getName())) .map(NodeIOData::valueToString) .findFirst() .orElse(""); } else { firstInputText = state.getInputs().get(0).valueToString(); } + + log.debug("Using first input text for node: {}, value: {}", state.getUuid(), + firstInputText.length() > 50 ? firstInputText.substring(0, 50) + "..." : firstInputText); return firstInputText; } diff --git a/ruoyi-modules-api/ruoyi-workflow-api/src/main/java/org/ruoyi/workflow/workflow/node/keywordExtractor/KeywordExtractorNode.java b/ruoyi-modules-api/ruoyi-workflow-api/src/main/java/org/ruoyi/workflow/workflow/node/keywordExtractor/KeywordExtractorNode.java new file mode 100644 index 00000000..0f1d579e --- /dev/null +++ b/ruoyi-modules-api/ruoyi-workflow-api/src/main/java/org/ruoyi/workflow/workflow/node/keywordExtractor/KeywordExtractorNode.java @@ -0,0 +1,104 @@ +package org.ruoyi.workflow.workflow.node.keywordExtractor; + +import dev.langchain4j.data.message.UserMessage; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.ruoyi.workflow.entity.WorkflowComponent; +import org.ruoyi.workflow.entity.WorkflowNode; +import org.ruoyi.workflow.util.SpringUtil; +import org.ruoyi.workflow.workflow.NodeProcessResult; +import org.ruoyi.workflow.workflow.WfNodeState; +import org.ruoyi.workflow.workflow.WfState; +import org.ruoyi.workflow.workflow.WorkflowUtil; +import org.ruoyi.workflow.workflow.data.NodeIOData; +import org.ruoyi.workflow.workflow.node.AbstractWfNode; + +import java.util.ArrayList; +import java.util.List; + +import static org.ruoyi.workflow.cosntant.AdiConstant.WorkflowConstant.DEFAULT_OUTPUT_PARAM_NAME; + +/** + * 【节点】关键词提取节点 + * 使用 LLM 从文本中提取关键词 + */ +@Slf4j +public class KeywordExtractorNode extends AbstractWfNode { + + public KeywordExtractorNode(WorkflowComponent wfComponent, WorkflowNode nodeDef, WfState wfState, WfNodeState nodeState) { + super(wfComponent, nodeDef, wfState, nodeState); + } + + /** + * 处理关键词提取 + * nodeConfig 格式: + * { + * "model_name": "deepseek-chat", + * "category": "llm", + * "top_n": 5, + * "prompt": "额外的提示词" + * } + * + * @return 提取的关键词列表 + */ + @Override + public NodeProcessResult onProcess() { + KeywordExtractorNodeConfig config = checkAndGetConfig(KeywordExtractorNodeConfig.class); + + // 获取输入文本 + String inputText = getFirstInputText(); + if (StringUtils.isBlank(inputText)) { + log.warn("Keyword extractor node has no input text, node: {}", state.getUuid()); + // 返回空结果 + List outputs = new ArrayList<>(); + outputs.add(NodeIOData.createByText(DEFAULT_OUTPUT_PARAM_NAME, "", "")); + return NodeProcessResult.builder().content(outputs).build(); + } + + log.info("Keyword extractor node config: {}", config); + log.info("Input text length: {}", inputText.length()); + + // 构建提示词 + String prompt = buildPrompt(config, inputText); + log.info("Keyword extraction prompt: {}", prompt); + + // 调用 LLM 进行关键词提取 + WorkflowUtil workflowUtil = SpringUtil.getBean(WorkflowUtil.class); + String modelName = config.getModelName(); + String category = config.getCategory(); + List systemMessage = List.of(UserMessage.from(prompt)); + + // 使用流式调用 + workflowUtil.streamingInvokeLLM(wfState, state, node, category, modelName, systemMessage); + + return new NodeProcessResult(); + } + + /** + * 构建关键词提取的提示词 + */ + private String buildPrompt(KeywordExtractorNodeConfig config, String inputText) { + StringBuilder promptBuilder = new StringBuilder(); + + // 基础提示词 + promptBuilder.append("请从以下文本中提取 ").append(config.getTopN()).append(" 个最重要的关键词。\n\n"); + + // 添加自定义提示词(如果有) + if (StringUtils.isNotBlank(config.getPrompt())) { + promptBuilder.append(config.getPrompt()).append("\n\n"); + } + + // 输出格式要求 + promptBuilder.append("要求:\n"); + promptBuilder.append("1. 只返回关键词,每个关键词用逗号分隔\n"); + promptBuilder.append("2. 关键词应该是名词或名词短语\n"); + promptBuilder.append("3. 按重要性从高到低排序\n"); + promptBuilder.append("4. 不要添加任何解释或额外的文字\n\n"); + + // 原始文本 + promptBuilder.append("文本内容:\n"); + promptBuilder.append(inputText); + + return promptBuilder.toString(); + } +} diff --git a/ruoyi-modules-api/ruoyi-workflow-api/src/main/java/org/ruoyi/workflow/workflow/node/keywordExtractor/KeywordExtractorNodeConfig.java b/ruoyi-modules-api/ruoyi-workflow-api/src/main/java/org/ruoyi/workflow/workflow/node/keywordExtractor/KeywordExtractorNodeConfig.java new file mode 100644 index 00000000..6071f410 --- /dev/null +++ b/ruoyi-modules-api/ruoyi-workflow-api/src/main/java/org/ruoyi/workflow/workflow/node/keywordExtractor/KeywordExtractorNodeConfig.java @@ -0,0 +1,42 @@ +package org.ruoyi.workflow.workflow.node.keywordExtractor; + +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 关键词提取节点配置 + */ +@EqualsAndHashCode +@Data +public class KeywordExtractorNodeConfig { + + /** + * 模型分类(如:llm, embedding 等) + */ + private String category; + + /** + * 模型名称 + */ + @NotNull + @JsonProperty("model_name") + private String modelName; + + /** + * 提取的关键词数量 + */ + @Min(1) + @Max(50) + @JsonProperty("top_n") + private Integer topN = 5; + + /** + * 提示词(可选) + * 用于指导关键词提取的额外说明 + */ + private String prompt; +} diff --git a/ruoyi-modules-api/ruoyi-workflow-api/src/main/java/org/ruoyi/workflow/workflow/node/mailSend/MailSendNode.java b/ruoyi-modules-api/ruoyi-workflow-api/src/main/java/org/ruoyi/workflow/workflow/node/mailSend/MailSendNode.java new file mode 100644 index 00000000..11c30f07 --- /dev/null +++ b/ruoyi-modules-api/ruoyi-workflow-api/src/main/java/org/ruoyi/workflow/workflow/node/mailSend/MailSendNode.java @@ -0,0 +1,176 @@ +package org.ruoyi.workflow.workflow.node.mailSend; + +import jakarta.mail.internet.MimeMessage; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.ruoyi.workflow.entity.WorkflowComponent; +import org.ruoyi.workflow.entity.WorkflowNode; +import org.ruoyi.workflow.workflow.NodeProcessResult; +import org.ruoyi.workflow.workflow.WfNodeState; +import org.ruoyi.workflow.workflow.WfState; +import org.ruoyi.workflow.workflow.WorkflowUtil; +import org.ruoyi.workflow.workflow.data.NodeIOData; +import org.ruoyi.workflow.workflow.node.AbstractWfNode; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.JavaMailSenderImpl; +import org.springframework.mail.javamail.MimeMessageHelper; +import java.util.Arrays; +import java.util.List; +import java.util.Properties; + +@Slf4j +public class MailSendNode extends AbstractWfNode { + + public MailSendNode(WorkflowComponent wfComponent, WorkflowNode nodeDef, WfState wfState, WfNodeState nodeState) { + super(wfComponent, nodeDef, wfState, nodeState); + } + + @Override + public NodeProcessResult onProcess() { + try { + MailSendNodeConfig config = checkAndGetConfig(MailSendNodeConfig.class); + List inputs = state.getInputs(); + + // 安全获取模板(使用 defaultString 避免 null) + String subjectTemplate = StringUtils.defaultString(config.getSubject()); + String contentTemplate = StringUtils.defaultString(config.getContent()); + String toMailsTemplate = StringUtils.defaultString(config.getToMails()); + String ccMailsTemplate = StringUtils.defaultString(config.getCcMails()); + + // 渲染收件人和主题 + String toMails = WorkflowUtil.renderTemplate(toMailsTemplate, inputs); + String ccMails = WorkflowUtil.renderTemplate(ccMailsTemplate, inputs); + String subject = WorkflowUtil.renderTemplate(subjectTemplate, inputs); + + // 内容:优先使用配置的内容模板,否则使用 output 或 input 参数 + String content; + if (StringUtils.isNotBlank(contentTemplate)) { + content = WorkflowUtil.renderTemplate(contentTemplate, inputs); + } else { + // 优先使用 output,如果没有则使用 input + content = inputs.stream() + .filter(item -> "output".equals(item.getName())) + .map(NodeIOData::valueToString) + .findFirst() + .orElseGet(() -> inputs.stream() + .filter(item -> "input".equals(item.getName())) + .map(NodeIOData::valueToString) + .findFirst() + .orElse("")); + } + + // 将换行符转换为 HTML 换行 + if (StringUtils.isNotBlank(content)) { + content = content.replace("\n", "
"); + } else { + content = ""; // 安全兜底 + } + + // 校验必要字段 + if (config.getSender() == null) { + throw new IllegalArgumentException("发件人配置(sender)不能为空"); + } + if (StringUtils.isBlank(toMails)) { + throw new IllegalArgumentException("收件人邮箱(to_mails)不能为空或未解析出有效值"); + } + + // 创建邮件发送器 + JavaMailSender mailSender = createMailSender(config); + MimeMessage message = mailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8"); + + helper.setFrom(config.getSender().getMail(), config.getSender().getName()); + + // 设置收件人 + String[] toArray = Arrays.stream(toMails.split(",")) + .map(String::trim) + .filter(StringUtils::isNotBlank) + .toArray(String[]::new); + if (toArray.length == 0) { + throw new IllegalArgumentException("收件人邮箱列表为空"); + } + helper.setTo(toArray); + + // 设置抄送(如有) + if (StringUtils.isNotBlank(ccMails)) { + String[] ccArray = Arrays.stream(ccMails.split(",")) + .map(String::trim) + .filter(StringUtils::isNotBlank) + .toArray(String[]::new); + if (ccArray.length > 0) { + helper.setCc(ccArray); + } + } + + // 设置主题和内容(支持 HTML) + helper.setSubject(subject); + helper.setText(content, true); + + // 发送 + mailSender.send(message); + log.info("Email sent successfully to: {}", toMails); + + // 构造输出:统一输出为 output 参数 + List outputs = new java.util.ArrayList<>(); + + // 优先使用 output,如果没有则使用 input(但重命名为 output) + inputs.stream() + .filter(item -> "output".equals(item.getName())) + .findFirst() + .ifPresentOrElse( + outputs::add, + () -> inputs.stream() + .filter(item -> "input".equals(item.getName())) + .findFirst() + .ifPresent(inputParam -> { + String title = inputParam.getContent() != null && inputParam.getContent().getTitle() != null + ? inputParam.getContent().getTitle() : ""; + NodeIOData outputParam = NodeIOData.createByText("output", title, inputParam.valueToString()); + outputs.add(outputParam); + }) + ); + + return NodeProcessResult.builder().content(outputs).build(); + + } catch (Exception e) { + log.error("Failed to send email in node: {}", node.getId(), e); + // 异常时也统一输出为 output 参数,添加错误信息 + List errorOutputs = new java.util.ArrayList<>(); + + state.getInputs().stream() + .filter(item -> "output".equals(item.getName())) + .findFirst() + .ifPresentOrElse( + errorOutputs::add, + () -> state.getInputs().stream() + .filter(item -> "input".equals(item.getName())) + .findFirst() + .ifPresent(inputParam -> { + String title = inputParam.getContent() != null && inputParam.getContent().getTitle() != null + ? inputParam.getContent().getTitle() : ""; + NodeIOData outputParam = NodeIOData.createByText("output", title, inputParam.valueToString()); + errorOutputs.add(outputParam); + }) + ); + + errorOutputs.add(NodeIOData.createByText("error", "mail", e.getMessage())); + return NodeProcessResult.builder().content(errorOutputs).build(); + } + } + + private JavaMailSender createMailSender(MailSendNodeConfig config) { + JavaMailSenderImpl sender = new JavaMailSenderImpl(); + sender.setHost(config.getSmtp().getHost()); + sender.setPort(config.getSmtp().getPort()); + sender.setUsername(config.getSender().getMail()); + sender.setPassword(config.getSender().getPassword()); + + Properties props = new Properties(); + props.put("mail.smtp.auth", "true"); + props.put("mail.smtp.ssl.enable", "true"); // QQ 邮箱 465 必须开 SSL + props.put("mail.smtp.ssl.protocols", "TLSv1.2"); + sender.setJavaMailProperties(props); + + return sender; + } +} diff --git a/ruoyi-modules-api/ruoyi-workflow-api/src/main/java/org/ruoyi/workflow/workflow/node/mailSend/MailSendNodeConfig.java b/ruoyi-modules-api/ruoyi-workflow-api/src/main/java/org/ruoyi/workflow/workflow/node/mailSend/MailSendNodeConfig.java new file mode 100644 index 00000000..5f4ae5f6 --- /dev/null +++ b/ruoyi-modules-api/ruoyi-workflow-api/src/main/java/org/ruoyi/workflow/workflow/node/mailSend/MailSendNodeConfig.java @@ -0,0 +1,32 @@ +package org.ruoyi.workflow.workflow.node.mailSend; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +@Data +public class MailSendNodeConfig { + private SmtpConfig smtp; + private SenderConfig sender; + private String subject; + private String content; + + @JsonProperty("to_mails") + private String toMails; // 支持 "a@x.com,b@y.com" + + @JsonProperty("cc_mails") + private String ccMails; // 可选 + private Integer senderType; + + @Data + public static class SmtpConfig { + private String host; + private Integer port; // 465 或 587 + } + + @Data + public static class SenderConfig { + private String mail; + private String name; + private String password; // 授权码 + } +} diff --git a/ruoyi-modules-api/ruoyi-workflow-api/src/main/java/org/ruoyi/workflow/workflow/node/switcher/SwitcherNode.java b/ruoyi-modules-api/ruoyi-workflow-api/src/main/java/org/ruoyi/workflow/workflow/node/switcher/SwitcherNode.java new file mode 100644 index 00000000..85cb4a32 --- /dev/null +++ b/ruoyi-modules-api/ruoyi-workflow-api/src/main/java/org/ruoyi/workflow/workflow/node/switcher/SwitcherNode.java @@ -0,0 +1,331 @@ +package org.ruoyi.workflow.workflow.node.switcher; + +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.ruoyi.workflow.entity.WorkflowComponent; +import org.ruoyi.workflow.entity.WorkflowNode; +import org.ruoyi.workflow.workflow.NodeProcessResult; +import org.ruoyi.workflow.workflow.WfNodeState; +import org.ruoyi.workflow.workflow.WfState; +import org.ruoyi.workflow.workflow.data.NodeIOData; +import org.ruoyi.workflow.workflow.node.AbstractWfNode; +import java.math.BigDecimal; +import java.util.List; + +/** + * 条件分支节点 + * 根据配置的条件规则,选择不同的分支路径执行 + */ +@Slf4j +public class SwitcherNode extends AbstractWfNode { + + public SwitcherNode(WorkflowComponent wfComponent, WorkflowNode nodeDef, WfState wfState, WfNodeState nodeState) { + super(wfComponent, nodeDef, wfState, nodeState); + } + + @Override + public NodeProcessResult onProcess() { + try { + SwitcherNodeConfig config = checkAndGetConfig(SwitcherNodeConfig.class); + List inputs = state.getInputs(); + + log.info("条件分支节点处理中,分支数量: {}", + config.getCases() != null ? config.getCases().size() : 0); + + // 按顺序评估每个分支 + if (config.getCases() != null) { + for (int i = 0; i < config.getCases().size(); i++) { + SwitcherCase switcherCase = config.getCases().get(i); + log.info("评估分支 {}: uuid={}, 运算符={}", + i + 1, switcherCase.getUuid(), switcherCase.getOperator()); + + if (evaluateCase(switcherCase, inputs)) { + // 检查目标节点UUID是否为空 + if (StringUtils.isBlank(switcherCase.getTargetNodeUuid())) { + log.warn("分支 {} 匹配但目标节点UUID为空,跳过到下一个分支", i + 1); + continue; + } + + log.info("分支 {} 匹配,跳转到节点: {}", + i + 1, switcherCase.getTargetNodeUuid()); + + // 构造输出:只保留 output 和其他非 input 参数 + 添加分支匹配信息 + List outputs = new java.util.ArrayList<>(); + + // 过滤输入:排除 input 参数(与 output 冗余),保留其他参数 + inputs.stream() + .filter(item -> !"input".equals(item.getName())) + .forEach(outputs::add); + + // 如果没有 output 参数,从 input 创建 output(便于后续节点使用) + boolean hasOutput = outputs.stream().anyMatch(item -> "output".equals(item.getName())); + if (!hasOutput) { + inputs.stream() + .filter(item -> "input".equals(item.getName())) + .findFirst() + .ifPresent(inputParam -> { + String title = inputParam.getContent() != null && inputParam.getContent().getTitle() != null + ? inputParam.getContent().getTitle() : ""; + NodeIOData outputParam = NodeIOData.createByText("output", title, inputParam.valueToString()); + outputs.add(outputParam); + log.debug("从输入创建输出参数供下游节点使用"); + }); + } + + outputs.add(NodeIOData.createByText("matched_case", "switcher", String.valueOf(i + 1))); + outputs.add(NodeIOData.createByText("case_uuid", "switcher", switcherCase.getUuid())); + outputs.add(NodeIOData.createByText("target_node", "switcher", switcherCase.getTargetNodeUuid())); + + // WorkflowEngine 会自动将 nextNodeUuid 放入 resultMap 的 "next" 键中 + return NodeProcessResult.builder() + .content(outputs) + .nextNodeUuid(switcherCase.getTargetNodeUuid()) + .build(); + } + } + } + + // 所有分支都不满足,使用默认分支 + log.info("没有分支匹配,使用默认分支: {}", config.getDefaultTargetNodeUuid()); + + if (StringUtils.isBlank(config.getDefaultTargetNodeUuid())) { + log.warn("默认目标节点UUID为空,工作流可能在此停止"); + } + + String defaultTarget = config.getDefaultTargetNodeUuid() != null ? + config.getDefaultTargetNodeUuid() : ""; + + // 构造输出:只保留 output 和其他非 input 参数 + 添加默认分支信息 + List outputs = new java.util.ArrayList<>(); + + // 过滤输入:排除 input 参数(与 output 冗余),保留其他参数 + inputs.stream() + .filter(item -> !"input".equals(item.getName())) + .forEach(outputs::add); + + // 如果没有 output 参数,从 input 创建 output(便于后续节点使用) + boolean hasOutput = outputs.stream().anyMatch(item -> "output".equals(item.getName())); + if (!hasOutput) { + inputs.stream() + .filter(item -> "input".equals(item.getName())) + .findFirst() + .ifPresent(inputParam -> { + String title = inputParam.getContent() != null && inputParam.getContent().getTitle() != null + ? inputParam.getContent().getTitle() : ""; + NodeIOData outputParam = NodeIOData.createByText("output", title, inputParam.valueToString()); + outputs.add(outputParam); + log.debug("从输入创建输出参数供下游节点使用"); + }); + } + + outputs.add(NodeIOData.createByText("matched_case", "switcher", "default")); + outputs.add(NodeIOData.createByText("target_node", "switcher", defaultTarget)); + + // WorkflowEngine 会自动将 nextNodeUuid 放入 resultMap 的 "next" 键中 + return NodeProcessResult.builder() + .content(outputs) + .nextNodeUuid(config.getDefaultTargetNodeUuid()) + .build(); + + } catch (Exception e) { + log.error("处理条件分支节点失败: {}", node.getUuid(), e); + + List errorOutputs = List.of( + NodeIOData.createByText("status", "switcher", "error"), + NodeIOData.createByText("error", "switcher", e.getMessage()) + ); + + return NodeProcessResult.builder() + .content(errorOutputs) + .error(true) + .message("条件分支节点错误: " + e.getMessage()) + .build(); + } + } + + /** + * 评估单个分支的条件 + * @param switcherCase 分支配置 + * @param inputs 输入数据 + * @return 是否满足条件 + */ + private boolean evaluateCase(SwitcherCase switcherCase, List inputs) { + if (switcherCase.getConditions() == null || switcherCase.getConditions().isEmpty()) { + log.warn("分支 {} 没有条件,跳过", switcherCase.getUuid()); + return false; + } + + String operator = switcherCase.getOperator(); + boolean isAnd = "and".equalsIgnoreCase(operator); + + log.debug("使用 {} 逻辑评估 {} 个条件", + operator, switcherCase.getConditions().size()); + + for (SwitcherCase.Condition condition : switcherCase.getConditions()) { + boolean conditionResult = evaluateCondition(condition, inputs); + log.debug("条件结果: {} (参数: {}, 运算符: {}, 值: {})", + conditionResult, condition.getNodeParamName(), + condition.getOperator(), condition.getValue()); + + if (isAnd && !conditionResult) { + // AND 逻辑:任何一个条件不满足就返回 false + return false; + } else if (!isAnd && conditionResult) { + // OR 逻辑:任何一个条件满足就返回 true + return true; + } + } + // AND 逻辑:所有条件都满足返回 true + // OR 逻辑:所有条件都不满足返回 false + return isAnd; + } + + /** + * 评估单个条件 + * @param condition 条件配置 + * @param inputs 输入数据 + * @return 是否满足条件 + */ + private boolean evaluateCondition(SwitcherCase.Condition condition, List inputs) { + try { + log.info("评估条件 - 节点UUID: {}, 参数名: {}, 运算符: {}, 期望值: {}", + condition.getNodeUuid(), condition.getNodeParamName(), + condition.getOperator(), condition.getValue()); + + // 获取实际值 + String actualValue = getValueFromInputs(condition.getNodeUuid(), + condition.getNodeParamName(), inputs); + + if (actualValue == null) { + log.warn("无法找到节点: {}, 参数: {} 的值 - 可用输入: {}", + condition.getNodeUuid(), condition.getNodeParamName(), + inputs.stream().map(NodeIOData::getName).toList()); + actualValue = ""; + } + + log.info("获取到的实际值: '{}' (类型: {})", actualValue, actualValue.getClass().getSimpleName()); + + String expectedValue = condition.getValue() != null ? condition.getValue() : ""; + OperatorEnum operator = OperatorEnum.getByName(condition.getOperator()); + + if (operator == null) { + log.warn("未知运算符: {},视为false", condition.getOperator()); + return false; + } + + boolean result = evaluateOperator(operator, actualValue, expectedValue); + log.info("条件评估结果: {} (实际值='{}', 运算符={}, 期望值='{}')", + result, actualValue, operator, expectedValue); + + return result; + + } catch (Exception e) { + log.error("评估条件时出错: {}", condition, e); + return false; + } + } + + /** + * 从输入数据中获取指定节点的参数值 + */ + private String getValueFromInputs(String nodeUuid, String paramName, List inputs) { + log.debug("从节点UUID '{}' 搜索参数 '{}'", nodeUuid, paramName); + + // 首先尝试从当前输入中查找 + log.debug("检查当前输入 (数量: {})", inputs.size()); + for (NodeIOData input : inputs) { + log.debug(" - 输入: 名称='{}', 值='{}'", input.getName(), input.valueToString()); + if (paramName.equals(input.getName())) { + log.info("在当前输入中找到参数 '{}': '{}'", paramName, input.valueToString()); + return input.valueToString(); + } + } + + // 如果当前输入中没有,尝试从工作流状态中查找指定节点的输出 + if (StringUtils.isNotBlank(nodeUuid)) { + List nodeOutputs = wfState.getIOByNodeUuid(nodeUuid); + log.debug("检查节点 '{}' 的输出 (数量: {})", nodeUuid, nodeOutputs.size()); + for (NodeIOData output : nodeOutputs) { + log.debug(" - 输出: 名称='{}', 值='{}'", output.getName(), output.valueToString()); + if (paramName.equals(output.getName())) { + log.info("在节点 '{}' 的输出中找到参数 '{}': '{}'", nodeUuid, paramName, output.valueToString()); + return output.valueToString(); + } + } + } else { + log.debug("节点UUID为空,跳过工作流状态搜索"); + } + + log.warn("在输入或节点 '{}' 的输出中未找到参数 '{}'", nodeUuid, paramName); + return null; + } + + /** + * 根据运算符评估条件 + */ + private boolean evaluateOperator(OperatorEnum operator, String actualValue, String expectedValue) { + switch (operator) { + case CONTAINS: + return actualValue.contains(expectedValue); + + case NOT_CONTAINS: + return !actualValue.contains(expectedValue); + + case START_WITH: + return actualValue.startsWith(expectedValue); + + case END_WITH: + return actualValue.endsWith(expectedValue); + + case EMPTY: + return StringUtils.isBlank(actualValue); + + case NOT_EMPTY: + return StringUtils.isNotBlank(actualValue); + + case EQUAL: + return actualValue.equals(expectedValue); + + case NOT_EQUAL: + return !actualValue.equals(expectedValue); + + case GREATER: + case GREATER_OR_EQUAL: + case LESS: + case LESS_OR_EQUAL: + return evaluateNumericComparison(operator, actualValue, expectedValue); + + default: + log.warn("不支持的运算符: {}", operator); + return false; + } + } + + /** + * 评估数值比较 + */ + private boolean evaluateNumericComparison(OperatorEnum operator, String actualValue, String expectedValue) { + try { + BigDecimal actual = new BigDecimal(actualValue.trim()); + BigDecimal expected = new BigDecimal(expectedValue.trim()); + int comparison = actual.compareTo(expected); + + switch (operator) { + case GREATER: + return comparison > 0; + case GREATER_OR_EQUAL: + return comparison >= 0; + case LESS: + return comparison < 0; + case LESS_OR_EQUAL: + return comparison <= 0; + default: + return false; + } + } catch (NumberFormatException e) { + log.warn("无法解析数字进行比较: 实际值={}, 期望值={}", + actualValue, expectedValue); + return false; + } + } +}