TrackingController.java 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724
  1. package com.adx.tencent.httpapi;
  2. import com.adx.tencent.baidu.model.NormalizedBidResponse;
  3. import com.adx.tencent.baidu.AdxClient;
  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.adx.tencent.util.TraceIds;
  9. import com.fasterxml.jackson.databind.ObjectMapper;
  10. import org.slf4j.Logger;
  11. import org.slf4j.LoggerFactory;
  12. import org.springframework.beans.factory.annotation.Value;
  13. import org.springframework.http.HttpStatus;
  14. import org.springframework.lang.Nullable;
  15. import org.springframework.web.bind.annotation.*;
  16. import org.springframework.web.server.ResponseStatusException;
  17. import jakarta.servlet.http.HttpServletRequest;
  18. import jakarta.servlet.http.HttpServletResponse;
  19. import java.net.URLDecoder;
  20. import java.nio.charset.StandardCharsets;
  21. import java.time.Instant;
  22. import java.util.*;
  23. import java.util.concurrent.Semaphore;
  24. /**
  25. * 腾讯监测链接接收端点。
  26. * 提供曝光和点击监测 URL,接收腾讯广告平台的回调。
  27. */
  28. @RestController
  29. public class TrackingController {
  30. private static final Logger log = LoggerFactory.getLogger(TrackingController.class);
  31. private static final int MAX_CONCURRENT = 50;
  32. private final Semaphore impressionSemaphore = new Semaphore(MAX_CONCURRENT);
  33. private final Semaphore clickSemaphore = new Semaphore(MAX_CONCURRENT);
  34. private final AdxClient adxClient;
  35. private final RedisHotStore hotStore;
  36. private final MediaPlacement tencentPlacement;
  37. private final boolean allowIpOverride;
  38. private final ObjectMapper objectMapper;
  39. public TrackingController(AdxClient adxClient,
  40. RedisHotStore hotStore,
  41. @Nullable MediaPlacement tencentPlacement,
  42. ObjectMapper objectMapper,
  43. @Value("${spring.profiles.active:}") String activeProfile) {
  44. this.adxClient = adxClient;
  45. this.hotStore = hotStore;
  46. this.tencentPlacement = tencentPlacement;
  47. this.objectMapper = objectMapper;
  48. this.allowIpOverride = "dev".equals(activeProfile) || "test".equals(activeProfile);
  49. }
  50. // ─── /health ────────────────────────────────────────────────────────────
  51. @GetMapping("/health")
  52. public Map<String, Boolean> health() {
  53. return Map.of("ok", true);
  54. }
  55. // ─── GET /tencent/impression ─────────────────────────────────────────────
  56. /**
  57. * 接收腾讯曝光监测。
  58. * 流程:解析参数 → 构造百度竞价请求 → 请求百度ADX → 缓存bid → 上报百度曝光 → 返回204
  59. * 限制最多50个线程同时执行。
  60. */
  61. @GetMapping("/tencent/impression")
  62. public Object tencentImpression(HttpServletRequest request,
  63. @RequestParam(name = "deductionRate", required = false, defaultValue = "0") int deductionRate)
  64. throws Exception {
  65. impressionSemaphore.acquire();
  66. try {
  67. return doImpression(request, "v1", deductionRate);
  68. } finally {
  69. impressionSemaphore.release();
  70. }
  71. }
  72. @GetMapping("/tencent/v2/impression")
  73. public Object tencentImpressionV2(HttpServletRequest request) throws Exception {
  74. impressionSemaphore.acquire();
  75. try {
  76. return doImpression(request, "v2", 20);
  77. } finally {
  78. impressionSemaphore.release();
  79. }
  80. }
  81. @GetMapping("/tencent/v3/impression")
  82. public Object tencentImpressionV3(HttpServletRequest request) throws Exception {
  83. impressionSemaphore.acquire();
  84. try {
  85. return doImpression(request, "v3", 50);
  86. } finally {
  87. impressionSemaphore.release();
  88. }
  89. }
  90. @GetMapping("/tencent/v4/impression")
  91. public Object tencentImpressionV4(HttpServletRequest request) throws Exception {
  92. impressionSemaphore.acquire();
  93. try {
  94. return doImpression(request, "v4", 70);
  95. } finally {
  96. impressionSemaphore.release();
  97. }
  98. }
  99. @GetMapping("/tencent/v5/impression")
  100. public Object tencentImpressionV5(HttpServletRequest request) throws Exception {
  101. impressionSemaphore.acquire();
  102. try {
  103. return doImpression(request, "v5", 40);
  104. } finally {
  105. impressionSemaphore.release();
  106. }
  107. }
  108. @GetMapping("/tencent/v6/impression")
  109. public Object tencentImpressionV6(HttpServletRequest request) throws Exception {
  110. impressionSemaphore.acquire();
  111. try {
  112. return doImpression(request, "v6", 30);
  113. } finally {
  114. impressionSemaphore.release();
  115. }
  116. }
  117. private Object doImpression(HttpServletRequest request, String trackingVersion, int deductionRate) throws Exception {
  118. Map<String, String> params = flatQueryParams(request);
  119. markTrackingVersion(params, trackingVersion, deductionRate);
  120. String traceId = mediaTraceId(params);
  121. if (traceId == null || traceId.isBlank()) {
  122. log.warn("[腾讯][曝光] 缺少 traceId, params={}", params.keySet());
  123. throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "missing media trace id (click_id/oaid/imei/idfa)");
  124. }
  125. if (tencentPlacement == null) {
  126. throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "tencent placement not configured");
  127. }
  128. log.info("[腾讯][曝光][入参] traceId={} ip={} params={}", traceId, clientIp(request), params);
  129. Map<String, Object> bidRequest = mediaBidRequest(request, "tencent", traceId, params, tencentPlacement);
  130. NormalizedBidResponse response = adxClient.requestBid("tencent", traceId, bidRequest);
  131. if (response == null) {
  132. log.warn("[腾讯][曝光][百度结果] traceId={} empty=true", traceId);
  133. throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "empty baidu ADX response");
  134. }
  135. log.info("[腾讯][曝光][竞价结果] traceId={} qk={} bidId={} success={} price={} clickUrlCount={} showUrlCount={}",
  136. traceId,
  137. response.getQk(),
  138. response.getBidId(),
  139. response.getBid() != null,
  140. response.getBid() != null ? response.getBid().getPrice() : 0,
  141. response.getBid() != null && response.getBid().getClickUrls() != null ? response.getBid().getClickUrls().size() : 0,
  142. response.getBid() != null && response.getBid().getShowUrls() != null ? response.getBid().getShowUrls().size() : 0);
  143. BidRecord record = bidRecordFromMediaResponse(response, "tencent", traceId, "impression", params);
  144. hotStore.recordBid(record);
  145. // 上报百度曝光
  146. if (response.getBid() != null && response.getBid().getShowUrls() != null
  147. && !response.getBid().getShowUrls().isEmpty()) {
  148. List<TrackingResult> trackingResults = reportDeviceTracking(record, params,
  149. response.getBid().getShowUrls(), response.getBid().getPrice(), false);
  150. recordTrackingResults(response.getQk(), "impression", trackingResults);
  151. }
  152. return Map.of("qk", nvl(record.getQk()), "traceId", traceId);
  153. }
  154. // ─── GET /tencent/click ──────────────────────────────────────────────────
  155. /**
  156. * 接收腾讯点击监测。
  157. * 流程:查找已有bid → 若无则请求百度ADX → 上报百度点击 → 302重定向到落地页
  158. * 限制最多50个线程同时执行。
  159. */
  160. @GetMapping("/tencent/click")
  161. public Object tencentClick(HttpServletRequest request,
  162. HttpServletResponse response,
  163. @RequestParam(name = "deductionRate", required = false, defaultValue = "0") int deductionRate)
  164. throws Exception {
  165. clickSemaphore.acquire();
  166. try {
  167. return doClick(request, response, "v1", deductionRate);
  168. } finally {
  169. clickSemaphore.release();
  170. }
  171. }
  172. @GetMapping("/tencent/v2/click")
  173. public Object tencentClickV2(HttpServletRequest request, HttpServletResponse response)
  174. throws Exception {
  175. clickSemaphore.acquire();
  176. try {
  177. return doClick(request, response, "v2", 20);
  178. } finally {
  179. clickSemaphore.release();
  180. }
  181. }
  182. @GetMapping("/tencent/v3/click")
  183. public Object tencentClickV3(HttpServletRequest request, HttpServletResponse response)
  184. throws Exception {
  185. clickSemaphore.acquire();
  186. try {
  187. return doClick(request, response, "v3", 50);
  188. } finally {
  189. clickSemaphore.release();
  190. }
  191. }
  192. @GetMapping("/tencent/v4/click")
  193. public Object tencentClickV4(HttpServletRequest request, HttpServletResponse response)
  194. throws Exception {
  195. clickSemaphore.acquire();
  196. try {
  197. return doClick(request, response, "v4", 70);
  198. } finally {
  199. clickSemaphore.release();
  200. }
  201. }
  202. @GetMapping("/tencent/v5/click")
  203. public Object tencentClickV5(HttpServletRequest request, HttpServletResponse response)
  204. throws Exception {
  205. clickSemaphore.acquire();
  206. try {
  207. return doClick(request, response, "v5", 40);
  208. } finally {
  209. clickSemaphore.release();
  210. }
  211. }
  212. @GetMapping("/tencent/v6/click")
  213. public Object tencentClickV6(HttpServletRequest request, HttpServletResponse response)
  214. throws Exception {
  215. clickSemaphore.acquire();
  216. try {
  217. return doClick(request, response, "v6", 30);
  218. } finally {
  219. clickSemaphore.release();
  220. }
  221. }
  222. private Object doClick(HttpServletRequest request, HttpServletResponse response,
  223. String trackingVersion, int deductionRate)
  224. throws Exception {
  225. if (tencentPlacement == null) {
  226. throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "tencent placement not configured");
  227. }
  228. Map<String, String> params = flatQueryParams(request);
  229. markTrackingVersion(params, trackingVersion, deductionRate);
  230. String traceId = mediaTraceId(params);
  231. if (traceId == null || traceId.isBlank()) {
  232. log.warn("[腾讯][点击] 缺少 traceId, params={}", params.keySet());
  233. throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "missing media trace id (click_id/oaid/imei/idfa)");
  234. }
  235. log.info("[腾讯][点击][入参] traceId={} ip={} params={}", traceId, clientIp(request), params);
  236. BidRecord record = hotStore.findBidByMediaTrace("tencent", traceId);
  237. if (record == null) {
  238. // 未找到,临时发起竞价请求
  239. Map<String, Object> bidRequest = mediaBidRequest(request, "tencent", traceId, params, tencentPlacement);
  240. NormalizedBidResponse resp = adxClient.requestBid("tencent", traceId, bidRequest);
  241. if (resp == null) {
  242. log.warn("[腾讯][点击][百度结果] traceId={} empty=true", traceId);
  243. throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "empty baidu ADX response");
  244. }
  245. log.info("[腾讯][点击][竞价结果] traceId={} qk={} bidId={} success={} price={} clickUrlCount={} showUrlCount={}",
  246. traceId,
  247. resp.getQk(),
  248. resp.getBidId(),
  249. resp.getBid() != null,
  250. resp.getBid() != null ? resp.getBid().getPrice() : 0,
  251. resp.getBid() != null && resp.getBid().getClickUrls() != null ? resp.getBid().getClickUrls().size() : 0,
  252. resp.getBid() != null && resp.getBid().getShowUrls() != null ? resp.getBid().getShowUrls().size() : 0);
  253. BidRecord newRecord = bidRecordFromMediaResponse(resp, "tencent", traceId, "click", params);
  254. hotStore.recordBid(newRecord);
  255. record = newRecord;
  256. if (resp.getBid() != null && resp.getBid().getShowUrls() != null
  257. && !resp.getBid().getShowUrls().isEmpty()) {
  258. List<TrackingResult> impressionResults = reportDeviceTracking(record, params,
  259. resp.getBid().getShowUrls(), resp.getBid().getPrice(), false);
  260. recordTrackingResults(record.getQk(), "impression", impressionResults);
  261. }
  262. }
  263. List<TrackingResult> trackingResults = reportDeviceTracking(record, params,
  264. record.getClickUrls() != null ? record.getClickUrls() : Collections.emptyList(), 0, true);
  265. recordTrackingResults(record.getQk(), "click", trackingResults);
  266. String destination = record.getAppStoreLink() != null && !record.getAppStoreLink().isBlank()
  267. ? record.getAppStoreLink() : record.getLandingPage();
  268. try {
  269. hotStore.shrinkAfterClick(record);
  270. } catch (Exception e) {
  271. }
  272. if (destination != null && !destination.isBlank()) {
  273. response.sendRedirect(destination);
  274. return null;
  275. }
  276. return Map.of("qk", nvl(record.getQk()), "traceId", traceId);
  277. }
  278. /** Only guard outbound monitoring; bidding, cache lifecycle and redirects keep their original flow. */
  279. private List<TrackingResult> reportDeviceTracking(BidRecord record, Map<String, String> params,
  280. List<String> urls, long price, boolean click) throws Exception {
  281. String tag = firstParam(params, "baidu_tag_id", "tag_id", "tagid");
  282. if (tag == null) tag = record.getTagId();
  283. // A cache hit previously needed no placement validation. Only read the default tag here.
  284. if (tag == null) tag = tencentPlacement.getBaiduTagId();
  285. return DeviceTrackingReporter.report(adxClient, "tencent", tag, params, record.getMediaParams(),
  286. urls, price, click, hotStore::claimDeviceTracking);
  287. }
  288. private void markTrackingVersion(Map<String, String> params, String trackingVersion, int deductionRate) {
  289. boolean isNewLink = !"v1".equalsIgnoreCase(trackingVersion);
  290. params.put(BidRecord.NEW_LINK_FLAG_KEY, Boolean.toString(isNewLink));
  291. params.put(BidRecord.TRACKING_VERSION_KEY, trackingVersion);
  292. params.put(BidRecord.DEDUCTION_RATE_KEY, String.valueOf(Math.max(deductionRate, 0)));
  293. String act2001Rate = firstParam(params,
  294. BidRecord.ACT_2001_DEDUCTION_RATE_KEY,
  295. "act2001DeductionRate",
  296. "deductionRate2001",
  297. "deduction_rate_2001");
  298. if (act2001Rate != null) {
  299. params.put(BidRecord.ACT_2001_DEDUCTION_RATE_KEY, act2001Rate);
  300. }
  301. removeDeductionAliases(params);
  302. }
  303. private static void removeDeductionAliases(Map<String, String> params) {
  304. params.remove("act2001DeductionRate");
  305. params.remove("deductionRate2001");
  306. params.remove("deduction_rate_2001");
  307. }
  308. // ─── 私有工具方法 ────────────────────────────────────────────────────────
  309. private BidRecord bidRecordFromMediaResponse(NormalizedBidResponse response,
  310. String media, String traceId,
  311. String eventType,
  312. Map<String, String> mediaParams) {
  313. BidRecord r = new BidRecord();
  314. r.setQk(response.getQk());
  315. r.setReqId(response.getReqId());
  316. r.setBidId(response.getBidId());
  317. r.setMedia(media);
  318. r.setMediaTraceId(traceId);
  319. r.setPlatform(mediaParams.get("baidu_platform"));
  320. r.setAdId(mediaParams.get("ad_id"));
  321. r.setAccountId(mediaParams.get("account_id"));
  322. r.setTagId(mediaParams.get("baidu_tag_id"));
  323. r.setEventType(eventType);
  324. r.setMediaParams(mediaParams);
  325. r.setCreatedAt(Instant.now());
  326. if (response.getBid() != null) {
  327. r.setCreativeId(response.getBid().getCrid());
  328. r.setImpId(response.getBid().getImpId());
  329. r.setTagId(response.getBid().getTagId());
  330. r.setPrice(response.getBid().getPrice());
  331. r.setShowUrls(response.getBid().getShowUrls());
  332. r.setClickUrls(response.getBid().getClickUrls());
  333. r.setLandingPage(response.getBid().getLandingPage());
  334. r.setAppStoreLink(response.getBid().getAppStoreLink());
  335. if (response.getBid().getAdm() != null) {
  336. r.setPackageName(response.getBid().getAdm().getPackageName());
  337. }
  338. }
  339. return r;
  340. }
  341. private Map<String, String> flatQueryParams(HttpServletRequest request) {
  342. Map<String, String> params = new HashMap<>();
  343. request.getParameterMap().forEach((k, v) -> {
  344. if (v != null && v.length > 0) params.put(k, v[0]);
  345. });
  346. return params;
  347. }
  348. private String mediaTraceId(Map<String, String> params) {
  349. for (String key : List.of("click_id", "impression_id", "trace_id", "callback", "request_id",
  350. "oaid", "imei", "idfa", "caid", "muid")) {
  351. String v = params.get(key);
  352. if (v != null && !v.isBlank()) return TraceIds.normalize(v);
  353. }
  354. return null;
  355. }
  356. private Map<String, Object> mediaBidRequest(HttpServletRequest request, String media,
  357. String traceId, Map<String, String> params,
  358. MediaPlacement placement) {
  359. MediaPlacement.Resolved resolved = placement.resolve(params);
  360. List<Integer> actionTypes = placement.getActionTypes();
  361. if (actionTypes == null || actionTypes.isEmpty()) actionTypes = List.of(0, 1, 2);
  362. params.put("baidu_app_id", resolved.appId());
  363. params.put("baidu_tag_id", resolved.tagId());
  364. if (resolved.platform() != null && !resolved.platform().isBlank()) {
  365. params.put("baidu_platform", resolved.platform());
  366. }
  367. Map<String, Object> imp = new LinkedHashMap<>();
  368. imp.put("id", "1");
  369. imp.put("appId", resolved.appId());
  370. imp.put("tagId", resolved.tagId());
  371. imp.put("secure", 1);
  372. imp.put("adType", 0);
  373. imp.put("assets", defaultBaiduAssets());
  374. imp.put("actionType", actionTypes);
  375. imp.put("maxCount", 1);
  376. if (placement.getBidFloor() > 0) imp.put("bidFloor", placement.getBidFloor());
  377. Map<String, Object> device = new LinkedHashMap<>();
  378. // S2S架构:腾讯服务器回调,真实用户IP通过参数传递,优先使用参数中的IP
  379. putDeviceIp(device, params.get("ip"), params.get("ipv6"), clientIp(request));
  380. // 优先使用真实设备UA,无则根据平台构造合理的移动设备UA
  381. String ua = firstParam(params, "ua", "user_agent", "useragent");
  382. if (ua == null || ua.isBlank()) {
  383. ua = defaultMobileUa(resolved.platform());
  384. }
  385. device.put("ua", ua);
  386. device.put("deviceType", 1);
  387. int os = baiduOsForPlatform(resolved.platform());
  388. if (os != 0) device.put("os", os);
  389. Map<String, Object> uid = baiduUidFromParams(params, resolved.platform());
  390. if (!uid.isEmpty()) device.put("uid", uid);
  391. Map<String, Object> bidReq = new LinkedHashMap<>();
  392. bidReq.put("mediaId", placement.getBaiduMediaId());
  393. Instant now = Instant.now();
  394. long nanos = now.getEpochSecond() * 1_000_000_000L + now.getNano();
  395. bidReq.put("reqId", traceId + "-" + nanos);
  396. bidReq.put("imp", List.of(imp));
  397. bidReq.put("device", device);
  398. List<Integer> appList = parseAppList(firstParam(params, "appList", "app_list", "applist"));
  399. bidReq.put("appList", appList);
  400. log.info("[腾讯][百度请求] traceId={} body={}", traceId, toJson(bidReq));
  401. return bidReq;
  402. }
  403. private List<Map<String, Integer>> defaultBaiduAssets() {
  404. return List.of(
  405. Map.of("templateId", 1, "ratio", 2),
  406. Map.of("templateId", 1, "ratio", 7),
  407. Map.of("templateId", 2, "ratio", 1),
  408. Map.of("templateId", 2, "ratio", 4),
  409. Map.of("templateId", 3, "ratio", 2),
  410. Map.of("templateId", 4, "ratio", 4),
  411. Map.of("templateId", 4, "ratio", 5)
  412. );
  413. }
  414. private int baiduOsForPlatform(String platform) {
  415. if (platform == null) return 0;
  416. return switch (platform) {
  417. case "ios" -> 1;
  418. case "android" -> 2;
  419. default -> 0;
  420. };
  421. }
  422. /**
  423. * 根据平台返回合理的移动设备UA,确保百度能识别为真实手机请求
  424. */
  425. private static String defaultMobileUa(String platform) {
  426. if ("ios".equals(platform)) {
  427. 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";
  428. }
  429. // 默认Android
  430. 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";
  431. }
  432. private static void putDeviceIp(Map<String, Object> device, String ipv4Candidate, String ipv6Candidate, String fallbackIp) {
  433. String ipv4 = normalizeIp(ipv4Candidate);
  434. String ipv6 = normalizeIp(ipv6Candidate);
  435. String fallback = normalizeIp(fallbackIp);
  436. if (ipv4 == null && ipv6 == null && fallback != null) {
  437. if (isIpv6(fallback)) {
  438. ipv6 = fallback;
  439. } else {
  440. ipv4 = fallback;
  441. }
  442. }
  443. if (ipv4 != null && isIpv6(ipv4)) {
  444. ipv6 = ipv4;
  445. ipv4 = null;
  446. }
  447. if (ipv6 != null && !isIpv6(ipv6)) {
  448. if (ipv4 == null) {
  449. ipv4 = ipv6;
  450. }
  451. ipv6 = null;
  452. }
  453. if (ipv4 != null) {
  454. device.put("ip", ipv4);
  455. }
  456. if (ipv6 != null) {
  457. device.put("ipv6", ipv6);
  458. }
  459. }
  460. private static String normalizeIp(String value) {
  461. if (value == null || value.isBlank()) {
  462. return null;
  463. }
  464. return value.trim();
  465. }
  466. private static boolean isIpv6(String value) {
  467. return value != null && value.contains(":");
  468. }
  469. private Map<String, Object> baiduUidFromParams(Map<String, String> p, String platform) {
  470. Map<String, Object> uid = new LinkedHashMap<>();
  471. if ("android".equals(platform)) {
  472. String oaid = firstParam(p, "oaid");
  473. if (oaid != null && !oaid.isBlank()) {
  474. putUid(uid, "oaid", oaid);
  475. } else {
  476. putUid(uid, "oaidMd5", firstParam(p, "oaid_md5", "oaidMd5", "hash_oaid"));
  477. }
  478. return uid;
  479. }
  480. putUid(uid, "did", firstParam(p, "did", "imei"));
  481. putUid(uid, "didMd5", firstParam(p, "did_md5", "didMd5", "imei_md5", "imeiMd5"));
  482. putUid(uid, "dpid", firstParam(p, "dpid", "android_id", "androidId"));
  483. putUid(uid, "dpidMd5", firstParam(p, "dpid_md5", "dpidMd5", "android_id_md5", "androidIdMd5", "hash_android_id"));
  484. putUid(uid, "idfa", p.get("idfa"));
  485. putUid(uid, "idfaMd5", firstParam(p, "idfa_md5", "idfaMd5"));
  486. putUid(uid, "oaid", p.get("oaid"));
  487. putUid(uid, "oaidMd5", firstParam(p, "oaid_md5", "oaidMd5", "hash_oaid"));
  488. List<Map<String, Object>> caid = baiduCaidFromParams(p);
  489. if (!caid.isEmpty()) {
  490. uid.put("caid", caid);
  491. }
  492. return uid;
  493. }
  494. private List<Map<String, Object>> baiduCaidFromParams(Map<String, String> params) {
  495. String rawCaid = firstParam(params, "caid");
  496. if (rawCaid == null || rawCaid.isBlank()) {
  497. return Collections.emptyList();
  498. }
  499. List<Map<String, Object>> parsed = parseTencentCaid(rawCaid);
  500. if (!parsed.isEmpty()) {
  501. return parsed;
  502. }
  503. Map<String, Object> caidItem = new LinkedHashMap<>();
  504. caidItem.put("id", rawCaid);
  505. return List.of(caidItem);
  506. }
  507. private List<Map<String, Object>> parseTencentCaid(String rawCaid) {
  508. String decoded = decodeCaid(rawCaid);
  509. if (decoded == null || decoded.isBlank()) {
  510. return Collections.emptyList();
  511. }
  512. try {
  513. Object value = objectMapper.readValue(decoded, Object.class);
  514. if (value instanceof List<?> list) {
  515. List<Map<String, Object>> result = new ArrayList<>();
  516. for (Object item : list) {
  517. Map<String, Object> caid = toBaiduCaidItem(item);
  518. if (!caid.isEmpty()) result.add(caid);
  519. }
  520. return result;
  521. }
  522. if (value instanceof Map<?, ?> map) {
  523. Map<String, Object> caid = toBaiduCaidItem(map);
  524. if (!caid.isEmpty()) {
  525. return List.of(caid);
  526. }
  527. }
  528. } catch (Exception e) {
  529. log.debug("[腾讯][百度请求] caid parse failed raw={} decoded={}", rawCaid, decoded);
  530. }
  531. return Collections.emptyList();
  532. }
  533. private String decodeCaid(String rawCaid) {
  534. try {
  535. return URLDecoder.decode(rawCaid, StandardCharsets.UTF_8);
  536. } catch (Exception e) {
  537. return rawCaid;
  538. }
  539. }
  540. private Map<String, Object> toBaiduCaidItem(Object source) {
  541. if (!(source instanceof Map<?, ?> map)) {
  542. return Collections.emptyMap();
  543. }
  544. String id = firstNonBlankValue(map, "qaid", "id", "caid");
  545. if (id == null) {
  546. return Collections.emptyMap();
  547. }
  548. Map<String, Object> caidItem = new LinkedHashMap<>();
  549. caidItem.put("id", id);
  550. String version = firstNonBlankValue(map, "version");
  551. if (version != null) {
  552. caidItem.put("version", version);
  553. }
  554. Long generateTime = firstLongValue(map, "generateTime");
  555. if (generateTime != null) {
  556. caidItem.put("generateTime", generateTime);
  557. }
  558. Long vendor = firstLongValue(map, "vendor");
  559. if (vendor != null) {
  560. caidItem.put("vendor", vendor);
  561. }
  562. return caidItem;
  563. }
  564. private String firstNonBlankValue(Map<?, ?> map, String... keys) {
  565. for (String key : keys) {
  566. Object value = map.get(key);
  567. if (value == null) continue;
  568. String text = String.valueOf(value).trim();
  569. if (!text.isEmpty()) return text;
  570. }
  571. return null;
  572. }
  573. private Long firstLongValue(Map<?, ?> map, String... keys) {
  574. for (String key : keys) {
  575. Object value = map.get(key);
  576. if (value == null) continue;
  577. if (value instanceof Number n) {
  578. return n.longValue();
  579. }
  580. try {
  581. return Long.parseLong(String.valueOf(value).trim());
  582. } catch (NumberFormatException ignored) {
  583. }
  584. }
  585. return null;
  586. }
  587. private void putUid(Map<String, Object> uid, String key, String value) {
  588. if (value != null && !value.isBlank()) uid.put(key, value);
  589. }
  590. private String firstParam(Map<String, String> p, String... keys) {
  591. for (String k : keys) {
  592. String v = p.get(k);
  593. if (v != null && !v.isBlank()) return v.trim();
  594. }
  595. return null;
  596. }
  597. private List<Integer> parseAppList(String value) {
  598. if (value == null || value.isBlank()) return Collections.emptyList();
  599. List<Integer> result = new ArrayList<>();
  600. for (String part : value.split("[,|]")) {
  601. String item = part == null ? null : part.trim();
  602. if (item == null || item.isEmpty()) continue;
  603. try {
  604. result.add(Integer.parseInt(item));
  605. } catch (NumberFormatException e) {
  606. log.warn("[腾讯][百度请求] invalidAppList raw={} item={}", value, item);
  607. }
  608. }
  609. return result;
  610. }
  611. private void recordTrackingResults(String qk, String kind, List<TrackingResult> results) {
  612. if (hotStore == null || results == null) return;
  613. for (TrackingResult r : results) {
  614. TrackingRecord record = new TrackingRecord();
  615. record.setQk(qk);
  616. record.setKind(kind);
  617. record.setUrl(r.getUrl());
  618. record.setStatus(r.getStatus());
  619. record.setOk(r.isOk());
  620. record.setCreatedAt(Instant.now());
  621. hotStore.recordTracking(record);
  622. }
  623. }
  624. static String clientIp(HttpServletRequest request) {
  625. String xff = request.getHeader("X-Forwarded-For");
  626. if (xff != null && !xff.isBlank()) {
  627. for (String part : xff.split(",")) {
  628. String ip = part.trim();
  629. if (!ip.isEmpty()) return ip;
  630. }
  631. }
  632. String xri = request.getHeader("X-Real-IP");
  633. if (xri != null && !xri.isBlank()) {
  634. return xri.trim();
  635. }
  636. return request.getRemoteAddr();
  637. }
  638. private static String nvl(String s) {
  639. return s != null ? s : "";
  640. }
  641. private String toJson(Object value) {
  642. try {
  643. return objectMapper.writeValueAsString(value);
  644. } catch (Exception e) {
  645. return String.valueOf(value);
  646. }
  647. }
  648. }