智学文枢:数据备份与恢复

数据备份与恢复:zip 打包、元数据校验与安全恢复

一句话摘要:backup_manager.py 将 ~/.ai-coach 整体打包为带元数据清单的 zip,支持完整/轻量两种模式,恢复时先做安全快照、释放引擎连接、清空重建,全程可校验可回退。

一、引言

第一性原理:这套系统里最昂贵的东西不是代码,而是数据------每个学生的档案、上百道题的批改记录、AI 分析、变式题、追问对话。SQLite 单文件 + 散落的图片 + 配置文件,数据分布在一个目录的 9 个子目录里。一旦磁盘损坏、误删、版本升级失败,私教工作会直接归零。

备份的本质是给数据完整性做「时间旅行」:任意时刻都能回到过去的某个可靠状态。这要求备份解决四个问题:

  1. 完整性:数据库、图片、PDF、配置一个都不能少。
  2. 可识别:恢复程序必须能区分「本系统的备份包」与「任意 zip 文件」。
  3. 可校验:打包即校验(CRC),恢复前再验一遍。
  4. 可回退:恢复前自动保留当前数据快照,恢复失败也能回到原点。

backup_manager.py 用 186 行实现了这四件事。

二、核心原理

2.1 一个 zip,两层内容

备份产物是单个 zip 文件,内部结构刻意设计:

text 复制代码
ai-coach-backup-full-20261005_143000.zip
└── ai_cache/                        # BACKUP_ROOT_NAME 统一顶层目录
    ├── database/ai_coach.db
    ├── images/questions/xxx.png
    ├── images/papers/xxx.png
    ├── pdf/papers/1234567890.pdf
    ├── config.json
    ├── reports/  exports/  logs/
    └── BACKUP_META.json             # 元数据清单(最后追加写入)

BACKUP_ROOT_NAME = "ai_cache" 是刻意选的名字------正常业务文件中不会出现以它为顶层的目录,这成为「本系统备份包」的指纹之一。元数据 BACKUP_META.json 最后追加写入,记录 created_at / kind / 文件数 / 原始大小 / 压缩后大小。

2.2 完整备份与轻量备份

python 复制代码
def create_backup(dest_dir, include_images=True):
    """创建备份 zip,返回备份文件完整路径。

    include_images=True  → 完整备份:数据库 + 全部图片 + PDF + 配置 + 日志/导出
    include_images=False → 轻量备份:仅数据库 + config.json
    """
    ensure_dirs()
    os.makedirs(dest_dir, exist_ok=True)
    stamp = _fmt_ts()
    kind = "full" if include_images else "lite"
    backup_path = os.path.join(dest_dir, f"ai-coach-backup-{kind}-{stamp}.zip")

    if include_images:
        files = _scan_files(APP_DATA_DIR)
    else:
        files = _scan_files(APP_DATA_DIR, only_subdirs=["database"])
        cfg = os.path.join(APP_DATA_DIR, "config.json")
        if os.path.isfile(cfg):
            files[cfg] = "config.json"

源码位置:data/backup_manager.py L48-L67

  • full :_scan_files(APP_DATA_DIR) 递归全目录,适合日常手动备份、换机迁移。
  • lite :only_subdirs=["database"] 只扫数据库目录 + 手动补 config.json,几十 KB 的纯数据包,适合云端网盘高频同步。

_scan_files 返回 {绝对路径: 相对路径} 映射,这个 dict 同时驱动写入与统计,保证 zip 内路径与真实目录结构一致。

2.3 元数据写入:先业务后清单

python 复制代码
meta = {
    "created_at": int(time.time()),
    "kind": kind,
    "include_images": include_images,
    "app_dir_size_bytes": _dir_size(APP_DATA_DIR),
    "file_count": len(files),
}

data_size = 0
with zipfile.ZipFile(backup_path, "w", zipfile.ZIP_DEFLATED, allowZip64=True) as zf:
    for abs_path, rel in files.items():
        data_size += os.path.getsize(abs_path)
        zf.write(abs_path, f"{BACKUP_ROOT_NAME}/{rel}")

