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