yumeng před 6 dny
rodič
revize
d0bd2001d7
30 změnil soubory, kde provedl 2196 přidání a 21 odebrání
  1. 19 0
      README.md
  2. 87 4
      src/main/java/com/adx/tencent/AppConfiguration.java
  3. 48 0
      src/main/java/com/adx/tencent/config/AppProperties.java
  4. 163 9
      src/main/java/com/adx/tencent/conversionsync/ConversionSyncService.java
  5. 3 1
      src/main/java/com/adx/tencent/httpapi/AdminController.java
  6. 144 0
      src/main/java/com/adx/tencent/oppo/client/OppoClient.java
  7. 340 0
      src/main/java/com/adx/tencent/oppo/controller/OppoTrackingController.java
  8. 64 0
      src/main/java/com/adx/tencent/oppo/model/OppoBidRecord.java
  9. 8 0
      src/main/java/com/adx/tencent/oppo/model/OppoMediaCallbackRecord.java
  10. 5 0
      src/main/java/com/adx/tencent/oppo/model/OppoTagEventRecord.java
  11. 6 0
      src/main/java/com/adx/tencent/oppo/model/OppoTokenRecord.java
  12. 9 0
      src/main/java/com/adx/tencent/oppo/model/OppoTrackingRecord.java
  13. 22 0
      src/main/java/com/adx/tencent/oppo/service/OppoAuthService.java
  14. 22 0
      src/main/java/com/adx/tencent/oppo/service/OppoBackgroundTasks.java
  15. 17 0
      src/main/java/com/adx/tencent/oppo/service/OppoColdWorker.java
  16. 15 0
      src/main/java/com/adx/tencent/oppo/service/OppoRetryService.java
  17. 10 0
      src/main/java/com/adx/tencent/oppo/service/OppoRetryTask.java
  18. 12 0
      src/main/java/com/adx/tencent/oppo/service/OppoTagEventResolver.java
  19. 19 0
      src/main/java/com/adx/tencent/oppo/service/OppoTagEventSyncService.java
  20. 28 0
      src/main/java/com/adx/tencent/oppo/service/OppoTrackingUrlBuilder.java
  21. 84 0
      src/main/java/com/adx/tencent/oppo/store/OppoColdStore.java
  22. 55 0
      src/main/java/com/adx/tencent/oppo/store/OppoHotStore.java
  23. 19 6
      src/main/java/com/adx/tencent/report/AdBidReportStore.java
  24. 2 1
      src/main/java/com/adx/tencent/rta/BaiduRtaService.java
  25. 16 0
      src/main/resources/application.yml
  26. 79 0
      src/main/resources/schema-oppo.sql
  27. 38 0
      src/test/java/com/adx/tencent/oppo/client/OppoClientTest.java
  28. 134 0
      src/test/java/com/adx/tencent/oppo/store/OppoHotStoreTest.java
  29. 621 0
      tmp/pdfs/oppo-callback.txt
  30. 107 0
      tmp/pdfs/oppo-tracking.txt

+ 19 - 0
README.md

@@ -0,0 +1,19 @@
+# OPPO 渠道接入说明
+
+OPPO APP 数据回传协议使用 `POST https://api.ads.heytapmobi.com/api/uploadActiveData`。
+配置 `adx.oppo-conversion-salt`、`adx.oppo-aes-base64-key` 和可选的
+`adx.oppo-owner-token` 后,通过 `OppoClient` 发送回传:`imei`、`mac`、`ouId`
+和 `androidId` 会按 OPPO v3.23 的 AES/ECB/PKCS5Padding + Base64 规则加密,
+签名严格使用发送的原始 JSON:`md5(postData + timestamp + salt)`。
+
+监测链接必须由 HTTPS 正式域名提供,使用 OPPO 新版宏格式 `__REQ__`、
+`__OAID__`、`__TS__`、`__OWNERID__`。扣量比例通过 `deduction_rate` 配置项
+传入,取值范围为 0-100;实际扣量判定沿用现有渠道的 Redis 原子计数语义。
+
+OPPO Redis 缓存与其他渠道采用相同生命周期:曝光写入的 qk、mediaTrace
+两份缓存 TTL 为 3 小时,内容仅保留点击及转化回传需要的字段。点击处理后,
+两份缓存一起移除点击 URL 和跳转链接,并按 `adx.bid-ttl` 重新计时
+(默认及当前 prod 配置为 36 小时)。瘦身不会修改当前请求用于跳转的对象。
+Stream 仍保存完整事件供后台异步落库,成功后 ACK 并删除消息。
+点击按 mediaTrace 查缓存未命中时重新请求广告;转化按 qk 查找仍支持 DB 回查。
+已有 Redis key 不会因部署自动缩短 TTL,会在后续写入、点击或自然过期时更新。

+ 87 - 4
src/main/java/com/adx/tencent/AppConfiguration.java

@@ -45,6 +45,15 @@ import com.adx.tencent.vivo.service.VivoTagEventResolver;
 import com.adx.tencent.vivo.service.VivoTagEventSyncService;
 import com.adx.tencent.vivo.store.VivoColdStore;
 import com.adx.tencent.vivo.store.VivoHotStore;
+import com.adx.tencent.oppo.client.OppoClient;
+import com.adx.tencent.oppo.store.OppoColdStore;
+import com.adx.tencent.oppo.store.OppoHotStore;
+import com.adx.tencent.oppo.service.OppoTagEventResolver;
+import com.adx.tencent.oppo.service.OppoAuthService;
+import com.adx.tencent.oppo.service.OppoRetryService;
+import com.adx.tencent.oppo.service.OppoRetryTask;
+import com.adx.tencent.oppo.service.OppoTagEventSyncService;
+import com.adx.tencent.oppo.service.OppoColdWorker;
 import com.adx.tencent.storage.RedisHotStore;
 import com.adx.tencent.storage.RedisLock;
 import com.adx.tencent.storage.TiDBColdStore;
@@ -153,6 +162,75 @@ public class AppConfiguration {
     }
 
     @Bean
+    public OppoClient oppoClient(ObjectMapper objectMapper, @Nullable OppoAuthService authService) {
+        if (authService == null || props.getOppoApiId() == null || props.getOppoApiId().isBlank()
+                || props.getOppoApiKey() == null || props.getOppoApiKey().isBlank()) {
+            return new OppoClient(props.getOppoConversionEndpoint(), props.getOppoConversionSalt(),
+                    props.getOppoAesBase64Key(), props.getOppoOwnerToken(), objectMapper);
+        }
+        return new OppoClient(props.getOppoConversionEndpoint(), props.getOppoConversionSalt(),
+                props.getOppoAesBase64Key(), ownerId -> authService.getOwnerToken(
+                        ownerId, props.getOppoApiId(), props.getOppoApiKey()), objectMapper);
+    }
+
+    @Bean
+    public OppoAuthService oppoAuthService(@Nullable OppoColdStore coldStore) {
+        if (coldStore == null) return null;
+        return new OppoAuthService(coldStore);
+    }
+
+    @Bean
+    public OppoRetryService oppoRetryService(@Nullable OppoColdStore coldStore, OppoClient client,
+                                             ObjectMapper mapper) {
+        return coldStore == null ? null : new OppoRetryService(coldStore, client, mapper);
+    }
+
+    @Bean
+    public OppoRetryTask oppoRetryTask(@Nullable OppoRetryService retryService) {
+        return retryService == null ? null : new OppoRetryTask(retryService, props);
+    }
+
+    @Bean
+    public OppoColdStore oppoColdStore(@Nullable DataSource tidbDataSource) {
+        if (tidbDataSource == null) return null;
+        try {
+            OppoColdStore store = new OppoColdStore(tidbDataSource);
+            store.migrate();
+            log.info("OPPO cold store initialized and migrated");
+            return store;
+        } catch (Exception e) {
+            log.warn("Failed to open OPPO cold store: {}", e.getMessage(), e);
+            return null;
+        }
+    }
+
+    @Bean
+    public OppoHotStore oppoHotStore(StringRedisTemplate redis, ObjectMapper objectMapper,
+                                     @Nullable OppoColdStore coldStore) {
+        if (coldStore == null) return null;
+        return new OppoHotStore(redis, objectMapper, coldStore, props.getOppoRedisStream(), props.getBidTtl(), props.getRedisStreamMaxLen());
+    }
+
+    @Bean
+    public OppoTagEventResolver oppoTagEventResolver(StringRedisTemplate redis,
+                                                     @Nullable OppoColdStore coldStore) {
+        if (coldStore == null) return null;
+        return new OppoTagEventResolver(redis, coldStore, props.getOppoTagEventSyncRedisPrefix());
+    }
+
+    @Bean
+    public OppoTagEventSyncService oppoTagEventSyncService(@Nullable OppoColdStore coldStore, StringRedisTemplate redis) {
+        if (coldStore == null) return null;
+        return new OppoTagEventSyncService(coldStore, redis, props.getOppoTagEventSyncRedisPrefix(), props.getOppoTagEventSyncInterval().multipliedBy(3));
+    }
+
+    @Bean
+    public OppoColdWorker oppoColdWorker(@Nullable OppoHotStore hotStore, @Nullable OppoColdStore coldStore, ObjectMapper mapper) {
+        if (hotStore == null || coldStore == null) return null;
+        return new OppoColdWorker(hotStore, coldStore, "oppo-cold-writers", props.getWorkerConsumer() + "-oppo", props.getWorkerBatch(), props.getRedisStreamMaxLen(), mapper);
+    }
+
+    @Bean
     public LeaderElection leaderElection(RedisLock redisLock) {
         return new LeaderElection(redisLock);
     }
@@ -560,20 +638,24 @@ public class AppConfiguration {
                                                          @Nullable HonorTagEventResolver honorTagEventResolver,
                                                          @Nullable KuaishouTagEventResolver kuaishouTagEventResolver,
                                                          @Nullable VivoTagEventResolver vivoTagEventResolver,
+                                                         @Nullable OppoTagEventResolver oppoTagEventResolver,
                                                          @Nullable HonorHotStore honorHotStore,
                                                          @Nullable HonorColdStore honorColdStore,
                                                          @Nullable KuaishouHotStore kuaishouHotStore,
                                                          @Nullable KuaishouColdStore kuaishouColdStore,
                                                          @Nullable VivoHotStore vivoHotStore,
                                                          @Nullable VivoColdStore vivoColdStore,
+                                                         @Nullable OppoHotStore oppoHotStore,
+                                                         @Nullable OppoColdStore oppoColdStore,
                                                          HonorClient honorClient,
                                                          KuaishouClient kuaishouClient,
-                                                         @Nullable VivoClient vivoClient) {
+                                                         @Nullable VivoClient vivoClient,
+                                                         @Nullable OppoClient oppoClient) {
         if (coldStore == null) return null;
         return new ConversionSyncService(baiduClient, hotStore, tencentClient, coldStore, tagEventResolver,
-                accountTagEventResolver, honorTagEventResolver, kuaishouTagEventResolver, vivoTagEventResolver,
+                accountTagEventResolver, honorTagEventResolver, kuaishouTagEventResolver, vivoTagEventResolver, oppoTagEventResolver,
                 honorHotStore, honorColdStore, kuaishouHotStore, kuaishouColdStore, vivoHotStore, vivoColdStore,
-                honorClient, kuaishouClient, vivoClient, props);
+                oppoHotStore, oppoColdStore, honorClient, kuaishouClient, vivoClient, oppoClient, props);
     }
 
     @Bean
@@ -667,7 +749,8 @@ public class AppConfiguration {
     }
 
     @Bean