with zipfile.ZipFile(backup_path, "a", zipfile.ZIP_DEFLATED) as zf:
    meta["data_size_bytes"] = data_size
    meta["backup_file_size_bytes"] = os.path.getsize(backup_path)
    zf.writestr(f"{BACKUP_ROOT_NAME}/{META_FILENAME}",
                json.dumps(meta, ensure_ascii=False).encode("utf-8"))

源码位置:data/backup_manager.py L68-L87

两个细节:allowZip64=True 应对超 4GB 的图片海量目录(Zip64 扩展);元数据在业务文件之后 追加(模式 "a"),此时 backup_file_size_bytes 才能准确记录压缩前的大小------先写数据、后写清单,清单里才有可信的体积统计。

三、代码实现

3.1 备份包识别:valid 判定

python 复制代码
def read_backup_info(zip_path):
    """读取备份包元数据。返回 dict:
    - 本系统备份包:含 created_at/kind/include_images/file_count/total_size 等
    - 非本系统 zip:仅基础文件信息,valid=False
    """
    info = {"path": zip_path, "valid": False}
    try:
        with zipfile.ZipFile(zip_path, "r") as zf:
            infos = zf.infolist()
            info["file_count"] = len(infos)
            info["total_size"] = sum(i.file_size for i in infos)
            info["compressed_size"] = sum(i.compress_size for i in infos)
            try:
                meta = zf.read(f"{BACKUP_ROOT_NAME}/{META_FILENAME}").decode("utf-8")
                info.update(json.loads(meta))
                info["valid"] = True
            except KeyError:
                info["error"] = "该文件不是智学文枢的备份包"
    except (zipfile.BadZipFile, OSError):
        info["error"] = "不是有效的 zip 备份文件"
    return info

源码位置:data/backup_manager.py L90-L110

识别逻辑极简但可靠:能否在指定路径读到 BACKUP_META.json。读不到 → 不是本系统备份(可能只是用户随手选的任意 zip);读到了 → 合法备份。数据管理页(第 80 篇)在用户选文件后立即调用它,把「是否可恢复」提前到 UI 层。

3.2 恢复第一关:完整性校验

python 复制代码
with zipfile.ZipFile(zip_path, "r") as zf:
    bad = zf.testzip()
    if bad:
        raise ValueError(f"备份包已损坏:{bad}")
    member_files = []
    for zi in zf.infolist():
        if zi.is_dir():
            continue
        parts = zi.filename.split("/")
        if len(parts) >= 2 and parts[0] == BACKUP_ROOT_NAME \
                and zi.filename.rsplit("/", 1)[-1] != META_FILENAME:
            member_files.append(zi)

源码位置:data/backup_manager.py L124-L135

testzip() 对每个成员做 CRC 校验,返回第一个坏文件名------解压前就验明正身,避免解压到一半才发现损坏。同时过滤出业务文件清单(排除目录项与元数据本身),这就是后续恢复的唯一工作集。

3.3 恢复第二关:安全快照 + 释放引擎

python 复制代码
# 1) 当前数据安全快照
safety_backup = None
if safety_dir:
    try:
        safety_backup = create_backup(safety_dir, include_images=True)
    except Exception:
        safety_backup = None

# 2) 释放数据库连接句柄(Windows 下文件被占用无法覆盖)
try:
    db.engine.dispose()
except Exception:
    pass

# 3) 清空并重建数据目录
if os.path.isdir(APP_DATA_DIR):
    for name in os.listdir(APP_DATA_DIR):
        p = os.path.join(APP_DATA_DIR, name)
        try:
            if os.path.isdir(p) and not os.path.islink(p):
                shutil.rmtree(p)
            else:
                os.remove(p)
        except Exception:
            pass
ensure_dirs()
os.makedirs(APP_DATA_DIR, exist_ok=True)

源码位置:data/backup_manager.py L137-L163

