From 1612c161a0ca692ea795e7ea509b587ec6cfe971 Mon Sep 17 00:00:00 2001 From: lorne <1991wangliang@gmail.com> Date: Mon, 31 Aug 2026 12:36:35 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=E7=9B=B8=E5=90=8C=E4=BA=BA=E5=91=98?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E9=80=9A=E8=BF=87=E8=8A=82=E7=82=B9=E7=94=9F?= =?UTF-8?q?=E6=88=90=E5=B7=B2=E5=8A=9E=E7=95=99=E7=97=95=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=EF=BC=88issue=20#226=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../codingapi/flow/node/BaseAuditNode.java | 64 ++- .../flow/pojo/response/FlowContent.java | 4 +- .../service/impl/FlowDelayTriggerService.java | 6 + .../service/impl/FlowProcessNodeService.java | 5 + .../flow/service/impl/FlowRevokeService.java | 41 +- .../impl/FlowSubProcessResultService.java | 6 + .../FlowIssue224SameOperatorAuditTest.java | 26 +- .../FlowIssue226AutoPassRecordTest.java | 421 ++++++++++++++++++ flow-frontend | 2 +- 9 files changed, 553 insertions(+), 22 deletions(-) create mode 100644 flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowIssue226AutoPassRecordTest.java diff --git a/flow-engine-framework/src/main/java/com/codingapi/flow/node/BaseAuditNode.java b/flow-engine-framework/src/main/java/com/codingapi/flow/node/BaseAuditNode.java index 10a15ca1..d6f15e14 100644 --- a/flow-engine-framework/src/main/java/com/codingapi/flow/node/BaseAuditNode.java +++ b/flow-engine-framework/src/main/java/com/codingapi/flow/node/BaseAuditNode.java @@ -1,6 +1,9 @@ package com.codingapi.flow.node; +import com.codingapi.flow.action.ActionType; +import com.codingapi.flow.context.LoopTriggerTraceContext; import com.codingapi.flow.error.ErrorThrow; +import com.codingapi.flow.event.FlowRecordDoneEvent; import com.codingapi.flow.exception.FlowExecutionException; import com.codingapi.flow.exception.FlowValidationException; import com.codingapi.flow.form.FlowForm; @@ -11,6 +14,7 @@ import com.codingapi.flow.record.FlowRecord; import com.codingapi.flow.session.FlowSession; import com.codingapi.flow.strategy.node.MultiOperatorAuditStrategy; +import com.codingapi.springboot.framework.event.EventPusher; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; @@ -180,18 +184,22 @@ private List generateCurrentRecords(FlowSession session, Set List operators = operatorManager.getOperators(); // 提交人与审批人一致时自动审批(issue #224):提交人流转到该节点且审批人含提交人本人时, // 过滤掉与提交人一致的操作员,避免本人审批本人提交的节点(AUTO_PASS 自动通过 / MANUAL_PASS 不跳过)。 - // 守卫条件(currentOperator == submitOperator)用于区分正常流转与加签等"为他人新增记录"的调用路径: - // 加签(AddAuditAction)通过 updateSession(加签目标) 构造会话,currentOperator 为被加签人而非提交人。 + // 守卫条件(currentOperator == submitOperator)用于区分正常流转与加签等"为他人新增记录"的调用路径; + // 动作类型限定为 PASS(issue #226 评审):仅正向通过触发本机制,退回(ReturnAction)、拒绝 + // (RejectAction)、加签(AddAuditAction)等路径保持普通记录生成契约——它们的消费方按"返回的 + // 即当前节点待办记录"处理(resetAddAudit、先清后存等),自动通过的留痕记录落库/事件副作用会破坏其语义。 if (nodeStrategyManager.isSameOperatorAutoPass() && session.getCurrentOperator() != null - && session.getCurrentOperator().getUserId() == session.getSubmitOperatorId()) { + && session.getCurrentOperator().getUserId() == session.getSubmitOperatorId() + && session.getCurrentAction() != null + && ActionType.PASS.name().equalsIgnoreCase(session.getCurrentAction().type())) { long submitOperatorId = session.getSubmitOperatorId(); operators = operators.stream() .filter(operator -> operator.getUserId() != submitOperatorId) .toList(); - // 全部审批人均与提交人一致,当前节点自动通过,继续向后续节点生成记录 + // 全部审批人均与提交人一致,当前节点自动通过:生成留痕记录并继续向后续节点生成记录(issue #226) if (operators.isEmpty()) { - return this.generateNextNodeRecords(session); + return this.autoPassAndGenerateNextNodeRecords(session, records); } } for (int order = 0; order < operators.size(); order++) { @@ -229,6 +237,52 @@ private List generateCurrentRecords(FlowSession session, Set } + /** + * 当前节点自动通过(审批人均为提交人本人且配置相同人员自动审批,issue #226)。 + * + *

