yumeng 3 недель назад
Родитель
Сommit
b3c55c7f8a
55 измененных файлов с 31465 добавлено и 0 удалено
  1. 558 0
      TEST_PLAN.md
  2. 26377 0
      logs/tencent-baidu-tracking.log
  3. 105 0
      pom.xml
  4. 374 0
      src/main/java/com/adx/tencent/AppConfiguration.java
  5. 16 0
      src/main/java/com/adx/tencent/Application.java
  6. 110 0
      src/main/java/com/adx/tencent/baidu/AdxClient.java
  7. 115 0
      src/main/java/com/adx/tencent/baidu/AuctionPriceEncoder.java
  8. 126 0
      src/main/java/com/adx/tencent/baidu/ConversionClient.java
  9. 17 0
      src/main/java/com/adx/tencent/baidu/model/Adm.java
  10. 33 0
      src/main/java/com/adx/tencent/baidu/model/Bid.java
  11. 21 0
      src/main/java/com/adx/tencent/baidu/model/BidResponse.java
  12. 27 0
      src/main/java/com/adx/tencent/baidu/model/ConversionQuery.java
  13. 27 0
      src/main/java/com/adx/tencent/baidu/model/ConversionResponse.java
  14. 38 0
      src/main/java/com/adx/tencent/baidu/model/NormalizedBid.java
  15. 55 0
      src/main/java/com/adx/tencent/baidu/model/NormalizedBidResponse.java
  16. 39 0
      src/main/java/com/adx/tencent/baidu/model/PaymentInfo.java
  17. 10 0
      src/main/java/com/adx/tencent/baidu/model/SeatBid.java
  18. 20 0
      src/main/java/com/adx/tencent/baidu/model/TrackingResult.java
  19. 241 0
      src/main/java/com/adx/tencent/config/AppProperties.java
  20. 41 0
      src/main/java/com/adx/tencent/conversionsync/ConversionSyncRunner.java
  21. 209 0
      src/main/java/com/adx/tencent/conversionsync/ConversionSyncService.java
  22. 78 0
      src/main/java/com/adx/tencent/conversionsync/RetryService.java
  23. 62 0
      src/main/java/com/adx/tencent/httpapi/AdminController.java
  24. 66 0
      src/main/java/com/adx/tencent/httpapi/GlobalExceptionHandler.java
  25. 129 0
      src/main/java/com/adx/tencent/httpapi/MediaPlacement.java
  26. 356 0
      src/main/java/com/adx/tencent/httpapi/TrackingController.java
  27. 139 0
      src/main/java/com/adx/tencent/leader/LeaderElection.java
  28. 14 0
      src/main/java/com/adx/tencent/leader/Lease.java
  29. 275 0
      src/main/java/com/adx/tencent/storage/RedisHotStore.java
  30. 109 0
      src/main/java/com/adx/tencent/storage/RedisLock.java
  31. 253 0
      src/main/java/com/adx/tencent/storage/TiDBColdStore.java
  32. 25 0
      src/main/java/com/adx/tencent/storage/mapper/BidEventMapper.java
  33. 21 0
      src/main/java/com/adx/tencent/storage/mapper/ConversionMapper.java
  34. 33 0
      src/main/java/com/adx/tencent/storage/mapper/MediaCallbackMapper.java
  35. 14 0
      src/main/java/com/adx/tencent/storage/mapper/TrackingReportMapper.java
  36. 104 0
      src/main/java/com/adx/tencent/storage/model/BidRecord.java
  37. 47 0
      src/main/java/com/adx/tencent/storage/model/ConversionRecord.java
  38. 47 0
      src/main/java/com/adx/tencent/storage/model/MediaCallbackRecord.java
  39. 14 0
      src/main/java/com/adx/tencent/storage/model/QueuedEvent.java
  40. 26 0
      src/main/java/com/adx/tencent/storage/model/TrackingRecord.java
  41. 354 0
      src/main/java/com/adx/tencent/tencent/TencentClient.java
  42. 23 0
      src/main/java/com/adx/tencent/tencent/model/TencentResponse.java
  43. 76 0
      src/main/java/com/adx/tencent/tencent/model/UserAction.java
  44. 36 0
      src/main/java/com/adx/tencent/tencent/model/UserId.java
  45. 111 0
      src/main/java/com/adx/tencent/worker/ColdWorker.java
  46. 52 0
      src/main/resources/application-dev.yml
  47. 55 0
      src/main/resources/application-prod.yml
  48. 55 0
      src/main/resources/application-test.yml
  49. 27 0
      src/main/resources/application.yml
  50. 75 0
      src/main/resources/logback-spring.xml
  51. 32 0
      src/main/resources/mapper/BidEventMapper.xml
  52. 19 0
      src/main/resources/mapper/ConversionMapper.xml
  53. 65 0
      src/main/resources/mapper/MediaCallbackMapper.xml
  54. 9 0
      src/main/resources/mapper/TrackingReportMapper.xml
  55. 105 0
      src/main/resources/schema.sql

+ 558 - 0
TEST_PLAN.md

@@ -0,0 +1,558 @@
+# 腾讯-百度追踪回传系统 (tencent-baidu-tracking) 测试文档
+
+## 一、项目概述
+
+本系统是腾讯广告平台与百度ADX之间的追踪和转化回传中转服务器,主要功能:
+1. 接收腾讯曝光/点击监测请求
+2. 向百度ADX发起竞价请求并上报追踪事件
+3. 定时从百度拉取转化数据并回传给腾讯
+4. 失败回调自动重试
+
+**技术栈**:Java 17 / Spring Boot 3.2.5 / MyBatis 3.0.3 / Redis (Lettuce) / MySQL(TiDB)
+
+---
+
+## 二、测试环境要求
+
+| 组件 | 版本/配置 |
+|------|-----------|
+| JDK | 17+ |
+| Redis | 6.0+ (需支持 Stream) |
+| MySQL/TiDB | 5.7+ / TiDB 6.0+ |
+| Maven | 3.8+ |
+| Profile | `application-test.yml` |
+
+**环境准备**:
+- Redis 地址: 配置在 `application-test.yml` 的 `adx.redis-addr`
+- MySQL/TiDB: 配置在 `adx.tidb-url`,需预先创建数据库 `adx_tencent`
+- 系统自动建表(`TiDBColdStore.migrate()`)
+
+---
+
+## 三、API 接口测试
+
+### 3.1 健康检查
+
+| 项目 | 说明 |
+|------|------|
+| 端点 | `GET /health` |
+| 预期响应 | `200 OK`,Body: `{"ok": true}` |
+
+**测试用例**:
+
+| 编号 | 场景 | 请求 | 预期结果 |
+|------|------|------|----------|
+| HC-01 | 正常健康检查 | `GET /health` | 200, `{"ok":true}` |
+| HC-02 | 服务启动后立即检查 | `GET /health` | 200, `{"ok":true}` |
+
+---
+
+### 3.2 曝光监测接口
+
+| 项目 | 说明 |
+|------|------|
+| 端点 | `GET /tencent/impression` |
+| 必需参数 | 至少一个: `click_id`, `trace_id`, `callback`, `request_id`, `oaid`, `imei`, `idfa`, `caid` |
+| 成功响应 | `204 No Content` |
+
+**测试用例**:
+
+| 编号 | 场景 | 请求参数 | 预期结果 |
+|------|------|----------|----------|
+| IMP-01 | 正常曝光(click_id) | `?click_id=test123&platform=android` | 204 No Content |
+| IMP-02 | 正常曝光(oaid) | `?oaid=abc123&platform=android` | 204 No Content |
+| IMP-03 | 正常曝光(imei) | `?imei=def456&platform=android` | 204 No Content |
+| IMP-04 | 正常曝光(idfa+ios) | `?idfa=guid123&platform=ios` | 204 No Content |
+| IMP-05 | 缺少 trace id | `?platform=android` | 400, `{"error":"missing media trace id..."}` |
+| IMP-06 | 空 trace id | `?click_id=&platform=android` | 400, `{"error":"missing media trace id..."}` |
+| IMP-07 | 无 platform 且有默认配置 | `?click_id=test123` | 204(使用默认 baidu_app_id/tag_id) |
+| IMP-08 | 无 platform 且无默认配置 | `?click_id=test123`(未配置默认) | 400, `{"error":"missing baidu_tag_id or platform"}` |
+| IMP-09 | 指定 baidu_tag_id | `?click_id=x&baidu_tag_id=tag001` | 204 |
+| IMP-10 | 无效的 baidu_tag_id | `?click_id=x&baidu_tag_id=invalid` | 400, `{"error":"unknown baidu tag id: invalid"}` |
+| IMP-11 | 百度 ADX 返回空响应 | 模拟百度返回空 | 502, `{"error":"empty baidu ADX response"}` |
+| IMP-12 | 百度 ADX 网络超时 | 模拟网络超时 | 502, `{"error":"..."}` |
+
+**验证点**:
+- Redis 中应存在 bid 缓存键:`adx:tencent:bid:{qk}` 和 `adx:tencent:media:tencent:{traceId}`
+- Redis Stream `adx:tencent:events` 中应有 type=bid 的事件
+- 若有 showUrls,应能看到 impression tracking 的 Stream 事件
+
+---
+
+### 3.3 点击监测接口
+
+| 项目 | 说明 |
+|------|------|
+| 端点 | `GET /tencent/click` |
+| 必需参数 | 至少一个: `click_id`, `trace_id`, `callback`, `request_id`, `oaid`, `imei`, `idfa`, `caid` |
+| 成功响应 | `302 Redirect`(重定向到落地页/应用商店) |
+
+**测试用例**:
+
+| 编号 | 场景 | 请求参数 | 预期结果 |
+|------|------|----------|----------|
+| CLK-01 | 已有 bid 记录(重定向到 landingPage) | `?click_id=test123&platform=android` | 302, Location=landingPage |
+| CLK-02 | 已有 bid 记录(重定向到 appStoreLink) | `?click_id=test123&platform=ios` | 302, Location=appStoreLink |
+| CLK-03 | 无 bid 记录(临时竞价) | `?click_id=newId&platform=android` | 302 或 JSON |
+| CLK-04 | 缺少 trace id | `?platform=android` | 400, `{"error":"missing media trace id..."}` |
+| CLK-05 | 无重定向目标 | `?click_id=noDest&platform=android` | 200 JSON: `{"qk":"...","traceId":"...","tracking":[...]}` |
+| CLK-06 | 百度点击上报失败(不影响重定向) | 模拟点击 URL 返回 500 | 仍然 302 重定向 |
+| CLK-07 | 百度 ADX 竞价失败(无已有 bid) | 模拟 ADX 返回错误 | 502, `{"error":"..."}` |
+
+**验证点**:
+- 点击上报后 Redis Stream 应有 type=tracking, kind=click 的事件
+- 302 重定向的 Location 头应为有效 URL
+
+---
+
+### 3.4 管理接口
+
+#### 3.4.1 查询百度转化
+
+| 项目 | 说明 |
+|------|------|
+| 端点 | `POST /admin/conversions/query` |
+| Content-Type | `application/json` |
+| 请求体 | `{"date":"20260702","pageSize":1,"acts":[1,2]}` |
+
+**测试用例**:
+
+| 编号 | 场景 | 请求体 | 预期结果 |
+|------|------|--------|----------|
+| AQ-01 | 正常查询 | `{"date":"20260702","pageSize":1}` | 200, ConversionResponse |
+| AQ-02 | 缺少 date | `{"pageSize":1}` | 400, `{"error":"date is required"}` |
+| AQ-03 | pageSize 无效 | `{"date":"20260702","pageSize":0}` | 400, `{"error":"pageSize must be > 0"}` |
+| AQ-04 | 百度 API 返回错误码 | 模拟百度返回 code!=200 | 502, `{"error":"Baidu conversion API failed: ..."}` |
+
+#### 3.4.2 手动触发转化同步
+
+| 项目 | 说明 |
+|------|------|
+| 端点 | `POST /admin/conversions/sync` |
+| Content-Type | `application/json` |
+
+**测试用例**:
+
+| 编号 | 场景 | 请求体 | 预期结果 |
+|------|------|--------|----------|
+| AS-01 | 正常同步 | `{"date":"20260702","pageSize":1,"acts":[1]}` | 200, SyncResult |
+| AS-02 | syncer 未配置 | (TiDB 未配置时) | 503, `{"error":"conversion syncer is not configured"}` |
+| AS-03 | 百度无数据 | `{"date":"20260101","pageSize":1}` | 200, `fetched=0` |
+
+#### 3.4.3 手动触发回调重试
+
+| 项目 | 说明 |
+|------|------|
+| 端点 | `POST /admin/callbacks/retry` |
+| Content-Type | `application/json` |
+
+**测试用例**:
+
+| 编号 | 场景 | 请求体 | 预期结果 |
+|------|------|--------|----------|
+| AR-01 | 正常重试 | `{"limit":10}` | 200, RetryResult |
+| AR-02 | retryService 未配置 | (TiDB 未配置时) | 503, `{"error":"callback retryer is not configured"}` |
+| AR-03 | limit=0(使用默认值) | `{"limit":0}` | 200, RetryResult |
+| AR-04 | 无待重试记录 | `{"limit":10}` | 200, `{"fetched":0,"sent":0,"failed":0}` |
+
+---
+
+## 四、核心业务逻辑测试
+
+### 4.1 竞价价格编码 (AuctionPriceEncoder)
+
+| 编号 | 模式 | 输入 | 预期 |
+|------|------|------|------|
+| PE-01 | none | price=1000 | URL encode 的 "1000" |
+| PE-02 | aes-ecb | price=1000,eKey=16字节 | Base64 编码的 AES-ECB 加密值(URL encoded) |
+| PE-03 | aes-cbc | price=1000,eKey+iKey | Base64 编码的 AES-CBC 加密值(URL encoded) |
+| PE-04 | aes-cbc iKey 不足16字节 | eKey=xxx, iKey=short | 抛出 IllegalArgumentException |
+| PE-05 | 未知模式 | mode="unknown" | 抛出 IllegalArgumentException |
+| PE-06 | 自动推断(有 eKey+iKey) | mode="",提供 eKey+iKey | 使用 aes-cbc |
+| PE-07 | 自动推断(仅 eKey) | mode="",仅提供 eKey | 使用 aes-ecb |
+| PE-08 | 自动推断(无 key) | mode="",无 key | 使用 none |
+
+### 4.2 百度转化签名 (ConversionClient)
+
+| 编号 | 场景 | 验证 |
+|------|------|------|
+| CS-01 | 签名正确性 | `md5(timestamp + secret + randomStr)` 结果为32位十六进制 |
+| CS-02 | 相同输入产生相同签名 | 固定 timestamp/secret/randomStr 验证一致性 |
+| CS-03 | 随机字符串唯一性 | 两次 `newRandomString()` 结果不同 |
+
+### 4.3 MediaPlacement 解析
+
+| 编号 | 场景 | 输入 params | 预期结果 |
+|------|------|-------------|----------|
+| MP-01 | 指定 baidu_tag_id(匹配 platform 配置) | `{baidu_tag_id: "tag1"}` | Resolved(platform, appId, "tag1") |
+| MP-02 | 指定 platform=android | `{platform: "android"}` | Resolved("android", androidAppId, androidTagId) |
+| MP-03 | 指定 platform=ios | `{platform: "ios"}` | Resolved("ios", iosAppId, iosTagId) |
+| MP-04 | platform 别名("安卓") | `{platform: "安卓"}` | 归一化为 "android" |
+| MP-05 | platform 别名("1"=ios) | `{platform: "1"}` | 归一化为 "ios" |
+| MP-06 | 无 tag/platform 但有默认值 | `{}` | 使用默认 baiduAppId/baiduTagId |
+| MP-07 | 多 tagId 未指定具体 tag | `{platform: "android"}`(配置多个 tagId) | 抛出 IllegalArgumentException |
+| MP-08 | 未知 tag_id | `{baidu_tag_id: "unknown"}` | 抛出 IllegalArgumentException |
+
+### 4.4 腾讯 Act 映射 (TencentClient)
+
+| 编号 | 百度 Act | 预期腾讯 ActionType |
+|------|----------|---------------------|
+| AM-01 | 1 | ACTIVATE_APP |
+| AM-02 | 2 | PURCHASE |
+| AM-03 | 3 | REGISTER |
+| AM-04 | 4 | ONE_DAY_RETENTION |
+| AM-05 | 5 | COMPLETE_ORDER |
+| AM-06 | 6 | START_APP |
+| AM-07 | 7 | CUSTOM (custom_action=key_behavior) |
+| AM-08 | 8 | CUSTOM (custom_action=7day_retention) |
+| AM-09 | 9 | CUSTOM (custom_action=3day_retention) |
+| AM-10 | 99(未映射) | 抛出 IllegalArgumentException |
+
+### 4.5 设备标识 MD5 处理 (TencentClient.ensureMd5)
+
+| 编号 | 输入 | 预期 |
+|------|------|------|
+| MD-01 | 已是32位十六进制 "abcdef1234567890abcdef1234567890" | 原值(小写化) |
+| MD-02 | 原始 IMEI "123456789012345" | 计算 MD5 |
+| MD-03 | null | null |
+| MD-04 | 空字符串 | null |
+| MD-05 | 大写32位十六进制 | 转小写返回 |
+
+---
+
+## 五、数据存储测试
+
+### 5.1 Redis 热存储 (RedisHotStore)
+
+| 编号 | 场景 | 操作 | 验证 |
+|------|------|------|------|
+| RH-01 | 记录 Bid | `recordBid(record)` | 1. `adx:tencent:bid:{qk}` 存在 2. `adx:tencent:media:tencent:{traceId}` 存在 3. TTL=24h 4. Stream 有 type=bid 事件 |
+| RH-02 | 按 qk 查找 Bid | `findBidByQk(qk)` | 返回正确的 BidRecord |
+| RH-03 | 按 mediaTrace 查找 Bid | `findBidByMediaTrace("tencent", traceId)` | 返回正确的 BidRecord |
+| RH-04 | 查找不存在的 Bid | `findBidByQk("nonexist")` | 返回 null |
+| RH-05 | Bid TTL 过期 | 等待 TTL 后查询 | 返回 null |
+| RH-06 | 记录 Tracking | `recordTracking(record)` | Stream 有 type=tracking 事件 |
+| RH-07 | 确保消费者组 | `ensureGroup("test-group")` | 不抛异常,幂等 |
+| RH-08 | 读取 Stream 消息 | `read(group, consumer, 10)` | 返回待消费的事件列表 |
+| RH-09 | ACK 消息 | `ack(group, ids)` | 消息从 pending 列表移除 |
+| RH-10 | Trim Stream | `trim(maxLen)` | Stream 长度不超过 maxLen |
+| RH-11 | ClaimStale | 模拟超时未 ACK 消息 | `claimStale` 能获取到超时消息 |
+
+### 5.2 TiDB 冷存储 (TiDBColdStore)
+
+| 编号 | 场景 | 操作 | 验证 |
+|------|------|------|------|
+| TC-01 | 自动建表 | `migrate()` | 4张表创建成功 |
+| TC-02 | 保存 Bid(新记录) | `saveBid(record)` | ad_bid_events 表有记录 |
+| TC-03 | 保存 Bid(重复 qk) | 两次 `saveBid(sameQk)` | 更新而非报错(UPSERT) |
+| TC-04 | qk 为空抛异常 | `saveBid(qk=null)` | 抛出 IllegalArgumentException |
+| TC-05 | 保存 Tracking | `saveTracking(record)` | tracking_reports 表有记录 |
+| TC-06 | 保存 Conversion(新记录) | `saveConversion(record)` | baidu_conversions 表有记录 |
+| TC-07 | 保存 Conversion(重复 dedupeKey) | 两次相同 dedupeKey | 更新而非报错 |
+| TC-08 | 保存 MediaCallback | `saveMediaCallback(record)` | media_callbacks 表有记录 |
+| TC-09 | 查询成功回调存在 | `successfulCallbackExists(key)` | 有 ok=1 记录返回 true |
+| TC-10 | 查询成功回调不存在 | `successfulCallbackExists(key)` | 无 ok=1 记录返回 false |
+| TC-11 | 查询待重试回调 | `pendingMediaCallbacks("tencent", 10)` | 返回 ok=0 且无对应 ok=1 的记录 |
+| TC-12 | 空 dedupeKey 不查询 | `successfulCallbackExists("")` | 返回 false |
+
+---
+
+## 六、后台任务测试
+
+### 6.1 ColdWorker(冷路径数据持久化)
+
+| 编号 | 场景 | 验证 |
+|------|------|------|
+| CW-01 | 正常消费 bid 事件 | Stream 中 bid 事件被消费,TiDB ad_bid_events 有新记录 |
+| CW-02 | 正常消费 tracking 事件 | Stream 中 tracking 事件被消费,TiDB tracking_reports 有新记录 |
+| CW-03 | 未知事件类型 | Stream 中有 type=unknown 事件 → 抛异常,该批次不 ACK |
+| CW-04 | TiDB 写入失败 | 模拟 TiDB 不可用 → 抛异常,消息不 ACK(可重试) |
+| CW-05 | 空 Stream | 无消息时返回 0 |
+| CW-06 | 超时消息重新领取 | 模拟消费者崩溃 → 超过 pendingIdle 后被其他 worker claim |
+| CW-07 | Stream Trim | 消费后 Stream 长度被裁剪 |
+
+### 6.2 ConversionSyncRunner(转化同步)
+
+| 编号 | 场景 | 验证 |
+|------|------|------|
+| SR-01 | 正常同步(有匹配的 tencent bid) | fetched > 0, matched > 0, sent > 0 |
+| SR-02 | 无匹配 bid | fetched > 0, skipped > 0, matched=0 |
+| SR-03 | bid 不属于 tencent | 记录到 baidu_conversions 但 skipped |
+| SR-04 | 幂等 - 已成功发送过 | alreadySent > 0,不重复调用腾讯 |
+| SR-05 | 腾讯回传失败 | failed > 0,media_callbacks 记录 ok=0 |
+| SR-06 | 分页拉取多页 | 正确遍历多页数据 |
+| SR-07 | 最大页数限制(100页) | 超过100页时停止 |
+| SR-08 | dateOffsetDays 配置 | offset=0 查当天,offset=-1 查昨天 |
+
+### 6.3 RetryService(回调重试)
+
+| 编号 | 场景 | 验证 |
+|------|------|------|
+| RS-01 | 正常重试成功 | fetched > 0, sent > 0, media_callbacks 新增 ok=1 记录 |
+| RS-02 | 重试仍失败 | failed > 0, attempt 递增, 新记录 ok=0 |
+| RS-03 | 无待重试记录 | fetched=0, sent=0, failed=0 |
+| RS-04 | 使用默认 limit | limit=0 时使用配置的默认值 |
+| RS-05 | 网络异常 | failed > 0, errorMessage 记录异常信息 |
+
+### 6.4 Leader Election(分布式选举)
+
+| 编号 | 场景 | 验证 |
+|------|------|------|
+| LE-01 | 单实例获取锁 | 成功获取锁并执行任务 |
+| LE-02 | 多实例竞选 | 仅一个实例获得锁,其他等待 |
+| LE-03 | 锁续约成功 | 任务执行期间锁不过期 |
+| LE-04 | 续约失败(锁丢失) | jobStop.isStopped() = true,任务停止 |
+| LE-05 | 任务完成释放锁 | 任务结束后其他实例可获得锁 |
+| LE-06 | 外部停止信号 | stopped=true 时退出循环 |
+| LE-07 | skip-leader-election=true | 跳过选举直接运行任务 |
+
+---
+
+## 七、异常处理测试
+
+### 7.1 全局异常处理器 (GlobalExceptionHandler)
+
+| 编号 | 异常类型 | 预期 HTTP 状态码 | 预期响应格式 |
+|------|----------|------------------|--------------|
+| EH-01 | ResponseStatusException(400, "bad") | 400 | `{"error":"bad"}` |
+| EH-02 | ResponseStatusException(503, "unavailable") | 503 | `{"error":"unavailable"}` |
+| EH-03 | IllegalArgumentException("invalid param") | 400 | `{"error":"invalid param"}` |
+| EH-04 | IOException("timeout") | 502 | `{"error":"timeout"}` |
+| EH-05 | InterruptedException | 502 | `{"error":"..."}` |
+| EH-06 | RuntimeException("unexpected") | 502 | `{"error":"unexpected"}` |
+| EH-07 | NullPointerException(message=null) | 502 | `{"error":"internal error"}` |
+
+---
+
+## 八、数据库表结构验证
+
+### 8.1 ad_bid_events
+
+| 字段 | 类型 | 约束 | 验证 |
+|------|------|------|------|
+| qk | VARCHAR(128) | UNIQUE NOT NULL | 唯一键,重复插入应更新 |
+| media | VARCHAR(64) | - | 值为 "tencent" |
+| media_trace_id | VARCHAR(255) | 索引 | 与 media 联合索引 |
+| req_id | VARCHAR(512) | - | 竞价请求 ID |
+| price | BIGINT UNSIGNED | - | 竞价价格(分) |
+| show_urls | JSON | - | JSON 数组 |
+| click_urls | JSON | - | JSON 数组 |
+| created_at | TIMESTAMP(3) | 索引 | 毫秒精度 |
+
+### 8.2 tracking_reports
+
+| 字段 | 类型 | 约束 | 验证 |
+|------|------|------|------|
+| qk | VARCHAR(128) | 联合索引 | 关联 bid |
+| kind | VARCHAR(32) | NOT NULL | "impression" 或 "click" |
+| ok | TINYINT(1) | NOT NULL | 0 或 1 |
+| tracking_url | TEXT | NOT NULL | 上报的 URL |
+
+### 8.3 baidu_conversions
+
+| 字段 | 类型 | 约束 | 验证 |
+|------|------|------|------|
+| dedupe_key | VARCHAR(255) | UNIQUE | 幂等键,格式: `{media}:{qk}:{date}:{deviceId}:{act}` |
+| act | INT | 联合索引 | 百度行为类型码 |
+| payment | DOUBLE | - | 支付金额 |
+
+### 8.4 media_callbacks
+
+| 字段 | 类型 | 约束 | 验证 |
+|------|------|------|------|
+| dedupe_key | VARCHAR(255) | 索引 | 回调去重键 |
+| ok | TINYINT(1) | NOT NULL | 回传是否成功 |
+| attempt | INT | DEFAULT 1 | 重试次数递增 |
+| event_type | INT | NOT NULL | 对应百度 act |
+| error_message | TEXT | - | 失败时记录错误信息 |
+
+---
+
+## 九、端到端集成测试
+
+### 9.1 完整曝光 → 点击 → 转化回传流程
+
+```
+步骤 1: 发送曝光请求
+  GET /tencent/impression?click_id=e2e_test_001&platform=android&oaid=test_oaid_123
+
+  验证:
+  ✓ 返回 204
+  ✓ Redis 存在 bid 缓存
+  ✓ Redis Stream 有 bid 事件
+
+步骤 2: 发送点击请求
+  GET /tencent/click?click_id=e2e_test_001&platform=android&oaid=test_oaid_123
+
+  验证:
+  ✓ 返回 302 重定向
+  ✓ Redis Stream 有 click tracking 事件
+
+步骤 3: 等待 ColdWorker 消费
+  验证:
+  ✓ ad_bid_events 表有记录
+  ✓ tracking_reports 表有 impression 和 click 记录
+
+步骤 4: 触发转化同步
+  POST /admin/conversions/sync
+  Body: {"date":"20260702","pageSize":1,"acts":[1]}
+
+  验证:
+  ✓ 百度转化数据被拉取
+  ✓ 匹配到腾讯 bid 后回传成功
+  ✓ baidu_conversions 表有记录
+  ✓ media_callbacks 表有 ok=1 的记录
+
+步骤 5: 幂等验证 - 再次同步
+  POST /admin/conversions/sync (相同参数)
+
+  验证:
+  ✓ alreadySent > 0,不重复回传
+```
+
+### 9.2 回调失败 → 自动重试流程
+
+```
+步骤 1: 模拟腾讯回传失败
+  构造一条 media_callbacks 记录 (ok=0, attempt=1)
+
+步骤 2: 触发回调重试
+  POST /admin/callbacks/retry
+  Body: {"limit":10}
+
+  验证:
+  ✓ 获取到待重试记录
+  ✓ 重试后生成新的 callback 记录
+  ✓ attempt = 2
+```
+
+---
+
+## 十、性能与压力测试
+
+### 10.1 API 性能基线
+
+| 接口 | 目标 QPS | 目标 P99 延迟 | 备注 |
+|------|----------|--------------|------|
+| GET /health | >10000 | <10ms | 无外部依赖 |
+| GET /tencent/impression | >500 | <200ms | 含百度竞价+Redis 写入 |
+| GET /tencent/click | >500 | <200ms | 含 Redis 读取+百度上报 |
+
+### 10.2 后台任务性能
+
+| 任务 | 指标 | 目标 |
+|------|------|------|
+| ColdWorker | 消费速率 | >1000 events/s |
+| ConversionSync | 处理速率 | >100 records/batch(并发=10) |
+| RetryService | 重试速率 | >50 records/batch |
+
+### 10.3 Redis 压力
+
+- bid 缓存 key 数量: 验证 TTL 自动清理
+- Stream 长度: 验证 XTRIM 保持在配置的 maxLen 以内
+- 内存使用: 监控 Redis 内存占用稳定性
+
+---
+
+## 十一、安全测试
+
+| 编号 | 场景 | 验证 |
+|------|------|------|
+| SEC-01 | 百度签名校验 | MD5 签名格式正确、不可伪造 |
+| SEC-02 | Access Token 保护 | 生产环境 token 从环境变量注入,不硬编码 |
+| SEC-03 | Redis 锁安全性 | Lua 脚本保证仅持有者能释放/续约 |
+| SEC-04 | SQL 注入防护 | MyBatis 参数化查询,无 SQL 拼接 |
+| SEC-05 | 设备标识脱敏 | 腾讯回传使用 MD5 哈希而非明文 |
+
+---
+
+## 十二、配置测试
+
+| 编号 | 场景 | 验证 |
+|------|------|------|
+| CFG-01 | dev 环境启动 | skip-leader-election=true,sync/retry 禁用 |
+| CFG-02 | test 环境启动 | sync/retry 启用,短间隔 |
+| CFG-03 | prod 环境启动 | 环境变量注入,leader election 启用 |
+| CFG-04 | TiDB 未配置 | ColdStore=null,相关功能降级但不崩溃 |
+| CFG-05 | Redis 连接失败 | 启动失败,打印明确错误信息 |
+| CFG-06 | 百度 ADX endpoint 为空 | 竞价时抛 IllegalStateException |
+| CFG-07 | bid TTL 配置 | 默认 24h,可自定义 |
+
+---
+
+## 十三、测试执行方式
+
+### 单元测试(推荐 Mock)
+
+```bash
+# 运行全部测试
+mvn test -Ptest
+
+# 运行指定类
+mvn test -Dtest=AuctionPriceEncoderTest
+
+# 运行指定方法
+mvn test -Dtest=MediaPlacementTest#testResolvePlatformAndroid
+```
+
+### 集成测试(需要外部依赖)
+
+```bash
+# 需要 Redis + MySQL 环境
+mvn verify -Ptest -Dspring.profiles.active=test
+```
+
+### 手动接口测试(cURL)
+
+```bash
+# 健康检查
+curl -s http://localhost:8787/health
+
+# 曝光监测
+curl -s -o /dev/null -w "%{http_code}" \
+  "http://localhost:8787/tencent/impression?click_id=test001&platform=android&oaid=test_oaid"
+
+# 点击监测
+curl -s -o /dev/null -w "%{http_code}" -L \
+  "http://localhost:8787/tencent/click?click_id=test001&platform=android"
+
+# 查询转化
+curl -s -X POST http://localhost:8787/admin/conversions/query \
+  -H "Content-Type: application/json" \
+  -d '{"date":"20260702","pageSize":1,"acts":[1,2]}'
+
+# 触发同步
+curl -s -X POST http://localhost:8787/admin/conversions/sync \
+  -H "Content-Type: application/json" \
+  -d '{"date":"20260702","pageSize":1,"acts":[1]}'
+
+# 触发重试
+curl -s -X POST http://localhost:8787/admin/callbacks/retry \
+  -H "Content-Type: application/json" \
+  -d '{"limit":10}'
+```
+
+---
+
+## 十四、测试覆盖率目标
+
+| 模块 | 目标覆盖率 | 重点 |
+|------|-----------|------|
+| httpapi (Controller) | ≥80% | 参数校验、异常处理 |
+| baidu (AdxClient, ConversionClient) | ≥75% | 签名、加密、HTTP 调用 |
+| tencent (TencentClient) | ≥80% | act 映射、设备标识处理、回传逻辑 |
+| conversionsync | ≥85% | 核心业务逻辑 |
+| storage (Redis/TiDB) | ≥70% | CRUD、幂等性 |
+| leader | ≥60% | 锁获取/释放/续约 |
+| worker | ≥75% | 事件消费、错误处理 |
+
+---
+
+## 十五、已知限制与注意事项
+
+1. **重试限制**:RetryService 当前实现中,重试使用原始 callbackUrl 发送空 body,正式环境需验证腾讯 API 是否接受此方式
+2. **并发安全**:ConversionSyncService 使用 10 并发线程处理支付记录,需确保 Redis/TiDB 连接池够用
+3. **时钟依赖**:转化同步的 `dateOffsetDays` 依赖系统时钟,需确保服务器时间同步
+4. **百度 ADX 模拟**:单元测试需 Mock 百度 ADX HTTP 响应(推荐 WireMock 或 MockWebServer)
+5. **数据清理**:集成测试后需清理测试数据,避免影响后续测试
+6. **Adm 字段拼写**:百度原始字段 `landdingPage`(双 d),需注意 JSON 序列化/反序列化一致性

