优化
This commit is contained in:
@@ -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);
|
||||
|
||||
}
|
||||
+9
-5
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+9
@@ -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)
|
||||
|
||||
+30
-16
@@ -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) {
|
||||
|
||||
+10
-6
@@ -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) {
|
||||
|
||||
+8
-5
@@ -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) {
|
||||
|
||||
+11
-6
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user