SQLite 从"运行时数据库"降级为"备份交换格式":导出把当前库(MySQL 或 SQLite)
整库写成一份 SQLite 归档,恢复则是启动时在单个事务里 DELETE + INSERT。
前端三个接口的契约一行未改。
- core/system_backup.py 重写:
* dump_to_sqlite()(旧名 snapshot_db 保留为别名)取代在线备份 API:MySQL 下把
这条连接提到 REPEATABLE READ,保证 12 张表读的是同一时刻
* zip 结构不变(users.db + manifest.json + apks/),既有老备份继续可导入;不依赖
任何外部二进制(sqlite3 是标准库,python:slim 容器里现成)
* manifest 增加 db_backend / deployment_env / source_db_id,用于识别备份来源环境
* apply_restore:安全网从裸 pre_restore_*.db 升级为 pre_restore_*.zip(可直接再导入
回滚);跨环境导入默认硬停(需 force_env_mismatch=true)
* consume_pending_restore:启动时单事务整库替换,任何一步失败 rollback,当前数据
完好(比换文件更安全);坏归档挪 restore_failed_* 且不阻塞启动
* 归档库收尾时 checkpoint 回单文件模式并清掉 -wal/-shm(zip 只打包主文件)
- web_server.py:consume 从 init_db 之前移到之后(现在是事务替换,需要表与 app context)
- config.py:BACKUP_DIR / RESTORE_STAGING_DIR / RESTORE_PENDING_DIR 支持环境变量覆盖
—— 自动化测试必须把恢复目录指到临时位置,否则测试造的"待生效恢复任务"会在服务
下次重启时被当成用户的操作消费掉
- core/db_config.py:SQLite 回退模式也写库环境标签(备份要能标出来源环境)
- web/system_api.py + static/admin/system.js + templates/admin/monitor.html:
预览显示来源/当前环境,跨环境时出现「允许跨环境导入」勾选项;文案同步
验证(隔离副本,绝不碰真实库):导出→zip 结构与 manifest 正确;预览带来源环境与
告警;应用→生成 pre_restore zip + 归档就位;跨环境 apply 被拒;模拟重启→数据回到
导出时刻、经验库/动作库完好;塞入坏归档→挪 restore_failed_*、服务照常启动、数据未变。
85 lines
3.5 KiB
Python
85 lines
3.5 KiB
Python
"""系统管理 API(数据备份导出/导入)。仅管理员。
|
|
|
|
导出:POST /api/system/backup/export → 返回 zip 附件下载
|
|
预览:POST /api/system/backup/preview → multipart 上传 zip/db,返回暂存 token + 表清单
|
|
应用:POST /api/system/backup/apply → 校验后落「重启生效」恢复任务
|
|
|
|
导入涉及整库替换,只允许管理员操作(web/auth.admin_required)。
|
|
"""
|
|
from io import BytesIO
|
|
|
|
from flask import Blueprint, jsonify, request, send_file
|
|
|
|
from web.auth import admin_required
|
|
from core import system_backup as sb
|
|
from core.logger import get_logger
|
|
|
|
_log = get_logger("web.system")
|
|
|
|
bp = Blueprint("system", __name__)
|
|
|
|
|
|
@bp.route("/api/system/backup/export", methods=["POST"])
|
|
@admin_required
|
|
def api_system_backup_export():
|
|
"""生成导出 zip 并作为附件返回(含 users.db 快照 + manifest.json + 可选 apks)。"""
|
|
data = request.json or {}
|
|
include_apk = bool(data.get("include_apk", True))
|
|
try:
|
|
zip_path, fname, manifest = sb.create_export(include_apk=include_apk)
|
|
except sb.BackupError as e:
|
|
return jsonify({"ok": False, "error": str(e)}), 400
|
|
except Exception as e:
|
|
_log.error(f"导出备份失败: {e}")
|
|
return jsonify({"ok": False, "error": f"导出失败: {e}"}), 500
|
|
|
|
# 读入内存后立即删除磁盘文件再发送——Windows 上 send_file 流式发送时文件
|
|
# 句柄可能到 close 仍未释放,call_on_close 删不掉会残留;改 BytesIO 发送
|
|
# 则可确定性清理(导出为一次性下载,无需保留服务端副本)。
|
|
with open(zip_path, "rb") as f:
|
|
data = f.read()
|
|
sb.remove_quiet(zip_path)
|
|
resp = send_file(BytesIO(data), as_attachment=True, download_name=fname,
|
|
mimetype="application/zip")
|
|
return resp
|
|
|
|
|
|
@bp.route("/api/system/backup/preview", methods=["POST"])
|
|
@admin_required
|
|
def api_system_backup_preview():
|
|
"""上传备份文件(zip/db)→ 暂存并校验 → 返回预览(表行数/schema/告警)。"""
|
|
f = request.files.get("file")
|
|
if not f or not f.filename:
|
|
return jsonify({"ok": False, "error": "未选择文件"}), 400
|
|
try:
|
|
token, info = sb.stage_upload(f)
|
|
except sb.BackupError as e:
|
|
return jsonify({"ok": False, "error": str(e)}), 400
|
|
except Exception as e:
|
|
_log.error(f"备份预览失败: {e}")
|
|
return jsonify({"ok": False, "error": f"解析文件失败: {e}"}), 500
|
|
_log.info(f"备份预览: {info.get('file_name')} "
|
|
f"(schema v{info.get('schema_version')}) token={token}")
|
|
return jsonify({"ok": True, "token": token, "preview": info})
|
|
|
|
|
|
@bp.route("/api/system/backup/apply", methods=["POST"])
|
|
@admin_required
|
|
def api_system_backup_apply():
|
|
"""确认应用导入:自动备份当前库 → 落待生效恢复任务(重启后生效)。
|
|
|
|
force_env_mismatch 默认 False:备份来源环境与当前库环境不符时拒绝导入,
|
|
要跨环境(如把生产数据导进 dev 库)必须显式传 true。
|
|
"""
|
|
body = request.json or {}
|
|
token = body.get("token", "")
|
|
force = bool(body.get("force_env_mismatch", False))
|
|
try:
|
|
res = sb.apply_restore(str(token).strip(), force_env_mismatch=force)
|
|
except sb.BackupError as e:
|
|
return jsonify({"ok": False, "error": str(e)}), 400
|
|
except Exception as e:
|
|
_log.error(f"应用导入失败: {e}")
|
|
return jsonify({"ok": False, "error": f"应用导入失败: {e}"}), 500
|
|
return jsonify({"ok": True, **res})
|