三步环环相扣:

  • 安全快照:恢复是破坏性操作,先把当前数据完整打包(full 模式)到用户指定目录,恢复后不满意可手动找回。
  • 释放引擎 :Windows 上 SQLite 文件被进程句柄占用时无法覆盖删除 ,必须先 db.engine.dispose() 关掉连接池------这正是第 10 篇引擎「可销毁」能力的消费方。
  • 清空重建 :先删净旧目录(防残留文件干扰),再按 ensure_dirs 建回 9 个标准目录。shutil.rmtree 与 os.remove 双分支覆盖目录/文件两种残留。

3.4 恢复第三关:解压覆盖 + 进度回调

python 复制代码
restored_bytes = 0
restored_files = 0
total = len(member_files)
with zipfile.ZipFile(zip_path, "r") as zf:
    for zi in member_files:
        rel = os.path.relpath(zi.filename, BACKUP_ROOT_NAME)
        target = os.path.join(APP_DATA_DIR, rel)
        os.makedirs(os.path.dirname(target), exist_ok=True)
        with zf.open(zi) as src, open(target, "wb") as dst:
            shutil.copyfileobj(src, dst)
        restored_files += 1
        restored_bytes += zi.file_size
        if on_progress and total:
            on_progress(restored_files, total)

return {
    "restored_files": restored_files,
    "restored_bytes": restored_bytes,
    "safety_backup": safety_backup,
    "kind": info.get("kind", "full"),
}

源码位置:data/backup_manager.py L166-L186

on_progress(done, total) 是 UI 钩子------数据管理页用它在进度条上显示「第 N / 总 M 个文件」。shutil.copyfileobj 流式拷贝避免大图整块进内存。返回值携带安全快照路径,供 UI 提示「恢复完成,旧数据已备份至 xxx」。

3.5 备份与恢复的完整时序

SQLite引擎 ~/.ai-coach BackupManager 数据管理页 SQLite引擎 ~/.ai-coach BackupManager 数据管理页 #mermaid-svg-McFd7XfkWFbjomFJ{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-McFd7XfkWFbjomFJ .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-McFd7XfkWFbjomFJ .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-McFd7XfkWFbjomFJ .error-icon{fill:#552222;}#mermaid-svg-McFd7XfkWFbjomFJ .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-McFd7XfkWFbjomFJ .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-McFd7XfkWFbjomFJ .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-McFd7XfkWFbjomFJ .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-McFd7XfkWFbjomFJ .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-McFd7XfkWFbjomFJ .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-McFd7XfkWFbjomFJ .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-McFd7XfkWFbjomFJ .marker{fill:#333333;stroke:#333333;}#mermaid-svg-McFd7XfkWFbjomFJ .marker.cross{stroke:#333333;}#mermaid-svg-McFd7XfkWFbjomFJ svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-McFd7XfkWFbjomFJ p{margin:0;}#mermaid-svg-McFd7XfkWFbjomFJ .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-McFd7XfkWFbjomFJ text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-McFd7XfkWFbjomFJ .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-McFd7XfkWFbjomFJ .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-McFd7XfkWFbjomFJ .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-McFd7XfkWFbjomFJ .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-McFd7XfkWFbjomFJ #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-McFd7XfkWFbjomFJ .sequenceNumber{fill:white;}#mermaid-svg-McFd7XfkWFbjomFJ #sequencenumber{fill:#333;}#mermaid-svg-McFd7XfkWFbjomFJ #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-McFd7XfkWFbjomFJ .messageText{fill:#333;stroke:none;}#mermaid-svg-McFd7XfkWFbjomFJ .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-McFd7XfkWFbjomFJ .labelText,#mermaid-svg-McFd7XfkWFbjomFJ .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-McFd7XfkWFbjomFJ .loopText,#mermaid-svg-McFd7XfkWFbjomFJ .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-McFd7XfkWFbjomFJ .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-McFd7XfkWFbjomFJ .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-McFd7XfkWFbjomFJ .noteText,#mermaid-svg-McFd7XfkWFbjomFJ .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-McFd7XfkWFbjomFJ .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-McFd7XfkWFbjomFJ .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-McFd7XfkWFbjomFJ .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-McFd7XfkWFbjomFJ .actorPopupMenu{position:absolute;}#mermaid-svg-McFd7XfkWFbjomFJ .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-McFd7XfkWFbjomFJ .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-McFd7XfkWFbjomFJ .actor-man circle,#mermaid-svg-McFd7XfkWFbjomFJ line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-McFd7XfkWFbjomFJ :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 备份 恢复 create_backup(dir, include_images=True)_scan_files 全目录收集压缩业务文件 → zip追加 BACKUP_META.json备份包路径restore_backup(zip, safety_dir, on_progress)read_backup_info 判定 validtestzip CRC 校验当前数据 → safety zip(安全快照)engine.dispose() 释放句柄清空重建目录流式解压覆盖{restored_files, safety_backup}

