| | |
| | | import cn.hutool.core.date.DateUtil; |
| | | import cn.hutool.core.io.FileUtil; |
| | | import cn.hutool.core.io.IoUtil; |
| | | import cn.hutool.core.util.HexUtil; |
| | | import cn.hutool.core.util.StrUtil; |
| | | import cn.iocoder.yudao.framework.common.exception.ServiceException; |
| | | import jakarta.annotation.Resource; |
| | | import jakarta.servlet.http.HttpServletResponse; |
| | | import lombok.extern.slf4j.Slf4j; |
| | | import org.springframework.beans.factory.annotation.Value; |
| | | import org.springframework.stereotype.Service; |
| | | |
| | | import javax.sql.DataSource; |
| | | import java.io.BufferedReader; |
| | | import java.io.BufferedWriter; |
| | | import java.io.File; |
| | | import java.io.FileInputStream; |
| | | import java.io.FileOutputStream; |
| | |
| | | import java.io.InputStream; |
| | | import java.io.InputStreamReader; |
| | | import java.io.OutputStream; |
| | | import java.io.OutputStreamWriter; |
| | | import java.net.URLEncoder; |
| | | import java.nio.charset.StandardCharsets; |
| | | import java.sql.Connection; |
| | | import java.sql.ResultSet; |
| | | import java.sql.ResultSetMetaData; |
| | | import java.sql.Statement; |
| | | import java.sql.Types; |
| | | import java.util.ArrayList; |
| | | import java.util.Date; |
| | | import java.util.List; |
| | |
| | | /** 备份密码,留空则使用数据源密码 */ |
| | | @Value("${yudao.database-backup.password:}") |
| | | private String backupPassword; |
| | | |
| | | /** 主数据源,用于 JDBC 导出兜底 */ |
| | | @Resource |
| | | private DataSource dataSource; |
| | | |
| | | @Override |
| | | public void backupDatabase(HttpServletResponse response) throws Exception { |
| | |
| | | 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"; |
| | |
| | | FileUtil.del(tempFile); |
| | | } |
| | | } |
| | | throw new ServiceException(500, "数据库备份失败(已尝试 " + candidates.size() + " 种方式):" + String.join(" | ", failures)); |
| | | |
| | | // 外部命令全部失败或不可用时,使用 JDBC 导出兜底,保证任何部署环境(如应用容器内无 mysqldump/docker)都能备份 |
| | | File tempFile = File.createTempFile("backup_", ".sql"); |
| | | try { |
| | | String error = exportByJdbc(tempFile); |
| | | if (error == null) { |
| | | log.info("数据库备份成功,使用方式:JDBC 导出兜底"); |
| | | downloadAttachment(response, tempFile, fileName); |
| | | return; |
| | | } |
| | | log.warn("JDBC 导出兜底失败:{}", error); |
| | | failures.add("JDBC 导出兜底:" + error); |
| | | } finally { |
| | | FileUtil.del(tempFile); |
| | | } |
| | | throw new ServiceException(500, "数据库备份失败:" + String.join(" | ", failures)); |
| | | } |
| | | |
| | | /** |
| | | * 通过 JDBC 直连数据库导出建表语句与全量数据,作为外部命令不可用时的兜底备份方式 |
| | | * |
| | | * @return 执行成功返回 null;失败返回错误描述 |
| | | */ |
| | | private String exportByJdbc(File outputFile) { |
| | | try (Connection conn = dataSource.getConnection(); |
| | | BufferedWriter writer = new BufferedWriter( |
| | | new OutputStreamWriter(new FileOutputStream(outputFile), StandardCharsets.UTF_8))) { |
| | | writer.write("/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;\n"); |
| | | writer.write("/*!40101 SET NAMES utf8mb4 */;\n"); |
| | | writer.write("SET TIME_ZONE='+00:00';\n"); |
| | | writer.write("SET FOREIGN_KEY_CHECKS=0;\n"); |
| | | writer.write("SET UNIQUE_CHECKS=0;\n"); |
| | | writer.write("SET SQL_MODE='NO_AUTO_VALUE_ON_ZERO';\n\n"); |
| | | try (Statement st = conn.createStatement(); |
| | | ResultSet rs = st.executeQuery("SHOW TABLES")) { |
| | | List<String> tables = new ArrayList<>(); |
| | | while (rs.next()) { |
| | | tables.add(rs.getString(1)); |
| | | } |
| | | for (String table : tables) { |
| | | exportTable(conn, writer, table); |
| | | } |
| | | } |
| | | writer.write("\nSET FOREIGN_KEY_CHECKS=1;\n"); |
| | | writer.write("SET UNIQUE_CHECKS=1;\n"); |
| | | writer.write("/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;\n"); |
| | | return null; |
| | | } catch (Exception e) { |
| | | log.error("JDBC 导出兜底失败", e); |
| | | return "JDBC 导出异常: " + e.getMessage(); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 导出单张表的建表语句与全部数据 |
| | | */ |
| | | private void exportTable(Connection conn, BufferedWriter writer, String table) throws Exception { |
| | | String quoted = "`" + table.replace("`", "``") + "`"; |
| | | // 建表语句 |
| | | try (Statement st = conn.createStatement(); |
| | | ResultSet rs = st.executeQuery("SHOW CREATE TABLE " + quoted)) { |
| | | if (rs.next()) { |
| | | writer.write(rs.getString(2)); |
| | | writer.write(";\n\n"); |
| | | } |
| | | } |
| | | // 全量数据,流式读取避免大表占用过多内存 |
| | | try (Statement st = conn.createStatement()) { |
| | | st.setFetchSize(Integer.MIN_VALUE); |
| | | try (ResultSet rs = st.executeQuery("SELECT * FROM " + quoted)) { |
| | | ResultSetMetaData meta = rs.getMetaData(); |
| | | int columnCount = meta.getColumnCount(); |
| | | int[] types = new int[columnCount]; |
| | | for (int i = 0; i < columnCount; i++) { |
| | | types[i] = meta.getColumnType(i + 1); |
| | | } |
| | | while (rs.next()) { |
| | | StringBuilder sb = new StringBuilder("INSERT INTO ").append(quoted).append(" VALUES ("); |
| | | for (int i = 0; i < columnCount; i++) { |
| | | if (i > 0) { |
| | | sb.append(','); |
| | | } |
| | | if (rs.getObject(i + 1) == null) { |
| | | sb.append("NULL"); |
| | | continue; |
| | | } |
| | | int type = types[i]; |
| | | if (type == Types.BIT) { |
| | | sb.append(rs.getBoolean(i + 1) ? '1' : '0'); |
| | | } else if (isBinaryType(type)) { |
| | | sb.append("X'").append(HexUtil.encodeHexStr(rs.getBytes(i + 1))).append("'"); |
| | | } else { |
| | | sb.append('\'').append(escapeSql(rs.getString(i + 1))).append('\''); |
| | | } |
| | | } |
| | | sb.append(");\n"); |
| | | writer.write(sb.toString()); |
| | | } |
| | | } |
| | | } |
| | | writer.write('\n'); |
| | | } |
| | | |
| | | /** |
| | | * 是否二进制列类型,二进制数据以十六进制字面量导出避免乱码 |
| | | */ |
| | | private boolean isBinaryType(int type) { |
| | | return type == Types.BINARY || type == Types.VARBINARY |
| | | || type == Types.LONGVARBINARY || type == Types.BLOB; |
| | | } |
| | | |
| | | /** |
| | | * SQL 字符串字面量转义 |
| | | */ |
| | | private String escapeSql(String value) { |
| | | return value.replace("\\", "\\\\").replace("'", "''").replace("\0", "\\0"); |
| | | } |
| | | |
| | | /** |