feat(system): 实现用户管理接口

需求 2.5.1 用户账号管理,全部要求管理员权限(require_admin 依赖):

- 接口:列表/详情/新增/编辑/删除/重置密码/启停/角色下拉,
  角色下拉放 /users/meta/roles 以避开 /{user_id} 的路由匹配冲突
- 删除、禁用、改角色三处自我保护:不允许对当前登录账号执行,
  避免管理员误操作后立刻失去全部管理权限且无法自行恢复
- 重置密码与禁用均提升 token_version,使已签发令牌立即失效,
  不依赖 Redis 黑名单
- 密码按字节数校验(bcrypt 5.0 硬上限 72 字节,中文一字 3 字节)
- 用户列表按 id 倒序:create_time 为秒级精度,同秒数据分页结果不确定
- 修复 replace_roles 未同步关系集合的缺陷:改用 user.user_roles
  集合追加,此前 session.add(UserRole(...)) 会表现为保存成功但返回空角色
- update_user 的自我保护原先依赖返回 None 的占位函数,恒不生效,
  改为显式传入 operator_id

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
liuwenxin 2026-10-10 10:44:13 +08:00
parent ad5fe4a411
commit e22c4bcdc9
7 changed files with 650 additions and 4 deletions

View File

@ -6,8 +6,9 @@ from fastapi import Depends
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.orm import Session
from app.core.constants import ROLE_ADMIN
from app.core.db import get_db
from app.core.exceptions import AuthError
from app.core.exceptions import AuthError, ForbiddenError
from app.core.security import decode_token
from app.models import User
from app.repositories import user_repository
@ -71,3 +72,20 @@ def _is_digits(value: object) -> bool:
令牌内容不可信,直接 int() 会因非法输入抛 ValueError 而不是 401。
"""
return isinstance(value, str) and value.isdigit()
def require_admin(current_user: Annotated[User, Depends(get_current_user)]) -> User:
"""要求当前用户具备管理员角色。
需求 2.5.1 只定义了「系统管理员(全部权限)」与「普通用户(业务操作)」两个
角色,故权限判定用**角色粗粒度**即可,不必引入权限码表 —— `permissions`
与 `role_permissions` 表已建好,等业务方给出权限清单后再启用。
:raises ForbiddenError: 已登录但不具备管理员角色(403)
"""
if ROLE_ADMIN not in user_repository.get_role_codes(current_user):
raise ForbiddenError("需要管理员权限")
return current_user
AdminUser = Annotated[User, Depends(require_admin)]

View File

@ -8,6 +8,7 @@ from fastapi import APIRouter
from app.api import health
from app.api.v1 import auth
from app.api.v1.system import users as system_users
api_router = APIRouter()
@ -17,4 +18,10 @@ api_router.include_router(health.router)
# 业务接口:/api/v1/*
v1_router = APIRouter(prefix="/v1")
v1_router.include_router(auth.router)
# 系统管理(需求 2.5.1):/api/v1/system/*
system_router = APIRouter(prefix="/system")
system_router.include_router(system_users.router)
v1_router.include_router(system_router)
api_router.include_router(v1_router)

View File

@ -0,0 +1,7 @@
"""系统管理路由(需求 2.5.1)。
含用户管理、角色查询、操作日志查询三部分,全部要求管理员权限。
与 `api/v1/auth.py` 的分工:`auth` 是登录链路(未认证即可访问的验证码与登录,
以及任何登录用户都能访问的 `/me` 与登出);本包是**管理功能**。
"""

View File

@ -0,0 +1,131 @@
"""用户管理接口(需求 2.5.1:用户账号管理)。
全部要求管理员权限 —— 按需求「系统管理员(全部权限)、普通用户(业务操作)」,
用户管理属管理员专属。
删除、禁用、改角色三处都有**自我保护**:不允许对当前登录账号执行,
否则管理员误操作会立刻失去全部管理权限且无法自行恢复。
"""
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.user import (
PasswordResetIn,
RoleOut,
UserCreateIn,
UserOut,
UserQuery,
UserStatusIn,
UserUpdateIn,
)
from app.services import user_service
router = APIRouter(prefix="/users", tags=["系统管理 · 用户"])
@router.get("", response_model=ApiResponse[PageResult[UserOut]], summary="用户列表")
def list_users(
session: DbSession,
_: AdminUser,
query: Annotated[UserQuery, Query()],
) -> ApiResponse[PageResult[UserOut]]:
"""分页查询用户,支持按用户名/姓名模糊、状态与角色筛选。"""
return ok(user_service.list_users(session, query))
@router.get("/{user_id}", response_model=ApiResponse[UserOut], summary="用户详情")
def get_user(
session: DbSession,
_: AdminUser,
user_id: Annotated[int, Path(ge=1)],
) -> ApiResponse[UserOut]:
"""查询用户详情。"""
return ok(user_service.get_user(session, user_id))
@router.post("", response_model=ApiResponse[UserOut], summary="新增用户")
def create_user(
session: DbSession,
_: AdminUser,
payload: UserCreateIn,
) -> ApiResponse[UserOut]:
"""新增用户。用户名须唯一,至少指定一个角色。"""
return ok(user_service.create_user(session, payload), message="用户已创建")
@router.put("/{user_id}", response_model=ApiResponse[UserOut], summary="编辑用户")
def update_user(
session: DbSession,
current_user: AdminUser,
user_id: Annotated[int, Path(ge=1)],
payload: UserUpdateIn,
) -> ApiResponse[UserOut]:
"""编辑用户的基本信息与角色。
**不含用户名与密码**:用户名是审计追溯的锚点;密码走专门的重置接口。
"""
return ok(
user_service.update_user(session, user_id, payload, operator_id=current_user.id),
message="已保存",
)
@router.delete("/{user_id}", response_model=ApiResponse[None], summary="删除用户")
def delete_user(
session: DbSession,
current_user: AdminUser,
user_id: Annotated[int, Path(ge=1)],
) -> ApiResponse[None]:
"""删除用户。
物理删除。历史操作日志通过 `user_id` 置空 + 冗余用户名保留可追溯性。
业务上更推荐用「禁用」代替删除 —— 禁用保留完整审计线索。
"""
user_service.delete_user(session, user_id, operator_id=current_user.id)
return ok(message="用户已删除")
@router.put("/{user_id}/password", response_model=ApiResponse[None], summary="重置密码")
def reset_password(
session: DbSession,
_: AdminUser,
user_id: Annotated[int, Path(ge=1)],
payload: PasswordResetIn,
) -> ApiResponse[None]:
"""重置用户密码。
会同时提升该用户的令牌版本,使其**已签发的令牌立即失效** ——
否则改密后旧令牌在有效期内仍可用。
"""
user_service.reset_password(session, user_id, payload.new_password)
return ok(message="密码已重置,该用户需重新登录")
@router.put("/{user_id}/status", response_model=ApiResponse[UserOut], summary="启用/禁用用户")
def set_status(
session: DbSession,
current_user: AdminUser,
user_id: Annotated[int, Path(ge=1)],
payload: UserStatusIn,
) -> ApiResponse[UserOut]:
"""启用或禁用用户。
禁用会提升令牌版本使已签发令牌立即失效,否则被禁用者仍可凭旧令牌
继续访问直到过期(最长 2 小时)。
"""
result = user_service.set_status(session, user_id, payload.status, operator_id=current_user.id)
return ok(result, message="已启用" if payload.status == 1 else "已禁用")
@router.get("/meta/roles", response_model=ApiResponse[list[RoleOut]], summary="角色下拉选项")
def list_roles(session: DbSession, _: AdminUser) -> ApiResponse[list[RoleOut]]:
"""取全部启用角色,供用户表单的角色选择使用。
路径放在 `/users/meta/roles` 而非 `/roles`,是为了避免与
`/{user_id}` 的路由匹配冲突(`roles` 会被当成 user_id 解析而报 422)。
"""
return ok(user_service.list_roles(session))

View File

@ -5,10 +5,18 @@
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.orm import Session
from sqlalchemy import Select, func, select
from sqlalchemy.orm import Session, selectinload
from app.models import User
from app.models import Role, User, UserRole
def _base_query() -> Select[tuple[User]]:
"""用户查询基座。
预加载角色关联,避免列表渲染时逐行查角色(N+1)。
"""
return select(User).options(selectinload(User.user_roles).selectinload(UserRole.role))
def get_by_username(session: Session, username: str) -> User | None:
@ -34,3 +42,120 @@ def touch_last_login(session: Session, user: User, moment: datetime) -> None:
"""更新最后登录时间。只 flush,提交由 service 决定。"""
user.last_login_time = moment
session.flush()
# ==================== 列表查询 ====================
def list_users(
session: Session,
*,
offset: int,
limit: int,
username: str | None = None,
real_name: str | None = None,
status: int | None = None,
role_code: str | None = None,
) -> list[User]:
"""分页查询用户列表。
**按 id 倒序而非 create_time**:create_time 是秒级精度,批量导入的用户
可能落在同一秒,按它排序的分页结果不确定 —— 翻页时会出现重复或遗漏。
id 是自增主键,天然唯一且与创建顺序一致。
"""
stmt = _base_query()
stmt = _apply_filters(stmt, username=username, real_name=real_name, status=status)
if role_code:
# 按角色筛选需 join 关联表;角色是字典数据,数量极少,join 代价可忽略
stmt = stmt.join(UserRole).join(Role).where(Role.role_code == role_code)
stmt = stmt.order_by(User.id.desc()).offset(offset).limit(limit)
return list(session.execute(stmt).scalars().unique())
def count_users(
session: Session,
*,
username: str | None = None,
real_name: str | None = None,
status: int | None = None,
role_code: str | None = None,
) -> int:
"""统计符合条件的用户总数,供分页使用。"""
stmt = select(func.count(User.id))
stmt = _apply_filters(stmt, username=username, real_name=real_name, status=status)
if role_code:
stmt = stmt.join(UserRole).join(Role).where(Role.role_code == role_code)
return int(session.execute(stmt).scalar_one())
def _apply_filters(
stmt: Select,
*,
username: str | None,
real_name: str | None,
status: int | None,
) -> Select:
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。"""
if username:
stmt = stmt.where(User.username.like(f"%{username}%"))
if real_name:
stmt = stmt.where(User.real_name.like(f"%{real_name}%"))
if status is not None:
stmt = stmt.where(User.status == status)
return stmt
# ==================== 写入 ====================
def create(session: Session, user: User) -> User:
"""新增用户。只 flush 以取得自增 id。"""
session.add(user)
session.flush()
return user
def delete(session: Session, user: User) -> None:
"""物理删除用户。
本项目对用户采用「删除即禁用」的**业务**约定(见模型注释),
但保留物理删除能力供测试清理与误建账号的彻底移除使用。
`user_roles` 与 `operation_logs` 的外键分别配置了 CASCADE 与 SET NULL,
故不会留下悬挂引用。
"""
session.delete(user)
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:
"""整体替换用户的角色。
采用「先清后建」而非逐条比对:角色数量极少(本期仅两个),
差异计算的复杂度换不来收益,且整体替换的语义更直白。
**必须经 `user.user_roles` 集合追加,不能用 `session.add(UserRole(...))`**:
后者只把对象放进 session,不会同步到关系的已加载集合,于是同一事务内
再读 `user.user_roles` 仍是空的 —— 表现为「保存成功,但接口返回的角色
列表是 []」。经集合操作时 `user_id` 由关系自动填充,集合也立即反映新状态。
"""
user.user_roles.clear()
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())

