This commit is contained in:
2026-09-29 18:47:37 +08:00
parent 2c60a29e0f
commit c2927ab9a6
291 changed files with 4008 additions and 6298 deletions
@@ -0,0 +1,56 @@
package com.baiyue.finance.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.core.outbox.OutboxOperations;
import com.baiyue.common.core.outbox.EventOperationsTokenGuard;
import com.baiyue.common.event.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.web.bind.annotation.*;
import java.util.Set;
/** Internal-only operator recovery. It cannot mutate the original event or call a consumer directly. */
@RestController
@RequestMapping("/internal/finance/event-operations")
public class EventOperationsController {
private final EventOperationsTokenGuard guard;
private final OutboxOperations operations;
public EventOperationsController(JdbcTemplate jdbc, PlatformTransactionManager transactions,
ObjectMapper mapper, @Value("${baiyue.event-delivery.operations-token:}") String token) {
guard = new EventOperationsTokenGuard(token);
operations = new OutboxOperations(jdbc, transactions,
new OutboxOperations.Layout("fin_wallet_event_outbox", "event_id", "status", Set.of(EventTypes.WALLET_COMMAND_COMPLETED)),
json -> {
try {
EventEnvelope envelope = mapper.readValue(json, EventEnvelope.class).requireValid();
if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()), envelope.payloadHash())) {
throw new IllegalArgumentException("Hash mismatch");
}
return new OutboxOperations.Identity(envelope.eventId(), envelope.eventType(), envelope.payloadHash());
} catch (Exception invalid) {
throw new BizException(ResultCode.CONFLICT, "原事件格式或摘要无效,禁止人工重试");
}
});
}
@GetMapping("/{entryId}")
public ApiResponse<OutboxOperations.Entry> inspect(
@RequestHeader(value = "X-Event-Operations-Token", required = false) String token,
@PathVariable String entryId) {
guard.require(token);
return ApiResponse.success(operations.inspect(entryId));
}
@PostMapping("/{entryId}/retry")
public ApiResponse<OutboxOperations.RetryResult> retry(
@RequestHeader(value = "X-Event-Operations-Token", required = false) String token,
@PathVariable String entryId, @RequestBody OutboxOperations.RetryRequest request) {
guard.require(token);
return ApiResponse.success(operations.retry(entryId, request));
}
}
@@ -6,7 +6,6 @@ import com.baiyue.common.event.DeliveryAck;
import com.baiyue.common.event.EventPayloadCanonicalizer;
import com.baiyue.common.event.EventPayloadHash;
import com.baiyue.common.event.EventTypes;
import com.baiyue.common.event.RegionCode;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
@@ -36,9 +35,8 @@ public class BusinessFeeSourceEventService {
throw error("FIN-EVT-001", "跨区费用事件信封无效");
}
if (!EventTypes.BUSINESS_FEE_SOURCE_CHANGED.equals(envelope.eventType())
|| envelope.eventVersion() != 1 || envelope.sourceRegion() != RegionCode.OVERSEAS
|| envelope.targetRegion() != RegionCode.CN) {
throw error("FIN-EVT-001", "跨区费用事件类型、版本或区域无效");
|| envelope.eventVersion() != 1) {
throw error("FIN-EVT-001", "跨区费用事件类型或版本无效");
}
if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()), envelope.payloadHash())) {
throw new FinanceEventException("FIN-EVT-002", "事件信封摘要与实际载荷不一致");
@@ -7,7 +7,6 @@ import com.baiyue.common.event.EventIdentifiers;
import com.baiyue.common.event.EventPayloadCanonicalizer;
import com.baiyue.common.event.EventPayloadHash;
import com.baiyue.common.event.EventTypes;
import com.baiyue.common.event.RegionCode;
import com.baiyue.common.event.WalletCommandCompletedV1;
import com.baiyue.common.event.WalletCommandRequestedV1;
import com.baiyue.finance.dto.FinanceWalletDtos.WalletFreezeRequest;
@@ -108,9 +107,8 @@ public class WalletCommandEventService {
try { EventEnvelopeValidator.requireValid(envelope); }
catch (RuntimeException exception) { throw new FinanceEventException("FIN-EVT-001", "资金指令信封无效"); }
if (!EventTypes.WALLET_COMMAND_REQUESTED.equals(envelope.eventType())
|| envelope.eventVersion() != WalletCommandRequestedV1.VERSION
|| envelope.sourceRegion() != RegionCode.OVERSEAS || envelope.targetRegion() != RegionCode.CN) {
throw new FinanceEventException("FIN-EVT-001", "资金指令类型、版本或区域无效");
|| envelope.eventVersion() != WalletCommandRequestedV1.VERSION) {
throw new FinanceEventException("FIN-EVT-001", "资金指令类型或版本无效");
}
if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()), envelope.payloadHash())) {
throw new FinanceEventException("FIN-EVT-002", "事件信封摘要与实际载荷不一致");
@@ -146,7 +144,7 @@ public class WalletCommandEventService {
JsonNode payload = objectMapper.valueToTree(result);
String payloadHash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload));
EventEnvelope completed = new EventEnvelope(EventIdentifiers.newUuid(), EventTypes.WALLET_COMMAND_COMPLETED,
WalletCommandCompletedV1.VERSION, Instant.now(), RegionCode.CN, RegionCode.OVERSEAS,
WalletCommandCompletedV1.VERSION, Instant.now(),
String.valueOf(result.customerId()), "WALLET_HOLD", result.businessNo(), 1L, null,
commandEnvelope.traceId(), commandEnvelope.correlationId(), commandEnvelope.eventId(), payload, payloadHash).requireValid();
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))",
@@ -1,6 +1,12 @@
package com.baiyue.finance.service;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.core.outbox.EventDeliveryJournal;
import com.baiyue.common.core.outbox.EventDeliveryProducer;
import com.baiyue.common.core.outbox.EventDeliveryJournal.Outcome;
import com.baiyue.common.web.EventDeliveryMetrics;
import com.baiyue.common.web.EventDeliveryMetrics.Stream;
import com.baiyue.common.web.EventDeliveryObservation;
import com.baiyue.common.web.EventDeliveryFailure;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.baiyue.common.core.retry.BoundedRetryPolicy;
@@ -21,8 +27,10 @@ import java.util.UUID;
@Component
@RequiredArgsConstructor
@Slf4j
public class WalletResultOutboxRelay {
public class WalletResultOutboxRelay implements EventDeliveryProducer {
private final JdbcTemplate jdbc;
private final EventDeliveryJournal journal;
private final EventDeliveryMetrics metrics;
private final ObjectMapper objectMapper;
private final WalletResultLastlegClient client;
@Value("${baiyue.security.internal-service-token}") private String internalToken;
@@ -42,16 +50,21 @@ public class WalletResultOutboxRelay {
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);
if (!journal.begin(worker, Stream.FINANCE.service, "WalletCommandCompleted", eventId, eventId,
Stream.FINANCE.target("WalletCommandCompleted"), now,
() -> 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) == 1)) return;
Map<String, Object> row = jdbc.queryForMap("SELECT payload_json,retry_count FROM fin_wallet_event_outbox WHERE event_id=? AND status='SENDING' AND locked_by=?", eventId, worker);
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);
journal.complete(worker, Outcome.DEAD, LocalDateTime.now(), "RETRY_EXHAUSTED", null,
() -> 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;
}
Outcome outcome;
String diagnostic = null;
String auditError = null;
boolean requestStarted = false;
try {
EventEnvelope envelope = objectMapper.readValue(String.valueOf(row.get("payload_json")), EventEnvelope.class).requireValid();
@@ -59,21 +72,31 @@ public class WalletResultOutboxRelay {
var response = client.receive(internalToken, envelope);
EventDeliveryFailure.requireSuccessful(response);
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);
outcome = Outcome.valueOf(response.data().outcome().name());
} catch (Exception exception) {
String error = EventDeliveryFailure.diagnostic(exception, internalToken);
diagnostic = EventDeliveryFailure.diagnostic(exception, internalToken);
boolean permanent = EventDeliveryFailure.isPermanent(exception)
|| (!requestStarted && (exception instanceof JsonProcessingException || exception instanceof IllegalArgumentException));
int retries = failures + 1;
String status = permanent || policy.exhausted(retries) ? "DEAD" : "PENDING";
LocalDateTime next = permanent || 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(error), eventId, worker);
log.warn("资金结果事件投递失败 eventId={}, reason={}", eventId, error);
auditError = EventDeliveryObservation.errorCode(exception, permanent, requestStarted);
outcome = permanent || policy.exhausted(failures + 1) ? Outcome.DEAD : Outcome.RETRY;
}
LocalDateTime finished = LocalDateTime.now();
LocalDateTime next = outcome == Outcome.RETRY ? finished.plus(policy.delayAfterFailure(failures + 1)) : null;
Outcome result = outcome;
String error = diagnostic;
journal.complete(worker, result, finished, auditError, next, () -> {
if (result == Outcome.RETRY || result == Outcome.DEAD) {
return 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=?",
result == Outcome.DEAD ? "DEAD" : "PENDING", failures + 1, next == null ? finished : next, safe(error), finished, eventId, worker);
}
return jdbc.update("UPDATE fin_wallet_event_outbox SET status='PUBLISHED',published_at=?,locked_by=NULL,locked_at=NULL,last_error=NULL,updated_at=? WHERE event_id=? AND status='SENDING' AND locked_by=?",
finished, finished, eventId, worker);
});
}
@Scheduled(fixedDelayString = "${baiyue.event-delivery.metrics-sample-ms:30000}")
public void sampleMetrics() { metrics.sample(Stream.FINANCE, LocalDateTime.now()); }
private List<String> candidates() {
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);
}
@@ -84,10 +107,12 @@ public class WalletResultOutboxRelay {
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);
LocalDateTime next = dead ? null : now.plus(policy.delayAfterFailure(failures));
journal.complete((String) row.get("locked_by"), Outcome.LEASE_EXPIRED, now, "LEASE_EXPIRED", next,
() -> 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 : next,
"投递租约超时,消费结果未知" + (dead ? ";自动次数已耗尽" : ""), now,
row.get("event_id"), row.get("locked_by"), cutoff));
}
}
private BoundedRetryPolicy retryPolicy() {