Kafka 死信队列(DLQ)重投

0.注意事项

  1. DLQ消息重投的时候(必须确保headers的重投次数是最新的)以后,等待重投完成,前端必须重新刷新界面(防止重试次数被覆盖)(后端的校验怎么做?)
  2. 后端没办法做校验,不知道前端勾选的哪些数据,如果勾选的数据的时间差异很大,如果数据集比较大的时候,后端再查一次,会很慢;
  3. 或者可以重投的时候,将key和重投次数存放到redis,如果出现重复数据,不允许投递;设置过期时间24h,24h后界面一般都会要求重新登录

1. 功能概述

死信队列模块提供两个核心 HTTP API:

端点 方法 功能描述
POST /api/dlq/preview 分页预览 根据时间范围从 Kafka 拉取死信队列消息,支持多字段模糊筛选,只查看不消费
POST /api/dlq/resend 批量重投 将勾选的死信消息重新发送到原业务 topic,自动处理重试计数

此外还有一个模拟推送接口 POST /api/dlq/push,用于生成测试数据(将数据库表数据封装为消息推入 DLQ),迁移时可选择保留或移除。


2. 设计思路

2.1 整体架构

┌──────────┐     ┌──────────────┐     ┌─────────────────┐
│ 前端页面  │────→│ DlqController │────→│  DlqServiceImpl  │
└──────────┘     └──────────────┘     └────────┬────────┘
                                               │
                          ┌─────────────────────┼─────────────────────┐
                          │                     │                     │
                   ┌──────▼──────┐   ┌─────────▼────────┐   ┌───────▼────────┐
                   │KafkaConfig   │   │KafkaProducerCache│   │  Kafka Cluster  │
                   │ServiceImpl   │   │ (ConcurrentMap)  │   │ (DLQ Topic +    │
                   │(读DB配置)    │   │ (复用Producer)   │   │  Business Topic)│
                   └──────┬──────┘   └──────────────────┘   └────────────────┘
                          │
                   ┌──────▼──────┐
                   │ParamConfig  │
                   │Mapper (DB)  │
                   └─────────────┘

2.2 核心设计决策

预览接口(preview)

  1. 不消费消息:使用随机 group.iddlq-preview-{UUID}),关闭自动提交(enable.auto.commit=false),确保每次预览都从 earliest 开始拉取,不影响消费者组的 offset 进度。
  2. 按时间范围定位:通过 consumer.offsetsForTimes() 按起始时间 seek 到各分区对应 offset,poll 到结束时间即停止。
  3. 内存过滤与分页:将时间范围内全部消息拉到内存(上限 1000 条),在内存中进行模糊匹配筛选、按 messageKey 去重(保留最新一条),最后做内存分页。
  4. 安全上限:单次最多拉取 1000 条(MAX_TOTAL_RECORDS = 1000),防止无限拉取导致 OOM。每次 poll 最多 500 条,poll 超时 5 秒。

重投接口(resend)

  1. 原 topic 推导:从消息 value JSON 中提取 table 字段,拼接为 <KAFKA_TOPIC>.<table> 格式。
  2. 同步投递:使用 producer.send().get() 同步等待结果,任意一条失败立即抛 BizException 终止。
  3. 重试计数:检查原始 headers 中 __connector.errors.retry.count,存在则 +1,不存在则设为 1。
  4. Producer 复用:通过 KafkaProducerCache 按 tenantId 缓存 Producer 实例(ConcurrentHashMap),避免每次创建。

配置读取

  • Kafka 全部配置(bootstrap servers、topic 名称)不在 yml/properties 文件中写死,而是从数据库 param_config 表动态读取,支持多租户(按 tenantId + paramKey 唯一标识一条配置)。
  • 三个配置键:BOOTSTRAP(Kafka 地址)、DLQ_TOPIC(死信主题名)、KAFKA_TOPIC(原业务主题前缀)。

2.3 数据流

preview 请求
    │
    ▼
获取 DLQ topic ──→ 创建临时 Consumer ──→ offsetsForTimes 定位
    │
    ▼
poll 消息(≤ 1000条)──→ 内存过滤去重 ──→ 内存分页 ──→ PageInfo<DlqMessageVO>

resend 请求
    │
    ▼
遍历消息列表 ──→ 解析 value.table ──→ 获取原 topic
    │
    ▼
构建 retry headers ──→ 同步投递 ──→ 全部成功/任一失败抛异常

3. 涉及文件清单

迁移时需要将以下文件完整复制到目标系统(调整包路径):

必须迁移的核心文件(12 个)

序号 文件 说明
1 controller/DlqController.java REST 控制器(preview + resend + push)
2 model/dto/DlqQueryDTO.java 预览查询请求 DTO
3 model/dto/DlqResendDTO.java 重投请求 DTO(含内部类 MessageItem)
4 model/dto/DlqPushDTO.java 推送请求 DTO(模拟数据用,可选)
5 model/vo/DlqMessageVO.java 消息视图对象
6 service/DlqService.java 业务接口
7 service/impl/DlqServiceImpl.java 业务实现(核心,约 620 行)
8 service/KafkaConfigService.java Kafka 配置读取接口
9 service/impl/KafkaConfigServiceImpl.java Kafka 配置读取实现
10 cache/KafkaProducerCache.java Producer 缓存组件
11 mapper/ParamConfigMapper.java 配置表 Mapper 接口
12 resources/mapper/ParamConfigMapper.xml MyBatis XML 映射

目标系统已有时可跳过

序号 文件 说明
13 exception/BizException.java 业务异常类
14 common/Result.java 统一响应体
15 model/constants/ResultCode.java 响应状态码常量
16 model/pojo/ParamConfigDO.java 参数配置表实体

可选参考文件

序号 文件 说明
17 service/impl/DlqConsumerRunner.java DLQ 消费后台监听器(模拟消费失败→重投 DLQ 的场景)

4. Maven 依赖

以下依赖是 DLQ 功能必需的,确认目标系统 pom.xml 中已包含:

<!-- Spring Boot 父工程(版本 ≥ 3.0 以支持 Jakarta) -->
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.5.16</version>
</parent>

<!-- 核心依赖 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

<!-- Kafka 客户端 -->
<dependency>
    <groupId>org.apache.kafka</groupId>
    <artifactId>kafka-clients</artifactId>
</dependency>

<!-- MyBatis(如果目标系统不用 MyBatis 则需适配) -->
<dependency>
    <groupId>org.mybatis.spring.boot</groupId>
    <artifactId>mybatis-spring-boot-starter</artifactId>
    <version>3.0.5</version>
</dependency>

<!-- 分页插件 -->
<dependency>
    <groupId>com.github.pagehelper</groupId>
    <artifactId>pagehelper-spring-boot-starter</artifactId>
    <version>4.0.0</version>
</dependency>

<!-- 参数校验 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

<!-- Lombok -->
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <optional>true</optional>
</dependency>

<!-- Swagger 注解(接口文档用,可移除) -->
<dependency>
    <groupId>io.swagger.core.v3</groupId>
    <artifactId>swagger-annotations-jakarta</artifactId>
    <version>2.2.49</version>
</dependency>

注意:如果目标系统不使用 MyBatis,需要将 KafkaConfigServiceImpl 中的 ParamConfigMapper 替换为目标系统的配置读取方式(如 JPA Repository、直接 JDBC 等)。


5. 数据库表结构

param_config 表

