feat(flow): 回调链路迁平台 flow-callback starter + 表单级流程绑定
build / build-push (push) Failing after 8s

- 回调迁 starter(2026-09-20):删本地实现 FlowCallbackService/Controller/
  FilterConfiguration 及旧测试,X-Flw 验签/幂等/乱序门由 starter 承担,
  业务映射入 SvcFlowCallbackHandler(biz-type svc_item);端点仍 /callback/flow,
  存量注册 callback_url 不变、secret 零轮换,镜像表 flow_callback_reg
  由 sql/20260920_flow_callback_starter_migration.sql 迁移
- 表单级流程绑定:ServiceForm 增 flowGuid,事项未绑流程时回退取绑定表单
  最新版本(effectiveFlowGuid),绑定校验/在线办理口径同步
- FlowReconcileJob/FlowCenterClient/OpenApiAuthEnforceConfiguration 等
  SDK 适配微调;补 .gitignore(target/、日志)
- 全量 mvn test 本地绿(含新增 SvcFlowCallbackHandlerTest/
  ServiceItemEffectiveFlowTest)
This commit is contained in:
chenxing
2026-09-21 09:37:33 +08:00
parent 27e19923fb
commit b08d076f0b
26 changed files with 568 additions and 1052 deletions
@@ -3,7 +3,7 @@ package com.yunhai.svchall.service;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.svchall.entity.ServiceApply;
import com.yunhai.svchall.service.impl.FlowCallbackService;
import com.yunhai.svchall.service.impl.SvcFlowCallbackHandler;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@@ -97,7 +97,7 @@ class ApplyViewGuardTest {
void assigneePasses() {
assignees(30, 31);
assertDoesNotThrow(() -> guard.requireVisible(apply, user(30)));
verify(flowClient).getInstanceByBiz(eq(1L), eq(FlowCallbackService.BIZ_TYPE), eq("42"));
verify(flowClient).getInstanceByBiz(eq(1L), eq(SvcFlowCallbackHandler.BIZ_TYPE), eq("42"));
}
@Test
@@ -1,287 +0,0 @@
package com.yunhai.svchall.service.impl;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.svchall.entity.ServiceApply;
import com.yunhai.svchall.entity.ServiceFlowEvent;
import com.yunhai.svchall.enums.ApplyFlowStatusEnum;
import com.yunhai.svchall.repository.ServiceApplyRepository;
import com.yunhai.svchall.repository.ServiceFlowEventRepository;
import com.yunhai.svchall.repository.ServiceFlowRegRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 回调事件处理单测(对齐 Notice FlowCallbackServiceTest,5xx 语义 D2 核心断言):
* 六事件映射;eventId 重复/并发重复跳过;<b>乱序闸门严格小于(同版本事件不丢)</b>;
* 按 payload.bizId 定位办件(findByIdForUpdate 行锁);毒 JSON/缺字段/非 svc_item/ping 静默;
* DB 异常 → TransientCallbackException(→ controller 回 5xx 触发 flow-center 持续重试)。
*/
class FlowCallbackServiceTest {
private ServiceFlowEventRepository eventRepository;
private ServiceFlowRegRepository regRepository;
private ServiceApplyRepository applyRepository;
private FlowCallbackService service;
private ServiceApply apply;
@BeforeEach
void setUp() {
eventRepository = mock(ServiceFlowEventRepository.class);
regRepository = mock(ServiceFlowRegRepository.class);
applyRepository = mock(ServiceApplyRepository.class);
service = new FlowCallbackService(eventRepository, regRepository, applyRepository, new ObjectMapper());
apply = new ServiceApply();
ReflectionTestUtils.setField(apply, "id", 42);
apply.setSchId(1L);
apply.setItemId(7);
apply.setItemCode("ITEM-1");
apply.setItemTitle("测试事项");
apply.setSubmitterId(9);
apply.setSubmitterName("张三");
apply.setFlowGuid("fg-1");
apply.setFlowStatus(ApplyFlowStatusEnum.APPROVING.getCode());
apply.setLastInstanceVersion(1);
when(applyRepository.findByIdForUpdate(42)).thenReturn(Optional.of(apply));
}
// ===== 六事件映射 =====
@Test
void submitted_taskCreated_taskHandled_mapToApproving() {
for (String event : new String[]{"flow.submitted", "flow.task.created", "flow.task.handled"}) {
ServiceApply a = freshApply();
service.process(body(event, 2));
assertEquals(ApplyFlowStatusEnum.APPROVING.getCode(), a.getFlowStatus(), event);
assertNull(a.getFinishTime(), event + " 非终态不应置 finishTime");
}
}
@Test
void completed_mapsToCompletedAndStampsFinishTime() {
service.process(body("flow.completed", 4));
assertEquals(ApplyFlowStatusEnum.COMPLETED.getCode(), apply.getFlowStatus());
assertNotNull(apply.getFinishTime());
}
@Test
void rejected_mapsToRejectedAndStampsFinishTime() {
service.process(body("flow.rejected", 4));
assertEquals(ApplyFlowStatusEnum.REJECTED.getCode(), apply.getFlowStatus());
assertNotNull(apply.getFinishTime());
}
@Test
void withdrawn_mapsToWithdrawnAndStampsFinishTime() {
service.process(body("flow.withdrawn", 4));
assertEquals(ApplyFlowStatusEnum.WITHDRAWN.getCode(), apply.getFlowStatus());
assertNotNull(apply.getFinishTime());
}
// ===== 乱序闸门:严格小于(<),同版本不丢 =====
@Test
void sameVersionEventIsProcessed_notDiscarded() {
// flow-center 实测同版本多事件(submitted+task.created 同 v1)——旧 <= 闸门会误杀
apply.setLastInstanceVersion(1);
service.process(body("flow.task.created", 1));
assertEquals(ApplyFlowStatusEnum.APPROVING.getCode(), apply.getFlowStatus());
verify(applyRepository).save(apply);
}
@Test
void olderVersionEventIsDiscarded() {
apply.setLastInstanceVersion(3);
service.process(body("flow.completed", 2));
assertEquals(ApplyFlowStatusEnum.APPROVING.getCode(), apply.getFlowStatus());
verify(applyRepository, never()).save(any());
}
@Test
void missingVersionIsProcessedWithoutInventingVersion() {
// version 缺失放行推进,但不伪造 last+1
service.process(bodyWithout("instanceVersion"));
verify(applyRepository).save(apply);
assertEquals(Integer.valueOf(1), apply.getLastInstanceVersion());
}
// ===== eventId 去重(两道:先查后落,uk 兜底并发)=====
@Test
void duplicateEventIdIsSkipped() {
when(eventRepository.existsByEventId("e-1")).thenReturn(true);
service.process(body("flow.completed", 4));
verify(applyRepository, never()).save(any());
assertEquals(ApplyFlowStatusEnum.APPROVING.getCode(), apply.getFlowStatus());
}
@Test
void concurrentDuplicateViaUniqueConstraintIsSkipped() {
doThrow(new DataIntegrityViolationException("uk")).when(eventRepository).saveAndFlush(any(ServiceFlowEvent.class));
service.process(body("flow.completed", 4));
verify(applyRepository, never()).save(any());
assertEquals(ApplyFlowStatusEnum.APPROVING.getCode(), apply.getFlowStatus());
}
// ===== 定位:按 payload.bizId + 行锁;bizType 守卫 =====
@Test
void locateByBizIdWithPessimisticLock() {
service.process(body("flow.completed", 4));
verify(applyRepository).findByIdForUpdate(42);
verify(applyRepository, never()).findBySchIdAndFlowInstanceId(any(), any());
}
@Test
void nonSvcItemBizTypeIsSilentlyDropped() {
service.process(body("flow.completed", 4).replace("\"svc_item\"", "\"notice_item\""));
verify(applyRepository, never()).findByIdForUpdate(any());
verify(applyRepository, never()).save(any());
}
@Test
void unknownBizIdIsSilentlyDropped() {
when(applyRepository.findByIdForUpdate(42)).thenReturn(Optional.empty());
service.process(body("flow.completed", 4));
verify(applyRepository, never()).save(any());
}
@Test
void nonNumericBizIdIsSilentlyDropped() {
service.process(body("flow.completed", 4).replace("\"bizId\":\"42\"", "\"bizId\":\"abc\""));
verify(applyRepository, never()).findByIdForUpdate(any());
}
// ===== 毒消息/未知事件/ping:静默(协议层不可重试,防无限重放)=====
@Test
void poisonJsonIsSilentlyIgnored() {
service.process("not-json{{");
verify(applyRepository, never()).save(any());
}
@Test
void missingRequiredFieldIsSilentlyIgnored() {
service.process(body("flow.completed", 4).replace("\"bizId\":\"42\",", ""));
verify(applyRepository, never()).save(any());
}
@Test
void unknownEventTypeIsSilentlyIgnored() {
service.process(body("flow.handed", 2));
verify(applyRepository, never()).save(any());
}
@Test
void pingEventIsSilentlyIgnored() {
// 过滤器已直通 200,这里兜底防穿透
service.process(body("ping", 0));
verify(applyRepository, never()).findByIdForUpdate(any());
}
// ===== 5xx 语义:DB 瞬时失败 → TransientCallbackException =====
@Test
void transientDbFailureThrowsTransientCallbackException() {
doThrow(new RuntimeException("connection reset")).when(applyRepository).save(any(ServiceApply.class));
assertThrows(FlowCallbackService.TransientCallbackException.class,
() -> service.process(body("flow.completed", 4)));
}
// ===== 终态明细快照 =====
@Test
void terminalEventWritesResultSnapshot() {
service.process(body("flow.completed", 4));
assertNotNull(apply.getFlowResultJson());
assertEquals(ApplyFlowStatusEnum.COMPLETED.getCode(), apply.getFlowStatus());
}
// ===== P1-3 终态不回退守卫:同版本事件完成顺序倒置 =====
@Test
void terminalRow_rejectsNonTerminalEvent_sameVersionInversion() {
// completed(v4) 先落库(20 + result),迟到的 task.handled(v4) 不得打回 10、不得覆盖结果
service.process(body("e-a", "flow.completed", 4));
assertEquals(ApplyFlowStatusEnum.COMPLETED.getCode(), apply.getFlowStatus());
String frozen = apply.getFlowResultJson();
Mockito.clearInvocations(applyRepository);
service.process(body("e-b", "flow.task.handled", 4)); // 不抛 → 200
assertEquals(ApplyFlowStatusEnum.COMPLETED.getCode(), apply.getFlowStatus(), "终态行拒绝非终态事件");
assertEquals(frozen, apply.getFlowResultJson(), "已落定 flow_result_json 不得被 handled 明细覆盖");
verify(applyRepository, never()).save(any());
}
@Test
void terminalRow_rejectsNonTerminalEvent_higherVersion() {
service.process(body("e-c", "flow.rejected", 4));
assertEquals(ApplyFlowStatusEnum.REJECTED.getCode(), apply.getFlowStatus());
service.process(body("e-d", "flow.task.handled", 5));
assertEquals(ApplyFlowStatusEnum.REJECTED.getCode(), apply.getFlowStatus(),
"终态后任何非终态事件都不回退(重提走 resubmit 先置 10,不冲突)");
}
@Test
void rejectedTerminal_frozenResultNotOverwritten_byOtherTerminal() {
service.process(body("e-e", "flow.rejected", 4));
assertEquals(ApplyFlowStatusEnum.REJECTED.getCode(), apply.getFlowStatus());
String frozen = apply.getFlowResultJson();
service.process(body("e-f", "flow.withdrawn", 4));
assertEquals(ApplyFlowStatusEnum.REJECTED.getCode(), apply.getFlowStatus());
assertEquals(frozen, apply.getFlowResultJson(), "已终态不被另一终态改写");
}
// ===== helpers =====
private ServiceApply freshApply() {
ServiceApply a = new ServiceApply();
ReflectionTestUtils.setField(a, "id", 42);
a.setSchId(1L);
a.setItemId(7);
a.setItemCode("ITEM-1");
a.setItemTitle("测试事项");
a.setSubmitterId(9);
a.setSubmitterName("张三");
a.setFlowGuid("fg-1");
a.setFlowStatus(ApplyFlowStatusEnum.APPROVING.getCode());
a.setLastInstanceVersion(1);
when(applyRepository.findByIdForUpdate(42)).thenReturn(Optional.of(a));
return a;
}
private String body(String event, Integer version) {
return body("e-1", event, version);
}
private String body(String eventId, String event, Integer version) {
return "{\"eventId\":\"" + eventId + "\",\"event\":\"" + event + "\",\"schId\":1,\"instanceId\":9001,"
+ "\"bizType\":\"svc_item\",\"bizId\":\"42\",\"flowGuid\":\"fg-1\","
+ "\"instanceVersion\":" + version + ","
+ "\"data\":{\"result\":\"同意\",\"opinion\":\"情况属实\"}}";
}
private String bodyWithout(String field) {
String b = body("flow.task.created", 2);
String needle = "\"" + field + "\":2,";
assertEquals(true, b.contains(needle), "测试体应包含待删字段 " + needle);
return b.replace(needle, "");
}
}
@@ -7,6 +7,7 @@ import com.yunhai.svchall.model.svc.SvcModels.SvcFormSaveRequest;
import com.yunhai.svchall.model.svc.SvcModels.SvcFormVo;
import com.yunhai.svchall.repository.ServiceFormRepository;
import com.yunhai.svchall.repository.ServiceItemRepository;
import com.yunhai.svchall.service.FlowClient;
import com.yunhai.svchall.service.SiteDirectory;
import com.yunhai.svchall.service.SiteGuard;
import org.junit.jupiter.api.BeforeEach;
@@ -43,7 +44,10 @@ class ServiceFormServiceTest {
itemRepository = mock(ServiceItemRepository.class);
SiteGuard siteGuard = mock(SiteGuard.class);
when(siteGuard.requireSiteManager(null, "S1")).thenReturn(site);
service = new ServiceFormService(formRepository, itemRepository, siteGuard);
FlowClient flowClient = mock(FlowClient.class);
when(flowClient.enabled()).thenReturn(false);
service = new ServiceFormService(formRepository, itemRepository, siteGuard,
flowClient, mock(com.yunhai.platform.flowcallback.FlowCallbackRegistrar.class));
}
private ServiceForm form(String code, int version, int status) {
@@ -0,0 +1,80 @@
package com.yunhai.svchall.service.impl;
import com.yunhai.platform.flowcallback.FlowCallbackRegistrar;
import com.yunhai.svchall.entity.ServiceForm;
import com.yunhai.svchall.repository.ServiceFormRepository;
import com.yunhai.svchall.repository.ServiceItemRepository;
import com.yunhai.svchall.service.DeptSource;
import com.yunhai.svchall.service.FlowClient;
import com.yunhai.svchall.service.SiteDirectory;
import com.yunhai.svchall.service.SiteGuard;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.transaction.support.TransactionTemplate;
import java.util.Collections;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* 表单级绑定生效流程解析单测(2026-09-20):事项自身绑定优先;为空回退绑定表单
* 最新版本的 flow_guid(草稿也算——版本行携带绑定,发布后自然生效);两侧都空=null。
*/
class ServiceItemEffectiveFlowTest {
private ServiceFormRepository formRepository;
private ServiceItemService service;
@BeforeEach
void setUp() {
formRepository = mock(ServiceFormRepository.class);
service = new ServiceItemService(
mock(ServiceItemRepository.class), formRepository,
mock(SiteDirectory.class), mock(ServiceDictService.class), mock(ServiceFormService.class),
mock(AttachmentService.class), mock(SiteGuard.class), mock(DeptSource.class),
mock(FlowClient.class), mock(FlowCallbackRegistrar.class), mock(TransactionTemplate.class));
when(formRepository.findBySchIdAndCodeOrderByVersionDesc(anyLong(), anyString()))
.thenReturn(Collections.emptyList());
}
private ServiceForm form(String flowGuid, Integer version) {
ServiceForm f = new ServiceForm();
f.setSchId(1L);
f.setCode("F1");
f.setVersion(version);
f.setFlowGuid(flowGuid);
return f;
}
@Test
void itemBindingWins() {
assertEquals("item-flow", service.effectiveFlowGuid(1L, "item-flow", "F1"));
}
@Test
void nullItemBindingFallsBackToLatestFormVersion() {
when(formRepository.findBySchIdAndCodeOrderByVersionDesc(1L, "F1"))
.thenReturn(java.util.Arrays.asList(form("v3-flow", 3), form("v2-flow", 2)));
assertEquals("v3-flow", service.effectiveFlowGuid(1L, null, "F1"),
"回退取最新版本行的绑定");
}
@Test
void noFormOrUnboundForm_yieldsNull() {
assertNull(service.effectiveFlowGuid(1L, null, null), "没绑表单且事项未绑 → 不可在线办理");
when(formRepository.findBySchIdAndCodeOrderByVersionDesc(1L, "F1"))
.thenReturn(Collections.singletonList(form(null, 1)));
assertNull(service.effectiveFlowGuid(1L, null, "F1"), "表单也没绑 → null");
}
@Test
void emptyStringsTreatedAsUnbound() {
assertNull(service.effectiveFlowGuid(1L, "", ""));
}
}
@@ -1,7 +1,6 @@
package com.yunhai.svchall.service.impl;
import com.yunhai.svchall.entity.ServiceFlowReg;
import com.yunhai.svchall.repository.ServiceFlowRegRepository;
import com.yunhai.platform.flowcallback.FlowCallbackRegistrar;
import com.yunhai.svchall.repository.ServiceFormRepository;
import com.yunhai.svchall.repository.ServiceItemRepository;
import com.yunhai.svchall.service.DeptSource;
@@ -10,117 +9,59 @@ import com.yunhai.svchall.service.SiteDirectory;
import com.yunhai.svchall.service.SiteGuard;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.support.TransactionTemplate;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 回调注册防轮换单测(契约冻结:flow-center 重复注册会轮换 secret,导致已存密钥验签失配):
* 仅 reg 缺失/失效(secret 空或 status≠0)时才(重)注册;有效注册绝不重复调用;
* 注册失败 best-effort 不阻断事项保存。
* 回调注册联动单测(2026-09-20 迁 starter Registrar 后改版):
* "仅有效注册才不重注册"的防轮换语义已收进 starter FlowCallbackRegistrar(其侧测试覆盖),
* 本处只验联动开关——绑定 flowGuid 即委托 Registrar(Long→Integer schId 适配)、
* 未绑/流程中心停用不委托、注册失败 best-effort 不阻断保存。
*/
class ServiceItemFlowRegTest {
private ServiceFlowRegRepository flowRegRepository;
private FlowClient flowClient;
private FlowCallbackRegistrar registrar;
private ServiceItemService service;
private SiteDirectory.SiteInfo site = new SiteDirectory.SiteInfo("S1", "站点一", 1L);
@BeforeEach
void setUp() {
flowRegRepository = mock(ServiceFlowRegRepository.class);
flowClient = mock(FlowClient.class);
registrar = mock(FlowCallbackRegistrar.class);
service = new ServiceItemService(
mock(ServiceItemRepository.class), mock(ServiceFormRepository.class), flowRegRepository,
mock(ServiceItemRepository.class), mock(ServiceFormRepository.class),
mock(SiteDirectory.class), mock(ServiceDictService.class), mock(ServiceFormService.class),
mock(AttachmentService.class), mock(SiteGuard.class), mock(DeptSource.class),
flowClient, mock(TransactionTemplate.class));
ReflectionTestUtils.setField(service, "callbackPublicBase", "http://localhost:8086");
flowClient, registrar, mock(TransactionTemplate.class));
when(flowClient.enabled()).thenReturn(true);
}
private ServiceFlowReg reg(Integer status, String secret) {
ServiceFlowReg reg = new ServiceFlowReg();
reg.setSchId(1L);
reg.setFlowGuid("fg-1");
reg.setStatus(status);
reg.setSecret(secret);
return reg;
}
@Test
void validRegistrationIsNeverRepeated() {
when(flowRegRepository.findBySchIdAndFlowGuid(1L, "fg-1"))
.thenReturn(Optional.of(reg(0, "stored-secret")));
void boundFlow_delegatesToRegistrar() {
service.ensureFlowReg(site, "fg-1");
verify(flowClient, never()).registerCallback(any(), anyString(), anyString(), any());
verify(registrar).ensureRegistered(1, "fg-1");
}
@Test
void missingRegistrationIsCreated() {
when(flowRegRepository.findBySchIdAndFlowGuid(1L, "fg-1")).thenReturn(Optional.empty());
when(flowClient.registerCallback(eq(1L), eq("fg-1"),
eq("http://localhost:8086/callback/flow"), isNull())).thenReturn("issued-secret");
service.ensureFlowReg(site, "fg-1");
assertEquals("issued-secret", firstSavedReg().getSecret());
assertEquals(Integer.valueOf(0), firstSavedReg().getStatus());
assertEquals("http://localhost:8086/callback/flow", firstSavedReg().getCallbackUrl());
}
@Test
void registrationWithEmptySecretIsReissued() {
// 失效注册(secret 空)→ 重新注册并原地更新同一行(拿回新 secret)
ServiceFlowReg existing = reg(0, null);
when(flowRegRepository.findBySchIdAndFlowGuid(1L, "fg-1")).thenReturn(Optional.of(existing));
when(flowClient.registerCallback(eq(1L), eq("fg-1"), anyString(), isNull()))
.thenReturn("rotated-secret");
service.ensureFlowReg(site, "fg-1");
assertSame(existing, firstSavedReg(), "失效注册应原地更新同一行");
assertEquals("rotated-secret", firstSavedReg().getSecret());
}
@Test
void registrationWithDisabledStatusIsReissued() {
ServiceFlowReg existing = reg(1, "old-secret");
when(flowRegRepository.findBySchIdAndFlowGuid(1L, "fg-1")).thenReturn(Optional.of(existing));
when(flowClient.registerCallback(eq(1L), eq("fg-1"), anyString(), isNull()))
.thenReturn("rotated-secret");
service.ensureFlowReg(site, "fg-1");
verify(flowRegRepository).save(existing);
}
@Test
void registrationFailureIsBestEffort() {
when(flowRegRepository.findBySchIdAndFlowGuid(1L, "fg-1")).thenReturn(Optional.empty());
when(flowClient.registerCallback(any(), anyString(), anyString(), any()))
.thenThrow(new RuntimeException("flow-center down"));
service.ensureFlowReg(site, "fg-1"); // 不抛——事项保存不被注册失败阻断
verify(flowRegRepository, never()).save(any());
}
@Test
void flowClientDisabledSkipsEverything() {
void nullFlowOrDisabledClient_noDelegation() {
service.ensureFlowReg(site, null);
when(flowClient.enabled()).thenReturn(false);
service.ensureFlowReg(site, "fg-1");
verify(flowRegRepository, never()).findBySchIdAndFlowGuid(any(), anyString());
verify(registrar, never()).ensureRegistered(anyInt(), anyString());
}
private ServiceFlowReg firstSavedReg() {
org.mockito.ArgumentCaptor<ServiceFlowReg> captor =
org.mockito.ArgumentCaptor.forClass(ServiceFlowReg.class);
verify(flowRegRepository).save(captor.capture());
return captor.getValue();
@Test
void registrarFailure_swallowedBestEffort() {
doThrow(new RuntimeException("probe unreachable")).when(registrar)
.ensureRegistered(anyInt(), anyString());
service.ensureFlowReg(site, "fg-1"); // 不抛:下次保存重试
}
}
@@ -0,0 +1,165 @@
package com.yunhai.svchall.service.impl;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.platform.openapi.flow.model.FlowCallbackEvent;
import com.yunhai.svchall.entity.ServiceApply;
import com.yunhai.svchall.repository.ServiceApplyRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
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.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* 回调业务映射单测(2026-09-20 迁 yh-flow-callback starter 后聚焦 svc-hall 侧状态机;
* 管线类用例——ping/毒消息/缺字段/外来 bizType/eventId 幂等——已由 starter 的
* FlowCallbackReceiverTest 覆盖):事件映射;终态置 finishTime + 结果快照;
* 单据级乱序丢弃;终态不回退守卫;异常外抛(starter 回 5xx)。
*/
class SvcFlowCallbackHandlerTest {
private ServiceApplyRepository applyRepository;
private SvcFlowCallbackHandler handler;
private ServiceApply apply;
@BeforeEach
void setUp() {
applyRepository = mock(ServiceApplyRepository.class);
handler = new SvcFlowCallbackHandler(applyRepository, new ObjectMapper());
apply = new ServiceApply();
apply.setId(7);
apply.setSchId(1L);
apply.setFlowStatus(10);
apply.setLastInstanceVersion(1);
when(applyRepository.findByIdForUpdate(7)).thenReturn(Optional.of(apply));
when(applyRepository.save(any(ServiceApply.class))).thenAnswer(inv -> inv.getArgument(0));
}
private FlowCallbackEvent event(String eventId, String event, Integer version) {
FlowCallbackEvent e = new FlowCallbackEvent();
e.setEventId(eventId);
e.setEvent(event);
e.setSchId(1);
e.setFlowGuid("fg-1");
e.setInstanceId(9527L);
e.setInstanceVersion(version);
e.setBizType(SvcFlowCallbackHandler.BIZ_TYPE);
e.setBizId("7");
e.setTaskId(33L);
e.setOperatorUserId(201);
e.setOperatorName("张三");
Map<String, Object> data = new HashMap<>();
e.setData(data);
return e;
}
@Test
void inFlightEvents_mapToApproving() {
handler.onEvent(event("e1", "flow.submitted", 1));
assertEquals(10, apply.getFlowStatus().intValue());
handler.onEvent(event("e2", "flow.task.created", 1));
assertEquals(10, apply.getFlowStatus().intValue());
assertNull(apply.getFinishTime(), "在途事件不置办结时间");
}
@Test
void completed_setsFinishTimeAndResultSnapshot() {
FlowCallbackEvent e = event("e3", "flow.completed", 3);
e.getData().put("result", "APPROVED");
e.getData().put("opinion", "同意");
handler.onEvent(e);
assertEquals(20, apply.getFlowStatus().intValue());
assertNotNull(apply.getFinishTime(), "终态置 finish_time");
assertNotNull(apply.getFlowResultJson());
assertTrue(apply.getFlowResultJson().contains("APPROVED"));
assertTrue(apply.getFlowResultJson().contains("同意"));
assertEquals(3, apply.getLastInstanceVersion().intValue());
}
@Test
void rejected_mapsTo30_withRejectNode() {
FlowCallbackEvent e = event("e4", "flow.rejected", 3);
e.getData().put("result", "REJECTED");
e.getData().put("rejectNode", "终审");
handler.onEvent(e);
assertEquals(30, apply.getFlowStatus().intValue());
assertTrue(apply.getFlowResultJson().contains("终审"));
}
@Test
void withdrawn_mapsTo40() {
handler.onEvent(event("e5", "flow.withdrawn", 2));
assertEquals(40, apply.getFlowStatus().intValue());
}
@Test
void illegalBizId_silentReturn() {
FlowCallbackEvent e = event("e6", "flow.completed", 3);
e.setBizId("not-a-number");
handler.onEvent(e); // 不抛(starter 回 200)
}
@Test
void unknownApply_silentReturn() {
when(applyRepository.findByIdForUpdate(7)).thenReturn(Optional.empty());
handler.onEvent(event("e7", "flow.completed", 3)); // 不抛
}
@Test
void staleVersion_dropped() {
apply.setLastInstanceVersion(5);
handler.onEvent(event("e8", "flow.completed", 3));
assertEquals(10, apply.getFlowStatus().intValue(), "乱序事件丢弃");
}
@Test
void sameVersionDifferentEvent_processed() {
apply.setLastInstanceVersion(2);
handler.onEvent(event("e9", "flow.task.handled", 2));
assertEquals(10, apply.getFlowStatus().intValue(), "同版本事件合法(严格小于才丢弃)");
}
@Test
void terminalRow_rejectsNonTerminalEvent() {
handler.onEvent(event("t1", "flow.completed", 3));
assertEquals(20, apply.getFlowStatus().intValue());
String frozen = apply.getFlowResultJson();
handler.onEvent(event("t2", "flow.task.handled", 3)); // 迟到的同版本在途事件
assertEquals(20, apply.getFlowStatus().intValue(), "终态不回退");
assertEquals(frozen, apply.getFlowResultJson(), "已落定 flow_result_json 不得被覆盖");
}
@Test
void terminalGuard_doesNotBlockNewRound_afterResubmission() {
handler.onEvent(event("t3", "flow.rejected", 3));
apply.setFlowStatus(10); // resubmit 成功后窄列原子置 10
handler.onEvent(event("t4", "flow.submitted", 4));
assertEquals(10, apply.getFlowStatus().intValue(), "新一轮事件正常推进");
assertEquals(4, apply.getLastInstanceVersion().intValue());
}
@Test
void unknownEvent_silentReturn() {
handler.onEvent(event("u1", "flow.returned", 3));
assertEquals(10, apply.getFlowStatus().intValue(), "未消费的已知事件码静默忽略");
}
@Test
void dbFailure_propagates() {
when(applyRepository.save(any(ServiceApply.class)))
.thenThrow(new org.springframework.dao.DataAccessResourceFailureException("db down"));
assertThrows(org.springframework.dao.DataAccessResourceFailureException.class,
() -> handler.onEvent(event("e10", "flow.completed", 3)));
}
}