yumeng 3 週間 前
コミット
059ea07cea

ファイルの差分が大きいため隠しています
+ 3301 - 25942
logs/tencent-baidu-tracking.log


+ 0 - 3
src/main/java/com/adx/tencent/AppConfiguration.java

@@ -174,10 +174,7 @@ public class AppConfiguration {
             tokenSupplier = () -> staticToken;
         }
         return new TencentClient(
-                props.getTencentApiBaseUrl(),
                 tokenSupplier,
-                props.getTencentAndroidUserActionSetId(),
-                props.getTencentIosUserActionSetId(),
                 props.getTencentActMap(),
                 objectMapper);
     }

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

@@ -42,6 +42,7 @@ public class AppProperties {
     private String tencentApiBaseUrl = "https://api.e.qq.com/v3.0";
     private String tencentAccessToken;
     private String tencentAccessTokenRedisKey;
+    private long tencentAccountId;
     private long tencentAndroidUserActionSetId;
     private long tencentIosUserActionSetId;
 
@@ -155,6 +156,9 @@ public class AppProperties {
     public String getTencentAccessTokenRedisKey() { return tencentAccessTokenRedisKey; }
     public void setTencentAccessTokenRedisKey(String v) { this.tencentAccessTokenRedisKey = v; }
 
+    public long getTencentAccountId() { return tencentAccountId; }
+    public void setTencentAccountId(long v) { this.tencentAccountId = v; }
+
     public long getTencentAndroidUserActionSetId() { return tencentAndroidUserActionSetId; }
     public void setTencentAndroidUserActionSetId(long v) { this.tencentAndroidUserActionSetId = v; }
 

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

@@ -19,7 +19,7 @@ import java.util.concurrent.atomic.AtomicInteger;
 
 /**
  * 转化同步核心逻辑。
- * 定时从百度拉取转化数据,匹配腾讯 bid,回传腾讯 user_actions/add
+ * 定时从百度拉取转化数据,匹配腾讯 bid,通过 callback URL 回传腾讯。
  */
 public class ConversionSyncService {
 

+ 1 - 0
src/main/java/com/adx/tencent/conversionsync/RetryService.java

@@ -60,6 +60,7 @@ public class RetryService {
         copy.setMedia(src.getMedia());
         copy.setQk(src.getQk());
         copy.setCallbackUrl(src.getCallbackUrl());
+        copy.setRequestBody(src.getRequestBody());
         copy.setEventType(src.getEventType());
         copy.setEventTimeMs(src.getEventTimeMs());
         copy.setPurchase(src.getPurchase());

+ 3 - 1
src/main/java/com/adx/tencent/storage/model/BidRecord.java

@@ -115,7 +115,9 @@ public class BidRecord {
                 "imei", "imei_md5",
                 "idfa", "idfa_md5",
                 "oaid", "oaid_md5",
-                "android_id", "android_id_md5"
+                "android_id", "android_id_md5",
+                "hash_oaid", "hash_android_id",
+                "callback", "click_id", "account_id"
         );
         Map<String, String> slim = new LinkedHashMap<>();
         for (String key : deviceKeys) {

+ 3 - 0
src/main/java/com/adx/tencent/storage/model/MediaCallbackRecord.java

@@ -16,6 +16,7 @@ public class MediaCallbackRecord {
     @JsonProperty("attempt")      private int attempt;
     @JsonProperty("responseBody") private String responseBody;
     @JsonProperty("errorMessage") private String errorMessage;
+    @JsonProperty("requestBody")  private String requestBody;
     @JsonProperty("createdAt")    private Instant createdAt;
 
     public String getDedupeKey() { return dedupeKey; }
@@ -42,6 +43,8 @@ public class MediaCallbackRecord {
     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; }
 }

+ 104 - 140
src/main/java/com/adx/tencent/tencent/TencentClient.java

@@ -15,6 +15,7 @@ import java.net.URI;
 import java.net.http.HttpClient;
 import java.net.http.HttpRequest;
 import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
 import java.security.MessageDigest;
 import java.time.Duration;
 import java.time.Instant;
@@ -22,37 +23,28 @@ import java.util.*;
 import java.util.function.Supplier;
 
 /**
- * 腾讯行为回传客户端。
- * 调用腾讯 Marketing API user_actions/add 接口回传转化数据。
- *
- * API文档: https://developers.e.qq.com/v3.0/docs/api/user_actions/add
+ * 腾讯转化回传客户端。
+ * 通过腾讯 callback URL (tracking.e.qq.com/conv 或 api.e.qq.com/conv) POST 回传转化数据。
+ * callback URL 来自监测链接下发的 __CALLBACK__ 宏,存储在 Redis BidRecord.mediaParams.callback。
  */
 public class TencentClient {
 
     private static final Logger log = LoggerFactory.getLogger(TencentClient.class);
 
-    private final String apiBaseUrl;
     private final Supplier<String> accessTokenSupplier;
-    private final long androidUserActionSetId;
-    private final long iosUserActionSetId;
     private final Map<Integer, String> actMap;  // baiduAct -> tencentActionType
     private final HttpClient httpClient;
     private final ObjectMapper objectMapper;
 
-    public TencentClient(String apiBaseUrl, Supplier<String> accessTokenSupplier,
-                         long androidUserActionSetId, long iosUserActionSetId,
+    public TencentClient(Supplier<String> accessTokenSupplier,
                          Map<Integer, String> actMapOverride,
                          ObjectMapper objectMapper) {
-        this.apiBaseUrl = apiBaseUrl.replaceAll("/+$", "");
         this.accessTokenSupplier = accessTokenSupplier;
-        this.androidUserActionSetId = androidUserActionSetId;
-        this.iosUserActionSetId = iosUserActionSetId;
         this.objectMapper = objectMapper;
         this.httpClient = HttpClient.newBuilder()
                 .connectTimeout(Duration.ofSeconds(10))
                 .build();
 
-        // 从默认映射表开始,再用配置覆盖
         Map<Integer, String> merged = new HashMap<>(defaultActMap());
         if (actMapOverride != null) merged.putAll(actMapOverride);
         this.actMap = Collections.unmodifiableMap(merged);
@@ -70,23 +62,12 @@ public class TencentClient {
     }
 
     /**
-     * 根据平台获取对应的 user_action_set_id。
-     */
-    public long getUserActionSetId(String platform) {
-        if ("ios".equalsIgnoreCase(platform)) {
-            return iosUserActionSetId;
-        }
-        return androidUserActionSetId;
-    }
-
-    /**
-     * 发送转化事件到腾讯。
-     * 构造 user_actions/add 请求体并发送 POST 请求。
+     * 通过 callback URL 回传转化事件到腾讯。
      *
-     * @param bid      匹配到的竞价记录
+     * @param bid      匹配到的竞价记录(含 callback URL)
      * @param payment  百度转化支付信息
      * @param platform 平台标识("android" 或 "ios")
-     * @return MediaCallbackRecord 记录回传结果(包含成功/失败信息
+     * @return MediaCallbackRecord 记录回传结果(含请求体、响应、状态)
      */
     public MediaCallbackRecord sendConversionEvent(BidRecord bid, PaymentInfo payment, String platform) {
         long eventTimeMs = Instant.now().toEpochMilli();
@@ -101,92 +82,44 @@ public class TencentClient {
         record.setAttempt(1);
         record.setCreatedAt(Instant.now());
 
-        // 构造请求 URL
-        long timestamp = Instant.now().getEpochSecond();
-        String nonce = UUID.randomUUID().toString().replace("-", "").substring(0, 32);
-        String currentToken = accessTokenSupplier.get();
-        if (currentToken == null || currentToken.isBlank()) {
+        // 取 callback URL
+        String callbackUrl = bid.getMediaParams() != null ? bid.getMediaParams().get("callback") : null;
+        if (callbackUrl == null || callbackUrl.isBlank()) {
             record.setOk(false);
-            record.setErrorMessage("access_token is empty (check Redis key)");
-            log.warn("[Tencent] access_token 为空, qk={}", payment.getQk());
+            record.setErrorMessage("callback URL 缺失, 无法回传");
+            log.warn("[Tencent] callback URL 缺失, qk={}", payment.getQk());
             return record;
         }
-        String url = String.format("%s/user_actions/add?access_token=%s&timestamp=%d&nonce=%s",
-                apiBaseUrl, currentToken, timestamp, nonce);
-        record.setCallbackUrl(url);
+        record.setCallbackUrl(callbackUrl);
 
-        // 构造请求 Body
+        // 构造 actions body
         UserAction action = buildUserAction(bid, payment, actionType, eventTimeMs);
-
-        long actionSetId = getUserActionSetId(platform);
-        // account_id: 优先顶层字段,兜底从 mediaParams 读取(兼容旧数据)
-        long finalAccountId = 0;
-        String accountIdStr = bid.getAccountId();
-        if (accountIdStr == null || accountIdStr.isBlank()) {
-            accountIdStr = bid.getMediaParams() != null ? bid.getMediaParams().get("account_id") : null;
-        }
-        if (accountIdStr != null && !accountIdStr.isBlank()) {
-            try {
-                finalAccountId = Long.parseLong(accountIdStr);
-            } catch (NumberFormatException ignored) {}
-        }
-        if (finalAccountId == 0) {
-            log.warn("[Tencent] account_id 缺失, qk={}, 监测链接未传入account_id", payment.getQk());
-        }
         Map<String, Object> body = new LinkedHashMap<>();
-        body.put("account_id", finalAccountId);
-        body.put("user_action_set_id", actionSetId);
         body.put("actions", List.of(action));
-        // ad_id: 传入广告ID用于归因匹配
-        if (bid.getAdId() != null && !bid.getAdId().isBlank()) {
-            body.put("ad_id", bid.getAdId());
-        }
 
         try {
             byte[] payload = objectMapper.writeValueAsBytes(body);
-            String payloadStr = new String(payload, java.nio.charset.StandardCharsets.UTF_8);
-            log.info("[Tencent] sending user_action: qk={}, actionType={}, baiduAct={}, url={}, body={}",
-                    payment.getQk(), actionType, payment.getAct(), url, payloadStr);
+            String payloadStr = new String(payload, StandardCharsets.UTF_8);
+            record.setRequestBody(payloadStr);
 
-            HttpRequest request = HttpRequest.newBuilder()
-                    .uri(URI.create(url))
-                    .POST(HttpRequest.BodyPublishers.ofByteArray(payload))
-                    .header("Content-Type", "application/json")
-                    .timeout(Duration.ofSeconds(15))
-                    .build();
+            log.info("[Tencent] callback上报: qk={}, actionType={}, baiduAct={}, url={}, body={}",
+                    payment.getQk(), actionType, payment.getAct(), callbackUrl, payloadStr);
 
+            HttpRequest request = buildCallbackRequest(callbackUrl, payload);
             HttpResponse<byte[]> response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray());
-            int status = response.statusCode();
-            String responseBody = truncate(new String(response.body()), 4096);
-            record.setStatus(status);
-            record.setResponseBody(responseBody);
 
-            // 解析响应判断是否成功
-            if (status >= 200 && status < 300) {
-                TencentResponse tencentResp = objectMapper.readValue(response.body(), TencentResponse.class);
-                record.setOk(tencentResp.isSuccess());
-                if (!tencentResp.isSuccess()) {
-                    record.setErrorMessage("tencent api error: code=" + tencentResp.getCode()
-                            + " msg=" + tencentResp.getMessage());
-                }
-            } else {
-                record.setOk(false);
-                record.setErrorMessage("HTTP status: " + status);
-            }
-
-            log.info("[Tencent] callback result: qk={}, actionType={}, status={}, ok={}, response={}",
-                    payment.getQk(), actionType, status, record.isOk(), responseBody);
+            handleResponse(record, response, payment.getQk(), actionType);
         } catch (Exception e) {
             record.setOk(false);
             record.setErrorMessage(e.getMessage());
-            log.warn("[Tencent] callback failed: qk={}, actionType={}, error={}",
+            log.warn("[Tencent] callback失败: qk={}, actionType={}, error={}",
                     payment.getQk(), actionType, e.getMessage());
         }
         return record;
     }
 
     /**
-     * 重试已有回调记录。
+     * 重试已有回调记录,使用存储的 callbackUrl 和 requestBody。
      */
     public MediaCallbackRecord retryCallback(MediaCallbackRecord original)
             throws IOException, InterruptedException {
@@ -194,51 +127,32 @@ public class TencentClient {
         retry.setAttempt(original.getAttempt() + 1);
         retry.setCreatedAt(Instant.now());
 
-        try {
-            log.info("[Tencent] retrying callback: dedupeKey={}, attempt={}",
-                    original.getDedupeKey(), retry.getAttempt());
+        String callbackUrl = original.getCallbackUrl();
+        String requestBody = original.getRequestBody();
 
-            // 重新构造请求(使用新的 timestamp 和 nonce)
-            long timestamp = Instant.now().getEpochSecond();
-            String nonce = UUID.randomUUID().toString().replace("-", "").substring(0, 32);
-            String currentToken = accessTokenSupplier.get();
-            if (currentToken == null || currentToken.isBlank()) {
-                retry.setOk(false);
-                retry.setErrorMessage("access_token is empty (check Redis key)");
-                return retry;
-            }
-            String url = String.format("%s/user_actions/add?access_token=%s&timestamp=%d&nonce=%s",
-                    apiBaseUrl, currentToken, timestamp, nonce);
-
-            // 需要从 original 解析出原始 body 重发
-            // 简化处理:直接用原始的 callbackUrl 重新发送一个空 body 触发
-            // 实际上重试需要存储原始 body,这里用 responseBody 字段暂存
-            HttpRequest request = HttpRequest.newBuilder()
-                    .uri(URI.create(original.getCallbackUrl()))
-                    .POST(HttpRequest.BodyPublishers.noBody())
-                    .header("Content-Type", "application/json")
-                    .timeout(Duration.ofSeconds(15))
-                    .build();
+        if (callbackUrl == null || callbackUrl.isBlank()) {
+            retry.setOk(false);
+            retry.setErrorMessage("callback URL 缺失, 无法重试");
+            return retry;
+        }
+        if (requestBody == null || requestBody.isBlank()) {
+            retry.setOk(false);
+            retry.setErrorMessage("requestBody 缺失, 无法重试");
+            return retry;
+        }
 
+        try {
+            log.info("[Tencent] retrying callback: dedupeKey={}, attempt={}, url={}",
+                    original.getDedupeKey(), retry.getAttempt(), callbackUrl);
+
+            byte[] payload = requestBody.getBytes(StandardCharsets.UTF_8);
+            HttpRequest request = buildCallbackRequest(callbackUrl, payload);
             HttpResponse<byte[]> response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray());
-            int status = response.statusCode();
-            retry.setStatus(status);
-            retry.setResponseBody(truncate(new String(response.body()), 4096));
 
-            if (status >= 200 && status < 300) {
-                TencentResponse tencentResp = objectMapper.readValue(response.body(), TencentResponse.class);
-                retry.setOk(tencentResp.isSuccess());
-                if (!tencentResp.isSuccess()) {
-                    retry.setErrorMessage("tencent api error: code=" + tencentResp.getCode()
-                            + " msg=" + tencentResp.getMessage());
-                }
-            } else {
-                retry.setOk(false);
-                retry.setErrorMessage("HTTP status: " + status);
-            }
+            handleResponse(retry, response, original.getQk(), "retry");
 
             log.info("[Tencent] retry result: dedupeKey={}, status={}, ok={}",
-                    original.getDedupeKey(), status, retry.isOk());
+                    original.getDedupeKey(), retry.getStatus(), retry.isOk());
         } catch (IOException | InterruptedException e) {
             retry.setOk(false);
             retry.setErrorMessage(e.getMessage());
@@ -249,6 +163,63 @@ public class TencentClient {
         return retry;
     }
 
+    // ─── 内部方法 ────────────────────────────────────────────────────────────
+
+    /**
+     * 构造 callback POST 请求,自动判断是否需要鉴权 header。
+     */
+    private HttpRequest buildCallbackRequest(String callbackUrl, byte[] payload) {
+        boolean needAuth = callbackUrl.contains("api.e.qq.com");
+
+        HttpRequest.Builder reqBuilder = HttpRequest.newBuilder()
+                .uri(URI.create(callbackUrl))
+                .POST(HttpRequest.BodyPublishers.ofByteArray(payload))
+                .header("Content-Type", "application/json")
+                .timeout(Duration.ofSeconds(15));
+
+        if (needAuth) {
+            String currentToken = accessTokenSupplier.get();
+            if (currentToken != null && !currentToken.isBlank()) {
+                long timestamp = Instant.now().getEpochSecond();
+                String nonce = UUID.randomUUID().toString().replace("-", "").substring(0, 32);
+                reqBuilder.header("access-token", currentToken);
+                reqBuilder.header("timestamp", String.valueOf(timestamp));
+                reqBuilder.header("nonce", nonce);
+            }
+        }
+        return reqBuilder.build();
+    }
+
+    /**
+     * 处理 HTTP 响应,统一设置 record 状态。
+     */
+    private void handleResponse(MediaCallbackRecord record, HttpResponse<byte[]> response,
+                                String qk, String actionType) {
+        int status = response.statusCode();
+        String responseBody = truncate(new String(response.body(), StandardCharsets.UTF_8), 4096);
+        record.setStatus(status);
+        record.setResponseBody(responseBody);
+
+        if (status >= 200 && status < 300) {
+            try {
+                TencentResponse tencentResp = objectMapper.readValue(response.body(), TencentResponse.class);
+                record.setOk(tencentResp.isSuccess());
+                if (!tencentResp.isSuccess()) {
+                    record.setErrorMessage("code=" + tencentResp.getCode() + " msg=" + tencentResp.getMessage());
+                }
+            } catch (Exception e) {
+                record.setOk(false);
+                record.setErrorMessage("解析响应失败: " + e.getMessage());
+            }
+        } else {
+            record.setOk(false);
+            record.setErrorMessage("HTTP status: " + status);
+        }
+
+        log.info("[Tencent] callback结果: qk={}, actionType={}, status={}, ok={}, response={}",
+                qk, actionType, status, record.isOk(), responseBody);
+    }
+
     // ─── 构造 UserAction ────────────────────────────────────────────────────
 
     private UserAction buildUserAction(BidRecord bid, PaymentInfo payment,
@@ -277,7 +248,6 @@ public class TencentClient {
             action.setActionParam(param);
         } else if ("CUSTOM".equals(actionType)) {
             UserAction.ActionParam param = new UserAction.ActionParam();
-            // 根据百度 act 设置 custom_action
             String customAction = switch (payment.getAct()) {
                 case 7 -> "key_behavior";
                 case 8 -> "7day_retention";
@@ -299,17 +269,16 @@ public class TencentClient {
         Map<String, String> params = bid.getMediaParams();
         if (params == null) return userId;
 
-        // 优先使用 MD5 哈希值(腾讯要求 hash_* 字段)
         String imei = firstNotBlank(params.get("imei_md5"), params.get("imei"));
         if (imei != null) userId.setHashImei(ensureMd5(imei));
 
         String idfa = firstNotBlank(params.get("idfa_md5"), params.get("idfa"));
         if (idfa != null) userId.setHashIdfa(ensureMd5(idfa));
 
-        String oaid = firstNotBlank(params.get("oaid_md5"), params.get("oaid"));
+        String oaid = firstNotBlank(params.get("hash_oaid"), params.get("oaid_md5"), params.get("oaid"));
         if (oaid != null) userId.setHashOaid(ensureMd5(oaid));
 
-        String androidId = firstNotBlank(params.get("android_id_md5"), params.get("android_id"));
+        String androidId = firstNotBlank(params.get("hash_android_id"), params.get("android_id_md5"), params.get("android_id"));
         if (androidId != null) userId.setHashAndroidId(ensureMd5(androidId));
 
         return userId;
@@ -332,20 +301,14 @@ public class TencentClient {
 
     // ─── 工具方法 ────────────────────────────────────────────────────────────
 
-    /**
-     * 确保值是 MD5 格式(32位十六进制)。
-     * 如果不是,则计算 MD5。
-     */
     private static String ensureMd5(String value) {
         if (value == null || value.isBlank()) return null;
-        // 如果已经是32位十六进制,直接返回
         if (value.length() == 32 && value.matches("[0-9a-fA-F]+")) {
             return value.toLowerCase();
         }
-        // 否则计算 MD5
         try {
             MessageDigest md = MessageDigest.getInstance("MD5");
-            byte[] hash = md.digest(value.getBytes(java.nio.charset.StandardCharsets.UTF_8));
+            byte[] hash = md.digest(value.getBytes(StandardCharsets.UTF_8));
             return HexFormat.of().formatHex(hash);
         } catch (Exception e) {
             return value;
@@ -370,6 +333,7 @@ public class TencentClient {
         copy.setMedia(src.getMedia());
         copy.setQk(src.getQk());
         copy.setCallbackUrl(src.getCallbackUrl());
+        copy.setRequestBody(src.getRequestBody());
         copy.setEventType(src.getEventType());
         copy.setEventTimeMs(src.getEventTimeMs());
         copy.setPurchase(src.getPurchase());

+ 2 - 2
src/main/resources/application-dev.yml

@@ -40,10 +40,10 @@ adx:
   tidb-password: "hcst@2025"
 
   # --- Task ---
-  skip-leader-election: true
+  skip-leader-election: false
 
   # --- Conversion Sync ---
-  conversion-sync-enabled: true
+  conversion-sync-enabled: false
   conversion-sync-interval: 30s
 
   # --- Callback Retry ---

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

@@ -15,6 +15,7 @@ adx:
 
   # --- Tencent API ---
   tencent-access-token-redis-key: "adq:access_token:78578478"
+  tencent-account-id: 78578478
   tencent-api-base-url: "https://api.e.qq.com/v3.0"
   tencent-android-user-action-set-id: 1222435591
   tencent-ios-user-action-set-id: 1222435768

+ 5 - 3
src/main/resources/mapper/MediaCallbackMapper.xml

@@ -15,17 +15,18 @@
         <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="insert">
         INSERT INTO tencent_media_callbacks (
             dedupe_key, media, qk, callback_url, event_type, event_time_ms, purchase,
-            status, ok, attempt, response_body, error_message, created_at
+            status, ok, attempt, response_body, error_message, request_body, created_at
         ) VALUES (
             #{dedupeKey}, #{media}, #{qk}, #{callbackUrl}, #{eventType}, #{eventTimeMs},
             #{purchase}, #{status}, #{ok}, #{attempt}, #{responseBody}, #{errorMessage},
-            #{createdAt}
+            #{requestBody}, #{createdAt}
         )
     </insert>
 
@@ -46,7 +47,8 @@
 
     <select id="selectPendingCallbacks" resultMap="mediaCallbackResultMap">
         SELECT m.dedupe_key, m.media, m.qk, m.callback_url, m.event_type, m.event_time_ms,
-               m.purchase, m.status, m.ok AS ok_val, m.attempt, m.response_body, m.error_message, m.created_at
+               m.purchase, m.status, m.ok AS ok_val, m.attempt, m.response_body, m.error_message,
+               m.request_body, m.created_at
         FROM tencent_media_callbacks m
         JOIN (
             SELECT dedupe_key, MAX(id) AS id