feat(condition): CONDITION 判断方式二选一——EXPRESSION 内置 Aviator 表达式(沙箱)+ SERVICE 原口径;JudgeBody 补 submitterId
- ExpressionEvaluator:Aviator 5.4.3 沙箱(FEATURE_SET 只留 If、ALLOWED_CLASS_SET 空=禁类访问/反射)、编译缓存、 顶层变量缺失抛错挂起(Aviator 缺失变量按 nil 且 nil 视作最小值,<= 兜底出口会静默放行——宁挂起不误放行) - ConditionRouteService.decide 分流:EXPRESSION 按出边顺序取首个 true(逐边结果摘要落 raw_response), 全 false/异常挂起沿用 SERVICE 纪律(route_log outlet=NULL + EXPRESSION 常量 + 告警 + 管理端重跑) - DefValidator 发布期分层校验:EXPRESSION 出口缺条件/语法错/违禁表达式硬拦;表单变量放行(运行时核对); 内置变量疑似拼错 warn;judgeMode 非法值拦 - /admin/router/eval 调试端点(同一求值核心,不落库不走流程) - 单测 22 新增 + 全量 170 绿;E2E 18/18(发布/分支选路/挂起留痕/重跑/调试)见 local-debug/four-topics-a-e2e.py
This commit is contained in:
@@ -70,6 +70,14 @@
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- 条件路由表达式引擎(2026-10-10 四议题):CONDITION 节点 judgeMode=EXPRESSION 时
|
||||
出边 condition 用 AviatorScript 求值(JDK8 兼容;5.4.3 含安全修复,沙箱见 ExpressionEvaluator) -->
|
||||
<dependency>
|
||||
<groupId>com.googlecode.aviator</groupId>
|
||||
<artifactId>aviator</artifactId>
|
||||
<version>5.4.3</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
|
||||
@@ -49,6 +49,7 @@ public class FlowAdminController {
|
||||
private final FlwInstanceRepository instanceRepository;
|
||||
private final FlowDiagramService diagramService;
|
||||
private final com.yunhai.flowcenter.service.DiagramImageService imageService;
|
||||
private final com.fasterxml.jackson.databind.ObjectMapper objectMapper;
|
||||
|
||||
// ==================== 流程定义 ====================
|
||||
|
||||
@@ -288,6 +289,47 @@ public class FlowAdminController {
|
||||
routerAdminService.reroute(adminGuard.requireAdmin(user, null), id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 表达式调试求值(2026-10-10 四议题):body {expression, variablesJson},与运行时同一求值核心
|
||||
* (ExpressionEvaluator);不落库、不走流程,发布前/挂起排查用。内置变量需在 variablesJson 里
|
||||
* 自行提供样例值(线上是引擎注入的办件上下文)。
|
||||
*/
|
||||
@PostMapping("/router/eval")
|
||||
public Map<String, Object> routerEval(BoaUser user, @RequestBody Map<String, Object> body) {
|
||||
adminGuard.requireAdmin(user, null);
|
||||
String expression = body.get("expression") == null ? "" : String.valueOf(body.get("expression")).trim();
|
||||
String variablesJson = body.get("variablesJson") == null ? "" : String.valueOf(body.get("variablesJson")).trim();
|
||||
Map<String, Object> result = new java.util.HashMap<>();
|
||||
if (expression.isEmpty()) {
|
||||
result.put("result", false);
|
||||
result.put("error", "表达式为空");
|
||||
return result;
|
||||
}
|
||||
Map<String, Object> env;
|
||||
if (variablesJson.isEmpty()) {
|
||||
env = new java.util.HashMap<>();
|
||||
} else {
|
||||
try {
|
||||
env = objectMapper.readValue(variablesJson,
|
||||
new com.fasterxml.jackson.core.type.TypeReference<Map<String, Object>>() {
|
||||
});
|
||||
} catch (Exception e) {
|
||||
result.put("result", false);
|
||||
result.put("error", "样例变量不是合法 JSON 对象:" + e.getMessage());
|
||||
return result;
|
||||
}
|
||||
}
|
||||
try {
|
||||
Object value = com.yunhai.flowcenter.engine.ExpressionEvaluator.evaluate(expression, env);
|
||||
result.put("result", Boolean.TRUE.equals(value));
|
||||
result.put("value", String.valueOf(value));
|
||||
} catch (Exception e) {
|
||||
result.put("result", false);
|
||||
result.put("error", String.valueOf(e.getMessage() != null ? e.getMessage() : e.toString()));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ==================== 办件查询 ====================
|
||||
|
||||
@GetMapping("/instance/page")
|
||||
|
||||
@@ -42,6 +42,9 @@ public class ConditionRouteService {
|
||||
*/
|
||||
public String decide(FlwInstance instance, FlowDefModel model, FlowDefModel.Node node) {
|
||||
List<String> outlets = outletNames(model, node);
|
||||
if (node.expressionJudge()) {
|
||||
return decideByExpression(instance, model, node, outlets);
|
||||
}
|
||||
FlwRouter router = routerRepository.findByRouterKey(node.routerKey).orElse(null);
|
||||
if (router == null || router.getStatus() == null || router.getStatus() != 1) {
|
||||
log.error("[flow-center] 路由判断失败:routerKey={} 未注册或已停用(挂起):instance={} node={}",
|
||||
@@ -50,7 +53,8 @@ public class ConditionRouteService {
|
||||
}
|
||||
RouteJudgeClient.JudgeBody body = new RouteJudgeClient.JudgeBody(
|
||||
instance.getSchId(), instance.getId(), instance.getVersion(),
|
||||
instance.getBizType(), instance.getBizId(), node.id, node.name,
|
||||
instance.getBizType(), instance.getBizId(), instance.getSubmitterId(),
|
||||
node.id, node.name,
|
||||
node.routerKey, outlets, parseVariables(instance.getFormDataJson()));
|
||||
try {
|
||||
RouteJudgeClient.JudgeResult result = judgeClient.call(router, body);
|
||||
@@ -60,7 +64,7 @@ public class ConditionRouteService {
|
||||
return persistFailure(instance, node, outlets,
|
||||
"返回出口未命中 outlets:" + result.outlet + ";raw=" + result.rawResponse);
|
||||
}
|
||||
persist(instance, node, result.outlet, result.rawResponse);
|
||||
persist(instance, node, node.routerKey, result.outlet, result.rawResponse);
|
||||
log.info("[flow-center] 路由判断命中:instance={} node={} router={} outlet={}",
|
||||
instance.getId(), node.id, node.routerKey, result.outlet);
|
||||
return result.outlet;
|
||||
@@ -71,6 +75,54 @@ public class ConditionRouteService {
|
||||
}
|
||||
}
|
||||
|
||||
/** EXPRESSION 模式留痕的 router_key 快照(真实注册键不适用;列 NOT NULL) */
|
||||
static final String EXPRESSION_ROUTER_KEY = "EXPRESSION";
|
||||
|
||||
/**
|
||||
* EXPRESSION 模式(2026-10-10 四议题):按出边定义顺序对 condition 求值,首个 true 的出口名
|
||||
* 即结果;逐边结果摘要落 raw_response。全 false / 求值异常 → 挂起,沿用 SERVICE 模式纪律
|
||||
* (留痕 outlet=NULL + 告警 + 管理端重跑),不配默认出口。
|
||||
*/
|
||||
private String decideByExpression(FlwInstance instance, FlowDefModel model, FlowDefModel.Node node,
|
||||
List<String> outlets) {
|
||||
Map<String, Object> env = builtinEnv(instance, node, parseVariables(instance.getFormDataJson()));
|
||||
StringBuilder trace = new StringBuilder("EXPRESSION judge: ");
|
||||
try {
|
||||
for (FlowDefModel.Edge e : model.outEdges(node.id)) {
|
||||
Object result = ExpressionEvaluator.evaluate(e.condition, env);
|
||||
trace.append("[").append(e.name).append("] ").append(e.condition)
|
||||
.append(" => ").append(result).append("; ");
|
||||
if (Boolean.TRUE.equals(result)) {
|
||||
int attempt = persist(instance, node, EXPRESSION_ROUTER_KEY, e.name, trace.toString());
|
||||
log.info("[flow-center] 表达式判断命中:instance={} node={} outlet={} attempt={}",
|
||||
instance.getId(), node.id, e.name, attempt);
|
||||
return e.name;
|
||||
}
|
||||
}
|
||||
log.error("[flow-center] 表达式全部未命中(挂起):instance={} node={} outlets={} trace={}",
|
||||
instance.getId(), node.id, outlets, trace);
|
||||
return persistFailure(instance, node, outlets, "表达式全部未命中:" + trace);
|
||||
} catch (Exception e) {
|
||||
log.error("[flow-center] 表达式求值异常(挂起):instance={} node={} {}",
|
||||
instance.getId(), node.id, e.toString());
|
||||
return persistFailure(instance, node, outlets, "表达式求值异常:" + e + ";" + trace);
|
||||
}
|
||||
}
|
||||
|
||||
/** 内置变量注入(EXPRESSION 求值 env;SERVICE 模式的 JudgeBody 同名字段一一对应) */
|
||||
static Map<String, Object> builtinEnv(FlwInstance instance, FlowDefModel.Node node,
|
||||
Map<String, Object> variables) {
|
||||
variables.put("schId", instance.getSchId());
|
||||
variables.put("instanceId", instance.getId());
|
||||
variables.put("instanceVersion", instance.getVersion());
|
||||
variables.put("bizType", instance.getBizType());
|
||||
variables.put("bizId", instance.getBizId());
|
||||
variables.put("nodeId", node.id);
|
||||
variables.put("nodeName", node.name);
|
||||
variables.put("submitterId", instance.getSubmitterId());
|
||||
return variables;
|
||||
}
|
||||
|
||||
/** CONDITION 出边名即判断契约的 outlets(发布期已保证非空唯一) */
|
||||
private static List<String> outletNames(FlowDefModel model, FlowDefModel.Node node) {
|
||||
List<String> names = new ArrayList<>();
|
||||
@@ -97,19 +149,21 @@ public class ConditionRouteService {
|
||||
|
||||
private String persistFailure(FlwInstance instance, FlowDefModel.Node node, List<String> outlets,
|
||||
String error) {
|
||||
int attempt = persist(instance, node, null, truncate(error));
|
||||
int attempt = persist(instance, node, node.expressionJudge() ? EXPRESSION_ROUTER_KEY : node.routerKey,
|
||||
null, truncate(error));
|
||||
alertSuspended(instance, node, outlets, error, attempt);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 落一行留痕(REQUIRES_NEW:外部调用已真实发生,不随外层回滚),返回本轮 attempt(告警 msgNo 用) */
|
||||
private int persist(FlwInstance instance, FlowDefModel.Node node, String outlet, String raw) {
|
||||
private int persist(FlwInstance instance, FlowDefModel.Node node, String routerKeyLabel,
|
||||
String outlet, String raw) {
|
||||
FlwRouteLog logRow = new FlwRouteLog();
|
||||
logRow.setSchId(instance.getSchId());
|
||||
logRow.setInstanceId(instance.getId());
|
||||
logRow.setNodeId(node.id);
|
||||
logRow.setNodeName(node.name);
|
||||
logRow.setRouterKey(node.routerKey);
|
||||
logRow.setRouterKey(routerKeyLabel);
|
||||
logRow.setOutlet(outlet);
|
||||
logRow.setRawResponse(raw);
|
||||
return routeLogStore.append(logRow).getAttempt();
|
||||
|
||||
@@ -66,7 +66,7 @@ public final class DefValidator {
|
||||
validateNotify(n, enabledDeptKeys, problems);
|
||||
break;
|
||||
case FlowDefModel.TYPE_CONDITION:
|
||||
validateCondition(n, enabledRouterKeys, problems);
|
||||
validateCondition(model, n, enabledRouterKeys, problems);
|
||||
break;
|
||||
case FlowDefModel.TYPE_PARALLEL_SPLIT:
|
||||
case FlowDefModel.TYPE_PARALLEL_JOIN:
|
||||
@@ -243,12 +243,37 @@ public final class DefValidator {
|
||||
}
|
||||
|
||||
/**
|
||||
* CONDITION 条件分支节点校验(2026-09-24 §4):routerKey 必填且已注册启用——运行时才发现
|
||||
* 无效只会挂起,发布期就拦;出边=互斥分支,出口名即判断接口的 outlets 契约(命名/唯一/
|
||||
* 同分支上下文由度约束段复用 {@link #validateOutlets} 口径)。
|
||||
* CONDITION 条件分支节点校验(2026-09-24 §4):SERVICE 模式 routerKey 必填且已注册启用——
|
||||
* 运行时才发现无效只会挂起,发布期就拦;出边=互斥分支,出口名即判断接口的 outlets 契约
|
||||
* (命名/唯一/同分支上下文由度约束段复用 {@link #validateOutlets} 口径)。
|
||||
*
|
||||
* <p>EXPRESSION 模式(2026-10-10 四议题)分层校验:语法错/出口缺条件 → 发布期硬拦
|
||||
* (表单变量是子应用运行时传的,发布期无从核对 schema,一律放行、缺失按 null 注入求值)。
|
||||
*/
|
||||
private static void validateCondition(FlowDefModel.Node n, Set<String> enabledRouterKeys,
|
||||
private static void validateCondition(FlowDefModel model, FlowDefModel.Node n, Set<String> enabledRouterKeys,
|
||||
List<String> problems) {
|
||||
if (n.judgeMode != null && !FlowDefModel.JUDGE_MODE_SERVICE.equals(n.judgeMode)
|
||||
&& !FlowDefModel.JUDGE_MODE_EXPRESSION.equals(n.judgeMode)) {
|
||||
problems.add(nodeLabel(n) + " 判断方式取值非法:" + n.judgeMode + "(支持 SERVICE/EXPRESSION)");
|
||||
return;
|
||||
}
|
||||
if (n.expressionJudge()) {
|
||||
for (FlowDefModel.Edge e : model.outEdges(n.id)) {
|
||||
if (e.condition == null || e.condition.trim().isEmpty()) {
|
||||
problems.add(nodeLabel(n) + " 的出口「" + e.name
|
||||
+ "」缺少条件表达式(EXPRESSION 模式每个出口必须配置)");
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
warnBuiltinMisspelling(n, e);
|
||||
ExpressionEvaluator.compile(e.condition.trim());
|
||||
} catch (Exception ex) {
|
||||
problems.add(nodeLabel(n) + " 的出口「" + e.name + "」表达式不合法:"
|
||||
+ ex.getMessage());
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (n.routerKey == null || n.routerKey.trim().isEmpty()) {
|
||||
problems.add(nodeLabel(n) + " 缺少判断服务(routerKey)配置");
|
||||
} else if (enabledRouterKeys != null && !enabledRouterKeys.contains(n.routerKey.trim())) {
|
||||
@@ -257,6 +282,34 @@ public final class DefValidator {
|
||||
}
|
||||
}
|
||||
|
||||
/** 表达式内置变量白名单(ConditionRouteService.builtinEnv 注入的同名集) */
|
||||
private static final Set<String> EXPRESSION_BUILTINS = new HashSet<>(java.util.Arrays.asList(
|
||||
"schId", "instanceId", "instanceVersion", "bizType", "bizId",
|
||||
"nodeId", "nodeName", "submitterId"));
|
||||
|
||||
/**
|
||||
* 内置变量疑似拼错提示(warn 不硬拦):变量名与某内置变量仅大小写不一致(如 schid)时
|
||||
* 提示一次——拼错的表达式静默把 null 与数字比较,全部不命中挂起,很难排查。
|
||||
*/
|
||||
private static void warnBuiltinMisspelling(FlowDefModel.Node n, FlowDefModel.Edge e) {
|
||||
try {
|
||||
for (String v : ExpressionEvaluator.variableNames(e.condition.trim())) {
|
||||
if (!EXPRESSION_BUILTINS.contains(v)) {
|
||||
for (String b : EXPRESSION_BUILTINS) {
|
||||
if (b.equalsIgnoreCase(v)) {
|
||||
LOG.warn("[flow-center] 节点 {} 出口「{}」表达式变量 {} 疑似内置变量 {} 拼写错误",
|
||||
n.id, e.name, v, b);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception ignore) {
|
||||
// 语法错时拿不到变量名,交给 compile 路径报问题
|
||||
}
|
||||
}
|
||||
|
||||
private static final org.slf4j.Logger LOG = org.slf4j.LoggerFactory.getLogger(DefValidator.class);
|
||||
|
||||
private static void validateApproval(FlowDefModel.Node n, Set<String> enabledDeptKeys, List<String> problems) { FlowDefModel.Assignee a = n.assignee;
|
||||
if (a == null || a.type == null) {
|
||||
problems.add(nodeLabel(n) + " 缺少 assignee 配置");
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.yunhai.flowcenter.engine;
|
||||
|
||||
import com.googlecode.aviator.AviatorEvaluator;
|
||||
import com.googlecode.aviator.AviatorEvaluatorInstance;
|
||||
import com.googlecode.aviator.Expression;
|
||||
import com.googlecode.aviator.Feature;
|
||||
import com.googlecode.aviator.Options;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 条件路由表达式求值核心(2026-10-10 四议题 Phase 4):CONDITION 节点 judgeMode=EXPRESSION 时,
|
||||
* 出边 condition 即 AviatorScript 表达式,按出边定义顺序取首个求值为 true 的出口。
|
||||
* 发布期校验(DefValidator)、运行时(ConditionRouteService)、调试端点(/admin/router/eval)
|
||||
* 共用本类——三处口径天然一致。
|
||||
*
|
||||
* <p><b>沙箱纪律(勿放宽)</b>:FEATURE_SET 只留 If(三元/条件),NewInstance/Module/Use/
|
||||
* Static* 全部不在集合内即禁用;ALLOWED_CLASS_SET=空不可变集 = 禁一切 Java 类访问(反射/静态
|
||||
* 方法/类字面量);表达式只能引用变量与 Aviator 内置纯函数(math/string/seq)。
|
||||
*
|
||||
* <p><b>缺失变量口径(宁挂起不误放行)</b>:顶层变量缺失(拼写错/表单未传)直接抛错由调用方
|
||||
* 挂起——Aviator 的 nil 参与大小比较时视作最小值,{@code x <= n} 这类兜底出口会对缺失字段
|
||||
* <i>静默放行</i>,必须拦住。判空请显式写 {@code x == nil}(嵌套属性缺失走 nil 语义,
|
||||
* {@code form.amount == nil} 可用)。env 中显式为 null 的值仍按 nil 语义求值。
|
||||
*
|
||||
* <p>线程安全:{@link AviatorEvaluatorInstance} 本身线程安全,编译结果带缓存(key=表达式串)。
|
||||
*/
|
||||
public final class ExpressionEvaluator {
|
||||
|
||||
private ExpressionEvaluator() {
|
||||
}
|
||||
|
||||
private static final AviatorEvaluatorInstance INSTANCE = createSandboxed();
|
||||
|
||||
private static AviatorEvaluatorInstance createSandboxed() {
|
||||
AviatorEvaluatorInstance instance = AviatorEvaluator.newInstance();
|
||||
instance.setOption(Options.FEATURE_SET, Feature.asSet(Feature.If));
|
||||
instance.setOption(Options.ALLOWED_CLASS_SET, Collections.emptySet());
|
||||
instance.setOption(Options.NIL_WHEN_PROPERTY_NOT_FOUND, Boolean.TRUE);
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 求值(编译缓存复用)。顶层变量缺失直接抛错(调用方挂起)——Aviator 对缺失顶层变量一律
|
||||
* 按 nil 求值且不可配置,而 nil 视作最小值会让 {@code x <= n} 这类兜底出口静默放行。
|
||||
* 嵌套属性(form.amount)缺失走 nil 语义,判空请显式 {@code form.amount == nil}。
|
||||
*
|
||||
* @return 求值结果;调用方仅把 Boolean.TRUE 视为命中(null/非布尔一律不命中——宁挂起不误放行)
|
||||
*/
|
||||
public static Object evaluate(String expression, Map<String, Object> env) {
|
||||
Expression compiled = compile(expression);
|
||||
for (String name : compiled.getVariableNames()) {
|
||||
if (!env.containsKey(name)) {
|
||||
throw new IllegalStateException("表达式变量未提供:" + name
|
||||
+ "(拼写错误或表单未传;宁挂起不误放行,不按 nil 求值)");
|
||||
}
|
||||
}
|
||||
return compiled.execute(env);
|
||||
}
|
||||
|
||||
/** 语法校验/预编译(发布期拦语法错;与运行时走同一编译路径) */
|
||||
public static Expression compile(String expression) {
|
||||
return INSTANCE.compile(expression, true);
|
||||
}
|
||||
|
||||
/** 表达式引用的顶层变量名(发布期"内置变量疑似拼错"提示用) */
|
||||
public static List<String> variableNames(String expression) {
|
||||
return compile(expression).getVariableNames();
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,11 @@ public class FlowDefModel {
|
||||
*/
|
||||
public static final String TYPE_CONDITION = "CONDITION";
|
||||
|
||||
/** CONDITION 判断方式(2026-10-10 四议题):外部判断服务(缺省=旧口径,routerKey 寻址) */
|
||||
public static final String JUDGE_MODE_SERVICE = "SERVICE";
|
||||
/** CONDITION 判断方式:内置表达式引擎(出边 condition 即 AviatorScript 表达式) */
|
||||
public static final String JUDGE_MODE_EXPRESSION = "EXPRESSION";
|
||||
|
||||
/** 会签模式 */
|
||||
public static final String COUNTERSIGN_ALL = "ALL";
|
||||
public static final String COUNTERSIGN_ANY = "ANY";
|
||||
@@ -63,6 +68,13 @@ public class FlowDefModel {
|
||||
public Assignee receivers;
|
||||
/** CONDITION 判断服务注册键(flw_router.router_key,发布期校验须已注册且启用) */
|
||||
public String routerKey;
|
||||
/** CONDITION 判断方式:SERVICE(缺省,routerKey 外部服务)|EXPRESSION(出边 condition 表达式) */
|
||||
public String judgeMode;
|
||||
|
||||
/** 是否表达式判断(null 视作 SERVICE,旧 contentJson 无此字段天然走服务口径) */
|
||||
public boolean expressionJudge() {
|
||||
return JUDGE_MODE_EXPRESSION.equals(judgeMode);
|
||||
}
|
||||
/** ALL(默认)|ANY */
|
||||
public String countersign;
|
||||
/** PARALLEL_JOIN 汇聚条件,一期仅 ALL */
|
||||
|
||||
@@ -38,13 +38,14 @@ public class RouteJudgeClient {
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
/** 判断请求体(契约冻结:query 带 schId,业务方按 outlets 返回 {outlet}) */
|
||||
/** 判断请求体(契约冻结:query 带 schId,业务方按 outlets 返回 {outlet};submitterId 2026-10-10 增补,老实现忽略即可) */
|
||||
public static class JudgeBody {
|
||||
public final Long schId;
|
||||
public final Long instanceId;
|
||||
public final Integer instanceVersion;
|
||||
public final String bizType;
|
||||
public final String bizId;
|
||||
public final Integer submitterId;
|
||||
public final String nodeId;
|
||||
public final String nodeName;
|
||||
public final String routerKey;
|
||||
@@ -52,13 +53,14 @@ public class RouteJudgeClient {
|
||||
public final Map<String, Object> variables;
|
||||
|
||||
public JudgeBody(Long schId, Long instanceId, Integer instanceVersion, String bizType, String bizId,
|
||||
String nodeId, String nodeName, String routerKey, List<String> outlets,
|
||||
Map<String, Object> variables) {
|
||||
Integer submitterId, String nodeId, String nodeName, String routerKey,
|
||||
List<String> outlets, Map<String, Object> variables) {
|
||||
this.schId = schId;
|
||||
this.instanceId = instanceId;
|
||||
this.instanceVersion = instanceVersion;
|
||||
this.bizType = bizType;
|
||||
this.bizId = bizId;
|
||||
this.submitterId = submitterId;
|
||||
this.nodeId = nodeId;
|
||||
this.nodeName = nodeName;
|
||||
this.routerKey = routerKey;
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
package com.yunhai.flowcenter.engine;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.flowcenter.entity.FlwInstance;
|
||||
import com.yunhai.flowcenter.entity.FlwRouteLog;
|
||||
import com.yunhai.flowcenter.notify.UserMsgClient;
|
||||
import com.yunhai.flowcenter.repository.FlwRouterRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* CONDITION 判断编排单测(2026-10-10 四议题 Phase 4):EXPRESSION 模式分流——按出边顺序
|
||||
* 首个 true 命中、全 false/求值异常挂起(留痕 outlet=NULL + router_key=EXPRESSION + 告警)、
|
||||
* SERVICE 模式回归不受影响。挂起推进链路由 FlowEngineIT 覆盖。
|
||||
*/
|
||||
class ConditionRouteServiceExpressionTest {
|
||||
|
||||
private FlwRouterRepository routerRepository;
|
||||
private RouteLogStore routeLogStore;
|
||||
private RouteJudgeClient judgeClient;
|
||||
private ConditionRouteService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
routerRepository = mock(FlwRouterRepository.class);
|
||||
routeLogStore = mock(RouteLogStore.class);
|
||||
judgeClient = mock(RouteJudgeClient.class);
|
||||
UserMsgClient userMsgClient = mock(UserMsgClient.class);
|
||||
when(routeLogStore.append(any(FlwRouteLog.class))).thenAnswer(inv -> {
|
||||
FlwRouteLog row = inv.getArgument(0);
|
||||
row.setAttempt(1);
|
||||
return row;
|
||||
});
|
||||
service = new ConditionRouteService(routerRepository, routeLogStore, judgeClient,
|
||||
userMsgClient, new ObjectMapper());
|
||||
}
|
||||
|
||||
private FlwInstance instance(String formDataJson) {
|
||||
FlwInstance inst = new FlwInstance();
|
||||
inst.setId(7L);
|
||||
inst.setSchId(1L);
|
||||
inst.setVersion(3);
|
||||
inst.setBizType("leave");
|
||||
inst.setBizId("456");
|
||||
inst.setSubmitterId(990001);
|
||||
inst.setFormDataJson(formDataJson);
|
||||
return inst;
|
||||
}
|
||||
|
||||
/** CONDITION 表达式模型:出边顺序即求值优先级(出口一 → 出口二,互补对) */
|
||||
private FlowDefModel model() {
|
||||
return expressionModel("amount > 1000", "amount <= 1000");
|
||||
}
|
||||
|
||||
private FlowDefModel expressionModel(String cond1, String cond2) {
|
||||
return FlowDefModel.parse(new ObjectMapper(), "{"
|
||||
+ "\"code\":\"c1\",\"name\":\"流程\",\"start\":\"start\","
|
||||
+ "\"nodes\":["
|
||||
+ "{\"id\":\"start\",\"type\":\"START\"},"
|
||||
+ "{\"id\":\"cond\",\"type\":\"CONDITION\",\"name\":\"判断\",\"judgeMode\":\"EXPRESSION\"},"
|
||||
+ "{\"id\":\"end\",\"type\":\"END\"}],"
|
||||
+ "\"edges\":["
|
||||
+ "{\"from\":\"start\",\"to\":\"cond\"},"
|
||||
+ "{\"from\":\"cond\",\"to\":\"end\",\"name\":\"出口一\",\"condition\":\"" + cond1 + "\"},"
|
||||
+ "{\"from\":\"cond\",\"to\":\"end\",\"name\":\"出口二\",\"condition\":\"" + cond2 + "\"}]}");
|
||||
}
|
||||
|
||||
@Test
|
||||
void expressionAllFalse_suspendsWithFailureRow() {
|
||||
FlowDefModel model = expressionModel("amount > 1000", "amount > 2000");
|
||||
FlowDefModel.Node node = model.nodeById("cond");
|
||||
|
||||
String outlet = service.decide(instance("{\"amount\":500}"), model, node);
|
||||
|
||||
assertNull(outlet);
|
||||
ArgumentCaptor<FlwRouteLog> captor = ArgumentCaptor.forClass(FlwRouteLog.class);
|
||||
verify(routeLogStore, times(1)).append(captor.capture());
|
||||
assertNull(captor.getValue().getOutlet());
|
||||
assertEquals("EXPRESSION", captor.getValue().getRouterKey());
|
||||
assertTrue(captor.getValue().getRawResponse().contains("表达式全部未命中"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void expressionMissingVariable_suspendsWithTypeSafeError() {
|
||||
// 表单未传 amount(拼写错/漏传同款):直接挂起,不留静默放行口子
|
||||
FlowDefModel model = model();
|
||||
FlowDefModel.Node node = model.nodeById("cond");
|
||||
|
||||
String outlet = service.decide(instance("{\"other\":1}"), model, node);
|
||||
|
||||
assertNull(outlet);
|
||||
ArgumentCaptor<FlwRouteLog> captor = ArgumentCaptor.forClass(FlwRouteLog.class);
|
||||
verify(routeLogStore, times(1)).append(captor.capture());
|
||||
assertTrue(captor.getValue().getRawResponse().contains("表达式求值异常"));
|
||||
assertTrue(captor.getValue().getRawResponse().contains("amount"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void expressionSecondEdgeTaken_whenFirstFalse() {
|
||||
FlowDefModel model = model();
|
||||
FlowDefModel.Node node = model.nodeById("cond");
|
||||
|
||||
String outlet = service.decide(instance("{\"amount\":500}"), model, node);
|
||||
|
||||
assertEquals("出口二", outlet);
|
||||
}
|
||||
|
||||
@Test
|
||||
void expressionEvalError_suspends() {
|
||||
FlowDefModel model = FlowDefModel.parse(new ObjectMapper(), "{"
|
||||
+ "\"code\":\"c1\",\"name\":\"流程\",\"start\":\"start\","
|
||||
+ "\"nodes\":["
|
||||
+ "{\"id\":\"start\",\"type\":\"START\"},"
|
||||
+ "{\"id\":\"cond\",\"type\":\"CONDITION\",\"name\":\"判断\",\"judgeMode\":\"EXPRESSION\"},"
|
||||
+ "{\"id\":\"end\",\"type\":\"END\"}],"
|
||||
+ "\"edges\":["
|
||||
+ "{\"from\":\"start\",\"to\":\"cond\"},"
|
||||
+ "{\"from\":\"cond\",\"to\":\"end\",\"name\":\"出口一\",\"condition\":\"amount - 'x'\"}]}");
|
||||
FlowDefModel.Node node = model.nodeById("cond");
|
||||
|
||||
String outlet = service.decide(instance("{\"amount\":1}"), model, node);
|
||||
|
||||
assertNull(outlet);
|
||||
ArgumentCaptor<FlwRouteLog> captor = ArgumentCaptor.forClass(FlwRouteLog.class);
|
||||
verify(routeLogStore, times(1)).append(captor.capture());
|
||||
assertTrue(captor.getValue().getRawResponse().contains("表达式求值异常"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void builtinVariables_availableInEnv() {
|
||||
Map<String, Object> env = ConditionRouteService.builtinEnv(instance(null),
|
||||
model().nodeById("cond"), new HashMap<>());
|
||||
assertEquals(1L, env.get("schId"));
|
||||
assertEquals(7L, env.get("instanceId"));
|
||||
assertEquals(3, env.get("instanceVersion"));
|
||||
assertEquals("leave", env.get("bizType"));
|
||||
assertEquals("456", env.get("bizId"));
|
||||
assertEquals("cond", env.get("nodeId"));
|
||||
assertEquals(990001, env.get("submitterId"));
|
||||
|
||||
Object hit = ExpressionEvaluator.evaluate("bizType == 'leave' && submitterId == 990001", env);
|
||||
assertTrue((Boolean) hit);
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullJudgeMode_stillServicePath_routerMissSuspends() {
|
||||
FlowDefModel model = FlowDefModel.parse(new ObjectMapper(), "{"
|
||||
+ "\"code\":\"c1\",\"name\":\"流程\",\"start\":\"start\","
|
||||
+ "\"nodes\":["
|
||||
+ "{\"id\":\"start\",\"type\":\"START\"},"
|
||||
+ "{\"id\":\"cond\",\"type\":\"CONDITION\",\"name\":\"判断\",\"routerKey\":\"rk\"},"
|
||||
+ "{\"id\":\"end\",\"type\":\"END\"}],"
|
||||
+ "\"edges\":["
|
||||
+ "{\"from\":\"start\",\"to\":\"cond\"},"
|
||||
+ "{\"from\":\"cond\",\"to\":\"end\",\"name\":\"出口一\"},"
|
||||
+ "{\"from\":\"cond\",\"to\":\"end\",\"name\":\"出口二\"}]}");
|
||||
FlowDefModel.Node node = model.nodeById("cond");
|
||||
when(routerRepository.findByRouterKey("rk")).thenReturn(java.util.Optional.empty());
|
||||
|
||||
assertNull(service.decide(instance(null), model, node));
|
||||
ArgumentCaptor<FlwRouteLog> captor = ArgumentCaptor.forClass(FlwRouteLog.class);
|
||||
verify(routeLogStore, times(1)).append(captor.capture());
|
||||
assertEquals("rk", captor.getValue().getRouterKey());
|
||||
assertTrue(captor.getValue().getRawResponse().contains("未注册或已停用"));
|
||||
}
|
||||
}
|
||||
@@ -381,6 +381,78 @@ class DefValidatorTest {
|
||||
new HashSet<>(Collections.singletonList("rk1")));
|
||||
}
|
||||
|
||||
// ===== CONDITION EXPRESSION 模式(2026-10-10 四议题) =====
|
||||
|
||||
private String expressionDef(String judgeMode, String cond1, String cond2) {
|
||||
return "{"
|
||||
+ "\"code\":\"c1\",\"name\":\"流程\",\"start\":\"start\","
|
||||
+ "\"nodes\":["
|
||||
+ "{\"id\":\"start\",\"type\":\"START\"},"
|
||||
+ "{\"id\":\"cond1\",\"type\":\"CONDITION\",\"name\":\"条件判断\""
|
||||
+ (judgeMode == null ? "" : ",\"judgeMode\":\"" + judgeMode + "\"") + "},"
|
||||
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"ybb\"}},"
|
||||
+ "{\"id\":\"n2\",\"type\":\"APPROVAL\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"ybb\"}},"
|
||||
+ "{\"id\":\"end\",\"type\":\"END\"}],"
|
||||
+ "\"edges\":[{\"from\":\"start\",\"to\":\"cond1\"},"
|
||||
+ "{\"from\":\"cond1\",\"to\":\"n1\",\"name\":\"甲线\",\"condition\":\"" + cond1 + "\"},"
|
||||
+ "{\"from\":\"cond1\",\"to\":\"n2\",\"name\":\"乙线\",\"condition\":\"" + cond2 + "\"},"
|
||||
+ "{\"from\":\"n1\",\"to\":\"end\"},{\"from\":\"n2\",\"to\":\"end\"}]}";
|
||||
}
|
||||
|
||||
@Test
|
||||
void expressionMode_validConditions_passes() {
|
||||
DefValidator.validate(parse(expressionDef("EXPRESSION", "amount > 1000", "amount <= 1000")),
|
||||
new HashSet<>(Collections.singletonList("ybb")), null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void expressionMode_missingCondition_fails() {
|
||||
BizException e = assertThrows(BizException.class, () -> DefValidator.validate(
|
||||
parse(expressionDef("EXPRESSION", "amount > 1000", "")),
|
||||
new HashSet<>(Collections.singletonList("ybb")), null));
|
||||
assertTrue(e.getMessage().contains("缺少条件表达式"));
|
||||
assertTrue(e.getMessage().contains("乙线"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void expressionMode_syntaxError_fails() {
|
||||
BizException e = assertThrows(BizException.class, () -> DefValidator.validate(
|
||||
parse(expressionDef("EXPRESSION", "amount >", "amount <= 1000")),
|
||||
new HashSet<>(Collections.singletonList("ybb")), null));
|
||||
assertTrue(e.getMessage().contains("表达式不合法"));
|
||||
assertTrue(e.getMessage().contains("甲线"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void expressionMode_dangerousExpression_fails() {
|
||||
BizException e = assertThrows(BizException.class, () -> DefValidator.validate(
|
||||
parse(expressionDef("EXPRESSION", "new java.util.Date()", "amount <= 1000")),
|
||||
new HashSet<>(Collections.singletonList("ybb")), null));
|
||||
assertTrue(e.getMessage().contains("表达式不合法"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void expressionMode_formVariables_passPublishTime() {
|
||||
// 表单变量(子应用运行时传)发布期不拦:缺失按 null 注入求值
|
||||
DefValidator.validate(parse(expressionDef("EXPRESSION", "form.level == 'A'", "true")),
|
||||
new HashSet<>(Collections.singletonList("ybb")), null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void expressionMode_skipsRouterKeyCheck() {
|
||||
// EXPRESSION 模式不配 routerKey(enabledRouterKeys=null 也不查注册表)
|
||||
DefValidator.validate(parse(expressionDef("EXPRESSION", "true", "false")),
|
||||
new HashSet<>(Collections.singletonList("ybb")), null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void judgeMode_illegalValue_fails() {
|
||||
BizException e = assertThrows(BizException.class, () -> DefValidator.validate(
|
||||
parse(expressionDef("WHATEVER", "true", "false")),
|
||||
new HashSet<>(Collections.singletonList("ybb")), null));
|
||||
assertTrue(e.getMessage().contains("判断方式取值非法"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void condition_missingRouterKey_fails() {
|
||||
String json = "{"
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.yunhai.flowcenter.engine;
|
||||
|
||||
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.assertFalse;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* 表达式求值器单测(2026-10-10 四议题 Phase 4):沙箱纪律(禁类访问/反射/new)、
|
||||
* 缺失变量补 null、nil 属性语义、非布尔不命中。发布校验/运行时/调试端点共用本核心。
|
||||
*/
|
||||
class ExpressionEvaluatorTest {
|
||||
|
||||
@Test
|
||||
void basicComparison_trueAndFalse() {
|
||||
Map<String, Object> env = new HashMap<>();
|
||||
env.put("amount", 2000);
|
||||
assertTrue((Boolean) ExpressionEvaluator.evaluate("amount > 1000", env));
|
||||
assertFalse((Boolean) ExpressionEvaluator.evaluate("amount < 1000", env));
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingTopLevelVariable_throws_typoFailsClosed() {
|
||||
// 宁挂起不误放行:缺失变量直接抛错(nil 视作最小值,<= 兜底出口会静默放行,不能补 null)
|
||||
Map<String, Object> env = new HashMap<>();
|
||||
assertThrows(Exception.class, () -> ExpressionEvaluator.evaluate("missing > 1000", env));
|
||||
assertThrows(Exception.class, () -> ExpressionEvaluator.evaluate("missing <= 1000", env));
|
||||
}
|
||||
|
||||
@Test
|
||||
void explicitNullValue_nilComparisons_documentedSemantics() {
|
||||
// env 显式 null 仍走 nil 语义:== nil 可判空;<= 对 nil 为 true(Aviator 视 nil 为最小值)
|
||||
Map<String, Object> env = new HashMap<>();
|
||||
env.put("amount", null);
|
||||
assertTrue((Boolean) ExpressionEvaluator.evaluate("amount == nil", env));
|
||||
assertTrue((Boolean) ExpressionEvaluator.evaluate("amount <= 1000", env));
|
||||
}
|
||||
|
||||
@Test
|
||||
void nestedMapPropertyMissing_returnsNil() {
|
||||
Map<String, Object> env = new HashMap<>();
|
||||
env.put("form", new HashMap<String, Object>());
|
||||
Object r = ExpressionEvaluator.evaluate("form.amount == nil", env);
|
||||
assertTrue((Boolean) r);
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonBooleanResult_notTreatedAsTrue() {
|
||||
Map<String, Object> env = new HashMap<>();
|
||||
env.put("amount", 2000);
|
||||
Object r = ExpressionEvaluator.evaluate("amount + 1", env);
|
||||
assertEquals(2001L, ((Number) r).longValue());
|
||||
assertFalse(Boolean.TRUE.equals(r));
|
||||
}
|
||||
|
||||
@Test
|
||||
void stringFunction_allowed() {
|
||||
Map<String, Object> env = new HashMap<>();
|
||||
env.put("title", "请假申请");
|
||||
assertTrue((Boolean) ExpressionEvaluator.evaluate("string.contains(title, '请假')", env));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sandbox_rejectsNewInstance() {
|
||||
assertThrows(Exception.class, ()
|
||||
-> ExpressionEvaluator.evaluate("new java.util.Date()", new HashMap<>()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sandbox_rejectsStaticMethodAccess() {
|
||||
assertThrows(Exception.class, ()
|
||||
-> ExpressionEvaluator.evaluate("System.currentTimeMillis() > 0", new HashMap<>()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sandbox_rejectsChainedReflection() {
|
||||
Map<String, Object> env = new HashMap<>();
|
||||
env.put("title", "x");
|
||||
assertThrows(Exception.class, ()
|
||||
-> ExpressionEvaluator.evaluate("title.getClass().forName('java.lang.Runtime')", env));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sandbox_bareClassReference_isInertVariableLookup() {
|
||||
// 裸类名在属性语法糖下只是嵌套 map 查找(java→lang→Runtime),顶层变量 java 缺失被
|
||||
// 求值前核对拦下——不构成类访问;真正的类访问(new/静态方法/链式反射)由上面三个用例证明被拒
|
||||
assertThrows(IllegalStateException.class, ()
|
||||
-> ExpressionEvaluator.evaluate("java.lang.Runtime", new HashMap<>()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void builtinVariableStyle_comparisonAcrossNumberTypes() {
|
||||
Map<String, Object> env = new HashMap<>();
|
||||
env.put("submitterId", 990001);
|
||||
assertTrue((Boolean) ExpressionEvaluator.evaluate("submitterId == 990001", env));
|
||||
}
|
||||
|
||||
@Test
|
||||
void syntaxError_thrownAtCompile() {
|
||||
assertThrows(Exception.class, () -> ExpressionEvaluator.compile("amount >"));
|
||||
}
|
||||
}
|
||||
@@ -53,7 +53,7 @@ class RouteJudgeClientTest {
|
||||
}
|
||||
|
||||
private RouteJudgeClient.JudgeBody body() {
|
||||
return new RouteJudgeClient.JudgeBody(1L, 2L, 3, "notice", "456",
|
||||
return new RouteJudgeClient.JudgeBody(1L, 2L, 3, "notice", "456", 990001,
|
||||
"node_r1", "判断", "rk", Collections.singletonList("甲线"), new HashMap<>());
|
||||
}
|
||||
|
||||
|
||||
@@ -482,7 +482,8 @@ class FlowDiagramServiceTest {
|
||||
mock(FlowMapAdminService.class), mock(FlowCallbackAdminService.class),
|
||||
mock(com.yunhai.flowcenter.service.FlowRouterAdminService.class),
|
||||
mock(FlwInstanceRepository.class), diagramService,
|
||||
mock(com.yunhai.flowcenter.service.DiagramImageService.class));
|
||||
mock(com.yunhai.flowcenter.service.DiagramImageService.class),
|
||||
new ObjectMapper());
|
||||
}
|
||||
|
||||
private BoaUser user(int id, Integer type, Integer schId, Integer... roleIds) {
|
||||
|
||||
Reference in New Issue
Block a user