339 lines
13 KiB
Python
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,
|
|
}
|