yumeng 3 veckor sedan
förälder
incheckning
282209d404
24 ändrade filer med 923 tillägg och 39 borttagningar
  1. 63 5
      src/main/java/com/adx/tencent/AppConfiguration.java
  2. 28 1
      src/main/java/com/adx/tencent/config/AppProperties.java
  3. 61 15
      src/main/java/com/adx/tencent/conversionsync/ConversionSyncService.java
  4. 0 4
      src/main/java/com/adx/tencent/honor/client/HonorClient.java
  5. 1 0
      src/main/java/com/adx/tencent/honor/service/HonorColdWorker.java
  6. 18 1
      src/main/java/com/adx/tencent/honor/store/HonorHotStore.java
  7. 326 1
      src/main/java/com/adx/tencent/httpapi/AdminController.java
  8. 1 0
      src/main/java/com/adx/tencent/kuaishou/service/KuaishouColdWorker.java
  9. 19 2
      src/main/java/com/adx/tencent/kuaishou/store/KuaishouHotStore.java
  10. 19 2
      src/main/java/com/adx/tencent/storage/RedisHotStore.java
  11. 37 0
      src/main/java/com/adx/tencent/storage/TiDBColdStore.java
  12. 23 0
      src/main/java/com/adx/tencent/storage/mapper/AccountTagEventMapper.java
  13. 33 0
      src/main/java/com/adx/tencent/storage/model/AccountTagEventRecord.java
  14. 127 0
      src/main/java/com/adx/tencent/tagsync/AccountTagEventResolver.java
  15. 81 0
      src/main/java/com/adx/tencent/tagsync/AccountTagEventSyncService.java
  16. 17 6
      src/main/java/com/adx/tencent/tencent/TencentClient.java
  17. 1 0
      src/main/java/com/adx/tencent/vivo/service/VivoColdWorker.java
  18. 19 2
      src/main/java/com/adx/tencent/vivo/store/VivoHotStore.java
  19. 1 0
      src/main/java/com/adx/tencent/worker/ColdWorker.java
  20. 3 0
      src/main/resources/application-dev.yml
  21. 4 0
      src/main/resources/application-prod.yml
  22. 3 0
      src/main/resources/application-test.yml
  23. 23 0
      src/main/resources/mapper/AccountTagEventMapper.xml
  24. 15 0
      src/main/resources/schema.sql

+ 63 - 5
src/main/java/com/adx/tencent/AppConfiguration.java

@@ -29,6 +29,8 @@ import com.adx.tencent.kuaishou.store.KuaishouHotStore;
 import com.adx.tencent.leader.LeaderElection;
 import com.adx.tencent.report.AdBidReportStore;
 import com.adx.tencent.rta.RtaOrderStore;
+import com.adx.tencent.tagsync.AccountTagEventResolver;
+import com.adx.tencent.tagsync.AccountTagEventSyncService;
 import com.adx.tencent.tagsync.TagEventResolver;
 import com.adx.tencent.tagsync.TagEventSyncService;
 import com.adx.tencent.tencent.TencentClient;
@@ -140,7 +142,8 @@ public class AppConfiguration {
                 coldStore,
                 "adx:tencent:",
                 props.getRedisStream(),
-                props.getBidTtl());
+                props.getBidTtl(),
+                props.getRedisStreamMaxLen());
     }
 
     @Bean
@@ -278,7 +281,7 @@ public class AppConfiguration {
                                        @Nullable HonorColdStore honorColdStore) {
         if (honorColdStore == null) return null;
         return new HonorHotStore(redis, objectMapper, honorColdStore,
-                "adx:honor:", props.getHonorRedisStream(), props.getBidTtl());
+                "adx:honor:", props.getHonorRedisStream(), props.getBidTtl(), props.getRedisStreamMaxLen());
     }
 
     @Bean
@@ -352,7 +355,7 @@ public class AppConfiguration {
                                              @Nullable KuaishouColdStore kuaishouColdStore) {
         if (kuaishouColdStore == null) return null;
         return new KuaishouHotStore(redis, objectMapper, kuaishouColdStore,
-                "adx:kuaishou:", props.getKuaishouRedisStream(), props.getBidTtl());
+                "adx:kuaishou:", props.getKuaishouRedisStream(), props.getBidTtl(), props.getRedisStreamMaxLen());
     }
 
     @Bean
@@ -423,7 +426,7 @@ public class AppConfiguration {
                                      @Nullable VivoColdStore vivoColdStore) {
         if (vivoColdStore == null) return null;
         return new VivoHotStore(redis, objectMapper, vivoColdStore,
-                "adx:vivo:", props.getVivoRedisStream(), props.getBidTtl());
+                "adx:vivo:", props.getVivoRedisStream(), props.getBidTtl(), props.getRedisStreamMaxLen());
     }
 
     @Bean
@@ -527,6 +530,13 @@ public class AppConfiguration {
     }
 
     @Bean
