diff --git a/app.py b/app.py
index d64e769..efcaff3 100644
--- a/app.py
+++ b/app.py
@@ -7592,10 +7592,24 @@ try:
exchange_key="okx",
username=USERNAME,
password=PASSWORD,
+ db_path=DB_PATH,
+ upload_folder=UPLOAD_FOLDER,
)
except Exception as _settings_err:
print(f"[instance_settings] okx: {_settings_err}")
+try:
+ from lib.instance.backup_lib import start_backup_scheduler
+
+ start_backup_scheduler(
+ base_dir=BASE_DIR,
+ db_path_fn=lambda: DB_PATH,
+ upload_folder_fn=lambda: UPLOAD_FOLDER,
+ hour=3,
+ )
+except Exception as _backup_err:
+ print(f"[backup] scheduler: {_backup_err}")
+
from lib.sim.register import install_sim_trading
diff --git a/lib/common/static/instance_backup.js b/lib/common/static/instance_backup.js
new file mode 100644
index 0000000..c948ff0
--- /dev/null
+++ b/lib/common/static/instance_backup.js
@@ -0,0 +1,133 @@
+/**
+ * 系统设置 · 备份恢复
+ */
+(function (global) {
+ function statusEl() {
+ return document.getElementById("backup-status");
+ }
+
+ function setStatus(msg, isErr) {
+ const el = statusEl();
+ if (!el) return;
+ el.textContent = msg || "";
+ el.style.color = isErr ? "#fb7185" : "";
+ }
+
+ function fmtSize(n) {
+ const v = Number(n) || 0;
+ if (v < 1024) return v + " B";
+ if (v < 1024 * 1024) return (v / 1024).toFixed(1) + " KB";
+ return (v / (1024 * 1024)).toFixed(2) + " MB";
+ }
+
+ function escapeHtml(s) {
+ return String(s)
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """);
+ }
+
+ async function loadList() {
+ const body = document.getElementById("backup-list-body");
+ if (!body) return;
+ body.innerHTML = '
| 加载中… |
';
+ try {
+ const r = await fetch("/api/settings/backup/list", { credentials: "same-origin" });
+ const j = await r.json();
+ if (!j.ok) throw new Error(j.msg || "加载失败");
+ const dir = document.getElementById("backup-dir-label");
+ if (dir && j.backup_dir) dir.textContent = j.backup_dir;
+ const items = j.items || [];
+ if (!items.length) {
+ body.innerHTML = '| 暂无备份 |
';
+ return;
+ }
+ body.innerHTML = items
+ .map(function (it) {
+ const name = escapeHtml(it.name || "");
+ return (
+ "" +
+ "" +
+ name +
+ " | " +
+ "" +
+ escapeHtml(it.mtime_bj || "—") +
+ " | " +
+ "" +
+ escapeHtml(fmtSize(it.size)) +
+ " | " +
+ "" +
+ '下载 ' +
+ '' +
+ " | " +
+ "
"
+ );
+ })
+ .join("");
+ } catch (e) {
+ body.innerHTML =
+ '| ' + escapeHtml(e.message || "加载失败") + " |
";
+ }
+ }
+
+ async function createBackup() {
+ setStatus("备份中…");
+ try {
+ const r = await fetch("/api/settings/backup/create", {
+ method: "POST",
+ credentials: "same-origin",
+ headers: { "Content-Type": "application/json" },
+ body: "{}",
+ });
+ const j = await r.json();
+ if (!j.ok) throw new Error(j.msg || "备份失败");
+ setStatus("已生成 " + (j.name || "备份"));
+ await loadList();
+ } catch (e) {
+ setStatus(e.message || "备份失败", true);
+ }
+ }
+
+ async function restoreBackup(name) {
+ if (!name) return;
+ if (!confirm("确定用该备份恢复?\n将覆盖 .env、数据库与复盘图片,建议恢复后重启。")) return;
+ setStatus("恢复中…");
+ try {
+ const r = await fetch("/api/settings/backup/restore", {
+ method: "POST",
+ credentials: "same-origin",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ name: name }),
+ });
+ const j = await r.json();
+ if (!j.ok) throw new Error(j.msg || "恢复失败");
+ setStatus(j.msg || "恢复完成");
+ } catch (e) {
+ setStatus(e.message || "恢复失败", true);
+ }
+ }
+
+ function boot() {
+ const root = document.querySelector("[data-backup-root]");
+ if (!root || root.dataset.bound === "1") return;
+ root.dataset.bound = "1";
+ const createBtn = document.getElementById("backup-create-btn");
+ const refreshBtn = document.getElementById("backup-refresh-btn");
+ if (createBtn) createBtn.addEventListener("click", createBackup);
+ if (refreshBtn) refreshBtn.addEventListener("click", loadList);
+ root.addEventListener("click", function (ev) {
+ const btn = ev.target.closest("[data-backup-restore]");
+ if (!btn) return;
+ const name = decodeURIComponent(btn.getAttribute("data-backup-restore") || "");
+ void restoreBackup(name);
+ });
+ void loadList();
+ }
+
+ global.InstanceBackup = { boot: boot, loadList: loadList };
+})(typeof window !== "undefined" ? window : globalThis);
diff --git a/lib/common/static/instance_embed.js b/lib/common/static/instance_embed.js
index b9d2def..f8ae3ad 100644
--- a/lib/common/static/instance_embed.js
+++ b/lib/common/static/instance_embed.js
@@ -154,6 +154,9 @@
}
}
}
+ if (tab === "settings" && global.InstanceBackup && typeof global.InstanceBackup.boot === "function") {
+ global.InstanceBackup.boot();
+ }
}
if (!revisit) {
if (typeof global.refreshAccountSnapshot === "function") {
diff --git a/lib/hub/hub_bridge_register.py b/lib/hub/hub_bridge_register.py
index 389e706..d2a7f12 100644
--- a/lib/hub/hub_bridge_register.py
+++ b/lib/hub/hub_bridge_register.py
@@ -388,6 +388,43 @@ def register_hub_bridge_routes(app: Flask, app_module: Any) -> None:
finally:
conn.close()
+ @app.route("/api/hub/backup/list", methods=["GET"])
+ def hub_backup_list():
+ if not hub_bridge_token_ok(request.headers):
+ return jsonify({"ok": False, "msg": "未授权"}), 401
+ from lib.instance.backup_lib import backup_dir, backup_keep_days, list_backups
+
+ return jsonify(
+ {
+ "ok": True,
+ "items": list_backups(),
+ "backup_dir": backup_dir(),
+ "keep_days": backup_keep_days(),
+ "auto_hour_bj": 3,
+ "includes": [".env", "database", "options_journal"],
+ }
+ )
+
+ @app.route("/api/hub/backup/download/", methods=["GET"])
+ def hub_backup_download(name: str):
+ if not hub_bridge_token_ok(request.headers):
+ return jsonify({"ok": False, "msg": "未授权"}), 401
+ import os
+
+ from flask import send_file
+
+ from lib.instance.backup_lib import backup_path
+
+ path = backup_path(name)
+ if not path or not os.path.isfile(path):
+ return jsonify({"ok": False, "msg": "备份不存在"}), 404
+ return send_file(
+ path,
+ mimetype="application/gzip",
+ as_attachment=True,
+ download_name=os.path.basename(path),
+ )
+
@app.route("/hub_sso")
def hub_sso_login():
token = (request.args.get("token") or "").strip()
diff --git a/lib/instance/backup_lib.py b/lib/instance/backup_lib.py
new file mode 100644
index 0000000..f97fc6c
--- /dev/null
+++ b/lib/instance/backup_lib.py
@@ -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()
diff --git a/lib/instance/instance_embed_lib.py b/lib/instance/instance_embed_lib.py
index 1bf93d2..29ab729 100644
--- a/lib/instance/instance_embed_lib.py
+++ b/lib/instance/instance_embed_lib.py
@@ -78,7 +78,7 @@ def embed_shell_enabled() -> bool:
_SETTINGS_SUB_TABS = frozenset(
- {"nav", "password", "transfer", "export", "options_swap", "options_transfer"}
+ {"nav", "password", "transfer", "export", "backup", "options_swap", "options_transfer"}
)
diff --git a/lib/instance/instance_settings_lib.py b/lib/instance/instance_settings_lib.py
index 921c66c..645b378 100644
--- a/lib/instance/instance_settings_lib.py
+++ b/lib/instance/instance_settings_lib.py
@@ -131,6 +131,7 @@ def build_settings_tabs(display: dict[str, Any] | None, instance_settings: dict[
tabs.append({"key": "transfer", "title": "永续划转"})
if disp.get("show_settings_export", True):
tabs.append({"key": "export", "title": "数据导出"})
+ tabs.append({"key": "backup", "title": "备份恢复"})
if inst.get("options_settings_enabled") and disp.get("show_settings_options_swap", True):
_coin = False
try:
diff --git a/lib/instance/instance_settings_register.py b/lib/instance/instance_settings_register.py
index 2826fe8..333be84 100644
--- a/lib/instance/instance_settings_register.py
+++ b/lib/instance/instance_settings_register.py
@@ -1,11 +1,11 @@
-"""实例系统设置 API:导航开关,env 读写,改密,PM2 重启."""
+"""实例系统设置 API:导航开关,env 读写,改密,PM2 重启,备份恢复."""
from __future__ import annotations
import os
from functools import wraps
from typing import Any, Callable
-from flask import jsonify, request, session
+from flask import jsonify, request, send_file, session
from lib.env.env_file_lib import apply_env_updates
from lib.env.env_ui_manifest import (
@@ -83,10 +83,14 @@ def register_instance_settings_routes(
exchange_key: str,
username: str,
password: str,
+ db_path: str | None = None,
+ upload_folder: str | None = None,
) -> None:
env_path = os.path.join(base_dir, ".env")
example_path = os.path.join(base_dir, ".env.example")
api_auth = _api_login_required()
+ resolved_db = (db_path or "").strip() or os.path.join(base_dir, "crypto.db")
+ resolved_upload = (upload_folder or "").strip() or os.path.join(base_dir, "static", "images")
@app.route("/api/settings/display", methods=["GET", "POST"])
@api_auth
@@ -175,6 +179,71 @@ def register_instance_settings_routes(
def api_admin_health():
return jsonify({"ok": True, "status": "up"})
+ @app.route("/api/settings/backup/list", methods=["GET"])
+ @api_auth
+ def api_settings_backup_list():
+ from lib.instance.backup_lib import backup_dir, backup_keep_days, list_backups
+
+ return jsonify(
+ {
+ "ok": True,
+ "items": list_backups(),
+ "backup_dir": backup_dir(),
+ "keep_days": backup_keep_days(),
+ "auto_hour_bj": 3,
+ "includes": [".env", "database", "options_journal"],
+ }
+ )
+
+ @app.route("/api/settings/backup/create", methods=["POST"])
+ @api_auth
+ def api_settings_backup_create():
+ from lib.instance.backup_lib import create_backup
+
+ try:
+ result = create_backup(
+ base_dir=base_dir,
+ db_path=resolved_db,
+ upload_folder=resolved_upload,
+ label="manual",
+ )
+ return jsonify(result)
+ except Exception as exc:
+ return jsonify({"ok": False, "msg": str(exc) or "备份失败"}), 500
+
+ @app.route("/api/settings/backup/download/", methods=["GET"])
+ @api_auth
+ def api_settings_backup_download(name: str):
+ from lib.instance.backup_lib import backup_path
+
+ path = backup_path(name)
+ if not path or not os.path.isfile(path):
+ return jsonify({"ok": False, "msg": "备份不存在"}), 404
+ return send_file(
+ path,
+ mimetype="application/gzip",
+ as_attachment=True,
+ download_name=os.path.basename(path),
+ )
+
+ @app.route("/api/settings/backup/restore", methods=["POST"])
+ @api_auth
+ def api_settings_backup_restore():
+ from lib.instance.backup_lib import restore_backup
+
+ body = request.get_json(silent=True) or {}
+ name = str(body.get("name") or "").strip()
+ if not name:
+ return jsonify({"ok": False, "msg": "缺少备份文件名"}), 400
+ result = restore_backup(
+ name,
+ base_dir=base_dir,
+ db_path=resolved_db,
+ upload_folder=resolved_upload,
+ )
+ code = 200 if result.get("ok") else 400
+ return jsonify(result), code
+
def tab_allowed_fn(tab: str) -> bool:
prefs = get_display_prefs(get_db)
return tab_allowed(tab, prefs)
diff --git a/lib/instance/instance_static_lib.py b/lib/instance/instance_static_lib.py
index 7cd6dab..ccbee36 100644
--- a/lib/instance/instance_static_lib.py
+++ b/lib/instance/instance_static_lib.py
@@ -33,6 +33,7 @@ _COMMON_STATIC_ASSETS: dict[str, str] = {
"instance_stats.js": "application/javascript; charset=utf-8",
"instance_live.js": "application/javascript; charset=utf-8",
"instance_settings_prefs.js": "application/javascript; charset=utf-8",
+ "instance_backup.js": "application/javascript; charset=utf-8",
"instance_dashboard.js": "application/javascript; charset=utf-8",
"account_ledger.js": "application/javascript; charset=utf-8",
"options_expiry_countdown.js": "application/javascript; charset=utf-8",
diff --git a/lib/instance/templates/backup_settings_panel.html b/lib/instance/templates/backup_settings_panel.html
new file mode 100644
index 0000000..f2ec791
--- /dev/null
+++ b/lib/instance/templates/backup_settings_panel.html
@@ -0,0 +1,37 @@
+{# 备份恢复:本地 /root/crypto_okx_backups,凌晨3点自动,保留10天 #}
+
+
备份恢复
+
+ 含 .env、数据库、期权复盘图片。每天北京时间 03:00 自动备份,保留最近
+ 10 天。目录:
+ /root/crypto_okx_backups
+
+
+
+
+
+
+
+
+
+
+ | 文件 |
+ 时间(北京) |
+ 大小 |
+ 操作 |
+
+
+
+ | 加载中… |
+
+
+
+
+
+
diff --git a/lib/instance/templates/embed_shell.html b/lib/instance/templates/embed_shell.html
index 56a580e..b31fa0c 100644
--- a/lib/instance/templates/embed_shell.html
+++ b/lib/instance/templates/embed_shell.html
@@ -149,7 +149,7 @@ window.__INSTANCE_DISPLAY__ = {{ display | tojson }};
-
+