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,36 @@
package com.baiyue.lastleg.shipping.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_container_shipping_gate")
public class ContainerShippingGate {
@TableId(type = IdType.INPUT)
private String containerBookingId;
private String customerId;
private String oceanBookingId;
private String containerNo;
private String status;
private String standardNode;
private Integer nodeSequence;
private LocalDateTime observedAt;
private LocalDateTime actualAt;
private String locationCode;
private String locationName;
private String providerCode;
private String providerEventId;
private String providerNodeCode;
private String sourceReference;
private String rawPayloadHash;
private Long aggregateVersion;
private Long handoverVersion;
private String lastEventId;
private String payloadHash;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,29 @@
package com.baiyue.lastleg.shipping.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.shipping.entity.ContainerShippingGate;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface ContainerShippingGateMapper extends BaseMapper<ContainerShippingGate> {
@Select("SELECT * FROM ll_container_shipping_gate WHERE container_booking_id=#{containerBookingId} FOR UPDATE")
ContainerShippingGate lock(@Param("containerBookingId") String containerBookingId);
@Insert("INSERT IGNORE INTO ll_container_shipping_gate(container_booking_id,customer_id,ocean_booking_id," +
"container_no,status,node_sequence,aggregate_version,handover_version,created_at,updated_at) VALUES(" +
"#{containerBookingId},#{customerId},#{oceanBookingId},#{containerNo},'AWAITING_PICKUP_READY',0," +
"#{aggregateVersion},#{handoverVersion},NOW(3),NOW(3))")
int initialize(@Param("containerBookingId") String containerBookingId, @Param("customerId") String customerId,
@Param("oceanBookingId") String oceanBookingId, @Param("containerNo") String containerNo,
@Param("aggregateVersion") long aggregateVersion, @Param("handoverVersion") long handoverVersion);
@Select("SELECT CASE WHEN COUNT(DISTINCT o.source_container_booking_id)=1 " +
"AND MIN(CASE WHEN g.status='RECEIVING_AVAILABLE' THEN 1 ELSE 0 END)=1 THEN 1 ELSE 0 END " +
"FROM ll_order o LEFT JOIN ll_container_shipping_gate g " +
"ON g.container_booking_id=o.source_container_booking_id AND g.customer_id=o.customer_id " +
"WHERE o.customer_id=#{customerId} AND o.container_no=#{containerNo}")
int receivingAvailable(@Param("customerId") String customerId, @Param("containerNo") String containerNo);
}
@@ -0,0 +1,123 @@
package com.baiyue.lastleg.shipping.service;
import com.baiyue.common.event.EventEnvelope;
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.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
@Service
public class ContainerShippingNodeConsumer {
public static final int PICKUP_READY_SEQUENCE = 80;
@Resource private LastlegEventInboxMapper inboxes;
@Resource private ContainerShippingGateMapper gates;
@Resource private ObjectMapper objectMapper;
@Transactional(isolation = Isolation.READ_COMMITTED)
public int consume(EventEnvelope envelope) {
validate(envelope);
FirstLegContainerShippingNodeRecordedV1 payload = payload(envelope);
validateEnvelopePayload(envelope, payload);
LastlegEventInbox existing = inboxes.selectById(envelope.eventId());
if (existing != null) {
if (!existing.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) {
throw new IllegalArgumentException("船节点事件摘要冲突");
}
return 1;
}
LastlegEventInbox inbox = inbox(envelope);
try { inboxes.insert(inbox); }
catch (DuplicateKeyException raced) {
LastlegEventInbox concurrent = inboxes.selectById(envelope.eventId());
if (concurrent == null || !concurrent.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) {
throw new IllegalArgumentException("船节点事件摘要冲突", raced);
}
return 1;
}
gates.initialize(payload.containerBookingId(), payload.customerId(), payload.oceanBookingId(),
payload.containerNo(), 0, 1);
ContainerShippingGate gate = gates.lock(payload.containerBookingId());
if (gate == null || !payload.customerId().equals(gate.getCustomerId())
|| !payload.containerNo().equals(gate.getContainerNo())) {
throw new IllegalArgumentException("船节点柜号快照与装柜交接不一致");
}
apply(gate, envelope, payload);
gates.updateById(gate);
inbox.setStatus("PROCESSED"); inbox.setProcessedAt(LocalDateTime.now()); inboxes.updateById(inbox);
return 1;
}
private void apply(ContainerShippingGate gate, EventEnvelope envelope,
FirstLegContainerShippingNodeRecordedV1 payload) {
long currentVersion = gate.getAggregateVersion() == null ? 0 : gate.getAggregateVersion();
int currentSequence = gate.getNodeSequence() == null ? 0 : gate.getNodeSequence();
LocalDateTime actualAt = LocalDateTime.ofInstant(payload.actualAt(), ZoneOffset.UTC);
boolean newerVersion = envelope.aggregateVersion() >= currentVersion;
boolean newerNode = payload.nodeSequence() > currentSequence;
boolean laterSameNode = payload.nodeSequence() == currentSequence
&& (gate.getActualAt() == null || actualAt.isAfter(gate.getActualAt()));
boolean accepted = newerVersion && (newerNode || laterSameNode);
if (accepted) {
gate.setOceanBookingId(payload.oceanBookingId());
gate.setStandardNode(payload.standardNode()); gate.setNodeSequence(payload.nodeSequence());
gate.setObservedAt(LocalDateTime.ofInstant(payload.observedAt(), ZoneOffset.UTC)); gate.setActualAt(actualAt);
gate.setLocationCode(payload.locationCode()); gate.setLocationName(payload.locationName());
gate.setProviderCode(payload.providerCode()); gate.setProviderEventId(payload.providerEventId());
gate.setProviderNodeCode(payload.providerNodeCode()); gate.setSourceReference(payload.sourceReference());
gate.setRawPayloadHash(payload.rawPayloadHash()); gate.setLastEventId(envelope.eventId());
gate.setPayloadHash(envelope.payloadHash());
}
if (accepted && payload.nodeSequence() >= PICKUP_READY_SEQUENCE) {
gate.setStatus("RECEIVING_AVAILABLE");
}
gate.setAggregateVersion(Math.max(currentVersion, envelope.aggregateVersion()));
gate.setHandoverVersion(Math.max(gate.getHandoverVersion() == null ? 0 : gate.getHandoverVersion(),
envelope.handoverVersion()));
gate.setUpdatedAt(LocalDateTime.now());
}
private void validate(EventEnvelope envelope) {
envelope.requireValid();
if (!EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED.equals(envelope.eventType())
|| envelope.eventVersion() != 1 || envelope.sourceRegion() != RegionCode.CN
|| envelope.targetRegion() != RegionCode.OVERSEAS
|| !"CONTAINER_BOOKING".equals(envelope.aggregateType())
|| envelope.handoverVersion() == null
|| envelope.handoverVersion() != envelope.aggregateVersion() + 1) {
throw new IllegalArgumentException("船节点交接信封无效");
}
}
private void validateEnvelopePayload(EventEnvelope envelope, FirstLegContainerShippingNodeRecordedV1 payload) {
if (!envelope.tenantId().equals(payload.customerId())
|| !envelope.aggregateId().equals(payload.containerBookingId())) {
throw new IllegalArgumentException("船节点交接客户或聚合标识不一致");
}
}
private FirstLegContainerShippingNodeRecordedV1 payload(EventEnvelope envelope) {
try { return objectMapper.treeToValue(envelope.payload(), FirstLegContainerShippingNodeRecordedV1.class); }
catch (Exception exception) { throw new IllegalArgumentException("船节点交接负载无效", exception); }
}
private LastlegEventInbox inbox(EventEnvelope envelope) {
LastlegEventInbox value = new LastlegEventInbox(); value.setEventId(envelope.eventId());
value.setCustomerId(envelope.tenantId()); value.setEventType(envelope.eventType());
value.setEventVersion(envelope.eventVersion()); value.setPayloadHash(envelope.payloadHash());
value.setPayloadJson(envelope.payload().toString()); value.setStatus("RECEIVED");
value.setReceivedAt(LocalDateTime.now()); return value;
}
}