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 %}

@@ -532,23 +553,47 @@
- +
-

备份含 futures.dbuploads/.env,默认恢复至 {{ backup_restore_dir }}

+
+ +
+ + +
+

上传后会校验 manifest 与包结构,通过后加入下方列表。

+
+
+ {% if restore_status.state in ('pending','running') %} + {{ restore_status.message or '恢复进行中…' }} + {% elif restore_status.state == 'done' %} + {{ restore_status.message or '恢复完成' }} + {% elif restore_status.state == 'error' %} + 恢复失败:{{ restore_status.message or '未知错误' }} + {% else %} + {{ restore_status.message }} + {% endif %} +
+

备份含 futures.db / postgres_dump.sqluploads/.env。网页恢复目标:{{ backup_restore_dir }}

{% if backup_items %} - +
- + {% for item in backup_items %} - + + + - - + + {% endfor %} @@ -560,11 +605,10 @@
备份恢复说明
    -
  1. 下载 .tar.gz 到目标服务器(如 /root/)。
  2. -
  3. 解压:tar -xzf qihuo_backup_*.tar.gz
  4. -
  5. 执行:chmod +x restore.sh && ./restore.sh
  6. -
  7. 指定目录:RESTORE_DIR=/opt/qihuo ./restore.sh
  8. -
  9. 恢复脚本会自动还原数据库、uploads/.env,然后重启服务。
  10. +
  11. 可在上方上传 .tar.gz,或从列表下载备份到本机。
  12. +
  13. 点击「恢复此备份」会停止服务、还原数据库 / uploads/ / .env,然后自动重启。
  14. +
  15. 恢复前请确认备份类型与当前服务一致(SQLite / PostgreSQL)。
  16. +
  17. 也可在服务器手工执行包内 restore.sh(见 RESTORE_DIR)。
{% endcall %}
文件名大小时间
文件名类型.env大小时间操作
{{ item.name }}{{ item.backend_label or '—' }}{% if item.includes_env %}有{% else %}—{% endif %} {{ item.size_mb }} MB{{ item.mtime.replace('T', ' ')[:16] }}下载{{ (item.created_at or item.mtime).replace('T', ' ')[:16] }} + 下载 + +