This commit is contained in:
2026-09-29 17:55:54 +08:00
parent 41d8387cd5
commit 54ad26c607
111 changed files with 1837 additions and 962 deletions
@@ -52,7 +52,7 @@ class BusinessFeeSourceEventServiceTest {
when(jdbc.queryForObject(startsWith("SELECT id FROM fin_fee_source"), eq(Long.class),
eq("LASTLEG"), eq("OVERSEAS_PROCUREMENT_FEE"), eq(701L), eq(1))).thenReturn(901L);
assertThat(service.consume(event)).isEqualTo(901L);
assertThat(service.consume(event).eventId()).isEqualTo(event.eventId());
ArgumentCaptor<Object[]> values = ArgumentCaptor.forClass(Object[].class);
verify(jdbc).update(argThat(sql -> sql.startsWith("INSERT INTO fin_fee_source")
@@ -97,7 +97,7 @@ class BusinessFeeSourceEventServiceTest {
new com.baiyue.finance.client.WarehouseSupplierClient.SupplierSnapshot(71L, "供应商", null, "ACTIVE"));
when(jdbc.queryForObject(startsWith("SELECT id FROM fin_fee_source"), eq(Long.class),
eq("LASTLEG"), eq("OVERSEAS_CONTAINER_FEE"), eq(701L), eq(1))).thenReturn(901L);
assertThat(service.consume(event)).isEqualTo(901L);
assertThat(service.consume(event).eventId()).isEqualTo(event.eventId());
ArgumentCaptor<Object[]> source = ArgumentCaptor.forClass(Object[].class);
verify(jdbc).update(startsWith("INSERT INTO fin_fee_source"), source.capture());
assertThat(source.getValue()[0]).isEqualTo("LASTLEG");
@@ -138,7 +138,7 @@ class BusinessFeeSourceEventServiceTest {
new com.baiyue.finance.client.WarehouseSupplierClient.SupplierSnapshot(71L, "供应商", null, "ACTIVE"));
when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("LASTLEG"), eq(event.eventId())))
.thenReturn(List.of(Map.of("payload_hash", event.payloadHash(), "status", "PROCESSED", "result_resource_id", 901L)));
assertThat(service.consume(event)).isEqualTo(901L);
assertThat(service.consume(event).eventId()).isEqualTo(event.eventId());
verify(jdbc, never()).update(anyString(), any(Object[].class));
}
@@ -189,7 +189,7 @@ class BusinessFeeSourceEventServiceTest {
void voidedSourceCannotBeRevivedByPublicationReplay() {
var event = payableEvent("PUBLISHED", 1);
existingVersion(event.payload().get("sourceHash").asText(), "VOIDED");
assertThat(service.consume(event)).isEqualTo(901L);
assertThat(service.consume(event).eventId()).isEqualTo(event.eventId());
noFinancialWrites();
}
@@ -197,7 +197,7 @@ class BusinessFeeSourceEventServiceTest {
void voidTargetsOnlyMatchingOverseasCostVersion() {
var event = payableEvent("VOIDED", 1);
existingVersion(event.payload().get("sourceHash").asText(), "AVAILABLE");
assertThat(service.consume(event)).isEqualTo(901L);
assertThat(service.consume(event).eventId()).isEqualTo(event.eventId());
verify(jdbc).update(startsWith("UPDATE fin_cost SET status='VOIDED'"),
eq("LASTLEG"), eq("OVERSEAS_CONTAINER_FEE"), eq(701L), eq(1));
verify(jdbc, never()).update(startsWith("INSERT INTO fin_cost"), any(Object[].class));
@@ -16,14 +16,12 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.TransactionCallback;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.transaction.PlatformTransactionManager;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
import java.util.function.Consumer;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -34,39 +32,31 @@ class WalletCommandEventServiceTest {
private final JdbcTemplate jdbc = mock(JdbcTemplate.class);
private final ObjectMapper mapper = new ObjectMapper().findAndRegisterModules();
private final FinanceWalletService wallet = mock(FinanceWalletService.class);
private final TransactionTemplate transactions = mock(TransactionTemplate.class);
private final PlatformTransactionManager transactions = mock(PlatformTransactionManager.class);
private final WalletCommandEventService service = new WalletCommandEventService(jdbc, mapper, wallet, transactions);
@BeforeEach
void executeTransactionCallbacks() {
when(transactions.execute(any())).thenAnswer(invocation -> {
TransactionCallback<?> callback = invocation.getArgument(0);
return callback.doInTransaction(mock(TransactionStatus.class));
});
doAnswer(invocation -> {
Consumer<TransactionStatus> callback = invocation.getArgument(0);
callback.accept(mock(TransactionStatus.class));
return null;
}).when(transactions).executeWithoutResult(any());
when(transactions.getTransaction(any())).thenAnswer(invocation -> mock(TransactionStatus.class));
}
@Test
void freezesWalletAndPublishesCompletedEvent() {
EventEnvelope envelope = envelope("FREEZE", 31L, new BigDecimal("88.50"));
when(jdbc.queryForList(startsWith("SELECT payload_hash"), eq(String.class), eq(envelope.eventId())))
.thenReturn(List.of());
.thenReturn(List.of(envelope.payloadHash()));
when(jdbc.queryForObject(startsWith("SELECT COUNT(*)"), eq(Integer.class), anyString())).thenReturn(0);
when(wallet.freeze(eq(31L), eq("freeze-procurement-1"), any())).thenReturn(
new WalletHold(9L, 31L, "OVERSEAS_PROCUREMENT_FEE", "PO-1", new BigDecimal("88.50"),
new BigDecimal("80.00"), new BigDecimal("8.50"), "FROZEN", 71L, null,
null, null, null));
assertThat(service.consume(envelope)).isEqualTo(envelope.eventId());
assertThat(service.consume(envelope).eventId()).isEqualTo(envelope.eventId());
verify(wallet).freeze(eq(31L), eq("freeze-procurement-1"), argThat(request ->
request.amount().compareTo(new BigDecimal("88.50")) == 0
&& request.businessNo().equals("PO-1")));
verify(jdbc).update(startsWith("INSERT IGNORE INTO fin_wallet_event_outbox"), any(Object[].class));
verify(jdbc).update(startsWith("INSERT INTO fin_wallet_event_outbox"), any(Object[].class));
verify(jdbc).update(startsWith("UPDATE fin_wallet_command_inbox SET status="),
eq("SUCCEEDED"), contains("\"holdStatus\":\"FROZEN\""), eq(envelope.eventId()));
}
@@ -75,7 +65,7 @@ class WalletCommandEventServiceTest {
void settledWalletHoldMarksMatchingReceivableSourceSettled() {
EventEnvelope envelope = envelope("SETTLE", 31L, new BigDecimal("88.50"));
when(jdbc.queryForList(startsWith("SELECT payload_hash"), eq(String.class), eq(envelope.eventId())))
.thenReturn(List.of());
.thenReturn(List.of(envelope.payloadHash()));
when(jdbc.queryForObject(startsWith("SELECT COUNT(*)"), eq(Integer.class), anyString())).thenReturn(0);
when(wallet.settle(eq(31L), eq("OVERSEAS_PROCUREMENT_FEE"), eq("PO-1"),
eq("freeze-procurement-1"), any())).thenReturn(new WalletHold(9L, 31L,
@@ -87,6 +77,35 @@ class WalletCommandEventServiceTest {
verify(jdbc).update(startsWith("UPDATE fin_fee_source f JOIN fin_wallet_hold"), eq(9L));
}
@Test
void transientWalletFailureMustNotBecomeAFinalResult() {
EventEnvelope event = envelope("FREEZE", 31L, new BigDecimal("10.00"));
when(jdbc.queryForList(startsWith("SELECT payload_hash"), eq(String.class), anyString()))
.thenReturn(List.of(event.payloadHash()));
when(jdbc.queryForObject(startsWith("SELECT COUNT(*)"), eq(Integer.class), anyString())).thenReturn(0);
var failure = new org.springframework.dao.TransientDataAccessResourceException("temporary database failure");
when(wallet.freeze(anyLong(), anyString(), any())).thenThrow(failure);
assertThatThrownBy(() -> service.consume(event)).isSameAs(failure);
verify(jdbc, never()).update(startsWith("INSERT INTO fin_wallet_event_outbox"), any(Object[].class));
}
@Test
void losingInboxInsertMustRecheckThePersistedHash() {
EventEnvelope event = envelope("FREEZE", 31L, new BigDecimal("10.00"));
when(jdbc.queryForList(startsWith("SELECT payload_hash"), eq(String.class), anyString()))
.thenAnswer(invocation -> ((String) invocation.getArgument(0)).contains("FOR UPDATE")
? List.of("a-different-persisted-hash") : List.of());
when(jdbc.update(startsWith("INSERT INTO fin_wallet_command_inbox"), any(Object[].class)))
.thenThrow(new org.springframework.dao.DuplicateKeyException("another request won"));
when(jdbc.queryForObject(startsWith("SELECT COUNT(*)"), eq(Integer.class), anyString())).thenReturn(0);
assertThatThrownBy(() -> service.consume(event))
.isInstanceOf(FinanceEventException.class)
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-EVT-002");
verifyNoInteractions(wallet);
}
@Test
void rejectsCustomerMismatchBeforeTouchingWallet() {
EventEnvelope envelope = envelope("FREEZE", 31L, new BigDecimal("10.00"));
@@ -0,0 +1,205 @@
package com.baiyue.finance;
import com.baiyue.common.event.*;
import com.baiyue.finance.dto.FinanceWalletDtos.WalletHold;
import com.baiyue.finance.service.FinanceEventException;
import com.baiyue.finance.service.FinanceWalletService;
import com.baiyue.finance.service.WalletCommandEventService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.dao.TransientDataAccessResourceException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.UUID;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicBoolean;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/** Real JDBC transactions and row locks; the wallet boundary is a durable idempotent test double. */
class WalletCommandTransactionTest {
private JdbcTemplate jdbc;
private FinanceWalletService wallet;
private WalletCommandEventService service;
private final ObjectMapper mapper = new ObjectMapper().findAndRegisterModules();
@BeforeEach
void setup() {
JdbcDataSource source = new JdbcDataSource();
source.setURL("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=5000");
jdbc = spy(new JdbcTemplate(source));
jdbc.execute("CREATE TABLE fin_wallet_command_inbox(event_id VARCHAR(128) PRIMARY KEY,event_type VARCHAR(100),payload_hash VARCHAR(128),payload_json JSON,status VARCHAR(32),received_at TIMESTAMP,result_json JSON,processed_at TIMESTAMP)");
jdbc.execute("CREATE TABLE fin_wallet_event_outbox(event_id VARCHAR(128) PRIMARY KEY,event_key VARCHAR(200) UNIQUE,payload_json JSON,status VARCHAR(32),next_retry_at TIMESTAMP,created_at TIMESTAMP,updated_at TIMESTAMP)");
jdbc.execute("CREATE TABLE wallet_effect(idempotency_key VARCHAR(128) PRIMARY KEY,amount DECIMAL(18,2))");
wallet = mock(FinanceWalletService.class);
service = new WalletCommandEventService(jdbc, mapper, wallet, new DataSourceTransactionManager(source));
doAnswer(invocation -> {
String key = invocation.getArgument(1);
// Models the existing FinanceWalletService's durable replay behavior.
if (jdbc.queryForObject("SELECT COUNT(*) FROM wallet_effect WHERE idempotency_key=?", Integer.class, key) == 0) {
jdbc.update("INSERT INTO wallet_effect VALUES(?,?)", key, new BigDecimal("10.00"));
}
return hold();
}).when(wallet).freeze(anyLong(), anyString(), any());
}
@Test
void transientFailureRollsBackMoneyAndCanRetryTheOriginalEvent() {
EventEnvelope event = event(UUID.randomUUID().toString(), "10.00");
AtomicBoolean first = new AtomicBoolean(true);
doAnswer(invocation -> {
jdbc.update("INSERT INTO wallet_effect VALUES('freeze-1',10.00)");
if (first.getAndSet(false)) throw new TransientDataAccessResourceException("temporary outage");
return hold();
}).when(wallet).freeze(anyLong(), anyString(), any());
assertThatThrownBy(() -> service.consume(event)).isInstanceOf(TransientDataAccessResourceException.class);
assertThat(count("wallet_effect")).isZero();
assertThat(count("fin_wallet_event_outbox")).isZero();
assertThat(inboxStatus(event)).isEqualTo("PROCESSING");
assertThat(service.consume(event).eventId()).isEqualTo(event.eventId());
assertThat(count("wallet_effect")).isEqualTo(1);
assertThat(count("fin_wallet_event_outbox")).isEqualTo(1);
assertThat(inboxStatus(event)).isEqualTo("SUCCEEDED");
}
@Test
void businessRejectionRollsBackMoneyAndPublishesExactlyOneFailedResult() {
EventEnvelope event = event(UUID.randomUUID().toString(), "10.00");
doAnswer(invocation -> {
jdbc.update("INSERT INTO wallet_effect VALUES('freeze-1',10.00)");
throw new FinanceEventException("FIN-WALLET-INSUFFICIENT", "insufficient balance");
}).when(wallet).freeze(anyLong(), anyString(), any());
service.consume(event);
service.consume(event);
assertThat(count("wallet_effect")).isZero();
assertThat(count("fin_wallet_event_outbox")).isEqualTo(1);
assertThat(inboxStatus(event)).isEqualTo("FAILED");
verify(wallet, times(1)).freeze(anyLong(), anyString(), any());
}
@Test
void retryableFinanceCodeDoesNotPublishATerminalResult() {
EventEnvelope event = event(UUID.randomUUID().toString(), "10.00");
doThrow(new FinanceEventException("FIN-IDEMPOTENCY-PROCESSING", "in progress"))
.when(wallet).freeze(anyLong(), anyString(), any());
assertThatThrownBy(() -> service.consume(event)).isInstanceOf(FinanceEventException.class);
assertThat(inboxStatus(event)).isEqualTo("PROCESSING");
assertThat(count("fin_wallet_event_outbox")).isZero();
}
@Test
void failureAfterResultInsertRollsBackMoneyAndResultAndCanRetry() {
EventEnvelope event = event(UUID.randomUUID().toString(), "10.00");
AtomicBoolean first = new AtomicBoolean(true);
doAnswer(invocation -> {
if (first.getAndSet(false)) throw new TransientDataAccessResourceException("result commit failed");
return invocation.callRealMethod();
}).when(jdbc).update(startsWith("UPDATE fin_wallet_command_inbox SET status="), any(Object[].class));
assertThatThrownBy(() -> service.consume(event)).isInstanceOf(TransientDataAccessResourceException.class);
assertThat(count("wallet_effect")).isZero();
assertThat(count("fin_wallet_event_outbox")).isZero();
assertThat(inboxStatus(event)).isEqualTo("PROCESSING");
assertThat(service.consume(event).eventId()).isEqualTo(event.eventId());
assertThat(count("wallet_effect")).isEqualTo(1);
assertThat(count("fin_wallet_event_outbox")).isEqualTo(1);
assertThat(inboxStatus(event)).isEqualTo("SUCCEEDED");
verify(wallet, times(2)).freeze(eq(31L), eq("freeze-1"), any());
}
@Test
void differentPayloadCannotReplaceIdentityAfterAResultFailure() {
EventEnvelope event = event(UUID.randomUUID().toString(), "10.00");
doThrow(new TransientDataAccessResourceException("outbox unavailable"))
.when(jdbc).update(startsWith("INSERT INTO fin_wallet_event_outbox"), any(Object[].class));
assertThatThrownBy(() -> service.consume(event)).isInstanceOf(TransientDataAccessResourceException.class);
assertThatThrownBy(() -> service.consume(event(event.eventId(), "20.00")))
.isInstanceOf(FinanceEventException.class)
.extracting(e -> ((FinanceEventException) e).code()).isEqualTo("FIN-EVT-002");
assertThat(count("wallet_effect")).isZero();
verify(wallet, times(1)).freeze(anyLong(), anyString(), any());
}
@Test
void concurrentSamePayloadExecutesOnce() throws Exception {
runConcurrent(false);
}
@Test
void concurrentDifferentPayloadIsRejectedBeforeWalletExecution() throws Exception {
runConcurrent(true);
}
private void runConcurrent(boolean differentPayload) throws Exception {
EventEnvelope firstEvent = event(UUID.randomUUID().toString(), "10.00");
EventEnvelope secondEvent = differentPayload ? event(firstEvent.eventId(), "20.00") : firstEvent;
CountDownLatch enteredWallet = new CountDownLatch(1);
CountDownLatch releaseWallet = new CountDownLatch(1);
CountDownLatch secondStarted = new CountDownLatch(1);
doAnswer(invocation -> {
enteredWallet.countDown();
if (!releaseWallet.await(5, TimeUnit.SECONDS)) throw new AssertionError("test timed out");
jdbc.update("INSERT INTO wallet_effect VALUES('freeze-1',10.00)");
return hold();
}).when(wallet).freeze(anyLong(), anyString(), any());
ExecutorService executor = Executors.newFixedThreadPool(2);
try {
Future<com.baiyue.common.event.DeliveryAck> first = executor.submit(() -> service.consume(firstEvent));
assertThat(enteredWallet.await(5, TimeUnit.SECONDS)).isTrue();
Future<com.baiyue.common.event.DeliveryAck> second = executor.submit(() -> {
secondStarted.countDown();
return service.consume(secondEvent);
});
assertThat(secondStarted.await(5, TimeUnit.SECONDS)).isTrue();
assertThatThrownBy(() -> second.get(150, TimeUnit.MILLISECONDS)).isInstanceOf(TimeoutException.class);
releaseWallet.countDown();
assertThat(first.get(5, TimeUnit.SECONDS).eventId()).isEqualTo(firstEvent.eventId());
if (differentPayload) {
assertThatThrownBy(() -> second.get(5, TimeUnit.SECONDS))
.isInstanceOf(ExecutionException.class).hasCauseInstanceOf(FinanceEventException.class)
.satisfies(e -> assertThat(((FinanceEventException) e.getCause()).code()).isEqualTo("FIN-EVT-002"));
} else {
assertThat(second.get(5, TimeUnit.SECONDS).eventId()).isEqualTo(firstEvent.eventId());
}
verify(wallet, times(1)).freeze(anyLong(), anyString(), any());
assertThat(count("wallet_effect")).isEqualTo(1);
assertThat(count("fin_wallet_event_outbox")).isEqualTo(1);
assertThat(inboxStatus(firstEvent)).isEqualTo("SUCCEEDED");
} finally {
releaseWallet.countDown();
executor.shutdownNow();
assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue();
}
}
private int count(String table) {
return jdbc.queryForObject("SELECT COUNT(*) FROM " + table, Integer.class);
}
private String inboxStatus(EventEnvelope event) {
return jdbc.queryForObject("SELECT status FROM fin_wallet_command_inbox WHERE event_id=?", String.class, event.eventId());
}
private EventEnvelope event(String id, String amount) {
var command = new WalletCommandRequestedV1("FREEZE", 31L, "OVERSEAS_PROCUREMENT_FEE", "PO-1",
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,
null, UUID.randomUUID().toString(), UUID.randomUUID().toString(), null, payload,
EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)));
}
private WalletHold hold() {
return new WalletHold(9L, 31L, "OVERSEAS_PROCUREMENT_FEE", "PO-1", new BigDecimal("10.00"),
new BigDecimal("10.00"), BigDecimal.ZERO, "FROZEN", 71L, null, null, null, null);
}
}
@@ -0,0 +1,75 @@
package com.baiyue.finance;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.event.*;
import com.baiyue.finance.client.WalletResultLastlegClient;
import com.baiyue.finance.service.WalletResultOutboxRelay;
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.DriverManagerDataSource;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.Instant;
import java.util.UUID;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class WalletResultOutboxLeaseTest {
@Test void expiredAttemptCannotOverwriteNewAttemptInSameRelayInstance() throws Exception {
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))");
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,
"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);
ReflectionTestUtils.setField(relay, "internalToken", "token");
ReflectionTestUtils.setField(relay, "maxAttempts", 12);
var firstEntered = new CountDownLatch(1);
var secondEntered = new CountDownLatch(1);
var releaseFirst = new CountDownLatch(1);
var releaseSecond = new CountDownLatch(1);
var calls = new AtomicInteger();
when(client.receive(eq("token"), any())).thenAnswer(invocation -> {
if (calls.incrementAndGet() == 1) {
firstEntered.countDown();
if (!releaseFirst.await(10, TimeUnit.SECONDS)) throw new IllegalStateException("test timeout");
throw new IllegalStateException("late failure from expired request");
}
secondEntered.countDown();
if (!releaseSecond.await(10, TimeUnit.SECONDS)) throw new IllegalStateException("test timeout");
return ApiResponse.success(DeliveryAck.of(event, DeliveryAck.Outcome.APPLIED));
});
ExecutorService pool = Executors.newFixedThreadPool(2);
try {
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");
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);
assertThat(newOwner).isNotEqualTo(oldOwner);
releaseFirst.countDown();
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();
releaseSecond.countDown();
second.get(10, TimeUnit.SECONDS);
assertThat(jdbc.queryForObject("SELECT status FROM fin_wallet_event_outbox", String.class)).isEqualTo("PUBLISHED");
} finally {
releaseFirst.countDown(); releaseSecond.countDown(); pool.shutdownNow();
}
}
}
@@ -0,0 +1,86 @@
package com.baiyue.finance;
import com.baiyue.common.event.*;
import com.baiyue.finance.client.WalletResultLastlegClient;
import com.baiyue.finance.service.WalletResultOutboxRelay;
import com.fasterxml.jackson.databind.ObjectMapper;
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.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.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class WalletResultRetryTest {
JdbcTemplate jdbc;
WalletResultLastlegClient client;
WalletResultOutboxRelay relay;
String eventId;
String originalJson;
@BeforeEach void setup() throws Exception {
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))");
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,
"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);
ReflectionTestUtils.setField(relay, "internalToken", "token");
ReflectionTestUtils.setField(relay, "maxAttempts", 3);
ReflectionTestUtils.setField(relay, "retryJitter", 0.0);
}
@Test void failedDeliveryBacksOffThenStopsAtConfiguredLimitWithoutChangingPayload() {
when(client.receive(anyString(), any())).thenThrow(new IllegalStateException("service unavailable"));
LocalDateTime before = LocalDateTime.now();
relay.deliver(eventId);
LocalDateTime next = jdbc.queryForObject("SELECT next_retry_at FROM fin_wallet_event_outbox", LocalDateTime.class);
assertThat(next).isAfterOrEqualTo(before.plusSeconds(5)).isBefore(LocalDateTime.now().plusSeconds(6));
assertThat(status()).isEqualTo("PENDING");
for (int i = 0; i < 2; i++) {
jdbc.update("UPDATE fin_wallet_event_outbox SET next_retry_at=?", LocalDateTime.now().minusSeconds(1));
relay.deliver(eventId);
}
assertThat(status()).isEqualTo("DEAD");
assertThat(jdbc.queryForObject("SELECT retry_count FROM fin_wallet_event_outbox", Integer.class)).isEqualTo(3);
assertThat(jdbc.queryForObject("SELECT locked_by FROM fin_wallet_event_outbox", String.class)).isNull();
assertThat(jdbc.queryForObject("SELECT payload_json FROM fin_wallet_event_outbox", String.class)).isEqualTo(originalJson);
relay.deliver(eventId);
verify(client, times(3)).receive(anyString(), any());
}
@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));
relay.relay();
assertThat(status()).isEqualTo("DEAD");
assertThat(jdbc.queryForObject("SELECT retry_count FROM fin_wallet_event_outbox", Integer.class)).isEqualTo(3);
verifyNoInteractions(client);
}
@Test void expiredLeaseCountsAsFailureAndWaitsBeforeRetrying() {
jdbc.update("UPDATE fin_wallet_event_outbox SET status='SENDING',locked_by='expired',locked_at=?",
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);
assertThat(jdbc.queryForObject("SELECT next_retry_at FROM fin_wallet_event_outbox", LocalDateTime.class)).isAfter(LocalDateTime.now());
verifyNoInteractions(client);
}
private String status() { return jdbc.queryForObject("SELECT status FROM fin_wallet_event_outbox", String.class); }
}