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
@@ -1,5 +1,11 @@
package com.baiyue.lastleg.financefee.service;
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.event.EventEnvelope;
import com.baiyue.common.web.EventDeliveryFailure;
import com.fasterxml.jackson.core.JsonProcessingException;
@@ -18,10 +24,12 @@ import java.time.ZoneOffset;
import java.util.UUID;
@Component
public class OverseasFeeOutboxRelay {
public class OverseasFeeOutboxRelay implements EventDeliveryProducer {
@Resource private OverseasFeeOutboxRepository repository;
@Resource private DirectEventClient client;
@Resource private ObjectMapper objectMapper;
@Resource private EventDeliveryJournal journal;
@Resource private EventDeliveryMetrics metrics;
@Value("${baiyue.security.internal-service-token:}") private String token;
@Value("${baiyue.event-delivery.max-attempts:12}") private int maxAttempts = 12;
@@ -35,35 +43,63 @@ public class OverseasFeeOutboxRelay {
public void relay() {
if (leaseSeconds < 1) throw new IllegalArgumentException("Lease duration must be positive");
var policy = new BoundedRetryPolicy(maxAttempts, baseDelaySeconds, maxDelaySeconds, retryJitter);
LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
for (LastlegEventOutbox candidate : repository.pending(50, now, now.minusSeconds(leaseSeconds))) {
LocalDateTime scan = LocalDateTime.now(ZoneOffset.UTC);
for (LastlegEventOutbox candidate : repository.pending(50, scan, scan.minusSeconds(leaseSeconds))) {
LocalDateTime started = LocalDateTime.now(ZoneOffset.UTC);
if ("PROCESSING".equals(candidate.getStatus())) {
int failures = (candidate.getRetryCount() == null ? 0 : candidate.getRetryCount()) + 1;
LocalDateTime next = policy.exhausted(failures) ? null : started.plus(policy.delayAfterFailure(failures));
journal.complete(candidate.getLockedBy(), Outcome.LEASE_EXPIRED, started, "LEASE_EXPIRED", next,
() -> repository.expire(candidate.getId(), candidate.getLockedBy(), started.minusSeconds(leaseSeconds),
failures, next, "投递租约超时,消费结果未知"));
continue; // Recovery always waits for the new backoff window; never sends in this pass.
}
String worker = UUID.randomUUID().toString();
now = LocalDateTime.now(ZoneOffset.UTC);
if (!repository.claim(candidate.getId(), worker, now, now.minusSeconds(leaseSeconds))) continue;
String type = Stream.FEE.type(candidate.getEventType());
if (!journal.begin(worker, Stream.FEE.service, type, String.valueOf(candidate.getId()),
EventDeliveryObservation.eventId(objectMapper, candidate.getPayloadJson()), Stream.FEE.target(type), started,
() -> repository.claim(candidate.getId(), worker, started))) continue;
LastlegEventOutbox row = repository.claimed(candidate.getId(), worker);
if (row == null) continue;
int failures = row.getRetryCount() == null ? 0 : row.getRetryCount();
if (policy.exhausted(failures)) {
repository.dead(row.getId(), worker, failures, "自动投递次数已耗尽(含租约超时)");
journal.complete(worker, Outcome.DEAD, LocalDateTime.now(ZoneOffset.UTC), "RETRY_EXHAUSTED", null,
() -> repository.dead(row.getId(), worker, failures, "自动投递次数已耗尽(含租约超时)"));
continue;
}
Outcome outcome;
String diagnostic = null;
String auditError = null;
boolean permanent = false;
boolean requestStarted = false;
try {
EventEnvelope envelope = objectMapper.readValue(row.getPayloadJson(), EventEnvelope.class).requireValid();
EventEnvelope event = objectMapper.readValue(row.getPayloadJson(), EventEnvelope.class).requireValid();
requestStarted = true;
var response = client.receive(token, envelope);
var response = client.receive(token, event);
EventDeliveryFailure.requireSuccessful(response);
response.data().requireMatches(envelope);
repository.published(row.getId(), worker, LocalDateTime.now(ZoneOffset.UTC));
response.data().requireMatches(event);
outcome = Outcome.valueOf(response.data().outcome().name());
} catch (Exception exception) {
String error = EventDeliveryFailure.diagnostic(exception, token);
boolean permanent = EventDeliveryFailure.isPermanent(exception)
permanent = EventDeliveryFailure.isPermanent(exception)
|| (!requestStarted && (exception instanceof JsonProcessingException || exception instanceof IllegalArgumentException));
int retries = failures + 1;
if (permanent || policy.exhausted(retries)) repository.dead(row.getId(), worker, retries, error);
else repository.retry(row.getId(), worker, retries, LocalDateTime.now(ZoneOffset.UTC).plus(policy.delayAfterFailure(retries)), error);
diagnostic = EventDeliveryFailure.diagnostic(exception, token);
auditError = EventDeliveryObservation.errorCode(exception, permanent, requestStarted);
outcome = permanent || policy.exhausted(failures + 1) ? Outcome.DEAD : Outcome.RETRY;
}
// Persistence failures propagate. Do not reinterpret a failed commit as an HTTP failure.
LocalDateTime finished = LocalDateTime.now(ZoneOffset.UTC);
LocalDateTime next = outcome == Outcome.RETRY ? finished.plus(policy.delayAfterFailure(failures + 1)) : null;
Outcome result = outcome;
String error = diagnostic;
int retries = failures + 1;
journal.complete(worker, result, finished, auditError, next, () -> {
if (result == Outcome.DEAD) return repository.dead(row.getId(), worker, retries, error);
if (result == Outcome.RETRY) return repository.retry(row.getId(), worker, retries, next, error);
return repository.published(row.getId(), worker, finished);
});
}
}
@Scheduled(fixedDelayString = "${baiyue.event-delivery.metrics-sample-ms:30000}")
public void sampleMetrics() { metrics.sample(Stream.FEE, LocalDateTime.now(ZoneOffset.UTC)); }
}
@@ -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.lastleg.entity.LastlegEventOutbox;
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper;
@@ -37,7 +36,7 @@ public class OverseasFeeOutboxService {
+ source.getSourceType() + ':' + source.getSourceFeeId() + ':' + source.getSourceVersion() + ':' + action;
if (mapper.findByEventKey(eventKey) != null) return;
EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(),
EventTypes.BUSINESS_FEE_SOURCE_CHANGED, 1, Instant.now(), RegionCode.OVERSEAS, RegionCode.CN,
EventTypes.BUSINESS_FEE_SOURCE_CHANGED, 1, Instant.now(),
source.getCustomerId(), source.getSourceType(), String.valueOf(source.getSourceFeeId()),
source.getSourceVersion(), null, EventIdentifiers.newUuid(), eventKey, null, payloadNode,
EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode))).requireValid();