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)]*>"); 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 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("]"; } } 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; } } }