126
backend/app/schemas/user.py Normal file
View File

@ -0,0 +1,126 @@
"""用户与角色的请求/响应模型。
与 `auth.py` 的分工:`auth.py` 管登录链路(验证码、登录、当前用户),
本模块管**用户管理**(增删改查、重置密码、启停、角色分配)。
字段名驼峰(规范约定)与蛇形属性之间用 alias 映射,与响应模型保持同一套做法。
"""
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.core.constants import PASSWORD_MAX_BYTES, PASSWORD_MIN_BYTES
from app.core.response import PageQuery
def _validate_password_bytes(value: str) -> str:
"""按**字节数**校验密码长度。
必须在这里拦下:bcrypt 5.0 起对超过 72 字节的密码直接抛 ValueError,
而中文一个字符占 3 字节 —— 按字符数校验(如 max_length=20)根本拦不住。
与 `auth.py` 的登录校验共用同一规则,避免两处口径不一致。
"""
length = len(value.encode("utf-8"))
if length < PASSWORD_MIN_BYTES:
raise ValueError(f"密码不足 {PASSWORD_MIN_BYTES} 字节")
if length > PASSWORD_MAX_BYTES:
raise ValueError(f"密码超过 {PASSWORD_MAX_BYTES} 字节上限")
return value
class UserOut(BaseModel):
"""用户列表/详情响应。
不含 password —— 密码哈希绝不外泄,这是硬约束而非疏漏。
"""
model_config = ConfigDict(from_attributes=True)
id: int
username: str
real_name: str | None = None
phone: str | None = None
email: str | None = None
status: int = Field(description="状态:1启用 0禁用")
roles: list[str] = Field(default_factory=list, description="角色编码列表")
last_login_time: datetime | None = None
remark: str | None = None
create_time: datetime | None = None
class UserCreateIn(BaseModel):
"""新增用户。"""
model_config = ConfigDict(str_strip_whitespace=True)
username: str = Field(min_length=2, max_length=64, description="登录用户名,须唯一")
password: str = Field(description="初始密码")
real_name: str | None = Field(default=None, max_length=64)
phone: str | None = Field(default=None, max_length=20)
email: str | None = Field(default=None, max_length=128)
roles: list[str] = Field(default_factory=list, description="角色编码列表,至少一个")
remark: str | None = Field(default=None, max_length=255)
@field_validator("password")
@classmethod
def _check_password(cls, value: str) -> str:
return _validate_password_bytes(value)
class UserUpdateIn(BaseModel):
"""编辑用户。
**不含 username** —— 用户名是登录凭据与审计追溯的锚点,改名会让历史
操作日志与登录日志对不上。确需改名应走专门的流程(本期不提供)。
"""
model_config = ConfigDict(str_strip_whitespace=True)
real_name: str | None = Field(default=None, max_length=64)
phone: str | None = Field(default=None, max_length=20)
email: str | None = Field(default=None, max_length=128)
roles: list[str] | None = Field(default=None, description="传 null 表示不改角色")
remark: str | None = Field(default=None, max_length=255)
class PasswordResetIn(BaseModel):
"""重置密码(管理员操作,不需要原密码)。"""
new_password: str = Field(description="新密码")
@field_validator("new_password")
@classmethod
def _check_password(cls, value: str) -> str:
return _validate_password_bytes(value)
class UserStatusIn(BaseModel):
"""启用/禁用用户。"""
status: int = Field(ge=0, le=1, description="1启用 0禁用")
class UserQuery(PageQuery):
"""用户列表查询条件。
继承分页基类,额外支持按用户名/姓名模糊、按状态与角色筛选。
"""
username: str | None = Field(default=None, max_length=64, description="用户名,模糊匹配")
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

