www_site/services/api/app/schemas.py

259 lines
8.7 KiB
Python

from datetime import datetime
from typing import Any, Literal
from uuid import UUID
from pydantic import BaseModel, EmailStr, Field, field_validator, model_validator
class RegisterRequest(BaseModel):
username: str = Field(min_length=3, max_length=32, pattern=r"^[A-Za-z0-9_\-]+$")
email: EmailStr | None = None
phone: str | None = Field(default=None, min_length=11, max_length=20)
verification_channel: Literal["email", "phone"]
captcha_id: str = Field(min_length=10, max_length=100)
captcha_code: str = Field(min_length=4, max_length=8)
password: str = Field(min_length=8, max_length=128)
@field_validator("phone")
@classmethod
def normalize_phone(cls, value: str | None) -> str | None:
if value is None:
return None
normalized = value.strip().replace(" ", "").replace("-", "")
if normalized.startswith("+86"):
normalized = normalized[3:]
if not normalized.isdigit() or len(normalized) != 11 or not normalized.startswith("1"):
raise ValueError("请输入有效的中国大陆手机号")
return normalized
@model_validator(mode="after")
def validate_channel(self) -> "RegisterRequest":
if not self.email and not self.phone:
raise ValueError("邮箱或手机号至少填写一项")
if self.verification_channel == "email" and not self.email:
raise ValueError("邮箱验证需要填写邮箱")
if self.verification_channel == "phone" and not self.phone:
raise ValueError("短信验证需要填写手机号")
return self
class LoginRequest(BaseModel):
identifier: str = Field(min_length=3, max_length=120)
captcha_id: str = Field(min_length=10, max_length=100)
captcha_code: str = Field(min_length=4, max_length=8)
password: str = Field(min_length=1, max_length=128)
class PasswordChangeRequest(BaseModel):
current_password: str = Field(min_length=1, max_length=128)
new_password: str = Field(min_length=8, max_length=128)
class AdminUserCreateRequest(BaseModel):
username: str = Field(min_length=3, max_length=32, pattern=r"^[A-Za-z0-9_\-]+$")
email: EmailStr | None = None
phone: str | None = Field(default=None, min_length=11, max_length=20)
password: str = Field(min_length=8, max_length=128)
email_verified: bool = False
phone_verified: bool = False
reason: str = Field(min_length=1, max_length=500)
@field_validator("phone")
@classmethod
def normalize_phone(cls, value: str | None) -> str | None:
if value is None:
return None
normalized = value.strip().replace(" ", "").replace("-", "")
if normalized.startswith("+86"):
normalized = normalized[3:]
if not normalized.isdigit() or len(normalized) != 11 or not normalized.startswith("1"):
raise ValueError("请输入有效的中国大陆手机号")
return normalized
@model_validator(mode="after")
def validate_contact(self) -> "AdminUserCreateRequest":
if not self.email and not self.phone:
raise ValueError("邮箱或手机号至少填写一项")
return self
class AdminUserUpdateRequest(BaseModel):
username: str = Field(min_length=3, max_length=32, pattern=r"^[A-Za-z0-9_\-]+$")
email: EmailStr | None = None
phone: str | None = Field(default=None, min_length=11, max_length=20)
email_verified: bool = False
phone_verified: bool = False
reason: str = Field(min_length=1, max_length=500)
@field_validator("phone")
@classmethod
def normalize_phone(cls, value: str | None) -> str | None:
if value is None:
return None
normalized = value.strip().replace(" ", "").replace("-", "")
if normalized.startswith("+86"):
normalized = normalized[3:]
if not normalized.isdigit() or len(normalized) != 11 or not normalized.startswith("1"):
raise ValueError("请输入有效的中国大陆手机号")
return normalized
@model_validator(mode="after")
def validate_contact(self) -> "AdminUserUpdateRequest":
if not self.email and not self.phone:
raise ValueError("邮箱或手机号至少填写一项")
return self
class UserProfileUpdateRequest(BaseModel):
username: str = Field(min_length=3, max_length=32, pattern=r"^[A-Za-z0-9_\-]+$")
email: EmailStr | None = None
phone: str | None = Field(default=None, min_length=11, max_length=20)
@field_validator("phone")
@classmethod
def normalize_phone(cls, value: str | None) -> str | None:
if value is None:
return None
normalized = value.strip().replace(" ", "").replace("-", "")
if normalized.startswith("+86"):
normalized = normalized[3:]
if not normalized.isdigit() or len(normalized) != 11 or not normalized.startswith("1"):
raise ValueError("请输入有效的中国大陆手机号")
return normalized
@model_validator(mode="after")
def validate_contact(self) -> "UserProfileUpdateRequest":
if not self.email and not self.phone:
raise ValueError("邮箱或手机号至少填写一项")
return self
class UserPublic(BaseModel):
id: UUID
username: str
email: str | None
phone: str | None
role: Literal["user", "admin"]
plan: Literal["free", "vip"]
status: Literal["active", "disabled"]
email_verified: bool
phone_verified: bool
created_at: datetime
last_login_at: datetime | None
class AuthResponse(BaseModel):
user: UserPublic
class RegisterResponse(AuthResponse):
verification_required: bool
challenge_id: UUID
class MembershipRequest(BaseModel):
plan: Literal["free", "vip"]
starts_at: datetime | None = None
expires_at: datetime | None = None
reason: str = Field(min_length=1, max_length=500)
class StatusRequest(BaseModel):
status: Literal["active", "disabled"]
reason: str = Field(min_length=1, max_length=500)
class QuotaAdjustmentRequest(BaseModel):
amount: int
reason: str = Field(min_length=1, max_length=500)
idempotency_key: str = Field(min_length=8, max_length=120)
class MembershipRevokeRequest(BaseModel):
reason: str = Field(min_length=1, max_length=500)
class TtsSettingsUpdate(BaseModel):
upstream_url: str = Field(default="", max_length=500)
api_key: str | None = Field(default=None, max_length=2000)
timeout_seconds: int = Field(default=120, ge=5, le=600)
default_model: str = Field(default="qwen3-tts", min_length=1, max_length=100)
reason: str = Field(default="", max_length=500)
class TtsSettingsTest(BaseModel):
upstream_url: str | None = Field(default=None, max_length=500)
api_key: str | None = Field(default=None, max_length=2000)
timeout_seconds: int | None = Field(default=None, ge=5, le=600)
default_model: str | None = Field(default=None, max_length=100)
text: str | None = Field(default=None, max_length=50)
class TtsFileNameRequest(BaseModel):
file_name: str = Field(default="", max_length=60)
class AdminTaskFilter(BaseModel):
user_id: UUID | None = None
status: Literal["queued", "running", "succeeded", "failed"] | None = None
created_after: datetime | None = None
created_before: datetime | None = None
page: int = Field(default=1, ge=1)
limit: int = Field(default=20, ge=1, le=100)
class AdminUserFilter(BaseModel):
email: str | None = None
phone: str | None = None
status: Literal["active", "disabled"] | None = None
page: int = Field(default=1, ge=1)
limit: int = Field(default=20, ge=1, le=100)
class VerificationConfirmRequest(BaseModel):
challenge_id: UUID
code: str = Field(min_length=4, max_length=12)
class VerificationSendRequest(BaseModel):
channel: Literal["email", "phone"]
purpose: Literal["registration", "contact_binding", "contact_change", "password_reset"]
class VerificationResendRequest(BaseModel):
challenge_id: UUID
class TtsTaskRequest(BaseModel):
text: str = Field(min_length=1, max_length=10000)
voice_id: str = Field(min_length=1, max_length=120)
parameters: dict[str, Any] = Field(default_factory=dict)
class TtsVoicePublic(BaseModel):
id: UUID
provider_voice_id: str
name: str
language: str | None
description: str | None
supported_parameters: dict[str, Any]
class TtsTaskPublic(BaseModel):
id: UUID
status: str
text_length: int
voice_id: str
parameters: dict[str, Any]
error_code: str | None = None
audio_available: bool = False
audio_expires_at: datetime | None = None
file_name: str | None = None
created_at: datetime
started_at: datetime | None = None
finished_at: datetime | None = None
def normalize_email(value: str) -> str:
return value.strip().lower()