feat(engine): NOTIFY 抄送 + CONDITION 条件路由 + JOIN scope 修复(两批全栈)
build / build-push (push) Successful in 47s
build / build-push (push) Successful in 47s
- NOTIFY:TYPE_NOTIFY 瞬时抄送(flw_cc 逐人留痕 + CC_SENT 事件 + 站内通知不加待办角标)、/api/flow/cc/my、轨迹 kind=CC 与 oapi progress 抄送步、示意图青框卡片
- CONDITION:flw_router 判断服务注册 + flw_route_log 决策留痕(REQUIRES_NEW,reroute 回滚不丢失败行)+ 同步判断调用(服务目录寻址 + 平台签名,3s×3 退避重试)+ 失败挂起/管理端重跑(不配默认出口)+ 可选事件 flow.route.decided
- JOIN scope:branch_key 路径前缀编码 + scope 级到齐放行 + DefValidator SPLIT/JOIN 配对与键长守卫(存量无点号键零迁移,历史直连图回退实例级口径)
- 管理端:/api/flow/admin/router/* CRUD + POST /api/flow/admin/instance/{id}/reroute
- 生产 SQL:sql/20260923_create_flw_cc.sql(含 flw_task 时间列升 DATETIME(3))、20260924_create_flw_router_and_route_log.sql
- 测试:全量 125 绿(FlowEngineIT 24,含条件/嵌套并行/退回重走用例)
This commit is contained in:
@@ -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 "{"
|
||||
|
||||
Reference in New Issue
Block a user