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 个规范值,
历史数据无需迁移即可被正确检索。
This commit is contained in:
@ -11,6 +11,38 @@ import json
|
||||
audit_bp = Blueprint('audit', __name__)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 操作类型归一化
|
||||
#
|
||||
# 问题背景:历史数据里 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')
|
||||
@ -29,14 +61,37 @@ def get_audit_logs():
|
||||
start_date = request.args.get('start_date', '').strip()
|
||||
end_date = request.args.get('end_date', '').strip()
|
||||
|
||||
# ★ 操作人类型: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
|
||||
|
||||
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:
|
||||
# ★ 兼容历史别名:先把任意写法(中文/小写)归一化为规范值,
|
||||
# 再展开为该值的全部等价写法一起匹配。
|
||||
# 否则选「新增」只能搜到早期中文 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)
|
||||
@ -84,7 +139,10 @@ def get_audit_logs():
|
||||
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]]
|
||||
actions = [a[0] for a in actions_query.all() if a[0]]
|
||||
|
||||
# ★ action 下拉项归一化:把 CREATE/create/新增 等别名合并为一个规范值,
|
||||
# 避免下拉框出现「CREATE」与「新增」两个语义重复的选项。
|
||||
actions = sorted({canon_action(a[0]) for a in actions_query.all() if a[0]})
|
||||
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
|
||||
@ -9,6 +9,14 @@
|
||||
|
||||
<!-- 搜索条件 -->
|
||||
<el-form :inline="true" :model="queryParams" class="search-form">
|
||||
<!-- ★ 操作人类型:默认只看真实用户,避免被系统日志刷屏 -->
|
||||
<el-form-item label="操作来源">
|
||||
<el-radio-group v-model="queryParams.operator_type" @change="handleQuery">
|
||||
<el-radio-button label="user">真实用户</el-radio-button>
|
||||
<el-radio-button label="system">系统操作</el-radio-button>
|
||||
<el-radio-button label="all">全部</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="操作人">
|
||||
<el-input v-model="queryParams.username" placeholder="请输入操作人账号" clearable @keyup.enter="handleQuery" style="width: 150px" />
|
||||
</el-form-item>
|
||||
@ -19,7 +27,13 @@
|
||||
</el-form-item>
|
||||
<el-form-item label="操作类型">
|
||||
<el-select v-model="queryParams.action" placeholder="请选择操作类型" clearable style="width: 120px">
|
||||
<el-option v-for="item in actionOptions" :key="item" :label="item" :value="item" />
|
||||
<!-- ★ 下拉只列规范值,显示为中文;后端会把历史别名一并匹配 -->
|
||||
<el-option
|
||||
v-for="item in actionOptions"
|
||||
:key="item"
|
||||
:label="actionMap[item] || item"
|
||||
:value="item"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="时间范围">
|
||||
@ -46,7 +60,13 @@
|
||||
<!-- 表格 -->
|
||||
<el-table v-loading="tableLoading" :data="tableData" border stripe>
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="username" label="操作人" width="120" />
|
||||
<el-table-column prop="username" label="操作人" width="140">
|
||||
<template #default="scope">
|
||||
<!-- ★ 系统日志加灰色标签,便于在"全部"视图下快速识别 -->
|
||||
<el-tag v-if="scope.row.username === 'system'" type="info" size="small">系统</el-tag>
|
||||
<span v-else>{{ scope.row.username }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="display_name" label="姓名" width="100" />
|
||||
<el-table-column prop="module" label="模块" width="120">
|
||||
<template #default="scope">
|
||||
@ -124,7 +144,7 @@
|
||||
<el-table :data="changesList" border stripe size="small" max-height="350">
|
||||
<el-table-column label="字段名" width="150">
|
||||
<template #default="{ row }">
|
||||
<span class="field-name">{{ fieldMap[row.field] || row.field }}</span>
|
||||
<span class="field-name">{{ fieldLabel(row.field) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="修改前" min-width="200">
|
||||
@ -150,7 +170,7 @@
|
||||
<el-descriptions-item
|
||||
v-for="(value, key) in deletedSnapshot"
|
||||
:key="String(key)"
|
||||
:label="String(key)"
|
||||
:label="fieldLabel(key)"
|
||||
:span="isLongValue(value) ? 2 : 1"
|
||||
>
|
||||
<span class="snapshot-value">{{ formatValue(value) }}</span>
|
||||
@ -168,7 +188,7 @@
|
||||
<el-descriptions-item
|
||||
v-for="(value, key) in createdData"
|
||||
:key="String(key)"
|
||||
:label="String(key)"
|
||||
:label="fieldLabel(key)"
|
||||
:span="isLongValue(value) ? 2 : 1"
|
||||
>
|
||||
<span class="snapshot-value">{{ formatValue(value) }}</span>
|
||||
@ -207,6 +227,8 @@ const total = ref(0)
|
||||
const queryParams = reactive({
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
// ★ 默认「真实用户」:改造前的系统日志存量较多,默认过滤掉以免刷屏
|
||||
operator_type: 'user' as 'user' | 'system' | 'all',
|
||||
username: '',
|
||||
module: '',
|
||||
action: '',
|
||||
@ -227,34 +249,149 @@ const actionOptions = ref<string[]>(['create', 'update', 'delete', 'export', 'im
|
||||
// ============================================================
|
||||
|
||||
// 操作类型中文化映射
|
||||
// 同时兼容两种大小写:当前监听器写入 UPDATE/CREATE/DELETE(大写),
|
||||
// 历史数据中存在小写与零星中文(如 '修改' / '批量删除'),均需可读。
|
||||
const actionMap: Record<string, string> = {
|
||||
'UPDATE': '修改',
|
||||
'CREATE': '新增',
|
||||
'DELETE': '删除',
|
||||
'LOGIN': '登录',
|
||||
'LOGOUT': '登出'
|
||||
'UPDATE': '修改', 'update': '修改',
|
||||
'CREATE': '新增', 'create': '新增', 'INSERT': '新增', 'insert': '新增',
|
||||
'DELETE': '删除', 'delete': '删除',
|
||||
'LOGIN': '登录', 'login': '登录',
|
||||
'LOGOUT': '登出', 'logout': '登出'
|
||||
};
|
||||
|
||||
// 字段名中文化映射 (常见业务字段)
|
||||
// ============================================================
|
||||
// 字段名中文化映射
|
||||
//
|
||||
// ★ 覆盖范围:审批单 / 流水 / 库存 / 主数据 / 系统管理 五类核心业务表。
|
||||
// 未命中的字段由 fieldLabel() 兜底为「原字段名」,不会再出现大面积英文列名。
|
||||
// ★ 用法:变更对比、新增详情、删除快照三个区块统一经 fieldLabel() 取值。
|
||||
// (改造前仅"变更对比"区用了 fieldMap,另外两区直接渲染原始 key,
|
||||
// 这是"详情里一堆英文列名"的直接原因。)
|
||||
// ============================================================
|
||||
const fieldMap: Record<string, string> = {
|
||||
'available_quantity': '可用库存',
|
||||
'in_quantity': '入库数量',
|
||||
'stock_quantity': '总库存',
|
||||
'out_quantity': '出库数量',
|
||||
// --- 通用 ---
|
||||
'id': 'ID',
|
||||
'name': '名称',
|
||||
'title': '标题',
|
||||
'remark': '备注',
|
||||
'reason': '原因',
|
||||
'status': '状态',
|
||||
'created_at': '创建时间',
|
||||
'updated_at': '更新时间',
|
||||
'is_active': '是否启用',
|
||||
'is_enabled': '是否启用',
|
||||
'company_name': '所属公司',
|
||||
'operator_name': '操作人',
|
||||
'operator': '操作人',
|
||||
|
||||
// --- 物料主数据 ---
|
||||
'material_name': '物料名称',
|
||||
'spec_model': '规格型号',
|
||||
'category': '类别',
|
||||
'status': '状态',
|
||||
'remark': '备注',
|
||||
'is_active': '是否启用'
|
||||
'material_type': '物料类型',
|
||||
'unit': '单位',
|
||||
'base_id': '物料ID',
|
||||
'sku': 'SKU',
|
||||
'batch_number': '批次号',
|
||||
'serial_number': '序列号',
|
||||
'barcode': '条码',
|
||||
'warehouse_location': '库位',
|
||||
'warehouse_loc': '库位',
|
||||
'reference_price': '参考价格',
|
||||
'is_approval_required': '是否需审批',
|
||||
'is_inspection_required': '是否需质检',
|
||||
|
||||
// --- 库存数量 ---
|
||||
'in_quantity': '入库数量',
|
||||
'stock_quantity': '总库存',
|
||||
'available_quantity': '可用库存',
|
||||
'out_quantity': '出库数量',
|
||||
'quantity': '数量',
|
||||
'pre_tax_unit_price': '不含税单价',
|
||||
'post_tax_unit_price': '含税单价',
|
||||
'unit_price': '单价',
|
||||
'total_price': '总价',
|
||||
'tax_rate': '税率',
|
||||
'supplier_name': '供应商',
|
||||
'buyer_name': '采购员',
|
||||
|
||||
// --- 审批单 ---
|
||||
'request_no': '申请单号',
|
||||
'applicant_id': '申请人ID',
|
||||
'allowed_approvers': '允许审批人',
|
||||
'actual_approver_id': '实际审批人ID',
|
||||
'approved_at': '审批时间',
|
||||
'reject_reason': '驳回原因',
|
||||
'items_json': '物料明细',
|
||||
'outbound_type': '出库类型',
|
||||
'consumer_name': '领用人/客户',
|
||||
'borrower_name': '借用人',
|
||||
'executed_at': '执行时间',
|
||||
'executor_name': '执行人',
|
||||
|
||||
// --- 流水 ---
|
||||
'outbound_no': '出库单号',
|
||||
'outbound_time': '出库时间',
|
||||
'borrow_no': '借出单号',
|
||||
'borrow_time': '借出时间',
|
||||
'expected_return_time': '预计归还时间',
|
||||
'return_time': '归还时间',
|
||||
'return_operator': '归还操作人',
|
||||
'is_returned': '是否已归还',
|
||||
'scrap_request_no': '报废申请单号',
|
||||
'operation_time': '操作时间',
|
||||
'source_table': '来源表',
|
||||
'stock_id': '库存ID',
|
||||
'cost_at_scrap': '报废成本',
|
||||
'total_loss': '损失金额',
|
||||
'signature_path': '签名',
|
||||
|
||||
// --- 采购 / BOM ---
|
||||
'purchase_date': '采购日期',
|
||||
'requester_id': '申请人ID',
|
||||
'approver_id': '审批人ID',
|
||||
'bom_no': 'BOM编号',
|
||||
'bom_version': 'BOM版本',
|
||||
'parent_id': '父级ID',
|
||||
'child_id': '子级ID',
|
||||
|
||||
// --- 系统管理 ---
|
||||
'username': '用户名',
|
||||
'display_name': '显示名',
|
||||
'email': '邮箱',
|
||||
'role': '角色',
|
||||
'department': '部门',
|
||||
'password_hash': '密码哈希',
|
||||
'code': '编码',
|
||||
'path': '路径',
|
||||
'sort_order': '排序',
|
||||
'is_visible': '是否可见',
|
||||
'menu_code': '菜单编码',
|
||||
'element_type': '元素类型',
|
||||
'role_code': '角色编码',
|
||||
'target_code': '目标编码',
|
||||
'user_agent': '浏览器标识',
|
||||
'ip_address': 'IP地址'
|
||||
};
|
||||
|
||||
// 时间格式化:将后端的 UTC 时间字符串转换为本地时间 (UTC+8)
|
||||
// 字段中文名解析:命中映射取中文,否则原样返回
|
||||
const fieldLabel = (key: string | number): string => {
|
||||
const k = String(key)
|
||||
return fieldMap[k] || k
|
||||
}
|
||||
|
||||
// 时间格式化
|
||||
//
|
||||
// ★ 改造说明:后端 audit_listener 现在显式写入**北京时间**(beijing_time),
|
||||
// 返回的字符串已经是 UTC+8 的钟面值。此处**不能**再补 'Z' 当 UTC 解析,
|
||||
// 否则会被浏览器二次加 8 小时(原实现即存在该问题)。
|
||||
// 策略:把后端字符串按原样格式化输出;仅当浏览器不在 UTC+8 时,
|
||||
// 才需要按「字符串即北京时间」做等值换算。
|
||||
const formatLocalTime = (timeStr: string) => {
|
||||
if (!timeStr) return '-'
|
||||
// 补全 'Z' 让浏览器识别为 UTC 时间,自动转为当前系统的时区
|
||||
const date = new Date(timeStr.replace(' ', 'T') + 'Z')
|
||||
const iso = timeStr.replace(' ', 'T')
|
||||
// 按北京时间(UTC+8)理解该字符串,转为本地时间展示
|
||||
const date = new Date(iso + '+08:00')
|
||||
if (isNaN(date.getTime())) return timeStr
|
||||
|
||||
const y = date.getFullYear()
|
||||
@ -404,6 +541,8 @@ const handleQuery = () => {
|
||||
|
||||
const handleReset = () => {
|
||||
queryParams.page = 1
|
||||
// 重置回默认视图(真实用户),而非"全部"——与页面初始状态保持一致
|
||||
queryParams.operator_type = 'user'
|
||||
queryParams.username = ''
|
||||
queryParams.module = ''
|
||||
queryParams.action = ''
|
||||
|
||||
Reference in New Issue
Block a user