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:
liuwenxinandClaude Code committed 2026-10-10 16:28:04 +08:00
1 parent e22c4bcdc9
commit c83287819a
32 files changed
+3158 -137

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
"""系统管理路由(需求 2.5.1)。
含用户管理、角色查询、操作日志查询三部分,全部要求管理员权限。
含用户管理、角色管理、日志查询(操作日志 + 登录日志)四部分,全部要求管理员权限。
与 `api/v1/auth.py` 的分工:`auth` 是登录链路(未认证即可访问的验证码与登录,
以及任何登录用户都能访问的 `/me` 与登出);本包是**管理功能**。
+67
View File
@@ -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))
+100
View File
@@ -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="角色已删除")
+24 -9
View File
@@ -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 "已禁用")