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

@ -22,16 +22,14 @@ def create_app():
cors.init_app(app, resources={r"/*": {"origins": "*"}}, supports_credentials=True) cors.init_app(app, resources={r"/*": {"origins": "*"}}, supports_credentials=True)
# ========================================================= # =========================================================
# 1.1 注册全局审计日志监听器 # 1.1 [已迁移] 审计日志监听器注册
# ========================================================= #
with app.app_context(): # 原注册的是 app/utils/audit_events.py 的全局监听器(监听 db.Model、
try: # 无白名单、无请求上下文守卫),已停用;审计统一由
from app.utils.audit_events import register_audit_events # app/core/audit_listener.py 接管。
register_audit_events(db) #
print("✅ 审计事件监听器注册成功") # ★ 注册动作已移至本函数末尾见「5. 注册审计监听器」),
except Exception as e: # 因为此处模型尚未全部映射,会导致 scrap_approval 等表被静默漏掉。
print(f"⚠️ 审计事件监听器注册失败: {e}")
# ========================================================= # =========================================================
# 2. 注册蓝图 (Blueprints) # 2. 注册蓝图 (Blueprints)
# --------------------------------------------------------- # ---------------------------------------------------------
@ -286,6 +284,15 @@ def create_app():
from app.models.system import SysUser, SysLog, SysMenu, SysElement, SysRolePermission, SysWarehouseLocation from app.models.system import SysUser, SysLog, SysMenu, SysElement, SysRolePermission, SysWarehouseLocation
# 确保借还模型被加载 # 确保借还模型被加载
from app.models.transaction import TransBorrow, TransRepair, TransScrap from app.models.transaction import TransBorrow, TransRepair, TransScrap
# ★ 审批单模型(原仅在函数体内延迟导入,会导致审计监听器漏绑)
from app.models.outbound import OutboundApproval
from app.models.borrow import BorrowApproval
from app.models.scrap_approval import ScrapApproval
# ★ 其余延迟导入的业务模型,一并预加载以便审计覆盖
from app.models.bom import BomTable
from app.models.inbound.service import StockService
from app.models.stock.adjustment import StockAdjustment
from app.models.purchase import PurchaseRequest
# 首次运行时可取消注释自动建表 (但在生产环境建议使用 flask db upgrade) # 首次运行时可取消注释自动建表 (但在生产环境建议使用 flask db upgrade)
# db.create_all() # db.create_all()
@ -296,7 +303,24 @@ def create_app():
print(f"⚠️ 模型预加载发生未知错误: {e}") print(f"⚠️ 模型预加载发生未知错误: {e}")
# ========================================================= # =========================================================
# 4. 全局异常处理(面向生产:不泄露堆栈 # 5. 注册审计监听器(必须放在最后,等所有模型完成映射
#
# ★ 为什么不能放在 create_app 开头(紧邻 db.init_app
# 本函数早期只有 17/18 张白名单表完成映射 —— scrap_approval 等模型是在
# 蓝图注册阶段才被导入的,注册过早会导致这些表被静默漏掉
# register_audit_listeners 遍历 db.metadata.tables未映射即不可见
# 放在模型预加载之后可确保白名单全部就位。
# =========================================================
with app.app_context():
try:
from app.core.audit_listener import register_audit_listeners
count = register_audit_listeners(db)
print(f"✅ 审计监听器注册成功,共绑定 {count} 个模型")
except Exception as e:
print(f"⚠️ 审计监听器注册失败: {e}")
# =========================================================
# 6. 全局异常处理(面向生产:不泄露堆栈)
# ========================================================= # =========================================================
import logging import logging
import traceback import traceback

View File

@ -20,7 +20,7 @@ from app.extensions import db
# Service & Decorators # Service & Decorators
# ============================================================================== # ==============================================================================
from app.services.auth_service import AuthService from app.services.auth_service import AuthService
from app.utils.decorators import permission_required, audit_log from app.utils.decorators import permission_required
auth_bp = Blueprint('auth', __name__) auth_bp = Blueprint('auth', __name__)
@ -135,11 +135,6 @@ def refresh():
@auth_bp.route('/user/create', methods=['POST']) @auth_bp.route('/user/create', methods=['POST'])
@jwt_required() @jwt_required()
@permission_required('system_user:operation') @permission_required('system_user:operation')
@audit_log(
module='用户管理',
action='新增',
get_target_name_fn=lambda: request.get_json().get('username') if request.get_json() else None
)
def create_user(): def create_user():
try: try:
data = request.get_json() data = request.get_json()
@ -225,11 +220,6 @@ def batch_create_user():
@auth_bp.route('/user/<int:user_id>', methods=['PUT']) @auth_bp.route('/user/<int:user_id>', methods=['PUT'])
@jwt_required() @jwt_required()
@permission_required('system_user:operation') @permission_required('system_user:operation')
@audit_log(
module='用户管理',
action='修改',
get_target_id_fn=lambda: request.view_args.get('user_id')
)
def update_user(user_id): def update_user(user_id):
try: try:
data = request.get_json() data = request.get_json()
@ -288,11 +278,6 @@ def get_users():
@auth_bp.route('/user/<int:user_id>', methods=['DELETE']) @auth_bp.route('/user/<int:user_id>', methods=['DELETE'])
@jwt_required() @jwt_required()
@permission_required('system_user:operation') @permission_required('system_user:operation')
@audit_log(
module='用户管理',
action='删除',
get_target_id_fn=lambda: request.view_args.get('user_id')
)
def delete_user(user_id): def delete_user(user_id):
try: try:
claims = get_jwt() claims = get_jwt()

View File

