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
@@ -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);
}
@@ -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));
}
}
@@ -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]+", " ");