Files
FastapiAdmin/backend/app/core/middlewares.py
T
zhangtao 644d262cad refactor(backend): 错误码收敛为单一事实来源,统一异常写法
错误码(t16)
- HTTP 语义只由 core/exceptions.py 的 RET.code → fastapi.status 映射决定;
  映射表显式登记 -1→400 与全部「数值即 HTTP 状态」的 RET 成员,_DEFAULT_BUSINESS_STATUS
  降为「新增码忘登记」的安全网
- 移除冗余 status_code:131 处调用点改为纯 code 表达;security.py/dependencies.py
  的 status_code=401/403 去除(映射表已给);get_or_404 → code=RET.NOT_FOUND.code
- 新增静态护栏(AST):app/ 下除 exceptions.py 外不得出现 status_code 传参,
  路由装饰器 201 白名单显式放行并断言白名单非空

异常写法统一(t19)
- 删除 CustomException.internal 及 132 处包装:意外异常直接冒泡,由全局
  @app.exception_handler(Exception) 统一映射 5xx + 通用文案 + 日志;
  需要运维上下文处改为 logger.exception("<业务动作>失败") + raise
- 构造器不再接受 status_code 参数(属性保留,由 code 派生)→ 结构上杜绝第二来源
- 修复 6 处「宽泛 except 把 400 包成 500」的语义反转(补 except CustomException: raise),
  并加 AST 护栏防止回潮
- 全局 SQLAlchemyError:非完整性错误由 400 改为 503(连接类)/500(其它),
  不再把驱动原文/异常类型拼进客户端 msg;顺带修掉不存在的
  status.HTTP_403_SERVICE_UNAVAILABLE 常量(命中即 AttributeError→500)

验证:pytest 97 passed / ruff 全绿;AST 审计残留 0;护栏红绿测试通过;
线上实测验证码过期仍 400 并保留业务文案、无令牌 401。
2026-10-04 05:44:21 +08:00

153 lines
6.2 KiB
Python

import json
import uuid
from typing import Any
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.middleware.cors import CORSMiddleware
from starlette.middleware.gzip import GZipMiddleware
from starlette.middleware.trustedhost import TrustedHostMiddleware
from starlette.requests import Request
from starlette.responses import RedirectResponse, Response
from starlette.types import ASGIApp
from app.common.enums import RedisInitKeyConfig, SysParamKey
from app.common.response import ErrorResponse
from app.config.setting import settings
from app.core.exceptions import CustomException
from app.core.logger import logger, reset_correlation_id, set_correlation_id
from app.core.redis_crud import RedisCURD
from app.utils.ip_local_util import get_client_ip
class CustomCORSMiddleware(CORSMiddleware):
"""CORS 中间件"""
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):
"""演示模式 & IP黑名单拦截"""
def __init__(self, app: ASGIApp) -> None:
super().__init__(app)
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
client_ip = get_client_ip(request)
try:
path = request.url.path
config = await self._load_config(request)
is_blacklisted = bool(client_ip and client_ip in config[SysParamKey.IP_BLACK_LIST])
in_demo = (
config.get(SysParamKey.DEMO_ENABLE, False)
and request.method != "GET"
and (client_ip is None or client_ip not in config.get(SysParamKey.IP_WHITE_LIST, ()))
and not any(
path.startswith(item.rstrip("*")) if item.endswith("*") else path == item
for item in settings.WHITE_API_LIST_PATH
if isinstance(item, str) and item
)
)
if is_blacklisted or in_demo:
logger.warning(
"请求被拦截: {} {} | ip={} | 原因={}",
request.method,
path,
client_ip,
"IP黑名单" if is_blacklisted else "演示模式",
)
return ErrorResponse(msg="IP已被黑名单" if is_blacklisted else "演示环境,禁止操作")
return await call_next(request)
except CustomException as e:
logger.exception(f"中间件异常: {e!s}")
return ErrorResponse(msg="系统异常,请联系管理员", data=str(e))
@staticmethod
async def _load_config(request: Request) -> dict:
"""加载中间件配置,失败时返回全部默认值。"""
redis = getattr(request.app.state, "redis", None)
if not redis:
return {SysParamKey.DEMO_ENABLE: False, SysParamKey.IP_WHITE_LIST: (), SysParamKey.IP_BLACK_LIST: ()}
try:
config_keys = [
f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:{SysParamKey.DEMO_ENABLE.value}",
f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:{SysParamKey.IP_WHITE_LIST.value}",
f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:{SysParamKey.IP_BLACK_LIST.value}",
]
config_values = await RedisCURD(redis).mget(config_keys)
result: dict[str, Any] = {SysParamKey.DEMO_ENABLE: False, SysParamKey.IP_WHITE_LIST: (), SysParamKey.IP_BLACK_LIST: ()}
raw_demo, raw_white, raw_black = config_values
for raw, key in ((raw_demo, SysParamKey.DEMO_ENABLE), (raw_white, SysParamKey.IP_WHITE_LIST), (raw_black, SysParamKey.IP_BLACK_LIST)):
if not raw:
continue
try:
payload = json.loads(raw)
except json.JSONDecodeError:
logger.error("解析系统配置 %s 失败", key)
continue
if not isinstance(payload, dict) or payload.get("status", 0) != 0:
continue
cv = payload.get("config_value")
if cv is None:
continue
if key == SysParamKey.DEMO_ENABLE:
result[key.value] = cv in (True, "true", "1", "yes", "on")
else:
result[key.value] = json.loads(cv) if isinstance(cv, str) else cv
return result
except Exception:
return {SysParamKey.DEMO_ENABLE: False, SysParamKey.IP_WHITE_LIST: (), SysParamKey.IP_BLACK_LIST: ()}
class CustomGZipMiddleware(GZipMiddleware):
"""GZip 压缩中间件"""
def __init__(self, app: ASGIApp) -> None:
super().__init__(app, minimum_size=settings.GZIP_MIN_SIZE, compresslevel=settings.GZIP_COMPRESS_LEVEL)
class CustomHTTPSRedirectMiddleware(BaseHTTPMiddleware):
"""HTTP → HTTPS 重定向中间件(信任前端代理的 X-Forwarded-Proto 头)"""
async def dispatch(self, request: Request, call_next):
if request.url.scheme != "https" and request.headers.get("X-Forwarded-Proto") != "https":
url = request.url.replace(scheme="https")
return RedirectResponse(url, 301)
return await call_next(request)
class CustomTrustedHostMiddleware(TrustedHostMiddleware):
"""可信主机 Host 头校验中间件"""
def __init__(self, app: ASGIApp) -> None:
super().__init__(app, allowed_hosts=settings.ALLOWED_HOSTS)
class CorrelationIdMiddleware(BaseHTTPMiddleware):
"""请求 ID 中间件"""
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)