This commit is contained in:
2026-08-20 18:02:38 +08:00
parent b55b049e77
commit 1cd8b6a473
210 changed files with 6974 additions and 825 deletions
+4 -2
View File
@@ -28,8 +28,9 @@
| 货柜与智能排柜 | `controller/PlatformContainerController.java`、`PlatformSmartLoadingController.java`、`loading/` |
| PDA 拣货与装柜 | `controller/PlatformLoadingPdaController.java`、`loading/controller/PlatformLoadingDeliveryController.java` |
| 装柜完成跨区事件 | `service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`repository/LoadingCompletedOutboxRepository.java`、`client/EventBridgeClient.java`;事件模型见 `../common-event-contract/AGENTS.md` |
| 海外卸柜完成事件回写 | 内部入口 `controller/InternalOverseasContainerEventController.java`、消费者 `service/OverseasContainerEventConsumer.java`、Inbox `entity/ContainerEventInboxEntity.java` 与 `mapper/ContainerEventInboxMapper.java`;事件模型见 `../common-event-contract/AGENTS.md` |
| 三期海运订舱与出仓前船期资料 | `oceanbooking/controller/PlatformOceanBookingController.java`、`oceanbooking/service/OceanBookingService.java`、`portschedule/controller/PlatformPortScheduleController.java`;订舱契约见 `src/main/resources/openapi/ocean-bookings-v1.yaml`,表结构见 `src/main/resources/db/phase3-ocean-booking-schema.sql` |
| 四期国内出仓后船节点定时查询 | 现有 `portschedule/controller/PlatformCarrierScheduleQueryController.java`、`service/CarrierScheduleQueryService.java` 及查询任务、尝试、历史整体归四期;新增定时编排、节点事实或到港衔接继续归该子域,外部船司/轨迹调用经 `integration-service/`;范围见 `../../docs/四期/README.md` |
| 四期国内出仓后船节点定时查询 | `outbound/dispatch/service/OutboundDispatchService.java` 在确认出仓事务内幂等创建现有 `CarrierScheduleQuery`;编排与租约 `portschedule/service/CarrierScheduleQueryScheduler.java`,任务/标准节点事实/历史 `CarrierScheduleQueryService.java`,内部停止入口 `controller/InternalCarrierScheduleQueryController.java`,平台只读入口 `PlatformCarrierScheduleQueryController.java`;外部船司/轨迹调用经 `integration-service/`;范围见 `../../docs/四期/README.md` |
| Firstleg 业务单据编号 | `service/FirstlegDocumentNumberGenerator.java`;当前支持 `OB`、`TA`、`CB`、`CAB`、`LP` 前缀,按北京时间每日独立编号;具体业务归属以各调用服务为准 |
| 拖车与出仓调度 | `controller/PlatformTruckAppointmentController.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java` |
| 报关协同 | `customs/controller/PlatformCustomsController.java`、`MerchantCustomsController.java` |
@@ -49,7 +50,8 @@
- 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合,放舱后永久只读。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。
- 订舱柜型需求至少一项、最多 20 项,数量必须为正整数,容量和限重必须匹配平台柜型规格。起运仓必须是启用的国内中转仓;物流渠道必须来自该仓当前 ACTIVE 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。
- 订舱号统一使用 `OB-yyMMdd-####`,按北京时间创建日每日独立递增且单日最多 9999;不得以实体 ID 拼接生成。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。
- 四期船节点任务从确认柜子离开国内仓库后启用,必须绑定真实订舱和柜次,保存来源、节点观测时间、最近成功结果、失败尝试及完整历史,并以幂等方式把确认到港/可提柜结果衔接给海外区域。现有 `CarrierScheduleQuery` 及“每 3 天一次、窗口最多 3 次”约束属于四期;外部失败不得推进业务节点,不得在三期另建查询任务。
- 四期船节点任务从确认柜子离开国内仓库后启用,必须绑定真实订舱和柜次;标准节点固定为 `DEPARTED_WAREHOUSE → AT_ORIGIN_PORT → DEPARTED_ORIGIN_PORT → IN_TRANSIT → ARRIVED → PICKUP_READY`,保存来源、节点观测时间、最近成功结果、失败尝试及完整历史。现有 `CarrierScheduleQuery` 及“正常每 3 天一次、窗口最多 3 次、失败指数退避”约束属于四期;租约、调度尝试和内部停止入口不得暴露为平台页面 API,外部失败不得推进业务节点,不得在三期另建查询任务。
- 海外卸柜完成事件只接受 `OVERSEAS -> CN` 的 `OVERSEAS_CONTAINER_UNLOADED` V1,并通过内部服务令牌进入。消费者必须校验信封、Payload 哈希、来源货柜和时间顺序,以 event ID + Payload 哈希幂等,使用事件聚合版本阻止乱序覆盖;预计报空时间固定按卸柜照片上传时间加两天计算,实际报空时间可为空。
- Firstleg 只拥有业务费用及履约事实;Finance 拥有费用入池、账期、账单、收款、成本分摊结果和财务状态。修改费用来源版本、发布条件、柜次归属或体积快照时同步检查 `../finance/src/main/java/com/baiyue/finance/client/FirstlegFeeSourceClient.java`、同目录 `FirstlegCostSnapshotClient.java` 及双方契约测试,不在 Firstleg 复制财务状态机。
## 三期装柜与 PDA 资源结算
@@ -0,0 +1,28 @@
package com.baiyue.firstleg.client;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import java.time.LocalDateTime;
import java.util.List;
@FeignClient(name = "integration-service", contextId = "carrierScheduleIntegrationClient",
url = "${baiyue.integration-service.url:}")
public interface CarrierScheduleIntegrationClient {
@PostMapping("/internal/integrations/carrier-schedules/query")
Envelope<QueryResult> query(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody QueryRequest request);
record Envelope<T>(String code, String message, T data) {
public boolean successful() { return "200".equals(code); }
}
record QueryRequest(String carrierCode, String trackingType, String trackingNo) { }
record QueryResult(String providerCode, String outcome, List<Observation> observations,
String errorCode, String errorMessage, LocalDateTime providerObservedAt) { }
record Observation(String providerEventId, String providerNodeCode, String standardNode,
LocalDateTime observedAt, LocalDateTime estimatedAt, LocalDateTime actualAt,
String locationCode, String locationName, String sourceReference,
String rawPayloadHash) { }
}
@@ -14,6 +14,14 @@ public final class ContainerOverviewDtos {
List<TimelineEntry> timeline) {}
public record ContainerBookingEntityView(Long id, String bookingNo, String cabinetNo, String containerNo,
String status, Long warehouseId, String warehouseName, String voyageNo, String vesselName,
String billOfLadingNo, Long shippingCompanyId, String shippingCompanyName,
String departurePortCode, String departurePortName, String destinationPortCode,
String destinationPortName, LocalDateTime estimatedSailingAt, LocalDateTime actualSailingAt,
LocalDateTime estimatedPortArrivalAt, LocalDateTime actualPortArrivalAt,
LocalDateTime estimatedWarehouseArrivalAt, LocalDateTime actualWarehouseArrivalAt,
LocalDateTime unloadingCompletedAt, LocalDateTime unloadingPhotoUploadedAt,
LocalDateTime estimatedEmptyReportedAt, LocalDateTime actualEmptyReportedAt,
Long emptyReportSourceVersion,
Integer version) {}
public record PdaSummary(Long taskId, Long loadingPlanId, Long containerBookingId, String taskStatus,
String workflowStatus, Integer taskVersion, Integer itemCount, Integer completedItemCount, Integer unresolvedItemCount,
@@ -116,7 +116,7 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
actual_empty_reported_at=#{actualEmptyReportedAt}, empty_report_source_version=#{sourceVersion},
updated_by=0, updated_by_name='OVERSEAS_EVENT', updated_at=CURRENT_TIMESTAMP(3), version=version+1
WHERE id=#{id} AND record_status='ACTIVE'
AND (empty_report_source_version IS NULL OR empty_report_source_version &lt; #{sourceVersion})
AND (empty_report_source_version IS NULL OR empty_report_source_version < #{sourceVersion})
""")
int applyEmptyReport(@Param("id") long id,
@Param("unloadingCompletedAt") java.time.LocalDateTime unloadingCompletedAt,
@@ -10,6 +10,8 @@ import com.baiyue.firstleg.outbound.dispatch.entity.OutboundDispatchHistoryEntit
import com.baiyue.firstleg.outbound.dispatch.gate.OutboundDispatchGateContributor;
import com.baiyue.firstleg.outbound.dispatch.mapper.OutboundDispatchReadMapper;
import com.baiyue.firstleg.outbound.dispatch.repository.OutboundDispatchRepository;
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -26,10 +28,14 @@ public class OutboundDispatchService {
private final OutboundDispatchRepository repository;
private final Map<String, OutboundDispatchGateContributor> contributors;
private final CarrierScheduleQueryService carrierSchedules;
@Autowired
public OutboundDispatchService(OutboundDispatchRepository repository,
List<OutboundDispatchGateContributor> contributors) {
List<OutboundDispatchGateContributor> contributors,
CarrierScheduleQueryService carrierSchedules) {
this.repository = repository;
this.carrierSchedules = carrierSchedules;
Map<String, OutboundDispatchGateContributor> indexed = new HashMap<>();
for (OutboundDispatchGateContributor contributor : contributors) {
if (indexed.put(contributor.code(), contributor) != null) {
@@ -39,6 +45,11 @@ public class OutboundDispatchService {
this.contributors = Map.copyOf(indexed);
}
public OutboundDispatchService(OutboundDispatchRepository repository,
List<OutboundDispatchGateContributor> contributors) {
this(repository, contributors, null);
}
public List<Aggregate> list(Query query) {
requireSelector(query);
return repository.bindings(query).stream().map(this::aggregate).toList();
@@ -73,6 +84,10 @@ public class OutboundDispatchService {
}
history(state, "CONFIRM_DISPATCH", OutboundDispatchStatus.READY_FOR_DISPATCH.name(),
OutboundDispatchStatus.WAITING_FOR_LOADING.name(), request.remark(), operatorId, operatorName);
if (carrierSchedules != null) {
carrierSchedules.ensureFromOutboundConfirm(binding.tenantId(), before.oceanBookingId(),
binding.containerBookingId(), operatorId, operatorName);
}
if (repository.completeOperation(state.getId(), operatorId, "CONFIRM_DISPATCH", key) != 1) throw concurrent();
return detail(query);
}
@@ -0,0 +1,48 @@
package com.baiyue.firstleg.portschedule.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Detail;
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
@RestController
@RequestMapping("/internal/firstleg/v1/carrier-schedule-queries")
public class InternalCarrierScheduleQueryController {
private final CarrierScheduleQueryService service;
@Value("${baiyue.security.internal-service-token}") private String expectedToken;
public InternalCarrierScheduleQueryController(CarrierScheduleQueryService service) { this.service = service; }
@PostMapping("/{queryId}/stop")
public ApiResponse<Detail> stop(@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@PathVariable @Positive long queryId,
@Valid @RequestBody StopRequest request) {
authenticate(token);
return ApiResponse.success(service.stop(queryId, request.reason()));
}
private void authenticate(String token) {
if (token == null || expectedToken == null || expectedToken.isBlank()
|| !MessageDigest.isEqual(token.getBytes(StandardCharsets.UTF_8),
expectedToken.getBytes(StandardCharsets.UTF_8))) {
throw new BizException(ResultCode.FORBIDDEN, "内部服务令牌无效");
}
}
public record StopRequest(@NotBlank @Size(max = 500) String reason) { }
}
@@ -4,14 +4,13 @@ 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.dto.CarrierScheduleQueryDtos.History;
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import io.swagger.v3.oas.annotations.Operation;
@@ -66,7 +65,7 @@ public class PlatformCarrierScheduleQueryController {
@GetMapping("/{queryId}/history")
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:list')")
public ApiResponse<List<CarrierScheduleQueryHistoryEntity>> history(
public ApiResponse<List<History>> history(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long queryId) {
FirstlegScopeGuard.platform(user, service.customerId(queryId));
return ApiResponse.success(service.history(queryId));
@@ -83,18 +82,6 @@ public class PlatformCarrierScheduleQueryController {
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",
@@ -0,0 +1,29 @@
package com.baiyue.firstleg.portschedule.domain;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import java.util.Locale;
/** Provider-independent maritime milestones in monotonic business order. */
public enum CarrierScheduleNode {
DEPARTED_WAREHOUSE(10),
AT_ORIGIN_PORT(20),
DEPARTED_ORIGIN_PORT(30),
IN_TRANSIT(40),
ARRIVED(50),
PICKUP_READY(60);
private final int sequence;
CarrierScheduleNode(int sequence) { this.sequence = sequence; }
public int sequence() { return sequence; }
public static CarrierScheduleNode require(String value) {
try { return valueOf(value == null ? "" : value.trim().toUpperCase(Locale.ROOT)); }
catch (IllegalArgumentException exception) {
throw new BizException(ResultCode.BAD_REQUEST, "不支持的船节点:" + value);
}
}
}
@@ -1,8 +1,5 @@
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;
@@ -33,20 +30,44 @@ public final class CarrierScheduleQueryDtos {
@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 ProviderObservationRequest(@NotBlank @Size(max = 32) String providerCode,
@NotBlank @Size(max = 128) String providerEventId,
@NotBlank @Size(max = 64) String providerNodeCode,
@NotBlank @Size(max = 32) String standardNode,
@NotNull LocalDateTime observedAt,
LocalDateTime estimatedAt,
LocalDateTime actualAt,
@Size(max = 32) String locationCode,
@Size(max = 128) String locationName,
@Size(max = 500) String sourceReference,
@NotBlank @Size(min = 64, max = 64) String rawPayloadHash) {}
public record CarrierDescriptor(String carrierCode, String carrierName, String officialUrl,
String accessMode, boolean automated, boolean coverageGap,
String coverageGapCode, String manualReason) {}
public record QueryView(CarrierScheduleQueryEntity query, boolean expired,
int maxAttempts, LocalDateTime nextAllowedAt) {}
public record Task(Long id, String queryNo, Long customerId, Long oceanBookingId,
Long containerBookingId, String carrierCode, String trackingType, String trackingNo,
String officialUrl, String accessMode, String status, String triggerSource,
String latestStandardNode, LocalDateTime latestNodeObservedAt,
LocalDateTime lastSuccessAt, LocalDateTime sourceUpdatedAt, LocalDateTime expiresAt,
Long resultPortScheduleId, String manualReason, String lastErrorCode,
String lastErrorMessage, LocalDateTime stoppedAt, String stopReason, Integer version) {}
public record Detail(QueryView query, List<CarrierScheduleQueryAttemptEntity> attempts,
List<CarrierScheduleQueryHistoryEntity> histories) {}
public record QueryView(Task query, boolean expired) {}
public record NodeFact(Long id, String providerCode, String providerNodeCode, String standardNode,
int nodeSequence, LocalDateTime observedAt, LocalDateTime estimatedAt,
LocalDateTime actualAt, String locationCode, String locationName,
String sourceReference, String rawPayloadHash, LocalDateTime createdAt) {}
public record History(Long id, String action, Integer fromVersion, Integer toVersion, String reason,
Long operatorId, String operatorName, LocalDateTime createdAt) {}
/** Frontend detail intentionally excludes scheduler leases and raw query attempts. */
public record Detail(QueryView query, List<NodeFact> nodes, List<History> histories) {}
}
@@ -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_node_fact")
public class CarrierScheduleNodeFactEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long queryId;
private String eventKey;
private String providerCode;
private String providerNodeCode;
private String standardNode;
private Integer nodeSequence;
private LocalDateTime observedAt;
private LocalDateTime estimatedAt;
private LocalDateTime actualAt;
private String locationCode;
private String locationName;
private String sourceReference;
private String rawPayloadHash;
@TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt;
}
@@ -26,6 +26,14 @@ public class CarrierScheduleQueryEntity {
private String officialUrl;
private String accessMode;
private String status;
private String triggerSource;
private String latestStandardNode;
private LocalDateTime latestNodeObservedAt;
private LocalDateTime stoppedAt;
private String stopReason;
private String leaseOwner;
private LocalDateTime leaseUntil;
private Integer consecutiveFailures;
private LocalDateTime windowStartedAt;
private Integer attemptCount;
private LocalDateTime nextAllowedAt;
@@ -0,0 +1,23 @@
package com.baiyue.firstleg.portschedule.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Param;
@Mapper
public interface CarrierScheduleNodeFactMapper extends BaseMapper<CarrierScheduleNodeFactEntity> {
@Insert("""
INSERT INTO fl_carrier_schedule_node_fact
(id,query_id,event_key,provider_code,provider_node_code,standard_node,node_sequence,observed_at,
estimated_at,actual_at,location_code,location_name,source_reference,raw_payload_hash,created_at)
VALUES
(#{value.id},#{value.queryId},#{value.eventKey},#{value.providerCode},#{value.providerNodeCode},
#{value.standardNode},#{value.nodeSequence},#{value.observedAt},#{value.estimatedAt},#{value.actualAt},
#{value.locationCode},#{value.locationName},#{value.sourceReference},#{value.rawPayloadHash},
CURRENT_TIMESTAMP(3))
ON DUPLICATE KEY UPDATE id=id
""")
int insertIgnore(@Param("value") CarrierScheduleNodeFactEntity value);
}
@@ -9,6 +9,9 @@ import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.time.LocalDateTime;
import java.util.List;
@Mapper
public interface CarrierScheduleQueryMapper extends BaseMapper<CarrierScheduleQueryEntity> {
@Select("""
@@ -33,9 +36,42 @@ public interface CarrierScheduleQueryMapper extends BaseMapper<CarrierScheduleQu
@Select("SELECT * FROM fl_carrier_schedule_query WHERE id=#{id} FOR UPDATE")
CarrierScheduleQueryEntity lockById(@Param("id") long id);
@Select("""
SELECT * FROM fl_carrier_schedule_query
WHERE customer_id=#{customerId} AND ocean_booking_id=#{oceanBookingId}
AND container_booking_id=#{containerBookingId}
ORDER BY id DESC LIMIT 1
""")
CarrierScheduleQueryEntity findByBusinessScope(@Param("customerId") long customerId,
@Param("oceanBookingId") long oceanBookingId,
@Param("containerBookingId") long containerBookingId);
@Select("""
SELECT * FROM fl_carrier_schedule_query
WHERE status IN ('READY','MANUAL_REQUIRED','ARRIVED','PICKUP_READY')
AND next_allowed_at IS NOT NULL AND next_allowed_at&lt;=#{now}
AND (lease_until IS NULL OR lease_until&lt;#{now})
ORDER BY next_allowed_at,id LIMIT #{limit}
""")
List<CarrierScheduleQueryEntity> selectDue(@Param("now") LocalDateTime now, @Param("limit") int limit);
@Update("""
UPDATE fl_carrier_schedule_query SET lease_owner=#{owner},lease_until=#{leaseUntil},
updated_at=CURRENT_TIMESTAMP(3)
WHERE id=#{id} AND status IN ('READY','MANUAL_REQUIRED','ARRIVED','PICKUP_READY')
AND next_allowed_at IS NOT NULL AND next_allowed_at&lt;=#{now}
AND (lease_until IS NULL OR lease_until&lt;#{now})
""")
int claimLease(@Param("id") long id, @Param("owner") String owner,
@Param("now") LocalDateTime now, @Param("leaseUntil") LocalDateTime leaseUntil);
@Update("""
UPDATE fl_carrier_schedule_query
SET official_url=#{value.officialUrl},access_mode=#{value.accessMode},status=#{value.status},
trigger_source=#{value.triggerSource},latest_standard_node=#{value.latestStandardNode},
latest_node_observed_at=#{value.latestNodeObservedAt},stopped_at=#{value.stoppedAt},
stop_reason=#{value.stopReason},lease_owner=#{value.leaseOwner},lease_until=#{value.leaseUntil},
consecutive_failures=#{value.consecutiveFailures},
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},
@@ -9,14 +9,17 @@ 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.CarrierScheduleNodeFactEntity;
import com.baiyue.firstleg.portschedule.mapper.CarrierScheduleQueryAttemptMapper;
import com.baiyue.firstleg.portschedule.mapper.CarrierScheduleQueryHistoryMapper;
import com.baiyue.firstleg.portschedule.mapper.CarrierScheduleQueryMapper;
import com.baiyue.firstleg.portschedule.mapper.CarrierScheduleQueryOperationMapper;
import com.baiyue.firstleg.portschedule.mapper.CarrierScheduleNodeFactMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.time.LocalDateTime;
@Repository
public class CarrierScheduleQueryRepository {
@@ -24,12 +27,20 @@ public class CarrierScheduleQueryRepository {
@Resource private CarrierScheduleQueryAttemptMapper attempts;
@Resource private CarrierScheduleQueryHistoryMapper histories;
@Resource private CarrierScheduleQueryOperationMapper operations;
@Resource private CarrierScheduleNodeFactMapper nodes;
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 CarrierScheduleQueryEntity findByBusinessScope(long customerId, long oceanBookingId, long containerBookingId) {
return queries.findByBusinessScope(customerId, oceanBookingId, containerBookingId);
}
public List<CarrierScheduleQueryEntity> due(LocalDateTime now, int limit) { return queries.selectDue(now, limit); }
public boolean claimLease(long id, String owner, LocalDateTime now, LocalDateTime leaseUntil) {
return queries.claimLease(id, owner, now, leaseUntil) == 1;
}
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);
@@ -48,6 +59,14 @@ public class CarrierScheduleQueryRepository {
.orderByAsc(CarrierScheduleQueryHistoryEntity::getId));
}
public void insert(CarrierScheduleQueryHistoryEntity value) { histories.insert(value); }
public List<CarrierScheduleNodeFactEntity> nodes(long id) {
return nodes.selectList(new LambdaQueryWrapper<CarrierScheduleNodeFactEntity>()
.eq(CarrierScheduleNodeFactEntity::getQueryId, id)
.orderByAsc(CarrierScheduleNodeFactEntity::getNodeSequence)
.orderByAsc(CarrierScheduleNodeFactEntity::getObservedAt)
.orderByAsc(CarrierScheduleNodeFactEntity::getId));
}
public boolean insertIfAbsent(CarrierScheduleNodeFactEntity value) { return nodes.insertIgnore(value) == 1; }
public boolean claimOperation(Long queryId, long operatorId, String operation, String key, String hash) {
return operations.claim(IdWorker.getId(), queryId, operatorId, operation, key, hash) == 1;
}
@@ -32,6 +32,14 @@ public class CarrierScheduleAdapterRegistry {
return required(carrierCode).code();
}
public String resolve(String carrierName) {
String normalized = carrierName == null ? "" : carrierName.trim().toUpperCase(Locale.ROOT);
return DEFINITIONS.values().stream().filter(value -> value.matches(normalized))
.findFirst().map(Definition::code)
.orElseThrow(() -> new BizException(ResultCode.BAD_REQUEST,
"出仓订舱船司无法映射至已支持的船节点供应商:" + carrierName));
}
private static Definition required(String carrierCode) {
String normalized = carrierCode == null ? "" : carrierCode.trim().toUpperCase(Locale.ROOT);
Definition value = DEFINITIONS.get(normalized);
@@ -79,6 +87,15 @@ public class CarrierScheduleAdapterRegistry {
return new CarrierDescriptor(code, name, officialUrl, accessMode, false, true,
coverageGapCode, manualReason);
}
boolean matches(String value) {
return value.equals(code) || value.contains(code) || value.contains(name.toUpperCase(Locale.ROOT))
|| "MATSON".equals(code) && value.contains("美森")
|| "SML".equals(code) && (value.contains("森罗") || value.contains("SM LINE"))
|| "ZIM".equals(code) && value.contains("以星")
|| "COSCO".equals(code) && (value.contains("中远") || value.contains("COSCO SHIPPING"))
|| "EMC".equals(code) && (value.contains("长荣") || value.contains("EVERGREEN"))
|| "HEDE".equals(code) && value.contains("合德");
}
}
public record PreparedAttempt(String carrierCode, String accessMode, String officialUrl,
@@ -0,0 +1,71 @@
package com.baiyue.firstleg.portschedule.service;
import com.baiyue.firstleg.client.CarrierScheduleIntegrationClient;
import com.baiyue.firstleg.client.CarrierScheduleIntegrationClient.QueryRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderObservationRequest;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity;
import com.baiyue.firstleg.portschedule.repository.CarrierScheduleQueryRepository;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.Clock;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
@Component
public class CarrierScheduleQueryScheduler {
private final CarrierScheduleQueryRepository repository;
private final CarrierScheduleQueryService service;
private final CarrierScheduleIntegrationClient integration;
private final String workerId = UUID.randomUUID().toString();
private Clock clock = Clock.systemDefaultZone();
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
@Value("${baiyue.carrier-schedule.scheduler.batch-size:20}") private int batchSize = 20;
@Value("${baiyue.carrier-schedule.scheduler.lease-duration:PT5M}")
private Duration leaseDuration = Duration.ofMinutes(5);
public CarrierScheduleQueryScheduler(CarrierScheduleQueryRepository repository,
CarrierScheduleQueryService service,
CarrierScheduleIntegrationClient integration) {
this.repository = repository; this.service = service; this.integration = integration;
}
@Scheduled(fixedDelayString = "${baiyue.carrier-schedule.scheduler.fixed-delay:PT1M}")
public void poll() {
LocalDateTime now = LocalDateTime.now(clock);
repository.due(now, Math.max(1, Math.min(batchSize, 100))).forEach(candidate -> execute(candidate, now));
}
/** Executes only after this worker wins the database lease. */
void execute(CarrierScheduleQueryEntity candidate, LocalDateTime now) {
if (!repository.claimLease(candidate.getId(), workerId, now, now.plus(leaseDuration))) return;
try {
var response = integration.query(internalToken,
new QueryRequest(candidate.getCarrierCode(), candidate.getTrackingType(), candidate.getTrackingNo()));
if (response == null || !response.successful() || response.data() == null) {
service.completeScheduledAttempt(candidate.getId(), "RETRYABLE_FAILURE", "EMPTY_PROVIDER_RESPONSE",
"轨迹供应商返回空响应");
return;
}
var result = response.data();
List<CarrierScheduleIntegrationClient.Observation> observations = result.observations() == null
? List.of() : result.observations();
for (var observation : observations) {
service.applyProviderObservation(candidate.getId(), new ProviderObservationRequest(
result.providerCode(), observation.providerEventId(), observation.providerNodeCode(),
observation.standardNode(), observation.observedAt(), observation.estimatedAt(),
observation.actualAt(), observation.locationCode(), observation.locationName(),
observation.sourceReference(), observation.rawPayloadHash()));
}
service.completeScheduledAttempt(candidate.getId(), result.outcome(), result.errorCode(),
result.errorMessage());
} catch (RuntimeException exception) {
service.completeScheduledAttempt(candidate.getId(), "RETRYABLE_FAILURE", "PROVIDER_CALL_FAILED",
exception.getClass().getSimpleName());
}
}
}
@@ -5,17 +5,22 @@ 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.dto.CarrierScheduleQueryDtos.ProviderObservationRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.NodeFact;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.History;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Task;
import com.baiyue.firstleg.portschedule.domain.CarrierScheduleNode;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryHistoryEntity;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryOperationEntity;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.portschedule.entity.PortScheduleEntity;
import com.baiyue.firstleg.portschedule.repository.CarrierScheduleQueryRepository;
import com.baiyue.firstleg.portschedule.repository.PortScheduleRepository;
@@ -61,13 +66,148 @@ public class CarrierScheduleQueryService {
public Detail detail(long id) { return detail(required(id)); }
public List<CarrierScheduleQueryHistoryEntity> history(long id) {
public List<History> history(long id) {
required(id);
return repository.histories(id);
return repository.histories(id).stream().map(this::history).toList();
}
public long customerId(long id) { return required(id).getCustomerId(); }
/** Idempotently starts the canonical tracking task after domestic outbound confirmation. */
@Transactional
public CarrierScheduleQueryEntity ensureFromOutboundConfirm(long customerId, long oceanBookingId,
long containerBookingId,
long operatorId, String operatorName) {
CarrierScheduleQueryEntity existing = repository.findByBusinessScope(customerId, oceanBookingId,
containerBookingId);
if (existing != null) return existing;
OceanBookingEntity booking = portSchedules.oceanBooking(oceanBookingId);
ContainerBookingEntity container = portSchedules.containerBooking(containerBookingId);
if (booking == null || container == null) throw new BizException(ResultCode.NOT_FOUND, "出仓订舱或柜次不存在");
validateLinks(customerId, oceanBookingId, containerBookingId);
String carrierCode = adapters.resolve(booking.getShippingCompanyName());
String trackingType;
String trackingNo;
if (StringUtils.hasText(container.getContainerNo())) {
trackingType = "CONTAINER_NO"; trackingNo = container.getContainerNo().trim();
} else if (StringUtils.hasText(booking.getBillOfLadingNo())) {
trackingType = "BILL_OF_LADING"; trackingNo = booking.getBillOfLadingNo().trim();
} else if (StringUtils.hasText(booking.getBookingNo())) {
trackingType = "BOOKING_NO"; trackingNo = booking.getBookingNo().trim();
} else {
throw badRequest("确认国内出仓前必须具备柜号、提单号或订舱号之一");
}
PreparedAttempt strategy = adapters.prepare(carrierCode, trackingType, trackingNo);
LocalDateTime now = now();
CarrierScheduleQueryEntity value = new CarrierScheduleQueryEntity();
value.setId(IdWorker.getId()); value.setQueryNo("CSQ" + value.getId());
value.setCustomerId(customerId); value.setOceanBookingId(oceanBookingId);
value.setContainerBookingId(containerBookingId); value.setCarrierCode(carrierCode);
value.setTrackingType(trackingType); value.setTrackingNo(trackingNo);
value.setOfficialUrl(strategy.officialUrl()); value.setAccessMode(strategy.accessMode());
value.setStatus("READY"); value.setTriggerSource("OUTBOUND_CONFIRM");
value.setLatestStandardNode(CarrierScheduleNode.DEPARTED_WAREHOUSE.name());
value.setLatestNodeObservedAt(now); value.setWindowStartedAt(now); value.setAttemptCount(0);
value.setConsecutiveFailures(0); value.setNextAllowedAt(now); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0);
try { repository.insert(value); }
catch (DuplicateKeyException exception) {
CarrierScheduleQueryEntity concurrent = repository.findByBusinessScope(customerId, oceanBookingId,
containerBookingId);
if (concurrent != null) return concurrent;
throw exception;
}
addNode(value.getId(), "FIRSTLEG", "OUTBOUND_CONFIRM", "OUTBOUND_CONFIRM",
CarrierScheduleNode.DEPARTED_WAREHOUSE, now, null, now, null, null, "OUTBOUND_CONFIRM",
sha256("OUTBOUND_CONFIRM|" + containerBookingId));
addHistory(value.getId(), "AUTO_CREATE", null, 0, "国内确认出仓自动创建", null, value,
operatorId, operatorName);
return value;
}
@Transactional
public Detail applyProviderObservation(long id, ProviderObservationRequest request) {
CarrierScheduleQueryEntity value = requiredLocked(id);
if ("STOPPED".equals(value.getStatus())) return detail(value);
CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode());
CarrierScheduleQueryEntity before = copy(value);
if (!addNode(id, request.providerCode(), request.providerEventId(), request.providerNodeCode(), node,
request.observedAt(), request.estimatedAt(), request.actualAt(), request.locationCode(),
request.locationName(), request.sourceReference(), request.rawPayloadHash())) {
return detail(value);
}
CarrierScheduleNode current = value.getLatestStandardNode() == null ? null
: CarrierScheduleNode.require(value.getLatestStandardNode());
if (current == null || node.sequence() >= current.sequence()) {
value.setLatestStandardNode(node.name()); value.setLatestNodeObservedAt(request.observedAt());
value.setLastSuccessAt(now()); value.setSourceUpdatedAt(request.observedAt());
value.setLastAttemptStatus("SUCCESS"); value.setLastErrorCode(null); value.setLastErrorMessage(null);
value.setStatus(node == CarrierScheduleNode.ARRIVED ? "ARRIVED"
: node == CarrierScheduleNode.PICKUP_READY ? "PICKUP_READY" : "READY");
value.setNextAllowedAt(now().plusDays(WINDOW_DAYS));
if (repository.updateState(value, value.getVersion(), 0L, "carrier-scheduler") != 1) throw concurrent();
}
CarrierScheduleQueryEntity after = required(id);
addHistory(id, "NODE_OBSERVED", before.getVersion(), after.getVersion(), request.standardNode(), before,
after, 0L, "carrier-scheduler");
return detail(after);
}
@Transactional
public Detail stop(long id, String reason) {
CarrierScheduleQueryEntity value = requiredLocked(id);
if ("STOPPED".equals(value.getStatus())) return detail(value);
CarrierScheduleQueryEntity before = copy(value);
value.setStatus("STOPPED"); value.setStoppedAt(now()); value.setStopReason(trim(reason));
value.setLeaseOwner(null); value.setLeaseUntil(null); value.setNextAllowedAt(null);
if (repository.updateState(value, value.getVersion(), 0L, "carrier-scheduler") != 1) throw concurrent();
CarrierScheduleQueryEntity after = required(id);
addHistory(id, "STOP", before.getVersion(), after.getVersion(), reason, before, after, 0L,
"carrier-scheduler");
return detail(after);
}
@Transactional
public void completeScheduledAttempt(long id, String outcome, String errorCode, String errorMessage) {
CarrierScheduleQueryEntity value = requiredLocked(id);
if ("STOPPED".equals(value.getStatus())) return;
CarrierScheduleQueryEntity before = copy(value);
LocalDateTime now = now();
value.setLeaseOwner(null); value.setLeaseUntil(null); value.setLastAttemptAt(now);
value.setLastAttemptStatus(outcome); value.setLastErrorCode(trim(errorCode));
value.setLastErrorMessage(trim(errorMessage));
if ("SUCCESS".equals(outcome) || "NO_CHANGE".equals(outcome)) {
value.setConsecutiveFailures(0); value.setAttemptCount(0); value.setWindowStartedAt(now);
value.setNextAllowedAt(now.plusDays(WINDOW_DAYS));
} else {
int failures = count(value.getConsecutiveFailures()) + 1;
LocalDateTime windowStart = value.getWindowStartedAt();
if (windowStart == null || !now.isBefore(windowStart.plusDays(WINDOW_DAYS))) {
windowStart = now; value.setAttemptCount(0);
}
int attempts = count(value.getAttemptCount()) + 1;
value.setWindowStartedAt(windowStart); value.setAttemptCount(Math.min(MAX_ATTEMPTS, attempts));
value.setConsecutiveFailures(failures);
if ("MANUAL_REQUIRED".equals(outcome) || "PERMANENT_FAILURE".equals(outcome)) {
if (!"ARRIVED".equals(value.getStatus()) && !"PICKUP_READY".equals(value.getStatus())) {
value.setStatus("MANUAL_REQUIRED");
}
value.setNextAllowedAt(now.plusDays(WINDOW_DAYS));
} else {
long minutes = Math.min(24L * 60L, 15L << Math.min(failures - 1, 6));
LocalDateTime retryAt = now.plusMinutes(minutes);
if (attempts >= MAX_ATTEMPTS && retryAt.isBefore(windowStart.plusDays(WINDOW_DAYS))) {
retryAt = windowStart.plusDays(WINDOW_DAYS);
}
value.setNextAllowedAt(retryAt);
}
}
if (repository.updateState(value, value.getVersion(), 0L, "carrier-scheduler") != 1) throw concurrent();
CarrierScheduleQueryEntity after = required(id);
addHistory(id, "PROVIDER_QUERY", before.getVersion(), after.getVersion(), outcome, before, after, 0L,
"carrier-scheduler");
}
@Transactional
public Detail create(long operatorId, String operatorName, String key, CreateRequest request) {
String operation = "CREATE";
@@ -84,7 +224,8 @@ public class CarrierScheduleQueryService {
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.setStatus("READY"); value.setTriggerSource("MANUAL");
value.setWindowStartedAt(now); value.setAttemptCount(0); value.setConsecutiveFailures(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);
@@ -94,51 +235,6 @@ public class CarrierScheduleQueryService {
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) {
@@ -155,7 +251,7 @@ public class CarrierScheduleQueryService {
}
CarrierScheduleQueryEntity before = copy(value);
LocalDateTime now = now();
value.setStatus("SUCCESS"); value.setResultPortScheduleId(schedule.getId());
value.setStatus("READY"); 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");
@@ -212,13 +308,48 @@ public class CarrierScheduleQueryService {
}
private Detail detail(CarrierScheduleQueryEntity value) {
return new Detail(view(value, now()), repository.attempts(value.getId()), repository.histories(value.getId()));
return new Detail(view(value, now()), repository.nodes(value.getId()).stream().map(this::node).toList(),
repository.histories(value.getId()).stream().map(this::history).toList());
}
private NodeFact node(CarrierScheduleNodeFactEntity value) {
return new NodeFact(value.getId(), value.getProviderCode(), value.getProviderNodeCode(),
value.getStandardNode(), value.getNodeSequence(), value.getObservedAt(), value.getEstimatedAt(),
value.getActualAt(), value.getLocationCode(), value.getLocationName(), value.getSourceReference(),
value.getRawPayloadHash(), value.getCreatedAt());
}
private History history(CarrierScheduleQueryHistoryEntity value) {
return new History(value.getId(), value.getAction(), value.getFromVersion(), value.getToVersion(),
value.getReason(), value.getOperatorId(), value.getOperatorName(), value.getCreatedAt());
}
private boolean addNode(long queryId, String providerCode, String providerEventId, String providerNodeCode,
CarrierScheduleNode node, LocalDateTime observedAt, LocalDateTime estimatedAt,
LocalDateTime actualAt, String locationCode, String locationName,
String sourceReference, String rawPayloadHash) {
CarrierScheduleNodeFactEntity fact = new CarrierScheduleNodeFactEntity();
fact.setId(IdWorker.getId()); fact.setQueryId(queryId);
fact.setEventKey(providerCode.trim().toUpperCase(Locale.ROOT) + ':' + providerEventId.trim());
fact.setProviderCode(providerCode.trim().toUpperCase(Locale.ROOT));
fact.setProviderNodeCode(providerNodeCode.trim()); fact.setStandardNode(node.name());
fact.setNodeSequence(node.sequence()); fact.setObservedAt(observedAt); fact.setEstimatedAt(estimatedAt);
fact.setActualAt(actualAt); fact.setLocationCode(trim(locationCode)); fact.setLocationName(trim(locationName));
fact.setSourceReference(trim(sourceReference)); fact.setRawPayloadHash(rawPayloadHash);
return repository.insertIfAbsent(fact);
}
private static QueryView view(CarrierScheduleQueryEntity value, LocalDateTime now) {
boolean expired = value.getLastSuccessAt() == null || value.getExpiresAt() == null
|| !value.getExpiresAt().isAfter(now);
return new QueryView(value, expired, MAX_ATTEMPTS, value.getNextAllowedAt());
Task task = new Task(value.getId(), value.getQueryNo(), value.getCustomerId(), value.getOceanBookingId(),
value.getContainerBookingId(), value.getCarrierCode(), value.getTrackingType(), value.getTrackingNo(),
value.getOfficialUrl(), value.getAccessMode(), value.getStatus(), value.getTriggerSource(),
value.getLatestStandardNode(), value.getLatestNodeObservedAt(), value.getLastSuccessAt(),
value.getSourceUpdatedAt(), value.getExpiresAt(), value.getResultPortScheduleId(), value.getManualReason(),
value.getLastErrorCode(), value.getLastErrorMessage(), value.getStoppedAt(), value.getStopReason(),
value.getVersion());
return new QueryView(task, expired);
}
private CarrierScheduleQueryEntity required(long id) {
@@ -234,6 +365,12 @@ public class CarrierScheduleQueryService {
return value;
}
private CarrierScheduleQueryEntity requiredLocked(long id) {
CarrierScheduleQueryEntity value = repository.lock(id);
if (value == null) throw new BizException(ResultCode.NOT_FOUND, "船司查询任务不存在");
return value;
}
private static CarrierScheduleQueryEntity copy(CarrierScheduleQueryEntity source) {
CarrierScheduleQueryEntity value = new CarrierScheduleQueryEntity();
value.setId(source.getId()); value.setQueryNo(source.getQueryNo()); value.setCustomerId(source.getCustomerId());
@@ -241,6 +378,10 @@ public class CarrierScheduleQueryService {
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.setTriggerSource(source.getTriggerSource()); value.setLatestStandardNode(source.getLatestStandardNode());
value.setLatestNodeObservedAt(source.getLatestNodeObservedAt()); value.setStoppedAt(source.getStoppedAt());
value.setStopReason(source.getStopReason()); value.setLeaseOwner(source.getLeaseOwner());
value.setLeaseUntil(source.getLeaseUntil()); value.setConsecutiveFailures(source.getConsecutiveFailures());
value.setWindowStartedAt(source.getWindowStartedAt()); value.setAttemptCount(source.getAttemptCount());
value.setNextAllowedAt(source.getNextAllowedAt()); value.setLastAttemptAt(source.getLastAttemptAt());
value.setLastSuccessAt(source.getLastSuccessAt()); value.setSourceUpdatedAt(source.getSourceUpdatedAt());
@@ -259,6 +400,12 @@ public class CarrierScheduleQueryService {
} catch (Exception ex) { throw new IllegalStateException("无法计算船司查询请求摘要", ex); }
}
private static String sha256(String value) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8))); }
catch (Exception exception) { throw new IllegalStateException(exception); }
}
private String json(Object value) {
if (value == null) return null;
try { return objectMapper.writeValueAsString(value); }
@@ -15,17 +15,27 @@ import java.util.List;
public class LoadingCompletedOutboxRepository {
@Resource private InboundOutboxMapper mapper;
public List<InboundOutboxEntity> pending(int limit, LocalDateTime now) {
public List<InboundOutboxEntity> pending(int limit, LocalDateTime now, LocalDateTime expiredLeaseBefore) {
return mapper.selectList(new LambdaQueryWrapper<InboundOutboxEntity>()
.eq(InboundOutboxEntity::getEventType, EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED)
.eq(InboundOutboxEntity::getPublishStatus, "PENDING")
.and(v -> v.isNull(InboundOutboxEntity::getNextRetryAt).or().le(InboundOutboxEntity::getNextRetryAt, now))
.and(status -> status.eq(InboundOutboxEntity::getPublishStatus, "PENDING")
.and(retry -> retry.isNull(InboundOutboxEntity::getNextRetryAt)
.or().le(InboundOutboxEntity::getNextRetryAt, now))
.or(stale -> stale.eq(InboundOutboxEntity::getPublishStatus, "PROCESSING")
.le(InboundOutboxEntity::getLockedAt, expiredLeaseBefore)))
.orderByAsc(InboundOutboxEntity::getCreatedAt).orderByAsc(InboundOutboxEntity::getId)
.last("LIMIT " + Math.min(Math.max(limit, 1), 100)));
}
public boolean claim(long id, String worker, LocalDateTime now) { return mapper.update(null, base(id, "PENDING")
public boolean claim(long id, String worker, LocalDateTime now, LocalDateTime expiredLeaseBefore) {
return mapper.update(null, new LambdaUpdateWrapper<InboundOutboxEntity>()
.eq(InboundOutboxEntity::getId, id)
.eq(InboundOutboxEntity::getEventType, EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED)
.and(status -> status.eq(InboundOutboxEntity::getPublishStatus, "PENDING")
.or(stale -> stale.eq(InboundOutboxEntity::getPublishStatus, "PROCESSING")
.le(InboundOutboxEntity::getLockedAt, expiredLeaseBefore)))
.set(InboundOutboxEntity::getPublishStatus, "PROCESSING").set(InboundOutboxEntity::getLockedBy, worker)
.set(InboundOutboxEntity::getLockedAt, now)) == 1; }
.set(InboundOutboxEntity::getLockedAt, now)) == 1;
}
public InboundOutboxEntity claimed(long id, String worker) { return mapper.selectOne(new LambdaQueryWrapper<InboundOutboxEntity>()
.eq(InboundOutboxEntity::getId, id).eq(InboundOutboxEntity::getEventType, EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED)
.eq(InboundOutboxEntity::getPublishStatus, "PROCESSING").eq(InboundOutboxEntity::getLockedBy, worker)); }
@@ -44,7 +44,14 @@ public class ContainerOverviewService {
List<TimelineEntry> timeline = timeline(id, task == null ? null : task.id(), exceptionHistory);
return new Overview(new ContainerBookingEntityView(booking.getId(), booking.getBookingNo(),
booking.getCabinetNo(), booking.getContainerNo(), booking.getPlanStatus(), booking.getWarehouseId(),
booking.getWarehouseName(), booking.getVoyageNo(), booking.getVesselName(), booking.getVersion()),
booking.getWarehouseName(), booking.getVoyageNo(), booking.getVesselName(), booking.getBillOfLadingNo(),
booking.getShippingCompanyId(), booking.getShippingCompanyName(), booking.getDeparturePortCode(),
booking.getDeparturePortName(), booking.getDestinationPortCode(), booking.getDestinationPortName(),
booking.getEstimatedSailingAt(), booking.getActualSailingAt(), booking.getEstimatedPortArrivalAt(),
booking.getActualPortArrivalAt(), booking.getEstimatedWarehouseArrivalAt(),
booking.getActualWarehouseArrivalAt(), booking.getUnloadingCompletedAt(),
booking.getUnloadingPhotoUploadedAt(), booking.getEstimatedEmptyReportedAt(),
booking.getActualEmptyReportedAt(), booking.getEmptyReportSourceVersion(), booking.getVersion()),
task == null ? booking.getPlanId() : task.loadingPlanId(), pda, documents,
new ExceptionState(latestException != null && "MARK".equals(latestException.action()),
latestException == null ? null : latestException.reason(),
@@ -22,13 +22,16 @@ public class LoadingCompletedOutboxRelay {
@Resource private EventBridgeClient client;
@Resource private ObjectMapper objectMapper;
@Value("${baiyue.security.internal-service-token}") private String internalToken;
@Value("${baiyue.lastleg.outbox-relay.lease-seconds:60}") private long leaseSeconds;
@Value("${baiyue.lastleg.outbox-relay.retry-seconds:30}") private long retrySeconds;
private final String workerId = UUID.randomUUID().toString();
@Scheduled(fixedDelayString = "${baiyue.lastleg.outbox-relay.fixed-delay-ms:5000}")
public void relay() {
LocalDateTime now=LocalDateTime.now();
for (InboundOutboxEntity candidate:repository.pending(50,now)) {
if(!repository.claim(candidate.getId(),workerId,now)) continue;
LocalDateTime expiredLeaseBefore=now.minusSeconds(Math.max(leaseSeconds,10));
for (InboundOutboxEntity candidate:repository.pending(50,now,expiredLeaseBefore)) {
if(!repository.claim(candidate.getId(),workerId,now,expiredLeaseBefore)) continue;
InboundOutboxEntity row=repository.claimed(candidate.getId(),workerId);
if(row==null) continue;
try {
@@ -39,9 +42,18 @@ public class LoadingCompletedOutboxRelay {
|| !event.eventId().equals(response.data().eventId())) throw new IllegalStateException("事件桥未确认装柜完成事件");
repository.published(row.getId(),workerId,LocalDateTime.now());
} catch(Exception exception) {
repository.retry(row.getId(),workerId,LocalDateTime.now().plusSeconds(30),exception.getMessage());
log.warn("装柜完成事件投递失败: eventKey={}, reason={}",row.getEventKey(),exception.getMessage());
String error=safeError(exception);
repository.retry(row.getId(),workerId,LocalDateTime.now().plusSeconds(Math.max(retrySeconds,1)),error);
log.warn("装柜完成事件投递失败: eventKey={}, reason={}",row.getEventKey(),error);
}
}
}
private String safeError(Exception exception) {
String value=exception.getMessage();
if(value==null || value.isBlank()) value=exception.getClass().getSimpleName();
if(internalToken!=null && !internalToken.isBlank()) value=value.replace(internalToken,"***");
value=value.replaceAll("[\\r\\n\\t]+"," ");
return value.length()>500?value.substring(0,500):value;
}
}
@@ -30,9 +30,12 @@ public class OverseasContainerEventConsumer {
public ContainerBookingEntity consume(EventEnvelope event) {
validateEnvelope(event);
OverseasContainerUnloadedV1 payload = payload(event);
if (payload.containerNo() == null || payload.containerNo().isBlank()
|| !payload.containerNo().equals(event.aggregateId())) {
throw new IllegalArgumentException("事件 aggregateId 与 containerNo 不一致");
if (payload.sourceContainerBookingId() == null || payload.sourceContainerBookingId().isBlank()
|| !payload.sourceContainerBookingId().equals(event.aggregateId())) {
throw new IllegalArgumentException("事件 aggregateId 与 sourceContainerBookingId 不一致");
}
if (payload.containerNo() == null || payload.containerNo().isBlank()) {
throw new IllegalArgumentException("货柜号不能为空");
}
if (payload.unloadingCompletedAt() == null || payload.unloadingPhotoUploadedAt() == null) {
throw new IllegalArgumentException("卸柜完成时间和卸柜照片上传时间不能为空");
@@ -47,7 +50,7 @@ public class OverseasContainerEventConsumer {
if (!existing.getPayloadHash().equalsIgnoreCase(event.payloadHash())) {
throw new IllegalArgumentException("相同 eventId 对应的事件摘要冲突");
}
return requiredContainer(payload.containerNo());
return requiredContainer(payload.sourceContainerBookingId(), payload.containerNo());
}
ContainerEventInboxEntity row = inbox(event);
try {
@@ -55,17 +58,17 @@ public class OverseasContainerEventConsumer {
} catch (DuplicateKeyException duplicate) {
ContainerEventInboxEntity raced = inbox.selectById(event.eventId());
if (raced == null || !raced.getPayloadHash().equalsIgnoreCase(event.payloadHash())) throw duplicate;
return requiredContainer(payload.containerNo());
return requiredContainer(payload.sourceContainerBookingId(), payload.containerNo());
}
ContainerBookingEntity container = requiredContainer(payload.containerNo());
ContainerBookingEntity container = requiredContainer(payload.sourceContainerBookingId(), payload.containerNo());
LocalDateTime uploadedAt = utc(payload.unloadingPhotoUploadedAt());
containers.applyEmptyReport(container.getId(), utc(payload.unloadingCompletedAt()), uploadedAt,
uploadedAt.plusDays(2), utc(payload.actualEmptyReportedAt()), event.aggregateVersion());
row.setStatus("PROCESSED");
row.setProcessedAt(LocalDateTime.now(ZoneOffset.UTC));
inbox.updateById(row);
return requiredContainer(payload.containerNo());
return requiredContainer(payload.sourceContainerBookingId(), payload.containerNo());
}
private void validateEnvelope(EventEnvelope event) {
@@ -97,9 +100,18 @@ public class OverseasContainerEventConsumer {
return row;
}
private ContainerBookingEntity requiredContainer(String containerNo) {
ContainerBookingEntity value = containers.selectActiveByContainerNo(containerNo);
private ContainerBookingEntity requiredContainer(String sourceContainerBookingId, String containerNo) {
long bookingId;
try {
bookingId = Long.parseLong(sourceContainerBookingId);
} catch (NumberFormatException exception) {
throw new IllegalArgumentException("国内货柜订舱 ID 无效", exception);
}
ContainerBookingEntity value = containers.selectActiveBookingForUpdate(bookingId);
if (value == null) throw new IllegalArgumentException("头程货柜不存在或不可用");
if (value.getContainerNo() == null || !value.getContainerNo().equals(containerNo)) {
throw new IllegalArgumentException("事件货柜号与国内订舱记录不一致");
}
return value;
}
@@ -13,10 +13,18 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query (
tracking_no VARCHAR(128) NOT NULL COMMENT '提交至船公司官网的跟踪号码',
official_url VARCHAR(500) NOT NULL COMMENT '船公司官网查询入口地址快照',
access_mode VARCHAR(32) NOT NULL COMMENT '官网访问方式:PUBLIC_FORM公开表单、HCAPTCHA人机校验、DYNAMIC_IFRAME动态页面',
status VARCHAR(24) NOT NULL COMMENT '任务状态:READY可查询、MANUAL_REQUIRED需人工处理、SUCCESS已取得有效结果',
status VARCHAR(24) NOT NULL COMMENT '任务状态:READY、MANUAL_REQUIRED、ARRIVED、PICKUP_READY、STOPPED',
trigger_source VARCHAR(32) NOT NULL DEFAULT 'MANUAL' COMMENT '任务触发来源:OUTBOUND_CONFIRM或MANUAL',
latest_standard_node VARCHAR(32) NULL COMMENT '最近标准船节点',
latest_node_observed_at DATETIME(3) NULL COMMENT '最近标准节点观测时间',
stopped_at DATETIME(3) NULL COMMENT '任务停止时间',
stop_reason VARCHAR(500) NULL COMMENT '任务停止原因',
lease_owner VARCHAR(64) NULL COMMENT '当前调度租约持有者',
lease_until DATETIME(3) NULL COMMENT '当前调度租约过期时间',
consecutive_failures INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '连续失败次数,用于指数退避',
window_started_at DATETIME(3) NOT NULL COMMENT '当前三天查询窗口起始时间',
attempt_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '当前查询窗口已执行次数;最多3次',
next_allowed_at DATETIME(3) NOT NULL COMMENT '频控规则允许的下次查询时间',
next_allowed_at DATETIME(3) NULL COMMENT '频控规则允许的下次查询时间;停止后为空',
last_attempt_at DATETIME(3) NULL COMMENT '最近一次查询尝试时间',
last_success_at DATETIME(3) NULL COMMENT '最近一次取得有效结果时间',
source_updated_at DATETIME(3) NULL COMMENT '船公司官网展示的结果更新时间',
@@ -37,7 +45,7 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query (
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),
(customer_id,ocean_booking_id,container_scope_id),
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),
@@ -52,7 +60,10 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query (
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')),
(status IN ('READY','MANUAL_REQUIRED','ARRIVED','PICKUP_READY','STOPPED')),
CONSTRAINT chk_carrier_schedule_query_node CHECK
(latest_standard_node IS NULL OR latest_standard_node IN
('DEPARTED_WAREHOUSE','AT_ORIGIN_PORT','DEPARTED_ORIGIN_PORT','IN_TRANSIT','ARRIVED','PICKUP_READY')),
CONSTRAINT chk_carrier_schedule_query_attempt CHECK (attempt_count BETWEEN 0 AND 3)
) ENGINE=InnoDB COMMENT='六家船公司官网查询任务与三天窗口频控';
@@ -95,9 +106,35 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_history (
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'))
CONSTRAINT chk_carrier_schedule_history_action CHECK
(action IN ('CREATE','AUTO_CREATE','MANUAL_RESULT','NODE_OBSERVED','PROVIDER_QUERY','STOP'))
) ENGINE=InnoDB COMMENT='船司查询任务不可变历史';
CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact (
id BIGINT UNSIGNED NOT NULL COMMENT '标准船节点事实ID',
query_id BIGINT UNSIGNED NOT NULL COMMENT '所属查询任务ID',
event_key VARCHAR(192) NOT NULL COMMENT '供应商编码与供应商事件ID组成的幂等键',
provider_code VARCHAR(32) NOT NULL COMMENT '轨迹供应商编码',
provider_node_code VARCHAR(64) NOT NULL COMMENT '供应商原始节点编码',
standard_node VARCHAR(32) NOT NULL COMMENT '标准船节点',
node_sequence INT UNSIGNED NOT NULL COMMENT '标准节点单调序号',
observed_at DATETIME(3) NOT NULL COMMENT '供应商观测时间',
estimated_at DATETIME(3) NULL COMMENT '供应商预计发生时间',
actual_at DATETIME(3) NULL COMMENT '供应商确认实际发生时间',
location_code VARCHAR(32) NULL COMMENT '节点地点编码',
location_name VARCHAR(128) NULL COMMENT '节点地点名称',
source_reference VARCHAR(500) NULL COMMENT '可审计来源引用',
raw_payload_hash CHAR(64) NOT NULL COMMENT '原始响应SHA-256;不保存敏感原文',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事实入库时间',
PRIMARY KEY (id),
UNIQUE KEY uk_carrier_schedule_node_event (query_id,event_key),
KEY idx_carrier_schedule_node_timeline (query_id,node_sequence,observed_at,id),
CONSTRAINT fk_carrier_schedule_node_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id),
CONSTRAINT chk_carrier_schedule_node_standard CHECK
(standard_node IN ('DEPARTED_WAREHOUSE','AT_ORIGIN_PORT','DEPARTED_ORIGIN_PORT','IN_TRANSIT','ARRIVED','PICKUP_READY')),
CONSTRAINT chk_carrier_schedule_node_sequence CHECK (node_sequence IN (10,20,30,40,50,60))
) ENGINE=InnoDB COMMENT='供应商标准船节点不可变事实';
CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_operation (
id BIGINT UNSIGNED NOT NULL COMMENT '船司查询写操作幂等记录ID',
query_id BIGINT UNSIGNED NULL COMMENT '关联查询任务ID;创建任务落库前允许为空',
@@ -138,6 +175,8 @@ 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;
DROP TRIGGER IF EXISTS trg_carrier_schedule_node_before_update;
DROP TRIGGER IF EXISTS trg_carrier_schedule_node_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$$
@@ -147,15 +186,21 @@ CREATE TRIGGER trg_carrier_schedule_history_before_update BEFORE UPDATE ON fl_ca
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$$
CREATE TRIGGER trg_carrier_schedule_node_before_update BEFORE UPDATE ON fl_carrier_schedule_node_fact
FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='标准船节点事实不可修改'; END$$
CREATE TRIGGER trg_carrier_schedule_node_before_delete BEFORE DELETE ON fl_carrier_schedule_node_fact
FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='标准船节点事实不可删除'; END$$
DELIMITER ;
USE baiyue_iam;
DELETE role_permission FROM iam_role_permission role_permission
JOIN iam_permission permission ON permission.id=role_permission.permission_id
WHERE permission.tenant_id=0
AND permission.permission_code='platform:firstleg:port-schedule-query:view';
AND permission.permission_code IN ('platform:firstleg:port-schedule-query:view',
'platform:firstleg:port-schedule-query:attempt');
DELETE FROM iam_permission
WHERE tenant_id=0 AND permission_code='platform:firstleg:port-schedule-query:view';
WHERE tenant_id=0 AND permission_code IN ('platform:firstleg:port-schedule-query:view',
'platform:firstleg:port-schedule-query:attempt');
DROP PROCEDURE IF EXISTS assert_carrier_schedule_query_permission_ids;
DELIMITER $$
@@ -163,16 +208,15 @@ CREATE PROCEDURE assert_carrier_schedule_query_permission_ids()
BEGIN
IF EXISTS (
SELECT 1 FROM iam_permission
WHERE (id IN (5102184,5102186,5102187,5102188)
WHERE (id IN (5102184,5102186,5102188)
AND permission_code <> CASE id
WHEN 5102184 THEN 'platform:firstleg:port-schedule-query:list'
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 id=5102185
OR id IN (5102185,5102187)
OR (permission_code LIKE 'platform:firstleg:port-schedule-query:%'
AND (tenant_id<>0 OR app_code<>'PLATFORM' OR id NOT IN (5102184,5102186,5102187,5102188)))
AND (tenant_id<>0 OR app_code<>'PLATFORM' OR id NOT IN (5102184,5102186,5102188)))
) THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT='三期船司查询权限迁移失败:5102184-5102188 ID或权限码被占用';
@@ -188,7 +232,6 @@ INSERT INTO iam_permission
VALUES
(5102184,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule-query:list','查询船司任务',NULL,'GET',40,'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),
@@ -209,12 +252,13 @@ 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
'fl_carrier_schedule_query_history','fl_carrier_schedule_query_operation',
'fl_carrier_schedule_node_fact')) <> 5 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:%') <> 4 THEN
WHERE tenant_id=0 AND id IN (5102184,5102186,5102188)
AND permission_code LIKE 'platform:firstleg:port-schedule-query:%') <> 3 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询任务权限未完整创建';
END IF;
IF (SELECT COUNT(*)
@@ -223,7 +267,7 @@ BEGIN
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:%') <> 8 THEN
AND permission.permission_code LIKE 'platform:firstleg:port-schedule-query:%') <> 6 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询任务角色授权未完整创建';
END IF;
END$$
@@ -232,6 +232,8 @@ BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='订舱状态历史不可删除';
DELIMITER ;
USE baiyue_iam;
DELETE rp FROM iam_role_permission rp WHERE rp.permission_id IN (5102141,5102143,5102144,5102145);
DELETE FROM iam_permission WHERE id IN (5102141,5102143,5102144,5102145);
DROP PROCEDURE IF EXISTS assert_ocean_booking_permission_ids_available;
DELIMITER $$
CREATE PROCEDURE assert_ocean_booking_permission_ids_available()
@@ -239,23 +241,14 @@ BEGIN
IF EXISTS (
SELECT 1 FROM iam_permission
WHERE (id = 5102140 AND permission_code <> 'platform:firstleg:ocean-booking:list')
OR (id = 5102141 AND permission_code NOT IN (
'platform:firstleg:ocean-booking:list',
'platform:firstleg:ocean-booking:view'))
OR (id = 5102142 AND permission_code <> 'platform:firstleg:ocean-booking:create')
OR (id = 5102143 AND permission_code <> 'platform:firstleg:ocean-booking:edit')
OR (id = 5102144 AND permission_code <> 'platform:firstleg:ocean-booking:submit')
OR (id = 5102145 AND permission_code <> 'platform:firstleg:ocean-booking:confirm')
OR (id = 5102146 AND permission_code <> 'platform:firstleg:ocean-booking:release')
OR (id = 5102147 AND permission_code NOT IN (
'platform:firstleg:ocean-booking:cancel',
'platform:firstleg:ocean-booking:edit'))
OR (id = 5102148 AND permission_code NOT IN (
'platform:firstleg:ocean-booking:change',
'platform:firstleg:ocean-booking:delete'))
OR (id = 5102147 AND permission_code <> 'platform:firstleg:ocean-booking:edit')
OR (id = 5102148 AND permission_code <> 'platform:firstleg:ocean-booking:delete')
OR (id = 5102149 AND permission_code <> 'platform:firstleg:ocean-booking:allocate')
OR (permission_code LIKE 'platform:firstleg:ocean-booking:%'
AND (tenant_id <> 0 OR app_code <> 'PLATFORM' OR id NOT BETWEEN 5102140 AND 5102149))
AND (tenant_id <> 0 OR app_code <> 'PLATFORM'
OR id NOT IN (5102140,5102142,5102146,5102147,5102148,5102149)))
) THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '三期订舱权限迁移失败:5102140-5102149 ID 或权限码被其他语义占用';
@@ -265,13 +258,6 @@ DELIMITER ;
CALL assert_ocean_booking_permission_ids_available();
DROP PROCEDURE IF EXISTS assert_ocean_booking_permission_ids_available;
UPDATE iam_permission
SET permission_code='platform:firstleg:ocean-booking:view',status='INACTIVE',deleted=1
WHERE id=5102141 AND permission_code='platform:firstleg:ocean-booking:list';
DELETE rp FROM iam_role_permission rp WHERE rp.permission_id IN (5102143,5102144,5102145);
DELETE FROM iam_permission WHERE id IN (5102143,5102144,5102145);
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)
@@ -291,8 +277,4 @@ SELECT role.id,permission.id,0 FROM iam_role role JOIN iam_permission permission
ON permission.tenant_id=0 AND permission.permission_code LIKE 'platform:firstleg:ocean-booking:%'
WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR');
DELETE rp FROM iam_role_permission rp JOIN iam_permission p ON p.tenant_id=rp.tenant_id AND p.id=rp.permission_id
WHERE p.id=5102141;
UPDATE iam_permission SET status='INACTIVE',deleted=1 WHERE id=5102141;
USE baiyue_firstleg;
@@ -112,7 +112,7 @@ BEGIN
WHERE (id = 5102150 AND permission_code <> 'platform:firstleg:truck-appointment:list')
OR (id = 5102151 AND permission_code NOT IN (
'platform:firstleg:truck-appointment:list',
'platform:firstleg:truck-appointment:view'))
CONCAT('platform:firstleg:truck-appointment:', 'view')))
OR (id = 5102152 AND permission_code <> 'platform:firstleg:truck-appointment:create')
OR (id = 5102153 AND permission_code <> 'platform:firstleg:truck-appointment:edit')
OR (id = 5102154 AND permission_code <> 'platform:firstleg:truck-appointment:operate')
@@ -127,10 +127,6 @@ DELIMITER ;
CALL assert_truck_appointment_permission_ids_available();
DROP PROCEDURE IF EXISTS assert_truck_appointment_permission_ids_available;
UPDATE iam_permission
SET permission_code='platform:firstleg:truck-appointment:view',status='INACTIVE',deleted=1
WHERE id=5102151 AND permission_code='platform:firstleg:truck-appointment:list';
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)
@@ -151,6 +147,6 @@ WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')
DELETE rp FROM iam_role_permission rp JOIN iam_permission p ON p.tenant_id=rp.tenant_id AND p.id=rp.permission_id
WHERE p.id=5102151;
UPDATE iam_permission SET status='INACTIVE',deleted=1 WHERE id=5102151;
DELETE FROM iam_permission WHERE id=5102151;
USE baiyue_firstleg;
@@ -0,0 +1,23 @@
openapi: 3.0.3
info:
title: Carrier Schedule Internal Handoff API
version: 1.0.0
description: Internal-only handoff contract; it is not a platform or merchant frontend API.
paths:
/internal/firstleg/v1/carrier-schedule-queries/{queryId}/stop:
post:
summary: Stop tracking after the overseas pickup handoff has completed
parameters:
- {name: queryId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
- {name: X-Internal-Service-Token, in: header, required: true, schema: {type: string}}
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [reason]
properties:
reason: {type: string, minLength: 1, maxLength: 500}
responses:
'200': {description: Idempotent STOPPED task detail}
@@ -2,7 +2,7 @@ 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.
description: Phase-4 carrier tracking from domestic outbound confirmation through destination pickup readiness.
paths:
/api/platform/v1/carrier-schedule-queries/carriers:
get:
@@ -40,7 +40,7 @@ paths:
/api/platform/v1/carrier-schedule-queries/{queryId}:
parameters: [{$ref: '#/components/parameters/QueryId'}]
get:
summary: Get task, immutable attempts and history
summary: Get task, standardized node facts and immutable business history
x-permission: platform:firstleg:port-schedule-query:list
responses:
'200': {description: Stable task detail}
@@ -51,19 +51,6 @@ paths:
x-permission: platform:firstleg:port-schedule-query:list
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
x-permission: platform:firstleg:port-schedule-query:attempt
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:
@@ -84,7 +71,8 @@ components:
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]}
QueryStatus: {type: string, enum: [READY, MANUAL_REQUIRED, ARRIVED, PICKUP_READY, STOPPED]}
StandardNode: {type: string, enum: [DEPARTED_WAREHOUSE, AT_ORIGIN_PORT, DEPARTED_ORIGIN_PORT, IN_TRANSIT, ARRIVED, PICKUP_READY]}
CreateRequest:
type: object
required: [expectedVersion, customerId, oceanBookingId, carrierCode, trackingType, trackingNo]
@@ -97,12 +85,6 @@ components:
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]
@@ -112,9 +94,7 @@ components:
reason: {type: string, minLength: 1, maxLength: 1000}
QueryView:
type: object
required: [query, expired, maxAttempts, nextAllowedAt]
required: [query, expired]
properties:
query: {type: object, description: Includes source, attemptCount, lastAttemptAt, lastSuccessAt, sourceUpdatedAt and manualReason}
query: {type: object, description: Includes triggerSource, latestStandardNode, latestNodeObservedAt, source freshness and stop fields; scheduler leases and raw attempts are never exposed}
expired: {type: boolean}
maxAttempts: {type: integer, enum: [3]}
nextAllowedAt: {type: string, format: date-time}
@@ -0,0 +1,21 @@
package com.baiyue.firstleg.controller;
import com.baiyue.firstleg.dto.ContainerOverviewDtos.ContainerBookingEntityView;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import static org.assertj.core.api.Assertions.assertThat;
class ContainerOverviewTransportContractTest {
@Test
void overviewPublishesMaritimeAndEmptyReportFacts() {
assertThat(Arrays.stream(ContainerBookingEntityView.class.getRecordComponents()).map(value -> value.getName()))
.contains("billOfLadingNo", "shippingCompanyId", "shippingCompanyName", "departurePortCode",
"departurePortName", "destinationPortCode", "destinationPortName", "estimatedSailingAt",
"actualSailingAt", "estimatedPortArrivalAt", "actualPortArrivalAt",
"estimatedWarehouseArrivalAt", "actualWarehouseArrivalAt", "unloadingCompletedAt",
"unloadingPhotoUploadedAt", "estimatedEmptyReportedAt", "actualEmptyReportedAt",
"emptyReportSourceVersion");
}
}
@@ -8,6 +8,7 @@ import com.baiyue.firstleg.outbound.dispatch.gate.OutboundDispatchGateContributo
import com.baiyue.firstleg.outbound.dispatch.mapper.OutboundDispatchReadMapper;
import com.baiyue.firstleg.outbound.dispatch.repository.OutboundDispatchRepository;
import com.baiyue.firstleg.outbound.dispatch.service.OutboundDispatchService;
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
import org.junit.jupiter.api.Test;
import java.util.List;
@@ -67,6 +68,26 @@ class OutboundDispatchServiceTest {
verify(repository).completeOperation(33L, 9L, "CONFIRM_DISPATCH", "confirm-1");
}
@Test
void confirmCreatesCanonicalCarrierScheduleTaskInTheSameTransaction() {
CarrierScheduleQueryService carrierSchedules = mock(CarrierScheduleQueryService.class);
OutboundDispatchService service = new OutboundDispatchService(repository,
List.of(passing("CUSTOMS_CLEARANCE"), passing("SAILING_SCHEDULE")), carrierSchedules);
stubReadyChain();
OutboundDispatchEntity state = new OutboundDispatchEntity();
state.setId(33L); state.setTenantId(7L); state.setInboundId(11L);
state.setContainerBookingId(22L); state.setStatus("READY_FOR_DISPATCH"); state.setVersion(0);
when(repository.dispatch(7L, 11L, 22L)).thenReturn(state);
when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("confirm-carrier"), anyString())).thenReturn(true);
when(repository.confirm(33L, 0, 9L, "admin", null)).thenReturn(1);
when(repository.completeOperation(33L, 9L, "CONFIRM_DISPATCH", "confirm-carrier")).thenReturn(1);
when(repository.timeline(any(), any(), any())).thenReturn(List.of());
service.confirm(9L, "admin", "confirm-carrier", new ActionRequest(11L, 22L, 0, null));
verify(carrierSchedules).ensureFromOutboundConfirm(7L, 66L, 22L, 9L, "admin");
}
@Test
void confirmNeverWritesStateWhenAnyRealGateFails() {
OutboundDispatchService service = new OutboundDispatchService(repository,
@@ -9,19 +9,17 @@ import static org.assertj.core.api.Assertions.assertThat;
class CarrierScheduleQueryOpenApiContractTest {
@Test
void publishesFrequencyFreshnessAndManualFallbackContract() throws Exception {
void publishesBusinessNodesWithoutSchedulerOrAttemptEndpoints() 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("DEPARTED_WAREHOUSE, AT_ORIGIN_PORT, DEPARTED_ORIGIN_PORT, IN_TRANSIT, ARRIVED, PICKUP_READY")
.contains("MANUAL_REQUIRED")
.contains("coverageGap")
.contains("lastAttemptAt")
.contains("lastSuccessAt")
.contains("sourceUpdatedAt")
.contains("nextAllowedAt");
.contains("scheduler leases and raw attempts are never exposed")
.doesNotContain("/{queryId}/attempts:")
.doesNotContain("port-schedule-query:attempt");
}
}
@@ -22,16 +22,17 @@ class PlatformCarrierScheduleQueryControllerContractTest {
"detail", "platform:firstleg:port-schedule-query:list",
"history", "platform:firstleg:port-schedule-query:list",
"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")) {
if (name.equals("create") || name.equals("manualResult")) {
assertThat(method.getAnnotation(BusinessAudit.class)).isNotNull();
}
});
assertThat(Arrays.stream(PlatformCarrierScheduleQueryController.class.getDeclaredMethods())
.map(Method::getName)).doesNotContain("attempt");
}
}
@@ -17,11 +17,16 @@ class CarrierScheduleQuerySqlContractTest {
.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("CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact")
.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("船司查询历史不可删除");
.contains("船司查询历史不可删除")
.contains("标准船节点事实不可修改")
.contains("ARRIVED','PICKUP_READY','STOPPED")
.contains("lease_owner")
.contains("consecutive_failures");
}
@Test
@@ -29,9 +34,9 @@ class CarrierScheduleQuerySqlContractTest {
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("id IN (5102184,5102186,5102188)")
.contains("platform:firstleg:port-schedule-query:manual")
.contains("'platform:firstleg:port-schedule-query:attempt'")
.contains("role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')")
.contains("船司查询任务角色授权未完整创建");
}
@@ -43,7 +48,7 @@ class CarrierScheduleQuerySqlContractTest {
assertThat(unified)
.contains("fl_carrier_schedule_query")
.contains("uk_carrier_schedule_query_scope")
.contains("5102184 AND 5102188")
.contains("id IN (5102184,5102186,5102188)")
.contains("assert_carrier_schedule_query_schema")
.contains("船司查询任务角色授权未完整创建");
}
@@ -0,0 +1,41 @@
package com.baiyue.firstleg.portschedule.service;
import com.baiyue.firstleg.client.CarrierScheduleIntegrationClient;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity;
import com.baiyue.firstleg.portschedule.repository.CarrierScheduleQueryRepository;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class CarrierScheduleQuerySchedulerTest {
@Test
void leaseWinnerPersistsStandardNodesAndCompletesAttempt() {
CarrierScheduleQueryRepository repository = mock(CarrierScheduleQueryRepository.class);
CarrierScheduleQueryService service = mock(CarrierScheduleQueryService.class);
CarrierScheduleIntegrationClient integration = mock(CarrierScheduleIntegrationClient.class);
CarrierScheduleQueryScheduler scheduler = new CarrierScheduleQueryScheduler(repository, service, integration);
CarrierScheduleQueryEntity task = new CarrierScheduleQueryEntity(); task.setId(1L);
task.setCarrierCode("COSCO"); task.setTrackingType("CONTAINER_NO"); task.setTrackingNo("CSNU1234567");
LocalDateTime now = LocalDateTime.of(2026, 8, 20, 12, 0);
when(repository.claimLease(anyLong(), anyString(), any(), any())).thenReturn(true);
var observation = new CarrierScheduleIntegrationClient.Observation("evt-1", "DISCHARGED", "ARRIVED",
now, null, now, "USLAX", "Los Angeles", "provider:evt-1",
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
when(integration.query(any(), any())).thenReturn(new CarrierScheduleIntegrationClient.Envelope<>("200", null,
new CarrierScheduleIntegrationClient.QueryResult("TRACK_API", "SUCCESS", List.of(observation),
null, null, now)));
scheduler.execute(task, now);
verify(service).applyProviderObservation(anyLong(), any());
verify(service).completeScheduledAttempt(1L, "SUCCESS", null, null);
}
}
@@ -3,10 +3,9 @@ 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.dto.CarrierScheduleQueryDtos.ProviderObservationRequest;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity;
import com.baiyue.firstleg.portschedule.entity.PortScheduleEntity;
import com.baiyue.firstleg.portschedule.repository.CarrierScheduleQueryRepository;
@@ -14,7 +13,6 @@ 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;
@@ -48,7 +46,7 @@ class CarrierScheduleQueryServiceTest {
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.nodes(anyLong())).thenReturn(List.of());
when(repository.histories(anyLong())).thenReturn(List.of());
}
@@ -62,42 +60,48 @@ class CarrierScheduleQueryServiceTest {
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().query().carrierCode()).isEqualTo("ZIM");
assertThat(result.query().query().status()).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);
void outboundConfirmationCreatesTaskWithContainerTrackingAndDepartureNode() {
OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L);
booking.setShippingCompanyName("中远海运"); booking.setBookingNo("OB-260810-0001");
var container = new com.baiyue.firstleg.entity.ContainerBookingEntity();
container.setContainerNo("CSNU1234567");
when(portSchedules.oceanBooking(10L)).thenReturn(booking);
when(portSchedules.containerBooking(20L)).thenReturn(container);
when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity());
var result = service.attempt(1L, 7L, "admin", "carrier-attempt",
new AttemptRequest(0, "官网查询"));
CarrierScheduleQueryEntity result = service.ensureFromOutboundConfirm(10001L, 10L, 20L, 7L, "admin");
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");
assertThat(result.getTriggerSource()).isEqualTo("OUTBOUND_CONFIRM");
assertThat(result.getCarrierCode()).isEqualTo("COSCO");
assertThat(result.getTrackingType()).isEqualTo("CONTAINER_NO");
assertThat(result.getLatestStandardNode()).isEqualTo("DEPARTED_WAREHOUSE");
verify(repository).insertIfAbsent(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity.class));
}
@Test
void blocksFourthAttemptInsideSameThreeDayWindow() {
CarrierScheduleQueryEntity task = task(3, 4);
void providerObservationAdvancesToArrivedWithoutExposingAttempts() {
CarrierScheduleQueryEntity task = task(0, 0);
task.setLatestStandardNode("IN_TRANSIT");
when(repository.lock(1L)).thenReturn(task);
when(repository.find(1L)).thenReturn(task);
when(repository.insertIfAbsent(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity.class)))
.thenReturn(true);
when(repository.updateState(any(), any(Integer.class), anyLong(), anyString())).thenReturn(1);
assertThatThrownBy(() -> service.attempt(1L, 7L, "admin", "carrier-fourth",
new AttemptRequest(4, null)))
.isInstanceOf(BizException.class).hasMessageContaining("最多查询3次");
var result = service.applyProviderObservation(1L, new ProviderObservationRequest("TRACK_API", "evt-1",
"DISCHARGED", "ARRIVED", NOW, null, NOW, "USLAX", "Los Angeles", "provider:evt-1",
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"));
verify(repository, never()).updateState(any(), any(Integer.class), anyLong(), anyString());
assertThat(result.query().query().status()).isEqualTo("ARRIVED");
assertThat(result.query().query().latestStandardNode()).isEqualTo("ARRIVED");
assertThat(result.nodes()).isEmpty();
}
@Test
@@ -149,7 +149,7 @@ class Phase3FinalUnifiedSqlContractTest {
.contains("存在缺失中文注释的业务表")
.contains("存在缺失中文注释的业务字段")
.doesNotContain("CREATE PROCEDURE upgrade_column_comment");
assertThat(count(sql, "CALL queue_table_comment(")).isEqualTo(150);
assertThat(count(sql, "CALL queue_table_comment(")).isEqualTo(151);
assertThat(count(sql, "CALL queue_column_comment(")).isGreaterThanOrEqualTo(2459);
assertThat(sql)
.contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'supplier_id'")
@@ -0,0 +1,96 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.EventPayloadCanonicalizer;
import com.baiyue.common.event.EventPayloadHash;
import com.baiyue.common.event.FirstLegLoadingPlanCompletedV1;
import com.baiyue.common.event.EventTypes;
import com.baiyue.common.event.RegionCode;
import com.baiyue.firstleg.client.EventBridgeClient;
import com.baiyue.firstleg.entity.InboundOutboxEntity;
import com.baiyue.firstleg.repository.LoadingCompletedOutboxRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.List;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class LoadingCompletedOutboxRelayTest {
@Mock private LoadingCompletedOutboxRepository repository;
@Mock private EventBridgeClient client;
private ObjectMapper objectMapper;
private LoadingCompletedOutboxRelay relay;
@BeforeEach
void setUp() {
objectMapper = new ObjectMapper().registerModule(new JavaTimeModule());
relay = new LoadingCompletedOutboxRelay();
ReflectionTestUtils.setField(relay, "repository", repository);
ReflectionTestUtils.setField(relay, "client", client);
ReflectionTestUtils.setField(relay, "objectMapper", objectMapper);
ReflectionTestUtils.setField(relay, "internalToken", "internal-token");
ReflectionTestUtils.setField(relay, "leaseSeconds", 60L);
ReflectionTestUtils.setField(relay, "retrySeconds", 30L);
}
@Test
void claimsExpiredLeasesAndPublishesAfterOverseasBridgeAcknowledgement() throws Exception {
EventEnvelope event = event();
InboundOutboxEntity row = row(event);
when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row));
when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true);
when(repository.claimed(eq(9001L), anyString())).thenReturn(row);
when(client.receive(eq("internal-token"), any(EventEnvelope.class))).thenReturn(ApiResponse.success(
new EventBridgeClient.Acknowledgement(event.eventId(), "已接收", "2026-08-20T03:01:00")));
relay.relay();
verify(repository).published(eq(9001L), anyString(), any(LocalDateTime.class));
verify(repository, never()).retry(anyLong(), anyString(), any(), anyString());
}
@Test
void redactsInternalTokenBeforePersistingRetryError() throws Exception {
EventEnvelope event = event();
InboundOutboxEntity row = row(event);
when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row));
when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true);
when(repository.claimed(eq(9001L), anyString())).thenReturn(row);
when(client.receive(eq("internal-token"), any(EventEnvelope.class)))
.thenThrow(new IllegalStateException("failed internal-token\nremote"));
relay.relay();
verify(repository).retry(eq(9001L), anyString(), any(LocalDateTime.class), eq("failed *** remote"));
}
private InboundOutboxEntity row(EventEnvelope event) throws Exception {
InboundOutboxEntity row = new InboundOutboxEntity();
row.setId(9001L);
row.setEventKey(event.eventId());
row.setPayloadJson(objectMapper.writeValueAsString(event));
return row;
}
private EventEnvelope event() {
var payload = objectMapper.valueToTree(new FirstLegLoadingPlanCompletedV1(
"LOAD-1", "PLAN-1", "BOOK-1", "MSCU1234567", null,
"WH-1", "Warehouse", List.of()));
return new EventEnvelope("00000000-0000-4000-8000-000000000019", EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED, 1,
Instant.parse("2026-08-20T03:00:01Z"), RegionCode.CN, RegionCode.OVERSEAS, "1",
"LOADING_PLAN", "LOAD-1", 7L, 1L, "trace-12345678", "correlation-12345678", null,
payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)));
}
}
@@ -43,12 +43,12 @@ class OverseasContainerEventConsumerTest {
@Test
void derivesEstimatedEmptyReportedAtFromPhotoUploadAndPersistsInbox() {
ContainerBookingEntity container = container();
when(containers.selectActiveByContainerNo("MSCU1234567")).thenReturn(container);
when(containers.selectActiveBookingForUpdate(1001L)).thenReturn(container);
Instant completedAt = Instant.parse("2026-08-20T03:00:00Z");
Instant uploadedAt = Instant.parse("2026-08-20T02:30:00Z");
consumer.consume(event("00000000-0000-4000-8000-000000000001", 7,
new OverseasContainerUnloadedV1("MSCU1234567", completedAt, uploadedAt, null)));
new OverseasContainerUnloadedV1("1001", "MSCU1234567", completedAt, uploadedAt, null)));
verify(containers).applyEmptyReport(eq(1001L), eq(LocalDateTime.parse("2026-08-20T03:00:00")),
eq(LocalDateTime.parse("2026-08-20T02:30:00")),
@@ -62,13 +62,13 @@ class OverseasContainerEventConsumerTest {
@Test
void repeatedEventWithSameHashDoesNotApplyAgain() {
OverseasContainerUnloadedV1 payload = new OverseasContainerUnloadedV1("MSCU1234567",
OverseasContainerUnloadedV1 payload = new OverseasContainerUnloadedV1("1001", "MSCU1234567",
Instant.parse("2026-08-20T03:00:00Z"), Instant.parse("2026-08-20T02:30:00Z"), null);
EventEnvelope event = event("00000000-0000-4000-8000-000000000002", 7, payload);
ContainerEventInboxEntity existing = new ContainerEventInboxEntity();
existing.setPayloadHash(event.payloadHash());
when(inbox.selectById(event.eventId())).thenReturn(existing);
when(containers.selectActiveByContainerNo("MSCU1234567")).thenReturn(container());
when(containers.selectActiveBookingForUpdate(1001L)).thenReturn(container());
consumer.consume(event);
@@ -79,7 +79,7 @@ class OverseasContainerEventConsumerTest {
@Test
void rejectsTamperedPayloadHash() {
EventEnvelope valid = event("00000000-0000-4000-8000-000000000003", 7,
new OverseasContainerUnloadedV1("MSCU1234567", Instant.parse("2026-08-20T03:00:00Z"),
new OverseasContainerUnloadedV1("1001", "MSCU1234567", Instant.parse("2026-08-20T03:00:00Z"),
Instant.parse("2026-08-20T02:30:00Z"), null));
EventEnvelope tampered = new EventEnvelope(valid.eventId(), valid.eventType(), valid.eventVersion(),
valid.occurredAt(), valid.sourceRegion(), valid.targetRegion(), valid.tenantId(), valid.aggregateType(),
@@ -96,7 +96,7 @@ class OverseasContainerEventConsumerTest {
String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node));
return new EventEnvelope(eventId, EventTypes.OVERSEAS_CONTAINER_UNLOADED, 1,
Instant.parse("2026-08-20T02:31:00Z"), RegionCode.OVERSEAS, RegionCode.CN, "1",
"CONTAINER_BOOKING", payload.containerNo(), aggregateVersion, null,
"CONTAINER_BOOKING", payload.sourceContainerBookingId(), aggregateVersion, null,
"trace-12345678", "correlation-12345678", null, node, hash);
}