@ -6,7 +6,7 @@ from app.models.base import MaterialBase
from app.models.bom import BomTable from app.models.bom import BomTable
from app.extensions import db from app.extensions import db
from flask_jwt_extended import jwt_required, get_jwt from flask_jwt_extended import jwt_required, get_jwt
from app.utils.decorators import permission_required, audit_log from app.utils.decorators import permission_required
from app.services.auth_service import AuthService from app.services.auth_service import AuthService
bom_bp = Blueprint('bom', __name__) bom_bp = Blueprint('bom', __name__)
@ -111,11 +111,6 @@ def get_bom_summary():
@bom_bp.route('/status', methods=['POST']) @bom_bp.route('/status', methods=['POST'])
@jwt_required() @jwt_required()
@permission_required('bom_manage:operation') @permission_required('bom_manage:operation')
@audit_log(
module='BOM管理',
action='更新状态',
get_target_name_fn=lambda: (request.get_json() or {}).get('bom_no')
)
def update_bom_status(): def update_bom_status():
"""仅切换某 BOM 版本(整组)的启用/停用状态。""" """仅切换某 BOM 版本(整组)的启用/停用状态。"""
try: try:
@ -140,11 +135,6 @@ def update_bom_status():
@bom_bp.route('/archive', methods=['POST']) @bom_bp.route('/archive', methods=['POST'])
@jwt_required() @jwt_required()
@permission_required('bom_manage:operation') @permission_required('bom_manage:operation')
@audit_log(
module='BOM管理',
action='归档/取消归档',
get_target_name_fn=lambda: (request.get_json() or {}).get('bom_no')
)
def update_bom_archive(): def update_bom_archive():
"""切换某 BOM 版本(整组)的归档状态。""" """切换某 BOM 版本(整组)的归档状态。"""
try: try:
@ -194,11 +184,6 @@ def get_bom_detail(bom_no):
@bom_bp.route('/save', methods=['POST']) @bom_bp.route('/save', methods=['POST'])
@jwt_required() @jwt_required()
@permission_required('bom_manage:operation') @permission_required('bom_manage:operation')
@audit_log(
module='BOM管理',
action='新增',
get_target_name_fn=lambda: request.get_json().get('bom_no') if request.get_json() else None
)
def save_bom(): def save_bom():
"""保存或更新 BOM 配方(支持自定义 bom_no 和 多版本)""" """保存或更新 BOM 配方(支持自定义 bom_no 和 多版本)"""
try: try:
@ -282,11 +267,6 @@ def get_bom_with_stock_by_no(bom_no):
@bom_bp.route('/<path:bom_no>', methods=['DELETE']) @bom_bp.route('/<path:bom_no>', methods=['DELETE'])
@jwt_required() @jwt_required()
@permission_required('bom_manage:operation') @permission_required('bom_manage:operation')
@audit_log(
module='BOM管理',
action='删除',
get_target_id_fn=lambda: request.view_args.get('bom_no')
)
def delete_bom(bom_no): def delete_bom(bom_no):
""" """
根据 BOM 编号删除 根据 BOM 编号删除
@ -349,11 +329,6 @@ def get_bom(parent_id):
@bom_bp.route('', methods=['POST']) @bom_bp.route('', methods=['POST'])
@jwt_required() @jwt_required()
@permission_required('bom_manage:operation') @permission_required('bom_manage:operation')
@audit_log(
module='BOM管理',
action='新增',
get_target_name_fn=lambda: request.get_json().get('bom_no') if request.get_json() else None
)
def save_bom_legacy(): def save_bom_legacy():
try: try:
req_data = request.get_json() req_data = request.get_json()

View File

@ -3,7 +3,7 @@
from flask import Blueprint, request, jsonify, send_file, g, current_app from flask import Blueprint, request, jsonify, send_file, g, current_app
from app.extensions import db, beijing_time from app.extensions import db, beijing_time
from app.services.inbound.base_service import MaterialBaseService from app.services.inbound.base_service import MaterialBaseService
from app.utils.decorators import login_required, permission_required, audit_log from app.utils.decorators import login_required, permission_required
from app.models.base import MaterialBase, MaterialWarningSetting from app.models.base import MaterialBase, MaterialWarningSetting
import traceback import traceback
import datetime import datetime
@ -239,11 +239,6 @@ def export_data():
# ============================================================================== # ==============================================================================
@inbound_base_bp.route('/', methods=['POST']) @inbound_base_bp.route('/', methods=['POST'])
@permission_required('material_list:operation') @permission_required('material_list:operation')
@audit_log(
module='基础信息管理',
action='新增',
get_target_name_fn=lambda: request.get_json().get('name') if request.get_json() else None
)
def create(): def create():
try: try:
data = request.get_json() data = request.get_json()
@ -302,12 +297,6 @@ def create():
# ============================================================================== # ==============================================================================
@inbound_base_bp.route('/<int:id>', methods=['PUT']) @inbound_base_bp.route('/<int:id>', methods=['PUT'])
@permission_required('material_list:operation') @permission_required('material_list:operation')
@audit_log(
module='基础信息管理',
action='修改',
get_target_id_fn=lambda: request.view_args.get('id'),
get_target_name_fn=lambda: request.get_json().get('name') if request.get_json() else None
)
def update(id): def update(id):
try: try:
data = request.get_json() data = request.get_json()
@ -359,11 +348,6 @@ def update(id):
# ============================================================================== # ==============================================================================
@inbound_base_bp.route('/<int:id>', methods=['DELETE']) @inbound_base_bp.route('/<int:id>', methods=['DELETE'])
@permission_required('material_list:operation') @permission_required('material_list:operation')
@audit_log(
module='基础信息管理',
action='删除',
get_target_id_fn=lambda: request.view_args.get('id')
)
def delete(id): def delete(id):
try: try:
material_name = MaterialBaseService.delete_material(id) material_name = MaterialBaseService.delete_material(id)

View File

@ -1,6 +1,6 @@
from flask import Blueprint, request, jsonify from flask import Blueprint, request, jsonify
from app.services.inbound.buy_service import BuyInboundService from app.services.inbound.buy_service import BuyInboundService
from app.utils.decorators import permission_required, audit_log from app.utils.decorators import permission_required
import traceback import traceback
inbound_buy_bp = Blueprint('stock_buy', __name__) inbound_buy_bp = Blueprint('stock_buy', __name__)
@ -124,11 +124,6 @@ def get_list():
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@inbound_buy_bp.route('/submit', methods=['POST']) @inbound_buy_bp.route('/submit', methods=['POST'])
@permission_required('inbound_buy:operation') @permission_required('inbound_buy:operation')
@audit_log(
module='采购入库',
action='新增',
get_target_name_fn=lambda: request.get_json().get('material_name') if request.get_json() else None
)
def submit(): def submit():
try: try:
data = request.get_json() data = request.get_json()
@ -172,12 +167,6 @@ def submit():
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@inbound_buy_bp.route('/<int:id>', methods=['PUT']) @inbound_buy_bp.route('/<int:id>', methods=['PUT'])
@permission_required('inbound_buy:operation') @permission_required('inbound_buy:operation')
@audit_log(
module='采购入库',
action='修改',
get_target_id_fn=lambda: request.view_args.get('id'),
get_target_name_fn=lambda: request.get_json().get('material_name') if request.get_json() else None
)
def update_buy(id): def update_buy(id):
try: try:
data = request.get_json() data = request.get_json()
@ -245,11 +234,6 @@ def update_buy(id):
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@inbound_buy_bp.route('/<int:id>', methods=['DELETE']) @inbound_buy_bp.route('/<int:id>', methods=['DELETE'])
@permission_required('inbound_buy:operation') @permission_required('inbound_buy:operation')
@audit_log(
module='采购入库',
action='删除',
get_target_id_fn=lambda: request.view_args.get('id')
)
def delete_buy(id): def delete_buy(id):
try: try:
material_name = BuyInboundService.delete_inbound(id) material_name = BuyInboundService.delete_inbound(id)

