Files

320 lines
10 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 '命令执行失败'}"