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
@@ -24,33 +24,41 @@ public class OverseasFeeOutboxRepository {
.or(processing -> processing.eq(LastlegEventOutbox::getStatus, "PROCESSING").le(LastlegEventOutbox::getLockedAt, stale)))
.orderByAsc(LastlegEventOutbox::getCreatedAt).last("LIMIT " + limit));
}
public boolean claim(long id, String worker, LocalDateTime now, LocalDateTime stale) {
public boolean claim(long id, String worker, LocalDateTime now) {
return mapper.update(null, new LambdaUpdateWrapper<LastlegEventOutbox>()
.eq(LastlegEventOutbox::getId, id).eq(LastlegEventOutbox::getEventType, EventTypes.BUSINESS_FEE_SOURCE_CHANGED)
.and(status -> status.eq(LastlegEventOutbox::getStatus, "PENDING")
.and(retry -> retry.isNull(LastlegEventOutbox::getNextRetryAt).or().le(LastlegEventOutbox::getNextRetryAt, now))
.or(processing ->
processing.eq(LastlegEventOutbox::getStatus, "PROCESSING").le(LastlegEventOutbox::getLockedAt, stale)))
.setSql("retry_count = retry_count + CASE WHEN status='PROCESSING' THEN 1 ELSE 0 END")
.set(LastlegEventOutbox::getStatus, "PROCESSING").set(LastlegEventOutbox::getLockedBy, worker)
.set(LastlegEventOutbox::getLockedAt, now)) == 1;
.eq(LastlegEventOutbox::getStatus, "PENDING")
.and(retry -> retry.isNull(LastlegEventOutbox::getNextRetryAt).or().le(LastlegEventOutbox::getNextRetryAt, now))
.set(LastlegEventOutbox::getStatus, "PROCESSING")
.set(LastlegEventOutbox::getLockedBy, worker).set(LastlegEventOutbox::getLockedAt, now)) == 1;
}
public int expire(long id, String worker, LocalDateTime cutoff, int failures, LocalDateTime next, String error) {
return mapper.update(null, new LambdaUpdateWrapper<LastlegEventOutbox>()
.eq(LastlegEventOutbox::getId, id).eq(LastlegEventOutbox::getEventType, EventTypes.BUSINESS_FEE_SOURCE_CHANGED)
.eq(LastlegEventOutbox::getStatus, "PROCESSING").eq(LastlegEventOutbox::getLockedBy, worker)
.le(LastlegEventOutbox::getLockedAt, cutoff)
.set(LastlegEventOutbox::getStatus, next == null ? "DEAD" : "PENDING")
.set(LastlegEventOutbox::getRetryCount, failures).set(LastlegEventOutbox::getNextRetryAt, next)
.set(LastlegEventOutbox::getLockedBy, null).set(LastlegEventOutbox::getLockedAt, null)
.set(LastlegEventOutbox::getLastError, truncate(error)));
}
public LastlegEventOutbox claimed(long id, String worker) {
return mapper.selectOne(new LambdaQueryWrapper<LastlegEventOutbox>().eq(LastlegEventOutbox::getId, id)
.eq(LastlegEventOutbox::getStatus, "PROCESSING").eq(LastlegEventOutbox::getLockedBy, worker));
}
public void published(long id, String worker, LocalDateTime now) {
mapper.update(null, claimed(id, worker, "PUBLISHED").set(LastlegEventOutbox::getPublishedAt, now)
public int published(long id, String worker, LocalDateTime now) {
return mapper.update(null, claimed(id, worker, "PUBLISHED").set(LastlegEventOutbox::getPublishedAt, now)
.set(LastlegEventOutbox::getNextRetryAt, null).set(LastlegEventOutbox::getLastError, null));
}
public void dead(long id, String worker, int failures, String error) {
mapper.update(null, claimed(id, worker, "DEAD")
public int dead(long id, String worker, int failures, String error) {
return mapper.update(null, claimed(id, worker, "DEAD")
.set(LastlegEventOutbox::getRetryCount, failures)
.set(LastlegEventOutbox::getNextRetryAt, null)
.set(LastlegEventOutbox::getLastError, truncate(error)));
}
public void retry(long id, String worker, int retryCount, LocalDateTime next, String error) {
mapper.update(null, claimed(id, worker, "PENDING").set(LastlegEventOutbox::getRetryCount, retryCount)
public int retry(long id, String worker, int retryCount, LocalDateTime next, String error) {
return mapper.update(null, claimed(id, worker, "PENDING").set(LastlegEventOutbox::getRetryCount, retryCount)
.set(LastlegEventOutbox::getNextRetryAt, next).set(LastlegEventOutbox::getLastError, truncate(error)));
}
private LambdaUpdateWrapper<LastlegEventOutbox> claimed(long id, String worker, String status) {
@@ -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();