"""数据库备份与离线还原用例。""" 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, )