| | |
| | | throw new ServiceException(500, "无法解析数据库备份连接信息,请在 yudao.database-backup 中配置 host/port/database"); |
| | | } |
| | | // localhost 统一走 TCP 127.0.0.1:mysqldump 对 localhost 可能走 socket/loopback, |
| | | // 而容器内 root@localhost 认证要求与 TCP root@'%' 不同,强制 TCP 避免认证失败 |
| | | // 而不同环境 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; |
| | | |
| | | // 检测部署环境,构建备份命令 |
| | | List<String> commandParts = new ArrayList<>(); |
| | | String connectHost = dbHost; |
| | | String connectPort = dbPort; |
| | | // 组装候选备份命令,按优先级依次尝试:手动配置 > 本机 mysqldump > Docker 容器 mysqldump |
| | | List<List<String>> candidates = new ArrayList<>(); |
| | | List<String> labels = new ArrayList<>(); |
| | | if (StrUtil.isNotEmpty(backupCommand)) { |
| | | // 手动指定命令,优先使用 |
| | | for (String token : backupCommand.trim().split("\\s+")) { |
| | | commandParts.add(token); |
| | | candidates.add(splitCommand(backupCommand)); |
| | | labels.add("手动配置命令[" + backupCommand + "]"); |
| | | } |
| | | log.info("使用手动配置的备份命令: {}", backupCommand); |
| | | } else if (isLocalMysqldumpAvailable()) { |
| | | // 本地部署:本机已安装 mysqldump |
| | | commandParts.add("mysqldump"); |
| | | log.info("检测到本机 mysqldump,使用本地备份命令"); |
| | | } else { |
| | | // Docker 部署:查找运行中的 MySQL 容器,通过 docker exec 调用容器内 mysqldump |
| | | if (isLocalMysqldumpAvailable()) { |
| | | candidates.add(buildLocalMysqldumpCommand(dbHost, dbPort, username, password, dbName)); |
| | | labels.add("本机 mysqldump"); |
| | | } |
| | | String container = findMysqlDockerContainer(dbPort); |
| | | if (StrUtil.isEmpty(container)) { |
| | | throw new ServiceException(500, "未检测到可用的 mysqldump 环境:本机未安装 MySQL 客户端," |
| | | + "也未发现运行中的 MySQL Docker 容器。可通过配置 yudao.database-backup.command 手动指定备份命令"); |
| | | if (StrUtil.isNotEmpty(container)) { |
| | | candidates.add(buildDockerExecCommand(container, username, password, dbName)); |
| | | labels.add("Docker 容器[" + container + "] mysqldump"); |
| | | } |
| | | commandParts.add("docker"); |
| | | commandParts.add("exec"); |
| | | // docker exec 不会转发宿主机环境变量,密码需通过 -e 显式传入容器 |
| | | if (StrUtil.isNotEmpty(password)) { |
| | | commandParts.add("-e"); |
| | | commandParts.add("MYSQL_PWD=" + password); |
| | | if (candidates.isEmpty()) { |
| | | throw new ServiceException(500, "未检测到可用的 mysqldump 环境:本机未安装 MySQL 客户端,也没有运行中的 MySQL Docker 容器。" |
| | | + "请安装 MySQL 客户端,或通过 yudao.database-backup.command 手动指定备份命令(如 docker exec mysql8 mysqldump)"); |
| | | } |
| | | 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); |
| | | log.info("数据库备份候选方式:{}", labels); |
| | | |
| | | // 依次尝试所有方式,全部失败才报错 |
| | | List<String> failures = new ArrayList<>(); |
| | | String timestamp = DateUtil.format(new Date(), "yyyyMMdd_HHmmss"); |
| | | String fileName = "backup_" + timestamp + ".sql"; |
| | | for (int i = 0; i < candidates.size(); i++) { |
| | | File tempFile = File.createTempFile("backup_", ".sql"); |
| | | |
| | | try { |
| | | ProcessBuilder pb = new ProcessBuilder(commandParts); |
| | | // 数据库密码通过环境变量传递,避免出现在命令行及进程列表 |
| | | 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 { |
| | | ProcessBuilder pb = new ProcessBuilder(command); |
| | | // 密码通过环境变量传递,避免出现在命令行及进程列表 |
| | | if (StrUtil.isNotEmpty(password)) { |
| | | pb.environment().put("MYSQL_PWD", password); |
| | | } |
| | |
| | | }); |
| | | stderrThread.start(); |
| | | |
| | | // stdout 写入本地临时文件(docker exec 场景输出不会落在容器内文件系统) |
| | | // mysqldump 输出写入本地临时文件(docker exec 场景输出不会落在容器内文件系统) |
| | | try (InputStream in = process.getInputStream(); |
| | | OutputStream out = new FileOutputStream(tempFile)) { |
| | | OutputStream out = new FileOutputStream(outputFile)) { |
| | | IoUtil.copy(in, out); |
| | | } |
| | | |
| | |
| | | stderrThread.join(); |
| | | if (!finished) { |
| | | process.destroyForcibly(); |
| | | throw new ServiceException(500, "数据库备份超时(超过 " + BACKUP_TIMEOUT_MINUTES + " 分钟)"); |
| | | return "执行超时(超过 " + BACKUP_TIMEOUT_MINUTES + " 分钟)"; |
| | | } |
| | | if (process.exitValue() != 0) { |
| | | log.error("数据库备份失败, exitCode: {}, error: {}", process.exitValue(), stderr); |
| | | throw new ServiceException(500, "数据库备份失败: " + stderr); |
| | | 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(tempFile.length()); |
| | | |
| | | // 写入响应流 |
| | | try (FileInputStream fis = new FileInputStream(tempFile); |
| | | response.setContentLengthLong(file.length()); |
| | | try (FileInputStream fis = new FileInputStream(file); |
| | | OutputStream os = response.getOutputStream()) { |
| | | IoUtil.copy(fis, os); |
| | | } |
| | | } finally { |
| | | // 删除临时文件 |
| | | FileUtil.del(tempFile); |
| | | } |
| | | |
| | | /** |
| | | * 将配置的命令字符串按空白切分为参数列表 |
| | | */ |
| | | private List<String> splitCommand(String command) { |
| | | List<String> parts = new ArrayList<>(); |
| | | for (String token : command.trim().split("\\s+")) { |
| | | parts.add(token); |
| | | } |
| | | return parts; |
| | | } |
| | | |
| | | /** |