diff --git a/README.md b/README.md index ed8421b..c61247b 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ |------|------| | **[功能说明](docs/FEATURES.md)** | 各模块功能、页面路径、数据库与后台任务 | | **[部署文档](docs/DEPLOY.md)** | 一键部署、更新、PM2、故障排查 | +| **[备份与恢复](docs/BACKUP.md)** | 自动备份、下载、跨服务器恢复 | | **[SimNow 接入](docs/SIMNOW.md)** | 仿真账号注册与 CTP 前置 | | **[交易与策略](docs/TRADING.md)** | 下单、持仓、可开仓品种、策略 API | | **[手续费与导航](docs/FEES.md)** | CTP 费率同步、导航开关 | diff --git a/app.py b/app.py index 31dd88e..b825465 100644 --- a/app.py +++ b/app.py @@ -51,6 +51,16 @@ from kline_stream import kline_hub, sse_format from kline_chart import generate_review_kline_chart, fetch_market_klines, MARKET_PERIODS from market import get_price as market_get_price, set_ths_refresh_token, get_quote_source_label from db_conn import connect_db +from db_backup import ( + backup_dir, + backup_in_progress, + default_restore_dir, + get_backup_last_at, + list_backups, + resolve_backup_file, + schedule_backup, + start_backup_worker, +) from strategy.strategy_db import init_strategy_tables from install_trading import install_trading from vnpy_bridge import try_init_vnpy @@ -404,6 +414,12 @@ def init_db(): set_setting("trailing_be_tick_buffer", "2") if not get_setting("pending_order_timeout_min"): set_setting("pending_order_timeout_min", "5") + if not get_setting("backup_auto_enabled"): + set_setting("backup_auto_enabled", "1") + if not get_setting("backup_auto_hour"): + set_setting("backup_auto_hour", "3") + if not get_setting("backup_keep_count"): + set_setting("backup_keep_count", "30") if not get_setting("fee_source_mode"): set_setting("fee_source_mode", "ctp") set_setting("fee_source_mode", "ctp") @@ -705,6 +721,7 @@ def start_background_threads(): daemon=True, ).start() threading.Thread(target=refresh_main_index, daemon=True).start() + start_backup_worker(get_setting_fn=get_setting, set_setting_fn=set_setting) # —————————————— 登录 —————————————— @@ -1659,12 +1676,60 @@ def fees(): ) +@app.route("/api/backup/list") +@login_required +def api_backup_list(): + return jsonify( + { + "dir": str(backup_dir()), + "last_at": get_backup_last_at(get_setting), + "running": backup_in_progress(), + "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("/settings", methods=["GET", "POST"]) @login_required def settings(): if request.method == "POST": action = request.form.get("action") - if action == "wechat": + if action == "backup_now": + ok, msg = schedule_backup( + get_setting=get_setting, + set_setting=set_setting, + include_uploads=True, + ) + flash(msg if ok else msg) + elif action == "backup_config": + auto = request.form.get("backup_auto_enabled") == "1" + set_setting("backup_auto_enabled", "1" if auto else "0") + try: + hour = int(request.form.get("backup_auto_hour", "3") or 3) + set_setting("backup_auto_hour", str(max(0, min(23, hour)))) + except ValueError: + flash("自动备份小时无效") + return redirect(url_for("settings")) + try: + keep = int(request.form.get("backup_keep_count", "30") or 30) + set_setting("backup_keep_count", str(max(5, min(200, keep)))) + except ValueError: + flash("保留份数无效") + return redirect(url_for("settings")) + flash("备份策略已保存") + elif action == "wechat": webhook = request.form.get("wechat_webhook", "").strip() set_setting("wechat_webhook", webhook) flash("企业微信配置已保存") @@ -1813,6 +1878,14 @@ def settings(): pending_order_timeout_min=get_setting("pending_order_timeout_min", "5"), nav_items=get_nav_items(get_setting), nav_toggles=NAV_TOGGLES, + backup_dir=str(backup_dir()), + backup_last_at=get_backup_last_at(get_setting), + backup_running=backup_in_progress(), + backup_items=list_backups(), + 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(), ) diff --git a/db_backup.py b/db_backup.py new file mode 100644 index 0000000..9afb9de --- /dev/null +++ b/db_backup.py @@ -0,0 +1,335 @@ +# Copyright (c) 2025-2026 马建军. All rights reserved. +# 专有软件 — 未经授权禁止复制、传播、转售。 +# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。 +# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md + +"""SQLite 数据库自动备份:打包 futures.db 与 uploads,可在其他服务器恢复。""" +from __future__ import annotations + +import json +import logging +import os +import re +import shutil +import sqlite3 +import tarfile +import tempfile +import threading +import time +from datetime import datetime +from pathlib import Path +from typing import Callable, Optional +from zoneinfo import ZoneInfo + +from db_conn import DB_PATH + +logger = logging.getLogger(__name__) + +TZ = ZoneInfo("Asia/Shanghai") +BACKUP_FILENAME_RE = re.compile(r"^qihuo_backup_\d{8}_\d{6}\.tar\.gz$") +BACKUP_LAST_KEY = "backup_last_at" +BACKUP_KEEP_KEY = "backup_keep_count" +BACKUP_AUTO_KEY = "backup_auto_enabled" +BACKUP_HOUR_KEY = "backup_auto_hour" +DEFAULT_KEEP_COUNT = 30 +DEFAULT_AUTO_HOUR = 3 +CHECK_INTERVAL_SEC = 3600 +_backup_lock = threading.Lock() + +RESTORE_MD = """# qihuo 备份恢复说明 + +本压缩包由 qihuo 系统自动生成,可在另一台 Linux 服务器上恢复交易数据。 + +## 包内文件 + +| 文件/目录 | 说明 | +|-----------|------| +| `futures.db` | SQLite 主库(账号、交易记录、设置等) | +| `uploads/` | 复盘截图与 K 线图(若备份时存在) | +| `manifest.json` | 备份元数据 | +| `restore.sh` | 一键恢复脚本 | + +## 快速恢复(推荐) + +1. 将本压缩包上传到目标服务器(例如 `/root/`) +2. 解压并执行恢复脚本: + +```bash +cd /root +tar -xzf qihuo_backup_YYYYMMDD_HHMMSS.tar.gz +cd qihuo_backup_YYYYMMDD_HHMMSS +chmod +x restore.sh +./restore.sh +``` + +默认恢复到 **`/root/qihuo`**。指定目录: + +```bash +RESTORE_DIR=/opt/qihuo ./restore.sh +``` + +3. 在新服务器部署 qihuo 代码与 Python 环境(见 `docs/DEPLOY.md`) +4. 若恢复到 `/opt/qihuo`,将生成的 `futures.db`、`uploads/` 放入该目录 +5. 配置 `.env`(CTP 账号、SECRET_KEY 等),**不要**直接复制旧 `.env` 到公网环境 +6. 重启服务:`pm2 restart qihuo` + +## 手工恢复 + +```bash +mkdir -p /root/qihuo/uploads +cp futures.db /root/qihuo/futures.db +cp -a uploads/. /root/qihuo/uploads/ # 若有 uploads 目录 +``` + +## 注意 + +- 恢复前请停止 qihuo 进程,避免覆盖正在使用的数据库 +- 恢复后首次启动会自动执行数据库迁移,一般无需手工改表 +- `.env` 含敏感信息,请单独安全传输,不要放入公开网盘 +""" + + +def _app_root() -> Path: + return Path(os.path.dirname(os.path.abspath(__file__))) + + +def default_backup_dir() -> str: + env = (os.getenv("QIHUO_BACKUP_DIR") or "").strip() + if env: + return env + if os.name == "nt": + return str(_app_root() / "qihuo_backup") + return "/root/qihuo_backup" + + +def default_restore_dir() -> str: + env = (os.getenv("QIHUO_RESTORE_DIR") or "").strip() + if env: + return env + if os.name == "nt": + return str(_app_root()) + return "/root/qihuo" + + +def backup_dir() -> Path: + path = Path(default_backup_dir()) + path.mkdir(parents=True, exist_ok=True) + return path + + +def backup_in_progress() -> bool: + return _backup_lock.locked() + + +def get_backup_last_at(get_setting: Callable[[str, str], str]) -> str: + return (get_setting(BACKUP_LAST_KEY, "") or "").strip() + + +def _backup_sqlite(src_path: str, dst_path: str) -> None: + src = sqlite3.connect(src_path, timeout=30) + try: + try: + src.execute("PRAGMA wal_checkpoint(TRUNCATE)") + except sqlite3.OperationalError: + pass + dst = sqlite3.connect(dst_path) + try: + src.backup(dst) + dst.commit() + finally: + dst.close() + finally: + src.close() + + +def _write_restore_script(dest: Path, folder_name: str) -> None: + script = f"""#!/bin/bash +set -euo pipefail +RESTORE_DIR="${{RESTORE_DIR:-{default_restore_dir()}}}" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +mkdir -p "$RESTORE_DIR/uploads" +if [ -f "$SCRIPT_DIR/futures.db" ]; then + cp -f "$SCRIPT_DIR/futures.db" "$RESTORE_DIR/futures.db" + echo "已复制 futures.db -> $RESTORE_DIR/futures.db" +fi +if [ -d "$SCRIPT_DIR/uploads" ]; then + cp -a "$SCRIPT_DIR/uploads/." "$RESTORE_DIR/uploads/" + echo "已复制 uploads -> $RESTORE_DIR/uploads/" +fi +echo "" +echo "恢复完成。目标目录: $RESTORE_DIR" +echo "下一步: 部署 qihuo 代码、配置 .env、pm2 restart qihuo" +echo "详见 RESTORE.md 与 docs/BACKUP.md" +""" + dest.write_text(script, encoding="utf-8") + + +def create_backup(*, include_uploads: bool = True) -> tuple[str, str]: + """创建 tar.gz 备份,返回 (文件名, 说明)。""" + if not os.path.isfile(DB_PATH): + raise FileNotFoundError(f"数据库不存在: {DB_PATH}") + + with _backup_lock: + stamp = datetime.now(TZ).strftime("%Y%m%d_%H%M%S") + folder_name = f"qihuo_backup_{stamp}" + filename = f"{folder_name}.tar.gz" + out_path = backup_dir() / filename + app_root = _app_root() + upload_src = app_root / "uploads" + + with tempfile.TemporaryDirectory(prefix="qihuo_bak_") as tmp: + work = Path(tmp) / folder_name + work.mkdir() + _backup_sqlite(DB_PATH, str(work / "futures.db")) + + if include_uploads and upload_src.is_dir(): + shutil.copytree(upload_src, work / "uploads", dirs_exist_ok=True) + + manifest = { + "app": "qihuo", + "created_at": datetime.now(TZ).isoformat(timespec="seconds"), + "db_path": DB_PATH, + "includes_uploads": include_uploads and upload_src.is_dir(), + "default_restore_dir": default_restore_dir(), + "files": sorted(p.name for p in work.iterdir()), + } + (work / "manifest.json").write_text( + json.dumps(manifest, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + (work / "RESTORE.md").write_text(RESTORE_MD, encoding="utf-8") + _write_restore_script(work / "restore.sh", folder_name) + + with tarfile.open(out_path, "w:gz") as tar: + tar.add(work, arcname=folder_name) + + size_mb = out_path.stat().st_size / (1024 * 1024) + return filename, f"备份已生成 {filename}({size_mb:.2f} MB)" + + +def list_backups() -> 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"), + } + ) + return items + + +def resolve_backup_file(filename: str) -> Path: + name = (filename or "").strip() + if not BACKUP_FILENAME_RE.match(name): + raise ValueError("无效的备份文件名") + path = (backup_dir() / name).resolve() + root = backup_dir().resolve() + if not str(path).startswith(str(root) + os.sep) and path != root: + raise ValueError("无效的备份路径") + if not path.is_file(): + raise FileNotFoundError("备份文件不存在") + return path + + +def prune_old_backups(keep: int) -> int: + keep_n = max(1, int(keep or DEFAULT_KEEP_COUNT)) + files = list_backups() + removed = 0 + for item in files[keep_n:]: + try: + resolve_backup_file(item["name"]).unlink() + removed += 1 + except Exception as exc: + logger.warning("prune backup %s: %s", item["name"], exc) + return removed + + +def run_backup_job( + *, + get_setting: Callable[[str, str], str], + set_setting: Callable[[str, str], None], + include_uploads: bool = True, +) -> tuple[str, str]: + keep = DEFAULT_KEEP_COUNT + try: + keep = max(5, min(200, int(get_setting(BACKUP_KEEP_KEY, str(DEFAULT_KEEP_COUNT)) or DEFAULT_KEEP_COUNT))) + except ValueError: + pass + filename, msg = create_backup(include_uploads=include_uploads) + set_setting(BACKUP_LAST_KEY, datetime.now(TZ).isoformat(timespec="seconds")) + removed = prune_old_backups(keep) + if removed: + msg = f"{msg},已清理 {removed} 个旧备份" + return filename, msg + + +def schedule_backup( + *, + get_setting: Callable[[str, str], str], + set_setting: Callable[[str, str], None], + include_uploads: bool = True, +) -> tuple[bool, str]: + if _backup_lock.locked(): + return False, "备份进行中,请稍后再试" + + def _run() -> None: + try: + run_backup_job( + get_setting=get_setting, + set_setting=set_setting, + include_uploads=include_uploads, + ) + except Exception as exc: + logger.exception("backup failed: %s", exc) + + threading.Thread(target=_run, daemon=True, name="qihuo-backup-run").start() + return True, "已在后台开始备份,请稍后刷新本页查看" + + +def _should_auto_backup(get_setting: Callable[[str, str], str]) -> bool: + if (get_setting(BACKUP_AUTO_KEY, "1") or "1").strip() not in ("1", "true", "yes"): + return False + try: + hour = int(get_setting(BACKUP_HOUR_KEY, str(DEFAULT_AUTO_HOUR)) or DEFAULT_AUTO_HOUR) + except ValueError: + hour = DEFAULT_AUTO_HOUR + hour = max(0, min(23, hour)) + now = datetime.now(TZ) + if now.hour != hour: + return False + last = get_backup_last_at(get_setting) + if last and last[:10] == now.date().isoformat(): + return False + return True + + +def start_backup_worker( + *, + get_setting_fn: Callable[[str, str], str], + set_setting_fn: Callable[[str, str], None], + interval: int = CHECK_INTERVAL_SEC, +) -> None: + """后台线程:按设定小时每日自动备份。""" + + def _loop() -> None: + time.sleep(30) + while True: + try: + if _should_auto_backup(get_setting_fn): + filename, msg = run_backup_job( + get_setting=get_setting_fn, + set_setting=set_setting_fn, + include_uploads=True, + ) + logger.info("auto backup: %s — %s", filename, msg) + except Exception as exc: + logger.warning("backup worker: %s", exc) + time.sleep(max(300, interval)) + + threading.Thread(target=_loop, daemon=True, name="qihuo-backup-worker").start() diff --git a/docs/BACKUP.md b/docs/BACKUP.md new file mode 100644 index 0000000..fa477a3 --- /dev/null +++ b/docs/BACKUP.md @@ -0,0 +1,119 @@ +# 数据备份与恢复 + +qihuo 支持自动备份 SQLite 数据库与复盘附件,生成可在其他 Linux 服务器恢复的压缩包。 + +--- + +## 备份内容 + +| 内容 | 说明 | +|------|------| +| `futures.db` | 主库:账号、交易记录、设置、统计缓存等 | +| `uploads/` | 复盘截图、自动 K 线图(若存在) | +| `manifest.json` | 备份时间与文件清单 | +| `RESTORE.md` | 包内恢复说明 | +| `restore.sh` | 一键恢复脚本 | + +**不包含** `.env`(含 CTP 密码等敏感信息),请单独安全保管或在新服务器重新配置。 + +--- + +## 备份目录 + +默认:**`/root/qihuo_backup`** + +可通过环境变量覆盖: + +```bash +# /opt/qihuo/.env 或 systemd/PM2 环境 +QIHUO_BACKUP_DIR=/data/qihuo_backup +``` + +--- + +## 系统设置页 + +路径:**系统设置 → 数据备份与恢复** + +- **立即备份**:后台生成 `qihuo_backup_YYYYMMDD_HHMMSS.tar.gz` +- **每日自动备份**:默认每天 **03:00**(Asia/Shanghai)执行 +- **保留份数**:默认保留最近 **30** 份,超出自动删除最旧文件 +- **下载**:列表中点击「下载」获取压缩包 + +--- + +## 在新服务器恢复 + +### 方式一:使用包内脚本(推荐) + +```bash +# 1. 上传压缩包到目标机 +scp qihuo_backup_20260626_030015.tar.gz root@新服务器:/root/ + +# 2. 解压并恢复 +cd /root +tar -xzf qihuo_backup_20260626_030015.tar.gz +cd qihuo_backup_20260626_030015 +chmod +x restore.sh +./restore.sh +``` + +默认恢复到 **`/root/qihuo`**。若生产目录为 `/opt/qihuo`: + +```bash +RESTORE_DIR=/opt/qihuo ./restore.sh +``` + +也可通过环境变量固定默认恢复目录: + +```bash +QIHUO_RESTORE_DIR=/opt/qihuo +``` + +### 方式二:手工复制 + +```bash +tar -xzf qihuo_backup_20260626_030015.tar.gz +cd qihuo_backup_20260626_030015 +pm2 stop qihuo # 或停止当前进程 +cp futures.db /opt/qihuo/futures.db +cp -a uploads/. /opt/qihuo/uploads/ # 若有 uploads +pm2 restart qihuo +``` + +### 恢复后检查清单 + +1. 已部署 qihuo 代码与 Python 虚拟环境(见 [DEPLOY.md](./DEPLOY.md)) +2. 已配置 `.env`(`SECRET_KEY`、CTP 账号等) +3. 数据库文件权限正确(运行用户可读写的 `futures.db`) +4. 访问 Web 登录,检查交易记录、统计页是否正常 +5. CTP 模式需在新环境重新连接柜台 + +--- + +## 注意事项 + +- **恢复前务必停止 qihuo**,避免进程占用数据库导致覆盖不完整 +- 备份使用 SQLite `backup` API,并在 WAL 模式下尝试 checkpoint,降低锁冲突风险 +- 自动备份在应用后台线程执行,与 Web 服务同进程;PM2 重启不影响已生成的历史压缩包 +- 大体积 `uploads/` 会使压缩包变大,可按需定期清理无用截图 +- 不要将含 `.env`、数据库的压缩包上传到公开网盘 + +--- + +## 故障排查 + +| 现象 | 处理 | +|------|------| +| 设置页无备份列表 | 检查 `/root/qihuo_backup` 目录权限,进程需可写 | +| 立即备份无反应 | 查看 PM2 日志;可能上一任务仍在进行 | +| 下载 404 | 文件名须为系统生成的 `qihuo_backup_*.tar.gz` | +| 恢复后无法登录 | 确认 `futures.db` 已覆盖到实际运行目录 | +| 恢复后 CTP 连不上 | 在新服务器配置正确的 `.env` CTP 参数 | + +--- + +## 相关文档 + +- [DEPLOY.md](./DEPLOY.md) — 部署与目录结构 +- [FEATURES.md](./FEATURES.md) — 功能与路由一览 diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 5864813..a527551 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -257,11 +257,22 @@ python app.py | 路径 | 说明 | |------|------| -| `/opt/qihuo/futures.db` | 主数据库,建议定期备份 | +| `/opt/qihuo/futures.db` | 主数据库 | | `/opt/qihuo/uploads/` | 复盘截图、自动 K 线图 | | `/opt/qihuo/data/fee_rates.json` | 默认手续费表(可重载) | +| `/root/qihuo_backup/` | 系统自动备份目录(`.tar.gz`) | -备份示例: +### 自动备份(推荐) + +系统设置 → **数据备份与恢复**: + +- 默认每天 03:00 自动备份到 `/root/qihuo_backup` +- 含 `futures.db` 与 `uploads/`,可在其他服务器恢复 +- 设置页可立即备份、下载历史压缩包 + +完整说明见 **[BACKUP.md](./BACKUP.md)**。 + +### 手工备份(备选) ```bash cp /opt/qihuo/futures.db /opt/qihuo/futures.db.bak.$(date +%Y%m%d) @@ -389,7 +400,7 @@ pm2 restart qihuo 1. 部署后立即修改默认密码 2. 勿将 `.env`、`futures.db` 提交到公开仓库 3. 生产环境使用 HTTPS + 限制访问 IP -4. 定期备份 `futures.db` 与 `uploads/` +4. 定期备份:系统设置页自动备份至 `/root/qihuo_backup`,或见 [BACKUP.md](docs/BACKUP.md) --- diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 7d6f2b2..dcf0a24 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -177,8 +177,11 @@ | 参考资金 | CTP 未连接时用于可开仓筛选与估算 | | 企业微信 Webhook | 计划/关键位推送 | | 修改密码 | 管理员密码 | +| 数据备份与恢复 | 自动/手动备份、下载压缩包、恢复说明 | | 深色/浅色主题 | 页头切换 | +备份详情见 [BACKUP.md](./BACKUP.md)。 + 忘记密码:`python reset_admin.py` --- @@ -231,6 +234,7 @@ | CTP 开盘前连接 | 默认开盘前 30 分钟 | | 挂单超时撤单 | 可配置分钟数 | | 止盈止损守护 | CTP 持仓监控线程 | +| 数据库自动备份 | 每日定时(默认 03:00)写入 `/root/qihuo_backup` | --- @@ -244,6 +248,7 @@ qihuo/ ├── ctp_trade_sync.py # 柜台成交同步到 trade_logs ├── product_recommend.py # 可开仓品种计算 ├── stats_engine.py # 统计分析 +├── db_backup.py # 数据库备份与恢复包 ├── fee_specs.py / ctp_fee_sync.py ├── market.py / kline_chart.py ├── templates/ static/ diff --git a/templates/settings.html b/templates/settings.html index d4aa25f..231b59b 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -54,6 +54,19 @@ .settings-ctp-fold-body{padding:0 1rem .85rem} .settings-ctp-fold.is-collapsed .settings-ctp-fold-body{display:none} .settings-ctp-status{font-size:.82rem;color:var(--text-muted);margin-top:.75rem;line-height:1.5} +.settings-backup-table{width:100%;border-collapse:collapse;font-size:.82rem;margin-top:.65rem} +.settings-backup-table th,.settings-backup-table td{padding:.45rem .5rem;border-bottom:1px solid var(--border);text-align:left} +.settings-backup-table th{color:var(--text-muted);font-weight:600} +.settings-backup-restore{ + margin-top:.85rem;padding:.75rem .85rem;border-radius:8px; + border:1px solid var(--border);background:var(--card-inner); + font-size:.82rem;color:var(--text-muted);line-height:1.6; +} +.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-download{color:var(--accent);text-decoration:none;font-weight:600} +.settings-backup-download:hover{text-decoration:underline} @media(max-width:900px){ .settings-password-form{grid-template-columns:1fr} .settings-ctp-cards-row{grid-template-columns:1fr} @@ -293,6 +306,75 @@ +
+

