系统设置增加备份恢复:凌晨3点自动备份,保留10天,含 env 与复盘图片。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,278 @@
|
||||
"""实例备份/恢复: .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()
|
||||
Reference in New Issue
Block a user