From edd510862517ff5dfa363c2113024fd2242e9fa0 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 10 Aug 2026 12:20:05 +0800 Subject: [PATCH] feat(firstleg): add compliant carrier schedule queries --- ...latformCarrierScheduleQueryController.java | 109 +++++++ .../dto/CarrierScheduleQueryDtos.java | 52 ++++ .../CarrierScheduleQueryAttemptEntity.java | 32 ++ .../entity/CarrierScheduleQueryEntity.java | 49 +++ .../CarrierScheduleQueryHistoryEntity.java | 28 ++ .../CarrierScheduleQueryOperationEntity.java | 27 ++ .../CarrierScheduleQueryAttemptMapper.java | 8 + .../CarrierScheduleQueryHistoryMapper.java | 8 + .../mapper/CarrierScheduleQueryMapper.java | 53 ++++ .../CarrierScheduleQueryOperationMapper.java | 28 ++ .../CarrierScheduleQueryRepository.java | 62 ++++ .../CarrierScheduleAdapterRegistry.java | 86 ++++++ .../service/CarrierScheduleQueryService.java | 289 ++++++++++++++++++ .../phase3-carrier-schedule-query-schema.sql | 226 ++++++++++++++ .../openapi/carrier-schedule-query-v1.yaml | 113 +++++++ ...rrierScheduleQueryOpenApiContractTest.java | 27 ++ ...erScheduleQueryControllerContractTest.java | 37 +++ .../CarrierScheduleQuerySqlContractTest.java | 55 ++++ .../CarrierScheduleAdapterRegistryTest.java | 40 +++ .../CarrierScheduleQueryServiceTest.java | 134 ++++++++ .../phase3-carrier-schedule-queries.mjs | 191 ++++++++++++ 21 files changed, 1654 insertions(+) create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryController.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/portschedule/dto/CarrierScheduleQueryDtos.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/CarrierScheduleQueryAttemptEntity.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/CarrierScheduleQueryEntity.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/CarrierScheduleQueryHistoryEntity.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/CarrierScheduleQueryOperationEntity.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryAttemptMapper.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryHistoryMapper.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryMapper.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryOperationMapper.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQueryRepository.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleAdapterRegistry.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java create mode 100644 firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql create mode 100644 firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/CarrierScheduleQueryOpenApiContractTest.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryControllerContractTest.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleAdapterRegistryTest.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java create mode 100644 scripts/admin-test-env-modules/phase3-carrier-schedule-queries.mjs diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryController.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryController.java new file mode 100644 index 000000000..6ef1ffe70 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryController.java @@ -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> carriers(@AuthenticationPrincipal AuthenticatedUser user) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.carriers()); + } + + @GetMapping + @PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:list')") + public ApiResponse> 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(@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> 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 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 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 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)); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/dto/CarrierScheduleQueryDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/dto/CarrierScheduleQueryDtos.java new file mode 100644 index 000000000..07a06443d --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/dto/CarrierScheduleQueryDtos.java @@ -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 attempts, + List histories) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/CarrierScheduleQueryAttemptEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/CarrierScheduleQueryAttemptEntity.java new file mode 100644 index 000000000..43eef926b --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/CarrierScheduleQueryAttemptEntity.java @@ -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; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/CarrierScheduleQueryEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/CarrierScheduleQueryEntity.java new file mode 100644 index 000000000..fd460a582 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/CarrierScheduleQueryEntity.java @@ -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; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/CarrierScheduleQueryHistoryEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/CarrierScheduleQueryHistoryEntity.java new file mode 100644 index 000000000..cf5eb5479 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/CarrierScheduleQueryHistoryEntity.java @@ -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; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/CarrierScheduleQueryOperationEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/CarrierScheduleQueryOperationEntity.java new file mode 100644 index 000000000..19a7414d4 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/CarrierScheduleQueryOperationEntity.java @@ -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; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryAttemptMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryAttemptMapper.java new file mode 100644 index 000000000..01e75f6fa --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryAttemptMapper.java @@ -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 {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryHistoryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryHistoryMapper.java new file mode 100644 index 000000000..63e8cc443 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryHistoryMapper.java @@ -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 {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryMapper.java new file mode 100644 index 000000000..cc973ee0b --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryMapper.java @@ -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 { + @Select(""" + + """) + Page selectPage(Page 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); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryOperationMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryOperationMapper.java new file mode 100644 index 000000000..a71b1cf38 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryOperationMapper.java @@ -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 { + @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); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQueryRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQueryRepository.java new file mode 100644 index 000000000..f92a10783 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQueryRepository.java @@ -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 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 attempts(long id) { + return attempts.selectList(new LambdaQueryWrapper() + .eq(CarrierScheduleQueryAttemptEntity::getQueryId, id) + .orderByAsc(CarrierScheduleQueryAttemptEntity::getCreatedAt) + .orderByAsc(CarrierScheduleQueryAttemptEntity::getId)); + } + public void insert(CarrierScheduleQueryAttemptEntity value) { attempts.insert(value); } + public List histories(long id) { + return histories.selectList(new LambdaQueryWrapper() + .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() + .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); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleAdapterRegistry.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleAdapterRegistry.java new file mode 100644 index 000000000..1ddcd1d77 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleAdapterRegistry.java @@ -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 DEFINITIONS = definitions(); + + public List 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 definitions() { + Map 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 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) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java new file mode 100644 index 000000000..c5df3da71 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java @@ -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 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 carriers() { return adapters.descriptors(); } + + public PageResult page(Query query) { + PageResult 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 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("数据已被其他人修改,请刷新后重试"); } +} diff --git a/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql b/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql new file mode 100644 index 000000000..5bf946d83 --- /dev/null +++ b/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql @@ -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; diff --git a/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml b/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml new file mode 100644 index 000000000..ff50fc8de --- /dev/null +++ b/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml @@ -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} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/CarrierScheduleQueryOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/CarrierScheduleQueryOpenApiContractTest.java new file mode 100644 index 000000000..774d22da0 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/CarrierScheduleQueryOpenApiContractTest.java @@ -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"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryControllerContractTest.java new file mode 100644 index 000000000..173c7cbb1 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryControllerContractTest.java @@ -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 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(); + } + }); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java new file mode 100644 index 000000000..6c6123073 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java @@ -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); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleAdapterRegistryTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleAdapterRegistryTest.java new file mode 100644 index 000000000..b7fc97fda --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleAdapterRegistryTest.java @@ -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"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java new file mode 100644 index 000000000..8c9801761 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java @@ -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 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); + } +} diff --git a/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.mjs b/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.mjs new file mode 100644 index 000000000..813e1ed16 --- /dev/null +++ b/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.mjs @@ -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) +}