320 lines
10 KiB
Python
320 lines
10 KiB
Python
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 '命令执行失败'}"
|