xiangqinxiaochengxu/backend/app/services/match_service.py
2026-04-17 21:28:56 +08:00

482 lines
16 KiB
Python

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 current_user.gender in {1, 2}:
stmt = stmt.where(User.gender == (2 if current_user.gender == 1 else 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)
match_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))
)
match_result = await session.execute(match_stmt)
match_rows = match_result.all()
like_stmt = (
select(MatchLike, other_user)
.join(other_user, other_user.id == MatchLike.to_user_id)
.where(MatchLike.from_user_id == current_user.id)
)
like_result = await session.execute(like_stmt)
like_rows = like_result.all()
items: list[dict] = []
matched_user_ids: set[int] = set()
for match, joined_user in match_rows:
if joined_user.id == current_user.id:
continue
matched_user_ids.add(joined_user.id)
items.append(
{
"match_id": match.id,
"matched_at": match.matched_at.isoformat(),
"match_status": "success",
"match_status_text": "匹配成功",
"fail_reason": None,
"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,
},
}
)
for like, joined_user in like_rows:
if joined_user.id == current_user.id or joined_user.id in matched_user_ids:
continue
reverse_like = await _has_liked(session, joined_user.id, current_user.id)
items.append(
{
"match_id": f"like-{like.id}",
"matched_at": like.created_at.isoformat(),
"match_status": "success" if reverse_like else "pending",
"match_status_text": "匹配成功" if reverse_like else "待回应",
"fail_reason": None if reverse_like else "等待对方回应",
"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,
},
}
)
items.sort(key=lambda item: item["matched_at"], reverse=True)
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