feat(audit): 按条件导出审计日志,并修 module 口径断裂与详情接口权限漏洞
需求是"想看某天或某段时间的入库的修改,支持下载或当场查看"。
核查后:筛选与查看审计页早已具备,缺的是导出 —— 但直接加导出会撞上两个
既有问题,故一并处理。
1. 「入库」的 module 口径在 2026-09-10 断过一次
那天旧的全局监听器 app/utils/audit_events.py 被现行的
app/core/audit_listener.py 取代(旧模块现已无人 import,是死代码),
新监听器把入库三表 stock_buy/stock_semi/stock_product 统一归为
「库存管理」,不再产出「入库管理」:
入库管理 16118 条 2026-04-22 ~ 2026-09-10
采购入库 1196 条 2026-03-18 ~ 2026-04-20
成品入库 8 条 2026-03-23 ~ 2026-04-17
库存管理 1371 条 2026-09-10 ~
后果:按单个 module 值筛「入库」会正好在那天断掉,现象是"入库记录突然
没了"。解法是**查询期别名展开**(audit_labels.MODULE_GROUPS),不迁移
历史数据 —— 改数据不可逆,而聚合查询无损。
2. GET /audit/logs/<id> 缺权限校验,也没有公司隔离
此前只有 @jwt_required(),任何登录用户改一下 URL 里的 id 就能读到全部
审计明细(含 details 里的完整快照)。列表接口有 system_audit 把关,
详情提供的信息是列表的超集,没道理比列表更宽松。
现补权限码 + 与列表同一个公司判据;越权与不存在**统一返回 404**,
区分开来等于告诉探测者"这个 id 存在,只是你没权限"。
3. 筛选逻辑只写一份
抽出 _build_audit_query(),/logs 与 /logs/export 共用。两处各写一份迟早
出现"页面上 300 条、导出来 280 条",而报表对不上比没有报表更糟。
导出实现:
- GET /audit/logs/export,权限码同 /logs;忽略分页参数,导全量。
- 三个工作表:汇总(含本次生效的筛选条件,收件人看不到页面上的筛选框)、
审计日志(一行一条)、变更明细(一行一个变更字段,可对「字段」列筛选)。
- EXPORT_LIMIT=100000 作熔断。实测全库 5.8 万条导出 2.5MB / 约 3 秒,
故走同步返回,不引入 export_service 那套异步框架(它还会落盘且无清理)。
触顶时在文件名与汇总表显式写明,不静默丢弃。
- 顺带补 /audit/modules 的公司隔离(下拉选项此前会跨公司)。
前端(AuditLog.vue / api/audit.ts):
- 模块下拉分「业务聚合 / 具体模块」两组,聚合项由后端下发,前端不硬编成员;
- 操作类型改多选,提交时 join(',')(后端 split(',') 接收);
- 导出按钮沿用仓库既有的 blob 下载写法,文件名前端自定。
验证:后端 21 项断言全过(口径断裂、多选别名归一、日期闭区间、公司隔离、
401/403/200 权限矩阵、导出口径与列表 total 一致、附件为合法 xlsx);
vue-tsc --noEmit 与 vite build 均 exit=0。
This commit is contained in:
@ -364,6 +364,58 @@ def person_name_label(value):
|
||||
return None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 5. 模块聚合别名 —— 让一个业务概念跨越历史上的口径变更
|
||||
#
|
||||
# ★ 背景:审计日志的 module 是**监听器写死的字符串**,而监听器换过一次。
|
||||
# `app/utils/audit_events.py`(旧的全局监听器)产出「入库管理」;
|
||||
# 现行的 `app/core/audit_listener.py` 把入库三表
|
||||
# stock_buy / stock_semi / stock_product 统一归为「库存管理」
|
||||
# (见 audit_listener.py 的 _get_module_name),不再产出「入库管理」。
|
||||
#
|
||||
# 实测的分界点是 2026-09-10:
|
||||
# 入库管理 16118 条 2026-04-22 ~ 2026-09-10
|
||||
# 库存管理 1371 条 2026-09-10 ~ 至今
|
||||
#
|
||||
# 后果:只按单个 module 值筛选「入库」,会正好在 9-10 那天断掉 ——
|
||||
# 选「入库管理」看不到 9-10 之后的,选「库存管理」看不到之前的。
|
||||
# 报表读起来像是"入库记录突然没了",极难排查。
|
||||
#
|
||||
# ★ 解法只在**查询期**做别名展开,不迁移历史数据:改数据不可逆,
|
||||
# 而聚合查询是无损的。新增值只需往元组里追加。
|
||||
#
|
||||
# ★ 与 STATUS_LABELS_BY_MODULE 一样,这份表是**唯一来源**:
|
||||
# 前端经 GET /audit/labels 取(前端 AuditLog.vue 自带的 moduleMap
|
||||
# 已经是手工同步的副本,不能再塞第二份进去)。
|
||||
# =============================================================================
|
||||
MODULE_GROUPS = {
|
||||
'入库(全部)': ('入库管理', '采购入库', '成品入库', '库存管理'),
|
||||
}
|
||||
|
||||
|
||||
def expand_modules(values):
|
||||
"""
|
||||
筛选值 → 实际要匹配的 module 列表。
|
||||
|
||||
接受三种输入并混合使用:
|
||||
· 聚合名('入库(全部)')→ 展开为其全部成员值
|
||||
· 真实 module 值('入库管理')→ 原样保留
|
||||
· 未登记的任意值 → 原样保留(不猜、不丢弃,查不到就是查不到)
|
||||
|
||||
返回**去重后**的列表 —— 用户同时选了「入库(全部)」与「入库管理」时,
|
||||
展开会出现重复值,虽然 .in_() 去重与否结果相同,但重复值会让
|
||||
SQL 参数列表无谓变长。
|
||||
"""
|
||||
out = []
|
||||
for v in values:
|
||||
v = (v or '').strip()
|
||||
if not v:
|
||||
continue
|
||||
out.extend(MODULE_GROUPS.get(v, (v,)))
|
||||
# dict.fromkeys 去重且保序(Python 3.7+ 的 dict 有序)
|
||||
return list(dict.fromkeys(out))
|
||||
|
||||
|
||||
def _stringify_keys(mapping):
|
||||
"""
|
||||
把映射的键统一转成字符串。
|
||||
@ -396,4 +448,7 @@ def labels_payload():
|
||||
'defaultStatus': _stringify_keys(DEFAULT_STATUS_LABELS),
|
||||
'booleanFields': sorted(BOOLEAN_FIELDS),
|
||||
'enumFields': sorted(ENUM_FIELDS),
|
||||
# 模块聚合项及其成员 —— 前端把键注入「模块」下拉,选中后原样回传给
|
||||
# 后端,由 expand_modules() 展开。前端不需要知道成员是谁。
|
||||
'moduleGroups': {k: list(v) for k, v in MODULE_GROUPS.items()},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user