feat(audit): 级联操作聚合 —— 一次业务点击只产生一条审计
问题:BOM 归档/启停这类接口一次请求改动整组数据,ORM 监听器逐行写审计,
审计页被刷屏。**实测问题范围远大于 BOM**(同一秒、同接口、同操作人的条数):
/api/v1/inbound/stock/draft/start-new 924 条
/api/v1/inbound/stock/stocktake/generate-missing 898 条
/api/v1/permissions/assign 381 条
/api/v1/outbound 110 条
/api/v1/bom/save 83 条
/api/v1/bom/archive 58 条
全库共 311 次「单请求 ≥20 条」的突发,累计 4 万余行。
实现:URL 白名单 + 同事务合并(方案 A 的收敛版)
· AGGREGATE_PATH_MARKERS 按**路径段前缀**匹配(不是子串):
/api/v1/bom_draft_x 不会命中 bom,/api/v1/somebom/thing 也不会。
· 命中白名单的变更在 flush 期间累加到 session.info,由 Session
after_flush 钩子合并成**一条** AuditLog。
· details = 各行的**深度公共子集** + aggregate{count, targets}。
★ 为什么用白名单而不是全局默认合并:合并会改变审计的**语义粒度**
(日报里「修改 354 条」可能变成几十条),全局改会让业务方以为日志坏了。
白名单的失败模式也更安全 —— 新接口忘了登记只是"仍然刷屏",
而不会把两个不相干的业务动作错误合并(错误合并 = 把 A 的改动记到 B 头上,
是审计里最危险的一类错)。
★ 只取公共子集,不用某一行的值代表整批 —— 那是编造。BOM 的级联批次各行
改动完全一致(实测 /bom/archive 400 条只有 3 种签名、349 条同一个),
故合并**无损**;各行不一致时 changes 只留共同字段,其余由 targets 交代
"动过哪些对象"。
★★ 钩子必须挂 after_flush,不能挂 before_commit —— 踩过的坑:
Session.commit() 的顺序是 before_commit → flush → after_flush → COMMIT,
而累加发生在 flush **期间**。挂 before_commit 时钩子跑在累加之前,
缓冲还是空的;等 flush 填满后没人再写 —— 结果是**审计整批丢失**
(实测归档请求产出 0 条日志,业务却已提交,正是最危险的"改了但没记录")。
首版就是这个错,靠真实请求打 /bom/archive 数日志条数才抓出来。
★ 为什么不用 after_request/teardown:那跑在业务事务之外,业务回滚也会留下
一条"成功"的审计 —— 假账。after_flush 仍属同一事务,聚合日志与业务改动
同生共死(实测回滚后不留日志)。
消费端:
· changes_summary 优先识别 aggregate,读作
「批量更新 58 条;是否启用:是→否;是否归档:否→是(来源:/bom/archive)」
· SNAPSHOT_VIEWS 增加 aggregate,抽屉据此渲染「变更次数 + 受影响对象表」
· 前端把「变更对比」与「快照/汇总」从二选一改为各自独立渲染 ——
聚合日志两者都有,原先的 v-else 会让汇总块显示不出来
验证(26 + 18 项断言全过):
· 真实请求 POST /api/v1/bom/archive 打一个 58 行的 BOM:
归档前 0 条 → 归档后**恰好 1 条**,count=58、targets 58 条、
共同变更无损、业务改动同时生效(58/58 行已归档)
· 回滚后不留下聚合日志(59996 → 59996)
· 路径匹配 15 个边界(含 bom_draft_x / somebom 两个反向用例)
· 非白名单接口行为不变;日报附件 490.5K/193.4K/92.3K 与 6 列结构未变;
导出口径与列表一致;权限与凭据过滤未松动;字典 181 键零缺失
· vue-tsc 与 vite build exit=0
⚠️ 仅对**改动之后**的请求生效,历史 4 万行突发数据不变。
⚠️ 验证过程在库里留下 4 条真实审计记录(id 60278/60279 等,均是本人对
SF-9000-9 V2.2 的归档/取消归档操作,业务数据已精确还原为原状)。
审计记录未删除 —— 删审计要单独决策。
This commit is contained in:
@ -38,6 +38,7 @@ from app.utils.audit_labels import (
|
||||
BOOLEAN_FIELDS,
|
||||
CHANGES_KEY,
|
||||
PERSON_NAME_FIELDS,
|
||||
SNAPSHOT_AGGREGATE,
|
||||
SNAPSHOT_CREATED,
|
||||
SNAPSHOT_DELETED,
|
||||
USER_ID_FIELDS,
|
||||
@ -1057,6 +1058,37 @@ def _child_text(child):
|
||||
f"#{child['id']}" if child.get('id') else '')
|
||||
|
||||
|
||||
def _aggregate_summary(row, agg, changes, limit):
|
||||
"""
|
||||
级联聚合日志的摘要,形如:
|
||||
批量更新 58 条:是否启用:是→否;是否归档:否→是(来源:/bom/archive)
|
||||
|
||||
★ 聚合行必须走这条分支,不能落到下面按快照/字段名罗列的逻辑:
|
||||
它的 details 是 {共同变更 + aggregate},按字段名罗列会得到
|
||||
「新增(BOM管理):count、targets」这种毫无意义的串。
|
||||
|
||||
changes: 该行的共同变更(各行都成立的字段)。**可能为空** ——
|
||||
各行改动不一致时不敢用某一行的值代表整批,那属于编造。
|
||||
"""
|
||||
verb = {'CREATE': '批量新增', 'UPDATE': '批量更新',
|
||||
'DELETE': '批量删除'}.get(canon_action(row.action), '批量操作')
|
||||
parts = [f"{verb} {agg.get('count', 0)} 条"]
|
||||
if changes:
|
||||
parts.append(';'.join(
|
||||
f"{lb}:{fmt_value(o)}→{fmt_value(n)}" for lb, o, n in changes))
|
||||
# 受影响对象数与变更次数是两回事(前者去重):336 次变更可能只涉及 21 个对象
|
||||
total = agg.get('targets_total')
|
||||
if total and total != agg.get('count'):
|
||||
parts.append(f"涉及 {total} 个对象")
|
||||
if agg.get('targets_truncated'):
|
||||
parts.append(f"对象清单仅列前 {len(agg.get('targets') or [])} 个")
|
||||
|
||||
body = truncate(':'.join(parts[:1]) + (';' + ';'.join(parts[1:])
|
||||
if len(parts) > 1 else ''), limit)
|
||||
source = url_source(row.url)
|
||||
return f"{body}(来源:/{source})" if source else body
|
||||
|
||||
|
||||
def changes_summary(row, ref_maps, limit=200, resolved=None, material=None,
|
||||
child=None):
|
||||
"""
|
||||
@ -1069,6 +1101,13 @@ def changes_summary(row, ref_maps, limit=200, resolved=None, material=None,
|
||||
台账摘要和明细长表,调用方预计算一次传进来即可 —— 否则每行
|
||||
要跑两遍 resolve_value(4 万行的导出上是可感知的浪费)。
|
||||
"""
|
||||
# ★ 级联聚合行:优先于下面所有分支(它的 details 形状与快照/变更都不同)
|
||||
details = row.details if isinstance(row.details, dict) else {}
|
||||
agg = details.get(SNAPSHOT_AGGREGATE)
|
||||
if isinstance(agg, dict):
|
||||
common = resolved_changes(row.module, changes_of(row), ref_maps)
|
||||
return _aggregate_summary(row, agg, common, limit)
|
||||
|
||||
if canon_action(row.action) == 'UPDATE':
|
||||
if resolved is None:
|
||||
resolved = resolved_changes(row.module, changes_of(row), ref_maps)
|
||||
|
||||
Reference in New Issue
Block a user