-    public AdBidReportStore adBidReportStore(@Nullable DataSource tidbDataSource) {
+    public AdBidReportStore adBidReportStore(@Nullable DataSource tidbDataSource,
+                                             @Nullable OppoColdStore oppoColdStore) {
         if (tidbDataSource == null) return null;
         String mainSchema = resolveDatabaseName(tidbDataSource, props.getTidbUrl());
         if (mainSchema == null || mainSchema.isBlank()) return null;

+ 48 - 0
src/main/java/com/adx/tencent/config/AppProperties.java

@@ -90,6 +90,23 @@ public class AppProperties {
     private Duration vivoAdvertiserSyncInterval = Duration.ofMinutes(10);
     private String vivoAdvertiserRedisPrefix = "adx:vivo:advertiser:";
 
+    // OPPO APP Callback (v3.23)
+    private String oppoConversionEndpoint = "https://api.ads.heytapmobi.com/api/uploadActiveData";
+    private String oppoConversionSalt;
+    private String oppoAesBase64Key;
+    private String oppoOwnerToken;
+    private String oppoTrackingEndpoint;
+    private boolean oppoCallbackRetryEnabled = false;
+    private Duration oppoCallbackRetryInterval = Duration.ofSeconds(300);
+    private int oppoCallbackRetryLimit = 100;
+    private String oppoRedisStream = "adx:oppo:events";
+    private boolean oppoTagEventSyncEnabled = false;
+    private Duration oppoTagEventSyncInterval = Duration.ofMinutes(5);
+    private String oppoTagEventSyncRedisPrefix = "adx:oppo:tag-event:";
+    private String oppoOwnerId;
+    private String oppoApiId;
+    private String oppoApiKey;
+
     // Tencent API
     private String tencentApiBaseUrl = "https://api.e.qq.com/v3.0";
     private String tencentAccessToken;
@@ -688,6 +705,37 @@ public class AppProperties {
         this.vivoAdvertiserRedisPrefix = v;
     }
 
+    public String getOppoConversionEndpoint() { return oppoConversionEndpoint; }
+    public void setOppoConversionEndpoint(String v) { this.oppoConversionEndpoint = v; }
+    public String getOppoConversionSalt() { return oppoConversionSalt; }
+    public void setOppoConversionSalt(String v) { this.oppoConversionSalt = v; }
+    public String getOppoAesBase64Key() { return oppoAesBase64Key; }
+    public void setOppoAesBase64Key(String v) { this.oppoAesBase64Key = v; }
+    public String getOppoOwnerToken() { return oppoOwnerToken; }
+    public void setOppoOwnerToken(String v) { this.oppoOwnerToken = v; }
+    public String getOppoTrackingEndpoint() { return oppoTrackingEndpoint; }
+    public void setOppoTrackingEndpoint(String v) { this.oppoTrackingEndpoint = v; }
+    public boolean isOppoCallbackRetryEnabled() { return oppoCallbackRetryEnabled; }
+    public void setOppoCallbackRetryEnabled(boolean v) { this.oppoCallbackRetryEnabled = v; }
+    public Duration getOppoCallbackRetryInterval() { return oppoCallbackRetryInterval; }
+    public void setOppoCallbackRetryInterval(Duration v) { this.oppoCallbackRetryInterval = v; }
+    public int getOppoCallbackRetryLimit() { return oppoCallbackRetryLimit; }
+    public void setOppoCallbackRetryLimit(int v) { this.oppoCallbackRetryLimit = v; }
+    public String getOppoRedisStream() { return oppoRedisStream; }
+    public void setOppoRedisStream(String v) { this.oppoRedisStream = v; }
+    public boolean isOppoTagEventSyncEnabled() { return oppoTagEventSyncEnabled; }
+    public void setOppoTagEventSyncEnabled(boolean v) { this.oppoTagEventSyncEnabled = v; }
+    public Duration getOppoTagEventSyncInterval() { return oppoTagEventSyncInterval; }
+    public void setOppoTagEventSyncInterval(Duration v) { this.oppoTagEventSyncInterval = v; }
+    public String getOppoTagEventSyncRedisPrefix() { return oppoTagEventSyncRedisPrefix; }
+    public void setOppoTagEventSyncRedisPrefix(String v) { this.oppoTagEventSyncRedisPrefix = v; }
+    public String getOppoOwnerId() { return oppoOwnerId; }
+    public void setOppoOwnerId(String v) { this.oppoOwnerId = v; }
+    public String getOppoApiId() { return oppoApiId; }
+    public void setOppoApiId(String v) { this.oppoApiId = v; }
+    public String getOppoApiKey() { return oppoApiKey; }
+    public void setOppoApiKey(String v) { this.oppoApiKey = v; }
+
     public String getTencentApiBaseUrl() {
         return tencentApiBaseUrl;
     }

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 163 - 9
src/main/java/com/adx/tencent/conversionsync/ConversionSyncService.java


+ 3 - 1
src/main/java/com/adx/tencent/httpapi/AdminController.java

@@ -416,7 +416,9 @@ public class AdminController {
                 "adx:kuaishou:bid:*",
                 "adx:kuaishou:media:*",
                 "adx:vivo:bid:*",
-                "adx:vivo:media:*"
+                "adx:vivo:media:*",
+                "adx:oppo:bid:*",
+                "adx:oppo:media:*"
         );
     }
 

+ 144 - 0
src/main/java/com/adx/tencent/oppo/client/OppoClient.java

@@ -0,0 +1,144 @@
+package com.adx.tencent.oppo.client;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import javax.crypto.Cipher;
+import javax.crypto.spec.SecretKeySpec;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.time.Duration;
+import java.util.Base64;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.function.Supplier;
+import java.util.function.Function;
+
+/** OPPO 广告主 APP 数据回传客户端(v3.23)。 */
+public class OppoClient {
+    public static final String DEFAULT_ENDPOINT = "https://api.ads.heytapmobi.com/api/uploadActiveData";
+
+    private final String endpoint;
+    private final String salt;
+    private final String base64AesKey;
+    private final String ownerToken;
+    private final Supplier<String> ownerTokenSupplier;
+    private final Function<String, String> ownerTokenByOwnerSupplier;
+    private final ObjectMapper objectMapper;
+    private final HttpClient httpClient;
+
+    public OppoClient(String endpoint, String salt, String base64AesKey, String ownerToken,
+                      ObjectMapper objectMapper) {
+        this.endpoint = endpoint == null || endpoint.isBlank() ? DEFAULT_ENDPOINT : endpoint;
+        this.salt = salt == null ? "" : salt;
+        this.base64AesKey = base64AesKey;
+        this.ownerToken = ownerToken;
+        this.ownerTokenSupplier = null;
+        this.ownerTokenByOwnerSupplier = null;
+        this.objectMapper = objectMapper;
+        this.httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
+    }
+
+    public OppoClient(String endpoint, String salt, String base64AesKey,
+                      Supplier<String> ownerTokenSupplier, ObjectMapper objectMapper) {
+        this.endpoint = endpoint == null || endpoint.isBlank() ? DEFAULT_ENDPOINT : endpoint;
+        this.salt = salt == null ? "" : salt;
+        this.base64AesKey = base64AesKey;
+        this.ownerToken = null;
+        this.ownerTokenSupplier = ownerTokenSupplier;
+        this.ownerTokenByOwnerSupplier = null;
+        this.objectMapper = objectMapper;
+        this.httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
+    }
+
+    public OppoClient(String endpoint, String salt, String base64AesKey,
+                      Function<String, String> ownerTokenByOwnerSupplier,
+                      ObjectMapper objectMapper) {
+        this.endpoint = endpoint == null || endpoint.isBlank() ? DEFAULT_ENDPOINT : endpoint;
+        this.salt = salt == null ? "" : salt;
+        this.base64AesKey = base64AesKey;
+        this.ownerToken = null;
+        this.ownerTokenSupplier = null;
+        this.ownerTokenByOwnerSupplier = ownerTokenByOwnerSupplier;
+        this.objectMapper = objectMapper;
+        this.httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
+    }
+
+    /**
+     * Builds and sends one OPPO conversion record. The returned body is the exact
+     * JSON string used for both signing and POST, as required by OPPO.
+     */
+    public Result upload(Map<String, Object> source) throws Exception {
+        String ownerId = source == null || source.get("ownerId") == null ? null : String.valueOf(source.get("ownerId"));
+        return upload(source, ownerId);
+    }
+
+    public Result upload(Map<String, Object> source, String ownerId) throws Exception {
+        if (source == null) throw new IllegalArgumentException("OPPO upload body is empty");
+        Map<String, Object> body = new LinkedHashMap<>(source);
+        body.remove("ownerId");
+        encryptDeviceValue(body, "imei");
+        encryptDeviceValue(body, "mac");
+        encryptDeviceValue(body, "ouId");
+        encryptDeviceValue(body, "androidId");
+
+        long timestamp = numberValue(body.get("timestamp"), System.currentTimeMillis());
+        body.put("timestamp", timestamp);
+        String postData = objectMapper.writeValueAsString(body);
+        String signature = md5(postData + timestamp + salt);
+
+        HttpRequest.Builder request = HttpRequest.newBuilder()
+                .uri(URI.create(endpoint))
+                .header("Content-Type", "application/json")
+                .header("timestamp", String.valueOf(timestamp))
+                .header("signature", signature)
+                .POST(HttpRequest.BodyPublishers.ofString(postData, StandardCharsets.UTF_8))
+                .timeout(Duration.ofSeconds(15));
+        String currentOwnerToken = ownerTokenByOwnerSupplier != null && ownerId != null
+                ? ownerTokenByOwnerSupplier.apply(ownerId)
+                : (ownerTokenSupplier == null ? ownerToken : ownerTokenSupplier.get());
+        if (currentOwnerToken != null && !currentOwnerToken.isBlank()) request.header("ownerToken", currentOwnerToken);
+
+        HttpResponse<String> response = httpClient.send(request.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
+        JsonNode json = null;
+        try { json = objectMapper.readTree(response.body()); } catch (Exception ignored) { }
+        boolean ok = response.statusCode() >= 200 && response.statusCode() < 300
+                && json != null && json.path("ret").asInt(Integer.MIN_VALUE) == 0;
+        return new Result(postData, signature, timestamp, response.statusCode(), response.body(), ok);
+    }
+
+    public String encrypt(String value) throws Exception {
+        if (value == null || value.isBlank()) return value;
+        if (base64AesKey == null || base64AesKey.isBlank()) {
+            throw new IllegalStateException("OPPO AES base64 key is not configured");
+        }
+        Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
+        cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(Base64.getDecoder().decode(base64AesKey), "AES"));
+        return Base64.getEncoder().encodeToString(cipher.doFinal(value.getBytes(StandardCharsets.UTF_8)));
+    }
+
+    private void encryptDeviceValue(Map<String, Object> body, String key) throws Exception {
+        Object value = body.get(key);
+        if (value != null && !String.valueOf(value).isBlank()) body.put(key, encrypt(String.valueOf(value)));
+    }
+
+    private static long numberValue(Object value, long fallback) {
+        if (value instanceof Number number) return number.longValue();
+        if (value != null) try { return Long.parseLong(String.valueOf(value)); } catch (NumberFormatException ignored) { }
+        return fallback;
+    }
+
+    public static String md5(String value) throws Exception {
+        byte[] digest = MessageDigest.getInstance("MD5").digest(value.getBytes(StandardCharsets.UTF_8));
+        StringBuilder result = new StringBuilder(32);
+        for (byte item : digest) result.append(String.format("%02x", item));
+        return result.toString();
+    }
+
+    public record Result(String requestBody, String signature, long timestamp, int status,
+                         String responseBody, boolean ok) { }
+}

+ 340 - 0
src/main/java/com/adx/tencent/oppo/controller/OppoTrackingController.java

@@ -0,0 +1,340 @@
+package com.adx.tencent.oppo.controller;
+
+import com.adx.tencent.baidu.AdxClient;
+import com.adx.tencent.baidu.model.NormalizedBidResponse;
+import com.adx.tencent.baidu.model.TrackingResult;
+import com.adx.tencent.config.AppProperties;
+import com.adx.tencent.oppo.model.OppoBidRecord;
+import com.adx.tencent.oppo.model.OppoTrackingRecord;
+import com.adx.tencent.oppo.store.OppoHotStore;
+import com.adx.tencent.rta.BaiduRtaDecision;
+import com.adx.tencent.rta.BaiduRtaService;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.springframework.http.HttpStatus;
+import org.springframework.lang.Nullable;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.server.ResponseStatusException;
+
+import java.time.Instant;
+import java.util.*;
+import java.util.concurrent.Semaphore;
+
+/**
+ * OPPO 监测流量入口:OPPO -> 本服务 -> 百度 ADX。
+ */
+@RestController
+public class OppoTrackingController {
+    private final AdxClient adx;
+    private final OppoHotStore store;
+    private final AppProperties props;
+    private final BaiduRtaService rtaService;
+    private final Semaphore impression = new Semaphore(50), click = new Semaphore(50);
+
+    public OppoTrackingController(AdxClient adx, @Nullable OppoHotStore store, AppProperties props,
+                                  @Nullable BaiduRtaService rtaService) {
+        this.adx = adx;
+        this.store = store;
+        this.props = props;
+        this.rtaService = rtaService;
+    }
+
+    @GetMapping("/oppo/impression")
+    public Object impression(HttpServletRequest request) throws Exception {
+        impression.acquire();
+        try {
+            return handleImpression(request);
+        } finally {
+            impression.release();
+        }
+    }
+
+    @GetMapping("/oppo/click")
+    public Object click(HttpServletRequest request, HttpServletResponse response) throws Exception {
+        click.acquire();
+        try {
+            return handleClick(request, response);
+        } finally {
+            click.release();
+        }
+    }
+
+    private Object handleImpression(HttpServletRequest request) throws Exception {
+        Map<String, String> p = params(request);
+        String trace = required(p, "req", "requestId");
+        ensureReady();
+        applyResolvedDefaults(p);
+        BaiduRtaDecision rtaDecision = evaluateRta(trace, p, request);
+        Map<String, String> rtaParams = enrichMediaParams(p, rtaDecision);
+        if (!rtaDecision.allowsBid()) {
+            OppoBidRecord blocked = blockedBidRecord(trace, "impression", rtaParams, rtaDecision);
+            store.recordBid(blocked);
+            return responseBody(rtaParams, blocked.getQk(), trace, blocked.getTagId(), rtaDecision);
+        }
+        NormalizedBidResponse response = adx.requestBid("oppo", trace, buildBidRequest(request, trace, rtaParams));
+        if (response == null) throw bad("empty baidu response");
+        OppoBidRecord record = record(response, trace, "impression", rtaParams);
+        record.setRtaCode(rtaDecision.getCode());
+        store.recordBid(record);
+        report(record, response.getBid() == null ? null : response.getBid().getShowUrls(), "impression");
+        return responseBody(rtaParams, record.getQk(), trace, record.getTagId(), rtaDecision);
+    }
+
+    private Object handleClick(HttpServletRequest request, HttpServletResponse response) throws Exception {
+        Map<String, String> p = params(request);
+        String trace = required(p, "req", "requestId");
+        ensureReady();
+        OppoBidRecord record = store.findBidByMediaTrace(trace);
+        BaiduRtaDecision rtaDecision = null;
+        if (record == null) {
+            applyResolvedDefaults(p);
+            rtaDecision = evaluateRta(trace, p, request);
+            Map<String, String> rtaParams = enrichMediaParams(p, rtaDecision);
+            if (!rtaDecision.allowsBid()) {
+                OppoBidRecord blocked = blockedBidRecord(trace, "click", rtaParams, rtaDecision);
+                store.recordBid(blocked);
+                return responseBody(rtaParams, blocked.getQk(), trace, blocked.getTagId(), rtaDecision);
+            }
+            NormalizedBidResponse bid = adx.requestBid("oppo", trace, buildBidRequest(request, trace, rtaParams));
+            if (bid == null) throw bad("empty baidu response");
+            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, record.getClickUrls(), "click");
+        try {
+            store.shrinkAfterClick(record);
+        } catch (Exception ignored) {
+            // 与其他渠道一致,缓存瘦身失败不阻断已完成上报的点击跳转。
+        }
+        String destination = record.getAppStoreLink();
+        if (destination == null || destination.isBlank()) destination = record.getLandingPage();
+        if (destination != null && !destination.isBlank()) {
+            response.sendRedirect(destination);
+            return null;
+        }
+        return responseBody(p, record.getQk(), trace, record.getTagId(), rtaDecision);
+    }
+
+    private void report(OppoBidRecord r, 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);
+            for (TrackingResult x : results) {
+                OppoTrackingRecord t = new OppoTrackingRecord();
+                t.setQk(r.getQk());
+                t.setTagId(r.getTagId());
+                t.setKind(kind);
+                t.setUrl(x.getUrl());
+                t.setStatus(x.getStatus());
+                t.setOk(x.isOk());
+                t.setCreatedAt(Instant.now());
+                if (store != null) store.recordTracking(t);
+            }
+        } catch (Exception ignored) {
+        }
+    }
+
+    private OppoBidRecord record(NormalizedBidResponse response, String trace, String kind, Map<String, String> p) {
+        OppoBidRecord r = new OppoBidRecord();
+        r.setQk(response.getQk());
+        r.setMediaTraceId(trace);
+        r.setPlatform(resolvePlatform(p));
+        r.setAccountId(first(p, "ownerId", "owner_id"));
+        r.setTagId(first(p, "tagId", "tag_id", "baidu_tag_id"));
+        r.setEventType(kind);
+        r.setMediaParams(p);
+        r.setCreatedAt(Instant.now());
+        if (response.getBid() != null) {
+            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());
+            r.setTagId(first(response.getBid().getTagId(), r.getTagId()));
+            if (response.getBid().getAdm() != null) r.setPackageName(response.getBid().getAdm().getPackageName());
+        }
+        r.setPackageName(firstValue(r.getPackageName(), p.get("pkg"), p.get("packageName")));
+        return r;
+    }
+
+    private BaiduRtaDecision evaluateRta(String trace, Map<String, String> p, HttpServletRequest request) {
+        if (rtaService == null) return BaiduRtaDecision.bypass();
+        return rtaService.evaluate(
+                "oppo",
+                resolveBaiduAppId(p),
+                resolveTagId(p),
+                resolvePlatform(p),
+                p,
+                firstValue(first(p, "ip", "clientIp", "client_ip"), clientIp(request, p)),
+                normalizeUserAgent(first(p, "ua", "user_agent", "useragent"), resolvePlatform(p)));
+    }
+
+    private OppoBidRecord blockedBidRecord(String trace, String kind, Map<String, String> p, BaiduRtaDecision decision) {
+        OppoBidRecord r = new OppoBidRecord();
+        r.setQk(firstValue(decision.getQk(), decision.getRequestId(), "oppo-rta-blocked-" + UUID.randomUUID().toString().replace("-", "")));
+        r.setMediaTraceId(trace);
+        r.setPlatform(resolvePlatform(p));
+        r.setAccountId(first(p, "ownerId", "owner_id"));
+        r.setTagId(value(resolveTagId(p)));
+        r.setEventType(kind);
+        r.setRtaCode(decision.getCode());
+        r.setPrice(0L);
+        r.setPackageName(first(p, "pkg", "packageName"));
+        r.setMediaParams(enrichMediaParams(p, decision));
+        r.setCreatedAt(Instant.now());
+        return r;
+    }
+
+    private static Map<String, String> enrichMediaParams(Map<String, String> params, BaiduRtaDecision decision) {
+        Map<String, String> enriched = new LinkedHashMap<>();
+        if (params != null) enriched.putAll(params);
+        if (decision == null || !decision.isApplied()) return enriched;
+        putIfNotBlank(enriched, "rta_tu", decision.getTu());
+        putIfNotBlank(enriched, "rta_order_id", firstValue(decision.getOrderIds(), decision.getRequestOrderIds()));
+        putIfNotBlank(enriched, "rta_request_order_id", decision.getRequestOrderIds());
+        putIfNotBlank(enriched, "rta_qk", decision.getQk());
+        putIfNotBlank(enriched, "rta_request_id", decision.getRequestId());
+        if (decision.getCode() != null) enriched.put("rta_code", String.valueOf(decision.getCode()));
+        return enriched;
+    }
+
+    private static Map<String, Object> responseBody(Map<String, String> params,
+                                                    String qk,
+                                                    String trace,
+                                                    String tagId,
+                                                    BaiduRtaDecision decision) {
+        Map<String, Object> body = new LinkedHashMap<>();
+        body.put("qk", value(qk));
+        body.put("req", trace);
+        body.put("tagId", value(tagId));
+        if ("true".equalsIgnoreCase(firstValue(params.get("_debugRta"), params.get("debugRta")))) {
+            body.put("rtaApplied", decision != null && decision.isApplied());
+            body.put("rtaMatched", decision == null ? null : decision.isMatched());
+            body.put("rtaCode", decision == null ? null : decision.getCode());
+            body.put("rtaQk", decision == null ? null : decision.getQk());
+            body.put("rtaRequestId", decision == null ? null : decision.getRequestId());
+            body.put("rtaRequestOrderId", decision == null ? null : decision.getRequestOrderIds());
+            body.put("rtaOrderId", decision == null ? null : decision.getOrderIds());
+            body.put("rtaError", decision == null ? null : decision.getErrorMessage());
+        }
+        return body;
+    }
+
+    private Map<String, Object> buildBidRequest(HttpServletRequest req, String trace, Map<String, String> p) {
+        String app = resolveBaiduAppId(p);
+        String tag = resolveTagId(p);
+        Map<String, Object> imp = new LinkedHashMap<>();
+        imp.put("id", "1");
+        imp.put("appId", app);
+        imp.put("tagId", tag);
+        imp.put("secure", 1);
+        imp.put("adType", 0);
+        imp.put("maxCount", 1);
+        imp.put("assets", List.of(Map.of("type", 1)));
+        Map<String, Object> device = new LinkedHashMap<>();
+        device.put("ua", firstValue(first(p, "ua", "user_agent"), req.getHeader("User-Agent")));
+        device.put("deviceType", 1);
+        device.put("ip", clientIp(req, p));
+        if (first(p, "oaid") != null) device.put("uid", Map.of("oaid", first(p, "oaid")));
+        Map<String, Object> body = new LinkedHashMap<>();
+        body.put("mediaId", props.getBaiduMediaId());
+        body.put("reqId", trace);
+        body.put("imp", List.of(imp));
+        body.put("device", device);
+        return body;
+    }
+
+    private static String clientIp(HttpServletRequest request, Map<String, String> params) {
+        String parameterIp = first(params, "ip", "clientIp", "client_ip");
+        if (parameterIp != null) return parameterIp;
+        String forwarded = request.getHeader("X-Forwarded-For");
+        if (forwarded != null && !forwarded.isBlank()) return forwarded.split(",", 2)[0].trim();
+        String real = request.getHeader("X-Real-IP");
+        return real == null || real.isBlank() ? request.getRemoteAddr() : real.trim();
+    }
+
+    private Map<String, String> params(HttpServletRequest r) {
+        Map<String, String> p = new LinkedHashMap<>();
+        r.getParameterMap().forEach((k, v) -> {
+            if (v != null && v.length > 0 && v[0] != null) p.put(k, v[0].trim());
+        });
+        return p;
+    }
+
+    private void applyResolvedDefaults(Map<String, String> p) {
+        p.put("platform", resolvePlatform(p));
+        String tag = resolveTagId(p);
+        if (tag != null) p.put("tagId", tag);
+        String app = resolveBaiduAppId(p);
+        if (app != null) p.put("baidu_app_id", app);
+    }
+
+    private String resolveBaiduAppId(Map<String, String> p) {
+        return firstValue(first(p, "baidu_app_id"), props.getBaiduAppId());
+    }
+
+    private String resolveTagId(Map<String, String> p) {
+        return firstValue(first(p, "tagId", "tag_id", "baidu_tag_id"), props.getBaiduTagId());
+    }
+
+    private static String resolvePlatform(Map<String, String> p) {
+        String platform = first(p, "platform", "os");
+        if (platform == null) return "android";
+        String normalized = platform.trim().toLowerCase(Locale.ROOT);
+        if ("ios".equals(normalized)) return "ios";
+        if ("harmony".equals(normalized) || "hongmeng".equals(normalized)) return "harmony";
+        return "android";
+    }
+
+    private void ensureReady() {
+        if (store == null)
+            throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "oppo store not configured");
+    }
+
+    private static String required(Map<String, String> p, String... keys) {
+        String v = first(p, keys);
+        if (v == null || v.isBlank())
+            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "missing OPPO request id");
+        return v;
+    }
+
+    private static String first(Map<String, String> p, String... k) {
+        for (String x : k) {
+            String v = p.get(x);
+            if (v != null && !v.isBlank()) return v;
+        }
+        return null;
+    }
+
+    private static String first(String a, String b) {
+        return a != null && !a.isBlank() ? a : b;
+    }
+
+    private static String firstValue(String... values) {
+        for (String v : values) if (v != null && !v.isBlank()) return v;
+        return null;
+    }
+
+    private static void putIfNotBlank(Map<String, String> params, String key, String value) {
+        if (value != null && !value.isBlank()) params.put(key, value);
+    }
+
+    private static String normalizeUserAgent(String ua, String platform) {
+        if (ua != null && !ua.isBlank()) return ua.trim();
+        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";
+        }
+        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 String value(String v) {
+        return v == null ? "" : v;
+    }
+
+    private static ResponseStatusException bad(String s) {
+        return new ResponseStatusException(HttpStatus.BAD_GATEWAY, s);
+    }
+}

