173 lines
6.0 KiB
Python
173 lines
6.0 KiB
Python
"""vps-manager 后端入口
|
||
|
||
- 注册资产 CRUD 与统计路由
|
||
- 挂载本地静态资源(Vue/Tailwind/前端逻辑)
|
||
- 渲染前端 SPA 入口页面
|
||
- 启用 CORS(便于外部程序/其他 AI 跨域调用写入接口)
|
||
"""
|
||
|
||
from contextlib import asynccontextmanager
|
||
from pathlib import Path
|
||
import asyncio
|
||
import logging
|
||
import subprocess
|
||
|
||
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 accounts, 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
|
||
时存下的条目会被视为新鲜而不再回源验证)。
|
||
启动时计算一次并缓存:静态资源只在代码更新时变化,而更新后服务会重启,
|
||
避免每次页面请求都遍历 stat 整个 static 目录。
|
||
"""
|
||
global _ASSET_VERSION_CACHE
|
||
if _ASSET_VERSION_CACHE is None:
|
||
latest = 0
|
||
for p in STATIC_DIR.rglob("*"):
|
||
if p.is_file():
|
||
latest = max(latest, int(p.stat().st_mtime))
|
||
_ASSET_VERSION_CACHE = str(latest)
|
||
return _ASSET_VERSION_CACHE
|
||
|
||
|
||
_ASSET_VERSION_CACHE: str | None = None
|
||
|
||
|
||
def _read_app_version() -> str:
|
||
"""语义版本号:读取项目根目录 VERSION 文件(缺失时回退 dev)"""
|
||
try:
|
||
return (BASE_DIR / "VERSION").read_text(encoding="utf-8").strip() or "dev"
|
||
except OSError:
|
||
return "dev"
|
||
|
||
|
||
def _read_git_commit() -> str:
|
||
"""当前 git 短哈希:用于比对本地与线上代码是否一致(非 git 环境回退 unknown)"""
|
||
try:
|
||
out = subprocess.run(
|
||
["git", "rev-parse", "--short", "HEAD"],
|
||
cwd=BASE_DIR, capture_output=True, text=True, timeout=5,
|
||
)
|
||
return out.stdout.strip() or "unknown"
|
||
except Exception:
|
||
return "unknown"
|
||
|
||
|
||
APP_VERSION = _read_app_version()
|
||
GIT_COMMIT = _read_git_commit()
|
||
|
||
# 全局日志配置:统一格式,便于生产环境排查
|
||
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=f"{APP_VERSION} ({GIT_COMMIT})", lifespan=lifespan)
|
||
|
||
# 启动即打印版本号,部署后可通过 journalctl 快速确认线上运行版本
|
||
logging.getLogger(__name__).info("vps-manager 启动:版本 %s,commit %s", APP_VERSION, GIT_COMMIT)
|
||
|
||
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(accounts.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 入口
|
||
|
||
no-cache:HTML 必须每次回源校验,否则浏览器启发式缓存会持有旧 HTML,
|
||
其中引用的 ?v= 静态资源版本号也是旧的,导致部署后用户看到旧版。
|
||
"""
|
||
html = jinja_env.get_template("index.html").render(
|
||
app_name=settings.APP_NAME, asset_version=_asset_version(),
|
||
app_version=APP_VERSION, git_commit=GIT_COMMIT,
|
||
)
|
||
return HTMLResponse(html, headers={"Cache-Control": "no-cache"})
|
||
|
||
|
||
@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/commit 动态读取,便于验证自动更新与比对本地版本)"""
|
||
return {
|
||
"status": "ok",
|
||
"app": "vps-manager",
|
||
"version": APP_VERSION,
|
||
"commit": GIT_COMMIT,
|
||
}
|