modify url parameter

insta
Dohyun Lim 2026-01-19 16:59:25 +09:00
parent f362effe7a
commit 56069a04a1
17 changed files with 68 additions and 492 deletions

View File

View File

@ -1,125 +0,0 @@
"""
카카오 로그인 API 라우터
"""
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import RedirectResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.dependencies import get_current_user_optional
from app.auth.models import User
from app.auth.schemas import AuthStatusResponse, TokenResponse, UserResponse
from app.auth.services.jwt import create_access_token
from app.auth.services.kakao import kakao_client
from app.database.session import get_session
router = APIRouter(tags=["Auth"])
@router.get("/auth/kakao/login")
async def kakao_login():
"""
카카오 로그인 페이지로 리다이렉트
프론트엔드에서 URL을 호출하면 카카오 로그인 페이지로 이동합니다.
"""
auth_url = kakao_client.get_authorization_url()
return RedirectResponse(url=auth_url)
@router.get("/kakao/callback", response_model=TokenResponse)
async def kakao_callback(
code: str,
session: AsyncSession = Depends(get_session),
):
"""
카카오 로그인 콜백
카카오 로그인 성공 인가 코드를 받아 JWT 토큰을 발급합니다.
"""
# 1. 인가 코드로 액세스 토큰 획득
token_data = await kakao_client.get_access_token(code)
if "error" in token_data:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"카카오 토큰 발급 실패: {token_data.get('error_description', token_data.get('error'))}",
)
access_token = token_data.get("access_token")
if not access_token:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="액세스 토큰을 받지 못했습니다",
)
# 2. 액세스 토큰으로 사용자 정보 조회
user_info = await kakao_client.get_user_info(access_token)
if "id" not in user_info:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="사용자 정보를 가져오지 못했습니다",
)
kakao_id = str(user_info["id"])
kakao_account = user_info.get("kakao_account", {})
profile = kakao_account.get("profile", {})
nickname = profile.get("nickname")
email = kakao_account.get("email")
profile_image = profile.get("profile_image_url")
# 3. 기존 회원 확인 또는 신규 가입
result = await session.execute(select(User).where(User.kakao_id == kakao_id))
user = result.scalar_one_or_none()
if user is None:
# 신규 가입
user = User(
kakao_id=kakao_id,
nickname=nickname,
email=email,
profile_image=profile_image,
)
session.add(user)
await session.commit()
await session.refresh(user)
else:
# 기존 회원 - 마지막 로그인 시간 및 정보 업데이트
user.nickname = nickname
user.email = email
user.profile_image = profile_image
user.last_login_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(user)
# 4. JWT 토큰 발급
jwt_token = create_access_token({"sub": str(user.id)})
return TokenResponse(
access_token=jwt_token,
user=UserResponse.model_validate(user),
)
@router.get("/auth/me", response_model=AuthStatusResponse)
async def get_auth_status(
current_user: User | None = Depends(get_current_user_optional),
):
"""
현재 인증 상태 확인
프론트엔드에서 로그인 상태를 확인할 사용합니다.
토큰이 유효하면 사용자 정보를, 아니면 is_authenticated=False를 반환합니다.
"""
if current_user is None:
return AuthStatusResponse(is_authenticated=False)
return AuthStatusResponse(
is_authenticated=True,
user=UserResponse.model_validate(current_user),
)

View File

