- core/: database/redis_client/jwt/password/response/exceptions/security/logger/deps/middleware/app - create_app 迁移到 core/app.py,中间件注册顺序与异常处理器原样保留 - extensions/ utils/ apps/ 旧文件全部改为 re-export 垫片,import 路径保持兼容 - 新增 core 单元测试(jwt/password/response/request 共 12 个) Co-Authored-By: Claude <noreply@anthropic.com>
286 lines
9.3 KiB
Python
286 lines
9.3 KiB
Python
# +======================================================================
|
||
# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ]
|
||
# +----------------------------------------------------------------------
|
||
# | Copyright (c) 2021-2026 一米阳光团队 版权所有
|
||
# | 开源协议: Apache-2.0
|
||
# +======================================================================
|
||
# 由 apps/middleware/ 合并迁移而来。
|
||
|
||
"""核心中间件与依赖:认证、鉴权、演示拦截、数据库会话、操作日志、CORS。"""
|
||
|
||
import asyncio
|
||
import json
|
||
import logging
|
||
import time
|
||
from functools import wraps
|
||
|
||
from fastapi import FastAPI
|
||
from starlette.middleware.cors import CORSMiddleware
|
||
from starlette.requests import Request
|
||
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
|
||
|
||
import config
|
||
from config.app import CORS_ALLOWED_ORIGINS
|
||
from core import response as R
|
||
from core.database import SessionLocal, _db_ctx
|
||
from core.exceptions import AuthorizationException
|
||
from core.jwt import get_access_token, is_token_blacklisted, parse_payload
|
||
from core.security import get_user_id, get_username
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
# ============================================================
|
||
# 中间件注册
|
||
# ============================================================
|
||
|
||
def register_middleware(app: FastAPI):
|
||
"""
|
||
请求响应拦截 hook(当前为空实现,可自定义业务逻辑)。
|
||
"""
|
||
|
||
@app.middleware("http")
|
||
async def logger_request(request: Request, call_next):
|
||
response = await call_next(request)
|
||
return response
|
||
|
||
|
||
def register_cros(app: FastAPI):
|
||
"""注册 CORS 与 ProxyHeaders 中间件。"""
|
||
origins_str = CORS_ALLOWED_ORIGINS.strip()
|
||
if origins_str:
|
||
origins = [o.strip() for o in origins_str.split(',') if o.strip()]
|
||
allow_credentials = True
|
||
allow_headers = [
|
||
"Authorization", "Content-Type", "Accept",
|
||
"X-Requested-With", "X-Process-Time",
|
||
]
|
||
else:
|
||
origins = ["*"]
|
||
allow_credentials = False
|
||
allow_headers = ["*"]
|
||
app.add_middleware(ProxyHeadersMiddleware)
|
||
app.add_middleware(
|
||
CORSMiddleware,
|
||
allow_origins=origins,
|
||
allow_credentials=allow_credentials,
|
||
allow_methods=['OPTIONS', 'GET', 'POST', 'DELETE', 'PUT'],
|
||
allow_headers=allow_headers,
|
||
max_age=3600
|
||
)
|
||
|
||
|
||
# ============================================================
|
||
# 登录认证(router 级依赖,挂载在 v1 上)
|
||
# ============================================================
|
||
|
||
async def login_required(request: Request):
|
||
"""登录验证依赖:提取并校验 Token,将用户信息写入 request.state。"""
|
||
success, access_token, msg = get_access_token(request)
|
||
if not success:
|
||
raise AuthorizationException(code=401, msg=msg)
|
||
|
||
if await is_token_blacklisted(access_token):
|
||
raise AuthorizationException(code=401, msg="登录已注销")
|
||
|
||
result = parse_payload(access_token)
|
||
if result['code'] != 0:
|
||
raise AuthorizationException(code=401, msg=result['msg'])
|
||
|
||
data = result['data']
|
||
request.state.user_id = int(data.get('userId', 0))
|
||
request.state.username = data.get('username', '')
|
||
request.state.realname = data.get('realname', '')
|
||
|
||
|
||
# ============================================================
|
||
# 节点权限鉴权(装饰器)
|
||
# ============================================================
|
||
|
||
def _extract_request(*args, **kwargs):
|
||
"""从函数参数中提取 Request 对象"""
|
||
req = kwargs.get('request')
|
||
if req is not None:
|
||
return req
|
||
for arg in args:
|
||
if isinstance(arg, Request):
|
||
return arg
|
||
return None
|
||
|
||
|
||
def permission_required(permission: str):
|
||
"""节点权限鉴权装饰器,userId==1(管理员)直接放行。"""
|
||
def decorator(func):
|
||
def _check(request):
|
||
"""同步权限校验(直查DB,无Redis依赖),返回 R.failed 或 None(通过)"""
|
||
userId = get_user_id(request)
|
||
if userId == 1:
|
||
return None
|
||
# 懒加载业务 service,避免 core → apps 顶层依赖(迁移完成后指向 modules)
|
||
from apps.services import menu
|
||
permission_list = menu.get_permissions_list_sync(userId)
|
||
if permission not in permission_list:
|
||
return R.failed("权限不足")
|
||
return None
|
||
|
||
if asyncio.iscoroutinefunction(func):
|
||
@wraps(func)
|
||
async def async_wrapper(*args, **kwargs):
|
||
request = _extract_request(*args, **kwargs)
|
||
denied = await asyncio.to_thread(_check, request)
|
||
if denied:
|
||
return denied
|
||
return await func(*args, **kwargs)
|
||
return async_wrapper
|
||
|
||
@wraps(func)
|
||
def sync_wrapper(*args, **kwargs):
|
||
request = _extract_request(*args, **kwargs)
|
||
denied = _check(request)
|
||
if denied:
|
||
return denied
|
||
return func(*args, **kwargs)
|
||
return sync_wrapper
|
||
|
||
return decorator
|
||
|
||
|
||
# ============================================================
|
||
# 演示模式拦截(装饰器)
|
||
# ============================================================
|
||
|
||
def check_demo(func):
|
||
"""演示模式拦截装饰器,放在 @permission_required 之后、路由函数之前。"""
|
||
if asyncio.iscoroutinefunction(func):
|
||
@wraps(func)
|
||
async def async_wrapper(*args, **kwargs):
|
||
if config.FASTAPI_DEMO:
|
||
return R.failed("演示环境,暂无操作权限")
|
||
return await func(*args, **kwargs)
|
||
return async_wrapper
|
||
|
||
@wraps(func)
|
||
def sync_wrapper(*args, **kwargs):
|
||
if config.FASTAPI_DEMO:
|
||
return R.failed("演示环境,暂无操作权限")
|
||
return func(*args, **kwargs)
|
||
return sync_wrapper
|
||
|
||
|
||
# ============================================================
|
||
# 数据库会话中间件
|
||
# ============================================================
|
||
|
||
def _is_business_success(response) -> bool:
|
||
"""检查响应体中业务状态码是否为成功(code == 0)。"""
|
||
try:
|
||
body = getattr(response, 'body', None)
|
||
if body:
|
||
data = json.loads(body)
|
||
return data.get('code') == 0
|
||
except Exception:
|
||
pass
|
||
return 200 <= response.status_code < 300
|
||
|
||
|
||
async def db_session_middleware(request: Request, call_next):
|
||
"""请求级 DB 会话:写操作按业务成功与否 commit/rollback,finally 关闭。"""
|
||
db = SessionLocal()
|
||
_db_ctx.set(db)
|
||
try:
|
||
response = await call_next(request)
|
||
if request.method in ('POST', 'PUT', 'DELETE', 'PATCH'):
|
||
if _is_business_success(response):
|
||
db.commit()
|
||
else:
|
||
db.rollback()
|
||
return response
|
||
except Exception:
|
||
db.rollback()
|
||
raise
|
||
finally:
|
||
db.close()
|
||
|
||
|
||
# ============================================================
|
||
# 操作日志中间件
|
||
# ============================================================
|
||
|
||
# 不记录操作日志的路径前缀
|
||
_EXCLUDE_PATHS = (
|
||
'/login',
|
||
'/captcha',
|
||
'/upload',
|
||
'/loginlog',
|
||
'/operationlog',
|
||
'/index/menu',
|
||
'/index/user',
|
||
'/index/logout',
|
||
)
|
||
|
||
|
||
def _should_log(request: Request) -> bool:
|
||
"""判断当前请求是否需要记录操作日志"""
|
||
if request.method not in ('POST', 'PUT', 'DELETE', 'PATCH'):
|
||
return False
|
||
path = request.url.path
|
||
for prefix in _EXCLUDE_PATHS:
|
||
if prefix in path:
|
||
return False
|
||
return True
|
||
|
||
|
||
def _get_route_summary(request: Request) -> str:
|
||
"""从路由获取操作标题(summary)"""
|
||
try:
|
||
route = request.scope.get('route')
|
||
if route and hasattr(route, 'summary') and route.summary:
|
||
return route.summary
|
||
except Exception:
|
||
pass
|
||
return request.method + ' ' + request.url.path
|
||
|
||
|
||
async def operation_log_middleware(request: Request, call_next):
|
||
"""操作日志中间件:自动拦截写请求并记录操作日志。"""
|
||
if not _should_log(request):
|
||
return await call_next(request)
|
||
|
||
start_time = time.time()
|
||
status = 0
|
||
error_msg = ''
|
||
|
||
try:
|
||
response = await call_next(request)
|
||
try:
|
||
body = getattr(response, 'body', None)
|
||
if body:
|
||
data = json.loads(body)
|
||
if data.get('code', 0) != 0:
|
||
status = 1
|
||
error_msg = data.get('msg', '')
|
||
except Exception:
|
||
pass
|
||
return response
|
||
except Exception as e:
|
||
status = 1
|
||
error_msg = str(e)
|
||
raise
|
||
finally:
|
||
try:
|
||
consume_time = int((time.time() - start_time) * 1000)
|
||
user_id = get_user_id(request)
|
||
if user_id and user_id > 0:
|
||
# 懒加载业务 service,避免 core → apps 顶层依赖(迁移完成后指向 modules)
|
||
from apps.services import operation_log
|
||
operation_log.record_operation_log(
|
||
request,
|
||
title=_get_route_summary(request),
|
||
method=get_username(request),
|
||
consume_time=consume_time,
|
||
status=status,
|
||
error=error_msg,
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"操作日志中间件异常: {str(e)}")
|