优化
This commit is contained in:
+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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user