modify url parameter
parent
f362effe7a
commit
56069a04a1
|
|
@ -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),
|
|
||||||
)
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -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}')>"
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -49,7 +49,7 @@ import traceback as tb
|
||||||
# 로거 설정
|
# 로거 설정
|
||||||
logger = get_logger("lyric")
|
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(
|
@router.get(
|
||||||
"s",
|
"s/",
|
||||||
summary="가사 목록 조회 (페이지네이션)",
|
summary="가사 목록 조회 (페이지네이션)",
|
||||||
description="""
|
description="""
|
||||||
생성 완료된 가사를 페이지네이션으로 조회합니다.
|
생성 완료된 가사를 페이지네이션으로 조회합니다.
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ Song API Router
|
||||||
|
|
||||||
엔드포인트 목록:
|
엔드포인트 목록:
|
||||||
- POST /song/generate/{task_id}: 노래 생성 요청 (task_id로 Project/Lyric 연결)
|
- 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)
|
- GET /song/download/{task_id}: 노래 다운로드 상태 조회 (DB polling)
|
||||||
|
|
||||||
사용 예시:
|
사용 예시:
|
||||||
|
|
@ -58,7 +58,7 @@ Suno API를 통해 노래 생성을 요청합니다.
|
||||||
## 반환 정보
|
## 반환 정보
|
||||||
- **success**: 요청 성공 여부
|
- **success**: 요청 성공 여부
|
||||||
- **task_id**: 내부 작업 ID (Project/Lyric task_id)
|
- **task_id**: 내부 작업 ID (Project/Lyric task_id)
|
||||||
- **suno_task_id**: Suno API 작업 ID (상태 조회에 사용)
|
- **song_id**: Suno API 작업 ID (상태 조회에 사용)
|
||||||
- **message**: 응답 메시지
|
- **message**: 응답 메시지
|
||||||
|
|
||||||
## 사용 예시
|
## 사용 예시
|
||||||
|
|
@ -73,7 +73,7 @@ POST /song/generate/019123ab-cdef-7890-abcd-ef1234567890
|
||||||
|
|
||||||
## 참고
|
## 참고
|
||||||
- 생성되는 노래는 약 1분 이내 길이입니다.
|
- 생성되는 노래는 약 1분 이내 길이입니다.
|
||||||
- suno_task_id를 사용하여 /status/{suno_task_id} 엔드포인트에서 생성 상태를 확인할 수 있습니다.
|
- song_id를 사용하여 /status/{song_id} 엔드포인트에서 생성 상태를 확인할 수 있습니다.
|
||||||
- Song 테이블에 데이터가 저장되며, project_id와 lyric_id가 자동으로 연결됩니다.
|
- Song 테이블에 데이터가 저장되며, project_id와 lyric_id가 자동으로 연결됩니다.
|
||||||
""",
|
""",
|
||||||
response_model=GenerateSongResponse,
|
response_model=GenerateSongResponse,
|
||||||
|
|
@ -193,7 +193,7 @@ async def generate_song(
|
||||||
return GenerateSongResponse(
|
return GenerateSongResponse(
|
||||||
success=False,
|
success=False,
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
suno_task_id=None,
|
song_id=None,
|
||||||
message="노래 생성 요청에 실패했습니다.",
|
message="노래 생성 요청에 실패했습니다.",
|
||||||
error_message=str(e),
|
error_message=str(e),
|
||||||
)
|
)
|
||||||
|
|
@ -237,7 +237,7 @@ async def generate_song(
|
||||||
return GenerateSongResponse(
|
return GenerateSongResponse(
|
||||||
success=False,
|
success=False,
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
suno_task_id=None,
|
song_id=None,
|
||||||
message="노래 생성 요청에 실패했습니다.",
|
message="노래 생성 요청에 실패했습니다.",
|
||||||
error_message=str(e),
|
error_message=str(e),
|
||||||
)
|
)
|
||||||
|
|
@ -273,8 +273,8 @@ async def generate_song(
|
||||||
return GenerateSongResponse(
|
return GenerateSongResponse(
|
||||||
success=True,
|
success=True,
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
suno_task_id=suno_task_id,
|
song_id=suno_task_id,
|
||||||
message="노래 생성 요청이 접수되었습니다. suno_task_id로 상태를 조회하세요.",
|
message="노래 생성 요청이 접수되었습니다. song_id로 상태를 조회하세요.",
|
||||||
error_message=None,
|
error_message=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -286,28 +286,26 @@ async def generate_song(
|
||||||
return GenerateSongResponse(
|
return GenerateSongResponse(
|
||||||
success=False,
|
success=False,
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
suno_task_id=suno_task_id,
|
song_id=suno_task_id,
|
||||||
message="노래 생성은 요청되었으나 DB 업데이트에 실패했습니다.",
|
message="노래 생성은 요청되었으나 DB 업데이트에 실패했습니다.",
|
||||||
error_message=str(e),
|
error_message=str(e),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/status/{suno_task_id}",
|
"/status/{song_id}",
|
||||||
summary="노래 생성 상태 조회",
|
summary="노래 생성 상태 조회",
|
||||||
description="""
|
description="""
|
||||||
Suno API를 통해 노래 생성 작업의 상태를 조회합니다.
|
Suno API를 통해 노래 생성 작업의 상태를 조회합니다.
|
||||||
SUCCESS 상태인 경우 백그라운드에서 MP3 파일을 다운로드하고 Azure Blob Storage에 업로드한 뒤 Song 테이블을 업데이트합니다.
|
SUCCESS 상태인 경우 백그라운드에서 MP3 파일을 다운로드하고 Azure Blob Storage에 업로드한 뒤 Song 테이블을 업데이트합니다.
|
||||||
|
|
||||||
## 경로 파라미터
|
## 경로 파라미터
|
||||||
- **suno_task_id**: 노래 생성 시 반환된 Suno API 작업 ID (필수)
|
- **song_id**: 노래 생성 시 반환된 작업 ID (필수)
|
||||||
|
|
||||||
## 반환 정보
|
## 반환 정보
|
||||||
- **success**: 조회 성공 여부
|
- **success**: 조회 성공 여부
|
||||||
- **status**: 작업 상태 (PENDING, processing, SUCCESS, failed)
|
- **status**: 작업 상태 (PENDING, processing, SUCCESS, failed)
|
||||||
- **message**: 상태 메시지
|
- **message**: 상태 메시지
|
||||||
- **clips**: 생성된 노래 클립 목록 (완료 시)
|
|
||||||
- **raw_response**: Suno API 원본 응답
|
|
||||||
|
|
||||||
## 사용 예시
|
## 사용 예시
|
||||||
```
|
```
|
||||||
|
|
@ -334,35 +332,41 @@ GET /song/status/abc123...
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
async def get_song_status(
|
async def get_song_status(
|
||||||
suno_task_id: str,
|
song_id: str,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> PollingSongResponse:
|
) -> PollingSongResponse:
|
||||||
"""suno_task_id로 노래 생성 작업의 상태를 조회합니다.
|
"""song_id로 노래 생성 작업의 상태를 조회합니다.
|
||||||
|
|
||||||
SUCCESS 상태인 경우 백그라운드에서 MP3 파일을 다운로드하고
|
SUCCESS 상태인 경우 백그라운드에서 MP3 파일을 다운로드하고
|
||||||
Azure Blob Storage에 업로드한 뒤 Song 테이블의 status를 completed로,
|
Azure Blob Storage에 업로드한 뒤 Song 테이블의 status를 completed로,
|
||||||
song_result_url을 Blob URL로 업데이트합니다.
|
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:
|
try:
|
||||||
suno_service = SunoService()
|
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)
|
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에 직접 저장
|
# 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 사용
|
# 첫 번째 클립(clips[0])의 audioUrl과 duration 사용
|
||||||
first_clip = parsed_response.clips[0]
|
first_clip = clips_data[0]
|
||||||
audio_url = first_clip.audio_url
|
audio_url = first_clip.get("audioUrl")
|
||||||
clip_duration = first_clip.duration
|
clip_duration = first_clip.get("duration")
|
||||||
logger.debug(f"[get_song_status] Using first clip - id: {first_clip.id}, audio_url: {audio_url}, duration: {clip_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:
|
if audio_url:
|
||||||
# suno_task_id로 Song 조회
|
# song_id로 Song 조회
|
||||||
song_result = await session.execute(
|
song_result = await session.execute(
|
||||||
select(Song)
|
select(Song)
|
||||||
.where(Song.suno_task_id == suno_task_id)
|
.where(Song.suno_task_id == song_id)
|
||||||
.order_by(Song.created_at.desc())
|
.order_by(Song.created_at.desc())
|
||||||
.limit(1)
|
.limit(1)
|
||||||
)
|
)
|
||||||
|
|
@ -375,23 +379,21 @@ async def get_song_status(
|
||||||
if clip_duration is not None:
|
if clip_duration is not None:
|
||||||
song.duration = clip_duration
|
song.duration = clip_duration
|
||||||
await session.commit()
|
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":
|
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
|
return parsed_response
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import traceback
|
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(
|
return PollingSongResponse(
|
||||||
success=False,
|
success=False,
|
||||||
status="error",
|
status="error",
|
||||||
message="상태 조회에 실패했습니다.",
|
message="상태 조회에 실패했습니다.",
|
||||||
clips=None,
|
|
||||||
raw_response=None,
|
|
||||||
error_message=f"{type(e).__name__}: {e}\n{traceback.format_exc()}",
|
error_message=f"{type(e).__name__}: {e}\n{traceback.format_exc()}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
@ -64,8 +64,8 @@ class GenerateSongResponse(BaseModel):
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
||||||
"suno_task_id": "abc123...",
|
"song_id": "abc123...",
|
||||||
"message": "노래 생성 요청이 접수되었습니다. suno_task_id로 상태를 조회하세요.",
|
"message": "노래 생성 요청이 접수되었습니다. song_id로 상태를 조회하세요.",
|
||||||
"error_message": null
|
"error_message": null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -73,7 +73,7 @@ class GenerateSongResponse(BaseModel):
|
||||||
{
|
{
|
||||||
"success": false,
|
"success": false,
|
||||||
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
||||||
"suno_task_id": null,
|
"song_id": null,
|
||||||
"message": "노래 생성 요청에 실패했습니다.",
|
"message": "노래 생성 요청에 실패했습니다.",
|
||||||
"error_message": "Suno API connection error"
|
"error_message": "Suno API connection error"
|
||||||
}
|
}
|
||||||
|
|
@ -81,7 +81,7 @@ class GenerateSongResponse(BaseModel):
|
||||||
|
|
||||||
success: bool = Field(..., description="요청 성공 여부")
|
success: bool = Field(..., description="요청 성공 여부")
|
||||||
task_id: Optional[str] = Field(None, description="내부 작업 ID (Project/Lyric task_id)")
|
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="응답 메시지")
|
message: str = Field(..., description="응답 메시지")
|
||||||
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
|
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
|
||||||
|
|
||||||
|
|
@ -90,7 +90,7 @@ class PollingSongRequest(BaseModel):
|
||||||
"""노래 생성 상태 조회 요청 스키마 (Legacy)
|
"""노래 생성 상태 조회 요청 스키마 (Legacy)
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
현재 사용되지 않음. GET /song/status/{suno_task_id} 엔드포인트 사용.
|
현재 사용되지 않음. GET /song/status/{song_id} 엔드포인트 사용.
|
||||||
|
|
||||||
Example Request:
|
Example Request:
|
||||||
{
|
{
|
||||||
|
|
@ -117,7 +117,7 @@ class PollingSongResponse(BaseModel):
|
||||||
"""노래 생성 상태 조회 응답 스키마
|
"""노래 생성 상태 조회 응답 스키마
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
GET /song/status/{suno_task_id}
|
GET /song/status/{song_id}
|
||||||
Suno API 작업 상태를 조회합니다.
|
Suno API 작업 상태를 조회합니다.
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
|
|
@ -138,8 +138,6 @@ class PollingSongResponse(BaseModel):
|
||||||
"success": true,
|
"success": true,
|
||||||
"status": "processing",
|
"status": "processing",
|
||||||
"message": "노래를 생성하고 있습니다.",
|
"message": "노래를 생성하고 있습니다.",
|
||||||
"clips": null,
|
|
||||||
"raw_response": {...},
|
|
||||||
"error_message": null
|
"error_message": null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -148,18 +146,6 @@ class PollingSongResponse(BaseModel):
|
||||||
"success": true,
|
"success": true,
|
||||||
"status": "SUCCESS",
|
"status": "SUCCESS",
|
||||||
"message": "노래 생성이 완료되었습니다.",
|
"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
|
"error_message": null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -168,8 +154,6 @@ class PollingSongResponse(BaseModel):
|
||||||
"success": false,
|
"success": false,
|
||||||
"status": "error",
|
"status": "error",
|
||||||
"message": "상태 조회에 실패했습니다.",
|
"message": "상태 조회에 실패했습니다.",
|
||||||
"clips": null,
|
|
||||||
"raw_response": null,
|
|
||||||
"error_message": "ConnectionError: ..."
|
"error_message": "ConnectionError: ..."
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
@ -179,8 +163,6 @@ class PollingSongResponse(BaseModel):
|
||||||
None, description="작업 상태 (PENDING, processing, SUCCESS, failed)"
|
None, description="작업 상태 (PENDING, processing, SUCCESS, failed)"
|
||||||
)
|
)
|
||||||
message: str = Field(..., description="상태 메시지")
|
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="에러 메시지 (실패 시)")
|
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,15 @@
|
||||||
카카오 로그인, JWT 토큰 관리, 사용자 인증 관련 비즈니스 로직을 처리합니다.
|
카카오 로그인, JWT 토큰 관리, 사용자 인증 관련 비즈니스 로직을 처리합니다.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from sqlalchemy import select, update
|
from sqlalchemy import select, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
from app.user.exceptions import (
|
from app.user.exceptions import (
|
||||||
InvalidTokenError,
|
InvalidTokenError,
|
||||||
TokenExpiredError,
|
TokenExpiredError,
|
||||||
|
|
@ -221,14 +224,19 @@ class AuthService:
|
||||||
kakao_account = kakao_user_info.kakao_account
|
kakao_account = kakao_user_info.kakao_account
|
||||||
profile = kakao_account.profile if kakao_account else None
|
profile = kakao_account.profile if kakao_account else None
|
||||||
|
|
||||||
|
logger.info(f"[AUTH] 사용자 조회 시작 - kakao_id: {kakao_id}")
|
||||||
|
|
||||||
# 기존 사용자 조회
|
# 기존 사용자 조회
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(User).where(User.kakao_id == kakao_id)
|
select(User).where(User.kakao_id == kakao_id)
|
||||||
)
|
)
|
||||||
user = result.scalar_one_or_none()
|
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:
|
if user is not None:
|
||||||
# 기존 사용자: 프로필 정보 업데이트
|
# 기존 사용자: 프로필 정보 업데이트
|
||||||
|
logger.info(f"[AUTH] 기존 사용자 발견 - user_id: {user.id}, is_new_user: False")
|
||||||
if profile:
|
if profile:
|
||||||
user.nickname = profile.nickname
|
user.nickname = profile.nickname
|
||||||
user.profile_image_url = profile.profile_image_url
|
user.profile_image_url = profile.profile_image_url
|
||||||
|
|
@ -239,6 +247,7 @@ class AuthService:
|
||||||
return user, False
|
return user, False
|
||||||
|
|
||||||
# 신규 사용자 생성
|
# 신규 사용자 생성
|
||||||
|
logger.info(f"[AUTH] 신규 사용자 생성 시작 - kakao_id: {kakao_id}")
|
||||||
new_user = User(
|
new_user = User(
|
||||||
kakao_id=kakao_id,
|
kakao_id=kakao_id,
|
||||||
email=kakao_account.email if kakao_account else None,
|
email=kakao_account.email if kakao_account else None,
|
||||||
|
|
@ -249,6 +258,7 @@ class AuthService:
|
||||||
session.add(new_user)
|
session.add(new_user)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
await session.refresh(new_user)
|
await session.refresh(new_user)
|
||||||
|
logger.info(f"[AUTH] 신규 사용자 생성 완료 - user_id: {new_user.id}, is_new_user: True")
|
||||||
return new_user, True
|
return new_user, True
|
||||||
|
|
||||||
async def _save_refresh_token(
|
async def _save_refresh_token(
|
||||||
|
|
|
||||||
|
|
@ -199,8 +199,6 @@ class SunoService:
|
||||||
success=False,
|
success=False,
|
||||||
status="error",
|
status="error",
|
||||||
message="Suno API 응답이 비어있습니다.",
|
message="Suno API 응답이 비어있습니다.",
|
||||||
clips=None,
|
|
||||||
raw_response=None,
|
|
||||||
error_message="Suno API returned None response",
|
error_message="Suno API returned None response",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -212,8 +210,6 @@ class SunoService:
|
||||||
success=False,
|
success=False,
|
||||||
status="failed",
|
status="failed",
|
||||||
message="Suno API 응답 오류",
|
message="Suno API 응답 오류",
|
||||||
clips=None,
|
|
||||||
raw_response=result,
|
|
||||||
error_message=result.get("msg", "Unknown error"),
|
error_message=result.get("msg", "Unknown error"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -255,7 +251,5 @@ class SunoService:
|
||||||
success=True,
|
success=True,
|
||||||
status=status,
|
status=status,
|
||||||
message=status_messages.get(status, f"상태: {status}"),
|
message=status_messages.get(status, f"상태: {status}"),
|
||||||
clips=clips,
|
|
||||||
raw_response=result,
|
|
||||||
error_message=None,
|
error_message=None,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
2
main.py
2
main.py
|
|
@ -68,7 +68,7 @@ tags_metadata = [
|
||||||
## 노래 생성 흐름
|
## 노래 생성 흐름
|
||||||
|
|
||||||
1. `POST /api/v1/song/generate/{task_id}` - 노래 생성 요청
|
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 조회
|
3. `GET /api/v1/song/download/{task_id}` - 노래 다운로드 URL 조회
|
||||||
""",
|
""",
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue