Files
KCGL/inventory-backend/app/api/v1/audit.py
yueli 52a3c4079c feat(audit): 操作人筛选改为下拉选择,过滤由模糊改为精确
原来的「操作人」是自由文本输入,后端按 `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
2026-09-28 11:15:15 +08:00

511 lines
22 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.

# inventory-backend/app/api/v1/audit.py
import io
from datetime import datetime, timedelta
from flask import Blueprint, current_app, jsonify, request, send_file
from flask_jwt_extended import jwt_required
from sqlalchemy import func, or_
from app.extensions import db
from app.models.audit import AuditLog
from app.models.system import SysUser
from app.services.audit_export_service import (
SYSTEM_USERNAME,
build_audit_workbook,
changes_summary,
format_target_display,
load_child_lookup,
load_material_context,
load_ref_maps,
sanitize_details,
)
from app.utils.decorators import get_current_company_filter, permission_required
audit_bp = Blueprint('audit', __name__)
# =============================================================================
# 操作类型归一化 / 中文化标签
#
# ★ 映射表已统一收敛到 app/utils/audit_labels.py(**唯一来源**)。
# 本模块与日报服务共用同一份,前端经 GET /audit/labels 拉取同一份 ——
# 改一处即可,不会再出现"两边手工同步、改一边漏一边"的漂移。
#
# 历史问题(迁移前):前端 AuditLog.vue 自带一份 fieldMap,后端这里自带一份
# ACTION_ALIASES,两份副本各自演化。
# =============================================================================
from app.utils.audit_labels import ( # noqa: E402
ACTION_ALIASES,
canon_action,
expand_modules,
labels_payload,
module_options,
)
# 列表页「变更摘要」列的截断长度。太长会把表格撑变形且扫读性差 ——
# 完整逐字段对比在详情抽屉里,摘要只负责"一眼看出改了啥"。
SUMMARY_LIMIT = 120
# 单次导出的记录数上限。
#
# ★ 这是**熔断**,不是"为了让报表好看"的截断:
# · Excel 单表硬上限 1,048,576 行,长表展开后会成倍放大;
# · 同步请求跑太久会超时,用户只看到"失败",不知道是数据量的问题。
# 实测当前全库共 5.8 万条、全量导出 3.3 秒 / 2MB,正常情况下永远碰不到这个值。
# 触顶时会**显式告知**(写进文件名和汇总工作表),不静默丢弃 ——
# 报表的读法就是"看到的就是全部",偷偷砍掉比报错更危险。
EXPORT_LIMIT = 100000
# =============================================================================
# 查询构造 —— /logs 与 /logs/export 的**唯一**入口
# =============================================================================
def _build_audit_query(args):
"""
按请求参数构造审计日志查询,返回 (query, company_limit)。
★ 列表与导出共用本函数。两处各写一份筛选逻辑,迟早会出现
"页面上看到 300 条、导出来 280 条"这种对不上的情况 ——
而报表对不上,比没有报表更糟(人会照着错的数字做决定)。
这正是 audit_labels.py 开头警告过的"两份手工同步的副本必然漂移"。
★ 可多选的参数用逗号分隔(与仓库既有惯例一致:semi.vue 的 statuses
用 join(',') 传参、inbound/product.py 用 split(',') 接收)。
"""
query = AuditLog.query
# ---------- 操作来源:all(默认) / user(仅真实用户) / system(仅系统) ----------
# 背景:改造前的全局监听器没有请求上下文守卫,系统初始化与后台定时任务
# 产生了大量 username='system' 的日志(历史存量约 1.8 万条),会把列表刷屏。
operator_type = (args.get('operator_type') or 'all').strip().lower()
if operator_type not in ('all', 'user', 'system'):
operator_type = 'all'
if operator_type == 'user':
query = query.filter(AuditLog.username != SYSTEM_USERNAME)
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 == username)
# ---------- 模块(支持多选 + 聚合别名)----------
# ★ 经 expand_modules 展开:「入库(全部)」会变成它名下的全部成员值。
# 这一步是必需的 —— 审计的 module 在 2026-09-10 换过一次口径
# (旧的「入库管理」→ 新的「库存管理」),不展开就会在换口径那天
# 断掉,用户看到"入库记录突然没了"。
# 详见 audit_labels.MODULE_GROUPS 的说明。
modules = [m for m in (args.get('module') or '').split(',') if m.strip()]
if modules:
query = query.filter(AuditLog.module.in_(expand_modules(modules)))
# ---------- 操作类型(支持多选)----------
actions = [a for a in (args.get('action') or '').split(',') if a.strip()]
if actions:
# ★ 逐个归一化再合并别名集合,不能只处理第一个:
# 历史数据里 CREATE 与「新增」混用,多选时若只对首个值展开别名,
# 其余选项就搜不到早期数据(4 月前的中文 action)。
aliases = set()
for a in actions:
canon = canon_action(a)
aliases.update(ACTION_ALIASES.get(canon, (a,)))
query = query.filter(AuditLog.action.in_(aliases))
target_id = (args.get('target_id') or '').strip()
if target_id:
query = query.filter(AuditLog.target_id == target_id)
# ---------- 操作对象模糊搜索 ----------
# 与 target_id 的分工:target_id 是**精确**匹配(程序化调用、追单条记录用),
# target_keyword 是给人在搜索框里用的模糊匹配,同时命中单号/编码/名称。
#
# ★ 用 ILIKE '%kw%' 走不了 btree 索引,会顺序扫描。当前全库 5.8 万条实测
# 毫秒级,可接受;若将来量级上去了,再加 pg_trgm 的 GIN 索引即可
# (不需要改这里的写法)。
target_keyword = (args.get('target_keyword') or '').strip()
if target_keyword:
query = query.filter(or_(
AuditLog.target_id.ilike(f'%{target_keyword}%'),
AuditLog.target_name.ilike(f'%{target_keyword}%'),
))
# ---------- 日期区间 ----------
# 语义是**闭区间**:end_date 当天 23:59:59 的数据也要包含进来,
# 故上界取次日 00:00 的严格小于。写成 <= end_date 会静默丢掉当天。
start_date = (args.get('start_date') or '').strip()
if start_date:
try:
query = query.filter(
AuditLog.created_at >= datetime.strptime(start_date, '%Y-%m-%d'))
except ValueError:
pass
end_date = (args.get('end_date') or '').strip()
if end_date:
try:
query = query.filter(
AuditLog.created_at < datetime.strptime(end_date, '%Y-%m-%d') + timedelta(days=1))
except ValueError:
pass
# ---------- 【行级数据隔离】----------
# AuditLog 表**没有** company_name 字段,公司信息只能经 SysUser.department 反查。
# 审计日志的 username 存的是账号('gaoxue'),而 sys_user.username 存的是
# '高雪/gaoxue',故取 '/' 之后的账号部分去 join。
#
# ★ 导出接口必须带同样的隔离,否则"导出"就成了绕过数据隔离的后门 ——
# 页面按公司过滤、导出却给全量,这个洞比页面越权更隐蔽。
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)
return query, company_limit
def _filter_summary_rows(args):
"""
把本次生效的筛选条件写成汇总表的几行。
★ 收到 Excel 的人看不到页面上的筛选框,没有这段说明就无从判断
"这份表是全量还是某个子集"。
"""
rows = []
operator_type = (args.get('operator_type') or '').strip().lower()
if operator_type:
rows.append(('筛选条件', '操作来源',
{'user': '仅真实用户', 'system': '仅系统操作'}.get(operator_type, '全部')))
labels = [
('username', '操作人(模糊匹配)'),
('module', '模块'),
('action', '操作类型'),
('target_id', '目标ID'),
('target_keyword', '操作对象(模糊匹配)'),
]
for key, label in labels:
val = (args.get(key) or '').strip()
if val:
rows.append(('筛选条件', label, val))
start_date = (args.get('start_date') or '').strip()
end_date = (args.get('end_date') or '').strip()
if start_date or end_date:
rows.append(('筛选条件', '日期区间',
f"{start_date or '不限'} ~ {end_date or '不限'}"))
return rows
def _serialize_logs(rows):
"""
审计记录 → 响应 dict(列表与详情共用)。
在 to_dict() 之上补两件事:
★ **action 归一化**。历史数据里 CREATE 与「新增」混用(早期装饰器写中文,
现行监听器写英文)。归一后前端不必再为每种历史写法兜底 —— 否则每个
新消费者都要记得再兜一次,漏一个就静默显示英文/中文原值。
归一用 audit_labels.canon_action(**唯一来源**,覆盖全部历史别名),
不在模型上另建一份映射:那份只认识 3 个中文词,`批量删除`/`分配`/`归还`
这类别名会漏掉。
★ **变更摘要**。这里算而不是做成模型 @property:摘要要把 user_id / base_id
翻成人名 / 物料名,需要查库;模型属性里发查询就是 N+1。
改为整页一次性批量解析(load_ref_maps 按 ref 类型各查一次),
50 条的页面对应 2~3 次查询,与逐行查库是两回事。
"""
if not rows:
return []
ref_maps = load_ref_maps(rows)
# ★ 操作对象补全:库里 target_name 大多只存了 SKU('0000002270')甚至内部
# 标识('stock_buy ID:1667'),业务人员看不懂。补成
# 「SKU - 物料名称 (规格型号)」;解不出物料的行不补,前端回落原始值。
# 见 load_material_context —— 撞号的 target_id 一律放弃,宁可留空不补错。
materials = load_material_context(rows)
# ★ 父子关系记录(BOM)的子件:批量解析,无 N+1。见 load_child_lookup。
children = load_child_lookup(rows)
out = []
for r in rows:
d = r.to_dict()
# ★ 在**接口出口**剥掉凭据类字段。只靠前端隐藏不够 ——
# 原始响应里照样有明文密码,打开 devtools 就读得到。
# 见 audit_export_service.sanitize_details。
d['details'] = sanitize_details(d.get('details'))
d['action'] = canon_action(d.get('action'))
mat = materials.get(r.id)
# 保留原始 target_name(搜索仍按它匹配),展示用 target_display
d['target_display'] = format_target_display(mat)
d['summary'] = changes_summary(
r, ref_maps, limit=SUMMARY_LIMIT, material=mat,
child=children.get(r.id))
out.append(d)
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):
"""
取某列的去重值(带公司隔离)。
★ 下拉选项也要隔离:不带的话,A 公司的用户在筛选框里能看到 B 公司
才有的模块名,属于低危但确实存在的信息泄露。
"""
q = db.session.query(column).distinct()
if company_limit is not None:
q = q.join(SysUser, func.split_part(SysUser.username, '/', 2) == AuditLog.username) \
.filter(SysUser.department == company_limit)
return [v[0] for v in q.all() if v[0]]
# =============================================================================
# 接口
# =============================================================================
@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)
query, company_limit = _build_audit_query(request.args)
# 排序
query = query.order_by(AuditLog.created_at.desc())
# 分页
pagination = query.paginate(page=page, per_page=page_size, error_out=False)
data = _serialize_logs(pagination.items)
# 获取可用的模块和操作类型(同公司范围内)
raw_modules = _distinct_with_company(AuditLog.module, company_limit)
actions_raw = _distinct_with_company(AuditLog.action, company_limit)
# ★ action 下拉项归一化:把 CREATE/create/新增 等别名合并为一个规范值,
# 避免下拉框出现「CREATE」与「新增」两个语义重复的选项。
actions = sorted({canon_action(a) for a in actions_raw})
return jsonify({
'code': 200,
'msg': '获取成功',
'data': {
'list': data,
'total': pagination.total,
'page': page,
'pageSize': page_size,
# ★ [{value,label}],历史命名已折叠进聚合项且不再单独列出 ——
# 规则由后端 module_options() 统一决定,前端零硬编。
'modules': module_options(raw_modules),
'actions': actions,
# 操作人下拉选项(与 /operators 同源,顺带回一份省一次请求)
'operators': _operator_options(company_limit),
}
}), 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/export', methods=['GET'])
@jwt_required()
@permission_required('system_audit')
def export_audit_logs():
"""
按筛选条件导出审计日志为 Excel(同步返回文件流)。
★ 筛选条件与 GET /logs **完全一致**(共用 _build_audit_query),
且**忽略分页参数** —— 导的是符合条件的全量,不是当前页。
「只导当前页 50 条」是导出功能最常见的误解,故这里明确不支持。
★ 为什么同步返回而不是走 export_service 那套异步任务:
实测全库 5.8 万条导出耗时 3.3 秒、2MB,同步完全够用;
而异步那套要落盘(uploads/exports/)且**没有清理机制**,
日积月累会把磁盘吃掉 —— 见 export_service/excel_task.py。
"""
try:
query, _ = _build_audit_query(request.args)
# 台账按时间倒序:导出的人多半想看"最近的"(与列表页一致)
query = query.order_by(AuditLog.created_at.desc())
total = query.count()
truncated = total > EXPORT_LIMIT
rows = query.limit(EXPORT_LIMIT).all() if truncated else query.all()
note = None
if truncated:
note = (f"匹配记录共 {total} 条,超过单次导出上限 {EXPORT_LIMIT} 条,"
f"本次仅导出前 {EXPORT_LIMIT} 条。请缩小日期范围后分批导出。")
data = build_audit_workbook(
rows,
summary_rows=_filter_summary_rows(request.args),
note=note,
)
stamp = datetime.now().strftime('%Y%m%d_%H%M%S')
# 文件名里带上「部分」—— 用户不解压也能看出这不是全量
name = (f"审计日志_部分{EXPORT_LIMIT}条_{stamp}.xlsx" if truncated
else f"审计日志_{stamp}.xlsx")
return send_file(
io.BytesIO(data),
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
as_attachment=True,
download_name=name,
)
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()
@permission_required('system_audit')
def get_audit_log_detail(log_id):
"""
获取单条审计日志详情。
★ 补权限码与公司隔离。此前这里**只有 @jwt_required()**,任何登录用户
改一下 URL 里的 id 就能读到全部审计明细(含 details 里的完整快照,
那是被操作记录的原始字段值)。列表接口有 system_audit 把关,
详情接口提供的信息是列表的超集,没道理比列表更宽松。
★ 越权与不存在**统一返回 404**,不区分二者 —— 区分开来就等于告诉
探测者"这个 id 是存在的,只是你没权限",反而泄露了数据规模。
"""
try:
query = AuditLog.query.filter(AuditLog.id == log_id)
# 与列表用同一个公司判据(SysUser.department 反查),保证两处一致:
# 列表里看不到的记录,详情也不该看得到。
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)
log = query.first()
if not log:
return jsonify({'code': 404, 'msg': '日志不存在'}), 404
return jsonify({
'code': 200,
'msg': '获取成功',
# 与列表同一套序列化:action 归一 + 摘要(详情页也用得上同一条摘要)
'data': _serialize_logs([log])[0]
}), 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():
"""
获取模块下拉选项(用于筛选)。
返回 [{value, label}] 而不是裸的 module 字符串 —— 历史命名已被折叠进
「入库」聚合项,规则由 audit_labels.module_options() 统一决定。
★ 只挂 @jwt_required()、不加权限码:这里返回的是模块**名称**(不含任何
业务记录),且已做公司隔离;审计页本身由 /logs 的权限码把关,
与 /labels 的处理一致。
"""
try:
company_limit = get_current_company_filter()
raw_modules = _distinct_with_company(AuditLog.module, company_limit)
return jsonify({'code': 200, 'data': module_options(raw_modules)}), 200
except Exception as e:
current_app.logger.error(f"获取模块列表失败: {str(e)}")
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():
"""
下发操作类型与字段名的中文映射。
★ 后端是**唯一来源**,前端不再自带副本 —— 否则两份手工同步的映射必然漂移。
前端拉到之前(或拉取失败时)对未命中字段原样显示字段名,
是可控的降级,不会让页面崩掉。
免权限码:纯静态标签,不含任何业务数据;审计页本身已由
@permission_required('system_audit') 把关(见 /logs)。
"""
return jsonify({'code': 200, 'data': labels_payload()}), 200