Files
MoviePilot/app/application/backup.py
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

427 lines
14 KiB
Python
Raw 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.
"""
数据库备份与离线还原用例。
"""
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),
)