xiangqinxiaochengxu/backend/app/services/activity_match_service.py

635 lines
25 KiB
Python
Raw Blame History

from __future__ import annotations
from datetime import datetime
from secrets import token_urlsafe
from pathlib import Path
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import aliased
from app.models.activity import Activity
from app.models.activity_guest_choice import ActivityGuestChoice
from app.models.registration import Registration
from app.models.user import User
from app.config import settings
from app.services.match_service import _level3_info
from app.utils.media import sanitize_user_media_payload
from app.utils.wx_api import generate_unlimited_qrcode, generate_url_link
ACTIVE_REGISTRATION_STATUSES = {1, 2, 4}
ACTIVE_CHOICE_STATUS = 1
CANCELLED_CHOICE_STATUS = 0
UPLOAD_ROOT = Path(__file__).resolve().parent.parent / "static" / "uploads"
def _format_datetime(value: datetime | None) -> str | None:
return value.isoformat() if value else None
def _candidate_item(user: User, selected: bool = False) -> dict:
return {
"user_id": user.id,
"nickname": user.nickname,
"birth_year": user.birth_year,
"city": user.city,
"personality_tags": user.personality_tags if isinstance(user.personality_tags, list) else [],
"avatar_blur_url": user.avatar_blur_url,
"selected": selected,
}
def _list_item(user: User, *, selected_at: datetime | None, matched_at: datetime | None, matched: bool) -> dict:
return {
"user_id": user.id,
"nickname": user.nickname,
"avatar_url": user.avatar_url,
"city": user.city,
"education": user.education,
"hobbies": user.hobbies if isinstance(user.hobbies, list) else [],
"selected_at": _format_datetime(selected_at),
"matched_at": _format_datetime(matched_at),
"match_status": "success" if matched else "pending",
"match_status_text": "匹配成功" if matched else "待回应",
"can_view_detail": matched,
}
def _candidate_item(user: User, selected: bool = False) -> dict:
return sanitize_user_media_payload({
"user_id": user.id,
"nickname": user.nickname,
"birth_year_range": _birth_year_range(user.birth_year),
"city": user.city,
"personality_tags": user.personality_tags if isinstance(user.personality_tags, list) else [],
"avatar_blur_url": user.avatar_blur_url,
"selected": selected,
})
def _list_item(user: User, *, selected_at: datetime | None, matched_at: datetime | None, matched: bool) -> dict:
return sanitize_user_media_payload({
"user_id": user.id,
"nickname": user.nickname,
"avatar_url": user.avatar_url,
"city": user.city,
"education": user.education,
"hobbies": user.hobbies if isinstance(user.hobbies, list) else [],
"selected_at": _format_datetime(selected_at),
"matched_at": _format_datetime(matched_at),
"match_status": "success" if matched else "pending",
"match_status_text": "ƥ<EFBFBD><EFBFBD>ɹ<EFBFBD>" if matched else "<EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ӧ",
"can_view_detail": matched,
})
async def get_activity_by_id(session: AsyncSession, activity_id: int) -> Activity | None:
result = await session.execute(select(Activity).where(Activity.id == activity_id))
return result.scalar_one_or_none()
async def get_registration(session: AsyncSession, user_id: int, activity_id: int) -> Registration | None:
result = await session.execute(
select(Registration).where(
Registration.user_id == user_id,
Registration.activity_id == activity_id,
)
)
return result.scalar_one_or_none()
async def ensure_activity_share_token(session: AsyncSession, activity: Activity) -> Activity:
if activity.share_token:
return activity
while True:
candidate = token_urlsafe(8).replace("-", "").replace("_", "")[:12]
exists = await session.execute(select(Activity.id).where(Activity.share_token == candidate))
if exists.scalar_one_or_none() is None:
activity.share_token = candidate
session.add(activity)
await session.commit()
await session.refresh(activity)
return activity
async def get_activity_by_share_token(session: AsyncSession, share_token: str) -> Activity | None:
result = await session.execute(select(Activity).where(Activity.share_token == share_token))
return result.scalar_one_or_none()
def _persist_activity_qrcode(activity_id: int, content: bytes) -> str:
qr_dir = UPLOAD_ROOT / "activities" / str(activity_id)
qr_dir.mkdir(parents=True, exist_ok=True)
qr_path = qr_dir / "share_qrcode.png"
qr_path.write_bytes(content)
return f"/static/uploads/activities/{activity_id}/{qr_path.name}"
async def ensure_activity_share_qrcode(session: AsyncSession, activity: Activity) -> Activity:
activity = await ensure_activity_share_token(session, activity)
if activity.share_qr_url:
return activity
page = settings.wx_miniprogram_page_activity_detail or "pages/activity-detail/activity-detail"
content = await generate_unlimited_qrcode(page=page, scene=f"share_token={activity.share_token}")
if not content:
return activity
activity.share_qr_url = _persist_activity_qrcode(activity.id, content)
session.add(activity)
await session.commit()
await session.refresh(activity)
return activity
def resolve_activity_stage(activity: Activity) -> str:
now = datetime.now()
if activity.start_time and now < activity.start_time:
return "before_start"
if activity.end_time and now > activity.end_time:
return "ended"
if activity.match_deadline and now > activity.match_deadline:
return "matching_closed"
return "matching_open"
async def _require_bound_user(session: AsyncSession, activity: Activity, current_user: User) -> Registration:
registration = await get_registration(session, current_user.id, activity.id)
if registration is None or registration.status not in ACTIVE_REGISTRATION_STATUSES:
raise PermissionError("请先绑定当前活动")
return registration
async def _active_choice_count(session: AsyncSession, activity_id: int, user_id: int) -> int:
result = await session.execute(
select(func.count(ActivityGuestChoice.id)).where(
ActivityGuestChoice.activity_id == activity_id,
ActivityGuestChoice.from_user_id == user_id,
ActivityGuestChoice.status == ACTIVE_CHOICE_STATUS,
)
)
return int(result.scalar() or 0)
async def _count_registered_by_gender(session: AsyncSession, activity_id: int, gender: int) -> int:
stmt = (
select(func.count(Registration.id))
.join(User, User.id == Registration.user_id)
.where(
Registration.activity_id == activity_id,
Registration.status.in_(list(ACTIVE_REGISTRATION_STATUSES)),
User.gender == gender,
)
)
result = await session.execute(stmt)
return int(result.scalar() or 0)
async def _has_mutual_choice(session: AsyncSession, activity_id: int, user_a_id: int, user_b_id: int) -> bool:
result = await session.execute(
select(ActivityGuestChoice.id).where(
ActivityGuestChoice.activity_id == activity_id,
ActivityGuestChoice.from_user_id == user_a_id,
ActivityGuestChoice.to_user_id == user_b_id,
ActivityGuestChoice.status == ACTIVE_CHOICE_STATUS,
)
)
return result.scalar_one_or_none() is not None
async def _matched_at(session: AsyncSession, activity_id: int, user_a_id: int, user_b_id: int) -> datetime | None:
forward = await session.execute(
select(ActivityGuestChoice.created_at).where(
ActivityGuestChoice.activity_id == activity_id,
ActivityGuestChoice.from_user_id == user_a_id,
ActivityGuestChoice.to_user_id == user_b_id,
ActivityGuestChoice.status == ACTIVE_CHOICE_STATUS,
)
)
backward = await session.execute(
select(ActivityGuestChoice.created_at).where(
ActivityGuestChoice.activity_id == activity_id,
ActivityGuestChoice.from_user_id == user_b_id,
ActivityGuestChoice.to_user_id == user_a_id,
ActivityGuestChoice.status == ACTIVE_CHOICE_STATUS,
)
)
times = [forward.scalar_one_or_none(), backward.scalar_one_or_none()]
times = [item for item in times if item is not None]
return max(times) if times else None
async def build_activity_share_info(
session: AsyncSession,
activity: Activity,
current_user: User | None,
) -> dict:
activity = await ensure_activity_share_token(session, activity)
activity = await ensure_activity_share_qrcode(session, activity)
registration = None
if current_user is not None:
registration = await get_registration(session, current_user.id, activity.id)
stage = resolve_activity_stage(activity)
selected_count = 0
registered_male = await _count_registered_by_gender(session, activity.id, 1)
registered_female = await _count_registered_by_gender(session, activity.id, 2)
is_bound = bool(registration and registration.status in ACTIVE_REGISTRATION_STATUSES)
if current_user is not None and registration and registration.status in ACTIVE_REGISTRATION_STATUSES:
selected_count = await _active_choice_count(session, activity.id, current_user.id)
can_register = bool(activity.is_published == 1)
if activity.signup_deadline and activity.signup_deadline <= datetime.now():
can_register = False
if current_user is None:
can_register = False
elif current_user.audit_status != 2 and activity.require_audit == 1:
can_register = False
elif current_user.gender not in {1, 2}:
can_register = False
elif is_bound:
can_register = True
elif current_user.gender == 1 and activity.capacity_male > 0 and registered_male >= activity.capacity_male:
can_register = False
elif current_user.gender == 2 and activity.capacity_female > 0 and registered_female >= activity.capacity_female:
can_register = False
return {
"id": activity.id,
"title": activity.title,
"description": activity.description,
"category": activity.category,
"location": activity.location,
"cover_image": activity.cover_image,
"start_time": _format_datetime(activity.start_time),
"end_time": _format_datetime(activity.end_time),
"signup_deadline": _format_datetime(activity.signup_deadline),
"match_deadline": _format_datetime(activity.match_deadline),
"share_token": activity.share_token,
"share_qr_url": activity.share_qr_url,
"selection_limit": activity.selection_limit,
"stage": stage,
"is_bound": is_bound,
"is_registered": is_bound,
"can_register": can_register,
"registered_male": registered_male,
"registered_female": registered_female,
"capacity_male": activity.capacity_male,
"capacity_female": activity.capacity_female,
"can_view_candidates": bool(is_bound and stage in {"matching_open", "matching_closed", "ended"}),
"can_enter_match": bool(is_bound and stage in {"matching_open", "matching_closed"}),
"selected_count": selected_count,
"remaining_count": max(activity.selection_limit - selected_count, 0),
}
async def get_activity_match_state(session: AsyncSession, activity: Activity, current_user: User) -> dict:
await _require_bound_user(session, activity, current_user)
stage = resolve_activity_stage(activity)
selected_count = await _active_choice_count(session, activity.id, current_user.id)
can_view = stage in {"matching_open", "matching_closed", "ended"}
can_modify = stage == "matching_open"
return {
"activity_id": activity.id,
"activity_title": activity.title,
"stage": stage,
"is_bound": True,
"can_view_candidates": can_view,
"can_choose": can_modify,
"can_modify_choice": can_modify,
"selection_limit": activity.selection_limit,
"selected_count": selected_count,
"remaining_count": max(activity.selection_limit - selected_count, 0),
"match_deadline": _format_datetime(activity.match_deadline),
"start_time": _format_datetime(activity.start_time),
}
async def bind_activity_user(session: AsyncSession, activity: Activity, current_user: User) -> Registration:
from app.services.activity_service import register_activity
return await register_activity(session, activity, current_user)
async def list_activity_candidates(session: AsyncSession, activity: Activity, current_user: User) -> list[dict]:
await _require_bound_user(session, activity, current_user)
stage = resolve_activity_stage(activity)
if stage == "before_start":
raise PermissionError("活动开始前暂不可查看嘉宾资料")
selected_rows = await session.execute(
select(ActivityGuestChoice.to_user_id).where(
ActivityGuestChoice.activity_id == activity.id,
ActivityGuestChoice.from_user_id == current_user.id,
ActivityGuestChoice.status == ACTIVE_CHOICE_STATUS,
)
)
selected_ids = {row[0] for row in selected_rows.all()}
stmt = (
select(User)
.join(Registration, Registration.user_id == User.id)
.where(
Registration.activity_id == activity.id,
Registration.status.in_(list(ACTIVE_REGISTRATION_STATUSES)),
User.id != current_user.id,
User.audit_status == 2,
User.is_active == 1,
)
.order_by(Registration.created_at.asc(), User.updated_at.desc())
)
if current_user.gender in {1, 2}:
stmt = stmt.where(User.gender == (2 if current_user.gender == 1 else 1))
result = await session.execute(stmt)
users = result.scalars().all()
return [_candidate_item(user, selected=user.id in selected_ids) for user in users]
async def create_activity_choice(
session: AsyncSession,
activity: Activity,
current_user: User,
target_user: User,
) -> dict:
await _require_bound_user(session, activity, current_user)
await _require_bound_user(session, activity, target_user)
if resolve_activity_stage(activity) != "matching_open":
raise ValueError("当前活动匹配已锁定,暂不可修改")
if current_user.id == target_user.id:
raise ValueError("不能选择自己")
if current_user.gender in {1, 2} and target_user.gender == current_user.gender:
raise ValueError("仅可选择异性嘉宾")
selected_count = await _active_choice_count(session, activity.id, current_user.id)
existing_result = await session.execute(
select(ActivityGuestChoice).where(
ActivityGuestChoice.activity_id == activity.id,
ActivityGuestChoice.from_user_id == current_user.id,
ActivityGuestChoice.to_user_id == target_user.id,
)
)
existing = existing_result.scalar_one_or_none()
if existing and existing.status == ACTIVE_CHOICE_STATUS:
raise ValueError("你已选择该嘉宾")
if existing is None and selected_count >= activity.selection_limit:
raise ValueError("当前活动下你的可选人数已达上限")
if existing is not None and existing.status != ACTIVE_CHOICE_STATUS and selected_count >= activity.selection_limit:
raise ValueError("请先撤销一位已选嘉宾后再继续选择")
if existing is None:
existing = ActivityGuestChoice(
activity_id=activity.id,
from_user_id=current_user.id,
to_user_id=target_user.id,
status=ACTIVE_CHOICE_STATUS,
)
else:
existing.status = ACTIVE_CHOICE_STATUS
existing.cancelled_at = None
session.add(existing)
await session.commit()
selected_count = await _active_choice_count(session, activity.id, current_user.id)
is_mutual = await _has_mutual_choice(session, activity.id, target_user.id, current_user.id)
return {
"activity_id": activity.id,
"selected_count": selected_count,
"remaining_count": max(activity.selection_limit - selected_count, 0),
"is_mutual": is_mutual,
"match_user_id": target_user.id if is_mutual else None,
}
async def cancel_activity_choice(
session: AsyncSession,
activity: Activity,
current_user: User,
target_user_id: int,
) -> dict:
await _require_bound_user(session, activity, current_user)
if resolve_activity_stage(activity) != "matching_open":
raise ValueError("当前活动匹配已锁定,暂不可修改")
result = await session.execute(
select(ActivityGuestChoice).where(
ActivityGuestChoice.activity_id == activity.id,
ActivityGuestChoice.from_user_id == current_user.id,
ActivityGuestChoice.to_user_id == target_user_id,
ActivityGuestChoice.status == ACTIVE_CHOICE_STATUS,
)
)
choice = result.scalar_one_or_none()
if choice is None:
raise ValueError("未找到可撤销的选择记录")
choice.status = CANCELLED_CHOICE_STATUS
choice.cancelled_at = datetime.now()
session.add(choice)
await session.commit()
selected_count = await _active_choice_count(session, activity.id, current_user.id)
return {
"activity_id": activity.id,
"selected_count": selected_count,
"remaining_count": max(activity.selection_limit - selected_count, 0),
"is_mutual": False,
"match_user_id": None,
}
async def list_my_activity_choices(session: AsyncSession, activity: Activity, current_user: User) -> list[dict]:
await _require_bound_user(session, activity, current_user)
other_user = aliased(User)
stmt = (
select(ActivityGuestChoice, other_user)
.join(other_user, other_user.id == ActivityGuestChoice.to_user_id)
.where(
ActivityGuestChoice.activity_id == activity.id,
ActivityGuestChoice.from_user_id == current_user.id,
ActivityGuestChoice.status == ACTIVE_CHOICE_STATUS,
)
.order_by(ActivityGuestChoice.created_at.desc())
)
result = await session.execute(stmt)
rows = result.all()
items = []
for choice, user in rows:
matched = await _has_mutual_choice(session, activity.id, user.id, current_user.id)
matched_at = await _matched_at(session, activity.id, current_user.id, user.id) if matched else None
items.append(_list_item(user, selected_at=choice.created_at, matched_at=matched_at, matched=matched))
return items
async def list_activity_liked_me(session: AsyncSession, activity: Activity, current_user: User) -> list[dict]:
await _require_bound_user(session, activity, current_user)
other_user = aliased(User)
stmt = (
select(ActivityGuestChoice, other_user)
.join(other_user, other_user.id == ActivityGuestChoice.from_user_id)
.where(
ActivityGuestChoice.activity_id == activity.id,
ActivityGuestChoice.to_user_id == current_user.id,
ActivityGuestChoice.status == ACTIVE_CHOICE_STATUS,
)
.order_by(ActivityGuestChoice.created_at.desc())
)
result = await session.execute(stmt)
rows = result.all()
items = []
for choice, user in rows:
matched = await _has_mutual_choice(session, activity.id, current_user.id, user.id)
matched_at = await _matched_at(session, activity.id, current_user.id, user.id) if matched else None
items.append(_list_item(user, selected_at=choice.created_at, matched_at=matched_at, matched=matched))
return items
async def list_activity_mutual_matches(session: AsyncSession, activity: Activity, current_user: User) -> list[dict]:
await _require_bound_user(session, activity, current_user)
my_choices = await list_my_activity_choices(session, activity, current_user)
return [item for item in my_choices if item["match_status"] == "success"]
async def get_activity_match_detail(
session: AsyncSession,
activity: Activity,
current_user: User,
target_user_id: int,
) -> dict | None:
await _require_bound_user(session, activity, current_user)
if not await _has_mutual_choice(session, activity.id, current_user.id, target_user_id):
raise PermissionError("当前仅可查看互选成功对象的完整资料")
if not await _has_mutual_choice(session, activity.id, target_user_id, current_user.id):
raise PermissionError("当前仅可查看互选成功对象的完整资料")
target_user = await session.get(User, target_user_id)
if target_user is None:
return None
matched_at = await _matched_at(session, activity.id, current_user.id, target_user_id)
return {
"activity_id": activity.id,
"target_user_id": target_user.id,
"matched_at": _format_datetime(matched_at) or _format_datetime(datetime.now()),
"other_user": _level3_info(target_user),
}
async def build_activity_share_path(session: AsyncSession, activity: Activity) -> str:
activity = await ensure_activity_share_token(session, activity)
return f"pages/activity-detail/activity-detail?share_token={activity.share_token}"
async def build_activity_share_api_url(session: AsyncSession, activity: Activity, api_base_url: str | None = None) -> str:
activity = await ensure_activity_share_token(session, activity)
base_url = (api_base_url or "").rstrip("/")
if base_url:
return f"{base_url}/activities/share/{activity.share_token}"
return activity.share_token or ""
async def build_activity_share_link(session: AsyncSession, activity: Activity, public_site_url: str | None = None) -> str:
activity = await ensure_activity_share_token(session, activity)
base_url = (public_site_url or "").rstrip("/")
if base_url:
return f"{base_url}/share/activity/{activity.share_token}"
return f"/share/activity/{activity.share_token}"
async def build_activity_url_link(session: AsyncSession, activity: Activity) -> str | None:
activity = await ensure_activity_share_token(session, activity)
page = settings.wx_miniprogram_page_activity_detail or "pages/activity-detail/activity-detail"
if activity.share_url_link:
return activity.share_url_link
url_link, error_message = await generate_url_link(path=page, query=f"share_token={activity.share_token}")
activity.share_url_link = url_link
activity.share_url_link_error = error_message
session.add(activity)
await session.commit()
await session.refresh(activity)
return activity.share_url_link
async def refresh_activity_share_assets(session: AsyncSession, activity: Activity) -> Activity:
activity.share_qr_url = None
activity.share_url_link = None
activity.share_url_link_error = None
session.add(activity)
await session.commit()
await session.refresh(activity)
activity = await ensure_activity_share_qrcode(session, activity)
await build_activity_url_link(session, activity)
await session.refresh(activity)
return activity
async def get_bound_activity_by_id(session: AsyncSession, activity_id: int, current_user: User) -> Activity:
activity = await get_activity_by_id(session, activity_id)
if activity is None:
raise ValueError("活动不存在")
await _require_bound_user(session, activity, current_user)
return activity
async def get_admin_activity_match_overview(session: AsyncSession, activity: Activity) -> dict:
total_choices_result = await session.execute(
select(func.count(ActivityGuestChoice.id)).where(
ActivityGuestChoice.activity_id == activity.id,
ActivityGuestChoice.status == ACTIVE_CHOICE_STATUS,
)
)
total_choices = int(total_choices_result.scalar() or 0)
rows = await session.execute(
select(ActivityGuestChoice.from_user_id, ActivityGuestChoice.to_user_id, ActivityGuestChoice.created_at).where(
ActivityGuestChoice.activity_id == activity.id,
ActivityGuestChoice.status == ACTIVE_CHOICE_STATUS,
)
)
active_rows = rows.all()
active_pairs = {(row.from_user_id, row.to_user_id): row.created_at for row in active_rows}
matched_pairs = set()
for from_user_id, to_user_id in active_pairs:
reverse = (to_user_id, from_user_id)
key = tuple(sorted((from_user_id, to_user_id)))
if reverse in active_pairs:
matched_pairs.add(key)
users_stmt = (
select(User.id, User.nickname)
.join(Registration, Registration.user_id == User.id)
.where(
Registration.activity_id == activity.id,
Registration.status.in_(list(ACTIVE_REGISTRATION_STATUSES)),
)
)
user_rows = await session.execute(users_stmt)
user_map = {row.id: row.nickname for row in user_rows.all()}
items = []
for row in active_rows:
matched = (min(row.from_user_id, row.to_user_id), max(row.from_user_id, row.to_user_id)) in matched_pairs
items.append(
{
"from_user_id": row.from_user_id,
"from_nickname": user_map.get(row.from_user_id),
"to_user_id": row.to_user_id,
"to_nickname": user_map.get(row.to_user_id),
"selected_at": _format_datetime(row.created_at),
"is_mutual": matched,
}
)
items.sort(key=lambda item: (not item["is_mutual"], item["selected_at"] or ""))
return {
"activity_id": activity.id,
"activity_title": activity.title,
"total_choices": total_choices,
"mutual_match_count": len(matched_pairs),
"choices": items,
}