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:
chenxing
2026-10-10 18:52:40 +08:00
parent a1f99fd7e0
commit 92619508f0
12 changed files with 619 additions and 15 deletions
@@ -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) {