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>
This commit is contained in:
1 parent
e22c4bcdc9
commit
c83287819a
32 files changed
+3158
-137
No files matched your search
+25
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends
|
||||
from fastapi import Depends, Request
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -13,6 +13,7 @@ from app.core.security import decode_token
|
||||
from app.models import User
|
||||
from app.repositories import user_repository
|
||||
from app.services import token_blacklist
|
||||
from app.services.audit_service import RequestMeta
|
||||
|
||||
# auto_error=False:令牌缺失时由我们自己抛 AuthError,
|
||||
# 才能返回统一响应信封,而不是 FastAPI 默认的 {"detail": "Not authenticated"}
|
||||
@@ -22,6 +23,29 @@ BearerCredentials = Annotated[HTTPAuthorizationCredentials | None, Depends(_bear
|
||||
DbSession = Annotated[Session, Depends(get_db)]
|
||||
|
||||
|
||||
def client_ip(request: Request) -> str | None:
|
||||
"""取客户端 IP。
|
||||
|
||||
当前取直连地址。生产环境经 Nginx 反代后直连地址会变成代理地址,
|
||||
届时需改为读取 `X-Forwarded-For` 的**第一个**值 —— 且必须确保该头
|
||||
由网关覆写,否则客户端可以伪造。此改动留到部署阶段一并处理。
|
||||
"""
|
||||
return request.client.host if request.client else None
|
||||
|
||||
|
||||
def get_request_meta(request: Request) -> RequestMeta:
|
||||
"""提取 HTTP 请求元信息,供操作日志审计使用。"""
|
||||
return RequestMeta(
|
||||
ip=client_ip(request),
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
method=request.method,
|
||||
path=request.url.path,
|
||||
)
|
||||
|
||||
|
||||
RequestMetaDep = Annotated[RequestMeta, Depends(get_request_meta)]
|
||||
|
||||
|
||||
def get_raw_token(credentials: BearerCredentials) -> str:
|
||||
"""取原始令牌字符串。登出时需要它来解析 jti。"""
|
||||
if credentials is None or not credentials.credentials:
|
||||
|
||||
@@ -8,6 +8,8 @@ from fastapi import APIRouter
|
||||
|
||||
from app.api import health
|
||||
from app.api.v1 import auth
|
||||
from app.api.v1.system import logs as system_logs
|
||||
from app.api.v1.system import roles as system_roles
|
||||
from app.api.v1.system import users as system_users
|
||||
|
||||
api_router = APIRouter()
|
||||
@@ -22,6 +24,9 @@ v1_router.include_router(auth.router)
|
||||
# 系统管理(需求 2.5.1):/api/v1/system/*
|
||||
system_router = APIRouter(prefix="/system")
|
||||
system_router.include_router(system_users.router)
|
||||
system_router.include_router(system_roles.router)
|
||||
system_router.include_router(system_logs.operation_router)
|
||||
system_router.include_router(system_logs.login_router)
|
||||
v1_router.include_router(system_router)
|
||||
|
||||
api_router.include_router(v1_router)
|
||||
@@ -4,7 +4,7 @@ from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
|
||||
from app.api.deps import DbSession, get_current_user, get_raw_token
|
||||
from app.api.deps import DbSession, client_ip, get_current_user, get_raw_token
|
||||
from app.core.response import ApiResponse, ok
|
||||
from app.models import User
|
||||
from app.repositories.user_repository import get_role_codes
|
||||
@@ -14,16 +14,6 @@ from app.services import auth_service, captcha_service
|
||||
router = APIRouter(prefix="/auth", tags=["认证"])
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str | None:
|
||||
"""取客户端 IP。
|
||||
|
||||
当前取直连地址。生产环境经 Nginx 反代后直连地址会变成代理地址,
|
||||
届时需改为读取 `X-Forwarded-For` 的**第一个**值 —— 且必须确保该头
|
||||
由网关覆写,否则客户端可以伪造。此改动留到部署阶段一并处理。
|
||||
"""
|
||||
return request.client.host if request.client else None
|
||||
|
||||
|
||||
@router.get("/captcha", response_model=ApiResponse[CaptchaOut], summary="获取图形验证码")
|
||||
def get_captcha() -> ApiResponse[CaptchaOut]:
|
||||
"""签发图形验证码。无需认证。
|
||||
@@ -52,7 +42,7 @@ def login(payload: LoginIn, request: Request, session: DbSession) -> ApiResponse
|
||||
password=payload.password,
|
||||
captcha_id=payload.captcha_id,
|
||||
captcha_code=payload.captcha_code,
|
||||
ip=_client_ip(request),
|
||||
ip=client_ip(request),
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
return ok(result, message="登录成功")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""系统管理路由(需求 2.5.1)。
|
||||
|
||||
含用户管理、角色查询、操作日志查询三部分,全部要求管理员权限。
|
||||
含用户管理、角色管理、日志查询(操作日志 + 登录日志)四部分,全部要求管理员权限。
|
||||
|
||||
与 `api/v1/auth.py` 的分工:`auth` 是登录链路(未认证即可访问的验证码与登录,
|
||||
以及任何登录用户都能访问的 `/me` 与登出);本包是**管理功能**。
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""日志查询接口(需求 2.5.1:支持日志查询)。
|
||||
|
||||
操作日志与登录日志是两个独立端点:两表字段与筛选项不同(前者含模块/操作/
|
||||
请求详情,后者含失败原因),合并会让 `action` 等参数产生二义;前端正好是
|
||||
两个 tab。操作日志另有单条详情(列表为性能排除 `request_data`)。
|
||||
|
||||
均要求管理员权限;只读查询不写审计(读操作记日志只会淹没审计表)。
|
||||
"""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Path, Query
|
||||
|
||||
from app.api.deps import AdminUser, DbSession
|
||||
from app.core.response import ApiResponse, PageResult, ok
|
||||
from app.schemas.log import (
|
||||
LoginLogOut,
|
||||
LoginLogQuery,
|
||||
OperationLogDetailOut,
|
||||
OperationLogOut,
|
||||
OperationLogQuery,
|
||||
)
|
||||
from app.services import log_service
|
||||
|
||||
operation_router = APIRouter(prefix="/operation-logs", tags=["系统管理 · 日志"])
|
||||
login_router = APIRouter(prefix="/login-logs", tags=["系统管理 · 日志"])
|
||||
|
||||
|
||||
@operation_router.get(
|
||||
"", response_model=ApiResponse[PageResult[OperationLogOut]], summary="操作日志"
|
||||
)
|
||||
def list_operation_logs(
|
||||
session: DbSession,
|
||||
_: AdminUser,
|
||||
query: Annotated[OperationLogQuery, Query()],
|
||||
) -> ApiResponse[PageResult[OperationLogOut]]:
|
||||
"""分页查询操作日志,支持按操作人/模块/操作类型/结果/时间范围筛选。
|
||||
|
||||
按 id 倒序(时间倒序的稳定实现)。列表**不含** `request_data`(大字段),
|
||||
展开详情请调 `GET /operation-logs/{log_id}`。
|
||||
"""
|
||||
return ok(log_service.list_operation_logs(session, query))
|
||||
|
||||
|
||||
@operation_router.get(
|
||||
"/{log_id}", response_model=ApiResponse[OperationLogDetailOut], summary="操作日志详情"
|
||||
)
|
||||
def get_operation_log(
|
||||
session: DbSession,
|
||||
_: AdminUser,
|
||||
log_id: Annotated[int, Path(ge=1)],
|
||||
) -> ApiResponse[OperationLogDetailOut]:
|
||||
"""查询单条操作日志详情(含已脱敏的请求参数)。
|
||||
|
||||
前端展开日志行时按 id 按需加载 —— 避免列表翻页携带大字段。
|
||||
"""
|
||||
return ok(log_service.get_operation_log(session, log_id))
|
||||
|
||||
|
||||
@login_router.get("", response_model=ApiResponse[PageResult[LoginLogOut]], summary="登录日志")
|
||||
def list_login_logs(
|
||||
session: DbSession,
|
||||
_: AdminUser,
|
||||
query: Annotated[LoginLogQuery, Query()],
|
||||
) -> ApiResponse[PageResult[LoginLogOut]]:
|
||||
"""分页查询登录日志,支持按用户名/IP/结果/时间范围筛选。"""
|
||||
return ok(log_service.list_login_logs(session, query))
|
||||
@@ -0,0 +1,100 @@
|
||||
"""角色管理接口(自主完善模块:需求 2.5.1 仅定义 admin / user 两个角色)。
|
||||
|
||||
保护规则(admin 禁删禁停、有绑定的角色禁删、role_code 不可改)见
|
||||
`role_service` 模块 docstring。前端会禁用相应按钮,但**后端校验是唯一防线**。
|
||||
|
||||
全部要求管理员权限;写操作经 `RequestMetaDep` 带入请求元信息由 service
|
||||
层写入操作日志。
|
||||
"""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Path, Query
|
||||
|
||||
from app.api.deps import AdminUser, DbSession, RequestMetaDep
|
||||
from app.core.response import ApiResponse, PageResult, ok
|
||||
from app.schemas.role import RoleCreateIn, RoleManageOut, RoleQuery, RoleStatusIn, RoleUpdateIn
|
||||
from app.services import role_service
|
||||
|
||||
router = APIRouter(prefix="/roles", tags=["系统管理 · 角色"])
|
||||
|
||||
|
||||
@router.get("", response_model=ApiResponse[PageResult[RoleManageOut]], summary="角色列表")
|
||||
def list_roles(
|
||||
session: DbSession,
|
||||
_: AdminUser,
|
||||
query: Annotated[RoleQuery, Query()],
|
||||
) -> ApiResponse[PageResult[RoleManageOut]]:
|
||||
"""分页查询角色。
|
||||
|
||||
与用户表单的角色下拉口径不同:**本列表含停用角色**(下拉只给启用项)。
|
||||
每行附带 `user_count`(绑定用户数),供前端提示删除保护。
|
||||
"""
|
||||
return ok(role_service.list_roles(session, query))
|
||||
|
||||
|
||||
@router.post("", response_model=ApiResponse[RoleManageOut], summary="新增角色")
|
||||
def create_role(
|
||||
session: DbSession,
|
||||
current_user: AdminUser,
|
||||
payload: RoleCreateIn,
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[RoleManageOut]:
|
||||
"""新增角色。编码全局唯一、创建后不可修改;新角色默认启用。"""
|
||||
return ok(
|
||||
role_service.create_role(session, payload, operator=current_user, meta=meta),
|
||||
message="角色已创建",
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{role_id}", response_model=ApiResponse[RoleManageOut], summary="编辑角色")
|
||||
def update_role(
|
||||
session: DbSession,
|
||||
current_user: AdminUser,
|
||||
role_id: Annotated[int, Path(ge=1)],
|
||||
payload: RoleUpdateIn,
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[RoleManageOut]:
|
||||
"""编辑角色名称/说明/排序。
|
||||
|
||||
**不含 role_code** —— 编码是授权判定(`ROLE_ADMIN` 常量)的锚点。
|
||||
"""
|
||||
return ok(
|
||||
role_service.update_role(session, role_id, payload, operator=current_user, meta=meta),
|
||||
message="已保存",
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{role_id}/status", response_model=ApiResponse[RoleManageOut], summary="启用/禁用角色")
|
||||
def set_status(
|
||||
session: DbSession,
|
||||
current_user: AdminUser,
|
||||
role_id: Annotated[int, Path(ge=1)],
|
||||
payload: RoleStatusIn,
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[RoleManageOut]:
|
||||
"""启用或禁用角色。
|
||||
|
||||
停用**不解除**用户绑定、也不影响既有授权(授权判定只看编码、不看状态);
|
||||
内置管理员角色不可停用。
|
||||
"""
|
||||
result = role_service.set_status(
|
||||
session, role_id, payload.status, operator=current_user, meta=meta
|
||||
)
|
||||
return ok(result, message="已启用" if payload.status == 1 else "已禁用")
|
||||
|
||||
|
||||
@router.delete("/{role_id}", response_model=ApiResponse[None], summary="删除角色")
|
||||
def delete_role(
|
||||
session: DbSession,
|
||||
current_user: AdminUser,
|
||||
role_id: Annotated[int, Path(ge=1)],
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[None]:
|
||||
"""删除角色。
|
||||
|
||||
内置管理员角色不可删除;仍有关联用户的角色不可删除(提示先解除绑定)——
|
||||
`user_roles` 外键是 CASCADE,后端拦截是防止静默剥离权限的唯一防线。
|
||||
"""
|
||||
role_service.delete_role(session, role_id, operator=current_user, meta=meta)
|
||||
return ok(message="角色已删除")
|
||||
@@ -5,17 +5,20 @@
|
||||
|
||||
删除、禁用、改角色三处都有**自我保护**:不允许对当前登录账号执行,
|
||||
否则管理员误操作会立刻失去全部管理权限且无法自行恢复。
|
||||
|
||||
全部写操作经 `RequestMetaDep` 带入请求元信息后由 service 层写入操作日志
|
||||
(成功与失败都记),操作人为当前登录管理员。
|
||||
"""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Path, Query
|
||||
|
||||
from app.api.deps import AdminUser, DbSession
|
||||
from app.api.deps import AdminUser, DbSession, RequestMetaDep
|
||||
from app.core.response import ApiResponse, PageResult, ok
|
||||
from app.schemas.role import RoleOut
|
||||
from app.schemas.user import (
|
||||
PasswordResetIn,
|
||||
RoleOut,
|
||||
UserCreateIn,
|
||||
UserOut,
|
||||
UserQuery,
|
||||
@@ -50,11 +53,15 @@ def get_user(
|
||||
@router.post("", response_model=ApiResponse[UserOut], summary="新增用户")
|
||||
def create_user(
|
||||
session: DbSession,
|
||||
_: AdminUser,
|
||||
current_user: AdminUser,
|
||||
payload: UserCreateIn,
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[UserOut]:
|
||||
"""新增用户。用户名须唯一,至少指定一个角色。"""
|
||||
return ok(user_service.create_user(session, payload), message="用户已创建")
|
||||
return ok(
|
||||
user_service.create_user(session, payload, operator=current_user, meta=meta),
|
||||
message="用户已创建",
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{user_id}", response_model=ApiResponse[UserOut], summary="编辑用户")
|
||||
@@ -63,13 +70,14 @@ def update_user(
|
||||
current_user: AdminUser,
|
||||
user_id: Annotated[int, Path(ge=1)],
|
||||
payload: UserUpdateIn,
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[UserOut]:
|
||||
"""编辑用户的基本信息与角色。
|
||||
|
||||
**不含用户名与密码**:用户名是审计追溯的锚点;密码走专门的重置接口。
|
||||
"""
|
||||
return ok(
|
||||
user_service.update_user(session, user_id, payload, operator_id=current_user.id),
|
||||
user_service.update_user(session, user_id, payload, operator=current_user, meta=meta),
|
||||
message="已保存",
|
||||
)
|
||||
|
||||
@@ -79,29 +87,33 @@ def delete_user(
|
||||
session: DbSession,
|
||||
current_user: AdminUser,
|
||||
user_id: Annotated[int, Path(ge=1)],
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[None]:
|
||||
"""删除用户。
|
||||
|
||||
物理删除。历史操作日志通过 `user_id` 置空 + 冗余用户名保留可追溯性。
|
||||
业务上更推荐用「禁用」代替删除 —— 禁用保留完整审计线索。
|
||||
"""
|
||||
user_service.delete_user(session, user_id, operator_id=current_user.id)
|
||||
user_service.delete_user(session, user_id, operator=current_user, meta=meta)
|
||||
return ok(message="用户已删除")
|
||||
|
||||
|
||||
@router.put("/{user_id}/password", response_model=ApiResponse[None], summary="重置密码")
|
||||
def reset_password(
|
||||
session: DbSession,
|
||||
_: AdminUser,
|
||||
current_user: AdminUser,
|
||||
user_id: Annotated[int, Path(ge=1)],
|
||||
payload: PasswordResetIn,
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[None]:
|
||||
"""重置用户密码。
|
||||
|
||||
会同时提升该用户的令牌版本,使其**已签发的令牌立即失效** ——
|
||||
否则改密后旧令牌在有效期内仍可用。
|
||||
"""
|
||||
user_service.reset_password(session, user_id, payload.new_password)
|
||||
user_service.reset_password(
|
||||
session, user_id, payload.new_password, operator=current_user, meta=meta
|
||||
)
|
||||
return ok(message="密码已重置,该用户需重新登录")
|
||||
|
||||
|
||||
@@ -111,13 +123,16 @@ def set_status(
|
||||
current_user: AdminUser,
|
||||
user_id: Annotated[int, Path(ge=1)],
|
||||
payload: UserStatusIn,
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[UserOut]:
|
||||
"""启用或禁用用户。
|
||||
|
||||
禁用会提升令牌版本使已签发令牌立即失效,否则被禁用者仍可凭旧令牌
|
||||
继续访问直到过期(最长 2 小时)。
|
||||
"""
|
||||
result = user_service.set_status(session, user_id, payload.status, operator_id=current_user.id)
|
||||
result = user_service.set_status(
|
||||
session, user_id, payload.status, operator=current_user, meta=meta
|
||||
)
|
||||
return ok(result, message="已启用" if payload.status == 1 else "已禁用")
|
||||
|
||||
|
||||
|
||||
@@ -36,3 +36,15 @@ PASSWORD_MAX_BYTES = 72
|
||||
# 剔除易混淆字符 0/O/1/I/l,降低人工识别错误率
|
||||
CAPTCHA_CHARSET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
|
||||
CAPTCHA_LENGTH = 4
|
||||
|
||||
# ===== 操作日志词表(operation_logs 的 module / action 取值)=====
|
||||
# 集中定义避免各处硬编码导致日志值漂移;前端筛选下拉项与此保持一致
|
||||
MODULE_USER = "用户管理"
|
||||
MODULE_ROLE = "角色管理"
|
||||
|
||||
ACTION_CREATE = "新增"
|
||||
ACTION_UPDATE = "修改"
|
||||
ACTION_DELETE = "删除"
|
||||
ACTION_RESET_PASSWORD = "重置密码"
|
||||
ACTION_ENABLE = "启用"
|
||||
ACTION_DISABLE = "禁用"
|
||||
@@ -21,6 +21,7 @@ USERNAME_MAX_LEN = 64
|
||||
MODULE_MAX_LEN = 64
|
||||
ACTION_MAX_LEN = 32
|
||||
SUMMARY_MAX_LEN = 255
|
||||
METHOD_MAX_LEN = 8
|
||||
PATH_MAX_LEN = 255
|
||||
IP_MAX_LEN = 45
|
||||
USER_AGENT_MAX_LEN = 255
|
||||
@@ -73,7 +74,7 @@ class OperationLog(Base):
|
||||
String(SUMMARY_MAX_LEN), nullable=False, comment="操作摘要(中文,供人阅读)"
|
||||
)
|
||||
|
||||
method: Mapped[str | None] = mapped_column(String(8), comment="HTTP 方法")
|
||||
method: Mapped[str | None] = mapped_column(String(METHOD_MAX_LEN), comment="HTTP 方法")
|
||||
path: Mapped[str | None] = mapped_column(String(PATH_MAX_LEN), comment="请求路径")
|
||||
ip: Mapped[str | None] = mapped_column(String(IP_MAX_LEN), comment="客户端IP(45位兼容IPv6)")
|
||||
user_agent: Mapped[str | None] = mapped_column(
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"""登录日志数据访问。"""
|
||||
|
||||
from sqlalchemy import Select, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import LoginLog
|
||||
from app.repositories.query_utils import LogFilters, apply_log_filters
|
||||
|
||||
# user_agent 列宽,超长需截断否则插入报错
|
||||
_USER_AGENT_MAX_LEN = 255
|
||||
@@ -36,3 +38,40 @@ def add(
|
||||
)
|
||||
)
|
||||
session.flush()
|
||||
|
||||
|
||||
def list_logs(
|
||||
session: Session,
|
||||
*,
|
||||
offset: int,
|
||||
limit: int,
|
||||
filters: LogFilters,
|
||||
ip: str | None = None,
|
||||
) -> list[LoginLog]:
|
||||
"""分页查询登录日志。
|
||||
|
||||
**按 id 倒序而非 create_time**:后者是秒级精度,同一秒内的多条记录排序
|
||||
不确定,翻页会重复或遗漏(与用户列表同一理由)。
|
||||
"""
|
||||
stmt = _apply_filters(select(LoginLog), filters=filters, ip=ip)
|
||||
stmt = stmt.order_by(LoginLog.id.desc()).offset(offset).limit(limit)
|
||||
return list(session.execute(stmt).scalars())
|
||||
|
||||
|
||||
def count_logs(
|
||||
session: Session,
|
||||
*,
|
||||
filters: LogFilters,
|
||||
ip: str | None = None,
|
||||
) -> int:
|
||||
"""统计符合条件的登录日志总数,供分页使用。"""
|
||||
stmt = _apply_filters(select(func.count(LoginLog.id)), filters=filters, ip=ip)
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
def _apply_filters(stmt: Select, *, filters: LogFilters, ip: str | None) -> Select:
|
||||
"""应用筛选条件(公共部分复用 query_utils,与操作日志同口径)。"""
|
||||
stmt = apply_log_filters(stmt, LoginLog, filters)
|
||||
if ip:
|
||||
stmt = stmt.where(LoginLog.ip == ip)
|
||||
return stmt
|
||||
@@ -0,0 +1,200 @@
|
||||
"""操作日志数据访问。
|
||||
|
||||
本层只 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 结构。
|
||||
_SENSITIVE_KEY_PARTS = ("password", "passwd", "pwd", "secret", "token", "app_key", "access_key")
|
||||
|
||||
_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")
|
||||
@@ -0,0 +1,54 @@
|
||||
"""repositories 层公共查询工具。
|
||||
|
||||
集中两类易错点,避免各 repository 各写一份、口径漂移:
|
||||
|
||||
- **模糊匹配的 LIKE 通配符转义**:直接 ``f"%{value}%"`` 拼接时,输入中的
|
||||
下划线会作为单字符通配执行(本系统用户名普遍含下划线,误配是常态而非
|
||||
理论),百分号则命中全表并触发全表扫描
|
||||
- **两类日志共有的筛选条件**:用户名/结果/时间范围在操作日志与登录日志
|
||||
之间逐字重复,抽为同一实现后「列表与计数」「操作与登录」不可能对不上
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Select
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LogFilters:
|
||||
"""两类日志(操作/登录)共有的筛选条件。"""
|
||||
|
||||
username: str | None = None
|
||||
success: int | None = None
|
||||
start_time: datetime | None = None
|
||||
end_time: datetime | None = None
|
||||
|
||||
|
||||
def fuzzy(stmt: Select, column: Any, value: str) -> Select:
|
||||
"""模糊匹配(含通配符自动转义)。
|
||||
|
||||
``contains(autoescape=True)`` 会转义输入中的 ``%``/``_`` 并附加 ``ESCAPE``
|
||||
子句,把用户输入当字面量匹配。
|
||||
"""
|
||||
return stmt.where(column.contains(value, autoescape=True))
|
||||
|
||||
|
||||
def apply_log_filters(stmt: Select, model: Any, filters: LogFilters) -> Select:
|
||||
"""应用两类日志共有的筛选条件。
|
||||
|
||||
``model`` 传 ``OperationLog`` / ``LoginLog`` 的模型类(两者字段同名同型)。
|
||||
|
||||
列表与计数共用本函数,避免「列表按条件过滤而总数不按条件过滤」——
|
||||
漏改一侧时表现为翻到最后一页是空页或页码虚增,且没有任何报错。
|
||||
"""
|
||||
if filters.username:
|
||||
stmt = fuzzy(stmt, model.username, filters.username)
|
||||
if filters.success is not None:
|
||||
stmt = stmt.where(model.success == filters.success)
|
||||
if filters.start_time:
|
||||
stmt = stmt.where(model.create_time >= filters.start_time)
|
||||
if filters.end_time:
|
||||
stmt = stmt.where(model.create_time <= filters.end_time)
|
||||
return stmt
|
||||
@@ -0,0 +1,123 @@
|
||||
"""角色数据访问。
|
||||
|
||||
本层只 flush 不 commit —— 事务边界由 services 层控制。
|
||||
|
||||
`get_all_roles` 与 `get_roles_by_codes` 原位于 `user_repository`,
|
||||
因角色管理成为独立模块而迁入本文件。
|
||||
"""
|
||||
|
||||
from sqlalchemy import Select, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Role, UserRole
|
||||
|
||||
# ==================== 读取 ====================
|
||||
|
||||
|
||||
def get_by_id(session: Session, role_id: int, *, for_update: bool = False) -> Role | None:
|
||||
"""按主键查询角色。不存在返回 None。
|
||||
|
||||
``for_update=True`` 走 ``SELECT ... FOR UPDATE`` 锁定角色行 —— 删除前的
|
||||
「有用户绑定禁删」校验必须用它:校验与 DELETE 之间若发生并发绑定,
|
||||
`user_roles` 的 CASCADE 会把新绑定一并静默剥离。锁行后,并发绑定的
|
||||
外键检查会等待,删除得以基于最新状态做出(对方失败而非数据被吞)。
|
||||
"""
|
||||
if for_update:
|
||||
stmt = select(Role).where(Role.id == role_id).with_for_update()
|
||||
return session.execute(stmt).scalar_one_or_none()
|
||||
return session.get(Role, role_id)
|
||||
|
||||
|
||||
def get_by_code(session: Session, role_code: str) -> Role | None:
|
||||
"""按编码查询角色。不存在返回 None。"""
|
||||
return session.execute(select(Role).where(Role.role_code == role_code)).scalar_one_or_none()
|
||||
|
||||
|
||||
def get_roles_by_codes(session: Session, role_codes: list[str]) -> list[Role]:
|
||||
"""按编码批量取角色。用于校验前端传来的角色是否存在。"""
|
||||
if not role_codes:
|
||||
return []
|
||||
stmt = select(Role).where(Role.role_code.in_(role_codes))
|
||||
return list(session.execute(stmt).scalars())
|
||||
|
||||
|
||||
def get_all_roles(session: Session) -> list[Role]:
|
||||
"""取全部启用角色,供前端下拉选择。"""
|
||||
stmt = select(Role).where(Role.status == 1).order_by(Role.sort_order, Role.id)
|
||||
return list(session.execute(stmt).scalars())
|
||||
|
||||
|
||||
def list_roles(
|
||||
session: Session,
|
||||
*,
|
||||
offset: int,
|
||||
limit: int,
|
||||
keyword: str | None = None,
|
||||
status: int | None = None,
|
||||
) -> list[Role]:
|
||||
"""分页查询角色(管理列表:含停用角色)。
|
||||
|
||||
排序与下拉口径一致(sort_order, id),保持同一套相对次序。
|
||||
"""
|
||||
stmt = _apply_filters(select(Role), keyword=keyword, status=status)
|
||||
stmt = stmt.order_by(Role.sort_order, Role.id).offset(offset).limit(limit)
|
||||
return list(session.execute(stmt).scalars())
|
||||
|
||||
|
||||
def count_roles(session: Session, *, keyword: str | None = None, status: int | None = None) -> int:
|
||||
"""统计符合条件的角色总数,供分页使用。"""
|
||||
stmt = _apply_filters(select(func.count(Role.id)), keyword=keyword, status=status)
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
def _apply_filters(stmt: Select, *, keyword: str | None, status: int | None) -> Select:
|
||||
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。
|
||||
|
||||
关键词匹配走 `contains(autoescape=True)`(转义 LIKE 通配符)——
|
||||
角色编码普遍含下划线,直接拼接会让 `_` 当单字符通配执行。
|
||||
"""
|
||||
if keyword:
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
Role.role_code.contains(keyword, autoescape=True),
|
||||
Role.role_name.contains(keyword, autoescape=True),
|
||||
)
|
||||
)
|
||||
if status is not None:
|
||||
stmt = stmt.where(Role.status == status)
|
||||
return stmt
|
||||
|
||||
|
||||
def count_users_by_role(session: Session, role_ids: list[int]) -> dict[int, int]:
|
||||
"""批量统计各角色关联的用户数。
|
||||
|
||||
一次 GROUP BY 取回全部计数,而非在列表循环里逐行查询(N+1)。
|
||||
"""
|
||||
if not role_ids:
|
||||
return {}
|
||||
stmt = (
|
||||
select(UserRole.role_id, func.count())
|
||||
.where(UserRole.role_id.in_(role_ids))
|
||||
.group_by(UserRole.role_id)
|
||||
)
|
||||
return {int(role_id): int(count) for role_id, count in session.execute(stmt)}
|
||||
|
||||
|
||||
# ==================== 写入 ====================
|
||||
|
||||
|
||||
def create(session: Session, role: Role) -> Role:
|
||||
"""新增角色。只 flush 以取得自增 id。"""
|
||||
session.add(role)
|
||||
session.flush()
|
||||
return role
|
||||
|
||||
|
||||
def delete(session: Session, role: Role) -> None:
|
||||
"""删除角色。
|
||||
|
||||
调用方(service)必须先校验该角色无用户绑定 —— `user_roles` 外键是
|
||||
CASCADE,直接删会静默剥离绑定用户的权限。
|
||||
"""
|
||||
session.delete(role)
|
||||
session.flush()
|
||||
@@ -1,4 +1,4 @@
|
||||
"""用户与角色数据访问。
|
||||
"""用户数据访问(角色相关的读取与维护见 `role_repository`)。
|
||||
|
||||
本层只 flush 不 commit —— 事务边界由 services 层控制。
|
||||
"""
|
||||
@@ -9,6 +9,7 @@ from sqlalchemy import Select, func, select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.models import Role, User, UserRole
|
||||
from app.repositories.query_utils import fuzzy
|
||||
|
||||
|
||||
def _base_query() -> Select[tuple[User]]:
|
||||
@@ -95,11 +96,15 @@ def _apply_filters(
|
||||
real_name: str | None,
|
||||
status: int | None,
|
||||
) -> Select:
|
||||
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。"""
|
||||
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。
|
||||
|
||||
模糊匹配统一走 `query_utils.fuzzy`(转义 LIKE 通配符)—— 用户名普遍
|
||||
含下划线,直接拼接会让 `_` 当单字符通配执行、误配无关账号。
|
||||
"""
|
||||
if username:
|
||||
stmt = stmt.where(User.username.like(f"%{username}%"))
|
||||
stmt = fuzzy(stmt, User.username, username)
|
||||
if real_name:
|
||||
stmt = stmt.where(User.real_name.like(f"%{real_name}%"))
|
||||
stmt = fuzzy(stmt, User.real_name, real_name)
|
||||
if status is not None:
|
||||
stmt = stmt.where(User.status == status)
|
||||
return stmt
|
||||
@@ -127,15 +132,7 @@ def delete(session: Session, user: User) -> None:
|
||||
session.flush()
|
||||
|
||||
|
||||
# ==================== 角色 ====================
|
||||
|
||||
|
||||
def get_roles_by_codes(session: Session, role_codes: list[str]) -> list[Role]:
|
||||
"""按编码批量取角色。用于校验前端传来的角色是否存在。"""
|
||||
if not role_codes:
|
||||
return []
|
||||
stmt = select(Role).where(Role.role_code.in_(role_codes))
|
||||
return list(session.execute(stmt).scalars())
|
||||
# ==================== 角色关联 ====================
|
||||
|
||||
|
||||
def replace_roles(session: Session, user: User, roles: list[Role]) -> None:
|
||||
@@ -144,18 +141,18 @@ def replace_roles(session: Session, user: User, roles: list[Role]) -> None:
|
||||
采用「先清后建」而非逐条比对:角色数量极少(本期仅两个),
|
||||
差异计算的复杂度换不来收益,且整体替换的语义更直白。
|
||||
|
||||
**clear 之后必须立刻 flush 一次**:SQLAlchemy 在同一事务的 flush 中
|
||||
**先处理 INSERT 再处理 DELETE**,若中间不落库,重插与旧集合有交集的
|
||||
(user_id, role_id)(编辑用户时保留既有角色就会发生)会撞
|
||||
`uk_user_roles_user_id_role_id` 唯一键,报 1062。
|
||||
|
||||
**必须经 `user.user_roles` 集合追加,不能用 `session.add(UserRole(...))`**:
|
||||
后者只把对象放进 session,不会同步到关系的已加载集合,于是同一事务内
|
||||
再读 `user.user_roles` 仍是空的 —— 表现为「保存成功,但接口返回的角色
|
||||
列表是 []」。经集合操作时 `user_id` 由关系自动填充,集合也立即反映新状态。
|
||||
"""
|
||||
user.user_roles.clear()
|
||||
session.flush()
|
||||
for role in roles:
|
||||
user.user_roles.append(UserRole(role_id=role.id))
|
||||
session.flush()
|
||||
|
||||
|
||||
def get_all_roles(session: Session) -> list[Role]:
|
||||
"""取全部启用角色,供前端下拉选择。"""
|
||||
stmt = select(Role).where(Role.status == 1).order_by(Role.sort_order, Role.id)
|
||||
return list(session.execute(stmt).scalars())
|
||||
@@ -0,0 +1,105 @@
|
||||
"""日志查询的请求/响应模型。
|
||||
|
||||
操作日志与登录日志字段不同(前者含模块/操作/请求详情,后者含失败原因),
|
||||
故各自定义 Query 与 Out,仅共用「用户名 + 结果 + 时间范围」的筛选基类。
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
from app.core.response import PageQuery
|
||||
|
||||
|
||||
class LogQueryBase(PageQuery):
|
||||
"""日志公共查询条件:模糊用户名 + 结果 + 时间范围。"""
|
||||
|
||||
username: str | None = Field(default=None, max_length=64, description="用户名,模糊匹配")
|
||||
success: int | None = Field(default=None, ge=0, le=1, description="1成功 0失败")
|
||||
start_time: datetime | None = Field(default=None, description="起始时间(含)")
|
||||
end_time: datetime | None = Field(default=None, description="结束时间(含)")
|
||||
|
||||
@field_validator("start_time", "end_time")
|
||||
@classmethod
|
||||
def _to_local_naive(cls, value: datetime | None) -> datetime | None:
|
||||
"""统一为本地时区的 naive 时间。
|
||||
|
||||
库列存本地时间(容器 TZ=Asia/Shanghai),而 API 接受 ISO 8601
|
||||
(可带 ``Z`` 等时区后缀)。不归一时有两种故障:aware 与 naive
|
||||
混用比较抛 TypeError(穿透成 500);aware 直接透传给驱动会
|
||||
**静默丢弃偏移**(查询窗口整体偏 8 小时且无任何提示)。
|
||||
aware 先转系统本地时区、再摘除 tzinfo 是唯一正确姿势;
|
||||
naive 视为本地时间原样保留。
|
||||
"""
|
||||
if value is not None and value.tzinfo is not None:
|
||||
return value.astimezone().replace(tzinfo=None)
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_time_range(self) -> "LogQueryBase":
|
||||
if self.start_time and self.end_time and self.start_time > self.end_time:
|
||||
raise ValueError("起始时间不能晚于结束时间")
|
||||
return self
|
||||
|
||||
|
||||
class OperationLogQuery(LogQueryBase):
|
||||
"""操作日志查询条件。"""
|
||||
|
||||
module: str | None = Field(default=None, max_length=64, description="所属模块,精确匹配")
|
||||
action: str | None = Field(default=None, max_length=32, description="操作类型,精确匹配")
|
||||
|
||||
|
||||
class OperationLogOut(BaseModel):
|
||||
"""操作日志列表项。
|
||||
|
||||
**不含 request_data**(TEXT,单行最大 60000 字节):列表页默认不展示它,
|
||||
随列表返回会让翻页负担随日志表增长线性放大;展开详情走
|
||||
`GET /operation-logs/{id}`(`OperationLogDetailOut`),前端按需加载。
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
user_id: int | None = None
|
||||
username: str
|
||||
module: str
|
||||
action: str
|
||||
summary: str
|
||||
method: str | None = None
|
||||
path: str | None = None
|
||||
ip: str | None = None
|
||||
user_agent: str | None = None
|
||||
success: int = 0
|
||||
error_message: str | None = None
|
||||
duration_ms: int | None = None
|
||||
create_time: datetime | None = None
|
||||
|
||||
|
||||
class OperationLogDetailOut(OperationLogOut):
|
||||
"""操作日志详情:列表字段 + 请求参数。
|
||||
|
||||
`request_data` 入库时已经结构化 + 文本两层脱敏,可直接展示。
|
||||
"""
|
||||
|
||||
request_data: str | None = None
|
||||
|
||||
|
||||
class LoginLogQuery(LogQueryBase):
|
||||
"""登录日志查询条件。"""
|
||||
|
||||
ip: str | None = Field(default=None, max_length=45, description="客户端IP,精确匹配")
|
||||
|
||||
|
||||
class LoginLogOut(BaseModel):
|
||||
"""登录日志响应。"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
user_id: int | None = None
|
||||
username: str
|
||||
ip: str | None = None
|
||||
user_agent: str | None = None
|
||||
success: int = 0
|
||||
fail_reason: str | None = None
|
||||
create_time: datetime | None = None
|
||||
@@ -0,0 +1,80 @@
|
||||
"""角色管理的请求/响应模型。
|
||||
|
||||
`RoleOut` 原位于 `schemas/user.py`,因角色管理成为独立模块而迁入本文件 ——
|
||||
用户表单的角色下拉与角色管理列表共用同一响应模型。
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from app.core.response import PageQuery
|
||||
|
||||
# 角色编码规则:小写字母开头,后接小写字母/数字/下划线,长度 2–32(与列宽一致)。
|
||||
# 不允许大写与中文 —— 编码会出现在授权判断(`ROLE_ADMIN` 常量按编码比对)与
|
||||
# 操作日志里,保持单一风格避免大小写/全角混用造成对照困难
|
||||
ROLE_CODE_PATTERN = re.compile(r"^[a-z][a-z0-9_]{1,31}$")
|
||||
|
||||
|
||||
class RoleOut(BaseModel):
|
||||
"""角色基础响应(用户表单下拉与角色列表共用)。"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
role_code: str
|
||||
role_name: str
|
||||
description: str | None = None
|
||||
sort_order: int = 0
|
||||
status: int = 1
|
||||
|
||||
|
||||
class RoleManageOut(RoleOut):
|
||||
"""角色管理列表项:额外携带绑定用户数,供前端提示删除保护的原因。"""
|
||||
|
||||
user_count: int = 0
|
||||
|
||||
|
||||
class RoleCreateIn(BaseModel):
|
||||
"""新增角色。"""
|
||||
|
||||
model_config = ConfigDict(str_strip_whitespace=True)
|
||||
|
||||
role_code: str = Field(min_length=2, max_length=32, description="角色编码,创建后不可修改")
|
||||
role_name: str = Field(min_length=1, max_length=64, description="角色名称")
|
||||
description: str | None = Field(default=None, max_length=255)
|
||||
sort_order: int = Field(default=0, ge=0, description="显示排序,越小越靠前")
|
||||
|
||||
@field_validator("role_code")
|
||||
@classmethod
|
||||
def _check_code(cls, value: str) -> str:
|
||||
if not ROLE_CODE_PATTERN.match(value):
|
||||
raise ValueError("角色编码须以小写字母开头,仅可含小写字母、数字与下划线")
|
||||
return value
|
||||
|
||||
|
||||
class RoleUpdateIn(BaseModel):
|
||||
"""编辑角色。
|
||||
|
||||
**不含 role_code** —— 编码是授权判定的锚点,修改会让既有授权语义静默改变。
|
||||
前端表单全量提交,故为全量替换语义。
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(str_strip_whitespace=True)
|
||||
|
||||
role_name: str = Field(min_length=1, max_length=64, description="角色名称")
|
||||
description: str | None = Field(default=None, max_length=255)
|
||||
sort_order: int = Field(default=0, ge=0, description="显示排序,越小越靠前")
|
||||
|
||||
|
||||
class RoleStatusIn(BaseModel):
|
||||
"""启用/禁用角色。"""
|
||||
|
||||
status: int = Field(ge=0, le=1, description="1启用 0禁用")
|
||||
|
||||
|
||||
class RoleQuery(PageQuery):
|
||||
"""角色列表查询条件。"""
|
||||
|
||||
keyword: str | None = Field(default=None, max_length=64, description="编码或名称,模糊匹配")
|
||||
status: int | None = Field(default=None, ge=0, le=1, description="状态筛选")
|
||||
@@ -1,4 +1,4 @@
|
||||
"""用户与角色的请求/响应模型。
|
||||
"""用户管理的请求/响应模型(角色相关模型见 `role.py`)。
|
||||
|
||||
与 `auth.py` 的分工:`auth.py` 管登录链路(验证码、登录、当前用户),
|
||||
本模块管**用户管理**(增删改查、重置密码、启停、角色分配)。
|
||||
@@ -111,16 +111,3 @@ class UserQuery(PageQuery):
|
||||
real_name: str | None = Field(default=None, max_length=64, description="姓名,模糊匹配")
|
||||
status: int | None = Field(default=None, ge=0, le=1, description="状态筛选")
|
||||
role_code: str | None = Field(default=None, max_length=32, description="角色编码筛选")
|
||||
|
||||
|
||||
class RoleOut(BaseModel):
|
||||
"""角色响应。"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
role_code: str
|
||||
role_name: str
|
||||
description: str | None = None
|
||||
sort_order: int = 0
|
||||
status: int = 1
|
||||
@@ -0,0 +1,173 @@
|
||||
"""操作日志审计服务。
|
||||
|
||||
需求 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))
|
||||
@@ -0,0 +1,82 @@
|
||||
"""日志查询编排。
|
||||
|
||||
操作日志与登录日志分别提供分页查询(两表字段与筛选项不同),操作日志另有
|
||||
单条详情(列表为性能排除的 `request_data` 走详情按需加载)。
|
||||
纯只读操作,不写审计 —— 读操作记日志只会淹没审计表。
|
||||
"""
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.exceptions import NotFoundError
|
||||
from app.core.response import PageResult
|
||||
from app.repositories import login_log_repository, operation_log_repository
|
||||
from app.repositories.query_utils import LogFilters
|
||||
from app.schemas.log import (
|
||||
LoginLogOut,
|
||||
LoginLogQuery,
|
||||
OperationLogDetailOut,
|
||||
OperationLogOut,
|
||||
OperationLogQuery,
|
||||
)
|
||||
|
||||
|
||||
def list_operation_logs(session: Session, query: OperationLogQuery) -> PageResult[OperationLogOut]:
|
||||
"""分页查询操作日志(列表不含 request_data,详情走 `get_operation_log`)。"""
|
||||
filters = _filters_from(query)
|
||||
logs = operation_log_repository.list_logs(
|
||||
session,
|
||||
offset=query.offset,
|
||||
limit=query.page_size,
|
||||
filters=filters,
|
||||
module=query.module,
|
||||
action=query.action,
|
||||
)
|
||||
total = operation_log_repository.count_logs(
|
||||
session, filters=filters, module=query.module, action=query.action
|
||||
)
|
||||
return PageResult[OperationLogOut](
|
||||
records=[OperationLogOut.model_validate(log) for log in logs],
|
||||
total=total,
|
||||
page_num=query.page_num,
|
||||
page_size=query.page_size,
|
||||
)
|
||||
|
||||
|
||||
def get_operation_log(session: Session, log_id: int) -> OperationLogDetailOut:
|
||||
"""查询单条操作日志详情(含已脱敏的 request_data)。
|
||||
|
||||
:raises NotFoundError: 日志不存在
|
||||
"""
|
||||
log = operation_log_repository.get_by_id(session, log_id)
|
||||
if log is None:
|
||||
raise NotFoundError("日志不存在")
|
||||
return OperationLogDetailOut.model_validate(log)
|
||||
|
||||
|
||||
def list_login_logs(session: Session, query: LoginLogQuery) -> PageResult[LoginLogOut]:
|
||||
"""分页查询登录日志。"""
|
||||
filters = _filters_from(query)
|
||||
logs = login_log_repository.list_logs(
|
||||
session,
|
||||
offset=query.offset,
|
||||
limit=query.page_size,
|
||||
filters=filters,
|
||||
ip=query.ip,
|
||||
)
|
||||
total = login_log_repository.count_logs(session, filters=filters, ip=query.ip)
|
||||
return PageResult[LoginLogOut](
|
||||
records=[LoginLogOut.model_validate(log) for log in logs],
|
||||
total=total,
|
||||
page_num=query.page_num,
|
||||
page_size=query.page_size,
|
||||
)
|
||||
|
||||
|
||||
def _filters_from(query: OperationLogQuery | LoginLogQuery) -> LogFilters:
|
||||
"""两类 Query 的公共筛选字段 → LogFilters(口径与 repository 共用一个实现)。"""
|
||||
return LogFilters(
|
||||
username=query.username,
|
||||
success=query.success,
|
||||
start_time=query.start_time,
|
||||
end_time=query.end_time,
|
||||
)
|
||||
@@ -0,0 +1,253 @@
|
||||
"""角色管理业务编排。
|
||||
|
||||
需求 2.5.1 只定义 admin / user 两个角色,本模块属用户确认的**自主完善**
|
||||
(轻量版:不含权限点分配 —— `permissions` / `role_permissions` 表继续闲置,
|
||||
待业务方给出权限清单后再启用)。
|
||||
|
||||
保护规则(都是系统自举与授权安全的最低防线,不只是界面体验):
|
||||
|
||||
- `admin` 角色**禁删禁停** —— `cli` 建管理员与 `require_admin` 判定都依赖
|
||||
该编码存在,删除会使系统无法自举
|
||||
- **有用户绑定的角色禁删** —— `user_roles` 外键是 CASCADE,直接删除会静默
|
||||
剥离绑定用户的权限,属安全与审计事故;删除前的校验走 `FOR UPDATE`
|
||||
锁行,堵住「校验与删除之间并发绑定」的窗口
|
||||
- `role_code` 创建后不可改 —— 它是授权判定(`ROLE_ADMIN` 常量按编码比对)的锚点
|
||||
- 停用角色**不解除绑定、不影响既有授权**(授权判定只看编码、不看状态),
|
||||
但**不可再分配给用户**(`user_service._resolve_roles` 拦截)
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.constants import (
|
||||
ACTION_CREATE,
|
||||
ACTION_DELETE,
|
||||
ACTION_DISABLE,
|
||||
ACTION_ENABLE,
|
||||
ACTION_UPDATE,
|
||||
MODULE_ROLE,
|
||||
ROLE_ADMIN,
|
||||
)
|
||||
from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError
|
||||
from app.core.response import PageResult
|
||||
from app.models import Role, User
|
||||
from app.repositories import role_repository
|
||||
from app.schemas.role import RoleCreateIn, RoleManageOut, RoleQuery, RoleUpdateIn
|
||||
from app.services import audit_service
|
||||
from app.services.audit_service import AuditRecord, RequestMeta
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _to_out(role: Role, user_count: int) -> RoleManageOut:
|
||||
"""ORM 对象转响应模型。禁止把 ORM 对象直接当 response_model。"""
|
||||
return RoleManageOut(
|
||||
id=role.id,
|
||||
role_code=role.role_code,
|
||||
role_name=role.role_name,
|
||||
description=role.description,
|
||||
sort_order=role.sort_order,
|
||||
status=role.status,
|
||||
user_count=user_count,
|
||||
)
|
||||
|
||||
|
||||
def _user_count(session: Session, role_id: int) -> int:
|
||||
"""单个角色的绑定用户数(复用批量统计,保持口径一致)。"""
|
||||
return role_repository.count_users_by_role(session, [role_id]).get(role_id, 0)
|
||||
|
||||
|
||||
def list_roles(session: Session, query: RoleQuery) -> PageResult[RoleManageOut]:
|
||||
"""分页查询角色。
|
||||
|
||||
与用户表单下拉(`/users/meta/roles`)口径不同:**管理列表含停用角色**,
|
||||
下拉只给启用项。
|
||||
"""
|
||||
roles = role_repository.list_roles(
|
||||
session,
|
||||
offset=query.offset,
|
||||
limit=query.page_size,
|
||||
keyword=query.keyword,
|
||||
status=query.status,
|
||||
)
|
||||
total = role_repository.count_roles(session, keyword=query.keyword, status=query.status)
|
||||
counts = role_repository.count_users_by_role(session, [role.id for role in roles])
|
||||
return PageResult[RoleManageOut](
|
||||
records=[_to_out(role, counts.get(role.id, 0)) for role in roles],
|
||||
total=total,
|
||||
page_num=query.page_num,
|
||||
page_size=query.page_size,
|
||||
)
|
||||
|
||||
|
||||
def create_role(
|
||||
session: Session,
|
||||
payload: RoleCreateIn,
|
||||
*,
|
||||
operator: User,
|
||||
meta: RequestMeta | None = None,
|
||||
) -> RoleManageOut:
|
||||
"""新增角色。编码唯一、创建后不可修改,新角色固定启用。
|
||||
|
||||
:raises BadRequestError: 角色编码已存在(含并发提交的兜底)
|
||||
"""
|
||||
with audit_service.audit(
|
||||
session,
|
||||
operator=operator,
|
||||
record=AuditRecord(
|
||||
module=MODULE_ROLE,
|
||||
action=ACTION_CREATE,
|
||||
summary=f"新增角色 {payload.role_name}({payload.role_code})",
|
||||
request_data=payload.model_dump(exclude_none=True),
|
||||
),
|
||||
meta=meta,
|
||||
):
|
||||
if role_repository.get_by_code(session, payload.role_code) is not None:
|
||||
raise BadRequestError(f"角色编码 {payload.role_code!r} 已存在")
|
||||
|
||||
role = Role(
|
||||
role_code=payload.role_code,
|
||||
role_name=payload.role_name,
|
||||
description=payload.description,
|
||||
sort_order=payload.sort_order,
|
||||
status=1,
|
||||
)
|
||||
try:
|
||||
role_repository.create(session, role)
|
||||
out = _to_out(role, 0)
|
||||
session.commit()
|
||||
except IntegrityError as exc:
|
||||
# 先查后写不是原子操作:并发提交同一编码会撞 uk_roles_role_code。
|
||||
# 回滚并把唯一键冲突翻译为可读的 400,而非 500
|
||||
session.rollback()
|
||||
raise BadRequestError(f"角色编码 {payload.role_code!r} 已存在") from exc
|
||||
|
||||
logger.info("新增角色 | role_code=%s", payload.role_code)
|
||||
return out
|
||||
|
||||
|
||||
def update_role(
|
||||
session: Session,
|
||||
role_id: int,
|
||||
payload: RoleUpdateIn,
|
||||
*,
|
||||
operator: User,
|
||||
meta: RequestMeta | None = None,
|
||||
) -> RoleManageOut:
|
||||
"""编辑角色名称/说明/排序(全量替换,不含 role_code)。
|
||||
|
||||
:raises NotFoundError: 角色不存在
|
||||
"""
|
||||
role = role_repository.get_by_id(session, role_id)
|
||||
with audit_service.audit(
|
||||
session,
|
||||
operator=operator,
|
||||
record=AuditRecord(
|
||||
module=MODULE_ROLE,
|
||||
action=ACTION_UPDATE,
|
||||
summary=f"修改角色 {role.role_name if role else f'#{role_id}'}",
|
||||
request_data=payload.model_dump(exclude_none=True),
|
||||
),
|
||||
meta=meta,
|
||||
):
|
||||
if role is None:
|
||||
raise NotFoundError("角色不存在")
|
||||
|
||||
role.role_name = payload.role_name
|
||||
role.description = payload.description
|
||||
role.sort_order = payload.sort_order
|
||||
# 响应与后置动作全部在 commit 之前完成(见 user_service 顶部说明)
|
||||
out = _to_out(role, _user_count(session, role_id))
|
||||
session.commit()
|
||||
logger.info("修改角色 | role_id=%s", role_id)
|
||||
return out
|
||||
|
||||
|
||||
def set_status(
|
||||
session: Session,
|
||||
role_id: int,
|
||||
status: int,
|
||||
*,
|
||||
operator: User,
|
||||
meta: RequestMeta | None = None,
|
||||
) -> RoleManageOut:
|
||||
"""启用或禁用角色。
|
||||
|
||||
停用不解除用户绑定、不影响既有授权(授权判定只看编码不看状态),
|
||||
但该角色不可再分配给用户(`user_service._resolve_roles` 拦截);
|
||||
内置管理员角色不可停用。
|
||||
|
||||
:raises NotFoundError: 角色不存在
|
||||
:raises ForbiddenError: 试图停用内置管理员角色
|
||||
"""
|
||||
role = role_repository.get_by_id(session, role_id)
|
||||
verb = "启用" if status == 1 else "禁用"
|
||||
with audit_service.audit(
|
||||
session,
|
||||
operator=operator,
|
||||
record=AuditRecord(
|
||||
module=MODULE_ROLE,
|
||||
action=ACTION_ENABLE if status == 1 else ACTION_DISABLE,
|
||||
summary=f"{verb}角色 {role.role_name if role else f'#{role_id}'}",
|
||||
request_data={"status": status},
|
||||
),
|
||||
meta=meta,
|
||||
):
|
||||
if role is None:
|
||||
raise NotFoundError("角色不存在")
|
||||
|
||||
if role.role_code == ROLE_ADMIN and status == 0:
|
||||
raise ForbiddenError("内置管理员角色不可停用")
|
||||
|
||||
role.status = status
|
||||
out = _to_out(role, _user_count(session, role_id))
|
||||
session.commit()
|
||||
logger.info("变更角色状态 | role_id=%s | status=%s", role_id, status)
|
||||
return out
|
||||
|
||||
|
||||
def delete_role(
|
||||
session: Session,
|
||||
role_id: int,
|
||||
*,
|
||||
operator: User,
|
||||
meta: RequestMeta | None = None,
|
||||
) -> None:
|
||||
"""删除角色。
|
||||
|
||||
内置管理员角色不可删除;仍有关联用户的角色不可删除(提示先解除绑定)。
|
||||
查询走 `FOR UPDATE` 锁行:校验与删除之间若发生并发绑定,CASCADE 会把
|
||||
新绑定一并静默剥离 —— 锁行使并发绑定失败(可见)而非数据被吞(不可见)。
|
||||
|
||||
:raises NotFoundError: 角色不存在
|
||||
:raises ForbiddenError: 试图删除内置管理员角色
|
||||
:raises BadRequestError: 角色下仍有绑定的用户
|
||||
"""
|
||||
role = role_repository.get_by_id(session, role_id, for_update=True)
|
||||
with audit_service.audit(
|
||||
session,
|
||||
operator=operator,
|
||||
record=AuditRecord(
|
||||
module=MODULE_ROLE,
|
||||
action=ACTION_DELETE,
|
||||
summary=f"删除角色 {role.role_name if role else f'#{role_id}'}",
|
||||
),
|
||||
meta=meta,
|
||||
):
|
||||
if role is None:
|
||||
raise NotFoundError("角色不存在")
|
||||
|
||||
if role.role_code == ROLE_ADMIN:
|
||||
raise ForbiddenError("内置管理员角色不可删除")
|
||||
|
||||
# user_roles 外键是 CASCADE:不拦截的话,删除会静默剥离绑定用户的权限
|
||||
user_count = _user_count(session, role_id)
|
||||
if user_count > 0:
|
||||
raise BadRequestError(f"该角色下仍有 {user_count} 个用户,请先解除绑定")
|
||||
|
||||
role_code = role.role_code # 固化:对象随删除失效
|
||||
role_repository.delete(session, role)
|
||||
session.commit()
|
||||
logger.info("删除角色 | role_code=%s", role_code)
|
||||
@@ -4,25 +4,45 @@
|
||||
repository 只 flush,事务由这里控制。
|
||||
|
||||
设计取舍:
|
||||
|
||||
- 删除采用**物理删除**,与「删除即禁用」并不矛盾 —— 后者是对业务方的建议
|
||||
(停用账号应走禁用而非删除,以保留审计线索),但需求 2.5.1 明确列了
|
||||
「增删改查」,故保留删除能力;`operation_logs` 通过 SET NULL + 冗余 username
|
||||
保证删除后仍可追溯
|
||||
- 不允许删除或禁用**自己** —— 管理员误操作会立刻失去全部管理权限,
|
||||
且没有其他账号可以救回来
|
||||
- 全部写操作经 `audit_service.audit()` 留痕,**成功与失败都记**。摘要需要
|
||||
「目标用户名」,故对目标的只读预取放在审计上下文**之前** —— 这样目标
|
||||
不存在(NotFound 失败)时也能留下可读的记录
|
||||
- **响应对象在 commit 之前构造**(`_to_out` 读的是内存中的已加载状态):
|
||||
commit 后再触碰 ORM 对象会因过期而触发额外查询,该查询一旦抛错,
|
||||
「已提交成功」的操作会被审计记成失败
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.constants import ROLE_ADMIN
|
||||
from app.core.constants import (
|
||||
ACTION_CREATE,
|
||||
ACTION_DELETE,
|
||||
ACTION_DISABLE,
|
||||
ACTION_ENABLE,
|
||||
ACTION_RESET_PASSWORD,
|
||||
ACTION_UPDATE,
|
||||
MODULE_USER,
|
||||
ROLE_ADMIN,
|
||||
)
|
||||
from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError
|
||||
from app.core.response import PageResult
|
||||
from app.core.security import hash_password
|
||||
from app.models import User
|
||||
from app.repositories import user_repository
|
||||
from app.schemas.user import RoleOut, UserCreateIn, UserOut, UserQuery, UserUpdateIn
|
||||
from app.repositories import role_repository, user_repository
|
||||
from app.schemas.role import RoleOut
|
||||
from app.schemas.user import UserCreateIn, UserOut, UserQuery, UserUpdateIn
|
||||
from app.services import audit_service
|
||||
from app.services.audit_service import AuditRecord, RequestMeta
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -83,73 +103,126 @@ def get_user(session: Session, user_id: int) -> UserOut:
|
||||
return _to_out(user)
|
||||
|
||||
|
||||
def create_user(session: Session, payload: UserCreateIn) -> UserOut:
|
||||
def create_user(
|
||||
session: Session,
|
||||
payload: UserCreateIn,
|
||||
*,
|
||||
operator: User,
|
||||
meta: RequestMeta | None = None,
|
||||
) -> UserOut:
|
||||
"""新增用户。
|
||||
|
||||
:raises BadRequestError: 用户名已存在,或角色编码无效
|
||||
:raises BadRequestError: 用户名已存在,或角色编码无效/已停用
|
||||
"""
|
||||
if user_repository.get_by_username(session, payload.username) is not None:
|
||||
raise BadRequestError(f"用户名 {payload.username!r} 已存在")
|
||||
with audit_service.audit(
|
||||
session,
|
||||
operator=operator,
|
||||
record=AuditRecord(
|
||||
module=MODULE_USER,
|
||||
action=ACTION_CREATE,
|
||||
summary=f"新增用户 {payload.username}",
|
||||
# password 会随字典进入审计管道,由 repository 层的结构化脱敏抹除其值
|
||||
request_data=payload.model_dump(exclude_none=True),
|
||||
),
|
||||
meta=meta,
|
||||
):
|
||||
if user_repository.get_by_username(session, payload.username) is not None:
|
||||
raise BadRequestError(f"用户名 {payload.username!r} 已存在")
|
||||
|
||||
roles = _resolve_roles(session, payload.roles)
|
||||
roles = _resolve_roles(session, payload.roles)
|
||||
|
||||
user = User(
|
||||
username=payload.username,
|
||||
password=hash_password(payload.password),
|
||||
real_name=payload.real_name,
|
||||
phone=payload.phone,
|
||||
email=payload.email,
|
||||
status=1,
|
||||
remark=payload.remark,
|
||||
)
|
||||
user_repository.create(session, user)
|
||||
user_repository.replace_roles(session, user, roles)
|
||||
session.commit()
|
||||
user = User(
|
||||
username=payload.username,
|
||||
password=hash_password(payload.password),
|
||||
real_name=payload.real_name,
|
||||
phone=payload.phone,
|
||||
email=payload.email,
|
||||
status=1,
|
||||
remark=payload.remark,
|
||||
)
|
||||
try:
|
||||
user_repository.create(session, user)
|
||||
user_repository.replace_roles(session, user, roles)
|
||||
out = _to_out(user)
|
||||
session.commit()
|
||||
except IntegrityError as exc:
|
||||
# 先查后写不是原子操作:并发提交同一用户名会撞 uk_users_username。
|
||||
# 回滚并把唯一键冲突翻译为可读的 400,而非 500
|
||||
session.rollback()
|
||||
raise BadRequestError(f"用户名 {payload.username!r} 已存在") from exc
|
||||
|
||||
logger.info("新增用户 | username=%s | roles=%s", user.username, payload.roles)
|
||||
return _to_out(user)
|
||||
logger.info("新增用户 | username=%s | roles=%s", payload.username, payload.roles)
|
||||
return out
|
||||
|
||||
|
||||
def update_user(
|
||||
session: Session, user_id: int, payload: UserUpdateIn, *, operator_id: int
|
||||
session: Session,
|
||||
user_id: int,
|
||||
payload: UserUpdateIn,
|
||||
*,
|
||||
operator: User,
|
||||
meta: RequestMeta | None = None,
|
||||
) -> UserOut:
|
||||
"""编辑用户的基本信息与角色。
|
||||
|
||||
**不含用户名与密码**:用户名是审计追溯的锚点;密码走专门的重置接口,
|
||||
以免"改资料"顺手改掉密码这类越权操作。
|
||||
|
||||
空串字段(`''`)归一为 None:编辑表单会把未修改的字段以空串提交,
|
||||
若原样写入会把库中的 NULL 污染成空串(两类空值语义漂移且不可逆);
|
||||
归一后「清空输入框」= 写回 NULL,语义正确。
|
||||
|
||||
:raises NotFoundError: 用户不存在
|
||||
:raises BadRequestError: 角色编码无效
|
||||
:raises BadRequestError: 角色编码无效或已停用
|
||||
"""
|
||||
user = user_repository.get_by_id(session, user_id)
|
||||
if user is None:
|
||||
raise NotFoundError("用户不存在")
|
||||
with audit_service.audit(
|
||||
session,
|
||||
operator=operator,
|
||||
record=AuditRecord(
|
||||
module=MODULE_USER,
|
||||
action=ACTION_UPDATE,
|
||||
summary=f"修改用户 {user.username if user else f'#{user_id}'}",
|
||||
request_data=payload.model_dump(exclude_none=True),
|
||||
),
|
||||
meta=meta,
|
||||
):
|
||||
if user is None:
|
||||
raise NotFoundError("用户不存在")
|
||||
|
||||
if payload.real_name is not None:
|
||||
user.real_name = payload.real_name
|
||||
if payload.phone is not None:
|
||||
user.phone = payload.phone
|
||||
if payload.email is not None:
|
||||
user.email = payload.email
|
||||
if payload.remark is not None:
|
||||
user.remark = payload.remark
|
||||
if payload.real_name is not None:
|
||||
user.real_name = payload.real_name or None
|
||||
if payload.phone is not None:
|
||||
user.phone = payload.phone or None
|
||||
if payload.email is not None:
|
||||
user.email = payload.email or None
|
||||
if payload.remark is not None:
|
||||
user.remark = payload.remark or None
|
||||
|
||||
# 不允许把自己降级掉管理角色,否则改完立刻失去管理权限
|
||||
if payload.roles is not None and user_id == operator_id and ROLE_ADMIN not in payload.roles:
|
||||
raise ForbiddenError("不能移除自己的管理员角色")
|
||||
# 不允许把自己降级掉管理角色,否则改完立刻失去管理权限
|
||||
if payload.roles is not None and user_id == operator.id and ROLE_ADMIN not in payload.roles:
|
||||
raise ForbiddenError("不能移除自己的管理员角色")
|
||||
|
||||
session.flush()
|
||||
session.flush()
|
||||
|
||||
if payload.roles is not None:
|
||||
roles = _resolve_roles(session, payload.roles)
|
||||
user_repository.replace_roles(session, user, roles)
|
||||
if payload.roles is not None:
|
||||
roles = _resolve_roles(session, payload.roles)
|
||||
user_repository.replace_roles(session, user, roles)
|
||||
|
||||
session.commit()
|
||||
logger.info("编辑用户 | user_id=%s | roles=%s", user_id, payload.roles)
|
||||
return _to_out(user)
|
||||
out = _to_out(user)
|
||||
session.commit()
|
||||
logger.info("编辑用户 | user_id=%s | roles=%s", user_id, payload.roles)
|
||||
return out
|
||||
|
||||
|
||||
def reset_password(session: Session, user_id: int, new_password: str) -> None:
|
||||
def reset_password(
|
||||
session: Session,
|
||||
user_id: int,
|
||||
new_password: str,
|
||||
*,
|
||||
operator: User,
|
||||
meta: RequestMeta | None = None,
|
||||
) -> None:
|
||||
"""重置用户密码。
|
||||
|
||||
同时把 `token_version` 加一,使该用户**已签发的令牌立即失效** ——
|
||||
@@ -158,16 +231,35 @@ def reset_password(session: Session, user_id: int, new_password: str) -> None:
|
||||
:raises NotFoundError: 用户不存在
|
||||
"""
|
||||
user = user_repository.get_by_id(session, user_id)
|
||||
if user is None:
|
||||
raise NotFoundError("用户不存在")
|
||||
with audit_service.audit(
|
||||
session,
|
||||
operator=operator,
|
||||
record=AuditRecord(
|
||||
module=MODULE_USER,
|
||||
action=ACTION_RESET_PASSWORD,
|
||||
summary=f"重置用户 {user.username if user else f'#{user_id}'} 的密码",
|
||||
# 明文进审计管道,由 repository 层的结构化脱敏整体抹除
|
||||
request_data={"new_password": new_password},
|
||||
),
|
||||
meta=meta,
|
||||
):
|
||||
if user is None:
|
||||
raise NotFoundError("用户不存在")
|
||||
|
||||
user.password = hash_password(new_password)
|
||||
user.token_version += 1
|
||||
session.commit()
|
||||
logger.info("重置密码 | user_id=%s(令牌版本已提升)", user_id)
|
||||
user.password = hash_password(new_password)
|
||||
user.token_version += 1
|
||||
session.commit()
|
||||
logger.info("重置密码 | user_id=%s(令牌版本已提升)", user_id)
|
||||
|
||||
|
||||
def set_status(session: Session, user_id: int, status: int, *, operator_id: int) -> UserOut:
|
||||
def set_status(
|
||||
session: Session,
|
||||
user_id: int,
|
||||
status: int,
|
||||
*,
|
||||
operator: User,
|
||||
meta: RequestMeta | None = None,
|
||||
) -> UserOut:
|
||||
"""启用或禁用用户。
|
||||
|
||||
禁用时同样提升 `token_version`,使已签发令牌立即失效 ——
|
||||
@@ -177,21 +269,40 @@ def set_status(session: Session, user_id: int, status: int, *, operator_id: int)
|
||||
:raises ForbiddenError: 试图禁用自己
|
||||
"""
|
||||
user = user_repository.get_by_id(session, user_id)
|
||||
if user is None:
|
||||
raise NotFoundError("用户不存在")
|
||||
verb = "启用" if status == 1 else "禁用"
|
||||
with audit_service.audit(
|
||||
session,
|
||||
operator=operator,
|
||||
record=AuditRecord(
|
||||
module=MODULE_USER,
|
||||
action=ACTION_ENABLE if status == 1 else ACTION_DISABLE,
|
||||
summary=f"{verb}用户 {user.username if user else f'#{user_id}'}",
|
||||
request_data={"status": status},
|
||||
),
|
||||
meta=meta,
|
||||
):
|
||||
if user is None:
|
||||
raise NotFoundError("用户不存在")
|
||||
|
||||
if status == 0 and user_id == operator_id:
|
||||
raise ForbiddenError("不能禁用自己")
|
||||
if status == 0 and user_id == operator.id:
|
||||
raise ForbiddenError("不能禁用自己")
|
||||
|
||||
user.status = status
|
||||
if status == 0:
|
||||
user.token_version += 1
|
||||
session.commit()
|
||||
logger.info("变更用户状态 | user_id=%s | status=%s", user_id, status)
|
||||
return _to_out(user)
|
||||
user.status = status
|
||||
if status == 0:
|
||||
user.token_version += 1
|
||||
out = _to_out(user)
|
||||
session.commit()
|
||||
logger.info("变更用户状态 | user_id=%s | status=%s", user_id, status)
|
||||
return out
|
||||
|
||||
|
||||
def delete_user(session: Session, user_id: int, *, operator_id: int) -> None:
|
||||
def delete_user(
|
||||
session: Session,
|
||||
user_id: int,
|
||||
*,
|
||||
operator: User,
|
||||
meta: RequestMeta | None = None,
|
||||
) -> None:
|
||||
"""删除用户。
|
||||
|
||||
物理删除。历史操作日志通过 `user_id` 置空 + 冗余 `username` 保留可追溯性。
|
||||
@@ -200,33 +311,50 @@ def delete_user(session: Session, user_id: int, *, operator_id: int) -> None:
|
||||
:raises ForbiddenError: 试图删除自己
|
||||
"""
|
||||
user = user_repository.get_by_id(session, user_id)
|
||||
if user is None:
|
||||
raise NotFoundError("用户不存在")
|
||||
with audit_service.audit(
|
||||
session,
|
||||
operator=operator,
|
||||
record=AuditRecord(
|
||||
module=MODULE_USER,
|
||||
action=ACTION_DELETE,
|
||||
summary=f"删除用户 {user.username if user else f'#{user_id}'}",
|
||||
),
|
||||
meta=meta,
|
||||
):
|
||||
if user is None:
|
||||
raise NotFoundError("用户不存在")
|
||||
|
||||
if user_id == operator_id:
|
||||
raise ForbiddenError("不能删除自己")
|
||||
if user_id == operator.id:
|
||||
raise ForbiddenError("不能删除自己")
|
||||
|
||||
username = user.username
|
||||
user_repository.delete(session, user)
|
||||
session.commit()
|
||||
logger.info("删除用户 | username=%s", username)
|
||||
username = user.username # 固化:对象随删除失效
|
||||
user_repository.delete(session, user)
|
||||
session.commit()
|
||||
logger.info("删除用户 | username=%s", username)
|
||||
|
||||
|
||||
def list_roles(session: Session) -> list[RoleOut]:
|
||||
"""取全部启用角色,供前端下拉选择。"""
|
||||
return [RoleOut.model_validate(role) for role in user_repository.get_all_roles(session)]
|
||||
"""取全部启用角色,供用户表单的角色选择使用。"""
|
||||
return [RoleOut.model_validate(role) for role in role_repository.get_all_roles(session)]
|
||||
|
||||
|
||||
def _resolve_roles(session: Session, role_codes: list[str]) -> list:
|
||||
"""把角色编码解析为角色对象,并校验全部存在。
|
||||
"""把角色编码解析为角色对象,校验全部存在且**未停用**。
|
||||
|
||||
:raises BadRequestError: 存在无效编码
|
||||
停用角色不出现在表单下拉中;接口层若不拦截,直接调 API(或使用停用前
|
||||
加载的旧选项)仍可绑定,与「停用后不可再分配」的语义矛盾。已绑定停用
|
||||
角色的用户需先转为启用角色再保存。
|
||||
|
||||
:raises BadRequestError: 存在无效编码,或角色已停用
|
||||
"""
|
||||
if not role_codes:
|
||||
raise BadRequestError("至少需要指定一个角色")
|
||||
roles = user_repository.get_roles_by_codes(session, role_codes)
|
||||
roles = role_repository.get_roles_by_codes(session, role_codes)
|
||||
if len(roles) != len(set(role_codes)):
|
||||
valid = {role.role_code for role in roles}
|
||||
invalid = sorted(set(role_codes) - valid)
|
||||
raise BadRequestError(f"角色编码无效:{invalid}")
|
||||
disabled = sorted(role.role_code for role in roles if role.status != 1)
|
||||
if disabled:
|
||||
raise BadRequestError(f"角色已停用,不可分配:{disabled}")
|
||||
return list(roles)
|
||||
Reference in new issue
Block a user