Files
video-gen/video-gen-api/app/services/credit/product_service.py
T
2026-08-11 13:23:22 +08:00

272 lines
10 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_product import (
CreditProductType,
ProductPriceType,
SUBSCRIPTION_GRANT_COUNT,
SubscriptionBillingCycle,
)
from app.enums.credit_subscription import (
CreditSubscriptionPeriodStatus,
CreditSubscriptionStatus,
)
from app.models.credit.product import CreditProduct
from app.models.credit.subscription import UserCreditSubscription
from app.models.credit.subscription_period import UserCreditSubscriptionPeriod
from app.models.user import User
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
base_price: Decimal
activity_price: Decimal | None
target_price: Decimal
deduction_amount: Decimal
payable_amount: Decimal
source_subscription_id: str | None = None
upgrade_period_ids: tuple[str, ...] = ()
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,
upgrade: bool = False,
) -> 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
base = to_credit_decimal(
product.regular_price if upgrade or not first_purchase else product.first_purchase_price
)
activity = activity_price_if_valid(product, request_time)
if activity is not None and activity < base:
return base, activity, activity, ProductPriceType.ACTIVITY.value
return base, activity, base, (
ProductPriceType.UPGRADE.value
if upgrade
else ProductPriceType.FIRST_PURCHASE.value if first_purchase else ProductPriceType.REGULAR.value
)
async def get_active_subscription(
db: AsyncSession,
user_id: str,
*,
request_time: datetime | None = None,
for_update: bool = False,
) -> UserCreditSubscription | None:
checked_at = request_time or utc_now()
stmt = (
select(UserCreditSubscription)
.where(
UserCreditSubscription.user_id == user_id,
UserCreditSubscription.status == CreditSubscriptionStatus.ACTIVE.value,
UserCreditSubscription.start_at <= checked_at,
UserCreditSubscription.expires_at > checked_at,
)
.order_by(UserCreditSubscription.start_at.desc(), UserCreditSubscription.id.desc())
.limit(1)
)
if for_update:
stmt = stmt.with_for_update()
result = await db.execute(stmt)
return result.scalar_one_or_none()
async def get_upgrade_deduction_preview(
db: AsyncSession,
*,
subscription: UserCreditSubscription,
request_time: datetime,
) -> Decimal:
if subscription.billing_cycle not in {
SubscriptionBillingCycle.QUARTERLY.value,
SubscriptionBillingCycle.YEARLY.value,
}:
return Decimal("0.00")
result = await db.execute(
select(UserCreditSubscriptionPeriod.allocated_paid_amount).where(
UserCreditSubscriptionPeriod.subscription_id == subscription.id,
UserCreditSubscriptionPeriod.scheduled_at > request_time,
UserCreditSubscriptionPeriod.status == CreditSubscriptionPeriodStatus.SCHEDULED.value,
)
)
return sum((to_credit_decimal(value) for value in result.scalars().all()), Decimal("0.00"))
async def list_active_products(db: AsyncSession) -> list[CreditProduct]:
result = await db.execute(
select(CreditProduct)
.where(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) -> CreditProduct | None:
stmt = select(CreditProduct).where(CreditProduct.id == product_id).limit(1)
if for_update:
stmt = stmt.with_for_update()
result = await db.execute(stmt)
return result.scalar_one_or_none()
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)
current = await get_active_subscription(db, user.id, request_time=checked_at)
first_purchase = user.first_membership_paid_at is None
subscription_products: list[dict] = []
credit_addons: list[dict] = []
upgrade_deduction = (
await get_upgrade_deduction_preview(db, subscription=current, request_time=checked_at)
if current is not None
else Decimal("0.00")
)
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
# 首订资格已经使用后,未开启续费的套餐不返回给客户端。
# 该过滤同时适用于过期后的续费和有效订阅期间的升级入口,
# 避免仅靠客户端隐藏后仍可被直接构造请求购买。
if not first_purchase and not bool(product.renewal_enabled):
continue
can_purchase = current is None
can_upgrade = False
reason = None
if current is not None:
can_upgrade = (
product.billing_cycle == current.billing_cycle
and int(product.tier_rank or 0) > int(current.tier_rank or 0)
)
can_purchase = can_upgrade
if not can_upgrade:
reason = "当前订阅有效,暂不能续费;仅可升级同周期更高等级套餐"
_, _, target_price, price_type = current_product_price(
product,
first_purchase=first_purchase,
request_time=checked_at,
upgrade=can_upgrade,
)
deduction_amount = upgrade_deduction if can_upgrade else Decimal("0.00")
user_price = max(Decimal("0.00"), target_price - deduction_amount)
if can_upgrade and user_price <= Decimal("0.00"):
can_purchase = False
reason = "当前升级抵扣金额已达到或超过目标套餐价格,暂不支持0元升级,请联系客服处理"
item = product_to_dict(
product,
user_price=user_price,
price_type=price_type,
can_purchase=can_purchase,
target_price=target_price,
deduction_amount=deduction_amount,
)
item["can_upgrade"] = can_upgrade
item["unavailable_reason"] = reason
subscription_products.append(item)
return {
"subscription_products": subscription_products,
"credit_addons": credit_addons,
"first_purchase_available": first_purchase,
"current_subscription": subscription_to_dict(current) if current else None,
}
def product_to_dict(
product: CreditProduct,
*,
user_price: Decimal | None = None,
price_type: str | None = None,
can_purchase: bool | None = None,
target_price: Decimal | None = None,
deduction_amount: Decimal | None = None,
) -> dict:
return {
"id": product.id,
"product_code": product.product_code,
"product_type": product.product_type,
"name": product.name,
"description": product.description,
"features": product.features_json or [],
"tier_code": product.tier_code,
"tier_rank": product.tier_rank,
"billing_cycle": product.billing_cycle,
"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),
"target_price": float(target_price) if target_price is not None else None,
"deduction_amount": float(deduction_amount or Decimal("0.00")),
"price_type": price_type,
"credit_level": product.credit_level,
"currency": product.currency,
"is_active": product.is_active,
"sort_order": product.sort_order,
"can_purchase": can_purchase,
}
def subscription_to_dict(subscription: UserCreditSubscription) -> dict:
return {
"id": subscription.id,
"product_id": subscription.product_id,
"status": subscription.status,
"purchase_scene": subscription.purchase_scene,
"tier_code": subscription.tier_code,
"tier_rank": subscription.tier_rank,
"billing_cycle": subscription.billing_cycle,
"anchor_at": subscription.anchor_at,
"start_at": subscription.start_at,
"expires_at": subscription.expires_at,
"monthly_grant_credits": float(subscription.monthly_grant_credits_snapshot),
"grant_count": subscription.grant_count,
"granted_count": subscription.granted_count,
}