+ 64 - 0
src/main/java/com/adx/tencent/oppo/model/OppoBidRecord.java

@@ -0,0 +1,64 @@
+package com.adx.tencent.oppo.model;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import java.time.Instant;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class OppoBidRecord {
+    private String qk, mediaTraceId, platform, accountId, eventType, tagId, packageName, landingPage, appStoreLink;
+    private Integer rtaCode;
+    private Long price;
+    private List<String> showUrls, clickUrls;
+    private Map<String, String> mediaParams;
+    private Instant createdAt;
+    public String getQk(){return qk;} public void setQk(String v){qk=v;}
+    public String getMedia(){return "oppo";}
+    public String getMediaTraceId(){return mediaTraceId;} public void setMediaTraceId(String v){mediaTraceId=v;}
+    public String getPlatform(){return platform;} public void setPlatform(String v){platform=v;}
+    public String getAccountId(){return accountId;} public void setAccountId(String v){accountId=v;}
+    public String getEventType(){return eventType;} public void setEventType(String v){eventType=v;}
+    public String getTagId(){return tagId;} public void setTagId(String v){tagId=v;}
+    public String getPackageName(){return packageName;} public void setPackageName(String v){packageName=v;}
+    public String getLandingPage(){return landingPage;} public void setLandingPage(String v){landingPage=v;}
+    public String getAppStoreLink(){return appStoreLink;} public void setAppStoreLink(String v){appStoreLink=v;}
+    public Integer getRtaCode(){return rtaCode;} public void setRtaCode(Integer v){rtaCode=v;}
+    public Long getPrice(){return price;} public void setPrice(Long v){price=v == null ? 0L : v;}
+    public List<String> getShowUrls(){return showUrls;} public void setShowUrls(List<String> v){showUrls=v;}
+    public List<String> getClickUrls(){return clickUrls;} public void setClickUrls(List<String> v){clickUrls=v;}
+    public Map<String,String> getMediaParams(){return mediaParams;} public void setMediaParams(Map<String,String> v){mediaParams=v;}
+    public Instant getCreatedAt(){return createdAt;} public void setCreatedAt(Instant v){createdAt=v;}
+    public java.sql.Timestamp getCreatedAtTs(){return createdAt == null ? null : java.sql.Timestamp.from(createdAt);}
+    /** 点击前保留上报与跳转所需字段,其他字段仅进入 Stream 落库。 */
+    public OppoBidRecord toHotRecord() {
+        OppoBidRecord hot = toPostClickRecord();
+        hot.clickUrls = clickUrls;
+        hot.landingPage = landingPage;
+        hot.appStoreLink = appStoreLink;
+        return hot;
+    }
+
+    /** 点击后仅保留转化匹配、授权和回传所需字段,不修改当前请求对象。 */
+    public OppoBidRecord toPostClickRecord() {
+        OppoBidRecord slim = new OppoBidRecord();
+        slim.qk = qk;
+        slim.mediaTraceId = mediaTraceId;
+        slim.platform = platform;
+        slim.accountId = accountId;
+        slim.tagId = tagId;
+        slim.rtaCode = rtaCode;
+        slim.packageName = packageName;
+        if (mediaParams != null) {
+            Map<String, String> params = new LinkedHashMap<>();
+            for (String key : List.of("oaid", "imei", "androidId", "mac", "ip", "adId", "deduction_rate",
+                    "rta_tu", "rta_order_id", "rta_request_order_id", "rta_qk", "rta_request_id", "rta_code")) {
+                String value = mediaParams.get(key);
+                if (value != null && !value.isBlank()) params.put(key, value);
+            }
+            slim.mediaParams = params.isEmpty() ? null : params;
+        }
+        return slim;
+    }
+}

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 8 - 0
src/main/java/com/adx/tencent/oppo/model/OppoMediaCallbackRecord.java


