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<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 {
|
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<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;
|
}
|
}
|
}
|