feat(engine): NOTIFY 抄送 + CONDITION 条件路由 + JOIN scope 修复(两批全栈)
build / build-push (push) Successful in 47s

- NOTIFY:TYPE_NOTIFY 瞬时抄送(flw_cc 逐人留痕 + CC_SENT 事件 + 站内通知不加待办角标)、/api/flow/cc/my、轨迹 kind=CC 与 oapi progress 抄送步、示意图青框卡片
- CONDITION:flw_router 判断服务注册 + flw_route_log 决策留痕(REQUIRES_NEW,reroute 回滚不丢失败行)+ 同步判断调用(服务目录寻址 + 平台签名,3s×3 退避重试)+ 失败挂起/管理端重跑(不配默认出口)+ 可选事件 flow.route.decided
- JOIN scope:branch_key 路径前缀编码 + scope 级到齐放行 + DefValidator SPLIT/JOIN 配对与键长守卫(存量无点号键零迁移,历史直连图回退实例级口径)
- 管理端:/api/flow/admin/router/* CRUD + POST /api/flow/admin/instance/{id}/reroute
- 生产 SQL:sql/20260923_create_flw_cc.sql(含 flw_task 时间列升 DATETIME(3))、20260924_create_flw_router_and_route_log.sql
- 测试:全量 125 绿(FlowEngineIT 24,含条件/嵌套并行/退回重走用例)
This commit is contained in:
chenxing
2026-09-24 08:58:26 +08:00
parent f2126cdf32
commit d2bef20e13
34 changed files with 2472 additions and 97 deletions
@@ -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<com.yunhai.flowcenter.entity.FlwRouter> 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<String, Object> 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")
@@ -66,6 +66,14 @@ public class FlowUserController {
return queryService.pageDone(requireSchId(user), user.getId(), page, size);
}
/** 我的抄送(NOTIFY 节点落库;知会不占待办,独立页签) */
@GetMapping("/cc/my")
public BasePageResult<FlowModels.CcItem> 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) {
@@ -52,6 +52,33 @@ public class AssigneeResolver {
return refs;
}
/**
* 抄送接收人解析(NOTIFY 专用,宽容口径):结构不合法照抛(发布期校验本应拦住,属脏定义),
* 但解析不出人(映射被停用/名单为空)只返回空列表由调用方记日志——抄送是知会不是审批,
* 绝不能因抄送对象缺失卡住流程推进。与 {@link #resolve} 的强口径相反。
*/
public List<AssigneeRef> 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<AssigneeRef> 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));
@@ -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})——两处必须同口径,故提取为静态工具。
*
* <p>嵌套口径(内层 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<String> visited = new HashSet<>();
Deque<String> 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<String, String>(), new HashMap<String, FlowDefModel.Node>(), 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<String, String>(), new HashMap<String, FlowDefModel.Node>(), 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<String, String> keyMemo, Map<String, FlowDefModel.Node> 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<String> visited = new HashSet<>();
visited.add(nodeId);
Deque<String> 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<String, String> keyMemo,
Map<String, FlowDefModel.Node> 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<String> visited = new HashSet<>();
visited.add(joinNodeId);
Deque<String> 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;
}
}
@@ -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(一次一行)。
*
* <p><b>失败策略(已定,勿改):挂起 + 管理端重跑,禁止配默认出口</b>——超时/非 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<String> 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<String> outletNames(FlowDefModel model, FlowDefModel.Node node) {
List<String> names = new ArrayList<>();
for (FlowDefModel.Edge e : model.outEdges(node.id)) {
names.add(e.name);
}
return names;
}
/** form_data_json 原样透传为 variables(SDK 发起参数不加新字段;解析失败=空对象) */
private Map<String, Object> 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<Map<String, Object>>() {
});
} 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<String> 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<String> outlets,
String error, int attempt) {
try {
List<Integer> 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<Integer> alertUserIds() {
List<Integer> 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;
}
}
@@ -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<String> 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<String> enabledDeptKeys, Set<String> enabledRouterKeys) {
List<String> 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<String, Integer> 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<String> enabledDeptKeys, List<String> problems) {
FlowDefModel.Assignee a = n.assignee;
/**
* NOTIFY 抄送节点校验:receivers 必填且类型合法(复用 Assignee 结构),DEPT_KEY 须有启用映射
* ——运行时解析是宽容口径(无人只记日志不阻塞),发布期拦一道防"配错科室静默抄送空"。
*/
private static void validateNotify(FlowDefModel.Node n, Set<String> enabledDeptKeys, List<String> 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<String> enabledRouterKeys,
List<String> 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<String> enabledDeptKeys, List<String> problems) { FlowDefModel.Assignee a = n.assignee;
if (a == null || a.type == null) {
problems.add(nodeLabel(n) + " 缺少 assignee 配置");
return;
@@ -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 */
@@ -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<FlowDefModel.Edge> 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<FlwBranch> 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<FlowDefModel.Edge> 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<AssigneeRef> 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<String, Object> 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<String, Object> 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());
@@ -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");
@@ -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})。
*
* <p>重试纪律:同步 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<String> outlets;
public final Map<String, Object> variables;
public JudgeBody(Long schId, Long instanceId, Integer instanceVersion, String bizType, String bizId,
String nodeId, String nodeName, String routerKey, List<String> outlets,
Map<String, Object> 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<String, String> query = new HashMap<>();
query.put("schId", String.valueOf(body.schId));
RuntimeException last = null;
for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
try {
Map<String, Object> 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();
}
}
}
@@ -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);
}
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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) */
@@ -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 {
@@ -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) {
@@ -15,7 +15,8 @@ public interface FlwBranchRepository extends JpaRepository<FlwBranch, Long> {
Optional<FlwBranch> findByInstanceIdAndBranchKey(Long instanceId, String branchKey);
long countByInstanceIdAndStatus(Long instanceId, Integer status);
/** scope 级放行判断用(§4.6):取全量进行中行后按前缀过滤,行数少无需再下钻查询 */
List<FlwBranch> findByInstanceIdAndStatus(Long instanceId, Integer status);
@Modifying
@Query("update FlwBranch b set b.status = 2 where b.instanceId = :instanceId and b.status = 0")
@@ -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<FlwCc, Long> {
/** 抄送我的(用户面页签,走 user+sent_time 索引) */
Page<FlwCc> findBySchIdAndUserIdOrderBySentTimeDesc(Long schId, Integer userId, Pageable pageable);
/** 办件轨迹/进度留痕 */
List<FlwCc> findByInstanceIdOrderByIdAsc(Long instanceId);
}
@@ -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<FlwRouteLog, Long> {
/** 办件轨迹/详情留痕 */
List<FlwRouteLog> 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);
}
@@ -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<FlwRouter, Long> {
Optional<FlwRouter> findByRouterKey(String routerKey);
/** 发布校验用:启用中的注册键全集(CONDITION routerKey 白名单) */
List<FlwRouter> findByStatusOrderByIdAsc(Integer status);
}
@@ -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;
}
@@ -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<String> 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<String> 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<String> enabledDeptKeys(Long schId) {
return mapRepository.findEnabledBySchId(schId).stream()
.map(FlwAssigneeMap::getDeptKey).collect(Collectors.toSet());
}
/** CONDITION routerKey 发布校验白名单(flw_router 启用键全集,全局不分校) */
private Set<String> 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));
@@ -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<FlwAssigneeMap> 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;
}
@@ -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<FlowModels.CcItem> pageCc(Long schId, Integer userId,
Integer page, Integer size) {
Page<FlwCc> p = ccRepository.findBySchIdAndUserIdOrderBySentTimeDesc(schId, userId,
PageRequest.of(normPage(page), normSize(size)));
List<FlowModels.CcItem> 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<FlowModels.CcItem> 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<FlowModels.TaskItem> pageTodo(Long schId, Integer assigneeId,
Integer page, Integer size) {
Page<FlwTask> p = taskRepository.pageTodo(schId, assigneeId,
@@ -122,9 +163,11 @@ public class FlowQueryService {
detail.setFinishTime(str(instance.getFinishTime()));
detail.setVersion(instance.getVersion());
List<FlwTask> tasks = taskRepository.findByInstanceIdOrderByIdAsc(instanceId);
List<FlwCc> ccs = ccRepository.findByInstanceIdOrderByIdAsc(instanceId);
List<FlwRouteLog> 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<Map<String, Object>> branches = new ArrayList<>();
for (FlwBranch b : branchRepository.findByInstanceId(instanceId)) {
Map<String, Object> row = new HashMap<>();
@@ -224,13 +267,19 @@ public class FlowQueryService {
* 合成一条按时间升序的轨迹,替掉原先"按 id 升序"的排法(id 序在撤回重走场景下与
* 真实发生顺序不一致)。
*
* <p>2026-09-23 并入抄送行(flw_cc,kind=CC):NOTIFY 节点不建任务,若不并入则抄送在
* 轨迹上不可见;按 sentTime 与任务行同场归位、参与轮次进位(退回重走的第 N 轮抄送)。
*
* <p>2026-09-24 并入路由判断行(flw_route_log,kind=ROUTE):CONDITION 判断留痕
* "系统判断 → 出口"(outlet=NULL 渲染为挂起);按 decideTime 同场归位。
*
* <p>每条行补四个前端直接用、不再自行推断的字段:
* <ul>
* <li>{@code time} 该行实际发生的时间(已办/退回/移交/撤回=handleTime,在办/失效=arriveTime);</li>
* <li>{@code kind} SUBMIT|RESUBMIT|WITHDRAW|APPROVE|REJECT|RETURN|TRANSFER|INVALID|PENDING
* <li>{@code time} 该行实际发生的时间(已办/退回/移交/撤回=handleTime,在办/失效=arriveTime,抄送=sentTime,判断=decideTime);</li>
* <li>{@code kind} SUBMIT|RESUBMIT|WITHDRAW|APPROVE|REJECT|RETURN|TRANSFER|INVALID|PENDING|CC|ROUTE
* (前端配色与文案的唯一依据);</li>
* <li>{@code round} 第几轮审批(遇"重新提交"进位,撤回属于它结束的那一轮);</li>
* <li>{@code rowKey} 去重 key(任务行 t{id}、合成行 submit —— taskId 可能为 null 不能当 key)。</li>
* <li>{@code rowKey} 去重 key(任务行 t{id}、抄送行 cc{id}、合成行 submit —— taskId 可能为 null 不能当 key)。</li>
* </ul>
* 科室口径(视图两档:dept/user)另需 assigneeType/deptKey/deptName —— 只有 DEPT_KEY 节点有
* 科室概念,故仅对该类节点回填;节点在定义里找不到(重提后老节点、留痕事件行)不回填,
@@ -240,40 +289,39 @@ public class FlowQueryService {
* 兜底插入到时间流对应位置,保证轨迹首条仍是"发起申请"。
*/
private List<Map<String, Object>> buildTrace(FlwInstance instance, List<FlwTask> tasks,
List<FlwCc> ccs, List<FlwRouteLog> routeLogs,
FlowDefModel model) {
List<FlwTask> 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<TimedRow> rows = new ArrayList<>();
Map<String, String> 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<Map<String, Object>> trace = new ArrayList<>();
int headIndex = 0;
LocalDateTime submitTime = instance.getSubmitTime();
for (FlwTask t : sorted) {
Map<String, Object> 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<String, Object> taskTraceRow(FlwTask t, FlowDefModel model, FlwInstance instance,
Map<String, String> deptNames) {
Map<String, Object> 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<String, Object> ccTraceRow(FlwCc c) {
Map<String, Object> 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<String, Object> routeTraceRow(FlwRouteLog r) {
Map<String, Object> 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<String, Object> row;
TimedRow(LocalDateTime time, int source, long seq, Map<String, Object> row) {
this.time = time;
this.source = source;
this.seq = seq;
this.row = row;
}
}
private static boolean hasSubmitRow(List<FlwTask> 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) {
@@ -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<FlwRouter> 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();
}
}
@@ -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<OapiModels.ProgressStepVo> steps = new ArrayList<>();
// 步骤发生时间(排序键):任务步=办理人行最晚时间(handleTime?:arriveTime),submit/事件步=动作时间,
// 抄送步=最晚 sentTime——抄送步与任务步按真实时间交织,人读时间线不出现"抄送悬在最后"。
Map<OapiModels.ProgressStepVo, java.time.LocalDateTime> 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<OapiModels.ProgressStepVo> ccStepList = ccSteps(instance.getId(), stepTimes);
if (!ccStepList.isEmpty()) {
Map<OapiModels.ProgressStepVo, Integer> 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<OapiModels.ProgressStepVo> ccSteps(Long instanceId,
Map<OapiModels.ProgressStepVo, java.time.LocalDateTime> stepTimes) {
List<FlwCc> ccs = ccRepository.findByInstanceIdOrderByIdAsc(instanceId);
if (ccs.isEmpty()) {
return new ArrayList<>();
}
Map<String, OapiModels.ProgressStepVo> 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;
}
@@ -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 '办理时间(毫秒)';
@@ -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());
@@ -195,6 +195,266 @@ class DefValidatorTest {
() -> DefValidator.validate(parse(json), Collections.emptySet()));
}
@Test
void notify_receiversValid_passes() {
String json = "{"
+ "\"code\":\"c1\",\"name\":\"流程\",\"start\":\"start\","
+ "\"nodes\":["
+ "{\"id\":\"start\",\"type\":\"START\"},"
+ "{\"id\":\"cc1\",\"type\":\"NOTIFY\",\"name\":\"抄送校长\",\"receivers\":{\"type\":\"USERS\",\"userIds\":[301]}},"
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"ybb\"}},"
+ "{\"id\":\"end\",\"type\":\"END\"}],"
+ "\"edges\":[{\"from\":\"start\",\"to\":\"cc1\"},{\"from\":\"cc1\",\"to\":\"n1\"},"
+ "{\"from\":\"n1\",\"to\":\"end\"}]}";
DefValidator.validate(parse(json), new HashSet<>(Collections.singletonList("ybb")));
}
@Test
void notify_missingReceivers_fails() {
String json = "{"
+ "\"code\":\"c1\",\"name\":\"流程\",\"start\":\"start\","
+ "\"nodes\":["
+ "{\"id\":\"start\",\"type\":\"START\"},"
+ "{\"id\":\"cc1\",\"type\":\"NOTIFY\",\"name\":\"抄送\"},"
+ "{\"id\":\"end\",\"type\":\"END\"}],"
+ "\"edges\":[{\"from\":\"start\",\"to\":\"cc1\"},{\"from\":\"cc1\",\"to\":\"end\"}]}";
BizException e = assertThrows(BizException.class,
() -> DefValidator.validate(parse(json), Collections.emptySet()));
assertTrue(e.getMessage().contains("抄送对象"));
}
@Test
void notify_deptKeyWithoutMapping_fails() {
// 运行时宽容(无人不阻塞),发布期仍拦——防配错科室静默抄送空
String json = "{"
+ "\"code\":\"c1\",\"name\":\"流程\",\"start\":\"start\","
+ "\"nodes\":["
+ "{\"id\":\"start\",\"type\":\"START\"},"
+ "{\"id\":\"cc1\",\"type\":\"NOTIFY\",\"name\":\"抄送\",\"receivers\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"gjc\"}},"
+ "{\"id\":\"end\",\"type\":\"END\"}],"
+ "\"edges\":[{\"from\":\"start\",\"to\":\"cc1\"},{\"from\":\"cc1\",\"to\":\"end\"}]}";
BizException e = assertThrows(BizException.class,
() -> DefValidator.validate(parse(json), new HashSet<>(Collections.singletonList("ybb"))));
assertTrue(e.getMessage().contains("gjc"));
}
@Test
void notify_twoOutEdges_fails() {
String json = "{"
+ "\"code\":\"c1\",\"name\":\"流程\",\"start\":\"start\","
+ "\"nodes\":["
+ "{\"id\":\"start\",\"type\":\"START\"},"
+ "{\"id\":\"cc1\",\"type\":\"NOTIFY\",\"name\":\"抄送\",\"receivers\":{\"type\":\"USERS\",\"userIds\":[301]}},"
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"ybb\"}},"
+ "{\"id\":\"end\",\"type\":\"END\"}],"
+ "\"edges\":[{\"from\":\"start\",\"to\":\"cc1\"},"
+ "{\"from\":\"cc1\",\"to\":\"n1\"},{\"from\":\"cc1\",\"to\":\"end\"},"
+ "{\"from\":\"n1\",\"to\":\"end\"}]}";
BizException e = assertThrows(BizException.class,
() -> DefValidator.validate(parse(json), new HashSet<>(Collections.singletonList("ybb"))));
assertTrue(e.getMessage().contains("出边必须恰为 1 条"));
}
// ===== 嵌套并行配对与键长守卫(2026-09-24 §4.6) =====
@Test
void nestedParallel_pairedSplitJoin_passes() {
String json = "{"
+ "\"code\":\"c1\",\"name\":\"流程\",\"start\":\"start\","
+ "\"nodes\":["
+ "{\"id\":\"start\",\"type\":\"START\"},"
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"name\":\"入口\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"ybb\"}},"
+ "{\"id\":\"s1\",\"type\":\"PARALLEL_SPLIT\"},"
+ "{\"id\":\"ta\",\"type\":\"APPROVAL\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"ybb\"}},"
+ "{\"id\":\"tb\",\"type\":\"APPROVAL\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"ybb\"}},"
+ "{\"id\":\"s2\",\"type\":\"PARALLEL_SPLIT\"},"
+ "{\"id\":\"tc\",\"type\":\"APPROVAL\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"ybb\"}},"
+ "{\"id\":\"td\",\"type\":\"APPROVAL\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"ybb\"}},"
+ "{\"id\":\"join2\",\"type\":\"PARALLEL_JOIN\",\"waitFor\":\"ALL\"},"
+ "{\"id\":\"join1\",\"type\":\"PARALLEL_JOIN\",\"waitFor\":\"ALL\"},"
+ "{\"id\":\"end\",\"type\":\"END\"}],"
+ "\"edges\":[{\"from\":\"start\",\"to\":\"n1\"},{\"from\":\"n1\",\"to\":\"s1\"},"
+ "{\"from\":\"s1\",\"to\":\"ta\"},{\"from\":\"s1\",\"to\":\"tb\"},"
+ "{\"from\":\"ta\",\"to\":\"join1\"},{\"from\":\"tb\",\"to\":\"s2\"},"
+ "{\"from\":\"s2\",\"to\":\"tc\"},{\"from\":\"s2\",\"to\":\"td\"},"
+ "{\"from\":\"tc\",\"to\":\"join2\"},{\"from\":\"td\",\"to\":\"join2\"},"
+ "{\"from\":\"join2\",\"to\":\"join1\"},{\"from\":\"join1\",\"to\":\"end\"}]}";
DefValidator.validate(parse(json), new HashSet<>(Collections.singletonList("ybb")));
}
@Test
void split_withoutJoin_fails() {
// 分支各自直达 end、无汇聚点:split 匹配不到 join
String json = "{"
+ "\"code\":\"c1\",\"name\":\"流程\",\"start\":\"start\","
+ "\"nodes\":["
+ "{\"id\":\"start\",\"type\":\"START\"},"
+ "{\"id\":\"split1\",\"type\":\"PARALLEL_SPLIT\"},"
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"ybb\"}},"
+ "{\"id\":\"end\",\"type\":\"END\"}],"
+ "\"edges\":[{\"from\":\"start\",\"to\":\"split1\"},"
+ "{\"from\":\"split1\",\"to\":\"n1\"},{\"from\":\"split1\",\"to\":\"end\"},"
+ "{\"from\":\"n1\",\"to\":\"end\"}]}";
BizException e = assertThrows(BizException.class,
() -> DefValidator.validate(parse(json), new HashSet<>(Collections.singletonList("ybb"))));
assertTrue(e.getMessage().contains("未汇聚"));
}
@Test
void join_withoutUpstreamSplit_fails() {
// 主线直连汇聚点:匹配不到分发节点(旧校验只拦"全图无 split",现按配对口径点名)
String json = "{"
+ "\"code\":\"c1\",\"name\":\"流程\",\"start\":\"start\","
+ "\"nodes\":["
+ "{\"id\":\"start\",\"type\":\"START\"},"
+ "{\"id\":\"join1\",\"type\":\"PARALLEL_JOIN\",\"waitFor\":\"ALL\"},"
+ "{\"id\":\"end\",\"type\":\"END\"}],"
+ "\"edges\":[{\"from\":\"start\",\"to\":\"join1\"},{\"from\":\"join1\",\"to\":\"end\"}]}";
BizException e = assertThrows(BizException.class,
() -> DefValidator.validate(parse(json), Collections.emptySet()));
assertTrue(e.getMessage().contains("匹配的分发节点"));
}
@Test
void splitBranchHead_isJoin_fails() {
// 分支头=汇聚点是无意义结构(键归属会错乱)
String json = "{"
+ "\"code\":\"c1\",\"name\":\"流程\",\"start\":\"start\","
+ "\"nodes\":["
+ "{\"id\":\"start\",\"type\":\"START\"},"
+ "{\"id\":\"split1\",\"type\":\"PARALLEL_SPLIT\"},"
+ "{\"id\":\"join1\",\"type\":\"PARALLEL_JOIN\",\"waitFor\":\"ALL\"},"
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"ybb\"}},"
+ "{\"id\":\"end\",\"type\":\"END\"}],"
+ "\"edges\":[{\"from\":\"start\",\"to\":\"split1\"},"
+ "{\"from\":\"split1\",\"to\":\"join1\"},{\"from\":\"split1\",\"to\":\"n1\"},"
+ "{\"from\":\"n1\",\"to\":\"join1\"},{\"from\":\"join1\",\"to\":\"end\"}]}";
BizException e = assertThrows(BizException.class,
() -> DefValidator.validate(parse(json), new HashSet<>(Collections.singletonList("ybb"))));
assertTrue(e.getMessage().contains("不能直接是汇聚点"));
}
@Test
void nestedBranchKey_over64_fails() {
// 两层 40 字符节点 id → 分支键 40+1+40=81 超 flw_branch.branch_key 列宽 64
String outer = "b1234567890123456789012345678901234567890"; // 41 字符
String inner = "c1234567890123456789012345678901234567890"; // 41 字符
String json = "{"
+ "\"code\":\"c1\",\"name\":\"流程\",\"start\":\"start\","
+ "\"nodes\":["
+ "{\"id\":\"start\",\"type\":\"START\"},"
+ "{\"id\":\"s1\",\"type\":\"PARALLEL_SPLIT\"},"
+ "{\"id\":\"" + outer + "\",\"type\":\"APPROVAL\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"ybb\"}},"
+ "{\"id\":\"s2\",\"type\":\"PARALLEL_SPLIT\"},"
+ "{\"id\":\"" + inner + "\",\"type\":\"APPROVAL\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"ybb\"}},"
+ "{\"id\":\"join2\",\"type\":\"PARALLEL_JOIN\",\"waitFor\":\"ALL\"},"
+ "{\"id\":\"join1\",\"type\":\"PARALLEL_JOIN\",\"waitFor\":\"ALL\"},"
+ "{\"id\":\"end\",\"type\":\"END\"}],"
+ "\"edges\":[{\"from\":\"start\",\"to\":\"s1\"},"
+ "{\"from\":\"s1\",\"to\":\"" + outer + "\"},{\"from\":\"s1\",\"to\":\"end\"},"
+ "{\"from\":\"" + outer + "\",\"to\":\"s2\"},"
+ "{\"from\":\"s2\",\"to\":\"" + inner + "\"},{\"from\":\"s2\",\"to\":\"join2\"},"
+ "{\"from\":\"" + inner + "\",\"to\":\"join2\"},{\"from\":\"join2\",\"to\":\"join1\"},"
+ "{\"from\":\"join1\",\"to\":\"end\"}]}";
BizException e = assertThrows(BizException.class,
() -> DefValidator.validate(parse(json), new HashSet<>(Collections.singletonList("ybb"))));
assertTrue(e.getMessage().contains("分支键超长"));
}
// ===== CONDITION 条件分支(2026-09-24 §4) =====
@Test
void condition_registeredRouter_twoNamedOutlets_passes() {
String json = "{"
+ "\"code\":\"c1\",\"name\":\"流程\",\"start\":\"start\","
+ "\"nodes\":["
+ "{\"id\":\"start\",\"type\":\"START\"},"
+ "{\"id\":\"cond1\",\"type\":\"CONDITION\",\"name\":\"条件判断\",\"routerKey\":\"rk1\"},"
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"ybb\"}},"
+ "{\"id\":\"n2\",\"type\":\"APPROVAL\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"ybb\"}},"
+ "{\"id\":\"end\",\"type\":\"END\"}],"
+ "\"edges\":[{\"from\":\"start\",\"to\":\"cond1\"},"
+ "{\"from\":\"cond1\",\"to\":\"n1\",\"name\":\"甲线\"},"
+ "{\"from\":\"cond1\",\"to\":\"n2\",\"name\":\"乙线\"},"
+ "{\"from\":\"n1\",\"to\":\"end\"},{\"from\":\"n2\",\"to\":\"end\"}]}";
DefValidator.validate(parse(json), new HashSet<>(Collections.singletonList("ybb")),
new HashSet<>(Collections.singletonList("rk1")));
}
@Test
void condition_missingRouterKey_fails() {
String json = "{"
+ "\"code\":\"c1\",\"name\":\"流程\",\"start\":\"start\","
+ "\"nodes\":["
+ "{\"id\":\"start\",\"type\":\"START\"},"
+ "{\"id\":\"cond1\",\"type\":\"CONDITION\",\"name\":\"条件判断\"},"
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"ybb\"}},"
+ "{\"id\":\"end\",\"type\":\"END\"}],"
+ "\"edges\":[{\"from\":\"start\",\"to\":\"cond1\"},"
+ "{\"from\":\"cond1\",\"to\":\"n1\",\"name\":\"甲线\"},"
+ "{\"from\":\"cond1\",\"to\":\"end\",\"name\":\"乙线\"},"
+ "{\"from\":\"n1\",\"to\":\"end\"}]}";
BizException e = assertThrows(BizException.class,
() -> DefValidator.validate(parse(json), new HashSet<>(Collections.singletonList("ybb")), null));
assertTrue(e.getMessage().contains("routerKey"));
}
@Test
void condition_unregisteredRouterKey_fails() {
// 运行时才发现无效只会挂起——发布期就拦(设计 §4.2 发布校验)
String json = "{"
+ "\"code\":\"c1\",\"name\":\"流程\",\"start\":\"start\","
+ "\"nodes\":["
+ "{\"id\":\"start\",\"type\":\"START\"},"
+ "{\"id\":\"cond1\",\"type\":\"CONDITION\",\"name\":\"条件判断\",\"routerKey\":\"rk-x\"},"
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"ybb\"}},"
+ "{\"id\":\"end\",\"type\":\"END\"}],"
+ "\"edges\":[{\"from\":\"start\",\"to\":\"cond1\"},"
+ "{\"from\":\"cond1\",\"to\":\"n1\",\"name\":\"甲线\"},"
+ "{\"from\":\"cond1\",\"to\":\"end\",\"name\":\"乙线\"},"
+ "{\"from\":\"n1\",\"to\":\"end\"}]}";
BizException e = assertThrows(BizException.class,
() -> DefValidator.validate(parse(json), new HashSet<>(Collections.singletonList("ybb")),
new HashSet<>(Collections.singletonList("rk1"))));
assertTrue(e.getMessage().contains("未注册或已停用"));
}
@Test
void condition_singleOutlet_fails() {
String json = "{"
+ "\"code\":\"c1\",\"name\":\"流程\",\"start\":\"start\","
+ "\"nodes\":["
+ "{\"id\":\"start\",\"type\":\"START\"},"
+ "{\"id\":\"cond1\",\"type\":\"CONDITION\",\"name\":\"条件判断\",\"routerKey\":\"rk1\"},"
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"ybb\"}},"
+ "{\"id\":\"end\",\"type\":\"END\"}],"
+ "\"edges\":[{\"from\":\"start\",\"to\":\"cond1\"},"
+ "{\"from\":\"cond1\",\"to\":\"n1\",\"name\":\"甲线\"},"
+ "{\"from\":\"n1\",\"to\":\"end\"}]}";
BizException e = assertThrows(BizException.class,
() -> DefValidator.validate(parse(json), new HashSet<>(Collections.singletonList("ybb")), null));
assertTrue(e.getMessage().contains("出边至少 2 条"));
}
@Test
void condition_unnamedOutlet_fails() {
// 出口名即判断接口 outlets 契约(复用多出口命名口径)
String json = "{"
+ "\"code\":\"c1\",\"name\":\"流程\",\"start\":\"start\","
+ "\"nodes\":["
+ "{\"id\":\"start\",\"type\":\"START\"},"
+ "{\"id\":\"cond1\",\"type\":\"CONDITION\",\"name\":\"条件判断\",\"routerKey\":\"rk1\"},"
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"ybb\"}},"
+ "{\"id\":\"end\",\"type\":\"END\"}],"
+ "\"edges\":[{\"from\":\"start\",\"to\":\"cond1\"},"
+ "{\"from\":\"cond1\",\"to\":\"n1\"},"
+ "{\"from\":\"cond1\",\"to\":\"end\",\"name\":\"乙线\"},"
+ "{\"from\":\"n1\",\"to\":\"end\"}]}";
BizException e = assertThrows(BizException.class,
() -> DefValidator.validate(parse(json), new HashSet<>(Collections.singletonList("ybb")), null));
assertTrue(e.getMessage().contains("出口必须命名"));
}
@Test
void overviewSample_def() {
// 概览 §5.1 缩样(并行+分支内串行):配齐 6 个 DEPT_KEY 映射应通过
@@ -43,6 +43,8 @@ class FlowEngineIT extends FlowTestSupport {
private FlowTaskService taskService;
@Autowired
private com.yunhai.flowcenter.service.FlowQueryService queryService;
@Autowired
private com.yunhai.flowcenter.service.FlowRouterAdminService routerAdminService;
@TestConfiguration
static class StubConfig {
@@ -51,11 +53,47 @@ class FlowEngineIT extends FlowTestSupport {
public com.yunhai.flowcenter.stub.StubUserClient stubUserClient() {
return new com.yunhai.flowcenter.stub.StubUserClient();
}
/**
* 路由判断桩(2026-09-24 CONDITION):替换真实 RouteJudgeClient(不出网),
* 每次调用取 StubJudge 当前编程值——出口名或抛错。真实客户端的重试/寻址逻辑
* 由 RouteJudgeClientTest 覆盖;本类聚焦引擎行为(选路/挂起/重跑/重判)。
*/
@Bean
@Primary
public RouteJudgeClient stubRouteJudgeClient() {
return new RouteJudgeClient(null, new com.fasterxml.jackson.databind.ObjectMapper()) {
@Override
public RouteJudgeClient.JudgeResult call(com.yunhai.flowcenter.entity.FlwRouter router,
RouteJudgeClient.JudgeBody body) {
return StubJudge.next();
}
};
}
}
/** 判断桩的编程值:outlet=命中出口;failure=非空时抛出(模拟服务不可用) */
static final class StubJudge {
static volatile String outlet = "甲线";
static volatile RuntimeException failure;
static RouteJudgeClient.JudgeResult next() {
if (failure != null) {
throw failure;
}
return new RouteJudgeClient.JudgeResult(outlet, "{\"outlet\":\"" + outlet + "\"}");
}
static void reset(String defaultOutlet) {
outlet = defaultOutlet;
failure = null;
}
}
@BeforeEach
void setUp() {
cleanDb();
StubJudge.reset("甲线");
}
private FlowInstanceService.ApplyOutcome apply(String flowGuid, String bizId,
@@ -433,6 +471,286 @@ class FlowEngineIT extends FlowTestSupport {
instanceRepository.findById(instanceId).get().getStatus());
}
/**
* NOTIFY 抄送·主线(2026-09-23):激活即逐人落 flw_cc,不建任务、不进 currentNodes,
* 流程无停顿直达下一审批节点;办件轨迹出现 kind=CC 行且"抄送我的"可查到。
*/
@Test
void notify_onMainline_writesCcRows_andKeepsFlowMoving() {
mapDept("km", 101, "甲");
FlwDef def = saveAndPublishDef("t16", "抄送主线", notifyMainlineDef("km"));
FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-16", 201, "张三");
Long instanceId = outcome.getInstanceId();
// 抄送瞬时放行:两个接收人各一行,无 cc 节点任务,直接建审核待办
List<com.yunhai.flowcenter.entity.FlwCc> ccs = ccRepository.findByInstanceIdOrderByIdAsc(instanceId);
assertEquals(2, ccs.size());
assertEquals("cc1", ccs.get(0).getNodeId());
assertEquals("抄送校领导", ccs.get(0).getNodeName());
assertEquals(301, ccs.get(0).getUserId());
assertEquals(302, ccs.get(1).getUserId());
assertEquals(1, taskRepository.findByInstanceIdAndStatus(instanceId, 0).size());
FlwInstance instance = instanceRepository.findById(instanceId).get();
assertEquals("n1", instance.getCurrentNodes());
assertEquals(FlwEnums.InstanceStatus.APPROVING.getCode(), instance.getStatus());
// 审核通过 → 完成(抄送节点从未阻塞过推进)
taskService.handle(SCH, tasksOf(instanceId, 101).get(0).getId(), true, "ok", 101);
assertEquals(FlwEnums.InstanceStatus.COMPLETED.getCode(),
instanceRepository.findById(instanceId).get().getStatus());
// 轨迹:发起 → 抄送×2 → 同意。时间列毫秒精度(DATETIME(3))后亚秒机器链因果序可复现;
// 展示字符串截断到秒(str),排序用原值。
java.util.List<java.util.Map<String, Object>> trace =
queryService.detail(SCH, instanceId, null).getTrace();
java.util.List<String> kinds = new java.util.ArrayList<>();
for (java.util.Map<String, Object> row : trace) {
kinds.add((String) row.get("kind"));
}
assertEquals(java.util.Arrays.asList("SUBMIT", "CC", "CC", "APPROVE"), kinds);
// 抄送我的:301 视角一行,回填办件信息
com.yunhai.platform.utils.model.BasePageResult<com.yunhai.flowcenter.model.FlowModels.CcItem>
ccPage = queryService.pageCc(SCH, 301, 1, 10);
assertEquals(1, ccPage.getContent().size());
assertEquals(instanceId, ccPage.getContent().get(0).getInstanceId());
assertEquals("张三的测试办件", ccPage.getContent().get(0).getBizTitle());
}
/** NOTIFY 在并行分支内:瞬时到达 JOIN 不占等待(审批支到齐即放行),落库与轨迹同主线口径 */
@Test
void notify_inParallelBranch_reachesJoin_withoutBlocking() {
mapDept("km", 101, "甲");
FlwDef def = saveAndPublishDef("t17", "抄送并行", notifyParallelDef("km"));
FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-17", 201, "张三");
Long instanceId = outcome.getInstanceId();
// 抄送支激活即写行并到达 JOIN:分支一 RUNNING(审批支)、一 ARRIVED(抄送支)
List<FlwBranch> branches = branchRows(instanceId);
assertEquals(2, branches.size());
assertEquals(1, branches.stream()
.filter(b -> FlwEnums.BranchStatus.RUNNING.getCode() == b.getStatus()).count());
assertEquals(1, ccRepository.findByInstanceIdOrderByIdAsc(instanceId).size());
// 办件仍在途等审批支:乙支瞬时完成没有让 JOIN 提前放行
assertEquals(FlwEnums.InstanceStatus.APPROVING.getCode(),
instanceRepository.findById(instanceId).get().getStatus());
// 审批支到齐 → JOIN 放行 → 完成
taskService.handle(SCH, tasksOf(instanceId, 101).get(0).getId(), true, "ok", 101);
assertEquals(FlwEnums.InstanceStatus.COMPLETED.getCode(),
instanceRepository.findById(instanceId).get().getStatus());
}
/** NOTIFY 接收人解析不出人(映射停用/名单空):不阻塞推进,只是没有抄送行 */
@Test
void notify_unresolvableReceivers_skipsCc_butFlowContinues() {
mapDept("km", 101, "甲");
// deptKey 未注册任何映射(mapDept 只注册 km,抄送挂 kx)
String json = notifyMainlineDef("km").replace(
"\"receivers\":{\"type\":\"USERS\",\"userIds\":[301,302]}}",
"\"receivers\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"kx\"}}");
FlwDef def = saveAndPublishDef("t18", "抄送空对象", json);
FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-18", 201, "张三");
Long instanceId = outcome.getInstanceId();
assertTrue(ccRepository.findByInstanceIdOrderByIdAsc(instanceId).isEmpty(),
"解析不出人不应落行");
assertEquals("n1", instanceRepository.findById(instanceId).get().getCurrentNodes());
assertEquals(FlwEnums.InstanceStatus.APPROVING.getCode(),
instanceRepository.findById(instanceId).get().getStatus());
taskService.handle(SCH, tasksOf(instanceId, 101).get(0).getId(), true, "ok", 101);
assertEquals(FlwEnums.InstanceStatus.COMPLETED.getCode(),
instanceRepository.findById(instanceId).get().getStatus());
}
/**
* 嵌套并行 scope 修复(2026-09-24 §4.6):外层分支 tb 内再套并行段 s2{tc,td}——
* 修复前 JOIN 计数是实例级(tb 行仍在进行),内层 join2 到不齐、外层 join1 等 tb,互锁死。
* 修复后:内层两支到齐 → join2 放行并以外层上下文 tb 续走 → join1 两轮到齐才完成。
*/
@Test
void nestedParallel_innerJoinReleasesFirst_outerCompletesAfterBoth() {
mapDept("km", 103, "主");
mapDept("ka", 101, "甲");
mapDept("kb", 102, "乙");
mapDept("kc", 104, "丙");
mapDept("kd", 105, "丁");
FlwDef def = saveAndPublishDef("t19", "嵌套并行", nestedParallelDef("km", "ka", "kb", "kc", "kd"));
FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-19", 201, "张三");
Long instanceId = outcome.getInstanceId();
taskService.handle(SCH, tasksOf(instanceId, 103).get(0).getId(), true, "ok", 103);
// 外层 split:顶层分支键无点号(存量口径)
assertEquals(new java.util.TreeSet<>(java.util.Arrays.asList("ta", "tb")),
branchRows(instanceId).stream().map(FlwBranch::getBranchKey)
.collect(java.util.stream.Collectors.toCollection(java.util.TreeSet::new)));
// 甲支先过:join1 等外层 tb(其内层段未走),办件仍在途
taskService.handle(SCH, tasksOf(instanceId, 101).get(0).getId(), true, "ok", 101);
assertEquals(FlwEnums.InstanceStatus.APPROVING.getCode(),
instanceRepository.findById(instanceId).get().getStatus());
// 乙支过 → 进入内层并行段:分支键=路径前缀 tb.tc / tb.td
taskService.handle(SCH, tasksOf(instanceId, 102).get(0).getId(), true, "ok", 102);
java.util.Map<String, Integer> byKey = branchRows(instanceId).stream()
.collect(java.util.stream.Collectors.toMap(FlwBranch::getBranchKey, FlwBranch::getStatus));
assertEquals(4, byKey.size());
assertEquals(FlwEnums.BranchStatus.ARRIVED.getCode(), (int) byKey.get("ta"));
assertEquals(FlwEnums.BranchStatus.RUNNING.getCode(), (int) byKey.get("tb"));
assertEquals(FlwEnums.BranchStatus.RUNNING.getCode(), (int) byKey.get("tb.tc"));
assertEquals(FlwEnums.BranchStatus.RUNNING.getCode(), (int) byKey.get("tb.td"));
// 内层丙支先过:join2 等丁支(修复前这里会因实例级计数连带等 tb 而死锁)
taskService.handle(SCH, tasksOf(instanceId, 104).get(0).getId(), true, "ok", 104);
assertEquals(FlwEnums.InstanceStatus.APPROVING.getCode(),
instanceRepository.findById(instanceId).get().getStatus());
// 丁支到齐 → join2 放行(tb 标记到达)→ join1 两轮到齐 → END 完成
taskService.handle(SCH, tasksOf(instanceId, 105).get(0).getId(), true, "ok", 105);
assertEquals(FlwEnums.InstanceStatus.COMPLETED.getCode(),
instanceRepository.findById(instanceId).get().getStatus());
}
/** 出口分支内并行段(CONDITION 分支内并行的前置形态):选"乙线"出口 → 并行段两支到齐 → 汇聚完成 */
@Test
void outletBranch_withParallelSegment_joinsAndCompletes() {
mapDept("km", 103, "主");
mapDept("ka", 101, "甲");
mapDept("kb", 102, "乙");
FlwDef def = saveAndPublishDef("t21", "出口并行", outletParallelDef("km", "ka", "kb"));
FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-21", 201, "张三");
Long instanceId = outcome.getInstanceId();
// 选"乙线"出口进入并行段(scope=主线,出口校验应放行)
taskService.handle(SCH, tasksOf(instanceId, 103).get(0).getId(), true, "走乙线", 103, "乙线");
assertEquals(FlwEnums.InstanceStatus.APPROVING.getCode(),
instanceRepository.findById(instanceId).get().getStatus());
assertEquals(2, taskRepository.findByInstanceIdAndStatus(instanceId, 0).size());
// 两支到齐 → join2 放行 → END 完成
taskService.handle(SCH, tasksOf(instanceId, 101).get(0).getId(), true, "ok", 101);
assertEquals(FlwEnums.InstanceStatus.APPROVING.getCode(),
instanceRepository.findById(instanceId).get().getStatus());
taskService.handle(SCH, tasksOf(instanceId, 102).get(0).getId(), true, "ok", 102);
assertEquals(FlwEnums.InstanceStatus.COMPLETED.getCode(),
instanceRepository.findById(instanceId).get().getStatus());
}
/**
* CONDITION 命中选路(2026-09-24 §4):判断返回"甲线"→ 只激活甲线待办,乙线不建任务;
* 办理后完成;轨迹出现 kind=ROUTE 行("系统判断 → 甲线"),留痕 outlet=甲线。
*/
@Test
void condition_routesByOutlet_andTraceShowsRouteRow() {
mapDept("km", 101, "甲");
mapDept("kb", 102, "乙");
registerRouter("test.judge");
FlwDef def = saveAndPublishDef("t22", "条件分支", conditionDef("test.judge", "km", "kb"));
StubJudge.reset("甲线");
FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-22", 201, "张三");
Long instanceId = outcome.getInstanceId();
// 甲线命中:仅甲科一条待办,CONDITION 已放行(不在 currentNodes)
List<FlwTask> pending = taskRepository.findByInstanceIdAndStatus(instanceId, 0);
assertEquals(1, pending.size());
assertEquals("n1", pending.get(0).getNodeId());
assertEquals("n1", instanceRepository.findById(instanceId).get().getCurrentNodes());
taskService.handle(SCH, pending.get(0).getId(), true, "ok", 101);
assertEquals(FlwEnums.InstanceStatus.COMPLETED.getCode(),
instanceRepository.findById(instanceId).get().getStatus());
// 留痕 + 轨迹:ROUTE 行在发起之后、办理之前
List<com.yunhai.flowcenter.entity.FlwRouteLog> logs =
routeLogRepository.findByInstanceIdOrderByIdAsc(instanceId);
assertEquals(1, logs.size());
assertEquals("甲线", logs.get(0).getOutlet());
assertEquals(1, logs.get(0).getAttempt());
java.util.List<java.util.Map<String, Object>> trace =
queryService.detail(SCH, instanceId, null).getTrace();
java.util.List<String> kinds = new java.util.ArrayList<>();
for (java.util.Map<String, Object> row : trace) {
kinds.add((String) row.get("kind"));
}
assertEquals(java.util.Arrays.asList("SUBMIT", "ROUTE", "APPROVE"), kinds);
assertEquals("甲线", trace.get(1).get("outlet"));
}
/**
* CONDITION 判断失败挂起 + 管理端重跑(§4.4 已定策略):失败=节点保持在途、办件仍 10、
* 落 outlet=NULL 留痕行(无默认出口,宁挂起);重跑成功后按新出口续走到完成。
*/
@Test
void condition_judgeFails_suspends_thenAdminReroute_resumes() {
mapDept("km", 101, "甲");
mapDept("kb", 102, "乙");
registerRouter("test.judge");
FlwDef def = saveAndPublishDef("t23", "条件挂起", conditionDef("test.judge", "km", "kb"));
StubJudge.failure = new RouteJudgeClient.JudgeUnavailableException("服务不可用", null);
FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-23", 201, "张三");
Long instanceId = outcome.getInstanceId();
// 挂起:办件在途、CONDITION 占位在 currentNodes、无待办任务
FlwInstance suspended = instanceRepository.findById(instanceId).get();
assertEquals(FlwEnums.InstanceStatus.APPROVING.getCode(), suspended.getStatus());
assertEquals("cond1", suspended.getCurrentNodes());
assertTrue(taskRepository.findByInstanceIdAndStatus(instanceId, 0).isEmpty());
List<com.yunhai.flowcenter.entity.FlwRouteLog> failLogs =
routeLogRepository.findByInstanceIdOrderByIdAsc(instanceId);
assertEquals(1, failLogs.size());
assertNull(failLogs.get(0).getOutlet(), "失败行 outlet=NULL");
assertEquals(1, failLogs.get(0).getAttempt());
// 重跑仍失败:留痕 attempt=2,办件继续挂起(业务错抛出)
assertThrows(com.yunhai.platform.apistd.BizException.class,
() -> routerAdminService.reroute(SCH, instanceId));
assertEquals(2, routeLogRepository.findByInstanceIdOrderByIdAsc(instanceId).size());
assertEquals("cond1", instanceRepository.findById(instanceId).get().getCurrentNodes());
// 判断恢复 → 重跑成功:按乙线续走建待办,办理完成;attempt=3 留痕 outlet=乙线
StubJudge.reset("乙线");
routerAdminService.reroute(SCH, instanceId);
FlwInstance resumed = instanceRepository.findById(instanceId).get();
assertEquals("n2", resumed.getCurrentNodes());
List<FlwTask> pending = taskRepository.findByInstanceIdAndStatus(instanceId, 0);
assertEquals(1, pending.size());
assertEquals(102, pending.get(0).getAssigneeId());
taskService.handle(SCH, pending.get(0).getId(), true, "ok", 102);
assertEquals(FlwEnums.InstanceStatus.COMPLETED.getCode(),
instanceRepository.findById(instanceId).get().getStatus());
List<com.yunhai.flowcenter.entity.FlwRouteLog> logs =
routeLogRepository.findByInstanceIdOrderByIdAsc(instanceId);
assertEquals(3, logs.size());
assertEquals("乙线", logs.get(2).getOutlet());
assertEquals(3, logs.get(2).getAttempt());
// 完成后的办件不再可重跑
assertThrows(com.yunhai.platform.apistd.BizException.class,
() -> routerAdminService.reroute(SCH, instanceId));
}
/**
* 退回重走 CONDITION 必须重新调判断(§4.4:单据可能已改,留痕只增不改多轮可见):
* 一轮判"甲线"到 n1,退回 n0 重走判"乙线"直达 END;route_log 两行 attempt 递增。
*/
@Test
void condition_returnBack_rewalk_rejudges() {
mapDept("km", 101, "甲");
mapDept("ka", 102, "乙");
registerRouter("test.judge");
FlwDef def = saveAndPublishDef("t24", "条件重判", mainlineConditionDef("test.judge", "km", "ka"));
StubJudge.reset("甲线");
FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-24", 201, "张三");
Long instanceId = outcome.getInstanceId();
// 一轮:n0 过 → 判甲线 → n1 待办
taskService.handle(SCH, tasksOf(instanceId, 101).get(0).getId(), true, "ok", 101);
assertEquals("n1", instanceRepository.findById(instanceId).get().getCurrentNodes());
// 退回入口 n0 重走(改判乙线)
StubJudge.reset("乙线");
FlwTask n1Task = taskRepository.findByInstanceIdAndStatus(instanceId, 0).get(0);
taskService.returnBack(SCH, n1Task.getId(), "n0", "改判乙线", 102);
taskService.handle(SCH, tasksOf(instanceId, 101).get(0).getId(), true, "再走一遍", 101);
// 乙线直达 END:办件完成(n1 未再激活)
assertEquals(FlwEnums.InstanceStatus.COMPLETED.getCode(),
instanceRepository.findById(instanceId).get().getStatus());
// 留痕两轮:attempt 1=甲线、2=乙线(只增不改)
List<com.yunhai.flowcenter.entity.FlwRouteLog> logs =
routeLogRepository.findByInstanceIdOrderByIdAsc(instanceId);
assertEquals(2, logs.size());
assertEquals("甲线", logs.get(0).getOutlet());
assertEquals(1, logs.get(0).getAttempt());
assertEquals("乙线", logs.get(1).getOutlet());
assertEquals(2, logs.get(1).getAttempt());
}
private List<FlwBranch> branchRows(Long instanceId) {
return branchRepository.findByInstanceId(instanceId);
}
@@ -0,0 +1,112 @@
package com.yunhai.flowcenter.engine;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.flowcenter.entity.FlwRouter;
import com.yunhai.platform.openapi.core.TargetSpec;
import com.yunhai.platform.openapi.core.YschOpenCall;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 路由判断客户端单测(2026-09-24 §4.3):重试纪律(3s×3 退避重试,Mock 出网)、
* flw_router.base_path 对目录前缀的覆盖、信封 data.outlet 解析。
* 引擎行为(选路/挂起/重跑)由 FlowEngineIT 覆盖。
*/
class RouteJudgeClientTest {
private YschOpenCall openCall;
private RouteJudgeClient client;
@BeforeEach
void setUp() {
openCall = mock(YschOpenCall.class);
when(openCall.resolveTarget(anyString()))
.thenReturn(TargetSpec.of("http://dir-host:8101", "/dirctx"));
client = new RouteJudgeClient(openCall, new ObjectMapper());
}
private FlwRouter router(String basePath) {
FlwRouter r = new FlwRouter();
r.setRouterKey("rk");
r.setName("n");
r.setServiceKey("notice");
r.setPath("/oapi/notice/flow/route/dept-judge");
r.setBasePath(basePath);
r.setStatus(1);
return r;
}
private RouteJudgeClient.JudgeBody body() {
return new RouteJudgeClient.JudgeBody(1L, 2L, 3, "notice", "456",
"node_r1", "判断", "rk", Collections.singletonList("甲线"), new HashMap<>());
}
@Test
void success_resolvesOutlet_andPassesRouterBasePathOverride() {
Map<String, Object> data = new HashMap<>();
data.put("outlet", "甲线");
when(openCall.post(any(TargetSpec.class), anyString(), anyMap(), any(), eq(Map.class)))
.thenReturn(data);
RouteJudgeClient.JudgeResult result = client.call(router("/notice"), body());
assertEquals("甲线", result.outlet);
ArgumentCaptor<TargetSpec> spec = ArgumentCaptor.forClass(TargetSpec.class);
verify(openCall).post(spec.capture(), anyString(), anyMap(), any(), eq(Map.class));
assertEquals("http://dir-host:8101", spec.getValue().getBaseHost(), "host 恒取目录解析");
assertEquals("/notice", spec.getValue().getBasePath(), "flw_router.base_path 非空时覆盖目录前缀");
assertEquals((Long) 3000L, spec.getValue().getReadTimeoutMs(), "判断专用 3s 超时");
}
@Test
void emptyBasePath_fallsBackToDirectoryPrefix() {
Map<String, Object> data = new HashMap<>();
data.put("outlet", "甲线");
when(openCall.post(any(TargetSpec.class), anyString(), anyMap(), any(), eq(Map.class)))
.thenReturn(data);
client.call(router(""), body());
ArgumentCaptor<TargetSpec> spec = ArgumentCaptor.forClass(TargetSpec.class);
verify(openCall).post(spec.capture(), anyString(), anyMap(), any(), eq(Map.class));
assertEquals("/dirctx", spec.getValue().getBasePath(), "base_path 空=沿用目录条目 context-path");
}
@Test
void transportFailure_retriesThreeTimes_thenThrows() {
when(openCall.post(any(TargetSpec.class), anyString(), anyMap(), any(), eq(Map.class)))
.thenThrow(new RuntimeException("connect timeout"));
assertThrows(RouteJudgeClient.JudgeUnavailableException.class,
() -> client.call(router(""), body()));
verify(openCall, times(RouteJudgeClient.MAX_ATTEMPTS))
.post(any(TargetSpec.class), anyString(), anyMap(), any(), eq(Map.class));
}
@Test
void missingOutletInResponse_failsAndRetries() {
// 信封 data 无 outlet=契约违规,按失败重试口径处理(非 2xx 同类)
when(openCall.post(any(TargetSpec.class), anyString(), anyMap(), any(), eq(Map.class)))
.thenReturn(new HashMap<String, Object>());
assertThrows(RouteJudgeClient.JudgeUnavailableException.class,
() -> client.call(router(""), body()));
verify(openCall, times(RouteJudgeClient.MAX_ATTEMPTS))
.post(any(TargetSpec.class), anyString(), anyMap(), any(), eq(Map.class));
}
}
@@ -87,6 +87,7 @@ class FlowDiagramServiceTest {
private FlwDefRepository defRepository;
private FlwAssigneeMapRepository mapRepository;
private FlwTaskRepository taskRepository;
private com.yunhai.flowcenter.repository.FlwCcRepository ccRepository;
private OpenApiUserClient userClient;
private FlowDiagramService service;
@@ -95,16 +96,22 @@ class FlowDiagramServiceTest {
defRepository = mock(FlwDefRepository.class);
mapRepository = mock(FlwAssigneeMapRepository.class);
taskRepository = mock(FlwTaskRepository.class);
ccRepository = mock(com.yunhai.flowcenter.repository.FlwCcRepository.class);
userClient = mock(OpenApiUserClient.class);
when(taskRepository.findByInstanceIdOrderByIdAsc(anyLong()))
.thenReturn(Collections.emptyList());
// 真 FlowEngine(真实 JSON 解析通道)+ mock 依赖
when(ccRepository.findByInstanceIdOrderByIdAsc(anyLong()))
.thenReturn(Collections.emptyList());
// 真 FlowEngine(真实 JSON 解析通道)+ mock 依赖(ConditionRouteService 不触达:纯示意图测试)
FlowEngine engine = new FlowEngine(new ObjectMapper(),
taskRepository, mock(FlwBranchRepository.class), mock(AssigneeResolver.class),
taskRepository, mock(FlwBranchRepository.class), ccRepository,
mock(AssigneeResolver.class), mock(com.yunhai.flowcenter.engine.ConditionRouteService.class),
mock(ApplicationEventPublisher.class));
service = new FlowDiagramService(defRepository, mapRepository,
mock(com.yunhai.flowcenter.repository.FlwInstanceRepository.class),
taskRepository, engine, userClient);
taskRepository, ccRepository,
mock(com.yunhai.flowcenter.repository.FlwRouteLogRepository.class),
engine, userClient);
}
private FlwDef def(String json) {
@@ -473,6 +480,7 @@ class FlowDiagramServiceTest {
// 真管理守卫(纯逻辑无依赖)+ 其余依赖 mock
controller = new FlowAdminController(new FlowAdminGuard(), defAdminService,
mock(FlowMapAdminService.class), mock(FlowCallbackAdminService.class),
mock(com.yunhai.flowcenter.service.FlowRouterAdminService.class),
mock(FlwInstanceRepository.class), diagramService,
mock(com.yunhai.flowcenter.service.DiagramImageService.class));
}
@@ -4,11 +4,16 @@ import com.yunhai.flowcenter.entity.FlwAssigneeMap;
import com.yunhai.flowcenter.entity.FlwCallbackLog;
import com.yunhai.flowcenter.entity.FlwCallbackReg;
import com.yunhai.flowcenter.entity.FlwDef;
import com.yunhai.flowcenter.entity.FlwRouteLog;
import com.yunhai.flowcenter.entity.FlwRouter;
import com.yunhai.flowcenter.repository.FlwAssigneeMapRepository;
import com.yunhai.flowcenter.repository.FlwCallbackLogRepository;
import com.yunhai.flowcenter.repository.FlwCallbackRegRepository;
import com.yunhai.flowcenter.repository.FlwCcRepository;
import com.yunhai.flowcenter.repository.FlwDefRepository;
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
import com.yunhai.flowcenter.repository.FlwRouteLogRepository;
import com.yunhai.flowcenter.repository.FlwRouterRepository;
import com.yunhai.flowcenter.repository.FlwTaskRepository;
import org.springframework.beans.factory.annotation.Autowired;
@@ -34,10 +39,19 @@ public abstract class FlowTestSupport {
protected FlwCallbackRegRepository regRepository;
@Autowired
protected FlwCallbackLogRepository logRepository;
@Autowired
protected FlwCcRepository ccRepository;
@Autowired
protected FlwRouterRepository routerRepository;
@Autowired
protected FlwRouteLogRepository routeLogRepository;
protected void cleanDb() {
logRepository.deleteAll();
regRepository.deleteAll();
ccRepository.deleteAll();
routeLogRepository.deleteAll();
routerRepository.deleteAll();
taskRepository.deleteAll();
instanceRepository.deleteAll();
defRepository.deleteAll();
@@ -100,6 +114,95 @@ public abstract class FlowTestSupport {
+ "\"settings\":{\"rejectPolicy\":\"END_REJECTED\"}}";
}
/** 抄送主线流程:START → cc(USERS [301,302]) → n1(deptA) → END */
protected String notifyMainlineDef(String deptA) {
return "{"
+ "\"code\":\"" + codeOf() + "\",\"name\":\"抄送主线测试流程\",\"start\":\"start\","
+ "\"nodes\":["
+ "{\"id\":\"start\",\"type\":\"START\",\"name\":\"开始\"},"
+ "{\"id\":\"cc1\",\"type\":\"NOTIFY\",\"name\":\"抄送校领导\",\"receivers\":{\"type\":\"USERS\",\"userIds\":[301,302]}},"
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"name\":\"审核\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"" + deptA + "\"}},"
+ "{\"id\":\"end\",\"type\":\"END\"}"
+ "],"
+ "\"edges\":[{\"from\":\"start\",\"to\":\"cc1\"},{\"from\":\"cc1\",\"to\":\"n1\"},"
+ "{\"from\":\"n1\",\"to\":\"end\"}],"
+ "\"settings\":{\"rejectPolicy\":\"END_REJECTED\"}}";
}
/** 抄送并行流程:START → split → {n1(deptA), cc(USERS [301])} → join → END(抄送支瞬时到达汇聚) */
protected String notifyParallelDef(String deptA) {
return "{"
+ "\"code\":\"" + codeOf() + "\",\"name\":\"抄送并行测试流程\",\"start\":\"start\","
+ "\"nodes\":["
+ "{\"id\":\"start\",\"type\":\"START\",\"name\":\"开始\"},"
+ "{\"id\":\"split1\",\"type\":\"PARALLEL_SPLIT\"},"
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"name\":\"审核\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"" + deptA + "\"}},"
+ "{\"id\":\"cc1\",\"type\":\"NOTIFY\",\"name\":\"抄送\",\"receivers\":{\"type\":\"USERS\",\"userIds\":[301]}},"
+ "{\"id\":\"join1\",\"type\":\"PARALLEL_JOIN\",\"waitFor\":\"ALL\"},"
+ "{\"id\":\"end\",\"type\":\"END\"}"
+ "],"
+ "\"edges\":[{\"from\":\"start\",\"to\":\"split1\"},"
+ "{\"from\":\"split1\",\"to\":\"n1\"},{\"from\":\"split1\",\"to\":\"cc1\"},"
+ "{\"from\":\"n1\",\"to\":\"join1\"},{\"from\":\"cc1\",\"to\":\"join1\"},"
+ "{\"from\":\"join1\",\"to\":\"end\"}],"
+ "\"settings\":{\"rejectPolicy\":\"END_REJECTED\"}}";
}
/**
* 条件分支流程(2026-09-24 CONDITION):START → cond1(routerKey) --"甲线"--> n1(deptA) → END
* └-"乙线"--> n2(deptB) → END
*/
protected String conditionDef(String routerKey, String deptA, String deptB) {
return "{"
+ "\"code\":\"" + codeOf() + "\",\"name\":\"条件分支测试流程\",\"start\":\"start\","
+ "\"nodes\":["
+ "{\"id\":\"start\",\"type\":\"START\",\"name\":\"开始\"},"
+ "{\"id\":\"cond1\",\"type\":\"CONDITION\",\"name\":\"条件判断\",\"routerKey\":\"" + routerKey + "\"},"
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"name\":\"甲线审核\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"" + deptA + "\"}},"
+ "{\"id\":\"n2\",\"type\":\"APPROVAL\",\"name\":\"乙线审核\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"" + deptB + "\"}},"
+ "{\"id\":\"end\",\"type\":\"END\"}"
+ "],"
+ "\"edges\":[{\"from\":\"start\",\"to\":\"cond1\"},"
+ "{\"from\":\"cond1\",\"to\":\"n1\",\"name\":\"甲线\"},"
+ "{\"from\":\"cond1\",\"to\":\"n2\",\"name\":\"乙线\"},"
+ "{\"from\":\"n1\",\"to\":\"end\"},{\"from\":\"n2\",\"to\":\"end\"}],"
+ "\"settings\":{\"rejectPolicy\":\"END_REJECTED\"}}";
}
/**
* 主线条件分支(退回重走重判用):START → n0(deptMain) → cond1(routerKey)
* --"甲线"--> n1(deptA) → END;--"乙线"--> END(乙线直达终点)
*/
protected String mainlineConditionDef(String routerKey, String deptMain, String deptA) {
return "{"
+ "\"code\":\"" + codeOf() + "\",\"name\":\"主线条件测试流程\",\"start\":\"start\","
+ "\"nodes\":["
+ "{\"id\":\"start\",\"type\":\"START\",\"name\":\"开始\"},"
+ "{\"id\":\"n0\",\"type\":\"APPROVAL\",\"name\":\"入口\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"" + deptMain + "\"}},"
+ "{\"id\":\"cond1\",\"type\":\"CONDITION\",\"name\":\"条件判断\",\"routerKey\":\"" + routerKey + "\"},"
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"name\":\"甲线审核\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"" + deptA + "\"}},"
+ "{\"id\":\"end\",\"type\":\"END\"}"
+ "],"
+ "\"edges\":[{\"from\":\"start\",\"to\":\"n0\"},"
+ "{\"from\":\"n0\",\"to\":\"cond1\"},"
+ "{\"from\":\"cond1\",\"to\":\"n1\",\"name\":\"甲线\"},"
+ "{\"from\":\"cond1\",\"to\":\"end\",\"name\":\"乙线\"},"
+ "{\"from\":\"n1\",\"to\":\"end\"}],"
+ "\"settings\":{\"rejectPolicy\":\"END_REJECTED\"}}";
}
/** 注册判断服务(测试库直接落行;serviceKey/path 随意——RouteJudgeClient 已被测试桩替换) */
protected FlwRouter registerRouter(String routerKey) {
FlwRouter router = new FlwRouter();
router.setRouterKey(routerKey);
router.setName("测试判断");
router.setServiceKey("notice");
router.setPath("/oapi/notice/flow/route/test-judge");
router.setBasePath("");
router.setStatus(1);
return routerRepository.save(router);
}
private static String codeOf() {
return "tc_" + UUID.randomUUID().toString().replace("-", "").substring(0, 8);
}
@@ -134,6 +237,62 @@ public abstract class FlowTestSupport {
+ "\"settings\":{\"rejectPolicy\":\"END_REJECTED\"}}";
}
/**
* 嵌套并行(2026-09-24 §4.6 scope 修复的主用例):
* START → n1(deptMain) → s1 → {ta(deptA), tb(deptB)};tb → s2 → {tc(deptC), td(deptD)} → join2 → join1;ta → join1 → END。
* 修复前 join2 的放行计数是实例级(外层 tb 行仍在进行)→ 内层到齐也永不到齐,死锁。
*/
protected String nestedParallelDef(String deptMain, String deptA, String deptB,
String deptC, String deptD) {
return "{"
+ "\"code\":\"" + codeOf() + "\",\"name\":\"嵌套并行测试流程\",\"start\":\"start\","
+ "\"nodes\":["
+ "{\"id\":\"start\",\"type\":\"START\",\"name\":\"开始\"},"
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"name\":\"入口\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"" + deptMain + "\"}},"
+ "{\"id\":\"s1\",\"type\":\"PARALLEL_SPLIT\"},"
+ "{\"id\":\"ta\",\"type\":\"APPROVAL\",\"name\":\"甲科审核\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"" + deptA + "\"}},"
+ "{\"id\":\"tb\",\"type\":\"APPROVAL\",\"name\":\"乙科审核\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"" + deptB + "\"}},"
+ "{\"id\":\"s2\",\"type\":\"PARALLEL_SPLIT\"},"
+ "{\"id\":\"tc\",\"type\":\"APPROVAL\",\"name\":\"丙科审核\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"" + deptC + "\"}},"
+ "{\"id\":\"td\",\"type\":\"APPROVAL\",\"name\":\"丁科审核\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"" + deptD + "\"}},"
+ "{\"id\":\"join2\",\"type\":\"PARALLEL_JOIN\",\"waitFor\":\"ALL\"},"
+ "{\"id\":\"join1\",\"type\":\"PARALLEL_JOIN\",\"waitFor\":\"ALL\"},"
+ "{\"id\":\"end\",\"type\":\"END\"}"
+ "],"
+ "\"edges\":[{\"from\":\"start\",\"to\":\"n1\"},{\"from\":\"n1\",\"to\":\"s1\"},"
+ "{\"from\":\"s1\",\"to\":\"ta\"},{\"from\":\"s1\",\"to\":\"tb\"},"
+ "{\"from\":\"ta\",\"to\":\"join1\"},{\"from\":\"tb\",\"to\":\"s2\"},"
+ "{\"from\":\"s2\",\"to\":\"tc\"},{\"from\":\"s2\",\"to\":\"td\"},"
+ "{\"from\":\"tc\",\"to\":\"join2\"},{\"from\":\"td\",\"to\":\"join2\"},"
+ "{\"from\":\"join2\",\"to\":\"join1\"},{\"from\":\"join1\",\"to\":\"end\"}],"
+ "\"settings\":{\"rejectPolicy\":\"END_REJECTED\"}}";
}
/**
* 出口分支内并行段(2026-09-24:CONDITION 分支内并行的前置形态——多出口审批的某出口里套并行段):
* START → n1(deptMain,出口"甲线"→END / "乙线"→s2) → s2 → {p1(deptA), p2(deptB)} → join2 → END
*/
protected String outletParallelDef(String deptMain, String deptA, String deptB) {
return "{"
+ "\"code\":\"" + codeOf() + "\",\"name\":\"出口并行测试流程\",\"start\":\"start\","
+ "\"nodes\":["
+ "{\"id\":\"start\",\"type\":\"START\",\"name\":\"开始\"},"
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"name\":\"分流审核\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"" + deptMain + "\"}},"
+ "{\"id\":\"s2\",\"type\":\"PARALLEL_SPLIT\"},"
+ "{\"id\":\"p1\",\"type\":\"APPROVAL\",\"name\":\"并行甲\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"" + deptA + "\"}},"
+ "{\"id\":\"p2\",\"type\":\"APPROVAL\",\"name\":\"并行乙\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"" + deptB + "\"}},"
+ "{\"id\":\"join2\",\"type\":\"PARALLEL_JOIN\",\"waitFor\":\"ALL\"},"
+ "{\"id\":\"end\",\"type\":\"END\"}"
+ "],"
+ "\"edges\":[{\"from\":\"start\",\"to\":\"n1\"},"
+ "{\"from\":\"n1\",\"to\":\"end\",\"name\":\"甲线\"},"
+ "{\"from\":\"n1\",\"to\":\"s2\",\"name\":\"乙线\"},"
+ "{\"from\":\"s2\",\"to\":\"p1\"},{\"from\":\"s2\",\"to\":\"p2\"},"
+ "{\"from\":\"p1\",\"to\":\"join2\"},{\"from\":\"p2\",\"to\":\"join2\"},"
+ "{\"from\":\"join2\",\"to\":\"end\"}],"
+ "\"settings\":{\"rejectPolicy\":\"END_REJECTED\"}}";
}
/** 主线带并行段:START → n1(deptA) → split → {ta(ka), tb(kb)} → join → n3(deptA) → END(退回重入用) */
protected String mainlineParallelTailDef(String deptA, String deptMain) {
return "{"