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 fastapi import APIRouter
|
||||||
|
|
||||||
from .auth_routes import router as auth_router
|
from .auth_routes import router as auth_router
|
||||||
|
from .backup_routes import router as backup_router
|
||||||
from .market import router as market_router
|
from .market import router as market_router
|
||||||
from .plan import router as plan_router
|
from .plan import router as plan_router
|
||||||
from .settings import router as settings_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(trades_router)
|
||||||
router.include_router(stats_router)
|
router.include_router(stats_router)
|
||||||
router.include_router(settings_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"]
|
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:
|
def _ensure_loaded() -> None:
|
||||||
global _username, _password
|
global _username, _password
|
||||||
if _username is not None and _password is not None:
|
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")
|
logger.exception("LIVE startup reconcile log failed")
|
||||||
engine.ensure_loop()
|
engine.ensure_loop()
|
||||||
|
|
||||||
|
from .backup import start_auto_backup_task
|
||||||
|
|
||||||
|
start_auto_backup_task()
|
||||||
|
|
||||||
session = bootstrap_session(settings)
|
session = bootstrap_session(settings)
|
||||||
try:
|
try:
|
||||||
await session.start()
|
await session.start()
|
||||||
@@ -79,6 +83,9 @@ async def lifespan(app: FastAPI):
|
|||||||
await engine._task
|
await engine._task
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
from .backup import stop_auto_backup_task
|
||||||
|
|
||||||
|
await stop_auto_backup_task()
|
||||||
await session.stop()
|
await session.stop()
|
||||||
from .exchange import set_exchange
|
from .exchange import set_exchange
|
||||||
from .strategy.session import set_session
|
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 达标含估平仓费;手动/紧急全平进休息
|
## 2026-07-26 — LIVE 达标含估平仓费;手动/紧急全平进休息
|
||||||
|
|
||||||
### 变更
|
### 变更
|
||||||
|
|||||||
+83
-24
@@ -299,28 +299,87 @@ export type RuntimeSettings = {
|
|||||||
sim: boolean;
|
sim: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type StrategySettings = {
|
export async function downloadBackup(name: string): Promise<void> {
|
||||||
fee_rate: number;
|
await ensureFreshToken();
|
||||||
exit_move_pct?: number;
|
const token = getToken();
|
||||||
exit_mode: "fixed_usdt" | "premium_multiple";
|
const res = await fetch(
|
||||||
net_profit_target: number;
|
`${getApiBase()}/api/backup/download/${encodeURIComponent(name)}`,
|
||||||
premium_exit_multiple: number;
|
{
|
||||||
rest_seconds: number;
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
live_order_interval_sec?: number;
|
},
|
||||||
skip_weekends: boolean;
|
);
|
||||||
initial_equity: number;
|
if (res.status === 401) {
|
||||||
leverage: number;
|
clearSession();
|
||||||
min_option_hours: number;
|
throw new Error("unauthorized");
|
||||||
min_option_leverage: number;
|
}
|
||||||
atm_open_offset_enabled: boolean;
|
if (!res.ok) {
|
||||||
max_atm_open_offset: number;
|
const text = await res.text();
|
||||||
close_bid_mark_max_pct: number;
|
let detail = res.statusText;
|
||||||
perp_qty_eth: number;
|
try {
|
||||||
option_qty_eth: number;
|
const j = JSON.parse(text) as { detail?: string };
|
||||||
show_manual_trade_buttons?: boolean;
|
if (j.detail) detail = String(j.detail);
|
||||||
exchange: "okx" | "binance";
|
} catch {
|
||||||
perp_inst_id?: string;
|
if (text) detail = text;
|
||||||
option_inst_family?: string;
|
}
|
||||||
index_inst_id?: string;
|
throw new Error(detail || `HTTP ${res.status}`);
|
||||||
ledger: { equity: number; available: number };
|
}
|
||||||
|
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,
|
apiFetch,
|
||||||
StrategySettings,
|
StrategySettings,
|
||||||
RuntimeSettings,
|
RuntimeSettings,
|
||||||
|
BackupStatus,
|
||||||
|
downloadBackup,
|
||||||
|
uploadRestoreBackup,
|
||||||
} from "../api/client";
|
} from "../api/client";
|
||||||
|
|
||||||
type Tab = "strategy" | "runtime" | "account";
|
type Tab = "strategy" | "runtime" | "account" | "backup";
|
||||||
type StratSub =
|
type StratSub =
|
||||||
| "position"
|
| "position"
|
||||||
| "select"
|
| "select"
|
||||||
@@ -87,6 +90,15 @@ export default function SettingsPage() {
|
|||||||
const [bnSecret, setBnSecret] = useState("");
|
const [bnSecret, setBnSecret] = useState("");
|
||||||
const [runtimeOk, setRuntimeOk] = 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() {
|
function loadRuntime() {
|
||||||
apiFetch<RuntimeSettings>("/api/settings/runtime")
|
apiFetch<RuntimeSettings>("/api/settings/runtime")
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
@@ -96,6 +108,17 @@ export default function SettingsPage() {
|
|||||||
.catch(() => undefined);
|
.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(() => {
|
useEffect(() => {
|
||||||
apiFetch<StrategySettings>("/api/settings/strategy")
|
apiFetch<StrategySettings>("/api/settings/strategy")
|
||||||
.then((s) => {
|
.then((s) => {
|
||||||
@@ -122,6 +145,89 @@ export default function SettingsPage() {
|
|||||||
loadRuntime();
|
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) {
|
async function onSaveCreds(e: FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setErr("");
|
setErr("");
|
||||||
@@ -276,6 +382,16 @@ export default function SettingsPage() {
|
|||||||
>
|
>
|
||||||
登录账户
|
登录账户
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={tab === "backup" ? "tab active" : "tab"}
|
||||||
|
onClick={() => {
|
||||||
|
setTab("backup");
|
||||||
|
loadBackup();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
备份恢复
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tab === "strategy" ? (
|
{tab === "strategy" ? (
|
||||||
@@ -855,6 +971,162 @@ export default function SettingsPage() {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user