18 小时以前 30a6da9dd62728fe4a329a5f9723e18e33d5c334
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,70 +45,291 @@
    @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");
        }
        // localhost 统一走 TCP 127.0.0.1:mysqldump 对 localhost 可能走 socket/loopback,
        // 而不同环境 root@localhost 的认证要求差异大,强制 TCP 更稳定
        if ("localhost".equalsIgnoreCase(dbHost)) {
            dbHost = "127.0.0.1";
        }
        String username = StrUtil.isEmpty(backupUsername) ? dbUsername : backupUsername;
        String password = StrUtil.isEmpty(backupPassword) ? dbPassword : backupPassword;
        // 组装候选备份命令,按优先级依次尝试:手动配置 > 本机 mysqldump > Docker 容器 mysqldump
        List<List<String>> candidates = new ArrayList<>();
        List<String> labels = new ArrayList<>();
        if (StrUtil.isNotEmpty(backupCommand)) {
            candidates.add(splitCommand(backupCommand));
            labels.add("手动配置命令[" + backupCommand + "]");
        }
        if (isLocalMysqldumpAvailable()) {
            candidates.add(buildLocalMysqldumpCommand(dbHost, dbPort, username, password, dbName));
            labels.add("本机 mysqldump");
        }
        String container = findMysqlDockerContainer(dbPort);
        if (StrUtil.isNotEmpty(container)) {
            candidates.add(buildDockerExecCommand(container, username, password, dbName));
            labels.add("Docker 容器[" + container + "] mysqldump");
        }
        if (candidates.isEmpty()) {
            throw new ServiceException(500, "未检测到可用的 mysqldump 环境:本机未安装 MySQL 客户端,也没有运行中的 MySQL Docker 容器。"
                    + "请安装 MySQL 客户端,或通过 yudao.database-backup.command 手动指定备份命令(如 docker exec mysql8 mysqldump)");
        }
        log.info("数据库备份候选方式:{}", labels);
        // 依次尝试所有方式,全部失败才报错
        List<String> failures = new ArrayList<>();
        String timestamp = DateUtil.format(new Date(), "yyyyMMdd_HHmmss");
        String fileName = "backup_" + timestamp + ".sql";
        File tempFile = File.createTempFile("backup_", ".sql");
        for (int i = 0; i < candidates.size(); i++) {
            File tempFile = File.createTempFile("backup_", ".sql");
            try {
                String error = runMysqldump(candidates.get(i), password, tempFile);
                if (error == null) {
                    log.info("数据库备份成功,使用方式:{}", labels.get(i));
                    downloadAttachment(response, tempFile, fileName);
                    return;
                }
                log.warn("备份方式 [{}] 失败:{}", labels.get(i), error);
                failures.add(labels.get(i) + ":" + error);
            } finally {
                FileUtil.del(tempFile);
            }
        }
        throw new ServiceException(500, "数据库备份失败(已尝试 " + candidates.size() + " 种方式):" + String.join(" | ", failures));
    }
    /**
     * 构建本机 mysqldump 备份命令
     */
    private List<String> buildLocalMysqldumpCommand(String host, String port, String username, String password, String dbName) {
        List<String> command = new ArrayList<>();
        command.add("mysqldump");
        // MySQL 8 caching_sha2_password 认证默认不请求服务端公钥,会报 Access denied (using password: YES)
        command.add("--get-server-public-key");
        command.add("-h" + host);
        command.add("-P" + port);
        command.add("-u" + username);
        if (StrUtil.isEmpty(password)) {
            command.add("--skip-password");
        }
        command.add(dbName);
        return command;
    }
    /**
     * 构建通过 docker exec 调用容器内 mysqldump 的备份命令
     */
    private List<String> buildDockerExecCommand(String container, String username, String password, String dbName) {
        List<String> command = new ArrayList<>();
        command.add("docker");
        command.add("exec");
        // docker exec 不会转发宿主机环境变量,密码需通过 -e 显式传入容器
        if (StrUtil.isNotEmpty(password)) {
            command.add("-e");
            command.add("MYSQL_PWD=" + password);
        }
        command.add(container);
        command.add("mysqldump");
        command.add("--get-server-public-key");
        // 容器内固定走 127.0.0.1:3306 的 TCP 连接自身,绕开 socket/loopback 认证差异
        command.add("-h127.0.0.1");
        command.add("-P" + DOCKER_MYSQL_PORT);
        command.add("-u" + username);
        if (StrUtil.isEmpty(password)) {
            command.add("--skip-password");
        }
        command.add(dbName);
        return command;
    }
    /**
     * 执行 mysqldump 命令,将 stdout 写入临时文件
     *
     * @return 执行成功返回 null;失败返回错误描述
     */
    private String runMysqldump(List<String> command, String password, File outputFile) {
        try {
            // 构建 mysqldump 命令
            String[] command = {
                    "mysqldump",
                    "-h" + host,
                    "-P" + port,
                    "-u" + dbUsername,
                    "-p" + dbPassword,
                    dbName,
                    "-r", tempFile.getAbsolutePath()
            };
            ProcessBuilder pb = new ProcessBuilder(command);
            // 密码通过环境变量传递,避免出现在命令行及进程列表
            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();
            // mysqldump 输出写入本地临时文件(docker exec 场景输出不会落在容器内文件系统)
            try (InputStream in = process.getInputStream();
                 OutputStream out = new FileOutputStream(outputFile)) {
                IoUtil.copy(in, out);
            }
            // 设置响应头
            response.setContentType("application/octet-stream; charset=UTF-8");
            response.setHeader("Content-Disposition", "attachment; filename=\"" + URLEncoder.encode(fileName, "UTF-8") + "\"");
            response.setContentLengthLong(tempFile.length());
            // 写入响应流
            try (FileInputStream fis = new FileInputStream(tempFile);
                 OutputStream os = response.getOutputStream()) {
                IoUtil.copy(fis, os);
            boolean finished = process.waitFor(BACKUP_TIMEOUT_MINUTES, TimeUnit.MINUTES);
            stderrThread.join();
            if (!finished) {
                process.destroyForcibly();
                return "执行超时(超过 " + BACKUP_TIMEOUT_MINUTES + " 分钟)";
            }
        } finally {
            // 删除临时文件
            FileUtil.del(tempFile);
            if (process.exitValue() != 0) {
                return "exitCode=" + process.exitValue() + "," + stderr.toString().trim();
            }
            return null;
        } catch (IOException e) {
            return "启动命令失败: " + e.getMessage();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return "执行被中断: " + e.getMessage();
        }
    }
}
    /**
     * 将备份文件作为附件输出到响应
     */
    private void downloadAttachment(HttpServletResponse response, File file, String fileName) throws IOException {
        response.setContentType("application/octet-stream; charset=UTF-8");
        response.setHeader("Content-Disposition", "attachment; filename=\"" + URLEncoder.encode(fileName, "UTF-8") + "\"");
        response.setContentLengthLong(file.length());
        try (FileInputStream fis = new FileInputStream(file);
             OutputStream os = response.getOutputStream()) {
            IoUtil.copy(fis, os);
        }
    }
    /**
     * 将配置的命令字符串按空白切分为参数列表
     */
    private List<String> splitCommand(String command) {
        List<String> parts = new ArrayList<>();
        for (String token : command.trim().split("\\s+")) {
            parts.add(token);
        }
        return parts;
    }
    /**
     * 检测本机 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;
        }
    }
}