HonorTrackingController.java 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335
  1. package com.adx.tencent.honor;
  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.honor.model.HonorBidRecord;
  6. import com.adx.tencent.honor.model.HonorTrackingRecord;
  7. import jakarta.servlet.http.HttpServletRequest;
  8. import jakarta.servlet.http.HttpServletResponse;
  9. import org.slf4j.Logger;
  10. import org.slf4j.LoggerFactory;
  11. import org.springframework.http.HttpStatus;
  12. import org.springframework.lang.Nullable;
  13. import org.springframework.web.bind.annotation.GetMapping;
  14. import org.springframework.web.bind.annotation.RestController;
  15. import org.springframework.web.server.ResponseStatusException;
  16. import java.time.Instant;
  17. import java.util.ArrayList;
  18. import java.util.Collections;
  19. import java.util.HashMap;
  20. import java.util.LinkedHashMap;
  21. import java.util.List;
  22. import java.util.Map;
  23. import java.util.concurrent.Semaphore;
  24. @RestController
  25. public class HonorTrackingController {
  26. private static final Logger log = LoggerFactory.getLogger(HonorTrackingController.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 HonorHotStore hotStore;
  32. private final HonorPlacement placement;
  33. public HonorTrackingController(AdxClient adxClient,
  34. HonorHotStore hotStore,
  35. @Nullable HonorPlacement placement) {
  36. this.adxClient = adxClient;
  37. this.hotStore = hotStore;
  38. this.placement = placement;
  39. }
  40. @GetMapping("/honor/impression")
  41. public Object impression(HttpServletRequest request) throws Exception {
  42. impressionSemaphore.acquire();
  43. try {
  44. return doImpression(request);
  45. } finally {
  46. impressionSemaphore.release();
  47. }
  48. }
  49. @GetMapping("/honor/click")
  50. public Object click(HttpServletRequest request, HttpServletResponse response) throws Exception {
  51. clickSemaphore.acquire();
  52. try {
  53. return doClick(request, response);
  54. } finally {
  55. clickSemaphore.release();
  56. }
  57. }
  58. private Object doImpression(HttpServletRequest request) throws Exception {
  59. if (placement == null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "honor placement not configured");
  60. Map<String, String> params = normalizeParams(request);
  61. String tagId = requireTagId(params);
  62. String traceId = honorTraceId(params);
  63. if (traceId == null || traceId.isBlank()) {
  64. log.warn("[荣耀][曝光] 缺少 traceId, params={}", params.keySet());
  65. throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "missing honor trace id (trackId)");
  66. }
  67. log.info("[荣耀][曝光] 收到曝光请求 | traceId={} | IP={} | 全部入参: {}", traceId, clientIp(request), params);
  68. NormalizedBidResponse response = adxClient.requestBid(buildBidRequest(request, traceId, params));
  69. if (response == null) {
  70. log.warn("[荣耀][曝光] 百度ADX返回空响应, traceId={}", traceId);
  71. throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "empty baidu ADX response");
  72. }
  73. log.info("[荣耀][曝光] 百度ADX返回: qk={}, hasBid={}, showUrls={}, clickUrls={}",
  74. response.getQk(),
  75. response.getBid() != null,
  76. response.getBid() != null && response.getBid().getShowUrls() != null ? response.getBid().getShowUrls().size() : 0,
  77. response.getBid() != null && response.getBid().getClickUrls() != null ? response.getBid().getClickUrls().size() : 0);
  78. HonorBidRecord record = bidRecordFromResponse(response, traceId, "impression", params);
  79. hotStore.recordBid(record);
  80. log.info("[荣耀][曝光] 已缓存bid, qk={}, traceId={}", record.getQk(), traceId);
  81. List<TrackingResult> tracking = Collections.emptyList();
  82. if (response.getBid() != null && response.getBid().getShowUrls() != null && !response.getBid().getShowUrls().isEmpty()) {
  83. tracking = adxClient.reportImpression(response.getBid().getShowUrls(), response.getBid().getPrice());
  84. log.info("[荣耀][曝光] 曝光上报完成, qk={}, results={}", record.getQk(), tracking);
  85. recordTracking(record.getQk(), tagId, "impression", tracking);
  86. }
  87. return Map.of("qk", record.getQk(), "traceId", traceId, "tagId", tagId);
  88. }
  89. private Object doClick(HttpServletRequest request, HttpServletResponse response) throws Exception {
  90. if (placement == null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "honor placement not configured");
  91. Map<String, String> params = normalizeParams(request);
  92. String tagId = requireTagId(params);
  93. String traceId = honorTraceId(params);
  94. if (traceId == null || traceId.isBlank()) {
  95. log.warn("[荣耀][点击] 缺少 traceId, params={}", params.keySet());
  96. throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "missing honor trace id (trackId)");
  97. }
  98. log.info("[荣耀][点击] 收到点击请求 | traceId={} | IP={} | 全部入参: {}", traceId, clientIp(request), params);
  99. HonorBidRecord record = hotStore.findBidByMediaTrace(traceId);
  100. if (record == null) {
  101. log.info("[荣耀][点击] Redis未命中, 发起竞价请求, traceId={}", traceId);
  102. NormalizedBidResponse bidResponse = adxClient.requestBid(buildBidRequest(request, traceId, params));
  103. if (bidResponse == null) {
  104. log.warn("[荣耀][点击] 百度ADX返回空响应, traceId={}", traceId);
  105. throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "empty baidu ADX response");
  106. }
  107. record = bidRecordFromResponse(bidResponse, traceId, "click", params);
  108. hotStore.recordBid(record);
  109. log.info("[荣耀][点击] 竞价成功, qk={}, traceId={}", record.getQk(), traceId);
  110. if (bidResponse.getBid() != null && bidResponse.getBid().getShowUrls() != null && !bidResponse.getBid().getShowUrls().isEmpty()) {
  111. List<TrackingResult> impressionTracking = adxClient.reportImpression(
  112. bidResponse.getBid().getShowUrls(), bidResponse.getBid().getPrice());
  113. log.info("[荣耀][点击] Redis未命中补竞价后,曝光上报完成, qk={}, results={}", record.getQk(), impressionTracking);
  114. recordTracking(record.getQk(), tagId, "impression", impressionTracking);
  115. }
  116. } else {
  117. log.info("[荣耀][点击] Redis命中, qk={}, traceId={}", record.getQk(), traceId);
  118. }
  119. List<TrackingResult> tracking = adxClient.reportClick(
  120. record.getClickUrls() != null ? record.getClickUrls() : Collections.emptyList());
  121. log.info("[荣耀][点击] 点击上报完成, qk={}, results={}", record.getQk(), tracking);
  122. recordTracking(record.getQk(), tagId, "click", tracking);
  123. try {
  124. hotStore.shrinkAfterClick(record);
  125. } catch (Exception e) {
  126. log.warn("[荣耀][点击] Redis缩减失败(不影响主流程), qk={}, error={}", record.getQk(), e.getMessage());
  127. }
  128. String destination = record.getAppStoreLink() != null && !record.getAppStoreLink().isBlank()
  129. ? record.getAppStoreLink() : record.getLandingPage();
  130. if (destination != null && !destination.isBlank()) {
  131. log.info("[荣耀][点击] 302重定向, qk={}, destination={}", record.getQk(), destination);
  132. response.sendRedirect(destination);
  133. return null;
  134. }
  135. return Map.of("qk", record.getQk(), "traceId", traceId, "tagId", tagId);
  136. }
  137. private Map<String, Object> buildBidRequest(HttpServletRequest request, String traceId, Map<String, String> params) {
  138. HonorPlacement.Resolved resolved = placement.resolve(params);
  139. params.put("baidu_app_id", resolved.appId());
  140. params.put("baidu_tag_id", resolved.tagId());
  141. params.put("tagId", resolved.tagId());
  142. if (resolved.platform() != null && !resolved.platform().isBlank()) params.put("baidu_platform", resolved.platform());
  143. Map<String, Object> imp = new LinkedHashMap<>();
  144. imp.put("id", "1");
  145. imp.put("appId", resolved.appId());
  146. imp.put("tagId", resolved.tagId());
  147. imp.put("secure", 1);
  148. imp.put("adType", 0);
  149. imp.put("assets", List.of(
  150. Map.of("templateId", 1, "ratio", 2),
  151. Map.of("templateId", 1, "ratio", 7),
  152. Map.of("templateId", 2, "ratio", 1),
  153. Map.of("templateId", 2, "ratio", 4),
  154. Map.of("templateId", 3, "ratio", 2),
  155. Map.of("templateId", 4, "ratio", 4),
  156. Map.of("templateId", 4, "ratio", 5)
  157. ));
  158. imp.put("actionType", placement.getActionTypes() == null || placement.getActionTypes().isEmpty()
  159. ? List.of(0, 1, 2)
  160. : placement.getActionTypes());
  161. imp.put("maxCount", 1);
  162. if (placement.getBidFloor() > 0) imp.put("bidFloor", placement.getBidFloor());
  163. Map<String, Object> device = new LinkedHashMap<>();
  164. device.put("ip", firstParam(params, "ip", "m6a") != null ? firstParam(params, "ip", "m6a") : clientIp(request));
  165. device.put("ua", firstParam(params, "ua", "user_agent", "useragent") != null
  166. ? firstParam(params, "ua", "user_agent", "useragent")
  167. : defaultUa(resolved.platform()));
  168. device.put("deviceType", 1);
  169. if ("ios".equals(resolved.platform())) device.put("os", 1);
  170. if ("android".equals(resolved.platform())) device.put("os", 2);
  171. Map<String, Object> uid = new LinkedHashMap<>();
  172. putUid(uid, "oaid", params.get("oaid"));
  173. putUid(uid, "oaidMd5", firstParam(params, "oaid_md5", "oaidMd5", "hash_oaid"));
  174. String muid = firstParam(params, "oaid_md5", "oaidMd5", "hash_oaid", "oaid");
  175. putUid(uid, "muid", muid);
  176. if (!uid.isEmpty()) device.put("uid", uid);
  177. Map<String, Object> bidReq = new LinkedHashMap<>();
  178. bidReq.put("mediaId", placement.getBaiduMediaId());
  179. bidReq.put("reqId", traceId + "-" + Instant.now().toEpochMilli());
  180. bidReq.put("imp", List.of(imp));
  181. bidReq.put("device", device);
  182. List<Integer> appList = parseAppList(firstParam(params, "appList", "app_list", "applist"));
  183. if (!appList.isEmpty()) bidReq.put("appList", appList);
  184. log.info("[荣耀][百度ADX] 竞价请求 | traceId={} | body={}", traceId, bidReq);
  185. return bidReq;
  186. }
  187. private HonorBidRecord bidRecordFromResponse(NormalizedBidResponse response, String traceId, String eventType, Map<String, String> params) {
  188. HonorBidRecord record = new HonorBidRecord();
  189. record.setQk(response.getQk());
  190. record.setReqId(response.getReqId());
  191. record.setBidId(response.getBidId());
  192. record.setMedia("honor");
  193. record.setMediaTraceId(traceId);
  194. record.setPlatform(params.get("baidu_platform"));
  195. record.setAdId(params.get("creativeId"));
  196. record.setAccountId(params.get("advertiserId"));
  197. record.setTagId(params.get("tagId"));
  198. record.setEventType(eventType);
  199. record.setMediaParams(params);
  200. record.setCreatedAt(Instant.now());
  201. if (response.getBid() != null) {
  202. record.setCreativeId(response.getBid().getCrid());
  203. record.setImpId(response.getBid().getImpId());
  204. record.setTagId(response.getBid().getTagId() != null ? response.getBid().getTagId() : record.getTagId());
  205. record.setPrice(response.getBid().getPrice());
  206. record.setShowUrls(response.getBid().getShowUrls());
  207. record.setClickUrls(response.getBid().getClickUrls());
  208. record.setLandingPage(response.getBid().getLandingPage());
  209. record.setAppStoreLink(response.getBid().getAppStoreLink());
  210. if (response.getBid().getAdm() != null) {
  211. record.setPackageName(response.getBid().getAdm().getPackageName());
  212. }
  213. }
  214. return record;
  215. }
  216. private void recordTracking(String qk, String tagId, String kind, List<TrackingResult> results) {
  217. if (results == null) return;
  218. for (TrackingResult result : results) {
  219. HonorTrackingRecord record = new HonorTrackingRecord();
  220. record.setQk(qk);
  221. record.setTagId(tagId);
  222. record.setKind(kind);
  223. record.setUrl(result.getUrl());
  224. record.setStatus(result.getStatus());
  225. record.setOk(result.isOk());
  226. record.setCreatedAt(Instant.now());
  227. hotStore.recordTracking(record);
  228. }
  229. }
  230. private Map<String, String> normalizeParams(HttpServletRequest request) {
  231. Map<String, String> params = new HashMap<>();
  232. request.getParameterMap().forEach((k, v) -> {
  233. if (v != null && v.length > 0 && v[0] != null) params.put(k, v[0]);
  234. });
  235. copyAlias(params, "tagId", "tagId", "tag_id", "baidu_tag_id");
  236. copyAlias(params, "trackId", "trackId", "track_id");
  237. copyAlias(params, "advertiserId", "advertiserId", "advertiser_id");
  238. copyAlias(params, "requestId", "requestId", "request_id", "REQUESTID");
  239. copyAlias(params, "eventTime", "eventTime", "time");
  240. copyAlias(params, "pkgName", "pkgName", "pkg_name", "packageName");
  241. String os = firstParam(params, "platform", "os");
  242. if (os != null) {
  243. String platform = HonorPlacement.normalizePlatform(os);
  244. if (platform != null) params.put("platform", platform);
  245. }
  246. return params;
  247. }
  248. private String requireTagId(Map<String, String> params) {
  249. String tagId = params.get("tagId");
  250. if (tagId == null || tagId.isBlank()) {
  251. throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "missing tagId");
  252. }
  253. return tagId;
  254. }
  255. private String honorTraceId(Map<String, String> params) {
  256. return firstParam(params, "trackId");
  257. }
  258. private static void copyAlias(Map<String, String> params, String target, String... aliases) {
  259. String value = firstParam(params, aliases);
  260. if (value != null) params.put(target, value);
  261. }
  262. private static String firstParam(Map<String, String> p, String... keys) {
  263. for (String key : keys) {
  264. String value = p.get(key);
  265. if (value != null && !value.isBlank()) return value.trim();
  266. }
  267. return null;
  268. }
  269. private List<Integer> parseAppList(String value) {
  270. if (value == null || value.isBlank()) return Collections.emptyList();
  271. List<Integer> result = new ArrayList<>();
  272. for (String part : value.split("[,|]")) {
  273. String item = part == null ? null : part.trim();
  274. if (item == null || item.isEmpty()) continue;
  275. try {
  276. result.add(Integer.parseInt(item));
  277. } catch (NumberFormatException e) {
  278. log.warn("[荣耀][百度ADX] 忽略非法 appList 值 | raw={} | item={}", value, item);
  279. }
  280. }
  281. return result;
  282. }
  283. private static void putUid(Map<String, Object> uid, String key, String value) {
  284. if (value != null && !value.isBlank()) uid.put(key, value);
  285. }
  286. private static String defaultUa(String platform) {
  287. if ("ios".equals(platform)) {
  288. 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";
  289. }
  290. 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";
  291. }
  292. private static String clientIp(HttpServletRequest request) {
  293. String xff = request.getHeader("X-Forwarded-For");
  294. if (xff != null && !xff.isBlank()) {
  295. for (String part : xff.split(",")) {
  296. String ip = part.trim();
  297. if (!ip.isEmpty()) return ip;
  298. }
  299. }
  300. String xri = request.getHeader("X-Real-IP");
  301. if (xri != null && !xri.isBlank()) return xri.trim();
  302. return request.getRemoteAddr();
  303. }
  304. }