View File

@ -1,7 +1,7 @@
# inventory-backend/app/api/v1/inbound/product.py # inventory-backend/app/api/v1/inbound/product.py
from flask import Blueprint, request, jsonify from flask import Blueprint, request, jsonify
from app.services.inbound.product_service import ProductInboundService from app.services.inbound.product_service import ProductInboundService
from app.utils.decorators import permission_required, audit_log from app.utils.decorators import permission_required
from app.models.base import MaterialBase from app.models.base import MaterialBase
from app.services.track_query_service import lookup_product from app.services.track_query_service import lookup_product
import traceback import traceback
@ -108,11 +108,6 @@ def get_list():
@inbound_product_bp.route('/submit', methods=['POST']) @inbound_product_bp.route('/submit', methods=['POST'])
@permission_required('inbound_product:operation') @permission_required('inbound_product:operation')
@audit_log(
module='成品入库',
action='新增',
get_target_name_fn=lambda: request.get_json().get('material_name') if request.get_json() else None
)
def submit(): def submit():
try: try:
data = request.get_json() data = request.get_json()
@ -191,12 +186,6 @@ def track_lookup():
@inbound_product_bp.route('/<int:id>', methods=['PUT']) @inbound_product_bp.route('/<int:id>', methods=['PUT'])
@permission_required('inbound_product:operation') @permission_required('inbound_product:operation')
@audit_log(
module='成品入库',
action='修改',
get_target_id_fn=lambda: request.view_args.get('id'),
get_target_name_fn=lambda: request.get_json().get('material_name') if request.get_json() else None
)
def update(id): def update(id):
try: try:
data = request.get_json() data = request.get_json()
@ -244,11 +233,6 @@ def update(id):
@inbound_product_bp.route('/<int:id>', methods=['DELETE']) @inbound_product_bp.route('/<int:id>', methods=['DELETE'])
@permission_required('inbound_product:operation') @permission_required('inbound_product:operation')
@audit_log(
module='成品入库',
action='删除',
get_target_id_fn=lambda: request.view_args.get('id')
)
def delete(id): def delete(id):
try: try:
material_name = ProductInboundService.delete_inbound(id) material_name = ProductInboundService.delete_inbound(id)

View File

@ -1,7 +1,7 @@
# inventory-backend/app/api/v1/inbound/repair.py # inventory-backend/app/api/v1/inbound/repair.py
from flask import Blueprint, request, jsonify from flask import Blueprint, request, jsonify
from app.services.inbound.repair_service import RepairInboundService from app.services.inbound.repair_service import RepairInboundService
from app.utils.decorators import permission_required, audit_log from app.utils.decorators import permission_required
import traceback import traceback
inbound_repair_bp = Blueprint('inbound_repair', __name__) inbound_repair_bp = Blueprint('inbound_repair', __name__)
@ -35,11 +35,6 @@ def get_list():
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@inbound_repair_bp.route('/submit', methods=['POST']) @inbound_repair_bp.route('/submit', methods=['POST'])
@permission_required('inbound_repair:add') @permission_required('inbound_repair:add')
@audit_log(
module='维修管理',
action='新增',
get_target_name_fn=lambda: request.get_json().get('repair_no') if request.get_json() else None
)
def create(): def create():
try: try:
data = request.get_json() data = request.get_json()
@ -55,11 +50,6 @@ def create():
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@inbound_repair_bp.route('/<int:id>', methods=['PUT']) @inbound_repair_bp.route('/<int:id>', methods=['PUT'])
@permission_required('inbound_repair:edit') @permission_required('inbound_repair:edit')
@audit_log(
module='维修管理',
action='更新',
get_target_name_fn=lambda: f"维修单ID:{request.view_args.get('id')}"
)
def update(id): def update(id):
try: try:
data = request.get_json() data = request.get_json()
@ -77,11 +67,6 @@ def update(id):
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@inbound_repair_bp.route('/update-status', methods=['POST']) @inbound_repair_bp.route('/update-status', methods=['POST'])
@permission_required('inbound_repair:edit') @permission_required('inbound_repair:edit')
@audit_log(
module='维修管理',
action='更新状态',
get_target_name_fn=lambda: f"维修单ID:{request.get_json().get('id')}"
)
def update_status(): def update_status():
try: try:
data = request.get_json() data = request.get_json()
@ -105,11 +90,6 @@ def update_status():
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@inbound_repair_bp.route('/<int:id>', methods=['DELETE']) @inbound_repair_bp.route('/<int:id>', methods=['DELETE'])
@permission_required('inbound_repair:delete') @permission_required('inbound_repair:delete')
@audit_log(
module='维修管理',
action='删除',
get_target_name_fn=lambda: f"维修单ID:{request.view_args.get('id')}"
)
def delete(id): def delete(id):
try: try:
success = RepairInboundService.delete(id) success = RepairInboundService.delete(id)
@ -134,4 +114,4 @@ def get_detail(id):
return jsonify({'code': 200, 'msg': 'success', 'data': result}) return jsonify({'code': 200, 'msg': 'success', 'data': result})
except Exception as e: except Exception as e:
traceback.print_exc() traceback.print_exc()
return jsonify({'code': 500, 'msg': str(e)}), 500 return jsonify({'code': 500, 'msg': str(e)}), 500

View File