DLQ 功能依赖此表存储 Kafka 动态配置。迁移前需在目标数据库中创建(如果还没有):

CREATE TABLE IF NOT EXISTS param_config (
    id          BIGSERIAL    PRIMARY KEY,
    tenant_id   VARCHAR(64)  NOT NULL,
    param_key   VARCHAR(128) NOT NULL,
    param_value VARCHAR(512) NOT NULL,
    creator_id  BIGINT,
    create_time TIMESTAMP    DEFAULT NOW(),
    updater_id  BIGINT,
    update_time TIMESTAMP    DEFAULT NOW(),
    deleted     SMALLINT     DEFAULT 0
);

必须插入的配置数据

-- 以下示例值请替换为实际环境的配置

-- Kafka 地址
INSERT INTO param_config (tenant_id, param_key, param_value)
VALUES ('<tenantId>', 'BOOTSTRAP', 'kafka-broker:9092');

-- 死信队列 topic 名称
INSERT INTO param_config (tenant_id, param_key, param_value)
VALUES ('<tenantId>', 'DLQ_TOPIC', 'dead-letter-queue');

-- 原业务 topic 前缀(重投时拼接 .<tableName>)
INSERT INTO param_config (tenant_id, param_key, param_value)
VALUES ('<tenantId>', 'KAFKA_TOPIC', 'manage_dev');

每个租户需要独立的三条配置。若系统无多租户概念,可固定 tenantId(如 "1""default")。


6. 完整源码

以下源码包含完整注释,可以直接复制到目标项目中。需将包路径 com.liang.study 替换为目标系统的包路径。

6.1 控制器 —— DlqController

package com.liang.study.controller;

import com.github.pagehelper.PageInfo;
import com.liang.study.common.Result;
import com.liang.study.model.dto.DlqPushDTO;
import com.liang.study.model.dto.DlqQueryDTO;
import com.liang.study.model.dto.DlqResendDTO;
import com.liang.study.model.vo.DlqMessageVO;
import com.liang.study.service.DlqService;
import io.swagger.v3.oas.annotations.Operation;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

/**
 * 死信队列控制器
 *
 * @author liang
 * @since 2026-06-28
 */
@RestController
@RequestMapping("/api/dlq")
@RequiredArgsConstructor
public class DlqController {

    private final DlqService dlqService;

    /**
     * 模拟推送消息到死信队列
     *
     * @param dto 推送请求
     * @return 成功响应
     */
    @PostMapping("/push")
    @Operation(summary = "模拟推送消息到死信队列")
    public Result<Void> push(@Valid @RequestBody DlqPushDTO dto) {
        dlqService.pushToDlq(dto);
        return Result.success();
    }

    /**
     * 分页预览死信队列消息(不消费,不提交 offset)
     *
     * @param dto 查询请求
     * @return 分页结果
     */
    @PostMapping("/preview")
    @Operation(summary = "分页预览死信队列消息")
    public Result<PageInfo<DlqMessageVO>> preview(@Valid @RequestBody DlqQueryDTO dto) {
        PageInfo<DlqMessageVO> pageInfo = dlqService.preview(dto);
        return Result.success(pageInfo);
    }

    /**
     * 批量重投选中的消息到原业务 topic
     *
     * @param dto 重投请求(包含消息列表)
     * @return 成功响应
     */
    @PostMapping("/resend")
    @Operation(summary = "批量重投选中的消息到原 topic")
    public Result<Void> resend(@Valid @RequestBody DlqResendDTO dto) {
        dlqService.resend(dto);
        return Result.success();
    }
}

6.2 数据传输对象(DTO)

DlqQueryDTO —— 预览查询请求

package com.liang.study.model.dto;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;

import java.time.LocalDateTime;

/**
 * 死信队列预览查询请求
 *
 * @author liang
 * @since 2026-06-28
 */
@Data
@Schema(description = "死信队列预览查询请求")
public class DlqQueryDTO {

    /** 租户ID */
    @NotBlank(message = "租户ID不能为空")
    @Schema(description = "租户ID", example = "tenant-001")
    private String tenantId;

    /** 查询开始时间 */
    @NotNull(message = "开始时间不能为空")
    @Schema(description = "开始时间", example = "2026-06-28T00:00:00")
    private LocalDateTime startTime;

    /** 查询结束时间 */
    @NotNull(message = "结束时间不能为空")
    @Schema(description = "结束时间", example = "2026-06-28T23:59:59")
    private LocalDateTime endTime;

    /** 连接器名称(模糊匹配,可选) */
    @Schema(description = "连接器名称(模糊匹配,可选)", example = "testConnector1")
    private String connectorName;

    /** 表名(模糊匹配,可选) */
    @Schema(description = "表名(模糊匹配,可选)", example = "user")
    private String tableName;

    /** IP 地址(模糊匹配,可选) */
    @Schema(description = "IP(模糊匹配,可选)", example = "127.0.0.1")
    private String ip;

    /** 错误消息(模糊匹配,可选) */
    @Schema(description = "错误消息(模糊匹配,可选)", example = "Failed")
    private String errorMsg;

    /** 页码,默认 1 */
    @Schema(description = "页码", example = "1")
    private Integer pageNum = 1;

    /** 每页条数,默认 20 */
    @Schema(description = "每页条数", example = "20")
    private Integer pageSize = 20;
}

DlqResendDTO —— 批量重投请求

package com.liang.study.model.dto;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import lombok.Data;

import java.util.List;
import java.util.Map;

/**
 * 批量重投消息到原 topic 请求
 *
 * @author liang
 * @since 2026-06-28
 */
@Data
@Schema(description = "批量重投消息到原 topic 请求")
public class DlqResendDTO {

    /** 租户ID */
    @NotBlank(message = "租户ID不能为空")
    @Schema(description = "租户ID", example = "tenant-001")
    private String tenantId;

    /** 待重投的消息列表(至少一条) */
    @NotEmpty(message = "消息列表不能为空")
    @Valid
    @Schema(description = "待重投的消息列表")
    private List<MessageItem> messages;

    /**
     * 单条待重投的消息
     */
    @Data
    @Schema(description = "单条待重投的消息")
    public static class MessageItem {

        /** 死信队列消息的 key(JSON 字符串) */
        @NotBlank(message = "消息Key不能为空")
        @Schema(description = "死信队列消息的 key(JSON 字符串)")
        private String key;

        /** 死信队列消息的 value(JSON 字符串) */
        @NotBlank(message = "消息Value不能为空")
        @Schema(description = "死信队列消息的 value(JSON 字符串)")
        private String value;

        /** 死信队列消息的 headers(key-value 对) */
        @Schema(description = "死信队列消息的 headers(key-value 对)")
        private Map<String, String> headers;
    }
}

DlqPushDTO —— 模拟推送请求(可选)

package com.liang.study.model.dto;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;

/**
 * 模拟推送消息到死信队列请求
 *
 * @author liang
 * @since 2026-06-28
 */
@Data
@Schema(description = "模拟推送消息到死信队列请求")
public class DlqPushDTO {

    /** 租户ID */
    @NotBlank(message = "租户ID不能为空")
    @Schema(description = "租户ID", example = "tenant-001")
    private String tenantId;

    /** 数据库表名(支持 schema.table 格式) */
    @NotBlank(message = "表名不能为空")
    @Schema(description = "数据库表名", example = "manage_dev.user")
    private String tableName;
}

6.3 视图对象(VO)

