Skip to content

Commit 1612c16

Browse files
xlorneclaude
andcommitted
fix: 相同人员自动通过节点生成已办留痕记录(issue #226
- BaseAuditNode 自动通过分支不再静默跳过:生成 isAutoDone 留痕记录(先行持久化, fromId 链衔接下游),PASS-only 守卫使拒绝/退回流转恢复生成正常待办, 追加被动式环检测(与抄送节点同模式)防止自动通过循环 - FlowRevokeService:自动办结记录不支持撤销;REVOKE_NEXT 将留痕记录视为透明节点 穿透判定后继,避免更下游仍有待办时永久阻断撤回 - FlowContent 对自动办结记录隐藏撤销/催办入口 - 子流程恢复、延迟触发、等待子流程预览三处对 actionId=null 留痕记录以通过动作兜底,避免 NPE 停滞 - 更新 flow-frontend 子模块指针至 0.2.8 发布提交(配合后端版本一致性,前端无功能变更) - 新增 FlowIssue226AutoPassRecordTest(6 用例:留痕、直达结束、撤回透明化、 撤销拦截、拒绝守卫、子流程恢复),并更新 #224 场景断言 closes #226 Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 59fc0ca commit 1612c16

9 files changed

Lines changed: 553 additions & 22 deletions

File tree

flow-engine-framework/src/main/java/com/codingapi/flow/node/BaseAuditNode.java

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package com.codingapi.flow.node;
22

3+
import com.codingapi.flow.action.ActionType;
4+
import com.codingapi.flow.context.LoopTriggerTraceContext;
35
import com.codingapi.flow.error.ErrorThrow;
6+
import com.codingapi.flow.event.FlowRecordDoneEvent;
47
import com.codingapi.flow.exception.FlowExecutionException;
58
import com.codingapi.flow.exception.FlowValidationException;
69
import com.codingapi.flow.form.FlowForm;
@@ -11,6 +14,7 @@
1114
import com.codingapi.flow.record.FlowRecord;
1215
import com.codingapi.flow.session.FlowSession;
1316
import com.codingapi.flow.strategy.node.MultiOperatorAuditStrategy;
17+
import com.codingapi.springboot.framework.event.EventPusher;
1418
import lombok.Getter;
1519
import lombok.NoArgsConstructor;
1620
import lombok.Setter;
@@ -180,18 +184,22 @@ private List<FlowRecord> generateCurrentRecords(FlowSession session, Set<String>
180184
List<IFlowOperator> operators = operatorManager.getOperators();
181185
// 提交人与审批人一致时自动审批(issue #224):提交人流转到该节点且审批人含提交人本人时,
182186
// 过滤掉与提交人一致的操作员,避免本人审批本人提交的节点(AUTO_PASS 自动通过 / MANUAL_PASS 不跳过)。
183-
// 守卫条件(currentOperator == submitOperator)用于区分正常流转与加签等"为他人新增记录"的调用路径:
184-
// 加签(AddAuditAction)通过 updateSession(加签目标) 构造会话,currentOperator 为被加签人而非提交人。
187+
// 守卫条件(currentOperator == submitOperator)用于区分正常流转与加签等"为他人新增记录"的调用路径;
188+
// 动作类型限定为 PASS(issue #226 评审):仅正向通过触发本机制,退回(ReturnAction)、拒绝
189+
// (RejectAction)、加签(AddAuditAction)等路径保持普通记录生成契约——它们的消费方按"返回的
190+
// 即当前节点待办记录"处理(resetAddAudit、先清后存等),自动通过的留痕记录落库/事件副作用会破坏其语义。
185191
if (nodeStrategyManager.isSameOperatorAutoPass()
186192
&& session.getCurrentOperator() != null
187-
&& session.getCurrentOperator().getUserId() == session.getSubmitOperatorId()) {
193+
&& session.getCurrentOperator().getUserId() == session.getSubmitOperatorId()
194+
&& session.getCurrentAction() != null
195+
&& ActionType.PASS.name().equalsIgnoreCase(session.getCurrentAction().type())) {
188196
long submitOperatorId = session.getSubmitOperatorId();
189197
operators = operators.stream()
190198
.filter(operator -> operator.getUserId() != submitOperatorId)
191199
.toList();
192-
// 全部审批人均与提交人一致,当前节点自动通过,继续向后续节点生成记录
200+
// 全部审批人均与提交人一致,当前节点自动通过:生成留痕记录并继续向后续节点生成记录(issue #226)
193201
if (operators.isEmpty()) {
194-
return this.generateNextNodeRecords(session);
202+
return this.autoPassAndGenerateNextNodeRecords(session, records);
195203
}
196204
}
197205
for (int order = 0; order < operators.size(); order++) {
@@ -229,6 +237,52 @@ private List<FlowRecord> generateCurrentRecords(FlowSession session, Set<String>
229237
}
230238

231239

240+
/**
241+
* 当前节点自动通过(审批人均为提交人本人且配置相同人员自动审批,issue #226)。
242+
*
243+
* <p>自动通过不等于静默跳过:为当前节点生成一条无审批动作的已办记录
244+
* ({@link FlowRecord#autoDone()},与或签/并签遗留待办的自动办结同一语义,
245+
* 展示层依据 {@link FlowRecord#isAutoDone()} 标记 autoSkip),并立即持久化、
246+
* 推送已办事件。该记录随后作为记录链上的前驱,继续向后续节点生成记录,
247+
* 保证流程记录与节点展示中保留当前节点的审批痕迹。
248+
*
249+
* <p>记录不入调用方返回列表:调用方(如 {@code PassAction})在后续节点触发完成后
250+
* 才统一保存返回列表,若本记录走同一保存路径,会以运行中状态覆盖结束节点
251+
* {@code fillNewRecord} 已写入的流程结束状态。
252+
*
253+
* <p>已办事件在本节点下游记录流转成功之后推送:下游生成(取审批人、异常跳转等)是
254+
* 最易抛出异常的环节,先流转后推事件保证抛错时事件尚未派发;推送时刻该记录已被
255+
* 结束节点 {@code over()} 定型,订阅方拿到的载荷即终态。事件相对调用方
256+
* {@code FlowRecordDoneEvent(前驱)} 的先后次序不受控,与
257+
* {@code EndNode#fillNewRecord} 直推 {@code FlowRecordFinishEvent} 属同一引擎既有模式。
258+
*
259+
* @param session 当前会话(currentNode 为自动通过的节点)
260+
* @param records 当前调用已累计的记录集合
261+
* @return 本记录不入列,返回后续节点生成的流程记录,可能为空
262+
*/
263+
private List<FlowRecord> autoPassAndGenerateNextNodeRecords(FlowSession session, List<FlowRecord> records) {
264+
FlowRecord currentRecord = session.getCurrentRecord();
265+
if (currentRecord != null) {
266+
// 被动式环检测:与抄送节点(NotifyNode)同一模式,时间窗口内同一流程实例的
267+
// 同一节点再次自动通过即判定为自动流转环,在任何留痕落库/事件派发之前终止
268+
String traceKey = currentRecord.getProcessId() + ":AUTO_PASS:" + this.getId();
269+
if (LoopTriggerTraceContext.getInstance().trace(traceKey)) {
270+
throw FlowExecutionException.nodeLoopDepthExceeded(session.getWorkflow().getMaxNestDepth());
271+
}
272+
}
273+
FlowRecord autoPassRecord = new FlowRecord(session.updateSession(session.getCurrentOperator()), 0);
274+
autoPassRecord.cleanAction();
275+
autoPassRecord.autoDone();
276+
// 先行持久化:级联直达结束节点时,EndNode.fillNewRecord 按 processId 加载历史并标记完成,
277+
// 必须包含本记录,否则本记录以运行中状态滞留、流程终态不一致
278+
session.getRepositoryHolder().saveRecord(autoPassRecord);
279+
// 本记录作为后续节点记录的前驱,fromId 链经过当前节点
280+
session.setCurrentRecord(autoPassRecord);
281+
records.addAll(this.generateNextNodeRecords(session));
282+
EventPusher.push(new FlowRecordDoneEvent(autoPassRecord, session.isMock()));
283+
return records;
284+
}
285+
232286
/**
233287
* 当前节点自动通过(如审批人均为提交人本人且配置相同人员自动审批),
234288
* 继续向后续节点生成流程记录。

flow-engine-framework/src/main/java/com/codingapi/flow/pojo/response/FlowContent.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,9 @@ public void setOperationAction(Workflow workflow, FlowRecord flowRecord) {
202202
this.urge = false;
203203
}
204204

205-
if (flowRecord.isDone() && !flowRecord.isFinish()) {
205+
// 自动办结记录(相同人员自动通过 / 或签并签遗留待办的自动已办)未发生真实审批,
206+
// 不暴露撤销/催办入口,与服务端 FlowRevokeService 的 isAutoDone 拦截保持一致(issue #226)
207+
if (flowRecord.isDone() && !flowRecord.isFinish() && !flowRecord.isAutoDone()) {
206208
IFlowNode node = workflow.getFlowNode(flowRecord.getNodeId());
207209
this.revoke = node.strategyManager().isEnableRevoke();
208210
this.urge = workflow.strategyManager().isEnableUrge();

flow-engine-framework/src/main/java/com/codingapi/flow/service/impl/FlowDelayTriggerService.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import com.codingapi.flow.cache.FlowRuntimeScriptLocalCache;
44

55
import com.codingapi.flow.action.IFlowAction;
6+
import com.codingapi.flow.action.actions.PassAction;
67
import com.codingapi.flow.cache.FlowOperatorLocalThreadCache;
78
import com.codingapi.flow.domain.DelayTask;
89
import com.codingapi.flow.exception.FlowNotFoundException;
@@ -63,6 +64,11 @@ public void trigger() {
6364
IFlowOperator submitOperator = flowOperatorGateway.get(flowRecord.getSubmitOperatorId());
6465
IFlowOperator currentOperator = flowOperatorGateway.get(flowRecord.getCurrentOperatorId());
6566
IFlowAction flowAction = currentNode.actionManager().getActionById(flowRecord.getActionId());
67+
// 延迟任务固化的前驱记录可能为相同人员自动通过的留痕记录(无审批动作,actionId=null,issue #226):
68+
// 以该记录所在审批节点的通过动作兜底恢复流转,避免 NPE 使流程永久停滞在延迟节点
69+
if (flowAction == null && flowRecord.isAutoDone()) {
70+
flowAction = currentNode.actionManager().getAction(PassAction.class);
71+
}
6672
FormData formData = new FormData(workflow.getForm());
6773
formData.reset(flowRecord.getFormData());
6874

flow-engine-framework/src/main/java/com/codingapi/flow/service/impl/FlowProcessNodeService.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -548,6 +548,11 @@ private void loadWaitingSubProcessNextData(IFlowOperator currentOperator) {
548548
}
549549
IFlowNode sourceNode = this.workflow.getFlowNode(parentRecord.getNodeId());
550550
IFlowAction sourceAction = sourceNode.actionManager().getActionById(parentRecord.getActionId());
551+
// 前驱记录可能为相同人员自动通过的留痕记录(actionId=null,issue #226):
552+
// 预览会话与恢复语义一致,以来源节点通过动作兜底
553+
if (sourceAction == null && parentRecord.isAutoDone()) {
554+
sourceAction = sourceNode.actionManager().getAction(PassAction.class);
555+
}
551556
IFlowNode subProcessNode = this.workflow.getFlowNode(subProcessRecord.getNodeId());
552557
IFlowOperator createOperator = this.loadRecordOperator(parentRecord.getCreateOperatorId());
553558
IFlowOperator submitOperator = this.loadRecordOperator(parentRecord.getSubmitOperatorId());

flow-engine-framework/src/main/java/com/codingapi/flow/service/impl/FlowRevokeService.java

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@
2222
import com.codingapi.springboot.framework.event.EventPusher;
2323

2424
import java.util.ArrayList;
25+
import java.util.HashSet;
2526
import java.util.List;
27+
import java.util.Set;
2628

2729
/**
2830
* 撤销流程服务
@@ -54,6 +56,11 @@ public void revoke() {
5456
if (currentRecord.isFinish()) {
5557
throw FlowStateException.recordNotSupportRevoke();
5658
}
59+
// 自动办结记录(无审批动作的已办:相同人员自动通过 / 或签并签遗留待办)从未产生过待办,
60+
// 不存在"撤回自己办理的审批"的语义,不支持撤销(issue #226)
61+
if (currentRecord.isAutoDone()) {
62+
throw FlowStateException.recordNotSupportRevoke();
63+
}
5764
boolean waitingSubProcess = repositoryHolder.getSubProcessRepository()
5865
.findByParentRecordId(currentRecord.getId()).stream()
5966
.anyMatch(SubProcessRecord::isWaiting);
@@ -78,11 +85,37 @@ public void revoke() {
7885
}
7986

8087
List<FlowRecord> afterRecords = flowRecordService.findFlowRecordAfterRecords(currentRecord.getProcessId(), currentRecord.getId());
81-
// 退回下级记录, 如果下级记录都完成则不允许退回
88+
// 退回下级记录, 如果下级记录都完成则不允许退回。
89+
// 计算有效直接后继时,自动办结记录(isAutoDone:相同人员自动通过的留痕记录)视为透明节点,
90+
// 沿 fromId 链向下穿透到真实后继——否则自动通过留痕会让"下级已办"误判成立,
91+
// 在更下游仍有真实待办时永久阻断撤回(issue #226)。
92+
// 仅当透明记录确有下游后继时才穿透,或签/并签遗留的 autoDone 记录无后继,保持原判。
8293
if (revokeStrategy.isRemoveNext()) {
83-
List<FlowRecord> nextRecords = afterRecords.stream()
84-
.filter(flowRecord -> flowRecord.getFromId() == currentRecord.getId())
85-
.toList();
94+
List<FlowRecord> nextRecords = new ArrayList<>();
95+
Set<Long> frontier = new HashSet<>();
96+
frontier.add(currentRecord.getId());
97+
while (!frontier.isEmpty()) {
98+
Set<Long> currentFrontier = frontier;
99+
List<FlowRecord> directRecords = afterRecords.stream()
100+
.filter(flowRecord -> currentFrontier.contains(flowRecord.getFromId()))
101+
.toList();
102+
boolean hasTodo = false;
103+
Set<Long> nextFrontier = new HashSet<>();
104+
for (FlowRecord directRecord : directRecords) {
105+
if (directRecord.isAutoDone()) {
106+
nextFrontier.add(directRecord.getId());
107+
} else {
108+
nextRecords.add(directRecord);
109+
if (directRecord.isTodo()) {
110+
hasTodo = true;
111+
}
112+
}
113+
}
114+
if (hasTodo) {
115+
break;
116+
}
117+
frontier = nextFrontier;
118+
}
86119
boolean nextRecordDone = true;
87120
for (FlowRecord nextRecord : nextRecords) {
88121
if (nextRecord.isTodo()) {

flow-engine-framework/src/main/java/com/codingapi/flow/service/impl/FlowSubProcessResultService.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import com.codingapi.flow.action.BaseAction;
44
import com.codingapi.flow.action.IFlowAction;
5+
import com.codingapi.flow.action.actions.PassAction;
56
import com.codingapi.flow.cache.FlowRuntimeScriptLocalCache;
67
import com.codingapi.flow.domain.SubProcessContext;
78
import com.codingapi.flow.domain.SubProcessRecord;
@@ -101,6 +102,11 @@ private FlowSession createParentSession(SubProcessRecord subProcessRecord) {
101102
Workflow workflow = workflowRuntime.toWorkflow();
102103
IFlowNode sourceNode = workflow.getFlowNode(parentRecord.getNodeId());
103104
IFlowAction action = sourceNode.actionManager().getActionById(parentRecord.getActionId());
105+
// 前驱记录可能为相同人员自动通过的留痕记录(无审批动作,actionId=null,issue #226):
106+
// 恢复下游以来源节点的通过动作兜底,避免空动作会话导致 NPE 使主流程永久停滞
107+
if (action == null && parentRecord.isAutoDone()) {
108+
action = sourceNode.actionManager().getAction(PassAction.class);
109+
}
104110
IFlowOperator currentOperator = repositoryHolder.getOperatorById(parentRecord.getCurrentOperatorId());
105111
IFlowOperator createdOperator = repositoryHolder.getOperatorById(parentRecord.getCreateOperatorId());
106112
IFlowOperator submitOperator = repositoryHolder.getOperatorById(parentRecord.getSubmitOperatorId());

flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowIssue224SameOperatorAuditTest.java

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,8 @@
4040
* <li>MANUAL_PASS —— 即使提交人与审批人相同,仍需手动审批。</li>
4141
* </ul>
4242
*
43-
* <p>场景一:B 节点审批人仅为 a(与提交人一致)+ 相同人员自动审批,a 提交后应自动跳过
44-
* B 节点,直达 C 节点由 c 审批。
43+
* <p>场景一:B 节点审批人仅为 a(与提交人一致)+ 相同人员自动审批,a 提交后 B 节点自动通过
44+
* (a 无待办,但保留一条自动通过的已办记录,issue #226),直达 C 节点由 c 审批。
4545
*
4646
* <p>场景二:B 节点审批人为 a、b(提交人 a 位列首位)+ 相同人员自动审批 + 依次审批,
4747
* a 提交后应自动跳过 a 本人,B 节点直接转交 b 审批。
@@ -163,15 +163,15 @@ private List<FlowRecord> processRecords(FlowRecord anyRecord) {
163163
return factory.flowRecordRepository.findProcessRecords(anyRecord.getProcessId());
164164
}
165165

166-
// ==================== 场景一:单审批人 = 提交人,自动通过,跳过 B 节点 ====================
166+
// ==================== 场景一:单审批人 = 提交人,自动通过(保留已办记录,issue #226) ====================
167167

168168
/**
169169
* 场景一:B 节点审批人仅为 a(与发起人一致)+ 相同人员自动审批(AUTO_PASS)。
170-
* <p>a 提交后应自动跳过 B 节点(B 节点不产生记录、a 无待办),
170+
* <p>a 提交后 B 节点自动通过:a 无待办,但 B 节点保留一条无审批动作的自动通过已办记录(issue #226),
171171
* 直接流转到 C 节点由 c 审批。
172172
*/
173173
@Test
174-
void autoPassShouldSkipBNodeWhenOnlyApproverIsInitiator() {
174+
void autoPassShouldKeepDoneRecordAndSkipToDoWhenOnlyApproverIsInitiator() {
175175
User a = new User(A, "a");
176176
User c = new User(C, "c");
177177
registerUsers(a, c);
@@ -186,23 +186,27 @@ void autoPassShouldSkipBNodeWhenOnlyApproverIsInitiator() {
186186
Workflow workflow = saveWorkflow(a, startNode, bNode, cNode, endNode);
187187
submitStart(workflow, startNode, a, data());
188188

189-
// a 本人不应收到 B 节点待办(相同人员自动通过,跳过 B 节点
189+
// a 本人不应收到 B 节点待办(相同人员自动通过)
190190
assertNoTodo(a);
191191

192192
// 直接流转到 C 节点:c 收到待办,且待办节点为 C 审批节点
193193
FlowRecord cTodo = todoOf(c);
194194
assertEquals(cNode.getId(), cTodo.getNodeId(), "a 提交后应直达 C 节点,待办应落在 C 审批节点");
195195

196-
// 流程记录:开始 + C 共 2 条,B 节点未产生任何记录(自动通过
196+
// 流程记录:开始 + B 自动通过 + C 共 3 条(issue #226:自动通过保留已办记录留痕
197197
List<FlowRecord> records = processRecords(cTodo);
198-
assertEquals(2, records.size(), "B 节点自动通过,流程记录应仅含 开始、C 两条");
199-
assertEquals(0, records.stream().filter(r -> bNode.getId().equals(r.getNodeId())).count(),
200-
"B 节点不应产生流程记录");
198+
assertEquals(3, records.size(), "B 节点自动通过,流程记录应含 开始、B、C 三条");
199+
List<FlowRecord> bRecords = records.stream()
200+
.filter(r -> bNode.getId().equals(r.getNodeId()))
201+
.toList();
202+
assertEquals(1, bRecords.size(), "B 节点应保留一条自动通过的流程记录");
203+
assertTrue(bRecords.get(0).isDone(), "B 节点自动通过记录应为已办状态");
204+
assertTrue(bRecords.get(0).isAutoDone(), "B 节点记录应为无审批动作的自动办结(autoSkip)");
201205

202206
// c 审批后流程正常结束
203207
pass(cTodo, cNode, c, data());
204208
records = processRecords(cTodo);
205-
assertEquals(2, records.size());
209+
assertEquals(3, records.size());
206210
assertTrue(records.stream().allMatch(FlowRecord::isFinish), "流程结束后全部记录应为完成状态");
207211
assertNoTodo(c);
208212
}

0 commit comments

Comments
 (0)