yumeng 1 hónapja
szülő
commit
85f3b26680
42 módosított fájl, 5008 hozzáadás és 33 törlés
  1. 171 0
      VIVO_ACCOUNT_AUTH_GUIDE.md
  2. 130 4
      src/main/java/com/adx/tencent/AppConfiguration.java
  3. 128 0
      src/main/java/com/adx/tencent/config/AppProperties.java
  4. 412 28
      src/main/java/com/adx/tencent/conversionsync/ConversionSyncService.java
  5. 2 1
      src/main/java/com/adx/tencent/report/AdBidReportStore.java
  6. 404 0
      src/main/java/com/adx/tencent/vivo/client/VivoClient.java
  7. 669 0
      src/main/java/com/adx/tencent/vivo/controller/VivoTrackingController.java
  8. 18 0
      src/main/java/com/adx/tencent/vivo/mapper/VivoAdvertiserAccountMapper.java
  9. 14 0
      src/main/java/com/adx/tencent/vivo/mapper/VivoBidEventMapper.java
  10. 16 0
      src/main/java/com/adx/tencent/vivo/mapper/VivoClientConfigMapper.java
  11. 14 0
      src/main/java/com/adx/tencent/vivo/mapper/VivoMediaCallbackMapper.java
  12. 14 0
      src/main/java/com/adx/tencent/vivo/mapper/VivoTagEventMapper.java
  13. 16 0
      src/main/java/com/adx/tencent/vivo/mapper/VivoTokenMapper.java
  14. 9 0
      src/main/java/com/adx/tencent/vivo/mapper/VivoTrackingReportMapper.java
  15. 33 0
      src/main/java/com/adx/tencent/vivo/model/VivoAdvertiserAccountRecord.java
  16. 152 0
      src/main/java/com/adx/tencent/vivo/model/VivoBidRecord.java
  17. 26 0
      src/main/java/com/adx/tencent/vivo/model/VivoClientConfigRecord.java
  18. 62 0
      src/main/java/com/adx/tencent/vivo/model/VivoMediaCallbackRecord.java
  19. 14 0
      src/main/java/com/adx/tencent/vivo/model/VivoTagEventRecord.java
  20. 51 0
      src/main/java/com/adx/tencent/vivo/model/VivoTokenRecord.java
  21. 29 0
      src/main/java/com/adx/tencent/vivo/model/VivoTrackingRecord.java
  22. 221 0
      src/main/java/com/adx/tencent/vivo/service/VivoAdvertiserSyncService.java
  23. 574 0
      src/main/java/com/adx/tencent/vivo/service/VivoAuthService.java
  24. 146 0
      src/main/java/com/adx/tencent/vivo/service/VivoBackgroundTasks.java
  25. 69 0
      src/main/java/com/adx/tencent/vivo/service/VivoColdWorker.java
  26. 137 0
      src/main/java/com/adx/tencent/vivo/service/VivoPlacement.java
  27. 75 0
      src/main/java/com/adx/tencent/vivo/service/VivoRetryService.java
  28. 97 0
      src/main/java/com/adx/tencent/vivo/service/VivoTagEventResolver.java
  29. 94 0
      src/main/java/com/adx/tencent/vivo/service/VivoTagEventSyncService.java
  30. 358 0
      src/main/java/com/adx/tencent/vivo/store/VivoColdStore.java
  31. 394 0
      src/main/java/com/adx/tencent/vivo/store/VivoHotStore.java
  32. 15 0
      src/main/resources/application-dev.yml
  33. 15 0
      src/main/resources/application-prod.yml
  34. 15 0
      src/main/resources/application-test.yml
  35. 53 0
      src/main/resources/mapper/VivoAdvertiserAccountMapper.xml
  36. 60 0
      src/main/resources/mapper/VivoBidEventMapper.xml
  37. 39 0
      src/main/resources/mapper/VivoClientConfigMapper.xml
  38. 69 0
      src/main/resources/mapper/VivoMediaCallbackMapper.xml
  39. 23 0
      src/main/resources/mapper/VivoTagEventMapper.xml
  40. 61 0
      src/main/resources/mapper/VivoTokenMapper.xml
  41. 9 0
      src/main/resources/mapper/VivoTrackingReportMapper.xml
  42. 100 0
      src/main/resources/schema-vivo.sql

+ 171 - 0
VIVO_ACCOUNT_AUTH_GUIDE.md

@@ -0,0 +1,171 @@
+# Vivo 账户授权操作文档
+
+更新时间:2026-08-19
+
+## 1. 文档目的
+
+本文用于指导业务或运营同学完成 Vivo 二代账户授权。  
+授权完成后,系统才能将百度转化数据回传给对应的 Vivo 投放账户。
+
+## 2. 适用范围
+
+- 适用于需要接入 Vivo 转化回传的广告账户
+- 一个 `secondaryAccountId` 需要单独授权一次
+- 同一个二代账号下的投放账户共用这一套授权 token
+
+## 3. 授权后的作用
+
+授权成功后,系统会为该 `secondaryAccountId` 获取并保存:
+
+- `access_token`
+- `refresh_token`
+
+系统后续会自动使用该账户 token 进行 Vivo 转化回传,并自动处理 token 刷新,无需人工重复操作。
+
+## 4. 回调地址
+
+本项目 Vivo 授权回调地址为:
+
+```text
+https://tencent.bjhcstszkj.cn/vivo/callback
+```
+
+## 5. 授权链接说明
+
+Vivo 授权链接格式如下:
+
+```text
+https://open-ad.vivo.com.cn/OAuth?clientId={数据库维护的clientId}&state={secondaryAccountId}&redirectUri=https%3A%2F%2Ftencent.bjhcstszkj.cn%2Fvivo%2Fcallback
+```
+
+说明:
+
+- `clientId`:由技术在数据库中维护,不再写死在配置文件
+- `state`:请填写当前需要授权的 Vivo 二代账户 ID,即 `secondaryAccountId`
+- `redirectUri`:固定回调地址,无需修改
+
+## 6. 操作步骤
+
+### 步骤一:确认待授权账户
+
+先确认本次需要授权的 Vivo 二代账户 `secondaryAccountId`。
+
+例如:
+
+```text
+123456789
+```
+
+### 步骤二:生成对应授权链接
+
+将上面的 `secondaryAccountId` 替换进 `state` 参数中。
+
+例如,若二代账户 ID 为 `123456789`,数据库维护的 `clientId` 为 `20260819030`,则授权链接为:
+
+```text
+https://open-ad.vivo.com.cn/OAuth?clientId=20260819030&state=123456789&redirectUri=https%3A%2F%2Ftencent.bjhcstszkj.cn%2Fvivo%2Fcallback
+```
+
+### 步骤三:使用该账户登录并授权
+
+1. 打开授权链接
+2. 使用对应的 Vivo 二代账户登录
+3. 按页面提示完成授权确认
+
+注意:
+
+- 必须使用目标二代账户本人或有授权权限的账号进行操作
+- 不同 `secondaryAccountId` 不能共用一次授权
+
+### 步骤四:等待跳转完成
+
+授权成功后,页面会自动跳转到:
+
+```text
+https://tencent.bjhcstszkj.cn/vivo/callback
+```
+
+系统收到回调后,会自动完成 token 保存。
+
+成功时,页面一般会返回类似结果:
+
+```json
+{
+  "secondaryAccountId": "123456789",
+  "tokenExpireAt": 1780000000000,
+  "refreshTokenExpireAt": 1790000000000,
+  "authorized": true
+}
+```
+
+只要看到 `authorized: true`,即可视为授权成功。
+
+## 7. 授权成功后的系统行为
+
+授权成功后,系统会自动:
+
+1. 保存该账户的授权 token
+2. 后续回传时优先使用缓存 token
+3. token 临近过期时自动刷新
+4. 刷新后自动更新缓存和数据库
+
+业务侧无需额外处理。
+
+## 8. 常见问题
+
+### 8.1 一个账户需要授权几次?
+
+通常一个 `secondaryAccountId` 授权一次即可。  
+只有在以下情况可能需要重新授权:
+
+- Vivo 侧主动失效授权
+- refresh token 失效
+- 账户权限发生变化
+
+### 8.2 多个账户是否可以共用一次授权?
+
+不可以。  
+每个 `secondaryAccountId` 需要单独授权。
+
+### 8.3 授权成功后多久可以使用?
+
+通常授权回调成功后即可使用。
+
+### 8.4 如果授权后仍无法回传怎么办?
+
+请提供以下信息给技术排查:
+
+- 授权使用的 `secondaryAccountId`
+- 授权时间
+- 授权链接
+- 回调页面返回内容截图
+
+## 9. 业务侧操作模板
+
+如果需要发给业务同学,可以直接使用下面这段话:
+
+```text
+请使用对应的 Vivo 投放账户打开以下授权链接并完成授权:
+
+https://open-ad.vivo.com.cn/OAuth?clientId={数据库中的clientId}&state={secondaryAccountId}&redirectUri=https%3A%2F%2Ftencent.bjhcstszkj.cn%2Fvivo%2Fcallback
+
+说明:
+1、请将 {secondaryAccountId} 和 {clientId} 替换为实际值
+2、必须使用该二代账户本人或具备授权权限的账号登录
+3、授权成功后页面会自动跳转到回调地址
+4、若页面返回 authorized=true,则表示授权成功
+```
+
+## 10. 备注
+
+当前项目正式授权回调域名为:
+
+```text
+https://tencent.bjhcstszkj.cn
+```
+
+当前授权回调完整地址为:
+
+```text
+https://tencent.bjhcstszkj.cn/vivo/callback
+```

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

@@ -31,6 +31,16 @@ import com.adx.tencent.report.AdBidReportStore;
 import com.adx.tencent.tagsync.TagEventResolver;
 import com.adx.tencent.tagsync.TagEventSyncService;
 import com.adx.tencent.tencent.TencentClient;
+import com.adx.tencent.vivo.client.VivoClient;
+import com.adx.tencent.vivo.service.VivoAuthService;
+import com.adx.tencent.vivo.service.VivoAdvertiserSyncService;
+import com.adx.tencent.vivo.service.VivoColdWorker;
+import com.adx.tencent.vivo.service.VivoPlacement;
+import com.adx.tencent.vivo.service.VivoRetryService;
+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.storage.RedisHotStore;
 import com.adx.tencent.storage.RedisLock;
 import com.adx.tencent.storage.TiDBColdStore;
@@ -371,6 +381,102 @@ public class AppConfiguration {
                 null, props.getRedisStreamMaxLen(), objectMapper);
     }
 
+    @Bean
+    public VivoColdStore vivoColdStore(@Nullable DataSource tidbDataSource, ObjectMapper objectMapper) {
+        if (tidbDataSource == null) return null;
+        try {
+            SqlSessionFactory sqlSessionFactory = createSqlSessionFactory(tidbDataSource);
+            VivoColdStore store = new VivoColdStore(sqlSessionFactory, objectMapper);
+            store.migrate();
+            return store;
+        } catch (Exception e) {
+            log.warn("Failed to open Vivo TiDB: {}", e.getMessage(), e);
+            return null;
+        }
+    }
+
+    @Bean
+    public VivoHotStore vivoHotStore(StringRedisTemplate redis,
+                                     ObjectMapper objectMapper,
+                                     @Nullable VivoColdStore vivoColdStore) {
+        if (vivoColdStore == null) return null;
+        return new VivoHotStore(redis, objectMapper, vivoColdStore,
+                "adx:vivo:", props.getVivoRedisStream(), props.getBidTtl());
+    }
+
+    @Bean
+    public VivoPlacement vivoPlacement() {
+        VivoPlacement p = new VivoPlacement();
+        p.setBaiduMediaId(props.getBaiduMediaId());
+        p.setBaiduAppId(props.getBaiduAppId());
+        p.setBaiduTagId(props.getBaiduTagId());
+        p.setBidFloor(props.getBaiduBidFloor());
+        p.setActionTypes(List.of(0, 1, 2));
+
+        Map<String, VivoPlacement.VivoAppPlacement> platforms = new HashMap<>();
+        if (props.getBaiduAndroidAppId() != null && !props.getBaiduAndroidAppId().isBlank()) {
+            VivoPlacement.VivoAppPlacement android = new VivoPlacement.VivoAppPlacement();
+            android.setAppId(props.getBaiduAndroidAppId());
+            android.setTagIds(props.getBaiduAndroidTagIds());
+            platforms.put("android", android);
+        }
+        if (props.getBaiduIosAppId() != null && !props.getBaiduIosAppId().isBlank()) {
+            VivoPlacement.VivoAppPlacement ios = new VivoPlacement.VivoAppPlacement();
+            ios.setAppId(props.getBaiduIosAppId());
+            ios.setTagIds(props.getBaiduIosTagIds());
+            platforms.put("ios", ios);
+        }
+        if (!platforms.isEmpty()) p.setPlatforms(platforms);
+        return p;
+    }
+
+    @Bean
+    public VivoAuthService vivoAuthService(StringRedisTemplate redisTemplate,
+                                           @Nullable VivoColdStore vivoColdStore,
+                                           ObjectMapper objectMapper) {
+        if (vivoColdStore == null) return null;
+        return new VivoAuthService(
+                props.getVivoConversionBaseUrl(),
+                props.getVivoRedirectUri(),
+                props.getVivoTokenRedisPrefix(),
+                props.getVivoAdvertiserRedisPrefix(),
+                redisTemplate,
+                vivoColdStore,
+                objectMapper
+        );
+    }
+
+    @Bean
+    public VivoClient vivoClient(@Nullable VivoAuthService vivoAuthService, ObjectMapper objectMapper) {
+        if (vivoAuthService == null) return null;
+        return new VivoClient(props.getVivoConversionBaseUrl(), vivoAuthService, objectMapper);
+    }
+
+    @Bean
+    public VivoRetryService vivoRetryService(@Nullable VivoColdStore vivoColdStore,
+                                             @Nullable VivoClient vivoClient) {
+        if (vivoColdStore == null || vivoClient == null) return null;
+        return new VivoRetryService(vivoColdStore, vivoClient, props.getVivoCallbackRetryLimit());
+    }
+
+    @Bean
+    public VivoAdvertiserSyncService vivoAdvertiserSyncService(@Nullable VivoAuthService vivoAuthService,
+                                                               @Nullable VivoColdStore vivoColdStore,
+                                                               ObjectMapper objectMapper) {
+        if (vivoAuthService == null || vivoColdStore == null) return null;
+        return new VivoAdvertiserSyncService(props.getVivoConversionBaseUrl(), vivoAuthService, vivoColdStore, objectMapper);
+    }
+
+    @Bean
+    public VivoColdWorker vivoColdWorker(@Nullable VivoHotStore vivoHotStore,
+                                         @Nullable VivoColdStore vivoColdStore,
+                                         ObjectMapper objectMapper) {
+        if (vivoHotStore == null || vivoColdStore == null) return null;
+        return new VivoColdWorker(vivoHotStore, vivoColdStore,
+                "vivo-cold-writers", props.getWorkerConsumer() + "-vivo", props.getWorkerBatch(),
+                null, props.getRedisStreamMaxLen(), objectMapper);
+    }
+
     // ─── Tencent 客户端 + Sync 服务 ──────────────────────────────────────────
 
     @Bean
@@ -413,6 +519,13 @@ public class AppConfiguration {
     }
 
     @Bean
+    public VivoTagEventResolver vivoTagEventResolver(@Nullable VivoColdStore vivoColdStore,
+                                                     StringRedisTemplate redisTemplate) {
+        if (vivoColdStore == null) return null;
+        return new VivoTagEventResolver(redisTemplate, vivoColdStore, props.getVivoTagEventSyncRedisPrefix());
+    }
+
+    @Bean
     public ConversionSyncService conversionSyncService(ConversionClient baiduClient,
                                                          RedisHotStore hotStore,
                                                          TencentClient tencentClient,
@@ -420,17 +533,21 @@ public class AppConfiguration {
                                                          @Nullable TagEventResolver tagEventResolver,
                                                          @Nullable HonorTagEventResolver honorTagEventResolver,
                                                          @Nullable KuaishouTagEventResolver kuaishouTagEventResolver,
+                                                         @Nullable VivoTagEventResolver vivoTagEventResolver,
                                                          @Nullable HonorHotStore honorHotStore,
                                                          @Nullable HonorColdStore honorColdStore,
                                                          @Nullable KuaishouHotStore kuaishouHotStore,
                                                          @Nullable KuaishouColdStore kuaishouColdStore,
+                                                         @Nullable VivoHotStore vivoHotStore,
+                                                         @Nullable VivoColdStore vivoColdStore,
                                                          HonorClient honorClient,
-                                                         KuaishouClient kuaishouClient) {
+                                                         KuaishouClient kuaishouClient,
+                                                         @Nullable VivoClient vivoClient) {
         if (coldStore == null) return null;
         return new ConversionSyncService(baiduClient, hotStore, tencentClient, coldStore, tagEventResolver,
-                honorTagEventResolver, kuaishouTagEventResolver,
-                honorHotStore, honorColdStore, kuaishouHotStore, kuaishouColdStore,
-                honorClient, kuaishouClient, props);
+                honorTagEventResolver, kuaishouTagEventResolver, vivoTagEventResolver,
+                honorHotStore, honorColdStore, kuaishouHotStore, kuaishouColdStore, vivoHotStore, vivoColdStore,
+                honorClient, kuaishouClient, vivoClient, props);
     }
 
     @Bean
@@ -491,6 +608,15 @@ public class AppConfiguration {
     }
 
     @Bean
