仓储层的 list_*/count_* 此前以 keyword/status/offset/limit 四五个散参数 传参,调用点重复铺开、易漏传且不可扩展;按项目「参数 >4 封装 DTO」的 约定统一收敛为各模块的 Query 对象(如 RoleQuery、BusinessQuery), 服务层与测试调用同步调整,行为不变。 - repositories 层查询方法签名改为 (session, query),分页与筛选条件 由 Query 承载,_apply_filters 同步收口 - schemas 层补齐各模块 Query 模型(approval/business_role/report 等) - services 层调用点同步改为传 Query - tests 随之调整;新增用例:待办角色流转(财务通过后领导出现待办)、 参数预览顺序稳定 验证:全量 pytest 900 passed(11:08);单元 526 passed;ruff check 绿。 Co-Authored-By: Claude Code <noreply@anthropic.com>
202 lines
7.5 KiB
Python
202 lines
7.5 KiB
Python
"""操作日志数据访问。
|
||
|
||
本层只 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 _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")
|