200702d066
Co-authored-by: Cursor <cursoragent@cursor.com>
164 lines
5.1 KiB
Python
164 lines
5.1 KiB
Python
"""备份下载 / 上传恢复 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
|