feat(firstleg): add compliant carrier schedule queries

This commit is contained in:
2026-08-10 12:20:05 +08:00
parent 5c1c6a1a07
commit edd5108625
21 changed files with 1654 additions and 0 deletions
@@ -0,0 +1,109 @@
package com.baiyue.firstleg.portschedule.controller;
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.service.CarrierScheduleQueryService;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Positive;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
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.util.List;
@Validated
@RestController
@Tag(name = "船公司官网查询任务")
@RequestMapping("/api/platform/v1/carrier-schedule-queries")
public class PlatformCarrierScheduleQueryController {
@Resource private CarrierScheduleQueryService service;
@Operation(summary = "查询六家船公司合规适配策略")
@GetMapping("/carriers")
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:view')")
public ApiResponse<List<CarrierDescriptor>> carriers(@AuthenticationPrincipal AuthenticatedUser user) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.carriers());
}
@GetMapping
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:list')")
public ApiResponse<PageResult<QueryView>> page(@AuthenticationPrincipal AuthenticatedUser user,
@Valid Query query) {
FirstlegScopeGuard.platform(user);
if (query.customerId() != null) FirstlegScopeGuard.platform(user, query.customerId());
return ApiResponse.success(service.page(query));
}
@GetMapping("/{queryId}")
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:view')")
public ApiResponse<Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long queryId) {
FirstlegScopeGuard.platform(user, service.customerId(queryId));
return ApiResponse.success(service.detail(queryId));
}
@GetMapping("/{queryId}/history")
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:view')")
public ApiResponse<List<CarrierScheduleQueryHistoryEntity>> history(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long queryId) {
FirstlegScopeGuard.platform(user, service.customerId(queryId));
return ApiResponse.success(service.history(queryId));
}
@PostMapping
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:create')")
@BusinessAudit(event = "CARRIER_SCHEDULE_QUERY_CREATE", resourceType = "CARRIER_SCHEDULE_QUERY",
description = "创建船司官网查询任务", captureBefore = false)
public ApiResponse<Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody CreateRequest request) {
FirstlegScopeGuard.platform(user, request.customerId());
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",
resourceId = "#queryId", description = "关联人工船期查询结果")
public ApiResponse<Detail> manualResult(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long queryId,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody ManualResultRequest request) {
FirstlegScopeGuard.platform(user, service.customerId(queryId));
return ApiResponse.success(service.attachManualResult(queryId, user.userId(), user.username(), key, request));
}
}
@@ -0,0 +1,52 @@
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;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import java.time.LocalDateTime;
import java.util.List;
public final class CarrierScheduleQueryDtos {
private 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; }
}
public record CreateRequest(@NotNull @Min(0) @Max(0) Integer expectedVersion,
@NotNull @Positive Long customerId,
@NotNull @Positive Long oceanBookingId,
@Positive Long containerBookingId,
@NotBlank @Size(max = 16) String carrierCode,
@NotBlank @Size(max = 32) String trackingType,
@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 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 Detail(QueryView query, List<CarrierScheduleQueryAttemptEntity> attempts,
List<CarrierScheduleQueryHistoryEntity> 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_query_attempt")
public class CarrierScheduleQueryAttemptEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long queryId;
private Integer windowAttemptNo;
private String carrierCode;
private String accessMode;
private String officialUrl;
private String attemptStatus;
private Boolean coverageGap;
private String coverageGapCode;
private String manualReason;
private String errorCode;
private String errorMessage;
private Long operatorId;
private String operatorName;
@TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt;
}
@@ -0,0 +1,49 @@
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 com.baomidou.mybatisplus.annotation.Version;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter
@Setter
@TableName("fl_carrier_schedule_query")
public class CarrierScheduleQueryEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private String queryNo;
private Long customerId;
private Long oceanBookingId;
private Long containerBookingId;
private String carrierCode;
private String trackingType;
private String trackingNo;
private String officialUrl;
private String accessMode;
private String status;
private LocalDateTime windowStartedAt;
private Integer attemptCount;
private LocalDateTime nextAllowedAt;
private LocalDateTime lastAttemptAt;
private LocalDateTime lastSuccessAt;
private LocalDateTime sourceUpdatedAt;
private LocalDateTime expiresAt;
private Long resultPortScheduleId;
private String lastAttemptStatus;
private String manualReason;
private String lastErrorCode;
private String lastErrorMessage;
private String remark;
private Long createdBy;
private String createdByName;
@TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt;
private Long updatedBy;
private String updatedByName;
@TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updatedAt;
@Version @TableField(fill = FieldFill.INSERT) private Integer version;
}
@@ -0,0 +1,28 @@
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_query_history")
public class CarrierScheduleQueryHistoryEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long queryId;
private String action;
private Integer fromVersion;
private Integer toVersion;
private String reason;
private String beforeSnapshot;
private String afterSnapshot;
private Long operatorId;
private String operatorName;
@TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt;
}
@@ -0,0 +1,27 @@
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_query_operation")
public class CarrierScheduleQueryOperationEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long queryId;
private Long operatorId;
private String operation;
private String idempotencyKey;
private String requestHash;
private String status;
private String resultJson;
@TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt;
private LocalDateTime completedAt;
}
@@ -0,0 +1,8 @@
package com.baiyue.firstleg.portschedule.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryAttemptEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface CarrierScheduleQueryAttemptMapper extends BaseMapper<CarrierScheduleQueryAttemptEntity> {}
@@ -0,0 +1,8 @@
package com.baiyue.firstleg.portschedule.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryHistoryEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface CarrierScheduleQueryHistoryMapper extends BaseMapper<CarrierScheduleQueryHistoryEntity> {}
@@ -0,0 +1,53 @@
package com.baiyue.firstleg.portschedule.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Query;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface CarrierScheduleQueryMapper extends BaseMapper<CarrierScheduleQueryEntity> {
@Select("""
<script>
SELECT * FROM fl_carrier_schedule_query
WHERE (#{query.customerId} IS NULL OR customer_id=#{query.customerId})
AND (#{query.oceanBookingId} IS NULL OR ocean_booking_id=#{query.oceanBookingId})
AND (#{query.containerBookingId} IS NULL OR container_booking_id=#{query.containerBookingId})
AND (#{query.carrierCode} IS NULL OR carrier_code=UPPER(#{query.carrierCode}))
AND (#{query.status} IS NULL OR status=UPPER(#{query.status}))
AND (#{query.expired} IS NULL
OR (#{query.expired}=TRUE AND (last_success_at IS NULL OR expires_at IS NULL OR expires_at&lt;=CURRENT_TIMESTAMP(3)))
OR (#{query.expired}=FALSE AND last_success_at IS NOT NULL AND expires_at&gt;CURRENT_TIMESTAMP(3)))
AND (#{query.keyword} IS NULL OR query_no LIKE CONCAT('%',#{query.keyword},'%')
OR tracking_no LIKE CONCAT('%',#{query.keyword},'%'))
ORDER BY updated_at DESC,id DESC
</script>
""")
Page<CarrierScheduleQueryEntity> selectPage(Page<CarrierScheduleQueryEntity> page,
@Param("query") Query query);
@Select("SELECT * FROM fl_carrier_schedule_query WHERE id=#{id} FOR UPDATE")
CarrierScheduleQueryEntity lockById(@Param("id") long id);
@Update("""
UPDATE fl_carrier_schedule_query
SET official_url=#{value.officialUrl},access_mode=#{value.accessMode},status=#{value.status},
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},
expires_at=#{value.expiresAt},result_port_schedule_id=#{value.resultPortScheduleId},
last_attempt_status=#{value.lastAttemptStatus},manual_reason=#{value.manualReason},
last_error_code=#{value.lastErrorCode},last_error_message=#{value.lastErrorMessage},
updated_by=#{operatorId},updated_by_name=#{operatorName},updated_at=CURRENT_TIMESTAMP(3),
version=version+1
WHERE id=#{value.id} AND customer_id=#{value.customerId} AND version=#{expectedVersion}
""")
int updateState(@Param("value") CarrierScheduleQueryEntity value,
@Param("expectedVersion") int expectedVersion,
@Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
}
@@ -0,0 +1,28 @@
package com.baiyue.firstleg.portschedule.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryOperationEntity;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface CarrierScheduleQueryOperationMapper extends BaseMapper<CarrierScheduleQueryOperationEntity> {
@Insert("""
INSERT IGNORE INTO fl_carrier_schedule_query_operation
(id,query_id,operator_id,operation,idempotency_key,request_hash,status,created_at)
VALUES (#{id},#{queryId},#{operatorId},#{operation},#{key},#{hash},'PROCESSING',CURRENT_TIMESTAMP(3))
""")
int claim(@Param("id") long id, @Param("queryId") Long queryId,
@Param("operatorId") long operatorId, @Param("operation") String operation,
@Param("key") String key, @Param("hash") String hash);
@Update("""
UPDATE fl_carrier_schedule_query_operation
SET query_id=#{queryId},status='SUCCESS',result_json=#{resultJson},completed_at=CURRENT_TIMESTAMP(3)
WHERE operator_id=#{operatorId} AND idempotency_key=#{key} AND status='PROCESSING'
""")
int complete(@Param("queryId") long queryId, @Param("operatorId") long operatorId,
@Param("key") String key, @Param("resultJson") String resultJson);
}
@@ -0,0 +1,62 @@
package com.baiyue.firstleg.portschedule.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Query;
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.mapper.CarrierScheduleQueryAttemptMapper;
import com.baiyue.firstleg.portschedule.mapper.CarrierScheduleQueryHistoryMapper;
import com.baiyue.firstleg.portschedule.mapper.CarrierScheduleQueryMapper;
import com.baiyue.firstleg.portschedule.mapper.CarrierScheduleQueryOperationMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public class CarrierScheduleQueryRepository {
@Resource private CarrierScheduleQueryMapper queries;
@Resource private CarrierScheduleQueryAttemptMapper attempts;
@Resource private CarrierScheduleQueryHistoryMapper histories;
@Resource private CarrierScheduleQueryOperationMapper operations;
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 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);
}
public List<CarrierScheduleQueryAttemptEntity> attempts(long id) {
return attempts.selectList(new LambdaQueryWrapper<CarrierScheduleQueryAttemptEntity>()
.eq(CarrierScheduleQueryAttemptEntity::getQueryId, id)
.orderByAsc(CarrierScheduleQueryAttemptEntity::getCreatedAt)
.orderByAsc(CarrierScheduleQueryAttemptEntity::getId));
}
public void insert(CarrierScheduleQueryAttemptEntity value) { attempts.insert(value); }
public List<CarrierScheduleQueryHistoryEntity> histories(long id) {
return histories.selectList(new LambdaQueryWrapper<CarrierScheduleQueryHistoryEntity>()
.eq(CarrierScheduleQueryHistoryEntity::getQueryId, id)
.orderByAsc(CarrierScheduleQueryHistoryEntity::getCreatedAt)
.orderByAsc(CarrierScheduleQueryHistoryEntity::getId));
}
public void insert(CarrierScheduleQueryHistoryEntity value) { histories.insert(value); }
public boolean claimOperation(Long queryId, long operatorId, String operation, String key, String hash) {
return operations.claim(IdWorker.getId(), queryId, operatorId, operation, key, hash) == 1;
}
public CarrierScheduleQueryOperationEntity operation(long operatorId, String key) {
return operations.selectOne(new LambdaQueryWrapper<CarrierScheduleQueryOperationEntity>()
.eq(CarrierScheduleQueryOperationEntity::getOperatorId, operatorId)
.eq(CarrierScheduleQueryOperationEntity::getIdempotencyKey, key));
}
public int completeOperation(long queryId, long operatorId, String key, String resultJson) {
return operations.complete(queryId, operatorId, key, resultJson);
}
}
@@ -0,0 +1,86 @@
package com.baiyue.firstleg.portschedule.service;
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 java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
@Component
public class CarrierScheduleAdapterRegistry {
private static final Map<String, Definition> DEFINITIONS = definitions();
public List<CarrierDescriptor> descriptors() {
return DEFINITIONS.values().stream().map(Definition::descriptor).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",
definition.coverageGapCode(), definition.manualReason());
}
public String normalize(String carrierCode) {
return required(carrierCode).code();
}
private static Definition required(String carrierCode) {
String normalized = carrierCode == null ? "" : carrierCode.trim().toUpperCase(Locale.ROOT);
Definition value = DEFINITIONS.get(normalized);
if (value == null) {
throw new BizException(ResultCode.BAD_REQUEST,
"carrierCode仅支持MATSON、SML、ZIM、COSCO、EMC或HEDE");
}
return value;
}
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";
}
return definition.officialUrl();
}
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", "官网仅公开交互表单,当前需人工查询并录入结果"));
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,必须人工查询"));
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", "官网未提供稳定公开结构化接口,当前需人工查询并录入结果"));
return Collections.unmodifiableMap(values);
}
private static void add(Map<String, Definition> values, Definition value) { values.put(value.code(), value); }
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,
coverageGapCode, manualReason);
}
}
public record PreparedAttempt(String carrierCode, String accessMode, String officialUrl,
String status, String coverageGapCode, String manualReason) {}
}
@@ -0,0 +1,289 @@
package com.baiyue.firstleg.portschedule.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
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.entity.CarrierScheduleQueryEntity;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryHistoryEntity;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryOperationEntity;
import com.baiyue.firstleg.portschedule.entity.PortScheduleEntity;
import com.baiyue.firstleg.portschedule.repository.CarrierScheduleQueryRepository;
import com.baiyue.firstleg.portschedule.repository.PortScheduleRepository;
import com.baiyue.firstleg.portschedule.service.CarrierScheduleAdapterRegistry.PreparedAttempt;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Clock;
import java.time.LocalDateTime;
import java.util.HexFormat;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
@Service
public class CarrierScheduleQueryService {
static final int MAX_ATTEMPTS = 3;
static final int WINDOW_DAYS = 3;
private static final Set<String> TRACKING_TYPES = Set.of("BOOKING_NO", "BILL_OF_LADING", "CONTAINER_NO");
@Resource private CarrierScheduleQueryRepository repository;
@Resource private PortScheduleRepository portSchedules;
@Resource private CarrierScheduleAdapterRegistry adapters;
@Resource private ObjectMapper objectMapper;
private Clock clock = Clock.systemDefaultZone();
public List<CarrierDescriptor> carriers() { return adapters.descriptors(); }
public PageResult<QueryView> page(Query query) {
PageResult<CarrierScheduleQueryEntity> source = repository.page(query);
LocalDateTime now = now();
return new PageResult<>(source.records().stream().map(value -> view(value, now)).toList(),
source.page(), source.size(), source.total(), source.pages());
}
public Detail detail(long id) { return detail(required(id)); }
public List<CarrierScheduleQueryHistoryEntity> history(long id) {
required(id);
return repository.histories(id);
}
public long customerId(long id) { return required(id).getCustomerId(); }
@Transactional
public Detail create(long operatorId, String operatorName, String key, CreateRequest request) {
String operation = "CREATE";
Detail replay = claimOrReplay(null, operatorId, operation, key, hash(operation, null, request));
if (replay != null) return replay;
validateLinks(request.customerId(), request.oceanBookingId(), request.containerBookingId());
String carrierCode = adapters.normalize(request.carrierCode());
String trackingType = normalizeTrackingType(request.trackingType());
PreparedAttempt strategy = adapters.prepare(carrierCode, trackingType, request.trackingNo());
LocalDateTime now = now();
CarrierScheduleQueryEntity value = new CarrierScheduleQueryEntity();
value.setId(IdWorker.getId()); value.setQueryNo("CSQ" + value.getId());
value.setCustomerId(request.customerId()); value.setOceanBookingId(request.oceanBookingId());
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.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);
try { repository.insert(value); }
catch (DuplicateKeyException ex) { throw conflict("同一订舱、柜次、船司和跟踪号的查询任务已存在"); }
addHistory(value.getId(), operation, null, 0, request.remark(), null, value, operatorId, operatorName);
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) {
String operation = "MANUAL_RESULT";
Detail replay = claimOrReplay(id, operatorId, operation, key, hash(operation, id, request));
if (replay != null) return replay;
CarrierScheduleQueryEntity value = requiredLockedVersion(id, request.expectedVersion());
PortScheduleEntity schedule = portSchedules.find(request.portScheduleId());
if (schedule == null) throw new BizException(ResultCode.NOT_FOUND, "人工船期结果不存在");
if (!Objects.equals(schedule.getCustomerId(), value.getCustomerId())
|| !Objects.equals(schedule.getOceanBookingId(), value.getOceanBookingId())
|| !Objects.equals(schedule.getContainerBookingId(), value.getContainerBookingId())) {
throw badRequest("人工船期结果与查询任务的客户、订舱或柜次不一致");
}
CarrierScheduleQueryEntity before = copy(value);
LocalDateTime now = now();
value.setStatus("SUCCESS"); 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");
value.setManualReason(trim(request.reason())); value.setLastErrorCode(null); value.setLastErrorMessage(null);
if (repository.updateState(value, request.expectedVersion(), operatorId, operatorName) != 1) throw concurrent();
CarrierScheduleQueryEntity after = required(id);
addHistory(id, operation, before.getVersion(), after.getVersion(), request.reason(), before, after,
operatorId, operatorName);
return complete(id, operatorId, key, detail(after));
}
private void validateLinks(long customerId, long oceanBookingId, Long containerBookingId) {
OceanBookingEntity booking = portSchedules.oceanBooking(oceanBookingId);
if (booking == null) throw new BizException(ResultCode.NOT_FOUND, "关联订舱不存在");
if (!Objects.equals(booking.getCustomerId(), customerId)) throw badRequest("customerId与订舱客户不一致");
if (containerBookingId != null) {
if (portSchedules.containerBooking(containerBookingId) == null) {
throw new BizException(ResultCode.NOT_FOUND, "关联柜次不存在");
}
if (portSchedules.allocation(oceanBookingId, containerBookingId) == null) {
throw badRequest("containerBookingId未分配至该订舱");
}
}
}
private Detail claimOrReplay(Long id, long operatorId, String operation, String key, String requestHash) {
validateKey(key);
if (repository.claimOperation(id, operatorId, operation, key, requestHash)) return null;
CarrierScheduleQueryOperationEntity existing = repository.operation(operatorId, key);
if (existing == null || !requestHash.equals(existing.getRequestHash())) throw conflict("幂等键已用于其他请求");
if (!"SUCCESS".equals(existing.getStatus()) || !StringUtils.hasText(existing.getResultJson())) {
throw conflict("相同请求正在处理中,请稍后重试");
}
try { return objectMapper.readValue(existing.getResultJson(), Detail.class); }
catch (JsonProcessingException ex) { throw new IllegalStateException("无法读取船司查询幂等结果", ex); }
}
private Detail complete(long id, long operatorId, String key, Detail result) {
try {
if (repository.completeOperation(id, operatorId, key, objectMapper.writeValueAsString(result)) != 1) {
throw conflict("船司查询幂等请求完成状态冲突");
}
return result;
} catch (JsonProcessingException ex) { throw new IllegalStateException("无法保存船司查询幂等结果", ex); }
}
private void addHistory(long id, String action, Integer fromVersion, Integer toVersion, String reason,
Object before, Object after, long operatorId, String operatorName) {
CarrierScheduleQueryHistoryEntity history = new CarrierScheduleQueryHistoryEntity();
history.setId(IdWorker.getId()); history.setQueryId(id); history.setAction(action);
history.setFromVersion(fromVersion); history.setToVersion(toVersion); history.setReason(trim(reason));
history.setBeforeSnapshot(json(before)); history.setAfterSnapshot(json(after));
history.setOperatorId(operatorId); history.setOperatorName(operatorName); repository.insert(history);
}
private Detail detail(CarrierScheduleQueryEntity value) {
return new Detail(view(value, now()), repository.attempts(value.getId()), repository.histories(value.getId()));
}
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());
}
private CarrierScheduleQueryEntity required(long id) {
CarrierScheduleQueryEntity value = repository.find(id);
if (value == null) throw new BizException(ResultCode.NOT_FOUND, "船司查询任务不存在");
return value;
}
private CarrierScheduleQueryEntity requiredLockedVersion(long id, int version) {
CarrierScheduleQueryEntity value = repository.lock(id);
if (value == null) throw new BizException(ResultCode.NOT_FOUND, "船司查询任务不存在");
if (!Integer.valueOf(version).equals(value.getVersion())) throw concurrent();
return value;
}
private static CarrierScheduleQueryEntity copy(CarrierScheduleQueryEntity source) {
CarrierScheduleQueryEntity value = new CarrierScheduleQueryEntity();
value.setId(source.getId()); value.setQueryNo(source.getQueryNo()); value.setCustomerId(source.getCustomerId());
value.setOceanBookingId(source.getOceanBookingId()); value.setContainerBookingId(source.getContainerBookingId());
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.setWindowStartedAt(source.getWindowStartedAt()); value.setAttemptCount(source.getAttemptCount());
value.setNextAllowedAt(source.getNextAllowedAt()); value.setLastAttemptAt(source.getLastAttemptAt());
value.setLastSuccessAt(source.getLastSuccessAt()); value.setSourceUpdatedAt(source.getSourceUpdatedAt());
value.setExpiresAt(source.getExpiresAt()); value.setResultPortScheduleId(source.getResultPortScheduleId());
value.setLastAttemptStatus(source.getLastAttemptStatus()); value.setManualReason(source.getManualReason());
value.setLastErrorCode(source.getLastErrorCode()); value.setLastErrorMessage(source.getLastErrorMessage());
value.setRemark(source.getRemark()); value.setVersion(source.getVersion());
return value;
}
private String hash(String operation, Long id, Object request) {
try {
byte[] bytes = (operation + '|' + id + '|' + objectMapper.writeValueAsString(request))
.getBytes(StandardCharsets.UTF_8);
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes));
} catch (Exception ex) { throw new IllegalStateException("无法计算船司查询请求摘要", ex); }
}
private String json(Object value) {
if (value == null) return null;
try { return objectMapper.writeValueAsString(value); }
catch (JsonProcessingException ex) { throw new IllegalStateException("无法保存船司查询历史快照", ex); }
}
private static String normalizeTrackingType(String value) {
String normalized = value.trim().toUpperCase(Locale.ROOT);
if (!TRACKING_TYPES.contains(normalized)) {
throw badRequest("trackingType仅支持BOOKING_NO、BILL_OF_LADING或CONTAINER_NO");
}
return normalized;
}
private LocalDateTime now() { return LocalDateTime.now(clock); }
private static int count(Integer value) { return value == null ? 0 : value; }
private static void validateKey(String key) {
if (!StringUtils.hasText(key) || key.length() > 128) throw badRequest("Idempotency-Key不能为空且不能超过128个字符");
}
private static String trim(String value) {
if (value == null) return null;
String result = value.trim();
return result.isEmpty() ? null : result;
}
private static BizException badRequest(String message) { return new BizException(ResultCode.BAD_REQUEST, message); }
private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); }
private static BizException concurrent() { return conflict("数据已被其他人修改,请刷新后重试"); }
}
@@ -0,0 +1,226 @@
SET NAMES utf8mb4;
USE baiyue_firstleg;
CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query (
id BIGINT UNSIGNED NOT NULL,
query_no VARCHAR(40) NOT NULL,
customer_id BIGINT UNSIGNED NOT NULL COMMENT '当前产品客户ID',
ocean_booking_id BIGINT UNSIGNED NOT NULL,
container_booking_id BIGINT UNSIGNED NULL,
container_scope_id BIGINT UNSIGNED GENERATED ALWAYS AS (COALESCE(container_booking_id,0)) STORED,
carrier_code VARCHAR(16) NOT NULL COMMENT 'MATSON/SML/ZIM/COSCO/EMC/HEDE',
tracking_type VARCHAR(32) NOT NULL COMMENT 'BOOKING_NO/BILL_OF_LADING/CONTAINER_NO',
tracking_no VARCHAR(128) NOT NULL,
official_url VARCHAR(500) NOT NULL,
access_mode VARCHAR(32) NOT NULL COMMENT 'PUBLIC_FORM/HCAPTCHA/DYNAMIC_IFRAME',
status VARCHAR(24) NOT NULL COMMENT 'READY/MANUAL_REQUIRED/SUCCESS',
window_started_at DATETIME(3) NOT NULL,
attempt_count INT UNSIGNED NOT NULL DEFAULT 0,
next_allowed_at DATETIME(3) NOT NULL,
last_attempt_at DATETIME(3) NULL,
last_success_at DATETIME(3) NULL,
source_updated_at DATETIME(3) NULL,
expires_at DATETIME(3) NULL,
result_port_schedule_id BIGINT UNSIGNED NULL,
last_attempt_status VARCHAR(24) NULL,
manual_reason VARCHAR(1000) NULL,
last_error_code VARCHAR(64) NULL,
last_error_message VARCHAR(1000) NULL,
remark VARCHAR(1000) NULL,
created_by BIGINT UNSIGNED NOT NULL,
created_by_name VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_by BIGINT UNSIGNED NOT NULL,
updated_by_name VARCHAR(64) NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
version INT UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (id),
UNIQUE KEY uk_carrier_schedule_query_no (query_no),
UNIQUE KEY uk_carrier_schedule_query_scope
(customer_id,ocean_booking_id,container_scope_id,carrier_code,tracking_type,tracking_no),
KEY idx_carrier_schedule_query_due (status,next_allowed_at,id),
KEY idx_carrier_schedule_query_customer (customer_id,updated_at,id),
KEY idx_carrier_schedule_query_expiry (expires_at,id),
CONSTRAINT fk_carrier_schedule_query_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id),
CONSTRAINT fk_carrier_schedule_query_container FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking(id),
CONSTRAINT fk_carrier_schedule_query_result FOREIGN KEY (result_port_schedule_id) REFERENCES fl_port_schedule(id),
CONSTRAINT chk_carrier_schedule_query_customer CHECK (customer_id > 0),
CONSTRAINT chk_carrier_schedule_query_carrier CHECK
(carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE')),
CONSTRAINT chk_carrier_schedule_query_tracking CHECK
(tracking_type IN ('BOOKING_NO','BILL_OF_LADING','CONTAINER_NO')),
CONSTRAINT chk_carrier_schedule_query_access CHECK
(access_mode IN ('PUBLIC_FORM','HCAPTCHA','DYNAMIC_IFRAME')),
CONSTRAINT chk_carrier_schedule_query_status CHECK
(status IN ('READY','MANUAL_REQUIRED','SUCCESS')),
CONSTRAINT chk_carrier_schedule_query_attempt CHECK (attempt_count BETWEEN 0 AND 3)
) ENGINE=InnoDB COMMENT='六家船公司官网查询任务与三天窗口频控';
CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_attempt (
id BIGINT UNSIGNED NOT NULL,
query_id BIGINT UNSIGNED NOT NULL,
window_attempt_no INT UNSIGNED NOT NULL,
carrier_code VARCHAR(16) NOT NULL,
access_mode VARCHAR(32) NOT NULL,
official_url VARCHAR(500) NOT NULL,
attempt_status VARCHAR(24) NOT NULL,
coverage_gap TINYINT(1) NOT NULL DEFAULT 0,
coverage_gap_code VARCHAR(64) NULL,
manual_reason VARCHAR(1000) NULL,
error_code VARCHAR(64) NULL,
error_message VARCHAR(1000) NULL,
operator_id BIGINT UNSIGNED NOT NULL,
operator_name VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
KEY idx_carrier_schedule_attempt_query (query_id,created_at,id),
CONSTRAINT fk_carrier_schedule_attempt_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id),
CONSTRAINT chk_carrier_schedule_attempt_no CHECK (window_attempt_no BETWEEN 1 AND 3),
CONSTRAINT chk_carrier_schedule_attempt_status CHECK
(attempt_status IN ('MANUAL_REQUIRED','SUCCESS','FAILED'))
) ENGINE=InnoDB COMMENT='船司官网查询尝试不可变审计';
CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_history (
id BIGINT UNSIGNED NOT NULL,
query_id BIGINT UNSIGNED NOT NULL,
action VARCHAR(24) NOT NULL,
from_version INT UNSIGNED NULL,
to_version INT UNSIGNED NOT NULL,
reason VARCHAR(1000) NULL,
before_snapshot JSON NULL,
after_snapshot JSON NOT NULL,
operator_id BIGINT UNSIGNED NOT NULL,
operator_name VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
KEY idx_carrier_schedule_history_query (query_id,created_at,id),
CONSTRAINT fk_carrier_schedule_history_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id),
CONSTRAINT chk_carrier_schedule_history_action CHECK (action IN ('CREATE','ATTEMPT','MANUAL_RESULT'))
) ENGINE=InnoDB COMMENT='船司查询任务不可变历史';
CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_operation (
id BIGINT UNSIGNED NOT NULL,
query_id BIGINT UNSIGNED NULL,
operator_id BIGINT UNSIGNED NOT NULL,
operation VARCHAR(24) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
request_hash CHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'PROCESSING',
result_json JSON NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
completed_at DATETIME(3) NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_carrier_schedule_operation (operator_id,idempotency_key),
KEY idx_carrier_schedule_operation_query (query_id,created_at,id),
CONSTRAINT fk_carrier_schedule_operation_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id),
CONSTRAINT chk_carrier_schedule_operation_status CHECK (status IN ('PROCESSING','SUCCESS'))
) ENGINE=InnoDB COMMENT='船司查询写操作幂等与请求摘要';
DROP TRIGGER IF EXISTS trg_carrier_schedule_query_before_insert;
DELIMITER $$
CREATE TRIGGER trg_carrier_schedule_query_before_insert BEFORE INSERT ON fl_carrier_schedule_query FOR EACH ROW
BEGIN
IF NOT EXISTS (SELECT 1 FROM fl_ocean_booking booking
WHERE booking.id=NEW.ocean_booking_id AND booking.customer_id=NEW.customer_id) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询客户与订舱不一致';
END IF;
IF NEW.container_booking_id IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM fl_ocean_booking_container_allocation allocation
WHERE allocation.ocean_booking_id=NEW.ocean_booking_id
AND allocation.container_booking_id=NEW.container_booking_id
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询柜次未分配至订舱';
END IF;
END$$
DELIMITER ;
DROP TRIGGER IF EXISTS trg_carrier_schedule_attempt_before_update;
DROP TRIGGER IF EXISTS trg_carrier_schedule_attempt_before_delete;
DROP TRIGGER IF EXISTS trg_carrier_schedule_history_before_update;
DROP TRIGGER IF EXISTS trg_carrier_schedule_history_before_delete;
DELIMITER $$
CREATE TRIGGER trg_carrier_schedule_attempt_before_update BEFORE UPDATE ON fl_carrier_schedule_query_attempt
FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询尝试不可修改'; END$$
CREATE TRIGGER trg_carrier_schedule_attempt_before_delete BEFORE DELETE ON fl_carrier_schedule_query_attempt
FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询尝试不可删除'; END$$
CREATE TRIGGER trg_carrier_schedule_history_before_update BEFORE UPDATE ON fl_carrier_schedule_query_history
FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询历史不可修改'; END$$
CREATE TRIGGER trg_carrier_schedule_history_before_delete BEFORE DELETE ON fl_carrier_schedule_query_history
FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询历史不可删除'; END$$
DELIMITER ;
USE baiyue_iam;
DROP PROCEDURE IF EXISTS assert_carrier_schedule_query_permission_ids;
DELIMITER $$
CREATE PROCEDURE assert_carrier_schedule_query_permission_ids()
BEGIN
IF EXISTS (
SELECT 1 FROM iam_permission
WHERE (id BETWEEN 5102184 AND 5102188
AND permission_code <> CASE id
WHEN 5102184 THEN 'platform:firstleg:port-schedule-query:list'
WHEN 5102185 THEN 'platform:firstleg:port-schedule-query:view'
WHEN 5102186 THEN 'platform:firstleg:port-schedule-query:create'
WHEN 5102187 THEN 'platform:firstleg:port-schedule-query:attempt'
WHEN 5102188 THEN 'platform:firstleg:port-schedule-query:manual'
END)
OR (permission_code LIKE 'platform:firstleg:port-schedule-query:%'
AND (tenant_id<>0 OR app_code<>'PLATFORM' OR id NOT BETWEEN 5102184 AND 5102188))
) THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT='三期船司查询权限迁移失败:5102184-5102188 ID或权限码被占用';
END IF;
END$$
DELIMITER ;
CALL assert_carrier_schedule_query_permission_ids();
DROP PROCEDURE IF EXISTS assert_carrier_schedule_query_permission_ids;
INSERT INTO iam_permission
(id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,
route_path,http_method,sort_no,status,built_in,deleted)
VALUES
(5102184,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule-query:list','查询船司任务',NULL,'GET',40,'ACTIVE',1,0),
(5102185,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule-query:view','查看船司任务',NULL,'GET',50,'ACTIVE',1,0),
(5102186,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule-query:create','创建船司任务',NULL,'POST',60,'ACTIVE',1,0),
(5102187,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule-query:attempt','执行船司查询',NULL,'POST',70,'ACTIVE',1,0),
(5102188,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule-query:manual','录入人工结果',NULL,'POST',80,'ACTIVE',1,0)
ON DUPLICATE KEY UPDATE tenant_id=0,app_code='PLATFORM',permission_type=VALUES(permission_type),
permission_code=VALUES(permission_code),permission_name=VALUES(permission_name),parent_id=VALUES(parent_id),
route_path=VALUES(route_path),http_method=VALUES(http_method),sort_no=VALUES(sort_no),
status='ACTIVE',built_in=1,deleted=0;
INSERT IGNORE INTO iam_role_permission(role_id,permission_id,tenant_id)
SELECT role.id,permission.id,0 FROM iam_role role JOIN iam_permission permission
ON permission.tenant_id=0 AND permission.app_code='PLATFORM'
AND permission.permission_code LIKE 'platform:firstleg:port-schedule-query:%'
WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR');
USE baiyue_firstleg;
DROP PROCEDURE IF EXISTS assert_carrier_schedule_query_schema;
DELIMITER $$
CREATE PROCEDURE assert_carrier_schedule_query_schema()
BEGIN
IF (SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema='baiyue_firstleg' AND table_name IN
('fl_carrier_schedule_query','fl_carrier_schedule_query_attempt',
'fl_carrier_schedule_query_history','fl_carrier_schedule_query_operation')) <> 4 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询任务表未完整创建';
END IF;
IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission
WHERE tenant_id=0 AND id BETWEEN 5102184 AND 5102188
AND permission_code LIKE 'platform:firstleg:port-schedule-query:%') <> 5 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询任务权限未完整创建';
END IF;
IF (SELECT COUNT(*)
FROM baiyue_iam.iam_role_permission role_permission
JOIN baiyue_iam.iam_role role ON role.id=role_permission.role_id
JOIN baiyue_iam.iam_permission permission ON permission.id=role_permission.permission_id
WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')
AND permission.tenant_id=0
AND permission.permission_code LIKE 'platform:firstleg:port-schedule-query:%') <> 10 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询任务角色授权未完整创建';
END IF;
END$$
DELIMITER ;
CALL assert_carrier_schedule_query_schema();
DROP PROCEDURE IF EXISTS assert_carrier_schedule_query_schema;
@@ -0,0 +1,113 @@
openapi: 3.0.3
info:
title: Carrier Schedule Query API
version: 1.0.0
description: Six-carrier query tasks with a three-day window, at most three attempts, and explicit manual fallback.
paths:
/api/platform/v1/carrier-schedule-queries/carriers:
get:
summary: List official carrier sources and compliant access modes
responses:
'200': {description: MATSON, SML, ZIM, COSCO, EMC and HEDE descriptors}
/api/platform/v1/carrier-schedule-queries:
get:
summary: Query carrier schedule tasks
parameters:
- {name: customerId, in: query, schema: {type: integer, format: int64, minimum: 1}}
- {name: oceanBookingId, in: query, schema: {type: integer, format: int64, minimum: 1}}
- {name: containerBookingId, in: query, schema: {type: integer, format: int64, minimum: 1}}
- {name: carrierCode, in: query, schema: {$ref: '#/components/schemas/CarrierCode'}}
- {name: status, in: query, schema: {$ref: '#/components/schemas/QueryStatus'}}
- {name: expired, in: query, schema: {type: boolean}}
- {name: keyword, in: query, schema: {type: string, maxLength: 128}}
- {name: page, in: query, schema: {type: integer, minimum: 1, default: 1}}
- {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 200, default: 20}}
responses:
'200': {description: Query tasks with source, freshness and rate-limit fields}
post:
summary: Create a query task linked to a real booking and allocated container
parameters: [{$ref: '#/components/parameters/IdempotencyKey'}]
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/CreateRequest'}
responses:
'200': {description: Created task or idempotent replay}
/api/platform/v1/carrier-schedule-queries/{queryId}:
parameters: [{$ref: '#/components/parameters/QueryId'}]
get:
summary: Get task, immutable attempts and history
responses:
'200': {description: Stable task detail}
/api/platform/v1/carrier-schedule-queries/{queryId}/history:
parameters: [{$ref: '#/components/parameters/QueryId'}]
get:
summary: Get immutable task history
responses:
'200': {description: CREATE, ATTEMPT and MANUAL_RESULT history}
/api/platform/v1/carrier-schedule-queries/{queryId}/attempts:
parameters: [{$ref: '#/components/parameters/QueryId'}]
post:
summary: Execute the compliant carrier strategy; never bypass captcha or dynamic pages
parameters: [{$ref: '#/components/parameters/IdempotencyKey'}]
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/AttemptRequest'}
responses:
'200': {description: MANUAL_REQUIRED with coverageGap when no public structured API is available}
/api/platform/v1/carrier-schedule-queries/{queryId}/manual-results:
parameters: [{$ref: '#/components/parameters/QueryId'}]
post:
summary: Link a validated manual port-schedule observation without pretending automated success
parameters: [{$ref: '#/components/parameters/IdempotencyKey'}]
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/ManualResultRequest'}
responses:
'200': {description: Successful manual fallback and next query time}
components:
parameters:
QueryId: {name: queryId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, minLength: 1, maxLength: 128}}
schemas:
CarrierCode: {type: string, enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE]}
TrackingType: {type: string, enum: [BOOKING_NO, BILL_OF_LADING, CONTAINER_NO]}
QueryStatus: {type: string, enum: [READY, MANUAL_REQUIRED, SUCCESS]}
CreateRequest:
type: object
required: [expectedVersion, customerId, oceanBookingId, carrierCode, trackingType, trackingNo]
properties:
expectedVersion: {type: integer, minimum: 0, maximum: 0}
customerId: {type: integer, format: int64, minimum: 1}
oceanBookingId: {type: integer, format: int64, minimum: 1}
containerBookingId: {type: integer, format: int64, minimum: 1, nullable: true}
carrierCode: {$ref: '#/components/schemas/CarrierCode'}
trackingType: {$ref: '#/components/schemas/TrackingType'}
trackingNo: {type: string, minLength: 1, maxLength: 128}
remark: {type: string, nullable: true, maxLength: 1000}
AttemptRequest:
type: object
required: [expectedVersion]
properties:
expectedVersion: {type: integer, minimum: 0}
reason: {type: string, nullable: true, maxLength: 1000}
ManualResultRequest:
type: object
required: [expectedVersion, portScheduleId, reason]
properties:
expectedVersion: {type: integer, minimum: 0}
portScheduleId: {type: integer, format: int64, minimum: 1}
reason: {type: string, minLength: 1, maxLength: 1000}
QueryView:
type: object
required: [query, expired, maxAttempts, nextAllowedAt]
properties:
query: {type: object, description: Includes source, attemptCount, lastAttemptAt, lastSuccessAt, sourceUpdatedAt and manualReason}
expired: {type: boolean}
maxAttempts: {type: integer, enum: [3]}
nextAllowedAt: {type: string, format: date-time}
@@ -0,0 +1,27 @@
package com.baiyue.firstleg.portschedule.controller;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
class CarrierScheduleQueryOpenApiContractTest {
@Test
void publishesFrequencyFreshnessAndManualFallbackContract() throws Exception {
String yaml = new ClassPathResource("openapi/carrier-schedule-query-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(yaml)
.contains("/api/platform/v1/carrier-schedule-queries:")
.contains("/api/platform/v1/carrier-schedule-queries/{queryId}/attempts:")
.contains("/api/platform/v1/carrier-schedule-queries/{queryId}/manual-results:")
.contains("enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE]")
.contains("MANUAL_REQUIRED")
.contains("coverageGap")
.contains("lastAttemptAt")
.contains("lastSuccessAt")
.contains("sourceUpdatedAt")
.contains("nextAllowedAt");
}
}
@@ -0,0 +1,37 @@
package com.baiyue.firstleg.portschedule.controller;
import com.baiyue.common.audit.BusinessAudit;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.RequestMapping;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class PlatformCarrierScheduleQueryControllerContractTest {
@Test
void exposesDedicatedTaskResourceAndLeastPrivilegeActions() {
assertThat(PlatformCarrierScheduleQueryController.class.getAnnotation(RequestMapping.class).value())
.containsExactly("/api/platform/v1/carrier-schedule-queries");
Map<String, String> permissions = Map.of(
"carriers", "platform:firstleg:port-schedule-query:view",
"page", "platform:firstleg:port-schedule-query:list",
"detail", "platform:firstleg:port-schedule-query:view",
"history", "platform:firstleg:port-schedule-query:view",
"create", "platform:firstleg:port-schedule-query:create",
"attempt", "platform:firstleg:port-schedule-query:attempt",
"manualResult", "platform:firstleg:port-schedule-query:manual");
permissions.forEach((name, permission) -> {
Method method = Arrays.stream(PlatformCarrierScheduleQueryController.class.getDeclaredMethods())
.filter(candidate -> candidate.getName().equals(name)).findFirst().orElseThrow();
assertThat(method.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('" + permission + "')");
if (name.equals("create") || name.equals("attempt") || name.equals("manualResult")) {
assertThat(method.getAnnotation(BusinessAudit.class)).isNotNull();
}
});
}
}
@@ -0,0 +1,55 @@
package com.baiyue.firstleg.portschedule.repository;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class CarrierScheduleQuerySqlContractTest {
@Test
void definesTaskAttemptsHistoryIdempotencyAndHardLimit() throws Exception {
assertThat(moduleSql())
.contains("CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query (")
.contains("CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_attempt")
.contains("CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_history")
.contains("CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_operation")
.contains("attempt_count BETWEEN 0 AND 3")
.contains("window_attempt_no BETWEEN 1 AND 3")
.contains("UNIQUE KEY uk_carrier_schedule_operation (operator_id,idempotency_key)")
.contains("船司查询尝试不可修改")
.contains("船司查询历史不可删除");
}
@Test
void enforcesRealBookingContainerAndPermissions() throws Exception {
assertThat(moduleSql())
.contains("booking.id=NEW.ocean_booking_id AND booking.customer_id=NEW.customer_id")
.contains("allocation.container_booking_id=NEW.container_booking_id")
.contains("5102184 AND 5102188")
.contains("platform:firstleg:port-schedule-query:attempt")
.contains("platform:firstleg:port-schedule-query:manual")
.contains("role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')")
.contains("船司查询任务角色授权未完整创建");
}
@Test
void unifiedDeploymentEntryContainsTheSameContract() throws Exception {
String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"),
StandardCharsets.UTF_8);
assertThat(unified)
.contains("fl_carrier_schedule_query")
.contains("uk_carrier_schedule_query_scope")
.contains("5102184 AND 5102188")
.contains("assert_carrier_schedule_query_schema")
.contains("船司查询任务角色授权未完整创建");
}
private static String moduleSql() throws Exception {
return new ClassPathResource("db/phase3-carrier-schedule-query-schema.sql")
.getContentAsString(StandardCharsets.UTF_8);
}
}
@@ -0,0 +1,40 @@
package com.baiyue.firstleg.portschedule.service;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class CarrierScheduleAdapterRegistryTest {
private final CarrierScheduleAdapterRegistry registry = new CarrierScheduleAdapterRegistry();
@Test
void exposesExactlySixOfficialSourcesWithoutPretendingAutomation() {
assertThat(registry.descriptors()).extracting("carrierCode")
.containsExactly("MATSON", "SML", "ZIM", "COSCO", "EMC", "HEDE");
assertThat(registry.descriptors()).allSatisfy(value -> {
assertThat(value.automated()).isFalse();
assertThat(value.coverageGap()).isTrue();
assertThat(value.officialUrl()).startsWith("http");
});
}
@Test
void returnsStructuredManualRequirementsForCaptchaAndDynamicPage() {
var zim = registry.prepare("zim", "BILL_OF_LADING", "GAOU6898053");
var cosco = registry.prepare("COSCO", "CONTAINER_NO", "C123");
assertThat(zim.status()).isEqualTo("MANUAL_REQUIRED");
assertThat(zim.accessMode()).isEqualTo("HCAPTCHA");
assertThat(zim.coverageGapCode()).isEqualTo("HCAPTCHA_REQUIRED");
assertThat(zim.officialUrl()).contains("consnumber=GAOU6898053");
assertThat(cosco.accessMode()).isEqualTo("DYNAMIC_IFRAME");
assertThat(cosco.coverageGapCode()).isEqualTo("DYNAMIC_IFRAME_REQUIRED");
}
@Test
void rejectsUnknownCarrier() {
assertThatThrownBy(() -> registry.prepare("UNKNOWN", "BOOKING_NO", "X"))
.hasMessageContaining("仅支持MATSON");
}
}
@@ -0,0 +1,134 @@
package com.baiyue.firstleg.portschedule.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.AttemptRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CreateRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualResultRequest;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryAttemptEntity;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity;
import com.baiyue.firstleg.portschedule.entity.PortScheduleEntity;
import com.baiyue.firstleg.portschedule.repository.CarrierScheduleQueryRepository;
import com.baiyue.firstleg.portschedule.repository.PortScheduleRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class CarrierScheduleQueryServiceTest {
private static final LocalDateTime NOW = LocalDateTime.of(2026, 8, 10, 12, 0);
@Mock private CarrierScheduleQueryRepository repository;
@Mock private PortScheduleRepository portSchedules;
@InjectMocks private CarrierScheduleQueryService service;
@BeforeEach
void setUp() throws Exception {
MockitoAnnotations.openMocks(this);
set("objectMapper", new ObjectMapper().findAndRegisterModules());
set("adapters", new CarrierScheduleAdapterRegistry());
set("clock", Clock.fixed(Instant.parse("2026-08-10T04:00:00Z"), ZoneOffset.UTC));
when(repository.claimOperation(any(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
when(repository.completeOperation(anyLong(), anyLong(), anyString(), anyString())).thenReturn(1);
when(repository.attempts(anyLong())).thenReturn(List.of());
when(repository.histories(anyLong())).thenReturn(List.of());
}
@Test
void createsTaskOnlyForBookingCustomerAndAllocatedContainer() {
OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L);
when(portSchedules.oceanBooking(10L)).thenReturn(booking);
when(portSchedules.containerBooking(20L)).thenReturn(new com.baiyue.firstleg.entity.ContainerBookingEntity());
when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity());
var result = service.create(7L, "admin", "carrier-create",
new CreateRequest(0, 10001L, 10L, 20L, "ZIM", "BILL_OF_LADING", "GAOU6898053", null));
assertThat(result.query().query().getCarrierCode()).isEqualTo("ZIM");
assertThat(result.query().query().getStatus()).isEqualTo("READY");
assertThat(result.query().expired()).isTrue();
verify(repository).insert(any(CarrierScheduleQueryEntity.class));
}
@Test
void recordsManualRequiredAttemptWithoutBypassingCaptcha() {
CarrierScheduleQueryEntity task = task(0, 0);
when(repository.lock(1L)).thenReturn(task);
when(repository.updateState(any(), any(Integer.class), anyLong(), anyString())).thenReturn(1);
when(repository.find(1L)).thenAnswer(invocation -> task);
var result = service.attempt(1L, 7L, "admin", "carrier-attempt",
new AttemptRequest(0, "官网查询"));
assertThat(result.query().query().getStatus()).isEqualTo("MANUAL_REQUIRED");
assertThat(result.query().query().getAttemptCount()).isEqualTo(1);
assertThat(result.query().query().getManualReason()).contains("hCaptcha");
ArgumentCaptor<CarrierScheduleQueryAttemptEntity> attempt =
ArgumentCaptor.forClass(CarrierScheduleQueryAttemptEntity.class);
verify(repository).insert(attempt.capture());
assertThat(attempt.getValue().getCoverageGap()).isTrue();
assertThat(attempt.getValue().getCoverageGapCode()).isEqualTo("HCAPTCHA_REQUIRED");
}
@Test
void blocksFourthAttemptInsideSameThreeDayWindow() {
CarrierScheduleQueryEntity task = task(3, 4);
when(repository.lock(1L)).thenReturn(task);
assertThatThrownBy(() -> service.attempt(1L, 7L, "admin", "carrier-fourth",
new AttemptRequest(4, null)))
.isInstanceOf(BizException.class).hasMessageContaining("最多查询3次");
verify(repository, never()).updateState(any(), any(Integer.class), anyLong(), anyString());
}
@Test
void manualResultMustBelongToSameCustomerBookingAndContainer() {
CarrierScheduleQueryEntity task = task(1, 1);
when(repository.lock(1L)).thenReturn(task);
PortScheduleEntity schedule = new PortScheduleEntity();
schedule.setId(99L); schedule.setCustomerId(20002L); schedule.setOceanBookingId(10L);
schedule.setContainerBookingId(20L); schedule.setObservedAt(NOW); schedule.setExpiresAt(NOW.plusDays(3));
when(portSchedules.find(99L)).thenReturn(schedule);
assertThatThrownBy(() -> service.attachManualResult(1L, 7L, "admin", "carrier-manual",
new ManualResultRequest(1, 99L, "人工核验")))
.isInstanceOf(BizException.class).hasMessageContaining("不一致");
verify(repository, never()).updateState(any(), any(Integer.class), anyLong(), anyString());
}
private static CarrierScheduleQueryEntity task(int attempts, int version) {
CarrierScheduleQueryEntity value = new CarrierScheduleQueryEntity();
value.setId(1L); value.setQueryNo("CSQ1"); value.setCustomerId(10001L);
value.setOceanBookingId(10L); value.setContainerBookingId(20L); value.setCarrierCode("ZIM");
value.setTrackingType("BILL_OF_LADING"); value.setTrackingNo("GAOU6898053");
value.setOfficialUrl("https://www.zimchina.com/tools/track-a-shipment"); value.setAccessMode("HCAPTCHA");
value.setStatus("MANUAL_REQUIRED"); value.setWindowStartedAt(NOW.minusDays(1));
value.setAttemptCount(attempts); value.setNextAllowedAt(NOW); value.setVersion(version);
return value;
}
private void set(String fieldName, Object value) throws Exception {
var field = CarrierScheduleQueryService.class.getDeclaredField(fieldName);
field.setAccessible(true); field.set(service, value);
}
}
@@ -0,0 +1,191 @@
const MODULE = 'phase3-carrier-schedule-queries'
const OPTIONS = { scenario: 'carrier-schedule-query-policy', deploymentBlocker: true }
const BASE = '/api/platform/v1/carrier-schedule-queries'
const OCEAN = '/api/platform/v1/ocean-bookings'
const PORT = '/api/platform/v1/port-schedules'
function id(value) { return value == null ? '' : String(value) }
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
function data(response) { return codeOf(response) === '200' ? response.body.data : null }
function localIso(value) { return value.toISOString().slice(0, 19) }
function at(offsetMinutes) { return localIso(new Date(Date.now() + offsetMinutes * 60_000)) }
function step(context, name, response, passed, evidence) {
context.addStep(MODULE, name, response, passed, evidence, { ...OPTIONS, deploymentBlocker: !passed })
}
async function request(context, method, path, body, headers = {}) {
try {
const response = await fetch(`${context.options.gateway}${path}`, {
method,
headers: {
Accept: 'application/json', Authorization: `Bearer ${context.token}`,
...(body === undefined ? {} : { 'Content-Type': 'application/json; charset=utf-8' }), ...headers,
},
body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(60_000),
})
const raw = await response.text(); let payload = null
try { payload = raw ? JSON.parse(raw) : null } catch { /* bounded raw evidence */ }
return { method, path, http: response.status, body: payload, raw: payload === null ? raw.slice(0, 500) : null,
requestId: payload?.requestId || response.headers.get('x-request-id'),
traceId: payload?.traceId || response.headers.get('x-trace-id') }
} catch (error) {
return { method, path, http: 0, body: null, raw: String(error), requestId: null, traceId: null }
}
}
function oceanDraft(context, suffix) {
return {
customerId: '10001', customerName: `船司查询客户-${suffix}`, billOfLadingNo: `CSQ-BL-${suffix}`,
shippingCompanyName: 'ZIM', bookingAgentName: `船司查询代理-${suffix}`,
vesselName: `船司查询船-${suffix}`, voyageNo: `CQ${suffix}`, departurePortCode: 'CNSZX',
departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶',
placeOfDelivery: 'LAX', serviceTerm: 'CY/CY', freightTerm: 'PREPAID',
estimatedSailingAt: at(7 * 24 * 60), estimatedArrivalAt: at(28 * 24 * 60), remark: context.marker,
parties: [
{ partyType: 'SHIPPER', partyName: `发货方-${suffix}` },
{ partyType: 'CONSIGNEE', partyName: `收货方-${suffix}` },
{ partyType: 'NOTIFY_PARTY', partyName: `通知方-${suffix}` },
],
containerRequirements: [{ containerType: '40HQ', containerKind: 'FCL', quantity: 1 }],
}
}
async function ensureOcean(context, suffix) {
const existing = context.createdResources?.oceanBooking
if (existing?.id && id(existing.customerId || '10001') === '10001') return { id: id(existing.id), containerBookingId: null }
const response = await request(context, 'POST', OCEAN, { expectedVersion: 0, data: oceanDraft(context, suffix) },
{ 'Idempotency-Key': `${context.marker}-carrier-query-ocean`.slice(0, 128) })
const detail = context.apiSuccess(MODULE, '创建船司查询隔离订舱', response, '', OPTIONS)
return { id: id(detail?.oceanBooking?.id), containerBookingId: null }
}
async function createManualSchedule(context, suffix, oceanBookingId) {
const existing = context.createdResources?.portSchedule
if (existing?.id && id(existing.oceanBookingId) === oceanBookingId) return id(existing.id)
const body = {
expectedVersion: 0,
data: {
customerId: '10001', oceanBookingId, containerBookingId: null, sourceType: 'MANUAL',
sourceName: `船司查询人工兜底-${suffix}`, sourceRef: `${context.marker}-CARRIER-MANUAL`,
vesselName: `人工船期船-${suffix}`, voyageNo: `CM${suffix}`, departurePortCode: 'CNSZX',
departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶',
etdAt: at(10 * 24 * 60), etaAt: at(30 * 24 * 60), bookingCutoffAt: at(7 * 24 * 60),
customsCutoffAt: at(8 * 24 * 60), portCutoffAt: at(9 * 24 * 60),
observedAt: at(-1), expiresAt: at(3 * 24 * 60), remark: context.marker,
},
reason: '船司官网人工查询兜底',
}
const response = await request(context, 'POST', PORT, body,
{ 'Idempotency-Key': `${context.marker}-carrier-manual-schedule`.slice(0, 128) })
const detail = context.apiSuccess(MODULE, '创建真实人工船期兜底结果', response, '', OPTIONS)
return id(detail?.portSchedule?.schedule?.id)
}
export async function run(context) {
context.addCoverageGap(MODULE, OPTIONS.scenario, 'ZIM官网自动查询',
'ZIM启用hCaptcha,本期禁止绕过;真实契约返回MANUAL_REQUIRED并提供官方入口和人工结果绑定',
'external-third-party')
context.addCoverageGap(MODULE, OPTIONS.scenario, 'COSCO官网自动查询',
'COSCO为动态页面/iframe且没有稳定公开机器接口;真实契约返回MANUAL_REQUIRED,不伪造自动成功',
'external-third-party')
const suffix = context.marker.replace(/\D/g, '').slice(-10)
const carriersResponse = await request(context, 'GET', `${BASE}/carriers`)
const carriers = context.apiSuccess(MODULE, '六家船司查询策略入口已部署', carriersResponse, '', OPTIONS)
const byCode = new Map((Array.isArray(carriers) ? carriers : []).map((item) => [item.carrierCode, item]))
context.assert(MODULE, '六家官方来源及合规模式完整', carriersResponse,
['MATSON', 'SML', 'ZIM', 'COSCO', 'EMC', 'HEDE'].every((code) => byCode.has(code))
&& byCode.get('ZIM')?.accessMode === 'HCAPTCHA' && byCode.get('ZIM')?.coverageGap === true
&& byCode.get('COSCO')?.accessMode === 'DYNAMIC_IFRAME' && byCode.get('COSCO')?.coverageGap === true
&& [...byCode.values()].every((item) => item.officialUrl?.startsWith('http') && item.automated === false),
`codes=${[...byCode.keys()].join(',') || 'missing'}`, OPTIONS)
const ocean = await ensureOcean(context, suffix)
if (!ocean.id) return
const createBody = {
expectedVersion: 0, customerId: '10001', oceanBookingId: ocean.id,
containerBookingId: ocean.containerBookingId, carrierCode: 'ZIM', trackingType: 'BILL_OF_LADING',
trackingNo: `${context.marker}-ZIM`, remark: context.marker,
}
const createKey = `${context.marker}-carrier-query-create`.slice(0, 128)
const createResponse = await request(context, 'POST', BASE, createBody, { 'Idempotency-Key': createKey })
const created = context.apiSuccess(MODULE, '创建真实船司查询任务', createResponse, '', OPTIONS)
const queryId = id(created?.query?.query?.id)
context.assert(MODULE, '查询任务初始来源、频控和过期字段稳定', createResponse,
Boolean(queryId) && created?.query?.query?.status === 'READY' && created?.query?.query?.attemptCount === 0
&& created?.query?.maxAttempts === 3 && created?.query?.expired === true
&& Boolean(created?.query?.nextAllowedAt) && created?.query?.query?.accessMode === 'HCAPTCHA',
`queryId=${queryId || 'missing'}, status=${created?.query?.query?.status || 'missing'}`, OPTIONS)
if (!queryId) return
const replay = await request(context, 'POST', BASE, createBody, { 'Idempotency-Key': createKey })
context.assert(MODULE, '创建任务幂等重放返回同一资源', replay,
id(data(replay)?.query?.query?.id) === queryId,
`first=${queryId}, replay=${id(data(replay)?.query?.query?.id) || 'missing'}`, OPTIONS)
let version = Number(created.query.query.version)
for (let attemptNo = 1; attemptNo <= 3; attemptNo += 1) {
const response = await request(context, 'POST', `${BASE}/${queryId}/attempts`,
{ expectedVersion: version, reason: `第${attemptNo}次官网查询` },
{ 'Idempotency-Key': `${context.marker}-carrier-attempt-${attemptNo}`.slice(0, 128) })
const detail = context.apiSuccess(MODULE, `第${attemptNo}次查询返回结构化人工兜底`, response, '', OPTIONS)
context.assert(MODULE, `第${attemptNo}次查询记录来源和审计状态`, response,
detail?.query?.query?.status === 'MANUAL_REQUIRED'
&& Number(detail?.query?.query?.attemptCount) === attemptNo
&& detail?.attempts?.length === attemptNo
&& detail?.attempts?.at(-1)?.coverageGap === true
&& detail?.attempts?.at(-1)?.coverageGapCode === 'HCAPTCHA_REQUIRED'
&& Boolean(detail?.query?.query?.lastAttemptAt) && Boolean(detail?.query?.query?.manualReason),
`status=${detail?.query?.query?.status || 'missing'}, attempts=${detail?.query?.query?.attemptCount ?? 'missing'}`, OPTIONS)
version = Number(detail?.query?.query?.version)
}
const fourth = await request(context, 'POST', `${BASE}/${queryId}/attempts`,
{ expectedVersion: version, reason: '超过窗口上限' },
{ 'Idempotency-Key': `${context.marker}-carrier-attempt-4`.slice(0, 128) })
step(context, '同一3天窗口第4次查询被服务端拒绝', fourth,
fourth.http === 200 && ['A0409', '409'].includes(codeOf(fourth)),
`HTTP=${fourth.http}, code=${codeOf(fourth)}`)
const unknownCarrier = await request(context, 'POST', BASE,
{ ...createBody, carrierCode: 'UNKNOWN', trackingNo: `${context.marker}-UNKNOWN` },
{ 'Idempotency-Key': `${context.marker}-carrier-unknown`.slice(0, 128) })
step(context, '非法船司代码被拒绝', unknownCarrier,
unknownCarrier.http === 200 && ['A0400', '400'].includes(codeOf(unknownCarrier)),
`HTTP=${unknownCarrier.http}, code=${codeOf(unknownCarrier)}`)
const forgedCustomer = await request(context, 'POST', BASE,
{ ...createBody, customerId: '10002', trackingNo: `${context.marker}-FORGED` },
{ 'Idempotency-Key': `${context.marker}-carrier-forged-customer`.slice(0, 128) })
step(context, '伪造客户与订舱关联被拒绝', forgedCustomer,
forgedCustomer.http === 200 && ['A0400', '400'].includes(codeOf(forgedCustomer)),
`HTTP=${forgedCustomer.http}, code=${codeOf(forgedCustomer)}`)
const scheduleId = await createManualSchedule(context, suffix, ocean.id)
if (!scheduleId) return
const manual = await request(context, 'POST', `${BASE}/${queryId}/manual-results`,
{ expectedVersion: version, portScheduleId: scheduleId, reason: '人工完成官网查询并复核船期' },
{ 'Idempotency-Key': `${context.marker}-carrier-manual-result`.slice(0, 128) })
const completed = context.apiSuccess(MODULE, '绑定真实人工船期结果', manual, '', OPTIONS)
context.assert(MODULE, '人工兜底保留官网尝试且形成有效更新时间和过期状态', manual,
completed?.query?.query?.status === 'SUCCESS' && id(completed?.query?.query?.resultPortScheduleId) === scheduleId
&& Boolean(completed?.query?.query?.lastSuccessAt) && Boolean(completed?.query?.query?.sourceUpdatedAt)
&& Boolean(completed?.query?.nextAllowedAt) && completed?.query?.expired === false
&& completed?.histories?.some((item) => item.action === 'MANUAL_RESULT'),
`status=${completed?.query?.query?.status || 'missing'}, result=${id(completed?.query?.query?.resultPortScheduleId) || 'missing'}`, OPTIONS)
const early = await request(context, 'POST', `${BASE}/${queryId}/attempts`,
{ expectedVersion: Number(completed?.query?.query?.version), reason: '未到三天再次查询' },
{ 'Idempotency-Key': `${context.marker}-carrier-too-early`.slice(0, 128) })
step(context, '成功后未到3天禁止再次查询', early,
early.http === 200 && ['A0409', '409'].includes(codeOf(early)),
`HTTP=${early.http}, code=${codeOf(early)}`)
const detailResponse = await request(context, 'GET', `${BASE}/${queryId}`)
const detail = context.apiSuccess(MODULE, '回查船司查询任务详情与历史', detailResponse, '', OPTIONS)
context.assert(MODULE, '查询尝试和版本历史完整且不伪装自动成功', detailResponse,
detail?.attempts?.length === 3 && detail?.attempts?.every((item) => item.attemptStatus === 'MANUAL_REQUIRED')
&& detail?.histories?.some((item) => item.action === 'CREATE')
&& detail?.histories?.filter((item) => item.action === 'ATTEMPT').length === 3
&& detail?.histories?.some((item) => item.action === 'MANUAL_RESULT'),
`attempts=${detail?.attempts?.length ?? 'missing'}, histories=${detail?.histories?.length ?? 'missing'}`, OPTIONS)
}