From 55b1b74944e81e4141bc74029bc9ed73f31941b5 Mon Sep 17 00:00:00 2001 From: butubb <1422726308@qq.com> Date: Wed, 9 Sep 2026 14:49:40 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=B3=BB=E7=BB=9F=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=A4=87=E4=BB=BD=E5=AF=BC=E5=87=BA/=E5=AF=BC=E5=85=A5?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=E2=80=94=E2=80=94sqlite=20=E5=9C=A8=E7=BA=BF?= =?UTF-8?q?=E5=BF=AB=E7=85=A7=20+=20apk=20=E6=89=93=E5=8C=85=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=EF=BC=9B=E4=B8=8A=E4=BC=A0=E6=A0=A1=E9=AA=8C=E9=A2=84?= =?UTF-8?q?=E8=A7=88=E2=86=92=E5=BA=94=E7=94=A8=EF=BC=88=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E5=BF=AB=E7=85=A7=E5=BD=93=E5=89=8D=E5=BA=93=EF=BC=89+=20?= =?UTF-8?q?=E9=87=8D=E5=90=AF=E7=94=9F=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 导出: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 排除运行产物。 --- .gitignore | 3 + config.py | 7 + core/system_backup.py | 413 ++++++++++++++++++++++++++++++++++++++++++ web/__init__.py | 4 +- web/system_api.py | 78 ++++++++ web_server.py | 9 + 6 files changed, 513 insertions(+), 1 deletion(-) create mode 100644 core/system_backup.py create mode 100644 web/system_api.py diff --git a/.gitignore b/.gitignore index a77b774..ebad71c 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,6 @@ data/*.corrupt_* data/users.db* data/users.db-wal data/users.db-shm +data/backups/ +data/restore_staging/ +data/restore_pending/ diff --git a/config.py b/config.py index faa6491..94ef392 100644 --- a/config.py +++ b/config.py @@ -60,6 +60,13 @@ DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data") # APK 文件存储目录(应用管理功能) APK_DIR = os.path.join(DATA_DIR, "apks") +# ================== 系统数据备份(导出/导入) ================== +# 导出 zip 与恢复前快照存放;导入暂存目录;待下次启动生效的恢复目录 +# (运行时产物,不入 git,见 .gitignore data/backups 等) +BACKUP_DIR = os.path.join(DATA_DIR, "backups") +RESTORE_STAGING_DIR = os.path.join(DATA_DIR, "restore_staging") +RESTORE_PENDING_DIR = os.path.join(DATA_DIR, "restore_pending") + # ================== SSH 到部署机(已废弃,STF 相关操作退役) ================== # 阶段 3 后代码不再使用 SSH;保留配置仅供手动运维 220(如 docker stop stf)。 STF_SSH_TARGET = _env("STF_SSH_TARGET", "") diff --git a/core/system_backup.py b/core/system_backup.py new file mode 100644 index 0000000..a368c04 --- /dev/null +++ b/core/system_backup.py @@ -0,0 +1,413 @@ +"""系统数据备份导出/导入(数据库 + APK 文件)。 + +导出:用 sqlite3 在线备份 API 对 data/users.db 做一致性快照 → zip + (users.db + manifest.json + 可选 apks/*.apk)。 +导入:上传(zip/db) → 暂存校验预览 → 应用(先把当前库快照到 + BACKUP_DIR/pre_restore_*.db,再把暂存库移到 RESTORE_PENDING_DIR)→ + 下次启动 consume_pending_restore() 换位生效。 + +为什么必须重启生效:web_server 单进程内 SQLAlchemy engine + 多个常驻后台线程 +(TaskManager 内存态 / 设备发现 / Agent 等)持有 users.db(WAL);Windows 下 +不能直接替换正被打开的文件,且 TaskManager 启动时才把 groups/jobs 读入内存。 +故导入只落一个「待生效恢复任务」,由 web_server.py 在 init_db 之前消费。 + +安全红线:全程只读现有库 / 新增或移动自己目录下的文件;绝不 kill-server、 +绝不 disconnect(与全项目一致)。 +""" +import json +import os +import shutil +import sqlite3 +import time +import uuid +import zipfile +from urllib.parse import quote + +from config import (DATA_DIR, APK_DIR, BACKUP_DIR, + RESTORE_STAGING_DIR, RESTORE_PENDING_DIR) +from core.logger import get_logger +from core.models import SCHEMA_MIGRATIONS + +_log = get_logger("core.backup") + +# 当前代码期望的 schema 版本(来自版本化迁移表) +CURRENT_SCHEMA_VERSION = max(v[0] for v in SCHEMA_MIGRATIONS) if SCHEMA_MIGRATIONS else 0 + +DB_FILE = os.path.join(DATA_DIR, "users.db") + +# 判定「本平台备份库」的必需表(缺任何一张即拒绝导入) +REQUIRED_TABLES = ("app_meta", "user", "task_job", "device_group") +# 预览/清单里展示行数的业务表(存在才统计) +SUMMARY_TABLES = ( + "user", "device_group", "task_job", "custom_action", + "apk_file", "device", "pending_device", + "agent_conversation", "agent_experience", "experience_audit", +) +TABLE_LABELS = { + "user": "用户", "device_group": "设备分组", "task_job": "任务计划", + "custom_action": "自定义动作", "apk_file": "APK 记录", "device": "设备池", + "pending_device": "待连接设备", "agent_conversation": "AI 会话", + "agent_experience": "经验库", "experience_audit": "经验巡检", +} +_STAGE_TTL = 1800 # 导入暂存有效期(秒) + + +class BackupError(Exception): + """备份/导入相关可预期错误(msg 直接给前端展示)。""" + + +# ================== 通用工具 ================== +def _ts(): + return time.strftime("%Y%m%d_%H%M%S") + + +def _readonly_uri(path): + """把文件路径转成 sqlite 只读 URI(兼容含空格/中文/反斜杠的 Windows 路径)。""" + return "file:{}?mode=ro".format(quote(os.path.abspath(path).replace("\\", "/"), safe="/:")) + + +def _connect_readonly(path): + con = sqlite3.connect(_readonly_uri(path), uri=True) + con.text_factory = str # 与平台一致按 UTF-8 读(表内文本均为 UTF-8) + return con + + +def remove_quiet(path): + """尽力删除文件/目录(发送完成清理等场景,忽略不存在)。""" + try: + if os.path.isdir(path): + shutil.rmtree(path, ignore_errors=True) + elif os.path.exists(path): + os.remove(path) + except OSError: + pass + + +def _ensure_dir(path): + os.makedirs(path, exist_ok=True) + + +def _list_tables(con): + rows = con.execute( + "SELECT name FROM sqlite_master WHERE type='table'").fetchall() + return {r[0] for r in rows} + + +def _read_schema_version(con): + try: + row = con.execute( + "SELECT value FROM app_meta WHERE key='schema_version'").fetchone() + return int(row[0]) if row and row[0] else 0 + except Exception: + return 0 + + +def _table_rows(con, table): + try: + return con.execute('SELECT COUNT(*) FROM "%s"' % table).fetchone()[0] + except Exception: + return None + + +# ================== 在线快照 ================== +def snapshot_db(dest_path, src_path=None): + """对 src(默认当前 users.db)做 sqlite 在线一致快照到 dest_path。 + + 用标准库备份 API(不依赖 SQLAlchemy engine,WAL 模式下读一致快照安全)。 + 备份完成后把目标强制落回单文件(journal_mode=DELETE + checkpoint), + 避免副本以 WAL 模式残留 -wal/-shm 或主文件缺刚提交帧。 + """ + src_path = src_path or DB_FILE + if not os.path.exists(src_path): + raise BackupError(f"数据库不存在: {src_path}") + con = _connect_readonly(src_path) + out = sqlite3.connect(dest_path) + try: + con.backup(out) + try: + out.execute("PRAGMA journal_mode=DELETE") # checkpoint 并转回 DELETE + except sqlite3.Error: + pass + finally: + out.close() + con.close() + # 防御:确保没有任何残留附属文件 + remove_quiet(dest_path + "-wal") + remove_quiet(dest_path + "-shm") + + +# ================== 导出 ================== +def _summary_info(con): + """表行数摘要(只列出存在的表)。""" + rows = [] + for t in SUMMARY_TABLES: + n = _table_rows(con, t) + if n is not None: + rows.append({"table": t, "label": TABLE_LABELS.get(t, t), "rows": n}) + return rows + + +def _prune_old_exports(max_age=3600): + """清理过期的导出临时 zip(下载完成后的 call_on_close 在 Windows 上可能 + 因文件锁删不掉,这里按时间兜底清理;保留近 1 小时的便于失败重试)。""" + now = time.time() + if not os.path.isdir(BACKUP_DIR): + return + for n in os.listdir(BACKUP_DIR): + if n.startswith("export_") and n.endswith(".zip"): + p = os.path.join(BACKUP_DIR, n) + try: + if now - os.path.getmtime(p) > max_age: + os.remove(p) + except OSError: + pass + + +def create_export(include_apk=True): + """生成导出 zip,返回 (zip_path, filename, manifest)。""" + _ensure_dir(BACKUP_DIR) + _prune_old_exports() + base = os.path.join(BACKUP_DIR, f"export_{_ts()}") + snap = base + ".snapshot.db" + snapshot_db(snap) + + apk_meta = [] + if include_apk and os.path.isdir(APK_DIR): + for fname in sorted(os.listdir(APK_DIR)): + if fname.lower().endswith(".apk"): + fp = os.path.join(APK_DIR, fname) + try: + apk_meta.append({"name": fname, "size": os.path.getsize(fp)}) + except OSError: + pass + + zip_path = base + ".zip" + con = _connect_readonly(snap) + try: + manifest = { + "format": "auto_control_backup", + "version": 1, + "created_at": time.strftime("%Y-%m-%d %H:%M:%S"), + "schema_version": _read_schema_version(con), + "include_apk": include_apk, + "tables": _summary_info(con), + "apks": apk_meta, + } + finally: + con.close() + + try: + with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as z: + z.write(snap, arcname="users.db") + for a in apk_meta: + z.write(os.path.join(APK_DIR, a["name"]), arcname="apks/" + a["name"]) + z.writestr("manifest.json", json.dumps( + manifest, ensure_ascii=False, indent=2)) + finally: + remove_quiet(snap) + return zip_path, os.path.basename(zip_path), manifest + + +# ================== 校验 ================== +def validate_backup(db_file): + """校验备份库可读、是本平台库。返回 (ok, info|error_msg)。""" + if not os.path.exists(db_file): + return False, "缺少数据库文件 users.db" + try: + con = _connect_readonly(db_file) + except sqlite3.Error as e: + return False, f"无法打开数据库: {e}" + try: + integrity = con.execute("PRAGMA integrity_check").fetchone()[0] + if integrity != "ok": + return False, f"数据库完整性校验失败: {integrity}" + tables = _list_tables(con) + missing = [t for t in REQUIRED_TABLES if t not in tables] + if missing: + return False, f"不是本平台备份库(缺少必需表: {', '.join(missing)})" + schema_version = _read_schema_version(con) + missing_optional = [t for t in SUMMARY_TABLES + if t not in REQUIRED_TABLES and t not in tables] + warnings = [] + if schema_version < CURRENT_SCHEMA_VERSION: + warnings.append( + f"备份 schema 较旧(v{schema_version} < 当前 v{CURRENT_SCHEMA_VERSION})," + f"应用后首次启动会自动迁移补齐结构与新表") + elif schema_version > CURRENT_SCHEMA_VERSION: + warnings.append( + f"备份 schema 较新(v{schema_version} > 当前 v{CURRENT_SCHEMA_VERSION})," + f"当前平台版本可能读不了新增列/表,建议先升级再恢复") + if missing_optional: + warnings.append("备份缺少部分可选表(" + "、".join( + TABLE_LABELS.get(t, t) for t in missing_optional) + + "),应用后启动会自动补建空表") + warnings.append("备份为全量快照:含用户口令哈希、AI 配置里的 API Key 等敏感信息,请妥善保管") + return True, { + "integrity": integrity, + "schema_version": schema_version, + "current_schema_version": CURRENT_SCHEMA_VERSION, + "tables": _summary_info(con), + "missing_optional": missing_optional, + "warnings": warnings, + } + except sqlite3.Error as e: + return False, f"读取数据库失败: {e}" + finally: + con.close() + + +# ================== 导入:暂存 + 预览 ================== +def _extract_zip(zip_path, staging_dir): + """解压备份 zip:取顶层 users.db 与可选 apks/*.apk(防路径穿越,只用 basename)。""" + db_dest = os.path.join(staging_dir, "users.db") + apk_dest = os.path.join(staging_dir, "apks") + got_db = False + with zipfile.ZipFile(zip_path) as z: + for info in z.infolist(): + if info.is_dir(): + continue + name = info.filename.replace("\\", "/") + base = os.path.basename(name) + if name == "users.db" and base == "users.db": + z.extract(info, staging_dir) # 已确认顶层名字,无穿越 + got_db = True + elif name.startswith("apks/") and base.lower().endswith(".apk"): + _ensure_dir(apk_dest) + with z.open(info) as src, open( + os.path.join(apk_dest, base), "wb") as dst: + shutil.copyfileobj(src, dst) + if not got_db: + raise BackupError("zip 内未找到 users.db(顶层)") + return db_dest + + +def _prune_stale_staging(): + """清理超时未应用的暂存目录(TTL 后自动删除)。""" + now = time.time() + if not os.path.isdir(RESTORE_STAGING_DIR): + return + for name in os.listdir(RESTORE_STAGING_DIR): + p = os.path.join(RESTORE_STAGING_DIR, name) + try: + if os.path.isdir(p) and now - os.path.getmtime(p) > _STAGE_TTL: + remove_quiet(p) + except OSError: + pass + + +def stage_upload(file_storage): + """保存上传备份并校验,返回 (token, info)。失败抛 BackupError。""" + _prune_stale_staging() + orig_name = file_storage.filename or "backup" + token = uuid.uuid4().hex[:12] + staging_dir = os.path.join(RESTORE_STAGING_DIR, token) + _ensure_dir(staging_dir) + low = (orig_name or "").lower() + try: + raw_path = os.path.join(staging_dir, "upload" + ("." + low.rsplit(".", 1)[1] if "." in low else "")) + file_storage.save(raw_path) + if low.endswith(".zip"): + _extract_zip(raw_path, staging_dir) + db_file = os.path.join(staging_dir, "users.db") + remove_quiet(raw_path) + elif low.endswith(".db"): + db_file = os.path.join(staging_dir, "users.db") + os.replace(raw_path, db_file) + else: + raise BackupError("仅支持 .zip(平台导出)或 .db(sqlite 库)文件") + ok, info = validate_backup(db_file) + if not ok: + raise BackupError(info) + info["file_name"] = orig_name + info["file_size"] = os.path.getsize(db_file) + return token, info + except BackupError: + remove_quiet(staging_dir) + raise + except Exception as e: + remove_quiet(staging_dir) + raise BackupError(f"暂存上传文件失败: {e}") + + +# ================== 导入:应用(落待生效任务) ================== +def apply_restore(token): + """校验 token → 快照当前库 → 把暂存库移到 restore_pending。 + + 返回 dict {ok, backup_name, message};失败抛 BackupError。token 形如 12 位 hex。 + """ + if not token or len(token) != 12 or not all(c in "0123456789abcdef" for c in token): + raise BackupError("无效的导入标识") + staging_dir = os.path.join(RESTORE_STAGING_DIR, token) + db_file = os.path.join(staging_dir, "users.db") + if not os.path.exists(db_file): + raise BackupError("导入会话已失效,请重新上传预览") + ok, info = validate_backup(db_file) + if not ok: + raise BackupError(info) + + # 1) 自动备份当前库(安全网,可回滚) + _ensure_dir(BACKUP_DIR) + backup_name = f"pre_restore_{_ts()}.db" + snapshot_db(os.path.join(BACKUP_DIR, backup_name)) + + # 2) 落待生效恢复任务(旧未消费任务被覆盖——上一份已无意义) + pending_dir = RESTORE_PENDING_DIR + if os.path.isdir(pending_dir): + remove_quiet(pending_dir) + _ensure_dir(pending_dir) + os.replace(db_file, os.path.join(pending_dir, "users.db")) + apk_src = os.path.join(staging_dir, "apks") + if os.path.isdir(apk_src) and os.listdir(apk_src): + shutil.move(apk_src, os.path.join(pending_dir, "apks")) + remove_quiet(staging_dir) + + schema_version = info.get("schema_version", 0) + _log.info(f"导入已落恢复任务: schema v{schema_version}," + f"重启 web_server 后生效;当前库已快照 {backup_name}") + return { + "ok": True, + "backup_name": backup_name, + "message": "恢复任务已生成:当前库已自动备份,重启 web_server 后即应用导入的数据", + } + + +# ================== 启动消费(web_server.py init_db 前调用) ================== +def consume_pending_restore(): + """把 restore_pending/users.db 换位为当前 users.db(删除旧 wal/shm,合并 apks)。 + + 必须在 SQLAlchemy engine 首次打开 users.db 之前执行(web_server 装配时调用)。 + 失败不阻塞启动:坏恢复文件会挪到 BACKUP_DIR/restore_failed_*/ 并继续用旧库。 + 返回是否真的应用了恢复。 + """ + pending_db = os.path.join(RESTORE_PENDING_DIR, "users.db") + if not os.path.exists(pending_db): + return False + # 消费前轻量复验(防止意外损坏文件把主库换掉) + ok, msg = validate_backup(pending_db) + if not ok: + fail_dir = os.path.join(BACKUP_DIR, f"restore_failed_{_ts()}") + _ensure_dir(fail_dir) + os.replace(pending_db, os.path.join(fail_dir, "users.db")) + remove_quiet(RESTORE_PENDING_DIR) + _log.error(f"恢复任务校验失败,已搁置到 {fail_dir},继续使用当前库: {msg}") + return False + _ensure_dir(DATA_DIR) + os.replace(pending_db, DB_FILE) + for ext in ("-wal", "-shm"): + p = DB_FILE + ext + if os.path.exists(p): + remove_quiet(p) + # 合并 apks(覆盖同名) + apk_src = os.path.join(RESTORE_PENDING_DIR, "apks") + if os.path.isdir(apk_src): + _ensure_dir(APK_DIR) + for fname in os.listdir(apk_src): + src = os.path.join(apk_src, fname) + dst = os.path.join(APK_DIR, fname) + if os.path.exists(dst): + os.remove(dst) + shutil.move(src, dst) + remove_quiet(RESTORE_PENDING_DIR) + schema_version = msg.get("schema_version", 0) if isinstance(msg, dict) else "?" + _log.info(f"已应用备份恢复: users.db 替换完成(schema v{schema_version}),apks 已合并") + return True diff --git a/web/__init__.py b/web/__init__.py index 617ddcb..d38fc73 100644 --- a/web/__init__.py +++ b/web/__init__.py @@ -9,6 +9,7 @@ devices — 设备池管理 apks — 应用管理 tailscale — Tailscale 管理 + system — 系统数据备份导出/导入 """ from flask import Blueprint @@ -26,6 +27,7 @@ def register_blueprints(app): from . import agent_api as _agent_mod from .agent_api import bp as agent_bp _agent_mod.set_app(app) + from .system_api import bp as system_bp for bp in (auth_bp, monitor_bp, tasks_bp, admin_bp, tools_bp, - devices_bp, apks_bp, tailscale_bp, agent_bp): + devices_bp, apks_bp, tailscale_bp, agent_bp, system_bp): app.register_blueprint(bp) diff --git a/web/system_api.py b/web/system_api.py new file mode 100644 index 0000000..4a9e63b --- /dev/null +++ b/web/system_api.py @@ -0,0 +1,78 @@ +"""系统管理 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}) diff --git a/web_server.py b/web_server.py index d1892d8..01000ca 100644 --- a/web_server.py +++ b/web_server.py @@ -40,6 +40,15 @@ app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False login_manager = LoginManager(app) login_manager.login_view = "auth.login" # 蓝图化后路由前缀 auth +# 消费「待生效的备份恢复」:若存在 data/restore_pending/users.db,在 engine 首次 +# 打开数据库之前换位。导入必须重启生效——TaskManager 启动时读库入内存, +# Windows 无法热替换正被持有的库文件。 +try: + from core.system_backup import consume_pending_restore + consume_pending_restore() +except Exception as _e: + _log.error(f"消费备份恢复任务异常(不影响启动): {_e}") + # 先初始化数据库(含旧 JSON 迁移),再创建 TaskManager(需要 app context 读写 DB) init_db(app) # 设备池(本地清单 + adb 在线状态):任务调度的数据源