package com.adx.tencent.httpapi; import com.adx.tencent.baidu.AdxClient; import com.adx.tencent.baidu.model.NormalizedBidResponse; import com.adx.tencent.baidu.model.TrackingResult; import com.adx.tencent.storage.RedisHotStore; import com.adx.tencent.storage.model.BidRecord; import com.adx.tencent.storage.model.TrackingRecord; import com.fasterxml.jackson.databind.ObjectMapper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; import org.springframework.lang.Nullable; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.time.Instant; import java.util.*; import java.util.concurrent.Semaphore; /** * 腾讯监测链接接收端点。 * 提供曝光和点击监测 URL,接收腾讯广告平台的回调。 */ @RestController public class TrackingController { private static final Logger log = LoggerFactory.getLogger(TrackingController.class); private static final int MAX_CONCURRENT = 50; private final Semaphore impressionSemaphore = new Semaphore(MAX_CONCURRENT); private final Semaphore clickSemaphore = new Semaphore(MAX_CONCURRENT); private final AdxClient adxClient; private final RedisHotStore hotStore; private final MediaPlacement tencentPlacement; private final boolean allowIpOverride; private final ObjectMapper objectMapper; public TrackingController(AdxClient adxClient, RedisHotStore hotStore, @Nullable MediaPlacement tencentPlacement, ObjectMapper objectMapper, @Value("${spring.profiles.active:}") String activeProfile) { this.adxClient = adxClient; this.hotStore = hotStore; this.tencentPlacement = tencentPlacement; this.objectMapper = objectMapper; this.allowIpOverride = "dev".equals(activeProfile) || "test".equals(activeProfile); } // ─── /health ──────────────────────────────────────────────────────────── @GetMapping("/health") public Map health() { return Map.of("ok", true); } // ─── GET /tencent/impression ───────────────────────────────────────────── /** * 接收腾讯曝光监测。 * 流程:解析参数 → 构造百度竞价请求 → 请求百度ADX → 缓存bid → 上报百度曝光 → 返回204 * 限制最多50个线程同时执行。 */ @GetMapping("/tencent/impression") public Object tencentImpression(HttpServletRequest request) throws Exception { impressionSemaphore.acquire(); try { return doImpression(request); } finally { impressionSemaphore.release(); } } private Object doImpression(HttpServletRequest request) throws Exception { Map params = flatQueryParams(request); String traceId = mediaTraceId(params); if (traceId == null || traceId.isBlank()) { log.warn("[腾讯][曝光] 缺少 traceId, params={}", params.keySet()); throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "missing media trace id (click_id/oaid/imei/idfa)"); } if (tencentPlacement == null) { throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "tencent placement not configured"); } log.info("[腾讯][曝光][入参] traceId={} ip={} params={}", traceId, clientIp(request), params); Map bidRequest = mediaBidRequest(request, "tencent", traceId, params, tencentPlacement); NormalizedBidResponse response = adxClient.requestBid(traceId, bidRequest); if (response == null) { log.warn("[腾讯][曝光][百度结果] traceId={} empty=true", traceId); throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "empty baidu ADX response"); } log.info("[腾讯][曝光][竞价结果] traceId={} qk={} bidId={} success={} price={} clickUrlCount={} showUrlCount={}", traceId, response.getQk(), response.getBidId(), response.getBid() != null, response.getBid() != null ? response.getBid().getPrice() : 0, response.getBid() != null && response.getBid().getClickUrls() != null ? response.getBid().getClickUrls().size() : 0, response.getBid() != null && response.getBid().getShowUrls() != null ? response.getBid().getShowUrls().size() : 0); BidRecord record = bidRecordFromMediaResponse(response, "tencent", traceId, "impression", params); hotStore.recordBid(record); // 上报百度曝光 if (response.getBid() != null && response.getBid().getShowUrls() != null && !response.getBid().getShowUrls().isEmpty()) { List trackingResults = adxClient.reportImpression( response.getBid().getShowUrls(), response.getBid().getPrice()); recordTrackingResults(response.getQk(), "impression", trackingResults); } return Map.of("qk", nvl(record.getQk()), "traceId", traceId); } // ─── GET /tencent/click ────────────────────────────────────────────────── /** * 接收腾讯点击监测。 * 流程:查找已有bid → 若无则请求百度ADX → 上报百度点击 → 302重定向到落地页 * 限制最多50个线程同时执行。 */ @GetMapping("/tencent/click") public Object tencentClick(HttpServletRequest request, HttpServletResponse response) throws Exception { clickSemaphore.acquire(); try { return doClick(request, response); } finally { clickSemaphore.release(); } } private Object doClick(HttpServletRequest request, HttpServletResponse response) throws Exception { if (tencentPlacement == null) { throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "tencent placement not configured"); } Map params = flatQueryParams(request); String traceId = mediaTraceId(params); if (traceId == null || traceId.isBlank()) { log.warn("[腾讯][点击] 缺少 traceId, params={}", params.keySet()); throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "missing media trace id (click_id/oaid/imei/idfa)"); } log.info("[腾讯][点击][入参] traceId={} ip={} params={}", traceId, clientIp(request), params); BidRecord record = hotStore.findBidByMediaTrace("tencent", traceId); if (record == null) { // 未找到,临时发起竞价请求 Map bidRequest = mediaBidRequest(request, "tencent", traceId, params, tencentPlacement); NormalizedBidResponse resp = adxClient.requestBid(traceId, bidRequest); if (resp == null) { log.warn("[腾讯][点击][百度结果] traceId={} empty=true", traceId); throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "empty baidu ADX response"); } log.info("[腾讯][点击][竞价结果] traceId={} qk={} bidId={} success={} price={} clickUrlCount={} showUrlCount={}", traceId, resp.getQk(), resp.getBidId(), resp.getBid() != null, resp.getBid() != null ? resp.getBid().getPrice() : 0, resp.getBid() != null && resp.getBid().getClickUrls() != null ? resp.getBid().getClickUrls().size() : 0, resp.getBid() != null && resp.getBid().getShowUrls() != null ? resp.getBid().getShowUrls().size() : 0); BidRecord newRecord = bidRecordFromMediaResponse(resp, "tencent", traceId, "click", params); hotStore.recordBid(newRecord); record = newRecord; if (resp.getBid() != null && resp.getBid().getShowUrls() != null && !resp.getBid().getShowUrls().isEmpty()) { List impressionResults = adxClient.reportImpression( resp.getBid().getShowUrls(), resp.getBid().getPrice()); recordTrackingResults(record.getQk(), "impression", impressionResults); } } List trackingResults = adxClient.reportClick( record.getClickUrls() != null ? record.getClickUrls() : Collections.emptyList()); recordTrackingResults(record.getQk(), "click", trackingResults); String destination = record.getAppStoreLink() != null && !record.getAppStoreLink().isBlank() ? record.getAppStoreLink() : record.getLandingPage(); try { hotStore.shrinkAfterClick(record); } catch (Exception e) { } if (destination != null && !destination.isBlank()) { response.sendRedirect(destination); return null; } return Map.of("qk", nvl(record.getQk()), "traceId", traceId); } // ─── 私有工具方法 ──────────────────────────────────────────────────────── private BidRecord bidRecordFromMediaResponse(NormalizedBidResponse response, String media, String traceId, String eventType, Map mediaParams) { BidRecord r = new BidRecord(); r.setQk(response.getQk()); r.setReqId(response.getReqId()); r.setBidId(response.getBidId()); r.setMedia(media); r.setMediaTraceId(traceId); r.setPlatform(mediaParams.get("baidu_platform")); r.setAdId(mediaParams.get("ad_id")); r.setAccountId(mediaParams.get("account_id")); r.setTagId(mediaParams.get("baidu_tag_id")); r.setEventType(eventType); r.setMediaParams(mediaParams); r.setCreatedAt(Instant.now()); if (response.getBid() != null) { r.setCreativeId(response.getBid().getCrid()); r.setImpId(response.getBid().getImpId()); r.setTagId(response.getBid().getTagId()); r.setPrice(response.getBid().getPrice()); r.setShowUrls(response.getBid().getShowUrls()); r.setClickUrls(response.getBid().getClickUrls()); r.setLandingPage(response.getBid().getLandingPage()); r.setAppStoreLink(response.getBid().getAppStoreLink()); if (response.getBid().getAdm() != null) { r.setPackageName(response.getBid().getAdm().getPackageName()); } } return r; } private Map flatQueryParams(HttpServletRequest request) { Map params = new HashMap<>(); request.getParameterMap().forEach((k, v) -> { if (v != null && v.length > 0) params.put(k, v[0]); }); return params; } private String mediaTraceId(Map params) { for (String key : List.of("click_id", "impression_id", "trace_id", "callback", "request_id", "oaid", "imei", "idfa", "caid")) { String v = params.get(key); if (v != null && !v.isBlank()) return v; } return null; } private Map mediaBidRequest(HttpServletRequest request, String media, String traceId, Map params, MediaPlacement placement) { MediaPlacement.Resolved resolved = placement.resolve(params); List actionTypes = placement.getActionTypes(); if (actionTypes == null || actionTypes.isEmpty()) actionTypes = List.of(0, 1, 2); params.put("baidu_app_id", resolved.appId()); params.put("baidu_tag_id", resolved.tagId()); if (resolved.platform() != null && !resolved.platform().isBlank()) { params.put("baidu_platform", resolved.platform()); } Map imp = new LinkedHashMap<>(); imp.put("id", "1"); imp.put("appId", resolved.appId()); imp.put("tagId", resolved.tagId()); imp.put("secure", 1); imp.put("adType", 0); imp.put("assets", defaultBaiduAssets()); imp.put("actionType", actionTypes); imp.put("maxCount", 1); if (placement.getBidFloor() > 0) imp.put("bidFloor", placement.getBidFloor()); Map device = new LinkedHashMap<>(); // S2S架构:腾讯服务器回调,真实用户IP通过参数传递,优先使用参数中的IP String paramIp = params.get("ip"); device.put("ip", (paramIp != null && !paramIp.isBlank()) ? paramIp : clientIp(request)); // 优先使用真实设备UA,无则根据平台构造合理的移动设备UA String ua = firstParam(params, "ua", "user_agent", "useragent"); if (ua == null || ua.isBlank()) { ua = defaultMobileUa(resolved.platform()); } device.put("ua", ua); device.put("deviceType", 1); int os = baiduOsForPlatform(resolved.platform()); if (os != 0) device.put("os", os); Map uid = baiduUidFromParams(params); if (!uid.isEmpty()) device.put("uid", uid); Map bidReq = new LinkedHashMap<>(); bidReq.put("mediaId", placement.getBaiduMediaId()); Instant now = Instant.now(); long nanos = now.getEpochSecond() * 1_000_000_000L + now.getNano(); bidReq.put("reqId", traceId + "-" + nanos); bidReq.put("imp", List.of(imp)); bidReq.put("device", device); List appList = parseAppList(firstParam(params, "appList", "app_list", "applist")); if (!appList.isEmpty()) bidReq.put("appList", appList); log.info("[腾讯][百度请求] traceId={} body={}", traceId, toJson(bidReq)); return bidReq; } private List> defaultBaiduAssets() { return List.of( Map.of("templateId", 1, "ratio", 2), Map.of("templateId", 1, "ratio", 7), Map.of("templateId", 2, "ratio", 1), Map.of("templateId", 2, "ratio", 4), Map.of("templateId", 3, "ratio", 2), Map.of("templateId", 4, "ratio", 4), Map.of("templateId", 4, "ratio", 5) ); } private int baiduOsForPlatform(String platform) { if (platform == null) return 0; return switch (platform) { case "ios" -> 1; case "android" -> 2; default -> 0; }; } /** 根据平台返回合理的移动设备UA,确保百度能识别为真实手机请求 */ private static String defaultMobileUa(String platform) { if ("ios".equals(platform)) { 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"; } // 默认Android 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"; } private Map baiduUidFromParams(Map p) { Map uid = new LinkedHashMap<>(); putUid(uid, "did", firstParam(p, "did", "imei")); putUid(uid, "didMd5", firstParam(p, "did_md5", "didMd5", "imei_md5", "imeiMd5")); putUid(uid, "dpid", firstParam(p, "dpid", "android_id", "androidId")); putUid(uid, "dpidMd5", firstParam(p, "dpid_md5", "dpidMd5", "android_id_md5", "androidIdMd5", "hash_android_id")); putUid(uid, "idfa", p.get("idfa")); putUid(uid, "idfaMd5", firstParam(p, "idfa_md5", "idfaMd5")); putUid(uid, "oaid", p.get("oaid")); putUid(uid, "oaidMd5", firstParam(p, "oaid_md5", "oaidMd5", "hash_oaid")); // 百度ADX要求muid不能为空,Android用IMEI MD5,无则用oaidMd5或dpidMd5兜底 String muid = firstParam(p, "muid", "imei_md5", "imeiMd5", "did_md5", "didMd5"); if (muid == null || muid.isBlank()) { muid = firstParam(p, "hash_oaid", "oaid_md5", "oaidMd5", "hash_android_id", "dpid_md5", "dpidMd5"); } putUid(uid, "muid", muid); return uid; } private void putUid(Map uid, String key, String value) { if (value != null && !value.isBlank()) uid.put(key, value); } private String firstParam(Map p, String... keys) { for (String k : keys) { String v = p.get(k); if (v != null && !v.isBlank()) return v.trim(); } return null; } private List parseAppList(String value) { if (value == null || value.isBlank()) return Collections.emptyList(); List result = new ArrayList<>(); for (String part : value.split("[,|]")) { String item = part == null ? null : part.trim(); if (item == null || item.isEmpty()) continue; try { result.add(Integer.parseInt(item)); } catch (NumberFormatException e) { log.warn("[腾讯][百度请求] invalidAppList raw={} item={}", value, item); } } return result; } private void recordTrackingResults(String qk, String kind, List results) { if (hotStore == null || results == null) return; for (TrackingResult r : results) { TrackingRecord record = new TrackingRecord(); record.setQk(qk); record.setKind(kind); record.setUrl(r.getUrl()); record.setStatus(r.getStatus()); record.setOk(r.isOk()); record.setCreatedAt(Instant.now()); hotStore.recordTracking(record); } } static String clientIp(HttpServletRequest request) { String xff = request.getHeader("X-Forwarded-For"); if (xff != null && !xff.isBlank()) { for (String part : xff.split(",")) { String ip = part.trim(); if (!ip.isEmpty()) return ip; } } String xri = request.getHeader("X-Real-IP"); if (xri != null && !xri.isBlank()) { return xri.trim(); } return request.getRemoteAddr(); } private static String nvl(String s) { return s != null ? s : ""; } private String toJson(Object value) { try { return objectMapper.writeValueAsString(value); } catch (Exception e) { return String.valueOf(value); } } }