优化
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) {
|
||||
|
||||
+48
-6
@@ -8,7 +8,7 @@ import com.baiyue.common.event.EventPayloadHash;
|
||||
import com.baiyue.common.event.FirstLegLoadingPlanCompletedV1;
|
||||
import com.baiyue.common.event.EventTypes;
|
||||
import com.baiyue.common.event.RegionCode;
|
||||
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;
|
||||
@@ -31,7 +31,7 @@ import static org.mockito.Mockito.*;
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class LoadingCompletedOutboxRelayTest {
|
||||
@Mock private LoadingCompletedOutboxRepository repository;
|
||||
@Mock private EventBridgeClient client;
|
||||
@Mock private LastlegEventClient client;
|
||||
private ObjectMapper objectMapper;
|
||||
private LoadingCompletedOutboxRelay relay;
|
||||
|
||||
@@ -44,18 +44,18 @@ class LoadingCompletedOutboxRelayTest {
|
||||
ReflectionTestUtils.setField(relay, "objectMapper", objectMapper);
|
||||
ReflectionTestUtils.setField(relay, "internalToken", "internal-token");
|
||||
ReflectionTestUtils.setField(relay, "leaseSeconds", 60L);
|
||||
ReflectionTestUtils.setField(relay, "retrySeconds", 30L);
|
||||
ReflectionTestUtils.setField(relay, "baseDelaySeconds", 5L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void claimsExpiredLeasesAndPublishesAfterOverseasBridgeAcknowledgement() throws Exception {
|
||||
void claimsExpiredLeasesAndPublishesAfterConsumerAcknowledgement() throws Exception {
|
||||
EventEnvelope event = event();
|
||||
InboundOutboxEntity row = row(event);
|
||||
when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row));
|
||||
when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true);
|
||||
when(repository.claimed(eq(9001L), anyString())).thenReturn(row);
|
||||
when(client.receive(eq("internal-token"), any(EventEnvelope.class))).thenReturn(ApiResponse.success(
|
||||
new EventBridgeClient.Acknowledgement(event.eventId(), "已接收", "2026-08-20T03:01:00")));
|
||||
com.baiyue.common.event.DeliveryAck.of(event, com.baiyue.common.event.DeliveryAck.Outcome.APPLIED)));
|
||||
|
||||
relay.relay();
|
||||
|
||||
@@ -110,11 +110,53 @@ class LoadingCompletedOutboxRelayTest {
|
||||
relay.relay();
|
||||
|
||||
verify(repository).retry(eq(9001L), anyString(), any(LocalDateTime.class), eq(
|
||||
"IllegalStateException: 事件桥拒绝头程跨区事件: code=S0503, message=bridge inbox unavailable, "
|
||||
"IllegalStateException: Lastleg拒绝头程业务事件: code=S0503, message=bridge inbox unavailable, "
|
||||
+ "requestId=request-123, traceId=trace-456"));
|
||||
verify(repository, never()).published(anyLong(), anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void matchingEventIdWithWrongPayloadHashDoesNotPublish() throws Exception {
|
||||
EventEnvelope event = event();
|
||||
InboundOutboxEntity row = row(event);
|
||||
when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row));
|
||||
when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true);
|
||||
when(repository.claimed(eq(9001L), anyString())).thenReturn(row);
|
||||
when(client.receive(eq("internal-token"), any(EventEnvelope.class))).thenReturn(ApiResponse.success(
|
||||
new com.baiyue.common.event.DeliveryAck(event.eventId(), "sha256:" + "0".repeat(64),
|
||||
com.baiyue.common.event.DeliveryAck.Outcome.APPLIED)));
|
||||
relay.relay();
|
||||
verify(repository, never()).published(anyLong(), anyString(), any());
|
||||
verify(repository).retry(eq(9001L), anyString(), any(), contains("ACK does not match"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void twelfthFailureGoesDeadInsteadOfSchedulingAnotherRetry() throws Exception {
|
||||
InboundOutboxEntity row = row(event());
|
||||
row.setRetryCount(11);
|
||||
when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row));
|
||||
when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true);
|
||||
when(repository.claimed(eq(9001L), anyString())).thenReturn(row);
|
||||
when(client.receive(anyString(), any())).thenThrow(new IllegalStateException("consumer unavailable"));
|
||||
relay.relay();
|
||||
verify(repository).dead(eq(9001L), anyString(), eq(12), contains("consumer unavailable"));
|
||||
verify(repository, never()).retry(anyLong(), anyString(), any(), anyString());
|
||||
verify(repository, never()).published(anyLong(), anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void recoveredExhaustedLeaseDoesNotSendAgain() throws Exception {
|
||||
InboundOutboxEntity row = row(event());
|
||||
row.setRetryCount(12);
|
||||
when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row));
|
||||
when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true);
|
||||
when(repository.claimed(eq(9001L), anyString())).thenReturn(row);
|
||||
relay.relay();
|
||||
verifyNoInteractions(client);
|
||||
verify(repository).dead(eq(9001L), anyString(), eq(12), contains("租约超时"));
|
||||
verify(repository, never()).retry(anyLong(), anyString(), any(), anyString());
|
||||
}
|
||||
|
||||
private InboundOutboxEntity row(EventEnvelope event) throws Exception {
|
||||
InboundOutboxEntity row = new InboundOutboxEntity();
|
||||
row.setId(9001L);
|
||||
|
||||
+14
-2
@@ -44,6 +44,7 @@ class OverseasContainerEventConsumerTest {
|
||||
void derivesEstimatedEmptyReportedAtFromUnloadCompletionAndPersistsInbox() {
|
||||
ContainerBookingEntity container = container();
|
||||
when(containers.selectActiveBookingForUpdate(1001L)).thenReturn(container);
|
||||
when(containers.applyEmptyReport(anyLong(), any(), any(), any(), any(), anyLong())).thenReturn(1);
|
||||
Instant completedAt = Instant.parse("2026-08-20T03:00:00Z");
|
||||
Instant uploadedAt = Instant.parse("2026-08-20T02:30:00Z");
|
||||
|
||||
@@ -67,10 +68,10 @@ class OverseasContainerEventConsumerTest {
|
||||
EventEnvelope event = event("00000000-0000-4000-8000-000000000002", 7, payload);
|
||||
ContainerEventInboxEntity existing = new ContainerEventInboxEntity();
|
||||
existing.setPayloadHash(event.payloadHash());
|
||||
existing.setStatus("PROCESSED");
|
||||
when(inbox.selectById(event.eventId())).thenReturn(existing);
|
||||
when(containers.selectActiveBookingForUpdate(1001L)).thenReturn(container());
|
||||
|
||||
consumer.consume(event);
|
||||
assertThat(consumer.consume(event).outcome()).isEqualTo(com.baiyue.common.event.DeliveryAck.Outcome.DUPLICATE);
|
||||
|
||||
verify(containers, never()).applyEmptyReport(anyLong(), any(), any(), any(), any(), anyLong());
|
||||
verify(inbox, never()).insert(any(ContainerEventInboxEntity.class));
|
||||
@@ -91,6 +92,17 @@ class OverseasContainerEventConsumerTest {
|
||||
verifyNoInteractions(containers, inbox);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unappliedUnloadCannotBeAcknowledgedAsSuccessful() {
|
||||
when(containers.selectActiveBookingForUpdate(1001L)).thenReturn(container());
|
||||
EventEnvelope event = event("00000000-0000-4000-8000-000000000004", 7,
|
||||
new OverseasContainerUnloadedV1("1001", "ZCSU7881970", Instant.parse("2026-08-20T03:00:00Z"),
|
||||
Instant.parse("2026-08-20T02:30:00Z"), null));
|
||||
assertThatThrownBy(() -> consumer.consume(event)).isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("卸柜事实未写入");
|
||||
verify(inbox, never()).updateById(any(ContainerEventInboxEntity.class));
|
||||
}
|
||||
|
||||
private EventEnvelope event(String eventId, long aggregateVersion, OverseasContainerUnloadedV1 payload) {
|
||||
var node = objectMapper.valueToTree(payload);
|
||||
String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node));
|
||||
|
||||
+4
-2
@@ -47,7 +47,8 @@ class OverseasInboundOrderDetainedConsumerTest {
|
||||
when(inboundOrders.selectActiveByIdForUpdate(1001L)).thenReturn(inbound);
|
||||
when(inboundOrders.applyOverseasHold(88L, 1001L, true, "等待查验", 7L, 66L)).thenReturn(1);
|
||||
|
||||
consumer.consume(event(7, true, "等待查验"));
|
||||
assertThat(consumer.consume(event(7, true, "等待查验")).outcome())
|
||||
.isEqualTo(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED);
|
||||
|
||||
verify(inboundOrders).applyOverseasHold(88L, 1001L, true, "等待查验", 7L, 66L);
|
||||
ArgumentCaptor<InboundHoldRecordEntity> history = ArgumentCaptor.forClass(InboundHoldRecordEntity.class);
|
||||
@@ -61,7 +62,8 @@ class OverseasInboundOrderDetainedConsumerTest {
|
||||
void staleReleaseCannotOverwriteNewerHoldState() {
|
||||
when(inboundOrders.selectActiveByIdForUpdate(1001L)).thenReturn(inbound(9L));
|
||||
|
||||
consumer.consume(event(7, false, "解除"));
|
||||
assertThat(consumer.consume(event(7, false, "解除")).outcome())
|
||||
.isEqualTo(com.baiyue.common.event.DeliveryAck.Outcome.STALE);
|
||||
|
||||
verify(inboundOrders, never()).applyOverseasHold(anyLong(), anyLong(), anyBoolean(), any(), anyLong(), anyLong());
|
||||
verifyNoInteractions(holdRecords);
|
||||
|
||||
Reference in New Issue
Block a user