DlqMessageVO —— 消息视图

package com.liang.study.model.vo;

import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;

import java.time.LocalDateTime;
import java.util.Map;

/**
 * 死信队列消息视图
 *
 * @author liang
 * @since 2026-06-28
 */
@Data
@Schema(description = "死信队列消息视图")
public class DlqMessageVO {

    /** 消息时间戳 */
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss.SSS", timezone = "GMT+8")
    @Schema(description = "消息时间戳")
    private LocalDateTime timestamp;

    /** 连接器名称(从 headers.__connector.errors.connector.name 提取) */
    @Schema(description = "连接器名称(从 headers 提取)")
    private String connectorName;

    /** IP 地址(从 headers.__connector.errors.ip 提取) */
    @Schema(description = "IP(从 headers 提取)")
    private String ip;

    /** 表名(从 value.table 提取) */
    @Schema(description = "表名(从 value.table 提取)")
    private String tableName;

    /** 错误消息(从 headers.__connector.errors.exception.message 提取) */
    @Schema(description = "错误消息(从 headers 提取)")
    private String errorMsg;

    /** 重试次数(从 headers.__connector.errors.retry.count 提取) */
    @Schema(description = "重试次数(从 headers 提取)")
    private Integer retryCount;

    /** 消息 key(JSON 字符串) */
    @Schema(description = "消息 key(JSON 字符串)")
    private String messageKey;

    /** 消息 value(JSON 字符串) */
    @Schema(description = "消息 value(JSON 字符串)")
    private String messageValue;

    /** 消息 headers 键值对 */
    @Schema(description = "消息 headers")
    private Map<String, String> headers;
}

6.4 服务接口

DlqService

package com.liang.study.service;

import com.github.pagehelper.PageInfo;
import com.liang.study.model.dto.DlqPushDTO;
import com.liang.study.model.dto.DlqQueryDTO;
import com.liang.study.model.dto.DlqResendDTO;
import com.liang.study.model.vo.DlqMessageVO;

/**
 * 死信队列业务接口
 *
 * @author liang
 * @since 2026-06-28
 */
public interface DlqService {

    /**
     * 根据表名查询数据库表数据并封装为消息推送到死信队列
     * <p>
     * 业务步骤:获取主键列 → 查询全表数据 → 逐行构造消息 → 推送到 DLQ topic。
     *
     * @param dto 推送请求(包含 tenantId 和 tableName)
     * @throws com.liang.study.exception.BizException 表无主键、表名格式非法、配置缺失时
     */
    void pushToDlq(DlqPushDTO dto);

    /**
     * 按时间范围从 Kafka 拉取死信队列消息并分页返回(不消费,不提交 offset)
     * <p>
     * 后端一次性拉取时间范围内全部消息到内存中过滤和分页。
     *
     * @param dto 查询请求(包含时间范围、可选筛选条件、分页参数)
     * @return 分页后的死信消息列表
     */
    PageInfo<DlqMessageVO> preview(DlqQueryDTO dto);

    /**
     * 将选中的一条或多条死信消息重投到原业务 topic
     * <p>
     * 每条消息的原 topic 名称 = KAFKA_TOPIC 配置值 + "." + value.table。
     * 重投时 headers 中的 __connector.errors.retry.count 新设或自增。
     * 任意一条消息投递失败即终止并抛异常。
     *
     * @param dto 重投请求(包含 tenantId 和消息列表)
     * @throws com.liang.study.exception.BizException 任一消息投递失败时
     */
    void resend(DlqResendDTO dto);
}

6.5 服务实现(核心)

DlqServiceImpl

package com.liang.study.service.impl;

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.github.pagehelper.PageInfo;
import com.liang.study.cache.KafkaProducerCache;
import com.liang.study.exception.BizException;
import com.liang.study.model.dto.DlqPushDTO;
import com.liang.study.model.dto.DlqQueryDTO;
import com.liang.study.model.dto.DlqResendDTO;
import com.liang.study.model.vo.DlqMessageVO;
import com.liang.study.service.DlqService;
import com.liang.study.service.KafkaConfigService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.apache.kafka.clients.consumer.ConsumerRecords;
import org.apache.kafka.clients.consumer.KafkaConsumer;
import org.apache.kafka.clients.consumer.OffsetAndTimestamp;
import org.apache.kafka.clients.producer.KafkaProducer;
import org.apache.kafka.clients.producer.ProducerRecord;
import org.apache.kafka.common.TopicPartition;
import org.apache.kafka.common.header.Header;
import org.apache.kafka.common.header.internals.RecordHeader;
import org.apache.kafka.common.serialization.StringDeserializer;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;

import javax.sql.DataSource;
import java.nio.charset.StandardCharsets;
import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.ResultSet;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.*;
import java.util.concurrent.ThreadLocalRandom;
import java.util.stream.Collectors;

/**
 * 死信队列业务实现
 *
 * @author liang
 * @since 2026-06-28
 */
@Slf4j
@Service
@RequiredArgsConstructor
public class DlqServiceImpl implements DlqService {

    // ==================== 常量 ====================

    /**
     * headers 键:connector 名称
     */
    static final String HEADER_CONNECTOR_NAME = "__connector.errors.connector.name";
    /**
     * headers 键:异常消息
     */
    static final String HEADER_EXCEPTION_MESSAGE = "__connector.errors.exception.message";
    /**
     * headers 键:IP 地址
     */
    static final String HEADER_IP = "__connector.errors.ip";
    /**
     * headers 键:重试次数
     */
    static final String HEADER_RETRY_COUNT = "__connector.errors.retry.count";

    /**
     * 每次 poll 最大拉取消息条数
     */
    private static final int MAX_POLL_RECORDS = 500;
    /**
     * poll 超时时间(毫秒)
     */
    private static final long POLL_TIMEOUT_MS = 5000;
    /**
     * 预览接口单次最多拉取消息总条数,超过该值自动停止拉取
     */
    private static final int MAX_TOTAL_RECORDS = 1000;
    /**
     * 模拟的 connector 名称候选值
     */
    private static final String[] CONNECTOR_NAMES = {"testConnector1", "testConnector2"};
    /**
     * 模拟的错误消息候选值
     */
    private static final String[] ERROR_MESSAGES = {
            "Failed to flush batch to Kafka",
            "Failed to deliver message to broker",
            "Failed to produce record: topic not found",
            "Failed to commit offset: coordinator not available",
            "Failed to deserialize record value",
            "Failed to connect to Kafka broker"
    };
    /**
     * 模拟的 IP 候选值
     */
    private static final String[] IPS = {"127.0.0.1", "192.168.2.3"};

    private final KafkaConfigService kafkaConfigService;
    private final KafkaProducerCache producerCache;
    private final DataSource dataSource;
    private final ObjectMapper objectMapper;

    // ==================== 公开方法 ====================

