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:
dekun
2026-06-26 13:04:48 +08:00
parent 508d85a282
commit 98239d29c1
7 changed files with 630 additions and 4 deletions
+1
View File
@@ -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 费率同步、导航开关 |
+74 -1
View File
@@ -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/<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"])
@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(),
)
+335
View File
@@ -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
View File
@@ -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
View File
@@ -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)
---
+5
View File
@@ -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/
+82
View File
@@ -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 @@
</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>自动备份时刻(023 点)</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 &amp;&amp; ./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="card">
<h2>修改密码</h2>