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

339 lines
13 KiB
Python

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,
}