Compare commits
7 Commits
2e1ccebe43
...
ba26284451
| Author | SHA1 | Date |
|---|---|---|
|
|
ba26284451 | |
|
|
3f75b6d61d | |
|
|
b84c07c325 | |
|
|
94aae50564 | |
|
|
2b777f5314 | |
|
|
1199eca649 | |
|
|
073777081e |
|
|
@ -1,38 +1,38 @@
|
|||
from fastapi import FastAPI
|
||||
from sqladmin import Admin
|
||||
|
||||
from app.database.session import engine
|
||||
from app.home.api.home_admin import ImageAdmin, ProjectAdmin
|
||||
from app.lyric.api.lyrics_admin import LyricAdmin
|
||||
from app.song.api.song_admin import SongAdmin
|
||||
from app.video.api.video_admin import VideoAdmin
|
||||
from config import prj_settings
|
||||
|
||||
# https://github.com/aminalaee/sqladmin
|
||||
|
||||
|
||||
def init_admin(
|
||||
app: FastAPI,
|
||||
db_engine: engine,
|
||||
base_url: str = prj_settings.ADMIN_BASE_URL,
|
||||
) -> Admin:
|
||||
admin = Admin(
|
||||
app,
|
||||
db_engine,
|
||||
base_url=base_url,
|
||||
)
|
||||
|
||||
# 프로젝트 관리
|
||||
admin.add_view(ProjectAdmin)
|
||||
admin.add_view(ImageAdmin)
|
||||
|
||||
# 가사 관리
|
||||
admin.add_view(LyricAdmin)
|
||||
|
||||
# 노래 관리
|
||||
admin.add_view(SongAdmin)
|
||||
|
||||
# 영상 관리
|
||||
admin.add_view(VideoAdmin)
|
||||
|
||||
return admin
|
||||
from fastapi import FastAPI
|
||||
from sqladmin import Admin
|
||||
|
||||
from app.database.session import engine
|
||||
from app.home.api.home_admin import ImageAdmin, ProjectAdmin
|
||||
from app.lyric.api.lyrics_admin import LyricAdmin
|
||||
from app.song.api.song_admin import SongAdmin
|
||||
from app.video.api.video_admin import VideoAdmin
|
||||
from config import prj_settings
|
||||
|
||||
# https://github.com/aminalaee/sqladmin
|
||||
|
||||
|
||||
def init_admin(
|
||||
app: FastAPI,
|
||||
db_engine: engine,
|
||||
base_url: str = prj_settings.ADMIN_BASE_URL,
|
||||
) -> Admin:
|
||||
admin = Admin(
|
||||
app,
|
||||
db_engine,
|
||||
base_url=base_url,
|
||||
)
|
||||
|
||||
# 프로젝트 관리
|
||||
admin.add_view(ProjectAdmin)
|
||||
admin.add_view(ImageAdmin)
|
||||
|
||||
# 가사 관리
|
||||
admin.add_view(LyricAdmin)
|
||||
|
||||
# 노래 관리
|
||||
admin.add_view(SongAdmin)
|
||||
|
||||
# 영상 관리
|
||||
admin.add_view(VideoAdmin)
|
||||
|
||||
return admin
|
||||
|
|
|
|||
|
|
@ -1,51 +1,51 @@
|
|||
# app/main.py
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""FastAPI 애플리케이션 생명주기 관리"""
|
||||
# Startup - 애플리케이션 시작 시
|
||||
print("Starting up...")
|
||||
|
||||
try:
|
||||
from config import prj_settings
|
||||
|
||||
# DEBUG 모드일 때만 데이터베이스 테이블 자동 생성
|
||||
if prj_settings.DEBUG:
|
||||
from app.database.session import create_db_tables
|
||||
|
||||
await create_db_tables()
|
||||
print("Database tables created (DEBUG mode)")
|
||||
except asyncio.TimeoutError:
|
||||
print("Database initialization timed out")
|
||||
# 타임아웃 시 앱 시작 중단하려면 raise, 계속하려면 pass
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"Database initialization failed: {e}")
|
||||
# 에러 시 앱 시작 중단하려면 raise, 계속하려면 pass
|
||||
raise
|
||||
|
||||
yield # 애플리케이션 실행 중
|
||||
|
||||
# Shutdown - 애플리케이션 종료 시
|
||||
print("Shutting down...")
|
||||
|
||||
# 공유 HTTP 클라이언트 종료
|
||||
from app.utils.creatomate import close_shared_client
|
||||
from app.utils.upload_blob_as_request import close_shared_blob_client
|
||||
|
||||
await close_shared_client()
|
||||
await close_shared_blob_client()
|
||||
|
||||
# 데이터베이스 엔진 종료
|
||||
from app.database.session import dispose_engine
|
||||
|
||||
await dispose_engine()
|
||||
|
||||
|
||||
# FastAPI 앱 생성 (lifespan 적용)
|
||||
app = FastAPI(title="CastAD", lifespan=lifespan)
|
||||
# app/main.py
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""FastAPI 애플리케이션 생명주기 관리"""
|
||||
# Startup - 애플리케이션 시작 시
|
||||
print("Starting up...")
|
||||
|
||||
try:
|
||||
from config import prj_settings
|
||||
|
||||
# DEBUG 모드일 때만 데이터베이스 테이블 자동 생성
|
||||
if prj_settings.DEBUG:
|
||||
from app.database.session import create_db_tables
|
||||
|
||||
await create_db_tables()
|
||||
print("Database tables created (DEBUG mode)")
|
||||
except asyncio.TimeoutError:
|
||||
print("Database initialization timed out")
|
||||
# 타임아웃 시 앱 시작 중단하려면 raise, 계속하려면 pass
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"Database initialization failed: {e}")
|
||||
# 에러 시 앱 시작 중단하려면 raise, 계속하려면 pass
|
||||
raise
|
||||
|
||||
yield # 애플리케이션 실행 중
|
||||
|
||||
# Shutdown - 애플리케이션 종료 시
|
||||
print("Shutting down...")
|
||||
|
||||
# 공유 HTTP 클라이언트 종료
|
||||
from app.utils.creatomate import close_shared_client
|
||||
from app.utils.upload_blob_as_request import close_shared_blob_client
|
||||
|
||||
await close_shared_client()
|
||||
await close_shared_blob_client()
|
||||
|
||||
# 데이터베이스 엔진 종료
|
||||
from app.database.session import dispose_engine
|
||||
|
||||
await dispose_engine()
|
||||
|
||||
|
||||
# FastAPI 앱 생성 (lifespan 적용)
|
||||
app = FastAPI(title="CastAD", lifespan=lifespan)
|
||||
|
|
|
|||
|
|
@ -1,114 +1,313 @@
|
|||
from fastapi import FastAPI, HTTPException, Request, Response, status
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
|
||||
class FastShipError(Exception):
|
||||
"""Base exception for all exceptions in fastship api"""
|
||||
# status_code to be returned for this exception
|
||||
# when it is handled
|
||||
status = status.HTTP_400_BAD_REQUEST
|
||||
|
||||
|
||||
class EntityNotFound(FastShipError):
|
||||
"""Entity not found in database"""
|
||||
|
||||
status = status.HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
class BadPassword(FastShipError):
|
||||
"""Password is not strong enough or invalid"""
|
||||
|
||||
status = status.HTTP_400_BAD_REQUEST
|
||||
|
||||
|
||||
class ClientNotAuthorized(FastShipError):
|
||||
"""Client is not authorized to perform the action"""
|
||||
|
||||
status = status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
class ClientNotVerified(FastShipError):
|
||||
"""Client is not verified"""
|
||||
|
||||
status = status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
class NothingToUpdate(FastShipError):
|
||||
"""No data provided to update"""
|
||||
|
||||
|
||||
class BadCredentials(FastShipError):
|
||||
"""User email or password is incorrect"""
|
||||
|
||||
status = status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
class InvalidToken(FastShipError):
|
||||
"""Access token is invalid or expired"""
|
||||
|
||||
status = status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
class DeliveryPartnerNotAvailable(FastShipError):
|
||||
"""Delivery partner/s do not service the destination"""
|
||||
|
||||
status = status.HTTP_406_NOT_ACCEPTABLE
|
||||
|
||||
|
||||
class DeliveryPartnerCapacityExceeded(FastShipError):
|
||||
"""Delivery partner has reached their max handling capacity"""
|
||||
|
||||
status = status.HTTP_406_NOT_ACCEPTABLE
|
||||
|
||||
|
||||
def _get_handler(status: int, detail: str):
|
||||
# Define
|
||||
def handler(request: Request, exception: Exception) -> Response:
|
||||
# DEBUG PRINT STATEMENT 👇
|
||||
from rich import print, panel
|
||||
print(
|
||||
panel.Panel(
|
||||
exception.__class__.__name__,
|
||||
title="Handled Exception",
|
||||
border_style="red",
|
||||
),
|
||||
)
|
||||
# DEBUG PRINT STATEMENT 👆
|
||||
|
||||
# Raise HTTPException with given status and detail
|
||||
# can return JSONResponse as well
|
||||
raise HTTPException(
|
||||
status_code=status,
|
||||
detail=detail,
|
||||
)
|
||||
# Return ExceptionHandler required with given
|
||||
# status and detail for HTTPExcetion above
|
||||
return handler
|
||||
|
||||
|
||||
def add_exception_handlers(app: FastAPI):
|
||||
# Get all subclass of 👇, our custom exceptions
|
||||
exception_classes = FastShipError.__subclasses__()
|
||||
|
||||
for exception_class in exception_classes:
|
||||
# Add exception handler
|
||||
app.add_exception_handler(
|
||||
# Custom exception class
|
||||
exception_class,
|
||||
# Get handler function
|
||||
_get_handler(
|
||||
status=exception_class.status,
|
||||
detail=exception_class.__doc__,
|
||||
),
|
||||
)
|
||||
|
||||
@app.exception_handler(status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
def internal_server_error_handler(request, exception):
|
||||
return JSONResponse(
|
||||
content={"detail": "Something went wrong..."},
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
headers={
|
||||
"X-Error": f"{exception}",
|
||||
}
|
||||
)
|
||||
import logging
|
||||
import traceback
|
||||
from functools import wraps
|
||||
from typing import Any, Callable, TypeVar
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request, Response, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
# 로거 설정
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class FastShipError(Exception):
|
||||
"""Base exception for all exceptions in fastship api"""
|
||||
# status_code to be returned for this exception
|
||||
# when it is handled
|
||||
status = status.HTTP_400_BAD_REQUEST
|
||||
|
||||
|
||||
class EntityNotFound(FastShipError):
|
||||
"""Entity not found in database"""
|
||||
|
||||
status = status.HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
class BadPassword(FastShipError):
|
||||
"""Password is not strong enough or invalid"""
|
||||
|
||||
status = status.HTTP_400_BAD_REQUEST
|
||||
|
||||
|
||||
class ClientNotAuthorized(FastShipError):
|
||||
"""Client is not authorized to perform the action"""
|
||||
|
||||
status = status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
class ClientNotVerified(FastShipError):
|
||||
"""Client is not verified"""
|
||||
|
||||
status = status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
class NothingToUpdate(FastShipError):
|
||||
"""No data provided to update"""
|
||||
|
||||
|
||||
class BadCredentials(FastShipError):
|
||||
"""User email or password is incorrect"""
|
||||
|
||||
status = status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
class InvalidToken(FastShipError):
|
||||
"""Access token is invalid or expired"""
|
||||
|
||||
status = status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
class DeliveryPartnerNotAvailable(FastShipError):
|
||||
"""Delivery partner/s do not service the destination"""
|
||||
|
||||
status = status.HTTP_406_NOT_ACCEPTABLE
|
||||
|
||||
|
||||
class DeliveryPartnerCapacityExceeded(FastShipError):
|
||||
"""Delivery partner has reached their max handling capacity"""
|
||||
|
||||
status = status.HTTP_406_NOT_ACCEPTABLE
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 데이터베이스 관련 예외
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class DatabaseError(FastShipError):
|
||||
"""Database operation failed"""
|
||||
|
||||
status = status.HTTP_503_SERVICE_UNAVAILABLE
|
||||
|
||||
|
||||
class DatabaseConnectionError(DatabaseError):
|
||||
"""Database connection failed"""
|
||||
|
||||
status = status.HTTP_503_SERVICE_UNAVAILABLE
|
||||
|
||||
|
||||
class DatabaseTimeoutError(DatabaseError):
|
||||
"""Database operation timed out"""
|
||||
|
||||
status = status.HTTP_504_GATEWAY_TIMEOUT
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 외부 서비스 관련 예외
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class ExternalServiceError(FastShipError):
|
||||
"""External service call failed"""
|
||||
|
||||
status = status.HTTP_502_BAD_GATEWAY
|
||||
|
||||
|
||||
class GPTServiceError(ExternalServiceError):
|
||||
"""GPT API call failed"""
|
||||
|
||||
status = status.HTTP_502_BAD_GATEWAY
|
||||
|
||||
|
||||
class CrawlingError(ExternalServiceError):
|
||||
"""Web crawling failed"""
|
||||
|
||||
status = status.HTTP_502_BAD_GATEWAY
|
||||
|
||||
|
||||
class BlobStorageError(ExternalServiceError):
|
||||
"""Azure Blob Storage operation failed"""
|
||||
|
||||
status = status.HTTP_502_BAD_GATEWAY
|
||||
|
||||
|
||||
class CreatomateError(ExternalServiceError):
|
||||
"""Creatomate API call failed"""
|
||||
|
||||
status = status.HTTP_502_BAD_GATEWAY
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 예외 처리 데코레이터
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def handle_db_exceptions(
|
||||
error_message: str = "데이터베이스 작업 중 오류가 발생했습니다.",
|
||||
):
|
||||
"""데이터베이스 예외를 처리하는 데코레이터.
|
||||
|
||||
Args:
|
||||
error_message: 오류 발생 시 반환할 메시지
|
||||
|
||||
Example:
|
||||
@handle_db_exceptions("사용자 조회 중 오류 발생")
|
||||
async def get_user(user_id: int):
|
||||
...
|
||||
"""
|
||||
|
||||
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
||||
@wraps(func)
|
||||
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
try:
|
||||
return await func(*args, **kwargs)
|
||||
except HTTPException:
|
||||
# HTTPException은 그대로 raise
|
||||
raise
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"[DB Error] {func.__name__}: {e}")
|
||||
logger.error(traceback.format_exc())
|
||||
print(f"[DB Error] {func.__name__}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=error_message,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[Unexpected Error] {func.__name__}: {e}")
|
||||
logger.error(traceback.format_exc())
|
||||
print(f"[Unexpected Error] {func.__name__}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="서비스 처리 중 예기치 않은 오류가 발생했습니다.",
|
||||
)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def handle_external_service_exceptions(
|
||||
service_name: str = "외부 서비스",
|
||||
error_message: str | None = None,
|
||||
):
|
||||
"""외부 서비스 호출 예외를 처리하는 데코레이터.
|
||||
|
||||
Args:
|
||||
service_name: 서비스 이름 (로그용)
|
||||
error_message: 오류 발생 시 반환할 메시지
|
||||
|
||||
Example:
|
||||
@handle_external_service_exceptions("GPT")
|
||||
async def call_gpt():
|
||||
...
|
||||
"""
|
||||
|
||||
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
||||
@wraps(func)
|
||||
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
try:
|
||||
return await func(*args, **kwargs)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
msg = error_message or f"{service_name} 서비스 호출 중 오류가 발생했습니다."
|
||||
logger.error(f"[{service_name} Error] {func.__name__}: {e}")
|
||||
logger.error(traceback.format_exc())
|
||||
print(f"[{service_name} Error] {func.__name__}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=msg,
|
||||
)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def handle_api_exceptions(
|
||||
error_message: str = "요청 처리 중 오류가 발생했습니다.",
|
||||
):
|
||||
"""API 엔드포인트 예외를 처리하는 데코레이터.
|
||||
|
||||
Args:
|
||||
error_message: 오류 발생 시 반환할 메시지
|
||||
|
||||
Example:
|
||||
@handle_api_exceptions("가사 생성 중 오류 발생")
|
||||
async def generate_lyric():
|
||||
...
|
||||
"""
|
||||
|
||||
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
||||
@wraps(func)
|
||||
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
try:
|
||||
return await func(*args, **kwargs)
|
||||
except HTTPException:
|
||||
raise
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"[API DB Error] {func.__name__}: {e}")
|
||||
logger.error(traceback.format_exc())
|
||||
print(f"[API DB Error] {func.__name__}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="데이터베이스 연결에 문제가 발생했습니다.",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[API Error] {func.__name__}: {e}")
|
||||
logger.error(traceback.format_exc())
|
||||
print(f"[API Error] {func.__name__}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=error_message,
|
||||
)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def _get_handler(status: int, detail: str):
|
||||
# Define
|
||||
def handler(request: Request, exception: Exception) -> Response:
|
||||
# DEBUG PRINT STATEMENT 👇
|
||||
from rich import print, panel
|
||||
print(
|
||||
panel.Panel(
|
||||
exception.__class__.__name__,
|
||||
title="Handled Exception",
|
||||
border_style="red",
|
||||
),
|
||||
)
|
||||
# DEBUG PRINT STATEMENT 👆
|
||||
|
||||
# Raise HTTPException with given status and detail
|
||||
# can return JSONResponse as well
|
||||
raise HTTPException(
|
||||
status_code=status,
|
||||
detail=detail,
|
||||
)
|
||||
# Return ExceptionHandler required with given
|
||||
# status and detail for HTTPExcetion above
|
||||
return handler
|
||||
|
||||
|
||||
def add_exception_handlers(app: FastAPI):
|
||||
# Get all subclass of 👇, our custom exceptions
|
||||
exception_classes = FastShipError.__subclasses__()
|
||||
|
||||
for exception_class in exception_classes:
|
||||
# Add exception handler
|
||||
app.add_exception_handler(
|
||||
# Custom exception class
|
||||
exception_class,
|
||||
# Get handler function
|
||||
_get_handler(
|
||||
status=exception_class.status,
|
||||
detail=exception_class.__doc__,
|
||||
),
|
||||
)
|
||||
|
||||
@app.exception_handler(status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
def internal_server_error_handler(request, exception):
|
||||
return JSONResponse(
|
||||
content={"detail": "Something went wrong..."},
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
headers={
|
||||
"X-Error": f"{exception}",
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -1,30 +1,30 @@
|
|||
from uuid import UUID
|
||||
from redis.asyncio import Redis
|
||||
|
||||
from app.config import db_settings
|
||||
|
||||
|
||||
_token_blacklist = Redis(
|
||||
host=db_settings.REDIS_HOST,
|
||||
port=db_settings.REDIS_PORT,
|
||||
db=0,
|
||||
)
|
||||
_shipment_verification_codes = Redis(
|
||||
host=db_settings.REDIS_HOST,
|
||||
port=db_settings.REDIS_PORT,
|
||||
db=1,
|
||||
decode_responses=True,
|
||||
)
|
||||
|
||||
async def add_jti_to_blacklist(jti: str):
|
||||
await _token_blacklist.set(jti, "blacklisted")
|
||||
|
||||
|
||||
async def is_jti_blacklisted(jti: str) -> bool:
|
||||
return await _token_blacklist.exists(jti)
|
||||
|
||||
async def add_shipment_verification_code(id: UUID, code: int):
|
||||
await _shipment_verification_codes.set(str(id), code)
|
||||
|
||||
async def get_shipment_verification_code(id: UUID) -> str:
|
||||
from uuid import UUID
|
||||
from redis.asyncio import Redis
|
||||
|
||||
from app.config import db_settings
|
||||
|
||||
|
||||
_token_blacklist = Redis(
|
||||
host=db_settings.REDIS_HOST,
|
||||
port=db_settings.REDIS_PORT,
|
||||
db=0,
|
||||
)
|
||||
_shipment_verification_codes = Redis(
|
||||
host=db_settings.REDIS_HOST,
|
||||
port=db_settings.REDIS_PORT,
|
||||
db=1,
|
||||
decode_responses=True,
|
||||
)
|
||||
|
||||
async def add_jti_to_blacklist(jti: str):
|
||||
await _token_blacklist.set(jti, "blacklisted")
|
||||
|
||||
|
||||
async def is_jti_blacklisted(jti: str) -> bool:
|
||||
return await _token_blacklist.exists(jti)
|
||||
|
||||
async def add_shipment_verification_code(id: UUID, code: int):
|
||||
await _shipment_verification_codes.set(str(id), code)
|
||||
|
||||
async def get_shipment_verification_code(id: UUID) -> str:
|
||||
return str(await _shipment_verification_codes.get(str(id)))
|
||||
|
|
@ -1,97 +1,97 @@
|
|||
from asyncio import current_task
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
from sqlalchemy.pool import AsyncQueuePool # 비동기 풀 클래스
|
||||
|
||||
from config import db_settings
|
||||
|
||||
|
||||
# Base 클래스 정의
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
engine = create_async_engine(
|
||||
# MySQL async URL (asyncmy 드라이버)
|
||||
url=db_settings.MYSQL_URL, # 예: "mysql+asyncmy://test:test@host:3306/poc"
|
||||
# === Connection Pool 설정 ===
|
||||
pool_size=10, # 기본 풀 크기: 10개 연결 유지
|
||||
max_overflow=10, # 최대 증가: 10개 (총 20개까지 가능)
|
||||
poolclass=AsyncQueuePool, # 비동기 큐 풀 사용 (기본값, 명시적 지정)
|
||||
pool_timeout=30, # 풀에서 연결 대기 시간: 30초 (기본 30초)
|
||||
pool_recycle=3600, # 연결 재사용 주기: 1시간 (기본 3600초)
|
||||
pool_pre_ping=True, # 연결 사용 전 유효성 검사: True로 설정
|
||||
pool_reset_on_return="rollback", # 연결 반환 시 자동 롤백
|
||||
# === MySQL 특화 설정 ===
|
||||
echo=False, # SQL 쿼리 로깅 (디버깅 시 True)
|
||||
# === 연결 타임아웃 및 재시도 ===
|
||||
connect_args={
|
||||
"connect_timeout": 10, # MySQL 연결 타임아웃: 10초
|
||||
"read_timeout": 30, # 읽기 타임아웃: 30초
|
||||
"write_timeout": 30, # 쓰기 타임아웃: 30초
|
||||
"charset": "utf8mb4", # 문자셋 (이모지 지원)
|
||||
"sql_mode": "STRICT_TRANS_TABLES,NO_ZERO_DATE,NO_ZERO_IN_DATE",
|
||||
"init_command": "SET SESSION time_zone = '+00:00'", # 초기 연결 시 실행
|
||||
},
|
||||
)
|
||||
|
||||
# Async 세션 팩토리 생성
|
||||
async_session_factory = async_sessionmaker(
|
||||
bind=engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False, # 커밋 후 객체 상태 유지
|
||||
autoflush=True, # 변경 감지 자동 플러시
|
||||
)
|
||||
|
||||
# async_scoped_session 생성
|
||||
AsyncScopedSession = async_session_factory(
|
||||
async_session_factory,
|
||||
scopefunc=current_task,
|
||||
)
|
||||
|
||||
|
||||
# 테이블 생성 함수
|
||||
async def create_db_tables() -> None:
|
||||
async with engine.begin() as conn:
|
||||
# from app.database.models import Shipment, Seller # noqa: F401
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
print("MySQL tables created successfully")
|
||||
|
||||
|
||||
# 세션 제너레이터 (FastAPI Depends에 사용)
|
||||
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""
|
||||
Async 세션 컨텍스트 매니저
|
||||
- FastAPI dependency로 사용
|
||||
- Connection Pool에서 연결 획득/반환 자동 관리
|
||||
"""
|
||||
async with async_session_factory() as session:
|
||||
# pre-commit 훅 (선택적: 트랜잭션 시작 전 실행)
|
||||
# await session.begin() # async_sessionmaker에서 자동 begin
|
||||
|
||||
try:
|
||||
yield session
|
||||
# FastAPI 요청 완료 시 자동 commit (예외 발생 시 rollback)
|
||||
except Exception as e:
|
||||
await session.rollback() # 명시적 롤백 (선택적)
|
||||
print(f"Session rollback due to: {e}") # 로깅
|
||||
raise
|
||||
finally:
|
||||
# 명시적 세션 종료 (Connection Pool에 반환)
|
||||
# context manager가 자동 처리하지만, 명시적으로 유지
|
||||
await session.close()
|
||||
print("session closed successfully")
|
||||
# 또는 session.aclose() - Python 3.10+
|
||||
|
||||
|
||||
# 애플리케이션 종료 시 엔진 정리 (선택적)
|
||||
async def dispose_engine() -> None:
|
||||
"""애플리케이션 종료 시 모든 연결 해제"""
|
||||
await engine.dispose()
|
||||
print("Database engine disposed")
|
||||
from asyncio import current_task
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
from sqlalchemy.pool import AsyncQueuePool # 비동기 풀 클래스
|
||||
|
||||
from config import db_settings
|
||||
|
||||
|
||||
# Base 클래스 정의
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
engine = create_async_engine(
|
||||
# MySQL async URL (asyncmy 드라이버)
|
||||
url=db_settings.MYSQL_URL, # 예: "mysql+asyncmy://test:test@host:3306/poc"
|
||||
# === Connection Pool 설정 ===
|
||||
pool_size=10, # 기본 풀 크기: 10개 연결 유지
|
||||
max_overflow=10, # 최대 증가: 10개 (총 20개까지 가능)
|
||||
poolclass=AsyncQueuePool, # 비동기 큐 풀 사용 (기본값, 명시적 지정)
|
||||
pool_timeout=30, # 풀에서 연결 대기 시간: 30초 (기본 30초)
|
||||
pool_recycle=3600, # 연결 재사용 주기: 1시간 (기본 3600초)
|
||||
pool_pre_ping=True, # 연결 사용 전 유효성 검사: True로 설정
|
||||
pool_reset_on_return="rollback", # 연결 반환 시 자동 롤백
|
||||
# === MySQL 특화 설정 ===
|
||||
echo=False, # SQL 쿼리 로깅 (디버깅 시 True)
|
||||
# === 연결 타임아웃 및 재시도 ===
|
||||
connect_args={
|
||||
"connect_timeout": 10, # MySQL 연결 타임아웃: 10초
|
||||
"read_timeout": 30, # 읽기 타임아웃: 30초
|
||||
"write_timeout": 30, # 쓰기 타임아웃: 30초
|
||||
"charset": "utf8mb4", # 문자셋 (이모지 지원)
|
||||
"sql_mode": "STRICT_TRANS_TABLES,NO_ZERO_DATE,NO_ZERO_IN_DATE",
|
||||
"init_command": "SET SESSION time_zone = '+00:00'", # 초기 연결 시 실행
|
||||
},
|
||||
)
|
||||
|
||||
# Async 세션 팩토리 생성
|
||||
async_session_factory = async_sessionmaker(
|
||||
bind=engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False, # 커밋 후 객체 상태 유지
|
||||
autoflush=True, # 변경 감지 자동 플러시
|
||||
)
|
||||
|
||||
# async_scoped_session 생성
|
||||
AsyncScopedSession = async_session_factory(
|
||||
async_session_factory,
|
||||
scopefunc=current_task,
|
||||
)
|
||||
|
||||
|
||||
# 테이블 생성 함수
|
||||
async def create_db_tables() -> None:
|
||||
async with engine.begin() as conn:
|
||||
# from app.database.models import Shipment, Seller # noqa: F401
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
print("MySQL tables created successfully")
|
||||
|
||||
|
||||
# 세션 제너레이터 (FastAPI Depends에 사용)
|
||||
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""
|
||||
Async 세션 컨텍스트 매니저
|
||||
- FastAPI dependency로 사용
|
||||
- Connection Pool에서 연결 획득/반환 자동 관리
|
||||
"""
|
||||
async with async_session_factory() as session:
|
||||
# pre-commit 훅 (선택적: 트랜잭션 시작 전 실행)
|
||||
# await session.begin() # async_sessionmaker에서 자동 begin
|
||||
|
||||
try:
|
||||
yield session
|
||||
# FastAPI 요청 완료 시 자동 commit (예외 발생 시 rollback)
|
||||
except Exception as e:
|
||||
await session.rollback() # 명시적 롤백 (선택적)
|
||||
print(f"Session rollback due to: {e}") # 로깅
|
||||
raise
|
||||
finally:
|
||||
# 명시적 세션 종료 (Connection Pool에 반환)
|
||||
# context manager가 자동 처리하지만, 명시적으로 유지
|
||||
await session.close()
|
||||
print("session closed successfully")
|
||||
# 또는 session.aclose() - Python 3.10+
|
||||
|
||||
|
||||
# 애플리케이션 종료 시 엔진 정리 (선택적)
|
||||
async def dispose_engine() -> None:
|
||||
"""애플리케이션 종료 시 모든 연결 해제"""
|
||||
await engine.dispose()
|
||||
print("Database engine disposed")
|
||||
|
|
|
|||
|
|
@ -1,161 +1,161 @@
|
|||
import time
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from config import db_settings
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 메인 엔진 (FastAPI 요청용)
|
||||
# =============================================================================
|
||||
engine = create_async_engine(
|
||||
url=db_settings.MYSQL_URL,
|
||||
echo=False,
|
||||
pool_size=20, # 기본 풀 크기: 20
|
||||
max_overflow=20, # 추가 연결: 20 (총 최대 40)
|
||||
pool_timeout=30, # 풀에서 연결 대기 시간 (초)
|
||||
pool_recycle=280, # MySQL wait_timeout(기본 28800s, 클라우드는 보통 300s) 보다 짧게 설정
|
||||
pool_pre_ping=True, # 연결 유효성 검사 (죽은 연결 자동 재연결)
|
||||
pool_reset_on_return="rollback", # 반환 시 롤백으로 초기화
|
||||
connect_args={
|
||||
"connect_timeout": 10, # DB 연결 타임아웃
|
||||
"charset": "utf8mb4",
|
||||
},
|
||||
)
|
||||
|
||||
# 메인 세션 팩토리 (FastAPI DI용)
|
||||
AsyncSessionLocal = async_sessionmaker(
|
||||
bind=engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autoflush=False, # 명시적 flush 권장
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 백그라운드 태스크 전용 엔진 (메인 풀과 분리)
|
||||
# =============================================================================
|
||||
background_engine = create_async_engine(
|
||||
url=db_settings.MYSQL_URL,
|
||||
echo=False,
|
||||
pool_size=10, # 백그라운드용 풀 크기: 10
|
||||
max_overflow=10, # 추가 연결: 10 (총 최대 20)
|
||||
pool_timeout=60, # 백그라운드는 대기 시간 여유있게
|
||||
pool_recycle=280, # MySQL wait_timeout 보다 짧게 설정
|
||||
pool_pre_ping=True, # 연결 유효성 검사 (죽은 연결 자동 재연결)
|
||||
pool_reset_on_return="rollback",
|
||||
connect_args={
|
||||
"connect_timeout": 10,
|
||||
"charset": "utf8mb4",
|
||||
},
|
||||
)
|
||||
|
||||
# 백그라운드 세션 팩토리
|
||||
BackgroundSessionLocal = async_sessionmaker(
|
||||
bind=background_engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autoflush=False,
|
||||
)
|
||||
|
||||
|
||||
async def create_db_tables():
|
||||
import asyncio
|
||||
|
||||
# 모델 import (테이블 메타데이터 등록용)
|
||||
from app.home.models import Image, Project # noqa: F401
|
||||
from app.lyric.models import Lyric # noqa: F401
|
||||
from app.song.models import Song # noqa: F401
|
||||
from app.video.models import Video # noqa: F401
|
||||
|
||||
print("Creating database tables...")
|
||||
|
||||
async with asyncio.timeout(10):
|
||||
async with engine.begin() as connection:
|
||||
await connection.run_sync(Base.metadata.create_all)
|
||||
|
||||
|
||||
# FastAPI 의존성용 세션 제너레이터
|
||||
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
start_time = time.perf_counter()
|
||||
pool = engine.pool
|
||||
|
||||
# 커넥션 풀 상태 로깅 (디버깅용)
|
||||
print(
|
||||
f"[get_session] ACQUIRE - pool_size: {pool.size()}, "
|
||||
f"in: {pool.checkedin()}, out: {pool.checkedout()}, "
|
||||
f"overflow: {pool.overflow()}"
|
||||
)
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
acquire_time = time.perf_counter()
|
||||
print(
|
||||
f"[get_session] Session acquired in "
|
||||
f"{(acquire_time - start_time)*1000:.1f}ms"
|
||||
)
|
||||
try:
|
||||
yield session
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
print(
|
||||
f"[get_session] ROLLBACK - error: {type(e).__name__}: {e}, "
|
||||
f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms"
|
||||
)
|
||||
raise e
|
||||
finally:
|
||||
total_time = time.perf_counter() - start_time
|
||||
print(
|
||||
f"[get_session] RELEASE - duration: {total_time*1000:.1f}ms, "
|
||||
f"pool_out: {pool.checkedout()}"
|
||||
)
|
||||
|
||||
|
||||
# 백그라운드 태스크용 세션 제너레이터
|
||||
async def get_background_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
start_time = time.perf_counter()
|
||||
pool = background_engine.pool
|
||||
|
||||
print(
|
||||
f"[get_background_session] ACQUIRE - pool_size: {pool.size()}, "
|
||||
f"in: {pool.checkedin()}, out: {pool.checkedout()}, "
|
||||
f"overflow: {pool.overflow()}"
|
||||
)
|
||||
|
||||
async with BackgroundSessionLocal() as session:
|
||||
acquire_time = time.perf_counter()
|
||||
print(
|
||||
f"[get_background_session] Session acquired in "
|
||||
f"{(acquire_time - start_time)*1000:.1f}ms"
|
||||
)
|
||||
try:
|
||||
yield session
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
print(
|
||||
f"[get_background_session] ROLLBACK - "
|
||||
f"error: {type(e).__name__}: {e}, "
|
||||
f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms"
|
||||
)
|
||||
raise e
|
||||
finally:
|
||||
total_time = time.perf_counter() - start_time
|
||||
print(
|
||||
f"[get_background_session] RELEASE - "
|
||||
f"duration: {total_time*1000:.1f}ms, "
|
||||
f"pool_out: {pool.checkedout()}"
|
||||
)
|
||||
|
||||
|
||||
# 앱 종료 시 엔진 리소스 정리 함수
|
||||
async def dispose_engine() -> None:
|
||||
print("[dispose_engine] Disposing database engines...")
|
||||
await engine.dispose()
|
||||
print("[dispose_engine] Main engine disposed")
|
||||
await background_engine.dispose()
|
||||
print("[dispose_engine] Background engine disposed - ALL DONE")
|
||||
import time
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from config import db_settings
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 메인 엔진 (FastAPI 요청용)
|
||||
# =============================================================================
|
||||
engine = create_async_engine(
|
||||
url=db_settings.MYSQL_URL,
|
||||
echo=False,
|
||||
pool_size=20, # 기본 풀 크기: 20
|
||||
max_overflow=20, # 추가 연결: 20 (총 최대 40)
|
||||
pool_timeout=30, # 풀에서 연결 대기 시간 (초)
|
||||
pool_recycle=280, # MySQL wait_timeout(기본 28800s, 클라우드는 보통 300s) 보다 짧게 설정
|
||||
pool_pre_ping=True, # 연결 유효성 검사 (죽은 연결 자동 재연결)
|
||||
pool_reset_on_return="rollback", # 반환 시 롤백으로 초기화
|
||||
connect_args={
|
||||
"connect_timeout": 10, # DB 연결 타임아웃
|
||||
"charset": "utf8mb4",
|
||||
},
|
||||
)
|
||||
|
||||
# 메인 세션 팩토리 (FastAPI DI용)
|
||||
AsyncSessionLocal = async_sessionmaker(
|
||||
bind=engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autoflush=False, # 명시적 flush 권장
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 백그라운드 태스크 전용 엔진 (메인 풀과 분리)
|
||||
# =============================================================================
|
||||
background_engine = create_async_engine(
|
||||
url=db_settings.MYSQL_URL,
|
||||
echo=False,
|
||||
pool_size=10, # 백그라운드용 풀 크기: 10
|
||||
max_overflow=10, # 추가 연결: 10 (총 최대 20)
|
||||
pool_timeout=60, # 백그라운드는 대기 시간 여유있게
|
||||
pool_recycle=280, # MySQL wait_timeout 보다 짧게 설정
|
||||
pool_pre_ping=True, # 연결 유효성 검사 (죽은 연결 자동 재연결)
|
||||
pool_reset_on_return="rollback",
|
||||
connect_args={
|
||||
"connect_timeout": 10,
|
||||
"charset": "utf8mb4",
|
||||
},
|
||||
)
|
||||
|
||||
# 백그라운드 세션 팩토리
|
||||
BackgroundSessionLocal = async_sessionmaker(
|
||||
bind=background_engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autoflush=False,
|
||||
)
|
||||
|
||||
|
||||
async def create_db_tables():
|
||||
import asyncio
|
||||
|
||||
# 모델 import (테이블 메타데이터 등록용)
|
||||
from app.home.models import Image, Project # noqa: F401
|
||||
from app.lyric.models import Lyric # noqa: F401
|
||||
from app.song.models import Song # noqa: F401
|
||||
from app.video.models import Video # noqa: F401
|
||||
|
||||
print("Creating database tables...")
|
||||
|
||||
async with asyncio.timeout(10):
|
||||
async with engine.begin() as connection:
|
||||
await connection.run_sync(Base.metadata.create_all)
|
||||
|
||||
|
||||
# FastAPI 의존성용 세션 제너레이터
|
||||
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
start_time = time.perf_counter()
|
||||
pool = engine.pool
|
||||
|
||||
# 커넥션 풀 상태 로깅 (디버깅용)
|
||||
print(
|
||||
f"[get_session] ACQUIRE - pool_size: {pool.size()}, "
|
||||
f"in: {pool.checkedin()}, out: {pool.checkedout()}, "
|
||||
f"overflow: {pool.overflow()}"
|
||||
)
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
acquire_time = time.perf_counter()
|
||||
print(
|
||||
f"[get_session] Session acquired in "
|
||||
f"{(acquire_time - start_time)*1000:.1f}ms"
|
||||
)
|
||||
try:
|
||||
yield session
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
print(
|
||||
f"[get_session] ROLLBACK - error: {type(e).__name__}: {e}, "
|
||||
f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms"
|
||||
)
|
||||
raise e
|
||||
finally:
|
||||
total_time = time.perf_counter() - start_time
|
||||
print(
|
||||
f"[get_session] RELEASE - duration: {total_time*1000:.1f}ms, "
|
||||
f"pool_out: {pool.checkedout()}"
|
||||
)
|
||||
|
||||
|
||||
# 백그라운드 태스크용 세션 제너레이터
|
||||
async def get_background_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
start_time = time.perf_counter()
|
||||
pool = background_engine.pool
|
||||
|
||||
print(
|
||||
f"[get_background_session] ACQUIRE - pool_size: {pool.size()}, "
|
||||
f"in: {pool.checkedin()}, out: {pool.checkedout()}, "
|
||||
f"overflow: {pool.overflow()}"
|
||||
)
|
||||
|
||||
async with BackgroundSessionLocal() as session:
|
||||
acquire_time = time.perf_counter()
|
||||
print(
|
||||
f"[get_background_session] Session acquired in "
|
||||
f"{(acquire_time - start_time)*1000:.1f}ms"
|
||||
)
|
||||
try:
|
||||
yield session
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
print(
|
||||
f"[get_background_session] ROLLBACK - "
|
||||
f"error: {type(e).__name__}: {e}, "
|
||||
f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms"
|
||||
)
|
||||
raise e
|
||||
finally:
|
||||
total_time = time.perf_counter() - start_time
|
||||
print(
|
||||
f"[get_background_session] RELEASE - "
|
||||
f"duration: {total_time*1000:.1f}ms, "
|
||||
f"pool_out: {pool.checkedout()}"
|
||||
)
|
||||
|
||||
|
||||
# 앱 종료 시 엔진 리소스 정리 함수
|
||||
async def dispose_engine() -> None:
|
||||
print("[dispose_engine] Disposing database engines...")
|
||||
await engine.dispose()
|
||||
print("[dispose_engine] Main engine disposed")
|
||||
await background_engine.dispose()
|
||||
print("[dispose_engine] Background engine disposed - ALL DONE")
|
||||
|
|
|
|||
|
|
@ -1,102 +1,102 @@
|
|||
from sqladmin import ModelView
|
||||
|
||||
from app.home.models import Image, Project
|
||||
|
||||
|
||||
class ProjectAdmin(ModelView, model=Project):
|
||||
name = "프로젝트"
|
||||
name_plural = "프로젝트 목록"
|
||||
icon = "fa-solid fa-folder"
|
||||
category = "프로젝트 관리"
|
||||
page_size = 20
|
||||
|
||||
column_list = [
|
||||
"id",
|
||||
"store_name",
|
||||
"region",
|
||||
"task_id",
|
||||
"created_at",
|
||||
]
|
||||
|
||||
column_details_list = [
|
||||
"id",
|
||||
"store_name",
|
||||
"region",
|
||||
"task_id",
|
||||
"detail_region_info",
|
||||
"created_at",
|
||||
]
|
||||
|
||||
# 폼(생성/수정)에서 제외
|
||||
form_excluded_columns = ["created_at", "lyrics", "songs", "videos"]
|
||||
|
||||
column_searchable_list = [
|
||||
Project.store_name,
|
||||
Project.region,
|
||||
Project.task_id,
|
||||
]
|
||||
|
||||
column_default_sort = (Project.created_at, True) # True: DESC (최신순)
|
||||
|
||||
column_sortable_list = [
|
||||
Project.id,
|
||||
Project.store_name,
|
||||
Project.region,
|
||||
Project.created_at,
|
||||
]
|
||||
|
||||
column_labels = {
|
||||
"id": "ID",
|
||||
"store_name": "가게명",
|
||||
"region": "지역",
|
||||
"task_id": "작업 ID",
|
||||
"detail_region_info": "상세 지역 정보",
|
||||
"created_at": "생성일시",
|
||||
}
|
||||
|
||||
|
||||
class ImageAdmin(ModelView, model=Image):
|
||||
name = "이미지"
|
||||
name_plural = "이미지 목록"
|
||||
icon = "fa-solid fa-image"
|
||||
category = "프로젝트 관리"
|
||||
page_size = 20
|
||||
|
||||
column_list = [
|
||||
"id",
|
||||
"task_id",
|
||||
"img_name",
|
||||
"created_at",
|
||||
]
|
||||
|
||||
column_details_list = [
|
||||
"id",
|
||||
"task_id",
|
||||
"img_name",
|
||||
"img_url",
|
||||
"created_at",
|
||||
]
|
||||
|
||||
# 폼(생성/수정)에서 제외
|
||||
form_excluded_columns = ["created_at"]
|
||||
|
||||
column_searchable_list = [
|
||||
Image.task_id,
|
||||
Image.img_name,
|
||||
]
|
||||
|
||||
column_default_sort = (Image.created_at, True) # True: DESC (최신순)
|
||||
|
||||
column_sortable_list = [
|
||||
Image.id,
|
||||
Image.img_name,
|
||||
Image.created_at,
|
||||
]
|
||||
|
||||
column_labels = {
|
||||
"id": "ID",
|
||||
"task_id": "작업 ID",
|
||||
"img_name": "이미지명",
|
||||
"img_url": "이미지 URL",
|
||||
"created_at": "생성일시",
|
||||
}
|
||||
from sqladmin import ModelView
|
||||
|
||||
from app.home.models import Image, Project
|
||||
|
||||
|
||||
class ProjectAdmin(ModelView, model=Project):
|
||||
name = "프로젝트"
|
||||
name_plural = "프로젝트 목록"
|
||||
icon = "fa-solid fa-folder"
|
||||
category = "프로젝트 관리"
|
||||
page_size = 20
|
||||
|
||||
column_list = [
|
||||
"id",
|
||||
"store_name",
|
||||
"region",
|
||||
"task_id",
|
||||
"created_at",
|
||||
]
|
||||
|
||||
column_details_list = [
|
||||
"id",
|
||||
"store_name",
|
||||
"region",
|
||||
"task_id",
|
||||
"detail_region_info",
|
||||
"created_at",
|
||||
]
|
||||
|
||||
# 폼(생성/수정)에서 제외
|
||||
form_excluded_columns = ["created_at", "lyrics", "songs", "videos"]
|
||||
|
||||
column_searchable_list = [
|
||||
Project.store_name,
|
||||
Project.region,
|
||||
Project.task_id,
|
||||
]
|
||||
|
||||
column_default_sort = (Project.created_at, True) # True: DESC (최신순)
|
||||
|
||||
column_sortable_list = [
|
||||
Project.id,
|
||||
Project.store_name,
|
||||
Project.region,
|
||||
Project.created_at,
|
||||
]
|
||||
|
||||
column_labels = {
|
||||
"id": "ID",
|
||||
"store_name": "가게명",
|
||||
"region": "지역",
|
||||
"task_id": "작업 ID",
|
||||
"detail_region_info": "상세 지역 정보",
|
||||
"created_at": "생성일시",
|
||||
}
|
||||
|
||||
|
||||
class ImageAdmin(ModelView, model=Image):
|
||||
name = "이미지"
|
||||
name_plural = "이미지 목록"
|
||||
icon = "fa-solid fa-image"
|
||||
category = "프로젝트 관리"
|
||||
page_size = 20
|
||||
|
||||
column_list = [
|
||||
"id",
|
||||
"task_id",
|
||||
"img_name",
|
||||
"created_at",
|
||||
]
|
||||
|
||||
column_details_list = [
|
||||
"id",
|
||||
"task_id",
|
||||
"img_name",
|
||||
"img_url",
|
||||
"created_at",
|
||||
]
|
||||
|
||||
# 폼(생성/수정)에서 제외
|
||||
form_excluded_columns = ["created_at"]
|
||||
|
||||
column_searchable_list = [
|
||||
Image.task_id,
|
||||
Image.img_name,
|
||||
]
|
||||
|
||||
column_default_sort = (Image.created_at, True) # True: DESC (최신순)
|
||||
|
||||
column_sortable_list = [
|
||||
Image.id,
|
||||
Image.img_name,
|
||||
Image.created_at,
|
||||
]
|
||||
|
||||
column_labels = {
|
||||
"id": "ID",
|
||||
"task_id": "작업 ID",
|
||||
"img_name": "이미지명",
|
||||
"img_url": "이미지 URL",
|
||||
"created_at": "생성일시",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
"""API 1 Version Router Module."""
|
||||
|
||||
# from fastapi import APIRouter, Depends
|
||||
|
||||
# API 버전 1 라우터를 정의합니다.
|
||||
# router = APIRouter(
|
||||
# prefix="/api/v1",
|
||||
# dependencies=[Depends(check_use_api), Depends(set_current_connect)],
|
||||
# )
|
||||
# router = APIRouter(
|
||||
# prefix="/api/v1",
|
||||
# dependencies=[Depends(check_use_api), Depends(set_current_connect)],
|
||||
# )
|
||||
# router.include_router(auth.router, tags=[Tags.AUTH])
|
||||
# router.include_router(board.router, prefix="/boards", tags=[Tags.BOARD])
|
||||
"""API 1 Version Router Module."""
|
||||
|
||||
# from fastapi import APIRouter, Depends
|
||||
|
||||
# API 버전 1 라우터를 정의합니다.
|
||||
# router = APIRouter(
|
||||
# prefix="/api/v1",
|
||||
# dependencies=[Depends(check_use_api), Depends(set_current_connect)],
|
||||
# )
|
||||
# router = APIRouter(
|
||||
# prefix="/api/v1",
|
||||
# dependencies=[Depends(check_use_api), Depends(set_current_connect)],
|
||||
# )
|
||||
# router.include_router(auth.router, tags=[Tags.AUTH])
|
||||
# router.include_router(board.router, prefix="/boards", tags=[Tags.BOARD])
|
||||
|
|
|
|||
|
|
@ -1,215 +1,215 @@
|
|||
"""
|
||||
Home 모듈 SQLAlchemy 모델 정의
|
||||
|
||||
이 모듈은 영상 제작 파이프라인의 핵심 데이터 모델을 정의합니다.
|
||||
- Project: 프로젝트(사용자 입력 이력) 관리
|
||||
- Image: 업로드된 이미지 URL 관리
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, List, Optional
|
||||
|
||||
from sqlalchemy import DateTime, Index, Integer, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database.session import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.lyric.models import Lyric
|
||||
from app.song.models import Song
|
||||
from app.video.models import Video
|
||||
|
||||
|
||||
class Project(Base):
|
||||
"""
|
||||
프로젝트 테이블 (사용자 입력 이력)
|
||||
|
||||
영상 제작 요청의 시작점으로, 고객 정보와 지역 정보를 저장합니다.
|
||||
하위 테이블(Lyric, Song, Video)의 부모 테이블 역할을 합니다.
|
||||
|
||||
Attributes:
|
||||
id: 고유 식별자 (자동 증가)
|
||||
store_name: 고객명 (필수)
|
||||
region: 지역명 (필수, 예: 서울, 부산, 대구 등)
|
||||
task_id: 작업 고유 식별자 (UUID 형식, 36자)
|
||||
detail_region_info: 상세 지역 정보 (선택, JSON 또는 텍스트 형식)
|
||||
created_at: 생성 일시 (자동 설정)
|
||||
|
||||
Relationships:
|
||||
lyrics: 생성된 가사 목록
|
||||
songs: 생성된 노래 목록
|
||||
videos: 최종 영상 결과 목록
|
||||
"""
|
||||
|
||||
__tablename__ = "project"
|
||||
__table_args__ = (
|
||||
Index("idx_project_task_id", "task_id"),
|
||||
Index("idx_project_store_name", "store_name"),
|
||||
Index("idx_project_region", "region"),
|
||||
{
|
||||
"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="고유 식별자",
|
||||
)
|
||||
|
||||
store_name: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="가게명",
|
||||
)
|
||||
|
||||
region: Mapped[str] = mapped_column(
|
||||
String(100),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="지역명 (예: 군산)",
|
||||
)
|
||||
|
||||
task_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
nullable=False,
|
||||
comment="프로젝트 작업 고유 식별자 (UUID)",
|
||||
)
|
||||
|
||||
detail_region_info: Mapped[Optional[str]] = mapped_column(
|
||||
Text,
|
||||
nullable=True,
|
||||
comment="상세 지역 정보",
|
||||
)
|
||||
|
||||
language: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
nullable=False,
|
||||
default="Korean",
|
||||
comment="출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
|
||||
)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
comment="생성 일시",
|
||||
)
|
||||
|
||||
# Relationships
|
||||
lyrics: Mapped[List["Lyric"]] = relationship(
|
||||
"Lyric",
|
||||
back_populates="project",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
songs: Mapped[List["Song"]] = relationship(
|
||||
"Song",
|
||||
back_populates="project",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
videos: Mapped[List["Video"]] = relationship(
|
||||
"Video",
|
||||
back_populates="project",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
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"<Project("
|
||||
f"id={self.id}, "
|
||||
f"store_name='{self.store_name}', "
|
||||
f"task_id='{truncate(self.task_id)}'"
|
||||
f")>"
|
||||
)
|
||||
|
||||
|
||||
class Image(Base):
|
||||
"""
|
||||
업로드 이미지 테이블
|
||||
|
||||
사용자가 업로드한 이미지의 URL을 저장합니다.
|
||||
독립적으로 관리되며 Project와 직접적인 관계가 없습니다.
|
||||
|
||||
Attributes:
|
||||
id: 고유 식별자 (자동 증가)
|
||||
task_id: 이미지 업로드 작업 고유 식별자 (UUID)
|
||||
img_name: 이미지명
|
||||
img_url: 이미지 URL (S3, CDN 등의 경로)
|
||||
created_at: 생성 일시 (자동 설정)
|
||||
"""
|
||||
|
||||
__tablename__ = "image"
|
||||
__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="고유 식별자",
|
||||
)
|
||||
|
||||
task_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
nullable=False,
|
||||
comment="이미지 업로드 작업 고유 식별자 (UUID)",
|
||||
)
|
||||
|
||||
img_name: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
nullable=False,
|
||||
comment="이미지명",
|
||||
)
|
||||
|
||||
img_url: Mapped[str] = mapped_column(
|
||||
String(2048),
|
||||
nullable=False,
|
||||
comment="이미지 URL (blob, CDN 경로)",
|
||||
)
|
||||
|
||||
img_order: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
default=0,
|
||||
comment="이미지 순서",
|
||||
)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
comment="생성 일시",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
task_id_str = (
|
||||
(self.task_id[:10] + "...") if len(self.task_id) > 10 else self.task_id
|
||||
)
|
||||
img_name_str = (
|
||||
(self.img_name[:10] + "...") if len(self.img_name) > 10 else self.img_name
|
||||
)
|
||||
|
||||
return (
|
||||
f"<Image(id={self.id}, task_id='{task_id_str}', img_name='{img_name_str}')>"
|
||||
)
|
||||
"""
|
||||
Home 모듈 SQLAlchemy 모델 정의
|
||||
|
||||
이 모듈은 영상 제작 파이프라인의 핵심 데이터 모델을 정의합니다.
|
||||
- Project: 프로젝트(사용자 입력 이력) 관리
|
||||
- Image: 업로드된 이미지 URL 관리
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, List, Optional
|
||||
|
||||
from sqlalchemy import DateTime, Index, Integer, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database.session import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.lyric.models import Lyric
|
||||
from app.song.models import Song
|
||||
from app.video.models import Video
|
||||
|
||||
|
||||
class Project(Base):
|
||||
"""
|
||||
프로젝트 테이블 (사용자 입력 이력)
|
||||
|
||||
영상 제작 요청의 시작점으로, 고객 정보와 지역 정보를 저장합니다.
|
||||
하위 테이블(Lyric, Song, Video)의 부모 테이블 역할을 합니다.
|
||||
|
||||
Attributes:
|
||||
id: 고유 식별자 (자동 증가)
|
||||
store_name: 고객명 (필수)
|
||||
region: 지역명 (필수, 예: 서울, 부산, 대구 등)
|
||||
task_id: 작업 고유 식별자 (UUID 형식, 36자)
|
||||
detail_region_info: 상세 지역 정보 (선택, JSON 또는 텍스트 형식)
|
||||
created_at: 생성 일시 (자동 설정)
|
||||
|
||||
Relationships:
|
||||
lyrics: 생성된 가사 목록
|
||||
songs: 생성된 노래 목록
|
||||
videos: 최종 영상 결과 목록
|
||||
"""
|
||||
|
||||
__tablename__ = "project"
|
||||
__table_args__ = (
|
||||
Index("idx_project_task_id", "task_id"),
|
||||
Index("idx_project_store_name", "store_name"),
|
||||
Index("idx_project_region", "region"),
|
||||
{
|
||||
"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="고유 식별자",
|
||||
)
|
||||
|
||||
store_name: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="가게명",
|
||||
)
|
||||
|
||||
region: Mapped[str] = mapped_column(
|
||||
String(100),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="지역명 (예: 군산)",
|
||||
)
|
||||
|
||||
task_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
nullable=False,
|
||||
comment="프로젝트 작업 고유 식별자 (UUID)",
|
||||
)
|
||||
|
||||
detail_region_info: Mapped[Optional[str]] = mapped_column(
|
||||
Text,
|
||||
nullable=True,
|
||||
comment="상세 지역 정보",
|
||||
)
|
||||
|
||||
language: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
nullable=False,
|
||||
default="Korean",
|
||||
comment="출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
|
||||
)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
comment="생성 일시",
|
||||
)
|
||||
|
||||
# Relationships
|
||||
lyrics: Mapped[List["Lyric"]] = relationship(
|
||||
"Lyric",
|
||||
back_populates="project",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
songs: Mapped[List["Song"]] = relationship(
|
||||
"Song",
|
||||
back_populates="project",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
videos: Mapped[List["Video"]] = relationship(
|
||||
"Video",
|
||||
back_populates="project",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
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"<Project("
|
||||
f"id={self.id}, "
|
||||
f"store_name='{self.store_name}', "
|
||||
f"task_id='{truncate(self.task_id)}'"
|
||||
f")>"
|
||||
)
|
||||
|
||||
|
||||
class Image(Base):
|
||||
"""
|
||||
업로드 이미지 테이블
|
||||
|
||||
사용자가 업로드한 이미지의 URL을 저장합니다.
|
||||
독립적으로 관리되며 Project와 직접적인 관계가 없습니다.
|
||||
|
||||
Attributes:
|
||||
id: 고유 식별자 (자동 증가)
|
||||
task_id: 이미지 업로드 작업 고유 식별자 (UUID)
|
||||
img_name: 이미지명
|
||||
img_url: 이미지 URL (S3, CDN 등의 경로)
|
||||
created_at: 생성 일시 (자동 설정)
|
||||
"""
|
||||
|
||||
__tablename__ = "image"
|
||||
__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="고유 식별자",
|
||||
)
|
||||
|
||||
task_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
nullable=False,
|
||||
comment="이미지 업로드 작업 고유 식별자 (UUID)",
|
||||
)
|
||||
|
||||
img_name: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
nullable=False,
|
||||
comment="이미지명",
|
||||
)
|
||||
|
||||
img_url: Mapped[str] = mapped_column(
|
||||
String(2048),
|
||||
nullable=False,
|
||||
comment="이미지 URL (blob, CDN 경로)",
|
||||
)
|
||||
|
||||
img_order: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
default=0,
|
||||
comment="이미지 순서",
|
||||
)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
comment="생성 일시",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
task_id_str = (
|
||||
(self.task_id[:10] + "...") if len(self.task_id) > 10 else self.task_id
|
||||
)
|
||||
img_name_str = (
|
||||
(self.img_name[:10] + "...") if len(self.img_name) > 10 else self.img_name
|
||||
)
|
||||
|
||||
return (
|
||||
f"<Image(id={self.id}, task_id='{task_id_str}', img_name='{img_name_str}')>"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,260 +1,260 @@
|
|||
from typing import Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class AttributeInfo(BaseModel):
|
||||
"""음악 속성 정보"""
|
||||
|
||||
genre: str = Field(..., description="음악 장르")
|
||||
vocal: str = Field(..., description="보컬 스타일")
|
||||
tempo: str = Field(..., description="템포")
|
||||
mood: str = Field(..., description="분위기")
|
||||
|
||||
|
||||
class GenerateRequestImg(BaseModel):
|
||||
"""이미지 URL 스키마"""
|
||||
|
||||
url: str = Field(..., description="이미지 URL")
|
||||
name: Optional[str] = Field(None, description="이미지명 (없으면 URL에서 추출)")
|
||||
|
||||
|
||||
class GenerateRequestInfo(BaseModel):
|
||||
"""생성 요청 정보 스키마 (이미지 제외)"""
|
||||
|
||||
customer_name: str = Field(..., description="고객명/가게명")
|
||||
region: str = Field(..., description="지역명")
|
||||
detail_region_info: Optional[str] = Field(None, description="상세 지역 정보")
|
||||
attribute: AttributeInfo = Field(..., description="음악 속성 정보")
|
||||
language: str = Field(
|
||||
default="Korean",
|
||||
description="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
|
||||
)
|
||||
|
||||
|
||||
class GenerateRequest(GenerateRequestInfo):
|
||||
"""기본 생성 요청 스키마 (이미지 없음, JSON body)
|
||||
|
||||
이미지 없이 프로젝트 정보만 전달합니다.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"customer_name": "스테이 머뭄",
|
||||
"region": "군산",
|
||||
"detail_region_info": "군산 신흥동 말랭이 마을",
|
||||
"attribute": {
|
||||
"genre": "K-Pop",
|
||||
"vocal": "Raspy",
|
||||
"tempo": "110 BPM",
|
||||
"mood": "happy",
|
||||
},
|
||||
"language": "Korean",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class GenerateUrlsRequest(GenerateRequestInfo):
|
||||
"""URL 기반 생성 요청 스키마 (JSON body)
|
||||
|
||||
GenerateRequestInfo를 상속받아 이미지 목록을 추가합니다.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"customer_name": "스테이 머뭄",
|
||||
"region": "군산",
|
||||
"detail_region_info": "군산 신흥동 말랭이 마을",
|
||||
"attribute": {
|
||||
"genre": "K-Pop",
|
||||
"vocal": "Raspy",
|
||||
"tempo": "110 BPM",
|
||||
"mood": "happy",
|
||||
},
|
||||
"language": "Korean",
|
||||
"images": [
|
||||
{"url": "https://example.com/images/image_001.jpg"},
|
||||
{"url": "https://example.com/images/image_002.jpg", "name": "외관"},
|
||||
],
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
images: list[GenerateRequestImg] = Field(
|
||||
..., description="이미지 URL 목록", min_length=1
|
||||
)
|
||||
|
||||
|
||||
class GenerateUploadResponse(BaseModel):
|
||||
"""파일 업로드 기반 생성 응답 스키마"""
|
||||
|
||||
task_id: str = Field(..., description="작업 고유 식별자 (UUID7)")
|
||||
status: Literal["processing", "completed", "failed"] = Field(
|
||||
..., description="작업 상태"
|
||||
)
|
||||
message: str = Field(..., description="응답 메시지")
|
||||
uploaded_count: int = Field(..., description="업로드된 이미지 개수")
|
||||
|
||||
|
||||
class GenerateResponse(BaseModel):
|
||||
"""생성 응답 스키마"""
|
||||
|
||||
task_id: str = Field(..., description="작업 고유 식별자 (UUID7)")
|
||||
status: Literal["processing", "completed", "failed"] = Field(
|
||||
..., description="작업 상태"
|
||||
)
|
||||
message: str = Field(..., description="응답 메시지")
|
||||
|
||||
|
||||
class CrawlingRequest(BaseModel):
|
||||
"""크롤링 요청 스키마"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"url": "https://map.naver.com/p/search/%EC%8A%A4%ED%85%8C%EC%9D%B4%EB%A8%B8%EB%AD%84/place/1133638931?c=14.70,0,0,0,dh&placePath=/photo?businessCategory=pension&fromPanelNum=2&locale=ko&searchText=%EC%8A%A4%ED%85%8C%EC%9D%B4%EB%A8%B8%EB%AD%84&svcName=map_pcv5×tamp=202512191123&fromPanelNum=2&locale=ko&searchText=%EC%8A%A4%ED%85%8C%EC%9D%B4%EB%A8%B8%EB%AD%84&svcName=map_pcv5×tamp=202512191007&from=map&entry=bmp&filterType=%EC%97%85%EC%B2%B4&businessCategory=pension"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
url: str = Field(..., description="네이버 지도 장소 URL")
|
||||
|
||||
|
||||
class ProcessedInfo(BaseModel):
|
||||
"""가공된 장소 정보 스키마"""
|
||||
|
||||
customer_name: str = Field(..., description="고객명/가게명 (base_info.name)")
|
||||
region: str = Field(..., description="지역명 (roadAddress에서 추출한 시 이름)")
|
||||
detail_region_info: str = Field(..., description="상세 지역 정보 (roadAddress)")
|
||||
|
||||
|
||||
class MarketingAnalysis(BaseModel):
|
||||
"""마케팅 분석 결과 스키마"""
|
||||
|
||||
report: str = Field(..., description="마케팅 분석 리포트")
|
||||
tags: list[str] = Field(default_factory=list, description="추천 태그 목록")
|
||||
facilities: list[str] = Field(default_factory=list, description="추천 부대시설 목록")
|
||||
|
||||
|
||||
class CrawlingResponse(BaseModel):
|
||||
"""크롤링 응답 스키마"""
|
||||
|
||||
image_list: Optional[list[str]] = Field(None, description="이미지 URL 목록")
|
||||
image_count: int = Field(..., description="이미지 개수")
|
||||
processed_info: Optional[ProcessedInfo] = Field(
|
||||
None, description="가공된 장소 정보 (customer_name, region, detail_region_info)"
|
||||
)
|
||||
marketing_analysis: Optional[MarketingAnalysis] = Field(
|
||||
None, description="마케팅 분석 결과 (report, tags, facilities)"
|
||||
)
|
||||
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
"""에러 응답 스키마"""
|
||||
|
||||
success: bool = Field(default=False, description="요청 성공 여부")
|
||||
error_code: str = Field(..., description="에러 코드")
|
||||
message: str = Field(..., description="에러 메시지")
|
||||
detail: Optional[str] = Field(None, description="상세 에러 정보")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Image Upload Schemas
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class ImageUrlItem(BaseModel):
|
||||
"""이미지 URL 아이템 스키마"""
|
||||
|
||||
url: str = Field(..., description="외부 이미지 URL")
|
||||
name: Optional[str] = Field(None, description="이미지명 (없으면 URL에서 추출)")
|
||||
|
||||
|
||||
class ImageUploadRequest(BaseModel):
|
||||
"""이미지 업로드 요청 스키마 (JSON body 부분)
|
||||
|
||||
URL 이미지 목록을 전달합니다.
|
||||
바이너리 파일은 multipart/form-data로 별도 전달됩니다.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"images": [
|
||||
{"url": "https://example.com/images/image_001.jpg"},
|
||||
{"url": "https://example.com/images/image_002.jpg", "name": "외관"},
|
||||
]
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
images: Optional[list[ImageUrlItem]] = Field(
|
||||
None, description="외부 이미지 URL 목록"
|
||||
)
|
||||
|
||||
|
||||
class ImageUploadResultItem(BaseModel):
|
||||
"""업로드된 이미지 결과 아이템"""
|
||||
|
||||
id: int = Field(..., description="이미지 ID")
|
||||
img_name: str = Field(..., description="이미지명")
|
||||
img_url: str = Field(..., description="이미지 URL")
|
||||
img_order: int = Field(..., description="이미지 순서")
|
||||
source: Literal["url", "file", "blob"] = Field(
|
||||
..., description="이미지 소스 (url: 외부 URL, file: 로컬 서버, blob: Azure Blob)"
|
||||
)
|
||||
|
||||
|
||||
class ImageUploadResponse(BaseModel):
|
||||
"""이미지 업로드 응답 스키마"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
||||
"total_count": 3,
|
||||
"url_count": 2,
|
||||
"file_count": 1,
|
||||
"saved_count": 3,
|
||||
"images": [
|
||||
{
|
||||
"id": 1,
|
||||
"img_name": "외관",
|
||||
"img_url": "https://example.com/images/image_001.jpg",
|
||||
"img_order": 0,
|
||||
"source": "url",
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"img_name": "내부",
|
||||
"img_url": "https://example.com/images/image_002.jpg",
|
||||
"img_order": 1,
|
||||
"source": "url",
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"img_name": "uploaded_image.jpg",
|
||||
"img_url": "/media/image/2024-01-15/0694b716-dbff-7219-8000-d08cb5fce431/uploaded_image_002.jpg",
|
||||
"img_order": 2,
|
||||
"source": "file",
|
||||
},
|
||||
],
|
||||
"image_urls": [
|
||||
"https://example.com/images/image_001.jpg",
|
||||
"https://example.com/images/image_002.jpg",
|
||||
"/media/image/2024-01-15/0694b716-dbff-7219-8000-d08cb5fce431/uploaded_image_002.jpg",
|
||||
],
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
task_id: str = Field(..., description="작업 고유 식별자 (새로 생성된 UUID7)")
|
||||
total_count: int = Field(..., description="총 업로드된 이미지 개수")
|
||||
url_count: int = Field(..., description="URL로 등록된 이미지 개수")
|
||||
file_count: int = Field(..., description="파일로 업로드된 이미지 개수")
|
||||
saved_count: int = Field(..., description="Image 테이블에 저장된 row 수")
|
||||
images: list[ImageUploadResultItem] = Field(..., description="업로드된 이미지 목록")
|
||||
image_urls: list[str] = Field(..., description="Image 테이블에 저장된 현재 task_id의 이미지 URL 목록")
|
||||
from typing import Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class AttributeInfo(BaseModel):
|
||||
"""음악 속성 정보"""
|
||||
|
||||
genre: str = Field(..., description="음악 장르")
|
||||
vocal: str = Field(..., description="보컬 스타일")
|
||||
tempo: str = Field(..., description="템포")
|
||||
mood: str = Field(..., description="분위기")
|
||||
|
||||
|
||||
class GenerateRequestImg(BaseModel):
|
||||
"""이미지 URL 스키마"""
|
||||
|
||||
url: str = Field(..., description="이미지 URL")
|
||||
name: Optional[str] = Field(None, description="이미지명 (없으면 URL에서 추출)")
|
||||
|
||||
|
||||
class GenerateRequestInfo(BaseModel):
|
||||
"""생성 요청 정보 스키마 (이미지 제외)"""
|
||||
|
||||
customer_name: str = Field(..., description="고객명/가게명")
|
||||
region: str = Field(..., description="지역명")
|
||||
detail_region_info: Optional[str] = Field(None, description="상세 지역 정보")
|
||||
attribute: AttributeInfo = Field(..., description="음악 속성 정보")
|
||||
language: str = Field(
|
||||
default="Korean",
|
||||
description="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
|
||||
)
|
||||
|
||||
|
||||
class GenerateRequest(GenerateRequestInfo):
|
||||
"""기본 생성 요청 스키마 (이미지 없음, JSON body)
|
||||
|
||||
이미지 없이 프로젝트 정보만 전달합니다.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"customer_name": "스테이 머뭄",
|
||||
"region": "군산",
|
||||
"detail_region_info": "군산 신흥동 말랭이 마을",
|
||||
"attribute": {
|
||||
"genre": "K-Pop",
|
||||
"vocal": "Raspy",
|
||||
"tempo": "110 BPM",
|
||||
"mood": "happy",
|
||||
},
|
||||
"language": "Korean",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class GenerateUrlsRequest(GenerateRequestInfo):
|
||||
"""URL 기반 생성 요청 스키마 (JSON body)
|
||||
|
||||
GenerateRequestInfo를 상속받아 이미지 목록을 추가합니다.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"customer_name": "스테이 머뭄",
|
||||
"region": "군산",
|
||||
"detail_region_info": "군산 신흥동 말랭이 마을",
|
||||
"attribute": {
|
||||
"genre": "K-Pop",
|
||||
"vocal": "Raspy",
|
||||
"tempo": "110 BPM",
|
||||
"mood": "happy",
|
||||
},
|
||||
"language": "Korean",
|
||||
"images": [
|
||||
{"url": "https://example.com/images/image_001.jpg"},
|
||||
{"url": "https://example.com/images/image_002.jpg", "name": "외관"},
|
||||
],
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
images: list[GenerateRequestImg] = Field(
|
||||
..., description="이미지 URL 목록", min_length=1
|
||||
)
|
||||
|
||||
|
||||
class GenerateUploadResponse(BaseModel):
|
||||
"""파일 업로드 기반 생성 응답 스키마"""
|
||||
|
||||
task_id: str = Field(..., description="작업 고유 식별자 (UUID7)")
|
||||
status: Literal["processing", "completed", "failed"] = Field(
|
||||
..., description="작업 상태"
|
||||
)
|
||||
message: str = Field(..., description="응답 메시지")
|
||||
uploaded_count: int = Field(..., description="업로드된 이미지 개수")
|
||||
|
||||
|
||||
class GenerateResponse(BaseModel):
|
||||
"""생성 응답 스키마"""
|
||||
|
||||
task_id: str = Field(..., description="작업 고유 식별자 (UUID7)")
|
||||
status: Literal["processing", "completed", "failed"] = Field(
|
||||
..., description="작업 상태"
|
||||
)
|
||||
message: str = Field(..., description="응답 메시지")
|
||||
|
||||
|
||||
class CrawlingRequest(BaseModel):
|
||||
"""크롤링 요청 스키마"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"url": "https://map.naver.com/p/search/%EC%8A%A4%ED%85%8C%EC%9D%B4%EB%A8%B8%EB%AD%84/place/1133638931?c=14.70,0,0,0,dh&placePath=/photo?businessCategory=pension&fromPanelNum=2&locale=ko&searchText=%EC%8A%A4%ED%85%8C%EC%9D%B4%EB%A8%B8%EB%AD%84&svcName=map_pcv5×tamp=202512191123&fromPanelNum=2&locale=ko&searchText=%EC%8A%A4%ED%85%8C%EC%9D%B4%EB%A8%B8%EB%AD%84&svcName=map_pcv5×tamp=202512191007&from=map&entry=bmp&filterType=%EC%97%85%EC%B2%B4&businessCategory=pension"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
url: str = Field(..., description="네이버 지도 장소 URL")
|
||||
|
||||
|
||||
class ProcessedInfo(BaseModel):
|
||||
"""가공된 장소 정보 스키마"""
|
||||
|
||||
customer_name: str = Field(..., description="고객명/가게명 (base_info.name)")
|
||||
region: str = Field(..., description="지역명 (roadAddress에서 추출한 시 이름)")
|
||||
detail_region_info: str = Field(..., description="상세 지역 정보 (roadAddress)")
|
||||
|
||||
|
||||
class MarketingAnalysis(BaseModel):
|
||||
"""마케팅 분석 결과 스키마"""
|
||||
|
||||
report: str = Field(..., description="마케팅 분석 리포트")
|
||||
tags: list[str] = Field(default_factory=list, description="추천 태그 목록")
|
||||
facilities: list[str] = Field(default_factory=list, description="추천 부대시설 목록")
|
||||
|
||||
|
||||
class CrawlingResponse(BaseModel):
|
||||
"""크롤링 응답 스키마"""
|
||||
|
||||
image_list: Optional[list[str]] = Field(None, description="이미지 URL 목록")
|
||||
image_count: int = Field(..., description="이미지 개수")
|
||||
processed_info: Optional[ProcessedInfo] = Field(
|
||||
None, description="가공된 장소 정보 (customer_name, region, detail_region_info)"
|
||||
)
|
||||
marketing_analysis: Optional[MarketingAnalysis] = Field(
|
||||
None, description="마케팅 분석 결과 (report, tags, facilities)"
|
||||
)
|
||||
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
"""에러 응답 스키마"""
|
||||
|
||||
success: bool = Field(default=False, description="요청 성공 여부")
|
||||
error_code: str = Field(..., description="에러 코드")
|
||||
message: str = Field(..., description="에러 메시지")
|
||||
detail: Optional[str] = Field(None, description="상세 에러 정보")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Image Upload Schemas
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class ImageUrlItem(BaseModel):
|
||||
"""이미지 URL 아이템 스키마"""
|
||||
|
||||
url: str = Field(..., description="외부 이미지 URL")
|
||||
name: Optional[str] = Field(None, description="이미지명 (없으면 URL에서 추출)")
|
||||
|
||||
|
||||
class ImageUploadRequest(BaseModel):
|
||||
"""이미지 업로드 요청 스키마 (JSON body 부분)
|
||||
|
||||
URL 이미지 목록을 전달합니다.
|
||||
바이너리 파일은 multipart/form-data로 별도 전달됩니다.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"images": [
|
||||
{"url": "https://example.com/images/image_001.jpg"},
|
||||
{"url": "https://example.com/images/image_002.jpg", "name": "외관"},
|
||||
]
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
images: Optional[list[ImageUrlItem]] = Field(
|
||||
None, description="외부 이미지 URL 목록"
|
||||
)
|
||||
|
||||
|
||||
class ImageUploadResultItem(BaseModel):
|
||||
"""업로드된 이미지 결과 아이템"""
|
||||
|
||||
id: int = Field(..., description="이미지 ID")
|
||||
img_name: str = Field(..., description="이미지명")
|
||||
img_url: str = Field(..., description="이미지 URL")
|
||||
img_order: int = Field(..., description="이미지 순서")
|
||||
source: Literal["url", "file", "blob"] = Field(
|
||||
..., description="이미지 소스 (url: 외부 URL, file: 로컬 서버, blob: Azure Blob)"
|
||||
)
|
||||
|
||||
|
||||
class ImageUploadResponse(BaseModel):
|
||||
"""이미지 업로드 응답 스키마"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
||||
"total_count": 3,
|
||||
"url_count": 2,
|
||||
"file_count": 1,
|
||||
"saved_count": 3,
|
||||
"images": [
|
||||
{
|
||||
"id": 1,
|
||||
"img_name": "외관",
|
||||
"img_url": "https://example.com/images/image_001.jpg",
|
||||
"img_order": 0,
|
||||
"source": "url",
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"img_name": "내부",
|
||||
"img_url": "https://example.com/images/image_002.jpg",
|
||||
"img_order": 1,
|
||||
"source": "url",
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"img_name": "uploaded_image.jpg",
|
||||
"img_url": "/media/image/2024-01-15/0694b716-dbff-7219-8000-d08cb5fce431/uploaded_image_002.jpg",
|
||||
"img_order": 2,
|
||||
"source": "file",
|
||||
},
|
||||
],
|
||||
"image_urls": [
|
||||
"https://example.com/images/image_001.jpg",
|
||||
"https://example.com/images/image_002.jpg",
|
||||
"/media/image/2024-01-15/0694b716-dbff-7219-8000-d08cb5fce431/uploaded_image_002.jpg",
|
||||
],
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
task_id: str = Field(..., description="작업 고유 식별자 (새로 생성된 UUID7)")
|
||||
total_count: int = Field(..., description="총 업로드된 이미지 개수")
|
||||
url_count: int = Field(..., description="URL로 등록된 이미지 개수")
|
||||
file_count: int = Field(..., description="파일로 업로드된 이미지 개수")
|
||||
saved_count: int = Field(..., description="Image 테이블에 저장된 row 수")
|
||||
images: list[ImageUploadResultItem] = Field(..., description="업로드된 이미지 목록")
|
||||
image_urls: list[str] = Field(..., description="Image 테이블에 저장된 현재 task_id의 이미지 URL 목록")
|
||||
|
|
|
|||
|
|
@ -1,24 +1,24 @@
|
|||
from uuid import UUID
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
|
||||
class BaseService:
|
||||
def __init__(self, model, session: AsyncSession):
|
||||
self.model = model
|
||||
self.session = session
|
||||
|
||||
async def _get(self, id: UUID):
|
||||
return await self.session.get(self.model, id)
|
||||
|
||||
async def _add(self, entity):
|
||||
self.session.add(entity)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def _update(self, entity):
|
||||
return await self._add(entity)
|
||||
|
||||
async def _delete(self, entity):
|
||||
from uuid import UUID
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
|
||||
class BaseService:
|
||||
def __init__(self, model, session: AsyncSession):
|
||||
self.model = model
|
||||
self.session = session
|
||||
|
||||
async def _get(self, id: UUID):
|
||||
return await self.session.get(self.model, id)
|
||||
|
||||
async def _add(self, entity):
|
||||
self.session.add(entity)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def _update(self, entity):
|
||||
return await self._add(entity)
|
||||
|
||||
async def _delete(self, entity):
|
||||
await self.session.delete(entity)
|
||||
|
|
@ -1,48 +1,48 @@
|
|||
from typing import AsyncGenerator
|
||||
|
||||
import pytest_asyncio
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from app.database.session import Base
|
||||
from config import db_settings
|
||||
|
||||
# 테스트 전용 DB URL
|
||||
TEST_DB_URL = db_settings.MYSQL_URL.replace(
|
||||
f"/{db_settings.MYSQL_DB}",
|
||||
"/test_db", # 별도 테스트 DB 사용
|
||||
)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def test_engine():
|
||||
"""각 테스트마다 생성되는 테스트 엔진"""
|
||||
engine = create_async_engine(
|
||||
TEST_DB_URL,
|
||||
poolclass=NullPool, # 테스트에서는 풀 비활성화
|
||||
echo=True, # SQL 쿼리 로깅
|
||||
)
|
||||
|
||||
# 테스트 테이블 생성
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
yield engine
|
||||
|
||||
# 테스트 테이블 삭제
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def db_session(test_engine) -> AsyncGenerator[AsyncSession, None]:
|
||||
"""각 테스트마다 새로운 세션 (격리 보장)"""
|
||||
async_session = async_sessionmaker(
|
||||
test_engine, class_=AsyncSession, expire_on_commit=False
|
||||
)
|
||||
|
||||
async with async_session() as session:
|
||||
yield session
|
||||
await session.rollback() # 테스트 후 롤백
|
||||
from typing import AsyncGenerator
|
||||
|
||||
import pytest_asyncio
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from app.database.session import Base
|
||||
from config import db_settings
|
||||
|
||||
# 테스트 전용 DB URL
|
||||
TEST_DB_URL = db_settings.MYSQL_URL.replace(
|
||||
f"/{db_settings.MYSQL_DB}",
|
||||
"/test_db", # 별도 테스트 DB 사용
|
||||
)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def test_engine():
|
||||
"""각 테스트마다 생성되는 테스트 엔진"""
|
||||
engine = create_async_engine(
|
||||
TEST_DB_URL,
|
||||
poolclass=NullPool, # 테스트에서는 풀 비활성화
|
||||
echo=True, # SQL 쿼리 로깅
|
||||
)
|
||||
|
||||
# 테스트 테이블 생성
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
yield engine
|
||||
|
||||
# 테스트 테이블 삭제
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def db_session(test_engine) -> AsyncGenerator[AsyncSession, None]:
|
||||
"""각 테스트마다 새로운 세션 (격리 보장)"""
|
||||
async_session = async_sessionmaker(
|
||||
test_engine, class_=AsyncSession, expire_on_commit=False
|
||||
)
|
||||
|
||||
async with async_session() as session:
|
||||
yield session
|
||||
await session.rollback() # 테스트 후 롤백
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
import pytest
|
||||
from sqlalchemy import text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_database_connection(test_engine):
|
||||
"""테스트 엔진을 사용한 연결 테스트"""
|
||||
async with test_engine.begin() as connection:
|
||||
result = await connection.execute(text("SELECT 1"))
|
||||
assert result.scalar() == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_usage(db_session):
|
||||
"""세션을 사용한 테스트"""
|
||||
result = await db_session.execute(text("SELECT 1 as num"))
|
||||
assert result.scalar() == 1
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_database_connection(test_engine):
|
||||
"""테스트 엔진을 사용한 연결 테스트"""
|
||||
async with test_engine.begin() as connection:
|
||||
result = await connection.execute(text("SELECT 1"))
|
||||
assert result.scalar() == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_usage(db_session):
|
||||
"""세션을 사용한 테스트"""
|
||||
result = await db_session.execute(text("SELECT 1 as num"))
|
||||
assert result.scalar() == 1
|
||||
|
|
|
|||
|
|
@ -1,30 +1,30 @@
|
|||
import pytest
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.database.session import AsyncSessionLocal, engine
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_database_connection():
|
||||
"""데이터베이스 연결 테스트"""
|
||||
async with engine.begin() as connection:
|
||||
result = await connection.execute(text("SELECT 1"))
|
||||
assert result.scalar() == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_creation():
|
||||
"""세션 생성 테스트"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(text("SELECT 1"))
|
||||
assert result.scalar() == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_database_version():
|
||||
"""MySQL 버전 확인 테스트"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(text("SELECT VERSION()"))
|
||||
version = result.scalar()
|
||||
assert version is not None
|
||||
print(f"MySQL Version: {version}")
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.database.session import AsyncSessionLocal, engine
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_database_connection():
|
||||
"""데이터베이스 연결 테스트"""
|
||||
async with engine.begin() as connection:
|
||||
result = await connection.execute(text("SELECT 1"))
|
||||
assert result.scalar() == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_creation():
|
||||
"""세션 생성 테스트"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(text("SELECT 1"))
|
||||
assert result.scalar() == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_database_version():
|
||||
"""MySQL 버전 확인 테스트"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(text("SELECT VERSION()"))
|
||||
version = result.scalar()
|
||||
assert version is not None
|
||||
print(f"MySQL Version: {version}")
|
||||
|
|
|
|||
|
|
@ -1,61 +1,61 @@
|
|||
from sqladmin import ModelView
|
||||
|
||||
from app.lyric.models import Lyric
|
||||
|
||||
|
||||
class LyricAdmin(ModelView, model=Lyric):
|
||||
name = "가사"
|
||||
name_plural = "가사 목록"
|
||||
icon = "fa-solid fa-music"
|
||||
category = "가사 관리"
|
||||
page_size = 20
|
||||
|
||||
column_list = [
|
||||
"id",
|
||||
"project_id",
|
||||
"task_id",
|
||||
"status",
|
||||
"language",
|
||||
"created_at",
|
||||
]
|
||||
|
||||
column_details_list = [
|
||||
"id",
|
||||
"project_id",
|
||||
"task_id",
|
||||
"status",
|
||||
"language",
|
||||
"lyric_prompt",
|
||||
"lyric_result",
|
||||
"created_at",
|
||||
]
|
||||
|
||||
# 폼(생성/수정)에서 제외
|
||||
form_excluded_columns = ["created_at", "songs", "videos"]
|
||||
|
||||
column_searchable_list = [
|
||||
Lyric.task_id,
|
||||
Lyric.status,
|
||||
Lyric.language,
|
||||
]
|
||||
|
||||
column_default_sort = (Lyric.created_at, True) # True: DESC (최신순)
|
||||
|
||||
column_sortable_list = [
|
||||
Lyric.id,
|
||||
Lyric.project_id,
|
||||
Lyric.status,
|
||||
Lyric.language,
|
||||
Lyric.created_at,
|
||||
]
|
||||
|
||||
column_labels = {
|
||||
"id": "ID",
|
||||
"project_id": "프로젝트 ID",
|
||||
"task_id": "작업 ID",
|
||||
"status": "상태",
|
||||
"language": "언어",
|
||||
"lyric_prompt": "프롬프트",
|
||||
"lyric_result": "생성 결과",
|
||||
"created_at": "생성일시",
|
||||
}
|
||||
from sqladmin import ModelView
|
||||
|
||||
from app.lyric.models import Lyric
|
||||
|
||||
|
||||
class LyricAdmin(ModelView, model=Lyric):
|
||||
name = "가사"
|
||||
name_plural = "가사 목록"
|
||||
icon = "fa-solid fa-music"
|
||||
category = "가사 관리"
|
||||
page_size = 20
|
||||
|
||||
column_list = [
|
||||
"id",
|
||||
"project_id",
|
||||
"task_id",
|
||||
"status",
|
||||
"language",
|
||||
"created_at",
|
||||
]
|
||||
|
||||
column_details_list = [
|
||||
"id",
|
||||
"project_id",
|
||||
"task_id",
|
||||
"status",
|
||||
"language",
|
||||
"lyric_prompt",
|
||||
"lyric_result",
|
||||
"created_at",
|
||||
]
|
||||
|
||||
# 폼(생성/수정)에서 제외
|
||||
form_excluded_columns = ["created_at", "songs", "videos"]
|
||||
|
||||
column_searchable_list = [
|
||||
Lyric.task_id,
|
||||
Lyric.status,
|
||||
Lyric.language,
|
||||
]
|
||||
|
||||
column_default_sort = (Lyric.created_at, True) # True: DESC (최신순)
|
||||
|
||||
column_sortable_list = [
|
||||
Lyric.id,
|
||||
Lyric.project_id,
|
||||
Lyric.status,
|
||||
Lyric.language,
|
||||
Lyric.created_at,
|
||||
]
|
||||
|
||||
column_labels = {
|
||||
"id": "ID",
|
||||
"project_id": "프로젝트 ID",
|
||||
"task_id": "작업 ID",
|
||||
"status": "상태",
|
||||
"language": "언어",
|
||||
"lyric_prompt": "프롬프트",
|
||||
"lyric_result": "생성 결과",
|
||||
"created_at": "생성일시",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,417 +1,448 @@
|
|||
"""
|
||||
Lyric API Router
|
||||
|
||||
이 모듈은 가사 관련 API 엔드포인트를 정의합니다.
|
||||
모든 엔드포인트는 재사용 가능하도록 설계되었습니다.
|
||||
|
||||
엔드포인트 목록:
|
||||
- POST /lyric/generate: 가사 생성
|
||||
- GET /lyric/status/{task_id}: 가사 생성 상태 조회
|
||||
- GET /lyric/{task_id}: 가사 상세 조회
|
||||
- GET /lyrics: 가사 목록 조회 (페이지네이션)
|
||||
|
||||
사용 예시:
|
||||
from app.lyric.api.routers.v1.lyric import router
|
||||
app.include_router(router, prefix="/api/v1")
|
||||
|
||||
다른 서비스에서 재사용:
|
||||
# 이 파일의 헬퍼 함수들을 import하여 사용 가능
|
||||
from app.lyric.api.routers.v1.lyric import (
|
||||
get_lyric_status_by_task_id,
|
||||
get_lyric_by_task_id,
|
||||
)
|
||||
|
||||
# 페이지네이션은 pagination 모듈 사용
|
||||
from app.utils.pagination import PaginatedResponse, get_paginated
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.session import get_session
|
||||
from app.home.models import Project
|
||||
from app.lyric.models import Lyric
|
||||
from app.lyric.schemas.lyric import (
|
||||
GenerateLyricRequest,
|
||||
GenerateLyricResponse,
|
||||
LyricDetailResponse,
|
||||
LyricListItem,
|
||||
LyricStatusResponse,
|
||||
)
|
||||
from app.lyric.worker.lyric_task import generate_lyric_background
|
||||
from app.utils.chatgpt_prompt import ChatgptService
|
||||
from app.utils.pagination import PaginatedResponse, get_paginated
|
||||
|
||||
router = APIRouter(prefix="/lyric", tags=["lyric"])
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Reusable Service Functions (다른 모듈에서 import하여 사용 가능)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
async def get_lyric_status_by_task_id(
|
||||
session: AsyncSession, task_id: str
|
||||
) -> LyricStatusResponse:
|
||||
"""task_id로 가사 생성 작업의 상태를 조회합니다.
|
||||
|
||||
Args:
|
||||
session: SQLAlchemy AsyncSession
|
||||
task_id: 작업 고유 식별자
|
||||
|
||||
Returns:
|
||||
LyricStatusResponse: 상태 정보
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 - task_id에 해당하는 가사가 없는 경우
|
||||
|
||||
Usage:
|
||||
# 다른 서비스에서 사용
|
||||
from app.lyric.api.routers.v1.lyric import get_lyric_status_by_task_id
|
||||
|
||||
status_info = await get_lyric_status_by_task_id(session, "some-task-id")
|
||||
if status_info.status == "completed":
|
||||
# 완료 처리
|
||||
"""
|
||||
print(f"[get_lyric_status_by_task_id] START - task_id: {task_id}")
|
||||
result = await session.execute(
|
||||
select(Lyric)
|
||||
.where(Lyric.task_id == task_id)
|
||||
.order_by(Lyric.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
lyric = result.scalar_one_or_none()
|
||||
|
||||
if not lyric:
|
||||
print(f"[get_lyric_status_by_task_id] NOT FOUND - task_id: {task_id}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"task_id '{task_id}'에 해당하는 가사를 찾을 수 없습니다.",
|
||||
)
|
||||
|
||||
status_messages = {
|
||||
"processing": "가사 생성 중입니다.",
|
||||
"completed": "가사 생성이 완료되었습니다.",
|
||||
"failed": "가사 생성에 실패했습니다.",
|
||||
}
|
||||
|
||||
print(
|
||||
f"[get_lyric_status_by_task_id] SUCCESS - task_id: {task_id}, status: {lyric.status}"
|
||||
)
|
||||
return LyricStatusResponse(
|
||||
task_id=lyric.task_id,
|
||||
status=lyric.status,
|
||||
message=status_messages.get(lyric.status, "알 수 없는 상태입니다."),
|
||||
)
|
||||
|
||||
|
||||
async def get_lyric_by_task_id(
|
||||
session: AsyncSession, task_id: str
|
||||
) -> LyricDetailResponse:
|
||||
"""task_id로 생성된 가사 상세 정보를 조회합니다.
|
||||
|
||||
Args:
|
||||
session: SQLAlchemy AsyncSession
|
||||
task_id: 작업 고유 식별자
|
||||
|
||||
Returns:
|
||||
LyricDetailResponse: 가사 상세 정보
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 - task_id에 해당하는 가사가 없는 경우
|
||||
|
||||
Usage:
|
||||
# 다른 서비스에서 사용
|
||||
from app.lyric.api.routers.v1.lyric import get_lyric_by_task_id
|
||||
|
||||
lyric = await get_lyric_by_task_id(session, task_id)
|
||||
"""
|
||||
print(f"[get_lyric_by_task_id] START - task_id: {task_id}")
|
||||
result = await session.execute(
|
||||
select(Lyric)
|
||||
.where(Lyric.task_id == task_id)
|
||||
.order_by(Lyric.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
lyric = result.scalar_one_or_none()
|
||||
|
||||
if not lyric:
|
||||
print(f"[get_lyric_by_task_id] NOT FOUND - task_id: {task_id}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"task_id '{task_id}'에 해당하는 가사를 찾을 수 없습니다.",
|
||||
)
|
||||
|
||||
print(f"[get_lyric_by_task_id] SUCCESS - task_id: {task_id}, lyric_id: {lyric.id}")
|
||||
return LyricDetailResponse(
|
||||
id=lyric.id,
|
||||
task_id=lyric.task_id,
|
||||
project_id=lyric.project_id,
|
||||
status=lyric.status,
|
||||
lyric_prompt=lyric.lyric_prompt,
|
||||
lyric_result=lyric.lyric_result,
|
||||
created_at=lyric.created_at,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# API Endpoints
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@router.post(
|
||||
"/generate",
|
||||
summary="가사 생성",
|
||||
description="""
|
||||
고객 정보를 기반으로 ChatGPT를 이용하여 가사를 생성합니다.
|
||||
백그라운드에서 비동기로 처리되며, 즉시 task_id를 반환합니다.
|
||||
|
||||
## 요청 필드
|
||||
- **task_id**: 작업 고유 식별자 (이미지 업로드 시 생성된 task_id, 필수)
|
||||
- **customer_name**: 고객명/가게명 (필수)
|
||||
- **region**: 지역명 (필수)
|
||||
- **detail_region_info**: 상세 지역 정보 (선택)
|
||||
- **language**: 가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)
|
||||
|
||||
## 반환 정보
|
||||
- **success**: 요청 접수 성공 여부
|
||||
- **task_id**: 작업 고유 식별자
|
||||
- **lyric**: null (백그라운드 처리 중)
|
||||
- **language**: 가사 언어
|
||||
- **error_message**: 에러 메시지 (요청 접수 실패 시)
|
||||
|
||||
## 상태 확인
|
||||
- GET /lyric/status/{task_id} 로 처리 상태 확인
|
||||
- GET /lyric/{task_id} 로 생성된 가사 조회
|
||||
|
||||
## 사용 예시
|
||||
```
|
||||
POST /lyric/generate
|
||||
{
|
||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
||||
"customer_name": "스테이 머뭄",
|
||||
"region": "군산",
|
||||
"detail_region_info": "군산 신흥동 말랭이 마을",
|
||||
"language": "Korean"
|
||||
}
|
||||
```
|
||||
|
||||
## 응답 예시
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
||||
"lyric": null,
|
||||
"language": "Korean",
|
||||
"error_message": null
|
||||
}
|
||||
```
|
||||
""",
|
||||
response_model=GenerateLyricResponse,
|
||||
responses={
|
||||
200: {"description": "가사 생성 요청 접수 성공"},
|
||||
500: {"description": "서버 내부 오류"},
|
||||
},
|
||||
)
|
||||
async def generate_lyric(
|
||||
request_body: GenerateLyricRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> GenerateLyricResponse:
|
||||
"""고객 정보를 기반으로 가사를 생성합니다. (백그라운드 처리)"""
|
||||
task_id = request_body.task_id
|
||||
print(
|
||||
f"[generate_lyric] START - task_id: {task_id}, "
|
||||
f"customer_name: {request_body.customer_name}, "
|
||||
f"region: {request_body.region}"
|
||||
)
|
||||
|
||||
try:
|
||||
# 1. ChatGPT 서비스 초기화 및 프롬프트 생성
|
||||
service = ChatgptService(
|
||||
customer_name=request_body.customer_name,
|
||||
region=request_body.region,
|
||||
detail_region_info=request_body.detail_region_info or "",
|
||||
language=request_body.language,
|
||||
)
|
||||
prompt = service.build_lyrics_prompt()
|
||||
|
||||
# 2. Project 테이블에 데이터 저장
|
||||
project = Project(
|
||||
store_name=request_body.customer_name,
|
||||
region=request_body.region,
|
||||
task_id=task_id,
|
||||
detail_region_info=request_body.detail_region_info,
|
||||
language=request_body.language,
|
||||
)
|
||||
session.add(project)
|
||||
await session.commit()
|
||||
await session.refresh(project)
|
||||
print(
|
||||
f"[generate_lyric] Project saved - "
|
||||
f"project_id: {project.id}, task_id: {task_id}"
|
||||
)
|
||||
|
||||
# 3. Lyric 테이블에 데이터 저장 (status: processing)
|
||||
lyric = Lyric(
|
||||
project_id=project.id,
|
||||
task_id=task_id,
|
||||
status="processing",
|
||||
lyric_prompt=prompt,
|
||||
lyric_result=None,
|
||||
language=request_body.language,
|
||||
)
|
||||
session.add(lyric)
|
||||
await session.commit()
|
||||
await session.refresh(lyric)
|
||||
print(
|
||||
f"[generate_lyric] Lyric saved (processing) - "
|
||||
f"lyric_id: {lyric.id}, task_id: {task_id}"
|
||||
)
|
||||
|
||||
# 4. 백그라운드 태스크로 ChatGPT 가사 생성 실행
|
||||
background_tasks.add_task(
|
||||
generate_lyric_background,
|
||||
task_id=task_id,
|
||||
prompt=prompt,
|
||||
language=request_body.language,
|
||||
)
|
||||
print(f"[generate_lyric] Background task scheduled - task_id: {task_id}")
|
||||
|
||||
# 5. 즉시 응답 반환
|
||||
return GenerateLyricResponse(
|
||||
success=True,
|
||||
task_id=task_id,
|
||||
lyric=None,
|
||||
language=request_body.language,
|
||||
error_message=None,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"[generate_lyric] EXCEPTION - task_id: {task_id}, error: {e}")
|
||||
await session.rollback()
|
||||
return GenerateLyricResponse(
|
||||
success=False,
|
||||
task_id=task_id,
|
||||
lyric=None,
|
||||
language=request_body.language,
|
||||
error_message=str(e),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/status/{task_id}",
|
||||
summary="가사 생성 상태 조회",
|
||||
description="""
|
||||
task_id로 가사 생성 작업의 현재 상태를 조회합니다.
|
||||
|
||||
## 상태 값
|
||||
- **processing**: 가사 생성 중
|
||||
- **completed**: 가사 생성 완료
|
||||
- **failed**: 가사 생성 실패
|
||||
|
||||
## 사용 예시
|
||||
```
|
||||
GET /lyric/status/019123ab-cdef-7890-abcd-ef1234567890
|
||||
```
|
||||
""",
|
||||
response_model=LyricStatusResponse,
|
||||
responses={
|
||||
200: {"description": "상태 조회 성공"},
|
||||
404: {"description": "해당 task_id를 찾을 수 없음"},
|
||||
},
|
||||
)
|
||||
async def get_lyric_status(
|
||||
task_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> LyricStatusResponse:
|
||||
"""task_id로 가사 생성 작업 상태를 조회합니다."""
|
||||
return await get_lyric_status_by_task_id(session, task_id)
|
||||
|
||||
|
||||
@router.get(
|
||||
"s",
|
||||
summary="가사 목록 조회 (페이지네이션)",
|
||||
description="""
|
||||
생성 완료된 가사를 페이지네이션으로 조회합니다.
|
||||
|
||||
## 파라미터
|
||||
- **page**: 페이지 번호 (1부터 시작, 기본값: 1)
|
||||
- **page_size**: 페이지당 데이터 수 (기본값: 20, 최대: 100)
|
||||
|
||||
## 반환 정보
|
||||
- **items**: 가사 목록 (completed 상태만)
|
||||
- **total**: 전체 데이터 수
|
||||
- **page**: 현재 페이지
|
||||
- **page_size**: 페이지당 데이터 수
|
||||
- **total_pages**: 전체 페이지 수
|
||||
- **has_next**: 다음 페이지 존재 여부
|
||||
- **has_prev**: 이전 페이지 존재 여부
|
||||
|
||||
## 사용 예시
|
||||
```
|
||||
GET /lyrics # 기본 조회 (1페이지, 20개)
|
||||
GET /lyrics?page=2 # 2페이지 조회
|
||||
GET /lyrics?page=1&page_size=50 # 50개씩 조회
|
||||
```
|
||||
|
||||
## 참고
|
||||
- 생성 완료(completed)된 가사만 조회됩니다.
|
||||
- processing, failed 상태의 가사는 조회되지 않습니다.
|
||||
""",
|
||||
response_model=PaginatedResponse[LyricListItem],
|
||||
responses={
|
||||
200: {"description": "가사 목록 조회 성공"},
|
||||
},
|
||||
)
|
||||
async def list_lyrics(
|
||||
page: int = Query(1, ge=1, description="페이지 번호 (1부터 시작)"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="페이지당 데이터 수"),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> PaginatedResponse[LyricListItem]:
|
||||
"""페이지네이션으로 완료된 가사 목록을 조회합니다."""
|
||||
return await get_paginated(
|
||||
session=session,
|
||||
model=Lyric,
|
||||
item_schema=LyricListItem,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
filters={"status": "completed"},
|
||||
order_by="created_at",
|
||||
order_desc=True,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{task_id}",
|
||||
summary="가사 상세 조회",
|
||||
description="""
|
||||
task_id로 생성된 가사의 상세 정보를 조회합니다.
|
||||
|
||||
## 반환 정보
|
||||
- **id**: 가사 ID
|
||||
- **task_id**: 작업 고유 식별자
|
||||
- **project_id**: 프로젝트 ID
|
||||
- **status**: 처리 상태
|
||||
- **lyric_prompt**: 가사 생성에 사용된 프롬프트
|
||||
- **lyric_result**: 생성된 가사 (완료 시)
|
||||
- **created_at**: 생성 일시
|
||||
|
||||
## 사용 예시
|
||||
```
|
||||
GET /lyric/019123ab-cdef-7890-abcd-ef1234567890
|
||||
```
|
||||
""",
|
||||
response_model=LyricDetailResponse,
|
||||
responses={
|
||||
200: {"description": "가사 조회 성공"},
|
||||
404: {"description": "해당 task_id를 찾을 수 없음"},
|
||||
},
|
||||
)
|
||||
async def get_lyric_detail(
|
||||
task_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> LyricDetailResponse:
|
||||
"""task_id로 생성된 가사를 조회합니다."""
|
||||
return await get_lyric_by_task_id(session, task_id)
|
||||
"""
|
||||
Lyric API Router
|
||||
|
||||
이 모듈은 가사 관련 API 엔드포인트를 정의합니다.
|
||||
모든 엔드포인트는 재사용 가능하도록 설계되었습니다.
|
||||
|
||||
엔드포인트 목록:
|
||||
- POST /lyric/generate: 가사 생성
|
||||
- GET /lyric/status/{task_id}: 가사 생성 상태 조회
|
||||
- GET /lyric/{task_id}: 가사 상세 조회
|
||||
- GET /lyrics: 가사 목록 조회 (페이지네이션)
|
||||
|
||||
사용 예시:
|
||||
from app.lyric.api.routers.v1.lyric import router
|
||||
app.include_router(router, prefix="/api/v1")
|
||||
|
||||
다른 서비스에서 재사용:
|
||||
# 이 파일의 헬퍼 함수들을 import하여 사용 가능
|
||||
from app.lyric.api.routers.v1.lyric import (
|
||||
get_lyric_status_by_task_id,
|
||||
get_lyric_by_task_id,
|
||||
)
|
||||
|
||||
# 페이지네이션은 pagination 모듈 사용
|
||||
from app.utils.pagination import PaginatedResponse, get_paginated
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.session import get_session
|
||||
from app.home.models import Project
|
||||
from app.lyric.models import Lyric
|
||||
from app.lyric.schemas.lyric import (
|
||||
GenerateLyricRequest,
|
||||
GenerateLyricResponse,
|
||||
LyricDetailResponse,
|
||||
LyricListItem,
|
||||
LyricStatusResponse,
|
||||
)
|
||||
from app.lyric.worker.lyric_task import generate_lyric_background
|
||||
from app.utils.chatgpt_prompt import ChatgptService
|
||||
from app.utils.pagination import PaginatedResponse, get_paginated
|
||||
|
||||
router = APIRouter(prefix="/lyric", tags=["lyric"])
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Reusable Service Functions (다른 모듈에서 import하여 사용 가능)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
async def get_lyric_status_by_task_id(
|
||||
session: AsyncSession, task_id: str
|
||||
) -> LyricStatusResponse:
|
||||
"""task_id로 가사 생성 작업의 상태를 조회합니다.
|
||||
|
||||
Args:
|
||||
session: SQLAlchemy AsyncSession
|
||||
task_id: 작업 고유 식별자
|
||||
|
||||
Returns:
|
||||
LyricStatusResponse: 상태 정보
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 - task_id에 해당하는 가사가 없는 경우
|
||||
|
||||
Usage:
|
||||
# 다른 서비스에서 사용
|
||||
from app.lyric.api.routers.v1.lyric import get_lyric_status_by_task_id
|
||||
|
||||
status_info = await get_lyric_status_by_task_id(session, "some-task-id")
|
||||
if status_info.status == "completed":
|
||||
# 완료 처리
|
||||
"""
|
||||
print(f"[get_lyric_status_by_task_id] START - task_id: {task_id}")
|
||||
result = await session.execute(
|
||||
select(Lyric)
|
||||
.where(Lyric.task_id == task_id)
|
||||
.order_by(Lyric.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
lyric = result.scalar_one_or_none()
|
||||
|
||||
if not lyric:
|
||||
print(f"[get_lyric_status_by_task_id] NOT FOUND - task_id: {task_id}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"task_id '{task_id}'에 해당하는 가사를 찾을 수 없습니다.",
|
||||
)
|
||||
|
||||
status_messages = {
|
||||
"processing": "가사 생성 중입니다.",
|
||||
"completed": "가사 생성이 완료되었습니다.",
|
||||
"failed": "가사 생성에 실패했습니다.",
|
||||
}
|
||||
|
||||
print(
|
||||
f"[get_lyric_status_by_task_id] SUCCESS - task_id: {task_id}, status: {lyric.status}"
|
||||
)
|
||||
return LyricStatusResponse(
|
||||
task_id=lyric.task_id,
|
||||
status=lyric.status,
|
||||
message=status_messages.get(lyric.status, "알 수 없는 상태입니다."),
|
||||
)
|
||||
|
||||
|
||||
async def get_lyric_by_task_id(
|
||||
session: AsyncSession, task_id: str
|
||||
) -> LyricDetailResponse:
|
||||
"""task_id로 생성된 가사 상세 정보를 조회합니다.
|
||||
|
||||
Args:
|
||||
session: SQLAlchemy AsyncSession
|
||||
task_id: 작업 고유 식별자
|
||||
|
||||
Returns:
|
||||
LyricDetailResponse: 가사 상세 정보
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 - task_id에 해당하는 가사가 없는 경우
|
||||
|
||||
Usage:
|
||||
# 다른 서비스에서 사용
|
||||
from app.lyric.api.routers.v1.lyric import get_lyric_by_task_id
|
||||
|
||||
lyric = await get_lyric_by_task_id(session, task_id)
|
||||
"""
|
||||
print(f"[get_lyric_by_task_id] START - task_id: {task_id}")
|
||||
result = await session.execute(
|
||||
select(Lyric)
|
||||
.where(Lyric.task_id == task_id)
|
||||
.order_by(Lyric.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
lyric = result.scalar_one_or_none()
|
||||
|
||||
if not lyric:
|
||||
print(f"[get_lyric_by_task_id] NOT FOUND - task_id: {task_id}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"task_id '{task_id}'에 해당하는 가사를 찾을 수 없습니다.",
|
||||
)
|
||||
|
||||
print(f"[get_lyric_by_task_id] SUCCESS - task_id: {task_id}, lyric_id: {lyric.id}")
|
||||
return LyricDetailResponse(
|
||||
id=lyric.id,
|
||||
task_id=lyric.task_id,
|
||||
project_id=lyric.project_id,
|
||||
status=lyric.status,
|
||||
lyric_prompt=lyric.lyric_prompt,
|
||||
lyric_result=lyric.lyric_result,
|
||||
created_at=lyric.created_at,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# API Endpoints
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@router.post(
|
||||
"/generate",
|
||||
summary="가사 생성",
|
||||
description="""
|
||||
고객 정보를 기반으로 ChatGPT를 이용하여 가사를 생성합니다.
|
||||
백그라운드에서 비동기로 처리되며, 즉시 task_id를 반환합니다.
|
||||
|
||||
## 요청 필드
|
||||
- **task_id**: 작업 고유 식별자 (이미지 업로드 시 생성된 task_id, 필수)
|
||||
- **customer_name**: 고객명/가게명 (필수)
|
||||
- **region**: 지역명 (필수)
|
||||
- **detail_region_info**: 상세 지역 정보 (선택)
|
||||
- **language**: 가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)
|
||||
|
||||
## 반환 정보
|
||||
- **success**: 요청 접수 성공 여부
|
||||
- **task_id**: 작업 고유 식별자
|
||||
- **lyric**: null (백그라운드 처리 중)
|
||||
- **language**: 가사 언어
|
||||
- **error_message**: 에러 메시지 (요청 접수 실패 시)
|
||||
|
||||
## 상태 확인
|
||||
- GET /lyric/status/{task_id} 로 처리 상태 확인
|
||||
- GET /lyric/{task_id} 로 생성된 가사 조회
|
||||
|
||||
## 사용 예시
|
||||
```
|
||||
POST /lyric/generate
|
||||
{
|
||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
||||
"customer_name": "스테이 머뭄",
|
||||
"region": "군산",
|
||||
"detail_region_info": "군산 신흥동 말랭이 마을",
|
||||
"language": "Korean"
|
||||
}
|
||||
```
|
||||
|
||||
## 응답 예시
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
||||
"lyric": null,
|
||||
"language": "Korean",
|
||||
"error_message": null
|
||||
}
|
||||
```
|
||||
""",
|
||||
response_model=GenerateLyricResponse,
|
||||
responses={
|
||||
200: {"description": "가사 생성 요청 접수 성공"},
|
||||
500: {"description": "서버 내부 오류"},
|
||||
},
|
||||
)
|
||||
async def generate_lyric(
|
||||
request_body: GenerateLyricRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> GenerateLyricResponse:
|
||||
"""고객 정보를 기반으로 가사를 생성합니다. (백그라운드 처리)"""
|
||||
import time
|
||||
|
||||
request_start = time.perf_counter()
|
||||
task_id = request_body.task_id
|
||||
|
||||
print(f"[generate_lyric] ========== START ==========")
|
||||
print(
|
||||
f"[generate_lyric] task_id: {task_id}, "
|
||||
f"customer_name: {request_body.customer_name}, "
|
||||
f"region: {request_body.region}"
|
||||
)
|
||||
|
||||
try:
|
||||
# ========== Step 1: ChatGPT 서비스 초기화 및 프롬프트 생성 ==========
|
||||
step1_start = time.perf_counter()
|
||||
print(f"[generate_lyric] Step 1: 서비스 초기화 및 프롬프트 생성...")
|
||||
|
||||
service = ChatgptService(
|
||||
customer_name=request_body.customer_name,
|
||||
region=request_body.region,
|
||||
detail_region_info=request_body.detail_region_info or "",
|
||||
language=request_body.language,
|
||||
)
|
||||
prompt = service.build_lyrics_prompt()
|
||||
|
||||
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
||||
print(f"[generate_lyric] Step 1 완료 - 프롬프트 {len(prompt)}자 ({step1_elapsed:.1f}ms)")
|
||||
|
||||
# ========== Step 2: Project 테이블에 데이터 저장 ==========
|
||||
step2_start = time.perf_counter()
|
||||
print(f"[generate_lyric] Step 2: Project 저장...")
|
||||
|
||||
project = Project(
|
||||
store_name=request_body.customer_name,
|
||||
region=request_body.region,
|
||||
task_id=task_id,
|
||||
detail_region_info=request_body.detail_region_info,
|
||||
language=request_body.language,
|
||||
)
|
||||
session.add(project)
|
||||
await session.commit()
|
||||
await session.refresh(project)
|
||||
|
||||
step2_elapsed = (time.perf_counter() - step2_start) * 1000
|
||||
print(f"[generate_lyric] Step 2 완료 - project_id: {project.id} ({step2_elapsed:.1f}ms)")
|
||||
|
||||
# ========== Step 3: Lyric 테이블에 데이터 저장 ==========
|
||||
step3_start = time.perf_counter()
|
||||
print(f"[generate_lyric] Step 3: Lyric 저장 (processing)...")
|
||||
|
||||
lyric = Lyric(
|
||||
project_id=project.id,
|
||||
task_id=task_id,
|
||||
status="processing",
|
||||
lyric_prompt=prompt,
|
||||
lyric_result=None,
|
||||
language=request_body.language,
|
||||
)
|
||||
session.add(lyric)
|
||||
await session.commit()
|
||||
await session.refresh(lyric)
|
||||
|
||||
step3_elapsed = (time.perf_counter() - step3_start) * 1000
|
||||
print(f"[generate_lyric] Step 3 완료 - lyric_id: {lyric.id} ({step3_elapsed:.1f}ms)")
|
||||
|
||||
# ========== Step 4: 백그라운드 태스크 스케줄링 ==========
|
||||
step4_start = time.perf_counter()
|
||||
print(f"[generate_lyric] Step 4: 백그라운드 태스크 스케줄링...")
|
||||
|
||||
background_tasks.add_task(
|
||||
generate_lyric_background,
|
||||
task_id=task_id,
|
||||
prompt=prompt,
|
||||
language=request_body.language,
|
||||
)
|
||||
|
||||
step4_elapsed = (time.perf_counter() - step4_start) * 1000
|
||||
print(f"[generate_lyric] Step 4 완료 ({step4_elapsed:.1f}ms)")
|
||||
|
||||
# ========== 완료 ==========
|
||||
total_elapsed = (time.perf_counter() - request_start) * 1000
|
||||
print(f"[generate_lyric] ========== COMPLETE ==========")
|
||||
print(f"[generate_lyric] API 응답 소요시간: {total_elapsed:.1f}ms")
|
||||
print(f"[generate_lyric] - Step 1 (프롬프트 생성): {step1_elapsed:.1f}ms")
|
||||
print(f"[generate_lyric] - Step 2 (Project 저장): {step2_elapsed:.1f}ms")
|
||||
print(f"[generate_lyric] - Step 3 (Lyric 저장): {step3_elapsed:.1f}ms")
|
||||
print(f"[generate_lyric] - Step 4 (태스크 스케줄링): {step4_elapsed:.1f}ms")
|
||||
print(f"[generate_lyric] (GPT API 호출은 백그라운드에서 별도 진행)")
|
||||
|
||||
# 5. 즉시 응답 반환
|
||||
return GenerateLyricResponse(
|
||||
success=True,
|
||||
task_id=task_id,
|
||||
lyric=None,
|
||||
language=request_body.language,
|
||||
error_message=None,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
elapsed = (time.perf_counter() - request_start) * 1000
|
||||
print(f"[generate_lyric] EXCEPTION - task_id: {task_id}, error: {e} ({elapsed:.1f}ms)")
|
||||
await session.rollback()
|
||||
return GenerateLyricResponse(
|
||||
success=False,
|
||||
task_id=task_id,
|
||||
lyric=None,
|
||||
language=request_body.language,
|
||||
error_message=str(e),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/status/{task_id}",
|
||||
summary="가사 생성 상태 조회",
|
||||
description="""
|
||||
task_id로 가사 생성 작업의 현재 상태를 조회합니다.
|
||||
|
||||
## 상태 값
|
||||
- **processing**: 가사 생성 중
|
||||
- **completed**: 가사 생성 완료
|
||||
- **failed**: 가사 생성 실패
|
||||
|
||||
## 사용 예시
|
||||
```
|
||||
GET /lyric/status/019123ab-cdef-7890-abcd-ef1234567890
|
||||
```
|
||||
""",
|
||||
response_model=LyricStatusResponse,
|
||||
responses={
|
||||
200: {"description": "상태 조회 성공"},
|
||||
404: {"description": "해당 task_id를 찾을 수 없음"},
|
||||
},
|
||||
)
|
||||
async def get_lyric_status(
|
||||
task_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> LyricStatusResponse:
|
||||
"""task_id로 가사 생성 작업 상태를 조회합니다."""
|
||||
return await get_lyric_status_by_task_id(session, task_id)
|
||||
|
||||
|
||||
@router.get(
|
||||
"s",
|
||||
summary="가사 목록 조회 (페이지네이션)",
|
||||
description="""
|
||||
생성 완료된 가사를 페이지네이션으로 조회합니다.
|
||||
|
||||
## 파라미터
|
||||
- **page**: 페이지 번호 (1부터 시작, 기본값: 1)
|
||||
- **page_size**: 페이지당 데이터 수 (기본값: 20, 최대: 100)
|
||||
|
||||
## 반환 정보
|
||||
- **items**: 가사 목록 (completed 상태만)
|
||||
- **total**: 전체 데이터 수
|
||||
- **page**: 현재 페이지
|
||||
- **page_size**: 페이지당 데이터 수
|
||||
- **total_pages**: 전체 페이지 수
|
||||
- **has_next**: 다음 페이지 존재 여부
|
||||
- **has_prev**: 이전 페이지 존재 여부
|
||||
|
||||
## 사용 예시
|
||||
```
|
||||
GET /lyrics # 기본 조회 (1페이지, 20개)
|
||||
GET /lyrics?page=2 # 2페이지 조회
|
||||
GET /lyrics?page=1&page_size=50 # 50개씩 조회
|
||||
```
|
||||
|
||||
## 참고
|
||||
- 생성 완료(completed)된 가사만 조회됩니다.
|
||||
- processing, failed 상태의 가사는 조회되지 않습니다.
|
||||
""",
|
||||
response_model=PaginatedResponse[LyricListItem],
|
||||
responses={
|
||||
200: {"description": "가사 목록 조회 성공"},
|
||||
},
|
||||
)
|
||||
async def list_lyrics(
|
||||
page: int = Query(1, ge=1, description="페이지 번호 (1부터 시작)"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="페이지당 데이터 수"),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> PaginatedResponse[LyricListItem]:
|
||||
"""페이지네이션으로 완료된 가사 목록을 조회합니다."""
|
||||
return await get_paginated(
|
||||
session=session,
|
||||
model=Lyric,
|
||||
item_schema=LyricListItem,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
filters={"status": "completed"},
|
||||
order_by="created_at",
|
||||
order_desc=True,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{task_id}",
|
||||
summary="가사 상세 조회",
|
||||
description="""
|
||||
task_id로 생성된 가사의 상세 정보를 조회합니다.
|
||||
|
||||
## 반환 정보
|
||||
- **id**: 가사 ID
|
||||
- **task_id**: 작업 고유 식별자
|
||||
- **project_id**: 프로젝트 ID
|
||||
- **status**: 처리 상태
|
||||
- **lyric_prompt**: 가사 생성에 사용된 프롬프트
|
||||
- **lyric_result**: 생성된 가사 (완료 시)
|
||||
- **created_at**: 생성 일시
|
||||
|
||||
## 사용 예시
|
||||
```
|
||||
GET /lyric/019123ab-cdef-7890-abcd-ef1234567890
|
||||
```
|
||||
""",
|
||||
response_model=LyricDetailResponse,
|
||||
responses={
|
||||
200: {"description": "가사 조회 성공"},
|
||||
404: {"description": "해당 task_id를 찾을 수 없음"},
|
||||
},
|
||||
)
|
||||
async def get_lyric_detail(
|
||||
task_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> LyricDetailResponse:
|
||||
"""task_id로 생성된 가사를 조회합니다."""
|
||||
return await get_lyric_by_task_id(session, task_id)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.session import get_session
|
||||
|
||||
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.session import get_session
|
||||
|
||||
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
|
|
|||
|
|
@ -1,133 +1,133 @@
|
|||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, List
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func
|
||||
from sqlalchemy.dialects.mysql import LONGTEXT
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database.session import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.home.models import Project
|
||||
from app.song.models import Song
|
||||
from app.video.models import Video
|
||||
|
||||
|
||||
class Lyric(Base):
|
||||
"""
|
||||
가사 테이블
|
||||
|
||||
AI를 통해 생성된 가사 정보를 저장합니다.
|
||||
프롬프트와 생성 결과, 처리 상태를 관리합니다.
|
||||
|
||||
Attributes:
|
||||
id: 고유 식별자 (자동 증가)
|
||||
project_id: 연결된 Project의 id (외래키)
|
||||
task_id: 가사 생성 작업의 고유 식별자 (UUID 형식)
|
||||
status: 처리 상태 (pending, processing, completed, failed 등)
|
||||
lyric_prompt: 가사 생성에 사용된 프롬프트
|
||||
lyric_result: 생성된 가사 결과 (LONGTEXT로 긴 가사 지원)
|
||||
created_at: 생성 일시 (자동 설정)
|
||||
|
||||
Relationships:
|
||||
project: 연결된 Project
|
||||
songs: 이 가사를 사용한 노래 목록
|
||||
videos: 이 가사를 사용한 영상 목록
|
||||
"""
|
||||
|
||||
__tablename__ = "lyric"
|
||||
__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="고유 식별자",
|
||||
)
|
||||
|
||||
project_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("project.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="연결된 Project의 id",
|
||||
)
|
||||
|
||||
task_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
nullable=False,
|
||||
comment="가사 생성 작업 고유 식별자 (UUID)",
|
||||
)
|
||||
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
nullable=False,
|
||||
comment="처리 상태 (processing, completed, failed)",
|
||||
)
|
||||
|
||||
lyric_prompt: Mapped[str] = mapped_column(
|
||||
Text,
|
||||
nullable=False,
|
||||
comment="가사 생성에 사용된 프롬프트",
|
||||
)
|
||||
|
||||
lyric_result: Mapped[str] = mapped_column(
|
||||
LONGTEXT,
|
||||
nullable=True,
|
||||
comment="생성된 가사 결과",
|
||||
)
|
||||
|
||||
language: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
nullable=False,
|
||||
default="Korean",
|
||||
comment="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
|
||||
)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
nullable=True,
|
||||
server_default=func.now(),
|
||||
comment="생성 일시",
|
||||
)
|
||||
|
||||
# Relationships
|
||||
project: Mapped["Project"] = relationship(
|
||||
"Project",
|
||||
back_populates="lyrics",
|
||||
)
|
||||
|
||||
songs: Mapped[List["Song"]] = relationship(
|
||||
"Song",
|
||||
back_populates="lyric",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
videos: Mapped[List["Video"]] = relationship(
|
||||
"Video",
|
||||
back_populates="lyric",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
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"<Lyric("
|
||||
f"id={self.id}, "
|
||||
f"task_id='{truncate(self.task_id)}', "
|
||||
f"status='{self.status}'"
|
||||
f")>"
|
||||
)
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, List
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func
|
||||
from sqlalchemy.dialects.mysql import LONGTEXT
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database.session import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.home.models import Project
|
||||
from app.song.models import Song
|
||||
from app.video.models import Video
|
||||
|
||||
|
||||
class Lyric(Base):
|
||||
"""
|
||||
가사 테이블
|
||||
|
||||
AI를 통해 생성된 가사 정보를 저장합니다.
|
||||
프롬프트와 생성 결과, 처리 상태를 관리합니다.
|
||||
|
||||
Attributes:
|
||||
id: 고유 식별자 (자동 증가)
|
||||
project_id: 연결된 Project의 id (외래키)
|
||||
task_id: 가사 생성 작업의 고유 식별자 (UUID 형식)
|
||||
status: 처리 상태 (pending, processing, completed, failed 등)
|
||||
lyric_prompt: 가사 생성에 사용된 프롬프트
|
||||
lyric_result: 생성된 가사 결과 (LONGTEXT로 긴 가사 지원)
|
||||
created_at: 생성 일시 (자동 설정)
|
||||
|
||||
Relationships:
|
||||
project: 연결된 Project
|
||||
songs: 이 가사를 사용한 노래 목록
|
||||
videos: 이 가사를 사용한 영상 목록
|
||||
"""
|
||||
|
||||
__tablename__ = "lyric"
|
||||
__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="고유 식별자",
|
||||
)
|
||||
|
||||
project_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("project.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="연결된 Project의 id",
|
||||
)
|
||||
|
||||
task_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
nullable=False,
|
||||
comment="가사 생성 작업 고유 식별자 (UUID)",
|
||||
)
|
||||
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
nullable=False,
|
||||
comment="처리 상태 (processing, completed, failed)",
|
||||
)
|
||||
|
||||
lyric_prompt: Mapped[str] = mapped_column(
|
||||
Text,
|
||||
nullable=False,
|
||||
comment="가사 생성에 사용된 프롬프트",
|
||||
)
|
||||
|
||||
lyric_result: Mapped[str] = mapped_column(
|
||||
LONGTEXT,
|
||||
nullable=True,
|
||||
comment="생성된 가사 결과",
|
||||
)
|
||||
|
||||
language: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
nullable=False,
|
||||
default="Korean",
|
||||
comment="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
|
||||
)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
nullable=True,
|
||||
server_default=func.now(),
|
||||
comment="생성 일시",
|
||||
)
|
||||
|
||||
# Relationships
|
||||
project: Mapped["Project"] = relationship(
|
||||
"Project",
|
||||
back_populates="lyrics",
|
||||
)
|
||||
|
||||
songs: Mapped[List["Song"]] = relationship(
|
||||
"Song",
|
||||
back_populates="lyric",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
videos: Mapped[List["Video"]] = relationship(
|
||||
"Video",
|
||||
back_populates="lyric",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
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"<Lyric("
|
||||
f"id={self.id}, "
|
||||
f"task_id='{truncate(self.task_id)}', "
|
||||
f"status='{self.status}'"
|
||||
f")>"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,182 +1,182 @@
|
|||
"""
|
||||
Lyric API Schemas
|
||||
|
||||
이 모듈은 가사 관련 API 엔드포인트에서 사용되는 Pydantic 스키마를 정의합니다.
|
||||
|
||||
사용 예시:
|
||||
from app.lyric.schemas.lyric import (
|
||||
LyricStatusResponse,
|
||||
LyricDetailResponse,
|
||||
LyricListItem,
|
||||
)
|
||||
from app.utils.pagination import PaginatedResponse
|
||||
|
||||
# 라우터에서 response_model로 사용
|
||||
@router.get("/lyric/{task_id}", response_model=LyricDetailResponse)
|
||||
async def get_lyric(task_id: str):
|
||||
...
|
||||
|
||||
# 페이지네이션 응답 (공통 스키마 사용)
|
||||
@router.get("/songs", response_model=PaginatedResponse[SongListItem])
|
||||
async def list_songs(...):
|
||||
...
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class GenerateLyricRequest(BaseModel):
|
||||
"""가사 생성 요청 스키마
|
||||
|
||||
Usage:
|
||||
POST /lyric/generate
|
||||
Request body for generating lyrics.
|
||||
|
||||
Example Request:
|
||||
{
|
||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
||||
"customer_name": "스테이 머뭄",
|
||||
"region": "군산",
|
||||
"detail_region_info": "군산 신흥동 말랭이 마을",
|
||||
"language": "Korean"
|
||||
}
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
||||
"customer_name": "스테이 머뭄",
|
||||
"region": "군산",
|
||||
"detail_region_info": "군산 신흥동 말랭이 마을",
|
||||
"language": "Korean",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
task_id: str = Field(
|
||||
..., description="작업 고유 식별자 (이미지 업로드 시 생성된 task_id)"
|
||||
)
|
||||
customer_name: str = Field(..., description="고객명/가게명")
|
||||
region: str = Field(..., description="지역명")
|
||||
detail_region_info: Optional[str] = Field(None, description="상세 지역 정보")
|
||||
language: str = Field(
|
||||
default="Korean",
|
||||
description="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
|
||||
)
|
||||
|
||||
|
||||
class GenerateLyricResponse(BaseModel):
|
||||
"""가사 생성 응답 스키마
|
||||
|
||||
Usage:
|
||||
POST /lyric/generate
|
||||
Returns the generated lyrics.
|
||||
|
||||
Note:
|
||||
실패 조건:
|
||||
- ChatGPT API 오류
|
||||
- ChatGPT 거부 응답 (I'm sorry, I cannot, I can't, I apologize 등)
|
||||
- 응답에 ERROR: 포함
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"success": True,
|
||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
||||
"lyric": "인스타 감성의 스테이 머뭄\n군산 신흥동 말랭이 마을에서\n여유로운 하루를 보내며\n추억을 만들어가요",
|
||||
"language": "Korean",
|
||||
"error_message": None,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
success: bool = Field(..., description="생성 성공 여부")
|
||||
task_id: Optional[str] = Field(None, description="작업 고유 식별자 (uuid7)")
|
||||
lyric: Optional[str] = Field(None, description="생성된 가사 (성공 시)")
|
||||
language: str = Field(..., description="가사 언어")
|
||||
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시, ChatGPT 거부 응답 포함)")
|
||||
|
||||
|
||||
class LyricStatusResponse(BaseModel):
|
||||
"""가사 상태 조회 응답 스키마
|
||||
|
||||
Usage:
|
||||
GET /lyric/status/{task_id}
|
||||
Returns the current processing status of a lyric generation task.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
||||
"status": "completed",
|
||||
"message": "가사 생성이 완료되었습니다.",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
task_id: str = Field(..., description="작업 고유 식별자")
|
||||
status: str = Field(..., description="처리 상태 (processing, completed, failed)")
|
||||
message: str = Field(..., description="상태 메시지")
|
||||
|
||||
|
||||
class LyricDetailResponse(BaseModel):
|
||||
"""가사 상세 조회 응답 스키마
|
||||
|
||||
Usage:
|
||||
GET /lyric/{task_id}
|
||||
Returns the generated lyric content for a specific task.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"id": 1,
|
||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
||||
"project_id": 1,
|
||||
"status": "completed",
|
||||
"lyric_prompt": "고객명: 스테이 머뭄, 지역: 군산...",
|
||||
"lyric_result": "인스타 감성의 스테이 머뭄\n군산 신흥동 말랭이 마을에서\n여유로운 하루를 보내며\n추억을 만들어가요",
|
||||
"created_at": "2024-01-15T12:00:00",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
id: int = Field(..., description="가사 ID")
|
||||
task_id: str = Field(..., description="작업 고유 식별자")
|
||||
project_id: int = Field(..., description="프로젝트 ID")
|
||||
status: str = Field(..., description="처리 상태")
|
||||
lyric_prompt: str = Field(..., description="가사 생성 프롬프트")
|
||||
lyric_result: Optional[str] = Field(None, description="생성된 가사")
|
||||
created_at: Optional[datetime] = Field(None, description="생성 일시")
|
||||
|
||||
|
||||
class LyricListItem(BaseModel):
|
||||
"""가사 목록 아이템 스키마
|
||||
|
||||
Usage:
|
||||
Used as individual items in paginated lyric list responses.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"id": 1,
|
||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
||||
"status": "completed",
|
||||
"lyric_result": "인스타 감성의 스테이 머뭄\n군산 신흥동 말랭이 마을에서...",
|
||||
"created_at": "2024-01-15T12:00:00",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
id: int = Field(..., description="가사 ID")
|
||||
task_id: str = Field(..., description="작업 고유 식별자")
|
||||
status: str = Field(..., description="처리 상태")
|
||||
lyric_result: Optional[str] = Field(None, description="생성된 가사 (미리보기)")
|
||||
created_at: Optional[datetime] = Field(None, description="생성 일시")
|
||||
"""
|
||||
Lyric API Schemas
|
||||
|
||||
이 모듈은 가사 관련 API 엔드포인트에서 사용되는 Pydantic 스키마를 정의합니다.
|
||||
|
||||
사용 예시:
|
||||
from app.lyric.schemas.lyric import (
|
||||
LyricStatusResponse,
|
||||
LyricDetailResponse,
|
||||
LyricListItem,
|
||||
)
|
||||
from app.utils.pagination import PaginatedResponse
|
||||
|
||||
# 라우터에서 response_model로 사용
|
||||
@router.get("/lyric/{task_id}", response_model=LyricDetailResponse)
|
||||
async def get_lyric(task_id: str):
|
||||
...
|
||||
|
||||
# 페이지네이션 응답 (공통 스키마 사용)
|
||||
@router.get("/songs", response_model=PaginatedResponse[SongListItem])
|
||||
async def list_songs(...):
|
||||
...
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class GenerateLyricRequest(BaseModel):
|
||||
"""가사 생성 요청 스키마
|
||||
|
||||
Usage:
|
||||
POST /lyric/generate
|
||||
Request body for generating lyrics.
|
||||
|
||||
Example Request:
|
||||
{
|
||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
||||
"customer_name": "스테이 머뭄",
|
||||
"region": "군산",
|
||||
"detail_region_info": "군산 신흥동 말랭이 마을",
|
||||
"language": "Korean"
|
||||
}
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
||||
"customer_name": "스테이 머뭄",
|
||||
"region": "군산",
|
||||
"detail_region_info": "군산 신흥동 말랭이 마을",
|
||||
"language": "Korean",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
task_id: str = Field(
|
||||
..., description="작업 고유 식별자 (이미지 업로드 시 생성된 task_id)"
|
||||
)
|
||||
customer_name: str = Field(..., description="고객명/가게명")
|
||||
region: str = Field(..., description="지역명")
|
||||
detail_region_info: Optional[str] = Field(None, description="상세 지역 정보")
|
||||
language: str = Field(
|
||||
default="Korean",
|
||||
description="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
|
||||
)
|
||||
|
||||
|
||||
class GenerateLyricResponse(BaseModel):
|
||||
"""가사 생성 응답 스키마
|
||||
|
||||
Usage:
|
||||
POST /lyric/generate
|
||||
Returns the generated lyrics.
|
||||
|
||||
Note:
|
||||
실패 조건:
|
||||
- ChatGPT API 오류
|
||||
- ChatGPT 거부 응답 (I'm sorry, I cannot, I can't, I apologize 등)
|
||||
- 응답에 ERROR: 포함
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"success": True,
|
||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
||||
"lyric": "인스타 감성의 스테이 머뭄\n군산 신흥동 말랭이 마을에서\n여유로운 하루를 보내며\n추억을 만들어가요",
|
||||
"language": "Korean",
|
||||
"error_message": None,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
success: bool = Field(..., description="생성 성공 여부")
|
||||
task_id: Optional[str] = Field(None, description="작업 고유 식별자 (uuid7)")
|
||||
lyric: Optional[str] = Field(None, description="생성된 가사 (성공 시)")
|
||||
language: str = Field(..., description="가사 언어")
|
||||
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시, ChatGPT 거부 응답 포함)")
|
||||
|
||||
|
||||
class LyricStatusResponse(BaseModel):
|
||||
"""가사 상태 조회 응답 스키마
|
||||
|
||||
Usage:
|
||||
GET /lyric/status/{task_id}
|
||||
Returns the current processing status of a lyric generation task.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
||||
"status": "completed",
|
||||
"message": "가사 생성이 완료되었습니다.",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
task_id: str = Field(..., description="작업 고유 식별자")
|
||||
status: str = Field(..., description="처리 상태 (processing, completed, failed)")
|
||||
message: str = Field(..., description="상태 메시지")
|
||||
|
||||
|
||||
class LyricDetailResponse(BaseModel):
|
||||
"""가사 상세 조회 응답 스키마
|
||||
|
||||
Usage:
|
||||
GET /lyric/{task_id}
|
||||
Returns the generated lyric content for a specific task.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"id": 1,
|
||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
||||
"project_id": 1,
|
||||
"status": "completed",
|
||||
"lyric_prompt": "고객명: 스테이 머뭄, 지역: 군산...",
|
||||
"lyric_result": "인스타 감성의 스테이 머뭄\n군산 신흥동 말랭이 마을에서\n여유로운 하루를 보내며\n추억을 만들어가요",
|
||||
"created_at": "2024-01-15T12:00:00",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
id: int = Field(..., description="가사 ID")
|
||||
task_id: str = Field(..., description="작업 고유 식별자")
|
||||
project_id: int = Field(..., description="프로젝트 ID")
|
||||
status: str = Field(..., description="처리 상태")
|
||||
lyric_prompt: str = Field(..., description="가사 생성 프롬프트")
|
||||
lyric_result: Optional[str] = Field(None, description="생성된 가사")
|
||||
created_at: Optional[datetime] = Field(None, description="생성 일시")
|
||||
|
||||
|
||||
class LyricListItem(BaseModel):
|
||||
"""가사 목록 아이템 스키마
|
||||
|
||||
Usage:
|
||||
Used as individual items in paginated lyric list responses.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"id": 1,
|
||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
||||
"status": "completed",
|
||||
"lyric_result": "인스타 감성의 스테이 머뭄\n군산 신흥동 말랭이 마을에서...",
|
||||
"created_at": "2024-01-15T12:00:00",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
id: int = Field(..., description="가사 ID")
|
||||
task_id: str = Field(..., description="작업 고유 식별자")
|
||||
status: str = Field(..., description="처리 상태")
|
||||
lyric_result: Optional[str] = Field(None, description="생성된 가사 (미리보기)")
|
||||
created_at: Optional[datetime] = Field(None, description="생성 일시")
|
||||
|
|
|
|||
|
|
@ -1,91 +1,91 @@
|
|||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Dict, List
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
|
||||
@dataclass
|
||||
class StoreData:
|
||||
id: int
|
||||
created_at: datetime
|
||||
store_name: str
|
||||
store_category: str | None = None
|
||||
store_region: str | None = None
|
||||
store_address: str | None = None
|
||||
store_phone_number: str | None = None
|
||||
store_info: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class AttributeData:
|
||||
id: int
|
||||
attr_category: str
|
||||
attr_value: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class SongSampleData:
|
||||
id: int
|
||||
ai: str
|
||||
ai_model: str
|
||||
sample_song: str
|
||||
season: str | None = None
|
||||
num_of_people: int | None = None
|
||||
people_category: str | None = None
|
||||
genre: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class PromptTemplateData:
|
||||
id: int
|
||||
prompt: str
|
||||
description: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SongFormData:
|
||||
store_name: str
|
||||
store_id: str
|
||||
prompts: str
|
||||
attributes: Dict[str, str] = field(default_factory=dict)
|
||||
attributes_str: str = ""
|
||||
lyrics_ids: List[int] = field(default_factory=list)
|
||||
llm_model: str = "gpt-4o"
|
||||
|
||||
@classmethod
|
||||
async def from_form(cls, request: Request):
|
||||
"""Request의 form 데이터로부터 dataclass 인스턴스 생성"""
|
||||
form_data = await request.form()
|
||||
|
||||
# 고정 필드명들
|
||||
fixed_keys = {"store_info_name", "store_id", "llm_model", "prompts"}
|
||||
|
||||
# lyrics-{id} 형태의 모든 키를 찾아서 ID 추출
|
||||
lyrics_ids = []
|
||||
attributes = {}
|
||||
|
||||
for key, value in form_data.items():
|
||||
if key.startswith("lyrics-"):
|
||||
lyrics_id = key.split("-")[1]
|
||||
lyrics_ids.append(int(lyrics_id))
|
||||
elif key not in fixed_keys:
|
||||
attributes[key] = value
|
||||
|
||||
# attributes를 문자열로 변환
|
||||
attributes_str = (
|
||||
"\r\n\r\n".join([f"{key} : {value}" for key, value in attributes.items()])
|
||||
if attributes
|
||||
else ""
|
||||
)
|
||||
|
||||
return cls(
|
||||
store_name=form_data.get("store_info_name", ""),
|
||||
store_id=form_data.get("store_id", ""),
|
||||
attributes=attributes,
|
||||
attributes_str=attributes_str,
|
||||
lyrics_ids=lyrics_ids,
|
||||
llm_model=form_data.get("llm_model", "gpt-4o"),
|
||||
prompts=form_data.get("prompts", ""),
|
||||
)
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Dict, List
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
|
||||
@dataclass
|
||||
class StoreData:
|
||||
id: int
|
||||
created_at: datetime
|
||||
store_name: str
|
||||
store_category: str | None = None
|
||||
store_region: str | None = None
|
||||
store_address: str | None = None
|
||||
store_phone_number: str | None = None
|
||||
store_info: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class AttributeData:
|
||||
id: int
|
||||
attr_category: str
|
||||
attr_value: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class SongSampleData:
|
||||
id: int
|
||||
ai: str
|
||||
ai_model: str
|
||||
sample_song: str
|
||||
season: str | None = None
|
||||
num_of_people: int | None = None
|
||||
people_category: str | None = None
|
||||
genre: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class PromptTemplateData:
|
||||
id: int
|
||||
prompt: str
|
||||
description: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SongFormData:
|
||||
store_name: str
|
||||
store_id: str
|
||||
prompts: str
|
||||
attributes: Dict[str, str] = field(default_factory=dict)
|
||||
attributes_str: str = ""
|
||||
lyrics_ids: List[int] = field(default_factory=list)
|
||||
llm_model: str = "gpt-5-mini"
|
||||
|
||||
@classmethod
|
||||
async def from_form(cls, request: Request):
|
||||
"""Request의 form 데이터로부터 dataclass 인스턴스 생성"""
|
||||
form_data = await request.form()
|
||||
|
||||
# 고정 필드명들
|
||||
fixed_keys = {"store_info_name", "store_id", "llm_model", "prompts"}
|
||||
|
||||
# lyrics-{id} 형태의 모든 키를 찾아서 ID 추출
|
||||
lyrics_ids = []
|
||||
attributes = {}
|
||||
|
||||
for key, value in form_data.items():
|
||||
if key.startswith("lyrics-"):
|
||||
lyrics_id = key.split("-")[1]
|
||||
lyrics_ids.append(int(lyrics_id))
|
||||
elif key not in fixed_keys:
|
||||
attributes[key] = value
|
||||
|
||||
# attributes를 문자열로 변환
|
||||
attributes_str = (
|
||||
"\r\n\r\n".join([f"{key} : {value}" for key, value in attributes.items()])
|
||||
if attributes
|
||||
else ""
|
||||
)
|
||||
|
||||
return cls(
|
||||
store_name=form_data.get("store_info_name", ""),
|
||||
store_id=form_data.get("store_id", ""),
|
||||
attributes=attributes,
|
||||
attributes_str=attributes_str,
|
||||
lyrics_ids=lyrics_ids,
|
||||
llm_model=form_data.get("llm_model", "gpt-5-mini"),
|
||||
prompts=form_data.get("prompts", ""),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,24 +1,24 @@
|
|||
from uuid import UUID
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
|
||||
class BaseService:
|
||||
def __init__(self, model, session: AsyncSession):
|
||||
self.model = model
|
||||
self.session = session
|
||||
|
||||
async def _get(self, id: UUID):
|
||||
return await self.session.get(self.model, id)
|
||||
|
||||
async def _add(self, entity):
|
||||
self.session.add(entity)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def _update(self, entity):
|
||||
return await self._add(entity)
|
||||
|
||||
async def _delete(self, entity):
|
||||
from uuid import UUID
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
|
||||
class BaseService:
|
||||
def __init__(self, model, session: AsyncSession):
|
||||
self.model = model
|
||||
self.session = session
|
||||
|
||||
async def _get(self, id: UUID):
|
||||
return await self.session.get(self.model, id)
|
||||
|
||||
async def _add(self, entity):
|
||||
self.session.add(entity)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def _update(self, entity):
|
||||
return await self._add(entity)
|
||||
|
||||
async def _delete(self, entity):
|
||||
await self.session.delete(entity)
|
||||
|
|
@ -1,98 +1,146 @@
|
|||
"""
|
||||
Lyric Background Tasks
|
||||
|
||||
가사 생성 관련 백그라운드 태스크를 정의합니다.
|
||||
"""
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database.session import BackgroundSessionLocal
|
||||
from app.lyric.models import Lyric
|
||||
from app.utils.chatgpt_prompt import ChatgptService
|
||||
|
||||
|
||||
async def generate_lyric_background(
|
||||
task_id: str,
|
||||
prompt: str,
|
||||
language: str,
|
||||
) -> None:
|
||||
"""백그라운드에서 ChatGPT를 통해 가사를 생성하고 Lyric 테이블을 업데이트합니다.
|
||||
|
||||
Args:
|
||||
task_id: 프로젝트 task_id
|
||||
prompt: ChatGPT에 전달할 프롬프트
|
||||
language: 가사 언어
|
||||
"""
|
||||
print(f"[generate_lyric_background] START - task_id: {task_id}")
|
||||
|
||||
try:
|
||||
# ChatGPT 서비스 초기화 (프롬프트는 이미 생성되어 있음)
|
||||
service = ChatgptService(
|
||||
customer_name="", # 프롬프트가 이미 생성되었으므로 빈 값
|
||||
region="",
|
||||
detail_region_info="",
|
||||
language=language,
|
||||
)
|
||||
|
||||
# ChatGPT를 통해 가사 생성
|
||||
print(f"[generate_lyric_background] ChatGPT generation started - task_id: {task_id}")
|
||||
result = await service.generate(prompt=prompt)
|
||||
print(f"[generate_lyric_background] ChatGPT generation completed - task_id: {task_id}")
|
||||
|
||||
# 실패 응답 검사 (ERROR 또는 ChatGPT 거부 응답)
|
||||
failure_patterns = [
|
||||
"ERROR:",
|
||||
"I'm sorry",
|
||||
"I cannot",
|
||||
"I can't",
|
||||
"I apologize",
|
||||
"I'm unable",
|
||||
"I am unable",
|
||||
"I'm not able",
|
||||
"I am not able",
|
||||
]
|
||||
is_failure = any(
|
||||
pattern.lower() in result.lower() for pattern in failure_patterns
|
||||
)
|
||||
|
||||
# Lyric 테이블 업데이트 (백그라운드 전용 세션 사용)
|
||||
async with BackgroundSessionLocal() as session:
|
||||
query_result = await session.execute(
|
||||
select(Lyric)
|
||||
.where(Lyric.task_id == task_id)
|
||||
.order_by(Lyric.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
lyric = query_result.scalar_one_or_none()
|
||||
|
||||
if lyric:
|
||||
if is_failure:
|
||||
print(f"[generate_lyric_background] FAILED - task_id: {task_id}, error: {result}")
|
||||
lyric.status = "failed"
|
||||
lyric.lyric_result = result
|
||||
else:
|
||||
print(f"[generate_lyric_background] SUCCESS - task_id: {task_id}")
|
||||
lyric.status = "completed"
|
||||
lyric.lyric_result = result
|
||||
|
||||
await session.commit()
|
||||
else:
|
||||
print(f"[generate_lyric_background] Lyric NOT FOUND in DB - task_id: {task_id}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"[generate_lyric_background] EXCEPTION - task_id: {task_id}, error: {e}")
|
||||
# 실패 시 Lyric 테이블 업데이트
|
||||
async with BackgroundSessionLocal() as session:
|
||||
query_result = await session.execute(
|
||||
select(Lyric)
|
||||
.where(Lyric.task_id == task_id)
|
||||
.order_by(Lyric.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
lyric = query_result.scalar_one_or_none()
|
||||
|
||||
if lyric:
|
||||
lyric.status = "failed"
|
||||
lyric.lyric_result = f"Error: {str(e)}"
|
||||
await session.commit()
|
||||
print(f"[generate_lyric_background] FAILED - task_id: {task_id}, status updated to failed")
|
||||
"""
|
||||
Lyric Background Tasks
|
||||
|
||||
가사 생성 관련 백그라운드 태스크를 정의합니다.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import traceback
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from app.database.session import BackgroundSessionLocal
|
||||
from app.lyric.models import Lyric
|
||||
from app.utils.chatgpt_prompt import ChatgptService
|
||||
|
||||
# 로거 설정
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _update_lyric_status(
|
||||
task_id: str,
|
||||
status: str,
|
||||
result: str | None = None,
|
||||
) -> bool:
|
||||
"""Lyric 테이블의 상태를 업데이트합니다.
|
||||
|
||||
Args:
|
||||
task_id: 프로젝트 task_id
|
||||
status: 변경할 상태 ("processing", "completed", "failed")
|
||||
result: 가사 결과 또는 에러 메시지
|
||||
|
||||
Returns:
|
||||
bool: 업데이트 성공 여부
|
||||
"""
|
||||
try:
|
||||
async with BackgroundSessionLocal() as session:
|
||||
query_result = await session.execute(
|
||||
select(Lyric)
|
||||
.where(Lyric.task_id == task_id)
|
||||
.order_by(Lyric.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
lyric = query_result.scalar_one_or_none()
|
||||
|
||||
if lyric:
|
||||
lyric.status = status
|
||||
if result is not None:
|
||||
lyric.lyric_result = result
|
||||
await session.commit()
|
||||
logger.info(f"[Lyric] Status updated - task_id: {task_id}, status: {status}")
|
||||
print(f"[Lyric] Status updated - task_id: {task_id}, status: {status}")
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"[Lyric] NOT FOUND in DB - task_id: {task_id}")
|
||||
print(f"[Lyric] NOT FOUND in DB - task_id: {task_id}")
|
||||
return False
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"[Lyric] DB Error while updating status - task_id: {task_id}, error: {e}")
|
||||
print(f"[Lyric] DB Error while updating status - task_id: {task_id}, error: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"[Lyric] Unexpected error while updating status - task_id: {task_id}, error: {e}")
|
||||
print(f"[Lyric] Unexpected error while updating status - task_id: {task_id}, error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def generate_lyric_background(
|
||||
task_id: str,
|
||||
prompt: str,
|
||||
language: str,
|
||||
) -> None:
|
||||
"""백그라운드에서 ChatGPT를 통해 가사를 생성하고 Lyric 테이블을 업데이트합니다.
|
||||
|
||||
Args:
|
||||
task_id: 프로젝트 task_id
|
||||
prompt: ChatGPT에 전달할 프롬프트
|
||||
language: 가사 언어
|
||||
"""
|
||||
import time
|
||||
|
||||
task_start = time.perf_counter()
|
||||
logger.info(f"[generate_lyric_background] START - task_id: {task_id}")
|
||||
print(f"[generate_lyric_background] ========== START ==========")
|
||||
print(f"[generate_lyric_background] task_id: {task_id}")
|
||||
print(f"[generate_lyric_background] language: {language}")
|
||||
print(f"[generate_lyric_background] prompt length: {len(prompt)}자")
|
||||
|
||||
try:
|
||||
# ========== Step 1: ChatGPT 서비스 초기화 ==========
|
||||
step1_start = time.perf_counter()
|
||||
print(f"[generate_lyric_background] Step 1: ChatGPT 서비스 초기화...")
|
||||
|
||||
service = ChatgptService(
|
||||
customer_name="", # 프롬프트가 이미 생성되었으므로 빈 값
|
||||
region="",
|
||||
detail_region_info="",
|
||||
language=language,
|
||||
)
|
||||
|
||||
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
||||
print(f"[generate_lyric_background] Step 1 완료 ({step1_elapsed:.1f}ms)")
|
||||
|
||||
# ========== Step 2: ChatGPT API 호출 (가사 생성) ==========
|
||||
step2_start = time.perf_counter()
|
||||
logger.info(f"[generate_lyric_background] Step 2: ChatGPT API 호출 시작 - task_id: {task_id}")
|
||||
print(f"[generate_lyric_background] Step 2: ChatGPT API 호출 시작...")
|
||||
|
||||
result = await service.generate(prompt=prompt)
|
||||
|
||||
step2_elapsed = (time.perf_counter() - step2_start) * 1000
|
||||
logger.info(f"[generate_lyric_background] Step 2 완료 - 응답 {len(result)}자 ({step2_elapsed:.1f}ms)")
|
||||
print(f"[generate_lyric_background] Step 2 완료 - 응답 {len(result)}자 ({step2_elapsed:.1f}ms)")
|
||||
|
||||
# ========== Step 3: DB 상태 업데이트 ==========
|
||||
step3_start = time.perf_counter()
|
||||
print(f"[generate_lyric_background] Step 3: DB 상태 업데이트...")
|
||||
|
||||
await _update_lyric_status(task_id, "completed", result)
|
||||
|
||||
step3_elapsed = (time.perf_counter() - step3_start) * 1000
|
||||
print(f"[generate_lyric_background] Step 3 완료 ({step3_elapsed:.1f}ms)")
|
||||
|
||||
# ========== 완료 ==========
|
||||
total_elapsed = (time.perf_counter() - task_start) * 1000
|
||||
logger.info(f"[generate_lyric_background] SUCCESS - task_id: {task_id}, 총 소요시간: {total_elapsed:.1f}ms")
|
||||
print(f"[generate_lyric_background] ========== SUCCESS ==========")
|
||||
print(f"[generate_lyric_background] 총 소요시간: {total_elapsed:.1f}ms")
|
||||
print(f"[generate_lyric_background] - Step 1 (서비스 초기화): {step1_elapsed:.1f}ms")
|
||||
print(f"[generate_lyric_background] - Step 2 (GPT API 호출): {step2_elapsed:.1f}ms")
|
||||
print(f"[generate_lyric_background] - Step 3 (DB 업데이트): {step3_elapsed:.1f}ms")
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
elapsed = (time.perf_counter() - task_start) * 1000
|
||||
logger.error(f"[generate_lyric_background] DB ERROR - task_id: {task_id}, error: {e} ({elapsed:.1f}ms)")
|
||||
print(f"[generate_lyric_background] DB ERROR - {e} ({elapsed:.1f}ms)")
|
||||
traceback.print_exc()
|
||||
await _update_lyric_status(task_id, "failed", f"Database Error: {str(e)}")
|
||||
|
||||
except Exception as e:
|
||||
elapsed = (time.perf_counter() - task_start) * 1000
|
||||
logger.error(f"[generate_lyric_background] EXCEPTION - task_id: {task_id}, error: {e} ({elapsed:.1f}ms)")
|
||||
print(f"[generate_lyric_background] EXCEPTION - {e} ({elapsed:.1f}ms)")
|
||||
traceback.print_exc()
|
||||
await _update_lyric_status(task_id, "failed", f"Error: {str(e)}")
|
||||
|
|
|
|||
|
|
@ -1,69 +1,69 @@
|
|||
from sqladmin import ModelView
|
||||
|
||||
from app.song.models import Song
|
||||
|
||||
|
||||
class SongAdmin(ModelView, model=Song):
|
||||
name = "노래"
|
||||
name_plural = "노래 목록"
|
||||
icon = "fa-solid fa-headphones"
|
||||
category = "노래 관리"
|
||||
page_size = 20
|
||||
|
||||
column_list = [
|
||||
"id",
|
||||
"project_id",
|
||||
"lyric_id",
|
||||
"task_id",
|
||||
"suno_task_id",
|
||||
"status",
|
||||
"language",
|
||||
"created_at",
|
||||
]
|
||||
|
||||
column_details_list = [
|
||||
"id",
|
||||
"project_id",
|
||||
"lyric_id",
|
||||
"task_id",
|
||||
"suno_task_id",
|
||||
"status",
|
||||
"language",
|
||||
"song_prompt",
|
||||
"song_result_url",
|
||||
"created_at",
|
||||
]
|
||||
|
||||
# 폼(생성/수정)에서 제외
|
||||
form_excluded_columns = ["created_at", "videos"]
|
||||
|
||||
column_searchable_list = [
|
||||
Song.task_id,
|
||||
Song.suno_task_id,
|
||||
Song.status,
|
||||
Song.language,
|
||||
]
|
||||
|
||||
column_default_sort = (Song.created_at, True) # True: DESC (최신순)
|
||||
|
||||
column_sortable_list = [
|
||||
Song.id,
|
||||
Song.project_id,
|
||||
Song.lyric_id,
|
||||
Song.status,
|
||||
Song.language,
|
||||
Song.created_at,
|
||||
]
|
||||
|
||||
column_labels = {
|
||||
"id": "ID",
|
||||
"project_id": "프로젝트 ID",
|
||||
"lyric_id": "가사 ID",
|
||||
"task_id": "작업 ID",
|
||||
"suno_task_id": "Suno 작업 ID",
|
||||
"status": "상태",
|
||||
"language": "언어",
|
||||
"song_prompt": "프롬프트",
|
||||
"song_result_url": "결과 URL",
|
||||
"created_at": "생성일시",
|
||||
}
|
||||
from sqladmin import ModelView
|
||||
|
||||
from app.song.models import Song
|
||||
|
||||
|
||||
class SongAdmin(ModelView, model=Song):
|
||||
name = "노래"
|
||||
name_plural = "노래 목록"
|
||||
icon = "fa-solid fa-headphones"
|
||||
category = "노래 관리"
|
||||
page_size = 20
|
||||
|
||||
column_list = [
|
||||
"id",
|
||||
"project_id",
|
||||
"lyric_id",
|
||||
"task_id",
|
||||
"suno_task_id",
|
||||
"status",
|
||||
"language",
|
||||
"created_at",
|
||||
]
|
||||
|
||||
column_details_list = [
|
||||
"id",
|
||||
"project_id",
|
||||
"lyric_id",
|
||||
"task_id",
|
||||
"suno_task_id",
|
||||
"status",
|
||||
"language",
|
||||
"song_prompt",
|
||||
"song_result_url",
|
||||
"created_at",
|
||||
]
|
||||
|
||||
# 폼(생성/수정)에서 제외
|
||||
form_excluded_columns = ["created_at", "videos"]
|
||||
|
||||
column_searchable_list = [
|
||||
Song.task_id,
|
||||
Song.suno_task_id,
|
||||
Song.status,
|
||||
Song.language,
|
||||
]
|
||||
|
||||
column_default_sort = (Song.created_at, True) # True: DESC (최신순)
|
||||
|
||||
column_sortable_list = [
|
||||
Song.id,
|
||||
Song.project_id,
|
||||
Song.lyric_id,
|
||||
Song.status,
|
||||
Song.language,
|
||||
Song.created_at,
|
||||
]
|
||||
|
||||
column_labels = {
|
||||
"id": "ID",
|
||||
"project_id": "프로젝트 ID",
|
||||
"lyric_id": "가사 ID",
|
||||
"task_id": "작업 ID",
|
||||
"suno_task_id": "Suno 작업 ID",
|
||||
"status": "상태",
|
||||
"language": "언어",
|
||||
"song_prompt": "프롬프트",
|
||||
"song_result_url": "결과 URL",
|
||||
"created_at": "생성일시",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.session import get_session
|
||||
|
||||
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.session import get_session
|
||||
|
||||
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
|
|
|||
|
|
@ -1,152 +1,152 @@
|
|||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, List, Optional
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database.session import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.home.models import Project
|
||||
from app.lyric.models import Lyric
|
||||
from app.video.models import Video
|
||||
|
||||
|
||||
class Song(Base):
|
||||
"""
|
||||
노래 테이블
|
||||
|
||||
AI를 통해 생성된 노래 정보를 저장합니다.
|
||||
가사를 기반으로 생성됩니다.
|
||||
|
||||
Attributes:
|
||||
id: 고유 식별자 (자동 증가)
|
||||
project_id: 연결된 Project의 id (외래키)
|
||||
lyric_id: 연결된 Lyric의 id (외래키)
|
||||
task_id: 노래 생성 작업의 고유 식별자 (UUID 형식)
|
||||
suno_task_id: Suno API 작업 고유 식별자 (선택)
|
||||
status: 처리 상태 (pending, processing, completed, failed 등)
|
||||
song_prompt: 노래 생성에 사용된 프롬프트
|
||||
song_result_url: 생성 결과 URL (선택)
|
||||
language: 출력 언어
|
||||
created_at: 생성 일시 (자동 설정)
|
||||
|
||||
Relationships:
|
||||
project: 연결된 Project
|
||||
lyric: 연결된 Lyric
|
||||
videos: 이 노래를 사용한 영상 결과 목록
|
||||
"""
|
||||
|
||||
__tablename__ = "song"
|
||||
__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="고유 식별자",
|
||||
)
|
||||
|
||||
project_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("project.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="연결된 Project의 id",
|
||||
)
|
||||
|
||||
lyric_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("lyric.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="연결된 Lyric의 id",
|
||||
)
|
||||
|
||||
task_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
nullable=False,
|
||||
comment="노래 생성 작업 고유 식별자 (UUID)",
|
||||
)
|
||||
|
||||
suno_task_id: Mapped[Optional[str]] = mapped_column(
|
||||
String(64),
|
||||
nullable=True,
|
||||
comment="Suno API 작업 고유 식별자",
|
||||
)
|
||||
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
nullable=False,
|
||||
comment="처리 상태 (processing, completed, failed)",
|
||||
)
|
||||
|
||||
song_prompt: Mapped[str] = mapped_column(
|
||||
Text,
|
||||
nullable=False,
|
||||
comment="노래 생성에 사용된 프롬프트",
|
||||
)
|
||||
|
||||
song_result_url: Mapped[Optional[str]] = mapped_column(
|
||||
String(2048),
|
||||
nullable=True,
|
||||
comment="노래 결과 URL",
|
||||
)
|
||||
|
||||
duration: Mapped[Optional[float]] = mapped_column(
|
||||
nullable=True,
|
||||
comment="노래 재생 시간 (초)",
|
||||
)
|
||||
|
||||
language: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
nullable=False,
|
||||
default="Korean",
|
||||
comment="출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
|
||||
)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
comment="생성 일시",
|
||||
)
|
||||
|
||||
# Relationships
|
||||
project: Mapped["Project"] = relationship(
|
||||
"Project",
|
||||
back_populates="songs",
|
||||
)
|
||||
|
||||
lyric: Mapped["Lyric"] = relationship(
|
||||
"Lyric",
|
||||
back_populates="songs",
|
||||
)
|
||||
|
||||
videos: Mapped[List["Video"]] = relationship(
|
||||
"Video",
|
||||
back_populates="song",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
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"<Song("
|
||||
f"id={self.id}, "
|
||||
f"task_id='{truncate(self.task_id)}', "
|
||||
f"status='{self.status}'"
|
||||
f")>"
|
||||
)
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, List, Optional
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database.session import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.home.models import Project
|
||||
from app.lyric.models import Lyric
|
||||
from app.video.models import Video
|
||||
|
||||
|
||||
class Song(Base):
|
||||
"""
|
||||
노래 테이블
|
||||
|
||||
AI를 통해 생성된 노래 정보를 저장합니다.
|
||||
가사를 기반으로 생성됩니다.
|
||||
|
||||
Attributes:
|
||||
id: 고유 식별자 (자동 증가)
|
||||
project_id: 연결된 Project의 id (외래키)
|
||||
lyric_id: 연결된 Lyric의 id (외래키)
|
||||
task_id: 노래 생성 작업의 고유 식별자 (UUID 형식)
|
||||
suno_task_id: Suno API 작업 고유 식별자 (선택)
|
||||
status: 처리 상태 (pending, processing, completed, failed 등)
|
||||
song_prompt: 노래 생성에 사용된 프롬프트
|
||||
song_result_url: 생성 결과 URL (선택)
|
||||
language: 출력 언어
|
||||
created_at: 생성 일시 (자동 설정)
|
||||
|
||||
Relationships:
|
||||
project: 연결된 Project
|
||||
lyric: 연결된 Lyric
|
||||
videos: 이 노래를 사용한 영상 결과 목록
|
||||
"""
|
||||
|
||||
__tablename__ = "song"
|
||||
__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="고유 식별자",
|
||||
)
|
||||
|
||||
project_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("project.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="연결된 Project의 id",
|
||||
)
|
||||
|
||||
lyric_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("lyric.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="연결된 Lyric의 id",
|
||||
)
|
||||
|
||||
task_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
nullable=False,
|
||||
comment="노래 생성 작업 고유 식별자 (UUID)",
|
||||
)
|
||||
|
||||
suno_task_id: Mapped[Optional[str]] = mapped_column(
|
||||
String(64),
|
||||
nullable=True,
|
||||
comment="Suno API 작업 고유 식별자",
|
||||
)
|
||||
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
nullable=False,
|
||||
comment="처리 상태 (processing, completed, failed)",
|
||||
)
|
||||
|
||||
song_prompt: Mapped[str] = mapped_column(
|
||||
Text,
|
||||
nullable=False,
|
||||
comment="노래 생성에 사용된 프롬프트",
|
||||
)
|
||||
|
||||
song_result_url: Mapped[Optional[str]] = mapped_column(
|
||||
String(2048),
|
||||
nullable=True,
|
||||
comment="노래 결과 URL",
|
||||
)
|
||||
|
||||
duration: Mapped[Optional[float]] = mapped_column(
|
||||
nullable=True,
|
||||
comment="노래 재생 시간 (초)",
|
||||
)
|
||||
|
||||
language: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
nullable=False,
|
||||
default="Korean",
|
||||
comment="출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
|
||||
)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
comment="생성 일시",
|
||||
)
|
||||
|
||||
# Relationships
|
||||
project: Mapped["Project"] = relationship(
|
||||
"Project",
|
||||
back_populates="songs",
|
||||
)
|
||||
|
||||
lyric: Mapped["Lyric"] = relationship(
|
||||
"Lyric",
|
||||
back_populates="songs",
|
||||
)
|
||||
|
||||
videos: Mapped[List["Video"]] = relationship(
|
||||
"Video",
|
||||
back_populates="song",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
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"<Song("
|
||||
f"id={self.id}, "
|
||||
f"task_id='{truncate(self.task_id)}', "
|
||||
f"status='{self.status}'"
|
||||
f")>"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,374 +1,374 @@
|
|||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Pydantic Schemas for Song Generation API
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class GenerateSongRequest(BaseModel):
|
||||
"""노래 생성 요청 스키마
|
||||
|
||||
Usage:
|
||||
POST /song/generate/{task_id}
|
||||
Request body for generating a song via Suno API.
|
||||
|
||||
Example Request:
|
||||
{
|
||||
"lyrics": "인스타 감성의 스테이 머뭄...",
|
||||
"genre": "k-pop",
|
||||
"language": "Korean"
|
||||
}
|
||||
"""
|
||||
|
||||
model_config = {
|
||||
"json_schema_extra": {
|
||||
"example": {
|
||||
"lyrics": "인스타 감성의 스테이 머뭄, 머물러봐요 \n군산 신흥동 말랭이 마을의 마음 힐링 \n사진같은 하루, 여행의 시작 \n보석 같은 이곳은 감성 숙소의 느낌 \n\n인근 명소와 아름다움이 가득한 거리 \n힐링의 바람과 여행의 추억 \n글로벌 감성의 스테이 머뭄, 인스타 감성 \n사진으로 남기고 싶은 그 순간들이 되어줘요",
|
||||
"genre": "k-pop",
|
||||
"language": "Korean",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lyrics: str = Field(..., description="노래에 사용할 가사")
|
||||
genre: str = Field(
|
||||
...,
|
||||
description="음악 장르 (K-Pop, Pop, R&B, Hip-Hop, Ballad, EDM, Rock, Jazz 등)",
|
||||
)
|
||||
language: str = Field(
|
||||
default="Korean",
|
||||
description="노래 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
|
||||
)
|
||||
|
||||
|
||||
class GenerateSongResponse(BaseModel):
|
||||
"""노래 생성 응답 스키마
|
||||
|
||||
Usage:
|
||||
POST /song/generate/{task_id}
|
||||
Returns the task IDs for tracking song generation.
|
||||
|
||||
Note:
|
||||
실패 조건:
|
||||
- task_id에 해당하는 Project가 없는 경우 (404 HTTPException)
|
||||
- task_id에 해당하는 Lyric이 없는 경우 (404 HTTPException)
|
||||
- Suno API 호출 실패
|
||||
|
||||
Example Response (Success):
|
||||
{
|
||||
"success": true,
|
||||
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
||||
"suno_task_id": "abc123...",
|
||||
"message": "노래 생성 요청이 접수되었습니다. suno_task_id로 상태를 조회하세요.",
|
||||
"error_message": null
|
||||
}
|
||||
|
||||
Example Response (Failure):
|
||||
{
|
||||
"success": false,
|
||||
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
||||
"suno_task_id": null,
|
||||
"message": "노래 생성 요청에 실패했습니다.",
|
||||
"error_message": "Suno API connection error"
|
||||
}
|
||||
"""
|
||||
|
||||
success: bool = Field(..., description="요청 성공 여부")
|
||||
task_id: Optional[str] = Field(None, description="내부 작업 ID (Project/Lyric task_id)")
|
||||
suno_task_id: Optional[str] = Field(None, description="Suno API 작업 ID")
|
||||
message: str = Field(..., description="응답 메시지")
|
||||
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
|
||||
|
||||
|
||||
class PollingSongRequest(BaseModel):
|
||||
"""노래 생성 상태 조회 요청 스키마 (Legacy)
|
||||
|
||||
Note:
|
||||
현재 사용되지 않음. GET /song/status/{suno_task_id} 엔드포인트 사용.
|
||||
|
||||
Example Request:
|
||||
{
|
||||
"task_id": "abc123..."
|
||||
}
|
||||
"""
|
||||
|
||||
task_id: str = Field(..., description="Suno 작업 ID")
|
||||
|
||||
|
||||
class SongClipData(BaseModel):
|
||||
"""생성된 노래 클립 정보"""
|
||||
|
||||
id: Optional[str] = Field(None, description="클립 ID")
|
||||
audio_url: Optional[str] = Field(None, description="오디오 URL")
|
||||
stream_audio_url: Optional[str] = Field(None, description="스트리밍 오디오 URL")
|
||||
image_url: Optional[str] = Field(None, description="이미지 URL")
|
||||
title: Optional[str] = Field(None, description="곡 제목")
|
||||
status: Optional[str] = Field(None, description="클립 상태")
|
||||
duration: Optional[float] = Field(None, description="노래 길이 (초)")
|
||||
|
||||
|
||||
class PollingSongResponse(BaseModel):
|
||||
"""노래 생성 상태 조회 응답 스키마
|
||||
|
||||
Usage:
|
||||
GET /song/status/{suno_task_id}
|
||||
Suno API 작업 상태를 조회합니다.
|
||||
|
||||
Note:
|
||||
상태 값:
|
||||
- PENDING: 대기 중
|
||||
- processing: 생성 중
|
||||
- SUCCESS / TEXT_SUCCESS / complete: 생성 완료
|
||||
- failed: 생성 실패
|
||||
- error: API 조회 오류
|
||||
|
||||
SUCCESS 상태 시:
|
||||
- 백그라운드에서 MP3 파일 다운로드 시작
|
||||
- Song 테이블의 status를 completed로 업데이트
|
||||
- song_result_url에 로컬 파일 경로 저장
|
||||
|
||||
Example Response (Processing):
|
||||
{
|
||||
"success": true,
|
||||
"status": "processing",
|
||||
"message": "노래를 생성하고 있습니다.",
|
||||
"clips": null,
|
||||
"raw_response": {...},
|
||||
"error_message": null
|
||||
}
|
||||
|
||||
Example Response (Success):
|
||||
{
|
||||
"success": true,
|
||||
"status": "SUCCESS",
|
||||
"message": "노래 생성이 완료되었습니다.",
|
||||
"clips": [
|
||||
{
|
||||
"id": "clip-id",
|
||||
"audio_url": "https://...",
|
||||
"stream_audio_url": "https://...",
|
||||
"image_url": "https://...",
|
||||
"title": "Song Title",
|
||||
"status": "complete",
|
||||
"duration": 60.0
|
||||
}
|
||||
],
|
||||
"raw_response": {...},
|
||||
"error_message": null
|
||||
}
|
||||
|
||||
Example Response (Failure):
|
||||
{
|
||||
"success": false,
|
||||
"status": "error",
|
||||
"message": "상태 조회에 실패했습니다.",
|
||||
"clips": null,
|
||||
"raw_response": null,
|
||||
"error_message": "ConnectionError: ..."
|
||||
}
|
||||
"""
|
||||
|
||||
success: bool = Field(..., description="조회 성공 여부")
|
||||
status: Optional[str] = Field(
|
||||
None, description="작업 상태 (PENDING, processing, SUCCESS, failed)"
|
||||
)
|
||||
message: str = Field(..., description="상태 메시지")
|
||||
clips: Optional[List[SongClipData]] = Field(None, description="생성된 노래 클립 목록")
|
||||
raw_response: Optional[Dict[str, Any]] = Field(None, description="Suno API 원본 응답")
|
||||
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
|
||||
|
||||
|
||||
class SongListItem(BaseModel):
|
||||
"""노래 목록 아이템 스키마
|
||||
|
||||
Usage:
|
||||
GET /songs 응답의 개별 노래 정보
|
||||
|
||||
Example:
|
||||
{
|
||||
"store_name": "스테이 머뭄",
|
||||
"region": "군산",
|
||||
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
||||
"language": "Korean",
|
||||
"song_result_url": "http://localhost:8000/media/2025-01-15/스테이머뭄.mp3",
|
||||
"created_at": "2025-01-15T12:00:00"
|
||||
}
|
||||
"""
|
||||
|
||||
store_name: Optional[str] = Field(None, description="업체명")
|
||||
region: Optional[str] = Field(None, description="지역명")
|
||||
task_id: str = Field(..., description="작업 고유 식별자")
|
||||
language: Optional[str] = Field(None, description="언어")
|
||||
song_result_url: Optional[str] = Field(None, description="노래 결과 URL")
|
||||
created_at: Optional[datetime] = Field(None, description="생성 일시")
|
||||
|
||||
|
||||
class DownloadSongResponse(BaseModel):
|
||||
"""노래 다운로드 응답 스키마
|
||||
|
||||
Usage:
|
||||
GET /song/download/{task_id}
|
||||
Polls for song completion and returns project info with song URL.
|
||||
|
||||
Note:
|
||||
상태 값:
|
||||
- processing: 노래 생성 진행 중 (song_result_url은 null)
|
||||
- completed: 노래 생성 완료 (song_result_url 포함)
|
||||
- failed: 노래 생성 실패
|
||||
- not_found: task_id에 해당하는 Song 없음
|
||||
- error: 조회 중 오류 발생
|
||||
|
||||
Example Response (Processing):
|
||||
{
|
||||
"success": true,
|
||||
"status": "processing",
|
||||
"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):
|
||||
{
|
||||
"success": true,
|
||||
"status": "completed",
|
||||
"message": "노래 다운로드가 완료되었습니다.",
|
||||
"store_name": "스테이 머뭄",
|
||||
"region": "군산",
|
||||
"detail_region_info": "군산 신흥동 말랭이 마을",
|
||||
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
||||
"language": "Korean",
|
||||
"song_result_url": "http://localhost:8000/media/2025-01-15/스테이머뭄.mp3",
|
||||
"created_at": "2025-01-15T12:00:00",
|
||||
"error_message": null
|
||||
}
|
||||
|
||||
Example Response (Not Found):
|
||||
{
|
||||
"success": false,
|
||||
"status": "not_found",
|
||||
"message": "task_id 'xxx'에 해당하는 Song을 찾을 수 없습니다.",
|
||||
"store_name": null,
|
||||
"region": null,
|
||||
"detail_region_info": null,
|
||||
"task_id": null,
|
||||
"language": null,
|
||||
"song_result_url": null,
|
||||
"created_at": null,
|
||||
"error_message": "Song not found"
|
||||
}
|
||||
"""
|
||||
|
||||
success: bool = Field(..., description="다운로드 성공 여부")
|
||||
status: str = Field(..., description="처리 상태 (processing, completed, failed, not_found, error)")
|
||||
message: str = Field(..., description="응답 메시지")
|
||||
store_name: Optional[str] = Field(None, description="업체명")
|
||||
region: Optional[str] = Field(None, description="지역명")
|
||||
detail_region_info: Optional[str] = Field(None, description="상세 지역 정보")
|
||||
task_id: Optional[str] = Field(None, description="작업 고유 식별자")
|
||||
language: Optional[str] = Field(None, description="언어")
|
||||
song_result_url: Optional[str] = Field(None, description="노래 결과 URL")
|
||||
created_at: Optional[datetime] = Field(None, description="생성 일시")
|
||||
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Dataclass Schemas (Legacy)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@dataclass
|
||||
class StoreData:
|
||||
id: int
|
||||
created_at: datetime
|
||||
store_name: str
|
||||
store_category: str | None = None
|
||||
store_region: str | None = None
|
||||
store_address: str | None = None
|
||||
store_phone_number: str | None = None
|
||||
store_info: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class AttributeData:
|
||||
id: int
|
||||
attr_category: str
|
||||
attr_value: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class SongSampleData:
|
||||
id: int
|
||||
ai: str
|
||||
ai_model: str
|
||||
sample_song: str
|
||||
season: str | None = None
|
||||
num_of_people: int | None = None
|
||||
people_category: str | None = None
|
||||
genre: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class PromptTemplateData:
|
||||
id: int
|
||||
prompt: str
|
||||
description: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SongFormData:
|
||||
store_name: str
|
||||
store_id: str
|
||||
prompts: str
|
||||
attributes: Dict[str, str] = field(default_factory=dict)
|
||||
attributes_str: str = ""
|
||||
lyrics_ids: List[int] = field(default_factory=list)
|
||||
llm_model: str = "gpt-4o"
|
||||
|
||||
@classmethod
|
||||
async def from_form(cls, request: Request):
|
||||
"""Request의 form 데이터로부터 dataclass 인스턴스 생성"""
|
||||
form_data = await request.form()
|
||||
|
||||
# 고정 필드명들
|
||||
fixed_keys = {"store_info_name", "store_id", "llm_model", "prompts"}
|
||||
|
||||
# lyrics-{id} 형태의 모든 키를 찾아서 ID 추출
|
||||
lyrics_ids = []
|
||||
attributes = {}
|
||||
|
||||
for key, value in form_data.items():
|
||||
if key.startswith("lyrics-"):
|
||||
lyrics_id = key.split("-")[1]
|
||||
lyrics_ids.append(int(lyrics_id))
|
||||
elif key not in fixed_keys:
|
||||
attributes[key] = value
|
||||
|
||||
# attributes를 문자열로 변환
|
||||
attributes_str = (
|
||||
"\r\n\r\n".join([f"{key} : {value}" for key, value in attributes.items()])
|
||||
if attributes
|
||||
else ""
|
||||
)
|
||||
|
||||
return cls(
|
||||
store_name=form_data.get("store_info_name", ""),
|
||||
store_id=form_data.get("store_id", ""),
|
||||
attributes=attributes,
|
||||
attributes_str=attributes_str,
|
||||
lyrics_ids=lyrics_ids,
|
||||
llm_model=form_data.get("llm_model", "gpt-4o"),
|
||||
prompts=form_data.get("prompts", ""),
|
||||
)
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Pydantic Schemas for Song Generation API
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class GenerateSongRequest(BaseModel):
|
||||
"""노래 생성 요청 스키마
|
||||
|
||||
Usage:
|
||||
POST /song/generate/{task_id}
|
||||
Request body for generating a song via Suno API.
|
||||
|
||||
Example Request:
|
||||
{
|
||||
"lyrics": "인스타 감성의 스테이 머뭄...",
|
||||
"genre": "k-pop",
|
||||
"language": "Korean"
|
||||
}
|
||||
"""
|
||||
|
||||
model_config = {
|
||||
"json_schema_extra": {
|
||||
"example": {
|
||||
"lyrics": "인스타 감성의 스테이 머뭄, 머물러봐요 \n군산 신흥동 말랭이 마을의 마음 힐링 \n사진같은 하루, 여행의 시작 \n보석 같은 이곳은 감성 숙소의 느낌 \n\n인근 명소와 아름다움이 가득한 거리 \n힐링의 바람과 여행의 추억 \n글로벌 감성의 스테이 머뭄, 인스타 감성 \n사진으로 남기고 싶은 그 순간들이 되어줘요",
|
||||
"genre": "k-pop",
|
||||
"language": "Korean",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lyrics: str = Field(..., description="노래에 사용할 가사")
|
||||
genre: str = Field(
|
||||
...,
|
||||
description="음악 장르 (K-Pop, Pop, R&B, Hip-Hop, Ballad, EDM, Rock, Jazz 등)",
|
||||
)
|
||||
language: str = Field(
|
||||
default="Korean",
|
||||
description="노래 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
|
||||
)
|
||||
|
||||
|
||||
class GenerateSongResponse(BaseModel):
|
||||
"""노래 생성 응답 스키마
|
||||
|
||||
Usage:
|
||||
POST /song/generate/{task_id}
|
||||
Returns the task IDs for tracking song generation.
|
||||
|
||||
Note:
|
||||
실패 조건:
|
||||
- task_id에 해당하는 Project가 없는 경우 (404 HTTPException)
|
||||
- task_id에 해당하는 Lyric이 없는 경우 (404 HTTPException)
|
||||
- Suno API 호출 실패
|
||||
|
||||
Example Response (Success):
|
||||
{
|
||||
"success": true,
|
||||
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
||||
"suno_task_id": "abc123...",
|
||||
"message": "노래 생성 요청이 접수되었습니다. suno_task_id로 상태를 조회하세요.",
|
||||
"error_message": null
|
||||
}
|
||||
|
||||
Example Response (Failure):
|
||||
{
|
||||
"success": false,
|
||||
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
||||
"suno_task_id": null,
|
||||
"message": "노래 생성 요청에 실패했습니다.",
|
||||
"error_message": "Suno API connection error"
|
||||
}
|
||||
"""
|
||||
|
||||
success: bool = Field(..., description="요청 성공 여부")
|
||||
task_id: Optional[str] = Field(None, description="내부 작업 ID (Project/Lyric task_id)")
|
||||
suno_task_id: Optional[str] = Field(None, description="Suno API 작업 ID")
|
||||
message: str = Field(..., description="응답 메시지")
|
||||
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
|
||||
|
||||
|
||||
class PollingSongRequest(BaseModel):
|
||||
"""노래 생성 상태 조회 요청 스키마 (Legacy)
|
||||
|
||||
Note:
|
||||
현재 사용되지 않음. GET /song/status/{suno_task_id} 엔드포인트 사용.
|
||||
|
||||
Example Request:
|
||||
{
|
||||
"task_id": "abc123..."
|
||||
}
|
||||
"""
|
||||
|
||||
task_id: str = Field(..., description="Suno 작업 ID")
|
||||
|
||||
|
||||
class SongClipData(BaseModel):
|
||||
"""생성된 노래 클립 정보"""
|
||||
|
||||
id: Optional[str] = Field(None, description="클립 ID")
|
||||
audio_url: Optional[str] = Field(None, description="오디오 URL")
|
||||
stream_audio_url: Optional[str] = Field(None, description="스트리밍 오디오 URL")
|
||||
image_url: Optional[str] = Field(None, description="이미지 URL")
|
||||
title: Optional[str] = Field(None, description="곡 제목")
|
||||
status: Optional[str] = Field(None, description="클립 상태")
|
||||
duration: Optional[float] = Field(None, description="노래 길이 (초)")
|
||||
|
||||
|
||||
class PollingSongResponse(BaseModel):
|
||||
"""노래 생성 상태 조회 응답 스키마
|
||||
|
||||
Usage:
|
||||
GET /song/status/{suno_task_id}
|
||||
Suno API 작업 상태를 조회합니다.
|
||||
|
||||
Note:
|
||||
상태 값:
|
||||
- PENDING: 대기 중
|
||||
- processing: 생성 중
|
||||
- SUCCESS / TEXT_SUCCESS / complete: 생성 완료
|
||||
- failed: 생성 실패
|
||||
- error: API 조회 오류
|
||||
|
||||
SUCCESS 상태 시:
|
||||
- 백그라운드에서 MP3 파일 다운로드 시작
|
||||
- Song 테이블의 status를 completed로 업데이트
|
||||
- song_result_url에 로컬 파일 경로 저장
|
||||
|
||||
Example Response (Processing):
|
||||
{
|
||||
"success": true,
|
||||
"status": "processing",
|
||||
"message": "노래를 생성하고 있습니다.",
|
||||
"clips": null,
|
||||
"raw_response": {...},
|
||||
"error_message": null
|
||||
}
|
||||
|
||||
Example Response (Success):
|
||||
{
|
||||
"success": true,
|
||||
"status": "SUCCESS",
|
||||
"message": "노래 생성이 완료되었습니다.",
|
||||
"clips": [
|
||||
{
|
||||
"id": "clip-id",
|
||||
"audio_url": "https://...",
|
||||
"stream_audio_url": "https://...",
|
||||
"image_url": "https://...",
|
||||
"title": "Song Title",
|
||||
"status": "complete",
|
||||
"duration": 60.0
|
||||
}
|
||||
],
|
||||
"raw_response": {...},
|
||||
"error_message": null
|
||||
}
|
||||
|
||||
Example Response (Failure):
|
||||
{
|
||||
"success": false,
|
||||
"status": "error",
|
||||
"message": "상태 조회에 실패했습니다.",
|
||||
"clips": null,
|
||||
"raw_response": null,
|
||||
"error_message": "ConnectionError: ..."
|
||||
}
|
||||
"""
|
||||
|
||||
success: bool = Field(..., description="조회 성공 여부")
|
||||
status: Optional[str] = Field(
|
||||
None, description="작업 상태 (PENDING, processing, SUCCESS, failed)"
|
||||
)
|
||||
message: str = Field(..., description="상태 메시지")
|
||||
clips: Optional[List[SongClipData]] = Field(None, description="생성된 노래 클립 목록")
|
||||
raw_response: Optional[Dict[str, Any]] = Field(None, description="Suno API 원본 응답")
|
||||
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
|
||||
|
||||
|
||||
class SongListItem(BaseModel):
|
||||
"""노래 목록 아이템 스키마
|
||||
|
||||
Usage:
|
||||
GET /songs 응답의 개별 노래 정보
|
||||
|
||||
Example:
|
||||
{
|
||||
"store_name": "스테이 머뭄",
|
||||
"region": "군산",
|
||||
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
||||
"language": "Korean",
|
||||
"song_result_url": "http://localhost:8000/media/2025-01-15/스테이머뭄.mp3",
|
||||
"created_at": "2025-01-15T12:00:00"
|
||||
}
|
||||
"""
|
||||
|
||||
store_name: Optional[str] = Field(None, description="업체명")
|
||||
region: Optional[str] = Field(None, description="지역명")
|
||||
task_id: str = Field(..., description="작업 고유 식별자")
|
||||
language: Optional[str] = Field(None, description="언어")
|
||||
song_result_url: Optional[str] = Field(None, description="노래 결과 URL")
|
||||
created_at: Optional[datetime] = Field(None, description="생성 일시")
|
||||
|
||||
|
||||
class DownloadSongResponse(BaseModel):
|
||||
"""노래 다운로드 응답 스키마
|
||||
|
||||
Usage:
|
||||
GET /song/download/{task_id}
|
||||
Polls for song completion and returns project info with song URL.
|
||||
|
||||
Note:
|
||||
상태 값:
|
||||
- processing: 노래 생성 진행 중 (song_result_url은 null)
|
||||
- completed: 노래 생성 완료 (song_result_url 포함)
|
||||
- failed: 노래 생성 실패
|
||||
- not_found: task_id에 해당하는 Song 없음
|
||||
- error: 조회 중 오류 발생
|
||||
|
||||
Example Response (Processing):
|
||||
{
|
||||
"success": true,
|
||||
"status": "processing",
|
||||
"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):
|
||||
{
|
||||
"success": true,
|
||||
"status": "completed",
|
||||
"message": "노래 다운로드가 완료되었습니다.",
|
||||
"store_name": "스테이 머뭄",
|
||||
"region": "군산",
|
||||
"detail_region_info": "군산 신흥동 말랭이 마을",
|
||||
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
||||
"language": "Korean",
|
||||
"song_result_url": "http://localhost:8000/media/2025-01-15/스테이머뭄.mp3",
|
||||
"created_at": "2025-01-15T12:00:00",
|
||||
"error_message": null
|
||||
}
|
||||
|
||||
Example Response (Not Found):
|
||||
{
|
||||
"success": false,
|
||||
"status": "not_found",
|
||||
"message": "task_id 'xxx'에 해당하는 Song을 찾을 수 없습니다.",
|
||||
"store_name": null,
|
||||
"region": null,
|
||||
"detail_region_info": null,
|
||||
"task_id": null,
|
||||
"language": null,
|
||||
"song_result_url": null,
|
||||
"created_at": null,
|
||||
"error_message": "Song not found"
|
||||
}
|
||||
"""
|
||||
|
||||
success: bool = Field(..., description="다운로드 성공 여부")
|
||||
status: str = Field(..., description="처리 상태 (processing, completed, failed, not_found, error)")
|
||||
message: str = Field(..., description="응답 메시지")
|
||||
store_name: Optional[str] = Field(None, description="업체명")
|
||||
region: Optional[str] = Field(None, description="지역명")
|
||||
detail_region_info: Optional[str] = Field(None, description="상세 지역 정보")
|
||||
task_id: Optional[str] = Field(None, description="작업 고유 식별자")
|
||||
language: Optional[str] = Field(None, description="언어")
|
||||
song_result_url: Optional[str] = Field(None, description="노래 결과 URL")
|
||||
created_at: Optional[datetime] = Field(None, description="생성 일시")
|
||||
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Dataclass Schemas (Legacy)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@dataclass
|
||||
class StoreData:
|
||||
id: int
|
||||
created_at: datetime
|
||||
store_name: str
|
||||
store_category: str | None = None
|
||||
store_region: str | None = None
|
||||
store_address: str | None = None
|
||||
store_phone_number: str | None = None
|
||||
store_info: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class AttributeData:
|
||||
id: int
|
||||
attr_category: str
|
||||
attr_value: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class SongSampleData:
|
||||
id: int
|
||||
ai: str
|
||||
ai_model: str
|
||||
sample_song: str
|
||||
season: str | None = None
|
||||
num_of_people: int | None = None
|
||||
people_category: str | None = None
|
||||
genre: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class PromptTemplateData:
|
||||
id: int
|
||||
prompt: str
|
||||
description: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SongFormData:
|
||||
store_name: str
|
||||
store_id: str
|
||||
prompts: str
|
||||
attributes: Dict[str, str] = field(default_factory=dict)
|
||||
attributes_str: str = ""
|
||||
lyrics_ids: List[int] = field(default_factory=list)
|
||||
llm_model: str = "gpt-5-mini"
|
||||
|
||||
@classmethod
|
||||
async def from_form(cls, request: Request):
|
||||
"""Request의 form 데이터로부터 dataclass 인스턴스 생성"""
|
||||
form_data = await request.form()
|
||||
|
||||
# 고정 필드명들
|
||||
fixed_keys = {"store_info_name", "store_id", "llm_model", "prompts"}
|
||||
|
||||
# lyrics-{id} 형태의 모든 키를 찾아서 ID 추출
|
||||
lyrics_ids = []
|
||||
attributes = {}
|
||||
|
||||
for key, value in form_data.items():
|
||||
if key.startswith("lyrics-"):
|
||||
lyrics_id = key.split("-")[1]
|
||||
lyrics_ids.append(int(lyrics_id))
|
||||
elif key not in fixed_keys:
|
||||
attributes[key] = value
|
||||
|
||||
# attributes를 문자열로 변환
|
||||
attributes_str = (
|
||||
"\r\n\r\n".join([f"{key} : {value}" for key, value in attributes.items()])
|
||||
if attributes
|
||||
else ""
|
||||
)
|
||||
|
||||
return cls(
|
||||
store_name=form_data.get("store_info_name", ""),
|
||||
store_id=form_data.get("store_id", ""),
|
||||
attributes=attributes,
|
||||
attributes_str=attributes_str,
|
||||
lyrics_ids=lyrics_ids,
|
||||
llm_model=form_data.get("llm_model", "gpt-5-mini"),
|
||||
prompts=form_data.get("prompts", ""),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,24 +1,24 @@
|
|||
from uuid import UUID
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
|
||||
class BaseService:
|
||||
def __init__(self, model, session: AsyncSession):
|
||||
self.model = model
|
||||
self.session = session
|
||||
|
||||
async def _get(self, id: UUID):
|
||||
return await self.session.get(self.model, id)
|
||||
|
||||
async def _add(self, entity):
|
||||
self.session.add(entity)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def _update(self, entity):
|
||||
return await self._add(entity)
|
||||
|
||||
async def _delete(self, entity):
|
||||
from uuid import UUID
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
|
||||
class BaseService:
|
||||
def __init__(self, model, session: AsyncSession):
|
||||
self.model = model
|
||||
self.session = session
|
||||
|
||||
async def _get(self, id: UUID):
|
||||
return await self.session.get(self.model, id)
|
||||
|
||||
async def _add(self, entity):
|
||||
self.session.add(entity)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def _update(self, entity):
|
||||
return await self._add(entity)
|
||||
|
||||
async def _delete(self, entity):
|
||||
await self.session.delete(entity)
|
||||
|
|
@ -1,333 +1,419 @@
|
|||
"""
|
||||
Song Background Tasks
|
||||
|
||||
노래 생성 관련 백그라운드 태스크를 정의합니다.
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
import aiofiles
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database.session import BackgroundSessionLocal
|
||||
from app.song.models import Song
|
||||
from app.utils.common import generate_task_id
|
||||
from app.utils.upload_blob_as_request import AzureBlobUploader
|
||||
from config import prj_settings
|
||||
|
||||
|
||||
async def download_and_save_song(
|
||||
task_id: str,
|
||||
audio_url: str,
|
||||
store_name: str,
|
||||
) -> None:
|
||||
"""백그라운드에서 노래를 다운로드하고 Song 테이블을 업데이트합니다.
|
||||
|
||||
Args:
|
||||
task_id: 프로젝트 task_id
|
||||
audio_url: 다운로드할 오디오 URL
|
||||
store_name: 저장할 파일명에 사용할 업체명
|
||||
"""
|
||||
print(f"[download_and_save_song] START - task_id: {task_id}, store_name: {store_name}")
|
||||
try:
|
||||
# 저장 경로 생성: media/song/{날짜}/{uuid7}/{store_name}.mp3
|
||||
today = date.today().strftime("%Y-%m-%d")
|
||||
unique_id = await generate_task_id()
|
||||
# 파일명에 사용할 수 없는 문자 제거
|
||||
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"
|
||||
|
||||
# 절대 경로 생성
|
||||
media_dir = Path("media") / "song" / today / unique_id
|
||||
media_dir.mkdir(parents=True, exist_ok=True)
|
||||
file_path = media_dir / file_name
|
||||
print(f"[download_and_save_song] Directory created - path: {file_path}")
|
||||
|
||||
# 오디오 파일 다운로드
|
||||
print(f"[download_and_save_song] Downloading audio - task_id: {task_id}, url: {audio_url}")
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(audio_url, timeout=60.0)
|
||||
response.raise_for_status()
|
||||
|
||||
async with aiofiles.open(str(file_path), "wb") as f:
|
||||
await f.write(response.content)
|
||||
print(f"[download_and_save_song] File saved - task_id: {task_id}, path: {file_path}")
|
||||
|
||||
# 프론트엔드에서 접근 가능한 URL 생성
|
||||
relative_path = f"/media/song/{today}/{unique_id}/{file_name}"
|
||||
base_url = f"http://{prj_settings.PROJECT_DOMAIN}"
|
||||
file_url = f"{base_url}{relative_path}"
|
||||
print(f"[download_and_save_song] URL generated - task_id: {task_id}, url: {file_url}")
|
||||
|
||||
# Song 테이블 업데이트 (새 세션 사용)
|
||||
async with BackgroundSessionLocal() as session:
|
||||
# 여러 개 있을 경우 가장 최근 것 선택
|
||||
result = await session.execute(
|
||||
select(Song)
|
||||
.where(Song.task_id == task_id)
|
||||
.order_by(Song.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
song = result.scalar_one_or_none()
|
||||
|
||||
if song:
|
||||
song.status = "completed"
|
||||
song.song_result_url = file_url
|
||||
await session.commit()
|
||||
print(f"[download_and_save_song] SUCCESS - task_id: {task_id}, status: completed")
|
||||
else:
|
||||
print(f"[download_and_save_song] Song NOT FOUND in DB - task_id: {task_id}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"[download_and_save_song] EXCEPTION - task_id: {task_id}, error: {e}")
|
||||
# 실패 시 Song 테이블 업데이트
|
||||
async with BackgroundSessionLocal() as session:
|
||||
# 여러 개 있을 경우 가장 최근 것 선택
|
||||
result = await session.execute(
|
||||
select(Song)
|
||||
.where(Song.task_id == task_id)
|
||||
.order_by(Song.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
song = result.scalar_one_or_none()
|
||||
|
||||
if song:
|
||||
song.status = "failed"
|
||||
await session.commit()
|
||||
print(f"[download_and_save_song] FAILED - task_id: {task_id}, status updated to 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: 저장할 파일명에 사용할 업체명
|
||||
"""
|
||||
print(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
|
||||
print(f"[download_and_upload_song_to_blob] Temp directory created - path: {temp_file_path}")
|
||||
|
||||
# 오디오 파일 다운로드
|
||||
print(f"[download_and_upload_song_to_blob] Downloading audio - task_id: {task_id}, url: {audio_url}")
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(audio_url, timeout=60.0)
|
||||
response.raise_for_status()
|
||||
|
||||
async with aiofiles.open(str(temp_file_path), "wb") as f:
|
||||
await f.write(response.content)
|
||||
print(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
|
||||
print(f"[download_and_upload_song_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}")
|
||||
|
||||
# Song 테이블 업데이트 (새 세션 사용)
|
||||
async with BackgroundSessionLocal() as session:
|
||||
# 여러 개 있을 경우 가장 최근 것 선택
|
||||
result = await session.execute(
|
||||
select(Song)
|
||||
.where(Song.task_id == task_id)
|
||||
.order_by(Song.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
song = result.scalar_one_or_none()
|
||||
|
||||
if song:
|
||||
song.status = "completed"
|
||||
song.song_result_url = blob_url
|
||||
await session.commit()
|
||||
print(f"[download_and_upload_song_to_blob] SUCCESS - task_id: {task_id}, status: completed")
|
||||
else:
|
||||
print(f"[download_and_upload_song_to_blob] Song NOT FOUND in DB - task_id: {task_id}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"[download_and_upload_song_to_blob] EXCEPTION - task_id: {task_id}, error: {e}")
|
||||
# 실패 시 Song 테이블 업데이트
|
||||
async with BackgroundSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(Song)
|
||||
.where(Song.task_id == task_id)
|
||||
.order_by(Song.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
song = result.scalar_one_or_none()
|
||||
|
||||
if song:
|
||||
song.status = "failed"
|
||||
await session.commit()
|
||||
print(f"[download_and_upload_song_to_blob] FAILED - task_id: {task_id}, status updated to failed")
|
||||
|
||||
finally:
|
||||
# 임시 파일 삭제
|
||||
if temp_file_path and temp_file_path.exists():
|
||||
try:
|
||||
temp_file_path.unlink()
|
||||
print(f"[download_and_upload_song_to_blob] Temp file deleted - path: {temp_file_path}")
|
||||
except Exception as e:
|
||||
print(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(
|
||||
suno_task_id: str,
|
||||
audio_url: str,
|
||||
store_name: str,
|
||||
duration: float | None = None,
|
||||
) -> None:
|
||||
"""suno_task_id로 Song을 조회하여 노래를 다운로드하고 Azure Blob Storage에 업로드한 뒤 Song 테이블을 업데이트합니다.
|
||||
|
||||
Args:
|
||||
suno_task_id: Suno API 작업 ID
|
||||
audio_url: 다운로드할 오디오 URL
|
||||
store_name: 저장할 파일명에 사용할 업체명
|
||||
duration: 노래 재생 시간 (초)
|
||||
"""
|
||||
print(f"[download_and_upload_song_by_suno_task_id] START - suno_task_id: {suno_task_id}, store_name: {store_name}, duration: {duration}")
|
||||
temp_file_path: Path | None = None
|
||||
task_id: str | None = None
|
||||
|
||||
try:
|
||||
# suno_task_id로 Song 조회하여 task_id 가져오기
|
||||
async with BackgroundSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(Song)
|
||||
.where(Song.suno_task_id == suno_task_id)
|
||||
.order_by(Song.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
song = result.scalar_one_or_none()
|
||||
|
||||
if not song:
|
||||
print(f"[download_and_upload_song_by_suno_task_id] Song NOT FOUND - suno_task_id: {suno_task_id}")
|
||||
return
|
||||
|
||||
task_id = song.task_id
|
||||
print(f"[download_and_upload_song_by_suno_task_id] Song found - suno_task_id: {suno_task_id}, task_id: {task_id}")
|
||||
|
||||
# 파일명에 사용할 수 없는 문자 제거
|
||||
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
|
||||
print(f"[download_and_upload_song_by_suno_task_id] Temp directory created - path: {temp_file_path}")
|
||||
|
||||
# 오디오 파일 다운로드
|
||||
print(f"[download_and_upload_song_by_suno_task_id] Downloading audio - suno_task_id: {suno_task_id}, url: {audio_url}")
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(audio_url, timeout=60.0)
|
||||
response.raise_for_status()
|
||||
|
||||
async with aiofiles.open(str(temp_file_path), "wb") as f:
|
||||
await f.write(response.content)
|
||||
print(f"[download_and_upload_song_by_suno_task_id] File downloaded - suno_task_id: {suno_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
|
||||
print(f"[download_and_upload_song_by_suno_task_id] Uploaded to Blob - suno_task_id: {suno_task_id}, url: {blob_url}")
|
||||
|
||||
# Song 테이블 업데이트 (새 세션 사용)
|
||||
async with BackgroundSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(Song)
|
||||
.where(Song.suno_task_id == suno_task_id)
|
||||
.order_by(Song.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
song = result.scalar_one_or_none()
|
||||
|
||||
if song:
|
||||
song.status = "completed"
|
||||
song.song_result_url = blob_url
|
||||
if duration is not None:
|
||||
song.duration = duration
|
||||
await session.commit()
|
||||
print(f"[download_and_upload_song_by_suno_task_id] SUCCESS - suno_task_id: {suno_task_id}, status: completed, duration: {duration}")
|
||||
else:
|
||||
print(f"[download_and_upload_song_by_suno_task_id] Song NOT FOUND in DB - suno_task_id: {suno_task_id}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"[download_and_upload_song_by_suno_task_id] EXCEPTION - suno_task_id: {suno_task_id}, error: {e}")
|
||||
# 실패 시 Song 테이블 업데이트
|
||||
if task_id:
|
||||
async with BackgroundSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(Song)
|
||||
.where(Song.suno_task_id == suno_task_id)
|
||||
.order_by(Song.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
song = result.scalar_one_or_none()
|
||||
|
||||
if song:
|
||||
song.status = "failed"
|
||||
await session.commit()
|
||||
print(f"[download_and_upload_song_by_suno_task_id] FAILED - suno_task_id: {suno_task_id}, status updated to failed")
|
||||
|
||||
finally:
|
||||
# 임시 파일 삭제
|
||||
if temp_file_path and temp_file_path.exists():
|
||||
try:
|
||||
temp_file_path.unlink()
|
||||
print(f"[download_and_upload_song_by_suno_task_id] Temp file deleted - path: {temp_file_path}")
|
||||
except Exception as e:
|
||||
print(f"[download_and_upload_song_by_suno_task_id] Failed to delete temp file: {e}")
|
||||
|
||||
# 임시 디렉토리 삭제 시도
|
||||
if task_id:
|
||||
temp_dir = Path("media") / "temp" / task_id
|
||||
if temp_dir.exists():
|
||||
try:
|
||||
temp_dir.rmdir()
|
||||
except Exception:
|
||||
pass # 디렉토리가 비어있지 않으면 무시
|
||||
"""
|
||||
Song Background Tasks
|
||||
|
||||
노래 생성 관련 백그라운드 태스크를 정의합니다.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import traceback
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
import aiofiles
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from app.database.session import BackgroundSessionLocal
|
||||
from app.song.models import Song
|
||||
from app.utils.common import generate_task_id
|
||||
from app.utils.upload_blob_as_request import AzureBlobUploader
|
||||
from config import prj_settings
|
||||
|
||||
# 로거 설정
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# HTTP 요청 설정
|
||||
REQUEST_TIMEOUT = 120.0 # 초
|
||||
|
||||
|
||||
async def _update_song_status(
|
||||
task_id: str,
|
||||
status: str,
|
||||
song_url: str | None = None,
|
||||
suno_task_id: str | None = None,
|
||||
duration: float | None = None,
|
||||
) -> bool:
|
||||
"""Song 테이블의 상태를 업데이트합니다.
|
||||
|
||||
Args:
|
||||
task_id: 프로젝트 task_id
|
||||
status: 변경할 상태 ("processing", "completed", "failed")
|
||||
song_url: 노래 URL
|
||||
suno_task_id: Suno task ID (선택)
|
||||
duration: 노래 길이 (선택)
|
||||
|
||||
Returns:
|
||||
bool: 업데이트 성공 여부
|
||||
"""
|
||||
try:
|
||||
async with BackgroundSessionLocal() as session:
|
||||
if suno_task_id:
|
||||
query_result = await session.execute(
|
||||
select(Song)
|
||||
.where(Song.suno_task_id == suno_task_id)
|
||||
.order_by(Song.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
else:
|
||||
query_result = await session.execute(
|
||||
select(Song)
|
||||
.where(Song.task_id == task_id)
|
||||
.order_by(Song.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
song = query_result.scalar_one_or_none()
|
||||
|
||||
if song:
|
||||
song.status = status
|
||||
if song_url is not None:
|
||||
song.song_result_url = song_url
|
||||
if duration is not None:
|
||||
song.duration = duration
|
||||
await session.commit()
|
||||
logger.info(f"[Song] Status updated - task_id: {task_id}, status: {status}")
|
||||
print(f"[Song] Status updated - task_id: {task_id}, status: {status}")
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"[Song] NOT FOUND in DB - task_id: {task_id}")
|
||||
print(f"[Song] NOT FOUND in DB - task_id: {task_id}")
|
||||
return False
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"[Song] DB Error while updating status - task_id: {task_id}, error: {e}")
|
||||
print(f"[Song] DB Error while updating status - task_id: {task_id}, error: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"[Song] Unexpected error while updating status - task_id: {task_id}, error: {e}")
|
||||
print(f"[Song] Unexpected error while updating status - task_id: {task_id}, error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def _download_audio(url: str, task_id: str) -> bytes:
|
||||
"""URL에서 오디오 파일을 다운로드합니다.
|
||||
|
||||
Args:
|
||||
url: 다운로드할 URL
|
||||
task_id: 로그용 task_id
|
||||
|
||||
Returns:
|
||||
bytes: 다운로드한 파일 내용
|
||||
|
||||
Raises:
|
||||
httpx.HTTPError: 다운로드 실패 시
|
||||
"""
|
||||
logger.info(f"[Download] Downloading - task_id: {task_id}")
|
||||
print(f"[Download] Downloading - task_id: {task_id}")
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(url, timeout=REQUEST_TIMEOUT)
|
||||
response.raise_for_status()
|
||||
|
||||
logger.info(f"[Download] SUCCESS - task_id: {task_id}, size: {len(response.content)} bytes")
|
||||
print(f"[Download] SUCCESS - task_id: {task_id}, size: {len(response.content)} bytes")
|
||||
return response.content
|
||||
|
||||
|
||||
async def download_and_save_song(
|
||||
task_id: str,
|
||||
audio_url: str,
|
||||
store_name: str,
|
||||
) -> None:
|
||||
"""백그라운드에서 노래를 다운로드하고 Song 테이블을 업데이트합니다.
|
||||
|
||||
Args:
|
||||
task_id: 프로젝트 task_id
|
||||
audio_url: 다운로드할 오디오 URL
|
||||
store_name: 저장할 파일명에 사용할 업체명
|
||||
"""
|
||||
logger.info(f"[download_and_save_song] START - task_id: {task_id}, store_name: {store_name}")
|
||||
print(f"[download_and_save_song] START - task_id: {task_id}, store_name: {store_name}")
|
||||
|
||||
try:
|
||||
# 저장 경로 생성: media/song/{날짜}/{uuid7}/{store_name}.mp3
|
||||
today = date.today().strftime("%Y-%m-%d")
|
||||
unique_id = await generate_task_id()
|
||||
# 파일명에 사용할 수 없는 문자 제거
|
||||
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"
|
||||
|
||||
# 절대 경로 생성
|
||||
media_dir = Path("media") / "song" / today / unique_id
|
||||
media_dir.mkdir(parents=True, exist_ok=True)
|
||||
file_path = media_dir / file_name
|
||||
logger.info(f"[download_and_save_song] Directory created - path: {file_path}")
|
||||
print(f"[download_and_save_song] Directory created - path: {file_path}")
|
||||
|
||||
# 오디오 파일 다운로드
|
||||
logger.info(f"[download_and_save_song] Downloading audio - task_id: {task_id}, url: {audio_url}")
|
||||
print(f"[download_and_save_song] Downloading audio - task_id: {task_id}, url: {audio_url}")
|
||||
|
||||
content = await _download_audio(audio_url, task_id)
|
||||
|
||||
async with aiofiles.open(str(file_path), "wb") as f:
|
||||
await f.write(content)
|
||||
|
||||
logger.info(f"[download_and_save_song] File saved - task_id: {task_id}, path: {file_path}")
|
||||
print(f"[download_and_save_song] File saved - task_id: {task_id}, path: {file_path}")
|
||||
|
||||
# 프론트엔드에서 접근 가능한 URL 생성
|
||||
relative_path = f"/media/song/{today}/{unique_id}/{file_name}"
|
||||
base_url = f"http://{prj_settings.PROJECT_DOMAIN}"
|
||||
file_url = f"{base_url}{relative_path}"
|
||||
logger.info(f"[download_and_save_song] URL generated - task_id: {task_id}, url: {file_url}")
|
||||
print(f"[download_and_save_song] URL generated - task_id: {task_id}, url: {file_url}")
|
||||
|
||||
# Song 테이블 업데이트
|
||||
await _update_song_status(task_id, "completed", file_url)
|
||||
logger.info(f"[download_and_save_song] SUCCESS - task_id: {task_id}")
|
||||
print(f"[download_and_save_song] SUCCESS - task_id: {task_id}")
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"[download_and_save_song] DOWNLOAD ERROR - task_id: {task_id}, error: {e}")
|
||||
print(f"[download_and_save_song] DOWNLOAD ERROR - task_id: {task_id}, error: {e}")
|
||||
traceback.print_exc()
|
||||
await _update_song_status(task_id, "failed")
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"[download_and_save_song] DB ERROR - task_id: {task_id}, error: {e}")
|
||||
print(f"[download_and_save_song] DB ERROR - task_id: {task_id}, error: {e}")
|
||||
traceback.print_exc()
|
||||
await _update_song_status(task_id, "failed")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[download_and_save_song] EXCEPTION - task_id: {task_id}, error: {e}")
|
||||
print(f"[download_and_save_song] EXCEPTION - task_id: {task_id}, error: {e}")
|
||||
traceback.print_exc()
|
||||
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}")
|
||||
print(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}")
|
||||
print(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}")
|
||||
print(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}")
|
||||
print(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}")
|
||||
print(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}")
|
||||
print(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}")
|
||||
print(f"[download_and_upload_song_to_blob] DOWNLOAD ERROR - task_id: {task_id}, error: {e}")
|
||||
traceback.print_exc()
|
||||
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}")
|
||||
print(f"[download_and_upload_song_to_blob] DB ERROR - task_id: {task_id}, error: {e}")
|
||||
traceback.print_exc()
|
||||
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}")
|
||||
print(f"[download_and_upload_song_to_blob] EXCEPTION - task_id: {task_id}, error: {e}")
|
||||
traceback.print_exc()
|
||||
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}")
|
||||
print(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}")
|
||||
print(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(
|
||||
suno_task_id: str,
|
||||
audio_url: str,
|
||||
store_name: str,
|
||||
duration: float | None = None,
|
||||
) -> None:
|
||||
"""suno_task_id로 Song을 조회하여 노래를 다운로드하고 Azure Blob Storage에 업로드한 뒤 Song 테이블을 업데이트합니다.
|
||||
|
||||
Args:
|
||||
suno_task_id: Suno API 작업 ID
|
||||
audio_url: 다운로드할 오디오 URL
|
||||
store_name: 저장할 파일명에 사용할 업체명
|
||||
duration: 노래 재생 시간 (초)
|
||||
"""
|
||||
logger.info(f"[download_and_upload_song_by_suno_task_id] START - suno_task_id: {suno_task_id}, store_name: {store_name}, duration: {duration}")
|
||||
print(f"[download_and_upload_song_by_suno_task_id] START - suno_task_id: {suno_task_id}, store_name: {store_name}, duration: {duration}")
|
||||
temp_file_path: Path | None = None
|
||||
task_id: str | None = None
|
||||
|
||||
try:
|
||||
# suno_task_id로 Song 조회하여 task_id 가져오기
|
||||
async with BackgroundSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(Song)
|
||||
.where(Song.suno_task_id == suno_task_id)
|
||||
.order_by(Song.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
song = result.scalar_one_or_none()
|
||||
|
||||
if not song:
|
||||
logger.warning(f"[download_and_upload_song_by_suno_task_id] Song NOT FOUND - suno_task_id: {suno_task_id}")
|
||||
print(f"[download_and_upload_song_by_suno_task_id] Song NOT FOUND - suno_task_id: {suno_task_id}")
|
||||
return
|
||||
|
||||
task_id = song.task_id
|
||||
logger.info(f"[download_and_upload_song_by_suno_task_id] Song found - suno_task_id: {suno_task_id}, task_id: {task_id}")
|
||||
print(f"[download_and_upload_song_by_suno_task_id] Song found - suno_task_id: {suno_task_id}, task_id: {task_id}")
|
||||
|
||||
# 파일명에 사용할 수 없는 문자 제거
|
||||
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_by_suno_task_id] Temp directory created - path: {temp_file_path}")
|
||||
print(f"[download_and_upload_song_by_suno_task_id] Temp directory created - path: {temp_file_path}")
|
||||
|
||||
# 오디오 파일 다운로드
|
||||
logger.info(f"[download_and_upload_song_by_suno_task_id] Downloading audio - suno_task_id: {suno_task_id}, url: {audio_url}")
|
||||
print(f"[download_and_upload_song_by_suno_task_id] Downloading audio - suno_task_id: {suno_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_by_suno_task_id] File downloaded - suno_task_id: {suno_task_id}, path: {temp_file_path}")
|
||||
print(f"[download_and_upload_song_by_suno_task_id] File downloaded - suno_task_id: {suno_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_by_suno_task_id] Uploaded to Blob - suno_task_id: {suno_task_id}, url: {blob_url}")
|
||||
print(f"[download_and_upload_song_by_suno_task_id] Uploaded to Blob - suno_task_id: {suno_task_id}, url: {blob_url}")
|
||||
|
||||
# Song 테이블 업데이트
|
||||
await _update_song_status(
|
||||
task_id=task_id,
|
||||
status="completed",
|
||||
song_url=blob_url,
|
||||
suno_task_id=suno_task_id,
|
||||
duration=duration,
|
||||
)
|
||||
logger.info(f"[download_and_upload_song_by_suno_task_id] SUCCESS - suno_task_id: {suno_task_id}, duration: {duration}")
|
||||
print(f"[download_and_upload_song_by_suno_task_id] SUCCESS - suno_task_id: {suno_task_id}, duration: {duration}")
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"[download_and_upload_song_by_suno_task_id] DOWNLOAD ERROR - suno_task_id: {suno_task_id}, error: {e}")
|
||||
print(f"[download_and_upload_song_by_suno_task_id] DOWNLOAD ERROR - suno_task_id: {suno_task_id}, error: {e}")
|
||||
traceback.print_exc()
|
||||
if task_id:
|
||||
await _update_song_status(task_id, "failed", suno_task_id=suno_task_id)
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"[download_and_upload_song_by_suno_task_id] DB ERROR - suno_task_id: {suno_task_id}, error: {e}")
|
||||
print(f"[download_and_upload_song_by_suno_task_id] DB ERROR - suno_task_id: {suno_task_id}, error: {e}")
|
||||
traceback.print_exc()
|
||||
if task_id:
|
||||
await _update_song_status(task_id, "failed", suno_task_id=suno_task_id)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[download_and_upload_song_by_suno_task_id] EXCEPTION - suno_task_id: {suno_task_id}, error: {e}")
|
||||
print(f"[download_and_upload_song_by_suno_task_id] EXCEPTION - suno_task_id: {suno_task_id}, error: {e}")
|
||||
traceback.print_exc()
|
||||
if task_id:
|
||||
await _update_song_status(task_id, "failed", suno_task_id=suno_task_id)
|
||||
|
||||
finally:
|
||||
# 임시 파일 삭제
|
||||
if temp_file_path and temp_file_path.exists():
|
||||
try:
|
||||
temp_file_path.unlink()
|
||||
logger.info(f"[download_and_upload_song_by_suno_task_id] Temp file deleted - path: {temp_file_path}")
|
||||
print(f"[download_and_upload_song_by_suno_task_id] Temp file deleted - path: {temp_file_path}")
|
||||
except Exception as e:
|
||||
logger.warning(f"[download_and_upload_song_by_suno_task_id] Failed to delete temp file: {e}")
|
||||
print(f"[download_and_upload_song_by_suno_task_id] Failed to delete temp file: {e}")
|
||||
|
||||
# 임시 디렉토리 삭제 시도
|
||||
if task_id:
|
||||
temp_dir = Path("media") / "temp" / task_id
|
||||
if temp_dir.exists():
|
||||
try:
|
||||
temp_dir.rmdir()
|
||||
except Exception:
|
||||
pass # 디렉토리가 비어있지 않으면 무시
|
||||
|
|
|
|||
|
|
@ -1,329 +1,404 @@
|
|||
import json
|
||||
import re
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from config import apikey_settings
|
||||
|
||||
# fmt: off
|
||||
LYRICS_PROMPT_TEMPLATE_ORI = """
|
||||
1.Act as a content marketing expert with domain knowledges in [pension/staying services] in Korea, Goal: plan viral content creation that lead online reservations and promotion
|
||||
2.Conduct an in-depth analysis of [업체명:{customer_name}] in [지역명:{region}] by examining their official website or informations, photos on never map and online presence. Create a comprehensive "[지역 상세: {detail_region_info}]_Brand & Marketing Intelligence Report in Korean, that includes:
|
||||
|
||||
**Core Analysis:**
|
||||
- Target customer segments & personas
|
||||
- Unique Selling Propositions (USPs) and competitive differentiators
|
||||
- Comprehensive competitor landscape analysis (direct & indirect competitors)
|
||||
- Market positioning assessment
|
||||
|
||||
**Content Strategy Framework:**
|
||||
- Seasonal content calendar with trend integration
|
||||
- Visual storytelling direction (shot-by-shot creative guidance)
|
||||
- Brand tone & voice guidelines
|
||||
- Content themes aligned with target audience behaviors
|
||||
|
||||
**SEO & AEO Optimization:**
|
||||
- Recommended primary and long-tail keywords
|
||||
- SEO-optimized taglines and meta descriptions
|
||||
- Answer Engine Optimization (AEO) content suggestions
|
||||
- Local search optimization strategies
|
||||
|
||||
**Actionable Recommendations:**
|
||||
- Content distribution strategy across platforms
|
||||
- KPI measurement framework
|
||||
- Budget allocation recommendations by content type
|
||||
|
||||
콘텐츠 기획(Lyrics, Prompt for SUNO)
|
||||
1. Based on the Brand & Marketing Intelligence Report for [업체명 + 지역명 / {customer_name} ({region})], create original lyrics and define music attributes (song mood, BPM, genres, and key musical motifs, Prompt for Suno.com) specifically tailored for viral content.
|
||||
2. The lyrics should include, the name of [ Promotion Subject], [location], [main target],[Famous place, accessible in 10min], promotional words including but not limited to [인스타 감성], [사진같은 하루]
|
||||
|
||||
Deliver outputs optimized for three formats:1 minute. Ensure that each version aligns with the brand's core identity and is suitable for use in digital marketing and social media campaigns, in Korean
|
||||
""".strip()
|
||||
# fmt: on
|
||||
|
||||
LYRICS_PROMPT_TEMPLATE = """
|
||||
[ROLE]
|
||||
Content marketing expert and creative songwriter specializing in pension/accommodation services
|
||||
|
||||
[INPUT]
|
||||
- Business Name: {customer_name}
|
||||
- Region: {region}
|
||||
- Region Details: {detail_region_info}
|
||||
- Output Language: {language}
|
||||
|
||||
[INTERNAL ANALYSIS - DO NOT OUTPUT]
|
||||
Analyze the following internally to inform lyrics creation:
|
||||
- Target customer segments and personas
|
||||
- Unique Selling Propositions (USPs)
|
||||
- Regional characteristics and nearby attractions (within 10 min access)
|
||||
- Seasonal appeal points
|
||||
- Emotional triggers for the target audience
|
||||
|
||||
[LYRICS REQUIREMENTS]
|
||||
1. Must Include Elements:
|
||||
- Business name (TRANSLATED or TRANSLITERATED to {language})
|
||||
- Region name (TRANSLATED or TRANSLITERATED to {language})
|
||||
- Main target audience appeal
|
||||
- Nearby famous places or regional characteristics
|
||||
|
||||
2. Keywords to Incorporate (use language-appropriate trendy expressions):
|
||||
- Korean: 인스타 감성, 사진같은 하루, 힐링, 여행, 감성 숙소
|
||||
- English: Instagram vibes, picture-perfect day, healing, travel, getaway
|
||||
- Chinese: 网红打卡, 治愈系, 旅行, 度假, 拍照圣地
|
||||
- Japanese: インスタ映え, 写真のような一日, 癒し, 旅行, 絶景
|
||||
- Thai: ที่พักสวย, ฮีลใจ, เที่ยว, ถ่ายรูป, วิวสวย
|
||||
- Vietnamese: check-in đẹp, healing, du lịch, nghỉ dưỡng, view đẹp
|
||||
|
||||
3. Structure:
|
||||
- Length: For 1-minute video (approximately 8-12 lines)
|
||||
- Flow: Verse structure suitable for music
|
||||
- Rhythm: Natural speech rhythm in the specified language
|
||||
|
||||
4. Tone:
|
||||
- Emotional and heartfelt
|
||||
- Trendy and viral-friendly
|
||||
- Relatable to target audience
|
||||
|
||||
[CRITICAL LANGUAGE REQUIREMENT - ABSOLUTE RULE]
|
||||
ALL OUTPUT MUST BE 100% WRITTEN IN {language} - NO EXCEPTIONS
|
||||
- ALL lyrics content: {language} ONLY
|
||||
- ALL proper nouns (business names, region names, place names): MUST be translated or transliterated to {language}
|
||||
- Korean input like "군산" must become "Gunsan" in English, "群山" in Chinese, "グンサン" in Japanese, etc.
|
||||
- Korean input like "스테이 머뭄" must become "Stay Meoum" in English, "住留" in Chinese, "ステイモーム" in Japanese, etc.
|
||||
- ZERO Korean characters (한글) allowed when output language is NOT Korean
|
||||
- ZERO mixing of languages - the entire output must be monolingual in {language}
|
||||
- This is a NON-NEGOTIABLE requirement
|
||||
- Any output containing characters from other languages is considered a COMPLETE FAILURE
|
||||
- Violation of this rule invalidates the entire response
|
||||
|
||||
[OUTPUT RULES - STRICTLY ENFORCED]
|
||||
- Output lyrics ONLY
|
||||
- Lyrics MUST be written ENTIRELY in {language} - NO EXCEPTIONS
|
||||
- ALL names and places MUST be in {language} script/alphabet
|
||||
- NO Korean (한글), Chinese (漢字), Japanese (仮名), Thai (ไทย), or Vietnamese (Tiếng Việt) characters unless that is the selected output language
|
||||
- NO titles, descriptions, analysis, or explanations
|
||||
- NO greetings or closing remarks
|
||||
- NO additional commentary before or after lyrics
|
||||
- NO line numbers or labels
|
||||
- Follow the exact format below
|
||||
|
||||
[OUTPUT FORMAT - SUCCESS]
|
||||
---
|
||||
[Lyrics ENTIRELY in {language} here - no other language characters allowed]
|
||||
---
|
||||
|
||||
[OUTPUT FORMAT - FAILURE]
|
||||
If you cannot generate lyrics due to insufficient information, invalid input, or any other reason:
|
||||
---
|
||||
ERROR: [Brief reason for failure in English]
|
||||
---
|
||||
""".strip()
|
||||
# fmt: on
|
||||
|
||||
MARKETING_ANALYSIS_PROMPT_TEMPLATE = """
|
||||
[ROLE]
|
||||
Content marketing expert specializing in pension/accommodation services in Korea
|
||||
|
||||
[INPUT]
|
||||
- Business Name: {customer_name}
|
||||
- Region: {region}
|
||||
- Region Details: {detail_region_info}
|
||||
|
||||
[ANALYSIS REQUIREMENTS]
|
||||
Provide comprehensive marketing analysis including:
|
||||
1. Target Customer Segments
|
||||
- Primary and secondary target personas
|
||||
- Age groups, travel preferences, booking patterns
|
||||
2. Unique Selling Propositions (USPs)
|
||||
- Key differentiators based on location and region details
|
||||
- Competitive advantages
|
||||
3. Regional Characteristics
|
||||
- Nearby attractions and famous places (within 10 min access)
|
||||
- Local food, activities, and experiences
|
||||
- Transportation accessibility
|
||||
4. Seasonal Appeal Points
|
||||
- Best seasons to visit
|
||||
- Seasonal activities and events
|
||||
- Peak/off-peak marketing opportunities
|
||||
5. Marketing Keywords
|
||||
- Recommended hashtags and search keywords
|
||||
- Trending terms relevant to the property
|
||||
|
||||
[ADDITIONAL REQUIREMENTS]
|
||||
1. Recommended Tags
|
||||
- Generate 5 recommended hashtags/tags based on the business characteristics
|
||||
- Tags should be trendy, searchable, and relevant to accommodation marketing
|
||||
- Return as JSON with key "tags"
|
||||
- **MUST be written in Korean (한국어)**
|
||||
|
||||
2. Facilities
|
||||
- Based on the business name and region details, identify 5 likely facilities/amenities
|
||||
- Consider typical facilities for accommodations in the given region
|
||||
- Examples: 바베큐장, 수영장, 주차장, 와이파이, 주방, 테라스, 정원, etc.
|
||||
- Return as JSON with key "facilities"
|
||||
- **MUST be written in Korean (한국어)**
|
||||
|
||||
[CRITICAL LANGUAGE REQUIREMENT - ABSOLUTE RULE]
|
||||
ALL OUTPUT MUST BE WRITTEN IN KOREAN (한국어)
|
||||
- Analysis sections: Korean only
|
||||
- Tags: Korean only
|
||||
- Facilities: Korean only
|
||||
- This is a NON-NEGOTIABLE requirement
|
||||
- Any output in English or other languages is considered a FAILURE
|
||||
- Violation of this rule invalidates the entire response
|
||||
|
||||
[OUTPUT RULES - STRICTLY ENFORCED]
|
||||
- Output analysis ONLY
|
||||
- ALL content MUST be written in Korean (한국어) - NO EXCEPTIONS
|
||||
- NO greetings or closing remarks
|
||||
- NO additional commentary before or after analysis
|
||||
- Follow the exact format below
|
||||
|
||||
[OUTPUT FORMAT - SUCCESS]
|
||||
---
|
||||
## 타겟 고객 분석
|
||||
[한국어로 작성된 타겟 고객 분석]
|
||||
|
||||
## 핵심 차별점 (USP)
|
||||
[한국어로 작성된 USP 분석]
|
||||
|
||||
## 지역 특성
|
||||
[한국어로 작성된 지역 특성 분석]
|
||||
|
||||
## 시즌별 매력 포인트
|
||||
[한국어로 작성된 시즌별 분석]
|
||||
|
||||
## 마케팅 키워드
|
||||
[한국어로 작성된 마케팅 키워드]
|
||||
|
||||
## JSON Data
|
||||
```json
|
||||
{{
|
||||
"tags": ["태그1", "태그2", "태그3", "태그4", "태그5"],
|
||||
"facilities": ["부대시설1", "부대시설2", "부대시설3", "부대시설4", "부대시설5"]
|
||||
}}
|
||||
```
|
||||
---
|
||||
|
||||
[OUTPUT FORMAT - FAILURE]
|
||||
If you cannot generate analysis due to insufficient information, invalid input, or any other reason:
|
||||
---
|
||||
ERROR: [Brief reason for failure in English]
|
||||
---
|
||||
""".strip()
|
||||
# fmt: on
|
||||
|
||||
|
||||
class ChatgptService:
|
||||
def __init__(
|
||||
self,
|
||||
customer_name: str,
|
||||
region: str,
|
||||
detail_region_info: str = "",
|
||||
language: str = "Korean",
|
||||
):
|
||||
# 최신 모델: GPT-5, GPT-5 mini, GPT-5 nano, GPT-4.1, GPT-4.1 mini, GPT-4.1 nano
|
||||
# 이전 세대: GPT-4o, GPT-4o mini, GPT-4 Turbo, GPT-3.5 Turbo
|
||||
self.model = "gpt-4o"
|
||||
self.client = AsyncOpenAI(api_key=apikey_settings.CHATGPT_API_KEY)
|
||||
self.customer_name = customer_name
|
||||
self.region = region
|
||||
self.detail_region_info = detail_region_info
|
||||
self.language = language
|
||||
|
||||
def build_lyrics_prompt(self) -> str:
|
||||
"""LYRICS_PROMPT_TEMPLATE에 고객 정보를 대입하여 완성된 프롬프트 반환"""
|
||||
return LYRICS_PROMPT_TEMPLATE.format(
|
||||
customer_name=self.customer_name,
|
||||
region=self.region,
|
||||
detail_region_info=self.detail_region_info,
|
||||
language=self.language,
|
||||
)
|
||||
|
||||
def build_market_analysis_prompt(self) -> str:
|
||||
"""MARKETING_ANALYSIS_PROMPT_TEMPLATE에 고객 정보를 대입하여 완성된 프롬프트 반환"""
|
||||
return MARKETING_ANALYSIS_PROMPT_TEMPLATE.format(
|
||||
customer_name=self.customer_name,
|
||||
region=self.region,
|
||||
detail_region_info=self.detail_region_info,
|
||||
)
|
||||
|
||||
async def generate(self, prompt: str | None = None) -> str:
|
||||
"""GPT에게 프롬프트를 전달하여 결과를 반환"""
|
||||
if prompt is None:
|
||||
prompt = self.build_lyrics_prompt()
|
||||
print("Generated Prompt: ", prompt)
|
||||
completion = await self.client.chat.completions.create(
|
||||
model=self.model, messages=[{"role": "user", "content": prompt}]
|
||||
)
|
||||
message = completion.choices[0].message.content
|
||||
return message or ""
|
||||
|
||||
async def summarize_marketing(self, text: str) -> str:
|
||||
"""마케팅 텍스트를 항목으로 구분하여 500자로 요약 정리"""
|
||||
prompt = f"""[ROLE]
|
||||
마케팅 콘텐츠 요약 전문가
|
||||
|
||||
[INPUT]
|
||||
{text}
|
||||
|
||||
[TASK]
|
||||
위 텍스트를 분석하여 핵심 내용을 항목별로 구분하여 500자 이내로 요약해주세요.
|
||||
|
||||
[OUTPUT REQUIREMENTS]
|
||||
- 항목별로 구분하여 정리 (예: 타겟 고객, 차별점, 지역 특성 등)
|
||||
- 총 500자 이내로 요약
|
||||
- 핵심 정보만 간결하게 포함
|
||||
- 한국어로 작성
|
||||
|
||||
[OUTPUT FORMAT]
|
||||
---
|
||||
[항목별로 구분된 500자 이내 요약]
|
||||
---
|
||||
"""
|
||||
completion = await self.client.chat.completions.create(
|
||||
model=self.model, messages=[{"role": "user", "content": prompt}]
|
||||
)
|
||||
message = completion.choices[0].message.content
|
||||
result = message or ""
|
||||
|
||||
# --- 구분자 제거
|
||||
if result.startswith("---"):
|
||||
result = result[3:].strip()
|
||||
if result.endswith("---"):
|
||||
result = result[:-3].strip()
|
||||
|
||||
return result
|
||||
|
||||
async def parse_marketing_analysis(self, raw_response: str) -> dict:
|
||||
"""ChatGPT 마케팅 분석 응답을 파싱하고 요약하여 딕셔너리로 반환
|
||||
|
||||
Returns:
|
||||
dict: {"report": str, "tags": list[str], "facilities": list[str]}
|
||||
"""
|
||||
tags: list[str] = []
|
||||
facilities: list[str] = []
|
||||
report = raw_response
|
||||
|
||||
# JSON 블록 추출 시도
|
||||
json_match = re.search(r"```json\s*(\{.*?\})\s*```", raw_response, re.DOTALL)
|
||||
if json_match:
|
||||
try:
|
||||
json_data = json.loads(json_match.group(1))
|
||||
tags = json_data.get("tags", [])
|
||||
facilities = json_data.get("facilities", [])
|
||||
# JSON 블록을 제외한 리포트 부분 추출
|
||||
report = raw_response[: json_match.start()].strip()
|
||||
# --- 구분자 제거
|
||||
if report.startswith("---"):
|
||||
report = report[3:].strip()
|
||||
if report.endswith("---"):
|
||||
report = report[:-3].strip()
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# 리포트 내용을 500자로 요약
|
||||
if report:
|
||||
report = await self.summarize_marketing(report)
|
||||
|
||||
return {"report": report, "tags": tags, "facilities": facilities}
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from config import apikey_settings
|
||||
|
||||
# 로거 설정
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# fmt: off
|
||||
LYRICS_PROMPT_TEMPLATE_ORI = """
|
||||
1.Act as a content marketing expert with domain knowledges in [pension/staying services] in Korea, Goal: plan viral content creation that lead online reservations and promotion
|
||||
2.Conduct an in-depth analysis of [업체명:{customer_name}] in [지역명:{region}] by examining their official website or informations, photos on never map and online presence. Create a comprehensive "[지역 상세: {detail_region_info}]_Brand & Marketing Intelligence Report in Korean, that includes:
|
||||
|
||||
**Core Analysis:**
|
||||
- Target customer segments & personas
|
||||
- Unique Selling Propositions (USPs) and competitive differentiators
|
||||
- Comprehensive competitor landscape analysis (direct & indirect competitors)
|
||||
- Market positioning assessment
|
||||
|
||||
**Content Strategy Framework:**
|
||||
- Seasonal content calendar with trend integration
|
||||
- Visual storytelling direction (shot-by-shot creative guidance)
|
||||
- Brand tone & voice guidelines
|
||||
- Content themes aligned with target audience behaviors
|
||||
|
||||
**SEO & AEO Optimization:**
|
||||
- Recommended primary and long-tail keywords
|
||||
- SEO-optimized taglines and meta descriptions
|
||||
- Answer Engine Optimization (AEO) content suggestions
|
||||
- Local search optimization strategies
|
||||
|
||||
**Actionable Recommendations:**
|
||||
- Content distribution strategy across platforms
|
||||
- KPI measurement framework
|
||||
- Budget allocation recommendations by content type
|
||||
|
||||
콘텐츠 기획(Lyrics, Prompt for SUNO)
|
||||
1. Based on the Brand & Marketing Intelligence Report for [업체명 + 지역명 / {customer_name} ({region})], create original lyrics and define music attributes (song mood, BPM, genres, and key musical motifs, Prompt for Suno.com) specifically tailored for viral content.
|
||||
2. The lyrics should include, the name of [ Promotion Subject], [location], [main target],[Famous place, accessible in 10min], promotional words including but not limited to [인스타 감성], [사진같은 하루]
|
||||
|
||||
Deliver outputs optimized for three formats:1 minute. Ensure that each version aligns with the brand's core identity and is suitable for use in digital marketing and social media campaigns, in Korean
|
||||
""".strip()
|
||||
# fmt: on
|
||||
|
||||
LYRICS_PROMPT_TEMPLATE = """
|
||||
[ROLE]
|
||||
Content marketing expert and creative songwriter specializing in pension/accommodation services
|
||||
|
||||
[INPUT]
|
||||
- Business Name: {customer_name}
|
||||
- Region: {region}
|
||||
- Region Details: {detail_region_info}
|
||||
- Output Language: {language}
|
||||
|
||||
[INTERNAL ANALYSIS - DO NOT OUTPUT]
|
||||
Analyze the following internally to inform lyrics creation:
|
||||
- Target customer segments and personas
|
||||
- Unique Selling Propositions (USPs)
|
||||
- Regional characteristics and nearby attractions (within 10 min access)
|
||||
- Seasonal appeal points
|
||||
- Emotional triggers for the target audience
|
||||
|
||||
[LYRICS REQUIREMENTS]
|
||||
1. Must Include Elements:
|
||||
- Business name (TRANSLATED or TRANSLITERATED to {language})
|
||||
- Region name (TRANSLATED or TRANSLITERATED to {language})
|
||||
- Main target audience appeal
|
||||
- Nearby famous places or regional characteristics
|
||||
|
||||
2. Keywords to Incorporate (use language-appropriate trendy expressions):
|
||||
- Korean: 인스타 감성, 사진같은 하루, 힐링, 여행, 감성 숙소
|
||||
- English: Instagram vibes, picture-perfect day, healing, travel, getaway
|
||||
- Chinese: 网红打卡, 治愈系, 旅行, 度假, 拍照圣地
|
||||
- Japanese: インスタ映え, 写真のような一日, 癒し, 旅行, 絶景
|
||||
- Thai: ที่พักสวย, ฮีลใจ, เที่ยว, ถ่ายรูป, วิวสวย
|
||||
- Vietnamese: check-in đẹp, healing, du lịch, nghỉ dưỡng, view đẹp
|
||||
|
||||
3. Structure:
|
||||
- Length: For 1-minute video (approximately 8-12 lines)
|
||||
- Flow: Verse structure suitable for music
|
||||
- Rhythm: Natural speech rhythm in the specified language
|
||||
|
||||
4. Tone:
|
||||
- Emotional and heartfelt
|
||||
- Trendy and viral-friendly
|
||||
- Relatable to target audience
|
||||
|
||||
[CRITICAL LANGUAGE REQUIREMENT - ABSOLUTE RULE]
|
||||
ALL OUTPUT MUST BE 100% WRITTEN IN {language} - NO EXCEPTIONS
|
||||
- ALL lyrics content: {language} ONLY
|
||||
- ALL proper nouns (business names, region names, place names): MUST be translated or transliterated to {language}
|
||||
- Korean input like "군산" must become "Gunsan" in English, "群山" in Chinese, "グンサン" in Japanese, etc.
|
||||
- Korean input like "스테이 머뭄" must become "Stay Meoum" in English, "住留" in Chinese, "ステイモーム" in Japanese, etc.
|
||||
- ZERO Korean characters (한글) allowed when output language is NOT Korean
|
||||
- ZERO mixing of languages - the entire output must be monolingual in {language}
|
||||
- This is a NON-NEGOTIABLE requirement
|
||||
- Any output containing characters from other languages is considered a COMPLETE FAILURE
|
||||
- Violation of this rule invalidates the entire response
|
||||
|
||||
[OUTPUT RULES - STRICTLY ENFORCED]
|
||||
- Output lyrics ONLY
|
||||
- Lyrics MUST be written ENTIRELY in {language} - NO EXCEPTIONS
|
||||
- ALL names and places MUST be in {language} script/alphabet
|
||||
- NO Korean (한글), Chinese (漢字), Japanese (仮名), Thai (ไทย), or Vietnamese (Tiếng Việt) characters unless that is the selected output language
|
||||
- NO titles, descriptions, analysis, or explanations
|
||||
- NO greetings or closing remarks
|
||||
- NO additional commentary before or after lyrics
|
||||
- NO line numbers or labels
|
||||
- Follow the exact format below
|
||||
|
||||
[OUTPUT FORMAT - SUCCESS]
|
||||
---
|
||||
[Lyrics ENTIRELY in {language} here - no other language characters allowed]
|
||||
---
|
||||
|
||||
[OUTPUT FORMAT - FAILURE]
|
||||
If you cannot generate lyrics due to insufficient information, invalid input, or any other reason:
|
||||
---
|
||||
ERROR: [Brief reason for failure in English]
|
||||
---
|
||||
""".strip()
|
||||
# fmt: on
|
||||
|
||||
MARKETING_ANALYSIS_PROMPT_TEMPLATE = """
|
||||
[ROLE]
|
||||
Content marketing expert specializing in pension/accommodation services in Korea
|
||||
|
||||
[INPUT]
|
||||
- Business Name: {customer_name}
|
||||
- Region: {region}
|
||||
- Region Details: {detail_region_info}
|
||||
|
||||
[ANALYSIS REQUIREMENTS]
|
||||
Provide comprehensive marketing analysis including:
|
||||
1. Target Customer Segments
|
||||
- Primary and secondary target personas
|
||||
- Age groups, travel preferences, booking patterns
|
||||
2. Unique Selling Propositions (USPs)
|
||||
- Key differentiators based on location and region details
|
||||
- Competitive advantages
|
||||
3. Regional Characteristics
|
||||
- Nearby attractions and famous places (within 10 min access)
|
||||
- Local food, activities, and experiences
|
||||
- Transportation accessibility
|
||||
4. Seasonal Appeal Points
|
||||
- Best seasons to visit
|
||||
- Seasonal activities and events
|
||||
- Peak/off-peak marketing opportunities
|
||||
5. Marketing Keywords
|
||||
- Recommended hashtags and search keywords
|
||||
- Trending terms relevant to the property
|
||||
|
||||
[ADDITIONAL REQUIREMENTS]
|
||||
1. Recommended Tags
|
||||
- Generate 5 recommended hashtags/tags based on the business characteristics
|
||||
- Tags should be trendy, searchable, and relevant to accommodation marketing
|
||||
- Return as JSON with key "tags"
|
||||
- **MUST be written in Korean (한국어)**
|
||||
|
||||
[CRITICAL LANGUAGE REQUIREMENT - ABSOLUTE RULE]
|
||||
ALL OUTPUT MUST BE WRITTEN IN KOREAN (한국어)
|
||||
- Analysis sections: Korean only
|
||||
- Tags: Korean only
|
||||
- This is a NON-NEGOTIABLE requirement
|
||||
- Any output in English or other languages is considered a FAILURE
|
||||
- Violation of this rule invalidates the entire response
|
||||
|
||||
[OUTPUT RULES - STRICTLY ENFORCED]
|
||||
- Output analysis ONLY
|
||||
- ALL content MUST be written in Korean (한국어) - NO EXCEPTIONS
|
||||
- NO greetings or closing remarks
|
||||
- NO additional commentary before or after analysis
|
||||
- Follow the exact format below
|
||||
|
||||
[OUTPUT FORMAT - SUCCESS]
|
||||
---
|
||||
## 타겟 고객 분석
|
||||
[한국어로 작성된 타겟 고객 분석]
|
||||
|
||||
## 핵심 차별점 (USP)
|
||||
[한국어로 작성된 USP 분석]
|
||||
|
||||
## 지역 특성
|
||||
[한국어로 작성된 지역 특성 분석]
|
||||
|
||||
## 시즌별 매력 포인트
|
||||
[한국어로 작성된 시즌별 분석]
|
||||
|
||||
## 마케팅 키워드
|
||||
[한국어로 작성된 마케팅 키워드]
|
||||
|
||||
## JSON Data
|
||||
```json
|
||||
{{
|
||||
"tags": ["태그1", "태그2", "태그3", "태그4", "태그5"]
|
||||
}}
|
||||
```
|
||||
---
|
||||
|
||||
[OUTPUT FORMAT - FAILURE]
|
||||
If you cannot generate analysis due to insufficient information, invalid input, or any other reason:
|
||||
---
|
||||
ERROR: [Brief reason for failure in English]
|
||||
---
|
||||
""".strip()
|
||||
# fmt: on
|
||||
|
||||
|
||||
class ChatgptService:
|
||||
"""ChatGPT API 서비스 클래스
|
||||
|
||||
GPT 5.0 모델을 사용하여 마케팅 가사 및 분석을 생성합니다.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
customer_name: str,
|
||||
region: str,
|
||||
detail_region_info: str = "",
|
||||
language: str = "Korean",
|
||||
):
|
||||
# 최신 모델: gpt-5-mini
|
||||
self.model = "gpt-5-mini"
|
||||
self.client = AsyncOpenAI(api_key=apikey_settings.CHATGPT_API_KEY)
|
||||
self.customer_name = customer_name
|
||||
self.region = region
|
||||
self.detail_region_info = detail_region_info
|
||||
self.language = language
|
||||
|
||||
def build_lyrics_prompt(self) -> str:
|
||||
"""LYRICS_PROMPT_TEMPLATE에 고객 정보를 대입하여 완성된 프롬프트 반환"""
|
||||
return LYRICS_PROMPT_TEMPLATE.format(
|
||||
customer_name=self.customer_name,
|
||||
region=self.region,
|
||||
detail_region_info=self.detail_region_info,
|
||||
language=self.language,
|
||||
)
|
||||
|
||||
def build_market_analysis_prompt(self) -> str:
|
||||
"""MARKETING_ANALYSIS_PROMPT_TEMPLATE에 고객 정보를 대입하여 완성된 프롬프트 반환"""
|
||||
return MARKETING_ANALYSIS_PROMPT_TEMPLATE.format(
|
||||
customer_name=self.customer_name,
|
||||
region=self.region,
|
||||
detail_region_info=self.detail_region_info,
|
||||
)
|
||||
|
||||
async def _call_gpt_api(self, prompt: str) -> str:
|
||||
"""GPT API를 직접 호출합니다 (내부 메서드).
|
||||
|
||||
Args:
|
||||
prompt: GPT에 전달할 프롬프트
|
||||
|
||||
Returns:
|
||||
GPT 응답 문자열
|
||||
|
||||
Raises:
|
||||
APIError, APIConnectionError, RateLimitError: OpenAI API 오류
|
||||
"""
|
||||
completion = await self.client.chat.completions.create(
|
||||
model=self.model, messages=[{"role": "user", "content": prompt}]
|
||||
)
|
||||
message = completion.choices[0].message.content
|
||||
return message or ""
|
||||
|
||||
async def generate(
|
||||
self,
|
||||
prompt: str | None = None,
|
||||
) -> str:
|
||||
"""GPT에게 프롬프트를 전달하여 결과를 반환합니다.
|
||||
|
||||
Args:
|
||||
prompt: GPT에 전달할 프롬프트 (None이면 기본 가사 프롬프트 사용)
|
||||
|
||||
Returns:
|
||||
GPT 응답 문자열
|
||||
|
||||
Raises:
|
||||
APIError, APIConnectionError, RateLimitError: OpenAI API 오류
|
||||
"""
|
||||
if prompt is None:
|
||||
prompt = self.build_lyrics_prompt()
|
||||
|
||||
print(f"[ChatgptService] Generated Prompt (length: {len(prompt)})")
|
||||
logger.info(f"[ChatgptService] Starting GPT request with model: {self.model}")
|
||||
|
||||
# GPT API 호출
|
||||
response = await self._call_gpt_api(prompt)
|
||||
|
||||
print(f"[ChatgptService] SUCCESS - Response length: {len(response)}")
|
||||
logger.info(f"[ChatgptService] SUCCESS - Response length: {len(response)}")
|
||||
return response
|
||||
|
||||
async def summarize_marketing(self, text: str) -> str:
|
||||
"""마케팅 텍스트를 항목으로 구분하여 500자로 요약 정리.
|
||||
|
||||
Args:
|
||||
text: 요약할 마케팅 텍스트
|
||||
|
||||
Returns:
|
||||
요약된 텍스트
|
||||
|
||||
Raises:
|
||||
APIError, APIConnectionError, RateLimitError: OpenAI API 오류
|
||||
"""
|
||||
prompt = f"""[ROLE]
|
||||
마케팅 콘텐츠 요약 전문가
|
||||
|
||||
[INPUT]
|
||||
{text}
|
||||
|
||||
[TASK]
|
||||
위 텍스트를 분석하여 핵심 내용을 항목별로 구분하여 500자 이내로 요약해주세요.
|
||||
|
||||
[OUTPUT REQUIREMENTS]
|
||||
- 5개 항목으로 구분: 타겟 고객, 핵심 차별점, 지역 특성, 시즌별 포인트, 추천 키워드
|
||||
- 각 항목은 줄바꿈으로 구분
|
||||
- 총 500자 이내로 요약
|
||||
- 핵심 정보만 간결하게 포함
|
||||
- 한국어로 작성
|
||||
- 특수문자 사용 금지 (괄호, 슬래시, 하이픈, 물결표 등 제외)
|
||||
- 쉼표와 마침표만 사용하여 자연스러운 문장으로 작성
|
||||
|
||||
[OUTPUT FORMAT - 반드시 아래 형식 준수]
|
||||
---
|
||||
타겟 고객
|
||||
[대상 고객층을 자연스러운 문장으로 설명]
|
||||
|
||||
핵심 차별점
|
||||
[숙소의 차별화 포인트를 자연스러운 문장으로 설명]
|
||||
|
||||
지역 특성
|
||||
[주변 관광지와 지역 특색을 자연스러운 문장으로 설명]
|
||||
|
||||
시즌별 포인트
|
||||
[계절별 매력 포인트를 자연스러운 문장으로 설명]
|
||||
|
||||
추천 키워드
|
||||
[마케팅에 활용할 키워드를 쉼표로 구분하여 나열]
|
||||
---
|
||||
"""
|
||||
|
||||
result = await self.generate(prompt=prompt)
|
||||
|
||||
# --- 구분자 제거
|
||||
if result.startswith("---"):
|
||||
result = result[3:].strip()
|
||||
if result.endswith("---"):
|
||||
result = result[:-3].strip()
|
||||
|
||||
return result
|
||||
|
||||
async def parse_marketing_analysis(
|
||||
self, raw_response: str, facility_info: str | None = None
|
||||
) -> dict:
|
||||
"""ChatGPT 마케팅 분석 응답을 파싱하고 요약하여 딕셔너리로 반환
|
||||
|
||||
Args:
|
||||
raw_response: ChatGPT 마케팅 분석 응답 원문
|
||||
facility_info: 크롤링에서 가져온 편의시설 정보 문자열
|
||||
|
||||
Returns:
|
||||
dict: {"report": str, "tags": list[str], "facilities": list[str]}
|
||||
"""
|
||||
tags: list[str] = []
|
||||
facilities: list[str] = []
|
||||
report = raw_response
|
||||
|
||||
# JSON 블록 추출 시도
|
||||
json_match = re.search(r"```json\s*(\{.*?\})\s*```", raw_response, re.DOTALL)
|
||||
if json_match:
|
||||
try:
|
||||
json_data = json.loads(json_match.group(1))
|
||||
tags = json_data.get("tags", [])
|
||||
print(f"[parse_marketing_analysis] GPT 응답에서 tags 파싱 완료: {tags}")
|
||||
# JSON 블록을 제외한 리포트 부분 추출
|
||||
report = raw_response[: json_match.start()].strip()
|
||||
# --- 구분자 제거
|
||||
if report.startswith("---"):
|
||||
report = report[3:].strip()
|
||||
if report.endswith("---"):
|
||||
report = report[:-3].strip()
|
||||
except json.JSONDecodeError:
|
||||
print("[parse_marketing_analysis] JSON 파싱 실패")
|
||||
pass
|
||||
|
||||
# 크롤링에서 가져온 facility_info로 facilities 설정
|
||||
print(f"[parse_marketing_analysis] 크롤링 facility_info 원본: {facility_info}")
|
||||
if facility_info:
|
||||
# 쉼표로 구분된 편의시설 문자열을 리스트로 변환
|
||||
facilities = [f.strip() for f in facility_info.split(",") if f.strip()]
|
||||
print(f"[parse_marketing_analysis] facility_info 파싱 결과: {facilities}")
|
||||
else:
|
||||
facilities = ["등록된 정보 없음"]
|
||||
print("[parse_marketing_analysis] facility_info 없음 - '등록된 정보 없음' 설정")
|
||||
|
||||
# 리포트 내용을 500자로 요약
|
||||
if report:
|
||||
report = await self.summarize_marketing(report)
|
||||
|
||||
print(f"[parse_marketing_analysis] 최종 facilities: {facilities}")
|
||||
return {"report": report, "tags": tags, "facilities": facilities}
|
||||
|
|
|
|||
|
|
@ -1,24 +1,24 @@
|
|||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from config import cors_settings
|
||||
|
||||
# sys.path.append(
|
||||
# os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
# ) # root 경로 추가
|
||||
|
||||
|
||||
class CustomCORSMiddleware:
|
||||
def __init__(self, app: FastAPI):
|
||||
self.app = app
|
||||
|
||||
def configure_cors(self):
|
||||
self.app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=cors_settings.CORS_ALLOW_ORIGINS,
|
||||
allow_credentials=cors_settings.CORS_ALLOW_CREDENTIALS,
|
||||
allow_methods=cors_settings.CORS_ALLOW_METHODS,
|
||||
allow_headers=cors_settings.CORS_ALLOW_HEADERS,
|
||||
expose_headers=cors_settings.CORS_EXPOSE_HEADERS,
|
||||
max_age=cors_settings.CORS_MAX_AGE,
|
||||
)
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from config import cors_settings
|
||||
|
||||
# sys.path.append(
|
||||
# os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
# ) # root 경로 추가
|
||||
|
||||
|
||||
class CustomCORSMiddleware:
|
||||
def __init__(self, app: FastAPI):
|
||||
self.app = app
|
||||
|
||||
def configure_cors(self):
|
||||
self.app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=cors_settings.CORS_ALLOW_ORIGINS,
|
||||
allow_credentials=cors_settings.CORS_ALLOW_CREDENTIALS,
|
||||
allow_methods=cors_settings.CORS_ALLOW_METHODS,
|
||||
allow_headers=cors_settings.CORS_ALLOW_HEADERS,
|
||||
expose_headers=cors_settings.CORS_EXPOSE_HEADERS,
|
||||
max_age=cors_settings.CORS_MAX_AGE,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,437 +1,473 @@
|
|||
"""
|
||||
Creatomate API 클라이언트 모듈
|
||||
|
||||
API 문서: https://creatomate.com/docs/api
|
||||
|
||||
## 사용법
|
||||
```python
|
||||
from app.utils.creatomate import CreatomateService
|
||||
|
||||
# config에서 자동으로 API 키를 가져옴
|
||||
creatomate = CreatomateService()
|
||||
|
||||
# 또는 명시적으로 API 키 전달
|
||||
creatomate = CreatomateService(api_key="your_api_key")
|
||||
|
||||
# 템플릿 목록 조회 (비동기)
|
||||
templates = await creatomate.get_all_templates_data()
|
||||
|
||||
# 특정 템플릿 조회 (비동기)
|
||||
template = await creatomate.get_one_template_data(template_id)
|
||||
|
||||
# 영상 렌더링 요청 (비동기)
|
||||
response = await creatomate.make_creatomate_call(template_id, modifications)
|
||||
```
|
||||
|
||||
## 성능 최적화
|
||||
- 템플릿 캐싱: 템플릿 데이터는 메모리에 캐싱되어 반복 조회 시 API 호출을 줄입니다.
|
||||
- HTTP 클라이언트 재사용: 모듈 레벨의 공유 클라이언트로 커넥션 풀을 재사용합니다.
|
||||
- 캐시 만료: 기본 5분 후 자동 만료 (CACHE_TTL_SECONDS로 조정 가능)
|
||||
"""
|
||||
|
||||
import copy
|
||||
import time
|
||||
from typing import Literal
|
||||
|
||||
import httpx
|
||||
|
||||
from config import apikey_settings, creatomate_settings
|
||||
|
||||
|
||||
# Orientation 타입 정의
|
||||
OrientationType = Literal["horizontal", "vertical"]
|
||||
|
||||
# =============================================================================
|
||||
# 모듈 레벨 캐시 및 HTTP 클라이언트 (싱글톤 패턴)
|
||||
# =============================================================================
|
||||
|
||||
# 템플릿 캐시: {template_id: {"data": dict, "cached_at": float}}
|
||||
_template_cache: dict[str, dict] = {}
|
||||
|
||||
# 캐시 TTL (초) - 기본 5분
|
||||
CACHE_TTL_SECONDS = 300
|
||||
|
||||
# 모듈 레벨 공유 HTTP 클라이언트 (커넥션 풀 재사용)
|
||||
_shared_client: httpx.AsyncClient | None = None
|
||||
|
||||
|
||||
async def get_shared_client() -> httpx.AsyncClient:
|
||||
"""공유 HTTP 클라이언트를 반환합니다. 없으면 생성합니다."""
|
||||
global _shared_client
|
||||
if _shared_client is None or _shared_client.is_closed:
|
||||
_shared_client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(60.0, connect=10.0),
|
||||
limits=httpx.Limits(max_keepalive_connections=10, max_connections=20),
|
||||
)
|
||||
return _shared_client
|
||||
|
||||
|
||||
async def close_shared_client() -> None:
|
||||
"""공유 HTTP 클라이언트를 닫습니다. 앱 종료 시 호출하세요."""
|
||||
global _shared_client
|
||||
if _shared_client is not None and not _shared_client.is_closed:
|
||||
await _shared_client.aclose()
|
||||
_shared_client = None
|
||||
print("[CreatomateService] Shared HTTP client closed")
|
||||
|
||||
|
||||
def clear_template_cache() -> None:
|
||||
"""템플릿 캐시를 전체 삭제합니다."""
|
||||
global _template_cache
|
||||
_template_cache.clear()
|
||||
print("[CreatomateService] Template cache cleared")
|
||||
|
||||
|
||||
def _is_cache_valid(cached_at: float) -> bool:
|
||||
"""캐시가 유효한지 확인합니다."""
|
||||
return (time.time() - cached_at) < CACHE_TTL_SECONDS
|
||||
|
||||
|
||||
class CreatomateService:
|
||||
"""Creatomate API를 통한 영상 생성 서비스
|
||||
|
||||
모든 HTTP 호출 메서드는 비동기(async)로 구현되어 있습니다.
|
||||
"""
|
||||
|
||||
BASE_URL = "https://api.creatomate.com"
|
||||
|
||||
# 템플릿 설정 (config에서 가져옴)
|
||||
TEMPLATE_CONFIG = {
|
||||
"horizontal": {
|
||||
"template_id": creatomate_settings.TEMPLATE_ID_HORIZONTAL,
|
||||
"duration": creatomate_settings.TEMPLATE_DURATION_HORIZONTAL,
|
||||
},
|
||||
"vertical": {
|
||||
"template_id": creatomate_settings.TEMPLATE_ID_VERTICAL,
|
||||
"duration": creatomate_settings.TEMPLATE_DURATION_VERTICAL,
|
||||
},
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str | None = None,
|
||||
orientation: OrientationType = "vertical",
|
||||
target_duration: float | None = None,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
api_key: Creatomate API 키 (Bearer token으로 사용)
|
||||
None일 경우 config에서 자동으로 가져옴
|
||||
orientation: 영상 방향 ("horizontal" 또는 "vertical", 기본값: "vertical")
|
||||
target_duration: 목표 영상 길이 (초)
|
||||
None일 경우 orientation에 해당하는 기본값 사용
|
||||
"""
|
||||
self.api_key = api_key or apikey_settings.CREATOMATE_API_KEY
|
||||
self.orientation = orientation
|
||||
|
||||
# orientation에 따른 템플릿 설정 가져오기
|
||||
config = self.TEMPLATE_CONFIG.get(
|
||||
orientation, self.TEMPLATE_CONFIG["vertical"]
|
||||
)
|
||||
self.template_id = config["template_id"]
|
||||
self.target_duration = (
|
||||
target_duration if target_duration is not None else config["duration"]
|
||||
)
|
||||
|
||||
self.headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
}
|
||||
|
||||
async def get_all_templates_data(self) -> dict:
|
||||
"""모든 템플릿 정보를 조회합니다."""
|
||||
url = f"{self.BASE_URL}/v1/templates"
|
||||
client = await get_shared_client()
|
||||
response = await client.get(url, headers=self.headers, timeout=30.0)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def get_one_template_data(
|
||||
self,
|
||||
template_id: str,
|
||||
use_cache: bool = True,
|
||||
) -> dict:
|
||||
"""특정 템플릿 ID로 템플릿 정보를 조회합니다.
|
||||
|
||||
Args:
|
||||
template_id: 조회할 템플릿 ID
|
||||
use_cache: 캐시 사용 여부 (기본: True)
|
||||
|
||||
Returns:
|
||||
템플릿 데이터 (deep copy)
|
||||
"""
|
||||
global _template_cache
|
||||
|
||||
# 캐시 확인
|
||||
if use_cache and template_id in _template_cache:
|
||||
cached = _template_cache[template_id]
|
||||
if _is_cache_valid(cached["cached_at"]):
|
||||
print(f"[CreatomateService] Cache HIT - {template_id}")
|
||||
return copy.deepcopy(cached["data"])
|
||||
else:
|
||||
# 만료된 캐시 삭제
|
||||
del _template_cache[template_id]
|
||||
print(f"[CreatomateService] Cache EXPIRED - {template_id}")
|
||||
|
||||
# API 호출
|
||||
url = f"{self.BASE_URL}/v1/templates/{template_id}"
|
||||
client = await get_shared_client()
|
||||
response = await client.get(url, headers=self.headers, timeout=30.0)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
# 캐시 저장
|
||||
_template_cache[template_id] = {
|
||||
"data": data,
|
||||
"cached_at": time.time(),
|
||||
}
|
||||
print(f"[CreatomateService] Cache MISS - {template_id} (cached)")
|
||||
|
||||
return copy.deepcopy(data)
|
||||
|
||||
# 하위 호환성을 위한 별칭 (deprecated)
|
||||
async def get_one_template_data_async(self, template_id: str) -> dict:
|
||||
"""특정 템플릿 ID로 템플릿 정보를 조회합니다.
|
||||
|
||||
Deprecated: get_one_template_data()를 사용하세요.
|
||||
"""
|
||||
return await self.get_one_template_data(template_id)
|
||||
|
||||
def parse_template_component_name(self, template_source: list) -> dict:
|
||||
"""템플릿 정보를 파싱하여 리소스 이름을 추출합니다."""
|
||||
|
||||
def recursive_parse_component(element: dict) -> dict:
|
||||
if "name" in element:
|
||||
result_element_name_type = {element["name"]: element["type"]}
|
||||
else:
|
||||
result_element_name_type = {}
|
||||
|
||||
if element["type"] == "composition":
|
||||
minor_component_list = [
|
||||
recursive_parse_component(minor) for minor in element["elements"]
|
||||
]
|
||||
# WARNING: Same name component should shroud other component
|
||||
for minor_component in minor_component_list:
|
||||
result_element_name_type.update(minor_component)
|
||||
|
||||
return result_element_name_type
|
||||
|
||||
result = {}
|
||||
for result_element_dict in [
|
||||
recursive_parse_component(component) for component in template_source
|
||||
]:
|
||||
result.update(result_element_dict)
|
||||
|
||||
return result
|
||||
|
||||
async def template_connect_resource_blackbox(
|
||||
self,
|
||||
template_id: str,
|
||||
image_url_list: list[str],
|
||||
lyric: str,
|
||||
music_url: str,
|
||||
) -> dict:
|
||||
"""템플릿 정보와 이미지/가사/음악 리소스를 매핑합니다.
|
||||
|
||||
Note:
|
||||
- 이미지는 순차적으로 집어넣기
|
||||
- 가사는 개행마다 한 텍스트 삽입
|
||||
- Template에 audio-music 항목이 있어야 함
|
||||
"""
|
||||
template_data = await self.get_one_template_data(template_id)
|
||||
template_component_data = self.parse_template_component_name(
|
||||
template_data["source"]["elements"]
|
||||
)
|
||||
|
||||
lyric = lyric.replace("\r", "")
|
||||
lyric_splited = lyric.split("\n")
|
||||
modifications = {}
|
||||
|
||||
for idx, (template_component_name, template_type) in enumerate(
|
||||
template_component_data.items()
|
||||
):
|
||||
match template_type:
|
||||
case "image":
|
||||
modifications[template_component_name] = image_url_list[
|
||||
idx % len(image_url_list)
|
||||
]
|
||||
case "text":
|
||||
modifications[template_component_name] = lyric_splited[
|
||||
idx % len(lyric_splited)
|
||||
]
|
||||
|
||||
modifications["audio-music"] = music_url
|
||||
|
||||
return modifications
|
||||
|
||||
def elements_connect_resource_blackbox(
|
||||
self,
|
||||
elements: list,
|
||||
image_url_list: list[str],
|
||||
lyric: str,
|
||||
music_url: str,
|
||||
) -> dict:
|
||||
"""elements 정보와 이미지/가사/음악 리소스를 매핑합니다."""
|
||||
template_component_data = self.parse_template_component_name(elements)
|
||||
|
||||
lyric = lyric.replace("\r", "")
|
||||
lyric_splited = lyric.split("\n")
|
||||
modifications = {}
|
||||
|
||||
for idx, (template_component_name, template_type) in enumerate(
|
||||
template_component_data.items()
|
||||
):
|
||||
match template_type:
|
||||
case "image":
|
||||
modifications[template_component_name] = image_url_list[
|
||||
idx % len(image_url_list)
|
||||
]
|
||||
case "text":
|
||||
modifications[template_component_name] = lyric_splited[
|
||||
idx % len(lyric_splited)
|
||||
]
|
||||
|
||||
modifications["audio-music"] = music_url
|
||||
|
||||
return modifications
|
||||
|
||||
def modify_element(self, elements: list, modification: dict) -> list:
|
||||
"""elements의 source를 modification에 따라 수정합니다."""
|
||||
|
||||
def recursive_modify(element: dict) -> None:
|
||||
if "name" in element:
|
||||
match element["type"]:
|
||||
case "image":
|
||||
element["source"] = modification[element["name"]]
|
||||
case "audio":
|
||||
element["source"] = modification.get(element["name"], "")
|
||||
case "video":
|
||||
element["source"] = modification[element["name"]]
|
||||
case "text":
|
||||
element["source"] = modification.get(element["name"], "")
|
||||
case "composition":
|
||||
for minor in element["elements"]:
|
||||
recursive_modify(minor)
|
||||
|
||||
for minor in elements:
|
||||
recursive_modify(minor)
|
||||
|
||||
return elements
|
||||
|
||||
async def make_creatomate_call(
|
||||
self, template_id: str, modifications: dict
|
||||
) -> dict:
|
||||
"""Creatomate에 렌더링 요청을 보냅니다.
|
||||
|
||||
Note:
|
||||
response에 요청 정보가 있으니 폴링 필요
|
||||
"""
|
||||
url = f"{self.BASE_URL}/v2/renders"
|
||||
data = {
|
||||
"template_id": template_id,
|
||||
"modifications": modifications,
|
||||
}
|
||||
client = await get_shared_client()
|
||||
response = await client.post(
|
||||
url, json=data, headers=self.headers, timeout=60.0
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def make_creatomate_custom_call(self, source: dict) -> dict:
|
||||
"""템플릿 없이 Creatomate에 커스텀 렌더링 요청을 보냅니다.
|
||||
|
||||
Note:
|
||||
response에 요청 정보가 있으니 폴링 필요
|
||||
"""
|
||||
url = f"{self.BASE_URL}/v2/renders"
|
||||
client = await get_shared_client()
|
||||
response = await client.post(
|
||||
url, json=source, headers=self.headers, timeout=60.0
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
# 하위 호환성을 위한 별칭 (deprecated)
|
||||
async def make_creatomate_custom_call_async(self, source: dict) -> dict:
|
||||
"""템플릿 없이 Creatomate에 커스텀 렌더링 요청을 보냅니다.
|
||||
|
||||
Deprecated: make_creatomate_custom_call()을 사용하세요.
|
||||
"""
|
||||
return await self.make_creatomate_custom_call(source)
|
||||
|
||||
async def get_render_status(self, render_id: str) -> dict:
|
||||
"""렌더링 작업의 상태를 조회합니다.
|
||||
|
||||
Args:
|
||||
render_id: Creatomate 렌더 ID
|
||||
|
||||
Returns:
|
||||
렌더링 상태 정보
|
||||
|
||||
Note:
|
||||
상태 값:
|
||||
- planned: 예약됨
|
||||
- waiting: 대기 중
|
||||
- transcribing: 트랜스크립션 중
|
||||
- rendering: 렌더링 중
|
||||
- succeeded: 성공
|
||||
- failed: 실패
|
||||
"""
|
||||
url = f"{self.BASE_URL}/v1/renders/{render_id}"
|
||||
client = await get_shared_client()
|
||||
response = await client.get(url, headers=self.headers, timeout=30.0)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
# 하위 호환성을 위한 별칭 (deprecated)
|
||||
async def get_render_status_async(self, render_id: str) -> dict:
|
||||
"""렌더링 작업의 상태를 조회합니다.
|
||||
|
||||
Deprecated: get_render_status()를 사용하세요.
|
||||
"""
|
||||
return await self.get_render_status(render_id)
|
||||
|
||||
def calc_scene_duration(self, template: dict) -> float:
|
||||
"""템플릿의 전체 장면 duration을 계산합니다."""
|
||||
total_template_duration = 0.0
|
||||
|
||||
for elem in template["source"]["elements"]:
|
||||
try:
|
||||
if elem["type"] == "audio":
|
||||
continue
|
||||
total_template_duration += elem["duration"]
|
||||
if "animations" not in elem:
|
||||
continue
|
||||
for animation in elem["animations"]:
|
||||
assert animation["time"] == 0 # 0이 아닌 경우 확인 필요
|
||||
if animation["transition"]:
|
||||
total_template_duration -= animation["duration"]
|
||||
except Exception as e:
|
||||
print(f"[calc_scene_duration] Error processing element: {elem}, {e}")
|
||||
|
||||
return total_template_duration
|
||||
|
||||
def extend_template_duration(self, template: dict, target_duration: float) -> dict:
|
||||
"""템플릿의 duration을 target_duration으로 확장합니다."""
|
||||
template["duration"] = target_duration
|
||||
total_template_duration = self.calc_scene_duration(template)
|
||||
extend_rate = target_duration / total_template_duration
|
||||
new_template = copy.deepcopy(template)
|
||||
|
||||
for elem in new_template["source"]["elements"]:
|
||||
try:
|
||||
if elem["type"] == "audio":
|
||||
continue
|
||||
elem["duration"] = elem["duration"] * extend_rate
|
||||
if "animations" not in elem:
|
||||
continue
|
||||
for animation in elem["animations"]:
|
||||
assert animation["time"] == 0 # 0이 아닌 경우 확인 필요
|
||||
animation["duration"] = animation["duration"] * extend_rate
|
||||
except Exception as e:
|
||||
print(
|
||||
f"[extend_template_duration] Error processing element: {elem}, {e}"
|
||||
)
|
||||
|
||||
return new_template
|
||||
"""
|
||||
Creatomate API 클라이언트 모듈
|
||||
|
||||
API 문서: https://creatomate.com/docs/api
|
||||
|
||||
## 사용법
|
||||
```python
|
||||
from app.utils.creatomate import CreatomateService
|
||||
|
||||
# config에서 자동으로 API 키를 가져옴
|
||||
creatomate = CreatomateService()
|
||||
|
||||
# 또는 명시적으로 API 키 전달
|
||||
creatomate = CreatomateService(api_key="your_api_key")
|
||||
|
||||
# 템플릿 목록 조회 (비동기)
|
||||
templates = await creatomate.get_all_templates_data()
|
||||
|
||||
# 특정 템플릿 조회 (비동기)
|
||||
template = await creatomate.get_one_template_data(template_id)
|
||||
|
||||
# 영상 렌더링 요청 (비동기)
|
||||
response = await creatomate.make_creatomate_call(template_id, modifications)
|
||||
```
|
||||
|
||||
## 성능 최적화
|
||||
- 템플릿 캐싱: 템플릿 데이터는 메모리에 캐싱되어 반복 조회 시 API 호출을 줄입니다.
|
||||
- HTTP 클라이언트 재사용: 모듈 레벨의 공유 클라이언트로 커넥션 풀을 재사용합니다.
|
||||
- 캐시 만료: 기본 5분 후 자동 만료 (CACHE_TTL_SECONDS로 조정 가능)
|
||||
"""
|
||||
|
||||
import copy
|
||||
import logging
|
||||
import time
|
||||
from typing import Literal
|
||||
|
||||
import httpx
|
||||
|
||||
from config import apikey_settings, creatomate_settings
|
||||
|
||||
# 로거 설정
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Orientation 타입 정의
|
||||
OrientationType = Literal["horizontal", "vertical"]
|
||||
|
||||
# =============================================================================
|
||||
# 모듈 레벨 캐시 및 HTTP 클라이언트 (싱글톤 패턴)
|
||||
# =============================================================================
|
||||
|
||||
# 템플릿 캐시: {template_id: {"data": dict, "cached_at": float}}
|
||||
_template_cache: dict[str, dict] = {}
|
||||
|
||||
# 캐시 TTL (초) - 기본 5분
|
||||
CACHE_TTL_SECONDS = 300
|
||||
|
||||
# 모듈 레벨 공유 HTTP 클라이언트 (커넥션 풀 재사용)
|
||||
_shared_client: httpx.AsyncClient | None = None
|
||||
|
||||
|
||||
async def get_shared_client() -> httpx.AsyncClient:
|
||||
"""공유 HTTP 클라이언트를 반환합니다. 없으면 생성합니다."""
|
||||
global _shared_client
|
||||
if _shared_client is None or _shared_client.is_closed:
|
||||
_shared_client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(60.0, connect=10.0),
|
||||
limits=httpx.Limits(max_keepalive_connections=10, max_connections=20),
|
||||
)
|
||||
return _shared_client
|
||||
|
||||
|
||||
async def close_shared_client() -> None:
|
||||
"""공유 HTTP 클라이언트를 닫습니다. 앱 종료 시 호출하세요."""
|
||||
global _shared_client
|
||||
if _shared_client is not None and not _shared_client.is_closed:
|
||||
await _shared_client.aclose()
|
||||
_shared_client = None
|
||||
print("[CreatomateService] Shared HTTP client closed")
|
||||
|
||||
|
||||
def clear_template_cache() -> None:
|
||||
"""템플릿 캐시를 전체 삭제합니다."""
|
||||
global _template_cache
|
||||
_template_cache.clear()
|
||||
print("[CreatomateService] Template cache cleared")
|
||||
|
||||
|
||||
def _is_cache_valid(cached_at: float) -> bool:
|
||||
"""캐시가 유효한지 확인합니다."""
|
||||
return (time.time() - cached_at) < CACHE_TTL_SECONDS
|
||||
|
||||
|
||||
class CreatomateService:
|
||||
"""Creatomate API를 통한 영상 생성 서비스
|
||||
|
||||
모든 HTTP 호출 메서드는 비동기(async)로 구현되어 있습니다.
|
||||
"""
|
||||
|
||||
BASE_URL = "https://api.creatomate.com"
|
||||
|
||||
# 템플릿 설정 (config에서 가져옴)
|
||||
TEMPLATE_CONFIG = {
|
||||
"horizontal": {
|
||||
"template_id": creatomate_settings.TEMPLATE_ID_HORIZONTAL,
|
||||
"duration": creatomate_settings.TEMPLATE_DURATION_HORIZONTAL,
|
||||
},
|
||||
"vertical": {
|
||||
"template_id": creatomate_settings.TEMPLATE_ID_VERTICAL,
|
||||
"duration": creatomate_settings.TEMPLATE_DURATION_VERTICAL,
|
||||
},
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str | None = None,
|
||||
orientation: OrientationType = "vertical",
|
||||
target_duration: float | None = None,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
api_key: Creatomate API 키 (Bearer token으로 사용)
|
||||
None일 경우 config에서 자동으로 가져옴
|
||||
orientation: 영상 방향 ("horizontal" 또는 "vertical", 기본값: "vertical")
|
||||
target_duration: 목표 영상 길이 (초)
|
||||
None일 경우 orientation에 해당하는 기본값 사용
|
||||
"""
|
||||
self.api_key = api_key or apikey_settings.CREATOMATE_API_KEY
|
||||
self.orientation = orientation
|
||||
|
||||
# orientation에 따른 템플릿 설정 가져오기
|
||||
config = self.TEMPLATE_CONFIG.get(
|
||||
orientation, self.TEMPLATE_CONFIG["vertical"]
|
||||
)
|
||||
self.template_id = config["template_id"]
|
||||
self.target_duration = (
|
||||
target_duration if target_duration is not None else config["duration"]
|
||||
)
|
||||
|
||||
self.headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
}
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
url: str,
|
||||
timeout: float = 30.0,
|
||||
**kwargs,
|
||||
) -> httpx.Response:
|
||||
"""HTTP 요청을 수행합니다.
|
||||
|
||||
Args:
|
||||
method: HTTP 메서드 ("GET", "POST", etc.)
|
||||
url: 요청 URL
|
||||
timeout: 요청 타임아웃 (초)
|
||||
**kwargs: httpx 요청에 전달할 추가 인자
|
||||
|
||||
Returns:
|
||||
httpx.Response: 응답 객체
|
||||
|
||||
Raises:
|
||||
httpx.HTTPError: 요청 실패 시
|
||||
"""
|
||||
logger.info(f"[Creatomate] {method} {url}")
|
||||
print(f"[Creatomate] {method} {url}")
|
||||
|
||||
client = await get_shared_client()
|
||||
|
||||
if method.upper() == "GET":
|
||||
response = await client.get(
|
||||
url, headers=self.headers, timeout=timeout, **kwargs
|
||||
)
|
||||
elif method.upper() == "POST":
|
||||
response = await client.post(
|
||||
url, headers=self.headers, timeout=timeout, **kwargs
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unsupported HTTP method: {method}")
|
||||
|
||||
logger.info(f"[Creatomate] Response - Status: {response.status_code}")
|
||||
print(f"[Creatomate] Response - Status: {response.status_code}")
|
||||
return response
|
||||
|
||||
async def get_all_templates_data(self) -> dict:
|
||||
"""모든 템플릿 정보를 조회합니다."""
|
||||
url = f"{self.BASE_URL}/v1/templates"
|
||||
response = await self._request("GET", url, timeout=30.0)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def get_one_template_data(
|
||||
self,
|
||||
template_id: str,
|
||||
use_cache: bool = True,
|
||||
) -> dict:
|
||||
"""특정 템플릿 ID로 템플릿 정보를 조회합니다.
|
||||
|
||||
Args:
|
||||
template_id: 조회할 템플릿 ID
|
||||
use_cache: 캐시 사용 여부 (기본: True)
|
||||
|
||||
Returns:
|
||||
템플릿 데이터 (deep copy)
|
||||
"""
|
||||
global _template_cache
|
||||
|
||||
# 캐시 확인
|
||||
if use_cache and template_id in _template_cache:
|
||||
cached = _template_cache[template_id]
|
||||
if _is_cache_valid(cached["cached_at"]):
|
||||
print(f"[CreatomateService] Cache HIT - {template_id}")
|
||||
return copy.deepcopy(cached["data"])
|
||||
else:
|
||||
# 만료된 캐시 삭제
|
||||
del _template_cache[template_id]
|
||||
print(f"[CreatomateService] Cache EXPIRED - {template_id}")
|
||||
|
||||
# API 호출
|
||||
url = f"{self.BASE_URL}/v1/templates/{template_id}"
|
||||
response = await self._request("GET", url, timeout=30.0)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
# 캐시 저장
|
||||
_template_cache[template_id] = {
|
||||
"data": data,
|
||||
"cached_at": time.time(),
|
||||
}
|
||||
print(f"[CreatomateService] Cache MISS - {template_id} (cached)")
|
||||
|
||||
return copy.deepcopy(data)
|
||||
|
||||
# 하위 호환성을 위한 별칭 (deprecated)
|
||||
async def get_one_template_data_async(self, template_id: str) -> dict:
|
||||
"""특정 템플릿 ID로 템플릿 정보를 조회합니다.
|
||||
|
||||
Deprecated: get_one_template_data()를 사용하세요.
|
||||
"""
|
||||
return await self.get_one_template_data(template_id)
|
||||
|
||||
def parse_template_component_name(self, template_source: list) -> dict:
|
||||
"""템플릿 정보를 파싱하여 리소스 이름을 추출합니다."""
|
||||
|
||||
def recursive_parse_component(element: dict) -> dict:
|
||||
if "name" in element:
|
||||
result_element_name_type = {element["name"]: element["type"]}
|
||||
else:
|
||||
result_element_name_type = {}
|
||||
|
||||
if element["type"] == "composition":
|
||||
minor_component_list = [
|
||||
recursive_parse_component(minor) for minor in element["elements"]
|
||||
]
|
||||
# WARNING: Same name component should shroud other component
|
||||
for minor_component in minor_component_list:
|
||||
result_element_name_type.update(minor_component)
|
||||
|
||||
return result_element_name_type
|
||||
|
||||
result = {}
|
||||
for result_element_dict in [
|
||||
recursive_parse_component(component) for component in template_source
|
||||
]:
|
||||
result.update(result_element_dict)
|
||||
|
||||
return result
|
||||
|
||||
async def template_connect_resource_blackbox(
|
||||
self,
|
||||
template_id: str,
|
||||
image_url_list: list[str],
|
||||
lyric: str,
|
||||
music_url: str,
|
||||
) -> dict:
|
||||
"""템플릿 정보와 이미지/가사/음악 리소스를 매핑합니다.
|
||||
|
||||
Note:
|
||||
- 이미지는 순차적으로 집어넣기
|
||||
- 가사는 개행마다 한 텍스트 삽입
|
||||
- Template에 audio-music 항목이 있어야 함
|
||||
"""
|
||||
template_data = await self.get_one_template_data(template_id)
|
||||
template_component_data = self.parse_template_component_name(
|
||||
template_data["source"]["elements"]
|
||||
)
|
||||
|
||||
lyric = lyric.replace("\r", "")
|
||||
lyric_splited = lyric.split("\n")
|
||||
modifications = {}
|
||||
|
||||
for idx, (template_component_name, template_type) in enumerate(
|
||||
template_component_data.items()
|
||||
):
|
||||
match template_type:
|
||||
case "image":
|
||||
modifications[template_component_name] = image_url_list[
|
||||
idx % len(image_url_list)
|
||||
]
|
||||
case "text":
|
||||
modifications[template_component_name] = lyric_splited[
|
||||
idx % len(lyric_splited)
|
||||
]
|
||||
|
||||
modifications["audio-music"] = music_url
|
||||
|
||||
return modifications
|
||||
|
||||
def elements_connect_resource_blackbox(
|
||||
self,
|
||||
elements: list,
|
||||
image_url_list: list[str],
|
||||
lyric: str,
|
||||
music_url: str,
|
||||
) -> dict:
|
||||
"""elements 정보와 이미지/가사/음악 리소스를 매핑합니다."""
|
||||
template_component_data = self.parse_template_component_name(elements)
|
||||
|
||||
lyric = lyric.replace("\r", "")
|
||||
lyric_splited = lyric.split("\n")
|
||||
modifications = {}
|
||||
|
||||
for idx, (template_component_name, template_type) in enumerate(
|
||||
template_component_data.items()
|
||||
):
|
||||
match template_type:
|
||||
case "image":
|
||||
modifications[template_component_name] = image_url_list[
|
||||
idx % len(image_url_list)
|
||||
]
|
||||
case "text":
|
||||
modifications[template_component_name] = lyric_splited[
|
||||
idx % len(lyric_splited)
|
||||
]
|
||||
|
||||
modifications["audio-music"] = music_url
|
||||
|
||||
return modifications
|
||||
|
||||
def modify_element(self, elements: list, modification: dict) -> list:
|
||||
"""elements의 source를 modification에 따라 수정합니다."""
|
||||
|
||||
def recursive_modify(element: dict) -> None:
|
||||
if "name" in element:
|
||||
match element["type"]:
|
||||
case "image":
|
||||
element["source"] = modification[element["name"]]
|
||||
case "audio":
|
||||
element["source"] = modification.get(element["name"], "")
|
||||
case "video":
|
||||
element["source"] = modification[element["name"]]
|
||||
case "text":
|
||||
element["source"] = modification.get(element["name"], "")
|
||||
case "composition":
|
||||
for minor in element["elements"]:
|
||||
recursive_modify(minor)
|
||||
|
||||
for minor in elements:
|
||||
recursive_modify(minor)
|
||||
|
||||
return elements
|
||||
|
||||
async def make_creatomate_call(
|
||||
self, template_id: str, modifications: dict
|
||||
) -> dict:
|
||||
"""Creatomate에 렌더링 요청을 보냅니다.
|
||||
|
||||
Note:
|
||||
response에 요청 정보가 있으니 폴링 필요
|
||||
"""
|
||||
url = f"{self.BASE_URL}/v2/renders"
|
||||
data = {
|
||||
"template_id": template_id,
|
||||
"modifications": modifications,
|
||||
}
|
||||
response = await self._request("POST", url, timeout=60.0, json=data)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def make_creatomate_custom_call(self, source: dict) -> dict:
|
||||
"""템플릿 없이 Creatomate에 커스텀 렌더링 요청을 보냅니다.
|
||||
|
||||
Note:
|
||||
response에 요청 정보가 있으니 폴링 필요
|
||||
"""
|
||||
url = f"{self.BASE_URL}/v2/renders"
|
||||
response = await self._request("POST", url, timeout=60.0, json=source)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
# 하위 호환성을 위한 별칭 (deprecated)
|
||||
async def make_creatomate_custom_call_async(self, source: dict) -> dict:
|
||||
"""템플릿 없이 Creatomate에 커스텀 렌더링 요청을 보냅니다.
|
||||
|
||||
Deprecated: make_creatomate_custom_call()을 사용하세요.
|
||||
"""
|
||||
return await self.make_creatomate_custom_call(source)
|
||||
|
||||
async def get_render_status(self, render_id: str) -> dict:
|
||||
"""렌더링 작업의 상태를 조회합니다.
|
||||
|
||||
Args:
|
||||
render_id: Creatomate 렌더 ID
|
||||
|
||||
Returns:
|
||||
렌더링 상태 정보
|
||||
|
||||
Note:
|
||||
상태 값:
|
||||
- planned: 예약됨
|
||||
- waiting: 대기 중
|
||||
- transcribing: 트랜스크립션 중
|
||||
- rendering: 렌더링 중
|
||||
- succeeded: 성공
|
||||
- failed: 실패
|
||||
"""
|
||||
url = f"{self.BASE_URL}/v1/renders/{render_id}"
|
||||
response = await self._request("GET", url, timeout=30.0)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
# 하위 호환성을 위한 별칭 (deprecated)
|
||||
async def get_render_status_async(self, render_id: str) -> dict:
|
||||
"""렌더링 작업의 상태를 조회합니다.
|
||||
|
||||
Deprecated: get_render_status()를 사용하세요.
|
||||
"""
|
||||
return await self.get_render_status(render_id)
|
||||
|
||||
def calc_scene_duration(self, template: dict) -> float:
|
||||
"""템플릿의 전체 장면 duration을 계산합니다."""
|
||||
total_template_duration = 0.0
|
||||
|
||||
for elem in template["source"]["elements"]:
|
||||
try:
|
||||
if elem["type"] == "audio":
|
||||
continue
|
||||
total_template_duration += elem["duration"]
|
||||
if "animations" not in elem:
|
||||
continue
|
||||
for animation in elem["animations"]:
|
||||
assert animation["time"] == 0 # 0이 아닌 경우 확인 필요
|
||||
if animation["transition"]:
|
||||
total_template_duration -= animation["duration"]
|
||||
except Exception as e:
|
||||
print(f"[calc_scene_duration] Error processing element: {elem}, {e}")
|
||||
|
||||
return total_template_duration
|
||||
|
||||
def extend_template_duration(self, template: dict, target_duration: float) -> dict:
|
||||
"""템플릿의 duration을 target_duration으로 확장합니다."""
|
||||
template["duration"] = target_duration
|
||||
total_template_duration = self.calc_scene_duration(template)
|
||||
extend_rate = target_duration / total_template_duration
|
||||
new_template = copy.deepcopy(template)
|
||||
|
||||
for elem in new_template["source"]["elements"]:
|
||||
try:
|
||||
if elem["type"] == "audio":
|
||||
continue
|
||||
elem["duration"] = elem["duration"] * extend_rate
|
||||
if "animations" not in elem:
|
||||
continue
|
||||
for animation in elem["animations"]:
|
||||
assert animation["time"] == 0 # 0이 아닌 경우 확인 필요
|
||||
animation["duration"] = animation["duration"] * extend_rate
|
||||
except Exception as e:
|
||||
print(
|
||||
f"[extend_template_duration] Error processing element: {elem}, {e}"
|
||||
)
|
||||
|
||||
return new_template
|
||||
|
|
|
|||
|
|
@ -0,0 +1,113 @@
|
|||
import asyncio
|
||||
from playwright.async_api import async_playwright
|
||||
from urllib import parse
|
||||
|
||||
class nvMapPwScraper():
|
||||
# cls vars
|
||||
is_ready = False
|
||||
_playwright = None
|
||||
_browser = None
|
||||
_context = None
|
||||
_win_width = 1280
|
||||
_win_height = 720
|
||||
_max_retry = 30 # place id timeout threshold seconds
|
||||
|
||||
# instance var
|
||||
page = None
|
||||
|
||||
@classmethod
|
||||
def default_context_builder(cls):
|
||||
context_builder_dict = {}
|
||||
context_builder_dict['viewport'] = {
|
||||
'width' : cls._win_width,
|
||||
'height' : cls._win_height
|
||||
}
|
||||
context_builder_dict['screen'] = {
|
||||
'width' : cls._win_width,
|
||||
'height' : cls._win_height
|
||||
}
|
||||
context_builder_dict['user_agent'] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36"
|
||||
context_builder_dict['locale'] = 'ko-KR'
|
||||
context_builder_dict['timezone_id']='Asia/Seoul'
|
||||
|
||||
return context_builder_dict
|
||||
|
||||
@classmethod
|
||||
async def initiate_scraper(cls):
|
||||
if not cls._playwright:
|
||||
cls._playwright = await async_playwright().start()
|
||||
if not cls._browser:
|
||||
cls._browser = await cls._playwright.chromium.launch(headless=True)
|
||||
if not cls._context:
|
||||
cls._context = await cls._browser.new_context(**cls.default_context_builder())
|
||||
cls.is_ready = True
|
||||
|
||||
def __init__(self):
|
||||
if not self.is_ready:
|
||||
raise Exception("nvMapScraper is not initiated")
|
||||
|
||||
async def __aenter__(self):
|
||||
await self.create_page()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
await self.page.close()
|
||||
|
||||
async def create_page(self):
|
||||
self.page = await self._context.new_page()
|
||||
await self.page.add_init_script(
|
||||
'''const defaultGetter = Object.getOwnPropertyDescriptor(
|
||||
Navigator.prototype,
|
||||
"webdriver"
|
||||
).get;
|
||||
defaultGetter.apply(navigator);
|
||||
defaultGetter.toString();
|
||||
Object.defineProperty(Navigator.prototype, "webdriver", {
|
||||
set: undefined,
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
get: new Proxy(defaultGetter, {
|
||||
apply: (target, thisArg, args) => {
|
||||
Reflect.apply(target, thisArg, args);
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
});
|
||||
const patchedGetter = Object.getOwnPropertyDescriptor(
|
||||
Navigator.prototype,
|
||||
"webdriver"
|
||||
).get;
|
||||
patchedGetter.apply(navigator);
|
||||
patchedGetter.toString();''')
|
||||
|
||||
await self.page.set_extra_http_headers({
|
||||
'sec-ch-ua': '\"Not?A_Brand\";v=\"99\", \"Chromium\";v=\"130\"'
|
||||
})
|
||||
await self.page.goto("http://google.com")
|
||||
|
||||
async def goto_url(self, url, wait_until="domcontentloaded", timeout=20000):
|
||||
page = self.page
|
||||
await page.goto(url, wait_until=wait_until, timeout=timeout)
|
||||
|
||||
async def get_place_id_url(self, selected):
|
||||
|
||||
title = selected['title'].replace("<b>", "").replace("</b>", "")
|
||||
address = selected.get('roadAddress', selected['address']).replace("<b>", "").replace("</b>", "")
|
||||
encoded_query = parse.quote(f"{address} {title}")
|
||||
url = f"https://map.naver.com/p/search/{encoded_query}"
|
||||
|
||||
await self.goto_url(url, wait_until="networkidle",timeout = self._max_retry/2*1000)
|
||||
|
||||
if "/place/" in self.page.url:
|
||||
return self.page.url
|
||||
|
||||
url = self.page.url.replace("?","?isCorrectAnswer=true&")
|
||||
await self.goto_url(url, wait_until="networkidle",timeout = self._max_retry/2*1000)
|
||||
|
||||
if "/place/" in self.page.url:
|
||||
return self.page.url
|
||||
|
||||
if (count == self._max_retry / 2):
|
||||
raise Exception("Failed to identify place id. loading timeout")
|
||||
else:
|
||||
raise Exception("Failed to identify place id. item is ambiguous")
|
||||
|
|
@ -1,114 +1,203 @@
|
|||
import json
|
||||
import re
|
||||
|
||||
import aiohttp
|
||||
|
||||
from config import crawler_settings
|
||||
|
||||
|
||||
class GraphQLException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class NvMapScraper:
|
||||
GRAPHQL_URL: str = "https://pcmap-api.place.naver.com/graphql"
|
||||
|
||||
OVERVIEW_QUERY: str = """
|
||||
query getAccommodation($id: String!, $deviceType: String) {
|
||||
business: placeDetail(input: {id: $id, isNx: true, deviceType: $deviceType}) {
|
||||
base {
|
||||
id
|
||||
name
|
||||
category
|
||||
roadAddress
|
||||
address
|
||||
phone
|
||||
virtualPhone
|
||||
microReviews
|
||||
conveniences
|
||||
visitorReviewsTotal
|
||||
}
|
||||
images { images { origin url } }
|
||||
cpImages(source: [ugcImage]) { images { origin url } }
|
||||
}
|
||||
}"""
|
||||
|
||||
DEFAULT_HEADERS: dict = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36",
|
||||
"Referer": "https://map.naver.com/",
|
||||
"Origin": "https://map.naver.com",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
def __init__(self, url: str, cookies: str | None = None):
|
||||
self.url = url
|
||||
self.cookies = (
|
||||
cookies if cookies is not None else crawler_settings.NAVER_COOKIES
|
||||
)
|
||||
self.scrap_type: str | None = None
|
||||
self.rawdata: dict | None = None
|
||||
self.image_link_list: list[str] | None = None
|
||||
self.base_info: dict | None = None
|
||||
|
||||
def _get_request_headers(self) -> dict:
|
||||
headers = self.DEFAULT_HEADERS.copy()
|
||||
if self.cookies:
|
||||
headers["Cookie"] = self.cookies
|
||||
return headers
|
||||
|
||||
def parse_url(self) -> str:
|
||||
place_pattern = r"/place/(\d+)"
|
||||
match = re.search(place_pattern, self.url)
|
||||
if not match:
|
||||
raise GraphQLException("Failed to parse place ID from URL")
|
||||
return match[1]
|
||||
|
||||
async def scrap(self):
|
||||
try:
|
||||
place_id = self.parse_url()
|
||||
data = await self._call_get_accommodation(place_id)
|
||||
self.rawdata = data
|
||||
self.image_link_list = [
|
||||
nv_image["origin"]
|
||||
for nv_image in data["data"]["business"]["images"]["images"]
|
||||
]
|
||||
self.base_info = data["data"]["business"]["base"]
|
||||
self.scrap_type = "GraphQL"
|
||||
|
||||
except GraphQLException:
|
||||
print("fallback")
|
||||
self.scrap_type = "Playwright"
|
||||
pass # 나중에 pw 이용한 crawling으로 fallback 추가
|
||||
|
||||
return
|
||||
|
||||
async def _call_get_accommodation(self, place_id: str) -> dict:
|
||||
payload = {
|
||||
"operationName": "getAccommodation",
|
||||
"variables": {"id": place_id, "deviceType": "pc"},
|
||||
"query": self.OVERVIEW_QUERY,
|
||||
}
|
||||
json_payload = json.dumps(payload)
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
self.GRAPHQL_URL, data=json_payload, headers=self._get_request_headers()
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
return await response.json()
|
||||
else:
|
||||
print("실패 상태 코드:", response.status)
|
||||
raise GraphQLException(
|
||||
f"Request failed with status {response.status}"
|
||||
)
|
||||
|
||||
|
||||
# if __name__ == "__main__":
|
||||
# import asyncio
|
||||
|
||||
# url = "https://map.naver.com/p/search/%EC%8A%A4%ED%85%8C%EC%9D%B4%EB%A8%B8%EB%AD%84/place/1133638931?c=14.70,0,0,0,dh&placePath=/photo?businessCategory=pension&fromPanelNum=2&locale=ko&searchText=%EC%8A%A4%ED%85%8C%EC%9D%B4%EB%A8%B8%EB%AD%84&svcName=map_pcv5×tamp=202512191123&fromPanelNum=2&locale=ko&searchText=%EC%8A%A4%ED%85%8C%EC%9D%B4%EB%A8%B8%EB%AD%84&svcName=map_pcv5×tamp=202512191007&from=map&entry=bmp&filterType=%EC%97%85%EC%B2%B4&businessCategory=pension"
|
||||
# scraper = NvMapScraper(url)
|
||||
# asyncio.run(scraper.scrap())
|
||||
# print(scraper.image_link_list)
|
||||
# print(len(scraper.image_link_list) if scraper.image_link_list else 0)
|
||||
# print(scraper.base_info)
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
import aiohttp
|
||||
import bs4
|
||||
|
||||
from config import crawler_settings
|
||||
|
||||
# 로거 설정
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GraphQLException(Exception):
|
||||
"""GraphQL 요청 실패 시 발생하는 예외"""
|
||||
pass
|
||||
|
||||
|
||||
class CrawlingTimeoutException(Exception):
|
||||
"""크롤링 타임아웃 시 발생하는 예외"""
|
||||
pass
|
||||
|
||||
|
||||
class NvMapScraper:
|
||||
"""네이버 지도 GraphQL API 스크래퍼
|
||||
|
||||
네이버 지도에서 숙소/장소 정보를 크롤링합니다.
|
||||
"""
|
||||
|
||||
GRAPHQL_URL: str = "https://pcmap-api.place.naver.com/graphql"
|
||||
REQUEST_TIMEOUT = 120 # 초
|
||||
|
||||
OVERVIEW_QUERY: str = """
|
||||
query getAccommodation($id: String!, $deviceType: String) {
|
||||
business: placeDetail(input: {id: $id, isNx: true, deviceType: $deviceType}) {
|
||||
base {
|
||||
id
|
||||
name
|
||||
category
|
||||
roadAddress
|
||||
address
|
||||
phone
|
||||
virtualPhone
|
||||
microReviews
|
||||
conveniences
|
||||
visitorReviewsTotal
|
||||
}
|
||||
images { images { origin url } }
|
||||
cpImages(source: [ugcImage]) { images { origin url } }
|
||||
}
|
||||
}"""
|
||||
|
||||
DEFAULT_HEADERS: dict = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36",
|
||||
"Referer": "https://map.naver.com/",
|
||||
"Origin": "https://map.naver.com",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
def __init__(self, url: str, cookies: str | None = None):
|
||||
self.url = url
|
||||
self.cookies = (
|
||||
cookies if cookies is not None else crawler_settings.NAVER_COOKIES
|
||||
)
|
||||
self.scrap_type: str | None = None
|
||||
self.rawdata: dict | None = None
|
||||
self.image_link_list: list[str] | None = None
|
||||
self.base_info: dict | None = None
|
||||
self.facility_info: str | None = None
|
||||
|
||||
def _get_request_headers(self) -> dict:
|
||||
headers = self.DEFAULT_HEADERS.copy()
|
||||
if self.cookies:
|
||||
headers["Cookie"] = self.cookies
|
||||
return headers
|
||||
|
||||
async def parse_url(self) -> str:
|
||||
"""URL에서 place ID를 추출합니다. 단축 URL인 경우 실제 URL로 변환합니다."""
|
||||
place_pattern = r"/place/(\d+)"
|
||||
|
||||
# URL에 place가 없는 경우 단축 URL 처리
|
||||
if "place" not in self.url:
|
||||
if "naver.me" in self.url:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(self.url) as response:
|
||||
self.url = str(response.url)
|
||||
else:
|
||||
raise GraphQLException("This URL does not contain a place ID")
|
||||
|
||||
match = re.search(place_pattern, self.url)
|
||||
if not match:
|
||||
raise GraphQLException("Failed to parse place ID from URL")
|
||||
return match[1]
|
||||
|
||||
async def scrap(self):
|
||||
try:
|
||||
place_id = await self.parse_url()
|
||||
data = await self._call_get_accommodation(place_id)
|
||||
self.rawdata = data
|
||||
fac_data = await self._get_facility_string(place_id)
|
||||
self.rawdata["facilities"] = fac_data
|
||||
self.image_link_list = [
|
||||
nv_image["origin"]
|
||||
for nv_image in data["data"]["business"]["images"]["images"]
|
||||
]
|
||||
self.base_info = data["data"]["business"]["base"]
|
||||
self.facility_info = fac_data
|
||||
self.scrap_type = "GraphQL"
|
||||
|
||||
except GraphQLException:
|
||||
print("fallback")
|
||||
self.scrap_type = "Playwright"
|
||||
pass # 나중에 pw 이용한 crawling으로 fallback 추가
|
||||
|
||||
return
|
||||
|
||||
async def _call_get_accommodation(self, place_id: str) -> dict:
|
||||
"""GraphQL API를 호출하여 숙소 정보를 가져옵니다.
|
||||
|
||||
Args:
|
||||
place_id: 네이버 지도 장소 ID
|
||||
|
||||
Returns:
|
||||
GraphQL 응답 데이터
|
||||
|
||||
Raises:
|
||||
GraphQLException: API 호출 실패 시
|
||||
CrawlingTimeoutException: 타임아웃 발생 시
|
||||
"""
|
||||
payload = {
|
||||
"operationName": "getAccommodation",
|
||||
"variables": {"id": place_id, "deviceType": "pc"},
|
||||
"query": self.OVERVIEW_QUERY,
|
||||
}
|
||||
json_payload = json.dumps(payload)
|
||||
timeout = aiohttp.ClientTimeout(total=self.REQUEST_TIMEOUT)
|
||||
|
||||
try:
|
||||
logger.info(f"[NvMapScraper] Requesting place_id: {place_id}")
|
||||
print(f"[NvMapScraper] Requesting place_id: {place_id}")
|
||||
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.post(
|
||||
self.GRAPHQL_URL,
|
||||
data=json_payload,
|
||||
headers=self._get_request_headers()
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
logger.info(f"[NvMapScraper] SUCCESS - place_id: {place_id}")
|
||||
print(f"[NvMapScraper] SUCCESS - place_id: {place_id}")
|
||||
return await response.json()
|
||||
|
||||
# 실패 상태 코드
|
||||
logger.error(f"[NvMapScraper] Failed with status {response.status} - place_id: {place_id}")
|
||||
print(f"[NvMapScraper] 실패 상태 코드: {response.status}")
|
||||
raise GraphQLException(
|
||||
f"Request failed with status {response.status}"
|
||||
)
|
||||
|
||||
except (TimeoutError, asyncio.TimeoutError):
|
||||
logger.error(f"[NvMapScraper] Timeout - place_id: {place_id}")
|
||||
print(f"[NvMapScraper] Timeout - place_id: {place_id}")
|
||||
raise CrawlingTimeoutException(f"Request timed out after {self.REQUEST_TIMEOUT}s")
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
logger.error(f"[NvMapScraper] Client error: {e}")
|
||||
print(f"[NvMapScraper] Client error: {e}")
|
||||
raise GraphQLException(f"Client error: {e}")
|
||||
|
||||
async def _get_facility_string(self, place_id: str) -> str | None:
|
||||
"""숙소 페이지에서 편의시설 정보를 크롤링합니다.
|
||||
|
||||
Args:
|
||||
place_id: 네이버 지도 장소 ID
|
||||
|
||||
Returns:
|
||||
편의시설 정보 문자열 또는 None
|
||||
"""
|
||||
url = f"https://pcmap.place.naver.com/accommodation/{place_id}/home"
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url, headers=self._get_request_headers()) as response:
|
||||
soup = bs4.BeautifulSoup(await response.read(), "html.parser")
|
||||
c_elem = soup.find("span", "place_blind", string="편의")
|
||||
if c_elem:
|
||||
facilities = c_elem.parent.parent.find("div").string
|
||||
return facilities
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning(f"[NvMapScraper] Failed to get facility info: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# if __name__ == "__main__":
|
||||
# import asyncio
|
||||
|
||||
# url = "https://map.naver.com/p/search/%EC%8A%A4%ED%85%8C%EC%9D%B4%EB%A8%B8%EB%AD%84/place/1133638931?c=14.70,0,0,0,dh&placePath=/photo?businessCategory=pension&fromPanelNum=2&locale=ko&searchText=%EC%8A%A4%ED%85%8C%EC%9D%B4%EB%A8%B8%EB%AD%84&svcName=map_pcv5×tamp=202512191123&fromPanelNum=2&locale=ko&searchText=%EC%8A%A4%ED%85%8C%EC%9D%B4%EB%A8%B8%EB%AD%84&svcName=map_pcv5×tamp=202512191007&from=map&entry=bmp&filterType=%EC%97%85%EC%B2%B4&businessCategory=pension"
|
||||
# scraper = NvMapScraper(url)
|
||||
# asyncio.run(scraper.scrap())
|
||||
# print(scraper.image_link_list)
|
||||
# print(len(scraper.image_link_list) if scraper.image_link_list else 0)
|
||||
# print(scraper.base_info)
|
||||
|
|
|
|||
|
|
@ -1,443 +1,468 @@
|
|||
"""
|
||||
Azure Blob Storage 업로드 유틸리티
|
||||
|
||||
Azure Blob Storage에 파일을 업로드하는 클래스를 제공합니다.
|
||||
파일 경로 또는 바이트 데이터를 직접 업로드할 수 있습니다.
|
||||
|
||||
URL 경로 형식:
|
||||
- 음악: {BASE_URL}/{task_id}/song/{파일명}
|
||||
- 영상: {BASE_URL}/{task_id}/video/{파일명}
|
||||
- 이미지: {BASE_URL}/{task_id}/image/{파일명}
|
||||
|
||||
사용 예시:
|
||||
from app.utils.upload_blob_as_request import AzureBlobUploader
|
||||
|
||||
uploader = AzureBlobUploader(task_id="task-123")
|
||||
|
||||
# 파일 경로로 업로드
|
||||
success = await uploader.upload_music(file_path="my_song.mp3")
|
||||
success = await uploader.upload_video(file_path="my_video.mp4")
|
||||
success = await uploader.upload_image(file_path="my_image.png")
|
||||
|
||||
# 바이트 데이터로 직접 업로드 (media 저장 없이)
|
||||
success = await uploader.upload_music_bytes(audio_bytes, "my_song")
|
||||
success = await uploader.upload_video_bytes(video_bytes, "my_video")
|
||||
success = await uploader.upload_image_bytes(image_bytes, "my_image.png")
|
||||
|
||||
print(uploader.public_url) # 마지막 업로드의 공개 URL
|
||||
|
||||
성능 최적화:
|
||||
- HTTP 클라이언트 재사용: 모듈 레벨의 공유 클라이언트로 커넥션 풀 재사용
|
||||
- 동시 업로드: 공유 클라이언트를 통해 동시 요청 처리가 개선됩니다.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import aiofiles
|
||||
import httpx
|
||||
|
||||
from config import azure_blob_settings
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 모듈 레벨 공유 HTTP 클라이언트 (싱글톤 패턴)
|
||||
# =============================================================================
|
||||
|
||||
# 모듈 레벨 공유 HTTP 클라이언트 (커넥션 풀 재사용)
|
||||
_shared_blob_client: httpx.AsyncClient | None = None
|
||||
|
||||
|
||||
async def get_shared_blob_client() -> httpx.AsyncClient:
|
||||
"""공유 HTTP 클라이언트를 반환합니다. 없으면 생성합니다."""
|
||||
global _shared_blob_client
|
||||
if _shared_blob_client is None or _shared_blob_client.is_closed:
|
||||
print("[AzureBlobUploader] Creating shared HTTP client...")
|
||||
_shared_blob_client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(180.0, connect=10.0),
|
||||
limits=httpx.Limits(max_keepalive_connections=10, max_connections=20),
|
||||
)
|
||||
print("[AzureBlobUploader] Shared HTTP client created - "
|
||||
"max_connections: 20, max_keepalive: 10")
|
||||
return _shared_blob_client
|
||||
|
||||
|
||||
async def close_shared_blob_client() -> None:
|
||||
"""공유 HTTP 클라이언트를 닫습니다. 앱 종료 시 호출하세요."""
|
||||
global _shared_blob_client
|
||||
if _shared_blob_client is not None and not _shared_blob_client.is_closed:
|
||||
await _shared_blob_client.aclose()
|
||||
_shared_blob_client = None
|
||||
print("[AzureBlobUploader] Shared HTTP client closed")
|
||||
|
||||
|
||||
class AzureBlobUploader:
|
||||
"""Azure Blob Storage 업로드 클래스
|
||||
|
||||
Azure Blob Storage에 음악, 영상, 이미지 파일을 업로드합니다.
|
||||
URL 형식: {BASE_URL}/{task_id}/{category}/{file_name}?{SAS_TOKEN}
|
||||
|
||||
카테고리별 경로:
|
||||
- 음악: {task_id}/song/{file_name}
|
||||
- 영상: {task_id}/video/{file_name}
|
||||
- 이미지: {task_id}/image/{file_name}
|
||||
|
||||
Attributes:
|
||||
task_id: 작업 고유 식별자
|
||||
"""
|
||||
|
||||
# Content-Type 매핑
|
||||
IMAGE_CONTENT_TYPES = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".bmp": "image/bmp",
|
||||
}
|
||||
|
||||
def __init__(self, task_id: str):
|
||||
"""AzureBlobUploader 초기화
|
||||
|
||||
Args:
|
||||
task_id: 작업 고유 식별자
|
||||
"""
|
||||
self._task_id = task_id
|
||||
self._base_url = azure_blob_settings.AZURE_BLOB_BASE_URL
|
||||
self._sas_token = azure_blob_settings.AZURE_BLOB_SAS_TOKEN
|
||||
self._last_public_url: str = ""
|
||||
|
||||
@property
|
||||
def task_id(self) -> str:
|
||||
"""작업 고유 식별자"""
|
||||
return self._task_id
|
||||
|
||||
@property
|
||||
def public_url(self) -> str:
|
||||
"""마지막 업로드의 공개 URL (SAS 토큰 제외)"""
|
||||
return self._last_public_url
|
||||
|
||||
def _build_upload_url(self, category: str, file_name: str) -> str:
|
||||
"""업로드 URL 생성 (SAS 토큰 포함)"""
|
||||
# SAS 토큰 앞뒤의 ?, ', " 제거
|
||||
sas_token = self._sas_token.strip("?'\"")
|
||||
return (
|
||||
f"{self._base_url}/{self._task_id}/{category}/{file_name}?{sas_token}"
|
||||
)
|
||||
|
||||
def _build_public_url(self, category: str, file_name: str) -> str:
|
||||
"""공개 URL 생성 (SAS 토큰 제외)"""
|
||||
return f"{self._base_url}/{self._task_id}/{category}/{file_name}"
|
||||
|
||||
async def _upload_bytes(
|
||||
self,
|
||||
file_content: bytes,
|
||||
upload_url: str,
|
||||
headers: dict,
|
||||
timeout: float,
|
||||
log_prefix: str,
|
||||
) -> bool:
|
||||
"""바이트 데이터를 업로드하는 공통 내부 메서드"""
|
||||
start_time = time.perf_counter()
|
||||
|
||||
try:
|
||||
print(f"[{log_prefix}] Getting shared client...")
|
||||
client = await get_shared_blob_client()
|
||||
client_time = time.perf_counter()
|
||||
elapsed_ms = (client_time - start_time) * 1000
|
||||
print(f"[{log_prefix}] Client acquired in {elapsed_ms:.1f}ms")
|
||||
|
||||
size = len(file_content)
|
||||
print(f"[{log_prefix}] Starting upload... "
|
||||
f"(size: {size} bytes, timeout: {timeout}s)")
|
||||
|
||||
response = await asyncio.wait_for(
|
||||
client.put(upload_url, content=file_content, headers=headers),
|
||||
timeout=timeout,
|
||||
)
|
||||
upload_time = time.perf_counter()
|
||||
duration_ms = (upload_time - start_time) * 1000
|
||||
|
||||
if response.status_code in [200, 201]:
|
||||
print(f"[{log_prefix}] SUCCESS - Status: {response.status_code}, "
|
||||
f"Duration: {duration_ms:.1f}ms")
|
||||
print(f"[{log_prefix}] Public URL: {self._last_public_url}")
|
||||
return True
|
||||
else:
|
||||
print(f"[{log_prefix}] FAILED - Status: {response.status_code}, "
|
||||
f"Duration: {duration_ms:.1f}ms")
|
||||
print(f"[{log_prefix}] Response: {response.text[:500]}")
|
||||
return False
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
elapsed = time.perf_counter() - start_time
|
||||
print(f"[{log_prefix}] TIMEOUT after {elapsed:.1f}s "
|
||||
f"(limit: {timeout}s)")
|
||||
return False
|
||||
except httpx.ConnectError as e:
|
||||
elapsed = time.perf_counter() - start_time
|
||||
print(f"[{log_prefix}] CONNECT_ERROR after {elapsed:.1f}s - "
|
||||
f"{type(e).__name__}: {e}")
|
||||
return False
|
||||
except httpx.ReadError as e:
|
||||
elapsed = time.perf_counter() - start_time
|
||||
print(f"[{log_prefix}] READ_ERROR after {elapsed:.1f}s - "
|
||||
f"{type(e).__name__}: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
elapsed = time.perf_counter() - start_time
|
||||
print(f"[{log_prefix}] ERROR after {elapsed:.1f}s - "
|
||||
f"{type(e).__name__}: {e}")
|
||||
return False
|
||||
|
||||
async def _upload_file(
|
||||
self,
|
||||
file_path: str,
|
||||
category: str,
|
||||
content_type: str,
|
||||
timeout: float,
|
||||
log_prefix: str,
|
||||
) -> bool:
|
||||
"""파일을 Azure Blob Storage에 업로드하는 내부 메서드
|
||||
|
||||
Args:
|
||||
file_path: 업로드할 파일 경로
|
||||
category: 카테고리 (song, video, image)
|
||||
content_type: Content-Type 헤더 값
|
||||
timeout: 요청 타임아웃 (초)
|
||||
log_prefix: 로그 접두사
|
||||
|
||||
Returns:
|
||||
bool: 업로드 성공 여부
|
||||
"""
|
||||
# 파일 경로에서 파일명 추출
|
||||
file_name = Path(file_path).name
|
||||
|
||||
upload_url = self._build_upload_url(category, file_name)
|
||||
self._last_public_url = self._build_public_url(category, file_name)
|
||||
print(f"[{log_prefix}] URL (without SAS): {self._last_public_url}")
|
||||
|
||||
headers = {"Content-Type": content_type, "x-ms-blob-type": "BlockBlob"}
|
||||
|
||||
async with aiofiles.open(file_path, "rb") as file:
|
||||
file_content = await file.read()
|
||||
|
||||
return await self._upload_bytes(
|
||||
file_content=file_content,
|
||||
upload_url=upload_url,
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
log_prefix=log_prefix,
|
||||
)
|
||||
|
||||
async def upload_music(self, file_path: str) -> bool:
|
||||
"""음악 파일을 Azure Blob Storage에 업로드합니다.
|
||||
|
||||
URL 경로: {task_id}/song/{파일명}
|
||||
|
||||
Args:
|
||||
file_path: 업로드할 파일 경로
|
||||
|
||||
Returns:
|
||||
bool: 업로드 성공 여부
|
||||
|
||||
Example:
|
||||
uploader = AzureBlobUploader(task_id="task-123")
|
||||
success = await uploader.upload_music(file_path="my_song.mp3")
|
||||
print(uploader.public_url)
|
||||
"""
|
||||
return await self._upload_file(
|
||||
file_path=file_path,
|
||||
category="song",
|
||||
content_type="audio/mpeg",
|
||||
timeout=120.0,
|
||||
log_prefix="upload_music",
|
||||
)
|
||||
|
||||
async def upload_music_bytes(
|
||||
self, file_content: bytes, file_name: str
|
||||
) -> bool:
|
||||
"""음악 바이트 데이터를 Azure Blob Storage에 직접 업로드합니다.
|
||||
|
||||
URL 경로: {task_id}/song/{파일명}
|
||||
|
||||
Args:
|
||||
file_content: 업로드할 파일 바이트 데이터
|
||||
file_name: 저장할 파일명 (확장자가 없으면 .mp3 추가)
|
||||
|
||||
Returns:
|
||||
bool: 업로드 성공 여부
|
||||
|
||||
Example:
|
||||
uploader = AzureBlobUploader(task_id="task-123")
|
||||
success = await uploader.upload_music_bytes(audio_bytes, "my_song")
|
||||
print(uploader.public_url)
|
||||
"""
|
||||
# 확장자가 없으면 .mp3 추가
|
||||
if not Path(file_name).suffix:
|
||||
file_name = f"{file_name}.mp3"
|
||||
|
||||
upload_url = self._build_upload_url("song", file_name)
|
||||
self._last_public_url = self._build_public_url("song", file_name)
|
||||
log_prefix = "upload_music_bytes"
|
||||
print(f"[{log_prefix}] URL (without SAS): {self._last_public_url}")
|
||||
|
||||
headers = {"Content-Type": "audio/mpeg", "x-ms-blob-type": "BlockBlob"}
|
||||
|
||||
return await self._upload_bytes(
|
||||
file_content=file_content,
|
||||
upload_url=upload_url,
|
||||
headers=headers,
|
||||
timeout=120.0,
|
||||
log_prefix=log_prefix,
|
||||
)
|
||||
|
||||
async def upload_video(self, file_path: str) -> bool:
|
||||
"""영상 파일을 Azure Blob Storage에 업로드합니다.
|
||||
|
||||
URL 경로: {task_id}/video/{파일명}
|
||||
|
||||
Args:
|
||||
file_path: 업로드할 파일 경로
|
||||
|
||||
Returns:
|
||||
bool: 업로드 성공 여부
|
||||
|
||||
Example:
|
||||
uploader = AzureBlobUploader(task_id="task-123")
|
||||
success = await uploader.upload_video(file_path="my_video.mp4")
|
||||
print(uploader.public_url)
|
||||
"""
|
||||
return await self._upload_file(
|
||||
file_path=file_path,
|
||||
category="video",
|
||||
content_type="video/mp4",
|
||||
timeout=180.0,
|
||||
log_prefix="upload_video",
|
||||
)
|
||||
|
||||
async def upload_video_bytes(
|
||||
self, file_content: bytes, file_name: str
|
||||
) -> bool:
|
||||
"""영상 바이트 데이터를 Azure Blob Storage에 직접 업로드합니다.
|
||||
|
||||
URL 경로: {task_id}/video/{파일명}
|
||||
|
||||
Args:
|
||||
file_content: 업로드할 파일 바이트 데이터
|
||||
file_name: 저장할 파일명 (확장자가 없으면 .mp4 추가)
|
||||
|
||||
Returns:
|
||||
bool: 업로드 성공 여부
|
||||
|
||||
Example:
|
||||
uploader = AzureBlobUploader(task_id="task-123")
|
||||
success = await uploader.upload_video_bytes(video_bytes, "my_video")
|
||||
print(uploader.public_url)
|
||||
"""
|
||||
# 확장자가 없으면 .mp4 추가
|
||||
if not Path(file_name).suffix:
|
||||
file_name = f"{file_name}.mp4"
|
||||
|
||||
upload_url = self._build_upload_url("video", file_name)
|
||||
self._last_public_url = self._build_public_url("video", file_name)
|
||||
log_prefix = "upload_video_bytes"
|
||||
print(f"[{log_prefix}] URL (without SAS): {self._last_public_url}")
|
||||
|
||||
headers = {"Content-Type": "video/mp4", "x-ms-blob-type": "BlockBlob"}
|
||||
|
||||
return await self._upload_bytes(
|
||||
file_content=file_content,
|
||||
upload_url=upload_url,
|
||||
headers=headers,
|
||||
timeout=180.0,
|
||||
log_prefix=log_prefix,
|
||||
)
|
||||
|
||||
async def upload_image(self, file_path: str) -> bool:
|
||||
"""이미지 파일을 Azure Blob Storage에 업로드합니다.
|
||||
|
||||
URL 경로: {task_id}/image/{파일명}
|
||||
|
||||
Args:
|
||||
file_path: 업로드할 파일 경로
|
||||
|
||||
Returns:
|
||||
bool: 업로드 성공 여부
|
||||
|
||||
Example:
|
||||
uploader = AzureBlobUploader(task_id="task-123")
|
||||
success = await uploader.upload_image(file_path="my_image.png")
|
||||
print(uploader.public_url)
|
||||
"""
|
||||
extension = Path(file_path).suffix.lower()
|
||||
content_type = self.IMAGE_CONTENT_TYPES.get(extension, "image/jpeg")
|
||||
|
||||
return await self._upload_file(
|
||||
file_path=file_path,
|
||||
category="image",
|
||||
content_type=content_type,
|
||||
timeout=60.0,
|
||||
log_prefix="upload_image",
|
||||
)
|
||||
|
||||
async def upload_image_bytes(
|
||||
self, file_content: bytes, file_name: str
|
||||
) -> bool:
|
||||
"""이미지 바이트 데이터를 Azure Blob Storage에 직접 업로드합니다.
|
||||
|
||||
URL 경로: {task_id}/image/{파일명}
|
||||
|
||||
Args:
|
||||
file_content: 업로드할 파일 바이트 데이터
|
||||
file_name: 저장할 파일명
|
||||
|
||||
Returns:
|
||||
bool: 업로드 성공 여부
|
||||
|
||||
Example:
|
||||
uploader = AzureBlobUploader(task_id="task-123")
|
||||
with open("my_image.png", "rb") as f:
|
||||
content = f.read()
|
||||
success = await uploader.upload_image_bytes(content, "my_image.png")
|
||||
print(uploader.public_url)
|
||||
"""
|
||||
extension = Path(file_name).suffix.lower()
|
||||
content_type = self.IMAGE_CONTENT_TYPES.get(extension, "image/jpeg")
|
||||
|
||||
upload_url = self._build_upload_url("image", file_name)
|
||||
self._last_public_url = self._build_public_url("image", file_name)
|
||||
log_prefix = "upload_image_bytes"
|
||||
print(f"[{log_prefix}] URL (without SAS): {self._last_public_url}")
|
||||
|
||||
headers = {"Content-Type": content_type, "x-ms-blob-type": "BlockBlob"}
|
||||
|
||||
return await self._upload_bytes(
|
||||
file_content=file_content,
|
||||
upload_url=upload_url,
|
||||
headers=headers,
|
||||
timeout=60.0,
|
||||
log_prefix=log_prefix,
|
||||
)
|
||||
|
||||
|
||||
# 사용 예시:
|
||||
# import asyncio
|
||||
#
|
||||
# async def main():
|
||||
# uploader = AzureBlobUploader(task_id="task-123")
|
||||
#
|
||||
# # 음악 업로드 -> {BASE_URL}/task-123/song/my_song.mp3
|
||||
# await uploader.upload_music("my_song.mp3")
|
||||
# print(uploader.public_url)
|
||||
#
|
||||
# # 영상 업로드 -> {BASE_URL}/task-123/video/my_video.mp4
|
||||
# await uploader.upload_video("my_video.mp4")
|
||||
# print(uploader.public_url)
|
||||
#
|
||||
# # 이미지 업로드 -> {BASE_URL}/task-123/image/my_image.png
|
||||
# await uploader.upload_image("my_image.png")
|
||||
# print(uploader.public_url)
|
||||
#
|
||||
# asyncio.run(main())
|
||||
"""
|
||||
Azure Blob Storage 업로드 유틸리티
|
||||
|
||||
Azure Blob Storage에 파일을 업로드하는 클래스를 제공합니다.
|
||||
파일 경로 또는 바이트 데이터를 직접 업로드할 수 있습니다.
|
||||
|
||||
URL 경로 형식:
|
||||
- 음악: {BASE_URL}/{task_id}/song/{파일명}
|
||||
- 영상: {BASE_URL}/{task_id}/video/{파일명}
|
||||
- 이미지: {BASE_URL}/{task_id}/image/{파일명}
|
||||
|
||||
사용 예시:
|
||||
from app.utils.upload_blob_as_request import AzureBlobUploader
|
||||
|
||||
uploader = AzureBlobUploader(task_id="task-123")
|
||||
|
||||
# 파일 경로로 업로드
|
||||
success = await uploader.upload_music(file_path="my_song.mp3")
|
||||
success = await uploader.upload_video(file_path="my_video.mp4")
|
||||
success = await uploader.upload_image(file_path="my_image.png")
|
||||
|
||||
# 바이트 데이터로 직접 업로드 (media 저장 없이)
|
||||
success = await uploader.upload_music_bytes(audio_bytes, "my_song")
|
||||
success = await uploader.upload_video_bytes(video_bytes, "my_video")
|
||||
success = await uploader.upload_image_bytes(image_bytes, "my_image.png")
|
||||
|
||||
print(uploader.public_url) # 마지막 업로드의 공개 URL
|
||||
|
||||
성능 최적화:
|
||||
- HTTP 클라이언트 재사용: 모듈 레벨의 공유 클라이언트로 커넥션 풀 재사용
|
||||
- 동시 업로드: 공유 클라이언트를 통해 동시 요청 처리가 개선됩니다.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import aiofiles
|
||||
import httpx
|
||||
|
||||
from config import azure_blob_settings
|
||||
|
||||
# 로거 설정
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# =============================================================================
|
||||
# 모듈 레벨 공유 HTTP 클라이언트 (싱글톤 패턴)
|
||||
# =============================================================================
|
||||
|
||||
# 모듈 레벨 공유 HTTP 클라이언트 (커넥션 풀 재사용)
|
||||
_shared_blob_client: httpx.AsyncClient | None = None
|
||||
|
||||
|
||||
async def get_shared_blob_client() -> httpx.AsyncClient:
|
||||
"""공유 HTTP 클라이언트를 반환합니다. 없으면 생성합니다."""
|
||||
global _shared_blob_client
|
||||
if _shared_blob_client is None or _shared_blob_client.is_closed:
|
||||
print("[AzureBlobUploader] Creating shared HTTP client...")
|
||||
_shared_blob_client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(180.0, connect=10.0),
|
||||
limits=httpx.Limits(max_keepalive_connections=10, max_connections=20),
|
||||
)
|
||||
print("[AzureBlobUploader] Shared HTTP client created - "
|
||||
"max_connections: 20, max_keepalive: 10")
|
||||
return _shared_blob_client
|
||||
|
||||
|
||||
async def close_shared_blob_client() -> None:
|
||||
"""공유 HTTP 클라이언트를 닫습니다. 앱 종료 시 호출하세요."""
|
||||
global _shared_blob_client
|
||||
if _shared_blob_client is not None and not _shared_blob_client.is_closed:
|
||||
await _shared_blob_client.aclose()
|
||||
_shared_blob_client = None
|
||||
print("[AzureBlobUploader] Shared HTTP client closed")
|
||||
|
||||
|
||||
class AzureBlobUploader:
|
||||
"""Azure Blob Storage 업로드 클래스
|
||||
|
||||
Azure Blob Storage에 음악, 영상, 이미지 파일을 업로드합니다.
|
||||
URL 형식: {BASE_URL}/{task_id}/{category}/{file_name}?{SAS_TOKEN}
|
||||
|
||||
카테고리별 경로:
|
||||
- 음악: {task_id}/song/{file_name}
|
||||
- 영상: {task_id}/video/{file_name}
|
||||
- 이미지: {task_id}/image/{file_name}
|
||||
|
||||
Attributes:
|
||||
task_id: 작업 고유 식별자
|
||||
"""
|
||||
|
||||
# Content-Type 매핑
|
||||
IMAGE_CONTENT_TYPES = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".bmp": "image/bmp",
|
||||
}
|
||||
|
||||
def __init__(self, task_id: str):
|
||||
"""AzureBlobUploader 초기화
|
||||
|
||||
Args:
|
||||
task_id: 작업 고유 식별자
|
||||
"""
|
||||
self._task_id = task_id
|
||||
self._base_url = azure_blob_settings.AZURE_BLOB_BASE_URL
|
||||
self._sas_token = azure_blob_settings.AZURE_BLOB_SAS_TOKEN
|
||||
self._last_public_url: str = ""
|
||||
|
||||
@property
|
||||
def task_id(self) -> str:
|
||||
"""작업 고유 식별자"""
|
||||
return self._task_id
|
||||
|
||||
@property
|
||||
def public_url(self) -> str:
|
||||
"""마지막 업로드의 공개 URL (SAS 토큰 제외)"""
|
||||
return self._last_public_url
|
||||
|
||||
def _build_upload_url(self, category: str, file_name: str) -> str:
|
||||
"""업로드 URL 생성 (SAS 토큰 포함)"""
|
||||
# SAS 토큰 앞뒤의 ?, ', " 제거
|
||||
sas_token = self._sas_token.strip("?'\"")
|
||||
return (
|
||||
f"{self._base_url}/{self._task_id}/{category}/{file_name}?{sas_token}"
|
||||
)
|
||||
|
||||
def _build_public_url(self, category: str, file_name: str) -> str:
|
||||
"""공개 URL 생성 (SAS 토큰 제외)"""
|
||||
return f"{self._base_url}/{self._task_id}/{category}/{file_name}"
|
||||
|
||||
async def _upload_bytes(
|
||||
self,
|
||||
file_content: bytes,
|
||||
upload_url: str,
|
||||
headers: dict,
|
||||
timeout: float,
|
||||
log_prefix: str,
|
||||
) -> bool:
|
||||
"""바이트 데이터를 업로드하는 공통 내부 메서드
|
||||
|
||||
Args:
|
||||
file_content: 업로드할 바이트 데이터
|
||||
upload_url: 업로드 URL
|
||||
headers: HTTP 헤더
|
||||
timeout: 요청 타임아웃 (초)
|
||||
log_prefix: 로그 접두사
|
||||
|
||||
Returns:
|
||||
bool: 업로드 성공 여부
|
||||
"""
|
||||
size = len(file_content)
|
||||
start_time = time.perf_counter()
|
||||
|
||||
try:
|
||||
logger.info(f"[{log_prefix}] Starting upload")
|
||||
print(f"[{log_prefix}] Getting shared client...")
|
||||
|
||||
client = await get_shared_blob_client()
|
||||
client_time = time.perf_counter()
|
||||
elapsed_ms = (client_time - start_time) * 1000
|
||||
print(f"[{log_prefix}] Client acquired in {elapsed_ms:.1f}ms")
|
||||
|
||||
print(f"[{log_prefix}] Starting upload... "
|
||||
f"(size: {size} bytes, timeout: {timeout}s)")
|
||||
|
||||
response = await asyncio.wait_for(
|
||||
client.put(upload_url, content=file_content, headers=headers),
|
||||
timeout=timeout,
|
||||
)
|
||||
upload_time = time.perf_counter()
|
||||
duration_ms = (upload_time - start_time) * 1000
|
||||
|
||||
if response.status_code in [200, 201]:
|
||||
logger.info(f"[{log_prefix}] SUCCESS - Status: {response.status_code}")
|
||||
print(f"[{log_prefix}] SUCCESS - Status: {response.status_code}, "
|
||||
f"Duration: {duration_ms:.1f}ms")
|
||||
print(f"[{log_prefix}] Public URL: {self._last_public_url}")
|
||||
return True
|
||||
|
||||
# 업로드 실패
|
||||
logger.error(f"[{log_prefix}] FAILED - Status: {response.status_code}")
|
||||
print(f"[{log_prefix}] FAILED - Status: {response.status_code}, "
|
||||
f"Duration: {duration_ms:.1f}ms")
|
||||
print(f"[{log_prefix}] Response: {response.text[:500]}")
|
||||
return False
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
elapsed = time.perf_counter() - start_time
|
||||
logger.error(f"[{log_prefix}] TIMEOUT after {elapsed:.1f}s")
|
||||
print(f"[{log_prefix}] TIMEOUT after {elapsed:.1f}s")
|
||||
return False
|
||||
|
||||
except httpx.ConnectError as e:
|
||||
elapsed = time.perf_counter() - start_time
|
||||
logger.error(f"[{log_prefix}] CONNECT_ERROR: {e}")
|
||||
print(f"[{log_prefix}] CONNECT_ERROR after {elapsed:.1f}s - "
|
||||
f"{type(e).__name__}: {e}")
|
||||
return False
|
||||
|
||||
except httpx.ReadError as e:
|
||||
elapsed = time.perf_counter() - start_time
|
||||
logger.error(f"[{log_prefix}] READ_ERROR: {e}")
|
||||
print(f"[{log_prefix}] READ_ERROR after {elapsed:.1f}s - "
|
||||
f"{type(e).__name__}: {e}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
elapsed = time.perf_counter() - start_time
|
||||
logger.error(f"[{log_prefix}] ERROR: {type(e).__name__}: {e}")
|
||||
print(f"[{log_prefix}] ERROR after {elapsed:.1f}s - "
|
||||
f"{type(e).__name__}: {e}")
|
||||
return False
|
||||
|
||||
async def _upload_file(
|
||||
self,
|
||||
file_path: str,
|
||||
category: str,
|
||||
content_type: str,
|
||||
timeout: float,
|
||||
log_prefix: str,
|
||||
) -> bool:
|
||||
"""파일을 Azure Blob Storage에 업로드하는 내부 메서드
|
||||
|
||||
Args:
|
||||
file_path: 업로드할 파일 경로
|
||||
category: 카테고리 (song, video, image)
|
||||
content_type: Content-Type 헤더 값
|
||||
timeout: 요청 타임아웃 (초)
|
||||
log_prefix: 로그 접두사
|
||||
|
||||
Returns:
|
||||
bool: 업로드 성공 여부
|
||||
"""
|
||||
# 파일 경로에서 파일명 추출
|
||||
file_name = Path(file_path).name
|
||||
|
||||
upload_url = self._build_upload_url(category, file_name)
|
||||
self._last_public_url = self._build_public_url(category, file_name)
|
||||
print(f"[{log_prefix}] URL (without SAS): {self._last_public_url}")
|
||||
|
||||
headers = {"Content-Type": content_type, "x-ms-blob-type": "BlockBlob"}
|
||||
|
||||
async with aiofiles.open(file_path, "rb") as file:
|
||||
file_content = await file.read()
|
||||
|
||||
return await self._upload_bytes(
|
||||
file_content=file_content,
|
||||
upload_url=upload_url,
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
log_prefix=log_prefix,
|
||||
)
|
||||
|
||||
async def upload_music(self, file_path: str) -> bool:
|
||||
"""음악 파일을 Azure Blob Storage에 업로드합니다.
|
||||
|
||||
URL 경로: {task_id}/song/{파일명}
|
||||
|
||||
Args:
|
||||
file_path: 업로드할 파일 경로
|
||||
|
||||
Returns:
|
||||
bool: 업로드 성공 여부
|
||||
|
||||
Example:
|
||||
uploader = AzureBlobUploader(task_id="task-123")
|
||||
success = await uploader.upload_music(file_path="my_song.mp3")
|
||||
print(uploader.public_url)
|
||||
"""
|
||||
return await self._upload_file(
|
||||
file_path=file_path,
|
||||
category="song",
|
||||
content_type="audio/mpeg",
|
||||
timeout=120.0,
|
||||
log_prefix="upload_music",
|
||||
)
|
||||
|
||||
async def upload_music_bytes(
|
||||
self, file_content: bytes, file_name: str
|
||||
) -> bool:
|
||||
"""음악 바이트 데이터를 Azure Blob Storage에 직접 업로드합니다.
|
||||
|
||||
URL 경로: {task_id}/song/{파일명}
|
||||
|
||||
Args:
|
||||
file_content: 업로드할 파일 바이트 데이터
|
||||
file_name: 저장할 파일명 (확장자가 없으면 .mp3 추가)
|
||||
|
||||
Returns:
|
||||
bool: 업로드 성공 여부
|
||||
|
||||
Example:
|
||||
uploader = AzureBlobUploader(task_id="task-123")
|
||||
success = await uploader.upload_music_bytes(audio_bytes, "my_song")
|
||||
print(uploader.public_url)
|
||||
"""
|
||||
# 확장자가 없으면 .mp3 추가
|
||||
if not Path(file_name).suffix:
|
||||
file_name = f"{file_name}.mp3"
|
||||
|
||||
upload_url = self._build_upload_url("song", file_name)
|
||||
self._last_public_url = self._build_public_url("song", file_name)
|
||||
log_prefix = "upload_music_bytes"
|
||||
print(f"[{log_prefix}] URL (without SAS): {self._last_public_url}")
|
||||
|
||||
headers = {"Content-Type": "audio/mpeg", "x-ms-blob-type": "BlockBlob"}
|
||||
|
||||
return await self._upload_bytes(
|
||||
file_content=file_content,
|
||||
upload_url=upload_url,
|
||||
headers=headers,
|
||||
timeout=120.0,
|
||||
log_prefix=log_prefix,
|
||||
)
|
||||
|
||||
async def upload_video(self, file_path: str) -> bool:
|
||||
"""영상 파일을 Azure Blob Storage에 업로드합니다.
|
||||
|
||||
URL 경로: {task_id}/video/{파일명}
|
||||
|
||||
Args:
|
||||
file_path: 업로드할 파일 경로
|
||||
|
||||
Returns:
|
||||
bool: 업로드 성공 여부
|
||||
|
||||
Example:
|
||||
uploader = AzureBlobUploader(task_id="task-123")
|
||||
success = await uploader.upload_video(file_path="my_video.mp4")
|
||||
print(uploader.public_url)
|
||||
"""
|
||||
return await self._upload_file(
|
||||
file_path=file_path,
|
||||
category="video",
|
||||
content_type="video/mp4",
|
||||
timeout=180.0,
|
||||
log_prefix="upload_video",
|
||||
)
|
||||
|
||||
async def upload_video_bytes(
|
||||
self, file_content: bytes, file_name: str
|
||||
) -> bool:
|
||||
"""영상 바이트 데이터를 Azure Blob Storage에 직접 업로드합니다.
|
||||
|
||||
URL 경로: {task_id}/video/{파일명}
|
||||
|
||||
Args:
|
||||
file_content: 업로드할 파일 바이트 데이터
|
||||
file_name: 저장할 파일명 (확장자가 없으면 .mp4 추가)
|
||||
|
||||
Returns:
|
||||
bool: 업로드 성공 여부
|
||||
|
||||
Example:
|
||||
uploader = AzureBlobUploader(task_id="task-123")
|
||||
success = await uploader.upload_video_bytes(video_bytes, "my_video")
|
||||
print(uploader.public_url)
|
||||
"""
|
||||
# 확장자가 없으면 .mp4 추가
|
||||
if not Path(file_name).suffix:
|
||||
file_name = f"{file_name}.mp4"
|
||||
|
||||
upload_url = self._build_upload_url("video", file_name)
|
||||
self._last_public_url = self._build_public_url("video", file_name)
|
||||
log_prefix = "upload_video_bytes"
|
||||
print(f"[{log_prefix}] URL (without SAS): {self._last_public_url}")
|
||||
|
||||
headers = {"Content-Type": "video/mp4", "x-ms-blob-type": "BlockBlob"}
|
||||
|
||||
return await self._upload_bytes(
|
||||
file_content=file_content,
|
||||
upload_url=upload_url,
|
||||
headers=headers,
|
||||
timeout=180.0,
|
||||
log_prefix=log_prefix,
|
||||
)
|
||||
|
||||
async def upload_image(self, file_path: str) -> bool:
|
||||
"""이미지 파일을 Azure Blob Storage에 업로드합니다.
|
||||
|
||||
URL 경로: {task_id}/image/{파일명}
|
||||
|
||||
Args:
|
||||
file_path: 업로드할 파일 경로
|
||||
|
||||
Returns:
|
||||
bool: 업로드 성공 여부
|
||||
|
||||
Example:
|
||||
uploader = AzureBlobUploader(task_id="task-123")
|
||||
success = await uploader.upload_image(file_path="my_image.png")
|
||||
print(uploader.public_url)
|
||||
"""
|
||||
extension = Path(file_path).suffix.lower()
|
||||
content_type = self.IMAGE_CONTENT_TYPES.get(extension, "image/jpeg")
|
||||
|
||||
return await self._upload_file(
|
||||
file_path=file_path,
|
||||
category="image",
|
||||
content_type=content_type,
|
||||
timeout=60.0,
|
||||
log_prefix="upload_image",
|
||||
)
|
||||
|
||||
async def upload_image_bytes(
|
||||
self, file_content: bytes, file_name: str
|
||||
) -> bool:
|
||||
"""이미지 바이트 데이터를 Azure Blob Storage에 직접 업로드합니다.
|
||||
|
||||
URL 경로: {task_id}/image/{파일명}
|
||||
|
||||
Args:
|
||||
file_content: 업로드할 파일 바이트 데이터
|
||||
file_name: 저장할 파일명
|
||||
|
||||
Returns:
|
||||
bool: 업로드 성공 여부
|
||||
|
||||
Example:
|
||||
uploader = AzureBlobUploader(task_id="task-123")
|
||||
with open("my_image.png", "rb") as f:
|
||||
content = f.read()
|
||||
success = await uploader.upload_image_bytes(content, "my_image.png")
|
||||
print(uploader.public_url)
|
||||
"""
|
||||
extension = Path(file_name).suffix.lower()
|
||||
content_type = self.IMAGE_CONTENT_TYPES.get(extension, "image/jpeg")
|
||||
|
||||
upload_url = self._build_upload_url("image", file_name)
|
||||
self._last_public_url = self._build_public_url("image", file_name)
|
||||
log_prefix = "upload_image_bytes"
|
||||
print(f"[{log_prefix}] URL (without SAS): {self._last_public_url}")
|
||||
|
||||
headers = {"Content-Type": content_type, "x-ms-blob-type": "BlockBlob"}
|
||||
|
||||
return await self._upload_bytes(
|
||||
file_content=file_content,
|
||||
upload_url=upload_url,
|
||||
headers=headers,
|
||||
timeout=60.0,
|
||||
log_prefix=log_prefix,
|
||||
)
|
||||
|
||||
|
||||
# 사용 예시:
|
||||
# import asyncio
|
||||
#
|
||||
# async def main():
|
||||
# uploader = AzureBlobUploader(task_id="task-123")
|
||||
#
|
||||
# # 음악 업로드 -> {BASE_URL}/task-123/song/my_song.mp3
|
||||
# await uploader.upload_music("my_song.mp3")
|
||||
# print(uploader.public_url)
|
||||
#
|
||||
# # 영상 업로드 -> {BASE_URL}/task-123/video/my_video.mp4
|
||||
# await uploader.upload_video("my_video.mp4")
|
||||
# print(uploader.public_url)
|
||||
#
|
||||
# # 이미지 업로드 -> {BASE_URL}/task-123/image/my_image.png
|
||||
# await uploader.upload_image("my_image.png")
|
||||
# print(uploader.public_url)
|
||||
#
|
||||
# asyncio.run(main())
|
||||
|
|
|
|||
|
|
@ -1,62 +1,62 @@
|
|||
from sqladmin import ModelView
|
||||
|
||||
from app.video.models import Video
|
||||
|
||||
|
||||
class VideoAdmin(ModelView, model=Video):
|
||||
name = "영상"
|
||||
name_plural = "영상 목록"
|
||||
icon = "fa-solid fa-video"
|
||||
category = "영상 관리"
|
||||
page_size = 20
|
||||
|
||||
column_list = [
|
||||
"id",
|
||||
"project_id",
|
||||
"lyric_id",
|
||||
"song_id",
|
||||
"task_id",
|
||||
"status",
|
||||
"created_at",
|
||||
]
|
||||
|
||||
column_details_list = [
|
||||
"id",
|
||||
"project_id",
|
||||
"lyric_id",
|
||||
"song_id",
|
||||
"task_id",
|
||||
"status",
|
||||
"result_movie_url",
|
||||
"created_at",
|
||||
]
|
||||
|
||||
# 폼(생성/수정)에서 제외
|
||||
form_excluded_columns = ["created_at"]
|
||||
|
||||
column_searchable_list = [
|
||||
Video.task_id,
|
||||
Video.status,
|
||||
]
|
||||
|
||||
column_default_sort = (Video.created_at, True) # True: DESC (최신순)
|
||||
|
||||
column_sortable_list = [
|
||||
Video.id,
|
||||
Video.project_id,
|
||||
Video.lyric_id,
|
||||
Video.song_id,
|
||||
Video.status,
|
||||
Video.created_at,
|
||||
]
|
||||
|
||||
column_labels = {
|
||||
"id": "ID",
|
||||
"project_id": "프로젝트 ID",
|
||||
"lyric_id": "가사 ID",
|
||||
"song_id": "노래 ID",
|
||||
"task_id": "작업 ID",
|
||||
"status": "상태",
|
||||
"result_movie_url": "영상 URL",
|
||||
"created_at": "생성일시",
|
||||
}
|
||||
from sqladmin import ModelView
|
||||
|
||||
from app.video.models import Video
|
||||
|
||||
|
||||
class VideoAdmin(ModelView, model=Video):
|
||||
name = "영상"
|
||||
name_plural = "영상 목록"
|
||||
icon = "fa-solid fa-video"
|
||||
category = "영상 관리"
|
||||
page_size = 20
|
||||
|
||||
column_list = [
|
||||
"id",
|
||||
"project_id",
|
||||
"lyric_id",
|
||||
"song_id",
|
||||
"task_id",
|
||||
"status",
|
||||
"created_at",
|
||||
]
|
||||
|
||||
column_details_list = [
|
||||
"id",
|
||||
"project_id",
|
||||
"lyric_id",
|
||||
"song_id",
|
||||
"task_id",
|
||||
"status",
|
||||
"result_movie_url",
|
||||
"created_at",
|
||||
]
|
||||
|
||||
# 폼(생성/수정)에서 제외
|
||||
form_excluded_columns = ["created_at"]
|
||||
|
||||
column_searchable_list = [
|
||||
Video.task_id,
|
||||
Video.status,
|
||||
]
|
||||
|
||||
column_default_sort = (Video.created_at, True) # True: DESC (최신순)
|
||||
|
||||
column_sortable_list = [
|
||||
Video.id,
|
||||
Video.project_id,
|
||||
Video.lyric_id,
|
||||
Video.song_id,
|
||||
Video.status,
|
||||
Video.created_at,
|
||||
]
|
||||
|
||||
column_labels = {
|
||||
"id": "ID",
|
||||
"project_id": "프로젝트 ID",
|
||||
"lyric_id": "가사 ID",
|
||||
"song_id": "노래 ID",
|
||||
"task_id": "작업 ID",
|
||||
"status": "상태",
|
||||
"result_movie_url": "영상 URL",
|
||||
"created_at": "생성일시",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.session import get_session
|
||||
|
||||
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.session import get_session
|
||||
|
||||
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
|
|
|||
|
|
@ -1,139 +1,139 @@
|
|||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database.session import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.home.models import Project
|
||||
from app.lyric.models import Lyric
|
||||
from app.song.models import Song
|
||||
|
||||
|
||||
class Video(Base):
|
||||
"""
|
||||
영상 결과 테이블
|
||||
|
||||
최종 생성된 영상의 결과 URL을 저장합니다.
|
||||
Creatomate 서비스를 통해 이미지와 노래를 결합한 영상 결과입니다.
|
||||
|
||||
Attributes:
|
||||
id: 고유 식별자 (자동 증가)
|
||||
project_id: 연결된 Project의 id (외래키)
|
||||
lyric_id: 연결된 Lyric의 id (외래키)
|
||||
song_id: 연결된 Song의 id (외래키)
|
||||
task_id: 영상 생성 작업의 고유 식별자 (UUID 형식)
|
||||
status: 처리 상태 (pending, processing, completed, failed 등)
|
||||
result_movie_url: 생성된 영상 URL (S3, CDN 경로)
|
||||
created_at: 생성 일시 (자동 설정)
|
||||
|
||||
Relationships:
|
||||
project: 연결된 Project
|
||||
lyric: 연결된 Lyric
|
||||
song: 연결된 Song
|
||||
"""
|
||||
|
||||
__tablename__ = "video"
|
||||
__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="고유 식별자",
|
||||
)
|
||||
|
||||
project_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("project.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="연결된 Project의 id",
|
||||
)
|
||||
|
||||
lyric_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("lyric.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="연결된 Lyric의 id",
|
||||
)
|
||||
|
||||
song_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("song.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="연결된 Song의 id",
|
||||
)
|
||||
|
||||
task_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="영상 생성 작업 고유 식별자 (UUID)",
|
||||
)
|
||||
|
||||
creatomate_render_id: Mapped[Optional[str]] = mapped_column(
|
||||
String(64),
|
||||
nullable=True,
|
||||
comment="Creatomate API 렌더 ID",
|
||||
)
|
||||
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
nullable=False,
|
||||
comment="처리 상태 (processing, completed, failed)",
|
||||
)
|
||||
|
||||
result_movie_url: Mapped[Optional[str]] = mapped_column(
|
||||
String(2048),
|
||||
nullable=True,
|
||||
comment="생성된 영상 URL",
|
||||
)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
comment="생성 일시",
|
||||
)
|
||||
|
||||
# Relationships
|
||||
project: Mapped["Project"] = relationship(
|
||||
"Project",
|
||||
back_populates="videos",
|
||||
)
|
||||
|
||||
lyric: Mapped["Lyric"] = relationship(
|
||||
"Lyric",
|
||||
back_populates="videos",
|
||||
)
|
||||
|
||||
song: Mapped["Song"] = relationship(
|
||||
"Song",
|
||||
back_populates="videos",
|
||||
)
|
||||
|
||||
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"<Video("
|
||||
f"id={self.id}, "
|
||||
f"task_id='{truncate(self.task_id)}', "
|
||||
f"status='{self.status}'"
|
||||
f")>"
|
||||
)
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database.session import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.home.models import Project
|
||||
from app.lyric.models import Lyric
|
||||
from app.song.models import Song
|
||||
|
||||
|
||||
class Video(Base):
|
||||
"""
|
||||
영상 결과 테이블
|
||||
|
||||
최종 생성된 영상의 결과 URL을 저장합니다.
|
||||
Creatomate 서비스를 통해 이미지와 노래를 결합한 영상 결과입니다.
|
||||
|
||||
Attributes:
|
||||
id: 고유 식별자 (자동 증가)
|
||||
project_id: 연결된 Project의 id (외래키)
|
||||
lyric_id: 연결된 Lyric의 id (외래키)
|
||||
song_id: 연결된 Song의 id (외래키)
|
||||
task_id: 영상 생성 작업의 고유 식별자 (UUID 형식)
|
||||
status: 처리 상태 (pending, processing, completed, failed 등)
|
||||
result_movie_url: 생성된 영상 URL (S3, CDN 경로)
|
||||
created_at: 생성 일시 (자동 설정)
|
||||
|
||||
Relationships:
|
||||
project: 연결된 Project
|
||||
lyric: 연결된 Lyric
|
||||
song: 연결된 Song
|
||||
"""
|
||||
|
||||
__tablename__ = "video"
|
||||
__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="고유 식별자",
|
||||
)
|
||||
|
||||
project_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("project.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="연결된 Project의 id",
|
||||
)
|
||||
|
||||
lyric_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("lyric.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="연결된 Lyric의 id",
|
||||
)
|
||||
|
||||
song_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("song.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="연결된 Song의 id",
|
||||
)
|
||||
|
||||
task_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="영상 생성 작업 고유 식별자 (UUID)",
|
||||
)
|
||||
|
||||
creatomate_render_id: Mapped[Optional[str]] = mapped_column(
|
||||
String(64),
|
||||
nullable=True,
|
||||
comment="Creatomate API 렌더 ID",
|
||||
)
|
||||
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
nullable=False,
|
||||
comment="처리 상태 (processing, completed, failed)",
|
||||
)
|
||||
|
||||
result_movie_url: Mapped[Optional[str]] = mapped_column(
|
||||
String(2048),
|
||||
nullable=True,
|
||||
comment="생성된 영상 URL",
|
||||
)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
comment="생성 일시",
|
||||
)
|
||||
|
||||
# Relationships
|
||||
project: Mapped["Project"] = relationship(
|
||||
"Project",
|
||||
back_populates="videos",
|
||||
)
|
||||
|
||||
lyric: Mapped["Lyric"] = relationship(
|
||||
"Lyric",
|
||||
back_populates="videos",
|
||||
)
|
||||
|
||||
song: Mapped["Song"] = relationship(
|
||||
"Song",
|
||||
back_populates="videos",
|
||||
)
|
||||
|
||||
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"<Video("
|
||||
f"id={self.id}, "
|
||||
f"task_id='{truncate(self.task_id)}', "
|
||||
f"status='{self.status}'"
|
||||
f")>"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,156 +1,156 @@
|
|||
"""
|
||||
Video API Schemas
|
||||
|
||||
영상 생성 관련 Pydantic 스키마를 정의합니다.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Response Schemas
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class GenerateVideoResponse(BaseModel):
|
||||
"""영상 생성 응답 스키마
|
||||
|
||||
Usage:
|
||||
GET /video/generate/{task_id}
|
||||
Returns the task IDs for tracking video generation.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"success": True,
|
||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
||||
"creatomate_render_id": "render-id-123456",
|
||||
"message": "영상 생성 요청이 접수되었습니다. creatomate_render_id로 상태를 조회하세요.",
|
||||
"error_message": None,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
success: bool = Field(..., description="요청 성공 여부")
|
||||
task_id: Optional[str] = Field(None, description="내부 작업 ID (Project task_id)")
|
||||
creatomate_render_id: Optional[str] = Field(None, description="Creatomate 렌더 ID")
|
||||
message: str = Field(..., description="응답 메시지")
|
||||
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
|
||||
|
||||
|
||||
class VideoRenderData(BaseModel):
|
||||
"""Creatomate 렌더링 결과 데이터"""
|
||||
|
||||
id: Optional[str] = Field(None, description="렌더 ID")
|
||||
status: Optional[str] = Field(None, description="렌더 상태")
|
||||
url: Optional[str] = Field(None, description="영상 URL")
|
||||
snapshot_url: Optional[str] = Field(None, description="스냅샷 URL")
|
||||
|
||||
|
||||
class PollingVideoResponse(BaseModel):
|
||||
"""영상 생성 상태 조회 응답 스키마
|
||||
|
||||
Usage:
|
||||
GET /video/status/{creatomate_render_id}
|
||||
Creatomate API 작업 상태를 조회합니다.
|
||||
|
||||
Note:
|
||||
상태 값:
|
||||
- planned: 예약됨
|
||||
- waiting: 대기 중
|
||||
- transcribing: 트랜스크립션 중
|
||||
- rendering: 렌더링 중
|
||||
- succeeded: 성공
|
||||
- failed: 실패
|
||||
|
||||
Example Response (Success):
|
||||
{
|
||||
"success": true,
|
||||
"status": "succeeded",
|
||||
"message": "영상 생성이 완료되었습니다.",
|
||||
"render_data": {
|
||||
"id": "render-id",
|
||||
"status": "succeeded",
|
||||
"url": "https://...",
|
||||
"snapshot_url": "https://..."
|
||||
},
|
||||
"raw_response": {...},
|
||||
"error_message": null
|
||||
}
|
||||
"""
|
||||
|
||||
success: bool = Field(..., description="조회 성공 여부")
|
||||
status: Optional[str] = Field(
|
||||
None, description="작업 상태 (planned, waiting, rendering, succeeded, failed)"
|
||||
)
|
||||
message: str = Field(..., description="상태 메시지")
|
||||
render_data: Optional[VideoRenderData] = Field(None, description="렌더링 결과 데이터")
|
||||
raw_response: Optional[Dict[str, Any]] = Field(None, description="Creatomate API 원본 응답")
|
||||
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
|
||||
|
||||
|
||||
class DownloadVideoResponse(BaseModel):
|
||||
"""영상 다운로드 응답 스키마
|
||||
|
||||
Usage:
|
||||
GET /video/download/{task_id}
|
||||
Polls for video completion and returns project info with video URL.
|
||||
|
||||
Note:
|
||||
상태 값:
|
||||
- processing: 영상 생성 진행 중 (result_movie_url은 null)
|
||||
- completed: 영상 생성 완료 (result_movie_url 포함)
|
||||
- failed: 영상 생성 실패
|
||||
- not_found: task_id에 해당하는 Video 없음
|
||||
- error: 조회 중 오류 발생
|
||||
|
||||
Example Response (Completed):
|
||||
{
|
||||
"success": true,
|
||||
"status": "completed",
|
||||
"message": "영상 다운로드가 완료되었습니다.",
|
||||
"store_name": "스테이 머뭄",
|
||||
"region": "군산",
|
||||
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
||||
"result_movie_url": "http://localhost:8000/media/2025-01-15/video.mp4",
|
||||
"created_at": "2025-01-15T12:00:00",
|
||||
"error_message": null
|
||||
}
|
||||
"""
|
||||
|
||||
success: bool = Field(..., description="다운로드 성공 여부")
|
||||
status: str = Field(..., description="처리 상태 (processing, completed, failed, not_found, error)")
|
||||
message: str = Field(..., description="응답 메시지")
|
||||
store_name: Optional[str] = Field(None, description="업체명")
|
||||
region: Optional[str] = Field(None, description="지역명")
|
||||
task_id: Optional[str] = Field(None, description="작업 고유 식별자")
|
||||
result_movie_url: Optional[str] = Field(None, description="영상 결과 URL")
|
||||
created_at: Optional[datetime] = Field(None, description="생성 일시")
|
||||
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
|
||||
|
||||
|
||||
class VideoListItem(BaseModel):
|
||||
"""영상 목록 아이템 스키마
|
||||
|
||||
Usage:
|
||||
GET /videos 응답의 개별 영상 정보
|
||||
|
||||
Example:
|
||||
{
|
||||
"store_name": "스테이 머뭄",
|
||||
"region": "군산",
|
||||
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
||||
"result_movie_url": "http://localhost:8000/media/2025-01-15/video.mp4",
|
||||
"created_at": "2025-01-15T12:00:00"
|
||||
}
|
||||
"""
|
||||
|
||||
store_name: Optional[str] = Field(None, description="업체명")
|
||||
region: Optional[str] = Field(None, description="지역명")
|
||||
task_id: str = Field(..., description="작업 고유 식별자")
|
||||
result_movie_url: Optional[str] = Field(None, description="영상 결과 URL")
|
||||
created_at: Optional[datetime] = Field(None, description="생성 일시")
|
||||
"""
|
||||
Video API Schemas
|
||||
|
||||
영상 생성 관련 Pydantic 스키마를 정의합니다.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Response Schemas
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class GenerateVideoResponse(BaseModel):
|
||||
"""영상 생성 응답 스키마
|
||||
|
||||
Usage:
|
||||
GET /video/generate/{task_id}
|
||||
Returns the task IDs for tracking video generation.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"success": True,
|
||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
||||
"creatomate_render_id": "render-id-123456",
|
||||
"message": "영상 생성 요청이 접수되었습니다. creatomate_render_id로 상태를 조회하세요.",
|
||||
"error_message": None,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
success: bool = Field(..., description="요청 성공 여부")
|
||||
task_id: Optional[str] = Field(None, description="내부 작업 ID (Project task_id)")
|
||||
creatomate_render_id: Optional[str] = Field(None, description="Creatomate 렌더 ID")
|
||||
message: str = Field(..., description="응답 메시지")
|
||||
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
|
||||
|
||||
|
||||
class VideoRenderData(BaseModel):
|
||||
"""Creatomate 렌더링 결과 데이터"""
|
||||
|
||||
id: Optional[str] = Field(None, description="렌더 ID")
|
||||
status: Optional[str] = Field(None, description="렌더 상태")
|
||||
url: Optional[str] = Field(None, description="영상 URL")
|
||||
snapshot_url: Optional[str] = Field(None, description="스냅샷 URL")
|
||||
|
||||
|
||||
class PollingVideoResponse(BaseModel):
|
||||
"""영상 생성 상태 조회 응답 스키마
|
||||
|
||||
Usage:
|
||||
GET /video/status/{creatomate_render_id}
|
||||
Creatomate API 작업 상태를 조회합니다.
|
||||
|
||||
Note:
|
||||
상태 값:
|
||||
- planned: 예약됨
|
||||
- waiting: 대기 중
|
||||
- transcribing: 트랜스크립션 중
|
||||
- rendering: 렌더링 중
|
||||
- succeeded: 성공
|
||||
- failed: 실패
|
||||
|
||||
Example Response (Success):
|
||||
{
|
||||
"success": true,
|
||||
"status": "succeeded",
|
||||
"message": "영상 생성이 완료되었습니다.",
|
||||
"render_data": {
|
||||
"id": "render-id",
|
||||
"status": "succeeded",
|
||||
"url": "https://...",
|
||||
"snapshot_url": "https://..."
|
||||
},
|
||||
"raw_response": {...},
|
||||
"error_message": null
|
||||
}
|
||||
"""
|
||||
|
||||
success: bool = Field(..., description="조회 성공 여부")
|
||||
status: Optional[str] = Field(
|
||||
None, description="작업 상태 (planned, waiting, rendering, succeeded, failed)"
|
||||
)
|
||||
message: str = Field(..., description="상태 메시지")
|
||||
render_data: Optional[VideoRenderData] = Field(None, description="렌더링 결과 데이터")
|
||||
raw_response: Optional[Dict[str, Any]] = Field(None, description="Creatomate API 원본 응답")
|
||||
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
|
||||
|
||||
|
||||
class DownloadVideoResponse(BaseModel):
|
||||
"""영상 다운로드 응답 스키마
|
||||
|
||||
Usage:
|
||||
GET /video/download/{task_id}
|
||||
Polls for video completion and returns project info with video URL.
|
||||
|
||||
Note:
|
||||
상태 값:
|
||||
- processing: 영상 생성 진행 중 (result_movie_url은 null)
|
||||
- completed: 영상 생성 완료 (result_movie_url 포함)
|
||||
- failed: 영상 생성 실패
|
||||
- not_found: task_id에 해당하는 Video 없음
|
||||
- error: 조회 중 오류 발생
|
||||
|
||||
Example Response (Completed):
|
||||
{
|
||||
"success": true,
|
||||
"status": "completed",
|
||||
"message": "영상 다운로드가 완료되었습니다.",
|
||||
"store_name": "스테이 머뭄",
|
||||
"region": "군산",
|
||||
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
||||
"result_movie_url": "http://localhost:8000/media/2025-01-15/video.mp4",
|
||||
"created_at": "2025-01-15T12:00:00",
|
||||
"error_message": null
|
||||
}
|
||||
"""
|
||||
|
||||
success: bool = Field(..., description="다운로드 성공 여부")
|
||||
status: str = Field(..., description="처리 상태 (processing, completed, failed, not_found, error)")
|
||||
message: str = Field(..., description="응답 메시지")
|
||||
store_name: Optional[str] = Field(None, description="업체명")
|
||||
region: Optional[str] = Field(None, description="지역명")
|
||||
task_id: Optional[str] = Field(None, description="작업 고유 식별자")
|
||||
result_movie_url: Optional[str] = Field(None, description="영상 결과 URL")
|
||||
created_at: Optional[datetime] = Field(None, description="생성 일시")
|
||||
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
|
||||
|
||||
|
||||
class VideoListItem(BaseModel):
|
||||
"""영상 목록 아이템 스키마
|
||||
|
||||
Usage:
|
||||
GET /videos 응답의 개별 영상 정보
|
||||
|
||||
Example:
|
||||
{
|
||||
"store_name": "스테이 머뭄",
|
||||
"region": "군산",
|
||||
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
||||
"result_movie_url": "http://localhost:8000/media/2025-01-15/video.mp4",
|
||||
"created_at": "2025-01-15T12:00:00"
|
||||
}
|
||||
"""
|
||||
|
||||
store_name: Optional[str] = Field(None, description="업체명")
|
||||
region: Optional[str] = Field(None, description="지역명")
|
||||
task_id: str = Field(..., description="작업 고유 식별자")
|
||||
result_movie_url: Optional[str] = Field(None, description="영상 결과 URL")
|
||||
created_at: Optional[datetime] = Field(None, description="생성 일시")
|
||||
|
|
|
|||
|
|
@ -1,242 +1,333 @@
|
|||
"""
|
||||
Video Background Tasks
|
||||
|
||||
영상 생성 관련 백그라운드 태스크를 정의합니다.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import aiofiles
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database.session import BackgroundSessionLocal
|
||||
from app.video.models import Video
|
||||
from app.utils.upload_blob_as_request import AzureBlobUploader
|
||||
|
||||
|
||||
async def download_and_upload_video_to_blob(
|
||||
task_id: str,
|
||||
video_url: str,
|
||||
store_name: str,
|
||||
) -> None:
|
||||
"""백그라운드에서 영상을 다운로드하고 Azure Blob Storage에 업로드한 뒤 Video 테이블을 업데이트합니다.
|
||||
|
||||
Args:
|
||||
task_id: 프로젝트 task_id
|
||||
video_url: 다운로드할 영상 URL
|
||||
store_name: 저장할 파일명에 사용할 업체명
|
||||
"""
|
||||
print(f"[download_and_upload_video_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 "video"
|
||||
file_name = f"{safe_store_name}.mp4"
|
||||
|
||||
# 임시 저장 경로 생성
|
||||
temp_dir = Path("media") / "temp" / task_id
|
||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
temp_file_path = temp_dir / file_name
|
||||
print(f"[download_and_upload_video_to_blob] Temp directory created - path: {temp_file_path}")
|
||||
|
||||
# 영상 파일 다운로드
|
||||
print(f"[download_and_upload_video_to_blob] Downloading video - task_id: {task_id}, url: {video_url}")
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(video_url, timeout=180.0)
|
||||
response.raise_for_status()
|
||||
|
||||
async with aiofiles.open(str(temp_file_path), "wb") as f:
|
||||
await f.write(response.content)
|
||||
print(f"[download_and_upload_video_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_video(file_path=str(temp_file_path))
|
||||
|
||||
if not upload_success:
|
||||
raise Exception("Azure Blob Storage 업로드 실패")
|
||||
|
||||
# SAS 토큰이 제외된 public_url 사용
|
||||
blob_url = uploader.public_url
|
||||
print(f"[download_and_upload_video_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}")
|
||||
|
||||
# Video 테이블 업데이트 (새 세션 사용)
|
||||
async with BackgroundSessionLocal() as session:
|
||||
# 여러 개 있을 경우 가장 최근 것 선택
|
||||
result = await session.execute(
|
||||
select(Video)
|
||||
.where(Video.task_id == task_id)
|
||||
.order_by(Video.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
video = result.scalar_one_or_none()
|
||||
|
||||
if video:
|
||||
video.status = "completed"
|
||||
video.result_movie_url = blob_url
|
||||
await session.commit()
|
||||
print(f"[download_and_upload_video_to_blob] SUCCESS - task_id: {task_id}, status: completed")
|
||||
else:
|
||||
print(f"[download_and_upload_video_to_blob] Video NOT FOUND in DB - task_id: {task_id}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"[download_and_upload_video_to_blob] EXCEPTION - task_id: {task_id}, error: {e}")
|
||||
# 실패 시 Video 테이블 업데이트
|
||||
async with BackgroundSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(Video)
|
||||
.where(Video.task_id == task_id)
|
||||
.order_by(Video.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
video = result.scalar_one_or_none()
|
||||
|
||||
if video:
|
||||
video.status = "failed"
|
||||
await session.commit()
|
||||
print(f"[download_and_upload_video_to_blob] FAILED - task_id: {task_id}, status updated to failed")
|
||||
|
||||
finally:
|
||||
# 임시 파일 삭제
|
||||
if temp_file_path and temp_file_path.exists():
|
||||
try:
|
||||
temp_file_path.unlink()
|
||||
print(f"[download_and_upload_video_to_blob] Temp file deleted - path: {temp_file_path}")
|
||||
except Exception as e:
|
||||
print(f"[download_and_upload_video_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_video_by_creatomate_render_id(
|
||||
creatomate_render_id: str,
|
||||
video_url: str,
|
||||
store_name: str,
|
||||
) -> None:
|
||||
"""creatomate_render_id로 Video를 조회하여 영상을 다운로드하고 Azure Blob Storage에 업로드한 뒤 Video 테이블을 업데이트합니다.
|
||||
|
||||
Args:
|
||||
creatomate_render_id: Creatomate API 렌더 ID
|
||||
video_url: 다운로드할 영상 URL
|
||||
store_name: 저장할 파일명에 사용할 업체명
|
||||
"""
|
||||
print(f"[download_and_upload_video_by_creatomate_render_id] START - creatomate_render_id: {creatomate_render_id}, store_name: {store_name}")
|
||||
temp_file_path: Path | None = None
|
||||
task_id: str | None = None
|
||||
|
||||
try:
|
||||
# creatomate_render_id로 Video 조회하여 task_id 가져오기
|
||||
async with BackgroundSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(Video)
|
||||
.where(Video.creatomate_render_id == creatomate_render_id)
|
||||
.order_by(Video.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
video = result.scalar_one_or_none()
|
||||
|
||||
if not video:
|
||||
print(f"[download_and_upload_video_by_creatomate_render_id] Video NOT FOUND - creatomate_render_id: {creatomate_render_id}")
|
||||
return
|
||||
|
||||
task_id = video.task_id
|
||||
print(f"[download_and_upload_video_by_creatomate_render_id] Video found - creatomate_render_id: {creatomate_render_id}, task_id: {task_id}")
|
||||
|
||||
# 파일명에 사용할 수 없는 문자 제거
|
||||
safe_store_name = "".join(
|
||||
c for c in store_name if c.isalnum() or c in (" ", "_", "-")
|
||||
).strip()
|
||||
safe_store_name = safe_store_name or "video"
|
||||
file_name = f"{safe_store_name}.mp4"
|
||||
|
||||
# 임시 저장 경로 생성
|
||||
temp_dir = Path("media") / "temp" / task_id
|
||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
temp_file_path = temp_dir / file_name
|
||||
print(f"[download_and_upload_video_by_creatomate_render_id] Temp directory created - path: {temp_file_path}")
|
||||
|
||||
# 영상 파일 다운로드
|
||||
print(f"[download_and_upload_video_by_creatomate_render_id] Downloading video - creatomate_render_id: {creatomate_render_id}, url: {video_url}")
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(video_url, timeout=180.0)
|
||||
response.raise_for_status()
|
||||
|
||||
async with aiofiles.open(str(temp_file_path), "wb") as f:
|
||||
await f.write(response.content)
|
||||
print(f"[download_and_upload_video_by_creatomate_render_id] File downloaded - creatomate_render_id: {creatomate_render_id}, path: {temp_file_path}")
|
||||
|
||||
# Azure Blob Storage에 업로드
|
||||
uploader = AzureBlobUploader(task_id=task_id)
|
||||
upload_success = await uploader.upload_video(file_path=str(temp_file_path))
|
||||
|
||||
if not upload_success:
|
||||
raise Exception("Azure Blob Storage 업로드 실패")
|
||||
|
||||
# SAS 토큰이 제외된 public_url 사용
|
||||
blob_url = uploader.public_url
|
||||
print(f"[download_and_upload_video_by_creatomate_render_id] Uploaded to Blob - creatomate_render_id: {creatomate_render_id}, url: {blob_url}")
|
||||
|
||||
# Video 테이블 업데이트 (새 세션 사용)
|
||||
async with BackgroundSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(Video)
|
||||
.where(Video.creatomate_render_id == creatomate_render_id)
|
||||
.order_by(Video.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
video = result.scalar_one_or_none()
|
||||
|
||||
if video:
|
||||
video.status = "completed"
|
||||
video.result_movie_url = blob_url
|
||||
await session.commit()
|
||||
print(f"[download_and_upload_video_by_creatomate_render_id] SUCCESS - creatomate_render_id: {creatomate_render_id}, status: completed")
|
||||
else:
|
||||
print(f"[download_and_upload_video_by_creatomate_render_id] Video NOT FOUND in DB - creatomate_render_id: {creatomate_render_id}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"[download_and_upload_video_by_creatomate_render_id] EXCEPTION - creatomate_render_id: {creatomate_render_id}, error: {e}")
|
||||
# 실패 시 Video 테이블 업데이트
|
||||
if task_id:
|
||||
async with BackgroundSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(Video)
|
||||
.where(Video.creatomate_render_id == creatomate_render_id)
|
||||
.order_by(Video.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
video = result.scalar_one_or_none()
|
||||
|
||||
if video:
|
||||
video.status = "failed"
|
||||
await session.commit()
|
||||
print(f"[download_and_upload_video_by_creatomate_render_id] FAILED - creatomate_render_id: {creatomate_render_id}, status updated to failed")
|
||||
|
||||
finally:
|
||||
# 임시 파일 삭제
|
||||
if temp_file_path and temp_file_path.exists():
|
||||
try:
|
||||
temp_file_path.unlink()
|
||||
print(f"[download_and_upload_video_by_creatomate_render_id] Temp file deleted - path: {temp_file_path}")
|
||||
except Exception as e:
|
||||
print(f"[download_and_upload_video_by_creatomate_render_id] Failed to delete temp file: {e}")
|
||||
|
||||
# 임시 디렉토리 삭제 시도
|
||||
if task_id:
|
||||
temp_dir = Path("media") / "temp" / task_id
|
||||
if temp_dir.exists():
|
||||
try:
|
||||
temp_dir.rmdir()
|
||||
except Exception:
|
||||
pass # 디렉토리가 비어있지 않으면 무시
|
||||
"""
|
||||
Video Background Tasks
|
||||
|
||||
영상 생성 관련 백그라운드 태스크를 정의합니다.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
|
||||
import aiofiles
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from app.database.session import BackgroundSessionLocal
|
||||
from app.video.models import Video
|
||||
from app.utils.upload_blob_as_request import AzureBlobUploader
|
||||
|
||||
# 로거 설정
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# HTTP 요청 설정
|
||||
REQUEST_TIMEOUT = 300.0 # 초 (영상은 용량이 크므로 5분)
|
||||
|
||||
|
||||
async def _update_video_status(
|
||||
task_id: str,
|
||||
status: str,
|
||||
video_url: str | None = None,
|
||||
creatomate_render_id: str | None = None,
|
||||
) -> bool:
|
||||
"""Video 테이블의 상태를 업데이트합니다.
|
||||
|
||||
Args:
|
||||
task_id: 프로젝트 task_id
|
||||
status: 변경할 상태 ("processing", "completed", "failed")
|
||||
video_url: 영상 URL
|
||||
creatomate_render_id: Creatomate render ID (선택)
|
||||
|
||||
Returns:
|
||||
bool: 업데이트 성공 여부
|
||||
"""
|
||||
try:
|
||||
async with BackgroundSessionLocal() as session:
|
||||
if creatomate_render_id:
|
||||
query_result = await session.execute(
|
||||
select(Video)
|
||||
.where(Video.creatomate_render_id == creatomate_render_id)
|
||||
.order_by(Video.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
else:
|
||||
query_result = await session.execute(
|
||||
select(Video)
|
||||
.where(Video.task_id == task_id)
|
||||
.order_by(Video.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
video = query_result.scalar_one_or_none()
|
||||
|
||||
if video:
|
||||
video.status = status
|
||||
if video_url is not None:
|
||||
video.result_movie_url = video_url
|
||||
await session.commit()
|
||||
logger.info(f"[Video] Status updated - task_id: {task_id}, status: {status}")
|
||||
print(f"[Video] Status updated - task_id: {task_id}, status: {status}")
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"[Video] NOT FOUND in DB - task_id: {task_id}")
|
||||
print(f"[Video] NOT FOUND in DB - task_id: {task_id}")
|
||||
return False
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"[Video] DB Error while updating status - task_id: {task_id}, error: {e}")
|
||||
print(f"[Video] DB Error while updating status - task_id: {task_id}, error: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"[Video] Unexpected error while updating status - task_id: {task_id}, error: {e}")
|
||||
print(f"[Video] Unexpected error while updating status - task_id: {task_id}, error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def _download_video(url: str, task_id: str) -> bytes:
|
||||
"""URL에서 영상을 다운로드합니다.
|
||||
|
||||
Args:
|
||||
url: 다운로드할 URL
|
||||
task_id: 로그용 task_id
|
||||
|
||||
Returns:
|
||||
bytes: 다운로드한 파일 내용
|
||||
|
||||
Raises:
|
||||
httpx.HTTPError: 다운로드 실패 시
|
||||
"""
|
||||
logger.info(f"[VideoDownload] Downloading - task_id: {task_id}")
|
||||
print(f"[VideoDownload] Downloading - task_id: {task_id}")
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(url, timeout=REQUEST_TIMEOUT)
|
||||
response.raise_for_status()
|
||||
|
||||
logger.info(f"[VideoDownload] SUCCESS - task_id: {task_id}, size: {len(response.content)} bytes")
|
||||
print(f"[VideoDownload] SUCCESS - task_id: {task_id}, size: {len(response.content)} bytes")
|
||||
return response.content
|
||||
|
||||
|
||||
async def download_and_upload_video_to_blob(
|
||||
task_id: str,
|
||||
video_url: str,
|
||||
store_name: str,
|
||||
) -> None:
|
||||
"""백그라운드에서 영상을 다운로드하고 Azure Blob Storage에 업로드한 뒤 Video 테이블을 업데이트합니다.
|
||||
|
||||
Args:
|
||||
task_id: 프로젝트 task_id
|
||||
video_url: 다운로드할 영상 URL
|
||||
store_name: 저장할 파일명에 사용할 업체명
|
||||
"""
|
||||
logger.info(f"[download_and_upload_video_to_blob] START - task_id: {task_id}, store_name: {store_name}")
|
||||
print(f"[download_and_upload_video_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 "video"
|
||||
file_name = f"{safe_store_name}.mp4"
|
||||
|
||||
# 임시 저장 경로 생성
|
||||
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_video_to_blob] Temp directory created - path: {temp_file_path}")
|
||||
print(f"[download_and_upload_video_to_blob] Temp directory created - path: {temp_file_path}")
|
||||
|
||||
# 영상 파일 다운로드
|
||||
logger.info(f"[download_and_upload_video_to_blob] Downloading video - task_id: {task_id}, url: {video_url}")
|
||||
print(f"[download_and_upload_video_to_blob] Downloading video - task_id: {task_id}, url: {video_url}")
|
||||
|
||||
content = await _download_video(video_url, task_id)
|
||||
|
||||
async with aiofiles.open(str(temp_file_path), "wb") as f:
|
||||
await f.write(content)
|
||||
|
||||
logger.info(f"[download_and_upload_video_to_blob] File downloaded - task_id: {task_id}, path: {temp_file_path}")
|
||||
print(f"[download_and_upload_video_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_video(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_video_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}")
|
||||
print(f"[download_and_upload_video_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}")
|
||||
|
||||
# Video 테이블 업데이트
|
||||
await _update_video_status(task_id, "completed", blob_url)
|
||||
logger.info(f"[download_and_upload_video_to_blob] SUCCESS - task_id: {task_id}")
|
||||
print(f"[download_and_upload_video_to_blob] SUCCESS - task_id: {task_id}")
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"[download_and_upload_video_to_blob] DOWNLOAD ERROR - task_id: {task_id}, error: {e}")
|
||||
print(f"[download_and_upload_video_to_blob] DOWNLOAD ERROR - task_id: {task_id}, error: {e}")
|
||||
traceback.print_exc()
|
||||
await _update_video_status(task_id, "failed")
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"[download_and_upload_video_to_blob] DB ERROR - task_id: {task_id}, error: {e}")
|
||||
print(f"[download_and_upload_video_to_blob] DB ERROR - task_id: {task_id}, error: {e}")
|
||||
traceback.print_exc()
|
||||
await _update_video_status(task_id, "failed")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[download_and_upload_video_to_blob] EXCEPTION - task_id: {task_id}, error: {e}")
|
||||
print(f"[download_and_upload_video_to_blob] EXCEPTION - task_id: {task_id}, error: {e}")
|
||||
traceback.print_exc()
|
||||
await _update_video_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_video_to_blob] Temp file deleted - path: {temp_file_path}")
|
||||
print(f"[download_and_upload_video_to_blob] Temp file deleted - path: {temp_file_path}")
|
||||
except Exception as e:
|
||||
logger.warning(f"[download_and_upload_video_to_blob] Failed to delete temp file: {e}")
|
||||
print(f"[download_and_upload_video_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_video_by_creatomate_render_id(
|
||||
creatomate_render_id: str,
|
||||
video_url: str,
|
||||
store_name: str,
|
||||
) -> None:
|
||||
"""creatomate_render_id로 Video를 조회하여 영상을 다운로드하고 Azure Blob Storage에 업로드한 뒤 Video 테이블을 업데이트합니다.
|
||||
|
||||
Args:
|
||||
creatomate_render_id: Creatomate API 렌더 ID
|
||||
video_url: 다운로드할 영상 URL
|
||||
store_name: 저장할 파일명에 사용할 업체명
|
||||
"""
|
||||
logger.info(f"[download_and_upload_video_by_creatomate_render_id] START - creatomate_render_id: {creatomate_render_id}, store_name: {store_name}")
|
||||
print(f"[download_and_upload_video_by_creatomate_render_id] START - creatomate_render_id: {creatomate_render_id}, store_name: {store_name}")
|
||||
temp_file_path: Path | None = None
|
||||
task_id: str | None = None
|
||||
|
||||
try:
|
||||
# creatomate_render_id로 Video 조회하여 task_id 가져오기
|
||||
async with BackgroundSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(Video)
|
||||
.where(Video.creatomate_render_id == creatomate_render_id)
|
||||
.order_by(Video.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
video = result.scalar_one_or_none()
|
||||
|
||||
if not video:
|
||||
logger.warning(f"[download_and_upload_video_by_creatomate_render_id] Video NOT FOUND - creatomate_render_id: {creatomate_render_id}")
|
||||
print(f"[download_and_upload_video_by_creatomate_render_id] Video NOT FOUND - creatomate_render_id: {creatomate_render_id}")
|
||||
return
|
||||
|
||||
task_id = video.task_id
|
||||
logger.info(f"[download_and_upload_video_by_creatomate_render_id] Video found - creatomate_render_id: {creatomate_render_id}, task_id: {task_id}")
|
||||
print(f"[download_and_upload_video_by_creatomate_render_id] Video found - creatomate_render_id: {creatomate_render_id}, task_id: {task_id}")
|
||||
|
||||
# 파일명에 사용할 수 없는 문자 제거
|
||||
safe_store_name = "".join(
|
||||
c for c in store_name if c.isalnum() or c in (" ", "_", "-")
|
||||
).strip()
|
||||
safe_store_name = safe_store_name or "video"
|
||||
file_name = f"{safe_store_name}.mp4"
|
||||
|
||||
# 임시 저장 경로 생성
|
||||
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_video_by_creatomate_render_id] Temp directory created - path: {temp_file_path}")
|
||||
print(f"[download_and_upload_video_by_creatomate_render_id] Temp directory created - path: {temp_file_path}")
|
||||
|
||||
# 영상 파일 다운로드
|
||||
logger.info(f"[download_and_upload_video_by_creatomate_render_id] Downloading video - creatomate_render_id: {creatomate_render_id}, url: {video_url}")
|
||||
print(f"[download_and_upload_video_by_creatomate_render_id] Downloading video - creatomate_render_id: {creatomate_render_id}, url: {video_url}")
|
||||
|
||||
content = await _download_video(video_url, task_id)
|
||||
|
||||
async with aiofiles.open(str(temp_file_path), "wb") as f:
|
||||
await f.write(content)
|
||||
|
||||
logger.info(f"[download_and_upload_video_by_creatomate_render_id] File downloaded - creatomate_render_id: {creatomate_render_id}, path: {temp_file_path}")
|
||||
print(f"[download_and_upload_video_by_creatomate_render_id] File downloaded - creatomate_render_id: {creatomate_render_id}, path: {temp_file_path}")
|
||||
|
||||
# Azure Blob Storage에 업로드
|
||||
uploader = AzureBlobUploader(task_id=task_id)
|
||||
upload_success = await uploader.upload_video(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_video_by_creatomate_render_id] Uploaded to Blob - creatomate_render_id: {creatomate_render_id}, url: {blob_url}")
|
||||
print(f"[download_and_upload_video_by_creatomate_render_id] Uploaded to Blob - creatomate_render_id: {creatomate_render_id}, url: {blob_url}")
|
||||
|
||||
# Video 테이블 업데이트
|
||||
await _update_video_status(
|
||||
task_id=task_id,
|
||||
status="completed",
|
||||
video_url=blob_url,
|
||||
creatomate_render_id=creatomate_render_id,
|
||||
)
|
||||
logger.info(f"[download_and_upload_video_by_creatomate_render_id] SUCCESS - creatomate_render_id: {creatomate_render_id}")
|
||||
print(f"[download_and_upload_video_by_creatomate_render_id] SUCCESS - creatomate_render_id: {creatomate_render_id}")
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"[download_and_upload_video_by_creatomate_render_id] DOWNLOAD ERROR - creatomate_render_id: {creatomate_render_id}, error: {e}")
|
||||
print(f"[download_and_upload_video_by_creatomate_render_id] DOWNLOAD ERROR - creatomate_render_id: {creatomate_render_id}, error: {e}")
|
||||
traceback.print_exc()
|
||||
if task_id:
|
||||
await _update_video_status(task_id, "failed", creatomate_render_id=creatomate_render_id)
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"[download_and_upload_video_by_creatomate_render_id] DB ERROR - creatomate_render_id: {creatomate_render_id}, error: {e}")
|
||||
print(f"[download_and_upload_video_by_creatomate_render_id] DB ERROR - creatomate_render_id: {creatomate_render_id}, error: {e}")
|
||||
traceback.print_exc()
|
||||
if task_id:
|
||||
await _update_video_status(task_id, "failed", creatomate_render_id=creatomate_render_id)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[download_and_upload_video_by_creatomate_render_id] EXCEPTION - creatomate_render_id: {creatomate_render_id}, error: {e}")
|
||||
print(f"[download_and_upload_video_by_creatomate_render_id] EXCEPTION - creatomate_render_id: {creatomate_render_id}, error: {e}")
|
||||
traceback.print_exc()
|
||||
if task_id:
|
||||
await _update_video_status(task_id, "failed", creatomate_render_id=creatomate_render_id)
|
||||
|
||||
finally:
|
||||
# 임시 파일 삭제
|
||||
if temp_file_path and temp_file_path.exists():
|
||||
try:
|
||||
temp_file_path.unlink()
|
||||
logger.info(f"[download_and_upload_video_by_creatomate_render_id] Temp file deleted - path: {temp_file_path}")
|
||||
print(f"[download_and_upload_video_by_creatomate_render_id] Temp file deleted - path: {temp_file_path}")
|
||||
except Exception as e:
|
||||
logger.warning(f"[download_and_upload_video_by_creatomate_render_id] Failed to delete temp file: {e}")
|
||||
print(f"[download_and_upload_video_by_creatomate_render_id] Failed to delete temp file: {e}")
|
||||
|
||||
# 임시 디렉토리 삭제 시도
|
||||
if task_id:
|
||||
temp_dir = Path("media") / "temp" / task_id
|
||||
if temp_dir.exists():
|
||||
try:
|
||||
temp_dir.rmdir()
|
||||
except Exception:
|
||||
pass # 디렉토리가 비어있지 않으면 무시
|
||||
|
|
|
|||
358
config.py
|
|
@ -1,179 +1,179 @@
|
|||
from pathlib import Path
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
PROJECT_DIR = Path(__file__).resolve().parent
|
||||
|
||||
_base_config = SettingsConfigDict(
|
||||
env_file=PROJECT_DIR / ".env",
|
||||
env_ignore_empty=True,
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
|
||||
class ProjectSettings(BaseSettings):
|
||||
PROJECT_NAME: str = Field(default="CastAD")
|
||||
PROJECT_DOMAIN: str = Field(default="localhost:8000")
|
||||
VERSION: str = Field(default="0.1.0")
|
||||
DESCRIPTION: str = Field(default="FastAPI 기반 CastAD 프로젝트")
|
||||
ADMIN_BASE_URL: str = Field(default="/admin")
|
||||
DEBUG: bool = Field(default=True)
|
||||
|
||||
model_config = _base_config
|
||||
|
||||
|
||||
class APIKeySettings(BaseSettings):
|
||||
CHATGPT_API_KEY: str = Field(default="your-chatgpt-api-key") # 기본값 추가
|
||||
SUNO_API_KEY: str = Field(default="your-suno-api-key") # Suno API 키
|
||||
SUNO_CALLBACK_URL: str = Field(
|
||||
default="https://example.com/api/suno/callback"
|
||||
) # Suno 콜백 URL (필수)
|
||||
CREATOMATE_API_KEY: str = Field(default="your-creatomate-api-key") # Creatomate API 키
|
||||
|
||||
model_config = _base_config
|
||||
|
||||
|
||||
class CORSSettings(BaseSettings):
|
||||
# CORS (Cross-Origin Resource Sharing) 설정
|
||||
|
||||
# 요청을 허용할 출처(Origin) 목록
|
||||
# ["*"]: 모든 출처 허용 (개발 환경용, 프로덕션에서는 구체적인 도메인 지정 권장)
|
||||
# 예: ["https://example.com", "https://app.example.com"]
|
||||
CORS_ALLOW_ORIGINS: list[str] = ["*"]
|
||||
|
||||
# 자격 증명(쿠키, Authorization 헤더 등) 포함 요청 허용 여부
|
||||
# True: 클라이언트가 credentials: 'include'로 요청 시 쿠키/인증 정보 전송 가능
|
||||
# 주의: CORS_ALLOW_ORIGINS가 ["*"]일 때는 보안상 False 권장
|
||||
CORS_ALLOW_CREDENTIALS: bool = True
|
||||
|
||||
# 허용할 HTTP 메서드 목록
|
||||
# ["*"]: 모든 메서드 허용 (GET, POST, PUT, DELETE, PATCH, OPTIONS 등)
|
||||
# 구체적 지정 예: ["GET", "POST", "PUT", "DELETE"]
|
||||
CORS_ALLOW_METHODS: list[str] = ["*"]
|
||||
|
||||
# 클라이언트가 요청 시 사용할 수 있는 HTTP 헤더 목록
|
||||
# ["*"]: 모든 헤더 허용
|
||||
# 구체적 지정 예: ["Content-Type", "Authorization", "X-Custom-Header"]
|
||||
CORS_ALLOW_HEADERS: list[str] = ["*"]
|
||||
|
||||
# 브라우저의 JavaScript에서 접근 가능한 응답 헤더 목록
|
||||
# []: 기본 안전 헤더(Cache-Control, Content-Language, Content-Type,
|
||||
# Expires, Last-Modified, Pragma)만 접근 가능
|
||||
# 추가 노출 필요 시: ["X-Total-Count", "X-Request-Id", "X-Custom-Header"]
|
||||
CORS_EXPOSE_HEADERS: list[str] = []
|
||||
|
||||
# Preflight 요청(OPTIONS) 결과를 캐시하는 시간(초)
|
||||
# 600: 10분간 캐시 (이 시간 동안 동일 요청에 대해 preflight 생략)
|
||||
# 0으로 설정 시 매번 preflight 요청 발생
|
||||
CORS_MAX_AGE: int = 600
|
||||
|
||||
model_config = _base_config
|
||||
|
||||
|
||||
class DatabaseSettings(BaseSettings):
|
||||
# MySQL 연결 설정 (기본값: 테스트 계정 및 poc DB)
|
||||
MYSQL_HOST: str = Field(default="localhost")
|
||||
MYSQL_PORT: int = Field(default=3306)
|
||||
MYSQL_USER: str = Field(default="test")
|
||||
MYSQL_PASSWORD: str = Field(default="") # 환경변수에서 로드
|
||||
MYSQL_DB: str = Field(default="poc")
|
||||
|
||||
# Redis 설정
|
||||
REDIS_HOST: str = "localhost"
|
||||
REDIS_PORT: int = 6379
|
||||
|
||||
model_config = _base_config
|
||||
|
||||
@property
|
||||
def MYSQL_URL(self) -> str:
|
||||
"""비동기 MySQL URL 생성 (asyncmy 드라이버 사용, SQLAlchemy 통합 최적화)"""
|
||||
return f"mysql+asyncmy://{self.MYSQL_USER}:{self.MYSQL_PASSWORD}@{self.MYSQL_HOST}:{self.MYSQL_PORT}/{self.MYSQL_DB}"
|
||||
|
||||
def REDIS_URL(self, db: int = 0) -> str:
|
||||
"""Redis URL 생성 (db 인수로 기본값 지원)"""
|
||||
return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{db}"
|
||||
|
||||
|
||||
class SecuritySettings(BaseSettings):
|
||||
JWT_SECRET: str = "your-jwt-secret-key" # 기본값 추가 (필수 필드 안전)
|
||||
JWT_ALGORITHM: str = "HS256" # 기본값 추가 (필수 필드 안전)
|
||||
|
||||
model_config = _base_config
|
||||
|
||||
|
||||
class NotificationSettings(BaseSettings):
|
||||
MAIL_USERNAME: str = "your-email@example.com" # 기본값 추가
|
||||
MAIL_PASSWORD: str = "your-email-password" # 기본값 추가
|
||||
MAIL_FROM: str = "your-email@example.com" # 기본값 추가
|
||||
MAIL_PORT: int = 587 # 기본값 추가
|
||||
MAIL_SERVER: str = "smtp.gmail.com" # 기본값 추가
|
||||
MAIL_FROM_NAME: str = "FastPOC App" # 기본값 추가
|
||||
MAIL_STARTTLS: bool = True
|
||||
MAIL_SSL_TLS: bool = False
|
||||
USE_CREDENTIALS: bool = True
|
||||
VALIDATE_CERTS: bool = True
|
||||
|
||||
TWILIO_SID: str = "your-twilio-sid" # 기본값 추가
|
||||
TWILIO_AUTH_TOKEN: str = "your-twilio-token" # 기본값 추가
|
||||
TWILIO_NUMBER: str = "+1234567890" # 기본값 추가
|
||||
|
||||
model_config = _base_config
|
||||
|
||||
|
||||
class CrawlerSettings(BaseSettings):
|
||||
NAVER_COOKIES: str = Field(default="")
|
||||
|
||||
model_config = _base_config
|
||||
|
||||
|
||||
class AzureBlobSettings(BaseSettings):
|
||||
"""Azure Blob Storage 설정"""
|
||||
|
||||
AZURE_BLOB_SAS_TOKEN: str = Field(
|
||||
default="",
|
||||
description="Azure Blob Storage SAS 토큰",
|
||||
)
|
||||
AZURE_BLOB_BASE_URL: str = Field(
|
||||
default="https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original",
|
||||
description="Azure Blob Storage 기본 URL",
|
||||
)
|
||||
|
||||
model_config = _base_config
|
||||
|
||||
|
||||
class CreatomateSettings(BaseSettings):
|
||||
"""Creatomate 템플릿 설정"""
|
||||
|
||||
# 세로형 템플릿 (기본값)
|
||||
TEMPLATE_ID_VERTICAL: str = Field(
|
||||
default="e8c7b43f-de4b-4ba3-b8eb-5df688569193",
|
||||
description="Creatomate 세로형 템플릿 ID",
|
||||
)
|
||||
TEMPLATE_DURATION_VERTICAL: float = Field(
|
||||
default=90.0,
|
||||
description="세로형 템플릿 기본 duration (초)",
|
||||
)
|
||||
|
||||
# 가로형 템플릿
|
||||
TEMPLATE_ID_HORIZONTAL: str = Field(
|
||||
default="0f092a6a-f526-4ef0-9181-d4ad4426b9e7",
|
||||
description="Creatomate 가로형 템플릿 ID",
|
||||
)
|
||||
TEMPLATE_DURATION_HORIZONTAL: float = Field(
|
||||
default=30.0,
|
||||
description="가로형 템플릿 기본 duration (초)",
|
||||
)
|
||||
|
||||
model_config = _base_config
|
||||
|
||||
|
||||
prj_settings = ProjectSettings()
|
||||
apikey_settings = APIKeySettings()
|
||||
db_settings = DatabaseSettings()
|
||||
security_settings = SecuritySettings()
|
||||
notification_settings = NotificationSettings()
|
||||
cors_settings = CORSSettings()
|
||||
crawler_settings = CrawlerSettings()
|
||||
azure_blob_settings = AzureBlobSettings()
|
||||
creatomate_settings = CreatomateSettings()
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
PROJECT_DIR = Path(__file__).resolve().parent
|
||||
|
||||
_base_config = SettingsConfigDict(
|
||||
env_file=PROJECT_DIR / ".env",
|
||||
env_ignore_empty=True,
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
|
||||
class ProjectSettings(BaseSettings):
|
||||
PROJECT_NAME: str = Field(default="CastAD")
|
||||
PROJECT_DOMAIN: str = Field(default="localhost:8000")
|
||||
VERSION: str = Field(default="0.1.0")
|
||||
DESCRIPTION: str = Field(default="FastAPI 기반 CastAD 프로젝트")
|
||||
ADMIN_BASE_URL: str = Field(default="/admin")
|
||||
DEBUG: bool = Field(default=True)
|
||||
|
||||
model_config = _base_config
|
||||
|
||||
|
||||
class APIKeySettings(BaseSettings):
|
||||
CHATGPT_API_KEY: str = Field(default="your-chatgpt-api-key") # 기본값 추가
|
||||
SUNO_API_KEY: str = Field(default="your-suno-api-key") # Suno API 키
|
||||
SUNO_CALLBACK_URL: str = Field(
|
||||
default="https://example.com/api/suno/callback"
|
||||
) # Suno 콜백 URL (필수)
|
||||
CREATOMATE_API_KEY: str = Field(default="your-creatomate-api-key") # Creatomate API 키
|
||||
|
||||
model_config = _base_config
|
||||
|
||||
|
||||
class CORSSettings(BaseSettings):
|
||||
# CORS (Cross-Origin Resource Sharing) 설정
|
||||
|
||||
# 요청을 허용할 출처(Origin) 목록
|
||||
# ["*"]: 모든 출처 허용 (개발 환경용, 프로덕션에서는 구체적인 도메인 지정 권장)
|
||||
# 예: ["https://example.com", "https://app.example.com"]
|
||||
CORS_ALLOW_ORIGINS: list[str] = ["*"]
|
||||
|
||||
# 자격 증명(쿠키, Authorization 헤더 등) 포함 요청 허용 여부
|
||||
# True: 클라이언트가 credentials: 'include'로 요청 시 쿠키/인증 정보 전송 가능
|
||||
# 주의: CORS_ALLOW_ORIGINS가 ["*"]일 때는 보안상 False 권장
|
||||
CORS_ALLOW_CREDENTIALS: bool = True
|
||||
|
||||
# 허용할 HTTP 메서드 목록
|
||||
# ["*"]: 모든 메서드 허용 (GET, POST, PUT, DELETE, PATCH, OPTIONS 등)
|
||||
# 구체적 지정 예: ["GET", "POST", "PUT", "DELETE"]
|
||||
CORS_ALLOW_METHODS: list[str] = ["*"]
|
||||
|
||||
# 클라이언트가 요청 시 사용할 수 있는 HTTP 헤더 목록
|
||||
# ["*"]: 모든 헤더 허용
|
||||
# 구체적 지정 예: ["Content-Type", "Authorization", "X-Custom-Header"]
|
||||
CORS_ALLOW_HEADERS: list[str] = ["*"]
|
||||
|
||||
# 브라우저의 JavaScript에서 접근 가능한 응답 헤더 목록
|
||||
# []: 기본 안전 헤더(Cache-Control, Content-Language, Content-Type,
|
||||
# Expires, Last-Modified, Pragma)만 접근 가능
|
||||
# 추가 노출 필요 시: ["X-Total-Count", "X-Request-Id", "X-Custom-Header"]
|
||||
CORS_EXPOSE_HEADERS: list[str] = []
|
||||
|
||||
# Preflight 요청(OPTIONS) 결과를 캐시하는 시간(초)
|
||||
# 600: 10분간 캐시 (이 시간 동안 동일 요청에 대해 preflight 생략)
|
||||
# 0으로 설정 시 매번 preflight 요청 발생
|
||||
CORS_MAX_AGE: int = 600
|
||||
|
||||
model_config = _base_config
|
||||
|
||||
|
||||
class DatabaseSettings(BaseSettings):
|
||||
# MySQL 연결 설정 (기본값: 테스트 계정 및 poc DB)
|
||||
MYSQL_HOST: str = Field(default="localhost")
|
||||
MYSQL_PORT: int = Field(default=3306)
|
||||
MYSQL_USER: str = Field(default="test")
|
||||
MYSQL_PASSWORD: str = Field(default="") # 환경변수에서 로드
|
||||
MYSQL_DB: str = Field(default="poc")
|
||||
|
||||
# Redis 설정
|
||||
REDIS_HOST: str = "localhost"
|
||||
REDIS_PORT: int = 6379
|
||||
|
||||
model_config = _base_config
|
||||
|
||||
@property
|
||||
def MYSQL_URL(self) -> str:
|
||||
"""비동기 MySQL URL 생성 (asyncmy 드라이버 사용, SQLAlchemy 통합 최적화)"""
|
||||
return f"mysql+asyncmy://{self.MYSQL_USER}:{self.MYSQL_PASSWORD}@{self.MYSQL_HOST}:{self.MYSQL_PORT}/{self.MYSQL_DB}"
|
||||
|
||||
def REDIS_URL(self, db: int = 0) -> str:
|
||||
"""Redis URL 생성 (db 인수로 기본값 지원)"""
|
||||
return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{db}"
|
||||
|
||||
|
||||
class SecuritySettings(BaseSettings):
|
||||
JWT_SECRET: str = "your-jwt-secret-key" # 기본값 추가 (필수 필드 안전)
|
||||
JWT_ALGORITHM: str = "HS256" # 기본값 추가 (필수 필드 안전)
|
||||
|
||||
model_config = _base_config
|
||||
|
||||
|
||||
class NotificationSettings(BaseSettings):
|
||||
MAIL_USERNAME: str = "your-email@example.com" # 기본값 추가
|
||||
MAIL_PASSWORD: str = "your-email-password" # 기본값 추가
|
||||
MAIL_FROM: str = "your-email@example.com" # 기본값 추가
|
||||
MAIL_PORT: int = 587 # 기본값 추가
|
||||
MAIL_SERVER: str = "smtp.gmail.com" # 기본값 추가
|
||||
MAIL_FROM_NAME: str = "FastPOC App" # 기본값 추가
|
||||
MAIL_STARTTLS: bool = True
|
||||
MAIL_SSL_TLS: bool = False
|
||||
USE_CREDENTIALS: bool = True
|
||||
VALIDATE_CERTS: bool = True
|
||||
|
||||
TWILIO_SID: str = "your-twilio-sid" # 기본값 추가
|
||||
TWILIO_AUTH_TOKEN: str = "your-twilio-token" # 기본값 추가
|
||||
TWILIO_NUMBER: str = "+1234567890" # 기본값 추가
|
||||
|
||||
model_config = _base_config
|
||||
|
||||
|
||||
class CrawlerSettings(BaseSettings):
|
||||
NAVER_COOKIES: str = Field(default="")
|
||||
|
||||
model_config = _base_config
|
||||
|
||||
|
||||
class AzureBlobSettings(BaseSettings):
|
||||
"""Azure Blob Storage 설정"""
|
||||
|
||||
AZURE_BLOB_SAS_TOKEN: str = Field(
|
||||
default="",
|
||||
description="Azure Blob Storage SAS 토큰",
|
||||
)
|
||||
AZURE_BLOB_BASE_URL: str = Field(
|
||||
default="https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original",
|
||||
description="Azure Blob Storage 기본 URL",
|
||||
)
|
||||
|
||||
model_config = _base_config
|
||||
|
||||
|
||||
class CreatomateSettings(BaseSettings):
|
||||
"""Creatomate 템플릿 설정"""
|
||||
|
||||
# 세로형 템플릿 (기본값)
|
||||
TEMPLATE_ID_VERTICAL: str = Field(
|
||||
default="e8c7b43f-de4b-4ba3-b8eb-5df688569193",
|
||||
description="Creatomate 세로형 템플릿 ID",
|
||||
)
|
||||
TEMPLATE_DURATION_VERTICAL: float = Field(
|
||||
default=90.0,
|
||||
description="세로형 템플릿 기본 duration (초)",
|
||||
)
|
||||
|
||||
# 가로형 템플릿
|
||||
TEMPLATE_ID_HORIZONTAL: str = Field(
|
||||
default="0f092a6a-f526-4ef0-9181-d4ad4426b9e7",
|
||||
description="Creatomate 가로형 템플릿 ID",
|
||||
)
|
||||
TEMPLATE_DURATION_HORIZONTAL: float = Field(
|
||||
default=30.0,
|
||||
description="가로형 템플릿 기본 duration (초)",
|
||||
)
|
||||
|
||||
model_config = _base_config
|
||||
|
||||
|
||||
prj_settings = ProjectSettings()
|
||||
apikey_settings = APIKeySettings()
|
||||
db_settings = DatabaseSettings()
|
||||
security_settings = SecuritySettings()
|
||||
notification_settings = NotificationSettings()
|
||||
cors_settings = CORSSettings()
|
||||
crawler_settings = CrawlerSettings()
|
||||
azure_blob_settings = AzureBlobSettings()
|
||||
creatomate_settings = CreatomateSettings()
|
||||
|
|
|
|||
|
|
@ -1,297 +1,297 @@
|
|||
# 비동기 처리 문제 분석 보고서
|
||||
|
||||
## 요약
|
||||
|
||||
전반적으로 이 프로젝트는 현대적인 비동기 아키텍처를 잘 구현하고 있습니다. 그러나 몇 가지 잠재적인 문제점과 개선 가능한 부분이 발견되었습니다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 심각도 높음 - 즉시 개선 권장
|
||||
|
||||
### 1.1 N+1 쿼리 문제 (video.py:596-612)
|
||||
|
||||
```python
|
||||
# get_videos() 엔드포인트에서
|
||||
for video in videos:
|
||||
# 매 video마다 별도의 DB 쿼리 실행 - N+1 문제!
|
||||
project_result = await session.execute(
|
||||
select(Project).where(Project.id == video.project_id)
|
||||
)
|
||||
project = project_result.scalar_one_or_none()
|
||||
```
|
||||
|
||||
**문제점**: 비디오 목록 조회 시 각 비디오마다 별도의 Project 쿼리가 발생합니다. 10개 비디오 조회 시 11번의 DB 쿼리가 실행됩니다.
|
||||
|
||||
**개선 방안**:
|
||||
```python
|
||||
# selectinload를 사용한 eager loading
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
query = (
|
||||
select(Video)
|
||||
.options(selectinload(Video.project)) # relationship 필요
|
||||
.where(Video.id.in_(select(subquery.c.max_id)))
|
||||
.order_by(Video.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(pagination.page_size)
|
||||
)
|
||||
|
||||
# 또는 한 번에 project_ids 수집 후 일괄 조회
|
||||
project_ids = [v.project_id for v in videos]
|
||||
projects_result = await session.execute(
|
||||
select(Project).where(Project.id.in_(project_ids))
|
||||
)
|
||||
projects_map = {p.id: p for p in projects_result.scalars().all()}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 1.2 가사 생성 API의 블로킹 문제 (lyric.py:274-276)
|
||||
|
||||
```python
|
||||
# ChatGPT API 호출이 완료될 때까지 HTTP 응답이 블로킹됨
|
||||
print(f"[generate_lyric] ChatGPT generation started - task_id: {task_id}")
|
||||
result = await service.generate(prompt=prompt) # 수 초~수십 초 소요
|
||||
print(f"[generate_lyric] ChatGPT generation completed - task_id: {task_id}")
|
||||
```
|
||||
|
||||
**문제점**:
|
||||
- ChatGPT API 응답이 5-30초 이상 걸릴 수 있음
|
||||
- 이 시간 동안 클라이언트 연결이 유지되어야 함
|
||||
- 다수 동시 요청 시 worker 스레드 고갈 가능성
|
||||
|
||||
**개선 방안 (BackgroundTask 패턴)**:
|
||||
```python
|
||||
@router.post("/generate")
|
||||
async def generate_lyric(
|
||||
request_body: GenerateLyricRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> GenerateLyricResponse:
|
||||
# DB에 processing 상태로 저장
|
||||
lyric = Lyric(status="processing", ...)
|
||||
session.add(lyric)
|
||||
await session.commit()
|
||||
|
||||
# 백그라운드에서 ChatGPT 호출
|
||||
background_tasks.add_task(
|
||||
generate_lyric_background,
|
||||
task_id=task_id,
|
||||
prompt=prompt,
|
||||
)
|
||||
|
||||
# 즉시 응답 반환
|
||||
return GenerateLyricResponse(
|
||||
success=True,
|
||||
task_id=task_id,
|
||||
message="가사 생성이 시작되었습니다. /status/{task_id}로 상태를 확인하세요.",
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 1.3 Creatomate 서비스의 동기/비동기 메서드 혼재 (creatomate.py)
|
||||
|
||||
**문제점**: 동기 메서드가 여전히 존재하여 실수로 async 컨텍스트에서 호출될 수 있습니다.
|
||||
|
||||
| 동기 메서드 | 비동기 메서드 |
|
||||
|------------|--------------|
|
||||
| `get_all_templates_data()` | 없음 |
|
||||
| `get_one_template_data()` | `get_one_template_data_async()` |
|
||||
| `make_creatomate_call()` | 없음 |
|
||||
| `make_creatomate_custom_call()` | `make_creatomate_custom_call_async()` |
|
||||
| `get_render_status()` | `get_render_status_async()` |
|
||||
|
||||
**개선 방안**:
|
||||
```python
|
||||
# 모든 HTTP 호출 메서드를 async로 통일
|
||||
async def get_all_templates_data(self) -> dict:
|
||||
url = f"{self.BASE_URL}/v1/templates"
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(url, headers=self.headers, timeout=30.0)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
# 동기 버전 제거 또는 deprecated 표시
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 심각도 중간 - 개선 권장
|
||||
|
||||
### 2.1 백그라운드 태스크에서 매번 엔진 생성 (session.py:82-127)
|
||||
|
||||
```python
|
||||
@asynccontextmanager
|
||||
async def get_worker_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
# 매 호출마다 새 엔진 생성 - 오버헤드 발생
|
||||
worker_engine = create_async_engine(
|
||||
url=db_settings.MYSQL_URL,
|
||||
poolclass=NullPool,
|
||||
...
|
||||
)
|
||||
```
|
||||
|
||||
**문제점**: 백그라운드 태스크가 빈번하게 호출되면 엔진 생성/소멸 오버헤드가 증가합니다.
|
||||
|
||||
**개선 방안**:
|
||||
```python
|
||||
# 모듈 레벨에서 워커 전용 엔진 생성
|
||||
_worker_engine = create_async_engine(
|
||||
url=db_settings.MYSQL_URL,
|
||||
poolclass=NullPool,
|
||||
)
|
||||
_WorkerSessionLocal = async_sessionmaker(bind=_worker_engine, ...)
|
||||
|
||||
@asynccontextmanager
|
||||
async def get_worker_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
async with _WorkerSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
raise e
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.2 대용량 파일 다운로드 시 메모리 사용 (video_task.py:49-54)
|
||||
|
||||
```python
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(video_url, timeout=180.0)
|
||||
response.raise_for_status()
|
||||
# 전체 파일을 메모리에 로드 - 대용량 영상 시 문제
|
||||
async with aiofiles.open(str(temp_file_path), "wb") as f:
|
||||
await f.write(response.content)
|
||||
```
|
||||
|
||||
**문제점**: 수백 MB 크기의 영상 파일을 한 번에 메모리에 로드합니다.
|
||||
|
||||
**개선 방안 - 스트리밍 다운로드**:
|
||||
```python
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with client.stream("GET", video_url, timeout=180.0) as response:
|
||||
response.raise_for_status()
|
||||
async with aiofiles.open(str(temp_file_path), "wb") as f:
|
||||
async for chunk in response.aiter_bytes(chunk_size=8192):
|
||||
await f.write(chunk)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.3 httpx.AsyncClient 반복 생성
|
||||
|
||||
여러 곳에서 `async with httpx.AsyncClient() as client:`를 사용하여 매번 새 클라이언트를 생성합니다.
|
||||
|
||||
**개선 방안 - 재사용 가능한 클라이언트**:
|
||||
```python
|
||||
# app/utils/http_client.py
|
||||
from contextlib import asynccontextmanager
|
||||
import httpx
|
||||
|
||||
_client: httpx.AsyncClient | None = None
|
||||
|
||||
async def get_http_client() -> httpx.AsyncClient:
|
||||
global _client
|
||||
if _client is None:
|
||||
_client = httpx.AsyncClient(timeout=30.0)
|
||||
return _client
|
||||
|
||||
async def close_http_client():
|
||||
global _client
|
||||
if _client:
|
||||
await _client.aclose()
|
||||
_client = None
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 심각도 낮음 - 선택적 개선
|
||||
|
||||
### 3.1 generate_video 엔드포인트의 다중 DB 조회 (video.py:109-191)
|
||||
|
||||
```python
|
||||
# 4개의 개별 쿼리가 순차적으로 실행됨
|
||||
project_result = await session.execute(select(Project).where(...))
|
||||
lyric_result = await session.execute(select(Lyric).where(...))
|
||||
song_result = await session.execute(select(Song).where(...))
|
||||
image_result = await session.execute(select(Image).where(...))
|
||||
```
|
||||
|
||||
**개선 방안 - 병렬 쿼리 실행**:
|
||||
```python
|
||||
import asyncio
|
||||
|
||||
project_task = session.execute(select(Project).where(Project.task_id == task_id))
|
||||
lyric_task = session.execute(select(Lyric).where(Lyric.task_id == task_id))
|
||||
song_task = session.execute(
|
||||
select(Song).where(Song.task_id == task_id).order_by(Song.created_at.desc()).limit(1)
|
||||
)
|
||||
image_task = session.execute(
|
||||
select(Image).where(Image.task_id == task_id).order_by(Image.img_order.asc())
|
||||
)
|
||||
|
||||
project_result, lyric_result, song_result, image_result = await asyncio.gather(
|
||||
project_task, lyric_task, song_task, image_task
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.2 템플릿 조회 캐싱 미적용
|
||||
|
||||
`get_one_template_data_async()`가 매번 Creatomate API를 호출합니다.
|
||||
|
||||
**개선 방안 - 간단한 메모리 캐싱**:
|
||||
```python
|
||||
from functools import lru_cache
|
||||
from cachetools import TTLCache
|
||||
|
||||
_template_cache = TTLCache(maxsize=100, ttl=3600) # 1시간 캐시
|
||||
|
||||
async def get_one_template_data_async(self, template_id: str) -> dict:
|
||||
if template_id in _template_cache:
|
||||
return _template_cache[template_id]
|
||||
|
||||
url = f"{self.BASE_URL}/v1/templates/{template_id}"
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(url, headers=self.headers, timeout=30.0)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
_template_cache[template_id] = data
|
||||
return data
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 긍정적인 부분 (잘 구현된 패턴)
|
||||
|
||||
| 항목 | 상태 | 설명 |
|
||||
|------|------|------|
|
||||
| SQLAlchemy AsyncSession | O | `asyncmy` 드라이버와 `AsyncSessionLocal` 사용 |
|
||||
| 파일 I/O | O | `aiofiles` 사용으로 비동기 파일 처리 |
|
||||
| HTTP 클라이언트 | O | `httpx.AsyncClient` 사용 |
|
||||
| OpenAI API | O | `AsyncOpenAI` 클라이언트 사용 |
|
||||
| 백그라운드 태스크 | O | FastAPI `BackgroundTasks` 적절히 사용 |
|
||||
| 세션 관리 | O | 메인/워커 세션 분리로 이벤트 루프 충돌 방지 |
|
||||
| 연결 풀 설정 | O | `pool_size`, `pool_recycle`, `pool_pre_ping` 적절히 설정 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 우선순위별 개선 권장 사항
|
||||
|
||||
| 우선순위 | 항목 | 예상 효과 |
|
||||
|----------|------|----------|
|
||||
| **1** | N+1 쿼리 문제 해결 | DB 부하 감소, 응답 속도 개선 |
|
||||
| **2** | 가사 생성 백그라운드 처리 | 동시 요청 처리 능력 향상 |
|
||||
| **3** | Creatomate 동기 메서드 제거 | 실수로 인한 블로킹 방지 |
|
||||
| **4** | 대용량 파일 스트리밍 다운로드 | 메모리 사용량 감소 |
|
||||
| **5** | 워커 세션 엔진 재사용 | 오버헤드 감소 |
|
||||
|
||||
---
|
||||
|
||||
## 분석 일자
|
||||
|
||||
2024-12-29
|
||||
# 비동기 처리 문제 분석 보고서
|
||||
|
||||
## 요약
|
||||
|
||||
전반적으로 이 프로젝트는 현대적인 비동기 아키텍처를 잘 구현하고 있습니다. 그러나 몇 가지 잠재적인 문제점과 개선 가능한 부분이 발견되었습니다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 심각도 높음 - 즉시 개선 권장
|
||||
|
||||
### 1.1 N+1 쿼리 문제 (video.py:596-612)
|
||||
|
||||
```python
|
||||
# get_videos() 엔드포인트에서
|
||||
for video in videos:
|
||||
# 매 video마다 별도의 DB 쿼리 실행 - N+1 문제!
|
||||
project_result = await session.execute(
|
||||
select(Project).where(Project.id == video.project_id)
|
||||
)
|
||||
project = project_result.scalar_one_or_none()
|
||||
```
|
||||
|
||||
**문제점**: 비디오 목록 조회 시 각 비디오마다 별도의 Project 쿼리가 발생합니다. 10개 비디오 조회 시 11번의 DB 쿼리가 실행됩니다.
|
||||
|
||||
**개선 방안**:
|
||||
```python
|
||||
# selectinload를 사용한 eager loading
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
query = (
|
||||
select(Video)
|
||||
.options(selectinload(Video.project)) # relationship 필요
|
||||
.where(Video.id.in_(select(subquery.c.max_id)))
|
||||
.order_by(Video.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(pagination.page_size)
|
||||
)
|
||||
|
||||
# 또는 한 번에 project_ids 수집 후 일괄 조회
|
||||
project_ids = [v.project_id for v in videos]
|
||||
projects_result = await session.execute(
|
||||
select(Project).where(Project.id.in_(project_ids))
|
||||
)
|
||||
projects_map = {p.id: p for p in projects_result.scalars().all()}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 1.2 가사 생성 API의 블로킹 문제 (lyric.py:274-276)
|
||||
|
||||
```python
|
||||
# ChatGPT API 호출이 완료될 때까지 HTTP 응답이 블로킹됨
|
||||
print(f"[generate_lyric] ChatGPT generation started - task_id: {task_id}")
|
||||
result = await service.generate(prompt=prompt) # 수 초~수십 초 소요
|
||||
print(f"[generate_lyric] ChatGPT generation completed - task_id: {task_id}")
|
||||
```
|
||||
|
||||
**문제점**:
|
||||
- ChatGPT API 응답이 5-30초 이상 걸릴 수 있음
|
||||
- 이 시간 동안 클라이언트 연결이 유지되어야 함
|
||||
- 다수 동시 요청 시 worker 스레드 고갈 가능성
|
||||
|
||||
**개선 방안 (BackgroundTask 패턴)**:
|
||||
```python
|
||||
@router.post("/generate")
|
||||
async def generate_lyric(
|
||||
request_body: GenerateLyricRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> GenerateLyricResponse:
|
||||
# DB에 processing 상태로 저장
|
||||
lyric = Lyric(status="processing", ...)
|
||||
session.add(lyric)
|
||||
await session.commit()
|
||||
|
||||
# 백그라운드에서 ChatGPT 호출
|
||||
background_tasks.add_task(
|
||||
generate_lyric_background,
|
||||
task_id=task_id,
|
||||
prompt=prompt,
|
||||
)
|
||||
|
||||
# 즉시 응답 반환
|
||||
return GenerateLyricResponse(
|
||||
success=True,
|
||||
task_id=task_id,
|
||||
message="가사 생성이 시작되었습니다. /status/{task_id}로 상태를 확인하세요.",
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 1.3 Creatomate 서비스의 동기/비동기 메서드 혼재 (creatomate.py)
|
||||
|
||||
**문제점**: 동기 메서드가 여전히 존재하여 실수로 async 컨텍스트에서 호출될 수 있습니다.
|
||||
|
||||
| 동기 메서드 | 비동기 메서드 |
|
||||
|------------|--------------|
|
||||
| `get_all_templates_data()` | 없음 |
|
||||
| `get_one_template_data()` | `get_one_template_data_async()` |
|
||||
| `make_creatomate_call()` | 없음 |
|
||||
| `make_creatomate_custom_call()` | `make_creatomate_custom_call_async()` |
|
||||
| `get_render_status()` | `get_render_status_async()` |
|
||||
|
||||
**개선 방안**:
|
||||
```python
|
||||
# 모든 HTTP 호출 메서드를 async로 통일
|
||||
async def get_all_templates_data(self) -> dict:
|
||||
url = f"{self.BASE_URL}/v1/templates"
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(url, headers=self.headers, timeout=30.0)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
# 동기 버전 제거 또는 deprecated 표시
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 심각도 중간 - 개선 권장
|
||||
|
||||
### 2.1 백그라운드 태스크에서 매번 엔진 생성 (session.py:82-127)
|
||||
|
||||
```python
|
||||
@asynccontextmanager
|
||||
async def get_worker_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
# 매 호출마다 새 엔진 생성 - 오버헤드 발생
|
||||
worker_engine = create_async_engine(
|
||||
url=db_settings.MYSQL_URL,
|
||||
poolclass=NullPool,
|
||||
...
|
||||
)
|
||||
```
|
||||
|
||||
**문제점**: 백그라운드 태스크가 빈번하게 호출되면 엔진 생성/소멸 오버헤드가 증가합니다.
|
||||
|
||||
**개선 방안**:
|
||||
```python
|
||||
# 모듈 레벨에서 워커 전용 엔진 생성
|
||||
_worker_engine = create_async_engine(
|
||||
url=db_settings.MYSQL_URL,
|
||||
poolclass=NullPool,
|
||||
)
|
||||
_WorkerSessionLocal = async_sessionmaker(bind=_worker_engine, ...)
|
||||
|
||||
@asynccontextmanager
|
||||
async def get_worker_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
async with _WorkerSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
raise e
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.2 대용량 파일 다운로드 시 메모리 사용 (video_task.py:49-54)
|
||||
|
||||
```python
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(video_url, timeout=180.0)
|
||||
response.raise_for_status()
|
||||
# 전체 파일을 메모리에 로드 - 대용량 영상 시 문제
|
||||
async with aiofiles.open(str(temp_file_path), "wb") as f:
|
||||
await f.write(response.content)
|
||||
```
|
||||
|
||||
**문제점**: 수백 MB 크기의 영상 파일을 한 번에 메모리에 로드합니다.
|
||||
|
||||
**개선 방안 - 스트리밍 다운로드**:
|
||||
```python
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with client.stream("GET", video_url, timeout=180.0) as response:
|
||||
response.raise_for_status()
|
||||
async with aiofiles.open(str(temp_file_path), "wb") as f:
|
||||
async for chunk in response.aiter_bytes(chunk_size=8192):
|
||||
await f.write(chunk)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.3 httpx.AsyncClient 반복 생성
|
||||
|
||||
여러 곳에서 `async with httpx.AsyncClient() as client:`를 사용하여 매번 새 클라이언트를 생성합니다.
|
||||
|
||||
**개선 방안 - 재사용 가능한 클라이언트**:
|
||||
```python
|
||||
# app/utils/http_client.py
|
||||
from contextlib import asynccontextmanager
|
||||
import httpx
|
||||
|
||||
_client: httpx.AsyncClient | None = None
|
||||
|
||||
async def get_http_client() -> httpx.AsyncClient:
|
||||
global _client
|
||||
if _client is None:
|
||||
_client = httpx.AsyncClient(timeout=30.0)
|
||||
return _client
|
||||
|
||||
async def close_http_client():
|
||||
global _client
|
||||
if _client:
|
||||
await _client.aclose()
|
||||
_client = None
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 심각도 낮음 - 선택적 개선
|
||||
|
||||
### 3.1 generate_video 엔드포인트의 다중 DB 조회 (video.py:109-191)
|
||||
|
||||
```python
|
||||
# 4개의 개별 쿼리가 순차적으로 실행됨
|
||||
project_result = await session.execute(select(Project).where(...))
|
||||
lyric_result = await session.execute(select(Lyric).where(...))
|
||||
song_result = await session.execute(select(Song).where(...))
|
||||
image_result = await session.execute(select(Image).where(...))
|
||||
```
|
||||
|
||||
**개선 방안 - 병렬 쿼리 실행**:
|
||||
```python
|
||||
import asyncio
|
||||
|
||||
project_task = session.execute(select(Project).where(Project.task_id == task_id))
|
||||
lyric_task = session.execute(select(Lyric).where(Lyric.task_id == task_id))
|
||||
song_task = session.execute(
|
||||
select(Song).where(Song.task_id == task_id).order_by(Song.created_at.desc()).limit(1)
|
||||
)
|
||||
image_task = session.execute(
|
||||
select(Image).where(Image.task_id == task_id).order_by(Image.img_order.asc())
|
||||
)
|
||||
|
||||
project_result, lyric_result, song_result, image_result = await asyncio.gather(
|
||||
project_task, lyric_task, song_task, image_task
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.2 템플릿 조회 캐싱 미적용
|
||||
|
||||
`get_one_template_data_async()`가 매번 Creatomate API를 호출합니다.
|
||||
|
||||
**개선 방안 - 간단한 메모리 캐싱**:
|
||||
```python
|
||||
from functools import lru_cache
|
||||
from cachetools import TTLCache
|
||||
|
||||
_template_cache = TTLCache(maxsize=100, ttl=3600) # 1시간 캐시
|
||||
|
||||
async def get_one_template_data_async(self, template_id: str) -> dict:
|
||||
if template_id in _template_cache:
|
||||
return _template_cache[template_id]
|
||||
|
||||
url = f"{self.BASE_URL}/v1/templates/{template_id}"
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(url, headers=self.headers, timeout=30.0)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
_template_cache[template_id] = data
|
||||
return data
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 긍정적인 부분 (잘 구현된 패턴)
|
||||
|
||||
| 항목 | 상태 | 설명 |
|
||||
|------|------|------|
|
||||
| SQLAlchemy AsyncSession | O | `asyncmy` 드라이버와 `AsyncSessionLocal` 사용 |
|
||||
| 파일 I/O | O | `aiofiles` 사용으로 비동기 파일 처리 |
|
||||
| HTTP 클라이언트 | O | `httpx.AsyncClient` 사용 |
|
||||
| OpenAI API | O | `AsyncOpenAI` 클라이언트 사용 |
|
||||
| 백그라운드 태스크 | O | FastAPI `BackgroundTasks` 적절히 사용 |
|
||||
| 세션 관리 | O | 메인/워커 세션 분리로 이벤트 루프 충돌 방지 |
|
||||
| 연결 풀 설정 | O | `pool_size`, `pool_recycle`, `pool_pre_ping` 적절히 설정 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 우선순위별 개선 권장 사항
|
||||
|
||||
| 우선순위 | 항목 | 예상 효과 |
|
||||
|----------|------|----------|
|
||||
| **1** | N+1 쿼리 문제 해결 | DB 부하 감소, 응답 속도 개선 |
|
||||
| **2** | 가사 생성 백그라운드 처리 | 동시 요청 처리 능력 향상 |
|
||||
| **3** | Creatomate 동기 메서드 제거 | 실수로 인한 블로킹 방지 |
|
||||
| **4** | 대용량 파일 스트리밍 다운로드 | 메모리 사용량 감소 |
|
||||
| **5** | 워커 세션 엔진 재사용 | 오버헤드 감소 |
|
||||
|
||||
---
|
||||
|
||||
## 분석 일자
|
||||
|
||||
2024-12-29
|
||||
|
|
|
|||
|
|
@ -1,82 +1,82 @@
|
|||
-- input_history 테이블
|
||||
CREATE TABLE input_history (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
customer_name VARCHAR(255) NOT NULL,
|
||||
region VARCHAR(100) NOT NULL,
|
||||
task_id CHAR(36) NOT NULL,
|
||||
detail_region_info TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- upload_img_url 테이블
|
||||
CREATE TABLE upload_img_url (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
task_id CHAR(36) NOT NULL,
|
||||
img_uid INT NOT NULL,
|
||||
img_url VARCHAR(2048) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (task_id) REFERENCES input_history(task_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- lyrics 테이블
|
||||
CREATE TABLE lyrics (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
input_history_id INT NOT NULL,
|
||||
task_id CHAR(36) NOT NULL,
|
||||
status VARCHAR(50) NOT NULL,
|
||||
lyrics_prompt TEXT NOT NULL,
|
||||
lyrics_result LONGTEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (input_history_id) REFERENCES input_history(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- song 테이블
|
||||
CREATE TABLE song (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
input_history_id INT NOT NULL,
|
||||
lyrics_id INT NOT NULL,
|
||||
task_id CHAR(36) NOT NULL,
|
||||
status VARCHAR(50) NOT NULL,
|
||||
song_prompt TEXT NOT NULL,
|
||||
song_result_url_1 VARCHAR(2048),
|
||||
song_result_url_2 VARCHAR(2048),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (input_history_id) REFERENCES input_history(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (lyrics_id) REFERENCES lyrics(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- creatomate_result_url 테이블
|
||||
CREATE TABLE creatomate_result_url (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
input_history_id INT NOT NULL,
|
||||
song_id INT NOT NULL,
|
||||
task_id CHAR(36) NOT NULL,
|
||||
status VARCHAR(50) NOT NULL,
|
||||
result_movie_url VARCHAR(2048),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (input_history_id) REFERENCES input_history(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (song_id) REFERENCES song(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- ===== 인덱스 추가 (쿼리 성능 최적화) =====
|
||||
|
||||
-- input_history
|
||||
CREATE INDEX idx_input_history_task_id ON input_history(task_id);
|
||||
|
||||
-- upload_img_url (task_id 인덱스 + 복합 인덱스)
|
||||
CREATE INDEX idx_upload_img_url_task_id ON upload_img_url(task_id);
|
||||
CREATE INDEX idx_upload_img_url_task_id_img_uid ON upload_img_url(task_id, img_uid);
|
||||
|
||||
-- lyrics (input_history_id + task_id 인덱스)
|
||||
CREATE INDEX idx_lyrics_input_history_id ON lyrics(input_history_id);
|
||||
CREATE INDEX idx_lyrics_task_id ON lyrics(task_id);
|
||||
|
||||
-- song (input_history_id + lyrics_id + task_id 인덱스)
|
||||
CREATE INDEX idx_song_input_history_id ON song(input_history_id);
|
||||
CREATE INDEX idx_song_lyrics_id ON song(lyrics_id);
|
||||
CREATE INDEX idx_song_task_id ON song(task_id);
|
||||
|
||||
-- creatomate_result_url (input_history_id + song_id + task_id 인덱스)
|
||||
CREATE INDEX idx_creatomate_input_history_id ON creatomate_result_url(input_history_id);
|
||||
CREATE INDEX idx_creatomate_song_id ON creatomate_result_url(song_id);
|
||||
-- input_history 테이블
|
||||
CREATE TABLE input_history (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
customer_name VARCHAR(255) NOT NULL,
|
||||
region VARCHAR(100) NOT NULL,
|
||||
task_id CHAR(36) NOT NULL,
|
||||
detail_region_info TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- upload_img_url 테이블
|
||||
CREATE TABLE upload_img_url (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
task_id CHAR(36) NOT NULL,
|
||||
img_uid INT NOT NULL,
|
||||
img_url VARCHAR(2048) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (task_id) REFERENCES input_history(task_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- lyrics 테이블
|
||||
CREATE TABLE lyrics (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
input_history_id INT NOT NULL,
|
||||
task_id CHAR(36) NOT NULL,
|
||||
status VARCHAR(50) NOT NULL,
|
||||
lyrics_prompt TEXT NOT NULL,
|
||||
lyrics_result LONGTEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (input_history_id) REFERENCES input_history(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- song 테이블
|
||||
CREATE TABLE song (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
input_history_id INT NOT NULL,
|
||||
lyrics_id INT NOT NULL,
|
||||
task_id CHAR(36) NOT NULL,
|
||||
status VARCHAR(50) NOT NULL,
|
||||
song_prompt TEXT NOT NULL,
|
||||
song_result_url_1 VARCHAR(2048),
|
||||
song_result_url_2 VARCHAR(2048),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (input_history_id) REFERENCES input_history(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (lyrics_id) REFERENCES lyrics(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- creatomate_result_url 테이블
|
||||
CREATE TABLE creatomate_result_url (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
input_history_id INT NOT NULL,
|
||||
song_id INT NOT NULL,
|
||||
task_id CHAR(36) NOT NULL,
|
||||
status VARCHAR(50) NOT NULL,
|
||||
result_movie_url VARCHAR(2048),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (input_history_id) REFERENCES input_history(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (song_id) REFERENCES song(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- ===== 인덱스 추가 (쿼리 성능 최적화) =====
|
||||
|
||||
-- input_history
|
||||
CREATE INDEX idx_input_history_task_id ON input_history(task_id);
|
||||
|
||||
-- upload_img_url (task_id 인덱스 + 복합 인덱스)
|
||||
CREATE INDEX idx_upload_img_url_task_id ON upload_img_url(task_id);
|
||||
CREATE INDEX idx_upload_img_url_task_id_img_uid ON upload_img_url(task_id, img_uid);
|
||||
|
||||
-- lyrics (input_history_id + task_id 인덱스)
|
||||
CREATE INDEX idx_lyrics_input_history_id ON lyrics(input_history_id);
|
||||
CREATE INDEX idx_lyrics_task_id ON lyrics(task_id);
|
||||
|
||||
-- song (input_history_id + lyrics_id + task_id 인덱스)
|
||||
CREATE INDEX idx_song_input_history_id ON song(input_history_id);
|
||||
CREATE INDEX idx_song_lyrics_id ON song(lyrics_id);
|
||||
CREATE INDEX idx_song_task_id ON song(task_id);
|
||||
|
||||
-- creatomate_result_url (input_history_id + song_id + task_id 인덱스)
|
||||
CREATE INDEX idx_creatomate_input_history_id ON creatomate_result_url(input_history_id);
|
||||
CREATE INDEX idx_creatomate_song_id ON creatomate_result_url(song_id);
|
||||
CREATE INDEX idx_creatomate_task_id ON creatomate_result_url(task_id);
|
||||
|
|
@ -1,83 +1,83 @@
|
|||
-- input_history 테이블
|
||||
CREATE TABLE input_history (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
customer_name VARCHAR(255) NOT NULL,
|
||||
region VARCHAR(100) NOT NULL,
|
||||
task_id CHAR(36) NOT NULL UNIQUE, -- 유니크 UUID
|
||||
detail_region_info TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- upload_img_url 테이블
|
||||
CREATE TABLE upload_img_url (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
task_id CHAR(36) NOT NULL, -- input_history와 연결
|
||||
img_uid INT NOT NULL,
|
||||
img_url VARCHAR(2048) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY unique_img_task_image (task_id, img_uid),
|
||||
FOREIGN KEY (task_id) REFERENCES input_history(task_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- lyrics 테이블
|
||||
CREATE TABLE lyrics (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
input_history_id INT NOT NULL,
|
||||
task_id CHAR(36) NOT NULL UNIQUE,
|
||||
status VARCHAR(50) NOT NULL,
|
||||
lyrics_prompt TEXT NOT NULL,
|
||||
lyrics_result LONGTEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (input_history_id) REFERENCES input_history(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- song 테이블
|
||||
CREATE TABLE song (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
input_history_id INT NOT NULL,
|
||||
lyrics_id INT NOT NULL,
|
||||
task_id CHAR(36) NOT NULL UNIQUE,
|
||||
status VARCHAR(50) NOT NULL,
|
||||
song_prompt TEXT NOT NULL,
|
||||
song_result_url_1 VARCHAR(2048),
|
||||
song_result_url_2 VARCHAR(2048),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (input_history_id) REFERENCES input_history(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (lyrics_id) REFERENCES lyrics(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- creatomate_result_url 테이블
|
||||
CREATE TABLE creatomate_result_url (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
input_history_id INT NOT NULL,
|
||||
song_id INT NOT NULL,
|
||||
task_id CHAR(36) NOT NULL UNIQUE,
|
||||
status VARCHAR(50) NOT NULL,
|
||||
result_movie_url VARCHAR(2048),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (input_history_id) REFERENCES input_history(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (song_id) REFERENCES song(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- ===== 인덱스 추가 (쿼리 성능 최적화) =====
|
||||
|
||||
-- input_history
|
||||
CREATE INDEX idx_input_history_task_id ON input_history(task_id);
|
||||
|
||||
-- upload_img_url (task_id 인덱스 + 복합 인덱스)
|
||||
CREATE INDEX idx_upload_img_url_task_id ON upload_img_url(task_id);
|
||||
CREATE INDEX idx_upload_img_url_task_id_img_uid ON upload_img_url(task_id, img_uid);
|
||||
|
||||
-- lyrics (input_history_id + task_id 인덱스)
|
||||
CREATE INDEX idx_lyrics_input_history_id ON lyrics(input_history_id);
|
||||
CREATE INDEX idx_lyrics_task_id ON lyrics(task_id);
|
||||
|
||||
-- song (input_history_id + lyrics_id + task_id 인덱스)
|
||||
CREATE INDEX idx_song_input_history_id ON song(input_history_id);
|
||||
CREATE INDEX idx_song_lyrics_id ON song(lyrics_id);
|
||||
CREATE INDEX idx_song_task_id ON song(task_id);
|
||||
|
||||
-- creatomate_result_url (input_history_id + song_id + task_id 인덱스)
|
||||
CREATE INDEX idx_creatomate_input_history_id ON creatomate_result_url(input_history_id);
|
||||
CREATE INDEX idx_creatomate_song_id ON creatomate_result_url(song_id);
|
||||
-- input_history 테이블
|
||||
CREATE TABLE input_history (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
customer_name VARCHAR(255) NOT NULL,
|
||||
region VARCHAR(100) NOT NULL,
|
||||
task_id CHAR(36) NOT NULL UNIQUE, -- 유니크 UUID
|
||||
detail_region_info TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- upload_img_url 테이블
|
||||
CREATE TABLE upload_img_url (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
task_id CHAR(36) NOT NULL, -- input_history와 연결
|
||||
img_uid INT NOT NULL,
|
||||
img_url VARCHAR(2048) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY unique_img_task_image (task_id, img_uid),
|
||||
FOREIGN KEY (task_id) REFERENCES input_history(task_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- lyrics 테이블
|
||||
CREATE TABLE lyrics (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
input_history_id INT NOT NULL,
|
||||
task_id CHAR(36) NOT NULL UNIQUE,
|
||||
status VARCHAR(50) NOT NULL,
|
||||
lyrics_prompt TEXT NOT NULL,
|
||||
lyrics_result LONGTEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (input_history_id) REFERENCES input_history(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- song 테이블
|
||||
CREATE TABLE song (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
input_history_id INT NOT NULL,
|
||||
lyrics_id INT NOT NULL,
|
||||
task_id CHAR(36) NOT NULL UNIQUE,
|
||||
status VARCHAR(50) NOT NULL,
|
||||
song_prompt TEXT NOT NULL,
|
||||
song_result_url_1 VARCHAR(2048),
|
||||
song_result_url_2 VARCHAR(2048),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (input_history_id) REFERENCES input_history(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (lyrics_id) REFERENCES lyrics(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- creatomate_result_url 테이블
|
||||
CREATE TABLE creatomate_result_url (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
input_history_id INT NOT NULL,
|
||||
song_id INT NOT NULL,
|
||||
task_id CHAR(36) NOT NULL UNIQUE,
|
||||
status VARCHAR(50) NOT NULL,
|
||||
result_movie_url VARCHAR(2048),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (input_history_id) REFERENCES input_history(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (song_id) REFERENCES song(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- ===== 인덱스 추가 (쿼리 성능 최적화) =====
|
||||
|
||||
-- input_history
|
||||
CREATE INDEX idx_input_history_task_id ON input_history(task_id);
|
||||
|
||||
-- upload_img_url (task_id 인덱스 + 복합 인덱스)
|
||||
CREATE INDEX idx_upload_img_url_task_id ON upload_img_url(task_id);
|
||||
CREATE INDEX idx_upload_img_url_task_id_img_uid ON upload_img_url(task_id, img_uid);
|
||||
|
||||
-- lyrics (input_history_id + task_id 인덱스)
|
||||
CREATE INDEX idx_lyrics_input_history_id ON lyrics(input_history_id);
|
||||
CREATE INDEX idx_lyrics_task_id ON lyrics(task_id);
|
||||
|
||||
-- song (input_history_id + lyrics_id + task_id 인덱스)
|
||||
CREATE INDEX idx_song_input_history_id ON song(input_history_id);
|
||||
CREATE INDEX idx_song_lyrics_id ON song(lyrics_id);
|
||||
CREATE INDEX idx_song_task_id ON song(task_id);
|
||||
|
||||
-- creatomate_result_url (input_history_id + song_id + task_id 인덱스)
|
||||
CREATE INDEX idx_creatomate_input_history_id ON creatomate_result_url(input_history_id);
|
||||
CREATE INDEX idx_creatomate_song_id ON creatomate_result_url(song_id);
|
||||
CREATE INDEX idx_creatomate_task_id ON creatomate_result_url(task_id);
|
||||
|
|
@ -1,382 +1,382 @@
|
|||
# Pydantic ConfigDict 사용 매뉴얼
|
||||
|
||||
## 개요
|
||||
|
||||
Pydantic v2에서 `ConfigDict`는 모델의 유효성 검사, 직렬화, JSON 스키마 생성 등의 동작을 제어하는 설정을 정의하는 TypedDict입니다.
|
||||
|
||||
> Pydantic v1의 `class Config`는 더 이상 권장되지 않으며, `ConfigDict`를 사용해야 합니다.
|
||||
|
||||
## 기본 사용법
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
class MyModel(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
str_strip_whitespace=True,
|
||||
strict=True
|
||||
)
|
||||
|
||||
name: str
|
||||
age: int
|
||||
```
|
||||
|
||||
## 설정 옵션 전체 목록
|
||||
|
||||
### 문자열 처리
|
||||
|
||||
| 옵션 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| `str_to_lower` | `bool` | `False` | 문자열을 소문자로 변환 |
|
||||
| `str_to_upper` | `bool` | `False` | 문자열을 대문자로 변환 |
|
||||
| `str_strip_whitespace` | `bool` | `False` | 문자열 앞뒤 공백 제거 |
|
||||
| `str_min_length` | `int \| None` | `None` | 문자열 최소 길이 |
|
||||
| `str_max_length` | `int \| None` | `None` | 문자열 최대 길이 |
|
||||
|
||||
**예시:**
|
||||
```python
|
||||
class UserInput(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
str_strip_whitespace=True,
|
||||
str_to_lower=True,
|
||||
str_min_length=1,
|
||||
str_max_length=100
|
||||
)
|
||||
|
||||
username: str
|
||||
|
||||
user = UserInput(username=" HELLO ")
|
||||
print(user.username) # "hello"
|
||||
```
|
||||
|
||||
### 유효성 검사
|
||||
|
||||
| 옵션 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| `strict` | `bool` | `False` | 엄격한 타입 검사 활성화 (타입 강제 변환 비활성화) |
|
||||
| `validate_assignment` | `bool` | `False` | 속성 할당 시 유효성 검사 수행 |
|
||||
| `validate_default` | `bool` | `False` | 기본값도 유효성 검사 수행 |
|
||||
| `validate_return` | `bool` | `False` | 반환값 유효성 검사 |
|
||||
| `revalidate_instances` | `Literal['always', 'never', 'subclass-instances']` | `'never'` | 모델 인스턴스 재검증 시점 |
|
||||
| `arbitrary_types_allowed` | `bool` | `False` | Pydantic이 지원하지 않는 타입 허용 |
|
||||
|
||||
**예시 - strict 모드:**
|
||||
```python
|
||||
class StrictModel(BaseModel):
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
count: int
|
||||
|
||||
# strict=False (기본값): "123" -> 123 자동 변환
|
||||
# strict=True: "123" 입력 시 ValidationError 발생
|
||||
```
|
||||
|
||||
**예시 - validate_assignment:**
|
||||
```python
|
||||
class User(BaseModel):
|
||||
model_config = ConfigDict(validate_assignment=True)
|
||||
|
||||
age: int
|
||||
|
||||
user = User(age=25)
|
||||
user.age = "invalid" # ValidationError 발생
|
||||
```
|
||||
|
||||
### Extra 필드 처리
|
||||
|
||||
| 옵션 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| `extra` | `'allow' \| 'ignore' \| 'forbid'` | `'ignore'` | 추가 필드 처리 방식 |
|
||||
|
||||
**값 설명:**
|
||||
- `'ignore'`: 추가 필드 무시 (기본값)
|
||||
- `'allow'`: 추가 필드 허용, `__pydantic_extra__`에 저장
|
||||
- `'forbid'`: 추가 필드 입력 시 에러 발생
|
||||
|
||||
**예시:**
|
||||
```python
|
||||
class AllowExtra(BaseModel):
|
||||
model_config = ConfigDict(extra='allow')
|
||||
|
||||
name: str
|
||||
|
||||
data = AllowExtra(name="John", unknown_field="value")
|
||||
print(data.__pydantic_extra__) # {'unknown_field': 'value'}
|
||||
|
||||
class ForbidExtra(BaseModel):
|
||||
model_config = ConfigDict(extra='forbid')
|
||||
|
||||
name: str
|
||||
|
||||
ForbidExtra(name="John", unknown="value") # ValidationError 발생
|
||||
```
|
||||
|
||||
### 불변성 (Immutability)
|
||||
|
||||
| 옵션 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| `frozen` | `bool` | `False` | 모델을 불변(immutable)으로 만듦, `__hash__()` 구현 |
|
||||
|
||||
**예시:**
|
||||
```python
|
||||
class ImmutableUser(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
name: str
|
||||
age: int
|
||||
|
||||
user = ImmutableUser(name="John", age=30)
|
||||
user.age = 31 # 에러 발생: Instance is frozen
|
||||
|
||||
# frozen=True이면 해시 가능
|
||||
users_set = {user} # 정상 작동
|
||||
```
|
||||
|
||||
### 별칭 (Alias) 설정
|
||||
|
||||
| 옵션 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| `populate_by_name` | `bool` | `False` | 필드명과 별칭 모두로 값 설정 허용 (deprecated) |
|
||||
| `validate_by_alias` | `bool` | `True` | 별칭으로 필드 값 설정 허용 |
|
||||
| `validate_by_name` | `bool` | `False` | 별칭이 있어도 필드명으로 값 설정 허용 |
|
||||
| `serialize_by_alias` | `bool` | `False` | 직렬화 시 별칭 사용 |
|
||||
| `alias_generator` | `Callable[[str], str] \| None` | `None` | 별칭 자동 생성 함수 |
|
||||
| `loc_by_alias` | `bool` | `True` | 에러 위치에 별칭 사용 |
|
||||
|
||||
**예시:**
|
||||
```python
|
||||
from pydantic import Field
|
||||
|
||||
class APIResponse(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
validate_by_alias=True,
|
||||
validate_by_name=True,
|
||||
serialize_by_alias=True
|
||||
)
|
||||
|
||||
user_name: str = Field(alias="userName")
|
||||
|
||||
# 둘 다 가능
|
||||
response1 = APIResponse(userName="John")
|
||||
response2 = APIResponse(user_name="John")
|
||||
|
||||
print(response1.model_dump(by_alias=True)) # {"userName": "John"}
|
||||
```
|
||||
|
||||
**예시 - alias_generator:**
|
||||
```python
|
||||
def to_camel(name: str) -> str:
|
||||
parts = name.split('_')
|
||||
return parts[0] + ''.join(word.capitalize() for word in parts[1:])
|
||||
|
||||
class CamelModel(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
alias_generator=to_camel,
|
||||
serialize_by_alias=True
|
||||
)
|
||||
|
||||
first_name: str
|
||||
last_name: str
|
||||
|
||||
data = CamelModel(firstName="John", lastName="Doe")
|
||||
print(data.model_dump(by_alias=True))
|
||||
# {"firstName": "John", "lastName": "Doe"}
|
||||
```
|
||||
|
||||
### JSON 스키마
|
||||
|
||||
| 옵션 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| `title` | `str \| None` | `None` | JSON 스키마 타이틀 |
|
||||
| `json_schema_extra` | `dict \| Callable \| None` | `None` | JSON 스키마에 추가할 정보 |
|
||||
| `json_schema_serialization_defaults_required` | `bool` | `False` | 직렬화 스키마에서 기본값이 있는 필드도 required로 표시 |
|
||||
| `json_schema_mode_override` | `Literal['validation', 'serialization', None]` | `None` | JSON 스키마 모드 강제 지정 |
|
||||
|
||||
**예시 - json_schema_extra:**
|
||||
```python
|
||||
class Product(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
title="상품 정보",
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"name": "노트북",
|
||||
"price": 1500000
|
||||
},
|
||||
"description": "상품 데이터를 나타내는 모델"
|
||||
}
|
||||
)
|
||||
|
||||
name: str
|
||||
price: int
|
||||
|
||||
# OpenAPI/Swagger 문서에 예시가 표시됨
|
||||
```
|
||||
|
||||
**예시 - Callable json_schema_extra:**
|
||||
```python
|
||||
def add_examples(schema: dict) -> dict:
|
||||
schema["examples"] = [
|
||||
{"name": "예시1", "value": 100},
|
||||
{"name": "예시2", "value": 200}
|
||||
]
|
||||
return schema
|
||||
|
||||
class DynamicSchema(BaseModel):
|
||||
model_config = ConfigDict(json_schema_extra=add_examples)
|
||||
|
||||
name: str
|
||||
value: int
|
||||
```
|
||||
|
||||
### ORM/속성 모드
|
||||
|
||||
| 옵션 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| `from_attributes` | `bool` | `False` | 객체 속성에서 모델 생성 허용 (SQLAlchemy 등) |
|
||||
|
||||
**예시:**
|
||||
```python
|
||||
class UserORM:
|
||||
def __init__(self, name: str, age: int):
|
||||
self.name = name
|
||||
self.age = age
|
||||
|
||||
class UserModel(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
name: str
|
||||
age: int
|
||||
|
||||
orm_user = UserORM(name="John", age=30)
|
||||
pydantic_user = UserModel.model_validate(orm_user)
|
||||
print(pydantic_user) # name='John' age=30
|
||||
```
|
||||
|
||||
### Enum 처리
|
||||
|
||||
| 옵션 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| `use_enum_values` | `bool` | `False` | Enum 대신 값(value)으로 저장 |
|
||||
|
||||
**예시:**
|
||||
```python
|
||||
from enum import Enum
|
||||
|
||||
class Status(Enum):
|
||||
ACTIVE = "active"
|
||||
INACTIVE = "inactive"
|
||||
|
||||
class User(BaseModel):
|
||||
model_config = ConfigDict(use_enum_values=True)
|
||||
|
||||
status: Status
|
||||
|
||||
user = User(status=Status.ACTIVE)
|
||||
print(user.status) # "active" (문자열)
|
||||
print(type(user.status)) # <class 'str'>
|
||||
|
||||
# use_enum_values=False (기본값)이면
|
||||
# user.status는 Status.ACTIVE (Enum 객체)
|
||||
```
|
||||
|
||||
### 직렬화 설정
|
||||
|
||||
| 옵션 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| `ser_json_timedelta` | `'iso8601' \| 'float'` | `'iso8601'` | timedelta JSON 직렬화 형식 |
|
||||
| `ser_json_bytes` | `'utf8' \| 'base64' \| 'hex'` | `'utf8'` | bytes JSON 직렬화 인코딩 |
|
||||
| `ser_json_inf_nan` | `'null' \| 'constants' \| 'strings'` | `'null'` | 무한대/NaN JSON 직렬화 형식 |
|
||||
|
||||
### 숫자/Float 설정
|
||||
|
||||
| 옵션 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| `allow_inf_nan` | `bool` | `True` | float에서 무한대/NaN 허용 |
|
||||
| `coerce_numbers_to_str` | `bool` | `False` | 숫자를 문자열로 강제 변환 허용 |
|
||||
|
||||
### 기타 설정
|
||||
|
||||
| 옵션 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| `protected_namespaces` | `tuple[str, ...]` | `('model_',)` | 보호할 필드명 접두사 |
|
||||
| `hide_input_in_errors` | `bool` | `False` | 에러 메시지에서 입력값 숨김 |
|
||||
| `defer_build` | `bool` | `False` | validator/serializer 빌드 지연 |
|
||||
| `use_attribute_docstrings` | `bool` | `False` | 속성 docstring을 필드 설명으로 사용 |
|
||||
| `regex_engine` | `'rust-regex' \| 'python-re'` | `'rust-regex'` | 정규식 엔진 선택 |
|
||||
| `validation_error_cause` | `bool` | `False` | Python 예외를 에러 원인에 포함 |
|
||||
|
||||
## 설정 상속
|
||||
|
||||
자식 모델은 부모 모델의 `model_config`를 상속받습니다.
|
||||
|
||||
```python
|
||||
class ParentModel(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
str_strip_whitespace=True,
|
||||
extra='allow'
|
||||
)
|
||||
|
||||
name: str
|
||||
|
||||
class ChildModel(ParentModel):
|
||||
model_config = ConfigDict(
|
||||
frozen=True # 부모 설정 + frozen=True
|
||||
)
|
||||
|
||||
age: int
|
||||
|
||||
# ChildModel은 str_strip_whitespace=True, extra='allow', frozen=True
|
||||
```
|
||||
|
||||
## FastAPI와 함께 사용
|
||||
|
||||
FastAPI에서 요청/응답 스키마로 사용할 때 특히 유용합니다.
|
||||
|
||||
```python
|
||||
from fastapi import FastAPI
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
class CreateUserRequest(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
str_strip_whitespace=True,
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"username": "johndoe",
|
||||
"email": "john@example.com"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
username: str = Field(..., min_length=3, max_length=50)
|
||||
email: str
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
from_attributes=True, # ORM 객체에서 변환 가능
|
||||
serialize_by_alias=True
|
||||
)
|
||||
|
||||
id: int
|
||||
user_name: str = Field(alias="userName")
|
||||
|
||||
@app.post("/users", response_model=UserResponse)
|
||||
async def create_user(user: CreateUserRequest):
|
||||
# user.username은 자동으로 공백이 제거됨
|
||||
...
|
||||
```
|
||||
|
||||
## 주의사항
|
||||
|
||||
1. **v1에서 마이그레이션**: `class Config`는 deprecated입니다. `model_config = ConfigDict(...)`를 사용하세요.
|
||||
|
||||
2. **populate_by_name은 deprecated**: `validate_by_alias`와 `validate_by_name`을 함께 사용하세요.
|
||||
|
||||
3. **json_encoders는 deprecated**: 커스텀 직렬화가 필요하면 `@field_serializer` 데코레이터를 사용하세요.
|
||||
|
||||
## 참고 자료
|
||||
|
||||
- [Pydantic Configuration API 공식 문서](https://docs.pydantic.dev/latest/api/config/)
|
||||
- [Pydantic Models 개념](https://docs.pydantic.dev/latest/concepts/models/)
|
||||
- [Pydantic Migration Guide](https://docs.pydantic.dev/latest/migration/)
|
||||
# Pydantic ConfigDict 사용 매뉴얼
|
||||
|
||||
## 개요
|
||||
|
||||
Pydantic v2에서 `ConfigDict`는 모델의 유효성 검사, 직렬화, JSON 스키마 생성 등의 동작을 제어하는 설정을 정의하는 TypedDict입니다.
|
||||
|
||||
> Pydantic v1의 `class Config`는 더 이상 권장되지 않으며, `ConfigDict`를 사용해야 합니다.
|
||||
|
||||
## 기본 사용법
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
class MyModel(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
str_strip_whitespace=True,
|
||||
strict=True
|
||||
)
|
||||
|
||||
name: str
|
||||
age: int
|
||||
```
|
||||
|
||||
## 설정 옵션 전체 목록
|
||||
|
||||
### 문자열 처리
|
||||
|
||||
| 옵션 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| `str_to_lower` | `bool` | `False` | 문자열을 소문자로 변환 |
|
||||
| `str_to_upper` | `bool` | `False` | 문자열을 대문자로 변환 |
|
||||
| `str_strip_whitespace` | `bool` | `False` | 문자열 앞뒤 공백 제거 |
|
||||
| `str_min_length` | `int \| None` | `None` | 문자열 최소 길이 |
|
||||
| `str_max_length` | `int \| None` | `None` | 문자열 최대 길이 |
|
||||
|
||||
**예시:**
|
||||
```python
|
||||
class UserInput(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
str_strip_whitespace=True,
|
||||
str_to_lower=True,
|
||||
str_min_length=1,
|
||||
str_max_length=100
|
||||
)
|
||||
|
||||
username: str
|
||||
|
||||
user = UserInput(username=" HELLO ")
|
||||
print(user.username) # "hello"
|
||||
```
|
||||
|
||||
### 유효성 검사
|
||||
|
||||
| 옵션 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| `strict` | `bool` | `False` | 엄격한 타입 검사 활성화 (타입 강제 변환 비활성화) |
|
||||
| `validate_assignment` | `bool` | `False` | 속성 할당 시 유효성 검사 수행 |
|
||||
| `validate_default` | `bool` | `False` | 기본값도 유효성 검사 수행 |
|
||||
| `validate_return` | `bool` | `False` | 반환값 유효성 검사 |
|
||||
| `revalidate_instances` | `Literal['always', 'never', 'subclass-instances']` | `'never'` | 모델 인스턴스 재검증 시점 |
|
||||
| `arbitrary_types_allowed` | `bool` | `False` | Pydantic이 지원하지 않는 타입 허용 |
|
||||
|
||||
**예시 - strict 모드:**
|
||||
```python
|
||||
class StrictModel(BaseModel):
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
count: int
|
||||
|
||||
# strict=False (기본값): "123" -> 123 자동 변환
|
||||
# strict=True: "123" 입력 시 ValidationError 발생
|
||||
```
|
||||
|
||||
**예시 - validate_assignment:**
|
||||
```python
|
||||
class User(BaseModel):
|
||||
model_config = ConfigDict(validate_assignment=True)
|
||||
|
||||
age: int
|
||||
|
||||
user = User(age=25)
|
||||
user.age = "invalid" # ValidationError 발생
|
||||
```
|
||||
|
||||
### Extra 필드 처리
|
||||
|
||||
| 옵션 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| `extra` | `'allow' \| 'ignore' \| 'forbid'` | `'ignore'` | 추가 필드 처리 방식 |
|
||||
|
||||
**값 설명:**
|
||||
- `'ignore'`: 추가 필드 무시 (기본값)
|
||||
- `'allow'`: 추가 필드 허용, `__pydantic_extra__`에 저장
|
||||
- `'forbid'`: 추가 필드 입력 시 에러 발생
|
||||
|
||||
**예시:**
|
||||
```python
|
||||
class AllowExtra(BaseModel):
|
||||
model_config = ConfigDict(extra='allow')
|
||||
|
||||
name: str
|
||||
|
||||
data = AllowExtra(name="John", unknown_field="value")
|
||||
print(data.__pydantic_extra__) # {'unknown_field': 'value'}
|
||||
|
||||
class ForbidExtra(BaseModel):
|
||||
model_config = ConfigDict(extra='forbid')
|
||||
|
||||
name: str
|
||||
|
||||
ForbidExtra(name="John", unknown="value") # ValidationError 발생
|
||||
```
|
||||
|
||||
### 불변성 (Immutability)
|
||||
|
||||
| 옵션 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| `frozen` | `bool` | `False` | 모델을 불변(immutable)으로 만듦, `__hash__()` 구현 |
|
||||
|
||||
**예시:**
|
||||
```python
|
||||
class ImmutableUser(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
name: str
|
||||
age: int
|
||||
|
||||
user = ImmutableUser(name="John", age=30)
|
||||
user.age = 31 # 에러 발생: Instance is frozen
|
||||
|
||||
# frozen=True이면 해시 가능
|
||||
users_set = {user} # 정상 작동
|
||||
```
|
||||
|
||||
### 별칭 (Alias) 설정
|
||||
|
||||
| 옵션 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| `populate_by_name` | `bool` | `False` | 필드명과 별칭 모두로 값 설정 허용 (deprecated) |
|
||||
| `validate_by_alias` | `bool` | `True` | 별칭으로 필드 값 설정 허용 |
|
||||
| `validate_by_name` | `bool` | `False` | 별칭이 있어도 필드명으로 값 설정 허용 |
|
||||
| `serialize_by_alias` | `bool` | `False` | 직렬화 시 별칭 사용 |
|
||||
| `alias_generator` | `Callable[[str], str] \| None` | `None` | 별칭 자동 생성 함수 |
|
||||
| `loc_by_alias` | `bool` | `True` | 에러 위치에 별칭 사용 |
|
||||
|
||||
**예시:**
|
||||
```python
|
||||
from pydantic import Field
|
||||
|
||||
class APIResponse(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
validate_by_alias=True,
|
||||
validate_by_name=True,
|
||||
serialize_by_alias=True
|
||||
)
|
||||
|
||||
user_name: str = Field(alias="userName")
|
||||
|
||||
# 둘 다 가능
|
||||
response1 = APIResponse(userName="John")
|
||||
response2 = APIResponse(user_name="John")
|
||||
|
||||
print(response1.model_dump(by_alias=True)) # {"userName": "John"}
|
||||
```
|
||||
|
||||
**예시 - alias_generator:**
|
||||
```python
|
||||
def to_camel(name: str) -> str:
|
||||
parts = name.split('_')
|
||||
return parts[0] + ''.join(word.capitalize() for word in parts[1:])
|
||||
|
||||
class CamelModel(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
alias_generator=to_camel,
|
||||
serialize_by_alias=True
|
||||
)
|
||||
|
||||
first_name: str
|
||||
last_name: str
|
||||
|
||||
data = CamelModel(firstName="John", lastName="Doe")
|
||||
print(data.model_dump(by_alias=True))
|
||||
# {"firstName": "John", "lastName": "Doe"}
|
||||
```
|
||||
|
||||
### JSON 스키마
|
||||
|
||||
| 옵션 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| `title` | `str \| None` | `None` | JSON 스키마 타이틀 |
|
||||
| `json_schema_extra` | `dict \| Callable \| None` | `None` | JSON 스키마에 추가할 정보 |
|
||||
| `json_schema_serialization_defaults_required` | `bool` | `False` | 직렬화 스키마에서 기본값이 있는 필드도 required로 표시 |
|
||||
| `json_schema_mode_override` | `Literal['validation', 'serialization', None]` | `None` | JSON 스키마 모드 강제 지정 |
|
||||
|
||||
**예시 - json_schema_extra:**
|
||||
```python
|
||||
class Product(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
title="상품 정보",
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"name": "노트북",
|
||||
"price": 1500000
|
||||
},
|
||||
"description": "상품 데이터를 나타내는 모델"
|
||||
}
|
||||
)
|
||||
|
||||
name: str
|
||||
price: int
|
||||
|
||||
# OpenAPI/Swagger 문서에 예시가 표시됨
|
||||
```
|
||||
|
||||
**예시 - Callable json_schema_extra:**
|
||||
```python
|
||||
def add_examples(schema: dict) -> dict:
|
||||
schema["examples"] = [
|
||||
{"name": "예시1", "value": 100},
|
||||
{"name": "예시2", "value": 200}
|
||||
]
|
||||
return schema
|
||||
|
||||
class DynamicSchema(BaseModel):
|
||||
model_config = ConfigDict(json_schema_extra=add_examples)
|
||||
|
||||
name: str
|
||||
value: int
|
||||
```
|
||||
|
||||
### ORM/속성 모드
|
||||
|
||||
| 옵션 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| `from_attributes` | `bool` | `False` | 객체 속성에서 모델 생성 허용 (SQLAlchemy 등) |
|
||||
|
||||
**예시:**
|
||||
```python
|
||||
class UserORM:
|
||||
def __init__(self, name: str, age: int):
|
||||
self.name = name
|
||||
self.age = age
|
||||
|
||||
class UserModel(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
name: str
|
||||
age: int
|
||||
|
||||
orm_user = UserORM(name="John", age=30)
|
||||
pydantic_user = UserModel.model_validate(orm_user)
|
||||
print(pydantic_user) # name='John' age=30
|
||||
```
|
||||
|
||||
### Enum 처리
|
||||
|
||||
| 옵션 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| `use_enum_values` | `bool` | `False` | Enum 대신 값(value)으로 저장 |
|
||||
|
||||
**예시:**
|
||||
```python
|
||||
from enum import Enum
|
||||
|
||||
class Status(Enum):
|
||||
ACTIVE = "active"
|
||||
INACTIVE = "inactive"
|
||||
|
||||
class User(BaseModel):
|
||||
model_config = ConfigDict(use_enum_values=True)
|
||||
|
||||
status: Status
|
||||
|
||||
user = User(status=Status.ACTIVE)
|
||||
print(user.status) # "active" (문자열)
|
||||
print(type(user.status)) # <class 'str'>
|
||||
|
||||
# use_enum_values=False (기본값)이면
|
||||
# user.status는 Status.ACTIVE (Enum 객체)
|
||||
```
|
||||
|
||||
### 직렬화 설정
|
||||
|
||||
| 옵션 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| `ser_json_timedelta` | `'iso8601' \| 'float'` | `'iso8601'` | timedelta JSON 직렬화 형식 |
|
||||
| `ser_json_bytes` | `'utf8' \| 'base64' \| 'hex'` | `'utf8'` | bytes JSON 직렬화 인코딩 |
|
||||
| `ser_json_inf_nan` | `'null' \| 'constants' \| 'strings'` | `'null'` | 무한대/NaN JSON 직렬화 형식 |
|
||||
|
||||
### 숫자/Float 설정
|
||||
|
||||
| 옵션 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| `allow_inf_nan` | `bool` | `True` | float에서 무한대/NaN 허용 |
|
||||
| `coerce_numbers_to_str` | `bool` | `False` | 숫자를 문자열로 강제 변환 허용 |
|
||||
|
||||
### 기타 설정
|
||||
|
||||
| 옵션 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| `protected_namespaces` | `tuple[str, ...]` | `('model_',)` | 보호할 필드명 접두사 |
|
||||
| `hide_input_in_errors` | `bool` | `False` | 에러 메시지에서 입력값 숨김 |
|
||||
| `defer_build` | `bool` | `False` | validator/serializer 빌드 지연 |
|
||||
| `use_attribute_docstrings` | `bool` | `False` | 속성 docstring을 필드 설명으로 사용 |
|
||||
| `regex_engine` | `'rust-regex' \| 'python-re'` | `'rust-regex'` | 정규식 엔진 선택 |
|
||||
| `validation_error_cause` | `bool` | `False` | Python 예외를 에러 원인에 포함 |
|
||||
|
||||
## 설정 상속
|
||||
|
||||
자식 모델은 부모 모델의 `model_config`를 상속받습니다.
|
||||
|
||||
```python
|
||||
class ParentModel(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
str_strip_whitespace=True,
|
||||
extra='allow'
|
||||
)
|
||||
|
||||
name: str
|
||||
|
||||
class ChildModel(ParentModel):
|
||||
model_config = ConfigDict(
|
||||
frozen=True # 부모 설정 + frozen=True
|
||||
)
|
||||
|
||||
age: int
|
||||
|
||||
# ChildModel은 str_strip_whitespace=True, extra='allow', frozen=True
|
||||
```
|
||||
|
||||
## FastAPI와 함께 사용
|
||||
|
||||
FastAPI에서 요청/응답 스키마로 사용할 때 특히 유용합니다.
|
||||
|
||||
```python
|
||||
from fastapi import FastAPI
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
class CreateUserRequest(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
str_strip_whitespace=True,
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"username": "johndoe",
|
||||
"email": "john@example.com"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
username: str = Field(..., min_length=3, max_length=50)
|
||||
email: str
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
from_attributes=True, # ORM 객체에서 변환 가능
|
||||
serialize_by_alias=True
|
||||
)
|
||||
|
||||
id: int
|
||||
user_name: str = Field(alias="userName")
|
||||
|
||||
@app.post("/users", response_model=UserResponse)
|
||||
async def create_user(user: CreateUserRequest):
|
||||
# user.username은 자동으로 공백이 제거됨
|
||||
...
|
||||
```
|
||||
|
||||
## 주의사항
|
||||
|
||||
1. **v1에서 마이그레이션**: `class Config`는 deprecated입니다. `model_config = ConfigDict(...)`를 사용하세요.
|
||||
|
||||
2. **populate_by_name은 deprecated**: `validate_by_alias`와 `validate_by_name`을 함께 사용하세요.
|
||||
|
||||
3. **json_encoders는 deprecated**: 커스텀 직렬화가 필요하면 `@field_serializer` 데코레이터를 사용하세요.
|
||||
|
||||
## 참고 자료
|
||||
|
||||
- [Pydantic Configuration API 공식 문서](https://docs.pydantic.dev/latest/api/config/)
|
||||
- [Pydantic Models 개념](https://docs.pydantic.dev/latest/concepts/models/)
|
||||
- [Pydantic Migration Guide](https://docs.pydantic.dev/latest/migration/)
|
||||
|
|
|
|||
|
|
@ -1,158 +1,158 @@
|
|||
from app.core.database import Base
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
DateTime,
|
||||
Enum,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
PrimaryKeyConstraint,
|
||||
String,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from starlette.authentication import BaseUser
|
||||
|
||||
|
||||
class User(Base, BaseUser):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[int] = mapped_column(
|
||||
Integer, primary_key=True, nullable=False, autoincrement=True
|
||||
)
|
||||
username: Mapped[str] = mapped_column(
|
||||
String(255), unique=True, nullable=False, index=True
|
||||
)
|
||||
email: Mapped[str] = mapped_column(
|
||||
String(255), unique=True, nullable=False, index=True
|
||||
)
|
||||
hashed_password: Mapped[str] = mapped_column(String(60), nullable=False)
|
||||
# age_level 컬럼을 Enum으로 정의
|
||||
age_level_choices = ["10", "20", "30", "40", "50", "60", "70", "80"]
|
||||
age_level: Mapped[str] = mapped_column(
|
||||
Enum(*age_level_choices, name="age_level_enum"),
|
||||
nullable=False,
|
||||
default="10",
|
||||
)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[DateTime] = mapped_column(DateTime, server_default=func.now())
|
||||
|
||||
# One-to-many relationship with Post (DynamicMapped + lazy="dynamic")
|
||||
posts_user: Mapped[list["Post"]] = relationship("Post", back_populates="user_posts")
|
||||
|
||||
# # Many-to-many relationship with Group
|
||||
# user_groups: DynamicMapped["UserGroupAssociation"] = relationship(
|
||||
# "UserGroupAssociation", back_populates="user", lazy="dynamic"
|
||||
# )
|
||||
# n:m 관계 (Group) – 최적의 lazy 옵션: selectin
|
||||
group_user: Mapped[list["Group"]] = relationship(
|
||||
"Group",
|
||||
secondary="user_group_association",
|
||||
back_populates="user_group",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"id={self.id}, username={self.username}"
|
||||
|
||||
@property
|
||||
def is_authenticated(self) -> bool:
|
||||
return self.is_active
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
return self.username
|
||||
|
||||
@property
|
||||
def identity(self) -> str:
|
||||
return self.username
|
||||
|
||||
|
||||
# 1:N Relationship - Posts
|
||||
class Post(Base):
|
||||
__tablename__ = "posts"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
content: Mapped[str] = mapped_column(String(10000), nullable=False)
|
||||
is_published: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
created_at: Mapped[DateTime] = mapped_column(DateTime, server_default=func.now())
|
||||
updated_at: Mapped[DateTime] = mapped_column(
|
||||
DateTime, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
# tags: Mapped[dict] = mapped_column(MutableDict.as_mutable(JSONB), default=[]) // sqlite 지원 안함
|
||||
view_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
# Many-to-one relationship with User (using dynamic loading)
|
||||
user_posts: Mapped["User"] = relationship("User", back_populates="posts_user")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"Post(id={self.id}, user_id={self.user_id}, title={self.title})"
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_posts_user_id", "user_id"),
|
||||
Index("idx_posts_created_at", "created_at"),
|
||||
Index(
|
||||
"idx_posts_user_id_created_at", "user_id", "created_at"
|
||||
), # Composite index
|
||||
)
|
||||
|
||||
|
||||
# N:M Relationship - Users and Groups
|
||||
# Association table for many-to-many relationship
|
||||
# N:M Association Table (중간 테이블)
|
||||
class UserGroupAssociation(Base):
|
||||
__tablename__ = "user_group_association"
|
||||
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
group_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
|
||||
# # 관계 정의
|
||||
# user: Mapped["User"] = relationship("User", back_populates="user_groups")
|
||||
# group: Mapped["Group"] = relationship("Group", back_populates="group_users")
|
||||
# # 복합 기본 키 설정
|
||||
|
||||
# 기본 키 설정을 위한 __table_args__ 추가
|
||||
__table_args__ = (PrimaryKeyConstraint("user_id", "group_id"),)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"UserGroupAssociation(user_id={self.user_id}, group_id={self.group_id})"
|
||||
|
||||
|
||||
# Group 테이블
|
||||
class Group(Base):
|
||||
__tablename__ = "groups"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
|
||||
description: Mapped[str] = mapped_column(String(1000))
|
||||
is_public: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[DateTime] = mapped_column(DateTime, server_default=func.now())
|
||||
updated_at: Mapped[DateTime] = mapped_column(
|
||||
DateTime, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
user_group: Mapped[list["User"]] = relationship(
|
||||
"User",
|
||||
secondary="user_group_association",
|
||||
back_populates="group_user",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
# Group을 만든 사용자와 관계 (일반적인 1:N 관계)
|
||||
def __repr__(self) -> str:
|
||||
return f"Group(id={self.id}, name={self.name})"
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_groups_name", "name"),
|
||||
Index("idx_groups_is_public", "is_public"),
|
||||
Index("idx_groups_created_at", "created_at"),
|
||||
Index("idx_groups_composite", "is_public", "created_at"),
|
||||
)
|
||||
from app.core.database import Base
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
DateTime,
|
||||
Enum,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
PrimaryKeyConstraint,
|
||||
String,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from starlette.authentication import BaseUser
|
||||
|
||||
|
||||
class User(Base, BaseUser):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[int] = mapped_column(
|
||||
Integer, primary_key=True, nullable=False, autoincrement=True
|
||||
)
|
||||
username: Mapped[str] = mapped_column(
|
||||
String(255), unique=True, nullable=False, index=True
|
||||
)
|
||||
email: Mapped[str] = mapped_column(
|
||||
String(255), unique=True, nullable=False, index=True
|
||||
)
|
||||
hashed_password: Mapped[str] = mapped_column(String(60), nullable=False)
|
||||
# age_level 컬럼을 Enum으로 정의
|
||||
age_level_choices = ["10", "20", "30", "40", "50", "60", "70", "80"]
|
||||
age_level: Mapped[str] = mapped_column(
|
||||
Enum(*age_level_choices, name="age_level_enum"),
|
||||
nullable=False,
|
||||
default="10",
|
||||
)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[DateTime] = mapped_column(DateTime, server_default=func.now())
|
||||
|
||||
# One-to-many relationship with Post (DynamicMapped + lazy="dynamic")
|
||||
posts_user: Mapped[list["Post"]] = relationship("Post", back_populates="user_posts")
|
||||
|
||||
# # Many-to-many relationship with Group
|
||||
# user_groups: DynamicMapped["UserGroupAssociation"] = relationship(
|
||||
# "UserGroupAssociation", back_populates="user", lazy="dynamic"
|
||||
# )
|
||||
# n:m 관계 (Group) – 최적의 lazy 옵션: selectin
|
||||
group_user: Mapped[list["Group"]] = relationship(
|
||||
"Group",
|
||||
secondary="user_group_association",
|
||||
back_populates="user_group",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"id={self.id}, username={self.username}"
|
||||
|
||||
@property
|
||||
def is_authenticated(self) -> bool:
|
||||
return self.is_active
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
return self.username
|
||||
|
||||
@property
|
||||
def identity(self) -> str:
|
||||
return self.username
|
||||
|
||||
|
||||
# 1:N Relationship - Posts
|
||||
class Post(Base):
|
||||
__tablename__ = "posts"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
content: Mapped[str] = mapped_column(String(10000), nullable=False)
|
||||
is_published: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
created_at: Mapped[DateTime] = mapped_column(DateTime, server_default=func.now())
|
||||
updated_at: Mapped[DateTime] = mapped_column(
|
||||
DateTime, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
# tags: Mapped[dict] = mapped_column(MutableDict.as_mutable(JSONB), default=[]) // sqlite 지원 안함
|
||||
view_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
# Many-to-one relationship with User (using dynamic loading)
|
||||
user_posts: Mapped["User"] = relationship("User", back_populates="posts_user")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"Post(id={self.id}, user_id={self.user_id}, title={self.title})"
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_posts_user_id", "user_id"),
|
||||
Index("idx_posts_created_at", "created_at"),
|
||||
Index(
|
||||
"idx_posts_user_id_created_at", "user_id", "created_at"
|
||||
), # Composite index
|
||||
)
|
||||
|
||||
|
||||
# N:M Relationship - Users and Groups
|
||||
# Association table for many-to-many relationship
|
||||
# N:M Association Table (중간 테이블)
|
||||
class UserGroupAssociation(Base):
|
||||
__tablename__ = "user_group_association"
|
||||
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
group_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
|
||||
# # 관계 정의
|
||||
# user: Mapped["User"] = relationship("User", back_populates="user_groups")
|
||||
# group: Mapped["Group"] = relationship("Group", back_populates="group_users")
|
||||
# # 복합 기본 키 설정
|
||||
|
||||
# 기본 키 설정을 위한 __table_args__ 추가
|
||||
__table_args__ = (PrimaryKeyConstraint("user_id", "group_id"),)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"UserGroupAssociation(user_id={self.user_id}, group_id={self.group_id})"
|
||||
|
||||
|
||||
# Group 테이블
|
||||
class Group(Base):
|
||||
__tablename__ = "groups"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
|
||||
description: Mapped[str] = mapped_column(String(1000))
|
||||
is_public: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[DateTime] = mapped_column(DateTime, server_default=func.now())
|
||||
updated_at: Mapped[DateTime] = mapped_column(
|
||||
DateTime, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
user_group: Mapped[list["User"]] = relationship(
|
||||
"User",
|
||||
secondary="user_group_association",
|
||||
back_populates="group_user",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
# Group을 만든 사용자와 관계 (일반적인 1:N 관계)
|
||||
def __repr__(self) -> str:
|
||||
return f"Group(id={self.id}, name={self.name})"
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_groups_name", "name"),
|
||||
Index("idx_groups_is_public", "is_public"),
|
||||
Index("idx_groups_created_at", "created_at"),
|
||||
Index("idx_groups_composite", "is_public", "created_at"),
|
||||
)
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 191 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 191 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 191 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 191 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 191 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 191 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 152 KiB |
108
main.py
|
|
@ -1,54 +1,54 @@
|
|||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from scalar_fastapi import get_scalar_api_reference
|
||||
|
||||
from app.admin_manager import init_admin
|
||||
from app.core.common import lifespan
|
||||
from app.database.session import engine
|
||||
from app.home.api.routers.v1.home import router as home_router
|
||||
from app.lyric.api.routers.v1.lyric import router as lyric_router
|
||||
from app.song.api.routers.v1.song import router as song_router
|
||||
from app.video.api.routers.v1.video import router as video_router
|
||||
from app.utils.cors import CustomCORSMiddleware
|
||||
from config import prj_settings
|
||||
|
||||
app = FastAPI(
|
||||
title=prj_settings.PROJECT_NAME,
|
||||
version=prj_settings.VERSION,
|
||||
description=prj_settings.DESCRIPTION,
|
||||
lifespan=lifespan,
|
||||
docs_url=None, # 기본 Swagger UI 비활성화
|
||||
redoc_url=None, # 기본 ReDoc 비활성화
|
||||
)
|
||||
|
||||
init_admin(app, engine)
|
||||
|
||||
custom_cors_middleware = CustomCORSMiddleware(app)
|
||||
custom_cors_middleware.configure_cors()
|
||||
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
app.mount("/media", StaticFiles(directory="media"), name="media")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
allow_credentials=True,
|
||||
max_age=-1,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/docs", include_in_schema=False)
|
||||
def get_scalar_docs():
|
||||
return get_scalar_api_reference(
|
||||
openapi_url=app.openapi_url,
|
||||
title="Scalar API",
|
||||
)
|
||||
|
||||
|
||||
app.include_router(home_router)
|
||||
app.include_router(lyric_router) # Lyric API 라우터 추가
|
||||
app.include_router(song_router) # Song API 라우터 추가
|
||||
app.include_router(video_router) # Video API 라우터 추가
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from scalar_fastapi import get_scalar_api_reference
|
||||
|
||||
from app.admin_manager import init_admin
|
||||
from app.core.common import lifespan
|
||||
from app.database.session import engine
|
||||
from app.home.api.routers.v1.home import router as home_router
|
||||
from app.lyric.api.routers.v1.lyric import router as lyric_router
|
||||
from app.song.api.routers.v1.song import router as song_router
|
||||
from app.video.api.routers.v1.video import router as video_router
|
||||
from app.utils.cors import CustomCORSMiddleware
|
||||
from config import prj_settings
|
||||
|
||||
app = FastAPI(
|
||||
title=prj_settings.PROJECT_NAME,
|
||||
version=prj_settings.VERSION,
|
||||
description=prj_settings.DESCRIPTION,
|
||||
lifespan=lifespan,
|
||||
docs_url=None, # 기본 Swagger UI 비활성화
|
||||
redoc_url=None, # 기본 ReDoc 비활성화
|
||||
)
|
||||
|
||||
init_admin(app, engine)
|
||||
|
||||
custom_cors_middleware = CustomCORSMiddleware(app)
|
||||
custom_cors_middleware.configure_cors()
|
||||
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
app.mount("/media", StaticFiles(directory="media"), name="media")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
allow_credentials=True,
|
||||
max_age=-1,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/docs", include_in_schema=False)
|
||||
def get_scalar_docs():
|
||||
return get_scalar_api_reference(
|
||||
openapi_url=app.openapi_url,
|
||||
title="Scalar API",
|
||||
)
|
||||
|
||||
|
||||
app.include_router(home_router)
|
||||
app.include_router(lyric_router) # Lyric API 라우터 추가
|
||||
app.include_router(song_router) # Song API 라우터 추가
|
||||
app.include_router(video_router) # Video API 라우터 추가
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
import asyncio
|
||||
from nvMapScraper import nvMapScraper
|
||||
from nvMapPwScraper import nvMapPwScraper
|
||||
|
||||
async def main_function():
|
||||
await nvMapPwScraper.initiate_scraper()
|
||||
selected = {'title': '<b>스테이</b>,<b>머뭄</b>',
|
||||
'link': 'https://www.instagram.com/staymeomoom',
|
||||
'category': '숙박>펜션',
|
||||
'description': '',
|
||||
'telephone': '',
|
||||
'address': '전북특별자치도 군산시 신흥동 63-18',
|
||||
'roadAddress': '전북특별자치도 군산시 절골길 18',
|
||||
'mapx': '1267061254',
|
||||
'mapy': '359864175',
|
||||
'lng': 126.7061254,
|
||||
'lat': 35.9864175}
|
||||
|
||||
async with nvMapPwScraper() as pw_scraper:
|
||||
new_url = await pw_scraper.get_place_id_url(selected)
|
||||
|
||||
print(new_url)
|
||||
nv_scraper = nvMapScraper(new_url) # 이후 동일한 플로우
|
||||
await nv_scraper.scrap()
|
||||
print(nv_scraper.rawdata)
|
||||
return
|
||||
|
||||
print("running main_funtion..")
|
||||
asyncio.run(main_function())
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
import asyncio
|
||||
from playwright.async_api import async_playwright
|
||||
from urllib import parse
|
||||
|
||||
class nvMapPwScraper():
|
||||
# cls vars
|
||||
is_ready = False
|
||||
_playwright = None
|
||||
_browser = None
|
||||
_context = None
|
||||
_win_width = 1280
|
||||
_win_height = 720
|
||||
_max_retry = 30 # place id timeout threshold seconds
|
||||
|
||||
# instance var
|
||||
page = None
|
||||
|
||||
@classmethod
|
||||
def default_context_builder(cls):
|
||||
context_builder_dict = {}
|
||||
context_builder_dict['viewport'] = {
|
||||
'width' : cls._win_width,
|
||||
'height' : cls._win_height
|
||||
}
|
||||
context_builder_dict['screen'] = {
|
||||
'width' : cls._win_width,
|
||||
'height' : cls._win_height
|
||||
}
|
||||
context_builder_dict['user_agent'] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36"
|
||||
context_builder_dict['locale'] = 'ko-KR'
|
||||
context_builder_dict['timezone_id']='Asia/Seoul'
|
||||
|
||||
return context_builder_dict
|
||||
|
||||
@classmethod
|
||||
async def initiate_scraper(cls):
|
||||
if not cls._playwright:
|
||||
cls._playwright = await async_playwright().start()
|
||||
if not cls._browser:
|
||||
cls._browser = await cls._playwright.chromium.launch(headless=True)
|
||||
if not cls._context:
|
||||
cls._context = await cls._browser.new_context(**cls.default_context_builder())
|
||||
cls.is_ready = True
|
||||
|
||||
def __init__(self):
|
||||
if not self.is_ready:
|
||||
raise Exception("nvMapScraper is not initiated")
|
||||
|
||||
async def __aenter__(self):
|
||||
await self.create_page()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
await self.page.close()
|
||||
|
||||
async def create_page(self):
|
||||
self.page = await self._context.new_page()
|
||||
await self.page.add_init_script(
|
||||
'''const defaultGetter = Object.getOwnPropertyDescriptor(
|
||||
Navigator.prototype,
|
||||
"webdriver"
|
||||
).get;
|
||||
defaultGetter.apply(navigator);
|
||||
defaultGetter.toString();
|
||||
Object.defineProperty(Navigator.prototype, "webdriver", {
|
||||
set: undefined,
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
get: new Proxy(defaultGetter, {
|
||||
apply: (target, thisArg, args) => {
|
||||
Reflect.apply(target, thisArg, args);
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
});
|
||||
const patchedGetter = Object.getOwnPropertyDescriptor(
|
||||
Navigator.prototype,
|
||||
"webdriver"
|
||||
).get;
|
||||
patchedGetter.apply(navigator);
|
||||
patchedGetter.toString();''')
|
||||
|
||||
await self.page.set_extra_http_headers({
|
||||
'sec-ch-ua': '\"Not?A_Brand\";v=\"99\", \"Chromium\";v=\"130\"'
|
||||
})
|
||||
await self.page.goto("http://google.com")
|
||||
|
||||
async def goto_url(self, url, wait_until="domcontentloaded", timeout=20000):
|
||||
page = self.page
|
||||
await page.goto(url, wait_until=wait_until, timeout=timeout)
|
||||
|
||||
async def get_place_id_url(self, selected):
|
||||
|
||||
title = selected['title'].replace("<b>", "").replace("</b>", "")
|
||||
address = selected.get('roadAddress', selected['address']).replace("<b>", "").replace("</b>", "")
|
||||
encoded_query = parse.quote(f"{address} {title}")
|
||||
url = f"https://map.naver.com/p/search/{encoded_query}"
|
||||
|
||||
await self.goto_url(url, wait_until="networkidle",timeout = self._max_retry/2*1000)
|
||||
|
||||
if "/place/" in self.page.url:
|
||||
return self.page.url
|
||||
|
||||
url = self.page.url.replace("?","?isCorrectAnswer=true&")
|
||||
await self.goto_url(url, wait_until="networkidle",timeout = self._max_retry/2*1000)
|
||||
|
||||
if "/place/" in self.page.url:
|
||||
return self.page.url
|
||||
|
||||
if (count == self._max_retry / 2):
|
||||
raise Exception("Failed to identify place id. loading timeout")
|
||||
else:
|
||||
raise Exception("Failed to identify place id. item is ambiguous")
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
import re
|
||||
import aiohttp
|
||||
import json
|
||||
import asyncio
|
||||
import bs4
|
||||
|
||||
PLACE_PATTERN = r"/place/(\d+)"
|
||||
GRAPHQL_URL = "https://pcmap-api.place.naver.com/graphql"
|
||||
NAVER_COOKIES="NAC=mQ7mBownbQf4A; NNB=TQPII6AKDBFGQ; PLACE_LANGUAGE=ko; NACT=1; nid_inf=1431570813; NID_AUT=k2T7FraXOdIMRCHzEZIFtHQup+I7b87M5fd7+p65AXZTdGB/gelRmW8s/Q4oDxm8; tooltipDisplayed=true; SRT30=1762660151; NID_SES=AAAB1Lpy3y3hGzuPbJpJl8vvFx18C+HXXuZEFou/YPgocHe7k2/5MpFlgE48X1JF7c7IPoU2khZKkkuLx+tsvWAzOf0TnG/G8RrBGeawnSluSJcKcTdKKRJ4cygKc/OabVxoc3TNZJWxer3vFtXBoXkDS5querVNS6wvcMhA/p4vkPKOeepwKLR+1IJERlQJWZw4q29IdAysrbBNn3Akf9mDA5eTYvMDLYyRkToRh10TVMW/yhyNQeMXlIdnR8U1ZCNqe/9ErYdos5gQDstswEJQQA0T2cHFGJOtmlYMPlnhWado5w521iZXGJyKcA9ZawizM/i5nK5xNYtPGS3cvImUYl6B5ulIipUJSqpj8v2XstK0TZlOGxHToXaVDrCNmSfCA9vFYbTb6xJHB2JRAT3Jik/z6QgLjJLBWRnsucMDqldxoiEDAUHEhY3pjgZ89quR3c3hwAuTlI9hBn5I3e5VQR0Y/GxoS9mIkMF8pJmcGneqnE0BNIt91RN6Se5rDM69B+JWppBXtSir1JGuXADaRLLMP8VlxJX949iH0UYTKWKsrD4OgNNK5aUx24nAH494WPknBMlx4fCMIeWzy7K3sEZkNUn/+A+eHraqIFfbGpveSCNM+8EqEjMgA+YRgg3eig==; _naver_usersession_=Kkgzim/64JicPJzgkIIvqQ==; page_uid=jesTPsqVWUZssE4qJeossssssD0-011300; SRT5=1762662010; BUC=z5Fu3sAYtFwpbRDrrDFYdn4AgK5hNkOqX-DdaLU7VJM="
|
||||
|
||||
OVERVIEW_QUERY = '''
|
||||
query getAccommodation($id: String!, $deviceType: String) {
|
||||
business: placeDetail(input: {id: $id, isNx: true, deviceType: $deviceType}) {
|
||||
base {
|
||||
id
|
||||
name
|
||||
category
|
||||
roadAddress
|
||||
address
|
||||
phone
|
||||
virtualPhone
|
||||
microReviews
|
||||
conveniences
|
||||
visitorReviewsTotal
|
||||
}
|
||||
images { images { origin url } }
|
||||
cpImages(source: [ugcImage]) { images { origin url } }
|
||||
}
|
||||
}'''
|
||||
|
||||
REQUEST_HEADERS = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36",
|
||||
"Referer": "https://map.naver.com/",
|
||||
"Origin": "https://map.naver.com",
|
||||
"Content-Type": "application/json",
|
||||
"Cookie": NAVER_COOKIES
|
||||
}
|
||||
|
||||
class GraphQLException(Exception):
|
||||
pass
|
||||
|
||||
class nvMapScraper():
|
||||
url : str = None
|
||||
scrap_type : str = None
|
||||
rawdata : dict = None
|
||||
image_link_list : list[str] = None
|
||||
base_info : dict = None
|
||||
|
||||
|
||||
def __init__(self, url):
|
||||
self.url = url
|
||||
|
||||
async def parse_url(self):
|
||||
if 'place' not in self.url:
|
||||
if 'naver.me' in self.url:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(self.url) as response:
|
||||
self.url = str(response.url)
|
||||
else:
|
||||
raise GraphQLException("this shorten url not have place id")
|
||||
try:
|
||||
place_id = re.search(PLACE_PATTERN, self.url)[1]
|
||||
except Exception as E:
|
||||
raise GraphQLException("Cannot find place id")
|
||||
|
||||
return place_id
|
||||
|
||||
async def scrap(self):
|
||||
try:
|
||||
place_id = await self.parse_url()
|
||||
data = await self.call_get_accomodation(place_id)
|
||||
self.rawdata = data
|
||||
fac_data = await self.get_facility_string(place_id)
|
||||
self.rawdata['facilities'] = fac_data
|
||||
self.image_link_list = [nv_image['origin'] for nv_image in data['data']['business']['images']['images']]
|
||||
self.base_info = data['data']['business']['base']
|
||||
self.facility_info = fac_data
|
||||
self.scrap_type = "GraphQL"
|
||||
|
||||
except GraphQLException as G:
|
||||
print (G)
|
||||
print("fallback")
|
||||
self.scrap_type = "Playwright"
|
||||
pass # 나중에 pw 이용한 crawling으로 fallback 추가
|
||||
|
||||
return
|
||||
|
||||
async def call_get_accomodation(self, place_id):
|
||||
payload = {
|
||||
"operationName" : "getAccommodation",
|
||||
"variables": { "id": place_id, "deviceType": "pc" },
|
||||
"query": OVERVIEW_QUERY,
|
||||
}
|
||||
json_payload = json.dumps(payload)
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(GRAPHQL_URL, data=json_payload, headers=REQUEST_HEADERS) as response:
|
||||
response.encoding = 'utf-8'
|
||||
if response.status == 200: # 요청 성공
|
||||
return await response.json() # await 주의
|
||||
else: # 요청 실패
|
||||
print('실패 상태 코드:', response.status)
|
||||
print(response.text)
|
||||
raise Exception()
|
||||
|
||||
async def get_facility_string(self, place_id):
|
||||
url = f"https://pcmap.place.naver.com/accommodation/{place_id}/home"
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url, headers=REQUEST_HEADERS) as response:
|
||||
soup = bs4.BeautifulSoup(await response.read(), 'html.parser')
|
||||
c_elem = soup.find('span', 'place_blind', string='편의')
|
||||
facilities = c_elem.parent.parent.find('div').string
|
||||
return facilities
|
||||
|
||||
# url = "https://naver.me/IgJGCCic"
|
||||
# scraper = nvMapScraper(url)
|
||||
# asyncio.run(scraper.scrap())
|
||||
# print(scraper.image_link_list)
|
||||
# print(len(scraper.image_link_list))
|
||||
|
|
@ -1,226 +1,226 @@
|
|||
import copy
|
||||
|
||||
import requests
|
||||
|
||||
CREATOMATE_API_KEY = "df9e4382d7e84fe790bf8a2168152be195d5a3568524ceb66ed989a2dea809f7d3065d6803b2e3dd9d02b5e5ec1c9823"
|
||||
# ACCOUNT_URL = "https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/"
|
||||
# Creatomate 템플릿 정보 전부 가져오기
|
||||
|
||||
|
||||
class Creatomate:
|
||||
base_url: str = "https://api.creatomate.com"
|
||||
|
||||
def __init__(self, api_key):
|
||||
self.api_key = api_key
|
||||
|
||||
def get_all_templates_data(self) -> dict:
|
||||
url = Creatomate.base_url + "/v1/templates"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
}
|
||||
response = requests.get(url, headers=headers)
|
||||
return response.json()
|
||||
|
||||
# Creatomate 템플릿 ID 로부터 해당 템플릿 정보 가져오기
|
||||
def get_one_template_data(self, template_id: str) -> dict:
|
||||
url = Creatomate.base_url + f"/v1/templates/{template_id}"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
}
|
||||
response = requests.get(url, headers=headers)
|
||||
return response.json()
|
||||
|
||||
# 템플릿 정보 파싱하여 리소스 이름 추출하기
|
||||
def parse_template_component_name(self, template_source: dict) -> dict:
|
||||
def recursive_parse_component(element: dict) -> dict:
|
||||
if "name" in element:
|
||||
result_element_name_type = {element["name"]: element["type"]}
|
||||
else:
|
||||
result_element_name_type = {}
|
||||
|
||||
if element["type"] == "composition":
|
||||
minor_component_list = [
|
||||
recursive_parse_component(minor) for minor in element["elements"]
|
||||
]
|
||||
for minor_component in minor_component_list: ## WARNING : Same name component should shroud other component. be aware
|
||||
result_element_name_type.update(minor_component)
|
||||
|
||||
return result_element_name_type
|
||||
|
||||
result = {}
|
||||
for result_element_dict in [
|
||||
recursive_parse_component(component) for component in template_source
|
||||
]:
|
||||
result.update(result_element_dict)
|
||||
|
||||
return result
|
||||
|
||||
# 템플릿 정보 이미지/가사/음악 리소스와 매핑하기
|
||||
# 이미지는 순차적으로 집어넣기
|
||||
# 가사는 개행마다 한 텍스트 삽입
|
||||
# Template에 audio-music 항목이 있어야 함. (추가된 템플릿 Cafe뿐임)
|
||||
def template_connect_resource_blackbox(
|
||||
self, template_id: str, image_url_list: list[str], lyric: str, music_url: str
|
||||
) -> dict:
|
||||
template_data = self.get_one_template_data(template_id)
|
||||
template_component_data = self.parse_template_component_name(
|
||||
template_data["source"]["elements"]
|
||||
)
|
||||
|
||||
lyric.replace("\r", "")
|
||||
lyric_splited = lyric.split("\n")
|
||||
modifications = {}
|
||||
for idx, (template_component_name, template_type) in enumerate(
|
||||
template_component_data.items()
|
||||
):
|
||||
match template_type:
|
||||
case "image":
|
||||
modifications[template_component_name] = image_url_list[
|
||||
idx % len(image_url_list)
|
||||
]
|
||||
case "text":
|
||||
modifications[template_component_name] = lyric_splited[
|
||||
idx % len(lyric_splited)
|
||||
]
|
||||
|
||||
modifications["audio-music"] = music_url
|
||||
|
||||
return modifications
|
||||
|
||||
def elements_connect_resource_blackbox(
|
||||
self, elements: list, image_url_list: list[str], lyric: str, music_url: str
|
||||
) -> dict:
|
||||
template_component_data = self.parse_template_component_name(elements)
|
||||
|
||||
lyric.replace("\r", "")
|
||||
lyric_splited = lyric.split("\n")
|
||||
modifications = {}
|
||||
for idx, (template_component_name, template_type) in enumerate(
|
||||
template_component_data.items()
|
||||
):
|
||||
match template_type:
|
||||
case "image":
|
||||
modifications[template_component_name] = image_url_list[
|
||||
idx % len(image_url_list)
|
||||
]
|
||||
case "text":
|
||||
modifications[template_component_name] = lyric_splited[
|
||||
idx % len(lyric_splited)
|
||||
]
|
||||
|
||||
modifications["audio-music"] = music_url
|
||||
|
||||
return modifications
|
||||
|
||||
def modify_element(self, elements: list, modification: dict):
|
||||
def recursive_modify(element: dict) -> dict:
|
||||
if "name" in element:
|
||||
match element["type"]:
|
||||
case "image":
|
||||
element["source"] = modification[element["name"]]
|
||||
case "audio":
|
||||
element["source"] = modification.get(element["name"], "")
|
||||
case "video":
|
||||
element["source"] = modification[element["name"]]
|
||||
case "text":
|
||||
element["source"] = modification.get(element["name"], "")
|
||||
case "composition":
|
||||
for minor in element["elements"]:
|
||||
recursive_modify(minor)
|
||||
|
||||
for minor in elements:
|
||||
recursive_modify(minor)
|
||||
|
||||
return elements
|
||||
|
||||
# Creatomate에 생성 요청
|
||||
# response에 요청 정보 있으니 풀링 필요
|
||||
def make_creatomate_call(self, template_id: str, modifications: dict):
|
||||
url = Creatomate.base_url + "/v2/renders"
|
||||
|
||||
data = {"template_id": template_id, "modifications": modifications}
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
}
|
||||
|
||||
response = requests.post(url, json=data, headers=headers)
|
||||
return response
|
||||
|
||||
# Creatomate에 생성 요청 without template
|
||||
# response에 요청 정보 있으니 풀링 필요
|
||||
def make_creatomate_custom_call(self, source: str):
|
||||
url = Creatomate.base_url + "/v2/renders"
|
||||
data = source
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
}
|
||||
|
||||
response = requests.post(url, json=data, headers=headers)
|
||||
return response
|
||||
|
||||
def calc_scene_duration(self, template: dict):
|
||||
total_template_duration = 0
|
||||
for elem in template["source"]["elements"]:
|
||||
try:
|
||||
if elem["type"] == "audio":
|
||||
continue
|
||||
total_template_duration += elem["duration"]
|
||||
if "animations" not in elem:
|
||||
continue
|
||||
for animation in elem["animations"]:
|
||||
assert animation["time"] == 0 # 0이 아닌 경우 확인 필요
|
||||
if animation["transition"]:
|
||||
total_template_duration -= animation["duration"]
|
||||
except:
|
||||
print(elem)
|
||||
return total_template_duration
|
||||
|
||||
def extend_template_duration(self, template: dict, target_duration: float):
|
||||
template["duration"] = target_duration
|
||||
total_template_duration = self.calc_scene_duration(template)
|
||||
extend_rate = target_duration / total_template_duration
|
||||
new_template = copy.deepcopy(template)
|
||||
for elem in new_template["source"]["elements"]:
|
||||
try:
|
||||
if elem["type"] == "audio":
|
||||
continue
|
||||
elem["duration"] = elem["duration"] * extend_rate
|
||||
if "animations" not in elem:
|
||||
continue
|
||||
for animation in elem["animations"]:
|
||||
assert animation["time"] == 0 # 0이 아닌 경우 확인 필요
|
||||
animation["duration"] = animation["duration"] * extend_rate
|
||||
except:
|
||||
print(elem)
|
||||
return new_template
|
||||
|
||||
|
||||
# Azure사용한 legacy 코드 원본
|
||||
# def template_connect_resource_blackbox(template_id, user_idx, task_idx):
|
||||
# secret_client = get_keyvault_client()
|
||||
# account_url = secret_client.get_secret(BLOB_ACCOUNT_URL_KEY).value
|
||||
# media_folder_path = f"{user_idx}/{task_idx}"
|
||||
# lyric_path = f"{media_folder_path}/lyric.txt"
|
||||
# lyric = az_storage.az_storage_read_ado2_media(lyric_path).readall().decode('UTF-8')
|
||||
# media_list = az_storage.az_storage_get_ado2_media_list(media_folder_path)
|
||||
# image_list = [media.name for media in media_list if '/crawling-images/' in media.name]
|
||||
# template_data = get_one_template_data(template_id)
|
||||
# template_component_data = parse_template_component_name(template_data['source']['elements'])
|
||||
# lyric.replace("\r", "")
|
||||
# lyric_splited = lyric.split("\n")
|
||||
# modifications = {}
|
||||
# for idx, (template_component_name, template_type) in enumerate(template_component_data.items()):
|
||||
# match template_type:
|
||||
# case 'image':
|
||||
# modifications[template_component_name] = f"{account_url}/{BLOB_CONTAINER_NAME}/{image_list[idx % len(image_list)]}"
|
||||
# case 'text':
|
||||
# modifications[template_component_name] = lyric_splited[idx % len(lyric_splited)]
|
||||
|
||||
# modifications["audio-music"] = f"{account_url}/{BLOB_CONTAINER_NAME}/{BLOB_MEDIA_FOLDER}/{media_folder_path}/music_mureka.mp3"
|
||||
# print(modifications)
|
||||
|
||||
# return modifications
|
||||
import copy
|
||||
|
||||
import requests
|
||||
|
||||
CREATOMATE_API_KEY = "df9e4382d7e84fe790bf8a2168152be195d5a3568524ceb66ed989a2dea809f7d3065d6803b2e3dd9d02b5e5ec1c9823"
|
||||
# ACCOUNT_URL = "https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/"
|
||||
# Creatomate 템플릿 정보 전부 가져오기
|
||||
|
||||
|
||||
class Creatomate:
|
||||
base_url: str = "https://api.creatomate.com"
|
||||
|
||||
def __init__(self, api_key):
|
||||
self.api_key = api_key
|
||||
|
||||
def get_all_templates_data(self) -> dict:
|
||||
url = Creatomate.base_url + "/v1/templates"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
}
|
||||
response = requests.get(url, headers=headers)
|
||||
return response.json()
|
||||
|
||||
# Creatomate 템플릿 ID 로부터 해당 템플릿 정보 가져오기
|
||||
def get_one_template_data(self, template_id: str) -> dict:
|
||||
url = Creatomate.base_url + f"/v1/templates/{template_id}"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
}
|
||||
response = requests.get(url, headers=headers)
|
||||
return response.json()
|
||||
|
||||
# 템플릿 정보 파싱하여 리소스 이름 추출하기
|
||||
def parse_template_component_name(self, template_source: dict) -> dict:
|
||||
def recursive_parse_component(element: dict) -> dict:
|
||||
if "name" in element:
|
||||
result_element_name_type = {element["name"]: element["type"]}
|
||||
else:
|
||||
result_element_name_type = {}
|
||||
|
||||
if element["type"] == "composition":
|
||||
minor_component_list = [
|
||||
recursive_parse_component(minor) for minor in element["elements"]
|
||||
]
|
||||
for minor_component in minor_component_list: ## WARNING : Same name component should shroud other component. be aware
|
||||
result_element_name_type.update(minor_component)
|
||||
|
||||
return result_element_name_type
|
||||
|
||||
result = {}
|
||||
for result_element_dict in [
|
||||
recursive_parse_component(component) for component in template_source
|
||||
]:
|
||||
result.update(result_element_dict)
|
||||
|
||||
return result
|
||||
|
||||
# 템플릿 정보 이미지/가사/음악 리소스와 매핑하기
|
||||
# 이미지는 순차적으로 집어넣기
|
||||
# 가사는 개행마다 한 텍스트 삽입
|
||||
# Template에 audio-music 항목이 있어야 함. (추가된 템플릿 Cafe뿐임)
|
||||
def template_connect_resource_blackbox(
|
||||
self, template_id: str, image_url_list: list[str], lyric: str, music_url: str
|
||||
) -> dict:
|
||||
template_data = self.get_one_template_data(template_id)
|
||||
template_component_data = self.parse_template_component_name(
|
||||
template_data["source"]["elements"]
|
||||
)
|
||||
|
||||
lyric.replace("\r", "")
|
||||
lyric_splited = lyric.split("\n")
|
||||
modifications = {}
|
||||
for idx, (template_component_name, template_type) in enumerate(
|
||||
template_component_data.items()
|
||||
):
|
||||
match template_type:
|
||||
case "image":
|
||||
modifications[template_component_name] = image_url_list[
|
||||
idx % len(image_url_list)
|
||||
]
|
||||
case "text":
|
||||
modifications[template_component_name] = lyric_splited[
|
||||
idx % len(lyric_splited)
|
||||
]
|
||||
|
||||
modifications["audio-music"] = music_url
|
||||
|
||||
return modifications
|
||||
|
||||
def elements_connect_resource_blackbox(
|
||||
self, elements: list, image_url_list: list[str], lyric: str, music_url: str
|
||||
) -> dict:
|
||||
template_component_data = self.parse_template_component_name(elements)
|
||||
|
||||
lyric.replace("\r", "")
|
||||
lyric_splited = lyric.split("\n")
|
||||
modifications = {}
|
||||
for idx, (template_component_name, template_type) in enumerate(
|
||||
template_component_data.items()
|
||||
):
|
||||
match template_type:
|
||||
case "image":
|
||||
modifications[template_component_name] = image_url_list[
|
||||
idx % len(image_url_list)
|
||||
]
|
||||
case "text":
|
||||
modifications[template_component_name] = lyric_splited[
|
||||
idx % len(lyric_splited)
|
||||
]
|
||||
|
||||
modifications["audio-music"] = music_url
|
||||
|
||||
return modifications
|
||||
|
||||
def modify_element(self, elements: list, modification: dict):
|
||||
def recursive_modify(element: dict) -> dict:
|
||||
if "name" in element:
|
||||
match element["type"]:
|
||||
case "image":
|
||||
element["source"] = modification[element["name"]]
|
||||
case "audio":
|
||||
element["source"] = modification.get(element["name"], "")
|
||||
case "video":
|
||||
element["source"] = modification[element["name"]]
|
||||
case "text":
|
||||
element["source"] = modification.get(element["name"], "")
|
||||
case "composition":
|
||||
for minor in element["elements"]:
|
||||
recursive_modify(minor)
|
||||
|
||||
for minor in elements:
|
||||
recursive_modify(minor)
|
||||
|
||||
return elements
|
||||
|
||||
# Creatomate에 생성 요청
|
||||
# response에 요청 정보 있으니 풀링 필요
|
||||
def make_creatomate_call(self, template_id: str, modifications: dict):
|
||||
url = Creatomate.base_url + "/v2/renders"
|
||||
|
||||
data = {"template_id": template_id, "modifications": modifications}
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
}
|
||||
|
||||
response = requests.post(url, json=data, headers=headers)
|
||||
return response
|
||||
|
||||
# Creatomate에 생성 요청 without template
|
||||
# response에 요청 정보 있으니 풀링 필요
|
||||
def make_creatomate_custom_call(self, source: str):
|
||||
url = Creatomate.base_url + "/v2/renders"
|
||||
data = source
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
}
|
||||
|
||||
response = requests.post(url, json=data, headers=headers)
|
||||
return response
|
||||
|
||||
def calc_scene_duration(self, template: dict):
|
||||
total_template_duration = 0
|
||||
for elem in template["source"]["elements"]:
|
||||
try:
|
||||
if elem["type"] == "audio":
|
||||
continue
|
||||
total_template_duration += elem["duration"]
|
||||
if "animations" not in elem:
|
||||
continue
|
||||
for animation in elem["animations"]:
|
||||
assert animation["time"] == 0 # 0이 아닌 경우 확인 필요
|
||||
if animation["transition"]:
|
||||
total_template_duration -= animation["duration"]
|
||||
except:
|
||||
print(elem)
|
||||
return total_template_duration
|
||||
|
||||
def extend_template_duration(self, template: dict, target_duration: float):
|
||||
template["duration"] = target_duration
|
||||
total_template_duration = self.calc_scene_duration(template)
|
||||
extend_rate = target_duration / total_template_duration
|
||||
new_template = copy.deepcopy(template)
|
||||
for elem in new_template["source"]["elements"]:
|
||||
try:
|
||||
if elem["type"] == "audio":
|
||||
continue
|
||||
elem["duration"] = elem["duration"] * extend_rate
|
||||
if "animations" not in elem:
|
||||
continue
|
||||
for animation in elem["animations"]:
|
||||
assert animation["time"] == 0 # 0이 아닌 경우 확인 필요
|
||||
animation["duration"] = animation["duration"] * extend_rate
|
||||
except:
|
||||
print(elem)
|
||||
return new_template
|
||||
|
||||
|
||||
# Azure사용한 legacy 코드 원본
|
||||
# def template_connect_resource_blackbox(template_id, user_idx, task_idx):
|
||||
# secret_client = get_keyvault_client()
|
||||
# account_url = secret_client.get_secret(BLOB_ACCOUNT_URL_KEY).value
|
||||
# media_folder_path = f"{user_idx}/{task_idx}"
|
||||
# lyric_path = f"{media_folder_path}/lyric.txt"
|
||||
# lyric = az_storage.az_storage_read_ado2_media(lyric_path).readall().decode('UTF-8')
|
||||
# media_list = az_storage.az_storage_get_ado2_media_list(media_folder_path)
|
||||
# image_list = [media.name for media in media_list if '/crawling-images/' in media.name]
|
||||
# template_data = get_one_template_data(template_id)
|
||||
# template_component_data = parse_template_component_name(template_data['source']['elements'])
|
||||
# lyric.replace("\r", "")
|
||||
# lyric_splited = lyric.split("\n")
|
||||
# modifications = {}
|
||||
# for idx, (template_component_name, template_type) in enumerate(template_component_data.items()):
|
||||
# match template_type:
|
||||
# case 'image':
|
||||
# modifications[template_component_name] = f"{account_url}/{BLOB_CONTAINER_NAME}/{image_list[idx % len(image_list)]}"
|
||||
# case 'text':
|
||||
# modifications[template_component_name] = lyric_splited[idx % len(lyric_splited)]
|
||||
|
||||
# modifications["audio-music"] = f"{account_url}/{BLOB_CONTAINER_NAME}/{BLOB_MEDIA_FOLDER}/{media_folder_path}/music_mureka.mp3"
|
||||
# print(modifications)
|
||||
|
||||
# return modifications
|
||||
|
|
|
|||
|
|
@ -1,55 +1,55 @@
|
|||
import creatomate
|
||||
|
||||
CREATOMATE_API_KEY = "df9e4382d7e84fe790bf8a2168152be195d5a3568524ceb66ed989a2dea809f7d3065d6803b2e3dd9d02b5e5ec1c9823"
|
||||
shortform_4_template_id = "e8c7b43f-de4b-4ba3-b8eb-5df688569193"
|
||||
target_duration = 90.0 # s
|
||||
|
||||
creato = creatomate.Creatomate(CREATOMATE_API_KEY)
|
||||
|
||||
template = creato.get_one_template_data(shortform_4_template_id)
|
||||
|
||||
uploaded_image_url_list = [
|
||||
"https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/dev-user-idx/dev-task-idx/crawling-images/crawler4_img_1755818306_000_385523a5_99f2e8a8.jpg",
|
||||
"https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/dev-user-idx/dev-task-idx/crawling-images/crawler4_img_1755818306_001_d4cf6ec9_b81a1fdc.jpg",
|
||||
"https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/dev-user-idx/dev-task-idx/crawling-images/crawler4_img_1755818307_002_e4a0b276_680c5020.jpg",
|
||||
"https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/dev-user-idx/dev-task-idx/crawling-images/crawler4_img_1755818307_003_657f8c26_9f2c7168.jpg",
|
||||
"https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/dev-user-idx/dev-task-idx/crawling-images/crawler4_img_1755818307_004_9500e39d_24b9dad0.jpg",
|
||||
"https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/dev-user-idx/dev-task-idx/crawling-images/crawler4_img_1755818308_005_c3536641_9d490ccf.jpg",
|
||||
]
|
||||
|
||||
lyric = """
|
||||
진짜 맛있는 추어탕의 향연
|
||||
청담추어정 본점이야 말로
|
||||
온 가족이 함께 먹는 그 맛
|
||||
여수동 맛집으로 명성을 떨쳐
|
||||
|
||||
주차 가능, 단체 이용도 OK
|
||||
내 입맛을 사로잡는 맛
|
||||
청담추어정, 그 진정한 맛
|
||||
말복을 지나고 느껴보세요
|
||||
|
||||
한산한 분위기, 편안한 식사
|
||||
상황 추어탕으로 더욱 완벽
|
||||
톡톡 튀는 맛, 한 입에 느껴
|
||||
청담추어정에서 즐겨보세요
|
||||
|
||||
성남 출신의 맛집으로
|
||||
여수대로에서 빛나는 그곳
|
||||
청담추어정, 진짜 맛의 꿈
|
||||
여러분을 초대합니다 여기에
|
||||
|
||||
#청담추어정 #여수동맛집
|
||||
성남에서 만나는 진짜 맛
|
||||
"""
|
||||
|
||||
song_url = "https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/dev-user-idx/dev-task-idx/stay.mp3"
|
||||
|
||||
modifications = creato.elements_connect_resource_blackbox(
|
||||
template["source"]["elements"], uploaded_image_url_list, lyric, song_url
|
||||
)
|
||||
|
||||
|
||||
new_elements = creato.modify_element(template["source"]["elements"], modifications)
|
||||
template["source"]["elements"] = new_elements
|
||||
last_template = creato.extend_template_duration(template, target_duration)
|
||||
creato.make_creatomate_custom_call(last_template["source"])
|
||||
import creatomate
|
||||
|
||||
CREATOMATE_API_KEY = "df9e4382d7e84fe790bf8a2168152be195d5a3568524ceb66ed989a2dea809f7d3065d6803b2e3dd9d02b5e5ec1c9823"
|
||||
shortform_4_template_id = "e8c7b43f-de4b-4ba3-b8eb-5df688569193"
|
||||
target_duration = 90.0 # s
|
||||
|
||||
creato = creatomate.Creatomate(CREATOMATE_API_KEY)
|
||||
|
||||
template = creato.get_one_template_data(shortform_4_template_id)
|
||||
|
||||
uploaded_image_url_list = [
|
||||
"https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/dev-user-idx/dev-task-idx/crawling-images/crawler4_img_1755818306_000_385523a5_99f2e8a8.jpg",
|
||||
"https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/dev-user-idx/dev-task-idx/crawling-images/crawler4_img_1755818306_001_d4cf6ec9_b81a1fdc.jpg",
|
||||
"https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/dev-user-idx/dev-task-idx/crawling-images/crawler4_img_1755818307_002_e4a0b276_680c5020.jpg",
|
||||
"https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/dev-user-idx/dev-task-idx/crawling-images/crawler4_img_1755818307_003_657f8c26_9f2c7168.jpg",
|
||||
"https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/dev-user-idx/dev-task-idx/crawling-images/crawler4_img_1755818307_004_9500e39d_24b9dad0.jpg",
|
||||
"https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/dev-user-idx/dev-task-idx/crawling-images/crawler4_img_1755818308_005_c3536641_9d490ccf.jpg",
|
||||
]
|
||||
|
||||
lyric = """
|
||||
진짜 맛있는 추어탕의 향연
|
||||
청담추어정 본점이야 말로
|
||||
온 가족이 함께 먹는 그 맛
|
||||
여수동 맛집으로 명성을 떨쳐
|
||||
|
||||
주차 가능, 단체 이용도 OK
|
||||
내 입맛을 사로잡는 맛
|
||||
청담추어정, 그 진정한 맛
|
||||
말복을 지나고 느껴보세요
|
||||
|
||||
한산한 분위기, 편안한 식사
|
||||
상황 추어탕으로 더욱 완벽
|
||||
톡톡 튀는 맛, 한 입에 느껴
|
||||
청담추어정에서 즐겨보세요
|
||||
|
||||
성남 출신의 맛집으로
|
||||
여수대로에서 빛나는 그곳
|
||||
청담추어정, 진짜 맛의 꿈
|
||||
여러분을 초대합니다 여기에
|
||||
|
||||
#청담추어정 #여수동맛집
|
||||
성남에서 만나는 진짜 맛
|
||||
"""
|
||||
|
||||
song_url = "https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/dev-user-idx/dev-task-idx/stay.mp3"
|
||||
|
||||
modifications = creato.elements_connect_resource_blackbox(
|
||||
template["source"]["elements"], uploaded_image_url_list, lyric, song_url
|
||||
)
|
||||
|
||||
|
||||
new_elements = creato.modify_element(template["source"]["elements"], modifications)
|
||||
template["source"]["elements"] = new_elements
|
||||
last_template = creato.extend_template_duration(template, target_duration)
|
||||
creato.make_creatomate_custom_call(last_template["source"])
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ dependencies = [
|
|||
"aiohttp>=3.13.2",
|
||||
"aiomysql>=0.3.2",
|
||||
"asyncmy>=0.2.10",
|
||||
"beautifulsoup4>=4.14.3",
|
||||
"fastapi-cli>=0.0.16",
|
||||
"fastapi[standard]>=0.125.0",
|
||||
"openai>=2.13.0",
|
||||
|
|
|
|||
24
uv.lock
|
|
@ -157,6 +157,19 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "beautifulsoup4"
|
||||
version = "4.14.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "soupsieve" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2025.11.12"
|
||||
|
|
@ -751,6 +764,7 @@ dependencies = [
|
|||
{ name = "aiohttp" },
|
||||
{ name = "aiomysql" },
|
||||
{ name = "asyncmy" },
|
||||
{ name = "beautifulsoup4" },
|
||||
{ name = "fastapi", extra = ["standard"] },
|
||||
{ name = "fastapi-cli" },
|
||||
{ name = "openai" },
|
||||
|
|
@ -775,6 +789,7 @@ requires-dist = [
|
|||
{ name = "aiohttp", specifier = ">=3.13.2" },
|
||||
{ name = "aiomysql", specifier = ">=0.3.2" },
|
||||
{ name = "asyncmy", specifier = ">=0.2.10" },
|
||||
{ name = "beautifulsoup4", specifier = ">=4.14.3" },
|
||||
{ name = "fastapi", extras = ["standard"], specifier = ">=0.125.0" },
|
||||
{ name = "fastapi-cli", specifier = ">=0.0.16" },
|
||||
{ name = "openai", specifier = ">=2.13.0" },
|
||||
|
|
@ -1241,6 +1256,15 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "soupsieve"
|
||||
version = "2.8.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/89/23/adf3796d740536d63a6fbda113d07e60c734b6ed5d3058d1e47fc0495e47/soupsieve-2.8.1.tar.gz", hash = "sha256:4cf733bc50fa805f5df4b8ef4740fc0e0fa6218cf3006269afd3f9d6d80fd350", size = 117856, upload-time = "2025-12-18T13:50:34.655Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/48/f3/b67d6ea49ca9154453b6d70b34ea22f3996b9fa55da105a79d8732227adc/soupsieve-2.8.1-py3-none-any.whl", hash = "sha256:a11fe2a6f3d76ab3cf2de04eb339c1be5b506a8a47f2ceb6d139803177f85434", size = 36710, upload-time = "2025-12-18T13:50:33.267Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqladmin"
|
||||
version = "0.22.0"
|
||||
|
|
|
|||