From 6492d23bc1a891bbcc1c87fae59d102f7d5b92be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EA=B2=BD?= Date: Tue, 28 Apr 2026 14:34:19 +0900 Subject: [PATCH] =?UTF-8?q?=ED=81=AC=EB=A0=88=EB=94=A7=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/lyric/api/routers/v1/lyric.py | 17 ++++++++++++++++- app/song/api/routers/v1/song.py | 16 ++++++++++++++++ app/user/api/routers/v1/auth.py | 17 +++++++++++++++++ app/user/models.py | 8 ++++++++ app/user/schemas/user_schema.py | 16 ++++++++++++++++ app/user/services/credit.py | 18 ++++++++++++++++++ app/video/worker/video_task.py | 7 +++++++ .../migration_2026_04_28_add_user_credits.sql | 5 +++++ 8 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 app/user/services/credit.py create mode 100644 docs/database-schema/migration_2026_04_28_add_user_credits.sql diff --git a/app/lyric/api/routers/v1/lyric.py b/app/lyric/api/routers/v1/lyric.py index b988e3f..8c61dec 100644 --- a/app/lyric/api/routers/v1/lyric.py +++ b/app/lyric/api/routers/v1/lyric.py @@ -239,7 +239,22 @@ async def generate_lyric( request_start = time.perf_counter() task_id = request_body.task_id - + + user = (await session.execute( + select(User).where(User.user_uuid == current_user.user_uuid) + )).scalar_one() + + if user.credits <= 0: + logger.info( + f"크레딧 부족, user_uuid: {current_user.user_uuid}, credits: {current_user.credits}" + ) + return GenerateLyricResponse( + success=False, + task_id=task_id, + lyric=None, + language=request_body.language, + error_message="No credits remaining.", + ) logger.info(f"[generate_lyric] ========== START ==========") logger.info( diff --git a/app/song/api/routers/v1/song.py b/app/song/api/routers/v1/song.py index 5b8330a..27daea3 100644 --- a/app/song/api/routers/v1/song.py +++ b/app/song/api/routers/v1/song.py @@ -103,6 +103,22 @@ async def generate_song( from app.database.session import AsyncSessionLocal request_start = time.perf_counter() + + async with AsyncSessionLocal() as session: + user = (await session.execute( + select(User).where(User.user_uuid == current_user.user_uuid) + )).scalar_one() + + if user.credits <= 0: + logger.info(f"크레딧 부족, user_uuid: {current_user.user_uuid}, credits: {user.credits}") + return GenerateSongResponse( + success=False, + task_id=task_id, + song_id=None, + message="No credits remaining.", + error_message="No credits remaining.", + ) + logger.info( f"[generate_song] START - task_id: {task_id}, " f"genre: {request_body.genre}, language: {request_body.language}" diff --git a/app/user/api/routers/v1/auth.py b/app/user/api/routers/v1/auth.py index 002f1b6..c4a65ce 100644 --- a/app/user/api/routers/v1/auth.py +++ b/app/user/api/routers/v1/auth.py @@ -23,6 +23,7 @@ logger = logging.getLogger(__name__) from app.user.dependencies import get_current_user from app.user.models import RefreshToken, User from app.user.schemas.user_schema import ( + CreditResponse, KakaoCodeRequest, KakaoLoginResponse, LoginResponse, @@ -353,6 +354,22 @@ async def get_me( return UserResponse.model_validate(current_user) +@router.get( + "/me/credits", + response_model=CreditResponse, + summary="잔여 크레딧 조회", + description="현재 로그인한 사용자의 잔여 영상 생성 크레딧을 반환합니다.", + responses={ + 200: {"description": "조회 성공"}, + 401: {"description": "인증 실패 (토큰 없음/만료)"}, + }, +) +async def get_my_credits( + current_user: User = Depends(get_current_user), +) -> CreditResponse: + return CreditResponse(credits=current_user.credits) + + # ============================================================================= # 테스트용 엔드포인트 (DEBUG 모드에서만 main.py에서 라우터가 등록됨) # ============================================================================= diff --git a/app/user/models.py b/app/user/models.py index fea0cc0..d3015aa 100644 --- a/app/user/models.py +++ b/app/user/models.py @@ -216,6 +216,14 @@ class User(Base): comment="마지막 로그인 일시", ) + credits: Mapped[int] = mapped_column( + Integer, + nullable=False, + default=3, + server_default="3", + comment="잔여 영상 생성 크레딧", + ) + created_at: Mapped[datetime] = mapped_column( DateTime, nullable=False, diff --git a/app/user/schemas/user_schema.py b/app/user/schemas/user_schema.py index d10fd13..1649db1 100644 --- a/app/user/schemas/user_schema.py +++ b/app/user/schemas/user_schema.py @@ -160,6 +160,22 @@ class LoginResponse(BaseModel): } } +# ============================================================================= +# 크레딧 스키마 +# ============================================================================= +class CreditResponse(BaseModel): + """잔여 크레딧 응답""" + + credits: int = Field(..., description="영상 생성 크레딧") + + model_config = { + "json_schema_extra": { + "example": { + "credits": 3 + } + } + } + # ============================================================================= # 내부 사용 스키마 (카카오 API 응답 파싱) diff --git a/app/user/services/credit.py b/app/user/services/credit.py new file mode 100644 index 0000000..866e0fd --- /dev/null +++ b/app/user/services/credit.py @@ -0,0 +1,18 @@ +from sqlalchemy import update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.user.models import User + + +async def consume_credit(user_uuid, session: AsyncSession) -> bool: + """atomic UPDATE로 1크레딧 차감. + + WHERE credits > 0 조건으로 음수 차감 방지 + PostgreSQL 행 락. + 차감 성공 여부 반환. + """ + result = await session.execute( + update(User) + .where(User.user_uuid == user_uuid, User.credits > 0) + .values(credits=User.credits - 1) + ) + return result.rowcount > 0 diff --git a/app/video/worker/video_task.py b/app/video/worker/video_task.py index ad7af90..7cfa0b1 100644 --- a/app/video/worker/video_task.py +++ b/app/video/worker/video_task.py @@ -13,6 +13,7 @@ from sqlalchemy import select from sqlalchemy.exc import SQLAlchemyError from app.database.session import BackgroundSessionLocal +from app.user.services.credit import consume_credit from app.video.models import Video from app.utils.upload_blob_as_request import AzureBlobUploader from app.utils.logger import get_logger @@ -154,6 +155,12 @@ async def download_and_upload_video_to_blob( # Video 테이블 업데이트 (creatomate_render_id로 특정 Video 식별) await _update_video_status(task_id, "completed", blob_url, creatomate_render_id) + + # 영상 생성 완료 시 크레딧 1 차감 (credits > 0 조건으로 음수 방지) + async with BackgroundSessionLocal() as session: + await consume_credit(user_uuid, session) + await session.commit() + logger.info(f"[download_and_upload_video_to_blob] SUCCESS - task_id: {task_id}, creatomate_render_id: {creatomate_render_id}") except httpx.HTTPError as e: diff --git a/docs/database-schema/migration_2026_04_28_add_user_credits.sql b/docs/database-schema/migration_2026_04_28_add_user_credits.sql new file mode 100644 index 0000000..18ec720 --- /dev/null +++ b/docs/database-schema/migration_2026_04_28_add_user_credits.sql @@ -0,0 +1,5 @@ +-- 2026-04-28: 사용자 크레딧 시스템 도입 +-- 실행 방법: psql -U -d -f 2026_04_28_add_user_credits.sql + +ALTER TABLE `user` + ADD COLUMN credits INT NOT NULL DEFAULT 3 COMMENT '영상 생성 크레딧';