Разница между файлами не показана из-за своего большого размера
+ 26377 - 0
logs/tencent-baidu-tracking.log


+ 105 - 0
pom.xml

@@ -0,0 +1,105 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+
+    <groupId>com.adx</groupId>
+    <artifactId>tencent-baidu-tracking</artifactId>
+    <version>1.0.0</version>
+    <name>tencent-baidu-tracking</name>
+    <description>Tencent-Baidu ADX Tracking Server</description>
+
+    <properties>
+        <java.version>17</java.version>
+        <maven.compiler.source>${java.version}</maven.compiler.source>
+        <maven.compiler.target>${java.version}</maven.compiler.target>
+        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+        <spring-boot.version>3.2.5</spring-boot.version>
+    </properties>
+
+    <profiles>
+        <profile>
+            <id>dev</id>
+            <activation><activeByDefault>true</activeByDefault></activation>
+            <properties><profileActive>dev</profileActive></properties>
+        </profile>
+        <profile>
+            <id>prod</id>
+            <properties><profileActive>prod</profileActive></properties>
+        </profile>
+        <profile>
+            <id>test</id>
+            <properties><profileActive>test</profileActive></properties>
+        </profile>
+    </profiles>
+
+    <dependencyManagement>
+        <dependencies>
+            <dependency>
+                <groupId>org.springframework.boot</groupId>
+                <artifactId>spring-boot-dependencies</artifactId>
+                <version>${spring-boot.version}</version>
+                <type>pom</type>
+                <scope>import</scope>
+            </dependency>
+        </dependencies>
+    </dependencyManagement>
+
+    <dependencies>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-web</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-data-redis</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.mybatis.spring.boot</groupId>
+            <artifactId>mybatis-spring-boot-starter</artifactId>
+            <version>3.0.3</version>
+        </dependency>
+        <dependency>
+            <groupId>com.mysql</groupId>
+            <artifactId>mysql-connector-j</artifactId>
+            <scope>runtime</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-configuration-processor</artifactId>
+            <optional>true</optional>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-test</artifactId>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
+
+    <build>
+        <resources>
+            <resource>
+                <directory>src/main/resources</directory>
+                <filtering>true</filtering>
+            </resource>
+        </resources>
+        <plugins>
+            <plugin>
+                <groupId>org.springframework.boot</groupId>
+                <artifactId>spring-boot-maven-plugin</artifactId>
+                <version>${spring-boot.version}</version>
+                <executions><execution><goals><goal>repackage</goal></goals></execution></executions>
+            </plugin>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-resources-plugin</artifactId>
+                <version>3.3.1</version>
+                <configuration>
+                    <delimiters><delimiter>@</delimiter></delimiters>
+                    <useDefaultDelimiters>false</useDefaultDelimiters>
+                </configuration>
+            </plugin>
+        </plugins>
+    </build>
+</project>

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

@@ -0,0 +1,374 @@
+package com.adx.tencent;
+
+import com.adx.tencent.baidu.AdxClient;
+import com.adx.tencent.baidu.AuctionPriceEncoder;
+import com.adx.tencent.baidu.ConversionClient;
+import com.adx.tencent.config.AppProperties;
+import com.adx.tencent.conversionsync.ConversionSyncRunner;
+import com.adx.tencent.conversionsync.ConversionSyncService;
+import com.adx.tencent.conversionsync.RetryService;
+import com.adx.tencent.httpapi.MediaPlacement;
+import com.adx.tencent.leader.LeaderElection;
+import com.adx.tencent.tencent.TencentClient;
+import com.adx.tencent.storage.RedisHotStore;
+import com.adx.tencent.storage.RedisLock;
+import com.adx.tencent.storage.TiDBColdStore;
+import com.adx.tencent.worker.ColdWorker;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.ibatis.session.SqlSessionFactory;
+import org.mybatis.spring.SqlSessionFactoryBean;
+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.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
+import org.springframework.data.redis.connection.RedisConnectionFactory;
+import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
+import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.stereotype.Component;
+
+import org.springframework.lang.Nullable;
+
+import javax.sql.DataSource;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * 腾讯-百度回传项目 Bean 组装配置类。
+ * 负责所有核心 Bean 的创建和后台任务(Worker、Leader 选举、定时同步等)。
+ */
+@Configuration
+public class AppConfiguration {
+
+    private static final Logger log = LoggerFactory.getLogger(AppConfiguration.class);
+
+    @Autowired private AppProperties props;
+
+    // ─── Redis ───────────────────────────────────────────────────────────────
+
+    @Bean
+    public RedisConnectionFactory redisConnectionFactory() {
+        String addr = props.getRedisAddr();
+        String[] parts = addr.split(":", 2);
+        String host = parts[0];
+        int port = parts.length > 1 ? Integer.parseInt(parts[1]) : 6379;
+
+        RedisStandaloneConfiguration cfg = new RedisStandaloneConfiguration(host, port);
+        cfg.setDatabase(props.getRedisDb());
+        if (props.getRedisUsername() != null && !props.getRedisUsername().isBlank()) {
+            cfg.setUsername(props.getRedisUsername());
+        }
+        if (props.getRedisPassword() != null && !props.getRedisPassword().isBlank()) {
+            cfg.setPassword(props.getRedisPassword());
+        }
+        return new LettuceConnectionFactory(cfg);
+    }
+
+    @Bean
+    public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory factory) {
+        return new StringRedisTemplate(factory);
+    }
+
+    @Bean
+    public RedisHotStore redisHotStore(StringRedisTemplate redis, ObjectMapper objectMapper) {
+        return new RedisHotStore(redis, objectMapper,
+                "adx:tencent:",
+                props.getRedisStream(),
+                props.getBidTtl());
+    }
+
+    @Bean
+    public RedisLock redisLock(StringRedisTemplate redis) {
+        return new RedisLock(redis);
+    }
+
+    @Bean
+    public LeaderElection leaderElection(RedisLock redisLock) {
+        return new LeaderElection(redisLock);
+    }
+
+    // ─── TiDB(可选)────────────────────────────────────────────────────────
+
+    @Bean
+    public TiDBColdStore tiDBColdStore(ObjectMapper objectMapper) {
+        String url = props.getTidbUrl();
+        if (url == null || url.isBlank()) {
+            log.info("TiDB URL is empty; cold storage disabled");
+            return null;
+        }
+        try {
+            DataSource ds = createDataSource(url, props.getTidbUsername(), props.getTidbPassword());
+            SqlSessionFactory sqlSessionFactory = createSqlSessionFactory(ds);
+            TiDBColdStore store = new TiDBColdStore(sqlSessionFactory, objectMapper);
+            store.migrate();
+            log.info("TiDB cold store initialized and migrated");
+            return store;
+        } catch (Exception e) {
+            log.warn("Failed to open TiDB: {}; cold storage disabled", e.getMessage(), e);
+            return null;
+        }
+    }
+
+    private SqlSessionFactory createSqlSessionFactory(DataSource dataSource) throws Exception {
+        SqlSessionFactoryBean factoryBean = new SqlSessionFactoryBean();
+        factoryBean.setDataSource(dataSource);
+        factoryBean.setMapperLocations(
+                new PathMatchingResourcePatternResolver().getResources("classpath:mapper/*.xml"));
+        return factoryBean.getObject();
+    }
+
+    private DataSource createDataSource(String jdbcUrl, String username, String password) {
+        org.springframework.jdbc.datasource.DriverManagerDataSource ds =
+                new org.springframework.jdbc.datasource.DriverManagerDataSource();
+        ds.setDriverClassName("com.mysql.cj.jdbc.Driver");
+        ds.setUrl(jdbcUrl);
+        if (username != null && !username.isBlank()) ds.setUsername(username);
+        if (password != null && !password.isBlank()) ds.setPassword(password);
+        return ds;
+    }
+
+    // ─── Baidu 客户端 ────────────────────────────────────────────────────────
+
+    @Bean
+    public AuctionPriceEncoder auctionPriceEncoder() {
+        return AuctionPriceEncoder.create(
+                props.getBaiduAuctionPriceEncryption(),
+                props.getBaiduAuctionPriceEKey(),
+                props.getBaiduAuctionPriceIKey());
+    }
+
+    @Bean
+    public AdxClient adxClient(AuctionPriceEncoder encoder, ObjectMapper objectMapper) {
+        return new AdxClient(props.getBaiduAdxEndpoint(), encoder, objectMapper);
+    }
+
+    @Bean
+    public ConversionClient conversionClient(ObjectMapper objectMapper) {
+        return new ConversionClient(
+                props.getBaiduConversionBaseUrl(),
+                props.getBaiduCustomerName(),
+                props.getBaiduSecret(),
+                objectMapper);
+    }
+
+    // ─── Tencent 客户端 + Sync 服务 ──────────────────────────────────────────
+
+    @Bean
+    public TencentClient tencentClient(ObjectMapper objectMapper) {
+        return new TencentClient(
+                props.getTencentApiBaseUrl(),
+                props.getTencentAccessToken(),
+                props.getTencentAccountId(),
+                props.getTencentAndroidUserActionSetId(),
+                props.getTencentIosUserActionSetId(),
+                props.getTencentActMap(),
+                objectMapper);
+    }
+
+    @Bean
+    public ConversionSyncService conversionSyncService(ConversionClient baiduClient,
+                                                         RedisHotStore hotStore,
+                                                         TencentClient tencentClient,
+                                                         @Nullable TiDBColdStore coldStore) {
+        if (coldStore == null) return null;
+        return new ConversionSyncService(baiduClient, hotStore, tencentClient, coldStore);
+    }
+
+    @Bean
+    public ConversionSyncRunner conversionSyncRunner(@Nullable ConversionSyncService syncer) {
+        if (syncer == null) return null;
+        return new ConversionSyncRunner(syncer,
+                props.getConversionSyncDateOffsetDays(),
+                props.getConversionSyncPageSize(),
+                props.getConversionSyncActs());
+    }
+
+    @Bean
+    public RetryService retryService(@Nullable TiDBColdStore coldStore, TencentClient tencentClient) {
+        if (coldStore == null) return null;
+        return new RetryService(coldStore, tencentClient, props.getCallbackRetryLimit());
+    }
+
+    // ─── ColdWorker ─────────────────────────────────────────────────────────
+
+    @Bean
+    public ColdWorker coldWorker(RedisHotStore hotStore, @Nullable TiDBColdStore coldStore,
+                                  ObjectMapper objectMapper) {
+        if (coldStore == null) return null;
+        return new ColdWorker(hotStore, coldStore,
+                props.getWorkerGroup(), props.getWorkerConsumer(),
+                props.getWorkerBatch(), null,
+                props.getRedisStreamMaxLen(), objectMapper);
+    }
+
+    // ─── MediaPlacement(腾讯)───────────────────────────────────────────────
+
+    @Bean
+    public MediaPlacement tencentPlacement() {
+        MediaPlacement p = new MediaPlacement();
+        p.setBaiduMediaId(props.getBaiduMediaId());
+        p.setBaiduAppId(props.getBaiduAppId());
+        p.setBaiduTagId(props.getBaiduTagId());
+        p.setBidFloor(props.getBaiduBidFloor());
+        p.setActionTypes(List.of(props.getBaiduActionType()));
+
+        Map<String, MediaPlacement.BaiduAppPlacement> platforms = new HashMap<>();
+        if (props.getBaiduAndroidAppId() != null && !props.getBaiduAndroidAppId().isBlank()) {
+            MediaPlacement.BaiduAppPlacement android = new MediaPlacement.BaiduAppPlacement();
+            android.setAppId(props.getBaiduAndroidAppId());
+            android.setTagIds(props.getBaiduAndroidTagIds());
+            platforms.put("android", android);
+        }
+        if (props.getBaiduIosAppId() != null && !props.getBaiduIosAppId().isBlank()) {
+            MediaPlacement.BaiduAppPlacement ios = new MediaPlacement.BaiduAppPlacement();
+            ios.setAppId(props.getBaiduIosAppId());
+            ios.setTagIds(props.getBaiduIosTagIds());
+            platforms.put("ios", ios);
+        }
+        if (!platforms.isEmpty()) p.setPlatforms(platforms);
+        return p;
+    }
+
+    // ─── 后台任务调度 ────────────────────────────────────────────────────────
+
+    @Component
+    static class BackgroundTasks implements ApplicationRunner {
+
+        private static final Logger taskLog = LoggerFactory.getLogger(BackgroundTasks.class);
+
+        @Autowired private AppProperties props;
+        @Autowired(required = false) private ColdWorker coldWorker;
+        @Autowired(required = false) private ConversionSyncRunner conversionSyncRunner;
+        @Autowired(required = false) private RetryService retryService;
+        @Autowired(required = false) 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) {
+            // 冷路径 Worker
+            if (coldWorker != null) {
+                executor.submit(() -> {
+                    while (!stopped.get()) {
+                        try {
+                            coldWorker.runOnce();
+                        } catch (Exception e) {
+                            taskLog.error("cold worker: {}", e.getMessage(), e);
+                            sleep(1000);
+                        }
+                    }
+                });
+                taskLog.info("Cold worker started");
+            }
+
+            // 转化同步
+            if (conversionSyncRunner != null && props.isConversionSyncEnabled()) {
+                if (props.isSkipLeaderElection()) {
+                    executor.submit(() -> runConversionSync(() -> stopped.get()));
+                    taskLog.info("Conversion sync started (skip leader election)");
+                } else if (leaderElection != null) {
+                    executor.submit(() ->
+                        leaderElection.run(
+                            "adx:lock:tencent:conversion-sync",
+                            props.getTaskLockTtl(), props.getTaskLockRenewInterval(), props.getTaskLockRetryInterval(),
+                            stopped::get,
+                            (jobStop) -> runConversionSync(jobStop),
+                            e -> taskLog.error("tencent conversion sync leader: {}", e.getMessage(), e)
+                        )
+                    );
+                    taskLog.info("Conversion sync leader election started");
+                }
+            } else {
+                taskLog.warn("Conversion sync NOT started: runner={}, enabled={}",
+                        conversionSyncRunner != null, props.isConversionSyncEnabled());
+            }
+
+            // 回调重试
+            if (retryService != null && props.isCallbackRetryEnabled()) {
+                if (props.isSkipLeaderElection()) {
+                    executor.submit(() -> runCallbackRetry(() -> stopped.get()));
+                    taskLog.info("Callback retry started (skip leader election)");
+                } else if (leaderElection != null) {
+                    executor.submit(() ->
+                        leaderElection.run(
+                            "adx:lock:tencent:callback-retry",
+                            props.getTaskLockTtl(), props.getTaskLockRenewInterval(), props.getTaskLockRetryInterval(),
+                            stopped::get,
+                            (jobStop) -> runCallbackRetry(jobStop),
+                            e -> taskLog.error("tencent callback retry leader: {}", e.getMessage(), e)
+                        )
+                    );
+                    taskLog.info("Callback retry leader election started");
+                }
+            } else {
+                taskLog.warn("Callback retry NOT started: retryService={}, enabled={}",
+                        retryService != null, props.isCallbackRetryEnabled());
+            }
+        }
+
+        private void runConversionSync(LeaderElection.StopSignal jobStop) {
+            long intervalMs = props.getConversionSyncInterval().toMillis();
+            taskLog.info("[ConversionSync] task started, interval={}ms", intervalMs);
+            while (!jobStop.isStopped()) {
+                try {
+                    taskLog.info("[ConversionSync] executing...");
+                    ConversionSyncService.SyncResult r = conversionSyncRunner.runOnce();
+                    taskLog.info("[ConversionSync] done: fetched={} sent={} failed={}", r.fetched.get(), r.sent.get(), r.failed.get());
+                } catch (Exception e) {
+                    taskLog.error("[ConversionSync] error: {}", e.getMessage(), e);
+                }
+                taskLog.info("[ConversionSync] sleeping {}ms until next run", intervalMs);
+                sleepResponsive(intervalMs, jobStop);
+            }
+            taskLog.info("[ConversionSync] task stopped");
+        }
+
+        private void runCallbackRetry(LeaderElection.StopSignal jobStop) {
+            long intervalMs = props.getCallbackRetryInterval().toMillis();
+            taskLog.info("[CallbackRetry] task started, interval={}ms, limit={}", intervalMs, props.getCallbackRetryLimit());
+            while (!jobStop.isStopped()) {
+                try {
+                    taskLog.info("[CallbackRetry] executing...");
+                    RetryService.RetryResult r = retryService.retryTencentCallbacks(props.getCallbackRetryLimit());
+                    taskLog.info("[CallbackRetry] done: fetched={} sent={} failed={}", r.fetched, r.sent, r.failed);
+                } catch (Exception e) {
+                    taskLog.error("[CallbackRetry] error: {}", e.getMessage(), e);
+                }
+                taskLog.info("[CallbackRetry] sleeping {}ms until next run", intervalMs);
+                sleepResponsive(intervalMs, jobStop);
+            }
+            taskLog.info("[CallbackRetry] task stopped");
+        }
+
+        private static void sleepResponsive(long ms, LeaderElection.StopSignal jobStop) {
+            long deadline = System.currentTimeMillis() + ms;
+            while (!jobStop.isStopped()) {
+                long remaining = deadline - System.currentTimeMillis();
+                if (remaining <= 0) break;
+                try {
+                    Thread.sleep(Math.min(remaining, 200));
+                } catch (InterruptedException e) {
+                    Thread.currentThread().interrupt();
+                    break;
+                }
+            }
+        }
+
+        private static void sleep(long ms) {
+            try { Thread.sleep(ms); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
+        }
+    }
+}

