导出: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 排除运行产物。
79 lines
3.2 KiB
Python
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})
|