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 }}; - + diff --git a/lib/instance/templates/settings_panel.html b/lib/instance/templates/settings_panel.html index 5bb0974..b2166c3 100644 --- a/lib/instance/templates/settings_panel.html +++ b/lib/instance/templates/settings_panel.html @@ -47,6 +47,8 @@ 关键位(当前) 关键位历史 + {% elif tab.key == 'backup' %} + {% include 'backup_settings_panel.html' %} {% elif tab.key == 'options_swap' %}

币种兑换

{% include 'options_settings_swap.html' %}