+ 16 - 0
src/main/java/com/adx/tencent/Application.java

@@ -0,0 +1,16 @@
+package com.adx.tencent;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.scheduling.annotation.EnableScheduling;
+
+@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class})
+@EnableScheduling
+@EnableConfigurationProperties
+public class Application {
+    public static void main(String[] args) {
+        SpringApplication.run(Application.class, args);
+    }
+}

+ 110 - 0
src/main/java/com/adx/tencent/baidu/AdxClient.java

@@ -0,0 +1,110 @@
+package com.adx.tencent.baidu;
+
+import com.adx.tencent.baidu.model.*;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import java.io.IOException;
+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.util.ArrayList;
+import java.util.List;
+
+/**
+ * 对应 Go internal/baidu/adx.go 的 ADXClient。
+ * 负责:竞价请求(RequestBid)、曝光上报(ReportImpression)、点击上报(ReportClick)。
+ */
+public class AdxClient {
+
+    public static final String AUCTION_PRICE_MACRO = "${AUCTION_PRICE}";
+    public static final String BAIDU_USER_AGENT = "QTech-MeasurementNode/1.2 (Postback; Attribution; S2S)";
+
+    private final String endpoint;
+    private final HttpClient httpClient;
+    private final AuctionPriceEncoder priceEncoder;
+    private final ObjectMapper objectMapper;
+
+    public AdxClient(String endpoint, AuctionPriceEncoder priceEncoder, ObjectMapper objectMapper) {
+        this.endpoint = endpoint;
+        this.priceEncoder = priceEncoder;
+        this.httpClient = HttpClient.newHttpClient();
+        this.objectMapper = objectMapper;
+    }
+
+    /**
+     * 向百度 ADX 发起竞价请求,返回归一化后的 BidResponse。
+     * 如果百度返回空响应体(无广告填充),返回 null。
+     */
+    public NormalizedBidResponse requestBid(Object body) throws IOException, InterruptedException {
+        if (endpoint == null || endpoint.isBlank()) {
+            throw new IllegalStateException("Baidu ADX endpoint is required");
+        }
+
+        byte[] payload = objectMapper.writeValueAsBytes(body);
+        HttpRequest request = HttpRequest.newBuilder()
+                .uri(URI.create(endpoint))
+                .POST(HttpRequest.BodyPublishers.ofByteArray(payload))
+                .header("Content-Type", "application/json")
+                .header("User-Agent", BAIDU_USER_AGENT)
+                .build();
+
+        HttpResponse<byte[]> response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray());
+        if (response.statusCode() < 200 || response.statusCode() >= 300) {
+            throw new IOException("Baidu ADX HTTP status: " + response.statusCode());
+        }
+
+        // 百度 ADX 无广告填充时可能返回空 body
+        byte[] respBody = response.body();
+        if (respBody == null || respBody.length == 0) {
+            return null;
+        }
+
+        BidResponse bidResponse = objectMapper.readValue(respBody, BidResponse.class);
+        return NormalizedBidResponse.from(bidResponse);
+    }
+
+    /**
+     * 曝光上报:将 ${AUCTION_PRICE} 宏替换为编码后的价格,依次 GET 所有 showURLs。
+     */
+    public List<TrackingResult> reportImpression(List<String> showUrls, long price)
+            throws IOException, InterruptedException {
+        String encodedPrice = priceEncoder.encode(price);
+        List<String> urls = new ArrayList<>();
+        for (String url : showUrls) {
+            urls.add(url.replace(AUCTION_PRICE_MACRO, encodedPrice));
+        }
+        return reportTrackingUrls(urls);
+    }
+
+    /**
+     * 点击上报:直接 GET 所有 clickURLs。
+     */
+    public List<TrackingResult> reportClick(List<String> clickUrls)
+            throws IOException, InterruptedException {
+        return reportTrackingUrls(clickUrls);
+    }
+
+    private List<TrackingResult> reportTrackingUrls(List<String> trackingUrls)
+            throws IOException, InterruptedException {
+        List<TrackingResult> results = new ArrayList<>();
+        for (String url : trackingUrls) {
+            HttpRequest req = HttpRequest.newBuilder()
+                    .uri(URI.create(url))
+                    .GET()
+                    .header("User-Agent", BAIDU_USER_AGENT)
+                    .build();
+            HttpResponse<Void> resp = httpClient.send(req, HttpResponse.BodyHandlers.discarding());
+            int status = resp.statusCode();
+            results.add(new TrackingResult(url, status, status >= 200 && status < 400));
+        }
+        return results;
+    }
+
+    // ─── 默认价格编码(不加密,直接 URL encode 数字字符串)───────────────
+    public static String defaultEncodePrice(long price) {
+        return URLEncoder.encode(String.valueOf(price), StandardCharsets.UTF_8);
+    }
+}

+ 115 - 0
src/main/java/com/adx/tencent/baidu/AuctionPriceEncoder.java

@@ -0,0 +1,115 @@
+package com.adx.tencent.baidu;
+
+import javax.crypto.Cipher;
+import javax.crypto.spec.IvParameterSpec;
+import javax.crypto.spec.SecretKeySpec;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+
+/**
+ * 对应 Go NewAuctionPriceEncoder + newAESCBCPriceEncoder + newAESECBPriceEncoder。
+ * 支持三种模式:none(原始数字)、AES-ECB、AES-CBC。
+ */
+@FunctionalInterface
+public interface AuctionPriceEncoder {
+
+    String encode(long price);
+
+    // ─── 工厂方法 ────────────────────────────────────────────────────────────
+
+    static AuctionPriceEncoder create(String mode, String eKey, String iKey) {
+        String m = (mode == null ? "" : mode.trim().toLowerCase());
+        if (m.isEmpty()) {
+            // 自动推断模式
+            boolean hasEKey = eKey != null && !eKey.isEmpty();
+            boolean hasIKey = iKey != null && !iKey.isEmpty();
+            if (hasEKey && hasIKey) {
+                m = "aes-cbc";
+            } else if (hasEKey) {
+                m = "aes-ecb";
+            } else {
+                m = "none";
+            }
+        }
+        return switch (m) {
+            case "none", "raw" -> AuctionPriceEncoder::defaultEncode;
+            case "aes-cbc", "aes-128-cbc", "aes-192-cbc", "aes-256-cbc" ->
+                    newAesCbcEncoder(eKey, iKey);
+            case "aes-ecb", "aes-128-ecb", "aes-192-ecb", "aes-256-ecb" ->
+                    newAesEcbEncoder(eKey);
+            default -> throw new IllegalArgumentException(
+                    "Unsupported auction price encryption mode: " + mode);
+        };
+    }
+
+    static String defaultEncode(long price) {
+        return URLEncoder.encode(String.valueOf(price), StandardCharsets.UTF_8);
+    }
+
+    // ─── AES-CBC ────────────────────────────────────────────────────────────
+    static AuctionPriceEncoder newAesCbcEncoder(String eKey, String iKey) {
+        if (eKey == null || eKey.isEmpty()) {
+            throw new IllegalArgumentException("AES-CBC requires eKey");
+        }
+        // 对应 Go: len([]byte(iKey)) != aes.BlockSize
+        // 必须按 UTF-8 字节数检查,不能使用 String.length()(字符数)
+        // 否则包含多字节字符的 iKey 会被错误接受或拒绝
+        byte[] keyBytes = eKey.getBytes(StandardCharsets.UTF_8);
+        byte[] ivBytes  = iKey == null ? new byte[0] : iKey.getBytes(StandardCharsets.UTF_8);
+        if (ivBytes.length != 16) {
+            throw new IllegalArgumentException(
+                    "AES-CBC iKey must be 16 bytes, got: " + ivBytes.length);
+        }
+        return price -> {
+            try {
+                byte[] plaintext = String.valueOf(price).getBytes(StandardCharsets.UTF_8);
+                byte[] padded    = pkcs7Pad(plaintext, 16);
+                SecretKeySpec keySpec = new SecretKeySpec(keyBytes, "AES");
+                IvParameterSpec ivSpec = new IvParameterSpec(ivBytes);
+                Cipher cipher = Cipher.getInstance("AES/CBC/NoPadding");
+                cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec);
+                byte[] encrypted = cipher.doFinal(padded);
+                return URLEncoder.encode(
+                        Base64.getEncoder().encodeToString(encrypted),
+                        StandardCharsets.UTF_8);
+            } catch (Exception e) {
+                throw new RuntimeException("AES-CBC encode failed", e);
+            }
+        };
+    }
+
+    // ─── AES-ECB ────────────────────────────────────────────────────────────
+    static AuctionPriceEncoder newAesEcbEncoder(String eKey) {
+        if (eKey == null || eKey.isEmpty()) {
+            throw new IllegalArgumentException("AES-ECB requires eKey");
+        }
+        byte[] keyBytes = eKey.getBytes(StandardCharsets.UTF_8);
+        return price -> {
+            try {
+                byte[] plaintext = String.valueOf(price).getBytes(StandardCharsets.UTF_8);
+                byte[] padded    = pkcs7Pad(plaintext, 16);
+                SecretKeySpec keySpec = new SecretKeySpec(keyBytes, "AES");
+                Cipher cipher = Cipher.getInstance("AES/ECB/NoPadding");
+                cipher.init(Cipher.ENCRYPT_MODE, keySpec);
+                byte[] encrypted = cipher.doFinal(padded);
+                return URLEncoder.encode(
+                        Base64.getEncoder().encodeToString(encrypted),
+                        StandardCharsets.UTF_8);
+            } catch (Exception e) {
+                throw new RuntimeException("AES-ECB encode failed", e);
+            }
+        };
+    }
+
+    // ─── PKCS7 填充(对应 Go pkcs7Pad)──────────────────────────────────────
+    static byte[] pkcs7Pad(byte[] value, int blockSize) {
+        int padding = blockSize - (value.length % blockSize);
+        byte[] padded = new byte[value.length + padding];
+        System.arraycopy(value, 0, padded, 0, value.length);
+        for (int i = value.length; i < padded.length; i++) {
+            padded[i] = (byte) padding;
+        }
+        return padded;
+    }
+}

+ 126 - 0
src/main/java/com/adx/tencent/baidu/ConversionClient.java

@@ -0,0 +1,126 @@
+package com.adx.tencent.baidu;
+
+import com.adx.tencent.baidu.model.ConversionQuery;
+import com.adx.tencent.baidu.model.ConversionResponse;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import java.io.IOException;
+import java.math.BigInteger;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.security.MessageDigest;
+import java.security.SecureRandom;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.HashMap;
+import java.util.HexFormat;
+import java.util.Map;
+
+/**
+ * 对应 Go internal/baidu/conversion.go 的 ConversionClient。
+ * 签名算法:md5(timestamp + secret + randomStr)
+ */
+public class ConversionClient {
+
+    private static final String PAYMENT_PATH = "/media/order-stimulate/payment";
+
+    private final String baseUrl;
+    private final String customerName;
+    private final String secret;
+    private final HttpClient httpClient;
+    private final ObjectMapper objectMapper;
+
+    public ConversionClient(String baseUrl, String customerName, String secret,
+                            ObjectMapper objectMapper) {
+        this.baseUrl = baseUrl.replaceAll("/+$", "");
+        this.customerName = customerName;
+        this.secret = secret;
+        this.httpClient = HttpClient.newBuilder()
+                .connectTimeout(Duration.ofSeconds(10))
+                .build();
+        this.objectMapper = objectMapper;
+    }
+
+    /**
+     * 查询百度转化数据(支付信息)。
+     * 对应 Go ConversionClient.QueryPayments()。
+     */
+    public ConversionResponse queryPayments(ConversionQuery query) throws IOException, InterruptedException {
+        if (customerName == null || customerName.isBlank()) {
+            throw new IllegalStateException("customer name is required");
+        }
+        if (secret == null || secret.isBlank()) {
+            throw new IllegalStateException("secret is required");
+        }
+        if (query.getDate() == null || query.getDate().isBlank()) {
+            throw new IllegalArgumentException("date is required");
+        }
+        if (query.getPageSize() <= 0) {
+            throw new IllegalArgumentException("pageSize must be > 0");
+        }
+
+        long timestamp = Instant.now().getEpochSecond();
+        String randomStr = newRandomString();
+        String sign = conversionSign(timestamp, secret, randomStr);
+
+        Map<String, Object> body = new HashMap<>();
+        body.put("timestamp",    timestamp);
+        body.put("randomStr",    randomStr);
+        body.put("sign",         sign);
+        body.put("customerName", customerName);
+        body.put("date",         query.getDate());
+        body.put("pageSize",     query.getPageSize());
+        if (query.getAppSid()  != null && !query.getAppSid().isBlank())  body.put("appsid",   query.getAppSid());
+        if (query.getTu()      != null && !query.getTu().isBlank())      body.put("tu",        query.getTu());
+        if (query.getDeviceId()!= null && !query.getDeviceId().isBlank())body.put("deviceid",  query.getDeviceId());
+        if (query.getActs()    != null && !query.getActs().isEmpty())    body.put("acts",       query.getActs());
+
+        byte[] payload = objectMapper.writeValueAsBytes(body);
+        HttpRequest request = HttpRequest.newBuilder()
+                .uri(URI.create(baseUrl + PAYMENT_PATH))
+                .POST(HttpRequest.BodyPublishers.ofByteArray(payload))
+                .header("Content-Type", "application/json")
+                .header("User-Agent", AdxClient.BAIDU_USER_AGENT)
+                .timeout(Duration.ofSeconds(30))
+                .build();
+
+        HttpResponse<byte[]> response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray());
+        if (response.statusCode() < 200 || response.statusCode() >= 300) {
+            throw new IOException("Baidu conversion HTTP status: " + response.statusCode());
+        }
+
+        ConversionResponse resp = objectMapper.readValue(response.body(), ConversionResponse.class);
+        // 对应 Go: response.BaseResponse.Code != http.StatusOK
+        // Go 中 BaseResponse 是值类型(非指针),缺省 Code=0 != 200 → 也会报错
+        if (resp.getBaseResponse() == null || resp.getBaseResponse().getCode() != 200) {
+            int code = resp.getBaseResponse() != null ? resp.getBaseResponse().getCode() : 0;
+            String msg = resp.getBaseResponse() != null ? resp.getBaseResponse().getMsg() : "";
+            throw new IOException("Baidu conversion API failed: " + code + " " + msg);
+        }
+        return resp;
+    }
+
+    /**
+     * 签名:md5(timestamp + secret + randomStr)
+     * 对应 Go conversionSign()
+     */
+    static String conversionSign(long timestamp, String secret, String randomStr) {
+        try {
+            String raw = timestamp + secret + randomStr;
+            MessageDigest md = MessageDigest.getInstance("MD5");
+            byte[] hash = md.digest(raw.getBytes(java.nio.charset.StandardCharsets.UTF_8));
+            return HexFormat.of().formatHex(hash);
+        } catch (Exception e) {
+            throw new RuntimeException("MD5 sign failed", e);
+        }
+    }
+
+    /** 对应 Go newRandomString():16字节随机数的十六进制字符串 */
+    static String newRandomString() {
+        byte[] bytes = new byte[16];
+        new SecureRandom().nextBytes(bytes);
+        return HexFormat.of().formatHex(bytes);
+    }
+}

+ 17 - 0
src/main/java/com/adx/tencent/baidu/model/Adm.java

@@ -0,0 +1,17 @@
+package com.adx.tencent.baidu.model;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+public class Adm {
+    /** 注意 Go 里拼写是 landdingPage(原始字段名保持一致) */
+    @JsonProperty("landdingPage") private String landingPage;
+    @JsonProperty("appStoreLink") private String appStoreLink;
+    @JsonProperty("packageName")  private String packageName;
+
+    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; }
+}

+ 33 - 0
src/main/java/com/adx/tencent/baidu/model/Bid.java

