系统设置增加备份恢复:凌晨3点自动备份,保留10天,含 env 与复盘图片。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-10-04 06:50:44 +08:00
parent 9d3effd6fc
commit f846f832de
12 changed files with 579 additions and 4 deletions
+14
View File
@@ -7592,10 +7592,24 @@ try:
exchange_key="okx", exchange_key="okx",
username=USERNAME, username=USERNAME,
password=PASSWORD, password=PASSWORD,
db_path=DB_PATH,
upload_folder=UPLOAD_FOLDER,
) )
except Exception as _settings_err: except Exception as _settings_err:
print(f"[instance_settings] okx: {_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 from lib.sim.register import install_sim_trading
+133
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
async function loadList() {
const body = document.getElementById("backup-list-body");
if (!body) return;
body.innerHTML = '<tr><td colspan="4" class="muted">加载中…</td></tr>';
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 = '<tr><td colspan="4" class="muted">暂无备份</td></tr>';
return;
}
body.innerHTML = items
.map(function (it) {
const name = escapeHtml(it.name || "");
return (
"<tr>" +
"<td><code style=\"font-size:.74rem\">" +
name +
"</code></td>" +
"<td>" +
escapeHtml(it.mtime_bj || "—") +
"</td>" +
"<td>" +
escapeHtml(fmtSize(it.size)) +
"</td>" +
"<td style=\"white-space:nowrap\">" +
'<a class="btn-secondary" style="padding:3px 8px;font-size:.74rem;text-decoration:none" href="/api/settings/backup/download/' +
encodeURIComponent(it.name) +
'">下载</a> ' +
'<button type="button" class="btn-del" style="padding:3px 8px;font-size:.74rem" data-backup-restore="' +
encodeURIComponent(it.name) +
'">恢复</button>' +
"</td>" +
"</tr>"
);
})
.join("");
} catch (e) {
body.innerHTML =
'<tr><td colspan="4" class="muted">' + escapeHtml(e.message || "加载失败") + "</td></tr>";
}
}
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);
+3
View File
@@ -154,6 +154,9 @@
} }
} }
} }
if (tab === "settings" && global.InstanceBackup && typeof global.InstanceBackup.boot === "function") {
global.InstanceBackup.boot();
}
} }
if (!revisit) { if (!revisit) {
if (typeof global.refreshAccountSnapshot === "function") { if (typeof global.refreshAccountSnapshot === "function") {
+37
View File
@@ -388,6 +388,43 @@ def register_hub_bridge_routes(app: Flask, app_module: Any) -> None:
finally: finally:
conn.close() 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/<name>", 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") @app.route("/hub_sso")
def hub_sso_login(): def hub_sso_login():
token = (request.args.get("token") or "").strip() token = (request.args.get("token") or "").strip()
+278
View File
@@ -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()
+1 -1
View File
@@ -78,7 +78,7 @@ def embed_shell_enabled() -> bool:
_SETTINGS_SUB_TABS = frozenset( _SETTINGS_SUB_TABS = frozenset(
{"nav", "password", "transfer", "export", "options_swap", "options_transfer"} {"nav", "password", "transfer", "export", "backup", "options_swap", "options_transfer"}
) )
+1
View File
@@ -131,6 +131,7 @@ def build_settings_tabs(display: dict[str, Any] | None, instance_settings: dict[
tabs.append({"key": "transfer", "title": "永续划转"}) tabs.append({"key": "transfer", "title": "永续划转"})
if disp.get("show_settings_export", True): if disp.get("show_settings_export", True):
tabs.append({"key": "export", "title": "数据导出"}) tabs.append({"key": "export", "title": "数据导出"})
tabs.append({"key": "backup", "title": "备份恢复"})
if inst.get("options_settings_enabled") and disp.get("show_settings_options_swap", True): if inst.get("options_settings_enabled") and disp.get("show_settings_options_swap", True):
_coin = False _coin = False
try: try:
+71 -2
View File
@@ -1,11 +1,11 @@
"""实例系统设置 API:导航开关,env 读写,改密,PM2 重启.""" """实例系统设置 API:导航开关,env 读写,改密,PM2 重启,备份恢复."""
from __future__ import annotations from __future__ import annotations
import os import os
from functools import wraps from functools import wraps
from typing import Any, Callable 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_file_lib import apply_env_updates
from lib.env.env_ui_manifest import ( from lib.env.env_ui_manifest import (
@@ -83,10 +83,14 @@ def register_instance_settings_routes(
exchange_key: str, exchange_key: str,
username: str, username: str,
password: str, password: str,
db_path: str | None = None,
upload_folder: str | None = None,
) -> None: ) -> None:
env_path = os.path.join(base_dir, ".env") env_path = os.path.join(base_dir, ".env")
example_path = os.path.join(base_dir, ".env.example") example_path = os.path.join(base_dir, ".env.example")
api_auth = _api_login_required() 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"]) @app.route("/api/settings/display", methods=["GET", "POST"])
@api_auth @api_auth
@@ -175,6 +179,71 @@ def register_instance_settings_routes(
def api_admin_health(): def api_admin_health():
return jsonify({"ok": True, "status": "up"}) 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/<name>", 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: def tab_allowed_fn(tab: str) -> bool:
prefs = get_display_prefs(get_db) prefs = get_display_prefs(get_db)
return tab_allowed(tab, prefs) return tab_allowed(tab, prefs)
+1
View File
@@ -33,6 +33,7 @@ _COMMON_STATIC_ASSETS: dict[str, str] = {
"instance_stats.js": "application/javascript; charset=utf-8", "instance_stats.js": "application/javascript; charset=utf-8",
"instance_live.js": "application/javascript; charset=utf-8", "instance_live.js": "application/javascript; charset=utf-8",
"instance_settings_prefs.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", "instance_dashboard.js": "application/javascript; charset=utf-8",
"account_ledger.js": "application/javascript; charset=utf-8", "account_ledger.js": "application/javascript; charset=utf-8",
"options_expiry_countdown.js": "application/javascript; charset=utf-8", "options_expiry_countdown.js": "application/javascript; charset=utf-8",
@@ -0,0 +1,37 @@
{# 备份恢复:本地 /root/crypto_okx_backups,凌晨3点自动,保留10天 #}
<div class="backup-settings" data-backup-root="1">
<h2>备份恢复</h2>
<p class="settings-subcard-desc muted">
含 <code>.env</code>、数据库、期权复盘图片。每天北京时间 <strong>03:00</strong> 自动备份,保留最近
<strong>10</strong> 天。目录:
<code id="backup-dir-label">/root/crypto_okx_backups</code>
</p>
<div class="settings-export-links-inline settings-export-links-block" style="gap:8px;margin-bottom:12px">
<button type="button" class="btn" id="backup-create-btn">立即备份</button>
<button type="button" class="btn-secondary" id="backup-refresh-btn">刷新列表</button>
<span class="muted" id="backup-status" style="font-size:.78rem"></span>
</div>
<div class="table-wrap">
<table class="or-trades-table" style="width:100%;font-size:.8rem">
<thead>
<tr>
<th>文件</th>
<th>时间(北京)</th>
<th>大小</th>
<th>操作</th>
</tr>
</thead>
<tbody id="backup-list-body">
<tr><td colspan="4" class="muted">加载中…</td></tr>
</tbody>
</table>
</div>
</div>
<script src="/static/instance_backup.js?v=1"></script>
<script>
(function () {
if (window.InstanceBackup && typeof window.InstanceBackup.boot === "function") {
window.InstanceBackup.boot();
}
})();
</script>
+1 -1
View File
@@ -149,7 +149,7 @@ window.__INSTANCE_DISPLAY__ = {{ display | tojson }};
</script> </script>
<script src="/static/instance_settings_prefs.js?v=24"></script> <script src="/static/instance_settings_prefs.js?v=24"></script>
<script src="/static/instance_live.js?v=7"></script> <script src="/static/instance_live.js?v=7"></script>
<script src="/static/instance_embed.js?v=32"></script> <script src="/static/instance_embed.js?v=33"></script>
<script src="/static/instance_mobile_nav.js?v=2"></script> <script src="/static/instance_mobile_nav.js?v=2"></script>
</body> </body>
</html> </html>
@@ -47,6 +47,8 @@
<a href="/export/key_monitors">关键位(当前)</a> <a href="/export/key_monitors">关键位(当前)</a>
<a href="/export/key_monitor_history">关键位历史</a> <a href="/export/key_monitor_history">关键位历史</a>
</div> </div>
{% elif tab.key == 'backup' %}
{% include 'backup_settings_panel.html' %}
{% elif tab.key == 'options_swap' %} {% elif tab.key == 'options_swap' %}
<h2>币种兑换</h2> <h2>币种兑换</h2>
{% include 'options_settings_swap.html' %} {% include 'options_settings_swap.html' %}