feat: 系统设置增加备份恢复与默认登录 admin
支持手动/每日自动备份四所数据库、K线库与 env,上传 zip 一键恢复;中控默认账号 admin/admin123。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+125
-2
@@ -86,7 +86,7 @@ from env_load import load_hub_dotenv
|
||||
load_hub_dotenv()
|
||||
|
||||
import httpx
|
||||
from fastapi import Body, FastAPI, HTTPException, Request
|
||||
from fastapi import Body, FastAPI, File, Form, HTTPException, Request, UploadFile
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -99,6 +99,15 @@ from settings_store import (
|
||||
normalize_supervisor_settings,
|
||||
save_settings,
|
||||
)
|
||||
from lib.hub.hub_backup_lib import (
|
||||
backup_status,
|
||||
normalize_backup_settings,
|
||||
resolve_backup_download,
|
||||
restore_backup_archive,
|
||||
restore_backup_upload,
|
||||
run_backup,
|
||||
should_run_auto_backup,
|
||||
)
|
||||
from hub_web_auth import (
|
||||
SESSION_COOKIE,
|
||||
SESSION_MAX_AGE_SEC,
|
||||
@@ -157,6 +166,8 @@ _last_archive_sync: dict | None = None
|
||||
_volume_rank_stop: asyncio.Event | None = None
|
||||
_volume_rank_task: asyncio.Task | None = None
|
||||
_volume_rank_cache: dict | None = None
|
||||
_backup_stop: asyncio.Event | None = None
|
||||
_backup_task: asyncio.Task | None = None
|
||||
HUB_AGENT_TIMEOUT = float(os.getenv("HUB_AGENT_TIMEOUT", "8"))
|
||||
HUB_FLASK_TIMEOUT = float(os.getenv("HUB_FLASK_TIMEOUT", "10"))
|
||||
HUB_BOARD_TIMEOUT = float(os.getenv("HUB_BOARD_TIMEOUT", "45"))
|
||||
@@ -518,9 +529,28 @@ async def _run_supervisor_tick() -> dict:
|
||||
)
|
||||
|
||||
|
||||
async def _backup_scheduler_loop() -> None:
|
||||
global _backup_stop
|
||||
stop = _backup_stop
|
||||
if stop is None:
|
||||
return
|
||||
while not stop.is_set():
|
||||
try:
|
||||
settings = load_settings()
|
||||
if should_run_auto_backup(settings):
|
||||
await asyncio.to_thread(run_backup, trigger="auto", settings=settings)
|
||||
except Exception as e:
|
||||
print(f"[backup] auto backup failed: {e}", flush=True)
|
||||
try:
|
||||
await asyncio.wait_for(stop.wait(), timeout=60.0)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _hub_lifespan(_app: FastAPI):
|
||||
global _archive_sync_stop, _archive_sync_task, _volume_rank_stop, _volume_rank_task
|
||||
global _backup_stop, _backup_task
|
||||
set_supervisor_notify_hook(supervisor_store.bump)
|
||||
await board_store.start(_run_board_aggregate)
|
||||
await dashboard_store.start(_run_dashboard_aggregate)
|
||||
@@ -530,9 +560,21 @@ async def _hub_lifespan(_app: FastAPI):
|
||||
_archive_sync_task = asyncio.create_task(_archive_sync_loop(), name="hub-archive-sync")
|
||||
_volume_rank_stop = asyncio.Event()
|
||||
_volume_rank_task = asyncio.create_task(_volume_rank_loop(), name="hub-volume-rank")
|
||||
_backup_stop = asyncio.Event()
|
||||
_backup_task = asyncio.create_task(_backup_scheduler_loop(), name="hub-backup-scheduler")
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if _backup_stop:
|
||||
_backup_stop.set()
|
||||
if _backup_task:
|
||||
_backup_task.cancel()
|
||||
try:
|
||||
await _backup_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
_backup_task = None
|
||||
_backup_stop = None
|
||||
if _archive_sync_stop:
|
||||
_archive_sync_stop.set()
|
||||
if _archive_sync_task:
|
||||
@@ -879,10 +921,20 @@ class SupervisorSettingsBody(BaseModel):
|
||||
reopen_after_close_minutes: int = 30
|
||||
|
||||
|
||||
class BackupSettingsBody(BaseModel):
|
||||
auto_enabled: bool = True
|
||||
auto_hour: int = Field(default=0, ge=0, le=23)
|
||||
retention_days: int = Field(default=30, ge=1, le=365)
|
||||
include_env: bool = True
|
||||
include_exchange_images: bool = False
|
||||
backup_root: str = ""
|
||||
|
||||
|
||||
class SettingsBody(BaseModel):
|
||||
exchanges: list[dict] = Field(default_factory=list)
|
||||
display: SettingsDisplayBody | None = None
|
||||
supervisor: SupervisorSettingsBody | None = None
|
||||
backup: BackupSettingsBody | None = None
|
||||
|
||||
|
||||
@app.post("/api/settings")
|
||||
@@ -903,7 +955,18 @@ def api_save_settings(body: SettingsBody):
|
||||
supervisor = normalize_supervisor_settings(existing.get("supervisor"))
|
||||
if body.supervisor is not None:
|
||||
supervisor = normalize_supervisor_settings(body.supervisor.model_dump())
|
||||
save_settings({"version": 1, "exchanges": to_save, "display": display, "supervisor": supervisor})
|
||||
backup = normalize_backup_settings(existing.get("backup"))
|
||||
if body.backup is not None:
|
||||
backup = normalize_backup_settings(body.backup.model_dump())
|
||||
save_settings(
|
||||
{
|
||||
"version": 1,
|
||||
"exchanges": to_save,
|
||||
"display": display,
|
||||
"supervisor": supervisor,
|
||||
"backup": backup,
|
||||
}
|
||||
)
|
||||
return {"ok": True, "settings": load_settings()}
|
||||
|
||||
|
||||
@@ -1290,6 +1353,65 @@ async def api_chart_poll_meta():
|
||||
return chart_poll_store.event_dict()
|
||||
|
||||
|
||||
@app.get("/api/backup/status")
|
||||
def api_backup_status():
|
||||
return backup_status(load_settings())
|
||||
|
||||
|
||||
@app.post("/api/backup/run")
|
||||
async def api_backup_run():
|
||||
result = await asyncio.to_thread(run_backup, trigger="manual", settings=load_settings())
|
||||
if not result.get("ok"):
|
||||
raise HTTPException(status_code=500, detail=result.get("error") or "backup failed")
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/api/backup/download/{name}")
|
||||
def api_backup_download(name: str):
|
||||
fp = resolve_backup_download(load_settings(), name)
|
||||
if not fp:
|
||||
raise HTTPException(status_code=404, detail="backup not found")
|
||||
return FileResponse(
|
||||
str(fp),
|
||||
media_type="application/zip",
|
||||
filename=fp.name,
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/backup/restore")
|
||||
async def api_backup_restore(
|
||||
file: UploadFile = File(...),
|
||||
confirm: str = Form(""),
|
||||
):
|
||||
if (confirm or "").strip().upper() != "RESTORE":
|
||||
raise HTTPException(status_code=400, detail='请在 confirm 字段填写 RESTORE 以确认恢复')
|
||||
content = await file.read()
|
||||
result = await asyncio.to_thread(
|
||||
restore_backup_upload,
|
||||
content,
|
||||
file.filename or "backup.zip",
|
||||
settings=load_settings(),
|
||||
)
|
||||
if not result.get("ok"):
|
||||
raise HTTPException(status_code=500, detail=result.get("error") or "restore failed")
|
||||
return result
|
||||
|
||||
|
||||
@app.post("/api/backup/restore-local")
|
||||
async def api_backup_restore_local(body: dict = Body(...)):
|
||||
confirm = str(body.get("confirm") or "").strip().upper()
|
||||
name = str(body.get("name") or "").strip()
|
||||
if confirm != "RESTORE":
|
||||
raise HTTPException(status_code=400, detail='请在 confirm 字段填写 RESTORE 以确认恢复')
|
||||
fp = resolve_backup_download(load_settings(), name)
|
||||
if not fp:
|
||||
raise HTTPException(status_code=404, detail="backup not found")
|
||||
result = await asyncio.to_thread(restore_backup_archive, fp, settings=load_settings())
|
||||
if not result.get("ok"):
|
||||
raise HTTPException(status_code=500, detail=result.get("error") or "restore failed")
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/api/settings/meta")
|
||||
def api_settings_meta():
|
||||
po = public_origin()
|
||||
@@ -1304,6 +1426,7 @@ def api_settings_meta():
|
||||
else "复盘/展示链接已替换为对外地址"
|
||||
),
|
||||
"password_required": password_required(),
|
||||
"default_username": expected_username(),
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user