@@ -0,0 +1,33 @@
+package com.adx.tencent.baidu.model;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import java.util.List;
+
+/** 对应 Go baidu.Bid */
+public class Bid {
+    @JsonProperty("id")       private String id;
+    @JsonProperty("impId")    private String impId;
+    @JsonProperty("tagId")    private String tagId;
+    @JsonProperty("crid")     private String crid;
+    @JsonProperty("price")    private long price;
+    @JsonProperty("showUrl")  private List<String> showUrl;
+    @JsonProperty("clickUrl") private List<String> clickUrl;
+    @JsonProperty("adm")      private Adm adm;
+
+    public String getId() { return id; }
+    public void setId(String v) { this.id = 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 String getCrid() { return crid; }
+    public void setCrid(String v) { this.crid = v; }
+    public long getPrice() { return price; }
+    public void setPrice(long v) { this.price = v; }
+    public List<String> getShowUrl() { return showUrl; }
+    public void setShowUrl(List<String> v) { this.showUrl = v; }
+    public List<String> getClickUrl() { return clickUrl; }
+    public void setClickUrl(List<String> v) { this.clickUrl = v; }
+    public Adm getAdm() { return adm; }
+    public void setAdm(Adm v) { this.adm = v; }
+}

+ 21 - 0
src/main/java/com/adx/tencent/baidu/model/BidResponse.java

@@ -0,0 +1,21 @@
+package com.adx.tencent.baidu.model;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import java.util.List;
+
+/** 对应 Go baidu.BidResponse */
+public class BidResponse {
+    @JsonProperty("reqId")   private String reqId;
+    @JsonProperty("bidId")   private String bidId;
+    @JsonProperty("qk")      private String qk;
+    @JsonProperty("seatBid") private List<SeatBid> seatBid;
+
+    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 getQk() { return qk; }
+    public void setQk(String v) { this.qk = v; }
+    public List<SeatBid> getSeatBid() { return seatBid; }
+    public void setSeatBid(List<SeatBid> v) { this.seatBid = v; }
+}

+ 27 - 0
src/main/java/com/adx/tencent/baidu/model/ConversionQuery.java

@@ -0,0 +1,27 @@
+package com.adx.tencent.baidu.model;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import java.util.List;
+
+/** 对应 Go baidu.ConversionQuery */
+public class ConversionQuery {
+    @JsonProperty("date")     private String date;
+    @JsonProperty("pageSize") private int pageSize;
+    @JsonProperty("appsid")   private String appSid;
+    @JsonProperty("tu")       private String tu;
+    @JsonProperty("deviceid") private String deviceId;
+    @JsonProperty("acts")     private List<Integer> acts;
+
+    public String getDate() { return date; }
+    public void setDate(String v) { this.date = v; }
+    public int getPageSize() { return pageSize; }
+    public void setPageSize(int v) { this.pageSize = v; }
+    public String getAppSid() { return appSid; }
+    public void setAppSid(String v) { this.appSid = v; }
+    public String getTu() { return tu; }
+    public void setTu(String v) { this.tu = v; }
+    public String getDeviceId() { return deviceId; }
+    public void setDeviceId(String v) { this.deviceId = v; }
+    public List<Integer> getActs() { return acts; }
+    public void setActs(List<Integer> v) { this.acts = v; }
+}

+ 27 - 0
src/main/java/com/adx/tencent/baidu/model/ConversionResponse.java

@@ -0,0 +1,27 @@
+package com.adx.tencent.baidu.model;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import java.util.List;
+
+/** 对应 Go baidu.ConversionResponse */
+public class ConversionResponse {
+    @JsonProperty("baseResponse") private BaseResponse baseResponse;
+    @JsonProperty("pageSize")     private int pageSize;
+    @JsonProperty("data")         private List<PaymentInfo> data;
+
+    public BaseResponse getBaseResponse() { return baseResponse; }
+    public void setBaseResponse(BaseResponse v) { this.baseResponse = v; }
+    public int getPageSize() { return pageSize; }
+    public void setPageSize(int v) { this.pageSize = v; }
+    public List<PaymentInfo> getData() { return data; }
+    public void setData(List<PaymentInfo> v) { this.data = v; }
+
+    public static class BaseResponse {
+        @JsonProperty("code") private int code;
+        @JsonProperty("msg")  private String msg;
+        public int getCode() { return code; }
+        public void setCode(int v) { this.code = v; }
+        public String getMsg() { return msg; }
+        public void setMsg(String v) { this.msg = v; }
+    }
+}

+ 38 - 0
src/main/java/com/adx/tencent/baidu/model/NormalizedBid.java

@@ -0,0 +1,38 @@
+package com.adx.tencent.baidu.model;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import java.util.List;
+
+public class NormalizedBid {
+    @JsonProperty("id")           private String id;
+    @JsonProperty("impId")        private String impId;
+    @JsonProperty("tagId")        private String tagId;
+    @JsonProperty("crid")         private String crid;
+    @JsonProperty("price")        private long price;
+    @JsonProperty("showUrls")     private List<String> showUrls;
+    @JsonProperty("clickUrls")    private List<String> clickUrls;
+    @JsonProperty("landingPage")  private String landingPage;
+    @JsonProperty("appStoreLink") private String appStoreLink;
+    @JsonProperty("adm")          private Adm adm;
+
+    public String getId() { return id; }
+    public void setId(String v) { this.id = 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 String getCrid() { return crid; }
+    public void setCrid(String v) { this.crid = v; }
+    public long getPrice() { return price; }
+    public void setPrice(long v) { this.price = 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 Adm getAdm() { return adm; }
+    public void setAdm(Adm v) { this.adm = v; }
+}

+ 55 - 0
src/main/java/com/adx/tencent/baidu/model/NormalizedBidResponse.java

@@ -0,0 +1,55 @@
+package com.adx.tencent.baidu.model;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/** 对应 Go baidu.NormalizedBidResponse */
+public class NormalizedBidResponse {
+    @JsonProperty("reqId") private String reqId;
+    @JsonProperty("bidId") private String bidId;
+    @JsonProperty("qk")    private String qk;
+    @JsonProperty("bid")   private NormalizedBid bid;
+
+    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 getQk() { return qk; }
+    public void setQk(String v) { this.qk = v; }
+    public NormalizedBid getBid() { return bid; }
+    public void setBid(NormalizedBid v) { this.bid = v; }
+
+    /**
+     * 对应 Go NormalizeBidResponse():从原始 BidResponse 中提取第一个有效 Bid,
+     * 统一字段名(showUrl->showUrls, clickUrl->clickUrls 等)。
+     */
+    public static NormalizedBidResponse from(BidResponse raw) {
+        NormalizedBidResponse n = new NormalizedBidResponse();
+        n.reqId = raw.getReqId();
+        n.bidId = raw.getBidId();
+        n.qk    = raw.getQk();
+
+        if (raw.getSeatBid() != null) {
+            for (SeatBid seatBid : raw.getSeatBid()) {
+                if (seatBid.getBid() != null && !seatBid.getBid().isEmpty()) {
+                    Bid b = seatBid.getBid().get(0);
+                    NormalizedBid nb = new NormalizedBid();
+                    nb.setId(b.getId());
+                    nb.setImpId(b.getImpId());
+                    nb.setTagId(b.getTagId());
+                    nb.setCrid(b.getCrid());
+                    nb.setPrice(b.getPrice());
+                    nb.setShowUrls(b.getShowUrl());
+                    nb.setClickUrls(b.getClickUrl());
+                    if (b.getAdm() != null) {
+                        nb.setLandingPage(b.getAdm().getLandingPage());
+                        nb.setAppStoreLink(b.getAdm().getAppStoreLink());
+                        nb.setAdm(b.getAdm());
+                    }
+                    n.bid = nb;
+                    return n;
+                }
+            }
+        }
+        return n;
+    }
+}

+ 39 - 0
src/main/java/com/adx/tencent/baidu/model/PaymentInfo.java

@@ -0,0 +1,39 @@
+package com.adx.tencent.baidu.model;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import java.util.List;
+
+/** 对应 Go baidu.PaymentInfo */
+public class PaymentInfo {
+    @JsonProperty("date")         private String date;
+    @JsonProperty("appsid")       private String appSid;
+    @JsonProperty("customerName") private String customerName;
+    @JsonProperty("deviceid")     private String deviceId;
+    @JsonProperty("conv")         private double conv;
+    @JsonProperty("payment")      private double payment;
+    @JsonProperty("act")          private int act;
+    @JsonProperty("tu")           private String tu;
+    @JsonProperty("qk")           private String qk;
+    @JsonProperty("clkTime")      private String clkTime;
+
+    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 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 getQk() { return qk; }
+    public void setQk(String v) { this.qk = v; }
+    public String getClkTime() { return clkTime; }
+    public void setClkTime(String v) { this.clkTime = v; }
+}

+ 10 - 0
src/main/java/com/adx/tencent/baidu/model/SeatBid.java

@@ -0,0 +1,10 @@
+package com.adx.tencent.baidu.model;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import java.util.List;
+
+public class SeatBid {
+    @JsonProperty("bid") private List<Bid> bid;
+    public List<Bid> getBid() { return bid; }
+    public void setBid(List<Bid> v) { this.bid = v; }
+}

+ 20 - 0
src/main/java/com/adx/tencent/baidu/model/TrackingResult.java

@@ -0,0 +1,20 @@
+package com.adx.tencent.baidu.model;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+public class TrackingResult {
+    @JsonProperty("url")    private String url;
+    @JsonProperty("status") private int status;
+    @JsonProperty("ok")     private boolean ok;
+
+    public TrackingResult() {}
+    public TrackingResult(String url, int status, boolean ok) {
+        this.url = url; this.status = status; this.ok = ok;
+    }
+    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; }
+}

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

@@ -0,0 +1,241 @@
+package com.adx.tencent.config;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 全局配置属性类。
+ * 通过 application.yml 中的 adx.* 前缀绑定。
+ */
+@Component
+@ConfigurationProperties(prefix = "adx")
+public class AppProperties {
+
+    // HTTP Server
+    private String port = "8787";
+
+    // Baidu ADX
+    private String baiduAdxEndpoint = "http://debug.mobads.baidu.com/jbssp";
+    private String baiduConversionBaseUrl = "https://media-interact-test.baidu.com";
+    private String baiduCustomerName;
+    private String baiduSecret;
+    private String baiduAuctionPriceEncryption;
+    private String baiduAuctionPriceEKey;
+    private String baiduAuctionPriceIKey;
+    private long baiduMediaId = 0;
+    private String baiduAppId;
+    private String baiduTagId;
+    private String baiduAndroidAppId;
+    private List<String> baiduAndroidTagIds = new ArrayList<>();
+    private String baiduIosAppId;
+    private List<String> baiduIosTagIds = new ArrayList<>();
+    private long baiduBidFloor = 0;
+    private int baiduActionType = 1;
+
+    // Tencent API
+    private String tencentApiBaseUrl = "https://api.e.qq.com/v3.0";
+    private String tencentAccessToken;
+    private long tencentAccountId;
+    private long tencentAndroidUserActionSetId;
+    private long tencentIosUserActionSetId;
+
+    // Tencent Act Mapping: baiduAct -> tencentActionType
+    private Map<Integer, String> tencentActMap = new HashMap<>();
+
+    // Redis
+    private String redisAddr = "127.0.0.1:6379";
+    private String redisUsername;
+    private String redisPassword;
+    private int redisDb = 0;
+    private String redisStream = "adx:tencent:events";
+    private long redisStreamMaxLen = 50000;
+    private Duration bidTtl = Duration.ofHours(24);
+
+    // TiDB
+    private String tidbUrl;
+    private String tidbUsername;
+    private String tidbPassword;
+
+    // Task Lock
+    private Duration taskLockTtl = Duration.ofSeconds(120);
+    private Duration taskLockRenewInterval = Duration.ofSeconds(30);
+    private Duration taskLockRetryInterval = Duration.ofSeconds(15);
+    private boolean skipLeaderElection = false;
+
+    // Worker
+    private String workerGroup = "cold-writers";
+    private String workerConsumer = defaultHostname();
+    private int workerBatch = 100;
+
+    // Conversion Sync
+    private boolean conversionSyncEnabled = false;
+    private Duration conversionSyncInterval = Duration.ofSeconds(300);
+    private int conversionSyncDateOffsetDays = 0;
+    private int conversionSyncPageSize = 1;
+    private List<Integer> conversionSyncActs = List.of(1, 2, 3, 4, 5, 6, 8);
+
+    // Callback Retry
+    private boolean callbackRetryEnabled = false;
+    private Duration callbackRetryInterval = Duration.ofSeconds(300);
+    private int callbackRetryLimit = 100;
+
+    private static String defaultHostname() {
+        try {
+            return java.net.InetAddress.getLocalHost().getHostName();
+        } catch (Exception e) {
+            return "worker-1";
+        }
+    }
+
+    // ───── getters / setters ─────
+
+    public String getPort() { return port; }
+    public void setPort(String port) { this.port = port; }
+
+    public String getBaiduAdxEndpoint() { return baiduAdxEndpoint; }
+    public void setBaiduAdxEndpoint(String v) { this.baiduAdxEndpoint = v; }
+
+    public String getBaiduConversionBaseUrl() { return baiduConversionBaseUrl; }
+    public void setBaiduConversionBaseUrl(String v) { this.baiduConversionBaseUrl = v; }
+
+    public String getBaiduCustomerName() { return baiduCustomerName; }
+    public void setBaiduCustomerName(String v) { this.baiduCustomerName = v; }
+
+    public String getBaiduSecret() { return baiduSecret; }
+    public void setBaiduSecret(String v) { this.baiduSecret = v; }
+
+    public String getBaiduAuctionPriceEncryption() { return baiduAuctionPriceEncryption; }
+    public void setBaiduAuctionPriceEncryption(String v) { this.baiduAuctionPriceEncryption = v; }
+
+    public String getBaiduAuctionPriceEKey() { return baiduAuctionPriceEKey; }
+    public void setBaiduAuctionPriceEKey(String v) { this.baiduAuctionPriceEKey = v; }
+
+    public String getBaiduAuctionPriceIKey() { return baiduAuctionPriceIKey; }
+    public void setBaiduAuctionPriceIKey(String v) { this.baiduAuctionPriceIKey = v; }
+
+    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 String getBaiduAndroidAppId() { return baiduAndroidAppId; }
+    public void setBaiduAndroidAppId(String v) { this.baiduAndroidAppId = v; }
+
+    public List<String> getBaiduAndroidTagIds() { return baiduAndroidTagIds; }
+    public void setBaiduAndroidTagIds(List<String> v) { this.baiduAndroidTagIds = v; }
+
+    public String getBaiduIosAppId() { return baiduIosAppId; }
+    public void setBaiduIosAppId(String v) { this.baiduIosAppId = v; }
+
+    public List<String> getBaiduIosTagIds() { return baiduIosTagIds; }
+    public void setBaiduIosTagIds(List<String> v) { this.baiduIosTagIds = v; }
+
+    public long getBaiduBidFloor() { return baiduBidFloor; }
+    public void setBaiduBidFloor(long v) { this.baiduBidFloor = v; }
+
+    public int getBaiduActionType() { return baiduActionType; }
+    public void setBaiduActionType(int v) { this.baiduActionType = v; }
+
+    public String getTencentApiBaseUrl() { return tencentApiBaseUrl; }
+    public void setTencentApiBaseUrl(String v) { this.tencentApiBaseUrl = v; }
+
+    public String getTencentAccessToken() { return tencentAccessToken; }
+    public void setTencentAccessToken(String v) { this.tencentAccessToken = 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; }
+
+    public long getTencentIosUserActionSetId() { return tencentIosUserActionSetId; }
+    public void setTencentIosUserActionSetId(long v) { this.tencentIosUserActionSetId = v; }
+
+    public Map<Integer, String> getTencentActMap() { return tencentActMap; }
+    public void setTencentActMap(Map<Integer, String> v) { this.tencentActMap = v; }
+
+    public String getRedisAddr() { return redisAddr; }
+    public void setRedisAddr(String v) { this.redisAddr = v; }
+
+    public String getRedisUsername() { return redisUsername; }
+    public void setRedisUsername(String v) { this.redisUsername = v; }
+
+    public String getRedisPassword() { return redisPassword; }
+    public void setRedisPassword(String v) { this.redisPassword = v; }
+
+    public int getRedisDb() { return redisDb; }
+    public void setRedisDb(int v) { this.redisDb = v; }
+
+    public String getRedisStream() { return redisStream; }
+    public void setRedisStream(String v) { this.redisStream = v; }
+
+    public long getRedisStreamMaxLen() { return redisStreamMaxLen; }
+    public void setRedisStreamMaxLen(long v) { this.redisStreamMaxLen = v; }
+
+    public Duration getBidTtl() { return bidTtl; }
+    public void setBidTtl(Duration v) { this.bidTtl = v; }
+
+    public String getTidbUrl() { return tidbUrl; }
+    public void setTidbUrl(String v) { this.tidbUrl = v; }
+
+    public String getTidbUsername() { return tidbUsername; }
+    public void setTidbUsername(String v) { this.tidbUsername = v; }
+
+    public String getTidbPassword() { return tidbPassword; }
+    public void setTidbPassword(String v) { this.tidbPassword = v; }
+
+    public Duration getTaskLockTtl() { return taskLockTtl; }
+    public void setTaskLockTtl(Duration v) { this.taskLockTtl = v; }
+
+    public Duration getTaskLockRenewInterval() { return taskLockRenewInterval; }
+    public void setTaskLockRenewInterval(Duration v) { this.taskLockRenewInterval = v; }
+
+    public Duration getTaskLockRetryInterval() { return taskLockRetryInterval; }
+    public void setTaskLockRetryInterval(Duration v) { this.taskLockRetryInterval = v; }
+
+    public boolean isSkipLeaderElection() { return skipLeaderElection; }
+    public void setSkipLeaderElection(boolean v) { this.skipLeaderElection = v; }
+
+    public String getWorkerGroup() { return workerGroup; }
+    public void setWorkerGroup(String v) { this.workerGroup = v; }
+
+    public String getWorkerConsumer() { return workerConsumer; }
+    public void setWorkerConsumer(String v) { this.workerConsumer = v; }
+
+    public int getWorkerBatch() { return workerBatch; }
+    public void setWorkerBatch(int v) { this.workerBatch = v; }
+
+    public boolean isConversionSyncEnabled() { return conversionSyncEnabled; }
+    public void setConversionSyncEnabled(boolean v) { this.conversionSyncEnabled = v; }
+
+    public Duration getConversionSyncInterval() { return conversionSyncInterval; }
+    public void setConversionSyncInterval(Duration v) { this.conversionSyncInterval = v; }
+
+    public int getConversionSyncDateOffsetDays() { return conversionSyncDateOffsetDays; }
+    public void setConversionSyncDateOffsetDays(int v) { this.conversionSyncDateOffsetDays = v; }
+
+    public int getConversionSyncPageSize() { return conversionSyncPageSize; }
+    public void setConversionSyncPageSize(int v) { this.conversionSyncPageSize = v; }
+
+    public List<Integer> getConversionSyncActs() { return conversionSyncActs; }
+    public void setConversionSyncActs(List<Integer> v) { this.conversionSyncActs = v; }
+
+    public boolean isCallbackRetryEnabled() { return callbackRetryEnabled; }
+    public void setCallbackRetryEnabled(boolean v) { this.callbackRetryEnabled = v; }
+
+    public Duration getCallbackRetryInterval() { return callbackRetryInterval; }
+    public void setCallbackRetryInterval(Duration v) { this.callbackRetryInterval = v; }
+
+    public int getCallbackRetryLimit() { return callbackRetryLimit; }
+    public void setCallbackRetryLimit(int v) { this.callbackRetryLimit = v; }
+}

+ 41 - 0
src/main/java/com/adx/tencent/conversionsync/ConversionSyncRunner.java

@@ -0,0 +1,41 @@
+package com.adx.tencent.conversionsync;
+
+import com.adx.tencent.baidu.model.ConversionQuery;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.util.List;
+
+/**
+ * 转化同步调度器。
+ * 定期调用 ConversionSyncService.syncTencentConversions()。
+ */
+public class ConversionSyncRunner {
+
+    private static final Logger log = LoggerFactory.getLogger(ConversionSyncRunner.class);
+    private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyyMMdd");
+
+    private final ConversionSyncService syncer;
+    private final int dateOffsetDays;
+    private final int pageSize;
+    private final List<Integer> acts;
+
+    public ConversionSyncRunner(ConversionSyncService syncer,
+                                 int dateOffsetDays, int pageSize, List<Integer> acts) {
+        this.syncer = syncer;
+        this.dateOffsetDays = dateOffsetDays;
+        this.pageSize = pageSize > 0 ? pageSize : 1;
+        this.acts = acts;
+    }
+
+    public ConversionSyncService.SyncResult runOnce() throws Exception {
+        String date = LocalDate.now().plusDays(dateOffsetDays).format(DATE_FMT);
+        ConversionQuery query = new ConversionQuery();
+        query.setDate(date);
+        query.setPageSize(pageSize);
+        query.setActs(acts);
+        return syncer.syncTencentConversions(query);
+    }
+}

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

@@ -0,0 +1,209 @@
+package com.adx.tencent.conversionsync;
+
+import com.adx.tencent.baidu.ConversionClient;
+import com.adx.tencent.baidu.model.ConversionQuery;
+import com.adx.tencent.baidu.model.ConversionResponse;
+import com.adx.tencent.baidu.model.PaymentInfo;
+import com.adx.tencent.tencent.TencentClient;
+import com.adx.tencent.storage.RedisHotStore;
+import com.adx.tencent.storage.TiDBColdStore;
+import com.adx.tencent.storage.model.*;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.*;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * 转化同步核心逻辑。
+ * 定时从百度拉取转化数据,匹配腾讯 bid,回传腾讯 user_actions/add。
+ */
+public class ConversionSyncService {
+
+    private static final Logger log = LoggerFactory.getLogger(ConversionSyncService.class);
+    private static final int CONCURRENCY = 10;
+
+    private final ConversionClient baiduClient;
+    private final RedisHotStore hotStore;
+    private final TencentClient tencentClient;
+    private final TiDBColdStore coldStore;
+    private final ExecutorService executor;
+
+    public ConversionSyncService(ConversionClient baiduClient, RedisHotStore hotStore,
+                                  TencentClient tencentClient, TiDBColdStore coldStore) {
+        this.baiduClient = baiduClient;
+        this.hotStore = hotStore;
+        this.tencentClient = tencentClient;
+        this.coldStore = coldStore;
+        this.executor = Executors.newFixedThreadPool(CONCURRENCY,
+                r -> { Thread t = new Thread(r, "conv-sync-worker"); t.setDaemon(true); return t; });
+    }
+
+    /**
+     * 分页拉取百度转化数据并回传腾讯。
+     */
+    public SyncResult syncTencentConversions(ConversionQuery query) throws Exception {
+        if (query.getPageSize() <= 0) query.setPageSize(1);
+
+        SyncResult result = new SyncResult();
+        int maxPages = 100;
+        for (int page = query.getPageSize(); ; page++) {
+            query.setPageSize(page);
+            log.info("[ConversionSync] fetching page={}", page);
+            ConversionResponse response = baiduClient.queryPayments(query);
+            List<PaymentInfo> data = response.getData();
+            int dataSize = data != null ? data.size() : 0;
+            log.info("[ConversionSync] page={} returned {} records, responsePageSize={}",
+                    page, dataSize, response.getPageSize());
+            if (data != null) {
+                processPayments(data, result);
+            }
+            if (response.getPageSize() <= page) break;
+            if (page >= maxPages) {
+                log.warn("[ConversionSync] reached max page limit ({}), stopping", maxPages);
+                break;
+            }
+        }
+        return result;
+    }
+
+    private void processPayments(List<PaymentInfo> payments, SyncResult result) throws Exception {
+        List<CompletableFuture<Void>> futures = new ArrayList<>(payments.size());
+        for (PaymentInfo payment : payments) {
+            futures.add(CompletableFuture.runAsync(() -> processSinglePayment(payment, result), executor));
+        }
+        CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
+    }
+
+    private void processSinglePayment(PaymentInfo payment, SyncResult result) {
+        result.fetched.incrementAndGet();
+
+        // 用 qk 查找 bid
+        BidRecord bid;
+        try {
+            bid = hotStore.findBidByQk(payment.getQk());
+        } catch (Exception e) {
+            log.error("[ConversionSync] findBidByQk error: qk={}, {}", payment.getQk(), e.getMessage());
+            result.failed.incrementAndGet();
+            return;
+        }
+
+        if (bid == null) {
+            if (coldStore != null) {
+                try { coldStore.saveConversion(conversionRecord(payment, "")); } catch (Exception ignored) {}
+            }
+            result.skipped.incrementAndGet();
+            return;
+        }
+
+        // bid 不是腾讯,或没有设备标识
+        if (!"tencent".equals(bid.getMedia())) {
+            if (coldStore != null) {
+                try { coldStore.saveConversion(conversionRecord(payment, bid.getMedia())); } catch (Exception ignored) {}
+            }
+            result.skipped.incrementAndGet();
+            return;
+        }
+
+        result.matched.incrementAndGet();
+        if (coldStore != null) {
+            try { coldStore.saveConversion(conversionRecord(payment, bid.getMedia())); } catch (Exception ignored) {}
+        }
+
+        // 幂等检查
+        String callbackKey = callbackDedupeKey(payment, bid.getMedia());
+        if (coldStore != null) {
+            try {
+                String conversionKey = conversionDedupeKey(payment, bid.getMedia());
+                boolean exists = coldStore.successfulCallbackExistsForEvent(
+                        callbackKey, conversionKey, payment.getAct());
+                if (exists) {
+                    result.alreadySent.incrementAndGet();
+                    return;
+                }
+            } catch (Exception e) {
+                log.error("[ConversionSync] dedup check error: key={}, {}", callbackKey, e.getMessage());
+            }
+        }
+
+        // 发送到腾讯
+        String platform = resolvePlatform(bid);
+        MediaCallbackRecord callbackRecord = tencentClient.sendConversionEvent(bid, payment, platform);
+        callbackRecord.setDedupeKey(callbackKey);
+
+        if (callbackRecord.getStatus() == 0 && callbackRecord.getErrorMessage() != null) {
+            // 网络级错误
+            result.failed.incrementAndGet();
+            if (coldStore != null) {
+                try { coldStore.saveMediaCallback(callbackRecord); } catch (Exception ignored) {}
+            }
+            return;
+        }
+
+        // HTTP 请求完成
+        result.sent.incrementAndGet();
+        if (coldStore != null) {
+            try { coldStore.saveMediaCallback(callbackRecord); } catch (Exception ignored) {}
+        }
+    }
+
+    // ─── 辅助方法 ─────────────────────────────────────────────────────────────
+
+    /**
+     * 从 BidRecord 中提取平台信息。
+     * 优先读取 platform 字段,其次从 mediaParams 中提取,最后根据设备标识推断。
+     */
+    private static String resolvePlatform(BidRecord bid) {
+        // 优先使用 BidRecord 的 platform 字段
+        if (bid.getPlatform() != null && !bid.getPlatform().isBlank()) {
+            return bid.getPlatform().toLowerCase();
+        }
+        if (bid.getMediaParams() == null) return "android";
+        String platform = bid.getMediaParams().get("baidu_platform");
+        if (platform != null && !platform.isBlank()) return platform.toLowerCase();
+        // 有 idfa 则为 ios
+        String idfa = bid.getMediaParams().get("idfa");
+        if (idfa != null && !idfa.isBlank()) return "ios";
+        return "android";
+    }
+
+    private static ConversionRecord conversionRecord(PaymentInfo p, String media) {
+        ConversionRecord r = new ConversionRecord();
+        r.setDedupeKey(conversionDedupeKey(p, media));
+        r.setQk(p.getQk());
+        r.setMedia(media);
+        r.setDate(p.getDate());
+        r.setAppSid(p.getAppSid());
+        r.setCustomerName(p.getCustomerName());
+        r.setDeviceId(p.getDeviceId());
+        r.setConv(p.getConv());
+        r.setPayment(p.getPayment());
+        r.setAct(p.getAct());
+        r.setTu(p.getTu());
+        r.setClkTime(p.getClkTime());
+        r.setCreatedAt(Instant.now());
+        return r;
+    }
+
+    static String conversionDedupeKey(PaymentInfo p, String media) {
+        return String.format("%s:%s:%s:%s:%d", media, p.getQk(), p.getDate(), p.getDeviceId(), p.getAct());
+    }
+
+    static String callbackDedupeKey(PaymentInfo p, String media) {
+        return conversionDedupeKey(p, media) + ":" + p.getAct();
+    }
+
+    // ─── Result DTO ─────────────────────────────────────────────────────────
+
+    public static class SyncResult {
+        public final AtomicInteger fetched = new AtomicInteger();
+        public final AtomicInteger matched = new AtomicInteger();
+        public final AtomicInteger sent = new AtomicInteger();
+        public final AtomicInteger skipped = new AtomicInteger();
+        public final AtomicInteger failed = new AtomicInteger();
+        public final AtomicInteger alreadySent = new AtomicInteger();
+    }
+}

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

@@ -0,0 +1,78 @@
+package com.adx.tencent.conversionsync;
+
+import com.adx.tencent.tencent.TencentClient;
+import com.adx.tencent.storage.TiDBColdStore;
+import com.adx.tencent.storage.model.MediaCallbackRecord;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.time.Instant;
+import java.util.List;
+
+/**
+ * 回调重试服务。
+ * 查询 TiDB 中未成功的腾讯回调记录,逐条重试。
+ */
+public class RetryService {
+
+    private static final Logger log = LoggerFactory.getLogger(RetryService.class);
+
+    private final TiDBColdStore coldStore;
+    private final TencentClient tencentClient;
+    private final int defaultLimit;
+
+    public RetryService(TiDBColdStore coldStore, TencentClient tencentClient, int defaultLimit) {
+        this.coldStore = coldStore;
+        this.tencentClient = tencentClient;
+        this.defaultLimit = defaultLimit > 0 ? defaultLimit : 100;
+    }
+
+    public RetryResult retryTencentCallbacks(int limit) {
+        if (limit <= 0) limit = defaultLimit;
+
+        List<MediaCallbackRecord> pending = coldStore.pendingMediaCallbacks("tencent", limit);
+        RetryResult result = new RetryResult();
+        result.fetched = pending.size();
+
+        for (MediaCallbackRecord record : pending) {
+            MediaCallbackRecord retryRecord;
+            try {
+                retryRecord = tencentClient.retryCallback(record);
+                result.sent++;
+            } catch (Exception e) {
+                result.failed++;
+                retryRecord = copyRecord(record);
+                retryRecord.setAttempt(record.getAttempt() + 1);
+                retryRecord.setOk(false);
+                retryRecord.setErrorMessage(e.getMessage());
+                retryRecord.setCreatedAt(Instant.now());
+                try { coldStore.saveMediaCallback(retryRecord); } catch (Exception ignored) {}
+                continue;
+            }
+            coldStore.saveMediaCallback(retryRecord);
+        }
+        return result;
+    }
+
+    private static MediaCallbackRecord copyRecord(MediaCallbackRecord src) {
+        MediaCallbackRecord copy = new MediaCallbackRecord();
+        copy.setDedupeKey(src.getDedupeKey());
+        copy.setMedia(src.getMedia());
+        copy.setQk(src.getQk());
+        copy.setCallbackUrl(src.getCallbackUrl());
+        copy.setEventType(src.getEventType());
+        copy.setEventTimeMs(src.getEventTimeMs());
+        copy.setPurchase(src.getPurchase());
+        copy.setStatus(src.getStatus());
+        copy.setOk(src.isOk());
+        copy.setAttempt(src.getAttempt());
+        copy.setResponseBody(src.getResponseBody());
+        copy.setErrorMessage(src.getErrorMessage());
+        copy.setCreatedAt(src.getCreatedAt());
+        return copy;
+    }
+
+    public static class RetryResult {
+        public int fetched, sent, failed;
+    }
+}

+ 62 - 0
src/main/java/com/adx/tencent/httpapi/AdminController.java

@@ -0,0 +1,62 @@
+package com.adx.tencent.httpapi;
+
+import com.adx.tencent.baidu.ConversionClient;
+import com.adx.tencent.baidu.model.ConversionQuery;
+import com.adx.tencent.conversionsync.ConversionSyncService;
+import com.adx.tencent.conversionsync.RetryService;
+import org.springframework.http.HttpStatus;
+import org.springframework.lang.Nullable;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.server.ResponseStatusException;
+
+import java.util.Map;
+
+/**
+ * 管理接口:手动触发转化同步和回调重试。
+ */
+@RestController
+@RequestMapping("/admin")
+public class AdminController {
+
+    private final ConversionClient conversionClient;
+    private final ConversionSyncService conversionSyncer;
+    private final RetryService retryService;
+
+    public AdminController(ConversionClient conversionClient,
+                           @Nullable ConversionSyncService conversionSyncer,
+                           @Nullable RetryService retryService) {
+        this.conversionClient = conversionClient;
+        this.conversionSyncer = conversionSyncer;
+        this.retryService = retryService;
+    }
+
+    @PostMapping("/conversions/query")
+    public Object queryConversions(@RequestBody ConversionQuery query) throws Exception {
+        return conversionClient.queryPayments(query);
+    }
+
+    @PostMapping("/conversions/sync")
+    public Object syncConversions(@RequestBody ConversionQuery query) throws Exception {
+        if (conversionSyncer == null) {
+            throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
+                    "conversion syncer is not configured");
+        }
+        return conversionSyncer.syncTencentConversions(query);
+    }
+
+    @PostMapping("/callbacks/retry")
+    public Object retryCallbacks(@RequestBody Map<String, Object> body) {
+        if (retryService == null) {
+            throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
+                    "callback retryer is not configured");
+        }
+        int limit = toInt(body.get("limit"));
+        return retryService.retryTencentCallbacks(limit);
+    }
+
+    private static int toInt(Object v) {
+        if (v == null) return 0;
+        if (v instanceof Number n) return n.intValue();
+        try { return Integer.parseInt(v.toString()); } catch (NumberFormatException e) { return 0; }
+    }
+}

