This commit is contained in:
2026-09-29 18:47:37 +08:00
parent 2c60a29e0f
commit c2927ab9a6
291 changed files with 4008 additions and 6298 deletions
@@ -0,0 +1,22 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.core.exception.BizException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.PlatformTransactionManager;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class EventOperationsControllerTest {
@Test void credentialFailureCannotReadOrMutateDatabase() {
var jdbc = mock(JdbcTemplate.class);
var transactions = mock(PlatformTransactionManager.class);
for (String configured : new String[] {"", "configured-operations-secret-32-bytes"}) {
var controller = new EventOperationsController(jdbc, transactions, new ObjectMapper(), configured);
assertThatThrownBy(() -> controller.inspect(null, "1")).isInstanceOf(BizException.class);
assertThatThrownBy(() -> controller.retry("wrong", "1", null)).isInstanceOf(BizException.class);
}
verifyNoInteractions(jdbc, transactions);
}
}
@@ -30,9 +30,9 @@ class LoadingCompletedOutboxRepositoryTest {
when(mapper.update(isNull(InboundOutboxEntity.class), wrapper.capture())).thenReturn(1);
LocalDateTime now = LocalDateTime.of(2026, 8, 25, 12, 0);
assertThat(repository.claim(1L, "worker", now, now.minusMinutes(1))).isTrue();
assertThat(repository.claim(1L, "worker", now)).isTrue();
assertThat(wrapper.getValue().getSqlSegment())
.contains("next_retry_at", "<=", "publish_status", "locked_at");
.contains("next_retry_at", "<=", "publish_status").doesNotContain("locked_at");
}
}
@@ -0,0 +1,57 @@
package com.baiyue.firstleg.repository;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.firstleg.mapper.InboundOutboxMapper;
import com.baiyue.common.core.outbox.EventDeliveryJournal;
import com.baiyue.common.core.outbox.EventDeliveryJournal.Outcome;
import org.mybatis.spring.SqlSessionTemplate;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.LocalDateTime;
import java.util.UUID;
import static org.assertj.core.api.Assertions.*;
class OutboxLeaseDatabaseTest {
@Test void loadingCompletedOutboxRepositoryUsesAtomicAuditAndBackoff() throws Exception {
var dataSource = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
var jdbc = new JdbcTemplate(dataSource);
jdbc.execute("CREATE TABLE fl_inbound_outbox(id BIGINT PRIMARY KEY,tenant_id BIGINT,aggregate_type VARCHAR(64),aggregate_id BIGINT,event_version INT,aggregate_sequence INT,payload_hash VARCHAR(100),event_type VARCHAR(96),event_key VARCHAR(160),payload_json TEXT,publish_status VARCHAR(32),retry_count INT NOT NULL DEFAULT 0,next_retry_at TIMESTAMP,published_at TIMESTAMP,locked_at TIMESTAMP,locked_by VARCHAR(128),last_error VARCHAR(1000),created_at TIMESTAMP)");
jdbc.execute("CREATE TABLE event_delivery_attempt(attempt_id VARCHAR(36) PRIMARY KEY,service_name VARCHAR(32) NOT NULL,event_type VARCHAR(96) NOT NULL,entry_id VARCHAR(128) NOT NULL,event_id VARCHAR(128),target_name VARCHAR(64) NOT NULL,started_at TIMESTAMP(3) NOT NULL,finished_at TIMESTAMP(3),duration_ms BIGINT,outcome VARCHAR(24) NOT NULL,error_code VARCHAR(64),next_retry_at TIMESTAMP(3))");
var configuration = new MybatisConfiguration();
configuration.addMapper(InboundOutboxMapper.class);
var factory = new MybatisSqlSessionFactoryBean();
factory.setDataSource(dataSource); factory.setConfiguration(configuration);
var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(InboundOutboxMapper.class);
var repository = new LoadingCompletedOutboxRepository();
ReflectionTestUtils.setField(repository, "mapper", mapper);
var journal = new EventDeliveryJournal(jdbc, new DataSourceTransactionManager(dataSource));
LocalDateTime now = LocalDateTime.now();
jdbc.update("INSERT INTO fl_inbound_outbox(id,event_type,publish_status,retry_count,next_retry_at,created_at) VALUES(1,?,'PENDING',0,?,?)", "FirstLegLoadingPlanCompleted", now.minusSeconds(1), now.minusMinutes(3));
assertThat(journal.begin("old", "firstleg", "FirstLegLoadingPlanCompleted", "1", null, "consumer", now.minusMinutes(2), () -> repository.claim(1L, "old", now.minusMinutes(2)))).isFalse();
assertThat(journal.begin("old", "firstleg", "FirstLegLoadingPlanCompleted", "1", null, "consumer", now, () -> repository.claim(1L, "old", now))).isTrue();
jdbc.update("UPDATE fl_inbound_outbox SET locked_at=? WHERE id=1", now.minusSeconds(61));
assertThat(repository.claim(1L, "new", now)).isFalse();
var next = now.plusSeconds(10);
assertThat(journal.complete("old", Outcome.LEASE_EXPIRED, now, "LEASE_EXPIRED", next,
() -> repository.expire(1L, "old", now.minusSeconds(60), 1, next, "lease expired"))).isTrue();
assertThat(jdbc.queryForObject("SELECT retry_count FROM fl_inbound_outbox", Integer.class)).isEqualTo(1);
assertThat(repository.claim(1L, "early", now)).isFalse();
assertThat(journal.begin("new", "firstleg", "FirstLegLoadingPlanCompleted", "1", null, "consumer", next,
() -> repository.claim(1L, "new", next))).isTrue();
assertThat(journal.complete("old", Outcome.DEAD, next, "RETRY_EXHAUSTED", null,
() -> repository.dead(1L, "old", 12, "late"))).isFalse();
jdbc.execute("ALTER TABLE event_delivery_attempt ADD CONSTRAINT reject_applied CHECK(outcome <> 'APPLIED')");
assertThatThrownBy(() -> journal.complete("new", Outcome.APPLIED, next, null, null,
() -> repository.published(1L, "new", next))).isInstanceOf(RuntimeException.class);
assertThat(jdbc.queryForObject("SELECT publish_status FROM fl_inbound_outbox", String.class)).isEqualTo("PROCESSING");
assertThat(jdbc.queryForObject("SELECT outcome FROM event_delivery_attempt WHERE attempt_id='new'", String.class)).isEqualTo("STARTED");
jdbc.execute("ALTER TABLE event_delivery_attempt DROP CONSTRAINT reject_applied");
assertThat(journal.complete("new", Outcome.APPLIED, next, null, null,
() -> repository.published(1L, "new", next))).isTrue();
assertThat(jdbc.queryForObject("SELECT publish_status FROM fl_inbound_outbox", String.class)).isEqualTo("PUBLISHED");
}
}
@@ -7,7 +7,6 @@ import com.baiyue.common.event.EventPayloadCanonicalizer;
import com.baiyue.common.event.EventPayloadHash;
import com.baiyue.common.event.FirstLegLoadingPlanCompletedV1;
import com.baiyue.common.event.EventTypes;
import com.baiyue.common.event.RegionCode;
import com.baiyue.firstleg.client.LastlegEventClient;
import com.baiyue.firstleg.entity.InboundOutboxEntity;
import com.baiyue.firstleg.repository.LoadingCompletedOutboxRepository;
@@ -24,6 +23,11 @@ import org.springframework.cloud.client.circuitbreaker.NoFallbackAvailableExcept
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
import com.baiyue.common.core.outbox.EventDeliveryJournal;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@@ -35,11 +39,21 @@ class LoadingCompletedOutboxRelayTest {
private ObjectMapper objectMapper;
private LoadingCompletedOutboxRelay relay;
private EventDeliveryJournal journal;
@BeforeEach
void setUp() {
objectMapper = new ObjectMapper().registerModule(new JavaTimeModule());
relay = new LoadingCompletedOutboxRelay();
ReflectionTestUtils.setField(relay, "repository", repository);
var dataSource = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
var jdbc = new JdbcTemplate(dataSource);
jdbc.execute("CREATE TABLE event_delivery_attempt(attempt_id VARCHAR(36) PRIMARY KEY,service_name VARCHAR(32) NOT NULL,event_type VARCHAR(96) NOT NULL,entry_id VARCHAR(128) NOT NULL,event_id VARCHAR(128),target_name VARCHAR(64) NOT NULL,started_at TIMESTAMP(3) NOT NULL,finished_at TIMESTAMP(3),duration_ms BIGINT,outcome VARCHAR(24) NOT NULL,error_code VARCHAR(64),next_retry_at TIMESTAMP(3))");
journal = new EventDeliveryJournal(jdbc, new DataSourceTransactionManager(dataSource));
ReflectionTestUtils.setField(relay, "journal", journal);
lenient().when(repository.published(anyLong(), anyString(), any())).thenReturn(1);
lenient().when(repository.retry(anyLong(), anyString(), any(), anyString())).thenReturn(1);
lenient().when(repository.dead(anyLong(), anyString(), anyInt(), anyString())).thenReturn(1);
ReflectionTestUtils.setField(relay, "client", client);
ReflectionTestUtils.setField(relay, "objectMapper", objectMapper);
ReflectionTestUtils.setField(relay, "internalToken", "internal-token");
@@ -52,7 +66,7 @@ class LoadingCompletedOutboxRelayTest {
EventEnvelope event = event();
InboundOutboxEntity row = row(event);
when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row));
when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true);
when(repository.claim(eq(9001L), anyString(), any())).thenReturn(true);
when(repository.claimed(eq(9001L), anyString())).thenReturn(row);
when(client.receive(eq("internal-token"), any(EventEnvelope.class))).thenReturn(ApiResponse.success(
com.baiyue.common.event.DeliveryAck.of(event, com.baiyue.common.event.DeliveryAck.Outcome.APPLIED)));
@@ -68,7 +82,7 @@ class LoadingCompletedOutboxRelayTest {
EventEnvelope event = event();
InboundOutboxEntity row = row(event);
when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row));
when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true);
when(repository.claim(eq(9001L), anyString(), any())).thenReturn(true);
when(repository.claimed(eq(9001L), anyString())).thenReturn(row);
when(client.receive(eq("internal-token"), any(EventEnvelope.class)))
.thenThrow(new IllegalStateException("failed internal-token\nremote"));
@@ -84,7 +98,7 @@ class LoadingCompletedOutboxRelayTest {
EventEnvelope event = event();
InboundOutboxEntity row = row(event);
when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row));
when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true);
when(repository.claim(eq(9001L), anyString(), any())).thenReturn(true);
when(repository.claimed(eq(9001L), anyString())).thenReturn(row);
when(client.receive(eq("internal-token"), any(EventEnvelope.class))).thenThrow(
new NoFallbackAvailableException("No fallback available.",
@@ -102,7 +116,7 @@ class LoadingCompletedOutboxRelayTest {
EventEnvelope event = event();
InboundOutboxEntity row = row(event);
when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row));
when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true);
when(repository.claim(eq(9001L), anyString(), any())).thenReturn(true);
when(repository.claimed(eq(9001L), anyString())).thenReturn(row);
when(client.receive(eq("internal-token"), any(EventEnvelope.class))).thenReturn(ApiResponse.failure(
ResultCode.SERVICE_UNAVAILABLE, "bridge inbox unavailable", "request-123", "trace-456"));
@@ -110,7 +124,7 @@ class LoadingCompletedOutboxRelayTest {
relay.relay();
verify(repository).retry(eq(9001L), anyString(), any(LocalDateTime.class), eq(
"IllegalStateException: Lastleg拒绝头程业务事件: code=S0503, message=bridge inbox unavailable, "
"EventDeliveryFailure: Consumer rejected event: code=S0503, message=bridge inbox unavailable, "
+ "requestId=request-123, traceId=trace-456"));
verify(repository, never()).published(anyLong(), anyString(), any());
}
@@ -120,7 +134,7 @@ class LoadingCompletedOutboxRelayTest {
EventEnvelope event = event();
InboundOutboxEntity row = row(event);
when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row));
when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true);
when(repository.claim(eq(9001L), anyString(), any())).thenReturn(true);
when(repository.claimed(eq(9001L), anyString())).thenReturn(row);
when(client.receive(eq("internal-token"), any(EventEnvelope.class))).thenReturn(ApiResponse.success(
new com.baiyue.common.event.DeliveryAck(event.eventId(), "sha256:" + "0".repeat(64),
@@ -135,7 +149,7 @@ class LoadingCompletedOutboxRelayTest {
InboundOutboxEntity row = row(event());
row.setRetryCount(11);
when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row));
when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true);
when(repository.claim(eq(9001L), anyString(), any())).thenReturn(true);
when(repository.claimed(eq(9001L), anyString())).thenReturn(row);
when(client.receive(anyString(), any())).thenThrow(new IllegalStateException("consumer unavailable"));
relay.relay();
@@ -149,7 +163,7 @@ class LoadingCompletedOutboxRelayTest {
InboundOutboxEntity row = row(event());
row.setRetryCount(12);
when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row));
when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true);
when(repository.claim(eq(9001L), anyString(), any())).thenReturn(true);
when(repository.claimed(eq(9001L), anyString())).thenReturn(row);
relay.relay();
verifyNoInteractions(client);
@@ -157,10 +171,24 @@ class LoadingCompletedOutboxRelayTest {
verify(repository, never()).retry(anyLong(), anyString(), any(), anyString());
}
@Test
void expiredLeaseIsAuditedAndBackedOffWithoutImmediateHttp() throws Exception {
var row = row(event());
row.setPublishStatus("PROCESSING"); row.setLockedBy("old"); row.setRetryCount(1);
journal.begin("old", "firstleg", row.getEventType(), "9001", null, "consumer", LocalDateTime.now().minusMinutes(2), () -> true);
when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row));
when(repository.expire(eq(9001L), eq("old"), any(), eq(2), any(), anyString())).thenReturn(1);
relay.relay();
verify(repository).expire(eq(9001L), eq("old"), any(), eq(2), argThat(next -> next.isAfter(LocalDateTime.now())), anyString());
verify(repository, never()).claim(anyLong(), anyString(), any());
verifyNoInteractions(client);
}
private InboundOutboxEntity row(EventEnvelope event) throws Exception {
InboundOutboxEntity row = new InboundOutboxEntity();
row.setId(9001L);
row.setEventKey(event.eventId());
row.setEventType(event.eventType());
row.setPayloadJson(objectMapper.writeValueAsString(event));
return row;
}
@@ -170,7 +198,7 @@ class LoadingCompletedOutboxRelayTest {
"LOAD-1", "PLAN-1", "BOOK-1", "MSCU1234567", null,
"WH-1", "Warehouse", List.of()));
return new EventEnvelope("00000000-0000-4000-8000-000000000019", EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED, 1,
Instant.parse("2026-08-20T03:00:01Z"), RegionCode.CN, RegionCode.OVERSEAS, "1",
Instant.parse("2026-08-20T03:00:01Z"), "1",
"LOADING_PLAN", "LOAD-1", 7L, 1L, "trace-12345678", "correlation-12345678", null,
payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)));
}
@@ -5,7 +5,6 @@ import com.baiyue.common.event.EventPayloadCanonicalizer;
import com.baiyue.common.event.EventPayloadHash;
import com.baiyue.common.event.EventTypes;
import com.baiyue.common.event.OverseasContainerUnloadedV1;
import com.baiyue.common.event.RegionCode;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.entity.ContainerEventInboxEntity;
import com.baiyue.firstleg.mapper.ContainerBookingMapper;
@@ -83,7 +82,7 @@ class OverseasContainerEventConsumerTest {
new OverseasContainerUnloadedV1("1001", "ZCSU7881970", Instant.parse("2026-08-20T03:00:00Z"),
Instant.parse("2026-08-20T02:30:00Z"), null));
EventEnvelope tampered = new EventEnvelope(valid.eventId(), valid.eventType(), valid.eventVersion(),
valid.occurredAt(), valid.sourceRegion(), valid.targetRegion(), valid.tenantId(), valid.aggregateType(),
valid.occurredAt(), valid.tenantId(), valid.aggregateType(),
valid.aggregateId(), valid.aggregateVersion(), valid.handoverVersion(), valid.traceId(),
valid.correlationId(), valid.causationId(), valid.payload(), "sha256:" + "0".repeat(64));
@@ -107,7 +106,7 @@ class OverseasContainerEventConsumerTest {
var node = objectMapper.valueToTree(payload);
String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node));
return new EventEnvelope(eventId, EventTypes.OVERSEAS_CONTAINER_UNLOADED, 1,
Instant.parse("2026-08-20T02:31:00Z"), RegionCode.OVERSEAS, RegionCode.CN, "1",
Instant.parse("2026-08-20T02:31:00Z"), "1",
"CONTAINER_BOOKING", payload.sourceContainerBookingId(), aggregateVersion, null,
"trace-12345678", "correlation-12345678", null, node, hash);
}
@@ -78,7 +78,7 @@ class OverseasDeliveryCompletedConsumerTest {
"101", "IN-1", "201", "FL-201", "201", "ONT8", time);
var node = json.valueToTree(payload);
return new EventEnvelope("00000000-0000-4000-8000-000000000001",
EventTypes.OVERSEAS_DELIVERY_COMPLETED, 1, time, RegionCode.OVERSEAS, RegionCode.CN,
EventTypes.OVERSEAS_DELIVERY_COMPLETED, 1, time,
"88", "INBOUND_ORDER", "101", 2L, 1L,
"00000000-0000-4000-8000-000000000099", "delivery-101", null, node,
EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)));
@@ -5,7 +5,6 @@ import com.baiyue.common.event.EventPayloadCanonicalizer;
import com.baiyue.common.event.EventPayloadHash;
import com.baiyue.common.event.EventTypes;
import com.baiyue.common.event.OverseasInboundOrderDetainedChangedV1;
import com.baiyue.common.event.RegionCode;
import com.baiyue.firstleg.entity.InboundHoldRecordEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.entity.ContainerEventInboxEntity;
@@ -82,8 +81,7 @@ class OverseasInboundOrderDetainedConsumerTest {
"LM-9001", detained, reason, "66", "海外操作员", Instant.parse("2026-08-31T06:00:00Z"));
var node = objectMapper.valueToTree(payload);
return new EventEnvelope(String.format("00000000-0000-4000-8000-%012d", version),
EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED, 1, payload.changedAt(),
RegionCode.OVERSEAS, RegionCode.CN, "88", "INBOUND_ORDER", "1001", version, 1L,
EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED, 1, payload.changedAt(), "88", "INBOUND_ORDER", "1001", version, 1L,
"00000000-0000-4000-8000-000000000099", "hold-1001-" + version, null, node,
EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)));
}