Add frontend backup upload and list-based restore with validation.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-02 16:03:18 +08:00
parent 481086eddc
commit 9379bc4f4f
7 changed files with 726 additions and 68 deletions
+1
View File
@@ -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"))
+435 -10
View File
@@ -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:
+26
View File
@@ -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 <backup.tar.gz>", 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())
+68 -44
View File
@@ -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/<filename>")
@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/<filename>")
@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())
+6 -2
View File
@@ -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"),
+134
View File
@@ -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';
+56 -12
View File
@@ -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 @@
自动备份目录:<code>{{ backup_dir }}</code>
{% if backup_last_at %} · 上次备份 {{ backup_last_at.replace('T', ' ') }}{% else %} · 尚未备份{% endif %}
{% if backup_running %} · <span style="color:var(--accent)">备份进行中…</span>{% endif %}
{% if backup_restore_running %} · <span style="color:#c44">恢复进行中…</span>{% endif %}
</p>
<form action="{{ url_for('settings') }}" method="post" style="margin-bottom:.55rem">
<input type="hidden" name="action" value="backup_config">
@@ -532,23 +553,47 @@
<div class="settings-backup-actions">
<form action="{{ url_for('settings') }}" method="post">
<input type="hidden" name="action" value="backup_now">
<button type="submit" class="btn-primary" {% if backup_running %}disabled{% endif %}>立即备份</button>
<button type="submit" class="btn-primary" {% if backup_running or backup_restore_running %}disabled{% endif %}>立即备份</button>
</form>
</div>
<p class="hint" style="margin:.5rem 0 0">备份含 <code>futures.db</code><code>uploads/</code><code>.env</code>,默认恢复至 <code>{{ backup_restore_dir }}</code></p>
<div class="settings-backup-upload" id="backup-upload-panel">
<label for="backup-upload-file">上传备份包(.tar.gz</label>
<div class="settings-backup-upload-row">
<input id="backup-upload-file" type="file" accept=".tar.gz,application/gzip,application/x-gzip">
<button type="button" class="btn-primary" id="backup-upload-btn" {% if backup_running or backup_restore_running %}disabled{% endif %}>上传并校验</button>
</div>
<p class="hint" style="margin:.45rem 0 0">上传后会校验 manifest 与包结构,通过后加入下方列表。</p>
</div>
<div class="settings-backup-status{% if restore_status.state in ('pending','running') %} is-running{% elif restore_status.state == 'error' %} is-error{% elif restore_status.state == 'done' %} is-done{% endif %}" id="backup-restore-status"{% if restore_status.state == 'idle' %} hidden{% endif %}>
{% 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 %}
</div>
<p class="hint" style="margin:.5rem 0 0">备份含 <code>futures.db</code> / <code>postgres_dump.sql</code><code>uploads/</code><code>.env</code>。网页恢复目标:<code>{{ backup_restore_dir }}</code></p>
{% if backup_items %}
<table class="settings-backup-table">
<table class="settings-backup-table" id="backup-items-table">
<thead>
<tr><th>文件名</th><th>大小</th><th>时间</th><th></th></tr>
<tr><th>文件名</th><th>类型</th><th>.env</th><th>大小</th><th>时间</th><th>操作</th></tr>
</thead>
<tbody>
{% for item in backup_items %}
<tr>
<tr data-backup-name="{{ item.name }}">
<td><code>{{ item.name }}</code></td>
<td>{{ item.backend_label or '—' }}</td>
<td>{% if item.includes_env %}有{% else %}—{% endif %}</td>
<td>{{ item.size_mb }} MB</td>
<td>{{ item.mtime.replace('T', ' ')[:16] }}</td>
<td><a href="{{ url_for('api_backup_download', filename=item.name) }}" class="settings-backup-download">下载</a></td>
<td>{{ (item.created_at or item.mtime).replace('T', ' ')[:16] }}</td>
<td class="settings-backup-actions" style="margin:0">
<a href="{{ url_for('api_backup_download', filename=item.name) }}" class="settings-backup-download">下载</a>
<button type="button" class="settings-backup-restore-btn" data-backup-restore="{{ item.name }}" {% if backup_running or backup_restore_running %}disabled{% endif %}>恢复此备份</button>
</td>
</tr>
{% endfor %}
</tbody>
@@ -560,11 +605,10 @@
<details class="settings-backup-restore">
<summary>备份恢复说明</summary>
<ol style="margin:.5rem 0 0 1rem;padding:0">
<li>下载 <code>.tar.gz</code> 到目标服务器(如 <code>/root/</code></li>
<li>解压:<code>tar -xzf qihuo_backup_*.tar.gz</code></li>
<li>执行:<code>chmod +x restore.sh &amp;&amp; ./restore.sh</code></li>
<li>指定目录:<code>RESTORE_DIR=/opt/qihuo ./restore.sh</code></li>
<li>恢复脚本会自动还原数据库、<code>uploads/</code><code>.env</code>,然后重启服务。</li>
<li>可在上方上传 <code>.tar.gz</code>,或从列表下载备份到本机</li>
<li>点击「恢复此备份」会停止服务、还原数据库 / <code>uploads/</code> / <code>.env</code>,然后自动重启。</li>
<li>恢复前请确认备份类型与当前服务一致(SQLite / PostgreSQL)。</li>
<li>也可在服务器手工执行包内 <code>restore.sh</code>(见 <code>RESTORE_DIR</code>)。</li>
</ol>
</details>
{% endcall %}