Files
video-gen/video-gen-api/app/services/credit/subscription_service.py
T
2026-08-11 09:24:18 +08:00

809 lines
33 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_balance import (
CreditAllocationAction,
CreditBalanceSourceType,
CreditBalanceStatus,
)
from app.enums.credit_product import CreditProductType
from app.enums.credit_record import CreditRecordBillingScene, CreditRecordType
from app.enums.credit_subscription import (
CreditSubscriptionPeriodStatus,
CreditSubscriptionStatus,
)
from app.models.credit.allocation import CreditRecordAllocation
from app.models.credit.balance import UserCreditBalance
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.credit_record import CreditRecord
from app.models.payment_order import PaymentOrder
from app.models.user import User
from app.services.credit.ledger_service import grant_credits
from app.services.credit.locking import acquire_user_credit_lock
from app.services.credit.product_service import grant_count_for_cycle
from app.services.credit.query_service import get_available_credits
from app.services.credit.time_policy import add_natural_months, natural_month_period
from app.services.credit.utils import to_credit_decimal, utc_now
from app.services.operation_log_service import log_operation_event
from app.utils.id_gen import generate_id
def _allocate_amount_by_period(total: Decimal, count: int) -> list[Decimal]:
cents = int((to_credit_decimal(total) * 100).to_integral_value())
base, remainder = divmod(cents, count)
values = [Decimal(base) / 100 for _ in range(count)]
values[-1] += Decimal(remainder) / 100
return [to_credit_decimal(item) for item in values]
def _product_snapshot(product: CreditProduct) -> dict:
return {
"id": product.id,
"product_code": product.product_code,
"product_type": product.product_type,
"name": product.name,
"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),
"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,
"grant_credits": float(product.grant_credits or 0),
"credit_level": product.credit_level,
"features": product.features_json or [],
}
def _resolve_order_product_snapshot(
order: PaymentOrder,
product: CreditProduct | None,
) -> dict:
"""返回支付创建时冻结的商品快照。
支付履约不得读取后台后来修改后的价格、积分或套餐周期。旧订单若尚未
保存快照,才允许使用当前商品生成一次兼容快照。
"""
snapshot = dict(order.product_snapshot_json or {})
if not snapshot and product is not None:
snapshot = _product_snapshot(product)
if not snapshot:
raise ValueError("订单缺少商品快照,无法履约")
return snapshot
def _snapshot_decimal(snapshot: dict, key: str) -> Decimal:
return to_credit_decimal(snapshot.get(key) or 0)
async def _create_subscription(
db: AsyncSession,
*,
order: PaymentOrder,
product: CreditProduct | None,
paid_at: datetime,
) -> tuple[UserCreditSubscription, list[UserCreditSubscriptionPeriod]]:
snapshot = _resolve_order_product_snapshot(order, product)
billing_cycle = str(snapshot.get("billing_cycle") or "")
count = grant_count_for_cycle(billing_cycle)
expires_at = add_natural_months(paid_at, count)
# 升级订单的现金实付额已扣除旧套餐未来周期价值;新订阅后续再升级时,
# 周期价值必须按目标套餐完整价格快照分摊,不能只按本次现金实付额分摊。
pricing_basis_amount = to_credit_decimal(
order.target_price_snapshot
if order.purchase_scene == "upgrade" and order.target_price_snapshot is not None
else order.amount
)
subscription = UserCreditSubscription(
id=generate_id(),
user_id=order.user_id,
product_id=order.product_id,
payment_order_id=order.id,
status=CreditSubscriptionStatus.ACTIVE.value,
purchase_scene=str(order.purchase_scene or "renewal"),
tier_code=str(snapshot.get("tier_code") or ""),
tier_rank=int(snapshot.get("tier_rank") or 0),
billing_cycle=billing_cycle,
anchor_at=paid_at,
start_at=paid_at,
expires_at=expires_at,
next_grant_at=(add_natural_months(paid_at, 1) if count > 1 else None),
monthly_grant_credits_snapshot=_snapshot_decimal(snapshot, "monthly_grant_credits"),
grant_count=count,
granted_count=0,
paid_amount_snapshot=pricing_basis_amount,
product_snapshot_json=snapshot,
source_subscription_id=order.source_subscription_id,
upgrade_order_id=(order.id if order.purchase_scene == "upgrade" else None),
)
db.add(subscription)
await db.flush()
allocations = _allocate_amount_by_period(pricing_basis_amount, count)
periods: list[UserCreditSubscriptionPeriod] = []
for sequence in range(count):
start, end = natural_month_period(paid_at, sequence)
period = UserCreditSubscriptionPeriod(
id=generate_id(),
subscription_id=subscription.id,
sequence=sequence + 1,
scheduled_at=start,
valid_from=start,
expires_at=end,
grant_credits=_snapshot_decimal(snapshot, "monthly_grant_credits"),
allocated_paid_amount=allocations[sequence],
status=CreditSubscriptionPeriodStatus.SCHEDULED.value,
)
db.add(period)
periods.append(period)
await db.flush()
return subscription, periods
async def grant_subscription_period(
db: AsyncSession,
*,
subscription: UserCreditSubscription,
period: UserCreditSubscriptionPeriod,
request_time: datetime | None = None,
) -> UserCreditBalance:
checked_at = request_time or utc_now()
if period.status == CreditSubscriptionPeriodStatus.GRANTED.value and period.issued_balance_id:
result = await db.execute(
select(UserCreditBalance).where(UserCreditBalance.id == period.issued_balance_id).limit(1)
)
existing = result.scalar_one_or_none()
if existing:
return existing
if period.status not in {
CreditSubscriptionPeriodStatus.SCHEDULED.value,
CreditSubscriptionPeriodStatus.GRANTED.value,
}:
raise ValueError("当前订阅周期不能发放积分")
mutation = await grant_credits(
db,
user_id=subscription.user_id,
amount=period.grant_credits,
description=f"订阅套餐第{period.sequence}个月积分发放",
source_type=CreditBalanceSourceType.SUBSCRIPTION_GRANT.value,
valid_from=period.valid_from,
expires_at=period.expires_at,
credit_level=str(subscription.product_snapshot_json.get("credit_level") or "general"),
source_id=period.id,
product_id=subscription.product_id,
payment_order_id=subscription.payment_order_id,
subscription_id=subscription.id,
subscription_period_id=period.id,
related_id=subscription.id,
record_type=CreditRecordType.RECHARGE.value,
biz_key=f"subscription:{subscription.id}:period:{period.sequence}:grant",
metadata_json={"period_sequence": period.sequence},
request_time=checked_at,
)
result = await db.execute(
select(UserCreditBalance)
.where(UserCreditBalance.grant_record_id == mutation.record.id)
.limit(1)
)
balance = result.scalar_one()
period.status = CreditSubscriptionPeriodStatus.GRANTED.value
period.issued_balance_id = balance.id
period.issued_at = checked_at
subscription.granted_count = max(subscription.granted_count, period.sequence)
subscription.next_grant_at = (
add_natural_months(subscription.anchor_at, period.sequence)
if period.sequence < subscription.grant_count
else None
)
await db.flush()
return balance
async def _reconcile_upgrade_period_mismatch(
db: AsyncSession,
*,
order: PaymentOrder,
new_first_balance: UserCreditBalance,
checked_at: datetime,
) -> None:
"""核对升级订单创建时的抵扣周期与支付结算时状态。
正常情况下抵扣周期一直处于 upgrade_reserved,仅取消未来发放。
若历史竞态导致周期已发放,则撤销未消费积分,并把每一笔尚未退款的
消费分摊通过 source_allocation_id 迁移到升级套餐首期积分,保证后续
业务失败退款仍能准确退回当前实际承担成本的积分来源。
"""
period_ids = list(order.upgrade_period_ids_json or [])
if not period_ids:
return
result = await db.execute(
select(UserCreditSubscriptionPeriod)
.where(UserCreditSubscriptionPeriod.id.in_(period_ids))
.order_by(UserCreditSubscriptionPeriod.id.asc())
.with_for_update()
)
periods = list(result.scalars().all())
expected_ids = [str(item) for item in period_ids]
if len(expected_ids) != len(set(expected_ids)):
raise RuntimeError("升级订单抵扣周期快照存在重复ID")
actual_ids = {str(period.id) for period in periods}
if actual_ids != set(expected_ids):
raise RuntimeError("升级订单抵扣周期快照与实际周期集合不一致")
if not order.source_subscription_id:
raise RuntimeError("升级订单缺少原订阅ID")
allowed_statuses = {
CreditSubscriptionPeriodStatus.UPGRADE_RESERVED.value,
CreditSubscriptionPeriodStatus.GRANTED.value,
}
for period in periods:
if str(period.subscription_id) != str(order.source_subscription_id):
raise RuntimeError("升级订单抵扣周期不属于原订阅")
if str(period.upgrade_order_id or "") != str(order.id):
raise RuntimeError("升级订单抵扣周期未绑定当前订单")
if period.status not in allowed_statuses:
raise RuntimeError(f"升级订单抵扣周期状态不允许结算:{period.status}")
if period.status == CreditSubscriptionPeriodStatus.GRANTED.value and not period.issued_balance_id:
raise RuntimeError("升级订单抵扣周期已发放但缺少积分来源记录")
if period.status == CreditSubscriptionPeriodStatus.UPGRADE_RESERVED.value and period.issued_balance_id:
raise RuntimeError("升级订单抵扣周期仍为预留状态但已存在积分来源记录")
reserved = [
period for period in periods
if period.status == CreditSubscriptionPeriodStatus.UPGRADE_RESERVED.value
]
inconsistent = [
period
for period in periods
if period.status == CreditSubscriptionPeriodStatus.GRANTED.value
and period.issued_balance_id
]
for period in reserved:
period.status = CreditSubscriptionPeriodStatus.CANCELLED_BY_UPGRADE.value
period.cancelled_at = checked_at
if not inconsistent:
return
balance_ids = [str(period.issued_balance_id) for period in inconsistent if period.issued_balance_id]
balance_result = await db.execute(
select(UserCreditBalance)
.where(UserCreditBalance.id.in_(balance_ids))
.order_by(UserCreditBalance.id.asc())
.with_for_update()
)
old_balances = list(balance_result.scalars().all())
if {str(item.id) for item in old_balances} != set(balance_ids):
raise RuntimeError("升级边界补偿缺少原积分批次")
period_by_balance = {str(period.issued_balance_id): period for period in inconsistent}
for balance in old_balances:
period = period_by_balance.get(str(balance.id))
if period is None:
raise RuntimeError("升级边界补偿积分批次与周期无法对应")
if str(balance.user_id) != str(order.user_id):
raise RuntimeError("升级边界补偿积分批次不属于当前用户")
if str(balance.subscription_id or "") != str(order.source_subscription_id):
raise RuntimeError("升级边界补偿积分批次不属于原订阅")
if str(balance.subscription_period_id or "") != str(period.id):
raise RuntimeError("升级边界补偿积分批次与订阅周期不一致")
total_transfer = sum(
(to_credit_decimal(item.consumed_amount) for item in old_balances),
Decimal("0.00"),
)
if to_credit_decimal(new_first_balance.unspent_amount) < total_transfer:
raise RuntimeError("升级套餐首期积分不足以承接边界消费来源迁移")
before_available = await get_available_credits(db, order.user_id, request_time=checked_at)
record = CreditRecord(
id=generate_id(),
user_id=order.user_id,
type=CreditRecordType.REVOKE.value,
amount=Decimal("0.00"),
balance_delta=Decimal("0.00"),
expired_amount=Decimal("0.00"),
balance_after=before_available,
description="套餐升级边界核对:废弃重复发放积分并迁移已消费来源",
related_id=order.id,
request_time=checked_at,
biz_key=f"payment-order:{order.id}:upgrade-reconcile",
billing_scene=CreditRecordBillingScene.CREDIT_REVOKE.value,
)
db.add(record)
await db.flush()
new_unspent_cursor = to_credit_decimal(new_first_balance.unspent_amount)
new_consumed_cursor = to_credit_decimal(new_first_balance.consumed_amount)
total_removed = Decimal("0.00")
for old in old_balances:
old_unspent = to_credit_decimal(old.unspent_amount)
old_consumed = to_credit_decimal(old.consumed_amount)
total_removed += old_unspent + old_consumed
if old_unspent > 0:
db.add(
CreditRecordAllocation(
id=generate_id(),
credit_record_id=record.id,
credit_balance_id=old.id,
user_id=order.user_id,
allocation_action=CreditAllocationAction.REVOKE.value,
amount=old_unspent,
request_time=checked_at,
credit_level_snapshot=old.credit_level,
source_type_snapshot=old.source_type,
source_id_snapshot=old.source_id,
valid_from_snapshot=old.valid_from,
expires_at_snapshot=old.expires_at,
unspent_before=old_unspent,
unspent_after=Decimal("0.00"),
consumed_before=old_consumed,
consumed_after=old_consumed,
)
)
if old_consumed > 0:
consume_result = await db.execute(
select(CreditRecordAllocation)
.where(
CreditRecordAllocation.credit_balance_id == old.id,
CreditRecordAllocation.allocation_action
== CreditAllocationAction.CONSUME.value,
)
.order_by(CreditRecordAllocation.created_at.asc(), CreditRecordAllocation.id.asc())
)
original_allocations = list(consume_result.scalars().all())
original_ids = [item.id for item in original_allocations]
adjusted_by_source: dict[str, Decimal] = {}
if original_ids:
adjusted_result = await db.execute(
select(CreditRecordAllocation).where(
CreditRecordAllocation.source_allocation_id.in_(original_ids),
CreditRecordAllocation.allocation_action.in_(
[
CreditAllocationAction.REFUND_AVAILABLE.value,
CreditAllocationAction.REFUND_EXPIRED.value,
CreditAllocationAction.UPGRADE_SOURCE_TRANSFER_OUT.value,
]
),
)
)
for adjusted in adjusted_result.scalars().all():
if adjusted.source_allocation_id:
adjusted_by_source[adjusted.source_allocation_id] = (
adjusted_by_source.get(adjusted.source_allocation_id, Decimal("0.00"))
+ to_credit_decimal(adjusted.amount)
)
remaining = old_consumed
old_consumed_cursor = old_consumed
for original_allocation in original_allocations:
if remaining <= 0:
break
original_amount = to_credit_decimal(original_allocation.amount)
already_adjusted = adjusted_by_source.get(original_allocation.id, Decimal("0.00"))
available_to_transfer = original_amount - already_adjusted
if available_to_transfer < 0:
raise RuntimeError("消费分摊的退款或迁移金额超过原消费金额")
transfer_amount = min(available_to_transfer, remaining)
if transfer_amount <= 0:
continue
db.add(
CreditRecordAllocation(
id=generate_id(),
credit_record_id=record.id,
credit_balance_id=old.id,
user_id=order.user_id,
source_allocation_id=original_allocation.id,
allocation_action=CreditAllocationAction.UPGRADE_SOURCE_TRANSFER_OUT.value,
amount=transfer_amount,
request_time=checked_at,
credit_level_snapshot=old.credit_level,
source_type_snapshot=old.source_type,
source_id_snapshot=old.source_id,
valid_from_snapshot=old.valid_from,
expires_at_snapshot=old.expires_at,
unspent_before=Decimal("0.00"),
unspent_after=Decimal("0.00"),
consumed_before=old_consumed_cursor,
consumed_after=old_consumed_cursor - transfer_amount,
)
)
db.add(
CreditRecordAllocation(
id=generate_id(),
credit_record_id=record.id,
credit_balance_id=new_first_balance.id,
user_id=order.user_id,
source_allocation_id=original_allocation.id,
allocation_action=CreditAllocationAction.UPGRADE_SOURCE_TRANSFER_IN.value,
amount=transfer_amount,
request_time=checked_at,
credit_level_snapshot=new_first_balance.credit_level,
source_type_snapshot=new_first_balance.source_type,
source_id_snapshot=new_first_balance.source_id,
valid_from_snapshot=new_first_balance.valid_from,
expires_at_snapshot=new_first_balance.expires_at,
unspent_before=new_unspent_cursor,
unspent_after=new_unspent_cursor - transfer_amount,
consumed_before=new_consumed_cursor,
consumed_after=new_consumed_cursor + transfer_amount,
)
)
old_consumed_cursor -= transfer_amount
new_unspent_cursor -= transfer_amount
new_consumed_cursor += transfer_amount
remaining -= transfer_amount
if remaining > 0:
raise RuntimeError("无法定位全部已消费积分的原始业务分摊,升级结算已中止")
old.unspent_amount = Decimal("0.00")
old.consumed_amount = Decimal("0.00")
old.revoked_amount = to_credit_decimal(old.revoked_amount) + old_unspent + old_consumed
old.revoked_at = checked_at
old.status = CreditBalanceStatus.REVOKED.value
for period in inconsistent:
if period.issued_balance_id == old.id:
period.status = CreditSubscriptionPeriodStatus.REVOKED_BY_UPGRADE.value
period.revoked_at = checked_at
new_first_balance.unspent_amount = new_unspent_cursor
new_first_balance.consumed_amount = new_consumed_cursor
if new_unspent_cursor == 0:
new_first_balance.status = CreditBalanceStatus.CONSUMED.value
record.amount = -total_removed
record.balance_delta = -total_removed
record.balance_after = before_available - total_removed
await db.flush()
async def fulfill_payment_product(
db: AsyncSession,
*,
order: PaymentOrder,
fulfilled_at: datetime | None = None,
) -> None:
if order.fulfillment_status == "fulfilled":
return
checked_at = fulfilled_at or order.paid_at or utc_now()
await acquire_user_credit_lock(db, order.user_id)
user_result = await db.execute(select(User).where(User.id == order.user_id).with_for_update().limit(1))
user = user_result.scalar_one()
product_result = await db.execute(
select(CreditProduct).where(CreditProduct.id == order.product_id).limit(1)
)
product = product_result.scalar_one_or_none()
snapshot = _resolve_order_product_snapshot(order, product)
product_type = str(order.product_type or snapshot.get("product_type") or "")
if product_type == CreditProductType.CREDIT_ADDON.value:
await grant_credits(
db,
user_id=order.user_id,
amount=_snapshot_decimal(snapshot, "grant_credits"),
description=f"购买积分增值包:{order.product_name_snapshot or snapshot.get('name') or '积分增值包'}",
source_type=CreditBalanceSourceType.CREDIT_ADDON.value,
valid_from=checked_at,
expires_at=add_natural_months(checked_at, 1),
credit_level=str(snapshot.get("credit_level") or "general"),
source_id=order.id,
product_id=order.product_id,
payment_order_id=order.id,
related_id=order.id,
biz_key=f"payment-order:{order.id}:credit-addon:grant",
request_time=checked_at,
)
order.fulfillment_status = "fulfilled"
order.fulfilled_at = checked_at
return
old_subscription: UserCreditSubscription | None = None
if order.purchase_scene == "upgrade":
if not order.source_subscription_id:
order.fulfillment_status = CreditSubscriptionStatus.UPGRADE_RECONCILE_FAILED.value
await db.flush()
return
old_result = await db.execute(
select(UserCreditSubscription)
.where(UserCreditSubscription.id == order.source_subscription_id)
.with_for_update()
.limit(1)
)
old_subscription = old_result.scalar_one_or_none()
if old_subscription is None or str(old_subscription.user_id) != str(order.user_id):
order.fulfillment_status = CreditSubscriptionStatus.UPGRADE_RECONCILE_FAILED.value
await db.flush()
return
if order.purchase_scene != "upgrade":
# 首充/续费维持原有支付履约语义:创建或发放异常继续向外抛出,
# 不能被升级专用的核对失败状态吞掉。
subscription, periods = await _create_subscription(
db,
order=order,
product=product,
paid_at=checked_at,
)
await grant_subscription_period(
db,
subscription=subscription,
period=periods[0],
request_time=checked_at,
)
else:
try:
async with db.begin_nested():
subscription, periods = await _create_subscription(
db,
order=order,
product=product,
paid_at=checked_at,
)
first_balance = await grant_subscription_period(
db,
subscription=subscription,
period=periods[0],
request_time=checked_at,
)
await _reconcile_upgrade_period_mismatch(
db,
order=order,
new_first_balance=first_balance,
checked_at=checked_at,
)
except Exception as exc:
order.fulfillment_status = CreditSubscriptionStatus.UPGRADE_RECONCILE_FAILED.value
await db.flush()
log_operation_event(
domain="billing",
module="credit_upgrade",
event_type="CREDIT_UPGRADE_RECONCILE_FAILED",
event_status="failed",
source="app.services.credit.subscription_service.fulfill_payment_product",
user_id=str(order.user_id),
task_id=str(order.id),
message="套餐升级支付履约周期核对失败",
error=str(exc),
detail={
"order_id": str(order.id),
"order_no": str(order.order_no),
"source_subscription_id": str(order.source_subscription_id or ""),
"upgrade_period_ids": list(order.upgrade_period_ids_json or []),
},
)
return
if old_subscription is not None:
old_subscription.status = CreditSubscriptionStatus.UPGRADED.value
old_subscription.upgrade_order_id = order.id
order.subscription_id = subscription.id
order.fulfillment_status = "fulfilled"
order.fulfilled_at = checked_at
if user.first_membership_paid_at is None:
user.first_membership_paid_at = checked_at
await db.flush()
async def list_due_subscription_period_candidates(
db: AsyncSession,
*,
request_time: datetime,
batch_size: int = 100,
) -> list[tuple[str, str, str]]:
result = await db.execute(
select(
UserCreditSubscriptionPeriod.id,
UserCreditSubscriptionPeriod.subscription_id,
UserCreditSubscription.user_id,
)
.join(
UserCreditSubscription,
UserCreditSubscription.id == UserCreditSubscriptionPeriod.subscription_id,
)
.where(
UserCreditSubscription.status == CreditSubscriptionStatus.ACTIVE.value,
UserCreditSubscriptionPeriod.status == CreditSubscriptionPeriodStatus.SCHEDULED.value,
UserCreditSubscriptionPeriod.scheduled_at <= request_time,
)
.order_by(UserCreditSubscriptionPeriod.scheduled_at.asc(), UserCreditSubscriptionPeriod.id.asc())
.limit(max(1, batch_size))
)
return [(str(row.id), str(row.subscription_id), str(row.user_id)) for row in result.all()]
async def grant_due_subscription_period_by_id(
db: AsyncSession,
*,
period_id: str,
subscription_id: str,
user_id: str,
request_time: datetime,
) -> bool:
await acquire_user_credit_lock(db, user_id)
subscription_result = await db.execute(
select(UserCreditSubscription)
.where(UserCreditSubscription.id == subscription_id)
.with_for_update()
.limit(1)
)
subscription = subscription_result.scalar_one_or_none()
if subscription is None or subscription.status != CreditSubscriptionStatus.ACTIVE.value:
return False
period_result = await db.execute(
select(UserCreditSubscriptionPeriod)
.where(UserCreditSubscriptionPeriod.id == period_id)
.with_for_update()
.limit(1)
)
period = period_result.scalar_one_or_none()
if (
period is None
or period.subscription_id != subscription.id
or period.status != CreditSubscriptionPeriodStatus.SCHEDULED.value
or period.scheduled_at > request_time
):
return False
await grant_subscription_period(
db,
subscription=subscription,
period=period,
request_time=request_time,
)
return True
async def grant_due_subscription_periods(
db: AsyncSession,
*,
request_time: datetime | None = None,
batch_size: int = 100,
) -> int:
checked_at = request_time or utc_now()
candidates = await list_due_subscription_period_candidates(
db, request_time=checked_at, batch_size=batch_size
)
count = 0
for period_id, subscription_id, user_id in candidates:
if await grant_due_subscription_period_by_id(
db,
period_id=period_id,
subscription_id=subscription_id,
user_id=user_id,
request_time=checked_at,
):
count += 1
return count
async def list_due_subscription_ids(
db: AsyncSession,
*,
request_time: datetime,
batch_size: int = 500,
) -> list[str]:
result = await db.execute(
select(UserCreditSubscription.id)
.where(
UserCreditSubscription.status == CreditSubscriptionStatus.ACTIVE.value,
UserCreditSubscription.expires_at <= request_time,
)
.order_by(UserCreditSubscription.expires_at.asc(), UserCreditSubscription.id.asc())
.limit(max(1, batch_size))
)
return [str(item) for item in result.scalars().all()]
async def expire_subscription_by_id(
db: AsyncSession,
*,
subscription_id: str,
request_time: datetime,
) -> bool:
result = await db.execute(
select(UserCreditSubscription)
.where(UserCreditSubscription.id == subscription_id)
.with_for_update()
.limit(1)
)
subscription = result.scalar_one_or_none()
if (
subscription is None
or subscription.status != CreditSubscriptionStatus.ACTIVE.value
or subscription.expires_at > request_time
):
return False
subscription.status = CreditSubscriptionStatus.EXPIRED.value
subscription.next_grant_at = None
await db.flush()
return True
async def expire_due_subscriptions(
db: AsyncSession,
*,
request_time: datetime | None = None,
batch_size: int = 500,
) -> int:
checked_at = request_time or utc_now()
ids = await list_due_subscription_ids(db, request_time=checked_at, batch_size=batch_size)
count = 0
for subscription_id in ids:
if await expire_subscription_by_id(
db, subscription_id=subscription_id, request_time=checked_at
):
count += 1
return count
async def revoke_payment_order_credits(
db: AsyncSession,
*,
order: PaymentOrder,
reason: str,
request_time: datetime | None = None,
) -> float:
"""支付现有退款流程的积分账本适配:只撤销该订单当前尚未消费的积分。"""
from app.services.credit.ledger_service import revoke_balances
checked_at = request_time or utc_now()
await acquire_user_credit_lock(db, order.user_id)
balance_result = await db.execute(
select(UserCreditBalance)
.where(
UserCreditBalance.payment_order_id == order.id,
UserCreditBalance.user_id == order.user_id,
UserCreditBalance.unspent_amount > 0,
UserCreditBalance.valid_from <= checked_at,
UserCreditBalance.expires_at > checked_at,
UserCreditBalance.revoked_at.is_(None),
)
.order_by(UserCreditBalance.id.asc())
.with_for_update()
)
balances = list(balance_result.scalars().all())
revoked = sum((to_credit_decimal(item.unspent_amount) for item in balances), Decimal("0.00"))
if balances:
await revoke_balances(
db,
balances=balances,
description=f"{reason}:撤销订单未消费积分",
related_id=order.id,
biz_key=f"payment-order:{order.id}:refund-revoke",
request_time=checked_at,
)
if order.subscription_id:
subscription_result = await db.execute(
select(UserCreditSubscription)
.where(UserCreditSubscription.id == order.subscription_id)
.limit(1)
.with_for_update()
)
subscription = subscription_result.scalar_one_or_none()
if subscription:
subscription.status = CreditSubscriptionStatus.REFUNDED.value
periods_result = await db.execute(
select(UserCreditSubscriptionPeriod)
.where(
UserCreditSubscriptionPeriod.subscription_id == subscription.id,
UserCreditSubscriptionPeriod.status.in_([
CreditSubscriptionPeriodStatus.SCHEDULED.value,
CreditSubscriptionPeriodStatus.UPGRADE_RESERVED.value,
]),
)
.order_by(UserCreditSubscriptionPeriod.id.asc())
.with_for_update()
)
for period in periods_result.scalars().all():
period.status = CreditSubscriptionPeriodStatus.CANCELLED.value
period.cancelled_at = checked_at
period.upgrade_order_id = None
period.reserved_at = None
await db.flush()
return float(revoked)