From 03a80afc71b6469720bdf82969a7139e87e7a0a1 Mon Sep 17 00:00:00 2001 From: bluebamus Date: Fri, 26 Dec 2025 10:03:17 +0900 Subject: [PATCH] =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=EB=84=A4=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=EC=9C=A0=ED=8B=B8=EB=A1=9C=20=EC=9D=B4=EB=8F=99,?= =?UTF-8?q?=20=EA=B8=80=EB=A1=9C=EB=B2=8C=20depends=20=EC=A0=95=EC=9D=98,?= =?UTF-8?q?=20lyrics=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=B6=9C?= =?UTF-8?q?=EB=A0=A5=20=EC=99=84=EB=A3=8C=20=EB=AA=A8=EB=93=A0=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=ED=8C=A8=EC=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/dependencies/pagination.py | 121 +++++++++++++++++++++++++++++++ app/song/api/routers/v1/song.py | 122 +++++++++++++++++++++++++++++++- app/song/schemas/song_schema.py | 25 +++++++ 3 files changed, 267 insertions(+), 1 deletion(-) diff --git a/app/dependencies/pagination.py b/app/dependencies/pagination.py index e69de29..17a3e21 100644 --- a/app/dependencies/pagination.py +++ b/app/dependencies/pagination.py @@ -0,0 +1,121 @@ +""" +Pagination Dependencies + +페이지네이션 관련 의존성 주입을 정의합니다. + +사용 예시: + from app.dependencies.pagination import PaginationParams, get_pagination_params, paginated_query + + # 방법 1: 기본 파라미터만 사용 + @router.get("/items", response_model=PaginatedResponse[ItemModel]) + async def get_items( + pagination: PaginationParams = Depends(get_pagination_params), + session: AsyncSession = Depends(get_session), + ): + ... + + # 방법 2: paginated_query 사용 (get_paginated 래핑) + @router.get("/items", response_model=PaginatedResponse[ItemModel]) + async def get_items( + session: AsyncSession = Depends(get_session), + pagination: PaginationParams = Depends(get_pagination_params), + ): + return await paginated_query( + session=session, + model=Item, + item_schema=ItemModel, + pagination=pagination, + filters={"status": "completed"}, + ) +""" + +from dataclasses import dataclass +from typing import Annotated, Any, Callable, Dict, Optional, Type, TypeVar + +from fastapi import Query +from sqlalchemy.ext.asyncio import AsyncSession + +from app.utils.pagination import PaginatedResponse, get_paginated + +T = TypeVar("T") +ModelT = TypeVar("ModelT") + + +@dataclass +class PaginationParams: + """페이지네이션 파라미터를 담는 데이터 클래스""" + + page: int + page_size: int + + +def get_pagination_params( + page: Annotated[int, Query(ge=1, description="페이지 번호 (1부터 시작)")] = 1, + page_size: Annotated[ + int, Query(ge=1, le=100, description="페이지당 데이터 수 (최대 100)") + ] = 10, +) -> PaginationParams: + """페이지네이션 파라미터를 주입하는 의존성 함수 + + Args: + page: 페이지 번호 (1부터 시작, 기본값: 1) + page_size: 페이지당 데이터 수 (기본값: 10, 최대: 100) + + Returns: + PaginationParams: 페이지네이션 파라미터 객체 + """ + return PaginationParams(page=page, page_size=page_size) + + +async def paginated_query( + session: AsyncSession, + model: Type[ModelT], + item_schema: Type[T], + pagination: PaginationParams, + filters: Optional[Dict[str, Any]] = None, + order_by: Optional[str] = "created_at", + order_desc: bool = True, + transform_fn: Optional[Callable[[ModelT], T]] = None, +) -> PaginatedResponse[T]: + """페이지네이션 쿼리를 실행하는 헬퍼 함수 + + PaginationParams를 받아서 get_paginated를 호출합니다. + + Args: + session: SQLAlchemy AsyncSession + model: SQLAlchemy 모델 클래스 (예: Song, Lyric, Video) + item_schema: Pydantic 스키마 클래스 (예: SongListItem) + pagination: 페이지네이션 파라미터 (get_pagination_params에서 주입) + filters: 필터 조건 딕셔너리 (예: {"status": "completed"}) + order_by: 정렬 기준 컬럼명 (기본값: "created_at") + order_desc: 내림차순 정렬 여부 (기본값: True) + transform_fn: 모델을 스키마로 변환하는 함수 (None이면 자동 변환) + + Returns: + PaginatedResponse[T]: 페이지네이션된 응답 + + Usage: + @router.get("/songs", response_model=PaginatedResponse[SongListItem]) + async def get_songs( + session: AsyncSession = Depends(get_session), + pagination: PaginationParams = Depends(get_pagination_params), + ): + return await paginated_query( + session=session, + model=Song, + item_schema=SongListItem, + pagination=pagination, + filters={"status": "completed"}, + ) + """ + return await get_paginated( + session=session, + model=model, + item_schema=item_schema, + page=pagination.page, + page_size=pagination.page_size, + filters=filters, + order_by=order_by, + order_desc=order_desc, + transform_fn=transform_fn, + ) diff --git a/app/song/api/routers/v1/song.py b/app/song/api/routers/v1/song.py index 9ebe613..9cca649 100644 --- a/app/song/api/routers/v1/song.py +++ b/app/song/api/routers/v1/song.py @@ -14,10 +14,14 @@ Song API Router """ from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.database.session import get_session +from app.dependencies.pagination import ( + PaginationParams, + get_pagination_params, +) from app.home.models import Project from app.lyric.models import Lyric from app.song.models import Song @@ -26,8 +30,10 @@ from app.song.schemas.song_schema import ( GenerateSongRequest, GenerateSongResponse, PollingSongResponse, + SongListItem, ) from app.song.worker.song_task import download_and_save_song +from app.utils.pagination import PaginatedResponse from app.utils.suno import SunoService @@ -392,3 +398,117 @@ async def download_song( message="노래 다운로드 조회에 실패했습니다.", error_message=str(e), ) + + +@router.get( + "s/", + summary="생성된 노래 목록 조회", + description=""" +완료된 노래 목록을 페이지네이션하여 조회합니다. + +## 쿼리 파라미터 +- **page**: 페이지 번호 (1부터 시작, 기본값: 1) +- **page_size**: 페이지당 데이터 수 (기본값: 10, 최대: 100) + +## 반환 정보 +- **items**: 노래 목록 (store_name, region, task_id, language, song_result_url, created_at) +- **total**: 전체 데이터 수 +- **page**: 현재 페이지 +- **page_size**: 페이지당 데이터 수 +- **total_pages**: 전체 페이지 수 +- **has_next**: 다음 페이지 존재 여부 +- **has_prev**: 이전 페이지 존재 여부 + +## 사용 예시 +``` +GET /songs/?page=1&page_size=10 +``` + +## 참고 +- status가 'completed'인 노래만 반환됩니다. +- created_at 기준 내림차순 정렬됩니다. + """, + response_model=PaginatedResponse[SongListItem], + responses={ + 200: {"description": "노래 목록 조회 성공"}, + 500: {"description": "조회 실패"}, + }, +) +async def get_songs( + session: AsyncSession = Depends(get_session), + pagination: PaginationParams = Depends(get_pagination_params), +) -> PaginatedResponse[SongListItem]: + """완료된 노래 목록을 페이지네이션하여 반환합니다.""" + print(f"[get_songs] START - page: {pagination.page}, page_size: {pagination.page_size}") + try: + offset = (pagination.page - 1) * pagination.page_size + + # 서브쿼리: task_id별 최신 Song의 id 조회 (completed 상태만) + subquery = ( + select(func.max(Song.id).label("max_id")) + .where(Song.status == "completed") + .group_by(Song.task_id) + .subquery() + ) + + # 전체 개수 조회 (task_id별 최신 1개만) + count_query = select(func.count()).select_from(subquery) + total_result = await session.execute(count_query) + total = total_result.scalar() or 0 + + # 데이터 조회 (completed 상태, task_id별 최신 1개만, 최신순) + query = ( + select(Song) + .where(Song.id.in_(select(subquery.c.max_id))) + .order_by(Song.created_at.desc()) + .offset(offset) + .limit(pagination.page_size) + ) + result = await session.execute(query) + songs = result.scalars().all() + + # Project 정보와 함께 SongListItem으로 변환 + items = [] + for song in songs: + # Project 조회 (song.project_id 직접 사용) + project_result = await session.execute( + select(Project).where(Project.id == song.project_id) + ) + project = project_result.scalar_one_or_none() + + item = SongListItem( + store_name=project.store_name if project else None, + region=project.region if project else None, + task_id=song.task_id, + language=song.language, + song_result_url=song.song_result_url, + created_at=song.created_at, + ) + items.append(item) + + # 개별 아이템 로그 + print( + f"[get_songs] Item - store_name: {item.store_name}, region: {item.region}, " + f"task_id: {item.task_id}, language: {item.language}, " + f"song_result_url: {item.song_result_url}, created_at: {item.created_at}" + ) + + response = PaginatedResponse.create( + items=items, + total=total, + page=pagination.page, + page_size=pagination.page_size, + ) + + print( + f"[get_songs] SUCCESS - total: {total}, page: {pagination.page}, " + f"page_size: {pagination.page_size}, items_count: {len(items)}" + ) + return response + + except Exception as e: + print(f"[get_songs] EXCEPTION - error: {e}") + raise HTTPException( + status_code=500, + detail=f"노래 목록 조회에 실패했습니다: {str(e)}", + ) diff --git a/app/song/schemas/song_schema.py b/app/song/schemas/song_schema.py index 34dc0dc..ecec7a2 100644 --- a/app/song/schemas/song_schema.py +++ b/app/song/schemas/song_schema.py @@ -184,6 +184,31 @@ class PollingSongResponse(BaseModel): error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)") +class SongListItem(BaseModel): + """노래 목록 아이템 스키마 + + Usage: + GET /songs 응답의 개별 노래 정보 + + Example: + { + "store_name": "스테이 머뭄", + "region": "군산", + "task_id": "019123ab-cdef-7890-abcd-ef1234567890", + "language": "Korean", + "song_result_url": "http://localhost:8000/media/2025-01-15/스테이머뭄.mp3", + "created_at": "2025-01-15T12:00:00" + } + """ + + store_name: Optional[str] = Field(None, description="업체명") + region: Optional[str] = Field(None, description="지역명") + task_id: str = Field(..., description="작업 고유 식별자") + language: Optional[str] = Field(None, description="언어") + song_result_url: Optional[str] = Field(None, description="노래 결과 URL") + created_at: Optional[datetime] = Field(None, description="생성 일시") + + class DownloadSongResponse(BaseModel): """노래 다운로드 응답 스키마