yumeng 1 viikko sitten
vanhempi
commit
a3d78b9087

+ 10 - 0
README.md

@@ -17,3 +17,13 @@ OPPO Redis 缓存与其他渠道采用相同生命周期:曝光写入的 qk、
 Stream 仍保存完整事件供后台异步落库,成功后 ACK 并删除消息。
 点击按 mediaTrace 查缓存未命中时重新请求广告;转化按 qk 查找仍支持 DB 回查。
 已有 Redis key 不会因部署自动缩短 TTL,会在后续写入、点击或自然过期时更新。
+
+### jlt130 / jlt11 设备级上报限制
+
+腾讯、vivo、快手、荣耀、OPPO 监测入口的 `jlt130`、`jlt11` 按“媒体 + 广告位 + 设备标识 + 事件类型”分别执行滚动 24 小时去重,覆盖正常曝光、点击及点击补曝光路径(腾讯覆盖 v1–v6)。不同媒体、两个广告位独立计数;曝光、点击独立计时,重复请求不刷新去重 TTL。依赖共享 Redis 的原子 `SET NX`,不依赖 click_id、traceId 或进程内锁。
+
+设备标识按 OAID、IMEI/did、IDFA 顺序选择,支持对应 MD5 字段别名;明文按去除首尾空格后的原始大小写计算 MD5,与同样规则生成的 MD5 入参共用标识。不同类型的设备标识不自动关联。当前入参没有有效设备标识时尝试读取原竞价记录中的设备标识;仍缺失或 Redis 去重操作异常时,只跳过百度监测上报,继续原处理流程。
+
+新增 Redis 数据仅为曝光和点击的 24 小时标识,值为 `1`。不新增设备级竞价缓存或竞价初始化占位,不修改竞价、按 traceId 查记录、转化关联、缓存 TTL、点击后瘦身和跳转逻辑。重复请求仍可能按原流程竞价,但不会重复发送已占位的百度曝光/点击监测请求。
+
+`ClickUrls` 仅调用第一个有效 HTTP(S) 链接,空列表不消耗点击资格;曝光仍执行一轮 `ShowUrls` 列表。两类上报均关闭 HTTP 500 重试,超时或失败不释放已取得的上报资格,避免百度已收到请求但本地未收到响应时重复发送。正常 302 跟随仍保留。其他广告位沿用原逻辑。

+ 18 - 8
src/main/java/com/adx/tencent/baidu/AdxClient.java

