Initial commit
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
commit
71db82393a
@@ -0,0 +1,253 @@
|
||||
"""
|
||||
FastAPI 主应用入口
|
||||
报告管理系统:API + 前端静态文件,单端口服务
|
||||
增加 JWT 认证保护
|
||||
"""
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, Request, Depends, Response
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
|
||||
from .config import OUTPUT_DIR, STATIC_DIR, PROJECT_ROOT
|
||||
from .api.era2_routes import router as era2_router
|
||||
from .api.era2_state import era2_state
|
||||
from .auth import (
|
||||
LoginRequest, TokenResponse,
|
||||
authenticate_user, create_access_token, get_current_user, verify_token,
|
||||
)
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s - %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 前端构建产物目录
|
||||
FRONTEND_DIST = PROJECT_ROOT / "frontend" / "dist"
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""应用启动/关闭生命周期"""
|
||||
logger.info("🚀 正在初始化全市数据引擎...")
|
||||
era2_state.initialize()
|
||||
logger.info(f"✅ 数据引擎就绪: {len(era2_state.schools)}所学校, {len(era2_state.districts)}个区")
|
||||
|
||||
if FRONTEND_DIST.exists():
|
||||
logger.info(f"📦 前端静态文件: {FRONTEND_DIST}")
|
||||
else:
|
||||
logger.warning(f"⚠️ 前端静态文件不存在: {FRONTEND_DIST},请先 cd frontend && npm run build")
|
||||
|
||||
logger.info("🔐 认证已启用,所有 API 需要登录访问")
|
||||
yield
|
||||
logger.info("👋 应用关闭")
|
||||
|
||||
|
||||
import os
|
||||
|
||||
# 生产环境关闭 API 文档(设 DOCS_ENABLED=1 可临时打开)
|
||||
_docs_enabled = os.environ.get("DOCS_ENABLED", "0") == "1"
|
||||
|
||||
app = FastAPI(
|
||||
title="课程实施监测报告管理系统",
|
||||
description="上海市高中课程实施监测数据分析与报告生成 API",
|
||||
version="2.0.0",
|
||||
lifespan=lifespan,
|
||||
docs_url="/docs" if _docs_enabled else None,
|
||||
redoc_url="/redoc" if _docs_enabled else None,
|
||||
openapi_url="/openapi.json" if _docs_enabled else None,
|
||||
)
|
||||
|
||||
# CORS — 收紧配置(部署时按需修改 allow_origins)
|
||||
ALLOWED_ORIGINS = [
|
||||
"http://localhost:5173", # 前端开发服务器
|
||||
"http://localhost:7777", # 后端自身
|
||||
"http://127.0.0.1:5173",
|
||||
"http://127.0.0.1:7777",
|
||||
]
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=ALLOWED_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
# ==================== 无需认证的路由 ====================
|
||||
|
||||
# 登录接口
|
||||
@app.post("/api/auth/login", response_model=TokenResponse)
|
||||
async def login(req: LoginRequest, response: Response):
|
||||
"""用户登录,返回 JWT token"""
|
||||
username = authenticate_user(req.username, req.password)
|
||||
if not username:
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={"detail": "用户名或密码错误"},
|
||||
)
|
||||
|
||||
token, expires_in = create_access_token(username)
|
||||
|
||||
# 同时设置 Cookie(方便浏览器直接访问静态资源)
|
||||
response.set_cookie(
|
||||
key="access_token",
|
||||
value=token,
|
||||
max_age=expires_in,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
# secure=True, # 生产环境使用 HTTPS 时取消注释
|
||||
)
|
||||
|
||||
logger.info(f"🔑 用户 '{username}' 登录成功")
|
||||
return TokenResponse(access_token=token, expires_in=expires_in)
|
||||
|
||||
|
||||
# 验证 token 是否有效
|
||||
@app.get("/api/auth/verify")
|
||||
async def verify_auth(request: Request):
|
||||
"""验证当前 token 是否有效(前端刷新页面时调用)"""
|
||||
# 从 header 或 cookie 中提取 token
|
||||
token = None
|
||||
auth_header = request.headers.get("authorization", "")
|
||||
if auth_header.startswith("Bearer "):
|
||||
token = auth_header[7:]
|
||||
if not token:
|
||||
token = request.cookies.get("access_token")
|
||||
if not token:
|
||||
return JSONResponse(status_code=401, content={"valid": False})
|
||||
|
||||
username = verify_token(token)
|
||||
if not username:
|
||||
return JSONResponse(status_code=401, content={"valid": False})
|
||||
|
||||
return {"valid": True, "username": username}
|
||||
|
||||
|
||||
# 登出
|
||||
@app.post("/api/auth/logout")
|
||||
async def logout(response: Response):
|
||||
"""清除认证 Cookie"""
|
||||
response.delete_cookie("access_token")
|
||||
return {"message": "已退出登录"}
|
||||
|
||||
|
||||
# ==================== 认证中间件 ====================
|
||||
|
||||
# 不需要认证的路径前缀(API 文档不对外暴露)
|
||||
PUBLIC_PATHS = {
|
||||
"/api/auth/login",
|
||||
"/api/auth/logout",
|
||||
"/api/auth/verify",
|
||||
}
|
||||
|
||||
# 前端静态资源路径前缀(不需要 API 级别认证,前端自己处理路由守卫)
|
||||
STATIC_PREFIXES = ("/assets/", "/favicon", "/vite.svg")
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def auth_middleware(request: Request, call_next):
|
||||
"""
|
||||
全局认证中间件:
|
||||
- 公开路径(登录、静态资源)直接放行
|
||||
- API 路径需要有效的 JWT token
|
||||
- 前端 SPA 页面路径放行(由前端路由守卫处理)
|
||||
"""
|
||||
path = request.url.path
|
||||
|
||||
# 1. 公开 API 路径 — 放行
|
||||
if path in PUBLIC_PATHS:
|
||||
return await call_next(request)
|
||||
|
||||
# 2. 前端静态资源 — 放行
|
||||
if any(path.startswith(p) for p in STATIC_PREFIXES):
|
||||
return await call_next(request)
|
||||
|
||||
# 3. API 路径 — 需要认证
|
||||
if path.startswith("/api/") or path.startswith("/output/"):
|
||||
token = None
|
||||
# 来源1: Authorization header
|
||||
auth_header = request.headers.get("authorization", "")
|
||||
if auth_header.startswith("Bearer "):
|
||||
token = auth_header[7:]
|
||||
# 来源2: Cookie
|
||||
if not token:
|
||||
token = request.cookies.get("access_token")
|
||||
# 来源3: URL query param(供 <a href> / <iframe src> 等无法设 header 的场景)
|
||||
if not token:
|
||||
token = request.query_params.get("token")
|
||||
|
||||
if not token or not verify_token(token):
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={"detail": "未授权访问,请先登录"},
|
||||
)
|
||||
|
||||
# 4. 其他路径(前端 SPA 页面)— 放行,由前端路由守卫处理
|
||||
response = await call_next(request)
|
||||
|
||||
# 安全响应头
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["X-Frame-Options"] = "SAMEORIGIN"
|
||||
response.headers["X-XSS-Protection"] = "1; mode=block"
|
||||
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||||
# 防止浏览器缓存敏感 API 响应
|
||||
if path.startswith("/api/"):
|
||||
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate"
|
||||
response.headers["Pragma"] = "no-cache"
|
||||
|
||||
return response
|
||||
|
||||
|
||||
# ==================== 业务路由(全部需要认证) ====================
|
||||
|
||||
# 注册 API 路由
|
||||
app.include_router(era2_router, prefix="/api")
|
||||
|
||||
# 挂载 output 静态文件(报告 HTML/JSON)— 已由中间件保护
|
||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
app.mount("/output", StaticFiles(directory=str(OUTPUT_DIR)), name="output")
|
||||
|
||||
# 挂载 backend/static(如有)
|
||||
if STATIC_DIR.exists():
|
||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
|
||||
# 挂载前端静态资源(JS/CSS 等)
|
||||
if FRONTEND_DIST.exists():
|
||||
assets_dir = FRONTEND_DIST / "assets"
|
||||
if assets_dir.exists():
|
||||
app.mount("/assets", StaticFiles(directory=str(assets_dir)), name="frontend-assets")
|
||||
|
||||
|
||||
# SPA Fallback:所有未匹配的路由返回前端 index.html
|
||||
@app.get("/{full_path:path}")
|
||||
async def serve_frontend(request: Request, full_path: str):
|
||||
"""
|
||||
SPA 路由兜底:
|
||||
- 如果请求的是 dist 目录下的真实文件(如 favicon.ico),直接返回
|
||||
- 否则返回 index.html,让前端路由处理
|
||||
"""
|
||||
if FRONTEND_DIST.exists():
|
||||
# 尝试匹配真实文件(防路径穿越: resolve 后必须在 FRONTEND_DIST 内)
|
||||
file_path = (FRONTEND_DIST / full_path).resolve()
|
||||
if file_path.is_file() and str(file_path).startswith(str(FRONTEND_DIST.resolve())):
|
||||
return FileResponse(str(file_path))
|
||||
|
||||
# SPA fallback → index.html
|
||||
index_path = FRONTEND_DIST / "index.html"
|
||||
if index_path.exists():
|
||||
return FileResponse(str(index_path))
|
||||
|
||||
# 前端未构建时返回 API 信息
|
||||
return {
|
||||
"name": "课程实施监测报告管理系统",
|
||||
"version": "2.0.0",
|
||||
"status": "running",
|
||||
"message": "前端未构建,请访问 /api/auth/login 登录后使用 API",
|
||||
}
|
||||
Reference in New Issue
Block a user