原来的「操作人」是自由文本输入,后端按 `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
511 lines
22 KiB
Python
511 lines
22 KiB
Python
# 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
|