    @Override
    public void pushToDlq(DlqPushDTO dto) {
        String tenantId = dto.getTenantId();
        String tableName = dto.getTableName();

        List<String> pkColumns = getPrimaryKeyColumns(tableName);
        if (pkColumns.isEmpty()) {
            throw new BizException("表 " + tableName + " 没有主键索引");
        }

        List<Map<String, Object>> rows = queryTableData(tableName);
        String dlqTopic = kafkaConfigService.getDlqTopic(tenantId);
        KafkaProducer<String, String> producer = producerCache.getOrCreate(tenantId);

        for (Map<String, Object> row : rows) {
            String key = buildMessageKey(row, pkColumns);
            String value = buildMessageValue(row, tableName);
            List<Header> headers = buildRandomHeaders();
            ProducerRecord<String, String> record = new ProducerRecord<>(
                    dlqTopic, null, System.currentTimeMillis(), key, value, headers);
            producer.send(record, (metadata, exception) -> {
                if (exception != null) {
                    log.error("推送 DLQ 失败: tenantId={}, key={}", tenantId, key, exception);
                }
            });
        }
        producer.flush();
        log.info("模拟推送 DLQ 完成: tenantId={}, table={}, 条数={}",
                tenantId, tableName, rows.size());
    }

    @Override
    public PageInfo<DlqMessageVO> preview(DlqQueryDTO dto) {
        String tenantId = dto.getTenantId();
        String dlqTopic = kafkaConfigService.getDlqTopic(tenantId);
        Properties consumerProps = buildConsumerProps(tenantId);
        List<DlqMessageVO> allMessages = new ArrayList<>();
        long startEpoch = toEpochMilli(dto.getStartTime());
        long endEpoch = toEpochMilli(dto.getEndTime());

        try (KafkaConsumer<String, String> consumer = new KafkaConsumer<>(consumerProps)) {
            List<TopicPartition> partitions = fetchPartitions(consumer, dlqTopic);
            if (partitions.isEmpty()) {
                return buildEmptyPage(dto.getPageNum(), dto.getPageSize());
            }
            consumer.assign(partitions);
            seekToStartTime(consumer, partitions, startEpoch);
            pollAndCollect(consumer, dto, endEpoch, allMessages);
        }
        List<DlqMessageVO> deduped = dedupByMessageKey(allMessages);
        return paginateInMemory(deduped, dto.getPageNum(), dto.getPageSize());
    }

    @Override
    public void resend(DlqResendDTO dto) {
        String tenantId = dto.getTenantId();
        KafkaProducer<String, String> producer = producerCache.getOrCreate(tenantId);
        int successCount = 0;
        for (DlqResendDTO.MessageItem item : dto.getMessages()) {
            String tableName = parseTableFromValue(item.getValue());
            String originalTopic = kafkaConfigService.getOriginalTopic(tenantId, tableName);
            sendSingle(producer, originalTopic, item);
            successCount++;
        }
        log.info("批量重投完成: tenantId={}, 成功={}", tenantId, successCount);
    }

    /**
     * 发送单条消息到指定 topic,失败时抛 BizException
     *
     * @param producer      Kafka Producer 实例
     * @param originalTopic 目标 topic
     * @param item          待重投的消息
     * @throws BizException 投递失败时
     */
    private void sendSingle(KafkaProducer<String, String> producer,
                            String originalTopic, DlqResendDTO.MessageItem item) {
        Map<String, String> headers = buildRetryHeaders(item.getHeaders());
        List<Header> kafkaHeaders = toKafkaHeaders(headers);
        try {
            ProducerRecord<String, String> record = new ProducerRecord<>(
                    originalTopic, null, System.currentTimeMillis(),
                    item.getKey(), item.getValue(), kafkaHeaders);
            producer.send(record).get();
        } catch (Exception e) {
            log.error("重投失败: topic={}, key={}", originalTopic, item.getKey(), e);
            throw new BizException("重投消息失败: " + e.getMessage());
        }
    }

    // ==================== 私有方法:推送相关 ====================

    /**
     * 通过 JDBC DatabaseMetaData 获取表的主键列名
     *
     * @param tableName 表名(支持 schema.table 格式)
     * @return 主键列名列表
     */
    private List<String> getPrimaryKeyColumns(String tableName) {
        List<String> pkColumns = new ArrayList<>();
        String schema = null;
        String tbl = tableName;
        if (tableName.contains(".")) {
            String[] parts = tableName.split("\\.");
            schema = parts[0];
            tbl = parts[1];
        }
        try (Connection conn = dataSource.getConnection()) {
            DatabaseMetaData metaData = conn.getMetaData();
            try (ResultSet rs = metaData.getPrimaryKeys(null, schema, tbl)) {
                while (rs.next()) {
                    pkColumns.add(rs.getString("COLUMN_NAME"));
                }
            }
        } catch (Exception e) {
            log.error("获取主键失败: tableName={}", tableName, e);
            throw new BizException("获取表主键信息失败: " + e.getMessage());
        }
        return pkColumns;
    }

    /**
     * 查询表全部数据(做表名格式校验防 SQL 注入)
     *
     * @param tableName 表名
     * @return 行数据列表,每行为列名→值的 Map
     */
    private List<Map<String, Object>> queryTableData(String tableName) {
        if (!tableName.matches("^[a-zA-Z0-9_.]+$")) {
            throw new BizException("表名格式不合法: " + tableName);
        }
        JdbcTemplate jdbcTemplate = new JdbcTemplate(dataSource);
        return jdbcTemplate.queryForList("SELECT * FROM " + tableName);
    }

    /**
     * 构建消息 key,格式:{"主键列1":"值1", "主键列2":"值2"}
     *
     * @param row       数据行(列名 → 值)
     * @param pkColumns 主键列名列表
     * @return 消息 key 的 JSON 字符串
     * @throws BizException JSON 序列化失败时
     */
    private String buildMessageKey(Map<String, Object> row, List<String> pkColumns) {
        Map<String, Object> keyMap = new LinkedHashMap<>();
        for (String pkCol : pkColumns) {
            keyMap.put(pkCol, row.get(pkCol));
        }
        try {
            return objectMapper.writeValueAsString(keyMap);
        } catch (Exception e) {
            throw new BizException("构建消息 key 失败: " + e.getMessage());
        }
    }

    /**
     * 构建消息 value,格式:{"data":{...}, "opType":"U", "table":"短表名"}
     *
     * @param row       数据行(列名 → 值)
     * @param tableName 完整表名(可能包含 schema 前缀)
     * @return 消息 value 的 JSON 字符串
     * @throws BizException JSON 序列化失败时
     */
    private String buildMessageValue(Map<String, Object> row, String tableName) {
        String shortName = tableName.contains(".")
                ? tableName.substring(tableName.lastIndexOf('.') + 1) : tableName;
        Map<String, Object> valueMap = new LinkedHashMap<>();
        valueMap.put("data", row);
        valueMap.put("opType", "U");
        valueMap.put("table", shortName);
        try {
            return objectMapper.writeValueAsString(valueMap);
        } catch (Exception e) {
            throw new BizException("构建消息 value 失败: " + e.getMessage());
        }
    }

    /**
     * 构建模拟的随机 headers(从预定义候选值中随机选取 connectorName、errorMessage、IP)
     *
     * @return Kafka Header 列表
     */
    private List<Header> buildRandomHeaders() {
        ThreadLocalRandom random = ThreadLocalRandom.current();
        return Arrays.asList(
                new RecordHeader(HEADER_CONNECTOR_NAME,
                        CONNECTOR_NAMES[random.nextInt(CONNECTOR_NAMES.length)]
                                .getBytes(StandardCharsets.UTF_8)),
                new RecordHeader(HEADER_EXCEPTION_MESSAGE,
                        ERROR_MESSAGES[random.nextInt(ERROR_MESSAGES.length)]
                                .getBytes(StandardCharsets.UTF_8)),
                new RecordHeader(HEADER_IP,
                        IPS[random.nextInt(IPS.length)].getBytes(StandardCharsets.UTF_8))
        );
    }

