This commit is contained in:
2026-08-20 18:02:38 +08:00
parent b55b049e77
commit 1cd8b6a473
210 changed files with 6974 additions and 825 deletions
@@ -0,0 +1,28 @@
package com.baiyue.firstleg.client;
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;
import java.time.LocalDateTime;
import java.util.List;
@FeignClient(name = "integration-service", contextId = "carrierScheduleIntegrationClient",
url = "${baiyue.integration-service.url:}")
public interface CarrierScheduleIntegrationClient {
@PostMapping("/internal/integrations/carrier-schedules/query")
Envelope<QueryResult> query(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody QueryRequest request);
record Envelope<T>(String code, String message, T data) {
public boolean successful() { return "200".equals(code); }
}
record QueryRequest(String carrierCode, String trackingType, String trackingNo) { }
record QueryResult(String providerCode, String outcome, List<Observation> observations,
String errorCode, String errorMessage, LocalDateTime providerObservedAt) { }
record Observation(String providerEventId, String providerNodeCode, String standardNode,
LocalDateTime observedAt, LocalDateTime estimatedAt, LocalDateTime actualAt,
String locationCode, String locationName, String sourceReference,
String rawPayloadHash) { }
}
@@ -14,6 +14,14 @@ public final class ContainerOverviewDtos {
List<TimelineEntry> timeline) {}
public record ContainerBookingEntityView(Long id, String bookingNo, String cabinetNo, String containerNo,
String status, Long warehouseId, String warehouseName, String voyageNo, String vesselName,
String billOfLadingNo, Long shippingCompanyId, String shippingCompanyName,
String departurePortCode, String departurePortName, String destinationPortCode,
String destinationPortName, LocalDateTime estimatedSailingAt, LocalDateTime actualSailingAt,
LocalDateTime estimatedPortArrivalAt, LocalDateTime actualPortArrivalAt,
LocalDateTime estimatedWarehouseArrivalAt, LocalDateTime actualWarehouseArrivalAt,
LocalDateTime unloadingCompletedAt, LocalDateTime unloadingPhotoUploadedAt,
LocalDateTime estimatedEmptyReportedAt, LocalDateTime actualEmptyReportedAt,
Long emptyReportSourceVersion,
Integer version) {}
public record PdaSummary(Long taskId, Long loadingPlanId, Long containerBookingId, String taskStatus,
String workflowStatus, Integer taskVersion, Integer itemCount, Integer completedItemCount, Integer unresolvedItemCount,
@@ -116,7 +116,7 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
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})
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,
@@ -10,6 +10,8 @@ import com.baiyue.firstleg.outbound.dispatch.entity.OutboundDispatchHistoryEntit
import com.baiyue.firstleg.outbound.dispatch.gate.OutboundDispatchGateContributor;
import com.baiyue.firstleg.outbound.dispatch.mapper.OutboundDispatchReadMapper;
import com.baiyue.firstleg.outbound.dispatch.repository.OutboundDispatchRepository;
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -26,10 +28,14 @@ public class OutboundDispatchService {
private final OutboundDispatchRepository repository;
private final Map<String, OutboundDispatchGateContributor> contributors;
private final CarrierScheduleQueryService carrierSchedules;
@Autowired
public OutboundDispatchService(OutboundDispatchRepository repository,
List<OutboundDispatchGateContributor> contributors) {
List<OutboundDispatchGateContributor> contributors,
CarrierScheduleQueryService carrierSchedules) {
this.repository = repository;
this.carrierSchedules = carrierSchedules;
Map<String, OutboundDispatchGateContributor> indexed = new HashMap<>();
for (OutboundDispatchGateContributor contributor : contributors) {
if (indexed.put(contributor.code(), contributor) != null) {
@@ -39,6 +45,11 @@ public class OutboundDispatchService {
this.contributors = Map.copyOf(indexed);
}
public OutboundDispatchService(OutboundDispatchRepository repository,
List<OutboundDispatchGateContributor> contributors) {
this(repository, contributors, null);
}
public List<Aggregate> list(Query query) {
requireSelector(query);
return repository.bindings(query).stream().map(this::aggregate).toList();
@@ -73,6 +84,10 @@ public class OutboundDispatchService {
}
history(state, "CONFIRM_DISPATCH", OutboundDispatchStatus.READY_FOR_DISPATCH.name(),
OutboundDispatchStatus.WAITING_FOR_LOADING.name(), request.remark(), operatorId, operatorName);
if (carrierSchedules != null) {
carrierSchedules.ensureFromOutboundConfirm(binding.tenantId(), before.oceanBookingId(),
binding.containerBookingId(), operatorId, operatorName);
}
if (repository.completeOperation(state.getId(), operatorId, "CONFIRM_DISPATCH", key) != 1) throw concurrent();
return detail(query);
}
@@ -0,0 +1,48 @@
package com.baiyue.firstleg.portschedule.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.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Detail;
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.PathVariable;
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.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
@RestController
@RequestMapping("/internal/firstleg/v1/carrier-schedule-queries")
public class InternalCarrierScheduleQueryController {
private final CarrierScheduleQueryService service;
@Value("${baiyue.security.internal-service-token}") private String expectedToken;
public InternalCarrierScheduleQueryController(CarrierScheduleQueryService service) { this.service = service; }
@PostMapping("/{queryId}/stop")
public ApiResponse<Detail> stop(@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@PathVariable @Positive long queryId,
@Valid @RequestBody StopRequest request) {
authenticate(token);
return ApiResponse.success(service.stop(queryId, request.reason()));
}
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, "内部服务令牌无效");
}
}
public record StopRequest(@NotBlank @Size(max = 500) String reason) { }
}
@@ -4,14 +4,13 @@ import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.AttemptRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CarrierDescriptor;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CreateRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Detail;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualResultRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Query;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.QueryView;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryHistoryEntity;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.History;
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import io.swagger.v3.oas.annotations.Operation;
@@ -66,7 +65,7 @@ public class PlatformCarrierScheduleQueryController {
@GetMapping("/{queryId}/history")
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:list')")
public ApiResponse<List<CarrierScheduleQueryHistoryEntity>> history(
public ApiResponse<List<History>> history(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long queryId) {
FirstlegScopeGuard.platform(user, service.customerId(queryId));
return ApiResponse.success(service.history(queryId));
@@ -83,18 +82,6 @@ public class PlatformCarrierScheduleQueryController {
return ApiResponse.success(service.create(user.userId(), user.username(), key, request));
}
@PostMapping("/{queryId}/attempts")
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:attempt')")
@BusinessAudit(event = "CARRIER_SCHEDULE_QUERY_ATTEMPT", resourceType = "CARRIER_SCHEDULE_QUERY",
resourceId = "#queryId", description = "执行船司官网查询策略")
public ApiResponse<Detail> attempt(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long queryId,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody AttemptRequest request) {
FirstlegScopeGuard.platform(user, service.customerId(queryId));
return ApiResponse.success(service.attempt(queryId, user.userId(), user.username(), key, request));
}
@PostMapping("/{queryId}/manual-results")
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:manual')")
@BusinessAudit(event = "CARRIER_SCHEDULE_QUERY_MANUAL_RESULT", resourceType = "CARRIER_SCHEDULE_QUERY",
@@ -0,0 +1,29 @@
package com.baiyue.firstleg.portschedule.domain;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import java.util.Locale;
/** Provider-independent maritime milestones in monotonic business order. */
public enum CarrierScheduleNode {
DEPARTED_WAREHOUSE(10),
AT_ORIGIN_PORT(20),
DEPARTED_ORIGIN_PORT(30),
IN_TRANSIT(40),
ARRIVED(50),
PICKUP_READY(60);
private final int sequence;
CarrierScheduleNode(int sequence) { this.sequence = sequence; }
public int sequence() { return sequence; }
public static CarrierScheduleNode require(String value) {
try { return valueOf(value == null ? "" : value.trim().toUpperCase(Locale.ROOT)); }
catch (IllegalArgumentException exception) {
throw new BizException(ResultCode.BAD_REQUEST, "不支持的船节点:" + value);
}
}
}
@@ -1,8 +1,5 @@
package com.baiyue.firstleg.portschedule.dto;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryAttemptEntity;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryHistoryEntity;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
@@ -33,20 +30,44 @@ public final class CarrierScheduleQueryDtos {
@NotBlank @Size(max = 128) String trackingNo,
@Size(max = 1000) String remark) {}
public record AttemptRequest(@NotNull @Min(0) Integer expectedVersion,
@Size(max = 1000) String reason) {}
public record ManualResultRequest(@NotNull @Min(0) Integer expectedVersion,
@NotNull @Positive Long portScheduleId,
@NotBlank @Size(max = 1000) String reason) {}
public record ProviderObservationRequest(@NotBlank @Size(max = 32) String providerCode,
@NotBlank @Size(max = 128) String providerEventId,
@NotBlank @Size(max = 64) String providerNodeCode,
@NotBlank @Size(max = 32) String standardNode,
@NotNull LocalDateTime observedAt,
LocalDateTime estimatedAt,
LocalDateTime actualAt,
@Size(max = 32) String locationCode,
@Size(max = 128) String locationName,
@Size(max = 500) String sourceReference,
@NotBlank @Size(min = 64, max = 64) String rawPayloadHash) {}
public record CarrierDescriptor(String carrierCode, String carrierName, String officialUrl,
String accessMode, boolean automated, boolean coverageGap,
String coverageGapCode, String manualReason) {}
public record QueryView(CarrierScheduleQueryEntity query, boolean expired,
int maxAttempts, LocalDateTime nextAllowedAt) {}
public record Task(Long id, String queryNo, Long customerId, Long oceanBookingId,
Long containerBookingId, String carrierCode, String trackingType, String trackingNo,
String officialUrl, String accessMode, String status, String triggerSource,
String latestStandardNode, LocalDateTime latestNodeObservedAt,
LocalDateTime lastSuccessAt, LocalDateTime sourceUpdatedAt, LocalDateTime expiresAt,
Long resultPortScheduleId, String manualReason, String lastErrorCode,
String lastErrorMessage, LocalDateTime stoppedAt, String stopReason, Integer version) {}
public record Detail(QueryView query, List<CarrierScheduleQueryAttemptEntity> attempts,
List<CarrierScheduleQueryHistoryEntity> histories) {}
public record QueryView(Task query, boolean expired) {}
public record NodeFact(Long id, String providerCode, String providerNodeCode, String standardNode,
int nodeSequence, LocalDateTime observedAt, LocalDateTime estimatedAt,
LocalDateTime actualAt, String locationCode, String locationName,
String sourceReference, String rawPayloadHash, LocalDateTime createdAt) {}
public record History(Long id, String action, Integer fromVersion, Integer toVersion, String reason,
Long operatorId, String operatorName, LocalDateTime createdAt) {}
/** Frontend detail intentionally excludes scheduler leases and raw query attempts. */
public record Detail(QueryView query, List<NodeFact> nodes, List<History> histories) {}
}
@@ -0,0 +1,32 @@
package com.baiyue.firstleg.portschedule.entity;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
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_carrier_schedule_node_fact")
public class CarrierScheduleNodeFactEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long queryId;
private String eventKey;
private String providerCode;
private String providerNodeCode;
private String standardNode;
private Integer nodeSequence;
private LocalDateTime observedAt;
private LocalDateTime estimatedAt;
private LocalDateTime actualAt;
private String locationCode;
private String locationName;
private String sourceReference;
private String rawPayloadHash;
@TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt;
}
@@ -26,6 +26,14 @@ public class CarrierScheduleQueryEntity {
private String officialUrl;
private String accessMode;
private String status;
private String triggerSource;
private String latestStandardNode;
private LocalDateTime latestNodeObservedAt;
private LocalDateTime stoppedAt;
private String stopReason;
private String leaseOwner;
private LocalDateTime leaseUntil;
private Integer consecutiveFailures;
private LocalDateTime windowStartedAt;
private Integer attemptCount;
private LocalDateTime nextAllowedAt;
@@ -0,0 +1,23 @@
package com.baiyue.firstleg.portschedule.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Param;
@Mapper
public interface CarrierScheduleNodeFactMapper extends BaseMapper<CarrierScheduleNodeFactEntity> {
@Insert("""
INSERT INTO fl_carrier_schedule_node_fact
(id,query_id,event_key,provider_code,provider_node_code,standard_node,node_sequence,observed_at,
estimated_at,actual_at,location_code,location_name,source_reference,raw_payload_hash,created_at)
VALUES
(#{value.id},#{value.queryId},#{value.eventKey},#{value.providerCode},#{value.providerNodeCode},
#{value.standardNode},#{value.nodeSequence},#{value.observedAt},#{value.estimatedAt},#{value.actualAt},
#{value.locationCode},#{value.locationName},#{value.sourceReference},#{value.rawPayloadHash},
CURRENT_TIMESTAMP(3))
ON DUPLICATE KEY UPDATE id=id
""")
int insertIgnore(@Param("value") CarrierScheduleNodeFactEntity value);
}
@@ -9,6 +9,9 @@ import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.time.LocalDateTime;
import java.util.List;
@Mapper
public interface CarrierScheduleQueryMapper extends BaseMapper<CarrierScheduleQueryEntity> {
@Select("""
@@ -33,9 +36,42 @@ public interface CarrierScheduleQueryMapper extends BaseMapper<CarrierScheduleQu
@Select("SELECT * FROM fl_carrier_schedule_query WHERE id=#{id} FOR UPDATE")
CarrierScheduleQueryEntity lockById(@Param("id") long id);
@Select("""
SELECT * FROM fl_carrier_schedule_query
WHERE customer_id=#{customerId} AND ocean_booking_id=#{oceanBookingId}
AND container_booking_id=#{containerBookingId}
ORDER BY id DESC LIMIT 1
""")
CarrierScheduleQueryEntity findByBusinessScope(@Param("customerId") long customerId,
@Param("oceanBookingId") long oceanBookingId,
@Param("containerBookingId") long containerBookingId);
@Select("""
SELECT * FROM fl_carrier_schedule_query
WHERE status IN ('READY','MANUAL_REQUIRED','ARRIVED','PICKUP_READY')
AND next_allowed_at IS NOT NULL AND next_allowed_at&lt;=#{now}
AND (lease_until IS NULL OR lease_until&lt;#{now})
ORDER BY next_allowed_at,id LIMIT #{limit}
""")
List<CarrierScheduleQueryEntity> selectDue(@Param("now") LocalDateTime now, @Param("limit") int limit);
@Update("""
UPDATE fl_carrier_schedule_query SET lease_owner=#{owner},lease_until=#{leaseUntil},
updated_at=CURRENT_TIMESTAMP(3)
WHERE id=#{id} AND status IN ('READY','MANUAL_REQUIRED','ARRIVED','PICKUP_READY')
AND next_allowed_at IS NOT NULL AND next_allowed_at&lt;=#{now}
AND (lease_until IS NULL OR lease_until&lt;#{now})
""")
int claimLease(@Param("id") long id, @Param("owner") String owner,
@Param("now") LocalDateTime now, @Param("leaseUntil") LocalDateTime leaseUntil);
@Update("""
UPDATE fl_carrier_schedule_query
SET official_url=#{value.officialUrl},access_mode=#{value.accessMode},status=#{value.status},
trigger_source=#{value.triggerSource},latest_standard_node=#{value.latestStandardNode},
latest_node_observed_at=#{value.latestNodeObservedAt},stopped_at=#{value.stoppedAt},
stop_reason=#{value.stopReason},lease_owner=#{value.leaseOwner},lease_until=#{value.leaseUntil},
consecutive_failures=#{value.consecutiveFailures},
window_started_at=#{value.windowStartedAt},attempt_count=#{value.attemptCount},
next_allowed_at=#{value.nextAllowedAt},last_attempt_at=#{value.lastAttemptAt},
last_success_at=#{value.lastSuccessAt},source_updated_at=#{value.sourceUpdatedAt},
@@ -9,14 +9,17 @@ import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryAttemptEntity
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryHistoryEntity;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryOperationEntity;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity;
import com.baiyue.firstleg.portschedule.mapper.CarrierScheduleQueryAttemptMapper;
import com.baiyue.firstleg.portschedule.mapper.CarrierScheduleQueryHistoryMapper;
import com.baiyue.firstleg.portschedule.mapper.CarrierScheduleQueryMapper;
import com.baiyue.firstleg.portschedule.mapper.CarrierScheduleQueryOperationMapper;
import com.baiyue.firstleg.portschedule.mapper.CarrierScheduleNodeFactMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.time.LocalDateTime;
@Repository
public class CarrierScheduleQueryRepository {
@@ -24,12 +27,20 @@ public class CarrierScheduleQueryRepository {
@Resource private CarrierScheduleQueryAttemptMapper attempts;
@Resource private CarrierScheduleQueryHistoryMapper histories;
@Resource private CarrierScheduleQueryOperationMapper operations;
@Resource private CarrierScheduleNodeFactMapper nodes;
public PageResult<CarrierScheduleQueryEntity> page(Query query) {
return PageResult.from(queries.selectPage(new Page<>(query.page(), query.size()), query));
}
public CarrierScheduleQueryEntity find(long id) { return queries.selectById(id); }
public CarrierScheduleQueryEntity lock(long id) { return queries.lockById(id); }
public CarrierScheduleQueryEntity findByBusinessScope(long customerId, long oceanBookingId, long containerBookingId) {
return queries.findByBusinessScope(customerId, oceanBookingId, containerBookingId);
}
public List<CarrierScheduleQueryEntity> due(LocalDateTime now, int limit) { return queries.selectDue(now, limit); }
public boolean claimLease(long id, String owner, LocalDateTime now, LocalDateTime leaseUntil) {
return queries.claimLease(id, owner, now, leaseUntil) == 1;
}
public void insert(CarrierScheduleQueryEntity value) { queries.insert(value); }
public int updateState(CarrierScheduleQueryEntity value, int version, long operatorId, String operatorName) {
return queries.updateState(value, version, operatorId, operatorName);
@@ -48,6 +59,14 @@ public class CarrierScheduleQueryRepository {
.orderByAsc(CarrierScheduleQueryHistoryEntity::getId));
}
public void insert(CarrierScheduleQueryHistoryEntity value) { histories.insert(value); }
public List<CarrierScheduleNodeFactEntity> nodes(long id) {
return nodes.selectList(new LambdaQueryWrapper<CarrierScheduleNodeFactEntity>()
.eq(CarrierScheduleNodeFactEntity::getQueryId, id)
.orderByAsc(CarrierScheduleNodeFactEntity::getNodeSequence)
.orderByAsc(CarrierScheduleNodeFactEntity::getObservedAt)
.orderByAsc(CarrierScheduleNodeFactEntity::getId));
}
public boolean insertIfAbsent(CarrierScheduleNodeFactEntity value) { return nodes.insertIgnore(value) == 1; }
public boolean claimOperation(Long queryId, long operatorId, String operation, String key, String hash) {
return operations.claim(IdWorker.getId(), queryId, operatorId, operation, key, hash) == 1;
}
@@ -32,6 +32,14 @@ public class CarrierScheduleAdapterRegistry {
return required(carrierCode).code();
}
public String resolve(String carrierName) {
String normalized = carrierName == null ? "" : carrierName.trim().toUpperCase(Locale.ROOT);
return DEFINITIONS.values().stream().filter(value -> value.matches(normalized))
.findFirst().map(Definition::code)
.orElseThrow(() -> new BizException(ResultCode.BAD_REQUEST,
"出仓订舱船司无法映射至已支持的船节点供应商:" + carrierName));
}
private static Definition required(String carrierCode) {
String normalized = carrierCode == null ? "" : carrierCode.trim().toUpperCase(Locale.ROOT);
Definition value = DEFINITIONS.get(normalized);
@@ -79,6 +87,15 @@ public class CarrierScheduleAdapterRegistry {
return new CarrierDescriptor(code, name, officialUrl, accessMode, false, true,
coverageGapCode, manualReason);
}
boolean matches(String value) {
return value.equals(code) || value.contains(code) || value.contains(name.toUpperCase(Locale.ROOT))
|| "MATSON".equals(code) && value.contains("美森")
|| "SML".equals(code) && (value.contains("森罗") || value.contains("SM LINE"))
|| "ZIM".equals(code) && value.contains("以星")
|| "COSCO".equals(code) && (value.contains("中远") || value.contains("COSCO SHIPPING"))
|| "EMC".equals(code) && (value.contains("长荣") || value.contains("EVERGREEN"))
|| "HEDE".equals(code) && value.contains("合德");
}
}
public record PreparedAttempt(String carrierCode, String accessMode, String officialUrl,
@@ -0,0 +1,71 @@
package com.baiyue.firstleg.portschedule.service;
import com.baiyue.firstleg.client.CarrierScheduleIntegrationClient;
import com.baiyue.firstleg.client.CarrierScheduleIntegrationClient.QueryRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderObservationRequest;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity;
import com.baiyue.firstleg.portschedule.repository.CarrierScheduleQueryRepository;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.Clock;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
@Component
public class CarrierScheduleQueryScheduler {
private final CarrierScheduleQueryRepository repository;
private final CarrierScheduleQueryService service;
private final CarrierScheduleIntegrationClient integration;
private final String workerId = UUID.randomUUID().toString();
private Clock clock = Clock.systemDefaultZone();
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
@Value("${baiyue.carrier-schedule.scheduler.batch-size:20}") private int batchSize = 20;
@Value("${baiyue.carrier-schedule.scheduler.lease-duration:PT5M}")
private Duration leaseDuration = Duration.ofMinutes(5);
public CarrierScheduleQueryScheduler(CarrierScheduleQueryRepository repository,
CarrierScheduleQueryService service,
CarrierScheduleIntegrationClient integration) {
this.repository = repository; this.service = service; this.integration = integration;
}
@Scheduled(fixedDelayString = "${baiyue.carrier-schedule.scheduler.fixed-delay:PT1M}")
public void poll() {
LocalDateTime now = LocalDateTime.now(clock);
repository.due(now, Math.max(1, Math.min(batchSize, 100))).forEach(candidate -> execute(candidate, now));
}
/** Executes only after this worker wins the database lease. */
void execute(CarrierScheduleQueryEntity candidate, LocalDateTime now) {
if (!repository.claimLease(candidate.getId(), workerId, now, now.plus(leaseDuration))) return;
try {
var response = integration.query(internalToken,
new QueryRequest(candidate.getCarrierCode(), candidate.getTrackingType(), candidate.getTrackingNo()));
if (response == null || !response.successful() || response.data() == null) {
service.completeScheduledAttempt(candidate.getId(), "RETRYABLE_FAILURE", "EMPTY_PROVIDER_RESPONSE",
"轨迹供应商返回空响应");
return;
}
var result = response.data();
List<CarrierScheduleIntegrationClient.Observation> observations = result.observations() == null
? List.of() : result.observations();
for (var observation : observations) {
service.applyProviderObservation(candidate.getId(), new ProviderObservationRequest(
result.providerCode(), observation.providerEventId(), observation.providerNodeCode(),
observation.standardNode(), observation.observedAt(), observation.estimatedAt(),
observation.actualAt(), observation.locationCode(), observation.locationName(),
observation.sourceReference(), observation.rawPayloadHash()));
}
service.completeScheduledAttempt(candidate.getId(), result.outcome(), result.errorCode(),
result.errorMessage());
} catch (RuntimeException exception) {
service.completeScheduledAttempt(candidate.getId(), "RETRYABLE_FAILURE", "PROVIDER_CALL_FAILED",
exception.getClass().getSimpleName());
}
}
}
@@ -5,17 +5,22 @@ import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.AttemptRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CarrierDescriptor;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CreateRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Detail;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualResultRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Query;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.QueryView;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryAttemptEntity;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderObservationRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.NodeFact;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.History;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Task;
import com.baiyue.firstleg.portschedule.domain.CarrierScheduleNode;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryHistoryEntity;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryOperationEntity;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.portschedule.entity.PortScheduleEntity;
import com.baiyue.firstleg.portschedule.repository.CarrierScheduleQueryRepository;
import com.baiyue.firstleg.portschedule.repository.PortScheduleRepository;
@@ -61,13 +66,148 @@ public class CarrierScheduleQueryService {
public Detail detail(long id) { return detail(required(id)); }
public List<CarrierScheduleQueryHistoryEntity> history(long id) {
public List<History> history(long id) {
required(id);
return repository.histories(id);
return repository.histories(id).stream().map(this::history).toList();
}
public long customerId(long id) { return required(id).getCustomerId(); }
/** Idempotently starts the canonical tracking task after domestic outbound confirmation. */
@Transactional
public CarrierScheduleQueryEntity ensureFromOutboundConfirm(long customerId, long oceanBookingId,
long containerBookingId,
long operatorId, String operatorName) {
CarrierScheduleQueryEntity existing = repository.findByBusinessScope(customerId, oceanBookingId,
containerBookingId);
if (existing != null) return existing;
OceanBookingEntity booking = portSchedules.oceanBooking(oceanBookingId);
ContainerBookingEntity container = portSchedules.containerBooking(containerBookingId);
if (booking == null || container == null) throw new BizException(ResultCode.NOT_FOUND, "出仓订舱或柜次不存在");
validateLinks(customerId, oceanBookingId, containerBookingId);
String carrierCode = adapters.resolve(booking.getShippingCompanyName());
String trackingType;
String trackingNo;
if (StringUtils.hasText(container.getContainerNo())) {
trackingType = "CONTAINER_NO"; trackingNo = container.getContainerNo().trim();
} else if (StringUtils.hasText(booking.getBillOfLadingNo())) {
trackingType = "BILL_OF_LADING"; trackingNo = booking.getBillOfLadingNo().trim();
} else if (StringUtils.hasText(booking.getBookingNo())) {
trackingType = "BOOKING_NO"; trackingNo = booking.getBookingNo().trim();
} else {
throw badRequest("确认国内出仓前必须具备柜号、提单号或订舱号之一");
}
PreparedAttempt strategy = adapters.prepare(carrierCode, trackingType, trackingNo);
LocalDateTime now = now();
CarrierScheduleQueryEntity value = new CarrierScheduleQueryEntity();
value.setId(IdWorker.getId()); value.setQueryNo("CSQ" + value.getId());
value.setCustomerId(customerId); value.setOceanBookingId(oceanBookingId);
value.setContainerBookingId(containerBookingId); value.setCarrierCode(carrierCode);
value.setTrackingType(trackingType); value.setTrackingNo(trackingNo);
value.setOfficialUrl(strategy.officialUrl()); value.setAccessMode(strategy.accessMode());
value.setStatus("READY"); value.setTriggerSource("OUTBOUND_CONFIRM");
value.setLatestStandardNode(CarrierScheduleNode.DEPARTED_WAREHOUSE.name());
value.setLatestNodeObservedAt(now); value.setWindowStartedAt(now); value.setAttemptCount(0);
value.setConsecutiveFailures(0); value.setNextAllowedAt(now); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0);
try { repository.insert(value); }
catch (DuplicateKeyException exception) {
CarrierScheduleQueryEntity concurrent = repository.findByBusinessScope(customerId, oceanBookingId,
containerBookingId);
if (concurrent != null) return concurrent;
throw exception;
}
addNode(value.getId(), "FIRSTLEG", "OUTBOUND_CONFIRM", "OUTBOUND_CONFIRM",
CarrierScheduleNode.DEPARTED_WAREHOUSE, now, null, now, null, null, "OUTBOUND_CONFIRM",
sha256("OUTBOUND_CONFIRM|" + containerBookingId));
addHistory(value.getId(), "AUTO_CREATE", null, 0, "国内确认出仓自动创建", null, value,
operatorId, operatorName);
return value;
}
@Transactional
public Detail applyProviderObservation(long id, ProviderObservationRequest request) {
CarrierScheduleQueryEntity value = requiredLocked(id);
if ("STOPPED".equals(value.getStatus())) return detail(value);
CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode());
CarrierScheduleQueryEntity before = copy(value);
if (!addNode(id, request.providerCode(), request.providerEventId(), request.providerNodeCode(), node,
request.observedAt(), request.estimatedAt(), request.actualAt(), request.locationCode(),
request.locationName(), request.sourceReference(), request.rawPayloadHash())) {
return detail(value);
}
CarrierScheduleNode current = value.getLatestStandardNode() == null ? null
: CarrierScheduleNode.require(value.getLatestStandardNode());
if (current == null || node.sequence() >= current.sequence()) {
value.setLatestStandardNode(node.name()); value.setLatestNodeObservedAt(request.observedAt());
value.setLastSuccessAt(now()); value.setSourceUpdatedAt(request.observedAt());
value.setLastAttemptStatus("SUCCESS"); value.setLastErrorCode(null); value.setLastErrorMessage(null);
value.setStatus(node == CarrierScheduleNode.ARRIVED ? "ARRIVED"
: node == CarrierScheduleNode.PICKUP_READY ? "PICKUP_READY" : "READY");
value.setNextAllowedAt(now().plusDays(WINDOW_DAYS));
if (repository.updateState(value, value.getVersion(), 0L, "carrier-scheduler") != 1) throw concurrent();
}
CarrierScheduleQueryEntity after = required(id);
addHistory(id, "NODE_OBSERVED", before.getVersion(), after.getVersion(), request.standardNode(), before,
after, 0L, "carrier-scheduler");
return detail(after);
}
@Transactional
public Detail stop(long id, String reason) {
CarrierScheduleQueryEntity value = requiredLocked(id);
if ("STOPPED".equals(value.getStatus())) return detail(value);
CarrierScheduleQueryEntity before = copy(value);
value.setStatus("STOPPED"); value.setStoppedAt(now()); value.setStopReason(trim(reason));
value.setLeaseOwner(null); value.setLeaseUntil(null); value.setNextAllowedAt(null);
if (repository.updateState(value, value.getVersion(), 0L, "carrier-scheduler") != 1) throw concurrent();
CarrierScheduleQueryEntity after = required(id);
addHistory(id, "STOP", before.getVersion(), after.getVersion(), reason, before, after, 0L,
"carrier-scheduler");
return detail(after);
}
@Transactional
public void completeScheduledAttempt(long id, String outcome, String errorCode, String errorMessage) {
CarrierScheduleQueryEntity value = requiredLocked(id);
if ("STOPPED".equals(value.getStatus())) return;
CarrierScheduleQueryEntity before = copy(value);
LocalDateTime now = now();
value.setLeaseOwner(null); value.setLeaseUntil(null); value.setLastAttemptAt(now);
value.setLastAttemptStatus(outcome); value.setLastErrorCode(trim(errorCode));
value.setLastErrorMessage(trim(errorMessage));
if ("SUCCESS".equals(outcome) || "NO_CHANGE".equals(outcome)) {
value.setConsecutiveFailures(0); value.setAttemptCount(0); value.setWindowStartedAt(now);
value.setNextAllowedAt(now.plusDays(WINDOW_DAYS));
} else {
int failures = count(value.getConsecutiveFailures()) + 1;
LocalDateTime windowStart = value.getWindowStartedAt();
if (windowStart == null || !now.isBefore(windowStart.plusDays(WINDOW_DAYS))) {
windowStart = now; value.setAttemptCount(0);
}
int attempts = count(value.getAttemptCount()) + 1;
value.setWindowStartedAt(windowStart); value.setAttemptCount(Math.min(MAX_ATTEMPTS, attempts));
value.setConsecutiveFailures(failures);
if ("MANUAL_REQUIRED".equals(outcome) || "PERMANENT_FAILURE".equals(outcome)) {
if (!"ARRIVED".equals(value.getStatus()) && !"PICKUP_READY".equals(value.getStatus())) {
value.setStatus("MANUAL_REQUIRED");
}
value.setNextAllowedAt(now.plusDays(WINDOW_DAYS));
} else {
long minutes = Math.min(24L * 60L, 15L << Math.min(failures - 1, 6));
LocalDateTime retryAt = now.plusMinutes(minutes);
if (attempts >= MAX_ATTEMPTS && retryAt.isBefore(windowStart.plusDays(WINDOW_DAYS))) {
retryAt = windowStart.plusDays(WINDOW_DAYS);
}
value.setNextAllowedAt(retryAt);
}
}
if (repository.updateState(value, value.getVersion(), 0L, "carrier-scheduler") != 1) throw concurrent();
CarrierScheduleQueryEntity after = required(id);
addHistory(id, "PROVIDER_QUERY", before.getVersion(), after.getVersion(), outcome, before, after, 0L,
"carrier-scheduler");
}
@Transactional
public Detail create(long operatorId, String operatorName, String key, CreateRequest request) {
String operation = "CREATE";
@@ -84,7 +224,8 @@ public class CarrierScheduleQueryService {
value.setContainerBookingId(request.containerBookingId()); value.setCarrierCode(carrierCode);
value.setTrackingType(trackingType); value.setTrackingNo(request.trackingNo().trim());
value.setOfficialUrl(strategy.officialUrl()); value.setAccessMode(strategy.accessMode());
value.setStatus("READY"); value.setWindowStartedAt(now); value.setAttemptCount(0);
value.setStatus("READY"); value.setTriggerSource("MANUAL");
value.setWindowStartedAt(now); value.setAttemptCount(0); value.setConsecutiveFailures(0);
value.setNextAllowedAt(now); value.setManualReason(strategy.manualReason()); value.setRemark(trim(request.remark()));
value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0);
@@ -94,51 +235,6 @@ public class CarrierScheduleQueryService {
return complete(value.getId(), operatorId, key, detail(value));
}
@Transactional
public Detail attempt(long id, long operatorId, String operatorName, String key, AttemptRequest request) {
String operation = "ATTEMPT";
Detail replay = claimOrReplay(id, operatorId, operation, key, hash(operation, id, request));
if (replay != null) return replay;
CarrierScheduleQueryEntity before = requiredLockedVersion(id, request.expectedVersion());
LocalDateTime now = now();
LocalDateTime windowStart = before.getWindowStartedAt();
if (windowStart == null || !now.isBefore(windowStart.plusDays(WINDOW_DAYS))) {
windowStart = now;
before.setAttemptCount(0);
}
if (before.getLastSuccessAt() != null && before.getNextAllowedAt() != null
&& now.isBefore(before.getNextAllowedAt())) {
throw conflict("船期查询每3天执行一次,下次允许时间:" + before.getNextAllowedAt());
}
int attemptCount = count(before.getAttemptCount());
LocalDateTime windowEnd = windowStart.plusDays(WINDOW_DAYS);
if (attemptCount >= MAX_ATTEMPTS && now.isBefore(windowEnd)) {
throw conflict("当前3天窗口最多查询3次,下次允许时间:" + windowEnd);
}
PreparedAttempt strategy = adapters.prepare(before.getCarrierCode(), before.getTrackingType(), before.getTrackingNo());
CarrierScheduleQueryEntity snapshot = copy(before);
int nextCount = attemptCount + 1;
before.setOfficialUrl(strategy.officialUrl()); before.setAccessMode(strategy.accessMode());
before.setStatus(strategy.status()); before.setWindowStartedAt(windowStart); before.setAttemptCount(nextCount);
before.setLastAttemptAt(now); before.setLastAttemptStatus(strategy.status());
before.setManualReason(strategy.manualReason()); before.setLastErrorCode(strategy.coverageGapCode());
before.setLastErrorMessage(strategy.manualReason());
before.setNextAllowedAt(nextCount >= MAX_ATTEMPTS ? windowEnd : now);
if (repository.updateState(before, request.expectedVersion(), operatorId, operatorName) != 1) throw concurrent();
CarrierScheduleQueryEntity after = required(id);
CarrierScheduleQueryAttemptEntity attempt = new CarrierScheduleQueryAttemptEntity();
attempt.setId(IdWorker.getId()); attempt.setQueryId(id); attempt.setWindowAttemptNo(nextCount);
attempt.setCarrierCode(after.getCarrierCode()); attempt.setAccessMode(strategy.accessMode());
attempt.setOfficialUrl(strategy.officialUrl()); attempt.setAttemptStatus(strategy.status());
attempt.setCoverageGap(true); attempt.setCoverageGapCode(strategy.coverageGapCode());
attempt.setManualReason(strategy.manualReason()); attempt.setErrorCode(strategy.coverageGapCode());
attempt.setErrorMessage(strategy.manualReason()); attempt.setOperatorId(operatorId); attempt.setOperatorName(operatorName);
repository.insert(attempt);
addHistory(id, operation, snapshot.getVersion(), after.getVersion(), request.reason(), snapshot, after,
operatorId, operatorName);
return complete(id, operatorId, key, detail(after));
}
@Transactional
public Detail attachManualResult(long id, long operatorId, String operatorName, String key,
ManualResultRequest request) {
@@ -155,7 +251,7 @@ public class CarrierScheduleQueryService {
}
CarrierScheduleQueryEntity before = copy(value);
LocalDateTime now = now();
value.setStatus("SUCCESS"); value.setResultPortScheduleId(schedule.getId());
value.setStatus("READY"); value.setResultPortScheduleId(schedule.getId());
value.setLastSuccessAt(now); value.setSourceUpdatedAt(schedule.getObservedAt());
value.setExpiresAt(schedule.getExpiresAt()); value.setNextAllowedAt(now.plusDays(WINDOW_DAYS));
value.setWindowStartedAt(now); value.setLastAttemptStatus("MANUAL_COMPLETED");
@@ -212,13 +308,48 @@ public class CarrierScheduleQueryService {
}
private Detail detail(CarrierScheduleQueryEntity value) {
return new Detail(view(value, now()), repository.attempts(value.getId()), repository.histories(value.getId()));
return new Detail(view(value, now()), repository.nodes(value.getId()).stream().map(this::node).toList(),
repository.histories(value.getId()).stream().map(this::history).toList());
}
private NodeFact node(CarrierScheduleNodeFactEntity value) {
return new NodeFact(value.getId(), value.getProviderCode(), value.getProviderNodeCode(),
value.getStandardNode(), value.getNodeSequence(), value.getObservedAt(), value.getEstimatedAt(),
value.getActualAt(), value.getLocationCode(), value.getLocationName(), value.getSourceReference(),
value.getRawPayloadHash(), value.getCreatedAt());
}
private History history(CarrierScheduleQueryHistoryEntity value) {
return new History(value.getId(), value.getAction(), value.getFromVersion(), value.getToVersion(),
value.getReason(), value.getOperatorId(), value.getOperatorName(), value.getCreatedAt());
}
private boolean addNode(long queryId, String providerCode, String providerEventId, String providerNodeCode,
CarrierScheduleNode node, LocalDateTime observedAt, LocalDateTime estimatedAt,
LocalDateTime actualAt, String locationCode, String locationName,
String sourceReference, String rawPayloadHash) {
CarrierScheduleNodeFactEntity fact = new CarrierScheduleNodeFactEntity();
fact.setId(IdWorker.getId()); fact.setQueryId(queryId);
fact.setEventKey(providerCode.trim().toUpperCase(Locale.ROOT) + ':' + providerEventId.trim());
fact.setProviderCode(providerCode.trim().toUpperCase(Locale.ROOT));
fact.setProviderNodeCode(providerNodeCode.trim()); fact.setStandardNode(node.name());
fact.setNodeSequence(node.sequence()); fact.setObservedAt(observedAt); fact.setEstimatedAt(estimatedAt);
fact.setActualAt(actualAt); fact.setLocationCode(trim(locationCode)); fact.setLocationName(trim(locationName));
fact.setSourceReference(trim(sourceReference)); fact.setRawPayloadHash(rawPayloadHash);
return repository.insertIfAbsent(fact);
}
private static QueryView view(CarrierScheduleQueryEntity value, LocalDateTime now) {
boolean expired = value.getLastSuccessAt() == null || value.getExpiresAt() == null
|| !value.getExpiresAt().isAfter(now);
return new QueryView(value, expired, MAX_ATTEMPTS, value.getNextAllowedAt());
Task task = new Task(value.getId(), value.getQueryNo(), value.getCustomerId(), value.getOceanBookingId(),
value.getContainerBookingId(), value.getCarrierCode(), value.getTrackingType(), value.getTrackingNo(),
value.getOfficialUrl(), value.getAccessMode(), value.getStatus(), value.getTriggerSource(),
value.getLatestStandardNode(), value.getLatestNodeObservedAt(), value.getLastSuccessAt(),
value.getSourceUpdatedAt(), value.getExpiresAt(), value.getResultPortScheduleId(), value.getManualReason(),
value.getLastErrorCode(), value.getLastErrorMessage(), value.getStoppedAt(), value.getStopReason(),
value.getVersion());
return new QueryView(task, expired);
}
private CarrierScheduleQueryEntity required(long id) {
@@ -234,6 +365,12 @@ public class CarrierScheduleQueryService {
return value;
}
private CarrierScheduleQueryEntity requiredLocked(long id) {
CarrierScheduleQueryEntity value = repository.lock(id);
if (value == null) throw new BizException(ResultCode.NOT_FOUND, "船司查询任务不存在");
return value;
}
private static CarrierScheduleQueryEntity copy(CarrierScheduleQueryEntity source) {
CarrierScheduleQueryEntity value = new CarrierScheduleQueryEntity();
value.setId(source.getId()); value.setQueryNo(source.getQueryNo()); value.setCustomerId(source.getCustomerId());
@@ -241,6 +378,10 @@ public class CarrierScheduleQueryService {
value.setCarrierCode(source.getCarrierCode()); value.setTrackingType(source.getTrackingType());
value.setTrackingNo(source.getTrackingNo()); value.setOfficialUrl(source.getOfficialUrl());
value.setAccessMode(source.getAccessMode()); value.setStatus(source.getStatus());
value.setTriggerSource(source.getTriggerSource()); value.setLatestStandardNode(source.getLatestStandardNode());
value.setLatestNodeObservedAt(source.getLatestNodeObservedAt()); value.setStoppedAt(source.getStoppedAt());
value.setStopReason(source.getStopReason()); value.setLeaseOwner(source.getLeaseOwner());
value.setLeaseUntil(source.getLeaseUntil()); value.setConsecutiveFailures(source.getConsecutiveFailures());
value.setWindowStartedAt(source.getWindowStartedAt()); value.setAttemptCount(source.getAttemptCount());
value.setNextAllowedAt(source.getNextAllowedAt()); value.setLastAttemptAt(source.getLastAttemptAt());
value.setLastSuccessAt(source.getLastSuccessAt()); value.setSourceUpdatedAt(source.getSourceUpdatedAt());
@@ -259,6 +400,12 @@ public class CarrierScheduleQueryService {
} catch (Exception ex) { throw new IllegalStateException("无法计算船司查询请求摘要", ex); }
}
private static String sha256(String value) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8))); }
catch (Exception exception) { throw new IllegalStateException(exception); }
}
private String json(Object value) {
if (value == null) return null;
try { return objectMapper.writeValueAsString(value); }
@@ -15,17 +15,27 @@ import java.util.List;
public class LoadingCompletedOutboxRepository {
@Resource private InboundOutboxMapper mapper;
public List<InboundOutboxEntity> pending(int limit, LocalDateTime now) {
public List<InboundOutboxEntity> pending(int limit, LocalDateTime now, LocalDateTime expiredLeaseBefore) {
return mapper.selectList(new LambdaQueryWrapper<InboundOutboxEntity>()
.eq(InboundOutboxEntity::getEventType, EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED)
.eq(InboundOutboxEntity::getPublishStatus, "PENDING")
.and(v -> v.isNull(InboundOutboxEntity::getNextRetryAt).or().le(InboundOutboxEntity::getNextRetryAt, now))
.and(status -> status.eq(InboundOutboxEntity::getPublishStatus, "PENDING")
.and(retry -> retry.isNull(InboundOutboxEntity::getNextRetryAt)
.or().le(InboundOutboxEntity::getNextRetryAt, now))
.or(stale -> stale.eq(InboundOutboxEntity::getPublishStatus, "PROCESSING")
.le(InboundOutboxEntity::getLockedAt, expiredLeaseBefore)))
.orderByAsc(InboundOutboxEntity::getCreatedAt).orderByAsc(InboundOutboxEntity::getId)
.last("LIMIT " + Math.min(Math.max(limit, 1), 100)));
}
public boolean claim(long id, String worker, LocalDateTime now) { return mapper.update(null, base(id, "PENDING")
public boolean claim(long id, String worker, LocalDateTime now, LocalDateTime expiredLeaseBefore) {
return mapper.update(null, new LambdaUpdateWrapper<InboundOutboxEntity>()
.eq(InboundOutboxEntity::getId, id)
.eq(InboundOutboxEntity::getEventType, EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED)
.and(status -> status.eq(InboundOutboxEntity::getPublishStatus, "PENDING")
.or(stale -> stale.eq(InboundOutboxEntity::getPublishStatus, "PROCESSING")
.le(InboundOutboxEntity::getLockedAt, expiredLeaseBefore)))
.set(InboundOutboxEntity::getPublishStatus, "PROCESSING").set(InboundOutboxEntity::getLockedBy, worker)
.set(InboundOutboxEntity::getLockedAt, now)) == 1; }
.set(InboundOutboxEntity::getLockedAt, now)) == 1;
}
public InboundOutboxEntity claimed(long id, String worker) { return mapper.selectOne(new LambdaQueryWrapper<InboundOutboxEntity>()
.eq(InboundOutboxEntity::getId, id).eq(InboundOutboxEntity::getEventType, EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED)
.eq(InboundOutboxEntity::getPublishStatus, "PROCESSING").eq(InboundOutboxEntity::getLockedBy, worker)); }
@@ -44,7 +44,14 @@ public class ContainerOverviewService {
List<TimelineEntry> timeline = timeline(id, task == null ? null : task.id(), exceptionHistory);
return new Overview(new ContainerBookingEntityView(booking.getId(), booking.getBookingNo(),
booking.getCabinetNo(), booking.getContainerNo(), booking.getPlanStatus(), booking.getWarehouseId(),
booking.getWarehouseName(), booking.getVoyageNo(), booking.getVesselName(), booking.getVersion()),
booking.getWarehouseName(), booking.getVoyageNo(), booking.getVesselName(), booking.getBillOfLadingNo(),
booking.getShippingCompanyId(), booking.getShippingCompanyName(), booking.getDeparturePortCode(),
booking.getDeparturePortName(), booking.getDestinationPortCode(), booking.getDestinationPortName(),
booking.getEstimatedSailingAt(), booking.getActualSailingAt(), booking.getEstimatedPortArrivalAt(),
booking.getActualPortArrivalAt(), booking.getEstimatedWarehouseArrivalAt(),
booking.getActualWarehouseArrivalAt(), booking.getUnloadingCompletedAt(),
booking.getUnloadingPhotoUploadedAt(), booking.getEstimatedEmptyReportedAt(),
booking.getActualEmptyReportedAt(), booking.getEmptyReportSourceVersion(), booking.getVersion()),
task == null ? booking.getPlanId() : task.loadingPlanId(), pda, documents,
new ExceptionState(latestException != null && "MARK".equals(latestException.action()),
latestException == null ? null : latestException.reason(),
@@ -22,13 +22,16 @@ public class LoadingCompletedOutboxRelay {
@Resource private EventBridgeClient client;
@Resource private ObjectMapper objectMapper;
@Value("${baiyue.security.internal-service-token}") private String internalToken;
@Value("${baiyue.lastleg.outbox-relay.lease-seconds:60}") private long leaseSeconds;
@Value("${baiyue.lastleg.outbox-relay.retry-seconds:30}") private long retrySeconds;
private final String workerId = UUID.randomUUID().toString();
@Scheduled(fixedDelayString = "${baiyue.lastleg.outbox-relay.fixed-delay-ms:5000}")
public void relay() {
LocalDateTime now=LocalDateTime.now();
for (InboundOutboxEntity candidate:repository.pending(50,now)) {
if(!repository.claim(candidate.getId(),workerId,now)) continue;
LocalDateTime expiredLeaseBefore=now.minusSeconds(Math.max(leaseSeconds,10));
for (InboundOutboxEntity candidate:repository.pending(50,now,expiredLeaseBefore)) {
if(!repository.claim(candidate.getId(),workerId,now,expiredLeaseBefore)) continue;
InboundOutboxEntity row=repository.claimed(candidate.getId(),workerId);
if(row==null) continue;
try {
@@ -39,9 +42,18 @@ public class LoadingCompletedOutboxRelay {
|| !event.eventId().equals(response.data().eventId())) throw new IllegalStateException("事件桥未确认装柜完成事件");
repository.published(row.getId(),workerId,LocalDateTime.now());
} catch(Exception exception) {
repository.retry(row.getId(),workerId,LocalDateTime.now().plusSeconds(30),exception.getMessage());
log.warn("装柜完成事件投递失败: eventKey={}, reason={}",row.getEventKey(),exception.getMessage());
String error=safeError(exception);
repository.retry(row.getId(),workerId,LocalDateTime.now().plusSeconds(Math.max(retrySeconds,1)),error);
log.warn("装柜完成事件投递失败: eventKey={}, reason={}",row.getEventKey(),error);
}
}
}
private String safeError(Exception exception) {
String value=exception.getMessage();
if(value==null || value.isBlank()) value=exception.getClass().getSimpleName();
if(internalToken!=null && !internalToken.isBlank()) value=value.replace(internalToken,"***");
value=value.replaceAll("[\\r\\n\\t]+"," ");
return value.length()>500?value.substring(0,500):value;
}
}
@@ -30,9 +30,12 @@ public class OverseasContainerEventConsumer {
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.sourceContainerBookingId() == null || payload.sourceContainerBookingId().isBlank()
|| !payload.sourceContainerBookingId().equals(event.aggregateId())) {
throw new IllegalArgumentException("事件 aggregateId 与 sourceContainerBookingId 不一致");
}
if (payload.containerNo() == null || payload.containerNo().isBlank()) {
throw new IllegalArgumentException("货柜号不能为空");
}
if (payload.unloadingCompletedAt() == null || payload.unloadingPhotoUploadedAt() == null) {
throw new IllegalArgumentException("卸柜完成时间和卸柜照片上传时间不能为空");
@@ -47,7 +50,7 @@ public class OverseasContainerEventConsumer {
if (!existing.getPayloadHash().equalsIgnoreCase(event.payloadHash())) {
throw new IllegalArgumentException("相同 eventId 对应的事件摘要冲突");
}
return requiredContainer(payload.containerNo());
return requiredContainer(payload.sourceContainerBookingId(), payload.containerNo());
}
ContainerEventInboxEntity row = inbox(event);
try {
@@ -55,17 +58,17 @@ public class OverseasContainerEventConsumer {
} catch (DuplicateKeyException duplicate) {
ContainerEventInboxEntity raced = inbox.selectById(event.eventId());
if (raced == null || !raced.getPayloadHash().equalsIgnoreCase(event.payloadHash())) throw duplicate;
return requiredContainer(payload.containerNo());
return requiredContainer(payload.sourceContainerBookingId(), payload.containerNo());
}
ContainerBookingEntity container = requiredContainer(payload.containerNo());
ContainerBookingEntity container = requiredContainer(payload.sourceContainerBookingId(), 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());
return requiredContainer(payload.sourceContainerBookingId(), payload.containerNo());
}
private void validateEnvelope(EventEnvelope event) {
@@ -97,9 +100,18 @@ public class OverseasContainerEventConsumer {
return row;
}
private ContainerBookingEntity requiredContainer(String containerNo) {
ContainerBookingEntity value = containers.selectActiveByContainerNo(containerNo);
private ContainerBookingEntity requiredContainer(String sourceContainerBookingId, String containerNo) {
long bookingId;
try {
bookingId = Long.parseLong(sourceContainerBookingId);
} catch (NumberFormatException exception) {
throw new IllegalArgumentException("国内货柜订舱 ID 无效", exception);
}
ContainerBookingEntity value = containers.selectActiveBookingForUpdate(bookingId);
if (value == null) throw new IllegalArgumentException("头程货柜不存在或不可用");
if (value.getContainerNo() == null || !value.getContainerNo().equals(containerNo)) {
throw new IllegalArgumentException("事件货柜号与国内订舱记录不一致");
}
return value;
}