"""认证相关的请求与响应模型。""" from datetime import datetime from pydantic import BaseModel, ConfigDict, Field, field_validator from app.core.constants import CAPTCHA_LENGTH, PASSWORD_MAX_BYTES class CaptchaOut(BaseModel): """验证码响应。 图片以 base64 内联返回而非单独的 URL:让 id 与图片在同一响应中成对产生, 避免前端分两次请求时拿到不匹配的组合。 """ captcha_id: str = Field(description="验证码标识,登录时需回传") image_base64: str = Field(description="PNG 图片的 base64,不含 data: 前缀") expires_in: int = Field(description="有效期(秒)") class LoginIn(BaseModel): """登录请求。""" model_config = ConfigDict(str_strip_whitespace=True) username: str = Field(min_length=1, max_length=64, description="登录用户名") password: str = Field(min_length=1, description="密码") captcha_id: str = Field(min_length=1, max_length=64, description="验证码标识") captcha_code: str = Field( min_length=CAPTCHA_LENGTH, max_length=CAPTCHA_LENGTH, description="用户填写的验证码", ) @field_validator("password") @classmethod def _check_password_bytes(cls, value: str) -> str: """按**字节数**校验密码长度。 必须在这里拦下:bcrypt 5.0 起对超过 72 字节的密码直接抛 ValueError, 而中文一个字符占 3 字节——按字符数校验(如 max_length=20)根本拦不住, 24 个汉字就会让哈希函数抛异常、登录接口 500。 """ if len(value.encode("utf-8")) > PASSWORD_MAX_BYTES: raise ValueError(f"密码超过 {PASSWORD_MAX_BYTES} 字节上限") return value class UserBrief(BaseModel): """用户简要信息。""" model_config = ConfigDict(from_attributes=True) id: int username: str real_name: str | None = None roles: list[str] = Field(default_factory=list, description="角色编码列表") can_write_payroll: bool = False payroll_approval_enabled: bool = False permissions: list[str] = Field(default_factory=list) authorization_revision: int = 0 is_payroll_maker: bool = False maker_required_permissions: list[str] = Field(default_factory=list) class LoginOut(BaseModel): """登录成功响应。""" access_token: str token_type: str = "Bearer" expires_in: int = Field(description="有效期(秒)") user: UserBrief class CurrentUserOut(UserBrief): """当前登录用户信息。""" last_login_time: datetime | None = None