按《FL最终代码审计与上线阻断清单》194 项已审计工作树形成候选,基座 = origin/main 11e0778。
- 源码/测试/资源:沿用已审计工作树内容(96 项相对远端为新增或更新),含未跟踪实现
CallbackFactStore、CallbackSourcePolicy、VariableContract、FormalApplyCommandService、
FlowActorContextGate 等;C01 续验夹具修正一并入内。
- CI:保留远端 11e0778 的门禁强化(CI_SOURCE_SHA 核对、容器回收验证、空 schema 反向门禁、
main ref 才推镜像);ci/flow-test-schema.sql 按当前实体重新生成(14→20 表,补 6 新表与
6 新列,回调日志复合唯一键 uk_flw_log_event_reg),workflow 表数断言同步 14→20。
- 依据:docs/tasks/evidence/FL-final-audit-20261027(194 项哈希/版本核对)。
193 lines
8.6 KiB
Java
193 lines
8.6 KiB
Java
package com.yunhai.flowcenter.callback;
|
||
|
||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||
import com.yunhai.flowcenter.engine.FlowEvent;
|
||
import com.yunhai.flowcenter.entity.FlwDef;
|
||
import com.yunhai.flowcenter.entity.FlwInstance;
|
||
import com.yunhai.flowcenter.entity.FlwStateEvent;
|
||
import com.yunhai.flowcenter.repository.FlwDefRepository;
|
||
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
|
||
import com.yunhai.flowcenter.repository.FlwStateEventRepository;
|
||
import org.junit.jupiter.api.BeforeEach;
|
||
import org.junit.jupiter.api.DisplayName;
|
||
import org.junit.jupiter.api.Test;
|
||
import org.mockito.ArgumentCaptor;
|
||
import org.mockito.Mockito;
|
||
|
||
import java.util.Optional;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThat;
|
||
import static org.mockito.ArgumentMatchers.any;
|
||
import static org.mockito.Mockito.mock;
|
||
import static org.mockito.Mockito.never;
|
||
import static org.mockito.Mockito.verify;
|
||
import static org.mockito.Mockito.when;
|
||
|
||
/**
|
||
* 状态事件事实(T05;2026-10-25 R2 返修增补)规则单测:
|
||
*
|
||
* <ul>
|
||
* <li>事实与状态<b>同事务</b>落库(同步监听器语义)并为事件赋稳定身份;</li>
|
||
* <li>eventId 全链复用(事实行=投递行=重试);同事实重复发布不重复落行;</li>
|
||
* <li>同事务事实写失败=状态事务一并失败(不吞);</li>
|
||
* <li>URGE 站内信不入事件流;</li>
|
||
* <li>R2:事实载荷=完整冻结信封(含 eventId/eventSeq/flowGuid),并随身份冻结到事件对象——
|
||
* 正常分发与崩溃恢复共用同一冻结体。</li>
|
||
* </ul>
|
||
*/
|
||
@DisplayName("状态事件事实 FlwStateEventRecorder(T05/R2)")
|
||
class FlStateEventRecoveryTest {
|
||
|
||
private FlwStateEventRepository eventRepository;
|
||
private FlwInstanceRepository instanceRepository;
|
||
private FlwDefRepository defRepository;
|
||
private FlwStateEventRecorder recorder;
|
||
|
||
@BeforeEach
|
||
void setUp() {
|
||
eventRepository = mock(FlwStateEventRepository.class);
|
||
instanceRepository = mock(FlwInstanceRepository.class);
|
||
defRepository = mock(FlwDefRepository.class);
|
||
recorder = new FlwStateEventRecorder(eventRepository, instanceRepository, defRepository,
|
||
new ObjectMapper());
|
||
when(eventRepository.save(any())).thenAnswer(c -> {
|
||
FlwStateEvent e = c.getArgument(0);
|
||
e.setId(1L);
|
||
return e;
|
||
});
|
||
|
||
FlwInstance instance = new FlwInstance();
|
||
instance.setId(900L);
|
||
instance.setSchId(27L);
|
||
instance.setDefId(55L);
|
||
instance.setApplyRound(2);
|
||
instance.setBizType("svc-hall");
|
||
instance.setBizId("42");
|
||
when(instanceRepository.findById(900L)).thenReturn(Optional.of(instance));
|
||
when(instanceRepository.nextEventSeqForUpdate(900L)).thenReturn(5);
|
||
when(instanceRepository.setEventSeqCounter(Mockito.eq(900L), Mockito.anyInt())).thenReturn(1);
|
||
|
||
FlwDef def = new FlwDef();
|
||
def.setId(55L);
|
||
def.setFlowGuid("fg-r2-envelope");
|
||
when(defRepository.findById(55L)).thenReturn(Optional.of(def));
|
||
}
|
||
|
||
@Test
|
||
@DisplayName("同步监听:事实落库并赋稳定身份(eventId+实例内 seq=max+1)")
|
||
void recordAssignsStableIdentity() {
|
||
FlowEvent event = FlowEvent.of(FlowEvent.Type.SUBMITTED, 900L);
|
||
|
||
recorder.record(event);
|
||
|
||
Mockito.verify(eventRepository).save(any(FlwStateEvent.class));
|
||
assertThat(event.getStableEventId()).isNotBlank();
|
||
assertThat(event.getStableEventSeq()).isEqualTo(5);
|
||
}
|
||
|
||
@Test
|
||
@DisplayName("同事件对象重复发布:不重复落事实、身份不变")
|
||
void duplicatePublishKeepsIdentity() {
|
||
FlowEvent event = FlowEvent.of(FlowEvent.Type.COMPLETED, 900L);
|
||
recorder.record(event);
|
||
String first = event.getStableEventId();
|
||
|
||
recorder.record(event);
|
||
|
||
Mockito.verify(eventRepository, Mockito.times(1)).save(any(FlwStateEvent.class));
|
||
assertThat(event.getStableEventId()).isEqualTo(first);
|
||
}
|
||
|
||
@Test
|
||
@DisplayName("URGE 站内信:不入事件流(既有口径不变)")
|
||
void urgeSkipped() {
|
||
recorder.record(FlowEvent.of(FlowEvent.Type.URGE, 900L));
|
||
Mockito.verify(eventRepository, never()).save(any(FlwStateEvent.class));
|
||
}
|
||
|
||
@Test
|
||
@DisplayName("事实载荷快照含轮次身份(旧事件不被 AFTER_COMMIT 重读贴新轮标签)")
|
||
void payloadCarriesRoundIdentity() throws Exception {
|
||
FlowEvent event = FlowEvent.of(FlowEvent.Type.REJECTED, 900L);
|
||
event.captureRoundIdentity(2, "tok-1");
|
||
recorder.record(event);
|
||
Mockito.verify(eventRepository).save(Mockito.argThat(f -> {
|
||
try {
|
||
com.fasterxml.jackson.databind.JsonNode payload =
|
||
new ObjectMapper().readTree(f.getPayloadJson());
|
||
return payload.path("applyRound").asInt() == 2
|
||
&& "tok-1".equals(payload.path("data").path("requestToken").asText());
|
||
} catch (Exception e) {
|
||
return false;
|
||
}
|
||
}));
|
||
}
|
||
|
||
@Test
|
||
@DisplayName("R2:事实载荷=完整冻结信封——eventId/eventSeq/flowGuid/接收端必填四件套全部在体")
|
||
void payloadIsFullFrozenEnvelope() throws Exception {
|
||
FlowEvent event = FlowEvent.of(FlowEvent.Type.SUBMITTED, 900L);
|
||
recorder.record(event);
|
||
|
||
ArgumentCaptor<FlwStateEvent> captor = ArgumentCaptor.forClass(FlwStateEvent.class);
|
||
verify(eventRepository).save(captor.capture());
|
||
com.fasterxml.jackson.databind.JsonNode payload =
|
||
new ObjectMapper().readTree(captor.getValue().getPayloadJson());
|
||
// 接收端 FlowCallbackReceiver 必填:eventId/event/bizType/bizId(缺一按脏消息 200 丢弃)
|
||
assertThat(payload.path("eventId").asText()).isEqualTo(event.getStableEventId());
|
||
assertThat(payload.path("event").asText()).isEqualTo("flow.submitted");
|
||
assertThat(payload.path("bizType").asText()).isEqualTo("svc-hall");
|
||
assertThat(payload.path("bizId").asText()).isEqualTo("42");
|
||
// R2 新冻结字段:稳定序号 + flowGuid(原实现只在 HTTP 头/列里,消息体缺失)
|
||
assertThat(payload.path("eventSeq").asInt()).isEqualTo(5);
|
||
assertThat(payload.path("flowGuid").asText()).isEqualTo("fg-r2-envelope");
|
||
// 既有信封字段不回退
|
||
assertThat(payload.has("instanceId")).isTrue();
|
||
assertThat(payload.has("instanceVersion")).isTrue();
|
||
assertThat(payload.has("occurredTime")).isTrue();
|
||
}
|
||
|
||
@Test
|
||
@DisplayName("R2:冻结信封随身份一并赋给事件对象——分发器正常链直接复用冻结体(与恢复链同构)")
|
||
void frozenEnvelopeAssignedToEvent() throws Exception {
|
||
FlowEvent event = FlowEvent.of(FlowEvent.Type.COMPLETED, 900L);
|
||
recorder.record(event);
|
||
|
||
assertThat(event.getStablePayloadJson()).isNotBlank();
|
||
com.fasterxml.jackson.databind.JsonNode payload =
|
||
new ObjectMapper().readTree(event.getStablePayloadJson());
|
||
assertThat(payload.path("eventId").asText()).isEqualTo(event.getStableEventId());
|
||
assertThat(payload.path("flowGuid").asText()).isEqualTo("fg-r2-envelope");
|
||
|
||
// 重复发布不换冻结体(幂等)
|
||
String first = event.getStablePayloadJson();
|
||
recorder.record(event);
|
||
assertThat(event.getStablePayloadJson()).isEqualTo(first);
|
||
}
|
||
|
||
@Test
|
||
@DisplayName("R2:定义已删(def 缺席)——信封不带 flowGuid,不炸记录器(该场合分发器同样不投递)")
|
||
void missingDefStillRecordsWithoutFlowGuid() throws Exception {
|
||
FlwInstance instance = new FlwInstance();
|
||
instance.setId(901L);
|
||
instance.setSchId(27L);
|
||
instance.setDefId(404L);
|
||
instance.setBizType("svc-hall");
|
||
instance.setBizId("43");
|
||
when(instanceRepository.findById(901L)).thenReturn(Optional.of(instance));
|
||
when(defRepository.findById(404L)).thenReturn(Optional.empty());
|
||
when(instanceRepository.nextEventSeqForUpdate(901L)).thenReturn(1);
|
||
when(instanceRepository.setEventSeqCounter(Mockito.eq(901L), Mockito.anyInt())).thenReturn(1);
|
||
|
||
FlowEvent event = FlowEvent.of(FlowEvent.Type.SUBMITTED, 901L);
|
||
recorder.record(event);
|
||
|
||
ArgumentCaptor<FlwStateEvent> captor = ArgumentCaptor.forClass(FlwStateEvent.class);
|
||
verify(eventRepository).save(captor.capture());
|
||
com.fasterxml.jackson.databind.JsonNode payload =
|
||
new ObjectMapper().readTree(captor.getValue().getPayloadJson());
|
||
assertThat(payload.has("flowGuid")).isFalse();
|
||
assertThat(payload.path("eventId").asText()).isEqualTo(event.getStableEventId());
|
||
}
|
||
}
|