PayrollManagementSystem/backend/app/services/audit_service.py
liuwenxin c83287819a feat(system): 完成后端系统管理(审计/角色/操作日志/日志查询)
- 操作日志审计机制:audit 上下文(成功与失败都记、独立提交、操作人标量
  固化、commit 后查询前置约定),用户/角色全部写操作接入;request_data
  按键名的结构化脱敏 + UTF-8 字节边界截断
- 角色管理:CRUD 与启停;admin 禁删禁停、有绑定禁删(FOR UPDATE 锁行)、
  编码创建后不可改、停用角色不可再分配
- 日志查询:操作/登录两端点,列表与详情两段式(详情含已脱敏请求参数);
  时间参数统一转本地 naive(修混用时区 500 与偏移丢弃)
- 用户管理:补齐 reset_password 操作人、编辑空串归一 NULL、并发唯一键兜底 400
- 测试 301 例全绿(单元 223 / 集成 78)

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

174 lines
6.2 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""操作日志审计服务。
需求 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))