Files
KCGL/inventory-backend/app/api/v1/audit.py
yueli 6a9e41f53c feat(audit-ui): 审计页 UI 重构 —— 干净模块下拉、变更摘要、抽屉详情、触发来源
后端:GET /audit/modules 与 /logs 的 modules 改为下发最终选项 [{value,label}]
  · 历史命名(入库管理/采购入库/成品入库/库存管理)折叠进「入库」一项且
    **不再单独列出**,下拉里看不到历史包袱。展开仍只由后端 expand_modules
    负责 —— 前端若自己再拼一份成员表,将来后端加成员会静默失效(本项目
    已经踩过两次这种漂移)。
  · 英文历史 module(image_embeddings 2719 条 / purchase_request 75 条 /
    sys_element 6 条)在这里翻成中文 label,value 保留英文原值否则筛选
    匹配不上。映射表 MODULE_LABELS 从**前端** AuditLog.vue 的 moduleMap
    收进 audit_labels.py —— 那份硬编副本历史上已经漂移过一次。
  · /audit/labels 新增 moduleDisplay {原始值 → 展示名},同时覆盖聚合成员与
    英文历史值:只覆盖后者会出现「下拉显示『入库』、表格显示『库存管理』」,
    用户会以为筛选没生效。

后端:新增「触发来源」
  · 摘要追加 (来源:/outbound/request) 形式的 URL 尾段(去掉 /api/vN 前缀)。
    解决一个具体误解:业务方看到「入库/库存管理」里有大量非库管人员的
    UPDATE,以为越权改库存,实际是出库/借还/盘点等单据流转触发的自动扣减。
    实测该类 UPDATE 的来源:outbound 1582、outbound/request 424、
    borrow/dispatch 102、stocktake/update-quantity 95 —— 光看"谁改的"永远
    解释不清,必须能看出"哪个流程触发的"。
  · 来源拼在**截断之后**,保证这条关键信息永远不会被截掉。
  · 列表与导出共用 changes_summary,故 Excel 台账同样带来源。

后端:其余
  · 新增 target_keyword(ilike 同时匹配 target_id / target_name),
    放进共用的 _build_audit_query,导出自动支持。
  · action 在响应里归一化为 CREATE/UPDATE/DELETE(复用 audit_labels
    .canon_action,覆盖批量删除/分配/归还等全部历史别名)。库里还有 1573 条
    中文 action,归一后前端不必再为每种历史写法兜底。
  · 新增 summary 字段。不算在模型 @property 上:摘要要把 user_id/base_id
    翻成人名/物料名,需要查库,模型属性里发查询就是 N+1;改为整页一次
    load_ref_maps 批量解析。
  · 过滤对象 repr 脏值(<MaterialBase 3030>)。快照收集早期漏跳关系属性,
    库里留了 1529 条这种值 —— 不是业务数据,真正的值在对应 _id 字段里。
    判据收窄到「<类名 空格 内容>」,避免误伤备注里的「<急件>」。

前端 AuditLog.vue
  · 模块下拉改平铺 [{value,label}],删除硬编的 moduleMap
  · 新增「操作对象」模糊搜索
  · 合并「操作人」「姓名」两列(判据 username==='system' —— 响应里没有
    operator_type 字段,那是请求参数名,照搬会永远不显示系统标签)
  · 新增「变更摘要」列;操作对象显示为「名称 #id」
  · 操作时间改为恒显示北京时间,不再按浏览器时区换算,与数据库/日报/导出一致
  · 详情弹窗改 el-drawer,按 action 分支渲染:UPDATE 出对比表,
    CREATE/DELETE 出快照属性表,并跳过对象 repr
  · 抽屉顶部高亮展示触发来源(METHOD + URL 告警条)

验证:后端 18 + 22 + 10 项断言全过(模块选项无历史名且「入库」=四值之和、
英文 value 仍可筛选、url_source 边界、来源不被截断、导出口径与列表一致、
权限 401/403/200 矩阵);vue-tsc --noEmit 与 vite build 均 exit=0。
日报回归:三天附件 490.5K/193.5K/10.3K,6 列结构与改动前一致。
2026-09-28 10:10:05 +08:00

421 lines
18 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,
load_ref_maps,
)
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)
username = (args.get('username') or '').strip()
if username:
query = query.filter(AuditLog.username.like(f'%{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)
out = []
for r in rows:
d = r.to_dict()
d['action'] = canon_action(d.get('action'))
d['summary'] = changes_summary(r, ref_maps, limit=SUMMARY_LIMIT)
out.append(d)
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
}
}), 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('/labels', methods=['GET'])
@jwt_required()
def get_labels():
"""
下发操作类型与字段名的中文映射。
★ 后端是**唯一来源**,前端不再自带副本 —— 否则两份手工同步的映射必然漂移。
前端拉到之前(或拉取失败时)对未命中字段原样显示字段名,
是可控的降级,不会让页面崩掉。
免权限码:纯静态标签,不含任何业务数据;审计页本身已由
@permission_required('system_audit') 把关(见 /logs)。
"""
return jsonify({'code': 200, 'data': labels_payload()}), 200