yumeng vor 18 Stunden
Commit
bcf1251bb7
91 geänderte Dateien mit 4214 neuen und 0 gelöschten Zeilen
  1. 0 0
      .idea/.gitignore
  2. 18 0
      .idea/compiler.xml
  3. 6 0
      .idea/encodings.xml
  4. 20 0
      .idea/jarRepositories.xml
  5. 12 0
      .idea/misc.xml
  6. 91 0
      .idea/workspace.xml
  7. 75 0
      pom.xml
  8. 363 0
      src/main/java/com/adx/tracking/AppConfiguration.java
  9. 16 0
      src/main/java/com/adx/tracking/Application.java
  10. 103 0
      src/main/java/com/adx/tracking/baidu/AdxClient.java
  11. 115 0
      src/main/java/com/adx/tracking/baidu/AuctionPriceEncoder.java
  12. 126 0
      src/main/java/com/adx/tracking/baidu/ConversionClient.java
  13. 17 0
      src/main/java/com/adx/tracking/baidu/model/Adm.java
  14. 33 0
      src/main/java/com/adx/tracking/baidu/model/Bid.java
  15. 21 0
      src/main/java/com/adx/tracking/baidu/model/BidResponse.java
  16. 27 0
      src/main/java/com/adx/tracking/baidu/model/ConversionQuery.java
  17. 27 0
      src/main/java/com/adx/tracking/baidu/model/ConversionResponse.java
  18. 38 0
      src/main/java/com/adx/tracking/baidu/model/NormalizedBid.java
  19. 55 0
      src/main/java/com/adx/tracking/baidu/model/NormalizedBidResponse.java
  20. 39 0
      src/main/java/com/adx/tracking/baidu/model/PaymentInfo.java
  21. 10 0
      src/main/java/com/adx/tracking/baidu/model/SeatBid.java
  22. 20 0
      src/main/java/com/adx/tracking/baidu/model/TrackingResult.java
  23. 225 0
      src/main/java/com/adx/tracking/config/AppProperties.java
  24. 53 0
      src/main/java/com/adx/tracking/conversionsync/ConversionSyncRunner.java
  25. 170 0
      src/main/java/com/adx/tracking/conversionsync/ConversionSyncService.java
  26. 83 0
      src/main/java/com/adx/tracking/conversionsync/RetryService.java
  27. 421 0
      src/main/java/com/adx/tracking/httpapi/AdxController.java
  28. 66 0
      src/main/java/com/adx/tracking/httpapi/GlobalExceptionHandler.java
  29. 129 0
      src/main/java/com/adx/tracking/httpapi/MediaPlacement.java
  30. 139 0
      src/main/java/com/adx/tracking/leader/LeaderElection.java
  31. 14 0
      src/main/java/com/adx/tracking/leader/Lease.java
  32. 250 0
      src/main/java/com/adx/tracking/media/kuaishou/KuaishouClient.java
  33. 271 0
      src/main/java/com/adx/tracking/storage/RedisHotStore.java
  34. 109 0
      src/main/java/com/adx/tracking/storage/RedisLock.java
  35. 302 0
      src/main/java/com/adx/tracking/storage/TiDBColdStore.java
  36. 59 0
      src/main/java/com/adx/tracking/storage/model/BidRecord.java
  37. 48 0
      src/main/java/com/adx/tracking/storage/model/ConversionRecord.java
  38. 48 0
      src/main/java/com/adx/tracking/storage/model/MediaCallbackRecord.java
  39. 15 0
      src/main/java/com/adx/tracking/storage/model/QueuedEvent.java
  40. 27 0
      src/main/java/com/adx/tracking/storage/model/TrackingRecord.java
  41. 111 0
      src/main/java/com/adx/tracking/worker/ColdWorker.java
  42. 65 0
      src/main/resources/application.properties
  43. BIN
      target/adx-tracking-1.0.0.jar
  44. BIN
      target/adx-tracking-1.0.0.jar.original
  45. 232 0
      target/classes/META-INF/spring-configuration-metadata.json
  46. 65 0
      target/classes/application.properties
  47. BIN
      target/classes/com/adx/tracking/AppConfiguration$BackgroundTasks.class
  48. BIN
      target/classes/com/adx/tracking/AppConfiguration.class
  49. BIN
      target/classes/com/adx/tracking/Application.class
  50. BIN
      target/classes/com/adx/tracking/baidu/AdxClient.class
  51. BIN
      target/classes/com/adx/tracking/baidu/AuctionPriceEncoder.class
  52. BIN
      target/classes/com/adx/tracking/baidu/ConversionClient.class
  53. BIN
      target/classes/com/adx/tracking/baidu/model/Adm.class
  54. BIN
      target/classes/com/adx/tracking/baidu/model/Bid.class
  55. BIN
      target/classes/com/adx/tracking/baidu/model/BidResponse.class
  56. BIN
      target/classes/com/adx/tracking/baidu/model/ConversionQuery.class
  57. BIN
      target/classes/com/adx/tracking/baidu/model/ConversionResponse$BaseResponse.class
  58. BIN
      target/classes/com/adx/tracking/baidu/model/ConversionResponse.class
  59. BIN
      target/classes/com/adx/tracking/baidu/model/NormalizedBid.class
  60. BIN
      target/classes/com/adx/tracking/baidu/model/NormalizedBidResponse.class
  61. BIN
      target/classes/com/adx/tracking/baidu/model/PaymentInfo.class
  62. BIN
      target/classes/com/adx/tracking/baidu/model/SeatBid.class
  63. BIN
      target/classes/com/adx/tracking/baidu/model/TrackingResult.class
  64. BIN
      target/classes/com/adx/tracking/config/AppProperties.class
  65. BIN
      target/classes/com/adx/tracking/conversionsync/ConversionSyncRunner.class
  66. BIN
      target/classes/com/adx/tracking/conversionsync/ConversionSyncService$SyncResult.class
  67. BIN
      target/classes/com/adx/tracking/conversionsync/ConversionSyncService.class
  68. BIN
      target/classes/com/adx/tracking/conversionsync/RetryService$RetryResult.class
  69. BIN
      target/classes/com/adx/tracking/conversionsync/RetryService.class
  70. BIN
      target/classes/com/adx/tracking/httpapi/AdxController.class
  71. BIN
      target/classes/com/adx/tracking/httpapi/GlobalExceptionHandler.class
  72. BIN
      target/classes/com/adx/tracking/httpapi/MediaPlacement$BaiduAppPlacement.class
  73. BIN
      target/classes/com/adx/tracking/httpapi/MediaPlacement$Resolved.class
  74. BIN
      target/classes/com/adx/tracking/httpapi/MediaPlacement.class
  75. BIN
      target/classes/com/adx/tracking/leader/LeaderElection$StopSignal.class
  76. BIN
      target/classes/com/adx/tracking/leader/LeaderElection.class
  77. BIN
      target/classes/com/adx/tracking/leader/Lease.class
  78. BIN
      target/classes/com/adx/tracking/media/kuaishou/KuaishouClient.class
  79. BIN
      target/classes/com/adx/tracking/storage/RedisHotStore.class
  80. BIN
      target/classes/com/adx/tracking/storage/RedisLock$RedisLease.class
  81. BIN
      target/classes/com/adx/tracking/storage/RedisLock.class
  82. BIN
      target/classes/com/adx/tracking/storage/TiDBColdStore.class
  83. BIN
      target/classes/com/adx/tracking/storage/model/BidRecord.class
  84. BIN
      target/classes/com/adx/tracking/storage/model/ConversionRecord.class
  85. BIN
      target/classes/com/adx/tracking/storage/model/MediaCallbackRecord.class
  86. BIN
      target/classes/com/adx/tracking/storage/model/QueuedEvent.class
  87. BIN
      target/classes/com/adx/tracking/storage/model/TrackingRecord.class
  88. BIN
      target/classes/com/adx/tracking/worker/ColdWorker.class
  89. 3 0
      target/maven-archiver/pom.properties
  90. 43 0
      target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst
  91. 34 0
      target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst

+ 0 - 0
.idea/.gitignore


+ 18 - 0
.idea/compiler.xml

@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="CompilerConfiguration">
+    <annotationProcessing>
+      <profile name="Maven default annotation processors profile" enabled="true">
+        <sourceOutputDir name="target/generated-sources/annotations" />
+        <sourceTestOutputDir name="target/generated-test-sources/test-annotations" />
+        <outputRelativeToContentRoot value="true" />
+        <module name="adx-tracking" />
+      </profile>
+    </annotationProcessing>
+  </component>
+  <component name="JavacSettings">
+    <option name="ADDITIONAL_OPTIONS_OVERRIDE">
+      <module name="adx-tracking" options="-parameters" />
+    </option>
+  </component>
+</project>

+ 6 - 0
.idea/encodings.xml

@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="Encoding">
+    <file url="file://$PROJECT_DIR$/src/main/java" charset="UTF-8" />
+  </component>
+</project>

+ 20 - 0
.idea/jarRepositories.xml

@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="RemoteRepositoriesConfiguration">
+    <remote-repository>
+      <option name="id" value="central" />
+      <option name="name" value="Central Repository" />
+      <option name="url" value="https://repo.maven.apache.org/maven2" />
+    </remote-repository>
+    <remote-repository>
+      <option name="id" value="central" />
+      <option name="name" value="Maven Central repository" />
+      <option name="url" value="https://repo1.maven.org/maven2" />
+    </remote-repository>
+    <remote-repository>
+      <option name="id" value="jboss.community" />
+      <option name="name" value="JBoss Community repository" />
+      <option name="url" value="https://repository.jboss.org/nexus/content/repositories/public/" />
+    </remote-repository>
+  </component>
+</project>

+ 12 - 0
.idea/misc.xml

@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="ExternalStorageConfigurationManager" enabled="true" />
+  <component name="MavenProjectsManager">
+    <option name="originalFiles">
+      <list>
+        <option value="$PROJECT_DIR$/pom.xml" />
+      </list>
+    </option>
+  </component>
+  <component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="17" project-jdk-type="JavaSDK" />
+</project>

+ 91 - 0
.idea/workspace.xml

