feat: 系统设置增加备份恢复与默认登录 admin

支持手动/每日自动备份四所数据库、K线库与 env,上传 zip 一键恢复;中控默认账号 admin/admin123。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-02 16:39:46 +08:00
parent 55261b7812
commit bfa3352122
16 changed files with 1052 additions and 22 deletions
+125 -2
View File
@@ -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(),
}