feat(database): add managed backup and offline restore (#6359)

This commit is contained in:
InfinityPacer
2026-08-19 17:34:32 +08:00
committed by GitHub
parent f4a879b3de
commit f700db5ac8
27 changed files with 1576 additions and 80 deletions
+176
View File
@@ -0,0 +1,176 @@
"""数据库备份与离线还原用例。"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta
from pathlib import Path
from typing import Callable, Protocol
from app.adapters.system.backup.files import BackupFiles
from app.runtime.log import logger
@dataclass(frozen=True, slots=True)
class BackupPolicy:
"""一次备份操作使用的目录与保留策略快照。"""
root: Path
retention_days: int = 0
max_count: int = 0
def __post_init__(self) -> None:
if self.retention_days < 0 or self.max_count < 0:
raise ValueError("数据库备份保留策略不能使用负数")
@dataclass(frozen=True, slots=True)
class BackupArtifact:
"""一个已完成校验并发布的数据库备份文件。"""
name: str
db_type: str
created_at: datetime
path: Path
size: int
@dataclass(frozen=True, slots=True)
class BackupVerification:
"""数据库备份文件的基础内容校验结果。"""
valid: bool
method: str
detail: str | None = None
class BackupCheck(Protocol):
"""数据库适配器校验结果的结构合同。"""
valid: bool
method: str
detail: str | None
class DatabaseBackupBackend(Protocol):
"""活动数据库创建、校验和离线还原所需的最小技术端口。"""
db_type: str
suffix: str
def create(self, destination: Path) -> None:
"""把活动数据库的一致快照写入目标文件。"""
def verify(self, artifact: Path) -> BackupCheck:
"""在不修改数据库的前提下校验备份文件。"""
def restore(self, artifact: Path) -> None:
"""把已校验制品还原到离线目标数据库。"""
class DatabaseBackupService:
"""管理单文件数据库备份及明确的离线还原操作。"""
def __init__(
self,
*,
backend: DatabaseBackupBackend,
policy_reader: Callable[[], BackupPolicy],
clock: Callable[[], datetime] = datetime.now,
) -> None:
self._backend = backend
self._policy_reader = policy_reader
self._clock = clock
def create(self) -> BackupArtifact:
"""创建、校验并发布一个在线一致快照。"""
policy = self._policy_reader()
files = BackupFiles(policy.root)
created_at = self._clock()
name = files.available_name(
db_type=self._backend.db_type,
created_at=created_at,
suffix=self._backend.suffix,
)
temporary = files.create_temporary(self._backend.suffix)
try:
self._backend.create(temporary)
verification = self._backend.verify(temporary)
if not verification.valid:
detail = f"{verification.detail}" if verification.detail else ""
raise RuntimeError(
f"数据库备份校验失败({verification.method}{detail}"
)
path = files.publish(temporary, name)
except Exception:
files.discard(temporary)
raise
artifact = self._artifact(path, created_at=created_at)
self._prune(files, policy, keep=artifact.name)
logger.info(
f"数据库备份完成:文件={artifact.name},类型={artifact.db_type}"
f"大小={artifact.size} bytes"
)
return artifact
def list(self) -> tuple[BackupArtifact, ...]:
"""按创建时间倒序列出受管数据库备份文件。"""
files = BackupFiles(self._policy_reader().root)
return tuple(self._artifact(path) for path in files.list())
def verify(self, name: str) -> BackupVerification:
"""按文件名校验一个受管数据库备份。"""
path = BackupFiles(self._policy_reader().root).resolve(name)
self._require_matching_type(path)
result = self._backend.verify(path)
return BackupVerification(result.valid, result.method, result.detail)
def restore(self, name: str) -> BackupArtifact:
"""校验后将受管制品还原到当前 CLI 解析出的离线数据库目标。"""
path = BackupFiles(self._policy_reader().root).resolve(name)
self._require_matching_type(path)
verification = self._backend.verify(path)
if not verification.valid:
detail = f"{verification.detail}" if verification.detail else ""
raise RuntimeError(
f"数据库备份校验失败({verification.method}{detail}"
)
self._backend.restore(path)
logger.info(
f"数据库离线还原完成:文件={path.name},类型={self._backend.db_type}"
)
return self._artifact(path)
def _prune(self, files: BackupFiles, policy: BackupPolicy, *, keep: str) -> None:
"""新备份发布成功后按天数或份数清理同一目录中的旧文件。"""
cutoff = (
self._clock() - timedelta(days=policy.retention_days)
if policy.retention_days > 0
else None
)
for index, path in enumerate(files.list()):
if path.name == keep:
continue
created_at = files.created_at(path.name)
expired = cutoff is not None and created_at < cutoff
exceeds_count = policy.max_count > 0 and index >= policy.max_count
if expired or exceeds_count:
files.delete(path.name)
def _require_matching_type(self, path: Path) -> None:
db_type = BackupFiles.database_type(path.name)
if db_type != self._backend.db_type:
raise ValueError(
f"备份类型 {db_type} 与当前数据库类型 {self._backend.db_type} 不一致"
)
@staticmethod
def _artifact(path: Path, *, created_at: datetime | None = None) -> BackupArtifact:
return BackupArtifact(
name=path.name,
db_type=BackupFiles.database_type(path.name),
created_at=created_at or BackupFiles.created_at(path.name),
path=path,
size=path.stat().st_size,
)