package cn.iocoder.yudao.module.system.service.database; 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 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; @Value("${spring.datasource.dynamic.datasource.master.username:root}") private String dbUsername; @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 (StrUtil.isEmpty(dbUrl)) { throw new ServiceException(500, "未找到数据库连接配置"); } // 备份连接的数据库信息:优先使用 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 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 { ProcessBuilder pb = new ProcessBuilder(commandParts); // 数据库密码通过环境变量传递,避免出现在命令行及进程列表 if (StrUtil.isNotEmpty(password)) { pb.environment().put("MYSQL_PWD", password); } Process process = pb.start(); // 后台读取 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); } // 设置响应头 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); } } 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 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; } } }