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
@@ -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) {