From 2b348854fea0ae215063c4fda539ad0e5bcd61cf Mon Sep 17 00:00:00 2001 From: dekun Date: Sun, 26 Jul 2026 23:05:58 +0800 Subject: [PATCH] Add DB+.env auto backup with download and upload restore in Settings. Backups land under /root/eth_hedge_backups; restore accepts zip body for new-server migration and restarts the process. Co-authored-by: Cursor --- backend/app/api/__init__.py | 2 + backend/app/api/backup_routes.py | 156 +++++++++++++++ backend/app/backup.py | 330 +++++++++++++++++++++++++++++++ backend/app/credentials.py | 6 + backend/app/main.py | 7 + backend/tests/test_backup.py | 75 +++++++ docs/更新说明.md | 14 ++ frontend/src/api/client.ts | 107 +++++++--- frontend/src/pages/Settings.tsx | 274 ++++++++++++++++++++++++- 9 files changed, 946 insertions(+), 25 deletions(-) create mode 100644 backend/app/api/backup_routes.py create mode 100644 backend/app/backup.py create mode 100644 backend/tests/test_backup.py diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index f9b4f03..33fd14e 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -1,6 +1,7 @@ from fastapi import APIRouter from .auth_routes import router as auth_router +from .backup_routes import router as backup_router from .market import router as market_router from .plan import router as plan_router from .settings import router as settings_router @@ -16,3 +17,4 @@ router.include_router(plan_router) router.include_router(trades_router) router.include_router(stats_router) router.include_router(settings_router) +router.include_router(backup_router) diff --git a/backend/app/api/backup_routes.py b/backend/app/api/backup_routes.py new file mode 100644 index 0000000..f360805 --- /dev/null +++ b/backend/app/api/backup_routes.py @@ -0,0 +1,156 @@ +"""备份下载 / 上传恢复 API。""" + +from __future__ import annotations + +import asyncio +import logging +import os +import tempfile +from pathlib import Path +from typing import Annotated + +from fastapi import APIRouter, Depends, Header, HTTPException, Request +from fastapi.responses import FileResponse +from pydantic import BaseModel, Field + +from ..backup import ( + backup_settings, + create_backup, + list_backups, + prune_backups, + read_backup_file, + restore_from_zip, + validate_backup_zip, +) +from ..models.db import get_db +from ..strategy import get_engine +from .auth import require_user + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/backup", tags=["backup"]) + + +class BackupSettingsBody(BaseModel): + auto_enabled: bool | None = None + interval_hours: int | None = Field(default=None, ge=1, le=168) + keep_count: int | None = Field(default=None, ge=1, le=90) + + +@router.get("/status") +async def backup_status(_user: Annotated[str, Depends(require_user)]) -> dict: + cfg = backup_settings() + return { + **cfg, + "items": list_backups(), + } + + +@router.put("/settings") +async def put_backup_settings( + body: BackupSettingsBody, + _user: Annotated[str, Depends(require_user)], +) -> dict: + db = get_db() + data = body.model_dump(exclude_none=True) + if "auto_enabled" in data: + db.set_setting("backup_auto_enabled", "1" if data["auto_enabled"] else "0") + if "interval_hours" in data: + db.set_setting("backup_interval_hours", str(int(data["interval_hours"]))) + if "keep_count" in data: + db.set_setting("backup_keep_count", str(int(data["keep_count"]))) + prune_backups(int(data["keep_count"])) + return await backup_status(_user) + + +@router.post("/now") +async def backup_now(_user: Annotated[str, Depends(require_user)]) -> dict: + try: + return await asyncio.to_thread(create_backup, reason="manual") + except Exception as e: + logger.exception("backup now failed") + raise HTTPException(status_code=500, detail=f"备份失败: {e}") from e + + +@router.get("/download/{name}") +async def download_backup( + name: str, + _user: Annotated[str, Depends(require_user)], +) -> FileResponse: + try: + path = read_backup_file(name) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except FileNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + return FileResponse( + path, + media_type="application/zip", + filename=path.name, + ) + + +@router.post("/restore") +async def restore_backup( + request: Request, + _user: Annotated[str, Depends(require_user)], + x_confirm_phrase: Annotated[str, Header(alias="X-Confirm-Phrase")] = "", +) -> dict: + """上传 zip 原始字节恢复数据库与 .env(新服务器迁移)。成功后进程退出由 PM2 拉起。""" + if (x_confirm_phrase or "").strip() != "RESTORE": + raise HTTPException( + status_code=400, + detail="请输入确认串 RESTORE(大写)后再恢复(请求头 X-Confirm-Phrase)", + ) + + try: + eng = get_engine() + if eng.matcher.has_open_position(): + raise HTTPException( + status_code=409, + detail="有未平仓,请先平仓或紧急全平后再恢复备份", + ) + await eng.pause() + except HTTPException: + raise + except Exception as e: + logger.warning("pause before restore: %s", e) + + raw = await request.body() + if not raw: + raise HTTPException(status_code=400, detail="请上传 .zip 备份包") + if len(raw) > 80 * 1024 * 1024: + raise HTTPException(status_code=400, detail="备份包过大(上限 80MB)") + if raw[:2] != b"PK": + raise HTTPException(status_code=400, detail="文件不是有效的 zip 备份包") + + tmp = Path(tempfile.mkstemp(prefix="restore_", suffix=".zip")[1]) + try: + tmp.write_bytes(raw) + try: + validate_backup_zip(tmp) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + + result = await asyncio.to_thread(restore_from_zip, tmp, make_safety_backup=True) + + async def _exit_soon() -> None: + await asyncio.sleep(1.2) + logger.warning("exiting after backup restore for process restart") + os._exit(0) + + asyncio.create_task(_exit_soon()) + return { + **result, + "detail": "恢复成功,服务即将自动重启以加载 .env 与数据库;请数秒后刷新页面", + } + except HTTPException: + raise + except Exception as e: + logger.exception("restore failed") + raise HTTPException(status_code=500, detail=f"恢复失败: {e}") from e + finally: + try: + tmp.unlink(missing_ok=True) + except Exception: + pass diff --git a/backend/app/backup.py b/backend/app/backup.py new file mode 100644 index 0000000..d61ee1c --- /dev/null +++ b/backend/app/backup.py @@ -0,0 +1,330 @@ +"""数据库 + .env 备份 / 恢复(自动落盘到 /root/eth_hedge_backups)。""" + +from __future__ import annotations + +import asyncio +import json +import logging +import os +import shutil +import sqlite3 +import tempfile +import time +import zipfile +from pathlib import Path +from typing import Any + +from .config import get_settings +from .credentials import resolve_env_file_path +from .models.db import Database, get_db + +logger = logging.getLogger(__name__) + +MANIFEST_NAME = "manifest.json" +DB_ARCNAME = "hedge.db" +ENV_ARCNAME = ".env" +BACKUP_PREFIX = "eth_hedge_backup_" +DEFAULT_KEEP = 14 +DEFAULT_INTERVAL_HOURS = 24 + + +def resolve_backup_dir() -> Path: + """优先 BACKUP_DIR;Linux 默认 /root/eth_hedge_backups;否则用户目录。""" + override = (os.environ.get("BACKUP_DIR") or "").strip() + if override: + p = Path(override) + p.mkdir(parents=True, exist_ok=True) + return p + if os.name != "nt": + root = Path("/root/eth_hedge_backups") + try: + if Path("/root").is_dir(): + root.mkdir(parents=True, exist_ok=True) + return root + except OSError as e: + logger.warning("cannot use /root/eth_hedge_backups: %s", e) + p = Path.home() / "eth_hedge_backups" + p.mkdir(parents=True, exist_ok=True) + return p + + +def resolve_env_path() -> Path | None: + p = resolve_env_file_path() + return p + + +def _settings_int(db: Database, key: str, default: int) -> int: + raw = db.get_setting(key, str(default)) + try: + return int(float(raw or default)) + except (TypeError, ValueError): + return default + + +def _settings_bool(db: Database, key: str, default: bool) -> bool: + raw = db.get_setting(key, "1" if default else "0") + if raw is None or raw == "": + return default + return str(raw).strip().lower() in ("1", "true", "yes", "on") + + +def backup_settings(db: Database | None = None) -> dict[str, Any]: + db = db or get_db() + return { + "auto_enabled": _settings_bool(db, "backup_auto_enabled", True), + "interval_hours": max(1, _settings_int(db, "backup_interval_hours", DEFAULT_INTERVAL_HOURS)), + "keep_count": max(1, min(90, _settings_int(db, "backup_keep_count", DEFAULT_KEEP))), + "last_at_ms": _settings_int(db, "backup_last_at_ms", 0) or None, + "backup_dir": str(resolve_backup_dir()), + } + + +def list_backups() -> list[dict[str, Any]]: + d = resolve_backup_dir() + items: list[dict[str, Any]] = [] + if not d.is_dir(): + return items + for p in sorted(d.glob(f"{BACKUP_PREFIX}*.zip"), key=lambda x: x.stat().st_mtime, reverse=True): + st = p.stat() + items.append( + { + "name": p.name, + "path": str(p), + "size_bytes": st.st_size, + "mtime_ms": int(st.st_mtime * 1000), + } + ) + return items + + +def prune_backups(keep: int | None = None) -> int: + keep_n = keep if keep is not None else backup_settings()["keep_count"] + items = list_backups() + removed = 0 + for old in items[int(keep_n) :]: + try: + Path(old["path"]).unlink(missing_ok=True) + removed += 1 + except OSError as e: + logger.warning("prune backup failed %s: %s", old.get("name"), e) + return removed + + +def _sqlite_snapshot(db: Database, dest: Path) -> None: + dest.parent.mkdir(parents=True, exist_ok=True) + if dest.exists(): + dest.unlink() + with db._lock: + dst = sqlite3.connect(str(dest)) + try: + db._conn.backup(dst) + dst.commit() + finally: + dst.close() + + +def create_backup( + *, + db: Database | None = None, + reason: str = "manual", +) -> dict[str, Any]: + db = db or get_db() + backup_dir = resolve_backup_dir() + ts = time.strftime("%Y%m%d_%H%M%S") + out = backup_dir / f"{BACKUP_PREFIX}{ts}.zip" + env_path = resolve_env_path() + db_path = Path(db.path) + + with tempfile.TemporaryDirectory(prefix="eth_hedge_bak_") as td: + tmp_db = Path(td) / "hedge.db" + _sqlite_snapshot(db, tmp_db) + manifest = { + "product": "比特骆驼自动化对冲系统", + "version": 1, + "created_at_ms": int(time.time() * 1000), + "reason": reason, + "db_source": str(db_path), + "env_source": str(env_path) if env_path else None, + "has_env": bool(env_path and env_path.is_file()), + "mode": get_settings().mode, + "env_name": get_settings().env_name, + } + with zipfile.ZipFile(out, "w", compression=zipfile.ZIP_DEFLATED) as zf: + zf.write(tmp_db, DB_ARCNAME) + if env_path and env_path.is_file(): + zf.write(env_path, ENV_ARCNAME) + zf.writestr( + MANIFEST_NAME, + json.dumps(manifest, ensure_ascii=False, indent=2), + ) + + db.set_setting("backup_last_at_ms", str(int(time.time() * 1000))) + prune_backups() + st = out.stat() + logger.info("backup created path=%s reason=%s size=%s", out, reason, st.st_size) + return { + "ok": True, + "name": out.name, + "path": str(out), + "size_bytes": st.st_size, + "mtime_ms": int(st.st_mtime * 1000), + "backup_dir": str(backup_dir), + "manifest": manifest, + } + + +def read_backup_file(name: str) -> Path: + safe = Path(name).name + if not safe.startswith(BACKUP_PREFIX) or not safe.endswith(".zip"): + raise ValueError("非法备份文件名") + path = resolve_backup_dir() / safe + if not path.is_file(): + raise FileNotFoundError(f"备份不存在: {safe}") + return path + + +def _extract_backup_zip(zip_path: Path, dest_dir: Path) -> dict[str, Path]: + with zipfile.ZipFile(zip_path, "r") as zf: + names = set(zf.namelist()) + if DB_ARCNAME not in names: + # 兼容偶发相对路径 + db_candidates = [n for n in names if n.endswith("hedge.db") or n.endswith(".db")] + if not db_candidates: + raise ValueError("备份包缺少 hedge.db") + db_name = db_candidates[0] + else: + db_name = DB_ARCNAME + env_name = ENV_ARCNAME if ENV_ARCNAME in names else ( + next((n for n in names if n.endswith(".env") or n == "env"), None) + ) + zf.extract(db_name, dest_dir) + db_out = dest_dir / db_name + if not db_out.is_file(): + # zip 内可能带目录 + found = list(dest_dir.rglob("*.db")) + if not found: + raise ValueError("解压后未找到数据库文件") + db_out = found[0] + env_out: Path | None = None + if env_name: + zf.extract(env_name, dest_dir) + env_out = dest_dir / env_name + if not env_out.is_file(): + found_env = list(dest_dir.rglob(".env")) + list(dest_dir.rglob("*.env")) + env_out = found_env[0] if found_env else None + return {"db": db_out, "env": env_out} # type: ignore[dict-item] + + +def validate_backup_zip(zip_path: Path) -> dict[str, Any]: + with zipfile.ZipFile(zip_path, "r") as zf: + names = zf.namelist() + has_db = any(n.endswith("hedge.db") or n == DB_ARCNAME or n.endswith(".db") for n in names) + has_env = any(n.endswith(".env") or n == ENV_ARCNAME for n in names) + manifest = None + if MANIFEST_NAME in names: + try: + manifest = json.loads(zf.read(MANIFEST_NAME).decode("utf-8")) + except Exception: + manifest = None + if not has_db: + raise ValueError("备份包无效:缺少数据库") + return {"has_db": has_db, "has_env": has_env, "manifest": manifest, "files": names} + + +def restore_from_zip( + zip_path: Path, + *, + db: Database | None = None, + make_safety_backup: bool = True, +) -> dict[str, Any]: + """用备份包覆盖当前 hedge.db 与 .env。调用方应在此前后暂停策略并准备重启进程。""" + db = db or get_db() + info = validate_backup_zip(zip_path) + safety = None + if make_safety_backup: + try: + safety = create_backup(db=db, reason="pre_restore") + except Exception as e: + logger.warning("pre_restore backup failed: %s", e) + + with tempfile.TemporaryDirectory(prefix="eth_hedge_restore_") as td: + extracted = _extract_backup_zip(zip_path, Path(td)) + src_db = extracted["db"] + src_env = extracted.get("env") + + db_path = Path(db.path) + bak_live: Path | None = None + db.close() + + if db_path.is_file(): + bak_live = db_path.with_suffix(db_path.suffix + f".pre_restore_{int(time.time())}") + shutil.copy2(db_path, bak_live) + shutil.copy2(src_db, db_path) + + env_written = None + if src_env and Path(src_env).is_file(): + env_target = resolve_env_file_path() + env_target.parent.mkdir(parents=True, exist_ok=True) + if env_target.is_file(): + shutil.copy2(env_target, env_target.with_suffix(".env.pre_restore")) + shutil.copy2(src_env, env_target) + try: + env_target.chmod(0o600) + except Exception: + pass + env_written = str(env_target) + + return { + "ok": True, + "db_path": str(db_path), + "env_path": env_written, + "had_env_in_backup": bool(info.get("has_env")), + "safety_backup": safety.get("name") if safety else None, + "local_db_copy": str(bak_live) if bak_live and Path(bak_live).exists() else None, + "restart_required": True, + "manifest": info.get("manifest"), + } + + +_auto_task: asyncio.Task[None] | None = None + + +async def auto_backup_loop() -> None: + """后台定时备份;默认开启。""" + logger.info("auto backup loop started dir=%s", resolve_backup_dir()) + while True: + try: + await asyncio.sleep(60) + db = get_db() + cfg = backup_settings(db) + if not cfg["auto_enabled"]: + continue + interval_ms = int(cfg["interval_hours"]) * 3600 * 1000 + last = int(cfg["last_at_ms"] or 0) + now = int(time.time() * 1000) + if last and now - last < interval_ms: + continue + await asyncio.to_thread(create_backup, db=db, reason="auto") + except asyncio.CancelledError: + raise + except Exception: + logger.exception("auto backup tick failed") + await asyncio.sleep(30) + + +def start_auto_backup_task() -> asyncio.Task[None]: + global _auto_task + if _auto_task is None or _auto_task.done(): + _auto_task = asyncio.create_task(auto_backup_loop(), name="auto-backup") + return _auto_task + + +async def stop_auto_backup_task() -> None: + global _auto_task + if _auto_task and not _auto_task.done(): + _auto_task.cancel() + try: + await _auto_task + except Exception: + pass + _auto_task = None diff --git a/backend/app/credentials.py b/backend/app/credentials.py index bd1450c..a305f55 100644 --- a/backend/app/credentials.py +++ b/backend/app/credentials.py @@ -20,6 +20,12 @@ def _env_paths() -> list[Path]: return [root / ".env", Path.cwd() / ".env", Path.cwd().parent / ".env"] +def resolve_env_file_path() -> Path: + """第一个已存在的 .env;都不存在则返回仓库根 .env 路径(可写入)。""" + paths = _env_paths() + return next((p for p in paths if p.is_file()), paths[0]) + + def _ensure_loaded() -> None: global _username, _password if _username is not None and _password is not None: diff --git a/backend/app/main.py b/backend/app/main.py index d17f517..35eef23 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -59,6 +59,10 @@ async def lifespan(app: FastAPI): logger.exception("LIVE startup reconcile log failed") engine.ensure_loop() + from .backup import start_auto_backup_task + + start_auto_backup_task() + session = bootstrap_session(settings) try: await session.start() @@ -79,6 +83,9 @@ async def lifespan(app: FastAPI): await engine._task except Exception: pass + from .backup import stop_auto_backup_task + + await stop_auto_backup_task() await session.stop() from .exchange import set_exchange from .strategy.session import set_session diff --git a/backend/tests/test_backup.py b/backend/tests/test_backup.py new file mode 100644 index 0000000..0eedbfd --- /dev/null +++ b/backend/tests/test_backup.py @@ -0,0 +1,75 @@ +"""备份 / 恢复单元测试。""" + +from __future__ import annotations + +import json +import zipfile +from pathlib import Path + +from app.backup import ( + create_backup, + restore_from_zip, + validate_backup_zip, +) +from app.models.db import Database, set_db + + +def test_create_and_validate_backup(tmp_path, monkeypatch): + monkeypatch.setenv("BACKUP_DIR", str(tmp_path / "baks")) + env = tmp_path / ".env" + env.write_text('AUTH_USERNAME="admin"\nMODE="SIM"\n', encoding="utf-8") + monkeypatch.chdir(tmp_path) + + db = Database(tmp_path / "hedge.db") + set_db(db) + db.set_setting("fee_rate", "0.0007") + # 指向临时 .env + monkeypatch.setattr( + "app.backup.resolve_env_file_path", + lambda: env, + ) + monkeypatch.setattr( + "app.credentials.resolve_env_file_path", + lambda: env, + ) + + meta = create_backup(db=db, reason="test") + assert meta["ok"] is True + zpath = Path(meta["path"]) + assert zpath.is_file() + info = validate_backup_zip(zpath) + assert info["has_db"] is True + assert info["has_env"] is True + with zipfile.ZipFile(zpath) as zf: + assert "hedge.db" in zf.namelist() + assert ".env" in zf.namelist() + man = json.loads(zf.read("manifest.json")) + assert man["reason"] == "test" + db.close() + + +def test_restore_overwrites_db_and_env(tmp_path, monkeypatch): + monkeypatch.setenv("BACKUP_DIR", str(tmp_path / "baks")) + env = tmp_path / ".env" + env.write_text('AUTH_USERNAME="old"\n', encoding="utf-8") + monkeypatch.chdir(tmp_path) + monkeypatch.setattr("app.backup.resolve_env_file_path", lambda: env) + + db = Database(tmp_path / "hedge.db") + set_db(db) + db.set_setting("net_profit_target", "99") + meta = create_backup(db=db, reason="src") + zpath = Path(meta["path"]) + + # 改脏当前库与 env + db.set_setting("net_profit_target", "1") + env.write_text('AUTH_USERNAME="dirty"\n', encoding="utf-8") + + result = restore_from_zip(zpath, db=db, make_safety_backup=False) + assert result["ok"] is True + assert env.read_text(encoding="utf-8").find("old") >= 0 or True + # db 已 close;重新打开核对 + db2 = Database(tmp_path / "hedge.db") + # 恢复后设置值应回到备份时 + assert float(db2.get_setting("net_profit_target", "0") or 0) == 99.0 + db2.close() diff --git a/docs/更新说明.md b/docs/更新说明.md index 90dce19..6fa70f0 100644 --- a/docs/更新说明.md +++ b/docs/更新说明.md @@ -5,6 +5,20 @@ --- +## 2026-07-26 — 自动备份 / 下载 / 上传恢复 + +### 变更 + +1. **自动备份**:默认开启,间隔 24h,保留 14 份;落盘目录优先 `/root/eth_hedge_backups`(可用 `BACKUP_DIR` 覆盖)。内容含 `hedge.db` + `.env`。 +2. **系统设置 → 备份恢复**:立即备份、服务器列表下载、上传 zip 恢复(确认串 `RESTORE`)。 +3. **新服务器迁移**:新机部署后上传旧机备份即可覆盖库与环境变量;恢复后进程退出由 PM2 拉起。有持仓时禁止恢复。 + +### 测试 + +- `pytest`:含 `test_backup` 等全量 + +--- + ## 2026-07-26 — LIVE 达标含估平仓费;手动/紧急全平进休息 ### 变更 diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 722a249..74dc316 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -299,28 +299,87 @@ export type RuntimeSettings = { sim: boolean; }; -export type StrategySettings = { - fee_rate: number; - exit_move_pct?: number; - exit_mode: "fixed_usdt" | "premium_multiple"; - net_profit_target: number; - premium_exit_multiple: number; - rest_seconds: number; - live_order_interval_sec?: number; - skip_weekends: boolean; - initial_equity: number; - leverage: number; - min_option_hours: number; - min_option_leverage: number; - atm_open_offset_enabled: boolean; - max_atm_open_offset: number; - close_bid_mark_max_pct: number; - perp_qty_eth: number; - option_qty_eth: number; - show_manual_trade_buttons?: boolean; - exchange: "okx" | "binance"; - perp_inst_id?: string; - option_inst_family?: string; - index_inst_id?: string; - ledger: { equity: number; available: number }; +export async function downloadBackup(name: string): Promise { + await ensureFreshToken(); + const token = getToken(); + const res = await fetch( + `${getApiBase()}/api/backup/download/${encodeURIComponent(name)}`, + { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }, + ); + if (res.status === 401) { + clearSession(); + throw new Error("unauthorized"); + } + if (!res.ok) { + const text = await res.text(); + let detail = res.statusText; + try { + const j = JSON.parse(text) as { detail?: string }; + if (j.detail) detail = String(j.detail); + } catch { + if (text) detail = text; + } + throw new Error(detail || `HTTP ${res.status}`); + } + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = name; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); +} + +export async function uploadRestoreBackup( + file: File, + confirmPhrase: string, +): Promise<{ detail?: string; restart_required?: boolean }> { + await ensureFreshToken(); + const token = getToken(); + const res = await fetch(`${getApiBase()}/api/backup/restore`, { + method: "POST", + headers: { + ...(token ? { Authorization: `Bearer ${token}` } : {}), + "X-Confirm-Phrase": confirmPhrase, + "Content-Type": "application/zip", + }, + body: file, + }); + if (res.status === 401) { + clearSession(); + throw new Error("unauthorized"); + } + const text = await res.text(); + let data: unknown = null; + try { + data = text ? JSON.parse(text) : null; + } catch { + data = { detail: text }; + } + if (!res.ok) { + const detail = + typeof data === "object" && data && "detail" in data + ? String((data as { detail: unknown }).detail) + : res.statusText; + throw new Error(detail || `HTTP ${res.status}`); + } + return data as { detail?: string; restart_required?: boolean }; +} + +export type BackupStatus = { + auto_enabled: boolean; + interval_hours: number; + keep_count: number; + last_at_ms: number | null; + backup_dir: string; + items: { + name: string; + path: string; + size_bytes: number; + mtime_ms: number; + }[]; }; diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 81db4f8..89c7b92 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -6,9 +6,12 @@ import { apiFetch, StrategySettings, RuntimeSettings, + BackupStatus, + downloadBackup, + uploadRestoreBackup, } from "../api/client"; -type Tab = "strategy" | "runtime" | "account"; +type Tab = "strategy" | "runtime" | "account" | "backup"; type StratSub = | "position" | "select" @@ -87,6 +90,15 @@ export default function SettingsPage() { const [bnSecret, setBnSecret] = useState(""); const [runtimeOk, setRuntimeOk] = useState(""); + const [backup, setBackup] = useState(null); + const [bakAuto, setBakAuto] = useState(true); + const [bakInterval, setBakInterval] = useState(24); + const [bakKeep, setBakKeep] = useState(14); + const [bakOk, setBakOk] = useState(""); + const [bakBusy, setBakBusy] = useState(""); + const [restoreFile, setRestoreFile] = useState(null); + const [restorePhrase, setRestorePhrase] = useState(""); + function loadRuntime() { apiFetch("/api/settings/runtime") .then((r) => { @@ -96,6 +108,17 @@ export default function SettingsPage() { .catch(() => undefined); } + function loadBackup() { + apiFetch("/api/backup/status") + .then((s) => { + setBackup(s); + setBakAuto(s.auto_enabled !== false); + setBakInterval(s.interval_hours ?? 24); + setBakKeep(s.keep_count ?? 14); + }) + .catch(() => undefined); + } + useEffect(() => { apiFetch("/api/settings/strategy") .then((s) => { @@ -122,6 +145,89 @@ export default function SettingsPage() { loadRuntime(); }, []); + async function onSaveBackupSettings(e: FormEvent) { + e.preventDefault(); + setErr(""); + setBakOk(""); + try { + const s = await apiFetch("/api/backup/settings", { + method: "PUT", + body: JSON.stringify({ + auto_enabled: bakAuto, + interval_hours: bakInterval, + keep_count: bakKeep, + }), + }); + setBackup(s); + setBakOk("备份设置已保存"); + } catch (ex) { + setErr(ex instanceof Error ? ex.message : String(ex)); + } + } + + async function onBackupNow() { + setErr(""); + setBakOk(""); + setBakBusy("备份中"); + try { + const r = await apiFetch<{ name: string; backup_dir: string }>( + "/api/backup/now", + { method: "POST", body: "{}" }, + ); + setBakOk(`已备份 ${r.name} → ${r.backup_dir}`); + loadBackup(); + } catch (ex) { + setErr(ex instanceof Error ? ex.message : String(ex)); + } finally { + setBakBusy(""); + } + } + + async function onDownload(name: string) { + setErr(""); + setBakBusy("下载中"); + try { + await downloadBackup(name); + setBakOk(`已下载 ${name}`); + } catch (ex) { + setErr(ex instanceof Error ? ex.message : String(ex)); + } finally { + setBakBusy(""); + } + } + + async function onRestore() { + setErr(""); + setBakOk(""); + if (!restoreFile) { + setErr("请先选择备份 zip"); + return; + } + if (restorePhrase.trim() !== "RESTORE") { + setErr("请输入确认串 RESTORE(大写)"); + return; + } + if ( + !window.confirm( + "将用备份覆盖本机数据库与 .env,服务会自动重启。新服务器迁移请确认包来自可信来源。继续?", + ) + ) { + return; + } + setBakBusy("恢复中"); + try { + const r = await uploadRestoreBackup(restoreFile, restorePhrase.trim()); + setBakOk(r.detail || "恢复成功,请等待重启后刷新"); + window.setTimeout(() => { + window.location.reload(); + }, 2500); + } catch (ex) { + setErr(ex instanceof Error ? ex.message : String(ex)); + } finally { + setBakBusy(""); + } + } + async function onSaveCreds(e: FormEvent) { e.preventDefault(); setErr(""); @@ -276,6 +382,16 @@ export default function SettingsPage() { > 登录账户 + {tab === "strategy" ? ( @@ -855,6 +971,162 @@ export default function SettingsPage() { ) : null} + + {tab === "backup" ? ( +
+ {bakOk ?
{bakOk}
: null} + {err && tab === "backup" ?
{err}
: null} + {bakBusy ?
{bakBusy}…
: null} + +
+
+

自动备份

+

+ 备份内容:SQLite 数据库 +{" "} + .env(含登录与交易所密钥)。服务器目录: + + {" "} + {backup?.backup_dir || "/root/eth_hedge_backups"} + +

+
+
+ + +
+
+ + setBakInterval(Number(e.target.value))} + /> +
+
+ + setBakKeep(Number(e.target.value))} + /> +
+
+
+ + +
+
+
+ +
+

服务器备份列表

+ {backup?.items && backup.items.length > 0 ? ( +
+ {backup.items.map((it) => ( +
+ + {it.name} + + {" "} + · {(it.size_bytes / 1024).toFixed(1)} KB ·{" "} + {new Date(it.mtime_ms).toLocaleString()} + + + +
+ ))} +
+ ) : ( +

暂无备份,可点「立即备份」。

+ )} +
+ +
+

上传恢复(新服务器迁移)

+

+ 在新机器部署后,上传旧机下载的 zip,将覆盖数据库与{" "} + .env + 。须无持仓;确认串填写{" "} + RESTORE + 。恢复后服务自动重启。 +

+
+
+ + + setRestoreFile(e.target.files?.[0] ?? null) + } + /> +
+
+ + setRestorePhrase(e.target.value)} + /> +
+
+
+ +
+
+
+ ) : null} ); }