feat: 引擎多出口路由/退回/移交/或签补齐 + 定义 layout 坐标
build / build-push (push) Successful in 5m11s

- 多出口:FlwTask 增 edge_name 列(sql/20260916),FlwTaskRepository.claimAndFinish
  同步落列;FlowTaskService.handle 出度>1 时 edgeName 必填;FlowEngine.continueAdvance
  按 edge name 选路(单出口原路径逐字节兼容,未命中不推进);FlowAdvanceService/
  FlowNodeAdvanceListener 透传;BranchScope 从 FlowEngine 提取
- DefValidator:多出口出口命名必填且同节点唯一;跨分支连线发布期明确拒绝
- 退回 POST /task/{id}/returnBack:默认退主管线最近已批节点,精确失效当前节点待办,
  split 幂等 upsert 使退回重走并行段不撞 flw_branch uk
- 移交 POST /task/{id}/transfer:TRANSFERRED(5)+新建待办,AssigneeResolver.requireUserName
- 或签 ANY 补齐:首个同意失效同节点兄弟待办
- 已办口径/撤回守卫:status in (1,2,4,5);新事件 flow.returned/flow.transferred
- FlowDiagramService:EdgeVo.name/NodeVo.state/DiagramVo.layout,办件图状态计算
- 新 IT:多出口×3、ANY 或签、退回重走并行段、退回/移交/撤回

