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