    // ==================== 私有方法:预览相关 ====================

    /**
     * 构建 Kafka Consumer 配置(不自动提交 offset,每次使用随机 group 确保只拉取不消费)
     *
     * @param tenantId 租户ID
     * @return Kafka Consumer 配置属性
     */
    private Properties buildConsumerProps(String tenantId) {
        String bootstrapServers = kafkaConfigService.getKafkaProperties(tenantId)
                .getProperty(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG);
        Properties props = new Properties();
        props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
        props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG,
                StringDeserializer.class.getName());
        props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG,
                StringDeserializer.class.getName());
        // 每次预览使用随机 group,不提交 offset,确保只拉取不消费
        props.put(ConsumerConfig.GROUP_ID_CONFIG, "dlq-preview-" + UUID.randomUUID());
        props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "false");
        props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
        props.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, MAX_POLL_RECORDS);
        return props;
    }

    /**
     * 获取 topic 的所有分区
     *
     * @param consumer Kafka Consumer 实例
     * @param topic    topic 名称
     * @return 分区列表
     */
    private List<TopicPartition> fetchPartitions(KafkaConsumer<String, String> consumer,
                                                 String topic) {
        return consumer.partitionsFor(topic).stream()
                .map(p -> new TopicPartition(p.topic(), p.partition()))
                .collect(Collectors.toList());
    }

    /**
     * 按时间戳 seek 到各分区的起始 offset(使用 {@link KafkaConsumer#offsetsForTimes})
     * <p>
     * 若时间戳超出分区所有消息范围(例如未来时间),则 seek 到分区末尾,确保不返回任何消息。
     *
     * @param consumer   Kafka Consumer 实例
     * @param partitions 目标分区列表
     * @param startEpoch 起始时间的 epoch 毫秒值
     */
    private void seekToStartTime(KafkaConsumer<String, String> consumer,
                                 List<TopicPartition> partitions, long startEpoch) {
        Map<TopicPartition, Long> timestamps = new HashMap<>();
        for (TopicPartition tp : partitions) {
            timestamps.put(tp, startEpoch);
        }
        Map<TopicPartition, OffsetAndTimestamp> offsets = consumer.offsetsForTimes(timestamps);
        List<TopicPartition> seekToEndPartitions = new ArrayList<>();
        for (Map.Entry<TopicPartition, OffsetAndTimestamp> entry : offsets.entrySet()) {
            if (entry.getValue() != null) {
                consumer.seek(entry.getKey(), entry.getValue().offset());
            } else {
                // 时间戳超出消息范围,标记为需要 seek 到末尾
                seekToEndPartitions.add(entry.getKey());
            }
        }
        if (!seekToEndPartitions.isEmpty()) {
            consumer.seekToEnd(seekToEndPartitions);
        }
    }

    /**
     * 循环 poll 消息,直到超过结束时间、无新消息或达到 {@link #MAX_TOTAL_RECORDS} 上限
     *
     * @param consumer  Kafka Consumer 实例
     * @param dto       查询请求(用于筛选条件)
     * @param endEpoch  结束时间的 epoch 毫秒值
     * @param collector 收集器(结果写入此列表)
     */
    private void pollAndCollect(KafkaConsumer<String, String> consumer,
                                DlqQueryDTO dto, long endEpoch,
                                List<DlqMessageVO> collector) {
        boolean hasMore = true;
        while (hasMore && collector.size() < MAX_TOTAL_RECORDS) {
            ConsumerRecords<String, String> records =
                    consumer.poll(Duration.ofMillis(POLL_TIMEOUT_MS));
            if (records.isEmpty()) {
                break;
            }
            for (ConsumerRecord<String, String> record : records) {
                if (record.timestamp() > endEpoch) {
                    hasMore = false;
                    break;
                }
                DlqMessageVO vo = parseRecord(record);
                if (matchesFilter(vo, dto)) {
                    collector.add(vo);
                    if (collector.size() >= MAX_TOTAL_RECORDS) {
                        break;
                    }
                }
            }
        }
        if (collector.size() >= MAX_TOTAL_RECORDS) {
            log.info("预览消息已达拉取上限: max={}, 超出部分未拉取", MAX_TOTAL_RECORDS);
        }
    }

    /**
     * 将一条 Kafka 消息解析为 DlqMessageVO
     * <p>
     * 提取 headers 中的 connectorName、ip、errorMsg,以及 value 中的 table。
     *
     * @param record Kafka 消息记录
     * @return 死信消息视图对象
     */
    private DlqMessageVO parseRecord(ConsumerRecord<String, String> record) {
        DlqMessageVO vo = new DlqMessageVO();
        vo.setTimestamp(LocalDateTime.ofInstant(
                Instant.ofEpochMilli(record.timestamp()), ZoneId.systemDefault()));
        vo.setMessageKey(record.key());
        vo.setMessageValue(record.value());

        Map<String, String> headersMap = new LinkedHashMap<>();
        for (Header header : record.headers()) {
            String headerKey = header.key();
            String headerValue = new String(header.value(), StandardCharsets.UTF_8);
            headersMap.put(headerKey, headerValue);

            if (HEADER_CONNECTOR_NAME.equals(headerKey)) {
                vo.setConnectorName(headerValue);
            } else if (HEADER_IP.equals(headerKey)) {
                vo.setIp(headerValue);
            } else if (HEADER_EXCEPTION_MESSAGE.equals(headerKey)) {
                vo.setErrorMsg(headerValue);
            } else if (HEADER_RETRY_COUNT.equals(headerKey)) {
                try {
                    vo.setRetryCount(Integer.parseInt(headerValue));
                } catch (NumberFormatException e) {
                    log.warn("解析 retry.count 失败: value={}", headerValue);
                }
            }
        }
        vo.setHeaders(headersMap);

        // 从 value JSON 中提取 table 字段
        try {
            Map<String, Object> valueMap = objectMapper.readValue(record.value(),
                    new TypeReference<>() {
                    });
            vo.setTableName((String) valueMap.get("table"));
        } catch (Exception e) {
            log.warn("解析 value.table 失败: key={}", record.key(), e);
        }
        return vo;
    }

    /**
     * 判断消息是否匹配所有筛选条件(模糊匹配,空条件不过滤)
     *
     * @param vo  消息视图
     * @param dto 查询请求(包含各项筛选条件)
     * @return true=匹配通过, false=被过滤
     */
    private boolean matchesFilter(DlqMessageVO vo, DlqQueryDTO dto) {
        return matches(vo.getConnectorName(), dto.getConnectorName())
                && matches(vo.getTableName(), dto.getTableName())
                && matches(vo.getIp(), dto.getIp())
                && matches(vo.getErrorMsg(), dto.getErrorMsg());
    }

    /**
     * 单字段模糊匹配:筛选值为 null 或空则通过,否则检查字段值包含筛选值
     *
     * @param fieldValue  消息中的实际值
     * @param filterValue 用户输入的筛选值
     * @return true=匹配通过, false=不匹配
     */
    private boolean matches(String fieldValue, String filterValue) {
        if (filterValue == null || filterValue.isEmpty()) {
            return true;
        }
        return fieldValue != null && fieldValue.contains(filterValue);
    }

    /**
     * 按 messageKey 去重,相同 key 仅保留时间戳最新的一条
     *
     * @param messages 原始消息列表
     * @return 去重后的消息列表
     */
    private List<DlqMessageVO> dedupByMessageKey(List<DlqMessageVO> messages) {
        Map<String, DlqMessageVO> latestByKey = new LinkedHashMap<>();
        for (DlqMessageVO vo : messages) {
            String key = vo.getMessageKey();
            if (key == null) {
                continue;
            }
            DlqMessageVO existing = latestByKey.get(key);
            if (existing == null || vo.getTimestamp().isAfter(existing.getTimestamp())) {
                latestByKey.put(key, vo);
            }
        }
        return new ArrayList<>(latestByKey.values());
    }

    /**
     * 内存分页:从全量数据中截取对应页的数据
     *
     * @param allMessages 全量消息列表
     * @param pageNum     页码(从 1 开始)
     * @param pageSize    每页条数
     * @return 分页结果
     */
    private PageInfo<DlqMessageVO> paginateInMemory(List<DlqMessageVO> allMessages,
                                                    int pageNum, int pageSize) {
        int total = allMessages.size();
        int from = (pageNum - 1) * pageSize;
        if (from >= total) {
            return buildEmptyPage(pageNum, pageSize);
        }
        int to = Math.min(from + pageSize, total);
        PageInfo<DlqMessageVO> pageInfo = new PageInfo<>(allMessages.subList(from, to));
        pageInfo.setTotal(total);
        pageInfo.setPageNum(pageNum);
        pageInfo.setPageSize(pageSize);
        pageInfo.setPages((total + pageSize - 1) / pageSize);
        return pageInfo;
    }

    /**
     * 构建空分页结果(total=0,list 为空)
     *
     * @param pageNum  页码
     * @param pageSize 每页条数
     * @return 空分页对象
     */
    private PageInfo<DlqMessageVO> buildEmptyPage(int pageNum, int pageSize) {
        PageInfo<DlqMessageVO> pageInfo = new PageInfo<>(Collections.emptyList());
        pageInfo.setTotal(0);
        pageInfo.setPageNum(pageNum);
        pageInfo.setPageSize(pageSize);
        pageInfo.setPages(0);
        return pageInfo;
    }

    // ==================== 私有方法:重投相关 ====================

    /**
     * 从消息 value JSON 中提取 table 字段
     *
     * @param value 消息 value(JSON 字符串)
     * @return table 字段值
     * @throws BizException table 字段缺失或 JSON 格式错误时
     */
    private String parseTableFromValue(String value) {
        try {
            Map<String, Object> map = objectMapper.readValue(value,
                    new TypeReference<>() {
                    });
            Object table = map.get("table");
            if (table == null) {
                throw new BizException("消息 value 中缺少 table 字段");
            }
            return (String) table;
        } catch (BizException e) {
            throw e;
        } catch (Exception e) {
            throw new BizException("消息 value 格式错误: " + e.getMessage());
        }
    }

    /**
     * 构建重投 headers:retry.count 首次设置为 1,否则自增 1
     *
     * @param originalHeaders 原始 headers(可能为 null)
     * @return 处理后的 headers(新 Map,不会修改原始对象)
     */
    private Map<String, String> buildRetryHeaders(Map<String, String> originalHeaders) {
        Map<String, String> headers = (originalHeaders != null)
                ? new HashMap<>(originalHeaders) : new HashMap<>();
        String retryStr = headers.get(HEADER_RETRY_COUNT);
        if (retryStr != null) {
            headers.put(HEADER_RETRY_COUNT, String.valueOf(Integer.parseInt(retryStr) + 1));
        } else {
            headers.put(HEADER_RETRY_COUNT, "1");
        }
        return headers;
    }

    /**
     * Map header 转 Kafka Header 列表
     *
     * @param headers 消息 headers 键值对
     * @return Kafka Header 列表
     */
    private List<Header> toKafkaHeaders(Map<String, String> headers) {
        return headers.entrySet().stream()
                .map(e -> new RecordHeader(e.getKey(),
                        e.getValue().getBytes(StandardCharsets.UTF_8)))
                .collect(Collectors.toList());
    }

    // ==================== 私有工具方法 ====================

    /**
     * LocalDateTime 转 epoch 毫秒(使用系统默认时区)
     *
     * @param dateTime 本地日期时间
     * @return epoch 毫秒值
     */
    private long toEpochMilli(LocalDateTime dateTime) {
        return dateTime.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
    }
}

