refactor(audit): 审计架构清理——复活白名单监听器、停用噪声监听器、清除僵尸装饰器
一、统一为单一监听器实现
原先两套 SQLAlchemy 事件监听器并存:
· app/utils/audit_events.py —— 全局监听 db.Model、无白名单、无请求上下文守卫(实际在跑)
· app/core/audit_listener.py —— 白名单制、有守卫、有模型级开关(从未生效)
后者失效的根因:注册代码写在 extensions.py 的 init_extensions() 内,
而该函数全仓库只有定义、没有任何调用(create_app 直接内联调用 db.init_app 等)。
现统一由 app/core/audit_listener.py 承担,并在 create_app() 中显式注册。
extensions.py 的死函数 init_extensions 整体删除,避免后人误以为它是有效入口。
二、修复监听器三处致命缺陷(此前注册了也写不进数据)
1. 事件回调第二个参数是 Connection,原代码却调用 Connection.add()(不存在),
每次写日志都抛 AttributeError 并被 except 吞掉 → 改为 connection.execute()
2. register_audit_listeners 从 app.models 批量 import 多个未导出的模型,
ImportError 被上层 try/except 吞掉 → 改为按表名从 db.metadata 取模型
3. 本项目有 31 处函数体内延迟导入模型(如 scrap.py 内部才 import ScrapApproval),
一次性注册会静默漏表 → 增加 ensure_audit_listeners() 惰性补绑,
并在模型预加载段补全审批单/BOM/采购等模型
三、强约束
· WHITELIST_TABLES:仅 18 张核心业务表,系统表/草稿表/向量表不再自审
· has_request_context() 守卫:系统初始化与后台定时任务不再产生 username=system 噪声
· IGNORE_FIELDS 增加 password/password_hash/salt/token/secret/api_key(安全红线)
· created_at 显式写 beijing_time(),与全系统时间口径一致
四、清除僵尸装饰器
@audit_log 早已退化为直接透传的空壳(module/action 参数全被忽略,
数据库中零星的中文 action 即其历史遗留产物),却仍挂在 38 处路由上。
连同 13 个文件的 import 一并移除;audit_events.register_audit_events 改为空操作。
验证:应用上下文中的写操作不产生日志;HTTP 请求产生 5 条日志,
对象为业务单号(APR-SCRAP-... / SKU),模块中文,操作人真实,时间为北京时间。
This commit is contained in:
@ -1,22 +1,137 @@
|
||||
# inventory-backend/app/core/audit_listener.py
|
||||
"""
|
||||
SQLAlchemy Event Listener 审计监听器(单体架构版)
|
||||
监听器亲自完成入库,不依赖 g 对象,不依赖装饰器回调。
|
||||
只要模型发生 INSERT/UPDATE/DELETE,监听器直接创建 AuditLog 并挂载到当前事务 session。
|
||||
SQLAlchemy Event Listener 审计监听器(业务友好版)
|
||||
|
||||
设计要点
|
||||
--------
|
||||
1. **白名单制**:只审计核心业务表(WHITELIST_TABLES),不做全局 db.Model 监听。
|
||||
2. **请求上下文守卫**:仅在 has_request_context() 为真时记录,杜绝系统初始化 /
|
||||
后台定时任务产生的 `username=system` 噪声日志。
|
||||
3. **同事务写入**:使用事件回调给的 connection 直接 INSERT,随主事务提交/回滚,
|
||||
不影响业务事务;写入失败仅记日志,不抛异常。
|
||||
4. **业务友好**:target_name 优先取业务标识(单号/SKU/物料编码),
|
||||
兜底格式为「中文表名 - 业务号或ID」,不再出现 `stock_buy ID:468`。
|
||||
5. **安全**:密码/令牌类字段在 IGNORE_FIELDS 中,绝不落入 details。
|
||||
6. **时区**:created_at 显式写入北京时间(beijing_time),与全系统口径一致。
|
||||
"""
|
||||
from sqlalchemy import event, inspect
|
||||
import json
|
||||
from datetime import datetime, date
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import event, inspect, text
|
||||
from flask import current_app, request, has_request_context
|
||||
from datetime import datetime
|
||||
|
||||
from app.extensions import beijing_time
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 配置:白名单 / 忽略字段 / 中文映射
|
||||
# =============================================================================
|
||||
|
||||
# ★ 白名单:只有这些「表」的变更才会被审计。
|
||||
# 未列入者(audit_logs、sys_log、sys_menu、sys_element、bom_draft_table、
|
||||
# stocktake_draft 等)一律不记录,避免系统表自我审计与草稿表噪声。
|
||||
WHITELIST_TABLES = {
|
||||
# --- 审批单 ---
|
||||
'outbound_approval',
|
||||
'borrow_approval',
|
||||
'scrap_approval',
|
||||
# --- 业务流水 ---
|
||||
'trans_outbound',
|
||||
'trans_borrow',
|
||||
'trans_scrap',
|
||||
'trans_repair',
|
||||
# --- 库存(三表 + 库存调整)---
|
||||
'stock_buy',
|
||||
'stock_semi',
|
||||
'stock_product',
|
||||
'stock_adjustment',
|
||||
# --- 主数据 ---
|
||||
'material_base',
|
||||
'purchase_request',
|
||||
'bom_table',
|
||||
'material_warning_settings',
|
||||
# --- 系统管理 ---
|
||||
'sys_user',
|
||||
'sys_role_permission',
|
||||
'sys_warehouse_location',
|
||||
}
|
||||
|
||||
# ★ 表名 → 中文名(用于 target_name 兜底与可读性)
|
||||
TABLE_LABELS = {
|
||||
'outbound_approval': '出库申请单',
|
||||
'borrow_approval': '借库申请单',
|
||||
'scrap_approval': '报废申请单',
|
||||
'trans_outbound': '出库流水',
|
||||
'trans_borrow': '借还流水',
|
||||
'trans_scrap': '报废流水',
|
||||
'trans_repair': '维修单',
|
||||
'stock_buy': '采购库存',
|
||||
'stock_semi': '半成品库存',
|
||||
'stock_product': '成品库存',
|
||||
'stock_adjustment': '库存调整单',
|
||||
'material_base': '物料主数据',
|
||||
'purchase_request': '采购申请',
|
||||
'bom_table': 'BOM配方',
|
||||
'material_warning_settings': '物料预警设置',
|
||||
'sys_user': '用户',
|
||||
'sys_role_permission': '角色权限',
|
||||
'sys_warehouse_location': '库位',
|
||||
}
|
||||
|
||||
# ★ 业务标识字段优先级:命中即作为 target_name(比"名称"字段更能唯一定位单据)
|
||||
BUSINESS_ID_FIELDS = (
|
||||
'request_no', # 各类申请单/审批单号(APR-OUT / APR-BOR / APR-SCRAP / PUR)
|
||||
'outbound_no', # 出库单号
|
||||
'borrow_no', # 借出单号
|
||||
'bom_no', # BOM 编号
|
||||
'order_no', # 通用订单号
|
||||
'sku', # 物料 SKU
|
||||
'material_code', # 物料编码
|
||||
'serial_number', # 序列号
|
||||
)
|
||||
|
||||
# ★ 人类可读名称字段(业务标识缺失时的次选)
|
||||
NAME_FIELDS = (
|
||||
'name', 'title', 'material_name', 'product_name',
|
||||
'display_name', 'username', 'company_name',
|
||||
)
|
||||
|
||||
# ★ 忽略字段:时间戳类(无审计价值)+ 敏感字段(安全红线,绝不入库)
|
||||
IGNORE_FIELDS = {
|
||||
# 时间戳/版本:变更频繁但无业务含义
|
||||
'updated_at', 'update_time', 'modified_time', 'last_modified',
|
||||
'created_at', 'create_time', 'created_on',
|
||||
# ★ 敏感字段:密码/令牌类,任何情况下都不得写入审计详情
|
||||
'password', 'password_hash', 'hashed_password', 'salt',
|
||||
'token', 'access_token', 'refresh_token', 'secret', 'api_key',
|
||||
}
|
||||
|
||||
# 二进制/大对象字段:体积大且无审计价值
|
||||
IGNORE_FIELD_KEYWORDS = ('embedding', 'image_data', 'photo_blob')
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 序列化
|
||||
# =============================================================================
|
||||
|
||||
class _AuditJSONEncoder(json.JSONEncoder):
|
||||
"""让 datetime / Decimal 等类型可 JSON 序列化"""
|
||||
def default(self, obj):
|
||||
if isinstance(obj, (datetime, date)):
|
||||
return obj.strftime('%Y-%m-%d %H:%M:%S')
|
||||
if isinstance(obj, Decimal):
|
||||
return float(obj)
|
||||
if isinstance(obj, (bytes, bytearray)):
|
||||
try:
|
||||
return obj.decode('utf-8')
|
||||
except Exception:
|
||||
return '[二进制数据]'
|
||||
return str(obj)
|
||||
|
||||
|
||||
def _serialize_value(value):
|
||||
"""序列化值确保 JSON 兼容"""
|
||||
"""单值序列化,确保 JSON 兼容"""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
@ -26,114 +141,242 @@ def _serialize_value(value):
|
||||
return value.decode('utf-8')
|
||||
except Exception:
|
||||
return '[二进制数据]'
|
||||
if hasattr(value, '__class__') and value.__class__.__name__ in ('InstanceState', 'LazyLoader'):
|
||||
return str(value)
|
||||
if isinstance(value, Decimal):
|
||||
return float(value)
|
||||
return value
|
||||
|
||||
|
||||
def _is_audit_model(mapper):
|
||||
"""判断模型是否需要审计"""
|
||||
if hasattr(mapper.class_, 'audit_enabled') and mapper.class_.audit_enabled is False:
|
||||
return False
|
||||
def _should_skip_field(key):
|
||||
"""字段是否应被跳过(忽略名单 + 敏感 + 大对象 + 关系属性)"""
|
||||
if key in IGNORE_FIELDS:
|
||||
return True
|
||||
return any(kw in key for kw in IGNORE_FIELD_KEYWORDS)
|
||||
|
||||
AUDIT_WHITELIST = {
|
||||
'MaterialBase', 'MaterialWarningSetting',
|
||||
'StockBuy', 'StockSemi', 'StockProduct', 'StockService',
|
||||
'RepairRecord', 'TransOutbound', 'TransBorrow', 'TransReturn',
|
||||
'BomTable', 'StockTake', 'StockAdjust',
|
||||
'TransScrap',
|
||||
'SysUser', 'SysMenu', 'SysElement', 'SysRolePermission', # ★ 新增:系统管理三表纳入审计
|
||||
}
|
||||
return mapper.class_.__name__ in AUDIT_WHITELIST
|
||||
|
||||
# =============================================================================
|
||||
# 元信息提取
|
||||
# =============================================================================
|
||||
|
||||
def _is_audit_model(mapper):
|
||||
"""按表名白名单判断是否需要审计"""
|
||||
cls = mapper.class_
|
||||
# 模型级开关:显式声明 audit_enabled = False 可单独关闭
|
||||
if getattr(cls, 'audit_enabled', None) is False:
|
||||
return False
|
||||
tablename = getattr(cls, '__tablename__', None)
|
||||
return tablename in WHITELIST_TABLES
|
||||
|
||||
|
||||
def table_label(tablename):
|
||||
"""表名 → 中文显示名(未登记时回退原表名)"""
|
||||
return TABLE_LABELS.get(tablename, tablename or '未知对象')
|
||||
|
||||
|
||||
def _get_module_name(mapper):
|
||||
"""根据模型类名推断所属模块"""
|
||||
"""根据表名推断所属业务模块"""
|
||||
tablename = getattr(mapper.class_, '__tablename__', '') or ''
|
||||
name = mapper.class_.__name__
|
||||
if 'Stock' in name or 'Buy' in name:
|
||||
return '入库管理'
|
||||
if 'Outbound' in name or 'TransOut' in name:
|
||||
|
||||
if tablename in ('outbound_approval', 'trans_outbound'):
|
||||
return '出库管理'
|
||||
if 'Borrow' in name or 'Return' in name:
|
||||
if tablename in ('borrow_approval', 'trans_borrow'):
|
||||
return '借还管理'
|
||||
if tablename in ('scrap_approval', 'trans_scrap'):
|
||||
return '报废管理'
|
||||
if tablename == 'trans_repair':
|
||||
return '维修管理'
|
||||
if tablename in ('stock_buy', 'stock_semi', 'stock_product'):
|
||||
return '库存管理'
|
||||
if tablename == 'stock_adjustment':
|
||||
return '盘点管理'
|
||||
if tablename == 'purchase_request':
|
||||
return '采购管理'
|
||||
if tablename == 'bom_table':
|
||||
return 'BOM管理'
|
||||
if tablename in ('material_base', 'material_warning_settings'):
|
||||
return '基础数据'
|
||||
if tablename.startswith('sys_'):
|
||||
return '系统管理'
|
||||
|
||||
# 类名兜底
|
||||
if 'Bom' in name:
|
||||
return 'BOM管理'
|
||||
if 'StockTake' in name or 'Adjust' in name or 'Scrap' in name:
|
||||
return '盘点管理'
|
||||
if 'Repair' in name:
|
||||
return '维修管理'
|
||||
if 'SysUser' in name or 'SysMenu' in name or 'SysRole' in name:
|
||||
return '系统管理'
|
||||
if 'Material' in name:
|
||||
return '基础数据'
|
||||
return '未知模块'
|
||||
|
||||
|
||||
def _get_request_user_info():
|
||||
"""从当前 HTTP 请求中尽力提取用户信息,获取不到拉倒"""
|
||||
user_id, username, ip = None, 'system', ''
|
||||
if has_request_context():
|
||||
try:
|
||||
from flask_jwt_extended import get_jwt_identity, get_jwt
|
||||
user_id = get_jwt_identity()
|
||||
claims = get_jwt()
|
||||
username = claims.get('username', 'system')
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
ip = request.headers.get('X-Forwarded-For', '') or request.remote_addr or ''
|
||||
if ip and ',' in ip:
|
||||
ip = ip.split(',')[0].strip()
|
||||
except Exception:
|
||||
pass
|
||||
return user_id, username, ip
|
||||
def _extract_target_id(target):
|
||||
"""提取被操作数据的主键"""
|
||||
for field in ('id', 'stock_id', 'uuid', 'bom_no'):
|
||||
if hasattr(target, field):
|
||||
val = getattr(target, field, None)
|
||||
if val is not None:
|
||||
return str(val)
|
||||
return ''
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 核心:监听器内部直接创建并挂载日志
|
||||
# ============================================================
|
||||
|
||||
def _create_audit_log(session, mapper, target, action, details):
|
||||
def _get_target_name(target, tablename, target_id):
|
||||
"""
|
||||
监听器内部直接实例化 AuditLog 并加入当前事务 session。
|
||||
由 SQLAlchemy 生命周期保证随主事务一同提交或回滚。
|
||||
★ 业务友好的 target_name 生成(Phase 2 核心)
|
||||
|
||||
优先级:
|
||||
1. 业务标识字段(request_no / outbound_no / borrow_no / sku / bom_no …)
|
||||
—— 单据号或物料编码,最能唯一定位且用户可读
|
||||
2. 人类可读名称字段(name / material_name / username …)
|
||||
3. 兜底:「中文表名 - 业务号或ID」
|
||||
|
||||
相比改造前的 `{tablename} ID:{id}`,用户看到的是
|
||||
「出库申请单 - APR-OUT-20260805-1550-0005」这类可理解的对象描述。
|
||||
"""
|
||||
# 1) 业务标识优先
|
||||
for field in BUSINESS_ID_FIELDS:
|
||||
val = getattr(target, field, None)
|
||||
if val:
|
||||
return str(val)
|
||||
|
||||
# 2) 名称字段次之
|
||||
for field in NAME_FIELDS:
|
||||
val = getattr(target, field, None)
|
||||
if val:
|
||||
return str(val)
|
||||
|
||||
# 3) 关联对象名称(如 stock 记录 -> base.name)
|
||||
base = getattr(target, 'base', None)
|
||||
if base is not None:
|
||||
base_name = getattr(base, 'name', None)
|
||||
if base_name:
|
||||
return str(base_name)
|
||||
|
||||
# 4) 中文表名 + 业务号/ID 兜底
|
||||
label = table_label(tablename)
|
||||
return f"{label} - {target_id}" if target_id else label
|
||||
|
||||
|
||||
def _get_request_user_info():
|
||||
"""从当前 HTTP 请求中提取操作人信息"""
|
||||
info = {
|
||||
'user_id': None,
|
||||
'username': 'system',
|
||||
'display_name': '',
|
||||
'ip': '',
|
||||
'method': '',
|
||||
'url': '',
|
||||
}
|
||||
if not has_request_context():
|
||||
return info
|
||||
|
||||
try:
|
||||
from flask_jwt_extended import get_jwt_identity, get_jwt
|
||||
identity = get_jwt_identity()
|
||||
if identity is not None:
|
||||
info['user_id'] = str(identity)
|
||||
claims = get_jwt()
|
||||
info['username'] = claims.get('username') or 'system'
|
||||
info['display_name'] = claims.get('display_name') or ''
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
ip = request.headers.get('X-Forwarded-For', '') or request.remote_addr or ''
|
||||
if ip and ',' in ip:
|
||||
ip = ip.split(',')[0].strip()
|
||||
info['ip'] = ip
|
||||
info['method'] = request.method or ''
|
||||
info['url'] = (request.path or '')[:500]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return info
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 核心:写入审计日志
|
||||
# =============================================================================
|
||||
|
||||
def _create_audit_log(connection, mapper, target, action, details):
|
||||
"""
|
||||
使用事件回调传入的 connection 直接 INSERT。
|
||||
|
||||
★ 为什么不沿用改造前的 `session.add(log)`:
|
||||
该函数的第二个参数来自 SQLAlchemy 事件回调,类型是 **Connection** 而非
|
||||
Session,Connection 没有 .add() 方法 —— 改造前每次调用都会抛
|
||||
AttributeError 并被 except 吞掉,导致监听器即使注册成功也永远写不进数据。
|
||||
直接 connection.execute 还能保证与主事务同生共死(业务回滚则日志一并回滚)。
|
||||
"""
|
||||
try:
|
||||
from app.models.audit import AuditLog
|
||||
tablename = getattr(target.__class__, '__tablename__', '') or ''
|
||||
if tablename in ('audit_logs', 'sys_log'):
|
||||
return # 防递归(白名单已排除,此处为双保险)
|
||||
|
||||
user_id, username, ip = _get_request_user_info()
|
||||
target_id = _extract_target_id(target)
|
||||
target_name = _get_target_name(target, tablename, target_id)
|
||||
user = _get_request_user_info()
|
||||
module = _get_module_name(mapper)
|
||||
|
||||
target_id = None
|
||||
if hasattr(target, 'id'):
|
||||
target_id = target.id
|
||||
elif hasattr(target, 'stock_id'):
|
||||
target_id = target.stock_id
|
||||
elif hasattr(target, 'bom_no'):
|
||||
target_id = target.bom_no
|
||||
|
||||
log = AuditLog(
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
action=action,
|
||||
module=module,
|
||||
target_id=str(target_id) if target_id else '0',
|
||||
details=details,
|
||||
ip_address=ip
|
||||
)
|
||||
session.add(log)
|
||||
sql = text("""
|
||||
INSERT INTO audit_logs
|
||||
(user_id, username, display_name, action, module,
|
||||
target_id, target_name, details, ip_address, method, url, created_at)
|
||||
VALUES
|
||||
(:user_id, :username, :display_name, :action, :module,
|
||||
:target_id, :target_name, cast(:details AS jsonb), :ip_address,
|
||||
:method, :url, :created_at)
|
||||
""")
|
||||
|
||||
connection.execute(sql, {
|
||||
'user_id': user['user_id'],
|
||||
'username': user['username'],
|
||||
'display_name': user['display_name'],
|
||||
'action': action,
|
||||
'module': module,
|
||||
'target_id': target_id,
|
||||
'target_name': target_name[:200],
|
||||
'details': json.dumps(details or {}, cls=_AuditJSONEncoder),
|
||||
'ip_address': user['ip'],
|
||||
'method': user['method'],
|
||||
'url': user['url'],
|
||||
# ★ 显式写入北京时间,与全系统时间口径一致
|
||||
'created_at': beijing_time(),
|
||||
})
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Audit log auto-creation failed: {e}")
|
||||
try:
|
||||
current_app.logger.error(f"Audit log auto-creation failed: {e}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _collect_snapshot(target):
|
||||
"""收集对象全字段快照(跳过忽略字段与关系属性)"""
|
||||
snap = {}
|
||||
state = inspect(target)
|
||||
for attr in state.attrs:
|
||||
key = attr.key
|
||||
if _should_skip_field(key):
|
||||
continue
|
||||
# 跳过关系属性(如 .base / .material),它们不是列
|
||||
if hasattr(attr, 'property') and hasattr(attr.property, 'direction'):
|
||||
continue
|
||||
snap[key] = _serialize_value(getattr(target, key, None))
|
||||
return snap
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 事件监听器
|
||||
# =============================================================================
|
||||
|
||||
def _ensure_bound_once():
|
||||
"""每次事件触发前补绑延迟导入的模型(已全部绑定后为一次集合比对,开销可忽略)"""
|
||||
if _BOUND_TABLES < WHITELIST_TABLES:
|
||||
ensure_audit_listeners()
|
||||
|
||||
|
||||
def before_update_listener(mapper, connection, target):
|
||||
"""UPDATE 事件:抓取字段变更明细"""
|
||||
if not _is_audit_model(mapper): return
|
||||
|
||||
# ★★★ 关键修复:系统初始化(PermissionService.init_all_menus 等)时,
|
||||
# username='system' 且 has_request_context()=False,
|
||||
# 这类非用户发起的变更不应产生审计日志,直接跳过。
|
||||
"""UPDATE:仅记录真正发生变化的字段"""
|
||||
_ensure_bound_once()
|
||||
if not _is_audit_model(mapper):
|
||||
return
|
||||
# ★ 非 HTTP 请求上下文(系统初始化 / 后台定时任务)不产生审计日志
|
||||
if not has_request_context():
|
||||
return
|
||||
|
||||
@ -141,87 +384,128 @@ def before_update_listener(mapper, connection, target):
|
||||
state = inspect(target)
|
||||
changes = {}
|
||||
for attr in state.attrs:
|
||||
if attr.key in IGNORE_FIELDS: continue
|
||||
if 'embedding' in attr.key: continue
|
||||
if attr.history.has_changes():
|
||||
old_val = attr.history.deleted[0] if attr.history.deleted else None
|
||||
new_val = attr.history.added[0] if attr.history.added else None
|
||||
changes[attr.key] = {
|
||||
'old': _serialize_value(old_val),
|
||||
'new': _serialize_value(new_val)
|
||||
}
|
||||
key = attr.key
|
||||
if _should_skip_field(key):
|
||||
continue
|
||||
if hasattr(attr, 'property') and hasattr(attr.property, 'direction'):
|
||||
continue
|
||||
if not attr.history.has_changes():
|
||||
continue
|
||||
|
||||
old_val = attr.history.deleted[0] if attr.history.deleted else None
|
||||
new_val = attr.history.added[0] if attr.history.added else None
|
||||
old_s = _serialize_value(old_val)
|
||||
new_s = _serialize_value(new_val)
|
||||
if old_s != new_s:
|
||||
changes[key] = {'old': old_s, 'new': new_s}
|
||||
|
||||
if changes:
|
||||
_create_audit_log(connection, mapper, target, 'update', {'changes': changes})
|
||||
_create_audit_log(connection, mapper, target, 'UPDATE', {'changes': changes})
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Audit Update Error: {e}")
|
||||
try:
|
||||
current_app.logger.error(f"Audit Update Error: {e}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def before_delete_listener(mapper, connection, target):
|
||||
"""DELETE 事件:抓取被删除对象的完整快照"""
|
||||
if not _is_audit_model(mapper): return
|
||||
# ★★★ 关键修复:非 HTTP 请求上下文下的初始化操作(如 PermissionService)
|
||||
if not has_request_context(): return
|
||||
"""DELETE:记录被删除对象的完整快照"""
|
||||
_ensure_bound_once()
|
||||
if not _is_audit_model(mapper):
|
||||
return
|
||||
if not has_request_context():
|
||||
return
|
||||
try:
|
||||
state = inspect(target)
|
||||
snap = {}
|
||||
for attr in state.attrs:
|
||||
if attr.key in IGNORE_FIELDS: continue
|
||||
if 'embedding' in attr.key: continue
|
||||
val = getattr(target, attr.key, None)
|
||||
snap[attr.key] = _serialize_value(val)
|
||||
_create_audit_log(connection, mapper, target, 'delete', {'deleted_snapshot': snap})
|
||||
snap = _collect_snapshot(target)
|
||||
_create_audit_log(connection, mapper, target, 'DELETE',
|
||||
{'deleted_snapshot': snap})
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Audit Delete Error: {e}")
|
||||
try:
|
||||
current_app.logger.error(f"Audit Delete Error: {e}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def after_insert_listener(mapper, connection, target):
|
||||
"""INSERT 事件:抓取新增对象的完整快照"""
|
||||
if not _is_audit_model(mapper): return
|
||||
# ★★★ 关键修复:非 HTTP 请求上下文下的初始化操作(如 PermissionService)
|
||||
if not has_request_context(): return
|
||||
"""INSERT:记录新增对象的完整快照"""
|
||||
_ensure_bound_once()
|
||||
if not _is_audit_model(mapper):
|
||||
return
|
||||
if not has_request_context():
|
||||
return
|
||||
try:
|
||||
state = inspect(target)
|
||||
snap = {}
|
||||
for attr in state.attrs:
|
||||
if attr.key in IGNORE_FIELDS: continue
|
||||
if 'embedding' in attr.key: continue
|
||||
val = getattr(target, attr.key, None)
|
||||
snap[attr.key] = _serialize_value(val)
|
||||
_create_audit_log(connection, mapper, target, 'insert', {'created': snap})
|
||||
except Exception:
|
||||
pass
|
||||
snap = _collect_snapshot(target)
|
||||
_create_audit_log(connection, mapper, target, 'CREATE', {'created': snap})
|
||||
except Exception as e:
|
||||
try:
|
||||
current_app.logger.error(f"Audit Insert Error: {e}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 注册函数
|
||||
# ============================================================
|
||||
# =============================================================================
|
||||
# 注册
|
||||
# =============================================================================
|
||||
|
||||
# 已绑定的表名,避免重复 event.listen(重复监听会导致同一次变更写多条日志)
|
||||
_BOUND_TABLES = set()
|
||||
|
||||
|
||||
def register_audit_listeners(db):
|
||||
"""向所有需要审计的模型注册事件监听器"""
|
||||
from app.models import (
|
||||
MaterialBase, MaterialWarningSetting,
|
||||
StockBuy, StockSemi, StockProduct, StockService,
|
||||
RepairRecord, TransOutbound, TransBorrow, TransReturn,
|
||||
BomTable, StockTake, StockAdjust,
|
||||
TransScrap, SysUser
|
||||
)
|
||||
"""
|
||||
按白名单向已完成映射的模型注册事件监听器,返回本次**新增**绑定的模型数。
|
||||
|
||||
audit_models = [
|
||||
MaterialBase, MaterialWarningSetting,
|
||||
StockBuy, StockSemi, StockProduct, StockService,
|
||||
RepairRecord, TransOutbound, TransBorrow, TransReturn,
|
||||
BomTable, StockTake, StockAdjust,
|
||||
TransScrap, SysUser
|
||||
]
|
||||
★ 为什么需要"惰性补绑"(见 ensure_audit_listeners):
|
||||
本项目大量模型是在**函数体内延迟导入**的(31 处,例如
|
||||
app/api/v1/scrap.py 内部 `from app.models.scrap_approval import ScrapApproval`),
|
||||
因此 create_app() 执行完毕时 scrap_approval 等表仍未进入 db.metadata。
|
||||
一次性注册必然漏掉它们,且不留任何报错痕迹。
|
||||
解决办法:启动时先绑能绑的,之后每次写操作前调 ensure_audit_listeners()
|
||||
补绑新出现的表 —— 代价极小(集合比对),却能覆盖全部白名单。
|
||||
|
||||
audit_models = [m for m in audit_models if m is not None]
|
||||
★ 同时修复改造前的两个致命缺陷:
|
||||
1. 原实现从 app.models 批量 import 多个未必导出的模型,ImportError 被
|
||||
上层 try/except 吞掉,造成"注册失败但无感知";
|
||||
2. 原事件回调调用 Connection.add()(Connection 没有该方法),
|
||||
写日志必然抛 AttributeError 并被静默吞掉。
|
||||
现改为按 **表名** 从 db.metadata 取模型,不依赖 app.models 的导出。
|
||||
"""
|
||||
count = 0
|
||||
for model in audit_models:
|
||||
for tablename in list(WHITELIST_TABLES):
|
||||
if tablename in _BOUND_TABLES:
|
||||
continue
|
||||
mapper = next((m for m in db.Model.registry.mappers
|
||||
if getattr(m.class_, '__tablename__', None) == tablename), None)
|
||||
if mapper is None:
|
||||
continue # 尚未映射(延迟导入的模型),等待后续惰性补绑
|
||||
try:
|
||||
model = mapper.class_
|
||||
event.listen(model, 'before_update', before_update_listener, propagate=True)
|
||||
event.listen(model, 'before_delete', before_delete_listener, propagate=True)
|
||||
event.listen(model, 'after_insert', after_insert_listener, propagate=True)
|
||||
_BOUND_TABLES.add(tablename)
|
||||
count += 1
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
try:
|
||||
current_app.logger.warning(f"审计监听器绑定失败 [{tablename}]: {e}")
|
||||
except Exception:
|
||||
pass
|
||||
return count
|
||||
|
||||
|
||||
def ensure_audit_listeners():
|
||||
"""
|
||||
惰性补绑:为「白名单中、已映射、但尚未绑定」的表补注册监听器。
|
||||
|
||||
在每次审计事件触发前调用。未绑定的表说明其模型刚被延迟导入,
|
||||
此时补绑后,该表的后续变更即可被正常审计。
|
||||
|
||||
调用开销:一次集合差集运算;已全部绑定后立即返回。
|
||||
"""
|
||||
if _BOUND_TABLES >= WHITELIST_TABLES:
|
||||
return 0
|
||||
try:
|
||||
from app.extensions import db
|
||||
return register_audit_listeners(db)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
Reference in New Issue
Block a user