mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 15:38:19 +08:00
feat(database): add managed backup and offline restore (#6359)
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
"""数据库备份单文件的受限文件系统操作。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
_BACKUP_NAME = re.compile(
|
||||
r"^(?P<db_type>sqlite|postgresql)_"
|
||||
r"(?P<timestamp>\d{8}_\d{6})"
|
||||
r"(?:_(?P<sequence>\d+))?"
|
||||
r"(?P<suffix>\.db|\.dump)$"
|
||||
)
|
||||
|
||||
|
||||
class BackupFiles:
|
||||
"""把备份文件操作限制在一个私有根目录内。"""
|
||||
|
||||
def __init__(self, root: Path) -> None:
|
||||
self.root = Path(root)
|
||||
|
||||
def create_temporary(self, suffix: str) -> Path:
|
||||
"""在最终目录内创建私有临时文件,保证发布可使用原子替换。"""
|
||||
self._ensure_root()
|
||||
descriptor, filename = tempfile.mkstemp(
|
||||
prefix=".database-",
|
||||
suffix=f"{suffix}.partial",
|
||||
dir=self.root,
|
||||
)
|
||||
os.close(descriptor)
|
||||
path = Path(filename)
|
||||
path.chmod(0o600)
|
||||
return path
|
||||
|
||||
def publish(self, temporary: Path, name: str) -> Path:
|
||||
"""把已校验临时文件发布为正式备份文件。"""
|
||||
destination = self._resolve_name(name, require_exists=False)
|
||||
os.replace(temporary, destination)
|
||||
destination.chmod(0o600)
|
||||
return destination
|
||||
|
||||
def discard(self, temporary: Path) -> None:
|
||||
"""清理本次操作拥有的未发布临时文件。"""
|
||||
path = Path(temporary)
|
||||
if path.parent == self.root and path.name.startswith(".database-"):
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
def list(self) -> list[Path]:
|
||||
"""返回当前根目录内格式合法的正式备份文件。"""
|
||||
if not self.root.is_dir():
|
||||
return []
|
||||
paths = [
|
||||
path
|
||||
for path in self.root.iterdir()
|
||||
if path.is_file() and _BACKUP_NAME.fullmatch(path.name)
|
||||
]
|
||||
return sorted(paths, key=lambda path: (self.created_at(path.name), path.name), reverse=True)
|
||||
|
||||
def resolve(self, name: str) -> Path:
|
||||
"""按受限文件名解析一个必须存在的备份文件。"""
|
||||
return self._resolve_name(name, require_exists=True)
|
||||
|
||||
def delete(self, name: str) -> None:
|
||||
"""删除一个已通过名称约束的备份文件。"""
|
||||
self.resolve(name).unlink()
|
||||
|
||||
def available_name(self, *, db_type: str, created_at: datetime, suffix: str) -> str:
|
||||
"""生成包含数据库类型和秒级时间的简短可读文件名。"""
|
||||
timestamp = created_at.strftime("%Y%m%d_%H%M%S")
|
||||
base = f"{db_type}_{timestamp}"
|
||||
candidate = f"{base}{suffix}"
|
||||
sequence = 1
|
||||
while (self.root / candidate).exists():
|
||||
candidate = f"{base}_{sequence}{suffix}"
|
||||
sequence += 1
|
||||
if not _BACKUP_NAME.fullmatch(candidate):
|
||||
raise ValueError("数据库备份文件名无效")
|
||||
return candidate
|
||||
|
||||
@staticmethod
|
||||
def database_type(name: str) -> str:
|
||||
"""从受管文件名读取数据库类型。"""
|
||||
return BackupFiles._match(name).group("db_type")
|
||||
|
||||
@staticmethod
|
||||
def created_at(name: str) -> datetime:
|
||||
"""从受管文件名读取本地创建时间。"""
|
||||
return datetime.strptime(
|
||||
BackupFiles._match(name).group("timestamp"),
|
||||
"%Y%m%d_%H%M%S",
|
||||
)
|
||||
|
||||
def _ensure_root(self) -> None:
|
||||
self.root.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
self.root.chmod(0o700)
|
||||
|
||||
def _resolve_name(self, name: str, *, require_exists: bool) -> Path:
|
||||
normalized = str(name).strip()
|
||||
self._match(normalized)
|
||||
if Path(normalized).name != normalized:
|
||||
raise ValueError("数据库备份文件名不能包含路径")
|
||||
path = self.root / normalized
|
||||
if require_exists and not path.is_file():
|
||||
raise FileNotFoundError(normalized)
|
||||
return path
|
||||
|
||||
@staticmethod
|
||||
def _match(name: str) -> re.Match[str]:
|
||||
matched = _BACKUP_NAME.fullmatch(str(name))
|
||||
if matched is None:
|
||||
raise ValueError("数据库备份文件名无效")
|
||||
return matched
|
||||
Reference in New Issue
Block a user