6.6 Kafka 配置服务

KafkaConfigService 接口

package com.liang.study.service;

import java.util.Properties;

/**
 * Kafka 配置读取服务
 *
 * @author liang
 * @since 2026-06-28
 */
public interface KafkaConfigService {

    /**
     * 获取 Kafka 连接配置属性(包含 bootstrap.servers 及默认的序列化器)
     *
     * @param tenantId 租户ID
     * @return Kafka 生产者配置 Properties
     * @throws com.liang.study.exception.BizException 指定租户的 BOOTSTRAP 配置不存在时
     */
    Properties getKafkaProperties(String tenantId);

    /**
     * 获取死信队列 topic 名称
     *
     * @param tenantId 租户ID
     * @return DLQ topic 名称
     * @throws com.liang.study.exception.BizException 指定租户的 DLQ_TOPIC 配置不存在时
     */
    String getDlqTopic(String tenantId);

    /**
     * 获取原业务 topic 名称,格式为 KAFKA_TOPIC.tableName
     *
     * @param tenantId  租户ID
     * @param tableName 表名
     * @return 完整 topic 名称
     * @throws com.liang.study.exception.BizException 指定租户的 KAFKA_TOPIC 配置不存在时
     */
    String getOriginalTopic(String tenantId, String tableName);
}

KafkaConfigServiceImpl 实现

package com.liang.study.service.impl;

import com.liang.study.exception.BizException;
import com.liang.study.mapper.ParamConfigMapper;
import com.liang.study.model.pojo.ParamConfigDO;
import com.liang.study.service.KafkaConfigService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.kafka.clients.producer.ProducerConfig;
import org.apache.kafka.common.serialization.StringSerializer;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Properties;

/**
 * Kafka 配置读取服务实现
 * <p>
 * 从 param_config 表中按 tenantId + paramKey 读取配置,
 * 支持 BOOTSTRAP、DLQ_TOPIC、KAFKA_TOPIC 三个配置键。
 *
 * @author liang
 * @since 2026-06-28
 */
@Slf4j
@Service
@RequiredArgsConstructor
public class KafkaConfigServiceImpl implements KafkaConfigService {

    /**
     * 参数配置表中 Kafka 地址对应的 paramKey
     */
    private static final String BOOTSTRAP_KEY = "BOOTSTRAP";

    /**
     * 参数配置表中死信队列 topic 对应的 paramKey
     */
    private static final String DLQ_TOPIC_KEY = "DLQ_TOPIC";

    /**
     * 参数配置表中原业务 topic 前缀对应的 paramKey
     */
    private static final String KAFKA_TOPIC_KEY = "KAFKA_TOPIC";

    private final ParamConfigMapper paramConfigMapper;