+ 66 - 0
src/main/java/com/adx/tencent/httpapi/GlobalExceptionHandler.java

@@ -0,0 +1,66 @@
+package com.adx.tencent.httpapi;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+import org.springframework.web.server.ResponseStatusException;
+
+import java.io.IOException;
+import java.util.Map;
+
+/**
+ * 全局异常处理器:对齐 Go 的 respondError(c, status, err) 返回格式 {"error": "message"}。
+ * Go 中所有错误统一返回 {"error": "..."} + 明确的 HTTP 状态码,
+ * Spring 默认返回 {"timestamp":..., "status":..., "error":..., "message":...} 格式不一致。
+ */
+@RestControllerAdvice
+public class GlobalExceptionHandler {
+
+    private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
+
+    /**
+     * ResponseStatusException(手动抛出的带状态码的异常)。
+     * 对应 Go: respondError(c, specificStatus, err)
+     */
+    @ExceptionHandler(ResponseStatusException.class)
+    public ResponseEntity<Map<String, String>> handleResponseStatus(ResponseStatusException ex) {
+        String reason = ex.getReason() != null ? ex.getReason() : ex.getMessage();
+        return ResponseEntity.status(ex.getStatusCode())
+                .body(Map.of("error", reason));
+    }
+
+    /**
+     * IllegalArgumentException(参数校验失败)。
+     * 对应 Go: respondError(c, http.StatusBadRequest, err)
+     */
+    @ExceptionHandler(IllegalArgumentException.class)
+    public ResponseEntity<Map<String, String>> handleBadRequest(IllegalArgumentException ex) {
+        return ResponseEntity.status(HttpStatus.BAD_REQUEST)
+                .body(Map.of("error", ex.getMessage()));
+    }
+
+    /**
+     * IOException / InterruptedException(外部服务调用失败)。
+     * 对应 Go: respondError(c, http.StatusBadGateway, err)
+     */
+    @ExceptionHandler({IOException.class, InterruptedException.class})
+    public ResponseEntity<Map<String, String>> handleBadGateway(Exception ex) {
+        log.error("External service error: {}", ex.getMessage(), ex);
+        return ResponseEntity.status(HttpStatus.BAD_GATEWAY)
+                .body(Map.of("error", ex.getMessage()));
+    }
+
+    /**
+     * 兜底:所有未预期异常。
+     * 对应 Go: respondError(c, http.StatusBadGateway, err)
+     */
+    @ExceptionHandler(Exception.class)
+    public ResponseEntity<Map<String, String>> handleGeneral(Exception ex) {
+        log.error("Unhandled exception: {}", ex.getMessage(), ex);
+        return ResponseEntity.status(HttpStatus.BAD_GATEWAY)
+                .body(Map.of("error", ex.getMessage() != null ? ex.getMessage() : "internal error"));
+    }
+}

+ 129 - 0
src/main/java/com/adx/tencent/httpapi/MediaPlacement.java

@@ -0,0 +1,129 @@
+package com.adx.tencent.httpapi;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 对应 Go httpapi.MediaPlacement。
+ * 持有百度媒体投放配置(mediaId / appId / tagId / 平台映射)。
+ */
+public class MediaPlacement {
+
+    private long baiduMediaId;
+    private String baiduAppId;
+    private String baiduTagId;
+    private Map<String, BaiduAppPlacement> platforms; // "android" | "ios"
+    private long bidFloor;
+    private List<Integer> actionTypes;
+
+    // ─── Getters / Setters ────────────────────────────────────────────────────
+
+    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, BaiduAppPlacement> getPlatforms() { return platforms; }
+    public void setPlatforms(Map<String, BaiduAppPlacement> 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; }
+
+    // ─── resolve() ───────────────────────────────────────────────────────────
+
+    /**
+     * 对应 Go MediaPlacement.Resolve()。
+     * 优先按 baidu_tag_id/tag_id 查,其次按 platform,最后用默认值。
+     */
+    public Resolved resolve(Map<String, String> params) {
+        String tagId = firstParam(params, "baidu_tag_id", "tag_id", "tagid");
+        if (tagId != null && !tagId.isBlank()) {
+            Resolved r = resolveByTagId(tagId);
+            if (r != null) return r;
+            throw new IllegalArgumentException("unknown baidu tag id: " + tagId);
+        }
+
+        String platform = normalizePlatform(firstParam(params, "platform", "os", "system", "device_os"));
+        if (platform != null && !platform.isBlank()) {
+            Resolved r = resolveByPlatform(platform);
+            if (r != null) return r;
+            throw new IllegalArgumentException("unsupported 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 baidu_tag_id or platform");
+    }
+
+    private Resolved resolveByTagId(String tagId) {
+        if (platforms != null) {
+            for (Map.Entry<String, BaiduAppPlacement> e : platforms.entrySet()) {
+                BaiduAppPlacement 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;
+        BaiduAppPlacement 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(
+                    "baidu_tag_id is required for 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;
+    }
+
+    static String normalizePlatform(String value) {
+        if (value == null) return null;
+        return switch (value.toLowerCase().trim()) {
+            case "ios", "iphone", "ipad", "1" -> "ios";
+            case "android", "安卓", "2" -> "android";
+            default -> null;
+        };
+    }
+
+    // ─── 嵌套类型 ─────────────────────────────────────────────────────────────
+
+    public static class BaiduAppPlacement {
+        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; }
+    }
+
+    /** 解析结果(对应 Go resolvedMediaPlacement) */
+    public record Resolved(String platform, String appId, String tagId) {}
+}

+ 356 - 0
src/main/java/com/adx/tencent/httpapi/TrackingController.java

@@ -0,0 +1,356 @@
+package com.adx.tencent.httpapi;
+
+import com.adx.tencent.baidu.AdxClient;
+import com.adx.tencent.baidu.model.NormalizedBidResponse;
+import com.adx.tencent.baidu.model.TrackingResult;
+import com.adx.tencent.storage.RedisHotStore;
+import com.adx.tencent.storage.model.BidRecord;
+import com.adx.tencent.storage.model.TrackingRecord;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.HttpStatus;
+import org.springframework.lang.Nullable;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.server.ResponseStatusException;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import java.time.Instant;
+import java.util.*;
+import java.util.concurrent.Semaphore;
+
+/**
+ * 腾讯监测链接接收端点。
+ * 提供曝光和点击监测 URL,接收腾讯广告平台的回调。
+ */
+@RestController
+public class TrackingController {
+
+    private static final Logger log = LoggerFactory.getLogger(TrackingController.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 RedisHotStore hotStore;
+    private final MediaPlacement tencentPlacement;
+    private final boolean allowIpOverride;
+
+    public TrackingController(AdxClient adxClient,
+                              RedisHotStore hotStore,
+                              @Nullable MediaPlacement tencentPlacement,
+                              @Value("${spring.profiles.active:}") String activeProfile) {
+        this.adxClient = adxClient;
+        this.hotStore = hotStore;
+        this.tencentPlacement = tencentPlacement;
+        this.allowIpOverride = "dev".equals(activeProfile) || "test".equals(activeProfile);
+    }
+
+    // ─── /health ────────────────────────────────────────────────────────────
+
+    @GetMapping("/health")
+    public Map<String, Boolean> health() {
+        return Map.of("ok", true);
+    }
+
+    // ─── GET /tencent/impression ─────────────────────────────────────────────
+
+    /**
+     * 接收腾讯曝光监测。
+     * 流程:解析参数 → 构造百度竞价请求 → 请求百度ADX → 缓存bid → 上报百度曝光 → 返回204
+     * 限制最多50个线程同时执行。
+     */
+    @GetMapping("/tencent/impression")
+    @ResponseStatus(HttpStatus.NO_CONTENT)
+    public void tencentImpression(HttpServletRequest request) throws Exception {
+        impressionSemaphore.acquire();
+        try {
+            doImpression(request);
+        } finally {
+            impressionSemaphore.release();
+        }
+    }
+
+    private void doImpression(HttpServletRequest request) throws Exception {
+        Map<String, String> params = flatQueryParams(request);
+        String traceId = mediaTraceId(params);
+        if (traceId == null || traceId.isBlank()) {
+            log.warn("[impression] 缺少 traceId, params={}", params.keySet());
+            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "missing media trace id (click_id/oaid/imei/idfa)");
+        }
+        if (tencentPlacement == null) {
+            throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "tencent placement not configured");
+        }
+
+        log.info("[impression] traceId={}, ip={}, params={}", traceId, clientIp(request), params);
+
+        Map<String, Object> bidRequest = mediaBidRequest(request, "tencent", traceId, params, tencentPlacement);
+        NormalizedBidResponse response = adxClient.requestBid(bidRequest);
+        if (response == null) {
+            log.warn("[impression] 百度ADX返回空响应, traceId={}", traceId);
+            throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "empty baidu ADX response");
+        }
+
+        log.info("[impression] 百度ADX返回: qk={}, hasBid={}, showUrls={}, clickUrls={}", 
+                response.getQk(),
+                response.getBid() != null,
+                response.getBid() != null && response.getBid().getShowUrls() != null ? response.getBid().getShowUrls().size() : 0,
+                response.getBid() != null && response.getBid().getClickUrls() != null ? response.getBid().getClickUrls().size() : 0);
+
+        BidRecord record = bidRecordFromMediaResponse(response, "tencent", traceId, params);
+        hotStore.recordBid(record);
+        log.info("[impression] 已缓存bid, qk={}, traceId={}", record.getQk(), traceId);
+
+        // 上报百度曝光
+        if (response.getBid() != null && response.getBid().getShowUrls() != null
+                && !response.getBid().getShowUrls().isEmpty()) {
+            List<TrackingResult> trackingResults = adxClient.reportImpression(
+                    response.getBid().getShowUrls(), response.getBid().getPrice());
+            log.info("[impression] 曝光上报完成, qk={}, results={}", response.getQk(), trackingResults);
+            recordTrackingResults(response.getQk(), "impression", trackingResults);
+        }
+    }
+
+    // ─── GET /tencent/click ──────────────────────────────────────────────────
+
+    /**
+     * 接收腾讯点击监测。
+     * 流程:查找已有bid → 若无则请求百度ADX → 上报百度点击 → 302重定向到落地页
+     * 限制最多50个线程同时执行。
+     */
+    @GetMapping("/tencent/click")
+    public Object tencentClick(HttpServletRequest request, HttpServletResponse response)
+            throws Exception {
+        clickSemaphore.acquire();
+        try {
+            return doClick(request, response);
+        } finally {
+            clickSemaphore.release();
+        }
+    }
+
+    private Object doClick(HttpServletRequest request, HttpServletResponse response)
+            throws Exception {
+        if (tencentPlacement == null) {
+            throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "tencent placement not configured");
+        }
+
+        Map<String, String> params = flatQueryParams(request);
+        String traceId = mediaTraceId(params);
+        if (traceId == null || traceId.isBlank()) {
+            log.warn("[click] 缺少 traceId, params={}", params.keySet());
+            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "missing media trace id (click_id/oaid/imei/idfa)");
+        }
+
+        log.info("[click] traceId={}, ip={}, params={}", traceId, clientIp(request), params);
+
+        // 先从 Redis 按 mediaTrace 查找已有 bid
+        BidRecord record = hotStore.findBidByMediaTrace("tencent", traceId);
+        if (record == null) {
+            log.info("[click] Redis未命中, 发起竞价请求, traceId={}", traceId);
+            // 未找到,临时发起竞价请求
+            Map<String, Object> bidRequest = mediaBidRequest(request, "tencent", traceId, params, tencentPlacement);
+            NormalizedBidResponse resp = adxClient.requestBid(bidRequest);
+            if (resp == null) {
+                log.warn("[click] 百度ADX返回空响应, traceId={}", traceId);
+                throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "empty baidu ADX response");
+            }
+            BidRecord newRecord = bidRecordFromMediaResponse(resp, "tencent", traceId, params);
+            hotStore.recordBid(newRecord);
+            record = newRecord;
+            log.info("[click] 竞价成功, qk={}, traceId={}", record.getQk(), traceId);
+        } else {
+            log.info("[click] Redis命中, qk={}, traceId={}", record.getQk(), traceId);
+        }
+
+        // 上报百度点击
+        List<TrackingResult> trackingResults = adxClient.reportClick(
+                record.getClickUrls() != null ? record.getClickUrls() : Collections.emptyList());
+        log.info("[click] 点击上报完成, qk={}, results={}", record.getQk(), trackingResults);
+        recordTrackingResults(record.getQk(), "click", trackingResults);
+
+        // 重定向到 landing page 或 app store link
+        String destination = record.getAppStoreLink() != null && !record.getAppStoreLink().isBlank()
+                ? record.getAppStoreLink() : record.getLandingPage();
+        if (destination != null && !destination.isBlank()) {
+            log.info("[click] 302重定向, qk={}, destination={}", record.getQk(), destination);
+            response.sendRedirect(destination);
+            return null;
+        }
+
+        return Map.of("qk", nvl(record.getQk()), "traceId", traceId, "tracking", trackingResults);
+    }
+
+    // ─── 私有工具方法 ────────────────────────────────────────────────────────
+
+    private BidRecord bidRecordFromMediaResponse(NormalizedBidResponse response,
+                                                  String media, String traceId,
+                                                  Map<String, String> mediaParams) {
+        BidRecord r = new BidRecord();
+        r.setQk(response.getQk());
+        r.setReqId(response.getReqId());
+        r.setBidId(response.getBidId());
+        r.setMedia(media);
+        r.setMediaTraceId(traceId);
+        r.setPlatform(mediaParams.get("baidu_platform"));
+        r.setMediaParams(mediaParams);
+        r.setCreatedAt(Instant.now());
+        if (response.getBid() != null) {
+            r.setCreativeId(response.getBid().getCrid());
+            r.setImpId(response.getBid().getImpId());
+            r.setTagId(response.getBid().getTagId());
+            r.setPrice(response.getBid().getPrice());
+            r.setShowUrls(response.getBid().getShowUrls());
+            r.setClickUrls(response.getBid().getClickUrls());
+            r.setLandingPage(response.getBid().getLandingPage());
+            r.setAppStoreLink(response.getBid().getAppStoreLink());
+            if (response.getBid().getAdm() != null) {
+                r.setPackageName(response.getBid().getAdm().getPackageName());
+            }
+        }
+        return r;
+    }
+
+    private Map<String, String> flatQueryParams(HttpServletRequest request) {
+        Map<String, String> params = new HashMap<>();
+        request.getParameterMap().forEach((k, v) -> {
+            if (v != null && v.length > 0) params.put(k, v[0]);
+        });
+        return params;
+    }
+
+    private String mediaTraceId(Map<String, String> params) {
+        for (String key : List.of("click_id", "trace_id", "callback", "request_id",
+                "oaid", "imei", "idfa", "caid")) {
+            String v = params.get(key);
+            if (v != null && !v.isBlank()) return v;
+        }
+        return null;
+    }
+
+    private Map<String, Object> mediaBidRequest(HttpServletRequest request, String media,
+                                                 String traceId, Map<String, String> params,
+                                                 MediaPlacement placement) {
+        MediaPlacement.Resolved resolved = placement.resolve(params);
+        List<Integer> actionTypes = placement.getActionTypes();
+        if (actionTypes == null || actionTypes.isEmpty()) actionTypes = List.of(1);
+
+        params.put("baidu_app_id", resolved.appId());
+        params.put("baidu_tag_id", resolved.tagId());
+        if (resolved.platform() != null && !resolved.platform().isBlank()) {
+            params.put("baidu_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", actionTypes);
+        imp.put("maxCount", 1);
+        if (placement.getBidFloor() > 0) imp.put("bidFloor", placement.getBidFloor());
+
+        Map<String, Object> device = new LinkedHashMap<>();
+        // 仅 dev/test 环境允许通过 ip 参数覆盖(本地测试用)
+        String ip = allowIpOverride ? params.get("ip") : null;
+        device.put("ip", (ip != null && !ip.isBlank()) ? ip : clientIp(request));
+        device.put("ua", AdxClient.BAIDU_USER_AGENT);
+        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());
+        Instant now = Instant.now();
+        long nanos = now.getEpochSecond() * 1_000_000_000L + now.getNano();
+        bidReq.put("reqId", traceId + "-" + nanos);
+        bidReq.put("imp", List.of(imp));
+        bidReq.put("device", device);
+        bidReq.put("media", media);
+        bidReq.put("traceId", traceId);
+        bidReq.put("mediaParams", params);
+        return bidReq;
+    }
+
+    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 Map<String, Object> baiduUidFromParams(Map<String, String> p) {
+        Map<String, Object> uid = new LinkedHashMap<>();
+        putUid(uid, "did", firstParam(p, "did", "imei"));
+        putUid(uid, "didMd5", firstParam(p, "did_md5", "didMd5", "imei_md5", "imeiMd5"));
+        putUid(uid, "dpid", firstParam(p, "dpid", "android_id", "androidId"));
+        putUid(uid, "dpidMd5", firstParam(p, "dpid_md5", "dpidMd5", "android_id_md5", "androidIdMd5"));
+        putUid(uid, "idfa", p.get("idfa"));
+        putUid(uid, "idfaMd5", firstParam(p, "idfa_md5", "idfaMd5"));
+        putUid(uid, "oaid", p.get("oaid"));
+        putUid(uid, "oaidMd5", firstParam(p, "oaid_md5", "oaidMd5"));
+        return uid;
+    }
+
+    private void putUid(Map<String, Object> uid, String key, String value) {
+        if (value != null && !value.isBlank()) uid.put(key, value);
+    }
+
+    private String firstParam(Map<String, String> p, String... keys) {
+        for (String k : keys) {
+            String v = p.get(k);
+            if (v != null && !v.isBlank()) return v.trim();
+        }
+        return null;
+    }
+
+    private void recordTrackingResults(String qk, String kind, List<TrackingResult> results) {
+        if (hotStore == null || results == null) return;
+        for (TrackingResult r : results) {
+            TrackingRecord record = new TrackingRecord();
+            record.setQk(qk);
+            record.setKind(kind);
+            record.setUrl(r.getUrl());
+            record.setStatus(r.getStatus());
+            record.setOk(r.isOk());
+            record.setCreatedAt(Instant.now());
+            hotStore.recordTracking(record);
+        }
+    }
+
+    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 nvl(String s) { return s != null ? s : ""; }
+}

+ 139 - 0
src/main/java/com/adx/tencent/leader/LeaderElection.java

@@ -0,0 +1,139 @@
+package com.adx.tencent.leader;
+
+import com.adx.tencent.storage.RedisLock;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.time.Duration;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.Consumer;
+
+/**
+ * 对应 Go internal/leader/leader.go 的 Run()。
+ * 通过 Redis 分布式锁竞选 Leader,赢得锁后执行 job 回调,
+ * 同时启动后台线程定期续约(renewUntilDone),job 结束后释放锁。
+ */
+public class LeaderElection {
+
+    private static final Logger log = LoggerFactory.getLogger(LeaderElection.class);
+
+    private final RedisLock redisLock;
+
+    public LeaderElection(RedisLock redisLock) {
+        this.redisLock = redisLock;
+    }
+
+    /**
+     * 循环竞选,获得锁后执行 job(阻塞直到 job 返回或 stopped 为 true)。
+     * 对应 Go leader.Run():job 通过 StopSignal 参数感知锁丢失(对应 Go 的 ctx.Done())。
+     *
+     * @param key            Redis 锁 Key
+     * @param ttl            锁 TTL
+     * @param renewInterval  续约间隔
+     * @param retryInterval  未获锁时重试间隔
+     * @param stopped        外部停止信号(volatile boolean 引用)
+     * @param job            获得锁后执行的任务,接收 per-election StopSignal(锁丢失时触发)
+     * @param onError        错误回调
+     */
+    public void run(String key, Duration ttl, Duration renewInterval, Duration retryInterval,
+                    StopSignal stopped, Consumer<StopSignal> job, Consumer<Exception> onError) {
+
+        Duration resolvedTtl           = ttl.isZero()           ? Duration.ofMinutes(2)  : ttl;
+        Duration resolvedRenewInterval = renewInterval.isZero() ? resolvedTtl.dividedBy(4) : renewInterval;
+        Duration resolvedRetryInterval = retryInterval.isZero() ? Duration.ofSeconds(15) : retryInterval;
+
+        while (!stopped.isStopped()) {
+            Lease lease;
+            try {
+                log.info("[LeaderElection] trying to acquire lock: {}", key);
+                lease = redisLock.tryAcquire(key, resolvedTtl);
+            } catch (Exception e) {
+                log.error("[LeaderElection] acquire lock {} failed: {}", key, e.getMessage(), e);
+                report(onError, "acquire lock " + key + ": " + e.getMessage(), e);
+                sleep(resolvedRetryInterval, stopped);
+                continue;
+            }
+
+            if (lease == null) {
+                // 未获到锁,等待后重试
+                log.info("[LeaderElection] lock {} not acquired, retrying in {}ms", key, resolvedRetryInterval.toMillis());
+                sleep(resolvedRetryInterval, stopped);
+                continue;
+            }
+
+            log.info("[LeaderElection] lock {} acquired, starting job", key);
+
+            // ─── 获得锁,创建 per-election 停止信号(对应 Go 的 jobCtx)────────
+            // 必须用 AtomicBoolean 保证 scheduler 线程写入对 job 线程立即可见
+            // (对应 Go context.Cancel 的天然 happens-before 语义)
+            final AtomicBoolean renewFailed = new AtomicBoolean(false);
+            StopSignal jobStop = () -> stopped.isStopped() || renewFailed.get();
+
+            // ─── 启动续约线程(对应 Go renewUntilDone goroutine)────────────────
+            ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
+                Thread t = new Thread(r, "leader-renew-" + key);
+                t.setDaemon(true);
+                return t;
+            });
+            ScheduledFuture<?> renewFuture = scheduler.scheduleAtFixedRate(() -> {
+                try {
+                    boolean ok = lease.renew(resolvedTtl);
+                    if (!ok) {
+                        report(onError, "lost lock " + key, null);
+                        renewFailed.set(true);
+                    }
+                } catch (Exception e) {
+                    report(onError, "renew lock " + key + ": " + e.getMessage(), e);
+                    renewFailed.set(true);
+                }
+            }, resolvedRenewInterval.toMillis(), resolvedRenewInterval.toMillis(), TimeUnit.MILLISECONDS);
+
+            // ─── 执行 job(传入 jobStop,job 应循环检查 jobStop.isStopped())────
+            try {
+                job.accept(jobStop);
+            } catch (Exception e) {
+                report(onError, "job error: " + e.getMessage(), e);
+            } finally {
+                renewFuture.cancel(true);
+                scheduler.shutdownNow();
+                try { lease.release(); } catch (Exception e) {
+                    report(onError, "release lock " + key + ": " + e.getMessage(), e);
+                }
+            }
+
+            sleep(resolvedRetryInterval, stopped);
+        }
+    }
+
+    private static void sleep(Duration d, StopSignal stopped) {
+        long deadline = System.currentTimeMillis() + d.toMillis();
+        while (!stopped.isStopped()) {
+            long remaining = deadline - System.currentTimeMillis();
+            if (remaining <= 0) break;
+            try {
+                Thread.sleep(Math.min(remaining, 200));
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+                break;
+            }
+        }
+    }
+
+    private static void report(Consumer<Exception> onError, String msg, Exception cause) {
+        if (onError != null) {
+            onError.accept(cause != null ? cause : new RuntimeException(msg));
+        } else {
+            log.error(msg, cause);
+        }
+    }
+
+    /** 外部停止信号接口(对应 Go 中的 ctx.Done()) */
+    @FunctionalInterface
+    public interface StopSignal {
+        boolean isStopped();
+    }
+}

