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