TrackingController.java 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412
  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. if (resp.getBid() != null && resp.getBid().getShowUrls() != null
  141. && !resp.getBid().getShowUrls().isEmpty()) {
  142. List<TrackingResult> impressionResults = adxClient.reportImpression(
  143. resp.getBid().getShowUrls(), resp.getBid().getPrice());
  144. log.info("[click] Redis未命中补竞价后,曝光上报完成, qk={}, results={}", record.getQk(), impressionResults);
  145. recordTrackingResults(record.getQk(), "impression", impressionResults);
  146. }
  147. log.info("[click] 竞价成功, qk={}, traceId={}", record.getQk(), traceId);
  148. } else {
  149. log.info("[click] Redis命中, qk={}, traceId={}", record.getQk(), traceId);
  150. }
  151. // 上报百度点击
  152. List<TrackingResult> trackingResults = adxClient.reportClick(
  153. record.getClickUrls() != null ? record.getClickUrls() : Collections.emptyList());
  154. log.info("[click] 点击上报完成, qk={}, results={}", record.getQk(), trackingResults);
  155. recordTrackingResults(record.getQk(), "click", trackingResults);
  156. // 点击处理完成,缩减 Redis 数据(清除 clickUrls/landingPage/appStoreLink)
  157. String destination = record.getAppStoreLink() != null && !record.getAppStoreLink().isBlank()
  158. ? record.getAppStoreLink() : record.getLandingPage();
  159. try {
  160. hotStore.shrinkAfterClick(record);
  161. } catch (Exception e) {
  162. log.warn("[click] Redis缩减失败(不影响主流程), qk={}, error={}", record.getQk(), e.getMessage());
  163. }
  164. // 重定向到 landing page 或 app store link
  165. if (destination != null && !destination.isBlank()) {
  166. log.info("[click] 302重定向, qk={}, destination={}", record.getQk(), destination);
  167. response.sendRedirect(destination);
  168. return null;
  169. }
  170. return Map.of("qk", nvl(record.getQk()), "traceId", traceId, "tracking", trackingResults);
  171. }
  172. // ─── 私有工具方法 ────────────────────────────────────────────────────────
  173. private BidRecord bidRecordFromMediaResponse(NormalizedBidResponse response,
  174. String media, String traceId,
  175. String eventType,
  176. Map<String, String> mediaParams) {
  177. BidRecord r = new BidRecord();
  178. r.setQk(response.getQk());
  179. r.setReqId(response.getReqId());
  180. r.setBidId(response.getBidId());
  181. r.setMedia(media);
  182. r.setMediaTraceId(traceId);
  183. r.setPlatform(mediaParams.get("baidu_platform"));
  184. r.setAdId(mediaParams.get("ad_id"));
  185. r.setAccountId(mediaParams.get("account_id"));
  186. r.setTagId(mediaParams.get("baidu_tag_id"));
  187. r.setEventType(eventType);
  188. r.setMediaParams(mediaParams);
  189. r.setCreatedAt(Instant.now());
  190. if (response.getBid() != null) {
  191. r.setCreativeId(response.getBid().getCrid());
  192. r.setImpId(response.getBid().getImpId());
  193. r.setTagId(response.getBid().getTagId());
  194. r.setPrice(response.getBid().getPrice());
  195. r.setShowUrls(response.getBid().getShowUrls());
  196. r.setClickUrls(response.getBid().getClickUrls());
  197. r.setLandingPage(response.getBid().getLandingPage());
  198. r.setAppStoreLink(response.getBid().getAppStoreLink());
  199. if (response.getBid().getAdm() != null) {
  200. r.setPackageName(response.getBid().getAdm().getPackageName());
  201. }
  202. }
  203. return r;
  204. }
  205. private Map<String, String> flatQueryParams(HttpServletRequest request) {
  206. Map<String, String> params = new HashMap<>();
  207. request.getParameterMap().forEach((k, v) -> {
  208. if (v != null && v.length > 0) params.put(k, v[0]);
  209. });
  210. return params;
  211. }
  212. private String mediaTraceId(Map<String, String> params) {
  213. for (String key : List.of("click_id", "impression_id", "trace_id", "callback", "request_id",
  214. "oaid", "imei", "idfa", "caid")) {
  215. String v = params.get(key);
  216. if (v != null && !v.isBlank()) return v;
  217. }
  218. return null;
  219. }
  220. private Map<String, Object> mediaBidRequest(HttpServletRequest request, String media,
  221. String traceId, Map<String, String> params,
  222. MediaPlacement placement) {
  223. MediaPlacement.Resolved resolved = placement.resolve(params);
  224. List<Integer> actionTypes = placement.getActionTypes();
  225. if (actionTypes == null || actionTypes.isEmpty()) actionTypes = List.of(0, 1, 2);
  226. params.put("baidu_app_id", resolved.appId());
  227. params.put("baidu_tag_id", resolved.tagId());
  228. if (resolved.platform() != null && !resolved.platform().isBlank()) {
  229. params.put("baidu_platform", resolved.platform());
  230. }
  231. Map<String, Object> imp = new LinkedHashMap<>();
  232. imp.put("id", "1");
  233. imp.put("appId", resolved.appId());
  234. imp.put("tagId", resolved.tagId());
  235. imp.put("secure", 1);
  236. imp.put("adType", 0);
  237. imp.put("assets", defaultBaiduAssets());
  238. imp.put("actionType", actionTypes);
  239. imp.put("maxCount", 1);
  240. if (placement.getBidFloor() > 0) imp.put("bidFloor", placement.getBidFloor());
  241. Map<String, Object> device = new LinkedHashMap<>();
  242. // S2S架构:腾讯服务器回调,真实用户IP通过参数传递,优先使用参数中的IP
  243. String paramIp = params.get("ip");
  244. device.put("ip", (paramIp != null && !paramIp.isBlank()) ? paramIp : clientIp(request));
  245. // 优先使用真实设备UA,无则根据平台构造合理的移动设备UA
  246. String ua = firstParam(params, "ua", "user_agent", "useragent");
  247. if (ua == null || ua.isBlank()) {
  248. ua = defaultMobileUa(resolved.platform());
  249. }
  250. device.put("ua", ua);
  251. device.put("deviceType", 1);
  252. int os = baiduOsForPlatform(resolved.platform());
  253. if (os != 0) device.put("os", os);
  254. Map<String, Object> uid = baiduUidFromParams(params);
  255. if (!uid.isEmpty()) device.put("uid", uid);
  256. Map<String, Object> bidReq = new LinkedHashMap<>();
  257. bidReq.put("mediaId", placement.getBaiduMediaId());
  258. Instant now = Instant.now();
  259. long nanos = now.getEpochSecond() * 1_000_000_000L + now.getNano();
  260. bidReq.put("reqId", traceId + "-" + nanos);
  261. bidReq.put("imp", List.of(imp));
  262. bidReq.put("device", device);
  263. List<Integer> appList = parseAppList(firstParam(params, "appList", "app_list", "applist"));
  264. if (!appList.isEmpty()) bidReq.put("appList", appList);
  265. log.info("[百度ADX] 竞价请求 | traceId={} | body={}", traceId, bidReq);
  266. return bidReq;
  267. }
  268. private List<Map<String, Integer>> defaultBaiduAssets() {
  269. return List.of(
  270. Map.of("templateId", 1, "ratio", 2),
  271. Map.of("templateId", 1, "ratio", 7),
  272. Map.of("templateId", 2, "ratio", 1),
  273. Map.of("templateId", 2, "ratio", 4),
  274. Map.of("templateId", 3, "ratio", 2),
  275. Map.of("templateId", 4, "ratio", 4),
  276. Map.of("templateId", 4, "ratio", 5)
  277. );
  278. }
  279. private int baiduOsForPlatform(String platform) {
  280. if (platform == null) return 0;
  281. return switch (platform) {
  282. case "ios" -> 1;
  283. case "android" -> 2;
  284. default -> 0;
  285. };
  286. }
  287. /** 根据平台返回合理的移动设备UA,确保百度能识别为真实手机请求 */
  288. private static String defaultMobileUa(String platform) {
  289. if ("ios".equals(platform)) {
  290. return "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1";
  291. }
  292. // 默认Android
  293. return "Mozilla/5.0 (Linux; Android 14; Pixel 8 Build/UQ1A.240205.002) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.71 Mobile Safari/537.36";
  294. }
  295. private Map<String, Object> baiduUidFromParams(Map<String, String> p) {
  296. Map<String, Object> uid = new LinkedHashMap<>();
  297. putUid(uid, "did", firstParam(p, "did", "imei"));
  298. putUid(uid, "didMd5", firstParam(p, "did_md5", "didMd5", "imei_md5", "imeiMd5"));
  299. putUid(uid, "dpid", firstParam(p, "dpid", "android_id", "androidId"));
  300. putUid(uid, "dpidMd5", firstParam(p, "dpid_md5", "dpidMd5", "android_id_md5", "androidIdMd5", "hash_android_id"));
  301. putUid(uid, "idfa", p.get("idfa"));
  302. putUid(uid, "idfaMd5", firstParam(p, "idfa_md5", "idfaMd5"));
  303. putUid(uid, "oaid", p.get("oaid"));
  304. putUid(uid, "oaidMd5", firstParam(p, "oaid_md5", "oaidMd5", "hash_oaid"));
  305. // 百度ADX要求muid不能为空,Android用IMEI MD5,无则用oaidMd5或dpidMd5兜底
  306. String muid = firstParam(p, "muid", "imei_md5", "imeiMd5", "did_md5", "didMd5");
  307. if (muid == null || muid.isBlank()) {
  308. muid = firstParam(p, "hash_oaid", "oaid_md5", "oaidMd5", "hash_android_id", "dpid_md5", "dpidMd5");
  309. }
  310. putUid(uid, "muid", muid);
  311. return uid;
  312. }
  313. private void putUid(Map<String, Object> uid, String key, String value) {
  314. if (value != null && !value.isBlank()) uid.put(key, value);
  315. }
  316. private String firstParam(Map<String, String> p, String... keys) {
  317. for (String k : keys) {
  318. String v = p.get(k);
  319. if (v != null && !v.isBlank()) return v.trim();
  320. }
  321. return null;
  322. }
  323. private List<Integer> parseAppList(String value) {
  324. if (value == null || value.isBlank()) return Collections.emptyList();
  325. List<Integer> result = new ArrayList<>();
  326. for (String part : value.split("[,|]")) {
  327. String item = part == null ? null : part.trim();
  328. if (item == null || item.isEmpty()) continue;
  329. try {
  330. result.add(Integer.parseInt(item));
  331. } catch (NumberFormatException e) {
  332. log.warn("[百度ADX] 忽略非法 appList 值 | raw={} | item={}", value, item);
  333. }
  334. }
  335. return result;
  336. }
  337. private void recordTrackingResults(String qk, String kind, List<TrackingResult> results) {
  338. if (hotStore == null || results == null) return;
  339. for (TrackingResult r : results) {
  340. TrackingRecord record = new TrackingRecord();
  341. record.setQk(qk);
  342. record.setKind(kind);
  343. record.setUrl(r.getUrl());
  344. record.setStatus(r.getStatus());
  345. record.setOk(r.isOk());
  346. record.setCreatedAt(Instant.now());
  347. hotStore.recordTracking(record);
  348. }
  349. }
  350. static String clientIp(HttpServletRequest request) {
  351. String xff = request.getHeader("X-Forwarded-For");
  352. if (xff != null && !xff.isBlank()) {
  353. for (String part : xff.split(",")) {
  354. String ip = part.trim();
  355. if (!ip.isEmpty()) return ip;
  356. }
  357. }
  358. String xri = request.getHeader("X-Real-IP");
  359. if (xri != null && !xri.isBlank()) {
  360. return xri.trim();
  361. }
  362. return request.getRemoteAddr();
  363. }
  364. private static String nvl(String s) { return s != null ? s : ""; }
  365. }