签入版本
This commit is contained in:
@@ -0,0 +1,337 @@
|
||||
"""
|
||||
操作日志自动记录中间件
|
||||
=============================================================
|
||||
|
||||
通过 after_request 钩子自动记录写操作(POST/PUT/DELETE/PATCH)日志。
|
||||
无需在每个视图函数中手动调用 record_operation_log。
|
||||
|
||||
记录规则:
|
||||
- 只记录写操作(POST/PUT/DELETE/PATCH),GET/OPTIONS/HEAD 不记录
|
||||
- 只记录成功操作(HTTP 2xx 且业务 code=0)
|
||||
- 内部调用(定时任务等)不记录
|
||||
- 登录/退出/验证码等路径不记录(由 login_log 单独记录)
|
||||
- 日志记录失败不影响主流程
|
||||
|
||||
标题声明:
|
||||
在路由视图上使用 `@operation_log("岗位管理", "新增")` 声明操作日志标题,
|
||||
中间件从已注册视图函数读取 `__op_log_title__`,无需集中式模块映射表。
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
from flask import current_app, g, request
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def operation_log(module_name: str, action_name: str = ""):
|
||||
"""
|
||||
在路由视图上声明操作日志标题(模块名-动作名)。
|
||||
|
||||
挂载到视图函数上,中间件从 `current_app.view_functions[endpoint].__op_log_title__` 读取。
|
||||
|
||||
Args:
|
||||
module_name: 模块名称,如 "岗位管理"
|
||||
action_name: 操作名称,如 "新增",为空时仅使用模块名
|
||||
|
||||
Returns:
|
||||
decorator: 装饰器函数
|
||||
|
||||
Example:
|
||||
@position_blue.route("/add", methods=["POST"])
|
||||
@operation_log("岗位管理", "新增")
|
||||
@check_permission("sys:position:add")
|
||||
def add(): ...
|
||||
"""
|
||||
|
||||
def decorator(func):
|
||||
func.__op_log_title__ = f"{module_name}-{action_name}" if action_name else module_name
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
# 不记录操作日志的敏感路径尾段(登录/验证码/退出/SMS 验证码)。
|
||||
# 按路径尾段 endswith 匹配,天然兼容 /api/v1、/api/v2 等任意版本前缀,
|
||||
# 无需随版本升级逐个追加前缀。
|
||||
_EXCLUDED_PATH_SEGMENTS = ("/login", "/captcha", "/logout", "/sendSms")
|
||||
|
||||
|
||||
def _is_excluded_path(path) -> bool:
|
||||
"""判断路径是否属于敏感端点(尾段匹配,版本无关)"""
|
||||
return any(path.endswith(seg) for seg in _EXCLUDED_PATH_SEGMENTS)
|
||||
|
||||
|
||||
# 需脱敏的敏感字段(真正的凭据),防止写入操作日志/审计。
|
||||
# 注意:不包含 code/key——业务数据(role.code/dict.code 等)常含同名键,会被误打码;
|
||||
# 验证码/短信端点已按路径排除(_is_excluded_path),无需在此兜底。
|
||||
_SENSITIVE_KEYS = frozenset({
|
||||
"password", "salt", "oldPassword", "newPassword", "confirmPassword",
|
||||
"accessToken", "token",
|
||||
})
|
||||
|
||||
|
||||
def _redact_sensitive(data):
|
||||
"""
|
||||
递归脱敏敏感字段,用于操作日志请求/响应记录
|
||||
|
||||
将密码、令牌等敏感字段的值替换为 "******"。
|
||||
|
||||
Args:
|
||||
data: 待处理的数据(dict/list/基础类型)
|
||||
|
||||
Returns:
|
||||
脱敏后的数据
|
||||
"""
|
||||
if isinstance(data, dict):
|
||||
return {
|
||||
k: ("******" if k in _SENSITIVE_KEYS else _redact_sensitive(v))
|
||||
for k, v in data.items()
|
||||
}
|
||||
if isinstance(data, list):
|
||||
return [_redact_sensitive(item) for item in data]
|
||||
return data
|
||||
|
||||
|
||||
# HTTP 方法 → 操作日志类型 映射
|
||||
# 类型值见 scripts/seed_dict.py 的 DICT_SEED operation_type(显示文本由 dict_utils 从字典表翻译)
|
||||
_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):
|
||||
"""
|
||||
after_request 钩子:自动记录写操作日志
|
||||
|
||||
记录条件:
|
||||
1. 请求方法为 POST/PUT/DELETE/PATCH
|
||||
2. 路径不在排除列表中
|
||||
3. HTTP 状态码为 2xx
|
||||
4. 业务 code 为 0(成功)
|
||||
5. 非内部调用
|
||||
"""
|
||||
# 只记录写操作
|
||||
if request.method not in ("POST", "PUT", "DELETE", "PATCH"):
|
||||
return response
|
||||
|
||||
# 排除登录/退出/验证码/SMS 等敏感路径(尾段匹配,兼容任意版本前缀)
|
||||
path = request.path
|
||||
if _is_excluded_path(path):
|
||||
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):
|
||||
"""
|
||||
写入操作日志
|
||||
|
||||
从请求和响应中提取日志所需信息,调用注入的 writer 函数写入。
|
||||
|
||||
Args:
|
||||
response: Flask 响应对象
|
||||
"""
|
||||
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)
|
||||
|
||||
# 构建标题:优先取路由 @operation_log 声明的元数据
|
||||
title = _build_title(request.path, request.endpoint)
|
||||
|
||||
# 请求参数(限制长度,避免存入过大的数据)
|
||||
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.endpoint or "",
|
||||
request_method=request.method,
|
||||
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):
|
||||
"""
|
||||
根据请求方法和路径推断操作日志类型
|
||||
|
||||
优先根据路径后缀匹配,回退到 HTTP 方法映射。
|
||||
|
||||
Args:
|
||||
method: HTTP 请求方法
|
||||
path: 请求路径
|
||||
|
||||
Returns:
|
||||
int: 操作日志类型编码
|
||||
"""
|
||||
# 优先根据路径后缀匹配
|
||||
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():
|
||||
"""
|
||||
获取请求参数
|
||||
|
||||
优先从 JSON body 获取,回退到 URL 查询参数。
|
||||
自动对敏感字段进行脱敏处理。
|
||||
|
||||
Returns:
|
||||
str: JSON 格式的参数字符串,无参数时返回空字符串
|
||||
"""
|
||||
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(_redact_sensitive(data), ensure_ascii=False)
|
||||
# 回退到 URL 查询参数
|
||||
if request.args:
|
||||
import json
|
||||
from collections import OrderedDict
|
||||
|
||||
return json.dumps(_redact_sensitive(OrderedDict(request.args)), ensure_ascii=False)
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def _get_response_result(response):
|
||||
"""
|
||||
获取响应结果中的 data 字段
|
||||
|
||||
Args:
|
||||
response: Flask 响应对象
|
||||
|
||||
Returns:
|
||||
str: JSON 格式的响应数据字符串,无数据时返回空字符串
|
||||
"""
|
||||
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(_redact_sensitive(result_data), ensure_ascii=False)
|
||||
# data字段为空时取msg
|
||||
return data.get("msg", "")
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def _build_title(path, endpoint):
|
||||
"""
|
||||
构建操作日志标题
|
||||
|
||||
优先读取路由视图函数 `@operation_log` 声明的元数据;
|
||||
未标注的路由走路径兜底(模块段-动作段,不做中文映射)。
|
||||
|
||||
Args:
|
||||
path: 请求路径
|
||||
endpoint: Flask 端点名称
|
||||
|
||||
Returns:
|
||||
str: 操作日志标题
|
||||
"""
|
||||
view_func = current_app.view_functions.get(endpoint)
|
||||
if view_func:
|
||||
title = getattr(view_func, "__op_log_title__", None)
|
||||
if title:
|
||||
return title
|
||||
|
||||
# 兜底:未标注路由,用路径原样(剥离 /api/v1 版本前缀,取前两段)
|
||||
parts = [p for p in path.strip("/").split("/") if p]
|
||||
if parts and parts[0] == "api":
|
||||
parts = parts[2:] if len(parts) > 2 else []
|
||||
return "-".join(parts[:2]) if parts else path
|
||||
Reference in New Issue
Block a user