@@ -0,0 +1,91 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="AutoImportSettings">
+    <option name="autoReloadType" value="SELECTIVE" />
+  </component>
+  <component name="ChangeListManager">
+    <list default="true" id="f01767fd-48dc-4b73-b8e3-2962b2ef525e" name="Changes" comment="" />
+    <option name="SHOW_DIALOG" value="false" />
+    <option name="HIGHLIGHT_CONFLICTS" value="true" />
+    <option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
+    <option name="LAST_RESOLUTION" value="IGNORE" />
+  </component>
+  <component name="ComposerSettings">
+    <execution />
+  </component>
+  <component name="GoLibraries">
+    <option name="indexEntireGoPath" value="true" />
+  </component>
+  <component name="PhpWorkspaceProjectConfiguration" interpreter_name="/opt/homebrew/Cellar/php/8.4.7/bin/php" />
+  <component name="ProjectColorInfo"><![CDATA[{
+  "associatedIndex": 7
+}]]></component>
+  <component name="ProjectId" id="3EpwkP6nOoHoyCbV98Q8i4tmokQ" />
+  <component name="ProjectViewState">
+    <option name="hideEmptyMiddlePackages" value="true" />
+    <option name="showLibraryContents" value="true" />
+  </component>
+  <component name="PropertiesComponent"><![CDATA[{
+  "keyToString": {
+    "Maven.adx-tracking [clean].executor": "Run",
+    "Maven.adx-tracking [install].executor": "Run",
+    "ModuleVcsDetector.initialDetectionPerformed": "true",
+    "RequestMappingsPanelOrder0": "0",
+    "RequestMappingsPanelOrder1": "1",
+    "RequestMappingsPanelWidth0": "75",
+    "RequestMappingsPanelWidth1": "75",
+    "RunOnceActivity.ShowReadmeOnStart": "true",
+    "RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager": "true",
+    "RunOnceActivity.go.formatter.settings.were.checked": "true",
+    "RunOnceActivity.go.migrated.go.modules.settings": "true",
+    "Spring Boot.Application.executor": "Debug",
+    "go.import.settings.migrated": "true",
+    "last_opened_file_path": "/Users/yumeng/IdeaProjects/adx-tracking-java",
+    "node.js.detected.package.eslint": "true",
+    "node.js.detected.package.tslint": "true",
+    "node.js.selected.package.eslint": "(autodetect)",
+    "node.js.selected.package.tslint": "(autodetect)",
+    "nodejs_package_manager_path": "npm",
+    "settings.editor.selected.configurable": "com.dp.ai.tool.code.settings.CodeAssistSettingsConfigurable",
+    "vue.rearranger.settings.migration": "true"
+  }
+}]]></component>
+  <component name="ReactorSettings">
+    <option name="notificationShown" value="true" />
+  </component>
+  <component name="RunManager">
+    <configuration name="Application" type="SpringBootApplicationConfigurationType" factoryName="Spring Boot" nameIsGenerated="true">
+      <module name="adx-tracking" />
+      <option name="SPRING_BOOT_MAIN_CLASS" value="com.adx.tracking.Application" />
+      <method v="2">
+        <option name="Make" enabled="true" />
+      </method>
+    </configuration>
+  </component>
+  <component name="SharedIndexes">
+    <attachedChunks>
+      <set>
+        <option value="bundled-jdk-9823dce3aa75-fbdcb00ec9e3-intellij.indexing.shared.core-IU-251.26927.53" />
+        <option value="bundled-js-predefined-d6986cc7102b-09060db00ec0-JavaScript-IU-251.26927.53" />
+      </set>
+    </attachedChunks>
+  </component>
+  <component name="TaskManager">
+    <task active="true" id="Default" summary="Default task">
+      <changelist id="f01767fd-48dc-4b73-b8e3-2962b2ef525e" name="Changes" comment="" />
+      <created>1780888960573</created>
+      <option name="number" value="Default" />
+      <option name="presentableId" value="Default" />
+      <updated>1780888960573</updated>
+      <workItem from="1780888961642" duration="14903000" />
+    </task>
+    <servers />
+  </component>
+  <component name="TypeScriptGeneratedFilesManager">
+    <option name="version" value="3" />
+  </component>
+  <component name="VgoProject">
+    <integration-enabled>false</integration-enabled>
+    <settings-migrated>true</settings-migrated>
+  </component>
+</project>

+ 75 - 0
pom.xml

@@ -0,0 +1,75 @@
+<?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>
+
+    <parent>
+        <groupId>org.springframework.boot</groupId>
+        <artifactId>spring-boot-starter-parent</artifactId>
+        <version>3.2.5</version>
+        <relativePath/>
+    </parent>
+
+    <groupId>com.adx</groupId>
+    <artifactId>adx-tracking</artifactId>
+    <version>1.0.0</version>
+    <name>adx-tracking</name>
+    <description>ADX Tracking Server - Java implementation</description>
+
+    <properties>
+        <java.version>17</java.version>
+    </properties>
+
+    <dependencies>
+        <!-- Spring Web (REST API) -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-web</artifactId>
+        </dependency>
+
+        <!-- Spring Data Redis (Lettuce) -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-data-redis</artifactId>
+        </dependency>
+
+        <!-- Spring JDBC (TiDB/MySQL) -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-jdbc</artifactId>
+        </dependency>
+
+        <!-- MySQL Driver (TiDB compatible) -->
+        <dependency>
+            <groupId>com.mysql</groupId>
+            <artifactId>mysql-connector-j</artifactId>
+            <scope>runtime</scope>
+        </dependency>
+
+        <!-- Jackson (already included via spring-boot-starter-web) -->
+
+        <!-- Configuration Processor -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-configuration-processor</artifactId>
+            <optional>true</optional>
+        </dependency>
+
+        <!-- Test -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-test</artifactId>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.springframework.boot</groupId>
+                <artifactId>spring-boot-maven-plugin</artifactId>
+            </plugin>
+        </plugins>
+    </build>
+</project>

+ 363 - 0
src/main/java/com/adx/tracking/AppConfiguration.java

@@ -0,0 +1,363 @@
+package com.adx.tracking;
+
+import com.adx.tracking.baidu.AdxClient;
+import com.adx.tracking.baidu.AuctionPriceEncoder;
+import com.adx.tracking.baidu.ConversionClient;
+import com.adx.tracking.config.AppProperties;
+import com.adx.tracking.conversionsync.ConversionSyncRunner;
+import com.adx.tracking.conversionsync.ConversionSyncService;
+import com.adx.tracking.conversionsync.RetryService;
+import com.adx.tracking.httpapi.MediaPlacement;
+import com.adx.tracking.leader.LeaderElection;
+import com.adx.tracking.media.kuaishou.KuaishouClient;
+import com.adx.tracking.storage.RedisHotStore;
+import com.adx.tracking.storage.RedisLock;
+import com.adx.tracking.storage.TiDBColdStore;
+import com.adx.tracking.worker.ColdWorker;
+import com.fasterxml.jackson.databind.ObjectMapper;
+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.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;
+import java.util.function.Consumer;
+
+/**
+ * 对应 Go cmd/server/main.go 的 main()。
+ * 负责所有 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:",
+                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());
+            TiDBColdStore store = new TiDBColdStore(ds, objectMapper);
+            store.migrate();
+            log.info("TiDB cold store initialized and migrated");
+            return store;
+        } catch (Exception e) {
+            throw new RuntimeException("Failed to open TiDB: " + e.getMessage(), e);
+        }
+    }
+
+    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);
+    }
+
+    // ─── Kuaishou 客户端 + Sync 服务 ────────────────────────────────────────
+
+    @Bean
+    public KuaishouClient kuaishouClient() {
+        return new KuaishouClient(props.getKuaishouActMap(), props.getKuaishouExtraActMap());
+    }
+
+    @Bean
+    public ConversionSyncService conversionSyncService(ConversionClient baiduClient,
+                                                         RedisHotStore hotStore,
+                                                         KuaishouClient kuaishouClient,
+                                                         @Nullable TiDBColdStore coldStore) {
+        if (coldStore == null) return null;
+        return new ConversionSyncService(baiduClient, hotStore, kuaishouClient, 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, KuaishouClient kuaishouClient) {
+        if (coldStore == null) return null;
+        return new RetryService(coldStore, kuaishouClient, 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 kuaishouPlacement() {
+        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;
+    }
+
+    // ─── 后台任务调度 ────────────────────────────────────────────────────────
+
+    /**
+     * 对应 Go runColdWorker():持续轮询 Redis Stream → 写 TiDB。
+     * 在应用启动后在独立线程中运行。
+     */
+    @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:kuaishou:conversion-sync",
+                            props.getTaskLockTtl(), props.getTaskLockRenewInterval(), props.getTaskLockRetryInterval(),
+                            stopped::get,
+                            (jobStop) -> runConversionSync(jobStop),
+                            e -> taskLog.error("kuaishou 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:kuaishou:callback-retry",
+                            props.getTaskLockTtl(), props.getTaskLockRenewInterval(), props.getTaskLockRetryInterval(),
+                            stopped::get,
+                            (jobStop) -> runCallbackRetry(jobStop),
+                            e -> taskLog.error("kuaishou 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, r.sent, r.failed);
+                } 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.retryKuaishouCallbacks(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");
+        }
+
+        /**
+         * 响应式 sleep:每 200ms 检查一次 jobStop,锁丢失时立即退出。
+         * 对应 Go 的 select { case <-ctx.Done(): return; case <-ticker.C: } 语义。
+         */
+        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/tracking/Application.java

@@ -0,0 +1,16 @@
+package com.adx.tracking;
+
+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);
+    }
+}

+ 103 - 0
src/main/java/com/adx/tracking/baidu/AdxClient.java

@@ -0,0 +1,103 @@
+package com.adx.tracking.baidu;
+
+import com.adx.tracking.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。
+     */
+    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());
+        }
+
+        BidResponse bidResponse = objectMapper.readValue(response.body(), 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/tracking/baidu/AuctionPriceEncoder.java

@@ -0,0 +1,115 @@
+package com.adx.tracking.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/tracking/baidu/ConversionClient.java

@@ -0,0 +1,126 @@
+package com.adx.tracking.baidu;
+
+import com.adx.tracking.baidu.model.ConversionQuery;
+import com.adx.tracking.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/tracking/baidu/model/Adm.java

@@ -0,0 +1,17 @@
+package com.adx.tracking.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/tracking/baidu/model/Bid.java

@@ -0,0 +1,33 @@
+package com.adx.tracking.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/tracking/baidu/model/BidResponse.java

@@ -0,0 +1,21 @@
+package com.adx.tracking.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/tracking/baidu/model/ConversionQuery.java

@@ -0,0 +1,27 @@
+package com.adx.tracking.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/tracking/baidu/model/ConversionResponse.java

@@ -0,0 +1,27 @@
+package com.adx.tracking.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/tracking/baidu/model/NormalizedBid.java

@@ -0,0 +1,38 @@
+package com.adx.tracking.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/tracking/baidu/model/NormalizedBidResponse.java

@@ -0,0 +1,55 @@
+package com.adx.tracking.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/tracking/baidu/model/PaymentInfo.java

@@ -0,0 +1,39 @@
+package com.adx.tracking.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/tracking/baidu/model/SeatBid.java

@@ -0,0 +1,10 @@
+package com.adx.tracking.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/tracking/baidu/model/TrackingResult.java

@@ -0,0 +1,20 @@
+package com.adx.tracking.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; }
+}

+ 225 - 0
src/main/java/com/adx/tracking/config/AppProperties.java

