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)