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"]
|