[audit] P6.3 F5-4 reroute 增 useLatestDef 僵尸逃生门:按当前已发布最高版本重跑+快照刷新+trace 留痕(默认 false 现行为逐字不变)
build / build-push (push) Successful in 11m48s

This commit is contained in:
chenxing
2026-10-18 08:18:26 +08:00
parent ad87005169
commit 8ceb2a4247
5 changed files with 240 additions and 4 deletions
@@ -40,6 +40,7 @@ class FlowRouterAdminServiceRegisterTest {
service = new FlowRouterAdminService(routerRepository,
mock(com.yunhai.flowcenter.repository.FlwInstanceRepository.class),
mock(com.yunhai.flowcenter.repository.FlwDefRepository.class),
mock(com.yunhai.flowcenter.repository.FlwTaskRepository.class),
mock(com.yunhai.flowcenter.engine.FlowEngine.class),
new com.fasterxml.jackson.databind.ObjectMapper());
when(routerRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
@@ -0,0 +1,172 @@
package com.yunhai.flowcenter.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.flowcenter.entity.FlwDef;
import com.yunhai.flowcenter.entity.FlwInstance;
import com.yunhai.flowcenter.entity.FlwTask;
import com.yunhai.flowcenter.enums.FlwEnums;
import com.yunhai.flowcenter.repository.FlwDefRepository;
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
import com.yunhai.flowcenter.repository.FlwRouterRepository;
import com.yunhai.flowcenter.repository.FlwTaskRepository;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
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.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;
/**
* F5-4 僵尸逃生门(useLatestDef):v1 表达式必失败挂起 → 默认重跑仍按 v1 快照(行为逐字不变);
* useLatestDef=true 改取当前已发布最高版本 v2 → 快照刷新 + trace 留痕 + 引擎按 v2 续走;
* 在途 CONDITION 节点在新定义不存在时报人类可读业务错。锚定 128/129 僵尸案例。
*/
@DisplayName("reroute useLatestDef(F5-4 僵尸逃生门)")
class FlowRouterAdminServiceRerouteDefTest {
private static final Long SCH = 990001L;
private FlwInstanceRepository instanceRepository;
private FlwDefRepository defRepository;
private FlwTaskRepository taskRepository;
private com.yunhai.flowcenter.engine.FlowEngine flowEngine;
private FlowRouterAdminService service;
@BeforeEach
void setUp() {
instanceRepository = mock(FlwInstanceRepository.class);
defRepository = mock(FlwDefRepository.class);
taskRepository = mock(FlwTaskRepository.class);
flowEngine = mock(com.yunhai.flowcenter.engine.FlowEngine.class);
service = new FlowRouterAdminService(mock(FlwRouterRepository.class), instanceRepository,
defRepository, taskRepository, flowEngine, new ObjectMapper());
}
/** v1:CONDITION 节点 n1(办件挂在此);v2:n1 已被移除的改版 */
private static FlwDef def(long id, int version, String contentJson) {
FlwDef def = new FlwDef();
def.setId(id);
def.setSchId(SCH);
def.setFlowGuid("g-zombie");
def.setCode("leave");
def.setVersion(version);
def.setStatus(FlwEnums.DefStatus.PUBLISHED.getCode());
def.setContentJson(contentJson);
return def;
}
private static final String V1 = "{\"nodes\":[{\"id\":\"n1\",\"type\":\"CONDITION\"}],"
+ "\"edges\":[],\"start\":\"n1\"}";
private static final String V2 = "{\"nodes\":[{\"id\":\"n1\",\"type\":\"APPROVAL\"}],"
+ "\"edges\":[],\"start\":\"n1\"}";
private static final String V2_WITH_COND = "{\"nodes\":[{\"id\":\"n1\",\"type\":\"CONDITION\"}],"
+ "\"edges\":[{\"from\":\"n1\",\"to\":\"n2\",\"name\":\"go\"}],\"start\":\"n1\"}";
private FlwInstance parkedInstance(FlwDef def) {
FlwInstance instance = new FlwInstance();
instance.setId(500L);
instance.setSchId(SCH);
instance.setStatus(FlwEnums.InstanceStatus.APPROVING.getCode());
instance.setDefId(def.getId());
instance.setDefCode(def.getCode());
instance.setDefVersion(def.getVersion());
instance.setCurrentNodes("n1");
when(instanceRepository.findById(500L)).thenReturn(Optional.of(instance));
when(defRepository.findById(def.getId())).thenReturn(Optional.of(def));
return instance;
}
@Test
void defaultRerouteUsesDefSnapshotVerbatim() {
FlwDef v1 = def(11L, 1, V1);
FlwInstance instance = parkedInstance(v1);
service.reroute(SCH, 500L);
ArgumentCaptor<FlwDef> defCap = ArgumentCaptor.forClass(FlwDef.class);
verify(flowEngine).rerouteCondition(any(), defCap.capture(), eq("n1"));
assertEquals(11L, defCap.getValue().getId(), "默认重跑按落库 def 快照(v1)");
assertEquals(11L, instance.getDefId(), "默认不刷新定义快照");
verify(taskRepository, never()).save(any());
verify(defRepository, never())
.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(any(), any(), any());
}
@Test
void v1FailureKeepsInstanceHungUnderDefault() {
FlwDef v1 = def(11L, 1, V1);
parkedInstance(v1);
// 引擎按 v1 重跑仍失败(判断服务还是挂)——业务错上抛,办件保持挂起
org.mockito.Mockito.doThrow(new BizException(ErrorCode.PARAM_ERROR, "判断仍失败,办件继续挂起"))
.when(flowEngine).rerouteCondition(any(), eq(v1), eq("n1"));
BizException e = assertThrows(BizException.class, () -> service.reroute(SCH, 500L));
assertTrue(e.getMessage().contains("判断仍失败"), "v1 必失败场景默认重跑仍失败挂起(逃生门入口即为此)");
}
@Test
void useLatestDefSwitchesToV2AndRefreshesSnapshotWithTrace() {
FlwDef v1 = def(11L, 1, V1);
FlwDef v2 = def(12L, 2, V2_WITH_COND);
FlwInstance instance = parkedInstance(v1);
when(defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(
eq(SCH), eq("g-zombie"), eq(FlwEnums.DefStatus.PUBLISHED.getCode())))
.thenReturn(Optional.of(v2));
service.reroute(SCH, 500L, true);
ArgumentCaptor<FlwDef> defCap = ArgumentCaptor.forClass(FlwDef.class);
verify(flowEngine).rerouteCondition(any(), defCap.capture(), eq("n1"));
assertEquals(12L, defCap.getValue().getId(), "useLatestDef=true 引擎按 v2 续走");
assertEquals(12L, instance.getDefId(), "定义快照三列同步刷新(R4 同语义)");
assertEquals(Integer.valueOf(2), instance.getDefVersion());
ArgumentCaptor<FlwTask> trace = ArgumentCaptor.forClass(FlwTask.class);
verify(taskRepository).save(trace.capture());
assertEquals(FlwEnums.TaskStatus.NODE_REROUTE_DEF, trace.getValue().getNodeId(),
"trace 落保留节点 id 的留痕行");
assertTrue(trace.getValue().getNodeName().contains("v1→v2"), "留痕行载明版本迁移");
verify(instanceRepository).save(instance);
}
@Test
void useLatestDefWithSameVersionIsNoopRefresh() {
FlwDef v1 = def(11L, 1, V1);
FlwInstance instance = parkedInstance(v1);
when(defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(
eq(SCH), eq("g-zombie"), eq(FlwEnums.DefStatus.PUBLISHED.getCode())))
.thenReturn(Optional.of(v1));
service.reroute(SCH, 500L, true);
verify(flowEngine).rerouteCondition(any(), eq(v1), eq("n1"));
verify(taskRepository, never()).save(any());
assertEquals(11L, instance.getDefId(), "已是最新版不落留痕行、不刷新");
}
@Test
void useLatestDefRejectsWhenNodeMissingInNewDef() {
FlwDef v1 = def(11L, 1, V1);
// v2 把 n1 改型为 APPROVAL:在途 CONDITION 节点在新定义不存在
FlwDef v2 = def(12L, 2, V2);
parkedInstance(v1);
when(defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(
eq(SCH), eq("g-zombie"), eq(FlwEnums.DefStatus.PUBLISHED.getCode())))
.thenReturn(Optional.of(v2));
BizException e = assertThrows(BizException.class, () -> service.reroute(SCH, 500L, true));
assertTrue(e.getMessage().contains("最新已发布定义中不存在"), "节点在新定义缺失要报人类可读错,不是笼统的无需重跑");
verify(flowEngine, never()).rerouteCondition(any(), any(), any());
}
}