+    public VivoTagEventSyncService vivoTagEventSyncService(@Nullable VivoColdStore vivoColdStore,
+                                                           StringRedisTemplate redisTemplate) {
+        if (vivoColdStore == null) return null;
+        Duration ttl = props.getVivoTagEventSyncInterval().multipliedBy(3);
+        return new VivoTagEventSyncService(vivoColdStore, redisTemplate,
+                props.getVivoTagEventSyncRedisPrefix(), ttl);
+    }
+
+    @Bean
     public AdBidReportStore adBidReportStore(@Nullable DataSource tidbDataSource) {
         if (tidbDataSource == null) return null;
         String mainSchema = resolveDatabaseName(tidbDataSource, props.getTidbUrl());

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

@@ -64,6 +64,22 @@ public class AppProperties {
     private Duration kuaishouTagEventSyncInterval = Duration.ofMinutes(5);
     private String kuaishouTagEventSyncRedisPrefix = "";
 
+    // Vivo Callback
+    private String vivoConversionBaseUrl = "https://marketing-api.vivo.com.cn";
+    private String vivoOauthBaseUrl = "https://open-ad.vivo.com.cn";
+    private String vivoRedisStream = "adx:vivo:events";
+    private boolean vivoCallbackRetryEnabled = false;
+    private Duration vivoCallbackRetryInterval = Duration.ofSeconds(300);
+    private int vivoCallbackRetryLimit = 100;
+    private boolean vivoTagEventSyncEnabled = false;
+    private Duration vivoTagEventSyncInterval = Duration.ofMinutes(5);
+    private String vivoTagEventSyncRedisPrefix = "";
+    private String vivoRedirectUri;
+    private String vivoTokenRedisPrefix = "adx:vivo:token:";
+    private boolean vivoAdvertiserSyncEnabled = false;
+    private Duration vivoAdvertiserSyncInterval = Duration.ofMinutes(10);
+    private String vivoAdvertiserRedisPrefix = "adx:vivo:advertiser:";
+
     // Tencent API
     private String tencentApiBaseUrl = "https://api.e.qq.com/v3.0";
     private String tencentAccessToken;
@@ -458,6 +474,118 @@ public class AppProperties {
         this.kuaishouTagEventSyncRedisPrefix = v;
     }
 
+    public String getVivoConversionBaseUrl() {
+        return vivoConversionBaseUrl;
+    }
+
+    public void setVivoConversionBaseUrl(String v) {
+        this.vivoConversionBaseUrl = v;
+    }
+
+    public String getVivoOauthBaseUrl() {
+        return vivoOauthBaseUrl;
+    }
+
+    public void setVivoOauthBaseUrl(String v) {
+        this.vivoOauthBaseUrl = v;
+    }
+
+    public String getVivoRedisStream() {
+        return vivoRedisStream;
+    }
+
+    public void setVivoRedisStream(String v) {
+        this.vivoRedisStream = v;
+    }
+
+    public boolean isVivoCallbackRetryEnabled() {
+        return vivoCallbackRetryEnabled;
+    }
+
+    public void setVivoCallbackRetryEnabled(boolean v) {
+        this.vivoCallbackRetryEnabled = v;
+    }
+
+    public Duration getVivoCallbackRetryInterval() {
+        return vivoCallbackRetryInterval;
+    }
+
+    public void setVivoCallbackRetryInterval(Duration v) {
+        this.vivoCallbackRetryInterval = v;
+    }
+
+    public int getVivoCallbackRetryLimit() {
+        return vivoCallbackRetryLimit;
+    }
+
+    public void setVivoCallbackRetryLimit(int v) {
+        this.vivoCallbackRetryLimit = v;
+    }
+
+    public boolean isVivoTagEventSyncEnabled() {
+        return vivoTagEventSyncEnabled;
+    }
+
+    public void setVivoTagEventSyncEnabled(boolean v) {
+        this.vivoTagEventSyncEnabled = v;
+    }
+
+    public Duration getVivoTagEventSyncInterval() {
+        return vivoTagEventSyncInterval;
+    }
+
+    public void setVivoTagEventSyncInterval(Duration v) {
+        this.vivoTagEventSyncInterval = v;
+    }
+
+    public String getVivoTagEventSyncRedisPrefix() {
+        return vivoTagEventSyncRedisPrefix;
+    }
+
+    public void setVivoTagEventSyncRedisPrefix(String v) {
+        this.vivoTagEventSyncRedisPrefix = v;
+    }
+
+    public String getVivoRedirectUri() {
+        return vivoRedirectUri;
+    }
+
+    public void setVivoRedirectUri(String v) {
+        this.vivoRedirectUri = v;
+    }
+
+    public String getVivoTokenRedisPrefix() {
+        return vivoTokenRedisPrefix;
+    }
+
+    public void setVivoTokenRedisPrefix(String v) {
+        this.vivoTokenRedisPrefix = v;
+    }
+
+    public boolean isVivoAdvertiserSyncEnabled() {
+        return vivoAdvertiserSyncEnabled;
+    }
+
+    public void setVivoAdvertiserSyncEnabled(boolean v) {
+        this.vivoAdvertiserSyncEnabled = v;
+    }
+
+    public Duration getVivoAdvertiserSyncInterval() {
+        return vivoAdvertiserSyncInterval;
+    }
+
+    public void setVivoAdvertiserSyncInterval(Duration v) {
+        this.vivoAdvertiserSyncInterval = v;
+    }
+
+    public String getVivoAdvertiserRedisPrefix() {
+        return vivoAdvertiserRedisPrefix;
+    }
+
+    public void setVivoAdvertiserRedisPrefix(String v) {
+        this.vivoAdvertiserRedisPrefix = v;
+    }
+
     public String getTencentApiBaseUrl() {
         return tencentApiBaseUrl;
     }

+ 412 - 28
src/main/java/com/adx/tencent/conversionsync/ConversionSyncService.java

@@ -25,6 +25,13 @@ import com.adx.tencent.storage.model.ConversionRecord;
 import com.adx.tencent.storage.model.MediaCallbackRecord;
 import com.adx.tencent.tagsync.TagEventResolver;
 import com.adx.tencent.tencent.TencentClient;
+import com.adx.tencent.vivo.client.VivoClient;
+import com.adx.tencent.vivo.model.VivoBidRecord;
+import com.adx.tencent.vivo.model.VivoMediaCallbackRecord;
+import com.adx.tencent.vivo.model.VivoTagEventRecord;
+import com.adx.tencent.vivo.service.VivoTagEventResolver;
+import com.adx.tencent.vivo.store.VivoColdStore;
+import com.adx.tencent.vivo.store.VivoHotStore;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -61,20 +68,32 @@ public class ConversionSyncService {
     private static final int KS_EVENT_APP_WAKEUP = 84;
     private static final int KS_EVENT_KEY_ACTION = 143;
     private static final int KS_EVENT_THREE_DAY_RETENTION = 103;
+    private static final String VIVO_EVENT_ACTIVATION = "ACTIVATION";
+    private static final String VIVO_EVENT_REGISTER = "REGISTER";
+    private static final String VIVO_EVENT_PAY = "PAY";
+    private static final String VIVO_EVENT_RETENTION_1 = "RETENTION_1";
+    private static final String VIVO_EVENT_RETENTION_3 = "RETENTION_3";
+    private static final String VIVO_EVENT_RETENTION_7 = "RETENTION_7";
+    private static final String VIVO_EVENT_REACTIVATION = "REACTIVATION";
+    private static final String VIVO_EVENT_OTHER = "OTHER";
 
     private final ConversionClient baiduClient;
     private final RedisHotStore hotStore;
     private final HonorHotStore honorHotStore;
     private final KuaishouHotStore kuaishouHotStore;
+    private final VivoHotStore vivoHotStore;
     private final TencentClient tencentClient;
     private final TiDBColdStore coldStore;
     private final HonorColdStore honorColdStore;
     private final KuaishouColdStore kuaishouColdStore;
+    private final VivoColdStore vivoColdStore;
     private final HonorClient honorClient;
     private final KuaishouClient kuaishouClient;
+    private final VivoClient vivoClient;
     private final TagEventResolver tagEventResolver;
     private final HonorTagEventResolver honorTagEventResolver;
     private final KuaishouTagEventResolver kuaishouTagEventResolver;
+    private final VivoTagEventResolver vivoTagEventResolver;
     private final ExecutorService realtimeExecutor;
     private final ExecutorService backfillExecutor;
 
@@ -90,12 +109,16 @@ public class ConversionSyncService {
                                  TagEventResolver tagEventResolver,
                                  HonorTagEventResolver honorTagEventResolver,
                                  KuaishouTagEventResolver kuaishouTagEventResolver,
+                                 VivoTagEventResolver vivoTagEventResolver,
                                  HonorHotStore honorHotStore,
                                  HonorColdStore honorColdStore,
                                  KuaishouHotStore kuaishouHotStore,
                                  KuaishouColdStore kuaishouColdStore,
+                                 VivoHotStore vivoHotStore,
+                                 VivoColdStore vivoColdStore,
                                  HonorClient honorClient,
                                  KuaishouClient kuaishouClient,
+                                 VivoClient vivoClient,
                                  AppProperties props) {
         this.baiduClient = baiduClient;
         this.hotStore = hotStore;
@@ -104,12 +127,16 @@ public class ConversionSyncService {
         this.tagEventResolver = tagEventResolver;
         this.honorTagEventResolver = honorTagEventResolver;
         this.kuaishouTagEventResolver = kuaishouTagEventResolver;
+        this.vivoTagEventResolver = vivoTagEventResolver;
         this.honorHotStore = honorHotStore;
         this.honorColdStore = honorColdStore;
         this.kuaishouHotStore = kuaishouHotStore;
         this.kuaishouColdStore = kuaishouColdStore;
+        this.vivoHotStore = vivoHotStore;
+        this.vivoColdStore = vivoColdStore;
         this.honorClient = honorClient;
         this.kuaishouClient = kuaishouClient;
+        this.vivoClient = vivoClient;
         this.realtimeExecutor = Executors.newFixedThreadPool(
                 REALTIME_CONCURRENCY,
                 r -> {
@@ -221,7 +248,10 @@ public class ConversionSyncService {
         Map<String, KuaishouBidRecord> kuaishouBids = kuaishouHotStore != null
                 ? kuaishouHotStore.findBidsByQks(qkList)
                 : Map.of();
-        return new BidLookup(tencentBids, honorBids, kuaishouBids);
+        Map<String, VivoBidRecord> vivoBids = vivoHotStore != null
+                ? vivoHotStore.findBidsByQks(qkList)
+                : Map.of();
+        return new BidLookup(tencentBids, honorBids, kuaishouBids, vivoBids);
     }
 
     private void backfillTencentAccountIds(List<String> qkList, Map<String, BidRecord> tencentBids) {
@@ -276,8 +306,9 @@ public class ConversionSyncService {
             BidRecord tencentBid = lookup.tencentBids.get(payment.getQk());
             HonorBidRecord honorBid = lookup.honorBids.get(payment.getQk());
             KuaishouBidRecord kuaishouBid = lookup.kuaishouBids.get(payment.getQk());
+            VivoBidRecord vivoBid = lookup.vivoBids.get(payment.getQk());
 
-            if (tencentBid == null && honorBid == null && kuaishouBid == null) {
+            if (tencentBid == null && honorBid == null && kuaishouBid == null && vivoBid == null) {
                 conversionRecords.add(toConversionRecord(payment, null, ""));
                 result.skipped.incrementAndGet();
                 continue;
@@ -286,9 +317,11 @@ public class ConversionSyncService {
             boolean hasTencentBid = tencentBid != null;
             boolean hasHonorBid = honorBid != null;
             boolean hasKuaishouBid = kuaishouBid != null;
+            boolean hasVivoBid = vivoBid != null;
             boolean validTencent = hasTencentBid && "tencent".equals(tencentBid.getMedia());
             boolean validHonor = hasHonorBid && "honor".equals(honorBid.getMedia());
             boolean validKuaishou = hasKuaishouBid && "kuaishou".equals(kuaishouBid.getMedia());
+            boolean validVivo = hasVivoBid && "vivo".equals(vivoBid.getMedia());
 
             if (hasTencentBid && !validTencent) {
                 log.warn("[ConversionSync] 腾讯 bid media 非法 | qk={} | media={}", payment.getQk(), tencentBid.getMedia());
@@ -302,7 +335,11 @@ public class ConversionSyncService {
                 log.warn("[ConversionSync] 快手 bid media 非法 | qk={} | media={}", payment.getQk(), kuaishouBid.getMedia());
             }
 
-            int matchedMediaCount = (validTencent ? 1 : 0) + (validHonor ? 1 : 0) + (validKuaishou ? 1 : 0);
+            if (hasVivoBid && !validVivo) {
+                log.warn("[ConversionSync] vivo bid media 非法 | qk={} | media={}", payment.getQk(), vivoBid.getMedia());
+            }
+
+            int matchedMediaCount = (validTencent ? 1 : 0) + (validHonor ? 1 : 0) + (validKuaishou ? 1 : 0) + (validVivo ? 1 : 0);
             if (matchedMediaCount > 1) {
                 log.warn("[ConversionSync] qk 同时命中多媒体,跳过分发 | qk={} | act={} | date={}",
                         payment.getQk(), payment.getAct(), payment.getDate());
@@ -323,34 +360,47 @@ public class ConversionSyncService {
                 continue;
             }
 
-            if (!validHonor) {
-                if (validKuaishou) {
-                    conversionRecords.add(toKuaishouConversionRecord(payment, kuaishouBid));
-                    result.matched.incrementAndGet();
-                    log.info("[ConversionSync] 匹配到快手转化 | qk={} | act={} | deviceId={} | date={} | payment={}",
-                            payment.getQk(), payment.getAct(), payment.getDeviceId(), payment.getDate(), payment.getPayment());
+            if (validHonor) {
+                conversionRecords.add(toHonorConversionRecord(payment, honorBid));
+                result.matched.incrementAndGet();
+                log.info("[ConversionSync] 匹配到荣耀转化 | qk={} | act={} | deviceId={} | date={} | payment={}",
+                        payment.getQk(), payment.getAct(), payment.getDeviceId(), payment.getDate(), payment.getPayment());
 
-                    List<CallbackTask> kuaishouTasks = buildKuaishouCallbackTasks(payment, kuaishouBid);
-                    if (!kuaishouTasks.isEmpty()) {
-                        callbackTasks.addAll(kuaishouTasks);
-                    }
-                    continue;
+                CallbackTask honorTask = buildHonorCallbackTask(payment, honorBid);
+                if (honorTask != null) {
+                    callbackTasks.add(honorTask);
                 }
+                continue;
+            }
 
-                conversionRecords.add(toConversionRecord(payment, hasTencentBid ? tencentBid : null, hasTencentBid ? tencentBid.getMedia() : ""));
-                result.skipped.incrementAndGet();
+            if (validKuaishou) {
+                conversionRecords.add(toKuaishouConversionRecord(payment, kuaishouBid));
+                result.matched.incrementAndGet();
+                log.info("[ConversionSync] 匹配到快手转化 | qk={} | act={} | deviceId={} | date={} | payment={}",
+                        payment.getQk(), payment.getAct(), payment.getDeviceId(), payment.getDate(), payment.getPayment());
+
+                List<CallbackTask> kuaishouTasks = buildKuaishouCallbackTasks(payment, kuaishouBid);
+                if (!kuaishouTasks.isEmpty()) {
+                    callbackTasks.addAll(kuaishouTasks);
+                }
                 continue;
             }
 
-            conversionRecords.add(toHonorConversionRecord(payment, honorBid));
-            result.matched.incrementAndGet();
-            log.info("[ConversionSync] 匹配到荣耀转化 | qk={} | act={} | deviceId={} | date={} | payment={}",
-                    payment.getQk(), payment.getAct(), payment.getDeviceId(), payment.getDate(), payment.getPayment());
+            if (validVivo) {
+                conversionRecords.add(toVivoConversionRecord(payment, vivoBid));
+                result.matched.incrementAndGet();
+                log.info("[ConversionSync] 匹配到 vivo 转化 | qk={} | act={} | deviceId={} | date={} | payment={}",
+                        payment.getQk(), payment.getAct(), payment.getDeviceId(), payment.getDate(), payment.getPayment());
 
-            CallbackTask honorTask = buildHonorCallbackTask(payment, honorBid);
-            if (honorTask != null) {
-                callbackTasks.add(honorTask);
+                List<CallbackTask> vivoTasks = buildVivoCallbackTasks(payment, vivoBid);
+                if (!vivoTasks.isEmpty()) {
+                    callbackTasks.addAll(vivoTasks);
+                }
+                continue;
             }
+
+            conversionRecords.add(toConversionRecord(payment, hasTencentBid ? tencentBid : null, hasTencentBid ? tencentBid.getMedia() : ""));
+            result.skipped.incrementAndGet();
         }
     }
 
@@ -426,6 +476,30 @@ public class ConversionSyncService {
         return tasks;
     }
 
+    private List<CallbackTask> buildVivoCallbackTasks(PaymentInfo payment, VivoBidRecord bid) {
+        if (vivoColdStore == null && vivoTagEventResolver == null) {
+            return List.of();
+        }
+
+        List<VivoTagEventRecord> tagEvents = resolveVivoTagEvents(bid.getTagId(), payment.getAct());
+        if (tagEvents.isEmpty()) {
+            log.warn("[ConversionSync] vivo eventType 未配置且无兜底映射 | qk={} | tagId={} | act={}",
+                    payment.getQk(), bid.getTagId(), payment.getAct());
+            return List.of();
+        }
+
+        List<CallbackTask> tasks = new ArrayList<>(tagEvents.size());
+        for (VivoTagEventRecord tagEvent : tagEvents) {
+            tasks.add(CallbackTask.vivo(
+                    payment,
+                    bid,
+                    vivoCallbackDedupeKey(payment, bid, tagEvent.getEventType()),
+                    tagEvent
+            ));
+        }
+        return tasks;
+    }
+
     private Integer resolveHonorConversionIdFromDb(String tagId, int baiduAct) {
         if (honorColdStore == null) {
             return null;
@@ -452,6 +526,20 @@ public class ConversionSyncService {
         return fallback;
     }
 
+    private List<VivoTagEventRecord> resolveVivoTagEvents(String tagId, int baiduAct) {
+        List<VivoTagEventRecord> configured = resolveConfiguredVivoTagEvents(tagId, baiduAct);
+        if (!configured.isEmpty()) {
+            return configured;
+        }
+
+        List<VivoTagEventRecord> fallback = buildVivoFallbackTagEvents(tagId, baiduAct);
+        if (!fallback.isEmpty()) {
+            log.warn("[ConversionSync] vivo eventType 未配置, 启用兜底映射 | tagId={} | act={} | fallbackEventTypes={}",
+                    tagId, baiduAct, fallback.stream().map(VivoTagEventRecord::getEventType).toList());
+        }
+        return fallback;
+    }
+
     private List<KuaishouTagEventRecord> resolveConfiguredKuaishouTagEvents(String tagId, int baiduAct) {
         if (tagId == null || tagId.isBlank()) {
             return List.of();
@@ -514,6 +602,67 @@ public class ConversionSyncService {
         return records;
     }
 
+    private List<VivoTagEventRecord> resolveConfiguredVivoTagEvents(String tagId, int baiduAct) {
+        if (tagId == null || tagId.isBlank()) {
+            return List.of();
+        }
+        try {
+            if (vivoTagEventResolver != null) {
+                List<String> eventTypes = vivoTagEventResolver.resolveEventTypes(tagId, baiduAct);
+                if (eventTypes != null && !eventTypes.isEmpty()) {
+                    List<VivoTagEventRecord> records = new ArrayList<>(eventTypes.size());
+                    for (String eventType : eventTypes) {
+                        records.add(buildVivoTagEvent(tagId, baiduAct, eventType));
+                    }
+                    return records;
+                }
+                return List.of();
+            }
+            if (vivoColdStore == null) {
+                return List.of();
+            }
+            List<VivoTagEventRecord> records = vivoColdStore.getTagEvents(tagId, baiduAct);
+            if (records == null || records.isEmpty()) {
+                records = vivoColdStore.getTagEvents(tagId, 0);
+            }
+            return records == null ? List.of() : records;
+        } catch (Exception e) {
+            log.warn("[ConversionSync] 查询 vivo tag event 失败 | tagId={} | act={} | error={}",
+                    tagId, baiduAct, e.getMessage(), e);
+            return List.of();
+        }
+    }
+
+    private static VivoTagEventRecord buildVivoTagEvent(String tagId, int baiduAct, String eventType) {
+        VivoTagEventRecord record = new VivoTagEventRecord();
+        record.setTagId(tagId);
+        record.setBaiduAct(baiduAct);
+        record.setEventType(eventType);
+        return record;
+    }
+
+    private static List<VivoTagEventRecord> buildVivoFallbackTagEvents(String tagId, int baiduAct) {
+        List<String> eventTypes = switch (baiduAct) {
+            case 1 -> List.of(VIVO_EVENT_ACTIVATION);
+            case 2, 5 -> List.of(VIVO_EVENT_PAY);
+            case 3 -> List.of(VIVO_EVENT_REGISTER, VIVO_EVENT_ACTIVATION);
+            case 4 -> List.of(VIVO_EVENT_RETENTION_1);
+            case 6, 2001 -> List.of(VIVO_EVENT_REACTIVATION);
+            case 7 -> List.of(VIVO_EVENT_OTHER);
+            case 8 -> List.of(VIVO_EVENT_RETENTION_7);
+            case 9 -> List.of(VIVO_EVENT_RETENTION_3);
+            default -> List.of();
+        };
+        if (eventTypes.isEmpty()) {
+            return List.of();
+        }
+        List<VivoTagEventRecord> records = new ArrayList<>(eventTypes.size());
+        for (String eventType : eventTypes) {
+            records.add(buildVivoTagEvent(tagId, baiduAct, eventType));
+        }
+        return records;
+    }
+
     // ─── Step 3: 统一落百度转化明细 ───────────────────────────────────────────
 
     private void saveConversions(List<ConversionRecord> records) {
@@ -534,6 +683,7 @@ public class ConversionSyncService {
         existing.addAll(collectTencentSuccessfulCallbackKeys(callbackTasks));
         existing.addAll(collectHonorSuccessfulCallbackKeys(callbackTasks));
         existing.addAll(collectKuaishouSuccessfulCallbackKeys(callbackTasks));
+        existing.addAll(collectVivoSuccessfulCallbackKeys(callbackTasks));
 
         List<CallbackTask> pending = new ArrayList<>(callbackTasks.size());
         for (CallbackTask task : callbackTasks) {
@@ -623,6 +773,30 @@ public class ConversionSyncService {
         }
     }
 
+    private Set<String> collectVivoSuccessfulCallbackKeys(List<CallbackTask> callbackTasks) {
+        if (vivoColdStore == null) {
+            return Set.of();
+        }
+
+        Set<String> keys = new HashSet<>();
+        for (CallbackTask task : callbackTasks) {
+            if (task.isVivo()) {
+                keys.add(task.callbackKey);
+            }
+        }
+
+        if (keys.isEmpty()) {
+            return Set.of();
+        }
+
+        try {
+            return vivoColdStore.successfulCallbackKeys(keys);
+        } catch (Exception e) {
+            log.error("[ConversionSync] vivo dedup check error: size={}, {}", keys.size(), e.getMessage(), e);
+            return Set.of();
+        }
+    }
+
     // ─── Step 5: 并发回传媒体 ───────────────────────────────────────────────
 
     private List<CallbackDispatchResult> dispatchCallbacks(List<CallbackTask> tasks,
@@ -683,6 +857,17 @@ public class ConversionSyncService {
             return CallbackDispatchResult.kuaishou(task, toCallbackRecord(task, kuaishouRecord));
         }
 
+        if (task.isVivo()) {
+            int deductionRate = resolveVivoDeductionRate(task.vivoBid);
+            if (handleVivoDeduction(task)) {
+                return CallbackDispatchResult.vivo(task, toCallbackRecord(task, buildDeductedVivoCallbackRecord(task, deductionRate)));
+            }
+            VivoMediaCallbackRecord vivoRecord = vivoClient.sendConversion(
+                    task.vivoBid, task.payment, task.vivoTagEvent, true);
+            vivoRecord.setDeductionRate(deductionRate);
+            return CallbackDispatchResult.vivo(task, toCallbackRecord(task, vivoRecord));
+        }
+
         return null;
     }
 
@@ -696,11 +881,14 @@ public class ConversionSyncService {
         List<MediaCallbackRecord> tencentRecords = new ArrayList<>();
         List<HonorMediaCallbackRecord> honorRecords = new ArrayList<>();
         List<KuaishouMediaCallbackRecord> kuaishouRecords = new ArrayList<>();
+        List<VivoMediaCallbackRecord> vivoRecords = new ArrayList<>();
         for (CallbackDispatchResult result : results) {
             if (result.isHonor()) {
                 honorRecords.add(toHonorCallbackRecord(result.task, result.record));
             } else if (result.isKuaishou()) {
                 kuaishouRecords.add(toKuaishouCallbackRecord(result.task, result.record));
+            } else if (result.isVivo()) {
+                vivoRecords.add(toVivoCallbackRecord(result.task, result.record));
             } else {
                 tencentRecords.add(result.record);
             }
@@ -729,6 +917,14 @@ public class ConversionSyncService {
                 log.error("[ConversionSync] batch save kuaishou callbacks failed: size={}, {}", kuaishouRecords.size(), e.getMessage(), e);
             }
         }
+
+        if (vivoColdStore != null && !vivoRecords.isEmpty()) {
+            try {
+                vivoColdStore.saveMediaCallbacks(vivoRecords);
+            } catch (Exception e) {
+                log.error("[ConversionSync] batch save vivo callbacks failed: size={}, {}", vivoRecords.size(), e.getMessage(), e);
+            }
+        }
     }
 
     // ─── 工具方法 ────────────────────────────────────────────────────────────
@@ -811,6 +1007,26 @@ public class ConversionSyncService {
         return record;
     }
 
+    private static ConversionRecord toVivoConversionRecord(PaymentInfo payment, VivoBidRecord bid) {
+        ConversionRecord record = new ConversionRecord();
+        record.setDedupeKey(conversionDedupeKey(payment));
+        record.setQk(payment.getQk());
+        record.setMedia("vivo");
+        record.setTagId(bid != null ? bid.getTagId() : null);
+        record.setDate(payment.getDate());
+        record.setAppSid(payment.getAppSid());
+        record.setCustomerName(payment.getCustomerName());
+        record.setDeviceId(payment.getDeviceId());
+        record.setConv(payment.getConv());
+        record.setPayment(payment.getPayment());
+        record.setGmv(payment.getGmv());
+        record.setAct(payment.getAct());
+        record.setTu(payment.getTu());
+        record.setClkTime(payment.getClkTime());
+        record.setCreatedAt(Instant.now());
+        return record;
+    }
+
     static String conversionDedupeKey(PaymentInfo payment) {
         return String.format("%s:%s:%s:%d",
                 payment.getQk(),
@@ -860,6 +1076,16 @@ public class ConversionSyncService {
                 eventType);
     }
 
+    static String vivoCallbackDedupeKey(PaymentInfo payment, VivoBidRecord bid, String eventType) {
+        return String.format("vivo:%s:%s:%s:%s:%d:%s",
+                payment.getQk(),
+                payment.getDate(),
+                payment.getDeviceId(),
+                bid.getTagId(),
+                payment.getAct(),
+                eventType);
+    }
+
     private DeductionHandling handleTencentDeduction(CallbackTask task) {
         if (!task.isTencent() || task.tencentBid == null) {
             return DeductionHandling.SEND;
@@ -986,6 +1212,57 @@ public class ConversionSyncService {
         return record;
     }
 
+    private boolean handleVivoDeduction(CallbackTask task) {
+        if (!task.isVivo() || task.vivoBid == null || vivoHotStore == null) {
+            return false;
+        }
+        int deductionRate = resolveVivoDeductionRate(task.vivoBid);
+        if (task.payment.getAct() == 2001 || deductionRate <= 0) {
+            return false;
+        }
+        if (!vivoHotStore.markConversionSeen(task.callbackKey)) {
+            log.info("[ConversionSync] vivo 转化已见过,跳过重复扣量判断 | callbackKey={} | qk={} | accountId={}",
+                    task.callbackKey, task.payment.getQk(), safe(task.vivoBid.getAccountId()));
+            return false;
+        }
+        VivoHotStore.DeductionDecision decision = vivoHotStore.evaluateDeduction(
+                task.vivoBid.getAccountId(),
+                task.payment.getAct(),
+                task.payment.getDate(),
+                deductionRate
+        );
+        boolean deduct = decision.isShouldDeduct();
+        log.info("[ConversionSync] vivo 转化扣量判定 | qk={} | act={} | accountId={} | tagId={} | rate={} | totalSeen={} | totalDeducted={} | callbackKey={} | deduct={}",
+                task.payment.getQk(), task.payment.getAct(), safe(task.vivoBid.getAccountId()),
+                safe(task.vivoBid.getTagId()), deductionRate,
+                decision.getTotalSeen(), decision.getTotalDeducted(), task.callbackKey, deduct);
+        return deduct;
+    }
+
+    private VivoMediaCallbackRecord buildDeductedVivoCallbackRecord(CallbackTask task, int deductionRate) {
+        VivoMediaCallbackRecord record = new VivoMediaCallbackRecord();
+        Instant now = Instant.now();
+        record.setDedupeKey(task.callbackKey);
+        record.setQk(task.payment.getQk());
+        record.setTagId(task.vivoBid.getTagId());
+        record.setCallbackUrl("");
+        record.setEventType(task.vivoTagEvent.getEventType());
+        record.setEventTimeMs(now.toEpochMilli());
+        record.setPurchase(Math.max(task.payment.getPayment(), 0D));
+        record.setDeductionRate(deductionRate);
+        record.setDirectMatch(false);
+        record.setDispatchStatus(VivoMediaCallbackRecord.DISPATCH_STATUS_DEDUCTED);
+        record.setStatus(0);
+        record.setOk(false);
+        record.setAttempt(1);
+        record.setErrorMessage("deducted_by_rate");
+        record.setCreatedAt(now);
+        log.info("[ConversionSync] vivo 转化扣量 | qk={} | act={} | rate={} | tagId={} | accountId={} | callbackKey={}",
+                task.payment.getQk(), task.payment.getAct(), deductionRate,
+                task.vivoBid.getTagId(), safe(task.vivoBid.getAccountId()), task.callbackKey);
+        return record;
+    }
+
     private static String resolveTrackingVersion(BidRecord bid) {
         if (bid == null || bid.getMediaParams() == null) {
             return "v1";
@@ -1022,6 +1299,20 @@ public class ConversionSyncService {
         return 0;
     }
 
+    private int resolveVivoDeductionRate(VivoBidRecord bid) {
+        if (bid == null || bid.getMediaParams() == null) {
+            return 0;
+        }
+        String rawRate = bid.getMediaParams().get(VivoBidRecord.DEDUCTION_RATE_KEY);
+        if (rawRate != null && !rawRate.isBlank()) {
+            try {
+                return sanitizeDeductionRate(Integer.parseInt(rawRate.trim()));
+            } catch (NumberFormatException ignored) {
+            }
+        }
+        return 0;
+    }
+
     private static String safe(String value) {
         return value == null ? "" : value;
     }
@@ -1074,6 +1365,31 @@ public class ConversionSyncService {
         return callback;
     }
 
+    private static VivoMediaCallbackRecord toVivoCallbackRecord(CallbackTask task, MediaCallbackRecord record) {
+        VivoMediaCallbackRecord callback = new VivoMediaCallbackRecord();
+        callback.setDedupeKey(record.getDedupeKey());
+        callback.setQk(record.getQk());
+        callback.setTagId(task.tagId);
+        callback.setCallbackUrl(record.getCallbackUrl());
+        callback.setEventType(record.getEventType() == 0 ? null : String.valueOf(record.getEventType()));
+        if (task.vivoTagEvent != null) {
+            callback.setEventType(task.vivoTagEvent.getEventType());
+        }
+        callback.setEventTimeMs(record.getEventTimeMs());
+        callback.setPurchase(record.getPurchase());
+        callback.setDeductionRate(parseVivoDeductionRate(task.vivoBid));
+        callback.setStatus(record.getStatus());
+        callback.setOk(record.isOk());
+        callback.setAttempt(record.getAttempt());
+        callback.setResponseBody(record.getResponseBody());
+        callback.setErrorMessage(record.getErrorMessage());
+        callback.setRequestBody(record.getRequestBody());
+        callback.setDispatchStatus(record.getDispatchStatus());
+        callback.setDirectMatch(!VivoMediaCallbackRecord.DISPATCH_STATUS_DEDUCTED.equals(record.getDispatchStatus()));
+        callback.setCreatedAt(record.getCreatedAt());
+        return callback;
+    }
+
     private static MediaCallbackRecord toCallbackRecord(CallbackTask task, HonorMediaCallbackRecord honorRecord) {
         MediaCallbackRecord callbackRecord = new MediaCallbackRecord();
         callbackRecord.setDedupeKey(task.callbackKey);
@@ -1115,6 +1431,27 @@ public class ConversionSyncService {
         return callbackRecord;
     }
 
+    private static MediaCallbackRecord toCallbackRecord(CallbackTask task, VivoMediaCallbackRecord vivoRecord) {
+        MediaCallbackRecord callbackRecord = new MediaCallbackRecord();
+        callbackRecord.setDedupeKey(task.callbackKey);
+        callbackRecord.setMedia("vivo");
+        callbackRecord.setQk(vivoRecord.getQk());
+        callbackRecord.setCallbackUrl(vivoRecord.getCallbackUrl());
+        callbackRecord.setEventType(0);
+        callbackRecord.setEventTimeMs(vivoRecord.getEventTimeMs());
+        callbackRecord.setPurchase(vivoRecord.getPurchase());
+        callbackRecord.setStatus(vivoRecord.getStatus());
+        callbackRecord.setOk(vivoRecord.isOk());
+        callbackRecord.setAttempt(vivoRecord.getAttempt());
+        callbackRecord.setResponseBody(vivoRecord.getResponseBody());
+        callbackRecord.setErrorMessage(vivoRecord.getErrorMessage());
+        callbackRecord.setRequestBody(vivoRecord.getRequestBody());
+        callbackRecord.setDispatchStatus(vivoRecord.getDispatchStatus());
+        callbackRecord.setTrackingVersion(vivoRecord.isDirectMatch() ? "direct" : "deducted");
+        callbackRecord.setCreatedAt(vivoRecord.getCreatedAt());
+        return callbackRecord;
+    }
+
     private static int parseKuaishouDeductionRate(KuaishouBidRecord bid) {
         if (bid == null || bid.getMediaParams() == null) {
             return 0;
@@ -1134,19 +1471,41 @@ public class ConversionSyncService {
         }
     }
 
+    private static int parseVivoDeductionRate(VivoBidRecord bid) {
+        if (bid == null || bid.getMediaParams() == null) {
+            return 0;
+        }
+        String rawRate = bid.getMediaParams().get(VivoBidRecord.DEDUCTION_RATE_KEY);
+        if (rawRate == null || rawRate.isBlank()) {
+            return 0;
+        }
+        try {
+            int rate = Integer.parseInt(rawRate.trim());
+            if (rate < 0) {
+                return 0;
+            }
+            return Math.min(rate, 100);
+        } catch (NumberFormatException ignored) {
+            return 0;
+        }
+    }
+
     // ─── 内部数据结构 ────────────────────────────────────────────────────────
 
     private static class BidLookup {
         private final Map<String, BidRecord> tencentBids;
         private final Map<String, HonorBidRecord> honorBids;
         private final Map<String, KuaishouBidRecord> kuaishouBids;
+        private final Map<String, VivoBidRecord> vivoBids;
 
         private BidLookup(Map<String, BidRecord> tencentBids,
                           Map<String, HonorBidRecord> honorBids,
-                          Map<String, KuaishouBidRecord> kuaishouBids) {
+                          Map<String, KuaishouBidRecord> kuaishouBids,
+                          Map<String, VivoBidRecord> vivoBids) {
             this.tencentBids = tencentBids;
             this.honorBids = honorBids;
             this.kuaishouBids = kuaishouBids;
+            this.vivoBids = vivoBids;
         }
     }
 
@@ -1161,6 +1520,8 @@ public class ConversionSyncService {
         private final Integer honorConversionId;
         private final KuaishouBidRecord kuaishouBid;
         private final KuaishouTagEventRecord kuaishouTagEvent;
+        private final VivoBidRecord vivoBid;
+        private final VivoTagEventRecord vivoTagEvent;
 
         private CallbackTask(String media,
                              PaymentInfo payment,
@@ -1171,7 +1532,9 @@ public class ConversionSyncService {
                              HonorBidRecord honorBid,
                              Integer honorConversionId,
                              KuaishouBidRecord kuaishouBid,
-                             KuaishouTagEventRecord kuaishouTagEvent) {
+                             KuaishouTagEventRecord kuaishouTagEvent,
+                             VivoBidRecord vivoBid,
+                             VivoTagEventRecord vivoTagEvent) {
             this.media = media;
             this.payment = payment;
             this.tagId = tagId;
@@ -1182,27 +1545,36 @@ public class ConversionSyncService {
             this.honorConversionId = honorConversionId;
             this.kuaishouBid = kuaishouBid;
             this.kuaishouTagEvent = kuaishouTagEvent;
+            this.vivoBid = vivoBid;
+            this.vivoTagEvent = vivoTagEvent;
         }
 
         private static CallbackTask tencent(PaymentInfo payment,
                                             BidRecord bid,
                                             String callbackKey,
                                             String actionType) {
-            return new CallbackTask("tencent", payment, bid.getTagId(), callbackKey, bid, actionType, null, null, null, null);
+            return new CallbackTask("tencent", payment, bid.getTagId(), callbackKey, bid, actionType, null, null, null, null, null, null);
         }
 
         private static CallbackTask honor(PaymentInfo payment,
                                           HonorBidRecord bid,
                                           String callbackKey,
                                           Integer conversionId) {
-            return new CallbackTask("honor", payment, bid.getTagId(), callbackKey, null, null, bid, conversionId, null, null);
+            return new CallbackTask("honor", payment, bid.getTagId(), callbackKey, null, null, bid, conversionId, null, null, null, null);
         }
 
         private static CallbackTask kuaishou(PaymentInfo payment,
                                              KuaishouBidRecord bid,
                                              String callbackKey,
                                              KuaishouTagEventRecord tagEvent) {
-            return new CallbackTask("kuaishou", payment, bid.getTagId(), callbackKey, null, null, null, null, bid, tagEvent);
+            return new CallbackTask("kuaishou", payment, bid.getTagId(), callbackKey, null, null, null, null, bid, tagEvent, null, null);
+        }
+
+        private static CallbackTask vivo(PaymentInfo payment,
+                                         VivoBidRecord bid,
+                                         String callbackKey,
+                                         VivoTagEventRecord tagEvent) {
+            return new CallbackTask("vivo", payment, bid.getTagId(), callbackKey, null, null, null, null, null, null, bid, tagEvent);
         }
 
         private boolean isTencent() {
@@ -1216,6 +1588,10 @@ public class ConversionSyncService {
         private boolean isKuaishou() {
             return "kuaishou".equals(media);
         }
+
+        private boolean isVivo() {
+            return "vivo".equals(media);
+        }
     }
 
     private static class CallbackDispatchResult {
@@ -1241,6 +1617,10 @@ public class ConversionSyncService {
             return new CallbackDispatchResult("kuaishou", task, record);
         }
 
+        private static CallbackDispatchResult vivo(CallbackTask task, MediaCallbackRecord record) {
+            return new CallbackDispatchResult("vivo", task, record);
+        }
+
         private boolean isHonor() {
             return "honor".equals(media);
         }
@@ -1248,6 +1628,10 @@ public class ConversionSyncService {
         private boolean isKuaishou() {
             return "kuaishou".equals(media);
         }
+
+        private boolean isVivo() {
+            return "vivo".equals(media);
+        }
     }
 
     private enum DeductionHandling {

+ 2 - 1
src/main/java/com/adx/tencent/report/AdBidReportStore.java

@@ -302,7 +302,8 @@ public class AdBidReportStore {
     private enum MediaTable {
         TENCENT("tencent_ad_bid_events", "tencent_ad_bid_events_history_", "tencent_ad_bid_events_daily_stats", "腾讯"),
         HONOR("honor_ad_bid_events", "honor_ad_bid_events_history_", "honor_ad_bid_events_daily_stats", "荣耀"),
-        KUAISHOU("kuaishou_ad_bid_events", "kuaishou_ad_bid_events_history_", "kuaishou_ad_bid_events_daily_stats", "快手");
+        KUAISHOU("kuaishou_ad_bid_events", "kuaishou_ad_bid_events_history_", "kuaishou_ad_bid_events_daily_stats", "快手"),
+        VIVO("vivo_ad_bid_events", "vivo_ad_bid_events_history_", "vivo_ad_bid_events_daily_stats", "vivo");
 
         private final String hotTable;
         private final String historyPrefix;

+ 404 - 0
src/main/java/com/adx/tencent/vivo/client/VivoClient.java

@@ -0,0 +1,404 @@
+package com.adx.tencent.vivo.client;
+
+import com.adx.tencent.baidu.model.PaymentInfo;
+import com.adx.tencent.vivo.model.VivoBidRecord;
+import com.adx.tencent.vivo.model.VivoMediaCallbackRecord;
+import com.adx.tencent.vivo.model.VivoTagEventRecord;
+import com.adx.tencent.vivo.service.VivoAuthService;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.net.URI;
+import java.net.URLEncoder;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.UUID;
+
+public class VivoClient {
+
+    private static final Logger log = LoggerFactory.getLogger(VivoClient.class);
+    private static final String DEFAULT_ENDPOINT = "https://marketing-api.vivo.com.cn/openapi/v2/advertiser/behavior/upload";
+
+    private final String callbackEndpoint;
+    private final VivoAuthService authService;
+    private final ObjectMapper objectMapper;
+    private final HttpClient httpClient;
+
+    public VivoClient(String callbackEndpoint, VivoAuthService authService, ObjectMapper objectMapper) {
+        this.callbackEndpoint = (callbackEndpoint == null || callbackEndpoint.isBlank())
+                ? DEFAULT_ENDPOINT
+                : trimTrailingSlash(callbackEndpoint) + "/openapi/v2/advertiser/behavior/upload";
+        this.authService = authService;
+        this.objectMapper = objectMapper;
+        this.httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
+    }
+
+    public VivoMediaCallbackRecord sendConversion(VivoBidRecord bid,
+                                                  PaymentInfo payment,
+                                                  VivoTagEventRecord tagEvent,
+                                                  boolean directMatch) {
+        VivoMediaCallbackRecord record = buildBaseRecord(bid, payment, tagEvent, directMatch);
+        try {
+            RequestPayload payload = buildPayload(bid, payment, tagEvent, directMatch);
+            String accessToken = authService.getAccessTokenForAdvertiser(payload.advertiserId);
+            long timestamp = System.currentTimeMillis();
+            String nonce = UUID.randomUUID().toString().replace("-", "");
+            String callbackUrl = buildCallbackUrl(payload.advertiserId, accessToken, timestamp, nonce);
+            String requestBody = objectMapper.writeValueAsString(payload.body);
+
+            record.setCallbackUrl(callbackUrl);
+            record.setRequestBody(requestBody);
+            record.setEventTimeMs(payload.eventTimeMs);
+
+            log.info("[Vivo] callback请求: qk={} tagId={} eventType={} directMatch={} advertiserId={} srcId={} url={} body={}",
+                    payment.getQk(), bid.getTagId(), tagEvent.getEventType(), directMatch,
+                    payload.advertiserId, payload.eventSourceId, callbackUrl, requestBody);
+
+            HttpResponse<String> response = sendPost(callbackUrl, requestBody);
+            record.setStatus(response.statusCode());
+            record.setResponseBody(response.body());
+            applyResponse(record, response.statusCode(), response.body());
+            log.info("[Vivo] callback结果: qk={} tagId={} eventType={} status={} ok={} response={}",
+                    payment.getQk(), bid.getTagId(), tagEvent.getEventType(),
+                    record.getStatus(), record.isOk(), response.body());
+        } catch (Exception e) {
+            record.setOk(false);
+            record.setErrorMessage(e.getMessage());
+            log.warn("[Vivo] callback失败: qk={} tagId={} eventType={} error={}",
+                    payment.getQk(), bid.getTagId(), tagEvent.getEventType(), e.getMessage(), e);
+        }
+        return record;
+    }
+
+    public VivoMediaCallbackRecord retryCallback(VivoMediaCallbackRecord original) throws Exception {
+        VivoMediaCallbackRecord retry = copy(original);
+        retry.setAttempt(original.getAttempt() + 1);
+        retry.setCreatedAt(Instant.now());
+
+        String advertiserId = queryParam(original.getCallbackUrl(), "advertiser_id");
+        if (advertiserId == null || advertiserId.isBlank()) {
+            throw new IllegalStateException("missing advertiser_id in vivo callback url");
+        }
+        String accessToken = authService.getAccessTokenForAdvertiser(advertiserId);
+        long timestamp = System.currentTimeMillis();
+        String nonce = UUID.randomUUID().toString().replace("-", "");
+        String callbackUrl = buildCallbackUrl(advertiserId, accessToken, timestamp, nonce);
+        String requestBody = original.getRequestBody();
+
+        retry.setCallbackUrl(callbackUrl);
+        log.info("[Vivo] callback重试请求: qk={} tagId={} eventType={} attempt={} url={} body={}",
+                original.getQk(), original.getTagId(), original.getEventType(),
+                retry.getAttempt(), callbackUrl, requestBody);
+
+        HttpResponse<String> response = sendPost(callbackUrl, requestBody);
+        retry.setStatus(response.statusCode());
+        retry.setResponseBody(response.body());
+        applyResponse(retry, response.statusCode(), response.body());
+        log.info("[Vivo] callback重试结果: qk={} tagId={} eventType={} attempt={} status={} ok={} response={}",
+                retry.getQk(), retry.getTagId(), retry.getEventType(), retry.getAttempt(),
+                retry.getStatus(), retry.isOk(), retry.getResponseBody());
+        return retry;
+    }
+
+    private VivoMediaCallbackRecord buildBaseRecord(VivoBidRecord bid,
+                                                    PaymentInfo payment,
+                                                    VivoTagEventRecord tagEvent,
+                                                    boolean directMatch) {
+        VivoMediaCallbackRecord record = new VivoMediaCallbackRecord();
+        record.setQk(payment.getQk());
+        record.setTagId(bid.getTagId());
+        record.setEventType(tagEvent.getEventType());
+        record.setPurchase(resolvePurchaseAmount(payment));
+        record.setDirectMatch(directMatch);
+        record.setDispatchStatus(directMatch
+                ? VivoMediaCallbackRecord.DISPATCH_STATUS_SENT
+                : VivoMediaCallbackRecord.DISPATCH_STATUS_DEDUCTED);
+        record.setAttempt(1);
+        record.setCreatedAt(Instant.now());
+        return record;
+    }
+
+    private RequestPayload buildPayload(VivoBidRecord bid,
+                                        PaymentInfo payment,
+                                        VivoTagEventRecord tagEvent,
+                                        boolean directMatch) {
+        Map<String, String> params = bid.getMediaParams() == null ? Map.of() : bid.getMediaParams();
+        String advertiserId = firstNonBlank(
+                bid.getAccountId(),
+                params.get("accountId"),
+                params.get("accountid"),
+                params.get("advertiser_id"),
+                params.get("advertiserId"));
+        if (advertiserId == null || advertiserId.isBlank()) {
+            throw new IllegalStateException("missing vivo advertiserId");
+        }
+        String eventSourceId = firstNonBlank(
+                params.get(VivoBidRecord.EVENT_SOURCE_ID_KEY),
+                params.get("srcId"));
+        if (eventSourceId == null || eventSourceId.isBlank()) {
+            throw new IllegalStateException("missing vivo eventSourceId");
+        }
+        String pkgName = firstNonBlank(
+                bid.getPackageName(),
+                params.get(VivoBidRecord.PACKAGE_NAME_KEY),
+                params.get("packageName"));
+        if (pkgName == null || pkgName.isBlank()) {
+            throw new IllegalStateException("missing vivo pkgName");
+        }
+
+        UserIdentity identity = resolveUserIdentity(bid.getPlatform(), params);
+        if (identity == null) {
+            throw new IllegalStateException("missing vivo user identity");
+        }
+
+        long eventTimeMs = resolveEventTimeMs(params, payment);
+        Map<String, Object> dataItem = new LinkedHashMap<>();
+        dataItem.put("userIdType", identity.type);
+        dataItem.put("userId", identity.value);
+        dataItem.put("cvType", tagEvent.getEventType());
+        String clickId = firstNonBlank(params.get(VivoBidRecord.CLICK_ID_KEY), bid.getMediaTraceId());
+        if (clickId != null && !clickId.isBlank()) {
+            dataItem.put("clickId", clickId);
+        }
+        dataItem.put("cvTime", eventTimeMs);
+
+        if ("PAY".equalsIgnoreCase(tagEvent.getEventType())) {
+            double amount = resolvePurchaseAmount(payment);
+            if (amount > 0D) {
+                dataItem.put("extParam", Map.of("payAmount", toFen(amount)));
+            }
+        }
+
+        Map<String, Object> body = new LinkedHashMap<>();
+        body.put("srcType", "APP");
+        body.put("pkgName", pkgName);
+        body.put("srcId", eventSourceId);
+        body.put("dataFrom", "8");
+        body.put("dataFor", directMatch ? "0" : "1");
+        body.put("dataList", List.of(dataItem));
+
+        RequestPayload payload = new RequestPayload();
+        payload.advertiserId = advertiserId;
+        payload.eventSourceId = eventSourceId;
+        payload.eventTimeMs = eventTimeMs;
+        payload.body = body;
+        return payload;
+    }
+
+    private HttpResponse<String> sendPost(String url, String body) throws Exception {
+        HttpRequest request = HttpRequest.newBuilder()
+                .uri(URI.create(url))
+                .header("Content-Type", "application/json")
+                .POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8))
+                .timeout(Duration.ofSeconds(15))
+                .build();
+        return httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
+    }
+
+    private String buildCallbackUrl(String advertiserId, String accessToken, long timestamp, String nonce) {
+        Map<String, String> query = new LinkedHashMap<>();
+        query.put("access_token", accessToken);
+        query.put("timestamp", String.valueOf(timestamp));
+        query.put("nonce", nonce);
+        query.put("advertiser_id", advertiserId);
+        return callbackEndpoint + "?" + toQuery(query);
+    }
+
+    private void applyResponse(VivoMediaCallbackRecord record, int httpStatus, String body) {
+        if (httpStatus < 200 || httpStatus >= 300) {
+            record.setOk(false);
+            record.setErrorMessage("HTTP status: " + httpStatus);
+            return;
+        }
+        try {
+            JsonNode root = objectMapper.readTree(body);
+            int code = root.path("code").asInt(Integer.MIN_VALUE);
+            if (code == 0) {
+                record.setOk(true);
+                record.setErrorMessage(null);
+                return;
+            }
+            record.setOk(false);
+            record.setErrorMessage("code=" + code + " message=" + root.path("message").asText() + " body=" + body);
+        } catch (Exception e) {
+            record.setOk(false);
+            record.setErrorMessage("parse vivo response failed: " + e.getMessage());
+        }
+    }
+
+    private UserIdentity resolveUserIdentity(String platform, Map<String, String> params) {
+        String normalizedPlatform = platform == null ? "" : platform.trim().toLowerCase(Locale.ROOT);
+        if ("ios".equals(normalizedPlatform)) {
+            String caid = firstNonBlank(params.get("caid"));
+            if (caid != null) {
+                return new UserIdentity("OTHER", caid);
+            }
+            String idfaMd5 = firstNonBlank(params.get("idfaMD5"));
+            if (idfaMd5 != null) {
+                return new UserIdentity("OTHER", idfaMd5);
+            }
+            String idfa = firstNonBlank(params.get("idfa"));
+            if (idfa != null) {
+                return new UserIdentity("OTHER", idfa);
+            }
+            return null;
+        }
+
+        String oaid = firstNonBlank(params.get("oaid"));
+        if (oaid != null) {
+            return new UserIdentity("OAID", oaid);
+        }
+        String oaidMd5 = firstNonBlank(params.get("oaidMD5"));
+        if (oaidMd5 != null) {
+            return new UserIdentity("OAID_MD5", oaidMd5);
+        }
+        String imeiMd5 = firstNonBlank(params.get("imeiMD5"));
+        if (imeiMd5 != null) {
+            return new UserIdentity("IMEI_MD5", imeiMd5);
+        }
+        String imei = firstNonBlank(params.get("imei"));
+        if (imei != null) {
+            return new UserIdentity("IMEI", imei);
+        }
+        String androidId = firstNonBlank(params.get("androidIdMD5"), params.get("androidId"));
+        if (androidId != null) {
+            return new UserIdentity("OTHER", androidId);
+        }
+        return null;
+    }
+
+    private long resolveEventTimeMs(Map<String, String> params, PaymentInfo payment) {
+        long ts = parseLong(params.get("ts"));
+        if (ts > 0L) {
+            return ts;
+        }
+        return Instant.now().toEpochMilli();
+    }
+
+    private static double resolvePurchaseAmount(PaymentInfo payment) {
+        if (payment == null) return 0D;
+        if (payment.getGmv() > 0D) return payment.getGmv();
+        return Math.max(payment.getPayment(), 0D);
+    }
+
+    private static String toFen(double amount) {
+        long fen = Math.round(amount * 100D);
+        return String.valueOf(Math.max(fen, 0L));
+    }
+
+    private static VivoMediaCallbackRecord copy(VivoMediaCallbackRecord src) {
+        VivoMediaCallbackRecord copy = new VivoMediaCallbackRecord();
+        copy.setDedupeKey(src.getDedupeKey());
+        copy.setQk(src.getQk());
+        copy.setTagId(src.getTagId());
+        copy.setCallbackUrl(src.getCallbackUrl());
+        copy.setEventType(src.getEventType());
+        copy.setEventTimeMs(src.getEventTimeMs());
+        copy.setPurchase(src.getPurchase());
+        copy.setDeductionRate(src.getDeductionRate());
+        copy.setDirectMatch(src.isDirectMatch());
+        copy.setDispatchStatus(src.getDispatchStatus());
+        copy.setStatus(src.getStatus());
+        copy.setOk(src.isOk());
+        copy.setAttempt(src.getAttempt());
+        copy.setResponseBody(src.getResponseBody());
+        copy.setErrorMessage(src.getErrorMessage());
+        copy.setRequestBody(src.getRequestBody());
+        copy.setCreatedAt(src.getCreatedAt());
+        return copy;
+    }
+
+    private static String firstNonBlank(String... values) {
+        if (values == null) {
+            return null;
+        }
+        for (String value : values) {
+            if (value != null && !value.isBlank()) {
+                return value.trim();
+            }
+        }
+        return null;
+    }
+
+    private static long parseLong(String value) {
+        try {
+            return value == null ? 0L : Long.parseLong(value.trim());
+        } catch (Exception e) {
+            return 0L;
+        }
+    }
+
+    private static String toQuery(Map<String, String> params) {
+        StringBuilder sb = new StringBuilder();
+        boolean first = true;
+        for (Map.Entry<String, String> entry : params.entrySet()) {
+            if (entry.getValue() == null || entry.getValue().isBlank()) continue;
+            if (!first) sb.append('&');
+            first = false;
+            sb.append(urlEncode(entry.getKey())).append('=').append(urlEncode(entry.getValue()));
+        }
+        return sb.toString();
+    }
+
+    private static String queryParam(String url, String key) {
+        if (url == null || url.isBlank()) {
+            return null;
+        }
+        int queryIndex = url.indexOf('?');
+        if (queryIndex < 0 || queryIndex + 1 >= url.length()) {
+            return null;
+        }
+        for (String part : url.substring(queryIndex + 1).split("&")) {
+            int idx = part.indexOf('=');
+            if (idx <= 0) continue;
+            String currentKey = decode(part.substring(0, idx));
+            if (key.equals(currentKey)) {
+                return decode(part.substring(idx + 1));
+            }
+        }
+        return null;
+    }
+
+    private static String decode(String value) {
+        return java.net.URLDecoder.decode(value, StandardCharsets.UTF_8);
+    }
+
+    private static String urlEncode(String value) {
+        return URLEncoder.encode(value, StandardCharsets.UTF_8);
+    }
+
+    private static String trimTrailingSlash(String value) {
+        String result = value == null ? "" : value.trim();
+        while (result.endsWith("/")) {
+            result = result.substring(0, result.length() - 1);
+        }
+        return result;
+    }
+
+    private static class UserIdentity {
+        private final String type;
+        private final String value;
+
+        private UserIdentity(String type, String value) {
+            this.type = type;
+            this.value = value;
+        }
+    }
+
+    private static class RequestPayload {
+        private String advertiserId;
+        private String eventSourceId;
+        private long eventTimeMs;
+        private Map<String, Object> body;
+    }
+}

+ 669 - 0
src/main/java/com/adx/tencent/vivo/controller/VivoTrackingController.java

@@ -0,0 +1,669 @@
+package com.adx.tencent.vivo.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.vivo.model.VivoBidRecord;
+import com.adx.tencent.vivo.model.VivoTrackingRecord;
+import com.adx.tencent.vivo.service.VivoAuthService;
+import com.adx.tencent.vivo.service.VivoPlacement;
+import com.adx.tencent.vivo.store.VivoHotStore;
+import com.adx.tencent.util.TraceIds;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+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.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.Semaphore;
+
+@RestController
+public class VivoTrackingController {
+
+    private static final Logger log = LoggerFactory.getLogger(VivoTrackingController.class);
+    private static final int MAX_CONCURRENT = 50;
+
+    private final Semaphore impressionSemaphore = new Semaphore(MAX_CONCURRENT);
+    private final Semaphore clickSemaphore = new Semaphore(MAX_CONCURRENT);
+
+    private final AdxClient adxClient;
+    private final VivoHotStore hotStore;
+    private final VivoPlacement placement;
+    private final VivoAuthService authService;
+    private final ObjectMapper objectMapper;
+
+    public VivoTrackingController(AdxClient adxClient,
+                                  @Nullable VivoHotStore hotStore,
+                                  @Nullable VivoPlacement placement,
+                                  @Nullable VivoAuthService authService,
+                                  ObjectMapper objectMapper) {
+        this.adxClient = adxClient;
+        this.hotStore = hotStore;
+        this.placement = placement;
+        this.authService = authService;
+        this.objectMapper = objectMapper;
+    }
+
+    @GetMapping("/vivo/impression")
+    public Object impression(HttpServletRequest request) throws Exception {
+        impressionSemaphore.acquire();
+        try {
+            return doImpression(request);
+        } finally {
+            impressionSemaphore.release();
+        }
+    }
+
+    @GetMapping("/vivo/click")
+    public Object click(HttpServletRequest request, HttpServletResponse response) throws Exception {
+        clickSemaphore.acquire();
+        try {
+            return doClick(request, response);
+        } finally {
+            clickSemaphore.release();
+        }
+    }
+
+    @GetMapping("/vivo/callback")
+    public Object callback(HttpServletRequest request) {
+        if (authService == null) {
+            throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "vivo auth service not configured");
+        }
+        String secondaryAccountId = firstRequestParam(request, "secondaryAccountId", "secondary_account_id", "state");
+        String clientId = firstRequestParam(request, "clientId", "client_id");
+        String code = request.getParameter("code");
+        String state = request.getParameter("state");
+        if ((secondaryAccountId == null || secondaryAccountId.isBlank() || clientId == null || clientId.isBlank())
+                && state != null && state.contains("|")) {
+            String[] parts = state.split("\\|", 2);
+            if (secondaryAccountId == null || secondaryAccountId.isBlank()) {
+                secondaryAccountId = parts.length > 0 ? parts[0].trim() : null;
+            }
+            if (clientId == null || clientId.isBlank()) {
+                clientId = parts.length > 1 ? parts[1].trim() : null;
+            }
+        }
+        if (secondaryAccountId == null || secondaryAccountId.isBlank()) {
+            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "missing vivo secondaryAccountId/state");
+        }
+        if (code == null || code.isBlank()) {
+            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "missing vivo code");
+        }
+        var token = authService.authorizeSecondaryAccount(secondaryAccountId, clientId, code);
+        return Map.of(
+                "secondaryAccountId", token.getAccountId(),
+                "clientId", token.getClientId() == null ? "" : token.getClientId(),
+                "accountName", token.getAccountName() == null ? "" : token.getAccountName(),
+                "tokenExpireAt", token.getTokenExpireAt(),
+                "refreshTokenExpireAt", token.getRefreshTokenExpireAt(),
+                "authorized", true
+        );
+    }
+
+    private Object doImpression(HttpServletRequest request) throws Exception {
+        ensureReady();
+        Map<String, String> params = normalizeParams(request);
+        String traceId = vivoTraceId(params);
+        if (traceId == null || traceId.isBlank()) {
+            log.warn("[Vivo][曝光] 缺少 traceId, params={}", params.keySet());
+            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "missing vivo trace id (clickId)");
+        }
+        requireVivoParams(params);
+        log.info("[Vivo][曝光][入参] traceId={} ip={} params={}", traceId, clientIp(request), params);
+
+        Map<String, Object> bidRequest = buildBidRequest(request, traceId, params);
+        NormalizedBidResponse response = adxClient.requestBid("vivo", traceId, bidRequest);
+        if (response == null) {
+            log.warn("[Vivo][曝光][百度结果] traceId={} empty=true", traceId);
+            throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "empty baidu ADX response");
+        }
+        log.info("[Vivo][曝光][竞价结果] traceId={} qk={} bidId={} success={} price={} clickUrlCount={} showUrlCount={}",
+                traceId,
+                response.getQk(),
+                response.getBidId(),
+                response.getBid() != null,
+                response.getBid() != null ? response.getBid().getPrice() : 0,
+                response.getBid() != null && response.getBid().getClickUrls() != null ? response.getBid().getClickUrls().size() : 0,
+                response.getBid() != null && response.getBid().getShowUrls() != null ? response.getBid().getShowUrls().size() : 0);
+
+        VivoBidRecord record = bidRecordFromResponse(response, traceId, "impression", params);
+        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());
+            recordTracking(record.getQk(), record.getTagId(), "impression", tracking);
+        }
+        return Map.of("qk", record.getQk(), "traceId", traceId, "tagId", record.getTagId());
+    }
+
+    private Object doClick(HttpServletRequest request, HttpServletResponse response) throws Exception {
+        ensureReady();
+        Map<String, String> params = normalizeParams(request);
+        String traceId = vivoTraceId(params);
+        if (traceId == null || traceId.isBlank()) {
+            log.warn("[Vivo][点击] 缺少 traceId, params={}", params.keySet());
+            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "missing vivo trace id (clickId)");
+        }
+        requireVivoParams(params);
+        log.info("[Vivo][点击][入参] traceId={} ip={} params={}", traceId, clientIp(request), params);
+
+        VivoBidRecord record = hotStore.findBidByMediaTrace(traceId);
+        if (record == null) {
+            NormalizedBidResponse bidResponse = adxClient.requestBid("vivo", traceId, buildBidRequest(request, traceId, params));
+            if (bidResponse == null) {
+                log.warn("[Vivo][点击][百度结果] traceId={} empty=true", traceId);
+                throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "empty baidu ADX response");
+            }
+            log.info("[Vivo][点击][竞价结果] traceId={} qk={} bidId={} success={} price={} clickUrlCount={} showUrlCount={}",
+                    traceId,
+                    bidResponse.getQk(),
+                    bidResponse.getBidId(),
+                    bidResponse.getBid() != null,
+                    bidResponse.getBid() != null ? bidResponse.getBid().getPrice() : 0,
+                    bidResponse.getBid() != null && bidResponse.getBid().getClickUrls() != null ? bidResponse.getBid().getClickUrls().size() : 0,
+                    bidResponse.getBid() != null && bidResponse.getBid().getShowUrls() != null ? bidResponse.getBid().getShowUrls().size() : 0);
+
+            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());
+                recordTracking(record.getQk(), record.getTagId(), "impression", impressionTracking);
+            }
+        }
+
+        List<TrackingResult> tracking = adxClient.reportClick(
+                record.getClickUrls() != null ? record.getClickUrls() : Collections.emptyList());
+        recordTracking(record.getQk(), record.getTagId(), "click", tracking);
+        try {
+            hotStore.shrinkAfterClick(record);
+        } catch (Exception ignored) {
+        }
+
+        String destination = record.getAppStoreLink() != null && !record.getAppStoreLink().isBlank()
+                ? record.getAppStoreLink() : record.getLandingPage();
+        if (destination != null && !destination.isBlank()) {
+            response.sendRedirect(destination);
+            return null;
+        }
+        return Map.of("qk", record.getQk(), "traceId", traceId, "tagId", record.getTagId());
+    }
+
+    private void ensureReady() {
+        if (placement == null || hotStore == null) {
+            throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "vivo placement not configured");
+        }
+    }
+
+    private Map<String, String> normalizeParams(HttpServletRequest request) {
+        Map<String, String> params = new LinkedHashMap<>();
+        request.getParameterMap().forEach((k, v) -> {
+            if (v != null && v.length > 0 && v[0] != null) {
+                params.put(k, v[0].trim());
+            }
+        });
+        alias(params, "tagId", "tag_id", "baidu_tag_id");
+        alias(params, "callback", "cb");
+        alias(params, "platform", "os", "system", "device_os");
+        alias(params, "accountId", "accountid", "advertiser_id", "advertiserId");
+        alias(params, "accountid", "accountId", "advertiser_id", "advertiserId");
+        alias(params, "advertiserId", "advertiser_id", "accountId", "accountid");
+        alias(params, "advertiser_id", "advertiserId", "accountId", "accountid");
+        alias(params, "eventSourceId", "event_source_id", "eventsourceid", "srcId", "src_id");
+        alias(params, "srcId", "eventSourceId", "event_source_id");
+        alias(params, "clickId", "clickid", "click_id", "trace_id", "traceId");
+        alias(params, "pkgName", "packageName", "package_name", "pkg_name");
+        alias(params, "ua", "user_agent", "useragent");
+        alias(params, "ip", "ipv4", "m6a");
+        alias(params, "ipv6", "ipV6");
+        alias(params, "oaid", "oaid_plain");
+        alias(params, "oaidMD5", "oaid_md5", "oaidMd5", "oaidMd5");
+        alias(params, "imei", "imei_plain");
+        alias(params, "imeiMD5", "imei_md5", "imeiMd5");
+        alias(params, "androidId", "androidid", "android_id");
+        alias(params, "imeiSHA1", "imei_sha1", "imeiSha1");
+        alias(params, "androidIdMD5", "android_id_md5", "androidIdMd5");
+        alias(params, "androidIdSHA1", "android_id_sha1", "androidIdSha1");
+        alias(params, "idfa", "idfa_plain");
+        alias(params, "idfaMD5", "idfa_md5", "idfaMd5");
+        alias(params, "idfaSHA1", "idfa_sha1", "idfaSha1");
+        alias(params, "caid", "kenyid_caa", "kenyIdCaa");
+        alias(params, "kenyid_caa", "caid", "kenyIdCaa");
+        alias(params, "ts", "eventTime", "timestamp");
+        normalizeDeductionParams(params);
+        return params;
+    }
+
+    private Map<String, Object> buildBidRequest(HttpServletRequest request, String traceId, Map<String, String> params) {
+        VivoPlacement.Resolved resolved = placement.resolve(params);
+        params.put("baidu_app_id", resolved.appId());
+        params.put("baidu_tag_id", resolved.tagId());
+        params.put("tagId", resolved.tagId());
+        if (resolved.platform() != null && !resolved.platform().isBlank()) {
+            params.put("baidu_platform", resolved.platform());
+            params.put("platform", resolved.platform());
+        }
+
+        Map<String, Object> imp = new LinkedHashMap<>();
+        imp.put("id", "1");
+        imp.put("appId", resolved.appId());
+        imp.put("tagId", resolved.tagId());
+        imp.put("secure", 1);
+        imp.put("adType", 0);
+        imp.put("assets", defaultBaiduAssets());
+        imp.put("actionType", placement.getActionTypes() == null || placement.getActionTypes().isEmpty()
+                ? List.of(0, 1, 2)
+                : placement.getActionTypes());
+        imp.put("maxCount", 1);
+        if (placement.getBidFloor() > 0) {
+            imp.put("bidFloor", placement.getBidFloor());
+        }
+
+        Map<String, Object> device = new LinkedHashMap<>();
+        putDeviceIp(device, firstParam(params, "ip"), firstParam(params, "ipv6"), clientIp(request));
+        device.put("ua", normalizeUserAgent(firstParam(params, "ua"), resolved.platform()));
+        device.put("deviceType", 1);
+        int os = baiduOsForPlatform(resolved.platform());
+        if (os != 0) device.put("os", os);
+        Map<String, Object> uid = baiduUidFromParams(params);
+        if (!uid.isEmpty()) device.put("uid", uid);
+
+        Map<String, Object> bidReq = new LinkedHashMap<>();
+        bidReq.put("mediaId", placement.getBaiduMediaId());
+        bidReq.put("reqId", baiduReqId(traceId));
+        bidReq.put("imp", List.of(imp));
+        bidReq.put("device", device);
+        List<Integer> appList = parseAppList(firstParam(params, "appList", "app_list", "applist"));
+        bidReq.put("appList", appList);
+        log.info("[Vivo][百度请求] traceId={} body={}", traceId, toJson(bidReq));
+        return bidReq;
+    }
+
+    private VivoBidRecord bidRecordFromResponse(NormalizedBidResponse response,
+                                                    String traceId,
+                                                    String eventType,
+                                                    Map<String, String> params) {
+        VivoBidRecord record = new VivoBidRecord();
+        record.setQk(response.getQk());
+        record.setReqId(response.getReqId());
+        record.setBidId(response.getBidId());
+        record.setMedia("vivo");
+        record.setMediaTraceId(traceId);
+        record.setPlatform(firstParam(params, "baidu_platform", "platform"));
+        record.setAccountId(firstParam(params, "accountId", "accountid"));
+        record.setTagId(params.get("tagId"));
+        record.setEventType(eventType);
+        record.setMediaParams(params);
+        record.setCreatedAt(Instant.now());
+        if (response.getBid() != null) {
+            record.setCreativeId(response.getBid().getCrid());
+            record.setImpId(response.getBid().getImpId());
+            record.setTagId(response.getBid().getTagId() != null ? response.getBid().getTagId() : record.getTagId());
+            record.setPrice(response.getBid().getPrice());
+            record.setShowUrls(response.getBid().getShowUrls());
+            record.setClickUrls(response.getBid().getClickUrls());
+            record.setLandingPage(response.getBid().getLandingPage());
+            record.setAppStoreLink(response.getBid().getAppStoreLink());
+            if ((record.getPackageName() == null || record.getPackageName().isBlank())
+                    && response.getBid().getAdm() != null) {
+                record.setPackageName(response.getBid().getAdm().getPackageName());
+            }
+        }
+        if (record.getPackageName() == null || record.getPackageName().isBlank()) {
+            record.setPackageName(firstParam(params, "pkgName", "packageName"));
+        }
+        return record;
+    }
+
+    private Map<String, Object> baiduUidFromParams(Map<String, String> p) {
+        Map<String, Object> uid = new LinkedHashMap<>();
+        putUidWithMd5Fallback(uid, "did", "didMd5",
+                firstParam(p, "imei"),
+                firstParam(p, "imeiMD5", "imei_md5", "imeiMd5"));
+        putUidWithMd5Fallback(uid, "dpid", "dpidMd5",
+                firstParam(p, "androidid", "androidId", "android_id"),
+                firstParam(p, "androidIdMD5", "android_id_md5", "androidIdMd5"));
+        putUidWithMd5Fallback(uid, "idfa", "idfaMd5",
+                firstParam(p, "idfa"),
+                firstParam(p, "idfaMD5", "idfa_md5", "idfaMd5"));
+        putUidWithMd5Fallback(uid, "oaid", "oaidMd5",
+                firstParam(p, "oaid"),
+                firstParam(p, "oaidMD5", "oaid_md5", "oaidMd5"));
+        List<Map<String, Object>> caid = baiduCaidFromParams(p);
+        if (!caid.isEmpty()) {
+            uid.put("caid", caid);
+        }
+        return uid;
+    }
+
+    private List<Map<String, Object>> baiduCaidFromParams(Map<String, String> params) {
+        String rawCaid = firstParam(params, "kenyid_caa", "caid");
+        if (rawCaid == null || rawCaid.isBlank()) {
+            return Collections.emptyList();
+        }
+        String decoded;
+        try {
+            decoded = URLDecoder.decode(rawCaid, StandardCharsets.UTF_8);
+        } catch (Exception e) {
+            decoded = rawCaid;
+        }
+        try {
+            Object value = objectMapper.readValue(decoded, Object.class);
+            if (value instanceof List<?> list) {
+                List<Map<String, Object>> result = new ArrayList<>();
+                for (Object item : list) {
+                    Map<String, Object> caid = toCaidItem(item);
+                    if (!caid.isEmpty()) result.add(caid);
+                }
+                return result;
+            }
+            Map<String, Object> single = toCaidItem(value);
+            return single.isEmpty() ? Collections.emptyList() : List.of(single);
+        } catch (Exception e) {
+            return List.of(Map.of("id", decoded));
+        }
+    }
+
+    private Map<String, Object> toCaidItem(Object source) {
+        if (!(source instanceof Map<?, ?> map)) {
+            return Collections.emptyMap();
+        }
+        String id = firstNonBlankValue(map, "qaid", "id", "caid");
+        if (id == null) {
+            return Collections.emptyMap();
+        }
+        Map<String, Object> caid = new LinkedHashMap<>();
+        caid.put("id", id);
+        String version = firstNonBlankValue(map, "version");
+        if (version != null) {
+            caid.put("version", version);
+        }
+        Long generateTime = firstLongValue(map, "generateTime");
+        if (generateTime != null) {
+            caid.put("generateTime", generateTime);
+        }
+        Long vendor = firstLongValue(map, "vendor");
+        if (vendor != null) {
+            caid.put("vendor", vendor);
+        }
+        return caid;
+    }
+
+    private void recordTracking(String qk, String tagId, String kind, List<TrackingResult> results) {
+        if (results == null) return;
+        for (TrackingResult result : results) {
+            VivoTrackingRecord record = new VivoTrackingRecord();
+            record.setQk(qk);
+            record.setTagId(tagId);
+            record.setKind(kind);
+            record.setUrl(result.getUrl());
+            record.setStatus(result.getStatus());
+            record.setOk(result.isOk());
+            record.setCreatedAt(Instant.now());
+            hotStore.recordTracking(record);
+        }
+    }
+
+    private String vivoTraceId(Map<String, String> params) {
+        return TraceIds.normalize(firstParam(params,
+                "clickId", "trace_id", "traceId", "request_id",
+                "idfaMD5", "idfa", "oaidMD5", "oaid", "imeiMD5", "imei", "androidIdMD5", "androidId"));
+    }
+
+    private void normalizeDeductionParams(Map<String, String> params) {
+        String rate = firstParam(params, "deduction_rate", "deductionRate", "rate");
+        int sanitized = 0;
+        try {
+            if (rate != null) sanitized = Math.max(0, Math.min(100, Integer.parseInt(rate)));
+        } catch (NumberFormatException ignored) {
+        }
+        params.put(VivoBidRecord.DEDUCTION_RATE_KEY, String.valueOf(sanitized));
+    }
+
+    private List<Map<String, Integer>> defaultBaiduAssets() {
+        return List.of(
+                Map.of("templateId", 1, "ratio", 2),
+                Map.of("templateId", 1, "ratio", 7),
+                Map.of("templateId", 2, "ratio", 1),
+                Map.of("templateId", 2, "ratio", 4),
+                Map.of("templateId", 3, "ratio", 2),
+                Map.of("templateId", 4, "ratio", 4),
+                Map.of("templateId", 4, "ratio", 5)
+        );
+    }
+
+    private int baiduOsForPlatform(String platform) {
+        if (platform == null) return 0;
+        return switch (platform) {
+            case "ios" -> 1;
+            case "android" -> 2;
+            default -> 0;
+        };
+    }
+
+    private static String normalizeUserAgent(String ua, String platform) {
+        if (ua != null && !ua.isBlank()) return ua;
+        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 List<Integer> parseAppList(String value) {
+        if (value == null || value.isBlank()) return Collections.emptyList();
+        List<Integer> result = new ArrayList<>();
+        for (String part : value.split("[,|]")) {
+            String item = part == null ? null : part.trim();
+            if (item == null || item.isEmpty()) continue;
+            try {
+                result.add(Integer.parseInt(item));
+            } catch (NumberFormatException ignored) {
+            }
+        }
+        return result;
+    }
+
+    private void alias(Map<String, String> params, String target, String... aliases) {
+        if (params.get(target) != null && !params.get(target).isBlank()) return;
+        for (String alias : aliases) {
+            String value = params.get(alias);
+            if (value != null && !value.isBlank()) {
+                params.put(target, value.trim());
+                return;
+            }
+        }
+    }
+
+    private void putUid(Map<String, Object> uid, String key, String value) {
+        if (value != null && !value.isBlank()) uid.put(key, value);
+    }
+
+    private void putUidWithMd5Fallback(Map<String, Object> uid,
+                                       String rawKey,
+                                       String md5Key,
+                                       String rawCandidate,
+                                       String explicitMd5Candidate) {
+        String md5Value = normalizeMd5(explicitMd5Candidate);
+        if (md5Value != null) {
+            uid.put(md5Key, md5Value);
+        }
+        if (rawCandidate == null || rawCandidate.isBlank()) {
+            return;
+        }
+        String value = rawCandidate.trim();
+        if (isMd5(value)) {
+            uid.putIfAbsent(md5Key, value.toLowerCase());
+            return;
+        }
+        uid.put(rawKey, value);
+        uid.putIfAbsent(md5Key, md5Hex(value));
+    }
+
+    private static boolean isMd5(String value) {
+        return value != null && value.matches("(?i)^[0-9a-f]{32}$");
+    }
+
+    private static String normalizeMd5(String value) {
+        if (value == null || value.isBlank()) {
+            return null;
+        }
+        String trimmed = value.trim();
+        return isMd5(trimmed) ? trimmed.toLowerCase() : trimmed;
+    }
+
+    private static String md5Hex(String value) {
+        try {
+            MessageDigest md = MessageDigest.getInstance("MD5");
+            byte[] digest = md.digest(value.getBytes(StandardCharsets.UTF_8));
+            StringBuilder sb = new StringBuilder(digest.length * 2);
+            for (byte b : digest) {
+                sb.append(String.format("%02x", b));
+            }
+            return sb.toString();
+        } catch (Exception e) {
+            return value;
+        }
+    }
+
+    private String baiduReqId(String traceId) {
+        String base = traceId == null ? "" : traceId.replaceAll("[^A-Za-z0-9_-]", "");
+        if (base.isBlank()) {
+            base = md5Hex(traceId == null ? "vivo" : traceId);
+        }
+        if (base.length() > 64) {
+            base = base.substring(0, 64);
+        }
+        return base + "-" + Instant.now().toEpochMilli();
+    }
+
+    private static void putDeviceIp(Map<String, Object> device, String ipv4Candidate, String ipv6Candidate, String fallbackIp) {
+        String ipv4 = normalizeIp(ipv4Candidate);
+        String ipv6 = normalizeIp(ipv6Candidate);
+        String fallback = normalizeIp(fallbackIp);
+        if (ipv4 == null && ipv6 == null && fallback != null) {
+            if (isIpv6(fallback)) {
+                ipv6 = fallback;
+            } else {
+                ipv4 = fallback;
+            }
+        }
+        if (ipv4 != null && isIpv6(ipv4)) {
+            ipv6 = ipv4;
+            ipv4 = null;
+        }
+        if (ipv6 != null && !isIpv6(ipv6)) {
+            if (ipv4 == null) {
+                ipv4 = ipv6;
+            }
+            ipv6 = null;
+        }
+        if (ipv4 != null) {
+            device.put("ip", ipv4);
+        }
+        if (ipv6 != null) {
+            device.put("ipv6", ipv6);
+        }
+    }
+
+    private static String normalizeIp(String value) {
+        if (value == null || value.isBlank()) {
+            return null;
+        }
+        return value.trim();
+    }
+
+    private static boolean isIpv6(String value) {
+        return value != null && value.contains(":");
+    }
+
+    private String firstParam(Map<String, String> params, String... keys) {
+        for (String key : keys) {
+            String value = params.get(key);
+            if (value != null && !value.isBlank()) return value.trim();
+        }
+        return null;
+    }
+
+    private static String firstNonBlankValue(Map<?, ?> map, String... keys) {
+        for (String key : keys) {
+            Object value = map.get(key);
+            if (value == null) continue;
+            String text = String.valueOf(value).trim();
+            if (!text.isEmpty()) return text;
+        }
+        return null;
+    }
+
+    private static Long firstLongValue(Map<?, ?> map, String... keys) {
+        for (String key : keys) {
+            Object value = map.get(key);
+            if (value == null) continue;
+            if (value instanceof Number number) return number.longValue();
+            try {
+                return Long.parseLong(String.valueOf(value).trim());
+            } catch (NumberFormatException ignored) {
+            }
+        }
+        return null;
+    }
+
+    private String toJson(Object value) {
+        try {
+            return objectMapper.writeValueAsString(value);
+        } catch (Exception e) {
+            return String.valueOf(value);
+        }
+    }
+
+    static String clientIp(HttpServletRequest request) {
+        String xff = request.getHeader("X-Forwarded-For");
+        if (xff != null && !xff.isBlank()) {
+            for (String part : xff.split(",")) {
+                String ip = part.trim();
+                if (!ip.isEmpty()) return ip;
+            }
+        }
+        String xri = request.getHeader("X-Real-IP");
+        if (xri != null && !xri.isBlank()) {
+            return xri.trim();
+        }
+        return request.getRemoteAddr();
+    }
+
+    private static String safe(String value) {
+        return value == null ? "" : value;
+    }
+
+    private void requireVivoParams(Map<String, String> params) {
+        if (firstParam(params, "accountId", "accountid") == null) {
+            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "missing vivo accountId");
+        }
+        if (firstParam(params, "eventSourceId", "srcId") == null) {
+            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "missing vivo eventSourceId");
+        }
+    }
+
+    private static String firstRequestParam(HttpServletRequest request, String... keys) {
+        for (String key : keys) {
+            String value = request.getParameter(key);
+            if (value != null && !value.isBlank()) {
+                return value.trim();
+            }
+        }
+        return null;
+    }
+}

+ 18 - 0
src/main/java/com/adx/tencent/vivo/mapper/VivoAdvertiserAccountMapper.java

@@ -0,0 +1,18 @@
+package com.adx.tencent.vivo.mapper;
+
+import com.adx.tencent.vivo.model.VivoAdvertiserAccountRecord;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+@Mapper
+public interface VivoAdvertiserAccountMapper {
+    VivoAdvertiserAccountRecord selectByAdvertiserId(@Param("advertiserId") String advertiserId);
+
+    List<String> selectAdvertiserIdsBySecondaryAccountId(@Param("secondaryAccountId") String secondaryAccountId);
+
+    void deleteBySecondaryAccountId(@Param("secondaryAccountId") String secondaryAccountId);
+
+    void batchInsertOrUpdate(@Param("records") List<VivoAdvertiserAccountRecord> records);
+}

+ 14 - 0
src/main/java/com/adx/tencent/vivo/mapper/VivoBidEventMapper.java

@@ -0,0 +1,14 @@
+package com.adx.tencent.vivo.mapper;
+
+import com.adx.tencent.vivo.model.VivoBidRecord;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+@Mapper
+public interface VivoBidEventMapper {
+    VivoBidRecord selectByQk(@Param("qk") String qk);
+    List<VivoBidRecord> selectByQks(@Param("qks") List<String> qks);
+    void insertOrUpdate(@Param("record") VivoBidRecord record);
+}

+ 16 - 0
src/main/java/com/adx/tencent/vivo/mapper/VivoClientConfigMapper.java

@@ -0,0 +1,16 @@
+package com.adx.tencent.vivo.mapper;
+
+import com.adx.tencent.vivo.model.VivoClientConfigRecord;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+@Mapper
+public interface VivoClientConfigMapper {
+    VivoClientConfigRecord selectByClientId(@Param("clientId") String clientId);
+
+    List<VivoClientConfigRecord> selectEnabled();
+
+    void insertOrUpdate(@Param("record") VivoClientConfigRecord record);
+}

+ 14 - 0
src/main/java/com/adx/tencent/vivo/mapper/VivoMediaCallbackMapper.java

@@ -0,0 +1,14 @@
+package com.adx.tencent.vivo.mapper;
+
+import com.adx.tencent.vivo.model.VivoMediaCallbackRecord;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+@Mapper
+public interface VivoMediaCallbackMapper {
+    void batchInsert(@Param("records") List<VivoMediaCallbackRecord> records);
+    List<String> selectSuccessfulDedupeKeys(@Param("dedupeKeys") List<String> dedupeKeys);
+    List<VivoMediaCallbackRecord> selectPendingCallbacks(@Param("limit") int limit);
+}

+ 14 - 0
src/main/java/com/adx/tencent/vivo/mapper/VivoTagEventMapper.java

@@ -0,0 +1,14 @@
+package com.adx.tencent.vivo.mapper;
+
+import com.adx.tencent.vivo.model.VivoTagEventRecord;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+@Mapper
+public interface VivoTagEventMapper {
+    List<VivoTagEventRecord> selectByTagIdAndAct(@Param("tagId") String tagId, @Param("baiduAct") int baiduAct);
+
+    List<VivoTagEventRecord> selectAll();
+}

+ 16 - 0
src/main/java/com/adx/tencent/vivo/mapper/VivoTokenMapper.java

@@ -0,0 +1,16 @@
+package com.adx.tencent.vivo.mapper;
+
+import com.adx.tencent.vivo.model.VivoTokenRecord;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+@Mapper
+public interface VivoTokenMapper {
+    VivoTokenRecord selectByAccountId(@Param("accountId") String accountId);
+
+    List<VivoTokenRecord> selectAll();
+
+    void insertOrUpdate(@Param("record") VivoTokenRecord record);
+}

+ 9 - 0
src/main/java/com/adx/tencent/vivo/mapper/VivoTrackingReportMapper.java

@@ -0,0 +1,9 @@
+package com.adx.tencent.vivo.mapper;
+
+import com.adx.tencent.vivo.model.VivoTrackingRecord;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface VivoTrackingReportMapper {
+    void insert(VivoTrackingRecord record);
+}

+ 33 - 0
src/main/java/com/adx/tencent/vivo/model/VivoAdvertiserAccountRecord.java

@@ -0,0 +1,33 @@
+package com.adx.tencent.vivo.model;
+
+import java.time.Instant;
+
+public class VivoAdvertiserAccountRecord {
+    private String advertiserId;
+    private String secondaryAccountId;
+    private String advertiserName;
+    private Integer advertiserStatus;
+    private Instant lastSyncAt;
+    private Instant updatedAt;
+
+    public String getAdvertiserId() { return advertiserId; }
+    public void setAdvertiserId(String v) { this.advertiserId = v; }
+    public String getSecondaryAccountId() { return secondaryAccountId; }
+    public void setSecondaryAccountId(String v) { this.secondaryAccountId = v; }
+    public String getAdvertiserName() { return advertiserName; }
+    public void setAdvertiserName(String v) { this.advertiserName = v; }
+    public Integer getAdvertiserStatus() { return advertiserStatus; }
+    public void setAdvertiserStatus(Integer v) { this.advertiserStatus = v; }
+    public Instant getLastSyncAt() { return lastSyncAt; }
+    public void setLastSyncAt(Instant v) { this.lastSyncAt = v; }
+    public Instant getUpdatedAt() { return updatedAt; }
+    public void setUpdatedAt(Instant v) { this.updatedAt = v; }
+
+    public java.sql.Timestamp getLastSyncAtTs() {
+        return lastSyncAt == null ? null : java.sql.Timestamp.from(lastSyncAt);
+    }
+
+    public java.sql.Timestamp getUpdatedAtTs() {
+        return updatedAt == null ? null : java.sql.Timestamp.from(updatedAt);
+    }
+}

+ 152 - 0
src/main/java/com/adx/tencent/vivo/model/VivoBidRecord.java

@@ -0,0 +1,152 @@
+package com.adx.tencent.vivo.model;
+
+import com.adx.tencent.util.TraceIds;
+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 VivoBidRecord {
+
+    public static final String DEDUCTION_RATE_KEY = "deduction_rate";
+    public static final String CLICK_ID_KEY = "clickId";
+    public static final String EVENT_SOURCE_ID_KEY = "eventSourceId";
+    public static final String PACKAGE_NAME_KEY = "pkgName";
+
+    private String qk;
+    private String media;
+    private String mediaTraceId;
+    private String platform;
+    private String accountId;
+    private String eventType;
+    private String reqId;
+    private String bidId;
+    private String creativeId;
+    private String impId;
+    private String tagId;
+    private Long price;
+    private List<String> showUrls;
+    private List<String> clickUrls;
+    private String landingPage;
+    private String appStoreLink;
+    private String packageName;
+    private Map<String, String> mediaParams;
+    private Instant createdAt;
+
+    public String getQk() { return qk; }
+    public void setQk(String v) { this.qk = v; }
+    public String getMedia() { return media; }
+    public void setMedia(String v) { this.media = v; }
+    public String getMediaTraceId() { return mediaTraceId; }
+    public void setMediaTraceId(String v) { this.mediaTraceId = TraceIds.normalize(v); }
+    public String getPlatform() { return platform; }
+    public void setPlatform(String v) { this.platform = v; }
+    public String getAccountId() { return accountId; }
+    public void setAccountId(String v) { this.accountId = v; }
+    public String getEventType() { return eventType; }
+    public void setEventType(String v) { this.eventType = v; }
+    public String getReqId() { return reqId; }
+    public void setReqId(String v) { this.reqId = v; }
+    public String getBidId() { return bidId; }
+    public void setBidId(String v) { this.bidId = v; }
+    public String getCreativeId() { return creativeId; }
+    public void setCreativeId(String v) { this.creativeId = v; }
+    public String getImpId() { return impId; }
+    public void setImpId(String v) { this.impId = v; }
+    public String getTagId() { return tagId; }
+    public void setTagId(String v) { this.tagId = v; }
+    public Long getPrice() { return price; }
+    public void setPrice(Long v) { this.price = v == null ? 0L : v; }
+    public List<String> getShowUrls() { return showUrls; }
+    public void setShowUrls(List<String> v) { this.showUrls = v; }
+    public List<String> getClickUrls() { return clickUrls; }
+    public void setClickUrls(List<String> v) { this.clickUrls = v; }
+    public String getLandingPage() { return landingPage; }
+    public void setLandingPage(String v) { this.landingPage = v; }
+    public String getAppStoreLink() { return appStoreLink; }
+    public void setAppStoreLink(String v) { this.appStoreLink = v; }
+    public String getPackageName() { return packageName; }
+    public void setPackageName(String v) { this.packageName = v; }
+    public Map<String, String> getMediaParams() { return mediaParams; }
+    public void setMediaParams(Map<String, String> v) { this.mediaParams = v; }
+    public Instant getCreatedAt() { return createdAt; }
+    public void setCreatedAt(Instant v) { this.createdAt = v; }
+    public java.sql.Timestamp getCreatedAtTs() { return createdAt == null ? null : java.sql.Timestamp.from(createdAt); }
+
+    public VivoBidRecord toPostClickRecord() {
+        VivoBidRecord slim = new VivoBidRecord();
+        slim.qk = this.qk;
+        slim.media = this.media;
+        slim.mediaTraceId = this.mediaTraceId;
+        slim.platform = this.platform;
+        slim.accountId = this.accountId;
+        slim.tagId = this.tagId;
+        slim.packageName = this.packageName;
+        slim.mediaParams = slimDeviceParams(this.mediaParams);
+        return slim;
+    }
+
+    public VivoBidRecord toHotRecord() {
+        VivoBidRecord hot = new VivoBidRecord();
+        hot.qk = this.qk;
+        hot.media = this.media;
+        hot.mediaTraceId = this.mediaTraceId;
+        hot.platform = this.platform;
+        hot.accountId = this.accountId;
+        hot.tagId = this.tagId;
+        hot.clickUrls = this.clickUrls;
+        hot.landingPage = this.landingPage;
+        hot.appStoreLink = this.appStoreLink;
+        hot.packageName = this.packageName;
+        hot.mediaParams = slimDeviceParams(this.mediaParams);
+        return hot;
+    }
+
+    public VivoBidRecord toColdRecord() {
+        VivoBidRecord cold = new VivoBidRecord();
+        cold.qk = this.qk;
+        cold.media = this.media;
+        cold.mediaTraceId = this.mediaTraceId;
+        cold.platform = this.platform;
+        cold.accountId = this.accountId;
+        cold.eventType = this.eventType;
+        cold.tagId = this.tagId;
+        cold.price = this.price == null ? 0L : this.price;
+        cold.packageName = this.packageName;
+        cold.mediaParams = slimDeviceParams(this.mediaParams);
+        cold.createdAt = this.createdAt;
+        return cold;
+    }
+
+    private static Map<String, String> slimDeviceParams(Map<String, String> params) {
+        if (params == null || params.isEmpty()) return null;
+        List<String> keepKeys = List.of(
+                CLICK_ID_KEY,
+                EVENT_SOURCE_ID_KEY,
+                PACKAGE_NAME_KEY,
+                "ts",
+                "oaid",
+                "oaidMD5",
+                "imei",
+                "imeiMD5",
+                "idfa",
+                "idfaMD5",
+                "caid",
+                "androidId",
+                "androidIdMD5",
+                "platform",
+                DEDUCTION_RATE_KEY
+        );
+        Map<String, String> slim = new LinkedHashMap<>();
+        for (String key : keepKeys) {
+            String value = params.get(key);
+            if (value != null && !value.isBlank()) {
+                slim.put(key, value);
+            }
+        }
+        return slim.isEmpty() ? null : slim;
+    }
+}

+ 26 - 0
src/main/java/com/adx/tencent/vivo/model/VivoClientConfigRecord.java

@@ -0,0 +1,26 @@
+package com.adx.tencent.vivo.model;
+
+import java.time.Instant;
+
+public class VivoClientConfigRecord {
+    private String clientId;
+    private String clientSecret;
+    private String clientName;
+    private Boolean enabled;
+    private Instant updatedAt;
+
+    public String getClientId() { return clientId; }
+    public void setClientId(String v) { this.clientId = v; }
+    public String getClientSecret() { return clientSecret; }
+    public void setClientSecret(String v) { this.clientSecret = v; }
+    public String getClientName() { return clientName; }
+    public void setClientName(String v) { this.clientName = v; }
+    public Boolean getEnabled() { return enabled; }
+    public void setEnabled(Boolean v) { this.enabled = v; }
+    public Instant getUpdatedAt() { return updatedAt; }
+    public void setUpdatedAt(Instant v) { this.updatedAt = v; }
+
+    public java.sql.Timestamp getUpdatedAtTs() {
+        return updatedAt == null ? null : java.sql.Timestamp.from(updatedAt);
+    }
+}

+ 62 - 0
src/main/java/com/adx/tencent/vivo/model/VivoMediaCallbackRecord.java

@@ -0,0 +1,62 @@
+package com.adx.tencent.vivo.model;
+
+import java.time.Instant;
+
+public class VivoMediaCallbackRecord {
+    public static final String DISPATCH_STATUS_SENT = "SENT";
+    public static final String DISPATCH_STATUS_DEDUCTED = "DEDUCTED";
+
+    private String dedupeKey;
+    private String qk;
+    private String tagId;
+    private String callbackUrl;
+    private String eventType;
+    private long eventTimeMs;
+    private double purchase;
+    private int deductionRate;
+    private boolean directMatch = true;
+    private String dispatchStatus = DISPATCH_STATUS_SENT;
+    private int status;
+    private boolean ok;
+    private int attempt;
+    private String responseBody;
+    private String errorMessage;
+    private String requestBody;
+    private Instant createdAt;
+
+    public String getDedupeKey() { return dedupeKey; }
+    public void setDedupeKey(String v) { this.dedupeKey = v; }
+    public String getQk() { return qk; }
+    public void setQk(String v) { this.qk = v; }
+    public String getTagId() { return tagId; }
+    public void setTagId(String v) { this.tagId = v; }
+    public String getCallbackUrl() { return callbackUrl; }
+    public void setCallbackUrl(String v) { this.callbackUrl = v; }
+    public String getEventType() { return eventType; }
+    public void setEventType(String v) { this.eventType = v; }
+    public long getEventTimeMs() { return eventTimeMs; }
+    public void setEventTimeMs(long v) { this.eventTimeMs = v; }
+    public double getPurchase() { return purchase; }
+    public void setPurchase(double v) { this.purchase = v; }
+    public int getDeductionRate() { return deductionRate; }
+    public void setDeductionRate(int v) { this.deductionRate = v; }
+    public boolean isDirectMatch() { return directMatch; }
+    public void setDirectMatch(boolean v) { this.directMatch = v; }
+    public String getDispatchStatus() { return dispatchStatus; }
+    public void setDispatchStatus(String v) { this.dispatchStatus = v; }
+    public int getStatus() { return status; }
+    public void setStatus(int v) { this.status = v; }
+    public boolean isOk() { return ok; }
+    public void setOk(boolean v) { this.ok = v; }
+    public int getAttempt() { return attempt; }
+    public void setAttempt(int v) { this.attempt = v; }
+    public String getResponseBody() { return responseBody; }
+    public void setResponseBody(String v) { this.responseBody = v; }
+    public String getErrorMessage() { return errorMessage; }
+    public void setErrorMessage(String v) { this.errorMessage = v; }
+    public String getRequestBody() { return requestBody; }
+    public void setRequestBody(String v) { this.requestBody = v; }
+    public Instant getCreatedAt() { return createdAt; }
+    public void setCreatedAt(Instant v) { this.createdAt = v; }
+    public java.sql.Timestamp getCreatedAtTs() { return createdAt == null ? null : java.sql.Timestamp.from(createdAt); }
+}

+ 14 - 0
src/main/java/com/adx/tencent/vivo/model/VivoTagEventRecord.java

@@ -0,0 +1,14 @@
+package com.adx.tencent.vivo.model;
+
+public class VivoTagEventRecord {
+    private String tagId;
+    private int baiduAct;
+    private String eventType;
+
+    public String getTagId() { return tagId; }
+    public void setTagId(String v) { this.tagId = v; }
+    public int getBaiduAct() { return baiduAct; }
+    public void setBaiduAct(int v) { this.baiduAct = v; }
+    public String getEventType() { return eventType; }
+    public void setEventType(String v) { this.eventType = v; }
+}

+ 51 - 0
src/main/java/com/adx/tencent/vivo/model/VivoTokenRecord.java

@@ -0,0 +1,51 @@
+package com.adx.tencent.vivo.model;
+
+import java.time.Instant;
+
+public class VivoTokenRecord {
+    private String accountId;
+    private String accountName;
+    private Integer accountLevel;
+    private Integer accountStatus;
+    private String clientId;
+    private String clientSecret;
+    private String accessToken;
+    private String refreshToken;
+    private Long tokenExpireAt;
+    private Long refreshTokenExpireAt;
+    private Instant authorizedAt;
+    private Instant updatedAt;
+
+    public String getAccountId() { return accountId; }
+    public void setAccountId(String v) { this.accountId = v; }
+    public String getAccountName() { return accountName; }
+    public void setAccountName(String v) { this.accountName = v; }
+    public Integer getAccountLevel() { return accountLevel; }
+    public void setAccountLevel(Integer v) { this.accountLevel = v; }
+    public Integer getAccountStatus() { return accountStatus; }
+    public void setAccountStatus(Integer v) { this.accountStatus = v; }
+    public String getClientId() { return clientId; }
+    public void setClientId(String v) { this.clientId = v; }
+    public String getClientSecret() { return clientSecret; }
+    public void setClientSecret(String v) { this.clientSecret = v; }
+    public String getAccessToken() { return accessToken; }
+    public void setAccessToken(String v) { this.accessToken = v; }
+    public String getRefreshToken() { return refreshToken; }
+    public void setRefreshToken(String v) { this.refreshToken = v; }
+    public Long getTokenExpireAt() { return tokenExpireAt; }
+    public void setTokenExpireAt(Long v) { this.tokenExpireAt = v; }
+    public Long getRefreshTokenExpireAt() { return refreshTokenExpireAt; }
+    public void setRefreshTokenExpireAt(Long v) { this.refreshTokenExpireAt = v; }
+    public Instant getAuthorizedAt() { return authorizedAt; }
+    public void setAuthorizedAt(Instant v) { this.authorizedAt = v; }
+    public Instant getUpdatedAt() { return updatedAt; }
+    public void setUpdatedAt(Instant v) { this.updatedAt = v; }
+
+    public java.sql.Timestamp getAuthorizedAtTs() {
+        return authorizedAt == null ? null : java.sql.Timestamp.from(authorizedAt);
+    }
+
+    public java.sql.Timestamp getUpdatedAtTs() {
+        return updatedAt == null ? null : java.sql.Timestamp.from(updatedAt);
+    }
+}

+ 29 - 0
src/main/java/com/adx/tencent/vivo/model/VivoTrackingRecord.java

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

+ 221 - 0
src/main/java/com/adx/tencent/vivo/service/VivoAdvertiserSyncService.java

@@ -0,0 +1,221 @@
+package com.adx.tencent.vivo.service;
+
+import com.adx.tencent.vivo.model.VivoAdvertiserAccountRecord;
+import com.adx.tencent.vivo.model.VivoTokenRecord;
+import com.adx.tencent.vivo.store.VivoColdStore;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.net.URI;
+import java.net.URLEncoder;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+import java.util.stream.Collectors;
+
+public class VivoAdvertiserSyncService {
+
+    private static final Logger log = LoggerFactory.getLogger(VivoAdvertiserSyncService.class);
+    private static final String ADVERTISER_QUERY_PATH = "/openapi/v1/account/advertiser/query";
+    private static final int PAGE_SIZE = 100;
+
+    private final String baseUrl;
+    private final VivoAuthService authService;
+    private final VivoColdStore coldStore;
+    private final ObjectMapper objectMapper;
+    private final HttpClient httpClient;
+
+    public VivoAdvertiserSyncService(String baseUrl,
+                                     VivoAuthService authService,
+                                     VivoColdStore coldStore,
+                                     ObjectMapper objectMapper) {
+        this.baseUrl = trimTrailingSlash(baseUrl);
+        this.authService = authService;
+        this.coldStore = coldStore;
+        this.objectMapper = objectMapper;
+        this.httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
+    }
+
+    public void syncAllAuthorizedAccounts() {
+        List<VivoTokenRecord> tokens = authService.listAuthorizedSecondaryAccounts();
+        if (tokens == null || tokens.isEmpty()) {
+            return;
+        }
+        for (VivoTokenRecord token : tokens) {
+            if (token == null || token.getAccountId() == null || token.getAccountId().isBlank()) {
+                continue;
+            }
+            syncSecondaryAccount(token.getAccountId().trim());
+        }
+    }
+
+    public void syncSecondaryAccount(String secondaryAccountId) {
+        if (secondaryAccountId == null || secondaryAccountId.isBlank()) {
+            return;
+        }
+        String accessToken = authService.getAccessTokenBySecondaryAccountId(secondaryAccountId);
+        List<VivoAdvertiserAccountRecord> fetched = queryAllAdvertisers(secondaryAccountId, accessToken);
+        List<String> staleAdvertiserIds = new ArrayList<>(coldStore.listAdvertiserIdsBySecondaryAccountId(secondaryAccountId));
+        Set<String> freshIds = fetched.stream()
+                .map(VivoAdvertiserAccountRecord::getAdvertiserId)
+                .filter(id -> id != null && !id.isBlank())
+                .collect(Collectors.toCollection(LinkedHashSet::new));
+        staleAdvertiserIds.removeIf(freshIds::contains);
+
+        coldStore.replaceAdvertiserAccounts(secondaryAccountId, fetched);
+        authService.cacheAdvertiserMappings(fetched);
+        authService.evictAdvertiserMappings(staleAdvertiserIds);
+
+        log.info("[Vivo][广告主同步] secondaryAccountId={} count={} staleCount={}",
+                secondaryAccountId, fetched.size(), staleAdvertiserIds.size());
+    }
+
+    private List<VivoAdvertiserAccountRecord> queryAllAdvertisers(String secondaryAccountId, String accessToken) {
+        List<VivoAdvertiserAccountRecord> result = new ArrayList<>();
+        int pageIndex = 1;
+        int pageCount = 1;
+        while (pageIndex <= pageCount) {
+            PageResult page = queryAdvertiserPage(accessToken, pageIndex);
+            pageCount = Math.max(page.pageCount, 1);
+            if (page.records != null) {
+                result.addAll(page.records);
+            }
+            if (page.records == null || page.records.isEmpty()) {
+                break;
+            }
+            pageIndex++;
+        }
+        Instant now = Instant.now();
+        for (VivoAdvertiserAccountRecord record : result) {
+            record.setSecondaryAccountId(secondaryAccountId);
+            if (record.getLastSyncAt() == null) {
+                record.setLastSyncAt(now);
+            }
+            if (record.getUpdatedAt() == null) {
+                record.setUpdatedAt(now);
+            }
+        }
+        return result;
+    }
+
+    private PageResult queryAdvertiserPage(String accessToken, int pageIndex) {
+        try {
+            long timestamp = System.currentTimeMillis();
+            String nonce = UUID.randomUUID().toString().replace("-", "");
+            String url = baseUrl + ADVERTISER_QUERY_PATH + "?" + toQuery(Map.of(
+                    "access_token", accessToken,
+                    "timestamp", String.valueOf(timestamp),
+                    "nonce", nonce
+            ));
+            String body = objectMapper.writeValueAsString(Map.of("pageIndex", pageIndex, "pageSize", PAGE_SIZE));
+            HttpRequest request = HttpRequest.newBuilder()
+                    .uri(URI.create(url))
+                    .header("Content-Type", "application/json")
+                    .POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8))
+                    .timeout(Duration.ofSeconds(15))
+                    .build();
+            HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
+            if (response.statusCode() < 200 || response.statusCode() >= 300) {
+                throw new IllegalStateException("HTTP status: " + response.statusCode() + ", body=" + response.body());
+            }
+            JsonNode root = objectMapper.readTree(response.body());
+            int code = root.path("code").asInt(Integer.MIN_VALUE);
+            if (code != 0) {
+                throw new IllegalStateException("code=" + code + ", message=" + root.path("message").asText() + ", body=" + response.body());
+            }
+
+            JsonNode data = root.path("data");
+            JsonNode pageInfo = data.path("pageInfo");
+            int pageCount = pageInfo.path("pageCount").asInt(1);
+            List<VivoAdvertiserAccountRecord> records = new ArrayList<>();
+            JsonNode list = data.path("list");
+            if (list.isArray()) {
+                Instant now = Instant.now();
+                for (JsonNode item : list) {
+                    String advertiserId = text(item, "uuid");
+                    if (advertiserId == null || advertiserId.isBlank()) {
+                        continue;
+                    }
+                    VivoAdvertiserAccountRecord record = new VivoAdvertiserAccountRecord();
+                    record.setAdvertiserId(advertiserId);
+                    record.setAdvertiserName(text(item, "name"));
+                    record.setAdvertiserStatus(asInt(item.get("status")));
+                    record.setLastSyncAt(now);
+                    record.setUpdatedAt(now);
+                    records.add(record);
+                }
+            }
+            PageResult pageResult = new PageResult();
+            pageResult.pageCount = pageCount;
+            pageResult.records = records;
+            return pageResult;
+        } catch (Exception e) {
+            throw new RuntimeException("sync vivo advertiser page failed: " + e.getMessage(), e);
+        }
+    }
+
+    private static String toQuery(Map<String, String> params) {
+        StringBuilder sb = new StringBuilder();
+        boolean first = true;
+        for (Map.Entry<String, String> entry : params.entrySet()) {
+            if (entry.getValue() == null || entry.getValue().isBlank()) continue;
+            if (!first) sb.append('&');
+            first = false;
+            sb.append(URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8))
+                    .append('=')
+                    .append(URLEncoder.encode(entry.getValue(), StandardCharsets.UTF_8));
+        }
+        return sb.toString();
+    }
+
+    private static String text(JsonNode node, String field) {
+        JsonNode child = node == null ? null : node.get(field);
+        if (child == null || child.isNull()) {
+            return null;
+        }
+        String value = child.asText();
+        return value == null || value.isBlank() ? null : value.trim();
+    }
+
+    private static Integer asInt(JsonNode node) {
+        if (node == null || node.isNull()) {
+            return null;
+        }
+        if (node.isInt() || node.isLong()) {
+            return node.intValue();
+        }
+        try {
+            return Integer.parseInt(node.asText().trim());
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
+    private static String trimTrailingSlash(String value) {
+        if (value == null || value.isBlank()) {
+            return "https://marketing-api.vivo.com.cn";
+        }
+        String result = value.trim();
+        while (result.endsWith("/")) {
+            result = result.substring(0, result.length() - 1);
+        }
+        return result;
+    }
+
+    private static class PageResult {
+        private int pageCount;
+        private List<VivoAdvertiserAccountRecord> records;
+    }
+}

+ 574 - 0
src/main/java/com/adx/tencent/vivo/service/VivoAuthService.java

@@ -0,0 +1,574 @@
+package com.adx.tencent.vivo.service;
+
+import com.adx.tencent.vivo.model.VivoAdvertiserAccountRecord;
+import com.adx.tencent.vivo.model.VivoClientConfigRecord;
+import com.adx.tencent.vivo.model.VivoTokenRecord;
+import com.adx.tencent.vivo.store.VivoColdStore;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.data.redis.core.StringRedisTemplate;
+
+import java.net.URI;
+import java.net.URLEncoder;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+
+public class VivoAuthService {
+
+    private static final Logger log = LoggerFactory.getLogger(VivoAuthService.class);
+    private static final long REFRESH_AHEAD_MS = 60_000L;
+    private static final Duration DEFAULT_REDIS_TTL = Duration.ofDays(30);
+    private static final String TOKEN_PATH = "/openapi/v1/oauth2/token";
+    private static final String REFRESH_PATH = "/openapi/v1/oauth2/refreshToken";
+    private static final String AGENT_FETCH_PATH = "/openapi/v1/account/agent/fetch";
+
+    private final String baseUrl;
+    private final String redirectUri;
+    private final String tokenRedisKeyPrefix;
+    private final String advertiserRedisKeyPrefix;
+    private final StringRedisTemplate redis;
+    private final VivoColdStore coldStore;
+    private final ObjectMapper objectMapper;
+    private final HttpClient httpClient;
+    private final ConcurrentHashMap<String, Object> locks = new ConcurrentHashMap<>();
+
+    public VivoAuthService(String baseUrl,
+                           String redirectUri,
+                           String tokenRedisKeyPrefix,
+                           String advertiserRedisKeyPrefix,
+                           StringRedisTemplate redis,
+                           VivoColdStore coldStore,
+                           ObjectMapper objectMapper) {
+        this.baseUrl = trimTrailingSlash(baseUrl);
+        this.redirectUri = redirectUri;
+        this.tokenRedisKeyPrefix = tokenRedisKeyPrefix == null ? "adx:vivo:token:" : tokenRedisKeyPrefix;
+        this.advertiserRedisKeyPrefix = advertiserRedisKeyPrefix == null
+                ? "adx:vivo:advertiser:"
+                : advertiserRedisKeyPrefix;
+        this.redis = redis;
+        this.coldStore = coldStore;
+        this.objectMapper = objectMapper;
+        this.httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
+    }
+
+    public VivoTokenRecord authorizeSecondaryAccount(String secondaryAccountId, String clientId, String code) {
+        if (secondaryAccountId == null || secondaryAccountId.isBlank()) {
+            throw new IllegalArgumentException("vivo secondaryAccountId is required");
+        }
+        if (code == null || code.isBlank()) {
+            throw new IllegalArgumentException("vivo authorization code is required");
+        }
+        String expectedSecondaryAccountId = secondaryAccountId.trim();
+        ClientCredentials credentials = resolveAuthorizeCredentials(expectedSecondaryAccountId, clientId);
+        TokenPayload payload = fetchToken(Map.of(
+                "client_id", credentials.clientId,
+                "client_secret", credentials.clientSecret,
+                "grant_type", "code",
+                "code", code
+        ), TOKEN_PATH);
+
+        AgentAccountProfile profile = fetchAgentAccountProfile(payload.accessToken);
+        if (profile.accountId == null || profile.accountId.isBlank()) {
+            throw new IllegalStateException("vivo secondary account uuid is missing");
+        }
+        if (!expectedSecondaryAccountId.equals(profile.accountId)) {
+            throw new IllegalStateException("authorized vivo secondaryAccountId mismatch: expected="
+                    + expectedSecondaryAccountId + ", actual=" + profile.accountId);
+        }
+
+        VivoTokenRecord record = buildTokenRecord(profile, payload, expectedSecondaryAccountId, credentials);
+        saveToken(record);
+        log.info("[Vivo][授权] secondaryAccountId={} accountName={} level={} status={} tokenExpireAt={} refreshExpireAt={}",
+                record.getAccountId(), record.getAccountName(), record.getAccountLevel(), record.getAccountStatus(),
+                record.getTokenExpireAt(), record.getRefreshTokenExpireAt());
+        return record;
+    }
+
+    public String getAccessTokenForAdvertiser(String advertiserId) {
+        if (advertiserId == null || advertiserId.isBlank()) {
+            throw new IllegalArgumentException("vivo advertiserId is required");
+        }
+        String normalizedAdvertiserId = advertiserId.trim();
+        String secondaryAccountId = resolveSecondaryAccountId(normalizedAdvertiserId);
+        if (secondaryAccountId != null && !secondaryAccountId.isBlank()) {
+            return getAccessTokenBySecondaryAccountId(secondaryAccountId);
+        }
+
+        VivoTokenRecord directAuthorized = loadToken(normalizedAdvertiserId);
+        if (directAuthorized != null) {
+            log.warn("[Vivo][Token] advertiserId={} 未命中二代映射,退回使用同ID授权 token", normalizedAdvertiserId);
+            return getAccessTokenBySecondaryAccountId(normalizedAdvertiserId);
+        }
+        throw new IllegalStateException("vivo advertiser not mapped to any secondary account: " + normalizedAdvertiserId);
+    }
+
+    public String getAccessTokenBySecondaryAccountId(String secondaryAccountId) {
+        if (secondaryAccountId == null || secondaryAccountId.isBlank()) {
+            throw new IllegalArgumentException("vivo secondaryAccountId is required");
+        }
+        String normalizedSecondaryAccountId = secondaryAccountId.trim();
+        synchronized (locks.computeIfAbsent(normalizedSecondaryAccountId, ignored -> new Object())) {
+            VivoTokenRecord record = loadToken(normalizedSecondaryAccountId);
+            long now = System.currentTimeMillis();
+            if (isUsable(record != null ? record.getAccessToken() : null, record != null ? record.getTokenExpireAt() : null, now)) {
+                return record.getAccessToken();
+            }
+            if (record != null && isUsable(record.getRefreshToken(), record.getRefreshTokenExpireAt(), now)) {
+                VivoTokenRecord refreshed = refresh(normalizedSecondaryAccountId, record.getRefreshToken());
+                return refreshed.getAccessToken();
+            }
+            if (record != null && record.getAccessToken() != null && !record.getAccessToken().isBlank()) {
+                log.warn("[Vivo][Token] secondaryAccountId={} access_token 已过期且 refresh_token 不可用,暂退回使用旧 token",
+                        normalizedSecondaryAccountId);
+                return record.getAccessToken();
+            }
+            throw new IllegalStateException("vivo secondary account not authorized: " + normalizedSecondaryAccountId);
+        }
+    }
+
+    public List<VivoTokenRecord> listAuthorizedSecondaryAccounts() {
+        if (coldStore == null) {
+            return List.of();
+        }
+        return coldStore.listTokens();
+    }
+
+    public void cacheAdvertiserMappings(List<VivoAdvertiserAccountRecord> records) {
+        if (records == null || records.isEmpty()) {
+            return;
+        }
+        for (VivoAdvertiserAccountRecord record : records) {
+            cacheAdvertiserMapping(record);
+        }
+    }
+
+    public void evictAdvertiserMappings(List<String> advertiserIds) {
+        if (advertiserIds == null || advertiserIds.isEmpty()) {
+            return;
+        }
+        for (String advertiserId : advertiserIds) {
+            if (advertiserId == null || advertiserId.isBlank()) {
+                continue;
+            }
+            try {
+                redis.delete(advertiserRedisKey(advertiserId.trim()));
+            } catch (Exception e) {
+                log.warn("[Vivo][Advertiser] evict redis failed | advertiserId={} | error={}",
+                        advertiserId, e.getMessage());
+            }
+        }
+    }
+
+    public String buildAuthorizeUrl(String secondaryAccountId, String clientId) {
+        String normalizedSecondaryAccountId = secondaryAccountId == null ? "" : secondaryAccountId.trim();
+        ClientCredentials credentials = requireClientConfig(clientId);
+        return trimTrailingSlash("https://open-ad.vivo.com.cn")
+                + "/OAuth?clientId=" + urlEncode(credentials.clientId)
+                + "&state=" + urlEncode(normalizedSecondaryAccountId)
+                + "&redirectUri=" + urlEncode(redirectUri == null ? "" : redirectUri);
+    }
+
+    public String buildAuthorizeState(String secondaryAccountId, String clientId) {
+        if (secondaryAccountId == null || secondaryAccountId.isBlank()) {
+            throw new IllegalArgumentException("vivo secondaryAccountId is required");
+        }
+        return secondaryAccountId.trim();
+    }
+
+    private String resolveSecondaryAccountId(String advertiserId) {
+        String cached = loadSecondaryAccountIdFromRedis(advertiserId);
+        if (cached != null) {
+            return cached;
+        }
+        if (coldStore == null) {
+            return null;
+        }
+        VivoAdvertiserAccountRecord record = coldStore.getAdvertiserAccount(advertiserId);
+        if (record != null && record.getSecondaryAccountId() != null && !record.getSecondaryAccountId().isBlank()) {
+            cacheAdvertiserMapping(record);
+            return record.getSecondaryAccountId();
+        }
+        return null;
+    }
+
+    private String loadSecondaryAccountIdFromRedis(String advertiserId) {
+        try {
+            String payload = redis.opsForValue().get(advertiserRedisKey(advertiserId));
+            return payload == null || payload.isBlank() ? null : payload.trim();
+        } catch (Exception e) {
+            log.warn("[Vivo][Advertiser] read redis failed | advertiserId={} | error={}", advertiserId, e.getMessage());
+            return null;
+        }
+    }
+
+    private void cacheAdvertiserMapping(VivoAdvertiserAccountRecord record) {
+        if (record == null || record.getAdvertiserId() == null || record.getAdvertiserId().isBlank()
+                || record.getSecondaryAccountId() == null || record.getSecondaryAccountId().isBlank()) {
+            return;
+        }
+        try {
+            redis.opsForValue().set(
+                    advertiserRedisKey(record.getAdvertiserId().trim()),
+                    record.getSecondaryAccountId().trim(),
+                    DEFAULT_REDIS_TTL
+            );
+        } catch (Exception e) {
+            log.warn("[Vivo][Advertiser] write redis failed | advertiserId={} | secondaryAccountId={} | error={}",
+                    record.getAdvertiserId(), record.getSecondaryAccountId(), e.getMessage());
+        }
+    }
+
+    private VivoTokenRecord refresh(String secondaryAccountId, String refreshToken) {
+        ClientCredentials credentials = requireStoredCredentials(secondaryAccountId);
+        TokenPayload payload = fetchToken(Map.of(
+                "client_id", credentials.clientId,
+                "client_secret", credentials.clientSecret,
+                "refresh_token", refreshToken
+        ), REFRESH_PATH);
+        AgentAccountProfile profile = fetchAgentAccountProfile(payload.accessToken);
+        if (profile.accountId != null && !profile.accountId.isBlank() && !secondaryAccountId.equals(profile.accountId)) {
+            throw new IllegalStateException("refreshed vivo secondaryAccountId mismatch: expected="
+                    + secondaryAccountId + ", actual=" + profile.accountId);
+        }
+
+        VivoTokenRecord existing = coldStore != null ? coldStore.getToken(secondaryAccountId) : null;
+        VivoTokenRecord record = buildTokenRecord(profile, payload, secondaryAccountId, credentials);
+        record.setAuthorizedAt(existing != null && existing.getAuthorizedAt() != null ? existing.getAuthorizedAt() : Instant.now());
+        saveToken(record);
+        log.info("[Vivo][Token] secondaryAccountId={} refresh success tokenExpireAt={} refreshExpireAt={}",
+                secondaryAccountId, payload.tokenExpireAt, payload.refreshTokenExpireAt);
+        return record;
+    }
+
+    private VivoTokenRecord buildTokenRecord(AgentAccountProfile profile,
+                                             TokenPayload payload,
+                                             String secondaryAccountId,
+                                             ClientCredentials credentials) {
+        VivoTokenRecord record = new VivoTokenRecord();
+        record.setAccountId(secondaryAccountId);
+        record.setAccountName(profile.accountName);
+        record.setAccountLevel(profile.accountLevel);
+        record.setAccountStatus(profile.accountStatus);
+        record.setClientId(credentials.clientId);
+        record.setClientSecret(credentials.clientSecret);
+        record.setAccessToken(payload.accessToken);
+        record.setRefreshToken(payload.refreshToken);
+        record.setTokenExpireAt(payload.tokenExpireAt);
+        record.setRefreshTokenExpireAt(payload.refreshTokenExpireAt);
+        if (record.getAuthorizedAt() == null) {
+            record.setAuthorizedAt(Instant.now());
+        }
+        record.setUpdatedAt(Instant.now());
+        return record;
+    }
+
+    private AgentAccountProfile fetchAgentAccountProfile(String accessToken) {
+        try {
+            long timestamp = System.currentTimeMillis();
+            String nonce = randomNonce();
+            HttpRequest request = HttpRequest.newBuilder()
+                    .uri(URI.create(baseUrl + AGENT_FETCH_PATH + "?" + toQuery(Map.of(
+                            "access_token", accessToken,
+                            "timestamp", String.valueOf(timestamp),
+                            "nonce", nonce
+                    ))))
+                    .GET()
+                    .timeout(Duration.ofSeconds(15))
+                    .build();
+            HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
+            if (response.statusCode() < 200 || response.statusCode() >= 300) {
+                throw new IllegalStateException("HTTP status: " + response.statusCode() + ", body=" + response.body());
+            }
+            JsonNode root = objectMapper.readTree(response.body());
+            int code = root.path("code").asInt(Integer.MIN_VALUE);
+            if (code != 0) {
+                throw new IllegalStateException("code=" + code + ", message=" + root.path("message").asText() + ", body=" + response.body());
+            }
+            JsonNode data = root.path("data");
+            AgentAccountProfile profile = new AgentAccountProfile();
+            profile.accountId = text(data, "uuid");
+            profile.accountName = text(data, "name");
+            profile.accountLevel = asInt(data.get("level"));
+            profile.accountStatus = asInt(data.get("status"));
+            return profile;
+        } catch (Exception e) {
+            throw new RuntimeException("fetch vivo secondary account failed: " + e.getMessage(), e);
+        }
+    }
+
+    private TokenPayload fetchToken(Map<String, String> params, String path) {
+        try {
+            HttpRequest request = HttpRequest.newBuilder()
+                    .uri(URI.create(baseUrl + path + "?" + toQuery(params)))
+                    .GET()
+                    .timeout(Duration.ofSeconds(15))
+                    .build();
+            HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
+            if (response.statusCode() < 200 || response.statusCode() >= 300) {
+                throw new IllegalStateException("HTTP status: " + response.statusCode() + ", body=" + response.body());
+            }
+            JsonNode root = objectMapper.readTree(response.body());
+            int code = root.path("code").asInt(Integer.MIN_VALUE);
+            if (code != 0) {
+                throw new IllegalStateException("code=" + code + ", message=" + root.path("message").asText() + ", body=" + response.body());
+            }
+            JsonNode data = root.path("data");
+            TokenPayload payload = new TokenPayload();
+            payload.accessToken = text(data, "access_token");
+            payload.refreshToken = text(data, "refresh_token");
+            payload.tokenExpireAt = asLong(data.get("token_date"));
+            payload.refreshTokenExpireAt = asLong(data.get("refresh_token_date"));
+            if (payload.accessToken == null || payload.accessToken.isBlank()) {
+                throw new IllegalStateException("missing access_token in vivo token response");
+            }
+            return payload;
+        } catch (Exception e) {
+            throw new RuntimeException("fetch vivo token failed: " + e.getMessage(), e);
+        }
+    }
+
+    private VivoTokenRecord loadToken(String secondaryAccountId) {
+        VivoTokenRecord cached = loadTokenFromRedis(secondaryAccountId);
+        if (cached != null) {
+            return cached;
+        }
+        if (coldStore == null) {
+            return null;
+        }
+        VivoTokenRecord db = coldStore.getToken(secondaryAccountId);
+        if (db != null) {
+            cacheToken(db);
+        }
+        return db;
+    }
+
+    private VivoTokenRecord loadTokenFromRedis(String secondaryAccountId) {
+        try {
+            String payload = redis.opsForValue().get(tokenRedisKey(secondaryAccountId));
+            if (payload == null || payload.isBlank()) {
+                return null;
+            }
+            return objectMapper.readValue(payload, VivoTokenRecord.class);
+        } catch (Exception e) {
+            log.warn("[Vivo][Token] read redis failed | secondaryAccountId={} | error={}",
+                    secondaryAccountId, e.getMessage());
+            return null;
+        }
+    }
+
+    private void saveToken(VivoTokenRecord record) {
+        cacheToken(record);
+        if (coldStore != null) {
+            coldStore.saveToken(record);
+        }
+    }
+
+    private void cacheToken(VivoTokenRecord record) {
+        try {
+            redis.opsForValue().set(
+                    tokenRedisKey(record.getAccountId()),
+                    objectMapper.writeValueAsString(record),
+                    resolveRedisTtl(record)
+            );
+        } catch (Exception e) {
+            log.warn("[Vivo][Token] write redis failed | secondaryAccountId={} | error={}",
+                    record.getAccountId(), e.getMessage());
+        }
+    }
+
+    private Duration resolveRedisTtl(VivoTokenRecord record) {
+        long now = System.currentTimeMillis();
+        long expireAt = record.getRefreshTokenExpireAt() != null && record.getRefreshTokenExpireAt() > now
+                ? record.getRefreshTokenExpireAt()
+                : record.getTokenExpireAt() != null && record.getTokenExpireAt() > now
+                ? record.getTokenExpireAt()
+                : now + DEFAULT_REDIS_TTL.toMillis();
+        long ttlMs = Math.max(expireAt - now, Duration.ofMinutes(5).toMillis());
+        return Duration.ofMillis(ttlMs);
+    }
+
+    private String tokenRedisKey(String secondaryAccountId) {
+        return tokenRedisKeyPrefix + secondaryAccountId;
+    }
+
+    private String advertiserRedisKey(String advertiserId) {
+        return advertiserRedisKeyPrefix + advertiserId;
+    }
+
+    private ClientCredentials requireStoredCredentials(String secondaryAccountId) {
+        if (secondaryAccountId == null || secondaryAccountId.isBlank()) {
+            throw new IllegalArgumentException("vivo secondaryAccountId is required");
+        }
+        VivoTokenRecord record = loadToken(secondaryAccountId.trim());
+        String clientId = record == null ? null : trimToNull(record.getClientId());
+        String clientSecret = record == null ? null : trimToNull(record.getClientSecret());
+        if (clientId == null || clientSecret == null) {
+            throw new IllegalStateException("vivo clientId/clientSecret not maintained in DB for secondaryAccountId="
+                    + secondaryAccountId);
+        }
+        ClientCredentials credentials = new ClientCredentials();
+        credentials.clientId = clientId;
+        credentials.clientSecret = clientSecret;
+        return credentials;
+    }
+
+    private ClientCredentials resolveAuthorizeCredentials(String secondaryAccountId, String clientId) {
+        String normalizedClientId = trimToNull(clientId);
+        if (normalizedClientId != null) {
+            return requireClientConfig(normalizedClientId);
+        }
+
+        VivoTokenRecord existing = loadToken(secondaryAccountId);
+        String storedClientId = existing == null ? null : trimToNull(existing.getClientId());
+        String storedClientSecret = existing == null ? null : trimToNull(existing.getClientSecret());
+        if (storedClientId != null && storedClientSecret != null) {
+            ClientCredentials credentials = new ClientCredentials();
+            credentials.clientId = storedClientId;
+            credentials.clientSecret = storedClientSecret;
+            return credentials;
+        }
+
+        List<VivoClientConfigRecord> enabledConfigs = coldStore == null ? List.of() : coldStore.listEnabledClientConfigs();
+        if (enabledConfigs.size() == 1) {
+            return requireClientConfig(enabledConfigs.get(0).getClientId());
+        }
+        if (enabledConfigs.isEmpty()) {
+            throw new IllegalStateException("no enabled vivo client config found in vivo_client_configs");
+        }
+        throw new IllegalStateException("multiple enabled vivo client configs found; clientId is required for secondaryAccountId="
+                + secondaryAccountId);
+    }
+
+    private ClientCredentials requireClientConfig(String clientId) {
+        String normalizedClientId = trimToNull(clientId);
+        if (normalizedClientId == null) {
+            throw new IllegalArgumentException("vivo clientId is required");
+        }
+        VivoClientConfigRecord record = coldStore == null ? null : coldStore.getClientConfig(normalizedClientId);
+        if (record == null) {
+            throw new IllegalStateException("vivo clientId not maintained in vivo_client_configs: " + normalizedClientId);
+        }
+        if (Boolean.FALSE.equals(record.getEnabled())) {
+            throw new IllegalStateException("vivo clientId disabled: " + normalizedClientId);
+        }
+        String clientSecret = trimToNull(record.getClientSecret());
+        if (clientSecret == null) {
+            throw new IllegalStateException("vivo clientSecret is empty for clientId=" + normalizedClientId);
+        }
+        ClientCredentials credentials = new ClientCredentials();
+        credentials.clientId = normalizedClientId;
+        credentials.clientSecret = clientSecret;
+        return credentials;
+    }
+
+    private static boolean isUsable(String token, Long expireAt, long now) {
+        return token != null && !token.isBlank() && expireAt != null && expireAt - now > REFRESH_AHEAD_MS;
+    }
+
+    private static String toQuery(Map<String, String> params) {
+        StringBuilder sb = new StringBuilder();
+        boolean first = true;
+        for (Map.Entry<String, String> entry : params.entrySet()) {
+            if (entry.getValue() == null || entry.getValue().isBlank()) continue;
+            if (!first) sb.append('&');
+            first = false;
+            sb.append(urlEncode(entry.getKey())).append('=').append(urlEncode(entry.getValue()));
+        }
+        return sb.toString();
+    }
+
+    private static String randomNonce() {
+        return UUID.randomUUID().toString().replace("-", "");
+    }
+
+    private static String urlEncode(String value) {
+        return URLEncoder.encode(value, StandardCharsets.UTF_8);
+    }
+
+    private static String text(JsonNode node, String field) {
+        JsonNode child = node == null ? null : node.get(field);
+        if (child == null || child.isNull()) {
+            return null;
+        }
+        String value = child.asText();
+        return value == null || value.isBlank() ? null : value.trim();
+    }
+
+    private static Long asLong(JsonNode node) {
+        if (node == null || node.isNull()) {
+            return null;
+        }
+        if (node.isNumber()) {
+            return node.longValue();
+        }
+        try {
+            return Long.parseLong(node.asText().trim());
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
+    private static Integer asInt(JsonNode node) {
+        if (node == null || node.isNull()) {
+            return null;
+        }
+        if (node.isInt() || node.isLong()) {
+            return node.intValue();
+        }
+        try {
+            return Integer.parseInt(node.asText().trim());
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
+    private static String trimToNull(String value) {
+        if (value == null) {
+            return null;
+        }
+        String trimmed = value.trim();
+        return trimmed.isEmpty() ? null : trimmed;
+    }
+
+    private static String trimTrailingSlash(String value) {
+        if (value == null || value.isBlank()) {
+            return "https://marketing-api.vivo.com.cn";
+        }
+        String result = value.trim();
+        while (result.endsWith("/")) {
+            result = result.substring(0, result.length() - 1);
+        }
+        return result;
+    }
+
+    private static class TokenPayload {
+        private String accessToken;
+        private String refreshToken;
+        private Long tokenExpireAt;
+        private Long refreshTokenExpireAt;
+    }
+
+    private static class AgentAccountProfile {
+        private String accountId;
+        private String accountName;
+        private Integer accountLevel;
+        private Integer accountStatus;
+    }
+
+    private static class ClientCredentials {
+        private String clientId;
+        private String clientSecret;
+    }
+}

+ 146 - 0
src/main/java/com/adx/tencent/vivo/service/VivoBackgroundTasks.java

@@ -0,0 +1,146 @@
+package com.adx.tencent.vivo.service;
+
+import com.adx.tencent.config.AppProperties;
+import com.adx.tencent.leader.LeaderElection;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+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 VivoBackgroundTasks implements ApplicationRunner {
+
+    private static final Logger log = LoggerFactory.getLogger(VivoBackgroundTasks.class);
+
+    @Autowired private AppProperties props;
+    @Autowired(required = false) private VivoColdWorker vivoColdWorker;
+    @Autowired(required = false) private VivoRetryService vivoRetryService;
+    @Autowired(required = false) private VivoTagEventSyncService vivoTagEventSyncService;
+    @Autowired(required = false) private VivoAdvertiserSyncService vivoAdvertiserSyncService;
+    @Autowired(required = false) @Nullable private LeaderElection leaderElection;
+
+    private final AtomicBoolean stopped = new AtomicBoolean(false);
+    private final ExecutorService executor = Executors.newCachedThreadPool(r -> {
+        Thread t = new Thread(r);
+        t.setDaemon(true);
+        return t;
+    });
+
+    @Override
+    public void run(ApplicationArguments args) {
+        if (vivoColdWorker != null) {
+            executor.submit(() -> {
+                while (!stopped.get()) {
+                    try {
+                        vivoColdWorker.runOnce();
+                    } catch (Exception e) {
+                        log.error("vivo cold worker: {}", e.getMessage(), e);
+                        sleep(1000);
+                    }
+                }
+            });
+        }
+
+        if (vivoRetryService != null && props.isVivoCallbackRetryEnabled()) {
+            if (props.isSkipLeaderElection() || leaderElection == null) {
+                executor.submit(() -> runRetry(() -> stopped.get()));
+            } else {
+                executor.submit(() -> leaderElection.run(
+                        "adx:lock:vivo:callback-retry",
+                        props.getTaskLockTtl(), props.getTaskLockRenewInterval(), props.getTaskLockRetryInterval(),
+                        stopped::get,
+                        this::runRetry,
+                        e -> log.error("vivo callback retry leader: {}", e.getMessage(), e)
+                ));
+            }
+        }
+
+        if (vivoTagEventSyncService != null && props.isVivoTagEventSyncEnabled()) {
+            if (props.isSkipLeaderElection() || leaderElection == null) {
+                executor.submit(() -> runTagEventSync(() -> stopped.get()));
+            } else {
+                executor.submit(() -> leaderElection.run(
+                        "adx:lock:vivo:tag-event-sync",
+                        props.getTaskLockTtl(), props.getTaskLockRenewInterval(), props.getTaskLockRetryInterval(),
+                        stopped::get,
+                        this::runTagEventSync,
+                        e -> log.error("vivo tag event sync leader: {}", e.getMessage(), e)
+                ));
+            }
+        }
+
+        if (vivoAdvertiserSyncService != null && props.isVivoAdvertiserSyncEnabled()) {
+            if (props.isSkipLeaderElection() || leaderElection == null) {
+                executor.submit(() -> runAdvertiserSync(() -> stopped.get()));
+            } else {
+                executor.submit(() -> leaderElection.run(
+                        "adx:lock:vivo:advertiser-sync",
+                        props.getTaskLockTtl(), props.getTaskLockRenewInterval(), props.getTaskLockRetryInterval(),
+                        stopped::get,
+                        this::runAdvertiserSync,
+                        e -> log.error("vivo advertiser sync leader: {}", e.getMessage(), e)
+                ));
+            }
+        }
+    }
+
+    private void runRetry(LeaderElection.StopSignal stopSignal) {
+        long intervalMs = props.getVivoCallbackRetryInterval().toMillis();
+        while (!stopSignal.isStopped()) {
+            try {
+                vivoRetryService.retryCallbacks(props.getVivoCallbackRetryLimit());
+            } catch (Exception e) {
+                log.error("vivo callback retry: {}", e.getMessage(), e);
+            }
+            sleepResponsive(intervalMs, stopSignal);
+        }
+    }
+
+    private void runTagEventSync(LeaderElection.StopSignal stopSignal) {
+        long intervalMs = props.getVivoTagEventSyncInterval().toMillis();
+        while (!stopSignal.isStopped()) {
+            try {
+                vivoTagEventSyncService.syncOnce();
+            } catch (Exception e) {
+                log.error("vivo tag event sync: {}", e.getMessage(), e);
+            }
+            sleepResponsive(intervalMs, stopSignal);
+        }
+    }
+
+    private void runAdvertiserSync(LeaderElection.StopSignal stopSignal) {
+        long intervalMs = props.getVivoAdvertiserSyncInterval().toMillis();
+        while (!stopSignal.isStopped()) {
+            try {
+                vivoAdvertiserSyncService.syncAllAuthorizedAccounts();
+            } catch (Exception e) {
+                log.error("vivo advertiser sync: {}", e.getMessage(), e);
+            }
+            sleepResponsive(intervalMs, stopSignal);
+        }
+    }
+
+    private void sleepResponsive(long millis, LeaderElection.StopSignal stopSignal) {
+        long remaining = millis;
+        while (remaining > 0 && !stopSignal.isStopped()) {
+            long chunk = Math.min(remaining, 1000);
+            sleep(chunk);
+            remaining -= chunk;
+        }
+    }
+
+    private static void sleep(long millis) {
+        try {
+            Thread.sleep(millis);
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+        }
+    }
+}

+ 69 - 0
src/main/java/com/adx/tencent/vivo/service/VivoColdWorker.java

@@ -0,0 +1,69 @@
+package com.adx.tencent.vivo.service;
+
+import com.adx.tencent.vivo.model.VivoBidRecord;
+import com.adx.tencent.vivo.model.VivoTrackingRecord;
+import com.adx.tencent.vivo.store.VivoColdStore;
+import com.adx.tencent.vivo.store.VivoHotStore;
+import com.adx.tencent.storage.model.QueuedEvent;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+
+public class VivoColdWorker {
+
+    private final VivoHotStore hotStore;
+    private final VivoColdStore coldStore;
+    private final String group;
+    private final String consumer;
+    private final int batch;
+    private final Duration pendingIdle;
+    private final long streamMaxLen;
+    private final ObjectMapper objectMapper;
+
+    public VivoColdWorker(VivoHotStore hotStore, VivoColdStore coldStore,
+                           String group, String consumer, int batch,
+                           Duration pendingIdle, long streamMaxLen,
+                           ObjectMapper objectMapper) {
+        this.hotStore = hotStore;
+        this.coldStore = coldStore;
+        this.group = group;
+        this.consumer = consumer;
+        this.batch = batch <= 0 ? 100 : batch;
+        this.pendingIdle = pendingIdle == null || pendingIdle.isZero() ? Duration.ofMinutes(2) : pendingIdle;
+        this.streamMaxLen = streamMaxLen;
+        this.objectMapper = objectMapper;
+    }
+
+    public int runOnce() throws Exception {
+        hotStore.ensureGroup(group);
+        List<QueuedEvent> events = hotStore.claimStale(group, consumer, pendingIdle, batch);
+        if (events.isEmpty()) events = hotStore.read(group, consumer, batch);
+        if (events.isEmpty()) return 0;
+
+        List<String> acked = new ArrayList<>();
+        Exception failed = null;
+        for (QueuedEvent event : events) {
+            try {
+                handleEvent(event);
+            } catch (Exception e) {
+                failed = e;
+                break;
+            }
+            acked.add(event.getId());
+        }
+        if (failed != null) throw failed;
+        hotStore.ack(group, acked);
+        if (streamMaxLen > 0) hotStore.trim(streamMaxLen);
+        return events.size();
+    }
+
+    private void handleEvent(QueuedEvent event) throws Exception {
+        switch (event.getType()) {
+            case "bid" -> coldStore.saveBid(objectMapper.readValue(event.getPayload(), VivoBidRecord.class));
+            case "tracking" -> coldStore.saveTracking(objectMapper.readValue(event.getPayload(), VivoTrackingRecord.class));
+            default -> throw new IllegalArgumentException("unknown vivo event type: " + event.getType());
+        }
+    }
+}

+ 137 - 0
src/main/java/com/adx/tencent/vivo/service/VivoPlacement.java

@@ -0,0 +1,137 @@
+package com.adx.tencent.vivo.service;
+
+import java.util.List;
+import java.util.Map;
+
+public class VivoPlacement {
+
+    private long baiduMediaId;
+    private String baiduAppId;
+    private String baiduTagId;
+    private Map<String, VivoAppPlacement> platforms;
+    private long bidFloor;
+    private List<Integer> actionTypes;
+
+    public long getBaiduMediaId() { return baiduMediaId; }
+    public void setBaiduMediaId(long v) { this.baiduMediaId = v; }
+    public String getBaiduAppId() { return baiduAppId; }
+    public void setBaiduAppId(String v) { this.baiduAppId = v; }
+    public String getBaiduTagId() { return baiduTagId; }
+    public void setBaiduTagId(String v) { this.baiduTagId = v; }
+    public Map<String, VivoAppPlacement> getPlatforms() { return platforms; }
+    public void setPlatforms(Map<String, VivoAppPlacement> v) { this.platforms = v; }
+    public long getBidFloor() { return bidFloor; }
+    public void setBidFloor(long v) { this.bidFloor = v; }
+    public List<Integer> getActionTypes() { return actionTypes; }
+    public void setActionTypes(List<Integer> v) { this.actionTypes = v; }
+
+    public Resolved resolve(Map<String, String> params) {
+        String tagId = firstParam(params, "tagId", "tag_id", "baidu_tag_id");
+        if (tagId != null && !tagId.isBlank()) {
+            Resolved r = resolveByTagId(tagId);
+            if (r != null) return r;
+            throw new IllegalArgumentException("unknown vivo baidu tag id: " + tagId);
+        }
+
+        String platform = inferPlatform(params);
+        if (platform != null && !platform.isBlank()) {
+            Resolved r = resolveByPlatform(platform);
+            if (r != null) return r;
+            throw new IllegalArgumentException("unsupported vivo baidu platform: " + platform);
+        }
+
+        if (baiduAppId != null && !baiduAppId.isBlank()
+                && baiduTagId != null && !baiduTagId.isBlank()) {
+            return new Resolved(null, baiduAppId, baiduTagId);
+        }
+
+        if (platforms != null && platforms.size() == 1) {
+            String p = platforms.keySet().iterator().next();
+            Resolved r = resolveByPlatform(p);
+            if (r != null) return r;
+        }
+
+        throw new IllegalArgumentException("missing vivo tagId or platform");
+    }
+
+    private Resolved resolveByTagId(String tagId) {
+        if (platforms != null) {
+            for (Map.Entry<String, VivoAppPlacement> e : platforms.entrySet()) {
+                VivoAppPlacement p = e.getValue();
+                if (p.getAppId() != null && p.getTagIds() != null && p.getTagIds().contains(tagId)) {
+                    return new Resolved(e.getKey(), p.getAppId(), tagId);
+                }
+            }
+        }
+        if (baiduAppId != null && tagId.equals(baiduTagId)) {
+            return new Resolved(null, baiduAppId, baiduTagId);
+        }
+        return null;
+    }
+
+    private Resolved resolveByPlatform(String platform) {
+        if (platforms == null) return null;
+        VivoAppPlacement p = platforms.get(platform);
+        if (p == null || p.getAppId() == null || p.getTagIds() == null || p.getTagIds().isEmpty()) {
+            return null;
+        }
+        if (p.getTagIds().size() > 1) {
+            throw new IllegalArgumentException(
+                    "tagId is required for vivo platform " + platform + " because multiple tag ids are configured");
+        }
+        return new Resolved(platform, p.getAppId(), p.getTagIds().get(0));
+    }
+
+    private static String firstParam(Map<String, String> p, String... keys) {
+        if (p == null) return null;
+        for (String k : keys) {
+            String v = p.get(k);
+            if (v != null && !v.isBlank()) return v.trim();
+        }
+        return null;
+    }
+
+    public static String normalizePlatform(String value) {
+        if (value == null) return null;
+        return switch (value.toLowerCase().trim()) {
+            case "ios", "iphone", "ipad", "1" -> "ios";
+            case "android", "安卓", "0", "2" -> "android";
+            default -> null;
+        };
+    }
+
+    private static String inferPlatform(Map<String, String> params) {
+        String platform = normalizePlatform(firstParam(params, "platform", "os", "system", "device_os"));
+        if (platform != null) {
+            return platform;
+        }
+        if (hasAny(params, "idfaMD5", "idfa_md5", "idfaSHA1", "idfa_sha1", "idfa", "kenyid_caa", "kenyIdCaa", "caid")) {
+            return "ios";
+        }
+        if (hasAny(params, "oaidMD5", "oaid_md5", "oaid", "imeiMD5", "imei_md5", "imei", "androidIdMD5", "android_id_md5", "androidid", "androidId", "android_id")) {
+            return "android";
+        }
+        return null;
+    }
+
+    private static boolean hasAny(Map<String, String> params, String... keys) {
+        for (String key : keys) {
+            String value = params.get(key);
+            if (value != null && !value.isBlank()) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    public static class VivoAppPlacement {
+        private String appId;
+        private List<String> tagIds;
+        public String getAppId() { return appId; }
+        public void setAppId(String v) { this.appId = v; }
+        public List<String> getTagIds() { return tagIds; }
+        public void setTagIds(List<String> v) { this.tagIds = v; }
+    }
+
+    public record Resolved(String platform, String appId, String tagId) {}
+}

+ 75 - 0
src/main/java/com/adx/tencent/vivo/service/VivoRetryService.java

@@ -0,0 +1,75 @@
+package com.adx.tencent.vivo.service;
+
+import com.adx.tencent.vivo.client.VivoClient;
+import com.adx.tencent.vivo.model.VivoMediaCallbackRecord;
+import com.adx.tencent.vivo.store.VivoColdStore;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+
+public class VivoRetryService {
+
+    private final VivoColdStore coldStore;
+    private final VivoClient vivoClient;
+    private final int defaultLimit;
+
+    public VivoRetryService(VivoColdStore coldStore, VivoClient vivoClient, int defaultLimit) {
+        this.coldStore = coldStore;
+        this.vivoClient = vivoClient;
+        this.defaultLimit = defaultLimit > 0 ? defaultLimit : 100;
+    }
+
+    public RetryResult retryCallbacks(int limit) {
+        List<VivoMediaCallbackRecord> pending = coldStore.pendingMediaCallbacks(limit <= 0 ? defaultLimit : limit);
+        RetryResult result = new RetryResult();
+        result.fetched = pending.size();
+        List<VivoMediaCallbackRecord> saved = new ArrayList<>();
+        for (VivoMediaCallbackRecord record : pending) {
+            try {
+                VivoMediaCallbackRecord retry = vivoClient.retryCallback(record);
+                saved.add(retry);
+                if (retry.isOk()) result.sent++;
+                else result.failed++;
+            } catch (Exception e) {
+                VivoMediaCallbackRecord failed = copy(record);
+                failed.setAttempt(record.getAttempt() + 1);
+                failed.setOk(false);
+                failed.setErrorMessage(e.getMessage());
+                failed.setCreatedAt(Instant.now());
+                saved.add(failed);
+                result.failed++;
+            }
+        }
+        coldStore.saveMediaCallbacks(saved);
+        return result;
+    }
+
+    private static VivoMediaCallbackRecord copy(VivoMediaCallbackRecord src) {
+        VivoMediaCallbackRecord copy = new VivoMediaCallbackRecord();
+        copy.setDedupeKey(src.getDedupeKey());
+        copy.setQk(src.getQk());
+        copy.setTagId(src.getTagId());
+        copy.setCallbackUrl(src.getCallbackUrl());
+        copy.setEventType(src.getEventType());
+        copy.setEventTimeMs(src.getEventTimeMs());
+        copy.setPurchase(src.getPurchase());
+        copy.setDeductionRate(src.getDeductionRate());
+        copy.setDirectMatch(src.isDirectMatch());
+        copy.setDispatchStatus(src.getDispatchStatus());
+        copy.setStatus(src.getStatus());
+        copy.setOk(src.isOk());
+        copy.setAttempt(src.getAttempt());
+        copy.setResponseBody(src.getResponseBody());
+        copy.setErrorMessage(src.getErrorMessage());
+        copy.setRequestBody(src.getRequestBody());
+        copy.setCreatedAt(src.getCreatedAt());
+        return copy;
+    }
+
+    public static class RetryResult {
+        public int fetched;
+        public int sent;
+        public int failed;
+    }
+}

+ 97 - 0
src/main/java/com/adx/tencent/vivo/service/VivoTagEventResolver.java

@@ -0,0 +1,97 @@
+package com.adx.tencent.vivo.service;
+
+import com.adx.tencent.vivo.model.VivoTagEventRecord;
+import com.adx.tencent.vivo.store.VivoColdStore;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.data.redis.core.StringRedisTemplate;
+
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Vivo 广告位回传方式解析器。
+ * 根据广告位ID和百度行为解析 Vivo event_type 列表:
+ *   1. 优先从 Redis 读取(key = {prefix}{tagId}:{act});
+ *   2. Redis 未命中时回退查询 vivo_tag_event 表;
+ *   3. 支持 act=0 作为默认整组兜底配置。
+ */
+public class VivoTagEventResolver {
+
+    private static final Logger log = LoggerFactory.getLogger(VivoTagEventResolver.class);
+
+    private final StringRedisTemplate redis;
+    private final VivoColdStore coldStore;
+    private final String keyPrefix;
+
+    public VivoTagEventResolver(StringRedisTemplate redis, VivoColdStore coldStore, String keyPrefix) {
+        this.redis = redis;
+        this.coldStore = coldStore;
+        this.keyPrefix = keyPrefix == null ? "" : keyPrefix;
+    }
+
+    public List<String> resolveEventTypes(String tagId, int baiduAct) {
+        if (tagId == null || tagId.isBlank()) {
+            return List.of();
+        }
+
+        try {
+            List<String> cached = parseEventTypes(redis.opsForValue().get(buildRedisKey(tagId, baiduAct)));
+            if (!cached.isEmpty()) {
+                return cached;
+            }
+            cached = parseEventTypes(redis.opsForValue().get(buildRedisKey(tagId, 0)));
+            if (!cached.isEmpty()) {
+                return cached;
+            }
+        } catch (Exception e) {
+            log.warn("[VivoTagEventResolver] 读取 Redis 失败, 回退DB查询 | tagId={} | act={} | error={}",
+                    tagId, baiduAct, e.getMessage());
+        }
+
+        try {
+            List<VivoTagEventRecord> records = coldStore.getTagEvents(tagId, baiduAct);
+            if (records == null || records.isEmpty()) {
+                records = coldStore.getTagEvents(tagId, 0);
+            }
+            return toEventTypes(records);
+        } catch (Exception e) {
+            log.warn("[VivoTagEventResolver] 查询 vivo_tag_event 失败 | tagId={} | act={} | error={}",
+                    tagId, baiduAct, e.getMessage());
+            return List.of();
+        }
+    }
+
+    private String buildRedisKey(String tagId, int baiduAct) {
+        return keyPrefix + tagId + ":" + baiduAct;
+    }
+
+    private static List<String> parseEventTypes(String value) {
+        if (value == null || value.isBlank()) {
+            return List.of();
+        }
+        Set<String> result = new LinkedHashSet<>();
+        for (String part : value.split(",")) {
+            if (part == null || part.isBlank()) {
+                continue;
+            }
+            result.add(part.trim());
+        }
+        return new ArrayList<>(result);
+    }
+
+    private static List<String> toEventTypes(List<VivoTagEventRecord> records) {
+        if (records == null || records.isEmpty()) {
+            return List.of();
+        }
+        Set<String> result = new LinkedHashSet<>();
+        for (VivoTagEventRecord record : records) {
+            if (record.getEventType() != null && !record.getEventType().isBlank()) {
+                result.add(record.getEventType().trim());
+            }
+        }
+        return new ArrayList<>(result);
+    }
+}

+ 94 - 0
src/main/java/com/adx/tencent/vivo/service/VivoTagEventSyncService.java

@@ -0,0 +1,94 @@
+package com.adx.tencent.vivo.service;
+
+import com.adx.tencent.vivo.model.VivoTagEventRecord;
+import com.adx.tencent.vivo.store.VivoColdStore;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.data.redis.connection.RedisConnection;
+import org.springframework.data.redis.connection.RedisStringCommands;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.data.redis.core.types.Expiration;
+
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * Vivo 广告位回传方式同步服务。
+ * 定时从 vivo_tag_event 表读取全部记录,同步到 Redis:
+ *   key   = {prefix}{tag_id}:{baidu_act}
+ *   value = event_type 列表(逗号分隔)
+ */
+public class VivoTagEventSyncService {
+
+    private static final Logger log = LoggerFactory.getLogger(VivoTagEventSyncService.class);
+
+    private final VivoColdStore coldStore;
+    private final StringRedisTemplate redis;
+    private final String keyPrefix;
+    private final Duration ttl;
+
+    public VivoTagEventSyncService(VivoColdStore coldStore, StringRedisTemplate redis,
+                                    String keyPrefix, Duration ttl) {
+        this.coldStore = coldStore;
+        this.redis = redis;
+        this.keyPrefix = keyPrefix == null ? "" : keyPrefix;
+        this.ttl = ttl;
+    }
+
+    public int syncOnce() {
+        List<VivoTagEventRecord> rows = coldStore.listAllTagEvents();
+        if (rows == null || rows.isEmpty()) {
+            log.info("[VivoTagEventSync] 无数据可同步");
+            return 0;
+        }
+
+        Map<String, Set<String>> grouped = new LinkedHashMap<>();
+        for (VivoTagEventRecord row : rows) {
+            if (row.getTagId() == null || row.getTagId().isBlank()) {
+                continue;
+            }
+            if (row.getEventType() == null || row.getEventType().isBlank()) {
+                continue;
+            }
+            grouped.computeIfAbsent(buildRedisKey(row.getTagId(), row.getBaiduAct()), ignored -> new LinkedHashSet<>())
+                    .add(row.getEventType().trim());
+        }
+        if (grouped.isEmpty()) {
+            log.info("[VivoTagEventSync] 无有效数据可同步");
+            return 0;
+        }
+
+        long ttlMs = (ttl != null && !ttl.isZero()) ? ttl.toMillis() : 0;
+        redis.executePipelined((RedisConnection conn) -> {
+            for (Map.Entry<String, Set<String>> entry : grouped.entrySet()) {
+                byte[] key = entry.getKey().getBytes(StandardCharsets.UTF_8);
+                String value = entry.getValue().stream()
+                        .map(String::valueOf)
+                        .collect(Collectors.joining(","));
+                byte[] val = value.getBytes(StandardCharsets.UTF_8);
+                if (ttlMs > 0) {
+                    conn.stringCommands().set(key, val,
+                            Expiration.milliseconds(ttlMs),
+                            RedisStringCommands.SetOption.UPSERT);
+                } else {
+                    conn.stringCommands().set(key, val);
+                }
+            }
+            return null;
+        });
+
+        log.info("[VivoTagEventSync] 同步完成,共 {} 条 Vivo 广告位回传方式写入 Redis, 聚合后 {} 个键",
+                rows.size(), grouped.size());
+        return rows.size();
+    }
+
+    private String buildRedisKey(String tagId, int baiduAct) {
+        return keyPrefix + tagId + ":" + baiduAct;
+    }
+}

+ 358 - 0
src/main/java/com/adx/tencent/vivo/store/VivoColdStore.java

@@ -0,0 +1,358 @@
+package com.adx.tencent.vivo.store;
+
+import com.adx.tencent.vivo.mapper.VivoBidEventMapper;
+import com.adx.tencent.vivo.mapper.VivoAdvertiserAccountMapper;
+import com.adx.tencent.vivo.mapper.VivoClientConfigMapper;
+import com.adx.tencent.vivo.mapper.VivoMediaCallbackMapper;
+import com.adx.tencent.vivo.mapper.VivoTagEventMapper;
+import com.adx.tencent.vivo.mapper.VivoTokenMapper;
+import com.adx.tencent.vivo.mapper.VivoTrackingReportMapper;
+import com.adx.tencent.vivo.model.VivoAdvertiserAccountRecord;
+import com.adx.tencent.vivo.model.VivoBidRecord;
+import com.adx.tencent.vivo.model.VivoClientConfigRecord;
+import com.adx.tencent.vivo.model.VivoMediaCallbackRecord;
+import com.adx.tencent.vivo.model.VivoTagEventRecord;
+import com.adx.tencent.vivo.model.VivoTokenRecord;
+import com.adx.tencent.vivo.model.VivoTrackingRecord;
+import com.adx.tencent.storage.model.ConversionRecord;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.ibatis.session.ExecutorType;
+import org.apache.ibatis.session.SqlSession;
+import org.apache.ibatis.session.SqlSessionFactory;
+
+import java.sql.Statement;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+
+public class VivoColdStore {
+
+    private final SqlSessionFactory sqlSessionFactory;
+
+    public VivoColdStore(SqlSessionFactory sqlSessionFactory, ObjectMapper objectMapper) {
+        this.sqlSessionFactory = sqlSessionFactory;
+    }
+
+    public void migrate() {
+        String[] ddls = {
+                """
+                CREATE TABLE IF NOT EXISTS vivo_ad_bid_events (
+                    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+                    qk VARCHAR(128) NOT NULL COMMENT '唯一业务键(query key)',
+                    media VARCHAR(64) NOT NULL COMMENT '媒体来源(vivo)',
+                    media_trace_id VARCHAR(255) COMMENT '媒体追踪ID(callback/设备标识)',
+                    platform VARCHAR(16) COMMENT '平台(android/ios)',
+                    account_id VARCHAR(128) COMMENT 'vivo 投放账户ID',
+                    event_type VARCHAR(32) COMMENT '事件类型(impression/click),仅保留排障所需',
+                    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 '媒体参数精简白名单(仅保留 clickId/srcId/pkgName/设备号/ts/deduction_rate)',
+                    created_at TIMESTAMP(3) NOT NULL COMMENT '创建时间',
+                    UNIQUE KEY uk_vivo_ad_bid_events_qk (qk),
+                    KEY idx_vivo_ad_bid_events_media_trace (media, media_trace_id),
+                    KEY idx_vivo_ad_bid_events_platform_tag (platform, tag_id),
+                    KEY idx_vivo_ad_bid_events_account_created_at (account_id, created_at),
+                    KEY idx_vivo_ad_bid_events_created_at (created_at)
+                ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='vivo竞价事件记录'
+                """,
+                """
+                CREATE TABLE IF NOT EXISTS vivo_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 '是否成功(1=成功,0=失败)',
+                    created_at TIMESTAMP(3) NOT NULL COMMENT '创建时间',
+                    KEY idx_vivo_tracking_reports_qk_kind_created_at (qk, kind, created_at),
+                    KEY idx_vivo_tracking_reports_tag_created_at (tag_id, created_at)
+                ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='vivo曝光/点击上报结果'
+                """,
+                """
+                CREATE TABLE IF NOT EXISTS vivo_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',
+                    callback_url TEXT NOT NULL COMMENT '回传的API URL',
+                    event_type VARCHAR(64) NOT NULL COMMENT 'vivo 回传事件类型',
+                    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 '是否直归因(1=是,0=否)',
+                    dispatch_status VARCHAR(32) NOT NULL DEFAULT 'SENT' COMMENT '回传处理状态(SENT/DEDUCTED)',
+                    status INT NOT NULL COMMENT 'HTTP响应状态码',
+                    ok TINYINT(1) NOT NULL COMMENT '是否成功(1=成功,0=失败)',
+                    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_vivo_media_callbacks_dedupe_ok_created_at (dedupe_key, ok, created_at),
+                    KEY idx_vivo_media_callbacks_qk_created_at (qk, created_at),
+                    KEY idx_vivo_media_callbacks_tag_created_at (tag_id, created_at),
+                    KEY idx_vivo_media_callbacks_event_created_at (event_type, created_at)
+                ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='vivo媒体转化回传记录'
+                """,
+                """
+                CREATE TABLE IF NOT EXISTS vivo_tag_event (
+                    tag_id VARCHAR(128) NOT NULL COMMENT '百度广告位ID',
+                    baidu_act INT NOT NULL COMMENT '百度转化行为',
+                    event_type VARCHAR(64) NOT NULL COMMENT 'vivo 回传事件类型',
+                    PRIMARY KEY (tag_id, baidu_act, event_type)
+                ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='vivo广告位回传事件配置'
+                """,
+                """
+                CREATE TABLE IF NOT EXISTS vivo_account_tokens (
+                    account_id VARCHAR(128) NOT NULL COMMENT 'vivo 二代账户ID',
+                    account_name VARCHAR(255) COMMENT 'vivo 二代账户名称',
+                    account_level INT COMMENT '账户等级(level)',
+                    account_status INT COMMENT '账户状态(status)',
+                    client_id VARCHAR(128) COMMENT '本次授权使用的vivo API client_id快照',
+                    client_secret VARCHAR(1024) COMMENT '本次授权使用的vivo API client_secret快照',
+                    access_token VARCHAR(1024) COMMENT 'access_token',
+                    refresh_token VARCHAR(1024) COMMENT 'refresh_token',
+                    token_expire_at BIGINT COMMENT 'access_token 过期时间(毫秒时间戳)',
+                    refresh_token_expire_at BIGINT COMMENT 'refresh_token 过期时间(毫秒时间戳)',
+                    authorized_at TIMESTAMP(3) NULL COMMENT '授权完成时间',
+                    updated_at TIMESTAMP(3) NOT NULL COMMENT '更新时间',
+                    PRIMARY KEY (account_id)
+                ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='vivo 二代账户授权 token'
+                """,
+                """
+                CREATE TABLE IF NOT EXISTS vivo_client_configs (
+                    client_id VARCHAR(128) NOT NULL COMMENT 'vivo API client_id',
+                    client_secret VARCHAR(1024) NOT NULL COMMENT 'vivo API client_secret',
+                    client_name VARCHAR(255) COMMENT '凭证名称/备注',
+                    enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用(1=启用,0=停用)',
+                    updated_at TIMESTAMP(3) NOT NULL COMMENT '更新时间',
+                    PRIMARY KEY (client_id)
+                ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='vivo开放平台凭证配置'
+                """,
+                """
+                CREATE TABLE IF NOT EXISTS vivo_advertiser_accounts (
+                    advertiser_id VARCHAR(128) NOT NULL COMMENT 'vivo 广告主账户ID(advertiser_id)',
+                    secondary_account_id VARCHAR(128) NOT NULL COMMENT '所属vivo二代账户ID',
+                    advertiser_name VARCHAR(255) COMMENT 'vivo 广告主名称',
+                    advertiser_status INT COMMENT '广告主状态(status)',
+                    last_sync_at TIMESTAMP(3) NULL COMMENT '最近同步时间',
+                    updated_at TIMESTAMP(3) NOT NULL COMMENT '更新时间',
+                    PRIMARY KEY (advertiser_id),
+                    KEY idx_vivo_advertiser_accounts_secondary (secondary_account_id)
+                ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='vivo 广告主到账户映射'
+                """
+        };
+        try (SqlSession session = sqlSessionFactory.openSession(true)) {
+            Statement stmt = session.getConnection().createStatement();
+            for (String ddl : ddls) {
+                stmt.execute(ddl);
+            }
+            stmt.execute("UPDATE vivo_ad_bid_events SET price = 0 WHERE price IS NULL");
+            stmt.execute("ALTER TABLE vivo_ad_bid_events MODIFY COLUMN price BIGINT UNSIGNED NOT NULL DEFAULT 0");
+            try {
+                stmt.execute("ALTER TABLE vivo_media_callbacks ADD COLUMN deduction_rate INT NOT NULL DEFAULT 0 COMMENT '扣量比例(0-100)' AFTER purchase");
+            } catch (Exception ignored) {
+            }
+            try {
+                stmt.execute("ALTER TABLE vivo_tag_event DROP PRIMARY KEY, ADD PRIMARY KEY (tag_id, baidu_act, event_type)");
+            } catch (Exception ignored) {
+            }
+            try {
+                stmt.execute("ALTER TABLE vivo_media_callbacks MODIFY COLUMN event_type VARCHAR(64) NOT NULL COMMENT 'vivo 回传事件类型'");
+            } catch (Exception ignored) {
+            }
+            try {
+                stmt.execute("ALTER TABLE vivo_tag_event MODIFY COLUMN event_type VARCHAR(64) NOT NULL COMMENT 'vivo 回传事件类型'");
+            } catch (Exception ignored) {
+            }
+            try {
+                stmt.execute("ALTER TABLE vivo_account_tokens MODIFY COLUMN account_id VARCHAR(128) NOT NULL COMMENT 'vivo 二代账户ID'");
+            } catch (Exception ignored) {
+            }
+            try {
+                stmt.execute("ALTER TABLE vivo_account_tokens ADD COLUMN account_name VARCHAR(255) COMMENT 'vivo 二代账户名称' AFTER account_id");
+            } catch (Exception ignored) {
+            }
+            try {
+                stmt.execute("ALTER TABLE vivo_account_tokens ADD COLUMN account_level INT COMMENT '账户等级(level)' AFTER account_name");
+            } catch (Exception ignored) {
+            }
+            try {
+                stmt.execute("ALTER TABLE vivo_account_tokens ADD COLUMN account_status INT COMMENT '账户状态(status)' AFTER account_level");
+            } catch (Exception ignored) {
+            }
+            try {
+                stmt.execute("ALTER TABLE vivo_account_tokens ADD COLUMN client_id VARCHAR(128) COMMENT '本次授权使用的vivo API client_id快照' AFTER account_status");
+            } catch (Exception ignored) {
+            }
+            try {
+                stmt.execute("ALTER TABLE vivo_account_tokens ADD COLUMN client_secret VARCHAR(1024) COMMENT '本次授权使用的vivo API client_secret快照' AFTER client_id");
+            } catch (Exception ignored) {
+            }
+            stmt.close();
+        } catch (Exception e) {
+            throw new RuntimeException("vivo migrate failed", e);
+        }
+    }
+
+    public void saveBid(VivoBidRecord record) {
+        if (record.getCreatedAt() == null) record.setCreatedAt(Instant.now());
+        record.setMediaTraceId(record.getMediaTraceId());
+        try (SqlSession session = sqlSessionFactory.openSession(true)) {
+            session.getMapper(VivoBidEventMapper.class).insertOrUpdate(record.toColdRecord());
+        }
+    }
+
+    public List<VivoBidRecord> getBidsByQks(List<String> qks) {
+        if (qks == null || qks.isEmpty()) return List.of();
+        try (SqlSession session = sqlSessionFactory.openSession(true)) {
+            return session.getMapper(VivoBidEventMapper.class).selectByQks(qks);
+        }
+    }
+
+    public void saveTracking(VivoTrackingRecord record) {
+        if (record.getCreatedAt() == null) record.setCreatedAt(Instant.now());
+        try (SqlSession session = sqlSessionFactory.openSession(true)) {
+            session.getMapper(VivoTrackingReportMapper.class).insert(record);
+        }
+    }
+
+    public void saveConversions(List<ConversionRecord> records) {
+        if (records == null || records.isEmpty()) return;
+        throw new UnsupportedOperationException("vivo conversions should be saved through shared TiDBColdStore");
+    }
+
+    public void saveMediaCallbacks(List<VivoMediaCallbackRecord> records) {
+        if (records == null || records.isEmpty()) return;
+        for (VivoMediaCallbackRecord record : records) {
+            if (record.getCreatedAt() == null) record.setCreatedAt(Instant.now());
+            if (record.getAttempt() <= 0) record.setAttempt(1);
+        }
+        try (SqlSession session = sqlSessionFactory.openSession(ExecutorType.BATCH, false)) {
+            session.getMapper(VivoMediaCallbackMapper.class).batchInsert(records);
+            session.commit();
+        }
+    }
+
+    public Set<String> successfulCallbackKeys(Set<String> dedupeKeys) {
+        if (dedupeKeys == null || dedupeKeys.isEmpty()) return Set.of();
+        try (SqlSession session = sqlSessionFactory.openSession(true)) {
+            return Set.copyOf(session.getMapper(VivoMediaCallbackMapper.class)
+                    .selectSuccessfulDedupeKeys(new ArrayList<>(dedupeKeys)));
+        }
+    }
+
+    public List<VivoMediaCallbackRecord> pendingMediaCallbacks(int limit) {
+        if (limit <= 0) limit = 100;
+        try (SqlSession session = sqlSessionFactory.openSession(true)) {
+            return session.getMapper(VivoMediaCallbackMapper.class).selectPendingCallbacks(limit);
+        }
+    }
+
+    public List<VivoTagEventRecord> getTagEvents(String tagId, int baiduAct) {
+        if (tagId == null || tagId.isBlank()) return List.of();
+        try (SqlSession session = sqlSessionFactory.openSession(true)) {
+            return session.getMapper(VivoTagEventMapper.class).selectByTagIdAndAct(tagId, baiduAct);
+        }
+    }
+
+    public List<VivoTagEventRecord> listAllTagEvents() {
+        try (SqlSession session = sqlSessionFactory.openSession(true)) {
+            return session.getMapper(VivoTagEventMapper.class).selectAll();
+        }
+    }
+
+    public VivoTokenRecord getToken(String accountId) {
+        if (accountId == null || accountId.isBlank()) return null;
+        try (SqlSession session = sqlSessionFactory.openSession(true)) {
+            return session.getMapper(VivoTokenMapper.class).selectByAccountId(accountId);
+        }
+    }
+
+    public List<VivoTokenRecord> listTokens() {
+        try (SqlSession session = sqlSessionFactory.openSession(true)) {
+            return session.getMapper(VivoTokenMapper.class).selectAll();
+        }
+    }
+
+    public void saveToken(VivoTokenRecord record) {
+        if (record == null || record.getAccountId() == null || record.getAccountId().isBlank()) {
+            return;
+        }
+        if (record.getUpdatedAt() == null) {
+            record.setUpdatedAt(Instant.now());
+        }
+        try (SqlSession session = sqlSessionFactory.openSession(true)) {
+            session.getMapper(VivoTokenMapper.class).insertOrUpdate(record);
+        }
+    }
+
+    public VivoClientConfigRecord getClientConfig(String clientId) {
+        if (clientId == null || clientId.isBlank()) return null;
+        try (SqlSession session = sqlSessionFactory.openSession(true)) {
+            return session.getMapper(VivoClientConfigMapper.class).selectByClientId(clientId);
+        }
+    }
+
+    public List<VivoClientConfigRecord> listEnabledClientConfigs() {
+        try (SqlSession session = sqlSessionFactory.openSession(true)) {
+            return session.getMapper(VivoClientConfigMapper.class).selectEnabled();
+        }
+    }
+
+    public void saveClientConfig(VivoClientConfigRecord record) {
+        if (record == null || record.getClientId() == null || record.getClientId().isBlank()) {
+            return;
+        }
+        if (record.getUpdatedAt() == null) {
+            record.setUpdatedAt(Instant.now());
+        }
+        try (SqlSession session = sqlSessionFactory.openSession(true)) {
+            session.getMapper(VivoClientConfigMapper.class).insertOrUpdate(record);
+        }
+    }
+
+    public VivoAdvertiserAccountRecord getAdvertiserAccount(String advertiserId) {
+        if (advertiserId == null || advertiserId.isBlank()) return null;
+        try (SqlSession session = sqlSessionFactory.openSession(true)) {
+            return session.getMapper(VivoAdvertiserAccountMapper.class).selectByAdvertiserId(advertiserId);
+        }
+    }
+
+    public List<String> listAdvertiserIdsBySecondaryAccountId(String secondaryAccountId) {
+        if (secondaryAccountId == null || secondaryAccountId.isBlank()) return List.of();
+        try (SqlSession session = sqlSessionFactory.openSession(true)) {
+            return session.getMapper(VivoAdvertiserAccountMapper.class)
+                    .selectAdvertiserIdsBySecondaryAccountId(secondaryAccountId);
+        }
+    }
+
+    public void replaceAdvertiserAccounts(String secondaryAccountId, List<VivoAdvertiserAccountRecord> records) {
+        if (secondaryAccountId == null || secondaryAccountId.isBlank()) {
+            return;
+        }
+        Instant now = Instant.now();
+        if (records != null) {
+            for (VivoAdvertiserAccountRecord record : records) {
+                record.setSecondaryAccountId(secondaryAccountId);
+                if (record.getLastSyncAt() == null) {
+                    record.setLastSyncAt(now);
+                }
+                if (record.getUpdatedAt() == null) {
+                    record.setUpdatedAt(now);
+                }
+            }
+        }
+        try (SqlSession session = sqlSessionFactory.openSession(ExecutorType.BATCH, false)) {
+            VivoAdvertiserAccountMapper mapper = session.getMapper(VivoAdvertiserAccountMapper.class);
+            mapper.deleteBySecondaryAccountId(secondaryAccountId);
+            if (records != null && !records.isEmpty()) {
+                mapper.batchInsertOrUpdate(records);
+            }
+            session.commit();
+        }
+    }
+}

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

@@ -0,0 +1,394 @@
+package com.adx.tencent.vivo.store;
+
+import com.adx.tencent.vivo.model.VivoBidRecord;
+import com.adx.tencent.vivo.model.VivoTrackingRecord;
+import com.adx.tencent.storage.model.QueuedEvent;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.data.redis.connection.RedisStringCommands;
+import org.springframework.data.redis.connection.stream.Consumer;
+import org.springframework.data.redis.connection.stream.MapRecord;
+import org.springframework.data.redis.connection.stream.PendingMessage;
+import org.springframework.data.redis.connection.stream.PendingMessagesSummary;
+import org.springframework.data.redis.connection.stream.ReadOffset;
+import org.springframework.data.redis.connection.stream.RecordId;
+import org.springframework.data.redis.connection.stream.StreamOffset;
+import org.springframework.data.redis.connection.stream.StreamReadOptions;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.data.redis.core.script.DefaultRedisScript;
+import org.springframework.data.redis.core.types.Expiration;
+
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+
+public class VivoHotStore {
+
+    private static final Logger log = LoggerFactory.getLogger(VivoHotStore.class);
+    private static final Duration DEFAULT_CONVERSION_SEEN_TTL = Duration.ofDays(45);
+    private static final Duration DEFAULT_DEDUCTION_COUNTER_TTL = Duration.ofDays(7);
+
+    private final StringRedisTemplate redis;
+    private final ObjectMapper objectMapper;
+    private final VivoColdStore coldStore;
+    private final String prefix;
+    private final String stream;
+    private final Duration bidTtl;
+    private final DefaultRedisScript<List> deductionDecisionScript;
+    private final Set<String> initializedGroups = ConcurrentHashMap.newKeySet();
+
+    public VivoHotStore(StringRedisTemplate redis, ObjectMapper objectMapper,
+                            VivoColdStore coldStore, String prefix, String stream, Duration bidTtl) {
+        this.redis = redis;
+        this.objectMapper = objectMapper;
+        this.coldStore = coldStore;
+        this.prefix = prefix;
+        this.stream = stream;
+        this.bidTtl = bidTtl;
+        this.deductionDecisionScript = buildDeductionDecisionScript();
+    }
+
+    public void recordBid(VivoBidRecord record) {
+        if (record.getCreatedAt() == null) record.setCreatedAt(Instant.now());
+        try {
+            String hotPayload = objectMapper.writeValueAsString(record.toHotRecord());
+            String fullPayload = objectMapper.writeValueAsString(record);
+            redis.executePipelined((org.springframework.data.redis.connection.RedisConnection conn) -> {
+                byte[] hotBytes = hotPayload.getBytes(StandardCharsets.UTF_8);
+                long ttlMillis = bidTtl.toMillis();
+                if (record.getQk() != null && !record.getQk().isBlank()) {
+                    conn.stringCommands().set(
+                            bidKey(record.getQk()).getBytes(StandardCharsets.UTF_8),
+                            hotBytes,
+                            Expiration.milliseconds(ttlMillis),
+                            RedisStringCommands.SetOption.UPSERT
+                    );
+                }
+                if (record.getMediaTraceId() != null && !record.getMediaTraceId().isBlank()) {
+                    conn.stringCommands().set(
+                            mediaTraceKey(record.getMediaTraceId()).getBytes(StandardCharsets.UTF_8),
+                            hotBytes,
+                            Expiration.milliseconds(ttlMillis),
+                            RedisStringCommands.SetOption.UPSERT
+                    );
+                }
+                Map<byte[], byte[]> body = new LinkedHashMap<>();
+                body.put("type".getBytes(StandardCharsets.UTF_8), "bid".getBytes(StandardCharsets.UTF_8));
+                body.put("payload".getBytes(StandardCharsets.UTF_8), fullPayload.getBytes(StandardCharsets.UTF_8));
+                conn.streamCommands().xAdd(MapRecord.create(stream.getBytes(StandardCharsets.UTF_8), body));
+                return null;
+            });
+        } catch (Exception e) {
+            throw new RuntimeException("record vivo bid failed", e);
+        }
+    }
+
+    public void recordTracking(VivoTrackingRecord record) {
+        if (record.getCreatedAt() == null) record.setCreatedAt(Instant.now());
+        try {
+            String payload = objectMapper.writeValueAsString(record);
+            redis.opsForStream().add(MapRecord.create(stream, Map.of("type", "tracking", "payload", payload)).withStreamKey(stream));
+        } catch (Exception e) {
+            throw new RuntimeException("record vivo tracking failed", e);
+        }
+    }
+
+    public void shrinkAfterClick(VivoBidRecord record) {
+        try {
+            String slimPayload = objectMapper.writeValueAsString(record.toPostClickRecord());
+            byte[] slimBytes = slimPayload.getBytes(StandardCharsets.UTF_8);
+            Long remainingTtl = redis.getExpire(bidKey(record.getQk()), java.util.concurrent.TimeUnit.MILLISECONDS);
+            if (remainingTtl == null || remainingTtl <= 0) remainingTtl = bidTtl.toMillis();
+            long ttlMs = remainingTtl;
+            redis.executePipelined((org.springframework.data.redis.connection.RedisConnection conn) -> {
+                if (record.getQk() != null && !record.getQk().isBlank()) {
+                    conn.stringCommands().set(
+                            bidKey(record.getQk()).getBytes(StandardCharsets.UTF_8),
+                            slimBytes,
+                            Expiration.milliseconds(ttlMs),
+                            RedisStringCommands.SetOption.UPSERT
+                    );
+                }
+                if (record.getMediaTraceId() != null && !record.getMediaTraceId().isBlank()) {
+                    conn.stringCommands().set(
+                            mediaTraceKey(record.getMediaTraceId()).getBytes(StandardCharsets.UTF_8),
+                            slimBytes,
+                            Expiration.milliseconds(ttlMs),
+                            RedisStringCommands.SetOption.UPSERT
+                    );
+                }
+                return null;
+            });
+        } catch (Exception e) {
+            throw new RuntimeException("shrink vivo bid failed", e);
+        }
+    }
+
+    public VivoBidRecord findBidByMediaTrace(String traceId) {
+        String payload = redis.opsForValue().get(mediaTraceKey(traceId));
+        if (payload == null) return null;
+        return parseBidRecord(payload);
+    }
+
+    public Map<String, VivoBidRecord> findBidsByQks(List<String> qks) {
+        if (qks == null || qks.isEmpty()) return Collections.emptyMap();
+        Map<String, VivoBidRecord> result = new HashMap<>();
+        List<String> keys = new ArrayList<>();
+        Map<String, String> keyToQk = new HashMap<>();
+        for (String qk : qks) {
+            if (qk == null || qk.isBlank() || keyToQk.containsValue(qk)) continue;
+            String key = bidKey(qk);
+            keys.add(key);
+            keyToQk.put(key, qk);
+        }
+        List<String> payloads = redis.opsForValue().multiGet(keys);
+        Set<String> missing = new LinkedHashSet<>();
+        for (int i = 0; i < keys.size(); i++) {
+            String payload = payloads != null ? payloads.get(i) : null;
+            String qk = keyToQk.get(keys.get(i));
+            if (payload != null) {
+                result.put(qk, parseBidRecord(payload));
+            } else if (qk != null) {
+                missing.add(qk);
+            }
+        }
+        if (!missing.isEmpty() && coldStore != null) {
+            for (VivoBidRecord bid : coldStore.getBidsByQks(new ArrayList<>(missing))) {
+                if (bid != null && bid.getQk() != null) result.put(bid.getQk(), bid);
+            }
+        }
+        return result;
+    }
+
+    public boolean markConversionSeen(String dedupeKey) {
+        if (dedupeKey == null || dedupeKey.isBlank()) {
+            return false;
+        }
+        Boolean created = redis.opsForValue().setIfAbsent(
+                conversionSeenKey(dedupeKey),
+                "1",
+                DEFAULT_CONVERSION_SEEN_TTL
+        );
+        return Boolean.TRUE.equals(created);
+    }
+
+    public DeductionDecision evaluateDeduction(String accountId, int act, String date, int deductionRate) {
+        String key = deductionCounterKey(accountId, act, date);
+        List result = redis.execute(
+                deductionDecisionScript,
+                Collections.singletonList(key),
+                String.valueOf(Math.max(deductionRate, 0)),
+                String.valueOf(DEFAULT_DEDUCTION_COUNTER_TTL.getSeconds())
+        );
+        if (result == null || result.size() < 3) {
+            throw new RuntimeException("evaluate vivo deduction failed");
+        }
+        long total = toLong(result.get(0));
+        long deducted = toLong(result.get(1));
+        boolean shouldDeduct = toLong(result.get(2)) == 1L;
+        return new DeductionDecision(total, deducted, shouldDeduct);
+    }
+
+    public void ensureGroup(String group) {
+        if (group == null || group.isBlank()) return;
+        if (initializedGroups.contains(group)) return;
+        if (groupExists(group)) {
+            initializedGroups.add(group);
+            return;
+        }
+        try {
+            redis.opsForStream().createGroup(stream, ReadOffset.from("0"), group);
+            initializedGroups.add(group);
+        } catch (Exception e) {
+            if (isBusyGroupError(e)) {
+                initializedGroups.add(group);
+                return;
+            }
+            try {
+                var id = redis.opsForStream().add(org.springframework.data.redis.connection.stream.StreamRecords
+                        .newRecord().ofMap(Collections.singletonMap("_init", "1")).withStreamKey(stream));
+                if (id != null) redis.opsForStream().delete(stream, id);
+                if (groupExists(group)) {
+                    initializedGroups.add(group);
+                    return;
+                }
+                redis.opsForStream().createGroup(stream, ReadOffset.from("0"), group);
+                initializedGroups.add(group);
+            } catch (Exception retryEx) {
+                if (isBusyGroupError(retryEx)) {
+                    initializedGroups.add(group);
+                    return;
+                }
+                throw new RuntimeException("ensure vivo group failed", retryEx);
+            }
+        }
+    }
+
+    public List<QueuedEvent> read(String group, String consumer, int count) {
+        try {
+            List<MapRecord<String, Object, Object>> records = redis.opsForStream().read(
+                    Consumer.from(group, consumer),
+                    StreamReadOptions.empty().count(count).block(Duration.ofSeconds(1)),
+                    StreamOffset.create(stream, ReadOffset.lastConsumed())
+            );
+            return toQueuedEvents(records);
+        } catch (Exception e) {
+            if (e.getMessage() != null && e.getMessage().contains("NOGROUP")) return Collections.emptyList();
+            log.error("[VivoHotStore] read failed | stream={} | group={} | consumer={} | count={} | error={}",
+                    stream, group, consumer, count, rootMessage(e), e);
+            throw new RuntimeException("read vivo stream failed", e);
+        }
+    }
+
+    public List<QueuedEvent> claimStale(String group, String consumer, Duration minIdle, int count) {
+        try {
+            PendingMessagesSummary summary = redis.opsForStream().pending(stream, group);
+            if (summary == null || summary.getTotalPendingMessages() == 0) return Collections.emptyList();
+            var pendingMessages = redis.opsForStream().pending(stream, group, org.springframework.data.domain.Range.unbounded(), count);
+            if (pendingMessages == null || !pendingMessages.iterator().hasNext()) return Collections.emptyList();
+            List<String> staleIds = new ArrayList<>();
+            for (PendingMessage message : pendingMessages) {
+                if (message.getElapsedTimeSinceLastDelivery().compareTo(minIdle) >= 0) {
+                    staleIds.add(message.getIdAsString());
+                }
+            }
+            if (staleIds.isEmpty()) return Collections.emptyList();
+            List<MapRecord<String, Object, Object>> claimed = redis.opsForStream().claim(
+                    stream, group, consumer, minIdle, staleIds.stream().map(RecordId::of).toArray(RecordId[]::new)
+            );
+            return toQueuedEvents(claimed);
+        } catch (Exception e) {
+            return Collections.emptyList();
+        }
+    }
+
+    public void ack(String group, List<String> ids) {
+        if (ids == null || ids.isEmpty()) return;
+        redis.opsForStream().acknowledge(stream, group, ids.toArray(String[]::new));
+    }
+
+    public void trim(long maxLen) {
+        if (maxLen > 0) redis.opsForStream().trim(stream, maxLen, false);
+    }
+
+    private String bidKey(String qk) { return prefix + "bid:" + qk; }
+    private String mediaTraceKey(String traceId) { return prefix + "media:vivo:" + traceId; }
+    private String conversionSeenKey(String dedupeKey) { return prefix + "conv:seen:vivo:" + dedupeKey; }
+
+    private String deductionCounterKey(String accountId, int act, String date) {
+        return String.format("%sdeduct:state:vivo:acct:%s:act:%d:date:%s",
+                prefix,
+                sanitizePart(accountId, "unknown"),
+                act,
+                sanitizePart(date, "unknown"));
+    }
+
+    private static String sanitizePart(String value, String fallback) {
+        if (value == null || value.isBlank()) return fallback;
+        return value.replace(':', '_').trim();
+    }
+
+    private static long toLong(Object value) {
+        if (value instanceof Number number) return number.longValue();
+        return Long.parseLong(String.valueOf(value));
+    }
+
+    private DefaultRedisScript<List> buildDeductionDecisionScript() {
+        DefaultRedisScript<List> script = new DefaultRedisScript<>();
+        script.setResultType(List.class);
+        script.setScriptText("""
+                local key = KEYS[1]
+                local rate = tonumber(ARGV[1]) or 0
+                local ttl = tonumber(ARGV[2]) or 0
+                local total = redis.call('HINCRBY', key, 'total', 1)
+                local deducted = tonumber(redis.call('HGET', key, 'deducted') or '0')
+                local target = math.floor((total * rate) / 100)
+                local should_deduct = 0
+                if deducted < target then
+                    deducted = redis.call('HINCRBY', key, 'deducted', 1)
+                    should_deduct = 1
+                end
+                if ttl > 0 then
+                    redis.call('EXPIRE', key, ttl)
+                end
+                return { total, deducted, should_deduct }
+                """);
+        return script;
+    }
+
+    private VivoBidRecord parseBidRecord(String payload) {
+        try {
+            return objectMapper.readValue(payload, VivoBidRecord.class);
+        } catch (Exception e) {
+            throw new RuntimeException("parse vivo bid failed", e);
+        }
+    }
+
+    private List<QueuedEvent> toQueuedEvents(List<MapRecord<String, Object, Object>> records) {
+        if (records == null || records.isEmpty()) return Collections.emptyList();
+        List<QueuedEvent> result = new ArrayList<>(records.size());
+        for (MapRecord<String, Object, Object> record : records) {
+            QueuedEvent event = new QueuedEvent();
+            event.setId(record.getId().getValue());
+            event.setType(String.valueOf(record.getValue().get("type")));
+            event.setPayload(String.valueOf(record.getValue().get("payload")));
+            result.add(event);
+        }
+        return result;
+    }
+
+    private boolean groupExists(String group) {
+        try {
+            var groups = redis.opsForStream().groups(stream);
+            return groups != null && groups.stream().anyMatch(info -> group.equals(info.groupName()));
+        } catch (Exception e) {
+            return false;
+        }
+    }
+
+    private boolean isBusyGroupError(Throwable e) {
+        while (e != null) {
+            if (e.getMessage() != null && e.getMessage().contains("BUSYGROUP")) return true;
+            e = e.getCause();
+        }
+        return false;
+    }
+
+    private static String rootMessage(Throwable e) {
+        Throwable cur = e;
+        String msg = null;
+        while (cur != null) {
+            if (cur.getMessage() != null && !cur.getMessage().isBlank()) {
+                msg = cur.getMessage();
+            }
+            cur = cur.getCause();
+        }
+        return msg == null ? "" : msg;
+    }
+
+    public static class DeductionDecision {
+        private final long totalSeen;
+        private final long totalDeducted;
+        private final boolean shouldDeduct;
+
+        public DeductionDecision(long totalSeen, long totalDeducted, boolean shouldDeduct) {
+            this.totalSeen = totalSeen;
+            this.totalDeducted = totalDeducted;
+            this.shouldDeduct = shouldDeduct;
+        }
+
+        public long getTotalSeen() { return totalSeen; }
+        public long getTotalDeducted() { return totalDeducted; }
+        public boolean isShouldDeduct() { return shouldDeduct; }
+    }
+}

+ 15 - 0
src/main/resources/application-dev.yml

@@ -88,3 +88,18 @@ adx:
   honor-tag-event-sync-enabled: false
   honor-tag-event-sync-interval: 300s
   honor-tag-event-sync-redis-prefix: "adx:honor:tag-event:"
+
+  # --- Vivo Callback ---
+  vivo-conversion-base-url: "https://marketing-api.vivo.com.cn"
+  vivo-redis-stream: "adx:vivo:events"
+  vivo-callback-retry-enabled: false
+  vivo-callback-retry-interval: 300s
+  vivo-callback-retry-limit: 100
+  vivo-tag-event-sync-enabled: false
+  vivo-tag-event-sync-interval: 300s
+  vivo-tag-event-sync-redis-prefix: "adx:vivo:tag-event:"
+  vivo-redirect-uri: "/vivo/callback"
+  vivo-token-redis-prefix: "adx:vivo:token:"
+  vivo-advertiser-sync-enabled: true
+  vivo-advertiser-sync-interval: 600s
+  vivo-advertiser-redis-prefix: "adx:vivo:advertiser:"

+ 15 - 0
src/main/resources/application-prod.yml

@@ -93,3 +93,18 @@ adx:
   honor-tag-event-sync-enabled: true
   honor-tag-event-sync-interval: 300s
   honor-tag-event-sync-redis-prefix: "adx:honor:tag-event:"
+
+  # --- Vivo Callback ---
+  vivo-conversion-base-url: "https://marketing-api.vivo.com.cn"
+  vivo-redis-stream: "adx:vivo:events"
+  vivo-callback-retry-enabled: true
+  vivo-callback-retry-interval: 300s
+  vivo-callback-retry-limit: 100
+  vivo-tag-event-sync-enabled: true
+  vivo-tag-event-sync-interval: 300s
+  vivo-tag-event-sync-redis-prefix: "adx:vivo:tag-event:"
+  vivo-redirect-uri: "/vivo/callback"
+  vivo-token-redis-prefix: "adx:vivo:token:"
+  vivo-advertiser-sync-enabled: true
+  vivo-advertiser-sync-interval: 600s
+  vivo-advertiser-redis-prefix: "adx:vivo:advertiser:"

+ 15 - 0
src/main/resources/application-test.yml

@@ -92,3 +92,18 @@ adx:
   honor-tag-event-sync-enabled: false
   honor-tag-event-sync-interval: 300s
   honor-tag-event-sync-redis-prefix: "adx:honor:tag-event:"
+
+  # --- Vivo Callback ---
+  vivo-conversion-base-url: "https://marketing-api.vivo.com.cn"
+  vivo-redis-stream: "adx:vivo:events"
+  vivo-callback-retry-enabled: false
+  vivo-callback-retry-interval: 300s
+  vivo-callback-retry-limit: 100
+  vivo-tag-event-sync-enabled: false
+  vivo-tag-event-sync-interval: 300s
+  vivo-tag-event-sync-redis-prefix: "adx:vivo:tag-event:"
+  vivo-redirect-uri: "/vivo/callback"
+  vivo-token-redis-prefix: "adx:vivo:token:"
+  vivo-advertiser-sync-enabled: true
+  vivo-advertiser-sync-interval: 600s
+  vivo-advertiser-redis-prefix: "adx:vivo:advertiser:"

+ 53 - 0
src/main/resources/mapper/VivoAdvertiserAccountMapper.xml

@@ -0,0 +1,53 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.adx.tencent.vivo.mapper.VivoAdvertiserAccountMapper">
+    <resultMap id="vivoAdvertiserAccountResultMap" type="com.adx.tencent.vivo.model.VivoAdvertiserAccountRecord">
+        <result property="advertiserId" column="advertiser_id"/>
+        <result property="secondaryAccountId" column="secondary_account_id"/>
+        <result property="advertiserName" column="advertiser_name"/>
+        <result property="advertiserStatus" column="advertiser_status"/>
+        <result property="lastSyncAt" column="last_sync_at"/>
+        <result property="updatedAt" column="updated_at"/>
+    </resultMap>
+
+    <select id="selectByAdvertiserId" resultMap="vivoAdvertiserAccountResultMap">
+        SELECT advertiser_id, secondary_account_id, advertiser_name, advertiser_status, last_sync_at, updated_at
+        FROM vivo_advertiser_accounts
+        WHERE advertiser_id = #{advertiserId}
+        LIMIT 1
+    </select>
+
+    <select id="selectAdvertiserIdsBySecondaryAccountId" resultType="string">
+        SELECT advertiser_id
+        FROM vivo_advertiser_accounts
+        WHERE secondary_account_id = #{secondaryAccountId}
+    </select>
+
+    <delete id="deleteBySecondaryAccountId">
+        DELETE FROM vivo_advertiser_accounts
+        WHERE secondary_account_id = #{secondaryAccountId}
+    </delete>
+
+    <insert id="batchInsertOrUpdate">
+        INSERT INTO vivo_advertiser_accounts (
+            advertiser_id, secondary_account_id, advertiser_name, advertiser_status, last_sync_at, updated_at
+        ) VALUES
+        <foreach collection="records" item="record" separator=",">
+            (
+                #{record.advertiserId},
+                #{record.secondaryAccountId},
+                #{record.advertiserName},
+                #{record.advertiserStatus},
+                #{record.lastSyncAtTs},
+                #{record.updatedAtTs}
+            )
+        </foreach>
+        ON DUPLICATE KEY UPDATE
+            secondary_account_id = VALUES(secondary_account_id),
+            advertiser_name = VALUES(advertiser_name),
+            advertiser_status = VALUES(advertiser_status),
+            last_sync_at = VALUES(last_sync_at),
+            updated_at = VALUES(updated_at)
+    </insert>
+</mapper>

+ 60 - 0
src/main/resources/mapper/VivoBidEventMapper.xml

@@ -0,0 +1,60 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.adx.tencent.vivo.mapper.VivoBidEventMapper">
+    <resultMap id="vivoBidRecordResultMap" type="com.adx.tencent.vivo.model.VivoBidRecord">
+        <result property="qk" column="qk"/>
+        <result property="media" column="media"/>
+        <result property="mediaTraceId" column="media_trace_id"/>
+        <result property="platform" column="platform"/>
+        <result property="accountId" column="account_id"/>
+        <result property="eventType" column="event_type"/>
+        <result property="tagId" column="tag_id"/>
+        <result property="price" column="price"/>
+        <result property="packageName" column="package_name"/>
+        <result property="mediaParams" column="media_params" typeHandler="com.adx.tencent.storage.typehandler.JsonStringMapTypeHandler"/>
+        <result property="createdAt" column="created_at"/>
+    </resultMap>
+
+    <select id="selectByQk" resultMap="vivoBidRecordResultMap">
+        SELECT qk, media, media_trace_id, platform, account_id, event_type,
+               tag_id, price, package_name, media_params, created_at
+        FROM vivo_ad_bid_events
+        WHERE qk = #{qk}
+        LIMIT 1
+    </select>
+
+    <select id="selectByQks" resultMap="vivoBidRecordResultMap">
+        SELECT qk, media, media_trace_id, platform, account_id, event_type,
+               tag_id, price, package_name, media_params, created_at
+        FROM vivo_ad_bid_events
+        WHERE qk IN
+        <foreach collection="qks" item="qk" open="(" separator="," close=")">
+            #{qk}
+        </foreach>
+    </select>
+
+    <insert id="insertOrUpdate">
+        INSERT INTO vivo_ad_bid_events (
+            qk, media, media_trace_id, platform, account_id, event_type,
+            tag_id, price, package_name,
+            media_params, created_at
+        ) VALUES (
+            #{record.qk}, #{record.media}, #{record.mediaTraceId}, #{record.platform},
+            #{record.accountId}, #{record.eventType}, #{record.tagId},
+            COALESCE(#{record.price}, 0), #{record.packageName},
+            #{record.mediaParams, typeHandler=com.adx.tencent.storage.typehandler.JsonStringMapTypeHandler},
+            #{record.createdAtTs}
+        )
+        ON DUPLICATE KEY UPDATE
+            media = VALUES(media),
+            media_trace_id = VALUES(media_trace_id),
+            platform = VALUES(platform),
+            account_id = VALUES(account_id),
+            event_type = VALUES(event_type),
+            tag_id = VALUES(tag_id),
+            price = VALUES(price),
+            package_name = VALUES(package_name),
+            media_params = VALUES(media_params)
+    </insert>
+</mapper>

+ 39 - 0
src/main/resources/mapper/VivoClientConfigMapper.xml

@@ -0,0 +1,39 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.adx.tencent.vivo.mapper.VivoClientConfigMapper">
+    <resultMap id="vivoClientConfigResultMap" type="com.adx.tencent.vivo.model.VivoClientConfigRecord">
+        <result property="clientId" column="client_id"/>
+        <result property="clientSecret" column="client_secret"/>
+        <result property="clientName" column="client_name"/>
+        <result property="enabled" column="enabled"/>
+        <result property="updatedAt" column="updated_at"/>
+    </resultMap>
+
+    <select id="selectByClientId" resultMap="vivoClientConfigResultMap">
+        SELECT client_id, client_secret, client_name, enabled, updated_at
+        FROM vivo_client_configs
+        WHERE client_id = #{clientId}
+        LIMIT 1
+    </select>
+
+    <select id="selectEnabled" resultMap="vivoClientConfigResultMap">
+        SELECT client_id, client_secret, client_name, enabled, updated_at
+        FROM vivo_client_configs
+        WHERE enabled = 1
+        ORDER BY updated_at DESC
+    </select>
+
+    <insert id="insertOrUpdate">
+        INSERT INTO vivo_client_configs (
+            client_id, client_secret, client_name, enabled, updated_at
+        ) VALUES (
+            #{record.clientId}, #{record.clientSecret}, #{record.clientName}, #{record.enabled}, #{record.updatedAtTs}
+        )
+        ON DUPLICATE KEY UPDATE
+            client_secret = VALUES(client_secret),
+            client_name = VALUES(client_name),
+            enabled = VALUES(enabled),
+            updated_at = VALUES(updated_at)
+    </insert>
+</mapper>

+ 69 - 0
src/main/resources/mapper/VivoMediaCallbackMapper.xml

@@ -0,0 +1,69 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.adx.tencent.vivo.mapper.VivoMediaCallbackMapper">
+    <resultMap id="vivoMediaCallbackResultMap" type="com.adx.tencent.vivo.model.VivoMediaCallbackRecord">
+        <result property="dedupeKey" column="dedupe_key"/>
+        <result property="qk" column="qk"/>
+        <result property="tagId" column="tag_id"/>
+        <result property="callbackUrl" column="callback_url"/>
+        <result property="eventType" column="event_type"/>
+        <result property="eventTimeMs" column="event_time_ms"/>
+        <result property="purchase" column="purchase"/>
+        <result property="deductionRate" column="deduction_rate"/>
+        <result property="directMatch" column="is_direct_match"/>
+        <result property="dispatchStatus" column="dispatch_status"/>
+        <result property="status" column="status"/>
+        <result property="ok" column="ok_val"/>
+        <result property="attempt" column="attempt"/>
+        <result property="responseBody" column="response_body"/>
+        <result property="errorMessage" column="error_message"/>
+        <result property="requestBody" column="request_body"/>
+        <result property="createdAt" column="created_at"/>
+    </resultMap>
+
+        <insert id="batchInsert">
+        INSERT INTO vivo_media_callbacks (
+            dedupe_key, qk, tag_id, callback_url, event_type, event_time_ms, purchase, deduction_rate,
+            is_direct_match, dispatch_status, status, ok, attempt, response_body, error_message, request_body, created_at
+        ) VALUES
+        <foreach collection="records" item="r" separator=",">
+            (
+                #{r.dedupeKey}, #{r.qk}, #{r.tagId}, #{r.callbackUrl}, #{r.eventType},
+                #{r.eventTimeMs}, #{r.purchase}, #{r.deductionRate}, #{r.directMatch}, #{r.dispatchStatus}, #{r.status}, #{r.ok}, #{r.attempt},
+                #{r.responseBody}, #{r.errorMessage}, #{r.requestBody}, #{r.createdAtTs}
+            )
+        </foreach>
+    </insert>
+
+    <select id="selectSuccessfulDedupeKeys" resultType="string">
+        SELECT DISTINCT dedupe_key
+        FROM vivo_media_callbacks
+        WHERE (ok = 1 OR dispatch_status = 'DEDUCTED')
+          AND dedupe_key IN
+          <foreach collection="dedupeKeys" item="dedupeKey" open="(" separator="," close=")">
+              #{dedupeKey}
+          </foreach>
+    </select>
+
+    <select id="selectPendingCallbacks" resultMap="vivoMediaCallbackResultMap">
+        SELECT m.dedupe_key, m.qk, m.tag_id, m.callback_url, m.event_type, m.event_time_ms,
+               m.purchase, m.deduction_rate, m.is_direct_match, m.dispatch_status, m.status, m.ok AS ok_val, m.attempt, m.response_body, m.error_message,
+               m.request_body, m.created_at
+        FROM vivo_media_callbacks m
+        JOIN (
+            SELECT dedupe_key, MAX(id) AS id
+            FROM vivo_media_callbacks
+            WHERE dedupe_key IS NOT NULL AND dedupe_key != ''
+            GROUP BY dedupe_key
+        ) latest ON latest.id = m.id
+        WHERE m.ok = 0
+          AND m.dispatch_status != 'DEDUCTED'
+          AND NOT EXISTS (
+            SELECT 1 FROM vivo_media_callbacks ok_cb
+            WHERE ok_cb.dedupe_key = m.dedupe_key AND ok_cb.ok = 1
+          )
+        ORDER BY m.created_at ASC
+        LIMIT #{limit}
+    </select>
+</mapper>

+ 23 - 0
src/main/resources/mapper/VivoTagEventMapper.xml

@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.adx.tencent.vivo.mapper.VivoTagEventMapper">
+    <resultMap id="vivoTagEventResultMap" type="com.adx.tencent.vivo.model.VivoTagEventRecord">
+        <result property="tagId" column="tag_id"/>
+        <result property="baiduAct" column="baidu_act"/>
+        <result property="eventType" column="event_type"/>
+    </resultMap>
+
+    <select id="selectByTagIdAndAct" resultMap="vivoTagEventResultMap">
+        SELECT tag_id, baidu_act, event_type
+        FROM vivo_tag_event
+        WHERE tag_id = #{tagId} AND baidu_act = #{baiduAct}
+        ORDER BY event_type ASC
+    </select>
+
+    <select id="selectAll" resultMap="vivoTagEventResultMap">
+        SELECT tag_id, baidu_act, event_type
+        FROM vivo_tag_event
+        ORDER BY tag_id ASC, baidu_act ASC, event_type ASC
+    </select>
+</mapper>

+ 61 - 0
src/main/resources/mapper/VivoTokenMapper.xml

@@ -0,0 +1,61 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.adx.tencent.vivo.mapper.VivoTokenMapper">
+    <resultMap id="vivoTokenResultMap" type="com.adx.tencent.vivo.model.VivoTokenRecord">
+        <result property="accountId" column="account_id"/>
+        <result property="accountName" column="account_name"/>
+        <result property="accountLevel" column="account_level"/>
+        <result property="accountStatus" column="account_status"/>
+        <result property="clientId" column="client_id"/>
+        <result property="clientSecret" column="client_secret"/>
+        <result property="accessToken" column="access_token"/>
+        <result property="refreshToken" column="refresh_token"/>
+        <result property="tokenExpireAt" column="token_expire_at"/>
+        <result property="refreshTokenExpireAt" column="refresh_token_expire_at"/>
+        <result property="authorizedAt" column="authorized_at"/>
+        <result property="updatedAt" column="updated_at"/>
+    </resultMap>
+
+    <select id="selectByAccountId" resultMap="vivoTokenResultMap">
+        SELECT account_id, account_name, account_level, account_status, client_id, client_secret,
+               access_token, refresh_token, token_expire_at,
+               refresh_token_expire_at, authorized_at, updated_at
+        FROM vivo_account_tokens
+        WHERE account_id = #{accountId}
+        LIMIT 1
+    </select>
+
+    <select id="selectAll" resultMap="vivoTokenResultMap">
+        SELECT account_id, account_name, account_level, account_status, client_id, client_secret,
+               access_token, refresh_token, token_expire_at,
+               refresh_token_expire_at, authorized_at, updated_at
+        FROM vivo_account_tokens
+        ORDER BY updated_at DESC
+    </select>
+
+    <insert id="insertOrUpdate">
+        INSERT INTO vivo_account_tokens (
+            account_id, account_name, account_level, account_status, client_id, client_secret,
+            access_token, refresh_token, token_expire_at,
+            refresh_token_expire_at, authorized_at, updated_at
+        ) VALUES (
+            #{record.accountId}, #{record.accountName}, #{record.accountLevel}, #{record.accountStatus},
+            #{record.clientId}, #{record.clientSecret},
+            #{record.accessToken}, #{record.refreshToken}, #{record.tokenExpireAt},
+            #{record.refreshTokenExpireAt}, #{record.authorizedAtTs}, #{record.updatedAtTs}
+        )
+        ON DUPLICATE KEY UPDATE
+            account_name = VALUES(account_name),
+            account_level = VALUES(account_level),
+            account_status = VALUES(account_status),
+            client_id = VALUES(client_id),
+            client_secret = VALUES(client_secret),
+            access_token = VALUES(access_token),
+            refresh_token = VALUES(refresh_token),
+            token_expire_at = VALUES(token_expire_at),
+            refresh_token_expire_at = VALUES(refresh_token_expire_at),
+            authorized_at = VALUES(authorized_at),
+            updated_at = VALUES(updated_at)
+    </insert>
+</mapper>

+ 9 - 0
src/main/resources/mapper/VivoTrackingReportMapper.xml

@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.adx.tencent.vivo.mapper.VivoTrackingReportMapper">
+    <insert id="insert">
+        INSERT INTO vivo_tracking_reports (qk, tag_id, kind, tracking_url, status, ok, created_at)
+        VALUES (#{qk}, #{tagId}, #{kind}, #{url}, #{status}, #{ok}, #{createdAtTs})
+    </insert>
+</mapper>

+ 100 - 0
src/main/resources/schema-vivo.sql

@@ -0,0 +1,100 @@
+CREATE TABLE IF NOT EXISTS vivo_ad_bid_events (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    qk VARCHAR(128) NOT NULL COMMENT '唯一业务键(query key)',
+    media VARCHAR(64) NOT NULL COMMENT '媒体来源(vivo)',
+    media_trace_id VARCHAR(255) COMMENT '媒体追踪ID(callback/设备标识)',
+    platform VARCHAR(16) COMMENT '平台(android/ios)',
+    account_id VARCHAR(128) COMMENT 'vivo 投放账户ID',
+    event_type VARCHAR(32) COMMENT '事件类型(impression/click),仅保留排障所需',
+    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 '媒体参数精简白名单(仅保留 clickId/srcId/pkgName/设备号/ts/deduction_rate)',
+    created_at TIMESTAMP(3) NOT NULL COMMENT '创建时间',
+    UNIQUE KEY uk_vivo_ad_bid_events_qk (qk),
+    KEY idx_vivo_ad_bid_events_media_trace (media, media_trace_id),
+    KEY idx_vivo_ad_bid_events_platform_tag (platform, tag_id),
+    KEY idx_vivo_ad_bid_events_account_created_at (account_id, created_at),
+    KEY idx_vivo_ad_bid_events_created_at (created_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='vivo竞价事件记录';
+
+CREATE TABLE IF NOT EXISTS vivo_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 '是否成功(1=成功,0=失败)',
+    created_at TIMESTAMP(3) NOT NULL COMMENT '创建时间',
+    KEY idx_vivo_tracking_reports_qk_kind_created_at (qk, kind, created_at),
+    KEY idx_vivo_tracking_reports_tag_created_at (tag_id, created_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='vivo曝光/点击上报结果';
+
+CREATE TABLE IF NOT EXISTS vivo_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',
+    callback_url TEXT NOT NULL COMMENT '回传的API URL',
+    event_type VARCHAR(64) NOT NULL COMMENT 'vivo 回传事件类型',
+    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 '是否直归因(1=是,0=否)',
+    dispatch_status VARCHAR(32) NOT NULL DEFAULT 'SENT' COMMENT '回传处理状态(SENT/DEDUCTED)',
+    status INT NOT NULL COMMENT 'HTTP响应状态码',
+    ok TINYINT(1) NOT NULL COMMENT '是否成功(1=成功,0=失败)',
+    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_vivo_media_callbacks_dedupe_ok_created_at (dedupe_key, ok, created_at),
+    KEY idx_vivo_media_callbacks_qk_created_at (qk, created_at),
+    KEY idx_vivo_media_callbacks_tag_created_at (tag_id, created_at),
+    KEY idx_vivo_media_callbacks_event_created_at (event_type, created_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='vivo媒体转化回传记录';
+
+CREATE TABLE IF NOT EXISTS vivo_tag_event (
+    tag_id VARCHAR(128) NOT NULL COMMENT '百度广告位ID',
+    baidu_act INT NOT NULL COMMENT '百度转化行为',
+    event_type VARCHAR(64) NOT NULL COMMENT 'vivo 回传事件类型',
+    PRIMARY KEY (tag_id, baidu_act, event_type)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='vivo广告位回传事件配置';
+
+CREATE TABLE IF NOT EXISTS vivo_account_tokens (
+    account_id VARCHAR(128) NOT NULL COMMENT 'vivo 二代账户ID',
+    account_name VARCHAR(255) COMMENT 'vivo 二代账户名称',
+    account_level INT COMMENT '账户等级(level)',
+    account_status INT COMMENT '账户状态(status)',
+    client_id VARCHAR(128) COMMENT '本次授权使用的vivo API client_id快照',
+    client_secret VARCHAR(1024) COMMENT '本次授权使用的vivo API client_secret快照',
+    access_token VARCHAR(1024) COMMENT 'access_token',
+    refresh_token VARCHAR(1024) COMMENT 'refresh_token',
+    token_expire_at BIGINT COMMENT 'access_token 过期时间(毫秒时间戳)',
+    refresh_token_expire_at BIGINT COMMENT 'refresh_token 过期时间(毫秒时间戳)',
+    authorized_at TIMESTAMP(3) NULL COMMENT '授权完成时间',
+    updated_at TIMESTAMP(3) NOT NULL COMMENT '更新时间',
+    PRIMARY KEY (account_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='vivo 二代账户授权 token';
+
+CREATE TABLE IF NOT EXISTS vivo_client_configs (
+    client_id VARCHAR(128) NOT NULL COMMENT 'vivo API client_id',
+    client_secret VARCHAR(1024) NOT NULL COMMENT 'vivo API client_secret',
+    client_name VARCHAR(255) COMMENT '凭证名称/备注',
+    enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用(1=启用,0=停用)',
+    updated_at TIMESTAMP(3) NOT NULL COMMENT '更新时间',
+    PRIMARY KEY (client_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='vivo开放平台凭证配置';
+
+CREATE TABLE IF NOT EXISTS vivo_advertiser_accounts (
+    advertiser_id VARCHAR(128) NOT NULL COMMENT 'vivo 广告主账户ID(advertiser_id)',
+    secondary_account_id VARCHAR(128) NOT NULL COMMENT '所属vivo二代账户ID',
+    advertiser_name VARCHAR(255) COMMENT 'vivo 广告主名称',
+    advertiser_status INT COMMENT '广告主状态(status)',
+    last_sync_at TIMESTAMP(3) NULL COMMENT '最近同步时间',
+    updated_at TIMESTAMP(3) NOT NULL COMMENT '更新时间',
+    PRIMARY KEY (advertiser_id),
+    KEY idx_vivo_advertiser_accounts_secondary (secondary_account_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='vivo 广告主到账户映射';