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>
This commit is contained in:
DjangoAdmin Dev
2026-08-07 19:39:19 +08:00
co-authored by Claude
parent 35f89395a2
commit 0b0fa0c047
13 changed files with 1184 additions and 1053 deletions
+813
View File
@@ -0,0 +1,813 @@
"""
中间件模块
=============================================================
整合自 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()