feat(firstleg): add compliant carrier schedule queries

This commit is contained in:
2026-08-10 12:20:05 +08:00
parent 5c1c6a1a07
commit edd5108625
21 changed files with 1654 additions and 0 deletions
@@ -0,0 +1,27 @@
package com.baiyue.firstleg.portschedule.controller;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
class CarrierScheduleQueryOpenApiContractTest {
@Test
void publishesFrequencyFreshnessAndManualFallbackContract() throws Exception {
String yaml = new ClassPathResource("openapi/carrier-schedule-query-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(yaml)
.contains("/api/platform/v1/carrier-schedule-queries:")
.contains("/api/platform/v1/carrier-schedule-queries/{queryId}/attempts:")
.contains("/api/platform/v1/carrier-schedule-queries/{queryId}/manual-results:")
.contains("enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE]")
.contains("MANUAL_REQUIRED")
.contains("coverageGap")
.contains("lastAttemptAt")
.contains("lastSuccessAt")
.contains("sourceUpdatedAt")
.contains("nextAllowedAt");
}
}
@@ -0,0 +1,37 @@
package com.baiyue.firstleg.portschedule.controller;
import com.baiyue.common.audit.BusinessAudit;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.RequestMapping;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class PlatformCarrierScheduleQueryControllerContractTest {
@Test
void exposesDedicatedTaskResourceAndLeastPrivilegeActions() {
assertThat(PlatformCarrierScheduleQueryController.class.getAnnotation(RequestMapping.class).value())
.containsExactly("/api/platform/v1/carrier-schedule-queries");
Map<String, String> permissions = Map.of(
"carriers", "platform:firstleg:port-schedule-query:view",
"page", "platform:firstleg:port-schedule-query:list",
"detail", "platform:firstleg:port-schedule-query:view",
"history", "platform:firstleg:port-schedule-query:view",
"create", "platform:firstleg:port-schedule-query:create",
"attempt", "platform:firstleg:port-schedule-query:attempt",
"manualResult", "platform:firstleg:port-schedule-query:manual");
permissions.forEach((name, permission) -> {
Method method = Arrays.stream(PlatformCarrierScheduleQueryController.class.getDeclaredMethods())
.filter(candidate -> candidate.getName().equals(name)).findFirst().orElseThrow();
assertThat(method.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('" + permission + "')");
if (name.equals("create") || name.equals("attempt") || name.equals("manualResult")) {
assertThat(method.getAnnotation(BusinessAudit.class)).isNotNull();
}
});
}
}
@@ -0,0 +1,55 @@
package com.baiyue.firstleg.portschedule.repository;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class CarrierScheduleQuerySqlContractTest {
@Test
void definesTaskAttemptsHistoryIdempotencyAndHardLimit() throws Exception {
assertThat(moduleSql())
.contains("CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query (")
.contains("CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_attempt")
.contains("CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_history")
.contains("CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_operation")
.contains("attempt_count BETWEEN 0 AND 3")
.contains("window_attempt_no BETWEEN 1 AND 3")
.contains("UNIQUE KEY uk_carrier_schedule_operation (operator_id,idempotency_key)")
.contains("船司查询尝试不可修改")
.contains("船司查询历史不可删除");
}
@Test
void enforcesRealBookingContainerAndPermissions() throws Exception {
assertThat(moduleSql())
.contains("booking.id=NEW.ocean_booking_id AND booking.customer_id=NEW.customer_id")
.contains("allocation.container_booking_id=NEW.container_booking_id")
.contains("5102184 AND 5102188")
.contains("platform:firstleg:port-schedule-query:attempt")
.contains("platform:firstleg:port-schedule-query:manual")
.contains("role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')")
.contains("船司查询任务角色授权未完整创建");
}
@Test
void unifiedDeploymentEntryContainsTheSameContract() throws Exception {
String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"),
StandardCharsets.UTF_8);
assertThat(unified)
.contains("fl_carrier_schedule_query")
.contains("uk_carrier_schedule_query_scope")
.contains("5102184 AND 5102188")
.contains("assert_carrier_schedule_query_schema")
.contains("船司查询任务角色授权未完整创建");
}
private static String moduleSql() throws Exception {
return new ClassPathResource("db/phase3-carrier-schedule-query-schema.sql")
.getContentAsString(StandardCharsets.UTF_8);
}
}
@@ -0,0 +1,40 @@
package com.baiyue.firstleg.portschedule.service;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class CarrierScheduleAdapterRegistryTest {
private final CarrierScheduleAdapterRegistry registry = new CarrierScheduleAdapterRegistry();
@Test
void exposesExactlySixOfficialSourcesWithoutPretendingAutomation() {
assertThat(registry.descriptors()).extracting("carrierCode")
.containsExactly("MATSON", "SML", "ZIM", "COSCO", "EMC", "HEDE");
assertThat(registry.descriptors()).allSatisfy(value -> {
assertThat(value.automated()).isFalse();
assertThat(value.coverageGap()).isTrue();
assertThat(value.officialUrl()).startsWith("http");
});
}
@Test
void returnsStructuredManualRequirementsForCaptchaAndDynamicPage() {
var zim = registry.prepare("zim", "BILL_OF_LADING", "GAOU6898053");
var cosco = registry.prepare("COSCO", "CONTAINER_NO", "C123");
assertThat(zim.status()).isEqualTo("MANUAL_REQUIRED");
assertThat(zim.accessMode()).isEqualTo("HCAPTCHA");
assertThat(zim.coverageGapCode()).isEqualTo("HCAPTCHA_REQUIRED");
assertThat(zim.officialUrl()).contains("consnumber=GAOU6898053");
assertThat(cosco.accessMode()).isEqualTo("DYNAMIC_IFRAME");
assertThat(cosco.coverageGapCode()).isEqualTo("DYNAMIC_IFRAME_REQUIRED");
}
@Test
void rejectsUnknownCarrier() {
assertThatThrownBy(() -> registry.prepare("UNKNOWN", "BOOKING_NO", "X"))
.hasMessageContaining("仅支持MATSON");
}
}
@@ -0,0 +1,134 @@
package com.baiyue.firstleg.portschedule.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.AttemptRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CreateRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualResultRequest;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryAttemptEntity;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity;
import com.baiyue.firstleg.portschedule.entity.PortScheduleEntity;
import com.baiyue.firstleg.portschedule.repository.CarrierScheduleQueryRepository;
import com.baiyue.firstleg.portschedule.repository.PortScheduleRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class CarrierScheduleQueryServiceTest {
private static final LocalDateTime NOW = LocalDateTime.of(2026, 8, 10, 12, 0);
@Mock private CarrierScheduleQueryRepository repository;
@Mock private PortScheduleRepository portSchedules;
@InjectMocks private CarrierScheduleQueryService service;
@BeforeEach
void setUp() throws Exception {
MockitoAnnotations.openMocks(this);
set("objectMapper", new ObjectMapper().findAndRegisterModules());
set("adapters", new CarrierScheduleAdapterRegistry());
set("clock", Clock.fixed(Instant.parse("2026-08-10T04:00:00Z"), ZoneOffset.UTC));
when(repository.claimOperation(any(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
when(repository.completeOperation(anyLong(), anyLong(), anyString(), anyString())).thenReturn(1);
when(repository.attempts(anyLong())).thenReturn(List.of());
when(repository.histories(anyLong())).thenReturn(List.of());
}
@Test
void createsTaskOnlyForBookingCustomerAndAllocatedContainer() {
OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L);
when(portSchedules.oceanBooking(10L)).thenReturn(booking);
when(portSchedules.containerBooking(20L)).thenReturn(new com.baiyue.firstleg.entity.ContainerBookingEntity());
when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity());
var result = service.create(7L, "admin", "carrier-create",
new CreateRequest(0, 10001L, 10L, 20L, "ZIM", "BILL_OF_LADING", "GAOU6898053", null));
assertThat(result.query().query().getCarrierCode()).isEqualTo("ZIM");
assertThat(result.query().query().getStatus()).isEqualTo("READY");
assertThat(result.query().expired()).isTrue();
verify(repository).insert(any(CarrierScheduleQueryEntity.class));
}
@Test
void recordsManualRequiredAttemptWithoutBypassingCaptcha() {
CarrierScheduleQueryEntity task = task(0, 0);
when(repository.lock(1L)).thenReturn(task);
when(repository.updateState(any(), any(Integer.class), anyLong(), anyString())).thenReturn(1);
when(repository.find(1L)).thenAnswer(invocation -> task);
var result = service.attempt(1L, 7L, "admin", "carrier-attempt",
new AttemptRequest(0, "官网查询"));
assertThat(result.query().query().getStatus()).isEqualTo("MANUAL_REQUIRED");
assertThat(result.query().query().getAttemptCount()).isEqualTo(1);
assertThat(result.query().query().getManualReason()).contains("hCaptcha");
ArgumentCaptor<CarrierScheduleQueryAttemptEntity> attempt =
ArgumentCaptor.forClass(CarrierScheduleQueryAttemptEntity.class);
verify(repository).insert(attempt.capture());
assertThat(attempt.getValue().getCoverageGap()).isTrue();
assertThat(attempt.getValue().getCoverageGapCode()).isEqualTo("HCAPTCHA_REQUIRED");
}
@Test
void blocksFourthAttemptInsideSameThreeDayWindow() {
CarrierScheduleQueryEntity task = task(3, 4);
when(repository.lock(1L)).thenReturn(task);
assertThatThrownBy(() -> service.attempt(1L, 7L, "admin", "carrier-fourth",
new AttemptRequest(4, null)))
.isInstanceOf(BizException.class).hasMessageContaining("最多查询3次");
verify(repository, never()).updateState(any(), any(Integer.class), anyLong(), anyString());
}
@Test
void manualResultMustBelongToSameCustomerBookingAndContainer() {
CarrierScheduleQueryEntity task = task(1, 1);
when(repository.lock(1L)).thenReturn(task);
PortScheduleEntity schedule = new PortScheduleEntity();
schedule.setId(99L); schedule.setCustomerId(20002L); schedule.setOceanBookingId(10L);
schedule.setContainerBookingId(20L); schedule.setObservedAt(NOW); schedule.setExpiresAt(NOW.plusDays(3));
when(portSchedules.find(99L)).thenReturn(schedule);
assertThatThrownBy(() -> service.attachManualResult(1L, 7L, "admin", "carrier-manual",
new ManualResultRequest(1, 99L, "人工核验")))
.isInstanceOf(BizException.class).hasMessageContaining("不一致");
verify(repository, never()).updateState(any(), any(Integer.class), anyLong(), anyString());
}
private static CarrierScheduleQueryEntity task(int attempts, int version) {
CarrierScheduleQueryEntity value = new CarrierScheduleQueryEntity();
value.setId(1L); value.setQueryNo("CSQ1"); value.setCustomerId(10001L);
value.setOceanBookingId(10L); value.setContainerBookingId(20L); value.setCarrierCode("ZIM");
value.setTrackingType("BILL_OF_LADING"); value.setTrackingNo("GAOU6898053");
value.setOfficialUrl("https://www.zimchina.com/tools/track-a-shipment"); value.setAccessMode("HCAPTCHA");
value.setStatus("MANUAL_REQUIRED"); value.setWindowStartedAt(NOW.minusDays(1));
value.setAttemptCount(attempts); value.setNextAllowedAt(NOW); value.setVersion(version);
return value;
}
private void set(String fieldName, Object value) throws Exception {
var field = CarrierScheduleQueryService.class.getDeclaredField(fieldName);
field.setAccessible(true); field.set(service, value);
}
}