Files
DjangoAdmin_Flask_EleVue/src/core/middleware.py
T
DjangoAdmin DevandClaude 0b0fa0c047 refactor: P5 DI 注入 + 中间件迁入 src/core
【依赖注入(解决 core→modules 倒置)】
- src/core/permission.py:PermissionProvider 协议 + check_permission
  (不再 import menu 服务,改从 current_app.extensions['permission_provider'] 获取)
- src/core/middleware.py:整合 check_login/check_demo/transaction/op_log/error_handler
  (op_log 写入器 DI:current_app.extensions['operation_log_writer'])
- application/factory.py:组合根注入 MenuPermissionProvider + record_operation_log
- src/core/scheduler.py 懒加载指向 src.modules.job.scheduler_service

【验收】src/core 内零 application 引用;DI 注入验证通过;
未登录 401、白名单放行;12 passed;core+中间件 flake8 零告警

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 19:39:19 +08:00

814 lines
26 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.
"""
中间件模块
=============================================================
整合自 application/middleware/*.py:
- check_login: 登录校验(JWT + 白名单)
- check_demo: 演示模式拦截
- transaction: AOP 事务拦截器
- op_log: 操作日志自动记录(写入器由应用工厂注入)
- error_handler: 全局异常处理
依赖约束:本模块仅依赖 core/common,不依赖 modules 层。
"""
import logging
import fnmatch
from flask import g, request, current_app
from config import LOGIN_WHITELIST_EXACT, LOGIN_WHITELIST_DEBUG, LOGIN_WHITELIST_PREFIX, LOGIN_WHITELIST_WILDCARD
from src.core import response as R
from src.core.jwt import parse_payload, is_token_blacklisted, get_access_token
from functools import wraps
from config import FLASK_DEMO
from src.core.database import db
import time
import traceback
from werkzeug.exceptions import HTTPException
from src.core.exceptions import BizError
logger = logging.getLogger(__name__)
# ============================================================
# 登录校验中间件
# ============================================================
# 获取日志记录器
def is_whitelisted(path: str) -> bool:
"""
检查请求路径是否在白名单中
支持三种匹配模式:
1. 精确匹配:路径完全相等
2. 前缀匹配:路径以指定前缀开头
3. 通配符匹配:支持 *(匹配任意字符)和 ?(匹配单个字符)
Args:
path: 请求路径(如:/job/sendSms)
Returns:
bool: True-在白名单中,False-不在白名单中
"""
# ============================================================
# 1. 精确匹配检查
# ============================================================
# 示例:LOGIN_WHITELIST_EXACT=/login,/captcha
# 当 path 为 /login 或 /captcha 时返回 True
exact_urls = LOGIN_WHITELIST_EXACT
if exact_urls:
for url in exact_urls.split(","):
if url.strip() and url.strip() == path:
if LOGIN_WHITELIST_DEBUG:
logger.debug(f"精确匹配白名单: {path}")
return True
# ============================================================
# 2. 前缀匹配检查
# ============================================================
# 示例:LOGIN_WHITELIST_PREFIX=/job/sendSms,/public
# 当 path 以 /job/sendSms 或 /public 开头时返回 True
prefix_urls = LOGIN_WHITELIST_PREFIX
if prefix_urls:
for prefix in prefix_urls.split(","):
prefix = prefix.strip()
if prefix and path.startswith(prefix):
if LOGIN_WHITELIST_DEBUG:
logger.debug(f"前缀匹配白名单: {path} 匹配 {prefix}")
return True
# ============================================================
# 3. 通配符匹配检查
# ============================================================
# 示例:LOGIN_WHITELIST_WILDCARD=/job/*/send*,/api/v1/*/public*
# 当 path 匹配通配符模式时返回 True
# - * 匹配任意多个字符
# - ? 匹配单个字符
wildcard_patterns = LOGIN_WHITELIST_WILDCARD
if wildcard_patterns:
for pattern in wildcard_patterns.split(","):
pattern = pattern.strip()
if pattern and fnmatch.fnmatch(path, pattern):
if LOGIN_WHITELIST_DEBUG:
logger.debug(f"通配符匹配白名单: {path} 匹配 {pattern}")
return True
return False
def check_login():
"""
用户登录验证中间件
功能说明:
1. 检查请求路径是否在白名单中,白名单路径直接放行
2. OPTIONS请求(CORS预检)直接放行
3. 验证JWT Token的有效性
4. Token验证通过后将用户信息存储到g对象中
白名单配置(在.env文件中):
- LOGIN_WHITELIST_EXACT: 精确匹配白名单
- LOGIN_WHITELIST_PREFIX: 前缀匹配白名单
- LOGIN_WHITELIST_WILDCARD: 通配符匹配白名单
Returns:
None: 验证通过,继续处理请求
R.failed: 验证失败,返回错误响应(401未登录)
使用示例:
# 在app.py中注册中间件
@app.before_request
def before_request():
result = check_login()
if result:
return result
"""
current_path = request.path
# ============================================================
# 步骤1:检查白名单
# ============================================================
# 白名单中的路径不需要验证Token(如:登录页、验证码、短信发送接口等)
if is_whitelisted(current_path):
if LOGIN_WHITELIST_DEBUG:
logger.debug(f"白名单路径,跳过登录验证: {current_path}")
return None
# ============================================================
# 步骤2:OPTIONS请求放行
# ============================================================
# OPTIONS请求是CORS预检请求,不需要验证Token
if request.method == "OPTIONS":
if LOGIN_WHITELIST_DEBUG:
logger.debug(f"OPTIONS请求,跳过登录验证: {current_path}")
return None
# ============================================================
# 步骤3:获取并验证Token
# ============================================================
success, access_token, msg = get_access_token()
# 如果没有Token
if not success:
logger.warning(f"未登录访问: {current_path}, IP: {request.remote_addr}")
return R.failed(code=401, msg="请先登录")
# JWT解密验证
result = parse_payload(access_token)
# 验证失败处理
if result.get("code") != 0:
logger.warning(f"Token验证失败: {result.get('msg')}, 路径: {current_path}")
return R.failed(code=401, msg=result.get("msg", "Token无效,请重新登录"))
# ============================================================
# 步骤3.5:检查Token黑名单
# ============================================================
# 退出登录时Token会被加入黑名单,黑名单中的Token即使未过期也应拒绝
if is_token_blacklisted(access_token):
logger.warning(f"Token已被注销(黑名单): user_id={result.get('data', {}).get('userId')}, 路径: {current_path}")
return R.failed(code=401, msg="Token已失效,请重新登录")
# ============================================================
# 步骤4:存储用户信息
# ============================================================
# 将用户信息存储到g对象中,供后续的视图函数和权限验证使用
if result.get("data"):
g.user_id = result["data"].get("userId")
g.user_info = result["data"]
if LOGIN_WHITELIST_DEBUG:
logger.debug(f"用户登录验证通过: user_id={g.user_id}, 路径={current_path}")
# 验证通过,返回None继续处理请求
return None
# ============================================================
# 演示模式拦截
# ============================================================
"""
演示模式拦截装饰器
=============================================================
在演示环境(FLASK_DEMO=True)下拦截所有写操作,
返回"演示环境,暂无操作权限"提示。
使用方式:
在 View 路由函数上添加 @check_demo 装饰器,
替代原有的 if FLASK_DEMO: return R.failed(...) 判断。
"""
def check_demo(func):
"""
演示模式拦截装饰器
当 FLASK_DEMO=True 时,拦截写操作并返回提示。
应放在 @check_permission 之后、路由函数之前。
Example:
@position_blue.route('/add', methods=["POST"])
@check_permission("sys:position:add")
@check_demo
def add():
result = position.add_position()
return result
"""
@wraps(func)
def wrapper(*args, **kwargs):
if FLASK_DEMO:
return R.failed("演示环境,暂无操作权限")
return func(*args, **kwargs)
return wrapper
# ============================================================
# AOP 事务拦截器
# ============================================================
"""
AOP 事务拦截器(企业级实现)
设计理念:
1. 零代码侵入:业务代码无需任何事务管理代码
2. 自动读写分离:GET 不提交,POST/PUT/DELETE 自动提交
3. 统一异常处理:所有异常自动回滚
4. 性能监控:记录事务执行时间
使用方式:
在 Flask 应用初始化时调用 register_transaction(app) 即可。
业务代码无需任何改动。
Example:
# app/__init__.py
from src.core.middleware import register_transaction
def create_app():
app = Flask(__name__)
register_transaction(app) # 一行代码搞定所有事务
return app
"""
# ============================================================
# 核心:AOP 事务拦截器
# ============================================================
def register_transaction(app):
"""
注册 AOP 事务拦截器到 Flask 应用
在请求生命周期中自动管理事务:
- before_request: 开启事务标记
- after_request: 自动提交(POST/PUT/DELETE)
- teardown_request: 异常回滚 + 资源清理
Args:
app: Flask 应用实例
Example:
register_transaction(app)
"""
@app.before_request
def begin_transaction():
"""请求开始:记录开始时间,开启事务标记"""
g.request_start_time = time.time()
g.transaction_active = True
logger.debug(f"请求开始, method: {request.method}, path: {request.path}, " f"ip: {request.remote_addr}")
@app.after_request
def commit_transaction(response):
"""
请求结束:自动提交事务
提交规则:
1. 只对写操作(POST/PUT/DELETE/PATCH)提交
2. 只在响应成功(2xx)且业务成功(code=0)时提交
3. GET/OPTIONS/HEAD 不提交(只读)
4. 业务失败(R.failed)不提交,自动回滚
"""
# 只对写操作处理事务
if request.method in ("POST", "PUT", "DELETE", "PATCH"):
should_commit = 200 <= response.status_code < 300
# 检查业务状态码:R.failed() 返回 HTTP 200 但 code=1/ok=False
if should_commit:
try:
data = response.get_json(silent=True)
if data and data.get("code", 0) != 0:
should_commit = False
except Exception:
pass
if should_commit:
try:
if db.session.is_active:
db.session.commit()
logger.debug(
f"事务提交成功, method: {request.method}, "
f"path: {request.path}, status: {response.status_code}"
)
except Exception as e:
db.session.rollback()
logger.error(
f"事务提交失败, method: {request.method}, " f"path: {request.path}, error: {str(e)}",
exc_info=True,
)
from src.core import response as R
return R.failed(msg="数据处理失败"), 500
else:
# 业务失败或非2xx,回滚未提交的变更
try:
if db.session.is_active:
db.session.rollback()
except Exception:
pass
# 记录响应日志
elapsed = time.time() - g.get("request_start_time", time.time())
logger.debug(
f"请求结束, method: {request.method}, path: {request.path}, "
f"status: {response.status_code}, elapsed: {elapsed:.3f}s"
)
return response
@app.teardown_request
def cleanup_transaction(exception=None):
"""
请求结束:清理资源
发生异常时自动回滚事务,确保数据一致性。
"""
if exception is not None:
try:
db.session.rollback()
logger.error(
f"请求异常,事务已回滚, method: {request.method}, "
f"path: {request.path}, error: {str(exception)}",
exc_info=True,
)
except Exception as e:
logger.error(f"事务回滚失败: {str(e)}", exc_info=True)
# 清理请求标记
g.pop("transaction_active", None)
g.pop("request_start_time", None)
logger.info("AOP 事务拦截器注册成功")
# ============================================================
# 备用:精细控制装饰器(特殊场景使用)
# ============================================================
def transactional(func):
"""
事务装饰器(备用)
适用于需要精细控制事务边界的特殊场景。
大多数情况下,使用 AOP 拦截器即可。
Example:
@transactional
def complex_business():
# 精细控制的事务逻辑
pass
"""
@wraps(func)
def wrapper(*args, **kwargs):
if g.get("disable_auto_commit", False):
return func(*args, **kwargs)
try:
result = func(*args, **kwargs)
if not g.get("transaction_active", False):
db.session.commit()
return result
except Exception as e:
db.session.rollback()
logger.error(f"装饰器事务执行失败: {str(e)}", exc_info=True)
raise
return wrapper
def transactional_response(func):
"""
事务装饰器(带返回 R 对象)
用于需要直接返回 R 对象的接口。
Example:
@transactional_response
def add_position():
position.save()
return R.ok(msg="添加成功")
"""
@wraps(func)
def wrapper(*args, **kwargs):
from src.core import response as R
try:
result = func(*args, **kwargs)
if not g.get("transaction_active", False):
db.session.commit()
return result
except Exception as e:
db.session.rollback()
logger.error(f"装饰器事务执行失败: {str(e)}", exc_info=True)
return R.failed(msg=f"操作失败: {str(e)}")
return wrapper
# ============================================================
# 备用:事务上下文管理器(特殊场景使用)
# ============================================================
class TransactionContext:
"""
事务上下文管理器
用于需要精细控制事务的特殊场景。
Example:
with TransactionContext():
position.save()
user.save()
# 自动提交或回滚
"""
def __enter__(self):
g.disable_auto_commit = True
return self
def __exit__(self, exc_type, exc_val, exc_tb):
g.disable_auto_commit = False
if exc_type is None:
if not g.get("transaction_active", False):
db.session.commit()
else:
db.session.rollback()
logger.error(f"事务上下文回滚: {str(exc_val)}", exc_info=True)
return False
# ============================================================
# 操作日志自动记录(DI:写入器由应用工厂注入)
# ============================================================
"""
操作日志自动记录中间件
=============================================================
通过 after_request 钩子自动记录写操作(POST/PUT/DELETE/PATCH)日志。
无需在每个视图函数中手动调用 record_operation_log。
记录规则:
- 只记录写操作(POST/PUT/DELETE/PATCH),GET/OPTIONS/HEAD 不记录
- 只记录成功操作(HTTP 2xx 且业务 code=0)
- 内部调用(定时任务等)不记录
- 登录/退出/验证码等路径不记录(由 login_log 单独记录)
- 日志记录失败不影响主流程
"""
# 不记录操作日志的路径前缀(登录/退出/验证码等由 login_log 单独记录)
_EXCLUDED_PREFIXES = ("/login", "/captcha", "/logout")
# HTTP 方法 → 操作日志类型 映射
# 类型值参考 application/constants/dict.py 的 OPERATION_LOG_TYPE_LIST
_METHOD_LOG_TYPE = {
"POST": 3, # 新增
"PUT": 4, # 修改
"DELETE": 5, # 删除
"PATCH": 4, # 修改
}
# 路径后缀 → 操作日志类型 覆盖映射(更精确)
_PATH_ACTION_TYPE = {
"/add": 3, # 新增
"/update": 4, # 修改
"/delete": 5, # 删除
"/status": 4, # 状态变更→修改
"/batchdelete": 5, # 批量删除
"/reset": 4, # 重置→修改
"/save": 4, # 保存→修改
"/sort": 4, # 排序→修改
}
def register_operation_log(app):
"""
注册操作日志自动记录中间件
在 after_request 中自动记录写操作日志。
Args:
app: Flask 应用实例
"""
@app.after_request
def record_op_log(response):
# 只记录写操作
if request.method not in ("POST", "PUT", "DELETE", "PATCH"):
return response
# 排除登录/退出/验证码等路径
path = request.path
if any(path.startswith(prefix) for prefix in _EXCLUDED_PREFIXES):
return response
# 只记录成功操作(HTTP 2xx 且业务 code=0)
if not (200 <= response.status_code < 300):
return response
try:
data = response.get_json(silent=True)
if data and data.get("code", 0) != 0:
return response
except Exception:
pass
# 内部调用不记录
if getattr(g, "_internal_call", False):
return response
# 异步记录,不阻塞响应
try:
_write_log(response)
except Exception as e:
logger.warning(f"记录操作日志失败: {str(e)}")
return response
logger.info("操作日志中间件注册成功")
def _write_log(response):
"""写入操作日志"""
from src.core.security import get_username
from src.common.utils.ip import get_client_ip
from src.common.utils.ip2region import get_ip_location
from src.common.utils.user_agent import parse_user_agent
writer = current_app.extensions.get("operation_log_writer")
if writer is None:
logger.warning("operation_log_writer 未注入,跳过操作日志")
return
username = get_username() or "system"
client_ip = get_client_ip()
ua_info = parse_user_agent()
# 确定操作类型
log_type = _infer_log_type(request.method, request.path)
# 构建标题:从路径提取模块名,如 /user/add → "用户管理-新增"
title = _build_title(request.path)
# 请求参数(限制长度,避免存入过大的数据)
param = _get_request_param()
# 返回结果(限制长度)
result = _get_response_result(response)
# 请求耗时
start_time = getattr(g, "request_start_time", None)
consume_time = int((time.time() - start_time) * 1000) if start_time else 0
writer(
title=title,
log_type=log_type,
source=0, # OperationLog.Source.ADMIN
status=0, # OperationLog.Status.NORMAL
method=request.method,
request_method=request.endpoint or "",
url=request.path,
param=param,
result=result,
ip=client_ip,
location=get_ip_location(client_ip),
os=ua_info.get("os", "未知"),
browser=ua_info.get("browser", "未知"),
consume_time=consume_time,
error="",
username=username,
)
def _infer_log_type(method, path):
"""根据请求方法和路径推断操作日志类型"""
# 优先根据路径后缀匹配
path_lower = path.lower()
for suffix, log_type in _PATH_ACTION_TYPE.items():
if path_lower.endswith(suffix):
return log_type
# 回退到 HTTP 方法映射
return _METHOD_LOG_TYPE.get(method, 18) # 18=其他
def _get_request_param():
"""获取请求参数"""
try:
if request.method in ("POST", "PUT", "PATCH", "DELETE"):
# 优先解析 JSON body(batchDelete 等 DELETE 请求也带 body)
data = request.get_json(silent=True)
if data:
import json
return json.dumps(data, ensure_ascii=False)
# 回退到 URL 查询参数
if request.args:
import json
from collections import OrderedDict
return json.dumps(OrderedDict(request.args), ensure_ascii=False)
except Exception:
pass
return ""
def _get_response_result(response):
"""获取响应结果中的data字段"""
try:
data = response.get_json(silent=True)
if data and isinstance(data, dict):
result_data = data.get("data")
if result_data is not None:
import json
return json.dumps(result_data, ensure_ascii=False)
# data字段为空时取msg
return data.get("msg", "")
except Exception:
pass
return ""
def _build_title(path):
"""从请求路径构建操作标题"""
# 路径格式: /user/add, /role/update, /menu/delete 等
parts = path.strip("/").split("/")
if len(parts) >= 2:
module = parts[0]
# 模块名中文化映射
module_names = {
"user": "用户管理",
"role": "角色管理",
"menu": "菜单管理",
"dept": "部门管理",
"position": "岗位管理",
"level": "职级管理",
"param": "参数配置",
"dict": "字典管理",
"dictitem": "字典项管理",
"config": "系统配置",
"configitem": "配置项管理",
"category": "分类管理",
"article": "文章管理",
"notice": "通知管理",
"link": "友情链接",
"job": "定时任务",
"login": "登录管理",
"loginlog": "登录日志",
"operationlog": "操作日志",
"upload": "文件上传",
}
module_name = module_names.get(module.lower(), module)
action = parts[-1] if len(parts) > 1 else ""
action_names = {
"add": "新增",
"update": "修改",
"delete": "删除",
"status": "状态变更",
"batchdelete": "批量删除",
"reset": "重置",
"save": "保存",
"sort": "排序",
"page": "查询",
"detail": "查询",
"list": "查询",
}
action_name = action_names.get(action.lower(), action)
return f"{module_name}-{action_name}" if action_name else module_name
return path
# ============================================================
# 全局异常处理
# ============================================================
"""
全局异常处理模块
=============================================================
注册 Flask errorhandler,将所有 HTTP 错误和未捕获异常
统一转为 R.failed() JSON 响应,避免前端收到 HTML 错误页导致解析崩溃。
处理范围:
- 400:请求参数错误
- 401:未授权
- 403:权限不足
- 404:资源不存在
- 405:请求方法不允许
- 500:服务器内部错误
- Exception:兜底,捕获所有未预期异常
"""
def register_error_handlers(app):
"""
注册全局异常处理器
确保所有 HTTP 错误和未捕获异常都返回标准 JSON 响应。
Args:
app: Flask 应用实例
"""
@app.errorhandler(400)
def bad_request(e):
return R.failed(msg="请求参数错误", code=400), 400
@app.errorhandler(401)
def unauthorized(e):
return R.failed(msg="未授权,请重新登录", code=401), 401
@app.errorhandler(403)
def forbidden(e):
return R.failed(msg="权限不足", code=403), 403
@app.errorhandler(404)
def not_found(e):
return R.failed(msg="请求的资源不存在", code=404), 404
@app.errorhandler(405)
def method_not_allowed(e):
return R.failed(msg="请求方法不允许", code=405), 405
@app.errorhandler(500)
def internal_error(e):
logger.error(
f"服务器内部错误, method: {request.method}, path: {request.path}, " f"error: {str(e)}", exc_info=True
)
msg = f"服务器内部错误: {str(e)}" if app.debug else "服务器内部错误"
return R.failed(msg=msg, code=500), 500
@app.errorhandler(BizError)
def handle_biz_error(e):
"""
业务异常处理器
业务代码 raise BizError(...) 时统一转换为 R.failed 响应,
并回滚当前事务(teardown_request 处理)。
"""
logger.info(f"业务异常: {e.msg} (code={e.code})")
return R.failed(msg=e.msg, code=e.code, data=e.data, **e.kwargs)
@app.errorhandler(Exception)
def handle_exception(e):
# HTTPException 子类(如 abort(403))已有专用 handler,不重复处理
if isinstance(e, HTTPException):
return e.get_response()
# 非HTTP异常:未知运行时异常
logger.error(f"未捕获异常, method: {request.method}, path: {request.path}, " f"error: {traceback.format_exc()}")
msg = f"服务器内部错误: {str(e)}" if app.debug else "服务器内部错误"
return R.failed(msg=msg, code=500), 500
logger.info("全局异常处理器注册成功")
# ============================================================
# 中间件注册
# ============================================================
def register_middleware(app):
"""
注册应用中间件(登录校验 before_request)。
Args:
app: Flask 应用实例
"""
@app.before_request
def checklogin():
"""登录验证中间件"""
return check_login()