feat(firstleg): add compliant carrier schedule queries

This commit is contained in:
2026-08-10 12:20:05 +08:00
parent 5c1c6a1a07
commit edd5108625
21 changed files with 1654 additions and 0 deletions
@@ -0,0 +1,109 @@
package com.baiyue.firstleg.portschedule.controller;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.AttemptRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CarrierDescriptor;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CreateRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Detail;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualResultRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Query;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.QueryView;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryHistoryEntity;
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Positive;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@Validated
@RestController
@Tag(name = "船公司官网查询任务")
@RequestMapping("/api/platform/v1/carrier-schedule-queries")
public class PlatformCarrierScheduleQueryController {
@Resource private CarrierScheduleQueryService service;
@Operation(summary = "查询六家船公司合规适配策略")
@GetMapping("/carriers")
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:view')")
public ApiResponse<List<CarrierDescriptor>> carriers(@AuthenticationPrincipal AuthenticatedUser user) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.carriers());
}
@GetMapping
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:list')")
public ApiResponse<PageResult<QueryView>> page(@AuthenticationPrincipal AuthenticatedUser user,
@Valid Query query) {
FirstlegScopeGuard.platform(user);
if (query.customerId() != null) FirstlegScopeGuard.platform(user, query.customerId());
return ApiResponse.success(service.page(query));
}
@GetMapping("/{queryId}")
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:view')")
public ApiResponse<Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long queryId) {
FirstlegScopeGuard.platform(user, service.customerId(queryId));
return ApiResponse.success(service.detail(queryId));
}
@GetMapping("/{queryId}/history")
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:view')")
public ApiResponse<List<CarrierScheduleQueryHistoryEntity>> history(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long queryId) {
FirstlegScopeGuard.platform(user, service.customerId(queryId));
return ApiResponse.success(service.history(queryId));
}
@PostMapping
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:create')")
@BusinessAudit(event = "CARRIER_SCHEDULE_QUERY_CREATE", resourceType = "CARRIER_SCHEDULE_QUERY",
description = "创建船司官网查询任务", captureBefore = false)
public ApiResponse<Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody CreateRequest request) {
FirstlegScopeGuard.platform(user, request.customerId());
return ApiResponse.success(service.create(user.userId(), user.username(), key, request));
}
@PostMapping("/{queryId}/attempts")
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:attempt')")
@BusinessAudit(event = "CARRIER_SCHEDULE_QUERY_ATTEMPT", resourceType = "CARRIER_SCHEDULE_QUERY",
resourceId = "#queryId", description = "执行船司官网查询策略")
public ApiResponse<Detail> attempt(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long queryId,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody AttemptRequest request) {
FirstlegScopeGuard.platform(user, service.customerId(queryId));
return ApiResponse.success(service.attempt(queryId, user.userId(), user.username(), key, request));
}
@PostMapping("/{queryId}/manual-results")
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:manual')")
@BusinessAudit(event = "CARRIER_SCHEDULE_QUERY_MANUAL_RESULT", resourceType = "CARRIER_SCHEDULE_QUERY",
resourceId = "#queryId", description = "关联人工船期查询结果")
public ApiResponse<Detail> manualResult(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long queryId,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody ManualResultRequest request) {
FirstlegScopeGuard.platform(user, service.customerId(queryId));
return ApiResponse.success(service.attachManualResult(queryId, user.userId(), user.username(), key, request));
}
}
@@ -0,0 +1,52 @@
package com.baiyue.firstleg.portschedule.dto;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryAttemptEntity;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryHistoryEntity;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import java.time.LocalDateTime;
import java.util.List;
public final class CarrierScheduleQueryDtos {
private CarrierScheduleQueryDtos() {}
public record Query(@Positive Long customerId, @Positive Long oceanBookingId,
@Positive Long containerBookingId, @Size(max = 16) String carrierCode,
@Size(max = 24) String status, Boolean expired,
@Size(max = 128) String keyword, @Min(1) long page,
@Min(1) @Max(200) long size) {
public Query { if (page == 0) page = 1; if (size == 0) size = 20; }
}
public record CreateRequest(@NotNull @Min(0) @Max(0) Integer expectedVersion,
@NotNull @Positive Long customerId,
@NotNull @Positive Long oceanBookingId,
@Positive Long containerBookingId,
@NotBlank @Size(max = 16) String carrierCode,
@NotBlank @Size(max = 32) String trackingType,
@NotBlank @Size(max = 128) String trackingNo,
@Size(max = 1000) String remark) {}
public record AttemptRequest(@NotNull @Min(0) Integer expectedVersion,
@Size(max = 1000) String reason) {}
public record ManualResultRequest(@NotNull @Min(0) Integer expectedVersion,
@NotNull @Positive Long portScheduleId,
@NotBlank @Size(max = 1000) String reason) {}
public record CarrierDescriptor(String carrierCode, String carrierName, String officialUrl,
String accessMode, boolean automated, boolean coverageGap,
String coverageGapCode, String manualReason) {}
public record QueryView(CarrierScheduleQueryEntity query, boolean expired,
int maxAttempts, LocalDateTime nextAllowedAt) {}
public record Detail(QueryView query, List<CarrierScheduleQueryAttemptEntity> attempts,
List<CarrierScheduleQueryHistoryEntity> histories) {}
}
@@ -0,0 +1,32 @@
package com.baiyue.firstleg.portschedule.entity;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter
@Setter
@TableName("fl_carrier_schedule_query_attempt")
public class CarrierScheduleQueryAttemptEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long queryId;
private Integer windowAttemptNo;
private String carrierCode;
private String accessMode;
private String officialUrl;
private String attemptStatus;
private Boolean coverageGap;
private String coverageGapCode;
private String manualReason;
private String errorCode;
private String errorMessage;
private Long operatorId;
private String operatorName;
@TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt;
}
@@ -0,0 +1,49 @@
package com.baiyue.firstleg.portschedule.entity;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.Version;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter
@Setter
@TableName("fl_carrier_schedule_query")
public class CarrierScheduleQueryEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private String queryNo;
private Long customerId;
private Long oceanBookingId;
private Long containerBookingId;
private String carrierCode;
private String trackingType;
private String trackingNo;
private String officialUrl;
private String accessMode;
private String status;
private LocalDateTime windowStartedAt;
private Integer attemptCount;
private LocalDateTime nextAllowedAt;
private LocalDateTime lastAttemptAt;
private LocalDateTime lastSuccessAt;
private LocalDateTime sourceUpdatedAt;
private LocalDateTime expiresAt;
private Long resultPortScheduleId;
private String lastAttemptStatus;
private String manualReason;
private String lastErrorCode;
private String lastErrorMessage;
private String remark;
private Long createdBy;
private String createdByName;
@TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt;
private Long updatedBy;
private String updatedByName;
@TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updatedAt;
@Version @TableField(fill = FieldFill.INSERT) private Integer version;
}
@@ -0,0 +1,28 @@
package com.baiyue.firstleg.portschedule.entity;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter
@Setter
@TableName("fl_carrier_schedule_query_history")
public class CarrierScheduleQueryHistoryEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long queryId;
private String action;
private Integer fromVersion;
private Integer toVersion;
private String reason;
private String beforeSnapshot;
private String afterSnapshot;
private Long operatorId;
private String operatorName;
@TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt;
}
@@ -0,0 +1,27 @@
package com.baiyue.firstleg.portschedule.entity;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter
@Setter
@TableName("fl_carrier_schedule_query_operation")
public class CarrierScheduleQueryOperationEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long queryId;
private Long operatorId;
private String operation;
private String idempotencyKey;
private String requestHash;
private String status;
private String resultJson;
@TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt;
private LocalDateTime completedAt;
}
@@ -0,0 +1,8 @@
package com.baiyue.firstleg.portschedule.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryAttemptEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface CarrierScheduleQueryAttemptMapper extends BaseMapper<CarrierScheduleQueryAttemptEntity> {}
@@ -0,0 +1,8 @@
package com.baiyue.firstleg.portschedule.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryHistoryEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface CarrierScheduleQueryHistoryMapper extends BaseMapper<CarrierScheduleQueryHistoryEntity> {}
@@ -0,0 +1,53 @@
package com.baiyue.firstleg.portschedule.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Query;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface CarrierScheduleQueryMapper extends BaseMapper<CarrierScheduleQueryEntity> {
@Select("""
<script>
SELECT * FROM fl_carrier_schedule_query
WHERE (#{query.customerId} IS NULL OR customer_id=#{query.customerId})
AND (#{query.oceanBookingId} IS NULL OR ocean_booking_id=#{query.oceanBookingId})
AND (#{query.containerBookingId} IS NULL OR container_booking_id=#{query.containerBookingId})
AND (#{query.carrierCode} IS NULL OR carrier_code=UPPER(#{query.carrierCode}))
AND (#{query.status} IS NULL OR status=UPPER(#{query.status}))
AND (#{query.expired} IS NULL
OR (#{query.expired}=TRUE AND (last_success_at IS NULL OR expires_at IS NULL OR expires_at&lt;=CURRENT_TIMESTAMP(3)))
OR (#{query.expired}=FALSE AND last_success_at IS NOT NULL AND expires_at&gt;CURRENT_TIMESTAMP(3)))
AND (#{query.keyword} IS NULL OR query_no LIKE CONCAT('%',#{query.keyword},'%')
OR tracking_no LIKE CONCAT('%',#{query.keyword},'%'))
ORDER BY updated_at DESC,id DESC
</script>
""")
Page<CarrierScheduleQueryEntity> selectPage(Page<CarrierScheduleQueryEntity> page,
@Param("query") Query query);
@Select("SELECT * FROM fl_carrier_schedule_query WHERE id=#{id} FOR UPDATE")
CarrierScheduleQueryEntity lockById(@Param("id") long id);
@Update("""
UPDATE fl_carrier_schedule_query
SET official_url=#{value.officialUrl},access_mode=#{value.accessMode},status=#{value.status},
window_started_at=#{value.windowStartedAt},attempt_count=#{value.attemptCount},
next_allowed_at=#{value.nextAllowedAt},last_attempt_at=#{value.lastAttemptAt},
last_success_at=#{value.lastSuccessAt},source_updated_at=#{value.sourceUpdatedAt},
expires_at=#{value.expiresAt},result_port_schedule_id=#{value.resultPortScheduleId},
last_attempt_status=#{value.lastAttemptStatus},manual_reason=#{value.manualReason},
last_error_code=#{value.lastErrorCode},last_error_message=#{value.lastErrorMessage},
updated_by=#{operatorId},updated_by_name=#{operatorName},updated_at=CURRENT_TIMESTAMP(3),
version=version+1
WHERE id=#{value.id} AND customer_id=#{value.customerId} AND version=#{expectedVersion}
""")
int updateState(@Param("value") CarrierScheduleQueryEntity value,
@Param("expectedVersion") int expectedVersion,
@Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
}
@@ -0,0 +1,28 @@
package com.baiyue.firstleg.portschedule.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryOperationEntity;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface CarrierScheduleQueryOperationMapper extends BaseMapper<CarrierScheduleQueryOperationEntity> {
@Insert("""
INSERT IGNORE INTO fl_carrier_schedule_query_operation
(id,query_id,operator_id,operation,idempotency_key,request_hash,status,created_at)
VALUES (#{id},#{queryId},#{operatorId},#{operation},#{key},#{hash},'PROCESSING',CURRENT_TIMESTAMP(3))
""")
int claim(@Param("id") long id, @Param("queryId") Long queryId,
@Param("operatorId") long operatorId, @Param("operation") String operation,
@Param("key") String key, @Param("hash") String hash);
@Update("""
UPDATE fl_carrier_schedule_query_operation
SET query_id=#{queryId},status='SUCCESS',result_json=#{resultJson},completed_at=CURRENT_TIMESTAMP(3)
WHERE operator_id=#{operatorId} AND idempotency_key=#{key} AND status='PROCESSING'
""")
int complete(@Param("queryId") long queryId, @Param("operatorId") long operatorId,
@Param("key") String key, @Param("resultJson") String resultJson);
}
@@ -0,0 +1,62 @@
package com.baiyue.firstleg.portschedule.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Query;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryAttemptEntity;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryHistoryEntity;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryOperationEntity;
import com.baiyue.firstleg.portschedule.mapper.CarrierScheduleQueryAttemptMapper;
import com.baiyue.firstleg.portschedule.mapper.CarrierScheduleQueryHistoryMapper;
import com.baiyue.firstleg.portschedule.mapper.CarrierScheduleQueryMapper;
import com.baiyue.firstleg.portschedule.mapper.CarrierScheduleQueryOperationMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public class CarrierScheduleQueryRepository {
@Resource private CarrierScheduleQueryMapper queries;
@Resource private CarrierScheduleQueryAttemptMapper attempts;
@Resource private CarrierScheduleQueryHistoryMapper histories;
@Resource private CarrierScheduleQueryOperationMapper operations;
public PageResult<CarrierScheduleQueryEntity> page(Query query) {
return PageResult.from(queries.selectPage(new Page<>(query.page(), query.size()), query));
}
public CarrierScheduleQueryEntity find(long id) { return queries.selectById(id); }
public CarrierScheduleQueryEntity lock(long id) { return queries.lockById(id); }
public void insert(CarrierScheduleQueryEntity value) { queries.insert(value); }
public int updateState(CarrierScheduleQueryEntity value, int version, long operatorId, String operatorName) {
return queries.updateState(value, version, operatorId, operatorName);
}
public List<CarrierScheduleQueryAttemptEntity> attempts(long id) {
return attempts.selectList(new LambdaQueryWrapper<CarrierScheduleQueryAttemptEntity>()
.eq(CarrierScheduleQueryAttemptEntity::getQueryId, id)
.orderByAsc(CarrierScheduleQueryAttemptEntity::getCreatedAt)
.orderByAsc(CarrierScheduleQueryAttemptEntity::getId));
}
public void insert(CarrierScheduleQueryAttemptEntity value) { attempts.insert(value); }
public List<CarrierScheduleQueryHistoryEntity> histories(long id) {
return histories.selectList(new LambdaQueryWrapper<CarrierScheduleQueryHistoryEntity>()
.eq(CarrierScheduleQueryHistoryEntity::getQueryId, id)
.orderByAsc(CarrierScheduleQueryHistoryEntity::getCreatedAt)
.orderByAsc(CarrierScheduleQueryHistoryEntity::getId));
}
public void insert(CarrierScheduleQueryHistoryEntity value) { histories.insert(value); }
public boolean claimOperation(Long queryId, long operatorId, String operation, String key, String hash) {
return operations.claim(IdWorker.getId(), queryId, operatorId, operation, key, hash) == 1;
}
public CarrierScheduleQueryOperationEntity operation(long operatorId, String key) {
return operations.selectOne(new LambdaQueryWrapper<CarrierScheduleQueryOperationEntity>()
.eq(CarrierScheduleQueryOperationEntity::getOperatorId, operatorId)
.eq(CarrierScheduleQueryOperationEntity::getIdempotencyKey, key));
}
public int completeOperation(long queryId, long operatorId, String key, String resultJson) {
return operations.complete(queryId, operatorId, key, resultJson);
}
}
@@ -0,0 +1,86 @@
package com.baiyue.firstleg.portschedule.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CarrierDescriptor;
import org.springframework.stereotype.Component;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
@Component
public class CarrierScheduleAdapterRegistry {
private static final Map<String, Definition> DEFINITIONS = definitions();
public List<CarrierDescriptor> descriptors() {
return DEFINITIONS.values().stream().map(Definition::descriptor).toList();
}
public PreparedAttempt prepare(String carrierCode, String trackingType, String trackingNo) {
Definition definition = required(carrierCode);
return new PreparedAttempt(definition.code(), definition.accessMode(),
officialUrl(definition, trackingType, trackingNo), "MANUAL_REQUIRED",
definition.coverageGapCode(), definition.manualReason());
}
public String normalize(String carrierCode) {
return required(carrierCode).code();
}
private static Definition required(String carrierCode) {
String normalized = carrierCode == null ? "" : carrierCode.trim().toUpperCase(Locale.ROOT);
Definition value = DEFINITIONS.get(normalized);
if (value == null) {
throw new BizException(ResultCode.BAD_REQUEST,
"carrierCode仅支持MATSON、SML、ZIM、COSCO、EMC或HEDE");
}
return value;
}
private static String officialUrl(Definition definition, String trackingType, String trackingNo) {
String encoded = URLEncoder.encode(trackingNo.trim(), StandardCharsets.UTF_8);
if ("ZIM".equals(definition.code())) {
return definition.officialUrl() + "?consnumber=" + encoded;
}
if ("EMC".equals(definition.code())) {
String type = "CONTAINER_NO".equalsIgnoreCase(trackingType) ? "cntr" : "bl";
return definition.officialUrl() + "?c=" + encoded + "&t=" + type + "&url=EVERGREEN";
}
return definition.officialUrl();
}
private static Map<String, Definition> definitions() {
Map<String, Definition> values = new LinkedHashMap<>();
add(values, new Definition("MATSON", "美森", "https://www.matson.com/shipment-tracking.html",
"PUBLIC_FORM", "NO_DOCUMENTED_STRUCTURED_API", "官网仅公开交互表单,当前需人工查询并录入结果"));
add(values, new Definition("SML", "SML", "https://esvc.smlines.com/smline/CUP_HOM_3301.do?sessLocale=zh",
"PUBLIC_FORM", "NO_DOCUMENTED_STRUCTURED_API", "官网未提供稳定公开结构化接口,当前需人工查询并录入结果"));
add(values, new Definition("ZIM", "ZIM", "https://www.zimchina.com/tools/track-a-shipment",
"HCAPTCHA", "HCAPTCHA_REQUIRED", "官网启用hCaptcha,禁止绕过验证码,必须人工查询"));
add(values, new Definition("COSCO", "COSCO", "https://elines.coscoshipping.com/ebusiness/",
"DYNAMIC_IFRAME", "DYNAMIC_IFRAME_REQUIRED", "官网使用动态页面/iframe,未发现稳定公开API,必须人工查询"));
add(values, new Definition("EMC", "EMC", "https://www.evergreen-shipping.cn/servlet/TDB1_CargoTracking.do",
"PUBLIC_FORM", "NO_DOCUMENTED_STRUCTURED_API", "官网仅公开查询表单,当前需人工确认页面结果后录入"));
add(values, new Definition("HEDE", "合德", "http://elines.hedehk.com/cargoDynamic",
"PUBLIC_FORM", "NO_DOCUMENTED_STRUCTURED_API", "官网未提供稳定公开结构化接口,当前需人工查询并录入结果"));
return Collections.unmodifiableMap(values);
}
private static void add(Map<String, Definition> values, Definition value) { values.put(value.code(), value); }
private record Definition(String code, String name, String officialUrl, String accessMode,
String coverageGapCode, String manualReason) {
CarrierDescriptor descriptor() {
return new CarrierDescriptor(code, name, officialUrl, accessMode, false, true,
coverageGapCode, manualReason);
}
}
public record PreparedAttempt(String carrierCode, String accessMode, String officialUrl,
String status, String coverageGapCode, String manualReason) {}
}
@@ -0,0 +1,289 @@
package com.baiyue.firstleg.portschedule.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.AttemptRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CarrierDescriptor;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CreateRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Detail;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualResultRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Query;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.QueryView;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryAttemptEntity;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryHistoryEntity;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryOperationEntity;
import com.baiyue.firstleg.portschedule.entity.PortScheduleEntity;
import com.baiyue.firstleg.portschedule.repository.CarrierScheduleQueryRepository;
import com.baiyue.firstleg.portschedule.repository.PortScheduleRepository;
import com.baiyue.firstleg.portschedule.service.CarrierScheduleAdapterRegistry.PreparedAttempt;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Clock;
import java.time.LocalDateTime;
import java.util.HexFormat;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
@Service
public class CarrierScheduleQueryService {
static final int MAX_ATTEMPTS = 3;
static final int WINDOW_DAYS = 3;
private static final Set<String> TRACKING_TYPES = Set.of("BOOKING_NO", "BILL_OF_LADING", "CONTAINER_NO");
@Resource private CarrierScheduleQueryRepository repository;
@Resource private PortScheduleRepository portSchedules;
@Resource private CarrierScheduleAdapterRegistry adapters;
@Resource private ObjectMapper objectMapper;
private Clock clock = Clock.systemDefaultZone();
public List<CarrierDescriptor> carriers() { return adapters.descriptors(); }
public PageResult<QueryView> page(Query query) {
PageResult<CarrierScheduleQueryEntity> source = repository.page(query);
LocalDateTime now = now();
return new PageResult<>(source.records().stream().map(value -> view(value, now)).toList(),
source.page(), source.size(), source.total(), source.pages());
}
public Detail detail(long id) { return detail(required(id)); }
public List<CarrierScheduleQueryHistoryEntity> history(long id) {
required(id);
return repository.histories(id);
}
public long customerId(long id) { return required(id).getCustomerId(); }
@Transactional
public Detail create(long operatorId, String operatorName, String key, CreateRequest request) {
String operation = "CREATE";
Detail replay = claimOrReplay(null, operatorId, operation, key, hash(operation, null, request));
if (replay != null) return replay;
validateLinks(request.customerId(), request.oceanBookingId(), request.containerBookingId());
String carrierCode = adapters.normalize(request.carrierCode());
String trackingType = normalizeTrackingType(request.trackingType());
PreparedAttempt strategy = adapters.prepare(carrierCode, trackingType, request.trackingNo());
LocalDateTime now = now();
CarrierScheduleQueryEntity value = new CarrierScheduleQueryEntity();
value.setId(IdWorker.getId()); value.setQueryNo("CSQ" + value.getId());
value.setCustomerId(request.customerId()); value.setOceanBookingId(request.oceanBookingId());
value.setContainerBookingId(request.containerBookingId()); value.setCarrierCode(carrierCode);
value.setTrackingType(trackingType); value.setTrackingNo(request.trackingNo().trim());
value.setOfficialUrl(strategy.officialUrl()); value.setAccessMode(strategy.accessMode());
value.setStatus("READY"); value.setWindowStartedAt(now); value.setAttemptCount(0);
value.setNextAllowedAt(now); value.setManualReason(strategy.manualReason()); value.setRemark(trim(request.remark()));
value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0);
try { repository.insert(value); }
catch (DuplicateKeyException ex) { throw conflict("同一订舱、柜次、船司和跟踪号的查询任务已存在"); }
addHistory(value.getId(), operation, null, 0, request.remark(), null, value, operatorId, operatorName);
return complete(value.getId(), operatorId, key, detail(value));
}
@Transactional
public Detail attempt(long id, long operatorId, String operatorName, String key, AttemptRequest request) {
String operation = "ATTEMPT";
Detail replay = claimOrReplay(id, operatorId, operation, key, hash(operation, id, request));
if (replay != null) return replay;
CarrierScheduleQueryEntity before = requiredLockedVersion(id, request.expectedVersion());
LocalDateTime now = now();
LocalDateTime windowStart = before.getWindowStartedAt();
if (windowStart == null || !now.isBefore(windowStart.plusDays(WINDOW_DAYS))) {
windowStart = now;
before.setAttemptCount(0);
}
if (before.getLastSuccessAt() != null && before.getNextAllowedAt() != null
&& now.isBefore(before.getNextAllowedAt())) {
throw conflict("船期查询每3天执行一次,下次允许时间:" + before.getNextAllowedAt());
}
int attemptCount = count(before.getAttemptCount());
LocalDateTime windowEnd = windowStart.plusDays(WINDOW_DAYS);
if (attemptCount >= MAX_ATTEMPTS && now.isBefore(windowEnd)) {
throw conflict("当前3天窗口最多查询3次,下次允许时间:" + windowEnd);
}
PreparedAttempt strategy = adapters.prepare(before.getCarrierCode(), before.getTrackingType(), before.getTrackingNo());
CarrierScheduleQueryEntity snapshot = copy(before);
int nextCount = attemptCount + 1;
before.setOfficialUrl(strategy.officialUrl()); before.setAccessMode(strategy.accessMode());
before.setStatus(strategy.status()); before.setWindowStartedAt(windowStart); before.setAttemptCount(nextCount);
before.setLastAttemptAt(now); before.setLastAttemptStatus(strategy.status());
before.setManualReason(strategy.manualReason()); before.setLastErrorCode(strategy.coverageGapCode());
before.setLastErrorMessage(strategy.manualReason());
before.setNextAllowedAt(nextCount >= MAX_ATTEMPTS ? windowEnd : now);
if (repository.updateState(before, request.expectedVersion(), operatorId, operatorName) != 1) throw concurrent();
CarrierScheduleQueryEntity after = required(id);
CarrierScheduleQueryAttemptEntity attempt = new CarrierScheduleQueryAttemptEntity();
attempt.setId(IdWorker.getId()); attempt.setQueryId(id); attempt.setWindowAttemptNo(nextCount);
attempt.setCarrierCode(after.getCarrierCode()); attempt.setAccessMode(strategy.accessMode());
attempt.setOfficialUrl(strategy.officialUrl()); attempt.setAttemptStatus(strategy.status());
attempt.setCoverageGap(true); attempt.setCoverageGapCode(strategy.coverageGapCode());
attempt.setManualReason(strategy.manualReason()); attempt.setErrorCode(strategy.coverageGapCode());
attempt.setErrorMessage(strategy.manualReason()); attempt.setOperatorId(operatorId); attempt.setOperatorName(operatorName);
repository.insert(attempt);
addHistory(id, operation, snapshot.getVersion(), after.getVersion(), request.reason(), snapshot, after,
operatorId, operatorName);
return complete(id, operatorId, key, detail(after));
}
@Transactional
public Detail attachManualResult(long id, long operatorId, String operatorName, String key,
ManualResultRequest request) {
String operation = "MANUAL_RESULT";
Detail replay = claimOrReplay(id, operatorId, operation, key, hash(operation, id, request));
if (replay != null) return replay;
CarrierScheduleQueryEntity value = requiredLockedVersion(id, request.expectedVersion());
PortScheduleEntity schedule = portSchedules.find(request.portScheduleId());
if (schedule == null) throw new BizException(ResultCode.NOT_FOUND, "人工船期结果不存在");
if (!Objects.equals(schedule.getCustomerId(), value.getCustomerId())
|| !Objects.equals(schedule.getOceanBookingId(), value.getOceanBookingId())
|| !Objects.equals(schedule.getContainerBookingId(), value.getContainerBookingId())) {
throw badRequest("人工船期结果与查询任务的客户、订舱或柜次不一致");
}
CarrierScheduleQueryEntity before = copy(value);
LocalDateTime now = now();
value.setStatus("SUCCESS"); value.setResultPortScheduleId(schedule.getId());
value.setLastSuccessAt(now); value.setSourceUpdatedAt(schedule.getObservedAt());
value.setExpiresAt(schedule.getExpiresAt()); value.setNextAllowedAt(now.plusDays(WINDOW_DAYS));
value.setWindowStartedAt(now); value.setLastAttemptStatus("MANUAL_COMPLETED");
value.setManualReason(trim(request.reason())); value.setLastErrorCode(null); value.setLastErrorMessage(null);
if (repository.updateState(value, request.expectedVersion(), operatorId, operatorName) != 1) throw concurrent();
CarrierScheduleQueryEntity after = required(id);
addHistory(id, operation, before.getVersion(), after.getVersion(), request.reason(), before, after,
operatorId, operatorName);
return complete(id, operatorId, key, detail(after));
}
private void validateLinks(long customerId, long oceanBookingId, Long containerBookingId) {
OceanBookingEntity booking = portSchedules.oceanBooking(oceanBookingId);
if (booking == null) throw new BizException(ResultCode.NOT_FOUND, "关联订舱不存在");
if (!Objects.equals(booking.getCustomerId(), customerId)) throw badRequest("customerId与订舱客户不一致");
if (containerBookingId != null) {
if (portSchedules.containerBooking(containerBookingId) == null) {
throw new BizException(ResultCode.NOT_FOUND, "关联柜次不存在");
}
if (portSchedules.allocation(oceanBookingId, containerBookingId) == null) {
throw badRequest("containerBookingId未分配至该订舱");
}
}
}
private Detail claimOrReplay(Long id, long operatorId, String operation, String key, String requestHash) {
validateKey(key);
if (repository.claimOperation(id, operatorId, operation, key, requestHash)) return null;
CarrierScheduleQueryOperationEntity existing = repository.operation(operatorId, key);
if (existing == null || !requestHash.equals(existing.getRequestHash())) throw conflict("幂等键已用于其他请求");
if (!"SUCCESS".equals(existing.getStatus()) || !StringUtils.hasText(existing.getResultJson())) {
throw conflict("相同请求正在处理中,请稍后重试");
}
try { return objectMapper.readValue(existing.getResultJson(), Detail.class); }
catch (JsonProcessingException ex) { throw new IllegalStateException("无法读取船司查询幂等结果", ex); }
}
private Detail complete(long id, long operatorId, String key, Detail result) {
try {
if (repository.completeOperation(id, operatorId, key, objectMapper.writeValueAsString(result)) != 1) {
throw conflict("船司查询幂等请求完成状态冲突");
}
return result;
} catch (JsonProcessingException ex) { throw new IllegalStateException("无法保存船司查询幂等结果", ex); }
}
private void addHistory(long id, String action, Integer fromVersion, Integer toVersion, String reason,
Object before, Object after, long operatorId, String operatorName) {
CarrierScheduleQueryHistoryEntity history = new CarrierScheduleQueryHistoryEntity();
history.setId(IdWorker.getId()); history.setQueryId(id); history.setAction(action);
history.setFromVersion(fromVersion); history.setToVersion(toVersion); history.setReason(trim(reason));
history.setBeforeSnapshot(json(before)); history.setAfterSnapshot(json(after));
history.setOperatorId(operatorId); history.setOperatorName(operatorName); repository.insert(history);
}
private Detail detail(CarrierScheduleQueryEntity value) {
return new Detail(view(value, now()), repository.attempts(value.getId()), repository.histories(value.getId()));
}
private static QueryView view(CarrierScheduleQueryEntity value, LocalDateTime now) {
boolean expired = value.getLastSuccessAt() == null || value.getExpiresAt() == null
|| !value.getExpiresAt().isAfter(now);
return new QueryView(value, expired, MAX_ATTEMPTS, value.getNextAllowedAt());
}
private CarrierScheduleQueryEntity required(long id) {
CarrierScheduleQueryEntity value = repository.find(id);
if (value == null) throw new BizException(ResultCode.NOT_FOUND, "船司查询任务不存在");
return value;
}
private CarrierScheduleQueryEntity requiredLockedVersion(long id, int version) {
CarrierScheduleQueryEntity value = repository.lock(id);
if (value == null) throw new BizException(ResultCode.NOT_FOUND, "船司查询任务不存在");
if (!Integer.valueOf(version).equals(value.getVersion())) throw concurrent();
return value;
}
private static CarrierScheduleQueryEntity copy(CarrierScheduleQueryEntity source) {
CarrierScheduleQueryEntity value = new CarrierScheduleQueryEntity();
value.setId(source.getId()); value.setQueryNo(source.getQueryNo()); value.setCustomerId(source.getCustomerId());
value.setOceanBookingId(source.getOceanBookingId()); value.setContainerBookingId(source.getContainerBookingId());
value.setCarrierCode(source.getCarrierCode()); value.setTrackingType(source.getTrackingType());
value.setTrackingNo(source.getTrackingNo()); value.setOfficialUrl(source.getOfficialUrl());
value.setAccessMode(source.getAccessMode()); value.setStatus(source.getStatus());
value.setWindowStartedAt(source.getWindowStartedAt()); value.setAttemptCount(source.getAttemptCount());
value.setNextAllowedAt(source.getNextAllowedAt()); value.setLastAttemptAt(source.getLastAttemptAt());
value.setLastSuccessAt(source.getLastSuccessAt()); value.setSourceUpdatedAt(source.getSourceUpdatedAt());
value.setExpiresAt(source.getExpiresAt()); value.setResultPortScheduleId(source.getResultPortScheduleId());
value.setLastAttemptStatus(source.getLastAttemptStatus()); value.setManualReason(source.getManualReason());
value.setLastErrorCode(source.getLastErrorCode()); value.setLastErrorMessage(source.getLastErrorMessage());
value.setRemark(source.getRemark()); value.setVersion(source.getVersion());
return value;
}
private String hash(String operation, Long id, Object request) {
try {
byte[] bytes = (operation + '|' + id + '|' + objectMapper.writeValueAsString(request))
.getBytes(StandardCharsets.UTF_8);
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes));
} catch (Exception ex) { throw new IllegalStateException("无法计算船司查询请求摘要", ex); }
}
private String json(Object value) {
if (value == null) return null;
try { return objectMapper.writeValueAsString(value); }
catch (JsonProcessingException ex) { throw new IllegalStateException("无法保存船司查询历史快照", ex); }
}
private static String normalizeTrackingType(String value) {
String normalized = value.trim().toUpperCase(Locale.ROOT);
if (!TRACKING_TYPES.contains(normalized)) {
throw badRequest("trackingType仅支持BOOKING_NO、BILL_OF_LADING或CONTAINER_NO");
}
return normalized;
}
private LocalDateTime now() { return LocalDateTime.now(clock); }
private static int count(Integer value) { return value == null ? 0 : value; }
private static void validateKey(String key) {
if (!StringUtils.hasText(key) || key.length() > 128) throw badRequest("Idempotency-Key不能为空且不能超过128个字符");
}
private static String trim(String value) {
if (value == null) return null;
String result = value.trim();
return result.isEmpty() ? null : result;
}
private static BizException badRequest(String message) { return new BizException(ResultCode.BAD_REQUEST, message); }
private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); }
private static BizException concurrent() { return conflict("数据已被其他人修改,请刷新后重试"); }
}