from __future__ import annotations from datetime import datetime import math import random from sqlalchemy import or_, select from sqlalchemy.orm import aliased from sqlalchemy.ext.asyncio import AsyncSession from app.models.match import Match, MatchLike from app.models.user import User from app.services.admin_service import get_system_config_value WEIGHTS = { "age": 0.20, "hobbies": 0.18, "values": 0.18, "education": 0.14, "activity": 0.12, "location": 0.10, "height": 0.08, } def _birth_year_range(birth_year: int | None) -> str | None: if not birth_year: return None start = birth_year - 2 end = birth_year + 2 return f"{start}-{end}" def _level1_info(user: User, *, score: float | None = None, reasons: list[str] | None = None) -> dict: personality_tags = _safe_personality_tags(user.personality_tags) return { "user_id": user.id, "nickname": user.nickname, "birth_year_range": _birth_year_range(user.birth_year), "city": user.city, "personality_tags": personality_tags, "avatar_blur_url": user.avatar_blur_url, "match_score": score, "match_reasons": reasons or [], } def _level2_info(user: User) -> dict: return { "nickname": user.nickname, "avatar_url": user.avatar_url, "education": user.education, "hobbies": list(_safe_hobbies(user.hobbies)), } def _level3_info(user: User) -> dict: return { "user_id": user.id, "nickname": user.nickname, "avatar_url": user.avatar_url, "education": user.education, "city": user.city, "hobbies": list(_safe_hobbies(user.hobbies)), "job_industry": user.job_industry, "job_company": user.job_company, "self_intro": user.self_intro, "income_range": user.income_range, } def _age_score(user_a: User, user_b: User) -> float: if not user_b.birth_year: return 50.0 if user_a.prefer_age_min and user_b.birth_year < user_a.prefer_age_min: return 0.0 if user_a.prefer_age_max and user_b.birth_year > user_a.prefer_age_max: return 0.0 if user_a.prefer_age_min and user_a.prefer_age_max: mid = (user_a.prefer_age_min + user_a.prefer_age_max) / 2 diff = abs(user_b.birth_year - mid) half_range = max((user_a.prefer_age_max - user_a.prefer_age_min) / 2, 1) return max(0.0, 100 - (diff / half_range) * 50) return 80.0 def _hobby_score(user_a: User, user_b: User) -> tuple[float, list[str]]: hobbies_a = _safe_hobbies(user_a.hobbies) hobbies_b = _safe_hobbies(user_b.hobbies) if not hobbies_a or not hobbies_b: return 30.0, [] intersection = hobbies_a & hobbies_b union = hobbies_a | hobbies_b score = len(intersection) / len(union) * 100 if union else 0.0 return round(score, 1), list(intersection) def _value_score(user_a: User, user_b: User) -> tuple[float, str | None]: answers_a = _safe_dict(getattr(user_a, "value_answers", None)) answers_b = _safe_dict(getattr(user_b, "value_answers", None)) if not answers_a or not answers_b: return 50.0, None matched = sum(1 for key in ["q1", "q2", "q3", "q4"] if answers_a.get(key) == answers_b.get(key)) score = matched / 4 * 100 if matched >= 3: return round(score, 1), "生活节奏相近" if matched >= 2: return round(score, 1), "价值观较为一致" return round(score, 1), None def _education_score(user_a: User, user_b: User) -> float: if not user_a.prefer_education: return 80.0 if not user_b.education: return 40.0 if user_b.education >= user_a.prefer_education: return 100.0 return max(0.0, 100 - (user_a.prefer_education - user_b.education) * 25) def _activity_score(user_b: User) -> float: if not user_b.updated_at: return 30.0 updated_at = user_b.updated_at if updated_at.tzinfo is not None: updated_at = updated_at.replace(tzinfo=None) days_inactive = (datetime.now() - updated_at).days if days_inactive <= 3: return 100.0 if days_inactive <= 7: return 80.0 if days_inactive <= 30: return 50.0 return 20.0 def _location_score(user_a: User, user_b: User) -> float: if not user_a.city or not user_b.city: return 50.0 if user_a.city == user_b.city: return 100.0 if user_a.city[:2] == user_b.city[:2]: return 60.0 return 20.0 def _height_score(user_a: User, user_b: User) -> float: if not user_b.height: return 50.0 if user_a.gender == 2 and user_b.gender == 1: if user_b.height >= 175: return 100.0 if user_b.height >= 170: return 70.0 return 40.0 return 70.0 def _safe_personality_tags(value: object) -> list[str]: return value if isinstance(value, list) else [] def _safe_hobbies(value: object) -> set[str]: return set(value) if isinstance(value, list) else set() def _safe_dict(value: object) -> dict: return value if isinstance(value, dict) else {} async def _get_activity_match_limit(session: AsyncSession) -> int: value = await get_system_config_value(session, "matches_per_activity_limit", 3) try: limit = int(value) except (TypeError, ValueError): return 3 return max(limit, 1) def calculate_match_score(user_a: User, user_b: User) -> dict: scores = {} reasons: list[str] = [] scores["age"] = _age_score(user_a, user_b) hobby_score, hobby_overlap = _hobby_score(user_a, user_b) scores["hobbies"] = hobby_score if hobby_overlap: reasons.append(f"都喜欢{hobby_overlap[0]}") value_score, value_reason = _value_score(user_a, user_b) scores["values"] = value_score if value_reason: reasons.append(value_reason) scores["education"] = _education_score(user_a, user_b) scores["activity"] = _activity_score(user_b) location_score = _location_score(user_a, user_b) scores["location"] = location_score if location_score == 100: reasons.append("同城") scores["height"] = _height_score(user_a, user_b) total = sum(scores[key] * WEIGHTS[key] for key in scores) return {"score": round(total, 1), "reasons": reasons[:3]} async def _mutual_match_exists(session: AsyncSession, user_id: int, other_user_id: int) -> bool: user_a_id, user_b_id = sorted([user_id, other_user_id]) result = await session.execute( select(Match.id).where(Match.user_a_id == user_a_id, Match.user_b_id == user_b_id) ) return result.scalar_one_or_none() is not None async def _has_liked(session: AsyncSession, from_user_id: int, to_user_id: int) -> bool: result = await session.execute( select(MatchLike.id).where( MatchLike.from_user_id == from_user_id, MatchLike.to_user_id == to_user_id, ) ) return result.scalar_one_or_none() is not None async def get_candidates(session: AsyncSession, current_user: User, source: str) -> list[dict]: liked_rows = await session.execute( select(MatchLike.to_user_id).where(MatchLike.from_user_id == current_user.id) ) liked_user_ids = {row[0] for row in liked_rows.all()} matched_rows = await session.execute( select(Match.user_a_id, Match.user_b_id).where( or_(Match.user_a_id == current_user.id, Match.user_b_id == current_user.id) ) ) matched_user_ids: set[int] = set() for row in matched_rows.all(): matched_user_ids.add(row.user_a_id) matched_user_ids.add(row.user_b_id) matched_user_ids.discard(current_user.id) excluded_user_ids = liked_user_ids | matched_user_ids | {current_user.id} stmt = select(User).where( User.audit_status == 2, User.is_active == 1, ) if excluded_user_ids: stmt = stmt.where(~User.id.in_(excluded_user_ids)) stmt = stmt.limit(10) result = await session.execute(stmt) users = result.scalars().all() items = [] for user in users: try: score_result = calculate_match_score(current_user, user) reasons = score_result["reasons"] if source == "ai" else None items.append(_level1_info(user, score=score_result["score"], reasons=reasons)) except Exception: # 单个候选用户数据异常时跳过,避免整个首页候选列表请求失败。 continue return items async def _get_activity_match_limit(session: AsyncSession) -> int: value = await get_system_config_value(session, "matches_per_activity_limit", 3) try: return max(int(value), 1) except (TypeError, ValueError): return 3 async def _activity_match_count(session: AsyncSession, activity_id: int, user_id: int) -> int: result = await session.execute( select(Match.id).where( Match.source_activity_id == activity_id, or_(Match.user_a_id == user_id, Match.user_b_id == user_id), ) ) return len(result.scalars().all()) async def like_user( session: AsyncSession, current_user: User, to_user: User, source: str, activity_id: int | None = None, ) -> dict: if current_user.id == to_user.id: raise ValueError("不能对自己表示感兴趣") if to_user.gender == current_user.gender: raise ValueError("当前仅支持异性匹配") if source == "activity": if activity_id is None: raise ValueError("活动匹配缺少活动标识") limit = await _get_activity_match_limit(session) current_count = await _activity_match_count(session, activity_id, current_user.id) if current_count >= limit: raise ValueError("该活动下你的可匹配次数已达上限") existing_like = await session.execute( select(MatchLike).where( MatchLike.from_user_id == current_user.id, MatchLike.to_user_id == to_user.id, ) ) current_like = existing_like.scalar_one_or_none() if current_like is None: current_like = MatchLike( from_user_id=current_user.id, to_user_id=to_user.id, source=source, created_at=datetime.now(), ) session.add(current_like) await session.flush() reverse_like_exists = await _has_liked(session, to_user.id, current_user.id) if not reverse_like_exists: await session.commit() return {"is_mutual": False, "match_id": None, "match_info": None} user_a_id, user_b_id = sorted([current_user.id, to_user.id]) result = await session.execute( select(Match).where(Match.user_a_id == user_a_id, Match.user_b_id == user_b_id) ) match = result.scalar_one_or_none() if match is None: score_result = calculate_match_score(current_user, to_user) match = Match( user_a_id=user_a_id, user_b_id=user_b_id, match_score=score_result["score"], match_type=source, source_activity_id=activity_id if source == "activity" else None, matched_at=datetime.now(), ) session.add(match) await session.flush() elif source == "activity" and match.source_activity_id is None: match.source_activity_id = activity_id session.add(match) await session.commit() await session.refresh(match) return {"is_mutual": True, "match_id": match.id, "match_info": _level2_info(to_user)} async def list_my_matches(session: AsyncSession, current_user: User) -> list[dict]: other_user = aliased(User) stmt = ( select(Match, other_user) .join( other_user, or_( other_user.id == Match.user_a_id, other_user.id == Match.user_b_id, ), ) .where(or_(Match.user_a_id == current_user.id, Match.user_b_id == current_user.id)) ) result = await session.execute(stmt) rows = result.all() items: list[dict] = [] for match, joined_user in rows: if joined_user.id == current_user.id: continue items.append( { "match_id": match.id, "matched_at": match.matched_at.isoformat(), "other_user": { "user_id": joined_user.id, "nickname": joined_user.nickname, "avatar_url": joined_user.avatar_url, "education": joined_user.education, "city": joined_user.city, "hobbies": joined_user.hobbies, }, } ) return items async def get_match_detail(session: AsyncSession, current_user: User, match_id: int) -> dict | None: match = await session.get(Match, match_id) if match is None: return None if current_user.id not in {match.user_a_id, match.user_b_id}: raise PermissionError("无权限查看该匹配记录") other_user_id = match.user_b_id if match.user_a_id == current_user.id else match.user_a_id other_user = await session.get(User, other_user_id) if other_user is None: return None return { "match_id": match.id, "matched_at": match.matched_at.isoformat(), "other_user": _level3_info(other_user), } async def get_public_user_info(session: AsyncSession, current_user: User, target_user: User) -> dict: if await _mutual_match_exists(session, current_user.id, target_user.id): return _level3_info(target_user) if await _has_liked(session, current_user.id, target_user.id): level2 = _level1_info(target_user) level2.update(_level2_info(target_user)) return level2 return _level1_info(target_user) async def ai_suggest_candidates(session: AsyncSession, current_user: User, limit: int = 5) -> list[dict]: raw_candidates = await get_candidates(session, current_user, "ai") raw_candidates.sort(key=lambda item: item.get("match_score") or 0, reverse=True) main_count = math.ceil(limit * 0.7) main_list = raw_candidates[:main_count] diverse_pool = raw_candidates[main_count:main_count + 10] diverse_count = limit - len(main_list) diverse_list = random.sample(diverse_pool, min(diverse_count, len(diverse_pool))) if diverse_pool and diverse_count > 0 else [] return main_list + diverse_list async def get_match_users(session: AsyncSession, match: Match) -> tuple[User | None, User | None]: user_a = await session.get(User, match.user_a_id) user_b = await session.get(User, match.user_b_id) return user_a, user_b