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