272 lines
10 KiB
Python
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,
|
|
}
|