diff --git a/pom.xml b/pom.xml index db4314d..eaca642 100644 --- a/pom.xml +++ b/pom.xml @@ -70,6 +70,14 @@ runtime + + + com.googlecode.aviator + aviator + 5.4.3 + + org.projectlombok lombok diff --git a/src/main/java/com/yunhai/flowcenter/controller/api/FlowAdminController.java b/src/main/java/com/yunhai/flowcenter/controller/api/FlowAdminController.java index 6fa3a46..b082ce9 100644 --- a/src/main/java/com/yunhai/flowcenter/controller/api/FlowAdminController.java +++ b/src/main/java/com/yunhai/flowcenter/controller/api/FlowAdminController.java @@ -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 routerEval(BoaUser user, @RequestBody Map 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 result = new java.util.HashMap<>(); + if (expression.isEmpty()) { + result.put("result", false); + result.put("error", "表达式为空"); + return result; + } + Map env; + if (variablesJson.isEmpty()) { + env = new java.util.HashMap<>(); + } else { + try { + env = objectMapper.readValue(variablesJson, + new com.fasterxml.jackson.core.type.TypeReference>() { + }); + } 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") diff --git a/src/main/java/com/yunhai/flowcenter/engine/ConditionRouteService.java b/src/main/java/com/yunhai/flowcenter/engine/ConditionRouteService.java index ee4b56f..b552fa6 100644 --- a/src/main/java/com/yunhai/flowcenter/engine/ConditionRouteService.java +++ b/src/main/java/com/yunhai/flowcenter/engine/ConditionRouteService.java @@ -42,6 +42,9 @@ public class ConditionRouteService { */ public String decide(FlwInstance instance, FlowDefModel model, FlowDefModel.Node node) { List 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 outlets) { + Map 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 builtinEnv(FlwInstance instance, FlowDefModel.Node node, + Map 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 outletNames(FlowDefModel model, FlowDefModel.Node node) { List names = new ArrayList<>(); @@ -97,19 +149,21 @@ public class ConditionRouteService { private String persistFailure(FlwInstance instance, FlowDefModel.Node node, List 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(); diff --git a/src/main/java/com/yunhai/flowcenter/engine/DefValidator.java b/src/main/java/com/yunhai/flowcenter/engine/DefValidator.java index c69353c..ba6c661 100644 --- a/src/main/java/com/yunhai/flowcenter/engine/DefValidator.java +++ b/src/main/java/com/yunhai/flowcenter/engine/DefValidator.java @@ -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} 口径)。 + * + *

EXPRESSION 模式(2026-10-10 四议题)分层校验:语法错/出口缺条件 → 发布期硬拦 + * (表单变量是子应用运行时传的,发布期无从核对 schema,一律放行、缺失按 null 注入求值)。 */ - private static void validateCondition(FlowDefModel.Node n, Set enabledRouterKeys, + private static void validateCondition(FlowDefModel model, FlowDefModel.Node n, Set enabledRouterKeys, List 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 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 enabledDeptKeys, List problems) { FlowDefModel.Assignee a = n.assignee; if (a == null || a.type == null) { problems.add(nodeLabel(n) + " 缺少 assignee 配置"); diff --git a/src/main/java/com/yunhai/flowcenter/engine/ExpressionEvaluator.java b/src/main/java/com/yunhai/flowcenter/engine/ExpressionEvaluator.java new file mode 100644 index 0000000..b6dd76a --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/engine/ExpressionEvaluator.java @@ -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) + * 共用本类——三处口径天然一致。 + * + *

沙箱纪律(勿放宽):FEATURE_SET 只留 If(三元/条件),NewInstance/Module/Use/ + * Static* 全部不在集合内即禁用;ALLOWED_CLASS_SET=空不可变集 = 禁一切 Java 类访问(反射/静态 + * 方法/类字面量);表达式只能引用变量与 Aviator 内置纯函数(math/string/seq)。 + * + *

缺失变量口径(宁挂起不误放行):顶层变量缺失(拼写错/表单未传)直接抛错由调用方 + * 挂起——Aviator 的 nil 参与大小比较时视作最小值,{@code x <= n} 这类兜底出口会对缺失字段 + * 静默放行,必须拦住。判空请显式写 {@code x == nil}(嵌套属性缺失走 nil 语义, + * {@code form.amount == nil} 可用)。env 中显式为 null 的值仍按 nil 语义求值。 + * + *

线程安全:{@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 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 variableNames(String expression) { + return compile(expression).getVariableNames(); + } +} diff --git a/src/main/java/com/yunhai/flowcenter/engine/FlowDefModel.java b/src/main/java/com/yunhai/flowcenter/engine/FlowDefModel.java index a99c9c6..9a85190 100644 --- a/src/main/java/com/yunhai/flowcenter/engine/FlowDefModel.java +++ b/src/main/java/com/yunhai/flowcenter/engine/FlowDefModel.java @@ -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 */ diff --git a/src/main/java/com/yunhai/flowcenter/engine/RouteJudgeClient.java b/src/main/java/com/yunhai/flowcenter/engine/RouteJudgeClient.java index b9d51d4..cc955e4 100644 --- a/src/main/java/com/yunhai/flowcenter/engine/RouteJudgeClient.java +++ b/src/main/java/com/yunhai/flowcenter/engine/RouteJudgeClient.java @@ -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 variables; public JudgeBody(Long schId, Long instanceId, Integer instanceVersion, String bizType, String bizId, - String nodeId, String nodeName, String routerKey, List outlets, - Map variables) { + Integer submitterId, String nodeId, String nodeName, String routerKey, + List outlets, Map 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; diff --git a/src/test/java/com/yunhai/flowcenter/engine/ConditionRouteServiceExpressionTest.java b/src/test/java/com/yunhai/flowcenter/engine/ConditionRouteServiceExpressionTest.java new file mode 100644 index 0000000..dfa47ae --- /dev/null +++ b/src/test/java/com/yunhai/flowcenter/engine/ConditionRouteServiceExpressionTest.java @@ -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 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 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 captor = ArgumentCaptor.forClass(FlwRouteLog.class); + verify(routeLogStore, times(1)).append(captor.capture()); + assertTrue(captor.getValue().getRawResponse().contains("表达式求值异常")); + } + + @Test + void builtinVariables_availableInEnv() { + Map 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 captor = ArgumentCaptor.forClass(FlwRouteLog.class); + verify(routeLogStore, times(1)).append(captor.capture()); + assertEquals("rk", captor.getValue().getRouterKey()); + assertTrue(captor.getValue().getRawResponse().contains("未注册或已停用")); + } +} diff --git a/src/test/java/com/yunhai/flowcenter/engine/DefValidatorTest.java b/src/test/java/com/yunhai/flowcenter/engine/DefValidatorTest.java index d9fed85..d23ff44 100644 --- a/src/test/java/com/yunhai/flowcenter/engine/DefValidatorTest.java +++ b/src/test/java/com/yunhai/flowcenter/engine/DefValidatorTest.java @@ -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 = "{" diff --git a/src/test/java/com/yunhai/flowcenter/engine/ExpressionEvaluatorTest.java b/src/test/java/com/yunhai/flowcenter/engine/ExpressionEvaluatorTest.java new file mode 100644 index 0000000..fa968e7 --- /dev/null +++ b/src/test/java/com/yunhai/flowcenter/engine/ExpressionEvaluatorTest.java @@ -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 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 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 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 env = new HashMap<>(); + env.put("form", new HashMap()); + Object r = ExpressionEvaluator.evaluate("form.amount == nil", env); + assertTrue((Boolean) r); + } + + @Test + void nonBooleanResult_notTreatedAsTrue() { + Map 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 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 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 env = new HashMap<>(); + env.put("submitterId", 990001); + assertTrue((Boolean) ExpressionEvaluator.evaluate("submitterId == 990001", env)); + } + + @Test + void syntaxError_thrownAtCompile() { + assertThrows(Exception.class, () -> ExpressionEvaluator.compile("amount >")); + } +} diff --git a/src/test/java/com/yunhai/flowcenter/engine/RouteJudgeClientTest.java b/src/test/java/com/yunhai/flowcenter/engine/RouteJudgeClientTest.java index 1c68cf2..0b14a73 100644 --- a/src/test/java/com/yunhai/flowcenter/engine/RouteJudgeClientTest.java +++ b/src/test/java/com/yunhai/flowcenter/engine/RouteJudgeClientTest.java @@ -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<>()); } diff --git a/src/test/java/com/yunhai/flowcenter/service/FlowDiagramServiceTest.java b/src/test/java/com/yunhai/flowcenter/service/FlowDiagramServiceTest.java index e7d547c..433369f 100644 --- a/src/test/java/com/yunhai/flowcenter/service/FlowDiagramServiceTest.java +++ b/src/test/java/com/yunhai/flowcenter/service/FlowDiagramServiceTest.java @@ -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) {