From f4121b2c1e2e88dff13c6ccc3295a61bae58df80 Mon Sep 17 00:00:00 2001 From: chenxing Date: Sun, 18 Oct 2026 01:16:48 +0800 Subject: [PATCH] =?UTF-8?q?[ysch-flow]=20S2.7=20apply=20=E5=8F=AF=E9=80=89?= =?UTF-8?q?=E5=9B=9E=E5=B8=A6=E6=9C=9F=E6=9C=9B=E5=AE=9A=E4=B9=89=E7=89=88?= =?UTF-8?q?=E6=9C=AC=EF=BC=88=E7=89=88=E6=9C=AC=E7=AB=9E=E6=80=81=E4=BA=BA?= =?UTF-8?q?=E7=B1=BB=E5=8F=AF=E8=AF=BB=E6=8A=A5=E9=94=99=EF=BC=8C=E2=AD=90?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit preview 回带 defVersion,apply 支持可选 expectedDefVersion:两者不一致时直接业务码拒绝并说 「流程已更新,请刷新后重新选择审批人(期望 x,当前已发布 y)」,而不是让选好的 nodeId/候选 在新版本里对不上、报成「指派节点不存在」(《方案》§8.3 补强 2 / §18 决策 8)。 检查点放在 renderAssignJson 之前,引擎与指派校验都不被触发,不污染幂等键。 opt-in 设计:不传该字段的存量调用方(svc-hall / notice / work-leave)行为逐字不变—— 已由「withoutExpectedVersion_behavesExactlyAsBefore」钉住。 OapiModels 两处为纯新增字段;新增 5 例单测(不传/传对/传旧/preview 回带/无指派节点), mvn -o test -Dtest=!*IT 176 例全绿(原 171 + 新 5)。 --- .../yunhai/flowcenter/model/OapiModels.java | 11 ++ .../flowcenter/service/OapiFlowService.java | 9 ++ .../service/OapiFlowApplyDefVersionTest.java | 153 ++++++++++++++++++ 3 files changed, 173 insertions(+) create mode 100644 src/test/java/com/yunhai/flowcenter/service/OapiFlowApplyDefVersionTest.java diff --git a/src/main/java/com/yunhai/flowcenter/model/OapiModels.java b/src/main/java/com/yunhai/flowcenter/model/OapiModels.java index 4d32f81..52250f8 100644 --- a/src/main/java/com/yunhai/flowcenter/model/OapiModels.java +++ b/src/main/java/com/yunhai/flowcenter/model/OapiModels.java @@ -42,6 +42,12 @@ public final class OapiModels { private Map formData; /** 发起时指派(可选,2026-10-13):[{nodeId, userIds}],校验失败业务码拒绝发起 */ private List assigns; + /** + * 期望的定义版本(可选,2026-10-17 S2.7 版本竞态):调用方拿 apply/preview 时回带的 + * {@code defVersion} 原样传回。不匹配即业务码拒绝,报「流程已更新,请刷新后重新选择审批人」, + * 不再让选人结果落到改版后的线上而报「指派节点不存在」。不传=现状行为逐字不变(存量零影响)。 + */ + private Integer expectedDefVersion; } /** 发起时指派项(apply 可选字段) */ @@ -55,6 +61,11 @@ public final class OapiModels { @Data public static class ApplyPreviewVo { private String flowGuid; + /** + * 本次预览依据的已发布定义版本(2026-10-17 S2.7):调用方在 apply 时以 + * {@code expectedDefVersion} 原样回带,改版即人类可读拒绝而非「指派节点不存在」。 + */ + private Integer defVersion; private List nodes; } diff --git a/src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java b/src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java index f118a55..b3e6846 100644 --- a/src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java +++ b/src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java @@ -120,6 +120,14 @@ public class OapiFlowService { schId, body.getFlowGuid(), FlwEnums.DefStatus.PUBLISHED.getCode()) .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "流程不存在或未发布:" + body.getFlowGuid())); + // S2.7 版本竞态(可选、opt-in):调用方回带预览时的定义版本,改版即人类可读拒绝, + // 免得选好的 nodeId/候选人在新版本里对不上而报「指派节点不存在」。不传=现状逐字不变。 + if (body.getExpectedDefVersion() != null + && !body.getExpectedDefVersion().equals(def.getVersion())) { + throw new BizException(ErrorCode.PARAM_ERROR, + "流程已更新,请刷新后重新选择审批人(期望定义版本 " + body.getExpectedDefVersion() + + ",当前已发布版本 " + def.getVersion() + ")"); + } String assignJson = renderAssignJson(schId, def, body.getAssigns(), body.getSubmitterId()); String cleanAppKey = appKey == null || appKey.trim().isEmpty() ? null : appKey.trim(); FlowInstanceService.ApplyCommand cmd = new FlowInstanceService.ApplyCommand( @@ -253,6 +261,7 @@ public class OapiFlowService { FlowDefModel model = FlowDefModel.parse(objectMapper, def.getContentJson()); OapiModels.ApplyPreviewVo vo = new OapiModels.ApplyPreviewVo(); vo.setFlowGuid(def.getFlowGuid()); + vo.setDefVersion(def.getVersion()); List nodes = new ArrayList<>(); for (FlowDefModel.Node node : model.nodes) { if (!FlowDefModel.TYPE_APPROVAL.equals(node.type) || node.assignPolicy == null) { diff --git a/src/test/java/com/yunhai/flowcenter/service/OapiFlowApplyDefVersionTest.java b/src/test/java/com/yunhai/flowcenter/service/OapiFlowApplyDefVersionTest.java new file mode 100644 index 0000000..24d705e --- /dev/null +++ b/src/test/java/com/yunhai/flowcenter/service/OapiFlowApplyDefVersionTest.java @@ -0,0 +1,153 @@ +package com.yunhai.flowcenter.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.flowcenter.engine.AssigneeResolver; +import com.yunhai.flowcenter.engine.FlowEngine; +import com.yunhai.flowcenter.entity.FlwDef; +import com.yunhai.flowcenter.enums.FlwEnums; +import com.yunhai.flowcenter.model.OapiModels; +import com.yunhai.flowcenter.repository.FlwCallbackRegRepository; +import com.yunhai.flowcenter.repository.FlwCcRepository; +import com.yunhai.flowcenter.repository.FlwDefRepository; +import com.yunhai.flowcenter.repository.FlwInstanceRepository; +import com.yunhai.flowcenter.repository.FlwTaskRepository; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.openapi.base.OpenApiAppClient; +import com.yunhai.platform.openapi.base.OpenApiUserClient; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.ObjectProvider; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * S2.7 版本竞态(⭐):apply 可选回带预览时的定义版本,改版即人类可读拒绝。 + * 三态必测——不传(存量调用方,行为逐字不变)、传对(放行)、传旧(拒绝且不落任何引擎动作)。 + * 另钉住 preview 回带 defVersion(调用方拿得到的前提)。 + */ +@DisplayName("apply 期望定义版本(版本竞态人类可读报错)") +class OapiFlowApplyDefVersionTest { + + private static final Long SCH_ID = 1L; + private static final String FLOW_GUID = "g-assign"; + + private FlwDefRepository defRepository; + private FlowInstanceService instanceService; + private AssigneeResolver assigneeResolver; + private OapiFlowService service; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + defRepository = mock(FlwDefRepository.class); + instanceService = mock(FlowInstanceService.class); + assigneeResolver = mock(AssigneeResolver.class); + service = new OapiFlowService(defRepository, mock(FlwInstanceRepository.class), + mock(FlwTaskRepository.class), mock(FlwCcRepository.class), + mock(FlwCallbackRegRepository.class), instanceService, mock(FlowTaskService.class), + mock(FlowEngine.class), assigneeResolver, new ObjectMapper(), + mock(OpenApiUserClient.class), mock(OpenApiAppClient.class), + mock(DiagramTokenService.class), mock(ObjectProvider.class)); + // 本校校验在单测里关闭(@Value 字段未注入=默认 false),引擎出参给一个既有办件 + when(instanceService.apply(any(FlowInstanceService.ApplyCommand.class))) + .thenReturn(new FlowInstanceService.ApplyOutcome(500L, false)); + } + + private static FlwDef publishedDef(int version) { + FlwDef def = new FlwDef(); + def.setId(9L); + def.setSchId(SCH_ID); + def.setFlowGuid(FLOW_GUID); + def.setVersion(version); + def.setStatus(FlwEnums.DefStatus.PUBLISHED.getCode()); + def.setContentJson("{\"nodes\":[], \"edges\":[], \"start\":\"n1\"}"); + return def; + } + + private static OapiModels.ApplyBody applyBody(Integer expectedDefVersion) { + OapiModels.ApplyBody body = new OapiModels.ApplyBody(); + body.setFlowGuid(FLOW_GUID); + body.setBizType("notice"); + body.setBizId("77"); + body.setBizTitle("标题"); + body.setSubmitterId(21); + body.setSubmitterName("甲"); + body.setExpectedDefVersion(expectedDefVersion); + return body; + } + + private void stubDef(int version) { + when(defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc( + eq(SCH_ID), eq(FLOW_GUID), eq(FlwEnums.DefStatus.PUBLISHED.getCode()))) + .thenReturn(Optional.of(publishedDef(version))); + } + + @Test + void withoutExpectedVersion_behavesExactlyAsBefore() { + stubDef(3); + + OapiModels.ApplyResultVo vo = service.apply(SCH_ID, "100009", applyBody(null)); + + assertEquals(Long.valueOf(500L), vo.getInstanceId()); + verify(instanceService).apply(any(FlowInstanceService.ApplyCommand.class)); + } + + @Test + void matchingVersion_passesThrough() { + stubDef(3); + + OapiModels.ApplyResultVo vo = service.apply(SCH_ID, "100009", applyBody(3)); + + assertEquals(Long.valueOf(500L), vo.getInstanceId()); + verify(instanceService).apply(any(FlowInstanceService.ApplyCommand.class)); + } + + /** 改版后按旧预览结果提交:人类可读文案,且引擎与指派校验都不被触发(不污染幂等键) */ + @Test + void staleExpectedVersion_rejectedWithReadableMessage_beforeAnyEngineAction() { + stubDef(4); + + BizException e = assertThrows(BizException.class, + () -> service.apply(SCH_ID, "100009", applyBody(3))); + + assertTrue(e.getMessage().contains("流程已更新,请刷新后重新选择审批人"), e.getMessage()); + assertTrue(e.getMessage().contains("期望定义版本 3") && e.getMessage().contains("当前已发布版本 4"), + e.getMessage()); + verify(instanceService, never()).apply(any(FlowInstanceService.ApplyCommand.class)); + } + + @Test + void previewCarriesDefVersionSoCallerCanEchoIt() { + stubDef(7); + when(assigneeResolver.resolve(anyLong(), any())).thenReturn(java.util.Collections.emptyList()); + + OapiModels.ApplyPreviewVo vo = service.applyPreview(SCH_ID, FLOW_GUID, 21); + + assertEquals(Integer.valueOf(7), vo.getDefVersion()); + assertEquals(FLOW_GUID, vo.getFlowGuid()); + assertTrue(vo.getNodes() == null || vo.getNodes().isEmpty()); + } + + /** 存量兼容:无指派节点的流程 preview 仍返回空节点集(defVersion 不影响既有消费方) */ + @Test + void previewKeepsEmptyNodesForFlowWithoutAssignPolicy() { + stubDef(1); + + OapiModels.ApplyPreviewVo vo = service.applyPreview(SCH_ID, FLOW_GUID, 21); + + assertEquals(Integer.valueOf(1), vo.getDefVersion()); + assertTrue(vo.getNodes() == null || vo.getNodes().isEmpty()); + } +}