Add automatic database backup with download and restore docs.
Back up futures.db and uploads to /root/qihuo_backup on a daily schedule, expose backup downloads in settings, and document cross-server restore. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -8,6 +8,7 @@
|
|||||||
|------|------|
|
|------|------|
|
||||||
| **[功能说明](docs/FEATURES.md)** | 各模块功能、页面路径、数据库与后台任务 |
|
| **[功能说明](docs/FEATURES.md)** | 各模块功能、页面路径、数据库与后台任务 |
|
||||||
| **[部署文档](docs/DEPLOY.md)** | 一键部署、更新、PM2、故障排查 |
|
| **[部署文档](docs/DEPLOY.md)** | 一键部署、更新、PM2、故障排查 |
|
||||||
|
| **[备份与恢复](docs/BACKUP.md)** | 自动备份、下载、跨服务器恢复 |
|
||||||
| **[SimNow 接入](docs/SIMNOW.md)** | 仿真账号注册与 CTP 前置 |
|
| **[SimNow 接入](docs/SIMNOW.md)** | 仿真账号注册与 CTP 前置 |
|
||||||
| **[交易与策略](docs/TRADING.md)** | 下单、持仓、可开仓品种、策略 API |
|
| **[交易与策略](docs/TRADING.md)** | 下单、持仓、可开仓品种、策略 API |
|
||||||
| **[手续费与导航](docs/FEES.md)** | CTP 费率同步、导航开关 |
|
| **[手续费与导航](docs/FEES.md)** | CTP 费率同步、导航开关 |
|
||||||
|
|||||||
@@ -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 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 market import get_price as market_get_price, set_ths_refresh_token, get_quote_source_label
|
||||||
from db_conn import connect_db
|
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 strategy.strategy_db import init_strategy_tables
|
||||||
from install_trading import install_trading
|
from install_trading import install_trading
|
||||||
from vnpy_bridge import try_init_vnpy
|
from vnpy_bridge import try_init_vnpy
|
||||||
@@ -404,6 +414,12 @@ def init_db():
|
|||||||
set_setting("trailing_be_tick_buffer", "2")
|
set_setting("trailing_be_tick_buffer", "2")
|
||||||
if not get_setting("pending_order_timeout_min"):
|
if not get_setting("pending_order_timeout_min"):
|
||||||
set_setting("pending_order_timeout_min", "5")
|
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"):
|
if not get_setting("fee_source_mode"):
|
||||||
set_setting("fee_source_mode", "ctp")
|
set_setting("fee_source_mode", "ctp")
|
||||||
set_setting("fee_source_mode", "ctp")
|
set_setting("fee_source_mode", "ctp")
|
||||||
@@ -705,6 +721,7 @@ def start_background_threads():
|
|||||||
daemon=True,
|
daemon=True,
|
||||||
).start()
|
).start()
|
||||||
threading.Thread(target=refresh_main_index, 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/<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("/settings", methods=["GET", "POST"])
|
@app.route("/settings", methods=["GET", "POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def settings():
|
def settings():
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
action = request.form.get("action")
|
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()
|
webhook = request.form.get("wechat_webhook", "").strip()
|
||||||
set_setting("wechat_webhook", webhook)
|
set_setting("wechat_webhook", webhook)
|
||||||
flash("企业微信配置已保存")
|
flash("企业微信配置已保存")
|
||||||
@@ -1813,6 +1878,14 @@ def settings():
|
|||||||
pending_order_timeout_min=get_setting("pending_order_timeout_min", "5"),
|
pending_order_timeout_min=get_setting("pending_order_timeout_min", "5"),
|
||||||
nav_items=get_nav_items(get_setting),
|
nav_items=get_nav_items(get_setting),
|
||||||
nav_toggles=NAV_TOGGLES,
|
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(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+335
@@ -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()
|
||||||
+119
@@ -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) — 功能与路由一览
|
||||||
+14
-3
@@ -257,11 +257,22 @@ python app.py
|
|||||||
|
|
||||||
| 路径 | 说明 |
|
| 路径 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `/opt/qihuo/futures.db` | 主数据库,建议定期备份 |
|
| `/opt/qihuo/futures.db` | 主数据库 |
|
||||||
| `/opt/qihuo/uploads/` | 复盘截图、自动 K 线图 |
|
| `/opt/qihuo/uploads/` | 复盘截图、自动 K 线图 |
|
||||||
| `/opt/qihuo/data/fee_rates.json` | 默认手续费表(可重载) |
|
| `/opt/qihuo/data/fee_rates.json` | 默认手续费表(可重载) |
|
||||||
|
| `/root/qihuo_backup/` | 系统自动备份目录(`.tar.gz`) |
|
||||||
|
|
||||||
备份示例:
|
### 自动备份(推荐)
|
||||||
|
|
||||||
|
系统设置 → **数据备份与恢复**:
|
||||||
|
|
||||||
|
- 默认每天 03:00 自动备份到 `/root/qihuo_backup`
|
||||||
|
- 含 `futures.db` 与 `uploads/`,可在其他服务器恢复
|
||||||
|
- 设置页可立即备份、下载历史压缩包
|
||||||
|
|
||||||
|
完整说明见 **[BACKUP.md](./BACKUP.md)**。
|
||||||
|
|
||||||
|
### 手工备份(备选)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp /opt/qihuo/futures.db /opt/qihuo/futures.db.bak.$(date +%Y%m%d)
|
cp /opt/qihuo/futures.db /opt/qihuo/futures.db.bak.$(date +%Y%m%d)
|
||||||
@@ -389,7 +400,7 @@ pm2 restart qihuo
|
|||||||
1. 部署后立即修改默认密码
|
1. 部署后立即修改默认密码
|
||||||
2. 勿将 `.env`、`futures.db` 提交到公开仓库
|
2. 勿将 `.env`、`futures.db` 提交到公开仓库
|
||||||
3. 生产环境使用 HTTPS + 限制访问 IP
|
3. 生产环境使用 HTTPS + 限制访问 IP
|
||||||
4. 定期备份 `futures.db` 与 `uploads/`
|
4. 定期备份:系统设置页自动备份至 `/root/qihuo_backup`,或见 [BACKUP.md](docs/BACKUP.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -177,8 +177,11 @@
|
|||||||
| 参考资金 | CTP 未连接时用于可开仓筛选与估算 |
|
| 参考资金 | CTP 未连接时用于可开仓筛选与估算 |
|
||||||
| 企业微信 Webhook | 计划/关键位推送 |
|
| 企业微信 Webhook | 计划/关键位推送 |
|
||||||
| 修改密码 | 管理员密码 |
|
| 修改密码 | 管理员密码 |
|
||||||
|
| 数据备份与恢复 | 自动/手动备份、下载压缩包、恢复说明 |
|
||||||
| 深色/浅色主题 | 页头切换 |
|
| 深色/浅色主题 | 页头切换 |
|
||||||
|
|
||||||
|
备份详情见 [BACKUP.md](./BACKUP.md)。
|
||||||
|
|
||||||
忘记密码:`python reset_admin.py`
|
忘记密码:`python reset_admin.py`
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -231,6 +234,7 @@
|
|||||||
| CTP 开盘前连接 | 默认开盘前 30 分钟 |
|
| CTP 开盘前连接 | 默认开盘前 30 分钟 |
|
||||||
| 挂单超时撤单 | 可配置分钟数 |
|
| 挂单超时撤单 | 可配置分钟数 |
|
||||||
| 止盈止损守护 | CTP 持仓监控线程 |
|
| 止盈止损守护 | CTP 持仓监控线程 |
|
||||||
|
| 数据库自动备份 | 每日定时(默认 03:00)写入 `/root/qihuo_backup` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -244,6 +248,7 @@ qihuo/
|
|||||||
├── ctp_trade_sync.py # 柜台成交同步到 trade_logs
|
├── ctp_trade_sync.py # 柜台成交同步到 trade_logs
|
||||||
├── product_recommend.py # 可开仓品种计算
|
├── product_recommend.py # 可开仓品种计算
|
||||||
├── stats_engine.py # 统计分析
|
├── stats_engine.py # 统计分析
|
||||||
|
├── db_backup.py # 数据库备份与恢复包
|
||||||
├── fee_specs.py / ctp_fee_sync.py
|
├── fee_specs.py / ctp_fee_sync.py
|
||||||
├── market.py / kline_chart.py
|
├── market.py / kline_chart.py
|
||||||
├── templates/ static/
|
├── templates/ static/
|
||||||
|
|||||||
@@ -54,6 +54,19 @@
|
|||||||
.settings-ctp-fold-body{padding:0 1rem .85rem}
|
.settings-ctp-fold-body{padding:0 1rem .85rem}
|
||||||
.settings-ctp-fold.is-collapsed .settings-ctp-fold-body{display:none}
|
.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-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){
|
@media(max-width:900px){
|
||||||
.settings-password-form{grid-template-columns:1fr}
|
.settings-password-form{grid-template-columns:1fr}
|
||||||
.settings-ctp-cards-row{grid-template-columns:1fr}
|
.settings-ctp-cards-row{grid-template-columns:1fr}
|
||||||
@@ -293,6 +306,75 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>数据备份与恢复</h2>
|
||||||
|
<p class="settings-backup-meta">
|
||||||
|
自动备份目录:<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 %}
|
||||||
|
</p>
|
||||||
|
<form action="{{ url_for('settings') }}" method="post" style="margin-bottom:.85rem">
|
||||||
|
<input type="hidden" name="action" value="backup_config">
|
||||||
|
<div class="split-grid" style="margin-bottom:.65rem">
|
||||||
|
<div class="field">
|
||||||
|
<label style="display:flex;align-items:center;gap:.45rem;cursor:pointer">
|
||||||
|
<input type="checkbox" name="backup_auto_enabled" value="1" {% if backup_auto_enabled %}checked{% endif %}>
|
||||||
|
<span>启用每日自动备份</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>自动备份时刻(0–23 点)</label>
|
||||||
|
<input name="backup_auto_hour" type="number" min="0" max="23" step="1" value="{{ backup_auto_hour }}">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>保留最近份数</label>
|
||||||
|
<input name="backup_keep_count" type="number" min="5" max="200" step="1" value="{{ backup_keep_count }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-primary">保存备份策略</button>
|
||||||
|
</form>
|
||||||
|
<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>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<p class="hint" style="margin:.65rem 0 0">备份包含 <code>futures.db</code> 与 <code>uploads/</code>,压缩包可在其他服务器恢复。默认恢复目录 <code>{{ backup_restore_dir }}</code>。</p>
|
||||||
|
|
||||||
|
{% if backup_items %}
|
||||||
|
<table class="settings-backup-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>文件名</th><th>大小</th><th>时间</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for item in backup_items %}
|
||||||
|
<tr>
|
||||||
|
<td><code>{{ item.name }}</code></td>
|
||||||
|
<td>{{ item.size_mb }} MB</td>
|
||||||
|
<td>{{ item.mtime.replace('T', ' ') }}</td>
|
||||||
|
<td><a href="{{ url_for('api_backup_download', filename=item.name) }}" class="settings-backup-download">下载</a></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p class="hint" style="margin-top:.65rem;margin-bottom:0">暂无备份文件,可点击「立即备份」生成第一份。</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<details class="settings-backup-restore">
|
||||||
|
<summary>备份恢复说明</summary>
|
||||||
|
<ol style="margin:.65rem 0 0 1.1rem;padding:0">
|
||||||
|
<li>下载上方 <code>.tar.gz</code> 到目标服务器(如 <code>/root/</code>)。</li>
|
||||||
|
<li>解压:<code>tar -xzf qihuo_backup_YYYYMMDD_HHMMSS.tar.gz</code></li>
|
||||||
|
<li>进入目录执行:<code>chmod +x restore.sh && ./restore.sh</code>(默认恢复到 <code>{{ backup_restore_dir }}</code>)。</li>
|
||||||
|
<li>指定目录:<code>RESTORE_DIR=/opt/qihuo ./restore.sh</code></li>
|
||||||
|
<li>在新服务器部署 qihuo 代码与虚拟环境,配置 <code>.env</code> 后 <code>pm2 restart qihuo</code>。</li>
|
||||||
|
<li>恢复前请停止 qihuo,避免覆盖正在使用的数据库。</li>
|
||||||
|
</ol>
|
||||||
|
<p style="margin:.65rem 0 0">完整说明见项目文档 <code>docs/BACKUP.md</code>;压缩包内亦含 <code>RESTORE.md</code>。</p>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="split-grid">
|
<div class="split-grid">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>修改密码</h2>
|
<h2>修改密码</h2>
|
||||||
|
|||||||
Reference in New Issue
Block a user