TrackingController.java 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372
  1. package com.adx.tencent.httpapi;
  2. import com.adx.tencent.baidu.AdxClient;
  3. import com.adx.tencent.baidu.model.NormalizedBidResponse;
  4. import com.adx.tencent.baidu.model.TrackingResult;
  5. import com.adx.tencent.storage.RedisHotStore;
  6. import com.adx.tencent.storage.model.BidRecord;
  7. import com.adx.tencent.storage.model.TrackingRecord;
  8. import org.slf4j.Logger;
  9. import org.slf4j.LoggerFactory;
  10. import org.springframework.beans.factory.annotation.Value;
  11. import org.springframework.http.HttpStatus;
  12. import org.springframework.lang.Nullable;
  13. import org.springframework.web.bind.annotation.*;
  14. import org.springframework.web.server.ResponseStatusException;
  15. import jakarta.servlet.http.HttpServletRequest;
  16. import jakarta.servlet.http.HttpServletResponse;
  17. import java.time.Instant;
  18. import java.util.*;
  19. import java.util.concurrent.Semaphore;
  20. /**
  21. * 腾讯监测链接接收端点。
  22. * 提供曝光和点击监测 URL,接收腾讯广告平台的回调。
  23. */
  24. @RestController
  25. public class TrackingController {
  26. private static final Logger log = LoggerFactory.getLogger(TrackingController.class);
  27. private static final int MAX_CONCURRENT = 50;
  28. private final Semaphore impressionSemaphore = new Semaphore(MAX_CONCURRENT);
  29. private final Semaphore clickSemaphore = new Semaphore(MAX_CONCURRENT);
  30. private final AdxClient adxClient;
  31. private final RedisHotStore hotStore;
  32. private final MediaPlacement tencentPlacement;
  33. private final boolean allowIpOverride;
  34. public TrackingController(AdxClient adxClient,
  35. RedisHotStore hotStore,
  36. @Nullable MediaPlacement tencentPlacement,
  37. @Value("${spring.profiles.active:}") String activeProfile) {
  38. this.adxClient = adxClient;
  39. this.hotStore = hotStore;
  40. this.tencentPlacement = tencentPlacement;
  41. this.allowIpOverride = "dev".equals(activeProfile) || "test".equals(activeProfile);
  42. }
  43. // ─── /health ────────────────────────────────────────────────────────────
  44. @GetMapping("/health")
  45. public Map<String, Boolean> health() {
  46. return Map.of("ok", true);
  47. }
  48. // ─── GET /tencent/impression ─────────────────────────────────────────────
  49. /**
  50. * 接收腾讯曝光监测。
  51. * 流程:解析参数 → 构造百度竞价请求 → 请求百度ADX → 缓存bid → 上报百度曝光 → 返回204
  52. * 限制最多50个线程同时执行。
  53. */
  54. @GetMapping("/tencent/impression")
  55. @ResponseStatus(HttpStatus.NO_CONTENT)
  56. public void tencentImpression(HttpServletRequest request) throws Exception {
  57. impressionSemaphore.acquire();
  58. try {
  59. doImpression(request);
  60. } finally {
  61. impressionSemaphore.release();
  62. }
  63. }
  64. private void doImpression(HttpServletRequest request) throws Exception {
  65. Map<String, String> params = flatQueryParams(request);
  66. String traceId = mediaTraceId(params);
  67. if (traceId == null || traceId.isBlank()) {
  68. log.warn("[impression] 缺少 traceId, params={}", params.keySet());
  69. throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "missing media trace id (click_id/oaid/imei/idfa)");
  70. }
  71. if (tencentPlacement == null) {
  72. throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "tencent placement not configured");
  73. }
  74. log.info("[曝光] 收到曝光请求 | traceId={} | IP={} | 全部入参: {}", traceId, clientIp(request), params);
  75. Map<String, Object> bidRequest = mediaBidRequest(request, "tencent", traceId, params, tencentPlacement);
  76. NormalizedBidResponse response = adxClient.requestBid(bidRequest);
  77. if (response == null) {
  78. log.warn("[impression] 百度ADX返回空响应, traceId={}", traceId);
  79. throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "empty baidu ADX response");
  80. }
  81. log.info("[impression] 百度ADX返回: qk={}, hasBid={}, showUrls={}, clickUrls={}",
  82. response.getQk(),
  83. response.getBid() != null,
  84. response.getBid() != null && response.getBid().getShowUrls() != null ? response.getBid().getShowUrls().size() : 0,
  85. response.getBid() != null && response.getBid().getClickUrls() != null ? response.getBid().getClickUrls().size() : 0);
  86. BidRecord record = bidRecordFromMediaResponse(response, "tencent", traceId, "impression", params);
  87. hotStore.recordBid(record);
  88. log.info("[impression] 已缓存bid, qk={}, traceId={}", record.getQk(), traceId);
  89. // 上报百度曝光
  90. if (response.getBid() != null && response.getBid().getShowUrls() != null
  91. && !response.getBid().getShowUrls().isEmpty()) {
  92. List<TrackingResult> trackingResults = adxClient.reportImpression(
  93. response.getBid().getShowUrls(), response.getBid().getPrice());
  94. log.info("[impression] 曝光上报完成, qk={}, results={}", response.getQk(), trackingResults);
  95. recordTrackingResults(response.getQk(), "impression", trackingResults);
  96. }
  97. }
  98. // ─── GET /tencent/click ──────────────────────────────────────────────────
  99. /**
  100. * 接收腾讯点击监测。
  101. * 流程:查找已有bid → 若无则请求百度ADX → 上报百度点击 → 302重定向到落地页
  102. * 限制最多50个线程同时执行。
  103. */
  104. @GetMapping("/tencent/click")
  105. public Object tencentClick(HttpServletRequest request, HttpServletResponse response)
  106. throws Exception {
  107. clickSemaphore.acquire();
  108. try {
  109. return doClick(request, response);
  110. } finally {
  111. clickSemaphore.release();
  112. }
  113. }
  114. private Object doClick(HttpServletRequest request, HttpServletResponse response)
  115. throws Exception {
  116. if (tencentPlacement == null) {
  117. throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "tencent placement not configured");
  118. }
  119. Map<String, String> params = flatQueryParams(request);
  120. String traceId = mediaTraceId(params);
  121. if (traceId == null || traceId.isBlank()) {
  122. log.warn("[click] 缺少 traceId, params={}", params.keySet());
  123. throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "missing media trace id (click_id/oaid/imei/idfa)");
  124. }
  125. log.info("[点击] 收到点击请求 | traceId={} | IP={} | 全部入参: {}", traceId, clientIp(request), params);
  126. // 先从 Redis 按 mediaTrace 查找已有 bid
  127. BidRecord record = hotStore.findBidByMediaTrace("tencent", traceId);
  128. if (record == null) {
  129. log.info("[click] Redis未命中, 发起竞价请求, traceId={}", traceId);
  130. // 未找到,临时发起竞价请求
  131. Map<String, Object> bidRequest = mediaBidRequest(request, "tencent", traceId, params, tencentPlacement);
  132. NormalizedBidResponse resp = adxClient.requestBid(bidRequest);
  133. if (resp == null) {
  134. log.warn("[click] 百度ADX返回空响应, traceId={}", traceId);
  135. throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "empty baidu ADX response");
  136. }
  137. BidRecord newRecord = bidRecordFromMediaResponse(resp, "tencent", traceId, "click", params);
  138. hotStore.recordBid(newRecord);
  139. record = newRecord;
  140. log.info("[click] 竞价成功, qk={}, traceId={}", record.getQk(), traceId);
  141. } else {
  142. log.info("[click] Redis命中, qk={}, traceId={}", record.getQk(), traceId);
  143. }
  144. // 上报百度点击
  145. List<TrackingResult> trackingResults = adxClient.reportClick(
  146. record.getClickUrls() != null ? record.getClickUrls() : Collections.emptyList());
  147. log.info("[click] 点击上报完成, qk={}, results={}", record.getQk(), trackingResults);
  148. recordTrackingResults(record.getQk(), "click", trackingResults);
  149. // 点击处理完成,缩减 Redis 数据(清除 clickUrls/landingPage/appStoreLink)
  150. String destination = record.getAppStoreLink() != null && !record.getAppStoreLink().isBlank()
  151. ? record.getAppStoreLink() : record.getLandingPage();
  152. try {
  153. hotStore.shrinkAfterClick(record);
  154. } catch (Exception e) {
  155. log.warn("[click] Redis缩减失败(不影响主流程), qk={}, error={}", record.getQk(), e.getMessage());
  156. }
  157. // 重定向到 landing page 或 app store link
  158. if (destination != null && !destination.isBlank()) {
  159. log.info("[click] 302重定向, qk={}, destination={}", record.getQk(), destination);
  160. response.sendRedirect(destination);
  161. return null;
  162. }
  163. return Map.of("qk", nvl(record.getQk()), "traceId", traceId, "tracking", trackingResults);
  164. }
  165. // ─── 私有工具方法 ────────────────────────────────────────────────────────
  166. private BidRecord bidRecordFromMediaResponse(NormalizedBidResponse response,
  167. String media, String traceId,
  168. String eventType,
  169. Map<String, String> mediaParams) {
  170. BidRecord r = new BidRecord();
  171. r.setQk(response.getQk());
  172. r.setReqId(response.getReqId());
  173. r.setBidId(response.getBidId());
  174. r.setMedia(media);
  175. r.setMediaTraceId(traceId);
  176. r.setPlatform(mediaParams.get("baidu_platform"));
  177. r.setAdId(mediaParams.get("ad_id"));
  178. r.setAccountId(mediaParams.get("account_id"));
  179. r.setEventType(eventType);
  180. r.setMediaParams(mediaParams);
  181. r.setCreatedAt(Instant.now());
  182. if (response.getBid() != null) {
  183. r.setCreativeId(response.getBid().getCrid());
  184. r.setImpId(response.getBid().getImpId());
  185. r.setTagId(response.getBid().getTagId());
  186. r.setPrice(response.getBid().getPrice());
  187. r.setShowUrls(response.getBid().getShowUrls());
  188. r.setClickUrls(response.getBid().getClickUrls());
  189. r.setLandingPage(response.getBid().getLandingPage());
  190. r.setAppStoreLink(response.getBid().getAppStoreLink());
  191. if (response.getBid().getAdm() != null) {
  192. r.setPackageName(response.getBid().getAdm().getPackageName());
  193. }
  194. }
  195. return r;
  196. }
  197. private Map<String, String> flatQueryParams(HttpServletRequest request) {
  198. Map<String, String> params = new HashMap<>();
  199. request.getParameterMap().forEach((k, v) -> {
  200. if (v != null && v.length > 0) params.put(k, v[0]);
  201. });
  202. return params;
  203. }
  204. private String mediaTraceId(Map<String, String> params) {
  205. for (String key : List.of("click_id", "impression_id", "trace_id", "callback", "request_id",
  206. "oaid", "imei", "idfa", "caid")) {
  207. String v = params.get(key);
  208. if (v != null && !v.isBlank()) return v;
  209. }
  210. return null;
  211. }
  212. private Map<String, Object> mediaBidRequest(HttpServletRequest request, String media,
  213. String traceId, Map<String, String> params,
  214. MediaPlacement placement) {
  215. MediaPlacement.Resolved resolved = placement.resolve(params);
  216. List<Integer> actionTypes = placement.getActionTypes();
  217. if (actionTypes == null || actionTypes.isEmpty()) actionTypes = List.of(1);
  218. params.put("baidu_app_id", resolved.appId());
  219. params.put("baidu_tag_id", resolved.tagId());
  220. if (resolved.platform() != null && !resolved.platform().isBlank()) {
  221. params.put("baidu_platform", resolved.platform());
  222. }
  223. Map<String, Object> imp = new LinkedHashMap<>();
  224. imp.put("id", "1");
  225. imp.put("appId", resolved.appId());
  226. imp.put("tagId", resolved.tagId());
  227. imp.put("secure", 1);
  228. imp.put("adType", 0);
  229. imp.put("assets", defaultBaiduAssets());
  230. imp.put("actionType", actionTypes);
  231. imp.put("maxCount", 1);
  232. if (placement.getBidFloor() > 0) imp.put("bidFloor", placement.getBidFloor());
  233. Map<String, Object> device = new LinkedHashMap<>();
  234. // 仅 dev/test 环境允许通过 ip 参数覆盖(本地测试用)
  235. String ip = allowIpOverride ? params.get("ip") : null;
  236. device.put("ip", (ip != null && !ip.isBlank()) ? ip : clientIp(request));
  237. // 优先使用真实设备UA,无则使用默认UA
  238. String ua = firstParam(params, "ua", "user_agent", "useragent");
  239. device.put("ua", (ua != null && !ua.isBlank()) ? ua : AdxClient.BAIDU_USER_AGENT);
  240. device.put("deviceType", 1);
  241. int os = baiduOsForPlatform(resolved.platform());
  242. if (os != 0) device.put("os", os);
  243. Map<String, Object> uid = baiduUidFromParams(params);
  244. if (!uid.isEmpty()) device.put("uid", uid);
  245. Map<String, Object> bidReq = new LinkedHashMap<>();
  246. bidReq.put("mediaId", placement.getBaiduMediaId());
  247. Instant now = Instant.now();
  248. long nanos = now.getEpochSecond() * 1_000_000_000L + now.getNano();
  249. bidReq.put("reqId", traceId + "-" + nanos);
  250. bidReq.put("imp", List.of(imp));
  251. bidReq.put("device", device);
  252. bidReq.put("media", media);
  253. bidReq.put("traceId", traceId);
  254. bidReq.put("mediaParams", params);
  255. log.info("[百度ADX] 竞价请求 | traceId={} | body={}", traceId, bidReq);
  256. return bidReq;
  257. }
  258. private List<Map<String, Integer>> defaultBaiduAssets() {
  259. return List.of(
  260. Map.of("templateId", 1, "ratio", 2),
  261. Map.of("templateId", 1, "ratio", 7),
  262. Map.of("templateId", 2, "ratio", 1),
  263. Map.of("templateId", 2, "ratio", 4),
  264. Map.of("templateId", 3, "ratio", 2),
  265. Map.of("templateId", 4, "ratio", 4),
  266. Map.of("templateId", 4, "ratio", 5)
  267. );
  268. }
  269. private int baiduOsForPlatform(String platform) {
  270. if (platform == null) return 0;
  271. return switch (platform) {
  272. case "ios" -> 1;
  273. case "android" -> 2;
  274. default -> 0;
  275. };
  276. }
  277. private Map<String, Object> baiduUidFromParams(Map<String, String> p) {
  278. Map<String, Object> uid = new LinkedHashMap<>();
  279. putUid(uid, "did", firstParam(p, "did", "imei"));
  280. putUid(uid, "didMd5", firstParam(p, "did_md5", "didMd5", "imei_md5", "imeiMd5"));
  281. putUid(uid, "dpid", firstParam(p, "dpid", "android_id", "androidId"));
  282. putUid(uid, "dpidMd5", firstParam(p, "dpid_md5", "dpidMd5", "android_id_md5", "androidIdMd5"));
  283. putUid(uid, "idfa", p.get("idfa"));
  284. putUid(uid, "idfaMd5", firstParam(p, "idfa_md5", "idfaMd5"));
  285. putUid(uid, "oaid", p.get("oaid"));
  286. putUid(uid, "oaidMd5", firstParam(p, "oaid_md5", "oaidMd5"));
  287. return uid;
  288. }
  289. private void putUid(Map<String, Object> uid, String key, String value) {
  290. if (value != null && !value.isBlank()) uid.put(key, value);
  291. }
  292. private String firstParam(Map<String, String> p, String... keys) {
  293. for (String k : keys) {
  294. String v = p.get(k);
  295. if (v != null && !v.isBlank()) return v.trim();
  296. }
  297. return null;
  298. }
  299. private void recordTrackingResults(String qk, String kind, List<TrackingResult> results) {
  300. if (hotStore == null || results == null) return;
  301. for (TrackingResult r : results) {
  302. TrackingRecord record = new TrackingRecord();
  303. record.setQk(qk);
  304. record.setKind(kind);
  305. record.setUrl(r.getUrl());
  306. record.setStatus(r.getStatus());
  307. record.setOk(r.isOk());
  308. record.setCreatedAt(Instant.now());
  309. hotStore.recordTracking(record);
  310. }
  311. }
  312. static String clientIp(HttpServletRequest request) {
  313. String xff = request.getHeader("X-Forwarded-For");
  314. if (xff != null && !xff.isBlank()) {
  315. for (String part : xff.split(",")) {
  316. String ip = part.trim();
  317. if (!ip.isEmpty()) return ip;
  318. }
  319. }
  320. String xri = request.getHeader("X-Real-IP");
  321. if (xri != null && !xri.isBlank()) {
  322. return xri.trim();
  323. }
  324. return request.getRemoteAddr();
  325. }
  326. private static String nvl(String s) { return s != null ? s : ""; }
  327. }