package com.adx.tencent.baidu; import com.adx.tencent.baidu.model.*; import com.fasterxml.jackson.databind.ObjectMapper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; 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.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; import java.util.Optional; /** * 对应 Go internal/baidu/adx.go 的 ADXClient。 * 负责:竞价请求(RequestBid)、曝光上报(ReportImpression)、点击上报(ReportClick)。 */ public class AdxClient { private static final Logger log = LoggerFactory.getLogger(AdxClient.class); public static final String AUCTION_PRICE_MACRO = "${AUCTION_PRICE}"; public static final String BAIDU_USER_AGENT = "QTech-MeasurementNode/1.2 (Postback; Attribution; S2S)"; private final String endpoint; private final HttpClient httpClient; private final AuctionPriceEncoder priceEncoder; private final ObjectMapper objectMapper; private final int trackingMaxRedirects; private final int trackingMax500Retries; public AdxClient(String endpoint, AuctionPriceEncoder priceEncoder, ObjectMapper objectMapper, int trackingMaxRedirects, int trackingMax500Retries) { this.endpoint = endpoint; this.priceEncoder = priceEncoder; this.httpClient = HttpClient.newBuilder() .followRedirects(HttpClient.Redirect.NEVER) .build(); this.objectMapper = objectMapper; this.trackingMaxRedirects = Math.max(0, trackingMaxRedirects); this.trackingMax500Retries = Math.max(0, trackingMax500Retries); } /** * 向百度 ADX 发起竞价请求,返回归一化后的 BidResponse。 * 如果百度返回空响应体(无广告填充),返回 null。 */ public NormalizedBidResponse requestBid(String media, String traceId, Object body) throws IOException, InterruptedException { if (endpoint == null || endpoint.isBlank()) { throw new IllegalStateException("Baidu ADX endpoint is required"); } String mediaLabel = mediaLabel(media); byte[] payload = objectMapper.writeValueAsBytes(body); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(endpoint)) .POST(HttpRequest.BodyPublishers.ofByteArray(payload)) .header("Content-Type", "application/json") .header("User-Agent", BAIDU_USER_AGENT) .build(); HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray()); if (response.statusCode() < 200 || response.statusCode() >= 300) { throw new IOException("Baidu ADX HTTP status: " + response.statusCode()); } // 百度 ADX 无广告填充时可能返回空 body byte[] respBody = response.body(); if (respBody == null || respBody.length == 0) { log.info("[百度ADX][{}][原始响应] traceId={} status={} body=", mediaLabel, traceId, response.statusCode()); return null; } String rawBody = new String(respBody, StandardCharsets.UTF_8); try { BidResponse bidResponse = objectMapper.readValue(respBody, BidResponse.class); NormalizedBidResponse normalized = NormalizedBidResponse.from(bidResponse); if (normalized == null || normalized.getBid() == null) { log.info("[百度ADX][{}][原始响应] traceId={} status={} body={}", mediaLabel, traceId, response.statusCode(), rawBody); } return normalized; } catch (IOException e) { log.warn("[百度ADX][{}][原始响应] traceId={} status={} body={}", mediaLabel, traceId, response.statusCode(), rawBody); throw e; } } private static String mediaLabel(String media) { return switch (media) { case "honor" -> "荣耀"; case "tencent" -> "腾讯"; default -> media == null || media.isBlank() ? "未知" : media; }; } /** * 曝光上报:将 ${AUCTION_PRICE} 宏替换为编码后的价格,依次 GET 所有 showURLs。 */ public List reportImpression(List showUrls, long price) throws IOException, InterruptedException { return reportImpression(showUrls, price, true); } public List reportImpression(List showUrls, long price, boolean allowRetry) throws IOException, InterruptedException { String encodedPrice = priceEncoder.encode(price); List urls = new ArrayList<>(); for (String url : showUrls) { urls.add(url.replace(AUCTION_PRICE_MACRO, encodedPrice)); } return reportTrackingUrls(urls, allowRetry); } /** * 点击上报:直接 GET 所有 clickURLs。 */ public List reportClick(List clickUrls) throws IOException, InterruptedException { return reportClick(clickUrls, true); } public List reportClick(List clickUrls, boolean allowRetry) throws IOException, InterruptedException { return reportTrackingUrls(clickUrls, allowRetry); } private List reportTrackingUrls(List trackingUrls, boolean allowRetry) throws IOException, InterruptedException { List results = new ArrayList<>(); for (String url : trackingUrls) { results.add(reportTrackingUrl(url, allowRetry ? trackingMax500Retries : 0)); } return results; } private TrackingResult reportTrackingUrl(String originalUrl, int max500Retries) throws IOException, InterruptedException { String currentUrl = originalUrl; int redirectCount = 0; int status500RetryCount = 0; while (true) { HttpRequest req = HttpRequest.newBuilder() .uri(URI.create(currentUrl)) .GET() .header("User-Agent", BAIDU_USER_AGENT) .build(); HttpResponse resp = httpClient.send(req, HttpResponse.BodyHandlers.discarding()); int status = resp.statusCode(); if (status == 200) { if (redirectCount > 0) { log.info("[百度监测][302跟随成功] originalUrl={} finalUrl={} status={} redirects={}", summarizeUrl(originalUrl), summarizeUrl(currentUrl), status, redirectCount); } return new TrackingResult(originalUrl, status, true); } if (status == 500) { if (status500RetryCount >= max500Retries) { log.warn("[百度监测][500重试失败] originalUrl={} currentUrl={} status={} retries={} maxRetries={} redirects={} reason=retry_limit_exceeded", summarizeUrl(originalUrl), summarizeUrl(currentUrl), status, status500RetryCount, max500Retries, redirectCount); return new TrackingResult(originalUrl, status, false); } status500RetryCount++; log.warn("[百度监测][500重试] originalUrl={} currentUrl={} status={} retry={}/{} redirects={} action=retry_original_url", summarizeUrl(originalUrl), summarizeUrl(currentUrl), status, status500RetryCount, max500Retries, redirectCount); currentUrl = originalUrl; redirectCount = 0; continue; } if (status != 302) { if (redirectCount > 0) { log.warn("[百度监测][302跟随失败] originalUrl={} finalUrl={} status={} redirects={} reason=non_302_terminal_status", summarizeUrl(originalUrl), summarizeUrl(currentUrl), status, redirectCount); } return new TrackingResult(originalUrl, status, false); } Optional location = resp.headers().firstValue("location"); if (location.isEmpty() || location.get().isBlank()) { log.warn("[百度监测][302跟随失败] originalUrl={} currentUrl={} status={} redirects={} reason=missing_location", summarizeUrl(originalUrl), summarizeUrl(currentUrl), status, redirectCount); return new TrackingResult(originalUrl, status, false); } if (redirectCount >= trackingMaxRedirects) { log.warn("[百度监测][302跟随失败] originalUrl={} currentUrl={} location={} status={} redirects={} maxRedirects={} reason=redirect_limit_exceeded", summarizeUrl(originalUrl), summarizeUrl(currentUrl), summarizeUrl(location.get()), status, redirectCount, trackingMaxRedirects); return new TrackingResult(originalUrl, status, false); } String nextUrl = resolveRedirectUrl(currentUrl, location.get()); redirectCount++; log.info("[百度监测][302跟随] originalUrl={} currentUrl={} location={} nextUrl={} status={} redirects={}/{}", summarizeUrl(originalUrl), summarizeUrl(currentUrl), summarizeUrl(location.get()), summarizeUrl(nextUrl), status, redirectCount, trackingMaxRedirects); currentUrl = nextUrl; } } private static String resolveRedirectUrl(String currentUrl, String location) { URI current = URI.create(currentUrl); return current.resolve(location).toString(); } private static String summarizeUrl(String rawUrl) { if (rawUrl == null || rawUrl.isBlank()) { return rawUrl; } try { URI uri = URI.create(rawUrl); StringBuilder sb = new StringBuilder(); if (uri.getScheme() != null && uri.getHost() != null) { sb.append(uri.getScheme()).append("://").append(uri.getHost()); } if (uri.getRawPath() != null && !uri.getRawPath().isBlank()) { sb.append(uri.getRawPath()); } String query = uri.getRawQuery(); if (query != null && !query.isBlank()) { sb.append('?').append(abbreviate(query, 96)); } if (sb.length() == 0) { sb.append(abbreviate(rawUrl, 120)); } sb.append(" (len=").append(rawUrl.length()).append(')'); return sb.toString(); } catch (Exception ignore) { return abbreviate(rawUrl, 120) + " (len=" + rawUrl.length() + ")"; } } private static String abbreviate(String value, int maxLen) { if (value == null || value.length() <= maxLen) { return value; } if (maxLen <= 3) { return value.substring(0, maxLen); } return value.substring(0, maxLen) + "..."; } // ─── 默认价格编码(不加密,直接 URL encode 数字字符串)─────────────── public static String defaultEncodePrice(long price) { return URLEncoder.encode(String.valueOf(price), StandardCharsets.UTF_8); } }