+    public AccountTagEventResolver accountTagEventResolver(@Nullable TiDBColdStore coldStore,
+                                                           StringRedisTemplate redisTemplate) {
+        if (coldStore == null) return null;
+        return new AccountTagEventResolver(redisTemplate, coldStore, props.getAccountTagEventSyncRedisPrefix());
+    }
+
+    @Bean
     public HonorTagEventResolver honorTagEventResolver(@Nullable HonorColdStore honorColdStore,
                                                        StringRedisTemplate redisTemplate) {
         if (honorColdStore == null) return null;
@@ -553,6 +563,7 @@ public class AppConfiguration {
                                                          TencentClient tencentClient,
                                                          @Nullable TiDBColdStore coldStore,
                                                          @Nullable TagEventResolver tagEventResolver,
+                                                         @Nullable AccountTagEventResolver accountTagEventResolver,
                                                          @Nullable HonorTagEventResolver honorTagEventResolver,
                                                          @Nullable KuaishouTagEventResolver kuaishouTagEventResolver,
                                                          @Nullable VivoTagEventResolver vivoTagEventResolver,
@@ -567,7 +578,7 @@ public class AppConfiguration {
                                                          @Nullable VivoClient vivoClient) {
         if (coldStore == null) return null;
         return new ConversionSyncService(baiduClient, hotStore, tencentClient, coldStore, tagEventResolver,
-                honorTagEventResolver, kuaishouTagEventResolver, vivoTagEventResolver,
+                accountTagEventResolver, honorTagEventResolver, kuaishouTagEventResolver, vivoTagEventResolver,
                 honorHotStore, honorColdStore, kuaishouHotStore, kuaishouColdStore, vivoHotStore, vivoColdStore,
                 honorClient, kuaishouClient, vivoClient, props);
     }
@@ -612,6 +623,15 @@ public class AppConfiguration {
     }
 
     @Bean
+    public AccountTagEventSyncService accountTagEventSyncService(@Nullable TiDBColdStore coldStore,
+                                                                 StringRedisTemplate redisTemplate) {
+        if (coldStore == null) return null;
+        Duration ttl = props.getAccountTagEventSyncInterval().multipliedBy(3);
+        return new AccountTagEventSyncService(coldStore, redisTemplate,
+                props.getAccountTagEventSyncRedisPrefix(), ttl);
+    }
+
+    @Bean
     public HonorTagEventSyncService honorTagEventSyncService(@Nullable HonorColdStore honorColdStore,
                                                              StringRedisTemplate redisTemplate) {
         if (honorColdStore == null) return null;
@@ -751,6 +771,7 @@ public class AppConfiguration {
         @Autowired(required = false) private ConversionSyncRunner conversionSyncRunner;
         @Autowired(required = false) private RetryService retryService;
         @Autowired(required = false) private TagEventSyncService tagEventSyncService;
+        @Autowired(required = false) private AccountTagEventSyncService accountTagEventSyncService;
         @Autowired(required = false) private LeaderElection leaderElection;
 
         private final AtomicBoolean stopped = new AtomicBoolean(false);
@@ -842,6 +863,28 @@ public class AppConfiguration {
                 taskLog.warn("Tag event sync NOT started: service={}, enabled={}",
                         tagEventSyncService != null, props.isTagEventSyncEnabled());
             }
+
+            // 腾讯账户级广告位回传方式同步
+            if (accountTagEventSyncService != null && props.isAccountTagEventSyncEnabled()) {
+                if (props.isSkipLeaderElection()) {
+                    executor.submit(() -> runAccountTagEventSync(() -> stopped.get()));
+                    taskLog.info("Account tag event sync started (skip leader election)");
+                } else if (leaderElection != null) {
+                    executor.submit(() ->
+                        leaderElection.run(
+                            "adx:lock:tencent:account-tag-event-sync",
+                            props.getTaskLockTtl(), props.getTaskLockRenewInterval(), props.getTaskLockRetryInterval(),
+                            stopped::get,
+                            (jobStop) -> runAccountTagEventSync(jobStop),
+                            e -> taskLog.error("tencent account tag event sync leader: {}", e.getMessage(), e)
+                        )
+                    );
+                    taskLog.info("Account tag event sync leader election started");
+                }
+            } else {
+                taskLog.warn("Account tag event sync NOT started: service={}, enabled={}",
+                        accountTagEventSyncService != null, props.isAccountTagEventSyncEnabled());
+            }
         }
 
         private void runConversionSync(LeaderElection.StopSignal jobStop) {
@@ -935,6 +978,21 @@ public class AppConfiguration {
             taskLog.info("[TagEventSync] task stopped");
         }
 
+        private void runAccountTagEventSync(LeaderElection.StopSignal jobStop) {
+            long intervalMs = props.getAccountTagEventSyncInterval().toMillis();
+            taskLog.info("[AccountTagEventSync] task started, interval={}ms", intervalMs);
+            while (!jobStop.isStopped()) {
+                try {
+                    int n = accountTagEventSyncService.syncOnce();
+                    taskLog.info("[AccountTagEventSync] done: synced={}", n);
+                } catch (Exception e) {
+                    taskLog.error("[AccountTagEventSync] error: {}", e.getMessage(), e);
+                }
+                sleepResponsive(intervalMs, jobStop);
+            }
+            taskLog.info("[AccountTagEventSync] task stopped");
+        }
+
         private static void sleepResponsive(long ms, LeaderElection.StopSignal jobStop) {
             long deadline = System.currentTimeMillis() + ms;
             while (!jobStop.isStopped()) {

+ 28 - 1
src/main/java/com/adx/tencent/config/AppProperties.java

@@ -112,7 +112,7 @@ public class AppProperties {
     private int redisDb = 0;
     private String redisStream = "adx:tencent:events";
     private long redisStreamMaxLen = 50000;
-    private Duration bidTtl = Duration.ofDays(5);
+    private Duration bidTtl = Duration.ofDays(3);
     private Duration redisConnectTimeout = Duration.ofSeconds(5);
     private Duration redisCommandTimeout = Duration.ofSeconds(5);
     private Duration redisShutdownTimeout = Duration.ofMillis(100);
@@ -166,6 +166,9 @@ public class AppProperties {
     private boolean tagEventSyncEnabled = false;
     private Duration tagEventSyncInterval = Duration.ofMinutes(5);
     private String tagEventSyncRedisPrefix = "";
+    private boolean accountTagEventSyncEnabled = false;
+    private Duration accountTagEventSyncInterval = Duration.ofMinutes(5);
+    private String accountTagEventSyncRedisPrefix = "adx:tencent:account-tag-event:";
 
     private static String defaultHostname() {
         try {
@@ -1152,4 +1155,28 @@ public class AppProperties {
     public void setTagEventSyncRedisPrefix(String v) {
         this.tagEventSyncRedisPrefix = v;
     }
+
+    public boolean isAccountTagEventSyncEnabled() {
+        return accountTagEventSyncEnabled;
+    }
+
+    public void setAccountTagEventSyncEnabled(boolean v) {
+        this.accountTagEventSyncEnabled = v;
+    }
+
+    public Duration getAccountTagEventSyncInterval() {
+        return accountTagEventSyncInterval;
+    }
+
+    public void setAccountTagEventSyncInterval(Duration v) {
+        this.accountTagEventSyncInterval = v;
+    }
+
+    public String getAccountTagEventSyncRedisPrefix() {
+        return accountTagEventSyncRedisPrefix;
+    }
+
+    public void setAccountTagEventSyncRedisPrefix(String v) {
+        this.accountTagEventSyncRedisPrefix = v;
+    }
 }

+ 61 - 15
src/main/java/com/adx/tencent/conversionsync/ConversionSyncService.java

@@ -23,6 +23,8 @@ import com.adx.tencent.storage.TiDBColdStore;
 import com.adx.tencent.storage.model.BidRecord;
 import com.adx.tencent.storage.model.ConversionRecord;
 import com.adx.tencent.storage.model.MediaCallbackRecord;
+import com.adx.tencent.storage.model.AccountTagEventRecord;
+import com.adx.tencent.tagsync.AccountTagEventResolver;
 import com.adx.tencent.tagsync.TagEventResolver;
 import com.adx.tencent.tencent.TencentClient;
 import com.adx.tencent.vivo.client.VivoClient;
@@ -91,6 +93,7 @@ public class ConversionSyncService {
     private final KuaishouClient kuaishouClient;
     private final VivoClient vivoClient;
     private final TagEventResolver tagEventResolver;
+    private final AccountTagEventResolver accountTagEventResolver;
     private final HonorTagEventResolver honorTagEventResolver;
     private final KuaishouTagEventResolver kuaishouTagEventResolver;
     private final VivoTagEventResolver vivoTagEventResolver;
@@ -107,6 +110,7 @@ public class ConversionSyncService {
                                  TencentClient tencentClient,
                                  TiDBColdStore coldStore,
                                  TagEventResolver tagEventResolver,
+                                 AccountTagEventResolver accountTagEventResolver,
                                  HonorTagEventResolver honorTagEventResolver,
                                  KuaishouTagEventResolver kuaishouTagEventResolver,
                                  VivoTagEventResolver vivoTagEventResolver,
@@ -125,6 +129,7 @@ public class ConversionSyncService {
         this.tencentClient = tencentClient;
         this.coldStore = coldStore;
         this.tagEventResolver = tagEventResolver;
+        this.accountTagEventResolver = accountTagEventResolver;
         this.honorTagEventResolver = honorTagEventResolver;
         this.kuaishouTagEventResolver = kuaishouTagEventResolver;
         this.vivoTagEventResolver = vivoTagEventResolver;
@@ -406,18 +411,36 @@ public class ConversionSyncService {
 
     private CallbackTask buildTencentCallbackTask(PaymentInfo payment, BidRecord bid) {
         String actionType = null;
+        String customAction = null;
+        if (accountTagEventResolver != null) {
+            AccountTagEventRecord accountMapping = accountTagEventResolver.resolveAction(
+                    bid.getTagId(), bid.getAccountId(), payment.getAct());
+            if (accountMapping != null) {
+                actionType = accountMapping.getTencentActionType();
+                customAction = accountMapping.getTencentCustomAction();
+                if ("CUSTOM".equals(actionType) && (customAction == null || customAction.isBlank())) {
+                    log.warn("[ConversionSync] 账户级 CUSTOM 回传缺少 custom_action, 忽略账户级配置并回退广告位配置 | qk={} | tagId={} | accountId={} | act={}",
+                            payment.getQk(), bid.getTagId(), bid.getAccountId(), payment.getAct());
+                    actionType = null;
+                    customAction = null;
+                }
+            }
+        }
         if (tagEventResolver != null) {
-            actionType = tagEventResolver.resolveActionType(bid.getTagId(), payment.getAct());
+            if (actionType == null || actionType.isBlank()) {
+                actionType = tagEventResolver.resolveActionType(bid.getTagId(), payment.getAct());
+                customAction = null;
+            }
         }
         if (actionType == null || actionType.isBlank()) {
             try {
                 actionType = tencentClient.conversionActionType(payment.getAct());
-                log.warn("[ConversionSync] 广告位回传方式未配置, 回退act-map | qk={} | tagId={} | act={} | actionType={}",
-                        payment.getQk(), bid.getTagId(), payment.getAct(), actionType);
+                log.warn("[ConversionSync] 账户级/广告位回传方式未配置, 回退act-map | qk={} | tagId={} | accountId={} | act={} | actionType={}",
+                        payment.getQk(), bid.getTagId(), bid.getAccountId(), payment.getAct(), actionType);
             } catch (IllegalArgumentException e) {
                 // act 既没配广告位回传方式,也没配 act-map:只落库不回传,避免整个任务中断
-                log.warn("[ConversionSync] 腾讯回传方式未配置, 跳过回传 | qk={} | tagId={} | act={} | date={} | error={}",
-                        payment.getQk(), bid.getTagId(), payment.getAct(), payment.getDate(), e.getMessage());
+                log.warn("[ConversionSync] 腾讯回传方式未配置, 跳过回传 | qk={} | tagId={} | accountId={} | act={} | date={} | error={}",
+                        payment.getQk(), bid.getTagId(), bid.getAccountId(), payment.getAct(), payment.getDate(), e.getMessage());
                 return null;
             }
         }
@@ -426,7 +449,8 @@ public class ConversionSyncService {
                 payment,
                 bid,
                 callbackDedupeKey(payment, bid, actionType),
-                actionType
+                actionType,
+                customAction
         );
     }
 
@@ -439,9 +463,14 @@ public class ConversionSyncService {
                 ? honorTagEventResolver.resolveConversionId(bid.getTagId(), payment.getAct())
                 : resolveHonorConversionIdFromDb(bid.getTagId(), payment.getAct());
         if (conversionId == null) {
-            log.warn("[ConversionSync] 荣耀 conversionId 未配置 | qk={} | tagId={} | act={}",
-                    payment.getQk(), bid.getTagId(), payment.getAct());
-            return null;
+            conversionId = fallbackHonorConversionId(payment.getAct());
+            if (conversionId == null) {
+                log.warn("[ConversionSync] 荣耀 conversionId 未配置且无兜底映射 | qk={} | tagId={} | act={}",
+                        payment.getQk(), bid.getTagId(), payment.getAct());
+                return null;
+            }
+            log.warn("[ConversionSync] 荣耀 conversionId 未配置, 启用兜底映射 | qk={} | tagId={} | act={} | conversionId={}",
+                    payment.getQk(), bid.getTagId(), payment.getAct(), conversionId);
         }
 
         return CallbackTask.honor(
@@ -452,6 +481,19 @@ public class ConversionSyncService {
         );
     }
 
+    private static Integer fallbackHonorConversionId(int baiduAct) {
+        return switch (baiduAct) {
+            case 1 -> 10001;      // 激活
+            case 2, 5 -> 10004;   // 付费
+            case 3 -> 10002;      // 注册
+            case 4 -> 10003;      // 次留
+            case 6, 2001 -> 10022;// 拉活
+            case 8 -> 10014;      // 7日留存
+            case 9 -> 10013;      // 3日留存
+            default -> null;
+        };
+    }
+
     private List<CallbackTask> buildKuaishouCallbackTasks(PaymentInfo payment, KuaishouBidRecord bid) {
         if (kuaishouColdStore == null && kuaishouTagEventResolver == null) {
             return List.of();
@@ -835,7 +877,7 @@ public class ConversionSyncService {
             }
             String platform = resolvePlatform(task.tencentBid);
             MediaCallbackRecord callbackRecord = tencentClient.sendConversionEvent(
-                    task.tencentBid, task.payment, platform, task.tencentActionType);
+                    task.tencentBid, task.payment, platform, task.tencentActionType, task.tencentCustomAction);
             callbackRecord.setDedupeKey(task.callbackKey);
             return CallbackDispatchResult.tencent(task, callbackRecord);
         }
@@ -1516,6 +1558,7 @@ public class ConversionSyncService {
         private final String callbackKey;
         private final BidRecord tencentBid;
         private final String tencentActionType;
+        private final String tencentCustomAction;
         private final HonorBidRecord honorBid;
         private final Integer honorConversionId;
         private final KuaishouBidRecord kuaishouBid;
@@ -1529,6 +1572,7 @@ public class ConversionSyncService {
                              String callbackKey,
                              BidRecord tencentBid,
                              String tencentActionType,
+                             String tencentCustomAction,
                              HonorBidRecord honorBid,
                              Integer honorConversionId,
                              KuaishouBidRecord kuaishouBid,
@@ -1541,6 +1585,7 @@ public class ConversionSyncService {
             this.callbackKey = callbackKey;
             this.tencentBid = tencentBid;
             this.tencentActionType = tencentActionType;
+            this.tencentCustomAction = tencentCustomAction;
             this.honorBid = honorBid;
             this.honorConversionId = honorConversionId;
             this.kuaishouBid = kuaishouBid;
@@ -1552,29 +1597,30 @@ public class ConversionSyncService {
         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, null, null);
+                                            String actionType,
+                                            String customAction) {
+            return new CallbackTask("tencent", payment, bid.getTagId(), callbackKey, bid, actionType, customAction, 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, null, null);
+            return new CallbackTask("honor", payment, bid.getTagId(), callbackKey, null, 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, null, null);
+            return new CallbackTask("kuaishou", payment, bid.getTagId(), callbackKey, null, 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);
+            return new CallbackTask("vivo", payment, bid.getTagId(), callbackKey, null, null, null, null, null, null, null, bid, tagEvent);
         }
 
         private boolean isTencent() {

+ 0 - 4
src/main/java/com/adx/tencent/honor/client/HonorClient.java

@@ -23,7 +23,6 @@ import java.util.Map;
 public class HonorClient {
 
     private static final Logger log = LoggerFactory.getLogger(HonorClient.class);
-    private static final String FIXED_CHANNEL_ID = "9";
 
     private final String baseUrl;
     private final ObjectMapper objectMapper;
@@ -121,9 +120,6 @@ public class HonorClient {
         params.put("conversionTime", String.valueOf(conversionTime));
         put(params, "advertiserId", value(mediaParams, "advertiserId"));
         put(params, "oaid", oaid);
-        params.put("id_type", "0");
-        params.put("channelId", FIXED_CHANNEL_ID);
-        put(params, "pkgName", firstNonBlank(value(mediaParams, "pkgName"), bid.getPackageName()));
         if (conversionId == 10004) {
             params.put("payAmount", String.format(Locale.ROOT, "%.2f",
                     payment.getGmv() > 0 ? payment.getGmv() : payment.getPayment()));

+ 1 - 0
src/main/java/com/adx/tencent/honor/service/HonorColdWorker.java

@@ -55,6 +55,7 @@ public class HonorColdWorker {
         }
         if (failed != null) throw failed;
         hotStore.ack(group, acked);
+        hotStore.delete(acked);
         if (streamMaxLen > 0) hotStore.trim(streamMaxLen);
         return events.size();
     }

+ 18 - 1
src/main/java/com/adx/tencent/honor/store/HonorHotStore.java

@@ -39,16 +39,19 @@ public class HonorHotStore {
     private final String prefix;
     private final String stream;
     private final Duration bidTtl;
+    private final long streamMaxLen;
     private final Set<String> initializedGroups = ConcurrentHashMap.newKeySet();
 
     public HonorHotStore(StringRedisTemplate redis, ObjectMapper objectMapper,
-                         HonorColdStore coldStore, String prefix, String stream, Duration bidTtl) {
+                         HonorColdStore coldStore, String prefix, String stream, Duration bidTtl,
+                         long streamMaxLen) {
         this.redis = redis;
         this.objectMapper = objectMapper;
         this.coldStore = coldStore;
         this.prefix = prefix;
         this.stream = stream;
         this.bidTtl = bidTtl;
+        this.streamMaxLen = streamMaxLen;
     }
 
     public void recordBid(HonorBidRecord record) {
@@ -57,6 +60,7 @@ public class HonorHotStore {
             String hotPayload = objectMapper.writeValueAsString(record.toHotRecord());
             String fullPayload = objectMapper.writeValueAsString(record);
             redis.executePipelined((org.springframework.data.redis.connection.RedisConnection conn) -> {
+                trimStreamIfNeeded(conn);
                 byte[] hotBytes = hotPayload.getBytes(StandardCharsets.UTF_8);
                 long ttlMillis = bidTtl.toMillis();
                 conn.stringCommands().set(
@@ -87,6 +91,7 @@ public class HonorHotStore {
         if (record.getCreatedAt() == null) record.setCreatedAt(Instant.now());
         try {
             String payload = objectMapper.writeValueAsString(record);
+            trim(streamMaxLen);
             redis.opsForStream().add(MapRecord.create(stream, Map.of("type", "tracking", "payload", payload)).withStreamKey(stream));
         } catch (Exception e) {
             throw new RuntimeException("record honor tracking failed", e);
@@ -235,10 +240,22 @@ public class HonorHotStore {
         redis.opsForStream().acknowledge(stream, group, ids.toArray(String[]::new));
     }
 
+    public void delete(List<String> ids) {
+        if (ids == null || ids.isEmpty()) return;
+        redis.opsForStream().delete(stream, ids.toArray(String[]::new));
+    }
+
     public void trim(long maxLen) {
         if (maxLen > 0) redis.opsForStream().trim(stream, maxLen, false);
     }
 
+    private void trimStreamIfNeeded(org.springframework.data.redis.connection.RedisConnection conn) {
+        if (streamMaxLen <= 0) {
+            return;
+        }
+        conn.streamCommands().xTrim(stream.getBytes(StandardCharsets.UTF_8), streamMaxLen);
+    }
+
     private String bidKey(String qk) { return prefix + "bid:" + qk; }
     private String mediaTraceKey(String traceId) { return prefix + "media:honor:" + traceId; }
 

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

@@ -7,13 +7,21 @@ import com.adx.tencent.conversionsync.ConversionSyncRunner;
 import com.adx.tencent.conversionsync.ConversionSyncService;
 import com.adx.tencent.conversionsync.ManualTencentCallbackService;
 import com.adx.tencent.conversionsync.RetryService;
+import org.springframework.data.redis.core.Cursor;
+import org.springframework.data.redis.core.ScanOptions;
+import org.springframework.data.redis.core.StringRedisTemplate;
 import org.springframework.http.HttpStatus;
 import org.springframework.lang.Nullable;
 import org.springframework.web.bind.annotation.*;
 import org.springframework.web.server.ResponseStatusException;
 
+import java.time.Duration;
+import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
 
 /**
  * 管理接口:手动触发转化同步和回调重试。
@@ -28,19 +36,22 @@ public class AdminController {
     private final ConversionSyncService conversionSyncer;
     private final RetryService retryService;
     private final ManualTencentCallbackService manualTencentCallbackService;
+    private final StringRedisTemplate redisTemplate;
 
     public AdminController(ConversionClient conversionClient,
                            @Nullable ConversionSyncRunner conversionSyncRunner,
                            @Nullable ConversionBackfillJobService backfillJobService,
                            @Nullable ConversionSyncService conversionSyncer,
                            @Nullable RetryService retryService,
-                           @Nullable ManualTencentCallbackService manualTencentCallbackService) {
+                           @Nullable ManualTencentCallbackService manualTencentCallbackService,
+                           @Nullable StringRedisTemplate redisTemplate) {
         this.conversionClient = conversionClient;
         this.conversionSyncRunner = conversionSyncRunner;
         this.backfillJobService = backfillJobService;
         this.conversionSyncer = conversionSyncer;
         this.retryService = retryService;
         this.manualTencentCallbackService = manualTencentCallbackService;
+        this.redisTemplate = redisTemplate;
     }
 
     @PostMapping("/conversions/query")
@@ -103,6 +114,193 @@ public class AdminController {
         return manualTencentCallbackService.replayDeductedCallback(id);
     }
 
+    @PostMapping("/redis/tighten-bid-media-ttl")
+    public Object tightenBidMediaTtl(@RequestBody(required = false) Map<String, Object> body) {
+        if (redisTemplate == null) {
+            throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
+                    "redis template is not configured");
+        }
+        long ttlSeconds = toLong(body == null ? null : body.get("ttlSeconds"));
+        if (ttlSeconds <= 0) {
+            ttlSeconds = Duration.ofDays(3).getSeconds();
+        }
+        long scanCount = toLong(body == null ? null : body.get("scanCount"));
+        if (scanCount <= 0) {
+            scanCount = 2000;
+        }
+        long maxKeys = toLong(body == null ? null : body.get("maxKeys"));
+        if (maxKeys <= 0) {
+            maxKeys = 200000;
+        }
+        List<String> patterns = parseStringList(body == null ? null : body.get("patterns"));
+        if (patterns.isEmpty()) {
+            patterns = defaultBidMediaPatterns();
+        }
+
+        List<Map<String, Object>> results = new ArrayList<>();
+        long totalScanned = 0;
+        long totalUpdated = 0;
+        for (String pattern : patterns) {
+            TtlTightenResult result = tightenTtl(pattern, ttlSeconds, scanCount, Math.max(0, maxKeys - totalScanned));
+            results.add(Map.of(
+                    "pattern", pattern,
+                    "scanned", result.scanned(),
+                    "updated", result.updated(),
+                    "stoppedByLimit", result.stoppedByLimit()
+            ));
+            totalScanned += result.scanned();
+            totalUpdated += result.updated();
+            if (totalScanned >= maxKeys) {
+                break;
+            }
+        }
+        return Map.of(
+                "ttlSeconds", ttlSeconds,
+                "ttlHuman", Duration.ofSeconds(ttlSeconds).toString(),
+                "scanCount", scanCount,
+                "maxKeys", maxKeys,
+                "scanned", totalScanned,
+                "updated", totalUpdated,
+                "patterns", results
+        );
+    }
+
+    @PostMapping("/redis/delete-bid-media-expiring-before")
+    public Object deleteBidMediaExpiringBefore(@RequestBody(required = false) Map<String, Object> body) {
+        if (redisTemplate == null) {
+            throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
+                    "redis template is not configured");
+        }
+        long ttlLessThanSeconds = toLong(body == null ? null : body.get("ttlLessThanSeconds"));
+        if (ttlLessThanSeconds <= 0) {
+            ttlLessThanSeconds = Duration.ofDays(2).getSeconds();
+        }
+        long scanCount = toLong(body == null ? null : body.get("scanCount"));
+        if (scanCount <= 0) {
+            scanCount = 1000;
+        }
+        long maxKeys = toLong(body == null ? null : body.get("maxKeys"));
+        if (maxKeys <= 0) {
+            maxKeys = 50000;
+        }
+        List<String> patterns = parseStringList(body == null ? null : body.get("patterns"));
+        if (patterns.isEmpty()) {
+            patterns = defaultBidMediaPatterns();
+        }
+
+        String jobId = UUID.randomUUID().toString();
+        RedisMaintenanceJob job = new RedisMaintenanceJob(jobId, "RUNNING", ttlLessThanSeconds, scanCount, maxKeys,
+                0, 0, null, List.of());
+        saveRedisMaintenanceJob(job);
+        List<String> taskPatterns = List.copyOf(patterns);
+        long taskTtlLessThanSeconds = ttlLessThanSeconds;
+        long taskScanCount = scanCount;
+        long taskMaxKeys = maxKeys;
+        CompletableFuture.runAsync(() -> runDeleteExpiringJob(jobId, taskPatterns, taskTtlLessThanSeconds,
+                taskScanCount, taskMaxKeys));
+        return Map.of(
+                "jobId", jobId,
+                "status", "RUNNING",
+                "ttlLessThanSeconds", ttlLessThanSeconds,
+                "ttlLessThanHuman", Duration.ofSeconds(ttlLessThanSeconds).toString(),
+                "scanCount", scanCount,
+                "maxKeys", maxKeys,
+                "patterns", taskPatterns
+        );
+    }
+
+    @GetMapping("/redis/delete-bid-media-expiring-before/{jobId}")
+    public Object getDeleteBidMediaExpiringBeforeJob(@PathVariable("jobId") String jobId) {
+        Map<Object, Object> job = readRedisMaintenanceJob(jobId);
+        if (job == null) {
+            throw new ResponseStatusException(HttpStatus.NOT_FOUND, "job not found");
+        }
+        return job;
+    }
+
+    private void runDeleteExpiringJob(String jobId, List<String> patterns, long ttlLessThanSeconds,
+                                      long scanCount, long maxKeys) {
+        List<Map<String, Object>> results = new ArrayList<>();
+        long totalScanned = 0;
+        long totalDeleted = 0;
+        try {
+            for (String pattern : patterns) {
+                DeleteExpiringResult result = deleteExpiringKeys(pattern, ttlLessThanSeconds, scanCount,
+                        Math.max(0, maxKeys - totalScanned));
+                results.add(Map.of(
+                        "pattern", pattern,
+                        "scanned", result.scanned(),
+                        "deleted", result.deleted(),
+                        "stoppedByLimit", result.stoppedByLimit()
+                ));
+                totalScanned += result.scanned();
+                totalDeleted += result.deleted();
+                saveRedisMaintenanceJob(new RedisMaintenanceJob(jobId, "RUNNING", ttlLessThanSeconds,
+                        scanCount, maxKeys, totalScanned, totalDeleted, null, List.copyOf(results)));
+                if (totalScanned >= maxKeys) {
+                    break;
+                }
+            }
+            saveRedisMaintenanceJob(new RedisMaintenanceJob(jobId, "COMPLETED", ttlLessThanSeconds,
+                    scanCount, maxKeys, totalScanned, totalDeleted, null, List.copyOf(results)));
+        } catch (Exception e) {
+            saveRedisMaintenanceJob(new RedisMaintenanceJob(jobId, "FAILED", ttlLessThanSeconds,
+                    scanCount, maxKeys, totalScanned, totalDeleted, rootMessage(e), List.copyOf(results)));
+        }
+    }
+
+    @PostMapping("/redis/delete-bid-media-expiring-before-sync")
+    public Object deleteBidMediaExpiringBeforeSync(@RequestBody(required = false) Map<String, Object> body) {
+        if (redisTemplate == null) {
+            throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
+                    "redis template is not configured");
+        }
+        long ttlLessThanSeconds = toLong(body == null ? null : body.get("ttlLessThanSeconds"));
+        if (ttlLessThanSeconds <= 0) {
+            ttlLessThanSeconds = Duration.ofDays(2).getSeconds();
+        }
+        long scanCount = toLong(body == null ? null : body.get("scanCount"));
+        if (scanCount <= 0) {
+            scanCount = 1000;
+        }
+        long maxKeys = toLong(body == null ? null : body.get("maxKeys"));
+        if (maxKeys <= 0) {
+            maxKeys = 50000;
+        }
+        List<String> patterns = parseStringList(body == null ? null : body.get("patterns"));
+        if (patterns.isEmpty()) {
+            patterns = defaultBidMediaPatterns();
+        }
+
+        List<Map<String, Object>> results = new ArrayList<>();
+        long totalScanned = 0;
+        long totalDeleted = 0;
+        for (String pattern : patterns) {
+            DeleteExpiringResult result = deleteExpiringKeys(pattern, ttlLessThanSeconds, scanCount,
+                    Math.max(0, maxKeys - totalScanned));
+            results.add(Map.of(
+                    "pattern", pattern,
+                    "scanned", result.scanned(),
+                    "deleted", result.deleted(),
+                    "stoppedByLimit", result.stoppedByLimit()
+            ));
+            totalScanned += result.scanned();
+            totalDeleted += result.deleted();
+            if (totalScanned >= maxKeys) {
+                break;
+            }
+        }
+        return Map.of(
+                "ttlLessThanSeconds", ttlLessThanSeconds,
+                "ttlLessThanHuman", Duration.ofSeconds(ttlLessThanSeconds).toString(),
+                "scanCount", scanCount,
+                "maxKeys", maxKeys,
+                "scanned", totalScanned,
+                "deleted", totalDeleted,
+                "patterns", results
+        );
+    }
+
     private static int toInt(Object v) {
         if (v == null) return 0;
         if (v instanceof Number n) return n.intValue();
@@ -115,6 +313,113 @@ public class AdminController {
         try { return Long.parseLong(v.toString()); } catch (NumberFormatException e) { return 0L; }
     }
 
+    private TtlTightenResult tightenTtl(String pattern, long ttlSeconds, long scanCount, long maxKeys) {
+        if (maxKeys <= 0 || pattern == null || pattern.isBlank()) {
+            return new TtlTightenResult(0, 0, true);
+        }
+        long scanned = 0;
+        long updated = 0;
+        ScanOptions options = ScanOptions.scanOptions().match(pattern).count(scanCount).build();
+        try (Cursor<String> cursor = redisTemplate.scan(options)) {
+            while (cursor.hasNext()) {
+                String key = cursor.next();
+                scanned++;
+                Long currentTtl = redisTemplate.getExpire(key, TimeUnit.SECONDS);
+                if (currentTtl == null || currentTtl < 0 || currentTtl > ttlSeconds) {
+                    Boolean ok = redisTemplate.expire(key, Duration.ofSeconds(ttlSeconds));
+                    if (Boolean.TRUE.equals(ok)) {
+                        updated++;
+                    }
+                }
+                if (scanned >= maxKeys) {
+                    return new TtlTightenResult(scanned, updated, true);
+                }
+            }
+        }
+        return new TtlTightenResult(scanned, updated, false);
+    }
+
+    private DeleteExpiringResult deleteExpiringKeys(String pattern, long ttlLessThanSeconds, long scanCount, long maxKeys) {
+        if (maxKeys <= 0 || pattern == null || pattern.isBlank()) {
+            return new DeleteExpiringResult(0, 0, true);
+        }
+        long scanned = 0;
+        long deleted = 0;
+        ScanOptions options = ScanOptions.scanOptions().match(pattern).count(scanCount).build();
+        try (Cursor<String> cursor = redisTemplate.scan(options)) {
+            while (cursor.hasNext()) {
+                String key = cursor.next();
+                scanned++;
+                Long currentTtl = redisTemplate.getExpire(key, TimeUnit.SECONDS);
+                if (currentTtl != null && currentTtl > 0 && currentTtl < ttlLessThanSeconds) {
+                    Boolean ok = redisTemplate.delete(key);
+                    if (Boolean.TRUE.equals(ok)) {
+                        deleted++;
+                    }
+                }
+                if (scanned >= maxKeys) {
+                    return new DeleteExpiringResult(scanned, deleted, true);
+                }
+            }
+        }
+        return new DeleteExpiringResult(scanned, deleted, false);
+    }
+
+    private void saveRedisMaintenanceJob(RedisMaintenanceJob job) {
+        try {
+            String key = redisJobKey(job.jobId());
+            Map<String, String> values = Map.of(
+                    "jobId", job.jobId(),
+                    "status", job.status(),
+                    "ttlLessThanSeconds", String.valueOf(job.ttlLessThanSeconds()),
+                    "scanCount", String.valueOf(job.scanCount()),
+                    "maxKeys", String.valueOf(job.maxKeys()),
+                    "scanned", String.valueOf(job.scanned()),
+                    "deleted", String.valueOf(job.deleted()),
+                    "error", job.error() == null ? "" : job.error(),
+                    "patterns", job.patterns().toString()
+            );
+            redisTemplate.opsForHash().putAll(key, values);
+            redisTemplate.expire(key, Duration.ofHours(2));
+        } catch (Exception ignored) {
+            // Job 状态只用于管理查询,写状态失败不影响清理任务继续执行。
+        }
+    }
+
+    private Map<Object, Object> readRedisMaintenanceJob(String jobId) {
+        Map<Object, Object> payload = redisTemplate.opsForHash().entries(redisJobKey(jobId));
+        if (payload == null || payload.isEmpty()) {
+            return null;
+        }
+        return payload;
+    }
+
+    private static List<String> parseStringList(Object raw) {
+        if (!(raw instanceof Iterable<?> iterable)) {
+            return List.of();
+        }
+        List<String> values = new ArrayList<>();
+        for (Object item : iterable) {
+            if (item != null && !item.toString().isBlank()) {
+                values.add(item.toString());
+            }
+        }
+        return values;
+    }
+
+    private static List<String> defaultBidMediaPatterns() {
+        return List.of(
+                "adx:bid:*",
+                "adx:media:*",
+                "adx:honor:bid:*",
+                "adx:honor:media:*",
+                "adx:kuaishou:bid:*",
+                "adx:kuaishou:media:*",
+                "adx:vivo:bid:*",
+                "adx:vivo:media:*"
+        );
+    }
+
     private static List<Integer> parseOffsets(Map<String, Object> body) {
         if (body == null || body.isEmpty()) {
             return defaultBackfillOffsets();
@@ -133,4 +438,24 @@ public class AdminController {
     private static List<Integer> defaultBackfillOffsets() {
         return List.of(-1, -2, -3, -4, -5, -6, -7);
     }
+
+    private static String redisJobKey(String jobId) {
+        return "adx:admin:redis-maintenance-job:" + jobId;
+    }
+
+    private static String rootMessage(Throwable e) {
+        Throwable cur = e;
+        while (cur.getCause() != null) {
+            cur = cur.getCause();
+        }
+        return cur.getMessage() == null ? cur.getClass().getName() : cur.getMessage();
+    }
+
+    private record TtlTightenResult(long scanned, long updated, boolean stoppedByLimit) {}
+
+    private record DeleteExpiringResult(long scanned, long deleted, boolean stoppedByLimit) {}
+
+    private record RedisMaintenanceJob(String jobId, String status, long ttlLessThanSeconds, long scanCount,
+                                       long maxKeys, long scanned, long deleted, String error,
+                                       List<Map<String, Object>> patterns) {}
 }

+ 1 - 0
src/main/java/com/adx/tencent/kuaishou/service/KuaishouColdWorker.java

@@ -55,6 +55,7 @@ public class KuaishouColdWorker {
         }
         if (failed != null) throw failed;
         hotStore.ack(group, acked);
+        hotStore.delete(acked);
         if (streamMaxLen > 0) hotStore.trim(streamMaxLen);
         return events.size();
     }

+ 19 - 2
src/main/java/com/adx/tencent/kuaishou/store/KuaishouHotStore.java

@@ -35,7 +35,7 @@ import java.util.concurrent.ConcurrentHashMap;
 public class KuaishouHotStore {
 
     private static final Logger log = LoggerFactory.getLogger(KuaishouHotStore.class);
-    private static final Duration DEFAULT_CONVERSION_SEEN_TTL = Duration.ofDays(45);
+    private static final Duration DEFAULT_CONVERSION_SEEN_TTL = Duration.ofDays(7);
     private static final Duration DEFAULT_DEDUCTION_COUNTER_TTL = Duration.ofDays(7);
 
     private final StringRedisTemplate redis;
@@ -44,17 +44,20 @@ public class KuaishouHotStore {
     private final String prefix;
     private final String stream;
     private final Duration bidTtl;
+    private final long streamMaxLen;
     private final DefaultRedisScript<List> deductionDecisionScript;
     private final Set<String> initializedGroups = ConcurrentHashMap.newKeySet();
 
     public KuaishouHotStore(StringRedisTemplate redis, ObjectMapper objectMapper,
-                            KuaishouColdStore coldStore, String prefix, String stream, Duration bidTtl) {
+                            KuaishouColdStore coldStore, String prefix, String stream, Duration bidTtl,
+                            long streamMaxLen) {
         this.redis = redis;
         this.objectMapper = objectMapper;
         this.coldStore = coldStore;
         this.prefix = prefix;
         this.stream = stream;
         this.bidTtl = bidTtl;
+        this.streamMaxLen = streamMaxLen;
         this.deductionDecisionScript = buildDeductionDecisionScript();
     }
 
@@ -64,6 +67,7 @@ public class KuaishouHotStore {
             String hotPayload = objectMapper.writeValueAsString(record.toHotRecord());
             String fullPayload = objectMapper.writeValueAsString(record);
             redis.executePipelined((org.springframework.data.redis.connection.RedisConnection conn) -> {
+                trimStreamIfNeeded(conn);
                 byte[] hotBytes = hotPayload.getBytes(StandardCharsets.UTF_8);
                 long ttlMillis = bidTtl.toMillis();
                 if (record.getQk() != null && !record.getQk().isBlank()) {
@@ -97,6 +101,7 @@ public class KuaishouHotStore {
         if (record.getCreatedAt() == null) record.setCreatedAt(Instant.now());
         try {
             String payload = objectMapper.writeValueAsString(record);
+            trim(streamMaxLen);
             redis.opsForStream().add(MapRecord.create(stream, Map.of("type", "tracking", "payload", payload)).withStreamKey(stream));
         } catch (Exception e) {
             throw new RuntimeException("record kuaishou tracking failed", e);
@@ -277,10 +282,22 @@ public class KuaishouHotStore {
         redis.opsForStream().acknowledge(stream, group, ids.toArray(String[]::new));
     }
 
+    public void delete(List<String> ids) {
+        if (ids == null || ids.isEmpty()) return;
+        redis.opsForStream().delete(stream, ids.toArray(String[]::new));
+    }
+
     public void trim(long maxLen) {
         if (maxLen > 0) redis.opsForStream().trim(stream, maxLen, false);
     }
 
+    private void trimStreamIfNeeded(org.springframework.data.redis.connection.RedisConnection conn) {
+        if (streamMaxLen <= 0) {
+            return;
+        }
+        conn.streamCommands().xTrim(stream.getBytes(StandardCharsets.UTF_8), streamMaxLen);
+    }
+
     private String bidKey(String qk) { return prefix + "bid:" + qk; }
     private String mediaTraceKey(String traceId) { return prefix + "media:kuaishou:" + traceId; }
     private String conversionSeenKey(String dedupeKey) { return prefix + "conv:seen:kuaishou:" + dedupeKey; }

+ 19 - 2
src/main/java/com/adx/tencent/storage/RedisHotStore.java

@@ -24,7 +24,7 @@ import java.util.concurrent.ConcurrentHashMap;
 public class RedisHotStore {
 
     public static final String BID_NOT_FOUND_MSG = "bid not found";
-    private static final Duration DEFAULT_CONVERSION_SEEN_TTL = Duration.ofDays(45);
+    private static final Duration DEFAULT_CONVERSION_SEEN_TTL = Duration.ofDays(7);
     private static final Duration DEFAULT_DEDUCTION_COUNTER_TTL = Duration.ofDays(7);
 
     private static final String DEFAULT_PREFIX = "adx:";
@@ -36,17 +36,20 @@ public class RedisHotStore {
     private final String prefix;
     private final String stream;
     private final Duration bidTtl;
+    private final long streamMaxLen;
     private final DefaultRedisScript<List> deductionDecisionScript;
     private final Set<String> initializedGroups = ConcurrentHashMap.newKeySet();
 
     public RedisHotStore(StringRedisTemplate redis, ObjectMapper objectMapper,
-                         TiDBColdStore coldStore, String prefix, String stream, Duration bidTtl) {
+                         TiDBColdStore coldStore, String prefix, String stream, Duration bidTtl,
+                         long streamMaxLen) {
         this.redis = redis;
         this.objectMapper = objectMapper;
         this.coldStore = coldStore;
         this.prefix = (prefix == null || prefix.isBlank()) ? DEFAULT_PREFIX : prefix;
         this.stream = (stream == null || stream.isBlank()) ? DEFAULT_STREAM : stream;
         this.bidTtl = (bidTtl == null || bidTtl.isZero()) ? Duration.ofHours(24) : bidTtl;
+        this.streamMaxLen = streamMaxLen;
         this.deductionDecisionScript = buildDeductionDecisionScript();
     }
 
@@ -67,6 +70,7 @@ public class RedisHotStore {
             String fullPayload = objectMapper.writeValueAsString(record);
 
             redis.executePipelined((org.springframework.data.redis.connection.RedisConnection conn) -> {
+                trimStreamIfNeeded(conn);
                 byte[] hotBytes = hotPayload.getBytes(StandardCharsets.UTF_8);
                 long ttlMillis = bidTtl.toMillis();
                 if (record.getQk() != null && !record.getQk().isEmpty()) {
@@ -108,6 +112,7 @@ public class RedisHotStore {
         }
         try {
             String payload = objectMapper.writeValueAsString(record);
+            trim(streamMaxLen);
             redis.opsForStream().add(
                     MapRecord.create(stream, Map.of("type", "tracking", "payload", payload))
                             .withStreamKey(stream)
@@ -372,6 +377,11 @@ public class RedisHotStore {
         redis.opsForStream().acknowledge(stream, group, idArr);
     }
 
+    public void delete(List<String> ids) {
+        if (ids == null || ids.isEmpty()) return;
+        redis.opsForStream().delete(stream, ids.toArray(String[]::new));
+    }
+
     /**
      * 对应 Go Trim:XTRIMAPPROX。
      */
@@ -380,6 +390,13 @@ public class RedisHotStore {
         redis.opsForStream().trim(stream, maxLen, false);
     }
 
+    private void trimStreamIfNeeded(org.springframework.data.redis.connection.RedisConnection conn) {
+        if (streamMaxLen <= 0) {
+            return;
+        }
+        conn.streamCommands().xTrim(stream.getBytes(StandardCharsets.UTF_8), streamMaxLen);
+    }
+
     // ─── 分布式锁支持(供 RedisLock 使用)────────────────────────────────────
 
     public StringRedisTemplate getRedisTemplate() {

+ 37 - 0
src/main/java/com/adx/tencent/storage/TiDBColdStore.java

@@ -133,6 +133,19 @@ public class TiDBColdStore {
                 tencent_action_type VARCHAR(128) NOT NULL COMMENT '腾讯回传行为',
                 PRIMARY KEY (tag_id, baidu_act)
             ) COMMENT '竞价事件记录'
+            """,
+            """
+            CREATE TABLE IF NOT EXISTS tencent_account_tag_event (
+                tag_id VARCHAR(128) NOT NULL COMMENT '百度广告位ID',
+                account_id VARCHAR(64) NOT NULL COMMENT '腾讯广告主ID',
+                baidu_act INT NOT NULL COMMENT '百度转化行为',
+                tencent_action_type VARCHAR(128) NOT NULL COMMENT '腾讯回传行为',
+                tencent_custom_action VARCHAR(128) COMMENT '腾讯自定义行为(action_type=CUSTOM时使用)',
+                created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
+                updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
+                PRIMARY KEY (tag_id, account_id, baidu_act),
+                KEY idx_tencent_account_tag_event_account_act (account_id, baidu_act)
+            ) COMMENT '腾讯账户级广告位回传方式配置'
             """
         };
         try (SqlSession session = sqlSessionFactory.openSession(true)) {
@@ -196,6 +209,9 @@ public class TiDBColdStore {
             try {
                 stmt.execute("ALTER TABLE tencent_tag_event ADD PRIMARY KEY (tag_id, baidu_act)");
             } catch (Exception ignored) {}
+            try {
+                stmt.execute("ALTER TABLE tencent_account_tag_event ADD COLUMN tencent_custom_action VARCHAR(128) COMMENT '腾讯自定义行为(action_type=CUSTOM时使用)' AFTER tencent_action_type");
+            } catch (Exception ignored) {}
             stmt.close();
         } catch (Exception e) {
             throw new RuntimeException("migrate failed", e);
@@ -433,6 +449,27 @@ public class TiDBColdStore {
         }
     }
 
+    // ─── 查询:腾讯账户级广告位回传方式配置 ───────────────────────────────────
+
+    public List<AccountTagEventRecord> listAllAccountTagEvents() {
+        try (SqlSession session = sqlSessionFactory.openSession(true)) {
+            AccountTagEventMapper mapper = session.getMapper(AccountTagEventMapper.class);
+            return mapper.selectAll();
+        }
+    }
+
+    /**
+     * 根据广告位ID、腾讯广告主ID和百度行为查询账户级回传方式配置(DB 兜底查询)。
+     */
+    public AccountTagEventRecord getAccountTagEvent(String tagId, String accountId, int baiduAct) {
+        if (tagId == null || tagId.isBlank()) return null;
+        if (accountId == null || accountId.isBlank()) return null;
+        try (SqlSession session = sqlSessionFactory.openSession(true)) {
+            AccountTagEventMapper mapper = session.getMapper(AccountTagEventMapper.class);
+            return mapper.selectByTagIdAndAccountIdAndAct(tagId, accountId, baiduAct);
+        }
+    }
+
     // ─── 工具方法 ─────────────────────────────────────────────────────────────
 
     private String toJson(Object value) {

+ 23 - 0
src/main/java/com/adx/tencent/storage/mapper/AccountTagEventMapper.java

@@ -0,0 +1,23 @@
+package com.adx.tencent.storage.mapper;
+
+import com.adx.tencent.storage.model.AccountTagEventRecord;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+@Mapper
+public interface AccountTagEventMapper {
+
+    /**
+     * 查询全部腾讯账户级广告位回传方式配置。
+     */
+    List<AccountTagEventRecord> selectAll();
+
+    /**
+     * 根据广告位ID、腾讯广告主ID和百度行为查询账户级回传方式配置。
+     */
+    AccountTagEventRecord selectByTagIdAndAccountIdAndAct(@Param("tagId") String tagId,
+                                                          @Param("accountId") String accountId,
+                                                          @Param("baiduAct") int baiduAct);
+}

+ 33 - 0
src/main/java/com/adx/tencent/storage/model/AccountTagEventRecord.java

@@ -0,0 +1,33 @@
+package com.adx.tencent.storage.model;
+
+/**
+ * 腾讯账户级广告位回传方式记录,对应 tencent_account_tag_event 表。
+ *   tag_id              百度广告位ID
+ *   account_id          腾讯广告主ID
+ *   baidu_act           百度转化行为
+ *   tencent_action_type 腾讯回传行为
+ *   tencent_custom_action 腾讯自定义行为,当 action_type=CUSTOM 时使用
+ */
+public class AccountTagEventRecord {
+
+    private String tagId;
+    private String accountId;
+    private int baiduAct;
+    private String tencentActionType;
+    private String tencentCustomAction;
+
+    public String getTagId() { return tagId; }
+    public void setTagId(String v) { this.tagId = v; }
+
+    public String getAccountId() { return accountId; }
+    public void setAccountId(String v) { this.accountId = v; }
+
+    public int getBaiduAct() { return baiduAct; }
+    public void setBaiduAct(int v) { this.baiduAct = v; }
+
+    public String getTencentActionType() { return tencentActionType; }
+    public void setTencentActionType(String v) { this.tencentActionType = v; }
+
+    public String getTencentCustomAction() { return tencentCustomAction; }
+    public void setTencentCustomAction(String v) { this.tencentCustomAction = v; }
+}

+ 127 - 0
src/main/java/com/adx/tencent/tagsync/AccountTagEventResolver.java

@@ -0,0 +1,127 @@
+package com.adx.tencent.tagsync;
+
+import com.adx.tencent.storage.TiDBColdStore;
+import com.adx.tencent.storage.model.AccountTagEventRecord;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.data.redis.core.StringRedisTemplate;
+
+import java.time.Duration;
+
+/**
+ * 腾讯账户级广告位回传方式解析器。
+ * 按 tag_id + account_id + baidu_act 精确命中账户级覆盖配置。
+ */
+public class AccountTagEventResolver {
+
+    private static final Logger log = LoggerFactory.getLogger(AccountTagEventResolver.class);
+    private static final String NO_CONFIG_VALUE = "__NO_ACCOUNT_TAG_EVENT__";
+    private static final Duration CONFIG_CACHE_TTL = Duration.ofMinutes(5);
+    private static final Duration NEGATIVE_CACHE_TTL = Duration.ofMinutes(1);
+
+    private final StringRedisTemplate redis;
+    private final TiDBColdStore coldStore;
+    private final String keyPrefix;
+
+    public AccountTagEventResolver(StringRedisTemplate redis, TiDBColdStore coldStore, String keyPrefix) {
+        this.redis = redis;
+        this.coldStore = coldStore;
+        this.keyPrefix = keyPrefix == null ? "" : keyPrefix;
+    }
+
+    /**
+     * 解析腾讯账户级广告位回传方式。
+     *
+     * @param tagId     百度广告位ID
+     * @param accountId 腾讯广告主ID
+     * @param baiduAct  百度转化行为
+     * @return 账户级回传配置;未配置时返回 null,让调用方继续走 tencent_tag_event
+     */
+    public AccountTagEventRecord resolveAction(String tagId, String accountId, int baiduAct) {
+        if (tagId == null || tagId.isBlank() || accountId == null || accountId.isBlank()) {
+            return null;
+        }
+
+        String key = buildRedisKey(tagId, accountId, baiduAct);
+        boolean negativeCached = false;
+
+        try {
+            String cached = redis.opsForValue().get(key);
+            if (isConfiguredValue(cached)) {
+                return fromCacheValue(tagId, accountId, baiduAct, cached);
+            }
+            negativeCached = NO_CONFIG_VALUE.equals(cached);
+            if (negativeCached) {
+                return null;
+            }
+        } catch (Exception e) {
+            log.warn("[AccountTagEventResolver] 读取 Redis 失败, 回退DB查询 | tagId={} | accountId={} | act={} | error={}",
+                    tagId, accountId, baiduAct, e.getMessage());
+        }
+
+        try {
+            if (!negativeCached) {
+                AccountTagEventRecord record = coldStore.getAccountTagEvent(tagId, accountId, baiduAct);
+                if (hasActionType(record)) {
+                    cacheConfigured(key, record);
+                    return record;
+                }
+                cacheNegative(key);
+            }
+        } catch (Exception e) {
+            log.warn("[AccountTagEventResolver] 查询 tencent_account_tag_event 失败 | tagId={} | accountId={} | act={} | error={}",
+                    tagId, accountId, baiduAct, e.getMessage());
+        }
+
+        return null;
+    }
+
+    private String buildRedisKey(String tagId, String accountId, int baiduAct) {
+        return keyPrefix + tagId + ":" + accountId + ":" + baiduAct;
+    }
+
+    private static boolean isConfiguredValue(String value) {
+        return value != null && !value.isBlank() && !NO_CONFIG_VALUE.equals(value);
+    }
+
+    private static boolean hasActionType(AccountTagEventRecord record) {
+        return record != null
+                && record.getTencentActionType() != null
+                && !record.getTencentActionType().isBlank();
+    }
+
+    private static AccountTagEventRecord fromCacheValue(String tagId, String accountId, int baiduAct, String value) {
+        String[] parts = value.split("\t", -1);
+        AccountTagEventRecord record = new AccountTagEventRecord();
+        record.setTagId(tagId);
+        record.setAccountId(accountId);
+        record.setBaiduAct(baiduAct);
+        record.setTencentActionType(parts.length > 0 ? parts[0] : value);
+        record.setTencentCustomAction(parts.length > 1 ? parts[1] : null);
+        return record;
+    }
+
+    private void cacheConfigured(String key, AccountTagEventRecord record) {
+        try {
+            redis.opsForValue().set(key,
+                    cacheValue(record.getTencentActionType(), record.getTencentCustomAction()),
+                    CONFIG_CACHE_TTL);
+        } catch (Exception e) {
+            log.warn("[AccountTagEventResolver] 写入 Redis 配置缓存失败 | key={} | error={}", key, e.getMessage());
+        }
+    }
+
+    private static String cacheValue(String actionType, String customAction) {
+        String action = actionType == null ? "" : actionType;
+        String custom = customAction == null ? "" : customAction;
+        return action + "\t" + custom;
+    }
+
+    private void cacheNegative(String key) {
+        try {
+            redis.opsForValue().set(key, NO_CONFIG_VALUE, NEGATIVE_CACHE_TTL);
+        } catch (Exception e) {
+            log.warn("[AccountTagEventResolver] 写入 Redis 未配置缓存失败 | key={} | error={}", key, e.getMessage());
+        }
+    }
+}

+ 81 - 0
src/main/java/com/adx/tencent/tagsync/AccountTagEventSyncService.java

@@ -0,0 +1,81 @@
+package com.adx.tencent.tagsync;
+
+import com.adx.tencent.storage.TiDBColdStore;
+import com.adx.tencent.storage.model.AccountTagEventRecord;
+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.List;
+
+/**
+ * 腾讯账户级广告位回传方式同步服务。
+ * 定时从 tencent_account_tag_event 表读取全部记录,同步到 Redis:
+ *   key   = {prefix}{tag_id}:{account_id}:{baidu_act}
+ *   value = tencent_action_type + tab + tencent_custom_action
+ */
+public class AccountTagEventSyncService {
+
+    private static final Logger log = LoggerFactory.getLogger(AccountTagEventSyncService.class);
+
+    private final TiDBColdStore coldStore;
+    private final StringRedisTemplate redis;
+    private final String keyPrefix;
+    private final Duration ttl;
+
+    public AccountTagEventSyncService(TiDBColdStore coldStore, StringRedisTemplate redis,
+                                      String keyPrefix, Duration ttl) {
+        this.coldStore = coldStore;
+        this.redis = redis;
+        this.keyPrefix = keyPrefix == null ? "" : keyPrefix;
+        this.ttl = ttl;
+    }
+
+    /**
+     * 执行一次同步,返回写入 Redis 的记录数。
+     */
+    public int syncOnce() {
+        List<AccountTagEventRecord> rows = coldStore.listAllAccountTagEvents();
+        if (rows == null || rows.isEmpty()) {
+            log.info("[AccountTagEventSync] 无数据可同步");
+            return 0;
+        }
+        long ttlMs = (ttl != null && !ttl.isZero()) ? ttl.toMillis() : 0;
+
+        redis.executePipelined((RedisConnection conn) -> {
+            for (AccountTagEventRecord row : rows) {
+                if (row.getTagId() == null || row.getTagId().isBlank()) continue;
+                if (row.getAccountId() == null || row.getAccountId().isBlank()) continue;
+                byte[] key = buildRedisKey(row.getTagId(), row.getAccountId(), row.getBaiduAct()).getBytes(StandardCharsets.UTF_8);
+                byte[] val = cacheValue(row.getTencentActionType(), row.getTencentCustomAction())
+                        .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("[AccountTagEventSync] 同步完成,共 {} 条腾讯账户级广告位回传方式写入 Redis", rows.size());
+        return rows.size();
+    }
+
+    private String buildRedisKey(String tagId, String accountId, int baiduAct) {
+        return keyPrefix + tagId + ":" + accountId + ":" + baiduAct;
+    }
+
+    private static String cacheValue(String actionType, String customAction) {
+        String action = actionType == null ? "" : actionType;
+        String custom = customAction == null ? "" : customAction;
+        return action + "\t" + custom;
+    }
+}

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

@@ -72,6 +72,11 @@ public class TencentClient {
      */
     public MediaCallbackRecord sendConversionEvent(BidRecord bid, PaymentInfo payment, String platform,
                                                    String actionType) {
+        return sendConversionEvent(bid, payment, platform, actionType, null);
+    }
+
+    public MediaCallbackRecord sendConversionEvent(BidRecord bid, PaymentInfo payment, String platform,
+                                                   String actionType, String customActionOverride) {
         long eventTimeMs = Instant.now().toEpochMilli();
 
         MediaCallbackRecord record = new MediaCallbackRecord();
@@ -96,7 +101,7 @@ public class TencentClient {
         record.setCallbackUrl(callbackUrl);
 
         // 构造 actions body
-        UserAction action = buildUserAction(bid, payment, actionType, eventTimeMs);
+        UserAction action = buildUserAction(bid, payment, actionType, customActionOverride, eventTimeMs);
         Map<String, Object> body = new LinkedHashMap<>();
         body.put("actions", List.of(action));
 
@@ -227,7 +232,7 @@ public class TencentClient {
     // ─── 构造 UserAction ────────────────────────────────────────────────────
 
     private UserAction buildUserAction(BidRecord bid, PaymentInfo payment,
-                                       String actionType, long eventTimeMs) {
+                                       String actionType, String customActionOverride, long eventTimeMs) {
         UserAction action = new UserAction();
         action.setActionTime(eventTimeMs / 1000); // 秒级时间戳
         action.setActionType(actionType);
@@ -263,10 +268,9 @@ public class TencentClient {
             action.setActionParam(param);
         } else if ("CUSTOM".equals(actionType)) {
             UserAction.ActionParam param = new UserAction.ActionParam();
-            String customAction = switch (payment.getAct()) {
-                case 7 -> "UV_CORE_ACTION";  // 关键行为(文档标准值)
-                default -> "custom_" + payment.getAct();
-            };
+            String customAction = customActionOverride != null && !customActionOverride.isBlank()
+                    ? customActionOverride
+                    : defaultCustomAction(payment.getAct());
             param.setCustomAction(customAction);
             action.setActionParam(param);
         }
@@ -274,6 +278,13 @@ public class TencentClient {
         return action;
     }
 
+    private static String defaultCustomAction(int baiduAct) {
+        return switch (baiduAct) {
+            case 7 -> "UV_CORE_ACTION";  // 关键行为(文档标准值)
+            default -> "custom_" + baiduAct;
+        };
+    }
+
     private static String resolveTrackingVersion(BidRecord bid) {
         if (bid == null || bid.getMediaParams() == null) {
             return "v1";

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

@@ -55,6 +55,7 @@ public class VivoColdWorker {
         }
         if (failed != null) throw failed;
         hotStore.ack(group, acked);
+        hotStore.delete(acked);
         if (streamMaxLen > 0) hotStore.trim(streamMaxLen);
         return events.size();
     }

+ 19 - 2
src/main/java/com/adx/tencent/vivo/store/VivoHotStore.java

@@ -35,7 +35,7 @@ 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_CONVERSION_SEEN_TTL = Duration.ofDays(7);
     private static final Duration DEFAULT_DEDUCTION_COUNTER_TTL = Duration.ofDays(7);
 
     private final StringRedisTemplate redis;
@@ -44,17 +44,20 @@ public class VivoHotStore {
     private final String prefix;
     private final String stream;
     private final Duration bidTtl;
+    private final long streamMaxLen;
     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) {
+                            VivoColdStore coldStore, String prefix, String stream, Duration bidTtl,
+                            long streamMaxLen) {
         this.redis = redis;
         this.objectMapper = objectMapper;
         this.coldStore = coldStore;
         this.prefix = prefix;
         this.stream = stream;
         this.bidTtl = bidTtl;
+        this.streamMaxLen = streamMaxLen;
         this.deductionDecisionScript = buildDeductionDecisionScript();
     }
 
@@ -64,6 +67,7 @@ public class VivoHotStore {
             String hotPayload = objectMapper.writeValueAsString(record.toHotRecord());
             String fullPayload = objectMapper.writeValueAsString(record);
             redis.executePipelined((org.springframework.data.redis.connection.RedisConnection conn) -> {
+                trimStreamIfNeeded(conn);
                 byte[] hotBytes = hotPayload.getBytes(StandardCharsets.UTF_8);
                 long ttlMillis = bidTtl.toMillis();
                 if (record.getQk() != null && !record.getQk().isBlank()) {
@@ -97,6 +101,7 @@ public class VivoHotStore {
         if (record.getCreatedAt() == null) record.setCreatedAt(Instant.now());
         try {
             String payload = objectMapper.writeValueAsString(record);
+            trim(streamMaxLen);
             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);
@@ -277,10 +282,22 @@ public class VivoHotStore {
         redis.opsForStream().acknowledge(stream, group, ids.toArray(String[]::new));
     }
 
+    public void delete(List<String> ids) {
+        if (ids == null || ids.isEmpty()) return;
+        redis.opsForStream().delete(stream, ids.toArray(String[]::new));
+    }
+
     public void trim(long maxLen) {
         if (maxLen > 0) redis.opsForStream().trim(stream, maxLen, false);
     }
 
+    private void trimStreamIfNeeded(org.springframework.data.redis.connection.RedisConnection conn) {
+        if (streamMaxLen <= 0) {
+            return;
+        }
+        conn.streamCommands().xTrim(stream.getBytes(StandardCharsets.UTF_8), streamMaxLen);
+    }
+
     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; }

+ 1 - 0
src/main/java/com/adx/tencent/worker/ColdWorker.java

@@ -89,6 +89,7 @@ public class ColdWorker {
         }
 
         hotStore.ack(group, acked);
+        hotStore.delete(acked);
         if (streamMaxLen > 0) {
             hotStore.trim(streamMaxLen);
         }

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

@@ -86,6 +86,9 @@ adx:
   # --- Tag Event Sync(广告位回传方式 -> Redis)---
   tag-event-sync-enabled: false
   tag-event-sync-interval: 300s
+  account-tag-event-sync-enabled: false
+  account-tag-event-sync-interval: 300s
+  account-tag-event-sync-redis-prefix: "adx:tencent:account-tag-event:"
 
   # --- Honor Tag Event Sync(荣耀广告位回传方式 -> Redis)---
   honor-tag-event-sync-enabled: false

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

@@ -49,6 +49,7 @@ adx:
   redis-username: "hcst"
   redis-password: "hcst@2026qwe.."
   redis-db: 5
+  bid-ttl: 3d
   redis-connect-timeout: 5s
   redis-command-timeout: 5s
   redis-shutdown-timeout: 100ms
@@ -91,6 +92,9 @@ adx:
   # --- Tag Event Sync(广告位回传方式 -> Redis)---
   tag-event-sync-enabled: true
   tag-event-sync-interval: 300s
+  account-tag-event-sync-enabled: true
+  account-tag-event-sync-interval: 300s
+  account-tag-event-sync-redis-prefix: "adx:tencent:account-tag-event:"
 
   # --- Honor Tag Event Sync(荣耀广告位回传方式 -> Redis)---
   honor-tag-event-sync-enabled: true

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

@@ -90,6 +90,9 @@ adx:
   # --- Tag Event Sync(广告位回传方式 -> Redis)---
   tag-event-sync-enabled: true
   tag-event-sync-interval: 300s
+  account-tag-event-sync-enabled: true
+  account-tag-event-sync-interval: 300s
+  account-tag-event-sync-redis-prefix: "adx:tencent:account-tag-event:"
 
   # --- Honor Tag Event Sync(荣耀广告位回传方式 -> Redis)---
   honor-tag-event-sync-enabled: false

+ 23 - 0
src/main/resources/mapper/AccountTagEventMapper.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.storage.mapper.AccountTagEventMapper">
+    <resultMap id="accountTagEventResultMap" type="com.adx.tencent.storage.model.AccountTagEventRecord">
+        <result property="tagId" column="tag_id"/>
+        <result property="accountId" column="account_id"/>
+        <result property="baiduAct" column="baidu_act"/>
+        <result property="tencentActionType" column="tencent_action_type"/>
+        <result property="tencentCustomAction" column="tencent_custom_action"/>
+    </resultMap>
+
+    <select id="selectAll" resultMap="accountTagEventResultMap">
+        SELECT tag_id, account_id, baidu_act, tencent_action_type, tencent_custom_action
+        FROM tencent_account_tag_event
+    </select>
+
+    <select id="selectByTagIdAndAccountIdAndAct" resultMap="accountTagEventResultMap">
+        SELECT tag_id, account_id, baidu_act, tencent_action_type, tencent_custom_action
+        FROM tencent_account_tag_event
+        WHERE tag_id = #{tagId} AND account_id = #{accountId} AND baidu_act = #{baiduAct}
+    </select>
+</mapper>

+ 15 - 0
src/main/resources/schema.sql

@@ -106,6 +106,21 @@ CREATE TABLE IF NOT EXISTS tencent_tag_event (
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='广告位回传方式配置';
 
 -- ------------------------------------------------------------
+-- 6. 腾讯账户级广告位回传方式配置表
+-- ------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS tencent_account_tag_event (
+    tag_id VARCHAR(128) NOT NULL COMMENT '百度广告位ID',
+    account_id VARCHAR(64) NOT NULL COMMENT '腾讯广告主ID',
+    baidu_act INT NOT NULL COMMENT '百度转化行为',
+    tencent_action_type VARCHAR(128) NOT NULL COMMENT '腾讯回传行为',
+    tencent_custom_action VARCHAR(128) COMMENT '腾讯自定义行为(action_type=CUSTOM时使用)',
+    created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
+    updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
+    PRIMARY KEY (tag_id, account_id, baidu_act),
+    KEY idx_tencent_account_tag_event_account_act (account_id, baidu_act)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='腾讯账户级广告位回传方式配置';
+
+-- ------------------------------------------------------------
 -- 字段补丁(兼容旧表升级)
 -- ------------------------------------------------------------
 -- 如果 req_id 原来是 VARCHAR(128),扩展为 512