diff --git a/inventory-backend/app/__init__.py b/inventory-backend/app/__init__.py index babe5d4..58ac13a 100644 --- a/inventory-backend/app/__init__.py +++ b/inventory-backend/app/__init__.py @@ -22,16 +22,14 @@ def create_app(): cors.init_app(app, resources={r"/*": {"origins": "*"}}, supports_credentials=True) # ========================================================= - # 1.1 注册全局审计日志监听器 - # ========================================================= - with app.app_context(): - try: - from app.utils.audit_events import register_audit_events - register_audit_events(db) - print("✅ 审计事件监听器注册成功") - except Exception as e: - print(f"⚠️ 审计事件监听器注册失败: {e}") - + # 1.1 [已迁移] 审计日志监听器注册 + # + # 原注册的是 app/utils/audit_events.py 的全局监听器(监听 db.Model、 + # 无白名单、无请求上下文守卫),已停用;审计统一由 + # app/core/audit_listener.py 接管。 + # + # ★ 注册动作已移至本函数末尾(见「5. 注册审计监听器」), + # 因为此处模型尚未全部映射,会导致 scrap_approval 等表被静默漏掉。 # ========================================================= # 2. 注册蓝图 (Blueprints) # --------------------------------------------------------- @@ -286,6 +284,15 @@ def create_app(): from app.models.system import SysUser, SysLog, SysMenu, SysElement, SysRolePermission, SysWarehouseLocation # 确保借还模型被加载 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) # db.create_all() @@ -296,7 +303,24 @@ def create_app(): 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 traceback diff --git a/inventory-backend/app/api/v1/auth.py b/inventory-backend/app/api/v1/auth.py index b5cdca1..649659d 100644 --- a/inventory-backend/app/api/v1/auth.py +++ b/inventory-backend/app/api/v1/auth.py @@ -20,7 +20,7 @@ from app.extensions import db # Service & Decorators # ============================================================================== 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__) @@ -135,11 +135,6 @@ def refresh(): @auth_bp.route('/user/create', methods=['POST']) @jwt_required() @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(): try: data = request.get_json() @@ -225,11 +220,6 @@ def batch_create_user(): @auth_bp.route('/user/', methods=['PUT']) @jwt_required() @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): try: data = request.get_json() @@ -288,11 +278,6 @@ def get_users(): @auth_bp.route('/user/', methods=['DELETE']) @jwt_required() @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): try: claims = get_jwt() diff --git a/inventory-backend/app/api/v1/bom.py b/inventory-backend/app/api/v1/bom.py index 27e0361..e3898f3 100644 --- a/inventory-backend/app/api/v1/bom.py +++ b/inventory-backend/app/api/v1/bom.py @@ -6,7 +6,7 @@ from app.models.base import MaterialBase from app.models.bom import BomTable from app.extensions import db 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 bom_bp = Blueprint('bom', __name__) @@ -111,11 +111,6 @@ def get_bom_summary(): @bom_bp.route('/status', methods=['POST']) @jwt_required() @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(): """仅切换某 BOM 版本(整组)的启用/停用状态。""" try: @@ -140,11 +135,6 @@ def update_bom_status(): @bom_bp.route('/archive', methods=['POST']) @jwt_required() @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(): """切换某 BOM 版本(整组)的归档状态。""" try: @@ -194,11 +184,6 @@ def get_bom_detail(bom_no): @bom_bp.route('/save', methods=['POST']) @jwt_required() @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(): """保存或更新 BOM 配方(支持自定义 bom_no 和 多版本)""" try: @@ -282,11 +267,6 @@ def get_bom_with_stock_by_no(bom_no): @bom_bp.route('/', methods=['DELETE']) @jwt_required() @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): """ 根据 BOM 编号删除 @@ -349,11 +329,6 @@ def get_bom(parent_id): @bom_bp.route('', methods=['POST']) @jwt_required() @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(): try: req_data = request.get_json() diff --git a/inventory-backend/app/api/v1/inbound/base.py b/inventory-backend/app/api/v1/inbound/base.py index 647ffee..a6585a6 100644 --- a/inventory-backend/app/api/v1/inbound/base.py +++ b/inventory-backend/app/api/v1/inbound/base.py @@ -3,7 +3,7 @@ from flask import Blueprint, request, jsonify, send_file, g, current_app from app.extensions import db, beijing_time 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 import traceback import datetime @@ -239,11 +239,6 @@ def export_data(): # ============================================================================== @inbound_base_bp.route('/', methods=['POST']) @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(): try: data = request.get_json() @@ -302,12 +297,6 @@ def create(): # ============================================================================== @inbound_base_bp.route('/', methods=['PUT']) @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): try: data = request.get_json() @@ -359,11 +348,6 @@ def update(id): # ============================================================================== @inbound_base_bp.route('/', methods=['DELETE']) @permission_required('material_list:operation') -@audit_log( - module='基础信息管理', - action='删除', - get_target_id_fn=lambda: request.view_args.get('id') -) def delete(id): try: material_name = MaterialBaseService.delete_material(id) diff --git a/inventory-backend/app/api/v1/inbound/buy.py b/inventory-backend/app/api/v1/inbound/buy.py index 486001e..a614676 100644 --- a/inventory-backend/app/api/v1/inbound/buy.py +++ b/inventory-backend/app/api/v1/inbound/buy.py @@ -1,6 +1,6 @@ from flask import Blueprint, request, jsonify 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 inbound_buy_bp = Blueprint('stock_buy', __name__) @@ -124,11 +124,6 @@ def get_list(): # ------------------------------------------------------------------ @inbound_buy_bp.route('/submit', methods=['POST']) @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(): try: data = request.get_json() @@ -172,12 +167,6 @@ def submit(): # ------------------------------------------------------------------ @inbound_buy_bp.route('/', methods=['PUT']) @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): try: data = request.get_json() @@ -245,11 +234,6 @@ def update_buy(id): # ------------------------------------------------------------------ @inbound_buy_bp.route('/', methods=['DELETE']) @permission_required('inbound_buy:operation') -@audit_log( - module='采购入库', - action='删除', - get_target_id_fn=lambda: request.view_args.get('id') -) def delete_buy(id): try: material_name = BuyInboundService.delete_inbound(id) diff --git a/inventory-backend/app/api/v1/inbound/product.py b/inventory-backend/app/api/v1/inbound/product.py index c7062a7..61c7554 100644 --- a/inventory-backend/app/api/v1/inbound/product.py +++ b/inventory-backend/app/api/v1/inbound/product.py @@ -1,7 +1,7 @@ # inventory-backend/app/api/v1/inbound/product.py from flask import Blueprint, request, jsonify 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.services.track_query_service import lookup_product import traceback @@ -108,11 +108,6 @@ def get_list(): @inbound_product_bp.route('/submit', methods=['POST']) @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(): try: data = request.get_json() @@ -191,12 +186,6 @@ def track_lookup(): @inbound_product_bp.route('/', methods=['PUT']) @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): try: data = request.get_json() @@ -244,11 +233,6 @@ def update(id): @inbound_product_bp.route('/', methods=['DELETE']) @permission_required('inbound_product:operation') -@audit_log( - module='成品入库', - action='删除', - get_target_id_fn=lambda: request.view_args.get('id') -) def delete(id): try: material_name = ProductInboundService.delete_inbound(id) diff --git a/inventory-backend/app/api/v1/inbound/repair.py b/inventory-backend/app/api/v1/inbound/repair.py index 6c690f8..272b79c 100644 --- a/inventory-backend/app/api/v1/inbound/repair.py +++ b/inventory-backend/app/api/v1/inbound/repair.py @@ -1,7 +1,7 @@ # inventory-backend/app/api/v1/inbound/repair.py from flask import Blueprint, request, jsonify 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 inbound_repair_bp = Blueprint('inbound_repair', __name__) @@ -35,11 +35,6 @@ def get_list(): # ------------------------------------------------------------------ @inbound_repair_bp.route('/submit', methods=['POST']) @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(): try: data = request.get_json() @@ -55,11 +50,6 @@ def create(): # ------------------------------------------------------------------ @inbound_repair_bp.route('/', methods=['PUT']) @permission_required('inbound_repair:edit') -@audit_log( - module='维修管理', - action='更新', - get_target_name_fn=lambda: f"维修单ID:{request.view_args.get('id')}" -) def update(id): try: data = request.get_json() @@ -77,11 +67,6 @@ def update(id): # ------------------------------------------------------------------ @inbound_repair_bp.route('/update-status', methods=['POST']) @permission_required('inbound_repair:edit') -@audit_log( - module='维修管理', - action='更新状态', - get_target_name_fn=lambda: f"维修单ID:{request.get_json().get('id')}" -) def update_status(): try: data = request.get_json() @@ -105,11 +90,6 @@ def update_status(): # ------------------------------------------------------------------ @inbound_repair_bp.route('/', methods=['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): try: success = RepairInboundService.delete(id) @@ -134,4 +114,4 @@ def get_detail(id): return jsonify({'code': 200, 'msg': 'success', 'data': result}) except Exception as e: traceback.print_exc() - return jsonify({'code': 500, 'msg': str(e)}), 500 \ No newline at end of file + return jsonify({'code': 500, 'msg': str(e)}), 500 diff --git a/inventory-backend/app/api/v1/inbound/semi.py b/inventory-backend/app/api/v1/inbound/semi.py index dcf1cac..10d8130 100644 --- a/inventory-backend/app/api/v1/inbound/semi.py +++ b/inventory-backend/app/api/v1/inbound/semi.py @@ -1,7 +1,7 @@ # inventory-backend/app/api/v1/inbound/semi.py from flask import Blueprint, request, jsonify 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.services.track_query_service import lookup_product import traceback @@ -112,11 +112,6 @@ def get_list(): @inbound_semi_bp.route('/submit', methods=['POST']) @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(): try: data = request.get_json() @@ -194,12 +189,6 @@ def track_lookup(): @inbound_semi_bp.route('/', methods=['PUT']) @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): try: data = request.get_json() @@ -255,11 +244,6 @@ def update_semi(id): @inbound_semi_bp.route('/', methods=['DELETE']) @permission_required('inbound_semi:operation') -@audit_log( - module='半成品入库', - action='删除', - get_target_id_fn=lambda: request.view_args.get('id') -) def delete_semi(id): try: material_name = SemiInboundService.delete_inbound(id) diff --git a/inventory-backend/app/api/v1/inbound/service.py b/inventory-backend/app/api/v1/inbound/service.py index 33f52b0..0c38a79 100644 --- a/inventory-backend/app/api/v1/inbound/service.py +++ b/inventory-backend/app/api/v1/inbound/service.py @@ -3,7 +3,7 @@ from flask import request, jsonify, current_app from flask_jwt_extended import jwt_required from . import inbound_bp 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 @@ -110,11 +110,6 @@ def get_service_list(): @inbound_bp.route('/service', methods=['POST']) @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(): """创建服务权益""" data = request.get_json() @@ -191,12 +186,6 @@ def get_service(service_id): @inbound_bp.route('/service/', methods=['PUT']) @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): """更新服务权益""" data = request.get_json() @@ -256,11 +245,6 @@ def update_service(service_id): @inbound_bp.route('/service/', methods=['DELETE']) @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): """删除服务权益""" try: diff --git a/inventory-backend/app/api/v1/outbound.py b/inventory-backend/app/api/v1/outbound.py index 7c2da9b..5fe0500 100644 --- a/inventory-backend/app/api/v1/outbound.py +++ b/inventory-backend/app/api/v1/outbound.py @@ -1,7 +1,7 @@ from flask import Blueprint, request, jsonify, current_app from app.services.outbound_service import OutboundService 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 import traceback @@ -96,11 +96,6 @@ def scan_barcode(): @outbound_bp.route('', methods=['POST']) @jwt_required() @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(): # 权限检查:有 outbound_selection 菜单或操作权限即可提交 claims = get_jwt() diff --git a/inventory-backend/app/api/v1/permission.py b/inventory-backend/app/api/v1/permission.py index e258d35..1c9d945 100644 --- a/inventory-backend/app/api/v1/permission.py +++ b/inventory-backend/app/api/v1/permission.py @@ -2,7 +2,7 @@ from flask import Blueprint, request, jsonify, current_app from flask_jwt_extended import jwt_required, get_jwt 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__) @@ -67,11 +67,6 @@ def get_role_perms(role_code): @permission_bp.route('/assign', methods=['POST']) @jwt_required() @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(): """保存权限分配(自动带上当前操作者的公司标识)""" try: @@ -92,4 +87,4 @@ def assign_perms(): return jsonify({'code': 200, 'msg': '保存成功'}), 200 except Exception as e: current_app.logger.error(f"Assign Perms Failed: {str(e)}") - return jsonify({'code': 500, 'msg': str(e)}), 500 \ No newline at end of file + return jsonify({'code': 500, 'msg': str(e)}), 500 diff --git a/inventory-backend/app/api/v1/scrap.py b/inventory-backend/app/api/v1/scrap.py index 681c6cb..1c8c359 100644 --- a/inventory-backend/app/api/v1/scrap.py +++ b/inventory-backend/app/api/v1/scrap.py @@ -1,7 +1,7 @@ # inventory-backend/app/api/v1/scrap.py from flask import Blueprint, request, jsonify 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.extensions import db from app.models.transaction import TransScrap, TransRepair @@ -89,11 +89,6 @@ def scan_barcode(): # -------------------------------------------------------- @scrap_bp.route('', methods=['POST']) @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(): claims = get_jwt() user_role = claims.get('role') diff --git a/inventory-backend/app/api/v1/transactions.py b/inventory-backend/app/api/v1/transactions.py index 07c53fc..ec5a5a7 100644 --- a/inventory-backend/app/api/v1/transactions.py +++ b/inventory-backend/app/api/v1/transactions.py @@ -1,6 +1,6 @@ from flask import Blueprint, jsonify, request # .material -> .base refactor checked 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.trans_service import TransService 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']) @jwt_required() @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(): data = request.get_json() try: @@ -105,11 +100,6 @@ def scan_borrowed_item(): @trans_bp.route('/return', methods=['POST']) @jwt_required() @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(): data = request.get_json() # ★ 归还人存"姓名",而非 JWT 数字 ID @@ -125,11 +115,6 @@ def submit_return(): @trans_bp.route('/borrow/scrap', methods=['POST']) @jwt_required() @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(): """ 借库未归还直接报废(库管/主管操作) diff --git a/inventory-backend/app/api/v1/warehouse.py b/inventory-backend/app/api/v1/warehouse.py index 51718fa..ae94f64 100644 --- a/inventory-backend/app/api/v1/warehouse.py +++ b/inventory-backend/app/api/v1/warehouse.py @@ -3,7 +3,6 @@ from flask import Blueprint, request, jsonify from flask_jwt_extended import jwt_required from app.extensions import db from app.models.system import SysWarehouseLocation -from app.utils.decorators import audit_log warehouse_bp = Blueprint('warehouse', __name__, url_prefix='/api/v1/warehouse') @@ -93,11 +92,6 @@ def get_children(): @warehouse_bp.route('', methods=['POST']) @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(): """ 创建库位 @@ -150,12 +144,6 @@ def create_location(): @warehouse_bp.route('/', methods=['PUT']) @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): """ 更新库位 @@ -201,11 +189,6 @@ def update_location(location_id): @warehouse_bp.route('/', methods=['DELETE']) @jwt_required() -@audit_log( - module='库位管理', - action='删除', - get_target_id_fn=lambda: request.view_args.get('location_id') -) def delete_location(location_id): """ 删除库位(级联删除子库位) @@ -247,10 +230,6 @@ def delete_location(location_id): @warehouse_bp.route('/batch', methods=['DELETE']) @jwt_required() -@audit_log( - module='库位管理', - action='批量删除' -) def batch_delete_locations(): """ 批量删除库位 @@ -299,10 +278,6 @@ def batch_delete_locations(): @warehouse_bp.route('/batch-generate', methods=['POST']) @jwt_required() -@audit_log( - module='库位管理', - action='批量生成' -) def batch_generate_locations(): """ 规则化批量新增库位 diff --git a/inventory-backend/app/core/audit_listener.py b/inventory-backend/app/core/audit_listener.py index 9cf081f..f475d78 100644 --- a/inventory-backend/app/core/audit_listener.py +++ b/inventory-backend/app/core/audit_listener.py @@ -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 diff --git a/inventory-backend/app/extensions.py b/inventory-backend/app/extensions.py index f9a3b40..de2ed4b 100644 --- a/inventory-backend/app/extensions.py +++ b/inventory-backend/app/extensions.py @@ -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}") -# 2. 定义初始化函数 (供工厂函数 create_app 调用) -def init_extensions(app): - """ - 统一初始化所有 Flask 扩展 - """ - global redis_client - - # 初始化数据库 - db.init_app(app) - - # 初始化迁移工具 - 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}") \ No newline at end of file +# 2. [已移除] init_extensions(app) +# +# 该函数历史上用于统一初始化扩展并注册审计监听器,但 create_app() 始终是 +# 直接内联调用 db.init_app / migrate.init_app / jwt.init_app / cors.init_app, +# 从未调用过本函数 —— 属于死代码。其内部的审计注册因此也从未执行, +# 造成「两套监听器并存、实际只有未设计的那套在跑」的长期隐患。 +# +# 为避免后人误以为它是一个有效的初始化入口,已整体删除; +# 审计监听器现由 create_app() 显式注册(见 app/__init__.py 1.1 节)。 \ No newline at end of file diff --git a/inventory-backend/app/utils/audit_events.py b/inventory-backend/app/utils/audit_events.py index 0227587..f10d61f 100644 --- a/inventory-backend/app/utils/audit_events.py +++ b/inventory-backend/app/utils/audit_events.py @@ -1,8 +1,20 @@ # inventory-backend/app/utils/audit_events.py """ -全局无侵入的审计日志拦截器 -监听所有模型的增删改操作,自动提取旧值和新值存入 audit_logs 表 -完美对接前端 AuditLog.vue 的解析逻辑 (changes, deleted_snapshot, created) +[已停用 — DEPRECATED] 全局审计日志拦截器 + +★ 本模块自 v3.x 起不再注册、不再生效。 + 审计日志已统一由 app/core/audit_listener.py 接管(白名单制 + 请求上下文守卫)。 + +停用原因(原实现的三个设计缺陷): + 1. 监听 db.Model 全局,无白名单 —— 系统表/草稿表/向量表被一并审计, + 产生大量无业务价值的记录; + 2. 无 has_request_context() 守卫 —— 系统初始化与后台定时任务产生的变更 + 被记为 username='system' 的日志(历史数据中占 32%); + 3. target_name 兜底为 `{英文表名} ID:{id}`(历史数据中占 72%), + 对用户完全不可读。 + +保留文件仅为兼容可能的历史 import;register_audit_events() 已改为空操作, +调用它不会产生任何监听器。 """ import json from datetime import datetime, date @@ -208,116 +220,11 @@ def _has_changes(history): """检查历史记录对象是否有变更""" return history.has_changes() - def register_audit_events(db): """ - 全局注册审计事件监听器 - 监听所有模型的 INSERT/UPDATE/DELETE 事件 + [已停用] 空操作,保留仅为兼容历史调用。 + + 审计日志已由 app/core/audit_listener.py 接管(白名单制 + 请求上下文守卫)。 + 调用本函数不会注册任何监听器。 """ - from sqlalchemy import inspect - - @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 + return False diff --git a/inventory-backend/app/utils/decorators.py b/inventory-backend/app/utils/decorators.py index d645c4b..55c199c 100644 --- a/inventory-backend/app/utils/decorators.py +++ b/inventory-backend/app/utils/decorators.py @@ -298,19 +298,13 @@ def get_current_company_filter(): 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): - """ - 已废弃! - 由 SQLAlchemy 底层监听器(app/core/audit_listener.py)全面接管审计日志入库。 - 此装饰器保留空壳以防项目中其他文件 import 引用时报错。 - """ - def wrapper(fn): - from functools import wraps - @wraps(fn) - def decorator(*inner_args, **inner_kwargs): - return fn(*inner_args, **inner_kwargs) - return decorator - return wrapper +# [已移除] audit_log 装饰器 +# +# 该装饰器已废弃多年:它只是一个直接透传的空壳,其 module / action 参数 +# 全部被忽略(数据库中零星的中文 action 即其历史遗留产物)。审计日志现由 +# app/core/audit_listener.py 的 SQLAlchemy 事件监听器统一接管。 +# +# 为避免继续造成"以为它在工作"的误解,已连同 38 处路由引用一并删除。 def prevent_double_submit(lock_timeout=5):