2026-02-04 13:30:07 +08:00
|
|
|
|
# app/utils/decorators.py
|
|
|
|
|
|
from functools import wraps
|
2026-03-10 12:15:26 +08:00
|
|
|
|
from flask_jwt_extended import get_jwt, verify_jwt_in_request, get_jwt_identity
|
|
|
|
|
|
from flask import jsonify, g, request
|
2026-02-27 10:16:43 +08:00
|
|
|
|
import logging
|
2026-03-10 12:15:26 +08:00
|
|
|
|
import json
|
2026-02-04 13:30:07 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def role_required(*roles):
|
|
|
|
|
|
"""
|
|
|
|
|
|
自定义装饰器:检查用户角色
|
|
|
|
|
|
使用方法: @role_required('super_admin', 'finance')
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
def wrapper(fn):
|
|
|
|
|
|
@wraps(fn)
|
|
|
|
|
|
def decorator(*args, **kwargs):
|
|
|
|
|
|
claims = get_jwt()
|
|
|
|
|
|
user_role = claims.get('role')
|
2026-02-27 17:07:45 +08:00
|
|
|
|
user_role_upper = user_role.upper() if user_role else None
|
2026-02-04 13:30:07 +08:00
|
|
|
|
|
|
|
|
|
|
# 如果是超级管理员,拥有上帝视角,直接放行 (可选)
|
2026-02-27 17:07:45 +08:00
|
|
|
|
if user_role_upper == 'SUPER_ADMIN':
|
2026-02-04 13:30:07 +08:00
|
|
|
|
return fn(*args, **kwargs)
|
|
|
|
|
|
|
2026-02-27 17:07:45 +08:00
|
|
|
|
if user_role_upper not in [r.upper() for r in roles]:
|
2026-02-04 13:30:07 +08:00
|
|
|
|
return jsonify(msg='权限不足:您没有访问此资源的权限'), 403
|
|
|
|
|
|
|
|
|
|
|
|
return fn(*args, **kwargs)
|
|
|
|
|
|
|
|
|
|
|
|
return decorator
|
|
|
|
|
|
|
2026-02-27 10:16:43 +08:00
|
|
|
|
return wrapper
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def login_required(fn):
|
|
|
|
|
|
"""
|
|
|
|
|
|
验证 JWT 令牌是否存在且有效
|
|
|
|
|
|
"""
|
|
|
|
|
|
@wraps(fn)
|
|
|
|
|
|
def decorator(*args, **kwargs):
|
|
|
|
|
|
try:
|
|
|
|
|
|
verify_jwt_in_request()
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logging.warning(f"JWT verification failed: {e}")
|
|
|
|
|
|
return jsonify(msg='登录已过期,请重新登录'), 401
|
|
|
|
|
|
return fn(*args, **kwargs)
|
|
|
|
|
|
return decorator
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def permission_required(permission_code):
|
|
|
|
|
|
"""
|
|
|
|
|
|
检查当前用户是否拥有指定权限码
|
|
|
|
|
|
使用方法: @permission_required('material:base:read')
|
|
|
|
|
|
"""
|
|
|
|
|
|
def wrapper(fn):
|
|
|
|
|
|
@wraps(fn)
|
|
|
|
|
|
def decorator(*args, **kwargs):
|
|
|
|
|
|
# 首先验证 JWT
|
|
|
|
|
|
try:
|
|
|
|
|
|
verify_jwt_in_request()
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logging.warning(f"JWT verification failed: {e}")
|
|
|
|
|
|
return jsonify(msg='登录已过期,请重新登录'), 401
|
|
|
|
|
|
|
|
|
|
|
|
claims = get_jwt()
|
|
|
|
|
|
user_role = claims.get('role')
|
2026-02-27 17:04:22 +08:00
|
|
|
|
# 超级管理员放行 (忽略大小写)
|
|
|
|
|
|
if user_role and user_role.upper() == 'SUPER_ADMIN':
|
2026-02-27 10:16:43 +08:00
|
|
|
|
return fn(*args, **kwargs)
|
|
|
|
|
|
|
2026-02-27 10:20:09 +08:00
|
|
|
|
# 根据角色查询数据库中的权限
|
|
|
|
|
|
try:
|
|
|
|
|
|
from app.services.auth_service import AuthService
|
|
|
|
|
|
perm_dict = AuthService.get_user_permissions(user_role)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logging.warning(f"Failed to fetch permissions for role {user_role}: {e}")
|
|
|
|
|
|
return jsonify(msg='权限查询失败'), 403
|
2026-02-27 10:16:43 +08:00
|
|
|
|
|
2026-02-27 10:20:09 +08:00
|
|
|
|
# 合并菜单和元素权限
|
|
|
|
|
|
all_perms = perm_dict.get('menus', []) + perm_dict.get('elements', [])
|
|
|
|
|
|
if permission_code not in all_perms:
|
2026-02-27 10:27:44 +08:00
|
|
|
|
# 详细的调试日志
|
2026-02-27 10:29:15 +08:00
|
|
|
|
print(f"🔴 [权限拦截] 角色 '{user_role}' 访问被拒!需要权限码: '{permission_code}', 但该角色实际拥有: {all_perms}")
|
2026-02-27 10:20:09 +08:00
|
|
|
|
logging.warning(
|
2026-02-27 10:27:44 +08:00
|
|
|
|
f"权限检查失败: 角色={user_role}, 所需权限={permission_code}, 实际权限列表={all_perms}")
|
2026-02-27 10:20:09 +08:00
|
|
|
|
return jsonify(msg='权限不足:您没有访问此资源的权限'), 403
|
|
|
|
|
|
return fn(*args, **kwargs)
|
2026-02-27 10:16:43 +08:00
|
|
|
|
return decorator
|
|
|
|
|
|
return wrapper
|
2026-03-10 12:15:26 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def audit_log(module: str, action: str = None, get_target_id_fn=None, get_target_name_fn=None, get_details_fn=None):
|
|
|
|
|
|
"""
|
|
|
|
|
|
审计日志装饰器
|
|
|
|
|
|
用法: @audit_log(module='inbound_buy', action='create')
|
|
|
|
|
|
@audit_log(module='bom', action='update', get_target_id_fn=lambda: ..., get_details_fn=lambda req, resp: ...)
|
2026-03-10 17:36:02 +08:00
|
|
|
|
|
|
|
|
|
|
升级特性:
|
|
|
|
|
|
- 自动捕获请求 Payload 作为变更明细
|
|
|
|
|
|
- 自动过滤过长的 Base64 图片数据
|
|
|
|
|
|
- 支持自定义 get_details_fn 覆盖默认行为
|
2026-03-10 12:15:26 +08:00
|
|
|
|
"""
|
2026-03-10 17:36:02 +08:00
|
|
|
|
# 需要过滤的图片字段
|
|
|
|
|
|
IMAGE_FIELDS = {'arrival_photo', 'product_photo', 'photo', 'image', 'signature', 'borrow_signature', 'return_signature'}
|
|
|
|
|
|
|
|
|
|
|
|
def _filter_payload(payload):
|
|
|
|
|
|
"""过滤 Payload 中的大字段,防止数据库膨胀"""
|
|
|
|
|
|
if not payload or not isinstance(payload, dict):
|
|
|
|
|
|
return payload
|
|
|
|
|
|
filtered = {}
|
|
|
|
|
|
for key, value in payload.items():
|
|
|
|
|
|
if key.lower() in IMAGE_FIELDS and isinstance(value, str) and len(value) > 100:
|
|
|
|
|
|
filtered[key] = '[图片数据已省略]'
|
|
|
|
|
|
elif isinstance(value, dict):
|
|
|
|
|
|
filtered[key] = _filter_payload(value)
|
|
|
|
|
|
elif isinstance(value, list):
|
|
|
|
|
|
filtered[key] = [
|
|
|
|
|
|
_filter_payload(item) if isinstance(item, dict) else item
|
|
|
|
|
|
for item in value
|
|
|
|
|
|
]
|
|
|
|
|
|
else:
|
|
|
|
|
|
filtered[key] = value
|
|
|
|
|
|
return filtered
|
|
|
|
|
|
|
|
|
|
|
|
def _get_payload():
|
|
|
|
|
|
"""自动获取请求 Payload"""
|
|
|
|
|
|
# 尝试 JSON
|
|
|
|
|
|
payload = request.get_json(silent=True)
|
|
|
|
|
|
if payload:
|
|
|
|
|
|
return payload
|
|
|
|
|
|
# 尝试 Form Data
|
|
|
|
|
|
if request.form:
|
|
|
|
|
|
return request.form.to_dict()
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
2026-03-10 12:15:26 +08:00
|
|
|
|
def wrapper(fn):
|
|
|
|
|
|
@wraps(fn)
|
|
|
|
|
|
def decorator(*args, **kwargs):
|
|
|
|
|
|
# 获取请求上下文
|
|
|
|
|
|
claims = get_jwt()
|
|
|
|
|
|
user_id = get_jwt_identity()
|
|
|
|
|
|
username = claims.get('username', '')
|
|
|
|
|
|
display_name = claims.get('display_name', '')
|
|
|
|
|
|
|
|
|
|
|
|
# 获取IP
|
|
|
|
|
|
ip_address = request.headers.get('X-Forwarded-For') or request.remote_addr or ''
|
|
|
|
|
|
if ip_address and ',' in ip_address:
|
|
|
|
|
|
ip_address = ip_address.split(',')[0].strip()
|
2026-03-10 17:36:02 +08:00
|
|
|
|
|
2026-03-10 12:15:26 +08:00
|
|
|
|
# 获取请求信息
|
|
|
|
|
|
http_method = request.method
|
|
|
|
|
|
url = request.url
|
|
|
|
|
|
user_agent = request.headers.get('User-Agent', '')[:500]
|
|
|
|
|
|
|
|
|
|
|
|
# 解析 action(支持动态)
|
|
|
|
|
|
final_action = action
|
|
|
|
|
|
if callable(action):
|
|
|
|
|
|
final_action = action()
|
|
|
|
|
|
|
2026-03-10 17:36:02 +08:00
|
|
|
|
# 预先获取 Payload(用于后续 details 记录)
|
|
|
|
|
|
raw_payload = _get_payload()
|
|
|
|
|
|
filtered_payload = _filter_payload(raw_payload) if raw_payload else None
|
|
|
|
|
|
|
2026-03-10 12:15:26 +08:00
|
|
|
|
# 执行原函数
|
|
|
|
|
|
response = fn(*args, **kwargs)
|
|
|
|
|
|
|
|
|
|
|
|
# 只记录成功的请求(响应状态码 200/201)
|
|
|
|
|
|
status_code = 200
|
|
|
|
|
|
if hasattr(response, 'status_code'):
|
|
|
|
|
|
status_code = response.status_code
|
|
|
|
|
|
|
|
|
|
|
|
if status_code in [200, 201]:
|
|
|
|
|
|
try:
|
|
|
|
|
|
from app.models.audit import AuditLog
|
|
|
|
|
|
from app.extensions import db
|
|
|
|
|
|
from flask import current_app
|
|
|
|
|
|
|
|
|
|
|
|
# 获取 target_id
|
|
|
|
|
|
target_id = None
|
|
|
|
|
|
if get_target_id_fn:
|
|
|
|
|
|
try:
|
|
|
|
|
|
target_id = get_target_id_fn()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
if not target_id and hasattr(response, 'json'):
|
|
|
|
|
|
resp_data = response.get_json()
|
|
|
|
|
|
if resp_data and isinstance(resp_data, dict):
|
|
|
|
|
|
target_id = resp_data.get('id')
|
|
|
|
|
|
|
|
|
|
|
|
# 获取 target_name
|
|
|
|
|
|
target_name = None
|
|
|
|
|
|
if get_target_name_fn:
|
|
|
|
|
|
try:
|
|
|
|
|
|
target_name = get_target_name_fn()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
# 获取 details
|
|
|
|
|
|
details = None
|
|
|
|
|
|
if get_details_fn:
|
2026-03-10 17:36:02 +08:00
|
|
|
|
# 优先使用自定义差异对比函数
|
2026-03-10 12:15:26 +08:00
|
|
|
|
try:
|
|
|
|
|
|
details = get_details_fn(request, response)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
2026-03-10 17:36:02 +08:00
|
|
|
|
elif filtered_payload:
|
|
|
|
|
|
# 默认:记录请求 Payload
|
|
|
|
|
|
details = {'payload': filtered_payload}
|
2026-03-10 12:15:26 +08:00
|
|
|
|
|
|
|
|
|
|
# 保存日志
|
|
|
|
|
|
log_entry = AuditLog(
|
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
|
username=username,
|
|
|
|
|
|
display_name=display_name,
|
|
|
|
|
|
action=final_action or http_method.lower(),
|
|
|
|
|
|
module=module,
|
|
|
|
|
|
target_id=str(target_id) if target_id else None,
|
|
|
|
|
|
target_name=target_name,
|
|
|
|
|
|
details=details,
|
|
|
|
|
|
ip_address=ip_address,
|
|
|
|
|
|
user_agent=user_agent,
|
|
|
|
|
|
method=http_method,
|
|
|
|
|
|
url=url,
|
|
|
|
|
|
status_code=status_code
|
|
|
|
|
|
)
|
|
|
|
|
|
db.session.add(log_entry)
|
|
|
|
|
|
db.session.commit()
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
current_app.logger.error(f"审计日志记录失败: {str(e)}")
|
|
|
|
|
|
db.session.rollback()
|
|
|
|
|
|
|
|
|
|
|
|
return response
|
|
|
|
|
|
|
|
|
|
|
|
return decorator
|
|
|
|
|
|
return wrapper
|