|
|
@@ -0,0 +1,356 @@
|
|
|
+package com.adx.tencent.httpapi;
|
|
|
+
|
|
|
+import com.adx.tencent.baidu.AdxClient;
|
|
|
+import com.adx.tencent.baidu.model.NormalizedBidResponse;
|
|
|
+import com.adx.tencent.baidu.model.TrackingResult;
|
|
|
+import com.adx.tencent.storage.RedisHotStore;
|
|
|
+import com.adx.tencent.storage.model.BidRecord;
|
|
|
+import com.adx.tencent.storage.model.TrackingRecord;
|
|
|
+import org.slf4j.Logger;
|
|
|
+import org.slf4j.LoggerFactory;
|
|
|
+import org.springframework.beans.factory.annotation.Value;
|
|
|
+import org.springframework.http.HttpStatus;
|
|
|
+import org.springframework.lang.Nullable;
|
|
|
+import org.springframework.web.bind.annotation.*;
|
|
|
+import org.springframework.web.server.ResponseStatusException;
|
|
|
+
|
|
|
+import jakarta.servlet.http.HttpServletRequest;
|
|
|
+import jakarta.servlet.http.HttpServletResponse;
|
|
|
+import java.time.Instant;
|
|
|
+import java.util.*;
|
|
|
+import java.util.concurrent.Semaphore;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 腾讯监测链接接收端点。
|
|
|
+ * 提供曝光和点击监测 URL,接收腾讯广告平台的回调。
|
|
|
+ */
|
|
|
+@RestController
|
|
|
+public class TrackingController {
|
|
|
+
|
|
|
+ private static final Logger log = LoggerFactory.getLogger(TrackingController.class);
|
|
|
+ private static final int MAX_CONCURRENT = 50;
|
|
|
+ private final Semaphore impressionSemaphore = new Semaphore(MAX_CONCURRENT);
|
|
|
+ private final Semaphore clickSemaphore = new Semaphore(MAX_CONCURRENT);
|
|
|
+
|
|
|
+ private final AdxClient adxClient;
|
|
|
+ private final RedisHotStore hotStore;
|
|
|
+ private final MediaPlacement tencentPlacement;
|
|
|
+ private final boolean allowIpOverride;
|
|
|
+
|
|
|
+ public TrackingController(AdxClient adxClient,
|
|
|
+ RedisHotStore hotStore,
|
|
|
+ @Nullable MediaPlacement tencentPlacement,
|
|
|
+ @Value("${spring.profiles.active:}") String activeProfile) {
|
|
|
+ this.adxClient = adxClient;
|
|
|
+ this.hotStore = hotStore;
|
|
|
+ this.tencentPlacement = tencentPlacement;
|
|
|
+ this.allowIpOverride = "dev".equals(activeProfile) || "test".equals(activeProfile);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ─── /health ────────────────────────────────────────────────────────────
|
|
|
+
|
|
|
+ @GetMapping("/health")
|
|
|
+ public Map<String, Boolean> health() {
|
|
|
+ return Map.of("ok", true);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ─── GET /tencent/impression ─────────────────────────────────────────────
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 接收腾讯曝光监测。
|
|
|
+ * 流程:解析参数 → 构造百度竞价请求 → 请求百度ADX → 缓存bid → 上报百度曝光 → 返回204
|
|
|
+ * 限制最多50个线程同时执行。
|
|
|
+ */
|
|
|
+ @GetMapping("/tencent/impression")
|
|
|
+ @ResponseStatus(HttpStatus.NO_CONTENT)
|
|
|
+ public void tencentImpression(HttpServletRequest request) throws Exception {
|
|
|
+ impressionSemaphore.acquire();
|
|
|
+ try {
|
|
|
+ doImpression(request);
|
|
|
+ } finally {
|
|
|
+ impressionSemaphore.release();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private void doImpression(HttpServletRequest request) throws Exception {
|
|
|
+ Map<String, String> params = flatQueryParams(request);
|
|
|
+ String traceId = mediaTraceId(params);
|
|
|
+ if (traceId == null || traceId.isBlank()) {
|
|
|
+ log.warn("[impression] 缺少 traceId, params={}", params.keySet());
|
|
|
+ throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "missing media trace id (click_id/oaid/imei/idfa)");
|
|
|
+ }
|
|
|
+ if (tencentPlacement == null) {
|
|
|
+ throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "tencent placement not configured");
|
|
|
+ }
|
|
|
+
|
|
|
+ log.info("[impression] traceId={}, ip={}, params={}", traceId, clientIp(request), params);
|
|
|
+
|
|
|
+ Map<String, Object> bidRequest = mediaBidRequest(request, "tencent", traceId, params, tencentPlacement);
|
|
|
+ NormalizedBidResponse response = adxClient.requestBid(bidRequest);
|
|
|
+ if (response == null) {
|
|
|
+ log.warn("[impression] 百度ADX返回空响应, traceId={}", traceId);
|
|
|
+ throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "empty baidu ADX response");
|
|
|
+ }
|
|
|
+
|
|
|
+ log.info("[impression] 百度ADX返回: qk={}, hasBid={}, showUrls={}, clickUrls={}",
|
|
|
+ response.getQk(),
|
|
|
+ response.getBid() != null,
|
|
|
+ response.getBid() != null && response.getBid().getShowUrls() != null ? response.getBid().getShowUrls().size() : 0,
|
|
|
+ response.getBid() != null && response.getBid().getClickUrls() != null ? response.getBid().getClickUrls().size() : 0);
|
|
|
+
|
|
|
+ BidRecord record = bidRecordFromMediaResponse(response, "tencent", traceId, params);
|
|
|
+ hotStore.recordBid(record);
|
|
|
+ log.info("[impression] 已缓存bid, qk={}, traceId={}", record.getQk(), traceId);
|
|
|
+
|
|
|
+ // 上报百度曝光
|
|
|
+ if (response.getBid() != null && response.getBid().getShowUrls() != null
|
|
|
+ && !response.getBid().getShowUrls().isEmpty()) {
|
|
|
+ List<TrackingResult> trackingResults = adxClient.reportImpression(
|
|
|
+ response.getBid().getShowUrls(), response.getBid().getPrice());
|
|
|
+ log.info("[impression] 曝光上报完成, qk={}, results={}", response.getQk(), trackingResults);
|
|
|
+ recordTrackingResults(response.getQk(), "impression", trackingResults);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ─── GET /tencent/click ──────────────────────────────────────────────────
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 接收腾讯点击监测。
|
|
|
+ * 流程:查找已有bid → 若无则请求百度ADX → 上报百度点击 → 302重定向到落地页
|
|
|
+ * 限制最多50个线程同时执行。
|
|
|
+ */
|
|
|
+ @GetMapping("/tencent/click")
|
|
|
+ public Object tencentClick(HttpServletRequest request, HttpServletResponse response)
|
|
|
+ throws Exception {
|
|
|
+ clickSemaphore.acquire();
|
|
|
+ try {
|
|
|
+ return doClick(request, response);
|
|
|
+ } finally {
|
|
|
+ clickSemaphore.release();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private Object doClick(HttpServletRequest request, HttpServletResponse response)
|
|
|
+ throws Exception {
|
|
|
+ if (tencentPlacement == null) {
|
|
|
+ throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "tencent placement not configured");
|
|
|
+ }
|
|
|
+
|
|
|
+ Map<String, String> params = flatQueryParams(request);
|
|
|
+ String traceId = mediaTraceId(params);
|
|
|
+ if (traceId == null || traceId.isBlank()) {
|
|
|
+ log.warn("[click] 缺少 traceId, params={}", params.keySet());
|
|
|
+ throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "missing media trace id (click_id/oaid/imei/idfa)");
|
|
|
+ }
|
|
|
+
|
|
|
+ log.info("[click] traceId={}, ip={}, params={}", traceId, clientIp(request), params);
|
|
|
+
|
|
|
+ // 先从 Redis 按 mediaTrace 查找已有 bid
|
|
|
+ BidRecord record = hotStore.findBidByMediaTrace("tencent", traceId);
|
|
|
+ if (record == null) {
|
|
|
+ log.info("[click] Redis未命中, 发起竞价请求, traceId={}", traceId);
|
|
|
+ // 未找到,临时发起竞价请求
|
|
|
+ Map<String, Object> bidRequest = mediaBidRequest(request, "tencent", traceId, params, tencentPlacement);
|
|
|
+ NormalizedBidResponse resp = adxClient.requestBid(bidRequest);
|
|
|
+ if (resp == null) {
|
|
|
+ log.warn("[click] 百度ADX返回空响应, traceId={}", traceId);
|
|
|
+ throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "empty baidu ADX response");
|
|
|
+ }
|
|
|
+ BidRecord newRecord = bidRecordFromMediaResponse(resp, "tencent", traceId, params);
|
|
|
+ hotStore.recordBid(newRecord);
|
|
|
+ record = newRecord;
|
|
|
+ log.info("[click] 竞价成功, qk={}, traceId={}", record.getQk(), traceId);
|
|
|
+ } else {
|
|
|
+ log.info("[click] Redis命中, qk={}, traceId={}", record.getQk(), traceId);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 上报百度点击
|
|
|
+ List<TrackingResult> trackingResults = adxClient.reportClick(
|
|
|
+ record.getClickUrls() != null ? record.getClickUrls() : Collections.emptyList());
|
|
|
+ log.info("[click] 点击上报完成, qk={}, results={}", record.getQk(), trackingResults);
|
|
|
+ recordTrackingResults(record.getQk(), "click", trackingResults);
|
|
|
+
|
|
|
+ // 重定向到 landing page 或 app store link
|
|
|
+ String destination = record.getAppStoreLink() != null && !record.getAppStoreLink().isBlank()
|
|
|
+ ? record.getAppStoreLink() : record.getLandingPage();
|
|
|
+ if (destination != null && !destination.isBlank()) {
|
|
|
+ log.info("[click] 302重定向, qk={}, destination={}", record.getQk(), destination);
|
|
|
+ response.sendRedirect(destination);
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ return Map.of("qk", nvl(record.getQk()), "traceId", traceId, "tracking", trackingResults);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ─── 私有工具方法 ────────────────────────────────────────────────────────
|
|
|
+
|
|
|
+ private BidRecord bidRecordFromMediaResponse(NormalizedBidResponse response,
|
|
|
+ String media, String traceId,
|
|
|
+ Map<String, String> mediaParams) {
|
|
|
+ BidRecord r = new BidRecord();
|
|
|
+ r.setQk(response.getQk());
|
|
|
+ r.setReqId(response.getReqId());
|
|
|
+ r.setBidId(response.getBidId());
|
|
|
+ r.setMedia(media);
|
|
|
+ r.setMediaTraceId(traceId);
|
|
|
+ r.setPlatform(mediaParams.get("baidu_platform"));
|
|
|
+ r.setMediaParams(mediaParams);
|
|
|
+ r.setCreatedAt(Instant.now());
|
|
|
+ if (response.getBid() != null) {
|
|
|
+ r.setCreativeId(response.getBid().getCrid());
|
|
|
+ r.setImpId(response.getBid().getImpId());
|
|
|
+ r.setTagId(response.getBid().getTagId());
|
|
|
+ r.setPrice(response.getBid().getPrice());
|
|
|
+ r.setShowUrls(response.getBid().getShowUrls());
|
|
|
+ r.setClickUrls(response.getBid().getClickUrls());
|
|
|
+ r.setLandingPage(response.getBid().getLandingPage());
|
|
|
+ r.setAppStoreLink(response.getBid().getAppStoreLink());
|
|
|
+ if (response.getBid().getAdm() != null) {
|
|
|
+ r.setPackageName(response.getBid().getAdm().getPackageName());
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return r;
|
|
|
+ }
|
|
|
+
|
|
|
+ private Map<String, String> flatQueryParams(HttpServletRequest request) {
|
|
|
+ Map<String, String> params = new HashMap<>();
|
|
|
+ request.getParameterMap().forEach((k, v) -> {
|
|
|
+ if (v != null && v.length > 0) params.put(k, v[0]);
|
|
|
+ });
|
|
|
+ return params;
|
|
|
+ }
|
|
|
+
|
|
|
+ private String mediaTraceId(Map<String, String> params) {
|
|
|
+ for (String key : List.of("click_id", "trace_id", "callback", "request_id",
|
|
|
+ "oaid", "imei", "idfa", "caid")) {
|
|
|
+ String v = params.get(key);
|
|
|
+ if (v != null && !v.isBlank()) return v;
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ private Map<String, Object> mediaBidRequest(HttpServletRequest request, String media,
|
|
|
+ String traceId, Map<String, String> params,
|
|
|
+ MediaPlacement placement) {
|
|
|
+ MediaPlacement.Resolved resolved = placement.resolve(params);
|
|
|
+ List<Integer> actionTypes = placement.getActionTypes();
|
|
|
+ if (actionTypes == null || actionTypes.isEmpty()) actionTypes = List.of(1);
|
|
|
+
|
|
|
+ params.put("baidu_app_id", resolved.appId());
|
|
|
+ params.put("baidu_tag_id", resolved.tagId());
|
|
|
+ if (resolved.platform() != null && !resolved.platform().isBlank()) {
|
|
|
+ params.put("baidu_platform", resolved.platform());
|
|
|
+ }
|
|
|
+
|
|
|
+ Map<String, Object> imp = new LinkedHashMap<>();
|
|
|
+ imp.put("id", "1");
|
|
|
+ imp.put("appId", resolved.appId());
|
|
|
+ imp.put("tagId", resolved.tagId());
|
|
|
+ imp.put("secure", 1);
|
|
|
+ imp.put("adType", 0);
|
|
|
+ imp.put("assets", defaultBaiduAssets());
|
|
|
+ imp.put("actionType", actionTypes);
|
|
|
+ imp.put("maxCount", 1);
|
|
|
+ if (placement.getBidFloor() > 0) imp.put("bidFloor", placement.getBidFloor());
|
|
|
+
|
|
|
+ Map<String, Object> device = new LinkedHashMap<>();
|
|
|
+ // 仅 dev/test 环境允许通过 ip 参数覆盖(本地测试用)
|
|
|
+ String ip = allowIpOverride ? params.get("ip") : null;
|
|
|
+ device.put("ip", (ip != null && !ip.isBlank()) ? ip : clientIp(request));
|
|
|
+ device.put("ua", AdxClient.BAIDU_USER_AGENT);
|
|
|
+ device.put("deviceType", 1);
|
|
|
+ int os = baiduOsForPlatform(resolved.platform());
|
|
|
+ if (os != 0) device.put("os", os);
|
|
|
+ Map<String, Object> uid = baiduUidFromParams(params);
|
|
|
+ if (!uid.isEmpty()) device.put("uid", uid);
|
|
|
+
|
|
|
+ Map<String, Object> bidReq = new LinkedHashMap<>();
|
|
|
+ bidReq.put("mediaId", placement.getBaiduMediaId());
|
|
|
+ Instant now = Instant.now();
|
|
|
+ long nanos = now.getEpochSecond() * 1_000_000_000L + now.getNano();
|
|
|
+ bidReq.put("reqId", traceId + "-" + nanos);
|
|
|
+ bidReq.put("imp", List.of(imp));
|
|
|
+ bidReq.put("device", device);
|
|
|
+ bidReq.put("media", media);
|
|
|
+ bidReq.put("traceId", traceId);
|
|
|
+ bidReq.put("mediaParams", params);
|
|
|
+ return bidReq;
|
|
|
+ }
|
|
|
+
|
|
|
+ private List<Map<String, Integer>> defaultBaiduAssets() {
|
|
|
+ return List.of(
|
|
|
+ Map.of("templateId", 1, "ratio", 2),
|
|
|
+ Map.of("templateId", 1, "ratio", 7),
|
|
|
+ Map.of("templateId", 2, "ratio", 1),
|
|
|
+ Map.of("templateId", 2, "ratio", 4),
|
|
|
+ Map.of("templateId", 3, "ratio", 2),
|
|
|
+ Map.of("templateId", 4, "ratio", 4),
|
|
|
+ Map.of("templateId", 4, "ratio", 5)
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ private int baiduOsForPlatform(String platform) {
|
|
|
+ if (platform == null) return 0;
|
|
|
+ return switch (platform) {
|
|
|
+ case "ios" -> 1;
|
|
|
+ case "android" -> 2;
|
|
|
+ default -> 0;
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ private Map<String, Object> baiduUidFromParams(Map<String, String> p) {
|
|
|
+ Map<String, Object> uid = new LinkedHashMap<>();
|
|
|
+ putUid(uid, "did", firstParam(p, "did", "imei"));
|
|
|
+ putUid(uid, "didMd5", firstParam(p, "did_md5", "didMd5", "imei_md5", "imeiMd5"));
|
|
|
+ putUid(uid, "dpid", firstParam(p, "dpid", "android_id", "androidId"));
|
|
|
+ putUid(uid, "dpidMd5", firstParam(p, "dpid_md5", "dpidMd5", "android_id_md5", "androidIdMd5"));
|
|
|
+ putUid(uid, "idfa", p.get("idfa"));
|
|
|
+ putUid(uid, "idfaMd5", firstParam(p, "idfa_md5", "idfaMd5"));
|
|
|
+ putUid(uid, "oaid", p.get("oaid"));
|
|
|
+ putUid(uid, "oaidMd5", firstParam(p, "oaid_md5", "oaidMd5"));
|
|
|
+ return uid;
|
|
|
+ }
|
|
|
+
|
|
|
+ private void putUid(Map<String, Object> uid, String key, String value) {
|
|
|
+ if (value != null && !value.isBlank()) uid.put(key, value);
|
|
|
+ }
|
|
|
+
|
|
|
+ private String firstParam(Map<String, String> p, String... keys) {
|
|
|
+ for (String k : keys) {
|
|
|
+ String v = p.get(k);
|
|
|
+ if (v != null && !v.isBlank()) return v.trim();
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ private void recordTrackingResults(String qk, String kind, List<TrackingResult> results) {
|
|
|
+ if (hotStore == null || results == null) return;
|
|
|
+ for (TrackingResult r : results) {
|
|
|
+ TrackingRecord record = new TrackingRecord();
|
|
|
+ record.setQk(qk);
|
|
|
+ record.setKind(kind);
|
|
|
+ record.setUrl(r.getUrl());
|
|
|
+ record.setStatus(r.getStatus());
|
|
|
+ record.setOk(r.isOk());
|
|
|
+ record.setCreatedAt(Instant.now());
|
|
|
+ hotStore.recordTracking(record);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ static String clientIp(HttpServletRequest request) {
|
|
|
+ String xff = request.getHeader("X-Forwarded-For");
|
|
|
+ if (xff != null && !xff.isBlank()) {
|
|
|
+ for (String part : xff.split(",")) {
|
|
|
+ String ip = part.trim();
|
|
|
+ if (!ip.isEmpty()) return ip;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ String xri = request.getHeader("X-Real-IP");
|
|
|
+ if (xri != null && !xri.isBlank()) {
|
|
|
+ return xri.trim();
|
|
|
+ }
|
|
|
+ return request.getRemoteAddr();
|
|
|
+ }
|
|
|
+
|
|
|
+ private static String nvl(String s) { return s != null ? s : ""; }
|
|
|
+}
|