This commit is contained in:
2026-08-21 17:05:52 +08:00
parent cd477fe40a
commit 8c280e19c4
18 changed files with 411 additions and 103 deletions
@@ -19,10 +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("累计自动查询调用次数")
.contains("下一个09:00、12:30或16:00固定查询槽位")
.doesNotContain("attempt_count BETWEEN 0 AND 3")
.doesNotContain("window_attempt_no BETWEEN 1 AND 3")
.contains("当前正常查询槽位已调用次数;最多3次")
.contains("下一正常槽位或槽内技术退避时间")
.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("船司查询历史不可删除")
@@ -63,10 +63,24 @@ 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("CREATE PROCEDURE migrate_carrier_schedule_retry_columns()")
.contains("ADD COLUMN trigger_source VARCHAR(32) NOT NULL DEFAULT 'MANUAL'")
.contains("ADD COLUMN latest_standard_node VARCHAR(32) NULL")
.contains("ADD COLUMN latest_node_observed_at DATETIME(3) NULL")
.contains("ADD COLUMN stopped_at DATETIME(3) NULL")
.contains("ADD COLUMN stop_reason VARCHAR(500) NULL")
.contains("ADD COLUMN consecutive_failures INT UNSIGNED NOT NULL DEFAULT 0")
.contains("ADD COLUMN window_started_at DATETIME(3) NULL")
.contains("ADD COLUMN attempt_count INT UNSIGNED NOT NULL DEFAULT 0")
.contains("ADD COLUMN next_allowed_at DATETIME(3) NULL")
.contains("ADD KEY idx_carrier_schedule_query_due (status,next_allowed_at,id)")
.contains("SET @carrier_schedule_now = CONVERT_TZ(UTC_TIMESTAMP(3),'+00:00','+08:00')")
.contains("SET @carrier_schedule_next = CASE")
.contains("CREATE PROCEDURE ensure_carrier_schedule_retry_check()")
.contains("ADD CONSTRAINT chk_carrier_schedule_query_attempt CHECK (attempt_count BETWEEN 0 AND 3)")
.contains("统一迁移失败:船司查询固定槽位字段、索引或尝试次数约束不完整")
.doesNotContain("DROP CHECK chk_carrier_schedule_query_attempt")
.doesNotContain("\n+--")
.contains("'PUBLIC_FORM','PUBLIC_WEB_PROXY','PUBLIC_HTML','HCAPTCHA','OFFICIAL_API'")
.contains("船司查询任务角色授权未完整创建")
.doesNotContain("'platform:firstleg:port-schedule-query:attempt','执行船司查询'");
@@ -27,15 +27,15 @@ import static org.mockito.Mockito.when;
class CarrierScheduleQuerySchedulerTest {
@Test
void declaresExactlyThreeConfigurableShanghaiCronTriggers() throws Exception {
void declaresOneConfigurableDueScannerRatherThanDirectCarrierCronCalls() 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}");
assertThat(schedules).singleElement().satisfies(value -> {
assertThat(value.fixedDelayString())
.isEqualTo("${baiyue.carrier-schedule.scheduler.scan-fixed-delay:PT1M}");
assertThat(value.initialDelayString())
.isEqualTo("${baiyue.carrier-schedule.scheduler.scan-initial-delay:PT10S}");
assertThat(value.cron()).isEmpty();
});
}
@Test
@@ -231,10 +231,14 @@ class CarrierScheduleQueryServiceTest {
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).getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30));
assertThat(tasks.get(1).getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30));
assertThat(tasks.get(2).getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30));
assertThat(tasks.get(3).getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 15));
assertThat(tasks.get(4).getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30));
assertThat(tasks.get(3).getAttemptCount()).isEqualTo(1);
assertThat(tasks).filteredOn(value -> value != tasks.get(3))
.allSatisfy(value -> assertThat(value.getAttemptCount()).isZero());
assertThat(tasks.get(0).getStatus()).isEqualTo("READY");
assertThat(tasks.get(1).getStatus()).isEqualTo("READY");
assertThat(tasks.get(2).getStatus()).isEqualTo("MANUAL_REQUIRED");
@@ -242,6 +246,96 @@ class CarrierScheduleQueryServiceTest {
assertThat(tasks.get(4).getStatus()).isEqualTo("MANUAL_REQUIRED");
}
@Test
void retryableFailureUsesFifteenThenThirtyMinutesAndStopsAfterThreeCallsInTheSlot() throws Exception {
CarrierScheduleQueryEntity task = task(0, 0);
task.setStatus("READY"); task.setNextAllowedAt(LocalDateTime.of(2026, 8, 10, 9, 0));
when(repository.lock(1L)).thenReturn(task); when(repository.find(1L)).thenReturn(task);
when(repository.updateState(any(), any(Integer.class), anyLong(), anyString())).thenReturn(1);
set("clock", Clock.fixed(Instant.parse("2026-08-10T01:00:00Z"), ZoneOffset.UTC));
service.completeScheduledAttempt(1L, "RETRYABLE_FAILURE", "TIMEOUT", "timeout");
assertThat(task.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 9, 15));
assertThat(task.getAttemptCount()).isEqualTo(1);
set("clock", Clock.fixed(Instant.parse("2026-08-10T01:15:00Z"), ZoneOffset.UTC));
service.completeScheduledAttempt(1L, "RETRYABLE_FAILURE", "TIMEOUT", "timeout");
assertThat(task.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 9, 45));
assertThat(task.getAttemptCount()).isEqualTo(2);
set("clock", Clock.fixed(Instant.parse("2026-08-10T01:45:00Z"), ZoneOffset.UTC));
service.completeScheduledAttempt(1L, "RETRYABLE_FAILURE", "TIMEOUT", "timeout");
assertThat(task.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30));
assertThat(task.getAttemptCount()).isZero();
ArgumentCaptor<CarrierScheduleQueryAttemptEntity> attempts =
ArgumentCaptor.forClass(CarrierScheduleQueryAttemptEntity.class);
verify(repository, org.mockito.Mockito.times(3)).insert(attempts.capture());
assertThat(attempts.getAllValues()).extracting(CarrierScheduleQueryAttemptEntity::getWindowAttemptNo)
.containsExactly(1, 2, 3);
}
@Test
void retryThatWouldReachTheNextSlotWaitsForTheSlotInstead() throws Exception {
CarrierScheduleQueryEntity task = task(0, 0);
task.setStatus("READY"); task.setNextAllowedAt(LocalDateTime.of(2026, 8, 10, 12, 20));
when(repository.lock(1L)).thenReturn(task); when(repository.find(1L)).thenReturn(task);
when(repository.updateState(any(), any(Integer.class), anyLong(), anyString())).thenReturn(1);
set("clock", Clock.fixed(Instant.parse("2026-08-10T04:20:00Z"), ZoneOffset.UTC));
service.completeScheduledAttempt(1L, "RETRYABLE_FAILURE", "HTTP_503", "unavailable");
assertThat(task.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30));
assertThat(task.getAttemptCount()).isZero();
}
@Test
void missedNormalSlotIsDeferredWithoutCallingTheProviderOrWritingAnAttempt() {
CarrierScheduleQueryEntity task = task(0, 0);
task.setStatus("READY"); task.setNextAllowedAt(LocalDateTime.of(2026, 8, 10, 9, 0));
task.setLeaseOwner("worker"); task.setLeaseUntil(LocalDateTime.of(2026, 8, 10, 10, 5));
when(repository.lock(1L)).thenReturn(task);
when(repository.updateState(any(), any(Integer.class), anyLong(), anyString())).thenReturn(1);
boolean deferred = service.deferMissedNormalSlot(1L, LocalDateTime.of(2026, 8, 10, 10, 0));
assertThat(deferred).isTrue();
assertThat(task.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30));
assertThat(task.getLeaseOwner()).isNull(); assertThat(task.getLeaseUntil()).isNull();
verify(repository, never()).insert(any(CarrierScheduleQueryAttemptEntity.class));
}
@Test
void staleRetryBecomesANewFirstAttemptOnlyDuringTheNextFixedSlotMinute() {
CarrierScheduleQueryEntity atSlot = task(2, 0);
atSlot.setStatus("READY"); atSlot.setWindowStartedAt(LocalDateTime.of(2026, 8, 10, 9, 0));
atSlot.setNextAllowedAt(LocalDateTime.of(2026, 8, 10, 9, 45)); atSlot.setConsecutiveFailures(2);
atSlot.setLeaseOwner("worker"); atSlot.setLeaseUntil(LocalDateTime.of(2026, 8, 10, 12, 35));
when(repository.lock(1L)).thenReturn(atSlot);
when(repository.updateState(any(), any(Integer.class), anyLong(), anyString())).thenReturn(1);
boolean executeCurrentSlot = service.deferMissedNormalSlot(1L,
LocalDateTime.of(2026, 8, 10, 12, 30, 20));
assertThat(executeCurrentSlot).isFalse();
assertThat(atSlot.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30));
assertThat(atSlot.getAttemptCount()).isZero();
assertThat(atSlot.getLeaseOwner()).isEqualTo("worker");
CarrierScheduleQueryEntity outsideSlot = task(2, 0);
outsideSlot.setStatus("READY"); outsideSlot.setWindowStartedAt(LocalDateTime.of(2026, 8, 10, 9, 0));
outsideSlot.setNextAllowedAt(LocalDateTime.of(2026, 8, 10, 9, 45));
outsideSlot.setLeaseOwner("worker"); outsideSlot.setLeaseUntil(LocalDateTime.of(2026, 8, 10, 13, 5));
when(repository.lock(2L)).thenReturn(outsideSlot);
boolean deferred = service.deferMissedNormalSlot(2L, LocalDateTime.of(2026, 8, 10, 13, 0));
assertThat(deferred).isTrue();
assertThat(outsideSlot.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 16, 0));
assertThat(outsideSlot.getAttemptCount()).isZero();
assertThat(outsideSlot.getLeaseOwner()).isNull();
}
@Test
void matsonIsAdvertisedAsAutomatedOnlyWhenDeploymentEnablesIt() {
CarrierScheduleAdapterRegistry registry = new CarrierScheduleAdapterRegistry();
@@ -16,6 +16,10 @@ class CarrierScheduleQuerySlotsTest {
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));
assertThat(CarrierScheduleQuerySlots.isMissedNormalSlot(at(9, 0, 0), 0, at(9, 0, 59))).isFalse();
assertThat(CarrierScheduleQuerySlots.isMissedNormalSlot(at(9, 0, 0), 0, at(9, 1, 0))).isTrue();
assertThat(CarrierScheduleQuerySlots.isMissedNormalSlot(at(9, 0, 0), 0, at(12, 30, 30))).isFalse();
assertThat(CarrierScheduleQuerySlots.isMissedNormalSlot(at(9, 15, 0), 1, at(10, 0, 0))).isFalse();
}
private static LocalDateTime at(int hour, int minute, int second) {
@@ -84,6 +84,8 @@ class Phase3FinalUnifiedSqlContractTest {
.contains("platform:firstleg:outbound-dispatch:%")
.contains("platform:firstleg:customs:%")
.contains("platform:firstleg:port-schedule:%")
.contains("id BETWEEN 5102180 AND 5102183 AND permission_code LIKE 'platform:firstleg:port-schedule:%') <> 3")
.contains("OR p.permission_code LIKE 'platform:firstleg:pda:%')) <> 36")
.contains("platform:firstleg:pda:%")
.contains("role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')")
.contains("角色授权未完整写入");
@@ -210,6 +212,10 @@ class Phase3FinalUnifiedSqlContractTest {
assertThat(sql)
.doesNotContain("\n ADD COLUMN IF NOT EXISTS")
.doesNotContain("\n DROP CHECK IF EXISTS")
.doesNotContain("\n+--")
.doesNotContain("\n---")
.doesNotContain("\n>>>")
.doesNotContain("\n<<<")
.contains("CREATE PROCEDURE phase3_pda_add_column")
.contains("CREATE PROCEDURE phase3_pda_replace_operation_check");
}