From 9379bc4f4ff0c560e6ba614bd84eea93f4a79b8e Mon Sep 17 00:00:00 2001
From: dekun
Date: Thu, 2 Jul 2026 16:03:18 +0800
Subject: [PATCH] Add frontend backup upload and list-based restore with
validation.
Co-authored-by: Cursor
---
app.py | 1 +
modules/backup/db_backup.py | 445 +++++++++++++++++++++++++++-
modules/backup/restore_job.py | 26 ++
modules/backup/routes.py | 112 ++++---
modules/settings/routes.py | 8 +-
modules/web/static/js/settings.js | 134 +++++++++
modules/web/templates/settings.html | 68 ++++-
7 files changed, 726 insertions(+), 68 deletions(-)
create mode 100644 modules/backup/restore_job.py
diff --git a/app.py b/app.py
index f73cb7e..22e51dd 100644
--- a/app.py
+++ b/app.py
@@ -92,6 +92,7 @@ app = Flask(
static_folder=os.path.join(ROOT, "modules", "web", "static"),
)
app.secret_key = os.getenv("SECRET_KEY", "futures_monitor_default_secret")
+app.config["MAX_CONTENT_LENGTH"] = int(os.getenv("MAX_BACKUP_UPLOAD_MB", "500")) * 1024 * 1024
HOST = os.getenv("HOST", "0.0.0.0")
PORT = int(os.getenv("PORT", "6600"))
diff --git a/modules/backup/db_backup.py b/modules/backup/db_backup.py
index f0d0f7b..f15373a 100644
--- a/modules/backup/db_backup.py
+++ b/modules/backup/db_backup.py
@@ -13,13 +13,14 @@ import re
import shutil
import sqlite3
import subprocess
+import sys
import tarfile
import tempfile
import threading
import time
from datetime import datetime
from pathlib import Path
-from typing import Callable, Optional
+from typing import Any, Callable, IO, Optional
from zoneinfo import ZoneInfo
from modules.core.db_conn import DB_PATH, db_backend
@@ -36,6 +37,8 @@ DEFAULT_KEEP_COUNT = 30
DEFAULT_AUTO_HOUR = 3
CHECK_INTERVAL_SEC = 3600
_backup_lock = threading.Lock()
+RESTORE_STATUS_FILE = "restore_status.json"
+RESTORE_CONFIRM_TOKEN = "RESTORE"
RESTORE_MD = """# qihuo 备份恢复说明
@@ -130,6 +133,14 @@ def default_restore_dir() -> str:
return "/root/qihuo"
+def restore_target_dir() -> Path:
+ """Web/API 恢复目标目录,默认当前应用根目录。"""
+ env = (os.getenv("QIHUO_RESTORE_DIR") or "").strip()
+ if env:
+ return Path(env)
+ return _app_root()
+
+
def backup_dir() -> Path:
path = Path(default_backup_dir())
path.mkdir(parents=True, exist_ok=True)
@@ -140,6 +151,402 @@ def backup_in_progress() -> bool:
return _backup_lock.locked()
+def _restore_status_path() -> Path:
+ from modules.core.paths import DATA_DIR
+
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
+ return DATA_DIR / RESTORE_STATUS_FILE
+
+
+def _write_restore_status(state: str, message: str = "", **extra: Any) -> None:
+ payload = {
+ "state": state,
+ "message": message,
+ "updated_at": datetime.now(TZ).isoformat(timespec="seconds"),
+ }
+ payload.update(extra)
+ _restore_status_path().write_text(
+ json.dumps(payload, ensure_ascii=False, indent=2),
+ encoding="utf-8",
+ )
+
+
+def get_restore_status() -> dict:
+ path = _restore_status_path()
+ if not path.is_file():
+ return {"state": "idle", "message": "", "updated_at": ""}
+ try:
+ data = json.loads(path.read_text(encoding="utf-8"))
+ if isinstance(data, dict):
+ data.setdefault("state", "idle")
+ data.setdefault("message", "")
+ return data
+ except Exception:
+ pass
+ return {"state": "idle", "message": "", "updated_at": ""}
+
+
+def restore_in_progress() -> bool:
+ return get_restore_status().get("state") in ("pending", "running")
+
+
+def _manifest_root_prefix(tar: tarfile.TarFile) -> str:
+ for member in tar.getmembers():
+ name = member.name.rstrip("/")
+ if name.endswith("/manifest.json") or name == "manifest.json":
+ if name == "manifest.json":
+ return ""
+ return name[: -len("/manifest.json")]
+ raise ValueError("备份包缺少 manifest.json")
+
+
+def _read_manifest_from_tar(tar: tarfile.TarFile) -> dict:
+ root = _manifest_root_prefix(tar)
+ manifest_name = f"{root}/manifest.json" if root else "manifest.json"
+ try:
+ member = tar.getmember(manifest_name)
+ except KeyError as exc:
+ raise ValueError("备份包缺少 manifest.json") from exc
+ extracted = tar.extractfile(member)
+ if not extracted:
+ raise ValueError("无法读取 manifest.json")
+ data = json.loads(extracted.read().decode("utf-8"))
+ if not isinstance(data, dict):
+ raise ValueError("manifest.json 格式无效")
+ return data
+
+
+def _validate_manifest(manifest: dict, *, current_backend: str | None = None) -> str:
+ if manifest.get("app") != "qihuo":
+ raise ValueError("不是有效的 qihuo 备份包")
+ backend = (manifest.get("backend") or "").strip()
+ if backend not in ("sqlite", "postgres"):
+ raise ValueError("manifest 缺少或无效的数据库类型")
+ if current_backend and backend != current_backend:
+ label = "PostgreSQL" if backend == "postgres" else "SQLite"
+ cur = "PostgreSQL" if current_backend == "postgres" else "SQLite"
+ raise ValueError(f"备份为 {label},当前服务为 {cur},无法恢复")
+ return backend
+
+
+def _member_exists(tar: tarfile.TarFile, root: str, name: str) -> bool:
+ candidates = [name]
+ if root:
+ candidates.append(f"{root}/{name}")
+ return any(_tar_has_member(tar, path) for path in candidates)
+
+
+def _tar_has_member(tar: tarfile.TarFile, path: str) -> bool:
+ try:
+ tar.getmember(path)
+ return True
+ except KeyError:
+ return False
+
+
+def _validate_archive_contents(tar: tarfile.TarFile, manifest: dict, root: str) -> None:
+ backend = manifest["backend"]
+ if backend == "sqlite":
+ if not _member_exists(tar, root, "futures.db"):
+ raise ValueError("SQLite 备份缺少 futures.db")
+ elif not _member_exists(tar, root, "postgres_dump.sql"):
+ raise ValueError("PostgreSQL 备份缺少 postgres_dump.sql")
+
+
+def _manifest_preview(manifest: dict, path: Path) -> dict:
+ backend = manifest.get("backend", "")
+ stat = path.stat()
+ created_at = (manifest.get("created_at") or "").strip()
+ return {
+ "name": path.name,
+ "backend": backend,
+ "backend_label": "PostgreSQL" if backend == "postgres" else "SQLite",
+ "created_at": created_at,
+ "includes_uploads": bool(manifest.get("includes_uploads")),
+ "includes_env": bool(manifest.get("includes_env")),
+ "env_restore_path": (manifest.get("env_restore_path") or "").strip(),
+ "size": stat.st_size,
+ "size_mb": round(stat.st_size / (1024 * 1024), 2),
+ "mtime": datetime.fromtimestamp(stat.st_mtime, TZ).isoformat(timespec="seconds"),
+ }
+
+
+def peek_manifest(path: Path) -> dict:
+ with tarfile.open(path, "r:gz") as tar:
+ return _read_manifest_from_tar(tar)
+
+
+def inspect_backup_archive(path: Path, *, check_backend: bool = True) -> dict:
+ with tarfile.open(path, "r:gz") as tar:
+ manifest = _read_manifest_from_tar(tar)
+ current = db_backend() if check_backend else None
+ _validate_manifest(manifest, current_backend=current)
+ root = _manifest_root_prefix(tar)
+ _validate_archive_contents(tar, manifest, root)
+ return _manifest_preview(manifest, path)
+
+
+def _allocate_backup_filename(manifest: dict, preferred: str = "") -> str:
+ preferred = (preferred or "").strip()
+ if preferred and BACKUP_FILENAME_RE.match(preferred):
+ candidate = backup_dir() / preferred
+ if not candidate.exists():
+ return preferred
+ created = (manifest.get("created_at") or "").strip()
+ stamp = ""
+ if created:
+ try:
+ stamp = datetime.fromisoformat(created).strftime("%Y%m%d_%H%M%S")
+ except ValueError:
+ stamp = ""
+ if not stamp:
+ stamp = datetime.now(TZ).strftime("%Y%m%d_%H%M%S")
+ name = f"qihuo_backup_{stamp}.tar.gz"
+ if not (backup_dir() / name).exists():
+ return name
+ stamp = datetime.now(TZ).strftime("%Y%m%d_%H%M%S")
+ return f"qihuo_backup_{stamp}.tar.gz"
+
+
+def save_uploaded_backup(stream: IO[bytes], original_filename: str = "") -> tuple[str, dict]:
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".tar.gz") as tmp:
+ shutil.copyfileobj(stream, tmp)
+ tmp_path = Path(tmp.name)
+ try:
+ info = inspect_backup_archive(tmp_path, check_backend=True)
+ manifest = peek_manifest(tmp_path)
+ filename = _allocate_backup_filename(manifest, original_filename)
+ dest = backup_dir() / filename
+ shutil.move(str(tmp_path), str(dest))
+ info["name"] = filename
+ return filename, info
+ except Exception:
+ tmp_path.unlink(missing_ok=True)
+ raise
+
+
+def _pm2_available() -> bool:
+ return shutil.which("pm2") is not None
+
+
+def _pm2_stop() -> None:
+ if not _pm2_available():
+ logger.warning("pm2 not found, skip stop")
+ return
+ proc = subprocess.run(
+ ["pm2", "stop", "qihuo"],
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+ if proc.returncode != 0:
+ logger.warning("pm2 stop qihuo: %s", proc.stderr.strip() or proc.stdout.strip())
+
+
+def _pm2_restart() -> None:
+ if not _pm2_available():
+ logger.warning("pm2 not found, skip restart")
+ return
+ proc = subprocess.run(
+ ["pm2", "restart", "qihuo"],
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+ if proc.returncode != 0:
+ proc = subprocess.run(
+ ["pm2", "start", "qihuo"],
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+ if proc.returncode != 0:
+ raise RuntimeError(proc.stderr.strip() or proc.stdout.strip() or "pm2 restart 失败")
+
+
+def _extract_member_to_path(tar: tarfile.TarFile, member_name: str, dest: Path) -> None:
+ try:
+ member = tar.getmember(member_name)
+ except KeyError:
+ return
+ extracted = tar.extractfile(member)
+ if not extracted:
+ return
+ dest.parent.mkdir(parents=True, exist_ok=True)
+ with open(dest, "wb") as out:
+ shutil.copyfileobj(extracted, out)
+
+
+def _restore_uploads_dir(tar: tarfile.TarFile, root: str, restore_dir: Path) -> None:
+ prefix = f"{root}/uploads" if root else "uploads"
+ uploads_dest = restore_dir / "uploads"
+ uploads_dest.mkdir(parents=True, exist_ok=True)
+ found = False
+ for member in tar.getmembers():
+ if member.name == prefix or member.name.startswith(prefix + "/"):
+ found = True
+ rel = member.name[len(prefix) :].lstrip("/")
+ if not rel:
+ continue
+ target = uploads_dest / rel
+ if member.isdir():
+ target.mkdir(parents=True, exist_ok=True)
+ else:
+ target.parent.mkdir(parents=True, exist_ok=True)
+ extracted = tar.extractfile(member)
+ if extracted:
+ with open(target, "wb") as out:
+ shutil.copyfileobj(extracted, out)
+ if not found:
+ logger.info("backup has no uploads/")
+
+
+def _reload_env_file(env_path: Path) -> None:
+ if not env_path.is_file():
+ return
+ try:
+ from dotenv import load_dotenv
+
+ load_dotenv(str(env_path), override=True)
+ except Exception as exc:
+ logger.warning("reload .env failed: %s", exc)
+
+
+def _restore_postgres_dump(dump_path: Path) -> None:
+ url = (os.getenv("DATABASE_URL") or "").strip()
+ if not url:
+ raise RuntimeError("PostgreSQL 恢复需要 DATABASE_URL(请先恢复 .env 或检查环境变量)")
+ if not shutil.which("psql"):
+ raise RuntimeError("未找到 psql,请先安装 PostgreSQL 客户端")
+ proc = subprocess.run(
+ ["psql", url, "-f", str(dump_path)],
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+ if proc.returncode != 0:
+ raise RuntimeError(proc.stderr.strip() or proc.stdout.strip() or "psql 导入失败")
+
+
+def _perform_restore(archive_path: Path, restore_dir: Path) -> dict:
+ restore_dir.mkdir(parents=True, exist_ok=True)
+ with tempfile.TemporaryDirectory(prefix="qihuo_restore_") as tmp:
+ work = Path(tmp)
+ with tarfile.open(archive_path, "r:gz") as tar:
+ manifest = _read_manifest_from_tar(tar)
+ backend = _validate_manifest(manifest, current_backend=db_backend())
+ root = _manifest_root_prefix(tar)
+ _validate_archive_contents(tar, manifest, root)
+
+ env_member = f"{root}/.env" if root else ".env"
+ env_restore_path = (manifest.get("env_restore_path") or "config/.env").strip()
+ if manifest.get("includes_env") and _tar_has_member(tar, env_member):
+ env_dest = restore_dir / env_restore_path
+ _extract_member_to_path(tar, env_member, env_dest)
+ _reload_env_file(env_dest)
+
+ if backend == "sqlite":
+ db_member = f"{root}/futures.db" if root else "futures.db"
+ db_dest = Path(DB_PATH)
+ if not db_dest.is_absolute():
+ db_dest = restore_dir / db_dest.name
+ _extract_member_to_path(tar, db_member, db_dest)
+ else:
+ dump_member = f"{root}/postgres_dump.sql" if root else "postgres_dump.sql"
+ dump_path = work / "postgres_dump.sql"
+ _extract_member_to_path(tar, dump_member, dump_path)
+ _restore_postgres_dump(dump_path)
+
+ _restore_uploads_dir(tar, root, restore_dir)
+
+ return {
+ "backend": backend,
+ "restore_dir": str(restore_dir),
+ "includes_env": bool(manifest.get("includes_env")),
+ "includes_uploads": bool(manifest.get("includes_uploads")),
+ }
+
+
+def run_restore_job(archive_path: Path) -> None:
+ filename = archive_path.name
+ restore_dir = restore_target_dir()
+ try:
+ _write_restore_status(
+ "running",
+ "正在停止服务…",
+ filename=filename,
+ step="stop",
+ restore_dir=str(restore_dir),
+ )
+ _pm2_stop()
+
+ _write_restore_status(
+ "running",
+ "正在恢复数据…",
+ filename=filename,
+ step="restore",
+ restore_dir=str(restore_dir),
+ )
+ summary = _perform_restore(archive_path.resolve(), restore_dir)
+
+ _write_restore_status(
+ "running",
+ "正在重启服务…",
+ filename=filename,
+ step="restart",
+ restore_dir=str(restore_dir),
+ )
+ _pm2_restart()
+
+ _write_restore_status(
+ "done",
+ "恢复完成,服务已重启",
+ filename=filename,
+ restore_dir=str(restore_dir),
+ summary=summary,
+ )
+ except Exception as exc:
+ logger.exception("restore failed: %s", exc)
+ _write_restore_status(
+ "error",
+ str(exc),
+ filename=filename,
+ restore_dir=str(restore_dir),
+ )
+ try:
+ _pm2_restart()
+ except Exception as restart_exc:
+ logger.warning("restart after restore error: %s", restart_exc)
+
+
+def schedule_restore(filename: str) -> tuple[bool, str]:
+ if _backup_lock.locked():
+ return False, "备份进行中,请稍后再试"
+ if restore_in_progress():
+ return False, "恢复进行中,请稍后再试"
+ try:
+ path = resolve_backup_file(filename)
+ inspect_backup_archive(path, check_backend=True)
+ except (ValueError, FileNotFoundError) as exc:
+ return False, str(exc)
+
+ _write_restore_status(
+ "pending",
+ "恢复任务已提交…",
+ filename=filename,
+ restore_dir=str(restore_target_dir()),
+ )
+ script = Path(__file__).resolve().parent / "restore_job.py"
+ subprocess.Popen(
+ [sys.executable, str(script), str(path.resolve())],
+ start_new_session=True,
+ cwd=str(_app_root()),
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ )
+ return True, "恢复已开始,服务将短暂中断后自动重启"
+
+
def get_backup_last_at(get_setting: Callable[[str, str], str]) -> str:
return (get_setting(BACKUP_LAST_KEY, "") or "").strip()
@@ -316,20 +723,36 @@ def create_backup(*, include_uploads: bool = True) -> tuple[str, str]:
return filename, f"备份已生成 {filename}({label},{size_mb:.2f} MB)"
-def list_backups() -> list[dict]:
+def list_backups(*, with_manifest: bool = True) -> list[dict]:
items: list[dict] = []
for path in sorted(backup_dir().glob("qihuo_backup_*.tar.gz"), reverse=True):
if not BACKUP_FILENAME_RE.match(path.name):
continue
stat = path.stat()
- items.append(
- {
- "name": path.name,
- "size": stat.st_size,
- "size_mb": round(stat.st_size / (1024 * 1024), 2),
- "mtime": datetime.fromtimestamp(stat.st_mtime, TZ).isoformat(timespec="seconds"),
- }
- )
+ item = {
+ "name": path.name,
+ "size": stat.st_size,
+ "size_mb": round(stat.st_size / (1024 * 1024), 2),
+ "mtime": datetime.fromtimestamp(stat.st_mtime, TZ).isoformat(timespec="seconds"),
+ "backend": "",
+ "backend_label": "",
+ "created_at": "",
+ "includes_env": False,
+ "includes_uploads": False,
+ }
+ if with_manifest:
+ try:
+ manifest = peek_manifest(path)
+ item["backend"] = manifest.get("backend", "")
+ item["backend_label"] = (
+ "PostgreSQL" if manifest.get("backend") == "postgres" else "SQLite"
+ )
+ item["created_at"] = (manifest.get("created_at") or "").strip()
+ item["includes_env"] = bool(manifest.get("includes_env"))
+ item["includes_uploads"] = bool(manifest.get("includes_uploads"))
+ except Exception as exc:
+ logger.debug("read manifest %s: %s", path.name, exc)
+ items.append(item)
return items
@@ -386,6 +809,8 @@ def schedule_backup(
) -> tuple[bool, str]:
if _backup_lock.locked():
return False, "备份进行中,请稍后再试"
+ if restore_in_progress():
+ return False, "恢复进行中,请稍后再试"
def _run() -> None:
try:
diff --git a/modules/backup/restore_job.py b/modules/backup/restore_job.py
new file mode 100644
index 0000000..470b2b9
--- /dev/null
+++ b/modules/backup/restore_job.py
@@ -0,0 +1,26 @@
+# Copyright (c) 2025-2026 马建军. All rights reserved.
+"""Detached restore worker — survives pm2 stop of the parent web process."""
+
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+
+def main(argv: list[str] | None = None) -> int:
+ args = argv if argv is not None else sys.argv[1:]
+ if len(args) != 1:
+ print("usage: python -m modules.backup.restore_job ", file=sys.stderr)
+ return 2
+ archive = Path(args[0]).resolve()
+ if not archive.is_file():
+ print(f"backup not found: {archive}", file=sys.stderr)
+ return 1
+ from modules.backup.db_backup import run_restore_job
+
+ run_restore_job(archive)
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/modules/backup/routes.py b/modules/backup/routes.py
index b7e3628..6204d09 100644
--- a/modules/backup/routes.py
+++ b/modules/backup/routes.py
@@ -3,55 +3,31 @@
from __future__ import annotations
-from datetime import date, datetime
+import logging
-from flask import (
- Response,
- flash,
- jsonify,
- redirect,
- render_template,
- request,
- send_file,
- session,
- stream_with_context,
- url_for,
-)
+from flask import jsonify, request, send_file
+
+logger = logging.getLogger(__name__)
def register(deps) -> None:
app = deps.app
login_required = deps.login_required
- require_nav = deps.require_nav
- get_db = deps.get_db
get_setting = deps.get_setting
- set_setting = deps.set_setting
- fetch_price = deps.fetch_price
- send_wechat_msg = deps.send_wechat_msg
- touch_stats_cache = deps.touch_stats_cache
- get_stats_data = deps.get_stats_data
- build_market_quote_payload = deps.build_market_quote_payload
- today_str = deps.today_str
- expire_old_plans = deps.expire_old_plans
- TZ = deps.tz
- DB_PATH = deps.db_path
- UPLOAD_DIR = deps.upload_dir
- OPEN_TYPES = deps.open_types
- EXIT_TRIGGERS = deps.exit_triggers
- BEHAVIOR_TAGS = deps.behavior_tags
- KLINE_PERIODS = deps.kline_periods
- KLINE_CUTOFFS = deps.kline_cutoffs
- calc_holding_duration = deps.calc_holding_duration
- holding_to_minutes = deps.holding_to_minutes
- classify_close_result = deps.classify_close_result
- calc_rr_ratio = deps.calc_rr_ratio
- calc_theoretical_pnl = deps.calc_theoretical_pnl
- parse_review_date_filter = deps.parse_review_date_filter
- _trading_mode = deps.trading_mode
- _ua_is_phone = deps.ua_is_phone
- _static_asset_v = deps.static_asset_v
- from modules.backup.db_backup import list_backups, resolve_backup_file
+ from modules.backup.db_backup import (
+ RESTORE_CONFIRM_TOKEN,
+ backup_dir,
+ backup_in_progress,
+ get_backup_last_at,
+ get_restore_status,
+ inspect_backup_archive,
+ list_backups,
+ resolve_backup_file,
+ restore_in_progress,
+ save_uploaded_backup,
+ schedule_restore,
+ )
@app.route("/api/backup/list")
@login_required
@@ -61,18 +37,66 @@ def register(deps) -> None:
"dir": str(backup_dir()),
"last_at": get_backup_last_at(get_setting),
"running": backup_in_progress(),
+ "restore": get_restore_status(),
"items": list_backups(),
}
)
-
@app.route("/api/backup/download/")
@login_required
def api_backup_download(filename):
- from flask import send_file
-
try:
path = resolve_backup_file(filename)
except (ValueError, FileNotFoundError) as exc:
return jsonify({"error": str(exc)}), 404
return send_file(path, as_attachment=True, download_name=path.name)
+
+ @app.route("/api/backup/upload", methods=["POST"])
+ @login_required
+ def api_backup_upload():
+ if backup_in_progress():
+ return jsonify({"error": "备份进行中,请稍后再试"}), 409
+ if restore_in_progress():
+ return jsonify({"error": "恢复进行中,请稍后再试"}), 409
+ upload = request.files.get("file")
+ if not upload or not upload.filename:
+ return jsonify({"error": "请选择备份文件"}), 400
+ if not upload.filename.lower().endswith(".tar.gz"):
+ return jsonify({"error": "仅支持 .tar.gz 备份包"}), 400
+ try:
+ name, info = save_uploaded_backup(upload.stream, upload.filename)
+ return jsonify({"ok": True, "name": name, "info": info})
+ except ValueError as exc:
+ return jsonify({"error": str(exc)}), 400
+ except Exception:
+ logger.exception("backup upload failed")
+ return jsonify({"error": "上传失败,请检查备份包是否完整"}), 500
+
+ @app.route("/api/backup/info/")
+ @login_required
+ def api_backup_info(filename):
+ try:
+ path = resolve_backup_file(filename)
+ return jsonify(inspect_backup_archive(path, check_backend=True))
+ except (ValueError, FileNotFoundError) as exc:
+ return jsonify({"error": str(exc)}), 404
+
+ @app.route("/api/backup/restore", methods=["POST"])
+ @login_required
+ def api_backup_restore():
+ data = request.get_json(silent=True) or {}
+ filename = (data.get("filename") or request.form.get("filename") or "").strip()
+ confirm = (data.get("confirm") or request.form.get("confirm") or "").strip()
+ if confirm != RESTORE_CONFIRM_TOKEN:
+ return jsonify({"error": "请确认恢复操作"}), 400
+ if not filename:
+ return jsonify({"error": "缺少备份文件名"}), 400
+ ok, msg = schedule_restore(filename)
+ if ok:
+ return jsonify({"ok": True, "message": msg}), 202
+ return jsonify({"error": msg}), 409
+
+ @app.route("/api/backup/restore/status")
+ @login_required
+ def api_backup_restore_status():
+ return jsonify(get_restore_status())
diff --git a/modules/settings/routes.py b/modules/settings/routes.py
index 17aeddc..c7e8c91 100644
--- a/modules/settings/routes.py
+++ b/modules/settings/routes.py
@@ -56,9 +56,11 @@ def register(deps) -> None:
from modules.backup.db_backup import (
backup_dir,
backup_in_progress,
- default_restore_dir,
get_backup_last_at,
+ get_restore_status,
list_backups,
+ restore_in_progress,
+ restore_target_dir,
schedule_backup,
)
from modules.market.market import get_quote_source_label
@@ -300,7 +302,9 @@ def register(deps) -> None:
backup_auto_enabled=get_setting("backup_auto_enabled", "1") == "1",
backup_auto_hour=get_setting("backup_auto_hour", "3"),
backup_keep_count=get_setting("backup_keep_count", "30"),
- backup_restore_dir=default_restore_dir(),
+ backup_restore_dir=str(restore_target_dir()),
+ backup_restore_running=restore_in_progress(),
+ restore_status=get_restore_status(),
ai_enabled=get_setting("ai_enabled", "0") == "1",
ai_provider=get_setting("ai_provider", "ollama"),
ai_ollama_base_url=get_setting("ai_ollama_base_url", "http://127.0.0.1:11434"),
diff --git a/modules/web/static/js/settings.js b/modules/web/static/js/settings.js
index 4ac64e1..47a05f7 100644
--- a/modules/web/static/js/settings.js
+++ b/modules/web/static/js/settings.js
@@ -116,6 +116,140 @@
});
loadCtpFoldState();
+ function initBackupPanel() {
+ var uploadBtn = document.getElementById('backup-upload-btn');
+ var uploadInput = document.getElementById('backup-upload-file');
+ var statusEl = document.getElementById('backup-restore-status');
+ var pollTimer = null;
+
+ function setRestoreStatus(data) {
+ if (!statusEl || !data) return;
+ var state = data.state || 'idle';
+ statusEl.hidden = state === 'idle';
+ statusEl.classList.remove('is-running', 'is-error', 'is-done');
+ if (state === 'pending' || state === 'running') {
+ statusEl.classList.add('is-running');
+ statusEl.textContent = data.message || '恢复进行中…';
+ } else if (state === 'done') {
+ statusEl.classList.add('is-done');
+ statusEl.textContent = data.message || '恢复完成';
+ } else if (state === 'error') {
+ statusEl.classList.add('is-error');
+ statusEl.textContent = '恢复失败:' + (data.message || '未知错误');
+ } else {
+ statusEl.textContent = data.message || '';
+ }
+ }
+
+ function setBusy(busy) {
+ document.querySelectorAll('[data-backup-restore], #backup-upload-btn').forEach(function (btn) {
+ btn.disabled = !!busy;
+ });
+ }
+
+ function pollRestoreStatus() {
+ fetch('/api/backup/restore/status', { credentials: 'same-origin' })
+ .then(function (res) { return res.json(); })
+ .then(function (data) {
+ setRestoreStatus(data);
+ var active = data.state === 'pending' || data.state === 'running';
+ setBusy(active);
+ if (active) {
+ if (!pollTimer) {
+ pollTimer = window.setInterval(pollRestoreStatus, 2500);
+ }
+ } else if (pollTimer) {
+ window.clearInterval(pollTimer);
+ pollTimer = null;
+ if (data.state === 'done') {
+ window.setTimeout(function () { window.location.reload(); }, 1200);
+ }
+ }
+ })
+ .catch(function () { /* ignore */ });
+ }
+
+ if (uploadBtn && uploadInput && !uploadBtn.dataset.settingsBound) {
+ uploadBtn.dataset.settingsBound = '1';
+ uploadBtn.addEventListener('click', function () {
+ var file = uploadInput.files && uploadInput.files[0];
+ if (!file) {
+ window.alert('请先选择 .tar.gz 备份文件');
+ return;
+ }
+ if (!/\.tar\.gz$/i.test(file.name)) {
+ window.alert('仅支持 .tar.gz 格式');
+ return;
+ }
+ var form = new FormData();
+ form.append('file', file);
+ uploadBtn.disabled = true;
+ uploadBtn.textContent = '上传中…';
+ fetch('/api/backup/upload', { method: 'POST', body: form, credentials: 'same-origin' })
+ .then(function (res) { return res.json().then(function (body) { return { ok: res.ok, body: body }; }); })
+ .then(function (result) {
+ if (!result.ok) {
+ throw new Error((result.body && result.body.error) || '上传失败');
+ }
+ window.alert('上传成功:' + (result.body.name || file.name));
+ window.location.reload();
+ })
+ .catch(function (err) {
+ window.alert(err.message || '上传失败');
+ })
+ .finally(function () {
+ uploadBtn.disabled = false;
+ uploadBtn.textContent = '上传并校验';
+ });
+ });
+ }
+
+ document.querySelectorAll('[data-backup-restore]').forEach(function (btn) {
+ if (btn.dataset.settingsBound) return;
+ btn.dataset.settingsBound = '1';
+ btn.addEventListener('click', function () {
+ var name = btn.getAttribute('data-backup-restore') || '';
+ if (!name) return;
+ var ok = window.confirm(
+ '确定要恢复备份「' + name + '」吗?\n\n'
+ + '将停止服务并覆盖当前数据库、uploads 与 .env,完成后自动重启。\n'
+ + '此操作不可撤销,请确认已做好当前数据备份。'
+ );
+ if (!ok) return;
+ var typed = window.prompt('请输入 RESTORE 确认恢复:');
+ if (typed !== 'RESTORE') {
+ if (typed !== null) window.alert('确认文字不正确,已取消');
+ return;
+ }
+ btn.disabled = true;
+ fetch('/api/backup/restore', {
+ method: 'POST',
+ credentials: 'same-origin',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ filename: name, confirm: 'RESTORE' })
+ })
+ .then(function (res) { return res.json().then(function (body) { return { ok: res.ok, body: body }; }); })
+ .then(function (result) {
+ if (!result.ok) {
+ throw new Error((result.body && result.body.error) || '恢复启动失败');
+ }
+ setRestoreStatus({ state: 'pending', message: result.body.message || '恢复已开始…' });
+ setBusy(true);
+ pollRestoreStatus();
+ })
+ .catch(function (err) {
+ window.alert(err.message || '恢复启动失败');
+ btn.disabled = false;
+ });
+ });
+ });
+
+ if (statusEl && !statusEl.hidden) {
+ pollRestoreStatus();
+ }
+ }
+ initBackupPanel();
+
var ctpForm = document.getElementById('ctp-settings-form');
if (ctpForm && !ctpForm.dataset.settingsBound) {
ctpForm.dataset.settingsBound = '1';
diff --git a/modules/web/templates/settings.html b/modules/web/templates/settings.html
index 47e7f27..7f59005 100644
--- a/modules/web/templates/settings.html
+++ b/modules/web/templates/settings.html
@@ -72,6 +72,26 @@
.settings-backup-restore summary{cursor:pointer;color:var(--text-title);font-weight:600}
.settings-backup-meta{font-size:.82rem;color:var(--text-muted);line-height:1.55;margin:.35rem 0 .65rem}
.settings-backup-actions{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem .65rem}
+.settings-backup-upload{
+ margin-top:.65rem;padding:.65rem .75rem;border-radius:8px;
+ border:1px dashed var(--border);background:var(--card-inner);
+}
+.settings-backup-upload label{font-size:.82rem;color:var(--text-muted);display:block;margin-bottom:.4rem}
+.settings-backup-upload-row{display:flex;flex-wrap:wrap;align-items:center;gap:.45rem .6rem}
+.settings-backup-upload input[type=file]{font-size:.78rem;max-width:100%}
+.settings-backup-status{
+ margin-top:.55rem;padding:.55rem .7rem;border-radius:8px;font-size:.82rem;line-height:1.5;
+ border:1px solid var(--border);background:var(--card-inner);color:var(--text-muted);
+}
+.settings-backup-status.is-running{border-color:var(--accent);color:var(--text-title)}
+.settings-backup-status.is-error{border-color:#c44;color:#c44}
+.settings-backup-status.is-done{border-color:#3a8;color:#3a8}
+.settings-backup-restore-btn{
+ border:1px solid #c44;background:transparent;color:#c44;cursor:pointer;
+ padding:.2rem .45rem;border-radius:6px;font-size:.75rem;font-weight:600;
+}
+.settings-backup-restore-btn:hover{background:rgba(204,68,68,.08)}
+.settings-backup-restore-btn:disabled{opacity:.45;cursor:not-allowed}
.settings-backup-download{color:var(--accent);text-decoration:none;font-weight:600}
.settings-backup-download:hover{text-decoration:underline}
.settings-admin-row .settings-compact-card{font-size:.78rem}
@@ -508,6 +528,7 @@
自动备份目录:{{ backup_dir }}
{% if backup_last_at %} · 上次备份 {{ backup_last_at.replace('T', ' ') }}{% else %} · 尚未备份{% endif %}
{% if backup_running %} · 备份进行中…{% endif %}
+ {% if backup_restore_running %} · 恢复进行中…{% endif %}