"""系统管理 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 flask_login import current_user 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) try: from core import notifier notifier.notify("system.backup.exported", filename=fname, size=len(data), tables=len(manifest.get("tables") or []), include_apk=include_apk, user=getattr(current_user, "username", "")) except Exception: pass # 通知失败不影响导出 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})