[ysch-flow] S2.7 apply 可选回带期望定义版本(版本竞态人类可读报错,⭐)
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)。
This commit is contained in:
@@ -42,6 +42,12 @@ public final class OapiModels {
|
||||
private Map<String, Object> formData;
|
||||
/** 发起时指派(可选,2026-10-13):[{nodeId, userIds}],校验失败业务码拒绝发起 */
|
||||
private List<AssignItem> 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<PreviewNodeVo> nodes;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<OapiModels.PreviewNodeVo> nodes = new ArrayList<>();
|
||||
for (FlowDefModel.Node node : model.nodes) {
|
||||
if (!FlowDefModel.TYPE_APPROVAL.equals(node.type) || node.assignPolicy == null) {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user