891 lines
36 KiB
Python
891 lines
36 KiB
Python
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
from decimal import Decimal
|
|
from typing import Any
|
|
|
|
from fastapi import HTTPException
|
|
from sqlalchemy import case, func, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.enums.credit_balance import CreditAllocationAction, CreditScope
|
|
from app.enums.credit_product import (
|
|
SUBSCRIPTION_BILLING_CYCLE_LABELS,
|
|
SUBSCRIPTION_TIER_LABELS,
|
|
CreditProductType,
|
|
)
|
|
from app.enums.credit_subscription import (
|
|
CREDIT_SUBSCRIPTION_PERIOD_STATUS_LABELS,
|
|
CREDIT_SUBSCRIPTION_STATUS_LABELS,
|
|
CreditSubscriptionStatus,
|
|
)
|
|
from app.enums.team import TEAM_SEAT_STATUS_LABELS, TeamSeatStatus, TeamStatus
|
|
from app.models.credit.allocation import CreditRecordAllocation
|
|
from app.models.credit.balance import UserCreditBalance
|
|
from app.models.credit.subscription import UserCreditSubscription
|
|
from app.models.credit.subscription_period import UserCreditSubscriptionPeriod
|
|
from app.models.credit.team_seat import TeamSubscriptionSeat
|
|
from app.models.credit.team_seat_usage import TeamSubscriptionSeatUsage
|
|
from app.models.team import Team
|
|
from app.models.user import User
|
|
from app.services.credit.locking import (
|
|
acquire_subscription_credit_lock,
|
|
acquire_subscription_credit_locks,
|
|
acquire_team_business_lock,
|
|
)
|
|
from app.services.credit.utils import to_credit_decimal, utc_now
|
|
from app.services.operation_log_service import log_operation_event
|
|
from app.utils.id_gen import generate_id
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class TeamCreditCandidate:
|
|
balance: UserCreditBalance
|
|
seat: TeamSubscriptionSeat
|
|
usage: TeamSubscriptionSeatUsage | None
|
|
subscription: UserCreditSubscription
|
|
period: UserCreditSubscriptionPeriod
|
|
available: Decimal
|
|
|
|
|
|
def _remaining(seat: TeamSubscriptionSeat, usage: TeamSubscriptionSeatUsage | None) -> Decimal:
|
|
allocated = to_credit_decimal(seat.monthly_allocated_credits)
|
|
used = to_credit_decimal(usage.used_credits if usage else 0)
|
|
return max(Decimal("0.00"), allocated - used)
|
|
|
|
|
|
def _seat_status(
|
|
seat: TeamSubscriptionSeat,
|
|
subscription: UserCreditSubscription,
|
|
checked_at: datetime,
|
|
) -> str:
|
|
if seat.deleted_at is not None or seat.cancelled_at is not None:
|
|
return TeamSeatStatus.CANCELLED.value
|
|
if subscription.expires_at <= checked_at or subscription.status != CreditSubscriptionStatus.ACTIVE.value:
|
|
return TeamSeatStatus.EXPIRED.value
|
|
return TeamSeatStatus.ACTIVE.value
|
|
|
|
|
|
async def _load_team(db: AsyncSession, team_id: str, *, for_update: bool = False) -> Team:
|
|
stmt = select(Team).where(Team.id == team_id, Team.deleted_at.is_(None)).limit(1)
|
|
if for_update:
|
|
stmt = stmt.with_for_update()
|
|
result = await db.execute(stmt)
|
|
team = result.scalar_one_or_none()
|
|
if not team:
|
|
raise HTTPException(status_code=404, detail="团队不存在")
|
|
return team
|
|
|
|
|
|
async def _assert_manager_active_team(
|
|
db: AsyncSession,
|
|
*,
|
|
team_id: str,
|
|
manager_user_id: str,
|
|
) -> Team:
|
|
await acquire_team_business_lock(db, team_id)
|
|
team = await _load_team(db, team_id, for_update=True)
|
|
if team.manager_id != manager_user_id:
|
|
raise HTTPException(status_code=403, detail="只有当前团队队长才能管理团队订阅席位")
|
|
if team.status != TeamStatus.ACTIVE.value:
|
|
raise HTTPException(status_code=409, detail="团队已禁用,当前仅允许查看,不能修改团队订阅席位")
|
|
return team
|
|
|
|
|
|
async def _load_subscription(
|
|
db: AsyncSession,
|
|
*,
|
|
subscription_id: str,
|
|
team_id: str | None = None,
|
|
request_time: datetime | None = None,
|
|
for_update: bool = False,
|
|
require_active: bool = False,
|
|
) -> UserCreditSubscription:
|
|
checked_at = request_time or utc_now()
|
|
stmt = select(UserCreditSubscription).where(
|
|
UserCreditSubscription.id == subscription_id,
|
|
UserCreditSubscription.product_type_snapshot == CreditProductType.TEAM_SUBSCRIPTION.value,
|
|
)
|
|
if team_id:
|
|
stmt = stmt.where(UserCreditSubscription.team_id == team_id)
|
|
if require_active:
|
|
stmt = stmt.where(
|
|
UserCreditSubscription.status == CreditSubscriptionStatus.ACTIVE.value,
|
|
UserCreditSubscription.start_at <= checked_at,
|
|
UserCreditSubscription.expires_at > checked_at,
|
|
)
|
|
stmt = stmt.limit(1)
|
|
if for_update:
|
|
stmt = stmt.with_for_update()
|
|
result = await db.execute(stmt)
|
|
subscription = result.scalar_one_or_none()
|
|
if not subscription:
|
|
raise HTTPException(status_code=404, detail="团队订阅不存在或当前不可用")
|
|
return subscription
|
|
|
|
|
|
async def _load_current_period(
|
|
db: AsyncSession,
|
|
*,
|
|
subscription_id: str,
|
|
request_time: datetime,
|
|
) -> UserCreditSubscriptionPeriod | None:
|
|
result = await db.execute(
|
|
select(UserCreditSubscriptionPeriod)
|
|
.where(
|
|
UserCreditSubscriptionPeriod.subscription_id == subscription_id,
|
|
UserCreditSubscriptionPeriod.valid_from <= request_time,
|
|
UserCreditSubscriptionPeriod.expires_at > request_time,
|
|
)
|
|
.order_by(UserCreditSubscriptionPeriod.sequence.asc())
|
|
.limit(1)
|
|
)
|
|
return result.scalar_one_or_none()
|
|
|
|
|
|
async def _load_period_balance(
|
|
db: AsyncSession,
|
|
*,
|
|
period: UserCreditSubscriptionPeriod | None,
|
|
for_update: bool = False,
|
|
) -> UserCreditBalance | None:
|
|
if not period or not period.issued_balance_id:
|
|
return None
|
|
stmt = select(UserCreditBalance).where(UserCreditBalance.id == period.issued_balance_id).limit(1)
|
|
if for_update:
|
|
stmt = stmt.with_for_update()
|
|
result = await db.execute(stmt)
|
|
return result.scalar_one_or_none()
|
|
|
|
|
|
async def _usage_map(
|
|
db: AsyncSession,
|
|
*,
|
|
seat_ids: list[str],
|
|
period_id: str,
|
|
for_update: bool = False,
|
|
) -> dict[str, TeamSubscriptionSeatUsage]:
|
|
if not seat_ids:
|
|
return {}
|
|
stmt = select(TeamSubscriptionSeatUsage).where(
|
|
TeamSubscriptionSeatUsage.seat_id.in_(seat_ids),
|
|
TeamSubscriptionSeatUsage.subscription_period_id == period_id,
|
|
)
|
|
if for_update:
|
|
stmt = stmt.with_for_update()
|
|
result = await db.execute(stmt)
|
|
return {item.seat_id: item for item in result.scalars().all()}
|
|
|
|
|
|
async def _validate_allocation_pool(
|
|
db: AsyncSession,
|
|
*,
|
|
subscription: UserCreditSubscription,
|
|
target_seat_id: str | None,
|
|
target_allocated: Decimal,
|
|
request_time: datetime,
|
|
) -> None:
|
|
period = await _load_current_period(
|
|
db, subscription_id=subscription.id, request_time=request_time
|
|
)
|
|
balance = await _load_period_balance(db, period=period, for_update=True)
|
|
if not period or not balance:
|
|
raise HTTPException(status_code=409, detail="当前团队订阅周期积分尚未发放,暂不能调整席位额度")
|
|
|
|
result = await db.execute(
|
|
select(TeamSubscriptionSeat)
|
|
.where(
|
|
TeamSubscriptionSeat.subscription_id == subscription.id,
|
|
TeamSubscriptionSeat.deleted_at.is_(None),
|
|
TeamSubscriptionSeat.cancelled_at.is_(None),
|
|
)
|
|
.order_by(TeamSubscriptionSeat.id.asc())
|
|
.with_for_update()
|
|
)
|
|
seats = list(result.scalars().all())
|
|
usage_map = await _usage_map(
|
|
db,
|
|
seat_ids=[item.id for item in seats],
|
|
period_id=period.id,
|
|
for_update=True,
|
|
)
|
|
total_remaining = Decimal("0.00")
|
|
for seat in seats:
|
|
if seat.id == target_seat_id:
|
|
used = to_credit_decimal(usage_map.get(seat.id).used_credits if usage_map.get(seat.id) else 0)
|
|
total_remaining += max(Decimal("0.00"), target_allocated - used)
|
|
else:
|
|
total_remaining += _remaining(seat, usage_map.get(seat.id))
|
|
if target_seat_id is None:
|
|
total_remaining += target_allocated
|
|
|
|
if total_remaining > to_credit_decimal(balance.unspent_amount):
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail=(
|
|
f"席位剩余可消费额度合计不能超过当前周期剩余团队积分,"
|
|
f"当前最多可分配 {float(max(Decimal('0.00'), to_credit_decimal(balance.unspent_amount) - (total_remaining - target_allocated))):.2f} 积分"
|
|
),
|
|
)
|
|
|
|
|
|
async def create_seat(
|
|
db: AsyncSession,
|
|
*,
|
|
team_id: str,
|
|
subscription_id: str,
|
|
user_id: str,
|
|
monthly_allocated_credits: Decimal | float | int,
|
|
manager_user_id: str,
|
|
request_time: datetime | None = None,
|
|
) -> TeamSubscriptionSeat:
|
|
checked_at = request_time or utc_now()
|
|
allocated = to_credit_decimal(monthly_allocated_credits)
|
|
if allocated <= 0:
|
|
raise HTTPException(status_code=400, detail="席位月额度必须大于0")
|
|
await _assert_manager_active_team(db, team_id=team_id, manager_user_id=manager_user_id)
|
|
await acquire_subscription_credit_lock(db, subscription_id)
|
|
subscription = await _load_subscription(
|
|
db, subscription_id=subscription_id, team_id=team_id,
|
|
request_time=checked_at, for_update=True, require_active=True,
|
|
)
|
|
user_result = await db.execute(select(User).where(User.id == user_id).with_for_update().limit(1))
|
|
user = user_result.scalar_one_or_none()
|
|
if not user or user.team_id != team_id or not user.is_active:
|
|
raise HTTPException(status_code=400, detail="席位用户必须是当前团队的有效成员")
|
|
|
|
existing = (await db.execute(
|
|
select(TeamSubscriptionSeat.id).where(
|
|
TeamSubscriptionSeat.subscription_id == subscription_id,
|
|
TeamSubscriptionSeat.user_id == user_id,
|
|
TeamSubscriptionSeat.deleted_at.is_(None),
|
|
TeamSubscriptionSeat.cancelled_at.is_(None),
|
|
).limit(1)
|
|
)).scalar_one_or_none()
|
|
if existing:
|
|
raise HTTPException(status_code=409, detail="该用户已经占用当前团队订阅的席位")
|
|
|
|
active_count = (await db.execute(
|
|
select(func.count(TeamSubscriptionSeat.id)).where(
|
|
TeamSubscriptionSeat.subscription_id == subscription_id,
|
|
TeamSubscriptionSeat.deleted_at.is_(None),
|
|
TeamSubscriptionSeat.cancelled_at.is_(None),
|
|
)
|
|
)).scalar() or 0
|
|
if int(active_count) >= int(subscription.quantity_snapshot):
|
|
raise HTTPException(status_code=409, detail="当前团队订阅席位已经分配完毕")
|
|
|
|
await _validate_allocation_pool(
|
|
db,
|
|
subscription=subscription,
|
|
target_seat_id=None,
|
|
target_allocated=allocated,
|
|
request_time=checked_at,
|
|
)
|
|
seat = TeamSubscriptionSeat(
|
|
id=generate_id(),
|
|
team_id=team_id,
|
|
subscription_id=subscription_id,
|
|
user_id=user_id,
|
|
monthly_allocated_credits=allocated,
|
|
created_by_user_id=manager_user_id,
|
|
)
|
|
db.add(seat)
|
|
await db.flush()
|
|
log_operation_event(
|
|
domain="team",
|
|
module="team_subscription",
|
|
event_type="TEAM_SUBSCRIPTION_SEAT_CREATED",
|
|
user_id=manager_user_id,
|
|
message="团队订阅席位创建成功",
|
|
detail={
|
|
"team_id": team_id,
|
|
"subscription_id": subscription_id,
|
|
"seat_id": seat.id,
|
|
"seat_user_id": user_id,
|
|
"monthly_allocated_credits": float(allocated),
|
|
},
|
|
)
|
|
return seat
|
|
|
|
|
|
async def update_seat(
|
|
db: AsyncSession,
|
|
*,
|
|
team_id: str,
|
|
seat_id: str,
|
|
monthly_allocated_credits: Decimal | float | int,
|
|
manager_user_id: str,
|
|
request_time: datetime | None = None,
|
|
) -> TeamSubscriptionSeat:
|
|
checked_at = request_time or utc_now()
|
|
allocated = to_credit_decimal(monthly_allocated_credits)
|
|
if allocated <= 0:
|
|
raise HTTPException(status_code=400, detail="席位月额度必须大于0")
|
|
await _assert_manager_active_team(db, team_id=team_id, manager_user_id=manager_user_id)
|
|
seat_result = await db.execute(
|
|
select(TeamSubscriptionSeat)
|
|
.where(
|
|
TeamSubscriptionSeat.id == seat_id,
|
|
TeamSubscriptionSeat.team_id == team_id,
|
|
TeamSubscriptionSeat.deleted_at.is_(None),
|
|
TeamSubscriptionSeat.cancelled_at.is_(None),
|
|
)
|
|
.with_for_update()
|
|
.limit(1)
|
|
)
|
|
seat = seat_result.scalar_one_or_none()
|
|
if not seat:
|
|
raise HTTPException(status_code=404, detail="团队订阅席位不存在或已取消")
|
|
await acquire_subscription_credit_lock(db, seat.subscription_id)
|
|
subscription = await _load_subscription(
|
|
db, subscription_id=seat.subscription_id, team_id=team_id,
|
|
request_time=checked_at, for_update=True, require_active=True,
|
|
)
|
|
await _validate_allocation_pool(
|
|
db,
|
|
subscription=subscription,
|
|
target_seat_id=seat.id,
|
|
target_allocated=allocated,
|
|
request_time=checked_at,
|
|
)
|
|
before_allocated = to_credit_decimal(seat.monthly_allocated_credits)
|
|
seat.monthly_allocated_credits = allocated
|
|
await db.flush()
|
|
log_operation_event(
|
|
domain="team",
|
|
module="team_subscription",
|
|
event_type="TEAM_SUBSCRIPTION_SEAT_UPDATED",
|
|
user_id=manager_user_id,
|
|
message="团队订阅席位额度修改成功",
|
|
detail={
|
|
"team_id": team_id,
|
|
"subscription_id": seat.subscription_id,
|
|
"seat_id": seat.id,
|
|
"seat_user_id": seat.user_id,
|
|
"before_monthly_allocated_credits": float(before_allocated),
|
|
"monthly_allocated_credits": float(allocated),
|
|
},
|
|
)
|
|
return seat
|
|
|
|
|
|
async def cancel_seat(
|
|
db: AsyncSession,
|
|
*,
|
|
team_id: str,
|
|
seat_id: str,
|
|
manager_user_id: str,
|
|
request_time: datetime | None = None,
|
|
) -> TeamSubscriptionSeat:
|
|
checked_at = request_time or utc_now()
|
|
await _assert_manager_active_team(db, team_id=team_id, manager_user_id=manager_user_id)
|
|
result = await db.execute(
|
|
select(TeamSubscriptionSeat)
|
|
.where(
|
|
TeamSubscriptionSeat.id == seat_id,
|
|
TeamSubscriptionSeat.team_id == team_id,
|
|
TeamSubscriptionSeat.deleted_at.is_(None),
|
|
TeamSubscriptionSeat.cancelled_at.is_(None),
|
|
)
|
|
.with_for_update()
|
|
.limit(1)
|
|
)
|
|
seat = result.scalar_one_or_none()
|
|
if not seat:
|
|
raise HTTPException(status_code=404, detail="团队订阅席位不存在或已取消")
|
|
seat.cancelled_at = checked_at
|
|
seat.deleted_at = checked_at
|
|
await db.flush()
|
|
log_operation_event(
|
|
domain="team",
|
|
module="team_subscription",
|
|
event_type="TEAM_SUBSCRIPTION_SEAT_CANCELLED",
|
|
user_id=manager_user_id,
|
|
message="团队订阅席位取消成功",
|
|
detail={
|
|
"team_id": team_id,
|
|
"subscription_id": seat.subscription_id,
|
|
"seat_id": seat.id,
|
|
"seat_user_id": seat.user_id,
|
|
"monthly_allocated_credits": float(to_credit_decimal(seat.monthly_allocated_credits)),
|
|
},
|
|
)
|
|
return seat
|
|
|
|
|
|
async def get_or_create_usage_for_update(
|
|
db: AsyncSession,
|
|
*,
|
|
seat: TeamSubscriptionSeat,
|
|
period: UserCreditSubscriptionPeriod,
|
|
) -> TeamSubscriptionSeatUsage:
|
|
result = await db.execute(
|
|
select(TeamSubscriptionSeatUsage)
|
|
.where(
|
|
TeamSubscriptionSeatUsage.seat_id == seat.id,
|
|
TeamSubscriptionSeatUsage.subscription_period_id == period.id,
|
|
)
|
|
.with_for_update()
|
|
.limit(1)
|
|
)
|
|
usage = result.scalar_one_or_none()
|
|
if usage:
|
|
return usage
|
|
usage = TeamSubscriptionSeatUsage(
|
|
id=generate_id(),
|
|
seat_id=seat.id,
|
|
subscription_id=seat.subscription_id,
|
|
subscription_period_id=period.id,
|
|
user_id=seat.user_id,
|
|
used_credits=Decimal("0.00"),
|
|
)
|
|
db.add(usage)
|
|
await db.flush()
|
|
return usage
|
|
|
|
|
|
async def list_user_team_credit_candidates(
|
|
db: AsyncSession,
|
|
*,
|
|
user_id: str,
|
|
request_time: datetime | None = None,
|
|
require_team_active: bool = True,
|
|
) -> list[TeamCreditCandidate]:
|
|
checked_at = request_time or utc_now()
|
|
user_team_id = (await db.execute(select(User.team_id).where(User.id == user_id).limit(1))).scalar_one_or_none()
|
|
if not user_team_id:
|
|
return []
|
|
team = (await db.execute(
|
|
select(Team).where(Team.id == user_team_id, Team.deleted_at.is_(None)).limit(1)
|
|
)).scalar_one_or_none()
|
|
if not team:
|
|
return []
|
|
if require_team_active and team.status != TeamStatus.ACTIVE.value:
|
|
return []
|
|
|
|
result = await db.execute(
|
|
select(
|
|
TeamSubscriptionSeat,
|
|
UserCreditSubscription,
|
|
UserCreditSubscriptionPeriod,
|
|
UserCreditBalance,
|
|
TeamSubscriptionSeatUsage,
|
|
)
|
|
.join(UserCreditSubscription, UserCreditSubscription.id == TeamSubscriptionSeat.subscription_id)
|
|
.join(
|
|
UserCreditSubscriptionPeriod,
|
|
UserCreditSubscriptionPeriod.subscription_id == UserCreditSubscription.id,
|
|
)
|
|
.join(
|
|
UserCreditBalance,
|
|
UserCreditBalance.id == UserCreditSubscriptionPeriod.issued_balance_id,
|
|
)
|
|
.outerjoin(
|
|
TeamSubscriptionSeatUsage,
|
|
(TeamSubscriptionSeatUsage.seat_id == TeamSubscriptionSeat.id)
|
|
& (TeamSubscriptionSeatUsage.subscription_period_id == UserCreditSubscriptionPeriod.id),
|
|
)
|
|
.where(
|
|
TeamSubscriptionSeat.user_id == user_id,
|
|
TeamSubscriptionSeat.team_id == user_team_id,
|
|
TeamSubscriptionSeat.deleted_at.is_(None),
|
|
TeamSubscriptionSeat.cancelled_at.is_(None),
|
|
UserCreditSubscription.product_type_snapshot == CreditProductType.TEAM_SUBSCRIPTION.value,
|
|
UserCreditSubscription.status == CreditSubscriptionStatus.ACTIVE.value,
|
|
UserCreditSubscription.start_at <= checked_at,
|
|
UserCreditSubscription.expires_at > checked_at,
|
|
UserCreditSubscriptionPeriod.valid_from <= checked_at,
|
|
UserCreditSubscriptionPeriod.expires_at > checked_at,
|
|
UserCreditBalance.credit_scope == CreditScope.TEAM.value,
|
|
UserCreditBalance.valid_from <= checked_at,
|
|
UserCreditBalance.expires_at > checked_at,
|
|
UserCreditBalance.unspent_amount > 0,
|
|
UserCreditBalance.revoked_at.is_(None),
|
|
)
|
|
.order_by(
|
|
UserCreditBalance.credit_level_rank.asc(),
|
|
UserCreditBalance.expires_at.asc(),
|
|
UserCreditBalance.valid_from.asc(),
|
|
UserCreditBalance.id.asc(),
|
|
)
|
|
)
|
|
output: list[TeamCreditCandidate] = []
|
|
for seat, subscription, period, balance, usage in result.all():
|
|
available = min(to_credit_decimal(balance.unspent_amount), _remaining(seat, usage))
|
|
if available > 0:
|
|
output.append(TeamCreditCandidate(balance, seat, usage, subscription, period, available))
|
|
return output
|
|
|
|
|
|
async def lock_user_team_credit_candidates(
|
|
db: AsyncSession,
|
|
*,
|
|
user_id: str,
|
|
request_time: datetime,
|
|
) -> list[TeamCreditCandidate]:
|
|
user_team_id = (await db.execute(select(User.team_id).where(User.id == user_id).limit(1))).scalar_one_or_none()
|
|
if not user_team_id:
|
|
return []
|
|
await acquire_team_business_lock(db, user_team_id)
|
|
team = await _load_team(db, user_team_id, for_update=True)
|
|
if team.status != TeamStatus.ACTIVE.value:
|
|
return []
|
|
|
|
ids_result = await db.execute(
|
|
select(
|
|
TeamSubscriptionSeat.id,
|
|
TeamSubscriptionSeat.subscription_id,
|
|
UserCreditSubscriptionPeriod.id.label("period_id"),
|
|
UserCreditSubscriptionPeriod.issued_balance_id,
|
|
)
|
|
.join(UserCreditSubscription, UserCreditSubscription.id == TeamSubscriptionSeat.subscription_id)
|
|
.join(
|
|
UserCreditSubscriptionPeriod,
|
|
UserCreditSubscriptionPeriod.subscription_id == UserCreditSubscription.id,
|
|
)
|
|
.where(
|
|
TeamSubscriptionSeat.user_id == user_id,
|
|
TeamSubscriptionSeat.team_id == user_team_id,
|
|
TeamSubscriptionSeat.deleted_at.is_(None),
|
|
TeamSubscriptionSeat.cancelled_at.is_(None),
|
|
UserCreditSubscription.status == CreditSubscriptionStatus.ACTIVE.value,
|
|
UserCreditSubscription.start_at <= request_time,
|
|
UserCreditSubscription.expires_at > request_time,
|
|
UserCreditSubscriptionPeriod.valid_from <= request_time,
|
|
UserCreditSubscriptionPeriod.expires_at > request_time,
|
|
UserCreditSubscriptionPeriod.issued_balance_id.is_not(None),
|
|
)
|
|
)
|
|
raw = list(ids_result.all())
|
|
if not raw:
|
|
return []
|
|
await acquire_subscription_credit_locks(db, [str(row.subscription_id) for row in raw])
|
|
|
|
seat_ids = [str(row.id) for row in raw]
|
|
period_ids = [str(row.period_id) for row in raw]
|
|
balance_ids = [str(row.issued_balance_id) for row in raw]
|
|
seats_result = await db.execute(
|
|
select(TeamSubscriptionSeat)
|
|
.where(TeamSubscriptionSeat.id.in_(seat_ids))
|
|
.order_by(TeamSubscriptionSeat.id.asc())
|
|
.with_for_update()
|
|
)
|
|
seats = {item.id: item for item in seats_result.scalars().all()}
|
|
subs_result = await db.execute(
|
|
select(UserCreditSubscription)
|
|
.where(UserCreditSubscription.id.in_([str(row.subscription_id) for row in raw]))
|
|
.order_by(UserCreditSubscription.id.asc())
|
|
.with_for_update()
|
|
)
|
|
subs = {item.id: item for item in subs_result.scalars().all()}
|
|
periods_result = await db.execute(
|
|
select(UserCreditSubscriptionPeriod)
|
|
.where(UserCreditSubscriptionPeriod.id.in_(period_ids))
|
|
.order_by(UserCreditSubscriptionPeriod.id.asc())
|
|
.with_for_update()
|
|
)
|
|
periods = {item.id: item for item in periods_result.scalars().all()}
|
|
balances_result = await db.execute(
|
|
select(UserCreditBalance)
|
|
.where(
|
|
UserCreditBalance.id.in_(balance_ids),
|
|
UserCreditBalance.credit_scope == CreditScope.TEAM.value,
|
|
UserCreditBalance.valid_from <= request_time,
|
|
UserCreditBalance.expires_at > request_time,
|
|
UserCreditBalance.unspent_amount > 0,
|
|
UserCreditBalance.revoked_at.is_(None),
|
|
)
|
|
.order_by(UserCreditBalance.id.asc())
|
|
.with_for_update()
|
|
)
|
|
balances = {item.id: item for item in balances_result.scalars().all()}
|
|
usage_result = await db.execute(
|
|
select(TeamSubscriptionSeatUsage)
|
|
.where(
|
|
TeamSubscriptionSeatUsage.seat_id.in_(seat_ids),
|
|
TeamSubscriptionSeatUsage.subscription_period_id.in_(period_ids),
|
|
)
|
|
.order_by(TeamSubscriptionSeatUsage.id.asc())
|
|
.with_for_update()
|
|
)
|
|
usages = {(item.seat_id, item.subscription_period_id): item for item in usage_result.scalars().all()}
|
|
|
|
output: list[TeamCreditCandidate] = []
|
|
for row in raw:
|
|
seat = seats.get(str(row.id))
|
|
subscription = subs.get(str(row.subscription_id))
|
|
period = periods.get(str(row.period_id))
|
|
balance = balances.get(str(row.issued_balance_id))
|
|
if not seat or not subscription or not period or not balance:
|
|
continue
|
|
if seat.deleted_at is not None or seat.cancelled_at is not None:
|
|
continue
|
|
usage = usages.get((seat.id, period.id))
|
|
available = min(to_credit_decimal(balance.unspent_amount), _remaining(seat, usage))
|
|
if available > 0:
|
|
output.append(TeamCreditCandidate(balance, seat, usage, subscription, period, available))
|
|
return output
|
|
|
|
|
|
async def list_team_subscriptions_for_management(
|
|
db: AsyncSession,
|
|
*,
|
|
team_id: str,
|
|
request_time: datetime | None = None,
|
|
) -> list[dict[str, Any]]:
|
|
"""按 Subscription 实例返回团队席位管理视图;全程批量查询,避免 N+1。"""
|
|
checked_at = request_time or utc_now()
|
|
team = await _load_team(db, team_id)
|
|
subs_result = await db.execute(
|
|
select(UserCreditSubscription)
|
|
.where(
|
|
UserCreditSubscription.team_id == team_id,
|
|
UserCreditSubscription.product_type_snapshot == CreditProductType.TEAM_SUBSCRIPTION.value,
|
|
)
|
|
.order_by(UserCreditSubscription.created_at.desc(), UserCreditSubscription.id.desc())
|
|
)
|
|
subscriptions = list(subs_result.scalars().all())
|
|
if not subscriptions:
|
|
return []
|
|
|
|
subscription_ids = [item.id for item in subscriptions]
|
|
periods_result = await db.execute(
|
|
select(UserCreditSubscriptionPeriod)
|
|
.where(
|
|
UserCreditSubscriptionPeriod.subscription_id.in_(subscription_ids),
|
|
UserCreditSubscriptionPeriod.valid_from <= checked_at,
|
|
UserCreditSubscriptionPeriod.expires_at > checked_at,
|
|
)
|
|
.order_by(UserCreditSubscriptionPeriod.subscription_id.asc(), UserCreditSubscriptionPeriod.sequence.asc())
|
|
)
|
|
period_map: dict[str, UserCreditSubscriptionPeriod] = {}
|
|
for period in periods_result.scalars().all():
|
|
period_map.setdefault(period.subscription_id, period)
|
|
|
|
balance_ids = [period.issued_balance_id for period in period_map.values() if period.issued_balance_id]
|
|
balance_map: dict[str, UserCreditBalance] = {}
|
|
if balance_ids:
|
|
balances_result = await db.execute(select(UserCreditBalance).where(UserCreditBalance.id.in_(balance_ids)))
|
|
balance_map = {item.id: item for item in balances_result.scalars().all()}
|
|
|
|
seats_result = await db.execute(
|
|
select(TeamSubscriptionSeat, User.username)
|
|
.join(User, User.id == TeamSubscriptionSeat.user_id)
|
|
.where(TeamSubscriptionSeat.subscription_id.in_(subscription_ids))
|
|
.order_by(
|
|
TeamSubscriptionSeat.subscription_id.asc(),
|
|
TeamSubscriptionSeat.created_at.asc(),
|
|
TeamSubscriptionSeat.id.asc(),
|
|
)
|
|
)
|
|
seat_rows_by_subscription: dict[str, list[tuple[TeamSubscriptionSeat, str]]] = {}
|
|
all_seat_ids: list[str] = []
|
|
for seat, username in seats_result.all():
|
|
seat_rows_by_subscription.setdefault(seat.subscription_id, []).append((seat, username))
|
|
all_seat_ids.append(seat.id)
|
|
|
|
current_period_ids = [period.id for period in period_map.values()]
|
|
usage_map: dict[tuple[str, str], TeamSubscriptionSeatUsage] = {}
|
|
if all_seat_ids and current_period_ids:
|
|
usage_result = await db.execute(
|
|
select(TeamSubscriptionSeatUsage).where(
|
|
TeamSubscriptionSeatUsage.seat_id.in_(all_seat_ids),
|
|
TeamSubscriptionSeatUsage.subscription_period_id.in_(current_period_ids),
|
|
)
|
|
)
|
|
usage_map = {
|
|
(item.seat_id, item.subscription_period_id): item
|
|
for item in usage_result.scalars().all()
|
|
}
|
|
|
|
output: list[dict[str, Any]] = []
|
|
for subscription in subscriptions:
|
|
period = period_map.get(subscription.id)
|
|
balance = balance_map.get(period.issued_balance_id) if period and period.issued_balance_id else None
|
|
rows = seat_rows_by_subscription.get(subscription.id, [])
|
|
active_seats = [seat for seat, _ in rows if seat.deleted_at is None and seat.cancelled_at is None]
|
|
seats_payload = []
|
|
active_remaining = Decimal("0.00")
|
|
for seat, username in rows:
|
|
usage = usage_map.get((seat.id, period.id)) if period else None
|
|
remaining = (
|
|
_remaining(seat, usage)
|
|
if seat.deleted_at is None and seat.cancelled_at is None
|
|
else Decimal("0.00")
|
|
)
|
|
active_remaining += remaining
|
|
status = _seat_status(seat, subscription, checked_at)
|
|
seats_payload.append(
|
|
{
|
|
"id": seat.id,
|
|
"team_id": seat.team_id,
|
|
"subscription_id": seat.subscription_id,
|
|
"user_id": seat.user_id,
|
|
"username": username,
|
|
"monthly_allocated_credits": float(seat.monthly_allocated_credits),
|
|
"current_period_id": period.id if period else None,
|
|
"current_period_used_credits": float(usage.used_credits if usage else 0),
|
|
"current_period_remaining_credits": float(remaining),
|
|
"status": status,
|
|
"status_label": TEAM_SEAT_STATUS_LABELS.get(status, "其他状态"),
|
|
"created_at": seat.created_at,
|
|
"cancelled_at": seat.cancelled_at,
|
|
}
|
|
)
|
|
period_unspent = to_credit_decimal(balance.unspent_amount if balance else 0)
|
|
output.append(
|
|
{
|
|
"subscription": {
|
|
"id": subscription.id,
|
|
"subscription_no": subscription.subscription_no,
|
|
"user_id": subscription.user_id,
|
|
"team_id": subscription.team_id,
|
|
"team_manager_id_snapshot": subscription.team_manager_id_snapshot,
|
|
"product_id": subscription.product_id,
|
|
"payment_order_id": subscription.payment_order_id,
|
|
"status": subscription.status,
|
|
"status_label": CREDIT_SUBSCRIPTION_STATUS_LABELS.get(subscription.status, "其他状态"),
|
|
"purchase_scene": subscription.purchase_scene,
|
|
"product_type_snapshot": subscription.product_type_snapshot,
|
|
"product_type_label": "团队订阅套餐",
|
|
"product_name_snapshot": subscription.product_name_snapshot,
|
|
"tier_code": subscription.tier_code,
|
|
"tier_rank": subscription.tier_rank,
|
|
"billing_cycle": subscription.billing_cycle,
|
|
"billing_cycle_label": {"monthly": "月卡", "quarterly": "季卡", "yearly": "年卡"}.get(subscription.billing_cycle, "其他周期"),
|
|
"anchor_at": subscription.anchor_at,
|
|
"start_at": subscription.start_at,
|
|
"expires_at": subscription.expires_at,
|
|
"next_grant_at": subscription.next_grant_at,
|
|
"monthly_grant_credits_snapshot": float(subscription.monthly_grant_credits_snapshot),
|
|
"monthly_total_credits_snapshot": float(subscription.monthly_total_credits_snapshot),
|
|
"quantity_snapshot": subscription.quantity_snapshot,
|
|
"grant_count": subscription.grant_count,
|
|
"granted_count": subscription.granted_count,
|
|
"first_purchase_price_snapshot": float(subscription.first_purchase_price_snapshot),
|
|
"regular_price_snapshot": float(subscription.regular_price_snapshot),
|
|
"activity_price_snapshot": float(subscription.activity_price_snapshot) if subscription.activity_price_snapshot is not None else None,
|
|
"actual_unit_price_snapshot": float(subscription.actual_unit_price_snapshot),
|
|
"paid_amount_snapshot": float(subscription.paid_amount_snapshot),
|
|
"periods": [],
|
|
},
|
|
"current_period_id": period.id if period else None,
|
|
"current_period_start_at": period.valid_from if period else None,
|
|
"current_period_expires_at": period.expires_at if period else None,
|
|
"period_total_credits": float(period.grant_credits if period else 0),
|
|
"period_unspent_credits": float(period_unspent),
|
|
"period_unallocated_credits": float(max(Decimal("0.00"), period_unspent - active_remaining)),
|
|
"seat_limit": int(subscription.quantity_snapshot),
|
|
"active_seat_count": len(active_seats),
|
|
"seats": seats_payload,
|
|
"team_status": team.status,
|
|
"team_status_label": "启用" if team.status == TeamStatus.ACTIVE.value else "禁用",
|
|
}
|
|
)
|
|
return output
|
|
|
|
|
|
async def list_member_period_usage(
|
|
db: AsyncSession,
|
|
*,
|
|
team_id: str,
|
|
subscription_id: str | None = None,
|
|
) -> list[dict[str, Any]]:
|
|
conditions = [
|
|
CreditRecordAllocation.credit_scope_snapshot == CreditScope.TEAM.value,
|
|
CreditRecordAllocation.team_id_snapshot == team_id,
|
|
CreditRecordAllocation.allocation_action.in_([
|
|
CreditAllocationAction.CONSUME.value,
|
|
CreditAllocationAction.REFUND_AVAILABLE.value,
|
|
CreditAllocationAction.REFUND_EXPIRED.value,
|
|
]),
|
|
]
|
|
if subscription_id:
|
|
conditions.append(CreditRecordAllocation.subscription_id_snapshot == subscription_id)
|
|
result = await db.execute(
|
|
select(
|
|
CreditRecordAllocation.user_id,
|
|
CreditRecordAllocation.subscription_id_snapshot,
|
|
CreditRecordAllocation.subscription_period_id_snapshot,
|
|
User.username,
|
|
func.sum(
|
|
case(
|
|
(CreditRecordAllocation.allocation_action == CreditAllocationAction.CONSUME.value, CreditRecordAllocation.amount),
|
|
else_=-CreditRecordAllocation.amount,
|
|
)
|
|
).label("net_used"),
|
|
)
|
|
.join(User, User.id == CreditRecordAllocation.user_id)
|
|
.where(*conditions)
|
|
.group_by(
|
|
CreditRecordAllocation.user_id,
|
|
CreditRecordAllocation.subscription_id_snapshot,
|
|
CreditRecordAllocation.subscription_period_id_snapshot,
|
|
User.username,
|
|
)
|
|
)
|
|
rows = list(result.all())
|
|
if not rows:
|
|
return []
|
|
subscription_ids = [str(row.subscription_id_snapshot) for row in rows if row.subscription_id_snapshot]
|
|
period_ids = [str(row.subscription_period_id_snapshot) for row in rows if row.subscription_period_id_snapshot]
|
|
subscriptions_result = await db.execute(
|
|
select(
|
|
UserCreditSubscription.id,
|
|
UserCreditSubscription.subscription_no,
|
|
UserCreditSubscription.product_name_snapshot,
|
|
UserCreditSubscription.tier_code,
|
|
UserCreditSubscription.tier_rank,
|
|
UserCreditSubscription.billing_cycle,
|
|
).where(UserCreditSubscription.id.in_(subscription_ids))
|
|
) if subscription_ids else None
|
|
subscription_map = {
|
|
row.id: row for row in subscriptions_result.all()
|
|
} if subscriptions_result is not None else {}
|
|
periods_result = await db.execute(
|
|
select(
|
|
UserCreditSubscriptionPeriod.id,
|
|
UserCreditSubscriptionPeriod.sequence,
|
|
UserCreditSubscriptionPeriod.valid_from,
|
|
UserCreditSubscriptionPeriod.expires_at,
|
|
).where(UserCreditSubscriptionPeriod.id.in_(period_ids))
|
|
) if period_ids else None
|
|
period_map = {
|
|
row.id: row for row in periods_result.all()
|
|
} if periods_result is not None else {}
|
|
|
|
output: list[dict[str, Any]] = []
|
|
for row in rows:
|
|
subscription = subscription_map.get(row.subscription_id_snapshot)
|
|
period = period_map.get(row.subscription_period_id_snapshot)
|
|
tier_code = str(subscription.tier_code) if subscription else ""
|
|
billing_cycle = str(subscription.billing_cycle) if subscription else ""
|
|
period_sequence = int(period.sequence) + 1 if period else 0
|
|
output.append(
|
|
{
|
|
"user_id": row.user_id,
|
|
"username": row.username,
|
|
# ID 仍用于接口内部关联/筛选,但客户端不直接展示。
|
|
"subscription_id": row.subscription_id_snapshot,
|
|
"subscription_no": subscription.subscription_no if subscription else "历史订阅",
|
|
"subscription_name": subscription.product_name_snapshot if subscription else "历史团队订阅",
|
|
"tier_code": tier_code,
|
|
"tier_label": SUBSCRIPTION_TIER_LABELS.get(tier_code, tier_code or "未知等级"),
|
|
"tier_rank": int(subscription.tier_rank) if subscription else 0,
|
|
"billing_cycle": billing_cycle,
|
|
"billing_cycle_label": SUBSCRIPTION_BILLING_CYCLE_LABELS.get(
|
|
billing_cycle, billing_cycle or "未知周期"
|
|
),
|
|
"subscription_period_id": row.subscription_period_id_snapshot,
|
|
"period_sequence": period_sequence,
|
|
"period_label": f"第{period_sequence}个月" if period_sequence > 0 else "历史周期",
|
|
"period_start_at": period.valid_from if period else None,
|
|
"period_expires_at": period.expires_at if period else None,
|
|
"consumed_credits": float(max(Decimal("0.00"), to_credit_decimal(row.net_used))),
|
|
}
|
|
)
|
|
return output
|
|
|