优化
This commit is contained in:
@@ -1,18 +0,0 @@
|
||||
package com.baiyue.finance.client;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.event.EventEnvelope;
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
|
||||
@FeignClient(name = "event-bridge-service", contextId = "financeWalletResultEventBridgeClient",
|
||||
url = "${OVERSEAS_EVENT_BRIDGE_URL:${baiyue.wallet-event.event-bridge-url:}}")
|
||||
public interface WalletResultEventBridgeClient {
|
||||
@PostMapping("/internal/event-bridge/events")
|
||||
ApiResponse<Acknowledgement> receive(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestBody EventEnvelope envelope);
|
||||
|
||||
record Acknowledgement(String eventId, String result, String receivedAt) { }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.baiyue.finance.client;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.web.EventDeliveryFeignConfiguration;
|
||||
import com.baiyue.common.event.EventEnvelope;
|
||||
import com.baiyue.common.event.DeliveryAck;
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
|
||||
@FeignClient(configuration = EventDeliveryFeignConfiguration.class, name = "lastleg-service", contextId = "financeWalletResultLastlegClient",
|
||||
url = "${LASTLEG_SERVICE_URL:${baiyue.clients.lastleg.url:}}")
|
||||
public interface WalletResultLastlegClient {
|
||||
@PostMapping("/internal/lastleg/events")
|
||||
ApiResponse<DeliveryAck> receive(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestBody EventEnvelope envelope);
|
||||
|
||||
}
|
||||
+2
-2
@@ -25,7 +25,7 @@ public class InternalBusinessFeeSourceController {
|
||||
@Value("${baiyue.security.internal-service-token}") private String expectedToken;
|
||||
|
||||
@PostMapping
|
||||
public ApiResponse<Map<String, Long>> consume(
|
||||
public ApiResponse<com.baiyue.common.event.DeliveryAck> consume(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@RequestBody EventEnvelope event) {
|
||||
if (token == null || expectedToken == null || expectedToken.isBlank()
|
||||
@@ -33,6 +33,6 @@ public class InternalBusinessFeeSourceController {
|
||||
expectedToken.getBytes(StandardCharsets.UTF_8))) {
|
||||
throw new FinanceEventException("FIN-EVT-401", "内部服务令牌无效");
|
||||
}
|
||||
return FinanceResponses.success(Map.of("feeSourceId", service.consume(event)));
|
||||
return FinanceResponses.success(service.consume(event));
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -24,7 +24,7 @@ public class InternalWalletCommandEventController {
|
||||
@Value("${baiyue.security.internal-service-token}") private String expectedToken;
|
||||
|
||||
@PostMapping
|
||||
public ApiResponse<Map<String, String>> consume(
|
||||
public ApiResponse<com.baiyue.common.event.DeliveryAck> consume(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@RequestBody EventEnvelope event) {
|
||||
if (token == null || expectedToken == null || expectedToken.isBlank()
|
||||
@@ -32,6 +32,6 @@ public class InternalWalletCommandEventController {
|
||||
expectedToken.getBytes(StandardCharsets.UTF_8))) {
|
||||
throw new FinanceEventException("FIN-EVT-401", "内部服务令牌无效");
|
||||
}
|
||||
return FinanceResponses.success(Map.of("eventId", service.consume(event)));
|
||||
return FinanceResponses.success(service.consume(event));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.baiyue.finance.service;
|
||||
|
||||
import com.baiyue.common.event.BusinessFeeSourceChangedV1;
|
||||
import com.baiyue.common.event.EventEnvelope;
|
||||
import com.baiyue.common.event.DeliveryAck;
|
||||
import com.baiyue.common.event.EventPayloadCanonicalizer;
|
||||
import com.baiyue.common.event.EventPayloadHash;
|
||||
import com.baiyue.common.event.EventTypes;
|
||||
@@ -28,7 +29,7 @@ public class BusinessFeeSourceEventService {
|
||||
private final FinanceSupplierValidator supplierValidator;
|
||||
|
||||
@Transactional
|
||||
public long consume(EventEnvelope envelope) {
|
||||
public DeliveryAck consume(EventEnvelope envelope) {
|
||||
try {
|
||||
envelope.requireValid();
|
||||
} catch (RuntimeException exception) {
|
||||
@@ -51,7 +52,7 @@ public class BusinessFeeSourceEventService {
|
||||
if (!envelope.payloadHash().equals(row.get("payload_hash")))
|
||||
throw error("FIN-EVT-002", "同一事件ID对应不同费用快照");
|
||||
if ("PROCESSED".equals(row.get("status")) && row.get("result_resource_id") instanceof Number id)
|
||||
return id.longValue();
|
||||
return DeliveryAck.of(envelope, DeliveryAck.Outcome.DUPLICATE);
|
||||
throw error("FIN-EVT-003", "相同事件正在处理或上次处理失败");
|
||||
}
|
||||
jdbc.update("INSERT INTO fin_inbox_event(event_id,source_system,event_type,payload,payload_hash,status) VALUES (?,?,?,?,?,'RECEIVED')",
|
||||
@@ -59,7 +60,7 @@ public class BusinessFeeSourceEventService {
|
||||
long result = apply(sourceSystem, source);
|
||||
jdbc.update("UPDATE fin_inbox_event SET status='PROCESSED',result_resource_id=?,processed_at=CURRENT_TIMESTAMP(3),error_code=NULL WHERE source_system=? AND event_id=?",
|
||||
result, sourceSystem, envelope.eventId());
|
||||
return result;
|
||||
return DeliveryAck.of(envelope, DeliveryAck.Outcome.APPLIED);
|
||||
}
|
||||
|
||||
private long apply(String system, BusinessFeeSourceChangedV1 source) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.baiyue.finance.service;
|
||||
|
||||
import com.baiyue.common.event.EventEnvelope;
|
||||
import com.baiyue.common.event.DeliveryAck;
|
||||
import com.baiyue.common.event.EventEnvelopeValidator;
|
||||
import com.baiyue.common.event.EventIdentifiers;
|
||||
import com.baiyue.common.event.EventPayloadCanonicalizer;
|
||||
@@ -14,10 +15,11 @@ import com.baiyue.finance.dto.FinanceWalletDtos.WalletHold;
|
||||
import com.baiyue.finance.dto.FinanceWalletDtos.WalletHoldActionRequest;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.TransactionDefinition;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
@@ -27,35 +29,62 @@ import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class WalletCommandEventService {
|
||||
private static final Set<String> OPERATIONS = Set.of("FREEZE", "SETTLE", "RELEASE");
|
||||
private static final Set<String> FINAL_REJECTIONS = Set.of(
|
||||
"FIN-WALLET-INSUFFICIENT", "FIN-WALLET-HOLD-409", "FIN-IDEMPOTENCY-CONFLICT", "FIN-REQ-400");
|
||||
private final JdbcTemplate jdbc;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final FinanceWalletService walletService;
|
||||
private final TransactionTemplate transactions;
|
||||
|
||||
public String consume(EventEnvelope envelope) {
|
||||
public WalletCommandEventService(JdbcTemplate jdbc, ObjectMapper objectMapper,
|
||||
FinanceWalletService walletService, PlatformTransactionManager transactionManager) {
|
||||
this.jdbc = jdbc;
|
||||
this.objectMapper = objectMapper;
|
||||
this.walletService = walletService;
|
||||
this.transactions = new TransactionTemplate(transactionManager);
|
||||
this.transactions.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
|
||||
}
|
||||
|
||||
public DeliveryAck consume(EventEnvelope envelope) {
|
||||
WalletCommandRequestedV1 command = validate(envelope);
|
||||
String existing = existingHash(envelope.eventId());
|
||||
if (existing != null && !existing.equals(envelope.payloadHash())) {
|
||||
// Keep event identity durable even if processing rolls back.
|
||||
transactions.executeWithoutResult(status -> reserve(envelope));
|
||||
try {
|
||||
return transactions.execute(status -> {
|
||||
requireLockedIdentity(envelope);
|
||||
if (resultExists(envelope.eventId())) return DeliveryAck.of(envelope, DeliveryAck.Outcome.DUPLICATE);
|
||||
// Wallet methods join this transaction: money, the Inbox outcome and
|
||||
// the result Outbox commit together, or all roll back.
|
||||
WalletHold hold = execute(command);
|
||||
if (hold == null) throw new IllegalStateException("资金操作未返回处理结果");
|
||||
finalizeResult(envelope, completed(envelope.eventId(), command, "SUCCEEDED",
|
||||
hold.status(), hold.amount(), null, null));
|
||||
return DeliveryAck.of(envelope, DeliveryAck.Outcome.APPLIED);
|
||||
});
|
||||
} catch (FinanceEventException exception) {
|
||||
if (!FINAL_REJECTIONS.contains(exception.code())) throw exception;
|
||||
// A joined wallet transaction may have been marked rollback-only. Persist
|
||||
// a final business rejection only AFTER that transaction has rolled back.
|
||||
return transactions.execute(status -> {
|
||||
requireLockedIdentity(envelope);
|
||||
if (resultExists(envelope.eventId())) return DeliveryAck.of(envelope, DeliveryAck.Outcome.DUPLICATE);
|
||||
finalizeResult(envelope, completed(envelope.eventId(), command, "FAILED",
|
||||
null, command.amount(), exception.code(), exception.getMessage()));
|
||||
return DeliveryAck.of(envelope, DeliveryAck.Outcome.APPLIED);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void requireLockedIdentity(EventEnvelope envelope) {
|
||||
List<String> hashes = jdbc.queryForList(
|
||||
"SELECT payload_hash FROM fin_wallet_command_inbox WHERE event_id=? FOR UPDATE",
|
||||
String.class, envelope.eventId());
|
||||
if (hashes.size() != 1) throw new IllegalStateException("资金指令身份记录不存在");
|
||||
if (!hashes.get(0).equals(envelope.payloadHash())) {
|
||||
throw new FinanceEventException("FIN-EVT-002", "同一事件ID对应不同资金指令");
|
||||
}
|
||||
if (existing == null) reserve(envelope);
|
||||
if (resultExists(envelope.eventId())) return envelope.eventId();
|
||||
|
||||
WalletCommandCompletedV1 result;
|
||||
try {
|
||||
WalletHold hold = transactions.execute(status -> execute(command));
|
||||
result = completed(envelope.eventId(), command, "SUCCEEDED", hold.status(), hold.amount(), null, null);
|
||||
} catch (FinanceEventException exception) {
|
||||
result = completed(envelope.eventId(), command, "FAILED", null, command.amount(), exception.code(), exception.getMessage());
|
||||
} catch (RuntimeException exception) {
|
||||
result = completed(envelope.eventId(), command, "FAILED", null, command.amount(), "FIN-WALLET-COMMAND-FAILED", safe(exception.getMessage()));
|
||||
}
|
||||
WalletCommandCompletedV1 finalResult = result;
|
||||
transactions.executeWithoutResult(status -> finalizeResult(envelope, finalResult));
|
||||
return envelope.eventId();
|
||||
}
|
||||
|
||||
private WalletHold execute(WalletCommandRequestedV1 command) {
|
||||
@@ -104,7 +133,10 @@ public class WalletCommandEventService {
|
||||
try {
|
||||
jdbc.update("INSERT INTO fin_wallet_command_inbox(event_id,event_type,payload_hash,payload_json,status,received_at) VALUES(?,?,?,CAST(? AS JSON),'PROCESSING',NOW(3))",
|
||||
envelope.eventId(), envelope.eventType(), envelope.payloadHash(), json(envelope));
|
||||
} catch (DuplicateKeyException ignored) { }
|
||||
} catch (DuplicateKeyException duplicate) {
|
||||
// The durable winner is verified again under SELECT FOR UPDATE before
|
||||
// any wallet call, including when both requests initially saw no record.
|
||||
}
|
||||
}
|
||||
|
||||
private void finalizeResult(EventEnvelope commandEnvelope, WalletCommandCompletedV1 result) {
|
||||
@@ -114,16 +146,12 @@ public class WalletCommandEventService {
|
||||
WalletCommandCompletedV1.VERSION, Instant.now(), RegionCode.CN, RegionCode.OVERSEAS,
|
||||
String.valueOf(result.customerId()), "WALLET_HOLD", result.businessNo(), 1L, null,
|
||||
commandEnvelope.traceId(), commandEnvelope.correlationId(), commandEnvelope.eventId(), payload, payloadHash).requireValid();
|
||||
jdbc.update("INSERT IGNORE INTO fin_wallet_event_outbox(event_id,event_key,payload_json,status,next_retry_at,created_at,updated_at) VALUES(?,?,CAST(? AS JSON),'PENDING',NOW(3),NOW(3),NOW(3))",
|
||||
jdbc.update("INSERT INTO fin_wallet_event_outbox(event_id,event_key,payload_json,status,next_retry_at,created_at,updated_at) VALUES(?,?,CAST(? AS JSON),'PENDING',NOW(3),NOW(3),NOW(3))",
|
||||
completed.eventId(), "WALLET_RESULT:" + commandEnvelope.eventId(), json(completed));
|
||||
jdbc.update("UPDATE fin_wallet_command_inbox SET status=?,result_json=CAST(? AS JSON),processed_at=NOW(3) WHERE event_id=?",
|
||||
result.result(), json(result), commandEnvelope.eventId());
|
||||
}
|
||||
|
||||
private String existingHash(String eventId) {
|
||||
List<String> rows = jdbc.queryForList("SELECT payload_hash FROM fin_wallet_command_inbox WHERE event_id=?", String.class, eventId);
|
||||
return rows.isEmpty() ? null : rows.get(0);
|
||||
}
|
||||
private boolean resultExists(String eventId) {
|
||||
Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM fin_wallet_event_outbox WHERE event_key=?", Integer.class,
|
||||
"WALLET_RESULT:" + eventId);
|
||||
@@ -139,9 +167,4 @@ public class WalletCommandEventService {
|
||||
catch (Exception exception) { throw new FinanceEventException("FIN-EVT-001", "资金事件序列化失败"); }
|
||||
}
|
||||
private static boolean blank(String value) { return value == null || value.isBlank(); }
|
||||
private static String safe(String value) {
|
||||
if (value == null || value.isBlank()) return "资金指令执行失败";
|
||||
String normalized = value.replaceAll("[\\r\\n\\t]+", " ");
|
||||
return normalized.length() <= 500 ? normalized : normalized.substring(0, 500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
package com.baiyue.finance.service;
|
||||
|
||||
import com.baiyue.common.event.EventEnvelope;
|
||||
import com.baiyue.finance.client.WalletResultEventBridgeClient;
|
||||
import com.baiyue.common.core.retry.BoundedRetryPolicy;
|
||||
import com.baiyue.finance.client.WalletResultLastlegClient;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -21,10 +22,13 @@ import java.util.UUID;
|
||||
public class WalletResultOutboxRelay {
|
||||
private final JdbcTemplate jdbc;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final WalletResultEventBridgeClient client;
|
||||
private final WalletResultLastlegClient client;
|
||||
@Value("${baiyue.security.internal-service-token}") private String internalToken;
|
||||
@Value("${baiyue.wallet-event.max-attempts:12}") private int maxAttempts;
|
||||
private final String worker = UUID.randomUUID().toString();
|
||||
@Value("${baiyue.event-delivery.max-attempts:12}") private int maxAttempts = 12;
|
||||
@Value("${baiyue.event-delivery.base-delay-seconds:5}") private long baseDelaySeconds = 5;
|
||||
@Value("${baiyue.event-delivery.max-delay-seconds:1800}") private long maxDelaySeconds = 1800;
|
||||
@Value("${baiyue.event-delivery.jitter:0.1}") private double retryJitter = 0.1;
|
||||
@Value("${baiyue.event-delivery.lease-seconds:60}") private long leaseSeconds = 60;
|
||||
|
||||
@Scheduled(fixedDelayString = "${baiyue.wallet-event.fixed-delay-ms:5000}")
|
||||
public void relay() {
|
||||
@@ -33,24 +37,33 @@ public class WalletResultOutboxRelay {
|
||||
}
|
||||
|
||||
public void deliver(String eventId) {
|
||||
var policy = retryPolicy();
|
||||
String worker = UUID.randomUUID().toString();
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
int claimed = jdbc.update("UPDATE fin_wallet_event_outbox SET status='SENDING',locked_by=?,locked_at=?,updated_at=? WHERE event_id=? AND status='PENDING' AND next_retry_at<=?",
|
||||
worker, now, now, eventId, now);
|
||||
if (claimed != 1) return;
|
||||
Map<String, Object> row = jdbc.queryForMap("SELECT payload_json,retry_count FROM fin_wallet_event_outbox WHERE event_id=?", eventId);
|
||||
int failures = ((Number) row.get("retry_count")).intValue();
|
||||
if (policy.exhausted(failures)) {
|
||||
jdbc.update("UPDATE fin_wallet_event_outbox SET status='DEAD',locked_by=NULL,locked_at=NULL,last_error=?,updated_at=NOW(3) WHERE event_id=? AND status='SENDING' AND locked_by=?",
|
||||
"自动投递次数已耗尽(含租约超时)", eventId, worker);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
EventEnvelope envelope = objectMapper.readValue(String.valueOf(row.get("payload_json")), EventEnvelope.class).requireValid();
|
||||
var response = client.receive(internalToken, envelope);
|
||||
if (response == null || !response.successful() || response.data() == null
|
||||
|| !eventId.equals(response.data().eventId())) throw new IllegalStateException("海外事件桥未确认资金结果事件");
|
||||
jdbc.update("UPDATE fin_wallet_event_outbox SET status='PUBLISHED',published_at=NOW(3),locked_by=NULL,locked_at=NULL,last_error=NULL,updated_at=NOW(3) WHERE event_id=? AND locked_by=?",
|
||||
if (response == null || !response.successful() || response.data() == null)
|
||||
throw new IllegalStateException("Lastleg 未确认资金结果事件");
|
||||
response.data().requireMatches(envelope);
|
||||
jdbc.update("UPDATE fin_wallet_event_outbox SET status='PUBLISHED',published_at=NOW(3),locked_by=NULL,locked_at=NULL,last_error=NULL,updated_at=NOW(3) WHERE event_id=? AND status='SENDING' AND locked_by=?",
|
||||
eventId, worker);
|
||||
} catch (Exception exception) {
|
||||
int retries = ((Number) row.get("retry_count")).intValue() + 1;
|
||||
String status = retries >= maxAttempts ? "DEAD" : "PENDING";
|
||||
long delay = Math.min(1800, 5L << Math.min(retries, 8));
|
||||
jdbc.update("UPDATE fin_wallet_event_outbox SET status=?,retry_count=?,next_retry_at=?,locked_by=NULL,locked_at=NULL,last_error=?,updated_at=NOW(3) WHERE event_id=? AND locked_by=?",
|
||||
status, retries, now.plusSeconds(delay), safe(exception.getMessage()), eventId, worker);
|
||||
int retries = failures + 1;
|
||||
String status = policy.exhausted(retries) ? "DEAD" : "PENDING";
|
||||
LocalDateTime next = policy.exhausted(retries) ? LocalDateTime.now() : LocalDateTime.now().plus(policy.delayAfterFailure(retries));
|
||||
jdbc.update("UPDATE fin_wallet_event_outbox SET status=?,retry_count=?,next_retry_at=?,locked_by=NULL,locked_at=NULL,last_error=?,updated_at=NOW(3) WHERE event_id=? AND status='SENDING' AND locked_by=?",
|
||||
status, retries, next, safe(exception.getMessage()), eventId, worker);
|
||||
log.warn("资金结果事件投递失败 eventId={}, reason={}", eventId, exception.getMessage());
|
||||
}
|
||||
}
|
||||
@@ -59,7 +72,21 @@ public class WalletResultOutboxRelay {
|
||||
return jdbc.queryForList("SELECT event_id FROM fin_wallet_event_outbox WHERE status='PENDING' AND next_retry_at<=NOW(3) ORDER BY created_at,event_id LIMIT 50", String.class);
|
||||
}
|
||||
private void recoverExpired() {
|
||||
jdbc.update("UPDATE fin_wallet_event_outbox SET status='PENDING',locked_by=NULL,locked_at=NULL,next_retry_at=NOW(3),updated_at=NOW(3) WHERE status='SENDING' AND locked_at<DATE_SUB(NOW(3),INTERVAL 2 MINUTE)");
|
||||
var policy = retryPolicy();
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
LocalDateTime cutoff = now.minusSeconds(leaseSeconds);
|
||||
for (Map<String, Object> row : jdbc.queryForList("SELECT event_id,locked_by,retry_count FROM fin_wallet_event_outbox WHERE status='SENDING' AND locked_at<? ORDER BY locked_at,event_id LIMIT 50", cutoff)) {
|
||||
int failures = ((Number) row.get("retry_count")).intValue() + 1;
|
||||
boolean dead = policy.exhausted(failures);
|
||||
jdbc.update("UPDATE fin_wallet_event_outbox SET status=?,retry_count=?,next_retry_at=?,locked_by=NULL,locked_at=NULL,last_error=?,updated_at=? WHERE event_id=? AND status='SENDING' AND locked_by=? AND locked_at<?",
|
||||
dead ? "DEAD" : "PENDING", failures, dead ? now : now.plus(policy.delayAfterFailure(failures)),
|
||||
"投递租约超时,消费结果未知" + (dead ? ";自动次数已耗尽" : ""), now,
|
||||
row.get("event_id"), row.get("locked_by"), cutoff);
|
||||
}
|
||||
}
|
||||
private BoundedRetryPolicy retryPolicy() {
|
||||
if (leaseSeconds < 1) throw new IllegalArgumentException("Lease duration must be positive");
|
||||
return new BoundedRetryPolicy(maxAttempts, baseDelaySeconds, maxDelaySeconds, retryJitter);
|
||||
}
|
||||
private static String safe(String value) {
|
||||
String text = value == null ? "unknown" : value.replaceAll("[\\r\\n\\t]+", " ");
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
Reference in New Issue
Block a user