Files
report-admin/backend/app/main.py
T
lofyerandfactory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> 71db82393a Initial commit
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-07-13 15:38:41 +08:00

254 lines
8.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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",
}