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.
1597 lines
64 KiB
1597 lines
64 KiB
import logging
|
|
import math
|
|
import secrets
|
|
from contextlib import asynccontextmanager
|
|
from datetime import date, datetime, timedelta
|
|
from pathlib import Path
|
|
from typing import Optional, Union
|
|
|
|
from fastapi import Depends, FastAPI, File, HTTPException, Query, UploadFile
|
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
from jose import JWTError, jwt
|
|
from sqlalchemy import and_, case, delete, desc, func, or_, select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from .config import settings
|
|
from .database import Base, SessionLocal, engine, get_db
|
|
from .models import (AuthIdentity, ChatMessage, CommentLike, Conversation, FeedQuestion, Match, Post,
|
|
PostComment, PostLike, PostMedia, Swipe, User, UserLocation, UserMedia,
|
|
UserInterest, UserProfile, UserQuestionAnswer, UserSession, UserStatus)
|
|
from .schemas import (ChatMessageCreate, ChatMessageResponse, ChatParticipantResponse,
|
|
CommentCreate, CommentLikeResponse, CommentResponse, CommentUpdate, ConversationResponse, DiscoveryUserResponse,
|
|
FeedQuestionResponse, InterestsResponse, InterestsUpdate, LocationResponse, LocationUpdate, LoginRequest,
|
|
MatchResponse, OAuthRequest, PostAuthorResponse, PostCreate,
|
|
PostLikeResponse, PostLikerResponse, PostUnlikeResponse, PostUpdate,
|
|
PostLocation, PostMediaResponse, PostResponse, ProfileUpdate, PublicMediaResponse,
|
|
PublicUserProfileResponse,
|
|
ProfileResponse, QuestionAnswerResponse, QuestionAnswerUpdate, RefreshRequest,
|
|
RegisterRequest, RegisterTokenResponse,
|
|
SwipeRequest, SwipeResponse,
|
|
TokenResponse, UserActivityResponse, UserResponse)
|
|
from .security import ALGORITHM, create_access_token, create_refresh_token, hash_secret, verify_secret
|
|
from .storage import storage
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
bearer = HTTPBearer(auto_error=False)
|
|
UPLOAD_ROOT = Path(settings.upload_dir)
|
|
PUBLIC_MEDIA_TYPES = {"profile_image", "intro_video"}
|
|
POST_IMAGE_TYPES = {"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"}
|
|
POST_VIDEO_TYPES = {"video/mp4": ".mp4", "video/quicktime": ".mov", "video/webm": ".webm"}
|
|
DEFAULT_FEED_QUESTIONS = (
|
|
("weekend_movies", "Do you like watching movies on weekends?", "你喜欢周末看电影吗?", "boolean", 10),
|
|
("outdoor_activities", "Do you enjoy outdoor activities?", "你喜欢户外活动吗?", "boolean", 20),
|
|
("quiet_weekends", "Do you prefer quiet weekends?", "你更喜欢安静的周末吗?", "boolean", 30),
|
|
("long_term_relationship", "Are you looking for a long-term relationship?", "你希望发展长期关系吗?", "boolean", 40),
|
|
("ideal_first_date", "What is your ideal first date?", "你理想中的第一次约会是什么?", "text", 50),
|
|
)
|
|
|
|
|
|
def seed_feed_questions():
|
|
with SessionLocal() as db:
|
|
existing_codes = set(db.scalars(select(FeedQuestion.code)).all())
|
|
for code, text_en, text_zh, answer_type, sort_order in DEFAULT_FEED_QUESTIONS:
|
|
if code not in existing_codes:
|
|
db.add(FeedQuestion(
|
|
code=code,
|
|
question_text=text_en,
|
|
question_text_zh=text_zh,
|
|
answer_type=answer_type,
|
|
sort_order=sort_order,
|
|
))
|
|
db.commit()
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(_app: FastAPI):
|
|
Base.metadata.create_all(bind=engine)
|
|
UPLOAD_ROOT.mkdir(parents=True, exist_ok=True)
|
|
seed_feed_questions()
|
|
yield
|
|
|
|
|
|
app = FastAPI(
|
|
title="Dating App API",
|
|
version="0.6.12",
|
|
servers=[{"url": "http://127.0.0.1:8001", "description": "Local development"}],
|
|
lifespan=lifespan,
|
|
)
|
|
|
|
|
|
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(bearer), db: Session = Depends(get_db)) -> User:
|
|
if not credentials:
|
|
raise HTTPException(status_code=401, detail="需要登录")
|
|
try:
|
|
payload = jwt.decode(credentials.credentials, settings.jwt_secret, algorithms=[ALGORITHM])
|
|
user_id = int(payload.get("sub", ""))
|
|
except (JWTError, ValueError):
|
|
raise HTTPException(status_code=401, detail="登录凭证无效或已过期")
|
|
user = db.get(User, user_id)
|
|
if not user or user.status == UserStatus.suspended:
|
|
raise HTTPException(status_code=401, detail="用户不存在或已被停用")
|
|
return user
|
|
|
|
|
|
def get_active_user(user: User = Depends(get_current_user)) -> User:
|
|
if user.status != UserStatus.active:
|
|
raise HTTPException(status_code=403, detail="账号当前不可用")
|
|
return user
|
|
|
|
|
|
def latest_user_media(user: User, media_type: str) -> Optional[UserMedia]:
|
|
return max(
|
|
(
|
|
item for item in user.media
|
|
if item.media_type == media_type and item.review_status != "rejected"
|
|
),
|
|
key=lambda item: item.id,
|
|
default=None,
|
|
)
|
|
|
|
|
|
def user_media_url(user: User, media_type: str) -> Optional[str]:
|
|
media = latest_user_media(user, media_type)
|
|
return media_access_url(media) if media else None
|
|
|
|
|
|
def media_access_url(media: UserMedia) -> str:
|
|
fallback_url = f"/api/v1/media/{media.id}"
|
|
return storage.access_url(media.storage_path, fallback_url)
|
|
|
|
|
|
def calculate_age(birth_date: Optional[str]) -> Optional[int]:
|
|
if not birth_date:
|
|
return None
|
|
try:
|
|
born = date.fromisoformat(birth_date)
|
|
except ValueError:
|
|
return None
|
|
today = date.today()
|
|
return today.year - born.year - ((today.month, today.day) < (born.month, born.day))
|
|
|
|
|
|
def user_response(user: User, db: Optional[Session] = None, *, include_activity: bool = False) -> UserResponse:
|
|
profile = user.profile
|
|
activity = profile_activity_response(user, db) if include_activity and db is not None else {}
|
|
response_type = UserActivityResponse if include_activity and db is not None else UserResponse
|
|
return response_type(
|
|
id=user.id,
|
|
email_or_phone=user.email_or_phone,
|
|
status=user.status,
|
|
profile=ProfileResponse(
|
|
full_name=profile.full_name if profile else None,
|
|
gender=profile.gender if profile else None,
|
|
birth_date=profile.birth_date if profile else None,
|
|
city=profile.city if profile else None,
|
|
county=profile.county if profile else None,
|
|
country=profile.country if profile else None,
|
|
relationship_goal=profile.relationship_goal if profile else None,
|
|
bio=profile.bio if profile else None,
|
|
occupation=profile.occupation if profile else None,
|
|
education=profile.education if profile else None,
|
|
height_cm=profile.height_cm if profile else None,
|
|
beliefs=profile.beliefs if profile else None,
|
|
smoking=profile.smoking if profile else None,
|
|
drinking=profile.drinking if profile else None,
|
|
pets=profile.pets if profile else None,
|
|
exercise=profile.exercise if profile else None,
|
|
age=calculate_age(profile.birth_date if profile else None),
|
|
profile_image_url=user_media_url(user, "profile_image"),
|
|
video_url=user_media_url(user, "intro_video"),
|
|
interests=[item.name for item in user.interests],
|
|
) if profile or user.interests or user.media else None,
|
|
created_at=user.created_at,
|
|
**activity,
|
|
)
|
|
|
|
|
|
def replace_user_interests(user: User, values: list[str], db: Session):
|
|
db.execute(delete(UserInterest).where(UserInterest.user_id == user.id))
|
|
db.flush()
|
|
db.expire(user, ["interests"])
|
|
user.interests = [UserInterest(name=value) for value in values]
|
|
db.flush()
|
|
|
|
|
|
def issue_token_response(user: User, db: Session) -> TokenResponse:
|
|
refresh_token = create_refresh_token()
|
|
db.add(UserSession(
|
|
user_id=user.id,
|
|
refresh_token_hash=hash_secret(refresh_token),
|
|
expires_at=datetime.utcnow() + timedelta(days=settings.refresh_token_expire_days),
|
|
))
|
|
db.commit()
|
|
return TokenResponse(
|
|
access_token=create_access_token(user.id),
|
|
refresh_token=refresh_token,
|
|
user=user_response(user),
|
|
)
|
|
|
|
|
|
def find_refresh_session(refresh_token: str, db: Session) -> Optional[UserSession]:
|
|
candidates = db.scalars(select(UserSession).where(
|
|
UserSession.revoked_at.is_(None),
|
|
UserSession.expires_at > datetime.utcnow(),
|
|
)).all()
|
|
for session in candidates:
|
|
if verify_secret(refresh_token, session.refresh_token_hash):
|
|
return session
|
|
return None
|
|
|
|
|
|
def normalized_text(value: Optional[str]) -> Optional[str]:
|
|
if value is None:
|
|
return None
|
|
normalized = value.strip().casefold()
|
|
return normalized or None
|
|
|
|
|
|
def compatibility_percentage(current_user: User, candidate: User) -> int:
|
|
"""Score common interests and mutually answered Feed questions on a 0-100 scale."""
|
|
components: list[float] = []
|
|
|
|
current_interests = {item.name.strip().casefold() for item in current_user.interests}
|
|
candidate_interests = {item.name.strip().casefold() for item in candidate.interests}
|
|
if current_interests and candidate_interests:
|
|
union = current_interests | candidate_interests
|
|
components.append(len(current_interests & candidate_interests) / len(union))
|
|
|
|
current_answers = {item.question_id: answer_value(item) for item in current_user.question_answers}
|
|
candidate_answers = {item.question_id: answer_value(item) for item in candidate.question_answers}
|
|
common_questions = current_answers.keys() & candidate_answers.keys()
|
|
if common_questions:
|
|
matches = 0
|
|
for question_id in common_questions:
|
|
current_value = current_answers[question_id]
|
|
candidate_value = candidate_answers[question_id]
|
|
if isinstance(current_value, str) and isinstance(candidate_value, str):
|
|
is_match = normalized_text(current_value) == normalized_text(candidate_value)
|
|
else:
|
|
is_match = current_value == candidate_value
|
|
matches += int(is_match)
|
|
components.append(matches / len(common_questions))
|
|
|
|
if not components:
|
|
return 0
|
|
return round(sum(components) / len(components) * 100)
|
|
|
|
|
|
def discovery_priority(current_user: User, candidate: User) -> int:
|
|
current_profile = current_user.profile
|
|
candidate_profile = candidate.profile
|
|
if not current_profile or not candidate_profile:
|
|
return 2
|
|
current_city = normalized_text(current_profile.city)
|
|
candidate_city = normalized_text(candidate_profile.city)
|
|
if current_city and current_city == candidate_city:
|
|
return 0
|
|
current_country = normalized_text(current_profile.country)
|
|
candidate_country = normalized_text(candidate_profile.country)
|
|
if current_country and current_country == candidate_country:
|
|
return 1
|
|
return 2
|
|
|
|
|
|
def discovery_sort_key(current_user: User, candidate: User):
|
|
return (
|
|
discovery_priority(current_user, candidate),
|
|
-compatibility_percentage(current_user, candidate),
|
|
-candidate.id,
|
|
)
|
|
|
|
|
|
def public_user_response(user: User, current_user: Optional[User] = None,
|
|
distance_km: Optional[float] = None) -> DiscoveryUserResponse:
|
|
profile = user.profile
|
|
latest_public_media = [
|
|
item for media_type in ("profile_image", "intro_video")
|
|
if (item := latest_user_media(user, media_type)) is not None
|
|
]
|
|
media = [
|
|
PublicMediaResponse(id=item.id, media_type=item.media_type, url=media_access_url(item))
|
|
for item in latest_public_media
|
|
]
|
|
media_by_type = {item.media_type: item for item in latest_public_media}
|
|
return DiscoveryUserResponse(
|
|
id=user.id,
|
|
full_name=profile.full_name if profile else None,
|
|
gender=profile.gender if profile else None,
|
|
birth_date=profile.birth_date if profile else None,
|
|
city=profile.city if profile else None,
|
|
county=profile.county if profile else None,
|
|
country=profile.country if profile else None,
|
|
relationship_goal=profile.relationship_goal if profile else None,
|
|
bio=profile.bio if profile else None,
|
|
profile_image_url=(
|
|
media_access_url(media_by_type["profile_image"])
|
|
if "profile_image" in media_by_type else None
|
|
),
|
|
video_url=(
|
|
media_access_url(media_by_type["intro_video"])
|
|
if "intro_video" in media_by_type else None
|
|
),
|
|
distance_km=round(distance_km, 2) if distance_km is not None else None,
|
|
compatibility_percentage=compatibility_percentage(current_user, user) if current_user else 0,
|
|
media=media,
|
|
)
|
|
|
|
|
|
def distance_between(latitude: float, longitude: float, location: UserLocation) -> float:
|
|
earth_radius_km = 6371.0088
|
|
lat1, lat2 = math.radians(latitude), math.radians(location.latitude)
|
|
delta_lat = lat2 - lat1
|
|
delta_lon = math.radians(location.longitude - longitude)
|
|
value = math.sin(delta_lat / 2) ** 2 + math.cos(lat1) * math.cos(lat2) * math.sin(delta_lon / 2) ** 2
|
|
return 2 * earth_radius_km * math.asin(math.sqrt(value))
|
|
|
|
|
|
def post_author_response(user: User, current_user: Optional[User] = None) -> PostAuthorResponse:
|
|
profile = user.profile
|
|
return PostAuthorResponse(
|
|
id=user.id,
|
|
full_name=profile.full_name if profile else None,
|
|
profile_image_url=user_media_url(user, "profile_image"),
|
|
city=profile.city if profile else None,
|
|
country=profile.country if profile else None,
|
|
compatibility_percentage=compatibility_percentage(current_user, user) if current_user else 0,
|
|
)
|
|
|
|
|
|
def post_likers(post: Post) -> list[PostLikerResponse]:
|
|
return [
|
|
PostLikerResponse(
|
|
id=item.user_id,
|
|
full_name=item.user.profile.full_name if item.user.profile else None,
|
|
)
|
|
for item in sorted(post.likes, key=lambda like: like.id, reverse=True)
|
|
if item.user.status == UserStatus.active
|
|
]
|
|
|
|
|
|
def post_response(post: Post, current_user: User) -> PostResponse:
|
|
likers = post_likers(post)
|
|
return PostResponse(
|
|
id=post.id,
|
|
content=post.content,
|
|
post_type=post.post_type,
|
|
content_format=post.content_format,
|
|
feeling=post.feeling,
|
|
location=PostLocation(
|
|
name=post.location_name,
|
|
latitude=post.location_latitude,
|
|
longitude=post.location_longitude,
|
|
) if post.location_name else None,
|
|
author=post_author_response(post.user, current_user),
|
|
like_count=len(likers),
|
|
comment_count=len(post.comments),
|
|
liked_by_me=any(item.id == current_user.id for item in likers),
|
|
liked_by_users=likers,
|
|
media=[PostMediaResponse(
|
|
id=item.id,
|
|
media_type=item.media_type,
|
|
mime_type=item.mime_type,
|
|
size_bytes=item.size_bytes,
|
|
position=item.position,
|
|
url=storage.access_url(item.storage_path, f"/api/v1/explore/media/{item.id}"),
|
|
) for item in post.media],
|
|
created_at=post.created_at,
|
|
)
|
|
|
|
|
|
def matched_user_ids(user: User):
|
|
other_user_id = case((Match.user1_id == user.id, Match.user2_id), else_=Match.user1_id)
|
|
return select(other_user_id).where(
|
|
or_(Match.user1_id == user.id, Match.user2_id == user.id)
|
|
)
|
|
|
|
|
|
def liked_users(user: User, db: Session, *, received: bool = False,
|
|
limit: Optional[int] = 20, offset: int = 0) -> list[User]:
|
|
id_column = Swipe.actor_id if received else Swipe.target_id
|
|
owner_filter = Swipe.target_id == user.id if received else Swipe.actor_id == user.id
|
|
related_ids = list(db.scalars(
|
|
select(id_column).join(User, User.id == id_column).where(
|
|
owner_filter,
|
|
Swipe.direction.in_(("like", "super_like")),
|
|
User.status == UserStatus.active,
|
|
id_column.notin_(matched_user_ids(user)),
|
|
).order_by(Swipe.updated_at.desc(), Swipe.id.desc()).offset(offset).limit(limit)
|
|
).all())
|
|
if not related_ids:
|
|
return []
|
|
users_by_id = {
|
|
item.id: item for item in db.scalars(select(User).where(
|
|
User.id.in_(related_ids),
|
|
User.status == UserStatus.active,
|
|
)).all()
|
|
}
|
|
return [users_by_id[user_id] for user_id in related_ids if user_id in users_by_id]
|
|
|
|
|
|
def user_matches(user: User, db: Session, *, limit: Optional[int] = 20, offset: int = 0) -> list[MatchResponse]:
|
|
other_user_id = case((Match.user1_id == user.id, Match.user2_id), else_=Match.user1_id)
|
|
statement = (
|
|
select(Match).join(User, User.id == other_user_id).where(
|
|
or_(Match.user1_id == user.id, Match.user2_id == user.id),
|
|
User.status == UserStatus.active,
|
|
)
|
|
.order_by(Match.matched_at.desc(), Match.id.desc()).offset(offset)
|
|
)
|
|
if limit is not None:
|
|
statement = statement.limit(limit)
|
|
matches = db.scalars(statement).all()
|
|
result: list[MatchResponse] = []
|
|
for item in matches:
|
|
other_id = item.user2_id if item.user1_id == user.id else item.user1_id
|
|
other = db.get(User, other_id)
|
|
if other and other.status == UserStatus.active:
|
|
result.append(MatchResponse(
|
|
id=item.id,
|
|
matched_at=item.matched_at,
|
|
user=public_user_response(other, user),
|
|
))
|
|
return result
|
|
|
|
|
|
def user_posts(user: User, current_user: User, db: Session, *, limit: int = 20,
|
|
offset: int = 0) -> list[PostResponse]:
|
|
posts = db.scalars(
|
|
select(Post).where(Post.user_id == user.id)
|
|
.order_by(Post.id.desc()).offset(offset).limit(limit)
|
|
).unique().all()
|
|
return [post_response(post, current_user) for post in posts]
|
|
|
|
|
|
def public_user_profile_response(user: User, current_user: User, db: Session) -> PublicUserProfileResponse:
|
|
"""Return public profile details without private activity or contact fields."""
|
|
|
|
base = public_user_response(user, current_user)
|
|
return PublicUserProfileResponse(
|
|
**base.model_dump(),
|
|
interests=[item.name for item in user.interests],
|
|
posts=user_posts(user, current_user, db),
|
|
)
|
|
|
|
|
|
def profile_activity_response(user: User, db: Session) -> dict:
|
|
sent = liked_users(user, db)
|
|
received = liked_users(user, db, received=True)
|
|
active_likes = Swipe.direction.in_(("like", "super_like"))
|
|
liked_users_count = db.scalar(
|
|
select(func.count(Swipe.id)).join(User, User.id == Swipe.target_id).where(
|
|
Swipe.actor_id == user.id, active_likes, User.status == UserStatus.active,
|
|
Swipe.target_id.notin_(matched_user_ids(user)),
|
|
)
|
|
)
|
|
liked_by_users_count = db.scalar(
|
|
select(func.count(Swipe.id)).join(User, User.id == Swipe.actor_id).where(
|
|
Swipe.target_id == user.id, active_likes, User.status == UserStatus.active,
|
|
Swipe.actor_id.notin_(matched_user_ids(user)),
|
|
)
|
|
)
|
|
other_user_id = case((Match.user1_id == user.id, Match.user2_id), else_=Match.user1_id)
|
|
matches_count = db.scalar(
|
|
select(func.count(Match.id)).join(User, User.id == other_user_id).where(
|
|
or_(Match.user1_id == user.id, Match.user2_id == user.id),
|
|
User.status == UserStatus.active,
|
|
)
|
|
)
|
|
return {
|
|
"posts": user_posts(user, user, db),
|
|
"liked_users": [public_user_response(item, user) for item in sent],
|
|
"liked_by_users": [public_user_response(item, user) for item in received],
|
|
"matches": user_matches(user, db),
|
|
"liked_users_count": liked_users_count,
|
|
"liked_by_users_count": liked_by_users_count,
|
|
"matches_count": matches_count,
|
|
}
|
|
|
|
|
|
def get_post(post_id: int, db: Session) -> Post:
|
|
post = db.get(Post, post_id)
|
|
if not post or post.user.status != UserStatus.active:
|
|
raise HTTPException(status_code=404, detail="帖子不存在")
|
|
return post
|
|
|
|
|
|
def ensure_post_author(post: Post, user: User):
|
|
if post.user_id != user.id:
|
|
raise HTTPException(status_code=403, detail="只能操作自己的帖子")
|
|
|
|
|
|
def answer_value(answer: UserQuestionAnswer) -> Union[bool, str]:
|
|
if answer.boolean_answer is not None:
|
|
return answer.boolean_answer
|
|
return answer.text_answer or ""
|
|
|
|
|
|
def feed_question_response(question: FeedQuestion,
|
|
answer: Optional[UserQuestionAnswer] = None) -> FeedQuestionResponse:
|
|
return FeedQuestionResponse(
|
|
id=question.id,
|
|
code=question.code,
|
|
question_text=question.question_text,
|
|
question_text_zh=question.question_text_zh,
|
|
answer_type=question.answer_type,
|
|
current_answer=answer_value(answer) if answer else None,
|
|
answered_at=answer.answered_at if answer else None,
|
|
)
|
|
|
|
|
|
def chat_participant_response(user: User) -> ChatParticipantResponse:
|
|
profile = user.profile
|
|
return ChatParticipantResponse(
|
|
id=user.id,
|
|
full_name=profile.full_name if profile else None,
|
|
city=profile.city if profile else None,
|
|
country=profile.country if profile else None,
|
|
)
|
|
|
|
|
|
def chat_message_response(message: ChatMessage) -> ChatMessageResponse:
|
|
return ChatMessageResponse(
|
|
id=message.id,
|
|
conversation_id=message.conversation_id,
|
|
sender_id=message.sender_id,
|
|
content=None if message.deleted_at else message.content,
|
|
message_type=message.message_type,
|
|
is_deleted=message.deleted_at is not None,
|
|
created_at=message.created_at,
|
|
)
|
|
|
|
|
|
def get_user_match(match_id: int, user: User, db: Session) -> Match:
|
|
match = db.get(Match, match_id)
|
|
if not match or user.id not in (match.user1_id, match.user2_id):
|
|
raise HTTPException(status_code=404, detail="匹配关系不存在,无法聊天")
|
|
other_id = match.user2_id if match.user1_id == user.id else match.user1_id
|
|
other = db.get(User, other_id)
|
|
if not other or other.status != UserStatus.active:
|
|
raise HTTPException(status_code=404, detail="聊天对象不存在")
|
|
return match
|
|
|
|
|
|
def get_user_conversation(conversation_id: int, user: User, db: Session) -> Conversation:
|
|
conversation = db.get(Conversation, conversation_id)
|
|
if not conversation:
|
|
raise HTTPException(status_code=404, detail="会话不存在")
|
|
get_user_match(conversation.match_id, user, db)
|
|
return conversation
|
|
|
|
|
|
def conversation_response(conversation: Optional[Conversation], match: Match, user: User,
|
|
db: Session) -> ConversationResponse:
|
|
other_id = match.user2_id if match.user1_id == user.id else match.user1_id
|
|
other = db.get(User, other_id)
|
|
latest = None
|
|
if conversation and conversation.messages:
|
|
latest = max(conversation.messages, key=lambda item: item.id)
|
|
return ConversationResponse(
|
|
id=conversation.id if conversation else None,
|
|
match_id=match.id,
|
|
other_user=chat_participant_response(other),
|
|
last_message=chat_message_response(latest) if latest else None,
|
|
created_at=conversation.created_at if conversation else None,
|
|
updated_at=conversation.updated_at if conversation else None,
|
|
)
|
|
|
|
|
|
@app.get("/health")
|
|
def health():
|
|
return {"status": "ok"}
|
|
|
|
|
|
def normalize_email_or_phone(value: str) -> str:
|
|
value = value.strip()
|
|
return value.lower() if "@" in value else value
|
|
|
|
|
|
@app.post("/api/v1/auth/register", response_model=RegisterTokenResponse, status_code=201)
|
|
def register(payload: RegisterRequest, db: Session = Depends(get_db)):
|
|
email_or_phone = normalize_email_or_phone(payload.email_or_phone)
|
|
duplicate = db.scalar(select(User).where(User.email_or_phone == email_or_phone))
|
|
if duplicate:
|
|
raise HTTPException(status_code=409, detail="邮箱或手机号已注册")
|
|
user = User(
|
|
email_or_phone=email_or_phone,
|
|
password_hash=hash_secret(payload.password),
|
|
status=UserStatus.active,
|
|
)
|
|
user.profile = UserProfile(
|
|
full_name=payload.full_name,
|
|
gender=payload.gender,
|
|
birth_date=payload.birth_date,
|
|
city=payload.city,
|
|
country=payload.country,
|
|
relationship_goal=payload.relationship_goal,
|
|
bio=payload.bio,
|
|
)
|
|
if payload.interests:
|
|
user.interests = [UserInterest(name=value) for value in payload.interests]
|
|
db.add(user)
|
|
db.commit()
|
|
db.refresh(user)
|
|
token_response = issue_token_response(user, db)
|
|
return RegisterTokenResponse(
|
|
access_token=token_response.access_token,
|
|
refresh_token=token_response.refresh_token,
|
|
token_type=token_response.token_type,
|
|
)
|
|
|
|
|
|
@app.post("/api/v1/auth/login", response_model=TokenResponse)
|
|
def login(payload: LoginRequest, db: Session = Depends(get_db)):
|
|
email_or_phone = normalize_email_or_phone(payload.email_or_phone)
|
|
user = db.scalar(select(User).where(User.email_or_phone == email_or_phone))
|
|
if not user or not user.password_hash or not verify_secret(payload.password, user.password_hash):
|
|
raise HTTPException(status_code=401, detail="账号或密码错误")
|
|
if user.status == UserStatus.suspended:
|
|
raise HTTPException(status_code=403, detail="账号已被停用")
|
|
return issue_token_response(user, db)
|
|
|
|
|
|
@app.post("/api/v1/auth/oauth", response_model=TokenResponse, status_code=200)
|
|
def oauth_login(payload: OAuthRequest, db: Session = Depends(get_db)):
|
|
identity = db.scalar(select(AuthIdentity).where(AuthIdentity.provider == payload.provider,
|
|
AuthIdentity.provider_user_id == payload.provider_user_id))
|
|
if identity:
|
|
user = identity.user
|
|
else:
|
|
email_or_phone = str(payload.email).lower() if payload.email else None
|
|
user = db.scalar(select(User).where(User.email_or_phone == email_or_phone)) if email_or_phone else None
|
|
if not user:
|
|
user = User(email_or_phone=email_or_phone, status=UserStatus.active,
|
|
profile=UserProfile(full_name=payload.full_name))
|
|
db.add(user)
|
|
db.flush()
|
|
db.add(AuthIdentity(user_id=user.id, provider=payload.provider, provider_user_id=payload.provider_user_id))
|
|
db.commit()
|
|
db.refresh(user)
|
|
return issue_token_response(user, db)
|
|
|
|
|
|
@app.post("/api/v1/auth/refresh", response_model=TokenResponse)
|
|
def refresh_access_token(payload: RefreshRequest, db: Session = Depends(get_db)):
|
|
session = find_refresh_session(payload.refresh_token, db)
|
|
if not session or not session.user or session.user.status != UserStatus.active:
|
|
raise HTTPException(status_code=401, detail="refresh token 无效或已过期")
|
|
session.revoked_at = datetime.utcnow()
|
|
response = issue_token_response(session.user, db)
|
|
return response
|
|
|
|
|
|
@app.post("/api/v1/auth/logout")
|
|
def logout(payload: RefreshRequest, db: Session = Depends(get_db)):
|
|
session = find_refresh_session(payload.refresh_token, db)
|
|
if session:
|
|
session.revoked_at = datetime.utcnow()
|
|
db.commit()
|
|
return {"message": "已退出登录"}
|
|
|
|
|
|
@app.get("/api/v1/auth/me", response_model=UserActivityResponse)
|
|
@app.get("/api/v1/users/me", response_model=UserActivityResponse)
|
|
def get_me(user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
|
return user_response(user, db, include_activity=True)
|
|
|
|
|
|
@app.patch("/api/v1/users/me", response_model=UserResponse)
|
|
def update_me(payload: ProfileUpdate, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
|
if not user.profile:
|
|
user.profile = UserProfile()
|
|
values = payload.model_dump(exclude_unset=True)
|
|
if "city" in values and values["city"] != user.profile.city:
|
|
user.profile.county = None
|
|
interests = values.pop("interests", None)
|
|
replaced_videos: list[UserMedia] = []
|
|
if values.get("bio") is not None:
|
|
replaced_videos = list(db.scalars(select(UserMedia).where(
|
|
UserMedia.user_id == user.id,
|
|
UserMedia.media_type == "intro_video",
|
|
)).all())
|
|
for video in replaced_videos:
|
|
db.delete(video)
|
|
for key, value in values.items():
|
|
setattr(user.profile, key, value)
|
|
if interests is not None:
|
|
replace_user_interests(user, interests, db)
|
|
try:
|
|
db.commit()
|
|
except Exception:
|
|
db.rollback()
|
|
raise
|
|
for video in replaced_videos:
|
|
remove_stored_file(video.storage_path, context="bio replacement")
|
|
db.refresh(user)
|
|
return user_response(user)
|
|
|
|
|
|
@app.get("/api/v1/users/me/interests", response_model=InterestsResponse)
|
|
def get_my_interests(user: User = Depends(get_current_user)):
|
|
return InterestsResponse(interests=[item.name for item in user.interests])
|
|
|
|
|
|
@app.put("/api/v1/users/me/interests", response_model=InterestsResponse)
|
|
def update_my_interests(payload: InterestsUpdate, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
|
replace_user_interests(user, payload.interests, db)
|
|
db.commit()
|
|
return InterestsResponse(interests=[item.name for item in user.interests])
|
|
|
|
|
|
@app.put("/api/v1/users/me/location", response_model=LocationResponse)
|
|
def update_location(payload: LocationUpdate, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
|
if not user.profile:
|
|
user.profile = UserProfile()
|
|
user.profile.city = payload.city
|
|
user.profile.county = payload.county
|
|
if user.location:
|
|
user.location.latitude = payload.latitude
|
|
user.location.longitude = payload.longitude
|
|
else:
|
|
user.location = UserLocation(latitude=payload.latitude, longitude=payload.longitude)
|
|
db.commit()
|
|
db.refresh(user.location)
|
|
return LocationResponse(
|
|
latitude=user.location.latitude,
|
|
longitude=user.location.longitude,
|
|
city=user.profile.city,
|
|
county=user.profile.county,
|
|
updated_at=user.location.updated_at,
|
|
)
|
|
|
|
|
|
@app.get("/api/v1/users/me/posts", response_model=list[PostResponse])
|
|
def list_my_posts(
|
|
limit: int = Query(default=20, ge=1, le=50),
|
|
offset: int = Query(default=0, ge=0, le=5000),
|
|
user: User = Depends(get_active_user),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
return user_posts(user, user, db, limit=limit, offset=offset)
|
|
|
|
|
|
@app.get("/api/v1/users/me/liked", response_model=list[DiscoveryUserResponse])
|
|
def list_users_i_liked(
|
|
user: User = Depends(get_active_user),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
return [
|
|
public_user_response(item, user)
|
|
for item in liked_users(user, db, limit=None)
|
|
]
|
|
|
|
|
|
@app.get("/api/v1/users/me/likes", response_model=list[DiscoveryUserResponse])
|
|
def list_users_who_liked_me(
|
|
user: User = Depends(get_active_user),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
return [
|
|
public_user_response(item, user)
|
|
for item in liked_users(user, db, received=True, limit=None)
|
|
]
|
|
|
|
|
|
@app.get("/api/v1/users/me/matches", response_model=list[MatchResponse])
|
|
def list_my_matches(user: User = Depends(get_active_user), db: Session = Depends(get_db)):
|
|
return user_matches(user, db, limit=None)
|
|
|
|
|
|
@app.get("/api/v1/users/{user_id}/posts", response_model=list[PostResponse])
|
|
def list_public_user_posts(
|
|
user_id: int,
|
|
limit: int = Query(default=20, ge=1, le=50),
|
|
offset: int = Query(default=0, ge=0, le=5000),
|
|
user: User = Depends(get_active_user),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
owner = db.get(User, user_id)
|
|
if not owner or owner.status != UserStatus.active:
|
|
raise HTTPException(status_code=404, detail="用户不存在")
|
|
return user_posts(owner, user, db, limit=limit, offset=offset)
|
|
|
|
|
|
@app.get("/api/v1/users/{user_id}", response_model=PublicUserProfileResponse)
|
|
def get_public_user_profile(
|
|
user_id: int,
|
|
user: User = Depends(get_active_user),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
target = db.get(User, user_id)
|
|
if not target or target.status != UserStatus.active:
|
|
raise HTTPException(status_code=404, detail="用户不存在")
|
|
return public_user_profile_response(target, user, db)
|
|
|
|
|
|
def remove_stored_file(storage_path: str, *, context: str) -> None:
|
|
try:
|
|
storage.remove(storage_path)
|
|
except Exception:
|
|
logger.exception("Failed to remove stored file after %s: %s", context, storage_path)
|
|
|
|
|
|
def save_upload(
|
|
user_id: int,
|
|
media_type: str,
|
|
upload: UploadFile,
|
|
max_bytes: int,
|
|
allowed_types: set[str],
|
|
db: Session,
|
|
*,
|
|
replace_existing: bool = False,
|
|
clear_bio: bool = False,
|
|
):
|
|
if upload.content_type not in allowed_types:
|
|
raise HTTPException(status_code=415, detail="不支持的文件类型")
|
|
target_dir = UPLOAD_ROOT / str(user_id)
|
|
target_dir.mkdir(parents=True, exist_ok=True)
|
|
target = target_dir / f"{secrets.token_hex(16)}{Path(upload.filename or '').suffix.lower()}"
|
|
size = 0
|
|
with target.open("wb") as output:
|
|
while chunk := upload.file.read(1024 * 1024):
|
|
size += len(chunk)
|
|
if size > max_bytes:
|
|
target.unlink(missing_ok=True)
|
|
raise HTTPException(status_code=413, detail="文件超过大小限制")
|
|
output.write(chunk)
|
|
try:
|
|
storage_path = storage.finalize(target, f"users/{user_id}/{target.name}")
|
|
except Exception as exc:
|
|
target.unlink(missing_ok=True)
|
|
raise HTTPException(status_code=503, detail="文件存储服务暂时不可用") from exc
|
|
replaced_media: list[UserMedia] = []
|
|
media = UserMedia(
|
|
user_id=user_id,
|
|
media_type=media_type,
|
|
storage_path=storage_path,
|
|
original_name=upload.filename,
|
|
mime_type=upload.content_type,
|
|
)
|
|
try:
|
|
if replace_existing:
|
|
replaced_media = list(db.scalars(select(UserMedia).where(
|
|
UserMedia.user_id == user_id,
|
|
UserMedia.media_type == media_type,
|
|
)).all())
|
|
for previous in replaced_media:
|
|
db.delete(previous)
|
|
if clear_bio:
|
|
profile = db.scalar(select(UserProfile).where(UserProfile.user_id == user_id))
|
|
if profile:
|
|
profile.bio = None
|
|
db.add(media)
|
|
db.commit()
|
|
except Exception:
|
|
db.rollback()
|
|
remove_stored_file(storage_path, context="database rollback")
|
|
raise
|
|
|
|
for previous in replaced_media:
|
|
remove_stored_file(previous.storage_path, context=f"{media_type} replacement")
|
|
db.refresh(media)
|
|
return {"id": media.id, "media_type": media.media_type, "review_status": media.review_status}
|
|
|
|
|
|
@app.post("/api/v1/users/me/video")
|
|
def upload_video(video: UploadFile = File(...), user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
|
return save_upload(
|
|
user.id,
|
|
"intro_video",
|
|
video,
|
|
100 * 1024 * 1024,
|
|
{"video/mp4", "video/quicktime", "video/webm"},
|
|
db,
|
|
replace_existing=True,
|
|
clear_bio=True,
|
|
)
|
|
|
|
|
|
@app.post("/api/v1/users/me/image")
|
|
def upload_profile_image(image: UploadFile = File(...), user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
|
return save_upload(
|
|
user.id,
|
|
"profile_image",
|
|
image,
|
|
10 * 1024 * 1024,
|
|
{"image/jpeg", "image/png", "image/webp"},
|
|
db,
|
|
replace_existing=True,
|
|
)
|
|
|
|
|
|
@app.post("/api/v1/users/me/identity")
|
|
def upload_identity(document: UploadFile = File(...), user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
|
return save_upload(user.id, "identity_document", document, 10 * 1024 * 1024,
|
|
{"image/jpeg", "image/png", "application/pdf"}, db)
|
|
|
|
|
|
@app.get("/api/v1/media/{media_id}")
|
|
def get_media(media_id: int, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
|
media = db.get(UserMedia, media_id)
|
|
if not media:
|
|
raise HTTPException(status_code=404, detail="文件不存在")
|
|
if media.media_type not in PUBLIC_MEDIA_TYPES and media.user_id != user.id:
|
|
raise HTTPException(status_code=403, detail="无权访问该文件")
|
|
owner = db.get(User, media.user_id)
|
|
if media.user_id != user.id and (not owner or owner.status != UserStatus.active):
|
|
raise HTTPException(status_code=403, detail="无权访问该文件")
|
|
try:
|
|
response = storage.response(media.storage_path, media.mime_type, media.original_name)
|
|
except FileNotFoundError:
|
|
raise HTTPException(status_code=404, detail="文件不存在")
|
|
return response
|
|
|
|
|
|
@app.get("/api/v1/meet", response_model=list[DiscoveryUserResponse])
|
|
def meet_candidates(limit: int = Query(default=20, ge=1, le=50), user: User = Depends(get_active_user),
|
|
db: Session = Depends(get_db)):
|
|
already_swiped = select(Swipe.target_id).where(Swipe.actor_id == user.id)
|
|
super_likers = db.scalars(select(Swipe.actor_id).where(
|
|
Swipe.target_id == user.id,
|
|
Swipe.direction == "super_like",
|
|
)).all()
|
|
candidates = db.scalars(
|
|
select(User).where(
|
|
User.id != user.id,
|
|
User.status == UserStatus.active,
|
|
User.id.not_in(already_swiped),
|
|
).order_by(User.id.desc()).limit(500)
|
|
).all()
|
|
if super_likers:
|
|
priority_candidates = db.scalars(select(User).where(
|
|
User.id.in_(super_likers),
|
|
User.status == UserStatus.active,
|
|
User.id.not_in(already_swiped),
|
|
)).all()
|
|
candidates_by_id = {candidate.id: candidate for candidate in candidates}
|
|
candidates_by_id.update({candidate.id: candidate for candidate in priority_candidates})
|
|
candidates = list(candidates_by_id.values())
|
|
super_liker_ids = set(super_likers)
|
|
candidates.sort(key=lambda candidate: (
|
|
candidate.id not in super_liker_ids,
|
|
*discovery_sort_key(user, candidate),
|
|
))
|
|
return [public_user_response(candidate, user) for candidate in candidates[:limit]]
|
|
|
|
|
|
@app.post("/api/v1/meet/swipes", response_model=SwipeResponse)
|
|
def swipe(payload: SwipeRequest, user: User = Depends(get_active_user), db: Session = Depends(get_db)):
|
|
if payload.target_user_id == user.id:
|
|
raise HTTPException(status_code=400, detail="不能对自己进行滑动操作")
|
|
target = db.get(User, payload.target_user_id)
|
|
if not target or target.status != UserStatus.active:
|
|
raise HTTPException(status_code=404, detail="目标用户不存在")
|
|
|
|
existing = db.scalar(select(Swipe).where(Swipe.actor_id == user.id, Swipe.target_id == target.id))
|
|
if existing:
|
|
existing.direction = payload.direction
|
|
else:
|
|
db.add(Swipe(actor_id=user.id, target_id=target.id, direction=payload.direction))
|
|
|
|
first_id, second_id = sorted((user.id, target.id))
|
|
match = db.scalar(select(Match).where(Match.user1_id == first_id, Match.user2_id == second_id))
|
|
if payload.direction == "pass":
|
|
if match:
|
|
db.delete(match)
|
|
match = None
|
|
else:
|
|
reciprocal = db.scalar(select(Swipe).where(
|
|
Swipe.actor_id == target.id,
|
|
Swipe.target_id == user.id,
|
|
Swipe.direction.in_(("like", "super_like")),
|
|
))
|
|
if reciprocal and not match:
|
|
match = Match(user1_id=first_id, user2_id=second_id)
|
|
db.add(match)
|
|
db.flush()
|
|
db.commit()
|
|
return SwipeResponse(direction=payload.direction, matched=match is not None,
|
|
match_id=match.id if match else None)
|
|
|
|
|
|
@app.delete("/api/v1/meet/likes/{target_user_id}")
|
|
def cancel_like(target_user_id: int, user: User = Depends(get_active_user), db: Session = Depends(get_db)):
|
|
swipe_item = db.scalar(select(Swipe).where(
|
|
Swipe.actor_id == user.id,
|
|
Swipe.target_id == target_user_id,
|
|
Swipe.direction.in_(("like", "super_like")),
|
|
))
|
|
if not swipe_item:
|
|
raise HTTPException(status_code=404, detail="没有可取消的喜欢记录")
|
|
db.delete(swipe_item)
|
|
first_id, second_id = sorted((user.id, target_user_id))
|
|
db.execute(delete(Match).where(Match.user1_id == first_id, Match.user2_id == second_id))
|
|
db.commit()
|
|
return {"message": "已取消喜欢"}
|
|
|
|
|
|
@app.delete("/api/v1/meet/swipes/{target_user_id}")
|
|
def undo_swipe(target_user_id: int, user: User = Depends(get_active_user), db: Session = Depends(get_db)):
|
|
swipe_item = db.scalar(select(Swipe).where(
|
|
Swipe.actor_id == user.id,
|
|
Swipe.target_id == target_user_id,
|
|
))
|
|
if not swipe_item:
|
|
raise HTTPException(status_code=404, detail="没有可撤销的滑动记录")
|
|
previous_direction = swipe_item.direction
|
|
db.delete(swipe_item)
|
|
first_id, second_id = sorted((user.id, target_user_id))
|
|
db.execute(delete(Match).where(Match.user1_id == first_id, Match.user2_id == second_id))
|
|
db.commit()
|
|
return {"message": "已撤销滑动", "previous_direction": previous_direction}
|
|
|
|
|
|
@app.get("/api/v1/matches", response_model=list[MatchResponse])
|
|
def list_matches(user: User = Depends(get_active_user), db: Session = Depends(get_db)):
|
|
return user_matches(user, db, limit=None)
|
|
|
|
|
|
@app.get("/api/v1/search", response_model=list[DiscoveryUserResponse])
|
|
def search_users(
|
|
q: Optional[str] = Query(default=None, max_length=200),
|
|
city: Optional[str] = Query(default=None, max_length=120),
|
|
country: Optional[str] = Query(default=None, max_length=120),
|
|
latitude: Optional[float] = Query(default=None, ge=-90, le=90),
|
|
longitude: Optional[float] = Query(default=None, ge=-180, le=180),
|
|
max_distance_km: Optional[float] = Query(default=None, gt=0, le=500),
|
|
limit: int = Query(default=20, ge=1, le=50),
|
|
user: User = Depends(get_active_user),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
if (latitude is None) != (longitude is None):
|
|
raise HTTPException(status_code=422, detail="latitude 和 longitude 必须同时提供")
|
|
if max_distance_km is not None and latitude is None:
|
|
if not user.location:
|
|
raise HTTPException(status_code=422, detail="请提供坐标或先更新自己的位置")
|
|
latitude, longitude = user.location.latitude, user.location.longitude
|
|
|
|
statement = select(User).outerjoin(UserProfile).where(
|
|
User.id != user.id,
|
|
User.status == UserStatus.active,
|
|
)
|
|
if q and q.strip():
|
|
term_filters = []
|
|
for term in q.split():
|
|
pattern = f"%{term}%"
|
|
term_filters.append(or_(
|
|
UserProfile.full_name.ilike(pattern),
|
|
UserProfile.bio.ilike(pattern),
|
|
UserProfile.city.ilike(pattern),
|
|
UserProfile.country.ilike(pattern),
|
|
))
|
|
statement = statement.where(and_(*term_filters))
|
|
if city and city.strip():
|
|
statement = statement.where(func.lower(func.trim(UserProfile.city)) == city.strip().lower())
|
|
if country and country.strip():
|
|
statement = statement.where(func.lower(func.trim(UserProfile.country)) == country.strip().lower())
|
|
|
|
candidates = db.scalars(statement.order_by(User.id.desc()).limit(500)).unique().all()
|
|
results = []
|
|
for candidate in candidates:
|
|
distance = None
|
|
if latitude is not None and candidate.location:
|
|
distance = distance_between(latitude, longitude, candidate.location)
|
|
elif max_distance_km is not None:
|
|
continue
|
|
if max_distance_km is not None and distance is not None and distance > max_distance_km:
|
|
continue
|
|
results.append((candidate, public_user_response(candidate, user, distance)))
|
|
if latitude is not None:
|
|
results.sort(key=lambda item: (
|
|
discovery_priority(user, item[0]),
|
|
-compatibility_percentage(user, item[0]),
|
|
item[1].distance_km if item[1].distance_km is not None else float("inf"),
|
|
-item[0].id,
|
|
))
|
|
else:
|
|
results.sort(key=lambda item: discovery_sort_key(user, item[0]))
|
|
return [item[1] for item in results[:limit]]
|
|
|
|
|
|
@app.post("/api/v1/explore/posts", response_model=PostResponse, status_code=201)
|
|
def create_post(payload: PostCreate, user: User = Depends(get_active_user), db: Session = Depends(get_db)):
|
|
post = Post(
|
|
user_id=user.id,
|
|
content=payload.content.strip(),
|
|
post_type=payload.post_type,
|
|
content_format=payload.content_format,
|
|
feeling=payload.feeling,
|
|
location_name=payload.location.name if payload.location else None,
|
|
location_latitude=payload.location.latitude if payload.location else None,
|
|
location_longitude=payload.location.longitude if payload.location else None,
|
|
)
|
|
db.add(post)
|
|
db.commit()
|
|
db.refresh(post)
|
|
return post_response(post, user)
|
|
|
|
|
|
@app.patch("/api/v1/explore/posts/{post_id}", response_model=PostResponse)
|
|
def update_post(
|
|
post_id: int,
|
|
payload: PostUpdate,
|
|
user: User = Depends(get_active_user),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
post = get_post(post_id, db)
|
|
ensure_post_author(post, user)
|
|
values = payload.model_dump(exclude_unset=True)
|
|
if values.get("content") is not None:
|
|
values["content"] = values["content"].strip()
|
|
if "location" in values:
|
|
location = values.pop("location")
|
|
if location:
|
|
post.location_name = location.get("name")
|
|
post.location_latitude = location.get("latitude")
|
|
post.location_longitude = location.get("longitude")
|
|
else:
|
|
post.location_name = None
|
|
post.location_latitude = None
|
|
post.location_longitude = None
|
|
for key, value in values.items():
|
|
setattr(post, key, value)
|
|
if post.post_type == "text" and not (post.content.strip() or post.feeling or post.location_name):
|
|
raise HTTPException(status_code=422, detail="文字帖子至少需要文字、心情或地点")
|
|
db.commit()
|
|
db.refresh(post)
|
|
return post_response(post, user)
|
|
|
|
|
|
@app.get("/api/v1/explore/feed", response_model=list[PostResponse])
|
|
def explore_feed(
|
|
limit: int = Query(default=20, ge=1, le=50),
|
|
before_id: Optional[int] = Query(default=None, gt=0),
|
|
offset: int = Query(default=0, ge=0, le=5000),
|
|
user: User = Depends(get_active_user),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
if before_id is not None and offset:
|
|
raise HTTPException(status_code=422, detail="before_id 和 offset 不能同时使用")
|
|
statement = select(Post).join(User, Post.user_id == User.id).where(User.status == UserStatus.active)
|
|
if before_id is not None:
|
|
statement = statement.where(Post.id < before_id)
|
|
posts = db.scalars(statement.order_by(desc(Post.id)).limit(limit)).unique().all()
|
|
else:
|
|
posts = db.scalars(statement).unique().all()
|
|
posts.sort(key=lambda post: (*discovery_sort_key(user, post.user), -post.id))
|
|
page = posts if before_id is not None else posts[offset:offset + limit]
|
|
return [post_response(post, user) for post in page]
|
|
|
|
|
|
@app.get("/api/v1/explore/posts/{post_id}", response_model=PostResponse)
|
|
def get_explore_post(post_id: int, user: User = Depends(get_active_user), db: Session = Depends(get_db)):
|
|
return post_response(get_post(post_id, db), user)
|
|
|
|
|
|
@app.delete("/api/v1/explore/posts/{post_id}")
|
|
def delete_post(post_id: int, user: User = Depends(get_active_user), db: Session = Depends(get_db)):
|
|
post = get_post(post_id, db)
|
|
ensure_post_author(post, user)
|
|
stored_paths = [item.storage_path for item in post.media]
|
|
db.delete(post)
|
|
db.commit()
|
|
for storage_path in stored_paths:
|
|
storage.remove(storage_path)
|
|
return {"message": "帖子已删除"}
|
|
|
|
|
|
@app.post("/api/v1/explore/posts/{post_id}/media", response_model=PostMediaResponse, status_code=201)
|
|
def upload_post_media(
|
|
post_id: int,
|
|
media: UploadFile = File(...),
|
|
position: int = Query(default=0, ge=0, le=9),
|
|
user: User = Depends(get_active_user),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
post = get_post(post_id, db)
|
|
ensure_post_author(post, user)
|
|
content_type = media.content_type or ""
|
|
image_count = sum(1 for item in post.media if item.media_type == "image")
|
|
video_count = sum(1 for item in post.media if item.media_type == "video")
|
|
if content_type in POST_IMAGE_TYPES:
|
|
media_type = "image"
|
|
extension = POST_IMAGE_TYPES[content_type]
|
|
max_bytes = 10 * 1024 * 1024
|
|
if image_count >= 9:
|
|
raise HTTPException(status_code=409, detail="一条帖子最多上传 9 张图片")
|
|
elif content_type in POST_VIDEO_TYPES:
|
|
media_type = "video"
|
|
extension = POST_VIDEO_TYPES[content_type]
|
|
max_bytes = 100 * 1024 * 1024
|
|
if video_count >= 1:
|
|
raise HTTPException(status_code=409, detail="一条帖子最多上传 1 个视频")
|
|
else:
|
|
raise HTTPException(status_code=415, detail="仅支持 JPG、PNG、WebP、MP4、MOV 或 WebM")
|
|
|
|
target_dir = UPLOAD_ROOT / "posts" / str(post.id)
|
|
target_dir.mkdir(parents=True, exist_ok=True)
|
|
target = target_dir / f"{secrets.token_hex(16)}{extension}"
|
|
size = 0
|
|
try:
|
|
with target.open("wb") as output:
|
|
while chunk := media.file.read(1024 * 1024):
|
|
size += len(chunk)
|
|
if size > max_bytes:
|
|
raise HTTPException(status_code=413, detail="文件超过大小限制")
|
|
output.write(chunk)
|
|
try:
|
|
storage_path = storage.finalize(target, f"posts/{post.id}/{target.name}")
|
|
except Exception as exc:
|
|
target.unlink(missing_ok=True)
|
|
raise HTTPException(status_code=503, detail="文件存储服务暂时不可用") from exc
|
|
item = PostMedia(
|
|
post_id=post.id,
|
|
media_type=media_type,
|
|
storage_path=storage_path,
|
|
original_name=media.filename,
|
|
mime_type=content_type,
|
|
size_bytes=size,
|
|
position=position,
|
|
)
|
|
post.post_type = "mixed" if (
|
|
(media_type == "image" and video_count > 0) or (media_type == "video" and image_count > 0)
|
|
) else media_type
|
|
db.add(item)
|
|
db.commit()
|
|
db.refresh(item)
|
|
except Exception:
|
|
target.unlink(missing_ok=True)
|
|
raise
|
|
return PostMediaResponse(
|
|
id=item.id,
|
|
media_type=item.media_type,
|
|
mime_type=item.mime_type,
|
|
size_bytes=item.size_bytes,
|
|
position=item.position,
|
|
url=storage.access_url(item.storage_path, f"/api/v1/explore/media/{item.id}"),
|
|
)
|
|
|
|
|
|
@app.get("/api/v1/explore/media/{media_id}")
|
|
def get_post_media(media_id: int, user: User = Depends(get_active_user), db: Session = Depends(get_db)):
|
|
item = db.get(PostMedia, media_id)
|
|
if not item or item.post.user.status != UserStatus.active:
|
|
raise HTTPException(status_code=404, detail="帖子媒体不存在")
|
|
try:
|
|
response = storage.response(item.storage_path, item.mime_type, item.original_name)
|
|
except FileNotFoundError:
|
|
raise HTTPException(status_code=404, detail="媒体文件不存在")
|
|
return response
|
|
|
|
|
|
@app.delete("/api/v1/explore/media/{media_id}")
|
|
def delete_post_media(media_id: int, user: User = Depends(get_active_user), db: Session = Depends(get_db)):
|
|
item = db.get(PostMedia, media_id)
|
|
if not item:
|
|
raise HTTPException(status_code=404, detail="帖子媒体不存在")
|
|
post = item.post
|
|
ensure_post_author(post, user)
|
|
remaining_types = {media.media_type for media in post.media if media.id != item.id}
|
|
db.delete(item)
|
|
if remaining_types == {"image", "video"}:
|
|
post.post_type = "mixed"
|
|
elif "image" in remaining_types:
|
|
post.post_type = "image"
|
|
elif "video" in remaining_types:
|
|
post.post_type = "video"
|
|
elif post.content.strip() or post.feeling or post.location_name:
|
|
post.post_type = "text"
|
|
db.commit()
|
|
storage.remove(item.storage_path)
|
|
return {"message": "帖子媒体已删除"}
|
|
|
|
|
|
@app.get(
|
|
"/api/v1/explore/posts/{post_id}/like", response_model=list[PostLikerResponse],
|
|
description="Return all active post likers as ID/name pairs, newest like first. No pagination; empty list without likes.",
|
|
)
|
|
def list_post_likers(post_id: int, user: User = Depends(get_active_user), db: Session = Depends(get_db)):
|
|
return post_likers(get_post(post_id, db))
|
|
|
|
|
|
@app.post(
|
|
"/api/v1/explore/posts/{post_id}/like", response_model=PostLikeResponse,
|
|
description="Like a post and return a compact success result with count, viewer state, and liker ID/name pairs. Repeated likes are idempotent. The full post content and media are not returned.",
|
|
)
|
|
def like_post(post_id: int, user: User = Depends(get_active_user), db: Session = Depends(get_db)):
|
|
post = get_post(post_id, db)
|
|
existing = db.scalar(select(PostLike).where(PostLike.post_id == post.id, PostLike.user_id == user.id))
|
|
if not existing:
|
|
db.add(PostLike(post_id=post.id, user_id=user.id))
|
|
db.commit()
|
|
db.refresh(post)
|
|
likers = post_likers(post)
|
|
return PostLikeResponse(
|
|
message="like successful",
|
|
like_count=len(likers),
|
|
liked_by_me=True,
|
|
liked_by_users=likers,
|
|
)
|
|
|
|
|
|
@app.delete("/api/v1/explore/posts/{post_id}/like", response_model=PostUnlikeResponse)
|
|
def unlike_post(post_id: int, user: User = Depends(get_active_user), db: Session = Depends(get_db)):
|
|
post = get_post(post_id, db)
|
|
existing = db.scalar(select(PostLike).where(PostLike.post_id == post.id, PostLike.user_id == user.id))
|
|
if existing:
|
|
db.delete(existing)
|
|
db.commit()
|
|
db.refresh(post)
|
|
likers = post_likers(post)
|
|
return PostUnlikeResponse(
|
|
message="unlike successful",
|
|
like_count=len(likers),
|
|
liked_by_me=False,
|
|
liked_by_users=likers,
|
|
)
|
|
|
|
|
|
def comment_active_likes(comment: PostComment) -> list[CommentLike]:
|
|
return [item for item in comment.likes if item.user.status == UserStatus.active]
|
|
|
|
|
|
def comment_response(comment: PostComment, current_user: User) -> CommentResponse:
|
|
likes = comment_active_likes(comment)
|
|
return CommentResponse(
|
|
id=comment.id,
|
|
post_id=comment.post_id,
|
|
content=comment.content,
|
|
author=post_author_response(comment.user, current_user),
|
|
parent_comment_id=comment.parent_comment_id,
|
|
reply_count=len(comment.replies),
|
|
like_count=len(likes),
|
|
liked_by_me=any(item.user_id == current_user.id for item in likes),
|
|
created_at=comment.created_at,
|
|
)
|
|
|
|
|
|
@app.get("/api/v1/explore/posts/{post_id}/comments", response_model=list[CommentResponse])
|
|
def list_comments(post_id: int, user: User = Depends(get_active_user), db: Session = Depends(get_db)):
|
|
post = get_post(post_id, db)
|
|
comments = db.scalars(
|
|
select(PostComment).where(PostComment.post_id == post.id).order_by(PostComment.id.asc())
|
|
).all()
|
|
return [comment_response(comment, user) for comment in comments]
|
|
|
|
|
|
@app.post("/api/v1/explore/posts/{post_id}/comments", response_model=CommentResponse, status_code=201)
|
|
def create_comment(
|
|
post_id: int,
|
|
payload: CommentCreate,
|
|
user: User = Depends(get_active_user),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
post = get_post(post_id, db)
|
|
if not payload.content.strip():
|
|
raise HTTPException(status_code=422, detail="评论内容不能为空")
|
|
if payload.parent_comment_id is not None:
|
|
parent = db.get(PostComment, payload.parent_comment_id)
|
|
if not parent or parent.post_id != post.id:
|
|
raise HTTPException(status_code=404, detail="父评论不存在")
|
|
comment = PostComment(
|
|
post_id=post.id,
|
|
user_id=user.id,
|
|
parent_comment_id=payload.parent_comment_id,
|
|
content=payload.content.strip(),
|
|
)
|
|
db.add(comment)
|
|
db.commit()
|
|
db.refresh(comment)
|
|
return comment_response(comment, user)
|
|
|
|
|
|
@app.patch("/api/v1/explore/comments/{comment_id}", response_model=CommentResponse)
|
|
def update_comment(
|
|
comment_id: int,
|
|
payload: CommentUpdate,
|
|
user: User = Depends(get_active_user),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
comment = db.get(PostComment, comment_id)
|
|
if not comment:
|
|
raise HTTPException(status_code=404, detail="评论不存在")
|
|
if comment.user_id != user.id:
|
|
raise HTTPException(status_code=403, detail="只能编辑自己的评论")
|
|
content = payload.content.strip()
|
|
if not content:
|
|
raise HTTPException(status_code=422, detail="评论内容不能为空")
|
|
comment.content = content
|
|
db.commit()
|
|
db.refresh(comment)
|
|
return comment_response(comment, user)
|
|
|
|
|
|
@app.post("/api/v1/explore/comments/{comment_id}/like", response_model=CommentLikeResponse)
|
|
def like_comment(comment_id: int, user: User = Depends(get_active_user), db: Session = Depends(get_db)):
|
|
comment = db.get(PostComment, comment_id)
|
|
if not comment or comment.post.user.status != UserStatus.active:
|
|
raise HTTPException(status_code=404, detail="评论不存在")
|
|
existing = db.scalar(select(CommentLike).where(
|
|
CommentLike.comment_id == comment.id, CommentLike.user_id == user.id,
|
|
))
|
|
if not existing:
|
|
db.add(CommentLike(comment_id=comment.id, user_id=user.id))
|
|
db.commit()
|
|
db.refresh(comment)
|
|
return CommentLikeResponse(
|
|
message="like successful",
|
|
like_count=len(comment_active_likes(comment)),
|
|
liked_by_me=True,
|
|
)
|
|
|
|
|
|
@app.delete("/api/v1/explore/comments/{comment_id}/like", response_model=CommentLikeResponse)
|
|
def unlike_comment(comment_id: int, user: User = Depends(get_active_user), db: Session = Depends(get_db)):
|
|
comment = db.get(PostComment, comment_id)
|
|
if not comment or comment.post.user.status != UserStatus.active:
|
|
raise HTTPException(status_code=404, detail="评论不存在")
|
|
existing = db.scalar(select(CommentLike).where(
|
|
CommentLike.comment_id == comment.id, CommentLike.user_id == user.id,
|
|
))
|
|
if existing:
|
|
db.delete(existing)
|
|
db.commit()
|
|
db.refresh(comment)
|
|
return CommentLikeResponse(
|
|
message="unlike successful",
|
|
like_count=len(comment_active_likes(comment)),
|
|
liked_by_me=False,
|
|
)
|
|
|
|
|
|
@app.delete("/api/v1/explore/comments/{comment_id}")
|
|
def delete_comment(comment_id: int, user: User = Depends(get_active_user), db: Session = Depends(get_db)):
|
|
comment = db.get(PostComment, comment_id)
|
|
if not comment:
|
|
raise HTTPException(status_code=404, detail="评论不存在")
|
|
if comment.user_id != user.id:
|
|
raise HTTPException(status_code=403, detail="只能删除自己的评论")
|
|
db.delete(comment)
|
|
db.commit()
|
|
return {"message": "评论已删除"}
|
|
|
|
|
|
@app.get("/api/v1/feed/questions", response_model=list[FeedQuestionResponse])
|
|
def list_feed_questions(
|
|
unanswered_only: bool = Query(default=False),
|
|
user: User = Depends(get_active_user),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
questions = db.scalars(
|
|
select(FeedQuestion).where(FeedQuestion.is_active.is_(True))
|
|
.order_by(FeedQuestion.sort_order.asc(), FeedQuestion.id.asc())
|
|
).all()
|
|
answers = db.scalars(select(UserQuestionAnswer).where(UserQuestionAnswer.user_id == user.id)).all()
|
|
answers_by_question = {item.question_id: item for item in answers}
|
|
return [
|
|
feed_question_response(question, answers_by_question.get(question.id))
|
|
for question in questions
|
|
if not unanswered_only or question.id not in answers_by_question
|
|
]
|
|
|
|
|
|
@app.get("/api/v1/feed/answers/me", response_model=list[FeedQuestionResponse])
|
|
def list_my_feed_answers(user: User = Depends(get_active_user), db: Session = Depends(get_db)):
|
|
answers = db.scalars(
|
|
select(UserQuestionAnswer).where(UserQuestionAnswer.user_id == user.id)
|
|
.order_by(UserQuestionAnswer.answered_at.desc())
|
|
).all()
|
|
return [feed_question_response(answer.question, answer) for answer in answers]
|
|
|
|
|
|
@app.put("/api/v1/feed/questions/{question_id}/answer", response_model=QuestionAnswerResponse)
|
|
def answer_feed_question(
|
|
question_id: int,
|
|
payload: QuestionAnswerUpdate,
|
|
user: User = Depends(get_active_user),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
question = db.get(FeedQuestion, question_id)
|
|
if not question or not question.is_active:
|
|
raise HTTPException(status_code=404, detail="问题不存在或已停用")
|
|
if question.answer_type == "boolean" and not isinstance(payload.answer, bool):
|
|
raise HTTPException(status_code=422, detail="该问题只能回答 true 或 false")
|
|
if question.answer_type == "text" and not isinstance(payload.answer, str):
|
|
raise HTTPException(status_code=422, detail="该问题需要文字回答")
|
|
|
|
answer = db.scalar(select(UserQuestionAnswer).where(
|
|
UserQuestionAnswer.user_id == user.id,
|
|
UserQuestionAnswer.question_id == question.id,
|
|
))
|
|
if not answer:
|
|
answer = UserQuestionAnswer(user_id=user.id, question_id=question.id)
|
|
db.add(answer)
|
|
if question.answer_type == "boolean":
|
|
answer.boolean_answer = payload.answer
|
|
answer.text_answer = None
|
|
else:
|
|
answer.boolean_answer = None
|
|
answer.text_answer = payload.answer.strip()
|
|
answer.answered_at = datetime.utcnow()
|
|
db.commit()
|
|
db.refresh(answer)
|
|
return QuestionAnswerResponse(
|
|
question_id=question.id,
|
|
answer=answer_value(answer),
|
|
answered_at=answer.answered_at,
|
|
)
|
|
|
|
|
|
@app.delete("/api/v1/feed/questions/{question_id}/answer")
|
|
def delete_feed_answer(
|
|
question_id: int,
|
|
user: User = Depends(get_active_user),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
answer = db.scalar(select(UserQuestionAnswer).where(
|
|
UserQuestionAnswer.user_id == user.id,
|
|
UserQuestionAnswer.question_id == question_id,
|
|
))
|
|
if not answer:
|
|
raise HTTPException(status_code=404, detail="回答不存在")
|
|
db.delete(answer)
|
|
db.commit()
|
|
return {"message": "回答已删除"}
|
|
|
|
|
|
@app.get("/api/v1/chat/conversations", response_model=list[ConversationResponse])
|
|
def list_conversations(user: User = Depends(get_active_user), db: Session = Depends(get_db)):
|
|
matches = db.scalars(
|
|
select(Match).where(or_(Match.user1_id == user.id, Match.user2_id == user.id))
|
|
.order_by(Match.matched_at.desc())
|
|
).all()
|
|
result = []
|
|
for match in matches:
|
|
other_id = match.user2_id if match.user1_id == user.id else match.user1_id
|
|
other = db.get(User, other_id)
|
|
if not other or other.status != UserStatus.active:
|
|
continue
|
|
result.append(conversation_response(match.conversation, match, user, db))
|
|
return result
|
|
|
|
|
|
@app.post("/api/v1/chat/conversations/{match_id}", response_model=ConversationResponse, status_code=201)
|
|
def create_conversation(match_id: int, user: User = Depends(get_active_user), db: Session = Depends(get_db)):
|
|
match = get_user_match(match_id, user, db)
|
|
conversation = match.conversation
|
|
created = conversation is None
|
|
if created:
|
|
conversation = Conversation(match_id=match.id)
|
|
db.add(conversation)
|
|
db.commit()
|
|
db.refresh(conversation)
|
|
return conversation_response(conversation, match, user, db)
|
|
|
|
|
|
@app.get("/api/v1/chat/conversations/{conversation_id}/messages", response_model=list[ChatMessageResponse])
|
|
def list_messages(
|
|
conversation_id: int,
|
|
limit: int = Query(default=50, ge=1, le=100),
|
|
before_id: Optional[int] = Query(default=None, gt=0),
|
|
user: User = Depends(get_active_user),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
conversation = get_user_conversation(conversation_id, user, db)
|
|
statement = select(ChatMessage).where(ChatMessage.conversation_id == conversation.id)
|
|
if before_id is not None:
|
|
statement = statement.where(ChatMessage.id < before_id)
|
|
messages = db.scalars(statement.order_by(ChatMessage.id.desc()).limit(limit)).all()
|
|
return [chat_message_response(message) for message in reversed(messages)]
|
|
|
|
|
|
@app.post("/api/v1/chat/conversations/{conversation_id}/messages", response_model=ChatMessageResponse, status_code=201)
|
|
def send_message(
|
|
conversation_id: int,
|
|
payload: ChatMessageCreate,
|
|
user: User = Depends(get_active_user),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
conversation = get_user_conversation(conversation_id, user, db)
|
|
if not payload.content.strip():
|
|
raise HTTPException(status_code=422, detail="消息内容不能为空")
|
|
message = ChatMessage(
|
|
conversation_id=conversation.id,
|
|
sender_id=user.id,
|
|
content=payload.content.strip(),
|
|
message_type=payload.message_type,
|
|
)
|
|
conversation.updated_at = datetime.utcnow()
|
|
db.add(message)
|
|
db.commit()
|
|
db.refresh(message)
|
|
return chat_message_response(message)
|
|
|
|
|
|
@app.delete("/api/v1/chat/messages/{message_id}", response_model=ChatMessageResponse)
|
|
def recall_message(message_id: int, user: User = Depends(get_active_user), db: Session = Depends(get_db)):
|
|
message = db.get(ChatMessage, message_id)
|
|
if not message:
|
|
raise HTTPException(status_code=404, detail="消息不存在")
|
|
get_user_conversation(message.conversation_id, user, db)
|
|
if message.sender_id != user.id:
|
|
raise HTTPException(status_code=403, detail="只能撤回自己的消息")
|
|
if message.deleted_at is None:
|
|
message.deleted_at = datetime.utcnow()
|
|
db.commit()
|
|
db.refresh(message)
|
|
return chat_message_response(message)
|
|
|