import gzip import json import os import re import shutil import subprocess from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path from sqlalchemy.engine import make_url from app.core.config import settings from app.core.paths import data_path from app.db.session import engine BACKUP_PREFIX = "certificate_system" BACKUP_TIMEOUT_SECONDS = 600 BACKUP_NAME_PATTERN = re.compile( r"^certificate_system_\d{8}_\d{6}(?:_[a-z0-9_-]+)?\.(?:sql\.gz|sqlite3)$" ) class BackupError(RuntimeError): pass @dataclass(frozen=True) class DatabaseBackup: filename: str database_type: str created_at: datetime size_bytes: int size_label: str reason: str | None = None note: str | None = None def to_dict(self) -> dict[str, object]: return { "filename": self.filename, "database_type": self.database_type, "created_at": self.created_at, "size_bytes": self.size_bytes, "size_label": self.size_label, "reason": self.reason, "note": self.note, } def list_database_backups() -> list[dict[str, object]]: backups = [_backup_info(path).to_dict() for path in _backup_dir().iterdir() if _is_backup_file(path)] return sorted(backups, key=lambda item: item["created_at"], reverse=True) def create_database_backup(reason: str | None = "manual") -> dict[str, object]: kind = _database_kind() created_at = datetime.now(timezone.utc) target = _backup_target(kind, reason, created_at) if kind == "sqlite": _backup_sqlite(target) elif kind == "mysql": _backup_mysql(target) else: raise BackupError(f"当前数据库类型暂不支持后台备份:{kind}") info = _backup_info(target, created_at=created_at, reason=reason, database_type=kind) _write_metadata(target, info) return info.to_dict() def restore_database_backup(filename: str, confirmation: str) -> dict[str, object]: backup_path = _resolve_backup_path(filename) expected_confirmation = f"RESTORE {filename}" if confirmation.strip() != expected_confirmation: raise BackupError(f"确认文本不正确,请输入:{expected_confirmation}") restored_backup = _backup_info(backup_path) kind = _database_kind() if restored_backup.database_type != kind: raise BackupError(f"备份类型是 {restored_backup.database_type},当前数据库类型是 {kind},不能混用恢复") pre_restore_backup = create_database_backup(reason=f"pre_restore_{filename}") engine.dispose() if kind == "sqlite": _restore_sqlite(backup_path) elif kind == "mysql": _restore_mysql(backup_path) else: raise BackupError(f"当前数据库类型暂不支持后台回滚:{kind}") engine.dispose() return { "restored_backup": restored_backup.to_dict(), "pre_restore_backup": pre_restore_backup, } def _backup_dir() -> Path: path = data_path("db-backups") path.mkdir(parents=True, exist_ok=True) return path def _backup_target(kind: str, reason: str | None, created_at: datetime) -> Path: suffix = _reason_suffix(reason) extension = "sqlite3" if kind == "sqlite" else "sql.gz" base_name = f"{BACKUP_PREFIX}_{created_at:%Y%m%d_%H%M%S}{suffix}" target = _backup_dir() / f"{base_name}.{extension}" counter = 2 while target.exists(): target = _backup_dir() / f"{base_name}_{counter}.{extension}" counter += 1 return target def _database_kind() -> str: driver = make_url(settings.database_url).drivername if driver.startswith("sqlite"): return "sqlite" if driver.startswith("mysql"): return "mysql" return driver.split("+", 1)[0] def _backup_sqlite(target: Path) -> None: source = _sqlite_database_path() if not source.exists(): raise BackupError(f"SQLite 数据库文件不存在:{source}") shutil.copy2(source, target) def _restore_sqlite(backup_path: Path) -> None: if backup_path.suffix != ".sqlite3": raise BackupError("SQLite 只能恢复 .sqlite3 备份") target = _sqlite_database_path() target.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(backup_path, target) def _sqlite_database_path() -> Path: url = make_url(settings.database_url) if not url.database: raise BackupError("SQLite DATABASE_URL 缺少数据库文件路径") return Path(url.database).expanduser().resolve() def _backup_mysql(target: Path) -> None: cmd, env = _mysql_command("mysqldump") cmd.extend([ "--single-transaction", "--quick", "--routines", "--triggers", "--events", "--default-character-set=utf8mb4", _mysql_database_name(), ]) try: with gzip.open(target, "wb") as output: subprocess.run( cmd, stdout=output, stderr=subprocess.PIPE, env=env, check=True, timeout=BACKUP_TIMEOUT_SECONDS, ) except FileNotFoundError as exc: raise BackupError("后端环境缺少 mysqldump 命令,请重新构建 backend 镜像") from exc except subprocess.CalledProcessError as exc: target.unlink(missing_ok=True) raise BackupError(_subprocess_error("数据库备份失败", exc)) from exc except subprocess.TimeoutExpired as exc: target.unlink(missing_ok=True) raise BackupError("数据库备份超时,请稍后重试或检查数据库连接") from exc def _restore_mysql(backup_path: Path) -> None: if not backup_path.name.endswith(".sql.gz"): raise BackupError("MySQL 只能恢复 .sql.gz 备份") cmd, env = _mysql_command("mysql") cmd.append(_mysql_database_name()) try: with gzip.open(backup_path, "rb") as dump: subprocess.run( cmd, stdin=dump, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env, check=True, timeout=BACKUP_TIMEOUT_SECONDS, ) except FileNotFoundError as exc: raise BackupError("后端环境缺少 mysql 命令,请重新构建 backend 镜像") from exc except subprocess.CalledProcessError as exc: raise BackupError(_subprocess_error("数据库回滚失败", exc)) from exc except subprocess.TimeoutExpired as exc: raise BackupError("数据库回滚超时,请检查数据库连接和备份文件大小") from exc def _mysql_command(binary: str) -> tuple[list[str], dict[str, str]]: url = make_url(settings.database_url) command = [ binary, "-h", url.host or "127.0.0.1", "-P", str(url.port or 3306), ] if url.username: command.extend(["-u", url.username]) env = os.environ.copy() if url.password: env["MYSQL_PWD"] = url.password return command, env def _mysql_database_name() -> str: database = make_url(settings.database_url).database if not database: raise BackupError("MySQL DATABASE_URL 缺少数据库名") return database def _resolve_backup_path(filename: str) -> Path: if not BACKUP_NAME_PATTERN.match(filename): raise BackupError("备份文件名不合法") path = (_backup_dir() / filename).resolve() if path.parent != _backup_dir().resolve() or not path.exists() or not path.is_file(): raise BackupError("备份文件不存在") return path def _is_backup_file(path: Path) -> bool: return path.is_file() and BACKUP_NAME_PATTERN.match(path.name) is not None def _backup_info( path: Path, created_at: datetime | None = None, reason: str | None = None, database_type: str | None = None, ) -> DatabaseBackup: metadata = _read_metadata(path) stat = path.stat() meta_created_at = _parse_datetime(metadata.get("created_at")) if metadata else None return DatabaseBackup( filename=path.name, database_type=database_type or str(metadata.get("database_type") or _type_from_extension(path)), created_at=created_at or meta_created_at or datetime.fromtimestamp(stat.st_mtime, timezone.utc), size_bytes=stat.st_size, size_label=_size_label(stat.st_size), reason=reason if reason is not None else metadata.get("reason"), note=metadata.get("note"), ) def _write_metadata(path: Path, info: DatabaseBackup) -> None: payload = info.to_dict() payload["created_at"] = info.created_at.isoformat() _metadata_path(path).write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") def _read_metadata(path: Path) -> dict[str, object]: metadata_path = _metadata_path(path) if not metadata_path.exists(): return {} try: payload = json.loads(metadata_path.read_text(encoding="utf-8")) except Exception: return {} return payload if isinstance(payload, dict) else {} def _metadata_path(path: Path) -> Path: return path.with_name(f"{path.name}.json") def _type_from_extension(path: Path) -> str: if path.name.endswith(".sql.gz"): return "mysql" if path.suffix == ".sqlite3": return "sqlite" return "unknown" def _parse_datetime(value: object) -> datetime | None: if not isinstance(value, str): return None try: parsed = datetime.fromisoformat(value) except ValueError: return None if parsed.tzinfo is None: return parsed.replace(tzinfo=timezone.utc) return parsed def _size_label(size_bytes: int) -> str: value = float(size_bytes) for unit in ["B", "KB", "MB", "GB"]: if value < 1024 or unit == "GB": return f"{value:.1f} {unit}" if unit != "B" else f"{int(value)} B" value /= 1024 return f"{size_bytes} B" def _reason_suffix(reason: str | None) -> str: cleaned = re.sub(r"[^a-z0-9_-]+", "_", (reason or "manual").strip().lower()).strip("_") if not cleaned or cleaned == "manual": return "" return f"_{cleaned[:48]}" def _subprocess_error(prefix: str, exc: subprocess.CalledProcessError) -> str: stderr = (exc.stderr or b"").decode("utf-8", errors="replace").strip() return f"{prefix}:{stderr or '命令执行失败'}"