Ver Fonte

快手回传

yumeng há 1 mês atrás
pai
commit
09821faa94
31 ficheiros alterados com 3248 adições e 15 exclusões
  1. 228 0
      KUAISHOU_TRACKING_LINK_USAGE.md
  2. 107 5
      src/main/java/com/adx/tencent/AppConfiguration.java
  3. 74 0
      src/main/java/com/adx/tencent/config/AppProperties.java
  4. 326 10
      src/main/java/com/adx/tencent/conversionsync/ConversionSyncService.java
  5. 244 0
      src/main/java/com/adx/tencent/kuaishou/client/KuaishouClient.java
  6. 538 0
      src/main/java/com/adx/tencent/kuaishou/controller/KuaishouTrackingController.java
  7. 14 0
      src/main/java/com/adx/tencent/kuaishou/mapper/KuaishouBidEventMapper.java
  8. 12 0
      src/main/java/com/adx/tencent/kuaishou/mapper/KuaishouConversionMapper.java
  9. 14 0
      src/main/java/com/adx/tencent/kuaishou/mapper/KuaishouMediaCallbackMapper.java
  10. 14 0
      src/main/java/com/adx/tencent/kuaishou/mapper/KuaishouTagEventMapper.java
  11. 9 0
      src/main/java/com/adx/tencent/kuaishou/mapper/KuaishouTrackingReportMapper.java
  12. 143 0
      src/main/java/com/adx/tencent/kuaishou/model/KuaishouBidRecord.java
  13. 53 0
      src/main/java/com/adx/tencent/kuaishou/model/KuaishouConversionRecord.java
  14. 62 0
      src/main/java/com/adx/tencent/kuaishou/model/KuaishouMediaCallbackRecord.java
  15. 14 0
      src/main/java/com/adx/tencent/kuaishou/model/KuaishouTagEventRecord.java
  16. 29 0
      src/main/java/com/adx/tencent/kuaishou/model/KuaishouTrackingRecord.java
  17. 119 0
      src/main/java/com/adx/tencent/kuaishou/service/KuaishouBackgroundTasks.java
  18. 69 0
      src/main/java/com/adx/tencent/kuaishou/service/KuaishouColdWorker.java
  19. 137 0
      src/main/java/com/adx/tencent/kuaishou/service/KuaishouPlacement.java
  20. 75 0
      src/main/java/com/adx/tencent/kuaishou/service/KuaishouRetryService.java
  21. 76 0
      src/main/java/com/adx/tencent/kuaishou/service/KuaishouTagEventResolver.java
  22. 72 0
      src/main/java/com/adx/tencent/kuaishou/service/KuaishouTagEventSyncService.java
  23. 189 0
      src/main/java/com/adx/tencent/kuaishou/store/KuaishouColdStore.java
  24. 377 0
      src/main/java/com/adx/tencent/kuaishou/store/KuaishouHotStore.java
  25. 60 0
      src/main/resources/mapper/KuaishouBidEventMapper.xml
  26. 32 0
      src/main/resources/mapper/KuaishouConversionMapper.xml
  27. 69 0
      src/main/resources/mapper/KuaishouMediaCallbackMapper.xml
  28. 21 0
      src/main/resources/mapper/KuaishouTagEventMapper.xml
  29. 9 0
      src/main/resources/mapper/KuaishouTrackingReportMapper.xml
  30. 62 0
      src/main/resources/schema-kuaishou.sql
  31. BIN
      快手监测链接接入说明.docx

+ 228 - 0
KUAISHOU_TRACKING_LINK_USAGE.md

@@ -0,0 +1,228 @@
+# 快手曝光/点击链接使用文档
+
+本文档用于说明快手媒体接入时,安卓和 iOS 的曝光、点击监测链接如何拼接、各参数含义、哪些参数必填,以及服务端收到请求后的处理逻辑。
+
+适用范围:
+
+- 快手曝光链接:`/kuaishou/impression`
+- 快手点击链接:`/kuaishou/click`
+
+代码入口:
+
+- `src/main/java/com/adx/tencent/kuaishou/controller/KuaishouTrackingController.java`
+
+## 1. 链接地址
+
+请将 `{your-domain}` 替换为项目实际对外域名。
+
+### 1.1 iOS 曝光链接
+
+```text
+https://{your-domain}/kuaishou/impression?callback=__CALLBACK__&platform=ios&tagId={baiduTagId}&accountId={accountId}&idfa=__IDFA2__&kenyid_caa=__KENYID_CAA__&ip=__IP__&ua=__UA__&deduction_rate={0-100}&ts=__TS__
+```
+
+### 1.2 iOS 点击链接
+
+```text
+https://{your-domain}/kuaishou/click?callback=__CALLBACK__&platform=ios&tagId={baiduTagId}&accountId={accountId}&idfa=__IDFA2__&kenyid_caa=__KENYID_CAA__&ip=__IP__&ua=__UA__&deduction_rate={0-100}&ts=__TS__
+```
+
+### 1.3 Android 曝光链接
+
+```text
+https://{your-domain}/kuaishou/impression?callback=__CALLBACK__&platform=android&tagId={baiduTagId}&accountId={accountId}&oaid=__OAID2__&imei=__IMEI2__&androidid=__ANDROIDID2__&ip=__IP__&ua=__UA__&deduction_rate={0-100}&ts=__TS__
+```
+
+### 1.4 Android 点击链接
+
+```text
+https://{your-domain}/kuaishou/click?callback=__CALLBACK__&platform=android&tagId={baiduTagId}&accountId={accountId}&oaid=__OAID2__&imei=__IMEI2__&androidid=__ANDROIDID2__&ip=__IP__&ua=__UA__&deduction_rate={0-100}&ts=__TS__
+```
+
+## 2. 参数说明
+
+### 2.1 通用参数
+
+| 参数名 | 是否必填 | 说明 |
+| --- | --- | --- |
+| `callback` | 必填 | 快手下发的回传标识。后续回传快手时会从这里取值。 |
+| `tagId` | 建议必填 | 百度广告位 ID。若配置了多广告位,必须传。 |
+| `platform` | 建议必填 | 平台类型,取值:`ios` / `android`。 |
+| `accountId` | 必填 | 快手投放账户 ID。快手扣量按账户维度参与计算。 |
+| `ip` | 建议必填 | 用户 IP。若未传,服务端会尝试从请求中获取。 |
+| `ua` | 建议必填 | 用户 UA。若未传,服务端会根据平台补默认值。 |
+| `deduction_rate` | 选填 | 扣量比例,范围 `0-100`。不传时按 `0` 处理,即不扣量。 |
+| `ts` | 选填 | 媒体事件时间戳。用于回传时生成 `event_time`,建议传毫秒时间戳。 |
+
+说明:
+
+- `callback` 从业务上应视为必填。没有 `callback` 会导致后续无法正常回传快手。
+- `tagId` 与 `platform` 至少要能让服务端定位到正确的百度 `appId/tagId` 配置。若同一平台只配置了一个 `tagId`,理论上可仅传 `platform`;为减少歧义,建议始终传 `tagId`。
+
+### 2.2 iOS 设备参数
+
+| 参数名 | 是否必填 | 说明 |
+| --- | --- | --- |
+| `idfa` | 至少一种 | 对应快手宏 `__IDFA2__`,iOS 下的 IDFA MD5。 |
+| `kenyid_caa` | 至少一种 | 对应快手宏 `__KENYID_CAA__`,URL Encode 后的 CAID JSON 数组。 |
+
+说明:
+
+- `idfa`、`kenyid_caa` 至少传一种。
+- 若两者都传,服务端会一起带给百度。
+
+### 2.3 Android 设备参数
+
+| 参数名 | 是否必填 | 说明 |
+| --- | --- | --- |
+| `oaid` | 至少一种 | 对应快手宏 `__OAID2__`,Android 下的 OAID MD5。 |
+| `imei` | 至少一种 | 对应快手宏 `__IMEI2__` 或 `__IMEI4__`,Android 下的 IMEI MD5。 |
+| `androidid` | 至少一种 | 对应快手宏 `__ANDROIDID2__`,Android 下的 AndroidID MD5。 |
+
+说明:
+
+- `oaid`、`imei`、`androidid` 至少传一种。
+- 推荐优先传 `oaid`。
+
+## 3. 当前不需要传的参数
+
+以下参数在当前快手链路中不再参与主流程,也不再进入 Redis 瘦身白名单:
+
+- `campaignid`
+- `aid`
+- `cid`
+- `deduction_key`
+
+说明:
+
+- 快手扣量已按腾讯口径收敛,不再使用 `deduction_key`。
+- 如无特殊排障需求,不需要继续向媒体暴露这些参数。
+
+## 4. 服务端处理逻辑
+
+### 4.1 曝光链接
+
+服务端收到 `/kuaishou/impression` 请求后:
+
+1. 规范化请求参数。
+2. 根据 `tagId/platform` 解析百度广告位配置。
+3. 拼装百度竞价请求并调用百度 ADX。
+4. 落 `kuaishou_ad_bid_events` 竞价事件数据。
+5. 将百度返回的曝光监测地址上报,并记录到 `kuaishou_tracking_reports`。
+6. 返回 JSON:
+
+```json
+{
+  "qk": "xxx",
+  "traceId": "xxx",
+  "tagId": "xxx"
+}
+```
+
+### 4.2 点击链接
+
+服务端收到 `/kuaishou/click` 请求后:
+
+1. 优先按 `traceId` 从 Redis 查快手 bid 缓存。
+2. 若未命中,则补发一次百度竞价请求并落缓存。
+3. 调用百度点击监测地址。
+4. 点击完成后对 Redis 中的 bid 数据做瘦身。
+5. 若百度返回了落地页或商店链接,则 302 跳转到目标地址。
+6. 若没有可跳转地址,则返回 JSON:
+
+```json
+{
+  "qk": "xxx",
+  "traceId": "xxx",
+  "tagId": "xxx"
+}
+```
+
+## 5. 回传与扣量逻辑
+
+### 5.1 快手回传
+
+在百度转化拉取后:
+
+1. 先根据 `qk` 匹配快手 bid。
+2. 再根据 `tagId + baidu_act` 解析 `kuaishou_tag_event.event_type`。
+3. 回传前先查 `kuaishou_media_callbacks`,只发送未处理过的回传任务。
+
+### 5.2 扣量逻辑
+
+快手扣量已按腾讯口径处理:
+
+- 对外链接只需要传 `deduction_rate`
+- 扣量在百度转化回流时计算
+- 扣量维度:`accountId + act + date`
+
+扣量命中时:
+
+- 不回传快手
+- 仅落 `kuaishou_media_callbacks`
+- `dispatch_status = DEDUCTED`
+
+### 5.3 回传去重
+
+相同回传任务不会重复发送。去重依据为回传任务的 `dedupe_key`。
+
+已视为“处理过”的情况:
+
+- `ok = 1`
+- 或 `dispatch_status = DEDUCTED`
+
+## 6. `kuaishou_tag_event` 配置要求
+
+当前 `kuaishou_tag_event` 只保留以下字段:
+
+- `tag_id`
+- `baidu_act`
+- `event_type`
+
+说明:
+
+- `event_type` 为快手回传事件类型。
+- 不再通过表字段配置 `event_props_json`。
+- 若某些特殊 `event_type` 需要额外业务字段,应由代码按 `event_type` 分支处理。
+
+## 7. 联调建议
+
+建议媒体侧按以下最小参数集进行联调:
+
+### iOS 最小参数集
+
+```text
+callback + platform + tagId + accountId + idfa/kenyid_caa + ip + ua
+```
+
+### Android 最小参数集
+
+```text
+callback + platform + tagId + accountId + oaid/imei/androidid + ip + ua
+```
+
+### 如需扣量
+
+再补:
+
+```text
+deduction_rate
+```
+
+## 8. 注意事项
+
+1. `callback` 必须可用,否则后续无法正常回传快手。
+2. `accountId` 建议始终传,避免扣量维度退化到 `unknown`。
+3. `tagId` 建议始终传,避免多广告位场景下路由错误。
+4. `deduction_rate` 仅影响转化回流后的扣量,不影响曝光/点击请求本身是否成功。
+5. 若快手侧后续需要使用特殊 `event_type`,需提前确认是否存在额外必填业务字段。
+
+## 9. 快手宏对应关系
+
+| 链接参数 | 快手宏 |
+| --- | --- |
+| `idfa` | `__IDFA2__` |
+| `kenyid_caa` | `__KENYID_CAA__` |
+| `oaid` | `__OAID2__` |
+| `imei` | `__IMEI2__` 或 `__IMEI4__` |
+| `androidid` | `__ANDROIDID2__` |

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