数据备份与恢复

+

+ 自动备份目录:{{ backup_dir }} + {% if backup_last_at %} · 上次备份 {{ backup_last_at.replace('T', ' ') }}{% else %} · 尚未备份{% endif %} + {% if backup_running %} · 备份进行中…{% endif %} +

+
+ +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+
+ + +
+
+

备份包含 futures.dbuploads/,压缩包可在其他服务器恢复。默认恢复目录 {{ backup_restore_dir }}

+ + {% if backup_items %} + + + + + + {% for item in backup_items %} + + + + + + + {% endfor %} + +
文件名大小时间
{{ item.name }}{{ item.size_mb }} MB{{ item.mtime.replace('T', ' ') }}下载
+ {% else %} +

暂无备份文件,可点击「立即备份」生成第一份。

+ {% endif %} + +
+ 备份恢复说明 +
    +
  1. 下载上方 .tar.gz 到目标服务器(如 /root/)。
  2. +
  3. 解压:tar -xzf qihuo_backup_YYYYMMDD_HHMMSS.tar.gz
  4. +
  5. 进入目录执行:chmod +x restore.sh && ./restore.sh(默认恢复到 {{ backup_restore_dir }})。
  6. +
  7. 指定目录:RESTORE_DIR=/opt/qihuo ./restore.sh
  8. +
  9. 在新服务器部署 qihuo 代码与虚拟环境,配置 .envpm2 restart qihuo
  10. +
  11. 恢复前请停止 qihuo,避免覆盖正在使用的数据库。
  12. +
+

完整说明见项目文档 docs/BACKUP.md;压缩包内亦含 RESTORE.md

+
+
+

修改密码