feat: gate overseas receiving and harden phase4 workflows

This commit is contained in:
2026-08-25 10:31:04 +08:00
parent cb78bc6b41
commit 52b9fc382f
93 changed files with 2779 additions and 201 deletions
@@ -0,0 +1,118 @@
package com.baiyue.lastleg.shipping.service;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.EventPayloadCanonicalizer;
import com.baiyue.common.event.EventPayloadHash;
import com.baiyue.common.event.EventTypes;
import com.baiyue.common.event.FirstLegContainerShippingNodeRecordedV1;
import com.baiyue.common.event.RegionCode;
import com.baiyue.lastleg.entity.LastlegEventInbox;
import com.baiyue.lastleg.mapper.LastlegEventInboxMapper;
import com.baiyue.lastleg.shipping.entity.ContainerShippingGate;
import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class ContainerShippingNodeConsumerTest {
@Mock LastlegEventInboxMapper inboxes;
@Mock ContainerShippingGateMapper gates;
private final ObjectMapper json = new ObjectMapper().findAndRegisterModules();
private ContainerShippingNodeConsumer consumer;
@BeforeEach
void setUp() {
consumer = new ContainerShippingNodeConsumer();
ReflectionTestUtils.setField(consumer, "inboxes", inboxes);
ReflectionTestUtils.setField(consumer, "gates", gates);
ReflectionTestUtils.setField(consumer, "objectMapper", json);
}
@Test
void pickupReadyMonotonicallyOpensReceivingGate() {
ContainerShippingGate gate = gate("AWAITING_PICKUP_READY", 70, 4, Instant.parse("2026-08-20T10:00:00Z"));
when(gates.lock("CB-1")).thenReturn(gate);
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(gate.getStatus()).isEqualTo("RECEIVING_AVAILABLE");
assertThat(gate.getNodeSequence()).isEqualTo(80);
assertThat(gate.getAggregateVersion()).isEqualTo(5);
verify(gates).updateById(gate);
}
@Test
void lowerNodeAndOlderAggregateAreAuditedWithoutRegression() {
ContainerShippingGate gate = gate("RECEIVING_AVAILABLE", 90, 8, Instant.parse("2026-08-22T10:00:00Z"));
when(gates.lock("CB-1")).thenReturn(gate);
consumer.consume(event("123e4567-e89b-12d3-a456-426614174102", "PICKUP_READY", 80, 7,
Instant.parse("2026-08-23T10:00:00Z")));
assertThat(gate.getStatus()).isEqualTo("RECEIVING_AVAILABLE");
assertThat(gate.getNodeSequence()).isEqualTo(90);
assertThat(gate.getAggregateVersion()).isEqualTo(8);
}
@Test
void sameNodeOnlyAcceptsLaterActualTimeAndEmptyReturnedNeverClosesGate() {
ContainerShippingGate gate = gate("RECEIVING_AVAILABLE", 100, 9, Instant.parse("2026-08-22T10:00:00Z"));
when(gates.lock("CB-1")).thenReturn(gate);
consumer.consume(event("123e4567-e89b-12d3-a456-426614174103", "EMPTY_RETURNED", 100, 10,
Instant.parse("2026-08-22T12:00:00Z")));
assertThat(gate.getStatus()).isEqualTo("RECEIVING_AVAILABLE");
assertThat(gate.getActualAt()).isEqualTo(java.time.LocalDateTime.parse("2026-08-22T12:00:00"));
}
@Test
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());
when(inboxes.selectById(event.eventId())).thenReturn(existing);
assertThat(consumer.consume(event)).isEqualTo(1);
verify(gates, never()).updateById(any(ContainerShippingGate.class));
existing.setPayloadHash("sha256:" + "0".repeat(64));
assertThatThrownBy(() -> consumer.consume(event)).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("摘要冲突");
}
private ContainerShippingGate gate(String status, int sequence, long version, Instant actualAt) {
ContainerShippingGate gate = new ContainerShippingGate(); gate.setContainerBookingId("CB-1");
gate.setCustomerId("100"); gate.setOceanBookingId("OB-1"); gate.setContainerNo("CONT-1");
gate.setStatus(status); gate.setNodeSequence(sequence); gate.setAggregateVersion(version);
gate.setHandoverVersion(version + 1); gate.setActualAt(java.time.LocalDateTime.ofInstant(actualAt,
java.time.ZoneOffset.UTC)); return gate;
}
private EventEnvelope event(String eventId, String node, int sequence, long version, Instant actualAt) {
FirstLegContainerShippingNodeRecordedV1 payload = new FirstLegContainerShippingNodeRecordedV1(
"Q-1", "100", "OB-1", "CB-1", "CONT-1", node, sequence, actualAt,
actualAt, "LAX", "Los Angeles", "CARRIER", "PE-1", node, "REF-1", "a".repeat(64));
JsonNode payloadNode = json.valueToTree(payload);
String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode));
return new EventEnvelope(eventId, EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED, 1,
actualAt, RegionCode.CN, RegionCode.OVERSEAS, "100", "CONTAINER_BOOKING", "CB-1",
version, version + 1, "123e4567-e89b-12d3-a456-426614174199", "shipping:CB-1", null,
payloadNode, hash);
}
}