From 52a3c4079cdafac28ac2b29d03926a3c52a5abf4 Mon Sep 17 00:00:00 2001 From: yueli Date: Mon, 28 Sep 2026 11:15:15 +0800 Subject: [PATCH] =?UTF-8?q?feat(audit):=20=E6=93=8D=E4=BD=9C=E4=BA=BA?= =?UTF-8?q?=E7=AD=9B=E9=80=89=E6=94=B9=E4=B8=BA=E4=B8=8B=E6=8B=89=E9=80=89?= =?UTF-8?q?=E6=8B=A9=EF=BC=8C=E8=BF=87=E6=BB=A4=E7=94=B1=E6=A8=A1=E7=B3=8A?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E7=B2=BE=E7=A1=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原来的「操作人」是自由文本输入,后端按 `LIKE %值%` 模糊匹配。 后端: · 新增 GET /audit/operators,返回 [{value: 账号, label: '名(账号)'}], 按记录数降序(最活跃的排最前),排除 system;/logs 也内联一份省一次请求。 · username 过滤由 LIKE 改**精确匹配**。选出来的是完整账号,再模糊匹配就是错的: 实测 `LIKE '%gao%'` 会多带出 2439 条非 gaoxue 的记录,将来出现 gaoxue / gaoxue2 两个账号时,选前者会连带查出后者的记录。 部分匹配的需求由下拉的 filterable(在选项里搜)承担,不落到 SQL。 ★ 选项从 **audit_logs** 取,不是从 sys_user:审计记的是操作发生时的账号, 用户被删/改名后 sys_user 就查不到,而历史审计仍需能按他筛选 —— 实测 32 个操作人里有 1 个在 sys_user 中已不存在。 display_name 本来就在审计行上('杜邢宸(duxingchen)'),不必 join。 ★ 排除 system:它不是人,「操作来源」那组单选(真实用户/系统操作/全部) 已经专门管它,混进下拉会让两个控件语义打架。 ★ /operators 加了 system_audit 权限码,与 /modules(仅 JWT)**故意不同**: /modules 给的是模块名,这个给的是**人员账号清单**。它唯一的消费者就是 审计页,而审计页本身要 system_audit —— 没道理让人绕开页面直接拉全员名单。 前端 AuditLog.vue: · 操作人由 el-input 改为 el-select(clearable + filterable), 选项由后端下发,前端不硬编。 · 拉取失败(无权限 403 等)时 catch 住,下拉优雅退化为空,不抛未捕获异常。 验证(21 + 20 项断言全过): · 32 个操作人全覆盖、排除 system、label 带中文名、按记录数降序 · 精确匹配与 SQL count 逐一核对(gaoxue 2519 / duxingchen 16863 / liuqi 1236), 且结果里不含他人 · 导出口径在与列表一致的 4 组筛选下逐一对齐(含操作人筛选) · 权限矩阵:/logs 与 /export 无 token 401、无权限 403;/operators 无权限 403; /labels 保持仅 JWT(纯静态标签) · 日报三天附件 490.5K/193.4K/92.3K 与 6 列结构未变;聚合行仍可读 · vue-tsc 与 vite build exit=0 --- inventory-backend/app/api/v1/audit.py | 74 ++++++++++++++++++++- inventory-web/src/api/audit.ts | 11 +++ inventory-web/src/views/system/AuditLog.vue | 47 +++++++++++-- 3 files changed, 124 insertions(+), 8 deletions(-) diff --git a/inventory-backend/app/api/v1/audit.py b/inventory-backend/app/api/v1/audit.py index bb6c30e..96748bf 100644 --- a/inventory-backend/app/api/v1/audit.py +++ b/inventory-backend/app/api/v1/audit.py @@ -86,9 +86,14 @@ def _build_audit_query(args): elif operator_type == 'system': query = query.filter(AuditLog.username == SYSTEM_USERNAME) + # ---------- 操作人(精确匹配)---------- + # ★ 由模糊(LIKE %值%)改为精确:前端的操作人已从自由输入改为**下拉选择**, + # 选出来的是完整账号,再模糊匹配就是错的 —— 将来出现 `gaoxue` 与 + # `gaoxue2` 两个账号时,选前者会连带把后者的记录一起查出来。 + # 部分匹配的需求由下拉的 filterable(在选项里搜)承担,不需要落到 SQL。 username = (args.get('username') or '').strip() if username: - query = query.filter(AuditLog.username.like(f'%{username}%')) + query = query.filter(AuditLog.username == username) # ---------- 模块(支持多选 + 聚合别名)---------- # ★ 经 expand_modules 展开:「入库(全部)」会变成它名下的全部成员值。 @@ -243,6 +248,48 @@ def _serialize_logs(rows): return out +def _operator_options(company_limit): + """ + 操作人下拉选项:[{'value': 账号, 'label': '中文名(账号)'}],按记录数降序。 + + ★ 从 **audit_logs** 取而不是从 sys_user:审计里记的是操作发生时的账号, + 用户被删/改名后 sys_user 就查不到了,而历史审计仍需要能按他筛选。 + 实测 32 个操作人里有 1 个在 sys_user 中已不存在。 + + ★ display_name 本来就在审计行上('杜邢宸(duxingchen)'),不必 join。 + + ★ 按记录数降序:最活跃的人排最前,下拉一打开就是常用的那几个。 + + ★ 排除 system:它不是人,且「操作来源」那组单选已经专门管它 + (真实用户 / 系统操作 / 全部)。混进来只会让两个控件语义打架。 + """ + q = db.session.query( + AuditLog.username, + func.max(AuditLog.display_name), + func.count().label('n'), + ).filter(AuditLog.username != SYSTEM_USERNAME) + if company_limit is not None: + q = q.join(SysUser, func.split_part(SysUser.username, '/', 2) == AuditLog.username) \ + .filter(SysUser.department == company_limit) + q = q.group_by(AuditLog.username).order_by(func.count().desc()) + + out = [] + for name, display, _n in q.all(): + if not name: + continue + dn = (display or '').strip() + # display_name 库里存法不统一('高雪(gaoxue)' / '高闯/gaochuang' / 空), + # 统一成「名(账号)」;拿不到名字就只显示账号。 + label = name + for sep in ('(', '/'): + if sep in dn: + dn = dn.split(sep)[0].strip() + if dn and dn != name: + label = f"{dn}({name})" + out.append({'value': name, 'label': label}) + return out + + def _distinct_with_company(column, company_limit): """ 取某列的去重值(带公司隔离)。 @@ -298,7 +345,9 @@ def get_audit_logs(): # ★ [{value,label}],历史命名已折叠进聚合项且不再单独列出 —— # 规则由后端 module_options() 统一决定,前端零硬编。 'modules': module_options(raw_modules), - 'actions': actions + 'actions': actions, + # 操作人下拉选项(与 /operators 同源,顺带回一份省一次请求) + 'operators': _operator_options(company_limit), } }), 200 @@ -424,6 +473,27 @@ def get_modules(): return jsonify({'code': 500, 'msg': str(e)}), 500 +@audit_bp.route('/operators', methods=['GET']) +@jwt_required() +@permission_required('system_audit') +def get_operators(): + """ + 获取操作人下拉选项(用于筛选)。 + + 返回 [{value: 账号, label: '名(账号)'}],按记录数降序,已排除 system。 + + ★ 加权限码,与 /modules(仅 JWT)**故意不同**:/modules 给的是模块名, + 这个给的是**人员账号清单**。它的唯一消费者就是审计页,而审计页本身 + 要 system_audit —— 没道理让人绕开页面直接拉全员名单。 + """ + try: + company_limit = get_current_company_filter() + return jsonify({'code': 200, 'data': _operator_options(company_limit)}), 200 + except Exception as e: + current_app.logger.error(f"获取操作人列表失败: {str(e)}") + return jsonify({'code': 500, 'msg': str(e)}), 500 + + @audit_bp.route('/labels', methods=['GET']) @jwt_required() def get_labels(): diff --git a/inventory-web/src/api/audit.ts b/inventory-web/src/api/audit.ts index 77b2f89..10793cc 100644 --- a/inventory-web/src/api/audit.ts +++ b/inventory-web/src/api/audit.ts @@ -25,6 +25,17 @@ export function getAuditModules() { }) } +// 获取可选操作人列表 +// +// ★ 返回 [{value: 账号, label: '名(账号)'}],按记录数降序,已排除 system。 +// 从审计日志本身取而非用户表:用户被删/改名后仍能按历史账号筛选。 +export function getAuditOperators() { + return request({ + url: '/audit/operators', + method: 'get' + }) +} + // 获取操作类型 / 字段名的中文映射 // ★ 后端为唯一来源,前端不再自带副本(见 views/system/AuditLog.vue 的说明) export function getAuditLabels() { diff --git a/inventory-web/src/views/system/AuditLog.vue b/inventory-web/src/views/system/AuditLog.vue index 1fc35d2..bffc77f 100644 --- a/inventory-web/src/views/system/AuditLog.vue +++ b/inventory-web/src/views/system/AuditLog.vue @@ -18,13 +18,27 @@ - + + filterable + style="width: 180px" + > + +