"""备份下载 / 上传恢复 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