"""实例备份/恢复: .env + 数据库 + 期权复盘图片;默认落盘 /root/crypto_okx_backups.""" from __future__ import annotations import json import os import re import shutil import tarfile import tempfile import threading import time from datetime import datetime, timedelta, timezone from typing import Any, Callable _BJ = timezone(timedelta(hours=8)) _SAFE_NAME_RE = re.compile(r"^crypto_okx_backup_\d{8}_\d{6}\.tar\.gz$") _LOCK = threading.Lock() _LAST_AUTO_DAY = "" def beijing_now() -> datetime: return datetime.now(_BJ) def backup_keep_days() -> int: try: return max(1, int(os.getenv("BACKUP_KEEP_DAYS") or 10)) except (TypeError, ValueError): return 10 def backup_dir() -> str: """备份目录:默认 /root/crypto_okx_backups,可用 BACKUP_DIR 覆盖.""" root = (os.getenv("BACKUP_DIR") or "/root").strip() or "/root" path = os.path.join(root, "crypto_okx_backups") os.makedirs(path, exist_ok=True) return path def _safe_backup_name(name: str) -> str | None: n = os.path.basename((name or "").strip()) if not _SAFE_NAME_RE.match(n): return None return n def backup_path(name: str) -> str | None: safe = _safe_backup_name(name) if not safe: return None path = os.path.abspath(os.path.join(backup_dir(), safe)) root = os.path.abspath(backup_dir()) if not path.startswith(root + os.sep) and path != root: return None return path def list_backups() -> list[dict[str, Any]]: root = backup_dir() items: list[dict[str, Any]] = [] try: names = os.listdir(root) except OSError: return [] for name in names: if not _SAFE_NAME_RE.match(name): continue path = os.path.join(root, name) try: st = os.stat(path) except OSError: continue items.append( { "name": name, "size": int(st.st_size), "mtime": int(st.st_mtime), "mtime_bj": datetime.fromtimestamp(st.st_mtime, _BJ).strftime( "%Y-%m-%d %H:%M:%S" ), } ) items.sort(key=lambda x: x["mtime"], reverse=True) return items def prune_backups(*, keep_days: int | None = None) -> int: days = backup_keep_days() if keep_days is None else max(1, int(keep_days)) cutoff = time.time() - days * 86400 removed = 0 for item in list_backups(): if float(item["mtime"]) >= cutoff: continue path = backup_path(item["name"]) if not path: continue try: os.remove(path) removed += 1 except OSError: pass return removed def _add_path(tar: tarfile.TarFile, abs_path: str, arcname: str) -> None: if not abs_path or not os.path.exists(abs_path): return tar.add(abs_path, arcname=arcname, recursive=True) def create_backup( *, base_dir: str, db_path: str, upload_folder: str, label: str = "manual", ) -> dict[str, Any]: """打包 .env / 数据库 / options_journal 到 tar.gz.""" with _LOCK: now = beijing_now() name = f"crypto_okx_backup_{now.strftime('%Y%m%d_%H%M%S')}.tar.gz" out_path = os.path.join(backup_dir(), name) manifest = { "kind": "crypto_okx", "created_bj": now.strftime("%Y-%m-%d %H:%M:%S"), "label": (label or "manual").strip() or "manual", "includes": [".env", "database", "options_journal"], } env_path = os.path.join(base_dir, ".env") try: from lib.options.options_review_images_lib import options_review_upload_dir journal_dir = options_review_upload_dir(upload_folder) except Exception: journal_dir = os.path.join(upload_folder or "", "options_journal") tmp_fd, tmp_path = tempfile.mkstemp(suffix=".tar.gz", prefix="okx_bak_") os.close(tmp_fd) try: with tarfile.open(tmp_path, "w:gz") as tar: manifest_path = tmp_path + ".manifest.json" with open(manifest_path, "w", encoding="utf-8") as f: json.dump(manifest, f, ensure_ascii=False, indent=2) try: tar.add(manifest_path, arcname="manifest.json") finally: try: os.remove(manifest_path) except OSError: pass _add_path(tar, env_path, ".env") if db_path and os.path.isfile(db_path): _add_path(tar, db_path, "database/" + os.path.basename(db_path)) if journal_dir and os.path.isdir(journal_dir): _add_path(tar, journal_dir, "options_journal") shutil.move(tmp_path, out_path) except Exception: try: os.remove(tmp_path) except OSError: pass raise prune_backups() st = os.stat(out_path) return { "ok": True, "name": name, "path": out_path, "size": int(st.st_size), "mtime_bj": now.strftime("%Y-%m-%d %H:%M:%S"), "keep_days": backup_keep_days(), } def restore_backup( name: str, *, base_dir: str, db_path: str, upload_folder: str, ) -> dict[str, Any]: path = backup_path(name) if not path or not os.path.isfile(path): return {"ok": False, "msg": "备份不存在"} with _LOCK: try: from lib.options.options_review_images_lib import options_review_upload_dir journal_dir = options_review_upload_dir(upload_folder) except Exception: journal_dir = os.path.join(upload_folder or "", "options_journal") os.makedirs(journal_dir, exist_ok=True) with tempfile.TemporaryDirectory(prefix="okx_restore_") as tmp: with tarfile.open(path, "r:gz") as tar: tar.extractall(tmp) env_src = os.path.join(tmp, ".env") if os.path.isfile(env_src): shutil.copy2(env_src, os.path.join(base_dir, ".env")) db_src_dir = os.path.join(tmp, "database") if os.path.isdir(db_src_dir) and db_path: for fn in os.listdir(db_src_dir): src = os.path.join(db_src_dir, fn) if os.path.isfile(src): os.makedirs(os.path.dirname(os.path.abspath(db_path)) or ".", exist_ok=True) shutil.copy2(src, db_path) break journal_src = os.path.join(tmp, "options_journal") if os.path.isdir(journal_src): if os.path.isdir(journal_dir): # 覆盖同名,保留其它本地文件 for root, _dirs, files in os.walk(journal_src): rel = os.path.relpath(root, journal_src) dest_root = journal_dir if rel == "." else os.path.join(journal_dir, rel) os.makedirs(dest_root, exist_ok=True) for fn in files: shutil.copy2(os.path.join(root, fn), os.path.join(dest_root, fn)) else: shutil.copytree(journal_src, journal_dir) return { "ok": True, "msg": "已恢复 .env / 数据库 / 复盘图片,建议重启进程使配置完全生效", "name": os.path.basename(path), } def maybe_run_auto_backup( *, base_dir: str, db_path: str, upload_folder: str, hour: int = 3, ) -> dict[str, Any] | None: """北京时间指定整点触发一次自动备份(同日不重复).""" global _LAST_AUTO_DAY now = beijing_now() day = now.strftime("%Y-%m-%d") if now.hour != int(hour): return None if _LAST_AUTO_DAY == day: return None # 已有今日自动备份则跳过创建 prefix = f"crypto_okx_backup_{now.strftime('%Y%m%d')}_" for item in list_backups(): if str(item["name"]).startswith(prefix): _LAST_AUTO_DAY = day return None result = create_backup( base_dir=base_dir, db_path=db_path, upload_folder=upload_folder, label="auto", ) _LAST_AUTO_DAY = day return result def start_backup_scheduler( *, base_dir: str, db_path_fn: Callable[[], str], upload_folder_fn: Callable[[], str], hour: int = 3, ) -> None: def loop() -> None: while True: try: maybe_run_auto_backup( base_dir=base_dir, db_path=db_path_fn(), upload_folder=upload_folder_fn(), hour=hour, ) except Exception: pass time.sleep(30) t = threading.Thread(target=loop, name="okx-backup-scheduler", daemon=True) t.start()