@@ -0,0 +1,225 @@
+package com.adx.tracking.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;
+
+/**
+ * 对应 Go config.Config,通过环境变量加载所有配置项。
+ * Spring Boot 的 @ConfigurationProperties 会自动将 application.properties
+ * 中的 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;
+
+    // Redis
+    private String redisAddr = "127.0.0.1:6379";
+    private String redisUsername;
+    private String redisPassword;
+    private int redisDb = 0;
+    private String redisStream = "adx: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);
+
+    // Kuaishou Act Mapping: "1:1,2:3,..." -> Map<baiduAct, kuaishouEvent>
+    private Map<Integer, Integer> kuaishouActMap = new HashMap<>();
+    // Extra Act Mapping: "2:5,2:6,..." -> Map<baiduAct, List<kuaishouEvent>>
+    private Map<Integer, List<Integer>> kuaishouExtraActMap = new HashMap<>();
+
+    // 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 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 Map<Integer, Integer> getKuaishouActMap() { return kuaishouActMap; }
+    public void setKuaishouActMap(Map<Integer, Integer> v) { this.kuaishouActMap = v; }
+
+    public Map<Integer, List<Integer>> getKuaishouExtraActMap() { return kuaishouExtraActMap; }
+    public void setKuaishouExtraActMap(Map<Integer, List<Integer>> v) { this.kuaishouExtraActMap = 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; }
+}

+ 53 - 0
src/main/java/com/adx/tracking/conversionsync/ConversionSyncRunner.java

@@ -0,0 +1,53 @@
+package com.adx.tracking.conversionsync;
+
+import com.adx.tracking.baidu.model.ConversionQuery;
+import com.adx.tracking.storage.TiDBColdStore;
+import com.adx.tracking.storage.model.MediaCallbackRecord;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.util.List;
+
+/**
+ * 对应 Go internal/conversionsync/runner.go 的 Runner。
+ * 定期调用 ConversionSyncService.syncKuaishouConversions()。
+ * 在 Spring 中通过 @Scheduled 触发(见 AppScheduler)。
+ */
+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;
+    }
+
+    /**
+     * 对应 Go Runner.RunOnce()。
+     */
+    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.syncKuaishouConversions(query);
+    }
+}
+
+// ─── RetryService ─────────────────────────────────────────────────────────────
+
+// 单独的 RetryService 类,和 Go internal/conversionsync/retry.go 对应:
+// 从 TiDB 查出未成功的 Kuaishou 回调,逐条重试。

+ 170 - 0
src/main/java/com/adx/tracking/conversionsync/ConversionSyncService.java

@@ -0,0 +1,170 @@
+package com.adx.tracking.conversionsync;
+
+import com.adx.tracking.baidu.ConversionClient;
+import com.adx.tracking.baidu.model.ConversionQuery;
+import com.adx.tracking.baidu.model.ConversionResponse;
+import com.adx.tracking.baidu.model.PaymentInfo;
+import com.adx.tracking.media.kuaishou.KuaishouClient;
+import com.adx.tracking.storage.RedisHotStore;
+import com.adx.tracking.storage.TiDBColdStore;
+import com.adx.tracking.storage.model.*;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.time.Instant;
+import java.util.List;
+
+/**
+ * 对应 Go internal/conversionsync/service.go 的 Service。
+ * 核心逻辑:
+ *   1. 轮询百度转化数据(分页)
+ *   2. 按 qk 查找对应的 bid 记录
+ *   3. 若是快手 media 且有 callback,发送快手回调(支持幂等去重)
+ *   4. 持久化 ConversionRecord 和 MediaCallbackRecord
+ */
+public class ConversionSyncService {
+
+    private static final Logger log = LoggerFactory.getLogger(ConversionSyncService.class);
+
+    private final ConversionClient baiduClient;
+    private final RedisHotStore    hotStore;
+    private final KuaishouClient   kuaishouClient;
+    private final TiDBColdStore    coldStore;  // nullable
+
+    public ConversionSyncService(ConversionClient baiduClient, RedisHotStore hotStore,
+                                  KuaishouClient kuaishouClient, TiDBColdStore coldStore) {
+        this.baiduClient    = baiduClient;
+        this.hotStore       = hotStore;
+        this.kuaishouClient = kuaishouClient;
+        this.coldStore      = coldStore;
+    }
+
+    /**
+     * 对应 Go Service.SyncKuaishouConversions()。
+     * 分页拉取并处理,返回汇总 Result。
+     */
+    public SyncResult syncKuaishouConversions(ConversionQuery query) throws Exception {
+        if (query.getPageSize() <= 0) query.setPageSize(1);
+
+        SyncResult result = new SyncResult();
+        for (int page = query.getPageSize(); ; page++) {
+            query.setPageSize(page);
+            ConversionResponse response = baiduClient.queryPayments(query);
+            List<PaymentInfo> data = response.getData();
+            if (data != null) {
+                processPayments(data, result);
+            }
+            if (response.getPageSize() <= page) break;
+        }
+        return result;
+    }
+
+    private void processPayments(List<PaymentInfo> payments, SyncResult result) throws Exception {
+        for (PaymentInfo payment : payments) {
+            result.fetched++;
+
+            // 用 qk 查找 bid
+            BidRecord bid = hotStore.findBidByQk(payment.getQk());
+
+            if (bid == null) {
+                // bid 未找到 → 只保存转化记录,标记 skipped
+                if (coldStore != null) {
+                    coldStore.saveConversion(conversionRecord(payment, ""));
+                }
+                result.skipped++;
+                continue;
+            }
+
+            // bid 不是快手,或没有 callback
+            String callback = bid.getMediaParams() != null
+                    ? bid.getMediaParams().get("callback") : null;
+            if (!"kuaishou".equals(bid.getMedia()) || callback == null || callback.isBlank()) {
+                if (coldStore != null) {
+                    coldStore.saveConversion(conversionRecord(payment, bid.getMedia()));
+                }
+                result.skipped++;
+                continue;
+            }
+
+            result.matched++;
+            String conversionKey = conversionDedupeKey(payment, bid.getMedia());
+            if (coldStore != null) {
+                coldStore.saveConversion(conversionRecord(payment, bid.getMedia()));
+            }
+
+            // 获取事件类型列表
+            List<Integer> eventTypes;
+            try {
+                eventTypes = kuaishouClient.conversionEventTypes(payment);
+            } catch (Exception e) {
+                result.failed++;
+                continue;
+            }
+
+            for (int eventType : eventTypes) {
+                String callbackKey = callbackDedupeKey(payment, bid.getMedia(), eventType);
+
+                // 幂等检查
+                if (coldStore != null) {
+                    boolean exists = coldStore.successfulCallbackExistsForEvent(
+                            callbackKey, conversionKey, eventType);
+                    if (exists) {
+                        result.alreadySent++;
+                        continue;
+                    }
+                }
+
+                // 发送回调(对应 Go: err != nil 才是网络级失败,HTTP 非2xx 算 sent)
+                MediaCallbackRecord callbackRecord = kuaishouClient.sendConversionEvent(bid, payment, eventType);
+                callbackRecord.setDedupeKey(callbackKey);
+                if (callbackRecord.getStatus() == 0 && callbackRecord.getErrorMessage() != null) {
+                    // 网络级错误(连接被拒/超时等),对应 Go err != nil
+                    result.failed++;
+                    if (coldStore != null) {
+                        try { coldStore.saveMediaCallback(callbackRecord); } catch (Exception ignored) {}
+                    }
+                    continue;
+                }
+                // HTTP 请求完成(即使状态码非 2xx 也算 sent,对应 Go err==nil)
+                result.sent++;
+                if (coldStore != null) {
+                    coldStore.saveMediaCallback(callbackRecord);
+                }
+            }
+        }
+    }
+
+    // ─── 辅助方法 ─────────────────────────────────────────────────────────────
+
+    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, int eventType) {
+        return conversionDedupeKey(p, media) + ":" + eventType;
+    }
+
+    // ─── Result DTO ─────────────────────────────────────────────────────────
+
+    public static class SyncResult {
+        public int fetched, matched, sent, skipped, failed, alreadySent;
+    }
+}

+ 83 - 0
src/main/java/com/adx/tracking/conversionsync/RetryService.java

