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:
yueli
2026-09-28 10:40:00 +08:00
parent 91672214c8
commit 956d4acb5f
5 changed files with 377 additions and 58 deletions

View File

@ -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(() => {