+ 5 - 0
src/main/java/com/adx/tencent/oppo/model/OppoTagEventRecord.java

@@ -0,0 +1,5 @@
+package com.adx.tencent.oppo.model;
+public class OppoTagEventRecord {
+    private String tagId; private int baiduAct, dataType;
+    public String getTagId(){return tagId;} public void setTagId(String v){tagId=v;} public int getBaiduAct(){return baiduAct;} public void setBaiduAct(int v){baiduAct=v;} public int getDataType(){return dataType;} public void setDataType(int v){dataType=v;}
+}

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 6 - 0
src/main/java/com/adx/tencent/oppo/model/OppoTokenRecord.java


+ 9 - 0
src/main/java/com/adx/tencent/oppo/model/OppoTrackingRecord.java

@@ -0,0 +1,9 @@
+package com.adx.tencent.oppo.model;
+import java.time.Instant;
+public class OppoTrackingRecord {
+    private String qk, tagId, kind, url; private int status; private boolean ok; private Instant createdAt;
+    public String getQk(){return qk;} public void setQk(String v){qk=v;} public String getTagId(){return tagId;} public void setTagId(String v){tagId=v;}
+    public String getKind(){return kind;} public void setKind(String v){kind=v;} public String getUrl(){return url;} public void setUrl(String v){url=v;}
+    public int getStatus(){return status;} public void setStatus(int v){status=v;} public boolean isOk(){return ok;} public void setOk(boolean v){ok=v;}
+    public Instant getCreatedAt(){return createdAt;} public void setCreatedAt(Instant v){createdAt=v;} public java.sql.Timestamp getCreatedAtTs(){return createdAt == null ? null : java.sql.Timestamp.from(createdAt);}
+}

+ 22 - 0
src/main/java/com/adx/tencent/oppo/service/OppoAuthService.java

@@ -0,0 +1,22 @@
+package com.adx.tencent.oppo.service;
+
+import com.adx.tencent.oppo.model.OppoTokenRecord;
+import com.adx.tencent.oppo.store.OppoColdStore;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.time.Instant;
+import java.util.Base64;
+
+/** OPPO ownerToken 生成与持久化;token 有效期按文档的 20 分钟窗口处理。 */
+public class OppoAuthService {
+    private final OppoColdStore store;
+    public OppoAuthService(OppoColdStore store){this.store=store;}
+    public synchronized String getOwnerToken(String ownerId,String apiId,String apiKey) {
+        if(ownerId==null||ownerId.isBlank()||apiId==null||apiId.isBlank()||apiKey==null||apiKey.isBlank()) throw new IllegalStateException("OPPO ownerId/apiId/apiKey are required");
+        long now=Instant.now().getEpochSecond(); OppoTokenRecord old=store.getToken(ownerId);
+        if(old!=null&&old.getOwnerToken()!=null&&old.getIssuedAt()!=null&&Math.abs(now-old.getIssuedAt())<900)return old.getOwnerToken();
+        String sign=sha1(apiId+apiKey+now); String token=Base64.getEncoder().encodeToString((ownerId+","+apiId+","+now+","+sign).getBytes(StandardCharsets.UTF_8));
+        OppoTokenRecord record=new OppoTokenRecord();record.setOwnerId(ownerId);record.setApiId(apiId);record.setApiKey(apiKey);record.setIssuedAt(now);record.setOwnerToken(token);record.setUpdatedAt(Instant.now());store.saveToken(record);return token;
+    }
+    private static String sha1(String value){try{byte[] d=MessageDigest.getInstance("SHA-1").digest(value.getBytes(StandardCharsets.UTF_8));StringBuilder s=new StringBuilder();for(byte b:d)s.append(String.format("%02x",b));return s.toString();}catch(Exception e){throw new IllegalStateException("OPPO token sign failed",e);}}
+}

+ 22 - 0
src/main/java/com/adx/tencent/oppo/service/OppoBackgroundTasks.java

@@ -0,0 +1,22 @@
+package com.adx.tencent.oppo.service;
+
+import com.adx.tencent.config.AppProperties;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.ApplicationArguments;
+import org.springframework.boot.ApplicationRunner;
+import org.springframework.lang.Nullable;
+import org.springframework.stereotype.Component;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+@Component
+public class OppoBackgroundTasks implements ApplicationRunner {
+    @Autowired private AppProperties props;
+    @Autowired(required=false) private OppoColdWorker worker;
+    @Autowired(required=false) private OppoTagEventSyncService tagSync;
+    private final AtomicBoolean stopped=new AtomicBoolean();
+    private final ExecutorService executor=Executors.newCachedThreadPool(r->{Thread t=new Thread(r);t.setDaemon(true);return t;});
+    public void run(ApplicationArguments args){if(worker!=null)executor.submit(()->{while(!stopped.get()){try{worker.runOnce();}catch(Exception e){try{Thread.sleep(1000);}catch(InterruptedException x){Thread.currentThread().interrupt();return;}}}});if(tagSync!=null&&props.isOppoTagEventSyncEnabled())executor.submit(()->{while(!stopped.get()){try{tagSync.syncOnce();}catch(Exception ignored){}sleep(props.getOppoTagEventSyncInterval().toMillis());}});}
+    private void sleep(long millis){try{Thread.sleep(Math.max(1000,millis));}catch(InterruptedException e){Thread.currentThread().interrupt();stopped.set(true);}}
+}

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 17 - 0
src/main/java/com/adx/tencent/oppo/service/OppoColdWorker.java


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 15 - 0
src/main/java/com/adx/tencent/oppo/service/OppoRetryService.java


+ 10 - 0
src/main/java/com/adx/tencent/oppo/service/OppoRetryTask.java

@@ -0,0 +1,10 @@
+package com.adx.tencent.oppo.service;
+import com.adx.tencent.config.AppProperties;
+import org.springframework.scheduling.annotation.Scheduled;
+
+public class OppoRetryTask {
+    private final OppoRetryService retry; private final AppProperties props;
+    public OppoRetryTask(OppoRetryService retry,AppProperties props){this.retry=retry;this.props=props;}
+    @Scheduled(fixedDelayString="${adx.oppo-callback-retry-interval:300s}")
+    public void run(){if(props.isOppoCallbackRetryEnabled())retry.retry(props.getOppoCallbackRetryLimit());}
+}

+ 12 - 0
src/main/java/com/adx/tencent/oppo/service/OppoTagEventResolver.java

