|
| 1 | +# 修复计划: processNodes 接口分支节点排序异常 |
| 2 | + |
| 3 | +> 编码: 116393 | 日期: 2026-05-14 | 类型: Bug 修复 | 来源: bugfix | 基于: docs/bugs/116393-并行人工包容分支节点排序错误.md |
| 4 | +
|
| 5 | +## 根因分析 |
| 6 | + |
| 7 | +问题位于 `FlowProcessNodeService.NextNodeLoader.fetchNextNode()`(`flow-engine-framework/src/main/java/com/codingapi/flow/service/impl/FlowProcessNodeService.java:174-197`),递归逻辑是**深度优先(DFS)**: |
| 8 | + |
| 9 | +```java |
| 10 | +this.nodeList.add(processNode); // 先 add 当前 |
| 11 | +List<IFlowNode> nextNodes = workflow.nextNodes(flowNode); |
| 12 | +List<IFlowNode> selected = selectBranchNodes(nextNodes, ...); |
| 13 | +this.fetchNextNode(flowSession.updateSession(flowNode), selected); // 再递归 |
| 14 | +``` |
| 15 | + |
| 16 | +对并行(`ParallelNode`)/ 人工(`ManualNode`)/ 包容(`InclusiveNode`,当多分支同时满足)等"必须走多条分支"的控制节点,遍历会**先把第一条分支一路走到 End**,再回溯处理其它分支: |
| 17 | + |
| 18 | +``` |
| 19 | +顺序: Start → ParallelNode → Branch_1 → Approval_A → ParallelEnd → End |
| 20 | + ↑ DFS 一路走到底 |
| 21 | + → (回溯) Branch_2 → Approval_B ← 此时才 add,已经在 End 之后 |
| 22 | + → (回溯) Branch_3 → Approval_C |
| 23 | +``` |
| 24 | + |
| 25 | +`NextNodeLoader.loadNextNode()`(`FlowProcessNodeService.java:229-240`)最后只做了 `isDisplay` 过滤和"重复 nodeId 去重",**没有按节点定义顺序重排**,因此最终对外返回的列表顺序就是 DFS 的访问顺序:`[Start, Approval_A, ParallelEnd, End, Approval_B, Approval_C]`,分支节点错位在 End 之后。 |
| 26 | + |
| 27 | +而条件分支(`ConditionNode`)不受影响,因为 `selectBranchNodes`(`FlowProcessNodeService.java:199-227`)会把条件分支收敛到单条路径,DFS 不会回溯到其它分支。 |
| 28 | + |
| 29 | +证据: |
| 30 | +- `FlowProcessNodeService.java:174-197` DFS 递归未做后置排序 |
| 31 | +- `FlowProcessNodeService.java:199-227` 当前 `selectBranchNodes` 只覆盖 `ConditionBranchNode` / `InclusiveBranchNode` 等 4 个类型,且对 if 命中的情况会单选一条 |
| 32 | +- `ParallelBranchNode.filterBranches`(`ParallelBranchNode.java:50-65`)返回全部分支节点(不收敛),由此走 DFS 全遍历 |
| 33 | +- `ManualNode.filterBranches`(`ManualNode.java:45-54`)在无 advice 时返回全部分支 |
| 34 | +- 节点的"定义顺序"权威来源是 `Workflow.nodes`(`Workflow.java:108、162-164`),它是按 `WorkflowBuilder.addNode` 顺序追加的有序 `List` |
| 35 | + |
| 36 | +## 影响面评估 |
| 37 | + |
| 38 | +- 直接修改的 Maven 模块: `flow-engine-framework` |
| 39 | +- 可能回归的场景: |
| 40 | + - 含并行节点(`ParallelNode`)的流程预览顺序 |
| 41 | + - 含人工节点(`ManualNode`)的流程预览顺序 |
| 42 | + - 含包容节点(`InclusiveNode`)多分支同时命中场景 |
| 43 | + - 普通直线流程 / 条件分支流程(应当保持原顺序,因为 DFS 顺序本就与定义顺序一致) |
| 44 | + - 已运行流程(含历史节点 + 未来节点混合返回的场景) |
| 45 | +- 是否需要数据迁移/配置变更: 否 |
| 46 | + |
| 47 | +## 新增文件 |
| 48 | + |
| 49 | +| 文件路径 | 用途说明 | |
| 50 | +|----------|----------| |
| 51 | +| (无) | 排序逻辑直接复用 `Workflow.nodes` 索引,无需新增类 | |
| 52 | + |
| 53 | +## 修改文件 |
| 54 | + |
| 55 | +| 文件路径 | 修改内容 | |
| 56 | +|----------|----------| |
| 57 | +| `flow-engine-framework/src/main/java/com/codingapi/flow/service/impl/FlowProcessNodeService.java` | 在 `NextNodeLoader.loadNextNode()` 完成 DFS 与去重后,按 `workflow.getNodes()` 索引对结果重排,让"未来节点"按节点定义顺序输出。历史节点(buildHistoryNodes 返回)的顺序在 `processNodes()` 中独立保留,不受此处影响 | |
| 58 | + |
| 59 | +## 关键代码修改 |
| 60 | + |
| 61 | +`NextNodeLoader.loadNextNode()` 当前实现: |
| 62 | + |
| 63 | +```java |
| 64 | +public List<ProcessNode> loadNextNode(FlowSession flowSession) { |
| 65 | + this.fetchNextNode(flowSession, List.of(this.currentNode)); |
| 66 | + List<ProcessNode> displayNodes = nodeList.stream().filter(ProcessNode::isDisplay).toList(); |
| 67 | + List<ProcessNode> processNodeList = new ArrayList<>(); |
| 68 | + for (ProcessNode node : displayNodes) { |
| 69 | + if (!processNodeList.contains(node)) { |
| 70 | + processNodeList.add(node); |
| 71 | + } |
| 72 | + } |
| 73 | + return processNodeList; |
| 74 | +} |
| 75 | +``` |
| 76 | + |
| 77 | +修改为: |
| 78 | + |
| 79 | +```java |
| 80 | +public List<ProcessNode> loadNextNode(FlowSession flowSession) { |
| 81 | + this.fetchNextNode(flowSession, List.of(this.currentNode)); |
| 82 | + List<ProcessNode> displayNodes = nodeList.stream().filter(ProcessNode::isDisplay).toList(); |
| 83 | + List<ProcessNode> processNodeList = new ArrayList<>(); |
| 84 | + for (ProcessNode node : displayNodes) { |
| 85 | + if (!processNodeList.contains(node)) { |
| 86 | + processNodeList.add(node); |
| 87 | + } |
| 88 | + } |
| 89 | + return sortByWorkflowNodeOrder(processNodeList); |
| 90 | +} |
| 91 | + |
| 92 | +private List<ProcessNode> sortByWorkflowNodeOrder(List<ProcessNode> processNodes) { |
| 93 | + List<IFlowNode> workflowNodes = workflow.getNodes(); |
| 94 | + Map<String, Integer> indexMap = new HashMap<>(); |
| 95 | + for (int i = 0; i < workflowNodes.size(); i++) { |
| 96 | + indexMap.put(workflowNodes.get(i).getId(), i); |
| 97 | + } |
| 98 | + // 未在 workflow.nodes 顶层注册的节点(如嵌套子节点)放到末尾,相对顺序保持稳定 |
| 99 | + return processNodes.stream() |
| 100 | + .sorted(Comparator.comparingInt( |
| 101 | + n -> indexMap.getOrDefault(n.getNodeId(), Integer.MAX_VALUE))) |
| 102 | + .toList(); |
| 103 | +} |
| 104 | +``` |
| 105 | + |
| 106 | +要点说明: |
| 107 | + |
| 108 | +1. **以 `Workflow.nodes` 索引为权威排序键**:该列表在 `WorkflowBuilder.addNode()` 中按调用顺序追加,对应前端流程图保存时的节点顺序,是"定义顺序"的事实依据 |
| 109 | +2. **稳定排序**:`Comparator.comparingInt` 配合 `Stream.sorted` 是稳定排序,索引相同(如未注册节点)的元素保持 DFS 顺序,不会引入新的不确定性 |
| 110 | +3. **不动 `fetchNextNode` 内部逻辑**:递归 add 顺序保持原状,仅在最终输出前做一次重排。避免影响 `processNode.setCurrentState()` 等中间状态判定 |
| 111 | +4. **不影响历史节点**:`processNodes()` 在调用 `loadNextNode` 之前已经把历史节点 add 到 `this.nodeList` —— 这里返回的 `processNodeList` 只是"未来节点"部分,外层 `nodeList.addAll(nextNodes)` 仍把它们追加在历史节点之后 |
| 112 | + |
| 113 | +## 回归验证 |
| 114 | + |
| 115 | +| 验证项 | 说明 | |
| 116 | +|--------|------| |
| 117 | +| 并行分支顺序 | 构造 Start → ParallelNode(含 3 个分支,每分支 1 个 Approval)→ ParallelEnd → End 的流程,调用 `processNodes`,验证返回顺序为 Start → Approval_A → Approval_B → Approval_C → ParallelEnd → End | |
| 118 | +| 人工分支顺序 | 构造含 `ManualNode` 的流程(无 advice,返回全部分支),验证分支节点按定义顺序排列 | |
| 119 | +| 包容分支顺序 | 构造含 `InclusiveNode` 多分支同时命中的场景,验证分支节点按定义顺序排列 | |
| 120 | +| 条件分支不回归 | 验证 107562 修复的条件分支单路径选择行为依旧正确(包括 if 命中和 fallback else 两种情况) | |
| 121 | +| 直线流程不回归 | 普通 Start → Approval → End 流程顺序不变 | |
| 122 | +| 历史节点混合 | 已运行流程预览:历史节点(按时间顺序)排在前,未来节点(按定义顺序)排在后 | |
| 123 | +| 编译验证 | `mvn -pl flow-engine-framework -am compile` | |
| 124 | +| 既有测试不回归 | `mvn -pl flow-engine-framework test` | |
| 125 | + |
| 126 | +具体验证命令: |
| 127 | + |
| 128 | +```bash |
| 129 | +mvn -pl flow-engine-framework -am compile |
| 130 | +mvn -pl flow-engine-framework test |
| 131 | +``` |
| 132 | + |
| 133 | +## 执行顺序 |
| 134 | + |
| 135 | +1. 创建 git worktree(按项目规范,所有开发必须在 worktree 中进行) |
| 136 | +2. 修改 `flow-engine-framework/src/main/java/com/codingapi/flow/service/impl/FlowProcessNodeService.java`: |
| 137 | + - 增加 `java.util.Comparator` / `java.util.HashMap` / `java.util.Map` 的 import(如未引入) |
| 138 | + - 在 `NextNodeLoader` 类中新增私有方法 `sortByWorkflowNodeOrder(List<ProcessNode>)` |
| 139 | + - 修改 `loadNextNode(FlowSession)` 的 `return` 语句,最后调用 `sortByWorkflowNodeOrder` |
| 140 | +3. 执行 `mvn -pl flow-engine-framework -am compile` 编译验证 |
| 141 | +4. 执行 `mvn -pl flow-engine-framework test` 完整回归 |
| 142 | +5. 人工审核后提交 PR(目标分支:dev) |
| 143 | + |
| 144 | +## 注意事项 |
| 145 | + |
| 146 | +- 严格遵循 CLAUDE.md 中的开发规范:本变更必须在 git worktree 中进行,PR 目标为 `dev` 分支 |
| 147 | +- **依赖 107562 的修复**:本计划基于"selectBranchNodes 已存在"的代码版本撰写。若所在分支尚未合并 107562,需先合并或同源开发,避免 `loadNextNode` 区域出现冲突 |
| 148 | +- 不修改运行时分支选择路径(`FlowSession.matchNextNodes` / 各 `filterBranches` 实现),仅作用于 `processNodes` 预览路径 |
| 149 | +- `Workflow.nodes` 列表中通常仅包含顶层节点;嵌套在 `IBlockNode.blocks` 中的子节点(如 `ConditionBranchNode`)会被 `indexMap` 视为未注册节点排到末尾。鉴于这些子节点本身不是 `IDisplayNode`,已在 `loadNextNode` 上游被过滤,不会出现在最终结果中 |
0 commit comments