""" 数据库备份与离线还原用例。 """ from __future__ import annotations from dataclasses import dataclass from datetime import datetime, timedelta from pathlib import Path from threading import Lock from typing import Callable, Protocol from app.runtime.log import logger @dataclass(frozen=True, slots=True) class BackupPolicy: """ 一次备份操作使用的目录与保留策略快照。 :param root: 正式备份及临时文件的受管根目录 :param retention_days: 备份保留天数,0 表示不按时间清理,禁止负数 :param max_count: 最多保留的备份数量,0 表示不按数量清理,禁止负数 """ 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: """ 一个已完成校验并发布的数据库备份文件。 :param name: 正式备份的受管文件名,不包含目录 :param db_type: 数据库类型,sqlite 或 postgresql :param created_at: 从受管文件名解析的本地创建时间 :param path: 正式备份文件在受管目录内的路径 :param size: 正式备份文件大小,单位为字节 :param target: 备份所属数据库目标,默认 moviepilot """ name: str db_type: str created_at: datetime path: Path size: int target: str = "moviepilot" @dataclass(frozen=True, slots=True) class BackupVerification: """ 数据库备份文件的基础内容校验结果。 :param valid: 备份内容是否通过当前数据库类型的基础校验 :param method: 实际采用的校验方法标识 :param detail: 校验结果的补充说明,未提供时为 None """ valid: bool method: str detail: str | None = None class DatabaseBackupInProgressError(RuntimeError): """ 同一宿主进程已有数据库备份正在创建。 """ class BackupCheck(Protocol): """ 数据库适配器校验结果的结构合同。 """ @property def valid(self) -> bool: """ 返回制品是否通过校验。 :return: 校验通过时为 True """ @property def method(self) -> str: """ 返回本次校验使用的方法。 :return: 本次实际使用的内容校验方法标识 """ @property def detail(self) -> str | None: """ 返回校验失败详情,无详情时返回空值。 :return: 校验失败或结果详情,无详情时为 None """ class DatabaseBackupBackend(Protocol): """ 活动数据库创建、校验和离线还原所需的最小技术端口。 :ivar db_type: 当前后端的数据库类型,sqlite 或 postgresql :ivar suffix: 该数据库备份格式的文件扩展名,包括前导点 """ db_type: str suffix: str def create(self, destination: Path) -> None: """ 把活动数据库的一致快照写入目标文件。 :param destination: 调用方创建并拥有的临时备份路径 """ def verify(self, artifact: Path) -> BackupCheck: """ 在不修改数据库的前提下校验备份文件。 :param artifact: 待校验的备份文件路径 :return: 校验是否通过、所用方法及失败详情 """ def restore(self, artifact: Path) -> None: """ 把已校验制品还原到离线目标数据库。 :param artifact: 已通过内容校验且与目标数据库类型匹配的备份路径 """ class BackupArtifactStore(Protocol): """ 管理一个备份根目录内正式制品与临时文件的技术端口。 """ def create_temporary(self, suffix: str) -> Path: """ 创建由当前存储实例拥有的私有临时文件。 :param suffix: 数据库备份格式的扩展名,包括前导点 :return: 与正式备份处于同一受管目录的临时文件路径;调用方负责发布或丢弃 """ def publish(self, temporary: Path, name: str) -> Path: """ 把已校验临时文件原子发布为正式制品。 :param temporary: 当前操作拥有且已校验通过的临时文件 :param name: 目标正式制品文件名,不允许包含目录 :return: 发布后的正式备份路径 """ def discard(self, temporary: Path) -> None: """ 清理当前操作拥有的未发布临时文件。 :param temporary: 当前操作拥有的临时文件路径;文件已不存在时可安全重复清理 """ def list(self) -> list[Path]: """ 按创建时间倒序返回当前根目录内的正式制品。 :return: 按创建时间倒序排列的正式备份路径,不包含临时文件 """ def resolve(self, name: str) -> Path: """ 按受限文件名解析一个必须存在的正式制品。 :param name: 符合受管命名规则的备份文件名,不允许包含目录 :return: 受管根目录下已存在的正式备份路径 """ def delete(self, name: str) -> None: """ 删除一个已通过名称约束的正式制品。 :param name: 待删除的正式备份文件名,不允许包含目录 """ def available_name( self, *, db_type: str, created_at: datetime, suffix: str, target: str = "moviepilot", version: str | None = None, ) -> str: """ 返回当前根目录中可用的正式制品文件名。 :param db_type: 备份数据库类型,sqlite 或 postgresql :param created_at: 用于文件命名的本地创建时间 :param suffix: 数据库备份格式扩展名,包括前导点 :param target: 备份所属目标标识,默认 moviepilot :param version: 写入文件名的版本号;None 时主程序目标使用当前版本,其他目标可省略 :return: 当前尚未占用的受管备份文件名,重名时添加序号 """ def database_type(self, name: str) -> str: """ 从受管文件名读取数据库类型。 :param name: 符合受管命名规则的正式备份文件名 :return: 文件名中声明的数据库类型 """ def target(self, name: str) -> str: """ 从受管文件名读取备份目标。 :param name: 符合受管命名规则的正式备份文件名 :return: 文件名中的备份目标;旧格式归属于 moviepilot """ def created_at(self, name: str) -> datetime: """ 从受管文件名读取制品创建时间。 :param name: 符合受管命名规则的正式备份文件名 :return: 文件名中记录的本地创建时间 """ def size(self, name: str) -> int: """ 返回受管正式制品的字节数。 :param name: 符合受管命名规则的正式备份文件名 :return: 正式备份文件的字节数 """ class BackupArtifactStoreFactory(Protocol): """ 按一次策略快照的根目录构造制品存储端口。 """ def __call__(self, root: Path) -> BackupArtifactStore: """ 返回仅能访问指定备份根目录的制品存储。 :param root: 本次备份策略允许访问的根目录 :return: 限制在指定根目录内操作的制品存储实例 """ class DatabaseBackupService: """ 管理单文件数据库备份及明确的离线还原操作。 """ def __init__( self, *, backend: DatabaseBackupBackend, artifact_store_factory: BackupArtifactStoreFactory, policy_reader: Callable[[], BackupPolicy], clock: Callable[[], datetime] = datetime.now, target: str = "moviepilot", version: str | None = None, ) -> None: """ 保存备份端口与动态策略,并建立单次创建操作的互斥边界。 """ self._backend = backend self._artifact_store_factory = artifact_store_factory self._policy_reader = policy_reader self._clock = clock self._target = target self._version = version self._create_lock = Lock() def create(self) -> BackupArtifact: """ 创建、校验并发布一个在线一致快照。 """ if not self._create_lock.acquire(blocking=False): raise DatabaseBackupInProgressError("已有数据库备份任务正在执行") try: policy = self._policy_reader() files = self._artifact_store_factory(policy.root) created_at = self._clock() name = files.available_name( db_type=self._backend.db_type, created_at=created_at, suffix=self._backend.suffix, target=self._target, version=self._version, ) 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(files, 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 finally: self._create_lock.release() def list(self) -> tuple[BackupArtifact, ...]: """ 按创建时间倒序列出受管数据库备份文件。 """ files = self._artifact_store_factory(self._policy_reader().root) return tuple(self._artifact(files, path) for path in files.list()) def verify(self, name: str) -> BackupVerification: """ 按文件名校验一个受管数据库备份。 """ files = self._artifact_store_factory(self._policy_reader().root) path = files.resolve(name) self._require_matching_type(files, path) result = self._backend.verify(path) return BackupVerification(result.valid, result.method, result.detail) def delete(self, name: str) -> None: """ 删除一个受管数据库备份文件。 """ self._artifact_store_factory(self._policy_reader().root).delete(name) def restore(self, name: str) -> BackupArtifact: """ 校验后将受管制品还原到当前 CLI 解析出的离线数据库目标。 """ files = self._artifact_store_factory(self._policy_reader().root) path = files.resolve(name) self._require_matching_type(files, 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(files, path) def _prune( self, files: BackupArtifactStore, 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, files: BackupArtifactStore, path: Path, ) -> None: """ 拒绝把其他数据库类型的制品交给当前后端。 """ db_type = files.database_type(path.name) if db_type != self._backend.db_type: raise ValueError( f"备份类型 {db_type} 与当前数据库类型 {self._backend.db_type} 不一致" ) @staticmethod def _artifact( files: BackupArtifactStore, path: Path, *, created_at: datetime | None = None, ) -> BackupArtifact: """ 通过存储端口把受管路径投影为应用制品。 """ return BackupArtifact( name=path.name, db_type=files.database_type(path.name), created_at=created_at or files.created_at(path.name), path=path, size=files.size(path.name), target=files.target(path.name), )