优化
This commit is contained in:
+40
@@ -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 < #{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> {
|
||||
}
|
||||
+109
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user