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 <cursoragent@cursor.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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 达标含估平仓费;手动/紧急全平进休息
|
||||
|
||||
### 变更
|
||||
|
||||
+83
-24
@@ -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<void> {
|
||||
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;
|
||||
}[];
|
||||
};
|
||||
|
||||
@@ -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<BackupStatus | null>(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<File | null>(null);
|
||||
const [restorePhrase, setRestorePhrase] = useState("");
|
||||
|
||||
function loadRuntime() {
|
||||
apiFetch<RuntimeSettings>("/api/settings/runtime")
|
||||
.then((r) => {
|
||||
@@ -96,6 +108,17 @@ export default function SettingsPage() {
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
function loadBackup() {
|
||||
apiFetch<BackupStatus>("/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<StrategySettings>("/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<BackupStatus>("/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() {
|
||||
>
|
||||
登录账户
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={tab === "backup" ? "tab active" : "tab"}
|
||||
onClick={() => {
|
||||
setTab("backup");
|
||||
loadBackup();
|
||||
}}
|
||||
>
|
||||
备份恢复
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{tab === "strategy" ? (
|
||||
@@ -855,6 +971,162 @@ export default function SettingsPage() {
|
||||
</form>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{tab === "backup" ? (
|
||||
<div className="card settings-card">
|
||||
{bakOk ? <div className="settings-ok">{bakOk}</div> : null}
|
||||
{err && tab === "backup" ? <div className="err">{err}</div> : null}
|
||||
{bakBusy ? <div className="meta">{bakBusy}…</div> : null}
|
||||
|
||||
<form onSubmit={onSaveBackupSettings}>
|
||||
<section className="settings-section">
|
||||
<h3>自动备份</h3>
|
||||
<p style={{ color: "var(--muted)", marginTop: 0 }}>
|
||||
备份内容:SQLite 数据库 +{" "}
|
||||
<span className="mono">.env</span>(含登录与交易所密钥)。服务器目录:
|
||||
<span className="mono">
|
||||
{" "}
|
||||
{backup?.backup_dir || "/root/eth_hedge_backups"}
|
||||
</span>
|
||||
</p>
|
||||
<div className="settings-fields">
|
||||
<div className="field">
|
||||
<label htmlFor="bakAuto">自动备份</label>
|
||||
<select
|
||||
id="bakAuto"
|
||||
className="mono"
|
||||
value={bakAuto ? "1" : "0"}
|
||||
onChange={(e) => setBakAuto(e.target.value === "1")}
|
||||
>
|
||||
<option value="1">开启(默认)</option>
|
||||
<option value="0">关闭</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="bakInt">间隔(小时)</label>
|
||||
<input
|
||||
id="bakInt"
|
||||
className="mono"
|
||||
type="number"
|
||||
min={1}
|
||||
max={168}
|
||||
value={bakInterval}
|
||||
onChange={(e) => setBakInterval(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="bakKeep">保留份数</label>
|
||||
<input
|
||||
id="bakKeep"
|
||||
className="mono"
|
||||
type="number"
|
||||
min={1}
|
||||
max={90}
|
||||
value={bakKeep}
|
||||
onChange={(e) => setBakKeep(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-actions" style={{ gap: 8 }}>
|
||||
<button className="btn" type="submit">
|
||||
保存备份设置
|
||||
</button>
|
||||
<button
|
||||
className="btn ghost"
|
||||
type="button"
|
||||
disabled={!!bakBusy}
|
||||
onClick={() => void onBackupNow()}
|
||||
>
|
||||
立即备份
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</form>
|
||||
|
||||
<section className="settings-section">
|
||||
<h3>服务器备份列表</h3>
|
||||
{backup?.items && backup.items.length > 0 ? (
|
||||
<div className="settings-fields">
|
||||
{backup.items.map((it) => (
|
||||
<div
|
||||
key={it.name}
|
||||
className="field"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: 8,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<span className="mono" style={{ flex: "1 1 220px" }}>
|
||||
{it.name}
|
||||
<span style={{ color: "var(--muted)" }}>
|
||||
{" "}
|
||||
· {(it.size_bytes / 1024).toFixed(1)} KB ·{" "}
|
||||
{new Date(it.mtime_ms).toLocaleString()}
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
className="btn ghost"
|
||||
type="button"
|
||||
disabled={!!bakBusy}
|
||||
onClick={() => void onDownload(it.name)}
|
||||
>
|
||||
下载
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p style={{ color: "var(--muted)" }}>暂无备份,可点「立即备份」。</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="settings-section">
|
||||
<h3>上传恢复(新服务器迁移)</h3>
|
||||
<p style={{ color: "var(--muted)", marginTop: 0 }}>
|
||||
在新机器部署后,上传旧机下载的 zip,将覆盖数据库与{" "}
|
||||
<span className="mono">.env</span>
|
||||
。须无持仓;确认串填写{" "}
|
||||
<span className="mono">RESTORE</span>
|
||||
。恢复后服务自动重启。
|
||||
</p>
|
||||
<div className="settings-fields">
|
||||
<div className="field">
|
||||
<label htmlFor="bakFile">备份 zip</label>
|
||||
<input
|
||||
id="bakFile"
|
||||
type="file"
|
||||
accept=".zip,application/zip"
|
||||
onChange={(e) =>
|
||||
setRestoreFile(e.target.files?.[0] ?? null)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="bakPhrase">确认串</label>
|
||||
<input
|
||||
id="bakPhrase"
|
||||
className="mono"
|
||||
value={restorePhrase}
|
||||
placeholder="RESTORE"
|
||||
onChange={(e) => setRestorePhrase(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-actions">
|
||||
<button
|
||||
className="btn danger"
|
||||
type="button"
|
||||
disabled={!!bakBusy}
|
||||
onClick={() => void onRestore()}
|
||||
>
|
||||
上传并恢复
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user