| | |
| | | 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; |
| | |
| | | @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); |
| | | } |
| | | |
| | | // 设置响应头 |
| | |
| | | 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; |
| | | } |
| | | } |
| | | } |