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
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"
|
|
|