"""操作审计记录服务,统一处理详情脱敏与序列化。""" from __future__ import annotations import json from collections.abc import Mapping from datetime import datetime from typing import Any from sqlalchemy.orm import Session from ..models import Account, AuditLog, User from .account_events_service import CATEGORY_OPS, emit _SENSITIVE_KEY_PARTS = ( "cookie", "token", "password", "passwd", "secret", "signature", "sign", "qr_data", "authorization", "credential", "private_key", ) def _safe_value(value: Any, key: str = "") -> Any: """递归过滤敏感字段,避免把凭据、二维码和签名写入审计表。""" key_lower = key.lower() if any(part in key_lower for part in _SENSITIVE_KEY_PARTS): return "[已脱敏]" if isinstance(value, Mapping): return {str(k): _safe_value(v, str(k)) for k, v in value.items()} if isinstance(value, (list, tuple)): return [_safe_value(item) for item in value] if isinstance(value, (str, int, float, bool)) or value is None: return value return str(value) def record_audit( db: Session, current: User | None, *, action: str, target: str = "", detail: Mapping[str, Any] | str | None = None, success: bool = True, ) -> AuditLog: """加入一条审计记录;调用方负责与业务变更一起提交事务。""" if isinstance(detail, Mapping): detail_text = json.dumps( _safe_value(detail), ensure_ascii=False, separators=(",", ":") ) elif detail is None: detail_text = "" else: detail_text = str(_safe_value(detail)) if not success: detail_text = json.dumps( {"message": detail_text}, ensure_ascii=False, separators=(",", ":") ) entry = AuditLog( user_id=current.id if current else None, username=current.username if current else "supplier", action=action, target=target, detail=detail_text, success=success, ) db.add(entry) return entry def record_account_action( db: Session, current: User | None, *, account: Account, action: str, event_type: str, title: str, target: str = "", detail: Mapping[str, Any] | str | None = None, success: bool = True, occurred_at: datetime | None = None, ) -> AuditLog: """账号相关操作:**审计与账号时间线一次写完**。 为什么合成一个函数:这两件事必须成对出现 —— 只写审计,账号时间线里就缺一条「谁在什么时候改了什么」; 只发事件,合规审计里就缺一条。 分两次调用写,迟早有人漏一边(S4 之前 `assigned` / `tag_changed` 这两个事件类型定义了、前端也渲染了,但**从来没有代码发过**)。 顺带把 `account_id` 落到审计上:审计页可以按账号过滤,且账号被删除后 仍然查得到 —— `audit_logs.account_id` 是普通整型列、**没有外键**, 所以允许指向已删除的账号(这正是删除账号时也记它的原因)。 """ entry = record_audit( db, current, action=action, target=target or account.username, detail=detail, success=success, ) entry.account_id = account.id # 事件是旁路:emit 内部吞异常,记不上也绝不影响业务写入。 emit( db, account_id=account.id, event_type=event_type, title=title, category=CATEGORY_OPS, occurred_at=occurred_at, # 细节同时给事件一份(先脱敏,避免凭据类字段进时间线)。 # Mapping 直接作为事件 detail,不再套一层 —— 时间线里看到的就是 # {"from": ..., "to": ...},前端不必再往里挖一层。 detail=dict(_safe_value(dict(detail))) if isinstance(detail, Mapping) else None, source="audit", actor_type="user", actor_id=current.id if current else None, ) return entry