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:
yueli
2026-09-28 09:46:34 +08:00
parent 1d6beaa376
commit 7d203b20c1
4 changed files with 440 additions and 117 deletions

View File

@ -33,3 +33,20 @@ export function getAuditLabels() {
method: 'get'
})
}
// 按筛选条件导出审计日志 Excel
//
// ★ 与列表页**同一套筛选参数**,但后端导出的是**全部匹配结果**而不是当前页 ——
// 用户点「导出」要的就是全量,只导当前页会让他以为数据被截断了。
// ★ responseType: 'blob' 不能省,否则 axios 会把二进制当文本解析,文件打不开。
export function exportAuditLogs(params: any) {
return request({
url: '/audit/logs/export',
method: 'get',
params,
responseType: 'blob' as any,
headers: {
'Accept': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
}
})
}

View File

@ -21,18 +21,44 @@
<el-input v-model="queryParams.username" placeholder="请输入操作人账号" clearable @keyup.enter="handleQuery" style="width: 150px" />
</el-form-item>
<el-form-item label="模块">
<el-select v-model="queryParams.module" placeholder="请选择模块" clearable style="width: 170px">
<!-- ★ label 走中文映射,value 保留原始值以保证筛选命中 -->
<el-option
v-for="item in moduleOptions"
:key="item"
:label="moduleLabel(item)"
:value="item"
/>
<!--
★ 分两组:上面是业务聚合项,下面是具体的 module 值。
聚合项是必须的 —— 审计的 module 在 2026-09-10 换过一次口径
(旧的「入库管理」→ 新的「库存管理」),只按单个 module 值筛
「入库」会正好在那天断掉。聚合项由后端展开成全部成员值
(见后端 audit_labels.MODULE_GROUPS),前端不需要知道成员是谁。
-->
<el-select v-model="queryParams.module" placeholder="请选择模块" clearable style="width: 200px">
<el-option-group v-if="moduleGroupKeys.length" label="业务聚合">
<el-option
v-for="g in moduleGroupKeys"
:key="g"
:label="g"
:value="g"
/>
</el-option-group>
<el-option-group label="具体模块">
<!-- ★ label 走中文映射,value 保留原始值以保证筛选命中 -->
<el-option
v-for="item in moduleOptions"
:key="item"
:label="moduleLabel(item)"
:value="item"
/>
</el-option-group>
</el-select>
</el-form-item>
<el-form-item label="操作类型">
<el-select v-model="queryParams.action" placeholder="请选择操作类型" clearable style="width: 120px">
<!-- ★ 多选:新增 / 修改 / 删除 可以组合查询 -->
<el-select
v-model="queryParams.action"
multiple
collapse-tags
collapse-tags-tooltip
placeholder="可多选"
clearable
style="width: 190px"
>
<!-- ★ 下拉只列规范值,显示为中文;后端会把历史别名一并匹配 -->
<el-option
v-for="item in actionOptions"
@ -60,6 +86,15 @@
<el-button @click="handleReset">
<el-icon><Refresh /></el-icon>重置
</el-button>
<!-- 导的是当前筛选下的**全量**,不是当前页 -->
<el-button
type="success"
v-permission="'system_audit'"
@click="handleExport"
:loading="exporting"
>
<el-icon><Download /></el-icon>导出 Excel
</el-button>
</el-form-item>
</el-form>
@ -221,8 +256,9 @@
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'
import { Search, Refresh, EditPen, Delete, Plus, Document } from '@element-plus/icons-vue'
import { getAuditLogs, getAuditModules, getAuditLabels } from '@/api/audit'
import { ElMessage } from 'element-plus'
import { Search, Refresh, Download, EditPen, Delete, Plus, Document } from '@element-plus/icons-vue'
import { getAuditLogs, getAuditModules, getAuditLabels, exportAuditLogs } from '@/api/audit'
// 表格数据
const tableLoading = ref(false)
@ -230,6 +266,10 @@ const tableData = ref([])
const total = ref(0)
// 查询参数
//
// ★ 这里只放**界面状态**,不放派生的查询参数(start_date/end_date 由
// dateRange 推出来)。两处各存一份会漂移:用户改了日期但忘了同步其中一个,
// 列表和导出就会用不同的条件 —— 这正是本项目反复踩过的坑。
const queryParams = reactive({
page: 1,
pageSize: 50,
@ -237,10 +277,9 @@ const queryParams = reactive({
operator_type: 'user' as 'user' | 'system' | 'all',
username: '',
module: '',
action: '',
// ★ 多选,提交时 join(',')(后端 split(',') 接收)
action: [] as string[],
target_id: '',
start_date: '',
end_date: ''
})
// 日期范围
@ -249,6 +288,8 @@ const dateRange = ref<[string, string] | null>(null)
// 选项数据
const moduleOptions = ref<string[]>([])
const actionOptions = ref<string[]>(['create', 'update', 'delete', 'export', 'import'])
// 业务聚合项(如「入库(全部)」)—— 由后端 /audit/labels 下发,前端不硬编
const moduleGroupKeys = ref<string[]>([])
// ============================================================
// 中文化映射 —— ★ 后端为唯一来源,前端不再自带副本
@ -309,6 +350,9 @@ const loadLabels = async () => {
defaultStatusMap.value = res.data.defaultStatus || {}
enumFields.value = res.data.enumFields || ['status']
booleanFields.value = res.data.booleanFields || []
// 聚合项只取**键**(如「入库(全部)」)—— 它展开成哪些 module 值是
// 后端的事,前端原样回传即可,不复制一份成员表过来。
moduleGroupKeys.value = Object.keys(res.data.moduleGroups || {})
}
} catch (e) {
console.warn('[AuditLog] 审计中文映射拉取失败,字段名与码值将原样显示', e)
@ -481,18 +525,36 @@ const getActionType = (action: string) => {
return typeMap[action?.toLowerCase()] || 'info'
}
// 组装请求参数(列表与导出**共用同一份**)
//
// ★ 共用一个函数是关键:列表和导出各拼一遍参数,迟早出现"页面上 300 条、
// 导出来 280 条"——而报表对不上比没有报表更糟(人会照着错的数字做决定)。
const buildFilterParams = () => {
const params: any = {
operator_type: queryParams.operator_type,
username: queryParams.username,
module: queryParams.module,
// ★ 多选数组 → 逗号串(仓库惯例,后端 split(',') 接收)
action: (queryParams.action || []).join(','),
target_id: queryParams.target_id,
start_date: '',
end_date: '',
}
if (dateRange.value && dateRange.value.length === 2) {
params.start_date = dateRange.value[0]
params.end_date = dateRange.value[1]
}
return params
}
const getList = async () => {
tableLoading.value = true
try {
if (dateRange.value && dateRange.value.length === 2) {
queryParams.start_date = dateRange.value[0]
queryParams.end_date = dateRange.value[1]
} else {
queryParams.start_date = ''
queryParams.end_date = ''
}
const res = await getAuditLogs(queryParams)
const res = await getAuditLogs({
...buildFilterParams(),
page: queryParams.page,
pageSize: queryParams.pageSize,
})
if (res.code === 200) {
tableData.value = res.data.list
total.value = res.data.total
@ -521,14 +583,46 @@ const handleReset = () => {
queryParams.operator_type = 'user'
queryParams.username = ''
queryParams.module = ''
queryParams.action = ''
queryParams.action = []
queryParams.target_id = ''
queryParams.start_date = ''
queryParams.end_date = ''
dateRange.value = null
getList()
}
// ---- 导出 Excel ----
// 与列表**同一套筛选条件**(buildFilterParams),但导的是全部匹配结果 ——
// 后端忽略分页参数。下载是浏览器的静默行为,页面不跳转,所以要给一条
// 「导出中」的反馈,否则大范围导出时用户会以为按钮没反应。
const exporting = ref(false)
const handleExport = async () => {
exporting.value = true
try {
const res: any = await exportAuditLogs(buildFilterParams())
const blob = new Blob([res], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
})
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
// 文件名前端自定(与仓库其他导出一致,不读 Content-Disposition)
const p = (n: number) => String(n).padStart(2, '0')
const d = new Date()
const stamp = `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}_` +
`${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`
link.download = `审计日志_${stamp}.xlsx`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
ElMessage.success('导出成功')
} catch (e) {
console.error('导出审计日志失败', e)
ElMessage.error('导出失败,请重试')
} finally {
exporting.value = false
}
}
const handleViewDetails = (row: any) => {
currentLog.value = row
detailDialogVisible.value = true