@ -1,7 +1,7 @@
# inventory-backend/app/api/v1/inbound/semi.py # inventory-backend/app/api/v1/inbound/semi.py
from flask import Blueprint, request, jsonify from flask import Blueprint, request, jsonify
from app.services.inbound.semi_service import SemiInboundService from app.services.inbound.semi_service import SemiInboundService
from app.utils.decorators import permission_required, audit_log from app.utils.decorators import permission_required
from app.models.base import MaterialBase from app.models.base import MaterialBase
from app.services.track_query_service import lookup_product from app.services.track_query_service import lookup_product
import traceback import traceback
@ -112,11 +112,6 @@ def get_list():
@inbound_semi_bp.route('/submit', methods=['POST']) @inbound_semi_bp.route('/submit', methods=['POST'])
@permission_required('inbound_semi:operation') @permission_required('inbound_semi:operation')
@audit_log(
module='半成品入库',
action='新增',
get_target_name_fn=lambda: request.get_json().get('material_name') if request.get_json() else None
)
def submit(): def submit():
try: try:
data = request.get_json() data = request.get_json()
@ -194,12 +189,6 @@ def track_lookup():
@inbound_semi_bp.route('/<int:id>', methods=['PUT']) @inbound_semi_bp.route('/<int:id>', methods=['PUT'])
@permission_required('inbound_semi:operation') @permission_required('inbound_semi:operation')
@audit_log(
module='半成品入库',
action='修改',
get_target_id_fn=lambda: request.view_args.get('id'),
get_target_name_fn=lambda: request.get_json().get('material_name') if request.get_json() else None
)
def update_semi(id): def update_semi(id):
try: try:
data = request.get_json() data = request.get_json()
@ -255,11 +244,6 @@ def update_semi(id):
@inbound_semi_bp.route('/<int:id>', methods=['DELETE']) @inbound_semi_bp.route('/<int:id>', methods=['DELETE'])
@permission_required('inbound_semi:operation') @permission_required('inbound_semi:operation')
@audit_log(
module='半成品入库',
action='删除',
get_target_id_fn=lambda: request.view_args.get('id')
)
def delete_semi(id): def delete_semi(id):
try: try:
material_name = SemiInboundService.delete_inbound(id) material_name = SemiInboundService.delete_inbound(id)

View File

@ -3,7 +3,7 @@ from flask import request, jsonify, current_app
from flask_jwt_extended import jwt_required from flask_jwt_extended import jwt_required
from . import inbound_bp from . import inbound_bp
from app.services.inbound.service_service import ServiceService from app.services.inbound.service_service import ServiceService
from app.utils.decorators import role_required, permission_required, audit_log from app.utils.decorators import role_required, permission_required
import traceback import traceback
@ -110,11 +110,6 @@ def get_service_list():
@inbound_bp.route('/service', methods=['POST']) @inbound_bp.route('/service', methods=['POST'])
@permission_required('inbound_service:operation') @permission_required('inbound_service:operation')
@audit_log(
module='服务权益',
action='新增',
get_target_name_fn=lambda: request.get_json().get('material_name') if request.get_json() else None
)
def create_service(): def create_service():
"""创建服务权益""" """创建服务权益"""
data = request.get_json() data = request.get_json()
@ -191,12 +186,6 @@ def get_service(service_id):
@inbound_bp.route('/service/<int:service_id>', methods=['PUT']) @inbound_bp.route('/service/<int:service_id>', methods=['PUT'])
@permission_required('inbound_service:operation') @permission_required('inbound_service:operation')
@audit_log(
module='服务权益',
action='修改',
get_target_id_fn=lambda: request.view_args.get('service_id'),
get_target_name_fn=lambda: request.get_json().get('material_name') if request.get_json() else None
)
def update_service(service_id): def update_service(service_id):
"""更新服务权益""" """更新服务权益"""
data = request.get_json() data = request.get_json()
@ -256,11 +245,6 @@ def update_service(service_id):
@inbound_bp.route('/service/<int:service_id>', methods=['DELETE']) @inbound_bp.route('/service/<int:service_id>', methods=['DELETE'])
@permission_required('inbound_service:operation') @permission_required('inbound_service:operation')
@audit_log(
module='服务权益',
action='删除',
get_target_id_fn=lambda: request.view_args.get('service_id')
)
def delete_service(service_id): def delete_service(service_id):
"""删除服务权益""" """删除服务权益"""
try: try:

View File

@ -1,7 +1,7 @@
from flask import Blueprint, request, jsonify, current_app from flask import Blueprint, request, jsonify, current_app
from app.services.outbound_service import OutboundService from app.services.outbound_service import OutboundService
from flask_jwt_extended import jwt_required, get_jwt_identity, get_jwt from flask_jwt_extended import jwt_required, get_jwt_identity, get_jwt
from app.utils.decorators import permission_required, audit_log, prevent_double_submit, is_privileged_viewer from app.utils.decorators import permission_required, prevent_double_submit, is_privileged_viewer
from app.services.auth_service import AuthService from app.services.auth_service import AuthService
import traceback import traceback
@ -96,11 +96,6 @@ def scan_barcode():
@outbound_bp.route('', methods=['POST']) @outbound_bp.route('', methods=['POST'])
@jwt_required() @jwt_required()
@prevent_double_submit(lock_timeout=5) @prevent_double_submit(lock_timeout=5)
@audit_log(
module='出库管理',
action='新增',
get_target_name_fn=lambda: request.get_json().get('order_no') if request.get_json() else None
)
def create_outbound(): def create_outbound():
# 权限检查:有 outbound_selection 菜单或操作权限即可提交 # 权限检查:有 outbound_selection 菜单或操作权限即可提交
claims = get_jwt() claims = get_jwt()

View File

