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.inboundplan.pda.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;
@@ -22,10 +28,12 @@ import java.util.UUID;
/** 至少一次投递海外到国内业务事件;消费服务按 eventId 幂等接收。 */
@Component
@Slf4j
public class InboundUnloadCompletedOutboxRelay {
public class InboundUnloadCompletedOutboxRelay implements EventDeliveryProducer {
@Resource private UnloadCompletedOutboxRepository 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 internalToken;
@Value("${baiyue.event-delivery.lease-seconds:60}") private long leaseSeconds = 60;
@@ -38,44 +46,63 @@ public class InboundUnloadCompletedOutboxRelay {
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();
LocalDateTime expiredLeaseBefore = now.minusSeconds(leaseSeconds);
for (LastlegEventOutbox candidate : repository.pending(50, now, expiredLeaseBefore)) {
String workerId = UUID.randomUUID().toString();
now = LocalDateTime.now();
expiredLeaseBefore = now.minusSeconds(leaseSeconds);
if (!repository.claim(candidate.getId(), workerId, now, expiredLeaseBefore)) continue;
LastlegEventOutbox row = repository.claimed(candidate.getId(), workerId);
LocalDateTime scan = LocalDateTime.now();
for (LastlegEventOutbox candidate : repository.pending(50, scan, scan.minusSeconds(leaseSeconds))) {
LocalDateTime started = LocalDateTime.now();
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();
String type = Stream.LASTLEG.type(candidate.getEventType());
if (!journal.begin(worker, Stream.LASTLEG.service, type, String.valueOf(candidate.getId()),
EventDeliveryObservation.eventId(objectMapper, candidate.getPayloadJson()), Stream.LASTLEG.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(), workerId, failures, "自动投递次数已耗尽(含租约超时)");
journal.complete(worker, Outcome.DEAD, LocalDateTime.now(), "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 event = objectMapper.readValue(row.getPayloadJson(), EventEnvelope.class);
EventEnvelopeValidator.requireValid(event);
EventEnvelope event = objectMapper.readValue(row.getPayloadJson(), EventEnvelope.class).requireValid();
requestStarted = true;
var response = client.receive(internalToken, event);
requireAcknowledgement(event, response);
repository.published(row.getId(), workerId, LocalDateTime.now());
EventDeliveryFailure.requireSuccessful(response);
response.data().requireMatches(event);
outcome = Outcome.valueOf(response.data().outcome().name());
} catch (Exception exception) {
String error = EventDeliveryFailure.diagnostic(exception, internalToken);
boolean permanent = EventDeliveryFailure.isPermanent(exception)
permanent = EventDeliveryFailure.isPermanent(exception)
|| (!requestStarted && (exception instanceof JsonProcessingException || exception instanceof IllegalArgumentException));
if (permanent || policy.exhausted(failures + 1)) repository.dead(row.getId(), workerId, failures + 1, error);
else repository.retry(row.getId(), workerId, LocalDateTime.now().plus(policy.delayAfterFailure(failures + 1)),
error);
log.warn("海外到国内事件投递失败: eventKey={}, reason={}", row.getEventKey(), error);
diagnostic = EventDeliveryFailure.diagnostic(exception, internalToken);
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();
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, next, error);
return repository.published(row.getId(), worker, finished);
});
}
}
private void requireAcknowledgement(EventEnvelope event,
com.baiyue.common.core.api.ApiResponse<DeliveryAck> response) {
EventDeliveryFailure.requireSuccessful(response);
response.data().requireMatches(event);
}
@Scheduled(fixedDelayString = "${baiyue.event-delivery.metrics-sample-ms:30000}")
public void sampleMetrics() { metrics.sample(Stream.LASTLEG, LocalDateTime.now()); }
}
@@ -7,7 +7,6 @@ import com.baiyue.common.event.EventPayloadCanonicalizer;
import com.baiyue.common.event.EventPayloadHash;
import com.baiyue.common.event.EventTypes;
import com.baiyue.common.event.OverseasContainerUnloadedV1;
import com.baiyue.common.event.RegionCode;
import com.baiyue.lastleg.entity.LastlegEventOutbox;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
@@ -78,7 +77,7 @@ public class InboundUnloadCompletedOutboxService {
JsonNode payloadNode = objectMapper.valueToTree(payload);
String traceId = EventIdentifiers.newUuid();
EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), EventTypes.OVERSEAS_CONTAINER_UNLOADED,
1, Instant.now(), RegionCode.OVERSEAS, RegionCode.CN, "0", "CONTAINER_BOOKING",
1, Instant.now(), "0", "CONTAINER_BOOKING",
sourceContainerBookingId, Math.max(1, aggregateVersion), 1L, traceId, eventKey, null, payloadNode,
EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode))).requireValid();