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:
yueli
2026-09-10 14:16:27 +08:00
parent 1ef9ae4ad9
commit 4808a48594
18 changed files with 513 additions and 527 deletions

View File

@ -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