458cc42dd5
Co-authored-by: Cursor <cursoragent@cursor.com>
155 lines
3.9 KiB
Python
155 lines
3.9 KiB
Python
"""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
|