feat(flow): 工作台与轨迹优化 + 管理端启用回路 + 本地验签配置修复
build / build-push (push) Successful in 1m30s

- 已办去重: 同一办件一行 + doneCount 累计次数(FlwTaskRepository 查询去重)
- 发起/撤回/重提留痕事件行(状态码 6/7, 无需 DDL); /oapi/flow/progress steps 含撤回/重提步骤
- 管理端新增启用回路: POST /admin/map/enable/{id}、POST /admin/def/enable/{id}; GET /api/flow/def/source 源码查询
- 修复 findEnabledByOrdId 未过滤 status 的隐性缺陷; 新增 DeptLabelSupport 科室标签支撑
- 测试: FlowEngineIT 撤回留痕/已办去重断言、OapiFlowContractIT 撤回步骤断言(94 tests 全过)
- 本地验签修复: 本地 UserBase 无 context-path, yh.open.services 显式覆盖服务目录枚举兜底的 /userbase basePath, 否则出站签名与服务端剥离不对齐 → GetUserInfo 等全部「验签失败」; 生产经 Caddy 挂 /userbase 时删除 services 覆盖
This commit is contained in:
chenxing
2026-09-18 16:56:01 +08:00
parent 434aa9d185
commit 9cc0b06c29
18 changed files with 956 additions and 248 deletions
@@ -9,6 +9,7 @@ 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.FlwBranchRepository;
import com.yunhai.flowcenter.repository.FlwDefRepository;
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
@@ -23,6 +24,7 @@ import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -39,6 +41,7 @@ public class FlowQueryService {
private final FlwTaskRepository taskRepository;
private final FlwBranchRepository branchRepository;
private final FlwDefRepository defRepository;
private final FlwAssigneeMapRepository mapRepository;
private final FlowEngine flowEngine;
public BasePageResult<FlowModels.InstanceItem> pageMy(Long schId, Integer submitterId,
@@ -59,16 +62,36 @@ public class FlowQueryService {
return toResult(p.getTotalElements(), items, page, size);
}
/** 已办:一个办件一行(保留本人最后办理的那条),并回填累计办理次数 */
public BasePageResult<FlowModels.TaskItem> pageDone(Long schId, Integer assigneeId,
Integer page, Integer size) {
Page<FlwTask> p = taskRepository.pageDone(schId, assigneeId,
List<Integer> handled = FlwEnums.TaskStatus.handledCodes();
Page<FlwTask> p = taskRepository.pageDone(schId, assigneeId, handled,
PageRequest.of(normPage(page), normSize(size)));
List<FlowModels.TaskItem> items = p.getContent().stream()
.map(this::toTaskItem).collect(Collectors.toList());
fillInstanceInfo(items);
fillDoneCount(items, assigneeId, handled);
return toResult(p.getTotalElements(), items, page, size);
}
/** 已办行的累计办理次数(同一办件多节点办理/退回重办时的"共办理 N 次") */
private void fillDoneCount(List<FlowModels.TaskItem> items, Integer assigneeId,
List<Integer> handled) {
if (items.isEmpty()) {
return;
}
List<Long> ids = items.stream().map(FlowModels.TaskItem::getInstanceId)
.distinct().collect(Collectors.toList());
Map<Long, Integer> counts = new HashMap<>();
for (Object[] row : taskRepository.countDoneByInstance(ids, assigneeId, handled)) {
counts.put((Long) row[0], ((Number) row[1]).intValue());
}
for (FlowModels.TaskItem item : items) {
item.setDoneCount(counts.getOrDefault(item.getInstanceId(), 1));
}
}
/**
* 办件详情:快照 + 任务轨迹 + 分支进度 + 本人待办标记(办理页/详情页共用数据源)。
* viewerId 必须由控制器从 BoaUser.getId() 传入(CAS 会话 LoginUser.userId 是登录名字符串,
@@ -99,24 +122,9 @@ public class FlowQueryService {
detail.setFinishTime(str(instance.getFinishTime()));
detail.setVersion(instance.getVersion());
List<FlwTask> tasks = taskRepository.findByInstanceIdOrderByIdAsc(instanceId);
List<Map<String, Object>> trace = new ArrayList<>();
for (FlwTask t : tasks) {
Map<String, Object> row = new HashMap<>();
row.put("taskId", t.getId());
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("deadline", str(t.getDeadline()));
row.put("urgeCount", t.getUrgeCount());
row.put("edgeName", t.getEdgeName());
trace.add(row);
}
detail.setTrace(trace);
FlwDef def = defRepository.findById(instance.getDefId()).orElse(null);
FlowDefModel model = def == null ? null : flowEngine.parseModel(def);
detail.setTrace(buildTrace(instance, tasks, model));
List<Map<String, Object>> branches = new ArrayList<>();
for (FlwBranch b : branchRepository.findByInstanceId(instanceId)) {
Map<String, Object> row = new HashMap<>();
@@ -136,15 +144,13 @@ public class FlowQueryService {
detail.setMyPendingTask(!myPending.isEmpty());
detail.setMyPendingTaskIds(myPending);
}
FlwDef def = defRepository.findById(instance.getDefId()).orElse(null);
detail.setNodeSummary(def == null ? "" : FlowEngine.nodeSummary(flowEngine.parseModel(def)));
detail.setNodeSummary(def == null ? "" : FlowEngine.nodeSummary(model));
// 办理面板数据(出口/退回目标/移交标记):仅对持本人待办的查看者填充
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) {
@@ -213,6 +219,153 @@ public class FlowQueryService {
return item;
}
/**
* 流程轨迹(2026-09-18 重排为"时间流"):审批任务行 + 留痕事件行(6 发起/重新提交、7 撤回)
* 合成一条按时间升序的轨迹,替掉原先"按 id 升序"的排法(id 序在撤回重走场景下与
* 真实发生顺序不一致)。
*
* <p>每条行补四个前端直接用、不再自行推断的字段:
* <ul>
* <li>{@code time} 该行实际发生的时间(已办/退回/移交/撤回=handleTime,在办/失效=arriveTime);</li>
* <li>{@code kind} SUBMIT|RESUBMIT|WITHDRAW|APPROVE|REJECT|RETURN|TRANSFER|INVALID|PENDING
* (前端配色与文案的唯一依据);</li>
* <li>{@code round} 第几轮审批(遇"重新提交"进位,撤回属于它结束的那一轮);</li>
* <li>{@code rowKey} 去重 key(任务行 t{id}、合成行 submit —— taskId 可能为 null 不能当 key)。</li>
* </ul>
* 科室口径(视图两档:dept/user)另需 assigneeType/deptKey/deptName —— 只有 DEPT_KEY 节点有
* 科室概念,故仅对该类节点回填;节点在定义里找不到(重提后老节点、留痕事件行)不回填,
* 前端回落人名显示。
*
* <p>老办件(改造前发起)没有"发起"留痕行,按办件快照的 submitTime/submitterName 合成一条
* 兜底插入到时间流对应位置,保证轨迹首条仍是"发起申请"。
*/
private List<Map<String, Object>> buildTrace(FlwInstance instance, List<FlwTask> tasks,
FlowDefModel model) {
List<FlwTask> sorted = new ArrayList<>(tasks);
sorted.sort(Comparator.comparing(FlowQueryService::happenedAt,
Comparator.nullsLast(Comparator.naturalOrder()))
.thenComparing(FlwTask::getId));
Map<String, String> deptNames = new HashMap<>();
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);
// 老办件兜底行的插入位:时间早于提交时间的行数(老办件里恒为 0;重提过的老办件按时间放在中段)
if (submitTime != null && happenedAt(t).isBefore(submitTime)) {
headIndex = trace.size();
}
}
if (!hasSubmitRow(sorted)) {
trace.add(headIndex, syntheticSubmitRow(instance));
}
int round = 1;
for (Map<String, Object> row : trace) {
if ("RESUBMIT".equals(row.get("kind"))) {
round++;
}
row.put("round", round);
}
return trace;
}
private static boolean hasSubmitRow(List<FlwTask> tasks) {
for (FlwTask t : tasks) {
if (FlwEnums.TaskStatus.NODE_SUBMIT.equals(t.getNodeId())) {
return true;
}
}
return false;
}
/** 老办件兜底:改造前发起不留痕,用快照补一条"发起申请"(taskId 为空,前端按 rowKey 去重) */
private static Map<String, Object> syntheticSubmitRow(FlwInstance instance) {
Map<String, Object> row = new HashMap<>();
row.put("rowKey", "submit");
row.put("taskId", null);
row.put("kind", "SUBMIT");
row.put("nodeId", FlwEnums.TaskStatus.NODE_SUBMIT);
row.put("nodeName", "发起申请");
row.put("assigneeId", instance.getSubmitterId());
row.put("assigneeName", instance.getSubmitterName());
row.put("status", FlwEnums.TaskStatus.SUBMITTED.getCode());
row.put("time", str(instance.getSubmitTime()));
row.put("synthetic", true);
return row;
}
/** 该行实际发生的时间:办结过看 handleTime,否则看 arriveTime */
private static LocalDateTime happenedAt(FlwTask t) {
return t.getHandleTime() != null ? t.getHandleTime() : t.getArriveTime();
}
private static String kindOf(FlwTask t) {
int s = t.getStatus() == null ? FlwEnums.TaskStatus.PENDING.getCode() : t.getStatus();
if (s == FlwEnums.TaskStatus.SUBMITTED.getCode()) {
return FlwEnums.TaskStatus.NODE_RESUBMIT.equals(t.getNodeId()) ? "RESUBMIT" : "SUBMIT";
}
if (s == FlwEnums.TaskStatus.WITHDRAWN.getCode()) {
return "WITHDRAW";
}
if (s == FlwEnums.TaskStatus.APPROVED.getCode()) {
return "APPROVE";
}
if (s == FlwEnums.TaskStatus.REJECTED.getCode()) {
return "REJECT";
}
if (s == FlwEnums.TaskStatus.INVALIDATED.getCode()) {
return "INVALID";
}
if (s == FlwEnums.TaskStatus.RETURNED.getCode()) {
return "RETURN";
}
if (s == FlwEnums.TaskStatus.TRANSFERRED.getCode()) {
return "TRANSFER";
}
return "PENDING";
}
/**
* 科室口径富化:节点是 DEPT_KEY(科室办理)时带上 deptKey + 映射里的科室名,
* 前端"按科室显示"据此把人名换成科室名;USERS(指定人员)节点只带 assigneeType,
* 由前端显示"指定办理人"(口径同示意图的科室视图)。
*/
private void fillAssigneeScope(Map<String, Object> row, FlowDefModel model, String nodeId,
Long schId, Map<String, String> deptNames) {
FlowDefModel.Node node = model == null ? null : model.node(nodeId);
FlowDefModel.Assignee assignee = node == null ? null : node.assignee;
if (assignee == null || assignee.type == null) {
return;
}
row.put("assigneeType", assignee.type);
if (!FlowDefModel.ASSIGNEE_DEPT_KEY.equals(assignee.type) || assignee.deptKey == null) {
return;
}
row.put("deptKey", assignee.deptKey);
String deptName = deptNames.computeIfAbsent(assignee.deptKey,
k -> DeptLabelSupport.mappedDeptName(mapRepository.findEnabled(schId, k)));
if (deptName != null) {
row.put("deptName", deptName);
}
}
private FlowModels.TaskItem toTaskItem(FlwTask t) {
FlowModels.TaskItem item = new FlowModels.TaskItem();
item.setTaskId(t.getId());