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:
@ -16,6 +16,7 @@ from app.services.audit_export_service import (
|
|||||||
format_target_display,
|
format_target_display,
|
||||||
load_material_context,
|
load_material_context,
|
||||||
load_ref_maps,
|
load_ref_maps,
|
||||||
|
sanitize_details,
|
||||||
)
|
)
|
||||||
from app.utils.decorators import get_current_company_filter, permission_required
|
from app.utils.decorators import get_current_company_filter, permission_required
|
||||||
|
|
||||||
@ -224,6 +225,10 @@ def _serialize_logs(rows):
|
|||||||
out = []
|
out = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
d = r.to_dict()
|
d = r.to_dict()
|
||||||
|
# ★ 在**接口出口**剥掉凭据类字段。只靠前端隐藏不够 ——
|
||||||
|
# 原始响应里照样有明文密码,打开 devtools 就读得到。
|
||||||
|
# 见 audit_export_service.sanitize_details。
|
||||||
|
d['details'] = sanitize_details(d.get('details'))
|
||||||
d['action'] = canon_action(d.get('action'))
|
d['action'] = canon_action(d.get('action'))
|
||||||
mat = materials.get(r.id)
|
mat = materials.get(r.id)
|
||||||
# 保留原始 target_name(搜索仍按它匹配),展示用 target_display
|
# 保留原始 target_name(搜索仍按它匹配),展示用 target_display
|
||||||
|
|||||||
@ -36,7 +36,10 @@ from app.extensions import db
|
|||||||
from app.models.base import MaterialBase
|
from app.models.base import MaterialBase
|
||||||
from app.utils.audit_labels import (
|
from app.utils.audit_labels import (
|
||||||
BOOLEAN_FIELDS,
|
BOOLEAN_FIELDS,
|
||||||
|
CHANGES_KEY,
|
||||||
PERSON_NAME_FIELDS,
|
PERSON_NAME_FIELDS,
|
||||||
|
SNAPSHOT_CREATED,
|
||||||
|
SNAPSHOT_DELETED,
|
||||||
USER_ID_FIELDS,
|
USER_ID_FIELDS,
|
||||||
action_label,
|
action_label,
|
||||||
bool_label,
|
bool_label,
|
||||||
@ -44,6 +47,7 @@ from app.utils.audit_labels import (
|
|||||||
enum_label,
|
enum_label,
|
||||||
field_label,
|
field_label,
|
||||||
id_ref_of,
|
id_ref_of,
|
||||||
|
is_hidden_credential_field,
|
||||||
module_display,
|
module_display,
|
||||||
person_name_label,
|
person_name_label,
|
||||||
)
|
)
|
||||||
@ -54,10 +58,8 @@ SYSTEM_USERNAME = 'system'
|
|||||||
# 未解析出实体时的占位
|
# 未解析出实体时的占位
|
||||||
UNKNOWN = '-'
|
UNKNOWN = '-'
|
||||||
|
|
||||||
# details 里的三种结构各自的键名 —— 集中在此,避免调用方记错
|
# details 的键名统一在 audit_labels.py 定义(那里还要下发给前端),
|
||||||
SNAPSHOT_CREATED = 'created'
|
# 此处只做引用 —— 两个模块各存一份字符串就是在等着它们漂移。
|
||||||
SNAPSHOT_DELETED = 'deleted_snapshot'
|
|
||||||
CHANGES_KEY = 'changes'
|
|
||||||
|
|
||||||
# 变更字段的噪声过滤。
|
# 变更字段的噪声过滤。
|
||||||
#
|
#
|
||||||
@ -695,6 +697,11 @@ def changes_of(row):
|
|||||||
for key, val in changes.items():
|
for key, val in changes.items():
|
||||||
if key in IGNORED_CHANGE_FIELDS:
|
if key in IGNORED_CHANGE_FIELDS:
|
||||||
continue
|
continue
|
||||||
|
# ★ 凭据类字段一律不出现在任何出口(详情、摘要、Excel 导出)。
|
||||||
|
# 在这里滤是源头做法 —— 逐个出口去补必然漏掉某一个,
|
||||||
|
# 而漏掉的那个就是泄漏点。
|
||||||
|
if is_hidden_credential_field(key):
|
||||||
|
continue
|
||||||
if isinstance(val, dict):
|
if isinstance(val, dict):
|
||||||
old, new = val.get('old'), val.get('new')
|
old, new = val.get('old'), val.get('new')
|
||||||
else:
|
else:
|
||||||
@ -708,6 +715,25 @@ def changes_of(row):
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot_of_any(row):
|
||||||
|
"""
|
||||||
|
该行的业务快照 + 它的展示标题,按 SNAPSHOT_VIEWS 的优先级取第一个可用的。
|
||||||
|
|
||||||
|
返回 (title, data) 或 (None, None)。data 可能是 dict(常规)也可能是
|
||||||
|
list(`payload` 实测有整包就是数组的),由调用方按类型处置。
|
||||||
|
"""
|
||||||
|
from app.utils.audit_labels import SNAPSHOT_VIEWS
|
||||||
|
|
||||||
|
details = getattr(row, 'details', None)
|
||||||
|
if not isinstance(details, dict):
|
||||||
|
return None, None
|
||||||
|
for key, title in SNAPSHOT_VIEWS:
|
||||||
|
data = details.get(key)
|
||||||
|
if data:
|
||||||
|
return title, data
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
def snapshot_of(row, which):
|
def snapshot_of(row, which):
|
||||||
"""
|
"""
|
||||||
CREATE / DELETE 记录的整字段快照 → {字段: 值};结构异常时返回 {}。
|
CREATE / DELETE 记录的整字段快照 → {字段: 值};结构异常时返回 {}。
|
||||||
@ -723,16 +749,53 @@ def snapshot_of(row, which):
|
|||||||
snap = details.get(which)
|
snap = details.get(which)
|
||||||
if not isinstance(snap, dict):
|
if not isinstance(snap, dict):
|
||||||
return {}
|
return {}
|
||||||
return {k: v for k, v in snap.items() if k not in IGNORED_CHANGE_FIELDS}
|
return {
|
||||||
|
k: v for k, v in snap.items()
|
||||||
|
if k not in IGNORED_CHANGE_FIELDS
|
||||||
|
# 凭据类同样在源头滤掉(理由见 changes_of)
|
||||||
|
and not is_hidden_credential_field(k)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_details(details):
|
||||||
|
"""
|
||||||
|
details(含嵌套快照与数组)→ 剔除凭据类字段后的**新**结构。
|
||||||
|
|
||||||
|
★★ 必须在这层做,不能只靠前端隐藏:前端隐藏只是"不显示",原始响应里
|
||||||
|
照样有明文 —— 任何人打开 devtools 就读得到。
|
||||||
|
**凡是要挡的数据,都要在接口出口剥掉。** 前端那份只是为了不把
|
||||||
|
DOM 也渲染出来(双保险),不是防线本身。
|
||||||
|
|
||||||
|
★ 实测来源:`用户管理/新增` 的 payload 快照里存着明文密码(27 条),
|
||||||
|
写入路径把请求体整包记进了审计 —— 请求体里是哈希前的原始密码。
|
||||||
|
|
||||||
|
★ 递归进 dict 与 list:payload 里嵌着 items 数组,数组元素对象上同样
|
||||||
|
可能有凭据字段,只看第一层会漏。
|
||||||
|
|
||||||
|
★ 返回新对象,**不原地改** ORM 对象持有的 dict —— 那会污染 session
|
||||||
|
里的实体,同一个请求后续若还要用到原始数据就会拿到被改过的版本。
|
||||||
|
"""
|
||||||
|
if isinstance(details, dict):
|
||||||
|
return {
|
||||||
|
k: sanitize_details(v)
|
||||||
|
for k, v in details.items()
|
||||||
|
if not is_hidden_credential_field(k)
|
||||||
|
}
|
||||||
|
if isinstance(details, list):
|
||||||
|
return [sanitize_details(v) for v in details]
|
||||||
|
return details
|
||||||
|
|
||||||
|
|
||||||
def has_detail_content(details):
|
def has_detail_content(details):
|
||||||
"""details 里是否有可展示的结构(与前端 hasDetailContent 同判据)"""
|
"""details 里是否有可展示的结构(与前端 hasDetailContent 同判据)"""
|
||||||
if not isinstance(details, dict) or not details:
|
if not isinstance(details, dict) or not details:
|
||||||
return False
|
return False
|
||||||
return any(
|
# 键名取自 SNAPSHOT_VIEWS + changes —— 与前端渲染用的是同一份,
|
||||||
details.get(k) for k in (CHANGES_KEY, SNAPSHOT_DELETED, SNAPSHOT_CREATED, 'payload')
|
# 不在这里另列一遍(漏一个就会出现"有详情按钮但点开是空的")
|
||||||
)
|
from app.utils.audit_labels import SNAPSHOT_VIEWS
|
||||||
|
|
||||||
|
keys = [k for k, _ in SNAPSHOT_VIEWS] + [CHANGES_KEY]
|
||||||
|
return any(details.get(k) for k in keys)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@ -932,21 +995,30 @@ def changes_summary(row, ref_maps, limit=200, resolved=None, material=None):
|
|||||||
mod = module_display(row.module)
|
mod = module_display(row.module)
|
||||||
prefix = f"{verb}({mod}):" if mod else f"{verb}:"
|
prefix = f"{verb}({mod}):" if mod else f"{verb}:"
|
||||||
|
|
||||||
for which in (SNAPSHOT_CREATED, SNAPSHOT_DELETED):
|
# ★ 用 snapshot_of_any:`payload` 是与 `created` 平级的第三种快照存法
|
||||||
snap = snapshot_of(row, which)
|
# (借库/出库/采购入库走这条),只看 created/deleted_snapshot 会让
|
||||||
if not snap:
|
# 这些记录的新增摘要整列为空。
|
||||||
continue
|
_title, snap = snapshot_of_any(row)
|
||||||
# 优先:核心属性的**值**(多少数量、放哪)—— 这才是用户想知道的
|
if not isinstance(snap, dict):
|
||||||
core = _core_attrs(snap, material)
|
return ''
|
||||||
if core:
|
# 优先:核心属性的**值**(多少数量、放哪)—— 这才是用户想知道的
|
||||||
return truncate(prefix + '、'.join(core), limit)
|
core = _core_attrs(snap, material)
|
||||||
# 回落:没有核心属性可提(如 BOM 记录),退化成字段名清单。
|
if core:
|
||||||
# 跳过 id:主键每条都有,排最前却零信息量,白占位置。
|
return truncate(prefix + '、'.join(core), limit)
|
||||||
keys = [k for k in snap if k != 'id']
|
# 回落:没有核心属性可提(如 BOM 记录、payload 型的借库单),
|
||||||
names = [field_label(k) for k in keys[:8]]
|
# 退化成字段名清单。跳过 id(主键每条都有、零信息量)与凭据类字段
|
||||||
more = '' if len(keys) <= 8 else f" 等 {len(keys)} 个字段"
|
# (摘要里出现「password」这种字样本身就是多余的暴露)。
|
||||||
return truncate(f"{prefix}{'、'.join(names)}{more}", limit)
|
keys = [k for k in snap
|
||||||
return ''
|
if k != 'id' and not is_hidden_credential_field(k)]
|
||||||
|
names = []
|
||||||
|
for k in keys[:8]:
|
||||||
|
v = snap[k]
|
||||||
|
# 数组字段带上条数 —— 只写「物料明细」看不出有几行,
|
||||||
|
# 「物料明细 1 项」才是有用的信息
|
||||||
|
names.append(f"{field_label(k)} {len(v)} 项" if isinstance(v, list)
|
||||||
|
else field_label(k))
|
||||||
|
more = '' if len(keys) <= 8 else f" 等 {len(keys)} 个字段"
|
||||||
|
return truncate(f"{prefix}{'、'.join(names)}{more}", limit)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@ -55,8 +55,6 @@ from app.models.transaction import TransBorrow
|
|||||||
from app.services.audit_export_service import (
|
from app.services.audit_export_service import (
|
||||||
DAILY_REPORT_HEAD,
|
DAILY_REPORT_HEAD,
|
||||||
EXCEL_SNAPSHOT_COL_LIMIT,
|
EXCEL_SNAPSHOT_COL_LIMIT,
|
||||||
SNAPSHOT_CREATED,
|
|
||||||
SNAPSHOT_DELETED,
|
|
||||||
SYSTEM_USERNAME,
|
SYSTEM_USERNAME,
|
||||||
UNKNOWN,
|
UNKNOWN,
|
||||||
cell,
|
cell,
|
||||||
@ -71,7 +69,11 @@ from app.services.audit_export_service import (
|
|||||||
snapshot_headers,
|
snapshot_headers,
|
||||||
snapshot_of,
|
snapshot_of,
|
||||||
)
|
)
|
||||||
from app.utils.audit_labels import canon_action
|
from app.utils.audit_labels import (
|
||||||
|
SNAPSHOT_CREATED,
|
||||||
|
SNAPSHOT_DELETED,
|
||||||
|
canon_action,
|
||||||
|
)
|
||||||
from app.utils.constants import OutboundType
|
from app.utils.constants import OutboundType
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|||||||
@ -282,6 +282,57 @@ FIELD_LABELS = {
|
|||||||
'image_url': '图片地址',
|
'image_url': '图片地址',
|
||||||
'user_id': '用户ID',
|
'user_id': '用户ID',
|
||||||
'type': '类型',
|
'type': '类型',
|
||||||
|
|
||||||
|
# --- 快照里的数组字段 ---
|
||||||
|
# ★ 这些在详情抽屉里会**直接当表格标题**用(见前端的分层渲染),
|
||||||
|
# 漏一个就是在表头上裸露英文。
|
||||||
|
'items': '物料明细',
|
||||||
|
'rules': '规则',
|
||||||
|
'children': '子项',
|
||||||
|
'arrival_photo': '到货照片',
|
||||||
|
'generalImage': '通用图片',
|
||||||
|
'generalManual': '通用手册',
|
||||||
|
'permissions': '权限',
|
||||||
|
'signature_path': '签名',
|
||||||
|
|
||||||
|
# --- payload 型快照的字段 ---
|
||||||
|
# ★ 来源:接口层手工记录的请求体整包(借库/出库/采购入库/用户管理/预警设置)。
|
||||||
|
# 它的命名与 ORM 快照**不一致**,同一概念有两种写法:
|
||||||
|
# qty_stock / stock_quantity —— 都是"库存数量"
|
||||||
|
# commonName / common_name —— 都是"通用名"
|
||||||
|
# companyName / company_name —— 都是"所属公司"
|
||||||
|
# 这是历史遗留,改数据不可逆,故两种写法都登记。
|
||||||
|
'qty_stock': '库存数量',
|
||||||
|
'qty_available': '可用数量',
|
||||||
|
'qty_inbound': '入库数量',
|
||||||
|
'inventoryCount': '库存数量',
|
||||||
|
'availableCount': '可用数量',
|
||||||
|
'pending_quantity': '待处理数量',
|
||||||
|
'inbound_date': '入库日期',
|
||||||
|
'cn_name': '中文名',
|
||||||
|
'commonName': '通用名',
|
||||||
|
'companyName': '所属公司',
|
||||||
|
'spec': '规格型号',
|
||||||
|
'price': '价格',
|
||||||
|
'purchaser': '采购员',
|
||||||
|
'purchaser_email': '采购邮箱',
|
||||||
|
'print_copies': '打印份数',
|
||||||
|
'global_print_id_str': '全局打印ID(文本)',
|
||||||
|
'source_link': '来源链接',
|
||||||
|
'unit_total_cost': '单位总成本',
|
||||||
|
'current_location': '当前位置',
|
||||||
|
'isEnabled': '是否启用',
|
||||||
|
'isInspectionRequired': '是否需质检',
|
||||||
|
'visibilityLevel': '可见级别',
|
||||||
|
'warningEnabled': '是否启用预警',
|
||||||
|
'warningStatus': '预警状态',
|
||||||
|
'warningRed': '红色预警',
|
||||||
|
'warningYellow': '黄色预警',
|
||||||
|
'manual_link': '说明书链接',
|
||||||
|
'manual_link_remark': '说明书链接备注',
|
||||||
|
'product_image': '产品图片',
|
||||||
|
'product_image_remark': '产品图片备注',
|
||||||
|
'purchase_link': '采购链接',
|
||||||
}
|
}
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@ -310,19 +361,81 @@ HIDDEN_SNAPSHOT_FIELDS = frozenset({
|
|||||||
'arrival_image_embedding',
|
'arrival_image_embedding',
|
||||||
'qc_report_image_embedding',
|
'qc_report_image_embedding',
|
||||||
'image_embedding',
|
'image_embedding',
|
||||||
|
'password', # ★ 见下方凭据关键词的说明
|
||||||
|
'password_hash',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# 凭据类字段的**关键词**拦截。
|
||||||
|
#
|
||||||
|
# ★★ 这不是洁癖,是实测出来的洞:`用户管理/新增` 的 payload 快照里存着
|
||||||
|
# **明文密码**(27 条,实测 pw_len 6/8/11,形如 '1234…'、以用户名开头…)——
|
||||||
|
# 写入路径把请求体整包记进了审计,而请求体里是哈希前的原始密码。
|
||||||
|
# 这些内容会直接显示在详情抽屉里,并随导出进 Excel。
|
||||||
|
#
|
||||||
|
# ★ 用关键词而不是逐个登记:今天漏的是 payload 里的 password,
|
||||||
|
# 明天可能是 reset_token、api_secret。凡是键名沾这些词的都不展示 ——
|
||||||
|
# 新表加了凭据字段也自动被挡住。
|
||||||
|
_CREDENTIAL_KEYWORDS = ('password', 'passwd', 'secret', 'token', 'private_key')
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 7. 快照(details)的键与展示标题
|
||||||
|
#
|
||||||
|
# ★ 历史上有**三种**互不相同的存法,由不同时期的写入路径产生:
|
||||||
|
# {'created': {...}} —— ORM 监听器的 INSERT 快照
|
||||||
|
# {'deleted_snapshot': {...}} —— ORM 监听器的 DELETE 快照
|
||||||
|
# {'payload': {...}} —— 接口层手工记录的业务数据整包
|
||||||
|
# (借库/出库/采购入库等,内部还嵌 items 数组)
|
||||||
|
# `changes` 是第四种,但语义是"变更对比"而非快照,不走这套渲染。
|
||||||
|
#
|
||||||
|
# ★ 键名与标题都收在这里、由 GET /audit/labels 下发:前端不该硬编这三行 ——
|
||||||
|
# 将来再加第四种存法,只改这里即可(本项目已多次栽在"前端存一份副本"上)。
|
||||||
|
#
|
||||||
|
# ★ 顺序即优先级:一条记录同时有多个键时取第一个能用的。
|
||||||
|
# =============================================================================
|
||||||
|
SNAPSHOT_CREATED = 'created'
|
||||||
|
SNAPSHOT_DELETED = 'deleted_snapshot'
|
||||||
|
SNAPSHOT_PAYLOAD = 'payload'
|
||||||
|
CHANGES_KEY = 'changes'
|
||||||
|
|
||||||
|
SNAPSHOT_VIEWS = (
|
||||||
|
(SNAPSHOT_CREATED, '新增数据快照'),
|
||||||
|
(SNAPSHOT_DELETED, '删除前数据快照'),
|
||||||
|
(SNAPSHOT_PAYLOAD, '业务数据'),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_hidden_credential_field(key):
|
||||||
|
"""
|
||||||
|
该字段是否属于**凭据类**(密码/令牌/密钥)—— 这类字段不仅要"不显示",
|
||||||
|
还要在接口出口**整键剥掉**(见 audit_export_service.sanitize_details)。
|
||||||
|
|
||||||
|
★ 单列一个函数而不是复用 is_hidden_snapshot_field:两者处置方式不同。
|
||||||
|
技术字段(id/embedding)只是不该展示;凭据字段是**泄漏**,
|
||||||
|
必须连响应体里都不能有。混在一起会让"要不要剥掉"的语义变含糊。
|
||||||
|
"""
|
||||||
|
k = str(key or '').strip().lower()
|
||||||
|
return any(w in k for w in _CREDENTIAL_KEYWORDS)
|
||||||
|
|
||||||
|
|
||||||
def is_hidden_snapshot_field(key):
|
def is_hidden_snapshot_field(key):
|
||||||
"""
|
"""
|
||||||
该字段是否属于「不该在详情快照里展示」的纯技术字段。
|
该字段是否属于「不该在详情快照里展示」的字段。
|
||||||
|
|
||||||
★ 除名单本身,还按**后缀**拦截 `*embedding`:向量列在各表命名不一
|
三类:
|
||||||
(img_embedding / arrival_image_embedding / qc_report_image_embedding…),
|
· 名单内的纯技术字段(id / 时间戳 / 审计元数据)
|
||||||
逐个登记必然漏。新表加向量列时不必再改这里。
|
· `*embedding` 向量列 —— 按**后缀**拦截:各表命名不一
|
||||||
|
(img_embedding / arrival_image_embedding / qc_report_image_embedding…),
|
||||||
|
逐个登记必然漏。新表加向量列不必再改这里。
|
||||||
|
· 凭据类字段 —— 见 is_hidden_credential_field
|
||||||
|
|
||||||
|
★ 前端同一套规则再判一次(AuditLog.vue 的 isHiddenField):
|
||||||
|
后端名单管"点名"的,关键词两端各自判,语义一致。
|
||||||
"""
|
"""
|
||||||
k = str(key or '').strip().lower()
|
k = str(key or '').strip().lower()
|
||||||
return k in HIDDEN_SNAPSHOT_FIELDS or k.endswith('embedding')
|
if k in HIDDEN_SNAPSHOT_FIELDS or k.endswith('embedding'):
|
||||||
|
return True
|
||||||
|
return is_hidden_credential_field(k)
|
||||||
|
|
||||||
|
|
||||||
def field_label(key):
|
def field_label(key):
|
||||||
@ -676,4 +789,8 @@ def labels_payload():
|
|||||||
'moduleDisplay': module_display_map(),
|
'moduleDisplay': module_display_map(),
|
||||||
# 详情快照里不展示的纯技术字段(前端过滤用,见 HIDDEN_SNAPSHOT_FIELDS)
|
# 详情快照里不展示的纯技术字段(前端过滤用,见 HIDDEN_SNAPSHOT_FIELDS)
|
||||||
'hiddenFields': sorted(HIDDEN_SNAPSHOT_FIELDS),
|
'hiddenFields': sorted(HIDDEN_SNAPSHOT_FIELDS),
|
||||||
|
# 快照键 → 展示标题,按优先级排列(见 SNAPSHOT_VIEWS)。
|
||||||
|
# 前端据此把 created / deleted_snapshot / payload 三种存法抹平,
|
||||||
|
# 不硬编键名。
|
||||||
|
'snapshotViews': [{'key': k, 'title': t} for k, t in SNAPSHOT_VIEWS],
|
||||||
}
|
}
|
||||||
|
|||||||
@ -264,24 +264,50 @@
|
|||||||
</el-table>
|
</el-table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 情况2/3:CREATE / DELETE —— 快照属性列表 -->
|
<!-- 情况2/3/4:快照(created / deleted_snapshot / payload)分层渲染 -->
|
||||||
<div v-else-if="snapshotRows.length" class="details-section">
|
<template v-else-if="hasStructuredDetail">
|
||||||
<div class="section-title">
|
<!-- 第一层:平铺的标量字段 -->
|
||||||
<el-icon><Document /></el-icon>
|
<div v-if="snapshotView.scalars.length" class="details-section">
|
||||||
{{ isDelete ? '删除前数据快照' : '新增数据快照' }}
|
<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>
|
</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 v-else class="details-section">
|
||||||
<div class="section-title">
|
<div class="section-title">
|
||||||
<el-icon><Document /></el-icon>
|
<el-icon><Document /></el-icon>
|
||||||
@ -355,6 +381,8 @@ const enumFields = ref<string[]>(['status'])
|
|||||||
const booleanFields = ref<string[]>([])
|
const booleanFields = ref<string[]>([])
|
||||||
// 详情快照里不展示的纯技术字段(后端下发,见「详情降噪」一节)
|
// 详情快照里不展示的纯技术字段(后端下发,见「详情降噪」一节)
|
||||||
const hiddenFields = ref<string[]>([])
|
const hiddenFields = ref<string[]>([])
|
||||||
|
// 快照键 → 标题(后端下发,见「快照的分层渲染」一节)
|
||||||
|
const snapshotViews = ref<Array<{ key: string; title: string }>>([])
|
||||||
|
|
||||||
// 字段中文名解析:命中映射取中文,否则原样返回
|
// 字段中文名解析:命中映射取中文,否则原样返回
|
||||||
const fieldLabel = (key: string | number): string => {
|
const fieldLabel = (key: string | number): string => {
|
||||||
@ -399,6 +427,7 @@ const loadLabels = async () => {
|
|||||||
booleanFields.value = res.data.booleanFields || []
|
booleanFields.value = res.data.booleanFields || []
|
||||||
moduleDisplay.value = res.data.moduleDisplay || {}
|
moduleDisplay.value = res.data.moduleDisplay || {}
|
||||||
hiddenFields.value = res.data.hiddenFields || []
|
hiddenFields.value = res.data.hiddenFields || []
|
||||||
|
snapshotViews.value = res.data.snapshotViews || []
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[AuditLog] 审计中文映射拉取失败,字段名与码值将原样显示', e)
|
console.warn('[AuditLog] 审计中文映射拉取失败,字段名与码值将原样显示', e)
|
||||||
@ -485,9 +514,16 @@ const isBlankValue = (v: any): boolean => {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ★ 凭据类字段按**关键词**拦截,与后端 audit_labels._CREDENTIAL_KEYWORDS 同规则。
|
||||||
|
// 实测 `用户管理/新增` 的 payload 快照里存着明文密码(写入路径把请求体整包
|
||||||
|
// 记进了审计),必须在详情与导出里都挡住。
|
||||||
|
// 名单用关键词而不是后端下发:新表加了凭据字段也自动被挡,不会漏。
|
||||||
|
const CREDENTIAL_KEYWORDS = ['password', 'passwd', 'secret', 'token', 'private_key']
|
||||||
|
|
||||||
const isHiddenField = (key: string): boolean => {
|
const isHiddenField = (key: string): boolean => {
|
||||||
const k = String(key || '').trim().toLowerCase()
|
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:字段对比行
|
// UPDATE:字段对比行
|
||||||
@ -502,16 +538,103 @@ const diffRows = computed(() => {
|
|||||||
.map(([key, v]: [string, any]) => ({ key, old: v?.old, new: v?.new }))
|
.map(([key, v]: [string, any]) => ({ key, old: v?.old, new: v?.new }))
|
||||||
})
|
})
|
||||||
|
|
||||||
// CREATE / DELETE:快照属性行
|
// ============================================================
|
||||||
// 过滤:空值、纯技术字段、对象 repr 脏值(见 isObjectRepr)
|
// CREATE / DELETE / payload:快照的分层渲染
|
||||||
const snapshotRows = computed(() => {
|
//
|
||||||
const d = currentDetail.value?.details || {}
|
// 历史上有三种快照存法(created / deleted_snapshot / payload),
|
||||||
const snap = d.deleted_snapshot || d.created
|
// 键名与标题由**后端**下发(snapshotViews),前端不硬编 —— 见
|
||||||
if (!snap || typeof snap !== 'object') return []
|
// 后端 audit_labels.SNAPSHOT_VIEWS。
|
||||||
return Object.entries(snap)
|
//
|
||||||
.filter(([key, v]) =>
|
// ★ payload 型快照内部还嵌着数组(items 明细行、arrival_photo 图片 URL 列表),
|
||||||
!isBlankValue(v) && !isHiddenField(key) && !isObjectRepr(v))
|
// 旧实现遇到非平铺结构就退化成原始 JSON 代码块 —— 业务人员看不懂。
|
||||||
.map(([key, value]) => ({ key, value }))
|
// 现在拆成两层:
|
||||||
|
// 标量字段 → 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(() => {
|
const rawJson = computed(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user