mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-04 23:17:20 +08:00
feat(database): add managed backup and offline restore (#6359)
This commit is contained in:
@@ -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,
|
||||
)
|
||||
+67
-14
@@ -1,19 +1,25 @@
|
||||
"""数据库连通性应用服务。"""
|
||||
"""数据库健康、清理、备份与离线还原的统一应用门面。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Optional
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.application.backup import (
|
||||
BackupArtifact,
|
||||
BackupVerification,
|
||||
DatabaseBackupService,
|
||||
)
|
||||
from app.application.maintenance import DataCleanupService
|
||||
|
||||
|
||||
DatabaseProbe = Callable[[], Optional[str]]
|
||||
|
||||
|
||||
class DatabaseHealthService:
|
||||
"""为模块和诊断入口提供不暴露会话实现的数据库探测能力。"""
|
||||
"""提供不暴露会话实现的数据库探测能力。"""
|
||||
|
||||
def __init__(self, probe: DatabaseProbe) -> None:
|
||||
"""保存由组合根提供的数据库探测端口。"""
|
||||
self._probe = probe
|
||||
|
||||
def test(self) -> Optional[str]:
|
||||
@@ -21,17 +27,64 @@ class DatabaseHealthService:
|
||||
return self._probe()
|
||||
|
||||
|
||||
_configured_database_health: DatabaseHealthService | None = None
|
||||
class DatabaseGovernance:
|
||||
"""向宿主入口提供唯一的数据库治理能力入口。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
health: DatabaseHealthService,
|
||||
cleanup: DataCleanupService,
|
||||
backup: DatabaseBackupService,
|
||||
) -> None:
|
||||
self._health = health
|
||||
self._cleanup = cleanup
|
||||
self._backup = backup
|
||||
|
||||
def test(self) -> Optional[str]:
|
||||
"""探测当前活动数据库。"""
|
||||
return self._health.test()
|
||||
|
||||
def cleanup(
|
||||
self,
|
||||
*,
|
||||
batch_size: int | None = None,
|
||||
progress_callback: Callable[..., None] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""按当前配置执行数据表清理。"""
|
||||
return self._cleanup.execute(
|
||||
batch_size=batch_size,
|
||||
progress_callback=progress_callback,
|
||||
)
|
||||
|
||||
def create_backup(self) -> BackupArtifact:
|
||||
"""创建一个当前活动数据库的一致备份。"""
|
||||
return self._backup.create()
|
||||
|
||||
def list_backups(self) -> tuple[BackupArtifact, ...]:
|
||||
"""列出受管数据库备份文件。"""
|
||||
return self._backup.list()
|
||||
|
||||
def verify_backup(self, name: str) -> BackupVerification:
|
||||
"""校验一个受管数据库备份文件。"""
|
||||
return self._backup.verify(name)
|
||||
|
||||
def restore_backup(self, name: str) -> BackupArtifact:
|
||||
"""在离线 CLI 进程中还原一个受管数据库备份。"""
|
||||
return self._backup.restore(name)
|
||||
|
||||
|
||||
def configure_database_health(service: DatabaseHealthService) -> None:
|
||||
"""由启动组合根登记数据库探测服务。"""
|
||||
global _configured_database_health
|
||||
_configured_database_health = service
|
||||
_DATABASE_GOVERNANCE: list[DatabaseGovernance] = []
|
||||
|
||||
|
||||
def get_configured_database_health() -> DatabaseHealthService:
|
||||
"""返回启动阶段登记的数据库探测服务。"""
|
||||
if _configured_database_health is None:
|
||||
raise RuntimeError("数据库探测服务尚未配置")
|
||||
return _configured_database_health
|
||||
def configure_database_governance(governance: DatabaseGovernance) -> None:
|
||||
"""由启动组合根登记宿主唯一的数据库治理门面。"""
|
||||
_DATABASE_GOVERNANCE.clear()
|
||||
_DATABASE_GOVERNANCE.append(governance)
|
||||
|
||||
|
||||
def get_database_governance() -> DatabaseGovernance:
|
||||
"""返回启动阶段登记的数据库治理门面。"""
|
||||
if not _DATABASE_GOVERNANCE:
|
||||
raise RuntimeError("数据库治理服务尚未配置")
|
||||
return _DATABASE_GOVERNANCE[0]
|
||||
|
||||
@@ -324,24 +324,6 @@ def read_cleanup_policy() -> CleanupPolicy:
|
||||
)
|
||||
|
||||
|
||||
def build_cleanup_service() -> DataCleanupService:
|
||||
"""返回启动组合根登记的清理服务。"""
|
||||
if _configured_cleanup_service_factory is None:
|
||||
raise RuntimeError("数据清理服务尚未配置")
|
||||
return _configured_cleanup_service_factory()
|
||||
|
||||
|
||||
_configured_cleanup_service_factory: Callable[[], DataCleanupService] | None = None
|
||||
|
||||
|
||||
def configure_cleanup_service_factory(
|
||||
factory: Callable[[], DataCleanupService],
|
||||
) -> None:
|
||||
"""由启动组合根登记数据清理服务工厂。"""
|
||||
global _configured_cleanup_service_factory
|
||||
_configured_cleanup_service_factory = factory
|
||||
|
||||
|
||||
def _normalize_days(retention_days: Any) -> int:
|
||||
"""把配置保留期规范为非负整数,非法值按关闭单表清理处理。"""
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user