yumeng 1 mesiac pred
rodič
commit
fd8411e191

+ 8 - 6
KUAISHOU_TRACKING_LINK_USAGE.md

@@ -30,13 +30,13 @@ https://{your-domain}/kuaishou/click?callback=__CALLBACK__&platform=ios&tagId={b
 ### 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__
+https://{your-domain}/kuaishou/impression?callback=__CALLBACK__&platform=android&tagId={baiduTagId}&accountId={accountId}&oaid=__OAID__&oaidMD5=__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__
+https://{your-domain}/kuaishou/click?callback=__CALLBACK__&platform=android&tagId={baiduTagId}&accountId={accountId}&oaid=__OAID__&oaidMD5=__OAID2__&imei=__IMEI2__&androidid=__ANDROIDID2__&ip=__IP__&ua=__UA__&deduction_rate={0-100}&ts=__TS__
 ```
 
 ## 2. 参数说明
@@ -75,14 +75,15 @@ https://{your-domain}/kuaishou/click?callback=__CALLBACK__&platform=android&tagI
 
 | 参数名 | 是否必填 | 说明 |
 | --- | --- | --- |
-| `oaid` | 至少一种 | 对应快手宏 `__OAID2__`,Android 下的 OAID MD5。 |
+| `oaid` | 建议必填 | 对应快手宏 `__OAID__`,Android 下的 OAID 明文。会透传给百度 `device.uid.oaid`。 |
+| `oaidMD5` | 选填 | 对应快手宏 `__OAID2__`,Android 下的 OAID MD5。会透传给百度 `device.uid.oaidMd5`。 |
 | `imei` | 至少一种 | 对应快手宏 `__IMEI2__` 或 `__IMEI4__`,Android 下的 IMEI MD5。 |
 | `androidid` | 至少一种 | 对应快手宏 `__ANDROIDID2__`,Android 下的 AndroidID MD5。 |
 
 说明:
 
-- `oaid`、`imei`、`androidid` 至少传一种。
-- 推荐优先传 `oaid`。
+- `oaid`、`oaidMD5`、`imei`、`androidid` 至少传一种。
+- 推荐同时传 `oaid=__OAID__` 和 `oaidMD5=__OAID2__`。
 
 ## 3. 当前不需要传的参数
 
@@ -223,6 +224,7 @@ deduction_rate
 | --- | --- |
 | `idfa` | `__IDFA2__` |
 | `kenyid_caa` | `__KENYID_CAA__` |
-| `oaid` | `__OAID2__` |
+| `oaid` | `__OAID__` |
+| `oaidMD5` | `__OAID2__` |
 | `imei` | `__IMEI2__` 或 `__IMEI4__` |
 | `androidid` | `__ANDROIDID2__` |

+ 94 - 23
src/main/java/com/adx/tencent/conversionsync/ConversionSyncService.java

@@ -53,6 +53,14 @@ public class ConversionSyncService {
     private static final Logger log = LoggerFactory.getLogger(ConversionSyncService.class);
     private static final int REALTIME_CONCURRENCY = 10;
     private static final int BACKFILL_CONCURRENCY = 16;
+    private static final int KS_EVENT_ACTIVATION = 1;
+    private static final int KS_EVENT_REGISTRATION = 2;
+    private static final int KS_EVENT_PURCHASE = 3;
+    private static final int KS_EVENT_NEXT_DAY_RETENTION = 7;
+    private static final int KS_EVENT_SEVEN_DAY_RETENTION = 8;
+    private static final int KS_EVENT_APP_WAKEUP = 84;
+    private static final int KS_EVENT_KEY_ACTION = 143;
+    private static final int KS_EVENT_THREE_DAY_RETENTION = 103;
 
     private final ConversionClient baiduClient;
     private final RedisHotStore hotStore;
@@ -322,9 +330,9 @@ public class ConversionSyncService {
                     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);
+                    List<CallbackTask> kuaishouTasks = buildKuaishouCallbackTasks(payment, kuaishouBid);
+                    if (!kuaishouTasks.isEmpty()) {
+                        callbackTasks.addAll(kuaishouTasks);
                     }
                     continue;
                 }
@@ -394,24 +402,28 @@ public class ConversionSyncService {
         );
     }
 
-    private CallbackTask buildKuaishouCallbackTask(PaymentInfo payment, KuaishouBidRecord bid) {
+    private List<CallbackTask> buildKuaishouCallbackTasks(PaymentInfo payment, KuaishouBidRecord bid) {
         if (kuaishouColdStore == null && kuaishouTagEventResolver == null) {
-            return null;
+            return List.of();
         }
 
-        KuaishouTagEventRecord tagEvent = resolveKuaishouTagEvent(bid.getTagId(), payment.getAct());
-        if (tagEvent == null) {
-            log.warn("[ConversionSync] 快手 eventType 未配置 | qk={} | tagId={} | act={}",
+        List<KuaishouTagEventRecord> tagEvents = resolveKuaishouTagEvents(bid.getTagId(), payment.getAct());
+        if (tagEvents.isEmpty()) {
+            log.warn("[ConversionSync] 快手 eventType 未配置且无兜底映射 | qk={} | tagId={} | act={}",
                     payment.getQk(), bid.getTagId(), payment.getAct());
-            return null;
+            return List.of();
         }
 
-        return CallbackTask.kuaishou(
-                payment,
-                bid,
-                kuaishouCallbackDedupeKey(payment, bid, tagEvent.getEventType()),
-                tagEvent
-        );
+        List<CallbackTask> tasks = new ArrayList<>(tagEvents.size());
+        for (KuaishouTagEventRecord tagEvent : tagEvents) {
+            tasks.add(CallbackTask.kuaishou(
+                    payment,
+                    bid,
+                    kuaishouCallbackDedupeKey(payment, bid, tagEvent.getEventType()),
+                    tagEvent
+            ));
+        }
+        return tasks;
     }
 
     private Integer resolveHonorConversionIdFromDb(String tagId, int baiduAct) {
@@ -426,23 +438,82 @@ 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;
+    private List<KuaishouTagEventRecord> resolveKuaishouTagEvents(String tagId, int baiduAct) {
+        List<KuaishouTagEventRecord> configured = resolveConfiguredKuaishouTagEvents(tagId, baiduAct);
+        if (!configured.isEmpty()) {
+            return configured;
+        }
+
+        List<KuaishouTagEventRecord> fallback = buildKuaishouFallbackTagEvents(tagId, baiduAct);
+        if (!fallback.isEmpty()) {
+            log.warn("[ConversionSync] 快手 eventType 未配置, 启用兜底映射 | tagId={} | act={} | fallbackEventTypes={}",
+                    tagId, baiduAct, fallback.stream().map(KuaishouTagEventRecord::getEventType).toList());
+        }
+        return fallback;
+    }
+
+    private List<KuaishouTagEventRecord> resolveConfiguredKuaishouTagEvents(String tagId, int baiduAct) {
+        if (tagId == null || tagId.isBlank()) {
+            return List.of();
         }
         try {
-            KuaishouTagEventRecord record = kuaishouColdStore.getTagEvent(tagId, baiduAct);
-            if (record == null) {
-                record = kuaishouColdStore.getTagEvent(tagId, 0);
+            if (kuaishouTagEventResolver != null) {
+                List<Integer> eventTypes = kuaishouTagEventResolver.resolveEventTypes(tagId, baiduAct);
+                if (eventTypes != null && !eventTypes.isEmpty()) {
+                    List<KuaishouTagEventRecord> records = new ArrayList<>(eventTypes.size());
+                    for (Integer eventType : eventTypes) {
+                        records.add(buildKuaishouTagEvent(tagId, baiduAct, eventType));
+                    }
+                    return records;
+                }
+                return List.of();
             }
-            return record;
+            if (kuaishouColdStore == null) {
+                return List.of();
+            }
+            List<KuaishouTagEventRecord> records = kuaishouColdStore.getTagEvents(tagId, baiduAct);
+            if (records == null || records.isEmpty()) {
+                records = kuaishouColdStore.getTagEvents(tagId, 0);
+            }
+            return records == null ? List.of() : records;
         } catch (Exception e) {
             log.warn("[ConversionSync] 查询快手 tag event 失败 | tagId={} | act={} | error={}",
                     tagId, baiduAct, e.getMessage(), e);
-            return null;
+            return List.of();
         }
     }
 
+    private static KuaishouTagEventRecord buildKuaishouTagEvent(String tagId, int baiduAct, int eventType) {
+        KuaishouTagEventRecord record = new KuaishouTagEventRecord();
+        record.setTagId(tagId);
+        record.setBaiduAct(baiduAct);
+        record.setEventType(eventType);
+        return record;
+    }
+
+    private static List<KuaishouTagEventRecord> buildKuaishouFallbackTagEvents(String tagId, int baiduAct) {
+        List<Integer> eventTypes = switch (baiduAct) {
+            case 1 -> List.of(KS_EVENT_ACTIVATION);
+            case 2, 5 -> List.of(KS_EVENT_PURCHASE);
+            case 3 -> List.of(KS_EVENT_REGISTRATION, KS_EVENT_ACTIVATION);
+            case 4 -> List.of(KS_EVENT_NEXT_DAY_RETENTION);
+            case 6 -> List.of(KS_EVENT_APP_WAKEUP);
+            case 7 -> List.of(KS_EVENT_KEY_ACTION);
+            case 8 -> List.of(KS_EVENT_SEVEN_DAY_RETENTION);
+            case 9 -> List.of(KS_EVENT_THREE_DAY_RETENTION);
+            case 2001 -> List.of(KS_EVENT_APP_WAKEUP);
+            default -> List.of();
+        };
+        if (eventTypes.isEmpty()) {
+            return List.of();
+        }
+        List<KuaishouTagEventRecord> records = new ArrayList<>(eventTypes.size());
+        for (Integer eventType : eventTypes) {
+            records.add(buildKuaishouTagEvent(tagId, baiduAct, eventType));
+        }
+        return records;
+    }
+
     // ─── Step 3: 统一落百度转化明细 ───────────────────────────────────────────
 
     private void saveConversions(List<ConversionRecord> records) {

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

@@ -8,7 +8,7 @@ import java.util.List;
 
 @Mapper
 public interface KuaishouTagEventMapper {
-    KuaishouTagEventRecord selectByTagIdAndAct(@Param("tagId") String tagId, @Param("baiduAct") int baiduAct);
+    List<KuaishouTagEventRecord> selectByTagIdAndAct(@Param("tagId") String tagId, @Param("baiduAct") int baiduAct);
 
     List<KuaishouTagEventRecord> selectAll();
 }

+ 41 - 19
src/main/java/com/adx/tencent/kuaishou/service/KuaishouTagEventResolver.java

@@ -6,12 +6,17 @@ import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.data.redis.core.StringRedisTemplate;
 
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+
 /**
  * 快手广告位回传方式解析器。
- * 根据广告位ID和百度行为解析快手 event_type:
+ * 根据广告位ID和百度行为解析快手 event_type 列表
  *   1. 优先从 Redis 读取(key = {prefix}{tagId}:{act});
  *   2. Redis 未命中时回退查询 kuaishou_tag_event 表;
- *   3. 支持 act=0 作为默认兜底配置。
+ *   3. 支持 act=0 作为默认整组兜底配置。
  */
 public class KuaishouTagEventResolver {
 
@@ -27,18 +32,18 @@ public class KuaishouTagEventResolver {
         this.keyPrefix = keyPrefix == null ? "" : keyPrefix;
     }
 
-    public Integer resolveEventType(String tagId, int baiduAct) {
+    public List<Integer> resolveEventTypes(String tagId, int baiduAct) {
         if (tagId == null || tagId.isBlank()) {
-            return null;
+            return List.of();
         }
 
         try {
-            Integer cached = parseInt(redis.opsForValue().get(buildRedisKey(tagId, baiduAct)));
-            if (cached != null) {
+            List<Integer> cached = parseEventTypes(redis.opsForValue().get(buildRedisKey(tagId, baiduAct)));
+            if (!cached.isEmpty()) {
                 return cached;
             }
-            cached = parseInt(redis.opsForValue().get(buildRedisKey(tagId, 0)));
-            if (cached != null) {
+            cached = parseEventTypes(redis.opsForValue().get(buildRedisKey(tagId, 0)));
+            if (!cached.isEmpty()) {
                 return cached;
             }
         } catch (Exception e) {
@@ -47,15 +52,15 @@ public class KuaishouTagEventResolver {
         }
 
         try {
-            KuaishouTagEventRecord record = coldStore.getTagEvent(tagId, baiduAct);
-            if (record == null) {
-                record = coldStore.getTagEvent(tagId, 0);
+            List<KuaishouTagEventRecord> records = coldStore.getTagEvents(tagId, baiduAct);
+            if (records == null || records.isEmpty()) {
+                records = coldStore.getTagEvents(tagId, 0);
             }
-            return record != null ? record.getEventType() : null;
+            return toEventTypes(records);
         } catch (Exception e) {
             log.warn("[KuaishouTagEventResolver] 查询 kuaishou_tag_event 失败 | tagId={} | act={} | error={}",
                     tagId, baiduAct, e.getMessage());
-            return null;
+            return List.of();
         }
     }
 
@@ -63,14 +68,31 @@ public class KuaishouTagEventResolver {
         return keyPrefix + tagId + ":" + baiduAct;
     }
 
-    private static Integer parseInt(String value) {
+    private static List<Integer> parseEventTypes(String value) {
         if (value == null || value.isBlank()) {
-            return null;
+            return List.of();
         }
-        try {
-            return Integer.parseInt(value);
-        } catch (NumberFormatException e) {
-            return null;
+        Set<Integer> result = new LinkedHashSet<>();
+        for (String part : value.split(",")) {
+            if (part == null || part.isBlank()) {
+                continue;
+            }
+            try {
+                result.add(Integer.parseInt(part.trim()));
+            } catch (NumberFormatException ignored) {
+            }
+        }
+        return new ArrayList<>(result);
+    }
+
+    private static List<Integer> toEventTypes(List<KuaishouTagEventRecord> records) {
+        if (records == null || records.isEmpty()) {
+            return List.of();
+        }
+        Set<Integer> result = new LinkedHashSet<>();
+        for (KuaishouTagEventRecord record : records) {
+            result.add(record.getEventType());
         }
+        return new ArrayList<>(result);
     }
 }

+ 29 - 10
src/main/java/com/adx/tencent/kuaishou/service/KuaishouTagEventSyncService.java

@@ -11,13 +11,18 @@ import org.springframework.data.redis.core.types.Expiration;
 
 import java.nio.charset.StandardCharsets;
 import java.time.Duration;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
 import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
 
 /**
  * 快手广告位回传方式同步服务。
  * 定时从 kuaishou_tag_event 表读取全部记录,同步到 Redis:
  *   key   = {prefix}{tag_id}:{baidu_act}
- *   value = event_type
+ *   value = event_type 列表(逗号分隔)
  */
 public class KuaishouTagEventSyncService {
 
@@ -43,14 +48,27 @@ public class KuaishouTagEventSyncService {
             return 0;
         }
 
+        Map<String, Set<Integer>> grouped = new LinkedHashMap<>();
+        for (KuaishouTagEventRecord row : rows) {
+            if (row.getTagId() == null || row.getTagId().isBlank()) {
+                continue;
+            }
+            grouped.computeIfAbsent(buildRedisKey(row.getTagId(), row.getBaiduAct()), ignored -> new LinkedHashSet<>())
+                    .add(row.getEventType());
+        }
+        if (grouped.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);
+            for (Map.Entry<String, Set<Integer>> entry : grouped.entrySet()) {
+                byte[] key = entry.getKey().getBytes(StandardCharsets.UTF_8);
+                String value = entry.getValue().stream()
+                        .map(String::valueOf)
+                        .collect(Collectors.joining(","));
+                byte[] val = value.getBytes(StandardCharsets.UTF_8);
                 if (ttlMs > 0) {
                     conn.stringCommands().set(key, val,
                             Expiration.milliseconds(ttlMs),
@@ -62,11 +80,12 @@ public class KuaishouTagEventSyncService {
             return null;
         });
 
-        log.info("[KuaishouTagEventSync] 同步完成,共 {} 条快手广告位回传方式写入 Redis", rows.size());
+        log.info("[KuaishouTagEventSync] 同步完成,共 {} 条快手广告位回传方式写入 Redis, 聚合后 {} 个键",
+                rows.size(), grouped.size());
         return rows.size();
     }
 
-    private byte[] buildRedisKey(String tagId, int baiduAct) {
-        return (keyPrefix + tagId + ":" + baiduAct).getBytes(StandardCharsets.UTF_8);
+    private String buildRedisKey(String tagId, int baiduAct) {
+        return keyPrefix + tagId + ":" + baiduAct;
     }
 }

+ 7 - 3
src/main/java/com/adx/tencent/kuaishou/store/KuaishouColdStore.java

@@ -95,7 +95,7 @@ public class KuaishouColdStore {
                     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)
+                    PRIMARY KEY (tag_id, baidu_act, event_type)
                 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='快手广告位回传事件配置'
                 """
         };
@@ -110,6 +110,10 @@ public class KuaishouColdStore {
                 stmt.execute("ALTER TABLE kuaishou_media_callbacks ADD COLUMN deduction_rate INT NOT NULL DEFAULT 0 COMMENT '扣量比例(0-100)' AFTER purchase");
             } catch (Exception ignored) {
             }
+            try {
+                stmt.execute("ALTER TABLE kuaishou_tag_event DROP PRIMARY KEY, ADD PRIMARY KEY (tag_id, baidu_act, event_type)");
+            } catch (Exception ignored) {
+            }
             stmt.close();
         } catch (Exception e) {
             throw new RuntimeException("kuaishou migrate failed", e);
@@ -174,8 +178,8 @@ public class KuaishouColdStore {
         }
     }
 
-    public KuaishouTagEventRecord getTagEvent(String tagId, int baiduAct) {
-        if (tagId == null || tagId.isBlank()) return null;
+    public List<KuaishouTagEventRecord> getTagEvents(String tagId, int baiduAct) {
+        if (tagId == null || tagId.isBlank()) return List.of();
         try (SqlSession session = sqlSessionFactory.openSession(true)) {
             return session.getMapper(KuaishouTagEventMapper.class).selectByTagIdAndAct(tagId, baiduAct);
         }

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

@@ -12,10 +12,12 @@
         SELECT tag_id, baidu_act, event_type
         FROM kuaishou_tag_event
         WHERE tag_id = #{tagId} AND baidu_act = #{baiduAct}
+        ORDER BY event_type ASC
     </select>
 
     <select id="selectAll" resultMap="kuaishouTagEventResultMap">
         SELECT tag_id, baidu_act, event_type
         FROM kuaishou_tag_event
+        ORDER BY tag_id ASC, baidu_act ASC, event_type ASC
     </select>
 </mapper>

+ 1 - 1
src/main/resources/schema-kuaishou.sql

@@ -58,5 +58,5 @@ 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)
+    PRIMARY KEY (tag_id, baidu_act, event_type)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='快手广告位回传事件配置';