Files
video-gen/video-gen-api/app/services/credit/ledger_service.py
T
2026-08-14 15:23:46 +08:00

869 lines
33 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 select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.credit_balance import (
CREDIT_LEVEL_SORT,
CREDIT_SCOPE_SORT,
CreditAllocationAction,
CreditBalanceSourceType,
CreditBalanceStatus,
CreditLevel,
CreditScope,
)
from app.enums.credit_record import CreditRecordBillingScene, CreditRecordType
from app.models.credit.allocation import CreditRecordAllocation
from app.models.credit.balance import UserCreditBalance
from app.models.credit.subscription import UserCreditSubscription
from app.models.credit.team_seat_usage import TeamSubscriptionSeatUsage
from app.models.credit_record import CreditRecord
from app.models.user import User
from app.services.credit.locking import (
acquire_subscription_credit_locks,
acquire_team_business_lock,
acquire_user_credit_lock,
)
from app.services.credit.query_service import attach_credit_snapshot, get_available_credits
from app.services.credit.team_subscription_service import (
TeamCreditCandidate,
get_or_create_usage_for_update,
lock_user_team_credit_candidates,
)
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 CreditRefundResult:
records: tuple[CreditRecord, ...]
created: bool
total_amount: Decimal
available_amount: Decimal
expired_amount: Decimal
balance_before: Decimal
balance_after: Decimal
@dataclass(slots=True)
class _SpendCandidate:
scope: str
balance: UserCreditBalance
available: Decimal
team: TeamCreditCandidate | None = None
async def _load_user(db: AsyncSession, user_id: str, *, for_update: bool = False) -> User:
stmt = select(User).where(User.id == user_id).limit(1)
if for_update:
stmt = stmt.with_for_update()
result = await db.execute(stmt)
user = result.scalar_one_or_none()
if user is None:
raise ValueError("用户不存在")
return user
async def _record_meta_kwargs(
db: AsyncSession,
user: User,
record_meta: CreditRecordMeta | dict | None,
) -> dict:
if record_meta is None:
meta = CreditRecordMeta()
elif isinstance(record_meta, CreditRecordMeta):
meta = record_meta
else:
meta = CreditRecordMeta(**{k: v for k, v in record_meta.items() if k in CreditRecordMeta.__dataclass_fields__})
await with_user_snapshot(db, meta, user.id, user=user)
return meta.to_record_kwargs()
def _normalize_expiry(
request_time: datetime,
valid_from: datetime | None,
expires_at: datetime | None,
) -> tuple[datetime, datetime]:
start = valid_from or request_time
end = expires_at or add_natural_months(start, 1)
if end <= start:
raise ValueError("积分有效期结束时间必须晚于开始时间")
return start, end
def _record(
*,
user_id: str,
record_type: str,
amount: Decimal,
balance_delta: Decimal,
balance_after: Decimal,
description: str,
related_id: str | None,
request_time: datetime,
biz_key: str | None,
refund_for_biz_key: str | None = None,
refund_kind: str | None = None,
credit_level_snapshot: str | None = None,
expired_amount: Decimal = Decimal("0.00"),
meta_kwargs: dict | None = None,
) -> CreditRecord:
return CreditRecord(
id=generate_id(),
user_id=user_id,
type=record_type,
amount=amount,
balance_delta=balance_delta,
expired_amount=expired_amount,
balance_after=balance_after,
description=description,
related_id=related_id,
request_time=request_time,
biz_key=biz_key,
refund_for_biz_key=refund_for_biz_key,
refund_kind=refund_kind,
credit_level_snapshot=credit_level_snapshot,
**(meta_kwargs or {}),
)
def _allocation_snapshot_kwargs(
*,
balance: UserCreditBalance,
scope: str | None = None,
team_id: str | None = None,
team_manager_id: str | None = None,
subscription_id: str | None = None,
subscription_period_id: str | None = None,
seat_id: str | None = None,
) -> dict:
return {
"credit_level_snapshot": balance.credit_level,
"credit_scope_snapshot": scope or balance.credit_scope or CreditScope.PERSONAL.value,
"source_type_snapshot": balance.source_type,
"source_id_snapshot": balance.source_id,
"team_id_snapshot": team_id if team_id is not None else balance.team_id,
"team_manager_id_snapshot": team_manager_id,
"subscription_id_snapshot": subscription_id if subscription_id is not None else balance.subscription_id,
"subscription_period_id_snapshot": (
subscription_period_id if subscription_period_id is not None else balance.subscription_period_id
),
"seat_id_snapshot": seat_id,
"valid_from_snapshot": balance.valid_from,
"expires_at_snapshot": balance.expires_at,
}
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,
credit_scope: str = CreditScope.PERSONAL.value,
team_id: str | None = None,
team_manager_id_snapshot: str | None = None,
) -> CreditMutationResult:
checked_at = request_time or utc_now()
value = to_credit_decimal(amount)
if value < 0:
raise ValueError("发放积分不能小于0")
if credit_scope not in (CreditScope.PERSONAL.value, CreditScope.TEAM.value):
raise ValueError("未知积分资金域")
if credit_scope == CreditScope.TEAM.value and (not team_id or not subscription_id or not subscription_period_id):
raise ValueError("团队积分必须绑定团队、订阅和订阅周期")
await acquire_user_credit_lock(db, user_id)
if team_id:
await acquire_team_business_lock(db, team_id)
if subscription_id:
await acquire_subscription_credit_locks(db, [subscription_id])
user = await _load_user(db, user_id, for_update=True)
before = await get_available_credits(db, user_id, request_time=checked_at)
existing = await _find_record_by_biz_key(db, user_id, biz_key)
if existing:
attach_credit_snapshot(user, before)
return CreditMutationResult(user, existing, False, to_float(existing.amount), to_float(before), to_float(before))
start, end = _normalize_expiry(checked_at, valid_from, expires_at)
level_rank = CREDIT_LEVEL_SORT.get(credit_level)
if level_rank is None:
raise ValueError("未知积分等级")
record_after = before + value if credit_scope == CreditScope.PERSONAL.value else before
meta_kwargs = await _record_meta_kwargs(db, user, record_meta)
record = _record(
user_id=user_id,
record_type=record_type,
amount=value,
balance_delta=value if credit_scope == CreditScope.PERSONAL.value else Decimal("0.00"),
balance_after=record_after,
description=description,
related_id=related_id,
request_time=checked_at,
biz_key=biz_key,
credit_level_snapshot=credit_level,
meta_kwargs=meta_kwargs,
)
db.add(record)
await db.flush()
balance = UserCreditBalance(
id=generate_id(),
user_id=user_id,
credit_scope=credit_scope,
team_id=team_id,
credit_level=credit_level,
credit_level_rank=level_rank,
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=value,
unspent_amount=value,
consumed_amount=Decimal("0.00"),
expired_amount=Decimal("0.00"),
revoked_amount=Decimal("0.00"),
valid_from=start,
expires_at=end,
status=CreditBalanceStatus.ACTIVE.value if start <= checked_at else CreditBalanceStatus.SCHEDULED.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=value,
request_time=checked_at,
**_allocation_snapshot_kwargs(
balance=balance,
scope=credit_scope,
team_id=team_id,
team_manager_id=team_manager_id_snapshot,
subscription_id=subscription_id,
subscription_period_id=subscription_period_id,
),
unspent_before=Decimal("0.00"),
unspent_after=value,
consumed_before=Decimal("0.00"),
consumed_after=Decimal("0.00"),
)
db.add(allocation)
after = await get_available_credits(db, user_id, request_time=checked_at)
record.balance_after = after
attach_credit_snapshot(user, after)
log_operation_event(
domain="credit",
module="ledger",
event_type="CREDIT_GRANT",
user_id=user_id,
message="积分发放成功",
detail={
"record_id": record.id,
"balance_id": balance.id,
"amount": to_float(value),
"credit_scope": credit_scope,
"team_id": team_id,
"subscription_id": subscription_id,
"subscription_period_id": subscription_period_id,
},
)
return CreditMutationResult(user, record, True, to_float(value), to_float(before), to_float(after))
async def _load_personal_spend_candidates(
db: AsyncSession,
user_id: str,
request_time: datetime,
) -> list[_SpendCandidate]:
result = await db.execute(
select(UserCreditBalance)
.where(
UserCreditBalance.user_id == user_id,
UserCreditBalance.credit_scope == CreditScope.PERSONAL.value,
UserCreditBalance.valid_from <= request_time,
UserCreditBalance.expires_at > request_time,
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()
)
return [
_SpendCandidate(CreditScope.PERSONAL.value, balance, to_credit_decimal(balance.unspent_amount))
for balance in result.scalars().all()
]
def _candidate_sort(candidate: _SpendCandidate) -> tuple:
balance = candidate.balance
return (
int(balance.credit_level_rank or 999),
CREDIT_SCOPE_SORT.get(candidate.scope, 999),
balance.expires_at,
balance.valid_from,
balance.id,
)
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,
allowed_scopes: set[str] | None = None,
) -> CreditMutationResult:
checked_at = request_time or utc_now()
value = to_credit_decimal(amount)
if value < 0:
raise ValueError("扣减积分不能小于0")
if value == 0 and not create_zero_record:
user = await _load_user(db, user_id)
available = await get_available_credits(db, user_id, request_time=checked_at)
attach_credit_snapshot(user, available)
return CreditMutationResult(user, None, False, 0.0, to_float(available), to_float(available))
await acquire_user_credit_lock(db, user_id)
scope_filter = allowed_scopes or {CreditScope.PERSONAL.value, CreditScope.TEAM.value}
invalid_scopes = scope_filter - {CreditScope.PERSONAL.value, CreditScope.TEAM.value}
if invalid_scopes:
raise ValueError(f"不支持的积分资金域: {','.join(sorted(invalid_scopes))}")
# 固定锁顺序:user advisory -> team advisory -> user/balance/seat row locks。
if CreditScope.TEAM.value in scope_filter:
current_team_id = (await db.execute(select(User.team_id).where(User.id == user_id).limit(1))).scalar_one_or_none()
if current_team_id:
await acquire_team_business_lock(db, current_team_id)
user = await _load_user(db, user_id, for_update=True)
before = await get_available_credits(db, user_id, request_time=checked_at)
existing = await _find_record_by_biz_key(db, user_id, biz_key)
if existing:
attach_credit_snapshot(user, before)
return CreditMutationResult(user, existing, False, to_float(abs(existing.amount)), to_float(before), to_float(before))
team_candidates = (
await lock_user_team_credit_candidates(db, user_id=user_id, request_time=checked_at)
if CreditScope.TEAM.value in scope_filter
else []
)
personal_candidates = (
await _load_personal_spend_candidates(db, user_id, checked_at)
if CreditScope.PERSONAL.value in scope_filter
else []
)
candidates = personal_candidates + [
_SpendCandidate(CreditScope.TEAM.value, item.balance, item.available, item)
for item in team_candidates
if item.available > 0
]
candidates.sort(key=_candidate_sort)
if sum((c.available for c in candidates), Decimal("0.00")) < value:
raise InsufficientCreditsError()
meta_kwargs = await _record_meta_kwargs(db, user, record_meta)
after_expected = before - value
record = _record(
user_id=user_id,
record_type=record_type,
amount=-value,
balance_delta=-value,
balance_after=after_expected,
description=description,
related_id=related_id,
request_time=checked_at,
biz_key=biz_key,
refund_for_biz_key=refund_for_biz_key,
meta_kwargs=meta_kwargs,
)
db.add(record)
await db.flush()
remaining = value
team_amount = Decimal("0.00")
personal_amount = Decimal("0.00")
for candidate in candidates:
if remaining <= 0:
break
take = min(candidate.available, remaining)
if take <= 0:
continue
balance = candidate.balance
unspent_before = to_credit_decimal(balance.unspent_amount)
consumed_before = to_credit_decimal(balance.consumed_amount)
if unspent_before < take:
raise RuntimeError("积分并发结算异常:余额不足")
balance.unspent_amount = unspent_before - take
balance.consumed_amount = consumed_before + take
if balance.unspent_amount <= 0:
balance.status = CreditBalanceStatus.CONSUMED.value
team_id = None
manager_id = None
subscription_id = balance.subscription_id
period_id = balance.subscription_period_id
seat_id = None
if candidate.scope == CreditScope.TEAM.value:
if not candidate.team:
raise RuntimeError("团队积分候选缺少 Seat 上下文")
team_ctx = candidate.team
usage = team_ctx.usage
if usage is None:
usage = await get_or_create_usage_for_update(
db,
seat=team_ctx.seat,
period=team_ctx.period,
)
team_ctx.usage = usage
usage_before = to_credit_decimal(usage.used_credits)
if usage_before + take > to_credit_decimal(team_ctx.seat.monthly_allocated_credits):
raise RuntimeError("团队席位额度发生并发变化,请重试")
usage.used_credits = usage_before + take
team_id = team_ctx.subscription.team_id
manager_id = team_ctx.subscription.team_manager_id_snapshot
subscription_id = team_ctx.subscription.id
period_id = team_ctx.period.id
seat_id = team_ctx.seat.id
team_amount += take
else:
personal_amount += take
db.add(
CreditRecordAllocation(
id=generate_id(),
credit_record_id=record.id,
credit_balance_id=balance.id,
user_id=user_id,
allocation_action=CreditAllocationAction.CONSUME.value,
amount=take,
request_time=checked_at,
**_allocation_snapshot_kwargs(
balance=balance,
scope=candidate.scope,
team_id=team_id,
team_manager_id=manager_id,
subscription_id=subscription_id,
subscription_period_id=period_id,
seat_id=seat_id,
),
unspent_before=unspent_before,
unspent_after=to_credit_decimal(balance.unspent_amount),
consumed_before=consumed_before,
consumed_after=to_credit_decimal(balance.consumed_amount),
)
)
remaining -= take
if remaining > 0:
raise RuntimeError("积分结算候选不足,事务将回滚")
await db.flush()
after = await get_available_credits(db, user_id, request_time=checked_at)
record.balance_after = after
attach_credit_snapshot(user, after)
log_operation_event(
domain="credit",
module="ledger",
event_type="CREDIT_CONSUME",
user_id=user_id,
message="积分消费成功",
detail={
"credit_record_id": record.id,
"amount": to_float(value),
"team_amount": to_float(team_amount),
"personal_amount": to_float(personal_amount),
"biz_key": biz_key,
},
)
return CreditMutationResult(user, record, True, to_float(value), to_float(before), to_float(after))
async def _load_original_consume_allocations(
db: AsyncSession,
*,
user_id: str,
refund_for_biz_key: str,
) -> tuple[CreditRecord, list[CreditRecordAllocation]]:
result = await db.execute(
select(CreditRecord)
.where(
CreditRecord.user_id == user_id,
CreditRecord.biz_key == refund_for_biz_key,
CreditRecord.type == CreditRecordType.CONSUME.value,
)
.limit(1)
)
original_record = result.scalar_one_or_none()
if not original_record:
raise ValueError("未找到可退款的原消费记录")
allocations_result = await db.execute(
select(CreditRecordAllocation)
.where(
CreditRecordAllocation.credit_record_id == original_record.id,
CreditRecordAllocation.allocation_action == CreditAllocationAction.CONSUME.value,
CreditRecordAllocation.user_id == user_id,
)
.order_by(CreditRecordAllocation.id.asc())
)
allocations = list(allocations_result.scalars().all())
if not allocations:
raise ValueError("原消费记录缺少资金分配明细")
return original_record, allocations
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()
refund_biz_key = biz_key or f"refund:{refund_for_biz_key}"
await acquire_user_credit_lock(db, user_id)
# 先在 user advisory lock 保护下定位原资金链,再按固定顺序获取 team/subscription 锁,
# 最后才锁 User/Balance/SeatUsage 行,避免与席位/团队操作形成反向锁序。
_, originals = await _load_original_consume_allocations(
db, user_id=user_id, refund_for_biz_key=refund_for_biz_key
)
team_ids = sorted(
{
a.team_id_snapshot
for a in originals
if a.credit_scope_snapshot == CreditScope.TEAM.value and a.team_id_snapshot
}
)
for team_id in team_ids:
await acquire_team_business_lock(db, team_id)
await acquire_subscription_credit_locks(
db,
[a.subscription_id_snapshot for a in originals if a.subscription_id_snapshot],
)
user = await _load_user(db, user_id, for_update=True)
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.type == CreditRecordType.REFUND.value,
CreditRecord.refund_for_biz_key == refund_for_biz_key,
CreditRecord.biz_key.in_([
f"{refund_biz_key}:available",
f"{refund_biz_key}:expired",
]),
)
.order_by(CreditRecord.id.asc())
)
existing = list(existing_result.scalars().all())
if existing:
available = sum((to_credit_decimal(r.amount) for r in existing if r.refund_kind == "available"), Decimal("0.00"))
expired = sum((to_credit_decimal(r.expired_amount or r.amount) for r in existing if r.refund_kind == "expired"), Decimal("0.00"))
attach_credit_snapshot(user, before)
return CreditRefundResult(tuple(existing), False, available + expired, available, expired, before, before)
balance_ids = list(dict.fromkeys(a.credit_balance_id for a in originals))
balances_result = await db.execute(
select(UserCreditBalance)
.where(UserCreditBalance.id.in_(balance_ids))
.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_rows: list[tuple] = []
expired_rows: list[tuple] = []
for original in originals:
balance = balance_map.get(original.credit_balance_id)
if balance is None:
raise RuntimeError(f"原积分来源不存在: {original.credit_balance_id}")
if original.credit_scope_snapshot == CreditScope.PERSONAL.value and balance.user_id != user_id:
raise RuntimeError("个人积分退款资金所有人与消费者不一致")
refund_amount = to_credit_decimal(original.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 original.credit_scope_snapshot == CreditScope.TEAM.value and original.seat_id_snapshot:
usage_result = await db.execute(
select(TeamSubscriptionSeatUsage)
.where(
TeamSubscriptionSeatUsage.seat_id == original.seat_id_snapshot,
TeamSubscriptionSeatUsage.subscription_period_id == original.subscription_period_id_snapshot,
TeamSubscriptionSeatUsage.user_id == user_id,
)
.limit(1)
.with_for_update()
)
usage = usage_result.scalar_one_or_none()
if usage is None:
raise RuntimeError("原团队积分消费缺少席位月度使用记录")
used_before = to_credit_decimal(usage.used_credits)
if used_before < refund_amount:
raise RuntimeError("团队席位历史已用积分不足以回冲")
usage.used_credits = used_before - refund_amount
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_rows.append(
(original, balance, refund_amount, before_unspent, after_unspent, before_consumed, after_consumed)
)
else:
balance.expired_amount = to_credit_decimal(balance.expired_amount) + refund_amount
after_unspent = before_unspent
if to_credit_decimal(balance.unspent_amount) <= 0 and to_credit_decimal(balance.consumed_amount) <= 0:
balance.status = CreditBalanceStatus.EXPIRED.value
expired_total += refund_amount
expired_rows.append(
(original, 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] = []
async def create_refund_record(kind: str, amount: Decimal, expired_amount: Decimal) -> CreditRecord:
current_after = await get_available_credits(db, user_id, request_time=checked_at)
record = _record(
user_id=user_id,
record_type=CreditRecordType.REFUND.value,
amount=amount,
balance_delta=amount if kind == "available" else Decimal("0.00"),
balance_after=current_after,
expired_amount=expired_amount,
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"{refund_biz_key}:{kind}",
refund_for_biz_key=refund_for_biz_key,
refund_kind=kind,
meta_kwargs=meta_kwargs,
)
db.add(record)
await db.flush()
return record
async def add_refund_allocations(record: CreditRecord, rows: list[tuple], action: str) -> None:
for original, balance, amount, before_unspent, after_unspent, before_consumed, after_consumed in rows:
db.add(
CreditRecordAllocation(
id=generate_id(),
credit_record_id=record.id,
credit_balance_id=balance.id,
user_id=user_id,
source_allocation_id=original.id,
allocation_action=action,
amount=amount,
request_time=checked_at,
credit_level_snapshot=original.credit_level_snapshot,
credit_scope_snapshot=original.credit_scope_snapshot,
source_type_snapshot=original.source_type_snapshot,
source_id_snapshot=original.source_id_snapshot,
team_id_snapshot=original.team_id_snapshot,
team_manager_id_snapshot=original.team_manager_id_snapshot,
subscription_id_snapshot=original.subscription_id_snapshot,
subscription_period_id_snapshot=original.subscription_period_id_snapshot,
seat_id_snapshot=original.seat_id_snapshot,
valid_from_snapshot=original.valid_from_snapshot,
expires_at_snapshot=original.expires_at_snapshot,
unspent_before=before_unspent,
unspent_after=after_unspent,
consumed_before=before_consumed,
consumed_after=after_consumed,
)
)
if available_total > 0:
record = await create_refund_record("available", available_total, Decimal("0.00"))
created_records.append(record)
await add_refund_allocations(record, available_rows, CreditAllocationAction.REFUND_AVAILABLE.value)
if expired_total > 0:
record = await create_refund_record("expired", expired_total, expired_total)
created_records.append(record)
await add_refund_allocations(record, expired_rows, CreditAllocationAction.REFUND_EXPIRED.value)
await db.flush()
after = await get_available_credits(db, user_id, request_time=checked_at)
for record in created_records:
record.balance_after = after
attach_credit_snapshot(user, after)
log_operation_event(
domain="credit",
module="ledger",
event_type="CREDIT_SOURCE_REFUNDED",
user_id=user_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(originals),
"created_record_ids": [item.id for item in created_records],
},
)
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=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
if any(item.user_id != user_id for item in items):
raise ValueError("一次撤销只能处理同一资金所有人的积分")
await acquire_user_credit_lock(db, user_id)
user = await _load_user(db, user_id, for_update=True)
before = await get_available_credits(db, user_id, request_time=checked_at)
existing = await _find_record_by_biz_key(db, user_id, 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))
record = _record(
user_id=user_id,
record_type=CreditRecordType.REVOKE.value,
amount=Decimal("0.00"),
balance_delta=Decimal("0.00"),
balance_after=before,
description=description,
related_id=related_id,
request_time=checked_at,
biz_key=biz_key,
meta_kwargs={"billing_scene": CreditRecordBillingScene.CREDIT_REVOKE.value},
)
db.add(record)
await db.flush()
total = Decimal("0.00")
for balance in items:
if balance.revoked_at is not None:
continue
unspent_before = to_credit_decimal(balance.unspent_amount)
consumed_before = to_credit_decimal(balance.consumed_amount)
revoke_amount = unspent_before
if revoke_amount <= 0:
continue
balance.unspent_amount = Decimal("0.00")
balance.revoked_amount = to_credit_decimal(balance.revoked_amount) + revoke_amount
balance.revoked_at = checked_at
balance.status = CreditBalanceStatus.REVOKED.value
total += revoke_amount
db.add(
CreditRecordAllocation(
id=generate_id(),
credit_record_id=record.id,
credit_balance_id=balance.id,
user_id=user_id,
allocation_action=CreditAllocationAction.REVOKE.value,
amount=revoke_amount,
request_time=checked_at,
**_allocation_snapshot_kwargs(balance=balance),
unspent_before=unspent_before,
unspent_after=Decimal("0.00"),
consumed_before=consumed_before,
consumed_after=consumed_before,
)
)
record.amount = -total
record.balance_delta = -total
await db.flush()
after = await get_available_credits(db, user_id, request_time=checked_at)
record.balance_after = after
attach_credit_snapshot(user, after)
return CreditMutationResult(user, record, True, to_float(total), to_float(before), to_float(after))