164 lines
5.9 KiB
Python
164 lines
5.9 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
from decimal import Decimal
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.enums.credit_product import SubscriptionBillingCycle
|
|
from app.enums.credit_subscription import CreditSubscriptionPeriodStatus
|
|
from app.models.credit.product import CreditProduct
|
|
from app.models.credit.subscription_period import UserCreditSubscriptionPeriod
|
|
from app.models.payment_order import PaymentOrder
|
|
from app.models.user import User
|
|
from app.services.credit.locking import acquire_user_credit_lock
|
|
from app.services.credit.product_service import (
|
|
ProductPriceQuote,
|
|
current_product_price,
|
|
get_active_subscription,
|
|
)
|
|
from app.services.credit.utils import to_credit_decimal
|
|
|
|
|
|
async def quote_and_reserve_product_purchase(
|
|
db: AsyncSession,
|
|
*,
|
|
user: User,
|
|
product: CreditProduct,
|
|
order_id: str,
|
|
request_time: datetime,
|
|
) -> ProductPriceQuote:
|
|
if product.is_credit_addon:
|
|
price = to_credit_decimal(product.price)
|
|
return ProductPriceQuote(
|
|
product=product,
|
|
purchase_scene="credit_addon",
|
|
price_type="regular",
|
|
base_price=price,
|
|
activity_price=None,
|
|
target_price=price,
|
|
deduction_amount=Decimal("0.00"),
|
|
payable_amount=price,
|
|
)
|
|
|
|
# 所有订阅购买/升级先取得统一用户积分事务锁,再锁订阅和周期,
|
|
# 与月度发放、支付履约保持同一锁顺序,避免升级边界死锁。
|
|
await acquire_user_credit_lock(db, user.id)
|
|
pending_result = await db.execute(
|
|
select(PaymentOrder.id).where(
|
|
PaymentOrder.user_id == user.id,
|
|
PaymentOrder.id != order_id,
|
|
PaymentOrder.status == "pending",
|
|
PaymentOrder.product_type == "subscription",
|
|
).limit(1)
|
|
)
|
|
if pending_result.scalar_one_or_none() is not None:
|
|
raise ValueError("已有待支付的订阅或升级订单,请先完成或等待订单过期")
|
|
current = await get_active_subscription(
|
|
db,
|
|
user.id,
|
|
request_time=request_time,
|
|
for_update=True,
|
|
)
|
|
first_purchase = user.first_membership_paid_at is None
|
|
if not first_purchase and not bool(product.renewal_enabled):
|
|
raise ValueError("该订阅套餐暂未开放续费或升级")
|
|
if current is None:
|
|
base, activity, target, price_type = current_product_price(
|
|
product,
|
|
first_purchase=first_purchase,
|
|
request_time=request_time,
|
|
upgrade=False,
|
|
)
|
|
return ProductPriceQuote(
|
|
product=product,
|
|
purchase_scene="first_purchase" if first_purchase else "renewal",
|
|
price_type=price_type,
|
|
base_price=base,
|
|
activity_price=activity,
|
|
target_price=target,
|
|
deduction_amount=Decimal("0.00"),
|
|
payable_amount=target,
|
|
)
|
|
|
|
if product.billing_cycle != current.billing_cycle:
|
|
raise ValueError("当前订阅有效,只能升级同周期更高等级套餐")
|
|
if int(product.tier_rank or 0) <= int(current.tier_rank or 0):
|
|
raise ValueError("当前订阅有效,不能提前续费或降级")
|
|
|
|
base, activity, target, price_type = current_product_price(
|
|
product,
|
|
first_purchase=False,
|
|
request_time=request_time,
|
|
upgrade=True,
|
|
)
|
|
period_ids: list[str] = []
|
|
periods: list[UserCreditSubscriptionPeriod] = []
|
|
deduction = Decimal("0.00")
|
|
if current.billing_cycle in {
|
|
SubscriptionBillingCycle.QUARTERLY.value,
|
|
SubscriptionBillingCycle.YEARLY.value,
|
|
}:
|
|
result = await db.execute(
|
|
select(UserCreditSubscriptionPeriod)
|
|
.where(
|
|
UserCreditSubscriptionPeriod.subscription_id == current.id,
|
|
UserCreditSubscriptionPeriod.scheduled_at > request_time,
|
|
UserCreditSubscriptionPeriod.status == CreditSubscriptionPeriodStatus.SCHEDULED.value,
|
|
)
|
|
.order_by(UserCreditSubscriptionPeriod.sequence.asc())
|
|
.with_for_update()
|
|
)
|
|
periods = list(result.scalars().all())
|
|
for period in periods:
|
|
deduction += to_credit_decimal(period.allocated_paid_amount)
|
|
period_ids.append(period.id)
|
|
payable = target - deduction
|
|
if payable <= Decimal("0.00"):
|
|
raise ValueError("当前升级抵扣金额已达到或超过目标套餐价格,暂不支持0元升级,请联系客服处理")
|
|
for period in periods:
|
|
period.status = CreditSubscriptionPeriodStatus.UPGRADE_RESERVED.value
|
|
period.upgrade_order_id = order_id
|
|
period.reserved_at = request_time
|
|
return ProductPriceQuote(
|
|
product=product,
|
|
purchase_scene="upgrade",
|
|
price_type=price_type,
|
|
base_price=base,
|
|
activity_price=activity,
|
|
target_price=target,
|
|
deduction_amount=deduction,
|
|
payable_amount=payable,
|
|
source_subscription_id=current.id,
|
|
upgrade_period_ids=tuple(period_ids),
|
|
)
|
|
|
|
|
|
async def release_upgrade_reservation(
|
|
db: AsyncSession,
|
|
*,
|
|
order: PaymentOrder,
|
|
released_at: datetime,
|
|
) -> int:
|
|
period_ids = list(order.upgrade_period_ids_json or [])
|
|
if not period_ids:
|
|
return 0
|
|
await acquire_user_credit_lock(db, order.user_id)
|
|
result = await db.execute(
|
|
select(UserCreditSubscriptionPeriod)
|
|
.where(
|
|
UserCreditSubscriptionPeriod.id.in_(period_ids),
|
|
UserCreditSubscriptionPeriod.upgrade_order_id == order.id,
|
|
UserCreditSubscriptionPeriod.status == CreditSubscriptionPeriodStatus.UPGRADE_RESERVED.value,
|
|
)
|
|
.with_for_update()
|
|
)
|
|
periods = list(result.scalars().all())
|
|
for period in periods:
|
|
period.status = CreditSubscriptionPeriodStatus.SCHEDULED.value
|
|
period.upgrade_order_id = None
|
|
period.reserved_at = None
|
|
await db.flush()
|
|
return len(periods)
|