@@ -0,0 +1,83 @@
+package com.adx.tracking.conversionsync;
+
+import com.adx.tracking.media.kuaishou.KuaishouClient;
+import com.adx.tracking.storage.TiDBColdStore;
+import com.adx.tracking.storage.model.MediaCallbackRecord;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.time.Instant;
+import java.util.List;
+
+/**
+ * 对应 Go internal/conversionsync/retry.go 的 RetryService。
+ * 查询 TiDB 中未成功的快手回调记录,逐条重试。
+ */
+public class RetryService {
+
+    private static final Logger log = LoggerFactory.getLogger(RetryService.class);
+
+    private final TiDBColdStore coldStore;
+    private final KuaishouClient kuaishouClient;
+    private final int defaultLimit;
+
+    public RetryService(TiDBColdStore coldStore, KuaishouClient kuaishouClient, int defaultLimit) {
+        this.coldStore      = coldStore;
+        this.kuaishouClient = kuaishouClient;
+        this.defaultLimit   = defaultLimit > 0 ? defaultLimit : 100;
+    }
+
+    /**
+     * 对应 Go RetryService.RetryKuaishouCallbacks()。
+     * @param limit 最多处理条数,<=0 则使用 defaultLimit
+     */
+    public RetryResult retryKuaishouCallbacks(int limit) {
+        if (limit <= 0) limit = defaultLimit;
+
+        List<MediaCallbackRecord> pending = coldStore.pendingMediaCallbacks("kuaishou", limit);
+        RetryResult result = new RetryResult();
+        result.fetched = pending.size();
+
+        for (MediaCallbackRecord record : pending) {
+            MediaCallbackRecord retryRecord;
+            try {
+                retryRecord = kuaishouClient.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;
+            }
+            // 对应 Go: 成功重试后 saveMediaCallback 失败则终止当前批次
+            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;
+    }
+}

+ 421 - 0
src/main/java/com/adx/tracking/httpapi/AdxController.java

@@ -0,0 +1,421 @@
+package com.adx.tracking.httpapi;
+
+import com.adx.tracking.baidu.AdxClient;
+import com.adx.tracking.baidu.ConversionClient;
+import com.adx.tracking.baidu.model.ConversionQuery;
+import com.adx.tracking.baidu.model.NormalizedBidResponse;
+import com.adx.tracking.baidu.model.TrackingResult;
+import com.adx.tracking.conversionsync.ConversionSyncService;
+import com.adx.tracking.conversionsync.RetryService;
+import com.adx.tracking.storage.RedisHotStore;
+import com.adx.tracking.storage.model.BidRecord;
+import com.adx.tracking.storage.model.TrackingRecord;
+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.io.IOException;
+import java.time.Instant;
+import java.util.*;
+
+/**
+ * 对应 Go internal/httpapi/router.go 的所有路由。
+ * 路由列表:
+ *   GET  /health
+ *   POST /baidu/adx/request
+ *   POST /baidu/adx/report-impression
+ *   POST /baidu/adx/report-click
+ *   POST /baidu/conversions/query
+ *   GET  /media/kuaishou/impression
+ *   GET  /media/kuaishou/click
+ *   POST /jobs/kuaishou/conversions/sync
+ *   POST /jobs/kuaishou/callbacks/retry
+ */
+@RestController
+public class AdxController {
+
+    private final AdxClient adxClient;
+    private final ConversionClient conversionClient;
+    private final RedisHotStore hotStore;
+    private final ConversionSyncService conversionSyncer;   // nullable
+    private final RetryService retryService;                // nullable
+    private final MediaPlacement kuaishouPlacement;         // nullable
+
+    public AdxController(AdxClient adxClient,
+                         ConversionClient conversionClient,
+                         RedisHotStore hotStore,
+                         @Nullable ConversionSyncService conversionSyncer,
+                         @Nullable RetryService retryService,
+                         @Nullable MediaPlacement kuaishouPlacement) {
+        this.adxClient         = adxClient;
+        this.conversionClient  = conversionClient;
+        this.hotStore          = hotStore;
+        this.conversionSyncer  = conversionSyncer;
+        this.retryService      = retryService;
+        this.kuaishouPlacement = kuaishouPlacement;
+    }
+
+    // ─── /health ────────────────────────────────────────────────────────────
+
+    @GetMapping("/health")
+    public Map<String, Boolean> health() {
+        return Map.of("ok", true);
+    }
+
+    // ─── POST /baidu/adx/request ─────────────────────────────────────────────
+
+    @PostMapping("/baidu/adx/request")
+    public NormalizedBidResponse baiduAdxRequest(@RequestBody Map<String, Object> body) throws Exception {
+        NormalizedBidResponse response = adxClient.requestBid(body);
+        if (response != null && response.getQk() != null && !response.getQk().isBlank()) {
+            BidRecord record = bidRecordFromResponse(response, mediaParamsFromBody(body));
+            hotStore.recordBid(record);
+        }
+        return response;
+    }
+
+    // ─── POST /baidu/adx/report-impression ───────────────────────────────────
+
+    @PostMapping("/baidu/adx/report-impression")
+    public Map<String, Object> baiduAdxReportImpression(@RequestBody Map<String, Object> body) throws Exception {
+        String qk = (String) body.get("qk");
+        @SuppressWarnings("unchecked")
+        List<String> showUrls = (List<String>) body.getOrDefault("showUrls", Collections.emptyList());
+        long price = toLong(body.get("price"));
+
+        List<TrackingResult> results = adxClient.reportImpression(showUrls, price);
+        recordTrackingResults(qk, "impression", results);
+        return Map.of("data", results);
+    }
+
+    // ─── POST /baidu/adx/report-click ────────────────────────────────────────
+
+    @PostMapping("/baidu/adx/report-click")
+    public Map<String, Object> baiduAdxReportClick(@RequestBody Map<String, Object> body) throws Exception {
+        String qk = (String) body.get("qk");
+        @SuppressWarnings("unchecked")
+        List<String> clickUrls = (List<String>) body.getOrDefault("clickUrls", Collections.emptyList());
+
+        List<TrackingResult> results = adxClient.reportClick(clickUrls);
+        recordTrackingResults(qk, "click", results);
+        return Map.of("data", results);
+    }
+
+    // ─── POST /baidu/conversions/query ───────────────────────────────────────
+
+    @PostMapping("/baidu/conversions/query")
+    public Object baiduConversionsQuery(@RequestBody ConversionQuery query) throws Exception {
+        return conversionClient.queryPayments(query);
+    }
+
+    // ─── GET /media/kuaishou/impression ──────────────────────────────────────
+
+    @GetMapping("/media/kuaishou/impression")
+    public Map<String, Object> kuaishouImpression(HttpServletRequest request) throws Exception {
+        Map<String, String> params = flatQueryParams(request);
+        String traceId = mediaTraceId(params);
+        if (traceId == null || traceId.isBlank()) {
+            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "missing media trace id");
+        }
+        if (kuaishouPlacement == null) {
+            throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "kuaishou placement not configured");
+        }
+
+        Map<String, Object> bidRequest = mediaBidRequest(request, "kuaishou", traceId, params, kuaishouPlacement);
+        NormalizedBidResponse response = adxClient.requestBid(bidRequest);
+        if (response == null) {
+            throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "empty baidu ADX response");
+        }
+
+        BidRecord record = bidRecordFromMediaResponse(response, "kuaishou", traceId, params);
+        hotStore.recordBid(record);
+
+        List<TrackingResult> trackingResults = Collections.emptyList();
+        if (response.getBid() != null && response.getBid().getShowUrls() != null
+                && !response.getBid().getShowUrls().isEmpty()) {
+            trackingResults = adxClient.reportImpression(
+                    response.getBid().getShowUrls(), response.getBid().getPrice());
+            recordTrackingResults(response.getQk(), "impression", trackingResults);
+        }
+
+        return Map.of("qk", nvl(response.getQk()), "traceId", traceId, "tracking", trackingResults);
+    }
+
+    // ─── GET /media/kuaishou/click ────────────────────────────────────────────
+
+    @GetMapping("/media/kuaishou/click")
+    public Object kuaishouClick(HttpServletRequest request, HttpServletResponse response)
+            throws Exception {
+        if (kuaishouPlacement == null) {
+            throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "kuaishou placement not configured");
+        }
+
+        Map<String, String> params = flatQueryParams(request);
+        String traceId = mediaTraceId(params);
+        if (traceId == null || traceId.isBlank()) {
+            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "missing media trace id");
+        }
+
+        // 先从 Redis 按 mediaTrace 查找已有 bid
+        BidRecord record = hotStore.findBidByMediaTrace("kuaishou", traceId);
+        if (record == null) {
+            // 未找到,临时发起竞价请求
+            Map<String, Object> bidRequest = mediaBidRequest(request, "kuaishou", traceId, params, kuaishouPlacement);
+            NormalizedBidResponse resp = adxClient.requestBid(bidRequest);
+            if (resp == null) {
+                throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "empty baidu ADX response");
+            }
+            BidRecord newRecord = bidRecordFromMediaResponse(resp, "kuaishou", traceId, params);
+            hotStore.recordBid(newRecord);
+            record = newRecord;
+        }
+
+        List<TrackingResult> trackingResults = adxClient.reportClick(
+                record.getClickUrls() != null ? record.getClickUrls() : Collections.emptyList());
+        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()) {
+            response.sendRedirect(destination);
+            return null;
+        }
+
+        return Map.of("qk", nvl(record.getQk()), "traceId", traceId, "tracking", trackingResults);
+    }
+
+    // ─── POST /jobs/kuaishou/conversions/sync ─────────────────────────────────
+
+    @PostMapping("/jobs/kuaishou/conversions/sync")
+    public Object kuaishouConversionsSync(@RequestBody ConversionQuery query) throws Exception {
+        if (conversionSyncer == null) {
+            throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
+                    "kuaishou conversion syncer is not configured");
+        }
+        return conversionSyncer.syncKuaishouConversions(query);
+    }
+
+    // ─── POST /jobs/kuaishou/callbacks/retry ──────────────────────────────────
+
+    @PostMapping("/jobs/kuaishou/callbacks/retry")
+    public Object kuaishouCallbacksRetry(@RequestBody Map<String, Object> body) {
+        if (retryService == null) {
+            throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
+                    "kuaishou callback retryer is not configured");
+        }
+        int limit = toInt(body.get("limit"));
+        return retryService.retryKuaishouCallbacks(limit);
+    }
+
+    // ─── 私有工具方法(对应 Go router.go 中的辅助函数)─────────────────────────
+
+    private BidRecord bidRecordFromResponse(NormalizedBidResponse response,
+                                             Map<String, String> mediaParams) {
+        BidRecord r = new BidRecord();
+        r.setQk(response.getQk());
+        r.setReqId(response.getReqId());
+        r.setBidId(response.getBidId());
+        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 BidRecord bidRecordFromMediaResponse(NormalizedBidResponse response,
+                                                  String media, String traceId,
+                                                  Map<String, String> mediaParams) {
+        BidRecord r = bidRecordFromResponse(response, mediaParams);
+        r.setMedia(media);
+        r.setMediaTraceId(traceId);
+        return r;
+    }
+
+    @SuppressWarnings("unchecked")
+    private Map<String, String> mediaParamsFromBody(Map<String, Object> body) {
+        Object raw = body.get("mediaParams");
+        if (!(raw instanceof Map)) return null;
+        Map<Object, Object> rawMap = (Map<Object, Object>) raw;
+        Map<String, String> params = new HashMap<>();
+        rawMap.forEach((k, v) -> params.put(String.valueOf(k), String.valueOf(v)));
+        return params.isEmpty() ? null : params;
+    }
+
+    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("trace_id", "click_id", "callback", "request_id",
+                "event_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<>();
+        // 对应 Go gin c.ClientIP():默认信任反向代理,优先解析 X-Forwarded-For(取第一个非空 IP)、
+        // 再 X-Real-IP,最后回退到 RemoteAddr。生产环境必经反向代理,
+        // 直接使用 request.getRemoteAddr() 会得到代理 IP(如 127.0.0.1 / 内网 LB IP),
+        // 导致百度 ADX 拿到错误的客户端 IP。
+        device.put("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());
+        // 对应 Go: fmt.Sprintf("%s-%d", traceID, time.Now().UnixNano())
+        // 必须是纳秒精度,毫秒或 nanoTime() 都会使 reqId 与 Go 实现不一致
+        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);
+        }
+    }
+
+    /**
+     * 对应 Go gin Context.ClientIP() 的默认行为。
+     * 解析顺序:X-Forwarded-For 第一个非空 IP → X-Real-IP → RemoteAddr。
+     */
+    static String clientIp(HttpServletRequest request) {
+        String xff = request.getHeader("X-Forwarded-For");
+        if (xff != null && !xff.isBlank()) {
+            // X-Forwarded-For 格式可能为 "client, proxy1, proxy2",取第一个
+            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 long toLong(Object v) {
+        if (v == null) return 0L;
+        if (v instanceof Number n) return n.longValue();
+        try { return Long.parseLong(v.toString()); } catch (NumberFormatException e) { return 0L; }
+    }
+
+    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; }
+    }
+
+    private static String nvl(String s) { return s != null ? s : ""; }
+}

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

@@ -0,0 +1,66 @@
+package com.adx.tracking.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/tracking/httpapi/MediaPlacement.java

@@ -0,0 +1,129 @@
+package com.adx.tracking.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) {}
+}

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

@@ -0,0 +1,139 @@
+package com.adx.tracking.leader;
+
+import com.adx.tracking.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/tracking/leader/Lease.java

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

+ 250 - 0
src/main/java/com/adx/tracking/media/kuaishou/KuaishouClient.java