+ 14 - 0
src/main/java/com/adx/tencent/leader/Lease.java

@@ -0,0 +1,14 @@
+package com.adx.tencent.leader;
+
+import java.time.Duration;
+
+/**
+ * 对应 Go leader.Lease 接口。
+ * 持有分布式锁租约,支持续约和释放。
+ */
+public interface Lease {
+    /** 续约;返回 false 表示锁已丢失(token 不匹配) */
+    boolean renew(Duration ttl);
+    /** 释放锁 */
+    void release();
+}

+ 275 - 0
src/main/java/com/adx/tencent/storage/RedisHotStore.java

@@ -0,0 +1,275 @@
+package com.adx.tencent.storage;
+
+import com.adx.tencent.storage.model.BidRecord;
+import com.adx.tencent.storage.model.QueuedEvent;
+import com.adx.tencent.storage.model.TrackingRecord;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.springframework.data.redis.connection.stream.*;
+import org.springframework.data.redis.core.StringRedisTemplate;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.nio.charset.StandardCharsets;
+import java.util.*;
+
+/**
+ * 对应 Go internal/storage/redis.go 的 RedisHotStore。
+ * 职责:
+ *   1. bid 记录缓存(adx:bid:{qk} / adx:media:{media}:{traceId})
+ *   2. Redis Stream 事件队列(adx:events)
+ *   3. BidFinder 接口
+ */
+public class RedisHotStore {
+
+    public static final String BID_NOT_FOUND_MSG = "bid not found";
+
+    private static final String DEFAULT_PREFIX = "adx:";
+    private static final String DEFAULT_STREAM  = "adx:events";
+
+    private final StringRedisTemplate redis;
+    private final ObjectMapper objectMapper;
+    private final String prefix;
+    private final String stream;
+    private final Duration bidTtl;
+
+    public RedisHotStore(StringRedisTemplate redis, ObjectMapper objectMapper,
+                         String prefix, String stream, Duration bidTtl) {
+        this.redis = redis;
+        this.objectMapper = objectMapper;
+        this.prefix = (prefix == null || prefix.isBlank()) ? DEFAULT_PREFIX : prefix;
+        this.stream = (stream == null || stream.isBlank()) ? DEFAULT_STREAM : stream;
+        this.bidTtl = (bidTtl == null || bidTtl.isZero()) ? Duration.ofHours(24) : bidTtl;
+    }
+
+    // ─── EventRecorder ───────────────────────────────────────────────────────
+
+    /**
+     * 对应 Go RecordBid:
+     *   Pipeline: SET bid:{qk} + SET media:{media}:{traceId} + XADD
+     */
+    public void recordBid(BidRecord record) {
+        if (record.getCreatedAt() == null) {
+            record.setCreatedAt(Instant.now());
+        }
+        try {
+            // Redis KEY 只存关键字段(瘦身),降低内存占用
+            String hotPayload = objectMapper.writeValueAsString(record.toHotRecord());
+            // Stream 存全量数据,供 ColdWorker 持久化到 TiDB
+            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().isEmpty()) {
+                    conn.stringCommands().set(
+                            bidKey(record.getQk()).getBytes(StandardCharsets.UTF_8),
+                            hotBytes,
+                            org.springframework.data.redis.core.types.Expiration.milliseconds(ttlMillis),
+                            org.springframework.data.redis.connection.RedisStringCommands.SetOption.UPSERT
+                    );
+                }
+                if (record.getMedia() != null && !record.getMedia().isEmpty()
+                        && record.getMediaTraceId() != null && !record.getMediaTraceId().isEmpty()) {
+                    conn.stringCommands().set(
+                            mediaTraceKey(record.getMedia(), record.getMediaTraceId()).getBytes(StandardCharsets.UTF_8),
+                            hotBytes,
+                            org.springframework.data.redis.core.types.Expiration.milliseconds(ttlMillis),
+                            org.springframework.data.redis.connection.RedisStringCommands.SetOption.UPSERT
+                    );
+                }
+                // XADD 全量数据到 Stream
+                byte[] streamKey = stream.getBytes(StandardCharsets.UTF_8);
+                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(streamKey, body));
+                return null;
+            });
+        } catch (Exception e) {
+            throw new RuntimeException("recordBid failed", e);
+        }
+    }
+
+    /**
+     * 对应 Go RecordTracking:XADD 到 Stream。
+     */
+    public void recordTracking(TrackingRecord 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("recordTracking failed", e);
+        }
+    }
+
+    // ─── BidFinder ───────────────────────────────────────────────────────────
+
+    public BidRecord findBidByQk(String qk) {
+        String payload = redis.opsForValue().get(bidKey(qk));
+        if (payload == null) return null;
+        return parseBidRecord(payload);
+    }
+
+    public BidRecord findBidByMediaTrace(String media, String traceId) {
+        String payload = redis.opsForValue().get(mediaTraceKey(media, traceId));
+        if (payload == null) return null;
+        return parseBidRecord(payload);
+    }
+
+    // ─── EventQueue ──────────────────────────────────────────────────────────
+
+    /**
+     * 对应 Go EnsureGroup:XGROUP CREATE MKSTREAM(忽略 BUSYGROUP 错误)。
+     */
+    public void ensureGroup(String group) {
+        try {
+            redis.opsForStream().createGroup(stream, ReadOffset.from("0"), group);
+        } catch (Exception e) {
+            if (isBusyGroupError(e)) {
+                return; // 组已存在,忽略
+            }
+            // Stream 可能不存在,先创建 Stream 再重试
+            try {
+                Map<String, String> initMap = java.util.Collections.singletonMap("_init", "1");
+                org.springframework.data.redis.connection.stream.RecordId id =
+                        redis.opsForStream().add(org.springframework.data.redis.connection.stream.StreamRecords
+                                .newRecord().ofMap(initMap).withStreamKey(stream));
+                if (id != null) {
+                    redis.opsForStream().delete(stream, id);
+                }
+                redis.opsForStream().createGroup(stream, ReadOffset.from("0"), group);
+            } catch (Exception retryEx) {
+                if (!isBusyGroupError(retryEx)) {
+                    throw new RuntimeException("ensureGroup failed", retryEx);
+                }
+            }
+        }
+    }
+
+    private boolean isBusyGroupError(Throwable e) {
+        while (e != null) {
+            if (e.getMessage() != null && e.getMessage().contains("BUSYGROUP")) {
+                return true;
+            }
+            e = e.getCause();
+        }
+        return false;
+    }
+
+    /**
+     * 对应 Go Read:XREADGROUP GROUP {group} {consumer} COUNT {count} BLOCK 1000 STREAMS {stream} >
+     */
+    public List<QueuedEvent> read(String group, String consumer, int count) {
+        if (count <= 0) count = 100;
+        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();
+            }
+            throw new RuntimeException("read stream failed", e);
+        }
+    }
+
+    /**
+     * 对应 Go ClaimStale:XAUTOCLAIM(转交超时未 ACK 的消息)。
+     * 注意:StreamOperations.autoClaim 在 Spring Data Redis 3.2.x 中不可用,
+     * 使用 XCLAIM + XPENDING 替代。
+     */
+    public List<QueuedEvent> claimStale(String group, String consumer, Duration minIdle, int count) {
+        if (count <= 0) count = 100;
+        if (minIdle == null || minIdle.isZero()) minIdle = Duration.ofMinutes(2);
+        try {
+            // 先获取 pending 消息
+            var pendingMessages = redis.opsForStream().pending(
+                    stream, group, org.springframework.data.domain.Range.unbounded(), count);
+            if (pendingMessages == null || !pendingMessages.iterator().hasNext()) {
+                return Collections.emptyList();
+            }
+            // 筛选超时的消息 ID
+            Duration threshold = minIdle;
+            List<String> staleIds = new ArrayList<>();
+            pendingMessages.forEach(msg -> {
+                if (msg.getElapsedTimeSinceLastDelivery().compareTo(threshold) >= 0) {
+                    staleIds.add(msg.getIdAsString());
+                }
+            });
+            if (staleIds.isEmpty()) return Collections.emptyList();
+            // XCLAIM
+            List<MapRecord<String, Object, Object>> claimed = redis.opsForStream().claim(
+                    stream, group, consumer, threshold,
+                    staleIds.stream().map(RecordId::of).toArray(RecordId[]::new));
+            return toQueuedEvents(claimed);
+        } catch (Exception e) {
+            // 退化到空列表
+            return Collections.emptyList();
+        }
+    }
+
+    /**
+     * 对应 Go Ack:XACK。
+     */
+    public void ack(String group, List<String> ids) {
+        if (ids == null || ids.isEmpty()) return;
+        String[] idArr = ids.toArray(new String[0]);
+        redis.opsForStream().acknowledge(stream, group, idArr);
+    }
+
+    /**
+     * 对应 Go Trim:XTRIMAPPROX。
+     */
+    public void trim(long maxLen) {
+        if (maxLen <= 0) return;
+        redis.opsForStream().trim(stream, maxLen, false);
+    }
+
+    // ─── 分布式锁支持(供 RedisLock 使用)────────────────────────────────────
+
+    public StringRedisTemplate getRedisTemplate() {
+        return redis;
+    }
+
+    // ─── 私有工具方法 ─────────────────────────────────────────────────────────
+
+    private String bidKey(String qk) {
+        return prefix + "bid:" + qk;
+    }
+
+    private String mediaTraceKey(String media, String traceId) {
+        return prefix + "media:" + media + ":" + traceId;
+    }
+
+    private BidRecord parseBidRecord(String payload) {
+        try {
+            return objectMapper.readValue(payload, BidRecord.class);
+        } catch (Exception e) {
+            throw new RuntimeException("parse BidRecord failed", e);
+        }
+    }
+
+    @SuppressWarnings("unchecked")
+    private List<QueuedEvent> toQueuedEvents(List<MapRecord<String, Object, Object>> records) {
+        if (records == null) return Collections.emptyList();
+        List<QueuedEvent> events = new ArrayList<>();
+        for (MapRecord<String, Object, Object> rec : records) {
+            Map<Object, Object> values = rec.getValue();
+            QueuedEvent event = new QueuedEvent();
+            event.setId(rec.getId().getValue());
+            event.setType(String.valueOf(values.getOrDefault("type", "")));
+            event.setPayload(String.valueOf(values.getOrDefault("payload", "")));
+            events.add(event);
+        }
+        return events;
+    }
+}

+ 109 - 0
src/main/java/com/adx/tencent/storage/RedisLock.java

@@ -0,0 +1,109 @@
+package com.adx.tencent.storage;
+
+import com.adx.tencent.leader.Lease;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.data.redis.core.script.DefaultRedisScript;
+
+import java.security.SecureRandom;
+import java.time.Duration;
+import java.util.Collections;
+import java.util.HexFormat;
+
+/**
+ * 对应 Go internal/storage/redis_lock.go。
+ * 基于 Redis SETNX + Lua 脚本 实现分布式锁。
+ *
+ * 续约脚本(renewLockScript):仅当 key 的 value == token 时才 PEXPIRE。
+ * 释放脚本(releaseLockScript):仅当 key 的 value == token 时才 DEL。
+ */
+public class RedisLock {
+
+    private static final String RENEW_SCRIPT =
+            "if redis.call('GET', KEYS[1]) == ARGV[1] then\n" +
+            "  return redis.call('PEXPIRE', KEYS[1], ARGV[2])\n" +
+            "end\n" +
+            "return 0";
+
+    private static final String RELEASE_SCRIPT =
+            "if redis.call('GET', KEYS[1]) == ARGV[1] then\n" +
+            "  return redis.call('DEL', KEYS[1])\n" +
+            "end\n" +
+            "return 0";
+
+    private final StringRedisTemplate redis;
+
+    public RedisLock(StringRedisTemplate redis) {
+        this.redis = redis;
+    }
+
+    /**
+     * 尝试获取锁。对应 Go Acquire()。
+     * 返回 null 表示未获取到锁,非 null 表示获取成功的 Lease。
+     */
+    public Lease tryAcquire(String key, Duration ttl) {
+        if (key == null || key.isBlank()) throw new IllegalArgumentException("lock key is required");
+        if (ttl == null || ttl.isZero() || ttl.isNegative()) throw new IllegalArgumentException("lock ttl must be positive");
+
+        String token = newToken();
+        Boolean acquired = redis.opsForValue().setIfAbsent(key, token, ttl);
+        if (Boolean.TRUE.equals(acquired)) {
+            return new RedisLease(redis, key, token);
+        }
+        return null;
+    }
+
+    private static String newToken() {
+        byte[] bytes = new byte[16];
+        new SecureRandom().nextBytes(bytes);
+        return HexFormat.of().formatHex(bytes);
+    }
+
+    // ─── 内部 Lease 实现 ─────────────────────────────────────────────────────
+
+    public static class RedisLease implements Lease {
+        private final StringRedisTemplate redis;
+        private final String key;
+        private final String token;
+
+        private static final DefaultRedisScript<Long> RENEW_REDIS_SCRIPT;
+        private static final DefaultRedisScript<Long> RELEASE_REDIS_SCRIPT;
+
+        static {
+            RENEW_REDIS_SCRIPT = new DefaultRedisScript<>();
+            RENEW_REDIS_SCRIPT.setScriptText(RedisLock.RENEW_SCRIPT);
+            RENEW_REDIS_SCRIPT.setResultType(Long.class);
+
+            RELEASE_REDIS_SCRIPT = new DefaultRedisScript<>();
+            RELEASE_REDIS_SCRIPT.setScriptText(RedisLock.RELEASE_SCRIPT);
+            RELEASE_REDIS_SCRIPT.setResultType(Long.class);
+        }
+
+        RedisLease(StringRedisTemplate redis, String key, String token) {
+            this.redis = redis;
+            this.key   = key;
+            this.token = token;
+        }
+
+        /**
+         * 续约:对应 Go Renew()。返回 true 表示续约成功(锁仍属于我们)。
+         */
+        @Override
+        public boolean renew(Duration ttl) {
+            Long result = redis.execute(RENEW_REDIS_SCRIPT,
+                    Collections.singletonList(key),
+                    token,
+                    String.valueOf(ttl.toMillis()));
+            return Long.valueOf(1).equals(result);
+        }
+
+        /**
+         * 释放:对应 Go Release()。
+         */
+        @Override
+        public void release() {
+            redis.execute(RELEASE_REDIS_SCRIPT,
+                    Collections.singletonList(key),
+                    token);
+        }
+    }
+}

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

@@ -0,0 +1,253 @@
+package com.adx.tencent.storage;
+
+import com.adx.tencent.storage.mapper.*;
+import com.adx.tencent.storage.model.*;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.ibatis.session.SqlSession;
+import org.apache.ibatis.session.SqlSessionFactory;
+
+import java.sql.Statement;
+import java.sql.Timestamp;
+import java.time.Instant;
+import java.util.List;
+
+/**
+ * 对应 Go internal/storage/tidb.go 的 TiDBColdStore。
+ * 使用 MyBatis 操作 TiDB(MySQL 协议兼容)。
+ * 功能:
+ *   1. migrate() - 建表 DDL
+ *   2. saveBid / saveTracking / saveConversion / saveMediaCallback
+ *   3. successfulCallbackExists / successfulCallbackExistsForEvent
+ *   4. pendingMediaCallbacks
+ */
+public class TiDBColdStore {
+
+    private final SqlSessionFactory sqlSessionFactory;
+    private final ObjectMapper objectMapper;
+
+    public TiDBColdStore(SqlSessionFactory sqlSessionFactory, ObjectMapper objectMapper) {
+        this.sqlSessionFactory = sqlSessionFactory;
+        this.objectMapper = objectMapper;
+    }
+
+    // ─── DDL 迁移 ────────────────────────────────────────────────────────────
+
+    public void migrate() {
+        String[] ddls = {
+            """
+            CREATE TABLE IF NOT EXISTS tencent_ad_bid_events (
+                id BIGINT AUTO_INCREMENT PRIMARY KEY,
+                qk VARCHAR(128) NOT NULL,
+                media VARCHAR(64),
+                media_trace_id VARCHAR(255),
+                platform VARCHAR(16),
+                req_id VARCHAR(512),
+                bid_id VARCHAR(128),
+                creative_id VARCHAR(128),
+                imp_id VARCHAR(128),
+                tag_id VARCHAR(128),
+                price BIGINT UNSIGNED,
+                show_urls JSON,
+                click_urls JSON,
+                landing_page TEXT,
+                app_store_link TEXT,
+                package_name VARCHAR(255),
+                media_params JSON,
+                created_at TIMESTAMP(3) NOT NULL,
+                UNIQUE KEY uk_tencent_ad_bid_events_qk (qk),
+                KEY idx_tencent_ad_bid_events_media_trace (media, media_trace_id),
+                KEY idx_tencent_ad_bid_events_platform_tag (platform, tag_id),
+                KEY idx_tencent_ad_bid_events_created_at (created_at)
+            )
+            """,
+            """
+            CREATE TABLE IF NOT EXISTS tencent_tracking_reports (
+                id BIGINT AUTO_INCREMENT PRIMARY KEY,
+                qk VARCHAR(128),
+                kind VARCHAR(32) NOT NULL,
+                tracking_url TEXT NOT NULL,
+                status INT NOT NULL,
+                ok TINYINT(1) NOT NULL,
+                created_at TIMESTAMP(3) NOT NULL,
+                KEY idx_tencent_tracking_reports_qk_kind_created_at (qk, kind, created_at)
+            )
+            """,
+            """
+            CREATE TABLE IF NOT EXISTS tencent_baidu_conversions (
+                id BIGINT AUTO_INCREMENT PRIMARY KEY,
+                dedupe_key VARCHAR(255),
+                qk VARCHAR(128),
+                media VARCHAR(64),
+                date_value VARCHAR(16),
+                appsid VARCHAR(128),
+                customer_name VARCHAR(128),
+                device_id VARCHAR(255),
+                conv DOUBLE,
+                payment DOUBLE,
+                act INT,
+                tu VARCHAR(128),
+                clk_time VARCHAR(32),
+                created_at TIMESTAMP(3) NOT NULL,
+                UNIQUE KEY uk_tencent_baidu_conversions_dedupe (dedupe_key),
+                KEY idx_tencent_baidu_conversions_qk_act_created_at (qk, act, created_at),
+                KEY idx_tencent_baidu_conversions_device_id (device_id)
+            )
+            """,
+            """
+            CREATE TABLE IF NOT EXISTS tencent_media_callbacks (
+                id BIGINT AUTO_INCREMENT PRIMARY KEY,
+                dedupe_key VARCHAR(255),
+                media VARCHAR(64) NOT NULL,
+                qk VARCHAR(128),
+                callback_url TEXT NOT NULL,
+                event_type INT NOT NULL,
+                event_time_ms BIGINT NOT NULL,
+                purchase DOUBLE,
+                status INT NOT NULL,
+                ok TINYINT(1) NOT NULL,
+                attempt INT NOT NULL DEFAULT 1,
+                response_body TEXT,
+                error_message TEXT,
+                created_at TIMESTAMP(3) NOT NULL,
+                KEY idx_tencent_media_callbacks_dedupe_ok_created_at (dedupe_key, ok, created_at),
+                KEY idx_tencent_media_callbacks_qk_created_at (qk, created_at),
+                KEY idx_tencent_media_callbacks_media_event_created_at (media, event_type, created_at)
+            )
+            """
+        };
+        try (SqlSession session = sqlSessionFactory.openSession(true)) {
+            Statement stmt = session.getConnection().createStatement();
+            for (String ddl : ddls) {
+                stmt.execute(ddl);
+            }
+            // 字段长度补丁(已存在的表可能是旧的 128)
+            try {
+                stmt.execute("ALTER TABLE tencent_ad_bid_events MODIFY COLUMN req_id VARCHAR(512)");
+            } catch (Exception ignored) {}
+            // platform 字段补丁
+            try {
+                stmt.execute("ALTER TABLE tencent_ad_bid_events ADD COLUMN platform VARCHAR(16) AFTER media_trace_id");
+            } catch (Exception ignored) {}
+            try {
+                stmt.execute("ALTER TABLE tencent_ad_bid_events ADD KEY idx_tencent_ad_bid_events_platform_tag (platform, tag_id)");
+            } catch (Exception ignored) {}
+            stmt.close();
+        } catch (Exception e) {
+            throw new RuntimeException("migrate failed", e);
+        }
+    }
+
+    // ─── SaveBid ─────────────────────────────────────────────────────────────
+
+    public void saveBid(BidRecord record) {
+        if (record.getQk() == null || record.getQk().isBlank()) {
+            throw new IllegalArgumentException("qk is required");
+        }
+        if (record.getCreatedAt() == null) record.setCreatedAt(Instant.now());
+
+        String showUrls   = toJson(record.getShowUrls());
+        String clickUrls  = toJson(record.getClickUrls());
+        String mediaParams = toJson(record.getMediaParams());
+
+        try (SqlSession session = sqlSessionFactory.openSession(true)) {
+            BidEventMapper mapper = session.getMapper(BidEventMapper.class);
+            mapper.insertOrUpdate(
+                record.getQk(), record.getMedia(), record.getMediaTraceId(),
+                record.getPlatform(),
+                record.getReqId(), record.getBidId(), record.getCreativeId(),
+                record.getImpId(), record.getTagId(), record.getPrice() != null ? record.getPrice() : 0L,
+                showUrls, clickUrls, record.getLandingPage(),
+                record.getAppStoreLink(), record.getPackageName(),
+                mediaParams, Timestamp.from(record.getCreatedAt())
+            );
+        }
+    }
+
+    // ─── SaveTracking ─────────────────────────────────────────────────────────
+
+    public void saveTracking(TrackingRecord record) {
+        if (record.getCreatedAt() == null) record.setCreatedAt(Instant.now());
+        try (SqlSession session = sqlSessionFactory.openSession(true)) {
+            TrackingReportMapper mapper = session.getMapper(TrackingReportMapper.class);
+            mapper.insert(
+                record.getQk(), record.getKind(), record.getUrl(),
+                record.getStatus(), record.isOk() ? 1 : 0,
+                Timestamp.from(record.getCreatedAt())
+            );
+        }
+    }
+
+    // ─── SaveConversion ──────────────────────────────────────────────────────
+
+    public void saveConversion(ConversionRecord record) {
+        if (record.getCreatedAt() == null) record.setCreatedAt(Instant.now());
+        try (SqlSession session = sqlSessionFactory.openSession(true)) {
+            ConversionMapper mapper = session.getMapper(ConversionMapper.class);
+            mapper.insertOrUpdate(
+                record.getDedupeKey(), record.getQk(), record.getMedia(),
+                record.getDate(), record.getAppSid(), record.getCustomerName(),
+                record.getDeviceId(), record.getConv(), record.getPayment(),
+                record.getAct(), record.getTu(), record.getClkTime(),
+                Timestamp.from(record.getCreatedAt())
+            );
+        }
+    }
+
+    // ─── SaveMediaCallback ───────────────────────────────────────────────────
+
+    public void saveMediaCallback(MediaCallbackRecord record) {
+        if (record.getCreatedAt() == null) record.setCreatedAt(Instant.now());
+        int attempt = record.getAttempt() <= 0 ? 1 : record.getAttempt();
+        try (SqlSession session = sqlSessionFactory.openSession(true)) {
+            MediaCallbackMapper mapper = session.getMapper(MediaCallbackMapper.class);
+            mapper.insert(
+                record.getDedupeKey(), record.getMedia(), record.getQk(),
+                record.getCallbackUrl(), record.getEventType(), record.getEventTimeMs(),
+                record.getPurchase(), record.getStatus(), record.isOk() ? 1 : 0,
+                attempt, record.getResponseBody(), record.getErrorMessage(),
+                Timestamp.from(record.getCreatedAt())
+            );
+        }
+    }
+
+    // ─── 查询:成功回调是否存在 ────────────────────────────────────────────────
+
+    public boolean successfulCallbackExists(String dedupeKey) {
+        if (dedupeKey == null || dedupeKey.isBlank()) return false;
+        try (SqlSession session = sqlSessionFactory.openSession(true)) {
+            MediaCallbackMapper mapper = session.getMapper(MediaCallbackMapper.class);
+            int count = mapper.countSuccessfulByDedupeKey(dedupeKey);
+            return count > 0;
+        }
+    }
+
+    public boolean successfulCallbackExistsForEvent(String dedupeKey, String legacyDedupeKey, int eventType) {
+        if (dedupeKey == null || dedupeKey.isBlank()) return false;
+        try (SqlSession session = sqlSessionFactory.openSession(true)) {
+            MediaCallbackMapper mapper = session.getMapper(MediaCallbackMapper.class);
+            int count = mapper.countSuccessfulForEvent(dedupeKey, legacyDedupeKey, eventType);
+            return count > 0;
+        }
+    }
+
+    // ─── 查询:待重试的回调 ────────────────────────────────────────────────────
+
+    public List<MediaCallbackRecord> pendingMediaCallbacks(String media, int limit) {
+        if (limit <= 0) limit = 100;
+        try (SqlSession session = sqlSessionFactory.openSession(true)) {
+            MediaCallbackMapper mapper = session.getMapper(MediaCallbackMapper.class);
+            return mapper.selectPendingCallbacks(media, limit);
+        }
+    }
+
+    // ─── 工具方法 ─────────────────────────────────────────────────────────────
+
+    private String toJson(Object value) {
+        if (value == null) return "null";
+        try {
+            return objectMapper.writeValueAsString(value);
+        } catch (Exception e) {
+            throw new RuntimeException("JSON serialize failed", e);
+        }
+    }
+}

