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:
@ -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'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user