AdxClient.java 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255
  1. package com.adx.tencent.baidu;
  2. import com.adx.tencent.baidu.model.*;
  3. import com.fasterxml.jackson.databind.ObjectMapper;
  4. import org.slf4j.Logger;
  5. import org.slf4j.LoggerFactory;
  6. import java.io.IOException;
  7. import java.net.URI;
  8. import java.net.URLEncoder;
  9. import java.net.http.HttpClient;
  10. import java.net.http.HttpRequest;
  11. import java.net.http.HttpResponse;
  12. import java.nio.charset.StandardCharsets;
  13. import java.util.ArrayList;
  14. import java.util.List;
  15. import java.util.Optional;
  16. /**
  17. * 对应 Go internal/baidu/adx.go 的 ADXClient。
  18. * 负责:竞价请求(RequestBid)、曝光上报(ReportImpression)、点击上报(ReportClick)。
  19. */
  20. public class AdxClient {
  21. private static final Logger log = LoggerFactory.getLogger(AdxClient.class);
  22. public static final String AUCTION_PRICE_MACRO = "${AUCTION_PRICE}";
  23. public static final String BAIDU_USER_AGENT = "QTech-MeasurementNode/1.2 (Postback; Attribution; S2S)";
  24. private final String endpoint;
  25. private final HttpClient httpClient;
  26. private final AuctionPriceEncoder priceEncoder;
  27. private final ObjectMapper objectMapper;
  28. private final int trackingMaxRedirects;
  29. private final int trackingMax500Retries;
  30. public AdxClient(String endpoint,
  31. AuctionPriceEncoder priceEncoder,
  32. ObjectMapper objectMapper,
  33. int trackingMaxRedirects,
  34. int trackingMax500Retries) {
  35. this.endpoint = endpoint;
  36. this.priceEncoder = priceEncoder;
  37. this.httpClient = HttpClient.newBuilder()
  38. .followRedirects(HttpClient.Redirect.NEVER)
  39. .build();
  40. this.objectMapper = objectMapper;
  41. this.trackingMaxRedirects = Math.max(0, trackingMaxRedirects);
  42. this.trackingMax500Retries = Math.max(0, trackingMax500Retries);
  43. }
  44. /**
  45. * 向百度 ADX 发起竞价请求,返回归一化后的 BidResponse。
  46. * 如果百度返回空响应体(无广告填充),返回 null。
  47. */
  48. public NormalizedBidResponse requestBid(String media, String traceId, Object body) throws IOException, InterruptedException {
  49. if (endpoint == null || endpoint.isBlank()) {
  50. throw new IllegalStateException("Baidu ADX endpoint is required");
  51. }
  52. String mediaLabel = mediaLabel(media);
  53. byte[] payload = objectMapper.writeValueAsBytes(body);
  54. HttpRequest request = HttpRequest.newBuilder()
  55. .uri(URI.create(endpoint))
  56. .POST(HttpRequest.BodyPublishers.ofByteArray(payload))
  57. .header("Content-Type", "application/json")
  58. .header("User-Agent", BAIDU_USER_AGENT)
  59. .build();
  60. HttpResponse<byte[]> response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray());
  61. if (response.statusCode() < 200 || response.statusCode() >= 300) {
  62. throw new IOException("Baidu ADX HTTP status: " + response.statusCode());
  63. }
  64. // 百度 ADX 无广告填充时可能返回空 body
  65. byte[] respBody = response.body();
  66. if (respBody == null || respBody.length == 0) {
  67. log.info("[百度ADX][{}][原始响应] traceId={} status={} body=<empty>", mediaLabel, traceId, response.statusCode());
  68. return null;
  69. }
  70. String rawBody = new String(respBody, StandardCharsets.UTF_8);
  71. try {
  72. BidResponse bidResponse = objectMapper.readValue(respBody, BidResponse.class);
  73. NormalizedBidResponse normalized = NormalizedBidResponse.from(bidResponse);
  74. if (normalized == null || normalized.getBid() == null) {
  75. log.info("[百度ADX][{}][原始响应] traceId={} status={} body={}", mediaLabel, traceId, response.statusCode(), rawBody);
  76. }
  77. return normalized;
  78. } catch (IOException e) {
  79. log.warn("[百度ADX][{}][原始响应] traceId={} status={} body={}", mediaLabel, traceId, response.statusCode(), rawBody);
  80. throw e;
  81. }
  82. }
  83. private static String mediaLabel(String media) {
  84. return switch (media) {
  85. case "honor" -> "荣耀";
  86. case "tencent" -> "腾讯";
  87. default -> media == null || media.isBlank() ? "未知" : media;
  88. };
  89. }
  90. /**
  91. * 曝光上报:将 ${AUCTION_PRICE} 宏替换为编码后的价格,依次 GET 所有 showURLs。
  92. */
  93. public List<TrackingResult> reportImpression(List<String> showUrls, long price)
  94. throws IOException, InterruptedException {
  95. return reportImpression(showUrls, price, true);
  96. }
  97. public List<TrackingResult> reportImpression(List<String> showUrls, long price, boolean allowRetry)
  98. throws IOException, InterruptedException {
  99. String encodedPrice = priceEncoder.encode(price);
  100. List<String> urls = new ArrayList<>();
  101. for (String url : showUrls) {
  102. urls.add(url.replace(AUCTION_PRICE_MACRO, encodedPrice));
  103. }
  104. return reportTrackingUrls(urls, allowRetry);
  105. }
  106. /**
  107. * 点击上报:直接 GET 所有 clickURLs。
  108. */
  109. public List<TrackingResult> reportClick(List<String> clickUrls)
  110. throws IOException, InterruptedException {
  111. return reportClick(clickUrls, true);
  112. }
  113. public List<TrackingResult> reportClick(List<String> clickUrls, boolean allowRetry)
  114. throws IOException, InterruptedException {
  115. return reportTrackingUrls(clickUrls, allowRetry);
  116. }
  117. private List<TrackingResult> reportTrackingUrls(List<String> trackingUrls, boolean allowRetry)
  118. throws IOException, InterruptedException {
  119. List<TrackingResult> results = new ArrayList<>();
  120. for (String url : trackingUrls) {
  121. results.add(reportTrackingUrl(url, allowRetry ? trackingMax500Retries : 0));
  122. }
  123. return results;
  124. }
  125. private TrackingResult reportTrackingUrl(String originalUrl, int max500Retries)
  126. throws IOException, InterruptedException {
  127. String currentUrl = originalUrl;
  128. int redirectCount = 0;
  129. int status500RetryCount = 0;
  130. while (true) {
  131. HttpRequest req = HttpRequest.newBuilder()
  132. .uri(URI.create(currentUrl))
  133. .GET()
  134. .header("User-Agent", BAIDU_USER_AGENT)
  135. .build();
  136. HttpResponse<Void> resp = httpClient.send(req, HttpResponse.BodyHandlers.discarding());
  137. int status = resp.statusCode();
  138. if (status == 200) {
  139. if (redirectCount > 0) {
  140. log.info("[百度监测][302跟随成功] originalUrl={} finalUrl={} status={} redirects={}",
  141. summarizeUrl(originalUrl), summarizeUrl(currentUrl), status, redirectCount);
  142. }
  143. return new TrackingResult(originalUrl, status, true);
  144. }
  145. if (status == 500) {
  146. if (status500RetryCount >= max500Retries) {
  147. log.warn("[百度监测][500重试失败] originalUrl={} currentUrl={} status={} retries={} maxRetries={} redirects={} reason=retry_limit_exceeded",
  148. summarizeUrl(originalUrl), summarizeUrl(currentUrl), status,
  149. status500RetryCount, max500Retries, redirectCount);
  150. return new TrackingResult(originalUrl, status, false);
  151. }
  152. status500RetryCount++;
  153. log.warn("[百度监测][500重试] originalUrl={} currentUrl={} status={} retry={}/{} redirects={} action=retry_original_url",
  154. summarizeUrl(originalUrl), summarizeUrl(currentUrl), status,
  155. status500RetryCount, max500Retries, redirectCount);
  156. currentUrl = originalUrl;
  157. redirectCount = 0;
  158. continue;
  159. }
  160. if (status != 302) {
  161. if (redirectCount > 0) {
  162. log.warn("[百度监测][302跟随失败] originalUrl={} finalUrl={} status={} redirects={} reason=non_302_terminal_status",
  163. summarizeUrl(originalUrl), summarizeUrl(currentUrl), status, redirectCount);
  164. }
  165. return new TrackingResult(originalUrl, status, false);
  166. }
  167. Optional<String> location = resp.headers().firstValue("location");
  168. if (location.isEmpty() || location.get().isBlank()) {
  169. log.warn("[百度监测][302跟随失败] originalUrl={} currentUrl={} status={} redirects={} reason=missing_location",
  170. summarizeUrl(originalUrl), summarizeUrl(currentUrl), status, redirectCount);
  171. return new TrackingResult(originalUrl, status, false);
  172. }
  173. if (redirectCount >= trackingMaxRedirects) {
  174. log.warn("[百度监测][302跟随失败] originalUrl={} currentUrl={} location={} status={} redirects={} maxRedirects={} reason=redirect_limit_exceeded",
  175. summarizeUrl(originalUrl), summarizeUrl(currentUrl), summarizeUrl(location.get()),
  176. status, redirectCount, trackingMaxRedirects);
  177. return new TrackingResult(originalUrl, status, false);
  178. }
  179. String nextUrl = resolveRedirectUrl(currentUrl, location.get());
  180. redirectCount++;
  181. log.info("[百度监测][302跟随] originalUrl={} currentUrl={} location={} nextUrl={} status={} redirects={}/{}",
  182. summarizeUrl(originalUrl), summarizeUrl(currentUrl), summarizeUrl(location.get()),
  183. summarizeUrl(nextUrl), status, redirectCount, trackingMaxRedirects);
  184. currentUrl = nextUrl;
  185. }
  186. }
  187. private static String resolveRedirectUrl(String currentUrl, String location) {
  188. URI current = URI.create(currentUrl);
  189. return current.resolve(location).toString();
  190. }
  191. private static String summarizeUrl(String rawUrl) {
  192. if (rawUrl == null || rawUrl.isBlank()) {
  193. return rawUrl;
  194. }
  195. try {
  196. URI uri = URI.create(rawUrl);
  197. StringBuilder sb = new StringBuilder();
  198. if (uri.getScheme() != null && uri.getHost() != null) {
  199. sb.append(uri.getScheme()).append("://").append(uri.getHost());
  200. }
  201. if (uri.getRawPath() != null && !uri.getRawPath().isBlank()) {
  202. sb.append(uri.getRawPath());
  203. }
  204. String query = uri.getRawQuery();
  205. if (query != null && !query.isBlank()) {
  206. sb.append('?').append(abbreviate(query, 96));
  207. }
  208. if (sb.length() == 0) {
  209. sb.append(abbreviate(rawUrl, 120));
  210. }
  211. sb.append(" (len=").append(rawUrl.length()).append(')');
  212. return sb.toString();
  213. } catch (Exception ignore) {
  214. return abbreviate(rawUrl, 120) + " (len=" + rawUrl.length() + ")";
  215. }
  216. }
  217. private static String abbreviate(String value, int maxLen) {
  218. if (value == null || value.length() <= maxLen) {
  219. return value;
  220. }
  221. if (maxLen <= 3) {
  222. return value.substring(0, maxLen);
  223. }
  224. return value.substring(0, maxLen) + "...";
  225. }
  226. // ─── 默认价格编码(不加密,直接 URL encode 数字字符串)───────────────
  227. public static String defaultEncodePrice(long price) {
  228. return URLEncoder.encode(String.valueOf(price), StandardCharsets.UTF_8);
  229. }
  230. }