    @Override
    public Properties getKafkaProperties(String tenantId) {
        String bootstrapServers = getConfigValue(tenantId, BOOTSTRAP_KEY);
        Properties props = new Properties();
        props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
        props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG,
                StringSerializer.class.getName());
        props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG,
                StringSerializer.class.getName());
        return props;
    }

    @Override
    public String getDlqTopic(String tenantId) {
        return getConfigValue(tenantId, DLQ_TOPIC_KEY);
    }

    @Override
    public String getOriginalTopic(String tenantId, String tableName) {
        String baseTopic = getConfigValue(tenantId, KAFKA_TOPIC_KEY);
        return baseTopic + "." + tableName;
    }

    /**
     * 从参数配置表中按 tenantId + paramKey 查询配置值
     *
     * @param tenantId 租户ID
     * @param paramKey 参数键
     * @return 参数值
     * @throws BizException 配置不存在时
     */
    private String getConfigValue(String tenantId, String paramKey) {
        List<ParamConfigDO> list = paramConfigMapper.page(tenantId, paramKey);
        if (list.isEmpty()) {
            throw new BizException("Kafka 配置不存在: tenantId="
                    + tenantId + ", paramKey=" + paramKey);
        }
        return list.getFirst().getParamValue();
    }
}

6.7 Kafka Producer 缓存

package com.liang.study.cache;

import com.liang.study.service.KafkaConfigService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.kafka.clients.producer.KafkaProducer;
import org.springframework.beans.factory.DisposableBean;
import org.springframework.stereotype.Component;

import java.util.Map;
import java.util.Properties;
import java.util.concurrent.ConcurrentHashMap;

/**
 * Kafka Producer 缓存
 * <p>
 * 按 tenantId 缓存 KafkaProducer 实例,避免每次请求重新创建。
 * KafkaProducer 线程安全,可被多个请求并发复用。
 * 应用关闭时自动释放所有 Producer 连接。
 *
 * @author liang
 * @since 2026-06-28
 */
@Slf4j
@Component
@RequiredArgsConstructor
public class KafkaProducerCache implements DisposableBean {

    /**
     * Producer 缓存,key=tenantId, value=KafkaProducer
     */
    private final Map<String, KafkaProducer<String, String>> cache = new ConcurrentHashMap<>();

    private final KafkaConfigService kafkaConfigService;

    /**
     * 获取或创建指定租户的 KafkaProducer(复用已缓存的实例)
     *
     * @param tenantId 租户ID
     * @return KafkaProducer 实例
     */
    public KafkaProducer<String, String> getOrCreate(String tenantId) {
        return cache.computeIfAbsent(tenantId, id -> {
            log.info("创建 KafkaProducer: tenantId={}", id);
            Properties props = kafkaConfigService.getKafkaProperties(id);
            return new KafkaProducer<>(props);
        });
    }

    @Override
    public void destroy() {
        cache.forEach((tenantId, producer) -> {
            log.info("关闭 KafkaProducer: tenantId={}", tenantId);
            producer.close();
        });
        cache.clear();
    }
}

6.8 通用依赖组件

以下三个类是 DLQ 模块依赖的基础组件,如果目标系统已有类似的统一响应体和异常处理,可直接复用。

BizException —— 业务异常

package com.liang.study.exception;

import com.liang.study.model.constants.ResultCode;
import lombok.Getter;

/**
 * 自定义业务异常类
 * <p>
 * 禁止直接抛出 RuntimeException,业务异常统一使用此类
 *
 * @author liang
 * @since 2026-06-26
 */
@Getter
public class BizException extends RuntimeException {

    /** 异常状态码 */
    private final int code;

    /**
     * 构造业务异常
     *
     * @param code 状态码
     * @param msg  异常消息
     */
    public BizException(int code, String msg) {
        super(msg);
        this.code = code;
    }

    /**
     * 构造业务异常(默认 code=400)
     *
     * @param msg 异常消息
     */
    public BizException(String msg) {
        super(msg);
        this.code = ResultCode.FAIL;
    }
}

Result —— 统一响应体

package com.liang.study.common;

import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.liang.study.model.constants.ResultCode;
import lombok.Data;

import java.time.LocalDateTime;

/**
 * 通用统一响应体
 *
 * @param <T> 响应数据类型
 * @author liang
 * @since 2026-06-26
 */
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class Result<T> {

    /** 状态码:200-成功,400-业务异常,500-系统错误 */
    private int code;

    /** 响应数据 */
    private T data;

    /** 提示消息 */
    private String msg;

    /** 响应时间戳 */
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss.SSS", timezone = "GMT+8")
    private LocalDateTime timestamp;

    /** 追踪ID,仅非成功时返回,用于日志追踪和问题排查 */
    private String traceId;

    private Result() {
        this.timestamp = LocalDateTime.now();
    }

    /**
     * 成功返回(无数据)
     */
    public static <T> Result<T> success() {
        Result<T> result = new Result<>();
        result.setCode(ResultCode.SUCCESS);
        result.setMsg("操作成功");
        return result;
    }

    /**
     * 成功返回(带数据)
     */
    public static <T> Result<T> success(T data) {
        Result<T> result = new Result<>();
        result.setCode(ResultCode.SUCCESS);
        result.setMsg("操作成功");
        result.setData(data);
        return result;
    }

    /**
     * 业务异常返回
     */
    public static <T> Result<T> fail(String msg) {
        Result<T> result = new Result<>();
        result.setCode(ResultCode.FAIL);
        result.setMsg(msg);
        return result;
    }

    /**
     * 系统错误返回
     */
    public static <T> Result<T> error(String msg) {
        Result<T> result = new Result<>();
        result.setCode(ResultCode.ERROR);
        result.setMsg(msg);
        return result;
    }
}

ResultCode —— 状态码常量

package com.liang.study.model.constants;

/**
 * 统一响应状态码常量
 *
 * @author liang
 * @since 2026-06-26
 */
public final class ResultCode {

    private ResultCode() {
        // 工具类,禁止实例化
    }

    /** 成功 */
    public static final int SUCCESS = 200;

    /** 业务异常(参数校验失败、数据不存在等) */
    public static final int FAIL = 400;

    /** 系统错误(未知异常、服务不可用等) */
    public static final int ERROR = 500;
}

ParamConfigMapper(MyBatis)

package com.liang.study.mapper;

import com.liang.study.model.pojo.ParamConfigDO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;

import java.util.List;

/**
 * 参数配置表 Mapper
 *
 * @author liang
 * @since 2026-06-26
 */
@Mapper
public interface ParamConfigMapper {

    /**
     * 分页查询(配合 PageHelper 使用)
     *
     * @param tenantId 租户ID
     * @param paramKey 参数键(模糊搜索,可选)
     * @return 参数配置列表
     */
    List<ParamConfigDO> page(@Param("tenantId") String tenantId, @Param("paramKey") String paramKey);
}

ParamConfigMapper.xml

