yumeng 20 ore fa
parent
commit
acf7dc0c70

+ 4 - 3
pom.xml

@@ -34,10 +34,11 @@
             <artifactId>spring-boot-starter-data-redis</artifactId>
         </dependency>
 
-        <!-- Spring JDBC (TiDB/MySQL) -->
+        <!-- MyBatis (TiDB/MySQL) -->
         <dependency>
-            <groupId>org.springframework.boot</groupId>
-            <artifactId>spring-boot-starter-jdbc</artifactId>
+            <groupId>org.mybatis.spring.boot</groupId>
+            <artifactId>mybatis-spring-boot-starter</artifactId>
+            <version>3.0.3</version>
         </dependency>
 
         <!-- MySQL Driver (TiDB compatible) -->

+ 13 - 1
src/main/java/com/adx/tracking/AppConfiguration.java

@@ -15,6 +15,8 @@ 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.apache.ibatis.session.SqlSessionFactory;
+import org.mybatis.spring.SqlSessionFactoryBean;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -22,6 +24,7 @@ import org.springframework.boot.ApplicationArguments;
 import org.springframework.boot.ApplicationRunner;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
+import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
 import org.springframework.data.redis.connection.RedisConnectionFactory;
 import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
 import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
@@ -104,7 +107,8 @@ public class AppConfiguration {
         }
         try {
             DataSource ds = createDataSource(url, props.getTidbUsername(), props.getTidbPassword());
-            TiDBColdStore store = new TiDBColdStore(ds, objectMapper);
+            SqlSessionFactory sqlSessionFactory = createSqlSessionFactory(ds);
+            TiDBColdStore store = new TiDBColdStore(sqlSessionFactory, objectMapper);
             store.migrate();
             log.info("TiDB cold store initialized and migrated");
             return store;
@@ -113,6 +117,14 @@ public class AppConfiguration {
         }
     }
 
+    private SqlSessionFactory createSqlSessionFactory(DataSource dataSource) throws Exception {
+        SqlSessionFactoryBean factoryBean = new SqlSessionFactoryBean();
+        factoryBean.setDataSource(dataSource);
+        factoryBean.setMapperLocations(
+                new PathMatchingResourcePatternResolver().getResources("classpath:mapper/*.xml"));
+        return factoryBean.getObject();
+    }
+
     private DataSource createDataSource(String jdbcUrl, String username, String password) {
         org.springframework.jdbc.datasource.DriverManagerDataSource ds =
                 new org.springframework.jdbc.datasource.DriverManagerDataSource();

+ 73 - 132
src/main/java/com/adx/tracking/storage/TiDBColdStore.java

@@ -1,18 +1,19 @@
 package com.adx.tracking.storage;
 
+import com.adx.tracking.storage.mapper.*;
 import com.adx.tracking.storage.model.*;
 import com.fasterxml.jackson.databind.ObjectMapper;
-import org.springframework.jdbc.core.JdbcTemplate;
+import org.apache.ibatis.session.SqlSession;
+import org.apache.ibatis.session.SqlSessionFactory;
 
-import javax.sql.DataSource;
+import java.sql.Statement;
 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 协议兼容)。
+ * 使用 MyBatis 操作 TiDB(MySQL 协议兼容)。
  * 功能:
  *   1. migrate() - 建表 DDL
  *   2. saveBid / saveTracking / saveConversion / saveMediaCallback
