优化
This commit is contained in:
@@ -11,7 +11,7 @@
|
||||
- 内部接口:`controller/IntegrationProviderController.java`、`WeightOcrController.java`
|
||||
- OCR 编排:`service/ocr/WeightOcrBatchService.java`、`ImageCropper.java`、`WeightCandidateSelector.java`
|
||||
- 供应商适配:`provider/IntegrationProviderAdapter.java`、`BaiduOcrAdapter.java`、`AmazonLogisticsAdapter.java`
|
||||
- 四期船节点外部适配:内部契约 `controller/IntegrationProviderController.java` 的 `/internal/integrations/carrier-schedules/query`,统一 DTO `dto/CarrierScheduleDtos.java`,provider SPI `provider/CarrierScheduleProviderAdapter.java`,标准节点 `domain/CarrierScheduleStandardNode.java`;轮询任务、租约、订舱/柜次关联、最新节点和节点历史归 `firstleg/portschedule/`,范围见 `../../docs/四期/README.md`
|
||||
- 四期船节点外部适配:内部契约 `controller/IntegrationProviderController.java` 的 `/internal/integrations/carrier-schedules/query`,统一十节点与四类异常 DTO `dto/CarrierScheduleDtos.java`,provider SPI `provider/CarrierScheduleProviderAdapter.java`,标准枚举 `domain/CarrierScheduleStandardNode.java`、`CarrierScheduleExceptionCode.java`;美森网页会话、动态 `ttr` 与公开查询代理适配见 `provider/MatsonTrackingClient.java`、`MatsonCarrierScheduleProviderAdapter.java`,由 `MATSON_TRACKING_ENABLED` 显式启用;SML 的 122→125→124→126 JSON 调用链与 A/E 时间解析见 `provider/SmlTrackingClient.java`、`SmlCarrierScheduleProviderAdapter.java`,由 `SML_TRACKING_ENABLED` 显式启用;ZIM 正式 Tracing API 的 OAuth2 token 缓存、401 单次刷新及柜号活动解析见 `provider/ZimTrackingClient.java`、`ZimCarrierScheduleProviderAdapter.java`,凭据只从外部 secret 注入并由 `ZIM_TRACKING_ENABLED` 显式启用;长荣柜号→关联提单两段 UTF-8 HTML 查询、表头定位与日粒度事实解析见 `provider/EvergreenTrackingClient.java`、`EvergreenCarrierScheduleProviderAdapter.java`,由 `EMC_TRACKING_ENABLED` 显式启用且不输出完整提单号;合德 UTF-8 HTML 主表解析见 `provider/HedeTrackingClient.java`、`HedeCarrierScheduleProviderAdapter.java`,由 `HEDE_TRACKING_ENABLED` 显式启用且当前只自动解析柜号;轮询任务、租约、订舱/柜次关联、最新实际节点、节点/异常事实和历史归 `firstleg/portschedule/`,标准见 `../../docs/四期/船节点标准化契约.md`
|
||||
- 安全:`security/InternalServiceTokenGuard.java`;配置 `application.yml`、`../config/nacos/{local,cn}/integration-service.yml`
|
||||
- 测试:`src/test/java/com/baiyue/integration/`;`mvn -pl integration-service -am test`
|
||||
|
||||
|
||||
+132
-1
@@ -8,7 +8,8 @@ import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
|
||||
@ConfigurationProperties(prefix = "baiyue.integration")
|
||||
public record IntegrationProperties(BaiduOcr baiduOcr, AmazonSpApi amazonSpApi) {
|
||||
public record IntegrationProperties(BaiduOcr baiduOcr, AmazonSpApi amazonSpApi,
|
||||
CarrierSchedule carrierSchedule) {
|
||||
public record BaiduOcr(boolean enabled,
|
||||
String apiKey,
|
||||
String secretKey,
|
||||
@@ -84,6 +85,136 @@ public record IntegrationProperties(BaiduOcr baiduOcr, AmazonSpApi amazonSpApi)
|
||||
}
|
||||
}
|
||||
|
||||
public record CarrierSchedule(Matson matson, Hede hede, Sml sml, Zim zim, Evergreen evergreen) {
|
||||
public record Matson(boolean enabled,
|
||||
URI pageUrl,
|
||||
URI queryUrl,
|
||||
Duration connectTimeout,
|
||||
Duration requestTimeout,
|
||||
Duration minRequestInterval) {
|
||||
public boolean configured() {
|
||||
return enabled && pageUrl != null && queryUrl != null;
|
||||
}
|
||||
|
||||
public Duration effectiveConnectTimeout() {
|
||||
return positive(connectTimeout) ? connectTimeout : Duration.ofSeconds(10);
|
||||
}
|
||||
|
||||
public Duration effectiveRequestTimeout() {
|
||||
return positive(requestTimeout) ? requestTimeout : Duration.ofSeconds(30);
|
||||
}
|
||||
|
||||
public Duration effectiveMinRequestInterval() {
|
||||
return minRequestInterval != null && !minRequestInterval.isNegative()
|
||||
? minRequestInterval : Duration.ofSeconds(5);
|
||||
}
|
||||
|
||||
private static boolean positive(Duration value) {
|
||||
return value != null && !value.isNegative() && !value.isZero();
|
||||
}
|
||||
}
|
||||
|
||||
public record Hede(boolean enabled,
|
||||
URI queryUrl,
|
||||
Duration connectTimeout,
|
||||
Duration requestTimeout,
|
||||
Duration minRequestInterval) {
|
||||
public boolean configured() { return enabled && queryUrl != null; }
|
||||
|
||||
public Duration effectiveConnectTimeout() {
|
||||
return positive(connectTimeout) ? connectTimeout : Duration.ofSeconds(10);
|
||||
}
|
||||
|
||||
public Duration effectiveRequestTimeout() {
|
||||
return positive(requestTimeout) ? requestTimeout : Duration.ofSeconds(30);
|
||||
}
|
||||
|
||||
public Duration effectiveMinRequestInterval() {
|
||||
return minRequestInterval != null && !minRequestInterval.isNegative()
|
||||
? minRequestInterval : Duration.ofSeconds(5);
|
||||
}
|
||||
|
||||
private static boolean positive(Duration value) {
|
||||
return value != null && !value.isNegative() && !value.isZero();
|
||||
}
|
||||
}
|
||||
|
||||
public record Sml(boolean enabled,
|
||||
URI queryUrl,
|
||||
Duration connectTimeout,
|
||||
Duration requestTimeout,
|
||||
Duration minRequestInterval) {
|
||||
public boolean configured() { return enabled && queryUrl != null; }
|
||||
|
||||
public Duration effectiveConnectTimeout() {
|
||||
return positive(connectTimeout) ? connectTimeout : Duration.ofSeconds(10);
|
||||
}
|
||||
|
||||
public Duration effectiveRequestTimeout() {
|
||||
return positive(requestTimeout) ? requestTimeout : Duration.ofSeconds(30);
|
||||
}
|
||||
|
||||
public Duration effectiveMinRequestInterval() {
|
||||
return minRequestInterval != null && !minRequestInterval.isNegative()
|
||||
? minRequestInterval : Duration.ofSeconds(5);
|
||||
}
|
||||
|
||||
private static boolean positive(Duration value) {
|
||||
return value != null && !value.isNegative() && !value.isZero();
|
||||
}
|
||||
}
|
||||
|
||||
public record Zim(boolean enabled,
|
||||
URI authorizationUrl,
|
||||
URI tracingBaseUrl,
|
||||
String clientId,
|
||||
String clientSecret,
|
||||
String subscriptionKey,
|
||||
Duration connectTimeout,
|
||||
Duration requestTimeout,
|
||||
int maxConcurrency) {
|
||||
public boolean configured() {
|
||||
return enabled && authorizationUrl != null && tracingBaseUrl != null
|
||||
&& hasText(clientId) && hasText(clientSecret) && hasText(subscriptionKey);
|
||||
}
|
||||
|
||||
public Duration effectiveConnectTimeout() {
|
||||
return positive(connectTimeout) ? connectTimeout : Duration.ofSeconds(10);
|
||||
}
|
||||
|
||||
public Duration effectiveRequestTimeout() {
|
||||
return positive(requestTimeout) ? requestTimeout : Duration.ofSeconds(30);
|
||||
}
|
||||
|
||||
public int effectiveMaxConcurrency() { return Math.max(1, Math.min(10, maxConcurrency)); }
|
||||
|
||||
private static boolean positive(Duration value) {
|
||||
return value != null && !value.isNegative() && !value.isZero();
|
||||
}
|
||||
}
|
||||
|
||||
public record Evergreen(boolean enabled,
|
||||
URI queryUrl,
|
||||
Duration connectTimeout,
|
||||
Duration requestTimeout,
|
||||
Duration minRequestInterval) {
|
||||
public boolean configured() { return enabled && queryUrl != null; }
|
||||
public Duration effectiveConnectTimeout() {
|
||||
return positive(connectTimeout) ? connectTimeout : Duration.ofSeconds(10);
|
||||
}
|
||||
public Duration effectiveRequestTimeout() {
|
||||
return positive(requestTimeout) ? requestTimeout : Duration.ofSeconds(30);
|
||||
}
|
||||
public Duration effectiveMinRequestInterval() {
|
||||
return minRequestInterval != null && !minRequestInterval.isNegative()
|
||||
? minRequestInterval : Duration.ofSeconds(5);
|
||||
}
|
||||
private static boolean positive(Duration value) {
|
||||
return value != null && !value.isNegative() && !value.isZero();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean hasText(String value) {
|
||||
return value != null && !value.isBlank();
|
||||
}
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package com.baiyue.integration.domain;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
public enum CarrierScheduleExceptionCode {
|
||||
CUSTOMS_HOLD,
|
||||
TERMINAL_HOLD,
|
||||
DOCUMENT_HOLD,
|
||||
UNKNOWN_HOLD;
|
||||
|
||||
public static boolean supports(String value) {
|
||||
try { valueOf(value == null ? "" : value.trim().toUpperCase(Locale.ROOT)); return true; }
|
||||
catch (IllegalArgumentException exception) { return false; }
|
||||
}
|
||||
}
|
||||
+6
-2
@@ -5,10 +5,14 @@ import java.util.Locale;
|
||||
public enum CarrierScheduleStandardNode {
|
||||
DEPARTED_WAREHOUSE,
|
||||
AT_ORIGIN_PORT,
|
||||
LOADED_ON_VESSEL,
|
||||
DEPARTED_ORIGIN_PORT,
|
||||
IN_TRANSIT,
|
||||
ARRIVED,
|
||||
PICKUP_READY;
|
||||
ARRIVED_DESTINATION_PORT,
|
||||
DISCHARGED_FROM_VESSEL,
|
||||
PICKUP_READY,
|
||||
PICKED_UP,
|
||||
EMPTY_RETURNED;
|
||||
|
||||
public static boolean supports(String value) {
|
||||
try { valueOf(value == null ? "" : value.trim().toUpperCase(Locale.ROOT)); return true; }
|
||||
|
||||
@@ -14,10 +14,16 @@ public final class CarrierScheduleDtos {
|
||||
@NotBlank @Size(max = 128) String trackingNo) { }
|
||||
|
||||
public record QueryResult(String providerCode, String outcome, List<Observation> observations,
|
||||
List<ExceptionObservation> exceptions,
|
||||
String errorCode, String errorMessage, LocalDateTime providerObservedAt) { }
|
||||
|
||||
public record Observation(String providerEventId, String providerNodeCode, String standardNode,
|
||||
LocalDateTime observedAt, LocalDateTime estimatedAt, LocalDateTime actualAt,
|
||||
String locationCode, String locationName, String sourceReference,
|
||||
String rawPayloadHash) { }
|
||||
|
||||
public record ExceptionObservation(String providerEventId, String providerStatus,
|
||||
String exceptionCode, boolean active, LocalDateTime observedAt,
|
||||
String locationCode, String locationName, String sourceReference,
|
||||
String rawPayloadHash) { }
|
||||
}
|
||||
|
||||
+5
-2
@@ -4,6 +4,8 @@ import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest;
|
||||
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryResult;
|
||||
import com.baiyue.integration.dto.ProviderStatus;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.Order;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
@@ -15,6 +17,7 @@ import java.util.Set;
|
||||
* an explicit provider-unavailable outcome until a documented provider API adapter is configured.
|
||||
*/
|
||||
@Component
|
||||
@Order(Ordered.LOWEST_PRECEDENCE)
|
||||
public class CompliantCarrierScheduleProviderAdapter implements CarrierScheduleProviderAdapter, IntegrationProviderAdapter {
|
||||
private static final Set<String> CARRIERS = Set.of("MATSON", "SML", "ZIM", "COSCO", "EMC", "HEDE");
|
||||
|
||||
@@ -25,11 +28,11 @@ public class CompliantCarrierScheduleProviderAdapter implements CarrierScheduleP
|
||||
|
||||
@Override
|
||||
public QueryResult query(QueryRequest request) {
|
||||
return new QueryResult("COMPLIANT_MANUAL_FALLBACK", "MANUAL_REQUIRED", List.of(),
|
||||
return new QueryResult("COMPLIANT_MANUAL_FALLBACK", "MANUAL_REQUIRED", List.of(), List.of(),
|
||||
"PROVIDER_API_NOT_CONFIGURED", "未配置可审计的结构化轨迹供应商接口,不执行网页抓取",
|
||||
LocalDateTime.now());
|
||||
}
|
||||
|
||||
@Override
|
||||
public ProviderStatus status() { return ProviderStatus.of("CARRIER_SCHEDULE", true, false); }
|
||||
public ProviderStatus status() { return ProviderStatus.of("CARRIER_SCHEDULE_FALLBACK", true, false); }
|
||||
}
|
||||
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
package com.baiyue.integration.provider;
|
||||
|
||||
import com.baiyue.integration.dto.CarrierScheduleDtos.Observation;
|
||||
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest;
|
||||
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryResult;
|
||||
import com.baiyue.integration.dto.ProviderStatus;
|
||||
import com.baiyue.integration.provider.EvergreenTrackingClient.EvergreenTrackingException;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.util.HtmlUtils;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@Component
|
||||
@Order(8)
|
||||
public class EvergreenCarrierScheduleProviderAdapter implements CarrierScheduleProviderAdapter, IntegrationProviderAdapter {
|
||||
private static final String PROVIDER = "EVERGREEN_PUBLIC_HTML";
|
||||
private static final String LOADED = "LOADED (FCL) ON VESSEL";
|
||||
private static final DateTimeFormatter DAY = DateTimeFormatter.ofPattern("MMM-dd-uuuu", Locale.ENGLISH);
|
||||
private static final Pattern TABLE = Pattern.compile("(?is)<table\\b[^>]*>(.*?)</table>");
|
||||
private static final Pattern ROW = Pattern.compile("(?is)<tr\\b[^>]*>(.*?)</tr>");
|
||||
private static final Pattern CELL = Pattern.compile("(?is)<t[hd]\\b[^>]*>(.*?)</t[hd]>");
|
||||
private static final Pattern COUNTRY_CODE = Pattern.compile("\\(([A-Z]{2})\\)\\s*$");
|
||||
private static final Set<String> REQUIRED_HEADERS = Set.of("箱号", "柜型", "日期", "货柜动态", "地点", "船名航次", "METHOD", "VGM");
|
||||
private final EvergreenTrackingClient client;
|
||||
|
||||
public EvergreenCarrierScheduleProviderAdapter(EvergreenTrackingClient client) { this.client = client; }
|
||||
|
||||
@Override
|
||||
public boolean supports(String carrierCode) {
|
||||
return client.configured() && carrierCode != null && "EMC".equals(carrierCode.trim().toUpperCase(Locale.ROOT));
|
||||
}
|
||||
|
||||
@Override
|
||||
public QueryResult query(QueryRequest request) {
|
||||
LocalDateTime queriedAt = LocalDateTime.now(ZoneOffset.UTC);
|
||||
try {
|
||||
EvergreenTrackingClient.TrackingResponse response = client.query(request);
|
||||
List<Observation> observations = observations(request.trackingNo(), response, queriedAt);
|
||||
return new QueryResult(PROVIDER, observations.isEmpty() ? "NO_CHANGE" : "SUCCESS", observations,
|
||||
List.of(), null, null, queriedAt);
|
||||
} catch (EvergreenTrackingException exception) {
|
||||
return new QueryResult(PROVIDER, exception.outcome(), List.of(), List.of(), exception.code(),
|
||||
exception.getMessage(), queriedAt);
|
||||
} catch (EvergreenParseException exception) {
|
||||
return new QueryResult(PROVIDER, "MANUAL_REQUIRED", List.of(), List.of(), exception.code,
|
||||
exception.getMessage(), queriedAt);
|
||||
}
|
||||
}
|
||||
|
||||
List<Observation> observations(String containerNo, EvergreenTrackingClient.TrackingResponse response,
|
||||
LocalDateTime queriedAt) {
|
||||
if (response == null) return List.of();
|
||||
String container = normalize(containerNo);
|
||||
TrackingRow row = trackingRow(response.containerHtml(), container);
|
||||
if (row == null) throw new EvergreenParseException("EMC_CONTAINER_ROW_NOT_FOUND", "长荣 HTML 未找到目标柜行");
|
||||
String rawHash = sha256(response.rawBody() == null
|
||||
? safe(response.containerHtml()) + safe(response.billHtml()) : response.rawBody());
|
||||
List<Observation> facts = new ArrayList<>();
|
||||
if (LOADED.equals(normalizeText(row.status()))) {
|
||||
LocalDate date = parseDay(row.date());
|
||||
if (date == null) throw new EvergreenParseException("EMC_DAY_PARSE_FAILED", "长荣货柜动态日期无法解析");
|
||||
add(facts, container, "LOADED_FCL_ON_VESSEL", "LOADED_ON_VESSEL", queriedAt, date,
|
||||
false, row.location(), row.vesselVoyage(), response.billSuffix(), rawHash);
|
||||
}
|
||||
String estimatedRaw = estimatedLoadingDate(response.billHtml());
|
||||
LocalDate estimated = parseDay(estimatedRaw);
|
||||
if (estimated != null) {
|
||||
add(facts, container, "ESTIMATED_LOADING_DATE", "LOADED_ON_VESSEL", queriedAt, estimated,
|
||||
true, row.location(), row.vesselVoyage(), response.billSuffix(), rawHash);
|
||||
}
|
||||
facts.sort(Comparator.comparing(value -> value.actualAt() == null ? value.estimatedAt() : value.actualAt()));
|
||||
return List.copyOf(facts);
|
||||
}
|
||||
|
||||
static String standardNode(String status) {
|
||||
return LOADED.equals(normalizeText(status)) ? "LOADED_ON_VESSEL" : null;
|
||||
}
|
||||
|
||||
private static void add(List<Observation> facts, String container, String providerNode, String standardNode,
|
||||
LocalDateTime queriedAt, LocalDate day, boolean estimated, String location,
|
||||
String vesselVoyage, String billSuffix, String rawHash) {
|
||||
LocalDateTime eventAt = day.atStartOfDay();
|
||||
String rawDate = day.format(DAY).toUpperCase(Locale.ENGLISH);
|
||||
String reference = "EMC:CNTR:" + container + "|BL_SUFFIX:" + safe(billSuffix)
|
||||
+ "|DATE:" + rawDate + "|PRECISION:DAY|ZONE:Asia/Shanghai";
|
||||
String eventId = sha256(container + '|' + providerNode + '|' + rawDate + '|'
|
||||
+ normalizeText(location) + '|' + normalizeText(vesselVoyage));
|
||||
facts.add(new Observation(eventId, providerNode, standardNode, queriedAt,
|
||||
estimated ? eventAt : null, estimated ? null : eventAt, countryCode(location), blank(location),
|
||||
reference, rawHash));
|
||||
}
|
||||
|
||||
static TrackingRow trackingRow(String html, String targetContainer) {
|
||||
Matcher tables = TABLE.matcher(html == null ? "" : html);
|
||||
while (tables.find()) {
|
||||
List<List<String>> rows = rows(tables.group(1));
|
||||
for (int headerIndex = 0; headerIndex < rows.size(); headerIndex++) {
|
||||
Map<String, Integer> header = header(rows.get(headerIndex));
|
||||
if (!header.keySet().containsAll(REQUIRED_HEADERS)) continue;
|
||||
for (int index = headerIndex + 1; index < rows.size(); index++) {
|
||||
List<String> cells = rows.get(index);
|
||||
String container = cell(cells, header.get("箱号"));
|
||||
if (normalize(container).equals(targetContainer)) {
|
||||
return new TrackingRow(container, cell(cells, header.get("日期")),
|
||||
cell(cells, header.get("货柜动态")), cell(cells, header.get("地点")),
|
||||
cell(cells, header.get("船名航次")));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static String estimatedLoadingDate(String html) {
|
||||
for (List<String> row : allRows(html)) {
|
||||
for (int index = 0; index + 1 < row.size(); index++) {
|
||||
String label = row.get(index).replaceAll("\\s+", "");
|
||||
if ("预计装船日".equals(label) || "預計裝船日".equals(label)) return row.get(index + 1).trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static List<List<String>> allRows(String html) {
|
||||
List<List<String>> values = new ArrayList<>(); Matcher tables = TABLE.matcher(html == null ? "" : html);
|
||||
while (tables.find()) values.addAll(rows(tables.group(1))); return values;
|
||||
}
|
||||
private static List<List<String>> rows(String table) {
|
||||
List<List<String>> values = new ArrayList<>(); Matcher rows = ROW.matcher(table);
|
||||
while (rows.find()) {
|
||||
List<String> cells = new ArrayList<>(); Matcher cell = CELL.matcher(rows.group(1));
|
||||
while (cell.find()) cells.add(htmlText(cell.group(1))); if (!cells.isEmpty()) values.add(List.copyOf(cells));
|
||||
}
|
||||
return values;
|
||||
}
|
||||
private static Map<String, Integer> header(List<String> cells) {
|
||||
Map<String, Integer> values = new HashMap<>();
|
||||
for (int index = 0; index < cells.size(); index++) values.put(normalizeHeader(cells.get(index)), index);
|
||||
return values;
|
||||
}
|
||||
private static String normalizeHeader(String value) {
|
||||
String text = value == null ? "" : value.replaceAll("\\s+", "").trim();
|
||||
return text.chars().allMatch(ch -> ch < 128) ? text.toUpperCase(Locale.ROOT) : text;
|
||||
}
|
||||
private static String cell(List<String> cells, Integer index) {
|
||||
return index == null || index < 0 || index >= cells.size() ? "" : cells.get(index).trim();
|
||||
}
|
||||
private static String htmlText(String value) {
|
||||
return HtmlUtils.htmlUnescape(value.replaceAll("(?is)<[^>]+>", " ")).replace('\u00a0', ' ')
|
||||
.replaceAll("\\s+", " ").trim();
|
||||
}
|
||||
private static LocalDate parseDay(String value) {
|
||||
if (value == null || value.isBlank()) return null;
|
||||
try { return LocalDate.parse(value.trim(), DAY); }
|
||||
catch (DateTimeParseException exception) { return null; }
|
||||
}
|
||||
private static String countryCode(String location) {
|
||||
Matcher matcher = COUNTRY_CODE.matcher(location == null ? "" : location.trim().toUpperCase(Locale.ROOT));
|
||||
return matcher.find() ? matcher.group(1) : null;
|
||||
}
|
||||
private static String normalizeText(String value) {
|
||||
return value == null ? "" : HtmlUtils.htmlUnescape(value).trim().replaceAll("\\s+", " ").toUpperCase(Locale.ROOT);
|
||||
}
|
||||
private static String normalize(String value) {
|
||||
return value == null ? "" : value.replaceAll("[^A-Za-z0-9]", "").toUpperCase(Locale.ROOT);
|
||||
}
|
||||
private static String blank(String value) { return value == null || value.isBlank() ? null : value.trim(); }
|
||||
private static String safe(String value) { return value == null ? "" : value; }
|
||||
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); }
|
||||
}
|
||||
|
||||
record TrackingRow(String containerNo, String date, String status, String location, String vesselVoyage) { }
|
||||
private static final class EvergreenParseException extends RuntimeException {
|
||||
private final String code;
|
||||
private EvergreenParseException(String code, String message) { super(message); this.code = code; }
|
||||
}
|
||||
|
||||
@Override
|
||||
public ProviderStatus status() {
|
||||
return ProviderStatus.of("CARRIER_SCHEDULE_EMC", client.configured(), client.configured());
|
||||
}
|
||||
}
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
package com.baiyue.integration.provider;
|
||||
|
||||
import com.baiyue.integration.config.IntegrationProperties;
|
||||
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.util.HtmlUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.net.http.HttpTimeoutException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.Locale;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@Component
|
||||
public class EvergreenTrackingClient {
|
||||
private static final Pattern INPUT = Pattern.compile("(?is)<input\\b[^>]*>");
|
||||
private static final Pattern ATTRIBUTE = Pattern.compile("(?i)\\b([a-z_:][-a-z0-9_:.]*)\\s*=\\s*([\"'])(.*?)\\2");
|
||||
private static final Pattern DISP_INFO = Pattern.compile("(?is)GetDispInfo[^0-9]{0,160}(\\d{12})");
|
||||
private static final Pattern BL_QUERY = Pattern.compile("(?i)(?:[?&]|&)(?:BL|bl_no)=(\\d{12})");
|
||||
private final Object requestLock = new Object();
|
||||
@Resource private IntegrationProperties properties;
|
||||
private IntegrationProperties.CarrierSchedule.Evergreen config;
|
||||
private HttpClient httpClient;
|
||||
private long nextRequestAtMillis;
|
||||
|
||||
@PostConstruct
|
||||
void initialize() {
|
||||
config = properties.carrierSchedule() == null ? null : properties.carrierSchedule().evergreen();
|
||||
httpClient = HttpClient.newBuilder()
|
||||
.connectTimeout(config == null ? Duration.ofSeconds(10) : config.effectiveConnectTimeout())
|
||||
.followRedirects(HttpClient.Redirect.NORMAL).build();
|
||||
}
|
||||
|
||||
public boolean configured() { return config != null && config.configured(); }
|
||||
|
||||
public TrackingResponse query(QueryRequest request) {
|
||||
if (!configured()) manual("EMC_NOT_CONFIGURED", "长荣公开货柜动态查询未启用");
|
||||
if (!"CONTAINER_NO".equalsIgnoreCase(request.trackingType())) {
|
||||
manual("EMC_CONTAINER_REQUIRED", "长荣首批自动节点仅支持柜号查询");
|
||||
}
|
||||
synchronized (requestLock) {
|
||||
waitForSlot();
|
||||
try {
|
||||
String container = normalize(request.trackingNo());
|
||||
String containerHtml = post("TYPE=CNTR&CNTR=" + encode(container), "CONTAINER");
|
||||
String bill = relatedBill(containerHtml);
|
||||
if (bill == null) manual("EMC_RELATED_BL_MISSING", "长荣柜号结果缺少关联提单,需人工复核");
|
||||
String billHtml = post("TYPE=BL&BL=" + encode(bill), "BILL");
|
||||
return new TrackingResponse(containerHtml, billHtml, bill.substring(bill.length() - 4),
|
||||
containerHtml + billHtml);
|
||||
} finally {
|
||||
nextRequestAtMillis = System.currentTimeMillis() + config.effectiveMinRequestInterval().toMillis();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String post(String form, String stage) {
|
||||
try {
|
||||
HttpResponse<String> response = httpClient.send(HttpRequest.newBuilder(config.queryUrl())
|
||||
.timeout(config.effectiveRequestTimeout()).header("Accept", "text/html")
|
||||
.header("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(form, StandardCharsets.UTF_8)).build(),
|
||||
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
if (response.statusCode() == 429 || response.statusCode() >= 500) {
|
||||
throw retryable("EMC_" + stage + "_HTTP_" + response.statusCode(), "长荣公开查询暂时不可用", null);
|
||||
}
|
||||
if (response.statusCode() < 200 || response.statusCode() >= 300) {
|
||||
manual("EMC_" + stage + "_HTTP_" + response.statusCode(), "长荣公开查询拒绝请求");
|
||||
}
|
||||
if (!looksLikeHtml(response.body())) manual("EMC_" + stage + "_NON_HTML", "长荣返回非 HTML 内容");
|
||||
return response.body();
|
||||
} catch (EvergreenTrackingException exception) {
|
||||
throw exception;
|
||||
} catch (HttpTimeoutException exception) {
|
||||
throw retryable("EMC_" + stage + "_TIMEOUT", "长荣公开查询超时", exception);
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw retryable("EMC_" + stage + "_INTERRUPTED", "长荣公开查询被中断", exception);
|
||||
} catch (IOException exception) {
|
||||
throw retryable("EMC_" + stage + "_IO_FAILED", "长荣公开查询网络失败", exception);
|
||||
}
|
||||
}
|
||||
|
||||
static String relatedBill(String html) {
|
||||
Matcher inputs = INPUT.matcher(html == null ? "" : html);
|
||||
while (inputs.find()) {
|
||||
String name = attribute(inputs.group(), "name");
|
||||
String value = HtmlUtils.htmlUnescape(attribute(inputs.group(), "value"));
|
||||
if ("bl_no".equalsIgnoreCase(name) && value.matches("\\d{12}")) return value;
|
||||
}
|
||||
String decoded = HtmlUtils.htmlUnescape(html == null ? "" : html);
|
||||
Matcher action = DISP_INFO.matcher(decoded);
|
||||
if (action.find()) return action.group(1);
|
||||
Matcher query = BL_QUERY.matcher(decoded);
|
||||
return query.find() ? query.group(1) : null;
|
||||
}
|
||||
|
||||
private static String attribute(String tag, String expected) {
|
||||
Matcher attributes = ATTRIBUTE.matcher(tag);
|
||||
while (attributes.find()) if (expected.equalsIgnoreCase(attributes.group(1))) return attributes.group(3).trim();
|
||||
return "";
|
||||
}
|
||||
private static boolean looksLikeHtml(String value) {
|
||||
String lower = value == null ? "" : value.toLowerCase(Locale.ROOT);
|
||||
return lower.contains("<html") || lower.contains("<table") || lower.contains("<input");
|
||||
}
|
||||
private void waitForSlot() {
|
||||
long wait = nextRequestAtMillis - System.currentTimeMillis();
|
||||
if (wait <= 0) return;
|
||||
try { Thread.sleep(wait); }
|
||||
catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw retryable("EMC_INTERRUPTED", "等待长荣查询限流窗口时被中断", exception);
|
||||
}
|
||||
}
|
||||
private static void manual(String code, String message) {
|
||||
throw new EvergreenTrackingException("MANUAL_REQUIRED", code, message);
|
||||
}
|
||||
private static EvergreenTrackingException retryable(String code, String message, Throwable cause) {
|
||||
return new EvergreenTrackingException("RETRYABLE_FAILURE", code, message, cause);
|
||||
}
|
||||
private static String normalize(String value) {
|
||||
return value == null ? "" : value.replaceAll("[^A-Za-z0-9]", "").toUpperCase(Locale.ROOT);
|
||||
}
|
||||
private static String encode(String value) { return URLEncoder.encode(value, StandardCharsets.UTF_8); }
|
||||
|
||||
public record TrackingResponse(String containerHtml, String billHtml, String billSuffix, String rawBody) {
|
||||
@Override public String toString() {
|
||||
return "TrackingResponse[billSuffix=" + billSuffix + ",html=<redacted>]";
|
||||
}
|
||||
}
|
||||
public static final class EvergreenTrackingException extends RuntimeException {
|
||||
private final String outcome; private final String code;
|
||||
public EvergreenTrackingException(String outcome, String code, String message) {
|
||||
super(message); this.outcome = outcome; this.code = code;
|
||||
}
|
||||
public EvergreenTrackingException(String outcome, String code, String message, Throwable cause) {
|
||||
super(message, cause); this.outcome = outcome; this.code = code;
|
||||
}
|
||||
public String outcome() { return outcome; }
|
||||
public String code() { return code; }
|
||||
}
|
||||
}
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
package com.baiyue.integration.provider;
|
||||
|
||||
import com.baiyue.integration.dto.CarrierScheduleDtos.Observation;
|
||||
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest;
|
||||
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryResult;
|
||||
import com.baiyue.integration.dto.ProviderStatus;
|
||||
import com.baiyue.integration.provider.HedeTrackingClient.HedeTrackingException;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@Component
|
||||
@Order(10)
|
||||
public class HedeCarrierScheduleProviderAdapter implements CarrierScheduleProviderAdapter, IntegrationProviderAdapter {
|
||||
private static final String PROVIDER = "HEDE_PUBLIC_HTML";
|
||||
private static final DateTimeFormatter DATE_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
|
||||
private static final Pattern ROW_PATTERN = Pattern.compile(
|
||||
"<tr[^>]*class=[\\\"'][^\\\"']*read-tr[^\\\"']*[\\\"'][^>]*>([\\s\\S]*?)</tr>",
|
||||
Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern CELL_PATTERN = Pattern.compile("<td[^>]*>([\\s\\S]*?)</td>",
|
||||
Pattern.CASE_INSENSITIVE);
|
||||
private final HedeTrackingClient client;
|
||||
|
||||
public HedeCarrierScheduleProviderAdapter(HedeTrackingClient client) { this.client = client; }
|
||||
|
||||
@Override
|
||||
public boolean supports(String carrierCode) {
|
||||
return client.configured() && carrierCode != null && "HEDE".equals(carrierCode.trim().toUpperCase(Locale.ROOT));
|
||||
}
|
||||
|
||||
@Override
|
||||
public QueryResult query(QueryRequest request) {
|
||||
LocalDateTime queriedAt = LocalDateTime.now(ZoneOffset.UTC);
|
||||
if (!"CONTAINER_NO".equalsIgnoreCase(request.trackingType())) {
|
||||
return new QueryResult(PROVIDER, "PERMANENT_FAILURE", List.of(), List.of(), "HEDE_CONTAINER_REQUIRED",
|
||||
"合德自动节点当前仅支持按柜号精确解析,提单可能返回多个柜子", queriedAt);
|
||||
}
|
||||
try {
|
||||
HedeTrackingClient.TrackingResponse response = client.query(request);
|
||||
List<Observation> observations = observations(request.trackingNo(), response.html(), queriedAt);
|
||||
return new QueryResult(PROVIDER, observations.isEmpty() ? "NO_CHANGE" : "SUCCESS", observations, List.of(),
|
||||
null, null, queriedAt);
|
||||
} catch (HedeTrackingException exception) {
|
||||
return new QueryResult(PROVIDER, exception.retryable() ? "RETRYABLE_FAILURE" : "PERMANENT_FAILURE",
|
||||
List.of(), List.of(), exception.code(), exception.getMessage(), queriedAt);
|
||||
} catch (Exception exception) {
|
||||
return new QueryResult(PROVIDER, "PERMANENT_FAILURE", List.of(), List.of(), "HEDE_HTML_SCHEMA_CHANGED",
|
||||
"合德查询 HTML 结构无法解析", queriedAt);
|
||||
}
|
||||
}
|
||||
|
||||
List<Observation> observations(String containerNo, String html) throws Exception {
|
||||
return observations(containerNo, html, LocalDateTime.now(ZoneOffset.UTC));
|
||||
}
|
||||
|
||||
private List<Observation> observations(String containerNo, String html, LocalDateTime queriedAt) throws Exception {
|
||||
String normalizedContainer = normalizeIdentifier(containerNo);
|
||||
List<HedeRow> matched = rows(html).stream()
|
||||
.filter(value -> normalizeIdentifier(value.containerNo()).equals(normalizedContainer)).toList();
|
||||
if (matched.isEmpty()) return List.of();
|
||||
String rawHash = sha256(html);
|
||||
List<Observation> values = new ArrayList<>();
|
||||
for (HedeRow row : matched) {
|
||||
String reference = "HEDE:" + row.containerNo() + ':' + row.vessel() + '/' + row.voyage();
|
||||
addEstimated(values, row, "ESTIMATED_DEPARTURE", "DEPARTED_ORIGIN_PORT",
|
||||
row.estimatedDeparture(), queriedAt, reference, rawHash);
|
||||
addEstimated(values, row, "ESTIMATED_ARRIVAL", "ARRIVED_DESTINATION_PORT",
|
||||
row.estimatedArrival(), queriedAt, reference, rawHash);
|
||||
addActual(values, row, "FULL_GATE_IN", "AT_ORIGIN_PORT", row.fullGateIn(), queriedAt, reference, rawHash);
|
||||
addActual(values, row, "LOADED", "LOADED_ON_VESSEL", row.loadedAt(), queriedAt, reference, rawHash);
|
||||
addActual(values, row, "DISCHARGED", "DISCHARGED_FROM_VESSEL", row.dischargedAt(), queriedAt, reference, rawHash);
|
||||
addActual(values, row, "FULL_GATE_OUT", "PICKED_UP", row.fullGateOut(), queriedAt, reference, rawHash);
|
||||
addActual(values, row, "EMPTY_RETURNED", "EMPTY_RETURNED", row.emptyReturnedAt(), queriedAt, reference, rawHash);
|
||||
}
|
||||
values.sort(Comparator.comparing(Observation::observedAt));
|
||||
return List.copyOf(values);
|
||||
}
|
||||
|
||||
private static void addActual(List<Observation> values, HedeRow row, String providerNode,
|
||||
String standardNode, String timestamp, LocalDateTime queriedAt,
|
||||
String reference, String rawHash) {
|
||||
LocalDateTime actualAt = parseDate(timestamp);
|
||||
if (actualAt == null) return;
|
||||
String eventIdentity = row.containerNo() + '|' + providerNode + '|' + timestamp;
|
||||
values.add(new Observation(sha256(eventIdentity), providerNode, standardNode, queriedAt,
|
||||
null, actualAt, null, null, reference, rawHash));
|
||||
}
|
||||
|
||||
private static void addEstimated(List<Observation> values, HedeRow row, String providerNode,
|
||||
String standardNode, String timestamp, LocalDateTime queriedAt,
|
||||
String reference, String rawHash) {
|
||||
LocalDateTime estimatedAt = parseDate(timestamp);
|
||||
if (estimatedAt == null) return;
|
||||
String eventIdentity = row.containerNo() + '|' + providerNode + '|' + timestamp;
|
||||
values.add(new Observation(sha256(eventIdentity), providerNode, standardNode, queriedAt,
|
||||
estimatedAt, null, null, null, reference, rawHash));
|
||||
}
|
||||
|
||||
static List<HedeRow> rows(String html) {
|
||||
List<HedeRow> rows = new ArrayList<>();
|
||||
Matcher rowMatcher = ROW_PATTERN.matcher(html == null ? "" : html);
|
||||
while (rowMatcher.find()) {
|
||||
List<String> cells = new ArrayList<>();
|
||||
Matcher cellMatcher = CELL_PATTERN.matcher(rowMatcher.group(1));
|
||||
while (cellMatcher.find()) cells.add(htmlText(cellMatcher.group(1)));
|
||||
if (cells.size() >= 12) rows.add(row(cells));
|
||||
}
|
||||
return List.copyOf(rows);
|
||||
}
|
||||
|
||||
private static String htmlText(String value) {
|
||||
return value.replaceAll("(?is)<[^>]+>", " ")
|
||||
.replace(" ", " ").replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
.replace(""", "\"").replace("'", "'").trim().replaceAll("\\s+", " ");
|
||||
}
|
||||
|
||||
private static HedeRow row(List<String> cells) {
|
||||
return new HedeRow(cells.get(0), cells.get(1), cells.get(2), cells.get(3), cells.get(4), cells.get(5),
|
||||
cells.get(6), cells.get(7), cells.get(8), cells.get(9), cells.get(10), cells.get(11));
|
||||
}
|
||||
|
||||
private static LocalDateTime parseDate(String value) {
|
||||
if (value == null || value.isBlank()) return null;
|
||||
try { return LocalDateTime.parse(value.trim(), DATE_TIME); }
|
||||
catch (DateTimeParseException exception) { return null; }
|
||||
}
|
||||
|
||||
private static String normalizeIdentifier(String value) {
|
||||
return value == null ? "" : value.replaceAll("[^A-Za-z0-9]", "").toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
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); }
|
||||
}
|
||||
|
||||
@Override
|
||||
public ProviderStatus status() {
|
||||
return ProviderStatus.of("CARRIER_SCHEDULE_HEDE", client.configured(), client.configured());
|
||||
}
|
||||
|
||||
record HedeRow(String billNo, String containerNo, String vessel, String voyage,
|
||||
String estimatedDeparture, String estimatedArrival, String emptyGateOut,
|
||||
String fullGateIn, String loadedAt, String dischargedAt,
|
||||
String fullGateOut, String emptyReturnedAt) { }
|
||||
}
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
package com.baiyue.integration.provider;
|
||||
|
||||
import com.baiyue.integration.config.IntegrationProperties;
|
||||
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Locale;
|
||||
|
||||
@Component
|
||||
public class HedeTrackingClient {
|
||||
private static final String USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
|
||||
|
||||
@Resource private IntegrationProperties properties;
|
||||
private IntegrationProperties.CarrierSchedule.Hede config;
|
||||
private HttpClient httpClient;
|
||||
private final Object requestLock = new Object();
|
||||
private long nextRequestAtMillis;
|
||||
|
||||
@PostConstruct
|
||||
void initialize() {
|
||||
config = properties.carrierSchedule() == null ? null : properties.carrierSchedule().hede();
|
||||
httpClient = HttpClient.newBuilder()
|
||||
.connectTimeout(config == null ? java.time.Duration.ofSeconds(10) : config.effectiveConnectTimeout())
|
||||
.followRedirects(HttpClient.Redirect.NORMAL)
|
||||
.build();
|
||||
}
|
||||
|
||||
public boolean configured() { return config != null && config.configured(); }
|
||||
|
||||
public TrackingResponse query(QueryRequest request) {
|
||||
if (!configured()) {
|
||||
throw new HedeTrackingException("HEDE_NOT_CONFIGURED", false, "合德网页查询能力未启用或地址未配置");
|
||||
}
|
||||
synchronized (requestLock) {
|
||||
waitForRequestSlot();
|
||||
try {
|
||||
return execute(request);
|
||||
} finally {
|
||||
nextRequestAtMillis = System.currentTimeMillis()
|
||||
+ config.effectiveMinRequestInterval().toMillis();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private TrackingResponse execute(QueryRequest request) {
|
||||
String trackingType = request.trackingType().trim().toUpperCase(Locale.ROOT);
|
||||
String billNo = "BILL_OF_LADING".equals(trackingType) ? request.trackingNo().trim() : "";
|
||||
String containerNo = "CONTAINER_NO".equals(trackingType) ? request.trackingNo().trim() : "";
|
||||
if (billNo.isEmpty() && containerNo.isEmpty()) {
|
||||
throw new HedeTrackingException("HEDE_TRACKING_TYPE_UNSUPPORTED", false,
|
||||
"合德仅支持提单号或柜号查询");
|
||||
}
|
||||
String form = "billno=" + encode(billNo) + "&cntr=" + encode(containerNo)
|
||||
+ "&selecttype=1&resultlist=1¶mtype=0";
|
||||
try {
|
||||
HttpResponse<String> response = httpClient.send(HttpRequest.newBuilder(config.queryUrl())
|
||||
.timeout(config.effectiveRequestTimeout())
|
||||
.header("User-Agent", USER_AGENT)
|
||||
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
||||
.header("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(form, StandardCharsets.UTF_8)).build(),
|
||||
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
ensureHttp(response.statusCode());
|
||||
return new TrackingResponse(response.body());
|
||||
} catch (HedeTrackingException exception) {
|
||||
throw exception;
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new HedeTrackingException("HEDE_INTERRUPTED", true, "合德查询被中断", exception);
|
||||
} catch (IOException exception) {
|
||||
throw new HedeTrackingException("HEDE_IO_FAILED", true, "合德查询网络失败", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void waitForRequestSlot() {
|
||||
long waitMillis = nextRequestAtMillis - System.currentTimeMillis();
|
||||
if (waitMillis <= 0) return;
|
||||
try {
|
||||
Thread.sleep(waitMillis);
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new HedeTrackingException("HEDE_INTERRUPTED", true, "等待合德查询限流窗口时被中断", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ensureHttp(int status) {
|
||||
if (status >= 200 && status < 300) return;
|
||||
if (status == 429) {
|
||||
throw new HedeTrackingException("HEDE_RATE_LIMITED", true, "合德查询触发访问频率限制");
|
||||
}
|
||||
throw new HedeTrackingException("HEDE_HTTP_" + status, status >= 500,
|
||||
"合德查询返回异常 HTTP 状态:" + status);
|
||||
}
|
||||
|
||||
private static String encode(String value) { return URLEncoder.encode(value, StandardCharsets.UTF_8); }
|
||||
|
||||
public record TrackingResponse(String html) { }
|
||||
|
||||
public static final class HedeTrackingException extends RuntimeException {
|
||||
private final String code;
|
||||
private final boolean retryable;
|
||||
|
||||
public HedeTrackingException(String code, boolean retryable, String message) {
|
||||
super(message); this.code = code; this.retryable = retryable;
|
||||
}
|
||||
|
||||
public HedeTrackingException(String code, boolean retryable, String message, Throwable cause) {
|
||||
super(message, cause); this.code = code; this.retryable = retryable;
|
||||
}
|
||||
|
||||
public String code() { return code; }
|
||||
public boolean retryable() { return retryable; }
|
||||
}
|
||||
}
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
package com.baiyue.integration.provider;
|
||||
|
||||
import com.baiyue.integration.dto.CarrierScheduleDtos.Observation;
|
||||
import com.baiyue.integration.dto.CarrierScheduleDtos.ExceptionObservation;
|
||||
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest;
|
||||
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryResult;
|
||||
import com.baiyue.integration.dto.ProviderStatus;
|
||||
import com.baiyue.integration.provider.MatsonTrackingClient.MatsonTrackingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
@Component
|
||||
@Order(0)
|
||||
public class MatsonCarrierScheduleProviderAdapter implements CarrierScheduleProviderAdapter, IntegrationProviderAdapter {
|
||||
private static final String PROVIDER = "MATSON_PUBLIC_TRACKING";
|
||||
private final MatsonTrackingClient client;
|
||||
|
||||
public MatsonCarrierScheduleProviderAdapter(MatsonTrackingClient client) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supports(String carrierCode) {
|
||||
return client.configured() && carrierCode != null && "MATSON".equals(carrierCode.trim().toUpperCase(Locale.ROOT));
|
||||
}
|
||||
|
||||
@Override
|
||||
public QueryResult query(QueryRequest request) {
|
||||
try {
|
||||
MatsonTrackingClient.TrackingResponse response = client.query(request);
|
||||
LocalDateTime queriedAt = LocalDateTime.now(ZoneOffset.UTC);
|
||||
List<Observation> observations = observations(request, response.payload(), response.rawBody(), queriedAt);
|
||||
List<ExceptionObservation> exceptions = exceptions(request, response.payload(), response.rawBody(), queriedAt);
|
||||
return new QueryResult(PROVIDER, observations.isEmpty() && exceptions.isEmpty() ? "NO_CHANGE" : "SUCCESS",
|
||||
observations, exceptions,
|
||||
null, null, queriedAt);
|
||||
} catch (MatsonTrackingException exception) {
|
||||
return new QueryResult(PROVIDER, exception.retryable() ? "RETRYABLE_FAILURE" : "PERMANENT_FAILURE",
|
||||
List.of(), List.of(), exception.code(), exception.getMessage(), LocalDateTime.now(ZoneOffset.UTC));
|
||||
}
|
||||
}
|
||||
|
||||
List<Observation> observations(QueryRequest request, JsonNode payload, String rawBody,
|
||||
LocalDateTime queriedAt) {
|
||||
if (payload == null || !payload.isObject()) return List.of();
|
||||
if ("CONTAINER_NO".equalsIgnoreCase(request.trackingType()) && !matchesContainer(request.trackingNo(), payload)) {
|
||||
return List.of();
|
||||
}
|
||||
String rawHash = sha256(rawBody == null ? payload.toString() : rawBody);
|
||||
String sourceReference = "MATSON:" + text(payload, "bookingNumber", request.trackingNo());
|
||||
List<Observation> values = new ArrayList<>();
|
||||
JsonNode events = payload.path("events");
|
||||
if (events.isArray()) for (JsonNode event : events) {
|
||||
long epochMillis = event.path("date").asLong(0);
|
||||
String rawStatus = text(event, "status", "").trim();
|
||||
String standardNode = standardNode(rawStatus);
|
||||
if (epochMillis <= 0 || standardNode == null) continue;
|
||||
LocalDateTime actualAt = LocalDateTime.ofInstant(Instant.ofEpochMilli(epochMillis), ZoneOffset.UTC);
|
||||
String locationName = text(event, "location", "").trim();
|
||||
String locationCode = locationCode(payload, locationName, standardNode);
|
||||
String eventIdentity = request.trackingNo().trim().toUpperCase(Locale.ROOT) + '|' + epochMillis + '|'
|
||||
+ normalizeStatus(rawStatus) + '|' + locationName.toUpperCase(Locale.ROOT);
|
||||
values.add(new Observation(sha256(eventIdentity), truncate(normalizeStatus(rawStatus), 64), standardNode,
|
||||
queriedAt, null, actualAt, blankToNull(locationCode), blankToNull(locationName),
|
||||
sourceReference, rawHash));
|
||||
}
|
||||
LocalDateTime eta = parseDate(payload.get("vesselETA"));
|
||||
if (eta != null) {
|
||||
String eventIdentity = request.trackingNo().trim().toUpperCase(Locale.ROOT) + "|VESSEL_ETA|" + eta;
|
||||
values.add(new Observation(sha256(eventIdentity), "VESSEL_ETA", "ARRIVED_DESTINATION_PORT",
|
||||
queriedAt, eta, null, blankToNull(text(payload, "destCode", "")),
|
||||
blankToNull(text(payload, "destPort", "")), sourceReference, rawHash));
|
||||
}
|
||||
values.sort(Comparator.comparing(Observation::observedAt));
|
||||
return List.copyOf(values);
|
||||
}
|
||||
|
||||
List<ExceptionObservation> exceptions(QueryRequest request, JsonNode payload, String rawBody,
|
||||
LocalDateTime queriedAt) {
|
||||
if (payload == null || !payload.isObject()) return List.of();
|
||||
if ("CONTAINER_NO".equalsIgnoreCase(request.trackingType()) && !matchesContainer(request.trackingNo(), payload)) {
|
||||
return List.of();
|
||||
}
|
||||
String rawHash = sha256(rawBody == null ? payload.toString() : rawBody);
|
||||
String sourceReference = "MATSON:" + text(payload, "bookingNumber", request.trackingNo());
|
||||
List<ExceptionObservation> values = new ArrayList<>();
|
||||
JsonNode events = payload.path("events");
|
||||
if (!events.isArray()) return List.of();
|
||||
for (JsonNode event : events) {
|
||||
String rawStatus = text(event, "status", "").trim();
|
||||
String status = normalizeStatus(rawStatus);
|
||||
String exceptionCode = exceptionCode(status);
|
||||
long epochMillis = event.path("date").asLong(0);
|
||||
if (exceptionCode == null || epochMillis <= 0) continue;
|
||||
String locationName = text(event, "location", "").trim();
|
||||
String identity = request.trackingNo().trim().toUpperCase(Locale.ROOT) + '|' + epochMillis + '|'
|
||||
+ status + '|' + locationName.toUpperCase(Locale.ROOT);
|
||||
values.add(new ExceptionObservation(sha256(identity), truncate(rawStatus, 128), exceptionCode, true,
|
||||
queriedAt, blankToNull(locationCode(payload, locationName, "PICKUP_READY")),
|
||||
blankToNull(locationName), sourceReference, rawHash));
|
||||
}
|
||||
values.sort(Comparator.comparing(ExceptionObservation::observedAt));
|
||||
return List.copyOf(values);
|
||||
}
|
||||
|
||||
static String standardNode(String status) {
|
||||
String value = normalizeStatus(status);
|
||||
return switch (value) {
|
||||
case "INGATE" -> "AT_ORIGIN_PORT";
|
||||
case "LOAD TO VESSEL" -> "LOADED_ON_VESSEL";
|
||||
case "VESSEL DEPARTURE" -> "DEPARTED_ORIGIN_PORT";
|
||||
case "IN TRANSIT" -> "IN_TRANSIT";
|
||||
case "VESSEL ARRIVAL", "ARRIVED AT DESTINATION" -> "ARRIVED_DESTINATION_PORT";
|
||||
case "DISCHARGE FROM VESSEL" -> "DISCHARGED_FROM_VESSEL";
|
||||
case "AVAILABLE FOR PICKUP" -> "PICKUP_READY";
|
||||
case "OUTGATE" -> "PICKED_UP";
|
||||
case "EMPTY RETURN", "EMPTY RETURNED" -> "EMPTY_RETURNED";
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
static String exceptionCode(String status) {
|
||||
return "CONTAINER ON HOLD - CUSTOMS CLEARANCE".equals(normalizeStatus(status)) ? "CUSTOMS_HOLD" : null;
|
||||
}
|
||||
|
||||
private static boolean matchesContainer(String requested, JsonNode payload) {
|
||||
String actual = text(payload, "containerNumber", "") + text(payload, "checkDigit", "");
|
||||
return normalizeIdentifier(requested).equals(normalizeIdentifier(actual));
|
||||
}
|
||||
|
||||
private static String locationCode(JsonNode payload, String location, String standardNode) {
|
||||
String normalized = location.toUpperCase(Locale.ROOT);
|
||||
String originName = text(payload, "originPort", "").toUpperCase(Locale.ROOT);
|
||||
String loadPort = text(payload, "loadPort", "").toUpperCase(Locale.ROOT);
|
||||
String destinationName = text(payload, "destPort", "").toUpperCase(Locale.ROOT);
|
||||
String dischargePort = text(payload, "dischargePort", "").toUpperCase(Locale.ROOT);
|
||||
if (!normalized.isBlank() && (normalized.equals(originName) || normalized.equals(loadPort))) {
|
||||
return text(payload, "originCode", "");
|
||||
}
|
||||
if (!normalized.isBlank() && (normalized.equals(destinationName) || normalized.equals(dischargePort))) {
|
||||
return text(payload, "destCode", "");
|
||||
}
|
||||
if ("ARRIVED_DESTINATION_PORT".equals(standardNode)
|
||||
|| "DISCHARGED_FROM_VESSEL".equals(standardNode) || "PICKUP_READY".equals(standardNode)
|
||||
|| "PICKED_UP".equals(standardNode) || "EMPTY_RETURNED".equals(standardNode)) {
|
||||
return text(payload, "destCode", "");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private static String normalizeStatus(String value) {
|
||||
return value == null ? "" : value.trim().replaceAll("\\s+", " ").toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private static String normalizeIdentifier(String value) {
|
||||
return value == null ? "" : value.replaceAll("[^A-Za-z0-9]", "").toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private static String text(JsonNode node, String field, String fallback) {
|
||||
JsonNode value = node.get(field);
|
||||
return value == null || value.isNull() ? fallback : value.asText(fallback);
|
||||
}
|
||||
|
||||
private static LocalDateTime parseDate(JsonNode value) {
|
||||
if (value == null || value.isNull()) return null;
|
||||
if (value.isNumber()) {
|
||||
long epoch = value.asLong();
|
||||
if (epoch <= 0) return null;
|
||||
return LocalDateTime.ofInstant(Instant.ofEpochMilli(epoch < 10_000_000_000L ? epoch * 1000 : epoch),
|
||||
ZoneOffset.UTC);
|
||||
}
|
||||
String text = value.asText("").trim();
|
||||
if (text.isEmpty()) return null;
|
||||
try { return LocalDateTime.ofInstant(Instant.parse(text), ZoneOffset.UTC); }
|
||||
catch (DateTimeParseException ignored) { }
|
||||
for (DateTimeFormatter formatter : List.of(DateTimeFormatter.ISO_LOCAL_DATE_TIME,
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"), DateTimeFormatter.ofPattern("MM/dd/yyyy HH:mm"))) {
|
||||
try { return LocalDateTime.parse(text, formatter); }
|
||||
catch (DateTimeParseException ignored) { }
|
||||
}
|
||||
for (DateTimeFormatter formatter : List.of(DateTimeFormatter.ISO_LOCAL_DATE,
|
||||
DateTimeFormatter.ofPattern("MM/dd/yy"), DateTimeFormatter.ofPattern("MM/dd/yyyy"))) {
|
||||
try { return LocalDate.parse(text, formatter).atStartOfDay(); }
|
||||
catch (DateTimeParseException ignored) { }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String blankToNull(String value) { return value == null || value.isBlank() ? null : value; }
|
||||
private static String truncate(String value, int max) { return value.length() <= max ? value : value.substring(0, max); }
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ProviderStatus status() {
|
||||
return ProviderStatus.of("CARRIER_SCHEDULE_MATSON", client.configured(), client.configured());
|
||||
}
|
||||
}
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
package com.baiyue.integration.provider;
|
||||
|
||||
import com.baiyue.integration.config.IntegrationProperties;
|
||||
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.CookieManager;
|
||||
import java.net.CookiePolicy;
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.Locale;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@Component
|
||||
public class MatsonTrackingClient {
|
||||
private static final Pattern TTR_PATTERN = Pattern.compile(
|
||||
"id=[\\\"']ttr[\\\"'][^>]*value=[\\\"']([^\\\"']+)[\\\"']", Pattern.CASE_INSENSITIVE);
|
||||
private static final String USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
|
||||
|
||||
@Resource private IntegrationProperties properties;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
private IntegrationProperties.CarrierSchedule.Matson config;
|
||||
private final Object requestLock = new Object();
|
||||
private long nextRequestAtMillis;
|
||||
|
||||
@PostConstruct
|
||||
void initialize() {
|
||||
config = properties.carrierSchedule() == null ? null : properties.carrierSchedule().matson();
|
||||
}
|
||||
|
||||
public boolean configured() {
|
||||
return config != null && config.configured();
|
||||
}
|
||||
|
||||
public TrackingResponse query(QueryRequest request) {
|
||||
if (!configured()) {
|
||||
throw new MatsonTrackingException("MATSON_NOT_CONFIGURED", false,
|
||||
"美森公开查询能力未启用或地址未配置");
|
||||
}
|
||||
synchronized (requestLock) {
|
||||
waitForRequestSlot();
|
||||
try {
|
||||
return execute(request);
|
||||
} finally {
|
||||
nextRequestAtMillis = System.currentTimeMillis()
|
||||
+ config.effectiveMinRequestInterval().toMillis();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private TrackingResponse execute(QueryRequest request) {
|
||||
CookieManager cookies = new CookieManager(null, CookiePolicy.ACCEPT_ALL);
|
||||
HttpClient client = HttpClient.newBuilder()
|
||||
.cookieHandler(cookies)
|
||||
.connectTimeout(config.effectiveConnectTimeout())
|
||||
.followRedirects(HttpClient.Redirect.NORMAL)
|
||||
.build();
|
||||
try {
|
||||
HttpResponse<String> page = client.send(HttpRequest.newBuilder(config.pageUrl())
|
||||
.timeout(config.effectiveRequestTimeout())
|
||||
.header("User-Agent", USER_AGENT)
|
||||
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
||||
.header("Accept-Language", "en-US,en;q=0.9")
|
||||
.GET().build(),
|
||||
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
ensureHttp(page.statusCode(), "MATSON_PAGE");
|
||||
Matcher matcher = TTR_PATTERN.matcher(page.body());
|
||||
if (!matcher.find() || matcher.group(1).isBlank()) {
|
||||
throw new MatsonTrackingException("MATSON_TTR_MISSING", true,
|
||||
"美森查询页未返回动态校验参数");
|
||||
}
|
||||
|
||||
String form = "container_trackingnumber=" + encode(request.trackingNo().trim())
|
||||
+ "&trackingVal=" + trackingValue(request.trackingType())
|
||||
+ "&ttr=" + encode(matcher.group(1));
|
||||
HttpResponse<String> response = client.send(HttpRequest.newBuilder(config.queryUrl())
|
||||
.timeout(config.effectiveRequestTimeout())
|
||||
.header("User-Agent", USER_AGENT)
|
||||
.header("Accept", "application/json, text/javascript, */*; q=0.01")
|
||||
.header("Accept-Language", "en-US,en;q=0.9")
|
||||
.header("Origin", origin(config.pageUrl()))
|
||||
.header("Referer", config.pageUrl().toString())
|
||||
.header("X-Requested-With", "XMLHttpRequest")
|
||||
.header("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(form, StandardCharsets.UTF_8)).build(),
|
||||
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
ensureHttp(response.statusCode(), "MATSON_QUERY");
|
||||
JsonNode payload = objectMapper.readTree(response.body());
|
||||
return new TrackingResponse(payload, response.body());
|
||||
} catch (MatsonTrackingException exception) {
|
||||
throw exception;
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new MatsonTrackingException("MATSON_INTERRUPTED", true, "美森查询被中断", exception);
|
||||
} catch (IOException exception) {
|
||||
throw new MatsonTrackingException("MATSON_IO_FAILED", true, "美森查询网络或响应解析失败", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void waitForRequestSlot() {
|
||||
long waitMillis = nextRequestAtMillis - System.currentTimeMillis();
|
||||
if (waitMillis <= 0) return;
|
||||
try {
|
||||
Thread.sleep(waitMillis);
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new MatsonTrackingException("MATSON_INTERRUPTED", true, "等待美森查询限流窗口时被中断", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ensureHttp(int status, String stage) {
|
||||
if (status >= 200 && status < 300) return;
|
||||
if (status == 429) {
|
||||
throw new MatsonTrackingException("MATSON_RATE_LIMITED", true, "美森查询触发访问频率限制");
|
||||
}
|
||||
if (status == 403) {
|
||||
throw new MatsonTrackingException("MATSON_SESSION_REJECTED", true,
|
||||
"美森拒绝当前网页会话,请稍后重新获取动态参数");
|
||||
}
|
||||
throw new MatsonTrackingException(stage + "_HTTP_" + status, status >= 500,
|
||||
"美森查询返回异常 HTTP 状态:" + status);
|
||||
}
|
||||
|
||||
private static String trackingValue(String trackingType) {
|
||||
return switch (trackingType.trim().toUpperCase(Locale.ROOT)) {
|
||||
case "CONTAINER_NO" -> "tckconta";
|
||||
case "BOOKING_NO" -> "tckbok";
|
||||
case "BILL_OF_LADING" -> "tckbil";
|
||||
default -> throw new MatsonTrackingException("MATSON_TRACKING_TYPE_UNSUPPORTED", false,
|
||||
"美森不支持该跟踪类型:" + trackingType);
|
||||
};
|
||||
}
|
||||
|
||||
private static String origin(URI uri) {
|
||||
int port = uri.getPort();
|
||||
return uri.getScheme() + "://" + uri.getHost() + (port < 0 ? "" : ":" + port);
|
||||
}
|
||||
|
||||
private static String encode(String value) {
|
||||
return URLEncoder.encode(value, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
public record TrackingResponse(JsonNode payload, String rawBody) { }
|
||||
|
||||
public static final class MatsonTrackingException extends RuntimeException {
|
||||
private final String code;
|
||||
private final boolean retryable;
|
||||
|
||||
public MatsonTrackingException(String code, boolean retryable, String message) {
|
||||
super(message); this.code = code; this.retryable = retryable;
|
||||
}
|
||||
|
||||
public MatsonTrackingException(String code, boolean retryable, String message, Throwable cause) {
|
||||
super(message, cause); this.code = code; this.retryable = retryable;
|
||||
}
|
||||
|
||||
public String code() { return code; }
|
||||
public boolean retryable() { return retryable; }
|
||||
}
|
||||
}
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
package com.baiyue.integration.provider;
|
||||
|
||||
import com.baiyue.integration.dto.CarrierScheduleDtos.Observation;
|
||||
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest;
|
||||
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryResult;
|
||||
import com.baiyue.integration.dto.ProviderStatus;
|
||||
import com.baiyue.integration.provider.SmlTrackingClient.SmlTrackingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
@Component
|
||||
@Order(5)
|
||||
public class SmlCarrierScheduleProviderAdapter implements CarrierScheduleProviderAdapter, IntegrationProviderAdapter {
|
||||
private static final String PROVIDER = "SML_PUBLIC_TRACKING";
|
||||
private static final Map<String, String> NODE_MAP = Map.of(
|
||||
"FOTMAD", "AT_ORIGIN_PORT",
|
||||
"FLVMLO", "LOADED_ON_VESSEL",
|
||||
"FLVMDO", "DEPARTED_ORIGIN_PORT",
|
||||
"FUVMAD", "ARRIVED_DESTINATION_PORT",
|
||||
"FUVMBD", "ARRIVED_DESTINATION_PORT",
|
||||
"FUVMUD", "DISCHARGED_FROM_VESSEL",
|
||||
"MITYAD", "EMPTY_RETURNED");
|
||||
private final SmlTrackingClient client;
|
||||
|
||||
public SmlCarrierScheduleProviderAdapter(SmlTrackingClient client) { this.client = client; }
|
||||
|
||||
@Override
|
||||
public boolean supports(String carrierCode) {
|
||||
return client.configured() && carrierCode != null && "SML".equals(carrierCode.trim().toUpperCase(Locale.ROOT));
|
||||
}
|
||||
|
||||
@Override
|
||||
public QueryResult query(QueryRequest request) {
|
||||
LocalDateTime queriedAt = LocalDateTime.now(ZoneOffset.UTC);
|
||||
try {
|
||||
SmlTrackingClient.TrackingResponse response = client.query(request);
|
||||
List<Observation> observations = observations(request.trackingNo(), response, queriedAt);
|
||||
return new QueryResult(PROVIDER, observations.isEmpty() ? "NO_CHANGE" : "SUCCESS", observations,
|
||||
List.of(), null, null, queriedAt);
|
||||
} catch (SmlTrackingException exception) {
|
||||
return new QueryResult(PROVIDER, exception.retryable() ? "RETRYABLE_FAILURE" : "PERMANENT_FAILURE",
|
||||
List.of(), List.of(), exception.code(), exception.getMessage(), queriedAt);
|
||||
}
|
||||
}
|
||||
|
||||
List<Observation> observations(String containerNo, SmlTrackingClient.TrackingResponse response) {
|
||||
return observations(containerNo, response, LocalDateTime.now(ZoneOffset.UTC));
|
||||
}
|
||||
|
||||
private List<Observation> observations(String containerNo, SmlTrackingClient.TrackingResponse response,
|
||||
LocalDateTime queriedAt) {
|
||||
if (response == null || response.timeline() == null) return List.of();
|
||||
String rawHash = sha256(response.rawBody() == null ? response.timeline().toString() : response.rawBody());
|
||||
String reference = "SML:" + response.bookingNo() + ':' + response.copNo();
|
||||
Map<String, Observation> facts = new LinkedHashMap<>();
|
||||
collect(response.timeline(), containerNo, reference, rawHash, queriedAt, facts);
|
||||
return facts.values().stream().sorted(Comparator.comparing(Observation::observedAt)).toList();
|
||||
}
|
||||
|
||||
private static void collect(JsonNode node, String containerNo, String reference, String rawHash,
|
||||
LocalDateTime queriedAt,
|
||||
Map<String, Observation> facts) {
|
||||
if (node == null) return;
|
||||
if (node.isObject()) {
|
||||
String providerNode = directText(node, "actStsMapgCd", "actCd", "actStsCd", "eventCode");
|
||||
String actType = directText(node, "actTpCd", "actualType");
|
||||
String standardNode = providerNode == null ? null : NODE_MAP.get(providerNode.toUpperCase(Locale.ROOT));
|
||||
LocalDateTime eventAt = parseDate(directText(node, "eventDt", "actDt", "skdDt", "eventDate", "date"));
|
||||
if (standardNode != null && eventAt != null && ("A".equalsIgnoreCase(actType) || "E".equalsIgnoreCase(actType))) {
|
||||
String locationCode = directText(node, "locCd", "locationCode", "portCd");
|
||||
String locationName = directText(node, "locNm", "locationName", "portNm");
|
||||
String identity = normalize(containerNo) + '|' + providerNode.toUpperCase(Locale.ROOT) + '|'
|
||||
+ actType.toUpperCase(Locale.ROOT) + '|' + eventAt + '|' + normalize(locationCode) + '|'
|
||||
+ normalize(locationName);
|
||||
String eventId = sha256(identity);
|
||||
boolean actual = "A".equalsIgnoreCase(actType);
|
||||
facts.putIfAbsent(eventId, new Observation(eventId, providerNode.toUpperCase(Locale.ROOT),
|
||||
standardNode, queriedAt,
|
||||
actual ? null : eventAt, actual ? eventAt : null, blank(locationCode), blank(locationName),
|
||||
reference, rawHash));
|
||||
}
|
||||
Iterator<JsonNode> children = node.elements();
|
||||
while (children.hasNext()) collect(children.next(), containerNo, reference, rawHash, queriedAt, facts);
|
||||
} else if (node.isArray()) for (JsonNode child : node) {
|
||||
collect(child, containerNo, reference, rawHash, queriedAt, facts);
|
||||
}
|
||||
}
|
||||
|
||||
static String standardNode(String providerCode) {
|
||||
return providerCode == null ? null : NODE_MAP.get(providerCode.trim().toUpperCase(Locale.ROOT));
|
||||
}
|
||||
|
||||
private static String directText(JsonNode node, String... names) {
|
||||
for (String name : names) {
|
||||
Iterator<Map.Entry<String, JsonNode>> fields = node.fields();
|
||||
while (fields.hasNext()) {
|
||||
var field = fields.next();
|
||||
if (field.getKey().equalsIgnoreCase(name) && field.getValue().isValueNode()) {
|
||||
String value = field.getValue().asText("").trim();
|
||||
if (!value.isEmpty()) return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static LocalDateTime parseDate(String value) {
|
||||
if (value == null || value.isBlank()) return null;
|
||||
String text = value.trim();
|
||||
try {
|
||||
long epoch = Long.parseLong(text);
|
||||
return LocalDateTime.ofInstant(Instant.ofEpochMilli(epoch < 10_000_000_000L ? epoch * 1000 : epoch),
|
||||
ZoneOffset.UTC);
|
||||
} catch (NumberFormatException ignored) { }
|
||||
try { return LocalDateTime.ofInstant(Instant.parse(text), ZoneOffset.UTC); }
|
||||
catch (DateTimeParseException ignored) { }
|
||||
for (DateTimeFormatter formatter : List.of(DateTimeFormatter.ISO_LOCAL_DATE_TIME,
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"), DateTimeFormatter.ofPattern("yyyyMMddHHmm"))) {
|
||||
try { return LocalDateTime.parse(text, formatter); }
|
||||
catch (DateTimeParseException ignored) { }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String normalize(String value) {
|
||||
return value == null ? "" : value.replaceAll("[^A-Za-z0-9]", "").toUpperCase(Locale.ROOT);
|
||||
}
|
||||
private static String blank(String value) { return value == null || value.isBlank() ? null : value.trim(); }
|
||||
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); }
|
||||
}
|
||||
|
||||
@Override
|
||||
public ProviderStatus status() { return ProviderStatus.of("CARRIER_SCHEDULE_SML", client.configured(), client.configured()); }
|
||||
}
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
package com.baiyue.integration.provider;
|
||||
|
||||
import com.baiyue.integration.config.IntegrationProperties;
|
||||
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.http.HttpTimeoutException;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Iterator;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
@Component
|
||||
public class SmlTrackingClient {
|
||||
@Resource private IntegrationProperties properties;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
private IntegrationProperties.CarrierSchedule.Sml config;
|
||||
private HttpClient httpClient;
|
||||
private final Object requestLock = new Object();
|
||||
private long nextRequestAtMillis;
|
||||
|
||||
@PostConstruct
|
||||
void initialize() {
|
||||
config = properties.carrierSchedule() == null ? null : properties.carrierSchedule().sml();
|
||||
httpClient = HttpClient.newBuilder()
|
||||
.connectTimeout(config == null ? java.time.Duration.ofSeconds(10) : config.effectiveConnectTimeout())
|
||||
.followRedirects(HttpClient.Redirect.NORMAL).build();
|
||||
}
|
||||
|
||||
public boolean configured() { return config != null && config.configured(); }
|
||||
|
||||
public TrackingResponse query(QueryRequest request) {
|
||||
if (!configured()) throw new SmlTrackingException("SML_NOT_CONFIGURED", false, "SML 查询能力未启用");
|
||||
if (!"CONTAINER_NO".equalsIgnoreCase(request.trackingType())) {
|
||||
throw new SmlTrackingException("SML_CONTAINER_REQUIRED", false, "SML 自动节点只按柜号查询");
|
||||
}
|
||||
synchronized (requestLock) {
|
||||
waitForSlot();
|
||||
try {
|
||||
String container = request.trackingNo().trim();
|
||||
JsonResponse lookup = call("f_cmd=122&cntr_no=" + encode(container) + "&cust_cd=", "LOOKUP");
|
||||
validateBusinessResult(lookup.payload());
|
||||
String bookingNo = findText(lookup.payload(), "bkgNo", "bkg_no");
|
||||
String copNo = findText(lookup.payload(), "copNo", "cop_no");
|
||||
if (bookingNo == null || copNo == null) {
|
||||
throw new SmlTrackingException("SML_BUSINESS_KEYS_MISSING", false,
|
||||
"SML 查询结果缺少 bkgNo 或 copNo");
|
||||
}
|
||||
JsonResponse timeline = call("f_cmd=125&cntr_no=" + encode(container) + "&bkg_no="
|
||||
+ encode(bookingNo) + "&cop_no=" + encode(copNo), "TIMELINE");
|
||||
validateBusinessResult(timeline.payload());
|
||||
JsonResponse schedule = call("f_cmd=124&bkg_no=" + encode(bookingNo), "SCHEDULE");
|
||||
JsonResponse clearance = call("f_cmd=126&cntr_no=" + encode(container) + "&bkg_no="
|
||||
+ encode(bookingNo), "CLEARANCE");
|
||||
return new TrackingResponse(bookingNo, copNo, timeline.payload(), schedule.payload(),
|
||||
clearance.payload(), lookup.raw() + timeline.raw() + schedule.raw() + clearance.raw());
|
||||
} finally {
|
||||
nextRequestAtMillis = System.currentTimeMillis() + config.effectiveMinRequestInterval().toMillis();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private JsonResponse call(String form, String stage) {
|
||||
try {
|
||||
HttpResponse<String> response = httpClient.send(HttpRequest.newBuilder(config.queryUrl())
|
||||
.timeout(config.effectiveRequestTimeout())
|
||||
.header("Accept", "application/json, text/javascript, */*; q=0.01")
|
||||
.header("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(form, StandardCharsets.UTF_8)).build(),
|
||||
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
if (response.statusCode() == 429) throw new SmlTrackingException("SML_RATE_LIMITED", true, "SML 访问限流");
|
||||
if (response.statusCode() < 200 || response.statusCode() >= 300) {
|
||||
throw new SmlTrackingException("SML_" + stage + "_HTTP_" + response.statusCode(),
|
||||
response.statusCode() >= 500, "SML 查询返回异常 HTTP 状态:" + response.statusCode());
|
||||
}
|
||||
try { return new JsonResponse(objectMapper.readTree(response.body()), response.body()); }
|
||||
catch (Exception exception) {
|
||||
throw new SmlTrackingException("SML_" + stage + "_NON_JSON", true,
|
||||
"SML 查询返回 HTML 或无效 JSON", exception);
|
||||
}
|
||||
} catch (SmlTrackingException exception) {
|
||||
throw exception;
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new SmlTrackingException("SML_INTERRUPTED", true, "SML 查询被中断", exception);
|
||||
} catch (HttpTimeoutException exception) {
|
||||
throw new SmlTrackingException("SML_" + stage + "_TIMEOUT", true, "SML 查询超时", exception);
|
||||
} catch (IOException exception) {
|
||||
throw new SmlTrackingException("SML_IO_FAILED", true, "SML 查询网络失败", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateBusinessResult(JsonNode payload) {
|
||||
if (payload == null || payload.isNull()) throw new SmlTrackingException("SML_EMPTY_RESULT", false, "SML 返回空结果");
|
||||
String result = findText(payload, "TRANS_RESULT_KEY", "transResultKey");
|
||||
if (result != null && !"S".equalsIgnoreCase(result)) {
|
||||
throw new SmlTrackingException("SML_BUSINESS_FAILED", false, "SML 业务查询失败:" + result);
|
||||
}
|
||||
Long count = findLong(payload, "count", "totalCount");
|
||||
if (count != null && count == 0L) throw new SmlTrackingException("SML_NOT_FOUND", false, "SML 未找到业务对象");
|
||||
}
|
||||
|
||||
static String findText(JsonNode node, String... names) {
|
||||
if (node == null) return null;
|
||||
if (node.isObject()) {
|
||||
Iterator<Map.Entry<String, JsonNode>> fields = node.fields();
|
||||
while (fields.hasNext()) {
|
||||
var field = fields.next();
|
||||
for (String name : names) if (field.getKey().equalsIgnoreCase(name)
|
||||
&& field.getValue().isValueNode() && !field.getValue().asText().isBlank()) {
|
||||
return field.getValue().asText().trim();
|
||||
}
|
||||
String nested = findText(field.getValue(), names);
|
||||
if (nested != null) return nested;
|
||||
}
|
||||
} else if (node.isArray()) for (JsonNode child : node) {
|
||||
String nested = findText(child, names); if (nested != null) return nested;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Long findLong(JsonNode node, String... names) {
|
||||
String value = findText(node, names);
|
||||
if (value == null) return null;
|
||||
try { return Long.parseLong(value); }
|
||||
catch (NumberFormatException ignored) { return null; }
|
||||
}
|
||||
|
||||
private void waitForSlot() {
|
||||
long wait = nextRequestAtMillis - System.currentTimeMillis();
|
||||
if (wait <= 0) return;
|
||||
try { Thread.sleep(wait); }
|
||||
catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new SmlTrackingException("SML_INTERRUPTED", true, "等待 SML 限流窗口时被中断", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static String encode(String value) { return URLEncoder.encode(value, StandardCharsets.UTF_8); }
|
||||
|
||||
private record JsonResponse(JsonNode payload, String raw) { }
|
||||
public record TrackingResponse(String bookingNo, String copNo, JsonNode timeline, JsonNode schedule,
|
||||
JsonNode clearance, String rawBody) { }
|
||||
|
||||
public static final class SmlTrackingException extends RuntimeException {
|
||||
private final String code; private final boolean retryable;
|
||||
public SmlTrackingException(String code, boolean retryable, String message) {
|
||||
super(message); this.code = code; this.retryable = retryable;
|
||||
}
|
||||
public SmlTrackingException(String code, boolean retryable, String message, Throwable cause) {
|
||||
super(message, cause); this.code = code; this.retryable = retryable;
|
||||
}
|
||||
public String code() { return code; }
|
||||
public boolean retryable() { return retryable; }
|
||||
}
|
||||
}
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
package com.baiyue.integration.provider;
|
||||
|
||||
import com.baiyue.integration.dto.CarrierScheduleDtos.Observation;
|
||||
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest;
|
||||
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryResult;
|
||||
import com.baiyue.integration.dto.ProviderStatus;
|
||||
import com.baiyue.integration.provider.ZimTrackingClient.ContainerEvent;
|
||||
import com.baiyue.integration.provider.ZimTrackingClient.RouteDetail;
|
||||
import com.baiyue.integration.provider.ZimTrackingClient.ZimTrackingException;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.Comparator;
|
||||
import java.util.HexFormat;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
@Component
|
||||
@Order(7)
|
||||
public class ZimCarrierScheduleProviderAdapter implements CarrierScheduleProviderAdapter, IntegrationProviderAdapter {
|
||||
private static final String PROVIDER = "ZIM_OFFICIAL_CARGO_TRACKING";
|
||||
private static final Map<String, String> EVENT_MAP = Map.ofEntries(
|
||||
Map.entry("EXPORT GATE-IN FROM CUSTOMER TO PORT OF LOADING", "AT_ORIGIN_PORT"),
|
||||
Map.entry("EXPORT GATE-IN TO PORT OF LOADING", "AT_ORIGIN_PORT"),
|
||||
Map.entry("CONTAINER WAS LOADED AT TRANSSHIPMENT PORT TO TRANSSHIPMENT PORT", "LOADED_ON_VESSEL"),
|
||||
Map.entry("VESSEL DEPARTURE FROM FIRST TRANSSHIPMENT PORT TO NEXT TRANSSHIPMENT PORT", "IN_TRANSIT"),
|
||||
Map.entry("CONTAINER LOADED AT PORT OF LOADING", "LOADED_ON_VESSEL"),
|
||||
Map.entry("CONTAINER WAS LOADED AT PORT OF LOADING", "LOADED_ON_VESSEL"),
|
||||
Map.entry("CONTAINER WAS LOADED AT PORT OF LOADING TO PORT OF DISCHARGE", "LOADED_ON_VESSEL"),
|
||||
Map.entry("VESSEL DEPARTURE FROM PORT OF LOADING", "DEPARTED_ORIGIN_PORT"),
|
||||
Map.entry("VESSEL DEPARTURE FROM PORT OF LOADING TO PORT OF DISCHARGE", "DEPARTED_ORIGIN_PORT"));
|
||||
private final ZimTrackingClient client;
|
||||
|
||||
public ZimCarrierScheduleProviderAdapter(ZimTrackingClient client) { this.client = client; }
|
||||
|
||||
@Override
|
||||
public boolean supports(String carrierCode) {
|
||||
return client.configured() && carrierCode != null && "ZIM".equals(carrierCode.trim().toUpperCase(Locale.ROOT));
|
||||
}
|
||||
|
||||
@Override
|
||||
public QueryResult query(QueryRequest request) {
|
||||
LocalDateTime queriedAt = LocalDateTime.now(ZoneOffset.UTC);
|
||||
try {
|
||||
ZimTrackingClient.TrackingResponse response = client.query(request);
|
||||
List<Observation> observations = observations(request.trackingNo(), response, queriedAt);
|
||||
return new QueryResult(PROVIDER, observations.isEmpty() ? "NO_CHANGE" : "SUCCESS", observations,
|
||||
List.of(), null, null, queriedAt);
|
||||
} catch (ZimTrackingException exception) {
|
||||
return new QueryResult(PROVIDER, exception.outcome(), List.of(), List.of(), exception.code(),
|
||||
exception.getMessage(), queriedAt);
|
||||
}
|
||||
}
|
||||
|
||||
List<Observation> observations(String containerNo, ZimTrackingClient.TrackingResponse response,
|
||||
LocalDateTime queriedAt) {
|
||||
if (response == null || response.payload() == null || response.payload().bkBlDetails() == null) return List.of();
|
||||
String normalizedContainer = normalize(containerNo);
|
||||
ZimTrackingClient.ContainerDetail unit = response.payload().bkBlDetails().safeContainers().stream()
|
||||
.filter(value -> normalizedContainer.equals(normalize(value.containerNumber())))
|
||||
.findFirst().orElse(null);
|
||||
if (unit == null) return List.of();
|
||||
String rawHash = sha256(response.rawBody() == null ? normalizedContainer : response.rawBody());
|
||||
String reference = sourceReference(response.payload(), normalizedContainer);
|
||||
Map<String, Observation> facts = new LinkedHashMap<>();
|
||||
for (ContainerEvent event : unit.safeEvents()) {
|
||||
String eventName = normalizeEvent(event.eventName());
|
||||
String standardNode = EVENT_MAP.get(eventName);
|
||||
LocalDateTime actualAt = parseDate(first(event.activityDateTz(), event.activityDate()));
|
||||
if (standardNode == null || actualAt == null) continue;
|
||||
add(facts, normalizedContainer, truncate(eventName, 64), standardNode, queriedAt, null, actualAt,
|
||||
blank(event.portCode()), locationName(event.port(), event.country()), reference, rawHash,
|
||||
event.vesselName(), event.voyage(), event.leg());
|
||||
}
|
||||
List<RouteDetail> routes = response.payload().safeRoutes();
|
||||
RouteDetail firstSailing = routes.stream()
|
||||
.filter(value -> parseDate(first(value.sailingDateTz(), value.sailingDateDT())) != null)
|
||||
.findFirst().orElse(null);
|
||||
if (firstSailing != null) {
|
||||
addEstimated(facts, normalizedContainer, "ROUTE_POL_SAILING_PLAN", "DEPARTED_ORIGIN_PORT",
|
||||
parseDate(first(firstSailing.sailingDateTz(), firstSailing.sailingDateDT())), queriedAt,
|
||||
firstSailing, reference, rawHash);
|
||||
}
|
||||
RouteDetail lastArrival = null;
|
||||
for (int index = routes.size() - 1; index >= 0; index--) {
|
||||
RouteDetail candidate = routes.get(index);
|
||||
if (parseDate(first(candidate.arrivalDateTz(), candidate.arrivalDateDT())) != null) {
|
||||
lastArrival = candidate; break;
|
||||
}
|
||||
}
|
||||
if (lastArrival != null) {
|
||||
addEstimated(facts, normalizedContainer, "ROUTE_POD_ARRIVAL_PLAN", "ARRIVED_DESTINATION_PORT",
|
||||
parseDate(first(lastArrival.arrivalDateTz(), lastArrival.arrivalDateDT())), queriedAt,
|
||||
lastArrival, reference, rawHash);
|
||||
}
|
||||
if (response.payload().finalETA() != null) {
|
||||
addEstimated(facts, normalizedContainer, "FINAL_ETA", "ARRIVED_DESTINATION_PORT",
|
||||
parseDate(first(response.payload().finalETA().etaValue(), response.payload().finalETA().etaValueDate())),
|
||||
queriedAt, null, reference, rawHash);
|
||||
}
|
||||
return facts.values().stream().sorted(Comparator.comparing(Observation::standardNode)
|
||||
.thenComparing(value -> value.actualAt() == null ? value.estimatedAt() : value.actualAt())).toList();
|
||||
}
|
||||
|
||||
static String standardNode(String eventName) {
|
||||
return EVENT_MAP.get(normalizeEvent(eventName));
|
||||
}
|
||||
|
||||
private static void addEstimated(Map<String, Observation> facts, String container, String providerNode,
|
||||
String standardNode, LocalDateTime estimatedAt, LocalDateTime queriedAt,
|
||||
RouteDetail route, String reference, String rawHash) {
|
||||
if (estimatedAt == null) return;
|
||||
add(facts, container, providerNode, standardNode, queriedAt, estimatedAt, null,
|
||||
route == null ? null : blank(route.portCode()),
|
||||
route == null ? null : locationName(route.port(), route.country()), reference, rawHash,
|
||||
route == null ? null : route.vesselName(), route == null ? null : route.voyage(),
|
||||
route == null ? null : route.leg());
|
||||
}
|
||||
|
||||
private static void add(Map<String, Observation> facts, String container, String providerNode,
|
||||
String standardNode, LocalDateTime queriedAt, LocalDateTime estimatedAt,
|
||||
LocalDateTime actualAt, String locationCode, String locationName,
|
||||
String reference, String rawHash, String vesselName, String voyage, String leg) {
|
||||
LocalDateTime eventAt = actualAt == null ? estimatedAt : actualAt;
|
||||
String eventId = sha256(container + '|' + providerNode + '|' + eventAt + '|'
|
||||
+ normalize(locationCode) + '|' + normalize(locationName) + '|' + clean(vesselName) + '|'
|
||||
+ clean(voyage) + '|' + clean(leg));
|
||||
facts.putIfAbsent(eventId, new Observation(eventId, providerNode, standardNode, queriedAt,
|
||||
estimatedAt, actualAt, locationCode, locationName, reference, rawHash));
|
||||
}
|
||||
|
||||
private static String first(String... values) {
|
||||
for (String value : values) {
|
||||
if (value != null && !value.isBlank()) return value.trim();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private static String sourceReference(ZimTrackingClient.TracingPayload payload, String container) {
|
||||
StringBuilder value = new StringBuilder("ZIM:CONTAINER:").append(container);
|
||||
if (!clean(payload.bookingReference()).isEmpty()) value.append("|BOOKING:").append(clean(payload.bookingReference()));
|
||||
if (!clean(payload.blReference()).isEmpty()) value.append("|BL:").append(clean(payload.blReference()));
|
||||
return truncate(value.toString(), 500);
|
||||
}
|
||||
|
||||
private static String locationName(String port, String country) {
|
||||
String name = clean(port); String nation = clean(country);
|
||||
return blank(name + (name.isEmpty() || nation.isEmpty() ? "" : ", ") + nation);
|
||||
}
|
||||
|
||||
private static String normalizeEvent(String value) {
|
||||
return value == null ? "" : value.trim().replaceAll("\\s+", " ").toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private static LocalDateTime parseDate(String value) {
|
||||
if (value == null || value.isBlank()) return null;
|
||||
String text = value.trim();
|
||||
try { return LocalDateTime.ofInstant(Instant.parse(text), ZoneOffset.UTC); }
|
||||
catch (DateTimeParseException ignored) { }
|
||||
try { return LocalDateTime.ofInstant(OffsetDateTime.parse(text).toInstant(), ZoneOffset.UTC); }
|
||||
catch (DateTimeParseException ignored) { }
|
||||
for (DateTimeFormatter formatter : List.of(DateTimeFormatter.ISO_LOCAL_DATE_TIME,
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"), DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))) {
|
||||
try { return LocalDateTime.parse(text, formatter); }
|
||||
catch (DateTimeParseException ignored) { }
|
||||
}
|
||||
try { return LocalDate.parse(text, DateTimeFormatter.ISO_LOCAL_DATE).atStartOfDay(); }
|
||||
catch (DateTimeParseException ignored) { }
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String clean(String value) { return value == null ? "" : value.trim(); }
|
||||
private static String blank(String value) { return value == null || value.isBlank() ? null : value.trim(); }
|
||||
private static String truncate(String value, int max) { return value.length() <= max ? value : value.substring(0, max); }
|
||||
private static String normalize(String value) {
|
||||
return value == null ? "" : value.replaceAll("[^A-Za-z0-9]", "").toUpperCase(Locale.ROOT);
|
||||
}
|
||||
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); }
|
||||
}
|
||||
|
||||
@Override
|
||||
public ProviderStatus status() {
|
||||
return ProviderStatus.of("CARRIER_SCHEDULE_ZIM", client.configured(), client.configured());
|
||||
}
|
||||
}
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
package com.baiyue.integration.provider;
|
||||
|
||||
import com.baiyue.integration.config.IntegrationProperties;
|
||||
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.net.http.HttpTimeoutException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.Semaphore;
|
||||
|
||||
@Component
|
||||
public class ZimTrackingClient {
|
||||
private static final Duration TOKEN_REFRESH_SKEW = Duration.ofMinutes(1);
|
||||
@Resource private IntegrationProperties properties;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
private IntegrationProperties.CarrierSchedule.Zim config;
|
||||
private HttpClient httpClient;
|
||||
private Semaphore concurrency;
|
||||
private final Object tokenLock = new Object();
|
||||
private volatile AccessToken cachedToken;
|
||||
|
||||
@PostConstruct
|
||||
void initialize() {
|
||||
config = properties.carrierSchedule() == null ? null : properties.carrierSchedule().zim();
|
||||
httpClient = HttpClient.newBuilder()
|
||||
.connectTimeout(config == null ? Duration.ofSeconds(10) : config.effectiveConnectTimeout())
|
||||
.followRedirects(HttpClient.Redirect.NORMAL).build();
|
||||
concurrency = new Semaphore(config == null ? 1 : config.effectiveMaxConcurrency());
|
||||
}
|
||||
|
||||
public boolean configured() { return config != null && config.configured(); }
|
||||
|
||||
public TrackingResponse query(QueryRequest request) {
|
||||
if (!configured()) manual("ZIM_OFFICIAL_API_NOT_CONFIGURED", "ZIM Tracing API 未启用或缺少授权配置");
|
||||
if (!"CONTAINER_NO".equalsIgnoreCase(request.trackingType())) {
|
||||
manual("ZIM_CONTAINER_REQUIRED", "ZIM 首批已验证契约仅支持柜号查询");
|
||||
}
|
||||
boolean acquired = false;
|
||||
try {
|
||||
concurrency.acquire(); acquired = true;
|
||||
return execute(normalize(request.trackingNo()), false);
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw retryable("ZIM_INTERRUPTED", "ZIM Tracing API 查询被中断", exception);
|
||||
} finally {
|
||||
if (acquired) concurrency.release();
|
||||
}
|
||||
}
|
||||
|
||||
private TrackingResponse execute(String container, boolean refreshedAfterUnauthorized) {
|
||||
String token = accessToken(false);
|
||||
HttpResponse<String> response = send(HttpRequest.newBuilder(tracingUri(container))
|
||||
.timeout(config.effectiveRequestTimeout()).header("Accept", "application/json")
|
||||
.header("Ocp-Apim-Subscription-Key", config.subscriptionKey())
|
||||
.header("Authorization", "bearer " + token).GET().build(), "TRACING");
|
||||
if (response.statusCode() == 401 && !refreshedAfterUnauthorized) {
|
||||
invalidateToken(token); return execute(container, true);
|
||||
}
|
||||
if (response.statusCode() == 401 || response.statusCode() == 403) {
|
||||
manual("ZIM_HTTP_" + response.statusCode(), "ZIM Tracing API 授权或来源环境校验失败");
|
||||
}
|
||||
if (response.statusCode() == 429 || response.statusCode() >= 500) {
|
||||
throw retryable("ZIM_HTTP_" + response.statusCode(), "ZIM Tracing API 暂时不可用", null);
|
||||
}
|
||||
if (response.statusCode() < 200 || response.statusCode() >= 300) {
|
||||
manual("ZIM_HTTP_" + response.statusCode(), "ZIM Tracing API 拒绝查询");
|
||||
}
|
||||
TracingPayload payload = tracingPayload(response.body());
|
||||
validateBusinessResult(payload, container);
|
||||
return new TrackingResponse(payload, response.body());
|
||||
}
|
||||
|
||||
private String accessToken(boolean forceRefresh) {
|
||||
AccessToken current = cachedToken;
|
||||
if (!forceRefresh && current != null && current.usableAt(Instant.now())) return current.value();
|
||||
synchronized (tokenLock) {
|
||||
current = cachedToken;
|
||||
if (!forceRefresh && current != null && current.usableAt(Instant.now())) return current.value();
|
||||
String form = "grant_type=client_credentials&client_id=" + encode(config.clientId())
|
||||
+ "&client_secret=" + encode(config.clientSecret()) + "&scope=tracing";
|
||||
HttpResponse<String> response = send(HttpRequest.newBuilder(config.authorizationUrl())
|
||||
.timeout(config.effectiveRequestTimeout()).header("Accept", "application/json")
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(form, StandardCharsets.UTF_8)).build(), "AUTH");
|
||||
if (response.statusCode() == 429 || response.statusCode() >= 500) {
|
||||
throw retryable("ZIM_AUTH_HTTP_" + response.statusCode(), "ZIM OAuth 服务暂时不可用", null);
|
||||
}
|
||||
if (response.statusCode() < 200 || response.statusCode() >= 300) {
|
||||
manual("ZIM_AUTH_HTTP_" + response.statusCode(), "ZIM OAuth 客户端授权失败");
|
||||
}
|
||||
JsonNode payload = json(response.body(), "ZIM_AUTH_NON_JSON");
|
||||
String value = payload.path("access_token").asText("").trim();
|
||||
if (value.isEmpty()) manual("ZIM_AUTH_TOKEN_MISSING", "ZIM OAuth 响应缺少 access_token");
|
||||
long expiresIn = Math.max(60L, payload.path("expires_in").asLong(3600L));
|
||||
cachedToken = new AccessToken(value, Instant.now().plusSeconds(expiresIn));
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
private HttpResponse<String> send(HttpRequest request, String stage) {
|
||||
try { return httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); }
|
||||
catch (HttpTimeoutException exception) {
|
||||
throw retryable("ZIM_" + stage + "_TIMEOUT", "ZIM 官方 API 请求超时", exception);
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw retryable("ZIM_" + stage + "_INTERRUPTED", "ZIM 官方 API 请求被中断", exception);
|
||||
} catch (IOException exception) {
|
||||
throw retryable("ZIM_" + stage + "_IO_FAILED", "ZIM 官方 API 网络失败", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private JsonNode json(String body, String code) {
|
||||
try { return objectMapper.readTree(body); }
|
||||
catch (Exception exception) { throw retryable(code, "ZIM 官方 API 返回无效 JSON", exception); }
|
||||
}
|
||||
|
||||
private TracingPayload tracingPayload(String body) {
|
||||
try { return objectMapper.readValue(body, TracingPayload.class); }
|
||||
catch (Exception exception) { throw retryable("ZIM_TRACING_NON_JSON", "ZIM Tracing V2 返回无效 JSON", exception); }
|
||||
}
|
||||
|
||||
private void validateBusinessResult(TracingPayload payload, String requestedContainer) {
|
||||
if (payload == null || payload.bkBlDetails() == null) manual("ZIM_EMPTY_RESULT", "ZIM 返回空业务对象");
|
||||
List<ContainerDetail> containers = payload.bkBlDetails().safeContainers();
|
||||
boolean matched = containers.stream().anyMatch(value -> requestedContainer.equals(normalize(value.containerNumber())));
|
||||
if (containers.isEmpty() || !matched) manual("ZIM_NOT_FOUND", "ZIM 未返回匹配柜号的业务对象");
|
||||
}
|
||||
|
||||
private URI tracingUri(String container) {
|
||||
return URI.create(config.tracingBaseUrl().toString().replaceAll("/+$", "") + '/' + encode(container));
|
||||
}
|
||||
private void invalidateToken(String usedToken) {
|
||||
synchronized (tokenLock) {
|
||||
if (cachedToken != null && cachedToken.value().equals(usedToken)) cachedToken = null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void manual(String code, String message) {
|
||||
throw new ZimTrackingException("MANUAL_REQUIRED", code, message);
|
||||
}
|
||||
private static ZimTrackingException retryable(String code, String message, Throwable cause) {
|
||||
return new ZimTrackingException("RETRYABLE_FAILURE", code, message, cause);
|
||||
}
|
||||
private static String normalize(String value) {
|
||||
return value == null ? "" : value.replaceAll("[^A-Za-z0-9]", "").toUpperCase(Locale.ROOT);
|
||||
}
|
||||
private static String encode(String value) { return URLEncoder.encode(value, StandardCharsets.UTF_8); }
|
||||
|
||||
private record AccessToken(String value, Instant expiresAt) {
|
||||
boolean usableAt(Instant now) { return expiresAt.minus(TOKEN_REFRESH_SKEW).isAfter(now); }
|
||||
}
|
||||
public record TrackingResponse(TracingPayload payload, String rawBody) { }
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record TracingPayload(FinalEta finalETA, List<RouteDetail> routeDetails, BkBlDetails bkBlDetails,
|
||||
String bookingReference, String blReference) {
|
||||
public List<RouteDetail> safeRoutes() { return routeDetails == null ? List.of() : List.copyOf(routeDetails); }
|
||||
}
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record FinalEta(String etaValue, String etaValueDate, String podInd) { }
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record RouteDetail(String sailingDateTz, String sailingDateDT, String arrivalDateTz,
|
||||
String arrivalDateDT, String portCode, String port, String country,
|
||||
String vesselName, String voyage, String leg) { }
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record BkBlDetails(List<ContainerDetail> consContainerList) {
|
||||
public List<ContainerDetail> safeContainers() {
|
||||
return consContainerList == null ? List.of() : List.copyOf(consContainerList);
|
||||
}
|
||||
}
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record ContainerDetail(String containerNumber, List<ContainerEvent> containerEventsList) {
|
||||
public List<ContainerEvent> safeEvents() {
|
||||
return containerEventsList == null ? List.of() : List.copyOf(containerEventsList);
|
||||
}
|
||||
}
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record ContainerEvent(String activityDateTz, String activityDate, String eventName,
|
||||
String portCode, String port, String country,
|
||||
String vesselName, String voyage, String leg) { }
|
||||
public static final class ZimTrackingException extends RuntimeException {
|
||||
private final String outcome; private final String code;
|
||||
public ZimTrackingException(String outcome, String code, String message) {
|
||||
super(message); this.outcome = outcome; this.code = code;
|
||||
}
|
||||
public ZimTrackingException(String outcome, String code, String message, Throwable cause) {
|
||||
super(message, cause); this.outcome = outcome; this.code = code;
|
||||
}
|
||||
public String outcome() { return outcome; }
|
||||
public String code() { return code; }
|
||||
}
|
||||
}
|
||||
+11
-1
@@ -4,6 +4,7 @@ import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest;
|
||||
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryResult;
|
||||
import com.baiyue.integration.provider.CarrierScheduleProviderAdapter;
|
||||
import com.baiyue.integration.domain.CarrierScheduleStandardNode;
|
||||
import com.baiyue.integration.domain.CarrierScheduleExceptionCode;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
@@ -24,7 +25,8 @@ public class CarrierScheduleProviderService {
|
||||
.orElseThrow(() -> new IllegalArgumentException(
|
||||
"没有支持该船司的轨迹供应商适配器:" + request.carrierCode()))
|
||||
.query(request);
|
||||
if (result == null || result.observations() == null || !OUTCOMES.contains(result.outcome())) {
|
||||
if (result == null || result.observations() == null || result.exceptions() == null
|
||||
|| !OUTCOMES.contains(result.outcome())) {
|
||||
throw new IllegalStateException("轨迹供应商返回空结果");
|
||||
}
|
||||
if (result.observations().stream().anyMatch(value -> value == null
|
||||
@@ -34,6 +36,14 @@ public class CarrierScheduleProviderService {
|
||||
|| !value.rawPayloadHash().matches("[0-9a-f]{64}"))) {
|
||||
throw new IllegalStateException("轨迹供应商返回了无效标准节点");
|
||||
}
|
||||
if (result.exceptions().stream().anyMatch(value -> value == null
|
||||
|| !CarrierScheduleExceptionCode.supports(value.exceptionCode())
|
||||
|| value.providerEventId() == null || value.providerEventId().isBlank()
|
||||
|| value.providerStatus() == null || value.providerStatus().isBlank()
|
||||
|| value.observedAt() == null || value.rawPayloadHash() == null
|
||||
|| !value.rawPayloadHash().matches("[0-9a-f]{64}"))) {
|
||||
throw new IllegalStateException("轨迹供应商返回了无效异常事实");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,42 @@ baiyue:
|
||||
aws-access-key: ${AMAZON_SP_API_AWS_ACCESS_KEY:}
|
||||
aws-secret-key: ${AMAZON_SP_API_AWS_SECRET_KEY:}
|
||||
region: ${AMAZON_SP_API_REGION:}
|
||||
carrier-schedule:
|
||||
matson:
|
||||
enabled: ${MATSON_TRACKING_ENABLED:false}
|
||||
page-url: ${MATSON_TRACKING_PAGE_URL:https://www.matson.com/shipment-tracking.html}
|
||||
query-url: ${MATSON_TRACKING_QUERY_URL:https://www.matson.com/wp-content/plugins/matson-plugin/Api_calls/tracking_booking_proxy.php}
|
||||
connect-timeout: ${MATSON_TRACKING_CONNECT_TIMEOUT:10s}
|
||||
request-timeout: ${MATSON_TRACKING_REQUEST_TIMEOUT:30s}
|
||||
min-request-interval: ${MATSON_TRACKING_MIN_REQUEST_INTERVAL:5s}
|
||||
hede:
|
||||
enabled: ${HEDE_TRACKING_ENABLED:false}
|
||||
query-url: ${HEDE_TRACKING_QUERY_URL:http://elines.hedehk.com/getVBilldynamic}
|
||||
connect-timeout: ${HEDE_TRACKING_CONNECT_TIMEOUT:10s}
|
||||
request-timeout: ${HEDE_TRACKING_REQUEST_TIMEOUT:30s}
|
||||
min-request-interval: ${HEDE_TRACKING_MIN_REQUEST_INTERVAL:5s}
|
||||
sml:
|
||||
enabled: ${SML_TRACKING_ENABLED:false}
|
||||
query-url: ${SML_TRACKING_QUERY_URL:https://esvc.smlines.com/smline/CUP_HOM_3301GS.do}
|
||||
connect-timeout: ${SML_TRACKING_CONNECT_TIMEOUT:10s}
|
||||
request-timeout: ${SML_TRACKING_REQUEST_TIMEOUT:30s}
|
||||
min-request-interval: ${SML_TRACKING_MIN_REQUEST_INTERVAL:5s}
|
||||
zim:
|
||||
enabled: ${ZIM_TRACKING_ENABLED:false}
|
||||
authorization-url: ${ZIM_AUTHORIZATION_URL:https://apigw.zim.com/authorize/v1}
|
||||
tracing-base-url: ${ZIM_TRACING_BASE_URL:https://apigw.zim.com/tracing/v2}
|
||||
client-id: ${ZIM_CLIENT_ID:}
|
||||
client-secret: ${ZIM_CLIENT_SECRET:}
|
||||
subscription-key: ${ZIM_TRACKING_SUBSCRIPTION_KEY:}
|
||||
connect-timeout: ${ZIM_TRACKING_CONNECT_TIMEOUT:10s}
|
||||
request-timeout: ${ZIM_TRACKING_REQUEST_TIMEOUT:30s}
|
||||
max-concurrency: ${ZIM_TRACKING_MAX_CONCURRENCY:10}
|
||||
evergreen:
|
||||
enabled: ${EMC_TRACKING_ENABLED:false}
|
||||
query-url: ${EMC_TRACKING_QUERY_URL:https://www.evergreen-shipping.cn/servlet/TDB1_CargoTracking.do}
|
||||
connect-timeout: ${EMC_TRACKING_CONNECT_TIMEOUT:10s}
|
||||
request-timeout: ${EMC_TRACKING_REQUEST_TIMEOUT:30s}
|
||||
min-request-interval: ${EMC_TRACKING_MIN_REQUEST_INTERVAL:5s}
|
||||
|
||||
management:
|
||||
endpoints:
|
||||
|
||||
@@ -30,10 +30,11 @@ components:
|
||||
trackingNo: {type: string, minLength: 1, maxLength: 128}
|
||||
StandardNode:
|
||||
type: string
|
||||
enum: [DEPARTED_WAREHOUSE, AT_ORIGIN_PORT, DEPARTED_ORIGIN_PORT, IN_TRANSIT, ARRIVED, PICKUP_READY]
|
||||
enum: [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]
|
||||
ExceptionCode: {type: string, enum: [CUSTOMS_HOLD, TERMINAL_HOLD, DOCUMENT_HOLD, UNKNOWN_HOLD]}
|
||||
QueryResult:
|
||||
type: object
|
||||
required: [providerCode, outcome, observations]
|
||||
required: [providerCode, outcome, observations, exceptions]
|
||||
properties:
|
||||
providerCode: {type: string}
|
||||
outcome: {type: string, enum: [SUCCESS, NO_CHANGE, RETRYABLE_FAILURE, PERMANENT_FAILURE, MANUAL_REQUIRED]}
|
||||
@@ -53,3 +54,18 @@ components:
|
||||
locationName: {type: string, nullable: true}
|
||||
sourceReference: {type: string, nullable: true}
|
||||
rawPayloadHash: {type: string, pattern: '^[0-9a-f]{64}$'}
|
||||
exceptions:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required: [providerEventId, providerStatus, exceptionCode, active, observedAt, rawPayloadHash]
|
||||
properties:
|
||||
providerEventId: {type: string, maxLength: 128}
|
||||
providerStatus: {type: string, maxLength: 128}
|
||||
exceptionCode: {$ref: '#/components/schemas/ExceptionCode'}
|
||||
active: {type: boolean}
|
||||
observedAt: {type: string, format: date-time}
|
||||
locationCode: {type: string, nullable: true}
|
||||
locationName: {type: string, nullable: true}
|
||||
sourceReference: {type: string, nullable: true}
|
||||
rawPayloadHash: {type: string, pattern: '^[0-9a-f]{64}$'}
|
||||
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
package com.baiyue.integration.provider;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
class EvergreenCarrierScheduleProviderAdapterTest {
|
||||
private static final String CONTAINER_HTML = """
|
||||
<html><nav>irrelevant navigation</nav><table><tr><td>other</td></tr></table>
|
||||
<table class="tracking"><tr>
|
||||
<th>Method</th><th>地点</th><th>箱号</th><th>货柜动态</th><th>日期</th><th>柜型</th><th>VGM</th><th>船名航次</th>
|
||||
</tr><tr>
|
||||
<td>CY</td><td>NINGBO, CHINA (CN)</td><td> EGSU9319340 </td><td>Loaded (FCL) on vessel</td>
|
||||
<td>AUG-07-2026</td><td>40HQ</td><td></td><td>EVER LOVELY 1232-067E</td>
|
||||
</tr><tr>
|
||||
<td>CY</td><td>NINGBO, CHINA (CN)</td><td>EITU1262610</td><td>Loaded (FCL) on vessel</td>
|
||||
<td>AUG-06-2026</td><td>40HQ</td><td></td><td>EVER LOVELY 1232-067E</td>
|
||||
</tr></table></html>
|
||||
""";
|
||||
private static final String BILL_HTML = """
|
||||
<html><table><tr><td>预计装船日</td><td>AUG-07-2026</td></tr></table></html>
|
||||
""";
|
||||
|
||||
@Test
|
||||
void mapsTheExactContainerRowAndPreservesDayPrecisionWithoutUsingObservedAtAsActual() {
|
||||
var response = new EvergreenTrackingClient.TrackingResponse(CONTAINER_HTML, BILL_HTML, "0000",
|
||||
CONTAINER_HTML + BILL_HTML);
|
||||
LocalDateTime queriedAt = LocalDateTime.of(2026, 8, 21, 15, 30);
|
||||
|
||||
var values = new EvergreenCarrierScheduleProviderAdapter(mock(EvergreenTrackingClient.class))
|
||||
.observations("egsu9319340", response, queriedAt);
|
||||
|
||||
assertThat(values).hasSize(2);
|
||||
assertThat(values).extracting(value -> value.observedAt()).containsOnly(queriedAt);
|
||||
assertThat(values).filteredOn(value -> value.actualAt() != null).singleElement().satisfies(value -> {
|
||||
assertThat(value.standardNode()).isEqualTo("LOADED_ON_VESSEL");
|
||||
assertThat(value.actualAt()).isEqualTo(LocalDateTime.of(2026, 8, 7, 0, 0));
|
||||
assertThat(value.actualAt()).isNotEqualTo(queriedAt);
|
||||
assertThat(value.locationCode()).isEqualTo("CN");
|
||||
assertThat(value.locationName()).isEqualTo("NINGBO, CHINA (CN)");
|
||||
assertThat(value.sourceReference()).contains("DATE:AUG-07-2026", "PRECISION:DAY", "ZONE:Asia/Shanghai")
|
||||
.doesNotContain("000000000000");
|
||||
});
|
||||
assertThat(values).filteredOn(value -> value.estimatedAt() != null).singleElement()
|
||||
.satisfies(value -> assertThat(value.estimatedAt()).isEqualTo(LocalDateTime.of(2026, 8, 7, 0, 0)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void secondConfirmedFixtureRowUsesItsOwnDateAndUnknownStatusesStayUnmapped() {
|
||||
var response = new EvergreenTrackingClient.TrackingResponse(CONTAINER_HTML, "<html><table></table></html>",
|
||||
"0000", CONTAINER_HTML);
|
||||
var values = new EvergreenCarrierScheduleProviderAdapter(mock(EvergreenTrackingClient.class))
|
||||
.observations("EITU1262610", response, LocalDateTime.of(2026, 8, 21, 15, 30));
|
||||
|
||||
assertThat(values).singleElement().satisfies(value -> {
|
||||
assertThat(value.actualAt()).isEqualTo(LocalDateTime.of(2026, 8, 6, 0, 0));
|
||||
assertThat(value.standardNode()).isEqualTo("LOADED_ON_VESSEL");
|
||||
});
|
||||
assertThat(EvergreenCarrierScheduleProviderAdapter.standardNode("Loaded (FCL) on vessel"))
|
||||
.isEqualTo("LOADED_ON_VESSEL");
|
||||
assertThat(EvergreenCarrierScheduleProviderAdapter.standardNode("Discharged from vessel")).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingStableHeaderOrExactContainerRowIsNotParsed() {
|
||||
assertThat(EvergreenCarrierScheduleProviderAdapter.trackingRow(
|
||||
"<table><tr><th>箱号</th><th>日期</th></tr><tr><td>EGSU9319340</td><td>AUG-07-2026</td></tr></table>",
|
||||
"EGSU9319340")).isNull();
|
||||
assertThat(EvergreenCarrierScheduleProviderAdapter.trackingRow(CONTAINER_HTML, "OTHER0000000")).isNull();
|
||||
}
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
package com.baiyue.integration.provider;
|
||||
|
||||
import com.baiyue.integration.config.IntegrationProperties;
|
||||
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class EvergreenTrackingClientTest {
|
||||
private HttpServer server;
|
||||
|
||||
@AfterEach
|
||||
void stopServer() { if (server != null) server.stop(0); }
|
||||
|
||||
@Test
|
||||
void executesContainerThenRelatedBillPostsWithoutExposingTheFullBill() throws Exception {
|
||||
List<String> forms = new ArrayList<>();
|
||||
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
server.createContext("/track", exchange -> {
|
||||
String form = body(exchange); forms.add(form);
|
||||
String html = form.startsWith("TYPE=CNTR")
|
||||
? "<html><input value='000000000000' name='bl_no'><table></table></html>"
|
||||
: "<html><table><tr><td>预计装船日</td><td>AUG-07-2026</td></tr></table></html>";
|
||||
respond(exchange, 200, html);
|
||||
});
|
||||
server.start();
|
||||
|
||||
var result = client().query(new QueryRequest("EMC", "CONTAINER_NO", "EGSU9319340"));
|
||||
|
||||
assertThat(forms).containsExactly("TYPE=CNTR&CNTR=EGSU9319340", "TYPE=BL&BL=000000000000");
|
||||
assertThat(result.billSuffix()).isEqualTo("0000");
|
||||
assertThat(result.toString()).doesNotContain("000000000000");
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractsTheRelatedBillFromHiddenInputOrDispInfoLink() {
|
||||
assertThat(EvergreenTrackingClient.relatedBill(
|
||||
"<input VALUE=\"000000000000\" NAME=\"bl_no\">" )).isEqualTo("000000000000");
|
||||
assertThat(EvergreenTrackingClient.relatedBill(
|
||||
"<a href=\"javascript:GetDispInfo('000000000000')\">detail</a>" )).isEqualTo("000000000000");
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonHtmlAndMissingRelatedBillBecomeTraceableManualFailures() throws Exception {
|
||||
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
server.createContext("/track", exchange -> respond(exchange, 200, "{\"unexpected\":true}"));
|
||||
server.start();
|
||||
assertThatThrownBy(() -> client().query(new QueryRequest("EMC", "CONTAINER_NO", "EGSU9319340")))
|
||||
.isInstanceOfSatisfying(EvergreenTrackingClient.EvergreenTrackingException.class,
|
||||
value -> assertThat(value.code()).isEqualTo("EMC_CONTAINER_NON_HTML"));
|
||||
|
||||
server.stop(0);
|
||||
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
server.createContext("/track", exchange -> respond(exchange, 200, "<html><table></table></html>"));
|
||||
server.start();
|
||||
assertThatThrownBy(() -> client().query(new QueryRequest("EMC", "CONTAINER_NO", "EGSU9319340")))
|
||||
.isInstanceOfSatisfying(EvergreenTrackingClient.EvergreenTrackingException.class,
|
||||
value -> assertThat(value.code()).isEqualTo("EMC_RELATED_BL_MISSING"));
|
||||
}
|
||||
|
||||
private EvergreenTrackingClient client() {
|
||||
var evergreen = new IntegrationProperties.CarrierSchedule.Evergreen(true,
|
||||
URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/track"),
|
||||
Duration.ofSeconds(2), Duration.ofSeconds(2), Duration.ZERO);
|
||||
EvergreenTrackingClient client = new EvergreenTrackingClient();
|
||||
ReflectionTestUtils.setField(client, "properties", new IntegrationProperties(null, null,
|
||||
new IntegrationProperties.CarrierSchedule(null, null, null, null, evergreen)));
|
||||
client.initialize(); return client;
|
||||
}
|
||||
private static String body(HttpExchange exchange) throws IOException {
|
||||
return new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
|
||||
}
|
||||
private static void respond(HttpExchange exchange, int status, String body) throws IOException {
|
||||
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
|
||||
exchange.getResponseHeaders().add("Content-Type", "text/html; charset=utf-8");
|
||||
exchange.sendResponseHeaders(status, bytes.length); exchange.getResponseBody().write(bytes); exchange.close();
|
||||
}
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
package com.baiyue.integration.provider;
|
||||
|
||||
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest;
|
||||
import com.baiyue.integration.provider.HedeTrackingClient.TrackingResponse;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class HedeCarrierScheduleProviderAdapterTest {
|
||||
@Test
|
||||
void parsesConfirmedMainTableColumnsAndDoesNotTurnEtaIntoAnActualNode() throws Exception {
|
||||
String html = row("XYLU8251457", "2026-08-20 00:00", "2026-09-09 00:00",
|
||||
"", "", "", "", "", "");
|
||||
|
||||
var rows = HedeCarrierScheduleProviderAdapter.rows(html);
|
||||
HedeTrackingClient client = mock(HedeTrackingClient.class);
|
||||
HedeCarrierScheduleProviderAdapter adapter = new HedeCarrierScheduleProviderAdapter(client);
|
||||
|
||||
assertThat(rows).singleElement().satisfies(value -> {
|
||||
assertThat(value.containerNo()).isEqualTo("XYLU8251457");
|
||||
assertThat(value.vessel()).isEqualTo("POSEN");
|
||||
assertThat(value.voyage()).isEqualTo("2607W");
|
||||
assertThat(value.estimatedDeparture()).isEqualTo("2026-08-20 00:00");
|
||||
assertThat(value.estimatedArrival()).isEqualTo("2026-09-09 00:00");
|
||||
});
|
||||
assertThat(adapter.observations("XYLU8251457", html)).extracting(value -> value.standardNode())
|
||||
.containsExactlyInAnyOrder("DEPARTED_ORIGIN_PORT", "ARRIVED_DESTINATION_PORT");
|
||||
assertThat(adapter.observations("XYLU8251457", html)).allSatisfy(value -> {
|
||||
assertThat(value.estimatedAt()).isNotNull();
|
||||
assertThat(value.actualAt()).isNull();
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapsOnlyActualHedeMilestonesForTheRequestedContainer() {
|
||||
String html = row("FFAU4879673", "2026-08-05 04:24", "2026-08-22 00:00",
|
||||
"2026-07-30 21:31", "2026-08-01 15:55", "2026-08-04 19:45", "", "", "");
|
||||
HedeTrackingClient client = mock(HedeTrackingClient.class);
|
||||
when(client.configured()).thenReturn(true);
|
||||
when(client.query(new QueryRequest("HEDE", "CONTAINER_NO", "FFAU4879673")))
|
||||
.thenReturn(new TrackingResponse(html));
|
||||
HedeCarrierScheduleProviderAdapter adapter = new HedeCarrierScheduleProviderAdapter(client);
|
||||
|
||||
var result = adapter.query(new QueryRequest("HEDE", "CONTAINER_NO", "FFAU4879673"));
|
||||
|
||||
assertThat(result.outcome()).isEqualTo("SUCCESS");
|
||||
assertThat(result.observations()).extracting(value -> value.standardNode())
|
||||
.containsExactlyInAnyOrder("DEPARTED_ORIGIN_PORT", "ARRIVED_DESTINATION_PORT", "AT_ORIGIN_PORT",
|
||||
"LOADED_ON_VESSEL");
|
||||
assertThat(result.observations()).allSatisfy(value -> {
|
||||
assertThat(value.providerEventId()).hasSize(64);
|
||||
assertThat(value.rawPayloadHash()).hasSize(64);
|
||||
assertThat(value.sourceReference()).contains("FFAU4879673").contains("POSEN/2607W");
|
||||
});
|
||||
assertThat(result.observations()).extracting(value -> value.observedAt())
|
||||
.containsOnly(result.observations().get(0).observedAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void actualEmptyReturnIsMappedPrecisely() throws Exception {
|
||||
String html = row("FFAU4879673", "2026-08-05 04:24", "2026-08-22 00:00",
|
||||
"2026-07-30 21:31", "2026-08-01 15:55", "2026-08-04 19:45", "", "",
|
||||
"2026-08-28 08:00");
|
||||
HedeTrackingClient client = mock(HedeTrackingClient.class);
|
||||
var result = new HedeCarrierScheduleProviderAdapter(client).observations("FFAU4879673", html);
|
||||
assertThat(result).filteredOn(value -> "EMPTY_RETURNED".equals(value.standardNode()))
|
||||
.singleElement().satisfies(value -> {
|
||||
assertThat(value.actualAt()).isNotNull();
|
||||
assertThat(value.estimatedAt()).isNull();
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void refusesAmbiguousBillLevelAutomation() {
|
||||
HedeTrackingClient client = mock(HedeTrackingClient.class);
|
||||
HedeCarrierScheduleProviderAdapter adapter = new HedeCarrierScheduleProviderAdapter(client);
|
||||
|
||||
var result = adapter.query(new QueryRequest("HEDE", "BILL_OF_LADING", "HDE123"));
|
||||
|
||||
assertThat(result.outcome()).isEqualTo("PERMANENT_FAILURE");
|
||||
assertThat(result.errorCode()).isEqualTo("HEDE_CONTAINER_REQUIRED");
|
||||
}
|
||||
|
||||
private static String row(String container, String etd, String eta, String emptyOut, String fullIn,
|
||||
String loaded, String discharged, String fullOut, String emptyReturned) {
|
||||
return "<table><tbody><tr class=\"read-tr\">"
|
||||
+ td("***") + td(container) + td("POSEN") + td("2607W") + td(etd) + td(eta)
|
||||
+ td(emptyOut) + td(fullIn) + td(loaded) + td(discharged) + td(fullOut) + td(emptyReturned)
|
||||
+ td("查看") + "</tr></tbody></table>";
|
||||
}
|
||||
|
||||
private static String td(String value) { return "<td>" + value + "</td>"; }
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package com.baiyue.integration.provider;
|
||||
|
||||
import com.baiyue.integration.config.IntegrationProperties;
|
||||
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class HedeTrackingClientTest {
|
||||
private HttpServer server;
|
||||
|
||||
@AfterEach
|
||||
void stopServer() { if (server != null) server.stop(0); }
|
||||
|
||||
@Test
|
||||
void postsTheConfirmedHedeFormAsUtf8() throws Exception {
|
||||
AtomicReference<String> postedBody = new AtomicReference<>();
|
||||
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
server.createContext("/getVBilldynamic", exchange -> {
|
||||
postedBody.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8));
|
||||
respond(exchange, 200, "<html><table><tr class=\"read-tr\"><td>***</td>"
|
||||
+ "<td>XYLU8251457</td></tr></table></html>");
|
||||
});
|
||||
server.start();
|
||||
|
||||
var hede = new IntegrationProperties.CarrierSchedule.Hede(true,
|
||||
URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/getVBilldynamic"),
|
||||
Duration.ofSeconds(2), Duration.ofSeconds(2), Duration.ZERO);
|
||||
HedeTrackingClient client = new HedeTrackingClient();
|
||||
ReflectionTestUtils.setField(client, "properties", new IntegrationProperties(null, null,
|
||||
new IntegrationProperties.CarrierSchedule(null, hede, null, null, null)));
|
||||
client.initialize();
|
||||
|
||||
var response = client.query(new QueryRequest("HEDE", "CONTAINER_NO", "XYLU8251457"));
|
||||
|
||||
assertThat(response.html()).contains("XYLU8251457");
|
||||
assertThat(postedBody.get()).isEqualTo(
|
||||
"billno=&cntr=XYLU8251457&selecttype=1&resultlist=1¶mtype=0");
|
||||
}
|
||||
|
||||
private static void respond(HttpExchange exchange, int status, String body) throws IOException {
|
||||
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
|
||||
exchange.getResponseHeaders().add("Content-Type", "text/html; charset=UTF-8");
|
||||
exchange.sendResponseHeaders(status, bytes.length);
|
||||
exchange.getResponseBody().write(bytes);
|
||||
exchange.close();
|
||||
}
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
package com.baiyue.integration.provider;
|
||||
|
||||
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest;
|
||||
import com.baiyue.integration.provider.MatsonTrackingClient.TrackingResponse;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.time.Instant;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class MatsonCarrierScheduleProviderAdapterTest {
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Test
|
||||
void realMatu4646247FixtureProducesTwoActualsAndEtaOnly() throws Exception {
|
||||
String raw = """
|
||||
{"bookingNumber":"MATSON-REAL","containerNumber":"MATU464624","checkDigit":"7",
|
||||
"originPort":"LONG BEACH","destPort":"HONOLULU","originCode":"LAX","destCode":"HIL",
|
||||
"loadPort":"LONG BEACH","dischargePort":"HONOLULU","vesselETA":"08/25/26","events":[
|
||||
{"date":%d,"location":"LONG BEACH","status":"INGATE"},
|
||||
{"date":%d,"location":"LONG BEACH","status":"LOAD TO VESSEL"}]}
|
||||
""".formatted(Instant.parse("2026-08-18T19:15:00Z").toEpochMilli(),
|
||||
Instant.parse("2026-08-18T22:12:00Z").toEpochMilli());
|
||||
MatsonTrackingClient client = mock(MatsonTrackingClient.class);
|
||||
when(client.configured()).thenReturn(true);
|
||||
when(client.query(new QueryRequest("MATSON", "CONTAINER_NO", "MATU4646247")))
|
||||
.thenReturn(new TrackingResponse(objectMapper.readTree(raw), raw));
|
||||
|
||||
var result = new MatsonCarrierScheduleProviderAdapter(client)
|
||||
.query(new QueryRequest("MATSON", "CONTAINER_NO", "MATU4646247"));
|
||||
|
||||
assertThat(result.observations()).extracting(value -> value.standardNode())
|
||||
.containsExactlyInAnyOrder("AT_ORIGIN_PORT", "LOADED_ON_VESSEL", "ARRIVED_DESTINATION_PORT");
|
||||
assertThat(result.observations()).filteredOn(value -> value.actualAt() != null).hasSize(2);
|
||||
assertThat(result.observations()).filteredOn(value -> value.estimatedAt() != null).singleElement();
|
||||
assertThat(result.observations()).extracting(value -> value.observedAt())
|
||||
.containsOnly(result.observations().get(0).observedAt());
|
||||
assertThat(result.exceptions()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void conservativelyMapsKnownMatsonEventsWithoutTreatingLoadAsDeparture() throws Exception {
|
||||
String raw = """
|
||||
{"bookingNumber":"9974749","containerNumber":"MATU459895","checkDigit":"6",
|
||||
"originPort":"NINGBO","destPort":"LONG BEACH","originCode":"NGB","destCode":"LAX",
|
||||
"vesselETA":"08/24/26",
|
||||
"loadPort":"NINGBO","dischargePort":"LONG BEACH","events":[
|
||||
{"date":1787160480000,"location":"","status":"CONTAINER ON HOLD - Customs Clearance"},
|
||||
{"date":1787033640000,"location":"NINGBO","status":"LOAD TO VESSEL"},
|
||||
{"date":1786940100000,"location":"NINGBO","status":"INGATE"}]}
|
||||
""";
|
||||
MatsonTrackingClient client = mock(MatsonTrackingClient.class);
|
||||
when(client.configured()).thenReturn(true);
|
||||
when(client.query(new QueryRequest("MATSON", "CONTAINER_NO", "MATU4598956")))
|
||||
.thenReturn(new TrackingResponse(objectMapper.readTree(raw), raw));
|
||||
MatsonCarrierScheduleProviderAdapter adapter = new MatsonCarrierScheduleProviderAdapter(client);
|
||||
|
||||
var result = adapter.query(new QueryRequest("MATSON", "CONTAINER_NO", "MATU4598956"));
|
||||
|
||||
assertThat(result.outcome()).isEqualTo("SUCCESS");
|
||||
assertThat(result.observations()).extracting(value -> value.standardNode())
|
||||
.containsExactlyInAnyOrder("AT_ORIGIN_PORT", "LOADED_ON_VESSEL", "ARRIVED_DESTINATION_PORT");
|
||||
assertThat(result.observations()).filteredOn(value -> "ARRIVED_DESTINATION_PORT".equals(value.standardNode()))
|
||||
.singleElement().satisfies(value -> {
|
||||
assertThat(value.estimatedAt()).isNotNull();
|
||||
assertThat(value.actualAt()).isNull();
|
||||
});
|
||||
assertThat(result.exceptions()).singleElement().satisfies(value -> {
|
||||
assertThat(value.exceptionCode()).isEqualTo("CUSTOMS_HOLD");
|
||||
assertThat(value.active()).isTrue();
|
||||
assertThat(value.providerStatus()).isEqualTo("CONTAINER ON HOLD - Customs Clearance");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapsDestinationDischargeAndAvailabilityWithoutUsingEtaAsActualArrival() {
|
||||
assertThat(MatsonCarrierScheduleProviderAdapter.standardNode("LOAD TO VESSEL"))
|
||||
.isEqualTo("LOADED_ON_VESSEL");
|
||||
assertThat(MatsonCarrierScheduleProviderAdapter.standardNode("DISCHARGE FROM VESSEL"))
|
||||
.isEqualTo("DISCHARGED_FROM_VESSEL");
|
||||
assertThat(MatsonCarrierScheduleProviderAdapter.standardNode("AVAILABLE FOR PICKUP"))
|
||||
.isEqualTo("PICKUP_READY");
|
||||
assertThat(MatsonCarrierScheduleProviderAdapter.standardNode("OUTGATE")).isEqualTo("PICKED_UP");
|
||||
assertThat(MatsonCarrierScheduleProviderAdapter.standardNode("EMPTY RETURNED"))
|
||||
.isEqualTo("EMPTY_RETURNED");
|
||||
assertThat(MatsonCarrierScheduleProviderAdapter.standardNode("PICKUP APPOINTMENT"))
|
||||
.isNull();
|
||||
assertThat(MatsonCarrierScheduleProviderAdapter.standardNode("CONTAINER ON HOLD - Customs Clearance"))
|
||||
.isNull();
|
||||
assertThat(MatsonCarrierScheduleProviderAdapter.exceptionCode(
|
||||
"CONTAINER ON HOLD - Customs Clearance")).isEqualTo("CUSTOMS_HOLD");
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyBusinessObjectReturnsNoFacts() throws Exception {
|
||||
String raw = "{}";
|
||||
MatsonTrackingClient client = mock(MatsonTrackingClient.class);
|
||||
when(client.configured()).thenReturn(true);
|
||||
when(client.query(new QueryRequest("MATSON", "CONTAINER_NO", "MATU5164086")))
|
||||
.thenReturn(new TrackingResponse(objectMapper.readTree(raw), raw));
|
||||
var result = new MatsonCarrierScheduleProviderAdapter(client)
|
||||
.query(new QueryRequest("MATSON", "CONTAINER_NO", "MATU5164086"));
|
||||
assertThat(result.outcome()).isEqualTo("NO_CHANGE");
|
||||
assertThat(result.observations()).isEmpty();
|
||||
assertThat(result.exceptions()).isEmpty();
|
||||
}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
package com.baiyue.integration.provider;
|
||||
|
||||
import com.baiyue.integration.config.IntegrationProperties;
|
||||
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class MatsonTrackingClientTest {
|
||||
private HttpServer server;
|
||||
|
||||
@AfterEach
|
||||
void stopServer() {
|
||||
if (server != null) server.stop(0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void obtainsDynamicTtrAndCookieBeforePostingAjaxForm() throws Exception {
|
||||
AtomicReference<String> postedBody = new AtomicReference<>();
|
||||
AtomicReference<String> postedCookie = new AtomicReference<>();
|
||||
AtomicReference<String> requestedWith = new AtomicReference<>();
|
||||
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
server.createContext("/page", exchange -> {
|
||||
exchange.getResponseHeaders().add("Set-Cookie", "matson-session=abc123; Path=/");
|
||||
respond(exchange, 200, "<input type=\"hidden\" id=\"ttr\" name=\"ttr\" value=\"dynamic123\" />");
|
||||
});
|
||||
server.createContext("/query", exchange -> {
|
||||
postedBody.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8));
|
||||
postedCookie.set(exchange.getRequestHeaders().getFirst("Cookie"));
|
||||
requestedWith.set(exchange.getRequestHeaders().getFirst("X-Requested-With"));
|
||||
respond(exchange, 200, "{\"containerNumber\":\"MATU459895\",\"checkDigit\":\"6\",\"events\":[]}");
|
||||
});
|
||||
server.start();
|
||||
|
||||
int port = server.getAddress().getPort();
|
||||
var matson = new IntegrationProperties.CarrierSchedule.Matson(true,
|
||||
URI.create("http://127.0.0.1:" + port + "/page"),
|
||||
URI.create("http://127.0.0.1:" + port + "/query"),
|
||||
Duration.ofSeconds(2), Duration.ofSeconds(2), Duration.ZERO);
|
||||
MatsonTrackingClient client = new MatsonTrackingClient();
|
||||
ReflectionTestUtils.setField(client, "properties", new IntegrationProperties(null, null,
|
||||
new IntegrationProperties.CarrierSchedule(matson, null, null, null, null)));
|
||||
ReflectionTestUtils.setField(client, "objectMapper", new ObjectMapper());
|
||||
client.initialize();
|
||||
|
||||
var result = client.query(new QueryRequest("MATSON", "CONTAINER_NO", "MATU4598956"));
|
||||
|
||||
assertThat(result.payload().path("containerNumber").asText()).isEqualTo("MATU459895");
|
||||
assertThat(postedBody.get()).contains("container_trackingnumber=MATU4598956")
|
||||
.contains("trackingVal=tckconta").contains("ttr=dynamic123");
|
||||
assertThat(postedCookie.get()).contains("matson-session=abc123");
|
||||
assertThat(requestedWith.get()).isEqualTo("XMLHttpRequest");
|
||||
}
|
||||
|
||||
private static void respond(HttpExchange exchange, int status, String body) throws IOException {
|
||||
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
|
||||
exchange.sendResponseHeaders(status, bytes.length);
|
||||
exchange.getResponseBody().write(bytes);
|
||||
exchange.close();
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package com.baiyue.integration.provider;
|
||||
|
||||
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class SmlCarrierScheduleProviderAdapterTest {
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Test
|
||||
void sevenSampleBoundaryUsesActTypeAndLeavesFitmdoUnmapped() throws Exception {
|
||||
String timeline = """
|
||||
{"TRANS_RESULT_KEY":"S","count":9,"events":[
|
||||
{"actCd":"FOTMAD","actTpCd":"A","eventDt":"2026-08-01 15:55"},
|
||||
{"actCd":"FLVMLO","actTpCd":"A","eventDt":"2026-08-04 19:45"},
|
||||
{"actCd":"FLVMDO","actTpCd":"A","eventDt":"2026-08-07 17:00"},
|
||||
{"actCd":"FUVMAD","actTpCd":"E","eventDt":"2026-08-23 14:00"},
|
||||
{"actCd":"FUVMBD","actTpCd":"E","eventDt":"2026-08-23 16:00"},
|
||||
{"actCd":"FUVMUD","actTpCd":"E","eventDt":"2026-08-24 08:00"},
|
||||
{"actCd":"FITMDO","actTpCd":"E","eventDt":"2026-08-25 08:00"},
|
||||
{"actCd":"MITYAD","actTpCd":"E","eventDt":"2026-09-01 08:00"},
|
||||
{"actCd":"MOTYDO","actTpCd":"A","eventDt":"2026-07-28 08:00"}]}
|
||||
""";
|
||||
SmlTrackingClient client = mock(SmlTrackingClient.class);
|
||||
when(client.configured()).thenReturn(true);
|
||||
var response = new SmlTrackingClient.TrackingResponse("SMLBKG", "COP1",
|
||||
objectMapper.readTree(timeline), objectMapper.createObjectNode(), objectMapper.createObjectNode(), timeline);
|
||||
when(client.query(new QueryRequest("SML", "CONTAINER_NO", "SMCU7019890"))).thenReturn(response);
|
||||
|
||||
var result = new SmlCarrierScheduleProviderAdapter(client)
|
||||
.query(new QueryRequest("SML", "CONTAINER_NO", "SMCU7019890"));
|
||||
|
||||
assertThat(result.observations()).hasSize(7);
|
||||
assertThat(result.observations()).filteredOn(value -> value.actualAt() != null)
|
||||
.extracting(value -> value.standardNode())
|
||||
.containsExactlyInAnyOrder("AT_ORIGIN_PORT", "LOADED_ON_VESSEL", "DEPARTED_ORIGIN_PORT");
|
||||
assertThat(result.observations()).filteredOn(value -> value.estimatedAt() != null).hasSize(4);
|
||||
assertThat(result.observations()).extracting(value -> value.observedAt())
|
||||
.containsOnly(result.observations().get(0).observedAt());
|
||||
assertThat(result.observations()).extracting(value -> value.providerNodeCode())
|
||||
.doesNotContain("FITMDO", "MOTYDO");
|
||||
}
|
||||
|
||||
@Test
|
||||
void onlyActualMityadCanBecomeActualEmptyReturn() throws Exception {
|
||||
String timeline = """
|
||||
{"events":[
|
||||
{"actCd":"MITYAD","actTpCd":"E","eventDt":"2026-09-01 08:00"},
|
||||
{"actCd":"MITYAD","actTpCd":"A","eventDt":"2026-09-02 09:00"}]}
|
||||
""";
|
||||
var response = new SmlTrackingClient.TrackingResponse("BKG", "COP", objectMapper.readTree(timeline),
|
||||
objectMapper.createObjectNode(), objectMapper.createObjectNode(), timeline);
|
||||
var values = new SmlCarrierScheduleProviderAdapter(mock(SmlTrackingClient.class))
|
||||
.observations("SMCU7019890", response);
|
||||
assertThat(values).filteredOn(value -> value.actualAt() != null).singleElement()
|
||||
.satisfies(value -> assertThat(value.standardNode()).isEqualTo("EMPTY_RETURNED"));
|
||||
assertThat(values).filteredOn(value -> value.estimatedAt() != null).singleElement();
|
||||
}
|
||||
|
||||
@Test
|
||||
void ambiguousFitmdoAndCargoEmptyReleaseStayUnmapped() {
|
||||
assertThat(SmlCarrierScheduleProviderAdapter.standardNode("FITMDO")).isNull();
|
||||
assertThat(SmlCarrierScheduleProviderAdapter.standardNode("MOTYDO")).isNull();
|
||||
assertThat(SmlCarrierScheduleProviderAdapter.standardNode("FUVMUD"))
|
||||
.isEqualTo("DISCHARGED_FROM_VESSEL");
|
||||
}
|
||||
}
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
package com.baiyue.integration.provider;
|
||||
|
||||
import com.baiyue.integration.config.IntegrationProperties;
|
||||
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class SmlTrackingClientTest {
|
||||
private HttpServer server;
|
||||
|
||||
@AfterEach
|
||||
void stopServer() { if (server != null) server.stop(0); }
|
||||
|
||||
@Test
|
||||
void executesTheConfirmed122125124126Chain() throws Exception {
|
||||
List<String> forms = new ArrayList<>();
|
||||
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
server.createContext("/track", exchange -> {
|
||||
String form = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
|
||||
forms.add(form);
|
||||
String body = form.contains("f_cmd=122")
|
||||
? "{\"TRANS_RESULT_KEY\":\"S\",\"count\":1,\"data\":[{\"bkgNo\":\"BKG1\",\"copNo\":\"COP1\"}]}"
|
||||
: form.contains("f_cmd=125")
|
||||
? "{\"TRANS_RESULT_KEY\":\"S\",\"count\":1,\"events\":[{\"actCd\":\"FOTMAD\",\"actTpCd\":\"A\",\"eventDt\":\"2026-08-01 15:55\"}]}"
|
||||
: "{\"TRANS_RESULT_KEY\":\"S\",\"count\":0}";
|
||||
respond(exchange, 200, body);
|
||||
});
|
||||
server.start();
|
||||
|
||||
var result = new SmlCarrierScheduleProviderAdapter(client())
|
||||
.query(new QueryRequest("SML", "CONTAINER_NO", "SMCU7019890"));
|
||||
|
||||
assertThat(result.outcome()).isEqualTo("SUCCESS");
|
||||
assertThat(result.observations()).singleElement()
|
||||
.satisfies(value -> assertThat(value.sourceReference()).isEqualTo("SML:BKG1:COP1"));
|
||||
assertThat(forms).hasSize(4);
|
||||
assertThat(forms.get(0)).contains("f_cmd=122").contains("cntr_no=SMCU7019890");
|
||||
assertThat(forms.get(1)).contains("f_cmd=125").contains("bkg_no=BKG1").contains("cop_no=COP1");
|
||||
assertThat(forms.get(2)).contains("f_cmd=124").contains("bkg_no=BKG1");
|
||||
assertThat(forms.get(3)).contains("f_cmd=126").contains("cntr_no=SMCU7019890");
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonJsonAndMissingBusinessKeysFailClosed() throws Exception {
|
||||
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
server.createContext("/track", exchange -> respond(exchange, 200, "<html>schema changed</html>"));
|
||||
server.start();
|
||||
assertThatThrownBy(() -> client().query(new QueryRequest("SML", "CONTAINER_NO", "SMCU7019890")))
|
||||
.isInstanceOfSatisfying(SmlTrackingClient.SmlTrackingException.class,
|
||||
value -> assertThat(value.code()).contains("NON_JSON"));
|
||||
|
||||
server.stop(0);
|
||||
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
server.createContext("/track", exchange -> respond(exchange, 200,
|
||||
"{\"TRANS_RESULT_KEY\":\"S\",\"count\":1}"));
|
||||
server.start();
|
||||
assertThatThrownBy(() -> client().query(new QueryRequest("SML", "CONTAINER_NO", "SMCU7019890")))
|
||||
.isInstanceOfSatisfying(SmlTrackingClient.SmlTrackingException.class,
|
||||
value -> assertThat(value.code()).isEqualTo("SML_BUSINESS_KEYS_MISSING"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void providerFailureAndZeroCountAreTraceableNotFoundResults() throws Exception {
|
||||
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
server.createContext("/track", exchange -> respond(exchange, 200,
|
||||
"{\"TRANS_RESULT_KEY\":\"F\",\"count\":1}"));
|
||||
server.start();
|
||||
assertThatThrownBy(() -> client().query(new QueryRequest("SML", "CONTAINER_NO", "SMCU7019890")))
|
||||
.isInstanceOfSatisfying(SmlTrackingClient.SmlTrackingException.class,
|
||||
value -> assertThat(value.code()).isEqualTo("SML_BUSINESS_FAILED"));
|
||||
|
||||
server.stop(0);
|
||||
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
server.createContext("/track", exchange -> respond(exchange, 200,
|
||||
"{\"TRANS_RESULT_KEY\":\"S\",\"count\":0}"));
|
||||
server.start();
|
||||
assertThatThrownBy(() -> client().query(new QueryRequest("SML", "CONTAINER_NO", "SMCU7019890")))
|
||||
.isInstanceOfSatisfying(SmlTrackingClient.SmlTrackingException.class,
|
||||
value -> assertThat(value.code()).isEqualTo("SML_NOT_FOUND"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonSuccessHttpAndTimeoutRemainTraceableRetryableFailures() throws Exception {
|
||||
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
server.createContext("/track", exchange -> respond(exchange, 503, "{}"));
|
||||
server.start();
|
||||
assertThatThrownBy(() -> client().query(new QueryRequest("SML", "CONTAINER_NO", "SMCU7019890")))
|
||||
.isInstanceOfSatisfying(SmlTrackingClient.SmlTrackingException.class, value -> {
|
||||
assertThat(value.code()).isEqualTo("SML_LOOKUP_HTTP_503");
|
||||
assertThat(value.retryable()).isTrue();
|
||||
});
|
||||
|
||||
server.stop(0);
|
||||
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
server.createContext("/track", exchange -> {
|
||||
try { Thread.sleep(200); }
|
||||
catch (InterruptedException exception) { Thread.currentThread().interrupt(); }
|
||||
respond(exchange, 200, "{}");
|
||||
});
|
||||
server.start();
|
||||
assertThatThrownBy(() -> client(Duration.ofMillis(50))
|
||||
.query(new QueryRequest("SML", "CONTAINER_NO", "SMCU7019890")))
|
||||
.isInstanceOfSatisfying(SmlTrackingClient.SmlTrackingException.class, value -> {
|
||||
assertThat(value.code()).isEqualTo("SML_LOOKUP_TIMEOUT");
|
||||
assertThat(value.retryable()).isTrue();
|
||||
});
|
||||
}
|
||||
|
||||
private SmlTrackingClient client() {
|
||||
return client(Duration.ofSeconds(2));
|
||||
}
|
||||
|
||||
private SmlTrackingClient client(Duration requestTimeout) {
|
||||
var sml = new IntegrationProperties.CarrierSchedule.Sml(true,
|
||||
URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/track"),
|
||||
Duration.ofSeconds(2), requestTimeout, Duration.ZERO);
|
||||
SmlTrackingClient client = new SmlTrackingClient();
|
||||
ReflectionTestUtils.setField(client, "properties", new IntegrationProperties(null, null,
|
||||
new IntegrationProperties.CarrierSchedule(null, null, sml, null, null)));
|
||||
ReflectionTestUtils.setField(client, "objectMapper", new ObjectMapper());
|
||||
client.initialize();
|
||||
return client;
|
||||
}
|
||||
|
||||
private static void respond(HttpExchange exchange, int status, String body) throws IOException {
|
||||
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
|
||||
exchange.getResponseHeaders().add("Content-Type", "application/json; charset=UTF-8");
|
||||
exchange.sendResponseHeaders(status, bytes.length);
|
||||
exchange.getResponseBody().write(bytes);
|
||||
exchange.close();
|
||||
}
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
package com.baiyue.integration.provider;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
class ZimCarrierScheduleProviderAdapterTest {
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Test
|
||||
void v2FixtureSelectsOnlyTheExactContainerAndSeparatesActualFromPlans() throws Exception {
|
||||
String fixture = """
|
||||
{"bookingReference":" BK-STRING-001 ","blReference":" BL-STRING-001 ",
|
||||
"finalETA":{"etaValue":"2023-09-03T08:00:00","etaValueDate":"2023-09-03","podInd":"ETA"},
|
||||
"routeDetails":[
|
||||
{"sailingDateTz":"2023-08-10T12:00:00","arrivalDateTz":"2023-08-17T06:00:00","portCode":"CNNGB","port":"Ningbo","country":"CN","vesselName":"V1","voyage":"001E","leg":"1"},
|
||||
{"sailingDateDT":"2023-08-18T10:00:00","arrivalDateDT":"2023-09-02T04:00:00","portCode":"USNYC","port":"New York","country":"US","vesselName":"V2","voyage":"002E","leg":"2"},
|
||||
{"portCode":"USNYC","port":"New York","country":"US"}],
|
||||
"bkBlDetails":{"consContainerList":[
|
||||
{"containerNumber":"SIBLING000001 ","containerEventsList":[
|
||||
{"activityDateTz":"2023-08-01T01:00:00","eventName":"Export Gate-In to Port of Loading","portCode":"CNNGB"}]},
|
||||
{"containerNumber":" zcsu7881970 ","containerEventsList":[
|
||||
{"activityDateTz":"2023-08-01T01:00:00","eventName":"Empty container dispatched from Port of Loading to Customer","portCode":"CNNGB"},
|
||||
{"activityDateTz":"2023-08-10T02:00:00","eventName":"Export gate-in from Customer to Port of Loading","portCode":"CNNGB","port":"Ningbo","country":"CN"},
|
||||
{"activityDateTz":"2023-08-18T11:00:00","eventName":"Vessel departure from first Transshipment Port to next Transshipment Port","portCode":"SGSIN","port":"Singapore","country":"SG","vesselName":"V2","voyage":"002E","leg":"2"},
|
||||
{"activityDateTz":"2023-08-19T12:00:00","eventName":"Container was loaded at Transshipment Port to Transshipment Port","portCode":"SGSIN","port":"Singapore","country":"SG","vesselName":"V2","voyage":"002E","leg":"2"},
|
||||
{"activityDate":"2023-08-20T12:00:00","eventName":"Container was loaded at Transshipment Port to Transshipment Port","portCode":"SGSIN","port":"Singapore","country":"SG","vesselName":"V2","voyage":"002E","leg":"2"},
|
||||
{"activityDateTz":"2023-08-21T12:00:00","eventName":"Unknown Future Event","portCode":"SGSIN"}]}]}}
|
||||
""";
|
||||
var payload = objectMapper.readValue(fixture, ZimTrackingClient.TracingPayload.class);
|
||||
var response = new ZimTrackingClient.TrackingResponse(payload, fixture);
|
||||
LocalDateTime queriedAt = LocalDateTime.of(2026, 8, 21, 12, 0);
|
||||
|
||||
var values = new ZimCarrierScheduleProviderAdapter(mock(ZimTrackingClient.class))
|
||||
.observations("ZCSU7881970", response, queriedAt);
|
||||
|
||||
assertThat(values).hasSize(7);
|
||||
assertThat(values).extracting(value -> value.observedAt()).containsOnly(queriedAt);
|
||||
assertThat(values).filteredOn(value -> value.actualAt() != null)
|
||||
.extracting(value -> value.standardNode())
|
||||
.containsExactlyInAnyOrder("AT_ORIGIN_PORT", "IN_TRANSIT", "LOADED_ON_VESSEL", "LOADED_ON_VESSEL");
|
||||
assertThat(values).filteredOn(value -> value.estimatedAt() != null)
|
||||
.extracting(value -> value.standardNode())
|
||||
.containsExactlyInAnyOrder("DEPARTED_ORIGIN_PORT", "ARRIVED_DESTINATION_PORT",
|
||||
"ARRIVED_DESTINATION_PORT");
|
||||
assertThat(values).noneMatch(value -> LocalDateTime.of(2023, 8, 1, 1, 0).equals(value.actualAt()));
|
||||
assertThat(values).extracting(value -> value.providerNodeCode())
|
||||
.doesNotContain("EMPTY CONTAINER DISPATCHED FROM PORT OF LOADING TO CUSTOMER", "UNKNOWN FUTURE EVENT");
|
||||
assertThat(values).allSatisfy(value -> assertThat(value.sourceReference())
|
||||
.isEqualTo("ZIM:CONTAINER:ZCSU7881970|BOOKING:BK-STRING-001|BL:BL-STRING-001"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void eventNameMappingIsAnExactExpandableWhitelist() {
|
||||
assertThat(ZimCarrierScheduleProviderAdapter.standardNode(
|
||||
"Export Gate-In to Port of Loading")).isEqualTo("AT_ORIGIN_PORT");
|
||||
assertThat(ZimCarrierScheduleProviderAdapter.standardNode(
|
||||
"Container was loaded at Transshipment Port to Transshipment Port")).isEqualTo("LOADED_ON_VESSEL");
|
||||
assertThat(ZimCarrierScheduleProviderAdapter.standardNode(
|
||||
"Vessel departure from first Transshipment Port to next Transshipment Port")).isEqualTo("IN_TRANSIT");
|
||||
assertThat(ZimCarrierScheduleProviderAdapter.standardNode(
|
||||
"Container loaded at Port of Loading")).isEqualTo("LOADED_ON_VESSEL");
|
||||
assertThat(ZimCarrierScheduleProviderAdapter.standardNode(
|
||||
"Vessel departure from Port of Loading")).isEqualTo("DEPARTED_ORIGIN_PORT");
|
||||
assertThat(ZimCarrierScheduleProviderAdapter.standardNode(
|
||||
"Container was loaded at Port of Loading to Port of Discharge")).isEqualTo("LOADED_ON_VESSEL");
|
||||
assertThat(ZimCarrierScheduleProviderAdapter.standardNode(
|
||||
"Vessel departure from Port of Loading to Port of Discharge")).isEqualTo("DEPARTED_ORIGIN_PORT");
|
||||
assertThat(ZimCarrierScheduleProviderAdapter.standardNode(
|
||||
"Empty container dispatched from Port of Loading to Customer")).isNull();
|
||||
assertThat(ZimCarrierScheduleProviderAdapter.standardNode("Unknown Future Event")).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void liveBkBlOnlyFixtureToleratesMissingPlansAndDoesNotAdvanceCarrierRelease() throws Exception {
|
||||
String fixture = """
|
||||
{"bkBlDetails":{"consContainerList":[
|
||||
{"containerNumber":" ZCSU7205614 ","containerEventsList":[
|
||||
{"activityDateTz":"2026-08-01T01:00:00Z","eventName":"Empty container dispatched from inland point to Customer","portCode":"CNNGB"},
|
||||
{"activityDateTz":"2026-08-03T02:00:00Z","eventName":"Export gate-in from Customer to Port of Loading","portCode":"CNNGB","port":"Ningbo","country":"CN"},
|
||||
{"activityDateTz":"2026-08-05T03:00:00Z","eventName":"Container was loaded at Port of Loading to Port of Discharge","portCode":"CNNGB","port":"Ningbo","country":"CN","vesselName":"LIVE VESSEL","voyage":"001E","leg":"1"},
|
||||
{"activityDateTz":"2026-08-06T04:00:00Z","eventName":"Vessel departure from Port of Loading to Port of Discharge","portCode":"CNNGB","port":"Ningbo","country":"CN","vesselName":"LIVE VESSEL","voyage":"001E","leg":"1"},
|
||||
{"activityDateTz":"2026-08-20T05:00:00Z","eventName":"Carrier Release","portCode":"USLAX","port":"Los Angeles","country":"US"}]},
|
||||
{"containerNumber":"ZCSU7960370","containerEventsList":[
|
||||
{"activityDateTz":"2026-08-07T00:00:00Z","eventName":"Export gate-in from Customer to Port of Loading"}]}]}}
|
||||
""";
|
||||
var payload = objectMapper.readValue(fixture, ZimTrackingClient.TracingPayload.class);
|
||||
var response = new ZimTrackingClient.TrackingResponse(payload, fixture);
|
||||
|
||||
var values = new ZimCarrierScheduleProviderAdapter(mock(ZimTrackingClient.class))
|
||||
.observations("zcsu7205614", response, LocalDateTime.of(2026, 8, 21, 12, 0));
|
||||
|
||||
assertThat(payload.routeDetails()).isNull();
|
||||
assertThat(payload.finalETA()).isNull();
|
||||
assertThat(values).hasSize(3).allSatisfy(value -> assertThat(value.actualAt()).isNotNull());
|
||||
assertThat(values).extracting(value -> value.standardNode())
|
||||
.containsExactlyInAnyOrder("AT_ORIGIN_PORT", "LOADED_ON_VESSEL", "DEPARTED_ORIGIN_PORT");
|
||||
assertThat(values).extracting(value -> value.standardNode()).doesNotContain("PICKUP_READY");
|
||||
assertThat(values).extracting(value -> value.providerNodeCode())
|
||||
.doesNotContain("CARRIER RELEASE", "EMPTY CONTAINER DISPATCHED FROM INLAND POINT TO CUSTOMER");
|
||||
}
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
package com.baiyue.integration.provider;
|
||||
|
||||
import com.baiyue.integration.config.IntegrationProperties;
|
||||
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class ZimTrackingClientTest {
|
||||
private HttpServer server;
|
||||
|
||||
@AfterEach
|
||||
void stopServer() { if (server != null) server.stop(0); }
|
||||
|
||||
@Test
|
||||
void usesOAuthTokenCacheAndContainerOnlyTracingPath() throws Exception {
|
||||
Credentials credentials = Credentials.random();
|
||||
String accessToken = UUID.randomUUID().toString();
|
||||
AtomicInteger authCalls = new AtomicInteger();
|
||||
AtomicReference<String> authForm = new AtomicReference<>();
|
||||
AtomicReference<String> tracingPath = new AtomicReference<>();
|
||||
server = server();
|
||||
server.createContext("/authorize/v1", exchange -> {
|
||||
authCalls.incrementAndGet(); authForm.set(body(exchange));
|
||||
respond(exchange, 200, tokenBody(accessToken));
|
||||
});
|
||||
server.createContext("/tracing/v2", exchange -> {
|
||||
tracingPath.set(exchange.getRequestURI().getPath());
|
||||
assertThat(exchange.getRequestHeaders().getFirst("Authorization")).isEqualTo("bearer " + accessToken);
|
||||
assertThat(exchange.getRequestHeaders().getFirst("Ocp-Apim-Subscription-Key"))
|
||||
.isEqualTo(credentials.subscriptionKey());
|
||||
respond(exchange, 200, successBody());
|
||||
});
|
||||
server.start();
|
||||
ZimTrackingClient client = client(credentials);
|
||||
|
||||
client.query(query()); client.query(query());
|
||||
|
||||
assertThat(authCalls).hasValue(1);
|
||||
assertThat(authForm.get()).contains("grant_type=client_credentials", "scope=tracing")
|
||||
.contains("client_id=", "client_secret=");
|
||||
assertThat(tracingPath.get()).isEqualTo("/tracing/v2/ZCSU7881970");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unauthorizedRefreshesTokenAndRetriesTracingExactlyOnce() throws Exception {
|
||||
Credentials credentials = Credentials.random();
|
||||
String firstToken = UUID.randomUUID().toString();
|
||||
String secondToken = UUID.randomUUID().toString();
|
||||
AtomicInteger authCalls = new AtomicInteger();
|
||||
AtomicInteger tracingCalls = new AtomicInteger();
|
||||
server = server();
|
||||
server.createContext("/authorize/v1", exchange -> {
|
||||
int call = authCalls.incrementAndGet(); respond(exchange, 200, tokenBody(call == 1 ? firstToken : secondToken));
|
||||
});
|
||||
server.createContext("/tracing/v2", exchange -> {
|
||||
int call = tracingCalls.incrementAndGet();
|
||||
respond(exchange, call == 1 ? 401 : 200, call == 1 ? "{}" : successBody());
|
||||
});
|
||||
server.start();
|
||||
|
||||
client(credentials).query(query());
|
||||
|
||||
assertThat(authCalls).hasValue(2);
|
||||
assertThat(tracingCalls).hasValue(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void forbiddenIsManualAuthorizationFailureAndDoesNotEchoSecrets() throws Exception {
|
||||
Credentials credentials = Credentials.random();
|
||||
server = server();
|
||||
server.createContext("/authorize/v1", exchange -> respond(exchange, 200, tokenBody(UUID.randomUUID().toString())));
|
||||
server.createContext("/tracing/v2", exchange -> respond(exchange, 403, "<html>forbidden</html>"));
|
||||
server.start();
|
||||
|
||||
assertThatThrownBy(() -> client(credentials).query(query()))
|
||||
.isInstanceOfSatisfying(ZimTrackingClient.ZimTrackingException.class, value -> {
|
||||
assertThat(value.outcome()).isEqualTo("MANUAL_REQUIRED");
|
||||
assertThat(value.code()).isEqualTo("ZIM_HTTP_403");
|
||||
assertThat(value.getMessage()).doesNotContain(credentials.clientSecret())
|
||||
.doesNotContain(credentials.subscriptionKey()).doesNotContain("ZIM_NOT_FOUND");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyOrMissingContainerListRequiresManualHandling() throws Exception {
|
||||
Credentials credentials = Credentials.random();
|
||||
AtomicReference<String> businessBody = new AtomicReference<>(
|
||||
"{\"bkBlDetails\":{\"consContainerList\":[]}}");
|
||||
server = server();
|
||||
server.createContext("/authorize/v1", exchange -> respond(exchange, 200, tokenBody(UUID.randomUUID().toString())));
|
||||
server.createContext("/tracing/v2", exchange -> respond(exchange, 200, businessBody.get()));
|
||||
server.start();
|
||||
ZimTrackingClient client = client(credentials);
|
||||
assertThatThrownBy(() -> client.query(query())).isInstanceOfSatisfying(
|
||||
ZimTrackingClient.ZimTrackingException.class, value -> assertThat(value.code()).isEqualTo("ZIM_NOT_FOUND"));
|
||||
|
||||
businessBody.set("{}");
|
||||
assertThatThrownBy(() -> client.query(query())).isInstanceOfSatisfying(
|
||||
ZimTrackingClient.ZimTrackingException.class,
|
||||
value -> assertThat(value.code()).isEqualTo("ZIM_EMPTY_RESULT"));
|
||||
}
|
||||
|
||||
private HttpServer server() throws IOException { return HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); }
|
||||
private QueryRequest query() { return new QueryRequest("ZIM", "CONTAINER_NO", "ZCSU7881970"); }
|
||||
|
||||
private ZimTrackingClient client(Credentials credentials) {
|
||||
String root = "http://127.0.0.1:" + server.getAddress().getPort();
|
||||
var zim = new IntegrationProperties.CarrierSchedule.Zim(true, URI.create(root + "/authorize/v1"),
|
||||
URI.create(root + "/tracing/v2"), credentials.clientId(), credentials.clientSecret(),
|
||||
credentials.subscriptionKey(), Duration.ofSeconds(2), Duration.ofSeconds(2), 10);
|
||||
ZimTrackingClient client = new ZimTrackingClient();
|
||||
ReflectionTestUtils.setField(client, "properties", new IntegrationProperties(null, null,
|
||||
new IntegrationProperties.CarrierSchedule(null, null, null, zim, null)));
|
||||
ReflectionTestUtils.setField(client, "objectMapper", new ObjectMapper()); client.initialize(); return client;
|
||||
}
|
||||
|
||||
private static String body(HttpExchange exchange) throws IOException {
|
||||
return new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
|
||||
}
|
||||
private static String tokenBody(String token) {
|
||||
return "{\"access_token\":\"" + token + "\",\"expires_in\":3600}";
|
||||
}
|
||||
private static String successBody() {
|
||||
return "{\"bkBlDetails\":{\"consContainerList\":[{\"containerNumber\":\" ZCSU7881970 \"}]}}";
|
||||
}
|
||||
private static void respond(HttpExchange exchange, int status, String body) throws IOException {
|
||||
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
|
||||
exchange.getResponseHeaders().add("Content-Type", "application/json; charset=UTF-8");
|
||||
exchange.sendResponseHeaders(status, bytes.length); exchange.getResponseBody().write(bytes); exchange.close();
|
||||
}
|
||||
private record Credentials(String clientId, String clientSecret, String subscriptionKey) {
|
||||
static Credentials random() {
|
||||
return new Credentials(UUID.randomUUID().toString(), UUID.randomUUID().toString(), UUID.randomUUID().toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
-1
@@ -3,6 +3,7 @@ package com.baiyue.integration.service;
|
||||
import com.baiyue.integration.dto.CarrierScheduleDtos.Observation;
|
||||
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest;
|
||||
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryResult;
|
||||
import com.baiyue.integration.dto.CarrierScheduleDtos.ExceptionObservation;
|
||||
import com.baiyue.integration.provider.CarrierScheduleProviderAdapter;
|
||||
import com.baiyue.integration.provider.CompliantCarrierScheduleProviderAdapter;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -21,6 +22,7 @@ class CarrierScheduleProviderServiceTest {
|
||||
assertThat(result.outcome()).isEqualTo("MANUAL_REQUIRED");
|
||||
assertThat(result.errorCode()).isEqualTo("PROVIDER_API_NOT_CONFIGURED");
|
||||
assertThat(result.observations()).isEmpty();
|
||||
assertThat(result.exceptions()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -30,7 +32,7 @@ class CarrierScheduleProviderServiceTest {
|
||||
@Override public QueryResult query(QueryRequest request) {
|
||||
return new QueryResult("TEST", "SUCCESS", List.of(new Observation("evt", "RAW", "UNKNOWN",
|
||||
LocalDateTime.now(), null, null, null, null, null,
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")), null, null,
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")), List.of(), null, null,
|
||||
LocalDateTime.now());
|
||||
}
|
||||
};
|
||||
@@ -38,4 +40,20 @@ class CarrierScheduleProviderServiceTest {
|
||||
assertThatThrownBy(() -> service.query(new QueryRequest("COSCO", "CONTAINER_NO", "CSNU1234567")))
|
||||
.hasMessageContaining("无效标准节点");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsProviderHoldsOutsideTheFourFrozenCodes() {
|
||||
CarrierScheduleProviderAdapter invalid = new CarrierScheduleProviderAdapter() {
|
||||
@Override public boolean supports(String carrierCode) { return true; }
|
||||
@Override public QueryResult query(QueryRequest request) {
|
||||
return new QueryResult("TEST", "SUCCESS", List.of(), List.of(new ExceptionObservation(
|
||||
"hold-1", "RAW HOLD", "OTHER_HOLD", true, LocalDateTime.now(), null, null, null,
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")), null, null,
|
||||
LocalDateTime.now());
|
||||
}
|
||||
};
|
||||
var service = new CarrierScheduleProviderService(List.of(invalid));
|
||||
assertThatThrownBy(() -> service.query(new QueryRequest("COSCO", "CONTAINER_NO", "CSNU1234567")))
|
||||
.hasMessageContaining("无效异常事实");
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -80,7 +80,7 @@ class WeightOcrBatchServiceTest {
|
||||
URI.create("https://example.com/meter"), Duration.ofSeconds(1), Duration.ofSeconds(1),
|
||||
inputRoot, outputRoot, DataSize.ofMegabytes(3), 40_000_000L,
|
||||
50, Duration.ZERO, 0);
|
||||
return new IntegrationProperties(baidu, null);
|
||||
return new IntegrationProperties(baidu, null, null);
|
||||
}
|
||||
|
||||
@Configuration
|
||||
|
||||
Reference in New Issue
Block a user