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:
2026-04-17 21:15:06 +08:00
parent cab8b3b483
commit 9a1a9d3247
60 changed files with 819 additions and 7988 deletions

View File

@@ -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.htmlSPA 单页应用)
"""
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