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": "ƥ��ɹ�" if matched else "����Ӧ", "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, }