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