@@ -0,0 +1,12 @@
+package com.adx.tencent.oppo.service;
+
+import com.adx.tencent.oppo.model.OppoTagEventRecord;
+import com.adx.tencent.oppo.store.OppoColdStore;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import java.util.List;
+
+public class OppoTagEventResolver {
+    private final StringRedisTemplate redis; private final OppoColdStore cold; private final String prefix;
+    public OppoTagEventResolver(StringRedisTemplate redis,OppoColdStore cold,String prefix){this.redis=redis;this.cold=cold;this.prefix=prefix==null?"adx:oppo:tag-event:":prefix;}
+    public Integer resolveDataType(String tagId,int act){if(tagId==null||tagId.isBlank())return null;String v=redis.opsForValue().get(prefix+tagId+":"+act);if(v==null)v=redis.opsForValue().get(prefix+tagId+":0");if(v!=null&&!v.isBlank())try{return Integer.valueOf(v.trim());}catch(NumberFormatException ignored){}return null;}
+}

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 19 - 0
src/main/java/com/adx/tencent/oppo/service/OppoTagEventSyncService.java


+ 28 - 0
src/main/java/com/adx/tencent/oppo/service/OppoTrackingUrlBuilder.java

@@ -0,0 +1,28 @@
+package com.adx.tencent.oppo.service;
+
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+
+/** 生成符合 OPPO 监测规范的曝光/点击链接。 */
+public final class OppoTrackingUrlBuilder {
+    private OppoTrackingUrlBuilder() { }
+
+    public static String build(String httpsEndpoint, boolean click, String reqId,
+                               String oaid, String ownerId, String packageName,
+                               String tagId,
+                               String deductionRate) {
+        if (httpsEndpoint == null || !httpsEndpoint.startsWith("https://")) {
+            throw new IllegalArgumentException("OPPO tracking endpoint must use https");
+        }
+        String separator = httpsEndpoint.contains("?") ? "&" : "?";
+        return httpsEndpoint + separator
+                + "req=__REQ__&imei=__IMEI__&oaid=__OAID__&ip=__IP__&ua=__UA__&ts=__TS__&ownerId=__OWNERID__"
+                + param("req_id", reqId) + param("oaid_value", oaid)
+                + param("owner_id", ownerId) + param("pkg", packageName)
+                + param("tagId", tagId) + param("deduction_rate", deductionRate);
+    }
+
+    private static String param(String name, String value) {
+        return value == null || value.isBlank() ? "" : "&" + name + "=" + URLEncoder.encode(value, StandardCharsets.UTF_8);
+    }
+}

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 84 - 0
src/main/java/com/adx/tencent/oppo/store/OppoColdStore.java


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 55 - 0
src/main/java/com/adx/tencent/oppo/store/OppoHotStore.java


+ 19 - 6
src/main/java/com/adx/tencent/report/AdBidReportStore.java

