【结构】 - src/core/:database/response/jwt/password/security/redis_client/mail/scheduler - src/common/:base_model/base_db/forms(base_form+fields)/constants/utils - src/models_registry.py:模型聚合器(Flask-Migrate 全量表识别) 【绞杀者 shim】extensions/、utils/、application/constants|forms|models 打兼容转发 【兼容修复】应用工厂抽取至 application/factory.py(P2 前置,P1 已完成) 【基线修复】application/models(25) + middleware(7) 原被嵌套 .gitignore(*) 误排除出版本库,已补入(base_model/base_db 现为 shim,原版在 src/common/) 【登记既有缺陷】 1. flask db CLI 因 .env FLASK_VERSION 与 --version 冲突报错 → Makefile 已加 FLASK_SKIP_DOTENV=1 2. 本机 Redis 未设密码但 REDIS_AUTH=True,/captcha 报 AUTH 错误(环境问题) 【验收】22 模型表全量识别;src/ flake8 零告警;pytest 5 passed;应用启动; /login 表单校验、/user/page 鉴权拦截冒烟正常 Co-Authored-By: Claude <noreply@anthropic.com>
221 lines
7.2 KiB
Python
221 lines
7.2 KiB
Python
"""
|
||
操作日志自动记录中间件
|
||
=============================================================
|
||
|
||
通过 after_request 钩子自动记录写操作(POST/PUT/DELETE/PATCH)日志。
|
||
无需在每个视图函数中手动调用 record_operation_log。
|
||
|
||
记录规则:
|
||
- 只记录写操作(POST/PUT/DELETE/PATCH),GET/OPTIONS/HEAD 不记录
|
||
- 只记录成功操作(HTTP 2xx 且业务 code=0)
|
||
- 内部调用(定时任务等)不记录
|
||
- 登录/退出/验证码等路径不记录(由 login_log 单独记录)
|
||
- 日志记录失败不影响主流程
|
||
"""
|
||
|
||
import logging
|
||
import time
|
||
|
||
from flask import g, request
|
||
|
||
from application.models.operation_log import OperationLog
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# 不记录操作日志的路径前缀(登录/退出/验证码等由 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 application.services import operation_log
|
||
from utils.security import get_username
|
||
from utils.ip import get_client_ip
|
||
from utils.ip2region import get_ip_location
|
||
from utils.user_agent import parse_user_agent
|
||
|
||
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
|
||
|
||
operation_log.record_operation_log(
|
||
title=title,
|
||
log_type=log_type,
|
||
source=OperationLog.Source.ADMIN,
|
||
status=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
|