This commit is contained in:
2026-09-29 18:06:18 +08:00
parent 54ad26c607
commit 2c60a29e0f
30 changed files with 384 additions and 139 deletions
@@ -0,0 +1,21 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.event.EventProtocolException;
import com.baiyue.common.event.EventDependencyNotReadyException;
import org.slf4j.MDC;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.time.Instant;
@RestControllerAdvice
@Order(Ordered.HIGHEST_PRECEDENCE)
public class EventConsumptionExceptionHandler {
@ExceptionHandler({EventProtocolException.class, EventDependencyNotReadyException.class})
public ApiResponse<Void> event(IllegalArgumentException failure) {
String code = failure instanceof EventDependencyNotReadyException ? "EVT_DEPENDENCY" : "EVT_PROTOCOL";
return new ApiResponse<>(code, failure.getMessage(), null, MDC.get("requestId"), MDC.get("traceId"), Instant.now());
}
}
@@ -37,7 +37,7 @@ public class InternalOverseasContainerEventController {
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 业务事件类型");
default -> throw new com.baiyue.common.event.EventProtocolException("不支持的 Firstleg 业务事件类型");
};
return ApiResponse.success(result);
}
@@ -2,6 +2,8 @@ package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.web.EventDeliveryFailure;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.baiyue.common.core.retry.BoundedRetryPolicy;
import com.baiyue.common.event.EventEnvelopeValidator;
import com.baiyue.firstleg.client.LastlegEventClient;
@@ -50,15 +52,19 @@ public class LoadingCompletedOutboxRelay {
repository.dead(row.getId(), workerId, failures, "自动投递次数已耗尽(含租约超时)");
continue;
}
boolean requestStarted = false;
try {
EventEnvelope event=objectMapper.readValue(row.getPayloadJson(),EventEnvelope.class);
EventEnvelopeValidator.requireValid(event);
requestStarted = true;
var response=client.receive(internalToken,event);
requireAcknowledgement(event, response);
repository.published(row.getId(),workerId,LocalDateTime.now());
} catch(Exception exception) {
String error=safeError(exception);
if (policy.exhausted(failures + 1)) repository.dead(row.getId(), workerId, failures + 1, error);
boolean permanent = EventDeliveryFailure.isPermanent(exception)
|| (!requestStarted && (exception instanceof JsonProcessingException || exception instanceof IllegalArgumentException));
String error = EventDeliveryFailure.diagnostic(exception, internalToken);
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("头程业务事件投递失败: eventType={}, eventKey={}, reason={}",
row.getEventType(), row.getEventKey(), error);
@@ -68,33 +74,8 @@ public class LoadingCompletedOutboxRelay {
private void requireAcknowledgement(EventEnvelope event,
ApiResponse<com.baiyue.common.event.DeliveryAck> response) {
if(response==null) throw new IllegalStateException("Lastleg未返回响应");
if(!response.successful()) {
throw new IllegalStateException("Lastleg拒绝头程业务事件: code=" + response.code()
+ ", message=" + response.message() + ", requestId=" + response.requestId()
+ ", traceId=" + response.traceId());
}
if (response.data() == null) throw new IllegalStateException("Lastleg ACK 缺失");
EventDeliveryFailure.requireSuccessful(response);
response.data().requireMatches(event);
}
private String safeError(Exception exception) {
Throwable diagnostic=diagnosticCause(exception);
String message=diagnostic.getMessage();
String value=diagnostic.getClass().getSimpleName()
+ ((message==null || message.isBlank())?"":": " + message);
if(internalToken!=null && !internalToken.isBlank()) value=value.replace(internalToken,"***");
value=value.replaceAll("[\\r\\n\\t]+"," ");
return value.length()>500?value.substring(0,500):value;
}
private Throwable diagnosticCause(Throwable failure) {
Throwable current=failure;
while(current.getCause()!=null && current.getCause()!=current
&& ("NoFallbackAvailableException".equals(current.getClass().getSimpleName())
|| "No fallback available.".equals(current.getMessage()))) {
current=current.getCause();
}
return current;
}
}
@@ -1,6 +1,8 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.EventProtocolException;
import com.baiyue.common.event.EventDependencyNotReadyException;
import com.baiyue.common.event.DeliveryAck;
import com.baiyue.common.event.EventPayloadCanonicalizer;
import com.baiyue.common.event.EventPayloadHash;
@@ -33,23 +35,23 @@ public class OverseasContainerEventConsumer {
OverseasContainerUnloadedV1 payload = payload(event);
if (payload.sourceContainerBookingId() == null || payload.sourceContainerBookingId().isBlank()
|| !payload.sourceContainerBookingId().equals(event.aggregateId())) {
throw new IllegalArgumentException("事件 aggregateId 与 sourceContainerBookingId 不一致");
throw new EventProtocolException("事件 aggregateId 与 sourceContainerBookingId 不一致");
}
if (payload.containerNo() == null || payload.containerNo().isBlank()) {
throw new IllegalArgumentException("货柜号不能为空");
throw new EventProtocolException("货柜号不能为空");
}
if (payload.unloadingCompletedAt() == null || payload.unloadingPhotoUploadedAt() == null) {
throw new IllegalArgumentException("卸柜完成时间和卸柜照片上传时间不能为空");
throw new EventProtocolException("卸柜完成时间和卸柜照片上传时间不能为空");
}
if (payload.actualEmptyReportedAt() != null
&& payload.actualEmptyReportedAt().isBefore(payload.unloadingPhotoUploadedAt())) {
throw new IllegalArgumentException("实际报空时间不能早于卸柜照片上传时间");
throw new EventProtocolException("实际报空时间不能早于卸柜照片上传时间");
}
ContainerEventInboxEntity existing = inbox.selectById(event.eventId());
if (existing != null) {
if (!existing.getPayloadHash().equalsIgnoreCase(event.payloadHash())) {
throw new IllegalArgumentException("相同 eventId 对应的事件摘要冲突");
throw new EventProtocolException("相同 eventId 对应的事件摘要冲突");
}
if (!"PROCESSED".equals(existing.getStatus())) throw new IllegalStateException("事件尚未完成消费");
return DeliveryAck.of(event, DeliveryAck.Outcome.DUPLICATE);
@@ -79,20 +81,20 @@ public class OverseasContainerEventConsumer {
private void validateEnvelope(EventEnvelope event) {
event.requireValid();
if (!EventTypes.OVERSEAS_CONTAINER_UNLOADED.equals(event.eventType()) || event.eventVersion() != 1) {
throw new IllegalArgumentException("不支持的海外货柜事件");
throw new EventProtocolException("不支持的海外货柜事件");
}
if (event.sourceRegion() != RegionCode.OVERSEAS || event.targetRegion() != RegionCode.CN
|| !"CONTAINER_BOOKING".equals(event.aggregateType())) {
throw new IllegalArgumentException("海外货柜事件区域或聚合类型无效");
throw new EventProtocolException("海外货柜事件区域或聚合类型无效");
}
if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(event.payload()), event.payloadHash())) {
throw new IllegalArgumentException("payloadHash 与事件负载不一致");
throw new EventProtocolException("payloadHash 与事件负载不一致");
}
}
private OverseasContainerUnloadedV1 payload(EventEnvelope event) {
try { return objectMapper.treeToValue(event.payload(), OverseasContainerUnloadedV1.class); }
catch (Exception exception) { throw new IllegalArgumentException("海外货柜事件负载无效", exception); }
catch (Exception exception) { throw new EventProtocolException("海外货柜事件负载无效", exception); }
}
private ContainerEventInboxEntity inbox(EventEnvelope event) {
@@ -110,12 +112,12 @@ public class OverseasContainerEventConsumer {
try {
bookingId = Long.parseLong(sourceContainerBookingId);
} catch (NumberFormatException exception) {
throw new IllegalArgumentException("国内货柜订舱 ID 无效", exception);
throw new EventProtocolException("国内货柜订舱 ID 无效", exception);
}
ContainerBookingEntity value = containers.selectActiveBookingForUpdate(bookingId);
if (value == null) throw new IllegalArgumentException("头程货柜不存在或不可用");
if (value == null) throw new EventDependencyNotReadyException("头程货柜不存在或不可用");
if (value.getContainerNo() == null || !value.getContainerNo().equals(containerNo)) {
throw new IllegalArgumentException("事件货柜号与国内订舱记录不一致");
throw new EventProtocolException("事件货柜号与国内订舱记录不一致");
}
return value;
}
@@ -2,6 +2,8 @@ package com.baiyue.firstleg.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.EventProtocolException;
import com.baiyue.common.event.EventDependencyNotReadyException;
import com.baiyue.common.event.DeliveryAck;
import com.baiyue.common.event.EventPayloadCanonicalizer;
import com.baiyue.common.event.EventPayloadHash;
@@ -45,12 +47,12 @@ public class OverseasDeliveryCompletedConsumer {
long inboundId = positive(payload.sourceInboundId(), "来源入仓单 ID 无效");
long tenantId = positive(event.tenantId(), "客户 ID 无效");
if (!Objects.equals(event.aggregateId(), payload.sourceInboundId())) {
throw new IllegalArgumentException("事件 aggregateId 与来源入仓单 ID 不一致");
throw new EventProtocolException("事件 aggregateId 与来源入仓单 ID 不一致");
}
ContainerEventInboxEntity existing = inbox.selectById(event.eventId());
if (existing != null) {
if (!existing.getPayloadHash().equalsIgnoreCase(event.payloadHash())) {
throw new IllegalArgumentException("相同 eventId 对应的事件摘要冲突");
throw new EventProtocolException("相同 eventId 对应的事件摘要冲突");
}
if (!"PROCESSED".equals(existing.getStatus())) throw new IllegalStateException("事件尚未完成消费");
return DeliveryAck.of(event, DeliveryAck.Outcome.DUPLICATE);
@@ -65,7 +67,7 @@ public class OverseasDeliveryCompletedConsumer {
}
InboundOrderEntity inbound = required(inboundId, tenantId, payload);
if (!"LOADED".equals(inbound.getProcessStatus()) && !"DELIVERED".equals(inbound.getProcessStatus())) {
throw new IllegalArgumentException("来源头程入仓单尚未装柜完成");
throw new EventDependencyNotReadyException("来源头程入仓单尚未装柜完成");
}
if ("LOADED".equals(inbound.getProcessStatus())) {
LocalDateTime completedAt = LocalDateTime.ofInstant(payload.completedAt(), ZoneOffset.UTC);
@@ -102,10 +104,10 @@ public class OverseasDeliveryCompletedConsumer {
if (!EventTypes.OVERSEAS_DELIVERY_COMPLETED.equals(event.eventType()) || event.eventVersion() != 1
|| event.sourceRegion() != RegionCode.OVERSEAS || event.targetRegion() != RegionCode.CN
|| !"INBOUND_ORDER".equals(event.aggregateType())) {
throw new IllegalArgumentException("不支持的海外派送完成事件");
throw new EventProtocolException("不支持的海外派送完成事件");
}
if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(event.payload()), event.payloadHash())) {
throw new IllegalArgumentException("payloadHash 与事件负载不一致");
throw new EventProtocolException("payloadHash 与事件负载不一致");
}
}
@@ -114,22 +116,23 @@ public class OverseasDeliveryCompletedConsumer {
OverseasDeliveryCompletedV1 value = objectMapper.treeToValue(event.payload(), OverseasDeliveryCompletedV1.class);
if (value.completedAt() == null || !StringUtils.hasText(value.deliveryNo())
|| !StringUtils.hasText(value.sourceOrderId()) || !StringUtils.hasText(value.sourceOrderNo())
|| !StringUtils.hasText(value.inboundCode())) throw new IllegalArgumentException("字段不完整");
|| !StringUtils.hasText(value.inboundCode())) throw new EventProtocolException("字段不完整");
return value;
} catch (Exception exception) {
throw new IllegalArgumentException("海外派送完成事件负载无效", exception);
throw new EventProtocolException("海外派送完成事件负载无效", exception);
}
}
private InboundOrderEntity required(long inboundId, long tenantId, OverseasDeliveryCompletedV1 payload) {
InboundOrderEntity inbound = inboundOrders.selectActiveByIdForUpdate(inboundId);
if (inbound == null || !Objects.equals(inbound.getTenantId(), tenantId)
if (inbound == null) throw new EventDependencyNotReadyException("来源头程入仓单不存在");
if (!Objects.equals(inbound.getTenantId(), tenantId)
|| !Objects.equals(inbound.getInboundCode(), payload.inboundCode())
|| !Objects.equals(String.valueOf(inbound.getOrderId()), payload.sourceOrderId())
|| !Objects.equals(inbound.getOrderNo(), payload.sourceOrderNo())
|| (StringUtils.hasText(payload.fbaWarehouseCode()) && StringUtils.hasText(inbound.getDestinationCode())
&& !Objects.equals(inbound.getDestinationCode(), payload.fbaWarehouseCode()))) {
throw new IllegalArgumentException("事件与来源头程入仓单归属不一致");
throw new EventProtocolException("事件与来源头程入仓单归属不一致");
}
return inbound;
}
@@ -146,6 +149,6 @@ public class OverseasDeliveryCompletedConsumer {
private static long positive(String value, String message) {
try { long parsed = Long.parseLong(value); if (parsed > 0) return parsed; }
catch (RuntimeException ignored) { }
throw new IllegalArgumentException(message);
throw new EventProtocolException(message);
}
}
@@ -2,6 +2,8 @@ package com.baiyue.firstleg.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.EventProtocolException;
import com.baiyue.common.event.EventDependencyNotReadyException;
import com.baiyue.common.event.DeliveryAck;
import com.baiyue.common.event.EventPayloadCanonicalizer;
import com.baiyue.common.event.EventPayloadHash;
@@ -41,17 +43,17 @@ public class OverseasInboundOrderDetainedConsumer {
long inboundId = parsePositive(payload.sourceInboundId(), "来源入仓单 ID 无效");
long tenantId = parsePositive(event.tenantId(), "客户 ID 无效");
if (!payload.sourceInboundId().equals(event.aggregateId())) {
throw new IllegalArgumentException("事件 aggregateId 与来源入仓单 ID 不一致");
throw new EventProtocolException("事件 aggregateId 与来源入仓单 ID 不一致");
}
if (payload.changedAt() == null || payload.inboundCode() == null || payload.inboundCode().isBlank()
|| payload.reason() == null || payload.reason().isBlank()) {
throw new IllegalArgumentException("暂扣事件缺少入仓码、原因或变更时间");
throw new EventProtocolException("暂扣事件缺少入仓码、原因或变更时间");
}
ContainerEventInboxEntity existing = inbox.selectById(event.eventId());
if (existing != null) {
if (!existing.getPayloadHash().equalsIgnoreCase(event.payloadHash())) {
throw new IllegalArgumentException("相同 eventId 对应的事件摘要冲突");
throw new EventProtocolException("相同 eventId 对应的事件摘要冲突");
}
if (!"PROCESSED".equals(existing.getStatus())) throw new IllegalStateException("事件尚未完成消费");
return DeliveryAck.of(event, DeliveryAck.Outcome.DUPLICATE);
@@ -87,23 +89,23 @@ public class OverseasInboundOrderDetainedConsumer {
if (!EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED.equals(event.eventType())
|| event.eventVersion() != 1 || event.sourceRegion() != RegionCode.OVERSEAS
|| event.targetRegion() != RegionCode.CN || !"INBOUND_ORDER".equals(event.aggregateType())) {
throw new IllegalArgumentException("不支持的海外入仓单暂扣事件");
throw new EventProtocolException("不支持的海外入仓单暂扣事件");
}
if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(event.payload()), event.payloadHash())) {
throw new IllegalArgumentException("payloadHash 与事件负载不一致");
throw new EventProtocolException("payloadHash 与事件负载不一致");
}
}
private OverseasInboundOrderDetainedChangedV1 payload(EventEnvelope event) {
try { return objectMapper.treeToValue(event.payload(), OverseasInboundOrderDetainedChangedV1.class); }
catch (Exception exception) { throw new IllegalArgumentException("海外入仓单暂扣事件负载无效", exception); }
catch (Exception exception) { throw new EventProtocolException("海外入仓单暂扣事件负载无效", exception); }
}
private InboundOrderEntity requiredInbound(long inboundId, long tenantId, String inboundCode) {
InboundOrderEntity value = inboundOrders.selectActiveByIdForUpdate(inboundId);
if (value == null) throw new IllegalArgumentException("来源头程入仓单不存在");
if (value == null) throw new EventDependencyNotReadyException("来源头程入仓单不存在");
if (!Objects.equals(value.getTenantId(), tenantId) || !Objects.equals(value.getInboundCode(), inboundCode)) {
throw new IllegalArgumentException("事件与来源头程入仓单归属不一致");
throw new EventProtocolException("事件与来源头程入仓单归属不一致");
}
return value;
}
@@ -130,7 +132,7 @@ public class OverseasInboundOrderDetainedConsumer {
private long parsePositive(String value, String message) {
try { long parsed = Long.parseLong(value); if (parsed > 0) return parsed; }
catch (RuntimeException ignored) { }
throw new IllegalArgumentException(message);
throw new EventProtocolException(message);
}
private long parseOperatorId(String value) {