优化
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
package com.baiyue.lastleg.client;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.event.DeliveryAck;
|
||||
import com.baiyue.common.event.EventEnvelope;
|
||||
import com.baiyue.common.event.EventTypes;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** Dispatches each supported business event directly to its owning service. */
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class DirectEventClient {
|
||||
private final FirstlegEventClient firstleg;
|
||||
private final FinanceBusinessEventClient finance;
|
||||
|
||||
public ApiResponse<DeliveryAck> receive(String token, EventEnvelope event) {
|
||||
event.requireValid();
|
||||
return switch (event.eventType()) {
|
||||
case EventTypes.OVERSEAS_CONTAINER_UNLOADED,
|
||||
EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED,
|
||||
EventTypes.OVERSEAS_DELIVERY_COMPLETED -> firstleg.receive(token, event);
|
||||
case EventTypes.WALLET_COMMAND_REQUESTED -> finance.wallet(token, event);
|
||||
case EventTypes.BUSINESS_FEE_SOURCE_CHANGED -> finance.fee(token, event);
|
||||
default -> throw new IllegalArgumentException("Unsupported outbound event type: " + event.eventType());
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package com.baiyue.lastleg.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 = "overseasContainerEventBridgeClient",
|
||||
url = "${CN_EVENT_BRIDGE_URL:${baiyue.firstleg.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) { }
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package com.baiyue.lastleg.client;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.web.EventDeliveryFeignConfiguration;
|
||||
import com.baiyue.common.event.DeliveryAck;
|
||||
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;
|
||||
|
||||
@FeignClient(configuration = EventDeliveryFeignConfiguration.class, name = "finance-service", contextId = "FinanceBusinessEventClient",
|
||||
url = "${FINANCE_SERVICE_URL:${baiyue.clients.finance.url:}}")
|
||||
public interface FinanceBusinessEventClient {
|
||||
@PostMapping("/internal/finance/v1/wallet-command-events")
|
||||
ApiResponse<DeliveryAck> wallet(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestBody EventEnvelope envelope);
|
||||
@PostMapping("/internal/finance/v1/business-fee-source-events")
|
||||
ApiResponse<DeliveryAck> fee(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestBody EventEnvelope envelope);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.baiyue.lastleg.client;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.web.EventDeliveryFeignConfiguration;
|
||||
import com.baiyue.common.event.DeliveryAck;
|
||||
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;
|
||||
|
||||
@FeignClient(configuration = EventDeliveryFeignConfiguration.class, name = "firstleg-service", contextId = "FirstlegEventClient",
|
||||
url = "${FIRSTLEG_SERVICE_URL:${baiyue.clients.firstleg.url:}}")
|
||||
public interface FirstlegEventClient {
|
||||
@PostMapping("/internal/firstleg/v1/container-events")
|
||||
ApiResponse<DeliveryAck> receive(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestBody EventEnvelope envelope);
|
||||
}
|
||||
+3
-2
@@ -3,6 +3,7 @@ package com.baiyue.lastleg.controller;
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.event.EventEnvelope;
|
||||
import com.baiyue.common.event.DeliveryAck;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.dto.LastlegOrderDetailResponse;
|
||||
import com.baiyue.lastleg.dto.LastlegOrderRequests;
|
||||
@@ -60,11 +61,11 @@ public class LastlegOrderController {
|
||||
@PostMapping("/internal/lastleg/events")
|
||||
@Operation(summary = "Receive a first-leg loading or actual shipping-node event",
|
||||
description = "Accepts loading-completed V1/V2 and FirstLegContainerShippingNodeRecorded V1 through the internal token boundary; only monotonic actual PICKUP_READY-or-later facts open overseas PDA receiving.")
|
||||
public ApiResponse<Integer> receive(
|
||||
public ApiResponse<DeliveryAck> receive(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@RequestBody EventEnvelope event) {
|
||||
tokenGuard.verify(token);
|
||||
int consumed;
|
||||
DeliveryAck consumed;
|
||||
if (EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED.equals(event.eventType())) {
|
||||
consumed = shippingNodeConsumer.consume(event);
|
||||
} else if (EventTypes.WALLET_COMMAND_COMPLETED.equals(event.eventType())) {
|
||||
|
||||
+10
-1
@@ -27,8 +27,11 @@ public class OverseasFeeOutboxRepository {
|
||||
public boolean claim(long id, String worker, LocalDateTime now, LocalDateTime stale) {
|
||||
return mapper.update(null, new LambdaUpdateWrapper<LastlegEventOutbox>()
|
||||
.eq(LastlegEventOutbox::getId, id).eq(LastlegEventOutbox::getEventType, EventTypes.BUSINESS_FEE_SOURCE_CHANGED)
|
||||
.and(status -> status.eq(LastlegEventOutbox::getStatus, "PENDING").or(processing ->
|
||||
.and(status -> status.eq(LastlegEventOutbox::getStatus, "PENDING")
|
||||
.and(retry -> retry.isNull(LastlegEventOutbox::getNextRetryAt).or().le(LastlegEventOutbox::getNextRetryAt, now))
|
||||
.or(processing ->
|
||||
processing.eq(LastlegEventOutbox::getStatus, "PROCESSING").le(LastlegEventOutbox::getLockedAt, stale)))
|
||||
.setSql("retry_count = retry_count + CASE WHEN status='PROCESSING' THEN 1 ELSE 0 END")
|
||||
.set(LastlegEventOutbox::getStatus, "PROCESSING").set(LastlegEventOutbox::getLockedBy, worker)
|
||||
.set(LastlegEventOutbox::getLockedAt, now)) == 1;
|
||||
}
|
||||
@@ -40,6 +43,12 @@ public class OverseasFeeOutboxRepository {
|
||||
mapper.update(null, claimed(id, worker, "PUBLISHED").set(LastlegEventOutbox::getPublishedAt, now)
|
||||
.set(LastlegEventOutbox::getNextRetryAt, null).set(LastlegEventOutbox::getLastError, null));
|
||||
}
|
||||
public void dead(long id, String worker, int failures, String error) {
|
||||
mapper.update(null, claimed(id, worker, "DEAD")
|
||||
.set(LastlegEventOutbox::getRetryCount, failures)
|
||||
.set(LastlegEventOutbox::getNextRetryAt, null)
|
||||
.set(LastlegEventOutbox::getLastError, truncate(error)));
|
||||
}
|
||||
public void retry(long id, String worker, int retryCount, LocalDateTime next, String error) {
|
||||
mapper.update(null, claimed(id, worker, "PENDING").set(LastlegEventOutbox::getRetryCount, retryCount)
|
||||
.set(LastlegEventOutbox::getNextRetryAt, next).set(LastlegEventOutbox::getLastError, truncate(error)));
|
||||
|
||||
+28
-11
@@ -1,7 +1,8 @@
|
||||
package com.baiyue.lastleg.financefee.service;
|
||||
|
||||
import com.baiyue.common.event.EventEnvelope;
|
||||
import com.baiyue.lastleg.client.EventBridgeClient;
|
||||
import com.baiyue.common.core.retry.BoundedRetryPolicy;
|
||||
import com.baiyue.lastleg.client.DirectEventClient;
|
||||
import com.baiyue.lastleg.entity.LastlegEventOutbox;
|
||||
import com.baiyue.lastleg.financefee.repository.OverseasFeeOutboxRepository;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@@ -10,7 +11,6 @@ import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.lang.management.ManagementFactory;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.UUID;
|
||||
@@ -18,28 +18,45 @@ import java.util.UUID;
|
||||
@Component
|
||||
public class OverseasFeeOutboxRelay {
|
||||
@Resource private OverseasFeeOutboxRepository repository;
|
||||
@Resource private EventBridgeClient client;
|
||||
@Resource private DirectEventClient client;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
@Value("${baiyue.security.internal-service-token:}") private String token;
|
||||
private final String worker = ManagementFactory.getRuntimeMXBean().getName() + ':' + UUID.randomUUID();
|
||||
|
||||
@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;
|
||||
|
||||
@Value("${baiyue.event-delivery.lease-seconds:60}") private long leaseSeconds = 60;
|
||||
|
||||
@Scheduled(fixedDelayString = "${baiyue.finance-fee-relay.fixed-delay:3000}")
|
||||
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(ZoneOffset.UTC);
|
||||
for (LastlegEventOutbox candidate : repository.pending(50, now, now.minusSeconds(30))) {
|
||||
if (!repository.claim(candidate.getId(), worker, now, now.minusSeconds(30))) continue;
|
||||
for (LastlegEventOutbox candidate : repository.pending(50, now, now.minusSeconds(leaseSeconds))) {
|
||||
String worker = UUID.randomUUID().toString();
|
||||
now = LocalDateTime.now(ZoneOffset.UTC);
|
||||
|
||||
if (!repository.claim(candidate.getId(), worker, now, now.minusSeconds(leaseSeconds))) 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(), worker, failures, "自动投递次数已耗尽(含租约超时)");
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
EventEnvelope envelope = objectMapper.readValue(row.getPayloadJson(), EventEnvelope.class).requireValid();
|
||||
var response = client.receive(token, envelope);
|
||||
if (response == null || !"200".equals(response.code()) || response.data() == null
|
||||
|| !envelope.eventId().equals(response.data().eventId()))
|
||||
throw new IllegalStateException("Event Bridge未确认海外费用事件");
|
||||
if (response == null || !response.successful() || response.data() == null)
|
||||
throw new IllegalStateException("Finance未确认费用事件");
|
||||
response.data().requireMatches(envelope);
|
||||
repository.published(row.getId(), worker, LocalDateTime.now(ZoneOffset.UTC));
|
||||
} catch (Exception exception) {
|
||||
int retries = row.getRetryCount() == null ? 1 : row.getRetryCount() + 1;
|
||||
repository.retry(row.getId(), worker, retries, now.plusSeconds(Math.min(900, 5L << Math.min(retries, 7))), exception.getMessage());
|
||||
int retries = failures + 1;
|
||||
if (policy.exhausted(retries)) repository.dead(row.getId(), worker, retries, exception.getMessage());
|
||||
else repository.retry(row.getId(), worker, retries, LocalDateTime.now(ZoneOffset.UTC).plus(policy.delayAfterFailure(retries)), exception.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+30
-16
@@ -1,8 +1,10 @@
|
||||
package com.baiyue.lastleg.inboundplan.pda.service;
|
||||
|
||||
import com.baiyue.common.event.EventEnvelope;
|
||||
import com.baiyue.common.core.retry.BoundedRetryPolicy;
|
||||
import com.baiyue.common.event.DeliveryAck;
|
||||
import com.baiyue.common.event.EventEnvelopeValidator;
|
||||
import com.baiyue.lastleg.client.EventBridgeClient;
|
||||
import com.baiyue.lastleg.client.DirectEventClient;
|
||||
import com.baiyue.lastleg.entity.LastlegEventOutbox;
|
||||
import com.baiyue.lastleg.repository.UnloadCompletedOutboxRepository;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@@ -15,26 +17,40 @@ import org.springframework.stereotype.Component;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
/** 至少一次投递海外到国内业务事件;国内事件桥按 eventId 幂等接收。 */
|
||||
/** 至少一次投递海外到国内业务事件;消费服务按 eventId 幂等接收。 */
|
||||
@Component
|
||||
@Slf4j
|
||||
public class InboundUnloadCompletedOutboxRelay {
|
||||
@Resource private UnloadCompletedOutboxRepository repository;
|
||||
@Resource private EventBridgeClient client;
|
||||
@Resource private DirectEventClient client;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
@Value("${baiyue.security.internal-service-token}") private String internalToken;
|
||||
@Value("${baiyue.firstleg.outbox-relay.lease-seconds:60}") private long leaseSeconds;
|
||||
@Value("${baiyue.firstleg.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.firstleg.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 (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);
|
||||
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);
|
||||
@@ -42,7 +58,8 @@ public class InboundUnloadCompletedOutboxRelay {
|
||||
requireAcknowledgement(event, response);
|
||||
repository.published(row.getId(), workerId, LocalDateTime.now());
|
||||
} catch (Exception exception) {
|
||||
repository.retry(row.getId(), workerId, LocalDateTime.now().plusSeconds(Math.max(retrySeconds, 1)),
|
||||
if (policy.exhausted(failures + 1)) repository.dead(row.getId(), workerId, failures + 1, exception.getMessage());
|
||||
else repository.retry(row.getId(), workerId, LocalDateTime.now().plus(policy.delayAfterFailure(failures + 1)),
|
||||
exception.getMessage());
|
||||
log.warn("海外到国内事件投递失败: eventKey={}, reason={}", row.getEventKey(), exception.getMessage());
|
||||
}
|
||||
@@ -50,16 +67,13 @@ public class InboundUnloadCompletedOutboxRelay {
|
||||
}
|
||||
|
||||
private void requireAcknowledgement(EventEnvelope event,
|
||||
com.baiyue.common.core.api.ApiResponse<EventBridgeClient.Acknowledgement> response) {
|
||||
if (response == null) throw new IllegalStateException("国内事件桥返回空响应");
|
||||
com.baiyue.common.core.api.ApiResponse<DeliveryAck> response) {
|
||||
if (response == null) throw new IllegalStateException("消费服务返回空响应");
|
||||
if (!response.successful()) {
|
||||
throw new IllegalStateException("国内事件桥拒绝海外事件: code=" + response.code()
|
||||
throw new IllegalStateException("消费服务拒绝海外事件: code=" + response.code()
|
||||
+ ", message=" + response.message() + ", requestId=" + response.requestId());
|
||||
}
|
||||
if (response.data() == null) throw new IllegalStateException("国内事件桥成功响应缺少确认数据");
|
||||
if (!event.eventId().equals(response.data().eventId())) {
|
||||
throw new IllegalStateException("国内事件桥确认eventId不一致: expected=" + event.eventId()
|
||||
+ ", actual=" + response.data().eventId());
|
||||
}
|
||||
if (response.data() == null) throw new IllegalStateException("消费服务成功响应缺少确认数据");
|
||||
response.data().requireMatches(event);
|
||||
}
|
||||
}
|
||||
|
||||
+9
@@ -41,6 +41,7 @@ public class UnloadCompletedOutboxRepository {
|
||||
.or().le(LastlegEventOutbox::getNextRetryAt, now))
|
||||
.or(stale -> stale.eq(LastlegEventOutbox::getStatus, "PROCESSING")
|
||||
.le(LastlegEventOutbox::getLockedAt, expiredLeaseBefore)))
|
||||
.setSql("retry_count = retry_count + CASE WHEN status='PROCESSING' THEN 1 ELSE 0 END")
|
||||
.set(LastlegEventOutbox::getStatus, "PROCESSING")
|
||||
.set(LastlegEventOutbox::getLockedBy, worker)
|
||||
.set(LastlegEventOutbox::getLockedAt, now)) == 1;
|
||||
@@ -64,6 +65,14 @@ public class UnloadCompletedOutboxRepository {
|
||||
.set(LastlegEventOutbox::getLastError, null));
|
||||
}
|
||||
|
||||
public void dead(long id, String worker, int failures, String error) {
|
||||
mapper.update(null, claimedBase(id, worker)
|
||||
.set(LastlegEventOutbox::getStatus, "DEAD")
|
||||
.set(LastlegEventOutbox::getRetryCount, failures)
|
||||
.set(LastlegEventOutbox::getNextRetryAt, null)
|
||||
.set(LastlegEventOutbox::getLockedBy, null).set(LastlegEventOutbox::getLockedAt, null)
|
||||
.set(LastlegEventOutbox::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")
|
||||
|
||||
+6
-4
@@ -51,7 +51,7 @@ public class LoadingCompletedConsumer {
|
||||
@Resource private ContainerLoadingEvidenceMapper loadingEvidence;
|
||||
|
||||
@Transactional(isolation = Isolation.READ_COMMITTED)
|
||||
public int consume(EventEnvelope envelope) {
|
||||
public DeliveryAck consume(EventEnvelope envelope) {
|
||||
envelope.requireValid();
|
||||
if (!EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED.equals(envelope.eventType())
|
||||
|| (envelope.eventVersion() != 1 && envelope.eventVersion() != 2 && envelope.eventVersion() != 3))
|
||||
@@ -72,7 +72,8 @@ public class LoadingCompletedConsumer {
|
||||
LastlegEventInbox existing=inboxMapper.selectById(envelope.eventId());
|
||||
if(existing!=null) {
|
||||
if(!existing.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) throw new IllegalArgumentException("事件摘要冲突");
|
||||
return countOrders(envelope,containerNo);
|
||||
if (!"PROCESSED".equals(existing.getStatus())) throw new IllegalStateException("事件尚未完成消费");
|
||||
return DeliveryAck.of(envelope, DeliveryAck.Outcome.DUPLICATE);
|
||||
}
|
||||
|
||||
LastlegEventInbox inbox=new LastlegEventInbox();
|
||||
@@ -85,7 +86,8 @@ public class LoadingCompletedConsumer {
|
||||
|| !raced.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) {
|
||||
throw new IllegalArgumentException("事件摘要冲突", exception);
|
||||
}
|
||||
return countOrders(envelope,containerNo);
|
||||
if (!"PROCESSED".equals(raced.getStatus())) throw new IllegalStateException("事件尚未完成消费");
|
||||
return DeliveryAck.of(envelope, DeliveryAck.Outcome.DUPLICATE);
|
||||
}
|
||||
|
||||
projectLoadingEvidence(envelope, sourceContainerBookingId);
|
||||
@@ -102,7 +104,7 @@ public class LoadingCompletedConsumer {
|
||||
sourceContainerBookingId, owner);
|
||||
}
|
||||
inbox.setStatus("PROCESSED"); inbox.setProcessedAt(LocalDateTime.now()); inboxMapper.updateById(inbox);
|
||||
return payload.orders().size();
|
||||
return DeliveryAck.of(envelope, DeliveryAck.Outcome.APPLIED);
|
||||
}
|
||||
|
||||
private void projectLoadingEvidence(EventEnvelope envelope, String sourceContainerBookingId) {
|
||||
|
||||
+10
-6
@@ -1,6 +1,7 @@
|
||||
package com.baiyue.lastleg.shipping.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;
|
||||
@@ -28,7 +29,7 @@ public class ContainerShippingNodeConsumer {
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
|
||||
@Transactional(isolation = Isolation.READ_COMMITTED)
|
||||
public int consume(EventEnvelope envelope) {
|
||||
public DeliveryAck consume(EventEnvelope envelope) {
|
||||
validate(envelope);
|
||||
FirstLegContainerShippingNodeRecordedV1 payload = payload(envelope);
|
||||
validateEnvelopePayload(envelope, payload);
|
||||
@@ -37,7 +38,8 @@ public class ContainerShippingNodeConsumer {
|
||||
if (!existing.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) {
|
||||
throw new IllegalArgumentException("船节点事件摘要冲突");
|
||||
}
|
||||
return 1;
|
||||
if (!"PROCESSED".equals(existing.getStatus())) throw new IllegalStateException("事件尚未完成消费");
|
||||
return DeliveryAck.of(envelope, DeliveryAck.Outcome.DUPLICATE);
|
||||
}
|
||||
LastlegEventInbox inbox = inbox(envelope);
|
||||
try { inboxes.insert(inbox); }
|
||||
@@ -46,7 +48,8 @@ public class ContainerShippingNodeConsumer {
|
||||
if (concurrent == null || !concurrent.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) {
|
||||
throw new IllegalArgumentException("船节点事件摘要冲突", raced);
|
||||
}
|
||||
return 1;
|
||||
if (!"PROCESSED".equals(concurrent.getStatus())) throw new IllegalStateException("事件尚未完成消费");
|
||||
return DeliveryAck.of(envelope, DeliveryAck.Outcome.DUPLICATE);
|
||||
}
|
||||
|
||||
gates.initialize(payload.containerBookingId(), payload.oceanBookingId(),
|
||||
@@ -55,13 +58,13 @@ public class ContainerShippingNodeConsumer {
|
||||
if (gate == null || !payload.containerNo().equals(gate.getContainerNo())) {
|
||||
throw new IllegalArgumentException("船节点柜号快照与装柜交接不一致");
|
||||
}
|
||||
apply(gate, envelope, payload);
|
||||
boolean applied = apply(gate, envelope, payload);
|
||||
gates.updateById(gate);
|
||||
inbox.setStatus("PROCESSED"); inbox.setProcessedAt(LocalDateTime.now()); inboxes.updateById(inbox);
|
||||
return 1;
|
||||
return DeliveryAck.of(envelope, applied ? DeliveryAck.Outcome.APPLIED : DeliveryAck.Outcome.STALE);
|
||||
}
|
||||
|
||||
private void apply(ContainerShippingGate gate, EventEnvelope envelope,
|
||||
private boolean apply(ContainerShippingGate gate, EventEnvelope envelope,
|
||||
FirstLegContainerShippingNodeRecordedV1 payload) {
|
||||
long currentVersion = gate.getAggregateVersion() == null ? 0 : gate.getAggregateVersion();
|
||||
int currentSequence = gate.getNodeSequence() == null ? 0 : gate.getNodeSequence();
|
||||
@@ -88,6 +91,7 @@ public class ContainerShippingNodeConsumer {
|
||||
gate.setHandoverVersion(Math.max(gate.getHandoverVersion() == null ? 0 : gate.getHandoverVersion(),
|
||||
envelope.handoverVersion()));
|
||||
gate.setUpdatedAt(LocalDateTime.now());
|
||||
return accepted;
|
||||
}
|
||||
|
||||
private void validate(EventEnvelope envelope) {
|
||||
|
||||
+11
-6
@@ -1,6 +1,7 @@
|
||||
package com.baiyue.lastleg.wallet;
|
||||
|
||||
import com.baiyue.common.event.EventEnvelope;
|
||||
import com.baiyue.common.event.DeliveryAck;
|
||||
import com.baiyue.common.event.EventEnvelopeValidator;
|
||||
import com.baiyue.common.event.EventPayloadCanonicalizer;
|
||||
import com.baiyue.common.event.EventPayloadHash;
|
||||
@@ -26,23 +27,27 @@ public class WalletCommandCompletedConsumer {
|
||||
private final ObjectMapper objectMapper;
|
||||
private final List<WalletResultHandler> handlers;
|
||||
|
||||
@Transactional
|
||||
public int consume(EventEnvelope envelope) {
|
||||
@Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED)
|
||||
public DeliveryAck consume(EventEnvelope envelope) {
|
||||
WalletCommandCompletedV1 result = validate(envelope);
|
||||
List<String> existing = jdbc.queryForList("SELECT payload_hash FROM ll_wallet_event_inbox WHERE event_id=?", String.class, envelope.eventId());
|
||||
List<String> existing = jdbc.queryForList("SELECT payload_hash FROM ll_wallet_event_inbox WHERE event_id=? AND status='PROCESSED'", String.class, envelope.eventId());
|
||||
if (!existing.isEmpty()) {
|
||||
if (!existing.get(0).equals(envelope.payloadHash())) throw bad("同一资金结果事件ID对应不同载荷");
|
||||
return 0;
|
||||
return DeliveryAck.of(envelope, DeliveryAck.Outcome.DUPLICATE);
|
||||
}
|
||||
try {
|
||||
jdbc.update("INSERT INTO ll_wallet_event_inbox(event_id,command_event_id,payload_hash,payload_json,status,received_at) VALUES(?,?,?,CAST(? AS JSON),'RECEIVED',NOW(3))",
|
||||
envelope.eventId(), result.commandEventId(), envelope.payloadHash(), json(envelope));
|
||||
} catch (DuplicateKeyException exception) { return 0; }
|
||||
} catch (DuplicateKeyException exception) {
|
||||
List<String> raced = jdbc.queryForList("SELECT payload_hash FROM ll_wallet_event_inbox WHERE event_id=? AND status='PROCESSED'", String.class, envelope.eventId());
|
||||
if (raced.size() != 1 || !raced.get(0).equals(envelope.payloadHash())) throw bad("资金结果事件身份冲突或尚未完成");
|
||||
return DeliveryAck.of(envelope, DeliveryAck.Outcome.DUPLICATE);
|
||||
}
|
||||
WalletResultHandler handler = handlers.stream().filter(value -> value.supports(result.businessType())).findFirst()
|
||||
.orElseThrow(() -> bad("没有可处理该资金结果的业务模块"));
|
||||
handler.handle(result);
|
||||
jdbc.update("UPDATE ll_wallet_event_inbox SET status='PROCESSED',processed_at=NOW(3) WHERE event_id=?", envelope.eventId());
|
||||
return 1;
|
||||
return DeliveryAck.of(envelope, DeliveryAck.Outcome.APPLIED);
|
||||
}
|
||||
|
||||
private WalletCommandCompletedV1 validate(EventEnvelope envelope) {
|
||||
|
||||
@@ -88,10 +88,9 @@ baiyue:
|
||||
lease-seconds: ${FIRSTLEG_OUTBOX_RELAY_LEASE_SECONDS:60}
|
||||
retry-seconds: ${FIRSTLEG_OUTBOX_RELAY_RETRY_SECONDS:30}
|
||||
security:
|
||||
gateway-internal-service-token: ${GATEWAY_INTERNAL_SERVICE_TOKEN:${INTERNAL_SERVICE_TOKEN:}}
|
||||
internal-service-token: ${INTERNAL_SERVICE_TOKEN:}
|
||||
jwt-mode: jwks
|
||||
session-mode: gateway
|
||||
session-mode: redis
|
||||
issuer: ${JWT_ISSUER:auth-service}
|
||||
audience: ${JWT_AUDIENCE:lastleg-api}
|
||||
jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json}
|
||||
jwk-set-uri: ${JWT_JWKS_URI:http://auth-service:9100/api/auth/.well-known/jwks.json}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.baiyue.lastleg.client;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.event.*;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import java.time.Instant;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class DirectEventClientTest {
|
||||
private final FirstlegEventClient firstleg = mock(FirstlegEventClient.class);
|
||||
private final FinanceBusinessEventClient finance = mock(FinanceBusinessEventClient.class);
|
||||
private final DirectEventClient client = new DirectEventClient(firstleg, finance);
|
||||
|
||||
@Test void walletIsSentOnlyToFinance() {
|
||||
var event = event(EventTypes.WALLET_COMMAND_REQUESTED);
|
||||
var ack = ApiResponse.success(DeliveryAck.of(event, DeliveryAck.Outcome.APPLIED));
|
||||
when(finance.wallet("token", event)).thenReturn(ack);
|
||||
assertThat(client.receive("token", event)).isSameAs(ack);
|
||||
verify(finance).wallet("token", event);
|
||||
verifyNoMoreInteractions(finance);
|
||||
verifyNoInteractions(firstleg);
|
||||
}
|
||||
|
||||
@Test void feeIsSentOnlyToFinance() {
|
||||
var event = event(EventTypes.BUSINESS_FEE_SOURCE_CHANGED);
|
||||
client.receive("token", event);
|
||||
verify(finance).fee("token", event);
|
||||
verifyNoMoreInteractions(finance);
|
||||
verifyNoInteractions(firstleg);
|
||||
}
|
||||
|
||||
@Test void fulfillmentEventsAreSentOnlyToFirstleg() {
|
||||
for (String type : new String[]{EventTypes.OVERSEAS_CONTAINER_UNLOADED,
|
||||
EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED, EventTypes.OVERSEAS_DELIVERY_COMPLETED}) {
|
||||
var event = event(type);
|
||||
client.receive("token", event);
|
||||
verify(firstleg).receive("token", event);
|
||||
}
|
||||
verifyNoInteractions(finance);
|
||||
}
|
||||
|
||||
@Test void unsupportedEventsHaveNoDefaultDestination() {
|
||||
assertThatThrownBy(() -> client.receive("token", event(EventTypes.WALLET_COMMAND_COMPLETED)))
|
||||
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("Unsupported outbound");
|
||||
verifyNoInteractions(firstleg, finance);
|
||||
}
|
||||
|
||||
@Test void declaredEndpointsMatchTheSharedRouteTable() throws Exception {
|
||||
assertEndpoint(FirstlegEventClient.class, "receive", "FIRSTLEG_SERVICE_URL", "firstleg",
|
||||
EventTypes.OVERSEAS_CONTAINER_UNLOADED);
|
||||
assertEndpoint(FinanceBusinessEventClient.class, "wallet", "FINANCE_SERVICE_URL", "finance",
|
||||
EventTypes.WALLET_COMMAND_REQUESTED);
|
||||
assertEndpoint(FinanceBusinessEventClient.class, "fee", "FINANCE_SERVICE_URL", "finance",
|
||||
EventTypes.BUSINESS_FEE_SOURCE_CHANGED);
|
||||
}
|
||||
|
||||
private void assertEndpoint(Class<?> type, String method, String env, String property, String eventType) throws Exception {
|
||||
var route = EventDeliveryRoute.forEventType(eventType);
|
||||
var annotation = type.getAnnotation(FeignClient.class);
|
||||
assertThat(annotation.name()).isEqualTo(route.serviceName());
|
||||
assertThat(annotation.url()).isEqualTo("${" + env + ":${baiyue.clients." + property + ".url:}}");
|
||||
assertThat(type.getMethod(method, String.class, EventEnvelope.class).getAnnotation(PostMapping.class).value())
|
||||
.containsExactly(route.path());
|
||||
}
|
||||
|
||||
private EventEnvelope event(String type) {
|
||||
var payload = new ObjectMapper().createObjectNode().put("businessNo", "test");
|
||||
return new EventEnvelope("00000000-0000-4000-8000-000000000009", type, 1, Instant.now(),
|
||||
RegionCode.OVERSEAS, RegionCode.CN, "1", "BUSINESS", "1", 1L, null,
|
||||
"trace", "correlation", null, payload,
|
||||
EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)));
|
||||
}
|
||||
}
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
package com.baiyue.lastleg.client;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class EventBridgeClientContractTest {
|
||||
@Test
|
||||
void canonicalDomesticBridgeEnvironmentOverridesLegacyNacosProperty() {
|
||||
FeignClient contract = EventBridgeClient.class.getAnnotation(FeignClient.class);
|
||||
|
||||
assertThat(contract.url())
|
||||
.isEqualTo("${CN_EVENT_BRIDGE_URL:${baiyue.firstleg.event-bridge-url:}}");
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package com.baiyue.lastleg.financefee;
|
||||
|
||||
import com.baiyue.lastleg.client.DirectEventClient;
|
||||
import com.baiyue.lastleg.entity.LastlegEventOutbox;
|
||||
import com.baiyue.lastleg.financefee.repository.OverseasFeeOutboxRepository;
|
||||
import com.baiyue.lastleg.financefee.service.OverseasFeeOutboxRelay;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import java.util.List;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class OverseasFeeOutboxRetryTest {
|
||||
@Test void failedPayloadAtLimitGoesDeadAndExhaustedLeaseNeverCallsClient() {
|
||||
var repository = mock(OverseasFeeOutboxRepository.class);
|
||||
var client = mock(DirectEventClient.class);
|
||||
var relay = new OverseasFeeOutboxRelay();
|
||||
ReflectionTestUtils.setField(relay, "repository", repository);
|
||||
ReflectionTestUtils.setField(relay, "client", client);
|
||||
ReflectionTestUtils.setField(relay, "objectMapper", new ObjectMapper());
|
||||
var row = new LastlegEventOutbox();
|
||||
row.setId(1L); row.setRetryCount(11); row.setPayloadJson("invalid-json");
|
||||
when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row));
|
||||
when(repository.claim(eq(1L), anyString(), any(), any())).thenReturn(true);
|
||||
when(repository.claimed(eq(1L), anyString())).thenReturn(row);
|
||||
relay.relay();
|
||||
row.setRetryCount(12);
|
||||
relay.relay();
|
||||
verify(repository, times(2)).dead(eq(1L), anyString(), eq(12), anyString());
|
||||
verify(repository, never()).retry(anyLong(), anyString(), anyInt(), any(), anyString());
|
||||
verify(repository, never()).published(anyLong(), anyString(), any());
|
||||
verifyNoInteractions(client);
|
||||
}
|
||||
}
|
||||
+55
-12
@@ -2,12 +2,13 @@ package com.baiyue.lastleg.inboundplan.pda.service;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
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;
|
||||
import com.baiyue.common.event.OverseasContainerUnloadedV1;
|
||||
import com.baiyue.common.event.RegionCode;
|
||||
import com.baiyue.lastleg.client.EventBridgeClient;
|
||||
import com.baiyue.lastleg.client.DirectEventClient;
|
||||
import com.baiyue.lastleg.entity.LastlegEventOutbox;
|
||||
import com.baiyue.lastleg.repository.UnloadCompletedOutboxRepository;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@@ -30,7 +31,7 @@ import com.baiyue.common.core.api.ResultCode;
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class InboundUnloadCompletedOutboxRelayTest {
|
||||
@Mock private UnloadCompletedOutboxRepository repository;
|
||||
@Mock private EventBridgeClient client;
|
||||
@Mock private DirectEventClient client;
|
||||
private ObjectMapper objectMapper;
|
||||
private InboundUnloadCompletedOutboxRelay relay;
|
||||
|
||||
@@ -43,19 +44,18 @@ class InboundUnloadCompletedOutboxRelayTest {
|
||||
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 marksOutboxPublishedAfterDomesticBridgeAcknowledgesSameEvent() throws Exception {
|
||||
void marksOutboxPublishedAfterConsumerAcknowledgesSameEvent() throws Exception {
|
||||
EventEnvelope event = event();
|
||||
LastlegEventOutbox 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")));
|
||||
.thenReturn(ApiResponse.success(DeliveryAck.of(event, DeliveryAck.Outcome.APPLIED)));
|
||||
|
||||
relay.relay();
|
||||
|
||||
@@ -64,23 +64,23 @@ class InboundUnloadCompletedOutboxRelayTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void releasesLeaseForRetryWhenBridgeDeliveryFails() throws Exception {
|
||||
void releasesLeaseForRetryWhenConsumerDeliveryFails() throws Exception {
|
||||
EventEnvelope event = event();
|
||||
LastlegEventOutbox 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)))
|
||||
.thenThrow(new IllegalStateException("bridge unavailable"));
|
||||
.thenThrow(new IllegalStateException("consumer unavailable"));
|
||||
|
||||
relay.relay();
|
||||
|
||||
verify(repository).retry(eq(9001L), anyString(), any(LocalDateTime.class), eq("bridge unavailable"));
|
||||
verify(repository).retry(eq(9001L), anyString(), any(LocalDateTime.class), eq("consumer unavailable"));
|
||||
verify(repository, never()).published(anyLong(), anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void preservesDomesticBridgeBusinessErrorForDeploymentDiagnosis() throws Exception {
|
||||
void preservesConsumerBusinessErrorForDeploymentDiagnosis() throws Exception {
|
||||
EventEnvelope event = event();
|
||||
LastlegEventOutbox row = row(event);
|
||||
when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row));
|
||||
@@ -88,15 +88,58 @@ class InboundUnloadCompletedOutboxRelayTest {
|
||||
when(repository.claimed(eq(9001L), anyString())).thenReturn(row);
|
||||
when(client.receive(eq("internal-token"), any(EventEnvelope.class)))
|
||||
.thenReturn(ApiResponse.failure(ResultCode.BAD_REQUEST,
|
||||
"事件目标区域与当前事件桥区域不一致", "request-bridge", "trace-bridge"));
|
||||
"消费事件类型不支持", "request-bridge", "trace-bridge"));
|
||||
|
||||
relay.relay();
|
||||
|
||||
verify(repository).retry(eq(9001L), anyString(), any(LocalDateTime.class),
|
||||
contains("事件目标区域与当前事件桥区域不一致"));
|
||||
contains("消费事件类型不支持"));
|
||||
verify(repository, never()).published(anyLong(), anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void mismatchedHashOrUnallowedStaleCannotMarkPublished() throws Exception {
|
||||
EventEnvelope event = event();
|
||||
LastlegEventOutbox 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 DeliveryAck(event.eventId(), "sha256:" + "0".repeat(64), DeliveryAck.Outcome.APPLIED)))
|
||||
.thenReturn(ApiResponse.success(DeliveryAck.of(event, DeliveryAck.Outcome.STALE)));
|
||||
relay.relay();
|
||||
relay.relay();
|
||||
verify(repository, times(2)).retry(eq(9001L), anyString(), any(), anyString());
|
||||
verify(repository, never()).published(anyLong(), anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void twelfthFailureGoesDeadInsteadOfSchedulingAnotherRetry() throws Exception {
|
||||
LastlegEventOutbox 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 {
|
||||
LastlegEventOutbox 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 LastlegEventOutbox row(EventEnvelope event) throws Exception {
|
||||
LastlegEventOutbox row = new LastlegEventOutbox();
|
||||
row.setId(9001L);
|
||||
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package com.baiyue.lastleg.repository;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import com.baiyue.common.event.EventTypes;
|
||||
import com.baiyue.lastleg.entity.LastlegEventOutbox;
|
||||
import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper;
|
||||
import com.baiyue.lastleg.financefee.repository.OverseasFeeOutboxRepository;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class OutboxLeaseDatabaseTest {
|
||||
@Test void recoveredLeasesCountFailuresAndOldOwnersCannotChangeNewState() throws Exception {
|
||||
var dataSource = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID()
|
||||
+ ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
|
||||
var jdbc = new JdbcTemplate(dataSource);
|
||||
jdbc.execute("CREATE TABLE ll_event_outbox(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),aggregate_type VARCHAR(64),aggregate_id VARCHAR(64),event_type VARCHAR(64),event_version INT,event_key VARCHAR(160),payload_json TEXT,status VARCHAR(32),retry_count INT NOT NULL DEFAULT 0,next_retry_at TIMESTAMP,published_at TIMESTAMP,locked_at TIMESTAMP,locked_by VARCHAR(128),last_error VARCHAR(1000),created_at TIMESTAMP)");
|
||||
var configuration = new MybatisConfiguration();
|
||||
configuration.addMapper(LastlegEventOutboxMapper.class);
|
||||
var factory = new MybatisSqlSessionFactoryBean();
|
||||
factory.setDataSource(dataSource);
|
||||
factory.setConfiguration(configuration);
|
||||
try (var session = factory.getObject().openSession(true)) {
|
||||
var mapper = session.getMapper(LastlegEventOutboxMapper.class);
|
||||
var unload = new UnloadCompletedOutboxRepository();
|
||||
ReflectionTestUtils.setField(unload, "mapper", mapper);
|
||||
var fee = new OverseasFeeOutboxRepository(mapper);
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
for (long id : new long[]{1, 2}) {
|
||||
jdbc.update("INSERT INTO ll_event_outbox(id,event_type,status,retry_count,next_retry_at,created_at) VALUES(?,?,'PENDING',0,?,?)",
|
||||
id, id == 1 ? EventTypes.OVERSEAS_CONTAINER_UNLOADED : EventTypes.BUSINESS_FEE_SOURCE_CHANGED,
|
||||
now.minusSeconds(1), now.minusMinutes(2));
|
||||
boolean first = id == 1 ? unload.claim(id, "old", now, now.minusSeconds(60)) : fee.claim(id, "old", now, now.minusSeconds(60));
|
||||
assertThat(first).isTrue();
|
||||
assertThat(jdbc.queryForObject("SELECT retry_count FROM ll_event_outbox WHERE id=?", Integer.class, id)).isZero();
|
||||
jdbc.update("UPDATE ll_event_outbox SET locked_at=? WHERE id=?", now.minusSeconds(61), id);
|
||||
boolean second = id == 1 ? unload.claim(id, "new", now, now.minusSeconds(60)) : fee.claim(id, "new", now, now.minusSeconds(60));
|
||||
assertThat(second).isTrue();
|
||||
assertThat(jdbc.queryForObject("SELECT retry_count FROM ll_event_outbox WHERE id=?", Integer.class, id)).isEqualTo(1);
|
||||
if (id == 1) unload.dead(id, "old", 12, "late failure"); else fee.dead(id, "old", 12, "late failure");
|
||||
assertThat(jdbc.queryForObject("SELECT status FROM ll_event_outbox WHERE id=?", String.class, id)).isEqualTo("PROCESSING");
|
||||
if (id == 1) unload.dead(id, "new", 12, "exhausted"); else fee.dead(id, "new", 12, "exhausted");
|
||||
assertThat(jdbc.queryForObject("SELECT status FROM ll_event_outbox WHERE id=?", String.class, id)).isEqualTo("DEAD");
|
||||
boolean third = id == 1 ? unload.claim(id, "third", now, now.minusSeconds(60)) : fee.claim(id, "third", now, now.minusSeconds(60));
|
||||
assertThat(third).isFalse();
|
||||
}
|
||||
jdbc.update("UPDATE ll_event_outbox SET status='PENDING',next_retry_at=? WHERE id=2", now.plusMinutes(5));
|
||||
assertThat(fee.claim(2L, "early", now, now.minusSeconds(60))).isFalse();
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
@@ -18,6 +18,19 @@ import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class UnloadCompletedOutboxRepositoryTest {
|
||||
@Test
|
||||
@SuppressWarnings({"rawtypes", "unchecked"})
|
||||
void feeClaimAlsoRechecksRetryWindow() {
|
||||
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "test"), LastlegEventOutbox.class);
|
||||
LastlegEventOutboxMapper mapper = mock(LastlegEventOutboxMapper.class);
|
||||
var repository = new com.baiyue.lastleg.financefee.repository.OverseasFeeOutboxRepository(mapper);
|
||||
ArgumentCaptor<LambdaUpdateWrapper> wrapper = ArgumentCaptor.forClass(LambdaUpdateWrapper.class);
|
||||
when(mapper.update(isNull(LastlegEventOutbox.class), wrapper.capture())).thenReturn(1);
|
||||
LocalDateTime now = LocalDateTime.of(2026, 9, 29, 12, 0);
|
||||
assertThat(repository.claim(1L, "attempt", now, now.minusMinutes(1))).isTrue();
|
||||
assertThat(wrapper.getValue().getSqlSegment()).contains("next_retry_at", "<=", "status", "locked_at");
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings({"rawtypes", "unchecked"})
|
||||
void claimRechecksRetryWindowAfterCandidateSelection() {
|
||||
|
||||
+11
-11
@@ -5,8 +5,8 @@ class LoadingCompletedConsumerTest {
|
||||
private final Map<String,String> frozenContainerNos=new HashMap<>();
|
||||
@Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegInboundOrderMapper inboundOrders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock InboundPlanCartonMapper cartons; @Mock DropshipOrderService dropshipOrders; @Mock InventoryCommandService inventory; @Mock com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper shippingGates; @Mock com.baiyue.lastleg.inboundplan.pda.mapper.ContainerLoadingEvidenceMapper loadingEvidence; @Mock LastlegOrderNoDailySequenceMapper sequences; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service;
|
||||
@BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"inboundOrderMapper",inboundOrders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inventory",inventory); ReflectionTestUtils.setField(service,"shippingGates",shippingGates); ReflectionTestUtils.setField(service,"loadingEvidence",loadingEvidence); ReflectionTestUtils.setField(service,"objectMapper",json); LastlegOrderNoGenerator generator=new LastlegOrderNoGenerator(); ReflectionTestUtils.setField(generator,"sequenceMapper",sequences); ReflectionTestUtils.setField(service,"orderNoGenerator",generator); lenient().when(sequences.currentValue()).thenReturn(1L); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); lenient().when(orders.lockContainerNumbers(anyString())).thenReturn(List.of()); lenient().when(shippingGates.initialize(anyString(),anyString(),anyString(),anyLong(),anyLong())).thenAnswer(i->{ String key=i.getArgument(0); String value=i.getArgument(2); return frozenContainerNos.putIfAbsent(key,value)==null?1:0; }); lenient().when(shippingGates.lock(anyString())).thenAnswer(i->{ String key=i.getArgument(0); String value=frozenContainerNos.get(key); if(value==null)return null; var gate=new com.baiyue.lastleg.shipping.entity.ContainerShippingGate(); gate.setContainerBookingId(key); gate.setContainerNo(value); return gate; }); }
|
||||
@Test void createsPendingOrdersAndInTransitSnapshotsWithoutInboundPlans(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); assertThrows(NoSuchFieldException.class,()->LoadingCompletedConsumer.class.getDeclaredField("inboundPlans")); verify(orders,times(2)).insert(any(LastlegOrder.class)); ArgumentCaptor<InventoryCommandRequests.InTransit> inTransit=ArgumentCaptor.forClass(InventoryCommandRequests.InTransit.class); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),inTransit.capture()); assertEquals(Set.of("RK1","RK2"),inTransit.getAllValues().stream().map(InventoryCommandRequests.InTransit::inboundOrderNo).collect(java.util.stream.Collectors.toSet())); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor<LastlegOrder> c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getInboundOrderNo()==null)); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU6639870".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"FBA".equals(o.getDestinationType()))); assertTrue(c.getAllValues().stream().allMatch(o->"Customer 100".equals(o.getCustomerName()))); assertTrue(c.getAllValues().stream().allMatch(o->"ow-1".equals(o.getSourceWarehouseId())&&"193000000000200003".equals(o.getOverseasWarehouseId())&&"US-LAX-01".equals(o.getOverseasWarehouseCode())&&"洛杉矶仓".equals(o.getOverseasWarehouseName())&&Integer.valueOf(0).equals(o.getReceivingWarehouseVersion()))); ArgumentCaptor<LastlegInboundOrder> snapshots=ArgumentCaptor.forClass(LastlegInboundOrder.class); verify(inboundOrders,times(2)).insert(snapshots.capture()); assertTrue(snapshots.getAllValues().stream().allMatch(o->"100".equals(o.getCustomerId())&&"FBA".equals(o.getDestinationType()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(c.getAllValues().get(0).getOrderNo().matches("FL-WH-[0-9]{8}-[0-9]{6}")); }
|
||||
@Test void oneContainerWith15InboundRowsSharesOneStableContainerOrderNumber(){ EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); com.fasterxml.jackson.databind.node.ArrayNode rows=json.createArrayNode(); for(int index=1;index<=15;index++){ com.fasterxml.jackson.databind.node.ObjectNode row=node.path("orders").get(0).deepCopy(); row.put("sourceInboundId","in-"+index).put("rootInboundId","root-"+index).put("sourceOrderId","order-"+index).put("sourceOrderNo","FL-"+index).put("rootOrderId","root-order-"+index).put("inboundCode","RK-"+index); rows.add(row); } node.set("orders",rows); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope fifteen=new EventEnvelope("123e4567-e89b-12d3-a456-426614174015",source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); assertEquals(15,service.consume(fifteen)); ArgumentCaptor<LastlegOrder> captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(15)).insert(captured.capture()); assertEquals(1,captured.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(captured.getAllValues().get(0).getOrderNo().matches("^FL-WH-\\d{8}-\\d{6}$")); verify(sequences,never()).nextValue(any(java.time.LocalDate.class)); }
|
||||
@Test void createsPendingOrdersAndInTransitSnapshotsWithoutInboundPlans(){ EventEnvelope event=event(); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(event).outcome()); assertThrows(NoSuchFieldException.class,()->LoadingCompletedConsumer.class.getDeclaredField("inboundPlans")); verify(orders,times(2)).insert(any(LastlegOrder.class)); ArgumentCaptor<InventoryCommandRequests.InTransit> inTransit=ArgumentCaptor.forClass(InventoryCommandRequests.InTransit.class); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),inTransit.capture()); assertEquals(Set.of("RK1","RK2"),inTransit.getAllValues().stream().map(InventoryCommandRequests.InTransit::inboundOrderNo).collect(java.util.stream.Collectors.toSet())); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor<LastlegOrder> c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getInboundOrderNo()==null)); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU6639870".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"FBA".equals(o.getDestinationType()))); assertTrue(c.getAllValues().stream().allMatch(o->"Customer 100".equals(o.getCustomerName()))); assertTrue(c.getAllValues().stream().allMatch(o->"ow-1".equals(o.getSourceWarehouseId())&&"193000000000200003".equals(o.getOverseasWarehouseId())&&"US-LAX-01".equals(o.getOverseasWarehouseCode())&&"洛杉矶仓".equals(o.getOverseasWarehouseName())&&Integer.valueOf(0).equals(o.getReceivingWarehouseVersion()))); ArgumentCaptor<LastlegInboundOrder> snapshots=ArgumentCaptor.forClass(LastlegInboundOrder.class); verify(inboundOrders,times(2)).insert(snapshots.capture()); assertTrue(snapshots.getAllValues().stream().allMatch(o->"100".equals(o.getCustomerId())&&"FBA".equals(o.getDestinationType()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(c.getAllValues().get(0).getOrderNo().matches("FL-WH-[0-9]{8}-[0-9]{6}")); }
|
||||
@Test void oneContainerWith15InboundRowsSharesOneStableContainerOrderNumber(){ EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); com.fasterxml.jackson.databind.node.ArrayNode rows=json.createArrayNode(); for(int index=1;index<=15;index++){ com.fasterxml.jackson.databind.node.ObjectNode row=node.path("orders").get(0).deepCopy(); row.put("sourceInboundId","in-"+index).put("rootInboundId","root-"+index).put("sourceOrderId","order-"+index).put("sourceOrderNo","FL-"+index).put("rootOrderId","root-order-"+index).put("inboundCode","RK-"+index); rows.add(row); } node.set("orders",rows); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope fifteen=new EventEnvelope("123e4567-e89b-12d3-a456-426614174015",source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(fifteen).outcome()); ArgumentCaptor<LastlegOrder> captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(15)).insert(captured.capture()); assertEquals(1,captured.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(captured.getAllValues().get(0).getOrderNo().matches("^FL-WH-\\d{8}-\\d{6}$")); verify(sequences,never()).nextValue(any(java.time.LocalDate.class)); }
|
||||
@Test void rejectsMissingOwnerSnapshotBeforeAnyLastlegWrite(){ EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node.path("orders").get(0)).put("customerName"," "); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope missing=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(missing)); assertTrue(error.getMessage().contains("尾程事件负载无效")||error.getMessage().contains("货主快照")); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); }
|
||||
@Test void freezesFormalPlannedWarehouseArrivalFromLoadingEvent(){ EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node).put("estimatedWarehouseArrivalAt","2026-10-03T09:00"); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope changed=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); service.consume(changed); ArgumentCaptor<LastlegOrder> captor=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(captor.capture()); assertTrue(captor.getAllValues().stream().allMatch(order->java.time.LocalDateTime.of(2026,10,3,9,0).equals(order.getEstimatedWarehouseAt()))); }
|
||||
@Test void loadingCreatesAwaitingPickupReadyGateWithoutPollutingShippingVersion(){ service.consume(event()); verify(shippingGates).initialize("book-1","","MSCU6639870",0L,1L); }
|
||||
@@ -14,8 +14,8 @@ class LoadingCompletedConsumerTest {
|
||||
@Test void rejectsBlankActualContainerNumberEvenWhenCabinetNumberExists(){ EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node).put("containerNo"," ").put("cabinetNo","CABINET-ONLY"); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope changed=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); assertThrows(IllegalArgumentException.class,()->service.consume(changed)); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); }
|
||||
@Test void rejectsDifferentContainerNumberForSameSourceBookingBeforeAnyBusinessWrite(){ EventEnvelope source=event(); frozenContainerNos.put("book-1","CSQU3054383"); IllegalStateException error=assertThrows(IllegalStateException.class,()->service.consume(source)); assertTrue(error.getMessage().contains("sourceContainerBookingId")); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); }
|
||||
@Test void rejectsExistingOrderContainerDriftForSameSourceBooking(){ when(orders.lockContainerNumbers("book-1")).thenReturn(List.of("MSCU6639870","CSQU3054383")); assertThrows(IllegalStateException.class,()->service.consume(event())); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); }
|
||||
@Test void acceptsSeparateCustomerEventsForTheSameFrozenContainerIdentity(){ EventEnvelope first=event(); assertEquals(2,service.consume(first)); com.fasterxml.jackson.databind.node.ObjectNode node=first.payload().deepCopy(); int index=0; for(JsonNode order:node.path("orders")){ ((com.fasterxml.jackson.databind.node.ObjectNode)order).put("customerId","200").put("customerName","Customer 200").put("sourceInboundId","customer-200-in-"+(++index)); } String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope second=new EventEnvelope("123e4567-e89b-12d3-a456-426614174200",first.eventType(),first.eventVersion(),first.occurredAt(),first.sourceRegion(),first.targetRegion(),"200",first.aggregateType(),first.aggregateId(),first.aggregateVersion(),first.handoverVersion(),first.traceId(),first.correlationId(),first.causationId(),node,hash); assertEquals(2,service.consume(second)); verify(shippingGates,times(2)).initialize(eq("book-1"),eq(""),eq("MSCU6639870"),eq(0L),eq(1L)); ArgumentCaptor<LastlegOrder> captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(4)).insert(captured.capture()); assertEquals(Set.of("100","200"),captured.getAllValues().stream().map(LastlegOrder::getCustomerId).collect(java.util.stream.Collectors.toSet())); assertTrue(captured.getAllValues().stream().allMatch(order->"MSCU6639870".equals(order.getContainerNo()))); }
|
||||
@Test void replayPreservesExistingLmoNumberAndAddsNothing(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash(event.payloadHash()); when(inbox.selectById(event.eventId())).thenReturn(row); when(orders.countContainer("100","MSCU6639870")).thenReturn(2L); assertEquals(2,service.consume(event)); verify(orders).countContainer("100","MSCU6639870"); verify(sequences,never()).nextValue(any(java.time.LocalDate.class)); verify(orders,never()).insert(any(LastlegOrder.class)); verify(details,never()).insert(any(LastlegOrderDetail.class)); verify(histories,never()).insert(any(LastlegOrderStatusHistory.class)); }
|
||||
@Test void acceptsSeparateCustomerEventsForTheSameFrozenContainerIdentity(){ EventEnvelope first=event(); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(first).outcome()); com.fasterxml.jackson.databind.node.ObjectNode node=first.payload().deepCopy(); int index=0; for(JsonNode order:node.path("orders")){ ((com.fasterxml.jackson.databind.node.ObjectNode)order).put("customerId","200").put("customerName","Customer 200").put("sourceInboundId","customer-200-in-"+(++index)); } String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope second=new EventEnvelope("123e4567-e89b-12d3-a456-426614174200",first.eventType(),first.eventVersion(),first.occurredAt(),first.sourceRegion(),first.targetRegion(),"200",first.aggregateType(),first.aggregateId(),first.aggregateVersion(),first.handoverVersion(),first.traceId(),first.correlationId(),first.causationId(),node,hash); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(second).outcome()); verify(shippingGates,times(2)).initialize(eq("book-1"),eq(""),eq("MSCU6639870"),eq(0L),eq(1L)); ArgumentCaptor<LastlegOrder> captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(4)).insert(captured.capture()); assertEquals(Set.of("100","200"),captured.getAllValues().stream().map(LastlegOrder::getCustomerId).collect(java.util.stream.Collectors.toSet())); assertTrue(captured.getAllValues().stream().allMatch(order->"MSCU6639870".equals(order.getContainerNo()))); }
|
||||
@Test void replayPreservesExistingLmoNumberAndAddsNothing(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash(event.payloadHash()); row.setStatus("PROCESSED"); when(inbox.selectById(event.eventId())).thenReturn(row); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.DUPLICATE, service.consume(event).outcome()); verify(sequences,never()).nextValue(any(java.time.LocalDate.class)); verify(orders,never()).insert(any(LastlegOrder.class)); verify(details,never()).insert(any(LastlegOrderDetail.class)); verify(histories,never()).insert(any(LastlegOrderStatusHistory.class)); }
|
||||
@Test void sameEventWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(row); assertThrows(IllegalArgumentException.class,()->service.consume(event)); }
|
||||
@Test void tamperedPayloadHashIsRejectedBeforeInboxMutation(){ EventEnvelope source=event(); EventEnvelope tampered=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),"sha256:"+"0".repeat(64)); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(tampered)); assertTrue(error.getMessage().contains("payloadHash")); verify(inbox,never()).insert(any(LastlegEventInbox.class)); }
|
||||
@Test void concurrentDuplicateKeyWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox raced=new LastlegEventInbox(); raced.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(null,raced); doThrow(new org.springframework.dao.DuplicateKeyException("race")).when(inbox).insert(any(LastlegEventInbox.class)); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(event)); assertTrue(error.getMessage().contains("摘要冲突")); verify(orders,never()).countContainer(anyString(),anyString()); }
|
||||
@@ -26,7 +26,7 @@ class LoadingCompletedConsumerTest {
|
||||
assertTrue(table.contains("customer_id")); assertFalse(table.contains("tenant_id"));
|
||||
}
|
||||
@Test void rejectsEventFromWrongRegion(){ EventEnvelope source=event(); EventEnvelope invalid=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),RegionCode.OVERSEAS,RegionCode.CN,source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),source.payloadHash()); assertThrows(IllegalArgumentException.class,()->service.consume(invalid)); }
|
||||
@Test void consumesFormalDropshipPalletSnapshots(){ var sku=new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","Product A",2,20,List.of("BOX-1","BOX-2")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(sku)); var item=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-A","Product A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,null,"MARK",null,List.of()); var order=new FirstLegLoadingPlanCompletedV1.Order("in-d","root-d","ord-d","FL-D","root-od","RK-D","wh-1","ONT8","ONT8",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(item),"100","Customer 100","ONE_CLICK_DROPSHIP",List.of(pallet),"FBA"); var payload=new FirstLegLoadingPlanCompletedV1("plan-d","LP-D","book-d","MSCU6639870","CAB-D","92","Warehouse",List.of(order)); JsonNode node=json.valueToTree(payload); addManifest(node.path("orders").get(0).path("pallets").get(0),2); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); var event=new EventEnvelope("123e4567-e89b-12d3-a456-426614174010",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-d",2,1L,"123e4567-e89b-12d3-a456-426614174011","FIRSTLEG_LOADING_COMPLETED:plan-d:100:1",null,node,hash); assertEquals(1,service.consume(event)); ArgumentCaptor<DropshipRequests.Receive> captor=ArgumentCaptor.forClass(DropshipRequests.Receive.class); verify(dropshipOrders).receive(captor.capture()); verify(inventory,never()).registerInTransit(anyString(),anyString(),any()); assertEquals("100",captor.getValue().customerId()); assertEquals("123e4567-e89b-12d3-a456-426614174010:DROPSHIP:in-d",captor.getValue().idempotencyKey()); assertEquals(1,captor.getValue().pallets().size()); assertEquals("PLT-1",captor.getValue().pallets().get(0).palletNo()); assertEquals(List.of("BOX-1","BOX-2"),captor.getValue().pallets().get(0).skuPackages().get(0).cartonMarks()); }
|
||||
@Test void consumesFormalDropshipPalletSnapshots(){ var sku=new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","Product A",2,20,List.of("BOX-1","BOX-2")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(sku)); var item=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-A","Product A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,null,"MARK",null,List.of()); var order=new FirstLegLoadingPlanCompletedV1.Order("in-d","root-d","ord-d","FL-D","root-od","RK-D","wh-1","ONT8","ONT8",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(item),"100","Customer 100","ONE_CLICK_DROPSHIP",List.of(pallet),"FBA"); var payload=new FirstLegLoadingPlanCompletedV1("plan-d","LP-D","book-d","MSCU6639870","CAB-D","92","Warehouse",List.of(order)); JsonNode node=json.valueToTree(payload); addManifest(node.path("orders").get(0).path("pallets").get(0),2); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); var event=new EventEnvelope("123e4567-e89b-12d3-a456-426614174010",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-d",2,1L,"123e4567-e89b-12d3-a456-426614174011","FIRSTLEG_LOADING_COMPLETED:plan-d:100:1",null,node,hash); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(event).outcome()); ArgumentCaptor<DropshipRequests.Receive> captor=ArgumentCaptor.forClass(DropshipRequests.Receive.class); verify(dropshipOrders).receive(captor.capture()); verify(inventory,never()).registerInTransit(anyString(),anyString(),any()); assertEquals("100",captor.getValue().customerId()); assertEquals("123e4567-e89b-12d3-a456-426614174010:DROPSHIP:in-d",captor.getValue().idempotencyKey()); assertEquals(1,captor.getValue().pallets().size()); assertEquals("PLT-1",captor.getValue().pallets().get(0).palletNo()); assertEquals(List.of("BOX-1","BOX-2"),captor.getValue().pallets().get(0).skuPackages().get(0).cartonMarks()); }
|
||||
@Test void freezesReceivingWarehouseInInitialInTransitInventory(){
|
||||
service.consume(event());
|
||||
ArgumentCaptor<InventoryCommandRequests.InTransit> captured=ArgumentCaptor.forClass(InventoryCommandRequests.InTransit.class);
|
||||
@@ -42,7 +42,7 @@ class LoadingCompletedConsumerTest {
|
||||
var order=new FirstLegLoadingPlanCompletedV1.Order("in-multi","root-multi","ord-multi","FL-MULTI","root-ord-multi","RK-MULTI","wh-1","ONT8","ONT8",2,30,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(itemA,itemB),"100","Customer 100","ONE_CLICK_DROPSHIP",List.of(pallet),"FBA");
|
||||
EventEnvelope event=eventForPayload(new FirstLegLoadingPlanCompletedV1("plan-multi","LP-MULTI","book-multi","MSCU6639870","CAB-MULTI","92","Warehouse",List.of(order)),2);
|
||||
|
||||
assertEquals(1,service.consume(event));
|
||||
assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(event).outcome());
|
||||
verify(orders,times(1)).insert(any(LastlegOrder.class));
|
||||
ArgumentCaptor<LastlegOrderDetail> detailCaptor=ArgumentCaptor.forClass(LastlegOrderDetail.class);
|
||||
verify(details,times(2)).insert(detailCaptor.capture());
|
||||
@@ -67,7 +67,7 @@ class LoadingCompletedConsumerTest {
|
||||
EventEnvelope v2=new EventEnvelope(source.eventId(),source.eventType(),2,source.occurredAt(),source.sourceRegion(),
|
||||
source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),
|
||||
2L,source.traceId(),source.correlationId(),source.causationId(),node,hash);
|
||||
assertEquals(2,service.consume(v2));
|
||||
assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(v2).outcome());
|
||||
ArgumentCaptor<LastlegOrder> orderCaptor=ArgumentCaptor.forClass(LastlegOrder.class);
|
||||
verify(orders,times(2)).insert(orderCaptor.capture());
|
||||
assertTrue(orderCaptor.getAllValues().stream().allMatch(order->order.getFbaWarehouseCode()==null));
|
||||
@@ -100,7 +100,7 @@ class LoadingCompletedConsumerTest {
|
||||
EventEnvelope v2=new EventEnvelope(source.eventId(),source.eventType(),2,source.occurredAt(),source.sourceRegion(),
|
||||
source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),
|
||||
2L,source.traceId(),source.correlationId(),source.causationId(),node,hash);
|
||||
assertEquals(2,service.consume(v2));
|
||||
assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(v2).outcome());
|
||||
verify(cartons,never()).insert(any(InboundPlanCarton.class));
|
||||
}
|
||||
@Test void acceptsV2FbaByPackageCountWithoutCartonFacts(){
|
||||
@@ -111,7 +111,7 @@ class LoadingCompletedConsumerTest {
|
||||
EventEnvelope v2=new EventEnvelope(source.eventId(),source.eventType(),2,source.occurredAt(),source.sourceRegion(),
|
||||
source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),
|
||||
2L,source.traceId(),source.correlationId(),source.causationId(),node,hash);
|
||||
assertEquals(2,service.consume(v2));
|
||||
assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(v2).outcome());
|
||||
verify(cartons,never()).insert(any(InboundPlanCarton.class));
|
||||
}
|
||||
@Test void safelyInfersLegacyOverseasDestinationFromReceivingWarehouseIdentity(){
|
||||
@@ -125,7 +125,7 @@ class LoadingCompletedConsumerTest {
|
||||
((com.fasterxml.jackson.databind.node.ObjectNode)node.path("orders").get(1).path("items").get(0)).set("cartonMarks",json.valueToTree(List.of("BOX-6","BOX-7","BOX-8")));
|
||||
String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node));
|
||||
EventEnvelope v2=new EventEnvelope(source.eventId(),source.eventType(),2,source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),2L,source.traceId(),source.correlationId(),source.causationId(),node,hash);
|
||||
assertEquals(2,service.consume(v2));
|
||||
assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(v2).outcome());
|
||||
ArgumentCaptor<LastlegOrder> captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(captured.capture());
|
||||
assertTrue(captured.getAllValues().stream().allMatch(order->"OVERSEAS".equals(order.getDestinationType())));
|
||||
verify(cartons,times(8)).insert(any(InboundPlanCarton.class));
|
||||
@@ -248,7 +248,7 @@ class LoadingCompletedConsumerTest {
|
||||
for(String type:List.of("PHOTO_STEP_1","PHOTO_STEP_2","PHOTO_STEP_3","PHOTO_STEP_4","SEAL")) values.addObject().put("evidenceType",type).put("fileId",fileId++).put("fileName",type+".jpg");
|
||||
String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node));
|
||||
EventEnvelope event=new EventEnvelope(source.eventId(),source.eventType(),3,source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),3L,source.traceId(),source.correlationId(),source.causationId(),node,hash);
|
||||
assertEquals(2,service.consume(event));
|
||||
assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(event).outcome());
|
||||
ArgumentCaptor<com.baiyue.lastleg.inboundplan.pda.entity.ContainerLoadingEvidence> captured=ArgumentCaptor.forClass(com.baiyue.lastleg.inboundplan.pda.entity.ContainerLoadingEvidence.class);
|
||||
verify(loadingEvidence,times(5)).insert(captured.capture());
|
||||
assertEquals(Set.of("PHOTO_STEP_1","PHOTO_STEP_2","PHOTO_STEP_3","PHOTO_STEP_4","SEAL"),captured.getAllValues().stream().map(com.baiyue.lastleg.inboundplan.pda.entity.ContainerLoadingEvidence::getEvidenceType).collect(java.util.stream.Collectors.toSet()));
|
||||
|
||||
+1
-1
@@ -96,7 +96,7 @@ class TaillegRealConsumerAcceptanceTest {
|
||||
String envelopeJson = jdbc.queryForObject(
|
||||
"SELECT payload_json FROM baiyue_firstleg.fl_inbound_outbox WHERE event_key=?", String.class, EVENT_KEY);
|
||||
EventEnvelope envelope = json.readValue(envelopeJson, EventEnvelope.class);
|
||||
assertEquals(2, consumer.consume(envelope));
|
||||
assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, consumer.consume(envelope).outcome());
|
||||
|
||||
Long sourceOrderId = jdbc.queryForObject("SELECT id FROM ll_order WHERE customer_id=? AND " +
|
||||
"source_loading_plan_id='990022022' AND order_type='FIRST_LEG'", Long.class, CUSTOMER);
|
||||
|
||||
+3
-3
@@ -50,7 +50,7 @@ class ContainerShippingNodeConsumerTest {
|
||||
EventEnvelope event = event("123e4567-e89b-12d3-a456-426614174101", "PICKUP_READY", 80, 5,
|
||||
Instant.parse("2026-08-21T10:00:00Z"));
|
||||
|
||||
assertThat(consumer.consume(event)).isEqualTo(1);
|
||||
assertThat(consumer.consume(event).outcome()).isEqualTo(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED);
|
||||
|
||||
assertThat(gate.getStatus()).isEqualTo("RECEIVING_AVAILABLE");
|
||||
assertThat(gate.getNodeSequence()).isEqualTo(80);
|
||||
@@ -86,9 +86,9 @@ class ContainerShippingNodeConsumerTest {
|
||||
void eventIdIsIdempotentByHashAndRejectsDifferentHash() {
|
||||
EventEnvelope event = event("123e4567-e89b-12d3-a456-426614174104", "PICKUP_READY", 80, 5,
|
||||
Instant.parse("2026-08-21T10:00:00Z"));
|
||||
LastlegEventInbox existing = new LastlegEventInbox(); existing.setPayloadHash(event.payloadHash());
|
||||
LastlegEventInbox existing = new LastlegEventInbox(); existing.setPayloadHash(event.payloadHash()); existing.setStatus("PROCESSED");
|
||||
when(inboxes.selectById(event.eventId())).thenReturn(existing);
|
||||
assertThat(consumer.consume(event)).isEqualTo(1);
|
||||
assertThat(consumer.consume(event).outcome()).isEqualTo(com.baiyue.common.event.DeliveryAck.Outcome.DUPLICATE);
|
||||
verify(gates, never()).updateById(any(ContainerShippingGate.class));
|
||||
|
||||
existing.setPayloadHash("sha256:" + "0".repeat(64));
|
||||
|
||||
Reference in New Issue
Block a user