"""SQLite 在线备份与恢复。""" from __future__ import annotations import logging import re import sqlite3 from datetime import datetime from pathlib import Path from zoneinfo import ZoneInfo log = logging.getLogger("backup") _BACKUP_NAME_RE = re.compile(r"^market_intel_\d{8}_\d{6}\.db$") DISPLAY_LIMIT = 5 def _now_stamp(tz: str = "Asia/Shanghai") -> str: return datetime.now(ZoneInfo(tz)).strftime("%Y%m%d_%H%M%S") def ensure_backup_dir(path: Path) -> Path: path.mkdir(parents=True, exist_ok=True) return path def is_valid_backup_name(name: str) -> bool: return bool(_BACKUP_NAME_RE.fullmatch(name)) def resolve_backup_file(backup_dir: Path, name: str) -> Path: if not is_valid_backup_name(name): raise ValueError("invalid backup filename") path = (backup_dir / name).resolve() root = backup_dir.resolve() if not str(path).startswith(str(root)): raise ValueError("invalid backup path") if not path.is_file(): raise FileNotFoundError(name) return path def backup_db(db_path: Path, backup_dir: Path, tz: str = "Asia/Shanghai") -> Path: ensure_backup_dir(backup_dir) if not db_path.is_file(): raise FileNotFoundError(f"database not found: {db_path}") dest = backup_dir / f"market_intel_{_now_stamp(tz)}.db" src = sqlite3.connect(str(db_path)) try: dst = sqlite3.connect(str(dest)) try: src.backup(dst) finally: dst.close() finally: src.close() return dest def restore_db(backup_path: Path, db_path: Path) -> None: if not backup_path.is_file(): raise FileNotFoundError(f"backup not found: {backup_path}") src = sqlite3.connect(str(backup_path)) try: dst = sqlite3.connect(str(db_path)) try: src.backup(dst) finally: dst.close() finally: src.close() def list_backups(backup_dir: Path, limit: int | None = None) -> list[dict]: if not backup_dir.is_dir(): return [] items: list[dict] = [] for p in sorted(backup_dir.glob("market_intel_*.db"), reverse=True): if not p.is_file() or not is_valid_backup_name(p.name): continue st = p.stat() items.append( { "name": p.name, "size_bytes": st.st_size, "modified_ms": int(st.st_mtime * 1000), } ) if limit is not None and len(items) >= limit: break return items def prune_backups(backup_dir: Path, keep: int) -> int: """删除超出保留数量的旧备份,返回删除数。""" if keep < 1: return 0 if not backup_dir.is_dir(): return 0 files = sorted( [ p for p in backup_dir.glob("market_intel_*.db") if p.is_file() and is_valid_backup_name(p.name) ], key=lambda p: p.stat().st_mtime, reverse=True, ) removed = 0 for p in files[keep:]: try: p.unlink() removed += 1 except OSError as e: log.warning("failed to prune %s: %s", p, e) return removed def latest_backup_mtime_ms(backup_dir: Path) -> int | None: items = list_backups(backup_dir, limit=1) if not items: return None return int(items[0]["modified_ms"]) def should_auto_backup( backup_dir: Path, *, enabled: bool, interval_hours: float, ) -> bool: if not enabled: return False if interval_hours <= 0: return False latest = latest_backup_mtime_ms(backup_dir) if latest is None: return True age_ms = int(datetime.now().timestamp() * 1000) - latest return age_ms >= interval_hours * 3600 * 1000 def run_auto_backup( db_path: Path, backup_dir: Path, *, tz: str = "Asia/Shanghai", keep: int = 30, ) -> Path: dest = backup_db(db_path, backup_dir, tz=tz) prune_backups(backup_dir, keep) return dest