TrackingController.java 19 KB

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