@@ -0,0 +1,250 @@
+package com.adx.tracking.media.kuaishou;
+
+import com.adx.tracking.baidu.model.PaymentInfo;
+import com.adx.tracking.storage.model.BidRecord;
+import com.adx.tracking.storage.model.MediaCallbackRecord;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URLEncoder;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+import java.util.*;
+
+/**
+ * 对应 Go internal/media/kuaishou/client.go 的 Client。
+ * 职责:
+ *   1. 百度 act → 快手 eventType 的映射(默认表 + 可配置覆盖)
+ *   2. 发送转化事件回调(buildCallbackURL + HTTP GET)
+ *   3. 重试已有回调记录
+ */
+public class KuaishouClient {
+
+    private final HttpClient httpClient;
+    private final Map<Integer, Integer> actMap;        // baiduAct -> kuaishouEventType
+    private final Map<Integer, List<Integer>> extraActMap; // baiduAct -> [extra kuaishouEventTypes]
+
+    public KuaishouClient(Map<Integer, Integer> actMapOverride,
+                          Map<Integer, List<Integer>> extraActMap) {
+        this.httpClient = HttpClient.newHttpClient();
+        // 从默认表开始,再用配置覆盖
+        Map<Integer, Integer> merged = new HashMap<>(defaultActMap());
+        if (actMapOverride != null) merged.putAll(actMapOverride);
+        this.actMap = Collections.unmodifiableMap(merged);
+        this.extraActMap = extraActMap != null ? extraActMap : Collections.emptyMap();
+    }
+
+    // ─── ConversionEventTypes ────────────────────────────────────────────────
+
+    /**
+     * 对应 Go ConversionEventTypes():
+     * 根据 payment.act 查出所有需要发送的快手事件类型(主+附加)。
+     */
+    public List<Integer> conversionEventTypes(PaymentInfo payment) {
+        int eventType = actMap.getOrDefault(payment.getAct(), 0);
+        if (eventType == 0) {
+            throw new IllegalArgumentException("unsupported baidu act: " + payment.getAct());
+        }
+        Set<Integer> seen = new LinkedHashSet<>();
+        seen.add(eventType);
+        List<Integer> extras = extraActMap.getOrDefault(payment.getAct(), Collections.emptyList());
+        for (int extra : extras) {
+            if (extra != 0) seen.add(extra);
+        }
+        return new ArrayList<>(seen);
+    }
+
+    // ─── SendConversionEvent ─────────────────────────────────────────────────
+
+    /**
+     * 对应 Go SendConversionEvent():构造回调 URL 并发送 GET 请求。
+     * 与 Go 一致:即使出错也返回部分填充的 MediaCallbackRecord(而非抛异常),
+     * 调用方可以获取 callbackUrl / eventTimeMs 等信息用于持久化。
+     */
+    public MediaCallbackRecord sendConversionEvent(BidRecord bid, PaymentInfo payment, int eventType) {
+        String callbackUrl = bid.getMediaParams() != null
+                ? bid.getMediaParams().get("callback") : null;
+        if (callbackUrl == null || callbackUrl.isBlank()) {
+            throw new IllegalArgumentException("kuaishou callback is required");
+        }
+        long eventTimeMs = Instant.now().toEpochMilli();
+
+        MediaCallbackRecord record = new MediaCallbackRecord();
+        record.setMedia("kuaishou");
+        record.setQk(payment.getQk());
+        record.setEventType(eventType);
+        record.setEventTimeMs(eventTimeMs);
+        record.setPurchase(payment.getPayment());
+        record.setAttempt(1);
+        record.setCreatedAt(Instant.now());
+
+        String reportUrl;
+        try {
+            reportUrl = buildCallbackUrl(callbackUrl, eventType, eventTimeMs, payment.getPayment());
+        } catch (Exception e) {
+            // 对应 Go: buildCallbackURL 失败时返回 (emptyRecord, err),调用方计入 Failed++
+            record.setCallbackUrl(callbackUrl);
+            record.setOk(false);
+            record.setErrorMessage(e.getMessage());
+            return record;
+        }
+        record.setCallbackUrl(reportUrl);
+
+        try {
+            HttpRequest request = HttpRequest.newBuilder()
+                    .uri(URI.create(reportUrl))
+                    .GET()
+                    .build();
+            HttpResponse<byte[]> response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray());
+            int status = response.statusCode();
+            String body = truncate(new String(response.body()), 4096);
+            record.setStatus(status);
+            record.setOk(status >= 200 && status < 300);
+            record.setResponseBody(body);
+        } catch (Exception e) {
+            // 对应 Go:即使 HTTP 请求失败也返回 record(ErrorMessage 已设置),不抛异常
+            record.setOk(false);
+            record.setErrorMessage(e.getMessage());
+        }
+        return record;
+    }
+
+    // ─── RetryCallback ───────────────────────────────────────────────────────
+
+    /**
+     * 对应 Go RetryCallback():直接用原 callbackUrl 重发。
+     */
+    public MediaCallbackRecord retryCallback(MediaCallbackRecord original)
+            throws IOException, InterruptedException {
+        HttpRequest request = HttpRequest.newBuilder()
+                .uri(URI.create(original.getCallbackUrl()))
+                .GET()
+                .build();
+
+        MediaCallbackRecord retry = copyRecord(original);
+        retry.setAttempt(original.getAttempt() + 1);
+        retry.setCreatedAt(Instant.now());
+
+        try {
+            HttpResponse<byte[]> response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray());
+            int status = response.statusCode();
+            retry.setStatus(status);
+            retry.setOk(status >= 200 && status < 300);
+            retry.setResponseBody(truncate(new String(response.body()), 4096));
+            retry.setErrorMessage(null);
+        } catch (IOException | InterruptedException e) {
+            retry.setOk(false);
+            retry.setErrorMessage(e.getMessage());
+            throw e;
+        }
+        return retry;
+    }
+
+    // ─── 构建回调 URL ─────────────────────────────────────────────────────────
+
+    /**
+     * 对应 Go buildCallbackURL():
+     * 在 URL 上设置 event_type, event_time, (purchase_amount if eventType==3)。
+     * 与 Go 一致:使用 url.Values 风格的参数解析+重建,自动去重和正确编码。
+     * 关键:Go 的 url.Values.Encode() 会按 key 字典序输出,
+     * 这里使用 TreeMap 保证参数顺序与 Go 完全一致。
+     * 注意:对应 Go 的 parsed.Query()(自动解码 percent-encoding)+ query.Encode()(重新编码),
+     * 必须先解码再编码,避免双重编码。
+     */
+    static String buildCallbackUrl(String callbackUrl, int eventType, long eventTimeMs, double purchase) {
+        try {
+            URI uri = new URI(callbackUrl);
+            // 解析已有 query 参数到 Map(对应 Go parsed.Query(),自动解码 percent-encoding)
+            // 使用 TreeMap 按 key 字典序排序,与 Go url.Values.Encode() 行为一致
+            Map<String, String> params = parseQueryDecoded(uri.getRawQuery());
+            // Set 操作:已有则覆盖,没有则新增(对应 Go query.Set())
+            params.put("event_type", String.valueOf(eventType));
+            params.put("event_time", String.valueOf(eventTimeMs));
+            if (eventType == 3) {
+                params.put("purchase_amount", String.format("%.2f", purchase));
+            }
+            // 重建 query string(对应 Go query.Encode(),按 key 排序输出)
+            StringBuilder query = new StringBuilder();
+            for (Map.Entry<String, String> e : params.entrySet()) {
+                if (query.length() > 0) query.append("&");
+                query.append(URLEncoder.encode(e.getKey(), StandardCharsets.UTF_8))
+                      .append("=")
+                      .append(URLEncoder.encode(e.getValue(), StandardCharsets.UTF_8));
+            }
+            // 使用原始编码方式构建 URI,避免二次编码
+            String result = uri.getScheme() + "://" + uri.getRawAuthority() + uri.getRawPath()
+                    + "?" + query;
+            if (uri.getRawFragment() != null) {
+                result += "#" + uri.getRawFragment();
+            }
+            return result;
+        } catch (URISyntaxException e) {
+            throw new IllegalArgumentException("invalid callback url: " + callbackUrl, e);
+        }
+    }
+    
+    /**
+     * 解析 URL query string 为 TreeMap,并对值进行 percent-decode。
+     * 对应 Go 的 url.Parse().Query():返回解码后的 url.Values。
+     */
+    private static Map<String, String> parseQueryDecoded(String query) {
+        Map<String, String> params = new TreeMap<>();
+        if (query == null || query.isEmpty()) return params;
+        for (String pair : query.split("&")) {
+            int eq = pair.indexOf('=');
+            if (eq > 0) {
+                String key = java.net.URLDecoder.decode(pair.substring(0, eq), StandardCharsets.UTF_8);
+                String value = java.net.URLDecoder.decode(pair.substring(eq + 1), StandardCharsets.UTF_8);
+                params.put(key, value);
+            } else if (!pair.isEmpty()) {
+                String key = java.net.URLDecoder.decode(pair, StandardCharsets.UTF_8);
+                params.put(key, "");
+            }
+        }
+        return params;
+    }
+
+    // ─── 默认 act 映射表(对应 Go defaultActMap())────────────────────────────
+
+    static Map<Integer, Integer> defaultActMap() {
+        Map<Integer, Integer> m = new HashMap<>();
+        m.put(1, 1);
+        m.put(2, 3);
+        m.put(3, 2);
+        m.put(4, 7);
+        m.put(5, 3);
+        m.put(6, 84);
+        m.put(8, 8);
+        return m;
+    }
+
+    // ─── 工具 ────────────────────────────────────────────────────────────────
+
+    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;
+    }
+}

+ 271 - 0
src/main/java/com/adx/tracking/storage/RedisHotStore.java

@@ -0,0 +1,271 @@
+package com.adx.tracking.storage;
+
+import com.adx.tracking.storage.model.BidRecord;
+import com.adx.tracking.storage.model.QueuedEvent;
+import com.adx.tracking.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 {
+            String payload = objectMapper.writeValueAsString(record);
+            redis.executePipelined((org.springframework.data.redis.connection.RedisConnection conn) -> {
+                byte[] payloadBytes = payload.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),
+                            payloadBytes,
+                            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),
+                            payloadBytes,
+                            org.springframework.data.redis.core.types.Expiration.milliseconds(ttlMillis),
+                            org.springframework.data.redis.connection.RedisStringCommands.SetOption.UPSERT
+                    );
+                }
+                // XADD using byte-level API
+                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), payloadBytes);
+                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/tracking/storage/RedisLock.java

@@ -0,0 +1,109 @@
+package com.adx.tracking.storage;
+
+import com.adx.tracking.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);
+        }
+    }
+}

+ 302 - 0
src/main/java/com/adx/tracking/storage/TiDBColdStore.java

