from __future__ import annotations from typing import Any from fastapi import HTTPException from sqlalchemy import and_, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from app.enums.credit_product import CreditProductType from app.enums.credit_subscription import CreditSubscriptionStatus from app.enums.team import TEAM_STATUS_LABELS, TeamStatus from app.enums.user import UserType from app.models.credit.subscription import UserCreditSubscription from app.models.payment_order import PaymentOrder from app.models.team import Team from app.models.team_manager_history import TeamManagerHistory from app.models.user import User from app.services.credit.locking import acquire_team_business_lock from app.services.credit.query_service import attach_credit_snapshot, get_user_credit_summary_map from app.services.credit.utils import utc_now from app.services.operation_log_service import log_operation_event from app.utils.id_gen import generate_id async def _assert_transfer_allowed(db: AsyncSession, team: Team) -> None: if team.status != TeamStatus.ACTIVE.value: raise HTTPException(status_code=409, detail="团队已禁用,当前仅允许查看,不能更换队长") checked_at = utc_now() active_subscription = (await db.execute( select(UserCreditSubscription.id) .where( UserCreditSubscription.team_id == team.id, UserCreditSubscription.product_type_snapshot == CreditProductType.TEAM_SUBSCRIPTION.value, UserCreditSubscription.status == CreditSubscriptionStatus.ACTIVE.value, UserCreditSubscription.start_at <= checked_at, UserCreditSubscription.expires_at > checked_at, ) .limit(1) )).scalar_one_or_none() if active_subscription: raise HTTPException(status_code=409, detail="团队仍有有效团队订阅,全部订阅结束后才能更换队长") incomplete_order = (await db.execute( select(PaymentOrder.id) .where( PaymentOrder.team_id_snapshot == team.id, PaymentOrder.product_type == CreditProductType.TEAM_SUBSCRIPTION.value, or_( PaymentOrder.status == "pending", and_(PaymentOrder.status == "paid", PaymentOrder.fulfillment_status != "fulfilled"), ), ) .limit(1) )).scalar_one_or_none() if incomplete_order: raise HTTPException(status_code=409, detail="团队仍有待支付或待履约团队订阅订单,暂不能更换队长") async def set_team_manager(db: AsyncSession, team_id: str, user_id: str) -> Team: """更换团队队长;仅允许转给当前团队成员。""" await acquire_team_business_lock(db, team_id) result = await db.execute( select(Team) .where(Team.id == team_id, Team.deleted_at.is_(None)) .with_for_update() .limit(1) ) team = result.scalar_one_or_none() if not team: raise HTTPException(status_code=404, detail="团队不存在") if team.manager_id == user_id: return team await _assert_transfer_allowed(db, team) user_result = await db.execute( select(User).where(User.id == user_id, User.is_active.is_(True)).with_for_update().limit(1) ) user = user_result.scalar_one_or_none() if not user: raise HTTPException(status_code=404, detail="用户不存在") if user.user_type != UserType.FRONTEND.value: raise HTTPException(status_code=400, detail="仅前台用户可设为团队队长") if user.team_id != team_id: raise HTTPException(status_code=400, detail="新队长必须是当前团队成员") checked_at = utc_now() if team.manager_id: current_history_result = await db.execute( select(TeamManagerHistory) .where( TeamManagerHistory.team_id == team.id, TeamManagerHistory.manager_user_id == team.manager_id, TeamManagerHistory.ended_at.is_(None), ) .order_by(TeamManagerHistory.started_at.desc(), TeamManagerHistory.id.desc()) .with_for_update() .limit(1) ) current_history = current_history_result.scalar_one_or_none() if current_history: current_history.ended_at = checked_at previous_manager_id = team.manager_id db.add( TeamManagerHistory( id=generate_id(), team_id=team.id, manager_user_id=user_id, started_at=checked_at, ) ) team.manager_id = user_id await db.flush() log_operation_event( domain="team", module="team_manager", event_type="TEAM_MANAGER_CHANGED", user_id=user_id, message="团队队长更换成功", detail={ "team_id": team.id, "previous_manager_user_id": previous_manager_id, "new_manager_user_id": user_id, }, ) return team async def is_team_manager(db: AsyncSession, user_id: str, team_id: str | None) -> bool: if not team_id: return False result = await db.execute( select(Team.manager_id).where(Team.id == team_id, Team.deleted_at.is_(None)).limit(1) ) return result.scalar_one_or_none() == user_id async def get_managed_team(db: AsyncSession, user_id: str) -> Team | None: result = await db.execute( select(Team).where(Team.manager_id == user_id, Team.deleted_at.is_(None)).limit(1) ) return result.scalar_one_or_none() async def get_team_members( db: AsyncSession, team_id: str, *, page: int = 1, page_size: int = 20, ) -> dict[str, Any]: page = max(int(page or 1), 1) page_size = min(max(int(page_size or 20), 1), 100) team_result = await db.execute( select(Team).where(Team.id == team_id, Team.deleted_at.is_(None)).limit(1) ) if not team_result.scalar_one_or_none(): raise HTTPException(status_code=404, detail="团队不存在") total = (await db.execute( select(func.count(User.id)).where( User.user_type == UserType.FRONTEND.value, User.team_id == team_id, User.is_active.is_(True), ) )).scalar() or 0 result = await db.execute( select(User) .where( User.user_type == UserType.FRONTEND.value, User.team_id == team_id, User.is_active.is_(True), ) .order_by(User.created_at.asc()) .offset((page - 1) * page_size) .limit(page_size) ) members = list(result.scalars().all()) summary_map = await get_user_credit_summary_map(db, [item.id for item in members]) items = [] for member in members: summary = summary_map.get(member.id) credits = float(summary.available_credits if summary else 0) attach_credit_snapshot(member, credits) items.append( { "id": member.id, "username": member.username, "phone": member.phone, "credits": credits, "personal_credits": float(summary.personal_credits if summary else 0), "team_available_credits": float(summary.team_available_credits if summary else 0), "team_frozen_credits": float(summary.team_frozen_credits if summary else 0), "is_active": member.is_active, "joined_at": member.created_at, } ) return {"items": items, "total": int(total)} async def get_manager_history(db: AsyncSession, team_id: str) -> list[dict[str, Any]]: result = await db.execute( select(TeamManagerHistory, User.username) .join(User, User.id == TeamManagerHistory.manager_user_id) .where(TeamManagerHistory.team_id == team_id) .order_by(TeamManagerHistory.started_at.desc(), TeamManagerHistory.id.desc()) ) return [ { "id": history.id, "team_id": history.team_id, "manager_user_id": history.manager_user_id, "manager_name": username, "started_at": history.started_at, "ended_at": history.ended_at, } for history, username in result.all() ] async def transfer_credits_to_member(*args, **kwargs) -> None: raise HTTPException(status_code=409, detail="当前版本不支持团队积分转账,请使用团队订阅席位额度") async def list_manager_access_teams(db: AsyncSession, user_id: str) -> list[dict[str, Any]]: """返回用户作为当前/历史队长可查看团队流水的团队,按当前团队优先、最近任期倒序。""" history_result = await db.execute( select(TeamManagerHistory.team_id, func.max(TeamManagerHistory.started_at).label("last_started_at")) .where(TeamManagerHistory.manager_user_id == user_id) .group_by(TeamManagerHistory.team_id) ) history_rows = list(history_result.all()) team_ids = [row.team_id for row in history_rows] current_result = await db.execute( select(Team.id).where( Team.manager_id == user_id, Team.deleted_at.is_(None), ) ) for team_id in current_result.scalars().all(): if team_id not in team_ids: team_ids.append(team_id) if not team_ids: return [] teams_result = await db.execute(select(Team).where(Team.id.in_(team_ids))) team_map = {item.id: item for item in teams_result.scalars().all()} started_map = {row.team_id: row.last_started_at for row in history_rows} items: list[dict[str, Any]] = [] for team_id in team_ids: team = team_map.get(team_id) if not team: continue is_current = team.deleted_at is None and team.manager_id == user_id items.append( { "id": team.id, "name": team.name, "code": team.code, "status": team.status, "status_label": TEAM_STATUS_LABELS.get(team.status, "其他状态"), "is_current_manager": is_current, "last_managed_at": started_map.get(team.id), "deleted": team.deleted_at is not None, } ) items.sort( key=lambda item: ( 0 if item["is_current_manager"] else 1, -(item["last_managed_at"].timestamp() if item["last_managed_at"] else 0), item["id"], ) ) return items