174 lines
6.2 KiB
Python
174 lines
6.2 KiB
Python
|
|
"""操作日志审计服务。
|
|||
|
|
|
|||
|
|
需求 2.5.1 要求记录用户的操作行为。与登录行为的分工:登录在 `login_logs`
|
|||
|
|
单独记录(失败也要留痕且不依赖认证上下文),本模块记录**已认证用户的管理类
|
|||
|
|
操作**。
|
|||
|
|
|
|||
|
|
设计要点:
|
|||
|
|
|
|||
|
|
- **成功与失败都留痕** —— 失败的操作(如试图删除不存在的用户)同样是审计
|
|||
|
|
对象,模型里专门有 `success` / `error_message` 两列承载它
|
|||
|
|
- **审计失败不影响业务** —— 落库过程整体兜底,失败只记应用日志、绝不外抛
|
|||
|
|
(与 `auth_service._record` 同一原则:"审计可丢,业务不可断")
|
|||
|
|
- **独立提交** —— 日志单独 commit,不与业务同事务。同事务时业务失败回滚会
|
|||
|
|
连带丢掉最该留痕的失败记录
|
|||
|
|
|
|||
|
|
用法(上下文管理器)::
|
|||
|
|
|
|||
|
|
target = user_repository.get_by_id(session, user_id) # 只读预取,供摘要
|
|||
|
|
with audit(session, operator=current_user,
|
|||
|
|
record=AuditRecord(MODULE_USER, ACTION_DELETE,
|
|||
|
|
f"删除用户 {target.username}"),
|
|||
|
|
meta=meta):
|
|||
|
|
...业务操作与 session.commit()...
|
|||
|
|
return out
|
|||
|
|
|
|||
|
|
约定:
|
|||
|
|
|
|||
|
|
- 业务代码在上下文内自行 commit(事务边界仍在 service 层)。**commit 之后
|
|||
|
|
的任何数据库查询、ORM 属性读取都必须移出上下文**(提到 commit 之前或
|
|||
|
|
用已固化的局部变量)—— 业务已提交却因后置查询抛错被记成「失败」,会
|
|||
|
|
产生与事实相反的审计记录
|
|||
|
|
- 上下文内不得吞掉异常后正常退出——吞掉后会被记为成功
|
|||
|
|
- 摘要需要"目标实体名"时,应在进入上下文**之前**只读预取,
|
|||
|
|
这样目标不存在(NotFound 失败)时也能留下可追溯的记录
|
|||
|
|
- 不支持嵌套(在 audit 内再调用会自行 commit 的服务)——进入侦测到
|
|||
|
|
未提交变更会记 warning
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
import logging
|
|||
|
|
import time
|
|||
|
|
from collections.abc import Iterator
|
|||
|
|
from contextlib import contextmanager
|
|||
|
|
from dataclasses import dataclass
|
|||
|
|
|
|||
|
|
from sqlalchemy.orm import Session
|
|||
|
|
|
|||
|
|
from app.core.exceptions import AppException
|
|||
|
|
from app.models import User
|
|||
|
|
from app.repositories import operation_log_repository
|
|||
|
|
from app.repositories.operation_log_repository import OperationLogEntry
|
|||
|
|
|
|||
|
|
logger = logging.getLogger(__name__)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass(frozen=True, slots=True)
|
|||
|
|
class RequestMeta:
|
|||
|
|
"""HTTP 请求元信息。由路由层依赖 `RequestMetaDep` 提取后传入 service。"""
|
|||
|
|
|
|||
|
|
ip: str | None = None
|
|||
|
|
user_agent: str | None = None
|
|||
|
|
method: str | None = None
|
|||
|
|
path: str | None = None
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass(frozen=True, slots=True)
|
|||
|
|
class AuditRecord:
|
|||
|
|
"""审计的业务侧内容(模块 / 操作 / 摘要 / 请求参数)。"""
|
|||
|
|
|
|||
|
|
module: str
|
|||
|
|
action: str
|
|||
|
|
summary: str
|
|||
|
|
request_data: dict | None = None
|
|||
|
|
|
|||
|
|
|
|||
|
|
@contextmanager
|
|||
|
|
def audit(
|
|||
|
|
session: Session,
|
|||
|
|
*,
|
|||
|
|
operator: User,
|
|||
|
|
record: AuditRecord,
|
|||
|
|
meta: RequestMeta | None = None,
|
|||
|
|
) -> Iterator[None]:
|
|||
|
|
"""审计上下文:正常退出记成功;抛异常记失败并**原样重抛**。"""
|
|||
|
|
started = time.perf_counter()
|
|||
|
|
meta = meta or RequestMeta()
|
|||
|
|
|
|||
|
|
# 固化操作人为普通标量,之后不再触碰 ORM 对象 —— 失败路径先 rollback
|
|||
|
|
# 会使其全部过期,再读 id/username 将触发额外 SELECT;若账号恰被并发
|
|||
|
|
# 删除,ObjectDeletedError 还会被下面的兜底吞掉、失败审计静默丢失
|
|||
|
|
base: dict = {
|
|||
|
|
"user_id": operator.id,
|
|||
|
|
"username": operator.username,
|
|||
|
|
"module": record.module,
|
|||
|
|
"action": record.action,
|
|||
|
|
"summary": record.summary,
|
|||
|
|
"method": meta.method,
|
|||
|
|
"path": meta.path,
|
|||
|
|
"ip": meta.ip,
|
|||
|
|
"user_agent": meta.user_agent,
|
|||
|
|
"request_data": record.request_data,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# 进入审计时应无未提交变更(业务写操作在上下文内才发生)。出现变更
|
|||
|
|
# 意味着调用方可能嵌套了会自行 commit 的服务:失败路径的回滚将无法
|
|||
|
|
# 撤销已提交的部分,审计与数据状态会不一致
|
|||
|
|
if session.new or session.dirty or session.deleted:
|
|||
|
|
logger.warning(
|
|||
|
|
"进入审计上下文时存在未提交的变更 | module=%s | action=%s",
|
|||
|
|
record.module,
|
|||
|
|
record.action,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
yield
|
|||
|
|
except Exception as exc:
|
|||
|
|
# 先回滚再写日志:业务异常可能源于 IntegrityError 等数据库错误,
|
|||
|
|
# 不回滚的话 session 处于「必须回滚」状态,写日志会抛
|
|||
|
|
# PendingRollbackError —— 最该留下的失败记录反而被丢掉
|
|||
|
|
_rollback_quietly(session)
|
|||
|
|
_safe_write(
|
|||
|
|
session,
|
|||
|
|
entry=OperationLogEntry(
|
|||
|
|
**base,
|
|||
|
|
success=False,
|
|||
|
|
error_message=_error_text(exc),
|
|||
|
|
duration_ms=_elapsed_ms(started),
|
|||
|
|
),
|
|||
|
|
)
|
|||
|
|
raise
|
|||
|
|
else:
|
|||
|
|
_safe_write(
|
|||
|
|
session,
|
|||
|
|
entry=OperationLogEntry(**base, success=True, duration_ms=_elapsed_ms(started)),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _safe_write(session: Session, *, entry: OperationLogEntry) -> None:
|
|||
|
|
"""落库并独立提交。整体兜底:任何失败只记应用日志,绝不外抛。"""
|
|||
|
|
try:
|
|||
|
|
operation_log_repository.add(session, entry)
|
|||
|
|
session.commit()
|
|||
|
|
except Exception:
|
|||
|
|
logger.warning(
|
|||
|
|
"操作日志写入失败(不影响业务) | module=%s | action=%s | summary=%s",
|
|||
|
|
entry.module,
|
|||
|
|
entry.action,
|
|||
|
|
entry.summary,
|
|||
|
|
exc_info=True,
|
|||
|
|
)
|
|||
|
|
_rollback_quietly(session)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _rollback_quietly(session: Session) -> None:
|
|||
|
|
try:
|
|||
|
|
session.rollback()
|
|||
|
|
except Exception:
|
|||
|
|
logger.warning("会话回滚失败", exc_info=True)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _error_text(exc: Exception) -> str:
|
|||
|
|
"""失败原因文案。
|
|||
|
|
|
|||
|
|
业务异常取其面向用户的消息;未预期异常返回通用文案 —— 完整堆栈由全局
|
|||
|
|
异常处理器记入应用日志,审计表不落内部细节。
|
|||
|
|
"""
|
|||
|
|
if isinstance(exc, AppException):
|
|||
|
|
return exc.message
|
|||
|
|
return "服务端异常,请稍后重试"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _elapsed_ms(started: float) -> int:
|
|||
|
|
"""度量业务处理耗时(不含响应序列化)。"""
|
|||
|
|
return max(0, int((time.perf_counter() - started) * 1000))
|