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:
@ -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
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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')
|
||||||
|
|||||||
@ -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():
|
||||||
"""
|
"""
|
||||||
借库未归还直接报废(库管/主管操作)
|
借库未归还直接报废(库管/主管操作)
|
||||||
|
|||||||
@ -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():
|
||||||
"""
|
"""
|
||||||
规则化批量新增库位
|
规则化批量新增库位
|
||||||
|
|||||||
@ -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** 而非
|
||||||
|
Session,Connection 没有 .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
|
||||||
|
|||||||
@ -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}")
|
|
||||||
@ -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%),
|
||||||
|
对用户完全不可读。
|
||||||
|
|
||||||
|
保留文件仅为兼容可能的历史 import;register_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
|
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user