@ -2,7 +2,7 @@
from flask import Blueprint, request, jsonify, current_app from flask import Blueprint, request, jsonify, current_app
from flask_jwt_extended import jwt_required, get_jwt from flask_jwt_extended import jwt_required, get_jwt
from app.services.permission_service import PermissionService from app.services.permission_service import PermissionService
from app.utils.decorators import permission_required, audit_log from app.utils.decorators import permission_required
permission_bp = Blueprint('permission', __name__) permission_bp = Blueprint('permission', __name__)
@ -67,11 +67,6 @@ def get_role_perms(role_code):
@permission_bp.route('/assign', methods=['POST']) @permission_bp.route('/assign', methods=['POST'])
@jwt_required() @jwt_required()
@permission_required('system_permission:operation') @permission_required('system_permission:operation')
@audit_log(
module='权限管理',
action='分配',
get_target_name_fn=lambda: request.get_json().get('role_code') if request.get_json() else None
)
def assign_perms(): def assign_perms():
"""保存权限分配(自动带上当前操作者的公司标识)""" """保存权限分配(自动带上当前操作者的公司标识)"""
try: try:
@ -92,4 +87,4 @@ def assign_perms():
return jsonify({'code': 200, 'msg': '保存成功'}), 200 return jsonify({'code': 200, 'msg': '保存成功'}), 200
except Exception as e: except Exception as e:
current_app.logger.error(f"Assign Perms Failed: {str(e)}") current_app.logger.error(f"Assign Perms Failed: {str(e)}")
return jsonify({'code': 500, 'msg': str(e)}), 500 return jsonify({'code': 500, 'msg': str(e)}), 500

View File

@ -1,7 +1,7 @@
# inventory-backend/app/api/v1/scrap.py # inventory-backend/app/api/v1/scrap.py
from flask import Blueprint, request, jsonify from flask import Blueprint, request, jsonify
from flask_jwt_extended import jwt_required, get_jwt_identity, get_jwt from flask_jwt_extended import jwt_required, get_jwt_identity, get_jwt
from app.utils.decorators import permission_required, audit_log, get_current_company_filter from app.utils.decorators import permission_required, get_current_company_filter
from app.services.auth_service import AuthService from app.services.auth_service import AuthService
from app.extensions import db from app.extensions import db
from app.models.transaction import TransScrap, TransRepair from app.models.transaction import TransScrap, TransRepair
@ -89,11 +89,6 @@ def scan_barcode():
# -------------------------------------------------------- # --------------------------------------------------------
@scrap_bp.route('', methods=['POST']) @scrap_bp.route('', methods=['POST'])
@jwt_required() @jwt_required()
@audit_log(
module='报废管理',
action='报废出库',
get_target_name_fn=lambda: request.get_json().get('items')[0].get('sku') if request.get_json() and request.get_json().get('items') else None
)
def create_scrap(): def create_scrap():
claims = get_jwt() claims = get_jwt()
user_role = claims.get('role') user_role = claims.get('role')

View File

@ -1,6 +1,6 @@
from flask import Blueprint, jsonify, request # .material -> .base refactor checked from flask import Blueprint, jsonify, request # .material -> .base refactor checked
from flask_jwt_extended import jwt_required, get_jwt_identity, get_jwt from flask_jwt_extended import jwt_required, get_jwt_identity, get_jwt
from app.utils.decorators import permission_required, audit_log, prevent_double_submit, is_privileged_viewer from app.utils.decorators import permission_required, prevent_double_submit, is_privileged_viewer
from app.services.auth_service import AuthService from app.services.auth_service import AuthService
from app.services.trans_service import TransService from app.services.trans_service import TransService
from app.services.borrow_service import BorrowApprovalService from app.services.borrow_service import BorrowApprovalService
@ -71,11 +71,6 @@ def filter_item_by_permissions(item_dict, user_permissions, prefix='op_records')
@trans_bp.route('/borrow', methods=['POST']) @trans_bp.route('/borrow', methods=['POST'])
@jwt_required() @jwt_required()
@permission_required('op_borrow:operation') @permission_required('op_borrow:operation')
@audit_log(
module='借库管理',
action='新增',
get_target_name_fn=lambda: request.get_json().get('borrow_no') if request.get_json() else None
)
def create_borrow(): def create_borrow():
data = request.get_json() data = request.get_json()
try: try:
@ -105,11 +100,6 @@ def scan_borrowed_item():
@trans_bp.route('/return', methods=['POST']) @trans_bp.route('/return', methods=['POST'])
@jwt_required() @jwt_required()
@permission_required('op_return:operation') @permission_required('op_return:operation')
@audit_log(
module='借库管理',
action='归还',
get_target_name_fn=lambda: request.get_json().get('borrow_no') if request.get_json() else None
)
def submit_return(): def submit_return():
data = request.get_json() data = request.get_json()
# ★ 归还人存"姓名",而非 JWT 数字 ID # ★ 归还人存"姓名",而非 JWT 数字 ID
@ -125,11 +115,6 @@ def submit_return():
@trans_bp.route('/borrow/scrap', methods=['POST']) @trans_bp.route('/borrow/scrap', methods=['POST'])
@jwt_required() @jwt_required()
@permission_required('op_return:operation') # 复用归还权限:能归还的库管即可报废 @permission_required('op_return:operation') # 复用归还权限:能归还的库管即可报废
@audit_log(
module='借库管理',
action='借库报废',
get_target_name_fn=lambda: request.get_json().get('reason') if request.get_json() else None
)
def scrap_borrow(): def scrap_borrow():
""" """
借库未归还直接报废(库管/主管操作) 借库未归还直接报废(库管/主管操作)

View File

