数据备份与恢复:zip 打包、元数据校验与安全恢复
一句话摘要:
backup_manager.py将~/.ai-coach整体打包为带元数据清单的 zip,支持完整/轻量两种模式,恢复时先做安全快照、释放引擎连接、清空重建,全程可校验可回退。
一、引言
第一性原理:这套系统里最昂贵的东西不是代码,而是数据------每个学生的档案、上百道题的批改记录、AI 分析、变式题、追问对话。SQLite 单文件 + 散落的图片 + 配置文件,数据分布在一个目录的 9 个子目录里。一旦磁盘损坏、误删、版本升级失败,私教工作会直接归零。
备份的本质是给数据完整性做「时间旅行」:任意时刻都能回到过去的某个可靠状态。这要求备份解决四个问题:
- 完整性:数据库、图片、PDF、配置一个都不能少。
- 可识别:恢复程序必须能区分「本系统的备份包」与「任意 zip 文件」。
- 可校验:打包即校验(CRC),恢复前再验一遍。
- 可回退:恢复前自动保留当前数据快照,恢复失败也能回到原点。
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)。