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:
yueli
2026-09-28 11:07:10 +08:00
parent 89db1d14d3
commit 0b87a72a41
4 changed files with 302 additions and 2 deletions

View File

@ -333,6 +333,18 @@ FIELD_LABELS = {
'product_image': '产品图片',
'product_image_remark': '产品图片备注',
'purchase_link': '采购链接',
# --- 级联聚合日志的汇总字段 ---
# ★ 这些是 audit_listener 写聚合日志时自造的元字段(见其 AGGREGATE_PATH_MARKERS),
# 不是任何业务表上的列。名字都很通用(count/targets),
# 目前业务快照里没有同名键;将来若出现,要改成按 details 结构区分而非按字段名。
'count': '变更次数',
'targets': '受影响对象',
'targets_total': '对象总数',
'targets_truncated': '对象清单已截断',
# 早期版本的聚合载荷里带过 action(与日志自身的 action 重复,已不再写入),
# 但历史行里还在,留个标签免得它在详情里裸露英文
'action': '操作类型',
}
# =============================================================================
@ -398,10 +410,16 @@ SNAPSHOT_DELETED = 'deleted_snapshot'
SNAPSHOT_PAYLOAD = 'payload'
CHANGES_KEY = 'changes'
# 聚合键:级联接口的批量变更被合并成一条日志时,受影响对象清单存在这里
# (见 audit_listener 的级联聚合)。它不是"快照",但走同一套分层渲染,
# 故一并列在 VIEWS 里 —— 抽屉里会把它渲染成一张对象清单表。
SNAPSHOT_AGGREGATE = 'aggregate'
SNAPSHOT_VIEWS = (
(SNAPSHOT_CREATED, '新增数据快照'),
(SNAPSHOT_DELETED, '删除前数据快照'),
(SNAPSHOT_PAYLOAD, '业务数据'),
(SNAPSHOT_AGGREGATE, '批量汇总'),
)