From 8ceb2a42477ca5ac4111a87fcb782e769c365317 Mon Sep 17 00:00:00 2001 From: chenxing Date: Sun, 18 Oct 2026 08:18:26 +0800 Subject: [PATCH] =?UTF-8?q?[audit]=20P6.3=20F5-4=20reroute=20=E5=A2=9E=20u?= =?UTF-8?q?seLatestDef=20=E5=83=B5=E5=B0=B8=E9=80=83=E7=94=9F=E9=97=A8?= =?UTF-8?q?=EF=BC=9A=E6=8C=89=E5=BD=93=E5=89=8D=E5=B7=B2=E5=8F=91=E5=B8=83?= =?UTF-8?q?=E6=9C=80=E9=AB=98=E7=89=88=E6=9C=AC=E9=87=8D=E8=B7=91+?= =?UTF-8?q?=E5=BF=AB=E7=85=A7=E5=88=B7=E6=96=B0+trace=20=E7=95=99=E7=97=95?= =?UTF-8?q?=EF=BC=88=E9=BB=98=E8=AE=A4=20false=20=E7=8E=B0=E8=A1=8C?= =?UTF-8?q?=E4=B8=BA=E9=80=90=E5=AD=97=E4=B8=8D=E5=8F=98=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/api/FlowAdminController.java | 7 +- .../com/yunhai/flowcenter/enums/FlwEnums.java | 2 + .../service/FlowRouterAdminService.java | 62 ++++++- .../FlowRouterAdminServiceRegisterTest.java | 1 + .../FlowRouterAdminServiceRerouteDefTest.java | 172 ++++++++++++++++++ 5 files changed, 240 insertions(+), 4 deletions(-) create mode 100644 src/test/java/com/yunhai/flowcenter/service/FlowRouterAdminServiceRerouteDefTest.java diff --git a/src/main/java/com/yunhai/flowcenter/controller/api/FlowAdminController.java b/src/main/java/com/yunhai/flowcenter/controller/api/FlowAdminController.java index 6836995..5f3ff85 100644 --- a/src/main/java/com/yunhai/flowcenter/controller/api/FlowAdminController.java +++ b/src/main/java/com/yunhai/flowcenter/controller/api/FlowAdminController.java @@ -414,10 +414,13 @@ public class FlowAdminController { /** * 重跑路由(判断失败挂起的恢复入口,2026-09-24 §4.4):重新调判断服务,成功即按出口续走; * 仍失败回业务错(办件继续挂起,route_log 已落新行)。 + * useLatestDef=true(F5-4 僵尸逃生门):按该 flowGuid 当前已发布最高版本重跑并刷新办件定义快照。 */ @PostMapping("/instance/{id}/reroute") - public void instanceReroute(BoaUser user, @PathVariable("id") Long id) { - routerAdminService.reroute(adminGuard.requireAdmin(user, null), id); + public void instanceReroute(BoaUser user, @PathVariable("id") Long id, + @RequestParam(value = "useLatestDef", required = false, defaultValue = "false") + boolean useLatestDef) { + routerAdminService.reroute(adminGuard.requireAdmin(user, null), id, useLatestDef); } /** diff --git a/src/main/java/com/yunhai/flowcenter/enums/FlwEnums.java b/src/main/java/com/yunhai/flowcenter/enums/FlwEnums.java index 85cfa74..da6388c 100644 --- a/src/main/java/com/yunhai/flowcenter/enums/FlwEnums.java +++ b/src/main/java/com/yunhai/flowcenter/enums/FlwEnums.java @@ -48,6 +48,8 @@ public final class FlwEnums { public static final String NODE_RESUBMIT = "resubmit"; /** 留痕事件行保留节点 id:撤回 */ public static final String NODE_WITHDRAW = "withdraw"; + /** 留痕事件行保留节点 id:管理端按新版定义重跑路由(F5-4,nodeName 载明 v旧→v新) */ + public static final String NODE_REROUTE_DEF = "reroute-def"; private final int code; diff --git a/src/main/java/com/yunhai/flowcenter/service/FlowRouterAdminService.java b/src/main/java/com/yunhai/flowcenter/service/FlowRouterAdminService.java index 5c0c428..91c6b07 100644 --- a/src/main/java/com/yunhai/flowcenter/service/FlowRouterAdminService.java +++ b/src/main/java/com/yunhai/flowcenter/service/FlowRouterAdminService.java @@ -33,6 +33,7 @@ public class FlowRouterAdminService { private final FlwRouterRepository routerRepository; private final FlwInstanceRepository instanceRepository; private final FlwDefRepository defRepository; + private final com.yunhai.flowcenter.repository.FlwTaskRepository taskRepository; private final FlowEngine flowEngine; private final ObjectMapper objectMapper; @@ -153,9 +154,23 @@ public class FlowRouterAdminService { /** * 重跑路由(挂起恢复唯一入口,§4.4):办件须在途且挂在 CONDITION 节点(currentNodes 含之、 * 该节点无待办任务)。重新调判断,成功即续走推进;仍失败抛业务错(挂起不变,留痕已落新行)。 + * 默认按办件落库的 def 快照重跑(现行为逐字不变)。 */ @Transactional public void reroute(Long schId, Long instanceId) { + reroute(schId, instanceId, false); + } + + /** + * 重跑路由(F5-4 僵尸逃生门):useLatestDef=true 时 def 改取该 flowGuid 当前已发布最高版本, + * 办件 defId/defCode/defVersion 快照同步刷新(与 R4 重启同语义,只刷定义三列,不动业务快照/ + * 状态/时间),trace 落一行留痕(保留节点 id=reroute-def,nodeName 载明 v旧→v新)。 + * 锚定 128/129 僵尸案例:办件挂在 v1 的 CONDITION 节点、修好的表达式已发布为 v2,按 def 快照 + * 重跑永远打转——此开关让管理员一键切到 v2 续走。在途 CONDITION 节点在新定义不存在时 + * 按人类可读业务错拒绝(不猜测节点映射)。useLatestDef=false 现行为逐字不变。 + */ + @Transactional + public void reroute(Long schId, Long instanceId, boolean useLatestDef) { FlwInstance instance = instanceRepository.findById(instanceId) .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "办件不存在:" + instanceId)); if (!instance.getSchId().equals(schId)) { @@ -168,15 +183,58 @@ public class FlowRouterAdminService { if (def == null) { throw new BizException(ErrorCode.PARAM_ERROR, "办件定义缺失"); } + if (useLatestDef) { + def = refreshToLatestPublishedDef(instance, def); + } String conditionNode = findParkedCondition(instance, def); if (conditionNode == null) { - throw new BizException(ErrorCode.PARAM_ERROR, - "办件未挂在条件分支节点(无挂起的路由判断,无需重跑)"); + throw new BizException(ErrorCode.PARAM_ERROR, useLatestDef + ? "在途条件节点在最新已发布定义中不存在(节点已被改版移除/改型),无法按新定义重跑" + : "办件未挂在条件分支节点(无挂起的路由判断,无需重跑)"); } flowEngine.rerouteCondition(instance, def, conditionNode); instanceRepository.save(instance); } + /** 取 flowGuid 当前已发布最高版本并刷新办件定义快照(仅三列;已是最新则原样返回不落留痕) */ + private FlwDef refreshToLatestPublishedDef(FlwInstance instance, FlwDef current) { + FlwDef latest = defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc( + instance.getSchId(), current.getFlowGuid(), FlwEnums.DefStatus.PUBLISHED.getCode()) + .orElse(null); + if (latest == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "该流程当前无已发布版本,无法按最新定义重跑"); + } + if (latest.getId().equals(instance.getDefId())) { + return latest; + } + instance.setDefId(latest.getId()); + instance.setDefCode(latest.getCode()); + instance.setDefVersion(latest.getVersion()); + appendRerouteDefTrace(instance, current.getVersion(), latest.getVersion()); + return latest; + } + + /** + * 定义切换留痕行(同 FlowInstanceService.appendTraceEvent 形态;彼方法私有且该类冻结,不共用): + * status=6 + 保留节点 id,nodeName 载明版本迁移;不参与引擎任何判断(同 submit/withdraw 行)。 + */ + private void appendRerouteDefTrace(FlwInstance instance, Integer fromVersion, Integer toVersion) { + java.time.LocalDateTime now = java.time.LocalDateTime.now(); + com.yunhai.flowcenter.entity.FlwTask row = new com.yunhai.flowcenter.entity.FlwTask(); + row.setInstanceId(instance.getId()); + row.setSchId(instance.getSchId()); + row.setNodeId(FlwEnums.TaskStatus.NODE_REROUTE_DEF); + row.setNodeName("按新版定义重跑(v" + fromVersion + "→v" + toVersion + ")"); + // assignee=0:管理端动作无自然办理人,落 0 不产生待办(同发起行脏数据兜底口径) + row.setAssigneeId(0); + row.setAssigneeName("管理端"); + row.setStatus(FlwEnums.TaskStatus.SUBMITTED.getCode()); + row.setArriveTime(now); + row.setHandleTime(now); + row.setUrgeCount(0); + taskRepository.save(row); + } + /** 在途节点里找 CONDITION(多个时取第一个;正常挂起只会有一个) */ private String findParkedCondition(FlwInstance instance, FlwDef def) { String cur = instance.getCurrentNodes(); diff --git a/src/test/java/com/yunhai/flowcenter/service/FlowRouterAdminServiceRegisterTest.java b/src/test/java/com/yunhai/flowcenter/service/FlowRouterAdminServiceRegisterTest.java index 7c1f743..8edacd3 100644 --- a/src/test/java/com/yunhai/flowcenter/service/FlowRouterAdminServiceRegisterTest.java +++ b/src/test/java/com/yunhai/flowcenter/service/FlowRouterAdminServiceRegisterTest.java @@ -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)); diff --git a/src/test/java/com/yunhai/flowcenter/service/FlowRouterAdminServiceRerouteDefTest.java b/src/test/java/com/yunhai/flowcenter/service/FlowRouterAdminServiceRerouteDefTest.java new file mode 100644 index 0000000..6f149f6 --- /dev/null +++ b/src/test/java/com/yunhai/flowcenter/service/FlowRouterAdminServiceRerouteDefTest.java @@ -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 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 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 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()); + } +}