@ -1,71 +0,0 @@
"""
Auth 모듈 의존성 주입
"""
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.models import User
from app.auth.services.jwt import decode_access_token
from app.database.session import get_session
security = HTTPBearer(auto_error=False)
async def get_current_user(
credentials: HTTPAuthorizationCredentials | None = Depends(security),
session: AsyncSession = Depends(get_session),
) -> User:
"""현재 로그인한 사용자 반환 (필수 인증)"""
if credentials is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="인증이 필요합니다",
)
payload = decode_access_token(credentials.credentials)
if payload is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="유효하지 않은 토큰입니다",
)
user_id = payload.get("sub")
if user_id is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="유효하지 않은 토큰입니다",
)
result = await session.execute(select(User).where(User.id == int(user_id)))
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="사용자를 찾을 수 없습니다",
)
return user
async def get_current_user_optional(
credentials: HTTPAuthorizationCredentials | None = Depends(security),
session: AsyncSession = Depends(get_session),
) -> User | None:
"""현재 로그인한 사용자 반환 (선택적 인증)"""
if credentials is None:
return None
payload = decode_access_token(credentials.credentials)
if payload is None:
return None
user_id = payload.get("sub")
if user_id is None:
return None
result = await session.execute(select(User).where(User.id == int(user_id)))
return result.scalar_one_or_none()

View File

@ -1,88 +0,0 @@
"""
Auth 모듈 SQLAlchemy 모델 정의
카카오 로그인 사용자 정보를 저장합니다.
"""
from datetime import datetime
from sqlalchemy import DateTime, Index, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.database.session import Base
class User(Base):
"""
사용자 테이블 (카카오 로그인)
Attributes:
id: 고유 식별자 (자동 증가)
kakao_id: 카카오 고유 ID
nickname: 카카오 닉네임
email: 카카오 이메일 (선택)
profile_image: 프로필 이미지 URL
created_at: 가입 일시
last_login_at: 마지막 로그인 일시
"""
__tablename__ = "user"
__table_args__ = (
Index("idx_user_kakao_id", "kakao_id"),
{
"mysql_engine": "InnoDB",
"mysql_charset": "utf8mb4",
"mysql_collate": "utf8mb4_unicode_ci",
},
)
id: Mapped[int] = mapped_column(
Integer,
primary_key=True,
nullable=False,
autoincrement=True,
comment="고유 식별자",
)
kakao_id: Mapped[str] = mapped_column(
String(50),
nullable=False,
unique=True,
comment="카카오 고유 ID",
)
nickname: Mapped[str | None] = mapped_column(
String(100),
nullable=True,
comment="카카오 닉네임",
)
email: Mapped[str | None] = mapped_column(
String(255),
nullable=True,
comment="카카오 이메일",
)
profile_image: Mapped[str | None] = mapped_column(
String(500),
nullable=True,
comment="프로필 이미지 URL",
)
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
server_default=func.now(),
comment="가입 일시",
)
last_login_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
server_default=func.now(),
onupdate=func.now(),
comment="마지막 로그인 일시",
)
def __repr__(self) -> str:
return f"<User(id={self.id}, kakao_id='{self.kakao_id}', nickname='{self.nickname}')>"

View File

@ -1,36 +0,0 @@
"""
Auth 모듈 Pydantic 스키마
"""
from datetime import datetime
from pydantic import BaseModel
class UserResponse(BaseModel):
"""사용자 정보 응답"""
id: int
kakao_id: str
nickname: str | None
email: str | None
profile_image: str | None
created_at: datetime
last_login_at: datetime
model_config = {"from_attributes": True}
class TokenResponse(BaseModel):
"""토큰 응답"""
access_token: str
token_type: str = "bearer"
user: UserResponse
class AuthStatusResponse(BaseModel):
"""인증 상태 응답 (프론트엔드용)"""
is_authenticated: bool
user: UserResponse | None = None

View File

