"""操作日志数据访问。 本层只 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 结构。 # phone/mobile/id_card 系 CLAUDE.md「日志禁输出手机号/身份证」红线的键名口径 —— # 带分隔符的号码会穿过文本正则,必须在这里按整值抹除 _SENSITIVE_KEY_PARTS = ( "password", "passwd", "pwd", "secret", "token", "app_key", "access_key", "phone", "mobile", "id_card", "idcard", ) _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) @dataclass(frozen=True, slots=True) class OperationLogListQuery: """操作日志列表的查询条件(筛选 + 分页)。""" filters: LogFilters offset: int = 0 limit: int = 20 module: str | None = None action: str | None = None def list_logs(session: Session, query: OperationLogListQuery) -> list[OperationLog]: """分页查询操作日志。 **按 id 倒序而非 create_time**:后者是秒级精度,同一秒内的多条记录排序 不确定,翻页会重复或遗漏(与用户列表同一理由)。 **排除 request_data 列**(TEXT,单行最大 60000 字节):列表页默认不 展示它,全列加载会让翻页负担随日志表增长线性放大;详情走 `get_by_id` 按需读取。 """ stmt = _apply_filters( select(OperationLog).options(defer(OperationLog.request_data)), query, ) stmt = stmt.order_by(OperationLog.id.desc()).offset(query.offset).limit(query.limit) return list(session.execute(stmt).scalars()) def count_logs(session: Session, query: OperationLogListQuery) -> int: """统计符合条件的日志总数,供分页使用。""" stmt = _apply_filters(select(func.count(OperationLog.id)), query) return int(session.execute(stmt).scalar_one()) def distinct_modules(session: Session) -> list[str]: """全部模块名(去重、按名称升序):工作台「最近动态」的模块筛选项。 交给 SQL 的 DISTINCT,不在应用层聚合 —— 应用层去重要把整列拉进内存。 """ stmt = select(OperationLog.module).distinct().order_by(OperationLog.module) return [module for module in session.execute(stmt).scalars() if module] def _apply_filters(stmt: Select, query: OperationLogListQuery) -> Select: """应用筛选条件(公共部分复用 query_utils,与登录日志同口径)。""" stmt = apply_log_filters(stmt, OperationLog, query.filters) if query.module: stmt = stmt.where(OperationLog.module == query.module) if query.action: stmt = stmt.where(OperationLog.action == query.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")