Files
qihuo/modules/backup/db_backup.py
T

768 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 subprocess
import sys
import tarfile
import tempfile
import threading
import time
from datetime import datetime
from pathlib import Path
from typing import Any, Callable, IO, Optional
from zoneinfo import ZoneInfo
from modules.core.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_STATUS_FILE = "restore_status.json"
RESTORE_CONFIRM_TOKEN = "RESTORE"
RESTORE_MD = """# qihuo 备份恢复说明
本压缩包由 qihuo 系统自动生成,可在另一台 Linux 服务器上恢复交易数据。
## 包内文件
| 文件/目录 | 说明 |
|-----------|------|
| `futures.db` | SQLite 主库 |
| `uploads/` | 复盘截图与 K 线图(若备份时存在) |
| `.env` | 环境配置(`config/.env` 或根目录 `.env` |
| `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. 若包内含 `.env``restore.sh` 会自动恢复到应用目录(无需手工复制)
5. 重启服务:`pm2 restart qihuo`
## 手工恢复
```bash
mkdir -p /opt/qihuo/uploads
cp futures.db /opt/qihuo/futures.db
cp -a uploads/. /opt/qihuo/uploads/
```
## 注意
- 恢复前请停止 qihuo 进程
- `.env` 含敏感信息,请妥善保管备份包
- 详见 `docs/BACKUP.md`
"""
def _app_root() -> Path:
from modules.core.paths import ROOT
return ROOT
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 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)
return path
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) -> None:
if manifest.get("app") != "qihuo":
raise ValueError("不是有效的 qihuo 备份包")
backend = (manifest.get("backend") or "sqlite").strip()
if backend == "postgres":
raise ValueError("不再支持 PostgreSQL 备份,请使用 SQLite 备份包")
if backend != "sqlite":
raise ValueError("manifest 缺少或无效的数据库类型")
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, root: str) -> None:
if not _member_exists(tar, root, "futures.db"):
raise ValueError("备份缺少 futures.db")
def _manifest_preview(manifest: dict, path: Path) -> dict:
stat = path.stat()
created_at = (manifest.get("created_at") or "").strip()
return {
"name": path.name,
"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) -> dict:
with tarfile.open(path, "r:gz") as tar:
manifest = _read_manifest_from_tar(tar)
_validate_manifest(manifest)
root = _manifest_root_prefix(tar)
_validate_archive_contents(tar, 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)
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 _perform_restore(archive_path: Path, restore_dir: Path) -> dict:
restore_dir.mkdir(parents=True, exist_ok=True)
with tarfile.open(archive_path, "r:gz") as tar:
manifest = _read_manifest_from_tar(tar)
_validate_manifest(manifest)
root = _manifest_root_prefix(tar)
_validate_archive_contents(tar, 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)
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)
_restore_uploads_dir(tar, root, restore_dir)
return {
"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)
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()
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 _env_backup_info() -> tuple[Optional[Path], str]:
"""返回 (源 .env 路径, 恢复到应用目录的相对路径)。"""
from modules.core.paths import CONFIG_DIR, ENV_FILE, LEGACY_ENV_FILE
if ENV_FILE.is_file():
return ENV_FILE, "config/.env"
if LEGACY_ENV_FILE.is_file():
return LEGACY_ENV_FILE, ".env"
return None, "config/.env"
def _copy_env_into_backup(work: Path) -> tuple[bool, str]:
env_src, env_restore_path = _env_backup_info()
if env_src and env_src.is_file():
shutil.copy2(env_src, work / ".env")
return True, env_restore_path
return False, env_restore_path
def _write_restore_script(dest: Path, *, env_restore_path: str = "") -> None:
env_block = ""
if env_restore_path:
env_block = f"""
if [ -f "$SCRIPT_DIR/.env" ]; then
ENV_DEST="$RESTORE_DIR/{env_restore_path}"
mkdir -p "$(dirname "$ENV_DEST")"
cp -f "$SCRIPT_DIR/.env" "$ENV_DEST"
echo "已复制 .env -> $ENV_DEST"
fi
"""
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"
{env_block}
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 "下一步: pm2 restart qihuo"
echo "详见 RESTORE.md 与 docs/BACKUP.md"
"""
dest.write_text(script, encoding="utf-8")
def create_backup(*, include_uploads: bool = True, include_env: 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)
includes_env = False
env_restore_path = "config/.env"
if include_env:
includes_env, env_restore_path = _copy_env_into_backup(work)
if not includes_env:
logger.warning("backup: .env not found (checked config/.env and root .env)")
manifest = {
"app": "qihuo",
"backend": "sqlite",
"created_at": datetime.now(TZ).isoformat(timespec="seconds"),
"db_path": DB_PATH,
"includes_uploads": include_uploads and upload_src.is_dir(),
"includes_env": includes_env,
"env_restore_path": env_restore_path if includes_env else "",
"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",
env_restore_path=env_restore_path if includes_env else "",
)
with tarfile.open(out_path, "w:gz") as tar:
tar.add(work, arcname=folder_name)
size_mb = out_path.stat().st_size / (1024 * 1024)
env_note = "含 .env" if includes_env else "未含 .env"
return filename, f"备份已生成 {filename}{size_mb:.2f} MB{env_note}"
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()
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"),
"created_at": "",
"includes_env": False,
"includes_uploads": False,
}
if with_manifest:
try:
manifest = peek_manifest(path)
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
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,
include_env: 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, include_env=include_env)
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,
include_env: bool = True,
) -> tuple[bool, str]:
if _backup_lock.locked():
return False, "备份进行中,请稍后再试"
if restore_in_progress():
return False, "恢复进行中,请稍后再试"
def _run() -> None:
try:
run_backup_job(
get_setting=get_setting,
set_setting=set_setting,
include_uploads=include_uploads,
include_env=include_env,
)
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,
include_env=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()