@ -1,34 +0,0 @@
"""
JWT 토큰 유틸리티
"""
from datetime import datetime, timedelta, timezone
from jose import JWTError, jwt
from config import security_settings
def create_access_token(data: dict) -> str:
"""JWT 액세스 토큰 생성"""
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(minutes=security_settings.JWT_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
return jwt.encode(
to_encode,
security_settings.JWT_SECRET,
algorithm=security_settings.JWT_ALGORITHM,
)
def decode_access_token(token: str) -> dict | None:
"""JWT 액세스 토큰 디코딩"""
try:
payload = jwt.decode(
token,
security_settings.JWT_SECRET,
algorithms=[security_settings.JWT_ALGORITHM],
)
return payload
except JWTError:
return None

View File

@ -1,58 +0,0 @@
"""
카카오 OAuth API 클라이언트
"""
import aiohttp
from app.utils.logger import get_logger
from config import kakao_settings
logger = get_logger("kakao")
class KakaoOAuthClient:
"""카카오 OAuth API 클라이언트"""
AUTH_URL = "https://kauth.kakao.com/oauth/authorize"
TOKEN_URL = "https://kauth.kakao.com/oauth/token"
USER_INFO_URL = "https://kapi.kakao.com/v2/user/me"
def __init__(self):
self.client_id = kakao_settings.KAKAO_CLIENT_ID
self.client_secret = kakao_settings.KAKAO_CLIENT_SECRET
self.redirect_uri = kakao_settings.KAKAO_REDIRECT_URI
def get_authorization_url(self) -> str:
"""카카오 로그인 페이지 URL 반환"""
return (
f"{self.AUTH_URL}"
f"?client_id={self.client_id}"
f"&redirect_uri={self.redirect_uri}"
f"&response_type=code"
)
async def get_access_token(self, code: str) -> dict:
"""인가 코드로 액세스 토큰 획득"""
async with aiohttp.ClientSession() as session:
data = {
"grant_type": "authorization_code",
"client_id": self.client_id,
"client_secret": self.client_secret,
"redirect_uri": self.redirect_uri,
"code": code,
}
logger.debug(f"Token request - client_id: {self.client_id}, redirect_uri: {self.redirect_uri}")
async with session.post(self.TOKEN_URL, data=data) as response:
result = await response.json()
logger.debug(f"Token response: {result}")
return result
async def get_user_info(self, access_token: str) -> dict:
"""액세스 토큰으로 사용자 정보 조회"""
async with aiohttp.ClientSession() as session:
headers = {"Authorization": f"Bearer {access_token}"}
async with session.get(self.USER_INFO_URL, headers=headers) as response:
return await response.json()
kakao_client = KakaoOAuthClient()

View File

@ -49,7 +49,7 @@ import traceback as tb
# 로거 설정
logger = get_logger("lyric")
router = APIRouter(prefix="/lyric", tags=["lyric"])
router = APIRouter(prefix="/lyric", tags=["Lyric"])
# =============================================================================
@ -398,7 +398,7 @@ async def get_lyric_status(
@router.get(
"s",
"s/",
summary="가사 목록 조회 (페이지네이션)",
description="""
생성 완료된 가사를 페이지네이션으로 조회합니다.

View File

@ -5,7 +5,7 @@ Song API Router
엔드포인트 목록:
- POST /song/generate/{task_id}: 노래 생성 요청 (task_id로 Project/Lyric 연결)
- GET /song/status/{suno_task_id}: Suno API 노래 생성 상태 조회
- GET /song/status/{song_id}: Suno API 노래 생성 상태 조회
- GET /song/download/{task_id}: 노래 다운로드 상태 조회 (DB polling)
사용 예시:
@ -58,7 +58,7 @@ Suno API를 통해 노래 생성을 요청합니다.
## 반환 정보
- **success**: 요청 성공 여부
- **task_id**: 내부 작업 ID (Project/Lyric task_id)
- **suno_task_id**: Suno API 작업 ID (상태 조회에 사용)
- **song_id**: Suno API 작업 ID (상태 조회에 사용)
- **message**: 응답 메시지
## 사용 예시
@ -73,7 +73,7 @@ POST /song/generate/019123ab-cdef-7890-abcd-ef1234567890
## 참고
- 생성되는 노래는 1 이내 길이입니다.
- suno_task_id를 사용하여 /status/{suno_task_id} 엔드포인트에서 생성 상태를 확인할 있습니다.
- song_id를 사용하여 /status/{song_id} 엔드포인트에서 생성 상태를 확인할 있습니다.
- Song 테이블에 데이터가 저장되며, project_id와 lyric_id가 자동으로 연결됩니다.
""",
response_model=GenerateSongResponse,
@ -193,7 +193,7 @@ async def generate_song(
return GenerateSongResponse(
success=False,
task_id=task_id,
suno_task_id=None,
song_id=None,
message="노래 생성 요청에 실패했습니다.",
error_message=str(e),
)
@ -237,7 +237,7 @@ async def generate_song(
return GenerateSongResponse(
success=False,
task_id=task_id,
suno_task_id=None,
song_id=None,
message="노래 생성 요청에 실패했습니다.",
error_message=str(e),
)
@ -273,8 +273,8 @@ async def generate_song(
return GenerateSongResponse(
success=True,
task_id=task_id,
suno_task_id=suno_task_id,
message="노래 생성 요청이 접수되었습니다. suno_task_id로 상태를 조회하세요.",
song_id=suno_task_id,
message="노래 생성 요청이 접수되었습니다. song_id로 상태를 조회하세요.",
error_message=None,
)
@ -286,28 +286,26 @@ async def generate_song(
return GenerateSongResponse(
success=False,
task_id=task_id,
suno_task_id=suno_task_id,
song_id=suno_task_id,
message="노래 생성은 요청되었으나 DB 업데이트에 실패했습니다.",
error_message=str(e),
)
@router.get(
"/status/{suno_task_id}",
"/status/{song_id}",
summary="노래 생성 상태 조회",
description="""
Suno API를 통해 노래 생성 작업의 상태를 조회합니다.
SUCCESS 상태인 경우 백그라운드에서 MP3 파일을 다운로드하고 Azure Blob Storage에 업로드한 Song 테이블을 업데이트합니다.
## 경로 파라미터
- **suno_task_id**: 노래 생성 반환된 Suno API 작업 ID (필수)
- **song_id**: 노래 생성 반환된 작업 ID (필수)
## 반환 정보
- **success**: 조회 성공 여부
- **status**: 작업 상태 (PENDING, processing, SUCCESS, failed)
- **message**: 상태 메시지
- **clips**: 생성된 노래 클립 목록 (완료 )
- **raw_response**: Suno API 원본 응답
## 사용 예시
```
@ -334,35 +332,41 @@ GET /song/status/abc123...
},
)
async def get_song_status(
suno_task_id: str,
song_id: str,
session: AsyncSession = Depends(get_session),
) -> PollingSongResponse:
"""suno_task_id로 노래 생성 작업의 상태를 조회합니다.
"""song_id로 노래 생성 작업의 상태를 조회합니다.
SUCCESS 상태인 경우 백그라운드에서 MP3 파일을 다운로드하고
Azure Blob Storage에 업로드한 Song 테이블의 status를 completed로,
song_result_url을 Blob URL로 업데이트합니다.
"""
logger.info(f"[get_song_status] START - suno_task_id: {suno_task_id}")
logger.info(f"[get_song_status] START - song_id: {song_id}")
try:
suno_service = SunoService()
result = await suno_service.get_task_status(suno_task_id)
result = await suno_service.get_task_status(song_id)
parsed_response = suno_service.parse_status_response(result)
logger.info(f"[get_song_status] Suno API response - suno_task_id: {suno_task_id}, status: {parsed_response.status}")
logger.info(f"[get_song_status] Suno API response - song_id: {song_id}, status: {parsed_response.status}")
# SUCCESS 상태인 경우 첫 번째 클립 정보를 DB에 직접 저장
if parsed_response.status == "SUCCESS" and parsed_response.clips:
if parsed_response.status == "SUCCESS" and result:
# result에서 직접 clips 데이터 추출
data = result.get("data", {})
response_data = data.get("response") or {}
clips_data = response_data.get("sunoData") or []
if clips_data:
# 첫 번째 클립(clips[0])의 audioUrl과 duration 사용
first_clip = parsed_response.clips[0]
audio_url = first_clip.audio_url
clip_duration = first_clip.duration
logger.debug(f"[get_song_status] Using first clip - id: {first_clip.id}, audio_url: {audio_url}, duration: {clip_duration}")
first_clip = clips_data[0]
audio_url = first_clip.get("audioUrl")
clip_duration = first_clip.get("duration")
logger.debug(f"[get_song_status] Using first clip - id: {first_clip.get('id')}, audio_url: {audio_url}, duration: {clip_duration}")
if audio_url:
# suno_task_id로 Song 조회
# song_id로 Song 조회
song_result = await session.execute(
select(Song)
.where(Song.suno_task_id == suno_task_id)
.where(Song.suno_task_id == song_id)
.order_by(Song.created_at.desc())
.limit(1)
)
@ -375,23 +379,21 @@ async def get_song_status(
if clip_duration is not None:
song.duration = clip_duration
await session.commit()
logger.info(f"[get_song_status] Song updated - suno_task_id: {suno_task_id}, status: completed, song_result_url: {audio_url}, duration: {clip_duration}")
logger.info(f"[get_song_status] Song updated - song_id: {song_id}, status: completed, song_result_url: {audio_url}, duration: {clip_duration}")
elif song and song.status == "completed":
logger.info(f"[get_song_status] SKIPPED - Song already completed, suno_task_id: {suno_task_id}")
logger.info(f"[get_song_status] SKIPPED - Song already completed, song_id: {song_id}")
logger.info(f"[get_song_status] SUCCESS - suno_task_id: {suno_task_id}")
logger.info(f"[get_song_status] SUCCESS - song_id: {song_id}")
return parsed_response
except Exception as e:
import traceback
logger.error(f"[get_song_status] EXCEPTION - suno_task_id: {suno_task_id}, error: {e}")
logger.error(f"[get_song_status] EXCEPTION - song_id: {song_id}, error: {e}")
return PollingSongResponse(
success=False,
status="error",
message="상태 조회에 실패했습니다.",
clips=None,
raw_response=None,
error_message=f"{type(e).__name__}: {e}\n{traceback.format_exc()}",
)

View File

@ -1,6 +1,6 @@
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Dict, List, Optional
from typing import Dict, List, Optional
from fastapi import Request
from pydantic import BaseModel, Field
@ -64,8 +64,8 @@ class GenerateSongResponse(BaseModel):
{
"success": true,
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
"suno_task_id": "abc123...",
"message": "노래 생성 요청이 접수되었습니다. suno_task_id로 상태를 조회하세요.",
"song_id": "abc123...",
"message": "노래 생성 요청이 접수되었습니다. song_id로 상태를 조회하세요.",
"error_message": null
}
@ -73,7 +73,7 @@ class GenerateSongResponse(BaseModel):
{
"success": false,
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
"suno_task_id": null,
"song_id": null,
"message": "노래 생성 요청에 실패했습니다.",
"error_message": "Suno API connection error"
}
@ -81,7 +81,7 @@ class GenerateSongResponse(BaseModel):
success: bool = Field(..., description="요청 성공 여부")
task_id: Optional[str] = Field(None, description="내부 작업 ID (Project/Lyric task_id)")
suno_task_id: Optional[str] = Field(None, description="Suno API 작업 ID")
song_id: Optional[str] = Field(None, description="Suno API 작업 ID")
message: str = Field(..., description="응답 메시지")
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
@ -90,7 +90,7 @@ class PollingSongRequest(BaseModel):
"""노래 생성 상태 조회 요청 스키마 (Legacy)
Note:
현재 사용되지 않음. GET /song/status/{suno_task_id} 엔드포인트 사용.
현재 사용되지 않음. GET /song/status/{song_id} 엔드포인트 사용.
Example Request:
{
@ -117,7 +117,7 @@ class PollingSongResponse(BaseModel):
"""노래 생성 상태 조회 응답 스키마
Usage:
GET /song/status/{suno_task_id}
GET /song/status/{song_id}
Suno API 작업 상태를 조회합니다.
Note:
@ -138,8 +138,6 @@ class PollingSongResponse(BaseModel):
"success": true,
"status": "processing",
"message": "노래를 생성하고 있습니다.",
"clips": null,
"raw_response": {...},
"error_message": null
}
@ -148,18 +146,6 @@ class PollingSongResponse(BaseModel):
"success": true,
"status": "SUCCESS",
"message": "노래 생성이 완료되었습니다.",
"clips": [
{
"id": "clip-id",
"audio_url": "https://...",
"stream_audio_url": "https://...",
"image_url": "https://...",
"title": "Song Title",
"status": "complete",
"duration": 60.0
}
],
"raw_response": {...},
"error_message": null
}
@ -168,8 +154,6 @@ class PollingSongResponse(BaseModel):
"success": false,
"status": "error",
"message": "상태 조회에 실패했습니다.",
"clips": null,
"raw_response": null,
"error_message": "ConnectionError: ..."
}
"""
@ -179,8 +163,6 @@ class PollingSongResponse(BaseModel):
None, description="작업 상태 (PENDING, processing, SUCCESS, failed)"
)
message: str = Field(..., description="상태 메시지")
clips: Optional[List[SongClipData]] = Field(None, description="생성된 노래 클립 목록")
raw_response: Optional[Dict[str, Any]] = Field(None, description="Suno API 원본 응답")
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")

View File

@ -4,12 +4,15 @@
카카오 로그인, JWT 토큰 관리, 사용자 인증 관련 비즈니스 로직을 처리합니다.
"""
import logging
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
logger = logging.getLogger(__name__)
from app.user.exceptions import (
InvalidTokenError,
TokenExpiredError,
@ -221,14 +224,19 @@ class AuthService:
kakao_account = kakao_user_info.kakao_account
profile = kakao_account.profile if kakao_account else None
logger.info(f"[AUTH] 사용자 조회 시작 - kakao_id: {kakao_id}")
# 기존 사용자 조회
result = await session.execute(
select(User).where(User.kakao_id == kakao_id)
)
user = result.scalar_one_or_none()
logger.info(f"[AUTH] DB 조회 결과 - user_exists: {user is not None}, user_id: {user.id if user else None}")
if user is not None:
# 기존 사용자: 프로필 정보 업데이트
logger.info(f"[AUTH] 기존 사용자 발견 - user_id: {user.id}, is_new_user: False")
if profile:
user.nickname = profile.nickname
user.profile_image_url = profile.profile_image_url
@ -239,6 +247,7 @@ class AuthService:
return user, False
# 신규 사용자 생성
logger.info(f"[AUTH] 신규 사용자 생성 시작 - kakao_id: {kakao_id}")
new_user = User(
kakao_id=kakao_id,
email=kakao_account.email if kakao_account else None,
@ -249,6 +258,7 @@ class AuthService:
session.add(new_user)
await session.flush()
await session.refresh(new_user)
logger.info(f"[AUTH] 신규 사용자 생성 완료 - user_id: {new_user.id}, is_new_user: True")
return new_user, True
async def _save_refresh_token(

View File

@ -199,8 +199,6 @@ class SunoService:
success=False,
status="error",
message="Suno API 응답이 비어있습니다.",
clips=None,
raw_response=None,
error_message="Suno API returned None response",
)
@ -212,8 +210,6 @@ class SunoService:
success=False,
status="failed",
message="Suno API 응답 오류",
clips=None,
raw_response=result,
error_message=result.get("msg", "Unknown error"),
)
@ -255,7 +251,5 @@ class SunoService:
success=True,
status=status,
message=status_messages.get(status, f"상태: {status}"),
clips=clips,
raw_response=result,
error_message=None,
)

View File

@ -68,7 +68,7 @@ tags_metadata = [
## 노래 생성 흐름
1. `POST /api/v1/song/generate/{task_id}` - 노래 생성 요청
2. `GET /api/v1/song/status/{suno_task_id}` - Suno API 상태 확인
2. `GET /api/v1/song/status/{song_id}` - Suno API 상태 확인
3. `GET /api/v1/song/download/{task_id}` - 노래 다운로드 URL 조회
""",
},