Files
baiyue-firstleg/integration-service/src/main/java/com/baiyue/integration/provider/EvergreenTrackingClient.java
T
2026-08-21 16:04:16 +08:00

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)(?:[?&]|&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; }
}
}