o2o-castad-backend/app/credit/services/credit_service.py

383 lines
12 KiB
Python

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