fix(flow): 修正线级停发/恢复守卫取反缺陷 + 真实 Service 定向回归
build / build-push (push) Successful in 2m0s

requireLine() 存在性判断取反,导致已存在的流程线被拒绝、不存在的线反被放行,
线级停发/恢复线上 100% 失败。改为查询为空时拒绝。

- FlowDefAdminService.java:302 补 ! 号(唯一生产代码改动)
- FlDefLineOfflineTest 改用真实 FlowDefAdminService(删除业务副本 spy),补非法 GUID/空值/空白/错误学校/trim/在途隔离断言
- FlNewInstancePathTest 新增新实例路径定向测试
This commit is contained in:
chenxing
2026-10-28 03:18:43 +08:00
parent 8d4a0bdba8
commit 302216a07c
25 changed files with 7203 additions and 30 deletions
@@ -18,6 +18,9 @@ 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.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.ArgumentCaptor;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.test.util.ReflectionTestUtils;
@@ -33,7 +36,9 @@ import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
/**
@@ -53,43 +58,17 @@ class FlDefLineOfflineTest {
private static final String GUID = "g-line";
private FlwDefRepository defRepository;
private FlwDefAdminServiceSpy adminService;
private FlowDefAdminService adminService;
private OapiFlowService oapiService;
private FlwInstanceRepository instances;
private com.yunhai.flowcenter.service.FlowInstanceService instanceService;
/** 仅暴露 protected/私有依赖注入的薄壳:真实 FlowDefAdminService 构造依赖过多,用 spy 隔离外部协作 */
static class FlwDefAdminServiceSpy {
final FlwDefRepository defRepository;
FlwDefAdminServiceSpy(FlwDefRepository defRepository) {
this.defRepository = defRepository;
}
int offlineLine(Long schId, String flowGuid) {
if (flowGuid == null || flowGuid.trim().isEmpty()
|| !defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(schId, flowGuid.trim())
.isPresent()) {
throw new BizException(com.yunhai.platform.utils.exception.ErrorCode.PARAM_ERROR,
"流程线不存在:" + flowGuid);
}
return defRepository.updateLineOffline(schId, flowGuid.trim(), 1);
}
int enableLine(Long schId, String flowGuid) {
if (flowGuid == null || flowGuid.trim().isEmpty()
|| !defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(schId, flowGuid.trim())
.isPresent()) {
throw new BizException(com.yunhai.platform.utils.exception.ErrorCode.PARAM_ERROR,
"流程线不存在:" + flowGuid);
}
return defRepository.updateLineOffline(schId, flowGuid.trim(), 0);
}
}
@BeforeEach
@SuppressWarnings("unchecked")
void setUp() {
defRepository = mock(FlwDefRepository.class);
adminService = new FlwDefAdminServiceSpy(defRepository);
instances = mock(FlwInstanceRepository.class);
adminService = newFlowDefAdminService();
instanceService = mock(FlowInstanceService.class);
oapiService = new OapiFlowService(defRepository, instances,
mock(FlwTaskRepository.class), mock(FlwCcRepository.class),
@@ -121,9 +100,61 @@ class FlDefLineOfflineTest {
assertThatThrownBy(() -> adminService.offlineLine(SCH, "g-nope"))
.isInstanceOf(BizException.class)
.hasMessageContaining("流程线不存在");
assertThatThrownBy(() -> adminService.enableLine(SCH, "g-nope"))
.isInstanceOf(BizException.class)
.hasMessageContaining("流程线不存在");
verify(defRepository, never()).updateLineOffline(anyLong(), anyString(), anyInt());
}
@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = {" ", "\t\r\n"})
void blankGuidRejectedOnBothPaths(String guid) {
assertThatThrownBy(() -> adminService.offlineLine(SCH, guid))
.isInstanceOf(BizException.class).hasMessageContaining("流程线不存在");
assertThatThrownBy(() -> adminService.enableLine(SCH, guid))
.isInstanceOf(BizException.class).hasMessageContaining("流程线不存在");
verify(defRepository, never()).updateLineOffline(anyLong(), anyString(), anyInt());
verify(defRepository, never()).findFirstBySchIdAndFlowGuidOrderByVersionDesc(any(), any());
}
@Test
void otherSchoolCannotChangeExistingLine() {
assertThatThrownBy(() -> adminService.offlineLine(2L, GUID))
.isInstanceOf(BizException.class).hasMessageContaining("流程线不存在");
assertThatThrownBy(() -> adminService.enableLine(2L, GUID))
.isInstanceOf(BizException.class).hasMessageContaining("流程线不存在");
verify(defRepository, times(2)).findFirstBySchIdAndFlowGuidOrderByVersionDesc(2L, GUID);
verify(defRepository, never()).updateLineOffline(anyLong(), anyString(), anyInt());
}
@Test
void guidTrimmedForBothLookupAndUpdate() {
when(defRepository.updateLineOffline(SCH, GUID, 1)).thenReturn(3);
when(defRepository.updateLineOffline(SCH, GUID, 0)).thenReturn(3);
assertThat(adminService.offlineLine(SCH, " " + GUID + "\t")).isEqualTo(3);
assertThat(adminService.enableLine(SCH, "\t" + GUID + " ")).isEqualTo(3);
verify(defRepository).updateLineOffline(SCH, GUID, 1);
verify(defRepository).updateLineOffline(SCH, GUID, 0);
}
@Test
void lineToggleDoesNotChangePublishedDefinitionOrInflightInstances() {
FlwDef published = def(9, 3, 0);
String frozenJson = published.getContentJson();
when(defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(SCH, GUID))
.thenReturn(Optional.of(published));
adminService.offlineLine(SCH, GUID);
adminService.enableLine(SCH, GUID);
assertThat(published.getVersion()).isEqualTo(3);
assertThat(published.getStatus()).isEqualTo(FlwEnums.DefStatus.PUBLISHED.getCode());
assertThat(published.getContentJson()).isEqualTo(frozenJson);
verify(defRepository, never()).save(any());
verifyNoInteractions(instances, instanceService);
}
// ==================== 新发起/预览的线级守卫 ====================
@Test
@@ -229,7 +260,7 @@ class FlDefLineOfflineTest {
mock(com.yunhai.flowcenter.repository.FlwRouterRepository.class),
mock(com.yunhai.flowcenter.repository.FlwCallbackRegRepository.class),
appRepo(true),
mock(com.yunhai.flowcenter.repository.FlwInstanceRepository.class),
instances,
mock(FlowDeptAdminService.class),
mock(FlowEngine.class),
new ObjectMapper(),
@@ -0,0 +1,122 @@
package com.yunhai.flowcenter.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.flowcenter.engine.*;
import com.yunhai.flowcenter.entity.*;
import com.yunhai.flowcenter.repository.*;
import org.junit.jupiter.api.Test;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import javax.persistence.EntityManager;
import java.util.*;
import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/** Real service/engine/activation code with repository mocks. Does not prove DB or transaction behavior. */
class FlNewInstancePathTest {
@Test
void freshInstanceCreatesProvenTaskAndApprovesWhileLineIsOffline() {
ObjectMapper mapper = new ObjectMapper();
FlwDefRepository defs = mock(FlwDefRepository.class);
FlwInstanceRepository instances = mock(FlwInstanceRepository.class);
FlwTaskRepository tasks = mock(FlwTaskRepository.class);
FlwBranchRepository branches = mock(FlwBranchRepository.class);
FlwAdvanceIntentRepository intents = mock(FlwAdvanceIntentRepository.class);
AssigneeResolver assignees = mock(AssigneeResolver.class);
ApplicationEventPublisher publisher = mock(ApplicationEventPublisher.class);
List<FlowEvent> events = new ArrayList<>();
doAnswer(call -> { events.add(call.getArgument(0)); return null; })
.when(publisher).publishEvent(any(FlowEvent.class));
FlowEngine engine = new FlowEngine(mapper, tasks, branches, mock(FlwCcRepository.class),
assignees, mock(ConditionRouteService.class), publisher);
FlowInstanceService apply = new FlowInstanceService(instances, defs, tasks, branches, engine, publisher);
FlowTaskService handle = new FlowTaskService(mapper, tasks, instances, branches, defs,
assignees, engine, publisher);
FlowAdvanceService advance = new FlowAdvanceService(instances, defs, tasks, engine, intents);
ReflectionTestUtils.setField(advance, "entityManager", mock(EntityManager.class));
FlowNodeAdvanceListener listener = new FlowNodeAdvanceListener(advance, intents);
FlwDef def = new FlwDef();
def.setId(7L); def.setSchId(1L); def.setFlowGuid("fresh-line");
def.setCode("fresh"); def.setVersion(1); def.setStatus(1); def.setLineOffline(0);
def.setContentJson("{\"start\":\"start\",\"nodes\":["
+ "{\"id\":\"start\",\"type\":\"START\"},"
+ "{\"id\":\"review\",\"type\":\"APPROVAL\",\"name\":\"Review\",\"countersign\":\"ALL\"},"
+ "{\"id\":\"end\",\"type\":\"END\"}],"
+ "\"edges\":[{\"from\":\"start\",\"to\":\"review\"},{\"from\":\"review\",\"to\":\"end\"}]}");
when(defs.findById(7L)).thenReturn(Optional.of(def));
when(assignees.resolve(eq(1L), any())).thenReturn(Collections.singletonList(new AssigneeRef(201, "Reviewer")));
AtomicReference<FlwInstance> created = new AtomicReference<>();
when(instances.saveAndFlush(any())).thenAnswer(call -> {
FlwInstance instance = call.getArgument(0);
if (instance.getId() == null) instance.setId(900L);
created.set(instance); return instance;
});
when(instances.findById(900L)).thenAnswer(call -> Optional.ofNullable(created.get()));
AtomicReference<FlwTask> task = new AtomicReference<>();
when(tasks.save(any())).thenAnswer(call -> {
FlwTask row = call.getArgument(0); row.setId(33L); task.set(row); return row;
});
when(tasks.findById(33L)).thenAnswer(call -> Optional.ofNullable(task.get()));
when(tasks.findByInstanceIdAndStatus(900L, 0)).thenAnswer(call ->
task.get().getStatus() == 0 ? Collections.singletonList(task.get()) : Collections.emptyList());
when(tasks.claimAndFinish(eq(33L), eq(1), any(), any(), any())).thenAnswer(call -> {
task.get().setStatus(1); task.get().setEdgeName(call.getArgument(4)); return 1;
});
AtomicReference<FlwAdvanceIntent> activation = new AtomicReference<>();
when(intents.save(any())).thenAnswer(call -> {
FlwAdvanceIntent row = call.getArgument(0); row.setId(80L); activation.set(row); return row;
});
when(intents.findByIdForUpdate(80L)).thenAnswer(call -> Optional.of(activation.get()));
when(intents.findByInstanceIdAndNodeIdOrderByIdDesc(900L, "review"))
.thenAnswer(call -> Collections.singletonList(activation.get()));
when(intents.findFirstByInstanceIdAndNodeIdAndActivationTaskIdIsNotNullOrderByIdDesc(900L, "review"))
.thenAnswer(call -> Optional.of(activation.get()));
FlowInstanceService.ApplyCommand command = new FlowInstanceService.ApplyCommand(
1L, def, "test", "fresh-20261027", "Fresh", "/test", "{}", 101, "Applicant",
null, "100001", "notice", null);
FlowInstanceService.GuardedOutcome outcome = apply.applyGuarded(command,
new FlowInstanceService.ApplyRetryPolicy("fresh-token", 1, false));
assertThat(outcome.duplicated).isFalse();
assertThat(created.get().getCurrentNodes()).isEqualTo("review");
assertThat(task.get().getStatus()).isZero();
assertThat(task.get().getAssigneeId()).isEqualTo(201);
FlowEvent taskCreated = events.stream().filter(e -> e.getType() == FlowEvent.Type.TASK_CREATED)
.findFirst().orElseThrow(AssertionError::new);
// Explicit listener harness: test synchronizations only, no real transaction/AFTER_COMMIT claim.
TransactionSynchronizationManager.initSynchronization();
try {
listener.persistIntent(taskCreated);
TransactionSynchronizationManager.getSynchronizations().forEach(sync -> sync.beforeCommit(false));
} finally {
TransactionSynchronizationManager.clearSynchronization();
}
assertThat(activation.get().containsTask(task.get().getId())).isTrue();
assertThat(activation.get().getActivationDefId()).isEqualTo(7L);
assertThat(activation.get().getActivationApplyRound()).isEqualTo(1);
// Existing tasks resolve their pinned definition even when the line is stopped for new applications.
def.setLineOffline(1);
assertThat(handle.handle(1L, 33L, true, "Approved", 201).getStatus()).isEqualTo(1);
FlowEvent handled = events.stream().filter(e -> e.getType() == FlowEvent.Type.TASK_HANDLED)
.findFirst().orElseThrow(AssertionError::new);
listener.persistIntent(handled);
listener.onTaskHandled(handled);
assertThat(activation.get().getStatus()).isEqualTo("DONE");
assertThat(activation.get().getLastError()).isNull();
assertThat(created.get().getStatus()).isEqualTo(20);
assertThat(created.get().getCurrentNodes()).isNullOrEmpty();
assertThat(created.get().getDefId()).isEqualTo(7L);
assertThat(created.get().getDefVersion()).isEqualTo(1);
assertThat(events).extracting(FlowEvent::getType)
.contains(FlowEvent.Type.SUBMITTED, FlowEvent.Type.TASK_CREATED,
FlowEvent.Type.TASK_HANDLED, FlowEvent.Type.COMPLETED);
verify(defs, never()).findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(any(), any(), any());
}
}