发布 v1.10 #5
@@ -53,6 +53,10 @@
|
|||||||
<groupId>com.easyagents</groupId>
|
<groupId>com.easyagents</groupId>
|
||||||
<artifactId>easy-agents-spring-boot-starter</artifactId>
|
<artifactId>easy-agents-spring-boot-starter</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.google.re2j</groupId>
|
||||||
|
<artifactId>re2j</artifactId>
|
||||||
|
</dependency>
|
||||||
<!--使用
|
<!--使用
|
||||||
enjoy 模板引擎-->
|
enjoy 模板引擎-->
|
||||||
<dependency>
|
<dependency>
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckResult;
|
|||||||
import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage;
|
import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage;
|
||||||
import tech.easyflow.ai.entity.PluginItem;
|
import tech.easyflow.ai.entity.PluginItem;
|
||||||
import tech.easyflow.ai.entity.Workflow;
|
import tech.easyflow.ai.entity.Workflow;
|
||||||
|
import tech.easyflow.ai.node.ConditionNode;
|
||||||
|
import tech.easyflow.ai.node.ConditionRuleSupport;
|
||||||
import tech.easyflow.ai.node.filegeneration.FileGenerationRules;
|
import tech.easyflow.ai.node.filegeneration.FileGenerationRules;
|
||||||
import tech.easyflow.ai.node.filegeneration.SourceFormat;
|
import tech.easyflow.ai.node.filegeneration.SourceFormat;
|
||||||
import tech.easyflow.ai.node.filegeneration.TargetFormat;
|
import tech.easyflow.ai.node.filegeneration.TargetFormat;
|
||||||
@@ -50,6 +52,7 @@ public class WorkflowCheckService {
|
|||||||
private static final String TYPE_START = "startNode";
|
private static final String TYPE_START = "startNode";
|
||||||
private static final String TYPE_END = "endNode";
|
private static final String TYPE_END = "endNode";
|
||||||
private static final String TYPE_LOOP = "loopNode";
|
private static final String TYPE_LOOP = "loopNode";
|
||||||
|
private static final String TYPE_CONDITION = "conditionNode";
|
||||||
private static final String TYPE_WORKFLOW = "workflow-node";
|
private static final String TYPE_WORKFLOW = "workflow-node";
|
||||||
private static final String TYPE_PLUGIN = "plugin-node";
|
private static final String TYPE_PLUGIN = "plugin-node";
|
||||||
private static final String TYPE_MAKE_FILE = "make-file";
|
private static final String TYPE_MAKE_FILE = "make-file";
|
||||||
@@ -178,6 +181,7 @@ public class WorkflowCheckService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
checkLoopConfigurations(nodes, nodeMap, issues, issueKeys);
|
checkLoopConfigurations(nodes, nodeMap, issues, issueKeys);
|
||||||
|
checkConditionConfigurations(nodes, issues, issueKeys);
|
||||||
|
|
||||||
List<EdgeView> edges = new ArrayList<>();
|
List<EdgeView> edges = new ArrayList<>();
|
||||||
Set<String> edgeIds = new HashSet<>();
|
Set<String> edgeIds = new HashSet<>();
|
||||||
@@ -262,6 +266,131 @@ public class WorkflowCheckService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验条件判断节点的可视化规则配置。
|
||||||
|
*
|
||||||
|
* @param nodes 节点列表
|
||||||
|
* @param issues 问题列表
|
||||||
|
* @param issueKeys 问题去重键
|
||||||
|
*/
|
||||||
|
private void checkConditionConfigurations(
|
||||||
|
List<NodeView> nodes,
|
||||||
|
List<WorkflowCheckIssue> issues,
|
||||||
|
Set<String> issueKeys) {
|
||||||
|
for (NodeView node : nodes) {
|
||||||
|
if (!TYPE_CONDITION.equals(node.type) || node.data == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Object branchesValue = node.data.get("branches");
|
||||||
|
if (!(branchesValue instanceof JSONArray)
|
||||||
|
|| ((JSONArray) branchesValue).isEmpty()) {
|
||||||
|
addIssue(
|
||||||
|
issues,
|
||||||
|
issueKeys,
|
||||||
|
"CONDITION_BRANCHES_EMPTY",
|
||||||
|
"条件判断节点至少需要一个分支",
|
||||||
|
node.id,
|
||||||
|
null,
|
||||||
|
node.name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
JSONArray branches = (JSONArray) branchesValue;
|
||||||
|
for (int branchIndex = 0; branchIndex < branches.size(); branchIndex++) {
|
||||||
|
Object branchValue = branches.get(branchIndex);
|
||||||
|
if (!(branchValue instanceof JSONObject)) {
|
||||||
|
addIssue(
|
||||||
|
issues,
|
||||||
|
issueKeys,
|
||||||
|
"CONDITION_BRANCH_INVALID",
|
||||||
|
"第 " + (branchIndex + 1) + " 个条件分支配置无效",
|
||||||
|
node.id,
|
||||||
|
null,
|
||||||
|
node.name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
JSONObject branch = (JSONObject) branchValue;
|
||||||
|
if ("expression".equalsIgnoreCase(
|
||||||
|
trimToNull(branch.getString("mode")))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Object rulesValue = branch.get("rules");
|
||||||
|
if (rulesValue == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!(rulesValue instanceof JSONArray)) {
|
||||||
|
addIssue(
|
||||||
|
issues,
|
||||||
|
issueKeys,
|
||||||
|
"CONDITION_RULES_INVALID",
|
||||||
|
conditionBranchLabel(branch, branchIndex) + "的条件规则必须是数组",
|
||||||
|
node.id,
|
||||||
|
null,
|
||||||
|
node.name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
JSONArray rules = (JSONArray) rulesValue;
|
||||||
|
for (int ruleIndex = 0; ruleIndex < rules.size(); ruleIndex++) {
|
||||||
|
Object ruleValue = rules.get(ruleIndex);
|
||||||
|
ConditionNode.ConditionRule rule = toConditionRule(ruleValue);
|
||||||
|
String error = ConditionRuleSupport.validateRule(rule);
|
||||||
|
if (error == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
addIssue(
|
||||||
|
issues,
|
||||||
|
issueKeys,
|
||||||
|
"CONDITION_RULE_INVALID",
|
||||||
|
conditionBranchLabel(branch, branchIndex)
|
||||||
|
+ "第 " + (ruleIndex + 1)
|
||||||
|
+ " 条条件配置无效: " + error,
|
||||||
|
node.id,
|
||||||
|
null,
|
||||||
|
node.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 JSON 条件规则转换为运行时规则对象。
|
||||||
|
*
|
||||||
|
* @param ruleValue 条件规则 JSON 值
|
||||||
|
* @return 条件规则;输入无效时返回 {@code null}
|
||||||
|
*/
|
||||||
|
private ConditionNode.ConditionRule toConditionRule(Object ruleValue) {
|
||||||
|
if (!(ruleValue instanceof JSONObject)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
JSONObject ruleJson = (JSONObject) ruleValue;
|
||||||
|
ConditionNode.ConditionRule rule = new ConditionNode.ConditionRule();
|
||||||
|
rule.setId(ruleJson.getString("id"));
|
||||||
|
rule.setJoiner(ruleJson.getString("joiner"));
|
||||||
|
rule.setLeftRef(ruleJson.getString("leftRef"));
|
||||||
|
rule.setOperator(ruleJson.getString("operator"));
|
||||||
|
rule.setRightType(ruleJson.getString("rightType"));
|
||||||
|
rule.setRightValue(ruleJson.getString("rightValue"));
|
||||||
|
rule.setRightRef(ruleJson.getString("rightRef"));
|
||||||
|
return rule;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用于校验错误展示的条件分支名称。
|
||||||
|
*
|
||||||
|
* @param branch 分支配置
|
||||||
|
* @param branchIndex 分支序号
|
||||||
|
* @return 分支名称
|
||||||
|
*/
|
||||||
|
private String conditionBranchLabel(JSONObject branch, int branchIndex) {
|
||||||
|
String label = trimToNull(branch.getString("label"));
|
||||||
|
return StringUtils.hasText(label)
|
||||||
|
? "条件分支[" + label + "]"
|
||||||
|
: "第 " + (branchIndex + 1) + " 个条件分支";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 校验普通节点启用循环后的总执行次数。
|
* 校验普通节点启用循环后的总执行次数。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -148,8 +148,19 @@ public class ConditionNode extends BaseNode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Boolean matched = null;
|
Boolean matched = null;
|
||||||
for (ConditionRule rule : rules) {
|
for (int ruleIndex = 0; ruleIndex < rules.size(); ruleIndex++) {
|
||||||
boolean ruleMatched = checkRule(chain, rule);
|
ConditionRule rule = rules.get(ruleIndex);
|
||||||
|
boolean ruleMatched;
|
||||||
|
try {
|
||||||
|
ruleMatched = checkRule(chain, rule);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
throw new ChainException(String.format(
|
||||||
|
"条件分支规则执行失败,分支[%s/%s],规则[%d]: %s",
|
||||||
|
branch.getId(),
|
||||||
|
branch.getLabel(),
|
||||||
|
ruleIndex + 1,
|
||||||
|
e.getMessage()), e);
|
||||||
|
}
|
||||||
if (matched == null) {
|
if (matched == null) {
|
||||||
matched = ruleMatched;
|
matched = ruleMatched;
|
||||||
continue;
|
continue;
|
||||||
@@ -167,8 +178,9 @@ public class ConditionNode extends BaseNode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private boolean checkRule(Chain chain, ConditionRule rule) {
|
private boolean checkRule(Chain chain, ConditionRule rule) {
|
||||||
if (rule == null || StringUtil.noText(rule.getOperator())) {
|
String validationError = ConditionRuleSupport.validateRule(rule);
|
||||||
return false;
|
if (validationError != null) {
|
||||||
|
throw new IllegalArgumentException(validationError);
|
||||||
}
|
}
|
||||||
|
|
||||||
Object leftValue = resolveValue(chain, rule.getLeftRef());
|
Object leftValue = resolveValue(chain, rule.getLeftRef());
|
||||||
@@ -201,8 +213,10 @@ public class ConditionNode extends BaseNode {
|
|||||||
return contains(leftValue, rightValue);
|
return contains(leftValue, rightValue);
|
||||||
case "notContains":
|
case "notContains":
|
||||||
return !contains(leftValue, rightValue);
|
return !contains(leftValue, rightValue);
|
||||||
|
case ConditionRuleSupport.OPERATOR_REGEX_MATCH:
|
||||||
|
return ConditionRuleSupport.matchesRegex(leftValue, rule.getRightValue());
|
||||||
default:
|
default:
|
||||||
return false;
|
throw new IllegalArgumentException("不支持的条件操作符: " + operator);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,15 @@ import java.util.List;
|
|||||||
*/
|
*/
|
||||||
public class ConditionNodeParser extends BaseNodeParser<ConditionNode> {
|
public class ConditionNodeParser extends BaseNodeParser<ConditionNode> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 TinyFlow 条件节点配置解析为运行时节点。
|
||||||
|
*
|
||||||
|
* @param root 节点根配置
|
||||||
|
* @param data 节点业务配置
|
||||||
|
* @param tinyflow 工作流配置
|
||||||
|
* @return 条件判断运行时节点
|
||||||
|
* @throws RuntimeException 分支或规则配置无效
|
||||||
|
*/
|
||||||
@Override
|
@Override
|
||||||
protected ConditionNode doParse(JSONObject root, JSONObject data, JSONObject tinyflow) {
|
protected ConditionNode doParse(JSONObject root, JSONObject data, JSONObject tinyflow) {
|
||||||
ConditionNode node = new ConditionNode();
|
ConditionNode node = new ConditionNode();
|
||||||
@@ -32,11 +41,50 @@ public class ConditionNodeParser extends BaseNodeParser<ConditionNode> {
|
|||||||
if (StringUtil.noText(node.getDefaultBranchId())) {
|
if (StringUtil.noText(node.getDefaultBranchId())) {
|
||||||
throw new RuntimeException("条件判断节点必须配置默认分支");
|
throw new RuntimeException("条件判断节点必须配置默认分支");
|
||||||
}
|
}
|
||||||
|
validateBranches(branches);
|
||||||
|
|
||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回条件节点类型名称。
|
||||||
|
*
|
||||||
|
* @return 条件节点类型名称
|
||||||
|
*/
|
||||||
public String getNodeName() {
|
public String getNodeName() {
|
||||||
return "conditionNode";
|
return "conditionNode";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验可视化分支内的全部条件规则。
|
||||||
|
*
|
||||||
|
* @param branches 条件分支
|
||||||
|
* @throws RuntimeException 任一规则配置无效
|
||||||
|
*/
|
||||||
|
private void validateBranches(List<ConditionNode.ConditionBranch> branches) {
|
||||||
|
for (int branchIndex = 0; branchIndex < branches.size(); branchIndex++) {
|
||||||
|
ConditionNode.ConditionBranch branch = branches.get(branchIndex);
|
||||||
|
if (branch == null
|
||||||
|
|| "expression".equalsIgnoreCase(
|
||||||
|
StringUtil.getFirstWithText(branch.getMode(), "visual"))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
List<ConditionNode.ConditionRule> rules = branch.getRules();
|
||||||
|
if (rules == null || rules.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (int ruleIndex = 0; ruleIndex < rules.size(); ruleIndex++) {
|
||||||
|
String error = ConditionRuleSupport.validateRule(rules.get(ruleIndex));
|
||||||
|
if (error != null) {
|
||||||
|
String branchName = StringUtil.getFirstWithText(
|
||||||
|
branch.getLabel(),
|
||||||
|
branch.getId(),
|
||||||
|
"第 " + (branchIndex + 1) + " 个分支");
|
||||||
|
throw new RuntimeException(
|
||||||
|
"条件分支[" + branchName + "]第 " + (ruleIndex + 1)
|
||||||
|
+ " 条规则配置无效: " + error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package tech.easyflow.ai.node;
|
||||||
|
|
||||||
|
import com.easyagents.flow.core.util.StringUtil;
|
||||||
|
import com.google.re2j.Pattern;
|
||||||
|
import com.google.re2j.PatternSyntaxException;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 条件判断节点规则校验与安全正则执行支持。
|
||||||
|
*/
|
||||||
|
public final class ConditionRuleSupport {
|
||||||
|
|
||||||
|
public static final String OPERATOR_REGEX_MATCH = "regexMatch";
|
||||||
|
public static final int MAX_REGEX_LENGTH = 512;
|
||||||
|
|
||||||
|
private static final int MAX_REGEX_CACHE_SIZE = 256;
|
||||||
|
private static final Set<String> SUPPORTED_OPERATORS = Set.of(
|
||||||
|
"eq",
|
||||||
|
"ne",
|
||||||
|
"gt",
|
||||||
|
"gte",
|
||||||
|
"lt",
|
||||||
|
"lte",
|
||||||
|
"isEmpty",
|
||||||
|
"isNotEmpty",
|
||||||
|
"contains",
|
||||||
|
"notContains",
|
||||||
|
OPERATOR_REGEX_MATCH);
|
||||||
|
private static final Map<String, Pattern> REGEX_CACHE =
|
||||||
|
new LinkedHashMap<String, Pattern>(32, 0.75F, true) {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected boolean removeEldestEntry(Map.Entry<String, Pattern> eldest) {
|
||||||
|
return size() > MAX_REGEX_CACHE_SIZE;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 禁止创建工具类实例。
|
||||||
|
*/
|
||||||
|
private ConditionRuleSupport() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验一条可视化条件规则。
|
||||||
|
*
|
||||||
|
* @param rule 条件规则
|
||||||
|
* @return 校验错误;返回 {@code null} 表示通过
|
||||||
|
*/
|
||||||
|
public static String validateRule(ConditionNode.ConditionRule rule) {
|
||||||
|
if (rule == null) {
|
||||||
|
return "条件规则不能为空";
|
||||||
|
}
|
||||||
|
|
||||||
|
String operator = rule.getOperator();
|
||||||
|
if (StringUtil.noText(operator)) {
|
||||||
|
return "条件操作符不能为空";
|
||||||
|
}
|
||||||
|
if (!SUPPORTED_OPERATORS.contains(operator)) {
|
||||||
|
return "不支持的条件操作符: " + operator;
|
||||||
|
}
|
||||||
|
if (!OPERATOR_REGEX_MATCH.equals(operator)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!"fixed".equals(rule.getRightType())) {
|
||||||
|
return "正则表达式必须使用固定值";
|
||||||
|
}
|
||||||
|
return validateRegex(rule.getRightValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验正则表达式的长度和 RE2/J 语法。
|
||||||
|
*
|
||||||
|
* @param regex 正则表达式
|
||||||
|
* @return 校验错误;返回 {@code null} 表示通过
|
||||||
|
*/
|
||||||
|
public static String validateRegex(String regex) {
|
||||||
|
try {
|
||||||
|
requireRegex(regex);
|
||||||
|
return null;
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return e.getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用 RE2/J 在标量左值中查找正则匹配。
|
||||||
|
*
|
||||||
|
* @param leftValue 条件左值
|
||||||
|
* @param regex 正则表达式
|
||||||
|
* @return 任意位置存在匹配时返回 {@code true}
|
||||||
|
* @throws IllegalArgumentException 正则无效或左值类型不支持
|
||||||
|
*/
|
||||||
|
public static boolean matchesRegex(Object leftValue, String regex) {
|
||||||
|
Pattern pattern = requireRegex(regex);
|
||||||
|
if (leftValue == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!(leftValue instanceof CharSequence)
|
||||||
|
&& !(leftValue instanceof Number)
|
||||||
|
&& !(leftValue instanceof Boolean)
|
||||||
|
&& !(leftValue instanceof Character)) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"正则匹配仅支持字符串、数字或布尔值,当前类型: "
|
||||||
|
+ leftValue.getClass().getSimpleName());
|
||||||
|
}
|
||||||
|
return pattern.matcher(String.valueOf(leftValue)).find();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验并获取正则模式。
|
||||||
|
*
|
||||||
|
* @param regex 正则表达式
|
||||||
|
* @return 已编译 RE2/J 模式
|
||||||
|
* @throws IllegalArgumentException 正则为空、过长或语法无效
|
||||||
|
*/
|
||||||
|
private static Pattern requireRegex(String regex) {
|
||||||
|
if (StringUtil.noText(regex)) {
|
||||||
|
throw new IllegalArgumentException("正则表达式不能为空");
|
||||||
|
}
|
||||||
|
if (regex.length() > MAX_REGEX_LENGTH) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"正则表达式不能超过 " + MAX_REGEX_LENGTH + " 个字符");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return compileRegex(regex);
|
||||||
|
} catch (PatternSyntaxException e) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"正则表达式语法错误: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取已编译正则,并以有界 LRU 缓存复用结果。
|
||||||
|
*
|
||||||
|
* @param regex 正则表达式
|
||||||
|
* @return 已编译 RE2/J 模式
|
||||||
|
* @throws PatternSyntaxException 正则语法无效
|
||||||
|
*/
|
||||||
|
private static Pattern compileRegex(String regex) {
|
||||||
|
synchronized (REGEX_CACHE) {
|
||||||
|
Pattern cached = REGEX_CACHE.get(regex);
|
||||||
|
if (cached != null) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
Pattern compiled = Pattern.compile(regex);
|
||||||
|
REGEX_CACHE.put(regex, compiled);
|
||||||
|
return compiled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import org.junit.Test;
|
|||||||
import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckResult;
|
import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckResult;
|
||||||
import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage;
|
import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage;
|
||||||
import tech.easyflow.ai.entity.Workflow;
|
import tech.easyflow.ai.entity.Workflow;
|
||||||
|
import tech.easyflow.ai.node.ConditionNodeParser;
|
||||||
import tech.easyflow.ai.node.MakeFileNodeParser;
|
import tech.easyflow.ai.node.MakeFileNodeParser;
|
||||||
import tech.easyflow.ai.node.SearchDatasetNodeParser;
|
import tech.easyflow.ai.node.SearchDatasetNodeParser;
|
||||||
import tech.easyflow.ai.node.WorkflowNodeParser;
|
import tech.easyflow.ai.node.WorkflowNodeParser;
|
||||||
@@ -21,6 +22,92 @@ import java.util.Map;
|
|||||||
|
|
||||||
public class WorkflowCheckServiceTest {
|
public class WorkflowCheckServiceTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证保存阶段接受合法的正则条件规则。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void testSaveShouldPassValidRegexCondition() throws Exception {
|
||||||
|
WorkflowCheckService service = newService(new HashMap<>());
|
||||||
|
String content = workflowJson(
|
||||||
|
array(node(
|
||||||
|
"condition-1",
|
||||||
|
"conditionNode",
|
||||||
|
null,
|
||||||
|
conditionData("regexMatch", "fixed", "(?i)^[a-z]+-\\d+$"))),
|
||||||
|
new JSONArray());
|
||||||
|
|
||||||
|
WorkflowCheckResult result = service.checkContent(
|
||||||
|
content, WorkflowCheckStage.SAVE, null);
|
||||||
|
|
||||||
|
Assert.assertTrue(result.isPassed());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证保存阶段拒绝 RE2/J 不支持的正则语法。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void testSaveShouldBlockUnsupportedRegexSyntax() throws Exception {
|
||||||
|
WorkflowCheckService service = newService(new HashMap<>());
|
||||||
|
String content = workflowJson(
|
||||||
|
array(node(
|
||||||
|
"condition-1",
|
||||||
|
"conditionNode",
|
||||||
|
null,
|
||||||
|
conditionData("regexMatch", "fixed", "(?=VIP)VIP"))),
|
||||||
|
new JSONArray());
|
||||||
|
|
||||||
|
WorkflowCheckResult result = service.checkContent(
|
||||||
|
content, WorkflowCheckStage.SAVE, null);
|
||||||
|
|
||||||
|
Assert.assertFalse(result.isPassed());
|
||||||
|
assertHasCode(result, "CONDITION_RULE_INVALID");
|
||||||
|
Assert.assertTrue(result.getIssues().stream()
|
||||||
|
.anyMatch(issue -> issue.getMessage().contains("VIP 分支")
|
||||||
|
&& issue.getMessage().contains("第 1 条")));
|
||||||
|
|
||||||
|
WorkflowCheckResult preExecuteResult = service.checkContent(
|
||||||
|
content, WorkflowCheckStage.PRE_EXECUTE, null);
|
||||||
|
Assert.assertFalse(preExecuteResult.isPassed());
|
||||||
|
assertHasCode(preExecuteResult, "CONDITION_RULE_INVALID");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证保存阶段拒绝变量正则和未知操作符。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void testSaveShouldBlockDynamicRegexAndUnknownOperator() throws Exception {
|
||||||
|
WorkflowCheckService service = newService(new HashMap<>());
|
||||||
|
String content = workflowJson(
|
||||||
|
array(
|
||||||
|
node(
|
||||||
|
"condition-ref",
|
||||||
|
"conditionNode",
|
||||||
|
null,
|
||||||
|
conditionData(
|
||||||
|
"regexMatch",
|
||||||
|
"ref",
|
||||||
|
"start.regex")),
|
||||||
|
node(
|
||||||
|
"condition-unknown",
|
||||||
|
"conditionNode",
|
||||||
|
null,
|
||||||
|
conditionData(
|
||||||
|
"unknown",
|
||||||
|
"fixed",
|
||||||
|
"VIP"))),
|
||||||
|
new JSONArray());
|
||||||
|
|
||||||
|
WorkflowCheckResult result = service.checkContent(
|
||||||
|
content, WorkflowCheckStage.SAVE, null);
|
||||||
|
|
||||||
|
Assert.assertFalse(result.isPassed());
|
||||||
|
Assert.assertEquals(
|
||||||
|
2,
|
||||||
|
result.getIssues().stream()
|
||||||
|
.filter(issue -> "CONDITION_RULE_INVALID".equals(issue.getCode()))
|
||||||
|
.count());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证普通节点循环次数必须处于 1~300。
|
* 验证普通节点循环次数必须处于 1~300。
|
||||||
*/
|
*/
|
||||||
@@ -579,6 +666,7 @@ public class WorkflowCheckServiceTest {
|
|||||||
parser.addNodeParser("workflow-node", new WorkflowNodeParser());
|
parser.addNodeParser("workflow-node", new WorkflowNodeParser());
|
||||||
parser.addNodeParser("search-dataset-node", new SearchDatasetNodeParser());
|
parser.addNodeParser("search-dataset-node", new SearchDatasetNodeParser());
|
||||||
parser.addNodeParser("make-file", new MakeFileNodeParser());
|
parser.addNodeParser("make-file", new MakeFileNodeParser());
|
||||||
|
parser.addNodeParser("conditionNode", new ConditionNodeParser());
|
||||||
setField(service, "chainParser", parser);
|
setField(service, "chainParser", parser);
|
||||||
setField(service, "workflowService", mockWorkflowService(workflowStore));
|
setField(service, "workflowService", mockWorkflowService(workflowStore));
|
||||||
setField(service, "workflowDatacenterContentService", new WorkflowDatacenterContentService());
|
setField(service, "workflowDatacenterContentService", new WorkflowDatacenterContentService());
|
||||||
@@ -680,6 +768,49 @@ public class WorkflowCheckServiceTest {
|
|||||||
return node(id, "search-dataset-node", parentId, data);
|
return node(id, "search-dataset-node", parentId, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建包含一个普通分支和 Else 分支的条件节点配置。
|
||||||
|
*
|
||||||
|
* @param operator 条件操作符
|
||||||
|
* @param rightType 右值类型
|
||||||
|
* @param rightValue 右值
|
||||||
|
* @return 条件节点数据
|
||||||
|
*/
|
||||||
|
private static JSONObject conditionData(
|
||||||
|
String operator,
|
||||||
|
String rightType,
|
||||||
|
String rightValue) {
|
||||||
|
JSONObject rule = new JSONObject();
|
||||||
|
rule.put("id", "rule-1");
|
||||||
|
rule.put("joiner", "AND");
|
||||||
|
rule.put("leftRef", "start.value");
|
||||||
|
rule.put("operator", operator);
|
||||||
|
rule.put("rightType", rightType);
|
||||||
|
rule.put("rightValue", rightValue);
|
||||||
|
if ("ref".equals(rightType)) {
|
||||||
|
rule.put("rightRef", rightValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
JSONObject branch = new JSONObject();
|
||||||
|
branch.put("id", "branch-vip");
|
||||||
|
branch.put("label", "VIP 分支");
|
||||||
|
branch.put("mode", "visual");
|
||||||
|
branch.put("rules", array(rule));
|
||||||
|
|
||||||
|
JSONObject defaultBranch = new JSONObject();
|
||||||
|
defaultBranch.put("id", "branch-else");
|
||||||
|
defaultBranch.put("label", "Else");
|
||||||
|
defaultBranch.put("mode", "visual");
|
||||||
|
defaultBranch.put("rules", new JSONArray());
|
||||||
|
|
||||||
|
JSONObject data = data("条件判断");
|
||||||
|
data.put("branchMode", "first_match");
|
||||||
|
data.put("branches", array(branch, defaultBranch));
|
||||||
|
data.put("defaultBranchId", "branch-else");
|
||||||
|
data.put("defaultBranchLabel", "Else");
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建显式循环节点数据。
|
* 创建显式循环节点数据。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -150,6 +150,117 @@ public class ConditionNodeTest {
|
|||||||
Assert.assertEquals(false, result.get("matchedByDefault"));
|
Assert.assertEquals(false, result.get("matchedByDefault"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证正则默认查找匹配,并支持锚点和内联大小写标志。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void testRegexMatchFindAnchorsAndInlineFlag() {
|
||||||
|
ConditionNode node = new ConditionNode();
|
||||||
|
ConditionNode.ConditionBranch hit = visualBranch(
|
||||||
|
"branch_regex",
|
||||||
|
"正则分支",
|
||||||
|
visualRule(
|
||||||
|
"ctx.orderCode",
|
||||||
|
ConditionRuleSupport.OPERATOR_REGEX_MATCH,
|
||||||
|
"fixed",
|
||||||
|
"(?i)[a-z]+-\\d+",
|
||||||
|
null));
|
||||||
|
ConditionNode.ConditionBranch def = defaultBranch("branch_default", "默认分支");
|
||||||
|
node.setBranches(Arrays.asList(hit, def));
|
||||||
|
node.setDefaultBranchId(def.getId());
|
||||||
|
node.setDefaultBranchLabel(def.getLabel());
|
||||||
|
|
||||||
|
Map<String, Object> partialResult = node.execute(
|
||||||
|
createChain(Map.of("ctx", Map.of("orderCode", "订单ABC-123完成"))));
|
||||||
|
Assert.assertEquals("branch_regex", partialResult.get("matchedBranchId"));
|
||||||
|
|
||||||
|
hit.setRules(Collections.singletonList(
|
||||||
|
visualRule(
|
||||||
|
"ctx.orderCode",
|
||||||
|
ConditionRuleSupport.OPERATOR_REGEX_MATCH,
|
||||||
|
"fixed",
|
||||||
|
"(?i)^[a-z]+-\\d+$",
|
||||||
|
null)));
|
||||||
|
Map<String, Object> anchoredMiss = node.execute(
|
||||||
|
createChain(Map.of("ctx", Map.of("orderCode", "订单ABC-123完成"))));
|
||||||
|
Assert.assertEquals("branch_default", anchoredMiss.get("matchedBranchId"));
|
||||||
|
|
||||||
|
Map<String, Object> anchoredHit = node.execute(
|
||||||
|
createChain(Map.of("ctx", Map.of("orderCode", "ABC-123"))));
|
||||||
|
Assert.assertEquals("branch_regex", anchoredHit.get("matchedBranchId"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证正则支持数字和布尔标量,并覆盖语法和长度边界。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void testRegexMatchScalarAndValidationBoundaries() {
|
||||||
|
Assert.assertTrue(ConditionRuleSupport.matchesRegex(123, "^12\\d$"));
|
||||||
|
Assert.assertTrue(ConditionRuleSupport.matchesRegex(true, "^true$"));
|
||||||
|
Assert.assertFalse(ConditionRuleSupport.matchesRegex(null, ".*"));
|
||||||
|
Assert.assertTrue(
|
||||||
|
ConditionRuleSupport.validateRegex(" ")
|
||||||
|
.contains("不能为空"));
|
||||||
|
Assert.assertTrue(
|
||||||
|
ConditionRuleSupport.validateRegex("(a)\\1")
|
||||||
|
.contains("语法错误"));
|
||||||
|
Assert.assertTrue(
|
||||||
|
ConditionRuleSupport.validateRegex(
|
||||||
|
"a".repeat(ConditionRuleSupport.MAX_REGEX_LENGTH + 1))
|
||||||
|
.contains(String.valueOf(ConditionRuleSupport.MAX_REGEX_LENGTH)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证正则拒绝集合左值并返回可定位错误。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void testRegexMatchShouldRejectCollectionValue() {
|
||||||
|
ConditionNode node = new ConditionNode();
|
||||||
|
ConditionNode.ConditionBranch hit = visualBranch(
|
||||||
|
"branch_regex",
|
||||||
|
"正则分支",
|
||||||
|
visualRule(
|
||||||
|
"ctx.values",
|
||||||
|
ConditionRuleSupport.OPERATOR_REGEX_MATCH,
|
||||||
|
"fixed",
|
||||||
|
"\\d+",
|
||||||
|
null));
|
||||||
|
ConditionNode.ConditionBranch def = defaultBranch("branch_default", "默认分支");
|
||||||
|
node.setBranches(Arrays.asList(hit, def));
|
||||||
|
node.setDefaultBranchId(def.getId());
|
||||||
|
|
||||||
|
ChainException exception = Assert.assertThrows(
|
||||||
|
ChainException.class,
|
||||||
|
() -> node.execute(createChain(
|
||||||
|
Map.of("ctx", Map.of("values", Arrays.asList(1, 2))))));
|
||||||
|
|
||||||
|
Assert.assertTrue(exception.getMessage().contains("正则分支"));
|
||||||
|
Assert.assertTrue(exception.getMessage().contains("规则[1]"));
|
||||||
|
Assert.assertTrue(exception.getMessage().contains("仅支持字符串、数字或布尔值"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证未知条件操作符不会静默进入默认分支。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void testUnknownOperatorShouldFailExplicitly() {
|
||||||
|
ConditionNode node = new ConditionNode();
|
||||||
|
ConditionNode.ConditionBranch hit = visualBranch(
|
||||||
|
"branch_invalid",
|
||||||
|
"异常分支",
|
||||||
|
visualRule("ctx.value", "unknown", "fixed", "x", null));
|
||||||
|
ConditionNode.ConditionBranch def = defaultBranch("branch_default", "默认分支");
|
||||||
|
node.setBranches(Arrays.asList(hit, def));
|
||||||
|
node.setDefaultBranchId(def.getId());
|
||||||
|
|
||||||
|
ChainException exception = Assert.assertThrows(
|
||||||
|
ChainException.class,
|
||||||
|
() -> node.execute(createChain(Map.of("ctx", Map.of("value", "x")))));
|
||||||
|
|
||||||
|
Assert.assertTrue(exception.getMessage().contains("不支持的条件操作符"));
|
||||||
|
Assert.assertTrue(exception.getMessage().contains("异常分支"));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void testManagedEdgeConditionRouting() {
|
public void testManagedEdgeConditionRouting() {
|
||||||
ConditionNode node = new ConditionNode();
|
ConditionNode node = new ConditionNode();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<svelte:options customElement={{ props: {} }} />
|
<svelte:options customElement={{ props: {} }} />
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import {onDestroy} from 'svelte';
|
||||||
import NodeWrapper from '../core/NodeWrapper.svelte';
|
import NodeWrapper from '../core/NodeWrapper.svelte';
|
||||||
import {
|
import {
|
||||||
type Edge,
|
type Edge,
|
||||||
@@ -10,9 +11,13 @@
|
|||||||
useSvelteFlow,
|
useSvelteFlow,
|
||||||
useUpdateNodeInternals
|
useUpdateNodeInternals
|
||||||
} from '@xyflow/svelte';
|
} from '@xyflow/svelte';
|
||||||
import {Button, Input, MixedInput, Select} from '../base';
|
import {Button, Input, MixedInput, Select, Textarea} from '../base';
|
||||||
import ParamTokenEditor from '../core/ParamTokenEditor.svelte';
|
import ParamTokenEditor from '../core/ParamTokenEditor.svelte';
|
||||||
import {getCurrentNodeId} from '#components/utils/NodeUtils';
|
import {getCurrentNodeId} from '#components/utils/NodeUtils';
|
||||||
|
import {
|
||||||
|
CONDITION_REGEX_MAX_LENGTH,
|
||||||
|
getConditionRegexError
|
||||||
|
} from '#components/utils/conditionRegex';
|
||||||
import {genShortId} from '../utils/IdGen';
|
import {genShortId} from '../utils/IdGen';
|
||||||
import {deepEqual} from '../utils/deepEqual';
|
import {deepEqual} from '../utils/deepEqual';
|
||||||
import {useRefOptions} from '#components/utils/useRefOptions.svelte';
|
import {useRefOptions} from '#components/utils/useRefOptions.svelte';
|
||||||
@@ -32,7 +37,8 @@
|
|||||||
| 'isEmpty'
|
| 'isEmpty'
|
||||||
| 'isNotEmpty'
|
| 'isNotEmpty'
|
||||||
| 'contains'
|
| 'contains'
|
||||||
| 'notContains';
|
| 'notContains'
|
||||||
|
| 'regexMatch';
|
||||||
|
|
||||||
type RuleJoiner = 'AND' | 'OR';
|
type RuleJoiner = 'AND' | 'OR';
|
||||||
type BranchMode = 'visual' | 'expression';
|
type BranchMode = 'visual' | 'expression';
|
||||||
@@ -72,8 +78,11 @@
|
|||||||
{ value: 'isEmpty', label: '为空' },
|
{ value: 'isEmpty', label: '为空' },
|
||||||
{ value: 'isNotEmpty', label: '不为空' },
|
{ value: 'isNotEmpty', label: '不为空' },
|
||||||
{ value: 'contains', label: '包含' },
|
{ value: 'contains', label: '包含' },
|
||||||
{ value: 'notContains', label: '不包含' }
|
{ value: 'notContains', label: '不包含' },
|
||||||
|
{ value: 'regexMatch', label: '匹配正则' }
|
||||||
] as const;
|
] as const;
|
||||||
|
const REGEX_HELP =
|
||||||
|
'正则默认匹配文本中的任意位置;完整匹配请使用 ^ 和 $。支持常用正则语法,不支持环视和反向引用。';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
@@ -210,6 +219,8 @@
|
|||||||
return !['isEmpty', 'isNotEmpty'].includes(operator);
|
return !['isEmpty', 'isNotEmpty'].includes(operator);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isRegexOperator = (operator: ConditionOperator) => operator === 'regexMatch';
|
||||||
|
|
||||||
const branchKeyword = (branch: ConditionBranch, index: number, defaultId: string) => {
|
const branchKeyword = (branch: ConditionBranch, index: number, defaultId: string) => {
|
||||||
if (branch.id === defaultId) {
|
if (branch.id === defaultId) {
|
||||||
return 'Else';
|
return 'Else';
|
||||||
@@ -435,10 +446,19 @@
|
|||||||
[key]: value
|
[key]: value
|
||||||
};
|
};
|
||||||
|
|
||||||
if (key === 'operator' && !isOperatorNeedRight(value as ConditionOperator)) {
|
if (key === 'operator') {
|
||||||
nextRule.rightType = 'fixed';
|
const nextOperator = value as ConditionOperator;
|
||||||
nextRule.rightRef = '';
|
if (isRegexOperator(nextOperator)) {
|
||||||
nextRule.rightValue = '';
|
nextRule.rightType = 'fixed';
|
||||||
|
nextRule.rightRef = '';
|
||||||
|
if (rule.rightType === 'ref') {
|
||||||
|
nextRule.rightValue = '';
|
||||||
|
}
|
||||||
|
} else if (!isOperatorNeedRight(nextOperator)) {
|
||||||
|
nextRule.rightType = 'fixed';
|
||||||
|
nextRule.rightRef = '';
|
||||||
|
nextRule.rightValue = '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (key === 'rightValue') {
|
if (key === 'rightValue') {
|
||||||
@@ -536,6 +556,22 @@
|
|||||||
return branches.filter((branch) => branch.id !== defaultBranchId).length;
|
return branches.filter((branch) => branch.id !== defaultBranchId).length;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let nodeInternalsFrame: number | undefined;
|
||||||
|
const scheduleNodeInternalsUpdate = () => {
|
||||||
|
if (nodeInternalsFrame !== undefined) {
|
||||||
|
cancelAnimationFrame(nodeInternalsFrame);
|
||||||
|
}
|
||||||
|
nodeInternalsFrame = requestAnimationFrame(() => {
|
||||||
|
nodeInternalsFrame = undefined;
|
||||||
|
updateNodeInternals(currentNodeId);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
onDestroy(() => {
|
||||||
|
if (nodeInternalsFrame !== undefined) {
|
||||||
|
cancelAnimationFrame(nodeInternalsFrame);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const missingBranchLabels = $derived.by(() => {
|
const missingBranchLabels = $derived.by(() => {
|
||||||
const edges = store.getEdges();
|
const edges = store.getEdges();
|
||||||
const connected = new Set<string>();
|
const connected = new Set<string>();
|
||||||
@@ -622,7 +658,21 @@
|
|||||||
|
|
||||||
<div class="condition-node-content">
|
<div class="condition-node-content">
|
||||||
<div class="condition-rail-head">
|
<div class="condition-rail-head">
|
||||||
<div class="condition-rail-title">分支出口</div>
|
<div class="condition-rail-title-group">
|
||||||
|
<div class="condition-rail-title">分支出口</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="condition-regex-help nodrag"
|
||||||
|
data-help={REGEX_HELP}
|
||||||
|
aria-label="正则匹配说明"
|
||||||
|
aria-describedby={`condition-regex-help-${currentNodeId}`}
|
||||||
|
>
|
||||||
|
i
|
||||||
|
</button>
|
||||||
|
<span id={`condition-regex-help-${currentNodeId}`} class="condition-sr-only">
|
||||||
|
{REGEX_HELP}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<Button class="condition-icon-btn" title="新增分支" onclick={addBranch}>
|
<Button class="condition-icon-btn" title="新增分支" onclick={addBranch}>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M11 11V5H13V11H19V13H13V19H11V13H5V11H11Z"></path>
|
<path d="M11 11V5H13V11H19V13H13V19H11V13H5V11H11Z"></path>
|
||||||
@@ -740,7 +790,14 @@
|
|||||||
>OR</button>
|
>OR</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="condition-simple-rule-row">
|
{@const regexError = isRegexOperator(rule.operator)
|
||||||
|
? getConditionRegexError(rule.rightValue)
|
||||||
|
: ''}
|
||||||
|
{@const regexErrorId = `condition-regex-error-${currentNodeId}-${rule.id}`}
|
||||||
|
<div
|
||||||
|
class:condition-regex-rule={isRegexOperator(rule.operator)}
|
||||||
|
class="condition-simple-rule-row"
|
||||||
|
>
|
||||||
<Select
|
<Select
|
||||||
items={refOptions.current}
|
items={refOptions.current}
|
||||||
variant="reference"
|
variant="reference"
|
||||||
@@ -755,7 +812,53 @@
|
|||||||
value={[rule.operator]}
|
value={[rule.operator]}
|
||||||
onSelect={(item) => updateRuleField(activeBranch.id, rule.id, 'operator', item.value)}
|
onSelect={(item) => updateRuleField(activeBranch.id, rule.id, 'operator', item.value)}
|
||||||
/>
|
/>
|
||||||
{#if isOperatorNeedRight(rule.operator)}
|
{#if isRegexOperator(rule.operator)}
|
||||||
|
<div class="condition-regex-field">
|
||||||
|
<Textarea
|
||||||
|
value={rule.rightValue}
|
||||||
|
rows={3}
|
||||||
|
maxHeight={112}
|
||||||
|
maxlength={CONDITION_REGEX_MAX_LENGTH}
|
||||||
|
placeholder="输入正则表达式"
|
||||||
|
aria-label="正则表达式"
|
||||||
|
aria-invalid={regexError ? 'true' : undefined}
|
||||||
|
aria-describedby={regexErrorId}
|
||||||
|
class="condition-regex-textarea"
|
||||||
|
onHeightChange={scheduleNodeInternalsUpdate}
|
||||||
|
oninput={(event: Event) => updateRuleField(
|
||||||
|
activeBranch.id,
|
||||||
|
rule.id,
|
||||||
|
'rightValue',
|
||||||
|
(event.target as HTMLTextAreaElement).value
|
||||||
|
)}
|
||||||
|
onchange={(event: Event) => updateRuleField(
|
||||||
|
activeBranch.id,
|
||||||
|
rule.id,
|
||||||
|
'rightValue',
|
||||||
|
(event.target as HTMLTextAreaElement).value
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
id={regexErrorId}
|
||||||
|
class:visible={Boolean(regexError)}
|
||||||
|
class="condition-regex-error"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
{regexError}
|
||||||
|
</div>
|
||||||
|
{#if activeBranch.rules!.length > 1}
|
||||||
|
<Button
|
||||||
|
class="condition-rule-remove-btn"
|
||||||
|
title="删除条件"
|
||||||
|
onclick={() => updateBranchField(activeBranch.id, 'removeRule', rule.id)}
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12 2C6.47 2 2 6.47 2 12C2 17.53 6.47 22 12 22C17.53 22 22 17.53 22 12C22 6.47 17.53 2 12 2ZM17 13H7V11H17V13Z"></path>
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else if isOperatorNeedRight(rule.operator)}
|
||||||
<div class="condition-right-unified-box">
|
<div class="condition-right-unified-box">
|
||||||
<MixedInput
|
<MixedInput
|
||||||
type={rule.rightType}
|
type={rule.rightType}
|
||||||
@@ -846,6 +949,81 @@
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.condition-rail-title-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.condition-regex-help {
|
||||||
|
position: relative;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--tf-border-color-strong);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--tf-bg-surface);
|
||||||
|
color: var(--tf-text-secondary);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 14px;
|
||||||
|
text-align: center;
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
|
.condition-regex-help:hover,
|
||||||
|
.condition-regex-help:focus-visible {
|
||||||
|
border-color: var(--tf-primary-color);
|
||||||
|
color: var(--tf-primary-color);
|
||||||
|
background: var(--tf-primary-soft-bg);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.condition-regex-help:focus-visible {
|
||||||
|
box-shadow: 0 0 0 2px var(--tf-primary-soft-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.condition-regex-help::after {
|
||||||
|
content: attr(data-help);
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
width: 300px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--tf-tip-bg);
|
||||||
|
color: var(--tf-tip-text);
|
||||||
|
box-shadow: var(--tf-shadow-medium);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.5;
|
||||||
|
text-align: left;
|
||||||
|
white-space: normal;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 120;
|
||||||
|
}
|
||||||
|
|
||||||
|
.condition-regex-help:hover::after,
|
||||||
|
.condition-regex-help:focus-visible::after {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.condition-sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.condition-branch-rail {
|
.condition-branch-rail {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1110,13 +1288,18 @@
|
|||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.condition-simple-rule-row.condition-regex-rule {
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
.condition-simple-rule-row > * {
|
.condition-simple-rule-row > * {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.condition-simple-rule-row .tf-select),
|
:global(.condition-simple-rule-row .tf-select),
|
||||||
:global(.condition-simple-rule-row .tf-mixed-input-root) {
|
:global(.condition-simple-rule-row .tf-mixed-input-root),
|
||||||
|
:global(.condition-simple-rule-row .tf-textarea) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
@@ -1131,10 +1314,45 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:global(.condition-simple-rule-row .tf-select-input:hover),
|
:global(.condition-simple-rule-row .tf-select-input:hover),
|
||||||
:global(.condition-simple-rule-row .tf-mixed-input-root:hover) {
|
:global(.condition-simple-rule-row .tf-mixed-input-root:hover),
|
||||||
|
:global(.condition-simple-rule-row .tf-textarea:hover) {
|
||||||
border-color: var(--tf-border-color-strong);
|
border-color: var(--tf-border-color-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.condition-regex-field {
|
||||||
|
position: relative;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.condition-regex-textarea) {
|
||||||
|
min-height: 72px;
|
||||||
|
max-height: 112px;
|
||||||
|
resize: none;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.condition-regex-textarea[aria-invalid='true']) {
|
||||||
|
border-color: var(--tf-danger-color);
|
||||||
|
box-shadow: 0 0 0 1px var(--tf-danger-soft-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.condition-regex-error {
|
||||||
|
min-height: 18px;
|
||||||
|
padding-top: 4px;
|
||||||
|
color: transparent;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 14px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.condition-regex-error.visible {
|
||||||
|
color: var(--tf-danger-soft-text);
|
||||||
|
}
|
||||||
|
|
||||||
:global(.condition-icon-btn) {
|
:global(.condition-icon-btn) {
|
||||||
width: 26px;
|
width: 26px;
|
||||||
height: 26px;
|
height: 26px;
|
||||||
@@ -1208,6 +1426,10 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.condition-regex-field {
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.condition-editor-head {
|
.condition-editor-head {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CONDITION_REGEX_MAX_LENGTH,
|
||||||
|
getConditionRegexError,
|
||||||
|
} from './conditionRegex';
|
||||||
|
|
||||||
|
describe('condition regex validation', () => {
|
||||||
|
it('接受常用语法、锚点和安全内联标志', () => {
|
||||||
|
expect(getConditionRegexError('(?i)^[a-z]+-\\d+$')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('拒绝空值和超长表达式', () => {
|
||||||
|
expect(getConditionRegexError(' ')).toBe('请输入正则表达式');
|
||||||
|
expect(
|
||||||
|
getConditionRegexError('a'.repeat(CONDITION_REGEX_MAX_LENGTH + 1)),
|
||||||
|
).toContain(String(CONDITION_REGEX_MAX_LENGTH));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('不使用浏览器正则语法拦截服务端负责的语法校验', () => {
|
||||||
|
expect(getConditionRegexError('VIP(?=用户)')).toBe('');
|
||||||
|
expect(getConditionRegexError('(VIP)-\\1')).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
export const CONDITION_REGEX_MAX_LENGTH = 512;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回条件节点正则输入可即时确认的错误。
|
||||||
|
*
|
||||||
|
* 完整语法由后端 RE2/J 校验,避免浏览器正则语法差异误拦截有效配置。
|
||||||
|
*/
|
||||||
|
export const getConditionRegexError = (value: unknown) => {
|
||||||
|
const regex = String(value ?? '');
|
||||||
|
if (!regex.trim()) {
|
||||||
|
return '请输入正则表达式';
|
||||||
|
}
|
||||||
|
if (regex.length > CONDITION_REGEX_MAX_LENGTH) {
|
||||||
|
return `正则表达式不能超过 ${CONDITION_REGEX_MAX_LENGTH} 个字符`;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
};
|
||||||
7
pom.xml
7
pom.xml
@@ -50,6 +50,7 @@
|
|||||||
<netty.version>4.1.130.Final</netty.version>
|
<netty.version>4.1.130.Final</netty.version>
|
||||||
<proguard.version>7.9.1</proguard.version>
|
<proguard.version>7.9.1</proguard.version>
|
||||||
<proguard.maven.plugin.version>2.7.0</proguard.maven.plugin.version>
|
<proguard.maven.plugin.version>2.7.0</proguard.maven.plugin.version>
|
||||||
|
<re2j.version>1.8</re2j.version>
|
||||||
</properties>
|
</properties>
|
||||||
<dependencyManagement>
|
<dependencyManagement>
|
||||||
<dependencies>
|
<dependencies>
|
||||||
@@ -61,6 +62,12 @@
|
|||||||
<scope>compile</scope>
|
<scope>compile</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.google.re2j</groupId>
|
||||||
|
<artifactId>re2j</artifactId>
|
||||||
|
<version>${re2j.version}</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.apache.httpcomponents.client5</groupId>
|
<groupId>org.apache.httpcomponents.client5</groupId>
|
||||||
<artifactId>httpclient5</artifactId>
|
<artifactId>httpclient5</artifactId>
|
||||||
|
|||||||
Reference in New Issue
Block a user