179 lines
6.8 KiB
Python
179 lines
6.8 KiB
Python
"""自动备份与恢复演练(交付报告 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"]
|