mvn test 84/84 绿
This commit is contained in:
chenxing
2026-09-16 22:23:02 +08:00
parent 55e3a3f800
commit b8441065d0
31 changed files with 981 additions and 126 deletions
@@ -1,6 +1,6 @@
package com.yunhai.flowcenter.controller.api;
import com.yunhai.platform.openapi.client.YschBaseOpenApi;
import com.yunhai.platform.openapi.base.YschBaseOpenApi;
import com.yunhai.platform.utils.model.BoaModule;
import com.yunhai.platform.utils.model.BoaUser;
import org.springframework.web.bind.annotation.GetMapping;
@@ -81,12 +81,13 @@ public class FlowUserController {
return queryService.detail(schId, instance.getId(), user.getId());
}
/** 同意(opinion 可选)——一切审批入口的收敛点 */
/** 同意(opinion 可选;多出口节点必须带 edgeName=所选出口名,单出口可省)——一切审批入口的收敛点 */
@PostMapping("/task/{id}/approve")
public void approve(BoaUser user, @PathVariable("id") Long id,
@RequestBody(required = false) Map<String, String> body) {
String opinion = body == null ? null : body.get("opinion");
taskService.handle(requireSchId(user), id, true, opinion, user.getId());
String edgeName = body == null ? null : body.get("edgeName");
taskService.handle(requireSchId(user), id, true, opinion, user.getId(), edgeName);
}
/** 拒绝(opinion 必填) */
@@ -97,6 +98,26 @@ public class FlowUserController {
taskService.handle(requireSchId(user), id, false, opinion, user.getId());
}
/** 退回(targetNodeId 可选=缺省回最近已办节点;opinion 可选) */
@PostMapping("/task/{id}/returnBack")
public void returnBack(BoaUser user, @PathVariable("id") Long id,
@RequestBody(required = false) Map<String, String> body) {
String targetNodeId = body == null ? null : body.get("targetNodeId");
String opinion = body == null ? null : body.get("opinion");
taskService.returnBack(requireSchId(user), id, targetNodeId, opinion, user.getId());
}
/** 移交(toUserId 必填;reason 可选) */
@PostMapping("/task/{id}/transfer")
public void transfer(BoaUser user, @PathVariable("id") Long id,
@RequestBody Map<String, Object> body) {
Integer toUserId = body.get("toUserId") == null ? null
: Integer.valueOf(String.valueOf(body.get("toUserId")));
Object reason = body.get("reason");
taskService.transfer(requireSchId(user), id, toUserId,
reason == null ? null : String.valueOf(reason), user.getId());
}
/** 撤回(提交人;仅尚无任务被办理时) */
@PostMapping("/instance/{id}/withdraw")
public void withdraw(BoaUser user, @PathVariable("id") Long id) {
@@ -3,7 +3,7 @@ package com.yunhai.flowcenter.engine;
import com.yunhai.flowcenter.entity.FlwAssigneeMap;
import com.yunhai.flowcenter.repository.FlwAssigneeMapRepository;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.openapi.client.OpenApiUserClient;
import com.yunhai.platform.openapi.base.OpenApiUserClient;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.RequiredArgsConstructor;
@@ -61,4 +61,26 @@ public class AssigneeResolver {
return "";
}
}
/**
* 移交目标校验(严格口径,与 {@link #fetchName} 的降级容错相反):
* 查不到就必须拒绝——否则会建出一条谁在待办里都看不到的僵尸任务。
* UserBase 不可达与"人不存在"分别报错,便于排查。
*/
public String requireUserName(Integer userId) {
if (userId == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "缺少移交对象");
}
BoaUser user;
try {
user = userClient.getUserById(String.valueOf(userId));
} catch (Exception e) {
log.warn("[flow-center] 移交对象校验失败(UserBase 不可达)userId={}:{}", userId, e.getMessage());
throw new BizException(ErrorCode.PARAM_ERROR, "移交对象校验失败,请稍后重试:" + userId);
}
if (user == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "移交对象不存在:" + userId);
}
return user.getName() == null ? "" : user.getName();
}
}
@@ -0,0 +1,57 @@
package com.yunhai.flowcenter.engine;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.HashSet;
import java.util.Set;
/**
* 分支归属判定:节点属于哪个并行分支(branchKey=PARALLEL_SPLIT 出边目标节点 id),
* 主线节点与汇聚点之后属 null。运行时用于推导分支上下文({@link FlowEngine}),
* 发布期用于拒绝跨分支连线({@link DefValidator})——两处必须同口径,故提取为静态工具。
*/
public final class BranchScope {
private BranchScope() {
}
/**
* 沿入边反向 BFS 走到最近的上游 PARALLEL_SPLIT,返回该 split 指向本链的 outEdge.to(即分支键);
* 遇 START/PARALLEL_JOIN 边界即停(join 之后属汇聚链,分支键已消耗,返回 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;
}
/** 两节点是否同属一个分支上下文(含"都属主线"=都属于 null) */
public static boolean sameScope(FlowDefModel model, String a, String b) {
String ka = keyOf(model, a);
String kb = keyOf(model, b);
return ka == null ? kb == null : ka.equals(kb);
}
}
@@ -87,12 +87,19 @@ public final class DefValidator {
for (FlowDefModel.Node n : model.nodes) {
int out = model.outEdges(n.id).size();
int in = model.inEdges(n.id).size();
if (FlowDefModel.TYPE_START.equals(n.type) || FlowDefModel.TYPE_APPROVAL.equals(n.type)
if (FlowDefModel.TYPE_START.equals(n.type)
|| FlowDefModel.TYPE_PARALLEL_JOIN.equals(n.type)) {
if (out != 1) {
problems.add(nodeLabel(n) + " 出边必须恰为 1 条(当前 " + out + ")");
}
}
if (FlowDefModel.TYPE_APPROVAL.equals(n.type)) {
if (out < 1) {
problems.add(nodeLabel(n) + " 出边至少 1 条");
} else if (out > 1) {
validateOutlets(model, n, problems);
}
}
if (FlowDefModel.TYPE_PARALLEL_SPLIT.equals(n.type) && out < 1) {
problems.add(nodeLabel(n) + " 出边至少 1 条");
}
@@ -134,6 +141,29 @@ public final class DefValidator {
}
}
/**
* 多出口节点校验(办理人按出口名选路):每个出口必须命名且同节点内唯一(名即办理按钮文案);
* 出口目标必须与本节点同属一个分支上下文——跨分支连线依赖运行时的跨分支移动,一期未实现,
* 在发布期明确拒绝而非运行时静默走错边。
*/
private static void validateOutlets(FlowDefModel model, FlowDefModel.Node n, List<String> problems) {
Set<String> names = new HashSet<>();
for (FlowDefModel.Edge e : model.outEdges(n.id)) {
if (e.name == null || e.name.trim().isEmpty()) {
problems.add(nodeLabel(n) + " 有 " + model.outEdges(n.id).size()
+ " 条出边,每个出口必须命名(名称即办理按钮文案)");
continue;
}
if (!names.add(e.name.trim())) {
problems.add(nodeLabel(n) + " 出口名称重复:" + e.name + "(同一节点的出口名必须唯一)");
}
if (!BranchScope.sameScope(model, n.id, e.to)) {
problems.add(nodeLabel(n) + " 的出口「" + e.name + "」指向另一并行分支内的节点("
+ e.to + "):暂不支持跨分支连线,可改用并行分支编排");
}
}
}
private static void validateApproval(FlowDefModel.Node n, Set<String> enabledDeptKeys, List<String> problems) {
FlowDefModel.Assignee a = n.assignee;
if (a == null || a.type == null) {
@@ -2,6 +2,7 @@ package com.yunhai.flowcenter.engine;
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.FlwDefRepository;
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
@@ -27,9 +28,15 @@ public class FlowAdvanceService {
private final FlwTaskRepository taskRepository;
private final FlowEngine flowEngine;
/** @return 是否实际推进了 */
/**
* @param nodeId 待放行节点
* @param edgeName 多出口节点所选出口(办理事件携带);可空——兜底从该节点
* 最近一条已同意任务的 edge_name 列重放(推进在 AFTER_COMMIT 新事务重查,
* 事件传参只是快路径,落库的值才是权威)
* @return 是否实际推进了
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public boolean tryAdvance(Long instanceId, String nodeId) {
public boolean tryAdvance(Long instanceId, String nodeId, String edgeName) {
FlwInstance instance = instanceRepository.findById(instanceId).orElse(null);
if (instance == null
|| instance.getStatus() != FlwEnums.InstanceStatus.APPROVING.getCode()) {
@@ -55,7 +62,17 @@ public class FlowAdvanceService {
instanceId, instance.getDefId());
return false;
}
flowEngine.continueAdvance(instance, def, nodeId);
String chosen = edgeName;
if (chosen == null) {
chosen = taskRepository.findByInstanceIdAndStatus(instanceId,
FlwEnums.TaskStatus.APPROVED.getCode()).stream()
.filter(t -> nodeId.equals(t.getNodeId()) && t.getEdgeName() != null)
.max(java.util.Comparator.comparing(
FlwTask::getHandleTime, java.util.Comparator.nullsFirst(java.util.Comparator.naturalOrder())))
.map(FlwTask::getEdgeName)
.orElse(null);
}
flowEngine.continueAdvance(instance, def, nodeId, chosen);
instanceRepository.save(instance);
return true;
}
@@ -6,6 +6,7 @@ import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import java.util.List;
import java.util.Map;
/**
* 流程定义 JSON 模型(概览 §5.1 规范,一期 5 节点)。
@@ -38,6 +39,11 @@ public class FlowDefModel {
public List<Node> nodes;
public List<Edge> edges;
public Settings settings;
/**
* 画布坐标(nodeId → 左上角),仅前端设计器/查看器复用;引擎不消费。
* 缺失时前端自行做自动布局,故可选、可空。
*/
public Map<String, Layout> layout;
@JsonIgnoreProperties(ignoreUnknown = true)
public static class Node {
@@ -65,7 +71,20 @@ public class FlowDefModel {
public static class Edge {
public String from;
public String to;
/**
* 出边名(按钮文案)。单出口可空;多出口节点必填且同节点内唯一,
* 办理人在办理页选它决定走哪条边(见 {@link #edgeByName})。
*/
public String name;
/** 出口条件(二期条件自动路由预留;一期只校验透传,引擎不消费) */
public String condition;
}
/** 画布坐标(前端设计器专用) */
@JsonIgnoreProperties(ignoreUnknown = true)
public static class Layout {
public Integer x;
public Integer y;
}
@JsonIgnoreProperties(ignoreUnknown = true)
@@ -135,4 +154,17 @@ public class FlowDefModel {
}
return in;
}
/** 按出边名取边(多出口节点路由用);未命中返回 null */
public Edge edgeByName(String fromNodeId, String name) {
if (name == null) {
return null;
}
for (Edge e : outEdges(fromNodeId)) {
if (name.equals(e.name)) {
return e;
}
}
return null;
}
}
@@ -14,13 +14,9 @@ import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 流程引擎推进核心(概览 §5.2 激活规则):由调用方事务驱动——
@@ -50,19 +46,30 @@ public class FlowEngine {
}
/**
* 某节点全部同意后的放行推进:移出活跃节点集并激活唯一出边目标。
* 分支上下文由 {@link #branchKeyOf} 从图上回溯推导(分支内串行链的中间节点无任务级分支标记)。
* 某节点全部同意后的放行推进:移出活跃节点集并激活出边目标。
* 单出边走唯一出边(旧定义逐字节兼容);多出边按办理人选的出口名(edgeName,即办理按钮文案)
* 选路——未命中时记录错误并保持节点在途,绝不静默走第一条边。
* 分支上下文由 {@link BranchScope#keyOf} 从图上回溯推导(分支内串行链的中间节点无任务级分支标记)。
*/
public void continueAdvance(FlwInstance instance, FlwDef def, String fromNodeId) {
public void continueAdvance(FlwInstance instance, FlwDef def, String fromNodeId, String edgeName) {
FlowDefModel model = parseModel(def);
FlowDefModel.Node node = model.nodeById(fromNodeId);
List<FlowDefModel.Edge> outs = model.outEdges(fromNodeId);
if (outs.isEmpty()) {
return;
}
FlowDefModel.Edge chosen = outs.get(0);
if (outs.size() > 1) {
chosen = model.edgeByName(fromNodeId, edgeName);
if (chosen == null) {
log.error("[flow-center] 多出口节点未命中出口,推进中止(节点保持在途):instance={} node={} edgeName={}",
instance.getId(), fromNodeId, edgeName);
return;
}
}
removeCurrentNode(instance, fromNodeId);
String branchKey = branchKeyOf(model, fromNodeId);
String next = outs.get(0).to;
String branchKey = BranchScope.keyOf(model, fromNodeId);
String next = chosen.to;
FlowDefModel.Node nextNode = model.nodeById(next);
if (FlowDefModel.TYPE_PARALLEL_JOIN.equals(nextNode.type)) {
// 会签合并守门:标记本分支到达、全部到达才放行(直通无分支键时 JOIN 直通放行)
@@ -73,34 +80,15 @@ public class FlowEngine {
}
/**
* 回溯 fromNodeId 所在分支的 branchKey(= PARALLEL_SPLIT 出边目标节点 id):
* 沿入边反向走到 split,split→N 边的 N 即分支键;遇 START/PARALLEL_JOIN 边界即停
* (join 之后属汇聚链,branchKey 已消耗,返回 null)。
* 退回目标激活(FlowTaskService.returnBack 调用):从 currentNodes 摘除来源节点,
* 重新激活目标审批节点建新任务。沿用统一激活入口,split 幂等化后重入并行段不撞分支唯一约束。
*/
private String branchKeyOf(FlowDefModel model, String fromNodeId) {
Set<String> visited = new HashSet<>();
Deque<String> queue = new ArrayDeque<>();
queue.add(fromNodeId);
visited.add(fromNodeId);
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;
public void returnTo(FlwInstance instance, FlwDef def, String fromNodeId, String targetNodeId) {
FlowDefModel model = parseModel(def);
removeCurrentNode(instance, fromNodeId);
FlowDefModel.Node target = model.nodeById(targetNodeId);
String branchKey = BranchScope.keyOf(model, targetNodeId);
advance(instance, model, target.id, branchKey);
}
/** 统一激活入口:含 JOIN 的到达标记与放行判断 */
@@ -148,10 +136,17 @@ public class FlowEngine {
break;
case FlowDefModel.TYPE_PARALLEL_SPLIT: {
for (FlowDefModel.Edge edge : model.outEdges(node.id)) {
FlwBranch branch = new FlwBranch();
branch.setInstanceId(instance.getId());
branch.setBranchKey(edge.to);
branch.setStatus(FlwEnums.BranchStatus.RUNNING.getCode());
// 幂等 upsert:退回重入并行段时分支行已存在(uk instance+branchKey),重置进行中即可
FlwBranch branch = branchRepository
.findByInstanceIdAndBranchKey(instance.getId(), edge.to).orElse(null);
if (branch == null) {
branch = new FlwBranch();
branch.setInstanceId(instance.getId());
branch.setBranchKey(edge.to);
branch.setStatus(FlwEnums.BranchStatus.RUNNING.getCode());
} else {
branch.setStatus(FlwEnums.BranchStatus.RUNNING.getCode());
}
branchRepository.save(branch);
advance(instance, model, edge.to, edge.to);
}
@@ -28,6 +28,10 @@ public class FlowEvent {
REJECTED("flow.rejected"),
/** 提交人撤回,instance=40 */
WITHDRAWN("flow.withdrawn"),
/** 办理人退回上游节点(办件仍在途,instance 仍=10) */
RETURNED("flow.returned"),
/** 办理人移交办理权给他人(任务换人,办件仍在途) */
TRANSFERRED("flow.transferred"),
/** 催办(仅 UserMsg 通道,非回调事件枚举) */
URGE("flow.urge");
@@ -30,9 +30,10 @@ public class FlowNodeAdvanceListener {
if (nodeId == null) {
return;
}
String edgeName = (String) event.getData().get("edgeName");
for (int attempt = 1; attempt <= 3; attempt++) {
try {
advanceService.tryAdvance(event.getInstanceId(), nodeId);
advanceService.tryAdvance(event.getInstanceId(), nodeId, edgeName);
return;
} catch (ObjectOptimisticLockingFailureException e) {
log.warn("[flow-center] 推进冲突重试 {}/3:instance={} node={}",
@@ -47,7 +47,7 @@ public class FlwTask extends BaseEntity {
@Column(name = "assignee_name", length = 128)
private String assigneeName;
/** 0 待办 / 1 同意 / 2 拒绝 / 3 失效 */
/** 0 待办 / 1 同意 / 2 拒绝 / 3 失效 / 4 已退回 / 5 已移交 */
@Column(name = "status", nullable = false)
private Integer status = FlwEnums.TaskStatus.PENDING.getCode();
@@ -55,6 +55,13 @@ public class FlwTask extends BaseEntity {
@Column(name = "opinion", length = 500)
private String opinion;
/**
* 多出口节点办理时选中的出口名(对应 def 边 name);单出口节点恒为 null。
* 节点放行推进时按它决定走哪条边。
*/
@Column(name = "edge_name", length = 64)
private String edgeName;
@Column(name = "arrive_time", nullable = false)
private LocalDateTime arriveTime;
@@ -23,9 +23,13 @@ public final class FlwEnums {
}
}
/** 任务状态:0 待办 / 1 同意 / 2 拒绝 / 3 失效(拒绝/撤回/重启重提时的清理目标) */
/**
* 任务状态:0 待办 / 1 同意 / 2 拒绝 / 3 失效(拒绝/撤回/重启重提时的清理目标)
* / 4 已退回(办理人把流程退回上游节点)/ 5 已移交(办理人把办理权转给他人)。
* 4/5 都算"已被办理":既进我的已办,也阻止提交人撤回。
*/
public enum TaskStatus {
PENDING(0), APPROVED(1), REJECTED(2), INVALIDATED(3);
PENDING(0), APPROVED(1), REJECTED(2), INVALIDATED(3), RETURNED(4), TRANSFERRED(5);
private final int code;
@@ -36,6 +40,12 @@ public final class FlwEnums {
public int getCode() {
return code;
}
/** "已被办理"状态集(本人已办列表、撤回守卫共用口径),勿在各处硬编码字面量 */
public static java.util.List<Integer> handledCodes() {
return java.util.Arrays.asList(APPROVED.getCode(), REJECTED.getCode(),
RETURNED.getCode(), TRANSFERRED.getCode());
}
}
/** 并行分支状态:0 进行中 / 1 已到达汇聚点 / 2 已失效 */
@@ -76,6 +76,15 @@ public final class FlowModels {
/** 当前用户可否办理(存在本人的待办任务) */
private boolean myPendingTask;
private List<Long> myPendingTaskIds;
/**
* 本人待办所在节点的出口列表(多出口办理按钮,[{name,toNodeId,toNodeName}]);
* 单出口/无待办为空数组——前端据此决定是否显示去向选择器。
*/
private List<Map<String, Object>> outlets;
/** 可退回目标(主线已办审批节点去重,排除当前节点)[{nodeId,nodeName}] */
private List<Map<String, Object>> returnTargets;
/** 本人待办可否移交(恒=有本人待办,展示用) */
private boolean canTransfer;
}
/** 流程示意图出参(用户面 /diagram 与管理面 /admin/def/diagram 共用,方案 §3.1) */
@@ -91,9 +100,11 @@ public final class FlowModels {
private String mode;
private List<NodeVo> nodes;
private List<EdgeVo> edges;
/** 画布坐标(nodeId→{x,y},设计器保存时透传;空=查看器自行自动布局) */
private Map<String, Map<String, Integer>> layout;
}
/** 示意图节点(label/subLabel 已按视图解析好,前端只画不解析) */
/** 示意图节点(label/subLabel 已按视图解析好,前端只画不解析;state 仅 instance 口径有值) */
@Data
public static class NodeVo {
private String id;
@@ -104,12 +115,18 @@ public final class FlowModels {
private String assigneeType;
private String countersign;
private Integer deadlineHours;
/**
* 运行时状态(仅 instanceId 口径):done 已办过 / active 当前在办 / pending 未到 /
* rejected 被拒绝 / 其他为 null。flowGuid 口径恒 null。
*/
private String state;
}
/** 示意图连边 */
/** 示意图连边(name=出口文案,多出口节点显示为连线标签) */
@Data
public static class EdgeVo {
private String from;
private String to;
private String name;
}
}
@@ -1,9 +1,12 @@
package com.yunhai.flowcenter.notify;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.openapi.core.PlatformService;
import com.yunhai.platform.openapi.core.YschOpenCall;
import com.yunhai.platform.openapi.msg.YschMsgOpenApi;
import com.yunhai.platform.openapi.msg.model.SendMsgRequest;
import com.yunhai.platform.openapi.msg.model.TodoCountRequest;
import com.yunhai.platform.utils.http.IDHttp;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
@@ -11,30 +14,56 @@ import org.springframework.stereotype.Service;
import java.util.List;
/**
* UserMsg 站内信客户端(概览 §七 契约,M1 手工三件套路线的<b>直达版</b>):
* 经共享 IDHttp(yh-openapi 装配的出站自动签名 RestTemplate,X-App-Key/X-Timestamp/X-Signature
* = SignUtil.signRequest 请求级 canonical,与 svc-hall FlowCenterClient 同一套通道)直调
* UserMsg /oapi/msg/**。前提:UserMsg 侧 trusted-apps 白名单收本应用 appKey(本地占位 100019);
* SDK(yh-usermsg-spring-boot-starter)落地后整体切换为 SDK 调用,契约不变。
* 发送方不阻断主业务:失败只记日志(对齐 PartyLearn 容错语义),由调用方在 AFTER_COMMIT 监听器里容错。
* UserMsg 站内信客户端(概览 §七 契约):主路径走 SDK 类型化门面 {@link YschMsgOpenApi}
* (服务目录 key={@code usermsg}:yh.call.targets &gt; yh.open.services &gt; 枚举兜底
* base-host + /usermsg)——签名/信封/错误映射由 SDK 统一承载,本应用零专属地址配置
* (《子应用配置优化方案》§4.2)。目录未显式配置时回落 legacy
* {@code flow-center.usermsg.base-url}(ad-hoc,迁移期兼容)。<br><br>
* 发送方不阻断主业务:失败只记日志(对齐 PartyLearn 容错语义),由调用方在 AFTER_COMMIT
* 监听器里容错。{@code flow-center.usermsg.enabled=false} 时全部方法静默返回(UserMsg 未部署
* 的部署形态;生产当前即此态,避免打 localhost 的空转告警)。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class UserMsgClient {
/** 服务目录命名目标(UserMsg 已入 PlatformService 枚举:key=usermsg、ctx=/usermsg) */
private static final String DIRECTORY_KEY = PlatformService.USERMSG.key();
private final YschOpenCall openCall;
private final IDHttp http;
private final boolean enabled;
private final String legacyBaseUrl;
private final boolean directoryResolved;
@Value("${flow-center.usermsg.enabled:false}")
private boolean enabled;
@Value("${flow-center.usermsg.base-url:http://localhost:8080}")
private String baseUrl;
public UserMsgClient(YschOpenCall openCall,
IDHttp http,
@Value("${flow-center.usermsg.enabled:false}") boolean enabled,
@Value("${flow-center.usermsg.base-url:}") String baseUrl) {
this.openCall = openCall;
this.http = http;
this.enabled = enabled;
this.legacyBaseUrl = baseUrl == null ? "" : trimTrailing(baseUrl.trim());
this.directoryResolved = openCall != null && openCall.hasTarget(DIRECTORY_KEY);
if (directoryResolved) {
YschMsgOpenApi.setCall(openCall);
}
if (enabled && !directoryResolved && legacyBaseUrl.isEmpty()) {
// enabled=true 表示确实要发;目录与 legacy 键都缺 → 构造期 fail-fast
throw new IllegalStateException("UserMsg 调用地址未配置:请配置服务目录 yh.open.services."
+ DIRECTORY_KEY + "(推荐,容器直连完整 URL)或回落键 flow-center.usermsg.base-url");
}
}
public boolean enabled() {
return enabled;
}
/** 是否走服务目录命名目标(配置自检用) */
public boolean directoryResolved() {
return directoryResolved;
}
/** 发送站内信(content 必填;msgNo 幂等,重发安全) */
public void sendUserMsg(Integer schId, List<Integer> userIds, Integer msgType, String bizType,
String bizId, String title, String content, String jumpPath, String msgNo,
@@ -57,7 +86,11 @@ public class UserMsgClient {
body.setSchId(schId);
body.setExpireDays(expireDays);
try {
http.post(trimTrailing(baseUrl) + "/oapi/msg/sendUserMsg", body, Object.class);
if (directoryResolved) {
YschMsgOpenApi.sendUserMsg(body);
} else {
http.post(legacyBaseUrl + "/oapi/msg/sendUserMsg", body, Object.class);
}
} catch (BizException e) {
log.warn("[flow-center] UserMsg 发送被拒:msgNo={} msg={}", msgNo, e.getMessage());
} catch (Exception e) {
@@ -75,7 +108,11 @@ public class UserMsgClient {
body.setCount(count);
body.setSchId(schId);
try {
http.post(trimTrailing(baseUrl) + "/oapi/msg/todo/inc", body, Object.class);
if (directoryResolved) {
YschMsgOpenApi.todoInc(body);
} else {
http.post(legacyBaseUrl + "/oapi/msg/todo/inc", body, Object.class);
}
} catch (Exception e) {
log.warn("[flow-center] UserMsg 角标增量失败(不阻断主业务):userIds={} count={} {}",
userIds, count, e.toString());
@@ -85,31 +122,4 @@ public class UserMsgClient {
private static String trimTrailing(String url) {
return url.endsWith("/") ? url.substring(0, url.length() - 1) : url;
}
/** /oapi/msg/sendUserMsg 请求体(UserMsg SendMsgRequest 同构,字段逐字对齐) */
@Data
public static class SendMsgRequest {
private List<Integer> userIds;
private String title;
/** 必填(UserMsg @NotBlank) */
private String content;
private String msgNo;
private String bizType;
private String bizId;
/** 0系统通知/1业务提醒/2审批待办 */
private Integer msgType;
/** 0无跳转/1应用内路径/2外链 */
private Integer jumpType;
private String jumpPath;
private Integer schId;
private Integer expireDays;
}
/** /oapi/msg/todo/inc 请求体(UserMsg TodoCountRequest 同构) */
@Data
public static class TodoCountRequest {
private List<Integer> userIds;
private Integer count;
private Integer schId;
}
}
@@ -40,6 +40,12 @@ public class UserMsgNotifier {
case TASK_HANDLED:
notifyTaskHandled(event);
break;
case RETURNED:
notifyReturned(event);
break;
case TRANSFERRED:
notifyTaskCreated(event);
break;
case COMPLETED:
case REJECTED:
case WITHDRAWN:
@@ -80,6 +86,36 @@ public class UserMsgNotifier {
Collections.singletonList(task.getAssigneeId()), -1);
}
/**
* 退回:通知被退回节点的全部新待办人(RETURNED 事件 data.targetNodeIds 由服务层带上——
* 退回重建任务在事件发布前已提交,这里按节点查待办即可);todo 角标由 notifyTaskCreated
* 通用链路在引擎重建任务时已发过,这里只补一条带退回意见的消息。
*/
private void notifyReturned(FlowEvent event) {
FlwInstance instance = instanceOf(event.getInstanceId());
if (instance == null || event.getData() == null) {
return;
}
Object fromNode = event.getData().get("fromNodeName");
Object opinion = event.getData().get("opinion");
StringBuilder content = new StringBuilder("「").append(safe(instance.getBizTitle())).append("」")
.append("被退回(").append(fromNode == null ? "" : fromNode).append(")");
if (opinion != null && !String.valueOf(opinion).isEmpty()) {
content.append(",意见:").append(opinion);
}
java.util.List<Integer> targets = event.getData().get("targetUserIds") == null
? Collections.emptyList()
: (java.util.List<Integer>) event.getData().get("targetUserIds");
if (targets.isEmpty()) {
return;
}
userMsgClient.sendUserMsg(instance.getSchId().intValue(), targets, 1,
"flow.returned", String.valueOf(instance.getId()),
"办件被退回:" + safe(instance.getBizTitle()), content.toString(),
"/instance/" + instance.getId(),
"returned:" + instance.getId() + ":" + instance.getVersion(), 30);
}
private void notifyResult(FlowEvent event) {
FlwInstance instance = instanceOf(event.getInstanceId());
if (instance == null) {
@@ -21,15 +21,33 @@ public interface FlwTaskRepository extends JpaRepository<FlwTask, Long> {
/** 任务办理抢占(概览 §5.4):affected=1 才获得推进权,防双击/并发重复推进 */
@Modifying
@Query("update FlwTask t set t.status = :target, t.handleTime = :handleTime, t.opinion = :opinion "
+ "where t.id = :id and t.status = 0")
@Query("update FlwTask t set t.status = :target, t.handleTime = :handleTime, t.opinion = :opinion, "
+ "t.edgeName = :edgeName where t.id = :id and t.status = 0")
int claimAndFinish(@Param("id") Long id, @Param("target") Integer target,
@Param("handleTime") LocalDateTime handleTime, @Param("opinion") String opinion);
@Param("handleTime") LocalDateTime handleTime, @Param("opinion") String opinion,
@Param("edgeName") String edgeName);
@Modifying
@Query("update FlwTask t set t.status = 3 where t.instanceId = :instanceId and t.status = 0")
int invalidatePendingByInstance(@Param("instanceId") Long instanceId);
/**
* 失效指定节点的待办(退回专用):不能按办件整体失效——并行兄弟分支的待办必须保留。
*/
@Modifying
@Query("update FlwTask t set t.status = 3 where t.instanceId = :instanceId and t.status = 0 "
+ "and t.nodeId = :nodeId")
int invalidatePendingByNode(@Param("instanceId") Long instanceId, @Param("nodeId") String nodeId);
/**
* 失效同节点除指定任务外的待办(或签 ANY 专用):任一人同意后兄弟待办作废。
*/
@Modifying
@Query("update FlwTask t set t.status = 3 where t.instanceId = :instanceId and t.status = 0 "
+ "and t.nodeId = :nodeId and t.id <> :exceptTaskId")
int invalidatePendingByNodeExcept(@Param("instanceId") Long instanceId, @Param("nodeId") String nodeId,
@Param("exceptTaskId") Long exceptTaskId);
@Modifying
@Query("update FlwTask t set t.status = 3 where t.instanceId = :instanceId and t.status = 0 and t.id <> :exceptTaskId")
int invalidatePendingExcept(@Param("instanceId") Long instanceId, @Param("exceptTaskId") Long exceptTaskId);
@@ -39,9 +57,9 @@ public interface FlwTaskRepository extends JpaRepository<FlwTask, Long> {
+ "order by t.arriveTime asc, t.id asc")
Page<FlwTask> pageTodo(@Param("schId") Long schId, @Param("assigneeId") Integer assigneeId, Pageable pageable);
/** 我的已办(办理过的历史任务) */
@Query("select t from FlwTask t where t.schId = :schId and t.assigneeId = :assigneeId and t.status in (1, 2) "
+ "order by t.handleTime desc, t.id desc")
/** 我的已办(办理过的历史任务,含退回/移交) */
@Query("select t from FlwTask t where t.schId = :schId and t.assigneeId = :assigneeId "
+ "and t.status in (1, 2, 4, 5) order by t.handleTime desc, t.id desc")
Page<FlwTask> pageDone(@Param("schId") Long schId, @Param("assigneeId") Integer assigneeId, Pageable pageable);
/** 超期待催任务(定时扫描:待办+超期+未达催办上限) */
@@ -5,12 +5,15 @@ import com.yunhai.flowcenter.engine.FlowEngine;
import com.yunhai.flowcenter.entity.FlwAssigneeMap;
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.model.FlowModels;
import com.yunhai.flowcenter.repository.FlwAssigneeMapRepository;
import com.yunhai.flowcenter.repository.FlwDefRepository;
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
import com.yunhai.flowcenter.repository.FlwTaskRepository;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.openapi.client.OpenApiUserClient;
import com.yunhai.platform.openapi.base.OpenApiUserClient;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.RequiredArgsConstructor;
@@ -18,7 +21,11 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.HashSet;
/**
* 流程示意图出图(方案 §3.2):后端按视图解析好节点 label/subLabel,前端只画图不解析。
@@ -42,6 +49,8 @@ public class FlowDiagramService {
private final FlwDefRepository defRepository;
private final FlwAssigneeMapRepository mapRepository;
private final FlwInstanceRepository instanceRepository;
private final FlwTaskRepository taskRepository;
private final FlowEngine flowEngine;
private final OpenApiUserClient userClient;
@@ -87,12 +96,65 @@ public class FlowDiagramService {
FlowModels.EdgeVo ev = new FlowModels.EdgeVo();
ev.setFrom(e.from);
ev.setTo(e.to);
ev.setName(e.name);
edges.add(ev);
}
vo.setEdges(edges);
// 画布坐标透传(设计器保存;缺失时前端自行 Kahn 布局)
if (model.layout != null && !model.layout.isEmpty()) {
Map<String, Map<String, Integer>> layout = new HashMap<>();
for (Map.Entry<String, FlowDefModel.Layout> en : model.layout.entrySet()) {
if (en.getValue() == null) {
continue;
}
Map<String, Integer> p = new HashMap<>();
p.put("x", en.getValue().x);
p.put("y", en.getValue().y);
layout.put(en.getKey(), p);
}
vo.setLayout(layout);
}
// instance 口径:按任务轨迹给节点上运行时状态(前端高亮在办节点)
if (instanceId != null) {
Map<String, String> states = nodeStates(instanceId, model);
for (FlowModels.NodeVo nv : nodes) {
nv.setState(states.get(nv.getId()));
}
}
return vo;
}
/**
* 办件节点运行时状态:done=已有已同意任务 / rejected=有拒绝任务(终态前)/
* active=在 currentNodes / pending=其余。active 优先于 done(退回后重到同节点算在办)。
*/
private Map<String, String> nodeStates(Long instanceId, FlowDefModel model) {
Map<String, String> states = new HashMap<>();
Set<String> activeNodes = new HashSet<>();
FlwInstance instance = instanceRepository.findById(instanceId).orElse(null);
if (instance != null && instance.getCurrentNodes() != null && !instance.getCurrentNodes().isEmpty()) {
activeNodes.addAll(java.util.Arrays.asList(instance.getCurrentNodes().split(",")));
}
for (FlowDefModel.Node n : model.nodes) {
states.put(n.id, null);
}
for (FlwTask t : taskRepository.findByInstanceIdOrderByIdAsc(instanceId)) {
String nodeState = states.get(t.getNodeId());
if (t.getStatus() == FlwEnums.TaskStatus.REJECTED.getCode()) {
states.put(t.getNodeId(), "rejected");
} else if (t.getStatus() == FlwEnums.TaskStatus.APPROVED.getCode()
&& nodeState == null && !activeNodes.contains(t.getNodeId())) {
states.put(t.getNodeId(), "done");
}
}
for (String nodeId : activeNodes) {
if (states.containsKey(nodeId)) {
states.put(nodeId, "active");
}
}
return states;
}
private FlowModels.NodeVo toNodeVo(Long schId, FlowDefModel.Node n, String mode) {
FlowModels.NodeVo vo = new FlowModels.NodeVo();
vo.setId(n.id);
@@ -126,8 +126,7 @@ public class FlowInstanceService {
throw new BizException(ErrorCode.PARAM_ERROR, "仅审批中的办件可撤回");
}
if (!taskRepository.findByInstanceIdAndStatusIn(instanceId,
java.util.Arrays.asList(FlwEnums.TaskStatus.APPROVED.getCode(),
FlwEnums.TaskStatus.REJECTED.getCode())).isEmpty()) {
FlwEnums.TaskStatus.handledCodes()).isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "已有任务被办理,不能撤回");
}
instance.setStatus(FlwEnums.InstanceStatus.WITHDRAWN.getCode());
@@ -5,8 +5,8 @@ import com.yunhai.flowcenter.repository.FlwAssigneeMapRepository;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.platform.openapi.client.OpenApiGroupClient;
import com.yunhai.platform.openapi.client.OpenApiUserClient;
import com.yunhai.platform.openapi.base.OpenApiGroupClient;
import com.yunhai.platform.openapi.base.OpenApiUserClient;
import com.yunhai.platform.utils.model.BoaGroup;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@@ -1,5 +1,7 @@
package com.yunhai.flowcenter.service;
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.FlwDef;
@@ -111,6 +113,7 @@ public class FlowQueryService {
row.put("handleTime", str(t.getHandleTime()));
row.put("deadline", str(t.getDeadline()));
row.put("urgeCount", t.getUrgeCount());
row.put("edgeName", t.getEdgeName());
trace.add(row);
}
detail.setTrace(trace);
@@ -135,6 +138,53 @@ public class FlowQueryService {
}
FlwDef def = defRepository.findById(instance.getDefId()).orElse(null);
detail.setNodeSummary(def == null ? "" : FlowEngine.nodeSummary(flowEngine.parseModel(def)));
// 办理面板数据(出口/退回目标/移交标记):仅对持本人待办的查看者填充
FlwTask myPend = viewerId == null ? null : tasks.stream()
.filter(t -> viewerId.equals(t.getAssigneeId())
&& t.getStatus() == FlwEnums.TaskStatus.PENDING.getCode())
.findFirst().orElse(null);
if (myPend != null && def != null) {
FlowDefModel model = flowEngine.parseModel(def);
String curNodeId = myPend.getNodeId();
List<FlowDefModel.Edge> outs = model.outEdges(curNodeId);
if (outs.size() > 1) {
List<Map<String, Object>> outlets = new ArrayList<>();
for (FlowDefModel.Edge e : outs) {
Map<String, Object> o = new HashMap<>();
o.put("name", e.name);
o.put("toNodeId", e.to);
FlowDefModel.Node to = model.node(e.to);
o.put("toNodeName", to == null ? e.to
: (to.name == null || to.name.isEmpty() ? e.to : to.name));
outlets.add(o);
}
detail.setOutlets(outlets);
} else {
detail.setOutlets(new ArrayList<>());
}
// 可退回目标:办件已办过的主线审批节点(去重,排除当前节点)
Map<String, Map<String, Object>> targets = new java.util.LinkedHashMap<>();
for (FlwTask t : tasks) {
if (t.getStatus() != FlwEnums.TaskStatus.APPROVED.getCode()
|| t.getNodeId().equals(myPend.getNodeId())
|| BranchScope.keyOf(model, t.getNodeId()) != null) {
continue;
}
FlowDefModel.Node node = model.node(t.getNodeId());
if (node != null && FlowDefModel.TYPE_APPROVAL.equals(node.type)
&& !targets.containsKey(t.getNodeId())) {
Map<String, Object> r = new HashMap<>();
r.put("nodeId", node.id);
r.put("nodeName", node.name == null || node.name.isEmpty() ? node.id : node.name);
targets.put(node.id, r);
}
}
detail.setReturnTargets(new ArrayList<>(targets.values()));
detail.setCanTransfer(true);
} else {
detail.setOutlets(new ArrayList<>());
detail.setReturnTargets(new ArrayList<>());
}
return detail;
}
@@ -1,13 +1,17 @@
package com.yunhai.flowcenter.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.flowcenter.engine.AssigneeResolver;
import com.yunhai.flowcenter.engine.BranchScope;
import com.yunhai.flowcenter.engine.FlowDefModel;
import com.yunhai.flowcenter.engine.FlowEvent;
import com.yunhai.flowcenter.engine.FlowEngine;
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.FlwDefRepository;
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
import com.yunhai.flowcenter.repository.FlwTaskRepository;
import com.yunhai.platform.apistd.BizException;
@@ -38,15 +42,20 @@ public class FlowTaskService {
private final FlwTaskRepository taskRepository;
private final FlwInstanceRepository instanceRepository;
private final FlwBranchRepository branchRepository;
private final FlwDefRepository defRepository;
private final AssigneeResolver assigneeResolver;
private final FlowEngine flowEngine;
private final ApplicationEventPublisher eventPublisher;
/**
* 办理(同意/拒绝收敛点,概览 §8.5 不变式 2)。
*
* @param edgeName 多出口节点的所选出口(办理按钮文案);单出结节忽略,出度>1 时必填
* @return 办理后的任务(含最新状态)
*/
@Transactional
public FlwTask handle(Long schId, Long taskId, boolean approved, String opinion, Integer operatorId) {
public FlwTask handle(Long schId, Long taskId, boolean approved, String opinion,
Integer operatorId, String edgeName) {
FlwTask task = taskRepository.findById(taskId)
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "任务不存在:" + taskId));
if (!task.getSchId().equals(schId)) {
@@ -58,10 +67,12 @@ public class FlowTaskService {
if (!approved && (opinion == null || opinion.trim().isEmpty())) {
throw new BizException(ErrorCode.PARAM_ERROR, "拒绝必须填写意见");
}
// 多出口选路校验(发布期已保证出口命名唯一):出度>1 必须带上出口名且能命中
String chosenEdge = normalizeEdgeName(task, approved, edgeName);
// 原子抢占:affected=1 才获得办理权(防双击/并发重复推进)
int target = approved ? FlwEnums.TaskStatus.APPROVED.getCode() : FlwEnums.TaskStatus.REJECTED.getCode();
LocalDateTime now = LocalDateTime.now();
if (taskRepository.claimAndFinish(taskId, target, now, opinion) != 1) {
if (taskRepository.claimAndFinish(taskId, target, now, opinion, chosenEdge) != 1) {
throw new BizException(ErrorCode.PARAM_ERROR, "任务已被办理或已失效,请刷新");
}
FlwTask handled = taskRepository.findById(taskId).orElse(null);
@@ -82,14 +93,63 @@ public class FlowTaskService {
if (opinion != null && !opinion.isEmpty()) {
data.put("opinion", opinion);
}
if (chosenEdge != null) {
data.put("edgeName", chosenEdge);
}
eventPublisher.publishEvent(FlowEvent.of(FlowEvent.Type.TASK_HANDLED,
instance.getId(), taskId, data));
if (!approved) {
rejectFlow(instance, task, opinion);
} else if (FlowDefModel.COUNTERSIGN_ANY.equals(countersignOf(task))) {
// 或签(ANY):任一人同意即放行,失效同节点兄弟待办——
// 推进监听器只认 pending==0,不失效会卡到所有人办完(行为等同 ALL 的历史缺陷)
taskRepository.invalidatePendingByNodeExcept(instance.getId(), task.getNodeId(), taskId);
}
return handled;
}
/** 旧签名兼容(单出口调用方) */
@Transactional
public FlwTask handle(Long schId, Long taskId, boolean approved, String opinion, Integer operatorId) {
return handle(schId, taskId, approved, opinion, operatorId, null);
}
/** 出度>1 的节点必须选有效出口;单出口一律置 null(旧前端零改动) */
private String normalizeEdgeName(FlwTask task, boolean approved, String edgeName) {
if (!approved) {
return null;
}
FlwDef def = defRepository.findById(instanceRepository.findById(task.getInstanceId())
.map(FlwInstance::getDefId).orElse(null)).orElse(null);
if (def == null) {
return null;
}
FlowDefModel model = FlowDefModel.parse(objectMapper, def.getContentJson());
java.util.List<FlowDefModel.Edge> outs = model.outEdges(task.getNodeId());
if (outs.size() <= 1) {
return null;
}
if (edgeName == null || edgeName.trim().isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "该节点有多个出口,请选择办理去向");
}
FlowDefModel.Edge hit = model.edgeByName(task.getNodeId(), edgeName.trim());
if (hit == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "无效的办理去向:" + edgeName);
}
return hit.name;
}
/** 该任务所属节点的会签模式(定义缺失/未配置按 ALL) */
private String countersignOf(FlwTask task) {
FlwDef def = defRepository.findById(instanceRepository.findById(task.getInstanceId())
.map(FlwInstance::getDefId).orElse(null)).orElse(null);
if (def == null) {
return FlowDefModel.COUNTERSIGN_ALL;
}
FlowDefModel.Node node = FlowDefModel.parse(objectMapper, def.getContentJson()).node(task.getNodeId());
return node == null || node.countersign == null ? FlowDefModel.COUNTERSIGN_ALL : node.countersign;
}
/** END_REJECTED 拒绝终态(概览 §5.3):同批待办失效→分支失效→办件=30→拒绝事件 */
private void rejectFlow(FlwInstance instance, FlwTask task, String opinion) {
instance.setStatus(FlwEnums.InstanceStatus.REJECTED.getCode());
@@ -106,6 +166,150 @@ public class FlowTaskService {
instance.getId(), task.getId(), data));
}
/**
* 退回(B3):把流程退回上游已办节点重新办理。当前任务置 RETURNED、当前节点待办全部失效、
* 目标节点重新激活建新任务;办件保持审批中(区别于拒绝=终态)。
* v1 限定主线节点(BranchScope.keyOf(target)==null),分支内退回登记二期。
*
* @param targetNodeId 退回目标节点;空=最近一个已办节点(任务轨迹 handleTime 降序)
*/
@Transactional
public void returnBack(Long schId, Long taskId, String targetNodeId, String opinion, Integer operatorId) {
FlwTask task = requirePendingTask(schId, taskId, operatorId);
FlwInstance instance = instanceRepository.findById(task.getInstanceId())
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "办件不存在"));
FlwDef def = defRepository.findById(instance.getDefId())
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "流程定义缺失"));
FlowDefModel model = FlowDefModel.parse(objectMapper, def.getContentJson());
String target = resolveReturnTarget(model, instance, task, targetNodeId);
FlowDefModel.Node targetNode = model.node(target);
if (!FlowDefModel.TYPE_APPROVAL.equals(targetNode.type)) {
throw new BizException(ErrorCode.PARAM_ERROR, "退回目标必须是审批节点:" + target);
}
if (BranchScope.keyOf(model, target) != null) {
throw new BizException(ErrorCode.PARAM_ERROR, "暂不支持退回到并行分支内的节点");
}
LocalDateTime now = LocalDateTime.now();
String fromLabel = task.getNodeName() == null ? task.getNodeId() : task.getNodeName();
String targetLabel = targetNode.name == null ? target : targetNode.name;
if (taskRepository.claimAndFinish(taskId, FlwEnums.TaskStatus.RETURNED.getCode(), now,
"退回至 " + targetLabel + (opinion == null || opinion.isEmpty() ? "" : ":" + opinion), null) != 1) {
throw new BizException(ErrorCode.PARAM_ERROR, "任务已被办理或已失效,请刷新");
}
taskRepository.invalidatePendingByNode(instance.getId(), task.getNodeId());
flowEngine.returnTo(instance, def, task.getNodeId(), target);
instanceRepository.save(instance);
Map<String, Object> data = new HashMap<>();
data.put("fromNodeId", task.getNodeId());
data.put("fromNodeName", fromLabel);
data.put("targetNodeId", target);
data.put("targetNodeName", targetLabel);
if (opinion != null && !opinion.isEmpty()) {
data.put("opinion", opinion);
}
data.put("targetUserIds", taskRepository
.findByInstanceIdAndStatus(instance.getId(), FlwEnums.TaskStatus.PENDING.getCode()).stream()
.filter(t -> target.equals(t.getNodeId()))
.map(FlwTask::getAssigneeId)
.collect(java.util.stream.Collectors.toList()));
eventPublisher.publishEvent(FlowEvent.of(FlowEvent.Type.RETURNED, instance.getId(), taskId, data));
}
/** 退回目标解析:显式指定须合法;缺省取最近一个已办节点 */
private String resolveReturnTarget(FlowDefModel model, FlwInstance instance, FlwTask task,
String targetNodeId) {
if (targetNodeId != null && !targetNodeId.trim().isEmpty()) {
FlowDefModel.Node node = model.node(targetNodeId.trim());
if (node == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "退回目标节点不存在:" + targetNodeId);
}
if (node.id.equals(task.getNodeId())) {
throw new BizException(ErrorCode.PARAM_ERROR, "不能退回当前节点");
}
return node.id;
}
return taskRepository.findByInstanceIdOrderByIdAsc(instance.getId()).stream()
.filter(t -> FlwEnums.TaskStatus.APPROVED.getCode() == t.getStatus())
.filter(t -> !task.getNodeId().equals(t.getNodeId()))
.filter(t -> BranchScope.keyOf(model, t.getNodeId()) == null)
.reduce((first, second) -> second)
.map(FlwTask::getNodeId)
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR,
"无可退回的上游节点,请使用拒绝"));
}
/**
* 移交(B4):办理权转给他人。当前任务置 TRANSFERRED 留痕,同节点新建一条待办给目标人
* (deadline 沿用原任务);推进监听器因新待办 pending>0 自然不放行,无需改推进逻辑。
* 目标人存在性由 AssigneeResolver 严格校验(查不到必须拒绝,否则产生僵尸待办)。
*/
@Transactional
public void transfer(Long schId, Long taskId, Integer toUserId, String reason, Integer operatorId) {
if (toUserId == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "缺少移交对象");
}
FlwTask task = requirePendingTask(schId, taskId, operatorId);
if (toUserId.equals(task.getAssigneeId())) {
throw new BizException(ErrorCode.PARAM_ERROR, "不能移交给当前办理人本人");
}
FlwInstance instance = instanceRepository.findById(task.getInstanceId())
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "办件不存在"));
String toUserName = assigneeResolver.requireUserName(toUserId);
LocalDateTime now = LocalDateTime.now();
String fromLabel = task.getNodeName() == null ? task.getNodeId() : task.getNodeName();
if (taskRepository.claimAndFinish(taskId, FlwEnums.TaskStatus.TRANSFERRED.getCode(), now,
"移交给 " + (toUserName.isEmpty() ? toUserId : toUserName)
+ (reason == null || reason.isEmpty() ? "" : ":" + reason), null) != 1) {
throw new BizException(ErrorCode.PARAM_ERROR, "任务已被办理或已失效,请刷新");
}
FlwTask newTask = new FlwTask();
newTask.setInstanceId(task.getInstanceId());
newTask.setSchId(task.getSchId());
newTask.setNodeId(task.getNodeId());
newTask.setNodeName(task.getNodeName());
newTask.setAssigneeId(toUserId);
newTask.setAssigneeName(toUserName);
newTask.setStatus(FlwEnums.TaskStatus.PENDING.getCode());
newTask.setArriveTime(now);
newTask.setDeadline(task.getDeadline());
newTask.setUrgeCount(0);
taskRepository.save(newTask);
Map<String, Object> data = new HashMap<>();
data.put("fromNodeId", task.getNodeId());
data.put("fromNodeName", fromLabel);
data.put("fromUserId", task.getAssigneeId());
data.put("fromUserName", task.getAssigneeName());
data.put("toUserId", toUserId);
data.put("toUserName", toUserName);
data.put("taskId", newTask.getId());
if (reason != null && !reason.isEmpty()) {
data.put("opinion", reason);
}
eventPublisher.publishEvent(FlowEvent.of(FlowEvent.Type.TRANSFERRED,
instance.getId(), newTask.getId(), data));
}
/** 待办任务守卫:存在、本校、本人办理、仍待办 */
private FlwTask requirePendingTask(Long schId, Long taskId, Integer operatorId) {
FlwTask task = taskRepository.findById(taskId)
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "任务不存在:" + taskId));
if (!task.getSchId().equals(schId)) {
throw new BizException(ErrorCode.FORBIDDEN, "任务不属于该学校");
}
if (!task.getAssigneeId().equals(operatorId)) {
throw new BizException(ErrorCode.FORBIDDEN, "仅任务办理人本人可操作");
}
if (task.getStatus() != FlwEnums.TaskStatus.PENDING.getCode()) {
throw new BizException(ErrorCode.PARAM_ERROR, "仅待办任务可操作");
}
return task;
}
/** 手工催办(提交人/管理员):urge_count+1 + URGE 事件(UserMsg 通道) */
@Transactional
public void urge(Long schId, Long taskId, int maxTimes) {
@@ -15,7 +15,7 @@ import com.yunhai.flowcenter.repository.FlwTaskRepository;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.platform.openapi.client.OpenApiUserClient;
import com.yunhai.platform.openapi.base.OpenApiUserClient;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
+10 -4
View File
@@ -66,12 +66,15 @@ yh:
"100005": "2c7885de594742cc840b8762910b2d2e"
"100617": "2a3b4c5d6e7f8091a2b3c4d5e6f70819"
open:
enabled: true
# 身份密钥唯一来源 = yh.app-auth(本文件 app-auth 段),open.* 副本已删;
# 本地全链测试:指向 local-debug 的 UserBase;生产改回 base.yunhaixingtong.com
base-host: http://localhost:8060
base-path: /userbase
app-key: 100019
app-secret: "flow-center-dev-secret-100019"
# 服务目录(子应用配置优化方案 §4.2):usermsg 未部署时无需配置(enabled=false 即不解析);
# 部署后配容器直连完整 URL(如 http://usermsg-service:100xx/usermsg),或走平台级
# YH_OPEN_SERVICES_USERMSG 注入。
# services:
# usermsg: http://localhost:8080
# ===== 流程中心专属配置 =====
flow-center:
@@ -97,8 +100,11 @@ flow-center:
# 挂起/死信告警接收人(本校管理员 userId;本地联调可留空)
alert-user-ids: ""
usermsg:
# UserMsg 基址(IDHttp 出站自动签名直达 /oapi/msg/**;UserMsg 侧 trusted-apps 须收本应用占位 appKey=100019)
# 站内信/待办推送:主路径 = SDK 门面 YschMsgOpenApi(服务目录 yh.open.services.usermsg);
# legacy 回落键 base-url 已删(无默认值)——enabled=true 且两者皆缺时构造期 fail-fast。
# 本地 8080 起 UserMsg 后置 true;生产 UserMsg 未部署 → false(显式关闭,消除空转告警)。
enabled: true
# base-url legacy 回落(仅迁移期兜底,正常不用配):见上方 open.services.usermsg
base-url: http://localhost:8080
logging:
@@ -0,0 +1,9 @@
-- flw_task 增加 edge_name(多出口节点办理时选中的出边名,可空)
-- 用途:审批节点有多条出边时,办理人在办理页选一个出口(名称即按钮文案),
-- 所选出口落库在该任务行上,节点放行推进时按它决定走哪条边。
-- 落库而非走事件传参:推进发生在 AFTER_COMMIT 的新事务里且带乐观锁重试,
-- 事件传参不可靠;落列后可重放、可审计,并发办理时以最后办理的任务为准。
-- 单出口节点恒为 NULL(旧数据零影响)。
-- 本地/测试 ddl-auto=update 会自动加列;生产执行本脚本。
ALTER TABLE `flw_task`
ADD COLUMN `edge_name` VARCHAR(64) NULL COMMENT '多出口节点所选出口(对应 def 边 name)' AFTER `opinion`;
@@ -97,7 +97,8 @@ class CallbackDispatchIT extends FlowTestSupport {
new java.util.concurrent.atomic.AtomicInteger();
CountingUserMsgClient() {
super(null);
// enabled=false:本用例只覆盖回调派发,站内信为哑实现(两个方法均覆写)
super(null, null, false, "");
}
@Override
@@ -34,6 +34,75 @@ class DefValidatorTest {
DefValidator.validate(parse(json), new HashSet<>(Collections.singletonList("ybb")));
}
@Test
void multiOutlet_withoutNames_fails() {
String json = "{"
+ "\"code\":\"c1\",\"name\":\"流程\",\"start\":\"start\","
+ "\"nodes\":["
+ "{\"id\":\"start\",\"type\":\"START\"},"
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"name\":\"审批\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"ybb\"}},"
+ "{\"id\":\"n2\",\"type\":\"APPROVAL\",\"name\":\"复核\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"ybb\"}},"
+ "{\"id\":\"end\",\"type\":\"END\"}],"
+ "\"edges\":[{\"from\":\"start\",\"to\":\"n1\"},"
+ "{\"from\":\"n1\",\"to\":\"n2\"},{\"from\":\"n1\",\"to\":\"end\"},"
+ "{\"from\":\"n2\",\"to\":\"end\"}]}";
BizException e = assertThrows(BizException.class,
() -> DefValidator.validate(parse(json), new HashSet<>(Collections.singletonList("ybb"))));
assertTrue(e.getMessage().contains("出口必须命名"));
}
@Test
void multiOutlet_duplicateNames_fails() {
String json = "{"
+ "\"code\":\"c1\",\"name\":\"流程\",\"start\":\"start\","
+ "\"nodes\":["
+ "{\"id\":\"start\",\"type\":\"START\"},"
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"name\":\"审批\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"ybb\"}},"
+ "{\"id\":\"end\",\"type\":\"END\"}],"
+ "\"edges\":[{\"from\":\"start\",\"to\":\"n1\"},"
+ "{\"from\":\"n1\",\"to\":\"end\",\"name\":\"通过\"},"
+ "{\"from\":\"n1\",\"to\":\"end\",\"name\":\"通过\"}]}";
BizException e = assertThrows(BizException.class,
() -> DefValidator.validate(parse(json), new HashSet<>(Collections.singletonList("ybb"))));
assertTrue(e.getMessage().contains("出口名称重复"));
}
@Test
void multiOutlet_namedUnique_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\":\"end\",\"type\":\"END\"}],"
+ "\"edges\":[{\"from\":\"start\",\"to\":\"n1\"},"
+ "{\"from\":\"n1\",\"to\":\"end\",\"name\":\"通过\"},"
+ "{\"from\":\"n1\",\"to\":\"end\",\"name\":\"驳回结束\"}]}";
DefValidator.validate(parse(json), new HashSet<>(Collections.singletonList("ybb")));
}
@Test
void multiOutlet_crossBranchTarget_fails() {
// n1 在并行分支内,出口指向主线 end —— 跨分支连线一期拒绝
String json = "{"
+ "\"code\":\"c1\",\"name\":\"流程\",\"start\":\"start\","
+ "\"nodes\":["
+ "{\"id\":\"start\",\"type\":\"START\"},"
+ "{\"id\":\"split1\",\"type\":\"PARALLEL_SPLIT\"},"
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"name\":\"分支审批\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"ybb\"}},"
+ "{\"id\":\"join1\",\"type\":\"PARALLEL_JOIN\",\"waitFor\":\"ALL\"},"
+ "{\"id\":\"end\",\"type\":\"END\"}],"
+ "\"edges\":[{\"from\":\"start\",\"to\":\"split1\"},"
+ "{\"from\":\"split1\",\"to\":\"n1\"},"
+ "{\"from\":\"split1\",\"to\":\"end\",\"name\":\"直通\"},"
+ "{\"from\":\"n1\",\"to\":\"end\",\"name\":\"分支出口\"},"
+ "{\"from\":\"n1\",\"to\":\"join1\",\"name\":\"到汇聚\"},"
+ "{\"from\":\"join1\",\"to\":\"end\"}]}";
BizException e = assertThrows(BizException.class,
() -> DefValidator.validate(parse(json), new HashSet<>(Collections.singletonList("ybb"))));
assertTrue(e.getMessage().contains("跨分支连线"));
}
@Test
void missingEnd_fails() {
String json = "{"
@@ -243,6 +243,131 @@ class FlowEngineIT extends FlowTestSupport {
assertEquals(2, taskRepository.findByInstanceIdOrderByIdAsc(outcome.getInstanceId()).size());
}
@Test
void multiOutlet_approveWithChosenEdge_routesToTarget() {
mapDept("km", 101, "甲");
mapDept("kf", 102, "乙");
FlwDef def = saveAndPublishDef("t10", "多出口", multiOutletDef("km", "kf"));
FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-10", 201, "张三");
Long instanceId = outcome.getInstanceId();
FlwTask task = taskRepository.findByInstanceIdAndStatus(instanceId, 0).get(0);
// 选"通过"出口 → 直达 END 完成
taskService.handle(SCH, task.getId(), true, "ok", 101, "通过");
assertEquals(FlwEnums.InstanceStatus.COMPLETED.getCode(),
instanceRepository.findById(instanceId).get().getStatus());
assertEquals("通过", taskRepository.findById(task.getId()).get().getEdgeName());
}
@Test
void multiOutlet_chooseOtherEdge_routesToSecondNode() throws Exception {
mapDept("km", 101, "甲");
mapDept("kf", 102, "乙");
FlwDef def = saveAndPublishDef("t11", "多出口转向", multiOutletDef("km", "kf"));
FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-11", 201, "张三");
Long instanceId = outcome.getInstanceId();
FlwTask task = taskRepository.findByInstanceIdAndStatus(instanceId, 0).get(0);
// 选"转乙科"出口 → 到 n2 建新待办
taskService.handle(SCH, task.getId(), true, "转给乙科", 101, "转乙科");
assertEquals(FlwEnums.InstanceStatus.APPROVING.getCode(),
instanceRepository.findById(instanceId).get().getStatus());
List<FlwTask> pending = taskRepository.findByInstanceIdAndStatus(instanceId, 0);
assertEquals(1, pending.size());
assertEquals("n2", pending.get(0).getNodeId());
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());
}
@Test
void multiOutlet_multiOutletNode_requiresEdgeName() {
mapDept("km", 101, "甲");
mapDept("kf", 102, "乙");
FlwDef def = saveAndPublishDef("t12", "多出口必选", multiOutletDef("km", "kf"));
FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-12", 201, "张三");
FlwTask task = taskRepository.findByInstanceIdAndStatus(outcome.getInstanceId(), 0).get(0);
assertThrows(BizException.class,
() -> taskService.handle(SCH, task.getId(), true, "ok", 101, null));
assertThrows(BizException.class,
() -> taskService.handle(SCH, task.getId(), true, "ok", 101, "不存在的出口"));
}
@Test
void anyCountersign_firstApprove_invalidatesSiblings_thenAdvances() {
mapDept("ky", 101, "甲");
mapDept("ky", 102, "乙");
FlwDef def = saveAndPublishDef("t13", "或签", anyCountersignDef("ky"));
FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-13", 201, "张三");
Long instanceId = outcome.getInstanceId();
List<FlwTask> pending = taskRepository.findByInstanceIdAndStatus(instanceId, 0);
assertEquals(2, pending.size());
// 或签:任一人同意即放行,兄弟待办失效(修复前行为=卡到全员办完)
taskService.handle(SCH, pending.get(0).getId(), true, "我先批", 101);
FlwInstance instance = instanceRepository.findById(instanceId).get();
assertEquals(FlwEnums.InstanceStatus.COMPLETED.getCode(), instance.getStatus());
List<FlwTask> all = taskRepository.findByInstanceIdOrderByIdAsc(instanceId);
assertEquals(FlwEnums.TaskStatus.APPROVED.getCode(), all.get(0).getStatus());
assertEquals(FlwEnums.TaskStatus.INVALIDATED.getCode(), all.get(1).getStatus());
}
@Test
void returnBack_fromTailNode_backToHead_thenReTraverseParallel_noUkViolation() {
mapDept("km", 103, "主");
mapDept("ka", 101, "甲");
mapDept("kb", 102, "乙");
FlwDef def = saveAndPublishDef("t14", "退回重入", mainlineParallelTailDef("km", "km"));
FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-14", 201, "张三");
Long instanceId = outcome.getInstanceId();
// 顺序推进:n1 → 并行双分支 → join → n3(在出口复核节点)
taskService.handle(SCH, tasksOf(instanceId, 103).get(0).getId(), true, "ok", 103);
taskService.handle(SCH, tasksOf(instanceId, 101).get(0).getId(), true, "ok", 101);
taskService.handle(SCH, tasksOf(instanceId, 102).get(0).getId(), true, "ok", 102);
FlwTask tail = tasksOf(instanceId, 103).get(0);
assertEquals("n3", tail.getNodeId());
// 退回至入口(缺省主线最近已办=n1;并行内节点被过滤,不会命中 ta/tb)
taskService.returnBack(SCH, tail.getId(), null, "重新审一遍", 103);
FlwInstance returned = instanceRepository.findById(instanceId).get();
assertEquals("n1", returned.getCurrentNodes());
assertEquals(FlwEnums.InstanceStatus.APPROVING.getCode(), returned.getStatus());
// 重入并行段:split 幂等 upsert,不撞 uk(instance,branchKey)
taskService.handle(SCH, tasksOf(instanceId, 103).get(0).getId(), true, "再入口", 103);
taskService.handle(SCH, tasksOf(instanceId, 101).get(0).getId(), true, "ok", 101);
taskService.handle(SCH, tasksOf(instanceId, 102).get(0).getId(), true, "ok", 102);
FlwTask n3Again = tasksOf(instanceId, 103).get(0);
assertEquals("n3", n3Again.getNodeId());
taskService.handle(SCH, n3Again.getId(), true, "复核通过", 103);
assertEquals(FlwEnums.InstanceStatus.COMPLETED.getCode(),
instanceRepository.findById(instanceId).get().getStatus());
// 轨迹含 RETURNED 行
assertEquals(1, allTasks(instanceId).stream()
.filter(t -> FlwEnums.TaskStatus.RETURNED.getCode() == t.getStatus()).count());
}
@Test
void returnBack_noMainlineTarget_throws_andTransfer_reassigns() {
mapDept("km", 101, "甲");
FlwDef def = saveAndPublishDef("t15", "退回目标", linearDef("km", "首审"));
FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-15", 201, "张三");
Long instanceId = outcome.getInstanceId();
FlwTask task = taskRepository.findByInstanceIdAndStatus(instanceId, 0).get(0);
// 首节点无上游已办:缺省退回应明确报错
assertThrows(BizException.class, () ->
taskService.returnBack(SCH, task.getId(), null, "试试", 101));
// 移交:当前任务置 TRANSFERRED,同节点给新办理人建待办
taskService.transfer(SCH, task.getId(), 202, "人不在岗", 101);
List<FlwTask> handled = allTasks(instanceId);
assertEquals(2, handled.size(), "移交后应产生新待办行");
assertEquals(FlwEnums.TaskStatus.TRANSFERRED.getCode(), handled.get(0).getStatus());
FlwTask reassigned = taskRepository.findByInstanceIdAndStatus(instanceId, 0).get(0);
assertEquals(202, reassigned.getAssigneeId());
assertEquals(101, handled.get(0).getAssigneeId()); // 原办理人留在轨迹
// 新办理人通过 → 完成;已退回/移交后提交人不能撤回
taskService.handle(SCH, reassigned.getId(), true, "接手批准", 202);
assertEquals(FlwEnums.InstanceStatus.COMPLETED.getCode(),
instanceRepository.findById(instanceId).get().getStatus());
}
private List<FlwBranch> branchRows(Long instanceId) {
return branchRepository.findByInstanceId(instanceId);
}
@@ -17,7 +17,7 @@ import com.yunhai.flowcenter.repository.FlwDefRepository;
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
import com.yunhai.flowcenter.repository.FlwTaskRepository;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.openapi.client.OpenApiUserClient;
import com.yunhai.platform.openapi.base.OpenApiUserClient;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaUser;
import org.junit.jupiter.api.BeforeEach;
@@ -102,7 +102,9 @@ class FlowDiagramServiceTest {
FlowEngine engine = new FlowEngine(new ObjectMapper(),
taskRepository, mock(FlwBranchRepository.class), mock(AssigneeResolver.class),
mock(ApplicationEventPublisher.class));
service = new FlowDiagramService(defRepository, mapRepository, engine, userClient);
service = new FlowDiagramService(defRepository, mapRepository,
mock(com.yunhai.flowcenter.repository.FlwInstanceRepository.class),
taskRepository, engine, userClient);
}
private FlwDef def(String json) {
@@ -2,8 +2,8 @@ package com.yunhai.flowcenter.service;
import com.yunhai.flowcenter.entity.FlwAssigneeMap;
import com.yunhai.flowcenter.repository.FlwAssigneeMapRepository;
import com.yunhai.platform.openapi.client.OpenApiGroupClient;
import com.yunhai.platform.openapi.client.OpenApiUserClient;
import com.yunhai.platform.openapi.base.OpenApiGroupClient;
import com.yunhai.platform.openapi.base.OpenApiUserClient;
import com.yunhai.platform.utils.model.BoaGroup;
import com.yunhai.platform.utils.model.BoaUser;
import org.junit.jupiter.api.BeforeEach;
@@ -1,11 +1,11 @@
package com.yunhai.flowcenter.stub;
import com.yunhai.platform.openapi.client.OpenApiUserClient;
import com.yunhai.platform.openapi.model.SysUserGroupDeleteItem;
import com.yunhai.platform.openapi.model.SysUserGroupStateRequest;
import com.yunhai.platform.openapi.model.SysUserMoveRequest;
import com.yunhai.platform.openapi.model.SysUserRequest;
import com.yunhai.platform.openapi.model.SysUserTransferSchoolRequest;
import com.yunhai.platform.openapi.base.OpenApiUserClient;
import com.yunhai.platform.openapi.base.model.SysUserGroupDeleteItem;
import com.yunhai.platform.openapi.base.model.SysUserGroupStateRequest;
import com.yunhai.platform.openapi.base.model.SysUserMoveRequest;
import com.yunhai.platform.openapi.base.model.SysUserRequest;
import com.yunhai.platform.openapi.base.model.SysUserTransferSchoolRequest;
import com.yunhai.platform.utils.model.BoaGroup;
import com.yunhai.platform.utils.model.BoaModule;
import com.yunhai.platform.utils.model.BoaRoleInfo;
@@ -103,4 +103,55 @@ public abstract class FlowTestSupport {
private static String codeOf() {
return "tc_" + UUID.randomUUID().toString().replace("-", "").substring(0, 8);
}
/** 多出口流程:START → n1(deptA,出口"转乙科"→n2 / "通过"→end) → end;n2(deptB) → end */
protected String multiOutletDef(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\":\"" + deptA + "\"}},"
+ "{\"id\":\"n2\",\"type\":\"APPROVAL\",\"name\":\"复核\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"" + deptB + "\"}},"
+ "{\"id\":\"end\",\"type\":\"END\"}"
+ "],"
+ "\"edges\":[{\"from\":\"start\",\"to\":\"n1\"},"
+ "{\"from\":\"n1\",\"to\":\"n2\",\"name\":\"转乙科\"},"
+ "{\"from\":\"n1\",\"to\":\"end\",\"name\":\"通过\"},"
+ "{\"from\":\"n2\",\"to\":\"end\"}],"
+ "\"settings\":{\"rejectPolicy\":\"END_REJECTED\"}}";
}
/** 或签流程:START → n1(deptKey, ANY 或签) → END */
protected String anyCountersignDef(String deptKey) {
return "{"
+ "\"code\":\"" + codeOf() + "\",\"name\":\"夡签测试流程\",\"start\":\"start\","
+ "\"nodes\":["
+ "{\"id\":\"start\",\"type\":\"START\",\"name\":\"开始\"},"
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"name\":\"任一人审批\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"" + deptKey + "\"},\"countersign\":\"ANY\"},"
+ "{\"id\":\"end\",\"type\":\"END\"}"
+ "],"
+ "\"edges\":[{\"from\":\"start\",\"to\":\"n1\"},{\"from\":\"n1\",\"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 "{"
+ "\"code\":\"" + codeOf() + "\",\"name\":\"主线并行尾验流程\",\"start\":\"start\","
+ "\"nodes\":["
+ "{\"id\":\"start\",\"type\":\"START\",\"name\":\"开始\"},"
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"name\":\"入口\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"" + deptMain + "\"}},"
+ "{\"id\":\"split1\",\"type\":\"PARALLEL_SPLIT\"},"
+ "{\"id\":\"ta\",\"type\":\"APPROVAL\",\"name\":\"甲科审核\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"ka\"}},"
+ "{\"id\":\"tb\",\"type\":\"APPROVAL\",\"name\":\"乙科审核\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"kb\"}},"
+ "{\"id\":\"join1\",\"type\":\"PARALLEL_JOIN\",\"waitFor\":\"ALL\"},"
+ "{\"id\":\"n3\",\"type\":\"APPROVAL\",\"name\":\"出口复核\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"" + deptA + "\"}},"
+ "{\"id\":\"end\",\"type\":\"END\"}"
+ "],"
+ "\"edges\":[{\"from\":\"start\",\"to\":\"n1\"},{\"from\":\"n1\",\"to\":\"split1\"},"
+ "{\"from\":\"split1\",\"to\":\"ta\"},{\"from\":\"split1\",\"to\":\"tb\"},"
+ "{\"from\":\"ta\",\"to\":\"join1\"},{\"from\":\"tb\",\"to\":\"join1\"},"
+ "{\"from\":\"join1\",\"to\":\"n3\"},{\"from\":\"n3\",\"to\":\"end\"}],"
+ "\"settings\":{\"rejectPolicy\":\"END_REJECTED\"}}";
}
}