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

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