Files
KCGL/inventory-backend/app/extensions.py
yueli 4808a48594 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),模块中文,操作人真实,时间为北京时间。
2026-09-10 14:16:27 +08:00

95 lines
3.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_cors import CORS
from flask_jwt_extended import JWTManager # 确保引入了 JWTManager
from flask import current_app
from datetime import datetime, timezone, timedelta
import redis
# 1. 创建扩展实例(此时未绑定具体的 App)
db = SQLAlchemy()
migrate = Migrate()
cors = CORS()
jwt = JWTManager() # 必须实例化
# Redis 客户端 (单设备登录互踢 + JWT Token 黑名单用)
redis_client = None
# Redis Key 前缀
_JWT_BLOCKED_USER_PREFIX = "jwt_blocked_user:" # 存储被删除/禁用的 user_id
def beijing_time():
"""获取北京时间 (UTC+8),剥离时区信息以兼容数据库 naive DateTime 字段"""
return datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None)
# =============================================================================
# 全局 JWT Token 黑名单拦截器
# 原理:Flask-JWT-Extended 在每次 @jwt_required() 验证时,
# 会自动触发 token_in_blocklist_loader 回调。
# 若该回调返回 True(命中黑名单),请求直接被 401 拒绝,后续代码不会执行。
# =============================================================================
@jwt.token_in_blocklist_loader
def check_if_token_is_revoked(jwt_header, jwt_payload):
"""
全局 JWT 黑名单检查:每次 @jwt_required() 调用时自动触发。
无论 AI(Dify)还是人类用户调用的接口,均受此拦截。
检查逻辑:
1. 通过 jwt_payload['sub'](user_id)查询 Redis 黑名单
2. 若 user_id 存在于黑名单 → 返回 True → 请求被 401 拒绝
3. 若 Redis 不可用(fail-open)→ 放行(不影响正常业务)
"""
user_id = jwt_payload.get('sub')
if user_id is None:
return False
global redis_client
if redis_client is None:
return False
try:
blocked_key = f"{_JWT_BLOCKED_USER_PREFIX}{user_id}"
is_blocked = redis_client.exists(blocked_key)
if is_blocked:
current_app.logger.warning(
f"🚫 JWT revoked for deleted/disabled user: user_id={user_id}"
)
return bool(is_blocked)
except Exception as e:
current_app.logger.error(f"JWT blocklist check error: {e}")
return False # Redis 出错时 fail-open,不阻断正常业务
def revoke_all_tokens_for_user(user_id):
"""
将指定用户的 ID 加入 JWT 黑名单(14 天)。
效果:该用户的所有已发放 Token(无论是否过期)瞬间失效。
由 delete_user() / update_user(status!='active') 时调用。
"""
global redis_client
if redis_client is None:
current_app.logger.warning(
f"Redis unavailable, cannot revoke tokens for user_id={user_id}"
)
return
try:
blocked_key = f"{_JWT_BLOCKED_USER_PREFIX}{user_id}"
ttl_seconds = 14 * 24 * 3600 # 14 天,与 Refresh Token 有效期对齐
redis_client.setex(blocked_key, ttl_seconds, "1")
current_app.logger.info(f"✅ User {user_id} added to JWT blocklist (TTL={ttl_seconds}s)")
except Exception as e:
current_app.logger.error(f"Failed to revoke tokens for user_id={user_id}: {e}")
# 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 节)。