383 lines
12 KiB
Python
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
|