- 信息员预览基数改用本人销售额(原误用折算毛利润额,金额算错且口径自相矛盾) - 产品类别入参与维度值加存在性校验(拼错静默回退默认阈值 30% 会算错轨交工资) - submit 校验顺序前置查库(未注册 param_code 由 500 修正为 400) - 金额拒绝 NaN/Infinity(比较抛 InvalidOperation、计算抛溢出均穿透为 500) - version_no 并发冲突翻译为 400(对齐 user/role service 的 IntegrityError 处理) - 旧值留痕改取条目生效日时点的值(多未来批次下留痕链不再错位) - 脱敏红线:phone/mobile/id_card 键名整值抹除 + 手机号正则兼容分隔符写法 - 前端:撤回按钮与导出文件名改本地日期(UTC/本地混算)、维度行内精确「改」入口、 detailOf 补 catch、抽屉分页 size 生效、blob 错误读出真实 message - e2e 密码移除硬编码 fallback(缺失即报错退出);按钮文案同步「改」 - 全量回归:后端 406 + 集成 104 + 前端 type-check/build + e2e 7 步 全绿 Co-Authored-By: Claude Code <noreply@anthropic.com>
215 lines
7.6 KiB
Python
215 lines
7.6 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)
|
||
|
||
|
||
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")
|