Files
DjangoAdmin_Flask_AntdVue/src/middleware/operation_log.py
T
2026-09-14 14:48:52 +08:00

347 lines
10 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.
"""
操作日志自动记录中间件
=============================================================
通过 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):status=0(正常)
- 失败操作(code!=0,如演示环境拦截):status=1(异常),记录错误信息
"""
# 只记录写操作
if request.method not in ("POST", "PUT", "DELETE", "PATCH"):
return response
# 排除登录/退出/验证码/SMS 等敏感路径(尾段匹配,兼容任意版本前缀)
path = request.path
if _is_excluded_path(path):
return response
# HTTP 非 2xx 不记录(如 404、500 等)
if not (200 <= response.status_code < 300):
return response
# 内部调用不记录
if getattr(g, "_internal_call", False):
return response
# 判断业务状态:code=0 为成功,其他为失败
status = 0 # 默认成功
error_msg = ""
try:
data = response.get_json(silent=True)
if data and data.get("code", 0) != 0:
status = 1 # 业务失败
error_msg = data.get("msg", "操作失败")
except Exception:
pass
# 记录日志(成功和失败都记录)
try:
_write_log(response, status=status, error_msg=error_msg)
except Exception as e:
logger.warning(f"记录操作日志失败: {str(e)}")
return response
logger.info("操作日志中间件注册成功")
def _write_log(response, status=0, error_msg=""):
"""
写入操作日志
从请求和响应中提取日志所需信息,调用注入的 writer 函数写入。
Args:
response: Flask 响应对象
status: 操作状态(0-正常,1-异常)
error_msg: 错误信息(失败时)
"""
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=status,
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=error_msg,
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