This commit is contained in:
2026-08-21 16:04:16 +08:00
parent dceec6cbed
commit 0e7df9052a
68 changed files with 3985 additions and 267 deletions
@@ -20,9 +20,13 @@ public interface CarrierScheduleIntegrationClient {
}
record QueryRequest(String carrierCode, String trackingType, String trackingNo) { }
record QueryResult(String providerCode, String outcome, List<Observation> observations,
List<ExceptionObservation> exceptions,
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) { }
record ExceptionObservation(String providerEventId, String providerStatus, String exceptionCode,
boolean active, LocalDateTime observedAt, String locationCode,
String locationName, String sourceReference, String rawPayloadHash) { }
}
@@ -1,48 +0,0 @@
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) { }
}
@@ -8,6 +8,7 @@ import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CarrierDesc
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.ManualNodeRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Query;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.QueryView;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.History;
@@ -93,4 +94,16 @@ public class PlatformCarrierScheduleQueryController {
FirstlegScopeGuard.platform(user, service.customerId(queryId));
return ApiResponse.success(service.attachManualResult(queryId, user.userId(), user.username(), key, request));
}
@PostMapping("/{queryId}/manual-nodes")
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:manual')")
@BusinessAudit(event = "CARRIER_SCHEDULE_QUERY_MANUAL_NODE", resourceType = "CARRIER_SCHEDULE_QUERY",
resourceId = "#queryId", description = "人工补录船节点事实")
public ApiResponse<Detail> manualNode(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long queryId,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody ManualNodeRequest request) {
FirstlegScopeGuard.platform(user, service.customerId(queryId));
return ApiResponse.success(service.addManualNode(queryId, user.userId(), user.username(), key, request));
}
}
@@ -0,0 +1,20 @@
package com.baiyue.firstleg.portschedule.domain;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import java.util.Locale;
public enum CarrierScheduleExceptionCode {
CUSTOMS_HOLD,
TERMINAL_HOLD,
DOCUMENT_HOLD,
UNKNOWN_HOLD;
public static CarrierScheduleExceptionCode require(String value) {
try { return valueOf(value == null ? "" : value.trim().toUpperCase(Locale.ROOT)); }
catch (IllegalArgumentException exception) {
throw new BizException(ResultCode.BAD_REQUEST, "不支持的船节点异常:" + value);
}
}
}
@@ -9,10 +9,14 @@ import java.util.Locale;
public enum CarrierScheduleNode {
DEPARTED_WAREHOUSE(10),
AT_ORIGIN_PORT(20),
DEPARTED_ORIGIN_PORT(30),
IN_TRANSIT(40),
ARRIVED(50),
PICKUP_READY(60);
LOADED_ON_VESSEL(30),
DEPARTED_ORIGIN_PORT(40),
IN_TRANSIT(50),
ARRIVED_DESTINATION_PORT(60),
DISCHARGED_FROM_VESSEL(70),
PICKUP_READY(80),
PICKED_UP(90),
EMPTY_RETURNED(100);
private final int sequence;
@@ -16,9 +16,9 @@ public final class CarrierScheduleQueryDtos {
public record Query(@Positive Long customerId, @Positive Long oceanBookingId,
@Positive Long containerBookingId, @Size(max = 16) String carrierCode,
@Size(max = 24) String status, Boolean expired,
@Size(max = 128) String keyword, @Min(1) long page,
@Min(1) @Max(200) long size) {
public Query { if (page == 0) page = 1; if (size == 0) size = 20; }
@Size(max = 128) String keyword, @Min(1) Long page,
@Min(1) @Max(200) Long size) {
public Query { if (page == null || page == 0) page = 1L; if (size == null || size == 0) size = 20L; }
}
public record CreateRequest(@NotNull @Min(0) @Max(0) Integer expectedVersion,
@@ -34,6 +34,15 @@ public final class CarrierScheduleQueryDtos {
@NotNull @Positive Long portScheduleId,
@NotBlank @Size(max = 1000) String reason) {}
public record ManualNodeRequest(@NotNull @Min(0) Integer expectedVersion,
@NotBlank @Size(max = 32) String standardNode,
@NotNull LocalDateTime observedAt,
LocalDateTime estimatedAt,
LocalDateTime actualAt,
@Size(max = 32) String locationCode,
@Size(max = 128) String locationName,
@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,
@@ -46,6 +55,17 @@ public final class CarrierScheduleQueryDtos {
@Size(max = 500) String sourceReference,
@NotBlank @Size(min = 64, max = 64) String rawPayloadHash) {}
public record ProviderExceptionRequest(@NotBlank @Size(max = 32) String providerCode,
@NotBlank @Size(max = 128) String providerEventId,
@NotBlank @Size(max = 128) String providerStatus,
@NotBlank @Size(max = 32) String exceptionCode,
boolean active,
@NotNull LocalDateTime observedAt,
@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) {}
@@ -53,21 +73,28 @@ public final class CarrierScheduleQueryDtos {
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,
String latestActualNode, LocalDateTime latestActualAt,
LocalDateTime lastSuccessAt, LocalDateTime sourceUpdatedAt, LocalDateTime expiresAt,
Long resultPortScheduleId, String manualReason, String lastErrorCode,
String lastErrorMessage, LocalDateTime stoppedAt, String stopReason, Integer version) {}
public record QueryView(Task query, boolean expired) {}
public record NodeFact(Long id, String providerCode, String providerNodeCode, String standardNode,
public record NodeFact(Long id, String providerCode, String providerEventId,
String providerNodeCode, String standardNode,
int nodeSequence, LocalDateTime observedAt, LocalDateTime estimatedAt,
LocalDateTime actualAt, String locationCode, String locationName,
String sourceReference, String rawPayloadHash, LocalDateTime createdAt) {}
public record ExceptionFact(Long id, String providerCode, String providerEventId,
String providerStatus, String exceptionCode, boolean active,
LocalDateTime observedAt, 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) {}
public record Detail(QueryView query, List<NodeFact> nodes, List<ExceptionFact> exceptions,
List<History> histories) {}
}
@@ -0,0 +1,31 @@
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_exception_fact")
public class CarrierScheduleExceptionFactEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long queryId;
private String eventKey;
private String providerCode;
private String providerEventId;
private String providerStatus;
private String exceptionCode;
private Boolean active;
private LocalDateTime observedAt;
private String locationCode;
private String locationName;
private String sourceReference;
private String rawPayloadHash;
@TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt;
}
@@ -18,6 +18,7 @@ public class CarrierScheduleNodeFactEntity {
private Long queryId;
private String eventKey;
private String providerCode;
private String providerEventId;
private String providerNodeCode;
private String standardNode;
private Integer nodeSequence;
@@ -0,0 +1,23 @@
package com.baiyue.firstleg.portschedule.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleExceptionFactEntity;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
@Mapper
public interface CarrierScheduleExceptionFactMapper extends BaseMapper<CarrierScheduleExceptionFactEntity> {
@Insert("""
INSERT INTO fl_carrier_schedule_exception_fact
(id,query_id,event_key,provider_code,provider_event_id,provider_status,exception_code,active,
observed_at,location_code,location_name,source_reference,raw_payload_hash,created_at)
VALUES
(#{value.id},#{value.queryId},#{value.eventKey},#{value.providerCode},#{value.providerEventId},
#{value.providerStatus},#{value.exceptionCode},#{value.active},#{value.observedAt},
#{value.locationCode},#{value.locationName},#{value.sourceReference},#{value.rawPayloadHash},
CURRENT_TIMESTAMP(3))
ON DUPLICATE KEY UPDATE id=id
""")
int insertIfAbsent(@Param("value") CarrierScheduleExceptionFactEntity value);
}
@@ -10,10 +10,10 @@ import org.apache.ibatis.annotations.Param;
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,
(id,query_id,event_key,provider_code,provider_event_id,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.id},#{value.queryId},#{value.eventKey},#{value.providerCode},#{value.providerEventId},#{value.providerNodeCode},
#{value.standardNode},#{value.nodeSequence},#{value.observedAt},#{value.estimatedAt},#{value.actualAt},
#{value.locationCode},#{value.locationName},#{value.sourceReference},#{value.rawPayloadHash},
CURRENT_TIMESTAMP(3))
@@ -48,7 +48,7 @@ public interface CarrierScheduleQueryMapper extends BaseMapper<CarrierScheduleQu
@Select("""
SELECT * FROM fl_carrier_schedule_query
WHERE status IN ('READY','MANUAL_REQUIRED','ARRIVED','PICKUP_READY')
WHERE status IN ('READY','MANUAL_REQUIRED')
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}
@@ -58,7 +58,7 @@ public interface CarrierScheduleQueryMapper extends BaseMapper<CarrierScheduleQu
@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')
WHERE id=#{id} AND status IN ('READY','MANUAL_REQUIRED')
AND next_allowed_at IS NOT NULL AND next_allowed_at&lt;=#{now}
AND (lease_until IS NULL OR lease_until&lt;#{now})
""")
@@ -10,11 +10,13 @@ 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.entity.CarrierScheduleExceptionFactEntity;
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 com.baiyue.firstleg.portschedule.mapper.CarrierScheduleExceptionFactMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
@@ -28,6 +30,7 @@ public class CarrierScheduleQueryRepository {
@Resource private CarrierScheduleQueryHistoryMapper histories;
@Resource private CarrierScheduleQueryOperationMapper operations;
@Resource private CarrierScheduleNodeFactMapper nodes;
@Resource private CarrierScheduleExceptionFactMapper exceptions;
public PageResult<CarrierScheduleQueryEntity> page(Query query) {
return PageResult.from(queries.selectPage(new Page<>(query.page(), query.size()), query));
@@ -67,6 +70,15 @@ public class CarrierScheduleQueryRepository {
.orderByAsc(CarrierScheduleNodeFactEntity::getId));
}
public boolean insertIfAbsent(CarrierScheduleNodeFactEntity value) { return nodes.insertIgnore(value) == 1; }
public List<CarrierScheduleExceptionFactEntity> exceptions(long id) {
return exceptions.selectList(new LambdaQueryWrapper<CarrierScheduleExceptionFactEntity>()
.eq(CarrierScheduleExceptionFactEntity::getQueryId, id)
.orderByAsc(CarrierScheduleExceptionFactEntity::getObservedAt)
.orderByAsc(CarrierScheduleExceptionFactEntity::getId));
}
public boolean insertIfAbsent(CarrierScheduleExceptionFactEntity value) {
return exceptions.insertIfAbsent(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;
}
@@ -4,6 +4,7 @@ import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CarrierDescriptor;
import org.springframework.stereotype.Component;
import org.springframework.beans.factory.annotation.Value;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
@@ -16,15 +17,27 @@ import java.util.Map;
@Component
public class CarrierScheduleAdapterRegistry {
private static final Map<String, Definition> DEFINITIONS = definitions();
@Value("${baiyue.carrier-schedule.providers.matson-automated:false}")
private boolean matsonAutomated;
@Value("${baiyue.carrier-schedule.providers.hede-automated:false}")
private boolean hedeAutomated;
@Value("${baiyue.carrier-schedule.providers.sml-automated:false}")
private boolean smlAutomated;
@Value("${baiyue.carrier-schedule.providers.zim-official-api-enabled:false}")
private boolean zimOfficialApiEnabled;
@Value("${baiyue.carrier-schedule.providers.cosco-official-api-enabled:false}")
private boolean coscoOfficialApiEnabled;
@Value("${baiyue.carrier-schedule.providers.emc-automated:false}")
private boolean emcAutomated;
public List<CarrierDescriptor> descriptors() {
return DEFINITIONS.values().stream().map(Definition::descriptor).toList();
return DEFINITIONS.values().stream().map(value -> value.descriptor(automated(value))).toList();
}
public PreparedAttempt prepare(String carrierCode, String trackingType, String trackingNo) {
Definition definition = required(carrierCode);
return new PreparedAttempt(definition.code(), definition.accessMode(),
officialUrl(definition, trackingType, trackingNo), "MANUAL_REQUIRED",
officialUrl(definition, trackingType, trackingNo), automated(definition) ? "READY" : "MANUAL_REQUIRED",
definition.coverageGapCode(), definition.manualReason());
}
@@ -52,9 +65,6 @@ public class CarrierScheduleAdapterRegistry {
private static String officialUrl(Definition definition, String trackingType, String trackingNo) {
String encoded = URLEncoder.encode(trackingNo.trim(), StandardCharsets.UTF_8);
if ("ZIM".equals(definition.code())) {
return definition.officialUrl() + "?consnumber=" + encoded;
}
if ("EMC".equals(definition.code())) {
String type = "CONTAINER_NO".equalsIgnoreCase(trackingType) ? "cntr" : "bl";
return definition.officialUrl() + "?c=" + encoded + "&t=" + type + "&url=EVERGREEN";
@@ -65,26 +75,40 @@ public class CarrierScheduleAdapterRegistry {
private static Map<String, Definition> definitions() {
Map<String, Definition> values = new LinkedHashMap<>();
add(values, new Definition("MATSON", "美森", "https://www.matson.com/shipment-tracking.html",
"PUBLIC_FORM", "NO_DOCUMENTED_STRUCTURED_API", "官网仅公开交互表单,当前需人工查询并录入结果"));
"PUBLIC_WEB_PROXY", null, null));
add(values, new Definition("SML", "SML", "https://esvc.smlines.com/smline/CUP_HOM_3301.do?sessLocale=zh",
"PUBLIC_FORM", "NO_DOCUMENTED_STRUCTURED_API", "官网未提供稳定公开结构化接口,当前需人工查询并录入结果"));
add(values, new Definition("ZIM", "ZIM", "https://www.zimchina.com/tools/track-a-shipment",
"HCAPTCHA", "HCAPTCHA_REQUIRED", "官网启用hCaptcha,禁止绕过验证码,必须人工查询"));
add(values, new Definition("COSCO", "COSCO", "https://elines.coscoshipping.com/ebusiness/",
"DYNAMIC_IFRAME", "DYNAMIC_IFRAME_REQUIRED", "官网使用动态页面/iframe,未发现稳定公开API,必须人工查询"));
"PUBLIC_FORM", null, null));
add(values, new Definition("ZIM", "ZIM", "https://api.zim.com/",
"OFFICIAL_API", "OFFICIAL_API_CREDENTIALS_REQUIRED",
"ZIM Cargo Tracking API 待配置订阅凭据及来源授权,启用前必须人工查询"));
add(values, new Definition("COSCO", "COSCO", "https://cop.lines.coscoshipping.com/copPortal/",
"OFFICIAL_API", "OFFICIAL_API_CREDENTIALS_REQUIRED",
"COSCO Shipping Open Platform Cargo Tracking API 待注册订阅及凭据,启用前必须人工查询"));
add(values, new Definition("EMC", "EMC", "https://www.evergreen-shipping.cn/servlet/TDB1_CargoTracking.do",
"PUBLIC_FORM", "NO_DOCUMENTED_STRUCTURED_API", "官网仅公开查询表单,当前需人工确认页面结果后录入"));
add(values, new Definition("HEDE", "合德", "http://elines.hedehk.com/cargoDynamic",
"PUBLIC_FORM", "NO_DOCUMENTED_STRUCTURED_API", "官网未提供稳定公开结构化接口,当前需人工查询并录入结果"));
"PUBLIC_HTML", null, null));
return Collections.unmodifiableMap(values);
}
private static void add(Map<String, Definition> values, Definition value) { values.put(value.code(), value); }
private boolean automated(Definition definition) {
return switch (definition.code()) {
case "MATSON" -> matsonAutomated;
case "SML" -> smlAutomated;
case "ZIM" -> zimOfficialApiEnabled;
case "COSCO" -> coscoOfficialApiEnabled;
case "EMC" -> emcAutomated;
case "HEDE" -> hedeAutomated;
default -> false;
};
}
private record Definition(String code, String name, String officialUrl, String accessMode,
String coverageGapCode, String manualReason) {
CarrierDescriptor descriptor() {
return new CarrierDescriptor(code, name, officialUrl, accessMode, false, true,
CarrierDescriptor descriptor(boolean automated) {
return new CarrierDescriptor(code, name, officialUrl, accessMode, automated, !automated,
coverageGapCode, manualReason);
}
boolean matches(String value) {
@@ -3,6 +3,7 @@ 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.dto.CarrierScheduleQueryDtos.ProviderExceptionRequest;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity;
import com.baiyue.firstleg.portschedule.repository.CarrierScheduleQueryRepository;
import org.springframework.beans.factory.annotation.Value;
@@ -61,6 +62,14 @@ public class CarrierScheduleQueryScheduler {
observation.actualAt(), observation.locationCode(), observation.locationName(),
observation.sourceReference(), observation.rawPayloadHash()));
}
List<CarrierScheduleIntegrationClient.ExceptionObservation> exceptions = result.exceptions() == null
? List.of() : result.exceptions();
for (var exception : exceptions) {
service.applyProviderException(candidate.getId(), new ProviderExceptionRequest(
result.providerCode(), exception.providerEventId(), exception.providerStatus(),
exception.exceptionCode(), exception.active(), exception.observedAt(), exception.locationCode(),
exception.locationName(), exception.sourceReference(), exception.rawPayloadHash()));
}
service.completeScheduledAttempt(candidate.getId(), result.outcome(), result.errorCode(),
result.errorMessage());
} catch (RuntimeException exception) {
@@ -9,17 +9,23 @@ import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CarrierDesc
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.ManualNodeRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Query;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.QueryView;
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.ExceptionFact;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderExceptionRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Task;
import com.baiyue.firstleg.portschedule.domain.CarrierScheduleNode;
import com.baiyue.firstleg.portschedule.domain.CarrierScheduleExceptionCode;
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.entity.CarrierScheduleExceptionFactEntity;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryAttemptEntity;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.portschedule.entity.PortScheduleEntity;
import com.baiyue.firstleg.portschedule.repository.CarrierScheduleQueryRepository;
@@ -128,7 +134,6 @@ public class CarrierScheduleQueryService {
@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,
@@ -136,40 +141,83 @@ public class CarrierScheduleQueryService {
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);
boolean advanced = advanceActualNode(value, node, request.actualAt(), request.observedAt(),
0L, "carrier-scheduler");
CarrierScheduleQueryEntity after = advanced ? required(id) : value;
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) {
public Detail applyProviderException(long id, ProviderExceptionRequest request) {
CarrierScheduleQueryEntity value = requiredLocked(id);
if ("STOPPED".equals(value.getStatus())) return detail(value);
CarrierScheduleExceptionCode code = CarrierScheduleExceptionCode.require(request.exceptionCode());
if (!addException(id, request.providerCode(), request.providerEventId(), request.providerStatus(), code,
request.active(), request.observedAt(), request.locationCode(), request.locationName(),
request.sourceReference(), request.rawPayloadHash())) {
return detail(value);
}
addHistory(id, "EXCEPTION_OBSERVED", value.getVersion(), value.getVersion(), code.name(), value, value,
0L, "carrier-scheduler");
return detail(value);
}
@Transactional
public Detail addManualNode(long id, long operatorId, String operatorName, String key,
ManualNodeRequest request) {
String operation = "MANUAL_NODE";
Detail replay = claimOrReplay(id, operatorId, operation, key, hash(operation, id, request));
if (replay != null) return replay;
if (request.estimatedAt() == null && request.actualAt() == null) {
throw badRequest("estimatedAt和actualAt至少提供一个");
}
CarrierScheduleQueryEntity value = requiredLockedVersion(id, request.expectedVersion());
CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode());
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);
String providerEventId = sha256("MANUAL|" + id + '|' + key);
if (!addNode(id, "MANUAL", providerEventId, node.name(), node, request.observedAt(),
request.estimatedAt(), request.actualAt(), request.locationCode(), request.locationName(),
"MANUAL:" + operatorId, hash(operation, id, request))) {
return complete(id, operatorId, key, detail(value));
}
boolean advanced = advanceActualNode(value, node, request.actualAt(), request.observedAt(),
operatorId, operatorName);
CarrierScheduleQueryEntity after = advanced ? required(id) : value;
addHistory(id, operation, before.getVersion(), after.getVersion(), request.reason(), before, after,
operatorId, operatorName);
return complete(id, operatorId, key, detail(after));
}
private boolean advanceActualNode(CarrierScheduleQueryEntity value, CarrierScheduleNode node,
LocalDateTime actualAt, LocalDateTime observedAt,
long operatorId, String operatorName) {
if (actualAt == null || "STOPPED".equals(value.getStatus())) return false;
CarrierScheduleNode current = value.getLatestStandardNode() == null ? null
: CarrierScheduleNode.require(value.getLatestStandardNode());
boolean advances = current == null || node.sequence() > current.sequence()
|| node.sequence() == current.sequence() && (value.getLatestNodeObservedAt() == null
|| actualAt.isAfter(value.getLatestNodeObservedAt()));
if (advances) {
value.setLatestStandardNode(node.name()); value.setLatestNodeObservedAt(actualAt);
value.setLastSuccessAt(now()); value.setSourceUpdatedAt(observedAt);
value.setLastAttemptStatus("SUCCESS"); value.setLastErrorCode(null); value.setLastErrorMessage(null);
if (node == CarrierScheduleNode.EMPTY_RETURNED) {
value.setStatus("STOPPED"); value.setStoppedAt(now()); value.setStopReason("已还柜");
value.setLeaseOwner(null); value.setLeaseUntil(null); value.setNextAllowedAt(null);
} else {
value.setStatus("READY"); value.setNextAllowedAt(now().plusDays(WINDOW_DAYS));
}
if (repository.updateState(value, value.getVersion(), operatorId, operatorName) != 1) throw concurrent();
return true;
}
return false;
}
@Transactional
public void completeScheduledAttempt(long id, String outcome, String errorCode, String errorMessage) {
CarrierScheduleQueryEntity value = requiredLocked(id);
insertAttempt(value, outcome, errorCode, errorMessage);
if ("STOPPED".equals(value.getStatus())) return;
CarrierScheduleQueryEntity before = copy(value);
LocalDateTime now = now();
@@ -177,6 +225,7 @@ public class CarrierScheduleQueryService {
value.setLastAttemptStatus(outcome); value.setLastErrorCode(trim(errorCode));
value.setLastErrorMessage(trim(errorMessage));
if ("SUCCESS".equals(outcome) || "NO_CHANGE".equals(outcome)) {
value.setStatus("READY");
value.setConsecutiveFailures(0); value.setAttemptCount(0); value.setWindowStartedAt(now);
value.setNextAllowedAt(now.plusDays(WINDOW_DAYS));
} else {
@@ -189,9 +238,7 @@ public class CarrierScheduleQueryService {
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.setStatus("MANUAL_REQUIRED");
value.setNextAllowedAt(now.plusDays(WINDOW_DAYS));
} else {
long minutes = Math.min(24L * 60L, 15L << Math.min(failures - 1, 6));
@@ -208,6 +255,20 @@ public class CarrierScheduleQueryService {
"carrier-scheduler");
}
private void insertAttempt(CarrierScheduleQueryEntity value, String outcome,
String errorCode, String errorMessage) {
CarrierScheduleQueryAttemptEntity attempt = new CarrierScheduleQueryAttemptEntity();
attempt.setId(IdWorker.getId()); attempt.setQueryId(value.getId());
attempt.setWindowAttemptNo(Math.max(1, Math.min(MAX_ATTEMPTS, count(value.getAttemptCount()) + 1)));
attempt.setCarrierCode(value.getCarrierCode()); attempt.setAccessMode(value.getAccessMode());
attempt.setOfficialUrl(value.getOfficialUrl()); attempt.setAttemptStatus(outcome);
boolean manual = "MANUAL_REQUIRED".equals(outcome);
attempt.setCoverageGap(manual); attempt.setCoverageGapCode(manual ? trim(errorCode) : null);
attempt.setManualReason(manual ? trim(errorMessage) : null); attempt.setErrorCode(trim(errorCode));
attempt.setErrorMessage(trim(errorMessage)); attempt.setOperatorId(0L);
attempt.setOperatorName("carrier-scheduler"); repository.insert(attempt);
}
@Transactional
public Detail create(long operatorId, String operatorName, String key, CreateRequest request) {
String operation = "CREATE";
@@ -309,16 +370,24 @@ public class CarrierScheduleQueryService {
private Detail detail(CarrierScheduleQueryEntity value) {
return new Detail(view(value, now()), repository.nodes(value.getId()).stream().map(this::node).toList(),
repository.exceptions(value.getId()).stream().map(this::exception).toList(),
repository.histories(value.getId()).stream().map(this::history).toList());
}
private NodeFact node(CarrierScheduleNodeFactEntity value) {
return new NodeFact(value.getId(), value.getProviderCode(), value.getProviderNodeCode(),
return new NodeFact(value.getId(), value.getProviderCode(), value.getProviderEventId(), value.getProviderNodeCode(),
value.getStandardNode(), value.getNodeSequence(), value.getObservedAt(), value.getEstimatedAt(),
value.getActualAt(), value.getLocationCode(), value.getLocationName(), value.getSourceReference(),
value.getRawPayloadHash(), value.getCreatedAt());
}
private ExceptionFact exception(CarrierScheduleExceptionFactEntity value) {
return new ExceptionFact(value.getId(), value.getProviderCode(), value.getProviderEventId(),
value.getProviderStatus(), value.getExceptionCode(), Boolean.TRUE.equals(value.getActive()),
value.getObservedAt(), 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());
@@ -332,6 +401,7 @@ public class CarrierScheduleQueryService {
fact.setId(IdWorker.getId()); fact.setQueryId(queryId);
fact.setEventKey(providerCode.trim().toUpperCase(Locale.ROOT) + ':' + providerEventId.trim());
fact.setProviderCode(providerCode.trim().toUpperCase(Locale.ROOT));
fact.setProviderEventId(providerEventId.trim());
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));
@@ -339,6 +409,21 @@ public class CarrierScheduleQueryService {
return repository.insertIfAbsent(fact);
}
private boolean addException(long queryId, String providerCode, String providerEventId,
String providerStatus, CarrierScheduleExceptionCode code, boolean active,
LocalDateTime observedAt, String locationCode, String locationName,
String sourceReference, String rawPayloadHash) {
CarrierScheduleExceptionFactEntity fact = new CarrierScheduleExceptionFactEntity();
fact.setId(IdWorker.getId()); fact.setQueryId(queryId);
fact.setEventKey(providerCode.trim().toUpperCase(Locale.ROOT) + ':' + providerEventId.trim());
fact.setProviderCode(providerCode.trim().toUpperCase(Locale.ROOT));
fact.setProviderEventId(providerEventId.trim()); fact.setProviderStatus(providerStatus.trim());
fact.setExceptionCode(code.name()); fact.setActive(active); fact.setObservedAt(observedAt);
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);