This commit is contained in:
2026-08-20 15:20:05 +08:00
parent 6c7a670f1e
commit b55b049e77
30 changed files with 727 additions and 12 deletions
@@ -0,0 +1,40 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.service.OverseasContainerEventConsumer;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
@RestController
@RequiredArgsConstructor
public class InternalOverseasContainerEventController {
private final OverseasContainerEventConsumer consumer;
@Value("${baiyue.security.internal-service-token}") private String expectedToken;
@PostMapping("/internal/firstleg/v1/container-events")
public ApiResponse<ContainerBookingEntity> receive(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestBody EventEnvelope event) {
authenticate(token);
return ApiResponse.success(consumer.consume(event));
}
private void authenticate(String token) {
if (token == null || expectedToken == null || expectedToken.isBlank()
|| !MessageDigest.isEqual(token.getBytes(StandardCharsets.UTF_8),
expectedToken.getBytes(StandardCharsets.UTF_8))) {
throw new BizException(ResultCode.FORBIDDEN, "内部服务令牌无效");
}
}
}
@@ -54,6 +54,11 @@ public class ContainerBookingEntity {
private LocalDateTime actualPortArrivalAt;
private LocalDateTime estimatedWarehouseArrivalAt;
private LocalDateTime actualWarehouseArrivalAt;
private LocalDateTime unloadingCompletedAt;
private LocalDateTime unloadingPhotoUploadedAt;
private LocalDateTime estimatedEmptyReportedAt;
private LocalDateTime actualEmptyReportedAt;
private Long emptyReportSourceVersion;
private Long createdBy;
private String createdByName;
@TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt;
@@ -0,0 +1,24 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter
@Setter
@TableName("fl_container_event_inbox")
public class ContainerEventInboxEntity {
@TableId private String eventId;
private String eventType;
private Integer eventVersion;
private String aggregateId;
private Long aggregateVersion;
private String payloadHash;
private String payloadJson;
private String status;
private LocalDateTime receivedAt;
private LocalDateTime processedAt;
}
@@ -107,6 +107,24 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
@Select("SELECT * FROM fl_container_booking WHERE id=#{id} AND record_status='ACTIVE' FOR UPDATE")
ContainerBookingEntity selectActiveBookingForUpdate(@Param("id") long id);
@Select("SELECT * FROM fl_container_booking WHERE container_no=#{containerNo} AND record_status='ACTIVE'")
ContainerBookingEntity selectActiveByContainerNo(@Param("containerNo") String containerNo);
@Update("""
UPDATE fl_container_booking SET unloading_completed_at=#{unloadingCompletedAt},
unloading_photo_uploaded_at=#{photoUploadedAt}, estimated_empty_reported_at=#{estimatedEmptyReportedAt},
actual_empty_reported_at=#{actualEmptyReportedAt}, empty_report_source_version=#{sourceVersion},
updated_by=0, updated_by_name='OVERSEAS_EVENT', updated_at=CURRENT_TIMESTAMP(3), version=version+1
WHERE id=#{id} AND record_status='ACTIVE'
AND (empty_report_source_version IS NULL OR empty_report_source_version &lt; #{sourceVersion})
""")
int applyEmptyReport(@Param("id") long id,
@Param("unloadingCompletedAt") java.time.LocalDateTime unloadingCompletedAt,
@Param("photoUploadedAt") java.time.LocalDateTime photoUploadedAt,
@Param("estimatedEmptyReportedAt") java.time.LocalDateTime estimatedEmptyReportedAt,
@Param("actualEmptyReportedAt") java.time.LocalDateTime actualEmptyReportedAt,
@Param("sourceVersion") long sourceVersion);
@Update("""
UPDATE fl_container_booking SET container_no=NULL, ocean_booking_id=NULL,
ocean_booking_requirement_id=NULL, customer_id=NULL, customer_name=NULL,
@@ -0,0 +1,9 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.ContainerEventInboxEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ContainerEventInboxMapper extends BaseMapper<ContainerEventInboxEntity> {
}
@@ -0,0 +1,109 @@
package com.baiyue.firstleg.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.OverseasContainerUnloadedV1;
import com.baiyue.common.event.RegionCode;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.entity.ContainerEventInboxEntity;
import com.baiyue.firstleg.mapper.ContainerBookingMapper;
import com.baiyue.firstleg.mapper.ContainerEventInboxMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
@Service
@RequiredArgsConstructor
public class OverseasContainerEventConsumer {
private final ContainerBookingMapper containers;
private final ContainerEventInboxMapper inbox;
private final ObjectMapper objectMapper;
@Transactional
public ContainerBookingEntity consume(EventEnvelope event) {
validateEnvelope(event);
OverseasContainerUnloadedV1 payload = payload(event);
if (payload.containerNo() == null || payload.containerNo().isBlank()
|| !payload.containerNo().equals(event.aggregateId())) {
throw new IllegalArgumentException("事件 aggregateId 与 containerNo 不一致");
}
if (payload.unloadingCompletedAt() == null || payload.unloadingPhotoUploadedAt() == null) {
throw new IllegalArgumentException("卸柜完成时间和卸柜照片上传时间不能为空");
}
if (payload.actualEmptyReportedAt() != null
&& payload.actualEmptyReportedAt().isBefore(payload.unloadingPhotoUploadedAt())) {
throw new IllegalArgumentException("实际报空时间不能早于卸柜照片上传时间");
}
ContainerEventInboxEntity existing = inbox.selectById(event.eventId());
if (existing != null) {
if (!existing.getPayloadHash().equalsIgnoreCase(event.payloadHash())) {
throw new IllegalArgumentException("相同 eventId 对应的事件摘要冲突");
}
return requiredContainer(payload.containerNo());
}
ContainerEventInboxEntity row = inbox(event);
try {
inbox.insert(row);
} catch (DuplicateKeyException duplicate) {
ContainerEventInboxEntity raced = inbox.selectById(event.eventId());
if (raced == null || !raced.getPayloadHash().equalsIgnoreCase(event.payloadHash())) throw duplicate;
return requiredContainer(payload.containerNo());
}
ContainerBookingEntity container = requiredContainer(payload.containerNo());
LocalDateTime uploadedAt = utc(payload.unloadingPhotoUploadedAt());
containers.applyEmptyReport(container.getId(), utc(payload.unloadingCompletedAt()), uploadedAt,
uploadedAt.plusDays(2), utc(payload.actualEmptyReportedAt()), event.aggregateVersion());
row.setStatus("PROCESSED");
row.setProcessedAt(LocalDateTime.now(ZoneOffset.UTC));
inbox.updateById(row);
return requiredContainer(payload.containerNo());
}
private void validateEnvelope(EventEnvelope event) {
event.requireValid();
if (!EventTypes.OVERSEAS_CONTAINER_UNLOADED.equals(event.eventType()) || event.eventVersion() != 1) {
throw new IllegalArgumentException("不支持的海外货柜事件");
}
if (event.sourceRegion() != RegionCode.OVERSEAS || event.targetRegion() != RegionCode.CN
|| !"CONTAINER_BOOKING".equals(event.aggregateType())) {
throw new IllegalArgumentException("海外货柜事件区域或聚合类型无效");
}
if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(event.payload()), event.payloadHash())) {
throw new IllegalArgumentException("payloadHash 与事件负载不一致");
}
}
private OverseasContainerUnloadedV1 payload(EventEnvelope event) {
try { return objectMapper.treeToValue(event.payload(), OverseasContainerUnloadedV1.class); }
catch (Exception exception) { throw new IllegalArgumentException("海外货柜事件负载无效", exception); }
}
private ContainerEventInboxEntity inbox(EventEnvelope event) {
ContainerEventInboxEntity row = new ContainerEventInboxEntity();
row.setEventId(event.eventId()); row.setEventType(event.eventType()); row.setEventVersion(event.eventVersion());
row.setAggregateId(event.aggregateId()); row.setAggregateVersion(event.aggregateVersion());
row.setPayloadHash(event.payloadHash().toLowerCase(java.util.Locale.ROOT));
row.setPayloadJson(event.payload().toString()); row.setStatus("RECEIVED");
row.setReceivedAt(LocalDateTime.now(ZoneOffset.UTC));
return row;
}
private ContainerBookingEntity requiredContainer(String containerNo) {
ContainerBookingEntity value = containers.selectActiveByContainerNo(containerNo);
if (value == null) throw new IllegalArgumentException("头程货柜不存在或不可用");
return value;
}
private LocalDateTime utc(java.time.Instant value) {
return value == null ? null : LocalDateTime.ofInstant(value, ZoneOffset.UTC);
}
}