Files
flow-center-service/docs/evidence/core-fix-20261027/task-only.patch
T
chenxing 302216a07c
build / build-push (push) Successful in 2m0s
fix(flow): 修正线级停发/恢复守卫取反缺陷 + 真实 Service 定向回归
requireLine() 存在性判断取反,导致已存在的流程线被拒绝、不存在的线反被放行,
线级停发/恢复线上 100% 失败。改为查询为空时拒绝。

- FlowDefAdminService.java:302 补 ! 号(唯一生产代码改动)
- FlDefLineOfflineTest 改用真实 FlowDefAdminService(删除业务副本 spy),补非法 GUID/空值/空白/错误学校/trim/在途隔离断言
- FlNewInstancePathTest 新增新实例路径定向测试
2026-10-28 03:18:43 +08:00

282 lines
16 KiB
Diff

diff --git a/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java b/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java
index da109e3..3837c1a 100644
--- a/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java
+++ b/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java
@@ -299,7 +299,7 @@ public class FlowDefAdminService {
private void requireLine(Long schId, String flowGuid) {
if (flowGuid == null || flowGuid.trim().isEmpty()
- || defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(schId, flowGuid.trim())
+ || !defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(schId, flowGuid.trim())
.isPresent()) {
throw new BizException(ErrorCode.PARAM_ERROR, "流程线不存在:" + flowGuid);
}
diff --git a/src/test/java/com/yunhai/flowcenter/service/FlDefLineOfflineTest.java b/src/test/java/com/yunhai/flowcenter/service/FlDefLineOfflineTest.java
index 76bbf34..3cff9e9 100644
--- a/src/test/java/com/yunhai/flowcenter/service/FlDefLineOfflineTest.java
+++ b/src/test/java/com/yunhai/flowcenter/service/FlDefLineOfflineTest.java
@@ -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(),
diff --git a/src/test/java/com/yunhai/flowcenter/service/FlNewInstancePathTest.java b/src/test/java/com/yunhai/flowcenter/service/FlNewInstancePathTest.java
new file mode 100644
--- /dev/null
+++ b/src/test/java/com/yunhai/flowcenter/service/FlNewInstancePathTest.java
@@ -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());
+ }
+}