[audit] P6.3 F5-4 reroute 增 useLatestDef 僵尸逃生门:按当前已发布最高版本重跑+快照刷新+trace 留痕(默认 false 现行为逐字不变)
build / build-push (push) Successful in 11m48s
build / build-push (push) Successful in 11m48s
This commit is contained in:
@@ -414,10 +414,13 @@ public class FlowAdminController {
|
|||||||
/**
|
/**
|
||||||
* 重跑路由(判断失败挂起的恢复入口,2026-09-24 §4.4):重新调判断服务,成功即按出口续走;
|
* 重跑路由(判断失败挂起的恢复入口,2026-09-24 §4.4):重新调判断服务,成功即按出口续走;
|
||||||
* 仍失败回业务错(办件继续挂起,route_log 已落新行)。
|
* 仍失败回业务错(办件继续挂起,route_log 已落新行)。
|
||||||
|
* useLatestDef=true(F5-4 僵尸逃生门):按该 flowGuid 当前已发布最高版本重跑并刷新办件定义快照。
|
||||||
*/
|
*/
|
||||||
@PostMapping("/instance/{id}/reroute")
|
@PostMapping("/instance/{id}/reroute")
|
||||||
public void instanceReroute(BoaUser user, @PathVariable("id") Long id) {
|
public void instanceReroute(BoaUser user, @PathVariable("id") Long id,
|
||||||
routerAdminService.reroute(adminGuard.requireAdmin(user, null), id);
|
@RequestParam(value = "useLatestDef", required = false, defaultValue = "false")
|
||||||
|
boolean useLatestDef) {
|
||||||
|
routerAdminService.reroute(adminGuard.requireAdmin(user, null), id, useLatestDef);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ public final class FlwEnums {
|
|||||||
public static final String NODE_RESUBMIT = "resubmit";
|
public static final String NODE_RESUBMIT = "resubmit";
|
||||||
/** 留痕事件行保留节点 id:撤回 */
|
/** 留痕事件行保留节点 id:撤回 */
|
||||||
public static final String NODE_WITHDRAW = "withdraw";
|
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;
|
private final int code;
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ public class FlowRouterAdminService {
|
|||||||
private final FlwRouterRepository routerRepository;
|
private final FlwRouterRepository routerRepository;
|
||||||
private final FlwInstanceRepository instanceRepository;
|
private final FlwInstanceRepository instanceRepository;
|
||||||
private final FlwDefRepository defRepository;
|
private final FlwDefRepository defRepository;
|
||||||
|
private final com.yunhai.flowcenter.repository.FlwTaskRepository taskRepository;
|
||||||
private final FlowEngine flowEngine;
|
private final FlowEngine flowEngine;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
@@ -153,9 +154,23 @@ public class FlowRouterAdminService {
|
|||||||
/**
|
/**
|
||||||
* 重跑路由(挂起恢复唯一入口,§4.4):办件须在途且挂在 CONDITION 节点(currentNodes 含之、
|
* 重跑路由(挂起恢复唯一入口,§4.4):办件须在途且挂在 CONDITION 节点(currentNodes 含之、
|
||||||
* 该节点无待办任务)。重新调判断,成功即续走推进;仍失败抛业务错(挂起不变,留痕已落新行)。
|
* 该节点无待办任务)。重新调判断,成功即续走推进;仍失败抛业务错(挂起不变,留痕已落新行)。
|
||||||
|
* 默认按办件落库的 def 快照重跑(现行为逐字不变)。
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public void reroute(Long schId, Long instanceId) {
|
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)
|
FlwInstance instance = instanceRepository.findById(instanceId)
|
||||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "办件不存在:" + instanceId));
|
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "办件不存在:" + instanceId));
|
||||||
if (!instance.getSchId().equals(schId)) {
|
if (!instance.getSchId().equals(schId)) {
|
||||||
@@ -168,15 +183,58 @@ public class FlowRouterAdminService {
|
|||||||
if (def == null) {
|
if (def == null) {
|
||||||
throw new BizException(ErrorCode.PARAM_ERROR, "办件定义缺失");
|
throw new BizException(ErrorCode.PARAM_ERROR, "办件定义缺失");
|
||||||
}
|
}
|
||||||
|
if (useLatestDef) {
|
||||||
|
def = refreshToLatestPublishedDef(instance, def);
|
||||||
|
}
|
||||||
String conditionNode = findParkedCondition(instance, def);
|
String conditionNode = findParkedCondition(instance, def);
|
||||||
if (conditionNode == null) {
|
if (conditionNode == null) {
|
||||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
throw new BizException(ErrorCode.PARAM_ERROR, useLatestDef
|
||||||
"办件未挂在条件分支节点(无挂起的路由判断,无需重跑)");
|
? "在途条件节点在最新已发布定义中不存在(节点已被改版移除/改型),无法按新定义重跑"
|
||||||
|
: "办件未挂在条件分支节点(无挂起的路由判断,无需重跑)");
|
||||||
}
|
}
|
||||||
flowEngine.rerouteCondition(instance, def, conditionNode);
|
flowEngine.rerouteCondition(instance, def, conditionNode);
|
||||||
instanceRepository.save(instance);
|
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(多个时取第一个;正常挂起只会有一个) */
|
/** 在途节点里找 CONDITION(多个时取第一个;正常挂起只会有一个) */
|
||||||
private String findParkedCondition(FlwInstance instance, FlwDef def) {
|
private String findParkedCondition(FlwInstance instance, FlwDef def) {
|
||||||
String cur = instance.getCurrentNodes();
|
String cur = instance.getCurrentNodes();
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ class FlowRouterAdminServiceRegisterTest {
|
|||||||
service = new FlowRouterAdminService(routerRepository,
|
service = new FlowRouterAdminService(routerRepository,
|
||||||
mock(com.yunhai.flowcenter.repository.FlwInstanceRepository.class),
|
mock(com.yunhai.flowcenter.repository.FlwInstanceRepository.class),
|
||||||
mock(com.yunhai.flowcenter.repository.FlwDefRepository.class),
|
mock(com.yunhai.flowcenter.repository.FlwDefRepository.class),
|
||||||
|
mock(com.yunhai.flowcenter.repository.FlwTaskRepository.class),
|
||||||
mock(com.yunhai.flowcenter.engine.FlowEngine.class),
|
mock(com.yunhai.flowcenter.engine.FlowEngine.class),
|
||||||
new com.fasterxml.jackson.databind.ObjectMapper());
|
new com.fasterxml.jackson.databind.ObjectMapper());
|
||||||
when(routerRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
when(routerRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|||||||
+172
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user