@@ -0,0 +1,302 @@
+package com.adx.tracking.storage;
+
+import com.adx.tracking.storage.model.*;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.springframework.jdbc.core.JdbcTemplate;
+
+import javax.sql.DataSource;
+import java.sql.Timestamp;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 对应 Go internal/storage/tidb.go 的 TiDBColdStore。
+ * 使用 Spring JdbcTemplate 操作 TiDB(MySQL 协议兼容)。
+ * 功能:
+ *   1. migrate() - 建表 DDL
+ *   2. saveBid / saveTracking / saveConversion / saveMediaCallback
+ *   3. successfulCallbackExists / successfulCallbackExistsForEvent
+ *   4. pendingMediaCallbacks
+ */
+public class TiDBColdStore {
+
+    private final JdbcTemplate jdbc;
+    private final ObjectMapper objectMapper;
+
+    public TiDBColdStore(DataSource dataSource, ObjectMapper objectMapper) {
+        this.jdbc = new JdbcTemplate(dataSource);
+        this.objectMapper = objectMapper;
+    }
+
+    // ─── DDL 迁移 ────────────────────────────────────────────────────────────
+
+    public void migrate() {
+        String[] ddls = {
+            """
+            CREATE TABLE IF NOT EXISTS ad_bid_events (
+                id BIGINT AUTO_INCREMENT PRIMARY KEY,
+                qk VARCHAR(128) NOT NULL,
+                media VARCHAR(64),
+                media_trace_id VARCHAR(255),
+                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_ad_bid_events_qk (qk),
+                KEY idx_ad_bid_events_media_trace (media, media_trace_id),
+                KEY idx_ad_bid_events_created_at (created_at)
+            )
+            """,
+            """
+            CREATE TABLE IF NOT EXISTS 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_tracking_reports_qk_kind_created_at (qk, kind, created_at)
+            )
+            """,
+            """
+            CREATE TABLE IF NOT EXISTS 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_baidu_conversions_dedupe (dedupe_key),
+                KEY idx_baidu_conversions_qk_act_created_at (qk, act, created_at),
+                KEY idx_baidu_conversions_device_id (device_id)
+            )
+            """,
+            """
+            CREATE TABLE IF NOT EXISTS 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_media_callbacks_dedupe_ok_created_at (dedupe_key, ok, created_at),
+                KEY idx_media_callbacks_qk_created_at (qk, created_at),
+                KEY idx_media_callbacks_media_event_created_at (media, event_type, created_at)
+            )
+            """
+        };
+        for (String ddl : ddls) {
+            jdbc.execute(ddl);
+        }
+        // 字段长度补丁(已存在的表可能是旧的 128)
+        try {
+            jdbc.execute("ALTER TABLE ad_bid_events MODIFY COLUMN req_id VARCHAR(512)");
+        } catch (Exception ignored) {}
+    }
+
+    // ─── 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());
+
+        jdbc.update("""
+            INSERT INTO ad_bid_events (
+                qk, media, media_trace_id, 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+            ON DUPLICATE KEY UPDATE
+                media = VALUES(media),
+                media_trace_id = VALUES(media_trace_id),
+                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)
+            """,
+            record.getQk(), record.getMedia(), record.getMediaTraceId(),
+            record.getReqId(), record.getBidId(), record.getCreativeId(),
+            record.getImpId(), record.getTagId(), record.getPrice(),
+            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());
+        jdbc.update("""
+            INSERT INTO tracking_reports (qk, kind, tracking_url, status, ok, created_at)
+            VALUES (?, ?, ?, ?, ?, ?)
+            """,
+            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());
+        jdbc.update("""
+            INSERT INTO baidu_conversions (
+                dedupe_key, qk, media, date_value, appsid, customer_name, device_id, conv,
+                payment, act, tu, clk_time, created_at
+            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+            ON DUPLICATE KEY UPDATE
+                media = VALUES(media),
+                conv = VALUES(conv),
+                payment = VALUES(payment),
+                clk_time = VALUES(clk_time)
+            """,
+            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();
+        jdbc.update("""
+            INSERT INTO media_callbacks (
+                dedupe_key, media, qk, callback_url, event_type, event_time_ms, purchase,
+                status, ok, attempt, response_body, error_message, created_at
+            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+            """,
+            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;
+        Integer count = jdbc.queryForObject(
+                "SELECT COUNT(1) FROM media_callbacks WHERE dedupe_key = ? AND ok = 1 LIMIT 1",
+                Integer.class, dedupeKey);
+        return count != null && count > 0;
+    }
+
+    public boolean successfulCallbackExistsForEvent(String dedupeKey, String legacyDedupeKey, int eventType) {
+        if (dedupeKey == null || dedupeKey.isBlank()) return false;
+        Integer count = jdbc.queryForObject("""
+            SELECT COUNT(1)
+            FROM media_callbacks
+            WHERE ok = 1
+              AND (
+                dedupe_key = ?
+                OR (dedupe_key = ? AND event_type = ?)
+              )
+            LIMIT 1
+            """, Integer.class, dedupeKey, legacyDedupeKey, eventType);
+        return count != null && count > 0;
+    }
+
+    // ─── 查询:待重试的回调 ────────────────────────────────────────────────────
+
+    public List<MediaCallbackRecord> pendingMediaCallbacks(String media, int limit) {
+        if (limit <= 0) limit = 100;
+        return jdbc.query("""
+            SELECT m.dedupe_key, m.media, m.qk, m.callback_url, m.event_type, m.event_time_ms,
+                   m.purchase, m.status, m.ok, m.attempt, m.response_body, m.error_message, m.created_at
+            FROM media_callbacks m
+            JOIN (
+                SELECT dedupe_key, MAX(id) AS id
+                FROM media_callbacks
+                WHERE media = ? AND dedupe_key IS NOT NULL AND dedupe_key != ''
+                GROUP BY dedupe_key
+            ) latest ON latest.id = m.id
+            WHERE m.media = ? AND m.ok = 0
+              AND NOT EXISTS (
+                SELECT 1 FROM media_callbacks ok_cb
+                WHERE ok_cb.dedupe_key = m.dedupe_key AND ok_cb.ok = 1
+              )
+            ORDER BY m.created_at ASC
+            LIMIT ?
+            """,
+            (rs, rowNum) -> {
+                MediaCallbackRecord r = new MediaCallbackRecord();
+                r.setDedupeKey(rs.getString("dedupe_key"));
+                r.setMedia(rs.getString("media"));
+                r.setQk(rs.getString("qk"));
+                r.setCallbackUrl(rs.getString("callback_url"));
+                r.setEventType(rs.getInt("event_type"));
+                r.setEventTimeMs(rs.getLong("event_time_ms"));
+                r.setPurchase(rs.getDouble("purchase"));
+                r.setStatus(rs.getInt("status"));
+                r.setOk(rs.getInt("ok") == 1);
+                r.setAttempt(rs.getInt("attempt"));
+                r.setResponseBody(rs.getString("response_body"));
+                r.setErrorMessage(rs.getString("error_message"));
+                Timestamp ts = rs.getTimestamp("created_at");
+                if (ts != null) r.setCreatedAt(ts.toInstant());
+                return r;
+            },
+            media, 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);
+        }
+    }
+}

+ 59 - 0
src/main/java/com/adx/tracking/storage/model/BidRecord.java

@@ -0,0 +1,59 @@
+package com.adx.tracking.storage.model;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import java.time.Instant;
+import java.util.List;
+import java.util.Map;
+
+/** 对应 Go storage.BidRecord */
+public class BidRecord {
+    @JsonProperty("qk")           private String qk;
+    @JsonProperty("media")        private String media;
+    @JsonProperty("mediaTraceId") private String mediaTraceId;
+    @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 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; }
+}

+ 48 - 0
src/main/java/com/adx/tracking/storage/model/ConversionRecord.java

@@ -0,0 +1,48 @@
+package com.adx.tracking.storage.model;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import java.time.Instant;
+
+/** 对应 Go storage.ConversionRecord */
+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; }
+}

+ 48 - 0
src/main/java/com/adx/tracking/storage/model/MediaCallbackRecord.java

@@ -0,0 +1,48 @@
+package com.adx.tracking.storage.model;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import java.time.Instant;
+
+/** 对应 Go storage.MediaCallbackRecord */
+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; }
+}

+ 15 - 0
src/main/java/com/adx/tracking/storage/model/QueuedEvent.java

@@ -0,0 +1,15 @@
+package com.adx.tracking.storage.model;
+
+/** 对应 Go storage.QueuedEvent */
+public class QueuedEvent {
+    private String id;
+    private String type;    // "bid" | "tracking"
+    private String payload; // JSON string
+
+    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; }
+}

+ 27 - 0
src/main/java/com/adx/tracking/storage/model/TrackingRecord.java

@@ -0,0 +1,27 @@
+package com.adx.tracking.storage.model;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import java.time.Instant;
+
+/** 对应 Go storage.TrackingRecord */
+public class TrackingRecord {
+    @JsonProperty("qk")        private String qk;
+    @JsonProperty("kind")      private String kind;  // "impression" | "click"
+    @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; }
+}

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

@@ -0,0 +1,111 @@
+package com.adx.tracking.worker;
+
+import com.adx.tracking.storage.RedisHotStore;
+import com.adx.tracking.storage.TiDBColdStore;
+import com.adx.tracking.storage.model.BidRecord;
+import com.adx.tracking.storage.model.QueuedEvent;
+import com.adx.tracking.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());
+        }
+    }
+}

+ 65 - 0
src/main/resources/application.properties

@@ -0,0 +1,65 @@
+# --- Server -------------------------------------------------------
+server.port=8787
+
+# --- Baidu ADX -----------------------------------------------------
+adx.baidu-adx-endpoint=http://adsp.baidu.com/jbssp
+adx.baidu-conversion-base-url=https://media-interact-test.baidu.com
+adx.baidu-customer-name=dingzhaoming111
+adx.baidu-secret=gk780scz3oi2
+adx.baidu-auction-price-encryption=aes-128-cbc
+adx.baidu-auction-price-e-key=192c728294891ef6
+adx.baidu-auction-price-i-key=ee64e8c430a2510e
+adx.baidu-media-id=1213
+adx.baidu-android-app-id=dcb4cbec
+adx.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
+adx.baidu-ios-app-id=efff0562
+adx.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
+adx.baidu-bid-floor=0
+adx.baidu-action-type=1
+
+# --- Redis ---------------------------------------------------------
+adx.redis-addr=cq-crs-47p6pmwo.sql.tencentcdb.com:28036
+adx.redis-username=hcst
+adx.redis-password=hcst@2026qwe..
+adx.redis-db=0
+adx.redis-stream=adx:events
+adx.redis-stream-max-len=50000
+adx.bid-ttl=24h
+
+# --- TiDB/MySQL ----------------------------------------------------
+adx.tidb-url=jdbc:mysql://139.186.172.149:3390/adx_proxy?useSSL=false&useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&connectTimeout=5000&socketTimeout=30000
+adx.tidb-username=hcst
+adx.tidb-password=hcst@2025
+
+# --- Task Lock -----------------------------------------------------
+adx.task-lock-ttl=120s
+adx.task-lock-renew-interval=30s
+adx.task-lock-retry-interval=15s
+ # -- true?????
+adx.skip-leader-election=true
+
+# --- Worker --------------------------------------------------------
+adx.worker-group=cold-writers
+adx.worker-consumer=server-1
+adx.worker-batch=100
+
+# --- Conversion Sync -----------------------------------------------
+adx.conversion-sync-enabled=true
+adx.conversion-sync-interval=5s
+adx.conversion-sync-date-offset-days=0
+adx.conversion-sync-page-size=1
+adx.conversion-sync-acts=1,2,3,4,5,6,8
+
+# --- Kuaishou Act Map -----------------------------------------------
+# Go env: KUAISHOU_ACT_MAP= (empty -> Java default new HashMap)
+# Go env: KUAISHOU_EXTRA_ACT_MAP=3:1
+# Go env: REDIS_STREAM_MAXLEN=50000 -> see adx.redis-stream-max-len above
+adx.kuaishou-extra-act-map.3=1
+
+# --- Callback Retry ------------------------------------------------
+adx.callback-retry-enabled=true
+adx.callback-retry-interval=300s
+adx.callback-retry-limit=100
+
+# --- Jackson -------------------------------------------------------
+spring.jackson.default-property-inclusion=non_null

