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
@@ -5,7 +5,6 @@ import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.EventPayloadCanonicalizer;
import com.baiyue.common.event.EventPayloadHash;
import com.baiyue.common.event.EventTypes;
import com.baiyue.common.event.RegionCode;
import com.baiyue.finance.service.BusinessFeeSourceEventService;
import com.baiyue.finance.service.FinanceSupplierValidator;
import com.fasterxml.jackson.databind.JsonNode;
@@ -84,8 +83,7 @@ class BusinessFeeSourceEventServiceTest {
JsonNode node = json.valueToTree(payload);
String payloadHash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node));
return new EventEnvelope("123e4567-e89b-12d3-a456-426614174701",
EventTypes.BUSINESS_FEE_SOURCE_CHANGED, 1, Instant.parse("2026-09-02T08:00:00Z"),
RegionCode.OVERSEAS, RegionCode.CN, "10001", "BUSINESS_FEE", "701", 1,
EventTypes.BUSINESS_FEE_SOURCE_CHANGED, 1, Instant.parse("2026-09-02T08:00:00Z"), "10001", "BUSINESS_FEE", "701", 1,
null, "123e4567-e89b-12d3-a456-426614174702", "LASTLEG:FEE:701:1", null,
node, payloadHash);
}
@@ -162,8 +160,7 @@ class BusinessFeeSourceEventServiceTest {
payload.putNull("sourceHash");
payload.put("sourceHash", EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)));
payload.put("action", action);
return new EventEnvelope(base.eventId(), base.eventType(), 1, base.occurredAt(),
RegionCode.OVERSEAS, RegionCode.CN, "10001", "BUSINESS_FEE", "701", version,
return new EventEnvelope(base.eventId(), base.eventType(), 1, base.occurredAt(), "10001", "BUSINESS_FEE", "701", version,
null, base.traceId(), "LASTLEG:FEE:701:" + version, null, payload,
EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)));
}
@@ -4,7 +4,6 @@ import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.EventPayloadCanonicalizer;
import com.baiyue.common.event.EventPayloadHash;
import com.baiyue.common.event.EventTypes;
import com.baiyue.common.event.RegionCode;
import com.baiyue.common.event.WalletCommandRequestedV1;
import com.baiyue.finance.dto.FinanceWalletDtos.WalletHold;
import com.baiyue.finance.service.FinanceEventException;
@@ -44,7 +43,7 @@ class WalletCommandEventServiceTest {
void rejectsTamperedEnvelopeBeforeOpeningWalletTransaction() {
EventEnvelope valid = envelope("FREEZE", 1L, BigDecimal.TEN);
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));
assertThatThrownBy(() -> service.consume(tampered)).isInstanceOf(FinanceEventException.class)
@@ -122,7 +121,7 @@ class WalletCommandEventServiceTest {
void rejectsCustomerMismatchBeforeTouchingWallet() {
EventEnvelope envelope = envelope("FREEZE", 31L, new BigDecimal("10.00"));
envelope = new EventEnvelope(envelope.eventId(), envelope.eventType(), envelope.eventVersion(),
envelope.occurredAt(), envelope.sourceRegion(), envelope.targetRegion(), "32", envelope.aggregateType(),
envelope.occurredAt(), "32", envelope.aggregateType(),
envelope.aggregateId(), envelope.aggregateVersion(), envelope.handoverVersion(), envelope.traceId(),
envelope.correlationId(), envelope.causationId(), envelope.payload(), envelope.payloadHash());
@@ -139,8 +138,7 @@ class WalletCommandEventServiceTest {
"freeze-procurement-1", Instant.parse("2026-09-05T01:00:00Z"));
JsonNode payload = mapper.valueToTree(command);
return new EventEnvelope(UUID.randomUUID().toString(), EventTypes.WALLET_COMMAND_REQUESTED,
WalletCommandRequestedV1.VERSION, Instant.parse("2026-09-05T01:00:00Z"),
RegionCode.OVERSEAS, RegionCode.CN, String.valueOf(customerId), "WALLET_HOLD", "PO-1", 1,
WalletCommandRequestedV1.VERSION, Instant.parse("2026-09-05T01:00:00Z"), String.valueOf(customerId), "WALLET_HOLD", "PO-1", 1,
null, UUID.randomUUID().toString(), UUID.randomUUID().toString(), null, payload,
EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))).requireValid();
}
@@ -193,7 +193,7 @@ class WalletCommandTransactionTest {
new BigDecimal(amount), "fee", "freeze-1", Instant.parse("2026-09-05T01:00:00Z"));
var payload = mapper.valueToTree(command);
return new EventEnvelope(id, EventTypes.WALLET_COMMAND_REQUESTED, WalletCommandRequestedV1.VERSION,
Instant.now(), RegionCode.OVERSEAS, RegionCode.CN, "31", "WALLET_HOLD", "PO-1", 1,
Instant.now(), "31", "WALLET_HOLD", "PO-1", 1,
null, UUID.randomUUID().toString(), UUID.randomUUID().toString(), null, payload,
EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)));
}
@@ -8,9 +8,14 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import com.baiyue.common.core.outbox.EventDeliveryJournal;
import com.baiyue.common.web.EventDeliveryMetrics;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.UUID;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
@@ -23,15 +28,16 @@ class WalletResultOutboxLeaseTest {
var jdbc = new JdbcTemplate(new DriverManagerDataSource(
"jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""));
jdbc.execute("CREATE TABLE fin_wallet_event_outbox(event_id VARCHAR(64) PRIMARY KEY, payload_json TEXT, status VARCHAR(32), retry_count INT DEFAULT 0, next_retry_at TIMESTAMP NOT NULL, locked_by VARCHAR(128), locked_at TIMESTAMP, updated_at TIMESTAMP, published_at TIMESTAMP, last_error VARCHAR(1000))");
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 mapper = new ObjectMapper().registerModule(new JavaTimeModule());
var payload = mapper.createObjectNode().put("result", "SUCCEEDED");
var event = new EventEnvelope(UUID.randomUUID().toString(), EventTypes.WALLET_COMMAND_COMPLETED, 1,
Instant.now(), RegionCode.CN, RegionCode.OVERSEAS, "1", "WALLET_HOLD", "ORDER-1", 1L, null,
Instant.now(), "1", "WALLET_HOLD", "ORDER-1", 1L, null,
"trace", "correlation", null, payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)));
jdbc.update("INSERT INTO fin_wallet_event_outbox(event_id,payload_json,status,next_retry_at) VALUES(?,?,'PENDING',DATEADD('SECOND',-5,CURRENT_TIMESTAMP))",
event.eventId(), mapper.writeValueAsString(event));
var client = mock(WalletResultLastlegClient.class);
var relay = new WalletResultOutboxRelay(jdbc, mapper, client);
var relay = new WalletResultOutboxRelay(jdbc, new EventDeliveryJournal(jdbc, new DataSourceTransactionManager(jdbc.getDataSource())), new EventDeliveryMetrics(jdbc, new SimpleMeterRegistry()), mapper, client);
ReflectionTestUtils.setField(relay, "internalToken", "token");
ReflectionTestUtils.setField(relay, "maxAttempts", 12);
var firstEntered = new CountDownLatch(1);
@@ -54,8 +60,12 @@ class WalletResultOutboxLeaseTest {
Future<?> first = pool.submit(() -> relay.deliver(event.eventId()));
assertThat(firstEntered.await(10, TimeUnit.SECONDS)).isTrue();
String oldOwner = jdbc.queryForObject("SELECT locked_by FROM fin_wallet_event_outbox", String.class);
// Simulate lease recovery while the original HTTP request has not returned.
jdbc.update("UPDATE fin_wallet_event_outbox SET status='PENDING',locked_by=NULL,locked_at=NULL");
// The actual recovery path must finish the old audit and defer the new attempt.
jdbc.update("UPDATE fin_wallet_event_outbox SET locked_at=?", LocalDateTime.now().minusSeconds(61));
ReflectionTestUtils.invokeMethod(relay, "recoverExpired");
assertThat(jdbc.queryForObject("SELECT outcome FROM event_delivery_attempt WHERE attempt_id=?", String.class, oldOwner)).isEqualTo("LEASE_EXPIRED");
assertThat(jdbc.queryForObject("SELECT next_retry_at FROM fin_wallet_event_outbox", LocalDateTime.class)).isAfter(LocalDateTime.now());
jdbc.update("UPDATE fin_wallet_event_outbox SET next_retry_at=?", LocalDateTime.now().minusSeconds(1));
Future<?> second = pool.submit(() -> relay.deliver(event.eventId()));
assertThat(secondEntered.await(10, TimeUnit.SECONDS)).isTrue();
String newOwner = jdbc.queryForObject("SELECT locked_by FROM fin_wallet_event_outbox", String.class);
@@ -64,10 +74,12 @@ class WalletResultOutboxLeaseTest {
first.get(10, TimeUnit.SECONDS);
assertThat(jdbc.queryForObject("SELECT status FROM fin_wallet_event_outbox", String.class)).isEqualTo("SENDING");
assertThat(jdbc.queryForObject("SELECT locked_by FROM fin_wallet_event_outbox", String.class)).isEqualTo(newOwner);
assertThat(jdbc.queryForObject("SELECT retry_count FROM fin_wallet_event_outbox", Integer.class)).isZero();
assertThat(jdbc.queryForObject("SELECT retry_count FROM fin_wallet_event_outbox", Integer.class)).isEqualTo(1);
releaseSecond.countDown();
second.get(10, TimeUnit.SECONDS);
assertThat(jdbc.queryForObject("SELECT status FROM fin_wallet_event_outbox", String.class)).isEqualTo("PUBLISHED");
assertThat(jdbc.queryForObject("SELECT outcome FROM event_delivery_attempt WHERE attempt_id=?", String.class, newOwner)).isEqualTo("APPLIED");
assertThat(jdbc.queryForObject("SELECT outcome FROM event_delivery_attempt WHERE attempt_id=?", String.class, oldOwner)).isEqualTo("LEASE_EXPIRED");
} finally {
releaseFirst.countDown(); releaseSecond.countDown(); pool.shutdownNow();
}
@@ -8,12 +8,16 @@ import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import com.baiyue.common.core.outbox.EventDeliveryJournal;
import com.baiyue.common.web.EventDeliveryMetrics;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@@ -28,17 +32,18 @@ class WalletResultRetryTest {
jdbc = new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID()
+ ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""));
jdbc.execute("CREATE TABLE fin_wallet_event_outbox(event_id VARCHAR(64) PRIMARY KEY, payload_json TEXT, status VARCHAR(32), retry_count INT DEFAULT 0, next_retry_at TIMESTAMP NOT NULL, locked_by VARCHAR(128), locked_at TIMESTAMP, updated_at TIMESTAMP, published_at TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, last_error VARCHAR(1000))");
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 mapper = new ObjectMapper().registerModule(new JavaTimeModule());
var payload = mapper.createObjectNode().put("result", "SUCCEEDED");
eventId = UUID.randomUUID().toString();
var event = new EventEnvelope(eventId, EventTypes.WALLET_COMMAND_COMPLETED, 1, Instant.now(),
RegionCode.CN, RegionCode.OVERSEAS, "1", "WALLET_HOLD", "ORDER-1", 1L, null,
"1", "WALLET_HOLD", "ORDER-1", 1L, null,
"trace", "correlation", null, payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)));
originalJson = mapper.writeValueAsString(event);
jdbc.update("INSERT INTO fin_wallet_event_outbox(event_id,payload_json,status,next_retry_at) VALUES(?,?,'PENDING',?)",
eventId, originalJson, LocalDateTime.now().minusSeconds(1));
client = mock(WalletResultLastlegClient.class);
relay = new WalletResultOutboxRelay(jdbc, mapper, client);
relay = new WalletResultOutboxRelay(jdbc, new EventDeliveryJournal(jdbc, new DataSourceTransactionManager(jdbc.getDataSource())), new EventDeliveryMetrics(jdbc, new SimpleMeterRegistry()), mapper, client);
ReflectionTestUtils.setField(relay, "internalToken", "token");
ReflectionTestUtils.setField(relay, "maxAttempts", 3);
ReflectionTestUtils.setField(relay, "retryJitter", 0.0);
@@ -66,6 +71,7 @@ class WalletResultRetryTest {
@Test void expiredLastAttemptGoesDeadWithoutAnotherHttpRequest() {
jdbc.update("UPDATE fin_wallet_event_outbox SET status='SENDING',retry_count=2,locked_by='expired',locked_at=?",
LocalDateTime.now().minusSeconds(61));
jdbc.update("INSERT INTO event_delivery_attempt(attempt_id,service_name,event_type,entry_id,event_id,target_name,started_at,outcome) VALUES('expired','finance','WalletCommandCompleted',?,?,'lastleg-events',?,'STARTED')", eventId, eventId, LocalDateTime.now().minusSeconds(61));
relay.relay();
assertThat(status()).isEqualTo("DEAD");
assertThat(jdbc.queryForObject("SELECT retry_count FROM fin_wallet_event_outbox", Integer.class)).isEqualTo(3);
@@ -75,6 +81,7 @@ class WalletResultRetryTest {
@Test void expiredLeaseCountsAsFailureAndWaitsBeforeRetrying() {
jdbc.update("UPDATE fin_wallet_event_outbox SET status='SENDING',locked_by='expired',locked_at=?",
LocalDateTime.now().minusSeconds(61));
jdbc.update("INSERT INTO event_delivery_attempt(attempt_id,service_name,event_type,entry_id,event_id,target_name,started_at,outcome) VALUES('expired','finance','WalletCommandCompleted',?,?,'lastleg-events',?,'STARTED')", eventId, eventId, LocalDateTime.now().minusSeconds(61));
relay.relay();
assertThat(status()).isEqualTo("PENDING");
assertThat(jdbc.queryForObject("SELECT retry_count FROM fin_wallet_event_outbox", Integer.class)).isEqualTo(1);
@@ -82,5 +89,27 @@ class WalletResultRetryTest {
verifyNoInteractions(client);
}
@Test void auditStartFailurePreventsHttpAndRollsBackClaim() {
jdbc.execute("DROP TABLE event_delivery_attempt");
assertThatThrownBy(() -> relay.deliver(eventId)).isInstanceOf(RuntimeException.class);
assertThat(status()).isEqualTo("PENDING");
verifyNoInteractions(client);
}
@Test void successfulRemoteResponseWithFailedAuditCommitLeavesStartedLeaseForRecovery() throws Exception {
var mapper = new ObjectMapper().registerModule(new JavaTimeModule());
var event = mapper.readValue(originalJson, EventEnvelope.class);
when(client.receive(anyString(), any())).thenAnswer(invocation -> {
assertThat(jdbc.queryForObject("SELECT outcome FROM event_delivery_attempt", String.class)).isEqualTo("STARTED");
return com.baiyue.common.core.api.ApiResponse.success(DeliveryAck.of(event, DeliveryAck.Outcome.APPLIED));
});
jdbc.execute("ALTER TABLE event_delivery_attempt ADD CONSTRAINT reject_applied CHECK(outcome <> 'APPLIED')");
assertThatThrownBy(() -> relay.deliver(eventId)).isInstanceOf(RuntimeException.class);
assertThat(status()).isEqualTo("SENDING");
assertThat(jdbc.queryForObject("SELECT retry_count FROM fin_wallet_event_outbox", Integer.class)).isZero();
assertThat(jdbc.queryForObject("SELECT outcome FROM event_delivery_attempt", String.class)).isEqualTo("STARTED");
verify(client).receive(anyString(), any());
}
private String status() { return jdbc.queryForObject("SELECT status FROM fin_wallet_event_outbox", String.class); }
}
@@ -0,0 +1,22 @@
package com.baiyue.finance.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);
}
}