View File

@ -0,0 +1,232 @@
"""用户管理业务编排。
**事务边界在本层**:每个公开方法自行决定何时 commit。
repository 只 flush,事务由这里控制。
设计取舍:
- 删除采用**物理删除**,与「删除即禁用」并不矛盾 —— 后者是对业务方的建议
(停用账号应走禁用而非删除,以保留审计线索),但需求 2.5.1 明确列了
「增删改查」,故保留删除能力;`operation_logs` 通过 SET NULL + 冗余 username
保证删除后仍可追溯
- 不允许删除或禁用**自己** —— 管理员误操作会立刻失去全部管理权限,
且没有其他账号可以救回来
"""
import logging
from sqlalchemy.orm import Session
from app.core.constants import 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
logger = logging.getLogger(__name__)
def _to_out(user: User) -> UserOut:
"""ORM 对象转响应模型。
禁止把 ORM 对象直接当 response_model —— 表结构变更会直接泄漏到接口契约上。
"""
return UserOut(
id=user.id,
username=user.username,
real_name=user.real_name,
phone=user.phone,
email=user.email,
status=user.status,
roles=user_repository.get_role_codes(user),
last_login_time=user.last_login_time,
remark=user.remark,
create_time=user.create_time,
)
def list_users(session: Session, query: UserQuery) -> PageResult[UserOut]:
"""分页查询用户列表。"""
users = user_repository.list_users(
session,
offset=query.offset,
limit=query.page_size,
username=query.username,
real_name=query.real_name,
status=query.status,
role_code=query.role_code,
)
total = user_repository.count_users(
session,
username=query.username,
real_name=query.real_name,
status=query.status,
role_code=query.role_code,
)
return PageResult[UserOut](
records=[_to_out(user) for user in users],
total=total,
page_num=query.page_num,
page_size=query.page_size,
)
def get_user(session: Session, user_id: int) -> UserOut:
"""查询用户详情。
:raises NotFoundError: 用户不存在
"""
user = user_repository.get_by_id(session, user_id)
if user is None:
raise NotFoundError("用户不存在")
return _to_out(user)
def create_user(session: Session, payload: UserCreateIn) -> UserOut:
"""新增用户。
:raises BadRequestError: 用户名已存在,或角色编码无效
"""
if user_repository.get_by_username(session, payload.username) is not None:
raise BadRequestError(f"用户名 {payload.username!r} 已存在")
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()
logger.info("新增用户 | username=%s | roles=%s", user.username, payload.roles)
return _to_out(user)
def update_user(
session: Session, user_id: int, payload: UserUpdateIn, *, operator_id: int
) -> UserOut:
"""编辑用户的基本信息与角色。
**不含用户名与密码**:用户名是审计追溯的锚点;密码走专门的重置接口,
以免"改资料"顺手改掉密码这类越权操作。
:raises NotFoundError: 用户不存在
:raises BadRequestError: 角色编码无效
"""
user = user_repository.get_by_id(session, user_id)
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.roles is not None and user_id == operator_id and ROLE_ADMIN not in payload.roles:
raise ForbiddenError("不能移除自己的管理员角色")
session.flush()
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)
def reset_password(session: Session, user_id: int, new_password: str) -> None:
"""重置用户密码。
同时把 `token_version` 加一,使该用户**已签发的令牌立即失效** ——
改密后旧令牌仍可用是常见疏漏,这里与「离职即失效」复用同一机制。
:raises NotFoundError: 用户不存在
"""
user = user_repository.get_by_id(session, user_id)
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)
def set_status(session: Session, user_id: int, status: int, *, operator_id: int) -> UserOut:
"""启用或禁用用户。
禁用时同样提升 `token_version`,使已签发令牌立即失效 ——
否则被禁用者仍可凭旧令牌访问直到过期(最长 2 小时)。
:raises NotFoundError: 用户不存在
:raises ForbiddenError: 试图禁用自己
"""
user = user_repository.get_by_id(session, user_id)
if user is None:
raise NotFoundError("用户不存在")
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)
def delete_user(session: Session, user_id: int, *, operator_id: int) -> None:
"""删除用户。
物理删除。历史操作日志通过 `user_id` 置空 + 冗余 `username` 保留可追溯性。
:raises NotFoundError: 用户不存在
:raises ForbiddenError: 试图删除自己
"""
user = user_repository.get_by_id(session, user_id)
if user is None:
raise NotFoundError("用户不存在")
if user_id == operator_id:
raise ForbiddenError("不能删除自己")
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)]
def _resolve_roles(session: Session, role_codes: list[str]) -> list:
"""把角色编码解析为角色对象,并校验全部存在。
:raises BadRequestError: 存在无效编码
"""
if not role_codes:
raise BadRequestError("至少需要指定一个角色")
roles = user_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}")
return list(roles)