Files
auto_control/web/system_api.py
T
butubb 55b1b74944 feat: 系统数据备份导出/导入后端——sqlite 在线快照 + apk 打包导出;上传校验预览→应用(自动快照当前库)+ 重启生效
导出:sqlite3 在线备份 API 对 data/users.db 做一致快照 → zip(users.db +
manifest.json:schema_version/逐表行数/apk 清单)+ 可选 data/apks/*.apk。
导出文件读入内存(BytesIO)发送后即删磁盘副本,避免 Windows 流式句柄锁残留。
导入:上传 zip/db → 暂存校验(integrity + 必需表 app_meta/user/task_job/
device_group + schema 版本提示)→ 确认应用:先自动快照当前库到
data/backups/pre_restore_*.db,再把备份落到 data/restore_pending/,由
web_server.py 在 init_db 之前 consume 换库——TaskManager 启动时才读库入内存、
Windows 不能热替换正被持有的库文件,故导入必须重启生效。

新增 web/system_api.py(export/preview/apply,全 @admin_required)与目录常量
BACKUP_DIR/RESTORE_STAGING_DIR/RESTORE_PENDING_DIR;.gitignore 排除运行产物。
2026-09-09 14:49:40 +08:00

79 lines
3.2 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():
"""确认应用导入:自动备份当前库 → 落待生效恢复任务(重启后生效)。"""
token = (request.json or {}).get("token", "")
try:
res = sb.apply_restore(str(token).strip())
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})