fix(audit): 修复 _collect_snapshot 关系守卫失效导致的审计丢失

hasattr(attr, 'property') 对 inspect(obj).attrs 产出的 AttributeState
恒为 False,这个守卫从未生效过 —— 关系属性一律被 getattr() 取了值。

lazy='dynamic' 的关系(如 SysWarehouseLocation.children)拿到的是
AppenderQuery,_serialize_value 对非基础类型原样放行,最终 json.dumps
兜底走 str(query) → query.session → Session.flush(),而此刻正在 flush 中,
抛 InvalidRequestError("Session is already flushing"),整条审计被 except 吞掉。

后果:
- sys_warehouse_location 的新建/删除审计长期为 0 条
  (UPDATE 走 attr.history 而非 getattr,所以不受影响,症状是间歇性的)
- 批量生成 3050 个节点时逐行抛 3050 次异常:请求 873ms → 修复后 349ms

改为按 inspect(cls).relationships 的名字排除;before_update_listener
里的同一处坏守卫一并修正。

另把 warehouse 登记进 AGGREGATE_PATH_MARKERS:库位批量生成一次可产出
3000+ 节点,未登记时一次点击写 3000+ 条审计,登记后只留一条带 targets
清单的记录。
This commit is contained in:
yueli
2026-09-29 12:03:43 +08:00
parent 50232bb58f
commit 896bbd8eeb

View File

@ -149,6 +149,10 @@ AGGREGATE_PATH_MARKERS = (
'inbound/stock/stocktake/generate-missing', 'inbound/stock/stocktake/generate-missing',
'outbound', 'outbound',
'import/execute', 'import/execute',
# 库位管理:批量生成一次可产出 3000+ 个节点,批量删除同理。
# 未登记时一次点击就写 3000+ 条审计(且叠加 _collect_snapshot 旧守卫的逐行异常),
# 登记后一次点击只留一条带 targets 清单的记录。
'warehouse',
) )
# 聚合日志里保留的受影响对象清单上限。 # 聚合日志里保留的受影响对象清单上限。
@ -571,12 +575,21 @@ def _collect_snapshot(target):
"""收集对象全字段快照(跳过忽略字段与关系属性)""" """收集对象全字段快照(跳过忽略字段与关系属性)"""
snap = {} snap = {}
state = inspect(target) state = inspect(target)
# ★ 关系属性必须按**名字**排除。原守卫写的是
# hasattr(attr, 'property') and hasattr(attr.property, 'direction')
# 而 state.attrs 产出的是 AttributeState —— 它没有 .property,守卫恒为 False,
# 关系属性从来没被跳过过。后果不只是"快照里多几个字段":
# lazy='dynamic' 的关系(如 SysWarehouseLocation.children)取值得到的是
# AppenderQuery,_serialize_value 对非基础类型原样放行,最终 json.dumps
# 兜底走 str(query) → query.session → Session.flush(),而此刻正在 flush 中
# → InvalidRequestError("Session is already flushing") → 整条审计被 except 吞掉。
# 实测库位的新建/删除审计因此长期为 0 条,批量生成 3000 个节点会逐行抛 3000 次。
rel_keys = set(inspect(target.__class__).relationships.keys())
for attr in state.attrs: for attr in state.attrs:
key = attr.key key = attr.key
if _should_skip_field(key): if key in rel_keys:
continue continue
# 跳过关系属性(如 .base / .material),它们不是列 if _should_skip_field(key):
if hasattr(attr, 'property') and hasattr(attr.property, 'direction'):
continue continue
snap[key] = _serialize_value(getattr(target, key, None)) snap[key] = _serialize_value(getattr(target, key, None))
return snap return snap
@ -604,11 +617,15 @@ def before_update_listener(mapper, connection, target):
try: try:
state = inspect(target) state = inspect(target)
changes = {} changes = {}
# 同 _collect_snapshot:关系属性按名字排除(原守卫基于不存在的 .property,恒为 False)。
# 这里尤其危险 —— 关系一旦有 history,新旧值会被原样放进 changes,
# 后续 json.dumps 兜底 str() 到 AppenderQuery 就是同一个 flush 崩溃。
rel_keys = set(inspect(target.__class__).relationships.keys())
for attr in state.attrs: for attr in state.attrs:
key = attr.key key = attr.key
if _should_skip_field(key): if key in rel_keys:
continue continue
if hasattr(attr, 'property') and hasattr(attr.property, 'direction'): if _should_skip_field(key):
continue continue
if not attr.history.has_changes(): if not attr.history.has_changes():
continue continue