+ 25 - 0
src/main/java/com/adx/tencent/storage/mapper/BidEventMapper.java

@@ -0,0 +1,25 @@
+package com.adx.tencent.storage.mapper;
+
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+@Mapper
+public interface BidEventMapper {
+    void insertOrUpdate(@Param("qk") String qk,
+                        @Param("media") String media,
+                        @Param("mediaTraceId") String mediaTraceId,
+                        @Param("platform") String platform,
+                        @Param("reqId") String reqId,
+                        @Param("bidId") String bidId,
+                        @Param("creativeId") String creativeId,
+                        @Param("impId") String impId,
+                        @Param("tagId") String tagId,
+                        @Param("price") long price,
+                        @Param("showUrls") String showUrls,
+                        @Param("clickUrls") String clickUrls,
+                        @Param("landingPage") String landingPage,
+                        @Param("appStoreLink") String appStoreLink,
+                        @Param("packageName") String packageName,
+                        @Param("mediaParams") String mediaParams,
+                        @Param("createdAt") java.sql.Timestamp createdAt);
+}

+ 21 - 0
src/main/java/com/adx/tencent/storage/mapper/ConversionMapper.java

@@ -0,0 +1,21 @@
+package com.adx.tencent.storage.mapper;
+
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+@Mapper
+public interface ConversionMapper {
+    void insertOrUpdate(@Param("dedupeKey") String dedupeKey,
+                        @Param("qk") String qk,
+                        @Param("media") String media,
+                        @Param("dateValue") String dateValue,
+                        @Param("appsid") String appsid,
+                        @Param("customerName") String customerName,
+                        @Param("deviceId") String deviceId,
+                        @Param("conv") double conv,
+                        @Param("payment") double payment,
+                        @Param("act") int act,
+                        @Param("tu") String tu,
+                        @Param("clkTime") String clkTime,
+                        @Param("createdAt") java.sql.Timestamp createdAt);
+}

+ 33 - 0
src/main/java/com/adx/tencent/storage/mapper/MediaCallbackMapper.java

@@ -0,0 +1,33 @@
+package com.adx.tencent.storage.mapper;
+
+import com.adx.tencent.storage.model.MediaCallbackRecord;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+@Mapper
+public interface MediaCallbackMapper {
+    void insert(@Param("dedupeKey") String dedupeKey,
+                @Param("media") String media,
+                @Param("qk") String qk,
+                @Param("callbackUrl") String callbackUrl,
+                @Param("eventType") int eventType,
+                @Param("eventTimeMs") long eventTimeMs,
+                @Param("purchase") double purchase,
+                @Param("status") int status,
+                @Param("ok") int ok,
+                @Param("attempt") int attempt,
+                @Param("responseBody") String responseBody,
+                @Param("errorMessage") String errorMessage,
+                @Param("createdAt") java.sql.Timestamp createdAt);
+
+    int countSuccessfulByDedupeKey(@Param("dedupeKey") String dedupeKey);
+
+    int countSuccessfulForEvent(@Param("dedupeKey") String dedupeKey,
+                                @Param("legacyDedupeKey") String legacyDedupeKey,
+                                @Param("eventType") int eventType);
+
+    List<MediaCallbackRecord> selectPendingCallbacks(@Param("media") String media,
+                                                     @Param("limit") int limit);
+}

+ 14 - 0
src/main/java/com/adx/tencent/storage/mapper/TrackingReportMapper.java

@@ -0,0 +1,14 @@
+package com.adx.tencent.storage.mapper;
+
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+@Mapper
+public interface TrackingReportMapper {
+    void insert(@Param("qk") String qk,
+                @Param("kind") String kind,
+                @Param("trackingUrl") String trackingUrl,
+                @Param("status") int status,
+                @Param("ok") int ok,
+                @Param("createdAt") java.sql.Timestamp createdAt);
+}

+ 104 - 0
src/main/java/com/adx/tencent/storage/model/BidRecord.java

@@ -0,0 +1,104 @@
+package com.adx.tencent.storage.model;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import java.time.Instant;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class BidRecord {
+    @JsonProperty("qk")           private String qk;
+    @JsonProperty("media")        private String media;
+    @JsonProperty("mediaTraceId") private String mediaTraceId;
+    @JsonProperty("platform")     private String platform;
+    @JsonProperty("reqId")        private String reqId;
+    @JsonProperty("bidId")        private String bidId;
+    @JsonProperty("creativeId")   private String creativeId;
+    @JsonProperty("impId")        private String impId;
+    @JsonProperty("tagId")        private String tagId;
+    @JsonProperty("price")        private Long price;
+    @JsonProperty("showUrls")     private List<String> showUrls;
+    @JsonProperty("clickUrls")    private List<String> clickUrls;
+    @JsonProperty("landingPage")  private String landingPage;
+    @JsonProperty("appStoreLink") private String appStoreLink;
+    @JsonProperty("packageName")  private String packageName;
+    @JsonProperty("mediaParams")  private Map<String, String> mediaParams;
+    @JsonProperty("createdAt")    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 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; }
+    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; }
+
+    /**
+     * 返回一个只包含 Redis 热存储所需关键字段的瘦身副本。
+     * - 排除 reqId、bidId、creativeId、impId、tagId、price、showUrls、packageName
+     * - mediaParams 只保留设备标识相关字段(转化回传需要)
+     * - 不存 createdAt(Redis TTL 管理过期)
+     */
+    public BidRecord toHotRecord() {
+        BidRecord hot = new BidRecord();
+        hot.qk = this.qk;
+        hot.media = this.media;
+        hot.mediaTraceId = this.mediaTraceId;
+        hot.platform = this.platform;
+        hot.clickUrls = this.clickUrls;
+        hot.landingPage = this.landingPage;
+        hot.appStoreLink = this.appStoreLink;
+        hot.mediaParams = slimDeviceParams(this.mediaParams);
+        return hot;
+    }
+
+    /**
+     * 只保留转化回传所需的设备标识字段,过滤掉其余无关参数。
+     */
+    private static Map<String, String> slimDeviceParams(Map<String, String> params) {
+        if (params == null || params.isEmpty()) return null;
+        List<String> deviceKeys = List.of(
+                "imei", "imei_md5",
+                "idfa", "idfa_md5",
+                "oaid", "oaid_md5",
+                "android_id", "android_id_md5"
+        );
+        Map<String, String> slim = new LinkedHashMap<>();
+        for (String key : deviceKeys) {
+            String v = params.get(key);
+            if (v != null && !v.isBlank()) {
+                slim.put(key, v);
+            }
+        }
+        return slim.isEmpty() ? null : slim;
+    }
+}

+ 47 - 0
src/main/java/com/adx/tencent/storage/model/ConversionRecord.java

@@ -0,0 +1,47 @@
+package com.adx.tencent.storage.model;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import java.time.Instant;
+
+public class ConversionRecord {
+    @JsonProperty("dedupeKey")    private String dedupeKey;
+    @JsonProperty("qk")           private String qk;
+    @JsonProperty("media")        private String media;
+    @JsonProperty("date")         private String date;
+    @JsonProperty("appsid")       private String appSid;
+    @JsonProperty("customerName") private String customerName;
+    @JsonProperty("deviceid")     private String deviceId;
+    @JsonProperty("conv")         private double conv;
+    @JsonProperty("payment")      private double payment;
+    @JsonProperty("act")          private int act;
+    @JsonProperty("tu")           private String tu;
+    @JsonProperty("clkTime")      private String clkTime;
+    @JsonProperty("createdAt")    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 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 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; }
+}

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

@@ -0,0 +1,47 @@
+package com.adx.tencent.storage.model;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import java.time.Instant;
+
+public class MediaCallbackRecord {
+    @JsonProperty("dedupeKey")    private String dedupeKey;
+    @JsonProperty("media")        private String media;
+    @JsonProperty("qk")           private String qk;
+    @JsonProperty("callbackUrl")  private String callbackUrl;
+    @JsonProperty("eventType")    private int eventType;
+    @JsonProperty("eventTimeMs")  private long eventTimeMs;
+    @JsonProperty("purchase")     private double purchase;
+    @JsonProperty("status")       private int status;
+    @JsonProperty("ok")           private boolean ok;
+    @JsonProperty("attempt")      private int attempt;
+    @JsonProperty("responseBody") private String responseBody;
+    @JsonProperty("errorMessage") private String errorMessage;
+    @JsonProperty("createdAt")    private Instant createdAt;
+
+    public String getDedupeKey() { return dedupeKey; }
+    public void setDedupeKey(String v) { this.dedupeKey = v; }
+    public String getMedia() { return media; }
+    public void setMedia(String v) { this.media = v; }
+    public String getQk() { return qk; }
+    public void setQk(String v) { this.qk = 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 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 Instant getCreatedAt() { return createdAt; }
+    public void setCreatedAt(Instant v) { this.createdAt = v; }
+}

+ 14 - 0
src/main/java/com/adx/tencent/storage/model/QueuedEvent.java

@@ -0,0 +1,14 @@
+package com.adx.tencent.storage.model;
+
+public class QueuedEvent {
+    private String id;
+    private String type;
+    private String payload;
+
+    public String getId() { return id; }
+    public void setId(String v) { this.id = v; }
+    public String getType() { return type; }
+    public void setType(String v) { this.type = v; }
+    public String getPayload() { return payload; }
+    public void setPayload(String v) { this.payload = v; }
+}

+ 26 - 0
src/main/java/com/adx/tencent/storage/model/TrackingRecord.java

@@ -0,0 +1,26 @@
+package com.adx.tencent.storage.model;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import java.time.Instant;
+
+public class TrackingRecord {
+    @JsonProperty("qk")        private String qk;
+    @JsonProperty("kind")      private String kind;
+    @JsonProperty("url")       private String url;
+    @JsonProperty("status")    private int status;
+    @JsonProperty("ok")        private boolean ok;
+    @JsonProperty("createdAt") private Instant createdAt;
+
+    public String getQk() { return qk; }
+    public void setQk(String v) { this.qk = 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; }
+}

+ 354 - 0
src/main/java/com/adx/tencent/tencent/TencentClient.java

@@ -0,0 +1,354 @@
+package com.adx.tencent.tencent;
+
+import com.adx.tencent.baidu.model.PaymentInfo;
+import com.adx.tencent.storage.model.BidRecord;
+import com.adx.tencent.storage.model.MediaCallbackRecord;
+import com.adx.tencent.tencent.model.TencentResponse;
+import com.adx.tencent.tencent.model.UserAction;
+import com.adx.tencent.tencent.model.UserId;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.security.MessageDigest;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.*;
+
+/**
+ * 腾讯行为回传客户端。
+ * 调用腾讯 Marketing API user_actions/add 接口回传转化数据。
+ *
+ * API文档: https://developers.e.qq.com/v3.0/docs/api/user_actions/add
+ */
+public class TencentClient {
+
+    private static final Logger log = LoggerFactory.getLogger(TencentClient.class);
+
+    private final String apiBaseUrl;
+    private final String accessToken;
+    private final long accountId;
+    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, String accessToken, long accountId,
+                         long androidUserActionSetId, long iosUserActionSetId,
+                         Map<Integer, String> actMapOverride,
+                         ObjectMapper objectMapper) {
+        this.apiBaseUrl = apiBaseUrl.replaceAll("/+$", "");
+        this.accessToken = accessToken;
+        this.accountId = accountId;
+        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);
+    }
+
+    /**
+     * 获取百度 act 对应的腾讯 action_type。
+     */
+    public String conversionActionType(int baiduAct) {
+        String actionType = actMap.get(baiduAct);
+        if (actionType == null) {
+            throw new IllegalArgumentException("unsupported baidu act for tencent mapping: " + baiduAct);
+        }
+        return actionType;
+    }
+
+    /**
+     * 根据平台获取对应的 user_action_set_id。
+     */
+    public long getUserActionSetId(String platform) {
+        if ("ios".equalsIgnoreCase(platform)) {
+            return iosUserActionSetId;
+        }
+        return androidUserActionSetId;
+    }
+
+    /**
+     * 发送转化事件到腾讯。
+     * 构造 user_actions/add 请求体并发送 POST 请求。
+     *
+     * @param bid      匹配到的竞价记录
+     * @param payment  百度转化支付信息
+     * @param platform 平台标识("android" 或 "ios")
+     * @return MediaCallbackRecord 记录回传结果(包含成功/失败信息)
+     */
+    public MediaCallbackRecord sendConversionEvent(BidRecord bid, PaymentInfo payment, String platform) {
+        long eventTimeMs = Instant.now().toEpochMilli();
+        String actionType = conversionActionType(payment.getAct());
+
+        MediaCallbackRecord record = new MediaCallbackRecord();
+        record.setMedia("tencent");
+        record.setQk(payment.getQk());
+        record.setEventType(payment.getAct());
+        record.setEventTimeMs(eventTimeMs);
+        record.setPurchase(payment.getPayment());
+        record.setAttempt(1);
+        record.setCreatedAt(Instant.now());
+
+        // 构造请求 URL
+        long timestamp = Instant.now().getEpochSecond();
+        String nonce = UUID.randomUUID().toString().replace("-", "").substring(0, 32);
+        String url = String.format("%s/user_actions/add?access_token=%s&timestamp=%d&nonce=%s",
+                apiBaseUrl, accessToken, timestamp, nonce);
+        record.setCallbackUrl(url);
+
+        // 构造请求 Body
+        UserAction action = buildUserAction(bid, payment, actionType, eventTimeMs);
+
+        long actionSetId = getUserActionSetId(platform);
+        Map<String, Object> body = new LinkedHashMap<>();
+        body.put("account_id", accountId);
+        body.put("user_action_set_id", actionSetId);
+        body.put("actions", List.of(action));
+
+        try {
+            byte[] payload = objectMapper.writeValueAsBytes(body);
+            log.info("[Tencent] sending user_action: qk={}, actionType={}, baiduAct={}",
+                    payment.getQk(), actionType, payment.getAct());
+
+            HttpRequest request = HttpRequest.newBuilder()
+                    .uri(URI.create(url))
+                    .POST(HttpRequest.BodyPublishers.ofByteArray(payload))
+                    .header("Content-Type", "application/json")
+                    .timeout(Duration.ofSeconds(15))
+                    .build();
+
+            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={}",
+                    payment.getQk(), actionType, status, record.isOk());
+        } catch (Exception e) {
+            record.setOk(false);
+            record.setErrorMessage(e.getMessage());
+            log.warn("[Tencent] callback failed: qk={}, actionType={}, error={}",
+                    payment.getQk(), actionType, e.getMessage());
+        }
+        return record;
+    }
+
+    /**
+     * 重试已有回调记录。
+     */
+    public MediaCallbackRecord retryCallback(MediaCallbackRecord original)
+            throws IOException, InterruptedException {
+        MediaCallbackRecord retry = copyRecord(original);
+        retry.setAttempt(original.getAttempt() + 1);
+        retry.setCreatedAt(Instant.now());
+
+        try {
+            log.info("[Tencent] retrying callback: dedupeKey={}, attempt={}",
+                    original.getDedupeKey(), retry.getAttempt());
+
+            // 重新构造请求(使用新的 timestamp 和 nonce)
+            long timestamp = Instant.now().getEpochSecond();
+            String nonce = UUID.randomUUID().toString().replace("-", "").substring(0, 32);
+            String url = String.format("%s/user_actions/add?access_token=%s&timestamp=%d&nonce=%s",
+                    apiBaseUrl, accessToken, 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();
+
+            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);
+            }
+
+            log.info("[Tencent] retry result: dedupeKey={}, status={}, ok={}",
+                    original.getDedupeKey(), status, retry.isOk());
+        } catch (IOException | InterruptedException e) {
+            retry.setOk(false);
+            retry.setErrorMessage(e.getMessage());
+            log.warn("[Tencent] retry failed: dedupeKey={}, error={}",
+                    original.getDedupeKey(), e.getMessage());
+            throw e;
+        }
+        return retry;
+    }
+
+    // ─── 构造 UserAction ────────────────────────────────────────────────────
+
+    private UserAction buildUserAction(BidRecord bid, PaymentInfo payment,
+                                       String actionType, long eventTimeMs) {
+        UserAction action = new UserAction();
+        action.setActionTime(eventTimeMs / 1000); // 秒级时间戳
+        action.setActionType(actionType);
+        action.setOuterActionId(payment.getQk() + ":" + payment.getAct() + ":" + eventTimeMs);
+
+        // 设备标识
+        UserId userId = buildUserId(bid);
+        action.setUserId(userId);
+
+        // trace - click_id
+        String clickId = bid.getMediaTraceId();
+        if (clickId != null && !clickId.isBlank()) {
+            UserAction.Trace trace = new UserAction.Trace();
+            trace.setClickId(clickId);
+            action.setTrace(trace);
+        }
+
+        // action_param
+        if ("PURCHASE".equals(actionType) || "COMPLETE_ORDER".equals(actionType)) {
+            UserAction.ActionParam param = new UserAction.ActionParam();
+            param.setValue((int) (payment.getPayment() * 100)); // 分
+            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";
+                case 9 -> "3day_retention";
+                default -> "custom_" + payment.getAct();
+            };
+            param.setCustomAction(customAction);
+            action.setActionParam(param);
+        }
+
+        return action;
+    }
+
+    /**
+     * 从 BidRecord 的 mediaParams 中提取设备标识构造 UserId。
+     */
+    private UserId buildUserId(BidRecord bid) {
+        UserId userId = new UserId();
+        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"));
+        if (oaid != null) userId.setHashOaid(ensureMd5(oaid));
+
+        String androidId = firstNotBlank(params.get("android_id_md5"), params.get("android_id"));
+        if (androidId != null) userId.setHashAndroidId(ensureMd5(androidId));
+
+        return userId;
+    }
+
+    // ─── 默认 act 映射表 ────────────────────────────────────────────────────
+
+    static Map<Integer, String> defaultActMap() {
+        Map<Integer, String> m = new HashMap<>();
+        m.put(1, "ACTIVATE_APP");
+        m.put(2, "PURCHASE");
+        m.put(3, "REGISTER");
+        m.put(4, "ONE_DAY_RETENTION");
+        m.put(5, "COMPLETE_ORDER");
+        m.put(6, "START_APP");
+        m.put(7, "CUSTOM");
+        m.put(8, "CUSTOM");
+        m.put(9, "CUSTOM");
+        return m;
+    }
+
+    // ─── 工具方法 ────────────────────────────────────────────────────────────
+
+    /**
+     * 确保值是 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));
+            return HexFormat.of().formatHex(hash);
+        } catch (Exception e) {
+            return value;
+        }
+    }
+
+    private static String firstNotBlank(String... values) {
+        for (String v : values) {
+            if (v != null && !v.isBlank()) return v;
+        }
+        return null;
+    }
+
+    private static String truncate(String s, int maxLen) {
+        if (s == null) return null;
+        return s.length() <= maxLen ? s : s.substring(0, maxLen);
+    }
+
+    private static MediaCallbackRecord copyRecord(MediaCallbackRecord src) {
+        MediaCallbackRecord copy = new MediaCallbackRecord();
+        copy.setDedupeKey(src.getDedupeKey());
+        copy.setMedia(src.getMedia());
+        copy.setQk(src.getQk());
+        copy.setCallbackUrl(src.getCallbackUrl());
+        copy.setEventType(src.getEventType());
+        copy.setEventTimeMs(src.getEventTimeMs());
+        copy.setPurchase(src.getPurchase());
+        copy.setStatus(src.getStatus());
+        copy.setOk(src.isOk());
+        copy.setAttempt(src.getAttempt());
+        copy.setResponseBody(src.getResponseBody());
+        copy.setErrorMessage(src.getErrorMessage());
+        copy.setCreatedAt(src.getCreatedAt());
+        return copy;
+    }
+}

+ 23 - 0
src/main/java/com/adx/tencent/tencent/model/TencentResponse.java

@@ -0,0 +1,23 @@
+package com.adx.tencent.tencent.model;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * 腾讯 API 通用响应结构。
+ */
+public class TencentResponse {
+
+    @JsonProperty("code")
+    private int code;
+
+    @JsonProperty("message")
+    private String message;
+
+    public int getCode() { return code; }
+    public void setCode(int v) { this.code = v; }
+
+    public String getMessage() { return message; }
+    public void setMessage(String v) { this.message = v; }
+
+    public boolean isSuccess() { return code == 0; }
+}

+ 76 - 0
src/main/java/com/adx/tencent/tencent/model/UserAction.java

@@ -0,0 +1,76 @@
+package com.adx.tencent.tencent.model;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import java.util.Map;
+
+/**
+ * 腾讯 user_actions/add 接口中的单个 action 结构。
+ */
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class UserAction {
+
+    @JsonProperty("action_time")
+    private long actionTime;
+
+    @JsonProperty("user_id")
+    private UserId userId;
+
+    @JsonProperty("action_type")
+    private String actionType;
+
+    @JsonProperty("action_param")
+    private ActionParam actionParam;
+
+    @JsonProperty("trace")
+    private Trace trace;
+
+    @JsonProperty("outer_action_id")
+    private String outerActionId;
+
+    // ─── Getters / Setters ─────
+
+    public long getActionTime() { return actionTime; }
+    public void setActionTime(long v) { this.actionTime = v; }
+
+    public UserId getUserId() { return userId; }
+    public void setUserId(UserId v) { this.userId = v; }
+
+    public String getActionType() { return actionType; }
+    public void setActionType(String v) { this.actionType = v; }
+
+    public ActionParam getActionParam() { return actionParam; }
+    public void setActionParam(ActionParam v) { this.actionParam = v; }
+
+    public Trace getTrace() { return trace; }
+    public void setTrace(Trace v) { this.trace = v; }
+
+    public String getOuterActionId() { return outerActionId; }
+    public void setOuterActionId(String v) { this.outerActionId = v; }
+
+    // ─── 内嵌结构 ─────
+
+    @JsonInclude(JsonInclude.Include.NON_NULL)
+    public static class ActionParam {
+        @JsonProperty("value")
+        private Integer value;
+
+        @JsonProperty("custom_action")
+        private String customAction;
+
+        public Integer getValue() { return value; }
+        public void setValue(Integer v) { this.value = v; }
+        public String getCustomAction() { return customAction; }
+        public void setCustomAction(String v) { this.customAction = v; }
+    }
+
+    @JsonInclude(JsonInclude.Include.NON_NULL)
+    public static class Trace {
+        @JsonProperty("click_id")
+        private String clickId;
+
+        public String getClickId() { return clickId; }
+        public void setClickId(String v) { this.clickId = v; }
+    }
+}

+ 36 - 0
src/main/java/com/adx/tencent/tencent/model/UserId.java

