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))