Files
KCGL/inventory-backend/app/api/v1/audit.py

196 lines
8.1 KiB
Python
Raw Normal View History

# inventory-backend/app/api/v1/audit.py
from flask import Blueprint, request, jsonify, current_app
from flask_jwt_extended import jwt_required, get_jwt
from app.utils.decorators import permission_required
from app.models.audit import AuditLog
from app.extensions import db
from sqlalchemy import or_
from datetime import datetime
import json
audit_bp = Blueprint('audit', __name__)
feat(audit): 审计日志业务化——对象名与字段中文化、操作来源与类型筛选 一、target_name 业务化(后端 audit_listener.py) 优先级:业务标识(request_no/outbound_no/borrow_no/sku/bom_no…)→ 名称字段 → 关联物料名 → 「中文表名 - 业务号或ID」。 新增 TABLE_LABELS 表名到中文的映射(18 张白名单表全覆盖)。 用户看到的从 scrap_approval ID:371 变为 「出库申请单 - APR-OUT-20260805-1550-0005」这类可理解的对象描述。 二、前端字段中文化(AuditLog.vue) · fieldMap 由 11 项扩充到 110+ 项,覆盖审批单/流水/库存/主数据/系统管理五类; · 修复关键 bug:fieldMap 原先只作用于「变更对比」区,「删除快照」与 「新增详情」两区直接渲染原始 key(label 直接取 String(key)), 这正是详情里满是英文列名的直接原因。现三区统一经 fieldLabel() 取值。 三、时间显示修正 后端已改写北京时间,前端原先补 Z 当 UTC 解析会造成二次 +8 小时, 改为按 +08:00 理解该字符串。 四、新增两类筛选 · 操作来源(真实用户 / 系统操作 / 全部),默认「真实用户」。 历史存量含约 1.8 万条 username=system 的噪声日志,会把列表刷屏。 · 操作类型别名归一:历史数据中 action 有两套写法(早期装饰器写中文 新增/修改/删除,现行监听器写大写 CREATE/UPDATE/DELETE),导致下拉 同时出现二者、且选中文项只能搜到 3-4 月的老数据。现 ACTION_ALIASES 把任意写法归一化后展开匹配,下拉只暴露 3 个规范值, 历史数据无需迁移即可被正确检索。
2026-09-10 14:16:39 +08:00
# =============================================================================
# 操作类型归一化
#
# 问题背景:历史数据里 action 有两套写法 —— 早期装饰器(已废弃)写入中文
# (新增/修改/删除/批量删除…),现行监听器写入大写英文(CREATE/UPDATE/DELETE)。
# 前端下拉框直接取 DISTINCT action,于是同时出现「CREATE」和「新增」两个选项,
# 而表格里二者又都显示为「新增」(actionMap 做了映射),用户无法分辨。
#
# 后果:用户选了看得懂的中文项,只能搜到 3-4 月的历史数据,误以为"没有最近的内容"。
#
# 处理:对外只暴露规范值(CREATE/UPDATE/DELETE),筛选时自动展开到全部别名,
# 历史数据无需迁移即可被正确检索。
# =============================================================================
ACTION_ALIASES = {
'CREATE': ('CREATE', 'create', 'INSERT', 'insert', '新增', '批量生成'),
'UPDATE': ('UPDATE', 'update', '修改', '分配', '归还'),
'DELETE': ('DELETE', 'delete', '删除', '批量删除'),
}
# 反向索引:任意别名 → 规范值
_ALIAS_TO_CANON = {
alias: canon
for canon, aliases in ACTION_ALIASES.items()
for alias in aliases
}
def canon_action(action):
"""把任意写法的 action 归一化为规范值;无法识别时原样返回"""
return _ALIAS_TO_CANON.get((action or '').strip(), (action or '').strip())
@audit_bp.route('/logs', methods=['GET'])
@jwt_required()
@permission_required('system_audit')
def get_audit_logs():
"""获取审计日志列表(分页)"""
try:
# 分页参数
page = request.args.get('page', 1, type=int)
page_size = request.args.get('pageSize', 50, type=int)
# 筛选参数
username = request.args.get('username', '').strip()
module = request.args.get('module', '').strip()
action = request.args.get('action', '').strip()
target_id = request.args.get('target_id', '').strip()
start_date = request.args.get('start_date', '').strip()
end_date = request.args.get('end_date', '').strip()
feat(audit): 审计日志业务化——对象名与字段中文化、操作来源与类型筛选 一、target_name 业务化(后端 audit_listener.py) 优先级:业务标识(request_no/outbound_no/borrow_no/sku/bom_no…)→ 名称字段 → 关联物料名 → 「中文表名 - 业务号或ID」。 新增 TABLE_LABELS 表名到中文的映射(18 张白名单表全覆盖)。 用户看到的从 scrap_approval ID:371 变为 「出库申请单 - APR-OUT-20260805-1550-0005」这类可理解的对象描述。 二、前端字段中文化(AuditLog.vue) · fieldMap 由 11 项扩充到 110+ 项,覆盖审批单/流水/库存/主数据/系统管理五类; · 修复关键 bug:fieldMap 原先只作用于「变更对比」区,「删除快照」与 「新增详情」两区直接渲染原始 key(label 直接取 String(key)), 这正是详情里满是英文列名的直接原因。现三区统一经 fieldLabel() 取值。 三、时间显示修正 后端已改写北京时间,前端原先补 Z 当 UTC 解析会造成二次 +8 小时, 改为按 +08:00 理解该字符串。 四、新增两类筛选 · 操作来源(真实用户 / 系统操作 / 全部),默认「真实用户」。 历史存量含约 1.8 万条 username=system 的噪声日志,会把列表刷屏。 · 操作类型别名归一:历史数据中 action 有两套写法(早期装饰器写中文 新增/修改/删除,现行监听器写大写 CREATE/UPDATE/DELETE),导致下拉 同时出现二者、且选中文项只能搜到 3-4 月的老数据。现 ACTION_ALIASES 把任意写法归一化后展开匹配,下拉只暴露 3 个规范值, 历史数据无需迁移即可被正确检索。
2026-09-10 14:16:39 +08:00
# ★ 操作人类型:all(默认) / user(仅真实用户) / system(仅系统)
#
# 背景:改造前的全局监听器没有请求上下文守卫,系统初始化与后台定时任务
# 产生了大量 username='system' 的日志(历史存量约 1.8 万条),会把列表刷屏。
# 新监听器已加守卫不再产生此类记录,但存量数据仍需要能筛掉。
operator_type = request.args.get('operator_type', 'all').strip().lower()
if operator_type not in ('all', 'user', 'system'):
operator_type = 'all'
# 构建查询
query = AuditLog.query
feat(audit): 审计日志业务化——对象名与字段中文化、操作来源与类型筛选 一、target_name 业务化(后端 audit_listener.py) 优先级:业务标识(request_no/outbound_no/borrow_no/sku/bom_no…)→ 名称字段 → 关联物料名 → 「中文表名 - 业务号或ID」。 新增 TABLE_LABELS 表名到中文的映射(18 张白名单表全覆盖)。 用户看到的从 scrap_approval ID:371 变为 「出库申请单 - APR-OUT-20260805-1550-0005」这类可理解的对象描述。 二、前端字段中文化(AuditLog.vue) · fieldMap 由 11 项扩充到 110+ 项,覆盖审批单/流水/库存/主数据/系统管理五类; · 修复关键 bug:fieldMap 原先只作用于「变更对比」区,「删除快照」与 「新增详情」两区直接渲染原始 key(label 直接取 String(key)), 这正是详情里满是英文列名的直接原因。现三区统一经 fieldLabel() 取值。 三、时间显示修正 后端已改写北京时间,前端原先补 Z 当 UTC 解析会造成二次 +8 小时, 改为按 +08:00 理解该字符串。 四、新增两类筛选 · 操作来源(真实用户 / 系统操作 / 全部),默认「真实用户」。 历史存量含约 1.8 万条 username=system 的噪声日志,会把列表刷屏。 · 操作类型别名归一:历史数据中 action 有两套写法(早期装饰器写中文 新增/修改/删除,现行监听器写大写 CREATE/UPDATE/DELETE),导致下拉 同时出现二者、且选中文项只能搜到 3-4 月的老数据。现 ACTION_ALIASES 把任意写法归一化后展开匹配,下拉只暴露 3 个规范值, 历史数据无需迁移即可被正确检索。
2026-09-10 14:16:39 +08:00
if operator_type == 'user':
# 真实用户:排除 system 占位账号
query = query.filter(AuditLog.username != 'system')
elif operator_type == 'system':
query = query.filter(AuditLog.username == 'system')
if username:
query = query.filter(AuditLog.username.like(f'%{username}%'))
if module:
query = query.filter(AuditLog.module == module)
if action:
feat(audit): 审计日志业务化——对象名与字段中文化、操作来源与类型筛选 一、target_name 业务化(后端 audit_listener.py) 优先级:业务标识(request_no/outbound_no/borrow_no/sku/bom_no…)→ 名称字段 → 关联物料名 → 「中文表名 - 业务号或ID」。 新增 TABLE_LABELS 表名到中文的映射(18 张白名单表全覆盖)。 用户看到的从 scrap_approval ID:371 变为 「出库申请单 - APR-OUT-20260805-1550-0005」这类可理解的对象描述。 二、前端字段中文化(AuditLog.vue) · fieldMap 由 11 项扩充到 110+ 项,覆盖审批单/流水/库存/主数据/系统管理五类; · 修复关键 bug:fieldMap 原先只作用于「变更对比」区,「删除快照」与 「新增详情」两区直接渲染原始 key(label 直接取 String(key)), 这正是详情里满是英文列名的直接原因。现三区统一经 fieldLabel() 取值。 三、时间显示修正 后端已改写北京时间,前端原先补 Z 当 UTC 解析会造成二次 +8 小时, 改为按 +08:00 理解该字符串。 四、新增两类筛选 · 操作来源(真实用户 / 系统操作 / 全部),默认「真实用户」。 历史存量含约 1.8 万条 username=system 的噪声日志,会把列表刷屏。 · 操作类型别名归一:历史数据中 action 有两套写法(早期装饰器写中文 新增/修改/删除,现行监听器写大写 CREATE/UPDATE/DELETE),导致下拉 同时出现二者、且选中文项只能搜到 3-4 月的老数据。现 ACTION_ALIASES 把任意写法归一化后展开匹配,下拉只暴露 3 个规范值, 历史数据无需迁移即可被正确检索。
2026-09-10 14:16:39 +08:00
# ★ 兼容历史别名:先把任意写法(中文/小写)归一化为规范值,
# 再展开为该值的全部等价写法一起匹配。
# 否则选「新增」只能搜到早期中文 action 的数据(约 3-4 月),
# 会让用户误以为"没有最近的内容"。
canon = canon_action(action)
if canon in ACTION_ALIASES:
query = query.filter(AuditLog.action.in_(ACTION_ALIASES[canon]))
else:
query = query.filter(AuditLog.action == action)
if target_id:
query = query.filter(AuditLog.target_id == target_id)
if start_date:
try:
start_dt = datetime.strptime(start_date, '%Y-%m-%d')
query = query.filter(AuditLog.created_at >= start_dt)
except ValueError:
pass
if end_date:
try:
end_dt = datetime.strptime(end_date, '%Y-%m-%d')
# 包含当天结束时间
from datetime import timedelta
end_dt = end_dt + timedelta(days=1)
query = query.filter(AuditLog.created_at < end_dt)
except ValueError:
pass
# 【行级数据隔离】通过操作人 username 关联用户表过滤公司
from app.utils.decorators import get_current_company_filter
from app.models.system import SysUser
from sqlalchemy import func
company_limit = get_current_company_filter()
if company_limit is not None:
query = query.join(SysUser, func.split_part(SysUser.username, '/', 2) == AuditLog.username) \
.filter(SysUser.department == company_limit)
# 排序
query = query.order_by(AuditLog.created_at.desc())
# 分页
pagination = query.paginate(page=page, per_page=page_size, error_out=False)
logs = pagination.items
# 序列化
data = [log.to_dict() for log in logs]
# 获取可用的模块和操作类型(同公司范围内)
modules_query = db.session.query(AuditLog.module).distinct()
actions_query = db.session.query(AuditLog.action).distinct()
if company_limit is not None:
modules_query = modules_query.join(SysUser, func.split_part(SysUser.username, '/', 2) == AuditLog.username) \
.filter(SysUser.department == company_limit)
actions_query = actions_query.join(SysUser, func.split_part(SysUser.username, '/', 2) == AuditLog.username) \
.filter(SysUser.department == company_limit)
modules = [m[0] for m in modules_query.all() if m[0]]
feat(audit): 审计日志业务化——对象名与字段中文化、操作来源与类型筛选 一、target_name 业务化(后端 audit_listener.py) 优先级:业务标识(request_no/outbound_no/borrow_no/sku/bom_no…)→ 名称字段 → 关联物料名 → 「中文表名 - 业务号或ID」。 新增 TABLE_LABELS 表名到中文的映射(18 张白名单表全覆盖)。 用户看到的从 scrap_approval ID:371 变为 「出库申请单 - APR-OUT-20260805-1550-0005」这类可理解的对象描述。 二、前端字段中文化(AuditLog.vue) · fieldMap 由 11 项扩充到 110+ 项,覆盖审批单/流水/库存/主数据/系统管理五类; · 修复关键 bug:fieldMap 原先只作用于「变更对比」区,「删除快照」与 「新增详情」两区直接渲染原始 key(label 直接取 String(key)), 这正是详情里满是英文列名的直接原因。现三区统一经 fieldLabel() 取值。 三、时间显示修正 后端已改写北京时间,前端原先补 Z 当 UTC 解析会造成二次 +8 小时, 改为按 +08:00 理解该字符串。 四、新增两类筛选 · 操作来源(真实用户 / 系统操作 / 全部),默认「真实用户」。 历史存量含约 1.8 万条 username=system 的噪声日志,会把列表刷屏。 · 操作类型别名归一:历史数据中 action 有两套写法(早期装饰器写中文 新增/修改/删除,现行监听器写大写 CREATE/UPDATE/DELETE),导致下拉 同时出现二者、且选中文项只能搜到 3-4 月的老数据。现 ACTION_ALIASES 把任意写法归一化后展开匹配,下拉只暴露 3 个规范值, 历史数据无需迁移即可被正确检索。
2026-09-10 14:16:39 +08:00
# ★ action 下拉项归一化:把 CREATE/create/新增 等别名合并为一个规范值,
# 避免下拉框出现「CREATE」与「新增」两个语义重复的选项。
actions = sorted({canon_action(a[0]) for a in actions_query.all() if a[0]})
return jsonify({
'code': 200,
'msg': '获取成功',
'data': {
'list': data,
'total': pagination.total,
'page': page,
'pageSize': page_size,
'modules': modules,
'actions': actions
}
}), 200
except Exception as e:
current_app.logger.error(f"获取审计日志失败: {str(e)}")
return jsonify({'code': 500, 'msg': f'服务器内部错误: {str(e)}'}), 500
@audit_bp.route('/logs/<int:log_id>', methods=['GET'])
@jwt_required()
def get_audit_log_detail(log_id):
"""获取单条审计日志详情"""
try:
log = AuditLog.query.get(log_id)
if not log:
return jsonify({'code': 404, 'msg': '日志不存在'}), 404
return jsonify({
'code': 200,
'msg': '获取成功',
'data': log.to_dict()
}), 200
except Exception as e:
current_app.logger.error(f"获取审计日志详情失败: {str(e)}")
return jsonify({'code': 500, 'msg': str(e)}), 500
@audit_bp.route('/modules', methods=['GET'])
@jwt_required()
def get_modules():
"""获取所有模块列表(用于筛选)"""
try:
modules = db.session.query(AuditLog.module).distinct().all()
modules = [m[0] for m in modules if m[0]]
return jsonify({'code': 200, 'data': modules}), 200
except Exception as e:
current_app.logger.error(f"获取模块列表失败: {str(e)}")
return jsonify({'code': 500, 'msg': str(e)}), 500