@@ -0,0 +1,36 @@
+package com.adx.tencent.tencent.model;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * 腾讯 API 中的用户标识结构。
+ * 支持多种设备标识。
+ */
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class UserId {
+
+    @JsonProperty("hash_imei")
+    private String hashImei;
+
+    @JsonProperty("hash_idfa")
+    private String hashIdfa;
+
+    @JsonProperty("hash_oaid")
+    private String hashOaid;
+
+    @JsonProperty("hash_android_id")
+    private String hashAndroidId;
+
+    public String getHashImei() { return hashImei; }
+    public void setHashImei(String v) { this.hashImei = v; }
+
+    public String getHashIdfa() { return hashIdfa; }
+    public void setHashIdfa(String v) { this.hashIdfa = v; }
+
+    public String getHashOaid() { return hashOaid; }
+    public void setHashOaid(String v) { this.hashOaid = v; }
+
+    public String getHashAndroidId() { return hashAndroidId; }
+    public void setHashAndroidId(String v) { this.hashAndroidId = v; }
+}

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

@@ -0,0 +1,111 @@
+package com.adx.tencent.worker;
+
+import com.adx.tencent.storage.RedisHotStore;
+import com.adx.tencent.storage.TiDBColdStore;
+import com.adx.tencent.storage.model.BidRecord;
+import com.adx.tencent.storage.model.QueuedEvent;
+import com.adx.tencent.storage.model.TrackingRecord;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 对应 Go internal/worker/worker.go 的 Worker。
+ * 从 Redis Stream 读取事件,逐条写入 TiDB(冷路径)。
+ *
+ * 循环逻辑:
+ *   1. EnsureGroup(幂等创建消费者组)
+ *   2. ClaimStale(领取超时未 ACK 的消息)
+ *   3. 若无 stale,则 Read 新消息
+ *   4. 逐条处理(bid → saveBid,tracking → saveTracking)
+ *   5. Ack + Trim
+ */
+public class ColdWorker {
+
+    private static final Logger log = LoggerFactory.getLogger(ColdWorker.class);
+
+    private final RedisHotStore hotStore;
+    private final TiDBColdStore 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 ColdWorker(RedisHotStore hotStore, TiDBColdStore coldStore,
+                      String group, String consumer, int batch,
+                      Duration pendingIdle, long streamMaxLen,
+                      ObjectMapper objectMapper) {
+        this.hotStore    = hotStore;
+        this.coldStore   = coldStore;
+        this.group       = (group    == null || group.isBlank())    ? "cold-writers" : group;
+        this.consumer    = (consumer == null || consumer.isBlank()) ? "worker-1"     : consumer;
+        this.batch       = batch <= 0 ? 100 : batch;
+        this.pendingIdle = (pendingIdle == null || pendingIdle.isZero()) ? Duration.ofMinutes(2) : pendingIdle;
+        this.streamMaxLen = streamMaxLen;
+        this.objectMapper = objectMapper;
+    }
+
+    /**
+     * 单次运行:对应 Go Worker.RunOnce()。
+     * 返回本次处理的事件数量。
+     *
+     * 失败语义(严格对齐 Go):
+     *   • 任一 handleEvent 报错 → 不 Ack 任何 event(包括已成功的),整批原子性。
+     *   • 异常必须向上抛出,由调用方(BackgroundTasks) sleep 冷却,
+     *     否则 TiDB 持续写入失败时会陷入 busy loop。
+     */
+    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) {
+                log.error("handle event {}: {}", event.getId(), e.getMessage(), e);
+                failed = e;
+                break;
+            }
+            acked.add(event.getId());
+        }
+
+        // 对齐 Go:失败时直接抛出,不 Ack 任何 event(包括已成功的)。
+        // 失败的事件会在 pendingIdle 后被 ClaimStale 重新领取,TiDB 写入是幂等的。
+        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" -> {
+                BidRecord record = objectMapper.readValue(event.getPayload(), BidRecord.class);
+                coldStore.saveBid(record);
+            }
+            case "tracking" -> {
+                TrackingRecord record = objectMapper.readValue(event.getPayload(), TrackingRecord.class);
+                coldStore.saveTracking(record);
+            }
+            default -> throw new IllegalArgumentException("unknown event type: " + event.getType());
+        }
+    }
+}

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

@@ -0,0 +1,52 @@
+# ============================================================
+# 开发环境配置 (dev)
+# ============================================================
+
+logging:
+  file:
+    path: ./logs
+
+adx:
+  # --- Baidu ADX ---
+  baidu-adx-endpoint: "http://adsp.baidu.com/jbssp"
+  baidu-conversion-base-url: "https://media-interact.baidu.com"
+  baidu-customer-name: dingzhaoming111
+  baidu-secret: gk780scz3oi2
+
+  # --- Tencent API ---
+  tencent-api-base-url: "https://api.e.qq.com/v3.0"
+  tencent-access-token: "50aab5d01ee39ed5fa6ce6054924d132"
+  tencent-account-id: 0
+  tencent-android-user-action-set-id: 0
+  tencent-ios-user-action-set-id: 0
+  tencent-act-map:
+    "1": ACTIVATE_APP
+    "2": PURCHASE
+    "3": REGISTER
+    "4": ONE_DAY_RETENTION
+    "5": COMPLETE_ORDER
+    "6": START_APP
+    "7": CUSTOM
+    "8": CUSTOM
+    "9": CUSTOM
+
+  # --- Redis ---
+  redis-addr: "cq-crs-47p6pmwo.sql.tencentcdb.com:28036"
+  redis-username: "hcst"
+  redis-password: "hcst@2026qwe.."
+  redis-db: 5
+
+  # --- TiDB/MySQL ---
+  tidb-url: "jdbc:mysql://139.186.172.149:3390/adx_tencent?useSSL=false&useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true"
+  tidb-username: hcst
+  tidb-password: "hcst@2025"
+
+  # --- Task ---
+  skip-leader-election: true
+
+  # --- Conversion Sync ---
+  conversion-sync-enabled: true
+  conversion-sync-interval: 30s
+
+  # --- Callback Retry ---
+  callback-retry-enabled: false

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

@@ -0,0 +1,55 @@
+# ============================================================
+# 生产环境配置 (prod)
+# ============================================================
+
+logging:
+  file:
+    path: /var/log/adx-tencent
+
+adx:
+  # --- Baidu ADX ---
+  baidu-adx-endpoint: "http://adsp.baidu.com/jbssp"
+  baidu-conversion-base-url: "https://media-interact.baidu.com"
+  baidu-customer-name: "dingzhaoming111"
+  baidu-secret: "gk780scz3oi2"
+
+  # --- Tencent API ---
+  tencent-api-base-url: "https://api.e.qq.com/v3.0"
+  tencent-access-token: "50aab5d01ee39ed5fa6ce6054924d132"
+  tencent-account-id: ${TENCENT_ACCOUNT_ID:0}
+  tencent-android-user-action-set-id: ${TENCENT_ANDROID_USER_ACTION_SET_ID:0}
+  tencent-ios-user-action-set-id: ${TENCENT_IOS_USER_ACTION_SET_ID:0}
+  tencent-act-map:
+    "1": ACTIVATE_APP
+    "2": PURCHASE
+    "3": REGISTER
+    "4": ONE_DAY_RETENTION
+    "5": COMPLETE_ORDER
+    "6": START_APP
+    "7": CUSTOM
+    "8": CUSTOM
+    "9": CUSTOM
+
+  # --- Redis ---
+  redis-addr: "${REDIS_ADDR}"
+  redis-username: "${REDIS_USERNAME:}"
+  redis-password: "${REDIS_PASSWORD:}"
+  redis-db: 0
+
+  # --- TiDB/MySQL ---
+  tidb-url: "${TIDB_URL}"
+  tidb-username: "${TIDB_USERNAME}"
+  tidb-password: "${TIDB_PASSWORD}"
+
+  # --- Task ---
+  skip-leader-election: false
+
+  # --- Conversion Sync ---
+  conversion-sync-enabled: true
+  conversion-sync-interval: 300s
+  conversion-sync-date-offset-days: 0
+  conversion-sync-page-size: 1
+
+  # --- Callback Retry ---
+  callback-retry-enabled: true
+  callback-retry-interval: 300s

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

@@ -0,0 +1,55 @@
+# ============================================================
+# 测试环境配置 (test)
+# ============================================================
+
+logging:
+  file:
+    path: /var/log/adx-tencent
+
+adx:
+  # --- Baidu ADX ---
+  baidu-adx-endpoint: "http://adsp.baidu.com/jbssp"
+  baidu-conversion-base-url: "https://media-interact-test.baidu.com"
+  baidu-customer-name: dingzhaoming111
+  baidu-secret: gk780scz3oi2
+
+  # --- Tencent API ---
+  tencent-api-base-url: "https://api.e.qq.com/v3.0"
+  tencent-access-token: "YOUR_TEST_ACCESS_TOKEN"
+  tencent-account-id: 0
+  tencent-android-user-action-set-id: 0
+  tencent-ios-user-action-set-id: 0
+  tencent-act-map:
+    "1": ACTIVATE_APP
+    "2": PURCHASE
+    "3": REGISTER
+    "4": ONE_DAY_RETENTION
+    "5": COMPLETE_ORDER
+    "6": START_APP
+    "7": CUSTOM
+    "8": CUSTOM
+    "9": CUSTOM
+
+  # --- Redis ---
+  redis-addr: "cq-crs-47p6pmwo.sql.tencentcdb.com:28036"
+  redis-username: hcst
+  redis-password: "hcst@2026qwe.."
+  redis-db: 0
+
+  # --- TiDB/MySQL ---
+  tidb-url: "jdbc:mysql://139.186.172.149:3390/adx_tencent?useSSL=false&useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&connectTimeout=5000&socketTimeout=30000"
+  tidb-username: hcst
+  tidb-password: "hcst@2025"
+
+  # --- Task ---
+  skip-leader-election: false
+
+  # --- Conversion Sync ---
+  conversion-sync-enabled: true
+  conversion-sync-interval: 5s
+  conversion-sync-date-offset-days: 0
+  conversion-sync-page-size: 1
+
+  # --- Callback Retry ---
+  callback-retry-enabled: true
+  callback-retry-interval: 300s

+ 27 - 0
src/main/resources/application.yml

@@ -0,0 +1,27 @@
+# ============================================================
+# 公共配置(所有环境共享)
+# ============================================================
+
+spring:
+  profiles:
+    active: '@profileActive@'
+  autoconfigure:
+    exclude:
+      - org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
+      - org.mybatis.spring.boot.autoconfigure.MybatisAutoConfiguration
+  jackson:
+    default-property-inclusion: non_null
+
+server:
+  port: 8787
+
+adx:
+  # --- Baidu ADX ---
+  baidu-auction-price-encryption: aes-128-cbc
+  baidu-auction-price-e-key: 192c728294891ef6
+  baidu-auction-price-i-key: ee64e8c430a2510e
+  baidu-media-id: 1213
+  baidu-android-app-id: dcb4cbec
+  baidu-android-tag-ids: jlt52,jlt53,jlt54,jlt55,jlt56,jlt57,jlt58,jlt59,jlt60,jlt61,jlt62,jlt63,jlt64,jlt65,jlt66,jlt67,jlt68,jlt69,jlt70,jlt71,jlt72,jlt73,jlt74,jlt75,jlt76,jlt77,jlt78,jlt79,jlt80,jlt81,jlt82,jlt83,jlt84,jlt85,jlt86,jlt87,jlt88,jlt89,jlt90,jlt91,jlt92,jlt93,jlt94,jlt95,jlt96,jlt97,jlt98,jlt99,jlt100,jlt101
+  baidu-ios-app-id: efff0562
+  baidu-ios-tag-ids: jlt01,jlt03,jlt04,jlt05,jlt06,jlt07,jlt08,jlt09,jlt10,jlt11,jlt12,jlt13,jlt14,jlt15,jlt16,jlt17,jlt18,jlt19,jlt20,jlt21,jlt22,jlt23,jlt24,jlt25,jlt26,jlt27,jlt28,jlt29,jlt30,jlt31,jlt32,jlt33,jlt34,jlt35,jlt36,jlt37,jlt38,jlt39,jlt40,jlt41,jlt42,jlt43,jlt44,jlt45,jlt46,jlt47,jlt48,jlt49,jlt50,jlt51

+ 75 - 0
src/main/resources/logback-spring.xml

@@ -0,0 +1,75 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<configuration>
+
+    <property name="APP_NAME" value="tencent-baidu-tracking"/>
+    <springProperty scope="context" name="LOG_PATH" source="logging.file.path" defaultValue="./logs"/>
+    <property name="LOG_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"/>
+
+    <!-- 控制台输出 -->
+    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
+        <encoder>
+            <pattern>${LOG_PATTERN}</pattern>
+            <charset>UTF-8</charset>
+        </encoder>
+    </appender>
+
+    <!-- 全量日志文件(按天滚动) -->
+    <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
+        <file>${LOG_PATH}/${APP_NAME}.log</file>
+        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
+            <fileNamePattern>${LOG_PATH}/${APP_NAME}.%d{yyyy-MM-dd}.log</fileNamePattern>
+            <maxHistory>3</maxHistory>
+            <totalSizeCap>2GB</totalSizeCap>
+        </rollingPolicy>
+        <encoder>
+            <pattern>${LOG_PATTERN}</pattern>
+            <charset>UTF-8</charset>
+        </encoder>
+    </appender>
+
+    <!-- 错误日志文件(仅 WARN 及以上) -->
+    <appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
+        <file>${LOG_PATH}/${APP_NAME}-error.log</file>
+        <filter class="ch.qos.logback.classic.filter.ThresholdFilter">
+            <level>WARN</level>
+        </filter>
+        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
+            <fileNamePattern>${LOG_PATH}/${APP_NAME}-error.%d{yyyy-MM-dd}.log</fileNamePattern>
+            <maxHistory>3</maxHistory>
+            <totalSizeCap>1GB</totalSizeCap>
+        </rollingPolicy>
+        <encoder>
+            <pattern>${LOG_PATTERN}</pattern>
+            <charset>UTF-8</charset>
+        </encoder>
+    </appender>
+
+    <!-- ============ 开发环境 ============ -->
+    <springProfile name="dev">
+        <root level="INFO">
+            <appender-ref ref="CONSOLE"/>
+            <appender-ref ref="FILE"/>
+        </root>
+        <logger name="com.adx.tencent" level="DEBUG"/>
+    </springProfile>
+
+    <!-- ============ 测试环境 ============ -->
+    <springProfile name="test">
+        <root level="INFO">
+            <appender-ref ref="CONSOLE"/>
+            <appender-ref ref="FILE"/>
+            <appender-ref ref="ERROR_FILE"/>
+        </root>
+        <logger name="com.adx.tencent" level="DEBUG"/>
+    </springProfile>
+
+    <!-- ============ 生产环境 ============ -->
+    <springProfile name="prod">
+        <root level="INFO">
+            <appender-ref ref="FILE"/>
+            <appender-ref ref="ERROR_FILE"/>
+        </root>
+        <logger name="com.adx.tencent" level="INFO"/>
+    </springProfile>
+
+</configuration>

+ 32 - 0
src/main/resources/mapper/BidEventMapper.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.storage.mapper.BidEventMapper">
+    <insert id="insertOrUpdate">
+        INSERT INTO tencent_ad_bid_events (
+            qk, media, media_trace_id, platform, req_id, bid_id, creative_id, imp_id, tag_id, price,
+            show_urls, click_urls, landing_page, app_store_link, package_name,
+            media_params, created_at
+        ) VALUES (
+            #{qk}, #{media}, #{mediaTraceId}, #{platform}, #{reqId}, #{bidId}, #{creativeId},
+            #{impId}, #{tagId}, #{price}, #{showUrls}, #{clickUrls}, #{landingPage},
+            #{appStoreLink}, #{packageName}, #{mediaParams}, #{createdAt}
+        )
+        ON DUPLICATE KEY UPDATE
+            media = VALUES(media),
+            media_trace_id = VALUES(media_trace_id),
+            platform = VALUES(platform),
+            req_id = VALUES(req_id),
+            bid_id = VALUES(bid_id),
+            creative_id = VALUES(creative_id),
+            imp_id = VALUES(imp_id),
+            tag_id = VALUES(tag_id),
+            price = VALUES(price),
+            show_urls = VALUES(show_urls),
+            click_urls = VALUES(click_urls),
+            landing_page = VALUES(landing_page),
+            app_store_link = VALUES(app_store_link),
+            package_name = VALUES(package_name),
+            media_params = VALUES(media_params)
+    </insert>
+</mapper>

+ 19 - 0
src/main/resources/mapper/ConversionMapper.xml

@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.adx.tencent.storage.mapper.ConversionMapper">
+    <insert id="insertOrUpdate">
+        INSERT INTO tencent_baidu_conversions (
+            dedupe_key, qk, media, date_value, appsid, customer_name, device_id, conv,
+            payment, act, tu, clk_time, created_at
+        ) VALUES (
+            #{dedupeKey}, #{qk}, #{media}, #{dateValue}, #{appsid}, #{customerName},
+            #{deviceId}, #{conv}, #{payment}, #{act}, #{tu}, #{clkTime}, #{createdAt}
+        )
+        ON DUPLICATE KEY UPDATE
+            media = VALUES(media),
+            conv = VALUES(conv),
+            payment = VALUES(payment),
+            clk_time = VALUES(clk_time)
+    </insert>
+</mapper>

+ 65 - 0
src/main/resources/mapper/MediaCallbackMapper.xml

@@ -0,0 +1,65 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.adx.tencent.storage.mapper.MediaCallbackMapper">
+    <resultMap id="mediaCallbackResultMap" type="com.adx.tencent.storage.model.MediaCallbackRecord">
+        <result property="dedupeKey" column="dedupe_key"/>
+        <result property="media" column="media"/>
+        <result property="qk" column="qk"/>
+        <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="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="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
+        ) VALUES (
+            #{dedupeKey}, #{media}, #{qk}, #{callbackUrl}, #{eventType}, #{eventTimeMs},
+            #{purchase}, #{status}, #{ok}, #{attempt}, #{responseBody}, #{errorMessage},
+            #{createdAt}
+        )
+    </insert>
+
+    <select id="countSuccessfulByDedupeKey" resultType="int">
+        SELECT COUNT(1) FROM tencent_media_callbacks WHERE dedupe_key = #{dedupeKey} AND ok = 1 LIMIT 1
+    </select>
+
+    <select id="countSuccessfulForEvent" resultType="int">
+        SELECT COUNT(1)
+        FROM tencent_media_callbacks
+        WHERE ok = 1
+          AND (
+            dedupe_key = #{dedupeKey}
+            OR (dedupe_key = #{legacyDedupeKey} AND event_type = #{eventType})
+          )
+        LIMIT 1
+    </select>
+
+    <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
+        FROM tencent_media_callbacks m
+        JOIN (
+            SELECT dedupe_key, MAX(id) AS id
+            FROM tencent_media_callbacks
+            WHERE media = #{media} AND dedupe_key IS NOT NULL AND dedupe_key != ''
+            GROUP BY dedupe_key
+        ) latest ON latest.id = m.id
+        WHERE m.media = #{media} AND m.ok = 0
+          AND NOT EXISTS (
+            SELECT 1 FROM tencent_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>

+ 9 - 0
src/main/resources/mapper/TrackingReportMapper.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.storage.mapper.TrackingReportMapper">
+    <insert id="insert">
+        INSERT INTO tencent_tracking_reports (qk, kind, tracking_url, status, ok, created_at)
+        VALUES (#{qk}, #{kind}, #{trackingUrl}, #{status}, #{ok}, #{createdAt})
+    </insert>
+</mapper>

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

@@ -0,0 +1,105 @@
+-- ============================================================
+-- tencent-baidu-tracking 数据库建表语句
+-- 数据库: adx_tencent
+-- ============================================================
+
+CREATE DATABASE IF NOT EXISTS adx_tencent DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
+USE adx_tencent;
+
+-- ------------------------------------------------------------
+-- 1. 竞价事件表(百度ADX竞价记录)
+-- ------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS tencent_ad_bid_events (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    qk VARCHAR(128) NOT NULL COMMENT '唯一业务键(query key)',
+    media VARCHAR(64) COMMENT '媒体来源(tencent)',
+    media_trace_id VARCHAR(255) COMMENT '媒体追踪ID(click_id/oaid/imei/idfa)',
+    platform VARCHAR(16) COMMENT '平台(android/ios)',
+    req_id VARCHAR(512) COMMENT '百度ADX请求ID',
+    bid_id VARCHAR(128) COMMENT '竞价ID',
+    creative_id VARCHAR(128) COMMENT '创意ID',
+    imp_id VARCHAR(128) COMMENT '曝光ID',
+    tag_id VARCHAR(128) COMMENT '百度广告位ID',
+    price BIGINT UNSIGNED COMMENT '竞价价格(分)',
+    show_urls JSON COMMENT '曝光上报URL列表',
+    click_urls JSON COMMENT '点击上报URL列表',
+    landing_page TEXT COMMENT '落地页URL',
+    app_store_link TEXT COMMENT '应用商店链接',
+    package_name VARCHAR(255) COMMENT '应用包名',
+    media_params JSON COMMENT '媒体参数(设备标识等)',
+    created_at TIMESTAMP(3) NOT NULL COMMENT '创建时间',
+    UNIQUE KEY uk_tencent_ad_bid_events_qk (qk),
+    KEY idx_tencent_ad_bid_events_media_trace (media, media_trace_id),
+    KEY idx_tencent_ad_bid_events_platform_tag (platform, tag_id),
+    KEY idx_tencent_ad_bid_events_created_at (created_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='竞价事件记录';
+
+-- ------------------------------------------------------------
+-- 2. 上报结果表(曝光/点击上报百度DSP的结果)
+-- ------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS tencent_tracking_reports (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    qk VARCHAR(128) COMMENT '关联的业务键',
+    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_tencent_tracking_reports_qk_kind_created_at (qk, kind, created_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='曝光/点击上报结果';
+
+-- ------------------------------------------------------------
+-- 3. 百度转化数据表(从百度拉取的转化记录)
+-- ------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS tencent_baidu_conversions (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    dedupe_key VARCHAR(255) COMMENT '去重键',
+    qk VARCHAR(128) COMMENT '关联的业务键',
+    media VARCHAR(64) COMMENT '媒体来源',
+    date_value VARCHAR(16) COMMENT '转化日期',
+    appsid VARCHAR(128) COMMENT '百度应用ID',
+    customer_name VARCHAR(128) COMMENT '客户名称',
+    device_id VARCHAR(255) COMMENT '设备标识',
+    conv DOUBLE COMMENT '转化数',
+    payment DOUBLE COMMENT '付费金额',
+    act INT COMMENT '行为类型(1=激活,2=付费,3=注册...)',
+    tu VARCHAR(128) COMMENT '追踪URL参数',
+    clk_time VARCHAR(32) COMMENT '点击时间',
+    created_at TIMESTAMP(3) NOT NULL COMMENT '创建时间',
+    UNIQUE KEY uk_tencent_baidu_conversions_dedupe (dedupe_key),
+    KEY idx_tencent_baidu_conversions_qk_act_created_at (qk, act, created_at),
+    KEY idx_tencent_baidu_conversions_device_id (device_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='百度转化数据';
+
+-- ------------------------------------------------------------
+-- 4. 媒体回传记录表(向腾讯回传转化的结果)
+-- ------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS tencent_media_callbacks (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    dedupe_key VARCHAR(255) COMMENT '去重键',
+    media VARCHAR(64) NOT NULL COMMENT '媒体(tencent)',
+    qk VARCHAR(128) COMMENT '关联的业务键',
+    callback_url TEXT NOT NULL COMMENT '回传的API URL',
+    event_type INT NOT NULL COMMENT '事件类型(百度act)',
+    event_time_ms BIGINT NOT NULL COMMENT '事件时间(毫秒时间戳)',
+    purchase DOUBLE COMMENT '付费金额',
+    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 '错误信息',
+    created_at TIMESTAMP(3) NOT NULL COMMENT '创建时间',
+    KEY idx_tencent_media_callbacks_dedupe_ok_created_at (dedupe_key, ok, created_at),
+    KEY idx_tencent_media_callbacks_qk_created_at (qk, created_at),
+    KEY idx_tencent_media_callbacks_media_event_created_at (media, event_type, created_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='媒体转化回传记录';
+
+-- ------------------------------------------------------------
+-- 字段补丁(兼容旧表升级)
+-- ------------------------------------------------------------
+-- 如果 req_id 原来是 VARCHAR(128),扩展为 512
+-- ALTER TABLE tencent_ad_bid_events MODIFY COLUMN req_id VARCHAR(512);
+
+-- 如果旧表没有 platform 字段
+-- ALTER TABLE tencent_ad_bid_events ADD COLUMN platform VARCHAR(16) AFTER media_trace_id;
+-- ALTER TABLE tencent_ad_bid_events ADD KEY idx_tencent_ad_bid_events_platform_tag (platform, tag_id);