| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384 |
- 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 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;
- public TrackingController(AdxClient adxClient,
- RedisHotStore hotStore,
- @Nullable MediaPlacement tencentPlacement,
- @Value("${spring.profiles.active:}") String activeProfile) {
- this.adxClient = adxClient;
- this.hotStore = hotStore;
- this.tencentPlacement = tencentPlacement;
- this.allowIpOverride = "dev".equals(activeProfile) || "test".equals(activeProfile);
- }
- // ─── /health ────────────────────────────────────────────────────────────
- @GetMapping("/health")
- public Map<String, Boolean> health() {
- return Map.of("ok", true);
- }
- // ─── GET /tencent/impression ─────────────────────────────────────────────
- /**
- * 接收腾讯曝光监测。
- * 流程:解析参数 → 构造百度竞价请求 → 请求百度ADX → 缓存bid → 上报百度曝光 → 返回204
- * 限制最多50个线程同时执行。
- */
- @GetMapping("/tencent/impression")
- @ResponseStatus(HttpStatus.NO_CONTENT)
- public void tencentImpression(HttpServletRequest request) throws Exception {
- impressionSemaphore.acquire();
- try {
- doImpression(request);
- } finally {
- impressionSemaphore.release();
- }
- }
- private void doImpression(HttpServletRequest request) throws Exception {
- Map<String, String> params = flatQueryParams(request);
- String traceId = mediaTraceId(params);
- if (traceId == null || traceId.isBlank()) {
- log.warn("[impression] 缺少 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={} | 全部入参: {}", traceId, clientIp(request), params);
- Map<String, Object> bidRequest = mediaBidRequest(request, "tencent", traceId, params, tencentPlacement);
- NormalizedBidResponse response = adxClient.requestBid(bidRequest);
- if (response == null) {
- log.warn("[impression] 百度ADX返回空响应, traceId={}", traceId);
- throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "empty baidu ADX response");
- }
- log.info("[impression] 百度ADX返回: qk={}, hasBid={}, showUrls={}, clickUrls={}",
- response.getQk(),
- response.getBid() != null,
- response.getBid() != null && response.getBid().getShowUrls() != null ? response.getBid().getShowUrls().size() : 0,
- response.getBid() != null && response.getBid().getClickUrls() != null ? response.getBid().getClickUrls().size() : 0);
- BidRecord record = bidRecordFromMediaResponse(response, "tencent", traceId, "impression", params);
- hotStore.recordBid(record);
- log.info("[impression] 已缓存bid, qk={}, traceId={}", record.getQk(), traceId);
- // 上报百度曝光
- if (response.getBid() != null && response.getBid().getShowUrls() != null
- && !response.getBid().getShowUrls().isEmpty()) {
- List<TrackingResult> trackingResults = adxClient.reportImpression(
- response.getBid().getShowUrls(), response.getBid().getPrice());
- log.info("[impression] 曝光上报完成, qk={}, results={}", response.getQk(), trackingResults);
- recordTrackingResults(response.getQk(), "impression", trackingResults);
- }
- }
- // ─── 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<String, String> params = flatQueryParams(request);
- String traceId = mediaTraceId(params);
- if (traceId == null || traceId.isBlank()) {
- log.warn("[click] 缺少 traceId, params={}", params.keySet());
- throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "missing media trace id (click_id/oaid/imei/idfa)");
- }
- log.info("[点击] 收到点击请求 | traceId={} | IP={} | 全部入参: {}", traceId, clientIp(request), params);
- // 先从 Redis 按 mediaTrace 查找已有 bid
- BidRecord record = hotStore.findBidByMediaTrace("tencent", traceId);
- if (record == null) {
- log.info("[click] Redis未命中, 发起竞价请求, traceId={}", traceId);
- // 未找到,临时发起竞价请求
- Map<String, Object> bidRequest = mediaBidRequest(request, "tencent", traceId, params, tencentPlacement);
- NormalizedBidResponse resp = adxClient.requestBid(bidRequest);
- if (resp == null) {
- log.warn("[click] 百度ADX返回空响应, traceId={}", traceId);
- throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "empty baidu ADX response");
- }
- BidRecord newRecord = bidRecordFromMediaResponse(resp, "tencent", traceId, "click", params);
- hotStore.recordBid(newRecord);
- record = newRecord;
- log.info("[click] 竞价成功, qk={}, traceId={}", record.getQk(), traceId);
- } else {
- log.info("[click] Redis命中, qk={}, traceId={}", record.getQk(), traceId);
- }
- // 上报百度点击
- List<TrackingResult> trackingResults = adxClient.reportClick(
- record.getClickUrls() != null ? record.getClickUrls() : Collections.emptyList());
- log.info("[click] 点击上报完成, qk={}, results={}", record.getQk(), trackingResults);
- recordTrackingResults(record.getQk(), "click", trackingResults);
- // 点击处理完成,缩减 Redis 数据(清除 clickUrls/landingPage/appStoreLink)
- String destination = record.getAppStoreLink() != null && !record.getAppStoreLink().isBlank()
- ? record.getAppStoreLink() : record.getLandingPage();
- try {
- hotStore.shrinkAfterClick(record);
- } catch (Exception e) {
- log.warn("[click] Redis缩减失败(不影响主流程), qk={}, error={}", record.getQk(), e.getMessage());
- }
- // 重定向到 landing page 或 app store link
- if (destination != null && !destination.isBlank()) {
- log.info("[click] 302重定向, qk={}, destination={}", record.getQk(), destination);
- response.sendRedirect(destination);
- return null;
- }
- return Map.of("qk", nvl(record.getQk()), "traceId", traceId, "tracking", trackingResults);
- }
- // ─── 私有工具方法 ────────────────────────────────────────────────────────
- private BidRecord bidRecordFromMediaResponse(NormalizedBidResponse response,
- String media, String traceId,
- String eventType,
- Map<String, String> 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.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<String, String> flatQueryParams(HttpServletRequest request) {
- Map<String, String> 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<String, String> 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<String, Object> mediaBidRequest(HttpServletRequest request, String media,
- String traceId, Map<String, String> params,
- MediaPlacement placement) {
- MediaPlacement.Resolved resolved = placement.resolve(params);
- List<Integer> actionTypes = placement.getActionTypes();
- if (actionTypes == null || actionTypes.isEmpty()) actionTypes = List.of(1);
- 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<String, Object> 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<String, Object> 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<String, Object> uid = baiduUidFromParams(params);
- if (!uid.isEmpty()) device.put("uid", uid);
- Map<String, Object> 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);
- bidReq.put("media", media);
- bidReq.put("traceId", traceId);
- bidReq.put("mediaParams", params);
- log.info("[百度ADX] 竞价请求 | traceId={} | body={}", traceId, bidReq);
- return bidReq;
- }
- private List<Map<String, Integer>> 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<String, Object> baiduUidFromParams(Map<String, String> p) {
- Map<String, Object> 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"));
- return uid;
- }
- private void putUid(Map<String, Object> uid, String key, String value) {
- if (value != null && !value.isBlank()) uid.put(key, value);
- }
- private String firstParam(Map<String, String> p, String... keys) {
- for (String k : keys) {
- String v = p.get(k);
- if (v != null && !v.isBlank()) return v.trim();
- }
- return null;
- }
- private void recordTrackingResults(String qk, String kind, List<TrackingResult> 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 : ""; }
- }
|