Files
eth_hedge_sim/backend/app/api/backup_routes.py
T

164 lines
5.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""备份下载 / 上传恢复 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
from starlette.background import BackgroundTask
from ..backup import materialize_download_zip
# 下载包剥离 .env,机内完整备份仍保留供恢复
safe = materialize_download_zip(path)
return FileResponse(
safe,
media_type="application/zip",
filename=path.name.replace(".zip", "_noenv.zip"),
background=BackgroundTask(lambda: safe.unlink(missing_ok=True)),
)
@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