This commit is contained in:
2026-08-21 16:15:02 +08:00
parent 8e07342fa1
commit cd477fe40a
16 changed files with 278 additions and 72 deletions
@@ -13,6 +13,7 @@ import org.springframework.stereotype.Component;
import java.time.Clock;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.List;
import java.util.UUID;
@@ -26,6 +27,9 @@ public class CarrierScheduleQueryScheduler {
@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.max-tasks-per-trigger:2000}")
private int maxTasksPerTrigger = 2000;
@Value("${baiyue.carrier-schedule.scheduler.zone:Asia/Shanghai}") private String scheduleZone = "Asia/Shanghai";
@Value("${baiyue.carrier-schedule.scheduler.lease-duration:PT5M}")
private Duration leaseDuration = Duration.ofMinutes(5);
@@ -35,10 +39,27 @@ public class CarrierScheduleQueryScheduler {
this.repository = repository; this.service = service; this.integration = integration;
}
@Scheduled(fixedDelayString = "${baiyue.carrier-schedule.scheduler.fixed-delay:PT1M}")
@Scheduled(cron = "${baiyue.carrier-schedule.scheduler.cron-morning:0 0 9 * * *}",
zone = "${baiyue.carrier-schedule.scheduler.zone:Asia/Shanghai}")
@Scheduled(cron = "${baiyue.carrier-schedule.scheduler.cron-noon:0 30 12 * * *}",
zone = "${baiyue.carrier-schedule.scheduler.zone:Asia/Shanghai}")
@Scheduled(cron = "${baiyue.carrier-schedule.scheduler.cron-afternoon:0 0 16 * * *}",
zone = "${baiyue.carrier-schedule.scheduler.zone:Asia/Shanghai}")
public void poll() {
LocalDateTime now = LocalDateTime.now(clock);
repository.due(now, Math.max(1, Math.min(batchSize, 100))).forEach(candidate -> execute(candidate, now));
LocalDateTime triggerAt = LocalDateTime.ofInstant(clock.instant(), ZoneId.of(scheduleZone));
int batch = Math.max(1, Math.min(batchSize, 100));
int safetyLimit = Math.max(1, maxTasksPerTrigger);
int scanned = 0;
while (scanned < safetyLimit) {
List<CarrierScheduleQueryEntity> candidates = repository.due(triggerAt,
Math.min(batch, safetyLimit - scanned));
if (candidates.isEmpty()) return;
for (CarrierScheduleQueryEntity candidate : candidates) {
execute(candidate, triggerAt); scanned++;
if (scanned >= safetyLimit) return;
}
if (candidates.size() < batch) return;
}
}
/** Executes only after this worker wins the database lease. */
@@ -35,6 +35,7 @@ import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
@@ -43,6 +44,7 @@ import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Clock;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.HexFormat;
import java.util.List;
import java.util.Locale;
@@ -51,14 +53,14 @@ import java.util.Set;
@Service
public class CarrierScheduleQueryService {
static final int MAX_ATTEMPTS = 3;
static final int WINDOW_DAYS = 3;
private static final Set<String> TRACKING_TYPES = Set.of("BOOKING_NO", "BILL_OF_LADING", "CONTAINER_NO");
@Resource private CarrierScheduleQueryRepository repository;
@Resource private PortScheduleRepository portSchedules;
@Resource private CarrierScheduleAdapterRegistry adapters;
@Resource private ObjectMapper objectMapper;
@Value("${baiyue.carrier-schedule.scheduler.zone:Asia/Shanghai}")
private String scheduleZone = "Asia/Shanghai";
private Clock clock = Clock.systemDefaultZone();
public List<CarrierDescriptor> carriers() { return adapters.descriptors(); }
@@ -114,7 +116,8 @@ public class CarrierScheduleQueryService {
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.setConsecutiveFailures(0); value.setNextAllowedAt(CarrierScheduleQuerySlots.nextSlot(now));
value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0);
try { repository.insert(value); }
catch (DuplicateKeyException exception) {
@@ -206,7 +209,7 @@ public class CarrierScheduleQueryService {
value.setStatus("STOPPED"); value.setStoppedAt(now()); value.setStopReason("已还柜");
value.setLeaseOwner(null); value.setLeaseUntil(null); value.setNextAllowedAt(null);
} else {
value.setStatus("READY"); value.setNextAllowedAt(now().plusDays(WINDOW_DAYS));
value.setStatus("READY"); value.setNextAllowedAt(CarrierScheduleQuerySlots.nextSlot(now()));
}
if (repository.updateState(value, value.getVersion(), operatorId, operatorName) != 1) throw concurrent();
return true;
@@ -224,31 +227,13 @@ public class CarrierScheduleQueryService {
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.setStatus("READY");
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)) {
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);
}
}
boolean success = "SUCCESS".equals(outcome) || "NO_CHANGE".equals(outcome);
value.setStatus("MANUAL_REQUIRED".equals(outcome) || "PERMANENT_FAILURE".equals(outcome)
? "MANUAL_REQUIRED" : "READY");
value.setConsecutiveFailures(success ? 0 : increment(value.getConsecutiveFailures()));
value.setAttemptCount(increment(value.getAttemptCount()));
if (value.getWindowStartedAt() == null) value.setWindowStartedAt(now);
value.setNextAllowedAt(CarrierScheduleQuerySlots.nextSlot(now));
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,
@@ -259,7 +244,7 @@ public class CarrierScheduleQueryService {
String errorCode, String errorMessage) {
CarrierScheduleQueryAttemptEntity attempt = new CarrierScheduleQueryAttemptEntity();
attempt.setId(IdWorker.getId()); attempt.setQueryId(value.getId());
attempt.setWindowAttemptNo(Math.max(1, Math.min(MAX_ATTEMPTS, count(value.getAttemptCount()) + 1)));
attempt.setWindowAttemptNo(increment(value.getAttemptCount()));
attempt.setCarrierCode(value.getCarrierCode()); attempt.setAccessMode(value.getAccessMode());
attempt.setOfficialUrl(value.getOfficialUrl()); attempt.setAttemptStatus(outcome);
boolean manual = "MANUAL_REQUIRED".equals(outcome);
@@ -287,7 +272,8 @@ public class CarrierScheduleQueryService {
value.setOfficialUrl(strategy.officialUrl()); value.setAccessMode(strategy.accessMode());
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.setNextAllowedAt(CarrierScheduleQuerySlots.nextSlot(now));
value.setManualReason(strategy.manualReason()); value.setRemark(trim(request.remark()));
value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0);
try { repository.insert(value); }
@@ -314,7 +300,7 @@ public class CarrierScheduleQueryService {
LocalDateTime now = now();
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.setExpiresAt(schedule.getExpiresAt()); value.setNextAllowedAt(CarrierScheduleQuerySlots.nextSlot(now));
value.setWindowStartedAt(now); value.setLastAttemptStatus("MANUAL_COMPLETED");
value.setManualReason(trim(request.reason())); value.setLastErrorCode(null); value.setLastErrorMessage(null);
if (repository.updateState(value, request.expectedVersion(), operatorId, operatorName) != 1) throw concurrent();
@@ -505,8 +491,9 @@ public class CarrierScheduleQueryService {
return normalized;
}
private LocalDateTime now() { return LocalDateTime.now(clock); }
private static int count(Integer value) { return value == null ? 0 : value; }
private LocalDateTime now() { return LocalDateTime.ofInstant(clock.instant(), ZoneId.of(scheduleZone)); }
private static int increment(Integer value) { return value == null || value < 0 ? 1
: value == Integer.MAX_VALUE ? Integer.MAX_VALUE : value + 1; }
private static void validateKey(String key) {
if (!StringUtils.hasText(key) || key.length() > 128) throw badRequest("Idempotency-Key不能为空且不能超过128个字符");
}
@@ -0,0 +1,20 @@
package com.baiyue.firstleg.portschedule.service;
import java.time.LocalDateTime;
import java.time.LocalTime;
public final class CarrierScheduleQuerySlots {
private static final LocalTime MORNING = LocalTime.of(9, 0);
private static final LocalTime NOON = LocalTime.of(12, 30);
private static final LocalTime AFTERNOON = LocalTime.of(16, 0);
private CarrierScheduleQuerySlots() { }
/** Returns the first fixed slot strictly after {@code now}. Exact slot boundaries advance. */
public static LocalDateTime nextSlot(LocalDateTime now) {
if (now.toLocalTime().isBefore(MORNING)) return now.toLocalDate().atTime(MORNING);
if (now.toLocalTime().isBefore(NOON)) return now.toLocalDate().atTime(NOON);
if (now.toLocalTime().isBefore(AFTERNOON)) return now.toLocalDate().atTime(AFTERNOON);
return now.toLocalDate().plusDays(1).atTime(MORNING);
}
}
@@ -33,6 +33,14 @@ baiyue:
cosco-official-api-enabled: ${COSCO_OPEN_API_ENABLED:false}
emc-automated: ${EMC_TRACKING_ENABLED:false}
hede-automated: ${HEDE_TRACKING_ENABLED:false}
scheduler:
zone: ${CARRIER_SCHEDULE_ZONE:Asia/Shanghai}
cron-morning: ${CARRIER_SCHEDULE_CRON_MORNING:0 0 9 * * *}
cron-noon: ${CARRIER_SCHEDULE_CRON_NOON:0 30 12 * * *}
cron-afternoon: ${CARRIER_SCHEDULE_CRON_AFTERNOON:0 0 16 * * *}
batch-size: ${CARRIER_SCHEDULE_BATCH_SIZE:20}
max-tasks-per-trigger: ${CARRIER_SCHEDULE_MAX_TASKS_PER_TRIGGER:2000}
lease-duration: ${CARRIER_SCHEDULE_LEASE_DURATION:PT5M}
security:
jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start}
internal-service-token: ${INTERNAL_SERVICE_TOKEN:change-me-to-an-internal-token-at-least-32-bytes-long}
@@ -21,10 +21,10 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query (
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) 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 '累计自动查询调用次数',
next_allowed_at DATETIME(3) NULL COMMENT '下一个09:00、12:30或16:00固定查询槽位;停止后为空',
last_attempt_at DATETIME(3) NULL COMMENT '最近一次查询尝试时间',
last_success_at DATETIME(3) NULL COMMENT '最近一次取得有效结果时间',
source_updated_at DATETIME(3) NULL COMMENT '船公司官网展示的结果更新时间',
@@ -64,14 +64,13 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query (
CONSTRAINT chk_carrier_schedule_query_node CHECK
(latest_standard_node IS NULL OR latest_standard_node IN
('DEPARTED_WAREHOUSE','AT_ORIGIN_PORT','LOADED_ON_VESSEL','DEPARTED_ORIGIN_PORT','IN_TRANSIT',
'ARRIVED_DESTINATION_PORT','DISCHARGED_FROM_VESSEL','PICKUP_READY','PICKED_UP','EMPTY_RETURNED')),
CONSTRAINT chk_carrier_schedule_query_attempt CHECK (attempt_count BETWEEN 0 AND 3)
) ENGINE=InnoDB COMMENT='六家船公司官网查询任务与三天窗口频控';
'ARRIVED_DESTINATION_PORT','DISCHARGED_FROM_VESSEL','PICKUP_READY','PICKED_UP','EMPTY_RETURNED'))
) ENGINE=InnoDB COMMENT='六家船公司固定时刻自动查询任务';
CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_attempt (
id BIGINT UNSIGNED NOT NULL COMMENT '船司官网查询尝试ID',
query_id BIGINT UNSIGNED NOT NULL COMMENT '所属船司官网查询任务ID',
window_attempt_no INT UNSIGNED NOT NULL COMMENT '当前三天窗口内的尝试序号;取值1至3',
window_attempt_no INT UNSIGNED NOT NULL COMMENT '任务累计自动查询调用序号',
carrier_code VARCHAR(16) NOT NULL COMMENT '本次尝试的船公司编码快照',
access_mode VARCHAR(32) NOT NULL COMMENT '本次尝试的官网访问方式快照',
official_url VARCHAR(500) NOT NULL COMMENT '本次尝试使用的官网地址快照',
@@ -87,7 +86,6 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_attempt (
PRIMARY KEY (id),
KEY idx_carrier_schedule_attempt_query (query_id,created_at,id),
CONSTRAINT fk_carrier_schedule_attempt_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id),
CONSTRAINT chk_carrier_schedule_attempt_no CHECK (window_attempt_no BETWEEN 1 AND 3),
CONSTRAINT chk_carrier_schedule_attempt_status CHECK
(attempt_status IN ('SUCCESS','NO_CHANGE','RETRYABLE_FAILURE','PERMANENT_FAILURE','MANUAL_REQUIRED'))
) ENGINE=InnoDB COMMENT='船司官网查询尝试不可变审计';
@@ -25,7 +25,7 @@ paths:
- {name: page, in: query, schema: {type: integer, minimum: 1, default: 1}}
- {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 200, default: 20}}
responses:
'200': {description: Query tasks with source, freshness and rate-limit fields}
'200': {description: Query tasks with source and freshness fields; automatic execution uses the fixed Shanghai slots}
post:
summary: Create a query task linked to a real booking and allocated container
x-permission: platform:firstleg:port-schedule-query:create
@@ -19,8 +19,10 @@ class CarrierScheduleQuerySqlContractTest {
.contains("CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_operation")
.contains("CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact")
.contains("CREATE TABLE IF NOT EXISTS fl_carrier_schedule_exception_fact")
.contains("attempt_count BETWEEN 0 AND 3")
.contains("window_attempt_no BETWEEN 1 AND 3")
.contains("累计自动查询调用次数")
.contains("下一个09:00、12:30或16:00固定查询槽位")
.doesNotContain("attempt_count BETWEEN 0 AND 3")
.doesNotContain("window_attempt_no BETWEEN 1 AND 3")
.contains("UNIQUE KEY uk_carrier_schedule_operation (operator_id,idempotency_key)")
.contains("船司查询尝试不可修改")
.contains("船司查询历史不可删除")
@@ -61,6 +63,10 @@ class CarrierScheduleQuerySqlContractTest {
.contains("uk_carrier_schedule_query_scope")
.contains("id IN (5102184,5102186,5102188)")
.contains("assert_carrier_schedule_query_schema")
.contains("migrate_carrier_schedule_fixed_slots")
.contains("DROP CHECK chk_carrier_schedule_query_attempt")
.contains("DROP CHECK chk_carrier_schedule_attempt_no")
.contains("SET @carrier_schedule_now = CONVERT_TZ(UTC_TIMESTAMP(3),'+00:00','+08:00')")
.contains("'PUBLIC_FORM','PUBLIC_WEB_PROXY','PUBLIC_HTML','HCAPTCHA','OFFICIAL_API'")
.contains("船司查询任务角色授权未完整创建")
.doesNotContain("'platform:firstleg:port-schedule-query:attempt','执行船司查询'");
@@ -4,18 +4,81 @@ 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 org.springframework.scheduling.annotation.Scheduled;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class CarrierScheduleQuerySchedulerTest {
@Test
void declaresExactlyThreeConfigurableShanghaiCronTriggers() throws Exception {
Scheduled[] schedules = CarrierScheduleQueryScheduler.class.getMethod("poll").getAnnotationsByType(Scheduled.class);
assertThat(schedules).hasSize(3);
assertThat(schedules).extracting(Scheduled::cron).containsExactlyInAnyOrder(
"${baiyue.carrier-schedule.scheduler.cron-morning:0 0 9 * * *}",
"${baiyue.carrier-schedule.scheduler.cron-noon:0 30 12 * * *}",
"${baiyue.carrier-schedule.scheduler.cron-afternoon:0 0 16 * * *}");
assertThat(schedules).extracting(Scheduled::zone)
.containsOnly("${baiyue.carrier-schedule.scheduler.zone:Asia/Shanghai}");
}
@Test
void drainsControlledBatchesAndStopsAtThePerTriggerSafetyLimit() {
CarrierScheduleQueryRepository repository = mock(CarrierScheduleQueryRepository.class);
CarrierScheduleQueryScheduler scheduler = spy(new CarrierScheduleQueryScheduler(repository,
mock(CarrierScheduleQueryService.class), mock(CarrierScheduleIntegrationClient.class)));
CarrierScheduleQueryEntity first = task(1L); CarrierScheduleQueryEntity second = task(2L);
CarrierScheduleQueryEntity third = task(3L);
when(repository.due(any(), anyInt())).thenReturn(List.of(first, second), List.of(third));
doNothing().when(scheduler).execute(any(), any());
ReflectionTestUtils.setField(scheduler, "batchSize", 2);
ReflectionTestUtils.setField(scheduler, "maxTasksPerTrigger", 3);
ReflectionTestUtils.setField(scheduler, "scheduleZone", "Asia/Shanghai");
ReflectionTestUtils.setField(scheduler, "clock",
Clock.fixed(Instant.parse("2026-08-21T01:00:00Z"), ZoneOffset.UTC));
scheduler.poll();
verify(repository).due(LocalDateTime.of(2026, 8, 21, 9, 0), 2);
verify(repository).due(LocalDateTime.of(2026, 8, 21, 9, 0), 1);
verify(scheduler, times(3)).execute(any(), any());
}
@Test
void keepsClaimingControlledBatchesUntilNoDueTasksRemain() {
CarrierScheduleQueryRepository repository = mock(CarrierScheduleQueryRepository.class);
CarrierScheduleQueryScheduler scheduler = spy(new CarrierScheduleQueryScheduler(repository,
mock(CarrierScheduleQueryService.class), mock(CarrierScheduleIntegrationClient.class)));
when(repository.due(any(), anyInt())).thenReturn(List.of(task(1L), task(2L)), List.of());
doNothing().when(scheduler).execute(any(), any());
ReflectionTestUtils.setField(scheduler, "batchSize", 2);
ReflectionTestUtils.setField(scheduler, "maxTasksPerTrigger", 10);
ReflectionTestUtils.setField(scheduler, "scheduleZone", "Asia/Shanghai");
ReflectionTestUtils.setField(scheduler, "clock",
Clock.fixed(Instant.parse("2026-08-21T04:30:00Z"), ZoneOffset.UTC));
scheduler.poll();
verify(repository, times(2)).due(LocalDateTime.of(2026, 8, 21, 12, 30), 2);
verify(scheduler, times(2)).execute(any(), any());
}
@Test
void leaseWinnerPersistsStandardNodesAndCompletesAttempt() {
CarrierScheduleQueryRepository repository = mock(CarrierScheduleQueryRepository.class);
@@ -42,4 +105,8 @@ class CarrierScheduleQuerySchedulerTest {
verify(service).applyProviderException(anyLong(), any());
verify(service).completeScheduledAttempt(1L, "SUCCESS", null, null);
}
private static CarrierScheduleQueryEntity task(long id) {
CarrierScheduleQueryEntity value = new CarrierScheduleQueryEntity(); value.setId(id); return value;
}
}
@@ -71,7 +71,9 @@ class CarrierScheduleQueryServiceTest {
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));
ArgumentCaptor<CarrierScheduleQueryEntity> created = ArgumentCaptor.forClass(CarrierScheduleQueryEntity.class);
verify(repository).insert(created.capture());
assertThat(created.getValue().getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30));
}
@Test
@@ -90,6 +92,7 @@ class CarrierScheduleQueryServiceTest {
assertThat(result.getCarrierCode()).isEqualTo("COSCO");
assertThat(result.getTrackingType()).isEqualTo("CONTAINER_NO");
assertThat(result.getLatestStandardNode()).isEqualTo("DEPARTED_WAREHOUSE");
assertThat(result.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30));
verify(repository).insertIfAbsent(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity.class));
}
@@ -212,7 +215,31 @@ class CarrierScheduleQueryServiceTest {
verify(repository).insert(attempt.capture());
assertThat(attempt.getValue().getAttemptStatus()).isEqualTo("NO_CHANGE");
assertThat(attempt.getValue().getOperatorName()).isEqualTo("carrier-scheduler");
assertThat(attempt.getValue().getWindowAttemptNo()).isEqualTo(1);
assertThat(task.getStatus()).isEqualTo("READY");
assertThat(task.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30));
}
@Test
void everySchedulerOutcomeUsesTheNextFixedSlotWithoutExtraRetryTimes() {
List<String> outcomes = List.of("SUCCESS", "NO_CHANGE", "MANUAL_REQUIRED", "RETRYABLE_FAILURE",
"PERMANENT_FAILURE");
List<CarrierScheduleQueryEntity> tasks = outcomes.stream().map(value -> task(0, 0)).toList();
when(repository.lock(1L)).thenReturn(tasks.get(0), tasks.get(1), tasks.get(2), tasks.get(3), tasks.get(4));
when(repository.find(1L)).thenReturn(tasks.get(0), tasks.get(1), tasks.get(2), tasks.get(3), tasks.get(4));
when(repository.updateState(any(), any(Integer.class), anyLong(), anyString())).thenReturn(1);
outcomes.forEach(outcome -> service.completeScheduledAttempt(1L, outcome, null, null));
assertThat(tasks).allSatisfy(value -> {
assertThat(value.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30));
assertThat(value.getAttemptCount()).isEqualTo(1);
});
assertThat(tasks.get(0).getStatus()).isEqualTo("READY");
assertThat(tasks.get(1).getStatus()).isEqualTo("READY");
assertThat(tasks.get(2).getStatus()).isEqualTo("MANUAL_REQUIRED");
assertThat(tasks.get(3).getStatus()).isEqualTo("READY");
assertThat(tasks.get(4).getStatus()).isEqualTo("MANUAL_REQUIRED");
}
@Test
@@ -291,7 +318,7 @@ class CarrierScheduleQueryServiceTest {
value.setId(1L); value.setQueryNo("CSQ1"); value.setCustomerId(10001L);
value.setOceanBookingId(10L); value.setContainerBookingId(20L); value.setCarrierCode("ZIM");
value.setTrackingType("BILL_OF_LADING"); value.setTrackingNo("GAOU6898053");
value.setOfficialUrl("https://www.zimchina.com/tools/track-a-shipment"); value.setAccessMode("HCAPTCHA");
value.setOfficialUrl("https://api.zim.com/"); value.setAccessMode("OFFICIAL_API");
value.setStatus("MANUAL_REQUIRED"); value.setWindowStartedAt(NOW.minusDays(1));
value.setAttemptCount(attempts); value.setNextAllowedAt(NOW); value.setVersion(version);
return value;
@@ -0,0 +1,24 @@
package com.baiyue.firstleg.portschedule.service;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.assertThat;
class CarrierScheduleQuerySlotsTest {
@Test
void returnsTheFirstStrictlyLaterShanghaiSlotAtExactBoundaries() {
assertThat(CarrierScheduleQuerySlots.nextSlot(at(8, 59, 59))).isEqualTo(at(9, 0, 0));
assertThat(CarrierScheduleQuerySlots.nextSlot(at(9, 0, 0))).isEqualTo(at(12, 30, 0));
assertThat(CarrierScheduleQuerySlots.nextSlot(at(12, 29, 59))).isEqualTo(at(12, 30, 0));
assertThat(CarrierScheduleQuerySlots.nextSlot(at(12, 30, 0))).isEqualTo(at(16, 0, 0));
assertThat(CarrierScheduleQuerySlots.nextSlot(at(15, 59, 59))).isEqualTo(at(16, 0, 0));
assertThat(CarrierScheduleQuerySlots.nextSlot(at(16, 0, 0)))
.isEqualTo(LocalDateTime.of(2026, 8, 22, 9, 0));
}
private static LocalDateTime at(int hour, int minute, int second) {
return LocalDateTime.of(2026, 8, 21, hour, minute, second);
}
}