This commit is contained in:
2026-08-21 16:04:16 +08:00
parent dceec6cbed
commit 0e7df9052a
68 changed files with 3985 additions and 267 deletions
@@ -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();
}
@@ -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; }
}
}
@@ -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) { }
}
@@ -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); }
}
@@ -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());
}
}
@@ -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)(?:[?&]|&amp;)(?: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; }
}
}
@@ -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("&nbsp;", " ").replace("&amp;", "&").replace("&lt;", "<").replace("&gt;", ">")
.replace("&quot;", "\"").replace("&#39;", "'").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) { }
}
@@ -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&paramtype=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; }
}
}
@@ -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());
}
}
@@ -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; }
}
}
@@ -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()); }
}
@@ -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; }
}
}
@@ -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());
}
}
@@ -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; }
}
}
@@ -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;
}
}