|
| 1 | +# 调整计划: 发起人/审批人指定的人员范围约束 |
| 2 | + |
| 3 | +> 编码: 319860 | 日期: 2026-05-20 | 类型: 调整任务 | 来源: 用户需求 |
| 4 | +
|
| 5 | +## 一、需求与目标 |
| 6 | + |
| 7 | +为现有的 **发起人设定(`INITIATOR_SELECT`)** 与 **审批人设定(`APPROVER_SELECT`)** 两种操作人选择能力,增加「可选人员范围」约束: |
| 8 | + |
| 9 | +1. 这两种模式支持配置一个**范围脚本**,复用现有 `OperatorLoadScript`,脚本返回 `List<IFlowOperator>` 作为该节点的「可选人员范围」。 |
| 10 | +2. 前端在遇到需要设定操作人(发起人指定 / 审批人指定)的节点时,能拿到该节点的候选范围并弹窗,**默认全选**。 |
| 11 | +3. 若**未设定范围**,则视为可以选择任何人。 |
| 12 | +4. 提交设定时,后端**主动校验**所选人员是否超出范围,超出则报错提示。 |
| 13 | + |
| 14 | +## 二、关键决策(已与用户确认) |
| 15 | + |
| 16 | +| 决策点 | 结论 | |
| 17 | +|--------|------| |
| 18 | +| 范围脚本承载方式 | **复用** `OperatorLoadStrategy.operatorLoadScript` 字段(不新增字段) | |
| 19 | +| 配置了脚本但执行返回空列表 | **等同未配置 = 可选任意人** | |
| 20 | + |
| 21 | +由此得到一条统一规则: |
| 22 | + |
| 23 | +> **可选范围 = 执行 `operatorLoadScript` 的结果列表。结果为空(脚本为 null,或脚本执行返回空)→ 不限范围 → 可选任意人 → 跳过越界校验。** |
| 24 | +
|
| 25 | +## 三、现状分析 |
| 26 | + |
| 27 | +### 3.1 操作人选择三种模式 |
| 28 | + |
| 29 | +`OperatorSelectType`:`SCRIPT`(默认,脚本直接算出操作人)、`INITIATOR_SELECT`(发起人设定)、`APPROVER_SELECT`(审批人设定)。 |
| 30 | + |
| 31 | +### 3.2 现有链路 |
| 32 | + |
| 33 | +- `OperatorLoadStrategy`(`strategy/node/OperatorLoadStrategy.java`) |
| 34 | + - `SCRIPT` 模式:`loadOperators()` 执行 `operatorLoadScript` 直接得到操作人。 |
| 35 | + - `INITIATOR/APPROVER` 模式:`loadOperators()` 仅从 `t_flow_operator_assignment` 读已分配的人,**无脚本、无范围概念**。 |
| 36 | +- 扫描需要选人的节点: |
| 37 | + - 发起人:`PassAction.run`(StartNode 提交时)→ `InitiatorSelectNodeService.appendNodes`,对未分配的 `INITIATOR_SELECT` 节点产出 `NodeOption`。 |
| 38 | + - 审批人:`PassAction.collectApproverSelectNodes`,对下游未分配的 `APPROVER_SELECT` 节点产出 `NodeOption`。 |
| 39 | + - 产出后通过 `ActionResponse(ResponseType.OPERATOR_SELECT, options)` 返回前端。 |
| 40 | +- `NodeOption`(`pojo/response/NodeOption.java`)当前仅含 `id/name/type/display`,**不含候选人**。 |
| 41 | +- 保存分配:`FlowCreateService`(发起人)/`FlowActionService`(审批人)遍历 `operatorSelectMap` 调 `saveOperatorAssignment(processId, nodeId, operatorIds)`,**无范围校验**。 |
| 42 | +- `operatorSelectMap` 值为 `List<Long>`(userId);`IFlowOperator.getUserId()` 返回 `long`,二者可直接比对。 |
| 43 | + |
| 44 | +## 四、设计方案 |
| 45 | + |
| 46 | +### 4.1 `OperatorLoadStrategy` —— 增加范围脚本与范围计算 |
| 47 | + |
| 48 | +1. 工厂方法增加「带脚本」重载(无参重载保留 = 不限范围): |
| 49 | + |
| 50 | +```java |
| 51 | +public static OperatorLoadStrategy initiatorSelectStrategy() { ... } // 不限范围 |
| 52 | +public static OperatorLoadStrategy initiatorSelectStrategy(String rangeScript) { // 带范围 |
| 53 | + OperatorLoadStrategy s = new OperatorLoadStrategy(); |
| 54 | + s.selectType = OperatorSelectType.INITIATOR_SELECT; |
| 55 | + s.operatorLoadScript = new OperatorLoadScript(rangeScript); |
| 56 | + return s; |
| 57 | +} |
| 58 | +public static OperatorLoadStrategy approverSelectStrategy() { ... } // 不限范围 |
| 59 | +public static OperatorLoadStrategy approverSelectStrategy(String rangeScript) { ... } // 带范围 |
| 60 | +``` |
| 61 | + |
| 62 | +2. 新增范围计算方法(`loadOperators` 逻辑保持不变): |
| 63 | + |
| 64 | +```java |
| 65 | +/** 计算该节点的可选人员范围;返回空表示不限范围(可选任意人) */ |
| 66 | +public List<IFlowOperator> loadOperatorRange(FlowSession session) { |
| 67 | + if (operatorLoadScript == null) { |
| 68 | + return List.of(); |
| 69 | + } |
| 70 | + return operatorLoadScript.execute(session); // 返回空亦表示不限 |
| 71 | +} |
| 72 | +``` |
| 73 | + |
| 74 | +3. `toMap` / `fromMap` 兼容范围脚本(让 `INITIATOR/APPROVER` 模式也能序列化 `script`): |
| 75 | + |
| 76 | +```java |
| 77 | +// toMap:只要存在脚本就写出 |
| 78 | +if (operatorLoadScript != null) { |
| 79 | + map.put("script", operatorLoadScript.getScript()); |
| 80 | +} |
| 81 | +// fromMap:SCRIPT 模式按原逻辑;非 SCRIPT 模式若带 script 则装配为范围脚本 |
| 82 | +String script = (String) map.get("script"); |
| 83 | +if (strategy.selectType == OperatorSelectType.SCRIPT) { |
| 84 | + strategy.operatorLoadScript = new OperatorLoadScript(script); |
| 85 | +} else if (script != null) { |
| 86 | + strategy.operatorLoadScript = new OperatorLoadScript(script); |
| 87 | +} |
| 88 | +``` |
| 89 | + |
| 90 | +### 4.2 `NodeStrategyManager` —— 暴露范围计算 |
| 91 | + |
| 92 | +```java |
| 93 | +public List<IFlowOperator> loadOperatorRange(FlowSession session) { |
| 94 | + OperatorLoadStrategy s = getStrategy(OperatorLoadStrategy.class); |
| 95 | + return s == null ? List.of() : s.loadOperatorRange(session); |
| 96 | +} |
| 97 | +``` |
| 98 | + |
| 99 | +### 4.3 `NodeOption` —— 携带候选范围返回前端 |
| 100 | + |
| 101 | +- 新增字段 `private List<IFlowOperator> operators;`(候选范围;`null`/空 = 不限,前端可选任意人)。 |
| 102 | +- 新增构造 `NodeOption(IFlowNode node, List<IFlowOperator> operators)`;原 `NodeOption(IFlowNode node)`(手动节点分支选择用)保留。 |
| 103 | +- 注意:`@AllArgsConstructor` 新增字段后全参构造签名变化,已确认仅 Lombok 生成、无业务代码调用全参构造。 |
| 104 | + |
| 105 | +### 4.4 扫描时计算并填充范围 |
| 106 | + |
| 107 | +- `InitiatorSelectNodeService.appendNodes`:命中未分配的 `INITIATOR_SELECT` 节点时 |
| 108 | + |
| 109 | +```java |
| 110 | +FlowSession nodeSession = flowSession.updateSession(node); |
| 111 | +List<IFlowOperator> range = node.strategyManager().loadOperatorRange(nodeSession); |
| 112 | +operatorSelectNodes.add(new NodeOption(node, range)); |
| 113 | +``` |
| 114 | + |
| 115 | +- `PassAction.collectApproverSelectNodes`:方法增加 `FlowSession flowSession` 入参(递归同步传入),命中未分配的 `APPROVER_SELECT` 节点时 |
| 116 | + |
| 117 | +```java |
| 118 | +FlowSession nodeSession = flowSession.updateSession(node); |
| 119 | +result.add(new NodeOption(node, node.strategyManager().loadOperatorRange(nodeSession))); |
| 120 | +``` |
| 121 | + |
| 122 | +调用处改为 `collectApproverSelectNodes(flowSession, nextNode, operatorSelectMap, operatorSelectNodes)`。 |
| 123 | + |
| 124 | +### 4.5 保存前的越界校验(统一封装) |
| 125 | + |
| 126 | +新增框架层服务 `OperatorAssignmentService`(建议置于 `service/impl`),统一「校验 + 保存」,供两个 Service 复用: |
| 127 | + |
| 128 | +```java |
| 129 | +public static void validateAndSave(FlowSession baseSession, String processId, |
| 130 | + Map<String, List<Long>> operatorSelectMap) { |
| 131 | + if (operatorSelectMap == null || operatorSelectMap.isEmpty()) return; |
| 132 | + Workflow workflow = baseSession.getWorkflow(); |
| 133 | + IRepositoryHolder holder = baseSession.getRepositoryHolder(); |
| 134 | + operatorSelectMap.forEach((nodeId, operatorIds) -> { |
| 135 | + IFlowNode node = workflow.getFlowNode(nodeId); |
| 136 | + FlowSession nodeSession = baseSession.updateSession(node); |
| 137 | + List<IFlowOperator> range = node.strategyManager().loadOperatorRange(nodeSession); |
| 138 | + if (range != null && !range.isEmpty()) { // 仅在「有范围」时校验 |
| 139 | + Set<Long> allow = range.stream().map(IFlowOperator::getUserId).collect(Collectors.toSet()); |
| 140 | + for (Long id : operatorIds) { |
| 141 | + if (!allow.contains(id)) { |
| 142 | + throw FlowValidationException.operatorOutOfRange(nodeId); |
| 143 | + } |
| 144 | + } |
| 145 | + } |
| 146 | + holder.saveOperatorAssignment(processId, nodeId, operatorIds); |
| 147 | + }); |
| 148 | +} |
| 149 | +``` |
| 150 | + |
| 151 | +接入点: |
| 152 | + |
| 153 | +- `FlowCreateService.create`:将现有 `saveOperatorAssignment` 循环(第 88–96 行)替换为 |
| 154 | + `OperatorAssignmentService.validateAndSave(session, flowRecords.get(0).getProcessId(), request.getOperatorSelectMap());` |
| 155 | +- `FlowActionService.action`:将现有 `saveOperatorAssignment` 循环(第 87–94 行)下移至 `session` 创建(第 96 行)之后、`flowAction.run(session)`(第 100 行)之前,替换为 |
| 156 | + `OperatorAssignmentService.validateAndSave(session, flowRecord.getProcessId(), flowAdvice.getOperatorSelectMap());` |
| 157 | + (`run` 内对下游节点的判断基于 `advice.operatorSelectMap`,不依赖落库顺序;而后续生成下游记录的 `loadOperators` 需在 `run` 前完成落库,故保存放在 `run` 之前即可。) |
| 158 | + |
| 159 | +### 4.6 异常 |
| 160 | + |
| 161 | +`FlowValidationException` 新增工厂方法: |
| 162 | + |
| 163 | +```java |
| 164 | +public static FlowValidationException operatorOutOfRange(String nodeId) { |
| 165 | + return new FlowValidationException("validation.operator.outOfRange", |
| 166 | + String.format("Selected operators for node '%s' exceed the allowed range", nodeId)); |
| 167 | +} |
| 168 | +``` |
| 169 | + |
| 170 | +## 五、数据与兼容性 |
| 171 | + |
| 172 | +- **数据库**:无变更,仍复用 `t_flow_operator_assignment`。 |
| 173 | +- **节点序列化**:`OperatorLoadStrategy` 的 `script` 字段在 `INITIATOR/APPROVER` 模式下新增写出/读取,旧数据无 `script` → `operatorLoadScript` 为 null → 不限范围,向后兼容。 |
| 174 | +- **API**:`NodeOption` 新增 `operators` 字段(增量、向后兼容);前端可据此渲染范围并默认全选。 |
| 175 | +- **工厂方法**:仅新增重载,旧调用不受影响。 |
| 176 | + |
| 177 | +## 六、分步实施计划 |
| 178 | + |
| 179 | +| 步骤 | 内容 | 验证 | |
| 180 | +|------|------|------| |
| 181 | +| Step 1 | `OperatorLoadStrategy`(带脚本工厂重载 + `loadOperatorRange` + `toMap/fromMap`)、`NodeStrategyManager.loadOperatorRange`、`FlowValidationException.operatorOutOfRange` | 编译通过 | |
| 182 | +| Step 2 | `NodeOption` 增加 `operators` 字段与构造;`InitiatorSelectNodeService`、`PassAction.collectApproverSelectNodes` 填充范围 | 编译通过 | |
| 183 | +| Step 3 | 新增 `OperatorAssignmentService.validateAndSave`,接入 `FlowCreateService` / `FlowActionService` | 编译通过 | |
| 184 | +| Step 4 | 扩展 `OperatorSelectTest` 覆盖范围用例,运行 `./mvnw test -pl flow-engine-framework` | 全量测试通过 | |
| 185 | + |
| 186 | +## 七、测试计划(TDD,扩展 `OperatorSelectTest`) |
| 187 | + |
| 188 | +1. 发起人设定 — 带范围 — 选范围内人员 → 流程正常推进。 |
| 189 | +2. 发起人设定 — 带范围 — 选范围外人员 → 抛 `FlowValidationException`(operatorOutOfRange)。 |
| 190 | +3. 发起人设定 — 无范围脚本 → 可选任意人(现有行为不破坏)。 |
| 191 | +4. 发起人设定 — 脚本返回空 → 等同无范围,可选任意人。 |
| 192 | +5. 审批人设定 — 带范围 — 范围内 / 范围外(同 1、2)。 |
| 193 | +6. `OPERATOR_SELECT` 响应中 `NodeOption.operators` 返回的范围正确。 |
| 194 | +7. `OperatorLoadStrategy` 的 `toMap → fromMap` 往返保留范围脚本。 |
| 195 | + |
| 196 | +## 八、影响面 |
| 197 | + |
| 198 | +- 涉及 Maven 模块:`flow-engine-framework`。 |
| 199 | +- 改动文件:`OperatorLoadStrategy`、`NodeStrategyManager`、`NodeOption`、`PassAction`、`InitiatorSelectNodeService`、`FlowCreateService`、`FlowActionService`、`FlowValidationException`,新增 `OperatorAssignmentService`。 |
| 200 | +- `flow-engine-starter-api`:`NodeOption` 序列化结构新增 `operators` 字段(前端适配,向后兼容)。 |
| 201 | + |
| 202 | +## 九、可选增强(待确认,本计划默认不含) |
| 203 | + |
| 204 | +流程预览接口 `processNodes`(`FlowProcessNodeService.addFlowNode`)目前对未分配的 `INITIATOR/APPROVER` 节点返回空 `operators`、仅标记 `operatorStrategy`。若希望前端在「发起前预览」阶段即看到各节点候选范围,可在 `ProcessNode` 增加独立字段 `operatorRange` 并用 `loadOperatorRange` 填充。核心需求(提交时弹窗选人)通过 4.4 的 `OPERATOR_SELECT` 响应已可满足,故此项作为可选增强。 |
0 commit comments