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
@@ -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 "已禁用")
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user