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

792 lines
29 KiB
Python

from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal
from typing import Iterable
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.credit_balance import (
CREDIT_LEVEL_SORT,
CreditAllocationAction,
CreditBalanceSourceType,
CreditBalanceStatus,
CreditLevel,
)
from app.enums.credit_record import (
CreditRecordAction,
CreditRecordBillingScene,
CreditRecordType,
)
from app.enums.common import BillingBlockEventEnum
from app.models.credit.allocation import CreditRecordAllocation
from app.models.credit.balance import UserCreditBalance
from app.models.credit_record import CreditRecord
from app.models.user import User
from app.services.credit.locking import acquire_user_credit_lock
from app.services.credit.query_service import attach_credit_snapshot, get_available_credits
from app.services.credit.time_policy import add_natural_months
from app.services.credit.utils import to_credit_decimal, to_float, utc_now
from app.services.credit_record_meta_service import CreditRecordMeta, with_user_snapshot
from app.services.operation_log_service import log_operation_event
from app.utils.exceptions import InsufficientCreditsError
from app.utils.id_gen import generate_id
@dataclass(slots=True)
class CreditMutationResult:
user: User
record: CreditRecord | None
created: bool
amount: float
balance_before: float
balance_after: float
refund_available: float = 0.0
refund_expired: float = 0.0
@dataclass(slots=True, frozen=True)
class _RefundAllocationSource:
original_allocation: CreditRecordAllocation
credit_balance_id: str
amount: Decimal
@dataclass(slots=True, frozen=True)
class CreditRefundResult:
records: tuple[CreditRecord, ...]
created: bool
total_amount: Decimal
available_amount: Decimal
expired_amount: Decimal
balance_before: Decimal
balance_after: Decimal
async def _load_user(db: AsyncSession, user_id: str) -> User:
result = await db.execute(select(User).where(User.id == user_id).limit(1))
user = result.scalar_one_or_none()
if user is None:
raise ValueError("User not found")
return user
async def _record_meta_kwargs(
db: AsyncSession,
user: User,
record_meta: CreditRecordMeta | dict | None,
) -> dict:
if isinstance(record_meta, CreditRecordMeta):
populated = await with_user_snapshot(db, record_meta, user.id, user=user)
return populated.to_record_kwargs()
if isinstance(record_meta, dict):
return {key: value for key, value in record_meta.items() if value is not None}
return {}
async def _find_record_by_biz_key(
db: AsyncSession,
*,
user_id: str,
biz_key: str | None,
) -> CreditRecord | None:
if not biz_key:
return None
result = await db.execute(
select(CreditRecord)
.where(CreditRecord.user_id == user_id, CreditRecord.biz_key == biz_key)
.limit(1)
)
return result.scalar_one_or_none()
async def grant_credits(
db: AsyncSession,
*,
user_id: str,
amount: Decimal | float | int,
description: str,
source_type: str,
valid_from: datetime | None = None,
expires_at: datetime | None = None,
credit_level: str = CreditLevel.GENERAL.value,
source_id: str | None = None,
product_id: str | None = None,
payment_order_id: str | None = None,
subscription_id: str | None = None,
subscription_period_id: str | None = None,
related_id: str | None = None,
record_type: str = CreditRecordType.RECHARGE.value,
biz_key: str | None = None,
record_meta: CreditRecordMeta | dict | None = None,
metadata_json: dict | None = None,
request_time: datetime | None = None,
) -> CreditMutationResult:
grant_amount = to_credit_decimal(amount)
checked_at = request_time or utc_now()
starts_at = valid_from or checked_at
ends_at = expires_at or add_natural_months(starts_at, 1)
await acquire_user_credit_lock(db, user_id)
user = await _load_user(db, user_id)
before = await get_available_credits(db, user_id, request_time=checked_at)
existing = await _find_record_by_biz_key(db, user_id=user_id, biz_key=biz_key)
if existing is not None:
attach_credit_snapshot(user, before)
return CreditMutationResult(
user=user,
record=existing,
created=False,
amount=abs(to_float(existing.amount)),
balance_before=to_float(before),
balance_after=to_float(before),
)
if grant_amount <= 0:
attach_credit_snapshot(user, before)
return CreditMutationResult(user, None, False, 0.0, to_float(before), to_float(before))
if ends_at <= starts_at:
raise ValueError("积分过期时间必须晚于生效时间")
meta_kwargs = await _record_meta_kwargs(db, user, record_meta)
after = before + grant_amount if starts_at <= checked_at < ends_at else before
record = CreditRecord(
id=generate_id(),
user_id=user_id,
type=record_type,
amount=grant_amount,
balance_delta=(grant_amount if starts_at <= checked_at < ends_at else Decimal("0.00")),
expired_amount=Decimal("0.00"),
balance_after=after,
description=description,
related_id=related_id,
request_time=checked_at,
biz_key=biz_key,
credit_level_snapshot=credit_level,
**meta_kwargs,
)
db.add(record)
await db.flush()
balance = UserCreditBalance(
id=generate_id(),
user_id=user_id,
credit_level=credit_level,
credit_level_rank=CREDIT_LEVEL_SORT.get(credit_level, CREDIT_LEVEL_SORT[CreditLevel.GENERAL.value]),
source_type=source_type,
source_id=source_id,
product_id=product_id,
payment_order_id=payment_order_id,
subscription_id=subscription_id,
subscription_period_id=subscription_period_id,
grant_record_id=record.id,
grant_amount=grant_amount,
unspent_amount=grant_amount,
consumed_amount=Decimal("0.00"),
expired_amount=Decimal("0.00"),
revoked_amount=Decimal("0.00"),
valid_from=starts_at,
expires_at=ends_at,
status=CreditBalanceStatus.ACTIVE.value,
biz_key=f"{biz_key or record.id}:balance",
metadata_json=metadata_json,
)
db.add(balance)
await db.flush()
allocation = CreditRecordAllocation(
id=generate_id(),
credit_record_id=record.id,
credit_balance_id=balance.id,
user_id=user_id,
allocation_action=CreditAllocationAction.GRANT.value,
amount=grant_amount,
request_time=checked_at,
credit_level_snapshot=credit_level,
source_type_snapshot=source_type,
source_id_snapshot=source_id,
valid_from_snapshot=starts_at,
expires_at_snapshot=ends_at,
unspent_before=Decimal("0.00"),
unspent_after=grant_amount,
consumed_before=Decimal("0.00"),
consumed_after=Decimal("0.00"),
)
db.add(allocation)
attach_credit_snapshot(user, after)
await db.flush()
log_operation_event(
domain="billing",
module="dynamic_credit",
event_type="CREDIT_GRANTED",
event_status="success",
source="app.services.credit.ledger_service.grant_credits",
user_id=user_id,
task_id=related_id,
message="动态积分发放完成",
detail={
"record_id": record.id,
"balance_id": balance.id,
"amount": to_float(grant_amount),
"source_type": source_type,
"credit_level": credit_level,
"valid_from": starts_at.isoformat(),
"expires_at": ends_at.isoformat(),
"biz_key": biz_key,
},
)
return CreditMutationResult(
user=user,
record=record,
created=True,
amount=to_float(grant_amount),
balance_before=to_float(before),
balance_after=to_float(after),
)
async def deduct_credits(
db: AsyncSession,
*,
user_id: str,
amount: Decimal | float | int,
description: str,
related_id: str | None = None,
biz_key: str | None = None,
refund_for_biz_key: str | None = None,
record_meta: CreditRecordMeta | dict | None = None,
record_type: str = CreditRecordType.CONSUME.value,
create_zero_record: bool = False,
request_time: datetime | None = None,
) -> CreditMutationResult:
consume_amount = to_credit_decimal(amount)
checked_at = request_time or utc_now()
await acquire_user_credit_lock(db, user_id)
user = await _load_user(db, user_id)
before = await get_available_credits(db, user_id, request_time=checked_at)
existing = await _find_record_by_biz_key(db, user_id=user_id, biz_key=biz_key)
if existing is not None:
attach_credit_snapshot(user, before)
return CreditMutationResult(
user=user,
record=existing,
created=False,
amount=abs(to_float(existing.amount)),
balance_before=to_float(before),
balance_after=to_float(before),
)
if consume_amount <= 0 and not create_zero_record:
attach_credit_snapshot(user, before)
return CreditMutationResult(user, None, False, 0.0, to_float(before), to_float(before))
if consume_amount > before:
log_operation_event(
domain="billing",
module="dynamic_credit",
event_type=BillingBlockEventEnum.INSUFFICIENT_CREDITS.value,
event_status="failed",
source="app.services.credit.ledger_service.deduct_credits",
user_id=user_id,
task_id=related_id,
message="有效积分不足,已在创建业务任务前同步拦截",
detail={
"required_credits": to_float(consume_amount),
"available_credits": to_float(before),
"biz_key": biz_key,
"request_time": checked_at.isoformat(),
"description": description,
},
)
raise InsufficientCreditsError()
result = await db.execute(
select(UserCreditBalance)
.where(
UserCreditBalance.user_id == user_id,
UserCreditBalance.valid_from <= checked_at,
UserCreditBalance.expires_at > checked_at,
UserCreditBalance.unspent_amount > 0,
UserCreditBalance.revoked_at.is_(None),
)
.order_by(
UserCreditBalance.credit_level_rank.asc(),
UserCreditBalance.expires_at.asc(),
UserCreditBalance.valid_from.asc(),
UserCreditBalance.id.asc(),
)
.with_for_update()
)
balances = list(result.scalars().all())
remaining = consume_amount
allocations_data: list[tuple[UserCreditBalance, Decimal, Decimal, Decimal, Decimal, Decimal]] = []
for balance in balances:
if remaining <= 0:
break
available = to_credit_decimal(balance.unspent_amount)
if available <= 0:
continue
allocated = min(available, remaining)
before_unspent = available
before_consumed = to_credit_decimal(balance.consumed_amount)
after_unspent = before_unspent - allocated
after_consumed = before_consumed + allocated
balance.unspent_amount = after_unspent
balance.consumed_amount = after_consumed
if after_unspent == 0:
balance.status = CreditBalanceStatus.CONSUMED.value
allocations_data.append(
(balance, allocated, before_unspent, after_unspent, before_consumed, after_consumed)
)
remaining -= allocated
if remaining > 0:
# 理论上用户级锁和前置汇总后不应发生;保留硬失败以避免部分扣除。
raise RuntimeError("积分分摊不足,事务将回滚")
meta_kwargs = await _record_meta_kwargs(db, user, record_meta)
after = before - consume_amount
record = CreditRecord(
id=generate_id(),
user_id=user_id,
type=record_type,
amount=-consume_amount,
balance_delta=-consume_amount,
expired_amount=Decimal("0.00"),
balance_after=after,
description=description,
related_id=related_id,
request_time=checked_at,
biz_key=biz_key,
refund_for_biz_key=refund_for_biz_key,
**meta_kwargs,
)
db.add(record)
await db.flush()
db.add_all(
[
CreditRecordAllocation(
id=generate_id(),
credit_record_id=record.id,
credit_balance_id=balance.id,
user_id=user_id,
allocation_action=CreditAllocationAction.CONSUME.value,
amount=allocated,
request_time=checked_at,
credit_level_snapshot=balance.credit_level,
source_type_snapshot=balance.source_type,
source_id_snapshot=balance.source_id,
valid_from_snapshot=balance.valid_from,
expires_at_snapshot=balance.expires_at,
unspent_before=before_unspent,
unspent_after=after_unspent,
consumed_before=before_consumed,
consumed_after=after_consumed,
)
for balance, allocated, before_unspent, after_unspent, before_consumed, after_consumed in allocations_data
]
)
attach_credit_snapshot(user, after)
await db.flush()
log_operation_event(
domain="billing",
module="dynamic_credit",
event_type="CREDIT_DEDUCTED",
event_status="success",
source="app.services.credit.ledger_service.deduct_credits",
user_id=user_id,
task_id=related_id,
message="动态积分同步扣除完成",
detail={
"record_id": record.id,
"amount": to_float(consume_amount),
"balance_before": to_float(before),
"balance_after": to_float(after),
"allocation_count": len(allocations_data),
"allocation_balance_ids": [item[0].id for item in allocations_data[:20]],
"biz_key": biz_key,
"request_time": checked_at.isoformat(),
},
)
return CreditMutationResult(
user=user,
record=record,
created=True,
amount=to_float(consume_amount),
balance_before=to_float(before),
balance_after=to_float(after),
)
async def _load_original_consume_allocations(
db: AsyncSession,
*,
original_record_id: str,
) -> list[_RefundAllocationSource]:
result = await db.execute(
select(CreditRecordAllocation)
.where(
CreditRecordAllocation.credit_record_id == original_record_id,
CreditRecordAllocation.allocation_action == CreditAllocationAction.CONSUME.value,
)
.order_by(CreditRecordAllocation.id.desc())
)
originals = list(result.scalars().all())
if not originals:
return []
original_ids = [item.id for item in originals]
transfer_result = await db.execute(
select(CreditRecordAllocation)
.where(
CreditRecordAllocation.source_allocation_id.in_(original_ids),
CreditRecordAllocation.allocation_action
== CreditAllocationAction.UPGRADE_SOURCE_TRANSFER_IN.value,
)
.order_by(CreditRecordAllocation.id.asc())
)
transfers_by_source: dict[str, list[CreditRecordAllocation]] = {}
for item in transfer_result.scalars().all():
if item.source_allocation_id:
transfers_by_source.setdefault(item.source_allocation_id, []).append(item)
sources: list[_RefundAllocationSource] = []
for original in originals:
transferred = Decimal("0.00")
for transfer in transfers_by_source.get(original.id, []):
amount = to_credit_decimal(transfer.amount)
transferred += amount
sources.append(
_RefundAllocationSource(
original_allocation=original,
credit_balance_id=transfer.credit_balance_id,
amount=amount,
)
)
remaining = to_credit_decimal(original.amount) - transferred
if remaining < 0:
raise RuntimeError("升级积分来源迁移金额超过原消费分摊")
if remaining > 0:
sources.append(
_RefundAllocationSource(
original_allocation=original,
credit_balance_id=original.credit_balance_id,
amount=remaining,
)
)
return sources
async def refund_consumption(
db: AsyncSession,
*,
user_id: str,
refund_for_biz_key: str,
description: str,
related_id: str | None = None,
biz_key: str | None = None,
record_meta: CreditRecordMeta | dict | None = None,
refund_time: datetime | None = None,
) -> CreditRefundResult:
checked_at = refund_time or utc_now()
await acquire_user_credit_lock(db, user_id)
user = await _load_user(db, user_id)
before = await get_available_credits(db, user_id, request_time=checked_at)
existing_result = await db.execute(
select(CreditRecord).where(
CreditRecord.user_id == user_id,
CreditRecord.refund_for_biz_key == refund_for_biz_key,
CreditRecord.refund_kind.in_(["available", "expired"]),
)
)
existing_records = list(existing_result.scalars().all())
if existing_records:
available = sum((to_credit_decimal(item.balance_delta) for item in existing_records), Decimal("0.00"))
expired = sum((to_credit_decimal(item.expired_amount) for item in existing_records), Decimal("0.00"))
attach_credit_snapshot(user, before)
return CreditRefundResult(
records=tuple(existing_records),
created=False,
total_amount=available + expired,
available_amount=available,
expired_amount=expired,
balance_before=before,
balance_after=before,
)
original_result = await db.execute(
select(CreditRecord)
.where(CreditRecord.user_id == user_id, CreditRecord.biz_key == refund_for_biz_key)
.limit(1)
)
original = original_result.scalar_one_or_none()
if original is None:
raise ValueError("未找到原积分消费流水")
allocations = await _load_original_consume_allocations(db, original_record_id=original.id)
balance_ids = list(dict.fromkeys(item.credit_balance_id for item in allocations))
balances_result = await db.execute(
select(UserCreditBalance)
.where(UserCreditBalance.id.in_(balance_ids), UserCreditBalance.user_id == user_id)
.order_by(UserCreditBalance.id.asc())
.with_for_update()
)
balance_map = {item.id: item for item in balances_result.scalars().all()}
available_total = Decimal("0.00")
expired_total = Decimal("0.00")
available_allocations: list[tuple[CreditRecordAllocation, UserCreditBalance, Decimal, Decimal, Decimal, Decimal, Decimal]] = []
expired_allocations: list[tuple[CreditRecordAllocation, UserCreditBalance, Decimal, Decimal, Decimal, Decimal, Decimal]] = []
for allocation_source in allocations:
allocation = allocation_source.original_allocation
balance = balance_map.get(allocation_source.credit_balance_id)
if balance is None:
raise RuntimeError(f"原积分来源不存在: {allocation_source.credit_balance_id}")
refund_amount = to_credit_decimal(allocation_source.amount)
before_unspent = to_credit_decimal(balance.unspent_amount)
before_consumed = to_credit_decimal(balance.consumed_amount)
if before_consumed < refund_amount:
raise RuntimeError("原积分批次已消费金额不足以退款")
after_consumed = before_consumed - refund_amount
balance.consumed_amount = after_consumed
if checked_at < balance.expires_at and balance.revoked_at is None:
after_unspent = before_unspent + refund_amount
balance.unspent_amount = after_unspent
balance.status = CreditBalanceStatus.ACTIVE.value
available_total += refund_amount
available_allocations.append(
(allocation, balance, refund_amount, before_unspent, after_unspent, before_consumed, after_consumed)
)
else:
before_expired = to_credit_decimal(balance.expired_amount)
balance.expired_amount = before_expired + refund_amount
after_unspent = before_unspent
if balance.unspent_amount == 0 and balance.consumed_amount == 0:
balance.status = CreditBalanceStatus.EXPIRED.value
expired_total += refund_amount
expired_allocations.append(
(allocation, balance, refund_amount, before_unspent, after_unspent, before_consumed, after_consumed)
)
meta_kwargs = await _record_meta_kwargs(db, user, record_meta)
created_records: list[CreditRecord] = []
balance_after = before + available_total
async def create_refund_record(kind: str, amount: Decimal, expired_amount: Decimal) -> CreditRecord:
record = CreditRecord(
id=generate_id(),
user_id=user_id,
type=CreditRecordType.REFUND.value,
amount=amount,
balance_delta=(amount if kind == "available" else Decimal("0.00")),
expired_amount=expired_amount,
balance_after=balance_after,
description=(
f"{description},退回有效积分:{to_float(amount)}"
if kind == "available"
else f"{description},原积分已过期:{to_float(expired_amount)}"
),
related_id=related_id,
request_time=checked_at,
biz_key=f"{biz_key or refund_for_biz_key + ':refund'}:{kind}",
refund_for_biz_key=refund_for_biz_key,
refund_kind=kind,
**meta_kwargs,
)
db.add(record)
await db.flush()
return record
if available_total > 0:
available_record = await create_refund_record("available", available_total, Decimal("0.00"))
created_records.append(available_record)
db.add_all(
[
CreditRecordAllocation(
id=generate_id(),
credit_record_id=available_record.id,
credit_balance_id=balance.id,
user_id=user_id,
source_allocation_id=allocation.id,
allocation_action=CreditAllocationAction.REFUND_AVAILABLE.value,
amount=amount,
request_time=checked_at,
credit_level_snapshot=balance.credit_level,
source_type_snapshot=balance.source_type,
source_id_snapshot=balance.source_id,
valid_from_snapshot=balance.valid_from,
expires_at_snapshot=balance.expires_at,
unspent_before=before_unspent,
unspent_after=after_unspent,
consumed_before=before_consumed,
consumed_after=after_consumed,
)
for allocation, balance, amount, before_unspent, after_unspent, before_consumed, after_consumed in available_allocations
]
)
if expired_total > 0:
expired_record = await create_refund_record("expired", expired_total, expired_total)
created_records.append(expired_record)
db.add_all(
[
CreditRecordAllocation(
id=generate_id(),
credit_record_id=expired_record.id,
credit_balance_id=balance.id,
user_id=user_id,
source_allocation_id=allocation.id,
allocation_action=CreditAllocationAction.REFUND_EXPIRED.value,
amount=amount,
request_time=checked_at,
credit_level_snapshot=balance.credit_level,
source_type_snapshot=balance.source_type,
source_id_snapshot=balance.source_id,
valid_from_snapshot=balance.valid_from,
expires_at_snapshot=balance.expires_at,
unspent_before=before_unspent,
unspent_after=after_unspent,
consumed_before=before_consumed,
consumed_after=after_consumed,
)
for allocation, balance, amount, before_unspent, after_unspent, before_consumed, after_consumed in expired_allocations
]
)
attach_credit_snapshot(user, balance_after)
await db.flush()
log_operation_event(
domain="billing",
module="dynamic_credit",
event_type="CREDIT_SOURCE_REFUNDED",
event_status="success",
source="app.services.credit.ledger_service.refund_consumption",
user_id=user_id,
task_id=related_id,
message="按原积分来源完成业务失败退款",
detail={
"refund_for_biz_key": refund_for_biz_key,
"available_refund": to_float(available_total),
"expired_refund": to_float(expired_total),
"source_count": len(allocations),
"created_record_ids": [item.id for item in created_records],
"refund_time": checked_at.isoformat(),
},
)
return CreditRefundResult(
records=tuple(created_records),
created=True,
total_amount=available_total + expired_total,
available_amount=available_total,
expired_amount=expired_total,
balance_before=before,
balance_after=balance_after,
)
async def revoke_balances(
db: AsyncSession,
*,
balances: Iterable[UserCreditBalance],
description: str,
related_id: str | None,
biz_key: str,
request_time: datetime | None = None,
) -> CreditMutationResult:
items = list(balances)
if not items:
raise ValueError("没有可撤销的积分")
checked_at = request_time or utc_now()
user_id = items[0].user_id
await acquire_user_credit_lock(db, user_id)
user = await _load_user(db, user_id)
before = await get_available_credits(db, user_id, request_time=checked_at)
existing = await _find_record_by_biz_key(db, user_id=user_id, biz_key=biz_key)
if existing:
attach_credit_snapshot(user, before)
return CreditMutationResult(user, existing, False, abs(to_float(existing.amount)), to_float(before), to_float(before))
total = Decimal("0.00")
allocation_rows: list[CreditRecordAllocation] = []
record = CreditRecord(
id=generate_id(),
user_id=user_id,
type=CreditRecordType.REVOKE.value,
amount=Decimal("0.00"),
balance_delta=Decimal("0.00"),
expired_amount=Decimal("0.00"),
balance_after=before,
description=description,
related_id=related_id,
request_time=checked_at,
biz_key=biz_key,
billing_scene=CreditRecordBillingScene.CREDIT_REVOKE.value,
charge_action=CreditRecordAction.REFUND.value,
)
db.add(record)
await db.flush()
for balance in items:
if balance.user_id != user_id:
raise ValueError("不能跨用户撤销积分")
amount = to_credit_decimal(balance.unspent_amount)
if amount <= 0:
continue
before_unspent = amount
before_consumed = to_credit_decimal(balance.consumed_amount)
balance.unspent_amount = Decimal("0.00")
balance.revoked_amount = to_credit_decimal(balance.revoked_amount) + amount
balance.revoked_at = checked_at
balance.status = CreditBalanceStatus.REVOKED.value
total += amount
allocation_rows.append(
CreditRecordAllocation(
id=generate_id(),
credit_record_id=record.id,
credit_balance_id=balance.id,
user_id=user_id,
allocation_action=CreditAllocationAction.REVOKE.value,
amount=amount,
request_time=checked_at,
credit_level_snapshot=balance.credit_level,
source_type_snapshot=balance.source_type,
source_id_snapshot=balance.source_id,
valid_from_snapshot=balance.valid_from,
expires_at_snapshot=balance.expires_at,
unspent_before=before_unspent,
unspent_after=Decimal("0.00"),
consumed_before=before_consumed,
consumed_after=before_consumed,
)
)
after = before - total
record.amount = -total
record.balance_delta = -total
record.balance_after = after
db.add_all(allocation_rows)
attach_credit_snapshot(user, after)
await db.flush()
log_operation_event(
domain="billing",
module="dynamic_credit",
event_type="CREDIT_REVOKED",
event_status="success",
source="app.services.credit.ledger_service.revoke_balances",
user_id=user_id,
task_id=related_id,
message="积分批次撤销完成",
detail={
"record_id": record.id,
"amount": to_float(total),
"balance_count": len(allocation_rows),
"biz_key": biz_key,
},
)
return CreditMutationResult(user, record, True, to_float(total), to_float(before), to_float(after))