from __future__ import annotations from dataclasses import dataclass from datetime import datetime from decimal import Decimal from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.enums.credit_balance import CREDIT_LEVEL_LABELS from app.enums.credit_product import ( CREDIT_PRODUCT_TYPE_LABELS, PRODUCT_PRICE_TYPE_LABELS, SUBSCRIPTION_BILLING_CYCLE_LABELS, SUBSCRIPTION_GRANT_COUNT, SUBSCRIPTION_TIER_LABELS, CreditProductType, ProductPriceType, ) from app.enums.credit_subscription import CREDIT_SUBSCRIPTION_STATUS_LABELS, CreditSubscriptionStatus from app.enums.team import TeamStatus from app.models.credit.product import CreditProduct from app.models.credit.subscription import UserCreditSubscription from app.models.team import Team from app.models.user import User from app.services.credit.entitlement_service import get_personal_entitlement, get_team_entitlement from app.services.credit.utils import to_credit_decimal, utc_now @dataclass(slots=True, frozen=True) class ProductPriceQuote: product: CreditProduct purchase_scene: str price_type: str quoted_unit_price: Decimal quoted_amount: Decimal quantity: int first_purchase: bool SUBSCRIPTION_PRODUCT_TYPES = { CreditProductType.SUBSCRIPTION.value, CreditProductType.TEAM_SUBSCRIPTION.value, } def grant_count_for_cycle(cycle: str | None) -> int: try: return SUBSCRIPTION_GRANT_COUNT[str(cycle)] except KeyError as exc: raise ValueError("不支持的订阅周期") from exc def activity_price_if_valid(product: CreditProduct, request_time: datetime) -> Decimal | None: if product.activity_price is None: return None if product.activity_start_at is None or product.activity_end_at is None: return None if not (product.activity_start_at <= request_time < product.activity_end_at): return None return to_credit_decimal(product.activity_price) def current_product_price( product: CreditProduct, *, first_purchase: bool, request_time: datetime, ) -> tuple[Decimal, Decimal | None, Decimal, str]: if product.product_type == CreditProductType.CREDIT_ADDON.value: price = to_credit_decimal(product.price) return price, None, price, ProductPriceType.REGULAR.value activity = activity_price_if_valid(product, request_time) if first_purchase: first_price = to_credit_decimal(product.first_purchase_price) return first_price, activity, first_price, ProductPriceType.FIRST_PURCHASE.value regular_price = to_credit_decimal(product.regular_price) if activity is not None: return regular_price, activity, activity, ProductPriceType.ACTIVITY.value return regular_price, None, regular_price, ProductPriceType.REGULAR.value def ensure_repeat_purchase_allowed( product: CreditProduct, *, first_purchase: bool, ) -> None: """校验订阅套餐续购开关。 renewal_enabled 仅控制失去首购资格后的再次购买,不代表自动续费。 已创建订单后续履约只读取订单快照,不再动态检查此开关。 """ if ( product.product_type in SUBSCRIPTION_PRODUCT_TYPES and not first_purchase and not bool(product.renewal_enabled) ): raise ValueError("该套餐当前未开启续费,已失去首购资格后不能再次购买") def quote_product( product: CreditProduct, *, first_purchase: bool, request_time: datetime, quantity: int = 1, ) -> ProductPriceQuote: if quantity < 1: raise ValueError("购买数量必须大于0") if product.product_type != CreditProductType.TEAM_SUBSCRIPTION.value and quantity != 1: raise ValueError("个人订阅和积分增值包不支持购买数量") _, _, unit_price, price_type = current_product_price( product, first_purchase=first_purchase, request_time=request_time ) quoted_amount = to_credit_decimal(unit_price * quantity) return ProductPriceQuote( product=product, purchase_scene=price_type, price_type=price_type, quoted_unit_price=unit_price, quoted_amount=quoted_amount, quantity=quantity, first_purchase=first_purchase, ) async def list_active_products(db: AsyncSession) -> list[CreditProduct]: result = await db.execute( select(CreditProduct) .where(CreditProduct.deleted_at.is_(None), CreditProduct.is_active.is_(True)) .order_by(CreditProduct.product_type.asc(), CreditProduct.sort_order.asc(), CreditProduct.id.asc()) ) return list(result.scalars().all()) async def get_product( db: AsyncSession, product_id: str, *, for_update: bool = False, include_deleted: bool = False, ) -> CreditProduct | None: stmt = select(CreditProduct).where(CreditProduct.id == product_id).limit(1) if not include_deleted: stmt = stmt.where(CreditProduct.deleted_at.is_(None)) if for_update: stmt = stmt.with_for_update() result = await db.execute(stmt) return result.scalar_one_or_none() async def list_active_personal_subscriptions( db: AsyncSession, *, user_id: str, request_time: datetime | None = None, ) -> list[UserCreditSubscription]: checked_at = request_time or utc_now() result = await db.execute( select(UserCreditSubscription) .where( UserCreditSubscription.user_id == user_id, UserCreditSubscription.product_type_snapshot == CreditProductType.SUBSCRIPTION.value, UserCreditSubscription.status == CreditSubscriptionStatus.ACTIVE.value, UserCreditSubscription.start_at <= checked_at, UserCreditSubscription.expires_at > checked_at, ) .order_by(UserCreditSubscription.expires_at.asc(), UserCreditSubscription.id.asc()) ) return list(result.scalars().all()) async def resolve_team_purchase_context( db: AsyncSession, *, user: User, ) -> tuple[Team | None, bool, str | None]: if not user.team_id: return None, True, None result = await db.execute( select(Team).where(Team.id == user.team_id, Team.deleted_at.is_(None)).limit(1) ) team = result.scalar_one_or_none() if team is None: return None, False, "当前团队不存在" if team.manager_id != user.id: return team, False, "普通团队成员不能购买团队订阅套餐" if team.status != TeamStatus.ACTIVE.value: return team, False, "团队已禁用,当前只能查看团队数据,不能购买新的团队订阅" return team, True, None def _subscription_to_dict(subscription: UserCreditSubscription) -> dict: return { "id": subscription.id, "subscription_no": subscription.subscription_no, "product_id": subscription.product_id, "product_name": subscription.product_name_snapshot, "product_type": subscription.product_type_snapshot, "status": subscription.status, "status_label": CREDIT_SUBSCRIPTION_STATUS_LABELS.get(subscription.status, "其他状态"), "billing_cycle": subscription.billing_cycle, "billing_cycle_label": SUBSCRIPTION_BILLING_CYCLE_LABELS.get( subscription.billing_cycle, "其他周期" ), "tier_code": subscription.tier_code, "tier_rank": subscription.tier_rank, "start_at": subscription.start_at, "expires_at": subscription.expires_at, "monthly_grant_credits": float(subscription.monthly_grant_credits_snapshot), "paid_amount": float(subscription.paid_amount_snapshot), } async def build_product_catalog( db: AsyncSession, *, user: User, request_time: datetime | None = None, ) -> dict: checked_at = request_time or utc_now() products = await list_active_products(db) personal_first_purchase = user.first_membership_paid_at is None team, team_purchase_available, team_reason = await resolve_team_purchase_context(db, user=user) team_first_purchase = bool(team is None or team.first_subscription_paid_at is None) subscription_products: list[dict] = [] team_subscription_products: list[dict] = [] credit_addons: list[dict] = [] for product in products: if product.product_type == CreditProductType.CREDIT_ADDON.value: credit_addons.append( product_to_dict( product, user_price=to_credit_decimal(product.price), price_type=ProductPriceType.REGULAR.value, can_purchase=True, ) ) continue is_team = product.product_type == CreditProductType.TEAM_SUBSCRIPTION.value first_purchase = team_first_purchase if is_team else personal_first_purchase if not first_purchase and not bool(product.renewal_enabled): continue _, _, unit_price, price_type = current_product_price( product, first_purchase=first_purchase, request_time=checked_at, ) item = product_to_dict( product, user_price=unit_price, price_type=price_type, can_purchase=(team_purchase_available if is_team else True), ) if is_team: item["unavailable_reason"] = team_reason if team_purchase_available: team_subscription_products.append(item) else: subscription_products.append(item) active_personal = await list_active_personal_subscriptions( db, user_id=user.id, request_time=checked_at ) return { "subscription_products": subscription_products, "team_subscription_products": team_subscription_products, "credit_addons": credit_addons, "personal_first_purchase_available": personal_first_purchase, "team_first_purchase_available": team_first_purchase, "active_personal_subscriptions": [_subscription_to_dict(item) for item in active_personal], "personal_entitlement": await get_personal_entitlement( db, user_id=user.id, request_time=checked_at ), "team_entitlement": await get_team_entitlement( db, user_id=user.id, request_time=checked_at ), "team_purchase_available": team_purchase_available, "team_purchase_unavailable_reason": team_reason, } def product_to_dict( product: CreditProduct, *, user_price: Decimal | None = None, price_type: str | None = None, can_purchase: bool | None = None, ) -> dict: deleted = product.deleted_at is not None if deleted: status_label = "已删除" elif product.is_active: status_label = "已上架" else: status_label = "已下架" return { "id": product.id, "product_code": product.product_code, "product_type": product.product_type, "product_type_label": CREDIT_PRODUCT_TYPE_LABELS.get(product.product_type, "其他套餐类型"), "name": product.name, "description": product.description, "features": product.features_json or [], "tier_code": product.tier_code, "tier_label": SUBSCRIPTION_TIER_LABELS.get(str(product.tier_code)) if product.tier_code else None, "tier_rank": product.tier_rank, "billing_cycle": product.billing_cycle, "billing_cycle_label": SUBSCRIPTION_BILLING_CYCLE_LABELS.get(str(product.billing_cycle)) if product.billing_cycle else None, "monthly_grant_credits": float(product.monthly_grant_credits or 0), "grant_count": grant_count_for_cycle(product.billing_cycle) if product.is_subscription else 1, "first_purchase_price": float(product.first_purchase_price or 0), "regular_price": float(product.regular_price or 0), "activity_price": float(product.activity_price) if product.activity_price is not None else None, "activity_start_at": product.activity_start_at, "activity_end_at": product.activity_end_at, "renewal_enabled": bool(product.renewal_enabled), "grant_credits": float(product.grant_credits or 0), "validity_months": int(product.validity_months or 1) if product.is_credit_addon else None, "price": float(user_price if user_price is not None else product.price), "current_price": float(user_price if user_price is not None else product.price), "price_type": price_type, "price_type_label": PRODUCT_PRICE_TYPE_LABELS.get(price_type or "") if price_type else None, "credit_level": product.credit_level, "credit_level_label": CREDIT_LEVEL_LABELS.get(product.credit_level, "其他积分等级"), "currency": product.currency, "is_active": bool(product.is_active), "is_deleted": deleted, "deleted_at": product.deleted_at, "status_label": status_label, "sort_order": product.sort_order, "can_purchase": can_purchase, "unavailable_reason": None, }