feat(audit): 快照嵌套分层渲染 + 凭据字段从接口剥离(安全修复)
起因:借库管理等单据的详情页仍显示原始 JSON 代码块。
根因:details 有**第三种**存法 {'payload': {...}},且内部嵌着对象数组
(items 明细行),而旧实现只认平铺的 created/deleted_snapshot,
遇到非平铺结构就退化成原始 JSON。
1. 快照键收敛(后端)
created / deleted_snapshot / payload 三种键与展示标题收进
audit_labels.SNAPSHOT_VIEWS,经 /audit/labels 下发 snapshotViews,
前端不再硬编键名 —— 将来加第四种只改一处。
audit_export_service 与 daily_report_service 改为引用同一份。
changes_summary 也改用 snapshot_of_any:payload 型记录的新增摘要
此前整列为空,现在有内容了(如「新增(借库管理):物料明细 1 项、
备注、借用人、签名、预计归还时间」)。
2. 分层渲染(前端)
· 标量字段 → el-descriptions(保留字典翻译与空值/技术字段过滤)
· 数组字段 → el-table:列取**所有元素键的并集**(同一数组内元素键
并不完全一致,只取首个元素会漏字段),跳过技术字段,列头走 fieldLabel
· 标量数组(arrival_photo 等图片 URL 列表)→ 单列表格
· details / payload 本身是数组或标量的情况也一并处理
· 只有确实无可识别结构时才回落到原始 JSON
实测全库 58728 条:**0 条**会退化到原始 JSON 兜底
(payload 1495 条全部拆出结构)。
3. ★ 安全修复:审计快照里存有**明文密码**,本次从所有出口剥离
实测 `用户管理/新增` 的 payload 快照记着哈希前的原始密码(27 条,
形如 '123456'、'shili0823'),因为写入路径把请求体整包记进了审计。
· sanitize_details():递归剥掉凭据类字段,应用于列表与详情接口
· changes_of / snapshot_of 在**源头**滤掉凭据:逐个出口去补必然漏掉
某一个,而漏掉的那个就是泄漏点(日报附件、导出、摘要都走这两个函数)
· 凭据判定用**关键词**(password/passwd/secret/token/private_key)
而非逐个登记 —— 今天漏的是 payload 里的 password,明天可能是
reset_token。新表加凭据字段也自动被挡住。
★ 第一版我只做了前端隐藏(hiddenFields),被测试抓出来:原始响应里
密码照样在,打开 devtools 就读得到。**要挡的数据必须在接口出口剥掉,
前端隐藏不是防线。**
验证:14 + 15 项断言全过,含「列表/详情响应无密码原文与 password 键」
「全量导出的表头与单元格均无凭据」「日报三天附件无凭据」
「sanitize_details 递归进嵌套数组且不原地改原对象」。
6 列结构与附件大小未变(490.5K / 193.4K / 10.3K);vue-tsc 与 vite build exit=0。
⚠️ 数据库中仍存有明文密码(16 条非空),本次只挡展示与导出,**未改数据** ——
改数据不可逆,需单独立项决定。
This commit is contained in:
@ -264,24 +264,50 @@
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- 情况2/3:CREATE / DELETE —— 快照属性列表 -->
|
||||
<div v-else-if="snapshotRows.length" class="details-section">
|
||||
<div class="section-title">
|
||||
<el-icon><Document /></el-icon>
|
||||
{{ isDelete ? '删除前数据快照' : '新增数据快照' }}
|
||||
<!-- 情况2/3/4:快照(created / deleted_snapshot / payload)分层渲染 -->
|
||||
<template v-else-if="hasStructuredDetail">
|
||||
<!-- 第一层:平铺的标量字段 -->
|
||||
<div v-if="snapshotView.scalars.length" class="details-section">
|
||||
<div class="section-title">
|
||||
<el-icon><Document /></el-icon>
|
||||
{{ snapshotView.title }}
|
||||
</div>
|
||||
<el-descriptions :column="1" border size="small">
|
||||
<el-descriptions-item
|
||||
v-for="item in snapshotView.scalars"
|
||||
:key="String(item.key)"
|
||||
:label="fieldLabel(item.key)"
|
||||
>
|
||||
<span class="snapshot-value">{{ formatValue(item.value, String(item.key)) }}</span>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
<el-descriptions :column="1" border size="small">
|
||||
<el-descriptions-item
|
||||
v-for="item in snapshotRows"
|
||||
:key="String(item.key)"
|
||||
:label="fieldLabel(item.key)"
|
||||
>
|
||||
<span class="snapshot-value">{{ formatValue(item.value, String(item.key)) }}</span>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
|
||||
<!-- 兜底:没有可识别的结构时显示原始 JSON -->
|
||||
<!-- 第二层:嵌套数组 → 明细表(不再退化成原始 JSON 代码块) -->
|
||||
<div
|
||||
v-for="block in snapshotView.lists"
|
||||
:key="block.key"
|
||||
class="details-section"
|
||||
>
|
||||
<div class="section-title">
|
||||
<el-icon><Document /></el-icon>
|
||||
{{ block.label }}
|
||||
<span class="muted">共 {{ block.count }} 项</span>
|
||||
</div>
|
||||
<el-table :data="block.rows" border stripe size="small">
|
||||
<el-table-column
|
||||
v-for="col in block.columns"
|
||||
:key="col.prop"
|
||||
:prop="col.prop"
|
||||
:label="col.label"
|
||||
min-width="120"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 兜底:确实没有任何可识别结构时才显示原始 JSON -->
|
||||
<div v-else class="details-section">
|
||||
<div class="section-title">
|
||||
<el-icon><Document /></el-icon>
|
||||
@ -355,6 +381,8 @@ const enumFields = ref<string[]>(['status'])
|
||||
const booleanFields = ref<string[]>([])
|
||||
// 详情快照里不展示的纯技术字段(后端下发,见「详情降噪」一节)
|
||||
const hiddenFields = ref<string[]>([])
|
||||
// 快照键 → 标题(后端下发,见「快照的分层渲染」一节)
|
||||
const snapshotViews = ref<Array<{ key: string; title: string }>>([])
|
||||
|
||||
// 字段中文名解析:命中映射取中文,否则原样返回
|
||||
const fieldLabel = (key: string | number): string => {
|
||||
@ -399,6 +427,7 @@ const loadLabels = async () => {
|
||||
booleanFields.value = res.data.booleanFields || []
|
||||
moduleDisplay.value = res.data.moduleDisplay || {}
|
||||
hiddenFields.value = res.data.hiddenFields || []
|
||||
snapshotViews.value = res.data.snapshotViews || []
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[AuditLog] 审计中文映射拉取失败,字段名与码值将原样显示', e)
|
||||
@ -485,9 +514,16 @@ const isBlankValue = (v: any): boolean => {
|
||||
return false
|
||||
}
|
||||
|
||||
// ★ 凭据类字段按**关键词**拦截,与后端 audit_labels._CREDENTIAL_KEYWORDS 同规则。
|
||||
// 实测 `用户管理/新增` 的 payload 快照里存着明文密码(写入路径把请求体整包
|
||||
// 记进了审计),必须在详情与导出里都挡住。
|
||||
// 名单用关键词而不是后端下发:新表加了凭据字段也自动被挡,不会漏。
|
||||
const CREDENTIAL_KEYWORDS = ['password', 'passwd', 'secret', 'token', 'private_key']
|
||||
|
||||
const isHiddenField = (key: string): boolean => {
|
||||
const k = String(key || '').trim().toLowerCase()
|
||||
return hiddenFields.value.includes(k) || k.endsWith('embedding')
|
||||
if (hiddenFields.value.includes(k) || k.endsWith('embedding')) return true
|
||||
return CREDENTIAL_KEYWORDS.some(w => k.includes(w))
|
||||
}
|
||||
|
||||
// UPDATE:字段对比行
|
||||
@ -502,16 +538,103 @@ const diffRows = computed(() => {
|
||||
.map(([key, v]: [string, any]) => ({ key, old: v?.old, new: v?.new }))
|
||||
})
|
||||
|
||||
// CREATE / DELETE:快照属性行
|
||||
// 过滤:空值、纯技术字段、对象 repr 脏值(见 isObjectRepr)
|
||||
const snapshotRows = computed(() => {
|
||||
const d = currentDetail.value?.details || {}
|
||||
const snap = d.deleted_snapshot || d.created
|
||||
if (!snap || typeof snap !== 'object') return []
|
||||
return Object.entries(snap)
|
||||
.filter(([key, v]) =>
|
||||
!isBlankValue(v) && !isHiddenField(key) && !isObjectRepr(v))
|
||||
.map(([key, value]) => ({ key, value }))
|
||||
// ============================================================
|
||||
// CREATE / DELETE / payload:快照的分层渲染
|
||||
//
|
||||
// 历史上有三种快照存法(created / deleted_snapshot / payload),
|
||||
// 键名与标题由**后端**下发(snapshotViews),前端不硬编 —— 见
|
||||
// 后端 audit_labels.SNAPSHOT_VIEWS。
|
||||
//
|
||||
// ★ payload 型快照内部还嵌着数组(items 明细行、arrival_photo 图片 URL 列表),
|
||||
// 旧实现遇到非平铺结构就退化成原始 JSON 代码块 —— 业务人员看不懂。
|
||||
// 现在拆成两层:
|
||||
// 标量字段 → el-descriptions(保留字典翻译 + 空值/技术字段过滤)
|
||||
// 数组字段 → el-table(列取元素键的并集,同样过滤与翻译)
|
||||
// ============================================================
|
||||
|
||||
// 从 details 里挑出第一个可用的快照,抹平三种存法的入口差异
|
||||
const pickedSnapshot = computed(() => {
|
||||
const details = currentDetail.value?.details
|
||||
if (!details || typeof details !== 'object') return null
|
||||
for (const v of snapshotViews.value) {
|
||||
const data = (details as any)[v.key]
|
||||
// ★ 用真值判断会漏掉空数组/空对象,但那本来就没什么可渲染的,
|
||||
// 继续试下一个键反而是对的行为
|
||||
if (data) return { title: v.title, data }
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
// 数组 → 表格块。元素可能是对象(items)也可能是标量(图片 URL 列表)。
|
||||
const buildListBlock = (key: string, arr: any[]) => {
|
||||
const head = arr[0]
|
||||
// 标量数组:单列表格。图片 URL / 权限码都是这种,给个统一去处比拼成
|
||||
// 长字符串更好读(长 URL 会撑爆整行)。
|
||||
if (head === null || typeof head !== 'object') {
|
||||
return {
|
||||
key,
|
||||
label: fieldLabel(key),
|
||||
count: arr.length,
|
||||
columns: [{ prop: 'value', label: fieldLabel(key) }],
|
||||
rows: arr.map(v => ({ value: formatValue(v, key) })),
|
||||
}
|
||||
}
|
||||
// 对象数组:列取**所有元素键的并集** —— 只取第一个元素的键会漏掉
|
||||
// 后续元素才有的字段(实测同一数组内的元素键并不完全一致)。
|
||||
const cols: string[] = []
|
||||
for (const el of arr) {
|
||||
for (const k of Object.keys(el || {})) {
|
||||
if (!cols.includes(k) && !isHiddenField(k)) cols.push(k)
|
||||
}
|
||||
}
|
||||
return {
|
||||
key,
|
||||
label: fieldLabel(key),
|
||||
count: arr.length,
|
||||
columns: cols.map(c => ({ prop: c, label: fieldLabel(c) })),
|
||||
rows: arr.map(el => {
|
||||
const row: Record<string, string> = {}
|
||||
for (const c of cols) row[c] = formatValue((el || {})[c], c)
|
||||
return row
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// 快照 → { 标量字段, 数组块 }
|
||||
const snapshotView = computed(() => {
|
||||
const picked = pickedSnapshot.value
|
||||
if (!picked) return null
|
||||
|
||||
// payload 整包可能就是数组(实测有这种),此时全按列表渲染
|
||||
if (Array.isArray(picked.data)) {
|
||||
const block = buildListBlock('payload', picked.data)
|
||||
return { title: picked.title, scalars: [], lists: block ? [block] : [] }
|
||||
}
|
||||
if (typeof picked.data !== 'object') {
|
||||
return { title: picked.title, scalars: [], lists: [] }
|
||||
}
|
||||
|
||||
const scalars: Array<{ key: string; value: any }> = []
|
||||
const lists: any[] = []
|
||||
for (const [key, v] of Object.entries(picked.data)) {
|
||||
if (isHiddenField(key)) continue
|
||||
if (Array.isArray(v)) {
|
||||
if (!v.length) continue
|
||||
const block = buildListBlock(key, v)
|
||||
if (block) lists.push(block)
|
||||
} else if (!isBlankValue(v) && !isObjectRepr(v)) {
|
||||
// 嵌套对象(非数组)原样转 JSON 显示 —— 实测快照里没有这种,
|
||||
// 但留着比把它静默丢掉安全
|
||||
scalars.push({ key, value: v })
|
||||
}
|
||||
}
|
||||
return { title: picked.title, scalars, lists }
|
||||
})
|
||||
|
||||
// 有东西可渲染就不走原始 JSON 兜底
|
||||
const hasStructuredDetail = computed(() => {
|
||||
const v = snapshotView.value
|
||||
return !!(v && (v.scalars.length || v.lists.length))
|
||||
})
|
||||
|
||||
const rawJson = computed(() => {
|
||||
|
||||
Reference in New Issue
Block a user