feat(engine): NOTIFY 抄送 + CONDITION 条件路由 + JOIN scope 修复(两批全栈)
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:
chenxing
2026-09-24 08:58:26 +08:00
parent f2126cdf32
commit d2bef20e13
34 changed files with 2472 additions and 97 deletions
@@ -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);
}