@@ -14,10 +14,14 @@ import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
+import java.util.EnumSet;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 public class AdBidReportStore {
 
     private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai");
+    private static final Logger log = LoggerFactory.getLogger(AdBidReportStore.class);
 
     private final DataSource dataSource;
     private final String mainSchema;
@@ -27,6 +31,7 @@ public class AdBidReportStore {
     private final int archiveBatchSize;
     private final int archiveMaxBatchesPerRun;
     private final int statsLookbackDays;
+    private final java.util.Set<MediaTable> availableMedia = EnumSet.allOf(MediaTable.class);
 
     public AdBidReportStore(DataSource dataSource,
                             String mainSchema,
@@ -52,11 +57,17 @@ public class AdBidReportStore {
                      "CREATE DATABASE IF NOT EXISTS `" + reportSchema + "` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci")) {
             createDb.execute();
             for (MediaTable media : MediaTable.values()) {
-                try (PreparedStatement ps = conn.prepareStatement(createDailyStatsDdl(media))) {
-                    ps.execute();
+                try {
+                    try (PreparedStatement ps = conn.prepareStatement(createDailyStatsDdl(media))) {
+                        ps.execute();
+                    }
+                    ensureDailyStatsColumns(conn, media);
+                    ensureTrackingHotIndexes(conn, media);
+                } catch (Exception e) {
+                    if (media != MediaTable.OPPO) throw e;
+                    availableMedia.remove(media);
+                    log.warn("OPPO report tables unavailable; existing media reports remain enabled: {}", e.getMessage());
                 }
-                ensureDailyStatsColumns(conn, media);
-                ensureTrackingHotIndexes(conn, media);
             }
         } catch (Exception e) {
             throw new RuntimeException("ad bid report migrate failed", e);
@@ -73,7 +84,7 @@ public class AdBidReportStore {
         Timestamp trackingCutoff = Timestamp.from(Instant.now().minusSeconds(trackingRetentionDays * 24L * 3600L));
         try (Connection conn = dataSource.getConnection()) {
             conn.setAutoCommit(false);
-            for (MediaTable media : MediaTable.values()) {
+            for (MediaTable media : availableMedia) {
                 summary.archivedRows += archiveMedia(conn, media, adBidCutoff);
                 summary.archivedTrackingRows += archiveTrackingMedia(conn, media, trackingCutoff);
                 if (includeStats) {
@@ -512,7 +523,9 @@ public class AdBidReportStore {
         KUAISHOU("kuaishou_ad_bid_events", "kuaishou_ad_bid_events_history_", "kuaishou_ad_bid_events_daily_stats",
                 "kuaishou_tracking_reports", "kuaishou_tracking_reports_history_", true, "快手"),
         VIVO("vivo_ad_bid_events", "vivo_ad_bid_events_history_", "vivo_ad_bid_events_daily_stats",
-                "vivo_tracking_reports", "vivo_tracking_reports_history_", true, "vivo");
+                "vivo_tracking_reports", "vivo_tracking_reports_history_", true, "vivo"),
+        OPPO("oppo_ad_bid_events", "oppo_ad_bid_events_history_", "oppo_ad_bid_events_daily_stats",
+                "oppo_tracking_reports", "oppo_tracking_reports_history_", true, "OPPO");
 
         private final String hotTable;
         private final String historyPrefix;

+ 2 - 1
src/main/java/com/adx/tencent/rta/BaiduRtaService.java

@@ -180,7 +180,7 @@ public class BaiduRtaService {
     }
 
     private static boolean supportsMedia(String media) {
-        return "honor".equals(media) || "vivo".equals(media);
+        return "honor".equals(media) || "vivo".equals(media) || "oppo".equals(media);
     }
 
     private String buildTu(String tagId) {
@@ -285,6 +285,7 @@ public class BaiduRtaService {
         return switch (media) {
             case "honor" -> "荣耀";
             case "vivo" -> "vivo";
+            case "oppo" -> "OPPO";
             default -> media == null ? "未知" : media;
         };
     }

+ 16 - 0
src/main/resources/application.yml

@@ -16,6 +16,22 @@ server:
   port: 8788
 
 adx:
+  # --- OPPO APP Callback v3.23 ---
+  oppo-conversion-endpoint: "https://api.ads.heytapmobi.com/api/uploadActiveData"
+  oppo-conversion-salt: e0u6fnlag06lc3pl
+  oppo-aes-base64-key: XGAXicVG5GMBsx5bueOe4w==
+  oppo-owner-token:
+  oppo-owner-id:
+  oppo-api-id: 9fa7410e38b54173a6d422978c45fda1
+  oppo-api-key: 65e8843129014428881bdaad0bd336d0
+  oppo-tracking-endpoint: "https://tencent.bjhcstszkj.cn"
+  oppo-callback-retry-enabled: false
+  oppo-callback-retry-interval: 300s
+  oppo-callback-retry-limit: 100
+  oppo-redis-stream: "adx:oppo:events"
+  oppo-tag-event-sync-enabled: false
+  oppo-tag-event-sync-interval: 300s
+  oppo-tag-event-sync-redis-prefix: "adx:oppo:tag-event:"
   # --- Baidu ADX ---
   baidu-auction-price-encryption: aes-128-cbc
   baidu-auction-price-e-key: 192c728294891ef6

+ 79 - 0
src/main/resources/schema-oppo.sql

@@ -0,0 +1,79 @@
+-- OPPO channel tables. They are created in the configured TiDB/MySQL database.
+-- Report tables are created by AdBidReportStore in the configured report schema.
+
+CREATE TABLE IF NOT EXISTS oppo_ad_bid_events (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    qk VARCHAR(128) NOT NULL COMMENT '唯一业务键(query key)',
+    media VARCHAR(64) NOT NULL DEFAULT 'oppo' COMMENT '媒体来源(oppo)',
+    media_trace_id VARCHAR(255) COMMENT 'OPPO广告请求ID/追踪ID',
+    platform VARCHAR(16) COMMENT '平台(android/ios)',
+    account_id VARCHAR(128) COMMENT 'OPPO广告主ownerId',
+    event_type VARCHAR(32) COMMENT '事件类型(impression/click)',
+    rta_code INT COMMENT 'RTA状态码',
+    tag_id VARCHAR(128) NOT NULL COMMENT '百度广告位ID',
+    price BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '竞价价格(分)',
+    package_name VARCHAR(255) COMMENT '应用包名',
+    media_params JSON COMMENT 'OPPO监测参数及扣量配置',
+    created_at TIMESTAMP(3) NOT NULL COMMENT '创建时间',
+    UNIQUE KEY uk_oppo_ad_bid_events_qk (qk),
+    KEY idx_oppo_ad_bid_events_media_trace (media, media_trace_id),
+    KEY idx_oppo_ad_bid_events_platform_tag (platform, tag_id),
+    KEY idx_oppo_ad_bid_events_account_created_at (account_id, created_at),
+    KEY idx_oppo_ad_bid_events_created_at (created_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OPPO竞价事件记录';
+
+CREATE TABLE IF NOT EXISTS oppo_tracking_reports (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    qk VARCHAR(128) COMMENT '关联业务键',
+    tag_id VARCHAR(128) NOT NULL COMMENT '百度广告位ID',
+    kind VARCHAR(32) NOT NULL COMMENT '类型(impression/click)',
+    tracking_url TEXT NOT NULL COMMENT '上报的目标URL',
+    status INT NOT NULL COMMENT 'HTTP响应状态码',
+    ok TINYINT(1) NOT NULL COMMENT '是否成功',
+    created_at TIMESTAMP(3) NOT NULL COMMENT '创建时间',
+    KEY idx_oppo_tracking_reports_qk_kind_created_at (qk, kind, created_at),
+    KEY idx_oppo_tracking_reports_tag_created_at (tag_id, created_at),
+    KEY idx_oppo_tracking_reports_created_at_id (created_at, id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OPPO曝光/点击上报结果';
+
+CREATE TABLE IF NOT EXISTS oppo_media_callbacks (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    dedupe_key VARCHAR(255) NOT NULL COMMENT '去重键',
+    qk VARCHAR(128) COMMENT '关联业务键',
+    tag_id VARCHAR(128) NOT NULL COMMENT '百度广告位ID',
+    owner_id VARCHAR(128) COMMENT 'OPPO广告主ownerId,用于重试授权',
+    callback_url TEXT NOT NULL COMMENT 'OPPO回传接口地址',
+    event_type INT NOT NULL COMMENT 'OPPO dataType',
+    event_time_ms BIGINT NOT NULL COMMENT '事件时间(毫秒)',
+    purchase DOUBLE COMMENT '付费金额',
+    deduction_rate INT NOT NULL DEFAULT 0 COMMENT '扣量比例(0-100)',
+    is_direct_match TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否直归因',
+    dispatch_status VARCHAR(32) NOT NULL DEFAULT 'SENT' COMMENT 'SENT/DEDUCTED',
+    status INT NOT NULL COMMENT 'HTTP响应状态码',
+    ok TINYINT(1) NOT NULL COMMENT '是否成功',
+    attempt INT NOT NULL DEFAULT 1 COMMENT '尝试次数',
+    response_body TEXT COMMENT '响应体',
+    error_message TEXT COMMENT '错误信息',
+    request_body TEXT COMMENT '请求体快照',
+    created_at TIMESTAMP(3) NOT NULL COMMENT '创建时间',
+    KEY idx_oppo_media_callbacks_dedupe_ok_created_at (dedupe_key, ok, created_at),
+    KEY idx_oppo_media_callbacks_qk_created_at (qk, created_at),
+    KEY idx_oppo_media_callbacks_tag_created_at (tag_id, created_at),
+    KEY idx_oppo_media_callbacks_event_created_at (event_type, created_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OPPO转化回传记录';
+
+CREATE TABLE IF NOT EXISTS oppo_tag_event (
+    tag_id VARCHAR(128) NOT NULL COMMENT '百度广告位ID',
+    baidu_act INT NOT NULL COMMENT '百度转化行为',
+    data_type INT NOT NULL COMMENT 'OPPO dataType',
+    PRIMARY KEY (tag_id, baidu_act, data_type)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OPPO广告位转化事件映射';
+
+CREATE TABLE IF NOT EXISTS oppo_owner_tokens (
+    owner_id VARCHAR(128) NOT NULL PRIMARY KEY COMMENT 'OPPO广告主owner_id',
+    api_id VARCHAR(128) NOT NULL COMMENT 'OPPO Omni API id',
+    api_key VARCHAR(512) NOT NULL COMMENT 'OPPO Omni API key',
+    owner_token TEXT NOT NULL COMMENT '最近生成的ownerToken',
+    issued_at BIGINT NOT NULL COMMENT 'token生成时间(秒)',
+    updated_at TIMESTAMP(3) NOT NULL COMMENT '更新时间'
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OPPO ownerToken授权缓存';

+ 38 - 0
src/test/java/com/adx/tencent/oppo/client/OppoClientTest.java

@@ -0,0 +1,38 @@
+package com.adx.tencent.oppo.client;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class OppoClientTest {
+    private static final String KEY = "XGAXicVG5GMBsx5bueOe4w==";
+
+    @Test
+    void calculatesDocumentedSignature() throws Exception {
+        String body = "{\"payAmount\":100,\"adId\":101097648,\"appType\":1,\"clientIp\":\"127.0.0.1\",\"dataType\":1,\"ascribeType\":1,\"channel\":1,\"imei\":\"XJMyaLt8fDlv4a9b8/0RNQ==\",\"type\":1,\"pkg\":\"com.oppo.test\",\"mac\":\"TEViR6jSgD/lECBl3Ah70eNy2gUQrQlekHkWqEGkZsU=\",\"timestamp\":1571995483916}";
+        assertEquals("ce14fcc22abd7461e860263a8da983eb",
+                OppoClient.md5(body + "1571995483916" + "e0u6fnlag06lc3pl"));
+    }
+
+    @Test
+    void encryptsDeviceValuesWithDocumentedAes() throws Exception {
+        OppoClient client = new OppoClient(null, "", KEY, (String) null, new com.fasterxml.jackson.databind.ObjectMapper());
+        assertEquals("XJMyaLt8fDlv4a9b8/0RNQ==", client.encrypt("868123039927020"));
+    }
+
+    @Test
+    void exposesStableRequestFields() {
+        assertEquals("https://api.ads.heytapmobi.com/api/uploadActiveData", OppoClient.DEFAULT_ENDPOINT);
+        Map<String, Object> body = Map.of("dataType", 1);
+        assertEquals(1, body.get("dataType"));
+    }
+
+    @Test
+    void tokenPayloadUsesDocumentedShape() {
+        String payload = "owner,api,123,signature";
+        assertTrue(java.util.Base64.getEncoder().encodeToString(payload.getBytes()).length() > payload.length());
+    }
+}

+ 134 - 0
src/test/java/com/adx/tencent/oppo/store/OppoHotStoreTest.java

@@ -0,0 +1,134 @@
+package com.adx.tencent.oppo.store;
+
+import com.adx.tencent.oppo.model.OppoBidRecord;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.json.JsonMapper;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.data.redis.connection.stream.MapRecord;
+import org.springframework.data.redis.core.StreamOperations;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.data.redis.core.ValueOperations;
+
+import java.time.Duration;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+class OppoHotStoreTest {
+    private final ObjectMapper mapper = JsonMapper.builder().findAndAddModules().build();
+    private final StringRedisTemplate redis = mock(StringRedisTemplate.class);
+    @SuppressWarnings("unchecked")
+    private final ValueOperations<String, String> values = mock(ValueOperations.class);
+    @SuppressWarnings("unchecked")
+    private final StreamOperations<String, Object, Object> stream = mock(StreamOperations.class);
+    private final OppoColdStore cold = mock(OppoColdStore.class);
+
+    private OppoHotStore store(Duration ttl) {
+        when(redis.opsForValue()).thenReturn(values);
+        when(redis.opsForStream()).thenReturn(stream);
+        return new OppoHotStore(redis, mapper, cold, "adx:oppo:events", ttl, 50000);
+    }
+
+    @Test
+    @SuppressWarnings({"rawtypes", "unchecked"})
+    void impressionCachesAreSmallAndExpireInThreeHoursButStreamKeepsFullEvent() throws Exception {
+        OppoBidRecord record = record();
+        store(Duration.ofHours(36)).recordBid(record);
+        JsonNode hot = cachePayload(Duration.ofHours(3));
+        assertEquals("click-url", hot.get("clickUrls").get(0).asText());
+        assertEquals("store-url", hot.get("appStoreLink").asText());
+        assertFalse(hot.has("showUrls"));
+        assertFalse(hot.has("price"));
+        assertFalse(hot.has("createdAt"));
+        assertFalse(hot.get("mediaParams").has("unused"));
+        assertCallbackFields(hot);
+
+        ArgumentCaptor<MapRecord<String, Object, Object>> event = ArgumentCaptor.forClass(MapRecord.class);
+        verify(stream).add(event.capture());
+        assertEquals("bid", event.getValue().getValue().get("type"));
+        JsonNode full = mapper.readTree((String) event.getValue().getValue().get("payload"));
+        assertEquals("show-url", full.get("showUrls").get(0).asText());
+        assertEquals("large-unused-value", full.get("mediaParams").get("unused").asText());
+        assertEquals(123, full.get("price").asInt());
+        assertEquals("impression", full.get("eventType").asText());
+        assertTrue(full.hasNonNull("createdAt"));
+    }
+
+    @Test
+    void clickShrinksBothCachesUsingConfiguredTtlWithoutChangingRedirectObject() throws Exception {
+        OppoBidRecord record = record();
+        store(Duration.ofHours(12)).shrinkAfterClick(record);
+        JsonNode slim = cachePayload(Duration.ofHours(12));
+        for (String field : List.of("clickUrls", "showUrls", "landingPage", "appStoreLink", "price")) {
+            assertFalse(slim.has(field), field);
+        }
+        assertCallbackFields(slim);
+        assertEquals("store-url", record.getAppStoreLink());
+        assertEquals(List.of("click-url"), record.getClickUrls());
+        assertEquals("large-unused-value", record.getMediaParams().get("unused"));
+        verifyNoInteractions(stream);
+    }
+
+    @Test
+    void directClickUsesConfiguredTtl() throws Exception {
+        OppoBidRecord record = record();
+        record.setEventType("click");
+        store(Duration.ofHours(12)).recordBid(record);
+        cachePayload(Duration.ofHours(12));
+    }
+
+    @Test
+    void zeroTtlFallsBackToThirtySixHoursAfterClick() throws Exception {
+        store(Duration.ZERO).shrinkAfterClick(record());
+        cachePayload(Duration.ofHours(36));
+    }
+
+    @Test
+    void clickCacheMissDoesNotUseDbRecordWithoutTrackingLinks() {
+        assertNull(store(Duration.ofHours(36)).findBidByMediaTrace("req"));
+        verifyNoInteractions(cold);
+    }
+
+    @Test
+    void conversionLookupStillFallsBackToDb() {
+        OppoBidRecord record = record();
+        when(cold.getBidsByQks(List.of("qk"))).thenReturn(List.of(record));
+        assertSame(record, store(Duration.ofHours(36)).findBidsByQks(List.of("qk")).get("qk"));
+    }
+
+    private JsonNode cachePayload(Duration ttl) throws Exception {
+        ArgumentCaptor<String> payload = ArgumentCaptor.forClass(String.class);
+        verify(values).set(eq("adx:oppo:bid:qk"), payload.capture(), eq(ttl));
+        verify(values).set(eq("adx:oppo:media:oppo:req"), eq(payload.getValue()), eq(ttl));
+        return mapper.readTree(payload.getValue());
+    }
+
+    private void assertCallbackFields(JsonNode record) {
+        assertEquals("owner", record.get("accountId").asText());
+        assertEquals("req", record.get("mediaTraceId").asText());
+        assertEquals("pkg", record.get("packageName").asText());
+        assertEquals("tag", record.get("tagId").asText());
+        assertEquals(200, record.get("rtaCode").asInt());
+        Map<String, String> expected = Map.of("oaid", "oaid", "imei", "imei", "androidId", "android",
+                "mac", "mac", "ip", "ip", "adId", "123", "deduction_rate", "20", "rta_code", "200");
+        expected.forEach((key, value) -> assertEquals(value, record.get("mediaParams").get(key).asText(), key));
+    }
+
+    private OppoBidRecord record() {
+        OppoBidRecord record = new OppoBidRecord();
+        record.setQk("qk"); record.setMediaTraceId("req"); record.setAccountId("owner");
+        record.setTagId("tag"); record.setPackageName("pkg"); record.setEventType("impression");
+        record.setRtaCode(200);
+        record.setPrice(123L); record.setShowUrls(List.of("show-url")); record.setClickUrls(List.of("click-url"));
+        record.setLandingPage("landing-url"); record.setAppStoreLink("store-url");
+        record.setMediaParams(Map.of("oaid", "oaid", "imei", "imei", "androidId", "android",
+                "mac", "mac", "ip", "ip", "adId", "123", "deduction_rate", "20", "rta_code", "200",
+                "unused", "large-unused-value"));
+        return record;
+    }
+}

+ 621 - 0
tmp/pdfs/oppo-callback.txt

@@ -0,0 +1,621 @@
+
+===== PAGE 1 =====
+OPPO⼴告主APP数据回传接⼝⽂档v3.23
+版本 内容
+v3.2 ⽀持⼴告主回传oaid-md5,type 新增值 type = 2 oadi md5加密
+v3.3 dataType 新增值 21:⻓留⾼质量⼈群
+v3.4 dataType 新增值 22:应⽤付费次数,新增字段payId
+v3.5 dataType 新增值 23:⻓留⾃定义⼈群
+v3.6 dataType 新增值 24:快应⽤付费次数
+v3.7 dataType 新增值 25:付费流⽔
+v3.8 dataType 新增值 26:质量分
+v3.9 ascribeType值优化
+v3.10 dataType 新增值 27:完件
+v3.11 payId、payAmount是否必填信息补充
+v3.12 dataType 新增值 28:应⽤关键⾏为,dataType已有值20从游戏/
+应⽤关键⾏为变更为游戏关键⾏为
+v3.13 新增字段⾦融授信⾦额、通⽤后端效果质量分
+dataType 新增⾏业质量策略使⽤值,201到224
+v3.14 新增【通⽤后端效果质量分】对部分⾏业的回传建议说明
+v3.15 dataType新增值200:特殊回传(⽤于⾏业客户回传历史数据的短
+期处理)
+v3.16 新增dataCategory枚举值
+v3.17 新增dataCategory枚举值
+V3.18 更新⽇期:2025-08-27 
+更新⼈:章丹阳 
+更新内容:新增2.2 请求体两个参数userIdType和keyTrackInfo的回传说
+明,补充了2.3响应参数说明,以⽀持智能分包归因结果的回传
+v3.19 更新⽇期:2025-12-02更新⼈:卿婷
+更新内容:补充2.2 请求体参数userIdType回传说明,当使⽤智能分包
+回传时userIdType必填;补充了2.3响应参数说明,ret 1007:使⽤了智能
+分包回传但未提供userIdType
+v3.20 新增dataType枚举值231
+v3.21 新增dataType枚举值 232-浏览岗位,233-B端认证
+增加channel字段⽤于助攻的枚举
+更新⼈:宋乐怡
+
+
+===== PAGE 2 =====
+公共部分
+1.1请求头(本⽂档接⼝均需要添加如下请求头)
+编码⽅式统⼀使⽤UTF-8
+1.2 Signature签名
+(1) 计算⽅式
+使⽤md5对数据进⾏签名:  md5( postData + timestamp + salt),md5后转⼩写
+2. 签名示例
+数据准备:
+postData数据(示例): 
+{"payAmount":100,"adId":101097648,"appType":1,"clientIp":"127.0.0.1","dataType":1,
+"ascribeType":1,"channel":1,"imei":"XJMyaLt8fDlv4a9b8/0RNQ==","type":1,"pkg":"co
+m.oppo.test","mac":"TEViR6jSgD/lECBl3Ah70eNy2gUQrQlekHkWqEGkZsU=","timesta
+mp":1571995483916}
+注意:postData为请求的body json字符串,组成签名的json与body中的json必须完全⼀
+致,不可对该json进⾏添加任何空格和转义符。
+timestamp:1571995483916,salt:e0u6fnlag06lc3pl
+md5加密:
+contents = postData + timestamp + salt(中间不加任何字符)=
+v3.22 新增dataType枚举值 234-注册次留,235-发消息&对话,
+32-54 第9-31⽇留存
+v3.23 更新⽇期:2026-05-28
+更新⼈:⻩辉焱
+更新内容:请求头header新增鉴权参数ownerToken
+字段 类型 说明 是否必
+须
+signature string 根据签名算法计算得到的字符串,⻅
+签名算法1.2
+是
+timestamp string 时间戳转换为字符串(long毫秒) 是
+Content-Type string 固定值:application/json 是
+ownerToken string 营销平台⽣成的ominitoken 否
+字段 解释
+postData post的json格式数据
+timestamp 时间戳(long毫秒)
+salt OPPO提供,Salt: e0u6fnlag06lc3pl
+
+
+===== PAGE 3 =====
+{"payAmount":100,"adId":101097648,"appType":1,"clientIp":"127.0.0.1","dataType
+":1,"ascribeType":1,"channel":1,"imei":"XJMyaLt8fDlv4a9b8/0RNQ==","type":1,"pk
+g":"com.oppo.test","mac":"TEViR6jSgD/lECBl3Ah70eNy2gUQrQlekHkWqEGkZsU="
+,"timestamp":1571995483916}1571995483916e0u6fnlag06lc3pl
+使⽤md5加密:md5(contents)= ce14fcc22abd7461e860263a8da983eb
+1.3 加密算法
+请求中的imei与mac字段均需通过AES算法进⾏加密,并且将加密后的⼆进制数据通过
+base64编码为字符串。为了⽅便配置,示例中的密钥是经过base64处理后的字符串
+(base64Key)。base64Key在接⼊时由OPPO提供。
+Base64Key: XGAXicVG5GMBsx5bueOe4w==
+(1)java示例
+import java.security.GeneralSecurityException;
+import java.security.Key;
+import javax.crypto.Cipher;
+import javax.crypto.spec.SecretKeySpec;
+import org.apache.commons.codec.binary.Base64;
+public class Demo {
+ public static String encode(byte[] data, String base64Key) throws GeneralSecurityException {
+  final Key dataKey = new SecretKeySpec(Base64.decodeBase64(base64Key), "AES");
+  
+  Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
+  cipher.init(Cipher.ENCRYPT_MODE, dataKey);
+  byte[] encryptData = cipher.doFinal(data);
+ 
+  return Base64.encodeBase64String(encryptData).replaceAll("\r", "").replaceAll("\n", "");
+ }
+}
+(2)php示例(仅供参考)
+1.4 ownerToken鉴权
+鉴权⽬的
+⽤于建⽴平台与⼴告主之间安全可信的数据通道,保障⼴告主的投放权益:
+保护转化数据资产:仅授权⽅可写⼊回传数据,杜绝第三⽅伪造、篡改或恶意刷量;
+降低对账与排查成本:请求来源可追溯、责任可界定,出现数据异议时可快速定位;
+提升投放效果稳定性:确保模型训练样本真实可靠,归因结果更精准
+如何⽣成token
+与OPPO营销平台Omni API权限认证⾥的token保持⼀致:
+public function encrypt($input, $base64Key){
+return openssl_encrypt($input, ‘AES-128-ECB’,base64_decode($base64Key),0,’’);
+}
+
+
+===== PAGE 4 =====
+https://adsfs.heytapimage.com/mp/static/omniapi/latest/index.html?id=1%20%E9%8
+0%9A%E7%94%A8%E5%A3%B0%E6%98%8E
+-----------------------------------------------------------
+token=base64(owner_id+“,”+api_id+“,”+time_stamp+“,”+sign)
+所有内容使⽤半⻆逗号(,)按顺序拼接起来,再经过Base64编码。
+owner_id:⽤户帐号id,为营销平台的⼴告主ID;
+api_id:API授权接⼊⽅唯⼀身份标识,在开通授权邮件中会与app_id⼀块提供;
+time_stamp:发起请求时的时间戳,精确到秒,这个值跟接收到请求时的服务器时间戳值偏差
+(正或负)超过1200秒(20分钟)时,请求会被拒绝,要求调⽤⽅重新⽣成token(请使⽤
+UTC+08:00中国北京时间);
+sign:签名字符串,sign=sha1(api_id+api_key+time_stamp),采⽤sha1加密算法⽣成签名串
+(此处加号+代表字符串拼接,不是指要包含加号+),time_stamp为上⾯使⽤的时间戳;
+-----------------------------------------------------------
+token在接⼝协议的位置
+在回传接⼝的请求头部分, 参考1.1
+token鉴权失败的返回信息
+{
+  ret:1008
+  msg: "ownerToken 鉴权失败 "
+}
+1.数据回传接⼝
+2.1接⼝地址
+POST请求:https://api.ads.heytapmobi.com/api/uploadActiveData
+2.2请求体Body
+1
+2
+3
+4
+字段 类型 说明 是否必须 样例 备注
+userIdT
+ype
+int ⽤户标识类型。
+1:oaid或imei,即点击监测归
+因; 2:智能分包归因信息 。
+注意:
+当userIdType为1时,imei和
+ouId必传其⼀,keytrackinfo可
+选回传。
+当userIdType为2时,
+keytrackinfo必传。
+当⼴告主申请
+使⽤智能分包
+能⼒回传时必
+填;
+未接⼊智能分
+包能⼒时⾮必
+填
+
+
+===== PAGE 5 =====
+keyTrac
+kInfo
+string 智能分包归因信息。
+⼴告主应⽤客户端从OPPO商
+店客户端查询到的智能分包归
+因信息"key_track_info"。只需
+回传查询到的原始的完整的字
+符串内容,不需要额外处理,
+包含原始的下载时间戳、等完
+整信息。
+当此字段为必填时,需确保
+keytrackinfo字段有值。
+OPPO侧对有值校验通过时将
+返回“ret=0”,表示回传成功。
+同时OPPO侧将对其中的
+trackId或adTraceId字段进⾏
+是否有值的判断,返回给⼴告
+主参考,但不影响成功回传。
+当
+userIdType=2
+,此字段是必
+填
+当
+userIdType=1
+,此字段是选
+填。
+{
+    "key_trac
+k_ref": "
+{\"adId\":\"
+3700438\",\
+"trackId\":\"
+512cf5d5a1
+3e30fd5527
+36cb188e2f
+32\"}",
+    "ext": "
+{\"download
+_start_time\
+":17568152
+62855,\"adT
+raceId\":\"1
+-
+VwdhGy7uR
+2n8H1wM0y
+7xPLFYnn2l
+2HjRhWERM
+eA73LXjaYg
+Cc0+YqN57
+nCLXDsd3A
+DEg+hb\\\/
+JbufJuqk+nF
+3W5AtD\\\/
+kQyvoR3Wyi
+XR10oKk=\"
+,\"adSmartC
+hannelId\":\
+"enforceMo
+ckSmartCha
+nnelId\"}",
+    "status_c
+ode": "200"
+}
+imei string imei原⽂经过AES加密后的
+值,加密算法参考表格下⽅
+当userIdType 
+= 1时,必传其
+⼀
+当
+userIdType=2
+,此字段是选
+填。
+加密前:
+8681230399
+27020
+加密后:
+XJMyaLt8fDl
+v4a9b8/0RN
+Q==
+
+
+===== PAGE 6 =====
+ouId string oaid原⽂经过AES加密后的
+值,加密算法参考表格下⽅
+reques
+tId
+string 请求id 是
+mac string 客户端mac经过AES加密后
+的值,加密说明⻅2.3
+否 加密前:
+d7:1b:3e:0
+0:14:b3
+加密后:
+TEViR6jSg
+D/lECBl3A
+h70eNy2gU
+QrQlekHkW
+qEGkZsU=
+clientIp string 事件发⽣时的客户端ip,如
+210.210.210.210
+否 127.0.0.1
+timesta
+mp
+long 事件发⽣的时间戳(毫秒),
+如1522221766623 
+是 157199548
+3916
+pkg string 包名,如com.xxx
+如果是快应⽤,要填快应⽤
+id,如100137
+是 com.oppo.t
+est,要填投
+放应⽤的包
+名,不要依
+赖监测链接
+上报的pkg
+字段
+
+
+===== PAGE 7 =====
+dataTy
+pe
+int 转化数据类型:
+1、激活,2、注册,3、游
+戏付费,4、次留,5、应⽤
+内授信,6、应⽤内下单
+(电商),7、应⽤付费 
+8、⾃定义⽬标, 9、第3⽇
+留存 10、第4⽇留存, 
+11、第5⽇留存 12、第6⽇
+留存, 13、第7⽇留存,
+14、第8⽇留存,15、拉
+活,16、快应⽤付费,17、
+次留(DP)18、卸载激活 
+19、快应⽤关键⾏为 20、
+游戏关键⾏为 21、⻓留⾼
+质量⼈群(废弃) 22、应
+⽤付费次数 23、⻓留⾃定
+义 24、快应⽤付费次数 
+25、付费流⽔ 26、质量分 
+27、完件 28、应⽤关键⾏
+为 
+32、第9⽇留存 33、第10
+⽇留存 34、第11⽇留存 
+35、第12⽇留存 36、第13
+⽇留存 37、第14⽇留存 
+38、第15⽇留存 39、第16
+⽇留存 40、第17⽇留存 
+41、第18⽇留存 42、第19
+⽇留存 43、第20⽇留存 
+44、第21⽇留存 45、第22
+⽇留存 46、第23⽇留存 
+47、第24⽇留存 48、第25
+⽇留存 49、第26⽇留存 
+50、第27题留存、 51、第
+28⽇留存 52、第29⽇留存 
+53、第30⽇留存 54、第31
+⽇留存
+---⾏业质量策略使⽤---
+200-特殊回传
+【电商】
+下单(沿⽤已有6-应⽤内下
+单)
+201-商品加购
+202-商品收藏
+203-电商取消订单
+225-商品浏览
+是 1
+
+
+===== PAGE 8 =====
+226-商品搜索
+227-跳转结算界⾯
+228-调起⽀付⻚⾯/调起⽀
+付宝⽀付⻚⾯
+222-⾼价值⽤户
+【⾦融】
+27-完件(沿⽤已有27-完
+件)
+5-授信(沿⽤已有5-应⽤内
+授信)
+204-进件
+205-开户
+206-⼊⾦
+207-⽤信/放款
+208-交易(⾦融)
+209-投保
+210-保单升级
+211-退保
+231-⽤信/放款⾦额
+【教育】
+212-正价课购买
+213-正价课到课
+214-正价课完课
+215-添加⽼师
+216-低价课购买
+217-低价课到课
+218-低价课完课
+219-退课
+【招聘】
+220-创建简历
+221-投递简历
+232-浏览岗位
+233-B端认证
+【游戏】
+223-游戏注册
+224-游戏付费
+【社交】
+229 app主⻚
+
+
+===== PAGE 9 =====
+230 充值⻚
+【AI】
+234 注册次留
+235 发消息&对话
+【⽹服】
+236 特殊标记⼈群
+payId string 付费交易Id 否(但
+dataType=22
+/25 必填)
+custom
+Type
+int ⾃定义⽬标类型:dataType
+填了8之后补充,枚举值与
+客户沟通后补充
+否
+channe
+l
+int 渠道:
+1、OPPO
+2、⼀加
+0、其他
+*仅助攻回传时启⽤:
+3、巨量引擎
+4、腾讯⼴告
+5、快⼿磁⼒引擎
+6、百度
+7、⼩红书
+8、穿⼭甲
+9、优量汇
+是 1
+type int 0: ⽆md5加密、原值(默
+认为0)
+1:imei md5 加密
+2:oaid md5 加密
+是 1
+appTyp
+e
+int 应用类别: 1 应用  2 游戏  3 快
+应用  0 其他,默认 1 应用
+否 1
+
+
+===== PAGE 10 =====
+payAm
+ount
+long 付 费 金 额 ( 单 位:分)
+备注:
+应⽤付费ltv1:客户必须回
+传⾸次应⽤付费
+(datatype=7)&⾸⽇内每
+⼀笔付费流⽔
+(datatype=25)
+例如:
+⽤户imei1被xx应⽤拉新预
+算触达并在当⽇产⽣2笔付
+费流⽔,分别为6元和38元,
+需要回传3条数据明细,分
+别为:
+1.datatype=7  
+payAmount=''
+(回传⽤户⾸次付费6元的
+⾏为数据,告知平台该⽤户
+产⽣了付费转化)
+2.datatype=25 
+payAmount=600
+(回传该⽤户当⽇⾸次付费
+⾦额)
+3.datatype=25 
+payAmount=3800
+(回传该⽤户当⽇第⼆次付
+费⾦额)
+应⽤付费ltv7:客户必须回
+传⾸次应⽤付费
+(datatype=7)&7⽇内每
+⼀笔付费流⽔
+(datatype=25)
+(回传示例同上,除回传⾸
+次付费事件datatype=7外,
+7⽇内每笔付费数据均需按
+标准回传)
+否 100 该字段
+传值
+时。
+datatyp
+e填25
+ascribe
+Type
+int 归因类型:1:⼴告主归
+因,0:OPPO归因(默认或
+者不填即为0)
+是 1
+快应⽤必须
+选1⼴告主
+归因
+adId long ⼴告主回传转化数据时,附
+带已经归因好的⼴告id。对
+应营销平台上的⼴告创意id
+是 679063897
+
+
+===== PAGE 11 =====
+reques
+tId
+string 请求id 否
+quality
+Score
+float 质量分 否 0-1 最多回
+传两位⼩数
+淘宝专
+⽤
+creditL
+ine
+long ⾦融授信⾦额(单位:分) 否 100
+
+
+===== PAGE 12 =====
+commo
+nQualit
+yScore
+float 通⽤后端效果质量分
+备注:
+1.⾦融⾏业回传规范:
+【⽅式1】:授信⾦额区间-
+->回传质量分
+0-3k(含3k)-->0.15
+3-5k(含5k)-->0.30
+5-8k(含8k)-->0.45
+8-10k(含10k)--
+>0.60
+10-12k(含12k)--
+>0.75
+12k+以上-->0.90
+【⽅式2】:若上述分档不
+适⽤业务情况,可⾃⾏按照
+业务实际情况映射为0-1之
+间的⼩数回传,确保
+①质量分分布与授信⾦额呈
+正相关②⾄少有六个及以上
+的质量区间分档。
+例:
+0,02 | 0.19 | 0.24| 0.28| 
+0.54| 0.62| 0.83| 0.91| 0.97
+【操作备注】:
+回传质量分时,dataType转
+化数据类型选择26-质量
+分,与转化⾏为事件区分,
+分两条分别回传
+2.社交⾏业回传规范:
+【⽅式】:付费⾦额区间--
+>回传质量分
+0-6元(含6元)-->0.1
+6-15元(含15元)--
+>0.2
+15-30元(含30元)--
+>0.3
+30-50元(含50元)--
+>0.4
+50-100元(含100元)-
+->0.5
+100-200元(含200
+元)-->0.6
+200-300元(含300
+元)-->0.7
+否 0-1 最多回
+传两位⼩数
+该字段
+传值
+时。
+datatyp
+e填26
+
+
+===== PAGE 13 =====
+2.3返回值Response 
+300-400元(含400
+元)-->0.8
+400-500元(含500
+元)-->0.9
+500元以上-->1.0
+3.旅游⾏业回传规范:(⽤户
+后效质量-->回传质量分)
+【⽅式】根据业务⾃定义⽤
+户质量综合指标,映射为0-
+1之间的⼩数回传,确保
+①质量分分布与授信⾦额呈
+正相关②⾄少有六个及以上
+的质量区间分档。
+例:
+0,02 | 0.19 | 0.24| 0.28| 
+0.54| 0.62| 0.83| 0.91| 0.97
+dataCa
+tegory
+int 0:常规回传,1:⾏业优化
+模型,2:助攻,3:品牌(默
+认或者不填即为0)
+是
+字段 类型 说明 是否必须
+ret int 返回码,0正常,1001参数校验失败,
+1002未知错误,1007使⽤智能分包回传
+但未提供userIdType, 1008ownerToken
+鉴权失败
+注意:如果是签名错误的话会返回http 
+status 403
+是
+msg string 如果有错误,则返回错误信息 否
+传参情况 响应码 提示语
+当userIdType = 1时,imei和
+ouId同时为空。
+1001 imei/ouId/androidId/requestId
+ cannot be all empty!
+当userIdType = 2时,
+keyTrackInfo为空
+1001 keyTrackInfo is null!
+当userIdType = 2时,
+keyTrackInfo不为空。
+keyTrackInfo中解析出来的
+trackId和adTraceId同时为空
+0 traceId & trackId are all 
+empty!
+
+
+===== PAGE 14 =====
+2.4请求示例
+(1)Header
+(2)Body
+(3)Postman截图:
+应⽤已经在智能分包⽩名单
+中,但userIdType未填写
+1007 ⽩名单客户必须提供
+userIdType
+当ownerToken不为空,且鉴
+权失败
+1008 ownerToken鉴权失败
+signature ce14fcc22abd7461e860263a8da983eb
+Content-Type application/json
+timestamp 1571995483916
+{"payAmount":100,"adId":101097648,"appType":1,"clientIp":"127.0.0.1","dataTyp
+e":1,"ascribeType":1,"channel":1,"imei":"XJMyaLt8fDlv4a9b8/0RNQ==","type":1,"
+pkg":"com.oppo.test","mac":"TEViR6jSgD/lECBl3Ah70eNy2gUQrQlekHkWqEGkZ
+sU=","timestamp":1571995483916}

+ 107 - 0
tmp/pdfs/oppo-tracking.txt

@@ -0,0 +1,107 @@
+
+===== PAGE 1 =====
+OPPO 效果广告的广告主监测/三方监测规范 
+ 
+名词解释 
+名词 解释 
+宏参数 监测链接 URL 中将被真实内容替换的占位符 
+ 
+能力介绍: 
+监测链接是由广告主提供,并按照OPPO约定的格式填写,用来接收OPPO广告发送的
+用户的广告展示和广告点击信息。投放端填写位置如下图: 
+ 
+ 
+请求方:OPPO广告服务器 
+处理方:广告主(或广告主指定的)提供的监测平台服务器 
+通信方式:HTTP 协议,GET 方法 
+广告主响应内容:OPPO广告请求监测链接URL后,广告主使用Http的状态码告知是
+否成功。状态码说明:正常则返回HTTP 200,其余均为异常。 
+  
+监测链接URL 的格式: 
+监测链接URL的构成共三部分,① 前缀"https://XXXX.XXX.com?"+②将被OPPO 替换
+的参数”+③广告主自定义的其它参数,其中②和③的参数不限制顺序,所有参数只需
+符号"&"分隔即可 
+ 
+其中: 
+(1) 前缀只能是 https 开头,服务器域名必须是能够正常访问的正式域名。 
+(2) 将被OPPO 替换的宏参数:当产生广告行为时,相应的参数会换成真实的内容,并
+发送给广告主服务器。 
+• 宏参数格式:“__参数__”(见下表),使用与号 (&) 分隔参数。 
+• 可支持的参数:参考下表,广告主可以根据自己的需要选择其中部分参数。 
+• 注意: 
+o “__参数__”格式中参数左右两边均为两个连续的英文字符"_",共四
+个"_"。 
+
+
+
+===== PAGE 2 =====
+o 监测链接仅支持大小写字母、数字以及英文下划线。 
+o 自2025年 12月起监测链接的宏参数格式统一为“__参数__”;历史上
+曾经有部分宏参数格式为“$参数$”,该部分存量宏参数会进行兼容,
+增量监测链接的宏参数格式请统一为“__参数__” 
+监测链接示例: 
+ 
+①黑色加粗:接收服务域名,用问号“?”分隔 
+②绿色高亮部分:广告主自定义的参数,符号“=”后没有英文下划线,不同参数用符
+号&分隔 
+③黄色高亮部分:广告主自选的OPPO平台将替换的宏参数,英文下划线之间的内容将
+被替换成真实值,不同参数用&符号分隔。其中的英文下划线格式内容一定要按照规定
+拼写。 
+  
+  
+监测链接参数: 
+OPPO将对宏参数替换成真实的内容后,并发送给广告主。 
+宏参数格式 含义 描述 是否必填 
+__REQ__ 广告请求 ID 代表一次广告请求 必填 
+__IMEI__ IMEI 仅支持部分 Android Q 以下版本的设备号,Imei
+原值的 md5 加密 
+必填 
+__OAID__ OAID -Android Q 及以下版本同时上报md5 加密的
+IMEI 及原文的oaid 
+-Android Q 及更高版本,仅上报64 位原文
+oaid。 
+如果用户在设置>隐私中关闭了个性化广告跟
+踪,OAID 会被设置为全0。 
+OAID 广告标识符由64 位字母和数字构成。 
+必填 
+__TS__  广告行为时间 客户端发生广告行为事件(曝光/点击)的时
+间,以毫秒为单位的时间戳,13 位时间戳。 
+• 曝光监测:该时间戳被替换为客户端曝
+光时间 
+• 点击监测:该时间戳被替换为客户端点
+击时间 
+必填 
+__OWNERID__ 广告主账号 ID 由 OPPO 侧为广告主账号生成的id   
+__PLANID__ 广告计划 ID 广告主在 OPPO 投放端创建   
+__GROUPID__ 广告组 ID 广告主在 OPPO 投放端创建   
+__ADID__ 广告创意 ID 广告主在 OPPO 投放端创建 必填 
+
+
+
+===== PAGE 3 =====
+__AN__ 广告创意名称 广告主在 OPPO 投放端创建   
+__OV__ 操作系统版本号 操作系统版本号。示例:14.1   
+__OS__  操作系统平台 操作系统平台,只支持安卓。示例:android   
+__M__ 机型(model) 手机机型的认证型号。示例:CPH2651,表示
+OPPO Find X8 
+  
+__LAN__ 语言 语言,例如 ZH   
+__IP__ 客户端 IP 地址 客户端 IP 地址。正常情况下取值为IPv4 地址;
+当 IPV4 无法取到时,则取值为 IPv6。 
+  
+__UA__ 用户代理(ua,全称
+user agent) 
+一个特殊字符串头,使得服务器能够识别客户使
+用的操作系统及版本、CPU 类型、浏览器及版
+本、浏览器渲染引擎、浏览器语言、浏览器插件
+等。部分广告位可能缺失。 
+  
+__SPUID__ 商品 ID 仅投放商品广告时替换此宏参数。   
+__PROGRESS__ 视频播放进度 视频广告时有效,单位为毫秒   
+__EXPINFO__ 
+实验组 ID OPPO 广告平台帮助客户实现不同的流量策略实
+验,实验组 ID 随监测下发给客户,客户可以通
+过分析 ID 评估不同策略的效果。 
+注:需联系客户运营配置 
+  
+