<?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.liang.study.mapper.ParamConfigMapper">

    <!-- 分页查询(配合 PageHelper 使用) -->
    <select id="page" resultType="com.liang.study.model.pojo.ParamConfigDO">
        SELECT id, tenant_id, param_key, param_value, creator_id,
        create_time, updater_id, update_time
        FROM param_config
        WHERE tenant_id = #{tenantId} AND deleted = 0
        <if test="paramKey != null and paramKey != ''">
            AND param_key LIKE CONCAT('%', #{paramKey}, '%')
        </if>
        ORDER BY id DESC
    </select>

</mapper>

ParamConfigDO —— 配置表实体

package com.liang.study.model.pojo;

import lombok.Data;
import java.time.LocalDateTime;

/**
 * 参数配置表数据库实体
 *
 * @author liang
 * @since 2026-06-26
 */
@Data
public class ParamConfigDO {

    /** 主键ID */
    private Long id;

    /** 租户ID */
    private String tenantId;

    /** 参数键 */
    private String paramKey;

    /** 参数值 */
    private String paramValue;

    /** 创建人ID */
    private Long creatorId;

    /** 创建时间 */
    private LocalDateTime createTime;

    /** 更新人ID */
    private Long updaterId;

    /** 更新时间 */
    private LocalDateTime updateTime;

    /** 是否逻辑删除(0-未删除 1-已删除) */
    private Integer deleted;
}

7. 关键设计说明

7.1 消息格式

DLQ 中的消息为标准 Kafka 消息,格式如下:

Key (JSON 字符串)

{
    "主键列1": "值1",
    "主键列2": "值2"
}

Value (JSON 字符串)

{
    "data": {
        "列名1": "值1",
        "列名2": "值2"
    },
    "opType": "U",
    "table": "表名"
}

Headers

Key 说明 示例
__connector.errors.connector.name 连接器名称 testConnector1
__connector.errors.exception.message 错误消息 Failed to flush batch to Kafka
__connector.errors.ip IP 地址 127.0.0.1
__connector.errors.retry.count 重试次数 1

7.2 重投时原 topic 命名规则

原 topic = <KAFKA_TOPIC 配置值> + "." + <消息 value 中的 table 字段>

例如:KAFKA_TOPIC 配置为 manage_dev,消息 value.table 为 user,则重投目标 topic 为 manage_dev.user

7.3 预览接口的核心实现原理

1. 从数据库获取 DLQ topic 名称
2. 创建临时 KafkaConsumer(随机 group.id,不自动提交)
3. 获取所有分区元数据
4. 通过 offsetsForTimes() 按起始时间 seek 到对应 offset
5. 循环 poll,超过结束时间戳或达到 1000 条上限停止
6. 在内存中:模糊匹配筛选 → 按 messageKey 去重(保留最新) → 分页截取
7. 返回 PageInfo

7.4 多租户支持

所有接口通过 tenantId 区分租户,配置按 tenantId + paramKeyparam_config 表读取,Producer 按 tenantId 缓存。单租户系统可将 tenantId 固定为一个常量值(如 "default")。


8. 迁移注意事项

8.1 包路径修改

所有源码中的 com.liang.study 需要全局替换为目标系统的包路径。涉及的文件:

  • Java 源文件中的 package 声明和 import 语句
  • ParamConfigMapper.xml 中的 namespaceresultType

8.2 依赖适配

场景 处理方式
目标系统不用 MyBatis 重写 KafkaConfigServiceImpl.getConfigValue(),改用 JPA / JDBC 查询 param_config
目标系统不用 Lombok 将所有 @Data@Slf4j@RequiredArgsConstructor 替换为手写的 getter/setter、Logger、构造函数
目标系统不用 PageHelper 可保留 PageInfo 作为纯数据结构使用(已在 paginateInMemory 中手动设置字段),或者替换为目标系统的分页类
目标系统不用 Swagger 删除所有 @Schema@Operation 注解,不影响功能
目标系统是 Spring Boot 2.x jakarta.validation.* 替换为 javax.validation.*

8.3 配置数据准备

迁移前必须在目标数据库的 param_config 表中插入每个租户的三条配置数据(参见第 5 节)。否则启动后所有接口都会因配置缺失而抛出 BizException

8.4 时区注意事项

  • DlqMessageVO.timestamp 使用 @JsonFormat(timezone = "GMT+8") 序列化,DlqServiceImpl.toEpochMilli() 使用 ZoneId.systemDefault() 转换。如果目标系统部署在不同时区,需要统一调整。
  • 前端传入的 startTimeendTime 应使用 ISO-8601 格式(如 2026-06-28T00:00:00),Spring 会自动反序列化为 LocalDateTime

8.5 Bean 注入要求

以下 Bean 必须能被 Spring 容器管理:

Bean 类型 来源
objectMapper com.fasterxml.jackson.databind.ObjectMapper Spring Boot 自动配置(已有)
dataSource javax.sql.DataSource Spring Boot 自动配置(已有)
paramConfigMapper MyBatis Mapper 需扫描到 @Mapper
kafkaConfigService KafkaConfigService 需实现并注册
producerCache KafkaProducerCache 迁移时一并复制
dlqService DlqService 迁移时一并复制

8.6 预览接口的性能考量

  • MAX_TOTAL_RECORDS = 1000:可根据目标系统的内存和消息量调整。DLQ 消息量若非常大,可考虑降低此值。
  • MAX_POLL_RECORDS = 500:每次 poll 的最大条数,受 topic 分区数影响。过多分区可能导致单次 poll 返回大量消息。
  • POLL_TIMEOUT_MS = 5000:如果网络延迟较高,可适当增加 poll 超时时间。
  • 去重操作使用 LinkedHashMap 保留插入顺序,时间复杂度 O(n)。如果预期消息量很大(> 10000),可考虑优化策略。

8.7 重投接口的幂等性

重投接口不保证幂等性——每次调用都会将消息重新发送到原 topic。如果业务需要幂等,建议在目标系统添加去重逻辑(例如记录已重投的 messageKey)。

8.8 错误处理

  • 重投失败:任一条消息投递失败即抛 BizException 终止,前面已成功的消息不会回滚(Kafka 消息一旦发送无法撤销)。
  • 异常处理:需在目标系统配置 @RestControllerAdvice 全局异常处理器,确保 BizException 被正确捕获并返回 Result.fail(msg)
  • Consumer 关闭:preview 使用 try-with-resources 确保 Consumer 正确关闭,无需额外处理。

8.9 模拟推送接口的取舍

push 接口和 pushToDlq() 方法依赖 DataSource + JdbcTemplate 直接查询数据库表。如果目标系统不需要模拟测试功能,建议:

  1. 删除 DlqController.push() 方法
  2. 删除 DlqService.pushToDlq() 方法
  3. 删除 DlqServiceImplpushToDlq() 及其私有辅助方法(约 100 行)
  4. 删除 DlqPushDTO

8.10 安全注意事项

  • queryTableData() 方法对 tableName 做了正则校验 ^[a-zA-Z0-9_.]+$,防止 SQL 注入。迁移后如修改此方法,需保留此校验逻辑。
  • DLQ 预览接口可能暴露消息敏感内容,建议添加权限控制。

9. 目标系统需要提前准备的配置数据

迁移后需在目标数据库 param_config 表中插入如下数据(按实际环境修改值):

-- 示例:租户ID = "1"

-- 1. Kafka 连接地址
INSERT INTO param_config (tenant_id, param_key, param_value, creator_id)
VALUES ('1', 'BOOTSTRAP', '192.168.1.100:9092', 0);

-- 2. 死信队列 topic 名称
INSERT INTO param_config (tenant_id, param_key, param_value, creator_id)
VALUES ('1', 'DLQ_TOPIC', 'dead-letter-queue', 0);

-- 3. 原业务 topic 前缀
INSERT INTO param_config (tenant_id, param_key, param_value, creator_id)
VALUES ('1', 'KAFKA_TOPIC', 'manage_dev', 0);

文档版本:v1.0
生成时间:2026-06-28
适用代码:study-backend master 分支 commit 3f8c7c6

posted @ 2026-06-28 21:26  景之1231  阅读(9)  评论(0)    收藏  举报