"""操作日志数据访问。 本层只 flush 不 commit —— 事务边界由 services 层控制。 写入前处理(截断 / 脱敏 / 序列化)集中在本层,与 `login_log_repository` 的做法一致:模型定义的长度常量在这里落实,调用方只需传业务值。 """ import json from dataclasses import dataclass from sqlalchemy import Select, func, select from sqlalchemy.orm import Session, defer from app.core.logging import scrub from app.models import OperationLog from app.models.operation_log import ( ACTION_MAX_LEN, ERROR_MESSAGE_MAX_LEN, IP_MAX_LEN, METHOD_MAX_LEN, MODULE_MAX_LEN, PATH_MAX_LEN, REQUEST_DATA_MAX_BYTES, SUMMARY_MAX_LEN, USER_AGENT_MAX_LEN, USERNAME_MAX_LEN, ) from app.repositories.query_utils import LogFilters, apply_log_filters # 键名(不区分大小写)含以下子串的字段,其值一律整体抹除。 # 口径与 core.logging.scrub 的键名正则一致,但改为结构化处理 —— # 文本正则有三种逃逸形态:值以空白开头时整体匹配失败(完全不脱敏)、 # 值含空格/逗号时只抹首段、值含引号时既残留又破坏 JSON 结构。 _SENSITIVE_KEY_PARTS = ("password", "passwd", "pwd", "secret", "token", "app_key", "access_key") _MASK = "***" @dataclass(frozen=True, slots=True) class OperationLogEntry: """一条待写入的操作日志(全字段 DTO)。 替代 14 个平铺参数:字段若在 audit() / _safe_write() / add() 三处 重复展开,增删字段时任何一侧漏改都只表现为审计数据不完整、不报错 (success 默认 0 的语义使漏传尤其隐蔽)。 """ user_id: int | None username: str module: str action: str summary: str success: bool method: str | None = None path: str | None = None ip: str | None = None user_agent: str | None = None request_data: dict | None = None error_message: str | None = None duration_ms: int | None = None def add(session: Session, entry: OperationLogEntry) -> None: """写入一条操作日志。只 flush 不 commit。 所有文本列按模型定义的长度常量截断 —— 超长在 MySQL 严格模式下抛 1406, 而「审计失败不影响业务」的约定会把该异常吞掉,结果是整条日志**静默丢失**, 事后极难排查。截断是唯一防线,必须在此层无条件执行。 """ session.add( OperationLog( user_id=entry.user_id, username=entry.username[:USERNAME_MAX_LEN], module=entry.module[:MODULE_MAX_LEN], action=entry.action[:ACTION_MAX_LEN], summary=entry.summary[:SUMMARY_MAX_LEN], method=_clip(entry.method, METHOD_MAX_LEN), path=_clip(entry.path, PATH_MAX_LEN), ip=_clip(entry.ip, IP_MAX_LEN), user_agent=_clip(entry.user_agent, USER_AGENT_MAX_LEN), request_data=_serialize_request_data(entry.request_data), success=1 if entry.success else 0, error_message=_clip(entry.error_message, ERROR_MESSAGE_MAX_LEN), duration_ms=entry.duration_ms, ) ) session.flush() def get_by_id(session: Session, log_id: int) -> OperationLog | None: """按主键查询单条日志(详情端点用,含 request_data)。不存在返回 None。""" return session.get(OperationLog, log_id) def list_logs( session: Session, *, offset: int, limit: int, filters: LogFilters, module: str | None = None, action: str | None = None, ) -> list[OperationLog]: """分页查询操作日志。 **按 id 倒序而非 create_time**:后者是秒级精度,同一秒内的多条记录排序 不确定,翻页会重复或遗漏(与用户列表同一理由)。 **排除 request_data 列**(TEXT,单行最大 60000 字节):列表页默认不 展示它,全列加载会让翻页负担随日志表增长线性放大;详情走 `get_by_id` 按需读取。 """ stmt = _apply_filters( select(OperationLog).options(defer(OperationLog.request_data)), filters=filters, module=module, action=action, ) stmt = stmt.order_by(OperationLog.id.desc()).offset(offset).limit(limit) return list(session.execute(stmt).scalars()) def count_logs( session: Session, *, filters: LogFilters, module: str | None = None, action: str | None = None, ) -> int: """统计符合条件的日志总数,供分页使用。""" stmt = _apply_filters( select(func.count(OperationLog.id)), filters=filters, module=module, action=action ) return int(session.execute(stmt).scalar_one()) def _apply_filters( stmt: Select, *, filters: LogFilters, module: str | None, action: str | None, ) -> Select: """应用筛选条件(公共部分复用 query_utils,与登录日志同口径)。""" stmt = apply_log_filters(stmt, OperationLog, filters) if module: stmt = stmt.where(OperationLog.module == module) if action: stmt = stmt.where(OperationLog.action == action) return stmt def _clip(text: str | None, limit: int) -> str | None: """按字符数截断(长度常量与数据库列宽均按字符定义)。""" return text[:limit] if text else None def _is_sensitive_key(key: object) -> bool: name = str(key).lower() return any(part in name for part in _SENSITIVE_KEY_PARTS) def _sanitize_sensitive(value: object) -> object: """递归抹除请求参数中的敏感字段值(dict 按键名整体替换、list 逐项递归)。""" if isinstance(value, dict): return { key: _MASK if _is_sensitive_key(key) else _sanitize_sensitive(item) for key, item in value.items() } if isinstance(value, list): return [_sanitize_sensitive(item) for item in value] return value def _serialize_request_data(data: dict | None) -> str | None: """请求参数 → JSON 文本:两段脱敏 → 字节边界截断。 脱敏分两层,各司其职: - **结构化抹除**(`_sanitize_sensitive`):按键名整体置 ``***``, 不依赖文本模式匹配 —— 密码含空格、引号、前导空白等形态都不受影响 - `core.logging.scrub` 作文本兜底:覆盖手机号、Bearer 等无法按键名 判断的值 截断必须在 **UTF-8 字节边界** 进行:request_data 列是 TEXT(65535 字节 上限),而 request_data 上限 60000 字节也是按字节定的;若按字符切片, 中文场景下字符数与字节数相差 3 倍同样可能超限;先编码再按字节切片 则可能把多字节字符切成两半产生非法序列。故顺序为:编码 → 字节切片 → 宽容解码丢弃残片。encode 用 ``errors="replace"`` 兜底孤立代理字符 —— 请求 JSON 里的 ``\\ud800`` 转义会解析为孤立代理,直接 encode 会抛 UnicodeEncodeError,被审计兜底逻辑吞掉后该请求的记录**静默丢失**。 """ if not data: return None text = scrub(json.dumps(_sanitize_sensitive(data), ensure_ascii=False, default=str)) encoded = text.encode("utf-8", errors="replace") if len(encoded) <= REQUEST_DATA_MAX_BYTES: return text return encoded[:REQUEST_DATA_MAX_BYTES].decode("utf-8", errors="ignore")