需求 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>
127 lines
4.4 KiB
Python
127 lines
4.4 KiB
Python
"""用户与角色的请求/响应模型。
|
||
|
||
与 `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
|