Files
video-gen/video-gen-api/app/services/credit/team_subscription_service.py
T
2026-08-14 15:23:46 +08:00

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