feat(engine): NOTIFY 抄送 + CONDITION 条件路由 + JOIN scope 修复(两批全栈)
build / build-push (push) Successful in 47s
build / build-push (push) Successful in 47s
- NOTIFY:TYPE_NOTIFY 瞬时抄送(flw_cc 逐人留痕 + CC_SENT 事件 + 站内通知不加待办角标)、/api/flow/cc/my、轨迹 kind=CC 与 oapi progress 抄送步、示意图青框卡片
- CONDITION:flw_router 判断服务注册 + flw_route_log 决策留痕(REQUIRES_NEW,reroute 回滚不丢失败行)+ 同步判断调用(服务目录寻址 + 平台签名,3s×3 退避重试)+ 失败挂起/管理端重跑(不配默认出口)+ 可选事件 flow.route.decided
- JOIN scope:branch_key 路径前缀编码 + scope 级到齐放行 + DefValidator SPLIT/JOIN 配对与键长守卫(存量无点号键零迁移,历史直连图回退实例级口径)
- 管理端:/api/flow/admin/router/* CRUD + POST /api/flow/admin/instance/{id}/reroute
- 生产 SQL:sql/20260923_create_flw_cc.sql(含 flw_task 时间列升 DATETIME(3))、20260924_create_flw_router_and_route_log.sql
- 测试:全量 125 绿(FlowEngineIT 24,含条件/嵌套并行/退回重走用例)
This commit is contained in:
@@ -195,6 +195,266 @@ class DefValidatorTest {
|
||||
() -> DefValidator.validate(parse(json), Collections.emptySet()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void notify_receiversValid_passes() {
|
||||
String json = "{"
|
||||
+ "\"code\":\"c1\",\"name\":\"流程\",\"start\":\"start\","
|
||||
+ "\"nodes\":["
|
||||
+ "{\"id\":\"start\",\"type\":\"START\"},"
|
||||
+ "{\"id\":\"cc1\",\"type\":\"NOTIFY\",\"name\":\"抄送校长\",\"receivers\":{\"type\":\"USERS\",\"userIds\":[301]}},"
|
||||
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"ybb\"}},"
|
||||
+ "{\"id\":\"end\",\"type\":\"END\"}],"
|
||||
+ "\"edges\":[{\"from\":\"start\",\"to\":\"cc1\"},{\"from\":\"cc1\",\"to\":\"n1\"},"
|
||||
+ "{\"from\":\"n1\",\"to\":\"end\"}]}";
|
||||
DefValidator.validate(parse(json), new HashSet<>(Collections.singletonList("ybb")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void notify_missingReceivers_fails() {
|
||||
String json = "{"
|
||||
+ "\"code\":\"c1\",\"name\":\"流程\",\"start\":\"start\","
|
||||
+ "\"nodes\":["
|
||||
+ "{\"id\":\"start\",\"type\":\"START\"},"
|
||||
+ "{\"id\":\"cc1\",\"type\":\"NOTIFY\",\"name\":\"抄送\"},"
|
||||
+ "{\"id\":\"end\",\"type\":\"END\"}],"
|
||||
+ "\"edges\":[{\"from\":\"start\",\"to\":\"cc1\"},{\"from\":\"cc1\",\"to\":\"end\"}]}";
|
||||
BizException e = assertThrows(BizException.class,
|
||||
() -> DefValidator.validate(parse(json), Collections.emptySet()));
|
||||
assertTrue(e.getMessage().contains("抄送对象"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void notify_deptKeyWithoutMapping_fails() {
|
||||
// 运行时宽容(无人不阻塞),发布期仍拦——防配错科室静默抄送空
|
||||
String json = "{"
|
||||
+ "\"code\":\"c1\",\"name\":\"流程\",\"start\":\"start\","
|
||||
+ "\"nodes\":["
|
||||
+ "{\"id\":\"start\",\"type\":\"START\"},"
|
||||
+ "{\"id\":\"cc1\",\"type\":\"NOTIFY\",\"name\":\"抄送\",\"receivers\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"gjc\"}},"
|
||||
+ "{\"id\":\"end\",\"type\":\"END\"}],"
|
||||
+ "\"edges\":[{\"from\":\"start\",\"to\":\"cc1\"},{\"from\":\"cc1\",\"to\":\"end\"}]}";
|
||||
BizException e = assertThrows(BizException.class,
|
||||
() -> DefValidator.validate(parse(json), new HashSet<>(Collections.singletonList("ybb"))));
|
||||
assertTrue(e.getMessage().contains("gjc"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void notify_twoOutEdges_fails() {
|
||||
String json = "{"
|
||||
+ "\"code\":\"c1\",\"name\":\"流程\",\"start\":\"start\","
|
||||
+ "\"nodes\":["
|
||||
+ "{\"id\":\"start\",\"type\":\"START\"},"
|
||||
+ "{\"id\":\"cc1\",\"type\":\"NOTIFY\",\"name\":\"抄送\",\"receivers\":{\"type\":\"USERS\",\"userIds\":[301]}},"
|
||||
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"ybb\"}},"
|
||||
+ "{\"id\":\"end\",\"type\":\"END\"}],"
|
||||
+ "\"edges\":[{\"from\":\"start\",\"to\":\"cc1\"},"
|
||||
+ "{\"from\":\"cc1\",\"to\":\"n1\"},{\"from\":\"cc1\",\"to\":\"end\"},"
|
||||
+ "{\"from\":\"n1\",\"to\":\"end\"}]}";
|
||||
BizException e = assertThrows(BizException.class,
|
||||
() -> DefValidator.validate(parse(json), new HashSet<>(Collections.singletonList("ybb"))));
|
||||
assertTrue(e.getMessage().contains("出边必须恰为 1 条"));
|
||||
}
|
||||
|
||||
// ===== 嵌套并行配对与键长守卫(2026-09-24 §4.6) =====
|
||||
|
||||
@Test
|
||||
void nestedParallel_pairedSplitJoin_passes() {
|
||||
String json = "{"
|
||||
+ "\"code\":\"c1\",\"name\":\"流程\",\"start\":\"start\","
|
||||
+ "\"nodes\":["
|
||||
+ "{\"id\":\"start\",\"type\":\"START\"},"
|
||||
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"name\":\"入口\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"ybb\"}},"
|
||||
+ "{\"id\":\"s1\",\"type\":\"PARALLEL_SPLIT\"},"
|
||||
+ "{\"id\":\"ta\",\"type\":\"APPROVAL\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"ybb\"}},"
|
||||
+ "{\"id\":\"tb\",\"type\":\"APPROVAL\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"ybb\"}},"
|
||||
+ "{\"id\":\"s2\",\"type\":\"PARALLEL_SPLIT\"},"
|
||||
+ "{\"id\":\"tc\",\"type\":\"APPROVAL\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"ybb\"}},"
|
||||
+ "{\"id\":\"td\",\"type\":\"APPROVAL\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"ybb\"}},"
|
||||
+ "{\"id\":\"join2\",\"type\":\"PARALLEL_JOIN\",\"waitFor\":\"ALL\"},"
|
||||
+ "{\"id\":\"join1\",\"type\":\"PARALLEL_JOIN\",\"waitFor\":\"ALL\"},"
|
||||
+ "{\"id\":\"end\",\"type\":\"END\"}],"
|
||||
+ "\"edges\":[{\"from\":\"start\",\"to\":\"n1\"},{\"from\":\"n1\",\"to\":\"s1\"},"
|
||||
+ "{\"from\":\"s1\",\"to\":\"ta\"},{\"from\":\"s1\",\"to\":\"tb\"},"
|
||||
+ "{\"from\":\"ta\",\"to\":\"join1\"},{\"from\":\"tb\",\"to\":\"s2\"},"
|
||||
+ "{\"from\":\"s2\",\"to\":\"tc\"},{\"from\":\"s2\",\"to\":\"td\"},"
|
||||
+ "{\"from\":\"tc\",\"to\":\"join2\"},{\"from\":\"td\",\"to\":\"join2\"},"
|
||||
+ "{\"from\":\"join2\",\"to\":\"join1\"},{\"from\":\"join1\",\"to\":\"end\"}]}";
|
||||
DefValidator.validate(parse(json), new HashSet<>(Collections.singletonList("ybb")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void split_withoutJoin_fails() {
|
||||
// 分支各自直达 end、无汇聚点:split 匹配不到 join
|
||||
String json = "{"
|
||||
+ "\"code\":\"c1\",\"name\":\"流程\",\"start\":\"start\","
|
||||
+ "\"nodes\":["
|
||||
+ "{\"id\":\"start\",\"type\":\"START\"},"
|
||||
+ "{\"id\":\"split1\",\"type\":\"PARALLEL_SPLIT\"},"
|
||||
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"ybb\"}},"
|
||||
+ "{\"id\":\"end\",\"type\":\"END\"}],"
|
||||
+ "\"edges\":[{\"from\":\"start\",\"to\":\"split1\"},"
|
||||
+ "{\"from\":\"split1\",\"to\":\"n1\"},{\"from\":\"split1\",\"to\":\"end\"},"
|
||||
+ "{\"from\":\"n1\",\"to\":\"end\"}]}";
|
||||
BizException e = assertThrows(BizException.class,
|
||||
() -> DefValidator.validate(parse(json), new HashSet<>(Collections.singletonList("ybb"))));
|
||||
assertTrue(e.getMessage().contains("未汇聚"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void join_withoutUpstreamSplit_fails() {
|
||||
// 主线直连汇聚点:匹配不到分发节点(旧校验只拦"全图无 split",现按配对口径点名)
|
||||
String json = "{"
|
||||
+ "\"code\":\"c1\",\"name\":\"流程\",\"start\":\"start\","
|
||||
+ "\"nodes\":["
|
||||
+ "{\"id\":\"start\",\"type\":\"START\"},"
|
||||
+ "{\"id\":\"join1\",\"type\":\"PARALLEL_JOIN\",\"waitFor\":\"ALL\"},"
|
||||
+ "{\"id\":\"end\",\"type\":\"END\"}],"
|
||||
+ "\"edges\":[{\"from\":\"start\",\"to\":\"join1\"},{\"from\":\"join1\",\"to\":\"end\"}]}";
|
||||
BizException e = assertThrows(BizException.class,
|
||||
() -> DefValidator.validate(parse(json), Collections.emptySet()));
|
||||
assertTrue(e.getMessage().contains("匹配的分发节点"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void splitBranchHead_isJoin_fails() {
|
||||
// 分支头=汇聚点是无意义结构(键归属会错乱)
|
||||
String json = "{"
|
||||
+ "\"code\":\"c1\",\"name\":\"流程\",\"start\":\"start\","
|
||||
+ "\"nodes\":["
|
||||
+ "{\"id\":\"start\",\"type\":\"START\"},"
|
||||
+ "{\"id\":\"split1\",\"type\":\"PARALLEL_SPLIT\"},"
|
||||
+ "{\"id\":\"join1\",\"type\":\"PARALLEL_JOIN\",\"waitFor\":\"ALL\"},"
|
||||
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"ybb\"}},"
|
||||
+ "{\"id\":\"end\",\"type\":\"END\"}],"
|
||||
+ "\"edges\":[{\"from\":\"start\",\"to\":\"split1\"},"
|
||||
+ "{\"from\":\"split1\",\"to\":\"join1\"},{\"from\":\"split1\",\"to\":\"n1\"},"
|
||||
+ "{\"from\":\"n1\",\"to\":\"join1\"},{\"from\":\"join1\",\"to\":\"end\"}]}";
|
||||
BizException e = assertThrows(BizException.class,
|
||||
() -> DefValidator.validate(parse(json), new HashSet<>(Collections.singletonList("ybb"))));
|
||||
assertTrue(e.getMessage().contains("不能直接是汇聚点"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void nestedBranchKey_over64_fails() {
|
||||
// 两层 40 字符节点 id → 分支键 40+1+40=81 超 flw_branch.branch_key 列宽 64
|
||||
String outer = "b1234567890123456789012345678901234567890"; // 41 字符
|
||||
String inner = "c1234567890123456789012345678901234567890"; // 41 字符
|
||||
String json = "{"
|
||||
+ "\"code\":\"c1\",\"name\":\"流程\",\"start\":\"start\","
|
||||
+ "\"nodes\":["
|
||||
+ "{\"id\":\"start\",\"type\":\"START\"},"
|
||||
+ "{\"id\":\"s1\",\"type\":\"PARALLEL_SPLIT\"},"
|
||||
+ "{\"id\":\"" + outer + "\",\"type\":\"APPROVAL\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"ybb\"}},"
|
||||
+ "{\"id\":\"s2\",\"type\":\"PARALLEL_SPLIT\"},"
|
||||
+ "{\"id\":\"" + inner + "\",\"type\":\"APPROVAL\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"ybb\"}},"
|
||||
+ "{\"id\":\"join2\",\"type\":\"PARALLEL_JOIN\",\"waitFor\":\"ALL\"},"
|
||||
+ "{\"id\":\"join1\",\"type\":\"PARALLEL_JOIN\",\"waitFor\":\"ALL\"},"
|
||||
+ "{\"id\":\"end\",\"type\":\"END\"}],"
|
||||
+ "\"edges\":[{\"from\":\"start\",\"to\":\"s1\"},"
|
||||
+ "{\"from\":\"s1\",\"to\":\"" + outer + "\"},{\"from\":\"s1\",\"to\":\"end\"},"
|
||||
+ "{\"from\":\"" + outer + "\",\"to\":\"s2\"},"
|
||||
+ "{\"from\":\"s2\",\"to\":\"" + inner + "\"},{\"from\":\"s2\",\"to\":\"join2\"},"
|
||||
+ "{\"from\":\"" + inner + "\",\"to\":\"join2\"},{\"from\":\"join2\",\"to\":\"join1\"},"
|
||||
+ "{\"from\":\"join1\",\"to\":\"end\"}]}";
|
||||
BizException e = assertThrows(BizException.class,
|
||||
() -> DefValidator.validate(parse(json), new HashSet<>(Collections.singletonList("ybb"))));
|
||||
assertTrue(e.getMessage().contains("分支键超长"));
|
||||
}
|
||||
|
||||
// ===== CONDITION 条件分支(2026-09-24 §4) =====
|
||||
|
||||
@Test
|
||||
void condition_registeredRouter_twoNamedOutlets_passes() {
|
||||
String json = "{"
|
||||
+ "\"code\":\"c1\",\"name\":\"流程\",\"start\":\"start\","
|
||||
+ "\"nodes\":["
|
||||
+ "{\"id\":\"start\",\"type\":\"START\"},"
|
||||
+ "{\"id\":\"cond1\",\"type\":\"CONDITION\",\"name\":\"条件判断\",\"routerKey\":\"rk1\"},"
|
||||
+ "{\"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\":\"甲线\"},"
|
||||
+ "{\"from\":\"cond1\",\"to\":\"n2\",\"name\":\"乙线\"},"
|
||||
+ "{\"from\":\"n1\",\"to\":\"end\"},{\"from\":\"n2\",\"to\":\"end\"}]}";
|
||||
DefValidator.validate(parse(json), new HashSet<>(Collections.singletonList("ybb")),
|
||||
new HashSet<>(Collections.singletonList("rk1")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void condition_missingRouterKey_fails() {
|
||||
String json = "{"
|
||||
+ "\"code\":\"c1\",\"name\":\"流程\",\"start\":\"start\","
|
||||
+ "\"nodes\":["
|
||||
+ "{\"id\":\"start\",\"type\":\"START\"},"
|
||||
+ "{\"id\":\"cond1\",\"type\":\"CONDITION\",\"name\":\"条件判断\"},"
|
||||
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"ybb\"}},"
|
||||
+ "{\"id\":\"end\",\"type\":\"END\"}],"
|
||||
+ "\"edges\":[{\"from\":\"start\",\"to\":\"cond1\"},"
|
||||
+ "{\"from\":\"cond1\",\"to\":\"n1\",\"name\":\"甲线\"},"
|
||||
+ "{\"from\":\"cond1\",\"to\":\"end\",\"name\":\"乙线\"},"
|
||||
+ "{\"from\":\"n1\",\"to\":\"end\"}]}";
|
||||
BizException e = assertThrows(BizException.class,
|
||||
() -> DefValidator.validate(parse(json), new HashSet<>(Collections.singletonList("ybb")), null));
|
||||
assertTrue(e.getMessage().contains("routerKey"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void condition_unregisteredRouterKey_fails() {
|
||||
// 运行时才发现无效只会挂起——发布期就拦(设计 §4.2 发布校验)
|
||||
String json = "{"
|
||||
+ "\"code\":\"c1\",\"name\":\"流程\",\"start\":\"start\","
|
||||
+ "\"nodes\":["
|
||||
+ "{\"id\":\"start\",\"type\":\"START\"},"
|
||||
+ "{\"id\":\"cond1\",\"type\":\"CONDITION\",\"name\":\"条件判断\",\"routerKey\":\"rk-x\"},"
|
||||
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"ybb\"}},"
|
||||
+ "{\"id\":\"end\",\"type\":\"END\"}],"
|
||||
+ "\"edges\":[{\"from\":\"start\",\"to\":\"cond1\"},"
|
||||
+ "{\"from\":\"cond1\",\"to\":\"n1\",\"name\":\"甲线\"},"
|
||||
+ "{\"from\":\"cond1\",\"to\":\"end\",\"name\":\"乙线\"},"
|
||||
+ "{\"from\":\"n1\",\"to\":\"end\"}]}";
|
||||
BizException e = assertThrows(BizException.class,
|
||||
() -> DefValidator.validate(parse(json), new HashSet<>(Collections.singletonList("ybb")),
|
||||
new HashSet<>(Collections.singletonList("rk1"))));
|
||||
assertTrue(e.getMessage().contains("未注册或已停用"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void condition_singleOutlet_fails() {
|
||||
String json = "{"
|
||||
+ "\"code\":\"c1\",\"name\":\"流程\",\"start\":\"start\","
|
||||
+ "\"nodes\":["
|
||||
+ "{\"id\":\"start\",\"type\":\"START\"},"
|
||||
+ "{\"id\":\"cond1\",\"type\":\"CONDITION\",\"name\":\"条件判断\",\"routerKey\":\"rk1\"},"
|
||||
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"ybb\"}},"
|
||||
+ "{\"id\":\"end\",\"type\":\"END\"}],"
|
||||
+ "\"edges\":[{\"from\":\"start\",\"to\":\"cond1\"},"
|
||||
+ "{\"from\":\"cond1\",\"to\":\"n1\",\"name\":\"甲线\"},"
|
||||
+ "{\"from\":\"n1\",\"to\":\"end\"}]}";
|
||||
BizException e = assertThrows(BizException.class,
|
||||
() -> DefValidator.validate(parse(json), new HashSet<>(Collections.singletonList("ybb")), null));
|
||||
assertTrue(e.getMessage().contains("出边至少 2 条"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void condition_unnamedOutlet_fails() {
|
||||
// 出口名即判断接口 outlets 契约(复用多出口命名口径)
|
||||
String json = "{"
|
||||
+ "\"code\":\"c1\",\"name\":\"流程\",\"start\":\"start\","
|
||||
+ "\"nodes\":["
|
||||
+ "{\"id\":\"start\",\"type\":\"START\"},"
|
||||
+ "{\"id\":\"cond1\",\"type\":\"CONDITION\",\"name\":\"条件判断\",\"routerKey\":\"rk1\"},"
|
||||
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"ybb\"}},"
|
||||
+ "{\"id\":\"end\",\"type\":\"END\"}],"
|
||||
+ "\"edges\":[{\"from\":\"start\",\"to\":\"cond1\"},"
|
||||
+ "{\"from\":\"cond1\",\"to\":\"n1\"},"
|
||||
+ "{\"from\":\"cond1\",\"to\":\"end\",\"name\":\"乙线\"},"
|
||||
+ "{\"from\":\"n1\",\"to\":\"end\"}]}";
|
||||
BizException e = assertThrows(BizException.class,
|
||||
() -> DefValidator.validate(parse(json), new HashSet<>(Collections.singletonList("ybb")), null));
|
||||
assertTrue(e.getMessage().contains("出口必须命名"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void overviewSample_def() {
|
||||
// 概览 §5.1 缩样(并行+分支内串行):配齐 6 个 DEPT_KEY 映射应通过
|
||||
|
||||
@@ -43,6 +43,8 @@ class FlowEngineIT extends FlowTestSupport {
|
||||
private FlowTaskService taskService;
|
||||
@Autowired
|
||||
private com.yunhai.flowcenter.service.FlowQueryService queryService;
|
||||
@Autowired
|
||||
private com.yunhai.flowcenter.service.FlowRouterAdminService routerAdminService;
|
||||
|
||||
@TestConfiguration
|
||||
static class StubConfig {
|
||||
@@ -51,11 +53,47 @@ class FlowEngineIT extends FlowTestSupport {
|
||||
public com.yunhai.flowcenter.stub.StubUserClient stubUserClient() {
|
||||
return new com.yunhai.flowcenter.stub.StubUserClient();
|
||||
}
|
||||
|
||||
/**
|
||||
* 路由判断桩(2026-09-24 CONDITION):替换真实 RouteJudgeClient(不出网),
|
||||
* 每次调用取 StubJudge 当前编程值——出口名或抛错。真实客户端的重试/寻址逻辑
|
||||
* 由 RouteJudgeClientTest 覆盖;本类聚焦引擎行为(选路/挂起/重跑/重判)。
|
||||
*/
|
||||
@Bean
|
||||
@Primary
|
||||
public RouteJudgeClient stubRouteJudgeClient() {
|
||||
return new RouteJudgeClient(null, new com.fasterxml.jackson.databind.ObjectMapper()) {
|
||||
@Override
|
||||
public RouteJudgeClient.JudgeResult call(com.yunhai.flowcenter.entity.FlwRouter router,
|
||||
RouteJudgeClient.JudgeBody body) {
|
||||
return StubJudge.next();
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** 判断桩的编程值:outlet=命中出口;failure=非空时抛出(模拟服务不可用) */
|
||||
static final class StubJudge {
|
||||
static volatile String outlet = "甲线";
|
||||
static volatile RuntimeException failure;
|
||||
|
||||
static RouteJudgeClient.JudgeResult next() {
|
||||
if (failure != null) {
|
||||
throw failure;
|
||||
}
|
||||
return new RouteJudgeClient.JudgeResult(outlet, "{\"outlet\":\"" + outlet + "\"}");
|
||||
}
|
||||
|
||||
static void reset(String defaultOutlet) {
|
||||
outlet = defaultOutlet;
|
||||
failure = null;
|
||||
}
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
cleanDb();
|
||||
StubJudge.reset("甲线");
|
||||
}
|
||||
|
||||
private FlowInstanceService.ApplyOutcome apply(String flowGuid, String bizId,
|
||||
@@ -433,6 +471,286 @@ class FlowEngineIT extends FlowTestSupport {
|
||||
instanceRepository.findById(instanceId).get().getStatus());
|
||||
}
|
||||
|
||||
/**
|
||||
* NOTIFY 抄送·主线(2026-09-23):激活即逐人落 flw_cc,不建任务、不进 currentNodes,
|
||||
* 流程无停顿直达下一审批节点;办件轨迹出现 kind=CC 行且"抄送我的"可查到。
|
||||
*/
|
||||
@Test
|
||||
void notify_onMainline_writesCcRows_andKeepsFlowMoving() {
|
||||
mapDept("km", 101, "甲");
|
||||
FlwDef def = saveAndPublishDef("t16", "抄送主线", notifyMainlineDef("km"));
|
||||
FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-16", 201, "张三");
|
||||
Long instanceId = outcome.getInstanceId();
|
||||
|
||||
// 抄送瞬时放行:两个接收人各一行,无 cc 节点任务,直接建审核待办
|
||||
List<com.yunhai.flowcenter.entity.FlwCc> ccs = ccRepository.findByInstanceIdOrderByIdAsc(instanceId);
|
||||
assertEquals(2, ccs.size());
|
||||
assertEquals("cc1", ccs.get(0).getNodeId());
|
||||
assertEquals("抄送校领导", ccs.get(0).getNodeName());
|
||||
assertEquals(301, ccs.get(0).getUserId());
|
||||
assertEquals(302, ccs.get(1).getUserId());
|
||||
assertEquals(1, taskRepository.findByInstanceIdAndStatus(instanceId, 0).size());
|
||||
FlwInstance instance = instanceRepository.findById(instanceId).get();
|
||||
assertEquals("n1", instance.getCurrentNodes());
|
||||
assertEquals(FlwEnums.InstanceStatus.APPROVING.getCode(), instance.getStatus());
|
||||
// 审核通过 → 完成(抄送节点从未阻塞过推进)
|
||||
taskService.handle(SCH, tasksOf(instanceId, 101).get(0).getId(), true, "ok", 101);
|
||||
assertEquals(FlwEnums.InstanceStatus.COMPLETED.getCode(),
|
||||
instanceRepository.findById(instanceId).get().getStatus());
|
||||
|
||||
// 轨迹:发起 → 抄送×2 → 同意。时间列毫秒精度(DATETIME(3))后亚秒机器链因果序可复现;
|
||||
// 展示字符串截断到秒(str),排序用原值。
|
||||
java.util.List<java.util.Map<String, Object>> trace =
|
||||
queryService.detail(SCH, instanceId, null).getTrace();
|
||||
java.util.List<String> kinds = new java.util.ArrayList<>();
|
||||
for (java.util.Map<String, Object> row : trace) {
|
||||
kinds.add((String) row.get("kind"));
|
||||
}
|
||||
assertEquals(java.util.Arrays.asList("SUBMIT", "CC", "CC", "APPROVE"), kinds);
|
||||
// 抄送我的:301 视角一行,回填办件信息
|
||||
com.yunhai.platform.utils.model.BasePageResult<com.yunhai.flowcenter.model.FlowModels.CcItem>
|
||||
ccPage = queryService.pageCc(SCH, 301, 1, 10);
|
||||
assertEquals(1, ccPage.getContent().size());
|
||||
assertEquals(instanceId, ccPage.getContent().get(0).getInstanceId());
|
||||
assertEquals("张三的测试办件", ccPage.getContent().get(0).getBizTitle());
|
||||
}
|
||||
|
||||
/** NOTIFY 在并行分支内:瞬时到达 JOIN 不占等待(审批支到齐即放行),落库与轨迹同主线口径 */
|
||||
@Test
|
||||
void notify_inParallelBranch_reachesJoin_withoutBlocking() {
|
||||
mapDept("km", 101, "甲");
|
||||
FlwDef def = saveAndPublishDef("t17", "抄送并行", notifyParallelDef("km"));
|
||||
FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-17", 201, "张三");
|
||||
Long instanceId = outcome.getInstanceId();
|
||||
// 抄送支激活即写行并到达 JOIN:分支一 RUNNING(审批支)、一 ARRIVED(抄送支)
|
||||
List<FlwBranch> branches = branchRows(instanceId);
|
||||
assertEquals(2, branches.size());
|
||||
assertEquals(1, branches.stream()
|
||||
.filter(b -> FlwEnums.BranchStatus.RUNNING.getCode() == b.getStatus()).count());
|
||||
assertEquals(1, ccRepository.findByInstanceIdOrderByIdAsc(instanceId).size());
|
||||
// 办件仍在途等审批支:乙支瞬时完成没有让 JOIN 提前放行
|
||||
assertEquals(FlwEnums.InstanceStatus.APPROVING.getCode(),
|
||||
instanceRepository.findById(instanceId).get().getStatus());
|
||||
// 审批支到齐 → JOIN 放行 → 完成
|
||||
taskService.handle(SCH, tasksOf(instanceId, 101).get(0).getId(), true, "ok", 101);
|
||||
assertEquals(FlwEnums.InstanceStatus.COMPLETED.getCode(),
|
||||
instanceRepository.findById(instanceId).get().getStatus());
|
||||
}
|
||||
|
||||
/** NOTIFY 接收人解析不出人(映射停用/名单空):不阻塞推进,只是没有抄送行 */
|
||||
@Test
|
||||
void notify_unresolvableReceivers_skipsCc_butFlowContinues() {
|
||||
mapDept("km", 101, "甲");
|
||||
// deptKey 未注册任何映射(mapDept 只注册 km,抄送挂 kx)
|
||||
String json = notifyMainlineDef("km").replace(
|
||||
"\"receivers\":{\"type\":\"USERS\",\"userIds\":[301,302]}}",
|
||||
"\"receivers\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"kx\"}}");
|
||||
FlwDef def = saveAndPublishDef("t18", "抄送空对象", json);
|
||||
FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-18", 201, "张三");
|
||||
Long instanceId = outcome.getInstanceId();
|
||||
assertTrue(ccRepository.findByInstanceIdOrderByIdAsc(instanceId).isEmpty(),
|
||||
"解析不出人不应落行");
|
||||
assertEquals("n1", instanceRepository.findById(instanceId).get().getCurrentNodes());
|
||||
assertEquals(FlwEnums.InstanceStatus.APPROVING.getCode(),
|
||||
instanceRepository.findById(instanceId).get().getStatus());
|
||||
taskService.handle(SCH, tasksOf(instanceId, 101).get(0).getId(), true, "ok", 101);
|
||||
assertEquals(FlwEnums.InstanceStatus.COMPLETED.getCode(),
|
||||
instanceRepository.findById(instanceId).get().getStatus());
|
||||
}
|
||||
|
||||
/**
|
||||
* 嵌套并行 scope 修复(2026-09-24 §4.6):外层分支 tb 内再套并行段 s2{tc,td}——
|
||||
* 修复前 JOIN 计数是实例级(tb 行仍在进行),内层 join2 到不齐、外层 join1 等 tb,互锁死。
|
||||
* 修复后:内层两支到齐 → join2 放行并以外层上下文 tb 续走 → join1 两轮到齐才完成。
|
||||
*/
|
||||
@Test
|
||||
void nestedParallel_innerJoinReleasesFirst_outerCompletesAfterBoth() {
|
||||
mapDept("km", 103, "主");
|
||||
mapDept("ka", 101, "甲");
|
||||
mapDept("kb", 102, "乙");
|
||||
mapDept("kc", 104, "丙");
|
||||
mapDept("kd", 105, "丁");
|
||||
FlwDef def = saveAndPublishDef("t19", "嵌套并行", nestedParallelDef("km", "ka", "kb", "kc", "kd"));
|
||||
FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-19", 201, "张三");
|
||||
Long instanceId = outcome.getInstanceId();
|
||||
taskService.handle(SCH, tasksOf(instanceId, 103).get(0).getId(), true, "ok", 103);
|
||||
// 外层 split:顶层分支键无点号(存量口径)
|
||||
assertEquals(new java.util.TreeSet<>(java.util.Arrays.asList("ta", "tb")),
|
||||
branchRows(instanceId).stream().map(FlwBranch::getBranchKey)
|
||||
.collect(java.util.stream.Collectors.toCollection(java.util.TreeSet::new)));
|
||||
// 甲支先过:join1 等外层 tb(其内层段未走),办件仍在途
|
||||
taskService.handle(SCH, tasksOf(instanceId, 101).get(0).getId(), true, "ok", 101);
|
||||
assertEquals(FlwEnums.InstanceStatus.APPROVING.getCode(),
|
||||
instanceRepository.findById(instanceId).get().getStatus());
|
||||
// 乙支过 → 进入内层并行段:分支键=路径前缀 tb.tc / tb.td
|
||||
taskService.handle(SCH, tasksOf(instanceId, 102).get(0).getId(), true, "ok", 102);
|
||||
java.util.Map<String, Integer> byKey = branchRows(instanceId).stream()
|
||||
.collect(java.util.stream.Collectors.toMap(FlwBranch::getBranchKey, FlwBranch::getStatus));
|
||||
assertEquals(4, byKey.size());
|
||||
assertEquals(FlwEnums.BranchStatus.ARRIVED.getCode(), (int) byKey.get("ta"));
|
||||
assertEquals(FlwEnums.BranchStatus.RUNNING.getCode(), (int) byKey.get("tb"));
|
||||
assertEquals(FlwEnums.BranchStatus.RUNNING.getCode(), (int) byKey.get("tb.tc"));
|
||||
assertEquals(FlwEnums.BranchStatus.RUNNING.getCode(), (int) byKey.get("tb.td"));
|
||||
// 内层丙支先过:join2 等丁支(修复前这里会因实例级计数连带等 tb 而死锁)
|
||||
taskService.handle(SCH, tasksOf(instanceId, 104).get(0).getId(), true, "ok", 104);
|
||||
assertEquals(FlwEnums.InstanceStatus.APPROVING.getCode(),
|
||||
instanceRepository.findById(instanceId).get().getStatus());
|
||||
// 丁支到齐 → join2 放行(tb 标记到达)→ join1 两轮到齐 → END 完成
|
||||
taskService.handle(SCH, tasksOf(instanceId, 105).get(0).getId(), true, "ok", 105);
|
||||
assertEquals(FlwEnums.InstanceStatus.COMPLETED.getCode(),
|
||||
instanceRepository.findById(instanceId).get().getStatus());
|
||||
}
|
||||
|
||||
/** 出口分支内并行段(CONDITION 分支内并行的前置形态):选"乙线"出口 → 并行段两支到齐 → 汇聚完成 */
|
||||
@Test
|
||||
void outletBranch_withParallelSegment_joinsAndCompletes() {
|
||||
mapDept("km", 103, "主");
|
||||
mapDept("ka", 101, "甲");
|
||||
mapDept("kb", 102, "乙");
|
||||
FlwDef def = saveAndPublishDef("t21", "出口并行", outletParallelDef("km", "ka", "kb"));
|
||||
FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-21", 201, "张三");
|
||||
Long instanceId = outcome.getInstanceId();
|
||||
// 选"乙线"出口进入并行段(scope=主线,出口校验应放行)
|
||||
taskService.handle(SCH, tasksOf(instanceId, 103).get(0).getId(), true, "走乙线", 103, "乙线");
|
||||
assertEquals(FlwEnums.InstanceStatus.APPROVING.getCode(),
|
||||
instanceRepository.findById(instanceId).get().getStatus());
|
||||
assertEquals(2, taskRepository.findByInstanceIdAndStatus(instanceId, 0).size());
|
||||
// 两支到齐 → join2 放行 → END 完成
|
||||
taskService.handle(SCH, tasksOf(instanceId, 101).get(0).getId(), true, "ok", 101);
|
||||
assertEquals(FlwEnums.InstanceStatus.APPROVING.getCode(),
|
||||
instanceRepository.findById(instanceId).get().getStatus());
|
||||
taskService.handle(SCH, tasksOf(instanceId, 102).get(0).getId(), true, "ok", 102);
|
||||
assertEquals(FlwEnums.InstanceStatus.COMPLETED.getCode(),
|
||||
instanceRepository.findById(instanceId).get().getStatus());
|
||||
}
|
||||
|
||||
/**
|
||||
* CONDITION 命中选路(2026-09-24 §4):判断返回"甲线"→ 只激活甲线待办,乙线不建任务;
|
||||
* 办理后完成;轨迹出现 kind=ROUTE 行("系统判断 → 甲线"),留痕 outlet=甲线。
|
||||
*/
|
||||
@Test
|
||||
void condition_routesByOutlet_andTraceShowsRouteRow() {
|
||||
mapDept("km", 101, "甲");
|
||||
mapDept("kb", 102, "乙");
|
||||
registerRouter("test.judge");
|
||||
FlwDef def = saveAndPublishDef("t22", "条件分支", conditionDef("test.judge", "km", "kb"));
|
||||
StubJudge.reset("甲线");
|
||||
FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-22", 201, "张三");
|
||||
Long instanceId = outcome.getInstanceId();
|
||||
|
||||
// 甲线命中:仅甲科一条待办,CONDITION 已放行(不在 currentNodes)
|
||||
List<FlwTask> pending = taskRepository.findByInstanceIdAndStatus(instanceId, 0);
|
||||
assertEquals(1, pending.size());
|
||||
assertEquals("n1", pending.get(0).getNodeId());
|
||||
assertEquals("n1", instanceRepository.findById(instanceId).get().getCurrentNodes());
|
||||
|
||||
taskService.handle(SCH, pending.get(0).getId(), true, "ok", 101);
|
||||
assertEquals(FlwEnums.InstanceStatus.COMPLETED.getCode(),
|
||||
instanceRepository.findById(instanceId).get().getStatus());
|
||||
|
||||
// 留痕 + 轨迹:ROUTE 行在发起之后、办理之前
|
||||
List<com.yunhai.flowcenter.entity.FlwRouteLog> logs =
|
||||
routeLogRepository.findByInstanceIdOrderByIdAsc(instanceId);
|
||||
assertEquals(1, logs.size());
|
||||
assertEquals("甲线", logs.get(0).getOutlet());
|
||||
assertEquals(1, logs.get(0).getAttempt());
|
||||
java.util.List<java.util.Map<String, Object>> trace =
|
||||
queryService.detail(SCH, instanceId, null).getTrace();
|
||||
java.util.List<String> kinds = new java.util.ArrayList<>();
|
||||
for (java.util.Map<String, Object> row : trace) {
|
||||
kinds.add((String) row.get("kind"));
|
||||
}
|
||||
assertEquals(java.util.Arrays.asList("SUBMIT", "ROUTE", "APPROVE"), kinds);
|
||||
assertEquals("甲线", trace.get(1).get("outlet"));
|
||||
}
|
||||
|
||||
/**
|
||||
* CONDITION 判断失败挂起 + 管理端重跑(§4.4 已定策略):失败=节点保持在途、办件仍 10、
|
||||
* 落 outlet=NULL 留痕行(无默认出口,宁挂起);重跑成功后按新出口续走到完成。
|
||||
*/
|
||||
@Test
|
||||
void condition_judgeFails_suspends_thenAdminReroute_resumes() {
|
||||
mapDept("km", 101, "甲");
|
||||
mapDept("kb", 102, "乙");
|
||||
registerRouter("test.judge");
|
||||
FlwDef def = saveAndPublishDef("t23", "条件挂起", conditionDef("test.judge", "km", "kb"));
|
||||
StubJudge.failure = new RouteJudgeClient.JudgeUnavailableException("服务不可用", null);
|
||||
FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-23", 201, "张三");
|
||||
Long instanceId = outcome.getInstanceId();
|
||||
|
||||
// 挂起:办件在途、CONDITION 占位在 currentNodes、无待办任务
|
||||
FlwInstance suspended = instanceRepository.findById(instanceId).get();
|
||||
assertEquals(FlwEnums.InstanceStatus.APPROVING.getCode(), suspended.getStatus());
|
||||
assertEquals("cond1", suspended.getCurrentNodes());
|
||||
assertTrue(taskRepository.findByInstanceIdAndStatus(instanceId, 0).isEmpty());
|
||||
List<com.yunhai.flowcenter.entity.FlwRouteLog> failLogs =
|
||||
routeLogRepository.findByInstanceIdOrderByIdAsc(instanceId);
|
||||
assertEquals(1, failLogs.size());
|
||||
assertNull(failLogs.get(0).getOutlet(), "失败行 outlet=NULL");
|
||||
assertEquals(1, failLogs.get(0).getAttempt());
|
||||
|
||||
// 重跑仍失败:留痕 attempt=2,办件继续挂起(业务错抛出)
|
||||
assertThrows(com.yunhai.platform.apistd.BizException.class,
|
||||
() -> routerAdminService.reroute(SCH, instanceId));
|
||||
assertEquals(2, routeLogRepository.findByInstanceIdOrderByIdAsc(instanceId).size());
|
||||
assertEquals("cond1", instanceRepository.findById(instanceId).get().getCurrentNodes());
|
||||
|
||||
// 判断恢复 → 重跑成功:按乙线续走建待办,办理完成;attempt=3 留痕 outlet=乙线
|
||||
StubJudge.reset("乙线");
|
||||
routerAdminService.reroute(SCH, instanceId);
|
||||
FlwInstance resumed = instanceRepository.findById(instanceId).get();
|
||||
assertEquals("n2", resumed.getCurrentNodes());
|
||||
List<FlwTask> pending = taskRepository.findByInstanceIdAndStatus(instanceId, 0);
|
||||
assertEquals(1, pending.size());
|
||||
assertEquals(102, pending.get(0).getAssigneeId());
|
||||
taskService.handle(SCH, pending.get(0).getId(), true, "ok", 102);
|
||||
assertEquals(FlwEnums.InstanceStatus.COMPLETED.getCode(),
|
||||
instanceRepository.findById(instanceId).get().getStatus());
|
||||
List<com.yunhai.flowcenter.entity.FlwRouteLog> logs =
|
||||
routeLogRepository.findByInstanceIdOrderByIdAsc(instanceId);
|
||||
assertEquals(3, logs.size());
|
||||
assertEquals("乙线", logs.get(2).getOutlet());
|
||||
assertEquals(3, logs.get(2).getAttempt());
|
||||
// 完成后的办件不再可重跑
|
||||
assertThrows(com.yunhai.platform.apistd.BizException.class,
|
||||
() -> routerAdminService.reroute(SCH, instanceId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 退回重走 CONDITION 必须重新调判断(§4.4:单据可能已改,留痕只增不改多轮可见):
|
||||
* 一轮判"甲线"到 n1,退回 n0 重走判"乙线"直达 END;route_log 两行 attempt 递增。
|
||||
*/
|
||||
@Test
|
||||
void condition_returnBack_rewalk_rejudges() {
|
||||
mapDept("km", 101, "甲");
|
||||
mapDept("ka", 102, "乙");
|
||||
registerRouter("test.judge");
|
||||
FlwDef def = saveAndPublishDef("t24", "条件重判", mainlineConditionDef("test.judge", "km", "ka"));
|
||||
StubJudge.reset("甲线");
|
||||
FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-24", 201, "张三");
|
||||
Long instanceId = outcome.getInstanceId();
|
||||
|
||||
// 一轮:n0 过 → 判甲线 → n1 待办
|
||||
taskService.handle(SCH, tasksOf(instanceId, 101).get(0).getId(), true, "ok", 101);
|
||||
assertEquals("n1", instanceRepository.findById(instanceId).get().getCurrentNodes());
|
||||
// 退回入口 n0 重走(改判乙线)
|
||||
StubJudge.reset("乙线");
|
||||
FlwTask n1Task = taskRepository.findByInstanceIdAndStatus(instanceId, 0).get(0);
|
||||
taskService.returnBack(SCH, n1Task.getId(), "n0", "改判乙线", 102);
|
||||
taskService.handle(SCH, tasksOf(instanceId, 101).get(0).getId(), true, "再走一遍", 101);
|
||||
// 乙线直达 END:办件完成(n1 未再激活)
|
||||
assertEquals(FlwEnums.InstanceStatus.COMPLETED.getCode(),
|
||||
instanceRepository.findById(instanceId).get().getStatus());
|
||||
// 留痕两轮:attempt 1=甲线、2=乙线(只增不改)
|
||||
List<com.yunhai.flowcenter.entity.FlwRouteLog> logs =
|
||||
routeLogRepository.findByInstanceIdOrderByIdAsc(instanceId);
|
||||
assertEquals(2, logs.size());
|
||||
assertEquals("甲线", logs.get(0).getOutlet());
|
||||
assertEquals(1, logs.get(0).getAttempt());
|
||||
assertEquals("乙线", logs.get(1).getOutlet());
|
||||
assertEquals(2, logs.get(1).getAttempt());
|
||||
}
|
||||
|
||||
private List<FlwBranch> branchRows(Long instanceId) {
|
||||
return branchRepository.findByInstanceId(instanceId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.yunhai.flowcenter.engine;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.flowcenter.entity.FlwRouter;
|
||||
import com.yunhai.platform.openapi.core.TargetSpec;
|
||||
import com.yunhai.platform.openapi.core.YschOpenCall;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyMap;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 路由判断客户端单测(2026-09-24 §4.3):重试纪律(3s×3 退避重试,Mock 出网)、
|
||||
* flw_router.base_path 对目录前缀的覆盖、信封 data.outlet 解析。
|
||||
* 引擎行为(选路/挂起/重跑)由 FlowEngineIT 覆盖。
|
||||
*/
|
||||
class RouteJudgeClientTest {
|
||||
|
||||
private YschOpenCall openCall;
|
||||
private RouteJudgeClient client;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
openCall = mock(YschOpenCall.class);
|
||||
when(openCall.resolveTarget(anyString()))
|
||||
.thenReturn(TargetSpec.of("http://dir-host:8101", "/dirctx"));
|
||||
client = new RouteJudgeClient(openCall, new ObjectMapper());
|
||||
}
|
||||
|
||||
private FlwRouter router(String basePath) {
|
||||
FlwRouter r = new FlwRouter();
|
||||
r.setRouterKey("rk");
|
||||
r.setName("n");
|
||||
r.setServiceKey("notice");
|
||||
r.setPath("/oapi/notice/flow/route/dept-judge");
|
||||
r.setBasePath(basePath);
|
||||
r.setStatus(1);
|
||||
return r;
|
||||
}
|
||||
|
||||
private RouteJudgeClient.JudgeBody body() {
|
||||
return new RouteJudgeClient.JudgeBody(1L, 2L, 3, "notice", "456",
|
||||
"node_r1", "判断", "rk", Collections.singletonList("甲线"), new HashMap<>());
|
||||
}
|
||||
|
||||
@Test
|
||||
void success_resolvesOutlet_andPassesRouterBasePathOverride() {
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("outlet", "甲线");
|
||||
when(openCall.post(any(TargetSpec.class), anyString(), anyMap(), any(), eq(Map.class)))
|
||||
.thenReturn(data);
|
||||
|
||||
RouteJudgeClient.JudgeResult result = client.call(router("/notice"), body());
|
||||
|
||||
assertEquals("甲线", result.outlet);
|
||||
ArgumentCaptor<TargetSpec> spec = ArgumentCaptor.forClass(TargetSpec.class);
|
||||
verify(openCall).post(spec.capture(), anyString(), anyMap(), any(), eq(Map.class));
|
||||
assertEquals("http://dir-host:8101", spec.getValue().getBaseHost(), "host 恒取目录解析");
|
||||
assertEquals("/notice", spec.getValue().getBasePath(), "flw_router.base_path 非空时覆盖目录前缀");
|
||||
assertEquals((Long) 3000L, spec.getValue().getReadTimeoutMs(), "判断专用 3s 超时");
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyBasePath_fallsBackToDirectoryPrefix() {
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("outlet", "甲线");
|
||||
when(openCall.post(any(TargetSpec.class), anyString(), anyMap(), any(), eq(Map.class)))
|
||||
.thenReturn(data);
|
||||
|
||||
client.call(router(""), body());
|
||||
|
||||
ArgumentCaptor<TargetSpec> spec = ArgumentCaptor.forClass(TargetSpec.class);
|
||||
verify(openCall).post(spec.capture(), anyString(), anyMap(), any(), eq(Map.class));
|
||||
assertEquals("/dirctx", spec.getValue().getBasePath(), "base_path 空=沿用目录条目 context-path");
|
||||
}
|
||||
|
||||
@Test
|
||||
void transportFailure_retriesThreeTimes_thenThrows() {
|
||||
when(openCall.post(any(TargetSpec.class), anyString(), anyMap(), any(), eq(Map.class)))
|
||||
.thenThrow(new RuntimeException("connect timeout"));
|
||||
|
||||
assertThrows(RouteJudgeClient.JudgeUnavailableException.class,
|
||||
() -> client.call(router(""), body()));
|
||||
verify(openCall, times(RouteJudgeClient.MAX_ATTEMPTS))
|
||||
.post(any(TargetSpec.class), anyString(), anyMap(), any(), eq(Map.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingOutletInResponse_failsAndRetries() {
|
||||
// 信封 data 无 outlet=契约违规,按失败重试口径处理(非 2xx 同类)
|
||||
when(openCall.post(any(TargetSpec.class), anyString(), anyMap(), any(), eq(Map.class)))
|
||||
.thenReturn(new HashMap<String, Object>());
|
||||
assertThrows(RouteJudgeClient.JudgeUnavailableException.class,
|
||||
() -> client.call(router(""), body()));
|
||||
verify(openCall, times(RouteJudgeClient.MAX_ATTEMPTS))
|
||||
.post(any(TargetSpec.class), anyString(), anyMap(), any(), eq(Map.class));
|
||||
}
|
||||
}
|
||||
@@ -87,6 +87,7 @@ class FlowDiagramServiceTest {
|
||||
private FlwDefRepository defRepository;
|
||||
private FlwAssigneeMapRepository mapRepository;
|
||||
private FlwTaskRepository taskRepository;
|
||||
private com.yunhai.flowcenter.repository.FlwCcRepository ccRepository;
|
||||
private OpenApiUserClient userClient;
|
||||
private FlowDiagramService service;
|
||||
|
||||
@@ -95,16 +96,22 @@ class FlowDiagramServiceTest {
|
||||
defRepository = mock(FlwDefRepository.class);
|
||||
mapRepository = mock(FlwAssigneeMapRepository.class);
|
||||
taskRepository = mock(FlwTaskRepository.class);
|
||||
ccRepository = mock(com.yunhai.flowcenter.repository.FlwCcRepository.class);
|
||||
userClient = mock(OpenApiUserClient.class);
|
||||
when(taskRepository.findByInstanceIdOrderByIdAsc(anyLong()))
|
||||
.thenReturn(Collections.emptyList());
|
||||
// 真 FlowEngine(真实 JSON 解析通道)+ mock 依赖
|
||||
when(ccRepository.findByInstanceIdOrderByIdAsc(anyLong()))
|
||||
.thenReturn(Collections.emptyList());
|
||||
// 真 FlowEngine(真实 JSON 解析通道)+ mock 依赖(ConditionRouteService 不触达:纯示意图测试)
|
||||
FlowEngine engine = new FlowEngine(new ObjectMapper(),
|
||||
taskRepository, mock(FlwBranchRepository.class), mock(AssigneeResolver.class),
|
||||
taskRepository, mock(FlwBranchRepository.class), ccRepository,
|
||||
mock(AssigneeResolver.class), mock(com.yunhai.flowcenter.engine.ConditionRouteService.class),
|
||||
mock(ApplicationEventPublisher.class));
|
||||
service = new FlowDiagramService(defRepository, mapRepository,
|
||||
mock(com.yunhai.flowcenter.repository.FlwInstanceRepository.class),
|
||||
taskRepository, engine, userClient);
|
||||
taskRepository, ccRepository,
|
||||
mock(com.yunhai.flowcenter.repository.FlwRouteLogRepository.class),
|
||||
engine, userClient);
|
||||
}
|
||||
|
||||
private FlwDef def(String json) {
|
||||
@@ -473,6 +480,7 @@ class FlowDiagramServiceTest {
|
||||
// 真管理守卫(纯逻辑无依赖)+ 其余依赖 mock
|
||||
controller = new FlowAdminController(new FlowAdminGuard(), defAdminService,
|
||||
mock(FlowMapAdminService.class), mock(FlowCallbackAdminService.class),
|
||||
mock(com.yunhai.flowcenter.service.FlowRouterAdminService.class),
|
||||
mock(FlwInstanceRepository.class), diagramService,
|
||||
mock(com.yunhai.flowcenter.service.DiagramImageService.class));
|
||||
}
|
||||
|
||||
@@ -4,11 +4,16 @@ import com.yunhai.flowcenter.entity.FlwAssigneeMap;
|
||||
import com.yunhai.flowcenter.entity.FlwCallbackLog;
|
||||
import com.yunhai.flowcenter.entity.FlwCallbackReg;
|
||||
import com.yunhai.flowcenter.entity.FlwDef;
|
||||
import com.yunhai.flowcenter.entity.FlwRouteLog;
|
||||
import com.yunhai.flowcenter.entity.FlwRouter;
|
||||
import com.yunhai.flowcenter.repository.FlwAssigneeMapRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwCallbackLogRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwCallbackRegRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwCcRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwDefRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwRouteLogRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwRouterRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwTaskRepository;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
|
||||
@@ -34,10 +39,19 @@ public abstract class FlowTestSupport {
|
||||
protected FlwCallbackRegRepository regRepository;
|
||||
@Autowired
|
||||
protected FlwCallbackLogRepository logRepository;
|
||||
@Autowired
|
||||
protected FlwCcRepository ccRepository;
|
||||
@Autowired
|
||||
protected FlwRouterRepository routerRepository;
|
||||
@Autowired
|
||||
protected FlwRouteLogRepository routeLogRepository;
|
||||
|
||||
protected void cleanDb() {
|
||||
logRepository.deleteAll();
|
||||
regRepository.deleteAll();
|
||||
ccRepository.deleteAll();
|
||||
routeLogRepository.deleteAll();
|
||||
routerRepository.deleteAll();
|
||||
taskRepository.deleteAll();
|
||||
instanceRepository.deleteAll();
|
||||
defRepository.deleteAll();
|
||||
@@ -100,6 +114,95 @@ public abstract class FlowTestSupport {
|
||||
+ "\"settings\":{\"rejectPolicy\":\"END_REJECTED\"}}";
|
||||
}
|
||||
|
||||
/** 抄送主线流程:START → cc(USERS [301,302]) → n1(deptA) → END */
|
||||
protected String notifyMainlineDef(String deptA) {
|
||||
return "{"
|
||||
+ "\"code\":\"" + codeOf() + "\",\"name\":\"抄送主线测试流程\",\"start\":\"start\","
|
||||
+ "\"nodes\":["
|
||||
+ "{\"id\":\"start\",\"type\":\"START\",\"name\":\"开始\"},"
|
||||
+ "{\"id\":\"cc1\",\"type\":\"NOTIFY\",\"name\":\"抄送校领导\",\"receivers\":{\"type\":\"USERS\",\"userIds\":[301,302]}},"
|
||||
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"name\":\"审核\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"" + deptA + "\"}},"
|
||||
+ "{\"id\":\"end\",\"type\":\"END\"}"
|
||||
+ "],"
|
||||
+ "\"edges\":[{\"from\":\"start\",\"to\":\"cc1\"},{\"from\":\"cc1\",\"to\":\"n1\"},"
|
||||
+ "{\"from\":\"n1\",\"to\":\"end\"}],"
|
||||
+ "\"settings\":{\"rejectPolicy\":\"END_REJECTED\"}}";
|
||||
}
|
||||
|
||||
/** 抄送并行流程:START → split → {n1(deptA), cc(USERS [301])} → join → END(抄送支瞬时到达汇聚) */
|
||||
protected String notifyParallelDef(String deptA) {
|
||||
return "{"
|
||||
+ "\"code\":\"" + codeOf() + "\",\"name\":\"抄送并行测试流程\",\"start\":\"start\","
|
||||
+ "\"nodes\":["
|
||||
+ "{\"id\":\"start\",\"type\":\"START\",\"name\":\"开始\"},"
|
||||
+ "{\"id\":\"split1\",\"type\":\"PARALLEL_SPLIT\"},"
|
||||
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"name\":\"审核\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"" + deptA + "\"}},"
|
||||
+ "{\"id\":\"cc1\",\"type\":\"NOTIFY\",\"name\":\"抄送\",\"receivers\":{\"type\":\"USERS\",\"userIds\":[301]}},"
|
||||
+ "{\"id\":\"join1\",\"type\":\"PARALLEL_JOIN\",\"waitFor\":\"ALL\"},"
|
||||
+ "{\"id\":\"end\",\"type\":\"END\"}"
|
||||
+ "],"
|
||||
+ "\"edges\":[{\"from\":\"start\",\"to\":\"split1\"},"
|
||||
+ "{\"from\":\"split1\",\"to\":\"n1\"},{\"from\":\"split1\",\"to\":\"cc1\"},"
|
||||
+ "{\"from\":\"n1\",\"to\":\"join1\"},{\"from\":\"cc1\",\"to\":\"join1\"},"
|
||||
+ "{\"from\":\"join1\",\"to\":\"end\"}],"
|
||||
+ "\"settings\":{\"rejectPolicy\":\"END_REJECTED\"}}";
|
||||
}
|
||||
|
||||
/**
|
||||
* 条件分支流程(2026-09-24 CONDITION):START → cond1(routerKey) --"甲线"--> n1(deptA) → END
|
||||
* └-"乙线"--> n2(deptB) → END
|
||||
*/
|
||||
protected String conditionDef(String routerKey, String deptA, String deptB) {
|
||||
return "{"
|
||||
+ "\"code\":\"" + codeOf() + "\",\"name\":\"条件分支测试流程\",\"start\":\"start\","
|
||||
+ "\"nodes\":["
|
||||
+ "{\"id\":\"start\",\"type\":\"START\",\"name\":\"开始\"},"
|
||||
+ "{\"id\":\"cond1\",\"type\":\"CONDITION\",\"name\":\"条件判断\",\"routerKey\":\"" + routerKey + "\"},"
|
||||
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"name\":\"甲线审核\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"" + deptA + "\"}},"
|
||||
+ "{\"id\":\"n2\",\"type\":\"APPROVAL\",\"name\":\"乙线审核\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"" + deptB + "\"}},"
|
||||
+ "{\"id\":\"end\",\"type\":\"END\"}"
|
||||
+ "],"
|
||||
+ "\"edges\":[{\"from\":\"start\",\"to\":\"cond1\"},"
|
||||
+ "{\"from\":\"cond1\",\"to\":\"n1\",\"name\":\"甲线\"},"
|
||||
+ "{\"from\":\"cond1\",\"to\":\"n2\",\"name\":\"乙线\"},"
|
||||
+ "{\"from\":\"n1\",\"to\":\"end\"},{\"from\":\"n2\",\"to\":\"end\"}],"
|
||||
+ "\"settings\":{\"rejectPolicy\":\"END_REJECTED\"}}";
|
||||
}
|
||||
|
||||
/**
|
||||
* 主线条件分支(退回重走重判用):START → n0(deptMain) → cond1(routerKey)
|
||||
* --"甲线"--> n1(deptA) → END;--"乙线"--> END(乙线直达终点)
|
||||
*/
|
||||
protected String mainlineConditionDef(String routerKey, String deptMain, String deptA) {
|
||||
return "{"
|
||||
+ "\"code\":\"" + codeOf() + "\",\"name\":\"主线条件测试流程\",\"start\":\"start\","
|
||||
+ "\"nodes\":["
|
||||
+ "{\"id\":\"start\",\"type\":\"START\",\"name\":\"开始\"},"
|
||||
+ "{\"id\":\"n0\",\"type\":\"APPROVAL\",\"name\":\"入口\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"" + deptMain + "\"}},"
|
||||
+ "{\"id\":\"cond1\",\"type\":\"CONDITION\",\"name\":\"条件判断\",\"routerKey\":\"" + routerKey + "\"},"
|
||||
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"name\":\"甲线审核\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"" + deptA + "\"}},"
|
||||
+ "{\"id\":\"end\",\"type\":\"END\"}"
|
||||
+ "],"
|
||||
+ "\"edges\":[{\"from\":\"start\",\"to\":\"n0\"},"
|
||||
+ "{\"from\":\"n0\",\"to\":\"cond1\"},"
|
||||
+ "{\"from\":\"cond1\",\"to\":\"n1\",\"name\":\"甲线\"},"
|
||||
+ "{\"from\":\"cond1\",\"to\":\"end\",\"name\":\"乙线\"},"
|
||||
+ "{\"from\":\"n1\",\"to\":\"end\"}],"
|
||||
+ "\"settings\":{\"rejectPolicy\":\"END_REJECTED\"}}";
|
||||
}
|
||||
|
||||
/** 注册判断服务(测试库直接落行;serviceKey/path 随意——RouteJudgeClient 已被测试桩替换) */
|
||||
protected FlwRouter registerRouter(String routerKey) {
|
||||
FlwRouter router = new FlwRouter();
|
||||
router.setRouterKey(routerKey);
|
||||
router.setName("测试判断");
|
||||
router.setServiceKey("notice");
|
||||
router.setPath("/oapi/notice/flow/route/test-judge");
|
||||
router.setBasePath("");
|
||||
router.setStatus(1);
|
||||
return routerRepository.save(router);
|
||||
}
|
||||
|
||||
private static String codeOf() {
|
||||
return "tc_" + UUID.randomUUID().toString().replace("-", "").substring(0, 8);
|
||||
}
|
||||
@@ -134,6 +237,62 @@ public abstract class FlowTestSupport {
|
||||
+ "\"settings\":{\"rejectPolicy\":\"END_REJECTED\"}}";
|
||||
}
|
||||
|
||||
/**
|
||||
* 嵌套并行(2026-09-24 §4.6 scope 修复的主用例):
|
||||
* START → n1(deptMain) → s1 → {ta(deptA), tb(deptB)};tb → s2 → {tc(deptC), td(deptD)} → join2 → join1;ta → join1 → END。
|
||||
* 修复前 join2 的放行计数是实例级(外层 tb 行仍在进行)→ 内层到齐也永不到齐,死锁。
|
||||
*/
|
||||
protected String nestedParallelDef(String deptMain, String deptA, String deptB,
|
||||
String deptC, String deptD) {
|
||||
return "{"
|
||||
+ "\"code\":\"" + codeOf() + "\",\"name\":\"嵌套并行测试流程\",\"start\":\"start\","
|
||||
+ "\"nodes\":["
|
||||
+ "{\"id\":\"start\",\"type\":\"START\",\"name\":\"开始\"},"
|
||||
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"name\":\"入口\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"" + deptMain + "\"}},"
|
||||
+ "{\"id\":\"s1\",\"type\":\"PARALLEL_SPLIT\"},"
|
||||
+ "{\"id\":\"ta\",\"type\":\"APPROVAL\",\"name\":\"甲科审核\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"" + deptA + "\"}},"
|
||||
+ "{\"id\":\"tb\",\"type\":\"APPROVAL\",\"name\":\"乙科审核\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"" + deptB + "\"}},"
|
||||
+ "{\"id\":\"s2\",\"type\":\"PARALLEL_SPLIT\"},"
|
||||
+ "{\"id\":\"tc\",\"type\":\"APPROVAL\",\"name\":\"丙科审核\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"" + deptC + "\"}},"
|
||||
+ "{\"id\":\"td\",\"type\":\"APPROVAL\",\"name\":\"丁科审核\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"" + deptD + "\"}},"
|
||||
+ "{\"id\":\"join2\",\"type\":\"PARALLEL_JOIN\",\"waitFor\":\"ALL\"},"
|
||||
+ "{\"id\":\"join1\",\"type\":\"PARALLEL_JOIN\",\"waitFor\":\"ALL\"},"
|
||||
+ "{\"id\":\"end\",\"type\":\"END\"}"
|
||||
+ "],"
|
||||
+ "\"edges\":[{\"from\":\"start\",\"to\":\"n1\"},{\"from\":\"n1\",\"to\":\"s1\"},"
|
||||
+ "{\"from\":\"s1\",\"to\":\"ta\"},{\"from\":\"s1\",\"to\":\"tb\"},"
|
||||
+ "{\"from\":\"ta\",\"to\":\"join1\"},{\"from\":\"tb\",\"to\":\"s2\"},"
|
||||
+ "{\"from\":\"s2\",\"to\":\"tc\"},{\"from\":\"s2\",\"to\":\"td\"},"
|
||||
+ "{\"from\":\"tc\",\"to\":\"join2\"},{\"from\":\"td\",\"to\":\"join2\"},"
|
||||
+ "{\"from\":\"join2\",\"to\":\"join1\"},{\"from\":\"join1\",\"to\":\"end\"}],"
|
||||
+ "\"settings\":{\"rejectPolicy\":\"END_REJECTED\"}}";
|
||||
}
|
||||
|
||||
/**
|
||||
* 出口分支内并行段(2026-09-24:CONDITION 分支内并行的前置形态——多出口审批的某出口里套并行段):
|
||||
* START → n1(deptMain,出口"甲线"→END / "乙线"→s2) → s2 → {p1(deptA), p2(deptB)} → join2 → END
|
||||
*/
|
||||
protected String outletParallelDef(String deptMain, String deptA, String deptB) {
|
||||
return "{"
|
||||
+ "\"code\":\"" + codeOf() + "\",\"name\":\"出口并行测试流程\",\"start\":\"start\","
|
||||
+ "\"nodes\":["
|
||||
+ "{\"id\":\"start\",\"type\":\"START\",\"name\":\"开始\"},"
|
||||
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"name\":\"分流审核\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"" + deptMain + "\"}},"
|
||||
+ "{\"id\":\"s2\",\"type\":\"PARALLEL_SPLIT\"},"
|
||||
+ "{\"id\":\"p1\",\"type\":\"APPROVAL\",\"name\":\"并行甲\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"" + deptA + "\"}},"
|
||||
+ "{\"id\":\"p2\",\"type\":\"APPROVAL\",\"name\":\"并行乙\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"" + deptB + "\"}},"
|
||||
+ "{\"id\":\"join2\",\"type\":\"PARALLEL_JOIN\",\"waitFor\":\"ALL\"},"
|
||||
+ "{\"id\":\"end\",\"type\":\"END\"}"
|
||||
+ "],"
|
||||
+ "\"edges\":[{\"from\":\"start\",\"to\":\"n1\"},"
|
||||
+ "{\"from\":\"n1\",\"to\":\"end\",\"name\":\"甲线\"},"
|
||||
+ "{\"from\":\"n1\",\"to\":\"s2\",\"name\":\"乙线\"},"
|
||||
+ "{\"from\":\"s2\",\"to\":\"p1\"},{\"from\":\"s2\",\"to\":\"p2\"},"
|
||||
+ "{\"from\":\"p1\",\"to\":\"join2\"},{\"from\":\"p2\",\"to\":\"join2\"},"
|
||||
+ "{\"from\":\"join2\",\"to\":\"end\"}],"
|
||||
+ "\"settings\":{\"rejectPolicy\":\"END_REJECTED\"}}";
|
||||
}
|
||||
|
||||
/** 主线带并行段:START → n1(deptA) → split → {ta(ka), tb(kb)} → join → n3(deptA) → END(退回重入用) */
|
||||
protected String mainlineParallelTailDef(String deptA, String deptMain) {
|
||||
return "{"
|
||||
|
||||
Reference in New Issue
Block a user