签入版本

This commit is contained in:
2026-09-12 19:09:28 +08:00
commit 4eae62bc17
803 changed files with 105034 additions and 0 deletions
+337
View File
@@ -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