"""自动备份与恢复演练(交付报告 P0-5)。 设计: - 备份内容:SQLite 数据库(backup API 在线快照)+ 知识库原文(kb 目录)打包为 zip; - 触发时机:服务启动时一次 + 每 6 小时周期一次(守护线程); - 保留策略:默认保留最近 14 份,超限删最旧; - 完整性校验:每次备份后验证 zip 可打开且包含数据库文件; - 恢复演练:verify_restore 对最新备份做真实恢复测试——在临时目录解压, 用 SQLite 打开并查询关键表,验证备份可用(不是只看文件存在); - 运营看板展示最近备份时间/份数/最近一次恢复演练结果。 """ from __future__ import annotations import logging import shutil import sqlite3 import zipfile from datetime import datetime from pathlib import Path from .config import DATA_DIR, KB_DIR logger = logging.getLogger("yawen.backup") BACKUP_DIR = DATA_DIR / "backups" KEEP_COUNT = 14 DB_NAME = "workspace.db" def _db_path() -> Path: return DATA_DIR / DB_NAME def create_backup(tag: str = "auto") -> Path | None: """创建一次备份,返回备份文件路径;数据库不存在时返回 None。""" db_path = _db_path() if not db_path.exists(): return None BACKUP_DIR.mkdir(parents=True, exist_ok=True) stamp = datetime.now().strftime("%Y%m%d-%H%M%S") target = BACKUP_DIR / f"backup-{tag}-{stamp}.zip" tmp = target.with_suffix(".zip.tmp") try: with zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED, compresslevel=1) as zf: # SQLite 在线备份 API:一致性快照,不锁库不中断业务 snap_target = tmp.with_suffix(".db.snapshot") src = sqlite3.connect(str(db_path)) dst = sqlite3.connect(str(snap_target)) try: src.backup(dst) finally: dst.close() src.close() zf.write(snap_target, DB_NAME) snap_target.unlink(missing_ok=True) # 知识库原文(可选,体积可能大;跳过 vector 索引可重建) if KB_DIR.exists(): for file in sorted(KB_DIR.rglob("*")): if file.is_file() and "chroma" not in file.parts and "__pycache__" not in file.parts: zf.write(file, f"kb/{file.relative_to(KB_DIR)}") tmp.replace(target) except Exception as exc: tmp.unlink(missing_ok=True) logger.error("备份失败:%s", exc) return None _prune_old_backups() ok, detail = verify_backup(target) if not ok: logger.error("备份完整性校验失败 %s:%s", target.name, detail) return target def _prune_old_backups() -> int: """保留最近 KEEP_COUNT 份,返回删除数。""" files = sorted(BACKUP_DIR.glob("backup-*.zip"), key=lambda p: p.stat().st_mtime) removed = 0 for old in files[:-KEEP_COUNT]: old.unlink(missing_ok=True) removed += 1 return removed def _backup_dir() -> Path: """动态取备份目录(测试会替换 BACKUP_DIR)。""" return BACKUP_DIR def verify_backup(path: Path) -> tuple[bool, str]: """校验备份 zip:能打开、含数据库、SQLite 能读关键表。""" try: with zipfile.ZipFile(path) as zf: names = zf.namelist() if DB_NAME not in names: return False, "缺少数据库文件" bad = zf.testzip() if bad is not None: return False, f"损坏条目:{bad}" db_bytes = zf.read(DB_NAME) # 二进制安全打开:deserialize 直接从字节恢复内存库,避免文本解码产生 NUL mem = sqlite3.connect(":memory:") mem.deserialize(db_bytes) try: tables = {row[0] for row in mem.execute( "SELECT name FROM sqlite_master WHERE type='table'").fetchall()} required = {"campaign", "agent", "appuser"} missing = required - tables if missing: return False, f"缺少关键表:{sorted(missing)}" campaign_count = mem.execute("SELECT COUNT(*) FROM campaign").fetchone()[0] finally: mem.close() return True, f"campaign {campaign_count} 行" except Exception as exc: return False, str(exc)[:200] def verify_restore_latest() -> dict: """恢复演练:对最新备份执行 verify_backup,并额外验证可解压到临时目录。""" backups = sorted(BACKUP_DIR.glob("backup-*.zip"), key=lambda p: p.stat().st_mtime) if not backups: return {"ok": False, "detail": "尚无备份", "backup": "", "checked_at": datetime.now().isoformat()} latest = backups[-1] ok, detail = verify_backup(latest) # 真实解压演练(写入临时目录后清理) if ok: try: tmp = BACKUP_DIR / "_restore_drill" shutil.rmtree(tmp, ignore_errors=True) with zipfile.ZipFile(latest) as zf: zf.extractall(tmp) restored = tmp / DB_NAME with sqlite3.connect(str(restored)) as conn: conn.execute("SELECT COUNT(*) FROM sqlite_master").fetchone() shutil.rmtree(tmp, ignore_errors=True) detail += ";解压演练通过" except Exception as exc: ok = False detail = f"解压演练失败:{exc}"[:200] result = {"ok": ok, "detail": detail, "backup": latest.name, "checked_at": datetime.now().isoformat()} _write_drill_report(result) return result def _write_drill_report(result: dict) -> None: try: import json (BACKUP_DIR / "restore-drill.json").write_text( json.dumps(result, ensure_ascii=False, indent=1)) except OSError as exc: logger.error("恢复演练报告写入失败:%s", exc) def backup_status() -> dict: """看板用的备份状态摘要。""" files = sorted(BACKUP_DIR.glob("backup-*.zip"), key=lambda p: p.stat().st_mtime) latest = files[-1] if files else None drill_path = BACKUP_DIR / "restore-drill.json" drill = None if drill_path.exists(): try: import json drill = json.loads(drill_path.read_text()) except (OSError, ValueError): drill = None return { "count": len(files), "keep_count": KEEP_COUNT, "latest": latest.name if latest else "", "latest_at": datetime.fromtimestamp(latest.stat().st_mtime).isoformat() if latest else "", "latest_size_kb": round(latest.stat().st_size / 1024) if latest else 0, "last_drill": drill, "directory": str(BACKUP_DIR), } __all__ = ["create_backup", "verify_backup", "verify_restore_latest", "backup_status", "_prune_old_backups", "BACKUP_DIR", "KEEP_COUNT"]