@@ -106,12 +106,17 @@ public class AdxClient {
      */
     public List<TrackingResult> reportImpression(List<String> showUrls, long price)
             throws IOException, InterruptedException {
+        return reportImpression(showUrls, price, true);
+    }
+
+    public List<TrackingResult> reportImpression(List<String> showUrls, long price, boolean allowRetry)
+            throws IOException, InterruptedException {
         String encodedPrice = priceEncoder.encode(price);
         List<String> urls = new ArrayList<>();
         for (String url : showUrls) {
             urls.add(url.replace(AUCTION_PRICE_MACRO, encodedPrice));
         }
-        return reportTrackingUrls(urls);
+        return reportTrackingUrls(urls, allowRetry);
     }
 
     /**
@@ -119,19 +124,24 @@ public class AdxClient {
      */
     public List<TrackingResult> reportClick(List<String> clickUrls)
             throws IOException, InterruptedException {
-        return reportTrackingUrls(clickUrls);
+        return reportClick(clickUrls, true);
+    }
+
+    public List<TrackingResult> reportClick(List<String> clickUrls, boolean allowRetry)
+            throws IOException, InterruptedException {
+        return reportTrackingUrls(clickUrls, allowRetry);
     }
 
-    private List<TrackingResult> reportTrackingUrls(List<String> trackingUrls)
+    private List<TrackingResult> reportTrackingUrls(List<String> trackingUrls, boolean allowRetry)
             throws IOException, InterruptedException {
         List<TrackingResult> results = new ArrayList<>();
         for (String url : trackingUrls) {
-            results.add(reportTrackingUrl(url));
+            results.add(reportTrackingUrl(url, allowRetry ? trackingMax500Retries : 0));
         }
         return results;
     }
 
-    private TrackingResult reportTrackingUrl(String originalUrl)
+    private TrackingResult reportTrackingUrl(String originalUrl, int max500Retries)
             throws IOException, InterruptedException {
         String currentUrl = originalUrl;
         int redirectCount = 0;
@@ -152,16 +162,16 @@ public class AdxClient {
                 return new TrackingResult(originalUrl, status, true);
             }
             if (status == 500) {
-                if (status500RetryCount >= trackingMax500Retries) {
+                if (status500RetryCount >= max500Retries) {
                     log.warn("[百度监测][500重试失败] originalUrl={} currentUrl={} status={} retries={} maxRetries={} redirects={} reason=retry_limit_exceeded",
                             summarizeUrl(originalUrl), summarizeUrl(currentUrl), status,
-                            status500RetryCount, trackingMax500Retries, redirectCount);
+                            status500RetryCount, max500Retries, redirectCount);
                     return new TrackingResult(originalUrl, status, false);
                 }
                 status500RetryCount++;
                 log.warn("[百度监测][500重试] originalUrl={} currentUrl={} status={} retry={}/{} redirects={} action=retry_original_url",
                         summarizeUrl(originalUrl), summarizeUrl(currentUrl), status,
-                        status500RetryCount, trackingMax500Retries, redirectCount);
+                        status500RetryCount, max500Retries, redirectCount);
                 currentUrl = originalUrl;
                 redirectCount = 0;
                 continue;

+ 15 - 5
src/main/java/com/adx/tencent/honor/controller/HonorTrackingController.java

@@ -1,6 +1,7 @@
 package com.adx.tencent.honor.controller;
 
 import com.adx.tencent.baidu.AdxClient;
+import com.adx.tencent.httpapi.DeviceTrackingReporter;
 import com.adx.tencent.baidu.model.NormalizedBidResponse;
 import com.adx.tencent.baidu.model.TrackingResult;
 import com.adx.tencent.honor.model.HonorBidRecord;
@@ -125,12 +126,21 @@ public class HonorTrackingController {
 
         List<TrackingResult> tracking = Collections.emptyList();
         if (response.getBid() != null && response.getBid().getShowUrls() != null && !response.getBid().getShowUrls().isEmpty()) {
-            tracking = adxClient.reportImpression(response.getBid().getShowUrls(), response.getBid().getPrice());
+            tracking = reportDeviceTracking(record, params, response.getBid().getShowUrls(), response.getBid().getPrice(), false);
             recordTracking(record.getQk(), tagId, "impression", tracking);
         }
         return Map.of("qk", record.getQk(), "traceId", traceId, "tagId", tagId);
     }
 
+    private List<TrackingResult> reportDeviceTracking(HonorBidRecord record, Map<String, String> params,
+                                                      List<String> urls, long price, boolean click) throws Exception {
+        String tag = firstParam(params, "baidu_tag_id", "tag_id", "tagid", "tagId");
+        if (tag == null) tag = record.getTagId();
+        if (tag == null) tag = placement.getBaiduTagId();
+        return DeviceTrackingReporter.report(adxClient, "honor", tag, params, record.getMediaParams(),
+                urls, price, click, hotStore::claimDeviceTracking);
+    }
+
     private Object doClick(HttpServletRequest request, HttpServletResponse response) throws Exception {
         if (placement == null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "honor placement not configured");
         Map<String, String> params = normalizeParams(request);
@@ -171,14 +181,14 @@ public class HonorTrackingController {
             record.setRtaCode(rtaDecision.getCode());
             hotStore.recordBid(record);
             if (bidResponse.getBid() != null && bidResponse.getBid().getShowUrls() != null && !bidResponse.getBid().getShowUrls().isEmpty()) {
-                List<TrackingResult> impressionTracking = adxClient.reportImpression(
-                        bidResponse.getBid().getShowUrls(), bidResponse.getBid().getPrice());
+                List<TrackingResult> impressionTracking = reportDeviceTracking(record, params,
+                        bidResponse.getBid().getShowUrls(), bidResponse.getBid().getPrice(), false);
                 recordTracking(record.getQk(), tagId, "impression", impressionTracking);
             }
         }
 
-        List<TrackingResult> tracking = adxClient.reportClick(
-                record.getClickUrls() != null ? record.getClickUrls() : Collections.emptyList());
+        List<TrackingResult> tracking = reportDeviceTracking(record, params,
+                record.getClickUrls() != null ? record.getClickUrls() : Collections.emptyList(), 0, true);
         recordTracking(record.getQk(), tagId, "click", tracking);
         try {
             hotStore.shrinkAfterClick(record);

+ 6 - 0
src/main/java/com/adx/tencent/honor/store/HonorHotStore.java

@@ -60,6 +60,12 @@ public class HonorHotStore {
         this.deductionDecisionScript = buildDeductionDecisionScript();
     }
 
+    /** Independent rolling 24-hour monitoring flags; no additional bid cache. */
+    public boolean claimDeviceTracking(String scope, String event) {
+        return Boolean.TRUE.equals(redis.opsForValue().setIfAbsent(
+                prefix + "device-tracking:" + scope + ":" + event, "1", Duration.ofHours(24)));
+    }
+
     public void recordBid(HonorBidRecord record) {
         if (record.getCreatedAt() == null) record.setCreatedAt(Instant.now());
         try {

+ 51 - 0
src/main/java/com/adx/tencent/httpapi/DeviceTrackingPolicy.java

@@ -0,0 +1,51 @@
+package com.adx.tencent.httpapi;
+
+import org.springframework.util.DigestUtils;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.util.*;
+
+/** Device identity is independent of media request IDs and tracking API versions. */
+final class DeviceTrackingPolicy {
+    private DeviceTrackingPolicy() {}
+
+    static boolean applies(String tagId) {
+        return "jlt130".equals(tagId) || "jlt11".equals(tagId);
+    }
+
+    static String identity(Map<String, String> params) {
+        String[][] families = {
+                {"oaid", "oaid", "oaid_md5", "oaidMd5", "oaidMD5", "hash_oaid"},
+                {"imei", "imei", "did", "imei_md5", "imeiMd5", "imeiMD5", "did_md5", "didMd5"},
+                {"idfa", "idfa", "idfa_md5", "idfaMd5", "idfaMD5"}
+        };
+        for (String[] family : families) {
+            for (int i = 1; i < family.length; i++) {
+                String value = params.get(family[i]);
+                if (value == null) continue;
+                value = value.trim();
+                if (value.isEmpty() || value.equalsIgnoreCase("null") || value.equalsIgnoreCase("unknown")
+                        || value.matches("[0-]+") || value.contains("__") || value.contains("${")) continue;
+                boolean hashed = family[i].toLowerCase(Locale.ROOT).contains("md5") || family[i].startsWith("hash_");
+                if (hashed && !value.matches("(?i)[0-9a-f]{32}")) continue;
+                String digest = hashed ? value.toLowerCase(Locale.ROOT)
+                        : DigestUtils.md5DigestAsHex(value.getBytes(StandardCharsets.UTF_8));
+                return family[0] + ":" + digest;
+            }
+        }
+        return null;
+    }
+
+    static List<String> firstClick(List<String> urls) {
+        if (urls == null) return List.of();
+        for (String url : urls) {
+            if (url == null || url.isBlank()) continue;
+            try {
+                URI uri = URI.create(url.trim());
+                if (("https".equalsIgnoreCase(uri.getScheme()) || "http".equalsIgnoreCase(uri.getScheme()))
+                        && uri.getHost() != null) return List.of(url.trim());
+            } catch (IllegalArgumentException ignored) { }
+        }
+        return List.of();
+    }
+}

+ 43 - 0
src/main/java/com/adx/tencent/httpapi/DeviceTrackingReporter.java

@@ -0,0 +1,43 @@
+package com.adx.tencent.httpapi;
+
+import com.adx.tencent.baidu.AdxClient;
+import com.adx.tencent.baidu.model.TrackingResult;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import java.util.*;
+import java.util.function.BiPredicate;
+
+/** Shared monitoring-only policy. Does not alter any media's bidding or cache lifecycle. */
+public final class DeviceTrackingReporter {
+    private static final Logger log = LoggerFactory.getLogger(DeviceTrackingReporter.class);
+    private DeviceTrackingReporter() {}
+
+    public static List<TrackingResult> report(AdxClient adxClient, String media, String tag,
+            Map<String, String> params, Map<String, String> cachedParams, List<String> urls,
+            long price, boolean click, BiPredicate<String, String> claim) throws Exception {
+        if (!DeviceTrackingPolicy.applies(tag)) {
+            return click ? adxClient.reportClick(urls) : adxClient.reportImpression(urls, price);
+        }
+        List<String> selected = click ? DeviceTrackingPolicy.firstClick(urls) : urls;
+        if (selected == null || selected.isEmpty()) return Collections.emptyList();
+        String device = DeviceTrackingPolicy.identity(params);
+        if (device == null && cachedParams != null) {
+            device = DeviceTrackingPolicy.identity(cachedParams);
+        }
+        String event = click ? "click" : "impression";
+        if (device == null) {
+            log.warn("[百度监测][设备去重] media={} tag={} event={} skipped=missing_device", media, tag, event);
+            return Collections.emptyList();
+        }
+        try {
+            if (!claim.test(media + ":" + tag + ":" + device, event)) {
+                return Collections.emptyList();
+            }
+        } catch (RuntimeException e) {
+            // Redis failure must not allow duplicate monitoring or bypass the normal shrink/redirect flow.
+            log.error("[百度监测][设备去重] media={} tag={} event={} skipped=redis_error", media, tag, event, e);
+            return Collections.emptyList();
+        }
+        return click ? adxClient.reportClick(selected, false) : adxClient.reportImpression(selected, price, false);
+    }
+}

+ 17 - 6
src/main/java/com/adx/tencent/httpapi/TrackingController.java

@@ -168,8 +168,8 @@ public class TrackingController {
         // 上报百度曝光
         if (response.getBid() != null && response.getBid().getShowUrls() != null
                 && !response.getBid().getShowUrls().isEmpty()) {
-            List<TrackingResult> trackingResults = adxClient.reportImpression(
-                    response.getBid().getShowUrls(), response.getBid().getPrice());
+            List<TrackingResult> 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);
@@ -291,14 +291,14 @@ public class TrackingController {
             record = newRecord;
             if (resp.getBid() != null && resp.getBid().getShowUrls() != null
                     && !resp.getBid().getShowUrls().isEmpty()) {
-                List<TrackingResult> impressionResults = adxClient.reportImpression(
-                        resp.getBid().getShowUrls(), resp.getBid().getPrice());
+                List<TrackingResult> impressionResults = reportDeviceTracking(record, params,
+                        resp.getBid().getShowUrls(), resp.getBid().getPrice(), false);
                 recordTrackingResults(record.getQk(), "impression", impressionResults);
             }
         }
 
-        List<TrackingResult> trackingResults = adxClient.reportClick(
-                record.getClickUrls() != null ? record.getClickUrls() : Collections.emptyList());
+        List<TrackingResult> 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()
@@ -316,6 +316,17 @@ public class TrackingController {
         return Map.of("qk", nvl(record.getQk()), "traceId", traceId);
     }
 
+    /** Only guard outbound monitoring; bidding, cache lifecycle and redirects keep their original flow. */
+    private List<TrackingResult> reportDeviceTracking(BidRecord record, Map<String, String> params,
+                                                      List<String> 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<String, String> params, String trackingVersion, int deductionRate) {
         boolean isNewLink = !"v1".equalsIgnoreCase(trackingVersion);
         params.put(BidRecord.NEW_LINK_FLAG_KEY, Boolean.toString(isNewLink));

+ 15 - 5
src/main/java/com/adx/tencent/kuaishou/controller/KuaishouTrackingController.java

@@ -1,6 +1,7 @@
 package com.adx.tencent.kuaishou.controller;
 
 import com.adx.tencent.baidu.AdxClient;
+import com.adx.tencent.httpapi.DeviceTrackingReporter;
 import com.adx.tencent.baidu.model.NormalizedBidResponse;
 import com.adx.tencent.baidu.model.TrackingResult;
 import com.adx.tencent.kuaishou.model.KuaishouBidRecord;
@@ -103,12 +104,21 @@ public class KuaishouTrackingController {
         hotStore.recordBid(record);
 
         if (response.getBid() != null && response.getBid().getShowUrls() != null && !response.getBid().getShowUrls().isEmpty()) {
-            List<TrackingResult> tracking = adxClient.reportImpression(response.getBid().getShowUrls(), response.getBid().getPrice());
+            List<TrackingResult> tracking = reportDeviceTracking(record, params, response.getBid().getShowUrls(), response.getBid().getPrice(), false);
             recordTracking(record.getQk(), record.getTagId(), "impression", tracking);
         }
         return Map.of("qk", record.getQk(), "traceId", traceId, "tagId", record.getTagId());
     }
 
+    private List<TrackingResult> reportDeviceTracking(KuaishouBidRecord record, Map<String, String> params,
+                                                      List<String> urls, long price, boolean click) throws Exception {
+        String tag = firstParam(params, "baidu_tag_id", "tag_id", "tagid", "tagId");
+        if (tag == null) tag = record.getTagId();
+        if (tag == null) tag = placement.getBaiduTagId();
+        return DeviceTrackingReporter.report(adxClient, "kuaishou", tag, params, record.getMediaParams(),
+                urls, price, click, hotStore::claimDeviceTracking);
+    }
+
     private Object doClick(HttpServletRequest request, HttpServletResponse response) throws Exception {
         ensureReady();
         Map<String, String> params = normalizeParams(request);
@@ -138,14 +148,14 @@ public class KuaishouTrackingController {
             record = bidRecordFromResponse(bidResponse, traceId, "click", params);
             hotStore.recordBid(record);
             if (bidResponse.getBid() != null && bidResponse.getBid().getShowUrls() != null && !bidResponse.getBid().getShowUrls().isEmpty()) {
-                List<TrackingResult> impressionTracking = adxClient.reportImpression(
-                        bidResponse.getBid().getShowUrls(), bidResponse.getBid().getPrice());
+                List<TrackingResult> impressionTracking = reportDeviceTracking(record, params,
+                        bidResponse.getBid().getShowUrls(), bidResponse.getBid().getPrice(), false);
                 recordTracking(record.getQk(), record.getTagId(), "impression", impressionTracking);
             }
         }
 
-        List<TrackingResult> tracking = adxClient.reportClick(
-                record.getClickUrls() != null ? record.getClickUrls() : Collections.emptyList());
+        List<TrackingResult> tracking = reportDeviceTracking(record, params,
+                record.getClickUrls() != null ? record.getClickUrls() : Collections.emptyList(), 0, true);
         recordTracking(record.getQk(), record.getTagId(), "click", tracking);
         try {
             hotStore.shrinkAfterClick(record);

+ 6 - 0
src/main/java/com/adx/tencent/kuaishou/store/KuaishouHotStore.java

@@ -62,6 +62,12 @@ public class KuaishouHotStore {
         this.deductionDecisionScript = buildDeductionDecisionScript();
     }
 
+    /** Independent rolling 24-hour monitoring flags; no additional bid cache. */
+    public boolean claimDeviceTracking(String scope, String event) {
+        return Boolean.TRUE.equals(redis.opsForValue().setIfAbsent(
+                prefix + "device-tracking:" + scope + ":" + event, "1", Duration.ofHours(24)));
+    }
+
     public void recordBid(KuaishouBidRecord record) {
         if (record.getCreatedAt() == null) record.setCreatedAt(Instant.now());
         try {

+ 11 - 5
src/main/java/com/adx/tencent/oppo/controller/OppoTrackingController.java

@@ -1,6 +1,7 @@
 package com.adx.tencent.oppo.controller;
 
 import com.adx.tencent.baidu.AdxClient;
+import com.adx.tencent.httpapi.DeviceTrackingReporter;
 import com.adx.tencent.baidu.model.NormalizedBidResponse;
 import com.adx.tencent.baidu.model.TrackingResult;
 import com.adx.tencent.config.AppProperties;
@@ -77,7 +78,7 @@ public class OppoTrackingController {
         OppoBidRecord record = record(response, trace, "impression", rtaParams);
         record.setRtaCode(rtaDecision.getCode());
         store.recordBid(record);
-        report(record, response.getBid() == null ? null : response.getBid().getShowUrls(), "impression");
+        report(record, p, response.getBid() == null ? null : response.getBid().getShowUrls(), "impression");
         return responseBody(rtaParams, record.getQk(), trace, record.getTagId(), rtaDecision);
     }
 
@@ -101,9 +102,9 @@ public class OppoTrackingController {
             record = record(bid, trace, "click", rtaParams);
             record.setRtaCode(rtaDecision.getCode());
             store.recordBid(record);
-            report(record, bid.getBid() == null ? null : bid.getBid().getShowUrls(), "impression");
+            report(record, p, bid.getBid() == null ? null : bid.getBid().getShowUrls(), "impression");
         }
-        report(record, record.getClickUrls(), "click");
+        report(record, p, record.getClickUrls(), "click");
         try {
             store.shrinkAfterClick(record);
         } catch (Exception ignored) {
@@ -118,10 +119,15 @@ public class OppoTrackingController {
         return responseBody(p, record.getQk(), trace, record.getTagId(), rtaDecision);
     }
 
-    private void report(OppoBidRecord r, List<String> urls, String kind) {
+    private void report(OppoBidRecord r, Map<String, String> params, List<String> urls, String kind) {
         if (urls == null) return;
         try {
-            List<TrackingResult> results = "impression".equals(kind) ? adx.reportImpression(urls, r.getPrice() == null ? 0 : r.getPrice()) : adx.reportClick(urls);
+            String tag = first(params, "tagId", "tag_id", "baidu_tag_id");
+            if (tag == null) tag = r.getTagId();
+            if (tag == null) tag = props.getBaiduTagId();
+            List<TrackingResult> results = DeviceTrackingReporter.report(adx, "oppo", tag,
+                    params, r.getMediaParams(), urls, r.getPrice() == null ? 0 : r.getPrice(),
+                    "click".equals(kind), store::claimDeviceTracking);
             for (TrackingResult x : results) {
                 OppoTrackingRecord t = new OppoTrackingRecord();
                 t.setQk(r.getQk());

+ 6 - 0
src/main/java/com/adx/tencent/oppo/store/OppoHotStore.java

@@ -63,6 +63,12 @@ public class OppoHotStore {
         this.deductionDecisionScript = buildDeductionDecisionScript();
     }
 
+    /** Independent rolling 24-hour monitoring flags; no additional bid cache. */
+    public boolean claimDeviceTracking(String scope, String event) {
+        return Boolean.TRUE.equals(redis.opsForValue().setIfAbsent(
+                prefix + "device-tracking:" + scope + ":" + event, "1", Duration.ofHours(24)));
+    }
+
     public void recordBid(OppoBidRecord record) {
         if (record.getCreatedAt() == null) record.setCreatedAt(Instant.now());
         try {

+ 8 - 0
src/main/java/com/adx/tencent/storage/RedisHotStore.java

@@ -57,6 +57,14 @@ public class RedisHotStore {
         this.deductionDecisionScript = buildDeductionDecisionScript();
     }
 
+    private static final Duration DEVICE_TRACKING_TTL = Duration.ofHours(24);
+
+    /** Claim once without refreshing the rolling window. Fail closed on Redis errors. */
+    public boolean claimDeviceTracking(String scope, String event) {
+        return Boolean.TRUE.equals(redis.opsForValue().setIfAbsent(
+                prefix + "device-tracking:" + scope + ":" + event, "1", DEVICE_TRACKING_TTL));
+    }
+
     // ─── EventRecorder ───────────────────────────────────────────────────────
 
     /**

+ 15 - 5
src/main/java/com/adx/tencent/vivo/controller/VivoTrackingController.java

@@ -1,6 +1,7 @@
 package com.adx.tencent.vivo.controller;
 
 import com.adx.tencent.baidu.AdxClient;
+import com.adx.tencent.httpapi.DeviceTrackingReporter;
 import com.adx.tencent.baidu.model.NormalizedBidResponse;
 import com.adx.tencent.baidu.model.TrackingResult;
 import com.adx.tencent.rta.BaiduRtaDecision;
@@ -176,12 +177,21 @@ public class VivoTrackingController {
         hotStore.recordBid(record);
 
         if (response.getBid() != null && response.getBid().getShowUrls() != null && !response.getBid().getShowUrls().isEmpty()) {
-            List<TrackingResult> tracking = adxClient.reportImpression(response.getBid().getShowUrls(), response.getBid().getPrice());
+            List<TrackingResult> tracking = reportDeviceTracking(record, params, response.getBid().getShowUrls(), response.getBid().getPrice(), false);
             recordTracking(record.getQk(), record.getTagId(), "impression", tracking);
         }
         return Map.of("qk", record.getQk(), "traceId", traceId, "tagId", record.getTagId());
     }
 
+    private List<TrackingResult> reportDeviceTracking(VivoBidRecord record, Map<String, String> params,
+                                                      List<String> urls, long price, boolean click) throws Exception {
+        String tag = firstParam(params, "baidu_tag_id", "tag_id", "tagid", "tagId");
+        if (tag == null) tag = record.getTagId();
+        if (tag == null) tag = placement.getBaiduTagId();
+        return DeviceTrackingReporter.report(adxClient, "vivo", tag, params, record.getMediaParams(),
+                urls, price, click, hotStore::claimDeviceTracking);
+    }
+
     private Object doClick(HttpServletRequest request, HttpServletResponse response) throws Exception {
         ensureReady();
         Map<String, String> params = normalizeParams(request);
@@ -222,14 +232,14 @@ public class VivoTrackingController {
             record.setRtaCode(rtaDecision.getCode());
             hotStore.recordBid(record);
             if (bidResponse.getBid() != null && bidResponse.getBid().getShowUrls() != null && !bidResponse.getBid().getShowUrls().isEmpty()) {
-                List<TrackingResult> impressionTracking = adxClient.reportImpression(
-                        bidResponse.getBid().getShowUrls(), bidResponse.getBid().getPrice());
+                List<TrackingResult> impressionTracking = reportDeviceTracking(record, params,
+                        bidResponse.getBid().getShowUrls(), bidResponse.getBid().getPrice(), false);
                 recordTracking(record.getQk(), record.getTagId(), "impression", impressionTracking);
             }
         }
 
-        List<TrackingResult> tracking = adxClient.reportClick(
-                record.getClickUrls() != null ? record.getClickUrls() : Collections.emptyList());
+        List<TrackingResult> tracking = reportDeviceTracking(record, params,
+                record.getClickUrls() != null ? record.getClickUrls() : Collections.emptyList(), 0, true);
         recordTracking(record.getQk(), record.getTagId(), "click", tracking);
         try {
             hotStore.shrinkAfterClick(record);

+ 6 - 0
src/main/java/com/adx/tencent/vivo/store/VivoHotStore.java

@@ -62,6 +62,12 @@ public class VivoHotStore {
         this.deductionDecisionScript = buildDeductionDecisionScript();
     }
 
+    /** Independent rolling 24-hour monitoring flags; no additional bid cache. */
+    public boolean claimDeviceTracking(String scope, String event) {
+        return Boolean.TRUE.equals(redis.opsForValue().setIfAbsent(
+                prefix + "device-tracking:" + scope + ":" + event, "1", Duration.ofHours(24)));
+    }
+
     public void recordBid(VivoBidRecord record) {
         if (record.getCreatedAt() == null) record.setCreatedAt(Instant.now());
         try {

+ 56 - 0
src/test/java/com/adx/tencent/baidu/AdxTrackingRetryTest.java

@@ -0,0 +1,56 @@
+package com.adx.tencent.baidu;
+
+import com.sun.net.httpserver.HttpServer;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.Test;
+import java.net.InetSocketAddress;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+import static org.junit.jupiter.api.Assertions.*;
+
+class AdxTrackingRetryTest {
+    @Test void limitedReportsDoNotRetry500ButDefaultReportsStillDo() throws Exception {
+        HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+        AtomicInteger calls = new AtomicInteger();
+        server.createContext("/track", exchange -> {
+            calls.incrementAndGet(); exchange.sendResponseHeaders(500, -1); exchange.close();
+        });
+        server.start();
+        try {
+            String url = "http://127.0.0.1:" + server.getAddress().getPort() + "/track";
+            AdxClient client = new AdxClient(url, Long::toString, new ObjectMapper(), 3, 2);
+            client.reportClick(List.of(url), false);
+            assertEquals(1, calls.get());
+            client.reportImpression(List.of(url), 1, false);
+            assertEquals(2, calls.get());
+            client.reportClick(List.of(url));
+            assertEquals(5, calls.get());
+            client.reportImpression(List.of(url), 1);
+            assertEquals(8, calls.get());
+        } finally { server.stop(0); }
+    }
+    @Test void defaultReportsKeepEveryUrlPriceMacroAndRedirects() throws Exception {
+        HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+        java.util.List<String> paths = new java.util.concurrent.CopyOnWriteArrayList<>();
+        server.createContext("/", exchange -> {
+            paths.add(exchange.getRequestURI().toString());
+            if (exchange.getRequestURI().getPath().equals("/redirect")) {
+                exchange.getResponseHeaders().add("Location", "/final");
+                exchange.sendResponseHeaders(302, -1);
+            } else { exchange.sendResponseHeaders(200, -1); }
+            exchange.close();
+        });
+        server.start();
+        try {
+            String base = "http://127.0.0.1:" + server.getAddress().getPort();
+            AdxClient client = new AdxClient(base, p -> "encoded" + p, new ObjectMapper(), 3, 2);
+            assertEquals(2, client.reportClick(List.of(base + "/redirect", base + "/second")).size());
+            assertEquals(List.of("/redirect", "/final", "/second"), paths);
+            paths.clear();
+            assertEquals(2, client.reportImpression(List.of(base + "/show1?p=${AUCTION_PRICE}",
+                    base + "/show2?p=${AUCTION_PRICE}"), 123).size());
+            assertEquals(List.of("/show1?p=encoded123", "/show2?p=encoded123"), paths);
+        } finally { server.stop(0); }
+    }
+
+}

+ 243 - 0
src/test/java/com/adx/tencent/httpapi/DeviceTrackingTest.java

@@ -0,0 +1,243 @@
+package com.adx.tencent.httpapi;
+
+import com.adx.tencent.baidu.AdxClient;
+import com.adx.tencent.baidu.model.*;
+import com.adx.tencent.storage.RedisHotStore;
+import com.adx.tencent.storage.model.BidRecord;
+import com.fasterxml.jackson.databind.json.JsonMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.web.server.ResponseStatusException;
+import org.springframework.util.DigestUtils;
+import java.nio.charset.StandardCharsets;
+import java.util.*;
+import java.util.concurrent.*;
+import java.util.concurrent.atomic.AtomicLong;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+class DeviceTrackingTest {
+    final RedisHotStore store = mock(RedisHotStore.class);
+    final AdxClient client = mock(AdxClient.class);
+    final Map<String, Long> claims = new ConcurrentHashMap<>();
+    final AtomicLong now = new AtomicLong();
+    TrackingController controller;
+
+    @BeforeEach void setup() throws Exception {
+        MediaPlacement placement = new MediaPlacement();
+        placement.setBaiduAppId("app"); placement.setBaiduTagId("jlt130");
+        controller = new TrackingController(client, store, placement,
+                JsonMapper.builder().findAndAddModules().build(), "test");
+        when(store.claimDeviceTracking(anyString(), anyString())).thenAnswer(i -> {
+            String key = i.getArgument(0) + ":" + i.getArgument(1);
+            synchronized (claims) {
+                if (claims.getOrDefault(key, -1L) > now.get()) return false;
+                claims.put(key, now.get() + 86400); return true;
+            }
+        });
+        NormalizedBid bid = new NormalizedBid();
+        bid.setShowUrls(List.of("https://example.com/show")); bid.setPrice(100L);
+        bid.setClickUrls(Arrays.asList(null, "", "bad", "https://example.com/click1", "https://example.com/click2"));
+        bid.setLandingPage("https://example.com/landing");
+        NormalizedBidResponse response = new NormalizedBidResponse();
+        response.setQk("qk"); response.setBid(bid);
+        when(client.requestBid(anyString(), anyString(), any())).thenReturn(response);
+        when(client.reportImpression(anyList(), anyLong(), anyBoolean())).thenReturn(List.of());
+        when(client.reportClick(anyList(), anyBoolean())).thenReturn(List.of());
+    }
+
+    MockHttpServletRequest request(String tag, String trace) {
+        MockHttpServletRequest req = new MockHttpServletRequest();
+        req.setParameter("tag_id", tag); req.setParameter("click_id", trace); req.setParameter("oaid", "device-A");
+        return req;
+    }
+
+    @Test void repeatsAndVersionsKeepOriginalBidAndShrinkFlowButReportOnlyOnce() throws Exception {
+        controller.tencentImpression(request("jlt130", "i1"), 0);
+        controller.tencentImpressionV2(request("jlt130", "i2"));
+        for (int n = 0; n < 3; n++) {
+            MockHttpServletResponse res = new MockHttpServletResponse();
+            controller.tencentClickV2(request("jlt130", "c" + n), res);
+            assertEquals("https://example.com/landing", res.getRedirectedUrl());
+        }
+        verify(client, times(5)).requestBid(anyString(), anyString(), any());
+        verify(client, times(1)).reportImpression(anyList(), eq(100L), eq(false));
+        verify(client, times(1)).reportClick(List.of("https://example.com/click1"), false);
+        verify(store, times(3)).shrinkAfterClick(any());
+        verify(store, times(5)).recordBid(any());
+    }
+
+    @Test void clickFirstAndSeparatePlacementsAndDevices() throws Exception {
+        controller.tencentClick(request("jlt130", "c1"), new MockHttpServletResponse(), 0);
+        controller.tencentImpression(request("jlt130", "i1"), 0);
+        controller.tencentClick(request("jlt11", "c2"), new MockHttpServletResponse(), 0);
+        MockHttpServletRequest other = request("jlt130", "c3"); other.setParameter("oaid", "device-B");
+        controller.tencentClick(other, new MockHttpServletResponse(), 0);
+        verify(client, times(4)).requestBid(anyString(), anyString(), any());
+        verify(client, times(3)).reportImpression(anyList(), anyLong(), eq(false));
+        verify(client, times(3)).reportClick(anyList(), eq(false));
+    }
+
+    @Test void rollingWindowsAreIndependentAndNotExtendedByDuplicates() throws Exception {
+        controller.tencentImpression(request("jlt130", "i1"), 0);
+        now.set(300);
+        controller.tencentClick(request("jlt130", "c1"), new MockHttpServletResponse(), 0);
+        now.set(86399);
+        controller.tencentClick(request("jlt130", "c2"), new MockHttpServletResponse(), 0);
+        now.set(86400);
+        controller.tencentClick(request("jlt130", "c3"), new MockHttpServletResponse(), 0);
+        verify(client, times(2)).reportImpression(anyList(), anyLong(), eq(false));
+        verify(client, times(1)).reportClick(anyList(), eq(false));
+        now.set(86700);
+        controller.tencentClick(request("jlt130", "c4"), new MockHttpServletResponse(), 0);
+        verify(client, times(2)).reportClick(anyList(), eq(false));
+    }
+
+    @Test void concurrentRequestsOnlyDeduplicateTracking() throws Exception {
+        ExecutorService executor = Executors.newFixedThreadPool(12);
+        try {
+            List<Callable<Void>> tasks = new ArrayList<>();
+            for (int n = 0; n < 30; n++) {
+                final int index = n;
+                tasks.add(() -> {
+                    controller.tencentClick(request("jlt130", "c" + index), new MockHttpServletResponse(), 0);
+                    return null;
+                });
+            }
+            for (Future<Void> result : executor.invokeAll(tasks)) result.get();
+        } finally { executor.shutdownNow(); }
+        verify(client, times(30)).requestBid(anyString(), anyString(), any());
+        verify(store, times(30)).shrinkAfterClick(any());
+        verify(client, times(1)).reportImpression(anyList(), anyLong(), eq(false));
+        verify(client, times(1)).reportClick(anyList(), eq(false));
+    }
+
+    @Test void missingDeviceAndRedisFailureSkipTrackingButKeepBiddingShrinkAndRedirect() throws Exception {
+        MockHttpServletRequest req = request("jlt130", "c1"); req.removeParameter("oaid");
+        MockHttpServletResponse first = new MockHttpServletResponse();
+        controller.tencentClick(req, first, 0);
+        assertEquals("https://example.com/landing", first.getRedirectedUrl());
+        when(store.claimDeviceTracking(anyString(), anyString())).thenThrow(new IllegalStateException("redis unavailable"));
+        MockHttpServletResponse second = new MockHttpServletResponse();
+        controller.tencentClick(request("jlt130", "c2"), second, 0);
+        assertEquals("https://example.com/landing", second.getRedirectedUrl());
+        verify(client, times(2)).requestBid(anyString(), anyString(), any());
+        verify(client, never()).reportImpression(anyList(), anyLong(), anyBoolean());
+        verify(client, never()).reportClick(anyList(), anyBoolean());
+        verify(store, times(2)).shrinkAfterClick(any());
+    }
+
+    @Test void timeoutConsumesClickAllowance() throws Exception {
+        when(client.reportClick(anyList(), eq(false))).thenThrow(new java.io.IOException("timeout"));
+        assertThrows(java.io.IOException.class, () -> controller.tencentClick(request("jlt130", "c1"), new MockHttpServletResponse(), 0));
+        MockHttpServletResponse res = new MockHttpServletResponse();
+        controller.tencentClick(request("jlt130", "c2"), res, 0);
+        assertNotNull(res.getRedirectedUrl());
+        verify(client, times(1)).reportClick(anyList(), eq(false));
+    }
+
+    @Test void cachedRecordUsesOriginalLookupAndShrinkWithoutRebiddingOrCallbackRewrite() throws Exception {
+        BidRecord cached = new BidRecord();
+        cached.setQk("cached-qk"); cached.setTagId("jlt130");
+        cached.setClickUrls(List.of("https://example.com/cached-click"));
+        cached.setLandingPage("https://example.com/cached-landing");
+        cached.setMediaParams(Map.of("oaid", "device-A", "callback", "original-callback"));
+        when(store.findBidByMediaTrace("tencent", "c1")).thenReturn(cached);
+        MockHttpServletRequest req = request("jlt130", "c1");
+        req.removeParameter("oaid"); req.setParameter("callback", "new-callback");
+        MockHttpServletResponse res = new MockHttpServletResponse();
+        controller.tencentClick(req, res, 0);
+        assertEquals("https://example.com/cached-landing", res.getRedirectedUrl());
+        assertEquals("original-callback", cached.getMediaParams().get("callback"));
+        verify(client, never()).requestBid(anyString(), anyString(), any());
+        verify(store, never()).recordBid(any());
+        verify(store).shrinkAfterClick(cached);
+        verify(client).reportClick(List.of("https://example.com/cached-click"), false);
+    }
+
+    @Test void alreadySlimmedRecordDoesNotRestoreTrackingUrlsOrDestination() throws Exception {
+        BidRecord slim = new BidRecord(); slim.setQk("qk"); slim.setTagId("jlt130");
+        when(store.findBidByMediaTrace("tencent", "c1")).thenReturn(slim);
+        MockHttpServletResponse res = new MockHttpServletResponse();
+        controller.tencentClick(request("jlt130", "c1"), res, 0);
+        assertNull(res.getRedirectedUrl());
+        verify(store).shrinkAfterClick(slim);
+        verify(store, never()).claimDeviceTracking(anyString(), anyString());
+        verifyNoInteractions(client);
+    }
+
+    @Test void failedBidDoesNotReserveDeviceOrBlockNextBid() throws Exception {
+        when(client.requestBid(anyString(), anyString(), any())).thenReturn(null);
+        assertThrows(ResponseStatusException.class, () -> controller.tencentImpression(request("jlt130", "i1"), 0));
+        assertThrows(ResponseStatusException.class, () -> controller.tencentImpression(request("jlt130", "i2"), 0));
+        verify(client, times(2)).requestBid(anyString(), anyString(), any());
+        verify(store, never()).claimDeviceTracking(anyString(), anyString());
+    }
+
+    @Test void identitiesSupportRawAndHashAliasesWithoutUsingTraceId() {
+        String digest = DigestUtils.md5DigestAsHex("device-A".getBytes(StandardCharsets.UTF_8));
+        assertEquals(DeviceTrackingPolicy.identity(Map.of("oaid", " device-A ")),
+                DeviceTrackingPolicy.identity(Map.of("oaidMD5", digest.toUpperCase(Locale.ROOT))));
+        assertNull(DeviceTrackingPolicy.identity(Map.of("click_id", "123", "oaid", "__OAID__")));
+        assertNull(DeviceTrackingPolicy.identity(Map.of("oaid", "00000000-0000-0000-0000-000000000000")));
+    }
+
+    @Test void otherPlacementsAllVersionsKeepAllUrlsAndNeverClaimDevice() throws Exception {
+        List<String> clicks = Arrays.asList(null, "", "bad", "https://example.com/click1", "https://example.com/click2");
+        for (int version = 1; version <= 6; version++) {
+            String suffix = version == 1 ? "" : "V" + version;
+            MockHttpServletRequest impression = request("other", "i" + version);
+            MockHttpServletRequest click = request("other", "c" + version);
+            // Other placements must not acquire a device requirement.
+            impression.removeParameter("oaid"); click.removeParameter("oaid");
+            MockHttpServletResponse response = new MockHttpServletResponse();
+            if (version == 1) {
+                controller.tencentImpression(impression, 0);
+                controller.tencentClick(click, response, 0);
+            } else {
+                TrackingController.class.getMethod("tencentImpression" + suffix,
+                        jakarta.servlet.http.HttpServletRequest.class).invoke(controller, impression);
+                TrackingController.class.getMethod("tencentClick" + suffix,
+                        jakarta.servlet.http.HttpServletRequest.class, jakarta.servlet.http.HttpServletResponse.class)
+                        .invoke(controller, click, response);
+            }
+            assertEquals("https://example.com/landing", response.getRedirectedUrl());
+        }
+        verify(client, times(12)).requestBid(anyString(), anyString(), any());
+        verify(client, times(12)).reportImpression(List.of("https://example.com/show"), 100L);
+        verify(client, times(6)).reportClick(clicks);
+        verify(client, never()).reportImpression(anyList(), anyLong(), anyBoolean());
+        verify(client, never()).reportClick(anyList(), anyBoolean());
+        verify(store, times(12)).recordBid(any());
+        verify(store, times(6)).shrinkAfterClick(any());
+        verify(store, never()).claimDeviceTracking(anyString(), anyString());
+    }
+
+    @Test void legacyCachedClickNeedsNoPlacementResolution() throws Exception {
+        MediaPlacement placement = new MediaPlacement(); // No app/platform config: resolve would throw.
+        controller = new TrackingController(client, store, placement,
+                JsonMapper.builder().findAndAddModules().build(), "test");
+        BidRecord cached = new BidRecord(); cached.setQk("old-qk");
+        cached.setClickUrls(List.of("https://example.com/one", "https://example.com/two"));
+        cached.setLandingPage("https://example.com/landing");
+        when(store.findBidByMediaTrace("tencent", "old-click")).thenReturn(cached);
+        MockHttpServletRequest req = new MockHttpServletRequest(); req.setParameter("click_id", "old-click");
+        MockHttpServletResponse response = new MockHttpServletResponse();
+        controller.tencentClick(req, response, 0);
+        assertEquals(cached.getLandingPage(), response.getRedirectedUrl());
+        verify(client).reportClick(cached.getClickUrls());
+        verify(client, never()).requestBid(anyString(), anyString(), any());
+        verify(store).shrinkAfterClick(cached);
+        verify(store, never()).recordBid(any());
+        verify(store, never()).claimDeviceTracking(anyString(), anyString());
+    }
+
+    @Test void otherPlacementRetainsExistingClickBehavior() throws Exception {
+        controller.tencentClick(request("other", "c1"), new MockHttpServletResponse(), 0);
+        verify(client).reportClick(anyList());
+        verify(store, never()).claimDeviceTracking(anyString(), anyString());
+    }
+}

+ 108 - 0
src/test/java/com/adx/tencent/httpapi/OtherMediaDeviceTrackingTest.java

@@ -0,0 +1,108 @@
+package com.adx.tencent.httpapi;
+
+import com.adx.tencent.baidu.AdxClient;
+import com.adx.tencent.baidu.model.*;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.json.JsonMapper;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.mockito.Answers;
+import java.lang.reflect.*;
+import java.util.*;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+class OtherMediaDeviceTrackingTest {
+    @ParameterizedTest
+    @CsvSource({"vivo,Vivo,jlt130", "vivo,Vivo,jlt11", "vivo,Vivo,other",
+            "honor,Honor,jlt130", "honor,Honor,jlt11", "honor,Honor,other",
+            "kuaishou,Kuaishou,jlt130", "kuaishou,Kuaishou,jlt11", "kuaishou,Kuaishou,other",
+            "oppo,Oppo,jlt130", "oppo,Oppo,jlt11", "oppo,Oppo,other"})
+    void entryPointsOnlyLimitTargetTagsAndKeepShrinkAndRedirect(String media, String name, String tag) throws Exception {
+        AdxClient client = mock(AdxClient.class);
+        List<String> shows = List.of("https://example.com/show1", "https://example.com/show2");
+        List<String> clicks = List.of("https://example.com/click1", "https://example.com/click2");
+        NormalizedBid bid = new NormalizedBid(); bid.setTagId(tag); bid.setPrice(123L);
+        bid.setShowUrls(shows); bid.setClickUrls(clicks); bid.setLandingPage("https://example.com/landing");
+        NormalizedBidResponse result = new NormalizedBidResponse(); result.setQk("qk"); result.setBid(bid);
+        when(client.requestBid(anyString(), anyString(), any())).thenReturn(result);
+        Set<String> flags = new HashSet<>();
+        Class<?> storeType = Class.forName("com.adx.tencent." + media + ".store." + name + "HotStore");
+        Object store = mock(storeType, invocation -> {
+            if (invocation.getMethod().getName().equals("claimDeviceTracking")) {
+                assertTrue(invocation.<String>getArgument(0).startsWith(media + ":" + tag + ":"));
+                return flags.add(invocation.getArgument(0) + ":" + invocation.getArgument(1));
+            }
+            return Answers.RETURNS_DEFAULTS.answer(invocation);
+        });
+        Class<?> type = Class.forName("com.adx.tencent." + media + ".controller." + name + "TrackingController");
+        Constructor<?> constructor = type.getConstructors()[0];
+        Object[] args = new Object[constructor.getParameterCount()];
+        Class<?>[] types = constructor.getParameterTypes();
+        for (int i = 0; i < args.length; i++) {
+            if (types[i] == AdxClient.class) args[i] = client;
+            else if (types[i] == storeType) args[i] = store;
+            else if (types[i] == ObjectMapper.class) args[i] = JsonMapper.builder().findAndAddModules().build();
+            else if (types[i].getSimpleName().endsWith("Placement") || types[i].getSimpleName().equals("AppProperties")) {
+                args[i] = types[i].getConstructor().newInstance();
+                types[i].getMethod("setBaiduAppId", String.class).invoke(args[i], "app");
+                types[i].getMethod("setBaiduTagId", String.class).invoke(args[i], tag);
+            }
+        }
+        Object controller = constructor.newInstance(args);
+        // Click first exercises the supplemental impression, followed by normal impression and duplicate click.
+        for (int n = 0; n < 3; n++) {
+            MockHttpServletRequest req = new MockHttpServletRequest();
+            req.setParameter("tagId", tag); req.setParameter("tag_id", tag); req.setParameter("baidu_tag_id", tag);
+            req.setParameter("oaid", "device-A"); req.setParameter("accountId", "account");
+            req.setParameter("eventSourceId", "source"); req.setParameter("clickId", "trace" + n);
+            req.setParameter("trackId", "trace" + n); req.setParameter("request_id", "trace" + n);
+            req.setParameter("req", "trace" + n);
+            if (n == 1) {
+                type.getMethod("impression", HttpServletRequest.class).invoke(controller, req);
+            } else {
+                MockHttpServletResponse response = new MockHttpServletResponse();
+                type.getMethod("click", HttpServletRequest.class, HttpServletResponse.class).invoke(controller, req, response);
+                assertEquals("https://example.com/landing", response.getRedirectedUrl());
+            }
+        }
+        verify(client, times(3)).requestBid(eq(media), anyString(), any());
+        assertEquals(3, mockingDetails(store).getInvocations().stream().filter(i -> i.getMethod().getName().equals("recordBid")).count());
+        assertEquals(2, mockingDetails(store).getInvocations().stream().filter(i -> i.getMethod().getName().equals("shrinkAfterClick")).count());
+        if (tag.equals("other")) {
+            verify(client, times(3)).reportImpression(shows, 123L);
+            verify(client, times(2)).reportClick(clicks);
+            verify(client, never()).reportImpression(anyList(), anyLong(), anyBoolean());
+            verify(client, never()).reportClick(anyList(), anyBoolean());
+            assertTrue(flags.isEmpty());
+        } else {
+            verify(client).reportImpression(shows, 123L, false);
+            verify(client).reportClick(List.of(clicks.get(0)), false);
+            verify(client, never()).reportImpression(anyList(), anyLong());
+            verify(client, never()).reportClick(anyList());
+            assertEquals(2, flags.size());
+        }
+        // An existing hot-cache hit still skips bidding, uses the same guard and performs normal shrink.
+        Object saved = mockingDetails(store).getInvocations().stream()
+                .filter(i -> i.getMethod().getName().equals("recordBid")).findFirst().orElseThrow().getArgument(0);
+        Object cached = saved.getClass().getMethod("toHotRecord").invoke(saved);
+        storeType.getMethod("findBidByMediaTrace", String.class).invoke(doReturn(cached).when(store), "cached");
+        MockHttpServletRequest req = new MockHttpServletRequest();
+        req.setParameter("tagId", tag); req.setParameter("tag_id", tag);
+        req.setParameter("oaid", "device-A"); req.setParameter("accountId", "account");
+        req.setParameter("eventSourceId", "source"); req.setParameter("clickId", "cached");
+        req.setParameter("trackId", "cached"); req.setParameter("request_id", "cached"); req.setParameter("req", "cached");
+        MockHttpServletResponse response = new MockHttpServletResponse();
+        type.getMethod("click", HttpServletRequest.class, HttpServletResponse.class).invoke(controller, req, response);
+        assertEquals("https://example.com/landing", response.getRedirectedUrl());
+        verify(client, times(3)).requestBid(eq(media), anyString(), any());
+        assertEquals(3, mockingDetails(store).getInvocations().stream().filter(i -> i.getMethod().getName().equals("shrinkAfterClick")).count());
+        if (tag.equals("other")) verify(client, times(3)).reportClick(clicks);
+        else verify(client, times(1)).reportClick(List.of(clicks.get(0)), false);
+    }
+}

+ 25 - 0
src/test/java/com/adx/tencent/storage/DeviceTrackingStoreTest.java

@@ -0,0 +1,25 @@
+package com.adx.tencent.storage;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.Test;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.data.redis.core.ValueOperations;
+import java.time.Duration;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+class DeviceTrackingStoreTest {
+    @Test void atomicClaimUsesExactly24HoursAndDoesNotRefreshExistingKey() {
+        StringRedisTemplate redis = mock(StringRedisTemplate.class);
+        @SuppressWarnings("unchecked") ValueOperations<String, String> values = mock(ValueOperations.class);
+        when(redis.opsForValue()).thenReturn(values);
+        String key = "adx:device-tracking:tencent:jlt130:oaid:hash:click";
+        when(values.setIfAbsent(key, "1", Duration.ofHours(24))).thenReturn(true, false, null);
+        RedisHotStore store = new RedisHotStore(redis, new ObjectMapper(), null, null, null, null, 100);
+        assertTrue(store.claimDeviceTracking("tencent:jlt130:oaid:hash", "click"));
+        assertFalse(store.claimDeviceTracking("tencent:jlt130:oaid:hash", "click"));
+        assertFalse(store.claimDeviceTracking("tencent:jlt130:oaid:hash", "click"));
+        verify(values, times(3)).setIfAbsent(key, "1", Duration.ofHours(24));
+        verifyNoMoreInteractions(values);
+    }
+}

+ 40 - 0
src/test/java/com/adx/tencent/storage/OtherMediaDeviceTrackingStoreTest.java

@@ -0,0 +1,40 @@
+package com.adx.tencent.storage;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.data.redis.core.ValueOperations;
+import java.time.Duration;
+import java.lang.reflect.Constructor;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+class OtherMediaDeviceTrackingStoreTest {
+    @ParameterizedTest
+    @CsvSource({"vivo,Vivo", "honor,Honor", "kuaishou,Kuaishou", "oppo,Oppo"})
+    void eachMediaUsesAtomic24HourFlagsWithoutRefreshing(String media, String name) throws Exception {
+        StringRedisTemplate redis = mock(StringRedisTemplate.class);
+        @SuppressWarnings("unchecked") ValueOperations<String, String> values = mock(ValueOperations.class);
+        when(redis.opsForValue()).thenReturn(values);
+        Class<?> type = Class.forName("com.adx.tencent." + media + ".store." + name + "HotStore");
+        Constructor<?> constructor = type.getConstructors()[0];
+        Object[] args = new Object[constructor.getParameterCount()];
+        Class<?>[] types = constructor.getParameterTypes();
+        for (int i = 0; i < args.length; i++) {
+            if (types[i] == StringRedisTemplate.class) args[i] = redis;
+            else if (types[i] == ObjectMapper.class) args[i] = new ObjectMapper();
+            else if (types[i] == String.class) args[i] = "adx:" + media + ":";
+            else if (types[i] == Duration.class) args[i] = Duration.ofHours(24);
+            else if (types[i] == long.class) args[i] = 100L;
+        }
+        Object store = constructor.newInstance(args);
+        String scope = media + ":jlt130:oaid:hash";
+        String key = "adx:" + media + ":device-tracking:" + scope + ":click";
+        when(values.setIfAbsent(key, "1", Duration.ofHours(24))).thenReturn(true, false);
+        assertEquals(true, type.getMethod("claimDeviceTracking", String.class, String.class).invoke(store, scope, "click"));
+        assertEquals(false, type.getMethod("claimDeviceTracking", String.class, String.class).invoke(store, scope, "click"));
+        verify(values, times(2)).setIfAbsent(key, "1", Duration.ofHours(24));
+        verifyNoMoreInteractions(values);
+    }
+}