BIN
target/adx-tracking-1.0.0.jar


BIN
target/adx-tracking-1.0.0.jar.original


+ 232 - 0
target/classes/META-INF/spring-configuration-metadata.json

@@ -0,0 +1,232 @@
+{
+  "groups": [
+    {
+      "name": "adx",
+      "type": "com.adx.tracking.config.AppProperties",
+      "sourceType": "com.adx.tracking.config.AppProperties"
+    }
+  ],
+  "properties": [
+    {
+      "name": "adx.baidu-action-type",
+      "type": "java.lang.Integer",
+      "sourceType": "com.adx.tracking.config.AppProperties"
+    },
+    {
+      "name": "adx.baidu-adx-endpoint",
+      "type": "java.lang.String",
+      "sourceType": "com.adx.tracking.config.AppProperties"
+    },
+    {
+      "name": "adx.baidu-android-app-id",
+      "type": "java.lang.String",
+      "sourceType": "com.adx.tracking.config.AppProperties"
+    },
+    {
+      "name": "adx.baidu-android-tag-ids",
+      "type": "java.util.List<java.lang.String>",
+      "sourceType": "com.adx.tracking.config.AppProperties"
+    },
+    {
+      "name": "adx.baidu-app-id",
+      "type": "java.lang.String",
+      "sourceType": "com.adx.tracking.config.AppProperties"
+    },
+    {
+      "name": "adx.baidu-auction-price-e-key",
+      "type": "java.lang.String",
+      "sourceType": "com.adx.tracking.config.AppProperties"
+    },
+    {
+      "name": "adx.baidu-auction-price-encryption",
+      "type": "java.lang.String",
+      "sourceType": "com.adx.tracking.config.AppProperties"
+    },
+    {
+      "name": "adx.baidu-auction-price-i-key",
+      "type": "java.lang.String",
+      "sourceType": "com.adx.tracking.config.AppProperties"
+    },
+    {
+      "name": "adx.baidu-bid-floor",
+      "type": "java.lang.Long",
+      "sourceType": "com.adx.tracking.config.AppProperties"
+    },
+    {
+      "name": "adx.baidu-conversion-base-url",
+      "type": "java.lang.String",
+      "sourceType": "com.adx.tracking.config.AppProperties"
+    },
+    {
+      "name": "adx.baidu-customer-name",
+      "type": "java.lang.String",
+      "sourceType": "com.adx.tracking.config.AppProperties"
+    },
+    {
+      "name": "adx.baidu-ios-app-id",
+      "type": "java.lang.String",
+      "sourceType": "com.adx.tracking.config.AppProperties"
+    },
+    {
+      "name": "adx.baidu-ios-tag-ids",
+      "type": "java.util.List<java.lang.String>",
+      "sourceType": "com.adx.tracking.config.AppProperties"
+    },
+    {
+      "name": "adx.baidu-media-id",
+      "type": "java.lang.Long",
+      "sourceType": "com.adx.tracking.config.AppProperties"
+    },
+    {
+      "name": "adx.baidu-secret",
+      "type": "java.lang.String",
+      "sourceType": "com.adx.tracking.config.AppProperties"
+    },
+    {
+      "name": "adx.baidu-tag-id",
+      "type": "java.lang.String",
+      "sourceType": "com.adx.tracking.config.AppProperties"
+    },
+    {
+      "name": "adx.bid-ttl",
+      "type": "java.time.Duration",
+      "sourceType": "com.adx.tracking.config.AppProperties"
+    },
+    {
+      "name": "adx.callback-retry-enabled",
+      "type": "java.lang.Boolean",
+      "sourceType": "com.adx.tracking.config.AppProperties"
+    },
+    {
+      "name": "adx.callback-retry-interval",
+      "type": "java.time.Duration",
+      "sourceType": "com.adx.tracking.config.AppProperties"
+    },
+    {
+      "name": "adx.callback-retry-limit",
+      "type": "java.lang.Integer",
+      "sourceType": "com.adx.tracking.config.AppProperties"
+    },
+    {
+      "name": "adx.conversion-sync-acts",
+      "type": "java.util.List<java.lang.Integer>",
+      "sourceType": "com.adx.tracking.config.AppProperties"
+    },
+    {
+      "name": "adx.conversion-sync-date-offset-days",
+      "type": "java.lang.Integer",
+      "sourceType": "com.adx.tracking.config.AppProperties"
+    },
+    {
+      "name": "adx.conversion-sync-enabled",
+      "type": "java.lang.Boolean",
+      "sourceType": "com.adx.tracking.config.AppProperties"
+    },
+    {
+      "name": "adx.conversion-sync-interval",
+      "type": "java.time.Duration",
+      "sourceType": "com.adx.tracking.config.AppProperties"
+    },
+    {
+      "name": "adx.conversion-sync-page-size",
+      "type": "java.lang.Integer",
+      "sourceType": "com.adx.tracking.config.AppProperties"
+    },
+    {
+      "name": "adx.kuaishou-act-map",
+      "type": "java.util.Map<java.lang.Integer,java.lang.Integer>",
+      "sourceType": "com.adx.tracking.config.AppProperties"
+    },
+    {
+      "name": "adx.kuaishou-extra-act-map",
+      "type": "java.util.Map<java.lang.Integer,java.util.List<java.lang.Integer>>",
+      "sourceType": "com.adx.tracking.config.AppProperties"
+    },
+    {
+      "name": "adx.port",
+      "type": "java.lang.String",
+      "sourceType": "com.adx.tracking.config.AppProperties"
+    },
+    {
+      "name": "adx.redis-addr",
+      "type": "java.lang.String",
+      "sourceType": "com.adx.tracking.config.AppProperties"
+    },
+    {
+      "name": "adx.redis-db",
+      "type": "java.lang.Integer",
+      "sourceType": "com.adx.tracking.config.AppProperties"
+    },
+    {
+      "name": "adx.redis-password",
+      "type": "java.lang.String",
+      "sourceType": "com.adx.tracking.config.AppProperties"
+    },
+    {
+      "name": "adx.redis-stream",
+      "type": "java.lang.String",
+      "sourceType": "com.adx.tracking.config.AppProperties"
+    },
+    {
+      "name": "adx.redis-stream-max-len",
+      "type": "java.lang.Long",
+      "sourceType": "com.adx.tracking.config.AppProperties"
+    },
+    {
+      "name": "adx.redis-username",
+      "type": "java.lang.String",
+      "sourceType": "com.adx.tracking.config.AppProperties"
+    },
+    {
+      "name": "adx.skip-leader-election",
+      "type": "java.lang.Boolean",
+      "sourceType": "com.adx.tracking.config.AppProperties"
+    },
+    {
+      "name": "adx.task-lock-renew-interval",
+      "type": "java.time.Duration",
+      "sourceType": "com.adx.tracking.config.AppProperties"
+    },
+    {
+      "name": "adx.task-lock-retry-interval",
+      "type": "java.time.Duration",
+      "sourceType": "com.adx.tracking.config.AppProperties"
+    },
+    {
+      "name": "adx.task-lock-ttl",
+      "type": "java.time.Duration",
+      "sourceType": "com.adx.tracking.config.AppProperties"
+    },
+    {
+      "name": "adx.tidb-password",
+      "type": "java.lang.String",
+      "sourceType": "com.adx.tracking.config.AppProperties"
+    },
+    {
+      "name": "adx.tidb-url",
+      "type": "java.lang.String",
+      "sourceType": "com.adx.tracking.config.AppProperties"
+    },
+    {
+      "name": "adx.tidb-username",
+      "type": "java.lang.String",
+      "sourceType": "com.adx.tracking.config.AppProperties"
+    },
+    {
+      "name": "adx.worker-batch",
+      "type": "java.lang.Integer",
+      "sourceType": "com.adx.tracking.config.AppProperties"
+    },
+    {
+      "name": "adx.worker-consumer",
+      "type": "java.lang.String",
+      "sourceType": "com.adx.tracking.config.AppProperties"
+    },
+    {
+      "name": "adx.worker-group",
+      "type": "java.lang.String",
+      "sourceType": "com.adx.tracking.config.AppProperties"
+    }
+  ],
+  "hints": []
+}

+ 65 - 0
target/classes/application.properties

@@ -0,0 +1,65 @@
+# --- Server -------------------------------------------------------
+server.port=8787
+
+# --- Baidu ADX -----------------------------------------------------
+adx.baidu-adx-endpoint=http://adsp.baidu.com/jbssp
+adx.baidu-conversion-base-url=https://media-interact-test.baidu.com
+adx.baidu-customer-name=dingzhaoming111
+adx.baidu-secret=gk780scz3oi2
+adx.baidu-auction-price-encryption=aes-128-cbc
+adx.baidu-auction-price-e-key=192c728294891ef6
+adx.baidu-auction-price-i-key=ee64e8c430a2510e
+adx.baidu-media-id=1213
+adx.baidu-android-app-id=dcb4cbec
+adx.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
+adx.baidu-ios-app-id=efff0562
+adx.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
+adx.baidu-bid-floor=0
+adx.baidu-action-type=1
+
+# --- Redis ---------------------------------------------------------
+adx.redis-addr=cq-crs-47p6pmwo.sql.tencentcdb.com:28036
+adx.redis-username=hcst
+adx.redis-password=hcst@2026qwe..
+adx.redis-db=0
+adx.redis-stream=adx:events
+adx.redis-stream-max-len=50000
+adx.bid-ttl=24h
+
+# --- TiDB/MySQL ----------------------------------------------------
+adx.tidb-url=jdbc:mysql://139.186.172.149:3390/adx_proxy?useSSL=false&useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&connectTimeout=5000&socketTimeout=30000
+adx.tidb-username=hcst
+adx.tidb-password=hcst@2025
+
+# --- Task Lock -----------------------------------------------------
+adx.task-lock-ttl=120s
+adx.task-lock-renew-interval=30s
+adx.task-lock-retry-interval=15s
+# 本地开发时设为true跳过分布式锁,线上设为false
+adx.skip-leader-election=true
+
+# --- Worker --------------------------------------------------------
+adx.worker-group=cold-writers
+adx.worker-consumer=server-1
+adx.worker-batch=100
+
+# --- Conversion Sync -----------------------------------------------
+adx.conversion-sync-enabled=true
+adx.conversion-sync-interval=5s
+adx.conversion-sync-date-offset-days=0
+adx.conversion-sync-page-size=1
+adx.conversion-sync-acts=1,2,3,4,5,6,8
+
+# --- Kuaishou Act Map -----------------------------------------------
+# Go env: KUAISHOU_ACT_MAP= (empty -> Java default new HashMap)
+# Go env: KUAISHOU_EXTRA_ACT_MAP=3:1
+# Go env: REDIS_STREAM_MAXLEN=50000 -> see adx.redis-stream-max-len above
+adx.kuaishou-extra-act-map.3=1
+
+# --- Callback Retry ------------------------------------------------
+adx.callback-retry-enabled=true
+adx.callback-retry-interval=300s
+adx.callback-retry-limit=100
+
+# --- Jackson -------------------------------------------------------
+spring.jackson.default-property-inclusion=non_null

