package com.adx.tencent.httpapi; import com.adx.tencent.baidu.model.NormalizedBidResponse; import com.adx.tencent.baidu.AdxClient; 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.adx.tencent.util.TraceIds; 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.net.URLDecoder; import java.nio.charset.StandardCharsets; 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, @RequestParam(name = "deductionRate", required = false, defaultValue = "0") int deductionRate) throws Exception { impressionSemaphore.acquire(); try { return doImpression(request, "v1", deductionRate); } finally { impressionSemaphore.release(); } } @GetMapping("/tencent/v2/impression") public Object tencentImpressionV2(HttpServletRequest request) throws Exception { impressionSemaphore.acquire(); try { return doImpression(request, "v2", 20); } finally { impressionSemaphore.release(); } } @GetMapping("/tencent/v3/impression") public Object tencentImpressionV3(HttpServletRequest request) throws Exception { impressionSemaphore.acquire(); try { return doImpression(request, "v3", 50); } finally { impressionSemaphore.release(); } } @GetMapping("/tencent/v4/impression") public Object tencentImpressionV4(HttpServletRequest request) throws Exception { impressionSemaphore.acquire(); try { return doImpression(request, "v4", 70); } finally { impressionSemaphore.release(); } } @GetMapping("/tencent/v5/impression") public Object tencentImpressionV5(HttpServletRequest request) throws Exception { impressionSemaphore.acquire(); try { return doImpression(request, "v5", 40); } finally { impressionSemaphore.release(); } } @GetMapping("/tencent/v6/impression") public Object tencentImpressionV6(HttpServletRequest request) throws Exception { impressionSemaphore.acquire(); try { return doImpression(request, "v6", 30); } finally { impressionSemaphore.release(); } } private Object doImpression(HttpServletRequest request, String trackingVersion, int deductionRate) throws Exception { Map params = flatQueryParams(request); markTrackingVersion(params, trackingVersion, deductionRate); 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("tencent", 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 = reportDeviceTracking(record, params, response.getBid().getShowUrls(), response.getBid().getPrice(), false); 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, @RequestParam(name = "deductionRate", required = false, defaultValue = "0") int deductionRate) throws Exception { clickSemaphore.acquire(); try { return doClick(request, response, "v1", deductionRate); } finally { clickSemaphore.release(); } } @GetMapping("/tencent/v2/click") public Object tencentClickV2(HttpServletRequest request, HttpServletResponse response) throws Exception { clickSemaphore.acquire(); try { return doClick(request, response, "v2", 20); } finally { clickSemaphore.release(); } } @GetMapping("/tencent/v3/click") public Object tencentClickV3(HttpServletRequest request, HttpServletResponse response) throws Exception { clickSemaphore.acquire(); try { return doClick(request, response, "v3", 50); } finally { clickSemaphore.release(); } } @GetMapping("/tencent/v4/click") public Object tencentClickV4(HttpServletRequest request, HttpServletResponse response) throws Exception { clickSemaphore.acquire(); try { return doClick(request, response, "v4", 70); } finally { clickSemaphore.release(); } } @GetMapping("/tencent/v5/click") public Object tencentClickV5(HttpServletRequest request, HttpServletResponse response) throws Exception { clickSemaphore.acquire(); try { return doClick(request, response, "v5", 40); } finally { clickSemaphore.release(); } } @GetMapping("/tencent/v6/click") public Object tencentClickV6(HttpServletRequest request, HttpServletResponse response) throws Exception { clickSemaphore.acquire(); try { return doClick(request, response, "v6", 30); } finally { clickSemaphore.release(); } } private Object doClick(HttpServletRequest request, HttpServletResponse response, String trackingVersion, int deductionRate) throws Exception { if (tencentPlacement == null) { throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "tencent placement not configured"); } Map params = flatQueryParams(request); markTrackingVersion(params, trackingVersion, deductionRate); 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("tencent", 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 = reportDeviceTracking(record, params, resp.getBid().getShowUrls(), resp.getBid().getPrice(), false); recordTrackingResults(record.getQk(), "impression", impressionResults); } } List trackingResults = reportDeviceTracking(record, params, record.getClickUrls() != null ? record.getClickUrls() : Collections.emptyList(), 0, true); 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); } /** Only guard outbound monitoring; bidding, cache lifecycle and redirects keep their original flow. */ private List reportDeviceTracking(BidRecord record, Map params, List urls, long price, boolean click) throws Exception { String tag = firstParam(params, "baidu_tag_id", "tag_id", "tagid"); if (tag == null) tag = record.getTagId(); // A cache hit previously needed no placement validation. Only read the default tag here. if (tag == null) tag = tencentPlacement.getBaiduTagId(); return DeviceTrackingReporter.report(adxClient, "tencent", tag, params, record.getMediaParams(), urls, price, click, hotStore::claimDeviceTracking); } private void markTrackingVersion(Map params, String trackingVersion, int deductionRate) { boolean isNewLink = !"v1".equalsIgnoreCase(trackingVersion); params.put(BidRecord.NEW_LINK_FLAG_KEY, Boolean.toString(isNewLink)); params.put(BidRecord.TRACKING_VERSION_KEY, trackingVersion); params.put(BidRecord.DEDUCTION_RATE_KEY, String.valueOf(Math.max(deductionRate, 0))); String act2001Rate = firstParam(params, BidRecord.ACT_2001_DEDUCTION_RATE_KEY, "act2001DeductionRate", "deductionRate2001", "deduction_rate_2001"); if (act2001Rate != null) { params.put(BidRecord.ACT_2001_DEDUCTION_RATE_KEY, act2001Rate); } removeDeductionAliases(params); } private static void removeDeductionAliases(Map params) { params.remove("act2001DeductionRate"); params.remove("deductionRate2001"); params.remove("deduction_rate_2001"); } // ─── 私有工具方法 ──────────────────────────────────────────────────────── 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", "muid")) { String v = params.get(key); if (v != null && !v.isBlank()) return TraceIds.normalize(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 putDeviceIp(device, params.get("ip"), params.get("ipv6"), 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, resolved.platform()); 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")); 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 static void putDeviceIp(Map device, String ipv4Candidate, String ipv6Candidate, String fallbackIp) { String ipv4 = normalizeIp(ipv4Candidate); String ipv6 = normalizeIp(ipv6Candidate); String fallback = normalizeIp(fallbackIp); if (ipv4 == null && ipv6 == null && fallback != null) { if (isIpv6(fallback)) { ipv6 = fallback; } else { ipv4 = fallback; } } if (ipv4 != null && isIpv6(ipv4)) { ipv6 = ipv4; ipv4 = null; } if (ipv6 != null && !isIpv6(ipv6)) { if (ipv4 == null) { ipv4 = ipv6; } ipv6 = null; } if (ipv4 != null) { device.put("ip", ipv4); } if (ipv6 != null) { device.put("ipv6", ipv6); } } private static String normalizeIp(String value) { if (value == null || value.isBlank()) { return null; } return value.trim(); } private static boolean isIpv6(String value) { return value != null && value.contains(":"); } private Map baiduUidFromParams(Map p, String platform) { Map uid = new LinkedHashMap<>(); if ("android".equals(platform)) { String oaid = firstParam(p, "oaid"); if (oaid != null && !oaid.isBlank()) { putUid(uid, "oaid", oaid); } else { putUid(uid, "oaidMd5", firstParam(p, "oaid_md5", "oaidMd5", "hash_oaid")); } return uid; } 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")); List> caid = baiduCaidFromParams(p); if (!caid.isEmpty()) { uid.put("caid", caid); } return uid; } private List> baiduCaidFromParams(Map params) { String rawCaid = firstParam(params, "caid"); if (rawCaid == null || rawCaid.isBlank()) { return Collections.emptyList(); } List> parsed = parseTencentCaid(rawCaid); if (!parsed.isEmpty()) { return parsed; } Map caidItem = new LinkedHashMap<>(); caidItem.put("id", rawCaid); return List.of(caidItem); } private List> parseTencentCaid(String rawCaid) { String decoded = decodeCaid(rawCaid); if (decoded == null || decoded.isBlank()) { return Collections.emptyList(); } try { Object value = objectMapper.readValue(decoded, Object.class); if (value instanceof List list) { List> result = new ArrayList<>(); for (Object item : list) { Map caid = toBaiduCaidItem(item); if (!caid.isEmpty()) result.add(caid); } return result; } if (value instanceof Map map) { Map caid = toBaiduCaidItem(map); if (!caid.isEmpty()) { return List.of(caid); } } } catch (Exception e) { log.debug("[腾讯][百度请求] caid parse failed raw={} decoded={}", rawCaid, decoded); } return Collections.emptyList(); } private String decodeCaid(String rawCaid) { try { return URLDecoder.decode(rawCaid, StandardCharsets.UTF_8); } catch (Exception e) { return rawCaid; } } private Map toBaiduCaidItem(Object source) { if (!(source instanceof Map map)) { return Collections.emptyMap(); } String id = firstNonBlankValue(map, "qaid", "id", "caid"); if (id == null) { return Collections.emptyMap(); } Map caidItem = new LinkedHashMap<>(); caidItem.put("id", id); String version = firstNonBlankValue(map, "version"); if (version != null) { caidItem.put("version", version); } Long generateTime = firstLongValue(map, "generateTime"); if (generateTime != null) { caidItem.put("generateTime", generateTime); } Long vendor = firstLongValue(map, "vendor"); if (vendor != null) { caidItem.put("vendor", vendor); } return caidItem; } private String firstNonBlankValue(Map map, String... keys) { for (String key : keys) { Object value = map.get(key); if (value == null) continue; String text = String.valueOf(value).trim(); if (!text.isEmpty()) return text; } return null; } private Long firstLongValue(Map map, String... keys) { for (String key : keys) { Object value = map.get(key); if (value == null) continue; if (value instanceof Number n) { return n.longValue(); } try { return Long.parseLong(String.valueOf(value).trim()); } catch (NumberFormatException ignored) { } } return null; } 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); } } }