From d2bef20e13dc4504534b9fd56ee3619ad87a7041 Mon Sep 17 00:00:00 2001 From: chenxing Date: Thu, 24 Sep 2026 08:58:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(engine):=20NOTIFY=20=E6=8A=84=E9=80=81=20+?= =?UTF-8?q?=20CONDITION=20=E6=9D=A1=E4=BB=B6=E8=B7=AF=E7=94=B1=20+=20JOIN?= =?UTF-8?q?=20scope=20=E4=BF=AE=E5=A4=8D=EF=BC=88=E4=B8=A4=E6=89=B9?= =?UTF-8?q?=E5=85=A8=E6=A0=88=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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,含条件/嵌套并行/退回重走用例) --- .../controller/api/FlowAdminController.java | 39 +++ .../controller/api/FlowUserController.java | 8 + .../flowcenter/engine/AssigneeResolver.java | 27 ++ .../yunhai/flowcenter/engine/BranchScope.java | 147 ++++++-- .../engine/ConditionRouteService.java | 163 +++++++++ .../flowcenter/engine/DefValidator.java | 114 ++++++- .../flowcenter/engine/FlowDefModel.java | 12 + .../yunhai/flowcenter/engine/FlowEngine.java | 164 ++++++++- .../yunhai/flowcenter/engine/FlowEvent.java | 4 + .../flowcenter/engine/RouteJudgeClient.java | 142 ++++++++ .../flowcenter/engine/RouteLogStore.java | 30 ++ .../com/yunhai/flowcenter/entity/FlwCc.java | 53 +++ .../yunhai/flowcenter/entity/FlwRouteLog.java | 59 ++++ .../yunhai/flowcenter/entity/FlwRouter.java | 51 +++ .../com/yunhai/flowcenter/entity/FlwTask.java | 8 +- .../yunhai/flowcenter/model/FlowModels.java | 16 + .../flowcenter/notify/UserMsgNotifier.java | 29 ++ .../repository/FlwBranchRepository.java | 3 +- .../repository/FlwCcRepository.java | 17 + .../repository/FlwRouteLogRepository.java | 19 ++ .../repository/FlwRouterRepository.java | 15 + .../service/DiagramImageService.java | 54 ++- .../service/FlowDefAdminService.java | 21 +- .../service/FlowDiagramService.java | 41 +++ .../flowcenter/service/FlowQueryService.java | 180 ++++++++-- .../service/FlowRouterAdminService.java | 130 +++++++ .../flowcenter/service/OapiFlowService.java | 86 ++++- .../resources/sql/20260923_create_flw_cc.sql | 29 ++ ...260924_create_flw_router_and_route_log.sql | 45 +++ .../flowcenter/engine/DefValidatorTest.java | 260 ++++++++++++++ .../flowcenter/engine/FlowEngineIT.java | 318 ++++++++++++++++++ .../engine/RouteJudgeClientTest.java | 112 ++++++ .../service/FlowDiagramServiceTest.java | 14 +- .../flowcenter/support/FlowTestSupport.java | 159 +++++++++ 34 files changed, 2472 insertions(+), 97 deletions(-) create mode 100644 src/main/java/com/yunhai/flowcenter/engine/ConditionRouteService.java create mode 100644 src/main/java/com/yunhai/flowcenter/engine/RouteJudgeClient.java create mode 100644 src/main/java/com/yunhai/flowcenter/engine/RouteLogStore.java create mode 100644 src/main/java/com/yunhai/flowcenter/entity/FlwCc.java create mode 100644 src/main/java/com/yunhai/flowcenter/entity/FlwRouteLog.java create mode 100644 src/main/java/com/yunhai/flowcenter/entity/FlwRouter.java create mode 100644 src/main/java/com/yunhai/flowcenter/repository/FlwCcRepository.java create mode 100644 src/main/java/com/yunhai/flowcenter/repository/FlwRouteLogRepository.java create mode 100644 src/main/java/com/yunhai/flowcenter/repository/FlwRouterRepository.java create mode 100644 src/main/java/com/yunhai/flowcenter/service/FlowRouterAdminService.java create mode 100644 src/main/resources/sql/20260923_create_flw_cc.sql create mode 100644 src/main/resources/sql/20260924_create_flw_router_and_route_log.sql create mode 100644 src/test/java/com/yunhai/flowcenter/engine/RouteJudgeClientTest.java 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 e7fa822..10c6480 100644 --- a/src/main/java/com/yunhai/flowcenter/controller/api/FlowAdminController.java +++ b/src/main/java/com/yunhai/flowcenter/controller/api/FlowAdminController.java @@ -45,6 +45,7 @@ public class FlowAdminController { private final FlowDefAdminService defAdminService; private final FlowMapAdminService mapAdminService; private final FlowCallbackAdminService callbackAdminService; + private final com.yunhai.flowcenter.service.FlowRouterAdminService routerAdminService; private final FlwInstanceRepository instanceRepository; private final FlowDiagramService diagramService; private final com.yunhai.flowcenter.service.DiagramImageService imageService; @@ -241,6 +242,44 @@ public class FlowAdminController { callbackAdminService.rePush(adminGuard.requireAdmin(user, null), logIds); } + // ==================== 条件路由注册 / 重跑路由 ==================== + + /** 判断服务注册列表(flw_router 全局表;编辑器 routerKey 下拉与管理页共用) */ + @GetMapping("/router/list") + public List routerList(BoaUser user) { + adminGuard.requireAdmin(user, null); + return routerAdminService.list(); + } + + /** 保存注册(id 空=新建;serviceKey=服务目录键,path=判断端点,basePath=对端 context-path 生产必核对) */ + @PostMapping("/router/save") + public com.yunhai.flowcenter.entity.FlwRouter routerSave(BoaUser user, + @RequestBody Map body) { + adminGuard.requireAdmin(user, null); + return routerAdminService.save( + body.get("id") == null ? null : Long.valueOf(String.valueOf(body.get("id"))), + (String) body.get("routerKey"), (String) body.get("name"), + (String) body.get("serviceKey"), (String) body.get("path"), + (String) body.get("basePath")); + } + + /** 启用/停用注册(停用后发布校验拦截新引用) */ + @PostMapping("/router/status/{id}") + public void routerStatus(BoaUser user, @PathVariable("id") Long id, + @RequestParam("enabled") boolean enabled) { + adminGuard.requireAdmin(user, null); + routerAdminService.setStatus(id, enabled); + } + + /** + * 重跑路由(判断失败挂起的恢复入口,2026-09-24 §4.4):重新调判断服务,成功即按出口续走; + * 仍失败回业务错(办件继续挂起,route_log 已落新行)。 + */ + @PostMapping("/instance/{id}/reroute") + public void instanceReroute(BoaUser user, @PathVariable("id") Long id) { + routerAdminService.reroute(adminGuard.requireAdmin(user, null), id); + } + // ==================== 办件查询 ==================== @GetMapping("/instance/page") diff --git a/src/main/java/com/yunhai/flowcenter/controller/api/FlowUserController.java b/src/main/java/com/yunhai/flowcenter/controller/api/FlowUserController.java index a529550..239f34e 100644 --- a/src/main/java/com/yunhai/flowcenter/controller/api/FlowUserController.java +++ b/src/main/java/com/yunhai/flowcenter/controller/api/FlowUserController.java @@ -66,6 +66,14 @@ public class FlowUserController { return queryService.pageDone(requireSchId(user), user.getId(), page, size); } + /** 我的抄送(NOTIFY 节点落库;知会不占待办,独立页签) */ + @GetMapping("/cc/my") + public BasePageResult ccMy(BoaUser user, + @RequestParam(value = "page", required = false) Integer page, + @RequestParam(value = "size", required = false) Integer size) { + return queryService.pageCc(requireSchId(user), user.getId(), page, size); + } + /** 办件详情(提交人/办理人/本校管理员可见) */ @GetMapping("/instance/{id}") public FlowModels.InstanceDetail detail(BoaUser user, @PathVariable("id") Long id) { diff --git a/src/main/java/com/yunhai/flowcenter/engine/AssigneeResolver.java b/src/main/java/com/yunhai/flowcenter/engine/AssigneeResolver.java index 49cf532..d7e810c 100644 --- a/src/main/java/com/yunhai/flowcenter/engine/AssigneeResolver.java +++ b/src/main/java/com/yunhai/flowcenter/engine/AssigneeResolver.java @@ -52,6 +52,33 @@ public class AssigneeResolver { return refs; } + /** + * 抄送接收人解析(NOTIFY 专用,宽容口径):结构不合法照抛(发布期校验本应拦住,属脏定义), + * 但解析不出人(映射被停用/名单为空)只返回空列表由调用方记日志——抄送是知会不是审批, + * 绝不能因抄送对象缺失卡住流程推进。与 {@link #resolve} 的强口径相反。 + */ + public List resolveTolerant(Long schId, FlowDefModel.Node node) { + FlowDefModel.Assignee a = node.receivers; + if (a == null || a.type == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "节点缺少抄送对象配置:" + node.id); + } + List refs = new ArrayList<>(); + if (FlowDefModel.ASSIGNEE_DEPT_KEY.equals(a.type)) { + for (FlwAssigneeMap m : mapRepository.findEnabled(schId, a.deptKey)) { + refs.add(new AssigneeRef(m.getUserId(), m.getUserName())); + } + } else if (FlowDefModel.ASSIGNEE_USERS.equals(a.type)) { + if (a.userIds != null) { + for (Integer uid : a.userIds) { + refs.add(new AssigneeRef(uid, fetchName(uid))); + } + } + } else { + throw new BizException(ErrorCode.PARAM_ERROR, "不支持抄送对象类型:" + a.type); + } + return refs; + } + private String fetchName(Integer userId) { try { BoaUser user = userClient.getUserById(String.valueOf(userId)); diff --git a/src/main/java/com/yunhai/flowcenter/engine/BranchScope.java b/src/main/java/com/yunhai/flowcenter/engine/BranchScope.java index 29fe2af..d703d76 100644 --- a/src/main/java/com/yunhai/flowcenter/engine/BranchScope.java +++ b/src/main/java/com/yunhai/flowcenter/engine/BranchScope.java @@ -2,50 +2,45 @@ package com.yunhai.flowcenter.engine; import java.util.ArrayDeque; import java.util.Deque; +import java.util.HashMap; import java.util.HashSet; +import java.util.Map; import java.util.Set; /** - * 分支归属判定:节点属于哪个并行分支(branchKey=PARALLEL_SPLIT 出边目标节点 id), - * 主线节点与汇聚点之后属 null。运行时用于推导分支上下文({@link FlowEngine}), + * 分支归属判定(2026-09-24 §4.6 升级为路径前缀编码):节点属于哪个并行分支—— + * 顶层分支键=分支头节点 id(无点号,与存量单层完全一致),内层分支键=「外层键.分支头id」 + * (如 tb.tc,外层是内层的前缀),主线与汇聚链之后属 null。 + * 运行时用于推导分支上下文({@link FlowEngine}:建行/到达标记/scope 级放行), * 发布期用于拒绝跨分支连线({@link DefValidator})——两处必须同口径,故提取为静态工具。 + * + *

嵌套口径(内层 JOIN 先于外层放行的关键):上溯遇 JOIN 视为透明块——跳到该 JOIN + * 匹配 SPLIT 的上游继续走(内层块整体属外层某分支,块的出口链沿用外层上下文)。 */ public final class BranchScope { + /** 上溯递归深度上限:发布期已拦环,此处仅对未校验脏图防御性兜底 */ + private static final int MAX_WALK_DEPTH = 64; + private BranchScope() { } /** - * 沿入边反向 BFS 走到最近的上游 PARALLEL_SPLIT,返回该 split 指向本链的 outEdge.to(即分支键); - * 遇 START/PARALLEL_JOIN 边界即停(join 之后属汇聚链,分支键已消耗,返回 null)。 + * 节点的完整分支键(路径前缀编码)。沿入边反向 BFS 到最近的上游 PARALLEL_SPLIT, + * 返回「该 split 自身所在分支键.本链分支头id」;顶层 split 的分支头即键本身(存量兼容)。 + * 遇 START 或(透明跳越后的)耗尽即停——主线节点返回 null。 */ public static String keyOf(FlowDefModel model, String nodeId) { - if (model == null || nodeId == null) { - return null; - } - Set visited = new HashSet<>(); - Deque queue = new ArrayDeque<>(); - queue.add(nodeId); - visited.add(nodeId); - while (!queue.isEmpty()) { - String cur = queue.poll(); - for (FlowDefModel.Edge e : model.inEdges(cur)) { - FlowDefModel.Node from = model.node(e.from); - if (from == null) { - continue; - } - if (FlowDefModel.TYPE_PARALLEL_SPLIT.equals(from.type)) { - return e.to; - } - if (FlowDefModel.TYPE_START.equals(from.type) - || FlowDefModel.TYPE_PARALLEL_JOIN.equals(from.type) - || !visited.add(e.from)) { - continue; - } - queue.add(e.from); - } - } - return null; + return keyOf(model, nodeId, new HashMap(), new HashMap(), 0); + } + + /** + * JOIN 的匹配分发节点:沿入边上溯(内层 JOIN 块透明跳越)首个 PARALLEL_SPLIT 即匹配者。 + * 嵌套结构下内层 JOIN 必然先命中内层 split(内层先放行),外层 JOIN 透过内层块命中外层 split。 + * 找不到(如主线节点直连 JOIN 的历史脏图)返回 null,由调用方决定兜底口径。 + */ + public static FlowDefModel.Node matchedSplitOf(FlowDefModel model, String joinNodeId) { + return matchedSplitOf(model, joinNodeId, new HashMap(), new HashMap(), 0); } /** 两节点是否同属一个分支上下文(含"都属主线"=都属于 null) */ @@ -54,4 +49,96 @@ public final class BranchScope { String kb = keyOf(model, b); return ka == null ? kb == null : ka.equals(kb); } + + private static String keyOf(FlowDefModel model, String nodeId, + Map keyMemo, Map splitMemo, int depth) { + if (model == null || nodeId == null || depth > MAX_WALK_DEPTH) { + return null; + } + if (keyMemo.containsKey(nodeId)) { + return keyMemo.get(nodeId); + } + keyMemo.put(nodeId, null); // 占位防环:递归回访时按主线(null)处理并终止 + String result = null; + Set visited = new HashSet<>(); + visited.add(nodeId); + Deque queue = new ArrayDeque<>(); + queue.add(nodeId); + while (!queue.isEmpty() && result == null) { + String cur = queue.poll(); + for (FlowDefModel.Edge e : model.inEdges(cur)) { + FlowDefModel.Node from = model.node(e.from); + if (from == null) { + continue; + } + if (FlowDefModel.TYPE_PARALLEL_SPLIT.equals(from.type)) { + // 本链分支头确定:再取该 split 自身所在的外层上下文,拼成路径前缀 + String outer = keyOf(model, from.id, keyMemo, splitMemo, depth + 1); + result = outer == null ? e.to : outer + "." + e.to; + break; + } + if (FlowDefModel.TYPE_START.equals(from.type)) { + continue; + } + if (FlowDefModel.TYPE_PARALLEL_JOIN.equals(from.type)) { + // 内层汇聚块透明:从其匹配 split 的上游继续上溯(块出口链沿用外层上下文) + FlowDefModel.Node innerSplit = matchedSplitOf(model, from.id, keyMemo, splitMemo, depth + 1); + if (innerSplit != null && visited.add(innerSplit.id)) { + queue.add(innerSplit.id); + } + continue; + } + if (visited.add(e.from)) { + queue.add(e.from); + } + } + } + keyMemo.put(nodeId, result); + return result; + } + + private static FlowDefModel.Node matchedSplitOf(FlowDefModel model, String joinNodeId, + Map keyMemo, + Map splitMemo, int depth) { + if (model == null || joinNodeId == null || depth > MAX_WALK_DEPTH) { + return null; + } + if (splitMemo.containsKey(joinNodeId)) { + return splitMemo.get(joinNodeId); + } + splitMemo.put(joinNodeId, null); // 占位防环 + FlowDefModel.Node result = null; + Set visited = new HashSet<>(); + visited.add(joinNodeId); + Deque queue = new ArrayDeque<>(); + queue.add(joinNodeId); + while (!queue.isEmpty() && result == null) { + String cur = queue.poll(); + for (FlowDefModel.Edge e : model.inEdges(cur)) { + FlowDefModel.Node from = model.node(e.from); + if (from == null) { + continue; + } + if (FlowDefModel.TYPE_PARALLEL_SPLIT.equals(from.type)) { + result = from; + break; + } + if (FlowDefModel.TYPE_START.equals(from.type)) { + continue; + } + if (FlowDefModel.TYPE_PARALLEL_JOIN.equals(from.type)) { + FlowDefModel.Node innerSplit = matchedSplitOf(model, from.id, keyMemo, splitMemo, depth + 1); + if (innerSplit != null && visited.add(innerSplit.id)) { + queue.add(innerSplit.id); + } + continue; + } + if (visited.add(e.from)) { + queue.add(e.from); + } + } + } + splitMemo.put(joinNodeId, result); + return result; + } } diff --git a/src/main/java/com/yunhai/flowcenter/engine/ConditionRouteService.java b/src/main/java/com/yunhai/flowcenter/engine/ConditionRouteService.java new file mode 100644 index 0000000..289f011 --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/engine/ConditionRouteService.java @@ -0,0 +1,163 @@ +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.entity.FlwRouter; +import com.yunhai.flowcenter.notify.UserMsgClient; +import com.yunhai.flowcenter.repository.FlwRouterRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * 条件路由判断编排(2026-09-24 §4.4):查 flw_router 注册 → 组装判断契约体(variables 透传 + * form_data_json)→ {@link RouteJudgeClient} 同步调用 → 结果落 flw_route_log(一次一行)。 + * + *

失败策略(已定,勿改):挂起 + 管理端重跑,禁止配默认出口——超时/非 2xx/ + * outlet 未命中/注册缺失一律返回 null(route_log 落 outlet=NULL 行 + 告警日志与站内信), + * 节点保持在途(currentNodes 不动、办件仍 10),管理端 reroute 重新调判断。 + * "校长室免审"这类判断失败默认放行是安全事故,宁挂起。 + * 退回重走 CONDITION 必然重新调判断(单据可能已改):attempt 递增,留痕只增不改。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ConditionRouteService { + + private final FlwRouterRepository routerRepository; + private final RouteLogStore routeLogStore; + private final RouteJudgeClient judgeClient; + private final UserMsgClient userMsgClient; + private final ObjectMapper objectMapper; + + /** + * 判断并留痕。 + * + * @return 命中的出口名(必属 outlets);失败返回 null(已落 outlet=NULL 留痕行 + 告警) + */ + public String decide(FlwInstance instance, FlowDefModel model, FlowDefModel.Node node) { + List outlets = outletNames(model, node); + FlwRouter router = routerRepository.findByRouterKey(node.routerKey).orElse(null); + if (router == null || router.getStatus() == null || router.getStatus() != 1) { + log.error("[flow-center] 路由判断失败:routerKey={} 未注册或已停用(挂起):instance={} node={}", + node.routerKey, instance.getId(), node.id); + return persistFailure(instance, node, outlets, "routerKey 未注册或已停用:" + node.routerKey); + } + RouteJudgeClient.JudgeBody body = new RouteJudgeClient.JudgeBody( + instance.getSchId(), instance.getId(), instance.getVersion(), + instance.getBizType(), instance.getBizId(), node.id, node.name, + node.routerKey, outlets, parseVariables(instance.getFormDataJson())); + try { + RouteJudgeClient.JudgeResult result = judgeClient.call(router, body); + if (!outlets.contains(result.outlet)) { + log.error("[flow-center] 路由判断返回出口未命中 outlets(挂起):instance={} node={} outlet={} outlets={}", + instance.getId(), node.id, result.outlet, outlets); + return persistFailure(instance, node, outlets, + "返回出口未命中 outlets:" + result.outlet + ";raw=" + result.rawResponse); + } + persist(instance, node, result.outlet, result.rawResponse); + log.info("[flow-center] 路由判断命中:instance={} node={} router={} outlet={}", + instance.getId(), node.id, node.routerKey, result.outlet); + return result.outlet; + } catch (Exception e) { + log.error("[flow-center] 路由判断调用失败(挂起):instance={} node={} router={} {}", + instance.getId(), node.id, node.routerKey, e.toString()); + return persistFailure(instance, node, outlets, "调用失败:" + e); + } + } + + /** CONDITION 出边名即判断契约的 outlets(发布期已保证非空唯一) */ + private static List outletNames(FlowDefModel model, FlowDefModel.Node node) { + List names = new ArrayList<>(); + for (FlowDefModel.Edge e : model.outEdges(node.id)) { + names.add(e.name); + } + return names; + } + + /** form_data_json 原样透传为 variables(SDK 发起参数不加新字段;解析失败=空对象) */ + private Map parseVariables(String formDataJson) { + if (formDataJson == null || formDataJson.trim().isEmpty()) { + return new java.util.HashMap<>(); + } + try { + return objectMapper.readValue(formDataJson, + new com.fasterxml.jackson.core.type.TypeReference>() { + }); + } catch (Exception e) { + log.warn("[flow-center] form_data_json 非 JSON 对象,variables 透传空:{}", e.toString()); + return new java.util.HashMap<>(); + } + } + + private String persistFailure(FlwInstance instance, FlowDefModel.Node node, List outlets, + String error) { + int attempt = persist(instance, node, 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) { + 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.setOutlet(outlet); + logRow.setRawResponse(raw); + return routeLogStore.append(logRow).getAttempt(); + } + + /** 挂起告警本校管理员(复用回调挂起告警接收人配置;UserMsg 不可达只记日志,不影响挂起落定) */ + private void alertSuspended(FlwInstance instance, FlowDefModel.Node node, List outlets, + String error, int attempt) { + try { + List admins = alertUserIds(); + if (admins.isEmpty()) { + return; + } + userMsgClient.sendUserMsg(instance.getSchId().intValue(), admins, 1, + "flow.route", String.valueOf(instance.getId()), + "条件路由挂起告警", + "办件 " + instance.getId() + "(" + instance.getBizTitle() + ")在节点「" + + (node.name == null || node.name.isEmpty() ? node.id : node.name) + + "」路由判断失败已挂起,请在流程管理端重跑路由。出口候选:" + + outlets + "。最近错误:" + truncate(error), + null, "route-hang:" + instance.getId() + ":" + node.id + ":" + attempt, null); + } catch (Exception e) { + log.warn("[flow-center] 路由挂起告警发送失败:instance={} {}", instance.getId(), e.toString()); + } + } + + private List alertUserIds() { + List userIds = new ArrayList<>(); + String configured = alertUserIdsConfig; + if (configured == null || configured.trim().isEmpty()) { + return userIds; + } + for (String s : configured.split(",")) { + if (!s.trim().isEmpty()) { + userIds.add(Integer.valueOf(s.trim())); + } + } + return userIds; + } + + /** 告警接收人(与回调挂起告警同源配置,@Value 注入见构造) */ + @org.springframework.beans.factory.annotation.Value("${flow-center.callback.alert-user-ids:}") + private String alertUserIdsConfig; + + private static String truncate(String s) { + if (s == null) { + return null; + } + return s.length() > 1000 ? s.substring(0, 1000) : s; + } +} diff --git a/src/main/java/com/yunhai/flowcenter/engine/DefValidator.java b/src/main/java/com/yunhai/flowcenter/engine/DefValidator.java index 80cc260..c69353c 100644 --- a/src/main/java/com/yunhai/flowcenter/engine/DefValidator.java +++ b/src/main/java/com/yunhai/flowcenter/engine/DefValidator.java @@ -18,10 +18,16 @@ public final class DefValidator { private DefValidator() { } - /** - * @param enabledDeptKeys 本校 flw_assignee_map 中启用映射的 dept_key 全集 - */ + /** 兼容旧调用(无路由注册上下文):CONDITION 只校验 routerKey 非空,不查注册表 */ public static void validate(FlowDefModel model, Set enabledDeptKeys) { + validate(model, enabledDeptKeys, null); + } + + /** + * @param enabledDeptKeys 本校 flw_assignee_map 中启用映射的 dept_key 全集 + * @param enabledRouterKeys flw_router 启用注册键全集(CONDITION routerKey 白名单;null=跳过注册检查) + */ + public static void validate(FlowDefModel model, Set enabledDeptKeys, Set enabledRouterKeys) { List problems = new ArrayList<>(); if (model.nodes == null || model.nodes.isEmpty()) { @@ -56,6 +62,12 @@ public final class DefValidator { case FlowDefModel.TYPE_APPROVAL: validateApproval(n, enabledDeptKeys, problems); break; + case FlowDefModel.TYPE_NOTIFY: + validateNotify(n, enabledDeptKeys, problems); + break; + case FlowDefModel.TYPE_CONDITION: + validateCondition(n, enabledRouterKeys, problems); + break; case FlowDefModel.TYPE_PARALLEL_SPLIT: case FlowDefModel.TYPE_PARALLEL_JOIN: break; @@ -88,7 +100,8 @@ public final class DefValidator { int out = model.outEdges(n.id).size(); int in = model.inEdges(n.id).size(); if (FlowDefModel.TYPE_START.equals(n.type) - || FlowDefModel.TYPE_PARALLEL_JOIN.equals(n.type)) { + || FlowDefModel.TYPE_PARALLEL_JOIN.equals(n.type) + || FlowDefModel.TYPE_NOTIFY.equals(n.type)) { if (out != 1) { problems.add(nodeLabel(n) + " 出边必须恰为 1 条(当前 " + out + ")"); } @@ -100,6 +113,14 @@ public final class DefValidator { validateOutlets(model, n, problems); } } + // CONDITION 互斥分支:出边≥2(一出口无判断意义),出口命名/同分支上下文复用多出口口径 + if (FlowDefModel.TYPE_CONDITION.equals(n.type)) { + if (out < 2) { + problems.add(nodeLabel(n) + " 出边至少 2 条(当前 " + out + "):条件分支必须给出可判断的出口集合"); + } else { + validateOutlets(model, n, problems); + } + } if (FlowDefModel.TYPE_PARALLEL_SPLIT.equals(n.type) && out < 1) { problems.add(nodeLabel(n) + " 出边至少 1 条"); } @@ -114,11 +135,43 @@ public final class DefValidator { problems.add(nodeLabel(n) + " waitFor 一期仅支持 ALL"); } } - // 汇聚点必须有上游分发(分支进度行由 split 创建) - boolean hasSplit = model.nodes.stream().anyMatch(n -> FlowDefModel.TYPE_PARALLEL_SPLIT.equals(n.type)); - boolean hasJoin = model.nodes.stream().anyMatch(n -> FlowDefModel.TYPE_PARALLEL_JOIN.equals(n.type)); - if (hasJoin && !hasSplit) { - problems.add("存在 PARALLEL_JOIN 但无 PARALLEL_SPLIT"); + // 嵌套并行配对与键长守卫(2026-09-24 §4.6): + // ① 每个 JOIN 必须上溯找到唯一匹配 SPLIT,且每个 SPLIT 恰被一个 JOIN 匹配(分支全部汇入同一汇聚点); + // ② SPLIT 的出边目标不得是 JOIN(分支头=汇聚点是无意义结构,键归属会错乱); + // ③ 分支键(路径前缀)不得超 64——flw_branch.branch_key 列宽。 + java.util.Map splitMatchCount = new java.util.HashMap<>(); + for (FlowDefModel.Node n : model.nodes) { + if (FlowDefModel.TYPE_PARALLEL_JOIN.equals(n.type)) { + FlowDefModel.Node split = BranchScope.matchedSplitOf(model, n.id); + if (split == null) { + problems.add(nodeLabel(n) + " 找不到匹配的分发节点(PARALLEL_SPLIT)"); + } else { + splitMatchCount.merge(split.id, 1, Integer::sum); + } + } + if (FlowDefModel.TYPE_PARALLEL_SPLIT.equals(n.type)) { + for (FlowDefModel.Edge e : model.outEdges(n.id)) { + if (FlowDefModel.TYPE_PARALLEL_JOIN.equals(model.node(e.to) == null ? "" : model.node(e.to).type)) { + problems.add(nodeLabel(n) + " 的分支不能直接是汇聚点(" + e.to + ")"); + continue; + } + String key = BranchScope.keyOf(model, e.to); + if (key != null && key.length() > 64) { + problems.add(nodeLabel(n) + " 分支键超长(" + key.length() + " 字符 > 列宽 64):" + + "缩短节点 id 或减少并行嵌套层级(" + key + ")"); + } + } + } + } + for (FlowDefModel.Node n : model.nodes) { + if (FlowDefModel.TYPE_PARALLEL_SPLIT.equals(n.type)) { + int joins = splitMatchCount.getOrDefault(n.id, 0); + if (joins == 0) { + problems.add(nodeLabel(n) + " 的分支未汇聚到唯一 PARALLEL_JOIN(内层 JOIN 必须先于外层配对)"); + } else if (joins > 1) { + problems.add(nodeLabel(n) + " 的分支汇聚到了 " + joins + " 个 PARALLEL_JOIN(不支持多汇聚点)"); + } + } } // 可达性(无孤儿)+ 无环:从 start 出发 DFS,环检测用着色 if (startNode != null) { @@ -164,8 +217,47 @@ public final class DefValidator { } } - private static void validateApproval(FlowDefModel.Node n, Set enabledDeptKeys, List problems) { - FlowDefModel.Assignee a = n.assignee; + /** + * NOTIFY 抄送节点校验:receivers 必填且类型合法(复用 Assignee 结构),DEPT_KEY 须有启用映射 + * ——运行时解析是宽容口径(无人只记日志不阻塞),发布期拦一道防"配错科室静默抄送空"。 + */ + private static void validateNotify(FlowDefModel.Node n, Set enabledDeptKeys, List problems) { + FlowDefModel.Assignee r = n.receivers; + if (r == null || r.type == null) { + problems.add(nodeLabel(n) + " 缺少抄送对象(receivers)配置"); + return; + } + if (FlowDefModel.ASSIGNEE_DEPT_KEY.equals(r.type)) { + if (r.deptKey == null || r.deptKey.trim().isEmpty()) { + problems.add(nodeLabel(n) + " DEPT_KEY 抄送对象缺少 deptKey"); + } else if (enabledDeptKeys != null && !enabledDeptKeys.contains(r.deptKey)) { + problems.add(nodeLabel(n) + " 的 DEPT_KEY=" + r.deptKey + " 无启用中的办理人映射"); + } + } else if (FlowDefModel.ASSIGNEE_USERS.equals(r.type)) { + if (r.userIds == null || r.userIds.isEmpty()) { + problems.add(nodeLabel(n) + " USERS 抄送对象缺少 userIds"); + } + } else { + problems.add(nodeLabel(n) + " 未知抄送对象类型:" + r.type + "(支持 DEPT_KEY/USERS)"); + } + } + + /** + * CONDITION 条件分支节点校验(2026-09-24 §4):routerKey 必填且已注册启用——运行时才发现 + * 无效只会挂起,发布期就拦;出边=互斥分支,出口名即判断接口的 outlets 契约(命名/唯一/ + * 同分支上下文由度约束段复用 {@link #validateOutlets} 口径)。 + */ + private static void validateCondition(FlowDefModel.Node n, Set enabledRouterKeys, + List problems) { + if (n.routerKey == null || n.routerKey.trim().isEmpty()) { + problems.add(nodeLabel(n) + " 缺少判断服务(routerKey)配置"); + } else if (enabledRouterKeys != null && !enabledRouterKeys.contains(n.routerKey.trim())) { + problems.add(nodeLabel(n) + " 的 routerKey=" + n.routerKey + + " 未注册或已停用(流程管理端 > 条件路由维护)"); + } + } + + 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 配置"); return; diff --git a/src/main/java/com/yunhai/flowcenter/engine/FlowDefModel.java b/src/main/java/com/yunhai/flowcenter/engine/FlowDefModel.java index 8d10a79..a99c9c6 100644 --- a/src/main/java/com/yunhai/flowcenter/engine/FlowDefModel.java +++ b/src/main/java/com/yunhai/flowcenter/engine/FlowDefModel.java @@ -21,6 +21,14 @@ public class FlowDefModel { public static final String TYPE_APPROVAL = "APPROVAL"; public static final String TYPE_PARALLEL_SPLIT = "PARALLEL_SPLIT"; public static final String TYPE_PARALLEL_JOIN = "PARALLEL_JOIN"; + /** 抄送节点(2026-09-23 二期第一批):瞬时放行,逐人落 flw_cc + CC_SENT,不建任务不阻塞 */ + public static final String TYPE_NOTIFY = "NOTIFY"; + /** + * 条件分支节点(2026-09-24 二期第二批 §4):激活时同步调外部判断服务(flw_router 注册, + * routerKey 寻址)按返回出口名选路;失败挂起待管理端重跑,禁止配默认出口。 + * 出边=互斥分支,name 必填且唯一(出口名即判断接口的 outlets 契约)。 + */ + public static final String TYPE_CONDITION = "CONDITION"; /** 会签模式 */ public static final String COUNTERSIGN_ALL = "ALL"; @@ -51,6 +59,10 @@ public class FlowDefModel { public String type; public String name; public Assignee assignee; + /** NOTIFY 抄送接收人(复用 Assignee 结构:DEPT_KEY/USERS 两档,解析走 AssigneeResolver) */ + public Assignee receivers; + /** CONDITION 判断服务注册键(flw_router.router_key,发布期校验须已注册且启用) */ + public String routerKey; /** ALL(默认)|ANY */ public String countersign; /** PARALLEL_JOIN 汇聚条件,一期仅 ALL */ diff --git a/src/main/java/com/yunhai/flowcenter/engine/FlowEngine.java b/src/main/java/com/yunhai/flowcenter/engine/FlowEngine.java index d89a74f..8360ec0 100644 --- a/src/main/java/com/yunhai/flowcenter/engine/FlowEngine.java +++ b/src/main/java/com/yunhai/flowcenter/engine/FlowEngine.java @@ -2,11 +2,13 @@ package com.yunhai.flowcenter.engine; import com.fasterxml.jackson.databind.ObjectMapper; import com.yunhai.flowcenter.entity.FlwBranch; +import com.yunhai.flowcenter.entity.FlwCc; import com.yunhai.flowcenter.entity.FlwDef; import com.yunhai.flowcenter.entity.FlwInstance; import com.yunhai.flowcenter.entity.FlwTask; import com.yunhai.flowcenter.enums.FlwEnums; import com.yunhai.flowcenter.repository.FlwBranchRepository; +import com.yunhai.flowcenter.repository.FlwCcRepository; import com.yunhai.flowcenter.repository.FlwTaskRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -35,7 +37,9 @@ public class FlowEngine { private final ObjectMapper objectMapper; private final FlwTaskRepository taskRepository; private final FlwBranchRepository branchRepository; + private final FlwCcRepository ccRepository; private final AssigneeResolver assigneeResolver; + private final ConditionRouteService conditionRouteService; private final ApplicationEventPublisher eventPublisher; /** 发起后首推:从 START 激活(instance 已建好、status=10)。operator=null:TASK_CREATED/COMPLETED 的操作人回退发起人 */ @@ -108,23 +112,49 @@ public class FlowEngine { branchRepository.save(branch); } } - long running = branchRepository.countByInstanceIdAndStatus( - instance.getId(), FlwEnums.BranchStatus.RUNNING.getCode()); - if (running > 0) { - log.debug("[flow-center] JOIN {} 等待分支汇聚:instance={} remaining={}", - nodeId, instance.getId(), running); + // scope 级放行(2026-09-24 §4.6):只等本块——匹配 split 的直接分支——全部到达。 + // 内层块分支键带本块前缀,由内层 JOIN 消费,内层先放行、外层后放行,嵌套并行不再死锁。 + FlowDefModel.Node matchedSplit = BranchScope.matchedSplitOf(model, nodeId); + if (waitingForScope(instance, model, matchedSplit)) { + log.debug("[flow-center] JOIN {} 等待分支汇聚:instance={}", nodeId, instance.getId()); return; } List outs = model.outEdges(nodeId); if (outs.isEmpty()) { return; } - advance(instance, model, outs.get(0).to, null, operator); + // 续走本块所在的外层分支上下文(顶层块放行后回主线 null) + String continuation = matchedSplit == null ? null : BranchScope.keyOf(model, matchedSplit.id); + advance(instance, model, outs.get(0).to, continuation, operator); return; } activate(instance, model, node, branchKey, operator); } + /** + * JOIN 是否仍在等待:scope 的任一直接分支(或以其为前缀的内层分支,内层未汇聚=该分支未完成) + * 仍有进行中行即等待。匹配不到 split 的历史直连图退回实例级口径(与修复前行为一致)。 + */ + private boolean waitingForScope(FlwInstance instance, FlowDefModel model, FlowDefModel.Node matchedSplit) { + List running = branchRepository.findByInstanceIdAndStatus( + instance.getId(), FlwEnums.BranchStatus.RUNNING.getCode()); + if (running.isEmpty() || matchedSplit == null) { + return !running.isEmpty(); + } + for (FlowDefModel.Edge e : model.outEdges(matchedSplit.id)) { + String key = BranchScope.keyOf(model, e.to); + if (key == null) { + continue; + } + for (FlwBranch b : running) { + if (key.equals(b.getBranchKey()) || b.getBranchKey().startsWith(key + ".")) { + return true; + } + } + } + return false; + } + /** 非 JOIN 节点激活 */ private void activate(FlwInstance instance, FlowDefModel model, FlowDefModel.Node node, String branchKey, FlowOperator operator) { @@ -140,21 +170,34 @@ public class FlowEngine { createTasks(instance, model, node, operator); addCurrentNode(instance, node.id); break; + case FlowDefModel.TYPE_NOTIFY: { + notifyReceivers(instance, model, node, operator); + List outs = model.outEdges(node.id); + if (!outs.isEmpty()) { + advance(instance, model, outs.get(0).to, branchKey, operator); + } + break; + } + case FlowDefModel.TYPE_CONDITION: + activateCondition(instance, model, node, operator); + break; case FlowDefModel.TYPE_PARALLEL_SPLIT: { for (FlowDefModel.Edge edge : model.outEdges(node.id)) { + // 分支键=路径前缀编码(2026-09-24 §4.6):顶层=分支头 id(存量不变),内层=「外层键.分支头id」 + String edgeKey = BranchScope.keyOf(model, edge.to); // 幂等 upsert:退回重入并行段时分支行已存在(uk instance+branchKey),重置进行中即可 FlwBranch branch = branchRepository - .findByInstanceIdAndBranchKey(instance.getId(), edge.to).orElse(null); + .findByInstanceIdAndBranchKey(instance.getId(), edgeKey).orElse(null); if (branch == null) { branch = new FlwBranch(); branch.setInstanceId(instance.getId()); - branch.setBranchKey(edge.to); + branch.setBranchKey(edgeKey); branch.setStatus(FlwEnums.BranchStatus.RUNNING.getCode()); } else { branch.setStatus(FlwEnums.BranchStatus.RUNNING.getCode()); } branchRepository.save(branch); - advance(instance, model, edge.to, edge.to, operator); + advance(instance, model, edge.to, edgeKey, operator); } break; } @@ -198,6 +241,109 @@ public class FlowEngine { } } + /** + * NOTIFY 激活(2026-09-23 二期):逐接收人落 flw_cc + 抛 CC_SENT 事件,随后由 activate + * 推进唯一出边——不建任务、不进 currentNodes(瞬时放行,放在并行分支内也不影响 JOIN 计数)。 + * 接收人解析走宽容口径:解析不出人只记日志继续走(抄送是知会不是审批)。 + * operator=把流程推进到本节点的人(发起直达=发起人),CC_SENT 信封的操作人来源。 + */ + private void notifyReceivers(FlwInstance instance, FlowDefModel model, FlowDefModel.Node node, + FlowOperator operator) { + List receivers = assigneeResolver.resolveTolerant(instance.getSchId(), node); + if (receivers.isEmpty()) { + log.warn("[flow-center] NOTIFY 节点无可用抄送对象(不阻塞推进):instance={} node={}", + instance.getId(), node.id); + return; + } + FlowOperator op = operator != null ? operator + : FlowOperator.of(instance.getSubmitterId(), instance.getSubmitterName()); + LocalDateTime now = LocalDateTime.now(); + for (AssigneeRef ref : receivers) { + FlwCc cc = new FlwCc(); + cc.setSchId(instance.getSchId()); + cc.setInstanceId(instance.getId()); + cc.setNodeId(node.id); + cc.setNodeName(node.name); + cc.setUserId(ref.getUserId()); + cc.setUserName(ref.getUserName()); + cc.setSentTime(now); + ccRepository.save(cc); + Map data = new HashMap<>(); + data.put("nodeId", node.id); + data.put("nodeName", node.name); + data.put("nodeSummary", nodeSummary(model)); + data.put("ccId", cc.getId()); + data.put("ccUserId", ref.getUserId()); + data.put("ccUserName", ref.getUserName()); + eventPublisher.publishEvent(FlowEvent.of(FlowEvent.Type.CC_SENT, + instance.getId(), null, op.getUserId(), op.getUserName(), data)); + } + } + + /** + * CONDITION 激活(2026-09-24 §4.4):先占位(进 currentNodes,判断失败=挂起的在途标记), + * 同步调外部判断,按返回出口名走 edgeByName 选路(复用多出口纪律:未命中保持节点在途)。 + * 失败不配默认出口:宁挂起待管理端重跑(rerouteCondition),落 outlet=NULL 留痕见 + * {@link ConditionRouteService}。operator=推进到本节点的人(判断无人工动作,事件操作人沿用)。 + */ + private void activateCondition(FlwInstance instance, FlowDefModel model, FlowDefModel.Node node, + FlowOperator operator) { + addCurrentNode(instance, node.id); + String outlet = conditionRouteService.decide(instance, model, node); + if (outlet == null) { + return; + } + advanceByOutlet(instance, model, node, outlet, operator); + } + + /** 按出口名选路放行:命中出边即移出在途并推进;未命中保持节点在途(防御口径,正常不会发生) */ + private void advanceByOutlet(FlwInstance instance, FlowDefModel model, FlowDefModel.Node node, + String outlet, FlowOperator operator) { + FlowDefModel.Edge chosen = model.edgeByName(node.id, outlet); + if (chosen == null) { + log.error("[flow-center] CONDITION 出口未命中出边,保持节点在途:instance={} node={} outlet={}", + instance.getId(), node.id, outlet); + return; + } + removeCurrentNode(instance, node.id); + Map data = new HashMap<>(); + data.put("nodeId", node.id); + data.put("nodeName", node.name); + data.put("routerKey", node.routerKey); + data.put("outlet", outlet); + eventPublisher.publishEvent(FlowEvent.of(FlowEvent.Type.ROUTE_DECIDED, + instance.getId(), null, operator == null ? null : operator.getUserId(), + operator == null ? null : operator.getUserName(), data)); + advance(instance, model, chosen.to, BranchScope.keyOf(model, node.id), operator); + } + + /** + * 管理端重跑路由(挂起恢复唯一入口):仅对仍在途(currentNodes 含之)的 CONDITION 节点生效, + * 重新调判断,成功则按返回出口续走推进。失败抛 BizException(挂起不变,留痕已落)。 + */ + public void rerouteCondition(FlwInstance instance, FlwDef def, String nodeId) { + FlowDefModel model = parseModel(def); + FlowDefModel.Node node = model.nodeById(nodeId); + if (!FlowDefModel.TYPE_CONDITION.equals(node.type)) { + throw new com.yunhai.platform.apistd.BizException( + com.yunhai.platform.utils.exception.ErrorCode.PARAM_ERROR, "节点不是条件分支:" + nodeId); + } + String cur = instance.getCurrentNodes(); + boolean parked = cur != null && java.util.Arrays.stream(cur.split(",")).anyMatch(nodeId::equals); + if (!parked) { + throw new com.yunhai.platform.apistd.BizException( + com.yunhai.platform.utils.exception.ErrorCode.PARAM_ERROR, + "该条件分支节点不在途(可能已放行),无需重跑"); + } + String outlet = conditionRouteService.decide(instance, model, node); + if (outlet == null) { + throw new com.yunhai.platform.apistd.BizException( + com.yunhai.platform.utils.exception.ErrorCode.PARAM_ERROR, + "判断仍失败,办件继续挂起(详见路由留痕)"); + } + advanceByOutlet(instance, model, node, outlet, null); + } + /** END 激活:办件完成、残留任务/分支失效、完成事件。operator=null 回退发起人 */ private void complete(FlwInstance instance, FlowOperator operator) { instance.setStatus(FlwEnums.InstanceStatus.COMPLETED.getCode()); diff --git a/src/main/java/com/yunhai/flowcenter/engine/FlowEvent.java b/src/main/java/com/yunhai/flowcenter/engine/FlowEvent.java index ab396e6..f500a59 100644 --- a/src/main/java/com/yunhai/flowcenter/engine/FlowEvent.java +++ b/src/main/java/com/yunhai/flowcenter/engine/FlowEvent.java @@ -32,6 +32,10 @@ public class FlowEvent { RETURNED("flow.returned"), /** 办理人移交办理权给他人(任务换人,办件仍在途) */ TRANSFERRED("flow.transferred"), + /** NOTIFY 抄送(逐接收人一条,瞬时放行不建任务;data 带 ccId/ccUserId/ccUserName) */ + CC_SENT("flow.cc.sent"), + /** CONDITION 判断落定(订阅制,供业务侧审计"为什么走了这条线";data 带 nodeId/nodeName/routerKey/outlet) */ + ROUTE_DECIDED("flow.route.decided"), /** 催办(仅 UserMsg 通道,非回调事件枚举) */ URGE("flow.urge"); diff --git a/src/main/java/com/yunhai/flowcenter/engine/RouteJudgeClient.java b/src/main/java/com/yunhai/flowcenter/engine/RouteJudgeClient.java new file mode 100644 index 0000000..b9d51d4 --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/engine/RouteJudgeClient.java @@ -0,0 +1,142 @@ +package com.yunhai.flowcenter.engine; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.platform.openapi.core.TargetSpec; +import com.yunhai.platform.openapi.core.YschOpenCall; +import com.yunhai.flowcenter.entity.FlwRouter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 条件路由判断调用客户端(2026-09-24 §4.3):POST {目录解析 host}{basePath}{path}?schId=, + * 身份即本应用 appKey(100019)——签名拦截器由 {@link YschOpenCall} 装配链注入,与回调推送同构。 + * 寻址:service_key 走服务目录三级解析(targets > services > 枚举兜底); + * flw_router.base_path 非空时覆盖目录条目的 context-path(网关形态目录无前缀、实际服务有的场景), + * 漏填必 401(规范 §4.2 同款坑)。信封解析复用 SDK({code,msg,data})。 + * + *

重试纪律:同步 3s×3 次退避重试(500ms/1500ms),业务方义务=幂等纯读轻实现。 + * 全部失败抛 {@link JudgeUnavailableException}(由调用方落 outlet=NULL 留痕并挂起)。 + */ +@Slf4j +@Service +public class RouteJudgeClient { + + /** 判断接口单次超时(毫秒)与重试次数/退避(设计 §4.4:总预算 3s×3 退避重试) */ + static final int TIMEOUT_MS = 3_000; + static final int MAX_ATTEMPTS = 3; + private static final long[] BACKOFF_MS = {500L, 1500L}; + + private final YschOpenCall openCall; + private final ObjectMapper objectMapper; + + public RouteJudgeClient(YschOpenCall openCall, ObjectMapper objectMapper) { + this.openCall = openCall; + this.objectMapper = objectMapper; + } + + /** 判断请求体(契约冻结:query 带 schId,业务方按 outlets 返回 {outlet}) */ + 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 String nodeId; + public final String nodeName; + public final String routerKey; + public final List outlets; + 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) { + this.schId = schId; + this.instanceId = instanceId; + this.instanceVersion = instanceVersion; + this.bizType = bizType; + this.bizId = bizId; + this.nodeId = nodeId; + this.nodeName = nodeName; + this.routerKey = routerKey; + this.outlets = outlets; + this.variables = variables; + } + } + + /** 判断结果:出口名 + 原始返回(data 节点序列化,审计落 flw_route_log.raw_response) */ + public static class JudgeResult { + public final String outlet; + public final String rawResponse; + + public JudgeResult(String outlet, String rawResponse) { + this.outlet = outlet; + this.rawResponse = rawResponse; + } + } + + /** 判断服务不可用(重试耗尽/网络/信封错误);outlet 未命中由调用方判定,不走本异常 */ + public static class JudgeUnavailableException extends RuntimeException { + public JudgeUnavailableException(String message, Throwable cause) { + super(message, cause); + } + } + + @SuppressWarnings("unchecked") + public JudgeResult call(FlwRouter router, JudgeBody body) { + TargetSpec spec = resolveTarget(router); + Map query = new HashMap<>(); + query.put("schId", String.valueOf(body.schId)); + RuntimeException last = null; + for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + try { + Map data = openCall.post(spec, router.getPath(), query, body, Map.class); + String raw = objectMapper.writeValueAsString(data); + Object outlet = data == null ? null : data.get("outlet"); + if (outlet == null || String.valueOf(outlet).trim().isEmpty()) { + throw new IllegalStateException("判断接口未返回 outlet:" + raw); + } + return new JudgeResult(String.valueOf(outlet).trim(), raw); + } catch (RuntimeException e) { + last = e; + log.warn("[flow-center] 路由判断调用失败(第 {}/{} 次):router={} {}", + attempt, MAX_ATTEMPTS, router.getRouterKey(), e.toString()); + if (attempt < MAX_ATTEMPTS) { + sleep(BACKOFF_MS[attempt - 1]); + } + } catch (Exception e) { + last = new IllegalStateException(e.toString(), e); + log.warn("[flow-center] 路由判断调用异常(第 {}/{} 次):router={} {}", + attempt, MAX_ATTEMPTS, router.getRouterKey(), e.toString()); + if (attempt < MAX_ATTEMPTS) { + sleep(BACKOFF_MS[attempt - 1]); + } + } + } + throw new JudgeUnavailableException("判断服务调用失败(已重试 " + MAX_ATTEMPTS + " 次)", last); + } + + /** 目标解析:目录三级 + flw_router.base_path 覆盖(空=沿用目录条目前缀),判断专用 3s 超时 */ + private TargetSpec resolveTarget(FlwRouter router) { + TargetSpec resolved = openCall.resolveTarget(router.getServiceKey()); + String basePath = resolved.getBasePath(); + if (router.getBasePath() != null && !router.getBasePath().trim().isEmpty()) { + basePath = router.getBasePath().trim(); + } + TargetSpec spec = TargetSpec.of(resolved.getBaseHost(), basePath); + spec.setConnectTimeoutMs((long) TIMEOUT_MS); + spec.setReadTimeoutMs((long) TIMEOUT_MS); + return spec; + } + + private static void sleep(long ms) { + try { + Thread.sleep(ms); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } +} diff --git a/src/main/java/com/yunhai/flowcenter/engine/RouteLogStore.java b/src/main/java/com/yunhai/flowcenter/engine/RouteLogStore.java new file mode 100644 index 0000000..f5a71fe --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/engine/RouteLogStore.java @@ -0,0 +1,30 @@ +package com.yunhai.flowcenter.engine; + +import com.yunhai.flowcenter.entity.FlwRouteLog; +import com.yunhai.flowcenter.repository.FlwRouteLogRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +/** + * 路由留痕落库(REQUIRES_NEW,与回调 CallbackLogStore 同纪律):判断调用是真实外部副作用, + * 留痕不随外层事务回滚消失——挂起重跑失败(reroute 抛业务错回滚办件推进)时失败行仍保留; + * attempt 在同一新事务里"查最大+1"再落行,避免并发双判撞号。 + */ +@Service +@RequiredArgsConstructor +public class RouteLogStore { + + private final FlwRouteLogRepository routeLogRepository; + + /** 落一行留痕并提交(独立事务),返回带 attempt 的落库行 */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public FlwRouteLog append(FlwRouteLog row) { + row.setAttempt(routeLogRepository.maxAttempt(row.getInstanceId(), row.getNodeId()) + 1); + row.setDecideTime(LocalDateTime.now()); + return routeLogRepository.save(row); + } +} diff --git a/src/main/java/com/yunhai/flowcenter/entity/FlwCc.java b/src/main/java/com/yunhai/flowcenter/entity/FlwCc.java new file mode 100644 index 0000000..3b04538 --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/entity/FlwCc.java @@ -0,0 +1,53 @@ +package com.yunhai.flowcenter.entity; + +import lombok.Getter; +import lombok.Setter; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Index; +import javax.persistence.Table; +import java.time.LocalDateTime; + +/** + * 抄送留痕(2026-09-23 二期第一批):NOTIFY 节点激活时逐接收人一行,瞬时落库后立即推进 + * 下一步(不建任务、不占待办)。"抄送我的"列表与办件轨迹的"抄送"行都以本表为数据源; + * 同一办件多节点抄送/退回重走重复抄送各成一行(只增不改,轨迹按时间归位)。 + */ +@Getter +@Setter +@Entity +@Table(name = "flw_cc", + indexes = { + @Index(name = "idx_flw_cc_user", columnList = "user_id, sent_time"), + @Index(name = "idx_flw_cc_inst", columnList = "instance_id")}) +public class FlwCc extends BaseEntity { + + /** 归属办件 */ + @Column(name = "instance_id", nullable = false) + private Long instanceId; + + /** 租户键(冗余,抄送列表主路径免 join) */ + @Column(name = "sch_id", nullable = false) + private Long schId; + + /** 定义节点 id(JSON 里的 node.id) */ + @Column(name = "node_id", nullable = false, length = 64) + private String nodeId; + + /** 节点名快照 */ + @Column(name = "node_name", length = 128) + private String nodeName; + + /** 接收人 id */ + @Column(name = "user_id", nullable = false) + private Integer userId; + + /** 接收人姓名快照(免跨服务查名) */ + @Column(name = "user_name", length = 128) + private String userName; + + /** 抄送时间(=节点激活时刻)。毫秒精度(DATETIME(3)),与 flw_task 时间列同口径,轨迹交织排序用 */ + @Column(name = "sent_time", nullable = false, columnDefinition = "DATETIME(3)") + private LocalDateTime sentTime; +} diff --git a/src/main/java/com/yunhai/flowcenter/entity/FlwRouteLog.java b/src/main/java/com/yunhai/flowcenter/entity/FlwRouteLog.java new file mode 100644 index 0000000..9279fed --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/entity/FlwRouteLog.java @@ -0,0 +1,59 @@ +package com.yunhai.flowcenter.entity; + +import lombok.Getter; +import lombok.Setter; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Index; +import javax.persistence.Table; +import java.time.LocalDateTime; + +/** + * 条件路由决策留痕(2026-09-24 二期第二批 §4.5):一次判断调用一行,只增不改—— + * 退回重走 CONDITION / 管理端重跑都是新行(attempt 递增),轨迹可看多轮判断。 + * outlet=NULL 表示本次调用失败(超时/非 2xx/未命中出口),办件挂起在该节点。 + */ +@Getter +@Setter +@Entity +@Table(name = "flw_route_log", + indexes = @Index(name = "idx_flw_route_log_inst", columnList = "instance_id, node_id")) +public class FlwRouteLog extends BaseEntity { + + /** 租户键(冗余) */ + @Column(name = "sch_id", nullable = false) + private Long schId; + + /** 归属办件 */ + @Column(name = "instance_id", nullable = false) + private Long instanceId; + + /** 定义节点 id(CONDITION) */ + @Column(name = "node_id", nullable = false, length = 64) + private String nodeId; + + /** 节点名快照 */ + @Column(name = "node_name", length = 128) + private String nodeName; + + /** 判断服务注册键快照 */ + @Column(name = "router_key", nullable = false, length = 64) + private String routerKey; + + /** 命中出口名;NULL=本次调用失败(挂起) */ + @Column(name = "outlet", length = 64) + private String outlet; + + /** 判断接口原始返回(审计;失败时为错误摘要) */ + @Column(name = "raw_response", columnDefinition = "TEXT") + private String rawResponse; + + /** 第几次判断(同 instance+node 从 1 递增:退回重走/管理端重跑都 +1) */ + @Column(name = "attempt", nullable = false) + private Integer attempt = 1; + + /** 判断完成时刻。DATETIME(3) 与 flw_task/flw_cc 时间列同口径(轨迹交织排序) */ + @Column(name = "decide_time", nullable = false, columnDefinition = "DATETIME(3)") + private LocalDateTime decideTime; +} diff --git a/src/main/java/com/yunhai/flowcenter/entity/FlwRouter.java b/src/main/java/com/yunhai/flowcenter/entity/FlwRouter.java new file mode 100644 index 0000000..cfbca31 --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/entity/FlwRouter.java @@ -0,0 +1,51 @@ +package com.yunhai.flowcenter.entity; + +import lombok.Getter; +import lombok.Setter; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Table; +import javax.persistence.UniqueConstraint; + +/** + * 条件路由判断服务注册(2026-09-24 二期第二批 §4.2):CONDITION 节点按 routerKey 寻址 + * 外部判断接口——判断逻辑归业务方,引擎不做表达式求值,走"开放接口问结果"模式。 + * 全局一行(不分校):调用时带 schId,校内数据隔离由业务方查库自理。 + * 发布校验引用本表:CONDITION 节点的 routerKey 必须已注册且启用(status=1), + * 运行时才发现无效只会挂起,发布期就拦。 + */ +@Getter +@Setter +@Entity +@Table(name = "flw_router", + uniqueConstraints = @UniqueConstraint(name = "uk_flw_router_key", columnNames = "router_key")) +public class FlwRouter extends BaseEntity { + + /** 注册键(CONDITION 节点 routerKey 引用它,如 notice.dept-judge) */ + @Column(name = "router_key", nullable = false, length = 64) + private String routerKey; + + /** 展示名(编辑器下拉) */ + @Column(name = "name", nullable = false, length = 128) + private String name; + + /** 服务目录键(如 notice):寻址 yh.call.targets > yh.open.services > 枚举兜底 */ + @Column(name = "service_key", nullable = false, length = 64) + private String serviceKey; + + /** 判断端点路径(如 /oapi/notice/flow/route/dept-judge) */ + @Column(name = "path", nullable = false, length = 255) + private String path; + + /** + * 接收方 context-path(生产 /notice):签名拼串要剥,漏填必 401(规范 §4.2 同款坑)。 + * 空=沿用服务目录条目自带的前缀(目录完整 URL 形态已含 context-path)。 + */ + @Column(name = "base_path", length = 64) + private String basePath; + + /** 1 启用 / 0 停用(停用后发布校验拦截引用,存量在途办件重跑会失败挂起) */ + @Column(name = "status", nullable = false) + private Integer status = 1; +} diff --git a/src/main/java/com/yunhai/flowcenter/entity/FlwTask.java b/src/main/java/com/yunhai/flowcenter/entity/FlwTask.java index b1ff952..6ef28c3 100644 --- a/src/main/java/com/yunhai/flowcenter/entity/FlwTask.java +++ b/src/main/java/com/yunhai/flowcenter/entity/FlwTask.java @@ -62,10 +62,14 @@ public class FlwTask extends BaseEntity { @Column(name = "edge_name", length = 64) private String edgeName; - @Column(name = "arrive_time", nullable = false) + /** + * 到达时间。毫秒精度(DATETIME(3),2026-09-23):秒级分不出"发起→抄送→下一节点激活" + * 这类亚秒机器链的因果序,轨迹/progress 交织排序依赖它;显示层仍截断到秒。 + */ + @Column(name = "arrive_time", nullable = false, columnDefinition = "DATETIME(3)") private LocalDateTime arriveTime; - @Column(name = "handle_time") + @Column(name = "handle_time", columnDefinition = "DATETIME(3)") private LocalDateTime handleTime; /** 办理时限(一期自然时间,定义方自行折算工作日——拍板项#4) */ diff --git a/src/main/java/com/yunhai/flowcenter/model/FlowModels.java b/src/main/java/com/yunhai/flowcenter/model/FlowModels.java index 977710a..ef44632 100644 --- a/src/main/java/com/yunhai/flowcenter/model/FlowModels.java +++ b/src/main/java/com/yunhai/flowcenter/model/FlowModels.java @@ -54,6 +54,22 @@ public final class FlowModels { private Integer doneCount; } + /** 抄送我的列表行(NOTIFY 落库行,flw_cc) */ + @Data + public static class CcItem { + private Long ccId; + private Long instanceId; + private String bizType; + private String bizId; + private String bizTitle; + private Integer instanceStatus; + private String nodeId; + private String nodeName; + private Integer userId; + private String userName; + private String sentTime; + } + /** 办件详情(办理页/详情页数据源) */ @Data public static class InstanceDetail { diff --git a/src/main/java/com/yunhai/flowcenter/notify/UserMsgNotifier.java b/src/main/java/com/yunhai/flowcenter/notify/UserMsgNotifier.java index cb709ad..93d54ca 100644 --- a/src/main/java/com/yunhai/flowcenter/notify/UserMsgNotifier.java +++ b/src/main/java/com/yunhai/flowcenter/notify/UserMsgNotifier.java @@ -47,6 +47,9 @@ public class UserMsgNotifier { case TRANSFERRED: notifyTaskCreated(event); break; + case CC_SENT: + notifyCc(event); + break; case COMPLETED: case REJECTED: case WITHDRAWN: @@ -121,6 +124,32 @@ public class UserMsgNotifier { "returned:" + instance.getId() + ":" + instance.getVersion(), 30); } + /** + * 抄送(2026-09-23 NOTIFY 节点):msgType=1 通知类(待办才是 2),bizType=flow.cc、 + * msgNo=cc:{ccId}(逐人逐节点一行一消息,重走重复抄送不互吞);跳转与任务到达同策略 + * (bizUrl 优先,空回落办件详情)。★ 不调 todoInc——知会不占待办角标、不进待办列表。 + */ + private void notifyCc(FlowEvent event) { + FlwInstance instance = instanceOf(event.getInstanceId()); + Object ccUserId = event.getData() == null ? null : event.getData().get("ccUserId"); + if (instance == null || ccUserId == null) { + return; + } + Object nodeName = event.getData().get("nodeName"); + Object ccId = event.getData().get("ccId"); + String bizTitle = safe(instance.getBizTitle()); + String content = "「" + bizTitle + "」抄送给您" + + (nodeName == null || String.valueOf(nodeName).isEmpty() + ? "" : "(" + nodeName + ")"); + String jumpPath = instance.getBizUrl() == null || instance.getBizUrl().trim().isEmpty() + ? "/instance/" + instance.getId() : instance.getBizUrl(); + userMsgClient.sendUserMsg(instance.getSchId().intValue(), + Collections.singletonList((Integer) ccUserId), 1, + "flow.cc", String.valueOf(instance.getId()), + "您有新的抄送:" + bizTitle, content, + jumpPath, "cc:" + ccId, 30); + } + private void notifyResult(FlowEvent event) { FlwInstance instance = instanceOf(event.getInstanceId()); if (instance == null) { diff --git a/src/main/java/com/yunhai/flowcenter/repository/FlwBranchRepository.java b/src/main/java/com/yunhai/flowcenter/repository/FlwBranchRepository.java index eff5fb7..d6eb4a2 100644 --- a/src/main/java/com/yunhai/flowcenter/repository/FlwBranchRepository.java +++ b/src/main/java/com/yunhai/flowcenter/repository/FlwBranchRepository.java @@ -15,7 +15,8 @@ public interface FlwBranchRepository extends JpaRepository { Optional findByInstanceIdAndBranchKey(Long instanceId, String branchKey); - long countByInstanceIdAndStatus(Long instanceId, Integer status); + /** scope 级放行判断用(§4.6):取全量进行中行后按前缀过滤,行数少无需再下钻查询 */ + List findByInstanceIdAndStatus(Long instanceId, Integer status); @Modifying @Query("update FlwBranch b set b.status = 2 where b.instanceId = :instanceId and b.status = 0") diff --git a/src/main/java/com/yunhai/flowcenter/repository/FlwCcRepository.java b/src/main/java/com/yunhai/flowcenter/repository/FlwCcRepository.java new file mode 100644 index 0000000..61e8a01 --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/repository/FlwCcRepository.java @@ -0,0 +1,17 @@ +package com.yunhai.flowcenter.repository; + +import com.yunhai.flowcenter.entity.FlwCc; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface FlwCcRepository extends JpaRepository { + + /** 抄送我的(用户面页签,走 user+sent_time 索引) */ + Page findBySchIdAndUserIdOrderBySentTimeDesc(Long schId, Integer userId, Pageable pageable); + + /** 办件轨迹/进度留痕 */ + List findByInstanceIdOrderByIdAsc(Long instanceId); +} diff --git a/src/main/java/com/yunhai/flowcenter/repository/FlwRouteLogRepository.java b/src/main/java/com/yunhai/flowcenter/repository/FlwRouteLogRepository.java new file mode 100644 index 0000000..09d8d1b --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/repository/FlwRouteLogRepository.java @@ -0,0 +1,19 @@ +package com.yunhai.flowcenter.repository; + +import com.yunhai.flowcenter.entity.FlwRouteLog; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface FlwRouteLogRepository extends JpaRepository { + + /** 办件轨迹/详情留痕 */ + List findByInstanceIdOrderByIdAsc(Long instanceId); + + /** 同节点已判断次数(attempt 递增用:退回重走/管理端重跑都算新一轮) */ + @Query("select coalesce(max(l.attempt), 0) from FlwRouteLog l " + + "where l.instanceId = :instanceId and l.nodeId = :nodeId") + int maxAttempt(@Param("instanceId") Long instanceId, @Param("nodeId") String nodeId); +} diff --git a/src/main/java/com/yunhai/flowcenter/repository/FlwRouterRepository.java b/src/main/java/com/yunhai/flowcenter/repository/FlwRouterRepository.java new file mode 100644 index 0000000..dc90777 --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/repository/FlwRouterRepository.java @@ -0,0 +1,15 @@ +package com.yunhai.flowcenter.repository; + +import com.yunhai.flowcenter.entity.FlwRouter; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface FlwRouterRepository extends JpaRepository { + + Optional findByRouterKey(String routerKey); + + /** 发布校验用:启用中的注册键全集(CONDITION routerKey 白名单) */ + List findByStatusOrderByIdAsc(Integer status); +} diff --git a/src/main/java/com/yunhai/flowcenter/service/DiagramImageService.java b/src/main/java/com/yunhai/flowcenter/service/DiagramImageService.java index 9b73ad1..f3089ec 100644 --- a/src/main/java/com/yunhai/flowcenter/service/DiagramImageService.java +++ b/src/main/java/com/yunhai/flowcenter/service/DiagramImageService.java @@ -51,7 +51,7 @@ public class DiagramImageService { private static final int LEGEND_H = 48; private static final int ARROW = 9; - /** 各类型盒型(w,h):开始/结束胶囊、审批圆角卡、并行菱形(取外接矩形) */ + /** 各类型盒型(w,h):开始/结束胶囊、审批圆角卡、并行/条件菱形(取外接矩形) */ private static int[] boxOf(String type) { switch (type == null ? "" : type) { case FlowDefModel.TYPE_START: @@ -59,6 +59,7 @@ public class DiagramImageService { return new int[]{116, 44}; case FlowDefModel.TYPE_PARALLEL_SPLIT: case FlowDefModel.TYPE_PARALLEL_JOIN: + case FlowDefModel.TYPE_CONDITION: return new int[]{132, 78}; default: return new int[]{200, 64}; @@ -77,6 +78,11 @@ public class DiagramImageService { private static final Color C_PARALLEL_FILL = new Color(0xFF, 0xF8, 0xF0); private static final Color C_PARALLEL_BORDER = new Color(0xFF, 0x99, 0x00); private static final Color C_PARALLEL_TEXT = new Color(0xE4, 0x6A, 0x0A); + private static final Color C_NOTIFY_FILL = new Color(0xF0, 0xF9, 0xFF); + private static final Color C_NOTIFY_BORDER = new Color(0x13, 0xA8, 0xA8); + private static final Color C_CONDITION_FILL = new Color(0xF9, 0xF0, 0xFF); + private static final Color C_CONDITION_BORDER = new Color(0x92, 0x54, 0xDE); + private static final Color C_CONDITION_TEXT = new Color(0x72, 0x2E, 0xD1); private static final Color C_DONE_BORDER = new Color(0x19, 0xBE, 0x6B); private static final Color C_ACTIVE_BORDER = new Color(0xED, 0x40, 0x14); private static final Color C_REJECT_FILL = new Color(0xFF, 0xF1, 0xF0); @@ -433,6 +439,49 @@ public class DiagramImageService { g.drawString(label, cx - fm.stringWidth(label) / 2f, cy + fm.getAscent() / 2f - 1); return; } + if (FlowDefModel.TYPE_CONDITION.equals(type)) { + // 条件分支:紫菱形(挂起=active 红框加粗,与前端 FlowDiagram.vue 同配色) + int cx = p[0] + p[2] / 2; + int cy = p[1] + p[3] / 2; + Path2D d = new Path2D.Float(); + d.moveTo(cx, p[1]); + d.lineTo(p[0] + p[2], cy); + d.lineTo(cx, p[1] + p[3]); + d.lineTo(p[0], cy); + d.closePath(); + g.setColor(C_CONDITION_FILL); + g.fill(d); + boolean hung = "active".equals(n.getState()); + g.setColor(hung ? C_ACTIVE_BORDER : C_CONDITION_BORDER); + g.setStroke(new BasicStroke(hung ? 2.2f : 1.6f)); + g.draw(d); + g.setFont(subF); + g.setColor(C_CONDITION_TEXT); + FontMetrics fm = g.getFontMetrics(); + String label = truncate(g, n.getLabel(), p[2] - 36); + g.drawString(label, cx - fm.stringWidth(label) / 2f, cy + fm.getAscent() / 2f - 1); + return; + } + if (FlowDefModel.TYPE_NOTIFY.equals(type)) { + // 抄送:圆角卡(青框白底,label + 单行 subLabel)——瞬时节点不占 active 红, + // 状态着色与审批一致(done 绿框表示本办件已抄送过) + g.setColor(C_NOTIFY_FILL); + g.fillRoundRect(p[0], p[1], p[2], p[3], 12, 12); + g.setColor(borderOf(n)); + g.setStroke(new BasicStroke("active".equals(n.getState()) ? 2.4f : 1.6f)); + g.drawRoundRect(p[0], p[1], p[2], p[3], 12, 12); + g.setFont(labelF); + g.setColor(C_NOTIFY_BORDER); + String label = truncate(g, n.getLabel(), p[2] - 20); + g.drawString(label, p[0] + (p[2] - g.getFontMetrics().stringWidth(label)) / 2f, p[1] + 26); + if (n.getSubLabel() != null && !n.getSubLabel().isEmpty()) { + g.setFont(subF); + g.setColor(C_SUB); + String sub = truncate(g, n.getSubLabel(), p[2] - 20); + g.drawString(sub, p[0] + (p[2] - g.getFontMetrics().stringWidth(sub)) / 2f, p[1] + 46); + } + return; + } // 审批:圆角卡(默认蓝框;done 绿/active 红/pending 灰/rejected 红底),label + 单行 subLabel g.setColor("rejected".equals(n.getState()) ? C_REJECT_FILL : Color.WHITE); g.fillRoundRect(p[0], p[1], p[2], p[3], 12, 12); @@ -491,6 +540,9 @@ public class DiagramImageService { if (t.equals(FlowDefModel.TYPE_PARALLEL_SPLIT) || t.equals(FlowDefModel.TYPE_PARALLEL_JOIN)) { return C_PARALLEL_BORDER; } + if (t.equals(FlowDefModel.TYPE_NOTIFY)) { + return C_NOTIFY_BORDER; + } return C_APPROVAL_BORDER; } diff --git a/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java b/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java index 41ada80..9c0711d 100644 --- a/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java +++ b/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java @@ -31,6 +31,7 @@ public class FlowDefAdminService { private final FlwDefRepository defRepository; private final FlwAssigneeMapRepository mapRepository; + private final com.yunhai.flowcenter.repository.FlwRouterRepository routerRepository; private final FlowEngine flowEngine; private final ObjectMapper objectMapper; @@ -128,9 +129,7 @@ public class FlowDefAdminService { throw new BizException(ErrorCode.PARAM_ERROR, "已是发布状态"); } FlowDefModel model = FlowDefModel.parse(objectMapper, def.getContentJson()); - Set enabledDeptKeys = mapRepository.findEnabledBySchId(schId).stream() - .map(FlwAssigneeMap::getDeptKey).collect(Collectors.toSet()); - DefValidator.validate(model, enabledDeptKeys); + DefValidator.validate(model, enabledDeptKeys(schId), enabledRouterKeys()); def.setStatus(FlwEnums.DefStatus.PUBLISHED.getCode()); def.setPublishTime(LocalDateTime.now()); return defRepository.save(def); @@ -159,9 +158,7 @@ public class FlowDefAdminService { throw new BizException(ErrorCode.PARAM_ERROR, "仅停用的流程可启用;草稿请走发布"); } FlowDefModel model = FlowDefModel.parse(objectMapper, def.getContentJson()); - Set enabledDeptKeys = mapRepository.findEnabledBySchId(schId).stream() - .map(FlwAssigneeMap::getDeptKey).collect(Collectors.toSet()); - DefValidator.validate(model, enabledDeptKeys); + DefValidator.validate(model, enabledDeptKeys(schId), enabledRouterKeys()); def.setStatus(FlwEnums.DefStatus.PUBLISHED.getCode()); def.setPublishTime(LocalDateTime.now()); return defRepository.save(def); @@ -172,6 +169,18 @@ public class FlowDefAdminService { return defRepository.findBySchIdAndFlowGuidOrderByVersionDesc(schId, flowGuid); } + private Set enabledDeptKeys(Long schId) { + return mapRepository.findEnabledBySchId(schId).stream() + .map(FlwAssigneeMap::getDeptKey).collect(Collectors.toSet()); + } + + /** CONDITION routerKey 发布校验白名单(flw_router 启用键全集,全局不分校) */ + private Set enabledRouterKeys() { + return routerRepository.findByStatusOrderByIdAsc(1).stream() + .map(com.yunhai.flowcenter.entity.FlwRouter::getRouterKey) + .collect(Collectors.toSet()); + } + private FlwDef requireDef(Long schId, Long id) { FlwDef def = defRepository.findById(id) .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "流程定义不存在:" + id)); diff --git a/src/main/java/com/yunhai/flowcenter/service/FlowDiagramService.java b/src/main/java/com/yunhai/flowcenter/service/FlowDiagramService.java index 37e705a..14fbb00 100644 --- a/src/main/java/com/yunhai/flowcenter/service/FlowDiagramService.java +++ b/src/main/java/com/yunhai/flowcenter/service/FlowDiagramService.java @@ -3,14 +3,18 @@ package com.yunhai.flowcenter.service; import com.yunhai.flowcenter.engine.FlowDefModel; import com.yunhai.flowcenter.engine.FlowEngine; import com.yunhai.flowcenter.entity.FlwAssigneeMap; +import com.yunhai.flowcenter.entity.FlwCc; import com.yunhai.flowcenter.entity.FlwDef; import com.yunhai.flowcenter.entity.FlwInstance; +import com.yunhai.flowcenter.entity.FlwRouteLog; import com.yunhai.flowcenter.entity.FlwTask; import com.yunhai.flowcenter.enums.FlwEnums; import com.yunhai.flowcenter.model.FlowModels; import com.yunhai.flowcenter.repository.FlwAssigneeMapRepository; +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.FlwTaskRepository; import com.yunhai.platform.apistd.BizException; import com.yunhai.platform.openapi.base.OpenApiUserClient; @@ -51,6 +55,8 @@ public class FlowDiagramService { private final FlwAssigneeMapRepository mapRepository; private final FlwInstanceRepository instanceRepository; private final FlwTaskRepository taskRepository; + private final FlwCcRepository ccRepository; + private final FlwRouteLogRepository routeLogRepository; private final FlowEngine flowEngine; private final OpenApiUserClient userClient; @@ -186,6 +192,18 @@ public class FlowDiagramService { states.put(nodeId, "active"); } } + // 抄送节点(NOTIFY 不建任务,上面的任务循环覆盖不到它):有 flw_cc 落库行即视为已过 + // (瞬时完成,永不为 active——它不在 currentNodes 里) + for (FlwCc c : ccRepository.findByInstanceIdOrderByIdAsc(instanceId)) { + states.put(c.getNodeId(), "done"); + } + // 条件分支节点(不建任务):有命中留痕(outlet 非空)即视为已过; + // 挂起在 currentNodes → active 优先(退回重走已重新占位,不能被上一轮的命中留痕盖掉) + for (FlwRouteLog r : routeLogRepository.findByInstanceIdOrderByIdAsc(instanceId)) { + if (r.getOutlet() != null && !activeNodes.contains(r.getNodeId())) { + states.put(r.getNodeId(), "done"); + } + } return states; } @@ -195,6 +213,25 @@ public class FlowDiagramService { vo.setType(n.type); vo.setCountersign(n.countersign); vo.setDeadlineHours(n.deadlineHours); + if (FlowDefModel.TYPE_NOTIFY.equals(n.type)) { + // 抄送卡:label=节点名(缺省"抄送"),subLabel=接收人名单(dept 视图不列姓名,同 USERS 纪律) + vo.setLabel(firstNonBlank(n.name, "抄送")); + FlowDefModel.Assignee r = n.receivers; + if (r == null) { + return vo; + } + vo.setAssigneeType(r.type); + vo.setDeptKey(r.deptKey); + if (FlowDefModel.ASSIGNEE_DEPT_KEY.equals(r.type)) { + List maps = mapRepository.findEnabled(schId, r.deptKey); + vo.setSubLabel(MODE_DEPT.equals(mode) ? "抄送科室 · " + maps.size() + "人" + : "抄送 " + namesWithSuffix(memberNames(maps), new ArrayList<>())); + } else if (FlowDefModel.ASSIGNEE_USERS.equals(r.type)) { + vo.setSubLabel(MODE_DEPT.equals(mode) ? "抄送指定人员" + : "抄送 " + namesWithSuffix(userNames(r.userIds), new ArrayList<>())); + } + return vo; + } if (!FlowDefModel.TYPE_APPROVAL.equals(n.type)) { vo.setLabel(fixedLabel(n.type)); return vo; @@ -313,6 +350,10 @@ public class FlowDiagramService { return "并行分发"; case FlowDefModel.TYPE_PARALLEL_JOIN: return "汇聚等待"; + case FlowDefModel.TYPE_NOTIFY: + return "抄送"; + case FlowDefModel.TYPE_CONDITION: + return "条件分支"; default: return type; } diff --git a/src/main/java/com/yunhai/flowcenter/service/FlowQueryService.java b/src/main/java/com/yunhai/flowcenter/service/FlowQueryService.java index 00ba447..0a88214 100644 --- a/src/main/java/com/yunhai/flowcenter/service/FlowQueryService.java +++ b/src/main/java/com/yunhai/flowcenter/service/FlowQueryService.java @@ -4,15 +4,19 @@ import com.yunhai.flowcenter.engine.BranchScope; import com.yunhai.flowcenter.engine.FlowDefModel; import com.yunhai.flowcenter.engine.FlowEngine; import com.yunhai.flowcenter.entity.FlwBranch; +import com.yunhai.flowcenter.entity.FlwCc; import com.yunhai.flowcenter.entity.FlwDef; import com.yunhai.flowcenter.entity.FlwInstance; +import com.yunhai.flowcenter.entity.FlwRouteLog; import com.yunhai.flowcenter.entity.FlwTask; import com.yunhai.flowcenter.enums.FlwEnums; import com.yunhai.flowcenter.model.FlowModels; import com.yunhai.flowcenter.repository.FlwAssigneeMapRepository; import com.yunhai.flowcenter.repository.FlwBranchRepository; +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.FlwTaskRepository; import com.yunhai.platform.apistd.BizException; import com.yunhai.platform.utils.exception.ErrorCode; @@ -40,6 +44,8 @@ public class FlowQueryService { private final FlwInstanceRepository instanceRepository; private final FlwTaskRepository taskRepository; private final FlwBranchRepository branchRepository; + private final FlwCcRepository ccRepository; + private final FlwRouteLogRepository routeLogRepository; private final FlwDefRepository defRepository; private final FlwAssigneeMapRepository mapRepository; private final FlowEngine flowEngine; @@ -52,6 +58,41 @@ public class FlowQueryService { .collect(Collectors.toList()), page, size); } + /** 抄送我的(NOTIFY 落库行,sent_time 倒序;知会不占待办,独立于 todo/done 口径) */ + public BasePageResult pageCc(Long schId, Integer userId, + Integer page, Integer size) { + Page p = ccRepository.findBySchIdAndUserIdOrderBySentTimeDesc(schId, userId, + PageRequest.of(normPage(page), normSize(size))); + List items = p.getContent().stream() + .map(this::toCcItem).collect(Collectors.toList()); + fillCcInstanceInfo(items); + return toResult(p.getTotalElements(), items, page, size); + } + + private FlowModels.CcItem toCcItem(FlwCc cc) { + FlowModels.CcItem item = new FlowModels.CcItem(); + item.setCcId(cc.getId()); + item.setInstanceId(cc.getInstanceId()); + item.setNodeId(cc.getNodeId()); + item.setNodeName(cc.getNodeName()); + item.setUserId(cc.getUserId()); + item.setUserName(cc.getUserName()); + item.setSentTime(str(cc.getSentTime())); + return item; + } + + private void fillCcInstanceInfo(List items) { + for (FlowModels.CcItem item : items) { + FlwInstance instance = instanceRepository.findById(item.getInstanceId()).orElse(null); + if (instance != null) { + item.setBizTitle(instance.getBizTitle()); + item.setBizType(instance.getBizType()); + item.setBizId(instance.getBizId()); + item.setInstanceStatus(instance.getStatus()); + } + } + } + public BasePageResult pageTodo(Long schId, Integer assigneeId, Integer page, Integer size) { Page p = taskRepository.pageTodo(schId, assigneeId, @@ -122,9 +163,11 @@ public class FlowQueryService { detail.setFinishTime(str(instance.getFinishTime())); detail.setVersion(instance.getVersion()); List tasks = taskRepository.findByInstanceIdOrderByIdAsc(instanceId); + List ccs = ccRepository.findByInstanceIdOrderByIdAsc(instanceId); + List routeLogs = routeLogRepository.findByInstanceIdOrderByIdAsc(instanceId); FlwDef def = defRepository.findById(instance.getDefId()).orElse(null); FlowDefModel model = def == null ? null : flowEngine.parseModel(def); - detail.setTrace(buildTrace(instance, tasks, model)); + detail.setTrace(buildTrace(instance, tasks, ccs, routeLogs, model)); List> branches = new ArrayList<>(); for (FlwBranch b : branchRepository.findByInstanceId(instanceId)) { Map row = new HashMap<>(); @@ -224,13 +267,19 @@ public class FlowQueryService { * 合成一条按时间升序的轨迹,替掉原先"按 id 升序"的排法(id 序在撤回重走场景下与 * 真实发生顺序不一致)。 * + *

2026-09-23 并入抄送行(flw_cc,kind=CC):NOTIFY 节点不建任务,若不并入则抄送在 + * 轨迹上不可见;按 sentTime 与任务行同场归位、参与轮次进位(退回重走的第 N 轮抄送)。 + * + *

2026-09-24 并入路由判断行(flw_route_log,kind=ROUTE):CONDITION 判断留痕 + * "系统判断 → 出口"(outlet=NULL 渲染为挂起);按 decideTime 同场归位。 + * *

每条行补四个前端直接用、不再自行推断的字段: *

    - *
  • {@code time} 该行实际发生的时间(已办/退回/移交/撤回=handleTime,在办/失效=arriveTime);
  • - *
  • {@code kind} SUBMIT|RESUBMIT|WITHDRAW|APPROVE|REJECT|RETURN|TRANSFER|INVALID|PENDING + *
  • {@code time} 该行实际发生的时间(已办/退回/移交/撤回=handleTime,在办/失效=arriveTime,抄送=sentTime,判断=decideTime);
  • + *
  • {@code kind} SUBMIT|RESUBMIT|WITHDRAW|APPROVE|REJECT|RETURN|TRANSFER|INVALID|PENDING|CC|ROUTE * (前端配色与文案的唯一依据);
  • *
  • {@code round} 第几轮审批(遇"重新提交"进位,撤回属于它结束的那一轮);
  • - *
  • {@code rowKey} 去重 key(任务行 t{id}、合成行 submit —— taskId 可能为 null 不能当 key)。
  • + *
  • {@code rowKey} 去重 key(任务行 t{id}、抄送行 cc{id}、合成行 submit —— taskId 可能为 null 不能当 key)。
  • *
* 科室口径(视图两档:dept/user)另需 assigneeType/deptKey/deptName —— 只有 DEPT_KEY 节点有 * 科室概念,故仅对该类节点回填;节点在定义里找不到(重提后老节点、留痕事件行)不回填, @@ -240,40 +289,39 @@ public class FlowQueryService { * 兜底插入到时间流对应位置,保证轨迹首条仍是"发起申请"。 */ private List> buildTrace(FlwInstance instance, List tasks, + List ccs, List routeLogs, FlowDefModel model) { - List sorted = new ArrayList<>(tasks); - sorted.sort(Comparator.comparing(FlowQueryService::happenedAt, - Comparator.nullsLast(Comparator.naturalOrder())) - .thenComparing(FlwTask::getId)); + // 任务行、判断行与抄送行同场按发生时间排序:时间只到秒(DATETIME),同秒平局任务行恒在前、 + // 判断行次之(办理→判断→抄送的因果序)、抄送行最后(秩隔开跨表 id 空间;稳排保证同源行相对次序不变)。 + // 无判断/抄送的办件所有行 source 恒 0,排序与旧版 (time,id) 严格等价—— + // 不能给任务行再分因果秩:驳回行与重提行同秒是常态,旧序按 id(真实先后),秩会颠倒。 + List rows = new ArrayList<>(); Map deptNames = new HashMap<>(); + for (FlwTask t : tasks) { + rows.add(new TimedRow(happenedAt(t), 0, t.getId(), + taskTraceRow(t, model, instance, deptNames))); + } + for (FlwRouteLog r : routeLogs) { + rows.add(new TimedRow(r.getDecideTime(), 1, r.getId(), routeTraceRow(r))); + } + for (FlwCc c : ccs) { + rows.add(new TimedRow(c.getSentTime(), 2, c.getId(), ccTraceRow(c))); + } + rows.sort(Comparator.comparing((TimedRow r) -> r.time, + Comparator.nullsLast(Comparator.naturalOrder())) + .thenComparing(r -> r.source) + .thenComparing(r -> r.seq)); List> trace = new ArrayList<>(); int headIndex = 0; LocalDateTime submitTime = instance.getSubmitTime(); - for (FlwTask t : sorted) { - Map row = new HashMap<>(); - row.put("rowKey", "t" + t.getId()); - row.put("taskId", t.getId()); - row.put("kind", kindOf(t)); - row.put("nodeId", t.getNodeId()); - row.put("nodeName", t.getNodeName()); - row.put("assigneeId", t.getAssigneeId()); - row.put("assigneeName", t.getAssigneeName()); - row.put("status", t.getStatus()); - row.put("opinion", t.getOpinion()); - row.put("arriveTime", str(t.getArriveTime())); - row.put("handleTime", str(t.getHandleTime())); - row.put("time", str(happenedAt(t))); - row.put("deadline", str(t.getDeadline())); - row.put("urgeCount", t.getUrgeCount()); - row.put("edgeName", t.getEdgeName()); - fillAssigneeScope(row, model, t.getNodeId(), instance.getSchId(), deptNames); - trace.add(row); + for (TimedRow r : rows) { + trace.add(r.row); // 老办件兜底行的插入位:时间早于提交时间的行数(老办件里恒为 0;重提过的老办件按时间放在中段) - if (submitTime != null && happenedAt(t).isBefore(submitTime)) { + if (submitTime != null && r.time != null && r.time.isBefore(submitTime)) { headIndex = trace.size(); } } - if (!hasSubmitRow(sorted)) { + if (!hasSubmitRow(tasks)) { trace.add(headIndex, syntheticSubmitRow(instance)); } int round = 1; @@ -286,6 +334,74 @@ public class FlowQueryService { return trace; } + private Map taskTraceRow(FlwTask t, FlowDefModel model, FlwInstance instance, + Map deptNames) { + Map row = new HashMap<>(); + row.put("rowKey", "t" + t.getId()); + row.put("taskId", t.getId()); + row.put("kind", kindOf(t)); + row.put("nodeId", t.getNodeId()); + row.put("nodeName", t.getNodeName()); + row.put("assigneeId", t.getAssigneeId()); + row.put("assigneeName", t.getAssigneeName()); + row.put("status", t.getStatus()); + row.put("opinion", t.getOpinion()); + row.put("arriveTime", str(t.getArriveTime())); + row.put("handleTime", str(t.getHandleTime())); + row.put("time", str(happenedAt(t))); + row.put("deadline", str(t.getDeadline())); + row.put("urgeCount", t.getUrgeCount()); + row.put("edgeName", t.getEdgeName()); + fillAssigneeScope(row, model, t.getNodeId(), instance.getSchId(), deptNames); + return row; + } + + /** 抄送轨迹行(kind=CC):no taskId/opinion,接收人记在 assignee* 字段(前端复用人列) */ + private static Map ccTraceRow(FlwCc c) { + Map row = new HashMap<>(); + row.put("rowKey", "cc" + c.getId()); + row.put("ccId", c.getId()); + row.put("kind", "CC"); + row.put("nodeId", c.getNodeId()); + row.put("nodeName", c.getNodeName() == null || c.getNodeName().isEmpty() ? "抄送" : c.getNodeName()); + row.put("assigneeId", c.getUserId()); + row.put("assigneeName", c.getUserName()); + row.put("status", null); + row.put("time", str(c.getSentTime())); + return row; + } + + /** 路由判断轨迹行(kind=ROUTE,2026-09-24):"系统判断 → 出口",outlet=NULL 渲染为挂起 */ + private static Map routeTraceRow(FlwRouteLog r) { + Map row = new HashMap<>(); + row.put("rowKey", "r" + r.getId()); + row.put("routeLogId", r.getId()); + row.put("kind", "ROUTE"); + row.put("nodeId", r.getNodeId()); + row.put("nodeName", r.getNodeName() == null || r.getNodeName().isEmpty() ? "条件判断" : r.getNodeName()); + row.put("routerKey", r.getRouterKey()); + row.put("outlet", r.getOutlet()); + row.put("attempt", r.getAttempt()); + row.put("status", null); + row.put("time", str(r.getDecideTime())); + return row; + } + + /** 排序载体:发生时间 + 来源秩(任务 0/判断 1/抄送 2)+ 同表内序(id),跨表 id 不比大小 */ + private static final class TimedRow { + final LocalDateTime time; + final int source; + final long seq; + final Map row; + + TimedRow(LocalDateTime time, int source, long seq, Map row) { + this.time = time; + this.source = source; + this.seq = seq; + this.row = row; + } + } + private static boolean hasSubmitRow(List tasks) { for (FlwTask t : tasks) { if (FlwEnums.TaskStatus.NODE_SUBMIT.equals(t.getNodeId())) { @@ -394,8 +510,12 @@ public class FlowQueryService { } } + /** + * 展示时间统一截断到秒:时间列已升毫秒精度(DATETIME(3),轨迹交织排序用), + * 对外展示口径保持秒级不变(存量前端零适配);排序用未截断的原值。 + */ private static String str(LocalDateTime t) { - return t == null ? null : t.toString(); + return t == null ? null : t.truncatedTo(java.time.temporal.ChronoUnit.SECONDS).toString(); } private static int normPage(Integer page) { diff --git a/src/main/java/com/yunhai/flowcenter/service/FlowRouterAdminService.java b/src/main/java/com/yunhai/flowcenter/service/FlowRouterAdminService.java new file mode 100644 index 0000000..ff2e984 --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/service/FlowRouterAdminService.java @@ -0,0 +1,130 @@ +package com.yunhai.flowcenter.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.flowcenter.engine.FlowDefModel; +import com.yunhai.flowcenter.engine.FlowEngine; +import com.yunhai.flowcenter.entity.FlwDef; +import com.yunhai.flowcenter.entity.FlwInstance; +import com.yunhai.flowcenter.entity.FlwRouter; +import com.yunhai.flowcenter.enums.FlwEnums; +import com.yunhai.flowcenter.repository.FlwDefRepository; +import com.yunhai.flowcenter.repository.FlwInstanceRepository; +import com.yunhai.flowcenter.repository.FlwRouterRepository; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.util.List; + +/** + * 条件路由管理(2026-09-24 §4.2/§4.4):flw_router 注册 CRUD(全局表,管理员维护) + * + 挂起办件的重跑路由入口。发布校验引用本表(routerKey 白名单),停用注册会拦住新发布。 + */ +@Service +@RequiredArgsConstructor +public class FlowRouterAdminService { + + private final FlwRouterRepository routerRepository; + private final FlwInstanceRepository instanceRepository; + private final FlwDefRepository defRepository; + private final FlowEngine flowEngine; + private final ObjectMapper objectMapper; + + /** 全量注册(编辑器下拉与管理页共用;表小不分页) */ + public List list() { + return routerRepository.findAll(); + } + + /** 保存(id 空=新建;routerKey 唯一,改键时校验不撞已有注册) */ + @Transactional + public FlwRouter save(Long id, String routerKey, String name, String serviceKey, String path, + String basePath) { + if (isBlank(routerKey) || isBlank(name) || isBlank(serviceKey) || isBlank(path)) { + throw new BizException(ErrorCode.PARAM_ERROR, "routerKey/name/serviceKey/path 必填"); + } + if (!path.trim().startsWith("/")) { + throw new BizException(ErrorCode.PARAM_ERROR, "path 必须以 / 开头(如 /oapi/notice/flow/route/dept-judge)"); + } + FlwRouter router; + if (id == null) { + router = new FlwRouter(); + } else { + router = routerRepository.findById(id) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "注册不存在:" + id)); + } + routerRepository.findByRouterKey(routerKey.trim()).ifPresent(other -> { + if (id == null || !other.getId().equals(id)) { + throw new BizException(ErrorCode.PARAM_ERROR, "routerKey 已被注册:" + routerKey.trim()); + } + }); + router.setRouterKey(routerKey.trim()); + router.setName(name.trim()); + router.setServiceKey(serviceKey.trim()); + router.setPath(path.trim()); + router.setBasePath(basePath == null ? "" : basePath.trim()); + router.setStatus(1); + return routerRepository.save(router); + } + + /** 启用/停用(停用拦新发布引用;存量在途办件重跑会失败挂起,注意时机) */ + @Transactional + public FlwRouter setStatus(Long id, boolean enabled) { + FlwRouter router = routerRepository.findById(id) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "注册不存在:" + id)); + router.setStatus(enabled ? 1 : 0); + return routerRepository.save(router); + } + + /** + * 重跑路由(挂起恢复唯一入口,§4.4):办件须在途且挂在 CONDITION 节点(currentNodes 含之、 + * 该节点无待办任务)。重新调判断,成功即续走推进;仍失败抛业务错(挂起不变,留痕已落新行)。 + */ + @Transactional + public void reroute(Long schId, Long instanceId) { + FlwInstance instance = instanceRepository.findById(instanceId) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "办件不存在:" + instanceId)); + if (!instance.getSchId().equals(schId)) { + throw new BizException(ErrorCode.FORBIDDEN, "办件不属于该学校"); + } + if (instance.getStatus() != FlwEnums.InstanceStatus.APPROVING.getCode()) { + throw new BizException(ErrorCode.PARAM_ERROR, "仅审批中的办件可重跑路由"); + } + FlwDef def = defRepository.findById(instance.getDefId()).orElse(null); + if (def == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "办件定义缺失"); + } + String conditionNode = findParkedCondition(instance, def); + if (conditionNode == null) { + throw new BizException(ErrorCode.PARAM_ERROR, + "办件未挂在条件分支节点(无挂起的路由判断,无需重跑)"); + } + flowEngine.rerouteCondition(instance, def, conditionNode); + instanceRepository.save(instance); + } + + /** 在途节点里找 CONDITION(多个时取第一个;正常挂起只会有一个) */ + private String findParkedCondition(FlwInstance instance, FlwDef def) { + String cur = instance.getCurrentNodes(); + if (StringUtils.isEmpty(cur)) { + return null; + } + FlowDefModel model = FlowDefModel.parse(objectMapper, def.getContentJson()); + for (String nodeId : cur.split(",")) { + if (nodeId.isEmpty()) { + continue; + } + FlowDefModel.Node node = model.node(nodeId); + if (node != null && FlowDefModel.TYPE_CONDITION.equals(node.type)) { + return node.id; + } + } + return null; + } + + private static boolean isBlank(String s) { + return s == null || s.trim().isEmpty(); + } +} diff --git a/src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java b/src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java index 9880e88..fe26f75 100644 --- a/src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java +++ b/src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java @@ -3,6 +3,7 @@ package com.yunhai.flowcenter.service; import com.fasterxml.jackson.databind.ObjectMapper; import com.yunhai.flowcenter.callback.CallbackAppSigner; import com.yunhai.flowcenter.entity.FlwCallbackReg; +import com.yunhai.flowcenter.entity.FlwCc; import com.yunhai.flowcenter.entity.FlwDef; import com.yunhai.flowcenter.entity.FlwTask; import com.yunhai.flowcenter.entity.FlwInstance; @@ -10,6 +11,7 @@ import com.yunhai.flowcenter.engine.FlowEngine; import com.yunhai.flowcenter.enums.FlwEnums; import com.yunhai.flowcenter.model.OapiModels; 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.FlwTaskRepository; @@ -62,6 +64,7 @@ public class OapiFlowService { private final FlwDefRepository defRepository; private final FlwInstanceRepository instanceRepository; private final FlwTaskRepository taskRepository; + private final FlwCcRepository ccRepository; private final FlwCallbackRegRepository regRepository; private final FlowInstanceService instanceService; private final FlowTaskService taskService; @@ -352,11 +355,17 @@ public class OapiFlowService { boolean hasSubmitRow = rows.stream() .anyMatch(t -> FlwEnums.TaskStatus.NODE_SUBMIT.equals(t.getNodeId())); List steps = new ArrayList<>(); + // 步骤发生时间(排序键):任务步=办理人行最晚时间(handleTime?:arriveTime),submit/事件步=动作时间, + // 抄送步=最晚 sentTime——抄送步与任务步按真实时间交织,人读时间线不出现"抄送悬在最后"。 + Map stepTimes = + new java.util.IdentityHashMap<>(); // 第 0 步"发起申请"(对外时间线图3/图4口径):2026-09-18 起发起会留痕成真实任务行, // 有真实行时由下面的循环出这一步(可见内容与这里的合成步一致);老办件没有真实行, // 仍按办件快照合成,存量数据对外表现不变。 if (!hasSubmitRow) { - steps.add(submitStep(instance)); + OapiModels.ProgressStepVo submit = submitStep(instance); + steps.add(submit); + stepTimes.put(submit, instance.getSubmitTime()); } // 轨迹留痕事件行(6 发起/重新提交、7 撤回)单独成步;步骤 label 直接用事件语义, // 不参与下面的"任一拒绝>任一同意>全失效"汇总(否则会被判成"失效")。 @@ -370,6 +379,7 @@ public class OapiFlowService { OapiModels.ProgressStepVo es = eventStep(t); eventSteps.add(es); steps.add(es); + stepTimes.put(es, t.getHandleTime() != null ? t.getHandleTime() : t.getArriveTime()); continue; } OapiModels.ProgressStepVo step = stepsByNode.computeIfAbsent(t.getNodeId(), k -> { @@ -389,11 +399,77 @@ public class OapiFlowService { } s.setActive(activeNodes.contains(s.getNodeId())); s.setLabel(s.isActive() ? "待审核" : stepLabel(s.getAssignees())); + java.time.LocalDateTime latest = null; + for (OapiModels.ProgressTaskVo r : s.getAssignees()) { + java.time.LocalDateTime t = r.getHandleTime() != null + ? java.time.LocalDateTime.parse(r.getHandleTime()) + : (r.getArriveTime() == null ? null : java.time.LocalDateTime.parse(r.getArriveTime())); + if (t != null && (latest == null || t.isAfter(latest))) { + latest = t; + } + } + stepTimes.put(s, latest); + } + // 抄送步(有才交织排序):无抄送时保持既有拼装顺序原样输出(存量办件出参零变化); + // 有抄送时按 (发生时间, 秩) 排——既有步=0、抄送步=1(同秒 DATETIME 分不出先后,任务步 + // 恒在前;不给既有步分秩是因为驳回/重提步同秒靠 id 定序即真实先后,秩会颠倒)。 + List ccStepList = ccSteps(instance.getId(), stepTimes); + if (!ccStepList.isEmpty()) { + Map ranks = new java.util.IdentityHashMap<>(); + for (OapiModels.ProgressStepVo s : steps) { + ranks.put(s, 0); + } + for (OapiModels.ProgressStepVo s : ccStepList) { + ranks.put(s, 1); + } + steps.addAll(ccStepList); + steps.sort(java.util.Comparator + .comparing((OapiModels.ProgressStepVo s) -> stepTimes.get(s), + java.util.Comparator.nullsLast(java.util.Comparator.naturalOrder())) + .thenComparing(s -> ranks.get(s))); } vo.setSteps(steps); return vo; } + /** + * 抄送步(2026-09-23 NOTIFY 节点):按 nodeId 聚合(多人并排),label="抄送"、 + * 参与行 status=1/已抄送(复用"已办过"取值,消费方无需适配新状态);时间取 sentTime, + * 与任务步按真实发生时间交织排序。无抄送返回空列表(存量办件出参零变化)。 + */ + private List ccSteps(Long instanceId, + Map stepTimes) { + List ccs = ccRepository.findByInstanceIdOrderByIdAsc(instanceId); + if (ccs.isEmpty()) { + return new ArrayList<>(); + } + Map byNode = new java.util.LinkedHashMap<>(); + for (FlwCc c : ccs) { + OapiModels.ProgressStepVo step = byNode.computeIfAbsent(c.getNodeId(), k -> { + OapiModels.ProgressStepVo s = new OapiModels.ProgressStepVo(); + s.setNodeId(c.getNodeId()); + s.setNodeName(c.getNodeName() == null || c.getNodeName().isEmpty() ? "抄送" : c.getNodeName()); + s.setActive(false); + s.setLabel("抄送"); + s.setAssignees(new ArrayList<>()); + stepTimes.put(s, c.getSentTime()); + return s; + }); + OapiModels.ProgressTaskVo row = new OapiModels.ProgressTaskVo(); + row.setUserId(c.getUserId()); + row.setUserName(c.getUserName()); + row.setStatus(1); + row.setStatusLabel("已抄送"); + row.setArriveTime(c.getSentTime() == null ? null : c.getSentTime().toString()); + step.getAssignees().add(row); + java.time.LocalDateTime t = stepTimes.get(step); + if (c.getSentTime() != null && (t == null || c.getSentTime().isAfter(t))) { + stepTimes.put(step, c.getSentTime()); + } + } + return new ArrayList<>(byNode.values()); + } + /** 合成"发起申请"步(老办件:无真实留痕行,取办件快照的提交人/提交时间) */ private OapiModels.ProgressStepVo submitStep(FlwInstance instance) { OapiModels.ProgressStepVo submit = new OapiModels.ProgressStepVo(); @@ -436,7 +512,7 @@ public class OapiFlowService { return step; } - /** 任务行 → 对外步骤里的办理人行 */ + /** 任务行 → 对外步骤里的办理人行(展示时间截断到秒:列已升毫秒精度,对外口径不变) */ private OapiModels.ProgressTaskVo taskVo(FlwTask t) { OapiModels.ProgressTaskVo row = new OapiModels.ProgressTaskVo(); row.setTaskId(t.getId()); @@ -445,8 +521,10 @@ public class OapiFlowService { row.setStatus(t.getStatus()); row.setStatusLabel(taskLabel(t.getStatus())); row.setOpinion(t.getOpinion()); - row.setArriveTime(t.getArriveTime() == null ? null : t.getArriveTime().toString()); - row.setHandleTime(t.getHandleTime() == null ? null : t.getHandleTime().toString()); + row.setArriveTime(t.getArriveTime() == null ? null + : t.getArriveTime().truncatedTo(java.time.temporal.ChronoUnit.SECONDS).toString()); + row.setHandleTime(t.getHandleTime() == null ? null + : t.getHandleTime().truncatedTo(java.time.temporal.ChronoUnit.SECONDS).toString()); row.setEdgeName(t.getEdgeName()); return row; } diff --git a/src/main/resources/sql/20260923_create_flw_cc.sql b/src/main/resources/sql/20260923_create_flw_cc.sql new file mode 100644 index 0000000..bb80b6d --- /dev/null +++ b/src/main/resources/sql/20260923_create_flw_cc.sql @@ -0,0 +1,29 @@ +-- flw_cc 抄送留痕(2026-09-23 二期第一批 NOTIFY 抄送节点) +-- 用途:NOTIFY 节点激活时逐接收人落一行(瞬时放行,不建任务、不占待办); +-- "抄送我的"页签(idx user_id+sent_time)与办件轨迹/progress 的"抄送"行(idx instance_id)数据源。 +-- 语义:知会≠办理——接收人离职/映射停用只跳过不阻塞;退回重走再抄送为新增行(只增不改)。 +-- 附带:flw_task 到达/办理时间升到毫秒精度(秒级分不出"发起→抄送→下一节点激活"亚秒因果序, +-- 轨迹交织排序依赖毫秒;存量值秒级补 .000 平滑兼容,展示层截断到秒不变)。 +-- 本地/测试 ddl-auto=update 会自动建表/改列;生产执行本脚本。 +CREATE TABLE `flw_cc` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `instance_id` BIGINT NOT NULL COMMENT '归属办件', + `sch_id` BIGINT NOT NULL COMMENT '租户键(冗余,列表免 join)', + `node_id` VARCHAR(64) NOT NULL COMMENT '定义节点 id', + `node_name` VARCHAR(128) NULL COMMENT '节点名快照', + `user_id` INT NOT NULL COMMENT '接收人 id', + `user_name` VARCHAR(128) NULL COMMENT '接收人姓名快照', + `sent_time` DATETIME(3) NOT NULL COMMENT '抄送时间(节点激活时刻,毫秒)', + `create_by` VARCHAR(64) NULL, + `create_time` DATETIME NULL, + `update_by` VARCHAR(64) NULL, + `update_time` DATETIME NULL, + PRIMARY KEY (`id`), + KEY `idx_flw_cc_user` (`user_id`, `sent_time`), + KEY `idx_flw_cc_inst` (`instance_id`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='抄送留痕(NOTIFY 节点逐接收人一行)'; + +ALTER TABLE `flw_task` + MODIFY COLUMN `arrive_time` DATETIME(3) NOT NULL COMMENT '到达时间(毫秒)', + MODIFY COLUMN `handle_time` DATETIME(3) NULL COMMENT '办理时间(毫秒)'; + diff --git a/src/main/resources/sql/20260924_create_flw_router_and_route_log.sql b/src/main/resources/sql/20260924_create_flw_router_and_route_log.sql new file mode 100644 index 0000000..0710dbc --- /dev/null +++ b/src/main/resources/sql/20260924_create_flw_router_and_route_log.sql @@ -0,0 +1,45 @@ +-- flw_router / flw_route_log(2026-09-24 二期第二批 CONDITION 条件路由) +-- 用途:flw_router=判断服务注册表(CONDITION 节点 routerKey 寻址,发布期校验须已注册启用); +-- flw_route_log=决策留痕(一次判断一行,只增不改;outlet=NULL=失败挂起; +-- 退回重走/管理端重跑=新行 attempt 递增,轨迹可看多轮判断)。 +-- 本地/测试 ddl-auto=update 会自动建表;生产执行本脚本。 +CREATE TABLE `flw_router` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `router_key` VARCHAR(64) NOT NULL COMMENT '注册键(CONDITION 节点 routerKey 引用)', + `name` VARCHAR(128) NOT NULL COMMENT '展示名(编辑器下拉)', + `service_key` VARCHAR(64) NOT NULL COMMENT '服务目录键(如 notice)', + `path` VARCHAR(255) NOT NULL COMMENT '判断端点路径(如 /oapi/notice/flow/route/dept-judge)', + `base_path` VARCHAR(64) NULL COMMENT '接收方 context-path(生产 /notice);空=沿用目录条目前缀。签名拼串要剥,漏填必 401', + `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1 启用 / 0 停用', + `create_by` VARCHAR(64) NULL, + `create_time` DATETIME NULL, + `update_by` VARCHAR(64) NULL, + `update_time` DATETIME NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_flw_router_key` (`router_key`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='条件路由判断服务注册(全局一行,调用带 schId,校内隔离业务方自理)'; + +CREATE TABLE `flw_route_log` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `sch_id` BIGINT NOT NULL COMMENT '租户键(冗余)', + `instance_id` BIGINT NOT NULL COMMENT '归属办件', + `node_id` VARCHAR(64) NOT NULL COMMENT '定义节点 id(CONDITION)', + `node_name` VARCHAR(128) NULL COMMENT '节点名快照', + `router_key` VARCHAR(64) NOT NULL COMMENT '判断服务注册键快照', + `outlet` VARCHAR(64) NULL COMMENT '命中出口名;NULL=本次调用失败(挂起)', + `raw_response` TEXT NULL COMMENT '判断接口原始返回(审计;失败时为错误摘要)', + `attempt` INT NOT NULL DEFAULT 1 COMMENT '第几次判断(同 instance+node 递增:退回重走/重跑都 +1)', + `decide_time` DATETIME(3) NOT NULL COMMENT '判断完成时刻(毫秒,与 flw_task/flw_cc 同口径)', + `create_by` VARCHAR(64) NULL, + `create_time` DATETIME NULL, + `update_by` VARCHAR(64) NULL, + `update_time` DATETIME NULL, + PRIMARY KEY (`id`), + KEY `idx_flw_route_log_inst` (`instance_id`, `node_id`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='条件路由决策留痕(一次调用一行,只增不改)'; + +-- 初始化注册:notice 发文部门判断(生产执行前先核对 basePath——生产 notice 有 context-path /notice) +-- INSERT INTO `flw_router` +-- (`router_key`, `name`, `service_key`, `path`, `base_path`, `status`, `create_by`, `create_time`) +-- VALUES +-- ('notice.dept-judge', '发文部门判断', 'notice', '/oapi/notice/flow/route/dept-judge', '/notice', 1, 'admin', NOW()); diff --git a/src/test/java/com/yunhai/flowcenter/engine/DefValidatorTest.java b/src/test/java/com/yunhai/flowcenter/engine/DefValidatorTest.java index 3b93531..d9fed85 100644 --- a/src/test/java/com/yunhai/flowcenter/engine/DefValidatorTest.java +++ b/src/test/java/com/yunhai/flowcenter/engine/DefValidatorTest.java @@ -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 映射应通过 diff --git a/src/test/java/com/yunhai/flowcenter/engine/FlowEngineIT.java b/src/test/java/com/yunhai/flowcenter/engine/FlowEngineIT.java index 8a5e191..82611ff 100644 --- a/src/test/java/com/yunhai/flowcenter/engine/FlowEngineIT.java +++ b/src/test/java/com/yunhai/flowcenter/engine/FlowEngineIT.java @@ -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 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> trace = + queryService.detail(SCH, instanceId, null).getTrace(); + java.util.List kinds = new java.util.ArrayList<>(); + for (java.util.Map 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 + 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 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 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 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 logs = + routeLogRepository.findByInstanceIdOrderByIdAsc(instanceId); + assertEquals(1, logs.size()); + assertEquals("甲线", logs.get(0).getOutlet()); + assertEquals(1, logs.get(0).getAttempt()); + java.util.List> trace = + queryService.detail(SCH, instanceId, null).getTrace(); + java.util.List kinds = new java.util.ArrayList<>(); + for (java.util.Map 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 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 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 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 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 branchRows(Long instanceId) { return branchRepository.findByInstanceId(instanceId); } diff --git a/src/test/java/com/yunhai/flowcenter/engine/RouteJudgeClientTest.java b/src/test/java/com/yunhai/flowcenter/engine/RouteJudgeClientTest.java new file mode 100644 index 0000000..1c68cf2 --- /dev/null +++ b/src/test/java/com/yunhai/flowcenter/engine/RouteJudgeClientTest.java @@ -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 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 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 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 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()); + assertThrows(RouteJudgeClient.JudgeUnavailableException.class, + () -> client.call(router(""), body())); + verify(openCall, times(RouteJudgeClient.MAX_ATTEMPTS)) + .post(any(TargetSpec.class), anyString(), anyMap(), any(), eq(Map.class)); + } +} diff --git a/src/test/java/com/yunhai/flowcenter/service/FlowDiagramServiceTest.java b/src/test/java/com/yunhai/flowcenter/service/FlowDiagramServiceTest.java index 61e28e8..5fe87bb 100644 --- a/src/test/java/com/yunhai/flowcenter/service/FlowDiagramServiceTest.java +++ b/src/test/java/com/yunhai/flowcenter/service/FlowDiagramServiceTest.java @@ -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)); } diff --git a/src/test/java/com/yunhai/flowcenter/support/FlowTestSupport.java b/src/test/java/com/yunhai/flowcenter/support/FlowTestSupport.java index 2620766..59441f8 100644 --- a/src/test/java/com/yunhai/flowcenter/support/FlowTestSupport.java +++ b/src/test/java/com/yunhai/flowcenter/support/FlowTestSupport.java @@ -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 "{"