| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255 |
- 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<byte[]> 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=<empty>", 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<TrackingResult> reportImpression(List<String> showUrls, long price)
- throws IOException, InterruptedException {
- return reportImpression(showUrls, price, true);
- }
- public List<TrackingResult> reportImpression(List<String> showUrls, long price, boolean allowRetry)
- throws IOException, InterruptedException {
- String encodedPrice = priceEncoder.encode(price);
- List<String> urls = new ArrayList<>();
- for (String url : showUrls) {
- urls.add(url.replace(AUCTION_PRICE_MACRO, encodedPrice));
- }
- return reportTrackingUrls(urls, allowRetry);
- }
- /**
- * 点击上报:直接 GET 所有 clickURLs。
- */
- public List<TrackingResult> reportClick(List<String> clickUrls)
- throws IOException, InterruptedException {
- return reportClick(clickUrls, true);
- }
- public List<TrackingResult> reportClick(List<String> clickUrls, boolean allowRetry)
- throws IOException, InterruptedException {
- return reportTrackingUrls(clickUrls, allowRetry);
- }
- private List<TrackingResult> reportTrackingUrls(List<String> trackingUrls, boolean allowRetry)
- throws IOException, InterruptedException {
- List<TrackingResult> 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<Void> 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<String> 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);
- }
- }
|