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
|
||||
Reference in New Issue
Block a user