@@ -18,6 +18,14 @@ import com.adx.tencent.honor.service.HonorTagEventResolver;
 import com.adx.tencent.honor.service.HonorTagEventSyncService;
 import com.adx.tencent.honor.store.HonorColdStore;
 import com.adx.tencent.honor.store.HonorHotStore;
+import com.adx.tencent.kuaishou.client.KuaishouClient;
+import com.adx.tencent.kuaishou.service.KuaishouColdWorker;
+import com.adx.tencent.kuaishou.service.KuaishouPlacement;
+import com.adx.tencent.kuaishou.service.KuaishouRetryService;
+import com.adx.tencent.kuaishou.service.KuaishouTagEventResolver;
+import com.adx.tencent.kuaishou.service.KuaishouTagEventSyncService;
+import com.adx.tencent.kuaishou.store.KuaishouColdStore;
+import com.adx.tencent.kuaishou.store.KuaishouHotStore;
 import com.adx.tencent.leader.LeaderElection;
 import com.adx.tencent.tagsync.TagEventResolver;
 import com.adx.tencent.tagsync.TagEventSyncService;
@@ -46,10 +54,9 @@ import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
 import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration;
 import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
 import org.springframework.data.redis.core.StringRedisTemplate;
-import org.springframework.stereotype.Component;
-import org.springframework.scheduling.annotation.Scheduled;
-
 import org.springframework.lang.Nullable;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
 
 import javax.sql.DataSource;
 import java.time.Duration;
@@ -276,6 +283,79 @@ public class AppConfiguration {
                 null, props.getRedisStreamMaxLen(), objectMapper);
     }
 
+    @Bean
+    public KuaishouColdStore kuaishouColdStore(ObjectMapper objectMapper) {
+        String url = props.getTidbUrl();
+        if (url == null || url.isBlank()) return null;
+        try {
+            DataSource ds = createDataSource(url, props.getTidbUsername(), props.getTidbPassword());
+            SqlSessionFactory sqlSessionFactory = createSqlSessionFactory(ds);
+            KuaishouColdStore store = new KuaishouColdStore(sqlSessionFactory, objectMapper);
+            store.migrate();
+            return store;
+        } catch (Exception e) {
+            log.warn("Failed to open Kuaishou TiDB: {}", e.getMessage(), e);
+            return null;
+        }
+    }
+
+    @Bean
+    public KuaishouHotStore kuaishouHotStore(StringRedisTemplate redis,
+                                             ObjectMapper objectMapper,
+                                             @Nullable KuaishouColdStore kuaishouColdStore) {
+        if (kuaishouColdStore == null) return null;
+        return new KuaishouHotStore(redis, objectMapper, kuaishouColdStore,
+                "adx:kuaishou:", props.getKuaishouRedisStream(), props.getBidTtl());
+    }
+
+    @Bean
+    public KuaishouPlacement kuaishouPlacement() {
+        KuaishouPlacement p = new KuaishouPlacement();
+        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, KuaishouPlacement.KuaishouAppPlacement> platforms = new HashMap<>();
+        if (props.getBaiduAndroidAppId() != null && !props.getBaiduAndroidAppId().isBlank()) {
+            KuaishouPlacement.KuaishouAppPlacement android = new KuaishouPlacement.KuaishouAppPlacement();
+            android.setAppId(props.getBaiduAndroidAppId());
+            android.setTagIds(props.getBaiduAndroidTagIds());
+            platforms.put("android", android);
+        }
+        if (props.getBaiduIosAppId() != null && !props.getBaiduIosAppId().isBlank()) {
+            KuaishouPlacement.KuaishouAppPlacement ios = new KuaishouPlacement.KuaishouAppPlacement();
+            ios.setAppId(props.getBaiduIosAppId());
+            ios.setTagIds(props.getBaiduIosTagIds());
+            platforms.put("ios", ios);
+        }
+        if (!platforms.isEmpty()) p.setPlatforms(platforms);
+        return p;
+    }
+
+    @Bean
+    public KuaishouClient kuaishouClient(ObjectMapper objectMapper) {
+        return new KuaishouClient(props.getKuaishouConversionBaseUrl(), objectMapper);
+    }
+
+    @Bean
+    public KuaishouRetryService kuaishouRetryService(@Nullable KuaishouColdStore kuaishouColdStore,
+                                                     KuaishouClient kuaishouClient) {
+        if (kuaishouColdStore == null) return null;
+        return new KuaishouRetryService(kuaishouColdStore, kuaishouClient, props.getKuaishouCallbackRetryLimit());
+    }
+
+    @Bean
+    public KuaishouColdWorker kuaishouColdWorker(@Nullable KuaishouHotStore kuaishouHotStore,
+                                                 @Nullable KuaishouColdStore kuaishouColdStore,
+                                                 ObjectMapper objectMapper) {
+        if (kuaishouHotStore == null || kuaishouColdStore == null) return null;
+        return new KuaishouColdWorker(kuaishouHotStore, kuaishouColdStore,
+                "kuaishou-cold-writers", props.getWorkerConsumer() + "-kuaishou", props.getWorkerBatch(),
+                null, props.getRedisStreamMaxLen(), objectMapper);
+    }
+
     // ─── Tencent 客户端 + Sync 服务 ──────────────────────────────────────────
 
     @Bean
@@ -311,18 +391,31 @@ public class AppConfiguration {
     }
 
     @Bean
+    public KuaishouTagEventResolver kuaishouTagEventResolver(@Nullable KuaishouColdStore kuaishouColdStore,
+                                                             StringRedisTemplate redisTemplate) {
+        if (kuaishouColdStore == null) return null;
+        return new KuaishouTagEventResolver(redisTemplate, kuaishouColdStore, props.getKuaishouTagEventSyncRedisPrefix());
+    }
+
+    @Bean
     public ConversionSyncService conversionSyncService(ConversionClient baiduClient,
                                                          RedisHotStore hotStore,
                                                          TencentClient tencentClient,
                                                          @Nullable TiDBColdStore coldStore,
                                                          @Nullable TagEventResolver tagEventResolver,
                                                          @Nullable HonorTagEventResolver honorTagEventResolver,
+                                                         @Nullable KuaishouTagEventResolver kuaishouTagEventResolver,
                                                          @Nullable HonorHotStore honorHotStore,
                                                          @Nullable HonorColdStore honorColdStore,
-                                                         HonorClient honorClient) {
+                                                         @Nullable KuaishouHotStore kuaishouHotStore,
+                                                         @Nullable KuaishouColdStore kuaishouColdStore,
+                                                         HonorClient honorClient,
+                                                         KuaishouClient kuaishouClient) {
         if (coldStore == null) return null;
         return new ConversionSyncService(baiduClient, hotStore, tencentClient, coldStore, tagEventResolver,
-                honorTagEventResolver, honorHotStore, honorColdStore, honorClient, props);
+                honorTagEventResolver, kuaishouTagEventResolver,
+                honorHotStore, honorColdStore, kuaishouHotStore, kuaishouColdStore,
+                honorClient, kuaishouClient, props);
     }
 
     @Bean
@@ -373,6 +466,15 @@ public class AppConfiguration {
                 props.getHonorTagEventSyncRedisPrefix(), ttl);
     }
 
+    @Bean
+    public KuaishouTagEventSyncService kuaishouTagEventSyncService(@Nullable KuaishouColdStore kuaishouColdStore,
+                                                                   StringRedisTemplate redisTemplate) {
+        if (kuaishouColdStore == null) return null;
+        Duration ttl = props.getKuaishouTagEventSyncInterval().multipliedBy(3);
+        return new KuaishouTagEventSyncService(kuaishouColdStore, redisTemplate,
+                props.getKuaishouTagEventSyncRedisPrefix(), ttl);
+    }
+
     // ─── ColdWorker ─────────────────────────────────────────────────────────
 
     @Bean

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

