152 lines
7.6 KiB
Java
152 lines
7.6 KiB
Java
package com.baiyue.integration.provider;
|
|
|
|
import com.baiyue.integration.config.IntegrationProperties;
|
|
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest;
|
|
import jakarta.annotation.PostConstruct;
|
|
import jakarta.annotation.Resource;
|
|
import org.springframework.stereotype.Component;
|
|
import org.springframework.web.util.HtmlUtils;
|
|
|
|
import java.io.IOException;
|
|
import java.net.URLEncoder;
|
|
import java.net.http.HttpClient;
|
|
import java.net.http.HttpRequest;
|
|
import java.net.http.HttpResponse;
|
|
import java.net.http.HttpTimeoutException;
|
|
import java.nio.charset.StandardCharsets;
|
|
import java.time.Duration;
|
|
import java.util.Locale;
|
|
import java.util.regex.Matcher;
|
|
import java.util.regex.Pattern;
|
|
|
|
@Component
|
|
public class EvergreenTrackingClient {
|
|
private static final Pattern INPUT = Pattern.compile("(?is)<input\\b[^>]*>");
|
|
private static final Pattern ATTRIBUTE = Pattern.compile("(?i)\\b([a-z_:][-a-z0-9_:.]*)\\s*=\\s*([\"'])(.*?)\\2");
|
|
private static final Pattern DISP_INFO = Pattern.compile("(?is)GetDispInfo[^0-9]{0,160}(\\d{12})");
|
|
private static final Pattern BL_QUERY = Pattern.compile("(?i)(?:[?&]|&)(?:BL|bl_no)=(\\d{12})");
|
|
private final Object requestLock = new Object();
|
|
@Resource private IntegrationProperties properties;
|
|
private IntegrationProperties.CarrierSchedule.Evergreen config;
|
|
private HttpClient httpClient;
|
|
private long nextRequestAtMillis;
|
|
|
|
@PostConstruct
|
|
void initialize() {
|
|
config = properties.carrierSchedule() == null ? null : properties.carrierSchedule().evergreen();
|
|
httpClient = HttpClient.newBuilder()
|
|
.connectTimeout(config == null ? Duration.ofSeconds(10) : config.effectiveConnectTimeout())
|
|
.followRedirects(HttpClient.Redirect.NORMAL).build();
|
|
}
|
|
|
|
public boolean configured() { return config != null && config.configured(); }
|
|
|
|
public TrackingResponse query(QueryRequest request) {
|
|
if (!configured()) manual("EMC_NOT_CONFIGURED", "长荣公开货柜动态查询未启用");
|
|
if (!"CONTAINER_NO".equalsIgnoreCase(request.trackingType())) {
|
|
manual("EMC_CONTAINER_REQUIRED", "长荣首批自动节点仅支持柜号查询");
|
|
}
|
|
synchronized (requestLock) {
|
|
waitForSlot();
|
|
try {
|
|
String container = normalize(request.trackingNo());
|
|
String containerHtml = post("TYPE=CNTR&CNTR=" + encode(container), "CONTAINER");
|
|
String bill = relatedBill(containerHtml);
|
|
if (bill == null) manual("EMC_RELATED_BL_MISSING", "长荣柜号结果缺少关联提单,需人工复核");
|
|
String billHtml = post("TYPE=BL&BL=" + encode(bill), "BILL");
|
|
return new TrackingResponse(containerHtml, billHtml, bill.substring(bill.length() - 4),
|
|
containerHtml + billHtml);
|
|
} finally {
|
|
nextRequestAtMillis = System.currentTimeMillis() + config.effectiveMinRequestInterval().toMillis();
|
|
}
|
|
}
|
|
}
|
|
|
|
private String post(String form, String stage) {
|
|
try {
|
|
HttpResponse<String> response = httpClient.send(HttpRequest.newBuilder(config.queryUrl())
|
|
.timeout(config.effectiveRequestTimeout()).header("Accept", "text/html")
|
|
.header("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
|
|
.POST(HttpRequest.BodyPublishers.ofString(form, StandardCharsets.UTF_8)).build(),
|
|
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
|
if (response.statusCode() == 429 || response.statusCode() >= 500) {
|
|
throw retryable("EMC_" + stage + "_HTTP_" + response.statusCode(), "长荣公开查询暂时不可用", null);
|
|
}
|
|
if (response.statusCode() < 200 || response.statusCode() >= 300) {
|
|
manual("EMC_" + stage + "_HTTP_" + response.statusCode(), "长荣公开查询拒绝请求");
|
|
}
|
|
if (!looksLikeHtml(response.body())) manual("EMC_" + stage + "_NON_HTML", "长荣返回非 HTML 内容");
|
|
return response.body();
|
|
} catch (EvergreenTrackingException exception) {
|
|
throw exception;
|
|
} catch (HttpTimeoutException exception) {
|
|
throw retryable("EMC_" + stage + "_TIMEOUT", "长荣公开查询超时", exception);
|
|
} catch (InterruptedException exception) {
|
|
Thread.currentThread().interrupt();
|
|
throw retryable("EMC_" + stage + "_INTERRUPTED", "长荣公开查询被中断", exception);
|
|
} catch (IOException exception) {
|
|
throw retryable("EMC_" + stage + "_IO_FAILED", "长荣公开查询网络失败", exception);
|
|
}
|
|
}
|
|
|
|
static String relatedBill(String html) {
|
|
Matcher inputs = INPUT.matcher(html == null ? "" : html);
|
|
while (inputs.find()) {
|
|
String name = attribute(inputs.group(), "name");
|
|
String value = HtmlUtils.htmlUnescape(attribute(inputs.group(), "value"));
|
|
if ("bl_no".equalsIgnoreCase(name) && value.matches("\\d{12}")) return value;
|
|
}
|
|
String decoded = HtmlUtils.htmlUnescape(html == null ? "" : html);
|
|
Matcher action = DISP_INFO.matcher(decoded);
|
|
if (action.find()) return action.group(1);
|
|
Matcher query = BL_QUERY.matcher(decoded);
|
|
return query.find() ? query.group(1) : null;
|
|
}
|
|
|
|
private static String attribute(String tag, String expected) {
|
|
Matcher attributes = ATTRIBUTE.matcher(tag);
|
|
while (attributes.find()) if (expected.equalsIgnoreCase(attributes.group(1))) return attributes.group(3).trim();
|
|
return "";
|
|
}
|
|
private static boolean looksLikeHtml(String value) {
|
|
String lower = value == null ? "" : value.toLowerCase(Locale.ROOT);
|
|
return lower.contains("<html") || lower.contains("<table") || lower.contains("<input");
|
|
}
|
|
private void waitForSlot() {
|
|
long wait = nextRequestAtMillis - System.currentTimeMillis();
|
|
if (wait <= 0) return;
|
|
try { Thread.sleep(wait); }
|
|
catch (InterruptedException exception) {
|
|
Thread.currentThread().interrupt();
|
|
throw retryable("EMC_INTERRUPTED", "等待长荣查询限流窗口时被中断", exception);
|
|
}
|
|
}
|
|
private static void manual(String code, String message) {
|
|
throw new EvergreenTrackingException("MANUAL_REQUIRED", code, message);
|
|
}
|
|
private static EvergreenTrackingException retryable(String code, String message, Throwable cause) {
|
|
return new EvergreenTrackingException("RETRYABLE_FAILURE", code, message, cause);
|
|
}
|
|
private static String normalize(String value) {
|
|
return value == null ? "" : value.replaceAll("[^A-Za-z0-9]", "").toUpperCase(Locale.ROOT);
|
|
}
|
|
private static String encode(String value) { return URLEncoder.encode(value, StandardCharsets.UTF_8); }
|
|
|
|
public record TrackingResponse(String containerHtml, String billHtml, String billSuffix, String rawBody) {
|
|
@Override public String toString() {
|
|
return "TrackingResponse[billSuffix=" + billSuffix + ",html=<redacted>]";
|
|
}
|
|
}
|
|
public static final class EvergreenTrackingException extends RuntimeException {
|
|
private final String outcome; private final String code;
|
|
public EvergreenTrackingException(String outcome, String code, String message) {
|
|
super(message); this.outcome = outcome; this.code = code;
|
|
}
|
|
public EvergreenTrackingException(String outcome, String code, String message, Throwable cause) {
|
|
super(message, cause); this.outcome = outcome; this.code = code;
|
|
}
|
|
public String outcome() { return outcome; }
|
|
public String code() { return code; }
|
|
}
|
|
}
|