BIN
target/classes/com/adx/tracking/AppConfiguration$BackgroundTasks.class


BIN
target/classes/com/adx/tracking/AppConfiguration.class


BIN
target/classes/com/adx/tracking/Application.class


BIN
target/classes/com/adx/tracking/baidu/AdxClient.class


BIN
target/classes/com/adx/tracking/baidu/AuctionPriceEncoder.class


BIN
target/classes/com/adx/tracking/baidu/ConversionClient.class


BIN
target/classes/com/adx/tracking/baidu/model/Adm.class


BIN
target/classes/com/adx/tracking/baidu/model/Bid.class


BIN
target/classes/com/adx/tracking/baidu/model/BidResponse.class


BIN
target/classes/com/adx/tracking/baidu/model/ConversionQuery.class


BIN
target/classes/com/adx/tracking/baidu/model/ConversionResponse$BaseResponse.class


BIN
target/classes/com/adx/tracking/baidu/model/ConversionResponse.class


BIN
target/classes/com/adx/tracking/baidu/model/NormalizedBid.class


BIN
target/classes/com/adx/tracking/baidu/model/NormalizedBidResponse.class


BIN
target/classes/com/adx/tracking/baidu/model/PaymentInfo.class


BIN
target/classes/com/adx/tracking/baidu/model/SeatBid.class


BIN
target/classes/com/adx/tracking/baidu/model/TrackingResult.class


BIN
target/classes/com/adx/tracking/config/AppProperties.class


BIN
target/classes/com/adx/tracking/conversionsync/ConversionSyncRunner.class


BIN
target/classes/com/adx/tracking/conversionsync/ConversionSyncService$SyncResult.class


BIN
target/classes/com/adx/tracking/conversionsync/ConversionSyncService.class


BIN
target/classes/com/adx/tracking/conversionsync/RetryService$RetryResult.class


BIN
target/classes/com/adx/tracking/conversionsync/RetryService.class


BIN
target/classes/com/adx/tracking/httpapi/AdxController.class


BIN
target/classes/com/adx/tracking/httpapi/GlobalExceptionHandler.class


BIN
target/classes/com/adx/tracking/httpapi/MediaPlacement$BaiduAppPlacement.class


BIN
target/classes/com/adx/tracking/httpapi/MediaPlacement$Resolved.class


BIN
target/classes/com/adx/tracking/httpapi/MediaPlacement.class


BIN
target/classes/com/adx/tracking/leader/LeaderElection$StopSignal.class


BIN
target/classes/com/adx/tracking/leader/LeaderElection.class


BIN
target/classes/com/adx/tracking/leader/Lease.class


BIN
target/classes/com/adx/tracking/media/kuaishou/KuaishouClient.class


BIN
target/classes/com/adx/tracking/storage/RedisHotStore.class


BIN
target/classes/com/adx/tracking/storage/RedisLock$RedisLease.class


BIN
target/classes/com/adx/tracking/storage/RedisLock.class


BIN
target/classes/com/adx/tracking/storage/TiDBColdStore.class


BIN
target/classes/com/adx/tracking/storage/model/BidRecord.class


BIN
target/classes/com/adx/tracking/storage/model/ConversionRecord.class


BIN
target/classes/com/adx/tracking/storage/model/MediaCallbackRecord.class


BIN
target/classes/com/adx/tracking/storage/model/QueuedEvent.class


BIN
target/classes/com/adx/tracking/storage/model/TrackingRecord.class


BIN
target/classes/com/adx/tracking/worker/ColdWorker.class


+ 3 - 0
target/maven-archiver/pom.properties

@@ -0,0 +1,3 @@
+artifactId=adx-tracking
+groupId=com.adx
+version=1.0.0

+ 43 - 0
target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst

@@ -0,0 +1,43 @@
+com/adx/tracking/baidu/model/TrackingResult.class
+com/adx/tracking/baidu/model/BidResponse.class
+com/adx/tracking/httpapi/MediaPlacement.class
+com/adx/tracking/httpapi/MediaPlacement$Resolved.class
+com/adx/tracking/baidu/model/ConversionQuery.class
+com/adx/tracking/baidu/model/Bid.class
+com/adx/tracking/httpapi/MediaPlacement$BaiduAppPlacement.class
+com/adx/tracking/storage/model/MediaCallbackRecord.class
+com/adx/tracking/baidu/model/Adm.class
+com/adx/tracking/Application.class
+com/adx/tracking/baidu/AuctionPriceEncoder.class
+com/adx/tracking/storage/model/BidRecord.class
+com/adx/tracking/conversionsync/ConversionSyncService$SyncResult.class
+com/adx/tracking/worker/ColdWorker.class
+com/adx/tracking/storage/model/TrackingRecord.class
+com/adx/tracking/storage/RedisLock.class
+com/adx/tracking/media/kuaishou/KuaishouClient.class
+com/adx/tracking/config/AppProperties.class
+com/adx/tracking/storage/TiDBColdStore.class
+com/adx/tracking/baidu/model/ConversionResponse$BaseResponse.class
+com/adx/tracking/baidu/ConversionClient.class
+com/adx/tracking/baidu/AdxClient.class
+com/adx/tracking/leader/LeaderElection.class
+com/adx/tracking/AppConfiguration$BackgroundTasks.class
+META-INF/spring-configuration-metadata.json
+com/adx/tracking/baidu/model/ConversionResponse.class
+com/adx/tracking/conversionsync/ConversionSyncService.class
+com/adx/tracking/conversionsync/ConversionSyncRunner.class
+com/adx/tracking/baidu/model/PaymentInfo.class
+com/adx/tracking/conversionsync/RetryService$RetryResult.class
+com/adx/tracking/httpapi/AdxController.class
+com/adx/tracking/storage/RedisHotStore.class
+com/adx/tracking/baidu/model/NormalizedBidResponse.class
+com/adx/tracking/baidu/model/SeatBid.class
+com/adx/tracking/storage/model/ConversionRecord.class
+com/adx/tracking/conversionsync/RetryService.class
+com/adx/tracking/leader/Lease.class
+com/adx/tracking/baidu/model/NormalizedBid.class
+com/adx/tracking/AppConfiguration.class
+com/adx/tracking/leader/LeaderElection$StopSignal.class
+com/adx/tracking/storage/model/QueuedEvent.class
+com/adx/tracking/storage/RedisLock$RedisLease.class
+com/adx/tracking/httpapi/GlobalExceptionHandler.class

+ 34 - 0
target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst

@@ -0,0 +1,34 @@
+/Users/yumeng/IdeaProjects/adx-tracking-java/src/main/java/com/adx/tracking/storage/model/TrackingRecord.java
+/Users/yumeng/IdeaProjects/adx-tracking-java/src/main/java/com/adx/tracking/storage/RedisLock.java
+/Users/yumeng/IdeaProjects/adx-tracking-java/src/main/java/com/adx/tracking/baidu/ConversionClient.java
+/Users/yumeng/IdeaProjects/adx-tracking-java/src/main/java/com/adx/tracking/storage/TiDBColdStore.java
+/Users/yumeng/IdeaProjects/adx-tracking-java/src/main/java/com/adx/tracking/baidu/model/NormalizedBid.java
+/Users/yumeng/IdeaProjects/adx-tracking-java/src/main/java/com/adx/tracking/httpapi/GlobalExceptionHandler.java
+/Users/yumeng/IdeaProjects/adx-tracking-java/src/main/java/com/adx/tracking/baidu/model/NormalizedBidResponse.java
+/Users/yumeng/IdeaProjects/adx-tracking-java/src/main/java/com/adx/tracking/leader/Lease.java
+/Users/yumeng/IdeaProjects/adx-tracking-java/src/main/java/com/adx/tracking/httpapi/AdxController.java
+/Users/yumeng/IdeaProjects/adx-tracking-java/src/main/java/com/adx/tracking/baidu/model/ConversionResponse.java
+/Users/yumeng/IdeaProjects/adx-tracking-java/src/main/java/com/adx/tracking/baidu/model/BidResponse.java
+/Users/yumeng/IdeaProjects/adx-tracking-java/src/main/java/com/adx/tracking/baidu/model/ConversionQuery.java
+/Users/yumeng/IdeaProjects/adx-tracking-java/src/main/java/com/adx/tracking/worker/ColdWorker.java
+/Users/yumeng/IdeaProjects/adx-tracking-java/src/main/java/com/adx/tracking/media/kuaishou/KuaishouClient.java
+/Users/yumeng/IdeaProjects/adx-tracking-java/src/main/java/com/adx/tracking/Application.java
+/Users/yumeng/IdeaProjects/adx-tracking-java/src/main/java/com/adx/tracking/leader/LeaderElection.java
+/Users/yumeng/IdeaProjects/adx-tracking-java/src/main/java/com/adx/tracking/conversionsync/ConversionSyncRunner.java
+/Users/yumeng/IdeaProjects/adx-tracking-java/src/main/java/com/adx/tracking/baidu/AuctionPriceEncoder.java
+/Users/yumeng/IdeaProjects/adx-tracking-java/src/main/java/com/adx/tracking/baidu/model/TrackingResult.java
+/Users/yumeng/IdeaProjects/adx-tracking-java/src/main/java/com/adx/tracking/baidu/model/PaymentInfo.java
+/Users/yumeng/IdeaProjects/adx-tracking-java/src/main/java/com/adx/tracking/config/AppProperties.java
+/Users/yumeng/IdeaProjects/adx-tracking-java/src/main/java/com/adx/tracking/baidu/model/SeatBid.java
+/Users/yumeng/IdeaProjects/adx-tracking-java/src/main/java/com/adx/tracking/baidu/model/Bid.java
+/Users/yumeng/IdeaProjects/adx-tracking-java/src/main/java/com/adx/tracking/storage/model/MediaCallbackRecord.java
+/Users/yumeng/IdeaProjects/adx-tracking-java/src/main/java/com/adx/tracking/storage/model/ConversionRecord.java
+/Users/yumeng/IdeaProjects/adx-tracking-java/src/main/java/com/adx/tracking/storage/model/QueuedEvent.java
+/Users/yumeng/IdeaProjects/adx-tracking-java/src/main/java/com/adx/tracking/conversionsync/ConversionSyncService.java
+/Users/yumeng/IdeaProjects/adx-tracking-java/src/main/java/com/adx/tracking/AppConfiguration.java
+/Users/yumeng/IdeaProjects/adx-tracking-java/src/main/java/com/adx/tracking/storage/model/BidRecord.java
+/Users/yumeng/IdeaProjects/adx-tracking-java/src/main/java/com/adx/tracking/baidu/model/Adm.java
+/Users/yumeng/IdeaProjects/adx-tracking-java/src/main/java/com/adx/tracking/storage/RedisHotStore.java
+/Users/yumeng/IdeaProjects/adx-tracking-java/src/main/java/com/adx/tracking/conversionsync/RetryService.java
+/Users/yumeng/IdeaProjects/adx-tracking-java/src/main/java/com/adx/tracking/baidu/AdxClient.java
+/Users/yumeng/IdeaProjects/adx-tracking-java/src/main/java/com/adx/tracking/httpapi/MediaPlacement.java