@ -3,7 +3,6 @@ from flask import Blueprint, request, jsonify
from flask_jwt_extended import jwt_required from flask_jwt_extended import jwt_required
from app.extensions import db from app.extensions import db
from app.models.system import SysWarehouseLocation from app.models.system import SysWarehouseLocation
from app.utils.decorators import audit_log
warehouse_bp = Blueprint('warehouse', __name__, url_prefix='/api/v1/warehouse') warehouse_bp = Blueprint('warehouse', __name__, url_prefix='/api/v1/warehouse')
@ -93,11 +92,6 @@ def get_children():
@warehouse_bp.route('', methods=['POST']) @warehouse_bp.route('', methods=['POST'])
@jwt_required() @jwt_required()
@audit_log(
module='库位管理',
action='新增',
get_target_name_fn=lambda: request.get_json().get('name') if request.get_json() else None
)
def create_location(): def create_location():
""" """
创建库位 创建库位
@ -150,12 +144,6 @@ def create_location():
@warehouse_bp.route('/<int:location_id>', methods=['PUT']) @warehouse_bp.route('/<int:location_id>', methods=['PUT'])
@jwt_required() @jwt_required()
@audit_log(
module='库位管理',
action='修改',
get_target_id_fn=lambda: request.view_args.get('location_id'),
get_target_name_fn=lambda: request.get_json().get('name') if request.get_json() else None
)
def update_location(location_id): def update_location(location_id):
""" """
更新库位 更新库位
@ -201,11 +189,6 @@ def update_location(location_id):
@warehouse_bp.route('/<int:location_id>', methods=['DELETE']) @warehouse_bp.route('/<int:location_id>', methods=['DELETE'])
@jwt_required() @jwt_required()
@audit_log(
module='库位管理',
action='删除',
get_target_id_fn=lambda: request.view_args.get('location_id')
)
def delete_location(location_id): def delete_location(location_id):
""" """
删除库位(级联删除子库位) 删除库位(级联删除子库位)
@ -247,10 +230,6 @@ def delete_location(location_id):
@warehouse_bp.route('/batch', methods=['DELETE']) @warehouse_bp.route('/batch', methods=['DELETE'])
@jwt_required() @jwt_required()
@audit_log(
module='库位管理',
action='批量删除'
)
def batch_delete_locations(): def batch_delete_locations():
""" """
批量删除库位 批量删除库位
@ -299,10 +278,6 @@ def batch_delete_locations():
@warehouse_bp.route('/batch-generate', methods=['POST']) @warehouse_bp.route('/batch-generate', methods=['POST'])
@jwt_required() @jwt_required()
@audit_log(
module='库位管理',
action='批量生成'
)
def batch_generate_locations(): def batch_generate_locations():
""" """
规则化批量新增库位 规则化批量新增库位

View File

