635 lines
25 KiB
Python
635 lines
25 KiB
Python
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,
|
||
}
|