Compare commits

..

No commits in common. "88a91aa6d74cf6ca7af1312883db93894c93b2b3" and "38323870ec3f9e635a94a601ce9b3c1f615eaf53" have entirely different histories.

50 changed files with 2002 additions and 4167 deletions

198
README.md
View File

@ -4,29 +4,24 @@ AI 기반 광고 음악 생성 서비스의 백엔드 API 서버입니다.
## 기술 스택 ## 기술 스택
- **Language**: Python 3.13
- **Framework**: FastAPI - **Framework**: FastAPI
- **Database**: MySQL (asyncmy 비동기 드라이버), Redis - **Database**: MySQL (asyncmy 비동기 드라이버)
- **ORM**: SQLAlchemy (async) - **ORM**: SQLAlchemy (async)
- **Package Manager**: uv
- **AI Services**: - **AI Services**:
- OpenAI ChatGPT (가사 생성, 마케팅 분석) - OpenAI ChatGPT (가사 생성, 마케팅 분석)
- Suno AI (음악 생성) - Suno AI (음악 생성)
- Creatomate (비디오 생성)
## 프로젝트 구조 ## 프로젝트 구조
```text ```text
app/ app/
├── core/ # 핵심 설정 및 공통 모듈 (logging, exceptions) ├── core/ # 핵심 설정 및 공통 모듈
├── database/ # 데이터베이스 세션 및 Redis 설정 ├── database/ # 데이터베이스 세션 및 설정
├── dependencies/ # FastAPI 의존성 주입
├── home/ # 홈 API (크롤링, 영상 생성 요청) ├── home/ # 홈 API (크롤링, 영상 생성 요청)
├── lyric/ # 가사 API (가사 생성) ├── lyric/ # 가사 API (가사 생성)
├── song/ # 노래 API (Suno AI 음악 생성) ├── song/ # 노래 API (Suno AI 음악 생성)
├── user/ # 사용자 모듈 (카카오 로그인, JWT 인증)
├── video/ # 비디오 관련 모듈 ├── video/ # 비디오 관련 모듈
└── utils/ # 유틸리티 (ChatGPT, Suno, 크롤러, 프롬프트) └── utils/ # 유틸리티 (ChatGPT, Suno, 크롤러)
``` ```
## API 엔드포인트 ## API 엔드포인트
@ -62,84 +57,22 @@ app/
`.env` 파일에 다음 환경 변수를 설정합니다: `.env` 파일에 다음 환경 변수를 설정합니다:
```env ```env
# ================================ # 프로젝트 설정
# 프로젝트 기본 정보 PROJECT_NAME=CastAD
# ================================ PROJECT_DOMAIN=localhost:8000
PROJECT_NAME=CastAD # 프로젝트 이름 DEBUG=True
PROJECT_DOMAIN=localhost:8000 # 프로젝트 도메인 (호스트:포트)
PROJECT_VERSION=0.1.0 # 프로젝트 버전
DESCRIPTION=FastAPI 기반 CastAD 프로젝트 # 프로젝트 설명
ADMIN_BASE_URL=/admin # 관리자 페이지 기본 URL
DEBUG=True # 디버그 모드 (True: 개발, False: 운영)
# ================================
# MySQL 설정 # MySQL 설정
# ================================ MYSQL_HOST=localhost
MYSQL_HOST=localhost # MySQL 호스트 주소 MYSQL_PORT=3306
MYSQL_PORT=3306 # MySQL 포트 번호 MYSQL_USER=your_user
MYSQL_USER=castad-admin # MySQL 사용자명 MYSQL_PASSWORD=your_password
MYSQL_PASSWORD=o2o1324 # MySQL 비밀번호 MYSQL_DB=castad
MYSQL_DB=castad # 사용할 데이터베이스명
# ================================ # API Keys
# Redis 설정 CHATGPT_API_KEY=your_openai_api_key
# ================================ SUNO_API_KEY=your_suno_api_key
REDIS_HOST=localhost # Redis 호스트 주소 SUNO_CALLBACK_URL=https://your-domain.com/api/suno/callback
REDIS_PORT=6379 # Redis 포트 번호
# ================================
# CORS 설정
# ================================
CORS_ALLOW_ORIGINS='["*"]' # 허용할 Origin 목록 (JSON 배열 형식)
CORS_ALLOW_CREDENTIALS=True # 자격 증명(쿠키 등) 허용 여부
CORS_ALLOW_METHODS='["*"]' # 허용할 HTTP 메서드 (JSON 배열 형식)
CORS_ALLOW_HEADERS='["*"]' # 허용할 HTTP 헤더 (JSON 배열 형식)
CORS_MAX_AGE=600 # Preflight 요청 캐시 시간 (초)
# ================================
# Azure Blob Storage 설정
# ================================
AZURE_BLOB_SAS_TOKEN=your_sas_token # Azure Blob Storage SAS 토큰
AZURE_BLOB_BASE_URL=https://... # Azure Blob Storage 기본 URL
# ================================
# Creatomate 템플릿 설정
# ================================
TEMPLATE_ID_VERTICAL=your_template_id # 세로형(9:16) 비디오 템플릿 ID
TEMPLATE_DURATION_VERTICAL=60.0 # 세로형 비디오 기본 길이 (초)
TEMPLATE_ID_HORIZONTAL=your_template_id # 가로형(16:9) 비디오 템플릿 ID
TEMPLATE_DURATION_HORIZONTAL=20.0 # 가로형 비디오 기본 길이 (초)
# ================================
# JWT 토큰 설정
# ================================
JWT_SECRET=your_secret_key # JWT 서명용 비밀키 (랜덤 문자열 권장)
JWT_ALGORITHM=HS256 # JWT 알고리즘 (기본: HS256)
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=60 # Access Token 만료 시간 (분)
JWT_REFRESH_TOKEN_EXPIRE_DAYS=7 # Refresh Token 만료 시간 (일)
# ================================
# 프롬프트 설정
# ================================
PROMPT_FOLDER_ROOT=./app/utils/prompts # 프롬프트 파일 루트 디렉토리
MARKETING_PROMPT_NAME=marketing_prompt # 마케팅 분석용 프롬프트 파일명
SUMMARIZE_PROMPT_NAME=summarize_prompt # 요약용 프롬프트 파일명
LYLIC_PROMPT_NAME=lyric_prompt # 가사 생성용 프롬프트 파일명
# ================================
# 로그 설정
# ================================
LOG_CONSOLE_ENABLED=True # 콘솔 로그 출력 여부
LOG_FILE_ENABLED=True # 파일 로그 저장 여부
LOG_LEVEL=DEBUG # 전체 로그 레벨 (DEBUG, INFO, WARNING, ERROR, CRITICAL)
LOG_CONSOLE_LEVEL=DEBUG # 콘솔 출력 로그 레벨
LOG_FILE_LEVEL=DEBUG # 파일 저장 로그 레벨
LOG_MAX_SIZE_MB=15 # 로그 파일 최대 크기 (MB)
LOG_BACKUP_COUNT=30 # 로그 백업 파일 보관 개수
LOG_DIR=logs # 로그 저장 디렉토리 경로
# - 절대 경로: 해당 경로 사용
# - 상대 경로: 프로젝트 루트 기준
# - /www/log/uvicorn 존재 시: 자동으로 해당 경로 사용 (운영)
``` ```
## 실행 방법 ## 실행 방법
@ -177,100 +110,3 @@ fastapi run main.py
## API 문서 ## API 문서
서버 실행 후 `/docs` 에서 Scalar API 문서를 확인할 수 있습니다. 서버 실행 후 `/docs` 에서 Scalar API 문서를 확인할 수 있습니다.
## 서버 아키텍처
### 전체 시스템 흐름
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Client (Web/Mobile) │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ FastAPI Application │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
│ │ Auth API │ │ Home API │ │ Lyric API │ │ Song/Video API │ │
│ │ (카카오) │ │ (크롤링) │ │ (가사생성) │ │ (음악/영상 생성) │ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └──────────┬──────────┘ │
└─────────┼────────────────┼────────────────┼─────────────────────┼───────────┘
│ │ │ │
▼ ▼ ▼ ▼
┌─────────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐
│ Kakao OAuth │ │ Naver Maps │ │ ChatGPT │ │ External AI Services │
│ (로그인) │ │ (크롤링) │ │ (OpenAI) │ │ ┌───────┐ ┌──────────┐ │
└─────────────────┘ └─────────────┘ └─────────────┘ │ │ Suno │ │Creatomate│ │
│ │ (음악) │ │ (영상) │ │
│ └───────┘ └──────────┘ │
└─────────────────────────┘
│ │ │ │
▼ ▼ ▼ ▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ Data Layer │
│ ┌─────────────┐ ┌─────────────────────┐ │
│ │ MySQL │ │ Azure Blob Storage │ │
│ │ (메인 DB) │ │ (미디어 저장소) │ │
│ └─────────────┘ └─────────────────────┘ │
│ ┌─────────────┐ │
│ │ Redis │ │
│ │ (캐시/세션) │ │
│ └─────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
```
### 광고 콘텐츠 생성 플로우
```
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ 1. 입력 │───▶│ 2. 크롤링 │───▶│ 3. 가사 │───▶│ 4. 음악 │───▶│ 5. 영상 │
│ │ │ │ │ 생성 │ │ 생성 │ │ 생성 │
└──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
┌────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│장소 URL │ │Naver Maps│ │ ChatGPT │ │ Suno AI │ │Creatomate│
│or 이미지 │ │ 크롤러 │ │ API │ │ API │ │ API │
└────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘
│ │ │ │
▼ ▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│장소 정보 │ │ 광고 가사 │ │ MP3 │ │ 광고 영상 │
│이미지 수집 │ │ 텍스트 │ │ 파일 │ │ 파일 │
└──────────┘ └──────────┘ └──────────┘ └──────────┘
```
### 인증 플로우 (카카오 OAuth)
```
┌────────┐ ┌────────────┐ ┌───────────┐ ┌────────────┐
│ Client │ │ CastAD │ │ Kakao │ │ MySQL │
│ │ │ Backend │ │ OAuth │ │ │
└───┬────┘ └─────┬──────┘ └─────┬─────┘ └─────┬──────┘
│ │ │ │
│ 1. 로그인 요청 │ │ │
│───────────────▶│ │ │
│ │ │ │
│ 2. 카카오 URL │ │ │
│◀───────────────│ │ │
│ │ │ │
│ 3. 카카오 로그인 │ │ │
│────────────────────────────────▶ │ │
│ │ │ │
│ 4. 인가 코드 │ │ │
│◀──────────────────────────────── │ │
│ │ │ │
│ 5. 콜백 (code) │ │ │
│───────────────▶│ 6. 토큰 요청 │ │
│ │─────────────────▶│ │
│ │ 7. Access Token │ │
│ │◀─────────────────│ │
│ │ │ │
│ │ 8. 사용자 저장/조회 │ │
│ │─────────────────────────────────▶ │
│ │◀───────────────────────────────── │
│ │ │ │
│ 9. JWT 토큰 발급 │ │ │
│◀───────────────│ │ │
│ │ │ │
```

View File

View File

@ -0,0 +1,125 @@
"""
카카오 로그인 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),
)

71
app/auth/dependencies.py Normal file
View File

@ -0,0 +1,71 @@
"""
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()

88
app/auth/models.py Normal file
View File

@ -0,0 +1,88 @@
"""
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}')>"

36
app/auth/schemas.py Normal file
View File

@ -0,0 +1,36 @@
"""
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

34
app/auth/services/jwt.py Normal file
View File

@ -0,0 +1,34 @@
"""
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

@ -0,0 +1,55 @@
"""
카카오 OAuth API 클라이언트
"""
import aiohttp
from config import kakao_settings
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,
}
print(f"[kakao] 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()
print(f"[kakao] 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

@ -102,7 +102,7 @@ def _extract_region_from_address(road_address: str | None) -> str:
"model": ErrorResponse, "model": ErrorResponse,
}, },
}, },
tags=["Crawling"], tags=["crawling"],
) )
async def crawling(request_body: CrawlingRequest): async def crawling(request_body: CrawlingRequest):
"""네이버 지도 장소 크롤링""" """네이버 지도 장소 크롤링"""
@ -379,7 +379,7 @@ print(response.json())
200: {"description": "이미지 업로드 성공"}, 200: {"description": "이미지 업로드 성공"},
400: {"description": "이미지가 제공되지 않음", "model": ErrorResponse}, 400: {"description": "이미지가 제공되지 않음", "model": ErrorResponse},
}, },
tags=["Image-Server"], tags=["image"],
) )
async def upload_images( async def upload_images(
images_json: Optional[str] = Form( images_json: Optional[str] = Form(
@ -597,7 +597,7 @@ curl -X POST "http://localhost:8000/image/upload/blob" \\
200: {"description": "이미지 업로드 성공"}, 200: {"description": "이미지 업로드 성공"},
400: {"description": "이미지가 제공되지 않음", "model": ErrorResponse}, 400: {"description": "이미지가 제공되지 않음", "model": ErrorResponse},
}, },
tags=["Image-Blob"], tags=["image"],
) )
async def upload_images_blob( async def upload_images_blob(
images_json: Optional[str] = Form( images_json: Optional[str] = Form(

View File

@ -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="""
생성 완료된 가사를 페이지네이션으로 조회합니다. 생성 완료된 가사를 페이지네이션으로 조회합니다.
@ -418,9 +418,9 @@ async def get_lyric_status(
## 사용 예시 ## 사용 예시
``` ```
GET /lyrics/ # 기본 조회 (1페이지, 20개) GET /lyrics # 기본 조회 (1페이지, 20개)
GET /lyrics/?page=2 # 2페이지 조회 GET /lyrics?page=2 # 2페이지 조회
GET /lyrics/?page=1&page_size=50 # 50개씩 조회 GET /lyrics?page=1&page_size=50 # 50개씩 조회
``` ```
## 참고 ## 참고

View File

@ -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/{song_id}: Suno API 노래 생성 상태 조회 - GET /song/status/{suno_task_id}: Suno API 노래 생성 상태 조회
- GET /song/download/{task_id}: 노래 다운로드 상태 조회 (DB polling) - GET /song/download/{task_id}: 노래 다운로드 상태 조회 (DB polling)
사용 예시: 사용 예시:
@ -13,7 +13,7 @@ Song API Router
app.include_router(router, prefix="/api/v1") app.include_router(router, prefix="/api/v1")
""" """
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@ -24,8 +24,7 @@ from app.dependencies.pagination import (
) )
from app.home.models import Project from app.home.models import Project
from app.lyric.models import Lyric from app.lyric.models import Lyric
from app.song.models import Song, SongTimestamp from app.song.models import Song
from app.song.schemas.song_schema import ( from app.song.schemas.song_schema import (
DownloadSongResponse, DownloadSongResponse,
GenerateSongRequest, GenerateSongRequest,
@ -33,7 +32,6 @@ from app.song.schemas.song_schema import (
PollingSongResponse, PollingSongResponse,
SongListItem, SongListItem,
) )
from app.song.worker.song_task import download_and_upload_song_by_suno_task_id
from app.utils.logger import get_logger from app.utils.logger import get_logger
from app.utils.pagination import PaginatedResponse from app.utils.pagination import PaginatedResponse
from app.utils.suno import SunoService from app.utils.suno import SunoService
@ -60,7 +58,7 @@ Suno API를 통해 노래 생성을 요청합니다.
## 반환 정보 ## 반환 정보
- **success**: 요청 성공 여부 - **success**: 요청 성공 여부
- **task_id**: 내부 작업 ID (Project/Lyric task_id) - **task_id**: 내부 작업 ID (Project/Lyric task_id)
- **song_id**: Suno API 작업 ID (상태 조회에 사용) - **suno_task_id**: Suno API 작업 ID (상태 조회에 사용)
- **message**: 응답 메시지 - **message**: 응답 메시지
## 사용 예시 ## 사용 예시
@ -75,7 +73,7 @@ POST /song/generate/019123ab-cdef-7890-abcd-ef1234567890
## 참고 ## 참고
- 생성되는 노래는 1 이내 길이입니다. - 생성되는 노래는 1 이내 길이입니다.
- song_id를 사용하여 /status/{song_id} 엔드포인트에서 생성 상태를 확인할 있습니다. - suno_task_id를 사용하여 /status/{suno_task_id} 엔드포인트에서 생성 상태를 확인할 있습니다.
- Song 테이블에 데이터가 저장되며, project_id와 lyric_id가 자동으로 연결됩니다. - Song 테이블에 데이터가 저장되며, project_id와 lyric_id가 자동으로 연결됩니다.
""", """,
response_model=GenerateSongResponse, response_model=GenerateSongResponse,
@ -195,7 +193,7 @@ async def generate_song(
return GenerateSongResponse( return GenerateSongResponse(
success=False, success=False,
task_id=task_id, task_id=task_id,
song_id=None, suno_task_id=None,
message="노래 생성 요청에 실패했습니다.", message="노래 생성 요청에 실패했습니다.",
error_message=str(e), error_message=str(e),
) )
@ -239,7 +237,7 @@ async def generate_song(
return GenerateSongResponse( return GenerateSongResponse(
success=False, success=False,
task_id=task_id, task_id=task_id,
song_id=None, suno_task_id=None,
message="노래 생성 요청에 실패했습니다.", message="노래 생성 요청에 실패했습니다.",
error_message=str(e), error_message=str(e),
) )
@ -275,8 +273,8 @@ async def generate_song(
return GenerateSongResponse( return GenerateSongResponse(
success=True, success=True,
task_id=task_id, task_id=task_id,
song_id=suno_task_id, suno_task_id=suno_task_id,
message="노래 생성 요청이 접수되었습니다. song_id로 상태를 조회하세요.", message="노래 생성 요청이 접수되었습니다. suno_task_id로 상태를 조회하세요.",
error_message=None, error_message=None,
) )
@ -288,170 +286,119 @@ async def generate_song(
return GenerateSongResponse( return GenerateSongResponse(
success=False, success=False,
task_id=task_id, task_id=task_id,
song_id=suno_task_id, suno_task_id=suno_task_id,
message="노래 생성은 요청되었으나 DB 업데이트에 실패했습니다.", message="노래 생성은 요청되었으나 DB 업데이트에 실패했습니다.",
error_message=str(e), error_message=str(e),
) )
@router.get( @router.get(
"/status/{song_id}", "/status/{suno_task_id}",
summary="노래 생성 상태 조회 (Suno API)", summary="노래 생성 상태 조회",
description=""" description="""
Suno API를 통해 노래 생성 작업의 상태를 조회합니다. Suno API를 통해 노래 생성 작업의 상태를 조회합니다.
SUCCESS 상태인 경우 백그라운드에서 MP3 파일을 다운로드하고 Azure Blob Storage에 업로드시작합니다. SUCCESS 상태인 경우 백그라운드에서 MP3 파일을 다운로드하고 Azure Blob Storage에 업로드 Song 테이블을 업데이트합니다.
## 경로 파라미터 ## 경로 파라미터
- **song_id**: 노래 생성 반환된 Suno API 작업 ID (필수) - **suno_task_id**: 노래 생성 반환된 Suno API 작업 ID (필수)
## 반환 정보 ## 반환 정보
- **success**: 조회 성공 여부 - **success**: 조회 성공 여부
- **status**: Suno API 작업 상태 - **status**: 작업 상태 (PENDING, processing, SUCCESS, failed)
- **message**: 상태 메시지 - **message**: 상태 메시지
- **clips**: 생성된 노래 클립 목록 (완료 )
- **raw_response**: Suno API 원본 응답
## 사용 예시 ## 사용 예시
``` ```
GET /song/status/abc123... GET /song/status/abc123...
``` ```
## 상태 값 (Suno API 응답) ## 상태 값
- **PENDING**: Suno API 대기 - **PENDING**: 대기
- **processing**: Suno API에서 노래 생성 - **processing**: 생성
- **SUCCESS**: Suno API 노래 생성 완료 (백그라운드 Blob 업로드 시작) - **SUCCESS**: 생성 완료
- **TEXT_SUCCESS**: Suno API 노래 생성 완료 - **failed**: 생성 실패
- **failed**: Suno API 노래 생성 실패
- **error**: API 조회 오류
## 참고 ## 참고
- 엔드포인트는 Suno API의 상태를 반환합니다 - 스트림 URL: 30-40 생성
- SUCCESS 응답 백그라운드에서 MP3 다운로드 Azure Blob Storage 업로드가 시작됩니다 - 다운로드 URL: 2-3 생성
- 최종 완료 상태는 `/song/download/{task_id}` 엔드포인트에서 확인하세요 - SUCCESS 백그라운드에서 MP3 다운로드 Azure Blob Storage 업로드 Song 테이블 업데이트 진행
- Song 테이블 상태: processing uploading completed - 저장 경로: Azure Blob Storage ({BASE_URL}/{task_id}/song/{store_name}.mp3)
- Song 테이블의 song_result_url에 Blob URL이 저장됩니다
""", """,
response_model=PollingSongResponse, response_model=PollingSongResponse,
responses={ responses={
200: {"description": "상태 조회 성공"}, 200: {"description": "상태 조회 성공"},
500: {"description": "상태 조회 실패"},
}, },
) )
async def get_song_status( async def get_song_status(
song_id: str, suno_task_id: str,
background_tasks: BackgroundTasks,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> PollingSongResponse: ) -> PollingSongResponse:
"""song_id로 노래 생성 작업의 상태를 조회합니다. """suno_task_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로 업데이트합니다.
""" """
suno_task_id = song_id # 임시방편 / 외부 suno 노출 방지 logger.info(f"[get_song_status] START - suno_task_id: {suno_task_id}")
logger.info(f"[get_song_status] START - song_id: {suno_task_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(suno_task_id)
logger.debug(f"[get_song_status] Suno API raw response - song_id: {suno_task_id}, result: {result}")
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 - song_id: {suno_task_id}, status: {parsed_response.status}") logger.info(f"[get_song_status] Suno API response - suno_task_id: {suno_task_id}, status: {parsed_response.status}")
# SUCCESS 상태인 경우 백그라운드에서 MP3 다운로드 및 Blob 업로드 진행 # SUCCESS 상태인 경우 첫 번째 클립 정보를 DB에 직접 저장
if parsed_response.status == "SUCCESS" and result: if parsed_response.status == "SUCCESS" and parsed_response.clips:
# result에서 직접 clips 데이터 추출 # 첫 번째 클립(clips[0])의 audioUrl과 duration 사용
data = result.get("data", {}) first_clip = parsed_response.clips[0]
response_data = data.get("response") or {} audio_url = first_clip.audio_url
clips_data = response_data.get("sunoData") or [] 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}")
if clips_data: if audio_url:
# 첫 번째 클립(clips[0])의 audioUrl과 duration 사용 # suno_task_id로 Song 조회
first_clip = clips_data[0] song_result = await session.execute(
audio_url = first_clip.get("audioUrl") select(Song)
clip_duration = first_clip.get("duration") .where(Song.suno_task_id == suno_task_id)
logger.debug(f"[get_song_status] Using first clip - id: {first_clip.get('id')}, audio_url: {audio_url}, duration: {clip_duration}") .order_by(Song.created_at.desc())
.limit(1)
)
song = song_result.scalar_one_or_none()
if audio_url: if song and song.status != "completed":
# song_id로 Song 조회 # 첫 번째 클립의 audio_url과 duration을 직접 DB에 저장
song_result = await session.execute( song.status = "completed"
select(Song) song.song_result_url = audio_url
.where(Song.suno_task_id == suno_task_id) if clip_duration is not None:
.order_by(Song.created_at.desc()) song.duration = clip_duration
.limit(1) 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}")
song = song_result.scalar_one_or_none() elif song and song.status == "completed":
logger.info(f"[get_song_status] SKIPPED - Song already completed, suno_task_id: {suno_task_id}")
# processing 상태인 경우에만 백그라운드 태스크 실행 (중복 방지)
if song and song.status == "processing":
# store_name 조회
project_result = await session.execute(
select(Project).where(Project.id == song.project_id)
)
project = project_result.scalar_one_or_none()
store_name = project.store_name if project else "song"
# 상태를 uploading으로 변경 (중복 호출 방지) logger.info(f"[get_song_status] SUCCESS - suno_task_id: {suno_task_id}")
song.status = "uploading"
song.suno_audio_id = first_clip.get('id')
await session.commit()
logger.info(f"[get_song_status] Song status changed to uploading - song_id: {suno_task_id}")
# 백그라운드 태스크로 MP3 다운로드 및 Blob 업로드 실행
background_tasks.add_task(
download_and_upload_song_by_suno_task_id,
suno_task_id=suno_task_id,
audio_url=audio_url,
store_name=store_name,
duration=clip_duration,
)
logger.info(f"[get_song_status] Background task scheduled - song_id: {suno_task_id}, store_name: {store_name}")
suno_audio_id = first_clip.get('id')
word_data = await suno_service.get_lyric_timestamp(suno_task_id, suno_audio_id)
lyric_result = await session.execute(
select(Lyric)
.where(Lyric.task_id == song.task_id)
.order_by(Lyric.created_at.desc())
.limit(1)
)
lyric = lyric_result.scalar_one_or_none()
gt_lyric = lyric.lyric_result
lyric_line_list = gt_lyric.split("\n")
sentences = [lyric_line.strip(',. ') for lyric_line in lyric_line_list if lyric_line and lyric_line != "---"]
timestamped_lyrics = suno_service.align_lyrics(word_data, sentences)
# TODO : DB upload timestamped_lyrics
for order_idx, timestamped_lyric in enumerate(timestamped_lyrics):
song_timestamp = SongTimestamp(
suno_audio_id = suno_audio_id,
order_idx = order_idx,
lyric_line = timestamped_lyric["text"],
start_time = timestamped_lyric["start_sec"],
end_time = timestamped_lyric["end_sec"]
)
session.add(song_timestamp)
await session.commit()
elif song and song.status == "uploading":
logger.info(f"[get_song_status] SKIPPED - Song is already uploading, song_id: {suno_task_id}")
elif song and song.status == "completed":
logger.info(f"[get_song_status] SKIPPED - Song already completed, song_id: {suno_task_id}")
logger.info(f"[get_song_status] SUCCESS - song_id: {suno_task_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 - song_id: {song_id}, error: {e}") logger.error(f"[get_song_status] EXCEPTION - suno_task_id: {suno_task_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()}",
) )
@router.get( @router.get(
"/download/{task_id}", "/download/{task_id}",
summary="노래 다운로드 상태 조회 (DB Polling)", summary="노래 생성 URL 조회",
description=""" description="""
task_id를 기반으로 Song 테이블의 상태를 조회하고, task_id를 기반으로 Song 테이블의 상태를 조회하고,
completed인 경우 Project 정보와 노래 URL을 반환합니다. completed인 경우 Project 정보와 노래 URL을 반환합니다.
@ -461,38 +408,31 @@ completed인 경우 Project 정보와 노래 URL을 반환합니다.
## 반환 정보 ## 반환 정보
- **success**: 조회 성공 여부 - **success**: 조회 성공 여부
- **status**: DB 처리 상태 (processing, uploading, completed, failed, not_found, error) - **status**: 처리 상태 (processing, completed, failed, not_found)
- **message**: 응답 메시지 - **message**: 응답 메시지
- **store_name**: 업체명 (completed ) - **store_name**: 업체명
- **region**: 지역명 (completed ) - **region**: 지역명
- **detail_region_info**: 상세 지역 정보 (completed ) - **detail_region_info**: 상세 지역 정보
- **task_id**: 작업 고유 식별자 - **task_id**: 작업 고유 식별자
- **language**: 언어 (completed ) - **language**: 언어
- **song_result_url**: 노래 결과 URL (completed , Azure Blob Storage URL) - **song_result_url**: 노래 결과 URL (completed , Azure Blob Storage URL)
- **created_at**: 생성 일시 (completed ) - **created_at**: 생성 일시
- **error_message**: 에러 메시지 (실패 )
## 사용 예시 ## 사용 예시
``` ```
GET /song/download/019123ab-cdef-7890-abcd-ef1234567890 GET /song/download/019123ab-cdef-7890-abcd-ef1234567890
``` ```
## 상태 값 (DB 상태)
- **processing**: Suno API에서 노래 생성
- **uploading**: MP3 다운로드 Azure Blob 업로드
- **completed**: 모든 작업 완료, Blob URL 사용 가능
- **failed**: 노래 생성 또는 업로드 실패
- **not_found**: task_id에 해당하는 Song 없음
- **error**: 조회 오류 발생
## 참고 ## 참고
- 엔드포인트는 DB의 Song 테이블 상태를 반환합니다 - processing 상태인 경우 song_result_url은 null입니다.
- completed 상태인 경우 Project 정보와 함께 song_result_url (Azure Blob URL) 반환합니다 - completed 상태인 경우 Project 정보와 함께 song_result_url (Azure Blob URL) 반환합니다.
- song_result_url 형식: {AZURE_BLOB_BASE_URL}/{task_id}/song/{store_name}.mp3 - song_result_url 형식: {AZURE_BLOB_BASE_URL}/{task_id}/song/{store_name}.mp3
""", """,
response_model=DownloadSongResponse, response_model=DownloadSongResponse,
responses={ responses={
200: {"description": "조회 성공 (모든 상태에서 200 반환)"}, 200: {"description": "조회 성공"},
404: {"description": "Song을 찾을 수 없음"},
500: {"description": "조회 실패"},
}, },
) )
async def download_song( async def download_song(
@ -532,16 +472,6 @@ async def download_song(
task_id=task_id, task_id=task_id,
) )
# uploading 상태인 경우
if song.status == "uploading":
logger.info(f"[download_song] UPLOADING - task_id: {task_id}")
return DownloadSongResponse(
success=True,
status="uploading",
message="노래 파일을 업로드 중입니다.",
task_id=task_id,
)
# failed 상태인 경우 # failed 상태인 경우
if song.status == "failed": if song.status == "failed":
logger.warning(f"[download_song] FAILED - task_id: {task_id}") logger.warning(f"[download_song] FAILED - task_id: {task_id}")
@ -614,6 +544,7 @@ GET /songs/?page=1&page_size=10
response_model=PaginatedResponse[SongListItem], response_model=PaginatedResponse[SongListItem],
responses={ responses={
200: {"description": "노래 목록 조회 성공"}, 200: {"description": "노래 목록 조회 성공"},
500: {"description": "조회 실패"},
}, },
) )
async def get_songs( async def get_songs(

View File

@ -1,7 +1,7 @@
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING, List, Optional from typing import TYPE_CHECKING, List, Optional
from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, Text, func from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database.session import Base from app.database.session import Base
@ -25,7 +25,7 @@ class Song(Base):
lyric_id: 연결된 Lyric의 id (외래키) lyric_id: 연결된 Lyric의 id (외래키)
task_id: 노래 생성 작업의 고유 식별자 (UUID 형식) task_id: 노래 생성 작업의 고유 식별자 (UUID 형식)
suno_task_id: Suno API 작업 고유 식별자 (선택) suno_task_id: Suno API 작업 고유 식별자 (선택)
status: 처리 상태 (processing, uploading, completed, failed) status: 처리 상태 (pending, processing, completed, failed )
song_prompt: 노래 생성에 사용된 프롬프트 song_prompt: 노래 생성에 사용된 프롬프트
song_result_url: 생성 결과 URL (선택) song_result_url: 생성 결과 URL (선택)
language: 출력 언어 language: 출력 언어
@ -82,16 +82,10 @@ class Song(Base):
comment="Suno API 작업 고유 식별자", comment="Suno API 작업 고유 식별자",
) )
suno_audio_id: Mapped[Optional[str]] = mapped_column(
String(64),
nullable=True,
comment="Suno 첫번째 노래의 고유 식별자",
)
status: Mapped[str] = mapped_column( status: Mapped[str] = mapped_column(
String(50), String(50),
nullable=False, nullable=False,
comment="처리 상태 (processing, uploading, completed, failed)", comment="처리 상태 (processing, completed, failed)",
) )
song_prompt: Mapped[str] = mapped_column( song_prompt: Mapped[str] = mapped_column(
@ -156,92 +150,3 @@ class Song(Base):
f"status='{self.status}'" f"status='{self.status}'"
f")>" f")>"
) )
class SongTimestamp(Base):
"""
노래 타임스탬프 테이블
노래의 가사별 시작/종료 시간 정보를 저장합니다.
Suno API에서 반환된 타임스탬프 데이터를 기반으로 생성됩니다.
Attributes:
id: 고유 식별자 (자동 증가)
suno_audio_id: 가사의 원본 오디오 ID
order_idx: 오디오 내에서 가사의 순서
lyric_line: 가사 줄의 내용
start_time: 가사 시작 시점 ()
end_time: 가사 종료 시점 ()
created_at: 생성 일시 (자동 설정)
"""
__tablename__ = "song_timestamp"
__table_args__ = (
{
"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="고유 식별자",
)
suno_audio_id: Mapped[str] = mapped_column(
String(64),
nullable=False,
index=True,
comment="가사의 원본 오디오 ID",
)
order_idx: Mapped[int] = mapped_column(
Integer,
nullable=False,
comment="오디오 내에서 가사의 순서",
)
lyric_line: Mapped[str] = mapped_column(
Text,
nullable=False,
comment="가사 한 줄의 내용",
)
start_time: Mapped[float] = mapped_column(
Float,
nullable=False,
comment="가사 시작 시점 (초)",
)
end_time: Mapped[float] = mapped_column(
Float,
nullable=False,
comment="가사 종료 시점 (초)",
)
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
server_default=func.now(),
comment="생성 일시",
)
def __repr__(self) -> str:
def truncate(value: str | None, max_len: int = 10) -> str:
if value is None:
return "None"
return (value[:max_len] + "...") if len(value) > max_len else value
return (
f"<SongTimestamp("
f"id={self.id}, "
f"suno_audio_id='{truncate(self.suno_audio_id)}', "
f"order_idx={self.order_idx}, "
f"start_time={self.start_time}, "
f"end_time={self.end_time}"
f")>"
)

View File

@ -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 Dict, List, Optional from typing import Any, 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",
"song_id": "abc123...", "suno_task_id": "abc123...",
"message": "노래 생성 요청이 접수되었습니다. song_id로 상태를 조회하세요.", "message": "노래 생성 요청이 접수되었습니다. suno_task_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",
"song_id": null, "suno_task_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)")
song_id: Optional[str] = Field(None, description="Suno API 작업 ID") suno_task_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/{song_id} 엔드포인트 사용. 현재 사용되지 않음. GET /song/status/{suno_task_id} 엔드포인트 사용.
Example Request: Example Request:
{ {
@ -114,39 +114,32 @@ class SongClipData(BaseModel):
class PollingSongResponse(BaseModel): class PollingSongResponse(BaseModel):
"""노래 생성 상태 조회 응답 스키마 (Suno API) """노래 생성 상태 조회 응답 스키마
Usage: Usage:
GET /song/status/{song_id} GET /song/status/{suno_task_id}
Suno API 작업 상태를 조회합니다. Suno API 작업 상태를 조회합니다.
Note: Note:
상태 (Suno API 응답): 상태 :
- PENDING: Suno API 대기 - PENDING: 대기
- processing: Suno API에서 노래 생성 - processing: 생성
- SUCCESS: Suno API 노래 생성 완료 (백그라운드 Blob 업로드 시작) - SUCCESS / TEXT_SUCCESS / complete: 생성 완료
- TEXT_SUCCESS: Suno API 노래 생성 완료 - failed: 생성 실패
- failed: Suno API 노래 생성 실패
- error: API 조회 오류 - error: API 조회 오류
SUCCESS 상태 : SUCCESS 상태 :
- 백그라운드에서 MP3 파일 다운로드 Azure Blob 업로드 시작 - 백그라운드에서 MP3 파일 다운로드 시작
- Song 테이블의 status가 uploading으로 변경 - Song 테이블의 status를 completed로 업데이트
- 업로드 완료 status가 completed로 변경, song_result_url에 Blob URL 저장 - song_result_url에 로컬 파일 경로 저장
Example Response (Pending):
{
"success": true,
"status": "PENDING",
"message": "노래 생성 대기 중입니다.",
"error_message": null
}
Example Response (Processing): Example Response (Processing):
{ {
"success": true, "success": true,
"status": "processing", "status": "processing",
"message": "노래를 생성하고 있습니다.", "message": "노래를 생성하고 있습니다.",
"clips": null,
"raw_response": {...},
"error_message": null "error_message": null
} }
@ -155,6 +148,18 @@ 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
} }
@ -163,15 +168,19 @@ class PollingSongResponse(BaseModel):
"success": false, "success": false,
"status": "error", "status": "error",
"message": "상태 조회에 실패했습니다.", "message": "상태 조회에 실패했습니다.",
"clips": null,
"raw_response": null,
"error_message": "ConnectionError: ..." "error_message": "ConnectionError: ..."
} }
""" """
success: bool = Field(..., description="조회 성공 여부") success: bool = Field(..., description="조회 성공 여부")
status: Optional[str] = Field( status: Optional[str] = Field(
None, description="Suno API 작업 상태 (PENDING, processing, SUCCESS, TEXT_SUCCESS, failed, error)" 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="에러 메시지 (실패 시)")
@ -201,18 +210,17 @@ class SongListItem(BaseModel):
class DownloadSongResponse(BaseModel): class DownloadSongResponse(BaseModel):
"""노래 다운로드 응답 스키마 (DB Polling) """노래 다운로드 응답 스키마
Usage: Usage:
GET /song/download/{task_id} GET /song/download/{task_id}
DB의 Song 테이블 상태를 조회하고 완료 Project 정보와 노래 URL을 반환합니다. Polls for song completion and returns project info with song URL.
Note: Note:
상태 (DB 상태): 상태 :
- processing: Suno API에서 노래 생성 (song_result_url은 null) - processing: 노래 생성 진행 (song_result_url은 null)
- uploading: MP3 다운로드 Azure Blob 업로드 (song_result_url은 null) - completed: 노래 생성 완료 (song_result_url 포함)
- completed: 모든 작업 완료 (song_result_url에 Azure Blob URL 포함) - failed: 노래 생성 실패
- failed: 노래 생성 또는 업로드 실패
- not_found: task_id에 해당하는 Song 없음 - not_found: task_id에 해당하는 Song 없음
- error: 조회 오류 발생 - error: 조회 오류 발생
@ -231,21 +239,6 @@ class DownloadSongResponse(BaseModel):
"error_message": null "error_message": null
} }
Example Response (Uploading):
{
"success": true,
"status": "uploading",
"message": "노래 파일을 업로드 중입니다.",
"store_name": null,
"region": null,
"detail_region_info": null,
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
"language": null,
"song_result_url": null,
"created_at": null,
"error_message": null
}
Example Response (Completed): Example Response (Completed):
{ {
"success": true, "success": true,
@ -256,7 +249,7 @@ class DownloadSongResponse(BaseModel):
"detail_region_info": "군산 신흥동 말랭이 마을", "detail_region_info": "군산 신흥동 말랭이 마을",
"task_id": "019123ab-cdef-7890-abcd-ef1234567890", "task_id": "019123ab-cdef-7890-abcd-ef1234567890",
"language": "Korean", "language": "Korean",
"song_result_url": "https://blob.azure.com/.../song.mp3", "song_result_url": "http://localhost:8000/media/2025-01-15/스테이머뭄.mp3",
"created_at": "2025-01-15T12:00:00", "created_at": "2025-01-15T12:00:00",
"error_message": null "error_message": null
} }
@ -277,8 +270,8 @@ class DownloadSongResponse(BaseModel):
} }
""" """
success: bool = Field(..., description="조회 성공 여부") success: bool = Field(..., description="다운로드 성공 여부")
status: str = Field(..., description="DB 처리 상태 (processing, uploading, completed, failed, not_found, error)") status: str = Field(..., description="처리 상태 (processing, completed, failed, not_found, error)")
message: str = Field(..., description="응답 메시지") message: str = Field(..., description="응답 메시지")
store_name: Optional[str] = Field(None, description="업체명") store_name: Optional[str] = Field(None, description="업체명")
region: Optional[str] = Field(None, description="지역명") region: Optional[str] = Field(None, description="지역명")

View File

@ -152,7 +152,7 @@ async def download_and_save_song(
# 프론트엔드에서 접근 가능한 URL 생성 # 프론트엔드에서 접근 가능한 URL 생성
relative_path = f"/media/song/{today}/{unique_id}/{file_name}" relative_path = f"/media/song/{today}/{unique_id}/{file_name}"
base_url = f"{prj_settings.PROJECT_DOMAIN}" base_url = f"http://{prj_settings.PROJECT_DOMAIN}"
file_url = f"{base_url}{relative_path}" file_url = f"{base_url}{relative_path}"
logger.info(f"[download_and_save_song] URL generated - task_id: {task_id}, url: {file_url}") logger.info(f"[download_and_save_song] URL generated - task_id: {task_id}, url: {file_url}")
@ -173,6 +173,90 @@ async def download_and_save_song(
await _update_song_status(task_id, "failed") await _update_song_status(task_id, "failed")
async def download_and_upload_song_to_blob(
task_id: str,
audio_url: str,
store_name: str,
) -> None:
"""백그라운드에서 노래를 다운로드하고 Azure Blob Storage에 업로드한 뒤 Song 테이블을 업데이트합니다.
Args:
task_id: 프로젝트 task_id
audio_url: 다운로드할 오디오 URL
store_name: 저장할 파일명에 사용할 업체명
"""
logger.info(f"[download_and_upload_song_to_blob] START - task_id: {task_id}, store_name: {store_name}")
temp_file_path: Path | None = None
try:
# 파일명에 사용할 수 없는 문자 제거
safe_store_name = "".join(
c for c in store_name if c.isalnum() or c in (" ", "_", "-")
).strip()
safe_store_name = safe_store_name or "song"
file_name = f"{safe_store_name}.mp3"
# 임시 저장 경로 생성
temp_dir = Path("media") / "temp" / task_id
temp_dir.mkdir(parents=True, exist_ok=True)
temp_file_path = temp_dir / file_name
logger.info(f"[download_and_upload_song_to_blob] Temp directory created - path: {temp_file_path}")
# 오디오 파일 다운로드
logger.info(f"[download_and_upload_song_to_blob] Downloading audio - task_id: {task_id}, url: {audio_url}")
content = await _download_audio(audio_url, task_id)
async with aiofiles.open(str(temp_file_path), "wb") as f:
await f.write(content)
logger.info(f"[download_and_upload_song_to_blob] File downloaded - task_id: {task_id}, path: {temp_file_path}")
# Azure Blob Storage에 업로드
uploader = AzureBlobUploader(task_id=task_id)
upload_success = await uploader.upload_music(file_path=str(temp_file_path))
if not upload_success:
raise Exception("Azure Blob Storage 업로드 실패")
# SAS 토큰이 제외된 public_url 사용
blob_url = uploader.public_url
logger.info(f"[download_and_upload_song_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}")
# Song 테이블 업데이트
await _update_song_status(task_id, "completed", blob_url)
logger.info(f"[download_and_upload_song_to_blob] SUCCESS - task_id: {task_id}")
except httpx.HTTPError as e:
logger.error(f"[download_and_upload_song_to_blob] DOWNLOAD ERROR - task_id: {task_id}, error: {e}", exc_info=True)
await _update_song_status(task_id, "failed")
except SQLAlchemyError as e:
logger.error(f"[download_and_upload_song_to_blob] DB ERROR - task_id: {task_id}, error: {e}", exc_info=True)
await _update_song_status(task_id, "failed")
except Exception as e:
logger.error(f"[download_and_upload_song_to_blob] EXCEPTION - task_id: {task_id}, error: {e}", exc_info=True)
await _update_song_status(task_id, "failed")
finally:
# 임시 파일 삭제
if temp_file_path and temp_file_path.exists():
try:
temp_file_path.unlink()
logger.info(f"[download_and_upload_song_to_blob] Temp file deleted - path: {temp_file_path}")
except Exception as e:
logger.warning(f"[download_and_upload_song_to_blob] Failed to delete temp file: {e}")
# 임시 디렉토리 삭제 시도
temp_dir = Path("media") / "temp" / task_id
if temp_dir.exists():
try:
temp_dir.rmdir()
except Exception:
pass # 디렉토리가 비어있지 않으면 무시
async def download_and_upload_song_by_suno_task_id( async def download_and_upload_song_by_suno_task_id(
suno_task_id: str, suno_task_id: str,
audio_url: str, audio_url: str,

View File

@ -4,22 +4,18 @@
카카오 로그인, 토큰 갱신, 로그아웃, 정보 조회 엔드포인트를 제공합니다. 카카오 로그인, 토큰 갱신, 로그아웃, 정보 조회 엔드포인트를 제공합니다.
""" """
import logging
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, Header, Request, status from fastapi import APIRouter, Depends, Header, Request, status
from fastapi.responses import RedirectResponse, Response from fastapi.responses import Response
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from config import prj_settings
from app.database.session import get_session from app.database.session import get_session
logger = logging.getLogger(__name__)
from app.user.dependencies import get_current_user from app.user.dependencies import get_current_user
from app.user.models import User from app.user.models import User
from app.user.schemas.user_schema import ( from app.user.schemas.user_schema import (
AccessTokenResponse, AccessTokenResponse,
KakaoCodeRequest, KakaoCallbackRequest,
KakaoLoginResponse, KakaoLoginResponse,
LoginResponse, LoginResponse,
RefreshTokenRequest, RefreshTokenRequest,
@ -43,94 +39,30 @@ async def kakao_login() -> KakaoLoginResponse:
프론트엔드에서 URL로 사용자를 리다이렉트하면 프론트엔드에서 URL로 사용자를 리다이렉트하면
카카오 로그인 페이지가 표시됩니다. 카카오 로그인 페이지가 표시됩니다.
""" """
logger.info("[ROUTER] 카카오 로그인 URL 요청")
auth_url = kakao_client.get_authorization_url() auth_url = kakao_client.get_authorization_url()
logger.debug(f"[ROUTER] 카카오 인증 URL 생성 완료 - auth_url: {auth_url}")
return KakaoLoginResponse(auth_url=auth_url) return KakaoLoginResponse(auth_url=auth_url)
@router.get( @router.post(
"/kakao/callback", "/kakao/callback",
response_model=LoginResponse,
summary="카카오 로그인 콜백", summary="카카오 로그인 콜백",
description="카카오 인가 코드를 받아 로그인/가입을 처리하고 프론트엔드로 리다이렉트합니다.", description="카카오 인가 코드를 받아 로그인/가입을 처리하고 JWT 토큰을 발급합니다.",
) )
async def kakao_callback( async def kakao_callback(
request: Request, request: Request,
code: str, body: KakaoCallbackRequest,
session: AsyncSession = Depends(get_session),
user_agent: Optional[str] = Header(None, alias="User-Agent"),
) -> RedirectResponse:
"""
카카오 로그인 콜백
카카오 로그인 성공 발급받은 인가 코드로
JWT 토큰을 발급하고 프론트엔드로 리다이렉트합니다.
신규 사용자인 경우 자동으로 회원가입이 처리됩니다.
"""
logger.info(f"[ROUTER] 카카오 콜백 수신 - code: {code[:20]}...")
# 클라이언트 IP 추출
ip_address = request.client.host if request.client else None
# X-Forwarded-For 헤더 확인 (프록시/로드밸런서 뒤에 있는 경우)
forwarded_for = request.headers.get("X-Forwarded-For")
if forwarded_for:
ip_address = forwarded_for.split(",")[0].strip()
logger.debug(f"[ROUTER] 클라이언트 정보 - ip: {ip_address}, user_agent: {user_agent}")
result = await auth_service.kakao_login(
code=code,
session=session,
user_agent=user_agent,
ip_address=ip_address,
)
# 프론트엔드로 토큰과 함께 리다이렉트
redirect_url = (
f"https://{prj_settings.PROJECT_DOMAIN}"
f"?access_token={result.access_token}"
f"&refresh_token={result.refresh_token}"
)
logger.info(f"[ROUTER] 카카오 콜백 완료, 프론트엔드로 리다이렉트 - redirect_url: {redirect_url[:50]}...")
return RedirectResponse(url=redirect_url, status_code=302)
@router.post(
"/kakao/verify",
response_model=LoginResponse,
summary="카카오 인가 코드 검증 및 토큰 발급",
description="""
프론트엔드에서 카카오 로그인 받은 인가 코드를 검증하고 JWT 토큰을 발급합니다.
## 사용 시나리오
1. 프론트엔드가 카카오 로그인 완료 인가 코드(code) 받음
2. 프론트엔드가 엔드포인트에 code를 POST로 전달
3. 서버가 카카오 서버에 code 검증 사용자 정보 조회
4. JWT 토큰 발급 사용자 정보 반환
## 응답
- 신규 사용자인 경우 `user.is_new_user` `true` 반환됩니다.
- `redirect_url` 로그인 이동할 프론트엔드 URL입니다.
""",
)
async def kakao_verify(
request: Request,
body: KakaoCodeRequest,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
user_agent: Optional[str] = Header(None, alias="User-Agent"), user_agent: Optional[str] = Header(None, alias="User-Agent"),
) -> LoginResponse: ) -> LoginResponse:
""" """
카카오 인가 코드 검증 토큰 발급 카카오 로그인 콜백
프론트엔드가 카카오 콜백에서 받은 인가 코드를 전달하면 카카오 로그인 성공 발급받은 인가 코드로
카카오 서버에서 검증 JWT 토큰을 발급합니다. JWT 토큰을 발급합니다.
신규 사용자인 경우 자동으로 회원가입이 처리됩니다. 신규 사용자인 경우 자동으로 회원가입이 처리됩니다.
""" """
logger.info(f"[ROUTER] 카카오 인가 코드 검증 요청 - code: {body.code[:20]}...")
# 클라이언트 IP 추출 # 클라이언트 IP 추출
ip_address = request.client.host if request.client else None ip_address = request.client.host if request.client else None
@ -139,18 +71,13 @@ async def kakao_verify(
if forwarded_for: if forwarded_for:
ip_address = forwarded_for.split(",")[0].strip() ip_address = forwarded_for.split(",")[0].strip()
logger.debug(f"[ROUTER] 클라이언트 정보 - ip: {ip_address}, user_agent: {user_agent}") return await auth_service.kakao_login(
result = await auth_service.kakao_login(
code=body.code, code=body.code,
session=session, session=session,
user_agent=user_agent, user_agent=user_agent,
ip_address=ip_address, ip_address=ip_address,
) )
logger.info(f"[ROUTER] 카카오 인가 코드 검증 완료 - user_id: {result.user.id}, is_new_user: {result.user.is_new_user}")
return result
@router.post( @router.post(
"/refresh", "/refresh",

View File

@ -7,8 +7,7 @@ User 모듈 SQLAlchemy 모델 정의
from datetime import date, datetime from datetime import date, datetime
from typing import TYPE_CHECKING, List, Optional from typing import TYPE_CHECKING, List, Optional
from sqlalchemy import BigInteger, Boolean, Date, DateTime, ForeignKey, Index, Integer, String, Text, func from sqlalchemy import BigInteger, Boolean, Date, DateTime, ForeignKey, Index, Integer, String, func
from sqlalchemy.dialects.mysql import JSON
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database.session import Base from app.database.session import Base
@ -247,18 +246,6 @@ class User(Base):
lazy="selectin", lazy="selectin",
) )
# ==========================================================================
# SocialAccount 1:N 관계
# ==========================================================================
# 한 사용자는 여러 소셜 계정을 연동할 수 있음 (YouTube, Instagram, Facebook)
# ==========================================================================
social_accounts: Mapped[List["SocialAccount"]] = relationship(
"SocialAccount",
back_populates="user",
cascade="all, delete-orphan",
lazy="selectin",
)
def __repr__(self) -> str: def __repr__(self) -> str:
return ( return (
f"<User(" f"<User("
@ -386,179 +373,3 @@ class RefreshToken(Base):
f"expires_at={self.expires_at}" f"expires_at={self.expires_at}"
f")>" f")>"
) )
class SocialAccount(Base):
"""
소셜 계정 연동 테이블
사용자가 연동한 외부 소셜 플랫폼 계정 정보를 저장합니다.
YouTube, Instagram, Facebook 계정 연동을 지원합니다.
Attributes:
id: 고유 식별자 (자동 증가)
user_id: 사용자 외래키 (User.id 참조)
platform: 플랫폼 구분 (youtube, instagram, facebook)
access_token: OAuth 액세스 토큰
refresh_token: OAuth 리프레시 토큰 (선택)
token_expires_at: 토큰 만료 일시
scope: 허용된 권한 범위
platform_user_id: 플랫폼 사용자 고유 ID
platform_username: 플랫폼 사용자명/핸들
platform_data: 플랫폼별 추가 정보 (JSON)
is_active: 연동 활성화 상태
connected_at: 연동 일시
updated_at: 정보 수정 일시
플랫폼별 platform_data 예시:
- YouTube: {"channel_id": "UC...", "channel_title": "채널명"}
- Instagram: {"business_account_id": "...", "facebook_page_id": "..."}
- Facebook: {"page_id": "...", "page_access_token": "..."}
Relationships:
user: 연동된 사용자 (User 테이블 참조)
"""
__tablename__ = "social_account"
__table_args__ = (
Index("idx_social_account_user_id", "user_id"),
Index("idx_social_account_platform", "platform"),
Index("idx_social_account_is_active", "is_active"),
Index(
"uq_user_platform_account",
"user_id",
"platform",
"platform_user_id",
unique=True,
),
{
"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="고유 식별자",
)
user_id: Mapped[int] = mapped_column(
BigInteger,
ForeignKey("user.id", ondelete="CASCADE"),
nullable=False,
comment="사용자 외래키 (User.id 참조)",
)
# ==========================================================================
# 플랫폼 구분
# ==========================================================================
platform: Mapped[str] = mapped_column(
String(20),
nullable=False,
comment="플랫폼 구분 (youtube, instagram, facebook)",
)
# ==========================================================================
# OAuth 토큰 정보
# ==========================================================================
access_token: Mapped[str] = mapped_column(
Text,
nullable=False,
comment="OAuth 액세스 토큰",
)
refresh_token: Mapped[Optional[str]] = mapped_column(
Text,
nullable=True,
comment="OAuth 리프레시 토큰 (플랫폼에 따라 선택적)",
)
token_expires_at: Mapped[Optional[datetime]] = mapped_column(
DateTime,
nullable=True,
comment="토큰 만료 일시",
)
scope: Mapped[Optional[str]] = mapped_column(
Text,
nullable=True,
comment="허용된 권한 범위 (OAuth scope)",
)
# ==========================================================================
# 플랫폼 계정 식별 정보
# ==========================================================================
platform_user_id: Mapped[str] = mapped_column(
String(100),
nullable=False,
comment="플랫폼 내 사용자 고유 ID",
)
platform_username: Mapped[Optional[str]] = mapped_column(
String(100),
nullable=True,
comment="플랫폼 내 사용자명/핸들 (@username)",
)
# ==========================================================================
# 플랫폼별 추가 정보 (JSON)
# ==========================================================================
platform_data: Mapped[Optional[dict]] = mapped_column(
JSON,
nullable=True,
comment="플랫폼별 추가 정보 (채널ID, 페이지ID 등)",
)
# ==========================================================================
# 연동 상태
# ==========================================================================
is_active: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=True,
comment="연동 활성화 상태 (비활성화 시 사용 중지)",
)
# ==========================================================================
# 시간 정보
# ==========================================================================
connected_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
server_default=func.now(),
comment="연동 일시",
)
updated_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
server_default=func.now(),
onupdate=func.now(),
comment="정보 수정 일시",
)
# ==========================================================================
# User 관계
# ==========================================================================
user: Mapped["User"] = relationship(
"User",
back_populates="social_accounts",
)
def __repr__(self) -> str:
return (
f"<SocialAccount("
f"id={self.id}, "
f"user_id={self.user_id}, "
f"platform='{self.platform}', "
f"platform_username='{self.platform_username}', "
f"is_active={self.is_active}"
f")>"
)

View File

@ -1,6 +1,6 @@
from app.user.schemas.user_schema import ( from app.user.schemas.user_schema import (
AccessTokenResponse, AccessTokenResponse,
KakaoCodeRequest, KakaoCallbackRequest,
KakaoLoginResponse, KakaoLoginResponse,
KakaoTokenResponse, KakaoTokenResponse,
KakaoUserInfo, KakaoUserInfo,
@ -13,7 +13,7 @@ from app.user.schemas.user_schema import (
__all__ = [ __all__ = [
"AccessTokenResponse", "AccessTokenResponse",
"KakaoCodeRequest", "KakaoCallbackRequest",
"KakaoLoginResponse", "KakaoLoginResponse",
"KakaoTokenResponse", "KakaoTokenResponse",
"KakaoUserInfo", "KakaoUserInfo",

View File

@ -27,8 +27,8 @@ class KakaoLoginResponse(BaseModel):
} }
class KakaoCodeRequest(BaseModel): class KakaoCallbackRequest(BaseModel):
"""카카오 인가 코드 검증 요청 (프론트엔드에서 전달)""" """카카오 콜백 요청 (인가 코드)"""
code: str = Field(..., min_length=1, description="카카오 인가 코드") code: str = Field(..., min_length=1, description="카카오 인가 코드")
@ -163,7 +163,6 @@ class LoginResponse(BaseModel):
token_type: str = Field(default="Bearer", description="토큰 타입") token_type: str = Field(default="Bearer", description="토큰 타입")
expires_in: int = Field(..., description="액세스 토큰 만료 시간 (초)") expires_in: int = Field(..., description="액세스 토큰 만료 시간 (초)")
user: UserBriefResponse = Field(..., description="사용자 정보") user: UserBriefResponse = Field(..., description="사용자 정보")
redirect_url: str = Field(..., description="로그인 후 리다이렉트할 프론트엔드 URL")
model_config = { model_config = {
"json_schema_extra": { "json_schema_extra": {
@ -178,8 +177,7 @@ class LoginResponse(BaseModel):
"email": "user@kakao.com", "email": "user@kakao.com",
"profile_image_url": "https://k.kakaocdn.net/dn/.../profile.jpg", "profile_image_url": "https://k.kakaocdn.net/dn/.../profile.jpg",
"is_new_user": False "is_new_user": False
}, }
"redirect_url": "http://localhost:3000"
} }
} }
} }

View File

@ -4,17 +4,12 @@
카카오 로그인, 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
from config import prj_settings
logger = logging.getLogger(__name__)
from app.user.exceptions import ( from app.user.exceptions import (
InvalidTokenError, InvalidTokenError,
TokenExpiredError, TokenExpiredError,
@ -71,36 +66,24 @@ class AuthService:
Returns: Returns:
LoginResponse: 토큰 사용자 정보 LoginResponse: 토큰 사용자 정보
""" """
logger.info(f"[AUTH] 카카오 로그인 시작 - code: {code[:20]}..., ip: {ip_address}")
# 1. 카카오 토큰 획득 # 1. 카카오 토큰 획득
logger.info("[AUTH] 1단계: 카카오 토큰 획득 시작")
kakao_token = await kakao_client.get_access_token(code) kakao_token = await kakao_client.get_access_token(code)
logger.debug(f"[AUTH] 카카오 토큰 획득 완료 - token_type: {kakao_token.token_type}")
# 2. 카카오 사용자 정보 조회 # 2. 카카오 사용자 정보 조회
logger.info("[AUTH] 2단계: 카카오 사용자 정보 조회 시작")
kakao_user_info = await kakao_client.get_user_info(kakao_token.access_token) kakao_user_info = await kakao_client.get_user_info(kakao_token.access_token)
logger.debug(f"[AUTH] 카카오 사용자 정보 조회 완료 - kakao_id: {kakao_user_info.id}")
# 3. 사용자 조회 또는 생성 # 3. 사용자 조회 또는 생성
logger.info("[AUTH] 3단계: 사용자 조회/생성 시작")
user, is_new_user = await self._get_or_create_user(kakao_user_info, session) user, is_new_user = await self._get_or_create_user(kakao_user_info, session)
logger.info(f"[AUTH] 사용자 처리 완료 - user_id: {user.id}, is_new_user: {is_new_user}")
# 4. 비활성화 계정 체크 # 4. 비활성화 계정 체크
if not user.is_active: if not user.is_active:
logger.error(f"[AUTH] 비활성화 계정 접근 시도 - user_id: {user.id}")
raise UserInactiveError() raise UserInactiveError()
# 5. JWT 토큰 생성 # 5. JWT 토큰 생성
logger.info("[AUTH] 5단계: JWT 토큰 생성 시작")
access_token = create_access_token(user.id) access_token = create_access_token(user.id)
refresh_token = create_refresh_token(user.id) refresh_token = create_refresh_token(user.id)
logger.debug(f"[AUTH] JWT 토큰 생성 완료 - user_id: {user.id}")
# 6. 리프레시 토큰 DB 저장 # 6. 리프레시 토큰 DB 저장
logger.info("[AUTH] 6단계: 리프레시 토큰 저장 시작")
await self._save_refresh_token( await self._save_refresh_token(
user_id=user.id, user_id=user.id,
token=refresh_token, token=refresh_token,
@ -108,16 +91,11 @@ class AuthService:
user_agent=user_agent, user_agent=user_agent,
ip_address=ip_address, ip_address=ip_address,
) )
logger.debug(f"[AUTH] 리프레시 토큰 저장 완료 - user_id: {user.id}")
# 7. 마지막 로그인 시간 업데이트 # 7. 마지막 로그인 시간 업데이트
user.last_login_at = datetime.now(timezone.utc) user.last_login_at = datetime.now(timezone.utc)
await session.commit() await session.commit()
redirect_url = f"https://{prj_settings.PROJECT_DOMAIN}"
logger.info(f"[AUTH] 카카오 로그인 완료 - user_id: {user.id}, redirect_url: {redirect_url}")
logger.debug(f"[AUTH] 응답 토큰 정보 - access_token: {access_token[:30]}..., refresh_token: {refresh_token[:30]}...")
return LoginResponse( return LoginResponse(
access_token=access_token, access_token=access_token,
refresh_token=refresh_token, refresh_token=refresh_token,
@ -130,7 +108,6 @@ class AuthService:
profile_image_url=user.profile_image_url, profile_image_url=user.profile_image_url,
is_new_user=is_new_user, is_new_user=is_new_user,
), ),
redirect_url=redirect_url,
) )
async def refresh_tokens( async def refresh_tokens(
@ -244,19 +221,14 @@ 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
@ -267,7 +239,6 @@ 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,
@ -278,7 +249,6 @@ 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(

View File

@ -4,14 +4,10 @@
카카오 로그인 인증 흐름을 처리하는 클라이언트입니다. 카카오 로그인 인증 흐름을 처리하는 클라이언트입니다.
""" """
import logging
import aiohttp import aiohttp
from config import kakao_settings from config import kakao_settings
logger = logging.getLogger(__name__)
from app.user.exceptions import KakaoAPIError, KakaoAuthFailedError from app.user.exceptions import KakaoAPIError, KakaoAuthFailedError
from app.user.schemas.user_schema import KakaoTokenResponse, KakaoUserInfo from app.user.schemas.user_schema import KakaoTokenResponse, KakaoUserInfo
@ -43,15 +39,12 @@ class KakaoOAuthClient:
Returns: Returns:
카카오 OAuth 인증 페이지 URL 카카오 OAuth 인증 페이지 URL
""" """
auth_url = ( return (
f"{self.AUTH_URL}" f"{self.AUTH_URL}"
f"?client_id={self.client_id}" f"?client_id={self.client_id}"
f"&redirect_uri={self.redirect_uri}" f"&redirect_uri={self.redirect_uri}"
f"&response_type=code" f"&response_type=code"
) )
logger.info(f"[KAKAO] 인증 URL 생성 - redirect_uri: {self.redirect_uri}")
logger.debug(f"[KAKAO] 인증 URL 상세 - auth_url: {auth_url}")
return auth_url
async def get_access_token(self, code: str) -> KakaoTokenResponse: async def get_access_token(self, code: str) -> KakaoTokenResponse:
""" """
@ -67,7 +60,6 @@ class KakaoOAuthClient:
KakaoAuthFailedError: 토큰 발급 실패 KakaoAuthFailedError: 토큰 발급 실패
KakaoAPIError: API 호출 오류 KakaoAPIError: API 호출 오류
""" """
logger.info(f"[KAKAO] 액세스 토큰 요청 시작 - code: {code[:20]}...")
try: try:
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
data = { data = {
@ -80,27 +72,20 @@ class KakaoOAuthClient:
if self.client_secret: if self.client_secret:
data["client_secret"] = self.client_secret data["client_secret"] = self.client_secret
logger.debug(f"[KAKAO] 토큰 요청 데이터 - redirect_uri: {self.redirect_uri}, client_id: {self.client_id[:10]}...")
async with session.post(self.TOKEN_URL, data=data) as response: async with session.post(self.TOKEN_URL, data=data) as response:
result = await response.json() result = await response.json()
logger.debug(f"[KAKAO] 토큰 응답 상태 - status: {response.status}")
if "error" in result: if "error" in result:
error_desc = result.get( error_desc = result.get(
"error_description", result.get("error", "알 수 없는 오류") "error_description", result.get("error", "알 수 없는 오류")
) )
logger.error(f"[KAKAO] 토큰 발급 실패 - error: {result.get('error')}, description: {error_desc}")
raise KakaoAuthFailedError(f"카카오 토큰 발급 실패: {error_desc}") raise KakaoAuthFailedError(f"카카오 토큰 발급 실패: {error_desc}")
logger.info("[KAKAO] 액세스 토큰 발급 성공")
logger.debug(f"[KAKAO] 토큰 정보 - token_type: {result.get('token_type')}, expires_in: {result.get('expires_in')}")
return KakaoTokenResponse(**result) return KakaoTokenResponse(**result)
except KakaoAuthFailedError: except KakaoAuthFailedError:
raise raise
except Exception as e: except Exception as e:
logger.error(f"[KAKAO] API 호출 오류 - error: {str(e)}")
raise KakaoAPIError(f"카카오 API 호출 중 오류 발생: {str(e)}") raise KakaoAPIError(f"카카오 API 호출 중 오류 발생: {str(e)}")
async def get_user_info(self, access_token: str) -> KakaoUserInfo: async def get_user_info(self, access_token: str) -> KakaoUserInfo:
@ -117,31 +102,21 @@ class KakaoOAuthClient:
KakaoAuthFailedError: 사용자 정보 조회 실패 KakaoAuthFailedError: 사용자 정보 조회 실패
KakaoAPIError: API 호출 오류 KakaoAPIError: API 호출 오류
""" """
logger.info("[KAKAO] 사용자 정보 조회 시작")
try: try:
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
headers = {"Authorization": f"Bearer {access_token}"} headers = {"Authorization": f"Bearer {access_token}"}
async with session.get(self.USER_INFO_URL, headers=headers) as response: async with session.get(self.USER_INFO_URL, headers=headers) as response:
result = await response.json() result = await response.json()
logger.debug(f"[KAKAO] 사용자 정보 응답 상태 - status: {response.status}")
if "id" not in result: if "id" not in result:
logger.error(f"[KAKAO] 사용자 정보 조회 실패 - response: {result}")
raise KakaoAuthFailedError("카카오 사용자 정보를 가져올 수 없습니다.") raise KakaoAuthFailedError("카카오 사용자 정보를 가져올 수 없습니다.")
kakao_id = result.get("id")
kakao_account = result.get("kakao_account", {})
profile = kakao_account.get("profile", {})
logger.info(f"[KAKAO] 사용자 정보 조회 성공 - kakao_id: {kakao_id}")
logger.debug(f"[KAKAO] 사용자 상세 정보 - nickname: {profile.get('nickname')}, email: {kakao_account.get('email')}")
return KakaoUserInfo(**result) return KakaoUserInfo(**result)
except KakaoAuthFailedError: except KakaoAuthFailedError:
raise raise
except Exception as e: except Exception as e:
logger.error(f"[KAKAO] API 호출 오류 - error: {str(e)}")
raise KakaoAPIError(f"카카오 API 호출 중 오류 발생: {str(e)}") raise KakaoAPIError(f"카카오 API 호출 중 오류 발생: {str(e)}")

View File

@ -39,7 +39,7 @@ class ChatgptService:
) -> str: ) -> str:
prompt_text = prompt.build_prompt(input_data) prompt_text = prompt.build_prompt(input_data)
logger.debug(f"[ChatgptService] Generated Prompt (length: {len(prompt_text)})") print(f"[ChatgptService] Generated Prompt (length: {len(prompt_text)})")
logger.info(f"[ChatgptService] Starting GPT request with structured output with model: {prompt.prompt_model}") logger.info(f"[ChatgptService] Starting GPT request with structured output with model: {prompt.prompt_model}")
# GPT API 호출 # GPT API 호출

View File

@ -58,77 +58,6 @@ CACHE_TTL_SECONDS = 300
# 모듈 레벨 공유 HTTP 클라이언트 (커넥션 풀 재사용) # 모듈 레벨 공유 HTTP 클라이언트 (커넥션 풀 재사용)
_shared_client: httpx.AsyncClient | None = None _shared_client: httpx.AsyncClient | None = None
text_template_v_1 = {
"type": "composition",
"track": 3,
"elements": [
{
"type": "text",
"time": 0,
"y": "87.9086%",
"width": "100%",
"height": "40%",
"x_alignment": "50%",
"y_alignment": "50%",
"font_family": "Noto Sans",
"font_weight": "700",
"font_size": "8 vmin",
"background_color": "rgba(216,216,216,0)",
"background_x_padding": "33%",
"background_y_padding": "7%",
"background_border_radius": "28%",
"fill_color": "#ffffff",
"stroke_color": "rgba(51,51,51,1)",
"stroke_width": "0.6 vmin",
}
]
}
text_template_v_2 = {
"type": "composition",
"track": 3,
"elements": [
{
"type": "text",
"time": 0,
"x": "7.7233%",
"y": "82.9852%",
"width": "84.5534%",
"height": "5.7015%",
"x_anchor": "0%",
"y_anchor": "0%",
"x_alignment": "50%",
"y_alignment": "100%",
"font_family": "Noto Sans",
"font_weight": "700",
"font_size": "6.9999 vmin",
"fill_color": "#ffffff"
}
]
}
text_template_h_1 = {
"type": "composition",
"track": 3,
"elements": [
{
"type": "text",
"time": 0,
"x": "10%",
"y": "80%",
"width": "80%",
"height": "15%",
"x_anchor": "0%",
"y_anchor": "0%",
"x_alignment": "50%",
"font_family": "Noto Sans",
"font_weight": "700",
"font_size": "5.9998 vmin",
"fill_color": "#ffffff",
"stroke_color": "#333333",
"stroke_width": "0.2 vmin"
}
]
}
async def get_shared_client() -> httpx.AsyncClient: async def get_shared_client() -> httpx.AsyncClient:
"""공유 HTTP 클라이언트를 반환합니다. 없으면 생성합니다.""" """공유 HTTP 클라이언트를 반환합니다. 없으면 생성합니다."""
@ -541,22 +470,3 @@ class CreatomateService:
) )
return new_template return new_template
def lining_lyric(self, text_template: dict, lyric_index: int, lyric_text: str, start_sec: float, end_sec: float) -> dict:
duration = end_sec - start_sec
text_scene = copy.deepcopy(text_template)
text_scene["name"] = f"Caption-{lyric_index}"
text_scene["duration"] = duration
text_scene["time"] = start_sec
text_scene["elements"][0]["name"] = f"lyric-{lyric_index}"
text_scene["elements"][0]["text"] = lyric_text
return text_scene
def get_text_template(self):
match self.orientation:
case "vertical":
return text_template_v_2
case "horizontal":
return text_template_h_1

View File

@ -1,9 +1,6 @@
import os, json import os, json
from abc import ABCMeta from abc import ABCMeta
from config import prompt_settings from config import prompt_settings
from app.utils.logger import get_logger
logger = get_logger("prompt")
class Prompt(): class Prompt():
prompt_name : str # ex) marketing_prompt prompt_name : str # ex) marketing_prompt
@ -40,8 +37,8 @@ class Prompt():
def build_prompt(self, input_data:dict) -> str: def build_prompt(self, input_data:dict) -> str:
self.check_input(input_data) self.check_input(input_data)
build_template = self.prompt_template build_template = self.prompt_template
logger.debug(f"build_template: {build_template}") print("build_template", build_template)
logger.debug(f"input_data: {input_data}") print("input_data", input_data)
build_template = build_template.format(**input_data) build_template = build_template.format(**input_data)
return build_template return build_template

View File

@ -179,37 +179,6 @@ class SunoService:
raise ValueError("Suno API returned empty response for task status") raise ValueError("Suno API returned empty response for task status")
return data return data
async def get_lyric_timestamp(self, task_id: str, audio_id: str) -> dict[str, Any]:
"""
음악 타임스탬프 정보 추출
Args:
task_id: generate()에서 반환된 작업 ID
audio_id: 사용할 audio id
Returns:
data.alignedWords: 수노 가사 input - startS endS 시간 데이터 매핑
"""
payload = {
"task_id" : task_id,
"audio_id" : audio_id
}
async with httpx.AsyncClient() as client:
response = await client.post(
f"{self.BASE_URL}/generate/get-timestamped-lyrics",
headers=self.headers,
json=payload,
timeout=30.0,
)
response.raise_for_status()
data = response.json()
if not data or not data['data']:
raise ValueError("Suno API returned empty response for task status")
return data['data']['alignedWords']
def parse_status_response(self, result: dict | None) -> PollingSongResponse: def parse_status_response(self, result: dict | None) -> PollingSongResponse:
"""Suno API 상태 응답을 파싱하여 PollingSongResponse로 변환합니다. """Suno API 상태 응답을 파싱하여 PollingSongResponse로 변환합니다.
@ -230,6 +199,8 @@ 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",
) )
@ -241,6 +212,8 @@ 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"),
) )
@ -282,85 +255,7 @@ 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,
) )
def align_lyrics(self, word_data: list[dict], sentences: list[str]) -> list[dict]:
"""
word의 시작/ 포지션만 저장하고, 시간은 word에서 참조
"""
# Step 1: 전체 텍스트 + word별 포지션 범위 저장
full_text = ""
word_ranges = [] # [(start_pos, end_pos, entry), ...]
for entry in word_data:
word = entry['word']
start_pos = len(full_text)
full_text += word
end_pos = len(full_text) - 1
word_ranges.append((start_pos, end_pos, entry))
# Step 2: 메타데이터 제거 + 포지션 재매핑
meta_ranges = []
i = 0
while i < len(full_text):
if full_text[i] == '[':
start = i
while i < len(full_text) and full_text[i] != ']':
i += 1
meta_ranges.append((start, i + 1))
i += 1
clean_text = ""
new_to_old = {} # 클린 포지션 -> 원본 포지션
for old_pos, char in enumerate(full_text):
in_meta = any(s <= old_pos < e for s, e in meta_ranges)
if not in_meta:
new_to_old[len(clean_text)] = old_pos
clean_text += char
# Step 3: 포지션으로 word 찾기
def get_word_at(old_pos: int):
for start, end, entry in word_ranges:
if start <= old_pos <= end:
return entry
return None
# Step 4: 문장 매칭
def normalize(text):
return ''.join(c for c in text if c not in ' \n\t-')
norm_clean = normalize(clean_text)
norm_to_clean = [i for i, c in enumerate(clean_text) if c not in ' \n\t-']
results = []
search_pos = 0
for sentence in sentences:
norm_sentence = normalize(sentence)
found_pos = norm_clean.find(norm_sentence, search_pos)
if found_pos != -1:
clean_start = norm_to_clean[found_pos]
clean_end = norm_to_clean[found_pos + len(norm_sentence) - 1]
old_start = new_to_old[clean_start]
old_end = new_to_old[clean_end]
word_start = get_word_at(old_start)
word_end = get_word_at(old_end)
results.append({
'text': sentence,
'start_sec': word_start['startS'],
'end_sec': word_end['endS'],
})
search_pos = found_pos + len(norm_sentence)
else:
results.append({'text': sentence, 'start_sec': None, 'end_sec': None})
return results

View File

@ -27,7 +27,7 @@ from app.dependencies.pagination import (
) )
from app.home.models import Image, Project from app.home.models import Image, Project
from app.lyric.models import Lyric from app.lyric.models import Lyric
from app.song.models import Song, SongTimestamp from app.song.models import Song
from app.video.models import Video from app.video.models import Video
from app.video.schemas.video_schema import ( from app.video.schemas.video_schema import (
DownloadVideoResponse, DownloadVideoResponse,
@ -304,19 +304,6 @@ async def generate_video(
) )
logger.debug(f"[generate_video] Duration extended to {creatomate_service.target_duration}s - task_id: {task_id}") logger.debug(f"[generate_video] Duration extended to {creatomate_service.target_duration}s - task_id: {task_id}")
# 이런거 추가해야하는데 AI가 자꾸 번호 달면 제가 번호를 다 밀어야 하나요?
song_timestamp_result = await session.execute(
select(SongTimestamp)
.where(SongTimestamp.suno_audio_id == song.suno_audio_id)
)
song_timestamp_list = song_timestamp_result.scalars().all()
text_template = creatomate_service.get_text_template()
for idx, aligned in enumerate(song_timestamp_list):
caption = creatomate_service.lining_lyric(text_template, idx, aligned.lyric_line, aligned.start_time, aligned.end_time )
final_template['source']['elements'].append(caption)
# 6-5. 커스텀 렌더링 요청 (비동기) # 6-5. 커스텀 렌더링 요청 (비동기)
render_response = await creatomate_service.make_creatomate_custom_call_async( render_response = await creatomate_service.make_creatomate_custom_call_async(
final_template["source"], final_template["source"],

View File

@ -716,7 +716,7 @@ from app.services.image_processor import ImageProcessorService
from app.config import settings from app.config import settings
router = APIRouter(prefix="/images", tags=["Images"]) router = APIRouter(prefix="/images", tags=["images"])
# 외부 서비스 인스턴스 # 외부 서비스 인스턴스
processor_service = ImageProcessorService(settings.IMAGE_PROCESSOR_URL) processor_service = ImageProcessorService(settings.IMAGE_PROCESSOR_URL)

View File

@ -43,12 +43,12 @@ tags_metadata = [
"description": "홈 화면 및 프로젝트 관리 API", "description": "홈 화면 및 프로젝트 관리 API",
}, },
{ {
"name": "Crawling", "name": "crawling",
"description": "네이버 지도 크롤링 API - 장소 정보 및 이미지 수집", "description": "네이버 지도 크롤링 API - 장소 정보 및 이미지 수집",
}, },
{ {
"name": "Image-Blob", "name": "image",
"description": "이미지 업로드 API - Azure Blob Storage", "description": "이미지 업로드 API - 로컬 서버 또는 Azure Blob Storage",
}, },
{ {
"name": "Lyric", "name": "Lyric",
@ -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/{song_id}` - Suno API 상태 확인 2. `GET /api/v1/song/status/{suno_task_id}` - Suno API 상태 확인
3. `GET /api/v1/song/download/{task_id}` - 노래 다운로드 URL 조회 3. `GET /api/v1/song/download/{task_id}` - 노래 다운로드 URL 조회
""", """,
}, },

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,42 @@
from openai import OpenAI
from difflib import SequenceMatcher
from dataclasses import dataclass
from typing import List, Tuple
import aiohttp, json
@dataclass
class TimestampedLyric:
text: str
start: float
end: float
SUNO_BASE_URL="https://api.sunoapi.org"
SUNO_TIMESTAMP_ROUTE = "/api/v1/generate/get-timestamped-lyrics"
SUNO_DETAIL_ROUTE = "/api/v1/generate/record-info"
class LyricTimestampMapper:
suno_api_key : str
def __init__(self, suno_api_key):
self.suno_api_key = suno_api_key
async def get_suno_audio_id_from_task_id(self, suno_task_id): # expire if db save audio id
url = f"{SUNO_BASE_URL}{SUNO_DETAIL_ROUTE}"
headers = {"Authorization": f"Bearer {self.suno_api_key}"}
async with aiohttp.ClientSession() as session:
async with session.get(url, headers=headers, params={"taskId" : suno_task_id}) as response:
detail = await response.json()
result = detail['data']['response']['sunoData'][0]['id']
return result
async def get_suno_timestamp(self, suno_task_id, suno_audio_id): # expire if db save audio id
url = f"{SUNO_BASE_URL}{SUNO_TIMESTAMP_ROUTE}"
headers = {"Authorization": f"Bearer {self.suno_api_key}"}
payload = {
"task_id" : suno_task_id,
"audio_id" : suno_audio_id
}
async with aiohttp.ClientSession() as session:
async with session.post(url, headers=headers, data=json.dumps(payload)) as response:
result = await response.json()
return result

View File

@ -0,0 +1,19 @@
from lyric_timestamp_mapper import LyricTimestampMapper
API_KEY = "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"
AUDIO_PATH = "test_audio.mp3"
GROUND_TRUTH_LYRICS = [
"첫 번째 가사 라인입니다",
"두 번째 가사 라인입니다",
"세 번째 가사 라인입니다",
]
mapper = LyricTimestampMapper(api_key=API_KEY)
result = mapper.map_ground_truth(AUDIO_PATH, GROUND_TRUTH_LYRICS)
for lyric in result:
if lyric.start >= 0:
print(f"[{lyric.start:.2f} - {lyric.end:.2f}] {lyric.text}")
else:
print(f"[매칭 실패] {lyric.text}")

File diff suppressed because it is too large Load Diff