mirror of
https://github.com/handsomezhuzhu/QQuiz.git
synced 2026-04-18 22:42:53 +00:00
refactor: remove legacy frontend code and implement new Next.js structure
- Deleted the old Register page and utility functions. - Removed Tailwind CSS configuration and Vite configuration files. - Added a new script for starting a single container with FastAPI and Next.js. - Updated README to reflect the current status of the Next.js frontend. - Implemented new login and registration API routes with improved error handling. - Refactored frontend API calls to use the new proxy structure. - Enhanced error handling in API response processing. - Updated components to align with the new API endpoints and structure.
This commit is contained in:
210
backend/main.py
210
backend/main.py
@@ -1,16 +1,16 @@
|
||||
"""
|
||||
QQuiz FastAPI Application - 单容器模式(前后端整合)
|
||||
QQuiz FastAPI Application - single-container API and frontend proxy.
|
||||
"""
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from contextlib import asynccontextmanager
|
||||
import os
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
import httpx
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
from slowapi.middleware import SlowAPIMiddleware
|
||||
from starlette.background import BackgroundTask
|
||||
|
||||
from database import init_db, init_default_config, get_db_context
|
||||
from rate_limit import limiter
|
||||
@@ -18,6 +18,22 @@ from rate_limit import limiter
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
NEXT_SERVER_URL = os.getenv("NEXT_SERVER_URL", "http://127.0.0.1:3000").rstrip("/")
|
||||
INTERNAL_API_URL = os.getenv("INTERNAL_API_URL", "http://127.0.0.1:8000").rstrip("/")
|
||||
SESSION_COOKIE_NAME = "access_token"
|
||||
FRONTEND_PROXY_METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
|
||||
HOP_BY_HOP_HEADERS = {
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"te",
|
||||
"trailers",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
"content-length",
|
||||
}
|
||||
|
||||
|
||||
async def rate_limit_exceeded_handler(request: Request, exc: RateLimitExceeded):
|
||||
return JSONResponse(
|
||||
@@ -32,6 +48,11 @@ async def lifespan(app: FastAPI):
|
||||
# Startup
|
||||
print("🚀 Starting QQuiz Application...")
|
||||
|
||||
app.state.frontend_client = httpx.AsyncClient(
|
||||
follow_redirects=False,
|
||||
timeout=httpx.Timeout(30.0, connect=5.0),
|
||||
)
|
||||
|
||||
# Initialize database
|
||||
await init_db()
|
||||
|
||||
@@ -49,6 +70,7 @@ async def lifespan(app: FastAPI):
|
||||
yield
|
||||
|
||||
# Shutdown
|
||||
await app.state.frontend_client.aclose()
|
||||
print("👋 Shutting down QQuiz Application...")
|
||||
|
||||
|
||||
@@ -89,44 +111,152 @@ app.include_router(admin.router, prefix="/api/admin", tags=["Admin"])
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""Health check endpoint"""
|
||||
return {"status": "healthy"}
|
||||
|
||||
try:
|
||||
response = await app.state.frontend_client.get(
|
||||
f"{NEXT_SERVER_URL}/login",
|
||||
headers={"Accept-Encoding": "identity"},
|
||||
)
|
||||
except httpx.HTTPError:
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={
|
||||
"status": "degraded",
|
||||
"api": "healthy",
|
||||
"frontend": "unavailable",
|
||||
},
|
||||
)
|
||||
|
||||
frontend_status = "healthy" if response.status_code < 500 else "unavailable"
|
||||
if frontend_status != "healthy":
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={
|
||||
"status": "degraded",
|
||||
"api": "healthy",
|
||||
"frontend": frontend_status,
|
||||
},
|
||||
)
|
||||
|
||||
return {"status": "healthy", "api": "healthy", "frontend": "healthy"}
|
||||
|
||||
|
||||
# ============ 静态文件服务(前后端整合) ============
|
||||
def build_frontend_target(request: Request, full_path: str) -> str:
|
||||
normalized_path = f"/{full_path}" if full_path else "/"
|
||||
query = request.url.query
|
||||
return f"{NEXT_SERVER_URL}{normalized_path}{f'?{query}' if query else ''}"
|
||||
|
||||
# 检查静态文件目录是否存在
|
||||
STATIC_DIR = Path(__file__).parent / "static"
|
||||
if STATIC_DIR.exists():
|
||||
# 挂载静态资源(JS、CSS、图片等)
|
||||
app.mount("/assets", StaticFiles(directory=str(STATIC_DIR / "assets")), name="static_assets")
|
||||
|
||||
# 前端应用的所有路由(SPA路由)
|
||||
@app.get("/{full_path:path}")
|
||||
async def serve_frontend(full_path: str):
|
||||
"""
|
||||
服务前端应用
|
||||
- API 路由已在上面定义,优先匹配
|
||||
- 其他所有路由返回 index.html(SPA 单页应用)
|
||||
"""
|
||||
index_file = STATIC_DIR / "index.html"
|
||||
if index_file.exists():
|
||||
return FileResponse(index_file)
|
||||
else:
|
||||
return {
|
||||
"message": "Frontend not built yet",
|
||||
"hint": "Run 'cd frontend && npm run build' to build the frontend"
|
||||
}
|
||||
else:
|
||||
print("⚠️ 静态文件目录不存在,前端功能不可用")
|
||||
print("提示:请先构建前端应用或使用开发模式")
|
||||
def build_internal_api_target(request: Request, full_path: str, trailing_slash: bool = False) -> str:
|
||||
normalized_path = full_path.strip("/")
|
||||
if trailing_slash and normalized_path:
|
||||
normalized_path = f"{normalized_path}/"
|
||||
query = request.url.query
|
||||
return f"{INTERNAL_API_URL}/api/{normalized_path}{f'?{query}' if query else ''}"
|
||||
|
||||
# 如果没有静态文件,显示 API 信息
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""Root endpoint"""
|
||||
return {
|
||||
"message": "Welcome to QQuiz API",
|
||||
"version": "1.0.0",
|
||||
"docs": "/docs",
|
||||
"note": "Frontend not built. Please build frontend or use docker-compose."
|
||||
}
|
||||
|
||||
def filter_proxy_headers(request: Request) -> dict[str, str]:
|
||||
headers = {
|
||||
key: value
|
||||
for key, value in request.headers.items()
|
||||
if key.lower() not in HOP_BY_HOP_HEADERS and key.lower() != "host"
|
||||
}
|
||||
# Avoid sending compressed payloads through the proxy so response headers stay accurate.
|
||||
headers["Accept-Encoding"] = "identity"
|
||||
return headers
|
||||
|
||||
|
||||
def apply_proxy_headers(proxy_response: StreamingResponse, upstream_headers: httpx.Headers) -> None:
|
||||
proxy_response.raw_headers = [
|
||||
(key.encode("latin-1"), value.encode("latin-1"))
|
||||
for key, value in upstream_headers.multi_items()
|
||||
if key.lower() not in HOP_BY_HOP_HEADERS
|
||||
]
|
||||
|
||||
|
||||
@app.api_route("/frontend-api/proxy/{full_path:path}", methods=FRONTEND_PROXY_METHODS, include_in_schema=False)
|
||||
async def proxy_browser_api(request: Request, full_path: str):
|
||||
"""
|
||||
Serve browser-originated API calls directly from FastAPI in single-container mode.
|
||||
This avoids relying on Next.js route handlers for the /frontend-api/proxy/* namespace.
|
||||
"""
|
||||
target = build_internal_api_target(request, full_path)
|
||||
body = await request.body()
|
||||
client: httpx.AsyncClient = app.state.frontend_client
|
||||
headers = filter_proxy_headers(request)
|
||||
token = request.cookies.get(SESSION_COOKIE_NAME)
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
|
||||
try:
|
||||
async def send_request(target_url: str) -> httpx.Response:
|
||||
upstream_request = client.build_request(
|
||||
method=request.method,
|
||||
url=target_url,
|
||||
headers=headers,
|
||||
content=body if body else None,
|
||||
)
|
||||
return await client.send(
|
||||
upstream_request,
|
||||
stream=True,
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
upstream_response = await send_request(target)
|
||||
if (
|
||||
request.method in {"GET", "HEAD"}
|
||||
and upstream_response.status_code == 404
|
||||
and full_path
|
||||
and not full_path.endswith("/")
|
||||
):
|
||||
await upstream_response.aclose()
|
||||
upstream_response = await send_request(
|
||||
build_internal_api_target(request, full_path, trailing_slash=True)
|
||||
)
|
||||
except httpx.HTTPError:
|
||||
return JSONResponse(
|
||||
status_code=502,
|
||||
content={"detail": "Backend API is unavailable."},
|
||||
)
|
||||
|
||||
proxy_response = StreamingResponse(
|
||||
upstream_response.aiter_raw(),
|
||||
status_code=upstream_response.status_code,
|
||||
background=BackgroundTask(upstream_response.aclose),
|
||||
)
|
||||
apply_proxy_headers(proxy_response, upstream_response.headers)
|
||||
return proxy_response
|
||||
|
||||
|
||||
@app.api_route("/", methods=FRONTEND_PROXY_METHODS, include_in_schema=False)
|
||||
@app.api_route("/{full_path:path}", methods=FRONTEND_PROXY_METHODS, include_in_schema=False)
|
||||
async def proxy_frontend(request: Request, full_path: str = ""):
|
||||
"""
|
||||
Forward all non-API traffic to the embedded Next.js server.
|
||||
FastAPI keeps ownership of /api/*, /docs, /openapi.json, /redoc and /health.
|
||||
"""
|
||||
target = build_frontend_target(request, full_path)
|
||||
body = await request.body()
|
||||
client: httpx.AsyncClient = app.state.frontend_client
|
||||
|
||||
try:
|
||||
upstream_request = client.build_request(
|
||||
method=request.method,
|
||||
url=target,
|
||||
headers=filter_proxy_headers(request),
|
||||
content=body if body else None,
|
||||
)
|
||||
upstream_response = await client.send(upstream_request, stream=True)
|
||||
except httpx.HTTPError:
|
||||
return JSONResponse(
|
||||
status_code=502,
|
||||
content={"detail": "Frontend server is unavailable."},
|
||||
)
|
||||
|
||||
proxy_response = StreamingResponse(
|
||||
upstream_response.aiter_raw(),
|
||||
status_code=upstream_response.status_code,
|
||||
background=BackgroundTask(upstream_response.aclose),
|
||||
)
|
||||
apply_proxy_headers(proxy_response, upstream_response.headers)
|
||||
return proxy_response
|
||||
|
||||
Reference in New Issue
Block a user