Files
FastapiAdmin/backend/app/core/middlewares.py
T
zhangtao 0b38612cb2 refactor: 完成多维度代码优化与测试基建搭建
- 重构API路由与请求路径,统一批量状态更新接口
- 优化类型导入与参数继承逻辑,简化基础查询参数实现
- 修复日志格式化与权限校验细节问题
- 新增多模块接口测试用例,完善测试覆盖
- 调整环境配置与初始化脚本细节
- 简化服务层方法命名,统一代码风格
2026-06-21 20:25:39 +08:00

231 lines
8.7 KiB
Python

import json
import time
import uuid
from dataclasses import replace
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.middleware.cors import CORSMiddleware
from starlette.middleware.gzip import GZipMiddleware
from starlette.requests import Request
from starlette.responses import Response
from starlette.types import ASGIApp
from app.api.v1.module_system.params.service import ParamsService
from app.common.response import ErrorResponse
from app.config.setting import settings
from app.core.exceptions import CustomException
from app.core.logger import logger
from app.core.request_context import RequestContext, clear_current_tenant, reset_correlation_id, set_correlation_id, set_current_tenant
from app.core.security import decode_access_token
def _strip_bearer(authorization: str) -> str | None:
"""从 Authorization header 提取 token,非 Bearer 返回 None。"""
v = authorization.strip()
if v.lower().startswith("bearer "):
v = v[7:].strip()
elif v.lower().startswith("bearer"):
v = v[6:].strip()
else:
return None
return v or None
class CustomCORSMiddleware(CORSMiddleware):
def __init__(self, app: ASGIApp) -> None:
super().__init__(
app,
allow_origins=settings.ALLOW_ORIGINS,
allow_methods=settings.ALLOW_METHODS,
allow_headers=settings.ALLOW_HEADERS,
allow_credentials=settings.ALLOW_CREDENTIALS,
expose_headers=settings.CORS_EXPOSE_HEADERS,
)
class RequestLogMiddleware(BaseHTTPMiddleware):
"""请求日志 & 演示模式拦截"""
def __init__(self, app: ASGIApp) -> None:
super().__init__(app)
@staticmethod
def _hydrate_session_id(request: Request) -> None:
"""从 request.state.ctx 或 JWT 中提取 session_id 并写入 ctx(纯 side-effect)。
JWT sub 现为纯 session_id 字符串,无需 JSON 解析。
"""
ctx = getattr(request.state, "ctx", None)
if ctx:
if ctx.session_id:
return
if ctx.jwt_user_info:
sid = ctx.jwt_user_info.get("session_id")
if sid:
request.state.ctx = replace(ctx, session_id=sid)
return
token = _strip_bearer(request.headers.get("Authorization", ""))
if not token:
return
try:
payload = decode_access_token(token)
if not payload or not hasattr(payload, "sub"):
return
sid = payload.sub
if sid:
base = ctx or RequestContext()
request.state.ctx = replace(base, session_id=sid)
except Exception:
pass
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
start_time = time.time()
self._hydrate_session_id(request)
logger.info("请求: {} {} | client={}", request.method, request.url.path,
request.client.host if request.client else "unknown")
try:
path = request.scope.get("path")
request_ip = (
(x_forwarded_for.split(",")[0].strip())
if (x_forwarded_for := request.headers.get("X-Forwarded-For"))
else request.client.host if request.client else None
)
try:
redis = request.app.state.redis
config = await ParamsService.get_system_config_for_middleware(redis)
demo_enable = config["demo_enable"]
ip_white_list = config["ip_white_list"]
white_api_list_path = config["white_api_list_path"]
ip_black_list = config["ip_black_list"]
except Exception:
demo_enable = False
ip_white_list, white_api_list_path, ip_black_list = [], [], []
should_block = (request_ip and request_ip in ip_black_list) or (
demo_enable and request.method != "GET"
and request_ip not in ip_white_list
and path not in white_api_list_path
)
if should_block:
logger.warning("演示模式拦截: {} {} | ip={}", request.method, path, request_ip)
return ErrorResponse(msg="演示环境,禁止操作")
response = await call_next(request)
process_time = round(time.time() - start_time, 5)
response.headers["X-Process-Time"] = str(process_time)
logger.info("响应: {} | {:.1f}ms", response.status_code, process_time * 1000)
return response
except CustomException as e:
logger.exception(f"中间件异常: {e!s}")
return ErrorResponse(msg="系统异常,请联系管理员", data=str(e))
class CustomGZipMiddleware(GZipMiddleware):
def __init__(self, app: ASGIApp) -> None:
super().__init__(app, minimum_size=settings.GZIP_MIN_SIZE, compresslevel=settings.GZIP_COMPRESS_LEVEL)
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
def __init__(self, app: ASGIApp) -> None:
super().__init__(app)
self._hsts = settings.HSTS_ENABLE
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
response = await call_next(request)
if self._hsts:
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains; preload"
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=(), payment=(), usb=(), magnetometer=(), gyroscope=()"
return response
class CorrelationIdMiddleware(BaseHTTPMiddleware):
def __init__(self, app: ASGIApp) -> None:
self._header = "X-Correlation-ID"
super().__init__(app)
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
cid = request.headers.get(self._header) or str(uuid.uuid4())
token = set_correlation_id(cid)
try:
response = await call_next(request)
response.headers[self._header] = cid
return response
finally:
reset_correlation_id(token)
_TENANT_WHITELIST_PREFIXES = ("/docs", "/redoc", "/ljdoc", "/openapi.json", "/metrics", "/static")
_TENANT_WHITELIST_PATHS = (
"/api/v1/system/auth/login", "/api/v1/system/auth/captcha",
"/api/v1/system/auth/refresh", "/api/v1/health", "/api/v1/common/health",
)
_WHITELIST_ALL = _TENANT_WHITELIST_PATHS + tuple(settings.TENANT_WHITELIST_PATHS)
def _tenant_is_whitelisted(path: str) -> bool:
return any(path.startswith(p) for p in _WHITELIST_ALL) or \
any(path.startswith(p) for p in _TENANT_WHITELIST_PREFIXES)
async def _extract_tenant_from_token(request: Request) -> int | None:
token = _strip_bearer(request.headers.get("Authorization", ""))
if not token:
return None
try:
payload = decode_access_token(token)
if not payload or not hasattr(payload, "sub"):
return None
session_id = payload.sub
user_info = None
# 从 Redis 读取完整会话信息(含 tenant_id)
redis = request.app.state.redis
raw = await await_redis_get(redis, session_id) if redis else None
if raw:
user_info = json.loads(raw)
base = getattr(request.state, "ctx", None) or RequestContext()
request.state.ctx = replace(base, jwt_payload=payload, jwt_user_info=user_info)
return user_info.get("tenant_id") if user_info else None
except Exception:
return None
async def await_redis_get(redis, key: str) -> str | None:
"""异步获取 Redis 键值(封装为可复用工具函数)。"""
from app.common.enums import RedisInitKeyConfig
from app.core.redis_crud import RedisCURD
try:
return await RedisCURD(redis).get(
f"{RedisInitKeyConfig.USER_SESSION.key}:{key}"
)
except Exception:
return None
class TenantMiddleware(BaseHTTPMiddleware):
def __init__(self, app: ASGIApp) -> None:
super().__init__(app)
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
path = request.url.path
if request.method == "OPTIONS" or _tenant_is_whitelisted(path):
return await call_next(request)
try:
tenant_id = await _extract_tenant_from_token(request)
set_current_tenant(tenant_id)
except Exception:
logger.exception("租户中间件异常: path={}", path)
try:
return await call_next(request)
finally:
clear_current_tenant()