自动通过不等于静默跳过:为当前节点生成一条无审批动作的已办记录 + * ({@link FlowRecord#autoDone()},与或签/并签遗留待办的自动办结同一语义, + * 展示层依据 {@link FlowRecord#isAutoDone()} 标记 autoSkip),并立即持久化、 + * 推送已办事件。该记录随后作为记录链上的前驱,继续向后续节点生成记录, + * 保证流程记录与节点展示中保留当前节点的审批痕迹。 + * + *

记录不入调用方返回列表:调用方(如 {@code PassAction})在后续节点触发完成后 + * 才统一保存返回列表,若本记录走同一保存路径,会以运行中状态覆盖结束节点 + * {@code fillNewRecord} 已写入的流程结束状态。 + * + *

已办事件在本节点下游记录流转成功之后推送:下游生成(取审批人、异常跳转等)是 + * 最易抛出异常的环节,先流转后推事件保证抛错时事件尚未派发;推送时刻该记录已被 + * 结束节点 {@code over()} 定型,订阅方拿到的载荷即终态。事件相对调用方 + * {@code FlowRecordDoneEvent(前驱)} 的先后次序不受控,与 + * {@code EndNode#fillNewRecord} 直推 {@code FlowRecordFinishEvent} 属同一引擎既有模式。 + * + * @param session 当前会话(currentNode 为自动通过的节点) + * @param records 当前调用已累计的记录集合 + * @return 本记录不入列,返回后续节点生成的流程记录,可能为空 + */ + private List autoPassAndGenerateNextNodeRecords(FlowSession session, List records) { + FlowRecord currentRecord = session.getCurrentRecord(); + if (currentRecord != null) { + // 被动式环检测:与抄送节点(NotifyNode)同一模式,时间窗口内同一流程实例的 + // 同一节点再次自动通过即判定为自动流转环,在任何留痕落库/事件派发之前终止 + String traceKey = currentRecord.getProcessId() + ":AUTO_PASS:" + this.getId(); + if (LoopTriggerTraceContext.getInstance().trace(traceKey)) { + throw FlowExecutionException.nodeLoopDepthExceeded(session.getWorkflow().getMaxNestDepth()); + } + } + FlowRecord autoPassRecord = new FlowRecord(session.updateSession(session.getCurrentOperator()), 0); + autoPassRecord.cleanAction(); + autoPassRecord.autoDone(); + // 先行持久化:级联直达结束节点时,EndNode.fillNewRecord 按 processId 加载历史并标记完成, + // 必须包含本记录,否则本记录以运行中状态滞留、流程终态不一致 + session.getRepositoryHolder().saveRecord(autoPassRecord); + // 本记录作为后续节点记录的前驱,fromId 链经过当前节点 + session.setCurrentRecord(autoPassRecord); + records.addAll(this.generateNextNodeRecords(session)); + EventPusher.push(new FlowRecordDoneEvent(autoPassRecord, session.isMock())); + return records; + } + /** * 当前节点自动通过(如审批人均为提交人本人且配置相同人员自动审批), * 继续向后续节点生成流程记录。 diff --git a/flow-engine-framework/src/main/java/com/codingapi/flow/pojo/response/FlowContent.java b/flow-engine-framework/src/main/java/com/codingapi/flow/pojo/response/FlowContent.java index 4dafa851..52ae4874 100644 --- a/flow-engine-framework/src/main/java/com/codingapi/flow/pojo/response/FlowContent.java +++ b/flow-engine-framework/src/main/java/com/codingapi/flow/pojo/response/FlowContent.java @@ -202,7 +202,9 @@ public void setOperationAction(Workflow workflow, FlowRecord flowRecord) { this.urge = false; } - if (flowRecord.isDone() && !flowRecord.isFinish()) { + // 自动办结记录(相同人员自动通过 / 或签并签遗留待办的自动已办)未发生真实审批, + // 不暴露撤销/催办入口,与服务端 FlowRevokeService 的 isAutoDone 拦截保持一致(issue #226) + if (flowRecord.isDone() && !flowRecord.isFinish() && !flowRecord.isAutoDone()) { IFlowNode node = workflow.getFlowNode(flowRecord.getNodeId()); this.revoke = node.strategyManager().isEnableRevoke(); this.urge = workflow.strategyManager().isEnableUrge(); diff --git a/flow-engine-framework/src/main/java/com/codingapi/flow/service/impl/FlowDelayTriggerService.java b/flow-engine-framework/src/main/java/com/codingapi/flow/service/impl/FlowDelayTriggerService.java index c481cf40..1afdbb46 100644 --- a/flow-engine-framework/src/main/java/com/codingapi/flow/service/impl/FlowDelayTriggerService.java +++ b/flow-engine-framework/src/main/java/com/codingapi/flow/service/impl/FlowDelayTriggerService.java @@ -3,6 +3,7 @@ import com.codingapi.flow.cache.FlowRuntimeScriptLocalCache; import com.codingapi.flow.action.IFlowAction; +import com.codingapi.flow.action.actions.PassAction; import com.codingapi.flow.cache.FlowOperatorLocalThreadCache; import com.codingapi.flow.domain.DelayTask; import com.codingapi.flow.exception.FlowNotFoundException; @@ -63,6 +64,11 @@ public void trigger() { IFlowOperator submitOperator = flowOperatorGateway.get(flowRecord.getSubmitOperatorId()); IFlowOperator currentOperator = flowOperatorGateway.get(flowRecord.getCurrentOperatorId()); IFlowAction flowAction = currentNode.actionManager().getActionById(flowRecord.getActionId()); + // 延迟任务固化的前驱记录可能为相同人员自动通过的留痕记录(无审批动作,actionId=null,issue #226): + // 以该记录所在审批节点的通过动作兜底恢复流转,避免 NPE 使流程永久停滞在延迟节点 + if (flowAction == null && flowRecord.isAutoDone()) { + flowAction = currentNode.actionManager().getAction(PassAction.class); + } FormData formData = new FormData(workflow.getForm()); formData.reset(flowRecord.getFormData()); diff --git a/flow-engine-framework/src/main/java/com/codingapi/flow/service/impl/FlowProcessNodeService.java b/flow-engine-framework/src/main/java/com/codingapi/flow/service/impl/FlowProcessNodeService.java index 225049f5..97a9eb0a 100644 --- a/flow-engine-framework/src/main/java/com/codingapi/flow/service/impl/FlowProcessNodeService.java +++ b/flow-engine-framework/src/main/java/com/codingapi/flow/service/impl/FlowProcessNodeService.java @@ -548,6 +548,11 @@ private void loadWaitingSubProcessNextData(IFlowOperator currentOperator) { } IFlowNode sourceNode = this.workflow.getFlowNode(parentRecord.getNodeId()); IFlowAction sourceAction = sourceNode.actionManager().getActionById(parentRecord.getActionId()); + // 前驱记录可能为相同人员自动通过的留痕记录(actionId=null,issue #226): + // 预览会话与恢复语义一致,以来源节点通过动作兜底 + if (sourceAction == null && parentRecord.isAutoDone()) { + sourceAction = sourceNode.actionManager().getAction(PassAction.class); + } IFlowNode subProcessNode = this.workflow.getFlowNode(subProcessRecord.getNodeId()); IFlowOperator createOperator = this.loadRecordOperator(parentRecord.getCreateOperatorId()); IFlowOperator submitOperator = this.loadRecordOperator(parentRecord.getSubmitOperatorId()); diff --git a/flow-engine-framework/src/main/java/com/codingapi/flow/service/impl/FlowRevokeService.java b/flow-engine-framework/src/main/java/com/codingapi/flow/service/impl/FlowRevokeService.java index 5e51c4b9..c9e31db0 100644 --- a/flow-engine-framework/src/main/java/com/codingapi/flow/service/impl/FlowRevokeService.java +++ b/flow-engine-framework/src/main/java/com/codingapi/flow/service/impl/FlowRevokeService.java @@ -22,7 +22,9 @@ import com.codingapi.springboot.framework.event.EventPusher; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; +import java.util.Set; /** * 撤销流程服务 @@ -54,6 +56,11 @@ public void revoke() { if (currentRecord.isFinish()) { throw FlowStateException.recordNotSupportRevoke(); } + // 自动办结记录(无审批动作的已办:相同人员自动通过 / 或签并签遗留待办)从未产生过待办, + // 不存在"撤回自己办理的审批"的语义,不支持撤销(issue #226) + if (currentRecord.isAutoDone()) { + throw FlowStateException.recordNotSupportRevoke(); + } boolean waitingSubProcess = repositoryHolder.getSubProcessRepository() .findByParentRecordId(currentRecord.getId()).stream() .anyMatch(SubProcessRecord::isWaiting); @@ -78,11 +85,37 @@ public void revoke() { } List afterRecords = flowRecordService.findFlowRecordAfterRecords(currentRecord.getProcessId(), currentRecord.getId()); - // 退回下级记录, 如果下级记录都完成则不允许退回 + // 退回下级记录, 如果下级记录都完成则不允许退回。 + // 计算有效直接后继时,自动办结记录(isAutoDone:相同人员自动通过的留痕记录)视为透明节点, + // 沿 fromId 链向下穿透到真实后继——否则自动通过留痕会让"下级已办"误判成立, + // 在更下游仍有真实待办时永久阻断撤回(issue #226)。 + // 仅当透明记录确有下游后继时才穿透,或签/并签遗留的 autoDone 记录无后继,保持原判。 if (revokeStrategy.isRemoveNext()) { - List nextRecords = afterRecords.stream() - .filter(flowRecord -> flowRecord.getFromId() == currentRecord.getId()) - .toList(); + List nextRecords = new ArrayList<>(); + Set frontier = new HashSet<>(); + frontier.add(currentRecord.getId()); + while (!frontier.isEmpty()) { + Set currentFrontier = frontier; + List directRecords = afterRecords.stream() + .filter(flowRecord -> currentFrontier.contains(flowRecord.getFromId())) + .toList(); + boolean hasTodo = false; + Set nextFrontier = new HashSet<>(); + for (FlowRecord directRecord : directRecords) { + if (directRecord.isAutoDone()) { + nextFrontier.add(directRecord.getId()); + } else { + nextRecords.add(directRecord); + if (directRecord.isTodo()) { + hasTodo = true; + } + } + } + if (hasTodo) { + break; + } + frontier = nextFrontier; + } boolean nextRecordDone = true; for (FlowRecord nextRecord : nextRecords) { if (nextRecord.isTodo()) { diff --git a/flow-engine-framework/src/main/java/com/codingapi/flow/service/impl/FlowSubProcessResultService.java b/flow-engine-framework/src/main/java/com/codingapi/flow/service/impl/FlowSubProcessResultService.java index 4e8340b6..7d6fce00 100644 --- a/flow-engine-framework/src/main/java/com/codingapi/flow/service/impl/FlowSubProcessResultService.java +++ b/flow-engine-framework/src/main/java/com/codingapi/flow/service/impl/FlowSubProcessResultService.java @@ -2,6 +2,7 @@ import com.codingapi.flow.action.BaseAction; import com.codingapi.flow.action.IFlowAction; +import com.codingapi.flow.action.actions.PassAction; import com.codingapi.flow.cache.FlowRuntimeScriptLocalCache; import com.codingapi.flow.domain.SubProcessContext; import com.codingapi.flow.domain.SubProcessRecord; @@ -101,6 +102,11 @@ private FlowSession createParentSession(SubProcessRecord subProcessRecord) { Workflow workflow = workflowRuntime.toWorkflow(); IFlowNode sourceNode = workflow.getFlowNode(parentRecord.getNodeId()); IFlowAction action = sourceNode.actionManager().getActionById(parentRecord.getActionId()); + // 前驱记录可能为相同人员自动通过的留痕记录(无审批动作,actionId=null,issue #226): + // 恢复下游以来源节点的通过动作兜底,避免空动作会话导致 NPE 使主流程永久停滞 + if (action == null && parentRecord.isAutoDone()) { + action = sourceNode.actionManager().getAction(PassAction.class); + } IFlowOperator currentOperator = repositoryHolder.getOperatorById(parentRecord.getCurrentOperatorId()); IFlowOperator createdOperator = repositoryHolder.getOperatorById(parentRecord.getCreateOperatorId()); IFlowOperator submitOperator = repositoryHolder.getOperatorById(parentRecord.getSubmitOperatorId()); diff --git a/flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowIssue224SameOperatorAuditTest.java b/flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowIssue224SameOperatorAuditTest.java index 462f1fbf..5d17c454 100644 --- a/flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowIssue224SameOperatorAuditTest.java +++ b/flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowIssue224SameOperatorAuditTest.java @@ -40,8 +40,8 @@ *

  • MANUAL_PASS —— 即使提交人与审批人相同,仍需手动审批。
  • * * - *

    场景一:B 节点审批人仅为 a(与提交人一致)+ 相同人员自动审批,a 提交后应自动跳过 - * B 节点,直达 C 节点由 c 审批。 + *

    场景一:B 节点审批人仅为 a(与提交人一致)+ 相同人员自动审批,a 提交后 B 节点自动通过 + * (a 无待办,但保留一条自动通过的已办记录,issue #226),直达 C 节点由 c 审批。 * *

    场景二:B 节点审批人为 a、b(提交人 a 位列首位)+ 相同人员自动审批 + 依次审批, * a 提交后应自动跳过 a 本人,B 节点直接转交 b 审批。 @@ -163,15 +163,15 @@ private List processRecords(FlowRecord anyRecord) { return factory.flowRecordRepository.findProcessRecords(anyRecord.getProcessId()); } - // ==================== 场景一:单审批人 = 提交人,自动通过,跳过 B 节点 ==================== + // ==================== 场景一:单审批人 = 提交人,自动通过(保留已办记录,issue #226) ==================== /** * 场景一:B 节点审批人仅为 a(与发起人一致)+ 相同人员自动审批(AUTO_PASS)。 - *

    a 提交后应自动跳过 B 节点(B 节点不产生记录、a 无待办), + *

    a 提交后 B 节点自动通过:a 无待办,但 B 节点保留一条无审批动作的自动通过已办记录(issue #226), * 直接流转到 C 节点由 c 审批。 */ @Test - void autoPassShouldSkipBNodeWhenOnlyApproverIsInitiator() { + void autoPassShouldKeepDoneRecordAndSkipToDoWhenOnlyApproverIsInitiator() { User a = new User(A, "a"); User c = new User(C, "c"); registerUsers(a, c); @@ -186,23 +186,27 @@ void autoPassShouldSkipBNodeWhenOnlyApproverIsInitiator() { Workflow workflow = saveWorkflow(a, startNode, bNode, cNode, endNode); submitStart(workflow, startNode, a, data()); - // a 本人不应收到 B 节点待办(相同人员自动通过,跳过 B 节点) + // a 本人不应收到 B 节点待办(相同人员自动通过) assertNoTodo(a); // 直接流转到 C 节点:c 收到待办,且待办节点为 C 审批节点 FlowRecord cTodo = todoOf(c); assertEquals(cNode.getId(), cTodo.getNodeId(), "a 提交后应直达 C 节点,待办应落在 C 审批节点"); - // 流程记录:开始 + C 共 2 条,B 节点未产生任何记录(自动通过) + // 流程记录:开始 + B 自动通过 + C 共 3 条(issue #226:自动通过保留已办记录留痕) List records = processRecords(cTodo); - assertEquals(2, records.size(), "B 节点自动通过,流程记录应仅含 开始、C 两条"); - assertEquals(0, records.stream().filter(r -> bNode.getId().equals(r.getNodeId())).count(), - "B 节点不应产生流程记录"); + assertEquals(3, records.size(), "B 节点自动通过,流程记录应含 开始、B、C 三条"); + List bRecords = records.stream() + .filter(r -> bNode.getId().equals(r.getNodeId())) + .toList(); + assertEquals(1, bRecords.size(), "B 节点应保留一条自动通过的流程记录"); + assertTrue(bRecords.get(0).isDone(), "B 节点自动通过记录应为已办状态"); + assertTrue(bRecords.get(0).isAutoDone(), "B 节点记录应为无审批动作的自动办结(autoSkip)"); // c 审批后流程正常结束 pass(cTodo, cNode, c, data()); records = processRecords(cTodo); - assertEquals(2, records.size()); + assertEquals(3, records.size()); assertTrue(records.stream().allMatch(FlowRecord::isFinish), "流程结束后全部记录应为完成状态"); assertNoTodo(c); } diff --git a/flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowIssue226AutoPassRecordTest.java b/flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowIssue226AutoPassRecordTest.java new file mode 100644 index 00000000..b5ac0d11 --- /dev/null +++ b/flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowIssue226AutoPassRecordTest.java @@ -0,0 +1,421 @@ +package com.codingapi.flow.service; + +import com.codingapi.flow.action.IFlowAction; +import com.codingapi.flow.action.actions.PassAction; +import com.codingapi.flow.action.actions.RejectAction; +import com.codingapi.flow.builder.NodeStrategyBuilder; +import com.codingapi.flow.context.GatewayContext; +import com.codingapi.flow.exception.FlowStateException; +import com.codingapi.flow.factory.MyFlowServiceFactory; +import com.codingapi.flow.form.DataType; +import com.codingapi.flow.form.FlowForm; +import com.codingapi.flow.form.FlowFormBuilder; +import com.codingapi.flow.node.IFlowNode; +import com.codingapi.flow.node.nodes.ApprovalNode; +import com.codingapi.flow.node.nodes.EndNode; +import com.codingapi.flow.node.nodes.StartNode; +import com.codingapi.flow.node.nodes.SubProcessNode; +import com.codingapi.flow.pojo.body.FlowAdviceBody; +import com.codingapi.flow.pojo.request.FlowActionRequest; +import com.codingapi.flow.pojo.request.FlowCreateRequest; +import com.codingapi.flow.pojo.request.FlowProcessNodeRequest; +import com.codingapi.flow.pojo.request.FlowRevokeRequest; +import com.codingapi.flow.pojo.response.ProcessNode; +import com.codingapi.flow.record.FlowRecord; +import com.codingapi.flow.script.factory.FlowGroovyScriptFactory; +import com.codingapi.flow.strategy.node.MultiOperatorAuditStrategy; +import com.codingapi.flow.strategy.node.OperatorLoadStrategy; +import com.codingapi.flow.strategy.node.RevokeStrategy; +import com.codingapi.flow.strategy.node.SameOperatorAuditStrategy; +import com.codingapi.flow.strategy.node.SubProcessStrategy; +import com.codingapi.flow.user.User; +import com.codingapi.flow.workflow.Workflow; +import com.codingapi.flow.workflow.WorkflowBuilder; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * issue #226:相同流程审批人自动通过后,B 节点流程记录缺失 + * + *

    流程:A(发起) -> B(审批,审批人 a,依次审批 + 相同人员自动审批) -> C(审批,审批人 c) -> D(结束)。 + * + *

    a 发起流程后,B 节点因审批人与提交人一致被自动通过,流程直达 C 节点由 c 审批。 + * 此时流程记录与节点展示中应能看到 B 节点的自动通过记录(共 3 条记录:A 已办、B 自动通过、C 待办), + * 但当前实现中 B 节点不产生任何记录(见 {@code BaseAuditNode#generateCurrentRecords} 自动通过分支 + * 直接返回 {@code generateNextNodeRecords}),导致节点展示只剩 A-C-D。 + */ +class FlowIssue226AutoPassRecordTest { + + private final MyFlowServiceFactory factory = new MyFlowServiceFactory(); + + // 人员:a=1 发起人(同时是 B 节点审批人),c=3 C 节点审批人 + private static final long A = 1L; + private static final long C = 3L; + + // ==================== 基础工具方法 ==================== + + private void registerUsers(User... users) { + for (User user : users) { + factory.userGateway.save(user); + } + GatewayContext.getInstance().setFlowOperatorGateway(factory.userGateway); + } + + private FlowForm form() { + return FlowFormBuilder.builder() + .name("请假流程") + .code("leave") + .addField("请假人", "name", DataType.STRING) + .addField("请假天数", "days", DataType.INTEGER) + .build(); + } + + private Map data() { + return Map.of("name", "lorne", "days", 1); + } + + private StartNode startNode() { + return StartNode.builder().build(); + } + + /** + * 构建审批节点(脚本动态加载审批人) + */ + private ApprovalNode approvalNode(String name, String operatorIds, + SameOperatorAuditStrategy.Type sameOperatorType, + MultiOperatorAuditStrategy auditStrategy) { + NodeStrategyBuilder strategyBuilder = NodeStrategyBuilder.builder() + .addStrategy(new OperatorLoadStrategy( + FlowGroovyScriptFactory.createOperatorLoadScript("def run(request){return " + operatorIds + "}").getKey())); + if (sameOperatorType != null) { + strategyBuilder.addStrategy(new SameOperatorAuditStrategy(sameOperatorType)); + } + if (auditStrategy != null) { + strategyBuilder.addStrategy(auditStrategy); + } + return ApprovalNode.builder() + .name(name) + .strategies(strategyBuilder.build()) + .build(); + } + + private Workflow saveWorkflow(User creator, IFlowNode... nodes) { + WorkflowBuilder builder = WorkflowBuilder.builder() + .title("审批流程") + .code("leave") + .createdOperator(creator) + .form(form()); + for (IFlowNode node : nodes) { + builder.addNode(node); + } + Workflow workflow = builder.build(); + factory.workflowService.saveWorkflow(workflow); + return workflow; + } + + /** + * 发起人提交开始节点(直接通过),返回 B 自动通过后 c 在 C 节点的待办 + */ + private FlowRecord submitStart(Workflow workflow, StartNode startNode, User user) { + FlowCreateRequest createRequest = new FlowCreateRequest(); + createRequest.setWorkCode(workflow.getCode()); + createRequest.setFormData(data()); + createRequest.setActionId(startNode.actionManager().getActions().get(0).id()); + createRequest.setOperatorId(user.getUserId()); + factory.flowService.create(createRequest); + + FlowRecord todo = todoOf(user); + FlowActionRequest request = new FlowActionRequest(); + request.setFormData(data()); + request.setRecordId(todo.getId()); + request.setAdvice(new FlowAdviceBody(startNode.actionManager().getActions().get(0).id(), "同意", user.getUserId())); + factory.flowService.action(request); + return todo; + } + + private FlowRecord todoOf(User user) { + List list = factory.flowRecordRepository.findTodoByOperator(user.getUserId()); + assertEquals(1, list.size(), user.getName() + " 应有且仅有一条待办"); + return list.get(0); + } + + private List processRecords(FlowRecord anyRecord) { + return factory.flowRecordRepository.findProcessRecords(anyRecord.getProcessId()); + } + + private IFlowAction passAction(IFlowNode node) { + return node.actionManager().getActions().stream() + .filter(action -> "PASS".equals(action.type())) + .findFirst() + .orElseThrow(); + } + + /** + * 以指定动作执行一条待办记录 + */ + private void runAction(FlowRecord todo, IFlowAction action, User operator) { + FlowActionRequest request = new FlowActionRequest(); + request.setFormData(data()); + request.setRecordId(todo.getId()); + request.setAdvice(new FlowAdviceBody(action.id(), "处理", operator.getUserId())); + factory.flowService.action(request); + } + + // ==================== issue #226 场景 ==================== + + /** + * 场景:B 节点审批人仅为 a(与提交人一致)+ 相同人员自动审批(AUTO_PASS)+ 依次审批, + * C 节点审批人为 c。a 发起流程后流程直达 C 节点。 + *

    期望(issue #226): + *

      + *
    • B 节点应产生一条自动通过的流程记录(已办),流程记录共 3 条:A 已办、B 自动通过、C 待办;
    • + *
    • 节点展示可见 A、B、C、D 四个节点,B 节点状态为已通过。
    • + *
    + */ + @Test + void autoPassedNodeShouldKeepFlowRecord() { + User a = new User(A, "a"); + User c = new User(C, "c"); + registerUsers(a, c); + + StartNode startNode = startNode(); + ApprovalNode bNode = approvalNode("B审批", "[1]", + SameOperatorAuditStrategy.Type.AUTO_PASS, + new MultiOperatorAuditStrategy(MultiOperatorAuditStrategy.Type.SEQUENCE, 0)); + ApprovalNode cNode = approvalNode("C审批", "[3]", null, null); + EndNode endNode = EndNode.builder().build(); + + Workflow workflow = saveWorkflow(a, startNode, bNode, cNode, endNode); + submitStart(workflow, startNode, a); + + // a 无待办(B 节点被自动通过),c 在 C 节点收到待办 + assertEquals(0, factory.flowRecordRepository.findTodoByOperator(a.getUserId()).size(), "a 不应有待办"); + FlowRecord cTodo = todoOf(c); + assertEquals(cNode.getId(), cTodo.getNodeId(), "流程应直达 C 节点"); + + // 流程记录应存在 3 条:A 已办、B 自动通过、C 待办 + List records = processRecords(cTodo); + assertEquals(3, records.size(), "流程记录应含 A、B、C 三条,B 节点自动通过后应保留记录"); + + // B 节点应有一条已办记录 + List bRecords = records.stream() + .filter(r -> bNode.getId().equals(r.getNodeId())) + .toList(); + assertEquals(1, bRecords.size(), "B 节点应存在一条自动通过的流程记录"); + assertTrue(bRecords.get(0).isDone(), "B 节点记录应为已办状态"); + + // 节点展示可见 A、B、C、D 四个节点 + List processNodes = factory.flowService.processNodes( + new FlowProcessNodeRequest(cTodo.getId(), c.getUserId(), data())); + assertEquals(4, processNodes.size(), "流程节点展示应含 A、B、C、D 四个节点"); + assertEquals(bNode.getId(), processNodes.get(1).getNodeId(), "第二个节点应为 B 审批节点"); + assertEquals(ProcessNode.ApproveState.PASS, processNodes.get(1).getApproveState(), + "B 节点自动通过后展示状态应为已通过"); + } + + /** + * 边界场景:自动通过节点的下游直接是结束节点(A -> B(自动通过) -> End)。 + *

    a 发起并处理开始节点后,B 节点自动通过并保留已办记录,流程应正常判定结束, + * 全部记录(开始、B)均为完成状态,且无人持有待办。 + */ + @Test + void autoPassDirectBeforeEndNodeShouldFinishFlow() { + User a = new User(A, "a"); + registerUsers(a); + + StartNode startNode = startNode(); + ApprovalNode bNode = approvalNode("B审批", "[1]", + SameOperatorAuditStrategy.Type.AUTO_PASS, null); + EndNode endNode = EndNode.builder().build(); + + Workflow workflow = saveWorkflow(a, startNode, bNode, endNode); + FlowRecord startRecord = submitStart(workflow, startNode, a); + + // 流程无人待办,直接结束 + assertEquals(0, factory.flowRecordRepository.findTodoByOperator(a.getUserId()).size(), "a 不应有待办"); + + // 流程记录:开始 + B 自动通过 共 2 条(结束节点虚拟记录不持久化),全部完成 + List records = processRecords(startRecord); + assertEquals(2, records.size(), "流程记录应含 开始、B 两条"); + assertEquals(1, records.stream().filter(r -> bNode.getId().equals(r.getNodeId())).count(), + "B 节点应保留一条自动通过的流程记录"); + assertTrue(records.stream().allMatch(FlowRecord::isFinish), + "自动通过直达结束节点后,全部流程记录应为完成状态"); + } + + /** + * 回归场景(issue #226 修复 v2):REVOKE_NEXT 撤回时自动通过留痕记录应视为透明节点。 + *

    流程:A(发起,REVOKE_NEXT 撤回) -> B(审批人 a,AUTO_PASS) -> C(审批人 c) -> D(结束)。 + * a 提交后 B 被自动通过、流程停在 C 待办。此时撤回 A 的记录, + * B 的自动通过留痕(已办、无真实审批)不应让"下级已办"误判成立, + * 穿透后应发现 C 仍为待办并放行撤回:a 回到发起待办,c 待办被撤销。 + */ + @Test + void revokeNextThroughAutoPassRecordShouldSucceed() { + User a = new User(A, "a"); + User c = new User(C, "c"); + registerUsers(a, c); + + StartNode startNode = StartNode.builder() + .strategies(NodeStrategyBuilder.builder() + .addStrategy(new RevokeStrategy(true, RevokeStrategy.Type.REVOKE_NEXT)) + .build()) + .build(); + ApprovalNode bNode = approvalNode("B审批", "[1]", + SameOperatorAuditStrategy.Type.AUTO_PASS, null); + ApprovalNode cNode = approvalNode("C审批", "[3]", null, null); + EndNode endNode = EndNode.builder().build(); + + Workflow workflow = saveWorkflow(a, startNode, bNode, cNode, endNode); + FlowRecord startRecord = submitStart(workflow, startNode, a); + + FlowRecord cTodo = todoOf(c); + assertEquals(cNode.getId(), cTodo.getNodeId()); + + // 撤回发起记录:修复前被 B 的自动通过留痕误判为"下级已办"而拒绝,修复后穿透放行 + factory.flowService.revoke(new FlowRevokeRequest(startRecord.getId(), a.getUserId())); + + assertEquals(1, factory.flowRecordRepository.findTodoByOperator(a.getUserId()).size(), + "撤回成功后 a 应回到发起待办"); + assertEquals(0, factory.flowRecordRepository.findTodoByOperator(c.getUserId()).size(), + "撤回应连同下游 C 待办一并撤销"); + } + + /** + * 回归场景(issue #226 修复 v2):自动通过的留痕记录不支持撤销。 + *

    留痕记录从未产生待办、未发生真实审批,不存在"撤回自己办理的审批"语义, + * 对其发起撤销应抛出 FlowStateException(服务端权威拦截,与 FlowContent 入口隐藏一致)。 + */ + @Test + void revokeAutoPassRecordShouldThrow() { + User a = new User(A, "a"); + User c = new User(C, "c"); + registerUsers(a, c); + + StartNode startNode = startNode(); + ApprovalNode bNode = approvalNode("B审批", "[1]", + SameOperatorAuditStrategy.Type.AUTO_PASS, null); + ApprovalNode cNode = approvalNode("C审批", "[3]", null, null); + + Workflow workflow = saveWorkflow(a, startNode, bNode, cNode, EndNode.builder().build()); + FlowRecord startRecord = submitStart(workflow, startNode, a); + FlowRecord cTodo = todoOf(c); + + FlowRecord bRecord = processRecords(cTodo).stream() + .filter(r -> bNode.getId().equals(r.getNodeId())) + .findFirst().orElseThrow(); + assertTrue(bRecord.isAutoDone(), "B 节点记录应为自动办结留痕"); + + assertThrows(FlowStateException.class, + () -> factory.flowService.revoke(new FlowRevokeRequest(bRecord.getId(), a.getUserId())), + "自动通过留痕记录不应支持撤销"); + assertEquals(startRecord.getProcessId(), cTodo.getProcessId()); + } + + /** + * 回归场景(issue #226 修复 v2):自动通过守卫限定为 PASS 动作。 + *

    流程:A(发起) -> D(审批人 a,拒绝动作指向 X) -> X(审批人 a,AUTO_PASS) -> E(结束)。 + * 提交人 a 在 D 节点执行拒绝并回退到 X 节点时,流转动作是 REJECT 而非 PASS, + * X 节点应生成正常待办交提交人重新处理,而不是被静默自动通过 + * (修复前拒绝/退回流转也会命中相同人员自动通过分支,产生错误留痕并继续向下推进)。 + */ + @Test + void rejectToAutoPassNodeBySubmitterShouldCreateNormalTodo() { + User a = new User(A, "a"); + registerUsers(a); + + StartNode startNode = startNode(); + // 先构建 X 以取得节点 id 供拒绝脚本引用 + ApprovalNode xNode = approvalNode("X审批", "[1]", + SameOperatorAuditStrategy.Type.AUTO_PASS, null); + RejectAction rejectAction = RejectAction.defaultAction(); + rejectAction.setScript(FlowGroovyScriptFactory + .createActionRejectScript("def run(request){return '" + xNode.getId() + "'}") + .getKey()); + ApprovalNode dNode = ApprovalNode.builder() + .name("D审批") + .strategies(NodeStrategyBuilder.builder() + .addStrategy(new OperatorLoadStrategy( + FlowGroovyScriptFactory.createOperatorLoadScript("def run(request){return [1]}").getKey())) + .build()) + .actions(List.of(PassAction.defaultAction(), rejectAction)) + .build(); + + Workflow workflow = saveWorkflow(a, startNode, dNode, xNode, EndNode.builder().build()); + submitStart(workflow, startNode, a); + + // D 节点审批人为 a(未开启自动通过),a 在 D 待办上执行拒绝,回退到 X + FlowRecord dTodo = todoOf(a); + assertEquals(dNode.getId(), dTodo.getNodeId()); + runAction(dTodo, rejectAction, a); + + // X 节点应生成真实待办(REJECT 不触发自动通过),而非被静默跳过直达结束 + FlowRecord xTodo = todoOf(a); + assertEquals(xNode.getId(), xTodo.getNodeId(), "拒绝回退到自动通过节点应生成正常待办"); + assertTrue(xTodo.isTodo(), "X 节点记录应为待办状态"); + } + + /** + * 回归场景(issue #226 修复 v2):主流程经自动通过留痕记录进入子流程后, + * 子流程完成时应能以通过动作兜底恢复主流程,而非因留痕记录无审批动作(actionId=null)NPE 停滞。 + *

    主流程:A(发起 a) -> M(审批人 a,AUTO_PASS) -> 子流程(子审批人 c) -> 结束。 + * a 提交后 M 自动通过、子流程创建;c 完成子流程审批后主流程应恢复并结束。 + */ + @Test + void autoPassBeforeSubProcessShouldResumeParentOnChildCompletion() { + User a = new User(A, "a"); + User c = new User(C, "c"); + registerUsers(a, c); + + // 子流程:开始 -> 子审批(c) -> 结束 + StartNode childStart = startNode(); + ApprovalNode childApproval = approvalNode("子审批", "[3]", null, null); + Workflow childWorkflow = WorkflowBuilder.builder() + .title("子流程") + .code("child-leave") + .createdOperator(a) + .form(form()) + .addNode(childStart) + .addNode(childApproval) + .addNode(EndNode.builder().build()) + .build(); + factory.workflowService.saveWorkflow(childWorkflow); + + // 主流程:A -> M(自动通过) -> 子流程 -> 结束 + String createScript = "def run(request){return request.toCreateRequest('child-leave', " + + a.getUserId() + ", '" + passAction(childStart).id() + "', [name:'lorne', days:1])}"; + SubProcessNode subProcessNode = SubProcessNode.builder() + .name("子流程") + .strategies(NodeStrategyBuilder.builder() + .addStrategy(new SubProcessStrategy( + FlowGroovyScriptFactory.createSubProcessScript(createScript).getKey(), true)) + .build()) + .build(); + ApprovalNode mNode = approvalNode("M审批", "[1]", + SameOperatorAuditStrategy.Type.AUTO_PASS, null); + StartNode parentStart = startNode(); + + Workflow workflow = saveWorkflow(a, parentStart, mNode, subProcessNode, EndNode.builder().build()); + FlowRecord startRecord = submitStart(workflow, parentStart, a); + + // M 自动通过后子流程被创建,c 收到子流程审批待办 + FlowRecord childTodo = todoOf(c); + assertEquals(childApproval.getId(), childTodo.getNodeId(), "子流程应已创建并流转至子审批节点"); + + // c 完成子流程:恢复主流程的前驱记录为 M 的自动通过留痕(actionId=null),应以通过动作兜底 + runAction(childTodo, passAction(childApproval), c); + + // 主流程恢复后直达结束:无人持有待办,主流程记录全部完成 + assertEquals(0, factory.flowRecordRepository.findTodoByOperator(a.getUserId()).size(), "a 不应有待办"); + assertEquals(0, factory.flowRecordRepository.findTodoByOperator(c.getUserId()).size(), "c 不应有待办"); + List parentRecords = processRecords(startRecord); + assertTrue(parentRecords.stream().allMatch(FlowRecord::isFinish), + "子流程完成后主流程应恢复并结束,全部主流程记录应为完成状态"); + } +} diff --git a/flow-frontend b/flow-frontend index 073a69bd..2dd1e613 160000 --- a/flow-frontend +++ b/flow-frontend @@ -1 +1 @@ -Subproject commit 073a69bdc31e95759be77d6a4e1d05ed89156faa +Subproject commit 2dd1e613a3572948d5b078be9cd444772f0205ee From 6705690d313eaa5d86b86f5b048c04afdc371bc6 Mon Sep 17 00:00:00 2001 From: lorne <1991wangliang@gmail.com> Date: Mon, 31 Aug 2026 13:33:44 +0800 Subject: [PATCH 2/2] Update flow-frontend to latest main --- flow-frontend | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flow-frontend b/flow-frontend index 2dd1e613..9decfc0a 160000 --- a/flow-frontend +++ b/flow-frontend @@ -1 +1 @@ -Subproject commit 2dd1e613a3572948d5b078be9cd444772f0205ee +Subproject commit 9decfc0abce94d3e1019f62a03b7238459f4f9f6