yumeng před 1 týdnem
rodič
revize
13f9b2464c

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 363 - 3708
logs/tencent-baidu-tracking.log


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

@@ -32,6 +32,7 @@ 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.scheduling.annotation.Scheduled;
 
 import org.springframework.lang.Nullable;
 
@@ -83,6 +84,7 @@ public class AppConfiguration {
     @Bean
     public RedisHotStore redisHotStore(StringRedisTemplate redis, ObjectMapper objectMapper) {
         return new RedisHotStore(redis, objectMapper,
+                tiDBColdStore(objectMapper),
                 "adx:tencent:",
                 props.getRedisStream(),
                 props.getBidTtl());
@@ -386,6 +388,43 @@ public class AppConfiguration {
             taskLog.info("[ConversionSync] task stopped");
         }
 
+        @Scheduled(cron = "0 17 1,7,10,16,20,23 * * ?")
+        public void runDelayedConversionSync() {
+            if (conversionSyncRunner == null || !props.isConversionSyncEnabled()) {
+                return;
+            }
+            if (stopped.get()) {
+                return;
+            }
+
+            Runnable job = () -> {
+                try {
+                    taskLog.info("[ConversionBackfill] executing for offsets=1..7");
+                    ConversionSyncService.SyncResult r = conversionSyncRunner.runForOffsets(List.of(-1, -2, -3, -4, -5, -6, -7));
+                    taskLog.info("[ConversionBackfill] done: fetched={} matched={} sent={} failed={} skipped={} alreadySent={}",
+                            r.fetched.get(), r.matched.get(), r.sent.get(), r.failed.get(),
+                            r.skipped.get(), r.alreadySent.get());
+                } catch (Exception e) {
+                    taskLog.error("[ConversionBackfill] error: {}", e.getMessage(), e);
+                }
+            };
+
+            if (props.isSkipLeaderElection() || leaderElection == null) {
+                job.run();
+                return;
+            }
+
+            executor.submit(() ->
+                    leaderElection.runOnce(
+                            "adx:lock:tencent:conversion-backfill",
+                            props.getTaskLockTtl(), props.getTaskLockRenewInterval(),
+                            stopped::get,
+                            stop -> job.run(),
+                            e -> taskLog.error("tencent conversion backfill leader: {}", e.getMessage(), e)
+                    )
+            );
+        }
+
         private void runCallbackRetry(LeaderElection.StopSignal jobStop) {
             long intervalMs = props.getCallbackRetryInterval().toMillis();
             taskLog.info("[CallbackRetry] task started, interval={}ms, limit={}", intervalMs, props.getCallbackRetryLimit());

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

@@ -6,6 +6,7 @@ import org.slf4j.LoggerFactory;
 
 import java.time.LocalDate;
 import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
 import java.util.List;
 
 /**
@@ -31,11 +32,45 @@ public class ConversionSyncRunner {
     }
 
     public ConversionSyncService.SyncResult runOnce() throws Exception {
-        String date = LocalDate.now().plusDays(dateOffsetDays).format(DATE_FMT);
+        return runForOffsets(List.of(dateOffsetDays));
+    }
+
+    public ConversionSyncService.SyncResult runForOffsets(List<Integer> offsets) throws Exception {
+        ConversionSyncService.SyncResult total = new ConversionSyncService.SyncResult();
+        if (offsets == null || offsets.isEmpty()) {
+            return total;
+        }
+
+        List<Integer> normalized = new ArrayList<>(offsets.size());
+        for (Integer offset : offsets) {
+            if (offset != null) {
+                normalized.add(offset);
+            }
+        }
+
+        for (Integer offset : normalized) {
+            String date = LocalDate.now().plusDays(offset).format(DATE_FMT);
+            ConversionSyncService.SyncResult result = runForDate(date);
+            merge(total, result);
+        }
+        return total;
+    }
+
+    private ConversionSyncService.SyncResult runForDate(String date) throws Exception {
         ConversionQuery query = new ConversionQuery();
         query.setDate(date);
         query.setPageSize(pageSize);
         query.setActs(acts);
         return syncer.syncTencentConversions(query);
     }
+
+    private static void merge(ConversionSyncService.SyncResult total, ConversionSyncService.SyncResult part) {
+        if (total == null || part == null) return;
+        total.fetched.addAndGet(part.fetched.get());
+        total.matched.addAndGet(part.matched.get());
+        total.sent.addAndGet(part.sent.get());
+        total.skipped.addAndGet(part.skipped.get());
+        total.failed.addAndGet(part.failed.get());
+        total.alreadySent.addAndGet(part.alreadySent.get());
+    }
 }

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

@@ -2,6 +2,7 @@ package com.adx.tencent.httpapi;
 
 import com.adx.tencent.baidu.ConversionClient;
 import com.adx.tencent.baidu.model.ConversionQuery;
+import com.adx.tencent.conversionsync.ConversionSyncRunner;
 import com.adx.tencent.conversionsync.ConversionSyncService;
 import com.adx.tencent.conversionsync.RetryService;
 import org.springframework.http.HttpStatus;
@@ -9,6 +10,7 @@ import org.springframework.lang.Nullable;
 import org.springframework.web.bind.annotation.*;
 import org.springframework.web.server.ResponseStatusException;
 
+import java.util.List;
 import java.util.Map;
 
 /**
@@ -19,13 +21,16 @@ import java.util.Map;
 public class AdminController {
 
     private final ConversionClient conversionClient;
+    private final ConversionSyncRunner conversionSyncRunner;
     private final ConversionSyncService conversionSyncer;
     private final RetryService retryService;
 
     public AdminController(ConversionClient conversionClient,
+                           @Nullable ConversionSyncRunner conversionSyncRunner,
                            @Nullable ConversionSyncService conversionSyncer,
                            @Nullable RetryService retryService) {
         this.conversionClient = conversionClient;
+        this.conversionSyncRunner = conversionSyncRunner;
         this.conversionSyncer = conversionSyncer;
         this.retryService = retryService;
     }
@@ -44,6 +49,16 @@ public class AdminController {
         return conversionSyncer.syncTencentConversions(query);
     }
 
+    @PostMapping("/conversions/backfill")
+    public Object backfillConversions(@RequestBody(required = false) Map<String, Object> body) throws Exception {
+        if (conversionSyncRunner == null) {
+            throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
+                    "conversion sync runner is not configured");
+        }
+        List<Integer> offsets = parseOffsets(body);
+        return conversionSyncRunner.runForOffsets(offsets);
+    }
+
     @PostMapping("/callbacks/retry")
     public Object retryCallbacks(@RequestBody Map<String, Object> body) {
         if (retryService == null) {
@@ -59,4 +74,23 @@ public class AdminController {
         if (v instanceof Number n) return n.intValue();
         try { return Integer.parseInt(v.toString()); } catch (NumberFormatException e) { return 0; }
     }
+
+    private static List<Integer> parseOffsets(Map<String, Object> body) {
+        if (body == null || body.isEmpty()) {
+            return defaultBackfillOffsets();
+        }
+        Object raw = body.get("offsets");
+        if (!(raw instanceof Iterable<?> iterable)) {
+            return defaultBackfillOffsets();
+        }
+        List<Integer> offsets = new java.util.ArrayList<>();
+        for (Object item : iterable) {
+            offsets.add(toInt(item));
+        }
+        return offsets.isEmpty() ? defaultBackfillOffsets() : offsets;
+    }
+
+    private static List<Integer> defaultBackfillOffsets() {
+        return List.of(-1, -2, -3, -4, -5, -6, -7);
+    }
 }

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

@@ -109,6 +109,64 @@ public class LeaderElection {
         }
     }
 
+    /**
+     * 抢到锁后只执行一次 job。
+     * 适合定时任务场景:由调度器触发一次,由本方法保证单实例执行。
+     */
+    public boolean runOnce(String key, Duration ttl, Duration renewInterval,
+                           StopSignal stopped, Consumer<StopSignal> job, Consumer<Exception> onError) {
+        Duration resolvedTtl = ttl.isZero() ? Duration.ofMinutes(2) : ttl;
+        Duration resolvedRenewInterval = renewInterval.isZero() ? resolvedTtl.dividedBy(4) : renewInterval;
+
+        Lease lease;
+        try {
+            log.info("[LeaderElection] trying to acquire lock once: {}", key);
+            lease = redisLock.tryAcquire(key, resolvedTtl);
+        } catch (Exception e) {
+            report(onError, "acquire lock " + key + " failed: " + e.getMessage(), e);
+            return false;
+        }
+        if (lease == null) {
+            log.info("[LeaderElection] lock {} not acquired for once job", key);
+            return false;
+        }
+
+        final AtomicBoolean renewFailed = new AtomicBoolean(false);
+        StopSignal jobStop = () -> stopped.isStopped() || renewFailed.get();
+
+        ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
+            Thread t = new Thread(r, "leader-once-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);
+
+        try {
+            job.accept(jobStop);
+            return true;
+        } catch (Exception e) {
+            report(onError, "job error: " + e.getMessage(), e);
+            return false;
+        } finally {
+            renewFuture.cancel(true);
+            scheduler.shutdownNow();
+            try { lease.release(); } catch (Exception e) {
+                report(onError, "release lock " + key + ": " + e.getMessage(), e);
+            }
+        }
+    }
+
     private static void sleep(Duration d, StopSignal stopped) {
         long deadline = System.currentTimeMillis() + d.toMillis();
         while (!stopped.isStopped()) {

+ 10 - 3
src/main/java/com/adx/tencent/storage/RedisHotStore.java

@@ -28,14 +28,16 @@ public class RedisHotStore {
 
     private final StringRedisTemplate redis;
     private final ObjectMapper objectMapper;
+    private final TiDBColdStore coldStore;
     private final String prefix;
     private final String stream;
     private final Duration bidTtl;
 
     public RedisHotStore(StringRedisTemplate redis, ObjectMapper objectMapper,
-                         String prefix, String stream, Duration bidTtl) {
+                         TiDBColdStore coldStore, String prefix, String stream, Duration bidTtl) {
         this.redis = redis;
         this.objectMapper = objectMapper;
+        this.coldStore = coldStore;
         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;
@@ -151,8 +153,13 @@ public class RedisHotStore {
 
     public BidRecord findBidByQk(String qk) {
         String payload = redis.opsForValue().get(bidKey(qk));
-        if (payload == null) return null;
-        return parseBidRecord(payload);
+        if (payload != null) {
+            return parseBidRecord(payload);
+        }
+        if (coldStore != null) {
+            return coldStore.getBidByQk(qk);
+        }
+        return null;
     }
 
     public BidRecord findBidByMediaTrace(String media, String traceId) {

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

@@ -202,6 +202,14 @@ public class TiDBColdStore {
         }
     }
 
+    public BidRecord getBidByQk(String qk) {
+        if (qk == null || qk.isBlank()) return null;
+        try (SqlSession session = sqlSessionFactory.openSession(true)) {
+            BidEventMapper mapper = session.getMapper(BidEventMapper.class);
+            return mapper.selectByQk(qk);
+        }
+    }
+
     // ─── SaveTracking ─────────────────────────────────────────────────────────
 
     public void saveTracking(TrackingRecord record) {

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

@@ -5,6 +5,8 @@ import org.apache.ibatis.annotations.Param;
 
 @Mapper
 public interface BidEventMapper {
+    com.adx.tencent.storage.model.BidRecord selectByQk(@Param("qk") String qk);
+
     void insertOrUpdate(@Param("qk") String qk,
                         @Param("media") String media,
                         @Param("mediaTraceId") String mediaTraceId,

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

@@ -49,7 +49,7 @@ adx:
   redis-db: 5
 
   # --- TiDB/MySQL ---
-  tidb-url: "jdbc:mysql://139.186.172.149:3390/adx_tencent?useSSL=false&useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true"
+  tidb-url: "jdbc:mysql://172.30.0.36:3390/adx_tencent?useSSL=false&useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true"
   tidb-username: "hcst"
   tidb-password: "hcst@2025"
 

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

@@ -2,6 +2,38 @@
 <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
 <mapper namespace="com.adx.tencent.storage.mapper.BidEventMapper">
+    <resultMap id="bidRecordResultMap" type="com.adx.tencent.storage.model.BidRecord">
+        <result property="qk" column="qk"/>
+        <result property="media" column="media"/>
+        <result property="mediaTraceId" column="media_trace_id"/>
+        <result property="platform" column="platform"/>
+        <result property="adId" column="ad_id"/>
+        <result property="accountId" column="account_id"/>
+        <result property="eventType" column="event_type"/>
+        <result property="reqId" column="req_id"/>
+        <result property="bidId" column="bid_id"/>
+        <result property="creativeId" column="creative_id"/>
+        <result property="impId" column="imp_id"/>
+        <result property="tagId" column="tag_id"/>
+        <result property="price" column="price"/>
+        <result property="showUrls" column="show_urls" typeHandler="org.apache.ibatis.type.JacksonTypeHandler"/>
+        <result property="clickUrls" column="click_urls" typeHandler="org.apache.ibatis.type.JacksonTypeHandler"/>
+        <result property="landingPage" column="landing_page"/>
+        <result property="appStoreLink" column="app_store_link"/>
+        <result property="packageName" column="package_name"/>
+        <result property="mediaParams" column="media_params" typeHandler="org.apache.ibatis.type.JacksonTypeHandler"/>
+        <result property="createdAt" column="created_at"/>
+    </resultMap>
+
+    <select id="selectByQk" resultMap="bidRecordResultMap">
+        SELECT qk, media, media_trace_id, platform, ad_id, account_id, event_type,
+               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
+        FROM tencent_ad_bid_events
+        WHERE qk = #{qk}
+        LIMIT 1
+    </select>
+
     <insert id="insertOrUpdate">
         INSERT INTO tencent_ad_bid_events (
             qk, media, media_trace_id, platform, ad_id, account_id, event_type,

+ 101 - 0
src/main/resources/schema-baidu-clue.sql

@@ -0,0 +1,101 @@
+-- ============================================================
+-- baidu-clue 数据库建表语句
+-- 用途: 百度商机联盟线索提交 / 预请求 / 披露查询
+-- ============================================================
+
+CREATE DATABASE IF NOT EXISTS adx_baidu_clue DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
+USE adx_baidu_clue;
+
+-- ------------------------------------------------------------
+-- 1. 线索主表
+-- 说明:
+--   1) 一条线索一行,保存业务字段与最近一次提交结果
+--   2) prefetch / submit 使用同一个 clue_id
+-- ------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS baidu_clue_leads (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    clue_id VARCHAR(64) NOT NULL COMMENT '线索ID,建议以appsid开头',
+    appsid VARCHAR(64) NOT NULL COMMENT '百度商机联盟媒体标识',
+    tu_id VARCHAR(64) COMMENT '业务标签标识tuId',
+    post_time BIGINT NOT NULL COMMENT '线索提交时间,秒级时间戳',
+    query_words JSON NOT NULL COMMENT '关键词数组',
+    trade_level1 INT NOT NULL COMMENT '一级行业ID',
+    trade_level2 INT NOT NULL COMMENT '二级行业ID',
+    trade_type INT COMMENT '行业类型(1加盟/2汽车/3法律/4企服/5教育/6医美/7旅游/8房产)',
+    trade_info JSON COMMENT '行业扩展信息',
+    form_detail JSON COMMENT '表单扩展字段',
+    consult_str JSON COMMENT '对话内容',
+    media_info JSON COMMENT '媒体信息(media_type/media_url/media_website)',
+    pid INT NOT NULL COMMENT '省份ID',
+    cid INT NOT NULL COMMENT '城市ID',
+    info_linker VARCHAR(64) NOT NULL COMMENT '联系人',
+    info_phone VARCHAR(32) NOT NULL COMMENT '手机号',
+    ip VARCHAR(64) NOT NULL COMMENT '用户IP,支持IPv4/IPv6',
+    user_agent TEXT COMMENT '用户UA',
+    prefetch_biz_code INT COMMENT '预请求返回bizCode',
+    prefetch_code INT COMMENT '预请求HTTP业务码',
+    prefetch_msg VARCHAR(255) COMMENT '预请求返回消息',
+    submit_code INT COMMENT '提交接口业务码',
+    submit_msg VARCHAR(255) COMMENT '提交接口返回消息',
+    disclosure_status INT COMMENT '披露状态(0未下发/100匹配中/200已匹配/400未匹配)',
+    disclosure_time VARCHAR(32) COMMENT '披露返回中的线索时间',
+    last_submit_at TIMESTAMP(3) NULL COMMENT '最近一次正式提交时间',
+    last_prefetch_at TIMESTAMP(3) NULL COMMENT '最近一次预请求时间',
+    last_disclosure_at TIMESTAMP(3) NULL COMMENT '最近一次披露查询时间',
+    created_at TIMESTAMP(3) NOT NULL COMMENT '创建时间',
+    updated_at TIMESTAMP(3) NOT NULL COMMENT '更新时间',
+    UNIQUE KEY uk_baidu_clue_leads_clue_id (clue_id),
+    KEY idx_baidu_clue_leads_appsid_post_time (appsid, post_time),
+    KEY idx_baidu_clue_leads_phone_post_time (info_phone, post_time),
+    KEY idx_baidu_clue_leads_status_updated_at (submit_code, disclosure_status, updated_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='百度商机联盟线索主表';
+
+-- ------------------------------------------------------------
+-- 2. 接口调用日志表
+-- 说明:
+--   1) submit / prefetch / clueinfo 每次请求都记录一条
+--   2) 保存加密后data,避免落明文敏感数据
+-- ------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS baidu_clue_api_logs (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    api_type VARCHAR(32) NOT NULL COMMENT '接口类型(submit/prefetch/clueinfo)',
+    clue_id VARCHAR(64) COMMENT '线索ID;clueinfo批量窗口查询时可为空',
+    appsid VARCHAR(64) NOT NULL COMMENT '百度商机联盟媒体标识',
+    request_url VARCHAR(255) NOT NULL COMMENT '请求地址',
+    request_timestamp VARCHAR(32) NOT NULL COMMENT '签名时间戳',
+    access_type INT NOT NULL DEFAULT 4 COMMENT '固定值4',
+    encrypted_data MEDIUMTEXT COMMENT '加密后的data',
+    signature VARCHAR(64) COMMENT '签名',
+    request_body MEDIUMTEXT COMMENT '请求体JSON',
+    http_status INT COMMENT 'HTTP状态码',
+    response_body MEDIUMTEXT COMMENT '响应体',
+    response_code INT COMMENT '业务响应码',
+    response_msg VARCHAR(255) COMMENT '业务响应消息',
+    biz_code INT COMMENT '预请求预算码',
+    success TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否成功',
+    error_message TEXT COMMENT '异常信息',
+    created_at TIMESTAMP(3) NOT NULL COMMENT '创建时间',
+    KEY idx_baidu_clue_api_logs_api_type_created_at (api_type, created_at),
+    KEY idx_baidu_clue_api_logs_clue_id_api_type (clue_id, api_type),
+    KEY idx_baidu_clue_api_logs_response_code_created_at (response_code, created_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='百度商机联盟接口调用日志';
+
+-- ------------------------------------------------------------
+-- 3. 披露结果明细表
+-- 说明:
+--   1) clueinfo 查询返回通常是文本结果,建议拆成明细后落库
+--   2) 一次窗口查询可命中多条线索
+-- ------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS baidu_clue_disclosure_details (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    query_begin_time BIGINT NOT NULL COMMENT '查询开始时间,文档格式如202411270000',
+    query_end_time BIGINT NOT NULL COMMENT '查询结束时间,文档格式如202411271200',
+    clue_time VARCHAR(32) COMMENT '百度返回的线索时间',
+    clue_id VARCHAR(64) NOT NULL COMMENT '线索ID',
+    status INT NOT NULL COMMENT '状态(0未下发/100匹配中/200已匹配/400未匹配)',
+    raw_message TEXT COMMENT '原始响应片段',
+    created_at TIMESTAMP(3) NOT NULL COMMENT '创建时间',
+    KEY idx_baidu_clue_disclosure_details_clue_id_created_at (clue_id, created_at),
+    KEY idx_baidu_clue_disclosure_details_status_created_at (status, created_at),
+    KEY idx_baidu_clue_disclosure_details_query_window (query_begin_time, query_end_time)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='百度商机联盟披露结果明细';

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

@@ -17,6 +17,7 @@ CREATE TABLE IF NOT EXISTS tencent_ad_bid_events (
     platform VARCHAR(16) COMMENT '平台(android/ios)',
     ad_id VARCHAR(128) COMMENT '腾讯广告ID',
     account_id VARCHAR(64) COMMENT '腾讯广告主ID',
+    event_type VARCHAR(16) COMMENT '事件类型(impression/click)',
     req_id VARCHAR(512) COMMENT '百度ADX请求ID',
     bid_id VARCHAR(128) COMMENT '竞价ID',
     creative_id VARCHAR(128) COMMENT '创意ID',
@@ -58,18 +59,21 @@ CREATE TABLE IF NOT EXISTS tencent_baidu_conversions (
     dedupe_key VARCHAR(255) COMMENT '去重键',
     qk VARCHAR(128) COMMENT '关联的业务键',
     media VARCHAR(64) COMMENT '媒体来源',
+    tag_id VARCHAR(128) COMMENT '百度广告位ID',
     date_value VARCHAR(16) COMMENT '转化日期',
     appsid VARCHAR(128) COMMENT '百度应用ID',
     customer_name VARCHAR(128) COMMENT '客户名称',
     device_id VARCHAR(255) COMMENT '设备标识',
     conv DOUBLE COMMENT '转化数',
     payment DOUBLE COMMENT '付费金额',
+    gmv DOUBLE COMMENT '扩展信息中的GMV',
     act INT COMMENT '行为类型(1=激活,2=付费,3=注册...)',
     tu VARCHAR(128) COMMENT '追踪URL参数',
     clk_time VARCHAR(32) COMMENT '点击时间',
     created_at TIMESTAMP(3) NOT NULL COMMENT '创建时间',
     UNIQUE KEY uk_tencent_baidu_conversions_dedupe (dedupe_key),
     KEY idx_tencent_baidu_conversions_qk_act_created_at (qk, act, created_at),
+    KEY idx_tencent_baidu_conversions_tag_id (tag_id),
     KEY idx_tencent_baidu_conversions_device_id (device_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='百度转化数据';
 
@@ -97,6 +101,16 @@ CREATE TABLE IF NOT EXISTS tencent_media_callbacks (
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='媒体转化回传记录';
 
 -- ------------------------------------------------------------
+-- 5. 广告位回传方式配置表
+-- ------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS tencent_tag_event (
+    tag_id VARCHAR(32) NOT NULL COMMENT '广告位ID',
+    baidu_act INT NOT NULL COMMENT '百度转化行为',
+    tencent_action_type VARCHAR(128) NOT NULL COMMENT '腾讯回传行为',
+    PRIMARY KEY (tag_id, baidu_act)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='广告位回传方式配置';
+
+-- ------------------------------------------------------------
 -- 字段补丁(兼容旧表升级)
 -- ------------------------------------------------------------
 -- 如果 req_id 原来是 VARCHAR(128),扩展为 512
@@ -109,3 +123,18 @@ CREATE TABLE IF NOT EXISTS tencent_media_callbacks (
 -- 如果旧表没有 ad_id / account_id 字段
 -- ALTER TABLE tencent_ad_bid_events ADD COLUMN ad_id VARCHAR(128) COMMENT '腾讯广告ID' AFTER platform;
 -- ALTER TABLE tencent_ad_bid_events ADD COLUMN account_id VARCHAR(64) COMMENT '腾讯广告主ID' AFTER ad_id;
+
+-- 如果旧表没有 tag_id 字段
+-- ALTER TABLE tencent_baidu_conversions ADD COLUMN tag_id VARCHAR(128) AFTER media;
+-- ALTER TABLE tencent_baidu_conversions ADD KEY idx_tencent_baidu_conversions_tag_id (tag_id);
+
+-- 如果旧表没有 event_type 字段
+-- ALTER TABLE tencent_ad_bid_events ADD COLUMN event_type VARCHAR(16) COMMENT '事件类型(impression/click)' AFTER account_id;
+
+-- tencent_tag_event 升级为 (tag_id, baidu_act) 维度
+-- ALTER TABLE tencent_tag_event ADD COLUMN baidu_act INT NULL COMMENT '百度转化行为' AFTER tag_id;
+-- ALTER TABLE tencent_tag_event CHANGE COLUMN event_type tencent_action_type VARCHAR(128) NOT NULL COMMENT '腾讯回传行为';
+-- UPDATE tencent_tag_event SET baidu_act = 0 WHERE baidu_act IS NULL;
+-- ALTER TABLE tencent_tag_event MODIFY COLUMN baidu_act INT NOT NULL;
+-- ALTER TABLE tencent_tag_event DROP PRIMARY KEY;
+-- ALTER TABLE tencent_tag_event ADD PRIMARY KEY (tag_id, baidu_act);