# -*- coding: utf-8 -*- """操作审计写入统一入口(2026-09-23,`dama-entity-isolation-sweep-20260923` §二之二④)。 **约定(只动写侧;不加列、不改读侧口径、不回填历史)**: 1. 每条 `operation_logs` **尽量带 `detail.entity_id`** —— 语义是「**本动作所属账套**」: 会话账套(`Depends(get_entity_id)`)/ Bot 入参账套 / 被处理对象自身的账套。 2. 确实没有账套上下文的写入口 → `entity_id=None`,并打一条 `OPLOG_NO_ENTITY` 警告: 把覆盖缺口留成**可 grep 的哨兵**(下一轮审计收口有据可查), **绝不猜账套** —— 猜错比留空更有害(错账套会让 `knowledge_service` 归集时又串一次)。 3. 新的审计写入口一律走 `log_op()`,不要新写 `OperationLog(...)` 构造(第 28 处必漏)。 **读侧(本轮不动)**:`app/services/knowledge_service.py::extract_events` 仍按 `user_id → user_entities` 兜底过滤 —— `user_id IS NULL` 的 Bot/脚本日志因此进不了记忆抽取 (**覆盖缺口,非泄漏**)。`detail.entity_id` 到位后下一轮才切读侧。 """ from __future__ import annotations import json import logging from app.models import OperationLog logger = logging.getLogger("cma.oplog") #: 哨兵前缀(供 grep / 告警链订阅):无账套上下文的写入口 NO_ENTITY_TAG = "OPLOG_NO_ENTITY" #: 哨兵前缀:跨账套批量写(一次动作覆盖多账套,无法归单一账套)——覆盖缺口,待下一轮收口 BATCH_NO_ENTITY_TAG = "OPLOG_BATCH_NO_ENTITY" #: `scope` 取值:显式声明「该写入口不是账套维度」(平台字典表 / 角色级配置),不再报缺口 SCOPE_GLOBAL = "global" #: `scope` 取值:一次动作跨多账套批量(无法归单账套)——照实报警,不猜账套 SCOPE_BATCH = "batch" def _as_detail_dict(detail) -> dict: """把三种历史 detail 写法归一到 dict(**不改变已有存储形态语义**)。 - ``dict`` → 直接用(复制一份,不就地改调用方的对象) - JSON **对象**字符串(历史写法 ``json.dumps({...})``)→ 解析为 dict,与 entity_id 合并进同一对象 - 其他字符串 → ``{"note": <原文>}``(原先以标量字符串存的行,升级为对象并附账套) """ if detail is None: return {} if isinstance(detail, dict): return dict(detail) if isinstance(detail, str): s = detail.strip() if s.startswith("{"): try: v = json.loads(s) except (ValueError, TypeError): v = None if isinstance(v, dict): return v return {"note": detail} return {"note": str(detail)} def log_op(db, action, *, target_type=None, target_id=None, detail=None, user_id=None, entity_id=None, scope=None, commit=False): """写一条 `operation_logs`,统一带账套(`detail.entity_id`)。 :param db: SQLAlchemy Session :param action: 动作名(DB 非空列) :param target_type: 目标类型(DB 非空列) :param target_id: 目标 id(可空) :param detail: dict(推荐)/ JSON 对象字符串 / 普通 str(存为 ``{"note": <原文>}``)/ None :param user_id: 操作人;Bot/脚本无用户时 None(读侧本轮仍按 user_id 兜底) :param entity_id: **本动作所属账套**;无上下文时 None(触发 OPLOG_NO_ENTITY 哨兵) :param scope: ``SCOPE_GLOBAL`` = 该写入口按设计非账套维度(写 ``detail.entity_scope``,不报警); ``SCOPE_BATCH`` = 跨账套批量(写 ``entity_scope=batch`` **并**报警,不猜账套); None = 未知(报警,留可 grep 的覆盖缺口) :param commit: 是否立即提交(默认 False,交由调用方事务边界) """ d = _as_detail_dict(detail) # 调用方若已把账套写在 detail 里(历史写法),沿用之,避免同一个键两套来源 if entity_id is None and d.get("entity_id") is not None: entity_id = d["entity_id"] if entity_id is not None: d["entity_id"] = int(entity_id) elif scope == SCOPE_GLOBAL: d["entity_scope"] = SCOPE_GLOBAL elif scope == SCOPE_BATCH: d["entity_scope"] = SCOPE_BATCH logger.warning( "%s action=%s target_type=%s target_id=%s — 跨账套批量写,detail.entity_id 留空" "(禁止猜账套)", BATCH_NO_ENTITY_TAG, action, target_type, target_id) else: logger.warning( "%s action=%s target_type=%s target_id=%s — 该写入口无账套上下文," "detail.entity_id 留空(禁止猜账套)", NO_ENTITY_TAG, action, target_type, target_id) row = OperationLog( user_id=user_id, action=action, target_type=target_type, target_id=target_id, detail=d or None, ) db.add(row) if commit: db.commit() return row