import logging from datetime import datetime from typing import Optional from config import TIMEZONE from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession from app.credit.exceptions import ( ChargeRequestNotFoundError, InsufficientCreditError, InvalidRequestStateError, ) from app.credit.models import ( ChargeRequestStatus, CreditChargeRequest, CreditTransaction, CreditTransactionType, ) logger = logging.getLogger(__name__) async def record_transaction( *, session: AsyncSession, user_uuid: str, amount: int, balance_after: int, type: CreditTransactionType, reason: Optional[str] = None, admin_id: Optional[int] = None, related_request_id: Optional[int] = None, job_type: Optional[str] = None, job_ref: Optional[str] = None, ) -> CreditTransaction: tx = CreditTransaction( user_uuid=user_uuid, amount=amount, balance_after=balance_after, type=type, reason=reason, admin_id=admin_id, related_request_id=related_request_id, job_type=job_type, job_ref=job_ref, ) session.add(tx) await session.flush() return tx async def charge_credit( *, session: AsyncSession, user_uuid: str, amount: int, type: CreditTransactionType = CreditTransactionType.CHARGE, reason: Optional[str] = None, admin_id: Optional[int] = None, related_request_id: Optional[int] = None, ) -> CreditTransaction: from app.user.models import User result = await session.execute( select(User).where(User.user_uuid == user_uuid).with_for_update() ) user = result.scalar_one_or_none() if user is None: from app.user.services.auth import UserNotFoundError raise UserNotFoundError() user.credits = user.credits + amount await session.flush() tx = await record_transaction( session=session, user_uuid=user_uuid, amount=amount, balance_after=user.credits, type=type, reason=reason, admin_id=admin_id, related_request_id=related_request_id, ) logger.info(f"[CREDIT] charge user_uuid={user_uuid} amount=+{amount} balance_after={user.credits}") return tx async def deduct_credit( *, session: AsyncSession, user_uuid: str, amount: int, type: CreditTransactionType = CreditTransactionType.CONSUME, reason: Optional[str] = None, admin_id: Optional[int] = None, ) -> CreditTransaction: from app.user.models import User result = await session.execute( select(User).where(User.user_uuid == user_uuid).with_for_update() ) user = result.scalar_one_or_none() if user is None: from app.user.services.auth import UserNotFoundError raise UserNotFoundError() if user.credits < amount: logger.warning(f"[CREDIT] insufficient credits user_uuid={user_uuid} credits={user.credits} requested={amount}") raise InsufficientCreditError() user.credits = user.credits - amount await session.flush() tx = await record_transaction( session=session, user_uuid=user_uuid, amount=-amount, balance_after=user.credits, type=type, reason=reason, admin_id=admin_id, ) logger.info(f"[CREDIT] deduct user_uuid={user_uuid} amount=-{amount} balance_after={user.credits}") return tx # ============================================================================= # 작업 기반 차감/환불 (사전차감 정책) # ============================================================================= # 위의 charge_credit / deduct_credit 은 멱등성이 없어 재시도 시 중복 반영된다. # 생성 작업처럼 "시작할 때 차감하고 실패하면 환불"하는 경로에서는 아래 두 함수를 쓴다. # # 멱등 보장 방식은 2중이다: # 1) 먼저 (job_type, job_ref, type) 로 기존 행을 조회해 있으면 그대로 반환 # 2) 경합으로 1)을 통과한 두 요청이 동시에 INSERT 하면 DB 유니크 제약이 막는다 # 잔액 자체는 User 행을 with_for_update() 로 잠근 뒤 읽기→갱신하므로 경합에 안전하다. # # 두 함수 모두 **자체 commit 하지 않는다.** 호출부가 트랜잭션을 소유하고, # 작업 행 생성과 차감을 한 트랜잭션으로 묶어 마지막에 한 번만 커밋해야 한다. async def _find_job_transaction( session: AsyncSession, job_type: str, job_ref: str, type: CreditTransactionType, ) -> Optional[CreditTransaction]: """(job_type, job_ref, type) 에 해당하는 기존 원장 행 조회""" result = await session.execute( select(CreditTransaction).where( CreditTransaction.job_type == job_type, CreditTransaction.job_ref == job_ref, CreditTransaction.type == type, ) ) return result.scalar_one_or_none() async def deduct_credit_for_job( *, session: AsyncSession, user_uuid: str, amount: int, job_type: str, job_ref: str, reason: Optional[str] = None, ) -> CreditTransaction: """작업 시작 시점에 크레딧을 선차감한다. (job_type, job_ref) 기준 멱등. Args: session: 호출부가 소유하는 세션. 이 함수는 commit 하지 않는다. user_uuid: 사용자 UUID amount: 차감할 크레딧 (양수) job_type: 작업 종류 ("video" | "ssul") job_ref: 작업 식별자 (video.task_id 또는 str(ssul_task.id)) reason: 원장에 남길 사유 Returns: 새로 만든 차감 원장 행. 이미 차감된 작업이면 기존 행을 그대로 반환한다. Raises: InsufficientCreditError: 잔액 부족 (호출부에서 402 로 변환할 것) UserNotFoundError: 사용자 없음 """ from app.user.models import User existing = await _find_job_transaction( session, job_type, job_ref, CreditTransactionType.CONSUME ) if existing is not None: logger.info( f"[CREDIT] deduct skipped (already charged) " f"job={job_type}:{job_ref} tx_id={existing.id}" ) return existing result = await session.execute( select(User).where(User.user_uuid == user_uuid).with_for_update() ) user = result.scalar_one_or_none() if user is None: from app.user.services.auth import UserNotFoundError raise UserNotFoundError() if user.credits < amount: logger.warning( f"[CREDIT] insufficient credits user_uuid={user_uuid} " f"credits={user.credits} requested={amount} job={job_type}:{job_ref}" ) raise InsufficientCreditError() user.credits = user.credits - amount await session.flush() tx = await record_transaction( session=session, user_uuid=user_uuid, amount=-amount, balance_after=user.credits, type=CreditTransactionType.CONSUME, reason=reason, job_type=job_type, job_ref=job_ref, ) logger.info( f"[CREDIT] deduct user_uuid={user_uuid} amount=-{amount} " f"balance_after={user.credits} job={job_type}:{job_ref}" ) return tx async def refund_credit_for_job( *, session: AsyncSession, user_uuid: str, amount: int, job_type: str, job_ref: str, reason: Optional[str] = None, ) -> Optional[CreditTransaction]: """작업 실패 시 선차감한 크레딧을 환불한다. (job_type, job_ref) 기준 멱등. 차감 기록이 없으면 환불하지 않는다 — 애초에 차감되지 않은 작업(예: 정책 전환 이전에 시작된 in-flight 작업)에 환불을 얹으면 크레딧이 늘어나기 때문이다. Args: session: 호출부가 소유하는 세션. 이 함수는 commit 하지 않는다. user_uuid: 사용자 UUID amount: 환불할 크레딧 (양수) job_type: 작업 종류 ("video" | "ssul") job_ref: 작업 식별자 reason: 원장에 남길 사유 Returns: 새로 만든 환불 원장 행. 이미 환불했거나 차감 기록이 없으면 None. """ from app.user.models import User already = await _find_job_transaction( session, job_type, job_ref, CreditTransactionType.REFUND ) if already is not None: logger.info( f"[CREDIT] refund skipped (already refunded) " f"job={job_type}:{job_ref} tx_id={already.id}" ) return None consumed = await _find_job_transaction( session, job_type, job_ref, CreditTransactionType.CONSUME ) if consumed is None: logger.info( f"[CREDIT] refund skipped (never charged) job={job_type}:{job_ref}" ) return None result = await session.execute( select(User).where(User.user_uuid == user_uuid).with_for_update() ) user = result.scalar_one_or_none() if user is None: logger.warning( f"[CREDIT] refund skipped (user not found) " f"user_uuid={user_uuid} job={job_type}:{job_ref}" ) return None user.credits = user.credits + amount await session.flush() tx = await record_transaction( session=session, user_uuid=user_uuid, amount=amount, balance_after=user.credits, type=CreditTransactionType.REFUND, reason=reason, job_type=job_type, job_ref=job_ref, ) logger.info( f"[CREDIT] refund user_uuid={user_uuid} amount=+{amount} " f"balance_after={user.credits} job={job_type}:{job_ref}" ) return tx async def approve_charge_request( *, session: AsyncSession, request_id: int, admin_id: int, admin_note: Optional[str] = None, ) -> CreditChargeRequest: result = await session.execute( select(CreditChargeRequest) .where(CreditChargeRequest.id == request_id) .with_for_update() ) charge_request = result.scalar_one_or_none() if charge_request is None: raise ChargeRequestNotFoundError() if charge_request.status != ChargeRequestStatus.PENDING: logger.warning(f"[CREDIT] approve blocked request_id={request_id} status={charge_request.status}") raise InvalidRequestStateError() await charge_credit( session=session, user_uuid=charge_request.user_uuid, amount=charge_request.requested_amount, type=CreditTransactionType.CHARGE, reason="충전 요청 승인", admin_id=admin_id, related_request_id=request_id, ) charge_request.status = ChargeRequestStatus.APPROVED charge_request.admin_id = admin_id charge_request.admin_note = admin_note charge_request.processed_at = datetime.now(TIMEZONE) await session.flush() logger.info(f"[CREDIT] approved request_id={request_id} admin_id={admin_id} amount={charge_request.requested_amount}") return charge_request async def reject_charge_request( *, session: AsyncSession, request_id: int, admin_id: int, admin_note: Optional[str] = None, ) -> CreditChargeRequest: result = await session.execute( select(CreditChargeRequest) .where(CreditChargeRequest.id == request_id) .with_for_update() ) charge_request = result.scalar_one_or_none() if charge_request is None: raise ChargeRequestNotFoundError() if charge_request.status != ChargeRequestStatus.PENDING: logger.warning(f"[CREDIT] reject blocked request_id={request_id} status={charge_request.status}") raise InvalidRequestStateError() charge_request.status = ChargeRequestStatus.REJECTED charge_request.admin_id = admin_id charge_request.admin_note = admin_note charge_request.processed_at = datetime.now(TIMEZONE) await session.flush() logger.info(f"[CREDIT] rejected request_id={request_id} admin_id={admin_id}") return charge_request