@ -1,22 +1,137 @@
# inventory-backend/app/core/audit_listener.py # inventory-backend/app/core/audit_listener.py
""" """
SQLAlchemy Event Listener 审计监听器(单体架构版) SQLAlchemy Event Listener 审计监听器(业务友好版)
监听器亲自完成入库,不依赖 g 对象,不依赖装饰器回调。
只要模型发生 INSERT/UPDATE/DELETE监听器直接创建 AuditLog 并挂载到当前事务 session。 设计要点
--------
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 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 = { IGNORE_FIELDS = {
# 时间戳/版本:变更频繁但无业务含义
'updated_at', 'update_time', 'modified_time', 'last_modified', 'updated_at', 'update_time', 'modified_time', 'last_modified',
'created_at', 'create_time', 'created_on', '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): def _serialize_value(value):
"""序列化确保 JSON 兼容""" """单值序列化确保 JSON 兼容"""
if value is None: if value is None:
return None return None
if isinstance(value, datetime): if isinstance(value, datetime):
@ -26,114 +141,242 @@ def _serialize_value(value):
return value.decode('utf-8') return value.decode('utf-8')
except Exception: except Exception:
return '[二进制数据]' return '[二进制数据]'
if hasattr(value, '__class__') and value.__class__.__name__ in ('InstanceState', 'LazyLoader'): if isinstance(value, Decimal):
return str(value) return float(value)
return value return value
def _is_audit_model(mapper): def _should_skip_field(key):
"""判断模型是否需要审计""" """字段是否应被跳过(忽略名单 + 敏感 + 大对象 + 关系属性)"""
if hasattr(mapper.class_, 'audit_enabled') and mapper.class_.audit_enabled is False: if key in IGNORE_FIELDS:
return False 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', def _is_audit_model(mapper):
'SysUser', 'SysMenu', 'SysElement', 'SysRolePermission', # ★ 新增:系统管理三表纳入审计 """按表名白名单判断是否需要审计"""
} cls = mapper.class_
return mapper.class_.__name__ in AUDIT_WHITELIST # 模型级开关:显式声明 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): def _get_module_name(mapper):
"""根据模型类名推断所属模块""" """根据名推断所属业务模块"""
tablename = getattr(mapper.class_, '__tablename__', '') or ''
name = mapper.class_.__name__ name = mapper.class_.__name__
if 'Stock' in name or 'Buy' in name:
return '入库管理' if tablename in ('outbound_approval', 'trans_outbound'):
if 'Outbound' in name or 'TransOut' in name:
return '出库管理' return '出库管理'
if 'Borrow' in name or 'Return' in name: if tablename in ('borrow_approval', 'trans_borrow'):
return '借还管理' 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: if 'Bom' in name:
return 'BOM管理' 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: if 'Material' in name:
return '基础数据' return '基础数据'
return '未知模块' return '未知模块'
def _get_request_user_info(): def _extract_target_id(target):
"""从当前 HTTP 请求中尽力提取用户信息,获取不到拉倒""" """提取被操作数据的主键"""
user_id, username, ip = None, 'system', '' for field in ('id', 'stock_id', 'uuid', 'bom_no'):
if has_request_context(): if hasattr(target, field):
try: val = getattr(target, field, None)
from flask_jwt_extended import get_jwt_identity, get_jwt if val is not None:
user_id = get_jwt_identity() return str(val)
claims = get_jwt() return ''
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 _get_target_name(target, tablename, target_id):
# 核心:监听器内部直接创建并挂载日志
# ============================================================
def _create_audit_log(session, mapper, target, action, details):
""" """
监听器内部直接实例化 AuditLog 并加入当前事务 session。 ★ 业务友好的 target_name 生成Phase 2 核心)
由 SQLAlchemy 生命周期保证随主事务一同提交或回滚。
优先级:
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** 而非
SessionConnection 没有 .add() 方法 —— 改造前每次调用都会抛
AttributeError 并被 except 吞掉,导致监听器即使注册成功也永远写不进数据。
直接 connection.execute 还能保证与主事务同生共死(业务回滚则日志一并回滚)。
""" """
try: 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) module = _get_module_name(mapper)
target_id = None sql = text("""
if hasattr(target, 'id'): INSERT INTO audit_logs
target_id = target.id (user_id, username, display_name, action, module,
elif hasattr(target, 'stock_id'): target_id, target_name, details, ip_address, method, url, created_at)
target_id = target.stock_id VALUES
elif hasattr(target, 'bom_no'): (:user_id, :username, :display_name, :action, :module,
target_id = target.bom_no :target_id, :target_name, cast(:details AS jsonb), :ip_address,
:method, :url, :created_at)
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)
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: 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): def before_update_listener(mapper, connection, target):
"""UPDATE 事件:抓取字段变更明细""" """UPDATE:仅记录真正发生变化的字段"""
if not _is_audit_model(mapper): return _ensure_bound_once()
if not _is_audit_model(mapper):
# ★★★ 关键修复系统初始化PermissionService.init_all_menus 等)时, return
# username='system' 且 has_request_context()=False # ★ 非 HTTP 请求上下文(系统初始化 / 后台定时任务)不产生审计日志
# 这类非用户发起的变更不应产生审计日志,直接跳过。
if not has_request_context(): if not has_request_context():
return return
@ -141,87 +384,128 @@ def before_update_listener(mapper, connection, target):
state = inspect(target) state = inspect(target)
changes = {} changes = {}
for attr in state.attrs: for attr in state.attrs:
if attr.key in IGNORE_FIELDS: continue key = attr.key
if 'embedding' in attr.key: continue if _should_skip_field(key):
if attr.history.has_changes(): continue
old_val = attr.history.deleted[0] if attr.history.deleted else None if hasattr(attr, 'property') and hasattr(attr.property, 'direction'):
new_val = attr.history.added[0] if attr.history.added else None continue
changes[attr.key] = { if not attr.history.has_changes():
'old': _serialize_value(old_val), continue
'new': _serialize_value(new_val)
} 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: if changes:
_create_audit_log(connection, mapper, target, 'update', {'changes': changes}) _create_audit_log(connection, mapper, target, 'UPDATE', {'changes': changes})
except Exception as e: 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): def before_delete_listener(mapper, connection, target):
"""DELETE 事件:抓取被删除对象的完整快照""" """DELETE:记录被删除对象的完整快照"""
if not _is_audit_model(mapper): return _ensure_bound_once()
# ★★★ 关键修复:非 HTTP 请求上下文下的初始化操作(如 PermissionService if not _is_audit_model(mapper):
if not has_request_context(): return return
if not has_request_context():
return
try: try:
state = inspect(target) snap = _collect_snapshot(target)
snap = {} _create_audit_log(connection, mapper, target, 'DELETE',
for attr in state.attrs: {'deleted_snapshot': snap})
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})
except Exception as e: 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): def after_insert_listener(mapper, connection, target):
"""INSERT 事件:抓取新增对象的完整快照""" """INSERT:记录新增对象的完整快照"""
if not _is_audit_model(mapper): return _ensure_bound_once()
# ★★★ 关键修复:非 HTTP 请求上下文下的初始化操作(如 PermissionService if not _is_audit_model(mapper):
if not has_request_context(): return return
if not has_request_context():
return
try: try:
state = inspect(target) snap = _collect_snapshot(target)
snap = {} _create_audit_log(connection, mapper, target, 'CREATE', {'created': snap})
for attr in state.attrs: except Exception as e:
if attr.key in IGNORE_FIELDS: continue try:
if 'embedding' in attr.key: continue current_app.logger.error(f"Audit Insert Error: {e}")
val = getattr(target, attr.key, None) except Exception:
snap[attr.key] = _serialize_value(val) pass
_create_audit_log(connection, mapper, target, 'insert', {'created': snap})
except Exception:
pass
# ============================================================ # =============================================================================
# 注册函数 # 注册
# ============================================================ # =============================================================================
# 已绑定的表名,避免重复 event.listen重复监听会导致同一次变更写多条日志
_BOUND_TABLES = set()
def register_audit_listeners(db): 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 = [ ★ 为什么需要"惰性补绑"(见 ensure_audit_listeners
MaterialBase, MaterialWarningSetting, 本项目大量模型是在**函数体内延迟导入**的31 处,例如
StockBuy, StockSemi, StockProduct, StockService, app/api/v1/scrap.py 内部 `from app.models.scrap_approval import ScrapApproval`
RepairRecord, TransOutbound, TransBorrow, TransReturn, 因此 create_app() 执行完毕时 scrap_approval 等表仍未进入 db.metadata。
BomTable, StockTake, StockAdjust, 一次性注册必然漏掉它们,且不留任何报错痕迹。
TransScrap, SysUser 解决办法:启动时先绑能绑的,之后每次写操作前调 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 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: try:
model = mapper.class_
event.listen(model, 'before_update', before_update_listener, propagate=True) event.listen(model, 'before_update', before_update_listener, propagate=True)
event.listen(model, 'before_delete', before_delete_listener, propagate=True) event.listen(model, 'before_delete', before_delete_listener, propagate=True)
event.listen(model, 'after_insert', after_insert_listener, propagate=True) event.listen(model, 'after_insert', after_insert_listener, propagate=True)
_BOUND_TABLES.add(tablename)
count += 1 count += 1
except Exception: except Exception as e:
pass try:
current_app.logger.warning(f"审计监听器绑定失败 [{tablename}]: {e}")
except Exception:
pass
return count 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

View File

@ -84,40 +84,12 @@ def revoke_all_tokens_for_user(user_id):
current_app.logger.error(f"Failed to revoke tokens for user_id={user_id}: {e}") current_app.logger.error(f"Failed to revoke tokens for user_id={user_id}: {e}")
# 2. 定义初始化函数 (供工厂函数 create_app 调用) # 2. [已移除] init_extensions(app)
def init_extensions(app): #
""" # 该函数历史上用于统一初始化扩展并注册审计监听器,但 create_app() 始终是
统一初始化所有 Flask 扩展 # 直接内联调用 db.init_app / migrate.init_app / jwt.init_app / cors.init_app
""" # 从未调用过本函数 —— 属于死代码。其内部的审计注册因此也从未执行,
global redis_client # 造成「两套监听器并存、实际只有未设计的那套在跑」的长期隐患。
#
# 初始化数据库 # 为避免后人误以为它是一个有效的初始化入口,已整体删除;
db.init_app(app) # 审计监听器现由 create_app() 显式注册(见 app/__init__.py 1.1 节)。
# 初始化迁移工具
migrate.init_app(app, db)
# 初始化跨域设置 (允许 /api/* 路径被所有来源访问)
cors.init_app(app, resources={r"/api/*": {"origins": "*"}})
# 初始化 JWT (这一步至关重要,缺少它会导致 500 错误)
jwt.init_app(app)
# 初始化 Redis (单设备登录互踢)
redis_url = app.config.get('REDIS_URL', 'redis://localhost:6379/0')
try:
redis_client = redis.from_url(redis_url, decode_responses=True)
redis_client.ping()
app.logger.info("✅ Redis connected successfully")
except Exception as e:
app.logger.warning(f"⚠️ Redis connection failed: {e}, single-device login will be disabled")
# ★ 注册 SQLAlchemy 审计监听器
# 必须在 db.init_app 之后调用,确保所有模型已映射
try:
from app.core.audit_listener import register_audit_listeners
with app.app_context():
count = register_audit_listeners(db)
app.logger.info(f"✅ 审计监听器注册成功,共绑定 {count} 个模型")
except Exception as e:
app.logger.error(f"⚠️ 审计监听器注册失败: {e}")

View File

@ -1,8 +1,20 @@
# inventory-backend/app/utils/audit_events.py # inventory-backend/app/utils/audit_events.py
""" """
全局无侵入的审计日志拦截器 [已停用 — DEPRECATED] 全局审计日志拦截器
监听所有模型的增删改操作,自动提取旧值和新值存入 audit_logs 表
完美对接前端 AuditLog.vue 的解析逻辑 (changes, deleted_snapshot, created) ★ 本模块自 v3.x 起不再注册、不再生效。
审计日志已统一由 app/core/audit_listener.py 接管(白名单制 + 请求上下文守卫)。
停用原因(原实现的三个设计缺陷):
1. 监听 db.Model 全局,无白名单 —— 系统表/草稿表/向量表被一并审计,
产生大量无业务价值的记录;
2. 无 has_request_context() 守卫 —— 系统初始化与后台定时任务产生的变更
被记为 username='system' 的日志(历史数据中占 32%
3. target_name 兜底为 `{英文表名} ID:{id}`(历史数据中占 72%
对用户完全不可读。
保留文件仅为兼容可能的历史 importregister_audit_events() 已改为空操作,
调用它不会产生任何监听器。
""" """
import json import json
from datetime import datetime, date from datetime import datetime, date
@ -208,116 +220,11 @@ def _has_changes(history):
"""检查历史记录对象是否有变更""" """检查历史记录对象是否有变更"""
return history.has_changes() return history.has_changes()
def register_audit_events(db): def register_audit_events(db):
""" """
全局注册审计事件监听器 [已停用] 空操作,保留仅为兼容历史调用。
监听所有模型的 INSERT/UPDATE/DELETE 事件
审计日志已由 app/core/audit_listener.py 接管(白名单制 + 请求上下文守卫)。
调用本函数不会注册任何监听器。
""" """
from sqlalchemy import inspect return False
@event.listens_for(db.Model, 'before_update', propagate=True)
def before_update_listener(mapper, connection, target):
"""UPDATE 事件:抓取字段变更明细"""
if target.__tablename__ in IGNORE_TABLES:
return
try:
state = inspect(target)
changes = {}
for attr in state.attrs:
prop = attr.key
# 跳过忽略字段
if prop in IGNORE_FIELDS:
continue
# 跳过关系属性
if hasattr(attr, 'property') and hasattr(attr.property, 'direction'):
continue
if _has_changes(attr.history):
old_value = attr.history.deleted[0] if attr.history.deleted else None
new_value = attr.history.added[0] if attr.history.added else None
# 序列化值
old_serialized = serialize_value(old_value)
new_serialized = serialize_value(new_value)
# 只记录真正变化的字段
if old_serialized != new_serialized:
changes[prop] = {
'old': old_serialized,
'new': new_serialized
}
if changes:
insert_audit_log(connection, 'UPDATE', target, {'changes': changes})
except Exception as e:
import logging
logging.error(f"Audit Update Error: {e}")
@event.listens_for(db.Model, 'before_delete', propagate=True)
def before_delete_listener(mapper, connection, target):
"""DELETE 事件:抓取被删除对象的完整快照"""
if target.__tablename__ in IGNORE_TABLES:
return
try:
state = inspect(target)
snapshot = {}
for attr in state.attrs:
prop = attr.key
# 跳过忽略字段
if prop in IGNORE_FIELDS:
continue
# 跳过关系属性
if hasattr(attr, 'property') and hasattr(attr.property, 'direction'):
continue
value = getattr(target, prop, None)
snapshot[prop] = serialize_value(value)
insert_audit_log(connection, 'DELETE', target, {'deleted_snapshot': snapshot})
except Exception as e:
import logging
logging.error(f"Audit Delete Error: {e}")
@event.listens_for(db.Model, 'after_insert', propagate=True)
def after_insert_listener(mapper, connection, target):
"""INSERT 事件:抓取新增对象的完整快照"""
if target.__tablename__ in IGNORE_TABLES:
return
try:
state = inspect(target)
snapshot = {}
for attr in state.attrs:
prop = attr.key
# 跳过忽略字段
if prop in IGNORE_FIELDS:
continue
# 跳过关系属性
if hasattr(attr, 'property') and hasattr(attr.property, 'direction'):
continue
value = getattr(target, prop, None)
snapshot[prop] = serialize_value(value)
insert_audit_log(connection, 'CREATE', target, {'created': snapshot})
except Exception as e:
import logging
logging.error(f"Audit Insert Error: {e}")
# 返回注册成功信息
return True

View File

@ -298,19 +298,13 @@ def get_current_company_filter():
return '__NO_COMPANY__' return '__NO_COMPANY__'
def audit_log(module: str = None, action: str = None, get_target_id_fn=None, get_target_name_fn=None, get_details_fn=None): # [已移除] audit_log 装饰器
""" #
已废弃! # 该装饰器已废弃多年:它只是一个直接透传的空壳,其 module / action 参数
由 SQLAlchemy 底层监听器app/core/audit_listener.py全面接管审计日志入库。 # 全部被忽略(数据库中零星的中文 action 即其历史遗留产物)。审计日志现由
此装饰器保留空壳以防项目中其他文件 import 引用时报错 # app/core/audit_listener.py 的 SQLAlchemy 事件监听器统一接管
""" #
def wrapper(fn): # 为避免继续造成"以为它在工作"的误解,已连同 38 处路由引用一并删除。
from functools import wraps
@wraps(fn)
def decorator(*inner_args, **inner_kwargs):
return fn(*inner_args, **inner_kwargs)
return decorator
return wrapper
def prevent_double_submit(lock_timeout=5): def prevent_double_submit(lock_timeout=5):