bfa3352122
支持手动/每日自动备份四所数据库、K线库与 env,上传 zip 一键恢复;中控默认账号 admin/admin123。 Co-authored-by: Cursor <cursoragent@cursor.com>
448 lines
14 KiB
Python
448 lines
14 KiB
Python
"""中控备份与恢复:四所 SQLite、K 线库、env、hub JSON。"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
import zipfile
|
|
from datetime import datetime, timedelta
|
|
from pathlib import Path
|
|
from typing import Any, Callable, Optional
|
|
from zoneinfo import ZoneInfo
|
|
|
|
from lib.paths import REPO_ROOT, hub_data_dir, manual_trading_hub_dir
|
|
|
|
HUB_DIR = manual_trading_hub_dir()
|
|
TZ_NAME = (os.getenv("HUB_BACKUP_TZ") or "Asia/Shanghai").strip() or "Asia/Shanghai"
|
|
|
|
EXCHANGE_DIRS: list[tuple[str, str]] = [
|
|
("binance", "crypto_monitor_binance"),
|
|
("okx", "crypto_monitor_okx"),
|
|
("gate", "crypto_monitor_gate"),
|
|
("gate_bot", "crypto_monitor_gate_bot"),
|
|
]
|
|
|
|
HUB_JSON_FILES = (
|
|
"hub_settings.json",
|
|
"hub_fund_history.json",
|
|
"hub_ai_summaries.json",
|
|
"hub_ai_chat.json",
|
|
"hub_supervisor_state.json",
|
|
)
|
|
|
|
HUB_DATA_FILES = (
|
|
"hub_kline.db",
|
|
"hub_symbol_archive.db",
|
|
"hub_entry_plans.db",
|
|
"hub_macro_calendar.db",
|
|
"hub_volume_rank.json",
|
|
)
|
|
|
|
DEFAULT_BACKUP_SETTINGS = {
|
|
"auto_enabled": True,
|
|
"auto_hour": 0,
|
|
"retention_days": 30,
|
|
"include_env": True,
|
|
"include_exchange_images": False,
|
|
"backup_root": "",
|
|
}
|
|
|
|
BACKUP_STATE_PATH = HUB_DIR / "hub_backup_state.json"
|
|
|
|
|
|
def normalize_backup_settings(raw: dict | None) -> dict:
|
|
out = dict(DEFAULT_BACKUP_SETTINGS)
|
|
if isinstance(raw, dict):
|
|
for key in DEFAULT_BACKUP_SETTINGS:
|
|
if key in raw:
|
|
out[key] = raw[key]
|
|
try:
|
|
out["auto_hour"] = max(0, min(23, int(out.get("auto_hour", 0))))
|
|
except (TypeError, ValueError):
|
|
out["auto_hour"] = 0
|
|
try:
|
|
out["retention_days"] = max(1, min(365, int(out.get("retention_days", 30))))
|
|
except (TypeError, ValueError):
|
|
out["retention_days"] = 30
|
|
out["auto_enabled"] = bool(out.get("auto_enabled"))
|
|
out["include_env"] = bool(out.get("include_env", True))
|
|
out["include_exchange_images"] = bool(out.get("include_exchange_images"))
|
|
out["backup_root"] = str(out.get("backup_root") or "").strip()
|
|
return out
|
|
|
|
|
|
def backup_root(settings: dict | None = None) -> Path:
|
|
cfg = normalize_backup_settings((settings or {}).get("backup") if settings else None)
|
|
raw = cfg.get("backup_root") or (os.getenv("HUB_BACKUP_ROOT") or "").strip()
|
|
if not raw:
|
|
raw = (os.getenv("BACKUP_ROOT") or "/root/backups").strip()
|
|
root = Path(raw).expanduser()
|
|
if not root.is_absolute():
|
|
root = REPO_ROOT / root
|
|
portal = root / "crypto_monitor_portal"
|
|
portal.mkdir(parents=True, exist_ok=True)
|
|
return portal
|
|
|
|
|
|
def _now_local() -> datetime:
|
|
try:
|
|
return datetime.now(ZoneInfo(TZ_NAME))
|
|
except Exception:
|
|
return datetime.now()
|
|
|
|
|
|
def _read_env_var(env_path: Path, key: str, default: str = "") -> str:
|
|
if not env_path.is_file():
|
|
return default
|
|
try:
|
|
for line in env_path.read_text(encoding="utf-8", errors="ignore").splitlines():
|
|
raw = line.strip()
|
|
if not raw or raw.startswith("#") or "=" not in raw:
|
|
continue
|
|
k, v = raw.split("=", 1)
|
|
if k.strip() == key:
|
|
return v.strip().strip('"').strip("'")
|
|
except Exception:
|
|
pass
|
|
return default
|
|
|
|
|
|
def _resolve_project_path(project_dir: Path, rel: str) -> Path:
|
|
p = Path(rel or "")
|
|
if p.is_absolute():
|
|
return p
|
|
return project_dir / p
|
|
|
|
|
|
def _load_backup_state() -> dict:
|
|
if not BACKUP_STATE_PATH.is_file():
|
|
return {}
|
|
try:
|
|
data = json.loads(BACKUP_STATE_PATH.read_text(encoding="utf-8"))
|
|
return data if isinstance(data, dict) else {}
|
|
except Exception:
|
|
return {}
|
|
|
|
|
|
def _save_backup_state(state: dict) -> None:
|
|
BACKUP_STATE_PATH.write_text(
|
|
json.dumps(state, ensure_ascii=False, indent=2),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
|
|
def _safe_archive_name(name: str) -> bool:
|
|
return bool(re.fullmatch(r"backup_[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{6}\.zip", name or ""))
|
|
|
|
|
|
def _collect_targets(
|
|
*,
|
|
include_env: bool,
|
|
include_exchange_images: bool,
|
|
) -> list[tuple[str, Path, str]]:
|
|
"""Return list of (archive_rel_path, source_path, kind)."""
|
|
items: list[tuple[str, Path, str]] = []
|
|
|
|
if include_env:
|
|
hub_env = HUB_DIR / ".env"
|
|
if hub_env.is_file():
|
|
items.append(("hub/.env", hub_env, "env"))
|
|
|
|
for name in HUB_JSON_FILES:
|
|
src = HUB_DIR / name
|
|
if src.is_file():
|
|
items.append((f"hub/{name}", src, "json"))
|
|
|
|
data_dir = hub_data_dir()
|
|
for name in HUB_DATA_FILES:
|
|
src = data_dir / name
|
|
if src.is_file():
|
|
items.append((f"hub/data/{name}", src, "sqlite" if name.endswith(".db") else "json"))
|
|
|
|
for key, dirname in EXCHANGE_DIRS:
|
|
proj = REPO_ROOT / dirname
|
|
prefix = dirname
|
|
env_path = proj / ".env"
|
|
db_rel = "crypto.db"
|
|
upload_rel = "static/images"
|
|
if env_path.is_file():
|
|
db_rel = _read_env_var(env_path, "DB_PATH", "crypto.db") or "crypto.db"
|
|
upload_rel = _read_env_var(env_path, "UPLOAD_DIR", "static/images") or "static/images"
|
|
if include_env:
|
|
items.append((f"{prefix}/.env", env_path, "env"))
|
|
db_path = _resolve_project_path(proj, db_rel)
|
|
if db_path.is_file():
|
|
items.append((f"{prefix}/{db_rel}", db_path, "sqlite"))
|
|
if include_exchange_images:
|
|
img_dir = _resolve_project_path(proj, upload_rel)
|
|
if img_dir.is_dir():
|
|
for fp in sorted(img_dir.rglob("*")):
|
|
if fp.is_file():
|
|
rel = fp.relative_to(proj).as_posix()
|
|
items.append((f"{prefix}/{rel}", fp, "image"))
|
|
return items
|
|
|
|
|
|
def _write_manifest(staging: Path, trigger: str, files: list[dict]) -> None:
|
|
manifest = {
|
|
"version": 1,
|
|
"created_at": _now_local().strftime("%Y-%m-%d %H:%M:%S"),
|
|
"timezone": TZ_NAME,
|
|
"trigger": trigger,
|
|
"repo_root": str(REPO_ROOT),
|
|
"files": files,
|
|
}
|
|
(staging / "manifest.json").write_text(
|
|
json.dumps(manifest, ensure_ascii=False, indent=2),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
|
|
def run_backup(
|
|
*,
|
|
trigger: str = "manual",
|
|
settings: dict | None = None,
|
|
log_fn: Callable[[str], None] | None = None,
|
|
) -> dict[str, Any]:
|
|
cfg = normalize_backup_settings((settings or {}).get("backup") if settings else None)
|
|
root = backup_root(settings)
|
|
ts = _now_local().strftime("%Y-%m-%d_%H%M%S")
|
|
archive_name = f"backup_{ts}.zip"
|
|
archive_path = root / archive_name
|
|
|
|
def log(msg: str) -> None:
|
|
if log_fn:
|
|
log_fn(msg)
|
|
|
|
targets = _collect_targets(
|
|
include_env=cfg["include_env"],
|
|
include_exchange_images=cfg["include_exchange_images"],
|
|
)
|
|
if not targets:
|
|
return {"ok": False, "error": "没有可备份的文件"}
|
|
|
|
file_meta: list[dict] = []
|
|
with tempfile.TemporaryDirectory(prefix="hub_backup_") as tmp:
|
|
staging = Path(tmp)
|
|
for arc_rel, src, kind in targets:
|
|
dest = staging / arc_rel
|
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(src, dest)
|
|
file_meta.append(
|
|
{
|
|
"path": arc_rel.replace("\\", "/"),
|
|
"size": src.stat().st_size,
|
|
"kind": kind,
|
|
}
|
|
)
|
|
_write_manifest(staging, trigger, file_meta)
|
|
with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
|
for fp in sorted(staging.rglob("*")):
|
|
if fp.is_file():
|
|
zf.write(fp, fp.relative_to(staging).as_posix())
|
|
|
|
size = archive_path.stat().st_size
|
|
prune_old_backups(root, cfg["retention_days"])
|
|
state = _load_backup_state()
|
|
if trigger == "auto":
|
|
state["last_auto_day"] = _now_local().strftime("%Y-%m-%d")
|
|
state["last_auto_at"] = _now_local().strftime("%Y-%m-%d %H:%M:%S")
|
|
state["last_backup_at"] = _now_local().strftime("%Y-%m-%d %H:%M:%S")
|
|
state["last_backup_file"] = archive_name
|
|
state["last_trigger"] = trigger
|
|
_save_backup_state(state)
|
|
log(f"backup written: {archive_path}")
|
|
return {
|
|
"ok": True,
|
|
"file": archive_name,
|
|
"path": str(archive_path),
|
|
"size": size,
|
|
"file_count": len(file_meta),
|
|
"trigger": trigger,
|
|
}
|
|
|
|
|
|
def prune_old_backups(root: Path, retention_days: int) -> int:
|
|
if not root.is_dir():
|
|
return 0
|
|
cutoff = _now_local() - timedelta(days=max(1, retention_days))
|
|
removed = 0
|
|
for fp in root.glob("backup_*.zip"):
|
|
try:
|
|
mtime = datetime.fromtimestamp(fp.stat().st_mtime, tz=cutoff.tzinfo)
|
|
except Exception:
|
|
continue
|
|
if mtime < cutoff:
|
|
fp.unlink(missing_ok=True)
|
|
removed += 1
|
|
return removed
|
|
|
|
|
|
def list_backups(settings: dict | None = None) -> list[dict[str, Any]]:
|
|
root = backup_root(settings)
|
|
rows: list[dict[str, Any]] = []
|
|
if not root.is_dir():
|
|
return rows
|
|
for fp in sorted(root.glob("backup_*.zip"), reverse=True):
|
|
try:
|
|
st = fp.stat()
|
|
except OSError:
|
|
continue
|
|
rows.append(
|
|
{
|
|
"name": fp.name,
|
|
"size": st.st_size,
|
|
"modified_at": datetime.fromtimestamp(st.st_mtime).strftime("%Y-%m-%d %H:%M:%S"),
|
|
}
|
|
)
|
|
return rows
|
|
|
|
|
|
def backup_status(settings: dict | None = None) -> dict[str, Any]:
|
|
cfg = normalize_backup_settings((settings or {}).get("backup") if settings else None)
|
|
state = _load_backup_state()
|
|
root = backup_root(settings)
|
|
return {
|
|
"ok": True,
|
|
"settings": cfg,
|
|
"backup_root": str(root),
|
|
"state": state,
|
|
"backups": list_backups(settings)[:50],
|
|
"timezone": TZ_NAME,
|
|
}
|
|
|
|
|
|
def _pm2_restart_all() -> dict[str, Any]:
|
|
if os.name != "posix":
|
|
return {"ok": False, "skipped": True, "reason": "non-posix"}
|
|
try:
|
|
proc = subprocess.run(
|
|
["pm2", "restart", "all"],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=120,
|
|
)
|
|
return {
|
|
"ok": proc.returncode == 0,
|
|
"returncode": proc.returncode,
|
|
"stdout": (proc.stdout or "")[-2000:],
|
|
"stderr": (proc.stderr or "")[-2000:],
|
|
}
|
|
except Exception as e:
|
|
return {"ok": False, "error": str(e)}
|
|
|
|
|
|
def restore_backup_archive(
|
|
archive_path: Path,
|
|
*,
|
|
settings: dict | None = None,
|
|
pre_backup: bool = True,
|
|
restart_pm2: bool = True,
|
|
) -> dict[str, Any]:
|
|
if not archive_path.is_file():
|
|
return {"ok": False, "error": "备份文件不存在"}
|
|
|
|
pre = None
|
|
if pre_backup:
|
|
pre = run_backup(trigger="pre_restore", settings=settings)
|
|
|
|
restored: list[str] = []
|
|
skipped: list[str] = []
|
|
with tempfile.TemporaryDirectory(prefix="hub_restore_") as tmp:
|
|
extract_dir = Path(tmp)
|
|
with zipfile.ZipFile(archive_path, "r") as zf:
|
|
zf.extractall(extract_dir)
|
|
manifest_path = extract_dir / "manifest.json"
|
|
if not manifest_path.is_file():
|
|
return {"ok": False, "error": "无效的备份包:缺少 manifest.json"}
|
|
|
|
for fp in extract_dir.rglob("*"):
|
|
if not fp.is_file() or fp.name == "manifest.json":
|
|
continue
|
|
rel = fp.relative_to(extract_dir).as_posix()
|
|
parts = Path(rel).parts
|
|
if parts[0] == "hub":
|
|
if len(parts) >= 3 and parts[1] == "data":
|
|
dest = hub_data_dir() / parts[-1]
|
|
else:
|
|
dest = HUB_DIR.joinpath(*parts[1:])
|
|
else:
|
|
matched = False
|
|
for _key, dirname in EXCHANGE_DIRS:
|
|
if rel.startswith(dirname + "/"):
|
|
dest = REPO_ROOT / rel
|
|
matched = True
|
|
break
|
|
if not matched:
|
|
skipped.append(rel)
|
|
continue
|
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(fp, dest)
|
|
restored.append(rel)
|
|
|
|
pm2 = _pm2_restart_all() if restart_pm2 else {"ok": False, "skipped": True}
|
|
state = _load_backup_state()
|
|
state["last_restore_at"] = _now_local().strftime("%Y-%m-%d %H:%M:%S")
|
|
state["last_restore_from"] = archive_path.name
|
|
_save_backup_state(state)
|
|
return {
|
|
"ok": True,
|
|
"restored": restored,
|
|
"skipped": skipped,
|
|
"pre_backup": pre,
|
|
"pm2": pm2,
|
|
}
|
|
|
|
|
|
def restore_backup_upload(
|
|
content: bytes,
|
|
filename: str,
|
|
*,
|
|
settings: dict | None = None,
|
|
) -> dict[str, Any]:
|
|
if not content:
|
|
return {"ok": False, "error": "空文件"}
|
|
suffix = Path(filename or "").suffix.lower()
|
|
if suffix != ".zip":
|
|
return {"ok": False, "error": "仅支持 .zip 备份包"}
|
|
with tempfile.NamedTemporaryFile(prefix="hub_restore_upload_", suffix=".zip", delete=False) as tf:
|
|
tf.write(content)
|
|
temp_path = Path(tf.name)
|
|
try:
|
|
return restore_backup_archive(temp_path, settings=settings)
|
|
finally:
|
|
temp_path.unlink(missing_ok=True)
|
|
|
|
|
|
def resolve_backup_download(settings: dict | None, name: str) -> Optional[Path]:
|
|
if not _safe_archive_name(name):
|
|
return None
|
|
fp = backup_root(settings) / name
|
|
if fp.is_file():
|
|
return fp
|
|
return None
|
|
|
|
|
|
def should_run_auto_backup(settings: dict) -> bool:
|
|
cfg = normalize_backup_settings(settings.get("backup"))
|
|
if not cfg.get("auto_enabled"):
|
|
return False
|
|
now = _now_local()
|
|
today = now.strftime("%Y-%m-%d")
|
|
state = _load_backup_state()
|
|
if state.get("last_auto_day") == today:
|
|
return False
|
|
if now.hour < int(cfg.get("auto_hour", 0)):
|
|
return False
|
|
return True
|
|
|
|
|
|
def mark_auto_backup_done() -> None:
|
|
state = _load_backup_state()
|
|
state["last_auto_day"] = _now_local().strftime("%Y-%m-%d")
|
|
state["last_auto_at"] = _now_local().strftime("%Y-%m-%d %H:%M:%S")
|
|
_save_backup_state(state)
|