15 小时以前 f78ecae2003ce4eb8ab61a98bfa685d86ead9f7c
feat(system): 添加数据库备份配置和实现自动备份功能

- 在 application-dev、application-jhhg、application-local、application-test、application-wtxc、application-yzfx 等配置文件中添加 database-backup 配置项,包括 host、port、database、username、password 和 command 参数
- 新增 backupCommand、backupHost、backupPort、backupDatabase、backupUsername、backupPassword 配置属性
- 实现备份连接信息解析逻辑,优先使用 yudao.database-backup 配置,未配置时从数据源 JDBC URL 解析
- 支持多种部署环境检测,包括本地 mysqldump、Docker MySQL 容器和手动指定命令
- 添加 Docker 环境下的容器查找和执行逻辑,通过 docker exec 调用容器内 mysqldump
- 实现备份超时控制,默认 10 分钟超时限制
- 添加异常处理和日志记录,支持备份过程中的错误捕获和状态监控
- 优化密码传递方式,通过环境变量避免出现在命令行中
- 添加后台线程处理 stderr 输出,防止管道缓冲区阻塞进程
已修改7个文件
286 ■■■■ 文件已修改
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/database/DatabaseBackupServiceImpl.java 244 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-server/src/main/resources/application-dev.yaml 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-server/src/main/resources/application-jhhg.yaml 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-server/src/main/resources/application-local.yaml 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-server/src/main/resources/application-test.yaml 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-server/src/main/resources/application-wtxc.yaml 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-server/src/main/resources/application-yzfx.yaml 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/database/DatabaseBackupServiceImpl.java
@@ -3,24 +3,38 @@
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.io.IoUtil;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import jakarta.servlet.http.HttpServletResponse;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@Service
@Slf4j
public class DatabaseBackupServiceImpl implements DatabaseBackupService {
    /** 备份超时时间(分钟) */
    private static final long BACKUP_TIMEOUT_MINUTES = 10;
    /** MySQL 容器内部端口 */
    private static final String DOCKER_MYSQL_PORT = "3306";
    @Value("${spring.datasource.dynamic.datasource.master.url:}")
    private String dbUrl;
@@ -31,54 +45,145 @@
    @Value("${spring.datasource.dynamic.datasource.master.password:}")
    private String dbPassword;
    /** 备份命令,可显式指定(如 docker exec mysql8 mysqldump);留空则自动检测本地 mysqldump 或 Docker MySQL 容器 */
    @Value("${yudao.database-backup.command:}")
    private String backupCommand;
    /** 备份目标数据库 host,留空则从数据源 JDBC URL 解析 */
    @Value("${yudao.database-backup.host:}")
    private String backupHost;
    /** 备份目标数据库 port,留空则从数据源 JDBC URL 解析 */
    @Value("${yudao.database-backup.port:}")
    private String backupPort;
    /** 备份目标数据库名,留空则从数据源 JDBC URL 解析 */
    @Value("${yudao.database-backup.database:}")
    private String backupDatabase;
    /** 备份账号,留空则使用数据源账号 */
    @Value("${yudao.database-backup.username:}")
    private String backupUsername;
    /** 备份密码,留空则使用数据源密码 */
    @Value("${yudao.database-backup.password:}")
    private String backupPassword;
    @Override
    public void backupDatabase(HttpServletResponse response) throws Exception {
        if (dbUrl == null || dbUrl.isEmpty()) {
        if (StrUtil.isEmpty(dbUrl)) {
            throw new ServiceException(500, "未找到数据库连接配置");
        }
        // 解析 JDBC URL,提取 host, port, dbname
        // 格式: jdbc:mysql://localhost:3306/ruoyi-vue-pro?useSSL=false
        Pattern pattern = Pattern.compile("jdbc:mysql://([^:]+):(\\d+)/([^?]+)");
        Matcher matcher = pattern.matcher(dbUrl);
        String host = "127.0.0.1";
        String port = "3306";
        String dbName = "";
        if (matcher.find()) {
            host = matcher.group(1);
            port = matcher.group(2);
            dbName = matcher.group(3);
        } else {
            // fallback
            log.warn("无法解析 JDBC URL: {}, 使用默认配置", dbUrl);
            dbName = "ruoyi-vue-pro"; // 默认名,如果有的话
        // 备份连接的数据库信息:优先使用 yudao.database-backup 配置,未配置的部分从数据源 JDBC URL 解析兜底
        String dbHost = backupHost;
        String dbPort = backupPort;
        String dbName = backupDatabase;
        if (StrUtil.isEmpty(dbHost) || StrUtil.isEmpty(dbPort) || StrUtil.isEmpty(dbName)) {
            Matcher matcher = Pattern.compile("jdbc:mysql://([^:]+):(\\d+)/([^?]+)").matcher(dbUrl);
            if (matcher.find()) {
                if (StrUtil.isEmpty(dbHost)) {
                    dbHost = matcher.group(1);
                }
                if (StrUtil.isEmpty(dbPort)) {
                    dbPort = matcher.group(2);
                }
                if (StrUtil.isEmpty(dbName)) {
                    dbName = matcher.group(3);
                }
            }
        }
        if (StrUtil.isEmpty(dbHost) || StrUtil.isEmpty(dbPort) || StrUtil.isEmpty(dbName)) {
            log.warn("无法从配置或 JDBC URL 解析完整的数据库信息: host={}, port={}, db={}", dbHost, dbPort, dbName);
            throw new ServiceException(500, "无法解析数据库备份连接信息,请在 yudao.database-backup 中配置 host/port/database");
        }
        String username = StrUtil.isEmpty(backupUsername) ? dbUsername : backupUsername;
        String password = StrUtil.isEmpty(backupPassword) ? dbPassword : backupPassword;
        // 检测部署环境,构建备份命令
        List<String> commandParts = new ArrayList<>();
        String connectHost = dbHost;
        String connectPort = dbPort;
        if (StrUtil.isNotEmpty(backupCommand)) {
            // 手动指定命令,优先使用
            for (String token : backupCommand.trim().split("\\s+")) {
                commandParts.add(token);
            }
            log.info("使用手动配置的备份命令: {}", backupCommand);
        } else if (isLocalMysqldumpAvailable()) {
            // 本地部署:本机已安装 mysqldump
            commandParts.add("mysqldump");
            log.info("检测到本机 mysqldump,使用本地备份命令");
        } else {
            // Docker 部署:查找运行中的 MySQL 容器,通过 docker exec 调用容器内 mysqldump
            String container = findMysqlDockerContainer(dbPort);
            if (StrUtil.isEmpty(container)) {
                throw new ServiceException(500, "未检测到可用的 mysqldump 环境:本机未安装 MySQL 客户端,"
                        + "也未发现运行中的 MySQL Docker 容器。可通过配置 yudao.database-backup.command 手动指定备份命令");
            }
            commandParts.add("docker");
            commandParts.add("exec");
            // docker exec 不会转发宿主机环境变量,密码需通过 -e 显式传入容器
            if (StrUtil.isNotEmpty(password)) {
                commandParts.add("-e");
                commandParts.add("MYSQL_PWD=" + password);
            }
            commandParts.add(container);
            commandParts.add("mysqldump");
            // 容器内固定使用 127.0.0.1:3306 走 TCP 连接自身,绕开 socket 认证差异
            connectHost = "127.0.0.1";
            connectPort = DOCKER_MYSQL_PORT;
            log.info("检测到 Docker 部署,使用容器 [{}] 执行 mysqldump", container);
        }
        commandParts.add("-h" + connectHost);
        commandParts.add("-P" + connectPort);
        commandParts.add("-u" + username);
        if (StrUtil.isEmpty(password)) {
            commandParts.add("--skip-password");
        }
        commandParts.add(dbName);
        String timestamp = DateUtil.format(new Date(), "yyyyMMdd_HHmmss");
        String fileName = "backup_" + timestamp + ".sql";
        File tempFile = File.createTempFile("backup_", ".sql");
        try {
            // 构建 mysqldump 命令
            String[] command = {
                    "mysqldump",
                    "-h" + host,
                    "-P" + port,
                    "-u" + dbUsername,
                    "-p" + dbPassword,
                    dbName,
                    "-r", tempFile.getAbsolutePath()
            };
            ProcessBuilder pb = new ProcessBuilder(command);
            ProcessBuilder pb = new ProcessBuilder(commandParts);
            // 数据库密码通过环境变量传递,避免出现在命令行及进程列表
            if (StrUtil.isNotEmpty(password)) {
                pb.environment().put("MYSQL_PWD", password);
            }
            Process process = pb.start();
            int exitCode = process.waitFor();
            if (exitCode != 0) {
                String error = IoUtil.read(process.getErrorStream(), StandardCharsets.UTF_8);
                log.error("数据库备份失败, exitCode: {}, error: {}", exitCode, error);
                throw new ServiceException(500, "数据库备份失败: " + error);
            // 后台读取 stderr,防止管道缓冲区写满导致进程阻塞
            StringBuilder stderr = new StringBuilder();
            Thread stderrThread = new Thread(() -> {
                try (BufferedReader reader = new BufferedReader(
                        new InputStreamReader(process.getErrorStream(), StandardCharsets.UTF_8))) {
                    String line;
                    while ((line = reader.readLine()) != null) {
                        stderr.append(line).append(System.lineSeparator());
                    }
                } catch (IOException ignored) {
                }
            });
            stderrThread.start();
            // stdout 写入本地临时文件(docker exec 场景输出不会落在容器内文件系统)
            try (InputStream in = process.getInputStream();
                 OutputStream out = new FileOutputStream(tempFile)) {
                IoUtil.copy(in, out);
            }
            boolean finished = process.waitFor(BACKUP_TIMEOUT_MINUTES, TimeUnit.MINUTES);
            stderrThread.join();
            if (!finished) {
                process.destroyForcibly();
                throw new ServiceException(500, "数据库备份超时(超过 " + BACKUP_TIMEOUT_MINUTES + " 分钟)");
            }
            if (process.exitValue() != 0) {
                log.error("数据库备份失败, exitCode: {}, error: {}", process.exitValue(), stderr);
                throw new ServiceException(500, "数据库备份失败: " + stderr);
            }
            // 设置响应头
@@ -91,10 +196,73 @@
                 OutputStream os = response.getOutputStream()) {
                IoUtil.copy(fis, os);
            }
        } finally {
            // 删除临时文件
            FileUtil.del(tempFile);
        }
    }
}
    /**
     * 检测本机 mysqldump 是否可用
     */
    private boolean isLocalMysqldumpAvailable() {
        try {
            Process process = new ProcessBuilder("mysqldump", "--version").redirectErrorStream(true).start();
            boolean finished = process.waitFor(5, TimeUnit.SECONDS);
            if (!finished) {
                process.destroyForcibly();
                return false;
            }
            return process.exitValue() == 0;
        } catch (IOException e) {
            return false;
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return false;
        }
    }
    /**
     * 查找运行中的 MySQL Docker 容器,优先选择端口映射与数据源端口一致的容器
     *
     * @return 容器名;未找到返回 null
     */
    private String findMysqlDockerContainer(String dbPort) {
        try {
            Process ps = new ProcessBuilder("docker", "ps", "--format", "{{.ID}}|{{.Names}}|{{.Image}}")
                    .redirectErrorStream(true).start();
            String output = IoUtil.read(ps.getInputStream(), StandardCharsets.UTF_8);
            if (!ps.waitFor(10, TimeUnit.SECONDS) || ps.exitValue() != 0) {
                return null;
            }
            List<String[]> containers = new ArrayList<>();
            for (String line : output.split("\n")) {
                String[] parts = line.trim().split("\\|");
                if (parts.length == 3 && parts[2].startsWith("mysql")) {
                    containers.add(parts);
                }
            }
            if (containers.isEmpty()) {
                return null;
            }
            // 优先选择端口映射与数据源端口一致的容器
            for (String[] container : containers) {
                Process portProcess = new ProcessBuilder("docker", "port", container[0], "3306/tcp")
                        .redirectErrorStream(true).start();
                String portOutput = IoUtil.read(portProcess.getInputStream(), StandardCharsets.UTF_8);
                if (portProcess.waitFor(10, TimeUnit.SECONDS) && portProcess.exitValue() == 0
                        && portOutput.contains(dbPort)) {
                    return container[1];
                }
            }
            log.warn("未匹配到端口映射的 MySQL 容器,使用第一个: {}", containers.get(0)[1]);
            return containers.get(0)[1];
        } catch (IOException e) {
            log.warn("检测 Docker 环境失败: {}", e.getMessage());
            return null;
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return null;
        }
    }
}
yudao-server/src/main/resources/application-dev.yaml
@@ -154,3 +154,10 @@
    compress: true # 是否压缩图片
    need-compress-size: 10MB # 触发压缩的文件大小阈值
    compress-quality: 0.5 # 压缩质量(0.0-1.0)
  database-backup: # 数据库备份配置
    host: 127.0.0.1 # 备份目标 MySQL 主机,留空自动从数据源 JDBC URL 解析
    port: 3306 # 备份目标 MySQL 端口,留空自动从数据源 JDBC URL 解析
    database: ruoyi-vue-pro # 备份目标数据库,留空自动从数据源 JDBC URL 解析
    username: root # 备份账号,留空使用数据源账号
    password: '123456' # 备份密码,留空使用数据源密码
    command: # mysqldump 命令,留空自动检测部署环境(本机 mysqldump 或 Docker MySQL 容器);可手动指定,如:docker exec mysql8 mysqldump
yudao-server/src/main/resources/application-jhhg.yaml
@@ -220,3 +220,10 @@
    compress: true # 是否压缩图片
    need-compress-size: 10MB # 触发压缩的文件大小阈值
    compress-quality: 0.5 # 压缩质量(0.0-1.0)
  database-backup: # 数据库备份配置
    host: 172.17.0.1 # 备份目标 MySQL 主机,留空自动从数据源 JDBC URL 解析
    port: 9002 # 备份目标 MySQL 端口,留空自动从数据源 JDBC URL 解析
    database: ruoyi-vue-pro # 备份目标数据库,留空自动从数据源 JDBC URL 解析
    username: root # 备份账号,留空使用数据源账号
    password: 'jhhg@123456..' # 备份密码,留空使用数据源密码
    command: # mysqldump 命令,留空自动检测部署环境(本机 mysqldump 或 Docker MySQL 容器);可手动指定,如:docker exec mysql8 mysqldump
yudao-server/src/main/resources/application-local.yaml
@@ -198,3 +198,10 @@
    compress: true # 是否压缩图片
    need-compress-size: 10MB # 触发压缩的文件大小阈值
    compress-quality: 0.5 # 压缩质量(0.0-1.0)
  database-backup: # 数据库备份配置
    host: localhost # 备份目标 MySQL 主机,留空自动从数据源 JDBC URL 解析
    port: 3306 # 备份目标 MySQL 端口,留空自动从数据源 JDBC URL 解析
    database: ruoyi-vue-pro # 备份目标数据库,留空自动从数据源 JDBC URL 解析
    username: root # 备份账号,留空使用数据源账号
    password: '123456' # 备份密码,留空使用数据源密码
    command: # mysqldump 命令,留空自动检测部署环境(本机 mysqldump 或 Docker MySQL 容器);可手动指定,如:docker exec mysql8 mysqldump
yudao-server/src/main/resources/application-test.yaml
@@ -220,3 +220,10 @@
    compress: true # 是否压缩图片
    need-compress-size: 10MB # 触发压缩的文件大小阈值
    compress-quality: 0.5 # 压缩质量(0.0-1.0)
  database-backup: # 数据库备份配置
    host: 172.17.0.1 # 备份目标 MySQL 主机,留空自动从数据源 JDBC URL 解析
    port: 11101 # 备份目标 MySQL 端口,留空自动从数据源 JDBC URL 解析
    database: ruoyi-vue-pro # 备份目标数据库,留空自动从数据源 JDBC URL 解析
    username: root # 备份账号,留空使用数据源账号
    password: 'xd@123456..' # 备份密码,留空使用数据源密码
    command: # mysqldump 命令,留空自动检测部署环境(本机 mysqldump 或 Docker MySQL 容器);可手动指定,如:docker exec mysql8 mysqldump
yudao-server/src/main/resources/application-wtxc.yaml
@@ -220,3 +220,10 @@
    compress: true # 是否压缩图片
    need-compress-size: 10MB # 触发压缩的文件大小阈值
    compress-quality: 0.5 # 压缩质量(0.0-1.0)
  database-backup: # 数据库备份配置
    host: 172.17.0.1 # 备份目标 MySQL 主机,留空自动从数据源 JDBC URL 解析
    port: 9002 # 备份目标 MySQL 端口,留空自动从数据源 JDBC URL 解析
    database: mom-wtxc # 备份目标数据库,留空自动从数据源 JDBC URL 解析
    username: root # 备份账号,留空使用数据源账号
    password: 'wtxc@123456..' # 备份密码,留空使用数据源密码
    command: # mysqldump 命令,留空自动检测部署环境(本机 mysqldump 或 Docker MySQL 容器);可手动指定,如:docker exec mysql8 mysqldump
yudao-server/src/main/resources/application-yzfx.yaml
@@ -220,3 +220,10 @@
    compress: true # 是否压缩图片
    need-compress-size: 10MB # 触发压缩的文件大小阈值
    compress-quality: 0.5 # 压缩质量(0.0-1.0)
  database-backup: # 数据库备份配置
    host: 172.17.0.1 # 备份目标 MySQL 主机,留空自动从数据源 JDBC URL 解析
    port: 9002 # 备份目标 MySQL 端口,留空自动从数据源 JDBC URL 解析
    database: ruoyi-vue-pro # 备份目标数据库,留空自动从数据源 JDBC URL 解析
    username: root # 备份账号,留空使用数据源账号
    password: 'yzfx@123456..' # 备份密码,留空使用数据源密码
    command: # mysqldump 命令,留空自动检测部署环境(本机 mysqldump 或 Docker MySQL 容器);可手动指定,如:docker exec mysql8 mysqldump