@@ -54,6 +54,16 @@ public class AppProperties {
     private Duration honorTagEventSyncInterval = Duration.ofMinutes(5);
     private String honorTagEventSyncRedisPrefix = "";
 
+    // Kuaishou Callback
+    private String kuaishouConversionBaseUrl = "http://ad.partner.gifshow.com/track/activate";
+    private String kuaishouRedisStream = "adx:kuaishou:events";
+    private boolean kuaishouCallbackRetryEnabled = false;
+    private Duration kuaishouCallbackRetryInterval = Duration.ofSeconds(300);
+    private int kuaishouCallbackRetryLimit = 100;
+    private boolean kuaishouTagEventSyncEnabled = false;
+    private Duration kuaishouTagEventSyncInterval = Duration.ofMinutes(5);
+    private String kuaishouTagEventSyncRedisPrefix = "";
+
     // Tencent API
     private String tencentApiBaseUrl = "https://api.e.qq.com/v3.0";
     private String tencentAccessToken;
@@ -376,6 +386,70 @@ public class AppProperties {
         this.honorTagEventSyncRedisPrefix = v;
     }
 
+    public String getKuaishouConversionBaseUrl() {
+        return kuaishouConversionBaseUrl;
+    }
+
+    public void setKuaishouConversionBaseUrl(String v) {
+        this.kuaishouConversionBaseUrl = v;
+    }
+
+    public String getKuaishouRedisStream() {
+        return kuaishouRedisStream;
+    }
+
+    public void setKuaishouRedisStream(String v) {
+        this.kuaishouRedisStream = v;
+    }
+
+    public boolean isKuaishouCallbackRetryEnabled() {
+        return kuaishouCallbackRetryEnabled;
+    }
+
+    public void setKuaishouCallbackRetryEnabled(boolean v) {
+        this.kuaishouCallbackRetryEnabled = v;
+    }
+
+    public Duration getKuaishouCallbackRetryInterval() {
+        return kuaishouCallbackRetryInterval;
+    }
+
+    public void setKuaishouCallbackRetryInterval(Duration v) {
+        this.kuaishouCallbackRetryInterval = v;
+    }
+
+    public int getKuaishouCallbackRetryLimit() {
+        return kuaishouCallbackRetryLimit;
+    }
+
+    public void setKuaishouCallbackRetryLimit(int v) {
+        this.kuaishouCallbackRetryLimit = v;
+    }
+
+    public boolean isKuaishouTagEventSyncEnabled() {
+        return kuaishouTagEventSyncEnabled;
+    }
+
+    public void setKuaishouTagEventSyncEnabled(boolean v) {
+        this.kuaishouTagEventSyncEnabled = v;
+    }
+
+    public Duration getKuaishouTagEventSyncInterval() {
+        return kuaishouTagEventSyncInterval;
+    }
+
+    public void setKuaishouTagEventSyncInterval(Duration v) {
+        this.kuaishouTagEventSyncInterval = v;
+    }
+
+    public String getKuaishouTagEventSyncRedisPrefix() {
+        return kuaishouTagEventSyncRedisPrefix;
+    }
+
+    public void setKuaishouTagEventSyncRedisPrefix(String v) {
+        this.kuaishouTagEventSyncRedisPrefix = v;
+    }
+
     public String getTencentApiBaseUrl() {
         return tencentApiBaseUrl;
     }

+ 326 - 10
src/main/java/com/adx/tencent/conversionsync/ConversionSyncService.java

@@ -11,6 +11,13 @@ import com.adx.tencent.honor.model.HonorMediaCallbackRecord;
 import com.adx.tencent.honor.service.HonorTagEventResolver;
 import com.adx.tencent.honor.store.HonorColdStore;
 import com.adx.tencent.honor.store.HonorHotStore;
+import com.adx.tencent.kuaishou.client.KuaishouClient;
+import com.adx.tencent.kuaishou.model.KuaishouBidRecord;
+import com.adx.tencent.kuaishou.model.KuaishouMediaCallbackRecord;
+import com.adx.tencent.kuaishou.model.KuaishouTagEventRecord;
+import com.adx.tencent.kuaishou.service.KuaishouTagEventResolver;
+import com.adx.tencent.kuaishou.store.KuaishouColdStore;
+import com.adx.tencent.kuaishou.store.KuaishouHotStore;
 import com.adx.tencent.storage.RedisHotStore;
 import com.adx.tencent.storage.TiDBColdStore;
 import com.adx.tencent.storage.model.BidRecord;
@@ -37,7 +44,7 @@ import java.util.concurrent.atomic.AtomicInteger;
 /**
  * 统一转化同步:
  * 1. 只从百度拉一次转化
- * 2. 按 qk 匹配腾讯/荣耀 bid
+ * 2. 按 qk 匹配腾讯/荣耀/快手 bid
  * 3. 百度转化明细统一落 tencent_baidu_conversions
  * 4. 媒体回传结果分别落各自表
  */
@@ -50,12 +57,16 @@ public class ConversionSyncService {
     private final ConversionClient baiduClient;
     private final RedisHotStore hotStore;
     private final HonorHotStore honorHotStore;
+    private final KuaishouHotStore kuaishouHotStore;
     private final TencentClient tencentClient;
     private final TiDBColdStore coldStore;
     private final HonorColdStore honorColdStore;
+    private final KuaishouColdStore kuaishouColdStore;
     private final HonorClient honorClient;
+    private final KuaishouClient kuaishouClient;
     private final TagEventResolver tagEventResolver;
     private final HonorTagEventResolver honorTagEventResolver;
+    private final KuaishouTagEventResolver kuaishouTagEventResolver;
     private final ExecutorService realtimeExecutor;
     private final ExecutorService backfillExecutor;
 
@@ -70,9 +81,13 @@ public class ConversionSyncService {
                                  TiDBColdStore coldStore,
                                  TagEventResolver tagEventResolver,
                                  HonorTagEventResolver honorTagEventResolver,
+                                 KuaishouTagEventResolver kuaishouTagEventResolver,
                                  HonorHotStore honorHotStore,
                                  HonorColdStore honorColdStore,
+                                 KuaishouHotStore kuaishouHotStore,
+                                 KuaishouColdStore kuaishouColdStore,
                                  HonorClient honorClient,
+                                 KuaishouClient kuaishouClient,
                                  AppProperties props) {
         this.baiduClient = baiduClient;
         this.hotStore = hotStore;
@@ -80,9 +95,13 @@ public class ConversionSyncService {
         this.coldStore = coldStore;
         this.tagEventResolver = tagEventResolver;
         this.honorTagEventResolver = honorTagEventResolver;
+        this.kuaishouTagEventResolver = kuaishouTagEventResolver;
         this.honorHotStore = honorHotStore;
         this.honorColdStore = honorColdStore;
+        this.kuaishouHotStore = kuaishouHotStore;
+        this.kuaishouColdStore = kuaishouColdStore;
         this.honorClient = honorClient;
+        this.kuaishouClient = kuaishouClient;
         this.realtimeExecutor = Executors.newFixedThreadPool(
                 REALTIME_CONCURRENCY,
                 r -> {
@@ -149,7 +168,7 @@ public class ConversionSyncService {
 
         ExecutorService executor = mode == SyncMode.BACKFILL ? backfillExecutor : realtimeExecutor;
 
-        // 1. 批量查这一页转化对应的腾讯/荣耀 bid;Redis miss 时会自动回落到冷库。
+        // 1. 批量查这一页转化对应的腾讯/荣耀/快手 bid;Redis miss 时会自动回落到冷库。
         BidLookup lookup = loadBidLookup(payments, result);
 
         // 2. 统一整理要落库的百度转化明细,以及要回传给媒体的任务。
@@ -191,7 +210,10 @@ public class ConversionSyncService {
         Map<String, HonorBidRecord> honorBids = honorHotStore != null
                 ? honorHotStore.findBidsByQks(qkList)
                 : Map.of();
-        return new BidLookup(tencentBids, honorBids);
+        Map<String, KuaishouBidRecord> kuaishouBids = kuaishouHotStore != null
+                ? kuaishouHotStore.findBidsByQks(qkList)
+                : Map.of();
+        return new BidLookup(tencentBids, honorBids, kuaishouBids);
     }
 
     private void backfillTencentAccountIds(List<String> qkList, Map<String, BidRecord> tencentBids) {
@@ -245,8 +267,9 @@ public class ConversionSyncService {
         for (PaymentInfo payment : payments) {
             BidRecord tencentBid = lookup.tencentBids.get(payment.getQk());
             HonorBidRecord honorBid = lookup.honorBids.get(payment.getQk());
+            KuaishouBidRecord kuaishouBid = lookup.kuaishouBids.get(payment.getQk());
 
-            if (tencentBid == null && honorBid == null) {
+            if (tencentBid == null && honorBid == null && kuaishouBid == null) {
                 conversionRecords.add(toConversionRecord(payment, null, ""));
                 result.skipped.incrementAndGet();
                 continue;
@@ -254,8 +277,10 @@ public class ConversionSyncService {
 
             boolean hasTencentBid = tencentBid != null;
             boolean hasHonorBid = honorBid != null;
+            boolean hasKuaishouBid = kuaishouBid != null;
             boolean validTencent = hasTencentBid && "tencent".equals(tencentBid.getMedia());
             boolean validHonor = hasHonorBid && "honor".equals(honorBid.getMedia());
+            boolean validKuaishou = hasKuaishouBid && "kuaishou".equals(kuaishouBid.getMedia());
 
             if (hasTencentBid && !validTencent) {
                 log.warn("[ConversionSync] 腾讯 bid media 非法 | qk={} | media={}", payment.getQk(), tencentBid.getMedia());
@@ -265,8 +290,13 @@ public class ConversionSyncService {
                 log.warn("[ConversionSync] 荣耀 bid media 非法 | qk={} | media={}", payment.getQk(), honorBid.getMedia());
             }
 
-            if (validTencent && validHonor) {
-                log.warn("[ConversionSync] qk 同时命中腾讯和荣耀,跳过分发 | qk={} | act={} | date={}",
+            if (hasKuaishouBid && !validKuaishou) {
+                log.warn("[ConversionSync] 快手 bid media 非法 | qk={} | media={}", payment.getQk(), kuaishouBid.getMedia());
+            }
+
+            int matchedMediaCount = (validTencent ? 1 : 0) + (validHonor ? 1 : 0) + (validKuaishou ? 1 : 0);
+            if (matchedMediaCount > 1) {
+                log.warn("[ConversionSync] qk 同时命中多媒体,跳过分发 | qk={} | act={} | date={}",
                         payment.getQk(), payment.getAct(), payment.getDate());
                 result.skipped.incrementAndGet();
                 continue;
@@ -286,6 +316,19 @@ public class ConversionSyncService {
             }
 
             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());
+
+                    CallbackTask kuaishouTask = buildKuaishouCallbackTask(payment, kuaishouBid);
+                    if (kuaishouTask != null) {
+                        callbackTasks.add(kuaishouTask);
+                    }
+                    continue;
+                }
+
                 conversionRecords.add(toConversionRecord(payment, hasTencentBid ? tencentBid : null, hasTencentBid ? tencentBid.getMedia() : ""));
                 result.skipped.incrementAndGet();
                 continue;
@@ -351,6 +394,26 @@ public class ConversionSyncService {
         );
     }
 
+    private CallbackTask buildKuaishouCallbackTask(PaymentInfo payment, KuaishouBidRecord bid) {
+        if (kuaishouColdStore == null && kuaishouTagEventResolver == null) {
+            return null;
+        }
+
+        KuaishouTagEventRecord tagEvent = resolveKuaishouTagEvent(bid.getTagId(), payment.getAct());
+        if (tagEvent == null) {
+            log.warn("[ConversionSync] 快手 eventType 未配置 | qk={} | tagId={} | act={}",
+                    payment.getQk(), bid.getTagId(), payment.getAct());
+            return null;
+        }
+
+        return CallbackTask.kuaishou(
+                payment,
+                bid,
+                kuaishouCallbackDedupeKey(payment, bid, tagEvent.getEventType()),
+                tagEvent
+        );
+    }
+
     private Integer resolveHonorConversionIdFromDb(String tagId, int baiduAct) {
         if (honorColdStore == null) {
             return null;
@@ -363,6 +426,23 @@ public class ConversionSyncService {
         return fallback != null ? fallback.getConversionId() : null;
     }
 
+    private KuaishouTagEventRecord resolveKuaishouTagEvent(String tagId, int baiduAct) {
+        if (tagId == null || tagId.isBlank() || kuaishouColdStore == null) {
+            return null;
+        }
+        try {
+            KuaishouTagEventRecord record = kuaishouColdStore.getTagEvent(tagId, baiduAct);
+            if (record == null) {
+                record = kuaishouColdStore.getTagEvent(tagId, 0);
+            }
+            return record;
+        } catch (Exception e) {
+            log.warn("[ConversionSync] 查询快手 tag event 失败 | tagId={} | act={} | error={}",
+                    tagId, baiduAct, e.getMessage(), e);
+            return null;
+        }
+    }
+
     // ─── Step 3: 统一落百度转化明细 ───────────────────────────────────────────
 
     private void saveConversions(List<ConversionRecord> records) {
@@ -382,6 +462,7 @@ public class ConversionSyncService {
         Set<String> existing = new HashSet<>();
         existing.addAll(collectTencentSuccessfulCallbackKeys(callbackTasks));
         existing.addAll(collectHonorSuccessfulCallbackKeys(callbackTasks));
+        existing.addAll(collectKuaishouSuccessfulCallbackKeys(callbackTasks));
 
         List<CallbackTask> pending = new ArrayList<>(callbackTasks.size());
         for (CallbackTask task : callbackTasks) {
@@ -447,6 +528,30 @@ public class ConversionSyncService {
         }
     }
 
+    private Set<String> collectKuaishouSuccessfulCallbackKeys(List<CallbackTask> callbackTasks) {
+        if (kuaishouColdStore == null) {
+            return Set.of();
+        }
+
+        Set<String> keys = new HashSet<>();
+        for (CallbackTask task : callbackTasks) {
+            if (task.isKuaishou()) {
+                keys.add(task.callbackKey);
+            }
+        }
+
+        if (keys.isEmpty()) {
+            return Set.of();
+        }
+
+        try {
+            return kuaishouColdStore.successfulCallbackKeys(keys);
+        } catch (Exception e) {
+            log.error("[ConversionSync] kuaishou dedup check error: size={}, {}", keys.size(), e.getMessage(), e);
+            return Set.of();
+        }
+    }
+
     // ─── Step 5: 并发回传媒体 ───────────────────────────────────────────────
 
     private List<CallbackDispatchResult> dispatchCallbacks(List<CallbackTask> tasks,
@@ -496,6 +601,17 @@ public class ConversionSyncService {
             return CallbackDispatchResult.honor(task, toCallbackRecord(task, honorRecord));
         }
 
+        if (task.isKuaishou()) {
+            int deductionRate = resolveKuaishouDeductionRate(task.kuaishouBid);
+            if (handleKuaishouDeduction(task)) {
+                return CallbackDispatchResult.kuaishou(task, toCallbackRecord(task, buildDeductedKuaishouCallbackRecord(task, deductionRate)));
+            }
+            KuaishouMediaCallbackRecord kuaishouRecord = kuaishouClient.sendConversion(
+                    task.kuaishouBid, task.payment, task.kuaishouTagEvent, true);
+            kuaishouRecord.setDeductionRate(deductionRate);
+            return CallbackDispatchResult.kuaishou(task, toCallbackRecord(task, kuaishouRecord));
+        }
+
         return null;
     }
 
@@ -508,9 +624,12 @@ public class ConversionSyncService {
 
         List<MediaCallbackRecord> tencentRecords = new ArrayList<>();
         List<HonorMediaCallbackRecord> honorRecords = new ArrayList<>();
+        List<KuaishouMediaCallbackRecord> kuaishouRecords = 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 {
                 tencentRecords.add(result.record);
             }
@@ -531,6 +650,14 @@ public class ConversionSyncService {
                 log.error("[ConversionSync] batch save honor callbacks failed: size={}, {}", honorRecords.size(), e.getMessage(), e);
             }
         }
+
+        if (kuaishouColdStore != null && !kuaishouRecords.isEmpty()) {
+            try {
+                kuaishouColdStore.saveMediaCallbacks(kuaishouRecords);
+            } catch (Exception e) {
+                log.error("[ConversionSync] batch save kuaishou callbacks failed: size={}, {}", kuaishouRecords.size(), e.getMessage(), e);
+            }
+        }
     }
 
     // ─── 工具方法 ────────────────────────────────────────────────────────────
@@ -593,6 +720,26 @@ public class ConversionSyncService {
         return record;
     }
 
+    private static ConversionRecord toKuaishouConversionRecord(PaymentInfo payment, KuaishouBidRecord bid) {
+        ConversionRecord record = new ConversionRecord();
+        record.setDedupeKey(conversionDedupeKey(payment));
+        record.setQk(payment.getQk());
+        record.setMedia("kuaishou");
+        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(),
@@ -632,6 +779,16 @@ public class ConversionSyncService {
                 conversionId);
     }
 
+    static String kuaishouCallbackDedupeKey(PaymentInfo payment, KuaishouBidRecord bid, int eventType) {
+        return String.format("kuaishou:%s:%s:%s:%s:%d:%d",
+                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;
@@ -704,6 +861,60 @@ public class ConversionSyncService {
         return record;
     }
 
+    private boolean handleKuaishouDeduction(CallbackTask task) {
+        if (!task.isKuaishou() || task.kuaishouBid == null || kuaishouHotStore == null) {
+            return false;
+        }
+        int deductionRate = resolveKuaishouDeductionRate(task.kuaishouBid);
+        if (task.payment.getAct() == 2001 || deductionRate <= 0) {
+            return false;
+        }
+        if (!kuaishouHotStore.markConversionSeen(task.callbackKey)) {
+            log.info("[ConversionSync] 快手转化已见过,跳过重复扣量判断 | callbackKey={} | qk={} | accountId={}",
+                    task.callbackKey, task.payment.getQk(), safe(task.kuaishouBid.getAccountId()));
+            return false;
+        }
+        KuaishouHotStore.DeductionDecision decision = kuaishouHotStore.evaluateDeduction(
+                task.kuaishouBid.getAccountId(),
+                task.payment.getAct(),
+                task.payment.getDate(),
+                deductionRate
+        );
+        boolean deduct = decision.isShouldDeduct();
+        log.info("[ConversionSync] 快手转化扣量判定 | qk={} | act={} | accountId={} | tagId={} | rate={} | totalSeen={} | totalDeducted={} | callbackKey={} | deduct={}",
+                task.payment.getQk(), task.payment.getAct(), safe(task.kuaishouBid.getAccountId()),
+                safe(task.kuaishouBid.getTagId()), deductionRate,
+                decision.getTotalSeen(), decision.getTotalDeducted(), task.callbackKey, deduct);
+        return deduct;
+    }
+
+    private KuaishouMediaCallbackRecord buildDeductedKuaishouCallbackRecord(CallbackTask task, int deductionRate) {
+        KuaishouMediaCallbackRecord record = new KuaishouMediaCallbackRecord();
+        Instant now = Instant.now();
+        record.setDedupeKey(task.callbackKey);
+        record.setQk(task.payment.getQk());
+        record.setTagId(task.kuaishouBid.getTagId());
+        String callbackUrl = task.kuaishouBid.getMediaParams() != null
+                ? task.kuaishouBid.getMediaParams().get(KuaishouBidRecord.CALLBACK_KEY)
+                : null;
+        record.setCallbackUrl(callbackUrl != null ? callbackUrl : "");
+        record.setEventType(task.kuaishouTagEvent.getEventType());
+        record.setEventTimeMs(now.toEpochMilli());
+        record.setPurchase(Math.max(task.payment.getPayment(), 0D));
+        record.setDeductionRate(deductionRate);
+        record.setDirectMatch(false);
+        record.setDispatchStatus(KuaishouMediaCallbackRecord.DISPATCH_STATUS_DEDUCTED);
+        record.setStatus(0);
+        record.setOk(false);
+        record.setAttempt(1);
+        record.setErrorMessage("deducted_by_rate");
+        record.setCreatedAt(now);
+        log.info("[ConversionSync] 快手转化扣量 | qk={} | act={} | rate={} | tagId={} | accountId={} | callbackKey={}",
+                task.payment.getQk(), task.payment.getAct(), deductionRate,
+                task.kuaishouBid.getTagId(), safe(task.kuaishouBid.getAccountId()), task.callbackKey);
+        return record;
+    }
+
     private static String resolveTrackingVersion(BidRecord bid) {
         if (bid == null || bid.getMediaParams() == null) {
             return "v1";
@@ -726,6 +937,20 @@ public class ConversionSyncService {
         return 0;
     }
 
+    private int resolveKuaishouDeductionRate(KuaishouBidRecord bid) {
+        if (bid == null || bid.getMediaParams() == null) {
+            return 0;
+        }
+        String rawRate = bid.getMediaParams().get(KuaishouBidRecord.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;
     }
@@ -756,6 +981,28 @@ public class ConversionSyncService {
         return callback;
     }
 
+    private static KuaishouMediaCallbackRecord toKuaishouCallbackRecord(CallbackTask task, MediaCallbackRecord record) {
+        KuaishouMediaCallbackRecord callback = new KuaishouMediaCallbackRecord();
+        callback.setDedupeKey(record.getDedupeKey());
+        callback.setQk(record.getQk());
+        callback.setTagId(task.tagId);
+        callback.setCallbackUrl(record.getCallbackUrl());
+        callback.setEventType(record.getEventType());
+        callback.setEventTimeMs(record.getEventTimeMs());
+        callback.setPurchase(record.getPurchase());
+        callback.setDeductionRate(parseKuaishouDeductionRate(task.kuaishouBid));
+        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(!KuaishouMediaCallbackRecord.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);
@@ -776,15 +1023,59 @@ public class ConversionSyncService {
         return callbackRecord;
     }
 
+    private static MediaCallbackRecord toCallbackRecord(CallbackTask task, KuaishouMediaCallbackRecord kuaishouRecord) {
+        MediaCallbackRecord callbackRecord = new MediaCallbackRecord();
+        callbackRecord.setDedupeKey(task.callbackKey);
+        callbackRecord.setMedia("kuaishou");
+        callbackRecord.setQk(kuaishouRecord.getQk());
+        callbackRecord.setCallbackUrl(kuaishouRecord.getCallbackUrl());
+        callbackRecord.setEventType(kuaishouRecord.getEventType());
+        callbackRecord.setEventTimeMs(kuaishouRecord.getEventTimeMs());
+        callbackRecord.setPurchase(kuaishouRecord.getPurchase());
+        callbackRecord.setStatus(kuaishouRecord.getStatus());
+        callbackRecord.setOk(kuaishouRecord.isOk());
+        callbackRecord.setAttempt(kuaishouRecord.getAttempt());
+        callbackRecord.setResponseBody(kuaishouRecord.getResponseBody());
+        callbackRecord.setErrorMessage(kuaishouRecord.getErrorMessage());
+        callbackRecord.setRequestBody(kuaishouRecord.getRequestBody());
+        callbackRecord.setDispatchStatus(kuaishouRecord.getDispatchStatus());
+        callbackRecord.setTrackingVersion(kuaishouRecord.isDirectMatch() ? "direct" : "deducted");
+        callbackRecord.setCreatedAt(kuaishouRecord.getCreatedAt());
+        return callbackRecord;
+    }
+
+    private static int parseKuaishouDeductionRate(KuaishouBidRecord bid) {
+        if (bid == null || bid.getMediaParams() == null) {
+            return 0;
+        }
+        String rawRate = bid.getMediaParams().get(KuaishouBidRecord.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 BidLookup(Map<String, BidRecord> tencentBids, Map<String, HonorBidRecord> honorBids) {
+        private BidLookup(Map<String, BidRecord> tencentBids,
+                          Map<String, HonorBidRecord> honorBids,
+                          Map<String, KuaishouBidRecord> kuaishouBids) {
             this.tencentBids = tencentBids;
             this.honorBids = honorBids;
+            this.kuaishouBids = kuaishouBids;
         }
     }
 
@@ -797,6 +1088,8 @@ public class ConversionSyncService {
         private final String tencentActionType;
         private final HonorBidRecord honorBid;
         private final Integer honorConversionId;
+        private final KuaishouBidRecord kuaishouBid;
+        private final KuaishouTagEventRecord kuaishouTagEvent;
 
         private CallbackTask(String media,
                              PaymentInfo payment,
@@ -805,7 +1098,9 @@ public class ConversionSyncService {
                              BidRecord tencentBid,
                              String tencentActionType,
                              HonorBidRecord honorBid,
-                             Integer honorConversionId) {
+                             Integer honorConversionId,
+                             KuaishouBidRecord kuaishouBid,
+                             KuaishouTagEventRecord kuaishouTagEvent) {
             this.media = media;
             this.payment = payment;
             this.tagId = tagId;
@@ -814,20 +1109,29 @@ public class ConversionSyncService {
             this.tencentActionType = tencentActionType;
             this.honorBid = honorBid;
             this.honorConversionId = honorConversionId;
+            this.kuaishouBid = kuaishouBid;
+            this.kuaishouTagEvent = kuaishouTagEvent;
         }
 
         private static CallbackTask tencent(PaymentInfo payment,
                                             BidRecord bid,
                                             String callbackKey,
                                             String actionType) {
-            return new CallbackTask("tencent", payment, bid.getTagId(), callbackKey, bid, actionType, null, null);
+            return new CallbackTask("tencent", payment, bid.getTagId(), callbackKey, bid, actionType, 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);
+            return new CallbackTask("honor", payment, bid.getTagId(), callbackKey, null, null, bid, conversionId, 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);
         }
 
         private boolean isTencent() {
@@ -837,6 +1141,10 @@ public class ConversionSyncService {
         private boolean isHonor() {
             return "honor".equals(media);
         }
+
+        private boolean isKuaishou() {
+            return "kuaishou".equals(media);
+        }
     }
 
     private static class CallbackDispatchResult {
@@ -858,9 +1166,17 @@ public class ConversionSyncService {
             return new CallbackDispatchResult("honor", task, record);
         }
 
+        private static CallbackDispatchResult kuaishou(CallbackTask task, MediaCallbackRecord record) {
+            return new CallbackDispatchResult("kuaishou", task, record);
+        }
+
         private boolean isHonor() {
             return "honor".equals(media);
         }
+
+        private boolean isKuaishou() {
+            return "kuaishou".equals(media);
+        }
     }
 
     private enum DeductionHandling {

+ 244 - 0
src/main/java/com/adx/tencent/kuaishou/client/KuaishouClient.java

@@ -0,0 +1,244 @@
+package com.adx.tencent.kuaishou.client;
+
+import com.adx.tencent.baidu.model.PaymentInfo;
+import com.adx.tencent.kuaishou.model.KuaishouBidRecord;
+import com.adx.tencent.kuaishou.model.KuaishouMediaCallbackRecord;
+import com.adx.tencent.kuaishou.model.KuaishouTagEventRecord;
+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.Locale;
+import java.util.Map;
+
+public class KuaishouClient {
+
+    private static final Logger log = LoggerFactory.getLogger(KuaishouClient.class);
+    private static final String DEFAULT_ENDPOINT = "http://ad.partner.gifshow.com/track/activate";
+
+    private final String callbackEndpoint;
+    private final ObjectMapper objectMapper;
+    private final HttpClient httpClient;
+
+    public KuaishouClient(String callbackEndpoint, ObjectMapper objectMapper) {
+        this.callbackEndpoint = (callbackEndpoint == null || callbackEndpoint.isBlank())
+                ? DEFAULT_ENDPOINT
+                : callbackEndpoint;
+        this.objectMapper = objectMapper;
+        this.httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
+    }
+
+    public KuaishouMediaCallbackRecord sendConversion(KuaishouBidRecord bid,
+                                                      PaymentInfo payment,
+                                                      KuaishouTagEventRecord tagEvent,
+                                                      boolean directMatch) {
+        KuaishouMediaCallbackRecord record = new KuaishouMediaCallbackRecord();
+        record.setQk(payment.getQk());
+        record.setTagId(bid.getTagId());
+        record.setEventType(tagEvent.getEventType());
+        record.setPurchase(resolvePurchaseAmount(payment));
+        record.setDirectMatch(directMatch);
+        record.setDispatchStatus(directMatch
+                ? KuaishouMediaCallbackRecord.DISPATCH_STATUS_SENT
+                : KuaishouMediaCallbackRecord.DISPATCH_STATUS_DEDUCTED);
+        record.setAttempt(1);
+        record.setCreatedAt(Instant.now());
+
+        Map<String, String> params = buildParams(bid, payment, tagEvent, directMatch);
+        String callbackUrl = callbackEndpoint + "?" + toQuery(params);
+        record.setCallbackUrl(callbackUrl);
+        record.setRequestBody(toJson(params));
+        record.setEventTimeMs(parseLong(params.get("event_time")));
+
+        log.info("[快手] callback请求: qk={}, tagId={}, eventType={}, directMatch={}, url={}, params={}",
+                payment.getQk(), bid.getTagId(), tagEvent.getEventType(), directMatch, callbackUrl, params);
+
+        if (!params.containsKey("callback")) {
+            record.setStatus(400);
+            record.setOk(false);
+            record.setErrorMessage("callback missing in kuaishou bid mediaParams");
+            log.warn("[快手] callback跳过: qk={}, tagId={}, reason=missing callback, mediaParams={}",
+                    payment.getQk(), bid.getTagId(), bid.getMediaParams());
+            return record;
+        }
+
+        try {
+            HttpRequest request = HttpRequest.newBuilder()
+                    .uri(URI.create(callbackUrl))
+                    .GET()
+                    .timeout(Duration.ofSeconds(15))
+                    .build();
+            HttpResponse<byte[]> response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray());
+            String body = new String(response.body(), StandardCharsets.UTF_8);
+            record.setStatus(response.statusCode());
+            record.setResponseBody(body);
+            applyResponse(record, response.statusCode(), body);
+            log.info("[快手] callback结果: qk={}, tagId={}, eventType={}, directMatch={}, status={}, ok={}, response={}",
+                    payment.getQk(), bid.getTagId(), tagEvent.getEventType(), directMatch,
+                    record.getStatus(), record.isOk(), body);
+        } catch (Exception e) {
+            record.setOk(false);
+            record.setErrorMessage(e.getMessage());
+            log.warn("[快手] callback失败: qk={}, tagId={}, eventType={}, directMatch={}, error={}",
+                    payment.getQk(), bid.getTagId(), tagEvent.getEventType(), directMatch, e.getMessage());
+        }
+        return record;
+    }
+
+    public KuaishouMediaCallbackRecord retryCallback(KuaishouMediaCallbackRecord original) throws Exception {
+        KuaishouMediaCallbackRecord retry = copy(original);
+        retry.setAttempt(original.getAttempt() + 1);
+        retry.setCreatedAt(Instant.now());
+        log.info("[快手] callback重试请求: qk={}, tagId={}, eventType={}, attempt={}, url={}, requestBody={}",
+                original.getQk(), original.getTagId(), original.getEventType(),
+                retry.getAttempt(), original.getCallbackUrl(), original.getRequestBody());
+
+        HttpRequest request = HttpRequest.newBuilder()
+                .uri(URI.create(original.getCallbackUrl()))
+                .GET()
+                .timeout(Duration.ofSeconds(15))
+                .build();
+        HttpResponse<byte[]> response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray());
+        retry.setStatus(response.statusCode());
+        retry.setResponseBody(new String(response.body(), StandardCharsets.UTF_8));
+        applyResponse(retry, response.statusCode(), retry.getResponseBody());
+
+        log.info("[快手] 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 Map<String, String> buildParams(KuaishouBidRecord bid,
+                                            PaymentInfo payment,
+                                            KuaishouTagEventRecord tagEvent,
+                                            boolean directMatch) {
+        Map<String, String> mediaParams = bid.getMediaParams();
+        Map<String, String> params = new LinkedHashMap<>();
+        put(params, "callback", value(mediaParams, "callback"));
+        put(params, "event_type", String.valueOf(tagEvent.getEventType()));
+        params.put("event_time", String.valueOf(Math.max(Instant.now().toEpochMilli(), parseLong(value(mediaParams, "ts")))));
+        if (!directMatch) {
+            params.put("is_direct_match", "false");
+        }
+
+        double purchaseAmount = resolvePurchaseAmount(payment);
+        if (purchaseAmount > 0D) {
+            params.put("purchase_amount", String.format(Locale.ROOT, "%.2f", purchaseAmount));
+        }
+        appendEventTypeParams(params, tagEvent.getEventType());
+        return params;
+    }
+
+    private void appendEventTypeParams(Map<String, String> params, int eventType) {
+        switch (eventType) {
+            case 120, 121 -> log.warn("[快手] event_type={} 按文档通常需要 action_reason,当前代码未提供该业务字段", eventType);
+            case 143 -> log.warn("[快手] event_type={} 按文档通常需要 key_action_category/key_action_threshold,当前代码未提供该业务字段", eventType);
+            case 150 -> log.warn("[快手] event_type={} 按文档通常需要 novel_chapter_num,当前代码未提供该业务字段", eventType);
+            case 151 -> log.warn("[快手] event_type={} 按文档通常需要 pay_level,当前代码未提供该业务字段", eventType);
+            default -> {
+                // 其余常规事件当前只需要通用参数,不额外拼接 event_props。
+            }
+        }
+    }
+
+    private double resolvePurchaseAmount(PaymentInfo payment) {
+        if (payment == null) return 0D;
+        if (payment.getGmv() > 0D) return payment.getGmv();
+        return Math.max(payment.getPayment(), 0D);
+    }
+
+    private static KuaishouMediaCallbackRecord copy(KuaishouMediaCallbackRecord src) {
+        KuaishouMediaCallbackRecord copy = new KuaishouMediaCallbackRecord();
+        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 void applyResponse(KuaishouMediaCallbackRecord 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 result = root.path("result").asInt(Integer.MIN_VALUE);
+            if (result == 1) {
+                record.setOk(true);
+                record.setErrorMessage(null);
+                return;
+            }
+            record.setOk(false);
+            record.setErrorMessage("result=" + result + " body=" + body);
+        } catch (Exception e) {
+            record.setOk(false);
+            record.setErrorMessage("parse kuaishou response failed: " + e.getMessage());
+        }
+    }
+
+    private String toJson(Object value) {
+        try {
+            return objectMapper.writeValueAsString(value);
+        } catch (Exception e) {
+            return String.valueOf(value);
+        }
+    }
+
+    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 urlEncode(String value) {
+        return URLEncoder.encode(value, StandardCharsets.UTF_8);
+    }
+
+    private static void put(Map<String, String> params, String key, String value) {
+        if (value != null && !value.isBlank()) params.put(key, value);
+    }
+
+    private static String value(Map<String, String> params, String key) {
+        return params != null ? params.get(key) : null;
+    }
+
+    private static long parseLong(String value) {
+        try {
+            return value == null ? 0L : Long.parseLong(value);
+        } catch (Exception e) {
+            return 0L;
+        }
+    }
+}

+ 538 - 0
src/main/java/com/adx/tencent/kuaishou/controller/KuaishouTrackingController.java

@@ -0,0 +1,538 @@
+package com.adx.tencent.kuaishou.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.kuaishou.model.KuaishouBidRecord;
+import com.adx.tencent.kuaishou.model.KuaishouTrackingRecord;
+import com.adx.tencent.kuaishou.service.KuaishouPlacement;
+import com.adx.tencent.kuaishou.store.KuaishouHotStore;
+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 KuaishouTrackingController {
+
+    private static final Logger log = LoggerFactory.getLogger(KuaishouTrackingController.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 KuaishouHotStore hotStore;
+    private final KuaishouPlacement placement;
+    private final ObjectMapper objectMapper;
+
+    public KuaishouTrackingController(AdxClient adxClient,
+                                      @Nullable KuaishouHotStore hotStore,
+                                      @Nullable KuaishouPlacement placement,
+                                      ObjectMapper objectMapper) {
+        this.adxClient = adxClient;
+        this.hotStore = hotStore;
+        this.placement = placement;
+        this.objectMapper = objectMapper;
+    }
+
+    @GetMapping("/kuaishou/impression")
+    public Object impression(HttpServletRequest request) throws Exception {
+        impressionSemaphore.acquire();
+        try {
+            return doImpression(request);
+        } finally {
+            impressionSemaphore.release();
+        }
+    }
+
+    @GetMapping("/kuaishou/click")
+    public Object click(HttpServletRequest request, HttpServletResponse response) throws Exception {
+        clickSemaphore.acquire();
+        try {
+            return doClick(request, response);
+        } finally {
+            clickSemaphore.release();
+        }
+    }
+
+    private Object doImpression(HttpServletRequest request) throws Exception {
+        ensureReady();
+        Map<String, String> params = normalizeParams(request);
+        String traceId = kuaishouTraceId(params);
+        if (traceId == null || traceId.isBlank()) {
+            log.warn("[快手][曝光] 缺少 traceId, params={}", params.keySet());
+            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "missing kuaishou trace id (callback)");
+        }
+        log.info("[快手][曝光][入参] traceId={} ip={} params={}", traceId, clientIp(request), params);
+
+        Map<String, Object> bidRequest = buildBidRequest(request, traceId, params);
+        NormalizedBidResponse response = adxClient.requestBid("kuaishou", traceId, bidRequest);
+        if (response == null) {
+            log.warn("[快手][曝光][百度结果] traceId={} empty=true", traceId);
+            throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "empty baidu ADX response");
+        }
+        log.info("[快手][曝光][竞价结果] 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);
+
+        KuaishouBidRecord 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 = kuaishouTraceId(params);
+        if (traceId == null || traceId.isBlank()) {
+            log.warn("[快手][点击] 缺少 traceId, params={}", params.keySet());
+            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "missing kuaishou trace id (callback)");
+        }
+        log.info("[快手][点击][入参] traceId={} ip={} params={}", traceId, clientIp(request), params);
+
+        KuaishouBidRecord record = hotStore.findBidByMediaTrace(traceId);
+        if (record == null) {
+            NormalizedBidResponse bidResponse = adxClient.requestBid("kuaishou", traceId, buildBidRequest(request, traceId, params));
+            if (bidResponse == null) {
+                log.warn("[快手][点击][百度结果] traceId={} empty=true", traceId);
+                throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "empty baidu ADX response");
+            }
+            log.info("[快手][点击][竞价结果] 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, "kuaishou 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");
+        alias(params, "accountid", "accountId");
+        alias(params, "ua", "user_agent", "useragent");
+        alias(params, "ip", "ipv4", "m6a");
+        alias(params, "oaidMD5", "oaid_md5", "oaidMd5", "oaidMd5");
+        alias(params, "imeiMD5", "imei_md5", "imeiMd5");
+        alias(params, "imeiSHA1", "imei_sha1", "imeiSha1");
+        alias(params, "androidIdMD5", "android_id_md5", "androidIdMd5");
+        alias(params, "androidIdSHA1", "android_id_sha1", "androidIdSha1");
+        alias(params, "idfaMD5", "idfa_md5", "idfaMd5");
+        alias(params, "idfaSHA1", "idfa_sha1", "idfaSha1");
+        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) {
+        KuaishouPlacement.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<>();
+        device.put("ip", firstParam(params, "ip") != null ? firstParam(params, "ip") : 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", traceId + "-" + Instant.now().toEpochMilli());
+        bidReq.put("imp", List.of(imp));
+        bidReq.put("device", device);
+        List<Integer> appList = parseAppList(firstParam(params, "appList", "app_list", "applist"));
+        if (!appList.isEmpty()) bidReq.put("appList", appList);
+        log.info("[快手][百度请求] traceId={} body={}", traceId, toJson(bidReq));
+        return bidReq;
+    }
+
+    private KuaishouBidRecord bidRecordFromResponse(NormalizedBidResponse response,
+                                                    String traceId,
+                                                    String eventType,
+                                                    Map<String, String> params) {
+        KuaishouBidRecord record = new KuaishouBidRecord();
+        record.setQk(response.getQk());
+        record.setReqId(response.getReqId());
+        record.setBidId(response.getBidId());
+        record.setMedia("kuaishou");
+        record.setMediaTraceId(traceId);
+        record.setPlatform(params.get("baidu_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 (response.getBid().getAdm() != null) {
+                record.setPackageName(response.getBid().getAdm().getPackageName());
+            }
+        }
+        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) {
+            KuaishouTrackingRecord record = new KuaishouTrackingRecord();
+            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 kuaishouTraceId(Map<String, String> params) {
+        return firstParam(params, "callback", "request_id", "idfaMD5", "idfa", "oaidMD5", "oaid", "imeiMD5", "imei");
+    }
+
+    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(KuaishouBidRecord.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 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;
+    }
+}

+ 14 - 0
src/main/java/com/adx/tencent/kuaishou/mapper/KuaishouBidEventMapper.java

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

+ 12 - 0
src/main/java/com/adx/tencent/kuaishou/mapper/KuaishouConversionMapper.java

@@ -0,0 +1,12 @@
+package com.adx.tencent.kuaishou.mapper;
+
+import com.adx.tencent.kuaishou.model.KuaishouConversionRecord;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+@Mapper
+public interface KuaishouConversionMapper {
+    void batchInsertOrUpdate(@Param("records") List<KuaishouConversionRecord> records);
+}

+ 14 - 0
src/main/java/com/adx/tencent/kuaishou/mapper/KuaishouMediaCallbackMapper.java

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

+ 14 - 0
src/main/java/com/adx/tencent/kuaishou/mapper/KuaishouTagEventMapper.java

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

+ 9 - 0
src/main/java/com/adx/tencent/kuaishou/mapper/KuaishouTrackingReportMapper.java

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

+ 143 - 0
src/main/java/com/adx/tencent/kuaishou/model/KuaishouBidRecord.java

@@ -0,0 +1,143 @@
+package com.adx.tencent.kuaishou.model;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+
+import java.time.Instant;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class KuaishouBidRecord {
+
+    public static final String DEDUCTION_RATE_KEY = "deduction_rate";
+    public static final String CALLBACK_KEY = "callback";
+
+    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 = 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 KuaishouBidRecord toPostClickRecord() {
+        KuaishouBidRecord slim = new KuaishouBidRecord();
+        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 KuaishouBidRecord toHotRecord() {
+        KuaishouBidRecord hot = new KuaishouBidRecord();
+        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 KuaishouBidRecord toColdRecord() {
+        KuaishouBidRecord cold = new KuaishouBidRecord();
+        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(
+                "tagId", "platform", "os", "ip", "ua",
+                "callback", "accountid", "accountId",
+                "imei", "imeiMD5", "imei_md5", "imeiSHA1", "imei_sha1",
+                "oaid", "oaidMD5", "oaid_md5",
+                "androidid", "androidId", "android_id",
+                "androidIdMD5", "android_id_md5", "androidIdSHA1", "android_id_sha1",
+                "idfa", "idfaMD5", "idfa_md5", "idfaSHA1", "idfa_sha1",
+                "caid", "kenyid_caa", "kenyIdCaa", "ts",
+                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;
+    }
+}

+ 53 - 0
src/main/java/com/adx/tencent/kuaishou/model/KuaishouConversionRecord.java

@@ -0,0 +1,53 @@
+package com.adx.tencent.kuaishou.model;
+
+import java.time.Instant;
+
+public class KuaishouConversionRecord {
+    private String dedupeKey;
+    private String qk;
+    private String media;
+    private String tagId;
+    private String date;
+    private String appSid;
+    private String customerName;
+    private String deviceId;
+    private double conv;
+    private double payment;
+    private double gmv;
+    private int act;
+    private String tu;
+    private String clkTime;
+    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 getMedia() { return media; }
+    public void setMedia(String v) { this.media = v; }
+    public String getTagId() { return tagId; }
+    public void setTagId(String v) { this.tagId = v; }
+    public String getDate() { return date; }
+    public void setDate(String v) { this.date = v; }
+    public String getAppSid() { return appSid; }
+    public void setAppSid(String v) { this.appSid = v; }
+    public String getCustomerName() { return customerName; }
+    public void setCustomerName(String v) { this.customerName = v; }
+    public String getDeviceId() { return deviceId; }
+    public void setDeviceId(String v) { this.deviceId = v; }
+    public double getConv() { return conv; }
+    public void setConv(double v) { this.conv = v; }
+    public double getPayment() { return payment; }
+    public void setPayment(double v) { this.payment = v; }
+    public double getGmv() { return gmv; }
+    public void setGmv(double v) { this.gmv = v; }
+    public int getAct() { return act; }
+    public void setAct(int v) { this.act = v; }
+    public String getTu() { return tu; }
+    public void setTu(String v) { this.tu = v; }
+    public String getClkTime() { return clkTime; }
+    public void setClkTime(String v) { this.clkTime = 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); }
+}

+ 62 - 0
src/main/java/com/adx/tencent/kuaishou/model/KuaishouMediaCallbackRecord.java

@@ -0,0 +1,62 @@
+package com.adx.tencent.kuaishou.model;
+
+import java.time.Instant;
+
+public class KuaishouMediaCallbackRecord {
+    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 int 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 int getEventType() { return eventType; }
+    public void setEventType(int 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/kuaishou/model/KuaishouTagEventRecord.java

@@ -0,0 +1,14 @@
+package com.adx.tencent.kuaishou.model;
+
+public class KuaishouTagEventRecord {
+    private String tagId;
+    private int baiduAct;
+    private int 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 int getEventType() { return eventType; }
+    public void setEventType(int v) { this.eventType = v; }
+}

+ 29 - 0
src/main/java/com/adx/tencent/kuaishou/model/KuaishouTrackingRecord.java

@@ -0,0 +1,29 @@
+package com.adx.tencent.kuaishou.model;
+
+import java.time.Instant;
+
+public class KuaishouTrackingRecord {
+    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); }
+}

+ 119 - 0
src/main/java/com/adx/tencent/kuaishou/service/KuaishouBackgroundTasks.java

@@ -0,0 +1,119 @@
+package com.adx.tencent.kuaishou.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 KuaishouBackgroundTasks implements ApplicationRunner {
+
+    private static final Logger log = LoggerFactory.getLogger(KuaishouBackgroundTasks.class);
+
+    @Autowired private AppProperties props;
+    @Autowired(required = false) private KuaishouColdWorker kuaishouColdWorker;
+    @Autowired(required = false) private KuaishouRetryService kuaishouRetryService;
+    @Autowired(required = false) private KuaishouTagEventSyncService kuaishouTagEventSyncService;
+    @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 (kuaishouColdWorker != null) {
+            executor.submit(() -> {
+                while (!stopped.get()) {
+                    try {
+                        kuaishouColdWorker.runOnce();
+                    } catch (Exception e) {
+                        log.error("kuaishou cold worker: {}", e.getMessage(), e);
+                        sleep(1000);
+                    }
+                }
+            });
+        }
+
+        if (kuaishouRetryService != null && props.isKuaishouCallbackRetryEnabled()) {
+            if (props.isSkipLeaderElection() || leaderElection == null) {
+                executor.submit(() -> runRetry(() -> stopped.get()));
+            } else {
+                executor.submit(() -> leaderElection.run(
+                        "adx:lock:kuaishou:callback-retry",
+                        props.getTaskLockTtl(), props.getTaskLockRenewInterval(), props.getTaskLockRetryInterval(),
+                        stopped::get,
+                        this::runRetry,
+                        e -> log.error("kuaishou callback retry leader: {}", e.getMessage(), e)
+                ));
+            }
+        }
+
+        if (kuaishouTagEventSyncService != null && props.isKuaishouTagEventSyncEnabled()) {
+            if (props.isSkipLeaderElection() || leaderElection == null) {
+                executor.submit(() -> runTagEventSync(() -> stopped.get()));
+            } else {
+                executor.submit(() -> leaderElection.run(
+                        "adx:lock:kuaishou:tag-event-sync",
+                        props.getTaskLockTtl(), props.getTaskLockRenewInterval(), props.getTaskLockRetryInterval(),
+                        stopped::get,
+                        this::runTagEventSync,
+                        e -> log.error("kuaishou tag event sync leader: {}", e.getMessage(), e)
+                ));
+            }
+        }
+    }
+
+    private void runRetry(LeaderElection.StopSignal stopSignal) {
+        long intervalMs = props.getKuaishouCallbackRetryInterval().toMillis();
+        while (!stopSignal.isStopped()) {
+            try {
+                kuaishouRetryService.retryCallbacks(props.getKuaishouCallbackRetryLimit());
+            } catch (Exception e) {
+                log.error("kuaishou callback retry: {}", e.getMessage(), e);
+            }
+            sleepResponsive(intervalMs, stopSignal);
+        }
+    }
+
+    private void runTagEventSync(LeaderElection.StopSignal stopSignal) {
+        long intervalMs = props.getKuaishouTagEventSyncInterval().toMillis();
+        while (!stopSignal.isStopped()) {
+            try {
+                kuaishouTagEventSyncService.syncOnce();
+            } catch (Exception e) {
+                log.error("kuaishou tag event 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/kuaishou/service/KuaishouColdWorker.java

@@ -0,0 +1,69 @@
+package com.adx.tencent.kuaishou.service;
+
+import com.adx.tencent.kuaishou.model.KuaishouBidRecord;
+import com.adx.tencent.kuaishou.model.KuaishouTrackingRecord;
+import com.adx.tencent.kuaishou.store.KuaishouColdStore;
+import com.adx.tencent.kuaishou.store.KuaishouHotStore;
+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 KuaishouColdWorker {
+
+    private final KuaishouHotStore hotStore;
+    private final KuaishouColdStore 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 KuaishouColdWorker(KuaishouHotStore hotStore, KuaishouColdStore 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(), KuaishouBidRecord.class));
+            case "tracking" -> coldStore.saveTracking(objectMapper.readValue(event.getPayload(), KuaishouTrackingRecord.class));
+            default -> throw new IllegalArgumentException("unknown kuaishou event type: " + event.getType());
+        }
+    }
+}

+ 137 - 0
src/main/java/com/adx/tencent/kuaishou/service/KuaishouPlacement.java

@@ -0,0 +1,137 @@
+package com.adx.tencent.kuaishou.service;
+
+import java.util.List;
+import java.util.Map;
+
+public class KuaishouPlacement {
+
+    private long baiduMediaId;
+    private String baiduAppId;
+    private String baiduTagId;
+    private Map<String, KuaishouAppPlacement> 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, KuaishouAppPlacement> getPlatforms() { return platforms; }
+    public void setPlatforms(Map<String, KuaishouAppPlacement> 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 kuaishou 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 kuaishou 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 kuaishou tagId or platform");
+    }
+
+    private Resolved resolveByTagId(String tagId) {
+        if (platforms != null) {
+            for (Map.Entry<String, KuaishouAppPlacement> e : platforms.entrySet()) {
+                KuaishouAppPlacement 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;
+        KuaishouAppPlacement 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 kuaishou 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 KuaishouAppPlacement {
+        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/kuaishou/service/KuaishouRetryService.java

@@ -0,0 +1,75 @@
+package com.adx.tencent.kuaishou.service;
+
+import com.adx.tencent.kuaishou.client.KuaishouClient;
+import com.adx.tencent.kuaishou.model.KuaishouMediaCallbackRecord;
+import com.adx.tencent.kuaishou.store.KuaishouColdStore;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+
+public class KuaishouRetryService {
+
+    private final KuaishouColdStore coldStore;
+    private final KuaishouClient kuaishouClient;
+    private final int defaultLimit;
+
+    public KuaishouRetryService(KuaishouColdStore coldStore, KuaishouClient kuaishouClient, int defaultLimit) {
+        this.coldStore = coldStore;
+        this.kuaishouClient = kuaishouClient;
+        this.defaultLimit = defaultLimit > 0 ? defaultLimit : 100;
+    }
+
+    public RetryResult retryCallbacks(int limit) {
+        List<KuaishouMediaCallbackRecord> pending = coldStore.pendingMediaCallbacks(limit <= 0 ? defaultLimit : limit);
+        RetryResult result = new RetryResult();
+        result.fetched = pending.size();
+        List<KuaishouMediaCallbackRecord> saved = new ArrayList<>();
+        for (KuaishouMediaCallbackRecord record : pending) {
+            try {
+                KuaishouMediaCallbackRecord retry = kuaishouClient.retryCallback(record);
+                saved.add(retry);
+                if (retry.isOk()) result.sent++;
+                else result.failed++;
+            } catch (Exception e) {
+                KuaishouMediaCallbackRecord 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 KuaishouMediaCallbackRecord copy(KuaishouMediaCallbackRecord src) {
+        KuaishouMediaCallbackRecord copy = new KuaishouMediaCallbackRecord();
+        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;
+    }
+}

+ 76 - 0
src/main/java/com/adx/tencent/kuaishou/service/KuaishouTagEventResolver.java

@@ -0,0 +1,76 @@
+package com.adx.tencent.kuaishou.service;
+
+import com.adx.tencent.kuaishou.model.KuaishouTagEventRecord;
+import com.adx.tencent.kuaishou.store.KuaishouColdStore;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.data.redis.core.StringRedisTemplate;
+
+/**
+ * 快手广告位回传方式解析器。
+ * 根据广告位ID和百度行为解析快手 event_type:
+ *   1. 优先从 Redis 读取(key = {prefix}{tagId}:{act});
+ *   2. Redis 未命中时回退查询 kuaishou_tag_event 表;
+ *   3. 支持 act=0 作为默认兜底配置。
+ */
+public class KuaishouTagEventResolver {
+
+    private static final Logger log = LoggerFactory.getLogger(KuaishouTagEventResolver.class);
+
+    private final StringRedisTemplate redis;
+    private final KuaishouColdStore coldStore;
+    private final String keyPrefix;
+
+    public KuaishouTagEventResolver(StringRedisTemplate redis, KuaishouColdStore coldStore, String keyPrefix) {
+        this.redis = redis;
+        this.coldStore = coldStore;
+        this.keyPrefix = keyPrefix == null ? "" : keyPrefix;
+    }
+
+    public Integer resolveEventType(String tagId, int baiduAct) {
+        if (tagId == null || tagId.isBlank()) {
+            return null;
+        }
+
+        try {
+            Integer cached = parseInt(redis.opsForValue().get(buildRedisKey(tagId, baiduAct)));
+            if (cached != null) {
+                return cached;
+            }
+            cached = parseInt(redis.opsForValue().get(buildRedisKey(tagId, 0)));
+            if (cached != null) {
+                return cached;
+            }
+        } catch (Exception e) {
+            log.warn("[KuaishouTagEventResolver] 读取 Redis 失败, 回退DB查询 | tagId={} | act={} | error={}",
+                    tagId, baiduAct, e.getMessage());
+        }
+
+        try {
+            KuaishouTagEventRecord record = coldStore.getTagEvent(tagId, baiduAct);
+            if (record == null) {
+                record = coldStore.getTagEvent(tagId, 0);
+            }
+            return record != null ? record.getEventType() : null;
+        } catch (Exception e) {
+            log.warn("[KuaishouTagEventResolver] 查询 kuaishou_tag_event 失败 | tagId={} | act={} | error={}",
+                    tagId, baiduAct, e.getMessage());
+            return null;
+        }
+    }
+
+    private String buildRedisKey(String tagId, int baiduAct) {
+        return keyPrefix + tagId + ":" + baiduAct;
+    }
+
+    private static Integer parseInt(String value) {
+        if (value == null || value.isBlank()) {
+            return null;
+        }
+        try {
+            return Integer.parseInt(value);
+        } catch (NumberFormatException e) {
+            return null;
+        }
+    }
+}

+ 72 - 0
src/main/java/com/adx/tencent/kuaishou/service/KuaishouTagEventSyncService.java

@@ -0,0 +1,72 @@
+package com.adx.tencent.kuaishou.service;
+
+import com.adx.tencent.kuaishou.model.KuaishouTagEventRecord;
+import com.adx.tencent.kuaishou.store.KuaishouColdStore;
+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;
+
+/**
+ * 快手广告位回传方式同步服务。
+ * 定时从 kuaishou_tag_event 表读取全部记录,同步到 Redis:
+ *   key   = {prefix}{tag_id}:{baidu_act}
+ *   value = event_type
+ */
+public class KuaishouTagEventSyncService {
+
+    private static final Logger log = LoggerFactory.getLogger(KuaishouTagEventSyncService.class);
+
+    private final KuaishouColdStore coldStore;
+    private final StringRedisTemplate redis;
+    private final String keyPrefix;
+    private final Duration ttl;
+
+    public KuaishouTagEventSyncService(KuaishouColdStore 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<KuaishouTagEventRecord> rows = coldStore.listAllTagEvents();
+        if (rows == null || rows.isEmpty()) {
+            log.info("[KuaishouTagEventSync] 无数据可同步");
+            return 0;
+        }
+
+        long ttlMs = (ttl != null && !ttl.isZero()) ? ttl.toMillis() : 0;
+        redis.executePipelined((RedisConnection conn) -> {
+            for (KuaishouTagEventRecord row : rows) {
+                if (row.getTagId() == null || row.getTagId().isBlank()) {
+                    continue;
+                }
+                byte[] key = buildRedisKey(row.getTagId(), row.getBaiduAct());
+                byte[] val = String.valueOf(row.getEventType()).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("[KuaishouTagEventSync] 同步完成,共 {} 条快手广告位回传方式写入 Redis", rows.size());
+        return rows.size();
+    }
+
+    private byte[] buildRedisKey(String tagId, int baiduAct) {
+        return (keyPrefix + tagId + ":" + baiduAct).getBytes(StandardCharsets.UTF_8);
+    }
+}

+ 189 - 0
src/main/java/com/adx/tencent/kuaishou/store/KuaishouColdStore.java

@@ -0,0 +1,189 @@
+package com.adx.tencent.kuaishou.store;
+
+import com.adx.tencent.kuaishou.mapper.KuaishouBidEventMapper;
+import com.adx.tencent.kuaishou.mapper.KuaishouConversionMapper;
+import com.adx.tencent.kuaishou.mapper.KuaishouMediaCallbackMapper;
+import com.adx.tencent.kuaishou.mapper.KuaishouTagEventMapper;
+import com.adx.tencent.kuaishou.mapper.KuaishouTrackingReportMapper;
+import com.adx.tencent.kuaishou.model.KuaishouBidRecord;
+import com.adx.tencent.kuaishou.model.KuaishouConversionRecord;
+import com.adx.tencent.kuaishou.model.KuaishouMediaCallbackRecord;
+import com.adx.tencent.kuaishou.model.KuaishouTagEventRecord;
+import com.adx.tencent.kuaishou.model.KuaishouTrackingRecord;
+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 KuaishouColdStore {
+
+    private final SqlSessionFactory sqlSessionFactory;
+
+    public KuaishouColdStore(SqlSessionFactory sqlSessionFactory, ObjectMapper objectMapper) {
+        this.sqlSessionFactory = sqlSessionFactory;
+    }
+
+    public void migrate() {
+        String[] ddls = {
+                """
+                CREATE TABLE IF NOT EXISTS kuaishou_ad_bid_events (
+                    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+                    qk VARCHAR(128) NOT NULL COMMENT '唯一业务键(query key)',
+                    media VARCHAR(64) NOT NULL COMMENT '媒体来源(kuaishou)',
+                    media_trace_id VARCHAR(255) COMMENT '媒体追踪ID(callback/设备标识)',
+                    platform VARCHAR(16) COMMENT '平台(android/ios)',
+                    account_id VARCHAR(128) COMMENT '快手投放账户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 '媒体参数精简白名单(回传/扣量所需设备标识等)',
+                    created_at TIMESTAMP(3) NOT NULL COMMENT '创建时间',
+                    UNIQUE KEY uk_kuaishou_ad_bid_events_qk (qk),
+                    KEY idx_kuaishou_ad_bid_events_media_trace (media, media_trace_id),
+                    KEY idx_kuaishou_ad_bid_events_platform_tag (platform, tag_id),
+                    KEY idx_kuaishou_ad_bid_events_created_at (created_at)
+                ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='快手竞价事件记录'
+                """,
+                """
+                CREATE TABLE IF NOT EXISTS kuaishou_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_kuaishou_tracking_reports_qk_kind_created_at (qk, kind, created_at),
+                    KEY idx_kuaishou_tracking_reports_tag_created_at (tag_id, created_at)
+                ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='快手曝光/点击上报结果'
+                """,
+                """
+                CREATE TABLE IF NOT EXISTS kuaishou_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 INT NOT NULL COMMENT '快手回传事件类型',
+                    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_kuaishou_media_callbacks_dedupe_ok_created_at (dedupe_key, ok, created_at),
+                    KEY idx_kuaishou_media_callbacks_qk_created_at (qk, created_at),
+                    KEY idx_kuaishou_media_callbacks_tag_created_at (tag_id, created_at)
+                ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='快手媒体转化回传记录'
+                """,
+                """
+                CREATE TABLE IF NOT EXISTS kuaishou_tag_event (
+                    tag_id VARCHAR(128) NOT NULL COMMENT '百度广告位ID',
+                    baidu_act INT NOT NULL COMMENT '百度转化行为',
+                    event_type INT NOT NULL COMMENT '快手回传事件类型',
+                    PRIMARY KEY (tag_id, baidu_act)
+                ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='快手广告位回传事件配置'
+                """
+        };
+        try (SqlSession session = sqlSessionFactory.openSession(true)) {
+            Statement stmt = session.getConnection().createStatement();
+            for (String ddl : ddls) {
+                stmt.execute(ddl);
+            }
+            stmt.execute("UPDATE kuaishou_ad_bid_events SET price = 0 WHERE price IS NULL");
+            stmt.execute("ALTER TABLE kuaishou_ad_bid_events MODIFY COLUMN price BIGINT UNSIGNED NOT NULL DEFAULT 0");
+            try {
+                stmt.execute("ALTER TABLE kuaishou_media_callbacks ADD COLUMN deduction_rate INT NOT NULL DEFAULT 0 COMMENT '扣量比例(0-100)' AFTER purchase");
+            } catch (Exception ignored) {
+            }
+            stmt.close();
+        } catch (Exception e) {
+            throw new RuntimeException("kuaishou migrate failed", e);
+        }
+    }
+
+    public void saveBid(KuaishouBidRecord record) {
+        if (record.getCreatedAt() == null) record.setCreatedAt(Instant.now());
+        try (SqlSession session = sqlSessionFactory.openSession(true)) {
+            session.getMapper(KuaishouBidEventMapper.class).insertOrUpdate(record.toColdRecord());
+        }
+    }
+
+    public List<KuaishouBidRecord> getBidsByQks(List<String> qks) {
+        if (qks == null || qks.isEmpty()) return List.of();
+        try (SqlSession session = sqlSessionFactory.openSession(true)) {
+            return session.getMapper(KuaishouBidEventMapper.class).selectByQks(qks);
+        }
+    }
+
+    public void saveTracking(KuaishouTrackingRecord record) {
+        if (record.getCreatedAt() == null) record.setCreatedAt(Instant.now());
+        try (SqlSession session = sqlSessionFactory.openSession(true)) {
+            session.getMapper(KuaishouTrackingReportMapper.class).insert(record);
+        }
+    }
+
+    public void saveConversions(List<KuaishouConversionRecord> records) {
+        if (records == null || records.isEmpty()) return;
+        for (KuaishouConversionRecord record : records) {
+            if (record.getCreatedAt() == null) record.setCreatedAt(Instant.now());
+        }
+        try (SqlSession session = sqlSessionFactory.openSession(true)) {
+            session.getMapper(KuaishouConversionMapper.class).batchInsertOrUpdate(records);
+        }
+    }
+
+    public void saveMediaCallbacks(List<KuaishouMediaCallbackRecord> records) {
+        if (records == null || records.isEmpty()) return;
+        for (KuaishouMediaCallbackRecord 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(KuaishouMediaCallbackMapper.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(KuaishouMediaCallbackMapper.class)
+                    .selectSuccessfulDedupeKeys(new ArrayList<>(dedupeKeys)));
+        }
+    }
+
+    public List<KuaishouMediaCallbackRecord> pendingMediaCallbacks(int limit) {
+        if (limit <= 0) limit = 100;
+        try (SqlSession session = sqlSessionFactory.openSession(true)) {
+            return session.getMapper(KuaishouMediaCallbackMapper.class).selectPendingCallbacks(limit);
+        }
+    }
+
+    public KuaishouTagEventRecord getTagEvent(String tagId, int baiduAct) {
+        if (tagId == null || tagId.isBlank()) return null;
+        try (SqlSession session = sqlSessionFactory.openSession(true)) {
+            return session.getMapper(KuaishouTagEventMapper.class).selectByTagIdAndAct(tagId, baiduAct);
+        }
+    }
+
+    public List<KuaishouTagEventRecord> listAllTagEvents() {
+        try (SqlSession session = sqlSessionFactory.openSession(true)) {
+            return session.getMapper(KuaishouTagEventMapper.class).selectAll();
+        }
+    }
+}

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

@@ -0,0 +1,377 @@
+package com.adx.tencent.kuaishou.store;
+
+import com.adx.tencent.kuaishou.model.KuaishouBidRecord;
+import com.adx.tencent.kuaishou.model.KuaishouTrackingRecord;
+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 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_DEDUCTION_COUNTER_TTL = Duration.ofDays(7);
+
+    private final StringRedisTemplate redis;
+    private final ObjectMapper objectMapper;
+    private final KuaishouColdStore 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 KuaishouHotStore(StringRedisTemplate redis, ObjectMapper objectMapper,
+                            KuaishouColdStore 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(KuaishouBidRecord 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 kuaishou bid failed", e);
+        }
+    }
+
+    public void recordTracking(KuaishouTrackingRecord 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 kuaishou tracking failed", e);
+        }
+    }
+
+    public void shrinkAfterClick(KuaishouBidRecord 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 kuaishou bid failed", e);
+        }
+    }
+
+    public KuaishouBidRecord findBidByMediaTrace(String traceId) {
+        String payload = redis.opsForValue().get(mediaTraceKey(traceId));
+        if (payload == null) return null;
+        return parseBidRecord(payload);
+    }
+
+    public Map<String, KuaishouBidRecord> findBidsByQks(List<String> qks) {
+        if (qks == null || qks.isEmpty()) return Collections.emptyMap();
+        Map<String, KuaishouBidRecord> 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 (KuaishouBidRecord 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 kuaishou 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;
+        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);
+                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 kuaishou 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("[KuaishouHotStore] read failed | stream={} | group={} | consumer={} | count={} | error={}",
+                    stream, group, consumer, count, rootMessage(e), e);
+            throw new RuntimeException("read kuaishou 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:kuaishou:" + traceId; }
+    private String conversionSeenKey(String dedupeKey) { return prefix + "conv:seen:kuaishou:" + dedupeKey; }
+
+    private String deductionCounterKey(String accountId, int act, String date) {
+        return String.format("%sdeduct:state:kuaishou: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 KuaishouBidRecord parseBidRecord(String payload) {
+        try {
+            return objectMapper.readValue(payload, KuaishouBidRecord.class);
+        } catch (Exception e) {
+            throw new RuntimeException("parse kuaishou 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 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; }
+    }
+}

+ 60 - 0
src/main/resources/mapper/KuaishouBidEventMapper.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.kuaishou.mapper.KuaishouBidEventMapper">
+    <resultMap id="kuaishouBidRecordResultMap" type="com.adx.tencent.kuaishou.model.KuaishouBidRecord">
+        <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="kuaishouBidRecordResultMap">
+        SELECT qk, media, media_trace_id, platform, account_id, event_type,
+               tag_id, price, package_name, media_params, created_at
+        FROM kuaishou_ad_bid_events
+        WHERE qk = #{qk}
+        LIMIT 1
+    </select>
+
+    <select id="selectByQks" resultMap="kuaishouBidRecordResultMap">
+        SELECT qk, media, media_trace_id, platform, account_id, event_type,
+               tag_id, price, package_name, media_params, created_at
+        FROM kuaishou_ad_bid_events
+        WHERE qk IN
+        <foreach collection="qks" item="qk" open="(" separator="," close=")">
+            #{qk}
+        </foreach>
+    </select>
+
+    <insert id="insertOrUpdate">
+        INSERT INTO kuaishou_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>

+ 32 - 0
src/main/resources/mapper/KuaishouConversionMapper.xml

@@ -0,0 +1,32 @@
+<?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.kuaishou.mapper.KuaishouConversionMapper">
+    <insert id="batchInsertOrUpdate">
+        INSERT INTO tencent_baidu_conversions (
+            dedupe_key, qk, media, tag_id, date_value, appsid, customer_name, device_id, conv,
+            payment, gmv, act, tu, clk_time, created_at
+        ) VALUES
+        <foreach collection="records" item="r" separator=",">
+            (
+                #{r.dedupeKey}, #{r.qk}, #{r.media}, #{r.tagId}, #{r.date}, #{r.appSid}, #{r.customerName},
+                #{r.deviceId}, #{r.conv}, #{r.payment}, #{r.gmv}, #{r.act}, #{r.tu},
+                #{r.clkTime}, #{r.createdAtTs}
+            )
+        </foreach>
+        ON DUPLICATE KEY UPDATE
+            qk = VALUES(qk),
+            media = CASE
+                WHEN VALUES(media) IS NOT NULL AND VALUES(media) != '' THEN VALUES(media)
+                ELSE media
+            END,
+            tag_id = CASE
+                WHEN VALUES(tag_id) IS NOT NULL AND VALUES(tag_id) != '' THEN VALUES(tag_id)
+                ELSE tag_id
+            END,
+            conv = VALUES(conv),
+            payment = VALUES(payment),
+            gmv = VALUES(gmv),
+            clk_time = VALUES(clk_time)
+    </insert>
+</mapper>

+ 69 - 0
src/main/resources/mapper/KuaishouMediaCallbackMapper.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.kuaishou.mapper.KuaishouMediaCallbackMapper">
+    <resultMap id="kuaishouMediaCallbackResultMap" type="com.adx.tencent.kuaishou.model.KuaishouMediaCallbackRecord">
+        <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 kuaishou_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 kuaishou_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="kuaishouMediaCallbackResultMap">
+        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 kuaishou_media_callbacks m
+        JOIN (
+            SELECT dedupe_key, MAX(id) AS id
+            FROM kuaishou_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 kuaishou_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>

+ 21 - 0
src/main/resources/mapper/KuaishouTagEventMapper.xml

@@ -0,0 +1,21 @@
+<?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.kuaishou.mapper.KuaishouTagEventMapper">
+    <resultMap id="kuaishouTagEventResultMap" type="com.adx.tencent.kuaishou.model.KuaishouTagEventRecord">
+        <result property="tagId" column="tag_id"/>
+        <result property="baiduAct" column="baidu_act"/>
+        <result property="eventType" column="event_type"/>
+    </resultMap>
+
+    <select id="selectByTagIdAndAct" resultMap="kuaishouTagEventResultMap">
+        SELECT tag_id, baidu_act, event_type
+        FROM kuaishou_tag_event
+        WHERE tag_id = #{tagId} AND baidu_act = #{baiduAct}
+    </select>
+
+    <select id="selectAll" resultMap="kuaishouTagEventResultMap">
+        SELECT tag_id, baidu_act, event_type
+        FROM kuaishou_tag_event
+    </select>
+</mapper>

+ 9 - 0
src/main/resources/mapper/KuaishouTrackingReportMapper.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.kuaishou.mapper.KuaishouTrackingReportMapper">
+    <insert id="insert">
+        INSERT INTO kuaishou_tracking_reports (qk, tag_id, kind, tracking_url, status, ok, created_at)
+        VALUES (#{qk}, #{tagId}, #{kind}, #{url}, #{status}, #{ok}, #{createdAtTs})
+    </insert>
+</mapper>

+ 62 - 0
src/main/resources/schema-kuaishou.sql

@@ -0,0 +1,62 @@
+CREATE TABLE IF NOT EXISTS kuaishou_ad_bid_events (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    qk VARCHAR(128) NOT NULL COMMENT '唯一业务键(query key)',
+    media VARCHAR(64) NOT NULL COMMENT '媒体来源(kuaishou)',
+    media_trace_id VARCHAR(255) COMMENT '媒体追踪ID(callback/设备标识)',
+    platform VARCHAR(16) COMMENT '平台(android/ios)',
+    account_id VARCHAR(128) COMMENT '快手投放账户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 '媒体参数精简白名单(回传/扣量所需设备标识等)',
+    created_at TIMESTAMP(3) NOT NULL COMMENT '创建时间',
+    UNIQUE KEY uk_kuaishou_ad_bid_events_qk (qk),
+    KEY idx_kuaishou_ad_bid_events_media_trace (media, media_trace_id),
+    KEY idx_kuaishou_ad_bid_events_platform_tag (platform, tag_id),
+    KEY idx_kuaishou_ad_bid_events_created_at (created_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='快手竞价事件记录';
+
+CREATE TABLE IF NOT EXISTS kuaishou_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_kuaishou_tracking_reports_qk_kind_created_at (qk, kind, created_at),
+    KEY idx_kuaishou_tracking_reports_tag_created_at (tag_id, created_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='快手曝光/点击上报结果';
+
+CREATE TABLE IF NOT EXISTS kuaishou_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 INT NOT NULL COMMENT '快手回传事件类型',
+    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_kuaishou_media_callbacks_dedupe_ok_created_at (dedupe_key, ok, created_at),
+    KEY idx_kuaishou_media_callbacks_qk_created_at (qk, created_at),
+    KEY idx_kuaishou_media_callbacks_tag_created_at (tag_id, created_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='快手媒体转化回传记录';
+
+CREATE TABLE IF NOT EXISTS kuaishou_tag_event (
+    tag_id VARCHAR(128) NOT NULL COMMENT '百度广告位ID',
+    baidu_act INT NOT NULL COMMENT '百度转化行为',
+    event_type INT NOT NULL COMMENT '快手回传事件类型',
+    PRIMARY KEY (tag_id, baidu_act)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='快手广告位回传事件配置';

BIN
快手监测链接接入说明.docx