from datetime import datetime from typing import Annotated, Literal, Optional, Union from pydantic import BaseModel, ConfigDict, EmailStr, Field, StringConstraints, model_validator EmailOrPhone = Annotated[str, StringConstraints(strip_whitespace=True, min_length=3, max_length=320)] BioText = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=2000)] RelationshipGoal = Literal["serious_relationship", "casual_dates", "new_friends", "marriage"] class RegisterRequest(BaseModel): email_or_phone: EmailOrPhone password: str = Field(min_length=8, max_length=128) confirm_password: str = Field(min_length=8, max_length=128) relationship_goal: RelationshipGoal full_name: Optional[str] = Field(default=None, max_length=120) gender: Optional[str] = Field(default=None, max_length=30) birth_date: Optional[str] = Field(default=None, pattern=r"^\d{4}-\d{2}-\d{2}$") city: Optional[str] = Field(default=None, max_length=120) country: Optional[str] = Field(default=None, max_length=120) bio: Optional[BioText] = None interests: Optional[list[str]] = None @model_validator(mode="after") def validate_password_and_interests(self): if self.password != self.confirm_password: raise ValueError("密码和确认密码不一致") if self.interests is not None: self.interests = normalize_interests(self.interests) if len(self.interests) < 2: raise ValueError("至少选择 2 个兴趣") return self class LoginRequest(BaseModel): email_or_phone: EmailOrPhone password: str = Field(min_length=8, max_length=128) class RefreshRequest(BaseModel): refresh_token: str = Field(min_length=20, max_length=200) class OAuthRequest(BaseModel): provider: str = Field(pattern="^(wechat|google)$") provider_user_id: str = Field(min_length=1, max_length=255) email: Optional[EmailStr] = None full_name: Optional[str] = Field(default=None, max_length=120) class ProfileUpdate(BaseModel): full_name: Optional[str] = Field(default=None, max_length=120) gender: Optional[str] = Field(default=None, max_length=30) birth_date: Optional[str] = Field(default=None, pattern=r"^\d{4}-\d{2}-\d{2}$") city: Optional[str] = Field(default=None, max_length=120) country: Optional[str] = Field(default=None, max_length=120) relationship_goal: RelationshipGoal = Field(default=None) bio: Optional[BioText] = None occupation: Optional[str] = Field(default=None, max_length=120) education: Optional[str] = Field(default=None, max_length=120) height_cm: Optional[int] = Field(default=None, ge=100, le=250) beliefs: Optional[str] = Field(default=None, max_length=120) smoking: Optional[str] = Field(default=None, max_length=50) drinking: Optional[str] = Field(default=None, max_length=50) pets: Optional[str] = Field(default=None, max_length=120) exercise: Optional[str] = Field(default=None, max_length=50) interests: Optional[list[str]] = None @model_validator(mode="after") def validate_interests(self): if self.interests is not None: self.interests = normalize_interests(self.interests) if len(self.interests) < 2: raise ValueError("至少选择 2 个兴趣") return self class InterestsUpdate(BaseModel): interests: list[str] @model_validator(mode="after") def validate_interests(self): self.interests = normalize_interests(self.interests) if len(self.interests) < 2: raise ValueError("至少选择 2 个兴趣") return self class InterestsResponse(BaseModel): interests: list[str] def normalize_interests(values: list[str]) -> list[str]: normalized: list[str] = [] seen: set[str] = set() for value in values: item = value.strip() if not item: raise ValueError("兴趣内容不能为空") if len(item) > 120: raise ValueError("单个兴趣不能超过 120 个字符") key = item.casefold() if key not in seen: normalized.append(item) seen.add(key) return normalized class LocationUpdate(BaseModel): latitude: float = Field(ge=-90, le=90) longitude: float = Field(ge=-180, le=180) city: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=120)] county: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=120)] class LocationResponse(LocationUpdate): updated_at: datetime model_config = ConfigDict(from_attributes=True) class SwipeRequest(BaseModel): target_user_id: int = Field(gt=0) direction: Literal["like", "pass", "super_like"] class SwipeResponse(BaseModel): direction: Literal["like", "pass", "super_like"] matched: bool match_id: Optional[int] = None class PublicMediaResponse(BaseModel): id: int media_type: str url: str class DiscoveryUserResponse(BaseModel): id: int full_name: Optional[str] = None gender: Optional[str] = None birth_date: Optional[str] = None city: Optional[str] = None county: Optional[str] = None country: Optional[str] = None relationship_goal: Optional[RelationshipGoal] = None bio: Optional[str] = None profile_image_url: Optional[str] = None video_url: Optional[str] = None distance_km: Optional[float] = None compatibility_percentage: int = Field(default=0, ge=0, le=100) media: list[PublicMediaResponse] = Field(default_factory=list) class MatchResponse(BaseModel): id: int matched_at: datetime user: DiscoveryUserResponse class PostLocation(BaseModel): name: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=255)] latitude: Optional[float] = Field(default=None, ge=-90, le=90) longitude: Optional[float] = Field(default=None, ge=-180, le=180) @model_validator(mode="after") def validate_coordinates(self): if (self.latitude is None) != (self.longitude is None): raise ValueError("地点经纬度必须同时提供") return self class PostCreate(BaseModel): content: str = Field(default="", max_length=5000) post_type: Literal["text", "image", "video"] = "text" content_format: Literal["plain", "markdown"] = "plain" feeling: Optional[Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=50)]] = None location: Optional[PostLocation] = None @model_validator(mode="after") def validate_post_content(self): if self.post_type == "text" and not (self.content.strip() or self.feeling or self.location): raise ValueError("文字帖子至少需要文字、心情或地点") return self class PostUpdate(BaseModel): content: str = Field(default=None, max_length=5000) content_format: Literal["plain", "markdown"] = None feeling: Optional[Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=50)]] = None location: Optional[PostLocation] = None class PostAuthorResponse(BaseModel): id: int full_name: Optional[str] = None profile_image_url: Optional[str] = Field( default=None, description="Author avatar URL; null before upload. Production OSS URLs expire after one hour.", ) city: Optional[str] = None country: Optional[str] = None compatibility_percentage: int = Field(default=0, ge=0, le=100) class PostLikerResponse(BaseModel): """Public post liker identity, without contact details or full profile fields.""" id: int full_name: Optional[str] = None class PostLikeResponse(BaseModel): """Compact like result; update the existing post rather than replacing it.""" message: str like_count: int liked_by_me: bool liked_by_users: list[PostLikerResponse] = Field( default_factory=list, description="All active likers, newest like first; like_count equals this list's length.", ) class PostUnlikeResponse(BaseModel): """Compact unlike result; mirrors the like result without returning the full post body.""" message: str like_count: int liked_by_me: bool liked_by_users: list[PostLikerResponse] = Field( default_factory=list, description="All active likers, newest like first; like_count equals this list's length.", ) class PostMediaResponse(BaseModel): id: int media_type: Literal["image", "video"] mime_type: str size_bytes: int position: int url: str class PostResponse(BaseModel): id: int content: str post_type: str content_format: Literal["plain", "markdown"] feeling: Optional[str] = None location: Optional[PostLocation] = None author: PostAuthorResponse like_count: int comment_count: int liked_by_me: bool liked_by_users: list[PostLikerResponse] = Field( default_factory=list, description="All active likers, newest like first; like_count equals this list's length.", ) media: list[PostMediaResponse] = Field(default_factory=list) created_at: datetime class PublicUserProfileResponse(DiscoveryUserResponse): """Public profile details safe to show on another user's profile page.""" interests: list[str] = Field(default_factory=list) posts: list[PostResponse] = Field(default_factory=list) class CommentCreate(BaseModel): content: str = Field(min_length=1, max_length=2000) parent_comment_id: Optional[int] = Field(default=None, gt=0) class CommentUpdate(BaseModel): content: str = Field(min_length=1, max_length=2000) class CommentResponse(BaseModel): id: int post_id: int content: str author: PostAuthorResponse parent_comment_id: Optional[int] = None reply_count: int = 0 like_count: int = 0 liked_by_me: bool = False created_at: datetime class CommentLikeResponse(BaseModel): message: str like_count: int liked_by_me: bool class ChatMessageCreate(BaseModel): content: str = Field(min_length=1, max_length=5000) message_type: Literal["text"] = "text" class ChatMessageResponse(BaseModel): id: int conversation_id: int sender_id: int content: Optional[str] message_type: str is_deleted: bool created_at: datetime class ChatParticipantResponse(BaseModel): id: int full_name: Optional[str] = None city: Optional[str] = None country: Optional[str] = None class ConversationResponse(BaseModel): id: Optional[int] = None match_id: int other_user: ChatParticipantResponse last_message: Optional[ChatMessageResponse] = None created_at: Optional[datetime] = None updated_at: Optional[datetime] = None class QuestionAnswerUpdate(BaseModel): answer: Union[bool, str] @model_validator(mode="after") def validate_answer(self): if isinstance(self.answer, str) and not self.answer.strip(): raise ValueError("回答内容不能为空") return self class FeedQuestionResponse(BaseModel): id: int code: str question_text: str question_text_zh: Optional[str] answer_type: Literal["boolean", "text"] current_answer: Optional[Union[bool, str]] = None answered_at: Optional[datetime] = None class QuestionAnswerResponse(BaseModel): question_id: int answer: Union[bool, str] answered_at: datetime class ProfileResponse(BaseModel): model_config = ConfigDict(from_attributes=True) full_name: Optional[str] gender: Optional[str] birth_date: Optional[str] city: Optional[str] county: Optional[str] country: Optional[str] relationship_goal: Optional[RelationshipGoal] bio: Optional[str] occupation: Optional[str] = None education: Optional[str] = None height_cm: Optional[int] = None beliefs: Optional[str] = None smoking: Optional[str] = None drinking: Optional[str] = None pets: Optional[str] = None exercise: Optional[str] = None age: Optional[int] = None profile_image_url: Optional[str] video_url: Optional[str] interests: list[str] = Field(default_factory=list) class UserResponse(BaseModel): id: int email_or_phone: Optional[str] status: str profile: Optional[ProfileResponse] created_at: datetime posts: list[PostResponse] = Field(default_factory=list) liked_users: list[DiscoveryUserResponse] = Field(default_factory=list) liked_by_users: list[DiscoveryUserResponse] = Field(default_factory=list) matches: list[MatchResponse] = Field(default_factory=list) model_config = ConfigDict(from_attributes=True) class UserActivityResponse(UserResponse): liked_users_count: int = Field(ge=0) liked_by_users_count: int = Field(ge=0) matches_count: int = Field(ge=0) class TokenResponse(BaseModel): access_token: str refresh_token: Optional[str] = None token_type: str = "bearer" user: UserResponse class RegisterTokenResponse(BaseModel): """Tokens returned after registration.""" access_token: str refresh_token: Optional[str] = None token_type: str = "bearer"