蜜恋后端
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

409 lines
13 KiB

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"