- 回调迁 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:
@@ -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)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user