126 lines
4.2 KiB
Python
126 lines
4.2 KiB
Python
"""vps-manager 后端入口
|
|
|
|
- 注册资产 CRUD 与统计路由
|
|
- 挂载本地静态资源(Vue/Tailwind/前端逻辑)
|
|
- 渲染前端 SPA 入口页面
|
|
- 启用 CORS(便于外部程序/其他 AI 跨域调用写入接口)
|
|
"""
|
|
|
|
from contextlib import asynccontextmanager
|
|
from pathlib import Path
|
|
import asyncio
|
|
import logging
|
|
|
|
from fastapi import FastAPI
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.responses import FileResponse, HTMLResponse
|
|
from starlette.staticfiles import StaticFiles as StarletteStaticFiles
|
|
from jinja2 import Environment, FileSystemLoader
|
|
from sqlmodel import Session
|
|
|
|
from app.core.config import settings
|
|
from app.core.seed import seed_providers
|
|
from app.database import assets_engine, init_db
|
|
from app.routers import agent, assets, monitor, notify, providers, ssl, stats, sync
|
|
from app.services import cleanup_service
|
|
|
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
|
STATIC_DIR = BASE_DIR / "static"
|
|
TEMPLATE_DIR = BASE_DIR / "app" / "templates"
|
|
STATIC_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
def _asset_version() -> str:
|
|
"""静态资源版本号:取 static 目录内最新文件 mtime,代码更新后自动变更。
|
|
|
|
用于前端引用 ?v= 参数,绕开浏览器启发式缓存(旧响应无 Cache-Control
|
|
时存下的条目会被视为新鲜而不再回源验证)。
|
|
"""
|
|
latest = 0
|
|
for p in STATIC_DIR.rglob("*"):
|
|
if p.is_file():
|
|
latest = max(latest, int(p.stat().st_mtime))
|
|
return str(latest)
|
|
|
|
# 全局日志配置:统一格式,便于生产环境排查
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
|
|
)
|
|
|
|
# 直接使用 Jinja2 Environment 渲染(规避 Starlette Jinja2Templates 在 Python 3.14 下的缓存兼容问题)
|
|
jinja_env = Environment(loader=FileSystemLoader(TEMPLATE_DIR), autoescape=True)
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(_: FastAPI):
|
|
"""应用启动时自动建表、初始化预设平台,并启动监控数据定期清理任务"""
|
|
init_db()
|
|
with Session(assets_engine) as session:
|
|
seed_providers(session)
|
|
cleanup_task = asyncio.create_task(cleanup_service.cleanup_loop())
|
|
yield
|
|
cleanup_task.cancel()
|
|
|
|
|
|
app = FastAPI(title=settings.APP_NAME, version="0.2.0", lifespan=lifespan)
|
|
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=[o.strip() for o in settings.CORS_ORIGINS.split(",") if o.strip()],
|
|
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
|
allow_headers=["Content-Type", "X-API-Key", "X-Agent-Key"],
|
|
allow_credentials=True,
|
|
)
|
|
|
|
app.include_router(assets.router)
|
|
app.include_router(providers.router)
|
|
app.include_router(stats.router)
|
|
app.include_router(agent.router)
|
|
app.include_router(monitor.router)
|
|
app.include_router(sync.router)
|
|
app.include_router(notify.router)
|
|
app.include_router(ssl.router)
|
|
|
|
# 静态资源:no-cache(每次回源校验 ETag)。避免启发式缓存导致旧版本残留,
|
|
# 与 SW 的 no-store 回源配合,保证代码更新后立即生效。
|
|
class NoCacheStaticFiles(StarletteStaticFiles):
|
|
def file_response(self, *args, **kwargs):
|
|
resp = super().file_response(*args, **kwargs)
|
|
resp.headers.setdefault("Cache-Control", "no-cache")
|
|
return resp
|
|
|
|
app.mount("/static", NoCacheStaticFiles(directory=STATIC_DIR), name="static")
|
|
|
|
|
|
@app.get("/", include_in_schema=False)
|
|
def index() -> HTMLResponse:
|
|
"""前端 SPA 入口"""
|
|
html = jinja_env.get_template("index.html").render(
|
|
app_name=settings.APP_NAME, asset_version=_asset_version()
|
|
)
|
|
return HTMLResponse(html)
|
|
|
|
|
|
@app.get("/sw.js", include_in_schema=False)
|
|
def service_worker() -> FileResponse:
|
|
"""Service Worker(置于根路径以使 scope 覆盖全站)
|
|
|
|
no-cache:保证浏览器每次导航都校验 SW 是否有更新,
|
|
否则浏览器可能长时间持有旧版 SW(默认更新检查间隔长)。
|
|
"""
|
|
return FileResponse(
|
|
STATIC_DIR / "sw.js",
|
|
media_type="application/javascript",
|
|
headers={
|
|
"Service-Worker-Allowed": "/",
|
|
"Cache-Control": "no-cache",
|
|
},
|
|
)
|
|
|
|
|
|
@app.get("/health")
|
|
def health_check() -> dict:
|
|
"""健康检查(version 动态读取,便于验证自动更新)"""
|
|
return {"status": "ok", "app": "vps-manager", "version": app.version}
|