@@ -21,11 +22,11 @@ import java.util.List;
  */
 public class TiDBColdStore {
 
-    private final JdbcTemplate jdbc;
+    private final SqlSessionFactory sqlSessionFactory;
     private final ObjectMapper objectMapper;
 
-    public TiDBColdStore(DataSource dataSource, ObjectMapper objectMapper) {
-        this.jdbc = new JdbcTemplate(dataSource);
+    public TiDBColdStore(SqlSessionFactory sqlSessionFactory, ObjectMapper objectMapper) {
+        this.sqlSessionFactory = sqlSessionFactory;
         this.objectMapper = objectMapper;
     }
 
@@ -112,13 +113,19 @@ public class TiDBColdStore {
             )
             """
         };
-        for (String ddl : ddls) {
-            jdbc.execute(ddl);
+        try (SqlSession session = sqlSessionFactory.openSession(true)) {
+            Statement stmt = session.getConnection().createStatement();
+            for (String ddl : ddls) {
+                stmt.execute(ddl);
+            }
+            // 字段长度补丁(已存在的表可能是旧的 128)
+            try {
+                stmt.execute("ALTER TABLE ad_bid_events MODIFY COLUMN req_id VARCHAR(512)");
+            } catch (Exception ignored) {}
+            stmt.close();
+        } catch (Exception e) {
+            throw new RuntimeException("migrate failed", e);
         }
-        // 字段长度补丁(已存在的表可能是旧的 128)
-        try {
-            jdbc.execute("ALTER TABLE ad_bid_events MODIFY COLUMN req_id VARCHAR(512)");
-        } catch (Exception ignored) {}
     }
 
     // ─── SaveBid ─────────────────────────────────────────────────────────────
@@ -133,72 +140,47 @@ public class TiDBColdStore {
         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())
-        );
+        try (SqlSession session = sqlSessionFactory.openSession(true)) {
+            BidEventMapper mapper = session.getMapper(BidEventMapper.class);
+            mapper.insertOrUpdate(
+                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())
-        );
+        try (SqlSession session = sqlSessionFactory.openSession(true)) {
+            TrackingReportMapper mapper = session.getMapper(TrackingReportMapper.class);
+            mapper.insert(
+                record.getQk(), record.getKind(), record.getUrl(),
+                record.getStatus(), record.isOk() ? 1 : 0,
+                Timestamp.from(record.getCreatedAt())
+            );
+        }
     }
 
     // ─── SaveConversion ──────────────────────────────────────────────────────
 
     public void saveConversion(ConversionRecord record) {
         if (record.getCreatedAt() == null) record.setCreatedAt(Instant.now());
-        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())
-        );
+        try (SqlSession session = sqlSessionFactory.openSession(true)) {
+            ConversionMapper mapper = session.getMapper(ConversionMapper.class);
+            mapper.insertOrUpdate(
+                record.getDedupeKey(), record.getQk(), record.getMedia(),
+                record.getDate(), record.getAppSid(), record.getCustomerName(),
+                record.getDeviceId(), record.getConv(), record.getPayment(),
+                record.getAct(), record.getTu(), record.getClkTime(),
+                Timestamp.from(record.getCreatedAt())
+            );
+        }
     }
 
     // ─── SaveMediaCallback ───────────────────────────────────────────────────
@@ -206,87 +188,46 @@ public class TiDBColdStore {
     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())
-        );
+        try (SqlSession session = sqlSessionFactory.openSession(true)) {
+            MediaCallbackMapper mapper = session.getMapper(MediaCallbackMapper.class);
+            mapper.insert(
+                record.getDedupeKey(), record.getMedia(), record.getQk(),
+                record.getCallbackUrl(), record.getEventType(), record.getEventTimeMs(),
+                record.getPurchase(), record.getStatus(), record.isOk() ? 1 : 0,
+                attempt, record.getResponseBody(), record.getErrorMessage(),
+                Timestamp.from(record.getCreatedAt())
+            );
+        }
     }
 
     // ─── 查询:成功回调是否存在 ────────────────────────────────────────────────
 
     public boolean successfulCallbackExists(String dedupeKey) {
         if (dedupeKey == null || dedupeKey.isBlank()) return false;
-        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;
+        try (SqlSession session = sqlSessionFactory.openSession(true)) {
+            MediaCallbackMapper mapper = session.getMapper(MediaCallbackMapper.class);
+            int count = mapper.countSuccessfulByDedupeKey(dedupeKey);
+            return count > 0;
+        }
     }
 
     public boolean successfulCallbackExistsForEvent(String dedupeKey, String legacyDedupeKey, int eventType) {
         if (dedupeKey == null || dedupeKey.isBlank()) return false;
-        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;
+        try (SqlSession session = sqlSessionFactory.openSession(true)) {
+            MediaCallbackMapper mapper = session.getMapper(MediaCallbackMapper.class);
+            int count = mapper.countSuccessfulForEvent(dedupeKey, legacyDedupeKey, eventType);
+            return count > 0;
+        }
     }
 
     // ─── 查询:待重试的回调 ────────────────────────────────────────────────────
 
     public List<MediaCallbackRecord> pendingMediaCallbacks(String media, int limit) {
         if (limit <= 0) limit = 100;
-        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
-        );
+        try (SqlSession session = sqlSessionFactory.openSession(true)) {
+            MediaCallbackMapper mapper = session.getMapper(MediaCallbackMapper.class);
+            return mapper.selectPendingCallbacks(media, limit);
+        }
     }
 
     // ─── 工具方法 ─────────────────────────────────────────────────────────────

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

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

+ 22 - 0
src/main/java/com/adx/tracking/storage/mapper/ConversionMapper.java

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

+ 34 - 0
src/main/java/com/adx/tracking/storage/mapper/MediaCallbackMapper.java

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

+ 15 - 0
src/main/java/com/adx/tracking/storage/mapper/TrackingReportMapper.java

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

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

@@ -1,6 +1,9 @@
 # --- Server -------------------------------------------------------
 server.port=8787
 
+# --- Auto-configuration exclusions --------------------------------
+spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration,org.mybatis.spring.boot.autoconfigure.MybatisAutoConfiguration
+
 # --- Baidu ADX -----------------------------------------------------
 adx.baidu-adx-endpoint=http://adsp.baidu.com/jbssp
 adx.baidu-conversion-base-url=https://media-interact-test.baidu.com

+ 33 - 0
src/main/resources/mapper/BidEventMapper.xml

@@ -0,0 +1,33 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.adx.tracking.storage.mapper.BidEventMapper">
+
+    <insert id="insertOrUpdate">
+        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 (
+            #{qk}, #{media}, #{mediaTraceId}, #{reqId}, #{bidId}, #{creativeId},
+            #{impId}, #{tagId}, #{price}, #{showUrls}, #{clickUrls}, #{landingPage},
+            #{appStoreLink}, #{packageName}, #{mediaParams}, #{createdAt}
+        )
+        ON DUPLICATE KEY UPDATE
+            media = VALUES(media),
+            media_trace_id = VALUES(media_trace_id),
+            req_id = VALUES(req_id),
+            bid_id = VALUES(bid_id),
+            creative_id = VALUES(creative_id),
+            imp_id = VALUES(imp_id),
+            tag_id = VALUES(tag_id),
+            price = VALUES(price),
+            show_urls = VALUES(show_urls),
+            click_urls = VALUES(click_urls),
+            landing_page = VALUES(landing_page),
+            app_store_link = VALUES(app_store_link),
+            package_name = VALUES(package_name),
+            media_params = VALUES(media_params)
+    </insert>
+
+</mapper>

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

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

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

@@ -0,0 +1,67 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.adx.tracking.storage.mapper.MediaCallbackMapper">
+
+    <resultMap id="mediaCallbackResultMap" type="com.adx.tracking.storage.model.MediaCallbackRecord">
+        <result property="dedupeKey" column="dedupe_key"/>
+        <result property="media" column="media"/>
+        <result property="qk" column="qk"/>
+        <result property="callbackUrl" column="callback_url"/>
+        <result property="eventType" column="event_type"/>
+        <result property="eventTimeMs" column="event_time_ms"/>
+        <result property="purchase" column="purchase"/>
+        <result property="status" column="status"/>
+        <result property="ok" column="ok_val"/>
+        <result property="attempt" column="attempt"/>
+        <result property="responseBody" column="response_body"/>
+        <result property="errorMessage" column="error_message"/>
+        <result property="createdAt" column="created_at"/>
+    </resultMap>
+
+    <insert id="insert">
+        INSERT INTO media_callbacks (
+            dedupe_key, media, qk, callback_url, event_type, event_time_ms, purchase,
+            status, ok, attempt, response_body, error_message, created_at
+        ) VALUES (
+            #{dedupeKey}, #{media}, #{qk}, #{callbackUrl}, #{eventType}, #{eventTimeMs},
+            #{purchase}, #{status}, #{ok}, #{attempt}, #{responseBody}, #{errorMessage},
+            #{createdAt}
+        )
+    </insert>
+
+    <select id="countSuccessfulByDedupeKey" resultType="int">
+        SELECT COUNT(1) FROM media_callbacks WHERE dedupe_key = #{dedupeKey} AND ok = 1 LIMIT 1
+    </select>
+
+    <select id="countSuccessfulForEvent" resultType="int">
+        SELECT COUNT(1)
+        FROM media_callbacks
+        WHERE ok = 1
+          AND (
+            dedupe_key = #{dedupeKey}
+            OR (dedupe_key = #{legacyDedupeKey} AND event_type = #{eventType})
+          )
+        LIMIT 1
+    </select>
+
+    <select id="selectPendingCallbacks" resultMap="mediaCallbackResultMap">
+        SELECT m.dedupe_key, m.media, m.qk, m.callback_url, m.event_type, m.event_time_ms,
+               m.purchase, m.status, m.ok AS ok_val, m.attempt, m.response_body, m.error_message, m.created_at
+        FROM media_callbacks m
+        JOIN (
+            SELECT dedupe_key, MAX(id) AS id
+            FROM media_callbacks
+            WHERE media = #{media} AND dedupe_key IS NOT NULL AND dedupe_key != ''
+            GROUP BY dedupe_key
+        ) latest ON latest.id = m.id
+        WHERE m.media = #{media} AND m.ok = 0
+          AND NOT EXISTS (
+            SELECT 1 FROM media_callbacks ok_cb
+            WHERE ok_cb.dedupe_key = m.dedupe_key AND ok_cb.ok = 1
+          )
+        ORDER BY m.created_at ASC
+        LIMIT #{limit}
+    </select>
+
+</mapper>

+ 11 - 0
src/main/resources/mapper/TrackingReportMapper.xml

@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.adx.tracking.storage.mapper.TrackingReportMapper">
+
+    <insert id="insert">
+        INSERT INTO tracking_reports (qk, kind, tracking_url, status, ok, created_at)
+        VALUES (#{qk}, #{kind}, #{trackingUrl}, #{status}, #{ok}, #{createdAt})
+    </insert>
+
+</mapper>