文件
PayrollManagementSystem/backend/app/repositories/operation_log_repository.py
T
liuwenxin和Claude Code 61fe5cca98 feat(dashboard): 最近动态改服务端分页
工作台「最近动态」原为聚合端点内嵌、固定截断前 20 条,操作日志变长后
既撑大响应又只能看到头部。改为独立分页端点:
- GET /api/v1/dashboard/activities,支持 pageNum/pageSize/module 筛选
- 模块筛选项与列表同源返回(全量模块名,不随翻页变化),前端不必再取一次全量
- 聚合端点去掉内嵌 activities,避免同一份数据两处查询

前端改用 usePagedList + 统一 AppPagination(每页 10/20/50),模块筛选由
本地过滤改为服务端筛选。集成测试 13 例(含翻页不重不漏 / 模块筛选 / 越权 403)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-23 16:15:09 +08:00

211 行
7.9 KiB
Python
原始文件 Blame 文件历史

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
"""操作日志数据访问。
本层只 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")