四、设计权衡 / 踩坑记录

1. 为什么恢复前要 dispose 引擎而不是直接关会话

engine.dispose() 关闭整个连接池并释放所有底层 DBAPI 连接,SQLite 文件句柄随之释放;只关 session 不够------连接池里可能还挂着空闲连接。这是 Windows 上「文件被占用」问题的正解,实测在恢复流程中缺了这行必报 PermissionError。

2. 安全快照失败为什么不阻断恢复

safety_backup 创建失败时仅置 None 继续恢复(except Exception: safety_backup = None)。权衡:安全快照是「额外保险」而非「前置条件」,用户已明确选择恢复,不能因保险失效而中止主流程------UI 会显示「未生成安全快照」的警示。

3. ai_cache 顶层目录名的防误判设计

若顶层目录用 ai-coach(与真实目录同名),恢复时 os.path.relpath 会产生路径错位;BACKUP_ROOT_NAME 用 ai_cache 这个业务文件不会出现的名字,同时承担「指纹」与「路径根」双重职责,一石二鸟。

4. 清空删除的逐项容错

清空阶段每个文件单独 try/except:个别文件被其他进程占用(如用户正打开 reports 里的 PDF)时跳过,其余照常删除。若整体失败则恢复直接终止------「尽力清空」保证恢复不会因单个残留文件整体失败,残留项随后被同路径解压覆盖。

5. zip 备份 vs 目录拷贝

目录拷贝更直观,但 zip 带来三重收益:单文件易归档易传输、testzip 提供开箱校验、元数据可内嵌。代价是打包耗时,allowZip64 与流式写入已将其控制到可接受范围。

五、小结

  • 备份将 ~/.ai-coach 打包为带 BACKUP_META.json 元数据的 zip,full/lite 双模式覆盖「完整迁移」与「高频同步」两种诉求。
  • read_backup_info 以「能否读到元数据」判定备份包合法性,UI 层提前识别不可恢复文件。
  • 恢复流程四段式:CRC 校验 → 安全快照 → dispose 引擎释放句柄 → 清空重建并流式解压,全程带进度回调。
  • 每个环节都有容错与回退:快照失败不阻断、删除失败不中止、解压失败可回退到安全快照。
  • 数据层至此闭环:引擎(10)→ 模型(11)→ 仓库(12-15)→ 资产存储(16)→ 配置(17)→ 备份兜底(18)。
相关推荐
ONEYAC唯样1 小时前
TE:不只看产品,更要看价值:下一代高压连接方案如何选择?
python·django
高洁011 小时前
具身智能感知系统底层原理
人工智能·python·深度学习·机器学习·transformer
我就是不信2 小时前
Java8 lambda 表达式 forEach 如何提前终止?
开发语言·windows·python
全栈弄潮儿2 小时前
Python实战第9期:文件操作
后端·python·编程语言
小星星_ios2 小时前
从 3Blue1Brown 到 NumPy:手写一个神经网络训练 MNIST
人工智能·python·神经网络
Maxwin2 小时前
Manim 竖屏画布选 9×16 还是 4.5×8?从 font_size 和每单位像素数说起
python·ffmpeg
昭昭日月明2 小时前
使用 Ragas 量化 RAG
python·langchain·ai编程
姜九笙5612 小时前
python学习---归来仍是新人
java·python·学习
幸福清风2 小时前
从域名到一行 nginx 配置:一次站点 HTTPS 证书更换与排障复盘
python·nginx·https