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,19 +0,0 @@
package com.baiyue.firstleg.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;
/** 将本地业务 Outbox 投递到跨区事件桥。 */
@FeignClient(name = "event-bridge-service", contextId = "loadingCompletedEventBridgeClient", url = "${baiyue.lastleg.event-bridge-url:}")
public interface EventBridgeClient {
@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,20 @@
package com.baiyue.firstleg.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;
/** 将本地业务 Outbox 投递到Lastleg 消费者。 */
@FeignClient(configuration = EventDeliveryFeignConfiguration.class, name = "lastleg-service", contextId = "loadingCompletedLastlegEventClient", url = "${LASTLEG_SERVICE_URL:${baiyue.clients.lastleg.url:}}")
public interface LastlegEventClient {
@PostMapping("/internal/lastleg/events")
ApiResponse<DeliveryAck> receive(
@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody EventEnvelope envelope);
}
@@ -4,6 +4,7 @@ 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.event.EventEnvelope;
import com.baiyue.common.event.DeliveryAck;
import com.baiyue.common.event.EventTypes;
import com.baiyue.firstleg.service.OverseasContainerEventConsumer;
import com.baiyue.firstleg.service.OverseasInboundOrderDetainedConsumer;
@@ -27,14 +28,17 @@ public class InternalOverseasContainerEventController {
@Value("${baiyue.security.internal-service-token}") private String expectedToken;
@PostMapping("/internal/firstleg/v1/container-events")
public ApiResponse<Object> receive(
public ApiResponse<DeliveryAck> receive(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestBody EventEnvelope event) {
authenticate(token);
Object result = EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED.equals(event.eventType())
? detainedConsumer.consume(event)
: EventTypes.OVERSEAS_DELIVERY_COMPLETED.equals(event.eventType())
? deliveryCompletedConsumer.consume(event) : consumer.consume(event);
event.requireValid();
DeliveryAck result = switch (event.eventType()) {
case EventTypes.OVERSEAS_CONTAINER_UNLOADED -> consumer.consume(event);
case EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED -> detainedConsumer.consume(event);
case EventTypes.OVERSEAS_DELIVERY_COMPLETED -> deliveryCompletedConsumer.consume(event);
default -> throw new IllegalArgumentException("不支持的 Firstleg 业务事件类型");
};
return ApiResponse.success(result);
}
@@ -38,6 +38,7 @@ public class LoadingCompletedOutboxRepository {
.or().le(InboundOutboxEntity::getNextRetryAt, now))
.or(stale -> stale.eq(InboundOutboxEntity::getPublishStatus, "PROCESSING")
.le(InboundOutboxEntity::getLockedAt, expiredLeaseBefore)))
.setSql("retry_count = retry_count + CASE WHEN publish_status='PROCESSING' THEN 1 ELSE 0 END")
.set(InboundOutboxEntity::getPublishStatus, "PROCESSING").set(InboundOutboxEntity::getLockedBy, worker)
.set(InboundOutboxEntity::getLockedAt, now)) == 1;
}
@@ -48,6 +49,14 @@ public class LoadingCompletedOutboxRepository {
.set(InboundOutboxEntity::getPublishStatus, "PUBLISHED").set(InboundOutboxEntity::getPublishedAt, now)
.set(InboundOutboxEntity::getLockedBy, null).set(InboundOutboxEntity::getLockedAt, null)
.set(InboundOutboxEntity::getLastError, null)); }
public void dead(long id, String worker, int failures, String error) {
mapper.update(null, claimedBase(id, worker)
.set(InboundOutboxEntity::getPublishStatus, "DEAD")
.set(InboundOutboxEntity::getRetryCount, failures)
.set(InboundOutboxEntity::getNextRetryAt, null)
.set(InboundOutboxEntity::getLockedBy, null).set(InboundOutboxEntity::getLockedAt, null)
.set(InboundOutboxEntity::getLastError, truncate(error)));
}
public void retry(long id, String worker, LocalDateTime retryAt, String error) { mapper.update(null, claimedBase(id, worker)
.setSql("retry_count = retry_count + 1").set(InboundOutboxEntity::getPublishStatus, "PENDING")
.set(InboundOutboxEntity::getNextRetryAt, retryAt).set(InboundOutboxEntity::getLockedBy, null)
@@ -2,8 +2,9 @@ package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.core.retry.BoundedRetryPolicy;
import com.baiyue.common.event.EventEnvelopeValidator;
import com.baiyue.firstleg.client.EventBridgeClient;
import com.baiyue.firstleg.client.LastlegEventClient;
import com.baiyue.firstleg.entity.InboundOutboxEntity;
import com.baiyue.firstleg.repository.LoadingCompletedOutboxRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -16,25 +17,39 @@ import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.UUID;
/** 至少一次投递国内头程跨区事件;事件桥按 eventId + payloadHash 幂等接收。 */
/** 至少一次投递国内头程业务事件;Lastleg按 eventId + payloadHash 幂等接收。 */
@Component @Slf4j
public class LoadingCompletedOutboxRelay {
@Resource private LoadingCompletedOutboxRepository repository;
@Resource private EventBridgeClient client;
@Resource private LastlegEventClient client;
@Resource private ObjectMapper objectMapper;
@Value("${baiyue.security.internal-service-token}") private String internalToken;
@Value("${baiyue.lastleg.outbox-relay.lease-seconds:60}") private long leaseSeconds;
@Value("${baiyue.lastleg.outbox-relay.retry-seconds:30}") private long retrySeconds;
private final String workerId = UUID.randomUUID().toString();
@Value("${baiyue.event-delivery.lease-seconds:60}") private long leaseSeconds = 60;
@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;
@Scheduled(fixedDelayString = "${baiyue.lastleg.outbox-relay.fixed-delay-ms:5000}")
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(Math.max(leaseSeconds,10));
LocalDateTime expiredLeaseBefore=now.minusSeconds(leaseSeconds);
for (InboundOutboxEntity 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;
InboundOutboxEntity row=repository.claimed(candidate.getId(),workerId);
if(row==null) continue;
int failures = row.getRetryCount() == null ? 0 : row.getRetryCount();
if (policy.exhausted(failures)) {
repository.dead(row.getId(), workerId, failures, "自动投递次数已耗尽(含租约超时)");
continue;
}
try {
EventEnvelope event=objectMapper.readValue(row.getPayloadJson(),EventEnvelope.class);
EventEnvelopeValidator.requireValid(event);
@@ -43,25 +58,24 @@ public class LoadingCompletedOutboxRelay {
repository.published(row.getId(),workerId,LocalDateTime.now());
} catch(Exception exception) {
String error=safeError(exception);
repository.retry(row.getId(),workerId,LocalDateTime.now().plusSeconds(Math.max(retrySeconds,1)),error);
log.warn("头程跨区事件投递失败: eventType={}, eventKey={}, reason={}",
if (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("头程业务事件投递失败: eventType={}, eventKey={}, reason={}",
row.getEventType(), row.getEventKey(), error);
}
}
}
private void requireAcknowledgement(EventEnvelope event,
ApiResponse<EventBridgeClient.Acknowledgement> response) {
if(response==null) throw new IllegalStateException("事件桥未返回响应");
ApiResponse<com.baiyue.common.event.DeliveryAck> response) {
if(response==null) throw new IllegalStateException("Lastleg未返回响应");
if(!response.successful()) {
throw new IllegalStateException("事件桥拒绝头程跨区事件: code=" + response.code()
throw new IllegalStateException("Lastleg拒绝头程业务事件: code=" + response.code()
+ ", message=" + response.message() + ", requestId=" + response.requestId()
+ ", traceId=" + response.traceId());
}
if(response.data()==null || !event.eventId().equals(response.data().eventId())) {
throw new IllegalStateException("事件桥 ACK 不匹配: expectedEventId=" + event.eventId()
+ ", actualEventId=" + (response.data()==null?null:response.data().eventId()));
}
if (response.data() == null) throw new IllegalStateException("Lastleg ACK 缺失");
response.data().requireMatches(event);
}
private String safeError(Exception exception) {
@@ -1,6 +1,7 @@
package com.baiyue.firstleg.service;
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;
@@ -26,8 +27,8 @@ public class OverseasContainerEventConsumer {
private final ContainerEventInboxMapper inbox;
private final ObjectMapper objectMapper;
@Transactional
public ContainerBookingEntity consume(EventEnvelope event) {
@Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED)
public DeliveryAck consume(EventEnvelope event) {
validateEnvelope(event);
OverseasContainerUnloadedV1 payload = payload(event);
if (payload.sourceContainerBookingId() == null || payload.sourceContainerBookingId().isBlank()
@@ -50,7 +51,8 @@ public class OverseasContainerEventConsumer {
if (!existing.getPayloadHash().equalsIgnoreCase(event.payloadHash())) {
throw new IllegalArgumentException("相同 eventId 对应的事件摘要冲突");
}
return requiredContainer(payload.sourceContainerBookingId(), payload.containerNo());
if (!"PROCESSED".equals(existing.getStatus())) throw new IllegalStateException("事件尚未完成消费");
return DeliveryAck.of(event, DeliveryAck.Outcome.DUPLICATE);
}
ContainerEventInboxEntity row = inbox(event);
try {
@@ -58,18 +60,20 @@ public class OverseasContainerEventConsumer {
} catch (DuplicateKeyException duplicate) {
ContainerEventInboxEntity raced = inbox.selectById(event.eventId());
if (raced == null || !raced.getPayloadHash().equalsIgnoreCase(event.payloadHash())) throw duplicate;
return requiredContainer(payload.sourceContainerBookingId(), payload.containerNo());
if (!"PROCESSED".equals(raced.getStatus())) throw new IllegalStateException("事件尚未完成消费");
return DeliveryAck.of(event, DeliveryAck.Outcome.DUPLICATE);
}
ContainerBookingEntity container = requiredContainer(payload.sourceContainerBookingId(), payload.containerNo());
LocalDateTime uploadedAt = utc(payload.unloadingPhotoUploadedAt());
LocalDateTime unloadingCompletedAt = utc(payload.unloadingCompletedAt());
containers.applyEmptyReport(container.getId(), unloadingCompletedAt, uploadedAt,
int updated = containers.applyEmptyReport(container.getId(), unloadingCompletedAt, uploadedAt,
unloadingCompletedAt.plusHours(72), utc(payload.actualEmptyReportedAt()), event.aggregateVersion());
if (updated != 1) throw new IllegalStateException("卸柜事实未写入:来源版本冲突或货柜状态已变化");
row.setStatus("PROCESSED");
row.setProcessedAt(LocalDateTime.now(ZoneOffset.UTC));
inbox.updateById(row);
return requiredContainer(payload.sourceContainerBookingId(), payload.containerNo());
return DeliveryAck.of(event, DeliveryAck.Outcome.APPLIED);
}
private void validateEnvelope(EventEnvelope event) {
@@ -2,6 +2,7 @@ package com.baiyue.firstleg.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
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;
@@ -37,8 +38,8 @@ public class OverseasDeliveryCompletedConsumer {
private final ObjectMapper objectMapper;
@Value("${baiyue.security.internal-service-token}") private String internalToken;
@Transactional
public InboundOrderEntity consume(EventEnvelope event) {
@Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED)
public DeliveryAck consume(EventEnvelope event) {
validate(event);
OverseasDeliveryCompletedV1 payload = payload(event);
long inboundId = positive(payload.sourceInboundId(), "来源入仓单 ID 无效");
@@ -51,14 +52,16 @@ public class OverseasDeliveryCompletedConsumer {
if (!existing.getPayloadHash().equalsIgnoreCase(event.payloadHash())) {
throw new IllegalArgumentException("相同 eventId 对应的事件摘要冲突");
}
return required(inboundId, tenantId, payload);
if (!"PROCESSED".equals(existing.getStatus())) throw new IllegalStateException("事件尚未完成消费");
return DeliveryAck.of(event, DeliveryAck.Outcome.DUPLICATE);
}
ContainerEventInboxEntity inboxRow = inbox(event);
try { inbox.insert(inboxRow); }
catch (DuplicateKeyException duplicate) {
ContainerEventInboxEntity raced = inbox.selectById(event.eventId());
if (raced == null || !raced.getPayloadHash().equalsIgnoreCase(event.payloadHash())) throw duplicate;
return required(inboundId, tenantId, payload);
if (!"PROCESSED".equals(raced.getStatus())) throw new IllegalStateException("事件尚未完成消费");
return DeliveryAck.of(event, DeliveryAck.Outcome.DUPLICATE);
}
InboundOrderEntity inbound = required(inboundId, tenantId, payload);
if (!"LOADED".equals(inbound.getProcessStatus()) && !"DELIVERED".equals(inbound.getProcessStatus())) {
@@ -91,7 +94,7 @@ public class OverseasDeliveryCompletedConsumer {
}
inboxRow.setStatus("PROCESSED"); inboxRow.setProcessedAt(LocalDateTime.now(ZoneOffset.UTC));
inbox.updateById(inboxRow);
return required(inboundId, tenantId, payload);
return DeliveryAck.of(event, DeliveryAck.Outcome.APPLIED);
}
private void validate(EventEnvelope event) {
@@ -2,6 +2,7 @@ package com.baiyue.firstleg.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
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;
@@ -33,8 +34,8 @@ public class OverseasInboundOrderDetainedConsumer {
private final ContainerEventInboxMapper inbox;
private final ObjectMapper objectMapper;
@Transactional
public InboundOrderEntity consume(EventEnvelope event) {
@Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED)
public DeliveryAck consume(EventEnvelope event) {
validateEnvelope(event);
OverseasInboundOrderDetainedChangedV1 payload = payload(event);
long inboundId = parsePositive(payload.sourceInboundId(), "来源入仓单 ID 无效");
@@ -52,7 +53,8 @@ public class OverseasInboundOrderDetainedConsumer {
if (!existing.getPayloadHash().equalsIgnoreCase(event.payloadHash())) {
throw new IllegalArgumentException("相同 eventId 对应的事件摘要冲突");
}
return requiredInbound(inboundId, tenantId, payload.inboundCode());
if (!"PROCESSED".equals(existing.getStatus())) throw new IllegalStateException("事件尚未完成消费");
return DeliveryAck.of(event, DeliveryAck.Outcome.DUPLICATE);
}
ContainerEventInboxEntity inboxRow = inbox(event);
try {
@@ -60,21 +62,24 @@ public class OverseasInboundOrderDetainedConsumer {
} catch (DuplicateKeyException duplicate) {
ContainerEventInboxEntity raced = inbox.selectById(event.eventId());
if (raced == null || !raced.getPayloadHash().equalsIgnoreCase(event.payloadHash())) throw duplicate;
return requiredInbound(inboundId, tenantId, payload.inboundCode());
if (!"PROCESSED".equals(raced.getStatus())) throw new IllegalStateException("事件尚未完成消费");
return DeliveryAck.of(event, DeliveryAck.Outcome.DUPLICATE);
}
InboundOrderEntity inbound = requiredInbound(inboundId, tenantId, payload.inboundCode());
boolean applied = false;
if (inbound.getLastOverseasHoldVersion() == null
|| event.aggregateVersion() > inbound.getLastOverseasHoldVersion()) {
int updated = inboundOrders.applyOverseasHold(tenantId, inboundId, payload.detained(),
payload.detained() ? payload.reason() : null, event.aggregateVersion(),
parseOperatorId(payload.operatorId()));
if (updated == 1) insertHistory(event, payload, tenantId, inboundId);
applied = updated == 1;
if (applied) insertHistory(event, payload, tenantId, inboundId);
}
inboxRow.setStatus("PROCESSED");
inboxRow.setProcessedAt(LocalDateTime.now(ZoneOffset.UTC));
inbox.updateById(inboxRow);
return requiredInbound(inboundId, tenantId, payload.inboundCode());
return DeliveryAck.of(event, applied ? DeliveryAck.Outcome.APPLIED : DeliveryAck.Outcome.STALE);
}
private void validateEnvelope(EventEnvelope event) {