Compare commits
No commits in common. "ba2628445199dd75d8e8921878e1c6ae4e344895" and "2e1ccebe4379d05874d1056fbe9c3e887f68742a" have entirely different histories.
ba26284451
...
2e1ccebe43
|
|
@ -1,38 +1,38 @@
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from sqladmin import Admin
|
from sqladmin import Admin
|
||||||
|
|
||||||
from app.database.session import engine
|
from app.database.session import engine
|
||||||
from app.home.api.home_admin import ImageAdmin, ProjectAdmin
|
from app.home.api.home_admin import ImageAdmin, ProjectAdmin
|
||||||
from app.lyric.api.lyrics_admin import LyricAdmin
|
from app.lyric.api.lyrics_admin import LyricAdmin
|
||||||
from app.song.api.song_admin import SongAdmin
|
from app.song.api.song_admin import SongAdmin
|
||||||
from app.video.api.video_admin import VideoAdmin
|
from app.video.api.video_admin import VideoAdmin
|
||||||
from config import prj_settings
|
from config import prj_settings
|
||||||
|
|
||||||
# https://github.com/aminalaee/sqladmin
|
# https://github.com/aminalaee/sqladmin
|
||||||
|
|
||||||
|
|
||||||
def init_admin(
|
def init_admin(
|
||||||
app: FastAPI,
|
app: FastAPI,
|
||||||
db_engine: engine,
|
db_engine: engine,
|
||||||
base_url: str = prj_settings.ADMIN_BASE_URL,
|
base_url: str = prj_settings.ADMIN_BASE_URL,
|
||||||
) -> Admin:
|
) -> Admin:
|
||||||
admin = Admin(
|
admin = Admin(
|
||||||
app,
|
app,
|
||||||
db_engine,
|
db_engine,
|
||||||
base_url=base_url,
|
base_url=base_url,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 프로젝트 관리
|
# 프로젝트 관리
|
||||||
admin.add_view(ProjectAdmin)
|
admin.add_view(ProjectAdmin)
|
||||||
admin.add_view(ImageAdmin)
|
admin.add_view(ImageAdmin)
|
||||||
|
|
||||||
# 가사 관리
|
# 가사 관리
|
||||||
admin.add_view(LyricAdmin)
|
admin.add_view(LyricAdmin)
|
||||||
|
|
||||||
# 노래 관리
|
# 노래 관리
|
||||||
admin.add_view(SongAdmin)
|
admin.add_view(SongAdmin)
|
||||||
|
|
||||||
# 영상 관리
|
# 영상 관리
|
||||||
admin.add_view(VideoAdmin)
|
admin.add_view(VideoAdmin)
|
||||||
|
|
||||||
return admin
|
return admin
|
||||||
|
|
|
||||||
|
|
@ -1,51 +1,51 @@
|
||||||
# app/main.py
|
# app/main.py
|
||||||
import asyncio
|
import asyncio
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
"""FastAPI 애플리케이션 생명주기 관리"""
|
"""FastAPI 애플리케이션 생명주기 관리"""
|
||||||
# Startup - 애플리케이션 시작 시
|
# Startup - 애플리케이션 시작 시
|
||||||
print("Starting up...")
|
print("Starting up...")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from config import prj_settings
|
from config import prj_settings
|
||||||
|
|
||||||
# DEBUG 모드일 때만 데이터베이스 테이블 자동 생성
|
# DEBUG 모드일 때만 데이터베이스 테이블 자동 생성
|
||||||
if prj_settings.DEBUG:
|
if prj_settings.DEBUG:
|
||||||
from app.database.session import create_db_tables
|
from app.database.session import create_db_tables
|
||||||
|
|
||||||
await create_db_tables()
|
await create_db_tables()
|
||||||
print("Database tables created (DEBUG mode)")
|
print("Database tables created (DEBUG mode)")
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
print("Database initialization timed out")
|
print("Database initialization timed out")
|
||||||
# 타임아웃 시 앱 시작 중단하려면 raise, 계속하려면 pass
|
# 타임아웃 시 앱 시작 중단하려면 raise, 계속하려면 pass
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Database initialization failed: {e}")
|
print(f"Database initialization failed: {e}")
|
||||||
# 에러 시 앱 시작 중단하려면 raise, 계속하려면 pass
|
# 에러 시 앱 시작 중단하려면 raise, 계속하려면 pass
|
||||||
raise
|
raise
|
||||||
|
|
||||||
yield # 애플리케이션 실행 중
|
yield # 애플리케이션 실행 중
|
||||||
|
|
||||||
# Shutdown - 애플리케이션 종료 시
|
# Shutdown - 애플리케이션 종료 시
|
||||||
print("Shutting down...")
|
print("Shutting down...")
|
||||||
|
|
||||||
# 공유 HTTP 클라이언트 종료
|
# 공유 HTTP 클라이언트 종료
|
||||||
from app.utils.creatomate import close_shared_client
|
from app.utils.creatomate import close_shared_client
|
||||||
from app.utils.upload_blob_as_request import close_shared_blob_client
|
from app.utils.upload_blob_as_request import close_shared_blob_client
|
||||||
|
|
||||||
await close_shared_client()
|
await close_shared_client()
|
||||||
await close_shared_blob_client()
|
await close_shared_blob_client()
|
||||||
|
|
||||||
# 데이터베이스 엔진 종료
|
# 데이터베이스 엔진 종료
|
||||||
from app.database.session import dispose_engine
|
from app.database.session import dispose_engine
|
||||||
|
|
||||||
await dispose_engine()
|
await dispose_engine()
|
||||||
|
|
||||||
|
|
||||||
# FastAPI 앱 생성 (lifespan 적용)
|
# FastAPI 앱 생성 (lifespan 적용)
|
||||||
app = FastAPI(title="CastAD", lifespan=lifespan)
|
app = FastAPI(title="CastAD", lifespan=lifespan)
|
||||||
|
|
|
||||||
|
|
@ -1,313 +1,114 @@
|
||||||
import logging
|
from fastapi import FastAPI, HTTPException, Request, Response, status
|
||||||
import traceback
|
from fastapi.responses import JSONResponse
|
||||||
from functools import wraps
|
|
||||||
from typing import Any, Callable, TypeVar
|
|
||||||
|
class FastShipError(Exception):
|
||||||
from fastapi import FastAPI, HTTPException, Request, Response, status
|
"""Base exception for all exceptions in fastship api"""
|
||||||
from fastapi.responses import JSONResponse
|
# status_code to be returned for this exception
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
# when it is handled
|
||||||
|
status = status.HTTP_400_BAD_REQUEST
|
||||||
# 로거 설정
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
class EntityNotFound(FastShipError):
|
||||||
T = TypeVar("T")
|
"""Entity not found in database"""
|
||||||
|
|
||||||
|
status = status.HTTP_404_NOT_FOUND
|
||||||
class FastShipError(Exception):
|
|
||||||
"""Base exception for all exceptions in fastship api"""
|
|
||||||
# status_code to be returned for this exception
|
class BadPassword(FastShipError):
|
||||||
# when it is handled
|
"""Password is not strong enough or invalid"""
|
||||||
status = status.HTTP_400_BAD_REQUEST
|
|
||||||
|
status = status.HTTP_400_BAD_REQUEST
|
||||||
|
|
||||||
class EntityNotFound(FastShipError):
|
|
||||||
"""Entity not found in database"""
|
class ClientNotAuthorized(FastShipError):
|
||||||
|
"""Client is not authorized to perform the action"""
|
||||||
status = status.HTTP_404_NOT_FOUND
|
|
||||||
|
status = status.HTTP_401_UNAUTHORIZED
|
||||||
|
|
||||||
class BadPassword(FastShipError):
|
|
||||||
"""Password is not strong enough or invalid"""
|
class ClientNotVerified(FastShipError):
|
||||||
|
"""Client is not verified"""
|
||||||
status = status.HTTP_400_BAD_REQUEST
|
|
||||||
|
status = status.HTTP_401_UNAUTHORIZED
|
||||||
|
|
||||||
class ClientNotAuthorized(FastShipError):
|
|
||||||
"""Client is not authorized to perform the action"""
|
class NothingToUpdate(FastShipError):
|
||||||
|
"""No data provided to update"""
|
||||||
status = status.HTTP_401_UNAUTHORIZED
|
|
||||||
|
|
||||||
|
class BadCredentials(FastShipError):
|
||||||
class ClientNotVerified(FastShipError):
|
"""User email or password is incorrect"""
|
||||||
"""Client is not verified"""
|
|
||||||
|
status = status.HTTP_401_UNAUTHORIZED
|
||||||
status = status.HTTP_401_UNAUTHORIZED
|
|
||||||
|
|
||||||
|
class InvalidToken(FastShipError):
|
||||||
class NothingToUpdate(FastShipError):
|
"""Access token is invalid or expired"""
|
||||||
"""No data provided to update"""
|
|
||||||
|
status = status.HTTP_401_UNAUTHORIZED
|
||||||
|
|
||||||
class BadCredentials(FastShipError):
|
|
||||||
"""User email or password is incorrect"""
|
class DeliveryPartnerNotAvailable(FastShipError):
|
||||||
|
"""Delivery partner/s do not service the destination"""
|
||||||
status = status.HTTP_401_UNAUTHORIZED
|
|
||||||
|
status = status.HTTP_406_NOT_ACCEPTABLE
|
||||||
|
|
||||||
class InvalidToken(FastShipError):
|
|
||||||
"""Access token is invalid or expired"""
|
class DeliveryPartnerCapacityExceeded(FastShipError):
|
||||||
|
"""Delivery partner has reached their max handling capacity"""
|
||||||
status = status.HTTP_401_UNAUTHORIZED
|
|
||||||
|
status = status.HTTP_406_NOT_ACCEPTABLE
|
||||||
|
|
||||||
class DeliveryPartnerNotAvailable(FastShipError):
|
|
||||||
"""Delivery partner/s do not service the destination"""
|
def _get_handler(status: int, detail: str):
|
||||||
|
# Define
|
||||||
status = status.HTTP_406_NOT_ACCEPTABLE
|
def handler(request: Request, exception: Exception) -> Response:
|
||||||
|
# DEBUG PRINT STATEMENT 👇
|
||||||
|
from rich import print, panel
|
||||||
class DeliveryPartnerCapacityExceeded(FastShipError):
|
print(
|
||||||
"""Delivery partner has reached their max handling capacity"""
|
panel.Panel(
|
||||||
|
exception.__class__.__name__,
|
||||||
status = status.HTTP_406_NOT_ACCEPTABLE
|
title="Handled Exception",
|
||||||
|
border_style="red",
|
||||||
|
),
|
||||||
# =============================================================================
|
)
|
||||||
# 데이터베이스 관련 예외
|
# DEBUG PRINT STATEMENT 👆
|
||||||
# =============================================================================
|
|
||||||
|
# Raise HTTPException with given status and detail
|
||||||
|
# can return JSONResponse as well
|
||||||
class DatabaseError(FastShipError):
|
raise HTTPException(
|
||||||
"""Database operation failed"""
|
status_code=status,
|
||||||
|
detail=detail,
|
||||||
status = status.HTTP_503_SERVICE_UNAVAILABLE
|
)
|
||||||
|
# Return ExceptionHandler required with given
|
||||||
|
# status and detail for HTTPExcetion above
|
||||||
class DatabaseConnectionError(DatabaseError):
|
return handler
|
||||||
"""Database connection failed"""
|
|
||||||
|
|
||||||
status = status.HTTP_503_SERVICE_UNAVAILABLE
|
def add_exception_handlers(app: FastAPI):
|
||||||
|
# Get all subclass of 👇, our custom exceptions
|
||||||
|
exception_classes = FastShipError.__subclasses__()
|
||||||
class DatabaseTimeoutError(DatabaseError):
|
|
||||||
"""Database operation timed out"""
|
for exception_class in exception_classes:
|
||||||
|
# Add exception handler
|
||||||
status = status.HTTP_504_GATEWAY_TIMEOUT
|
app.add_exception_handler(
|
||||||
|
# Custom exception class
|
||||||
|
exception_class,
|
||||||
# =============================================================================
|
# Get handler function
|
||||||
# 외부 서비스 관련 예외
|
_get_handler(
|
||||||
# =============================================================================
|
status=exception_class.status,
|
||||||
|
detail=exception_class.__doc__,
|
||||||
|
),
|
||||||
class ExternalServiceError(FastShipError):
|
)
|
||||||
"""External service call failed"""
|
|
||||||
|
@app.exception_handler(status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
status = status.HTTP_502_BAD_GATEWAY
|
def internal_server_error_handler(request, exception):
|
||||||
|
return JSONResponse(
|
||||||
|
content={"detail": "Something went wrong..."},
|
||||||
class GPTServiceError(ExternalServiceError):
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
"""GPT API call failed"""
|
headers={
|
||||||
|
"X-Error": f"{exception}",
|
||||||
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 uuid import UUID
|
||||||
from redis.asyncio import Redis
|
from redis.asyncio import Redis
|
||||||
|
|
||||||
from app.config import db_settings
|
from app.config import db_settings
|
||||||
|
|
||||||
|
|
||||||
_token_blacklist = Redis(
|
_token_blacklist = Redis(
|
||||||
host=db_settings.REDIS_HOST,
|
host=db_settings.REDIS_HOST,
|
||||||
port=db_settings.REDIS_PORT,
|
port=db_settings.REDIS_PORT,
|
||||||
db=0,
|
db=0,
|
||||||
)
|
)
|
||||||
_shipment_verification_codes = Redis(
|
_shipment_verification_codes = Redis(
|
||||||
host=db_settings.REDIS_HOST,
|
host=db_settings.REDIS_HOST,
|
||||||
port=db_settings.REDIS_PORT,
|
port=db_settings.REDIS_PORT,
|
||||||
db=1,
|
db=1,
|
||||||
decode_responses=True,
|
decode_responses=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def add_jti_to_blacklist(jti: str):
|
async def add_jti_to_blacklist(jti: str):
|
||||||
await _token_blacklist.set(jti, "blacklisted")
|
await _token_blacklist.set(jti, "blacklisted")
|
||||||
|
|
||||||
|
|
||||||
async def is_jti_blacklisted(jti: str) -> bool:
|
async def is_jti_blacklisted(jti: str) -> bool:
|
||||||
return await _token_blacklist.exists(jti)
|
return await _token_blacklist.exists(jti)
|
||||||
|
|
||||||
async def add_shipment_verification_code(id: UUID, code: int):
|
async def add_shipment_verification_code(id: UUID, code: int):
|
||||||
await _shipment_verification_codes.set(str(id), code)
|
await _shipment_verification_codes.set(str(id), code)
|
||||||
|
|
||||||
async def get_shipment_verification_code(id: UUID) -> str:
|
async def get_shipment_verification_code(id: UUID) -> str:
|
||||||
return str(await _shipment_verification_codes.get(str(id)))
|
return str(await _shipment_verification_codes.get(str(id)))
|
||||||
|
|
@ -1,97 +1,97 @@
|
||||||
from asyncio import current_task
|
from asyncio import current_task
|
||||||
from typing import AsyncGenerator
|
from typing import AsyncGenerator
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import (
|
from sqlalchemy.ext.asyncio import (
|
||||||
AsyncSession,
|
AsyncSession,
|
||||||
async_sessionmaker,
|
async_sessionmaker,
|
||||||
create_async_engine,
|
create_async_engine,
|
||||||
)
|
)
|
||||||
from sqlalchemy.orm import DeclarativeBase
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
from sqlalchemy.pool import AsyncQueuePool # 비동기 풀 클래스
|
from sqlalchemy.pool import AsyncQueuePool # 비동기 풀 클래스
|
||||||
|
|
||||||
from config import db_settings
|
from config import db_settings
|
||||||
|
|
||||||
|
|
||||||
# Base 클래스 정의
|
# Base 클래스 정의
|
||||||
class Base(DeclarativeBase):
|
class Base(DeclarativeBase):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
engine = create_async_engine(
|
engine = create_async_engine(
|
||||||
# MySQL async URL (asyncmy 드라이버)
|
# MySQL async URL (asyncmy 드라이버)
|
||||||
url=db_settings.MYSQL_URL, # 예: "mysql+asyncmy://test:test@host:3306/poc"
|
url=db_settings.MYSQL_URL, # 예: "mysql+asyncmy://test:test@host:3306/poc"
|
||||||
# === Connection Pool 설정 ===
|
# === Connection Pool 설정 ===
|
||||||
pool_size=10, # 기본 풀 크기: 10개 연결 유지
|
pool_size=10, # 기본 풀 크기: 10개 연결 유지
|
||||||
max_overflow=10, # 최대 증가: 10개 (총 20개까지 가능)
|
max_overflow=10, # 최대 증가: 10개 (총 20개까지 가능)
|
||||||
poolclass=AsyncQueuePool, # 비동기 큐 풀 사용 (기본값, 명시적 지정)
|
poolclass=AsyncQueuePool, # 비동기 큐 풀 사용 (기본값, 명시적 지정)
|
||||||
pool_timeout=30, # 풀에서 연결 대기 시간: 30초 (기본 30초)
|
pool_timeout=30, # 풀에서 연결 대기 시간: 30초 (기본 30초)
|
||||||
pool_recycle=3600, # 연결 재사용 주기: 1시간 (기본 3600초)
|
pool_recycle=3600, # 연결 재사용 주기: 1시간 (기본 3600초)
|
||||||
pool_pre_ping=True, # 연결 사용 전 유효성 검사: True로 설정
|
pool_pre_ping=True, # 연결 사용 전 유효성 검사: True로 설정
|
||||||
pool_reset_on_return="rollback", # 연결 반환 시 자동 롤백
|
pool_reset_on_return="rollback", # 연결 반환 시 자동 롤백
|
||||||
# === MySQL 특화 설정 ===
|
# === MySQL 특화 설정 ===
|
||||||
echo=False, # SQL 쿼리 로깅 (디버깅 시 True)
|
echo=False, # SQL 쿼리 로깅 (디버깅 시 True)
|
||||||
# === 연결 타임아웃 및 재시도 ===
|
# === 연결 타임아웃 및 재시도 ===
|
||||||
connect_args={
|
connect_args={
|
||||||
"connect_timeout": 10, # MySQL 연결 타임아웃: 10초
|
"connect_timeout": 10, # MySQL 연결 타임아웃: 10초
|
||||||
"read_timeout": 30, # 읽기 타임아웃: 30초
|
"read_timeout": 30, # 읽기 타임아웃: 30초
|
||||||
"write_timeout": 30, # 쓰기 타임아웃: 30초
|
"write_timeout": 30, # 쓰기 타임아웃: 30초
|
||||||
"charset": "utf8mb4", # 문자셋 (이모지 지원)
|
"charset": "utf8mb4", # 문자셋 (이모지 지원)
|
||||||
"sql_mode": "STRICT_TRANS_TABLES,NO_ZERO_DATE,NO_ZERO_IN_DATE",
|
"sql_mode": "STRICT_TRANS_TABLES,NO_ZERO_DATE,NO_ZERO_IN_DATE",
|
||||||
"init_command": "SET SESSION time_zone = '+00:00'", # 초기 연결 시 실행
|
"init_command": "SET SESSION time_zone = '+00:00'", # 초기 연결 시 실행
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Async 세션 팩토리 생성
|
# Async 세션 팩토리 생성
|
||||||
async_session_factory = async_sessionmaker(
|
async_session_factory = async_sessionmaker(
|
||||||
bind=engine,
|
bind=engine,
|
||||||
class_=AsyncSession,
|
class_=AsyncSession,
|
||||||
expire_on_commit=False, # 커밋 후 객체 상태 유지
|
expire_on_commit=False, # 커밋 후 객체 상태 유지
|
||||||
autoflush=True, # 변경 감지 자동 플러시
|
autoflush=True, # 변경 감지 자동 플러시
|
||||||
)
|
)
|
||||||
|
|
||||||
# async_scoped_session 생성
|
# async_scoped_session 생성
|
||||||
AsyncScopedSession = async_session_factory(
|
AsyncScopedSession = async_session_factory(
|
||||||
async_session_factory,
|
async_session_factory,
|
||||||
scopefunc=current_task,
|
scopefunc=current_task,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# 테이블 생성 함수
|
# 테이블 생성 함수
|
||||||
async def create_db_tables() -> None:
|
async def create_db_tables() -> None:
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
# from app.database.models import Shipment, Seller # noqa: F401
|
# from app.database.models import Shipment, Seller # noqa: F401
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
print("MySQL tables created successfully")
|
print("MySQL tables created successfully")
|
||||||
|
|
||||||
|
|
||||||
# 세션 제너레이터 (FastAPI Depends에 사용)
|
# 세션 제너레이터 (FastAPI Depends에 사용)
|
||||||
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
||||||
"""
|
"""
|
||||||
Async 세션 컨텍스트 매니저
|
Async 세션 컨텍스트 매니저
|
||||||
- FastAPI dependency로 사용
|
- FastAPI dependency로 사용
|
||||||
- Connection Pool에서 연결 획득/반환 자동 관리
|
- Connection Pool에서 연결 획득/반환 자동 관리
|
||||||
"""
|
"""
|
||||||
async with async_session_factory() as session:
|
async with async_session_factory() as session:
|
||||||
# pre-commit 훅 (선택적: 트랜잭션 시작 전 실행)
|
# pre-commit 훅 (선택적: 트랜잭션 시작 전 실행)
|
||||||
# await session.begin() # async_sessionmaker에서 자동 begin
|
# await session.begin() # async_sessionmaker에서 자동 begin
|
||||||
|
|
||||||
try:
|
try:
|
||||||
yield session
|
yield session
|
||||||
# FastAPI 요청 완료 시 자동 commit (예외 발생 시 rollback)
|
# FastAPI 요청 완료 시 자동 commit (예외 발생 시 rollback)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await session.rollback() # 명시적 롤백 (선택적)
|
await session.rollback() # 명시적 롤백 (선택적)
|
||||||
print(f"Session rollback due to: {e}") # 로깅
|
print(f"Session rollback due to: {e}") # 로깅
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
# 명시적 세션 종료 (Connection Pool에 반환)
|
# 명시적 세션 종료 (Connection Pool에 반환)
|
||||||
# context manager가 자동 처리하지만, 명시적으로 유지
|
# context manager가 자동 처리하지만, 명시적으로 유지
|
||||||
await session.close()
|
await session.close()
|
||||||
print("session closed successfully")
|
print("session closed successfully")
|
||||||
# 또는 session.aclose() - Python 3.10+
|
# 또는 session.aclose() - Python 3.10+
|
||||||
|
|
||||||
|
|
||||||
# 애플리케이션 종료 시 엔진 정리 (선택적)
|
# 애플리케이션 종료 시 엔진 정리 (선택적)
|
||||||
async def dispose_engine() -> None:
|
async def dispose_engine() -> None:
|
||||||
"""애플리케이션 종료 시 모든 연결 해제"""
|
"""애플리케이션 종료 시 모든 연결 해제"""
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
print("Database engine disposed")
|
print("Database engine disposed")
|
||||||
|
|
|
||||||
|
|
@ -1,161 +1,161 @@
|
||||||
import time
|
import time
|
||||||
from typing import AsyncGenerator
|
from typing import AsyncGenerator
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
from sqlalchemy.orm import DeclarativeBase
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
from config import db_settings
|
from config import db_settings
|
||||||
|
|
||||||
|
|
||||||
class Base(DeclarativeBase):
|
class Base(DeclarativeBase):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# 메인 엔진 (FastAPI 요청용)
|
# 메인 엔진 (FastAPI 요청용)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
engine = create_async_engine(
|
engine = create_async_engine(
|
||||||
url=db_settings.MYSQL_URL,
|
url=db_settings.MYSQL_URL,
|
||||||
echo=False,
|
echo=False,
|
||||||
pool_size=20, # 기본 풀 크기: 20
|
pool_size=20, # 기본 풀 크기: 20
|
||||||
max_overflow=20, # 추가 연결: 20 (총 최대 40)
|
max_overflow=20, # 추가 연결: 20 (총 최대 40)
|
||||||
pool_timeout=30, # 풀에서 연결 대기 시간 (초)
|
pool_timeout=30, # 풀에서 연결 대기 시간 (초)
|
||||||
pool_recycle=280, # MySQL wait_timeout(기본 28800s, 클라우드는 보통 300s) 보다 짧게 설정
|
pool_recycle=280, # MySQL wait_timeout(기본 28800s, 클라우드는 보통 300s) 보다 짧게 설정
|
||||||
pool_pre_ping=True, # 연결 유효성 검사 (죽은 연결 자동 재연결)
|
pool_pre_ping=True, # 연결 유효성 검사 (죽은 연결 자동 재연결)
|
||||||
pool_reset_on_return="rollback", # 반환 시 롤백으로 초기화
|
pool_reset_on_return="rollback", # 반환 시 롤백으로 초기화
|
||||||
connect_args={
|
connect_args={
|
||||||
"connect_timeout": 10, # DB 연결 타임아웃
|
"connect_timeout": 10, # DB 연결 타임아웃
|
||||||
"charset": "utf8mb4",
|
"charset": "utf8mb4",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
# 메인 세션 팩토리 (FastAPI DI용)
|
# 메인 세션 팩토리 (FastAPI DI용)
|
||||||
AsyncSessionLocal = async_sessionmaker(
|
AsyncSessionLocal = async_sessionmaker(
|
||||||
bind=engine,
|
bind=engine,
|
||||||
class_=AsyncSession,
|
class_=AsyncSession,
|
||||||
expire_on_commit=False,
|
expire_on_commit=False,
|
||||||
autoflush=False, # 명시적 flush 권장
|
autoflush=False, # 명시적 flush 권장
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# 백그라운드 태스크 전용 엔진 (메인 풀과 분리)
|
# 백그라운드 태스크 전용 엔진 (메인 풀과 분리)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
background_engine = create_async_engine(
|
background_engine = create_async_engine(
|
||||||
url=db_settings.MYSQL_URL,
|
url=db_settings.MYSQL_URL,
|
||||||
echo=False,
|
echo=False,
|
||||||
pool_size=10, # 백그라운드용 풀 크기: 10
|
pool_size=10, # 백그라운드용 풀 크기: 10
|
||||||
max_overflow=10, # 추가 연결: 10 (총 최대 20)
|
max_overflow=10, # 추가 연결: 10 (총 최대 20)
|
||||||
pool_timeout=60, # 백그라운드는 대기 시간 여유있게
|
pool_timeout=60, # 백그라운드는 대기 시간 여유있게
|
||||||
pool_recycle=280, # MySQL wait_timeout 보다 짧게 설정
|
pool_recycle=280, # MySQL wait_timeout 보다 짧게 설정
|
||||||
pool_pre_ping=True, # 연결 유효성 검사 (죽은 연결 자동 재연결)
|
pool_pre_ping=True, # 연결 유효성 검사 (죽은 연결 자동 재연결)
|
||||||
pool_reset_on_return="rollback",
|
pool_reset_on_return="rollback",
|
||||||
connect_args={
|
connect_args={
|
||||||
"connect_timeout": 10,
|
"connect_timeout": 10,
|
||||||
"charset": "utf8mb4",
|
"charset": "utf8mb4",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
# 백그라운드 세션 팩토리
|
# 백그라운드 세션 팩토리
|
||||||
BackgroundSessionLocal = async_sessionmaker(
|
BackgroundSessionLocal = async_sessionmaker(
|
||||||
bind=background_engine,
|
bind=background_engine,
|
||||||
class_=AsyncSession,
|
class_=AsyncSession,
|
||||||
expire_on_commit=False,
|
expire_on_commit=False,
|
||||||
autoflush=False,
|
autoflush=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def create_db_tables():
|
async def create_db_tables():
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
# 모델 import (테이블 메타데이터 등록용)
|
# 모델 import (테이블 메타데이터 등록용)
|
||||||
from app.home.models import Image, Project # noqa: F401
|
from app.home.models import Image, Project # noqa: F401
|
||||||
from app.lyric.models import Lyric # noqa: F401
|
from app.lyric.models import Lyric # noqa: F401
|
||||||
from app.song.models import Song # noqa: F401
|
from app.song.models import Song # noqa: F401
|
||||||
from app.video.models import Video # noqa: F401
|
from app.video.models import Video # noqa: F401
|
||||||
|
|
||||||
print("Creating database tables...")
|
print("Creating database tables...")
|
||||||
|
|
||||||
async with asyncio.timeout(10):
|
async with asyncio.timeout(10):
|
||||||
async with engine.begin() as connection:
|
async with engine.begin() as connection:
|
||||||
await connection.run_sync(Base.metadata.create_all)
|
await connection.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
|
|
||||||
# FastAPI 의존성용 세션 제너레이터
|
# FastAPI 의존성용 세션 제너레이터
|
||||||
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
||||||
start_time = time.perf_counter()
|
start_time = time.perf_counter()
|
||||||
pool = engine.pool
|
pool = engine.pool
|
||||||
|
|
||||||
# 커넥션 풀 상태 로깅 (디버깅용)
|
# 커넥션 풀 상태 로깅 (디버깅용)
|
||||||
print(
|
print(
|
||||||
f"[get_session] ACQUIRE - pool_size: {pool.size()}, "
|
f"[get_session] ACQUIRE - pool_size: {pool.size()}, "
|
||||||
f"in: {pool.checkedin()}, out: {pool.checkedout()}, "
|
f"in: {pool.checkedin()}, out: {pool.checkedout()}, "
|
||||||
f"overflow: {pool.overflow()}"
|
f"overflow: {pool.overflow()}"
|
||||||
)
|
)
|
||||||
|
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
acquire_time = time.perf_counter()
|
acquire_time = time.perf_counter()
|
||||||
print(
|
print(
|
||||||
f"[get_session] Session acquired in "
|
f"[get_session] Session acquired in "
|
||||||
f"{(acquire_time - start_time)*1000:.1f}ms"
|
f"{(acquire_time - start_time)*1000:.1f}ms"
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
yield session
|
yield session
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
print(
|
print(
|
||||||
f"[get_session] ROLLBACK - error: {type(e).__name__}: {e}, "
|
f"[get_session] ROLLBACK - error: {type(e).__name__}: {e}, "
|
||||||
f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms"
|
f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms"
|
||||||
)
|
)
|
||||||
raise e
|
raise e
|
||||||
finally:
|
finally:
|
||||||
total_time = time.perf_counter() - start_time
|
total_time = time.perf_counter() - start_time
|
||||||
print(
|
print(
|
||||||
f"[get_session] RELEASE - duration: {total_time*1000:.1f}ms, "
|
f"[get_session] RELEASE - duration: {total_time*1000:.1f}ms, "
|
||||||
f"pool_out: {pool.checkedout()}"
|
f"pool_out: {pool.checkedout()}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# 백그라운드 태스크용 세션 제너레이터
|
# 백그라운드 태스크용 세션 제너레이터
|
||||||
async def get_background_session() -> AsyncGenerator[AsyncSession, None]:
|
async def get_background_session() -> AsyncGenerator[AsyncSession, None]:
|
||||||
start_time = time.perf_counter()
|
start_time = time.perf_counter()
|
||||||
pool = background_engine.pool
|
pool = background_engine.pool
|
||||||
|
|
||||||
print(
|
print(
|
||||||
f"[get_background_session] ACQUIRE - pool_size: {pool.size()}, "
|
f"[get_background_session] ACQUIRE - pool_size: {pool.size()}, "
|
||||||
f"in: {pool.checkedin()}, out: {pool.checkedout()}, "
|
f"in: {pool.checkedin()}, out: {pool.checkedout()}, "
|
||||||
f"overflow: {pool.overflow()}"
|
f"overflow: {pool.overflow()}"
|
||||||
)
|
)
|
||||||
|
|
||||||
async with BackgroundSessionLocal() as session:
|
async with BackgroundSessionLocal() as session:
|
||||||
acquire_time = time.perf_counter()
|
acquire_time = time.perf_counter()
|
||||||
print(
|
print(
|
||||||
f"[get_background_session] Session acquired in "
|
f"[get_background_session] Session acquired in "
|
||||||
f"{(acquire_time - start_time)*1000:.1f}ms"
|
f"{(acquire_time - start_time)*1000:.1f}ms"
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
yield session
|
yield session
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
print(
|
print(
|
||||||
f"[get_background_session] ROLLBACK - "
|
f"[get_background_session] ROLLBACK - "
|
||||||
f"error: {type(e).__name__}: {e}, "
|
f"error: {type(e).__name__}: {e}, "
|
||||||
f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms"
|
f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms"
|
||||||
)
|
)
|
||||||
raise e
|
raise e
|
||||||
finally:
|
finally:
|
||||||
total_time = time.perf_counter() - start_time
|
total_time = time.perf_counter() - start_time
|
||||||
print(
|
print(
|
||||||
f"[get_background_session] RELEASE - "
|
f"[get_background_session] RELEASE - "
|
||||||
f"duration: {total_time*1000:.1f}ms, "
|
f"duration: {total_time*1000:.1f}ms, "
|
||||||
f"pool_out: {pool.checkedout()}"
|
f"pool_out: {pool.checkedout()}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# 앱 종료 시 엔진 리소스 정리 함수
|
# 앱 종료 시 엔진 리소스 정리 함수
|
||||||
async def dispose_engine() -> None:
|
async def dispose_engine() -> None:
|
||||||
print("[dispose_engine] Disposing database engines...")
|
print("[dispose_engine] Disposing database engines...")
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
print("[dispose_engine] Main engine disposed")
|
print("[dispose_engine] Main engine disposed")
|
||||||
await background_engine.dispose()
|
await background_engine.dispose()
|
||||||
print("[dispose_engine] Background engine disposed - ALL DONE")
|
print("[dispose_engine] Background engine disposed - ALL DONE")
|
||||||
|
|
|
||||||
|
|
@ -1,102 +1,102 @@
|
||||||
from sqladmin import ModelView
|
from sqladmin import ModelView
|
||||||
|
|
||||||
from app.home.models import Image, Project
|
from app.home.models import Image, Project
|
||||||
|
|
||||||
|
|
||||||
class ProjectAdmin(ModelView, model=Project):
|
class ProjectAdmin(ModelView, model=Project):
|
||||||
name = "프로젝트"
|
name = "프로젝트"
|
||||||
name_plural = "프로젝트 목록"
|
name_plural = "프로젝트 목록"
|
||||||
icon = "fa-solid fa-folder"
|
icon = "fa-solid fa-folder"
|
||||||
category = "프로젝트 관리"
|
category = "프로젝트 관리"
|
||||||
page_size = 20
|
page_size = 20
|
||||||
|
|
||||||
column_list = [
|
column_list = [
|
||||||
"id",
|
"id",
|
||||||
"store_name",
|
"store_name",
|
||||||
"region",
|
"region",
|
||||||
"task_id",
|
"task_id",
|
||||||
"created_at",
|
"created_at",
|
||||||
]
|
]
|
||||||
|
|
||||||
column_details_list = [
|
column_details_list = [
|
||||||
"id",
|
"id",
|
||||||
"store_name",
|
"store_name",
|
||||||
"region",
|
"region",
|
||||||
"task_id",
|
"task_id",
|
||||||
"detail_region_info",
|
"detail_region_info",
|
||||||
"created_at",
|
"created_at",
|
||||||
]
|
]
|
||||||
|
|
||||||
# 폼(생성/수정)에서 제외
|
# 폼(생성/수정)에서 제외
|
||||||
form_excluded_columns = ["created_at", "lyrics", "songs", "videos"]
|
form_excluded_columns = ["created_at", "lyrics", "songs", "videos"]
|
||||||
|
|
||||||
column_searchable_list = [
|
column_searchable_list = [
|
||||||
Project.store_name,
|
Project.store_name,
|
||||||
Project.region,
|
Project.region,
|
||||||
Project.task_id,
|
Project.task_id,
|
||||||
]
|
]
|
||||||
|
|
||||||
column_default_sort = (Project.created_at, True) # True: DESC (최신순)
|
column_default_sort = (Project.created_at, True) # True: DESC (최신순)
|
||||||
|
|
||||||
column_sortable_list = [
|
column_sortable_list = [
|
||||||
Project.id,
|
Project.id,
|
||||||
Project.store_name,
|
Project.store_name,
|
||||||
Project.region,
|
Project.region,
|
||||||
Project.created_at,
|
Project.created_at,
|
||||||
]
|
]
|
||||||
|
|
||||||
column_labels = {
|
column_labels = {
|
||||||
"id": "ID",
|
"id": "ID",
|
||||||
"store_name": "가게명",
|
"store_name": "가게명",
|
||||||
"region": "지역",
|
"region": "지역",
|
||||||
"task_id": "작업 ID",
|
"task_id": "작업 ID",
|
||||||
"detail_region_info": "상세 지역 정보",
|
"detail_region_info": "상세 지역 정보",
|
||||||
"created_at": "생성일시",
|
"created_at": "생성일시",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class ImageAdmin(ModelView, model=Image):
|
class ImageAdmin(ModelView, model=Image):
|
||||||
name = "이미지"
|
name = "이미지"
|
||||||
name_plural = "이미지 목록"
|
name_plural = "이미지 목록"
|
||||||
icon = "fa-solid fa-image"
|
icon = "fa-solid fa-image"
|
||||||
category = "프로젝트 관리"
|
category = "프로젝트 관리"
|
||||||
page_size = 20
|
page_size = 20
|
||||||
|
|
||||||
column_list = [
|
column_list = [
|
||||||
"id",
|
"id",
|
||||||
"task_id",
|
"task_id",
|
||||||
"img_name",
|
"img_name",
|
||||||
"created_at",
|
"created_at",
|
||||||
]
|
]
|
||||||
|
|
||||||
column_details_list = [
|
column_details_list = [
|
||||||
"id",
|
"id",
|
||||||
"task_id",
|
"task_id",
|
||||||
"img_name",
|
"img_name",
|
||||||
"img_url",
|
"img_url",
|
||||||
"created_at",
|
"created_at",
|
||||||
]
|
]
|
||||||
|
|
||||||
# 폼(생성/수정)에서 제외
|
# 폼(생성/수정)에서 제외
|
||||||
form_excluded_columns = ["created_at"]
|
form_excluded_columns = ["created_at"]
|
||||||
|
|
||||||
column_searchable_list = [
|
column_searchable_list = [
|
||||||
Image.task_id,
|
Image.task_id,
|
||||||
Image.img_name,
|
Image.img_name,
|
||||||
]
|
]
|
||||||
|
|
||||||
column_default_sort = (Image.created_at, True) # True: DESC (최신순)
|
column_default_sort = (Image.created_at, True) # True: DESC (최신순)
|
||||||
|
|
||||||
column_sortable_list = [
|
column_sortable_list = [
|
||||||
Image.id,
|
Image.id,
|
||||||
Image.img_name,
|
Image.img_name,
|
||||||
Image.created_at,
|
Image.created_at,
|
||||||
]
|
]
|
||||||
|
|
||||||
column_labels = {
|
column_labels = {
|
||||||
"id": "ID",
|
"id": "ID",
|
||||||
"task_id": "작업 ID",
|
"task_id": "작업 ID",
|
||||||
"img_name": "이미지명",
|
"img_name": "이미지명",
|
||||||
"img_url": "이미지 URL",
|
"img_url": "이미지 URL",
|
||||||
"created_at": "생성일시",
|
"created_at": "생성일시",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,15 @@
|
||||||
"""API 1 Version Router Module."""
|
"""API 1 Version Router Module."""
|
||||||
|
|
||||||
# from fastapi import APIRouter, Depends
|
# from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
# API 버전 1 라우터를 정의합니다.
|
# API 버전 1 라우터를 정의합니다.
|
||||||
# router = APIRouter(
|
# router = APIRouter(
|
||||||
# prefix="/api/v1",
|
# prefix="/api/v1",
|
||||||
# dependencies=[Depends(check_use_api), Depends(set_current_connect)],
|
# dependencies=[Depends(check_use_api), Depends(set_current_connect)],
|
||||||
# )
|
# )
|
||||||
# router = APIRouter(
|
# router = APIRouter(
|
||||||
# prefix="/api/v1",
|
# prefix="/api/v1",
|
||||||
# dependencies=[Depends(check_use_api), Depends(set_current_connect)],
|
# dependencies=[Depends(check_use_api), Depends(set_current_connect)],
|
||||||
# )
|
# )
|
||||||
# router.include_router(auth.router, tags=[Tags.AUTH])
|
# router.include_router(auth.router, tags=[Tags.AUTH])
|
||||||
# router.include_router(board.router, prefix="/boards", tags=[Tags.BOARD])
|
# router.include_router(board.router, prefix="/boards", tags=[Tags.BOARD])
|
||||||
|
|
|
||||||
|
|
@ -1,215 +1,215 @@
|
||||||
"""
|
"""
|
||||||
Home 모듈 SQLAlchemy 모델 정의
|
Home 모듈 SQLAlchemy 모델 정의
|
||||||
|
|
||||||
이 모듈은 영상 제작 파이프라인의 핵심 데이터 모델을 정의합니다.
|
이 모듈은 영상 제작 파이프라인의 핵심 데이터 모델을 정의합니다.
|
||||||
- Project: 프로젝트(사용자 입력 이력) 관리
|
- Project: 프로젝트(사용자 입력 이력) 관리
|
||||||
- Image: 업로드된 이미지 URL 관리
|
- Image: 업로드된 이미지 URL 관리
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import TYPE_CHECKING, List, Optional
|
from typing import TYPE_CHECKING, List, Optional
|
||||||
|
|
||||||
from sqlalchemy import DateTime, Index, Integer, String, Text, func
|
from sqlalchemy import DateTime, Index, Integer, String, Text, func
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.database.session import Base
|
from app.database.session import Base
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from app.lyric.models import Lyric
|
from app.lyric.models import Lyric
|
||||||
from app.song.models import Song
|
from app.song.models import Song
|
||||||
from app.video.models import Video
|
from app.video.models import Video
|
||||||
|
|
||||||
|
|
||||||
class Project(Base):
|
class Project(Base):
|
||||||
"""
|
"""
|
||||||
프로젝트 테이블 (사용자 입력 이력)
|
프로젝트 테이블 (사용자 입력 이력)
|
||||||
|
|
||||||
영상 제작 요청의 시작점으로, 고객 정보와 지역 정보를 저장합니다.
|
영상 제작 요청의 시작점으로, 고객 정보와 지역 정보를 저장합니다.
|
||||||
하위 테이블(Lyric, Song, Video)의 부모 테이블 역할을 합니다.
|
하위 테이블(Lyric, Song, Video)의 부모 테이블 역할을 합니다.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
id: 고유 식별자 (자동 증가)
|
id: 고유 식별자 (자동 증가)
|
||||||
store_name: 고객명 (필수)
|
store_name: 고객명 (필수)
|
||||||
region: 지역명 (필수, 예: 서울, 부산, 대구 등)
|
region: 지역명 (필수, 예: 서울, 부산, 대구 등)
|
||||||
task_id: 작업 고유 식별자 (UUID 형식, 36자)
|
task_id: 작업 고유 식별자 (UUID 형식, 36자)
|
||||||
detail_region_info: 상세 지역 정보 (선택, JSON 또는 텍스트 형식)
|
detail_region_info: 상세 지역 정보 (선택, JSON 또는 텍스트 형식)
|
||||||
created_at: 생성 일시 (자동 설정)
|
created_at: 생성 일시 (자동 설정)
|
||||||
|
|
||||||
Relationships:
|
Relationships:
|
||||||
lyrics: 생성된 가사 목록
|
lyrics: 생성된 가사 목록
|
||||||
songs: 생성된 노래 목록
|
songs: 생성된 노래 목록
|
||||||
videos: 최종 영상 결과 목록
|
videos: 최종 영상 결과 목록
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__tablename__ = "project"
|
__tablename__ = "project"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
Index("idx_project_task_id", "task_id"),
|
Index("idx_project_task_id", "task_id"),
|
||||||
Index("idx_project_store_name", "store_name"),
|
Index("idx_project_store_name", "store_name"),
|
||||||
Index("idx_project_region", "region"),
|
Index("idx_project_region", "region"),
|
||||||
{
|
{
|
||||||
"mysql_engine": "InnoDB",
|
"mysql_engine": "InnoDB",
|
||||||
"mysql_charset": "utf8mb4",
|
"mysql_charset": "utf8mb4",
|
||||||
"mysql_collate": "utf8mb4_unicode_ci",
|
"mysql_collate": "utf8mb4_unicode_ci",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(
|
id: Mapped[int] = mapped_column(
|
||||||
Integer,
|
Integer,
|
||||||
primary_key=True,
|
primary_key=True,
|
||||||
nullable=False,
|
nullable=False,
|
||||||
autoincrement=True,
|
autoincrement=True,
|
||||||
comment="고유 식별자",
|
comment="고유 식별자",
|
||||||
)
|
)
|
||||||
|
|
||||||
store_name: Mapped[str] = mapped_column(
|
store_name: Mapped[str] = mapped_column(
|
||||||
String(255),
|
String(255),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
index=True,
|
index=True,
|
||||||
comment="가게명",
|
comment="가게명",
|
||||||
)
|
)
|
||||||
|
|
||||||
region: Mapped[str] = mapped_column(
|
region: Mapped[str] = mapped_column(
|
||||||
String(100),
|
String(100),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
index=True,
|
index=True,
|
||||||
comment="지역명 (예: 군산)",
|
comment="지역명 (예: 군산)",
|
||||||
)
|
)
|
||||||
|
|
||||||
task_id: Mapped[str] = mapped_column(
|
task_id: Mapped[str] = mapped_column(
|
||||||
String(36),
|
String(36),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
comment="프로젝트 작업 고유 식별자 (UUID)",
|
comment="프로젝트 작업 고유 식별자 (UUID)",
|
||||||
)
|
)
|
||||||
|
|
||||||
detail_region_info: Mapped[Optional[str]] = mapped_column(
|
detail_region_info: Mapped[Optional[str]] = mapped_column(
|
||||||
Text,
|
Text,
|
||||||
nullable=True,
|
nullable=True,
|
||||||
comment="상세 지역 정보",
|
comment="상세 지역 정보",
|
||||||
)
|
)
|
||||||
|
|
||||||
language: Mapped[str] = mapped_column(
|
language: Mapped[str] = mapped_column(
|
||||||
String(50),
|
String(50),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
default="Korean",
|
default="Korean",
|
||||||
comment="출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
|
comment="출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
|
||||||
)
|
)
|
||||||
|
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime,
|
DateTime,
|
||||||
nullable=False,
|
nullable=False,
|
||||||
server_default=func.now(),
|
server_default=func.now(),
|
||||||
comment="생성 일시",
|
comment="생성 일시",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
lyrics: Mapped[List["Lyric"]] = relationship(
|
lyrics: Mapped[List["Lyric"]] = relationship(
|
||||||
"Lyric",
|
"Lyric",
|
||||||
back_populates="project",
|
back_populates="project",
|
||||||
cascade="all, delete-orphan",
|
cascade="all, delete-orphan",
|
||||||
lazy="selectin",
|
lazy="selectin",
|
||||||
)
|
)
|
||||||
|
|
||||||
songs: Mapped[List["Song"]] = relationship(
|
songs: Mapped[List["Song"]] = relationship(
|
||||||
"Song",
|
"Song",
|
||||||
back_populates="project",
|
back_populates="project",
|
||||||
cascade="all, delete-orphan",
|
cascade="all, delete-orphan",
|
||||||
lazy="selectin",
|
lazy="selectin",
|
||||||
)
|
)
|
||||||
|
|
||||||
videos: Mapped[List["Video"]] = relationship(
|
videos: Mapped[List["Video"]] = relationship(
|
||||||
"Video",
|
"Video",
|
||||||
back_populates="project",
|
back_populates="project",
|
||||||
cascade="all, delete-orphan",
|
cascade="all, delete-orphan",
|
||||||
lazy="selectin",
|
lazy="selectin",
|
||||||
)
|
)
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
def truncate(value: str | None, max_len: int = 10) -> str:
|
def truncate(value: str | None, max_len: int = 10) -> str:
|
||||||
if value is None:
|
if value is None:
|
||||||
return "None"
|
return "None"
|
||||||
return (value[:max_len] + "...") if len(value) > max_len else value
|
return (value[:max_len] + "...") if len(value) > max_len else value
|
||||||
|
|
||||||
return (
|
return (
|
||||||
f"<Project("
|
f"<Project("
|
||||||
f"id={self.id}, "
|
f"id={self.id}, "
|
||||||
f"store_name='{self.store_name}', "
|
f"store_name='{self.store_name}', "
|
||||||
f"task_id='{truncate(self.task_id)}'"
|
f"task_id='{truncate(self.task_id)}'"
|
||||||
f")>"
|
f")>"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class Image(Base):
|
class Image(Base):
|
||||||
"""
|
"""
|
||||||
업로드 이미지 테이블
|
업로드 이미지 테이블
|
||||||
|
|
||||||
사용자가 업로드한 이미지의 URL을 저장합니다.
|
사용자가 업로드한 이미지의 URL을 저장합니다.
|
||||||
독립적으로 관리되며 Project와 직접적인 관계가 없습니다.
|
독립적으로 관리되며 Project와 직접적인 관계가 없습니다.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
id: 고유 식별자 (자동 증가)
|
id: 고유 식별자 (자동 증가)
|
||||||
task_id: 이미지 업로드 작업 고유 식별자 (UUID)
|
task_id: 이미지 업로드 작업 고유 식별자 (UUID)
|
||||||
img_name: 이미지명
|
img_name: 이미지명
|
||||||
img_url: 이미지 URL (S3, CDN 등의 경로)
|
img_url: 이미지 URL (S3, CDN 등의 경로)
|
||||||
created_at: 생성 일시 (자동 설정)
|
created_at: 생성 일시 (자동 설정)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__tablename__ = "image"
|
__tablename__ = "image"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
{
|
{
|
||||||
"mysql_engine": "InnoDB",
|
"mysql_engine": "InnoDB",
|
||||||
"mysql_charset": "utf8mb4",
|
"mysql_charset": "utf8mb4",
|
||||||
"mysql_collate": "utf8mb4_unicode_ci",
|
"mysql_collate": "utf8mb4_unicode_ci",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(
|
id: Mapped[int] = mapped_column(
|
||||||
Integer,
|
Integer,
|
||||||
primary_key=True,
|
primary_key=True,
|
||||||
nullable=False,
|
nullable=False,
|
||||||
autoincrement=True,
|
autoincrement=True,
|
||||||
comment="고유 식별자",
|
comment="고유 식별자",
|
||||||
)
|
)
|
||||||
|
|
||||||
task_id: Mapped[str] = mapped_column(
|
task_id: Mapped[str] = mapped_column(
|
||||||
String(36),
|
String(36),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
comment="이미지 업로드 작업 고유 식별자 (UUID)",
|
comment="이미지 업로드 작업 고유 식별자 (UUID)",
|
||||||
)
|
)
|
||||||
|
|
||||||
img_name: Mapped[str] = mapped_column(
|
img_name: Mapped[str] = mapped_column(
|
||||||
String(255),
|
String(255),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
comment="이미지명",
|
comment="이미지명",
|
||||||
)
|
)
|
||||||
|
|
||||||
img_url: Mapped[str] = mapped_column(
|
img_url: Mapped[str] = mapped_column(
|
||||||
String(2048),
|
String(2048),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
comment="이미지 URL (blob, CDN 경로)",
|
comment="이미지 URL (blob, CDN 경로)",
|
||||||
)
|
)
|
||||||
|
|
||||||
img_order: Mapped[int] = mapped_column(
|
img_order: Mapped[int] = mapped_column(
|
||||||
Integer,
|
Integer,
|
||||||
nullable=False,
|
nullable=False,
|
||||||
default=0,
|
default=0,
|
||||||
comment="이미지 순서",
|
comment="이미지 순서",
|
||||||
)
|
)
|
||||||
|
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime,
|
DateTime,
|
||||||
nullable=False,
|
nullable=False,
|
||||||
server_default=func.now(),
|
server_default=func.now(),
|
||||||
comment="생성 일시",
|
comment="생성 일시",
|
||||||
)
|
)
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
task_id_str = (
|
task_id_str = (
|
||||||
(self.task_id[:10] + "...") if len(self.task_id) > 10 else self.task_id
|
(self.task_id[:10] + "...") if len(self.task_id) > 10 else self.task_id
|
||||||
)
|
)
|
||||||
img_name_str = (
|
img_name_str = (
|
||||||
(self.img_name[:10] + "...") if len(self.img_name) > 10 else self.img_name
|
(self.img_name[:10] + "...") if len(self.img_name) > 10 else self.img_name
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
f"<Image(id={self.id}, task_id='{task_id_str}', img_name='{img_name_str}')>"
|
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 typing import Literal, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
class AttributeInfo(BaseModel):
|
class AttributeInfo(BaseModel):
|
||||||
"""음악 속성 정보"""
|
"""음악 속성 정보"""
|
||||||
|
|
||||||
genre: str = Field(..., description="음악 장르")
|
genre: str = Field(..., description="음악 장르")
|
||||||
vocal: str = Field(..., description="보컬 스타일")
|
vocal: str = Field(..., description="보컬 스타일")
|
||||||
tempo: str = Field(..., description="템포")
|
tempo: str = Field(..., description="템포")
|
||||||
mood: str = Field(..., description="분위기")
|
mood: str = Field(..., description="분위기")
|
||||||
|
|
||||||
|
|
||||||
class GenerateRequestImg(BaseModel):
|
class GenerateRequestImg(BaseModel):
|
||||||
"""이미지 URL 스키마"""
|
"""이미지 URL 스키마"""
|
||||||
|
|
||||||
url: str = Field(..., description="이미지 URL")
|
url: str = Field(..., description="이미지 URL")
|
||||||
name: Optional[str] = Field(None, description="이미지명 (없으면 URL에서 추출)")
|
name: Optional[str] = Field(None, description="이미지명 (없으면 URL에서 추출)")
|
||||||
|
|
||||||
|
|
||||||
class GenerateRequestInfo(BaseModel):
|
class GenerateRequestInfo(BaseModel):
|
||||||
"""생성 요청 정보 스키마 (이미지 제외)"""
|
"""생성 요청 정보 스키마 (이미지 제외)"""
|
||||||
|
|
||||||
customer_name: str = Field(..., description="고객명/가게명")
|
customer_name: str = Field(..., description="고객명/가게명")
|
||||||
region: str = Field(..., description="지역명")
|
region: str = Field(..., description="지역명")
|
||||||
detail_region_info: Optional[str] = Field(None, description="상세 지역 정보")
|
detail_region_info: Optional[str] = Field(None, description="상세 지역 정보")
|
||||||
attribute: AttributeInfo = Field(..., description="음악 속성 정보")
|
attribute: AttributeInfo = Field(..., description="음악 속성 정보")
|
||||||
language: str = Field(
|
language: str = Field(
|
||||||
default="Korean",
|
default="Korean",
|
||||||
description="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
|
description="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class GenerateRequest(GenerateRequestInfo):
|
class GenerateRequest(GenerateRequestInfo):
|
||||||
"""기본 생성 요청 스키마 (이미지 없음, JSON body)
|
"""기본 생성 요청 스키마 (이미지 없음, JSON body)
|
||||||
|
|
||||||
이미지 없이 프로젝트 정보만 전달합니다.
|
이미지 없이 프로젝트 정보만 전달합니다.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model_config = ConfigDict(
|
model_config = ConfigDict(
|
||||||
json_schema_extra={
|
json_schema_extra={
|
||||||
"example": {
|
"example": {
|
||||||
"customer_name": "스테이 머뭄",
|
"customer_name": "스테이 머뭄",
|
||||||
"region": "군산",
|
"region": "군산",
|
||||||
"detail_region_info": "군산 신흥동 말랭이 마을",
|
"detail_region_info": "군산 신흥동 말랭이 마을",
|
||||||
"attribute": {
|
"attribute": {
|
||||||
"genre": "K-Pop",
|
"genre": "K-Pop",
|
||||||
"vocal": "Raspy",
|
"vocal": "Raspy",
|
||||||
"tempo": "110 BPM",
|
"tempo": "110 BPM",
|
||||||
"mood": "happy",
|
"mood": "happy",
|
||||||
},
|
},
|
||||||
"language": "Korean",
|
"language": "Korean",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class GenerateUrlsRequest(GenerateRequestInfo):
|
class GenerateUrlsRequest(GenerateRequestInfo):
|
||||||
"""URL 기반 생성 요청 스키마 (JSON body)
|
"""URL 기반 생성 요청 스키마 (JSON body)
|
||||||
|
|
||||||
GenerateRequestInfo를 상속받아 이미지 목록을 추가합니다.
|
GenerateRequestInfo를 상속받아 이미지 목록을 추가합니다.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model_config = ConfigDict(
|
model_config = ConfigDict(
|
||||||
json_schema_extra={
|
json_schema_extra={
|
||||||
"example": {
|
"example": {
|
||||||
"customer_name": "스테이 머뭄",
|
"customer_name": "스테이 머뭄",
|
||||||
"region": "군산",
|
"region": "군산",
|
||||||
"detail_region_info": "군산 신흥동 말랭이 마을",
|
"detail_region_info": "군산 신흥동 말랭이 마을",
|
||||||
"attribute": {
|
"attribute": {
|
||||||
"genre": "K-Pop",
|
"genre": "K-Pop",
|
||||||
"vocal": "Raspy",
|
"vocal": "Raspy",
|
||||||
"tempo": "110 BPM",
|
"tempo": "110 BPM",
|
||||||
"mood": "happy",
|
"mood": "happy",
|
||||||
},
|
},
|
||||||
"language": "Korean",
|
"language": "Korean",
|
||||||
"images": [
|
"images": [
|
||||||
{"url": "https://example.com/images/image_001.jpg"},
|
{"url": "https://example.com/images/image_001.jpg"},
|
||||||
{"url": "https://example.com/images/image_002.jpg", "name": "외관"},
|
{"url": "https://example.com/images/image_002.jpg", "name": "외관"},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
images: list[GenerateRequestImg] = Field(
|
images: list[GenerateRequestImg] = Field(
|
||||||
..., description="이미지 URL 목록", min_length=1
|
..., description="이미지 URL 목록", min_length=1
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class GenerateUploadResponse(BaseModel):
|
class GenerateUploadResponse(BaseModel):
|
||||||
"""파일 업로드 기반 생성 응답 스키마"""
|
"""파일 업로드 기반 생성 응답 스키마"""
|
||||||
|
|
||||||
task_id: str = Field(..., description="작업 고유 식별자 (UUID7)")
|
task_id: str = Field(..., description="작업 고유 식별자 (UUID7)")
|
||||||
status: Literal["processing", "completed", "failed"] = Field(
|
status: Literal["processing", "completed", "failed"] = Field(
|
||||||
..., description="작업 상태"
|
..., description="작업 상태"
|
||||||
)
|
)
|
||||||
message: str = Field(..., description="응답 메시지")
|
message: str = Field(..., description="응답 메시지")
|
||||||
uploaded_count: int = Field(..., description="업로드된 이미지 개수")
|
uploaded_count: int = Field(..., description="업로드된 이미지 개수")
|
||||||
|
|
||||||
|
|
||||||
class GenerateResponse(BaseModel):
|
class GenerateResponse(BaseModel):
|
||||||
"""생성 응답 스키마"""
|
"""생성 응답 스키마"""
|
||||||
|
|
||||||
task_id: str = Field(..., description="작업 고유 식별자 (UUID7)")
|
task_id: str = Field(..., description="작업 고유 식별자 (UUID7)")
|
||||||
status: Literal["processing", "completed", "failed"] = Field(
|
status: Literal["processing", "completed", "failed"] = Field(
|
||||||
..., description="작업 상태"
|
..., description="작업 상태"
|
||||||
)
|
)
|
||||||
message: str = Field(..., description="응답 메시지")
|
message: str = Field(..., description="응답 메시지")
|
||||||
|
|
||||||
|
|
||||||
class CrawlingRequest(BaseModel):
|
class CrawlingRequest(BaseModel):
|
||||||
"""크롤링 요청 스키마"""
|
"""크롤링 요청 스키마"""
|
||||||
|
|
||||||
model_config = ConfigDict(
|
model_config = ConfigDict(
|
||||||
json_schema_extra={
|
json_schema_extra={
|
||||||
"example": {
|
"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": "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")
|
url: str = Field(..., description="네이버 지도 장소 URL")
|
||||||
|
|
||||||
|
|
||||||
class ProcessedInfo(BaseModel):
|
class ProcessedInfo(BaseModel):
|
||||||
"""가공된 장소 정보 스키마"""
|
"""가공된 장소 정보 스키마"""
|
||||||
|
|
||||||
customer_name: str = Field(..., description="고객명/가게명 (base_info.name)")
|
customer_name: str = Field(..., description="고객명/가게명 (base_info.name)")
|
||||||
region: str = Field(..., description="지역명 (roadAddress에서 추출한 시 이름)")
|
region: str = Field(..., description="지역명 (roadAddress에서 추출한 시 이름)")
|
||||||
detail_region_info: str = Field(..., description="상세 지역 정보 (roadAddress)")
|
detail_region_info: str = Field(..., description="상세 지역 정보 (roadAddress)")
|
||||||
|
|
||||||
|
|
||||||
class MarketingAnalysis(BaseModel):
|
class MarketingAnalysis(BaseModel):
|
||||||
"""마케팅 분석 결과 스키마"""
|
"""마케팅 분석 결과 스키마"""
|
||||||
|
|
||||||
report: str = Field(..., description="마케팅 분석 리포트")
|
report: str = Field(..., description="마케팅 분석 리포트")
|
||||||
tags: list[str] = Field(default_factory=list, description="추천 태그 목록")
|
tags: list[str] = Field(default_factory=list, description="추천 태그 목록")
|
||||||
facilities: list[str] = Field(default_factory=list, description="추천 부대시설 목록")
|
facilities: list[str] = Field(default_factory=list, description="추천 부대시설 목록")
|
||||||
|
|
||||||
|
|
||||||
class CrawlingResponse(BaseModel):
|
class CrawlingResponse(BaseModel):
|
||||||
"""크롤링 응답 스키마"""
|
"""크롤링 응답 스키마"""
|
||||||
|
|
||||||
image_list: Optional[list[str]] = Field(None, description="이미지 URL 목록")
|
image_list: Optional[list[str]] = Field(None, description="이미지 URL 목록")
|
||||||
image_count: int = Field(..., description="이미지 개수")
|
image_count: int = Field(..., description="이미지 개수")
|
||||||
processed_info: Optional[ProcessedInfo] = Field(
|
processed_info: Optional[ProcessedInfo] = Field(
|
||||||
None, description="가공된 장소 정보 (customer_name, region, detail_region_info)"
|
None, description="가공된 장소 정보 (customer_name, region, detail_region_info)"
|
||||||
)
|
)
|
||||||
marketing_analysis: Optional[MarketingAnalysis] = Field(
|
marketing_analysis: Optional[MarketingAnalysis] = Field(
|
||||||
None, description="마케팅 분석 결과 (report, tags, facilities)"
|
None, description="마케팅 분석 결과 (report, tags, facilities)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ErrorResponse(BaseModel):
|
class ErrorResponse(BaseModel):
|
||||||
"""에러 응답 스키마"""
|
"""에러 응답 스키마"""
|
||||||
|
|
||||||
success: bool = Field(default=False, description="요청 성공 여부")
|
success: bool = Field(default=False, description="요청 성공 여부")
|
||||||
error_code: str = Field(..., description="에러 코드")
|
error_code: str = Field(..., description="에러 코드")
|
||||||
message: str = Field(..., description="에러 메시지")
|
message: str = Field(..., description="에러 메시지")
|
||||||
detail: Optional[str] = Field(None, description="상세 에러 정보")
|
detail: Optional[str] = Field(None, description="상세 에러 정보")
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Image Upload Schemas
|
# Image Upload Schemas
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
class ImageUrlItem(BaseModel):
|
class ImageUrlItem(BaseModel):
|
||||||
"""이미지 URL 아이템 스키마"""
|
"""이미지 URL 아이템 스키마"""
|
||||||
|
|
||||||
url: str = Field(..., description="외부 이미지 URL")
|
url: str = Field(..., description="외부 이미지 URL")
|
||||||
name: Optional[str] = Field(None, description="이미지명 (없으면 URL에서 추출)")
|
name: Optional[str] = Field(None, description="이미지명 (없으면 URL에서 추출)")
|
||||||
|
|
||||||
|
|
||||||
class ImageUploadRequest(BaseModel):
|
class ImageUploadRequest(BaseModel):
|
||||||
"""이미지 업로드 요청 스키마 (JSON body 부분)
|
"""이미지 업로드 요청 스키마 (JSON body 부분)
|
||||||
|
|
||||||
URL 이미지 목록을 전달합니다.
|
URL 이미지 목록을 전달합니다.
|
||||||
바이너리 파일은 multipart/form-data로 별도 전달됩니다.
|
바이너리 파일은 multipart/form-data로 별도 전달됩니다.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model_config = ConfigDict(
|
model_config = ConfigDict(
|
||||||
json_schema_extra={
|
json_schema_extra={
|
||||||
"example": {
|
"example": {
|
||||||
"images": [
|
"images": [
|
||||||
{"url": "https://example.com/images/image_001.jpg"},
|
{"url": "https://example.com/images/image_001.jpg"},
|
||||||
{"url": "https://example.com/images/image_002.jpg", "name": "외관"},
|
{"url": "https://example.com/images/image_002.jpg", "name": "외관"},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
images: Optional[list[ImageUrlItem]] = Field(
|
images: Optional[list[ImageUrlItem]] = Field(
|
||||||
None, description="외부 이미지 URL 목록"
|
None, description="외부 이미지 URL 목록"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ImageUploadResultItem(BaseModel):
|
class ImageUploadResultItem(BaseModel):
|
||||||
"""업로드된 이미지 결과 아이템"""
|
"""업로드된 이미지 결과 아이템"""
|
||||||
|
|
||||||
id: int = Field(..., description="이미지 ID")
|
id: int = Field(..., description="이미지 ID")
|
||||||
img_name: str = Field(..., description="이미지명")
|
img_name: str = Field(..., description="이미지명")
|
||||||
img_url: str = Field(..., description="이미지 URL")
|
img_url: str = Field(..., description="이미지 URL")
|
||||||
img_order: int = Field(..., description="이미지 순서")
|
img_order: int = Field(..., description="이미지 순서")
|
||||||
source: Literal["url", "file", "blob"] = Field(
|
source: Literal["url", "file", "blob"] = Field(
|
||||||
..., description="이미지 소스 (url: 외부 URL, file: 로컬 서버, blob: Azure Blob)"
|
..., description="이미지 소스 (url: 외부 URL, file: 로컬 서버, blob: Azure Blob)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ImageUploadResponse(BaseModel):
|
class ImageUploadResponse(BaseModel):
|
||||||
"""이미지 업로드 응답 스키마"""
|
"""이미지 업로드 응답 스키마"""
|
||||||
|
|
||||||
model_config = ConfigDict(
|
model_config = ConfigDict(
|
||||||
json_schema_extra={
|
json_schema_extra={
|
||||||
"example": {
|
"example": {
|
||||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
||||||
"total_count": 3,
|
"total_count": 3,
|
||||||
"url_count": 2,
|
"url_count": 2,
|
||||||
"file_count": 1,
|
"file_count": 1,
|
||||||
"saved_count": 3,
|
"saved_count": 3,
|
||||||
"images": [
|
"images": [
|
||||||
{
|
{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"img_name": "외관",
|
"img_name": "외관",
|
||||||
"img_url": "https://example.com/images/image_001.jpg",
|
"img_url": "https://example.com/images/image_001.jpg",
|
||||||
"img_order": 0,
|
"img_order": 0,
|
||||||
"source": "url",
|
"source": "url",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 2,
|
"id": 2,
|
||||||
"img_name": "내부",
|
"img_name": "내부",
|
||||||
"img_url": "https://example.com/images/image_002.jpg",
|
"img_url": "https://example.com/images/image_002.jpg",
|
||||||
"img_order": 1,
|
"img_order": 1,
|
||||||
"source": "url",
|
"source": "url",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3,
|
"id": 3,
|
||||||
"img_name": "uploaded_image.jpg",
|
"img_name": "uploaded_image.jpg",
|
||||||
"img_url": "/media/image/2024-01-15/0694b716-dbff-7219-8000-d08cb5fce431/uploaded_image_002.jpg",
|
"img_url": "/media/image/2024-01-15/0694b716-dbff-7219-8000-d08cb5fce431/uploaded_image_002.jpg",
|
||||||
"img_order": 2,
|
"img_order": 2,
|
||||||
"source": "file",
|
"source": "file",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"image_urls": [
|
"image_urls": [
|
||||||
"https://example.com/images/image_001.jpg",
|
"https://example.com/images/image_001.jpg",
|
||||||
"https://example.com/images/image_002.jpg",
|
"https://example.com/images/image_002.jpg",
|
||||||
"/media/image/2024-01-15/0694b716-dbff-7219-8000-d08cb5fce431/uploaded_image_002.jpg",
|
"/media/image/2024-01-15/0694b716-dbff-7219-8000-d08cb5fce431/uploaded_image_002.jpg",
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
task_id: str = Field(..., description="작업 고유 식별자 (새로 생성된 UUID7)")
|
task_id: str = Field(..., description="작업 고유 식별자 (새로 생성된 UUID7)")
|
||||||
total_count: int = Field(..., description="총 업로드된 이미지 개수")
|
total_count: int = Field(..., description="총 업로드된 이미지 개수")
|
||||||
url_count: int = Field(..., description="URL로 등록된 이미지 개수")
|
url_count: int = Field(..., description="URL로 등록된 이미지 개수")
|
||||||
file_count: int = Field(..., description="파일로 업로드된 이미지 개수")
|
file_count: int = Field(..., description="파일로 업로드된 이미지 개수")
|
||||||
saved_count: int = Field(..., description="Image 테이블에 저장된 row 수")
|
saved_count: int = Field(..., description="Image 테이블에 저장된 row 수")
|
||||||
images: list[ImageUploadResultItem] = Field(..., description="업로드된 이미지 목록")
|
images: list[ImageUploadResultItem] = Field(..., description="업로드된 이미지 목록")
|
||||||
image_urls: list[str] = Field(..., description="Image 테이블에 저장된 현재 task_id의 이미지 URL 목록")
|
image_urls: list[str] = Field(..., description="Image 테이블에 저장된 현재 task_id의 이미지 URL 목록")
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,24 @@
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlmodel import SQLModel
|
from sqlmodel import SQLModel
|
||||||
|
|
||||||
|
|
||||||
class BaseService:
|
class BaseService:
|
||||||
def __init__(self, model, session: AsyncSession):
|
def __init__(self, model, session: AsyncSession):
|
||||||
self.model = model
|
self.model = model
|
||||||
self.session = session
|
self.session = session
|
||||||
|
|
||||||
async def _get(self, id: UUID):
|
async def _get(self, id: UUID):
|
||||||
return await self.session.get(self.model, id)
|
return await self.session.get(self.model, id)
|
||||||
|
|
||||||
async def _add(self, entity):
|
async def _add(self, entity):
|
||||||
self.session.add(entity)
|
self.session.add(entity)
|
||||||
await self.session.commit()
|
await self.session.commit()
|
||||||
await self.session.refresh(entity)
|
await self.session.refresh(entity)
|
||||||
return entity
|
return entity
|
||||||
|
|
||||||
async def _update(self, entity):
|
async def _update(self, entity):
|
||||||
return await self._add(entity)
|
return await self._add(entity)
|
||||||
|
|
||||||
async def _delete(self, entity):
|
async def _delete(self, entity):
|
||||||
await self.session.delete(entity)
|
await self.session.delete(entity)
|
||||||
|
|
@ -1,48 +1,48 @@
|
||||||
from typing import AsyncGenerator
|
from typing import AsyncGenerator
|
||||||
|
|
||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
from sqlalchemy.pool import NullPool
|
from sqlalchemy.pool import NullPool
|
||||||
|
|
||||||
from app.database.session import Base
|
from app.database.session import Base
|
||||||
from config import db_settings
|
from config import db_settings
|
||||||
|
|
||||||
# 테스트 전용 DB URL
|
# 테스트 전용 DB URL
|
||||||
TEST_DB_URL = db_settings.MYSQL_URL.replace(
|
TEST_DB_URL = db_settings.MYSQL_URL.replace(
|
||||||
f"/{db_settings.MYSQL_DB}",
|
f"/{db_settings.MYSQL_DB}",
|
||||||
"/test_db", # 별도 테스트 DB 사용
|
"/test_db", # 별도 테스트 DB 사용
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
@pytest_asyncio.fixture
|
||||||
async def test_engine():
|
async def test_engine():
|
||||||
"""각 테스트마다 생성되는 테스트 엔진"""
|
"""각 테스트마다 생성되는 테스트 엔진"""
|
||||||
engine = create_async_engine(
|
engine = create_async_engine(
|
||||||
TEST_DB_URL,
|
TEST_DB_URL,
|
||||||
poolclass=NullPool, # 테스트에서는 풀 비활성화
|
poolclass=NullPool, # 테스트에서는 풀 비활성화
|
||||||
echo=True, # SQL 쿼리 로깅
|
echo=True, # SQL 쿼리 로깅
|
||||||
)
|
)
|
||||||
|
|
||||||
# 테스트 테이블 생성
|
# 테스트 테이블 생성
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
yield engine
|
yield engine
|
||||||
|
|
||||||
# 테스트 테이블 삭제
|
# 테스트 테이블 삭제
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.run_sync(Base.metadata.drop_all)
|
await conn.run_sync(Base.metadata.drop_all)
|
||||||
|
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
@pytest_asyncio.fixture
|
||||||
async def db_session(test_engine) -> AsyncGenerator[AsyncSession, None]:
|
async def db_session(test_engine) -> AsyncGenerator[AsyncSession, None]:
|
||||||
"""각 테스트마다 새로운 세션 (격리 보장)"""
|
"""각 테스트마다 새로운 세션 (격리 보장)"""
|
||||||
async_session = async_sessionmaker(
|
async_session = async_sessionmaker(
|
||||||
test_engine, class_=AsyncSession, expire_on_commit=False
|
test_engine, class_=AsyncSession, expire_on_commit=False
|
||||||
)
|
)
|
||||||
|
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
yield session
|
yield session
|
||||||
await session.rollback() # 테스트 후 롤백
|
await session.rollback() # 테스트 후 롤백
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,17 @@
|
||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_database_connection(test_engine):
|
async def test_database_connection(test_engine):
|
||||||
"""테스트 엔진을 사용한 연결 테스트"""
|
"""테스트 엔진을 사용한 연결 테스트"""
|
||||||
async with test_engine.begin() as connection:
|
async with test_engine.begin() as connection:
|
||||||
result = await connection.execute(text("SELECT 1"))
|
result = await connection.execute(text("SELECT 1"))
|
||||||
assert result.scalar() == 1
|
assert result.scalar() == 1
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_session_usage(db_session):
|
async def test_session_usage(db_session):
|
||||||
"""세션을 사용한 테스트"""
|
"""세션을 사용한 테스트"""
|
||||||
result = await db_session.execute(text("SELECT 1 as num"))
|
result = await db_session.execute(text("SELECT 1 as num"))
|
||||||
assert result.scalar() == 1
|
assert result.scalar() == 1
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,30 @@
|
||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
from app.database.session import AsyncSessionLocal, engine
|
from app.database.session import AsyncSessionLocal, engine
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_database_connection():
|
async def test_database_connection():
|
||||||
"""데이터베이스 연결 테스트"""
|
"""데이터베이스 연결 테스트"""
|
||||||
async with engine.begin() as connection:
|
async with engine.begin() as connection:
|
||||||
result = await connection.execute(text("SELECT 1"))
|
result = await connection.execute(text("SELECT 1"))
|
||||||
assert result.scalar() == 1
|
assert result.scalar() == 1
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_session_creation():
|
async def test_session_creation():
|
||||||
"""세션 생성 테스트"""
|
"""세션 생성 테스트"""
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
result = await session.execute(text("SELECT 1"))
|
result = await session.execute(text("SELECT 1"))
|
||||||
assert result.scalar() == 1
|
assert result.scalar() == 1
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_database_version():
|
async def test_database_version():
|
||||||
"""MySQL 버전 확인 테스트"""
|
"""MySQL 버전 확인 테스트"""
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
result = await session.execute(text("SELECT VERSION()"))
|
result = await session.execute(text("SELECT VERSION()"))
|
||||||
version = result.scalar()
|
version = result.scalar()
|
||||||
assert version is not None
|
assert version is not None
|
||||||
print(f"MySQL Version: {version}")
|
print(f"MySQL Version: {version}")
|
||||||
|
|
|
||||||
|
|
@ -1,61 +1,61 @@
|
||||||
from sqladmin import ModelView
|
from sqladmin import ModelView
|
||||||
|
|
||||||
from app.lyric.models import Lyric
|
from app.lyric.models import Lyric
|
||||||
|
|
||||||
|
|
||||||
class LyricAdmin(ModelView, model=Lyric):
|
class LyricAdmin(ModelView, model=Lyric):
|
||||||
name = "가사"
|
name = "가사"
|
||||||
name_plural = "가사 목록"
|
name_plural = "가사 목록"
|
||||||
icon = "fa-solid fa-music"
|
icon = "fa-solid fa-music"
|
||||||
category = "가사 관리"
|
category = "가사 관리"
|
||||||
page_size = 20
|
page_size = 20
|
||||||
|
|
||||||
column_list = [
|
column_list = [
|
||||||
"id",
|
"id",
|
||||||
"project_id",
|
"project_id",
|
||||||
"task_id",
|
"task_id",
|
||||||
"status",
|
"status",
|
||||||
"language",
|
"language",
|
||||||
"created_at",
|
"created_at",
|
||||||
]
|
]
|
||||||
|
|
||||||
column_details_list = [
|
column_details_list = [
|
||||||
"id",
|
"id",
|
||||||
"project_id",
|
"project_id",
|
||||||
"task_id",
|
"task_id",
|
||||||
"status",
|
"status",
|
||||||
"language",
|
"language",
|
||||||
"lyric_prompt",
|
"lyric_prompt",
|
||||||
"lyric_result",
|
"lyric_result",
|
||||||
"created_at",
|
"created_at",
|
||||||
]
|
]
|
||||||
|
|
||||||
# 폼(생성/수정)에서 제외
|
# 폼(생성/수정)에서 제외
|
||||||
form_excluded_columns = ["created_at", "songs", "videos"]
|
form_excluded_columns = ["created_at", "songs", "videos"]
|
||||||
|
|
||||||
column_searchable_list = [
|
column_searchable_list = [
|
||||||
Lyric.task_id,
|
Lyric.task_id,
|
||||||
Lyric.status,
|
Lyric.status,
|
||||||
Lyric.language,
|
Lyric.language,
|
||||||
]
|
]
|
||||||
|
|
||||||
column_default_sort = (Lyric.created_at, True) # True: DESC (최신순)
|
column_default_sort = (Lyric.created_at, True) # True: DESC (최신순)
|
||||||
|
|
||||||
column_sortable_list = [
|
column_sortable_list = [
|
||||||
Lyric.id,
|
Lyric.id,
|
||||||
Lyric.project_id,
|
Lyric.project_id,
|
||||||
Lyric.status,
|
Lyric.status,
|
||||||
Lyric.language,
|
Lyric.language,
|
||||||
Lyric.created_at,
|
Lyric.created_at,
|
||||||
]
|
]
|
||||||
|
|
||||||
column_labels = {
|
column_labels = {
|
||||||
"id": "ID",
|
"id": "ID",
|
||||||
"project_id": "프로젝트 ID",
|
"project_id": "프로젝트 ID",
|
||||||
"task_id": "작업 ID",
|
"task_id": "작업 ID",
|
||||||
"status": "상태",
|
"status": "상태",
|
||||||
"language": "언어",
|
"language": "언어",
|
||||||
"lyric_prompt": "프롬프트",
|
"lyric_prompt": "프롬프트",
|
||||||
"lyric_result": "생성 결과",
|
"lyric_result": "생성 결과",
|
||||||
"created_at": "생성일시",
|
"created_at": "생성일시",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,448 +1,417 @@
|
||||||
"""
|
"""
|
||||||
Lyric API Router
|
Lyric API Router
|
||||||
|
|
||||||
이 모듈은 가사 관련 API 엔드포인트를 정의합니다.
|
이 모듈은 가사 관련 API 엔드포인트를 정의합니다.
|
||||||
모든 엔드포인트는 재사용 가능하도록 설계되었습니다.
|
모든 엔드포인트는 재사용 가능하도록 설계되었습니다.
|
||||||
|
|
||||||
엔드포인트 목록:
|
엔드포인트 목록:
|
||||||
- POST /lyric/generate: 가사 생성
|
- POST /lyric/generate: 가사 생성
|
||||||
- GET /lyric/status/{task_id}: 가사 생성 상태 조회
|
- GET /lyric/status/{task_id}: 가사 생성 상태 조회
|
||||||
- GET /lyric/{task_id}: 가사 상세 조회
|
- GET /lyric/{task_id}: 가사 상세 조회
|
||||||
- GET /lyrics: 가사 목록 조회 (페이지네이션)
|
- GET /lyrics: 가사 목록 조회 (페이지네이션)
|
||||||
|
|
||||||
사용 예시:
|
사용 예시:
|
||||||
from app.lyric.api.routers.v1.lyric import router
|
from app.lyric.api.routers.v1.lyric import router
|
||||||
app.include_router(router, prefix="/api/v1")
|
app.include_router(router, prefix="/api/v1")
|
||||||
|
|
||||||
다른 서비스에서 재사용:
|
다른 서비스에서 재사용:
|
||||||
# 이 파일의 헬퍼 함수들을 import하여 사용 가능
|
# 이 파일의 헬퍼 함수들을 import하여 사용 가능
|
||||||
from app.lyric.api.routers.v1.lyric import (
|
from app.lyric.api.routers.v1.lyric import (
|
||||||
get_lyric_status_by_task_id,
|
get_lyric_status_by_task_id,
|
||||||
get_lyric_by_task_id,
|
get_lyric_by_task_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 페이지네이션은 pagination 모듈 사용
|
# 페이지네이션은 pagination 모듈 사용
|
||||||
from app.utils.pagination import PaginatedResponse, get_paginated
|
from app.utils.pagination import PaginatedResponse, get_paginated
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database.session import get_session
|
from app.database.session import get_session
|
||||||
from app.home.models import Project
|
from app.home.models import Project
|
||||||
from app.lyric.models import Lyric
|
from app.lyric.models import Lyric
|
||||||
from app.lyric.schemas.lyric import (
|
from app.lyric.schemas.lyric import (
|
||||||
GenerateLyricRequest,
|
GenerateLyricRequest,
|
||||||
GenerateLyricResponse,
|
GenerateLyricResponse,
|
||||||
LyricDetailResponse,
|
LyricDetailResponse,
|
||||||
LyricListItem,
|
LyricListItem,
|
||||||
LyricStatusResponse,
|
LyricStatusResponse,
|
||||||
)
|
)
|
||||||
from app.lyric.worker.lyric_task import generate_lyric_background
|
from app.lyric.worker.lyric_task import generate_lyric_background
|
||||||
from app.utils.chatgpt_prompt import ChatgptService
|
from app.utils.chatgpt_prompt import ChatgptService
|
||||||
from app.utils.pagination import PaginatedResponse, get_paginated
|
from app.utils.pagination import PaginatedResponse, get_paginated
|
||||||
|
|
||||||
router = APIRouter(prefix="/lyric", tags=["lyric"])
|
router = APIRouter(prefix="/lyric", tags=["lyric"])
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Reusable Service Functions (다른 모듈에서 import하여 사용 가능)
|
# Reusable Service Functions (다른 모듈에서 import하여 사용 가능)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
async def get_lyric_status_by_task_id(
|
async def get_lyric_status_by_task_id(
|
||||||
session: AsyncSession, task_id: str
|
session: AsyncSession, task_id: str
|
||||||
) -> LyricStatusResponse:
|
) -> LyricStatusResponse:
|
||||||
"""task_id로 가사 생성 작업의 상태를 조회합니다.
|
"""task_id로 가사 생성 작업의 상태를 조회합니다.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
session: SQLAlchemy AsyncSession
|
session: SQLAlchemy AsyncSession
|
||||||
task_id: 작업 고유 식별자
|
task_id: 작업 고유 식별자
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
LyricStatusResponse: 상태 정보
|
LyricStatusResponse: 상태 정보
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
HTTPException: 404 - task_id에 해당하는 가사가 없는 경우
|
HTTPException: 404 - task_id에 해당하는 가사가 없는 경우
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
# 다른 서비스에서 사용
|
# 다른 서비스에서 사용
|
||||||
from app.lyric.api.routers.v1.lyric import get_lyric_status_by_task_id
|
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")
|
status_info = await get_lyric_status_by_task_id(session, "some-task-id")
|
||||||
if status_info.status == "completed":
|
if status_info.status == "completed":
|
||||||
# 완료 처리
|
# 완료 처리
|
||||||
"""
|
"""
|
||||||
print(f"[get_lyric_status_by_task_id] START - task_id: {task_id}")
|
print(f"[get_lyric_status_by_task_id] START - task_id: {task_id}")
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(Lyric)
|
select(Lyric)
|
||||||
.where(Lyric.task_id == task_id)
|
.where(Lyric.task_id == task_id)
|
||||||
.order_by(Lyric.created_at.desc())
|
.order_by(Lyric.created_at.desc())
|
||||||
.limit(1)
|
.limit(1)
|
||||||
)
|
)
|
||||||
lyric = result.scalar_one_or_none()
|
lyric = result.scalar_one_or_none()
|
||||||
|
|
||||||
if not lyric:
|
if not lyric:
|
||||||
print(f"[get_lyric_status_by_task_id] NOT FOUND - task_id: {task_id}")
|
print(f"[get_lyric_status_by_task_id] NOT FOUND - task_id: {task_id}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
detail=f"task_id '{task_id}'에 해당하는 가사를 찾을 수 없습니다.",
|
detail=f"task_id '{task_id}'에 해당하는 가사를 찾을 수 없습니다.",
|
||||||
)
|
)
|
||||||
|
|
||||||
status_messages = {
|
status_messages = {
|
||||||
"processing": "가사 생성 중입니다.",
|
"processing": "가사 생성 중입니다.",
|
||||||
"completed": "가사 생성이 완료되었습니다.",
|
"completed": "가사 생성이 완료되었습니다.",
|
||||||
"failed": "가사 생성에 실패했습니다.",
|
"failed": "가사 생성에 실패했습니다.",
|
||||||
}
|
}
|
||||||
|
|
||||||
print(
|
print(
|
||||||
f"[get_lyric_status_by_task_id] SUCCESS - task_id: {task_id}, status: {lyric.status}"
|
f"[get_lyric_status_by_task_id] SUCCESS - task_id: {task_id}, status: {lyric.status}"
|
||||||
)
|
)
|
||||||
return LyricStatusResponse(
|
return LyricStatusResponse(
|
||||||
task_id=lyric.task_id,
|
task_id=lyric.task_id,
|
||||||
status=lyric.status,
|
status=lyric.status,
|
||||||
message=status_messages.get(lyric.status, "알 수 없는 상태입니다."),
|
message=status_messages.get(lyric.status, "알 수 없는 상태입니다."),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def get_lyric_by_task_id(
|
async def get_lyric_by_task_id(
|
||||||
session: AsyncSession, task_id: str
|
session: AsyncSession, task_id: str
|
||||||
) -> LyricDetailResponse:
|
) -> LyricDetailResponse:
|
||||||
"""task_id로 생성된 가사 상세 정보를 조회합니다.
|
"""task_id로 생성된 가사 상세 정보를 조회합니다.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
session: SQLAlchemy AsyncSession
|
session: SQLAlchemy AsyncSession
|
||||||
task_id: 작업 고유 식별자
|
task_id: 작업 고유 식별자
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
LyricDetailResponse: 가사 상세 정보
|
LyricDetailResponse: 가사 상세 정보
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
HTTPException: 404 - task_id에 해당하는 가사가 없는 경우
|
HTTPException: 404 - task_id에 해당하는 가사가 없는 경우
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
# 다른 서비스에서 사용
|
# 다른 서비스에서 사용
|
||||||
from app.lyric.api.routers.v1.lyric import get_lyric_by_task_id
|
from app.lyric.api.routers.v1.lyric import get_lyric_by_task_id
|
||||||
|
|
||||||
lyric = await get_lyric_by_task_id(session, task_id)
|
lyric = await get_lyric_by_task_id(session, task_id)
|
||||||
"""
|
"""
|
||||||
print(f"[get_lyric_by_task_id] START - task_id: {task_id}")
|
print(f"[get_lyric_by_task_id] START - task_id: {task_id}")
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(Lyric)
|
select(Lyric)
|
||||||
.where(Lyric.task_id == task_id)
|
.where(Lyric.task_id == task_id)
|
||||||
.order_by(Lyric.created_at.desc())
|
.order_by(Lyric.created_at.desc())
|
||||||
.limit(1)
|
.limit(1)
|
||||||
)
|
)
|
||||||
lyric = result.scalar_one_or_none()
|
lyric = result.scalar_one_or_none()
|
||||||
|
|
||||||
if not lyric:
|
if not lyric:
|
||||||
print(f"[get_lyric_by_task_id] NOT FOUND - task_id: {task_id}")
|
print(f"[get_lyric_by_task_id] NOT FOUND - task_id: {task_id}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
detail=f"task_id '{task_id}'에 해당하는 가사를 찾을 수 없습니다.",
|
detail=f"task_id '{task_id}'에 해당하는 가사를 찾을 수 없습니다.",
|
||||||
)
|
)
|
||||||
|
|
||||||
print(f"[get_lyric_by_task_id] SUCCESS - task_id: {task_id}, lyric_id: {lyric.id}")
|
print(f"[get_lyric_by_task_id] SUCCESS - task_id: {task_id}, lyric_id: {lyric.id}")
|
||||||
return LyricDetailResponse(
|
return LyricDetailResponse(
|
||||||
id=lyric.id,
|
id=lyric.id,
|
||||||
task_id=lyric.task_id,
|
task_id=lyric.task_id,
|
||||||
project_id=lyric.project_id,
|
project_id=lyric.project_id,
|
||||||
status=lyric.status,
|
status=lyric.status,
|
||||||
lyric_prompt=lyric.lyric_prompt,
|
lyric_prompt=lyric.lyric_prompt,
|
||||||
lyric_result=lyric.lyric_result,
|
lyric_result=lyric.lyric_result,
|
||||||
created_at=lyric.created_at,
|
created_at=lyric.created_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# API Endpoints
|
# API Endpoints
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/generate",
|
"/generate",
|
||||||
summary="가사 생성",
|
summary="가사 생성",
|
||||||
description="""
|
description="""
|
||||||
고객 정보를 기반으로 ChatGPT를 이용하여 가사를 생성합니다.
|
고객 정보를 기반으로 ChatGPT를 이용하여 가사를 생성합니다.
|
||||||
백그라운드에서 비동기로 처리되며, 즉시 task_id를 반환합니다.
|
백그라운드에서 비동기로 처리되며, 즉시 task_id를 반환합니다.
|
||||||
|
|
||||||
## 요청 필드
|
## 요청 필드
|
||||||
- **task_id**: 작업 고유 식별자 (이미지 업로드 시 생성된 task_id, 필수)
|
- **task_id**: 작업 고유 식별자 (이미지 업로드 시 생성된 task_id, 필수)
|
||||||
- **customer_name**: 고객명/가게명 (필수)
|
- **customer_name**: 고객명/가게명 (필수)
|
||||||
- **region**: 지역명 (필수)
|
- **region**: 지역명 (필수)
|
||||||
- **detail_region_info**: 상세 지역 정보 (선택)
|
- **detail_region_info**: 상세 지역 정보 (선택)
|
||||||
- **language**: 가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)
|
- **language**: 가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)
|
||||||
|
|
||||||
## 반환 정보
|
## 반환 정보
|
||||||
- **success**: 요청 접수 성공 여부
|
- **success**: 요청 접수 성공 여부
|
||||||
- **task_id**: 작업 고유 식별자
|
- **task_id**: 작업 고유 식별자
|
||||||
- **lyric**: null (백그라운드 처리 중)
|
- **lyric**: null (백그라운드 처리 중)
|
||||||
- **language**: 가사 언어
|
- **language**: 가사 언어
|
||||||
- **error_message**: 에러 메시지 (요청 접수 실패 시)
|
- **error_message**: 에러 메시지 (요청 접수 실패 시)
|
||||||
|
|
||||||
## 상태 확인
|
## 상태 확인
|
||||||
- GET /lyric/status/{task_id} 로 처리 상태 확인
|
- GET /lyric/status/{task_id} 로 처리 상태 확인
|
||||||
- GET /lyric/{task_id} 로 생성된 가사 조회
|
- GET /lyric/{task_id} 로 생성된 가사 조회
|
||||||
|
|
||||||
## 사용 예시
|
## 사용 예시
|
||||||
```
|
```
|
||||||
POST /lyric/generate
|
POST /lyric/generate
|
||||||
{
|
{
|
||||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
||||||
"customer_name": "스테이 머뭄",
|
"customer_name": "스테이 머뭄",
|
||||||
"region": "군산",
|
"region": "군산",
|
||||||
"detail_region_info": "군산 신흥동 말랭이 마을",
|
"detail_region_info": "군산 신흥동 말랭이 마을",
|
||||||
"language": "Korean"
|
"language": "Korean"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## 응답 예시
|
## 응답 예시
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
||||||
"lyric": null,
|
"lyric": null,
|
||||||
"language": "Korean",
|
"language": "Korean",
|
||||||
"error_message": null
|
"error_message": null
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
""",
|
""",
|
||||||
response_model=GenerateLyricResponse,
|
response_model=GenerateLyricResponse,
|
||||||
responses={
|
responses={
|
||||||
200: {"description": "가사 생성 요청 접수 성공"},
|
200: {"description": "가사 생성 요청 접수 성공"},
|
||||||
500: {"description": "서버 내부 오류"},
|
500: {"description": "서버 내부 오류"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
async def generate_lyric(
|
async def generate_lyric(
|
||||||
request_body: GenerateLyricRequest,
|
request_body: GenerateLyricRequest,
|
||||||
background_tasks: BackgroundTasks,
|
background_tasks: BackgroundTasks,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> GenerateLyricResponse:
|
) -> GenerateLyricResponse:
|
||||||
"""고객 정보를 기반으로 가사를 생성합니다. (백그라운드 처리)"""
|
"""고객 정보를 기반으로 가사를 생성합니다. (백그라운드 처리)"""
|
||||||
import time
|
task_id = request_body.task_id
|
||||||
|
print(
|
||||||
request_start = time.perf_counter()
|
f"[generate_lyric] START - task_id: {task_id}, "
|
||||||
task_id = request_body.task_id
|
f"customer_name: {request_body.customer_name}, "
|
||||||
|
f"region: {request_body.region}"
|
||||||
print(f"[generate_lyric] ========== START ==========")
|
)
|
||||||
print(
|
|
||||||
f"[generate_lyric] task_id: {task_id}, "
|
try:
|
||||||
f"customer_name: {request_body.customer_name}, "
|
# 1. ChatGPT 서비스 초기화 및 프롬프트 생성
|
||||||
f"region: {request_body.region}"
|
service = ChatgptService(
|
||||||
)
|
customer_name=request_body.customer_name,
|
||||||
|
region=request_body.region,
|
||||||
try:
|
detail_region_info=request_body.detail_region_info or "",
|
||||||
# ========== Step 1: ChatGPT 서비스 초기화 및 프롬프트 생성 ==========
|
language=request_body.language,
|
||||||
step1_start = time.perf_counter()
|
)
|
||||||
print(f"[generate_lyric] Step 1: 서비스 초기화 및 프롬프트 생성...")
|
prompt = service.build_lyrics_prompt()
|
||||||
|
|
||||||
service = ChatgptService(
|
# 2. Project 테이블에 데이터 저장
|
||||||
customer_name=request_body.customer_name,
|
project = Project(
|
||||||
region=request_body.region,
|
store_name=request_body.customer_name,
|
||||||
detail_region_info=request_body.detail_region_info or "",
|
region=request_body.region,
|
||||||
language=request_body.language,
|
task_id=task_id,
|
||||||
)
|
detail_region_info=request_body.detail_region_info,
|
||||||
prompt = service.build_lyrics_prompt()
|
language=request_body.language,
|
||||||
|
)
|
||||||
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
session.add(project)
|
||||||
print(f"[generate_lyric] Step 1 완료 - 프롬프트 {len(prompt)}자 ({step1_elapsed:.1f}ms)")
|
await session.commit()
|
||||||
|
await session.refresh(project)
|
||||||
# ========== Step 2: Project 테이블에 데이터 저장 ==========
|
print(
|
||||||
step2_start = time.perf_counter()
|
f"[generate_lyric] Project saved - "
|
||||||
print(f"[generate_lyric] Step 2: Project 저장...")
|
f"project_id: {project.id}, task_id: {task_id}"
|
||||||
|
)
|
||||||
project = Project(
|
|
||||||
store_name=request_body.customer_name,
|
# 3. Lyric 테이블에 데이터 저장 (status: processing)
|
||||||
region=request_body.region,
|
lyric = Lyric(
|
||||||
task_id=task_id,
|
project_id=project.id,
|
||||||
detail_region_info=request_body.detail_region_info,
|
task_id=task_id,
|
||||||
language=request_body.language,
|
status="processing",
|
||||||
)
|
lyric_prompt=prompt,
|
||||||
session.add(project)
|
lyric_result=None,
|
||||||
await session.commit()
|
language=request_body.language,
|
||||||
await session.refresh(project)
|
)
|
||||||
|
session.add(lyric)
|
||||||
step2_elapsed = (time.perf_counter() - step2_start) * 1000
|
await session.commit()
|
||||||
print(f"[generate_lyric] Step 2 완료 - project_id: {project.id} ({step2_elapsed:.1f}ms)")
|
await session.refresh(lyric)
|
||||||
|
print(
|
||||||
# ========== Step 3: Lyric 테이블에 데이터 저장 ==========
|
f"[generate_lyric] Lyric saved (processing) - "
|
||||||
step3_start = time.perf_counter()
|
f"lyric_id: {lyric.id}, task_id: {task_id}"
|
||||||
print(f"[generate_lyric] Step 3: Lyric 저장 (processing)...")
|
)
|
||||||
|
|
||||||
lyric = Lyric(
|
# 4. 백그라운드 태스크로 ChatGPT 가사 생성 실행
|
||||||
project_id=project.id,
|
background_tasks.add_task(
|
||||||
task_id=task_id,
|
generate_lyric_background,
|
||||||
status="processing",
|
task_id=task_id,
|
||||||
lyric_prompt=prompt,
|
prompt=prompt,
|
||||||
lyric_result=None,
|
language=request_body.language,
|
||||||
language=request_body.language,
|
)
|
||||||
)
|
print(f"[generate_lyric] Background task scheduled - task_id: {task_id}")
|
||||||
session.add(lyric)
|
|
||||||
await session.commit()
|
# 5. 즉시 응답 반환
|
||||||
await session.refresh(lyric)
|
return GenerateLyricResponse(
|
||||||
|
success=True,
|
||||||
step3_elapsed = (time.perf_counter() - step3_start) * 1000
|
task_id=task_id,
|
||||||
print(f"[generate_lyric] Step 3 완료 - lyric_id: {lyric.id} ({step3_elapsed:.1f}ms)")
|
lyric=None,
|
||||||
|
language=request_body.language,
|
||||||
# ========== Step 4: 백그라운드 태스크 스케줄링 ==========
|
error_message=None,
|
||||||
step4_start = time.perf_counter()
|
)
|
||||||
print(f"[generate_lyric] Step 4: 백그라운드 태스크 스케줄링...")
|
|
||||||
|
except Exception as e:
|
||||||
background_tasks.add_task(
|
print(f"[generate_lyric] EXCEPTION - task_id: {task_id}, error: {e}")
|
||||||
generate_lyric_background,
|
await session.rollback()
|
||||||
task_id=task_id,
|
return GenerateLyricResponse(
|
||||||
prompt=prompt,
|
success=False,
|
||||||
language=request_body.language,
|
task_id=task_id,
|
||||||
)
|
lyric=None,
|
||||||
|
language=request_body.language,
|
||||||
step4_elapsed = (time.perf_counter() - step4_start) * 1000
|
error_message=str(e),
|
||||||
print(f"[generate_lyric] Step 4 완료 ({step4_elapsed:.1f}ms)")
|
)
|
||||||
|
|
||||||
# ========== 완료 ==========
|
|
||||||
total_elapsed = (time.perf_counter() - request_start) * 1000
|
@router.get(
|
||||||
print(f"[generate_lyric] ========== COMPLETE ==========")
|
"/status/{task_id}",
|
||||||
print(f"[generate_lyric] API 응답 소요시간: {total_elapsed:.1f}ms")
|
summary="가사 생성 상태 조회",
|
||||||
print(f"[generate_lyric] - Step 1 (프롬프트 생성): {step1_elapsed:.1f}ms")
|
description="""
|
||||||
print(f"[generate_lyric] - Step 2 (Project 저장): {step2_elapsed:.1f}ms")
|
task_id로 가사 생성 작업의 현재 상태를 조회합니다.
|
||||||
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 호출은 백그라운드에서 별도 진행)")
|
- **processing**: 가사 생성 중
|
||||||
|
- **completed**: 가사 생성 완료
|
||||||
# 5. 즉시 응답 반환
|
- **failed**: 가사 생성 실패
|
||||||
return GenerateLyricResponse(
|
|
||||||
success=True,
|
## 사용 예시
|
||||||
task_id=task_id,
|
```
|
||||||
lyric=None,
|
GET /lyric/status/019123ab-cdef-7890-abcd-ef1234567890
|
||||||
language=request_body.language,
|
```
|
||||||
error_message=None,
|
""",
|
||||||
)
|
response_model=LyricStatusResponse,
|
||||||
|
responses={
|
||||||
except Exception as e:
|
200: {"description": "상태 조회 성공"},
|
||||||
elapsed = (time.perf_counter() - request_start) * 1000
|
404: {"description": "해당 task_id를 찾을 수 없음"},
|
||||||
print(f"[generate_lyric] EXCEPTION - task_id: {task_id}, error: {e} ({elapsed:.1f}ms)")
|
},
|
||||||
await session.rollback()
|
)
|
||||||
return GenerateLyricResponse(
|
async def get_lyric_status(
|
||||||
success=False,
|
task_id: str,
|
||||||
task_id=task_id,
|
session: AsyncSession = Depends(get_session),
|
||||||
lyric=None,
|
) -> LyricStatusResponse:
|
||||||
language=request_body.language,
|
"""task_id로 가사 생성 작업 상태를 조회합니다."""
|
||||||
error_message=str(e),
|
return await get_lyric_status_by_task_id(session, task_id)
|
||||||
)
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
@router.get(
|
"s",
|
||||||
"/status/{task_id}",
|
summary="가사 목록 조회 (페이지네이션)",
|
||||||
summary="가사 생성 상태 조회",
|
description="""
|
||||||
description="""
|
생성 완료된 가사를 페이지네이션으로 조회합니다.
|
||||||
task_id로 가사 생성 작업의 현재 상태를 조회합니다.
|
|
||||||
|
## 파라미터
|
||||||
## 상태 값
|
- **page**: 페이지 번호 (1부터 시작, 기본값: 1)
|
||||||
- **processing**: 가사 생성 중
|
- **page_size**: 페이지당 데이터 수 (기본값: 20, 최대: 100)
|
||||||
- **completed**: 가사 생성 완료
|
|
||||||
- **failed**: 가사 생성 실패
|
## 반환 정보
|
||||||
|
- **items**: 가사 목록 (completed 상태만)
|
||||||
## 사용 예시
|
- **total**: 전체 데이터 수
|
||||||
```
|
- **page**: 현재 페이지
|
||||||
GET /lyric/status/019123ab-cdef-7890-abcd-ef1234567890
|
- **page_size**: 페이지당 데이터 수
|
||||||
```
|
- **total_pages**: 전체 페이지 수
|
||||||
""",
|
- **has_next**: 다음 페이지 존재 여부
|
||||||
response_model=LyricStatusResponse,
|
- **has_prev**: 이전 페이지 존재 여부
|
||||||
responses={
|
|
||||||
200: {"description": "상태 조회 성공"},
|
## 사용 예시
|
||||||
404: {"description": "해당 task_id를 찾을 수 없음"},
|
```
|
||||||
},
|
GET /lyrics # 기본 조회 (1페이지, 20개)
|
||||||
)
|
GET /lyrics?page=2 # 2페이지 조회
|
||||||
async def get_lyric_status(
|
GET /lyrics?page=1&page_size=50 # 50개씩 조회
|
||||||
task_id: str,
|
```
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
) -> LyricStatusResponse:
|
## 참고
|
||||||
"""task_id로 가사 생성 작업 상태를 조회합니다."""
|
- 생성 완료(completed)된 가사만 조회됩니다.
|
||||||
return await get_lyric_status_by_task_id(session, task_id)
|
- processing, failed 상태의 가사는 조회되지 않습니다.
|
||||||
|
""",
|
||||||
|
response_model=PaginatedResponse[LyricListItem],
|
||||||
@router.get(
|
responses={
|
||||||
"s",
|
200: {"description": "가사 목록 조회 성공"},
|
||||||
summary="가사 목록 조회 (페이지네이션)",
|
},
|
||||||
description="""
|
)
|
||||||
생성 완료된 가사를 페이지네이션으로 조회합니다.
|
async def list_lyrics(
|
||||||
|
page: int = Query(1, ge=1, description="페이지 번호 (1부터 시작)"),
|
||||||
## 파라미터
|
page_size: int = Query(20, ge=1, le=100, description="페이지당 데이터 수"),
|
||||||
- **page**: 페이지 번호 (1부터 시작, 기본값: 1)
|
session: AsyncSession = Depends(get_session),
|
||||||
- **page_size**: 페이지당 데이터 수 (기본값: 20, 최대: 100)
|
) -> PaginatedResponse[LyricListItem]:
|
||||||
|
"""페이지네이션으로 완료된 가사 목록을 조회합니다."""
|
||||||
## 반환 정보
|
return await get_paginated(
|
||||||
- **items**: 가사 목록 (completed 상태만)
|
session=session,
|
||||||
- **total**: 전체 데이터 수
|
model=Lyric,
|
||||||
- **page**: 현재 페이지
|
item_schema=LyricListItem,
|
||||||
- **page_size**: 페이지당 데이터 수
|
page=page,
|
||||||
- **total_pages**: 전체 페이지 수
|
page_size=page_size,
|
||||||
- **has_next**: 다음 페이지 존재 여부
|
filters={"status": "completed"},
|
||||||
- **has_prev**: 이전 페이지 존재 여부
|
order_by="created_at",
|
||||||
|
order_desc=True,
|
||||||
## 사용 예시
|
)
|
||||||
```
|
|
||||||
GET /lyrics # 기본 조회 (1페이지, 20개)
|
|
||||||
GET /lyrics?page=2 # 2페이지 조회
|
@router.get(
|
||||||
GET /lyrics?page=1&page_size=50 # 50개씩 조회
|
"/{task_id}",
|
||||||
```
|
summary="가사 상세 조회",
|
||||||
|
description="""
|
||||||
## 참고
|
task_id로 생성된 가사의 상세 정보를 조회합니다.
|
||||||
- 생성 완료(completed)된 가사만 조회됩니다.
|
|
||||||
- processing, failed 상태의 가사는 조회되지 않습니다.
|
## 반환 정보
|
||||||
""",
|
- **id**: 가사 ID
|
||||||
response_model=PaginatedResponse[LyricListItem],
|
- **task_id**: 작업 고유 식별자
|
||||||
responses={
|
- **project_id**: 프로젝트 ID
|
||||||
200: {"description": "가사 목록 조회 성공"},
|
- **status**: 처리 상태
|
||||||
},
|
- **lyric_prompt**: 가사 생성에 사용된 프롬프트
|
||||||
)
|
- **lyric_result**: 생성된 가사 (완료 시)
|
||||||
async def list_lyrics(
|
- **created_at**: 생성 일시
|
||||||
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]:
|
GET /lyric/019123ab-cdef-7890-abcd-ef1234567890
|
||||||
"""페이지네이션으로 완료된 가사 목록을 조회합니다."""
|
```
|
||||||
return await get_paginated(
|
""",
|
||||||
session=session,
|
response_model=LyricDetailResponse,
|
||||||
model=Lyric,
|
responses={
|
||||||
item_schema=LyricListItem,
|
200: {"description": "가사 조회 성공"},
|
||||||
page=page,
|
404: {"description": "해당 task_id를 찾을 수 없음"},
|
||||||
page_size=page_size,
|
},
|
||||||
filters={"status": "completed"},
|
)
|
||||||
order_by="created_at",
|
async def get_lyric_detail(
|
||||||
order_desc=True,
|
task_id: str,
|
||||||
)
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> LyricDetailResponse:
|
||||||
|
"""task_id로 생성된 가사를 조회합니다."""
|
||||||
@router.get(
|
return await get_lyric_by_task_id(session, task_id)
|
||||||
"/{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 typing import Annotated
|
||||||
|
|
||||||
from fastapi import Depends
|
from fastapi import Depends
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database.session import get_session
|
from app.database.session import get_session
|
||||||
|
|
||||||
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||||
|
|
|
||||||
|
|
@ -1,133 +1,133 @@
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import TYPE_CHECKING, List
|
from typing import TYPE_CHECKING, List
|
||||||
|
|
||||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func
|
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func
|
||||||
from sqlalchemy.dialects.mysql import LONGTEXT
|
from sqlalchemy.dialects.mysql import LONGTEXT
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.database.session import Base
|
from app.database.session import Base
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from app.home.models import Project
|
from app.home.models import Project
|
||||||
from app.song.models import Song
|
from app.song.models import Song
|
||||||
from app.video.models import Video
|
from app.video.models import Video
|
||||||
|
|
||||||
|
|
||||||
class Lyric(Base):
|
class Lyric(Base):
|
||||||
"""
|
"""
|
||||||
가사 테이블
|
가사 테이블
|
||||||
|
|
||||||
AI를 통해 생성된 가사 정보를 저장합니다.
|
AI를 통해 생성된 가사 정보를 저장합니다.
|
||||||
프롬프트와 생성 결과, 처리 상태를 관리합니다.
|
프롬프트와 생성 결과, 처리 상태를 관리합니다.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
id: 고유 식별자 (자동 증가)
|
id: 고유 식별자 (자동 증가)
|
||||||
project_id: 연결된 Project의 id (외래키)
|
project_id: 연결된 Project의 id (외래키)
|
||||||
task_id: 가사 생성 작업의 고유 식별자 (UUID 형식)
|
task_id: 가사 생성 작업의 고유 식별자 (UUID 형식)
|
||||||
status: 처리 상태 (pending, processing, completed, failed 등)
|
status: 처리 상태 (pending, processing, completed, failed 등)
|
||||||
lyric_prompt: 가사 생성에 사용된 프롬프트
|
lyric_prompt: 가사 생성에 사용된 프롬프트
|
||||||
lyric_result: 생성된 가사 결과 (LONGTEXT로 긴 가사 지원)
|
lyric_result: 생성된 가사 결과 (LONGTEXT로 긴 가사 지원)
|
||||||
created_at: 생성 일시 (자동 설정)
|
created_at: 생성 일시 (자동 설정)
|
||||||
|
|
||||||
Relationships:
|
Relationships:
|
||||||
project: 연결된 Project
|
project: 연결된 Project
|
||||||
songs: 이 가사를 사용한 노래 목록
|
songs: 이 가사를 사용한 노래 목록
|
||||||
videos: 이 가사를 사용한 영상 목록
|
videos: 이 가사를 사용한 영상 목록
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__tablename__ = "lyric"
|
__tablename__ = "lyric"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
{
|
{
|
||||||
"mysql_engine": "InnoDB",
|
"mysql_engine": "InnoDB",
|
||||||
"mysql_charset": "utf8mb4",
|
"mysql_charset": "utf8mb4",
|
||||||
"mysql_collate": "utf8mb4_unicode_ci",
|
"mysql_collate": "utf8mb4_unicode_ci",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(
|
id: Mapped[int] = mapped_column(
|
||||||
Integer,
|
Integer,
|
||||||
primary_key=True,
|
primary_key=True,
|
||||||
nullable=False,
|
nullable=False,
|
||||||
autoincrement=True,
|
autoincrement=True,
|
||||||
comment="고유 식별자",
|
comment="고유 식별자",
|
||||||
)
|
)
|
||||||
|
|
||||||
project_id: Mapped[int] = mapped_column(
|
project_id: Mapped[int] = mapped_column(
|
||||||
Integer,
|
Integer,
|
||||||
ForeignKey("project.id", ondelete="CASCADE"),
|
ForeignKey("project.id", ondelete="CASCADE"),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
index=True,
|
index=True,
|
||||||
comment="연결된 Project의 id",
|
comment="연결된 Project의 id",
|
||||||
)
|
)
|
||||||
|
|
||||||
task_id: Mapped[str] = mapped_column(
|
task_id: Mapped[str] = mapped_column(
|
||||||
String(36),
|
String(36),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
comment="가사 생성 작업 고유 식별자 (UUID)",
|
comment="가사 생성 작업 고유 식별자 (UUID)",
|
||||||
)
|
)
|
||||||
|
|
||||||
status: Mapped[str] = mapped_column(
|
status: Mapped[str] = mapped_column(
|
||||||
String(50),
|
String(50),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
comment="처리 상태 (processing, completed, failed)",
|
comment="처리 상태 (processing, completed, failed)",
|
||||||
)
|
)
|
||||||
|
|
||||||
lyric_prompt: Mapped[str] = mapped_column(
|
lyric_prompt: Mapped[str] = mapped_column(
|
||||||
Text,
|
Text,
|
||||||
nullable=False,
|
nullable=False,
|
||||||
comment="가사 생성에 사용된 프롬프트",
|
comment="가사 생성에 사용된 프롬프트",
|
||||||
)
|
)
|
||||||
|
|
||||||
lyric_result: Mapped[str] = mapped_column(
|
lyric_result: Mapped[str] = mapped_column(
|
||||||
LONGTEXT,
|
LONGTEXT,
|
||||||
nullable=True,
|
nullable=True,
|
||||||
comment="생성된 가사 결과",
|
comment="생성된 가사 결과",
|
||||||
)
|
)
|
||||||
|
|
||||||
language: Mapped[str] = mapped_column(
|
language: Mapped[str] = mapped_column(
|
||||||
String(50),
|
String(50),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
default="Korean",
|
default="Korean",
|
||||||
comment="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
|
comment="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
|
||||||
)
|
)
|
||||||
|
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime,
|
DateTime,
|
||||||
nullable=True,
|
nullable=True,
|
||||||
server_default=func.now(),
|
server_default=func.now(),
|
||||||
comment="생성 일시",
|
comment="생성 일시",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
project: Mapped["Project"] = relationship(
|
project: Mapped["Project"] = relationship(
|
||||||
"Project",
|
"Project",
|
||||||
back_populates="lyrics",
|
back_populates="lyrics",
|
||||||
)
|
)
|
||||||
|
|
||||||
songs: Mapped[List["Song"]] = relationship(
|
songs: Mapped[List["Song"]] = relationship(
|
||||||
"Song",
|
"Song",
|
||||||
back_populates="lyric",
|
back_populates="lyric",
|
||||||
cascade="all, delete-orphan",
|
cascade="all, delete-orphan",
|
||||||
lazy="selectin",
|
lazy="selectin",
|
||||||
)
|
)
|
||||||
|
|
||||||
videos: Mapped[List["Video"]] = relationship(
|
videos: Mapped[List["Video"]] = relationship(
|
||||||
"Video",
|
"Video",
|
||||||
back_populates="lyric",
|
back_populates="lyric",
|
||||||
cascade="all, delete-orphan",
|
cascade="all, delete-orphan",
|
||||||
lazy="selectin",
|
lazy="selectin",
|
||||||
)
|
)
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
def truncate(value: str | None, max_len: int = 10) -> str:
|
def truncate(value: str | None, max_len: int = 10) -> str:
|
||||||
if value is None:
|
if value is None:
|
||||||
return "None"
|
return "None"
|
||||||
return (value[:max_len] + "...") if len(value) > max_len else value
|
return (value[:max_len] + "...") if len(value) > max_len else value
|
||||||
|
|
||||||
return (
|
return (
|
||||||
f"<Lyric("
|
f"<Lyric("
|
||||||
f"id={self.id}, "
|
f"id={self.id}, "
|
||||||
f"task_id='{truncate(self.task_id)}', "
|
f"task_id='{truncate(self.task_id)}', "
|
||||||
f"status='{self.status}'"
|
f"status='{self.status}'"
|
||||||
f")>"
|
f")>"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,182 +1,182 @@
|
||||||
"""
|
"""
|
||||||
Lyric API Schemas
|
Lyric API Schemas
|
||||||
|
|
||||||
이 모듈은 가사 관련 API 엔드포인트에서 사용되는 Pydantic 스키마를 정의합니다.
|
이 모듈은 가사 관련 API 엔드포인트에서 사용되는 Pydantic 스키마를 정의합니다.
|
||||||
|
|
||||||
사용 예시:
|
사용 예시:
|
||||||
from app.lyric.schemas.lyric import (
|
from app.lyric.schemas.lyric import (
|
||||||
LyricStatusResponse,
|
LyricStatusResponse,
|
||||||
LyricDetailResponse,
|
LyricDetailResponse,
|
||||||
LyricListItem,
|
LyricListItem,
|
||||||
)
|
)
|
||||||
from app.utils.pagination import PaginatedResponse
|
from app.utils.pagination import PaginatedResponse
|
||||||
|
|
||||||
# 라우터에서 response_model로 사용
|
# 라우터에서 response_model로 사용
|
||||||
@router.get("/lyric/{task_id}", response_model=LyricDetailResponse)
|
@router.get("/lyric/{task_id}", response_model=LyricDetailResponse)
|
||||||
async def get_lyric(task_id: str):
|
async def get_lyric(task_id: str):
|
||||||
...
|
...
|
||||||
|
|
||||||
# 페이지네이션 응답 (공통 스키마 사용)
|
# 페이지네이션 응답 (공통 스키마 사용)
|
||||||
@router.get("/songs", response_model=PaginatedResponse[SongListItem])
|
@router.get("/songs", response_model=PaginatedResponse[SongListItem])
|
||||||
async def list_songs(...):
|
async def list_songs(...):
|
||||||
...
|
...
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
class GenerateLyricRequest(BaseModel):
|
class GenerateLyricRequest(BaseModel):
|
||||||
"""가사 생성 요청 스키마
|
"""가사 생성 요청 스키마
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
POST /lyric/generate
|
POST /lyric/generate
|
||||||
Request body for generating lyrics.
|
Request body for generating lyrics.
|
||||||
|
|
||||||
Example Request:
|
Example Request:
|
||||||
{
|
{
|
||||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
||||||
"customer_name": "스테이 머뭄",
|
"customer_name": "스테이 머뭄",
|
||||||
"region": "군산",
|
"region": "군산",
|
||||||
"detail_region_info": "군산 신흥동 말랭이 마을",
|
"detail_region_info": "군산 신흥동 말랭이 마을",
|
||||||
"language": "Korean"
|
"language": "Korean"
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model_config = ConfigDict(
|
model_config = ConfigDict(
|
||||||
json_schema_extra={
|
json_schema_extra={
|
||||||
"example": {
|
"example": {
|
||||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
||||||
"customer_name": "스테이 머뭄",
|
"customer_name": "스테이 머뭄",
|
||||||
"region": "군산",
|
"region": "군산",
|
||||||
"detail_region_info": "군산 신흥동 말랭이 마을",
|
"detail_region_info": "군산 신흥동 말랭이 마을",
|
||||||
"language": "Korean",
|
"language": "Korean",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
task_id: str = Field(
|
task_id: str = Field(
|
||||||
..., description="작업 고유 식별자 (이미지 업로드 시 생성된 task_id)"
|
..., description="작업 고유 식별자 (이미지 업로드 시 생성된 task_id)"
|
||||||
)
|
)
|
||||||
customer_name: str = Field(..., description="고객명/가게명")
|
customer_name: str = Field(..., description="고객명/가게명")
|
||||||
region: str = Field(..., description="지역명")
|
region: str = Field(..., description="지역명")
|
||||||
detail_region_info: Optional[str] = Field(None, description="상세 지역 정보")
|
detail_region_info: Optional[str] = Field(None, description="상세 지역 정보")
|
||||||
language: str = Field(
|
language: str = Field(
|
||||||
default="Korean",
|
default="Korean",
|
||||||
description="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
|
description="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class GenerateLyricResponse(BaseModel):
|
class GenerateLyricResponse(BaseModel):
|
||||||
"""가사 생성 응답 스키마
|
"""가사 생성 응답 스키마
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
POST /lyric/generate
|
POST /lyric/generate
|
||||||
Returns the generated lyrics.
|
Returns the generated lyrics.
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
실패 조건:
|
실패 조건:
|
||||||
- ChatGPT API 오류
|
- ChatGPT API 오류
|
||||||
- ChatGPT 거부 응답 (I'm sorry, I cannot, I can't, I apologize 등)
|
- ChatGPT 거부 응답 (I'm sorry, I cannot, I can't, I apologize 등)
|
||||||
- 응답에 ERROR: 포함
|
- 응답에 ERROR: 포함
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model_config = ConfigDict(
|
model_config = ConfigDict(
|
||||||
json_schema_extra={
|
json_schema_extra={
|
||||||
"example": {
|
"example": {
|
||||||
"success": True,
|
"success": True,
|
||||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
||||||
"lyric": "인스타 감성의 스테이 머뭄\n군산 신흥동 말랭이 마을에서\n여유로운 하루를 보내며\n추억을 만들어가요",
|
"lyric": "인스타 감성의 스테이 머뭄\n군산 신흥동 말랭이 마을에서\n여유로운 하루를 보내며\n추억을 만들어가요",
|
||||||
"language": "Korean",
|
"language": "Korean",
|
||||||
"error_message": None,
|
"error_message": None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
success: bool = Field(..., description="생성 성공 여부")
|
success: bool = Field(..., description="생성 성공 여부")
|
||||||
task_id: Optional[str] = Field(None, description="작업 고유 식별자 (uuid7)")
|
task_id: Optional[str] = Field(None, description="작업 고유 식별자 (uuid7)")
|
||||||
lyric: Optional[str] = Field(None, description="생성된 가사 (성공 시)")
|
lyric: Optional[str] = Field(None, description="생성된 가사 (성공 시)")
|
||||||
language: str = Field(..., description="가사 언어")
|
language: str = Field(..., description="가사 언어")
|
||||||
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시, ChatGPT 거부 응답 포함)")
|
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시, ChatGPT 거부 응답 포함)")
|
||||||
|
|
||||||
|
|
||||||
class LyricStatusResponse(BaseModel):
|
class LyricStatusResponse(BaseModel):
|
||||||
"""가사 상태 조회 응답 스키마
|
"""가사 상태 조회 응답 스키마
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
GET /lyric/status/{task_id}
|
GET /lyric/status/{task_id}
|
||||||
Returns the current processing status of a lyric generation task.
|
Returns the current processing status of a lyric generation task.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model_config = ConfigDict(
|
model_config = ConfigDict(
|
||||||
json_schema_extra={
|
json_schema_extra={
|
||||||
"example": {
|
"example": {
|
||||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"message": "가사 생성이 완료되었습니다.",
|
"message": "가사 생성이 완료되었습니다.",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
task_id: str = Field(..., description="작업 고유 식별자")
|
task_id: str = Field(..., description="작업 고유 식별자")
|
||||||
status: str = Field(..., description="처리 상태 (processing, completed, failed)")
|
status: str = Field(..., description="처리 상태 (processing, completed, failed)")
|
||||||
message: str = Field(..., description="상태 메시지")
|
message: str = Field(..., description="상태 메시지")
|
||||||
|
|
||||||
|
|
||||||
class LyricDetailResponse(BaseModel):
|
class LyricDetailResponse(BaseModel):
|
||||||
"""가사 상세 조회 응답 스키마
|
"""가사 상세 조회 응답 스키마
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
GET /lyric/{task_id}
|
GET /lyric/{task_id}
|
||||||
Returns the generated lyric content for a specific task.
|
Returns the generated lyric content for a specific task.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model_config = ConfigDict(
|
model_config = ConfigDict(
|
||||||
json_schema_extra={
|
json_schema_extra={
|
||||||
"example": {
|
"example": {
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
||||||
"project_id": 1,
|
"project_id": 1,
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"lyric_prompt": "고객명: 스테이 머뭄, 지역: 군산...",
|
"lyric_prompt": "고객명: 스테이 머뭄, 지역: 군산...",
|
||||||
"lyric_result": "인스타 감성의 스테이 머뭄\n군산 신흥동 말랭이 마을에서\n여유로운 하루를 보내며\n추억을 만들어가요",
|
"lyric_result": "인스타 감성의 스테이 머뭄\n군산 신흥동 말랭이 마을에서\n여유로운 하루를 보내며\n추억을 만들어가요",
|
||||||
"created_at": "2024-01-15T12:00:00",
|
"created_at": "2024-01-15T12:00:00",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
id: int = Field(..., description="가사 ID")
|
id: int = Field(..., description="가사 ID")
|
||||||
task_id: str = Field(..., description="작업 고유 식별자")
|
task_id: str = Field(..., description="작업 고유 식별자")
|
||||||
project_id: int = Field(..., description="프로젝트 ID")
|
project_id: int = Field(..., description="프로젝트 ID")
|
||||||
status: str = Field(..., description="처리 상태")
|
status: str = Field(..., description="처리 상태")
|
||||||
lyric_prompt: str = Field(..., description="가사 생성 프롬프트")
|
lyric_prompt: str = Field(..., description="가사 생성 프롬프트")
|
||||||
lyric_result: Optional[str] = Field(None, description="생성된 가사")
|
lyric_result: Optional[str] = Field(None, description="생성된 가사")
|
||||||
created_at: Optional[datetime] = Field(None, description="생성 일시")
|
created_at: Optional[datetime] = Field(None, description="생성 일시")
|
||||||
|
|
||||||
|
|
||||||
class LyricListItem(BaseModel):
|
class LyricListItem(BaseModel):
|
||||||
"""가사 목록 아이템 스키마
|
"""가사 목록 아이템 스키마
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
Used as individual items in paginated lyric list responses.
|
Used as individual items in paginated lyric list responses.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model_config = ConfigDict(
|
model_config = ConfigDict(
|
||||||
json_schema_extra={
|
json_schema_extra={
|
||||||
"example": {
|
"example": {
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"lyric_result": "인스타 감성의 스테이 머뭄\n군산 신흥동 말랭이 마을에서...",
|
"lyric_result": "인스타 감성의 스테이 머뭄\n군산 신흥동 말랭이 마을에서...",
|
||||||
"created_at": "2024-01-15T12:00:00",
|
"created_at": "2024-01-15T12:00:00",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
id: int = Field(..., description="가사 ID")
|
id: int = Field(..., description="가사 ID")
|
||||||
task_id: str = Field(..., description="작업 고유 식별자")
|
task_id: str = Field(..., description="작업 고유 식별자")
|
||||||
status: str = Field(..., description="처리 상태")
|
status: str = Field(..., description="처리 상태")
|
||||||
lyric_result: Optional[str] = Field(None, description="생성된 가사 (미리보기)")
|
lyric_result: Optional[str] = Field(None, description="생성된 가사 (미리보기)")
|
||||||
created_at: Optional[datetime] = Field(None, description="생성 일시")
|
created_at: Optional[datetime] = Field(None, description="생성 일시")
|
||||||
|
|
|
||||||
|
|
@ -1,91 +1,91 @@
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Dict, List
|
from typing import Dict, List
|
||||||
|
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class StoreData:
|
class StoreData:
|
||||||
id: int
|
id: int
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
store_name: str
|
store_name: str
|
||||||
store_category: str | None = None
|
store_category: str | None = None
|
||||||
store_region: str | None = None
|
store_region: str | None = None
|
||||||
store_address: str | None = None
|
store_address: str | None = None
|
||||||
store_phone_number: str | None = None
|
store_phone_number: str | None = None
|
||||||
store_info: str | None = None
|
store_info: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class AttributeData:
|
class AttributeData:
|
||||||
id: int
|
id: int
|
||||||
attr_category: str
|
attr_category: str
|
||||||
attr_value: str
|
attr_value: str
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class SongSampleData:
|
class SongSampleData:
|
||||||
id: int
|
id: int
|
||||||
ai: str
|
ai: str
|
||||||
ai_model: str
|
ai_model: str
|
||||||
sample_song: str
|
sample_song: str
|
||||||
season: str | None = None
|
season: str | None = None
|
||||||
num_of_people: int | None = None
|
num_of_people: int | None = None
|
||||||
people_category: str | None = None
|
people_category: str | None = None
|
||||||
genre: str | None = None
|
genre: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class PromptTemplateData:
|
class PromptTemplateData:
|
||||||
id: int
|
id: int
|
||||||
prompt: str
|
prompt: str
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class SongFormData:
|
class SongFormData:
|
||||||
store_name: str
|
store_name: str
|
||||||
store_id: str
|
store_id: str
|
||||||
prompts: str
|
prompts: str
|
||||||
attributes: Dict[str, str] = field(default_factory=dict)
|
attributes: Dict[str, str] = field(default_factory=dict)
|
||||||
attributes_str: str = ""
|
attributes_str: str = ""
|
||||||
lyrics_ids: List[int] = field(default_factory=list)
|
lyrics_ids: List[int] = field(default_factory=list)
|
||||||
llm_model: str = "gpt-5-mini"
|
llm_model: str = "gpt-4o"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def from_form(cls, request: Request):
|
async def from_form(cls, request: Request):
|
||||||
"""Request의 form 데이터로부터 dataclass 인스턴스 생성"""
|
"""Request의 form 데이터로부터 dataclass 인스턴스 생성"""
|
||||||
form_data = await request.form()
|
form_data = await request.form()
|
||||||
|
|
||||||
# 고정 필드명들
|
# 고정 필드명들
|
||||||
fixed_keys = {"store_info_name", "store_id", "llm_model", "prompts"}
|
fixed_keys = {"store_info_name", "store_id", "llm_model", "prompts"}
|
||||||
|
|
||||||
# lyrics-{id} 형태의 모든 키를 찾아서 ID 추출
|
# lyrics-{id} 형태의 모든 키를 찾아서 ID 추출
|
||||||
lyrics_ids = []
|
lyrics_ids = []
|
||||||
attributes = {}
|
attributes = {}
|
||||||
|
|
||||||
for key, value in form_data.items():
|
for key, value in form_data.items():
|
||||||
if key.startswith("lyrics-"):
|
if key.startswith("lyrics-"):
|
||||||
lyrics_id = key.split("-")[1]
|
lyrics_id = key.split("-")[1]
|
||||||
lyrics_ids.append(int(lyrics_id))
|
lyrics_ids.append(int(lyrics_id))
|
||||||
elif key not in fixed_keys:
|
elif key not in fixed_keys:
|
||||||
attributes[key] = value
|
attributes[key] = value
|
||||||
|
|
||||||
# attributes를 문자열로 변환
|
# attributes를 문자열로 변환
|
||||||
attributes_str = (
|
attributes_str = (
|
||||||
"\r\n\r\n".join([f"{key} : {value}" for key, value in attributes.items()])
|
"\r\n\r\n".join([f"{key} : {value}" for key, value in attributes.items()])
|
||||||
if attributes
|
if attributes
|
||||||
else ""
|
else ""
|
||||||
)
|
)
|
||||||
|
|
||||||
return cls(
|
return cls(
|
||||||
store_name=form_data.get("store_info_name", ""),
|
store_name=form_data.get("store_info_name", ""),
|
||||||
store_id=form_data.get("store_id", ""),
|
store_id=form_data.get("store_id", ""),
|
||||||
attributes=attributes,
|
attributes=attributes,
|
||||||
attributes_str=attributes_str,
|
attributes_str=attributes_str,
|
||||||
lyrics_ids=lyrics_ids,
|
lyrics_ids=lyrics_ids,
|
||||||
llm_model=form_data.get("llm_model", "gpt-5-mini"),
|
llm_model=form_data.get("llm_model", "gpt-4o"),
|
||||||
prompts=form_data.get("prompts", ""),
|
prompts=form_data.get("prompts", ""),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,24 @@
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlmodel import SQLModel
|
from sqlmodel import SQLModel
|
||||||
|
|
||||||
|
|
||||||
class BaseService:
|
class BaseService:
|
||||||
def __init__(self, model, session: AsyncSession):
|
def __init__(self, model, session: AsyncSession):
|
||||||
self.model = model
|
self.model = model
|
||||||
self.session = session
|
self.session = session
|
||||||
|
|
||||||
async def _get(self, id: UUID):
|
async def _get(self, id: UUID):
|
||||||
return await self.session.get(self.model, id)
|
return await self.session.get(self.model, id)
|
||||||
|
|
||||||
async def _add(self, entity):
|
async def _add(self, entity):
|
||||||
self.session.add(entity)
|
self.session.add(entity)
|
||||||
await self.session.commit()
|
await self.session.commit()
|
||||||
await self.session.refresh(entity)
|
await self.session.refresh(entity)
|
||||||
return entity
|
return entity
|
||||||
|
|
||||||
async def _update(self, entity):
|
async def _update(self, entity):
|
||||||
return await self._add(entity)
|
return await self._add(entity)
|
||||||
|
|
||||||
async def _delete(self, entity):
|
async def _delete(self, entity):
|
||||||
await self.session.delete(entity)
|
await self.session.delete(entity)
|
||||||
|
|
@ -1,146 +1,98 @@
|
||||||
"""
|
"""
|
||||||
Lyric Background Tasks
|
Lyric Background Tasks
|
||||||
|
|
||||||
가사 생성 관련 백그라운드 태스크를 정의합니다.
|
가사 생성 관련 백그라운드 태스크를 정의합니다.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
from sqlalchemy import select
|
||||||
import traceback
|
|
||||||
|
from app.database.session import BackgroundSessionLocal
|
||||||
from sqlalchemy import select
|
from app.lyric.models import Lyric
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
from app.utils.chatgpt_prompt import ChatgptService
|
||||||
|
|
||||||
from app.database.session import BackgroundSessionLocal
|
|
||||||
from app.lyric.models import Lyric
|
async def generate_lyric_background(
|
||||||
from app.utils.chatgpt_prompt import ChatgptService
|
task_id: str,
|
||||||
|
prompt: str,
|
||||||
# 로거 설정
|
language: str,
|
||||||
logger = logging.getLogger(__name__)
|
) -> None:
|
||||||
|
"""백그라운드에서 ChatGPT를 통해 가사를 생성하고 Lyric 테이블을 업데이트합니다.
|
||||||
|
|
||||||
async def _update_lyric_status(
|
Args:
|
||||||
task_id: str,
|
task_id: 프로젝트 task_id
|
||||||
status: str,
|
prompt: ChatGPT에 전달할 프롬프트
|
||||||
result: str | None = None,
|
language: 가사 언어
|
||||||
) -> bool:
|
"""
|
||||||
"""Lyric 테이블의 상태를 업데이트합니다.
|
print(f"[generate_lyric_background] START - task_id: {task_id}")
|
||||||
|
|
||||||
Args:
|
try:
|
||||||
task_id: 프로젝트 task_id
|
# ChatGPT 서비스 초기화 (프롬프트는 이미 생성되어 있음)
|
||||||
status: 변경할 상태 ("processing", "completed", "failed")
|
service = ChatgptService(
|
||||||
result: 가사 결과 또는 에러 메시지
|
customer_name="", # 프롬프트가 이미 생성되었으므로 빈 값
|
||||||
|
region="",
|
||||||
Returns:
|
detail_region_info="",
|
||||||
bool: 업데이트 성공 여부
|
language=language,
|
||||||
"""
|
)
|
||||||
try:
|
|
||||||
async with BackgroundSessionLocal() as session:
|
# ChatGPT를 통해 가사 생성
|
||||||
query_result = await session.execute(
|
print(f"[generate_lyric_background] ChatGPT generation started - task_id: {task_id}")
|
||||||
select(Lyric)
|
result = await service.generate(prompt=prompt)
|
||||||
.where(Lyric.task_id == task_id)
|
print(f"[generate_lyric_background] ChatGPT generation completed - task_id: {task_id}")
|
||||||
.order_by(Lyric.created_at.desc())
|
|
||||||
.limit(1)
|
# 실패 응답 검사 (ERROR 또는 ChatGPT 거부 응답)
|
||||||
)
|
failure_patterns = [
|
||||||
lyric = query_result.scalar_one_or_none()
|
"ERROR:",
|
||||||
|
"I'm sorry",
|
||||||
if lyric:
|
"I cannot",
|
||||||
lyric.status = status
|
"I can't",
|
||||||
if result is not None:
|
"I apologize",
|
||||||
lyric.lyric_result = result
|
"I'm unable",
|
||||||
await session.commit()
|
"I am unable",
|
||||||
logger.info(f"[Lyric] Status updated - task_id: {task_id}, status: {status}")
|
"I'm not able",
|
||||||
print(f"[Lyric] Status updated - task_id: {task_id}, status: {status}")
|
"I am not able",
|
||||||
return True
|
]
|
||||||
else:
|
is_failure = any(
|
||||||
logger.warning(f"[Lyric] NOT FOUND in DB - task_id: {task_id}")
|
pattern.lower() in result.lower() for pattern in failure_patterns
|
||||||
print(f"[Lyric] NOT FOUND in DB - task_id: {task_id}")
|
)
|
||||||
return False
|
|
||||||
|
# Lyric 테이블 업데이트 (백그라운드 전용 세션 사용)
|
||||||
except SQLAlchemyError as e:
|
async with BackgroundSessionLocal() as session:
|
||||||
logger.error(f"[Lyric] DB Error while updating status - task_id: {task_id}, error: {e}")
|
query_result = await session.execute(
|
||||||
print(f"[Lyric] DB Error while updating status - task_id: {task_id}, error: {e}")
|
select(Lyric)
|
||||||
return False
|
.where(Lyric.task_id == task_id)
|
||||||
except Exception as e:
|
.order_by(Lyric.created_at.desc())
|
||||||
logger.error(f"[Lyric] Unexpected error while updating status - task_id: {task_id}, error: {e}")
|
.limit(1)
|
||||||
print(f"[Lyric] Unexpected error while updating status - task_id: {task_id}, error: {e}")
|
)
|
||||||
return False
|
lyric = query_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if lyric:
|
||||||
async def generate_lyric_background(
|
if is_failure:
|
||||||
task_id: str,
|
print(f"[generate_lyric_background] FAILED - task_id: {task_id}, error: {result}")
|
||||||
prompt: str,
|
lyric.status = "failed"
|
||||||
language: str,
|
lyric.lyric_result = result
|
||||||
) -> None:
|
else:
|
||||||
"""백그라운드에서 ChatGPT를 통해 가사를 생성하고 Lyric 테이블을 업데이트합니다.
|
print(f"[generate_lyric_background] SUCCESS - task_id: {task_id}")
|
||||||
|
lyric.status = "completed"
|
||||||
Args:
|
lyric.lyric_result = result
|
||||||
task_id: 프로젝트 task_id
|
|
||||||
prompt: ChatGPT에 전달할 프롬프트
|
await session.commit()
|
||||||
language: 가사 언어
|
else:
|
||||||
"""
|
print(f"[generate_lyric_background] Lyric NOT FOUND in DB - task_id: {task_id}")
|
||||||
import time
|
|
||||||
|
except Exception as e:
|
||||||
task_start = time.perf_counter()
|
print(f"[generate_lyric_background] EXCEPTION - task_id: {task_id}, error: {e}")
|
||||||
logger.info(f"[generate_lyric_background] START - task_id: {task_id}")
|
# 실패 시 Lyric 테이블 업데이트
|
||||||
print(f"[generate_lyric_background] ========== START ==========")
|
async with BackgroundSessionLocal() as session:
|
||||||
print(f"[generate_lyric_background] task_id: {task_id}")
|
query_result = await session.execute(
|
||||||
print(f"[generate_lyric_background] language: {language}")
|
select(Lyric)
|
||||||
print(f"[generate_lyric_background] prompt length: {len(prompt)}자")
|
.where(Lyric.task_id == task_id)
|
||||||
|
.order_by(Lyric.created_at.desc())
|
||||||
try:
|
.limit(1)
|
||||||
# ========== Step 1: ChatGPT 서비스 초기화 ==========
|
)
|
||||||
step1_start = time.perf_counter()
|
lyric = query_result.scalar_one_or_none()
|
||||||
print(f"[generate_lyric_background] Step 1: ChatGPT 서비스 초기화...")
|
|
||||||
|
if lyric:
|
||||||
service = ChatgptService(
|
lyric.status = "failed"
|
||||||
customer_name="", # 프롬프트가 이미 생성되었으므로 빈 값
|
lyric.lyric_result = f"Error: {str(e)}"
|
||||||
region="",
|
await session.commit()
|
||||||
detail_region_info="",
|
print(f"[generate_lyric_background] FAILED - task_id: {task_id}, status updated to failed")
|
||||||
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 sqladmin import ModelView
|
||||||
|
|
||||||
from app.song.models import Song
|
from app.song.models import Song
|
||||||
|
|
||||||
|
|
||||||
class SongAdmin(ModelView, model=Song):
|
class SongAdmin(ModelView, model=Song):
|
||||||
name = "노래"
|
name = "노래"
|
||||||
name_plural = "노래 목록"
|
name_plural = "노래 목록"
|
||||||
icon = "fa-solid fa-headphones"
|
icon = "fa-solid fa-headphones"
|
||||||
category = "노래 관리"
|
category = "노래 관리"
|
||||||
page_size = 20
|
page_size = 20
|
||||||
|
|
||||||
column_list = [
|
column_list = [
|
||||||
"id",
|
"id",
|
||||||
"project_id",
|
"project_id",
|
||||||
"lyric_id",
|
"lyric_id",
|
||||||
"task_id",
|
"task_id",
|
||||||
"suno_task_id",
|
"suno_task_id",
|
||||||
"status",
|
"status",
|
||||||
"language",
|
"language",
|
||||||
"created_at",
|
"created_at",
|
||||||
]
|
]
|
||||||
|
|
||||||
column_details_list = [
|
column_details_list = [
|
||||||
"id",
|
"id",
|
||||||
"project_id",
|
"project_id",
|
||||||
"lyric_id",
|
"lyric_id",
|
||||||
"task_id",
|
"task_id",
|
||||||
"suno_task_id",
|
"suno_task_id",
|
||||||
"status",
|
"status",
|
||||||
"language",
|
"language",
|
||||||
"song_prompt",
|
"song_prompt",
|
||||||
"song_result_url",
|
"song_result_url",
|
||||||
"created_at",
|
"created_at",
|
||||||
]
|
]
|
||||||
|
|
||||||
# 폼(생성/수정)에서 제외
|
# 폼(생성/수정)에서 제외
|
||||||
form_excluded_columns = ["created_at", "videos"]
|
form_excluded_columns = ["created_at", "videos"]
|
||||||
|
|
||||||
column_searchable_list = [
|
column_searchable_list = [
|
||||||
Song.task_id,
|
Song.task_id,
|
||||||
Song.suno_task_id,
|
Song.suno_task_id,
|
||||||
Song.status,
|
Song.status,
|
||||||
Song.language,
|
Song.language,
|
||||||
]
|
]
|
||||||
|
|
||||||
column_default_sort = (Song.created_at, True) # True: DESC (최신순)
|
column_default_sort = (Song.created_at, True) # True: DESC (최신순)
|
||||||
|
|
||||||
column_sortable_list = [
|
column_sortable_list = [
|
||||||
Song.id,
|
Song.id,
|
||||||
Song.project_id,
|
Song.project_id,
|
||||||
Song.lyric_id,
|
Song.lyric_id,
|
||||||
Song.status,
|
Song.status,
|
||||||
Song.language,
|
Song.language,
|
||||||
Song.created_at,
|
Song.created_at,
|
||||||
]
|
]
|
||||||
|
|
||||||
column_labels = {
|
column_labels = {
|
||||||
"id": "ID",
|
"id": "ID",
|
||||||
"project_id": "프로젝트 ID",
|
"project_id": "프로젝트 ID",
|
||||||
"lyric_id": "가사 ID",
|
"lyric_id": "가사 ID",
|
||||||
"task_id": "작업 ID",
|
"task_id": "작업 ID",
|
||||||
"suno_task_id": "Suno 작업 ID",
|
"suno_task_id": "Suno 작업 ID",
|
||||||
"status": "상태",
|
"status": "상태",
|
||||||
"language": "언어",
|
"language": "언어",
|
||||||
"song_prompt": "프롬프트",
|
"song_prompt": "프롬프트",
|
||||||
"song_result_url": "결과 URL",
|
"song_result_url": "결과 URL",
|
||||||
"created_at": "생성일시",
|
"created_at": "생성일시",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import Depends
|
from fastapi import Depends
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database.session import get_session
|
from app.database.session import get_session
|
||||||
|
|
||||||
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||||
|
|
|
||||||
|
|
@ -1,152 +1,152 @@
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import TYPE_CHECKING, List, Optional
|
from typing import TYPE_CHECKING, List, Optional
|
||||||
|
|
||||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func
|
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.database.session import Base
|
from app.database.session import Base
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from app.home.models import Project
|
from app.home.models import Project
|
||||||
from app.lyric.models import Lyric
|
from app.lyric.models import Lyric
|
||||||
from app.video.models import Video
|
from app.video.models import Video
|
||||||
|
|
||||||
|
|
||||||
class Song(Base):
|
class Song(Base):
|
||||||
"""
|
"""
|
||||||
노래 테이블
|
노래 테이블
|
||||||
|
|
||||||
AI를 통해 생성된 노래 정보를 저장합니다.
|
AI를 통해 생성된 노래 정보를 저장합니다.
|
||||||
가사를 기반으로 생성됩니다.
|
가사를 기반으로 생성됩니다.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
id: 고유 식별자 (자동 증가)
|
id: 고유 식별자 (자동 증가)
|
||||||
project_id: 연결된 Project의 id (외래키)
|
project_id: 연결된 Project의 id (외래키)
|
||||||
lyric_id: 연결된 Lyric의 id (외래키)
|
lyric_id: 연결된 Lyric의 id (외래키)
|
||||||
task_id: 노래 생성 작업의 고유 식별자 (UUID 형식)
|
task_id: 노래 생성 작업의 고유 식별자 (UUID 형식)
|
||||||
suno_task_id: Suno API 작업 고유 식별자 (선택)
|
suno_task_id: Suno API 작업 고유 식별자 (선택)
|
||||||
status: 처리 상태 (pending, processing, completed, failed 등)
|
status: 처리 상태 (pending, processing, completed, failed 등)
|
||||||
song_prompt: 노래 생성에 사용된 프롬프트
|
song_prompt: 노래 생성에 사용된 프롬프트
|
||||||
song_result_url: 생성 결과 URL (선택)
|
song_result_url: 생성 결과 URL (선택)
|
||||||
language: 출력 언어
|
language: 출력 언어
|
||||||
created_at: 생성 일시 (자동 설정)
|
created_at: 생성 일시 (자동 설정)
|
||||||
|
|
||||||
Relationships:
|
Relationships:
|
||||||
project: 연결된 Project
|
project: 연결된 Project
|
||||||
lyric: 연결된 Lyric
|
lyric: 연결된 Lyric
|
||||||
videos: 이 노래를 사용한 영상 결과 목록
|
videos: 이 노래를 사용한 영상 결과 목록
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__tablename__ = "song"
|
__tablename__ = "song"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
{
|
{
|
||||||
"mysql_engine": "InnoDB",
|
"mysql_engine": "InnoDB",
|
||||||
"mysql_charset": "utf8mb4",
|
"mysql_charset": "utf8mb4",
|
||||||
"mysql_collate": "utf8mb4_unicode_ci",
|
"mysql_collate": "utf8mb4_unicode_ci",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(
|
id: Mapped[int] = mapped_column(
|
||||||
Integer,
|
Integer,
|
||||||
primary_key=True,
|
primary_key=True,
|
||||||
nullable=False,
|
nullable=False,
|
||||||
autoincrement=True,
|
autoincrement=True,
|
||||||
comment="고유 식별자",
|
comment="고유 식별자",
|
||||||
)
|
)
|
||||||
|
|
||||||
project_id: Mapped[int] = mapped_column(
|
project_id: Mapped[int] = mapped_column(
|
||||||
Integer,
|
Integer,
|
||||||
ForeignKey("project.id", ondelete="CASCADE"),
|
ForeignKey("project.id", ondelete="CASCADE"),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
index=True,
|
index=True,
|
||||||
comment="연결된 Project의 id",
|
comment="연결된 Project의 id",
|
||||||
)
|
)
|
||||||
|
|
||||||
lyric_id: Mapped[int] = mapped_column(
|
lyric_id: Mapped[int] = mapped_column(
|
||||||
Integer,
|
Integer,
|
||||||
ForeignKey("lyric.id", ondelete="CASCADE"),
|
ForeignKey("lyric.id", ondelete="CASCADE"),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
index=True,
|
index=True,
|
||||||
comment="연결된 Lyric의 id",
|
comment="연결된 Lyric의 id",
|
||||||
)
|
)
|
||||||
|
|
||||||
task_id: Mapped[str] = mapped_column(
|
task_id: Mapped[str] = mapped_column(
|
||||||
String(36),
|
String(36),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
comment="노래 생성 작업 고유 식별자 (UUID)",
|
comment="노래 생성 작업 고유 식별자 (UUID)",
|
||||||
)
|
)
|
||||||
|
|
||||||
suno_task_id: Mapped[Optional[str]] = mapped_column(
|
suno_task_id: Mapped[Optional[str]] = mapped_column(
|
||||||
String(64),
|
String(64),
|
||||||
nullable=True,
|
nullable=True,
|
||||||
comment="Suno API 작업 고유 식별자",
|
comment="Suno API 작업 고유 식별자",
|
||||||
)
|
)
|
||||||
|
|
||||||
status: Mapped[str] = mapped_column(
|
status: Mapped[str] = mapped_column(
|
||||||
String(50),
|
String(50),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
comment="처리 상태 (processing, completed, failed)",
|
comment="처리 상태 (processing, completed, failed)",
|
||||||
)
|
)
|
||||||
|
|
||||||
song_prompt: Mapped[str] = mapped_column(
|
song_prompt: Mapped[str] = mapped_column(
|
||||||
Text,
|
Text,
|
||||||
nullable=False,
|
nullable=False,
|
||||||
comment="노래 생성에 사용된 프롬프트",
|
comment="노래 생성에 사용된 프롬프트",
|
||||||
)
|
)
|
||||||
|
|
||||||
song_result_url: Mapped[Optional[str]] = mapped_column(
|
song_result_url: Mapped[Optional[str]] = mapped_column(
|
||||||
String(2048),
|
String(2048),
|
||||||
nullable=True,
|
nullable=True,
|
||||||
comment="노래 결과 URL",
|
comment="노래 결과 URL",
|
||||||
)
|
)
|
||||||
|
|
||||||
duration: Mapped[Optional[float]] = mapped_column(
|
duration: Mapped[Optional[float]] = mapped_column(
|
||||||
nullable=True,
|
nullable=True,
|
||||||
comment="노래 재생 시간 (초)",
|
comment="노래 재생 시간 (초)",
|
||||||
)
|
)
|
||||||
|
|
||||||
language: Mapped[str] = mapped_column(
|
language: Mapped[str] = mapped_column(
|
||||||
String(50),
|
String(50),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
default="Korean",
|
default="Korean",
|
||||||
comment="출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
|
comment="출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
|
||||||
)
|
)
|
||||||
|
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime,
|
DateTime,
|
||||||
nullable=False,
|
nullable=False,
|
||||||
server_default=func.now(),
|
server_default=func.now(),
|
||||||
comment="생성 일시",
|
comment="생성 일시",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
project: Mapped["Project"] = relationship(
|
project: Mapped["Project"] = relationship(
|
||||||
"Project",
|
"Project",
|
||||||
back_populates="songs",
|
back_populates="songs",
|
||||||
)
|
)
|
||||||
|
|
||||||
lyric: Mapped["Lyric"] = relationship(
|
lyric: Mapped["Lyric"] = relationship(
|
||||||
"Lyric",
|
"Lyric",
|
||||||
back_populates="songs",
|
back_populates="songs",
|
||||||
)
|
)
|
||||||
|
|
||||||
videos: Mapped[List["Video"]] = relationship(
|
videos: Mapped[List["Video"]] = relationship(
|
||||||
"Video",
|
"Video",
|
||||||
back_populates="song",
|
back_populates="song",
|
||||||
cascade="all, delete-orphan",
|
cascade="all, delete-orphan",
|
||||||
lazy="selectin",
|
lazy="selectin",
|
||||||
)
|
)
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
def truncate(value: str | None, max_len: int = 10) -> str:
|
def truncate(value: str | None, max_len: int = 10) -> str:
|
||||||
if value is None:
|
if value is None:
|
||||||
return "None"
|
return "None"
|
||||||
return (value[:max_len] + "...") if len(value) > max_len else value
|
return (value[:max_len] + "...") if len(value) > max_len else value
|
||||||
|
|
||||||
return (
|
return (
|
||||||
f"<Song("
|
f"<Song("
|
||||||
f"id={self.id}, "
|
f"id={self.id}, "
|
||||||
f"task_id='{truncate(self.task_id)}', "
|
f"task_id='{truncate(self.task_id)}', "
|
||||||
f"status='{self.status}'"
|
f"status='{self.status}'"
|
||||||
f")>"
|
f")>"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,374 +1,374 @@
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Pydantic Schemas for Song Generation API
|
# Pydantic Schemas for Song Generation API
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
class GenerateSongRequest(BaseModel):
|
class GenerateSongRequest(BaseModel):
|
||||||
"""노래 생성 요청 스키마
|
"""노래 생성 요청 스키마
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
POST /song/generate/{task_id}
|
POST /song/generate/{task_id}
|
||||||
Request body for generating a song via Suno API.
|
Request body for generating a song via Suno API.
|
||||||
|
|
||||||
Example Request:
|
Example Request:
|
||||||
{
|
{
|
||||||
"lyrics": "인스타 감성의 스테이 머뭄...",
|
"lyrics": "인스타 감성의 스테이 머뭄...",
|
||||||
"genre": "k-pop",
|
"genre": "k-pop",
|
||||||
"language": "Korean"
|
"language": "Korean"
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model_config = {
|
model_config = {
|
||||||
"json_schema_extra": {
|
"json_schema_extra": {
|
||||||
"example": {
|
"example": {
|
||||||
"lyrics": "인스타 감성의 스테이 머뭄, 머물러봐요 \n군산 신흥동 말랭이 마을의 마음 힐링 \n사진같은 하루, 여행의 시작 \n보석 같은 이곳은 감성 숙소의 느낌 \n\n인근 명소와 아름다움이 가득한 거리 \n힐링의 바람과 여행의 추억 \n글로벌 감성의 스테이 머뭄, 인스타 감성 \n사진으로 남기고 싶은 그 순간들이 되어줘요",
|
"lyrics": "인스타 감성의 스테이 머뭄, 머물러봐요 \n군산 신흥동 말랭이 마을의 마음 힐링 \n사진같은 하루, 여행의 시작 \n보석 같은 이곳은 감성 숙소의 느낌 \n\n인근 명소와 아름다움이 가득한 거리 \n힐링의 바람과 여행의 추억 \n글로벌 감성의 스테이 머뭄, 인스타 감성 \n사진으로 남기고 싶은 그 순간들이 되어줘요",
|
||||||
"genre": "k-pop",
|
"genre": "k-pop",
|
||||||
"language": "Korean",
|
"language": "Korean",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
lyrics: str = Field(..., description="노래에 사용할 가사")
|
lyrics: str = Field(..., description="노래에 사용할 가사")
|
||||||
genre: str = Field(
|
genre: str = Field(
|
||||||
...,
|
...,
|
||||||
description="음악 장르 (K-Pop, Pop, R&B, Hip-Hop, Ballad, EDM, Rock, Jazz 등)",
|
description="음악 장르 (K-Pop, Pop, R&B, Hip-Hop, Ballad, EDM, Rock, Jazz 등)",
|
||||||
)
|
)
|
||||||
language: str = Field(
|
language: str = Field(
|
||||||
default="Korean",
|
default="Korean",
|
||||||
description="노래 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
|
description="노래 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class GenerateSongResponse(BaseModel):
|
class GenerateSongResponse(BaseModel):
|
||||||
"""노래 생성 응답 스키마
|
"""노래 생성 응답 스키마
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
POST /song/generate/{task_id}
|
POST /song/generate/{task_id}
|
||||||
Returns the task IDs for tracking song generation.
|
Returns the task IDs for tracking song generation.
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
실패 조건:
|
실패 조건:
|
||||||
- task_id에 해당하는 Project가 없는 경우 (404 HTTPException)
|
- task_id에 해당하는 Project가 없는 경우 (404 HTTPException)
|
||||||
- task_id에 해당하는 Lyric이 없는 경우 (404 HTTPException)
|
- task_id에 해당하는 Lyric이 없는 경우 (404 HTTPException)
|
||||||
- Suno API 호출 실패
|
- Suno API 호출 실패
|
||||||
|
|
||||||
Example Response (Success):
|
Example Response (Success):
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
||||||
"suno_task_id": "abc123...",
|
"suno_task_id": "abc123...",
|
||||||
"message": "노래 생성 요청이 접수되었습니다. suno_task_id로 상태를 조회하세요.",
|
"message": "노래 생성 요청이 접수되었습니다. suno_task_id로 상태를 조회하세요.",
|
||||||
"error_message": null
|
"error_message": null
|
||||||
}
|
}
|
||||||
|
|
||||||
Example Response (Failure):
|
Example Response (Failure):
|
||||||
{
|
{
|
||||||
"success": false,
|
"success": false,
|
||||||
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
||||||
"suno_task_id": null,
|
"suno_task_id": null,
|
||||||
"message": "노래 생성 요청에 실패했습니다.",
|
"message": "노래 생성 요청에 실패했습니다.",
|
||||||
"error_message": "Suno API connection error"
|
"error_message": "Suno API connection error"
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
success: bool = Field(..., description="요청 성공 여부")
|
success: bool = Field(..., description="요청 성공 여부")
|
||||||
task_id: Optional[str] = Field(None, description="내부 작업 ID (Project/Lyric task_id)")
|
task_id: Optional[str] = Field(None, description="내부 작업 ID (Project/Lyric task_id)")
|
||||||
suno_task_id: Optional[str] = Field(None, description="Suno API 작업 ID")
|
suno_task_id: Optional[str] = Field(None, description="Suno API 작업 ID")
|
||||||
message: str = Field(..., description="응답 메시지")
|
message: str = Field(..., description="응답 메시지")
|
||||||
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
|
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
|
||||||
|
|
||||||
|
|
||||||
class PollingSongRequest(BaseModel):
|
class PollingSongRequest(BaseModel):
|
||||||
"""노래 생성 상태 조회 요청 스키마 (Legacy)
|
"""노래 생성 상태 조회 요청 스키마 (Legacy)
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
현재 사용되지 않음. GET /song/status/{suno_task_id} 엔드포인트 사용.
|
현재 사용되지 않음. GET /song/status/{suno_task_id} 엔드포인트 사용.
|
||||||
|
|
||||||
Example Request:
|
Example Request:
|
||||||
{
|
{
|
||||||
"task_id": "abc123..."
|
"task_id": "abc123..."
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
task_id: str = Field(..., description="Suno 작업 ID")
|
task_id: str = Field(..., description="Suno 작업 ID")
|
||||||
|
|
||||||
|
|
||||||
class SongClipData(BaseModel):
|
class SongClipData(BaseModel):
|
||||||
"""생성된 노래 클립 정보"""
|
"""생성된 노래 클립 정보"""
|
||||||
|
|
||||||
id: Optional[str] = Field(None, description="클립 ID")
|
id: Optional[str] = Field(None, description="클립 ID")
|
||||||
audio_url: Optional[str] = Field(None, description="오디오 URL")
|
audio_url: Optional[str] = Field(None, description="오디오 URL")
|
||||||
stream_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")
|
image_url: Optional[str] = Field(None, description="이미지 URL")
|
||||||
title: Optional[str] = Field(None, description="곡 제목")
|
title: Optional[str] = Field(None, description="곡 제목")
|
||||||
status: Optional[str] = Field(None, description="클립 상태")
|
status: Optional[str] = Field(None, description="클립 상태")
|
||||||
duration: Optional[float] = Field(None, description="노래 길이 (초)")
|
duration: Optional[float] = Field(None, description="노래 길이 (초)")
|
||||||
|
|
||||||
|
|
||||||
class PollingSongResponse(BaseModel):
|
class PollingSongResponse(BaseModel):
|
||||||
"""노래 생성 상태 조회 응답 스키마
|
"""노래 생성 상태 조회 응답 스키마
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
GET /song/status/{suno_task_id}
|
GET /song/status/{suno_task_id}
|
||||||
Suno API 작업 상태를 조회합니다.
|
Suno API 작업 상태를 조회합니다.
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
상태 값:
|
상태 값:
|
||||||
- PENDING: 대기 중
|
- PENDING: 대기 중
|
||||||
- processing: 생성 중
|
- processing: 생성 중
|
||||||
- SUCCESS / TEXT_SUCCESS / complete: 생성 완료
|
- SUCCESS / TEXT_SUCCESS / complete: 생성 완료
|
||||||
- failed: 생성 실패
|
- failed: 생성 실패
|
||||||
- error: API 조회 오류
|
- error: API 조회 오류
|
||||||
|
|
||||||
SUCCESS 상태 시:
|
SUCCESS 상태 시:
|
||||||
- 백그라운드에서 MP3 파일 다운로드 시작
|
- 백그라운드에서 MP3 파일 다운로드 시작
|
||||||
- Song 테이블의 status를 completed로 업데이트
|
- Song 테이블의 status를 completed로 업데이트
|
||||||
- song_result_url에 로컬 파일 경로 저장
|
- song_result_url에 로컬 파일 경로 저장
|
||||||
|
|
||||||
Example Response (Processing):
|
Example Response (Processing):
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
"status": "processing",
|
"status": "processing",
|
||||||
"message": "노래를 생성하고 있습니다.",
|
"message": "노래를 생성하고 있습니다.",
|
||||||
"clips": null,
|
"clips": null,
|
||||||
"raw_response": {...},
|
"raw_response": {...},
|
||||||
"error_message": null
|
"error_message": null
|
||||||
}
|
}
|
||||||
|
|
||||||
Example Response (Success):
|
Example Response (Success):
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
"status": "SUCCESS",
|
"status": "SUCCESS",
|
||||||
"message": "노래 생성이 완료되었습니다.",
|
"message": "노래 생성이 완료되었습니다.",
|
||||||
"clips": [
|
"clips": [
|
||||||
{
|
{
|
||||||
"id": "clip-id",
|
"id": "clip-id",
|
||||||
"audio_url": "https://...",
|
"audio_url": "https://...",
|
||||||
"stream_audio_url": "https://...",
|
"stream_audio_url": "https://...",
|
||||||
"image_url": "https://...",
|
"image_url": "https://...",
|
||||||
"title": "Song Title",
|
"title": "Song Title",
|
||||||
"status": "complete",
|
"status": "complete",
|
||||||
"duration": 60.0
|
"duration": 60.0
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"raw_response": {...},
|
"raw_response": {...},
|
||||||
"error_message": null
|
"error_message": null
|
||||||
}
|
}
|
||||||
|
|
||||||
Example Response (Failure):
|
Example Response (Failure):
|
||||||
{
|
{
|
||||||
"success": false,
|
"success": false,
|
||||||
"status": "error",
|
"status": "error",
|
||||||
"message": "상태 조회에 실패했습니다.",
|
"message": "상태 조회에 실패했습니다.",
|
||||||
"clips": null,
|
"clips": null,
|
||||||
"raw_response": null,
|
"raw_response": null,
|
||||||
"error_message": "ConnectionError: ..."
|
"error_message": "ConnectionError: ..."
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
success: bool = Field(..., description="조회 성공 여부")
|
success: bool = Field(..., description="조회 성공 여부")
|
||||||
status: Optional[str] = Field(
|
status: Optional[str] = Field(
|
||||||
None, description="작업 상태 (PENDING, processing, SUCCESS, failed)"
|
None, description="작업 상태 (PENDING, processing, SUCCESS, failed)"
|
||||||
)
|
)
|
||||||
message: str = Field(..., description="상태 메시지")
|
message: str = Field(..., description="상태 메시지")
|
||||||
clips: Optional[List[SongClipData]] = Field(None, description="생성된 노래 클립 목록")
|
clips: Optional[List[SongClipData]] = Field(None, description="생성된 노래 클립 목록")
|
||||||
raw_response: Optional[Dict[str, Any]] = Field(None, description="Suno API 원본 응답")
|
raw_response: Optional[Dict[str, Any]] = Field(None, description="Suno API 원본 응답")
|
||||||
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
|
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
|
||||||
|
|
||||||
|
|
||||||
class SongListItem(BaseModel):
|
class SongListItem(BaseModel):
|
||||||
"""노래 목록 아이템 스키마
|
"""노래 목록 아이템 스키마
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
GET /songs 응답의 개별 노래 정보
|
GET /songs 응답의 개별 노래 정보
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
{
|
{
|
||||||
"store_name": "스테이 머뭄",
|
"store_name": "스테이 머뭄",
|
||||||
"region": "군산",
|
"region": "군산",
|
||||||
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
||||||
"language": "Korean",
|
"language": "Korean",
|
||||||
"song_result_url": "http://localhost:8000/media/2025-01-15/스테이머뭄.mp3",
|
"song_result_url": "http://localhost:8000/media/2025-01-15/스테이머뭄.mp3",
|
||||||
"created_at": "2025-01-15T12:00:00"
|
"created_at": "2025-01-15T12:00:00"
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
store_name: Optional[str] = Field(None, description="업체명")
|
store_name: Optional[str] = Field(None, description="업체명")
|
||||||
region: Optional[str] = Field(None, description="지역명")
|
region: Optional[str] = Field(None, description="지역명")
|
||||||
task_id: str = Field(..., description="작업 고유 식별자")
|
task_id: str = Field(..., description="작업 고유 식별자")
|
||||||
language: Optional[str] = Field(None, description="언어")
|
language: Optional[str] = Field(None, description="언어")
|
||||||
song_result_url: Optional[str] = Field(None, description="노래 결과 URL")
|
song_result_url: Optional[str] = Field(None, description="노래 결과 URL")
|
||||||
created_at: Optional[datetime] = Field(None, description="생성 일시")
|
created_at: Optional[datetime] = Field(None, description="생성 일시")
|
||||||
|
|
||||||
|
|
||||||
class DownloadSongResponse(BaseModel):
|
class DownloadSongResponse(BaseModel):
|
||||||
"""노래 다운로드 응답 스키마
|
"""노래 다운로드 응답 스키마
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
GET /song/download/{task_id}
|
GET /song/download/{task_id}
|
||||||
Polls for song completion and returns project info with song URL.
|
Polls for song completion and returns project info with song URL.
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
상태 값:
|
상태 값:
|
||||||
- processing: 노래 생성 진행 중 (song_result_url은 null)
|
- processing: 노래 생성 진행 중 (song_result_url은 null)
|
||||||
- completed: 노래 생성 완료 (song_result_url 포함)
|
- completed: 노래 생성 완료 (song_result_url 포함)
|
||||||
- failed: 노래 생성 실패
|
- failed: 노래 생성 실패
|
||||||
- not_found: task_id에 해당하는 Song 없음
|
- not_found: task_id에 해당하는 Song 없음
|
||||||
- error: 조회 중 오류 발생
|
- error: 조회 중 오류 발생
|
||||||
|
|
||||||
Example Response (Processing):
|
Example Response (Processing):
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
"status": "processing",
|
"status": "processing",
|
||||||
"message": "노래 생성이 진행 중입니다.",
|
"message": "노래 생성이 진행 중입니다.",
|
||||||
"store_name": null,
|
"store_name": null,
|
||||||
"region": null,
|
"region": null,
|
||||||
"detail_region_info": null,
|
"detail_region_info": null,
|
||||||
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
||||||
"language": null,
|
"language": null,
|
||||||
"song_result_url": null,
|
"song_result_url": null,
|
||||||
"created_at": null,
|
"created_at": null,
|
||||||
"error_message": null
|
"error_message": null
|
||||||
}
|
}
|
||||||
|
|
||||||
Example Response (Completed):
|
Example Response (Completed):
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"message": "노래 다운로드가 완료되었습니다.",
|
"message": "노래 다운로드가 완료되었습니다.",
|
||||||
"store_name": "스테이 머뭄",
|
"store_name": "스테이 머뭄",
|
||||||
"region": "군산",
|
"region": "군산",
|
||||||
"detail_region_info": "군산 신흥동 말랭이 마을",
|
"detail_region_info": "군산 신흥동 말랭이 마을",
|
||||||
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
||||||
"language": "Korean",
|
"language": "Korean",
|
||||||
"song_result_url": "http://localhost:8000/media/2025-01-15/스테이머뭄.mp3",
|
"song_result_url": "http://localhost:8000/media/2025-01-15/스테이머뭄.mp3",
|
||||||
"created_at": "2025-01-15T12:00:00",
|
"created_at": "2025-01-15T12:00:00",
|
||||||
"error_message": null
|
"error_message": null
|
||||||
}
|
}
|
||||||
|
|
||||||
Example Response (Not Found):
|
Example Response (Not Found):
|
||||||
{
|
{
|
||||||
"success": false,
|
"success": false,
|
||||||
"status": "not_found",
|
"status": "not_found",
|
||||||
"message": "task_id 'xxx'에 해당하는 Song을 찾을 수 없습니다.",
|
"message": "task_id 'xxx'에 해당하는 Song을 찾을 수 없습니다.",
|
||||||
"store_name": null,
|
"store_name": null,
|
||||||
"region": null,
|
"region": null,
|
||||||
"detail_region_info": null,
|
"detail_region_info": null,
|
||||||
"task_id": null,
|
"task_id": null,
|
||||||
"language": null,
|
"language": null,
|
||||||
"song_result_url": null,
|
"song_result_url": null,
|
||||||
"created_at": null,
|
"created_at": null,
|
||||||
"error_message": "Song not found"
|
"error_message": "Song not found"
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
success: bool = Field(..., description="다운로드 성공 여부")
|
success: bool = Field(..., description="다운로드 성공 여부")
|
||||||
status: str = Field(..., description="처리 상태 (processing, completed, failed, not_found, error)")
|
status: str = Field(..., description="처리 상태 (processing, completed, failed, not_found, error)")
|
||||||
message: str = Field(..., description="응답 메시지")
|
message: str = Field(..., description="응답 메시지")
|
||||||
store_name: Optional[str] = Field(None, description="업체명")
|
store_name: Optional[str] = Field(None, description="업체명")
|
||||||
region: Optional[str] = Field(None, description="지역명")
|
region: Optional[str] = Field(None, description="지역명")
|
||||||
detail_region_info: Optional[str] = Field(None, description="상세 지역 정보")
|
detail_region_info: Optional[str] = Field(None, description="상세 지역 정보")
|
||||||
task_id: Optional[str] = Field(None, description="작업 고유 식별자")
|
task_id: Optional[str] = Field(None, description="작업 고유 식별자")
|
||||||
language: Optional[str] = Field(None, description="언어")
|
language: Optional[str] = Field(None, description="언어")
|
||||||
song_result_url: Optional[str] = Field(None, description="노래 결과 URL")
|
song_result_url: Optional[str] = Field(None, description="노래 결과 URL")
|
||||||
created_at: Optional[datetime] = Field(None, description="생성 일시")
|
created_at: Optional[datetime] = Field(None, description="생성 일시")
|
||||||
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
|
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Dataclass Schemas (Legacy)
|
# Dataclass Schemas (Legacy)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class StoreData:
|
class StoreData:
|
||||||
id: int
|
id: int
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
store_name: str
|
store_name: str
|
||||||
store_category: str | None = None
|
store_category: str | None = None
|
||||||
store_region: str | None = None
|
store_region: str | None = None
|
||||||
store_address: str | None = None
|
store_address: str | None = None
|
||||||
store_phone_number: str | None = None
|
store_phone_number: str | None = None
|
||||||
store_info: str | None = None
|
store_info: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class AttributeData:
|
class AttributeData:
|
||||||
id: int
|
id: int
|
||||||
attr_category: str
|
attr_category: str
|
||||||
attr_value: str
|
attr_value: str
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class SongSampleData:
|
class SongSampleData:
|
||||||
id: int
|
id: int
|
||||||
ai: str
|
ai: str
|
||||||
ai_model: str
|
ai_model: str
|
||||||
sample_song: str
|
sample_song: str
|
||||||
season: str | None = None
|
season: str | None = None
|
||||||
num_of_people: int | None = None
|
num_of_people: int | None = None
|
||||||
people_category: str | None = None
|
people_category: str | None = None
|
||||||
genre: str | None = None
|
genre: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class PromptTemplateData:
|
class PromptTemplateData:
|
||||||
id: int
|
id: int
|
||||||
prompt: str
|
prompt: str
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class SongFormData:
|
class SongFormData:
|
||||||
store_name: str
|
store_name: str
|
||||||
store_id: str
|
store_id: str
|
||||||
prompts: str
|
prompts: str
|
||||||
attributes: Dict[str, str] = field(default_factory=dict)
|
attributes: Dict[str, str] = field(default_factory=dict)
|
||||||
attributes_str: str = ""
|
attributes_str: str = ""
|
||||||
lyrics_ids: List[int] = field(default_factory=list)
|
lyrics_ids: List[int] = field(default_factory=list)
|
||||||
llm_model: str = "gpt-5-mini"
|
llm_model: str = "gpt-4o"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def from_form(cls, request: Request):
|
async def from_form(cls, request: Request):
|
||||||
"""Request의 form 데이터로부터 dataclass 인스턴스 생성"""
|
"""Request의 form 데이터로부터 dataclass 인스턴스 생성"""
|
||||||
form_data = await request.form()
|
form_data = await request.form()
|
||||||
|
|
||||||
# 고정 필드명들
|
# 고정 필드명들
|
||||||
fixed_keys = {"store_info_name", "store_id", "llm_model", "prompts"}
|
fixed_keys = {"store_info_name", "store_id", "llm_model", "prompts"}
|
||||||
|
|
||||||
# lyrics-{id} 형태의 모든 키를 찾아서 ID 추출
|
# lyrics-{id} 형태의 모든 키를 찾아서 ID 추출
|
||||||
lyrics_ids = []
|
lyrics_ids = []
|
||||||
attributes = {}
|
attributes = {}
|
||||||
|
|
||||||
for key, value in form_data.items():
|
for key, value in form_data.items():
|
||||||
if key.startswith("lyrics-"):
|
if key.startswith("lyrics-"):
|
||||||
lyrics_id = key.split("-")[1]
|
lyrics_id = key.split("-")[1]
|
||||||
lyrics_ids.append(int(lyrics_id))
|
lyrics_ids.append(int(lyrics_id))
|
||||||
elif key not in fixed_keys:
|
elif key not in fixed_keys:
|
||||||
attributes[key] = value
|
attributes[key] = value
|
||||||
|
|
||||||
# attributes를 문자열로 변환
|
# attributes를 문자열로 변환
|
||||||
attributes_str = (
|
attributes_str = (
|
||||||
"\r\n\r\n".join([f"{key} : {value}" for key, value in attributes.items()])
|
"\r\n\r\n".join([f"{key} : {value}" for key, value in attributes.items()])
|
||||||
if attributes
|
if attributes
|
||||||
else ""
|
else ""
|
||||||
)
|
)
|
||||||
|
|
||||||
return cls(
|
return cls(
|
||||||
store_name=form_data.get("store_info_name", ""),
|
store_name=form_data.get("store_info_name", ""),
|
||||||
store_id=form_data.get("store_id", ""),
|
store_id=form_data.get("store_id", ""),
|
||||||
attributes=attributes,
|
attributes=attributes,
|
||||||
attributes_str=attributes_str,
|
attributes_str=attributes_str,
|
||||||
lyrics_ids=lyrics_ids,
|
lyrics_ids=lyrics_ids,
|
||||||
llm_model=form_data.get("llm_model", "gpt-5-mini"),
|
llm_model=form_data.get("llm_model", "gpt-4o"),
|
||||||
prompts=form_data.get("prompts", ""),
|
prompts=form_data.get("prompts", ""),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,24 @@
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlmodel import SQLModel
|
from sqlmodel import SQLModel
|
||||||
|
|
||||||
|
|
||||||
class BaseService:
|
class BaseService:
|
||||||
def __init__(self, model, session: AsyncSession):
|
def __init__(self, model, session: AsyncSession):
|
||||||
self.model = model
|
self.model = model
|
||||||
self.session = session
|
self.session = session
|
||||||
|
|
||||||
async def _get(self, id: UUID):
|
async def _get(self, id: UUID):
|
||||||
return await self.session.get(self.model, id)
|
return await self.session.get(self.model, id)
|
||||||
|
|
||||||
async def _add(self, entity):
|
async def _add(self, entity):
|
||||||
self.session.add(entity)
|
self.session.add(entity)
|
||||||
await self.session.commit()
|
await self.session.commit()
|
||||||
await self.session.refresh(entity)
|
await self.session.refresh(entity)
|
||||||
return entity
|
return entity
|
||||||
|
|
||||||
async def _update(self, entity):
|
async def _update(self, entity):
|
||||||
return await self._add(entity)
|
return await self._add(entity)
|
||||||
|
|
||||||
async def _delete(self, entity):
|
async def _delete(self, entity):
|
||||||
await self.session.delete(entity)
|
await self.session.delete(entity)
|
||||||
|
|
@ -1,419 +1,333 @@
|
||||||
"""
|
"""
|
||||||
Song Background Tasks
|
Song Background Tasks
|
||||||
|
|
||||||
노래 생성 관련 백그라운드 태스크를 정의합니다.
|
노래 생성 관련 백그라운드 태스크를 정의합니다.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
from datetime import date
|
||||||
import traceback
|
from pathlib import Path
|
||||||
from datetime import date
|
|
||||||
from pathlib import Path
|
import aiofiles
|
||||||
|
import httpx
|
||||||
import aiofiles
|
from sqlalchemy import select
|
||||||
import httpx
|
|
||||||
from sqlalchemy import select
|
from app.database.session import BackgroundSessionLocal
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
from app.song.models import Song
|
||||||
|
from app.utils.common import generate_task_id
|
||||||
from app.database.session import BackgroundSessionLocal
|
from app.utils.upload_blob_as_request import AzureBlobUploader
|
||||||
from app.song.models import Song
|
from config import prj_settings
|
||||||
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,
|
||||||
logger = logging.getLogger(__name__)
|
store_name: str,
|
||||||
|
) -> None:
|
||||||
# HTTP 요청 설정
|
"""백그라운드에서 노래를 다운로드하고 Song 테이블을 업데이트합니다.
|
||||||
REQUEST_TIMEOUT = 120.0 # 초
|
|
||||||
|
Args:
|
||||||
|
task_id: 프로젝트 task_id
|
||||||
async def _update_song_status(
|
audio_url: 다운로드할 오디오 URL
|
||||||
task_id: str,
|
store_name: 저장할 파일명에 사용할 업체명
|
||||||
status: str,
|
"""
|
||||||
song_url: str | None = None,
|
print(f"[download_and_save_song] START - task_id: {task_id}, store_name: {store_name}")
|
||||||
suno_task_id: str | None = None,
|
try:
|
||||||
duration: float | None = None,
|
# 저장 경로 생성: media/song/{날짜}/{uuid7}/{store_name}.mp3
|
||||||
) -> bool:
|
today = date.today().strftime("%Y-%m-%d")
|
||||||
"""Song 테이블의 상태를 업데이트합니다.
|
unique_id = await generate_task_id()
|
||||||
|
# 파일명에 사용할 수 없는 문자 제거
|
||||||
Args:
|
safe_store_name = "".join(
|
||||||
task_id: 프로젝트 task_id
|
c for c in store_name if c.isalnum() or c in (" ", "_", "-")
|
||||||
status: 변경할 상태 ("processing", "completed", "failed")
|
).strip()
|
||||||
song_url: 노래 URL
|
safe_store_name = safe_store_name or "song"
|
||||||
suno_task_id: Suno task ID (선택)
|
file_name = f"{safe_store_name}.mp3"
|
||||||
duration: 노래 길이 (선택)
|
|
||||||
|
# 절대 경로 생성
|
||||||
Returns:
|
media_dir = Path("media") / "song" / today / unique_id
|
||||||
bool: 업데이트 성공 여부
|
media_dir.mkdir(parents=True, exist_ok=True)
|
||||||
"""
|
file_path = media_dir / file_name
|
||||||
try:
|
print(f"[download_and_save_song] Directory created - path: {file_path}")
|
||||||
async with BackgroundSessionLocal() as session:
|
|
||||||
if suno_task_id:
|
# 오디오 파일 다운로드
|
||||||
query_result = await session.execute(
|
print(f"[download_and_save_song] Downloading audio - task_id: {task_id}, url: {audio_url}")
|
||||||
select(Song)
|
async with httpx.AsyncClient() as client:
|
||||||
.where(Song.suno_task_id == suno_task_id)
|
response = await client.get(audio_url, timeout=60.0)
|
||||||
.order_by(Song.created_at.desc())
|
response.raise_for_status()
|
||||||
.limit(1)
|
|
||||||
)
|
async with aiofiles.open(str(file_path), "wb") as f:
|
||||||
else:
|
await f.write(response.content)
|
||||||
query_result = await session.execute(
|
print(f"[download_and_save_song] File saved - task_id: {task_id}, path: {file_path}")
|
||||||
select(Song)
|
|
||||||
.where(Song.task_id == task_id)
|
# 프론트엔드에서 접근 가능한 URL 생성
|
||||||
.order_by(Song.created_at.desc())
|
relative_path = f"/media/song/{today}/{unique_id}/{file_name}"
|
||||||
.limit(1)
|
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 = query_result.scalar_one_or_none()
|
|
||||||
|
# Song 테이블 업데이트 (새 세션 사용)
|
||||||
if song:
|
async with BackgroundSessionLocal() as session:
|
||||||
song.status = status
|
# 여러 개 있을 경우 가장 최근 것 선택
|
||||||
if song_url is not None:
|
result = await session.execute(
|
||||||
song.song_result_url = song_url
|
select(Song)
|
||||||
if duration is not None:
|
.where(Song.task_id == task_id)
|
||||||
song.duration = duration
|
.order_by(Song.created_at.desc())
|
||||||
await session.commit()
|
.limit(1)
|
||||||
logger.info(f"[Song] Status updated - task_id: {task_id}, status: {status}")
|
)
|
||||||
print(f"[Song] Status updated - task_id: {task_id}, status: {status}")
|
song = result.scalar_one_or_none()
|
||||||
return True
|
|
||||||
else:
|
if song:
|
||||||
logger.warning(f"[Song] NOT FOUND in DB - task_id: {task_id}")
|
song.status = "completed"
|
||||||
print(f"[Song] NOT FOUND in DB - task_id: {task_id}")
|
song.song_result_url = file_url
|
||||||
return False
|
await session.commit()
|
||||||
|
print(f"[download_and_save_song] SUCCESS - task_id: {task_id}, status: completed")
|
||||||
except SQLAlchemyError as e:
|
else:
|
||||||
logger.error(f"[Song] DB Error while updating status - task_id: {task_id}, error: {e}")
|
print(f"[download_and_save_song] Song NOT FOUND in DB - task_id: {task_id}")
|
||||||
print(f"[Song] DB Error while updating status - task_id: {task_id}, error: {e}")
|
|
||||||
return False
|
except Exception as e:
|
||||||
except Exception as e:
|
print(f"[download_and_save_song] EXCEPTION - task_id: {task_id}, error: {e}")
|
||||||
logger.error(f"[Song] Unexpected error while updating status - task_id: {task_id}, error: {e}")
|
# 실패 시 Song 테이블 업데이트
|
||||||
print(f"[Song] Unexpected error while updating status - task_id: {task_id}, error: {e}")
|
async with BackgroundSessionLocal() as session:
|
||||||
return False
|
# 여러 개 있을 경우 가장 최근 것 선택
|
||||||
|
result = await session.execute(
|
||||||
|
select(Song)
|
||||||
async def _download_audio(url: str, task_id: str) -> bytes:
|
.where(Song.task_id == task_id)
|
||||||
"""URL에서 오디오 파일을 다운로드합니다.
|
.order_by(Song.created_at.desc())
|
||||||
|
.limit(1)
|
||||||
Args:
|
)
|
||||||
url: 다운로드할 URL
|
song = result.scalar_one_or_none()
|
||||||
task_id: 로그용 task_id
|
|
||||||
|
if song:
|
||||||
Returns:
|
song.status = "failed"
|
||||||
bytes: 다운로드한 파일 내용
|
await session.commit()
|
||||||
|
print(f"[download_and_save_song] FAILED - task_id: {task_id}, status updated to failed")
|
||||||
Raises:
|
|
||||||
httpx.HTTPError: 다운로드 실패 시
|
|
||||||
"""
|
async def download_and_upload_song_to_blob(
|
||||||
logger.info(f"[Download] Downloading - task_id: {task_id}")
|
task_id: str,
|
||||||
print(f"[Download] Downloading - task_id: {task_id}")
|
audio_url: str,
|
||||||
|
store_name: str,
|
||||||
async with httpx.AsyncClient() as client:
|
) -> None:
|
||||||
response = await client.get(url, timeout=REQUEST_TIMEOUT)
|
"""백그라운드에서 노래를 다운로드하고 Azure Blob Storage에 업로드한 뒤 Song 테이블을 업데이트합니다.
|
||||||
response.raise_for_status()
|
|
||||||
|
Args:
|
||||||
logger.info(f"[Download] SUCCESS - task_id: {task_id}, size: {len(response.content)} bytes")
|
task_id: 프로젝트 task_id
|
||||||
print(f"[Download] SUCCESS - task_id: {task_id}, size: {len(response.content)} bytes")
|
audio_url: 다운로드할 오디오 URL
|
||||||
return response.content
|
store_name: 저장할 파일명에 사용할 업체명
|
||||||
|
"""
|
||||||
|
print(f"[download_and_upload_song_to_blob] START - task_id: {task_id}, store_name: {store_name}")
|
||||||
async def download_and_save_song(
|
temp_file_path: Path | None = None
|
||||||
task_id: str,
|
|
||||||
audio_url: str,
|
try:
|
||||||
store_name: str,
|
# 파일명에 사용할 수 없는 문자 제거
|
||||||
) -> None:
|
safe_store_name = "".join(
|
||||||
"""백그라운드에서 노래를 다운로드하고 Song 테이블을 업데이트합니다.
|
c for c in store_name if c.isalnum() or c in (" ", "_", "-")
|
||||||
|
).strip()
|
||||||
Args:
|
safe_store_name = safe_store_name or "song"
|
||||||
task_id: 프로젝트 task_id
|
file_name = f"{safe_store_name}.mp3"
|
||||||
audio_url: 다운로드할 오디오 URL
|
|
||||||
store_name: 저장할 파일명에 사용할 업체명
|
# 임시 저장 경로 생성
|
||||||
"""
|
temp_dir = Path("media") / "temp" / task_id
|
||||||
logger.info(f"[download_and_save_song] START - task_id: {task_id}, store_name: {store_name}")
|
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||||
print(f"[download_and_save_song] START - task_id: {task_id}, store_name: {store_name}")
|
temp_file_path = temp_dir / file_name
|
||||||
|
print(f"[download_and_upload_song_to_blob] Temp directory created - path: {temp_file_path}")
|
||||||
try:
|
|
||||||
# 저장 경로 생성: media/song/{날짜}/{uuid7}/{store_name}.mp3
|
# 오디오 파일 다운로드
|
||||||
today = date.today().strftime("%Y-%m-%d")
|
print(f"[download_and_upload_song_to_blob] Downloading audio - task_id: {task_id}, url: {audio_url}")
|
||||||
unique_id = await generate_task_id()
|
async with httpx.AsyncClient() as client:
|
||||||
# 파일명에 사용할 수 없는 문자 제거
|
response = await client.get(audio_url, timeout=60.0)
|
||||||
safe_store_name = "".join(
|
response.raise_for_status()
|
||||||
c for c in store_name if c.isalnum() or c in (" ", "_", "-")
|
|
||||||
).strip()
|
async with aiofiles.open(str(temp_file_path), "wb") as f:
|
||||||
safe_store_name = safe_store_name or "song"
|
await f.write(response.content)
|
||||||
file_name = f"{safe_store_name}.mp3"
|
print(f"[download_and_upload_song_to_blob] File downloaded - task_id: {task_id}, path: {temp_file_path}")
|
||||||
|
|
||||||
# 절대 경로 생성
|
# Azure Blob Storage에 업로드
|
||||||
media_dir = Path("media") / "song" / today / unique_id
|
uploader = AzureBlobUploader(task_id=task_id)
|
||||||
media_dir.mkdir(parents=True, exist_ok=True)
|
upload_success = await uploader.upload_music(file_path=str(temp_file_path))
|
||||||
file_path = media_dir / file_name
|
|
||||||
logger.info(f"[download_and_save_song] Directory created - path: {file_path}")
|
if not upload_success:
|
||||||
print(f"[download_and_save_song] Directory created - path: {file_path}")
|
raise Exception("Azure Blob Storage 업로드 실패")
|
||||||
|
|
||||||
# 오디오 파일 다운로드
|
# SAS 토큰이 제외된 public_url 사용
|
||||||
logger.info(f"[download_and_save_song] Downloading audio - task_id: {task_id}, url: {audio_url}")
|
blob_url = uploader.public_url
|
||||||
print(f"[download_and_save_song] Downloading audio - task_id: {task_id}, url: {audio_url}")
|
print(f"[download_and_upload_song_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}")
|
||||||
|
|
||||||
content = await _download_audio(audio_url, task_id)
|
# Song 테이블 업데이트 (새 세션 사용)
|
||||||
|
async with BackgroundSessionLocal() as session:
|
||||||
async with aiofiles.open(str(file_path), "wb") as f:
|
# 여러 개 있을 경우 가장 최근 것 선택
|
||||||
await f.write(content)
|
result = await session.execute(
|
||||||
|
select(Song)
|
||||||
logger.info(f"[download_and_save_song] File saved - task_id: {task_id}, path: {file_path}")
|
.where(Song.task_id == task_id)
|
||||||
print(f"[download_and_save_song] File saved - task_id: {task_id}, path: {file_path}")
|
.order_by(Song.created_at.desc())
|
||||||
|
.limit(1)
|
||||||
# 프론트엔드에서 접근 가능한 URL 생성
|
)
|
||||||
relative_path = f"/media/song/{today}/{unique_id}/{file_name}"
|
song = result.scalar_one_or_none()
|
||||||
base_url = f"http://{prj_settings.PROJECT_DOMAIN}"
|
|
||||||
file_url = f"{base_url}{relative_path}"
|
if song:
|
||||||
logger.info(f"[download_and_save_song] URL generated - task_id: {task_id}, url: {file_url}")
|
song.status = "completed"
|
||||||
print(f"[download_and_save_song] URL generated - task_id: {task_id}, url: {file_url}")
|
song.song_result_url = blob_url
|
||||||
|
await session.commit()
|
||||||
# Song 테이블 업데이트
|
print(f"[download_and_upload_song_to_blob] SUCCESS - task_id: {task_id}, status: completed")
|
||||||
await _update_song_status(task_id, "completed", file_url)
|
else:
|
||||||
logger.info(f"[download_and_save_song] SUCCESS - task_id: {task_id}")
|
print(f"[download_and_upload_song_to_blob] Song NOT FOUND in DB - task_id: {task_id}")
|
||||||
print(f"[download_and_save_song] SUCCESS - task_id: {task_id}")
|
|
||||||
|
except Exception as e:
|
||||||
except httpx.HTTPError as e:
|
print(f"[download_and_upload_song_to_blob] EXCEPTION - task_id: {task_id}, error: {e}")
|
||||||
logger.error(f"[download_and_save_song] DOWNLOAD ERROR - task_id: {task_id}, error: {e}")
|
# 실패 시 Song 테이블 업데이트
|
||||||
print(f"[download_and_save_song] DOWNLOAD ERROR - task_id: {task_id}, error: {e}")
|
async with BackgroundSessionLocal() as session:
|
||||||
traceback.print_exc()
|
result = await session.execute(
|
||||||
await _update_song_status(task_id, "failed")
|
select(Song)
|
||||||
|
.where(Song.task_id == task_id)
|
||||||
except SQLAlchemyError as e:
|
.order_by(Song.created_at.desc())
|
||||||
logger.error(f"[download_and_save_song] DB ERROR - task_id: {task_id}, error: {e}")
|
.limit(1)
|
||||||
print(f"[download_and_save_song] DB ERROR - task_id: {task_id}, error: {e}")
|
)
|
||||||
traceback.print_exc()
|
song = result.scalar_one_or_none()
|
||||||
await _update_song_status(task_id, "failed")
|
|
||||||
|
if song:
|
||||||
except Exception as e:
|
song.status = "failed"
|
||||||
logger.error(f"[download_and_save_song] EXCEPTION - task_id: {task_id}, error: {e}")
|
await session.commit()
|
||||||
print(f"[download_and_save_song] EXCEPTION - task_id: {task_id}, error: {e}")
|
print(f"[download_and_upload_song_to_blob] FAILED - task_id: {task_id}, status updated to failed")
|
||||||
traceback.print_exc()
|
|
||||||
await _update_song_status(task_id, "failed")
|
finally:
|
||||||
|
# 임시 파일 삭제
|
||||||
|
if temp_file_path and temp_file_path.exists():
|
||||||
async def download_and_upload_song_to_blob(
|
try:
|
||||||
task_id: str,
|
temp_file_path.unlink()
|
||||||
audio_url: str,
|
print(f"[download_and_upload_song_to_blob] Temp file deleted - path: {temp_file_path}")
|
||||||
store_name: str,
|
except Exception as e:
|
||||||
) -> None:
|
print(f"[download_and_upload_song_to_blob] Failed to delete temp file: {e}")
|
||||||
"""백그라운드에서 노래를 다운로드하고 Azure Blob Storage에 업로드한 뒤 Song 테이블을 업데이트합니다.
|
|
||||||
|
# 임시 디렉토리 삭제 시도
|
||||||
Args:
|
temp_dir = Path("media") / "temp" / task_id
|
||||||
task_id: 프로젝트 task_id
|
if temp_dir.exists():
|
||||||
audio_url: 다운로드할 오디오 URL
|
try:
|
||||||
store_name: 저장할 파일명에 사용할 업체명
|
temp_dir.rmdir()
|
||||||
"""
|
except Exception:
|
||||||
logger.info(f"[download_and_upload_song_to_blob] START - task_id: {task_id}, store_name: {store_name}")
|
pass # 디렉토리가 비어있지 않으면 무시
|
||||||
print(f"[download_and_upload_song_to_blob] START - task_id: {task_id}, store_name: {store_name}")
|
|
||||||
temp_file_path: Path | None = None
|
|
||||||
|
async def download_and_upload_song_by_suno_task_id(
|
||||||
try:
|
suno_task_id: str,
|
||||||
# 파일명에 사용할 수 없는 문자 제거
|
audio_url: str,
|
||||||
safe_store_name = "".join(
|
store_name: str,
|
||||||
c for c in store_name if c.isalnum() or c in (" ", "_", "-")
|
duration: float | None = None,
|
||||||
).strip()
|
) -> None:
|
||||||
safe_store_name = safe_store_name or "song"
|
"""suno_task_id로 Song을 조회하여 노래를 다운로드하고 Azure Blob Storage에 업로드한 뒤 Song 테이블을 업데이트합니다.
|
||||||
file_name = f"{safe_store_name}.mp3"
|
|
||||||
|
Args:
|
||||||
# 임시 저장 경로 생성
|
suno_task_id: Suno API 작업 ID
|
||||||
temp_dir = Path("media") / "temp" / task_id
|
audio_url: 다운로드할 오디오 URL
|
||||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
store_name: 저장할 파일명에 사용할 업체명
|
||||||
temp_file_path = temp_dir / file_name
|
duration: 노래 재생 시간 (초)
|
||||||
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}")
|
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
|
||||||
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}")
|
try:
|
||||||
|
# suno_task_id로 Song 조회하여 task_id 가져오기
|
||||||
content = await _download_audio(audio_url, task_id)
|
async with BackgroundSessionLocal() as session:
|
||||||
|
result = await session.execute(
|
||||||
async with aiofiles.open(str(temp_file_path), "wb") as f:
|
select(Song)
|
||||||
await f.write(content)
|
.where(Song.suno_task_id == suno_task_id)
|
||||||
|
.order_by(Song.created_at.desc())
|
||||||
logger.info(f"[download_and_upload_song_to_blob] File downloaded - task_id: {task_id}, path: {temp_file_path}")
|
.limit(1)
|
||||||
print(f"[download_and_upload_song_to_blob] File downloaded - task_id: {task_id}, path: {temp_file_path}")
|
)
|
||||||
|
song = result.scalar_one_or_none()
|
||||||
# Azure Blob Storage에 업로드
|
|
||||||
uploader = AzureBlobUploader(task_id=task_id)
|
if not song:
|
||||||
upload_success = await uploader.upload_music(file_path=str(temp_file_path))
|
print(f"[download_and_upload_song_by_suno_task_id] Song NOT FOUND - suno_task_id: {suno_task_id}")
|
||||||
|
return
|
||||||
if not upload_success:
|
|
||||||
raise Exception("Azure Blob Storage 업로드 실패")
|
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}")
|
||||||
# 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}")
|
safe_store_name = "".join(
|
||||||
print(f"[download_and_upload_song_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}")
|
c for c in store_name if c.isalnum() or c in (" ", "_", "-")
|
||||||
|
).strip()
|
||||||
# Song 테이블 업데이트
|
safe_store_name = safe_store_name or "song"
|
||||||
await _update_song_status(task_id, "completed", blob_url)
|
file_name = f"{safe_store_name}.mp3"
|
||||||
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}")
|
# 임시 저장 경로 생성
|
||||||
|
temp_dir = Path("media") / "temp" / task_id
|
||||||
except httpx.HTTPError as e:
|
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||||
logger.error(f"[download_and_upload_song_to_blob] DOWNLOAD ERROR - task_id: {task_id}, error: {e}")
|
temp_file_path = temp_dir / file_name
|
||||||
print(f"[download_and_upload_song_to_blob] DOWNLOAD ERROR - task_id: {task_id}, error: {e}")
|
print(f"[download_and_upload_song_by_suno_task_id] Temp directory created - path: {temp_file_path}")
|
||||||
traceback.print_exc()
|
|
||||||
await _update_song_status(task_id, "failed")
|
# 오디오 파일 다운로드
|
||||||
|
print(f"[download_and_upload_song_by_suno_task_id] Downloading audio - suno_task_id: {suno_task_id}, url: {audio_url}")
|
||||||
except SQLAlchemyError as e:
|
async with httpx.AsyncClient() as client:
|
||||||
logger.error(f"[download_and_upload_song_to_blob] DB ERROR - task_id: {task_id}, error: {e}")
|
response = await client.get(audio_url, timeout=60.0)
|
||||||
print(f"[download_and_upload_song_to_blob] DB ERROR - task_id: {task_id}, error: {e}")
|
response.raise_for_status()
|
||||||
traceback.print_exc()
|
|
||||||
await _update_song_status(task_id, "failed")
|
async with aiofiles.open(str(temp_file_path), "wb") as f:
|
||||||
|
await f.write(response.content)
|
||||||
except Exception as e:
|
print(f"[download_and_upload_song_by_suno_task_id] File downloaded - suno_task_id: {suno_task_id}, path: {temp_file_path}")
|
||||||
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}")
|
# Azure Blob Storage에 업로드
|
||||||
traceback.print_exc()
|
uploader = AzureBlobUploader(task_id=task_id)
|
||||||
await _update_song_status(task_id, "failed")
|
upload_success = await uploader.upload_music(file_path=str(temp_file_path))
|
||||||
|
|
||||||
finally:
|
if not upload_success:
|
||||||
# 임시 파일 삭제
|
raise Exception("Azure Blob Storage 업로드 실패")
|
||||||
if temp_file_path and temp_file_path.exists():
|
|
||||||
try:
|
# SAS 토큰이 제외된 public_url 사용
|
||||||
temp_file_path.unlink()
|
blob_url = uploader.public_url
|
||||||
logger.info(f"[download_and_upload_song_to_blob] Temp file deleted - path: {temp_file_path}")
|
print(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_to_blob] Temp file deleted - path: {temp_file_path}")
|
|
||||||
except Exception as e:
|
# Song 테이블 업데이트 (새 세션 사용)
|
||||||
logger.warning(f"[download_and_upload_song_to_blob] Failed to delete temp file: {e}")
|
async with BackgroundSessionLocal() as session:
|
||||||
print(f"[download_and_upload_song_to_blob] Failed to delete temp file: {e}")
|
result = await session.execute(
|
||||||
|
select(Song)
|
||||||
# 임시 디렉토리 삭제 시도
|
.where(Song.suno_task_id == suno_task_id)
|
||||||
temp_dir = Path("media") / "temp" / task_id
|
.order_by(Song.created_at.desc())
|
||||||
if temp_dir.exists():
|
.limit(1)
|
||||||
try:
|
)
|
||||||
temp_dir.rmdir()
|
song = result.scalar_one_or_none()
|
||||||
except Exception:
|
|
||||||
pass # 디렉토리가 비어있지 않으면 무시
|
if song:
|
||||||
|
song.status = "completed"
|
||||||
|
song.song_result_url = blob_url
|
||||||
async def download_and_upload_song_by_suno_task_id(
|
if duration is not None:
|
||||||
suno_task_id: str,
|
song.duration = duration
|
||||||
audio_url: str,
|
await session.commit()
|
||||||
store_name: str,
|
print(f"[download_and_upload_song_by_suno_task_id] SUCCESS - suno_task_id: {suno_task_id}, status: completed, duration: {duration}")
|
||||||
duration: float | None = None,
|
else:
|
||||||
) -> None:
|
print(f"[download_and_upload_song_by_suno_task_id] Song NOT FOUND in DB - suno_task_id: {suno_task_id}")
|
||||||
"""suno_task_id로 Song을 조회하여 노래를 다운로드하고 Azure Blob Storage에 업로드한 뒤 Song 테이블을 업데이트합니다.
|
|
||||||
|
except Exception as e:
|
||||||
Args:
|
print(f"[download_and_upload_song_by_suno_task_id] EXCEPTION - suno_task_id: {suno_task_id}, error: {e}")
|
||||||
suno_task_id: Suno API 작업 ID
|
# 실패 시 Song 테이블 업데이트
|
||||||
audio_url: 다운로드할 오디오 URL
|
if task_id:
|
||||||
store_name: 저장할 파일명에 사용할 업체명
|
async with BackgroundSessionLocal() as session:
|
||||||
duration: 노래 재생 시간 (초)
|
result = await session.execute(
|
||||||
"""
|
select(Song)
|
||||||
logger.info(f"[download_and_upload_song_by_suno_task_id] START - suno_task_id: {suno_task_id}, store_name: {store_name}, duration: {duration}")
|
.where(Song.suno_task_id == suno_task_id)
|
||||||
print(f"[download_and_upload_song_by_suno_task_id] START - suno_task_id: {suno_task_id}, store_name: {store_name}, duration: {duration}")
|
.order_by(Song.created_at.desc())
|
||||||
temp_file_path: Path | None = None
|
.limit(1)
|
||||||
task_id: str | None = None
|
)
|
||||||
|
song = result.scalar_one_or_none()
|
||||||
try:
|
|
||||||
# suno_task_id로 Song 조회하여 task_id 가져오기
|
if song:
|
||||||
async with BackgroundSessionLocal() as session:
|
song.status = "failed"
|
||||||
result = await session.execute(
|
await session.commit()
|
||||||
select(Song)
|
print(f"[download_and_upload_song_by_suno_task_id] FAILED - suno_task_id: {suno_task_id}, status updated to failed")
|
||||||
.where(Song.suno_task_id == suno_task_id)
|
|
||||||
.order_by(Song.created_at.desc())
|
finally:
|
||||||
.limit(1)
|
# 임시 파일 삭제
|
||||||
)
|
if temp_file_path and temp_file_path.exists():
|
||||||
song = result.scalar_one_or_none()
|
try:
|
||||||
|
temp_file_path.unlink()
|
||||||
if not song:
|
print(f"[download_and_upload_song_by_suno_task_id] Temp file deleted - path: {temp_file_path}")
|
||||||
logger.warning(f"[download_and_upload_song_by_suno_task_id] Song NOT FOUND - suno_task_id: {suno_task_id}")
|
except Exception as e:
|
||||||
print(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] Failed to delete temp file: {e}")
|
||||||
return
|
|
||||||
|
# 임시 디렉토리 삭제 시도
|
||||||
task_id = song.task_id
|
if 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}")
|
temp_dir = Path("media") / "temp" / task_id
|
||||||
print(f"[download_and_upload_song_by_suno_task_id] Song found - suno_task_id: {suno_task_id}, task_id: {task_id}")
|
if temp_dir.exists():
|
||||||
|
try:
|
||||||
# 파일명에 사용할 수 없는 문자 제거
|
temp_dir.rmdir()
|
||||||
safe_store_name = "".join(
|
except Exception:
|
||||||
c for c in store_name if c.isalnum() or c in (" ", "_", "-")
|
pass # 디렉토리가 비어있지 않으면 무시
|
||||||
).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,404 +1,329 @@
|
||||||
import json
|
import json
|
||||||
import logging
|
import re
|
||||||
import re
|
|
||||||
|
from openai import AsyncOpenAI
|
||||||
from openai import AsyncOpenAI
|
|
||||||
|
from config import apikey_settings
|
||||||
from config import apikey_settings
|
|
||||||
|
# fmt: off
|
||||||
# 로거 설정
|
LYRICS_PROMPT_TEMPLATE_ORI = """
|
||||||
logger = logging.getLogger(__name__)
|
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:
|
||||||
# fmt: off
|
|
||||||
LYRICS_PROMPT_TEMPLATE_ORI = """
|
**Core Analysis:**
|
||||||
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
|
- Target customer segments & personas
|
||||||
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:
|
- Unique Selling Propositions (USPs) and competitive differentiators
|
||||||
|
- Comprehensive competitor landscape analysis (direct & indirect competitors)
|
||||||
**Core Analysis:**
|
- Market positioning assessment
|
||||||
- Target customer segments & personas
|
|
||||||
- Unique Selling Propositions (USPs) and competitive differentiators
|
**Content Strategy Framework:**
|
||||||
- Comprehensive competitor landscape analysis (direct & indirect competitors)
|
- Seasonal content calendar with trend integration
|
||||||
- Market positioning assessment
|
- Visual storytelling direction (shot-by-shot creative guidance)
|
||||||
|
- Brand tone & voice guidelines
|
||||||
**Content Strategy Framework:**
|
- Content themes aligned with target audience behaviors
|
||||||
- Seasonal content calendar with trend integration
|
|
||||||
- Visual storytelling direction (shot-by-shot creative guidance)
|
**SEO & AEO Optimization:**
|
||||||
- Brand tone & voice guidelines
|
- Recommended primary and long-tail keywords
|
||||||
- Content themes aligned with target audience behaviors
|
- SEO-optimized taglines and meta descriptions
|
||||||
|
- Answer Engine Optimization (AEO) content suggestions
|
||||||
**SEO & AEO Optimization:**
|
- Local search optimization strategies
|
||||||
- Recommended primary and long-tail keywords
|
|
||||||
- SEO-optimized taglines and meta descriptions
|
**Actionable Recommendations:**
|
||||||
- Answer Engine Optimization (AEO) content suggestions
|
- Content distribution strategy across platforms
|
||||||
- Local search optimization strategies
|
- KPI measurement framework
|
||||||
|
- Budget allocation recommendations by content type
|
||||||
**Actionable Recommendations:**
|
|
||||||
- Content distribution strategy across platforms
|
콘텐츠 기획(Lyrics, Prompt for SUNO)
|
||||||
- KPI measurement framework
|
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.
|
||||||
- Budget allocation recommendations by content type
|
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 [인스타 감성], [사진같은 하루]
|
||||||
|
|
||||||
콘텐츠 기획(Lyrics, Prompt for SUNO)
|
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
|
||||||
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.
|
""".strip()
|
||||||
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 [인스타 감성], [사진같은 하루]
|
# fmt: on
|
||||||
|
|
||||||
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
|
LYRICS_PROMPT_TEMPLATE = """
|
||||||
""".strip()
|
[ROLE]
|
||||||
# fmt: on
|
Content marketing expert and creative songwriter specializing in pension/accommodation services
|
||||||
|
|
||||||
LYRICS_PROMPT_TEMPLATE = """
|
[INPUT]
|
||||||
[ROLE]
|
- Business Name: {customer_name}
|
||||||
Content marketing expert and creative songwriter specializing in pension/accommodation services
|
- Region: {region}
|
||||||
|
- Region Details: {detail_region_info}
|
||||||
[INPUT]
|
- Output Language: {language}
|
||||||
- Business Name: {customer_name}
|
|
||||||
- Region: {region}
|
[INTERNAL ANALYSIS - DO NOT OUTPUT]
|
||||||
- Region Details: {detail_region_info}
|
Analyze the following internally to inform lyrics creation:
|
||||||
- Output Language: {language}
|
- Target customer segments and personas
|
||||||
|
- Unique Selling Propositions (USPs)
|
||||||
[INTERNAL ANALYSIS - DO NOT OUTPUT]
|
- Regional characteristics and nearby attractions (within 10 min access)
|
||||||
Analyze the following internally to inform lyrics creation:
|
- Seasonal appeal points
|
||||||
- Target customer segments and personas
|
- Emotional triggers for the target audience
|
||||||
- Unique Selling Propositions (USPs)
|
|
||||||
- Regional characteristics and nearby attractions (within 10 min access)
|
[LYRICS REQUIREMENTS]
|
||||||
- Seasonal appeal points
|
1. Must Include Elements:
|
||||||
- Emotional triggers for the target audience
|
- Business name (TRANSLATED or TRANSLITERATED to {language})
|
||||||
|
- Region name (TRANSLATED or TRANSLITERATED to {language})
|
||||||
[LYRICS REQUIREMENTS]
|
- Main target audience appeal
|
||||||
1. Must Include Elements:
|
- Nearby famous places or regional characteristics
|
||||||
- Business name (TRANSLATED or TRANSLITERATED to {language})
|
|
||||||
- Region name (TRANSLATED or TRANSLITERATED to {language})
|
2. Keywords to Incorporate (use language-appropriate trendy expressions):
|
||||||
- Main target audience appeal
|
- Korean: 인스타 감성, 사진같은 하루, 힐링, 여행, 감성 숙소
|
||||||
- Nearby famous places or regional characteristics
|
- English: Instagram vibes, picture-perfect day, healing, travel, getaway
|
||||||
|
- Chinese: 网红打卡, 治愈系, 旅行, 度假, 拍照圣地
|
||||||
2. Keywords to Incorporate (use language-appropriate trendy expressions):
|
- Japanese: インスタ映え, 写真のような一日, 癒し, 旅行, 絶景
|
||||||
- Korean: 인스타 감성, 사진같은 하루, 힐링, 여행, 감성 숙소
|
- Thai: ที่พักสวย, ฮีลใจ, เที่ยว, ถ่ายรูป, วิวสวย
|
||||||
- English: Instagram vibes, picture-perfect day, healing, travel, getaway
|
- Vietnamese: check-in đẹp, healing, du lịch, nghỉ dưỡng, view đẹp
|
||||||
- Chinese: 网红打卡, 治愈系, 旅行, 度假, 拍照圣地
|
|
||||||
- Japanese: インスタ映え, 写真のような一日, 癒し, 旅行, 絶景
|
3. Structure:
|
||||||
- Thai: ที่พักสวย, ฮีลใจ, เที่ยว, ถ่ายรูป, วิวสวย
|
- Length: For 1-minute video (approximately 8-12 lines)
|
||||||
- Vietnamese: check-in đẹp, healing, du lịch, nghỉ dưỡng, view đẹp
|
- Flow: Verse structure suitable for music
|
||||||
|
- Rhythm: Natural speech rhythm in the specified language
|
||||||
3. Structure:
|
|
||||||
- Length: For 1-minute video (approximately 8-12 lines)
|
4. Tone:
|
||||||
- Flow: Verse structure suitable for music
|
- Emotional and heartfelt
|
||||||
- Rhythm: Natural speech rhythm in the specified language
|
- Trendy and viral-friendly
|
||||||
|
- Relatable to target audience
|
||||||
4. Tone:
|
|
||||||
- Emotional and heartfelt
|
[CRITICAL LANGUAGE REQUIREMENT - ABSOLUTE RULE]
|
||||||
- Trendy and viral-friendly
|
ALL OUTPUT MUST BE 100% WRITTEN IN {language} - NO EXCEPTIONS
|
||||||
- Relatable to target audience
|
- ALL lyrics content: {language} ONLY
|
||||||
|
- ALL proper nouns (business names, region names, place names): MUST be translated or transliterated to {language}
|
||||||
[CRITICAL LANGUAGE REQUIREMENT - ABSOLUTE RULE]
|
- Korean input like "군산" must become "Gunsan" in English, "群山" in Chinese, "グンサン" in Japanese, etc.
|
||||||
ALL OUTPUT MUST BE 100% WRITTEN IN {language} - NO EXCEPTIONS
|
- Korean input like "스테이 머뭄" must become "Stay Meoum" in English, "住留" in Chinese, "ステイモーム" in Japanese, etc.
|
||||||
- ALL lyrics content: {language} ONLY
|
- ZERO Korean characters (한글) allowed when output language is NOT Korean
|
||||||
- ALL proper nouns (business names, region names, place names): MUST be translated or transliterated to {language}
|
- ZERO mixing of languages - the entire output must be monolingual in {language}
|
||||||
- Korean input like "군산" must become "Gunsan" in English, "群山" in Chinese, "グンサン" in Japanese, etc.
|
- This is a NON-NEGOTIABLE requirement
|
||||||
- Korean input like "스테이 머뭄" must become "Stay Meoum" in English, "住留" in Chinese, "ステイモーム" in Japanese, etc.
|
- Any output containing characters from other languages is considered a COMPLETE FAILURE
|
||||||
- ZERO Korean characters (한글) allowed when output language is NOT Korean
|
- Violation of this rule invalidates the entire response
|
||||||
- ZERO mixing of languages - the entire output must be monolingual in {language}
|
|
||||||
- This is a NON-NEGOTIABLE requirement
|
[OUTPUT RULES - STRICTLY ENFORCED]
|
||||||
- Any output containing characters from other languages is considered a COMPLETE FAILURE
|
- Output lyrics ONLY
|
||||||
- Violation of this rule invalidates the entire response
|
- Lyrics MUST be written ENTIRELY in {language} - NO EXCEPTIONS
|
||||||
|
- ALL names and places MUST be in {language} script/alphabet
|
||||||
[OUTPUT RULES - STRICTLY ENFORCED]
|
- NO Korean (한글), Chinese (漢字), Japanese (仮名), Thai (ไทย), or Vietnamese (Tiếng Việt) characters unless that is the selected output language
|
||||||
- Output lyrics ONLY
|
- NO titles, descriptions, analysis, or explanations
|
||||||
- Lyrics MUST be written ENTIRELY in {language} - NO EXCEPTIONS
|
- NO greetings or closing remarks
|
||||||
- ALL names and places MUST be in {language} script/alphabet
|
- NO additional commentary before or after lyrics
|
||||||
- NO Korean (한글), Chinese (漢字), Japanese (仮名), Thai (ไทย), or Vietnamese (Tiếng Việt) characters unless that is the selected output language
|
- NO line numbers or labels
|
||||||
- NO titles, descriptions, analysis, or explanations
|
- Follow the exact format below
|
||||||
- NO greetings or closing remarks
|
|
||||||
- NO additional commentary before or after lyrics
|
[OUTPUT FORMAT - SUCCESS]
|
||||||
- NO line numbers or labels
|
---
|
||||||
- Follow the exact format below
|
[Lyrics ENTIRELY in {language} here - no other language characters allowed]
|
||||||
|
---
|
||||||
[OUTPUT FORMAT - SUCCESS]
|
|
||||||
---
|
[OUTPUT FORMAT - FAILURE]
|
||||||
[Lyrics ENTIRELY in {language} here - no other language characters allowed]
|
If you cannot generate lyrics due to insufficient information, invalid input, or any other reason:
|
||||||
---
|
---
|
||||||
|
ERROR: [Brief reason for failure in English]
|
||||||
[OUTPUT FORMAT - FAILURE]
|
---
|
||||||
If you cannot generate lyrics due to insufficient information, invalid input, or any other reason:
|
""".strip()
|
||||||
---
|
# fmt: on
|
||||||
ERROR: [Brief reason for failure in English]
|
|
||||||
---
|
MARKETING_ANALYSIS_PROMPT_TEMPLATE = """
|
||||||
""".strip()
|
[ROLE]
|
||||||
# fmt: on
|
Content marketing expert specializing in pension/accommodation services in Korea
|
||||||
|
|
||||||
MARKETING_ANALYSIS_PROMPT_TEMPLATE = """
|
[INPUT]
|
||||||
[ROLE]
|
- Business Name: {customer_name}
|
||||||
Content marketing expert specializing in pension/accommodation services in Korea
|
- Region: {region}
|
||||||
|
- Region Details: {detail_region_info}
|
||||||
[INPUT]
|
|
||||||
- Business Name: {customer_name}
|
[ANALYSIS REQUIREMENTS]
|
||||||
- Region: {region}
|
Provide comprehensive marketing analysis including:
|
||||||
- Region Details: {detail_region_info}
|
1. Target Customer Segments
|
||||||
|
- Primary and secondary target personas
|
||||||
[ANALYSIS REQUIREMENTS]
|
- Age groups, travel preferences, booking patterns
|
||||||
Provide comprehensive marketing analysis including:
|
2. Unique Selling Propositions (USPs)
|
||||||
1. Target Customer Segments
|
- Key differentiators based on location and region details
|
||||||
- Primary and secondary target personas
|
- Competitive advantages
|
||||||
- Age groups, travel preferences, booking patterns
|
3. Regional Characteristics
|
||||||
2. Unique Selling Propositions (USPs)
|
- Nearby attractions and famous places (within 10 min access)
|
||||||
- Key differentiators based on location and region details
|
- Local food, activities, and experiences
|
||||||
- Competitive advantages
|
- Transportation accessibility
|
||||||
3. Regional Characteristics
|
4. Seasonal Appeal Points
|
||||||
- Nearby attractions and famous places (within 10 min access)
|
- Best seasons to visit
|
||||||
- Local food, activities, and experiences
|
- Seasonal activities and events
|
||||||
- Transportation accessibility
|
- Peak/off-peak marketing opportunities
|
||||||
4. Seasonal Appeal Points
|
5. Marketing Keywords
|
||||||
- Best seasons to visit
|
- Recommended hashtags and search keywords
|
||||||
- Seasonal activities and events
|
- Trending terms relevant to the property
|
||||||
- Peak/off-peak marketing opportunities
|
|
||||||
5. Marketing Keywords
|
[ADDITIONAL REQUIREMENTS]
|
||||||
- Recommended hashtags and search keywords
|
1. Recommended Tags
|
||||||
- Trending terms relevant to the property
|
- Generate 5 recommended hashtags/tags based on the business characteristics
|
||||||
|
- Tags should be trendy, searchable, and relevant to accommodation marketing
|
||||||
[ADDITIONAL REQUIREMENTS]
|
- Return as JSON with key "tags"
|
||||||
1. Recommended Tags
|
- **MUST be written in Korean (한국어)**
|
||||||
- Generate 5 recommended hashtags/tags based on the business characteristics
|
|
||||||
- Tags should be trendy, searchable, and relevant to accommodation marketing
|
2. Facilities
|
||||||
- Return as JSON with key "tags"
|
- Based on the business name and region details, identify 5 likely facilities/amenities
|
||||||
- **MUST be written in Korean (한국어)**
|
- Consider typical facilities for accommodations in the given region
|
||||||
|
- Examples: 바베큐장, 수영장, 주차장, 와이파이, 주방, 테라스, 정원, etc.
|
||||||
[CRITICAL LANGUAGE REQUIREMENT - ABSOLUTE RULE]
|
- Return as JSON with key "facilities"
|
||||||
ALL OUTPUT MUST BE WRITTEN IN KOREAN (한국어)
|
- **MUST be written in Korean (한국어)**
|
||||||
- Analysis sections: Korean only
|
|
||||||
- Tags: Korean only
|
[CRITICAL LANGUAGE REQUIREMENT - ABSOLUTE RULE]
|
||||||
- This is a NON-NEGOTIABLE requirement
|
ALL OUTPUT MUST BE WRITTEN IN KOREAN (한국어)
|
||||||
- Any output in English or other languages is considered a FAILURE
|
- Analysis sections: Korean only
|
||||||
- Violation of this rule invalidates the entire response
|
- Tags: Korean only
|
||||||
|
- Facilities: Korean only
|
||||||
[OUTPUT RULES - STRICTLY ENFORCED]
|
- This is a NON-NEGOTIABLE requirement
|
||||||
- Output analysis ONLY
|
- Any output in English or other languages is considered a FAILURE
|
||||||
- ALL content MUST be written in Korean (한국어) - NO EXCEPTIONS
|
- Violation of this rule invalidates the entire response
|
||||||
- NO greetings or closing remarks
|
|
||||||
- NO additional commentary before or after analysis
|
[OUTPUT RULES - STRICTLY ENFORCED]
|
||||||
- Follow the exact format below
|
- Output analysis ONLY
|
||||||
|
- ALL content MUST be written in Korean (한국어) - NO EXCEPTIONS
|
||||||
[OUTPUT FORMAT - SUCCESS]
|
- NO greetings or closing remarks
|
||||||
---
|
- NO additional commentary before or after analysis
|
||||||
## 타겟 고객 분석
|
- Follow the exact format below
|
||||||
[한국어로 작성된 타겟 고객 분석]
|
|
||||||
|
[OUTPUT FORMAT - SUCCESS]
|
||||||
## 핵심 차별점 (USP)
|
---
|
||||||
[한국어로 작성된 USP 분석]
|
## 타겟 고객 분석
|
||||||
|
[한국어로 작성된 타겟 고객 분석]
|
||||||
## 지역 특성
|
|
||||||
[한국어로 작성된 지역 특성 분석]
|
## 핵심 차별점 (USP)
|
||||||
|
[한국어로 작성된 USP 분석]
|
||||||
## 시즌별 매력 포인트
|
|
||||||
[한국어로 작성된 시즌별 분석]
|
## 지역 특성
|
||||||
|
[한국어로 작성된 지역 특성 분석]
|
||||||
## 마케팅 키워드
|
|
||||||
[한국어로 작성된 마케팅 키워드]
|
## 시즌별 매력 포인트
|
||||||
|
[한국어로 작성된 시즌별 분석]
|
||||||
## JSON Data
|
|
||||||
```json
|
## 마케팅 키워드
|
||||||
{{
|
[한국어로 작성된 마케팅 키워드]
|
||||||
"tags": ["태그1", "태그2", "태그3", "태그4", "태그5"]
|
|
||||||
}}
|
## JSON Data
|
||||||
```
|
```json
|
||||||
---
|
{{
|
||||||
|
"tags": ["태그1", "태그2", "태그3", "태그4", "태그5"],
|
||||||
[OUTPUT FORMAT - FAILURE]
|
"facilities": ["부대시설1", "부대시설2", "부대시설3", "부대시설4", "부대시설5"]
|
||||||
If you cannot generate analysis due to insufficient information, invalid input, or any other reason:
|
}}
|
||||||
---
|
```
|
||||||
ERROR: [Brief reason for failure in English]
|
---
|
||||||
---
|
|
||||||
""".strip()
|
[OUTPUT FORMAT - FAILURE]
|
||||||
# fmt: on
|
If you cannot generate analysis due to insufficient information, invalid input, or any other reason:
|
||||||
|
---
|
||||||
|
ERROR: [Brief reason for failure in English]
|
||||||
class ChatgptService:
|
---
|
||||||
"""ChatGPT API 서비스 클래스
|
""".strip()
|
||||||
|
# fmt: on
|
||||||
GPT 5.0 모델을 사용하여 마케팅 가사 및 분석을 생성합니다.
|
|
||||||
"""
|
|
||||||
|
class ChatgptService:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
customer_name: str,
|
customer_name: str,
|
||||||
region: str,
|
region: str,
|
||||||
detail_region_info: str = "",
|
detail_region_info: str = "",
|
||||||
language: str = "Korean",
|
language: str = "Korean",
|
||||||
):
|
):
|
||||||
# 최신 모델: gpt-5-mini
|
# 최신 모델: GPT-5, GPT-5 mini, GPT-5 nano, GPT-4.1, GPT-4.1 mini, GPT-4.1 nano
|
||||||
self.model = "gpt-5-mini"
|
# 이전 세대: GPT-4o, GPT-4o mini, GPT-4 Turbo, GPT-3.5 Turbo
|
||||||
self.client = AsyncOpenAI(api_key=apikey_settings.CHATGPT_API_KEY)
|
self.model = "gpt-4o"
|
||||||
self.customer_name = customer_name
|
self.client = AsyncOpenAI(api_key=apikey_settings.CHATGPT_API_KEY)
|
||||||
self.region = region
|
self.customer_name = customer_name
|
||||||
self.detail_region_info = detail_region_info
|
self.region = region
|
||||||
self.language = language
|
self.detail_region_info = detail_region_info
|
||||||
|
self.language = language
|
||||||
def build_lyrics_prompt(self) -> str:
|
|
||||||
"""LYRICS_PROMPT_TEMPLATE에 고객 정보를 대입하여 완성된 프롬프트 반환"""
|
def build_lyrics_prompt(self) -> str:
|
||||||
return LYRICS_PROMPT_TEMPLATE.format(
|
"""LYRICS_PROMPT_TEMPLATE에 고객 정보를 대입하여 완성된 프롬프트 반환"""
|
||||||
customer_name=self.customer_name,
|
return LYRICS_PROMPT_TEMPLATE.format(
|
||||||
region=self.region,
|
customer_name=self.customer_name,
|
||||||
detail_region_info=self.detail_region_info,
|
region=self.region,
|
||||||
language=self.language,
|
detail_region_info=self.detail_region_info,
|
||||||
)
|
language=self.language,
|
||||||
|
)
|
||||||
def build_market_analysis_prompt(self) -> str:
|
|
||||||
"""MARKETING_ANALYSIS_PROMPT_TEMPLATE에 고객 정보를 대입하여 완성된 프롬프트 반환"""
|
def build_market_analysis_prompt(self) -> str:
|
||||||
return MARKETING_ANALYSIS_PROMPT_TEMPLATE.format(
|
"""MARKETING_ANALYSIS_PROMPT_TEMPLATE에 고객 정보를 대입하여 완성된 프롬프트 반환"""
|
||||||
customer_name=self.customer_name,
|
return MARKETING_ANALYSIS_PROMPT_TEMPLATE.format(
|
||||||
region=self.region,
|
customer_name=self.customer_name,
|
||||||
detail_region_info=self.detail_region_info,
|
region=self.region,
|
||||||
)
|
detail_region_info=self.detail_region_info,
|
||||||
|
)
|
||||||
async def _call_gpt_api(self, prompt: str) -> str:
|
|
||||||
"""GPT API를 직접 호출합니다 (내부 메서드).
|
async def generate(self, prompt: str | None = None) -> str:
|
||||||
|
"""GPT에게 프롬프트를 전달하여 결과를 반환"""
|
||||||
Args:
|
if prompt is None:
|
||||||
prompt: GPT에 전달할 프롬프트
|
prompt = self.build_lyrics_prompt()
|
||||||
|
print("Generated Prompt: ", prompt)
|
||||||
Returns:
|
completion = await self.client.chat.completions.create(
|
||||||
GPT 응답 문자열
|
model=self.model, messages=[{"role": "user", "content": prompt}]
|
||||||
|
)
|
||||||
Raises:
|
message = completion.choices[0].message.content
|
||||||
APIError, APIConnectionError, RateLimitError: OpenAI API 오류
|
return message or ""
|
||||||
"""
|
|
||||||
completion = await self.client.chat.completions.create(
|
async def summarize_marketing(self, text: str) -> str:
|
||||||
model=self.model, messages=[{"role": "user", "content": prompt}]
|
"""마케팅 텍스트를 항목으로 구분하여 500자로 요약 정리"""
|
||||||
)
|
prompt = f"""[ROLE]
|
||||||
message = completion.choices[0].message.content
|
마케팅 콘텐츠 요약 전문가
|
||||||
return message or ""
|
|
||||||
|
[INPUT]
|
||||||
async def generate(
|
{text}
|
||||||
self,
|
|
||||||
prompt: str | None = None,
|
[TASK]
|
||||||
) -> str:
|
위 텍스트를 분석하여 핵심 내용을 항목별로 구분하여 500자 이내로 요약해주세요.
|
||||||
"""GPT에게 프롬프트를 전달하여 결과를 반환합니다.
|
|
||||||
|
[OUTPUT REQUIREMENTS]
|
||||||
Args:
|
- 항목별로 구분하여 정리 (예: 타겟 고객, 차별점, 지역 특성 등)
|
||||||
prompt: GPT에 전달할 프롬프트 (None이면 기본 가사 프롬프트 사용)
|
- 총 500자 이내로 요약
|
||||||
|
- 핵심 정보만 간결하게 포함
|
||||||
Returns:
|
- 한국어로 작성
|
||||||
GPT 응답 문자열
|
|
||||||
|
[OUTPUT FORMAT]
|
||||||
Raises:
|
---
|
||||||
APIError, APIConnectionError, RateLimitError: OpenAI API 오류
|
[항목별로 구분된 500자 이내 요약]
|
||||||
"""
|
---
|
||||||
if prompt is None:
|
"""
|
||||||
prompt = self.build_lyrics_prompt()
|
completion = await self.client.chat.completions.create(
|
||||||
|
model=self.model, messages=[{"role": "user", "content": prompt}]
|
||||||
print(f"[ChatgptService] Generated Prompt (length: {len(prompt)})")
|
)
|
||||||
logger.info(f"[ChatgptService] Starting GPT request with model: {self.model}")
|
message = completion.choices[0].message.content
|
||||||
|
result = message or ""
|
||||||
# GPT API 호출
|
|
||||||
response = await self._call_gpt_api(prompt)
|
# --- 구분자 제거
|
||||||
|
if result.startswith("---"):
|
||||||
print(f"[ChatgptService] SUCCESS - Response length: {len(response)}")
|
result = result[3:].strip()
|
||||||
logger.info(f"[ChatgptService] SUCCESS - Response length: {len(response)}")
|
if result.endswith("---"):
|
||||||
return response
|
result = result[:-3].strip()
|
||||||
|
|
||||||
async def summarize_marketing(self, text: str) -> str:
|
return result
|
||||||
"""마케팅 텍스트를 항목으로 구분하여 500자로 요약 정리.
|
|
||||||
|
async def parse_marketing_analysis(self, raw_response: str) -> dict:
|
||||||
Args:
|
"""ChatGPT 마케팅 분석 응답을 파싱하고 요약하여 딕셔너리로 반환
|
||||||
text: 요약할 마케팅 텍스트
|
|
||||||
|
Returns:
|
||||||
Returns:
|
dict: {"report": str, "tags": list[str], "facilities": list[str]}
|
||||||
요약된 텍스트
|
"""
|
||||||
|
tags: list[str] = []
|
||||||
Raises:
|
facilities: list[str] = []
|
||||||
APIError, APIConnectionError, RateLimitError: OpenAI API 오류
|
report = raw_response
|
||||||
"""
|
|
||||||
prompt = f"""[ROLE]
|
# JSON 블록 추출 시도
|
||||||
마케팅 콘텐츠 요약 전문가
|
json_match = re.search(r"```json\s*(\{.*?\})\s*```", raw_response, re.DOTALL)
|
||||||
|
if json_match:
|
||||||
[INPUT]
|
try:
|
||||||
{text}
|
json_data = json.loads(json_match.group(1))
|
||||||
|
tags = json_data.get("tags", [])
|
||||||
[TASK]
|
facilities = json_data.get("facilities", [])
|
||||||
위 텍스트를 분석하여 핵심 내용을 항목별로 구분하여 500자 이내로 요약해주세요.
|
# JSON 블록을 제외한 리포트 부분 추출
|
||||||
|
report = raw_response[: json_match.start()].strip()
|
||||||
[OUTPUT REQUIREMENTS]
|
# --- 구분자 제거
|
||||||
- 5개 항목으로 구분: 타겟 고객, 핵심 차별점, 지역 특성, 시즌별 포인트, 추천 키워드
|
if report.startswith("---"):
|
||||||
- 각 항목은 줄바꿈으로 구분
|
report = report[3:].strip()
|
||||||
- 총 500자 이내로 요약
|
if report.endswith("---"):
|
||||||
- 핵심 정보만 간결하게 포함
|
report = report[:-3].strip()
|
||||||
- 한국어로 작성
|
except json.JSONDecodeError:
|
||||||
- 특수문자 사용 금지 (괄호, 슬래시, 하이픈, 물결표 등 제외)
|
pass
|
||||||
- 쉼표와 마침표만 사용하여 자연스러운 문장으로 작성
|
|
||||||
|
# 리포트 내용을 500자로 요약
|
||||||
[OUTPUT FORMAT - 반드시 아래 형식 준수]
|
if report:
|
||||||
---
|
report = await self.summarize_marketing(report)
|
||||||
타겟 고객
|
|
||||||
[대상 고객층을 자연스러운 문장으로 설명]
|
return {"report": report, "tags": tags, "facilities": facilities}
|
||||||
|
|
||||||
핵심 차별점
|
|
||||||
[숙소의 차별화 포인트를 자연스러운 문장으로 설명]
|
|
||||||
|
|
||||||
지역 특성
|
|
||||||
[주변 관광지와 지역 특색을 자연스러운 문장으로 설명]
|
|
||||||
|
|
||||||
시즌별 포인트
|
|
||||||
[계절별 매력 포인트를 자연스러운 문장으로 설명]
|
|
||||||
|
|
||||||
추천 키워드
|
|
||||||
[마케팅에 활용할 키워드를 쉼표로 구분하여 나열]
|
|
||||||
---
|
|
||||||
"""
|
|
||||||
|
|
||||||
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 import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from config import cors_settings
|
from config import cors_settings
|
||||||
|
|
||||||
# sys.path.append(
|
# sys.path.append(
|
||||||
# os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
# os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
# ) # root 경로 추가
|
# ) # root 경로 추가
|
||||||
|
|
||||||
|
|
||||||
class CustomCORSMiddleware:
|
class CustomCORSMiddleware:
|
||||||
def __init__(self, app: FastAPI):
|
def __init__(self, app: FastAPI):
|
||||||
self.app = app
|
self.app = app
|
||||||
|
|
||||||
def configure_cors(self):
|
def configure_cors(self):
|
||||||
self.app.add_middleware(
|
self.app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=cors_settings.CORS_ALLOW_ORIGINS,
|
allow_origins=cors_settings.CORS_ALLOW_ORIGINS,
|
||||||
allow_credentials=cors_settings.CORS_ALLOW_CREDENTIALS,
|
allow_credentials=cors_settings.CORS_ALLOW_CREDENTIALS,
|
||||||
allow_methods=cors_settings.CORS_ALLOW_METHODS,
|
allow_methods=cors_settings.CORS_ALLOW_METHODS,
|
||||||
allow_headers=cors_settings.CORS_ALLOW_HEADERS,
|
allow_headers=cors_settings.CORS_ALLOW_HEADERS,
|
||||||
expose_headers=cors_settings.CORS_EXPOSE_HEADERS,
|
expose_headers=cors_settings.CORS_EXPOSE_HEADERS,
|
||||||
max_age=cors_settings.CORS_MAX_AGE,
|
max_age=cors_settings.CORS_MAX_AGE,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,473 +1,437 @@
|
||||||
"""
|
"""
|
||||||
Creatomate API 클라이언트 모듈
|
Creatomate API 클라이언트 모듈
|
||||||
|
|
||||||
API 문서: https://creatomate.com/docs/api
|
API 문서: https://creatomate.com/docs/api
|
||||||
|
|
||||||
## 사용법
|
## 사용법
|
||||||
```python
|
```python
|
||||||
from app.utils.creatomate import CreatomateService
|
from app.utils.creatomate import CreatomateService
|
||||||
|
|
||||||
# config에서 자동으로 API 키를 가져옴
|
# config에서 자동으로 API 키를 가져옴
|
||||||
creatomate = CreatomateService()
|
creatomate = CreatomateService()
|
||||||
|
|
||||||
# 또는 명시적으로 API 키 전달
|
# 또는 명시적으로 API 키 전달
|
||||||
creatomate = CreatomateService(api_key="your_api_key")
|
creatomate = CreatomateService(api_key="your_api_key")
|
||||||
|
|
||||||
# 템플릿 목록 조회 (비동기)
|
# 템플릿 목록 조회 (비동기)
|
||||||
templates = await creatomate.get_all_templates_data()
|
templates = await creatomate.get_all_templates_data()
|
||||||
|
|
||||||
# 특정 템플릿 조회 (비동기)
|
# 특정 템플릿 조회 (비동기)
|
||||||
template = await creatomate.get_one_template_data(template_id)
|
template = await creatomate.get_one_template_data(template_id)
|
||||||
|
|
||||||
# 영상 렌더링 요청 (비동기)
|
# 영상 렌더링 요청 (비동기)
|
||||||
response = await creatomate.make_creatomate_call(template_id, modifications)
|
response = await creatomate.make_creatomate_call(template_id, modifications)
|
||||||
```
|
```
|
||||||
|
|
||||||
## 성능 최적화
|
## 성능 최적화
|
||||||
- 템플릿 캐싱: 템플릿 데이터는 메모리에 캐싱되어 반복 조회 시 API 호출을 줄입니다.
|
- 템플릿 캐싱: 템플릿 데이터는 메모리에 캐싱되어 반복 조회 시 API 호출을 줄입니다.
|
||||||
- HTTP 클라이언트 재사용: 모듈 레벨의 공유 클라이언트로 커넥션 풀을 재사용합니다.
|
- HTTP 클라이언트 재사용: 모듈 레벨의 공유 클라이언트로 커넥션 풀을 재사용합니다.
|
||||||
- 캐시 만료: 기본 5분 후 자동 만료 (CACHE_TTL_SECONDS로 조정 가능)
|
- 캐시 만료: 기본 5분 후 자동 만료 (CACHE_TTL_SECONDS로 조정 가능)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import copy
|
import copy
|
||||||
import logging
|
import time
|
||||||
import time
|
from typing import Literal
|
||||||
from typing import Literal
|
|
||||||
|
import httpx
|
||||||
import httpx
|
|
||||||
|
from config import apikey_settings, creatomate_settings
|
||||||
from config import apikey_settings, creatomate_settings
|
|
||||||
|
|
||||||
# 로거 설정
|
# Orientation 타입 정의
|
||||||
logger = logging.getLogger(__name__)
|
OrientationType = Literal["horizontal", "vertical"]
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
# Orientation 타입 정의
|
# 모듈 레벨 캐시 및 HTTP 클라이언트 (싱글톤 패턴)
|
||||||
OrientationType = Literal["horizontal", "vertical"]
|
# =============================================================================
|
||||||
|
|
||||||
# =============================================================================
|
# 템플릿 캐시: {template_id: {"data": dict, "cached_at": float}}
|
||||||
# 모듈 레벨 캐시 및 HTTP 클라이언트 (싱글톤 패턴)
|
_template_cache: dict[str, dict] = {}
|
||||||
# =============================================================================
|
|
||||||
|
# 캐시 TTL (초) - 기본 5분
|
||||||
# 템플릿 캐시: {template_id: {"data": dict, "cached_at": float}}
|
CACHE_TTL_SECONDS = 300
|
||||||
_template_cache: dict[str, dict] = {}
|
|
||||||
|
# 모듈 레벨 공유 HTTP 클라이언트 (커넥션 풀 재사용)
|
||||||
# 캐시 TTL (초) - 기본 5분
|
_shared_client: httpx.AsyncClient | None = None
|
||||||
CACHE_TTL_SECONDS = 300
|
|
||||||
|
|
||||||
# 모듈 레벨 공유 HTTP 클라이언트 (커넥션 풀 재사용)
|
async def get_shared_client() -> httpx.AsyncClient:
|
||||||
_shared_client: httpx.AsyncClient | None = None
|
"""공유 HTTP 클라이언트를 반환합니다. 없으면 생성합니다."""
|
||||||
|
global _shared_client
|
||||||
|
if _shared_client is None or _shared_client.is_closed:
|
||||||
async def get_shared_client() -> httpx.AsyncClient:
|
_shared_client = httpx.AsyncClient(
|
||||||
"""공유 HTTP 클라이언트를 반환합니다. 없으면 생성합니다."""
|
timeout=httpx.Timeout(60.0, connect=10.0),
|
||||||
global _shared_client
|
limits=httpx.Limits(max_keepalive_connections=10, max_connections=20),
|
||||||
if _shared_client is None or _shared_client.is_closed:
|
)
|
||||||
_shared_client = httpx.AsyncClient(
|
return _shared_client
|
||||||
timeout=httpx.Timeout(60.0, connect=10.0),
|
|
||||||
limits=httpx.Limits(max_keepalive_connections=10, max_connections=20),
|
|
||||||
)
|
async def close_shared_client() -> None:
|
||||||
return _shared_client
|
"""공유 HTTP 클라이언트를 닫습니다. 앱 종료 시 호출하세요."""
|
||||||
|
global _shared_client
|
||||||
|
if _shared_client is not None and not _shared_client.is_closed:
|
||||||
async def close_shared_client() -> None:
|
await _shared_client.aclose()
|
||||||
"""공유 HTTP 클라이언트를 닫습니다. 앱 종료 시 호출하세요."""
|
_shared_client = None
|
||||||
global _shared_client
|
print("[CreatomateService] Shared HTTP client closed")
|
||||||
if _shared_client is not None and not _shared_client.is_closed:
|
|
||||||
await _shared_client.aclose()
|
|
||||||
_shared_client = None
|
def clear_template_cache() -> None:
|
||||||
print("[CreatomateService] Shared HTTP client closed")
|
"""템플릿 캐시를 전체 삭제합니다."""
|
||||||
|
global _template_cache
|
||||||
|
_template_cache.clear()
|
||||||
def clear_template_cache() -> None:
|
print("[CreatomateService] Template cache cleared")
|
||||||
"""템플릿 캐시를 전체 삭제합니다."""
|
|
||||||
global _template_cache
|
|
||||||
_template_cache.clear()
|
def _is_cache_valid(cached_at: float) -> bool:
|
||||||
print("[CreatomateService] Template cache cleared")
|
"""캐시가 유효한지 확인합니다."""
|
||||||
|
return (time.time() - cached_at) < CACHE_TTL_SECONDS
|
||||||
|
|
||||||
def _is_cache_valid(cached_at: float) -> bool:
|
|
||||||
"""캐시가 유효한지 확인합니다."""
|
class CreatomateService:
|
||||||
return (time.time() - cached_at) < CACHE_TTL_SECONDS
|
"""Creatomate API를 통한 영상 생성 서비스
|
||||||
|
|
||||||
|
모든 HTTP 호출 메서드는 비동기(async)로 구현되어 있습니다.
|
||||||
class CreatomateService:
|
"""
|
||||||
"""Creatomate API를 통한 영상 생성 서비스
|
|
||||||
|
BASE_URL = "https://api.creatomate.com"
|
||||||
모든 HTTP 호출 메서드는 비동기(async)로 구현되어 있습니다.
|
|
||||||
"""
|
# 템플릿 설정 (config에서 가져옴)
|
||||||
|
TEMPLATE_CONFIG = {
|
||||||
BASE_URL = "https://api.creatomate.com"
|
"horizontal": {
|
||||||
|
"template_id": creatomate_settings.TEMPLATE_ID_HORIZONTAL,
|
||||||
# 템플릿 설정 (config에서 가져옴)
|
"duration": creatomate_settings.TEMPLATE_DURATION_HORIZONTAL,
|
||||||
TEMPLATE_CONFIG = {
|
},
|
||||||
"horizontal": {
|
"vertical": {
|
||||||
"template_id": creatomate_settings.TEMPLATE_ID_HORIZONTAL,
|
"template_id": creatomate_settings.TEMPLATE_ID_VERTICAL,
|
||||||
"duration": creatomate_settings.TEMPLATE_DURATION_HORIZONTAL,
|
"duration": creatomate_settings.TEMPLATE_DURATION_VERTICAL,
|
||||||
},
|
},
|
||||||
"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",
|
||||||
def __init__(
|
target_duration: float | None = None,
|
||||||
self,
|
):
|
||||||
api_key: str | None = None,
|
"""
|
||||||
orientation: OrientationType = "vertical",
|
Args:
|
||||||
target_duration: float | None = None,
|
api_key: Creatomate API 키 (Bearer token으로 사용)
|
||||||
):
|
None일 경우 config에서 자동으로 가져옴
|
||||||
"""
|
orientation: 영상 방향 ("horizontal" 또는 "vertical", 기본값: "vertical")
|
||||||
Args:
|
target_duration: 목표 영상 길이 (초)
|
||||||
api_key: Creatomate API 키 (Bearer token으로 사용)
|
None일 경우 orientation에 해당하는 기본값 사용
|
||||||
None일 경우 config에서 자동으로 가져옴
|
"""
|
||||||
orientation: 영상 방향 ("horizontal" 또는 "vertical", 기본값: "vertical")
|
self.api_key = api_key or apikey_settings.CREATOMATE_API_KEY
|
||||||
target_duration: 목표 영상 길이 (초)
|
self.orientation = orientation
|
||||||
None일 경우 orientation에 해당하는 기본값 사용
|
|
||||||
"""
|
# orientation에 따른 템플릿 설정 가져오기
|
||||||
self.api_key = api_key or apikey_settings.CREATOMATE_API_KEY
|
config = self.TEMPLATE_CONFIG.get(
|
||||||
self.orientation = orientation
|
orientation, self.TEMPLATE_CONFIG["vertical"]
|
||||||
|
)
|
||||||
# orientation에 따른 템플릿 설정 가져오기
|
self.template_id = config["template_id"]
|
||||||
config = self.TEMPLATE_CONFIG.get(
|
self.target_duration = (
|
||||||
orientation, self.TEMPLATE_CONFIG["vertical"]
|
target_duration if target_duration is not None else config["duration"]
|
||||||
)
|
)
|
||||||
self.template_id = config["template_id"]
|
|
||||||
self.target_duration = (
|
self.headers = {
|
||||||
target_duration if target_duration is not None else config["duration"]
|
"Content-Type": "application/json",
|
||||||
)
|
"Authorization": f"Bearer {self.api_key}",
|
||||||
|
}
|
||||||
self.headers = {
|
|
||||||
"Content-Type": "application/json",
|
async def get_all_templates_data(self) -> dict:
|
||||||
"Authorization": f"Bearer {self.api_key}",
|
"""모든 템플릿 정보를 조회합니다."""
|
||||||
}
|
url = f"{self.BASE_URL}/v1/templates"
|
||||||
|
client = await get_shared_client()
|
||||||
async def _request(
|
response = await client.get(url, headers=self.headers, timeout=30.0)
|
||||||
self,
|
response.raise_for_status()
|
||||||
method: str,
|
return response.json()
|
||||||
url: str,
|
|
||||||
timeout: float = 30.0,
|
async def get_one_template_data(
|
||||||
**kwargs,
|
self,
|
||||||
) -> httpx.Response:
|
template_id: str,
|
||||||
"""HTTP 요청을 수행합니다.
|
use_cache: bool = True,
|
||||||
|
) -> dict:
|
||||||
Args:
|
"""특정 템플릿 ID로 템플릿 정보를 조회합니다.
|
||||||
method: HTTP 메서드 ("GET", "POST", etc.)
|
|
||||||
url: 요청 URL
|
Args:
|
||||||
timeout: 요청 타임아웃 (초)
|
template_id: 조회할 템플릿 ID
|
||||||
**kwargs: httpx 요청에 전달할 추가 인자
|
use_cache: 캐시 사용 여부 (기본: True)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
httpx.Response: 응답 객체
|
템플릿 데이터 (deep copy)
|
||||||
|
"""
|
||||||
Raises:
|
global _template_cache
|
||||||
httpx.HTTPError: 요청 실패 시
|
|
||||||
"""
|
# 캐시 확인
|
||||||
logger.info(f"[Creatomate] {method} {url}")
|
if use_cache and template_id in _template_cache:
|
||||||
print(f"[Creatomate] {method} {url}")
|
cached = _template_cache[template_id]
|
||||||
|
if _is_cache_valid(cached["cached_at"]):
|
||||||
client = await get_shared_client()
|
print(f"[CreatomateService] Cache HIT - {template_id}")
|
||||||
|
return copy.deepcopy(cached["data"])
|
||||||
if method.upper() == "GET":
|
else:
|
||||||
response = await client.get(
|
# 만료된 캐시 삭제
|
||||||
url, headers=self.headers, timeout=timeout, **kwargs
|
del _template_cache[template_id]
|
||||||
)
|
print(f"[CreatomateService] Cache EXPIRED - {template_id}")
|
||||||
elif method.upper() == "POST":
|
|
||||||
response = await client.post(
|
# API 호출
|
||||||
url, headers=self.headers, timeout=timeout, **kwargs
|
url = f"{self.BASE_URL}/v1/templates/{template_id}"
|
||||||
)
|
client = await get_shared_client()
|
||||||
else:
|
response = await client.get(url, headers=self.headers, timeout=30.0)
|
||||||
raise ValueError(f"Unsupported HTTP method: {method}")
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
logger.info(f"[Creatomate] Response - Status: {response.status_code}")
|
|
||||||
print(f"[Creatomate] Response - Status: {response.status_code}")
|
# 캐시 저장
|
||||||
return response
|
_template_cache[template_id] = {
|
||||||
|
"data": data,
|
||||||
async def get_all_templates_data(self) -> dict:
|
"cached_at": time.time(),
|
||||||
"""모든 템플릿 정보를 조회합니다."""
|
}
|
||||||
url = f"{self.BASE_URL}/v1/templates"
|
print(f"[CreatomateService] Cache MISS - {template_id} (cached)")
|
||||||
response = await self._request("GET", url, timeout=30.0)
|
|
||||||
response.raise_for_status()
|
return copy.deepcopy(data)
|
||||||
return response.json()
|
|
||||||
|
# 하위 호환성을 위한 별칭 (deprecated)
|
||||||
async def get_one_template_data(
|
async def get_one_template_data_async(self, template_id: str) -> dict:
|
||||||
self,
|
"""특정 템플릿 ID로 템플릿 정보를 조회합니다.
|
||||||
template_id: str,
|
|
||||||
use_cache: bool = True,
|
Deprecated: get_one_template_data()를 사용하세요.
|
||||||
) -> dict:
|
"""
|
||||||
"""특정 템플릿 ID로 템플릿 정보를 조회합니다.
|
return await self.get_one_template_data(template_id)
|
||||||
|
|
||||||
Args:
|
def parse_template_component_name(self, template_source: list) -> dict:
|
||||||
template_id: 조회할 템플릿 ID
|
"""템플릿 정보를 파싱하여 리소스 이름을 추출합니다."""
|
||||||
use_cache: 캐시 사용 여부 (기본: True)
|
|
||||||
|
def recursive_parse_component(element: dict) -> dict:
|
||||||
Returns:
|
if "name" in element:
|
||||||
템플릿 데이터 (deep copy)
|
result_element_name_type = {element["name"]: element["type"]}
|
||||||
"""
|
else:
|
||||||
global _template_cache
|
result_element_name_type = {}
|
||||||
|
|
||||||
# 캐시 확인
|
if element["type"] == "composition":
|
||||||
if use_cache and template_id in _template_cache:
|
minor_component_list = [
|
||||||
cached = _template_cache[template_id]
|
recursive_parse_component(minor) for minor in element["elements"]
|
||||||
if _is_cache_valid(cached["cached_at"]):
|
]
|
||||||
print(f"[CreatomateService] Cache HIT - {template_id}")
|
# WARNING: Same name component should shroud other component
|
||||||
return copy.deepcopy(cached["data"])
|
for minor_component in minor_component_list:
|
||||||
else:
|
result_element_name_type.update(minor_component)
|
||||||
# 만료된 캐시 삭제
|
|
||||||
del _template_cache[template_id]
|
return result_element_name_type
|
||||||
print(f"[CreatomateService] Cache EXPIRED - {template_id}")
|
|
||||||
|
result = {}
|
||||||
# API 호출
|
for result_element_dict in [
|
||||||
url = f"{self.BASE_URL}/v1/templates/{template_id}"
|
recursive_parse_component(component) for component in template_source
|
||||||
response = await self._request("GET", url, timeout=30.0)
|
]:
|
||||||
response.raise_for_status()
|
result.update(result_element_dict)
|
||||||
data = response.json()
|
|
||||||
|
return result
|
||||||
# 캐시 저장
|
|
||||||
_template_cache[template_id] = {
|
async def template_connect_resource_blackbox(
|
||||||
"data": data,
|
self,
|
||||||
"cached_at": time.time(),
|
template_id: str,
|
||||||
}
|
image_url_list: list[str],
|
||||||
print(f"[CreatomateService] Cache MISS - {template_id} (cached)")
|
lyric: str,
|
||||||
|
music_url: str,
|
||||||
return copy.deepcopy(data)
|
) -> dict:
|
||||||
|
"""템플릿 정보와 이미지/가사/음악 리소스를 매핑합니다.
|
||||||
# 하위 호환성을 위한 별칭 (deprecated)
|
|
||||||
async def get_one_template_data_async(self, template_id: str) -> dict:
|
Note:
|
||||||
"""특정 템플릿 ID로 템플릿 정보를 조회합니다.
|
- 이미지는 순차적으로 집어넣기
|
||||||
|
- 가사는 개행마다 한 텍스트 삽입
|
||||||
Deprecated: get_one_template_data()를 사용하세요.
|
- Template에 audio-music 항목이 있어야 함
|
||||||
"""
|
"""
|
||||||
return await self.get_one_template_data(template_id)
|
template_data = await self.get_one_template_data(template_id)
|
||||||
|
template_component_data = self.parse_template_component_name(
|
||||||
def parse_template_component_name(self, template_source: list) -> dict:
|
template_data["source"]["elements"]
|
||||||
"""템플릿 정보를 파싱하여 리소스 이름을 추출합니다."""
|
)
|
||||||
|
|
||||||
def recursive_parse_component(element: dict) -> dict:
|
lyric = lyric.replace("\r", "")
|
||||||
if "name" in element:
|
lyric_splited = lyric.split("\n")
|
||||||
result_element_name_type = {element["name"]: element["type"]}
|
modifications = {}
|
||||||
else:
|
|
||||||
result_element_name_type = {}
|
for idx, (template_component_name, template_type) in enumerate(
|
||||||
|
template_component_data.items()
|
||||||
if element["type"] == "composition":
|
):
|
||||||
minor_component_list = [
|
match template_type:
|
||||||
recursive_parse_component(minor) for minor in element["elements"]
|
case "image":
|
||||||
]
|
modifications[template_component_name] = image_url_list[
|
||||||
# WARNING: Same name component should shroud other component
|
idx % len(image_url_list)
|
||||||
for minor_component in minor_component_list:
|
]
|
||||||
result_element_name_type.update(minor_component)
|
case "text":
|
||||||
|
modifications[template_component_name] = lyric_splited[
|
||||||
return result_element_name_type
|
idx % len(lyric_splited)
|
||||||
|
]
|
||||||
result = {}
|
|
||||||
for result_element_dict in [
|
modifications["audio-music"] = music_url
|
||||||
recursive_parse_component(component) for component in template_source
|
|
||||||
]:
|
return modifications
|
||||||
result.update(result_element_dict)
|
|
||||||
|
def elements_connect_resource_blackbox(
|
||||||
return result
|
self,
|
||||||
|
elements: list,
|
||||||
async def template_connect_resource_blackbox(
|
image_url_list: list[str],
|
||||||
self,
|
lyric: str,
|
||||||
template_id: str,
|
music_url: str,
|
||||||
image_url_list: list[str],
|
) -> dict:
|
||||||
lyric: str,
|
"""elements 정보와 이미지/가사/음악 리소스를 매핑합니다."""
|
||||||
music_url: str,
|
template_component_data = self.parse_template_component_name(elements)
|
||||||
) -> dict:
|
|
||||||
"""템플릿 정보와 이미지/가사/음악 리소스를 매핑합니다.
|
lyric = lyric.replace("\r", "")
|
||||||
|
lyric_splited = lyric.split("\n")
|
||||||
Note:
|
modifications = {}
|
||||||
- 이미지는 순차적으로 집어넣기
|
|
||||||
- 가사는 개행마다 한 텍스트 삽입
|
for idx, (template_component_name, template_type) in enumerate(
|
||||||
- Template에 audio-music 항목이 있어야 함
|
template_component_data.items()
|
||||||
"""
|
):
|
||||||
template_data = await self.get_one_template_data(template_id)
|
match template_type:
|
||||||
template_component_data = self.parse_template_component_name(
|
case "image":
|
||||||
template_data["source"]["elements"]
|
modifications[template_component_name] = image_url_list[
|
||||||
)
|
idx % len(image_url_list)
|
||||||
|
]
|
||||||
lyric = lyric.replace("\r", "")
|
case "text":
|
||||||
lyric_splited = lyric.split("\n")
|
modifications[template_component_name] = lyric_splited[
|
||||||
modifications = {}
|
idx % len(lyric_splited)
|
||||||
|
]
|
||||||
for idx, (template_component_name, template_type) in enumerate(
|
|
||||||
template_component_data.items()
|
modifications["audio-music"] = music_url
|
||||||
):
|
|
||||||
match template_type:
|
return modifications
|
||||||
case "image":
|
|
||||||
modifications[template_component_name] = image_url_list[
|
def modify_element(self, elements: list, modification: dict) -> list:
|
||||||
idx % len(image_url_list)
|
"""elements의 source를 modification에 따라 수정합니다."""
|
||||||
]
|
|
||||||
case "text":
|
def recursive_modify(element: dict) -> None:
|
||||||
modifications[template_component_name] = lyric_splited[
|
if "name" in element:
|
||||||
idx % len(lyric_splited)
|
match element["type"]:
|
||||||
]
|
case "image":
|
||||||
|
element["source"] = modification[element["name"]]
|
||||||
modifications["audio-music"] = music_url
|
case "audio":
|
||||||
|
element["source"] = modification.get(element["name"], "")
|
||||||
return modifications
|
case "video":
|
||||||
|
element["source"] = modification[element["name"]]
|
||||||
def elements_connect_resource_blackbox(
|
case "text":
|
||||||
self,
|
element["source"] = modification.get(element["name"], "")
|
||||||
elements: list,
|
case "composition":
|
||||||
image_url_list: list[str],
|
for minor in element["elements"]:
|
||||||
lyric: str,
|
recursive_modify(minor)
|
||||||
music_url: str,
|
|
||||||
) -> dict:
|
for minor in elements:
|
||||||
"""elements 정보와 이미지/가사/음악 리소스를 매핑합니다."""
|
recursive_modify(minor)
|
||||||
template_component_data = self.parse_template_component_name(elements)
|
|
||||||
|
return elements
|
||||||
lyric = lyric.replace("\r", "")
|
|
||||||
lyric_splited = lyric.split("\n")
|
async def make_creatomate_call(
|
||||||
modifications = {}
|
self, template_id: str, modifications: dict
|
||||||
|
) -> dict:
|
||||||
for idx, (template_component_name, template_type) in enumerate(
|
"""Creatomate에 렌더링 요청을 보냅니다.
|
||||||
template_component_data.items()
|
|
||||||
):
|
Note:
|
||||||
match template_type:
|
response에 요청 정보가 있으니 폴링 필요
|
||||||
case "image":
|
"""
|
||||||
modifications[template_component_name] = image_url_list[
|
url = f"{self.BASE_URL}/v2/renders"
|
||||||
idx % len(image_url_list)
|
data = {
|
||||||
]
|
"template_id": template_id,
|
||||||
case "text":
|
"modifications": modifications,
|
||||||
modifications[template_component_name] = lyric_splited[
|
}
|
||||||
idx % len(lyric_splited)
|
client = await get_shared_client()
|
||||||
]
|
response = await client.post(
|
||||||
|
url, json=data, headers=self.headers, timeout=60.0
|
||||||
modifications["audio-music"] = music_url
|
)
|
||||||
|
response.raise_for_status()
|
||||||
return modifications
|
return response.json()
|
||||||
|
|
||||||
def modify_element(self, elements: list, modification: dict) -> list:
|
async def make_creatomate_custom_call(self, source: dict) -> dict:
|
||||||
"""elements의 source를 modification에 따라 수정합니다."""
|
"""템플릿 없이 Creatomate에 커스텀 렌더링 요청을 보냅니다.
|
||||||
|
|
||||||
def recursive_modify(element: dict) -> None:
|
Note:
|
||||||
if "name" in element:
|
response에 요청 정보가 있으니 폴링 필요
|
||||||
match element["type"]:
|
"""
|
||||||
case "image":
|
url = f"{self.BASE_URL}/v2/renders"
|
||||||
element["source"] = modification[element["name"]]
|
client = await get_shared_client()
|
||||||
case "audio":
|
response = await client.post(
|
||||||
element["source"] = modification.get(element["name"], "")
|
url, json=source, headers=self.headers, timeout=60.0
|
||||||
case "video":
|
)
|
||||||
element["source"] = modification[element["name"]]
|
response.raise_for_status()
|
||||||
case "text":
|
return response.json()
|
||||||
element["source"] = modification.get(element["name"], "")
|
|
||||||
case "composition":
|
# 하위 호환성을 위한 별칭 (deprecated)
|
||||||
for minor in element["elements"]:
|
async def make_creatomate_custom_call_async(self, source: dict) -> dict:
|
||||||
recursive_modify(minor)
|
"""템플릿 없이 Creatomate에 커스텀 렌더링 요청을 보냅니다.
|
||||||
|
|
||||||
for minor in elements:
|
Deprecated: make_creatomate_custom_call()을 사용하세요.
|
||||||
recursive_modify(minor)
|
"""
|
||||||
|
return await self.make_creatomate_custom_call(source)
|
||||||
return elements
|
|
||||||
|
async def get_render_status(self, render_id: str) -> dict:
|
||||||
async def make_creatomate_call(
|
"""렌더링 작업의 상태를 조회합니다.
|
||||||
self, template_id: str, modifications: dict
|
|
||||||
) -> dict:
|
Args:
|
||||||
"""Creatomate에 렌더링 요청을 보냅니다.
|
render_id: Creatomate 렌더 ID
|
||||||
|
|
||||||
Note:
|
Returns:
|
||||||
response에 요청 정보가 있으니 폴링 필요
|
렌더링 상태 정보
|
||||||
"""
|
|
||||||
url = f"{self.BASE_URL}/v2/renders"
|
Note:
|
||||||
data = {
|
상태 값:
|
||||||
"template_id": template_id,
|
- planned: 예약됨
|
||||||
"modifications": modifications,
|
- waiting: 대기 중
|
||||||
}
|
- transcribing: 트랜스크립션 중
|
||||||
response = await self._request("POST", url, timeout=60.0, json=data)
|
- rendering: 렌더링 중
|
||||||
response.raise_for_status()
|
- succeeded: 성공
|
||||||
return response.json()
|
- failed: 실패
|
||||||
|
"""
|
||||||
async def make_creatomate_custom_call(self, source: dict) -> dict:
|
url = f"{self.BASE_URL}/v1/renders/{render_id}"
|
||||||
"""템플릿 없이 Creatomate에 커스텀 렌더링 요청을 보냅니다.
|
client = await get_shared_client()
|
||||||
|
response = await client.get(url, headers=self.headers, timeout=30.0)
|
||||||
Note:
|
response.raise_for_status()
|
||||||
response에 요청 정보가 있으니 폴링 필요
|
return response.json()
|
||||||
"""
|
|
||||||
url = f"{self.BASE_URL}/v2/renders"
|
# 하위 호환성을 위한 별칭 (deprecated)
|
||||||
response = await self._request("POST", url, timeout=60.0, json=source)
|
async def get_render_status_async(self, render_id: str) -> dict:
|
||||||
response.raise_for_status()
|
"""렌더링 작업의 상태를 조회합니다.
|
||||||
return response.json()
|
|
||||||
|
Deprecated: get_render_status()를 사용하세요.
|
||||||
# 하위 호환성을 위한 별칭 (deprecated)
|
"""
|
||||||
async def make_creatomate_custom_call_async(self, source: dict) -> dict:
|
return await self.get_render_status(render_id)
|
||||||
"""템플릿 없이 Creatomate에 커스텀 렌더링 요청을 보냅니다.
|
|
||||||
|
def calc_scene_duration(self, template: dict) -> float:
|
||||||
Deprecated: make_creatomate_custom_call()을 사용하세요.
|
"""템플릿의 전체 장면 duration을 계산합니다."""
|
||||||
"""
|
total_template_duration = 0.0
|
||||||
return await self.make_creatomate_custom_call(source)
|
|
||||||
|
for elem in template["source"]["elements"]:
|
||||||
async def get_render_status(self, render_id: str) -> dict:
|
try:
|
||||||
"""렌더링 작업의 상태를 조회합니다.
|
if elem["type"] == "audio":
|
||||||
|
continue
|
||||||
Args:
|
total_template_duration += elem["duration"]
|
||||||
render_id: Creatomate 렌더 ID
|
if "animations" not in elem:
|
||||||
|
continue
|
||||||
Returns:
|
for animation in elem["animations"]:
|
||||||
렌더링 상태 정보
|
assert animation["time"] == 0 # 0이 아닌 경우 확인 필요
|
||||||
|
if animation["transition"]:
|
||||||
Note:
|
total_template_duration -= animation["duration"]
|
||||||
상태 값:
|
except Exception as e:
|
||||||
- planned: 예약됨
|
print(f"[calc_scene_duration] Error processing element: {elem}, {e}")
|
||||||
- waiting: 대기 중
|
|
||||||
- transcribing: 트랜스크립션 중
|
return total_template_duration
|
||||||
- rendering: 렌더링 중
|
|
||||||
- succeeded: 성공
|
def extend_template_duration(self, template: dict, target_duration: float) -> dict:
|
||||||
- failed: 실패
|
"""템플릿의 duration을 target_duration으로 확장합니다."""
|
||||||
"""
|
template["duration"] = target_duration
|
||||||
url = f"{self.BASE_URL}/v1/renders/{render_id}"
|
total_template_duration = self.calc_scene_duration(template)
|
||||||
response = await self._request("GET", url, timeout=30.0)
|
extend_rate = target_duration / total_template_duration
|
||||||
response.raise_for_status()
|
new_template = copy.deepcopy(template)
|
||||||
return response.json()
|
|
||||||
|
for elem in new_template["source"]["elements"]:
|
||||||
# 하위 호환성을 위한 별칭 (deprecated)
|
try:
|
||||||
async def get_render_status_async(self, render_id: str) -> dict:
|
if elem["type"] == "audio":
|
||||||
"""렌더링 작업의 상태를 조회합니다.
|
continue
|
||||||
|
elem["duration"] = elem["duration"] * extend_rate
|
||||||
Deprecated: get_render_status()를 사용하세요.
|
if "animations" not in elem:
|
||||||
"""
|
continue
|
||||||
return await self.get_render_status(render_id)
|
for animation in elem["animations"]:
|
||||||
|
assert animation["time"] == 0 # 0이 아닌 경우 확인 필요
|
||||||
def calc_scene_duration(self, template: dict) -> float:
|
animation["duration"] = animation["duration"] * extend_rate
|
||||||
"""템플릿의 전체 장면 duration을 계산합니다."""
|
except Exception as e:
|
||||||
total_template_duration = 0.0
|
print(
|
||||||
|
f"[extend_template_duration] Error processing element: {elem}, {e}"
|
||||||
for elem in template["source"]["elements"]:
|
)
|
||||||
try:
|
|
||||||
if elem["type"] == "audio":
|
return new_template
|
||||||
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
|
|
||||||
|
|
|
||||||
|
|
@ -1,113 +0,0 @@
|
||||||
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,203 +1,114 @@
|
||||||
import asyncio
|
import json
|
||||||
import json
|
import re
|
||||||
import logging
|
|
||||||
import re
|
import aiohttp
|
||||||
|
|
||||||
import aiohttp
|
from config import crawler_settings
|
||||||
import bs4
|
|
||||||
|
|
||||||
from config import crawler_settings
|
class GraphQLException(Exception):
|
||||||
|
pass
|
||||||
# 로거 설정
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
class NvMapScraper:
|
||||||
|
GRAPHQL_URL: str = "https://pcmap-api.place.naver.com/graphql"
|
||||||
class GraphQLException(Exception):
|
|
||||||
"""GraphQL 요청 실패 시 발생하는 예외"""
|
OVERVIEW_QUERY: str = """
|
||||||
pass
|
query getAccommodation($id: String!, $deviceType: String) {
|
||||||
|
business: placeDetail(input: {id: $id, isNx: true, deviceType: $deviceType}) {
|
||||||
|
base {
|
||||||
class CrawlingTimeoutException(Exception):
|
id
|
||||||
"""크롤링 타임아웃 시 발생하는 예외"""
|
name
|
||||||
pass
|
category
|
||||||
|
roadAddress
|
||||||
|
address
|
||||||
class NvMapScraper:
|
phone
|
||||||
"""네이버 지도 GraphQL API 스크래퍼
|
virtualPhone
|
||||||
|
microReviews
|
||||||
네이버 지도에서 숙소/장소 정보를 크롤링합니다.
|
conveniences
|
||||||
"""
|
visitorReviewsTotal
|
||||||
|
}
|
||||||
GRAPHQL_URL: str = "https://pcmap-api.place.naver.com/graphql"
|
images { images { origin url } }
|
||||||
REQUEST_TIMEOUT = 120 # 초
|
cpImages(source: [ugcImage]) { images { origin url } }
|
||||||
|
}
|
||||||
OVERVIEW_QUERY: str = """
|
}"""
|
||||||
query getAccommodation($id: String!, $deviceType: String) {
|
|
||||||
business: placeDetail(input: {id: $id, isNx: true, deviceType: $deviceType}) {
|
DEFAULT_HEADERS: dict = {
|
||||||
base {
|
"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",
|
||||||
id
|
"Referer": "https://map.naver.com/",
|
||||||
name
|
"Origin": "https://map.naver.com",
|
||||||
category
|
"Content-Type": "application/json",
|
||||||
roadAddress
|
}
|
||||||
address
|
|
||||||
phone
|
def __init__(self, url: str, cookies: str | None = None):
|
||||||
virtualPhone
|
self.url = url
|
||||||
microReviews
|
self.cookies = (
|
||||||
conveniences
|
cookies if cookies is not None else crawler_settings.NAVER_COOKIES
|
||||||
visitorReviewsTotal
|
)
|
||||||
}
|
self.scrap_type: str | None = None
|
||||||
images { images { origin url } }
|
self.rawdata: dict | None = None
|
||||||
cpImages(source: [ugcImage]) { images { origin url } }
|
self.image_link_list: list[str] | None = None
|
||||||
}
|
self.base_info: dict | None = None
|
||||||
}"""
|
|
||||||
|
def _get_request_headers(self) -> dict:
|
||||||
DEFAULT_HEADERS: dict = {
|
headers = self.DEFAULT_HEADERS.copy()
|
||||||
"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",
|
if self.cookies:
|
||||||
"Referer": "https://map.naver.com/",
|
headers["Cookie"] = self.cookies
|
||||||
"Origin": "https://map.naver.com",
|
return headers
|
||||||
"Content-Type": "application/json",
|
|
||||||
}
|
def parse_url(self) -> str:
|
||||||
|
place_pattern = r"/place/(\d+)"
|
||||||
def __init__(self, url: str, cookies: str | None = None):
|
match = re.search(place_pattern, self.url)
|
||||||
self.url = url
|
if not match:
|
||||||
self.cookies = (
|
raise GraphQLException("Failed to parse place ID from URL")
|
||||||
cookies if cookies is not None else crawler_settings.NAVER_COOKIES
|
return match[1]
|
||||||
)
|
|
||||||
self.scrap_type: str | None = None
|
async def scrap(self):
|
||||||
self.rawdata: dict | None = None
|
try:
|
||||||
self.image_link_list: list[str] | None = None
|
place_id = self.parse_url()
|
||||||
self.base_info: dict | None = None
|
data = await self._call_get_accommodation(place_id)
|
||||||
self.facility_info: str | None = None
|
self.rawdata = data
|
||||||
|
self.image_link_list = [
|
||||||
def _get_request_headers(self) -> dict:
|
nv_image["origin"]
|
||||||
headers = self.DEFAULT_HEADERS.copy()
|
for nv_image in data["data"]["business"]["images"]["images"]
|
||||||
if self.cookies:
|
]
|
||||||
headers["Cookie"] = self.cookies
|
self.base_info = data["data"]["business"]["base"]
|
||||||
return headers
|
self.scrap_type = "GraphQL"
|
||||||
|
|
||||||
async def parse_url(self) -> str:
|
except GraphQLException:
|
||||||
"""URL에서 place ID를 추출합니다. 단축 URL인 경우 실제 URL로 변환합니다."""
|
print("fallback")
|
||||||
place_pattern = r"/place/(\d+)"
|
self.scrap_type = "Playwright"
|
||||||
|
pass # 나중에 pw 이용한 crawling으로 fallback 추가
|
||||||
# URL에 place가 없는 경우 단축 URL 처리
|
|
||||||
if "place" not in self.url:
|
return
|
||||||
if "naver.me" in self.url:
|
|
||||||
async with aiohttp.ClientSession() as session:
|
async def _call_get_accommodation(self, place_id: str) -> dict:
|
||||||
async with session.get(self.url) as response:
|
payload = {
|
||||||
self.url = str(response.url)
|
"operationName": "getAccommodation",
|
||||||
else:
|
"variables": {"id": place_id, "deviceType": "pc"},
|
||||||
raise GraphQLException("This URL does not contain a place ID")
|
"query": self.OVERVIEW_QUERY,
|
||||||
|
}
|
||||||
match = re.search(place_pattern, self.url)
|
json_payload = json.dumps(payload)
|
||||||
if not match:
|
|
||||||
raise GraphQLException("Failed to parse place ID from URL")
|
async with aiohttp.ClientSession() as session:
|
||||||
return match[1]
|
async with session.post(
|
||||||
|
self.GRAPHQL_URL, data=json_payload, headers=self._get_request_headers()
|
||||||
async def scrap(self):
|
) as response:
|
||||||
try:
|
if response.status == 200:
|
||||||
place_id = await self.parse_url()
|
return await response.json()
|
||||||
data = await self._call_get_accommodation(place_id)
|
else:
|
||||||
self.rawdata = data
|
print("실패 상태 코드:", response.status)
|
||||||
fac_data = await self._get_facility_string(place_id)
|
raise GraphQLException(
|
||||||
self.rawdata["facilities"] = fac_data
|
f"Request failed with status {response.status}"
|
||||||
self.image_link_list = [
|
)
|
||||||
nv_image["origin"]
|
|
||||||
for nv_image in data["data"]["business"]["images"]["images"]
|
|
||||||
]
|
# if __name__ == "__main__":
|
||||||
self.base_info = data["data"]["business"]["base"]
|
# import asyncio
|
||||||
self.facility_info = fac_data
|
|
||||||
self.scrap_type = "GraphQL"
|
# 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)
|
||||||
except GraphQLException:
|
# asyncio.run(scraper.scrap())
|
||||||
print("fallback")
|
# print(scraper.image_link_list)
|
||||||
self.scrap_type = "Playwright"
|
# print(len(scraper.image_link_list) if scraper.image_link_list else 0)
|
||||||
pass # 나중에 pw 이용한 crawling으로 fallback 추가
|
# print(scraper.base_info)
|
||||||
|
|
||||||
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,468 +1,443 @@
|
||||||
"""
|
"""
|
||||||
Azure Blob Storage 업로드 유틸리티
|
Azure Blob Storage 업로드 유틸리티
|
||||||
|
|
||||||
Azure Blob Storage에 파일을 업로드하는 클래스를 제공합니다.
|
Azure Blob Storage에 파일을 업로드하는 클래스를 제공합니다.
|
||||||
파일 경로 또는 바이트 데이터를 직접 업로드할 수 있습니다.
|
파일 경로 또는 바이트 데이터를 직접 업로드할 수 있습니다.
|
||||||
|
|
||||||
URL 경로 형식:
|
URL 경로 형식:
|
||||||
- 음악: {BASE_URL}/{task_id}/song/{파일명}
|
- 음악: {BASE_URL}/{task_id}/song/{파일명}
|
||||||
- 영상: {BASE_URL}/{task_id}/video/{파일명}
|
- 영상: {BASE_URL}/{task_id}/video/{파일명}
|
||||||
- 이미지: {BASE_URL}/{task_id}/image/{파일명}
|
- 이미지: {BASE_URL}/{task_id}/image/{파일명}
|
||||||
|
|
||||||
사용 예시:
|
사용 예시:
|
||||||
from app.utils.upload_blob_as_request import AzureBlobUploader
|
from app.utils.upload_blob_as_request import AzureBlobUploader
|
||||||
|
|
||||||
uploader = AzureBlobUploader(task_id="task-123")
|
uploader = AzureBlobUploader(task_id="task-123")
|
||||||
|
|
||||||
# 파일 경로로 업로드
|
# 파일 경로로 업로드
|
||||||
success = await uploader.upload_music(file_path="my_song.mp3")
|
success = await uploader.upload_music(file_path="my_song.mp3")
|
||||||
success = await uploader.upload_video(file_path="my_video.mp4")
|
success = await uploader.upload_video(file_path="my_video.mp4")
|
||||||
success = await uploader.upload_image(file_path="my_image.png")
|
success = await uploader.upload_image(file_path="my_image.png")
|
||||||
|
|
||||||
# 바이트 데이터로 직접 업로드 (media 저장 없이)
|
# 바이트 데이터로 직접 업로드 (media 저장 없이)
|
||||||
success = await uploader.upload_music_bytes(audio_bytes, "my_song")
|
success = await uploader.upload_music_bytes(audio_bytes, "my_song")
|
||||||
success = await uploader.upload_video_bytes(video_bytes, "my_video")
|
success = await uploader.upload_video_bytes(video_bytes, "my_video")
|
||||||
success = await uploader.upload_image_bytes(image_bytes, "my_image.png")
|
success = await uploader.upload_image_bytes(image_bytes, "my_image.png")
|
||||||
|
|
||||||
print(uploader.public_url) # 마지막 업로드의 공개 URL
|
print(uploader.public_url) # 마지막 업로드의 공개 URL
|
||||||
|
|
||||||
성능 최적화:
|
성능 최적화:
|
||||||
- HTTP 클라이언트 재사용: 모듈 레벨의 공유 클라이언트로 커넥션 풀 재사용
|
- HTTP 클라이언트 재사용: 모듈 레벨의 공유 클라이언트로 커넥션 풀 재사용
|
||||||
- 동시 업로드: 공유 클라이언트를 통해 동시 요청 처리가 개선됩니다.
|
- 동시 업로드: 공유 클라이언트를 통해 동시 요청 처리가 개선됩니다.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import time
|
||||||
import time
|
from pathlib import Path
|
||||||
from pathlib import Path
|
|
||||||
|
import aiofiles
|
||||||
import aiofiles
|
import httpx
|
||||||
import httpx
|
|
||||||
|
from config import azure_blob_settings
|
||||||
from config import azure_blob_settings
|
|
||||||
|
|
||||||
# 로거 설정
|
# =============================================================================
|
||||||
logger = logging.getLogger(__name__)
|
# 모듈 레벨 공유 HTTP 클라이언트 (싱글톤 패턴)
|
||||||
|
# =============================================================================
|
||||||
# =============================================================================
|
|
||||||
# 모듈 레벨 공유 HTTP 클라이언트 (싱글톤 패턴)
|
# 모듈 레벨 공유 HTTP 클라이언트 (커넥션 풀 재사용)
|
||||||
# =============================================================================
|
_shared_blob_client: httpx.AsyncClient | None = None
|
||||||
|
|
||||||
# 모듈 레벨 공유 HTTP 클라이언트 (커넥션 풀 재사용)
|
|
||||||
_shared_blob_client: httpx.AsyncClient | None = None
|
async def get_shared_blob_client() -> httpx.AsyncClient:
|
||||||
|
"""공유 HTTP 클라이언트를 반환합니다. 없으면 생성합니다."""
|
||||||
|
global _shared_blob_client
|
||||||
async def get_shared_blob_client() -> httpx.AsyncClient:
|
if _shared_blob_client is None or _shared_blob_client.is_closed:
|
||||||
"""공유 HTTP 클라이언트를 반환합니다. 없으면 생성합니다."""
|
print("[AzureBlobUploader] Creating shared HTTP client...")
|
||||||
global _shared_blob_client
|
_shared_blob_client = httpx.AsyncClient(
|
||||||
if _shared_blob_client is None or _shared_blob_client.is_closed:
|
timeout=httpx.Timeout(180.0, connect=10.0),
|
||||||
print("[AzureBlobUploader] Creating shared HTTP client...")
|
limits=httpx.Limits(max_keepalive_connections=10, max_connections=20),
|
||||||
_shared_blob_client = httpx.AsyncClient(
|
)
|
||||||
timeout=httpx.Timeout(180.0, connect=10.0),
|
print("[AzureBlobUploader] Shared HTTP client created - "
|
||||||
limits=httpx.Limits(max_keepalive_connections=10, max_connections=20),
|
"max_connections: 20, max_keepalive: 10")
|
||||||
)
|
return _shared_blob_client
|
||||||
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
|
||||||
async def close_shared_blob_client() -> None:
|
if _shared_blob_client is not None and not _shared_blob_client.is_closed:
|
||||||
"""공유 HTTP 클라이언트를 닫습니다. 앱 종료 시 호출하세요."""
|
await _shared_blob_client.aclose()
|
||||||
global _shared_blob_client
|
_shared_blob_client = None
|
||||||
if _shared_blob_client is not None and not _shared_blob_client.is_closed:
|
print("[AzureBlobUploader] Shared HTTP client closed")
|
||||||
await _shared_blob_client.aclose()
|
|
||||||
_shared_blob_client = None
|
|
||||||
print("[AzureBlobUploader] Shared HTTP client closed")
|
class AzureBlobUploader:
|
||||||
|
"""Azure Blob Storage 업로드 클래스
|
||||||
|
|
||||||
class AzureBlobUploader:
|
Azure Blob Storage에 음악, 영상, 이미지 파일을 업로드합니다.
|
||||||
"""Azure Blob Storage 업로드 클래스
|
URL 형식: {BASE_URL}/{task_id}/{category}/{file_name}?{SAS_TOKEN}
|
||||||
|
|
||||||
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}
|
||||||
- 음악: {task_id}/song/{file_name}
|
|
||||||
- 영상: {task_id}/video/{file_name}
|
Attributes:
|
||||||
- 이미지: {task_id}/image/{file_name}
|
task_id: 작업 고유 식별자
|
||||||
|
"""
|
||||||
Attributes:
|
|
||||||
task_id: 작업 고유 식별자
|
# Content-Type 매핑
|
||||||
"""
|
IMAGE_CONTENT_TYPES = {
|
||||||
|
".jpg": "image/jpeg",
|
||||||
# Content-Type 매핑
|
".jpeg": "image/jpeg",
|
||||||
IMAGE_CONTENT_TYPES = {
|
".png": "image/png",
|
||||||
".jpg": "image/jpeg",
|
".gif": "image/gif",
|
||||||
".jpeg": "image/jpeg",
|
".webp": "image/webp",
|
||||||
".png": "image/png",
|
".bmp": "image/bmp",
|
||||||
".gif": "image/gif",
|
}
|
||||||
".webp": "image/webp",
|
|
||||||
".bmp": "image/bmp",
|
def __init__(self, task_id: str):
|
||||||
}
|
"""AzureBlobUploader 초기화
|
||||||
|
|
||||||
def __init__(self, task_id: str):
|
Args:
|
||||||
"""AzureBlobUploader 초기화
|
task_id: 작업 고유 식별자
|
||||||
|
"""
|
||||||
Args:
|
self._task_id = 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._task_id = task_id
|
self._last_public_url: str = ""
|
||||||
self._base_url = azure_blob_settings.AZURE_BLOB_BASE_URL
|
|
||||||
self._sas_token = azure_blob_settings.AZURE_BLOB_SAS_TOKEN
|
@property
|
||||||
self._last_public_url: str = ""
|
def task_id(self) -> str:
|
||||||
|
"""작업 고유 식별자"""
|
||||||
@property
|
return self._task_id
|
||||||
def task_id(self) -> str:
|
|
||||||
"""작업 고유 식별자"""
|
@property
|
||||||
return self._task_id
|
def public_url(self) -> str:
|
||||||
|
"""마지막 업로드의 공개 URL (SAS 토큰 제외)"""
|
||||||
@property
|
return self._last_public_url
|
||||||
def public_url(self) -> str:
|
|
||||||
"""마지막 업로드의 공개 URL (SAS 토큰 제외)"""
|
def _build_upload_url(self, category: str, file_name: str) -> str:
|
||||||
return self._last_public_url
|
"""업로드 URL 생성 (SAS 토큰 포함)"""
|
||||||
|
# SAS 토큰 앞뒤의 ?, ', " 제거
|
||||||
def _build_upload_url(self, category: str, file_name: str) -> str:
|
sas_token = self._sas_token.strip("?'\"")
|
||||||
"""업로드 URL 생성 (SAS 토큰 포함)"""
|
return (
|
||||||
# SAS 토큰 앞뒤의 ?, ', " 제거
|
f"{self._base_url}/{self._task_id}/{category}/{file_name}?{sas_token}"
|
||||||
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}"
|
||||||
def _build_public_url(self, category: str, file_name: str) -> str:
|
|
||||||
"""공개 URL 생성 (SAS 토큰 제외)"""
|
async def _upload_bytes(
|
||||||
return f"{self._base_url}/{self._task_id}/{category}/{file_name}"
|
self,
|
||||||
|
file_content: bytes,
|
||||||
async def _upload_bytes(
|
upload_url: str,
|
||||||
self,
|
headers: dict,
|
||||||
file_content: bytes,
|
timeout: float,
|
||||||
upload_url: str,
|
log_prefix: str,
|
||||||
headers: dict,
|
) -> bool:
|
||||||
timeout: float,
|
"""바이트 데이터를 업로드하는 공통 내부 메서드"""
|
||||||
log_prefix: str,
|
start_time = time.perf_counter()
|
||||||
) -> bool:
|
|
||||||
"""바이트 데이터를 업로드하는 공통 내부 메서드
|
try:
|
||||||
|
print(f"[{log_prefix}] Getting shared client...")
|
||||||
Args:
|
client = await get_shared_blob_client()
|
||||||
file_content: 업로드할 바이트 데이터
|
client_time = time.perf_counter()
|
||||||
upload_url: 업로드 URL
|
elapsed_ms = (client_time - start_time) * 1000
|
||||||
headers: HTTP 헤더
|
print(f"[{log_prefix}] Client acquired in {elapsed_ms:.1f}ms")
|
||||||
timeout: 요청 타임아웃 (초)
|
|
||||||
log_prefix: 로그 접두사
|
size = len(file_content)
|
||||||
|
print(f"[{log_prefix}] Starting upload... "
|
||||||
Returns:
|
f"(size: {size} bytes, timeout: {timeout}s)")
|
||||||
bool: 업로드 성공 여부
|
|
||||||
"""
|
response = await asyncio.wait_for(
|
||||||
size = len(file_content)
|
client.put(upload_url, content=file_content, headers=headers),
|
||||||
start_time = time.perf_counter()
|
timeout=timeout,
|
||||||
|
)
|
||||||
try:
|
upload_time = time.perf_counter()
|
||||||
logger.info(f"[{log_prefix}] Starting upload")
|
duration_ms = (upload_time - start_time) * 1000
|
||||||
print(f"[{log_prefix}] Getting shared client...")
|
|
||||||
|
if response.status_code in [200, 201]:
|
||||||
client = await get_shared_blob_client()
|
print(f"[{log_prefix}] SUCCESS - Status: {response.status_code}, "
|
||||||
client_time = time.perf_counter()
|
f"Duration: {duration_ms:.1f}ms")
|
||||||
elapsed_ms = (client_time - start_time) * 1000
|
print(f"[{log_prefix}] Public URL: {self._last_public_url}")
|
||||||
print(f"[{log_prefix}] Client acquired in {elapsed_ms:.1f}ms")
|
return True
|
||||||
|
else:
|
||||||
print(f"[{log_prefix}] Starting upload... "
|
print(f"[{log_prefix}] FAILED - Status: {response.status_code}, "
|
||||||
f"(size: {size} bytes, timeout: {timeout}s)")
|
f"Duration: {duration_ms:.1f}ms")
|
||||||
|
print(f"[{log_prefix}] Response: {response.text[:500]}")
|
||||||
response = await asyncio.wait_for(
|
return False
|
||||||
client.put(upload_url, content=file_content, headers=headers),
|
|
||||||
timeout=timeout,
|
except asyncio.TimeoutError:
|
||||||
)
|
elapsed = time.perf_counter() - start_time
|
||||||
upload_time = time.perf_counter()
|
print(f"[{log_prefix}] TIMEOUT after {elapsed:.1f}s "
|
||||||
duration_ms = (upload_time - start_time) * 1000
|
f"(limit: {timeout}s)")
|
||||||
|
return False
|
||||||
if response.status_code in [200, 201]:
|
except httpx.ConnectError as e:
|
||||||
logger.info(f"[{log_prefix}] SUCCESS - Status: {response.status_code}")
|
elapsed = time.perf_counter() - start_time
|
||||||
print(f"[{log_prefix}] SUCCESS - Status: {response.status_code}, "
|
print(f"[{log_prefix}] CONNECT_ERROR after {elapsed:.1f}s - "
|
||||||
f"Duration: {duration_ms:.1f}ms")
|
f"{type(e).__name__}: {e}")
|
||||||
print(f"[{log_prefix}] Public URL: {self._last_public_url}")
|
return False
|
||||||
return True
|
except httpx.ReadError as e:
|
||||||
|
elapsed = time.perf_counter() - start_time
|
||||||
# 업로드 실패
|
print(f"[{log_prefix}] READ_ERROR after {elapsed:.1f}s - "
|
||||||
logger.error(f"[{log_prefix}] FAILED - Status: {response.status_code}")
|
f"{type(e).__name__}: {e}")
|
||||||
print(f"[{log_prefix}] FAILED - Status: {response.status_code}, "
|
return False
|
||||||
f"Duration: {duration_ms:.1f}ms")
|
except Exception as e:
|
||||||
print(f"[{log_prefix}] Response: {response.text[:500]}")
|
elapsed = time.perf_counter() - start_time
|
||||||
return False
|
print(f"[{log_prefix}] ERROR after {elapsed:.1f}s - "
|
||||||
|
f"{type(e).__name__}: {e}")
|
||||||
except asyncio.TimeoutError:
|
return False
|
||||||
elapsed = time.perf_counter() - start_time
|
|
||||||
logger.error(f"[{log_prefix}] TIMEOUT after {elapsed:.1f}s")
|
async def _upload_file(
|
||||||
print(f"[{log_prefix}] TIMEOUT after {elapsed:.1f}s")
|
self,
|
||||||
return False
|
file_path: str,
|
||||||
|
category: str,
|
||||||
except httpx.ConnectError as e:
|
content_type: str,
|
||||||
elapsed = time.perf_counter() - start_time
|
timeout: float,
|
||||||
logger.error(f"[{log_prefix}] CONNECT_ERROR: {e}")
|
log_prefix: str,
|
||||||
print(f"[{log_prefix}] CONNECT_ERROR after {elapsed:.1f}s - "
|
) -> bool:
|
||||||
f"{type(e).__name__}: {e}")
|
"""파일을 Azure Blob Storage에 업로드하는 내부 메서드
|
||||||
return False
|
|
||||||
|
Args:
|
||||||
except httpx.ReadError as e:
|
file_path: 업로드할 파일 경로
|
||||||
elapsed = time.perf_counter() - start_time
|
category: 카테고리 (song, video, image)
|
||||||
logger.error(f"[{log_prefix}] READ_ERROR: {e}")
|
content_type: Content-Type 헤더 값
|
||||||
print(f"[{log_prefix}] READ_ERROR after {elapsed:.1f}s - "
|
timeout: 요청 타임아웃 (초)
|
||||||
f"{type(e).__name__}: {e}")
|
log_prefix: 로그 접두사
|
||||||
return False
|
|
||||||
|
Returns:
|
||||||
except Exception as e:
|
bool: 업로드 성공 여부
|
||||||
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 - "
|
file_name = Path(file_path).name
|
||||||
f"{type(e).__name__}: {e}")
|
|
||||||
return False
|
upload_url = self._build_upload_url(category, file_name)
|
||||||
|
self._last_public_url = self._build_public_url(category, file_name)
|
||||||
async def _upload_file(
|
print(f"[{log_prefix}] URL (without SAS): {self._last_public_url}")
|
||||||
self,
|
|
||||||
file_path: str,
|
headers = {"Content-Type": content_type, "x-ms-blob-type": "BlockBlob"}
|
||||||
category: str,
|
|
||||||
content_type: str,
|
async with aiofiles.open(file_path, "rb") as file:
|
||||||
timeout: float,
|
file_content = await file.read()
|
||||||
log_prefix: str,
|
|
||||||
) -> bool:
|
return await self._upload_bytes(
|
||||||
"""파일을 Azure Blob Storage에 업로드하는 내부 메서드
|
file_content=file_content,
|
||||||
|
upload_url=upload_url,
|
||||||
Args:
|
headers=headers,
|
||||||
file_path: 업로드할 파일 경로
|
timeout=timeout,
|
||||||
category: 카테고리 (song, video, image)
|
log_prefix=log_prefix,
|
||||||
content_type: Content-Type 헤더 값
|
)
|
||||||
timeout: 요청 타임아웃 (초)
|
|
||||||
log_prefix: 로그 접두사
|
async def upload_music(self, file_path: str) -> bool:
|
||||||
|
"""음악 파일을 Azure Blob Storage에 업로드합니다.
|
||||||
Returns:
|
|
||||||
bool: 업로드 성공 여부
|
URL 경로: {task_id}/song/{파일명}
|
||||||
"""
|
|
||||||
# 파일 경로에서 파일명 추출
|
Args:
|
||||||
file_name = Path(file_path).name
|
file_path: 업로드할 파일 경로
|
||||||
|
|
||||||
upload_url = self._build_upload_url(category, file_name)
|
Returns:
|
||||||
self._last_public_url = self._build_public_url(category, file_name)
|
bool: 업로드 성공 여부
|
||||||
print(f"[{log_prefix}] URL (without SAS): {self._last_public_url}")
|
|
||||||
|
Example:
|
||||||
headers = {"Content-Type": content_type, "x-ms-blob-type": "BlockBlob"}
|
uploader = AzureBlobUploader(task_id="task-123")
|
||||||
|
success = await uploader.upload_music(file_path="my_song.mp3")
|
||||||
async with aiofiles.open(file_path, "rb") as file:
|
print(uploader.public_url)
|
||||||
file_content = await file.read()
|
"""
|
||||||
|
return await self._upload_file(
|
||||||
return await self._upload_bytes(
|
file_path=file_path,
|
||||||
file_content=file_content,
|
category="song",
|
||||||
upload_url=upload_url,
|
content_type="audio/mpeg",
|
||||||
headers=headers,
|
timeout=120.0,
|
||||||
timeout=timeout,
|
log_prefix="upload_music",
|
||||||
log_prefix=log_prefix,
|
)
|
||||||
)
|
|
||||||
|
async def upload_music_bytes(
|
||||||
async def upload_music(self, file_path: str) -> bool:
|
self, file_content: bytes, file_name: str
|
||||||
"""음악 파일을 Azure Blob Storage에 업로드합니다.
|
) -> bool:
|
||||||
|
"""음악 바이트 데이터를 Azure Blob Storage에 직접 업로드합니다.
|
||||||
URL 경로: {task_id}/song/{파일명}
|
|
||||||
|
URL 경로: {task_id}/song/{파일명}
|
||||||
Args:
|
|
||||||
file_path: 업로드할 파일 경로
|
Args:
|
||||||
|
file_content: 업로드할 파일 바이트 데이터
|
||||||
Returns:
|
file_name: 저장할 파일명 (확장자가 없으면 .mp3 추가)
|
||||||
bool: 업로드 성공 여부
|
|
||||||
|
Returns:
|
||||||
Example:
|
bool: 업로드 성공 여부
|
||||||
uploader = AzureBlobUploader(task_id="task-123")
|
|
||||||
success = await uploader.upload_music(file_path="my_song.mp3")
|
Example:
|
||||||
print(uploader.public_url)
|
uploader = AzureBlobUploader(task_id="task-123")
|
||||||
"""
|
success = await uploader.upload_music_bytes(audio_bytes, "my_song")
|
||||||
return await self._upload_file(
|
print(uploader.public_url)
|
||||||
file_path=file_path,
|
"""
|
||||||
category="song",
|
# 확장자가 없으면 .mp3 추가
|
||||||
content_type="audio/mpeg",
|
if not Path(file_name).suffix:
|
||||||
timeout=120.0,
|
file_name = f"{file_name}.mp3"
|
||||||
log_prefix="upload_music",
|
|
||||||
)
|
upload_url = self._build_upload_url("song", file_name)
|
||||||
|
self._last_public_url = self._build_public_url("song", file_name)
|
||||||
async def upload_music_bytes(
|
log_prefix = "upload_music_bytes"
|
||||||
self, file_content: bytes, file_name: str
|
print(f"[{log_prefix}] URL (without SAS): {self._last_public_url}")
|
||||||
) -> bool:
|
|
||||||
"""음악 바이트 데이터를 Azure Blob Storage에 직접 업로드합니다.
|
headers = {"Content-Type": "audio/mpeg", "x-ms-blob-type": "BlockBlob"}
|
||||||
|
|
||||||
URL 경로: {task_id}/song/{파일명}
|
return await self._upload_bytes(
|
||||||
|
file_content=file_content,
|
||||||
Args:
|
upload_url=upload_url,
|
||||||
file_content: 업로드할 파일 바이트 데이터
|
headers=headers,
|
||||||
file_name: 저장할 파일명 (확장자가 없으면 .mp3 추가)
|
timeout=120.0,
|
||||||
|
log_prefix=log_prefix,
|
||||||
Returns:
|
)
|
||||||
bool: 업로드 성공 여부
|
|
||||||
|
async def upload_video(self, file_path: str) -> bool:
|
||||||
Example:
|
"""영상 파일을 Azure Blob Storage에 업로드합니다.
|
||||||
uploader = AzureBlobUploader(task_id="task-123")
|
|
||||||
success = await uploader.upload_music_bytes(audio_bytes, "my_song")
|
URL 경로: {task_id}/video/{파일명}
|
||||||
print(uploader.public_url)
|
|
||||||
"""
|
Args:
|
||||||
# 확장자가 없으면 .mp3 추가
|
file_path: 업로드할 파일 경로
|
||||||
if not Path(file_name).suffix:
|
|
||||||
file_name = f"{file_name}.mp3"
|
Returns:
|
||||||
|
bool: 업로드 성공 여부
|
||||||
upload_url = self._build_upload_url("song", file_name)
|
|
||||||
self._last_public_url = self._build_public_url("song", file_name)
|
Example:
|
||||||
log_prefix = "upload_music_bytes"
|
uploader = AzureBlobUploader(task_id="task-123")
|
||||||
print(f"[{log_prefix}] URL (without SAS): {self._last_public_url}")
|
success = await uploader.upload_video(file_path="my_video.mp4")
|
||||||
|
print(uploader.public_url)
|
||||||
headers = {"Content-Type": "audio/mpeg", "x-ms-blob-type": "BlockBlob"}
|
"""
|
||||||
|
return await self._upload_file(
|
||||||
return await self._upload_bytes(
|
file_path=file_path,
|
||||||
file_content=file_content,
|
category="video",
|
||||||
upload_url=upload_url,
|
content_type="video/mp4",
|
||||||
headers=headers,
|
timeout=180.0,
|
||||||
timeout=120.0,
|
log_prefix="upload_video",
|
||||||
log_prefix=log_prefix,
|
)
|
||||||
)
|
|
||||||
|
async def upload_video_bytes(
|
||||||
async def upload_video(self, file_path: str) -> bool:
|
self, file_content: bytes, file_name: str
|
||||||
"""영상 파일을 Azure Blob Storage에 업로드합니다.
|
) -> bool:
|
||||||
|
"""영상 바이트 데이터를 Azure Blob Storage에 직접 업로드합니다.
|
||||||
URL 경로: {task_id}/video/{파일명}
|
|
||||||
|
URL 경로: {task_id}/video/{파일명}
|
||||||
Args:
|
|
||||||
file_path: 업로드할 파일 경로
|
Args:
|
||||||
|
file_content: 업로드할 파일 바이트 데이터
|
||||||
Returns:
|
file_name: 저장할 파일명 (확장자가 없으면 .mp4 추가)
|
||||||
bool: 업로드 성공 여부
|
|
||||||
|
Returns:
|
||||||
Example:
|
bool: 업로드 성공 여부
|
||||||
uploader = AzureBlobUploader(task_id="task-123")
|
|
||||||
success = await uploader.upload_video(file_path="my_video.mp4")
|
Example:
|
||||||
print(uploader.public_url)
|
uploader = AzureBlobUploader(task_id="task-123")
|
||||||
"""
|
success = await uploader.upload_video_bytes(video_bytes, "my_video")
|
||||||
return await self._upload_file(
|
print(uploader.public_url)
|
||||||
file_path=file_path,
|
"""
|
||||||
category="video",
|
# 확장자가 없으면 .mp4 추가
|
||||||
content_type="video/mp4",
|
if not Path(file_name).suffix:
|
||||||
timeout=180.0,
|
file_name = f"{file_name}.mp4"
|
||||||
log_prefix="upload_video",
|
|
||||||
)
|
upload_url = self._build_upload_url("video", file_name)
|
||||||
|
self._last_public_url = self._build_public_url("video", file_name)
|
||||||
async def upload_video_bytes(
|
log_prefix = "upload_video_bytes"
|
||||||
self, file_content: bytes, file_name: str
|
print(f"[{log_prefix}] URL (without SAS): {self._last_public_url}")
|
||||||
) -> bool:
|
|
||||||
"""영상 바이트 데이터를 Azure Blob Storage에 직접 업로드합니다.
|
headers = {"Content-Type": "video/mp4", "x-ms-blob-type": "BlockBlob"}
|
||||||
|
|
||||||
URL 경로: {task_id}/video/{파일명}
|
return await self._upload_bytes(
|
||||||
|
file_content=file_content,
|
||||||
Args:
|
upload_url=upload_url,
|
||||||
file_content: 업로드할 파일 바이트 데이터
|
headers=headers,
|
||||||
file_name: 저장할 파일명 (확장자가 없으면 .mp4 추가)
|
timeout=180.0,
|
||||||
|
log_prefix=log_prefix,
|
||||||
Returns:
|
)
|
||||||
bool: 업로드 성공 여부
|
|
||||||
|
async def upload_image(self, file_path: str) -> bool:
|
||||||
Example:
|
"""이미지 파일을 Azure Blob Storage에 업로드합니다.
|
||||||
uploader = AzureBlobUploader(task_id="task-123")
|
|
||||||
success = await uploader.upload_video_bytes(video_bytes, "my_video")
|
URL 경로: {task_id}/image/{파일명}
|
||||||
print(uploader.public_url)
|
|
||||||
"""
|
Args:
|
||||||
# 확장자가 없으면 .mp4 추가
|
file_path: 업로드할 파일 경로
|
||||||
if not Path(file_name).suffix:
|
|
||||||
file_name = f"{file_name}.mp4"
|
Returns:
|
||||||
|
bool: 업로드 성공 여부
|
||||||
upload_url = self._build_upload_url("video", file_name)
|
|
||||||
self._last_public_url = self._build_public_url("video", file_name)
|
Example:
|
||||||
log_prefix = "upload_video_bytes"
|
uploader = AzureBlobUploader(task_id="task-123")
|
||||||
print(f"[{log_prefix}] URL (without SAS): {self._last_public_url}")
|
success = await uploader.upload_image(file_path="my_image.png")
|
||||||
|
print(uploader.public_url)
|
||||||
headers = {"Content-Type": "video/mp4", "x-ms-blob-type": "BlockBlob"}
|
"""
|
||||||
|
extension = Path(file_path).suffix.lower()
|
||||||
return await self._upload_bytes(
|
content_type = self.IMAGE_CONTENT_TYPES.get(extension, "image/jpeg")
|
||||||
file_content=file_content,
|
|
||||||
upload_url=upload_url,
|
return await self._upload_file(
|
||||||
headers=headers,
|
file_path=file_path,
|
||||||
timeout=180.0,
|
category="image",
|
||||||
log_prefix=log_prefix,
|
content_type=content_type,
|
||||||
)
|
timeout=60.0,
|
||||||
|
log_prefix="upload_image",
|
||||||
async def upload_image(self, file_path: str) -> bool:
|
)
|
||||||
"""이미지 파일을 Azure Blob Storage에 업로드합니다.
|
|
||||||
|
async def upload_image_bytes(
|
||||||
URL 경로: {task_id}/image/{파일명}
|
self, file_content: bytes, file_name: str
|
||||||
|
) -> bool:
|
||||||
Args:
|
"""이미지 바이트 데이터를 Azure Blob Storage에 직접 업로드합니다.
|
||||||
file_path: 업로드할 파일 경로
|
|
||||||
|
URL 경로: {task_id}/image/{파일명}
|
||||||
Returns:
|
|
||||||
bool: 업로드 성공 여부
|
Args:
|
||||||
|
file_content: 업로드할 파일 바이트 데이터
|
||||||
Example:
|
file_name: 저장할 파일명
|
||||||
uploader = AzureBlobUploader(task_id="task-123")
|
|
||||||
success = await uploader.upload_image(file_path="my_image.png")
|
Returns:
|
||||||
print(uploader.public_url)
|
bool: 업로드 성공 여부
|
||||||
"""
|
|
||||||
extension = Path(file_path).suffix.lower()
|
Example:
|
||||||
content_type = self.IMAGE_CONTENT_TYPES.get(extension, "image/jpeg")
|
uploader = AzureBlobUploader(task_id="task-123")
|
||||||
|
with open("my_image.png", "rb") as f:
|
||||||
return await self._upload_file(
|
content = f.read()
|
||||||
file_path=file_path,
|
success = await uploader.upload_image_bytes(content, "my_image.png")
|
||||||
category="image",
|
print(uploader.public_url)
|
||||||
content_type=content_type,
|
"""
|
||||||
timeout=60.0,
|
extension = Path(file_name).suffix.lower()
|
||||||
log_prefix="upload_image",
|
content_type = self.IMAGE_CONTENT_TYPES.get(extension, "image/jpeg")
|
||||||
)
|
|
||||||
|
upload_url = self._build_upload_url("image", file_name)
|
||||||
async def upload_image_bytes(
|
self._last_public_url = self._build_public_url("image", file_name)
|
||||||
self, file_content: bytes, file_name: str
|
log_prefix = "upload_image_bytes"
|
||||||
) -> bool:
|
print(f"[{log_prefix}] URL (without SAS): {self._last_public_url}")
|
||||||
"""이미지 바이트 데이터를 Azure Blob Storage에 직접 업로드합니다.
|
|
||||||
|
headers = {"Content-Type": content_type, "x-ms-blob-type": "BlockBlob"}
|
||||||
URL 경로: {task_id}/image/{파일명}
|
|
||||||
|
return await self._upload_bytes(
|
||||||
Args:
|
file_content=file_content,
|
||||||
file_content: 업로드할 파일 바이트 데이터
|
upload_url=upload_url,
|
||||||
file_name: 저장할 파일명
|
headers=headers,
|
||||||
|
timeout=60.0,
|
||||||
Returns:
|
log_prefix=log_prefix,
|
||||||
bool: 업로드 성공 여부
|
)
|
||||||
|
|
||||||
Example:
|
|
||||||
uploader = AzureBlobUploader(task_id="task-123")
|
# 사용 예시:
|
||||||
with open("my_image.png", "rb") as f:
|
# import asyncio
|
||||||
content = f.read()
|
#
|
||||||
success = await uploader.upload_image_bytes(content, "my_image.png")
|
# async def main():
|
||||||
print(uploader.public_url)
|
# uploader = AzureBlobUploader(task_id="task-123")
|
||||||
"""
|
#
|
||||||
extension = Path(file_name).suffix.lower()
|
# # 음악 업로드 -> {BASE_URL}/task-123/song/my_song.mp3
|
||||||
content_type = self.IMAGE_CONTENT_TYPES.get(extension, "image/jpeg")
|
# await uploader.upload_music("my_song.mp3")
|
||||||
|
# print(uploader.public_url)
|
||||||
upload_url = self._build_upload_url("image", file_name)
|
#
|
||||||
self._last_public_url = self._build_public_url("image", file_name)
|
# # 영상 업로드 -> {BASE_URL}/task-123/video/my_video.mp4
|
||||||
log_prefix = "upload_image_bytes"
|
# await uploader.upload_video("my_video.mp4")
|
||||||
print(f"[{log_prefix}] URL (without SAS): {self._last_public_url}")
|
# print(uploader.public_url)
|
||||||
|
#
|
||||||
headers = {"Content-Type": content_type, "x-ms-blob-type": "BlockBlob"}
|
# # 이미지 업로드 -> {BASE_URL}/task-123/image/my_image.png
|
||||||
|
# await uploader.upload_image("my_image.png")
|
||||||
return await self._upload_bytes(
|
# print(uploader.public_url)
|
||||||
file_content=file_content,
|
#
|
||||||
upload_url=upload_url,
|
# asyncio.run(main())
|
||||||
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 sqladmin import ModelView
|
||||||
|
|
||||||
from app.video.models import Video
|
from app.video.models import Video
|
||||||
|
|
||||||
|
|
||||||
class VideoAdmin(ModelView, model=Video):
|
class VideoAdmin(ModelView, model=Video):
|
||||||
name = "영상"
|
name = "영상"
|
||||||
name_plural = "영상 목록"
|
name_plural = "영상 목록"
|
||||||
icon = "fa-solid fa-video"
|
icon = "fa-solid fa-video"
|
||||||
category = "영상 관리"
|
category = "영상 관리"
|
||||||
page_size = 20
|
page_size = 20
|
||||||
|
|
||||||
column_list = [
|
column_list = [
|
||||||
"id",
|
"id",
|
||||||
"project_id",
|
"project_id",
|
||||||
"lyric_id",
|
"lyric_id",
|
||||||
"song_id",
|
"song_id",
|
||||||
"task_id",
|
"task_id",
|
||||||
"status",
|
"status",
|
||||||
"created_at",
|
"created_at",
|
||||||
]
|
]
|
||||||
|
|
||||||
column_details_list = [
|
column_details_list = [
|
||||||
"id",
|
"id",
|
||||||
"project_id",
|
"project_id",
|
||||||
"lyric_id",
|
"lyric_id",
|
||||||
"song_id",
|
"song_id",
|
||||||
"task_id",
|
"task_id",
|
||||||
"status",
|
"status",
|
||||||
"result_movie_url",
|
"result_movie_url",
|
||||||
"created_at",
|
"created_at",
|
||||||
]
|
]
|
||||||
|
|
||||||
# 폼(생성/수정)에서 제외
|
# 폼(생성/수정)에서 제외
|
||||||
form_excluded_columns = ["created_at"]
|
form_excluded_columns = ["created_at"]
|
||||||
|
|
||||||
column_searchable_list = [
|
column_searchable_list = [
|
||||||
Video.task_id,
|
Video.task_id,
|
||||||
Video.status,
|
Video.status,
|
||||||
]
|
]
|
||||||
|
|
||||||
column_default_sort = (Video.created_at, True) # True: DESC (최신순)
|
column_default_sort = (Video.created_at, True) # True: DESC (최신순)
|
||||||
|
|
||||||
column_sortable_list = [
|
column_sortable_list = [
|
||||||
Video.id,
|
Video.id,
|
||||||
Video.project_id,
|
Video.project_id,
|
||||||
Video.lyric_id,
|
Video.lyric_id,
|
||||||
Video.song_id,
|
Video.song_id,
|
||||||
Video.status,
|
Video.status,
|
||||||
Video.created_at,
|
Video.created_at,
|
||||||
]
|
]
|
||||||
|
|
||||||
column_labels = {
|
column_labels = {
|
||||||
"id": "ID",
|
"id": "ID",
|
||||||
"project_id": "프로젝트 ID",
|
"project_id": "프로젝트 ID",
|
||||||
"lyric_id": "가사 ID",
|
"lyric_id": "가사 ID",
|
||||||
"song_id": "노래 ID",
|
"song_id": "노래 ID",
|
||||||
"task_id": "작업 ID",
|
"task_id": "작업 ID",
|
||||||
"status": "상태",
|
"status": "상태",
|
||||||
"result_movie_url": "영상 URL",
|
"result_movie_url": "영상 URL",
|
||||||
"created_at": "생성일시",
|
"created_at": "생성일시",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import Depends
|
from fastapi import Depends
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database.session import get_session
|
from app.database.session import get_session
|
||||||
|
|
||||||
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||||
|
|
|
||||||
|
|
@ -1,139 +1,139 @@
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import TYPE_CHECKING, Optional
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
|
||||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
|
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.database.session import Base
|
from app.database.session import Base
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from app.home.models import Project
|
from app.home.models import Project
|
||||||
from app.lyric.models import Lyric
|
from app.lyric.models import Lyric
|
||||||
from app.song.models import Song
|
from app.song.models import Song
|
||||||
|
|
||||||
|
|
||||||
class Video(Base):
|
class Video(Base):
|
||||||
"""
|
"""
|
||||||
영상 결과 테이블
|
영상 결과 테이블
|
||||||
|
|
||||||
최종 생성된 영상의 결과 URL을 저장합니다.
|
최종 생성된 영상의 결과 URL을 저장합니다.
|
||||||
Creatomate 서비스를 통해 이미지와 노래를 결합한 영상 결과입니다.
|
Creatomate 서비스를 통해 이미지와 노래를 결합한 영상 결과입니다.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
id: 고유 식별자 (자동 증가)
|
id: 고유 식별자 (자동 증가)
|
||||||
project_id: 연결된 Project의 id (외래키)
|
project_id: 연결된 Project의 id (외래키)
|
||||||
lyric_id: 연결된 Lyric의 id (외래키)
|
lyric_id: 연결된 Lyric의 id (외래키)
|
||||||
song_id: 연결된 Song의 id (외래키)
|
song_id: 연결된 Song의 id (외래키)
|
||||||
task_id: 영상 생성 작업의 고유 식별자 (UUID 형식)
|
task_id: 영상 생성 작업의 고유 식별자 (UUID 형식)
|
||||||
status: 처리 상태 (pending, processing, completed, failed 등)
|
status: 처리 상태 (pending, processing, completed, failed 등)
|
||||||
result_movie_url: 생성된 영상 URL (S3, CDN 경로)
|
result_movie_url: 생성된 영상 URL (S3, CDN 경로)
|
||||||
created_at: 생성 일시 (자동 설정)
|
created_at: 생성 일시 (자동 설정)
|
||||||
|
|
||||||
Relationships:
|
Relationships:
|
||||||
project: 연결된 Project
|
project: 연결된 Project
|
||||||
lyric: 연결된 Lyric
|
lyric: 연결된 Lyric
|
||||||
song: 연결된 Song
|
song: 연결된 Song
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__tablename__ = "video"
|
__tablename__ = "video"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
{
|
{
|
||||||
"mysql_engine": "InnoDB",
|
"mysql_engine": "InnoDB",
|
||||||
"mysql_charset": "utf8mb4",
|
"mysql_charset": "utf8mb4",
|
||||||
"mysql_collate": "utf8mb4_unicode_ci",
|
"mysql_collate": "utf8mb4_unicode_ci",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(
|
id: Mapped[int] = mapped_column(
|
||||||
Integer,
|
Integer,
|
||||||
primary_key=True,
|
primary_key=True,
|
||||||
nullable=False,
|
nullable=False,
|
||||||
autoincrement=True,
|
autoincrement=True,
|
||||||
comment="고유 식별자",
|
comment="고유 식별자",
|
||||||
)
|
)
|
||||||
|
|
||||||
project_id: Mapped[int] = mapped_column(
|
project_id: Mapped[int] = mapped_column(
|
||||||
Integer,
|
Integer,
|
||||||
ForeignKey("project.id", ondelete="CASCADE"),
|
ForeignKey("project.id", ondelete="CASCADE"),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
index=True,
|
index=True,
|
||||||
comment="연결된 Project의 id",
|
comment="연결된 Project의 id",
|
||||||
)
|
)
|
||||||
|
|
||||||
lyric_id: Mapped[int] = mapped_column(
|
lyric_id: Mapped[int] = mapped_column(
|
||||||
Integer,
|
Integer,
|
||||||
ForeignKey("lyric.id", ondelete="CASCADE"),
|
ForeignKey("lyric.id", ondelete="CASCADE"),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
index=True,
|
index=True,
|
||||||
comment="연결된 Lyric의 id",
|
comment="연결된 Lyric의 id",
|
||||||
)
|
)
|
||||||
|
|
||||||
song_id: Mapped[int] = mapped_column(
|
song_id: Mapped[int] = mapped_column(
|
||||||
Integer,
|
Integer,
|
||||||
ForeignKey("song.id", ondelete="CASCADE"),
|
ForeignKey("song.id", ondelete="CASCADE"),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
index=True,
|
index=True,
|
||||||
comment="연결된 Song의 id",
|
comment="연결된 Song의 id",
|
||||||
)
|
)
|
||||||
|
|
||||||
task_id: Mapped[str] = mapped_column(
|
task_id: Mapped[str] = mapped_column(
|
||||||
String(36),
|
String(36),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
index=True,
|
index=True,
|
||||||
comment="영상 생성 작업 고유 식별자 (UUID)",
|
comment="영상 생성 작업 고유 식별자 (UUID)",
|
||||||
)
|
)
|
||||||
|
|
||||||
creatomate_render_id: Mapped[Optional[str]] = mapped_column(
|
creatomate_render_id: Mapped[Optional[str]] = mapped_column(
|
||||||
String(64),
|
String(64),
|
||||||
nullable=True,
|
nullable=True,
|
||||||
comment="Creatomate API 렌더 ID",
|
comment="Creatomate API 렌더 ID",
|
||||||
)
|
)
|
||||||
|
|
||||||
status: Mapped[str] = mapped_column(
|
status: Mapped[str] = mapped_column(
|
||||||
String(50),
|
String(50),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
comment="처리 상태 (processing, completed, failed)",
|
comment="처리 상태 (processing, completed, failed)",
|
||||||
)
|
)
|
||||||
|
|
||||||
result_movie_url: Mapped[Optional[str]] = mapped_column(
|
result_movie_url: Mapped[Optional[str]] = mapped_column(
|
||||||
String(2048),
|
String(2048),
|
||||||
nullable=True,
|
nullable=True,
|
||||||
comment="생성된 영상 URL",
|
comment="생성된 영상 URL",
|
||||||
)
|
)
|
||||||
|
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime,
|
DateTime,
|
||||||
nullable=False,
|
nullable=False,
|
||||||
server_default=func.now(),
|
server_default=func.now(),
|
||||||
comment="생성 일시",
|
comment="생성 일시",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
project: Mapped["Project"] = relationship(
|
project: Mapped["Project"] = relationship(
|
||||||
"Project",
|
"Project",
|
||||||
back_populates="videos",
|
back_populates="videos",
|
||||||
)
|
)
|
||||||
|
|
||||||
lyric: Mapped["Lyric"] = relationship(
|
lyric: Mapped["Lyric"] = relationship(
|
||||||
"Lyric",
|
"Lyric",
|
||||||
back_populates="videos",
|
back_populates="videos",
|
||||||
)
|
)
|
||||||
|
|
||||||
song: Mapped["Song"] = relationship(
|
song: Mapped["Song"] = relationship(
|
||||||
"Song",
|
"Song",
|
||||||
back_populates="videos",
|
back_populates="videos",
|
||||||
)
|
)
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
def truncate(value: str | None, max_len: int = 10) -> str:
|
def truncate(value: str | None, max_len: int = 10) -> str:
|
||||||
if value is None:
|
if value is None:
|
||||||
return "None"
|
return "None"
|
||||||
return (value[:max_len] + "...") if len(value) > max_len else value
|
return (value[:max_len] + "...") if len(value) > max_len else value
|
||||||
|
|
||||||
return (
|
return (
|
||||||
f"<Video("
|
f"<Video("
|
||||||
f"id={self.id}, "
|
f"id={self.id}, "
|
||||||
f"task_id='{truncate(self.task_id)}', "
|
f"task_id='{truncate(self.task_id)}', "
|
||||||
f"status='{self.status}'"
|
f"status='{self.status}'"
|
||||||
f")>"
|
f")>"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,156 +1,156 @@
|
||||||
"""
|
"""
|
||||||
Video API Schemas
|
Video API Schemas
|
||||||
|
|
||||||
영상 생성 관련 Pydantic 스키마를 정의합니다.
|
영상 생성 관련 Pydantic 스키마를 정의합니다.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Dict, Literal, Optional
|
from typing import Any, Dict, Literal, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Response Schemas
|
# Response Schemas
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
class GenerateVideoResponse(BaseModel):
|
class GenerateVideoResponse(BaseModel):
|
||||||
"""영상 생성 응답 스키마
|
"""영상 생성 응답 스키마
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
GET /video/generate/{task_id}
|
GET /video/generate/{task_id}
|
||||||
Returns the task IDs for tracking video generation.
|
Returns the task IDs for tracking video generation.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model_config = ConfigDict(
|
model_config = ConfigDict(
|
||||||
json_schema_extra={
|
json_schema_extra={
|
||||||
"example": {
|
"example": {
|
||||||
"success": True,
|
"success": True,
|
||||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
||||||
"creatomate_render_id": "render-id-123456",
|
"creatomate_render_id": "render-id-123456",
|
||||||
"message": "영상 생성 요청이 접수되었습니다. creatomate_render_id로 상태를 조회하세요.",
|
"message": "영상 생성 요청이 접수되었습니다. creatomate_render_id로 상태를 조회하세요.",
|
||||||
"error_message": None,
|
"error_message": None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
success: bool = Field(..., description="요청 성공 여부")
|
success: bool = Field(..., description="요청 성공 여부")
|
||||||
task_id: Optional[str] = Field(None, description="내부 작업 ID (Project task_id)")
|
task_id: Optional[str] = Field(None, description="내부 작업 ID (Project task_id)")
|
||||||
creatomate_render_id: Optional[str] = Field(None, description="Creatomate 렌더 ID")
|
creatomate_render_id: Optional[str] = Field(None, description="Creatomate 렌더 ID")
|
||||||
message: str = Field(..., description="응답 메시지")
|
message: str = Field(..., description="응답 메시지")
|
||||||
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
|
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
|
||||||
|
|
||||||
|
|
||||||
class VideoRenderData(BaseModel):
|
class VideoRenderData(BaseModel):
|
||||||
"""Creatomate 렌더링 결과 데이터"""
|
"""Creatomate 렌더링 결과 데이터"""
|
||||||
|
|
||||||
id: Optional[str] = Field(None, description="렌더 ID")
|
id: Optional[str] = Field(None, description="렌더 ID")
|
||||||
status: Optional[str] = Field(None, description="렌더 상태")
|
status: Optional[str] = Field(None, description="렌더 상태")
|
||||||
url: Optional[str] = Field(None, description="영상 URL")
|
url: Optional[str] = Field(None, description="영상 URL")
|
||||||
snapshot_url: Optional[str] = Field(None, description="스냅샷 URL")
|
snapshot_url: Optional[str] = Field(None, description="스냅샷 URL")
|
||||||
|
|
||||||
|
|
||||||
class PollingVideoResponse(BaseModel):
|
class PollingVideoResponse(BaseModel):
|
||||||
"""영상 생성 상태 조회 응답 스키마
|
"""영상 생성 상태 조회 응답 스키마
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
GET /video/status/{creatomate_render_id}
|
GET /video/status/{creatomate_render_id}
|
||||||
Creatomate API 작업 상태를 조회합니다.
|
Creatomate API 작업 상태를 조회합니다.
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
상태 값:
|
상태 값:
|
||||||
- planned: 예약됨
|
- planned: 예약됨
|
||||||
- waiting: 대기 중
|
- waiting: 대기 중
|
||||||
- transcribing: 트랜스크립션 중
|
- transcribing: 트랜스크립션 중
|
||||||
- rendering: 렌더링 중
|
- rendering: 렌더링 중
|
||||||
- succeeded: 성공
|
- succeeded: 성공
|
||||||
- failed: 실패
|
- failed: 실패
|
||||||
|
|
||||||
Example Response (Success):
|
Example Response (Success):
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
"status": "succeeded",
|
"status": "succeeded",
|
||||||
"message": "영상 생성이 완료되었습니다.",
|
"message": "영상 생성이 완료되었습니다.",
|
||||||
"render_data": {
|
"render_data": {
|
||||||
"id": "render-id",
|
"id": "render-id",
|
||||||
"status": "succeeded",
|
"status": "succeeded",
|
||||||
"url": "https://...",
|
"url": "https://...",
|
||||||
"snapshot_url": "https://..."
|
"snapshot_url": "https://..."
|
||||||
},
|
},
|
||||||
"raw_response": {...},
|
"raw_response": {...},
|
||||||
"error_message": null
|
"error_message": null
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
success: bool = Field(..., description="조회 성공 여부")
|
success: bool = Field(..., description="조회 성공 여부")
|
||||||
status: Optional[str] = Field(
|
status: Optional[str] = Field(
|
||||||
None, description="작업 상태 (planned, waiting, rendering, succeeded, failed)"
|
None, description="작업 상태 (planned, waiting, rendering, succeeded, failed)"
|
||||||
)
|
)
|
||||||
message: str = Field(..., description="상태 메시지")
|
message: str = Field(..., description="상태 메시지")
|
||||||
render_data: Optional[VideoRenderData] = Field(None, description="렌더링 결과 데이터")
|
render_data: Optional[VideoRenderData] = Field(None, description="렌더링 결과 데이터")
|
||||||
raw_response: Optional[Dict[str, Any]] = Field(None, description="Creatomate API 원본 응답")
|
raw_response: Optional[Dict[str, Any]] = Field(None, description="Creatomate API 원본 응답")
|
||||||
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
|
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
|
||||||
|
|
||||||
|
|
||||||
class DownloadVideoResponse(BaseModel):
|
class DownloadVideoResponse(BaseModel):
|
||||||
"""영상 다운로드 응답 스키마
|
"""영상 다운로드 응답 스키마
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
GET /video/download/{task_id}
|
GET /video/download/{task_id}
|
||||||
Polls for video completion and returns project info with video URL.
|
Polls for video completion and returns project info with video URL.
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
상태 값:
|
상태 값:
|
||||||
- processing: 영상 생성 진행 중 (result_movie_url은 null)
|
- processing: 영상 생성 진행 중 (result_movie_url은 null)
|
||||||
- completed: 영상 생성 완료 (result_movie_url 포함)
|
- completed: 영상 생성 완료 (result_movie_url 포함)
|
||||||
- failed: 영상 생성 실패
|
- failed: 영상 생성 실패
|
||||||
- not_found: task_id에 해당하는 Video 없음
|
- not_found: task_id에 해당하는 Video 없음
|
||||||
- error: 조회 중 오류 발생
|
- error: 조회 중 오류 발생
|
||||||
|
|
||||||
Example Response (Completed):
|
Example Response (Completed):
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"message": "영상 다운로드가 완료되었습니다.",
|
"message": "영상 다운로드가 완료되었습니다.",
|
||||||
"store_name": "스테이 머뭄",
|
"store_name": "스테이 머뭄",
|
||||||
"region": "군산",
|
"region": "군산",
|
||||||
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
||||||
"result_movie_url": "http://localhost:8000/media/2025-01-15/video.mp4",
|
"result_movie_url": "http://localhost:8000/media/2025-01-15/video.mp4",
|
||||||
"created_at": "2025-01-15T12:00:00",
|
"created_at": "2025-01-15T12:00:00",
|
||||||
"error_message": null
|
"error_message": null
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
success: bool = Field(..., description="다운로드 성공 여부")
|
success: bool = Field(..., description="다운로드 성공 여부")
|
||||||
status: str = Field(..., description="처리 상태 (processing, completed, failed, not_found, error)")
|
status: str = Field(..., description="처리 상태 (processing, completed, failed, not_found, error)")
|
||||||
message: str = Field(..., description="응답 메시지")
|
message: str = Field(..., description="응답 메시지")
|
||||||
store_name: Optional[str] = Field(None, description="업체명")
|
store_name: Optional[str] = Field(None, description="업체명")
|
||||||
region: Optional[str] = Field(None, description="지역명")
|
region: Optional[str] = Field(None, description="지역명")
|
||||||
task_id: Optional[str] = Field(None, description="작업 고유 식별자")
|
task_id: Optional[str] = Field(None, description="작업 고유 식별자")
|
||||||
result_movie_url: Optional[str] = Field(None, description="영상 결과 URL")
|
result_movie_url: Optional[str] = Field(None, description="영상 결과 URL")
|
||||||
created_at: Optional[datetime] = Field(None, description="생성 일시")
|
created_at: Optional[datetime] = Field(None, description="생성 일시")
|
||||||
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
|
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
|
||||||
|
|
||||||
|
|
||||||
class VideoListItem(BaseModel):
|
class VideoListItem(BaseModel):
|
||||||
"""영상 목록 아이템 스키마
|
"""영상 목록 아이템 스키마
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
GET /videos 응답의 개별 영상 정보
|
GET /videos 응답의 개별 영상 정보
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
{
|
{
|
||||||
"store_name": "스테이 머뭄",
|
"store_name": "스테이 머뭄",
|
||||||
"region": "군산",
|
"region": "군산",
|
||||||
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
||||||
"result_movie_url": "http://localhost:8000/media/2025-01-15/video.mp4",
|
"result_movie_url": "http://localhost:8000/media/2025-01-15/video.mp4",
|
||||||
"created_at": "2025-01-15T12:00:00"
|
"created_at": "2025-01-15T12:00:00"
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
store_name: Optional[str] = Field(None, description="업체명")
|
store_name: Optional[str] = Field(None, description="업체명")
|
||||||
region: Optional[str] = Field(None, description="지역명")
|
region: Optional[str] = Field(None, description="지역명")
|
||||||
task_id: str = Field(..., description="작업 고유 식별자")
|
task_id: str = Field(..., description="작업 고유 식별자")
|
||||||
result_movie_url: Optional[str] = Field(None, description="영상 결과 URL")
|
result_movie_url: Optional[str] = Field(None, description="영상 결과 URL")
|
||||||
created_at: Optional[datetime] = Field(None, description="생성 일시")
|
created_at: Optional[datetime] = Field(None, description="생성 일시")
|
||||||
|
|
|
||||||
|
|
@ -1,333 +1,242 @@
|
||||||
"""
|
"""
|
||||||
Video Background Tasks
|
Video Background Tasks
|
||||||
|
|
||||||
영상 생성 관련 백그라운드 태스크를 정의합니다.
|
영상 생성 관련 백그라운드 태스크를 정의합니다.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
from pathlib import Path
|
||||||
import traceback
|
|
||||||
from pathlib import Path
|
import aiofiles
|
||||||
|
import httpx
|
||||||
import aiofiles
|
from sqlalchemy import select
|
||||||
import httpx
|
|
||||||
from sqlalchemy import select
|
from app.database.session import BackgroundSessionLocal
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
from app.video.models import Video
|
||||||
|
from app.utils.upload_blob_as_request import AzureBlobUploader
|
||||||
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,
|
||||||
logger = logging.getLogger(__name__)
|
store_name: str,
|
||||||
|
) -> None:
|
||||||
# HTTP 요청 설정
|
"""백그라운드에서 영상을 다운로드하고 Azure Blob Storage에 업로드한 뒤 Video 테이블을 업데이트합니다.
|
||||||
REQUEST_TIMEOUT = 300.0 # 초 (영상은 용량이 크므로 5분)
|
|
||||||
|
Args:
|
||||||
|
task_id: 프로젝트 task_id
|
||||||
async def _update_video_status(
|
video_url: 다운로드할 영상 URL
|
||||||
task_id: str,
|
store_name: 저장할 파일명에 사용할 업체명
|
||||||
status: str,
|
"""
|
||||||
video_url: str | None = None,
|
print(f"[download_and_upload_video_to_blob] START - task_id: {task_id}, store_name: {store_name}")
|
||||||
creatomate_render_id: str | None = None,
|
temp_file_path: Path | None = None
|
||||||
) -> bool:
|
|
||||||
"""Video 테이블의 상태를 업데이트합니다.
|
try:
|
||||||
|
# 파일명에 사용할 수 없는 문자 제거
|
||||||
Args:
|
safe_store_name = "".join(
|
||||||
task_id: 프로젝트 task_id
|
c for c in store_name if c.isalnum() or c in (" ", "_", "-")
|
||||||
status: 변경할 상태 ("processing", "completed", "failed")
|
).strip()
|
||||||
video_url: 영상 URL
|
safe_store_name = safe_store_name or "video"
|
||||||
creatomate_render_id: Creatomate render ID (선택)
|
file_name = f"{safe_store_name}.mp4"
|
||||||
|
|
||||||
Returns:
|
# 임시 저장 경로 생성
|
||||||
bool: 업데이트 성공 여부
|
temp_dir = Path("media") / "temp" / task_id
|
||||||
"""
|
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||||
try:
|
temp_file_path = temp_dir / file_name
|
||||||
async with BackgroundSessionLocal() as session:
|
print(f"[download_and_upload_video_to_blob] Temp directory created - path: {temp_file_path}")
|
||||||
if creatomate_render_id:
|
|
||||||
query_result = await session.execute(
|
# 영상 파일 다운로드
|
||||||
select(Video)
|
print(f"[download_and_upload_video_to_blob] Downloading video - task_id: {task_id}, url: {video_url}")
|
||||||
.where(Video.creatomate_render_id == creatomate_render_id)
|
async with httpx.AsyncClient() as client:
|
||||||
.order_by(Video.created_at.desc())
|
response = await client.get(video_url, timeout=180.0)
|
||||||
.limit(1)
|
response.raise_for_status()
|
||||||
)
|
|
||||||
else:
|
async with aiofiles.open(str(temp_file_path), "wb") as f:
|
||||||
query_result = await session.execute(
|
await f.write(response.content)
|
||||||
select(Video)
|
print(f"[download_and_upload_video_to_blob] File downloaded - task_id: {task_id}, path: {temp_file_path}")
|
||||||
.where(Video.task_id == task_id)
|
|
||||||
.order_by(Video.created_at.desc())
|
# Azure Blob Storage에 업로드
|
||||||
.limit(1)
|
uploader = AzureBlobUploader(task_id=task_id)
|
||||||
)
|
upload_success = await uploader.upload_video(file_path=str(temp_file_path))
|
||||||
|
|
||||||
video = query_result.scalar_one_or_none()
|
if not upload_success:
|
||||||
|
raise Exception("Azure Blob Storage 업로드 실패")
|
||||||
if video:
|
|
||||||
video.status = status
|
# SAS 토큰이 제외된 public_url 사용
|
||||||
if video_url is not None:
|
blob_url = uploader.public_url
|
||||||
video.result_movie_url = video_url
|
print(f"[download_and_upload_video_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}")
|
||||||
await session.commit()
|
|
||||||
logger.info(f"[Video] Status updated - task_id: {task_id}, status: {status}")
|
# Video 테이블 업데이트 (새 세션 사용)
|
||||||
print(f"[Video] Status updated - task_id: {task_id}, status: {status}")
|
async with BackgroundSessionLocal() as session:
|
||||||
return True
|
# 여러 개 있을 경우 가장 최근 것 선택
|
||||||
else:
|
result = await session.execute(
|
||||||
logger.warning(f"[Video] NOT FOUND in DB - task_id: {task_id}")
|
select(Video)
|
||||||
print(f"[Video] NOT FOUND in DB - task_id: {task_id}")
|
.where(Video.task_id == task_id)
|
||||||
return False
|
.order_by(Video.created_at.desc())
|
||||||
|
.limit(1)
|
||||||
except SQLAlchemyError as e:
|
)
|
||||||
logger.error(f"[Video] DB Error while updating status - task_id: {task_id}, error: {e}")
|
video = result.scalar_one_or_none()
|
||||||
print(f"[Video] DB Error while updating status - task_id: {task_id}, error: {e}")
|
|
||||||
return False
|
if video:
|
||||||
except Exception as e:
|
video.status = "completed"
|
||||||
logger.error(f"[Video] Unexpected error while updating status - task_id: {task_id}, error: {e}")
|
video.result_movie_url = blob_url
|
||||||
print(f"[Video] Unexpected error while updating status - task_id: {task_id}, error: {e}")
|
await session.commit()
|
||||||
return False
|
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}")
|
||||||
async def _download_video(url: str, task_id: str) -> bytes:
|
|
||||||
"""URL에서 영상을 다운로드합니다.
|
except Exception as e:
|
||||||
|
print(f"[download_and_upload_video_to_blob] EXCEPTION - task_id: {task_id}, error: {e}")
|
||||||
Args:
|
# 실패 시 Video 테이블 업데이트
|
||||||
url: 다운로드할 URL
|
async with BackgroundSessionLocal() as session:
|
||||||
task_id: 로그용 task_id
|
result = await session.execute(
|
||||||
|
select(Video)
|
||||||
Returns:
|
.where(Video.task_id == task_id)
|
||||||
bytes: 다운로드한 파일 내용
|
.order_by(Video.created_at.desc())
|
||||||
|
.limit(1)
|
||||||
Raises:
|
)
|
||||||
httpx.HTTPError: 다운로드 실패 시
|
video = result.scalar_one_or_none()
|
||||||
"""
|
|
||||||
logger.info(f"[VideoDownload] Downloading - task_id: {task_id}")
|
if video:
|
||||||
print(f"[VideoDownload] Downloading - task_id: {task_id}")
|
video.status = "failed"
|
||||||
|
await session.commit()
|
||||||
async with httpx.AsyncClient() as client:
|
print(f"[download_and_upload_video_to_blob] FAILED - task_id: {task_id}, status updated to failed")
|
||||||
response = await client.get(url, timeout=REQUEST_TIMEOUT)
|
|
||||||
response.raise_for_status()
|
finally:
|
||||||
|
# 임시 파일 삭제
|
||||||
logger.info(f"[VideoDownload] SUCCESS - task_id: {task_id}, size: {len(response.content)} bytes")
|
if temp_file_path and temp_file_path.exists():
|
||||||
print(f"[VideoDownload] SUCCESS - task_id: {task_id}, size: {len(response.content)} bytes")
|
try:
|
||||||
return response.content
|
temp_file_path.unlink()
|
||||||
|
print(f"[download_and_upload_video_to_blob] Temp file deleted - path: {temp_file_path}")
|
||||||
|
except Exception as e:
|
||||||
async def download_and_upload_video_to_blob(
|
print(f"[download_and_upload_video_to_blob] Failed to delete temp file: {e}")
|
||||||
task_id: str,
|
|
||||||
video_url: str,
|
# 임시 디렉토리 삭제 시도
|
||||||
store_name: str,
|
temp_dir = Path("media") / "temp" / task_id
|
||||||
) -> None:
|
if temp_dir.exists():
|
||||||
"""백그라운드에서 영상을 다운로드하고 Azure Blob Storage에 업로드한 뒤 Video 테이블을 업데이트합니다.
|
try:
|
||||||
|
temp_dir.rmdir()
|
||||||
Args:
|
except Exception:
|
||||||
task_id: 프로젝트 task_id
|
pass # 디렉토리가 비어있지 않으면 무시
|
||||||
video_url: 다운로드할 영상 URL
|
|
||||||
store_name: 저장할 파일명에 사용할 업체명
|
|
||||||
"""
|
async def download_and_upload_video_by_creatomate_render_id(
|
||||||
logger.info(f"[download_and_upload_video_to_blob] START - task_id: {task_id}, store_name: {store_name}")
|
creatomate_render_id: str,
|
||||||
print(f"[download_and_upload_video_to_blob] START - task_id: {task_id}, store_name: {store_name}")
|
video_url: str,
|
||||||
temp_file_path: Path | None = None
|
store_name: str,
|
||||||
|
) -> None:
|
||||||
try:
|
"""creatomate_render_id로 Video를 조회하여 영상을 다운로드하고 Azure Blob Storage에 업로드한 뒤 Video 테이블을 업데이트합니다.
|
||||||
# 파일명에 사용할 수 없는 문자 제거
|
|
||||||
safe_store_name = "".join(
|
Args:
|
||||||
c for c in store_name if c.isalnum() or c in (" ", "_", "-")
|
creatomate_render_id: Creatomate API 렌더 ID
|
||||||
).strip()
|
video_url: 다운로드할 영상 URL
|
||||||
safe_store_name = safe_store_name or "video"
|
store_name: 저장할 파일명에 사용할 업체명
|
||||||
file_name = f"{safe_store_name}.mp4"
|
"""
|
||||||
|
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
|
||||||
temp_dir = Path("media") / "temp" / task_id
|
task_id: str | None = None
|
||||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
temp_file_path = temp_dir / file_name
|
try:
|
||||||
logger.info(f"[download_and_upload_video_to_blob] Temp directory created - path: {temp_file_path}")
|
# creatomate_render_id로 Video 조회하여 task_id 가져오기
|
||||||
print(f"[download_and_upload_video_to_blob] Temp directory created - path: {temp_file_path}")
|
async with BackgroundSessionLocal() as session:
|
||||||
|
result = await session.execute(
|
||||||
# 영상 파일 다운로드
|
select(Video)
|
||||||
logger.info(f"[download_and_upload_video_to_blob] Downloading video - task_id: {task_id}, url: {video_url}")
|
.where(Video.creatomate_render_id == creatomate_render_id)
|
||||||
print(f"[download_and_upload_video_to_blob] Downloading video - task_id: {task_id}, url: {video_url}")
|
.order_by(Video.created_at.desc())
|
||||||
|
.limit(1)
|
||||||
content = await _download_video(video_url, task_id)
|
)
|
||||||
|
video = result.scalar_one_or_none()
|
||||||
async with aiofiles.open(str(temp_file_path), "wb") as f:
|
|
||||||
await f.write(content)
|
if not video:
|
||||||
|
print(f"[download_and_upload_video_by_creatomate_render_id] Video NOT FOUND - creatomate_render_id: {creatomate_render_id}")
|
||||||
logger.info(f"[download_and_upload_video_to_blob] File downloaded - task_id: {task_id}, path: {temp_file_path}")
|
return
|
||||||
print(f"[download_and_upload_video_to_blob] File downloaded - task_id: {task_id}, path: {temp_file_path}")
|
|
||||||
|
task_id = video.task_id
|
||||||
# Azure Blob Storage에 업로드
|
print(f"[download_and_upload_video_by_creatomate_render_id] Video found - creatomate_render_id: {creatomate_render_id}, task_id: {task_id}")
|
||||||
uploader = AzureBlobUploader(task_id=task_id)
|
|
||||||
upload_success = await uploader.upload_video(file_path=str(temp_file_path))
|
# 파일명에 사용할 수 없는 문자 제거
|
||||||
|
safe_store_name = "".join(
|
||||||
if not upload_success:
|
c for c in store_name if c.isalnum() or c in (" ", "_", "-")
|
||||||
raise Exception("Azure Blob Storage 업로드 실패")
|
).strip()
|
||||||
|
safe_store_name = safe_store_name or "video"
|
||||||
# SAS 토큰이 제외된 public_url 사용
|
file_name = f"{safe_store_name}.mp4"
|
||||||
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}")
|
temp_dir = Path("media") / "temp" / task_id
|
||||||
|
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||||
# Video 테이블 업데이트
|
temp_file_path = temp_dir / file_name
|
||||||
await _update_video_status(task_id, "completed", blob_url)
|
print(f"[download_and_upload_video_by_creatomate_render_id] Temp directory created - path: {temp_file_path}")
|
||||||
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}")
|
# 영상 파일 다운로드
|
||||||
|
print(f"[download_and_upload_video_by_creatomate_render_id] Downloading video - creatomate_render_id: {creatomate_render_id}, url: {video_url}")
|
||||||
except httpx.HTTPError as e:
|
async with httpx.AsyncClient() as client:
|
||||||
logger.error(f"[download_and_upload_video_to_blob] DOWNLOAD ERROR - task_id: {task_id}, error: {e}")
|
response = await client.get(video_url, timeout=180.0)
|
||||||
print(f"[download_and_upload_video_to_blob] DOWNLOAD ERROR - task_id: {task_id}, error: {e}")
|
response.raise_for_status()
|
||||||
traceback.print_exc()
|
|
||||||
await _update_video_status(task_id, "failed")
|
async with aiofiles.open(str(temp_file_path), "wb") as f:
|
||||||
|
await f.write(response.content)
|
||||||
except SQLAlchemyError as e:
|
print(f"[download_and_upload_video_by_creatomate_render_id] File downloaded - creatomate_render_id: {creatomate_render_id}, path: {temp_file_path}")
|
||||||
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}")
|
# Azure Blob Storage에 업로드
|
||||||
traceback.print_exc()
|
uploader = AzureBlobUploader(task_id=task_id)
|
||||||
await _update_video_status(task_id, "failed")
|
upload_success = await uploader.upload_video(file_path=str(temp_file_path))
|
||||||
|
|
||||||
except Exception as e:
|
if not upload_success:
|
||||||
logger.error(f"[download_and_upload_video_to_blob] EXCEPTION - task_id: {task_id}, error: {e}")
|
raise Exception("Azure Blob Storage 업로드 실패")
|
||||||
print(f"[download_and_upload_video_to_blob] EXCEPTION - task_id: {task_id}, error: {e}")
|
|
||||||
traceback.print_exc()
|
# SAS 토큰이 제외된 public_url 사용
|
||||||
await _update_video_status(task_id, "failed")
|
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}")
|
||||||
finally:
|
|
||||||
# 임시 파일 삭제
|
# Video 테이블 업데이트 (새 세션 사용)
|
||||||
if temp_file_path and temp_file_path.exists():
|
async with BackgroundSessionLocal() as session:
|
||||||
try:
|
result = await session.execute(
|
||||||
temp_file_path.unlink()
|
select(Video)
|
||||||
logger.info(f"[download_and_upload_video_to_blob] Temp file deleted - path: {temp_file_path}")
|
.where(Video.creatomate_render_id == creatomate_render_id)
|
||||||
print(f"[download_and_upload_video_to_blob] Temp file deleted - path: {temp_file_path}")
|
.order_by(Video.created_at.desc())
|
||||||
except Exception as e:
|
.limit(1)
|
||||||
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}")
|
video = result.scalar_one_or_none()
|
||||||
|
|
||||||
# 임시 디렉토리 삭제 시도
|
if video:
|
||||||
temp_dir = Path("media") / "temp" / task_id
|
video.status = "completed"
|
||||||
if temp_dir.exists():
|
video.result_movie_url = blob_url
|
||||||
try:
|
await session.commit()
|
||||||
temp_dir.rmdir()
|
print(f"[download_and_upload_video_by_creatomate_render_id] SUCCESS - creatomate_render_id: {creatomate_render_id}, status: completed")
|
||||||
except Exception:
|
else:
|
||||||
pass # 디렉토리가 비어있지 않으면 무시
|
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:
|
||||||
async def download_and_upload_video_by_creatomate_render_id(
|
print(f"[download_and_upload_video_by_creatomate_render_id] EXCEPTION - creatomate_render_id: {creatomate_render_id}, error: {e}")
|
||||||
creatomate_render_id: str,
|
# 실패 시 Video 테이블 업데이트
|
||||||
video_url: str,
|
if task_id:
|
||||||
store_name: str,
|
async with BackgroundSessionLocal() as session:
|
||||||
) -> None:
|
result = await session.execute(
|
||||||
"""creatomate_render_id로 Video를 조회하여 영상을 다운로드하고 Azure Blob Storage에 업로드한 뒤 Video 테이블을 업데이트합니다.
|
select(Video)
|
||||||
|
.where(Video.creatomate_render_id == creatomate_render_id)
|
||||||
Args:
|
.order_by(Video.created_at.desc())
|
||||||
creatomate_render_id: Creatomate API 렌더 ID
|
.limit(1)
|
||||||
video_url: 다운로드할 영상 URL
|
)
|
||||||
store_name: 저장할 파일명에 사용할 업체명
|
video = result.scalar_one_or_none()
|
||||||
"""
|
|
||||||
logger.info(f"[download_and_upload_video_by_creatomate_render_id] START - creatomate_render_id: {creatomate_render_id}, store_name: {store_name}")
|
if video:
|
||||||
print(f"[download_and_upload_video_by_creatomate_render_id] START - creatomate_render_id: {creatomate_render_id}, store_name: {store_name}")
|
video.status = "failed"
|
||||||
temp_file_path: Path | None = None
|
await session.commit()
|
||||||
task_id: str | None = None
|
print(f"[download_and_upload_video_by_creatomate_render_id] FAILED - creatomate_render_id: {creatomate_render_id}, status updated to failed")
|
||||||
|
|
||||||
try:
|
finally:
|
||||||
# creatomate_render_id로 Video 조회하여 task_id 가져오기
|
# 임시 파일 삭제
|
||||||
async with BackgroundSessionLocal() as session:
|
if temp_file_path and temp_file_path.exists():
|
||||||
result = await session.execute(
|
try:
|
||||||
select(Video)
|
temp_file_path.unlink()
|
||||||
.where(Video.creatomate_render_id == creatomate_render_id)
|
print(f"[download_and_upload_video_by_creatomate_render_id] Temp file deleted - path: {temp_file_path}")
|
||||||
.order_by(Video.created_at.desc())
|
except Exception as e:
|
||||||
.limit(1)
|
print(f"[download_and_upload_video_by_creatomate_render_id] Failed to delete temp file: {e}")
|
||||||
)
|
|
||||||
video = result.scalar_one_or_none()
|
# 임시 디렉토리 삭제 시도
|
||||||
|
if task_id:
|
||||||
if not video:
|
temp_dir = Path("media") / "temp" / task_id
|
||||||
logger.warning(f"[download_and_upload_video_by_creatomate_render_id] Video NOT FOUND - creatomate_render_id: {creatomate_render_id}")
|
if temp_dir.exists():
|
||||||
print(f"[download_and_upload_video_by_creatomate_render_id] Video NOT FOUND - creatomate_render_id: {creatomate_render_id}")
|
try:
|
||||||
return
|
temp_dir.rmdir()
|
||||||
|
except Exception:
|
||||||
task_id = video.task_id
|
pass # 디렉토리가 비어있지 않으면 무시
|
||||||
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 pathlib import Path
|
||||||
|
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
PROJECT_DIR = Path(__file__).resolve().parent
|
PROJECT_DIR = Path(__file__).resolve().parent
|
||||||
|
|
||||||
_base_config = SettingsConfigDict(
|
_base_config = SettingsConfigDict(
|
||||||
env_file=PROJECT_DIR / ".env",
|
env_file=PROJECT_DIR / ".env",
|
||||||
env_ignore_empty=True,
|
env_ignore_empty=True,
|
||||||
extra="ignore",
|
extra="ignore",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ProjectSettings(BaseSettings):
|
class ProjectSettings(BaseSettings):
|
||||||
PROJECT_NAME: str = Field(default="CastAD")
|
PROJECT_NAME: str = Field(default="CastAD")
|
||||||
PROJECT_DOMAIN: str = Field(default="localhost:8000")
|
PROJECT_DOMAIN: str = Field(default="localhost:8000")
|
||||||
VERSION: str = Field(default="0.1.0")
|
VERSION: str = Field(default="0.1.0")
|
||||||
DESCRIPTION: str = Field(default="FastAPI 기반 CastAD 프로젝트")
|
DESCRIPTION: str = Field(default="FastAPI 기반 CastAD 프로젝트")
|
||||||
ADMIN_BASE_URL: str = Field(default="/admin")
|
ADMIN_BASE_URL: str = Field(default="/admin")
|
||||||
DEBUG: bool = Field(default=True)
|
DEBUG: bool = Field(default=True)
|
||||||
|
|
||||||
model_config = _base_config
|
model_config = _base_config
|
||||||
|
|
||||||
|
|
||||||
class APIKeySettings(BaseSettings):
|
class APIKeySettings(BaseSettings):
|
||||||
CHATGPT_API_KEY: str = Field(default="your-chatgpt-api-key") # 기본값 추가
|
CHATGPT_API_KEY: str = Field(default="your-chatgpt-api-key") # 기본값 추가
|
||||||
SUNO_API_KEY: str = Field(default="your-suno-api-key") # Suno API 키
|
SUNO_API_KEY: str = Field(default="your-suno-api-key") # Suno API 키
|
||||||
SUNO_CALLBACK_URL: str = Field(
|
SUNO_CALLBACK_URL: str = Field(
|
||||||
default="https://example.com/api/suno/callback"
|
default="https://example.com/api/suno/callback"
|
||||||
) # Suno 콜백 URL (필수)
|
) # Suno 콜백 URL (필수)
|
||||||
CREATOMATE_API_KEY: str = Field(default="your-creatomate-api-key") # Creatomate API 키
|
CREATOMATE_API_KEY: str = Field(default="your-creatomate-api-key") # Creatomate API 키
|
||||||
|
|
||||||
model_config = _base_config
|
model_config = _base_config
|
||||||
|
|
||||||
|
|
||||||
class CORSSettings(BaseSettings):
|
class CORSSettings(BaseSettings):
|
||||||
# CORS (Cross-Origin Resource Sharing) 설정
|
# CORS (Cross-Origin Resource Sharing) 설정
|
||||||
|
|
||||||
# 요청을 허용할 출처(Origin) 목록
|
# 요청을 허용할 출처(Origin) 목록
|
||||||
# ["*"]: 모든 출처 허용 (개발 환경용, 프로덕션에서는 구체적인 도메인 지정 권장)
|
# ["*"]: 모든 출처 허용 (개발 환경용, 프로덕션에서는 구체적인 도메인 지정 권장)
|
||||||
# 예: ["https://example.com", "https://app.example.com"]
|
# 예: ["https://example.com", "https://app.example.com"]
|
||||||
CORS_ALLOW_ORIGINS: list[str] = ["*"]
|
CORS_ALLOW_ORIGINS: list[str] = ["*"]
|
||||||
|
|
||||||
# 자격 증명(쿠키, Authorization 헤더 등) 포함 요청 허용 여부
|
# 자격 증명(쿠키, Authorization 헤더 등) 포함 요청 허용 여부
|
||||||
# True: 클라이언트가 credentials: 'include'로 요청 시 쿠키/인증 정보 전송 가능
|
# True: 클라이언트가 credentials: 'include'로 요청 시 쿠키/인증 정보 전송 가능
|
||||||
# 주의: CORS_ALLOW_ORIGINS가 ["*"]일 때는 보안상 False 권장
|
# 주의: CORS_ALLOW_ORIGINS가 ["*"]일 때는 보안상 False 권장
|
||||||
CORS_ALLOW_CREDENTIALS: bool = True
|
CORS_ALLOW_CREDENTIALS: bool = True
|
||||||
|
|
||||||
# 허용할 HTTP 메서드 목록
|
# 허용할 HTTP 메서드 목록
|
||||||
# ["*"]: 모든 메서드 허용 (GET, POST, PUT, DELETE, PATCH, OPTIONS 등)
|
# ["*"]: 모든 메서드 허용 (GET, POST, PUT, DELETE, PATCH, OPTIONS 등)
|
||||||
# 구체적 지정 예: ["GET", "POST", "PUT", "DELETE"]
|
# 구체적 지정 예: ["GET", "POST", "PUT", "DELETE"]
|
||||||
CORS_ALLOW_METHODS: list[str] = ["*"]
|
CORS_ALLOW_METHODS: list[str] = ["*"]
|
||||||
|
|
||||||
# 클라이언트가 요청 시 사용할 수 있는 HTTP 헤더 목록
|
# 클라이언트가 요청 시 사용할 수 있는 HTTP 헤더 목록
|
||||||
# ["*"]: 모든 헤더 허용
|
# ["*"]: 모든 헤더 허용
|
||||||
# 구체적 지정 예: ["Content-Type", "Authorization", "X-Custom-Header"]
|
# 구체적 지정 예: ["Content-Type", "Authorization", "X-Custom-Header"]
|
||||||
CORS_ALLOW_HEADERS: list[str] = ["*"]
|
CORS_ALLOW_HEADERS: list[str] = ["*"]
|
||||||
|
|
||||||
# 브라우저의 JavaScript에서 접근 가능한 응답 헤더 목록
|
# 브라우저의 JavaScript에서 접근 가능한 응답 헤더 목록
|
||||||
# []: 기본 안전 헤더(Cache-Control, Content-Language, Content-Type,
|
# []: 기본 안전 헤더(Cache-Control, Content-Language, Content-Type,
|
||||||
# Expires, Last-Modified, Pragma)만 접근 가능
|
# Expires, Last-Modified, Pragma)만 접근 가능
|
||||||
# 추가 노출 필요 시: ["X-Total-Count", "X-Request-Id", "X-Custom-Header"]
|
# 추가 노출 필요 시: ["X-Total-Count", "X-Request-Id", "X-Custom-Header"]
|
||||||
CORS_EXPOSE_HEADERS: list[str] = []
|
CORS_EXPOSE_HEADERS: list[str] = []
|
||||||
|
|
||||||
# Preflight 요청(OPTIONS) 결과를 캐시하는 시간(초)
|
# Preflight 요청(OPTIONS) 결과를 캐시하는 시간(초)
|
||||||
# 600: 10분간 캐시 (이 시간 동안 동일 요청에 대해 preflight 생략)
|
# 600: 10분간 캐시 (이 시간 동안 동일 요청에 대해 preflight 생략)
|
||||||
# 0으로 설정 시 매번 preflight 요청 발생
|
# 0으로 설정 시 매번 preflight 요청 발생
|
||||||
CORS_MAX_AGE: int = 600
|
CORS_MAX_AGE: int = 600
|
||||||
|
|
||||||
model_config = _base_config
|
model_config = _base_config
|
||||||
|
|
||||||
|
|
||||||
class DatabaseSettings(BaseSettings):
|
class DatabaseSettings(BaseSettings):
|
||||||
# MySQL 연결 설정 (기본값: 테스트 계정 및 poc DB)
|
# MySQL 연결 설정 (기본값: 테스트 계정 및 poc DB)
|
||||||
MYSQL_HOST: str = Field(default="localhost")
|
MYSQL_HOST: str = Field(default="localhost")
|
||||||
MYSQL_PORT: int = Field(default=3306)
|
MYSQL_PORT: int = Field(default=3306)
|
||||||
MYSQL_USER: str = Field(default="test")
|
MYSQL_USER: str = Field(default="test")
|
||||||
MYSQL_PASSWORD: str = Field(default="") # 환경변수에서 로드
|
MYSQL_PASSWORD: str = Field(default="") # 환경변수에서 로드
|
||||||
MYSQL_DB: str = Field(default="poc")
|
MYSQL_DB: str = Field(default="poc")
|
||||||
|
|
||||||
# Redis 설정
|
# Redis 설정
|
||||||
REDIS_HOST: str = "localhost"
|
REDIS_HOST: str = "localhost"
|
||||||
REDIS_PORT: int = 6379
|
REDIS_PORT: int = 6379
|
||||||
|
|
||||||
model_config = _base_config
|
model_config = _base_config
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def MYSQL_URL(self) -> str:
|
def MYSQL_URL(self) -> str:
|
||||||
"""비동기 MySQL URL 생성 (asyncmy 드라이버 사용, SQLAlchemy 통합 최적화)"""
|
"""비동기 MySQL URL 생성 (asyncmy 드라이버 사용, SQLAlchemy 통합 최적화)"""
|
||||||
return f"mysql+asyncmy://{self.MYSQL_USER}:{self.MYSQL_PASSWORD}@{self.MYSQL_HOST}:{self.MYSQL_PORT}/{self.MYSQL_DB}"
|
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:
|
def REDIS_URL(self, db: int = 0) -> str:
|
||||||
"""Redis URL 생성 (db 인수로 기본값 지원)"""
|
"""Redis URL 생성 (db 인수로 기본값 지원)"""
|
||||||
return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{db}"
|
return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{db}"
|
||||||
|
|
||||||
|
|
||||||
class SecuritySettings(BaseSettings):
|
class SecuritySettings(BaseSettings):
|
||||||
JWT_SECRET: str = "your-jwt-secret-key" # 기본값 추가 (필수 필드 안전)
|
JWT_SECRET: str = "your-jwt-secret-key" # 기본값 추가 (필수 필드 안전)
|
||||||
JWT_ALGORITHM: str = "HS256" # 기본값 추가 (필수 필드 안전)
|
JWT_ALGORITHM: str = "HS256" # 기본값 추가 (필수 필드 안전)
|
||||||
|
|
||||||
model_config = _base_config
|
model_config = _base_config
|
||||||
|
|
||||||
|
|
||||||
class NotificationSettings(BaseSettings):
|
class NotificationSettings(BaseSettings):
|
||||||
MAIL_USERNAME: str = "your-email@example.com" # 기본값 추가
|
MAIL_USERNAME: str = "your-email@example.com" # 기본값 추가
|
||||||
MAIL_PASSWORD: str = "your-email-password" # 기본값 추가
|
MAIL_PASSWORD: str = "your-email-password" # 기본값 추가
|
||||||
MAIL_FROM: str = "your-email@example.com" # 기본값 추가
|
MAIL_FROM: str = "your-email@example.com" # 기본값 추가
|
||||||
MAIL_PORT: int = 587 # 기본값 추가
|
MAIL_PORT: int = 587 # 기본값 추가
|
||||||
MAIL_SERVER: str = "smtp.gmail.com" # 기본값 추가
|
MAIL_SERVER: str = "smtp.gmail.com" # 기본값 추가
|
||||||
MAIL_FROM_NAME: str = "FastPOC App" # 기본값 추가
|
MAIL_FROM_NAME: str = "FastPOC App" # 기본값 추가
|
||||||
MAIL_STARTTLS: bool = True
|
MAIL_STARTTLS: bool = True
|
||||||
MAIL_SSL_TLS: bool = False
|
MAIL_SSL_TLS: bool = False
|
||||||
USE_CREDENTIALS: bool = True
|
USE_CREDENTIALS: bool = True
|
||||||
VALIDATE_CERTS: bool = True
|
VALIDATE_CERTS: bool = True
|
||||||
|
|
||||||
TWILIO_SID: str = "your-twilio-sid" # 기본값 추가
|
TWILIO_SID: str = "your-twilio-sid" # 기본값 추가
|
||||||
TWILIO_AUTH_TOKEN: str = "your-twilio-token" # 기본값 추가
|
TWILIO_AUTH_TOKEN: str = "your-twilio-token" # 기본값 추가
|
||||||
TWILIO_NUMBER: str = "+1234567890" # 기본값 추가
|
TWILIO_NUMBER: str = "+1234567890" # 기본값 추가
|
||||||
|
|
||||||
model_config = _base_config
|
model_config = _base_config
|
||||||
|
|
||||||
|
|
||||||
class CrawlerSettings(BaseSettings):
|
class CrawlerSettings(BaseSettings):
|
||||||
NAVER_COOKIES: str = Field(default="")
|
NAVER_COOKIES: str = Field(default="")
|
||||||
|
|
||||||
model_config = _base_config
|
model_config = _base_config
|
||||||
|
|
||||||
|
|
||||||
class AzureBlobSettings(BaseSettings):
|
class AzureBlobSettings(BaseSettings):
|
||||||
"""Azure Blob Storage 설정"""
|
"""Azure Blob Storage 설정"""
|
||||||
|
|
||||||
AZURE_BLOB_SAS_TOKEN: str = Field(
|
AZURE_BLOB_SAS_TOKEN: str = Field(
|
||||||
default="",
|
default="",
|
||||||
description="Azure Blob Storage SAS 토큰",
|
description="Azure Blob Storage SAS 토큰",
|
||||||
)
|
)
|
||||||
AZURE_BLOB_BASE_URL: str = Field(
|
AZURE_BLOB_BASE_URL: str = Field(
|
||||||
default="https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original",
|
default="https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original",
|
||||||
description="Azure Blob Storage 기본 URL",
|
description="Azure Blob Storage 기본 URL",
|
||||||
)
|
)
|
||||||
|
|
||||||
model_config = _base_config
|
model_config = _base_config
|
||||||
|
|
||||||
|
|
||||||
class CreatomateSettings(BaseSettings):
|
class CreatomateSettings(BaseSettings):
|
||||||
"""Creatomate 템플릿 설정"""
|
"""Creatomate 템플릿 설정"""
|
||||||
|
|
||||||
# 세로형 템플릿 (기본값)
|
# 세로형 템플릿 (기본값)
|
||||||
TEMPLATE_ID_VERTICAL: str = Field(
|
TEMPLATE_ID_VERTICAL: str = Field(
|
||||||
default="e8c7b43f-de4b-4ba3-b8eb-5df688569193",
|
default="e8c7b43f-de4b-4ba3-b8eb-5df688569193",
|
||||||
description="Creatomate 세로형 템플릿 ID",
|
description="Creatomate 세로형 템플릿 ID",
|
||||||
)
|
)
|
||||||
TEMPLATE_DURATION_VERTICAL: float = Field(
|
TEMPLATE_DURATION_VERTICAL: float = Field(
|
||||||
default=90.0,
|
default=90.0,
|
||||||
description="세로형 템플릿 기본 duration (초)",
|
description="세로형 템플릿 기본 duration (초)",
|
||||||
)
|
)
|
||||||
|
|
||||||
# 가로형 템플릿
|
# 가로형 템플릿
|
||||||
TEMPLATE_ID_HORIZONTAL: str = Field(
|
TEMPLATE_ID_HORIZONTAL: str = Field(
|
||||||
default="0f092a6a-f526-4ef0-9181-d4ad4426b9e7",
|
default="0f092a6a-f526-4ef0-9181-d4ad4426b9e7",
|
||||||
description="Creatomate 가로형 템플릿 ID",
|
description="Creatomate 가로형 템플릿 ID",
|
||||||
)
|
)
|
||||||
TEMPLATE_DURATION_HORIZONTAL: float = Field(
|
TEMPLATE_DURATION_HORIZONTAL: float = Field(
|
||||||
default=30.0,
|
default=30.0,
|
||||||
description="가로형 템플릿 기본 duration (초)",
|
description="가로형 템플릿 기본 duration (초)",
|
||||||
)
|
)
|
||||||
|
|
||||||
model_config = _base_config
|
model_config = _base_config
|
||||||
|
|
||||||
|
|
||||||
prj_settings = ProjectSettings()
|
prj_settings = ProjectSettings()
|
||||||
apikey_settings = APIKeySettings()
|
apikey_settings = APIKeySettings()
|
||||||
db_settings = DatabaseSettings()
|
db_settings = DatabaseSettings()
|
||||||
security_settings = SecuritySettings()
|
security_settings = SecuritySettings()
|
||||||
notification_settings = NotificationSettings()
|
notification_settings = NotificationSettings()
|
||||||
cors_settings = CORSSettings()
|
cors_settings = CORSSettings()
|
||||||
crawler_settings = CrawlerSettings()
|
crawler_settings = CrawlerSettings()
|
||||||
azure_blob_settings = AzureBlobSettings()
|
azure_blob_settings = AzureBlobSettings()
|
||||||
creatomate_settings = CreatomateSettings()
|
creatomate_settings = CreatomateSettings()
|
||||||
|
|
|
||||||
|
|
@ -1,297 +1,297 @@
|
||||||
# 비동기 처리 문제 분석 보고서
|
# 비동기 처리 문제 분석 보고서
|
||||||
|
|
||||||
## 요약
|
## 요약
|
||||||
|
|
||||||
전반적으로 이 프로젝트는 현대적인 비동기 아키텍처를 잘 구현하고 있습니다. 그러나 몇 가지 잠재적인 문제점과 개선 가능한 부분이 발견되었습니다.
|
전반적으로 이 프로젝트는 현대적인 비동기 아키텍처를 잘 구현하고 있습니다. 그러나 몇 가지 잠재적인 문제점과 개선 가능한 부분이 발견되었습니다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. 심각도 높음 - 즉시 개선 권장
|
## 1. 심각도 높음 - 즉시 개선 권장
|
||||||
|
|
||||||
### 1.1 N+1 쿼리 문제 (video.py:596-612)
|
### 1.1 N+1 쿼리 문제 (video.py:596-612)
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# get_videos() 엔드포인트에서
|
# get_videos() 엔드포인트에서
|
||||||
for video in videos:
|
for video in videos:
|
||||||
# 매 video마다 별도의 DB 쿼리 실행 - N+1 문제!
|
# 매 video마다 별도의 DB 쿼리 실행 - N+1 문제!
|
||||||
project_result = await session.execute(
|
project_result = await session.execute(
|
||||||
select(Project).where(Project.id == video.project_id)
|
select(Project).where(Project.id == video.project_id)
|
||||||
)
|
)
|
||||||
project = project_result.scalar_one_or_none()
|
project = project_result.scalar_one_or_none()
|
||||||
```
|
```
|
||||||
|
|
||||||
**문제점**: 비디오 목록 조회 시 각 비디오마다 별도의 Project 쿼리가 발생합니다. 10개 비디오 조회 시 11번의 DB 쿼리가 실행됩니다.
|
**문제점**: 비디오 목록 조회 시 각 비디오마다 별도의 Project 쿼리가 발생합니다. 10개 비디오 조회 시 11번의 DB 쿼리가 실행됩니다.
|
||||||
|
|
||||||
**개선 방안**:
|
**개선 방안**:
|
||||||
```python
|
```python
|
||||||
# selectinload를 사용한 eager loading
|
# selectinload를 사용한 eager loading
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
query = (
|
query = (
|
||||||
select(Video)
|
select(Video)
|
||||||
.options(selectinload(Video.project)) # relationship 필요
|
.options(selectinload(Video.project)) # relationship 필요
|
||||||
.where(Video.id.in_(select(subquery.c.max_id)))
|
.where(Video.id.in_(select(subquery.c.max_id)))
|
||||||
.order_by(Video.created_at.desc())
|
.order_by(Video.created_at.desc())
|
||||||
.offset(offset)
|
.offset(offset)
|
||||||
.limit(pagination.page_size)
|
.limit(pagination.page_size)
|
||||||
)
|
)
|
||||||
|
|
||||||
# 또는 한 번에 project_ids 수집 후 일괄 조회
|
# 또는 한 번에 project_ids 수집 후 일괄 조회
|
||||||
project_ids = [v.project_id for v in videos]
|
project_ids = [v.project_id for v in videos]
|
||||||
projects_result = await session.execute(
|
projects_result = await session.execute(
|
||||||
select(Project).where(Project.id.in_(project_ids))
|
select(Project).where(Project.id.in_(project_ids))
|
||||||
)
|
)
|
||||||
projects_map = {p.id: p for p in projects_result.scalars().all()}
|
projects_map = {p.id: p for p in projects_result.scalars().all()}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 1.2 가사 생성 API의 블로킹 문제 (lyric.py:274-276)
|
### 1.2 가사 생성 API의 블로킹 문제 (lyric.py:274-276)
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# ChatGPT API 호출이 완료될 때까지 HTTP 응답이 블로킹됨
|
# ChatGPT API 호출이 완료될 때까지 HTTP 응답이 블로킹됨
|
||||||
print(f"[generate_lyric] ChatGPT generation started - task_id: {task_id}")
|
print(f"[generate_lyric] ChatGPT generation started - task_id: {task_id}")
|
||||||
result = await service.generate(prompt=prompt) # 수 초~수십 초 소요
|
result = await service.generate(prompt=prompt) # 수 초~수십 초 소요
|
||||||
print(f"[generate_lyric] ChatGPT generation completed - task_id: {task_id}")
|
print(f"[generate_lyric] ChatGPT generation completed - task_id: {task_id}")
|
||||||
```
|
```
|
||||||
|
|
||||||
**문제점**:
|
**문제점**:
|
||||||
- ChatGPT API 응답이 5-30초 이상 걸릴 수 있음
|
- ChatGPT API 응답이 5-30초 이상 걸릴 수 있음
|
||||||
- 이 시간 동안 클라이언트 연결이 유지되어야 함
|
- 이 시간 동안 클라이언트 연결이 유지되어야 함
|
||||||
- 다수 동시 요청 시 worker 스레드 고갈 가능성
|
- 다수 동시 요청 시 worker 스레드 고갈 가능성
|
||||||
|
|
||||||
**개선 방안 (BackgroundTask 패턴)**:
|
**개선 방안 (BackgroundTask 패턴)**:
|
||||||
```python
|
```python
|
||||||
@router.post("/generate")
|
@router.post("/generate")
|
||||||
async def generate_lyric(
|
async def generate_lyric(
|
||||||
request_body: GenerateLyricRequest,
|
request_body: GenerateLyricRequest,
|
||||||
background_tasks: BackgroundTasks,
|
background_tasks: BackgroundTasks,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> GenerateLyricResponse:
|
) -> GenerateLyricResponse:
|
||||||
# DB에 processing 상태로 저장
|
# DB에 processing 상태로 저장
|
||||||
lyric = Lyric(status="processing", ...)
|
lyric = Lyric(status="processing", ...)
|
||||||
session.add(lyric)
|
session.add(lyric)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
# 백그라운드에서 ChatGPT 호출
|
# 백그라운드에서 ChatGPT 호출
|
||||||
background_tasks.add_task(
|
background_tasks.add_task(
|
||||||
generate_lyric_background,
|
generate_lyric_background,
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 즉시 응답 반환
|
# 즉시 응답 반환
|
||||||
return GenerateLyricResponse(
|
return GenerateLyricResponse(
|
||||||
success=True,
|
success=True,
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
message="가사 생성이 시작되었습니다. /status/{task_id}로 상태를 확인하세요.",
|
message="가사 생성이 시작되었습니다. /status/{task_id}로 상태를 확인하세요.",
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 1.3 Creatomate 서비스의 동기/비동기 메서드 혼재 (creatomate.py)
|
### 1.3 Creatomate 서비스의 동기/비동기 메서드 혼재 (creatomate.py)
|
||||||
|
|
||||||
**문제점**: 동기 메서드가 여전히 존재하여 실수로 async 컨텍스트에서 호출될 수 있습니다.
|
**문제점**: 동기 메서드가 여전히 존재하여 실수로 async 컨텍스트에서 호출될 수 있습니다.
|
||||||
|
|
||||||
| 동기 메서드 | 비동기 메서드 |
|
| 동기 메서드 | 비동기 메서드 |
|
||||||
|------------|--------------|
|
|------------|--------------|
|
||||||
| `get_all_templates_data()` | 없음 |
|
| `get_all_templates_data()` | 없음 |
|
||||||
| `get_one_template_data()` | `get_one_template_data_async()` |
|
| `get_one_template_data()` | `get_one_template_data_async()` |
|
||||||
| `make_creatomate_call()` | 없음 |
|
| `make_creatomate_call()` | 없음 |
|
||||||
| `make_creatomate_custom_call()` | `make_creatomate_custom_call_async()` |
|
| `make_creatomate_custom_call()` | `make_creatomate_custom_call_async()` |
|
||||||
| `get_render_status()` | `get_render_status_async()` |
|
| `get_render_status()` | `get_render_status_async()` |
|
||||||
|
|
||||||
**개선 방안**:
|
**개선 방안**:
|
||||||
```python
|
```python
|
||||||
# 모든 HTTP 호출 메서드를 async로 통일
|
# 모든 HTTP 호출 메서드를 async로 통일
|
||||||
async def get_all_templates_data(self) -> dict:
|
async def get_all_templates_data(self) -> dict:
|
||||||
url = f"{self.BASE_URL}/v1/templates"
|
url = f"{self.BASE_URL}/v1/templates"
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
response = await client.get(url, headers=self.headers, timeout=30.0)
|
response = await client.get(url, headers=self.headers, timeout=30.0)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
# 동기 버전 제거 또는 deprecated 표시
|
# 동기 버전 제거 또는 deprecated 표시
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. 심각도 중간 - 개선 권장
|
## 2. 심각도 중간 - 개선 권장
|
||||||
|
|
||||||
### 2.1 백그라운드 태스크에서 매번 엔진 생성 (session.py:82-127)
|
### 2.1 백그라운드 태스크에서 매번 엔진 생성 (session.py:82-127)
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def get_worker_session() -> AsyncGenerator[AsyncSession, None]:
|
async def get_worker_session() -> AsyncGenerator[AsyncSession, None]:
|
||||||
# 매 호출마다 새 엔진 생성 - 오버헤드 발생
|
# 매 호출마다 새 엔진 생성 - 오버헤드 발생
|
||||||
worker_engine = create_async_engine(
|
worker_engine = create_async_engine(
|
||||||
url=db_settings.MYSQL_URL,
|
url=db_settings.MYSQL_URL,
|
||||||
poolclass=NullPool,
|
poolclass=NullPool,
|
||||||
...
|
...
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
**문제점**: 백그라운드 태스크가 빈번하게 호출되면 엔진 생성/소멸 오버헤드가 증가합니다.
|
**문제점**: 백그라운드 태스크가 빈번하게 호출되면 엔진 생성/소멸 오버헤드가 증가합니다.
|
||||||
|
|
||||||
**개선 방안**:
|
**개선 방안**:
|
||||||
```python
|
```python
|
||||||
# 모듈 레벨에서 워커 전용 엔진 생성
|
# 모듈 레벨에서 워커 전용 엔진 생성
|
||||||
_worker_engine = create_async_engine(
|
_worker_engine = create_async_engine(
|
||||||
url=db_settings.MYSQL_URL,
|
url=db_settings.MYSQL_URL,
|
||||||
poolclass=NullPool,
|
poolclass=NullPool,
|
||||||
)
|
)
|
||||||
_WorkerSessionLocal = async_sessionmaker(bind=_worker_engine, ...)
|
_WorkerSessionLocal = async_sessionmaker(bind=_worker_engine, ...)
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def get_worker_session() -> AsyncGenerator[AsyncSession, None]:
|
async def get_worker_session() -> AsyncGenerator[AsyncSession, None]:
|
||||||
async with _WorkerSessionLocal() as session:
|
async with _WorkerSessionLocal() as session:
|
||||||
try:
|
try:
|
||||||
yield session
|
yield session
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
raise e
|
raise e
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 2.2 대용량 파일 다운로드 시 메모리 사용 (video_task.py:49-54)
|
### 2.2 대용량 파일 다운로드 시 메모리 사용 (video_task.py:49-54)
|
||||||
|
|
||||||
```python
|
```python
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
response = await client.get(video_url, timeout=180.0)
|
response = await client.get(video_url, timeout=180.0)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
# 전체 파일을 메모리에 로드 - 대용량 영상 시 문제
|
# 전체 파일을 메모리에 로드 - 대용량 영상 시 문제
|
||||||
async with aiofiles.open(str(temp_file_path), "wb") as f:
|
async with aiofiles.open(str(temp_file_path), "wb") as f:
|
||||||
await f.write(response.content)
|
await f.write(response.content)
|
||||||
```
|
```
|
||||||
|
|
||||||
**문제점**: 수백 MB 크기의 영상 파일을 한 번에 메모리에 로드합니다.
|
**문제점**: 수백 MB 크기의 영상 파일을 한 번에 메모리에 로드합니다.
|
||||||
|
|
||||||
**개선 방안 - 스트리밍 다운로드**:
|
**개선 방안 - 스트리밍 다운로드**:
|
||||||
```python
|
```python
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
async with client.stream("GET", video_url, timeout=180.0) as response:
|
async with client.stream("GET", video_url, timeout=180.0) as response:
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
async with aiofiles.open(str(temp_file_path), "wb") as f:
|
async with aiofiles.open(str(temp_file_path), "wb") as f:
|
||||||
async for chunk in response.aiter_bytes(chunk_size=8192):
|
async for chunk in response.aiter_bytes(chunk_size=8192):
|
||||||
await f.write(chunk)
|
await f.write(chunk)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 2.3 httpx.AsyncClient 반복 생성
|
### 2.3 httpx.AsyncClient 반복 생성
|
||||||
|
|
||||||
여러 곳에서 `async with httpx.AsyncClient() as client:`를 사용하여 매번 새 클라이언트를 생성합니다.
|
여러 곳에서 `async with httpx.AsyncClient() as client:`를 사용하여 매번 새 클라이언트를 생성합니다.
|
||||||
|
|
||||||
**개선 방안 - 재사용 가능한 클라이언트**:
|
**개선 방안 - 재사용 가능한 클라이언트**:
|
||||||
```python
|
```python
|
||||||
# app/utils/http_client.py
|
# app/utils/http_client.py
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
_client: httpx.AsyncClient | None = None
|
_client: httpx.AsyncClient | None = None
|
||||||
|
|
||||||
async def get_http_client() -> httpx.AsyncClient:
|
async def get_http_client() -> httpx.AsyncClient:
|
||||||
global _client
|
global _client
|
||||||
if _client is None:
|
if _client is None:
|
||||||
_client = httpx.AsyncClient(timeout=30.0)
|
_client = httpx.AsyncClient(timeout=30.0)
|
||||||
return _client
|
return _client
|
||||||
|
|
||||||
async def close_http_client():
|
async def close_http_client():
|
||||||
global _client
|
global _client
|
||||||
if _client:
|
if _client:
|
||||||
await _client.aclose()
|
await _client.aclose()
|
||||||
_client = None
|
_client = None
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. 심각도 낮음 - 선택적 개선
|
## 3. 심각도 낮음 - 선택적 개선
|
||||||
|
|
||||||
### 3.1 generate_video 엔드포인트의 다중 DB 조회 (video.py:109-191)
|
### 3.1 generate_video 엔드포인트의 다중 DB 조회 (video.py:109-191)
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# 4개의 개별 쿼리가 순차적으로 실행됨
|
# 4개의 개별 쿼리가 순차적으로 실행됨
|
||||||
project_result = await session.execute(select(Project).where(...))
|
project_result = await session.execute(select(Project).where(...))
|
||||||
lyric_result = await session.execute(select(Lyric).where(...))
|
lyric_result = await session.execute(select(Lyric).where(...))
|
||||||
song_result = await session.execute(select(Song).where(...))
|
song_result = await session.execute(select(Song).where(...))
|
||||||
image_result = await session.execute(select(Image).where(...))
|
image_result = await session.execute(select(Image).where(...))
|
||||||
```
|
```
|
||||||
|
|
||||||
**개선 방안 - 병렬 쿼리 실행**:
|
**개선 방안 - 병렬 쿼리 실행**:
|
||||||
```python
|
```python
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
project_task = session.execute(select(Project).where(Project.task_id == task_id))
|
project_task = session.execute(select(Project).where(Project.task_id == task_id))
|
||||||
lyric_task = session.execute(select(Lyric).where(Lyric.task_id == task_id))
|
lyric_task = session.execute(select(Lyric).where(Lyric.task_id == task_id))
|
||||||
song_task = session.execute(
|
song_task = session.execute(
|
||||||
select(Song).where(Song.task_id == task_id).order_by(Song.created_at.desc()).limit(1)
|
select(Song).where(Song.task_id == task_id).order_by(Song.created_at.desc()).limit(1)
|
||||||
)
|
)
|
||||||
image_task = session.execute(
|
image_task = session.execute(
|
||||||
select(Image).where(Image.task_id == task_id).order_by(Image.img_order.asc())
|
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_result, lyric_result, song_result, image_result = await asyncio.gather(
|
||||||
project_task, lyric_task, song_task, image_task
|
project_task, lyric_task, song_task, image_task
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 3.2 템플릿 조회 캐싱 미적용
|
### 3.2 템플릿 조회 캐싱 미적용
|
||||||
|
|
||||||
`get_one_template_data_async()`가 매번 Creatomate API를 호출합니다.
|
`get_one_template_data_async()`가 매번 Creatomate API를 호출합니다.
|
||||||
|
|
||||||
**개선 방안 - 간단한 메모리 캐싱**:
|
**개선 방안 - 간단한 메모리 캐싱**:
|
||||||
```python
|
```python
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from cachetools import TTLCache
|
from cachetools import TTLCache
|
||||||
|
|
||||||
_template_cache = TTLCache(maxsize=100, ttl=3600) # 1시간 캐시
|
_template_cache = TTLCache(maxsize=100, ttl=3600) # 1시간 캐시
|
||||||
|
|
||||||
async def get_one_template_data_async(self, template_id: str) -> dict:
|
async def get_one_template_data_async(self, template_id: str) -> dict:
|
||||||
if template_id in _template_cache:
|
if template_id in _template_cache:
|
||||||
return _template_cache[template_id]
|
return _template_cache[template_id]
|
||||||
|
|
||||||
url = f"{self.BASE_URL}/v1/templates/{template_id}"
|
url = f"{self.BASE_URL}/v1/templates/{template_id}"
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
response = await client.get(url, headers=self.headers, timeout=30.0)
|
response = await client.get(url, headers=self.headers, timeout=30.0)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
_template_cache[template_id] = data
|
_template_cache[template_id] = data
|
||||||
return data
|
return data
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. 긍정적인 부분 (잘 구현된 패턴)
|
## 4. 긍정적인 부분 (잘 구현된 패턴)
|
||||||
|
|
||||||
| 항목 | 상태 | 설명 |
|
| 항목 | 상태 | 설명 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| SQLAlchemy AsyncSession | O | `asyncmy` 드라이버와 `AsyncSessionLocal` 사용 |
|
| SQLAlchemy AsyncSession | O | `asyncmy` 드라이버와 `AsyncSessionLocal` 사용 |
|
||||||
| 파일 I/O | O | `aiofiles` 사용으로 비동기 파일 처리 |
|
| 파일 I/O | O | `aiofiles` 사용으로 비동기 파일 처리 |
|
||||||
| HTTP 클라이언트 | O | `httpx.AsyncClient` 사용 |
|
| HTTP 클라이언트 | O | `httpx.AsyncClient` 사용 |
|
||||||
| OpenAI API | O | `AsyncOpenAI` 클라이언트 사용 |
|
| OpenAI API | O | `AsyncOpenAI` 클라이언트 사용 |
|
||||||
| 백그라운드 태스크 | O | FastAPI `BackgroundTasks` 적절히 사용 |
|
| 백그라운드 태스크 | O | FastAPI `BackgroundTasks` 적절히 사용 |
|
||||||
| 세션 관리 | O | 메인/워커 세션 분리로 이벤트 루프 충돌 방지 |
|
| 세션 관리 | O | 메인/워커 세션 분리로 이벤트 루프 충돌 방지 |
|
||||||
| 연결 풀 설정 | O | `pool_size`, `pool_recycle`, `pool_pre_ping` 적절히 설정 |
|
| 연결 풀 설정 | O | `pool_size`, `pool_recycle`, `pool_pre_ping` 적절히 설정 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. 우선순위별 개선 권장 사항
|
## 5. 우선순위별 개선 권장 사항
|
||||||
|
|
||||||
| 우선순위 | 항목 | 예상 효과 |
|
| 우선순위 | 항목 | 예상 효과 |
|
||||||
|----------|------|----------|
|
|----------|------|----------|
|
||||||
| **1** | N+1 쿼리 문제 해결 | DB 부하 감소, 응답 속도 개선 |
|
| **1** | N+1 쿼리 문제 해결 | DB 부하 감소, 응답 속도 개선 |
|
||||||
| **2** | 가사 생성 백그라운드 처리 | 동시 요청 처리 능력 향상 |
|
| **2** | 가사 생성 백그라운드 처리 | 동시 요청 처리 능력 향상 |
|
||||||
| **3** | Creatomate 동기 메서드 제거 | 실수로 인한 블로킹 방지 |
|
| **3** | Creatomate 동기 메서드 제거 | 실수로 인한 블로킹 방지 |
|
||||||
| **4** | 대용량 파일 스트리밍 다운로드 | 메모리 사용량 감소 |
|
| **4** | 대용량 파일 스트리밍 다운로드 | 메모리 사용량 감소 |
|
||||||
| **5** | 워커 세션 엔진 재사용 | 오버헤드 감소 |
|
| **5** | 워커 세션 엔진 재사용 | 오버헤드 감소 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 분석 일자
|
## 분석 일자
|
||||||
|
|
||||||
2024-12-29
|
2024-12-29
|
||||||
|
|
|
||||||
|
|
@ -1,82 +1,82 @@
|
||||||
-- input_history 테이블
|
-- input_history 테이블
|
||||||
CREATE TABLE input_history (
|
CREATE TABLE input_history (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
customer_name VARCHAR(255) NOT NULL,
|
customer_name VARCHAR(255) NOT NULL,
|
||||||
region VARCHAR(100) NOT NULL,
|
region VARCHAR(100) NOT NULL,
|
||||||
task_id CHAR(36) NOT NULL,
|
task_id CHAR(36) NOT NULL,
|
||||||
detail_region_info TEXT,
|
detail_region_info TEXT,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
-- upload_img_url 테이블
|
-- upload_img_url 테이블
|
||||||
CREATE TABLE upload_img_url (
|
CREATE TABLE upload_img_url (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
task_id CHAR(36) NOT NULL,
|
task_id CHAR(36) NOT NULL,
|
||||||
img_uid INT NOT NULL,
|
img_uid INT NOT NULL,
|
||||||
img_url VARCHAR(2048) NOT NULL,
|
img_url VARCHAR(2048) NOT NULL,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (task_id) REFERENCES input_history(task_id) ON DELETE CASCADE
|
FOREIGN KEY (task_id) REFERENCES input_history(task_id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
-- lyrics 테이블
|
-- lyrics 테이블
|
||||||
CREATE TABLE lyrics (
|
CREATE TABLE lyrics (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
input_history_id INT NOT NULL,
|
input_history_id INT NOT NULL,
|
||||||
task_id CHAR(36) NOT NULL,
|
task_id CHAR(36) NOT NULL,
|
||||||
status VARCHAR(50) NOT NULL,
|
status VARCHAR(50) NOT NULL,
|
||||||
lyrics_prompt TEXT NOT NULL,
|
lyrics_prompt TEXT NOT NULL,
|
||||||
lyrics_result LONGTEXT NOT NULL,
|
lyrics_result LONGTEXT NOT NULL,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (input_history_id) REFERENCES input_history(id) ON DELETE CASCADE
|
FOREIGN KEY (input_history_id) REFERENCES input_history(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
-- song 테이블
|
-- song 테이블
|
||||||
CREATE TABLE song (
|
CREATE TABLE song (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
input_history_id INT NOT NULL,
|
input_history_id INT NOT NULL,
|
||||||
lyrics_id INT NOT NULL,
|
lyrics_id INT NOT NULL,
|
||||||
task_id CHAR(36) NOT NULL,
|
task_id CHAR(36) NOT NULL,
|
||||||
status VARCHAR(50) NOT NULL,
|
status VARCHAR(50) NOT NULL,
|
||||||
song_prompt TEXT NOT NULL,
|
song_prompt TEXT NOT NULL,
|
||||||
song_result_url_1 VARCHAR(2048),
|
song_result_url_1 VARCHAR(2048),
|
||||||
song_result_url_2 VARCHAR(2048),
|
song_result_url_2 VARCHAR(2048),
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (input_history_id) REFERENCES input_history(id) ON DELETE CASCADE,
|
FOREIGN KEY (input_history_id) REFERENCES input_history(id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (lyrics_id) REFERENCES lyrics(id) ON DELETE CASCADE
|
FOREIGN KEY (lyrics_id) REFERENCES lyrics(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
-- creatomate_result_url 테이블
|
-- creatomate_result_url 테이블
|
||||||
CREATE TABLE creatomate_result_url (
|
CREATE TABLE creatomate_result_url (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
input_history_id INT NOT NULL,
|
input_history_id INT NOT NULL,
|
||||||
song_id INT NOT NULL,
|
song_id INT NOT NULL,
|
||||||
task_id CHAR(36) NOT NULL,
|
task_id CHAR(36) NOT NULL,
|
||||||
status VARCHAR(50) NOT NULL,
|
status VARCHAR(50) NOT NULL,
|
||||||
result_movie_url VARCHAR(2048),
|
result_movie_url VARCHAR(2048),
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (input_history_id) REFERENCES input_history(id) ON DELETE CASCADE,
|
FOREIGN KEY (input_history_id) REFERENCES input_history(id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (song_id) REFERENCES song(id) ON DELETE CASCADE
|
FOREIGN KEY (song_id) REFERENCES song(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
-- ===== 인덱스 추가 (쿼리 성능 최적화) =====
|
-- ===== 인덱스 추가 (쿼리 성능 최적화) =====
|
||||||
|
|
||||||
-- input_history
|
-- input_history
|
||||||
CREATE INDEX idx_input_history_task_id ON input_history(task_id);
|
CREATE INDEX idx_input_history_task_id ON input_history(task_id);
|
||||||
|
|
||||||
-- upload_img_url (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 ON upload_img_url(task_id);
|
||||||
CREATE INDEX idx_upload_img_url_task_id_img_uid ON upload_img_url(task_id, img_uid);
|
CREATE INDEX idx_upload_img_url_task_id_img_uid ON upload_img_url(task_id, img_uid);
|
||||||
|
|
||||||
-- lyrics (input_history_id + task_id 인덱스)
|
-- lyrics (input_history_id + task_id 인덱스)
|
||||||
CREATE INDEX idx_lyrics_input_history_id ON lyrics(input_history_id);
|
CREATE INDEX idx_lyrics_input_history_id ON lyrics(input_history_id);
|
||||||
CREATE INDEX idx_lyrics_task_id ON lyrics(task_id);
|
CREATE INDEX idx_lyrics_task_id ON lyrics(task_id);
|
||||||
|
|
||||||
-- song (input_history_id + lyrics_id + 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_input_history_id ON song(input_history_id);
|
||||||
CREATE INDEX idx_song_lyrics_id ON song(lyrics_id);
|
CREATE INDEX idx_song_lyrics_id ON song(lyrics_id);
|
||||||
CREATE INDEX idx_song_task_id ON song(task_id);
|
CREATE INDEX idx_song_task_id ON song(task_id);
|
||||||
|
|
||||||
-- creatomate_result_url (input_history_id + song_id + 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_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_song_id ON creatomate_result_url(song_id);
|
||||||
CREATE INDEX idx_creatomate_task_id ON creatomate_result_url(task_id);
|
CREATE INDEX idx_creatomate_task_id ON creatomate_result_url(task_id);
|
||||||
|
|
@ -1,83 +1,83 @@
|
||||||
-- input_history 테이블
|
-- input_history 테이블
|
||||||
CREATE TABLE input_history (
|
CREATE TABLE input_history (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
customer_name VARCHAR(255) NOT NULL,
|
customer_name VARCHAR(255) NOT NULL,
|
||||||
region VARCHAR(100) NOT NULL,
|
region VARCHAR(100) NOT NULL,
|
||||||
task_id CHAR(36) NOT NULL UNIQUE, -- 유니크 UUID
|
task_id CHAR(36) NOT NULL UNIQUE, -- 유니크 UUID
|
||||||
detail_region_info TEXT,
|
detail_region_info TEXT,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
-- upload_img_url 테이블
|
-- upload_img_url 테이블
|
||||||
CREATE TABLE upload_img_url (
|
CREATE TABLE upload_img_url (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
task_id CHAR(36) NOT NULL, -- input_history와 연결
|
task_id CHAR(36) NOT NULL, -- input_history와 연결
|
||||||
img_uid INT NOT NULL,
|
img_uid INT NOT NULL,
|
||||||
img_url VARCHAR(2048) NOT NULL,
|
img_url VARCHAR(2048) NOT NULL,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
UNIQUE KEY unique_img_task_image (task_id, img_uid),
|
UNIQUE KEY unique_img_task_image (task_id, img_uid),
|
||||||
FOREIGN KEY (task_id) REFERENCES input_history(task_id) ON DELETE CASCADE
|
FOREIGN KEY (task_id) REFERENCES input_history(task_id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
-- lyrics 테이블
|
-- lyrics 테이블
|
||||||
CREATE TABLE lyrics (
|
CREATE TABLE lyrics (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
input_history_id INT NOT NULL,
|
input_history_id INT NOT NULL,
|
||||||
task_id CHAR(36) NOT NULL UNIQUE,
|
task_id CHAR(36) NOT NULL UNIQUE,
|
||||||
status VARCHAR(50) NOT NULL,
|
status VARCHAR(50) NOT NULL,
|
||||||
lyrics_prompt TEXT NOT NULL,
|
lyrics_prompt TEXT NOT NULL,
|
||||||
lyrics_result LONGTEXT NOT NULL,
|
lyrics_result LONGTEXT NOT NULL,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (input_history_id) REFERENCES input_history(id) ON DELETE CASCADE
|
FOREIGN KEY (input_history_id) REFERENCES input_history(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
-- song 테이블
|
-- song 테이블
|
||||||
CREATE TABLE song (
|
CREATE TABLE song (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
input_history_id INT NOT NULL,
|
input_history_id INT NOT NULL,
|
||||||
lyrics_id INT NOT NULL,
|
lyrics_id INT NOT NULL,
|
||||||
task_id CHAR(36) NOT NULL UNIQUE,
|
task_id CHAR(36) NOT NULL UNIQUE,
|
||||||
status VARCHAR(50) NOT NULL,
|
status VARCHAR(50) NOT NULL,
|
||||||
song_prompt TEXT NOT NULL,
|
song_prompt TEXT NOT NULL,
|
||||||
song_result_url_1 VARCHAR(2048),
|
song_result_url_1 VARCHAR(2048),
|
||||||
song_result_url_2 VARCHAR(2048),
|
song_result_url_2 VARCHAR(2048),
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (input_history_id) REFERENCES input_history(id) ON DELETE CASCADE,
|
FOREIGN KEY (input_history_id) REFERENCES input_history(id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (lyrics_id) REFERENCES lyrics(id) ON DELETE CASCADE
|
FOREIGN KEY (lyrics_id) REFERENCES lyrics(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
-- creatomate_result_url 테이블
|
-- creatomate_result_url 테이블
|
||||||
CREATE TABLE creatomate_result_url (
|
CREATE TABLE creatomate_result_url (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
input_history_id INT NOT NULL,
|
input_history_id INT NOT NULL,
|
||||||
song_id INT NOT NULL,
|
song_id INT NOT NULL,
|
||||||
task_id CHAR(36) NOT NULL UNIQUE,
|
task_id CHAR(36) NOT NULL UNIQUE,
|
||||||
status VARCHAR(50) NOT NULL,
|
status VARCHAR(50) NOT NULL,
|
||||||
result_movie_url VARCHAR(2048),
|
result_movie_url VARCHAR(2048),
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (input_history_id) REFERENCES input_history(id) ON DELETE CASCADE,
|
FOREIGN KEY (input_history_id) REFERENCES input_history(id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (song_id) REFERENCES song(id) ON DELETE CASCADE
|
FOREIGN KEY (song_id) REFERENCES song(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
-- ===== 인덱스 추가 (쿼리 성능 최적화) =====
|
-- ===== 인덱스 추가 (쿼리 성능 최적화) =====
|
||||||
|
|
||||||
-- input_history
|
-- input_history
|
||||||
CREATE INDEX idx_input_history_task_id ON input_history(task_id);
|
CREATE INDEX idx_input_history_task_id ON input_history(task_id);
|
||||||
|
|
||||||
-- upload_img_url (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 ON upload_img_url(task_id);
|
||||||
CREATE INDEX idx_upload_img_url_task_id_img_uid ON upload_img_url(task_id, img_uid);
|
CREATE INDEX idx_upload_img_url_task_id_img_uid ON upload_img_url(task_id, img_uid);
|
||||||
|
|
||||||
-- lyrics (input_history_id + task_id 인덱스)
|
-- lyrics (input_history_id + task_id 인덱스)
|
||||||
CREATE INDEX idx_lyrics_input_history_id ON lyrics(input_history_id);
|
CREATE INDEX idx_lyrics_input_history_id ON lyrics(input_history_id);
|
||||||
CREATE INDEX idx_lyrics_task_id ON lyrics(task_id);
|
CREATE INDEX idx_lyrics_task_id ON lyrics(task_id);
|
||||||
|
|
||||||
-- song (input_history_id + lyrics_id + 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_input_history_id ON song(input_history_id);
|
||||||
CREATE INDEX idx_song_lyrics_id ON song(lyrics_id);
|
CREATE INDEX idx_song_lyrics_id ON song(lyrics_id);
|
||||||
CREATE INDEX idx_song_task_id ON song(task_id);
|
CREATE INDEX idx_song_task_id ON song(task_id);
|
||||||
|
|
||||||
-- creatomate_result_url (input_history_id + song_id + 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_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_song_id ON creatomate_result_url(song_id);
|
||||||
CREATE INDEX idx_creatomate_task_id ON creatomate_result_url(task_id);
|
CREATE INDEX idx_creatomate_task_id ON creatomate_result_url(task_id);
|
||||||
|
|
@ -1,382 +1,382 @@
|
||||||
# Pydantic ConfigDict 사용 매뉴얼
|
# Pydantic ConfigDict 사용 매뉴얼
|
||||||
|
|
||||||
## 개요
|
## 개요
|
||||||
|
|
||||||
Pydantic v2에서 `ConfigDict`는 모델의 유효성 검사, 직렬화, JSON 스키마 생성 등의 동작을 제어하는 설정을 정의하는 TypedDict입니다.
|
Pydantic v2에서 `ConfigDict`는 모델의 유효성 검사, 직렬화, JSON 스키마 생성 등의 동작을 제어하는 설정을 정의하는 TypedDict입니다.
|
||||||
|
|
||||||
> Pydantic v1의 `class Config`는 더 이상 권장되지 않으며, `ConfigDict`를 사용해야 합니다.
|
> Pydantic v1의 `class Config`는 더 이상 권장되지 않으며, `ConfigDict`를 사용해야 합니다.
|
||||||
|
|
||||||
## 기본 사용법
|
## 기본 사용법
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
class MyModel(BaseModel):
|
class MyModel(BaseModel):
|
||||||
model_config = ConfigDict(
|
model_config = ConfigDict(
|
||||||
str_strip_whitespace=True,
|
str_strip_whitespace=True,
|
||||||
strict=True
|
strict=True
|
||||||
)
|
)
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
age: int
|
age: int
|
||||||
```
|
```
|
||||||
|
|
||||||
## 설정 옵션 전체 목록
|
## 설정 옵션 전체 목록
|
||||||
|
|
||||||
### 문자열 처리
|
### 문자열 처리
|
||||||
|
|
||||||
| 옵션 | 타입 | 기본값 | 설명 |
|
| 옵션 | 타입 | 기본값 | 설명 |
|
||||||
|------|------|--------|------|
|
|------|------|--------|------|
|
||||||
| `str_to_lower` | `bool` | `False` | 문자열을 소문자로 변환 |
|
| `str_to_lower` | `bool` | `False` | 문자열을 소문자로 변환 |
|
||||||
| `str_to_upper` | `bool` | `False` | 문자열을 대문자로 변환 |
|
| `str_to_upper` | `bool` | `False` | 문자열을 대문자로 변환 |
|
||||||
| `str_strip_whitespace` | `bool` | `False` | 문자열 앞뒤 공백 제거 |
|
| `str_strip_whitespace` | `bool` | `False` | 문자열 앞뒤 공백 제거 |
|
||||||
| `str_min_length` | `int \| None` | `None` | 문자열 최소 길이 |
|
| `str_min_length` | `int \| None` | `None` | 문자열 최소 길이 |
|
||||||
| `str_max_length` | `int \| None` | `None` | 문자열 최대 길이 |
|
| `str_max_length` | `int \| None` | `None` | 문자열 최대 길이 |
|
||||||
|
|
||||||
**예시:**
|
**예시:**
|
||||||
```python
|
```python
|
||||||
class UserInput(BaseModel):
|
class UserInput(BaseModel):
|
||||||
model_config = ConfigDict(
|
model_config = ConfigDict(
|
||||||
str_strip_whitespace=True,
|
str_strip_whitespace=True,
|
||||||
str_to_lower=True,
|
str_to_lower=True,
|
||||||
str_min_length=1,
|
str_min_length=1,
|
||||||
str_max_length=100
|
str_max_length=100
|
||||||
)
|
)
|
||||||
|
|
||||||
username: str
|
username: str
|
||||||
|
|
||||||
user = UserInput(username=" HELLO ")
|
user = UserInput(username=" HELLO ")
|
||||||
print(user.username) # "hello"
|
print(user.username) # "hello"
|
||||||
```
|
```
|
||||||
|
|
||||||
### 유효성 검사
|
### 유효성 검사
|
||||||
|
|
||||||
| 옵션 | 타입 | 기본값 | 설명 |
|
| 옵션 | 타입 | 기본값 | 설명 |
|
||||||
|------|------|--------|------|
|
|------|------|--------|------|
|
||||||
| `strict` | `bool` | `False` | 엄격한 타입 검사 활성화 (타입 강제 변환 비활성화) |
|
| `strict` | `bool` | `False` | 엄격한 타입 검사 활성화 (타입 강제 변환 비활성화) |
|
||||||
| `validate_assignment` | `bool` | `False` | 속성 할당 시 유효성 검사 수행 |
|
| `validate_assignment` | `bool` | `False` | 속성 할당 시 유효성 검사 수행 |
|
||||||
| `validate_default` | `bool` | `False` | 기본값도 유효성 검사 수행 |
|
| `validate_default` | `bool` | `False` | 기본값도 유효성 검사 수행 |
|
||||||
| `validate_return` | `bool` | `False` | 반환값 유효성 검사 |
|
| `validate_return` | `bool` | `False` | 반환값 유효성 검사 |
|
||||||
| `revalidate_instances` | `Literal['always', 'never', 'subclass-instances']` | `'never'` | 모델 인스턴스 재검증 시점 |
|
| `revalidate_instances` | `Literal['always', 'never', 'subclass-instances']` | `'never'` | 모델 인스턴스 재검증 시점 |
|
||||||
| `arbitrary_types_allowed` | `bool` | `False` | Pydantic이 지원하지 않는 타입 허용 |
|
| `arbitrary_types_allowed` | `bool` | `False` | Pydantic이 지원하지 않는 타입 허용 |
|
||||||
|
|
||||||
**예시 - strict 모드:**
|
**예시 - strict 모드:**
|
||||||
```python
|
```python
|
||||||
class StrictModel(BaseModel):
|
class StrictModel(BaseModel):
|
||||||
model_config = ConfigDict(strict=True)
|
model_config = ConfigDict(strict=True)
|
||||||
|
|
||||||
count: int
|
count: int
|
||||||
|
|
||||||
# strict=False (기본값): "123" -> 123 자동 변환
|
# strict=False (기본값): "123" -> 123 자동 변환
|
||||||
# strict=True: "123" 입력 시 ValidationError 발생
|
# strict=True: "123" 입력 시 ValidationError 발생
|
||||||
```
|
```
|
||||||
|
|
||||||
**예시 - validate_assignment:**
|
**예시 - validate_assignment:**
|
||||||
```python
|
```python
|
||||||
class User(BaseModel):
|
class User(BaseModel):
|
||||||
model_config = ConfigDict(validate_assignment=True)
|
model_config = ConfigDict(validate_assignment=True)
|
||||||
|
|
||||||
age: int
|
age: int
|
||||||
|
|
||||||
user = User(age=25)
|
user = User(age=25)
|
||||||
user.age = "invalid" # ValidationError 발생
|
user.age = "invalid" # ValidationError 발생
|
||||||
```
|
```
|
||||||
|
|
||||||
### Extra 필드 처리
|
### Extra 필드 처리
|
||||||
|
|
||||||
| 옵션 | 타입 | 기본값 | 설명 |
|
| 옵션 | 타입 | 기본값 | 설명 |
|
||||||
|------|------|--------|------|
|
|------|------|--------|------|
|
||||||
| `extra` | `'allow' \| 'ignore' \| 'forbid'` | `'ignore'` | 추가 필드 처리 방식 |
|
| `extra` | `'allow' \| 'ignore' \| 'forbid'` | `'ignore'` | 추가 필드 처리 방식 |
|
||||||
|
|
||||||
**값 설명:**
|
**값 설명:**
|
||||||
- `'ignore'`: 추가 필드 무시 (기본값)
|
- `'ignore'`: 추가 필드 무시 (기본값)
|
||||||
- `'allow'`: 추가 필드 허용, `__pydantic_extra__`에 저장
|
- `'allow'`: 추가 필드 허용, `__pydantic_extra__`에 저장
|
||||||
- `'forbid'`: 추가 필드 입력 시 에러 발생
|
- `'forbid'`: 추가 필드 입력 시 에러 발생
|
||||||
|
|
||||||
**예시:**
|
**예시:**
|
||||||
```python
|
```python
|
||||||
class AllowExtra(BaseModel):
|
class AllowExtra(BaseModel):
|
||||||
model_config = ConfigDict(extra='allow')
|
model_config = ConfigDict(extra='allow')
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
|
|
||||||
data = AllowExtra(name="John", unknown_field="value")
|
data = AllowExtra(name="John", unknown_field="value")
|
||||||
print(data.__pydantic_extra__) # {'unknown_field': 'value'}
|
print(data.__pydantic_extra__) # {'unknown_field': 'value'}
|
||||||
|
|
||||||
class ForbidExtra(BaseModel):
|
class ForbidExtra(BaseModel):
|
||||||
model_config = ConfigDict(extra='forbid')
|
model_config = ConfigDict(extra='forbid')
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
|
|
||||||
ForbidExtra(name="John", unknown="value") # ValidationError 발생
|
ForbidExtra(name="John", unknown="value") # ValidationError 발생
|
||||||
```
|
```
|
||||||
|
|
||||||
### 불변성 (Immutability)
|
### 불변성 (Immutability)
|
||||||
|
|
||||||
| 옵션 | 타입 | 기본값 | 설명 |
|
| 옵션 | 타입 | 기본값 | 설명 |
|
||||||
|------|------|--------|------|
|
|------|------|--------|------|
|
||||||
| `frozen` | `bool` | `False` | 모델을 불변(immutable)으로 만듦, `__hash__()` 구현 |
|
| `frozen` | `bool` | `False` | 모델을 불변(immutable)으로 만듦, `__hash__()` 구현 |
|
||||||
|
|
||||||
**예시:**
|
**예시:**
|
||||||
```python
|
```python
|
||||||
class ImmutableUser(BaseModel):
|
class ImmutableUser(BaseModel):
|
||||||
model_config = ConfigDict(frozen=True)
|
model_config = ConfigDict(frozen=True)
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
age: int
|
age: int
|
||||||
|
|
||||||
user = ImmutableUser(name="John", age=30)
|
user = ImmutableUser(name="John", age=30)
|
||||||
user.age = 31 # 에러 발생: Instance is frozen
|
user.age = 31 # 에러 발생: Instance is frozen
|
||||||
|
|
||||||
# frozen=True이면 해시 가능
|
# frozen=True이면 해시 가능
|
||||||
users_set = {user} # 정상 작동
|
users_set = {user} # 정상 작동
|
||||||
```
|
```
|
||||||
|
|
||||||
### 별칭 (Alias) 설정
|
### 별칭 (Alias) 설정
|
||||||
|
|
||||||
| 옵션 | 타입 | 기본값 | 설명 |
|
| 옵션 | 타입 | 기본값 | 설명 |
|
||||||
|------|------|--------|------|
|
|------|------|--------|------|
|
||||||
| `populate_by_name` | `bool` | `False` | 필드명과 별칭 모두로 값 설정 허용 (deprecated) |
|
| `populate_by_name` | `bool` | `False` | 필드명과 별칭 모두로 값 설정 허용 (deprecated) |
|
||||||
| `validate_by_alias` | `bool` | `True` | 별칭으로 필드 값 설정 허용 |
|
| `validate_by_alias` | `bool` | `True` | 별칭으로 필드 값 설정 허용 |
|
||||||
| `validate_by_name` | `bool` | `False` | 별칭이 있어도 필드명으로 값 설정 허용 |
|
| `validate_by_name` | `bool` | `False` | 별칭이 있어도 필드명으로 값 설정 허용 |
|
||||||
| `serialize_by_alias` | `bool` | `False` | 직렬화 시 별칭 사용 |
|
| `serialize_by_alias` | `bool` | `False` | 직렬화 시 별칭 사용 |
|
||||||
| `alias_generator` | `Callable[[str], str] \| None` | `None` | 별칭 자동 생성 함수 |
|
| `alias_generator` | `Callable[[str], str] \| None` | `None` | 별칭 자동 생성 함수 |
|
||||||
| `loc_by_alias` | `bool` | `True` | 에러 위치에 별칭 사용 |
|
| `loc_by_alias` | `bool` | `True` | 에러 위치에 별칭 사용 |
|
||||||
|
|
||||||
**예시:**
|
**예시:**
|
||||||
```python
|
```python
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
|
|
||||||
class APIResponse(BaseModel):
|
class APIResponse(BaseModel):
|
||||||
model_config = ConfigDict(
|
model_config = ConfigDict(
|
||||||
validate_by_alias=True,
|
validate_by_alias=True,
|
||||||
validate_by_name=True,
|
validate_by_name=True,
|
||||||
serialize_by_alias=True
|
serialize_by_alias=True
|
||||||
)
|
)
|
||||||
|
|
||||||
user_name: str = Field(alias="userName")
|
user_name: str = Field(alias="userName")
|
||||||
|
|
||||||
# 둘 다 가능
|
# 둘 다 가능
|
||||||
response1 = APIResponse(userName="John")
|
response1 = APIResponse(userName="John")
|
||||||
response2 = APIResponse(user_name="John")
|
response2 = APIResponse(user_name="John")
|
||||||
|
|
||||||
print(response1.model_dump(by_alias=True)) # {"userName": "John"}
|
print(response1.model_dump(by_alias=True)) # {"userName": "John"}
|
||||||
```
|
```
|
||||||
|
|
||||||
**예시 - alias_generator:**
|
**예시 - alias_generator:**
|
||||||
```python
|
```python
|
||||||
def to_camel(name: str) -> str:
|
def to_camel(name: str) -> str:
|
||||||
parts = name.split('_')
|
parts = name.split('_')
|
||||||
return parts[0] + ''.join(word.capitalize() for word in parts[1:])
|
return parts[0] + ''.join(word.capitalize() for word in parts[1:])
|
||||||
|
|
||||||
class CamelModel(BaseModel):
|
class CamelModel(BaseModel):
|
||||||
model_config = ConfigDict(
|
model_config = ConfigDict(
|
||||||
alias_generator=to_camel,
|
alias_generator=to_camel,
|
||||||
serialize_by_alias=True
|
serialize_by_alias=True
|
||||||
)
|
)
|
||||||
|
|
||||||
first_name: str
|
first_name: str
|
||||||
last_name: str
|
last_name: str
|
||||||
|
|
||||||
data = CamelModel(firstName="John", lastName="Doe")
|
data = CamelModel(firstName="John", lastName="Doe")
|
||||||
print(data.model_dump(by_alias=True))
|
print(data.model_dump(by_alias=True))
|
||||||
# {"firstName": "John", "lastName": "Doe"}
|
# {"firstName": "John", "lastName": "Doe"}
|
||||||
```
|
```
|
||||||
|
|
||||||
### JSON 스키마
|
### JSON 스키마
|
||||||
|
|
||||||
| 옵션 | 타입 | 기본값 | 설명 |
|
| 옵션 | 타입 | 기본값 | 설명 |
|
||||||
|------|------|--------|------|
|
|------|------|--------|------|
|
||||||
| `title` | `str \| None` | `None` | JSON 스키마 타이틀 |
|
| `title` | `str \| None` | `None` | JSON 스키마 타이틀 |
|
||||||
| `json_schema_extra` | `dict \| Callable \| None` | `None` | JSON 스키마에 추가할 정보 |
|
| `json_schema_extra` | `dict \| Callable \| None` | `None` | JSON 스키마에 추가할 정보 |
|
||||||
| `json_schema_serialization_defaults_required` | `bool` | `False` | 직렬화 스키마에서 기본값이 있는 필드도 required로 표시 |
|
| `json_schema_serialization_defaults_required` | `bool` | `False` | 직렬화 스키마에서 기본값이 있는 필드도 required로 표시 |
|
||||||
| `json_schema_mode_override` | `Literal['validation', 'serialization', None]` | `None` | JSON 스키마 모드 강제 지정 |
|
| `json_schema_mode_override` | `Literal['validation', 'serialization', None]` | `None` | JSON 스키마 모드 강제 지정 |
|
||||||
|
|
||||||
**예시 - json_schema_extra:**
|
**예시 - json_schema_extra:**
|
||||||
```python
|
```python
|
||||||
class Product(BaseModel):
|
class Product(BaseModel):
|
||||||
model_config = ConfigDict(
|
model_config = ConfigDict(
|
||||||
title="상품 정보",
|
title="상품 정보",
|
||||||
json_schema_extra={
|
json_schema_extra={
|
||||||
"example": {
|
"example": {
|
||||||
"name": "노트북",
|
"name": "노트북",
|
||||||
"price": 1500000
|
"price": 1500000
|
||||||
},
|
},
|
||||||
"description": "상품 데이터를 나타내는 모델"
|
"description": "상품 데이터를 나타내는 모델"
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
price: int
|
price: int
|
||||||
|
|
||||||
# OpenAPI/Swagger 문서에 예시가 표시됨
|
# OpenAPI/Swagger 문서에 예시가 표시됨
|
||||||
```
|
```
|
||||||
|
|
||||||
**예시 - Callable json_schema_extra:**
|
**예시 - Callable json_schema_extra:**
|
||||||
```python
|
```python
|
||||||
def add_examples(schema: dict) -> dict:
|
def add_examples(schema: dict) -> dict:
|
||||||
schema["examples"] = [
|
schema["examples"] = [
|
||||||
{"name": "예시1", "value": 100},
|
{"name": "예시1", "value": 100},
|
||||||
{"name": "예시2", "value": 200}
|
{"name": "예시2", "value": 200}
|
||||||
]
|
]
|
||||||
return schema
|
return schema
|
||||||
|
|
||||||
class DynamicSchema(BaseModel):
|
class DynamicSchema(BaseModel):
|
||||||
model_config = ConfigDict(json_schema_extra=add_examples)
|
model_config = ConfigDict(json_schema_extra=add_examples)
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
value: int
|
value: int
|
||||||
```
|
```
|
||||||
|
|
||||||
### ORM/속성 모드
|
### ORM/속성 모드
|
||||||
|
|
||||||
| 옵션 | 타입 | 기본값 | 설명 |
|
| 옵션 | 타입 | 기본값 | 설명 |
|
||||||
|------|------|--------|------|
|
|------|------|--------|------|
|
||||||
| `from_attributes` | `bool` | `False` | 객체 속성에서 모델 생성 허용 (SQLAlchemy 등) |
|
| `from_attributes` | `bool` | `False` | 객체 속성에서 모델 생성 허용 (SQLAlchemy 등) |
|
||||||
|
|
||||||
**예시:**
|
**예시:**
|
||||||
```python
|
```python
|
||||||
class UserORM:
|
class UserORM:
|
||||||
def __init__(self, name: str, age: int):
|
def __init__(self, name: str, age: int):
|
||||||
self.name = name
|
self.name = name
|
||||||
self.age = age
|
self.age = age
|
||||||
|
|
||||||
class UserModel(BaseModel):
|
class UserModel(BaseModel):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
age: int
|
age: int
|
||||||
|
|
||||||
orm_user = UserORM(name="John", age=30)
|
orm_user = UserORM(name="John", age=30)
|
||||||
pydantic_user = UserModel.model_validate(orm_user)
|
pydantic_user = UserModel.model_validate(orm_user)
|
||||||
print(pydantic_user) # name='John' age=30
|
print(pydantic_user) # name='John' age=30
|
||||||
```
|
```
|
||||||
|
|
||||||
### Enum 처리
|
### Enum 처리
|
||||||
|
|
||||||
| 옵션 | 타입 | 기본값 | 설명 |
|
| 옵션 | 타입 | 기본값 | 설명 |
|
||||||
|------|------|--------|------|
|
|------|------|--------|------|
|
||||||
| `use_enum_values` | `bool` | `False` | Enum 대신 값(value)으로 저장 |
|
| `use_enum_values` | `bool` | `False` | Enum 대신 값(value)으로 저장 |
|
||||||
|
|
||||||
**예시:**
|
**예시:**
|
||||||
```python
|
```python
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
class Status(Enum):
|
class Status(Enum):
|
||||||
ACTIVE = "active"
|
ACTIVE = "active"
|
||||||
INACTIVE = "inactive"
|
INACTIVE = "inactive"
|
||||||
|
|
||||||
class User(BaseModel):
|
class User(BaseModel):
|
||||||
model_config = ConfigDict(use_enum_values=True)
|
model_config = ConfigDict(use_enum_values=True)
|
||||||
|
|
||||||
status: Status
|
status: Status
|
||||||
|
|
||||||
user = User(status=Status.ACTIVE)
|
user = User(status=Status.ACTIVE)
|
||||||
print(user.status) # "active" (문자열)
|
print(user.status) # "active" (문자열)
|
||||||
print(type(user.status)) # <class 'str'>
|
print(type(user.status)) # <class 'str'>
|
||||||
|
|
||||||
# use_enum_values=False (기본값)이면
|
# use_enum_values=False (기본값)이면
|
||||||
# user.status는 Status.ACTIVE (Enum 객체)
|
# user.status는 Status.ACTIVE (Enum 객체)
|
||||||
```
|
```
|
||||||
|
|
||||||
### 직렬화 설정
|
### 직렬화 설정
|
||||||
|
|
||||||
| 옵션 | 타입 | 기본값 | 설명 |
|
| 옵션 | 타입 | 기본값 | 설명 |
|
||||||
|------|------|--------|------|
|
|------|------|--------|------|
|
||||||
| `ser_json_timedelta` | `'iso8601' \| 'float'` | `'iso8601'` | timedelta JSON 직렬화 형식 |
|
| `ser_json_timedelta` | `'iso8601' \| 'float'` | `'iso8601'` | timedelta JSON 직렬화 형식 |
|
||||||
| `ser_json_bytes` | `'utf8' \| 'base64' \| 'hex'` | `'utf8'` | bytes JSON 직렬화 인코딩 |
|
| `ser_json_bytes` | `'utf8' \| 'base64' \| 'hex'` | `'utf8'` | bytes JSON 직렬화 인코딩 |
|
||||||
| `ser_json_inf_nan` | `'null' \| 'constants' \| 'strings'` | `'null'` | 무한대/NaN JSON 직렬화 형식 |
|
| `ser_json_inf_nan` | `'null' \| 'constants' \| 'strings'` | `'null'` | 무한대/NaN JSON 직렬화 형식 |
|
||||||
|
|
||||||
### 숫자/Float 설정
|
### 숫자/Float 설정
|
||||||
|
|
||||||
| 옵션 | 타입 | 기본값 | 설명 |
|
| 옵션 | 타입 | 기본값 | 설명 |
|
||||||
|------|------|--------|------|
|
|------|------|--------|------|
|
||||||
| `allow_inf_nan` | `bool` | `True` | float에서 무한대/NaN 허용 |
|
| `allow_inf_nan` | `bool` | `True` | float에서 무한대/NaN 허용 |
|
||||||
| `coerce_numbers_to_str` | `bool` | `False` | 숫자를 문자열로 강제 변환 허용 |
|
| `coerce_numbers_to_str` | `bool` | `False` | 숫자를 문자열로 강제 변환 허용 |
|
||||||
|
|
||||||
### 기타 설정
|
### 기타 설정
|
||||||
|
|
||||||
| 옵션 | 타입 | 기본값 | 설명 |
|
| 옵션 | 타입 | 기본값 | 설명 |
|
||||||
|------|------|--------|------|
|
|------|------|--------|------|
|
||||||
| `protected_namespaces` | `tuple[str, ...]` | `('model_',)` | 보호할 필드명 접두사 |
|
| `protected_namespaces` | `tuple[str, ...]` | `('model_',)` | 보호할 필드명 접두사 |
|
||||||
| `hide_input_in_errors` | `bool` | `False` | 에러 메시지에서 입력값 숨김 |
|
| `hide_input_in_errors` | `bool` | `False` | 에러 메시지에서 입력값 숨김 |
|
||||||
| `defer_build` | `bool` | `False` | validator/serializer 빌드 지연 |
|
| `defer_build` | `bool` | `False` | validator/serializer 빌드 지연 |
|
||||||
| `use_attribute_docstrings` | `bool` | `False` | 속성 docstring을 필드 설명으로 사용 |
|
| `use_attribute_docstrings` | `bool` | `False` | 속성 docstring을 필드 설명으로 사용 |
|
||||||
| `regex_engine` | `'rust-regex' \| 'python-re'` | `'rust-regex'` | 정규식 엔진 선택 |
|
| `regex_engine` | `'rust-regex' \| 'python-re'` | `'rust-regex'` | 정규식 엔진 선택 |
|
||||||
| `validation_error_cause` | `bool` | `False` | Python 예외를 에러 원인에 포함 |
|
| `validation_error_cause` | `bool` | `False` | Python 예외를 에러 원인에 포함 |
|
||||||
|
|
||||||
## 설정 상속
|
## 설정 상속
|
||||||
|
|
||||||
자식 모델은 부모 모델의 `model_config`를 상속받습니다.
|
자식 모델은 부모 모델의 `model_config`를 상속받습니다.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
class ParentModel(BaseModel):
|
class ParentModel(BaseModel):
|
||||||
model_config = ConfigDict(
|
model_config = ConfigDict(
|
||||||
str_strip_whitespace=True,
|
str_strip_whitespace=True,
|
||||||
extra='allow'
|
extra='allow'
|
||||||
)
|
)
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
|
|
||||||
class ChildModel(ParentModel):
|
class ChildModel(ParentModel):
|
||||||
model_config = ConfigDict(
|
model_config = ConfigDict(
|
||||||
frozen=True # 부모 설정 + frozen=True
|
frozen=True # 부모 설정 + frozen=True
|
||||||
)
|
)
|
||||||
|
|
||||||
age: int
|
age: int
|
||||||
|
|
||||||
# ChildModel은 str_strip_whitespace=True, extra='allow', frozen=True
|
# ChildModel은 str_strip_whitespace=True, extra='allow', frozen=True
|
||||||
```
|
```
|
||||||
|
|
||||||
## FastAPI와 함께 사용
|
## FastAPI와 함께 사용
|
||||||
|
|
||||||
FastAPI에서 요청/응답 스키마로 사용할 때 특히 유용합니다.
|
FastAPI에서 요청/응답 스키마로 사용할 때 특히 유용합니다.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
|
|
||||||
class CreateUserRequest(BaseModel):
|
class CreateUserRequest(BaseModel):
|
||||||
model_config = ConfigDict(
|
model_config = ConfigDict(
|
||||||
str_strip_whitespace=True,
|
str_strip_whitespace=True,
|
||||||
json_schema_extra={
|
json_schema_extra={
|
||||||
"example": {
|
"example": {
|
||||||
"username": "johndoe",
|
"username": "johndoe",
|
||||||
"email": "john@example.com"
|
"email": "john@example.com"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
username: str = Field(..., min_length=3, max_length=50)
|
username: str = Field(..., min_length=3, max_length=50)
|
||||||
email: str
|
email: str
|
||||||
|
|
||||||
class UserResponse(BaseModel):
|
class UserResponse(BaseModel):
|
||||||
model_config = ConfigDict(
|
model_config = ConfigDict(
|
||||||
from_attributes=True, # ORM 객체에서 변환 가능
|
from_attributes=True, # ORM 객체에서 변환 가능
|
||||||
serialize_by_alias=True
|
serialize_by_alias=True
|
||||||
)
|
)
|
||||||
|
|
||||||
id: int
|
id: int
|
||||||
user_name: str = Field(alias="userName")
|
user_name: str = Field(alias="userName")
|
||||||
|
|
||||||
@app.post("/users", response_model=UserResponse)
|
@app.post("/users", response_model=UserResponse)
|
||||||
async def create_user(user: CreateUserRequest):
|
async def create_user(user: CreateUserRequest):
|
||||||
# user.username은 자동으로 공백이 제거됨
|
# user.username은 자동으로 공백이 제거됨
|
||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
## 주의사항
|
## 주의사항
|
||||||
|
|
||||||
1. **v1에서 마이그레이션**: `class Config`는 deprecated입니다. `model_config = ConfigDict(...)`를 사용하세요.
|
1. **v1에서 마이그레이션**: `class Config`는 deprecated입니다. `model_config = ConfigDict(...)`를 사용하세요.
|
||||||
|
|
||||||
2. **populate_by_name은 deprecated**: `validate_by_alias`와 `validate_by_name`을 함께 사용하세요.
|
2. **populate_by_name은 deprecated**: `validate_by_alias`와 `validate_by_name`을 함께 사용하세요.
|
||||||
|
|
||||||
3. **json_encoders는 deprecated**: 커스텀 직렬화가 필요하면 `@field_serializer` 데코레이터를 사용하세요.
|
3. **json_encoders는 deprecated**: 커스텀 직렬화가 필요하면 `@field_serializer` 데코레이터를 사용하세요.
|
||||||
|
|
||||||
## 참고 자료
|
## 참고 자료
|
||||||
|
|
||||||
- [Pydantic Configuration API 공식 문서](https://docs.pydantic.dev/latest/api/config/)
|
- [Pydantic Configuration API 공식 문서](https://docs.pydantic.dev/latest/api/config/)
|
||||||
- [Pydantic Models 개념](https://docs.pydantic.dev/latest/concepts/models/)
|
- [Pydantic Models 개념](https://docs.pydantic.dev/latest/concepts/models/)
|
||||||
- [Pydantic Migration Guide](https://docs.pydantic.dev/latest/migration/)
|
- [Pydantic Migration Guide](https://docs.pydantic.dev/latest/migration/)
|
||||||
|
|
|
||||||
|
|
@ -1,158 +1,158 @@
|
||||||
from app.core.database import Base
|
from app.core.database import Base
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
Boolean,
|
Boolean,
|
||||||
DateTime,
|
DateTime,
|
||||||
Enum,
|
Enum,
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
Index,
|
Index,
|
||||||
Integer,
|
Integer,
|
||||||
PrimaryKeyConstraint,
|
PrimaryKeyConstraint,
|
||||||
String,
|
String,
|
||||||
func,
|
func,
|
||||||
)
|
)
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
from starlette.authentication import BaseUser
|
from starlette.authentication import BaseUser
|
||||||
|
|
||||||
|
|
||||||
class User(Base, BaseUser):
|
class User(Base, BaseUser):
|
||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(
|
id: Mapped[int] = mapped_column(
|
||||||
Integer, primary_key=True, nullable=False, autoincrement=True
|
Integer, primary_key=True, nullable=False, autoincrement=True
|
||||||
)
|
)
|
||||||
username: Mapped[str] = mapped_column(
|
username: Mapped[str] = mapped_column(
|
||||||
String(255), unique=True, nullable=False, index=True
|
String(255), unique=True, nullable=False, index=True
|
||||||
)
|
)
|
||||||
email: Mapped[str] = mapped_column(
|
email: Mapped[str] = mapped_column(
|
||||||
String(255), unique=True, nullable=False, index=True
|
String(255), unique=True, nullable=False, index=True
|
||||||
)
|
)
|
||||||
hashed_password: Mapped[str] = mapped_column(String(60), nullable=False)
|
hashed_password: Mapped[str] = mapped_column(String(60), nullable=False)
|
||||||
# age_level 컬럼을 Enum으로 정의
|
# age_level 컬럼을 Enum으로 정의
|
||||||
age_level_choices = ["10", "20", "30", "40", "50", "60", "70", "80"]
|
age_level_choices = ["10", "20", "30", "40", "50", "60", "70", "80"]
|
||||||
age_level: Mapped[str] = mapped_column(
|
age_level: Mapped[str] = mapped_column(
|
||||||
Enum(*age_level_choices, name="age_level_enum"),
|
Enum(*age_level_choices, name="age_level_enum"),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
default="10",
|
default="10",
|
||||||
)
|
)
|
||||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
created_at: Mapped[DateTime] = mapped_column(DateTime, server_default=func.now())
|
created_at: Mapped[DateTime] = mapped_column(DateTime, server_default=func.now())
|
||||||
|
|
||||||
# One-to-many relationship with Post (DynamicMapped + lazy="dynamic")
|
# One-to-many relationship with Post (DynamicMapped + lazy="dynamic")
|
||||||
posts_user: Mapped[list["Post"]] = relationship("Post", back_populates="user_posts")
|
posts_user: Mapped[list["Post"]] = relationship("Post", back_populates="user_posts")
|
||||||
|
|
||||||
# # Many-to-many relationship with Group
|
# # Many-to-many relationship with Group
|
||||||
# user_groups: DynamicMapped["UserGroupAssociation"] = relationship(
|
# user_groups: DynamicMapped["UserGroupAssociation"] = relationship(
|
||||||
# "UserGroupAssociation", back_populates="user", lazy="dynamic"
|
# "UserGroupAssociation", back_populates="user", lazy="dynamic"
|
||||||
# )
|
# )
|
||||||
# n:m 관계 (Group) – 최적의 lazy 옵션: selectin
|
# n:m 관계 (Group) – 최적의 lazy 옵션: selectin
|
||||||
group_user: Mapped[list["Group"]] = relationship(
|
group_user: Mapped[list["Group"]] = relationship(
|
||||||
"Group",
|
"Group",
|
||||||
secondary="user_group_association",
|
secondary="user_group_association",
|
||||||
back_populates="user_group",
|
back_populates="user_group",
|
||||||
lazy="selectin",
|
lazy="selectin",
|
||||||
)
|
)
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return f"id={self.id}, username={self.username}"
|
return f"id={self.id}, username={self.username}"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_authenticated(self) -> bool:
|
def is_authenticated(self) -> bool:
|
||||||
return self.is_active
|
return self.is_active
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def display_name(self) -> str:
|
def display_name(self) -> str:
|
||||||
return self.username
|
return self.username
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def identity(self) -> str:
|
def identity(self) -> str:
|
||||||
return self.username
|
return self.username
|
||||||
|
|
||||||
|
|
||||||
# 1:N Relationship - Posts
|
# 1:N Relationship - Posts
|
||||||
class Post(Base):
|
class Post(Base):
|
||||||
__tablename__ = "posts"
|
__tablename__ = "posts"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
user_id: Mapped[int] = mapped_column(
|
user_id: Mapped[int] = mapped_column(
|
||||||
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||||
)
|
)
|
||||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
content: Mapped[str] = mapped_column(String(10000), nullable=False)
|
content: Mapped[str] = mapped_column(String(10000), nullable=False)
|
||||||
is_published: Mapped[bool] = mapped_column(Boolean, default=False)
|
is_published: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
created_at: Mapped[DateTime] = mapped_column(DateTime, server_default=func.now())
|
created_at: Mapped[DateTime] = mapped_column(DateTime, server_default=func.now())
|
||||||
updated_at: Mapped[DateTime] = mapped_column(
|
updated_at: Mapped[DateTime] = mapped_column(
|
||||||
DateTime, server_default=func.now(), onupdate=func.now()
|
DateTime, server_default=func.now(), onupdate=func.now()
|
||||||
)
|
)
|
||||||
# tags: Mapped[dict] = mapped_column(MutableDict.as_mutable(JSONB), default=[]) // sqlite 지원 안함
|
# tags: Mapped[dict] = mapped_column(MutableDict.as_mutable(JSONB), default=[]) // sqlite 지원 안함
|
||||||
view_count: Mapped[int] = mapped_column(Integer, default=0)
|
view_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
|
||||||
# Many-to-one relationship with User (using dynamic loading)
|
# Many-to-one relationship with User (using dynamic loading)
|
||||||
user_posts: Mapped["User"] = relationship("User", back_populates="posts_user")
|
user_posts: Mapped["User"] = relationship("User", back_populates="posts_user")
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return f"Post(id={self.id}, user_id={self.user_id}, title={self.title})"
|
return f"Post(id={self.id}, user_id={self.user_id}, title={self.title})"
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
Index("idx_posts_user_id", "user_id"),
|
Index("idx_posts_user_id", "user_id"),
|
||||||
Index("idx_posts_created_at", "created_at"),
|
Index("idx_posts_created_at", "created_at"),
|
||||||
Index(
|
Index(
|
||||||
"idx_posts_user_id_created_at", "user_id", "created_at"
|
"idx_posts_user_id_created_at", "user_id", "created_at"
|
||||||
), # Composite index
|
), # Composite index
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# N:M Relationship - Users and Groups
|
# N:M Relationship - Users and Groups
|
||||||
# Association table for many-to-many relationship
|
# Association table for many-to-many relationship
|
||||||
# N:M Association Table (중간 테이블)
|
# N:M Association Table (중간 테이블)
|
||||||
class UserGroupAssociation(Base):
|
class UserGroupAssociation(Base):
|
||||||
__tablename__ = "user_group_association"
|
__tablename__ = "user_group_association"
|
||||||
|
|
||||||
user_id: Mapped[int] = mapped_column(
|
user_id: Mapped[int] = mapped_column(
|
||||||
Integer, ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
|
Integer, ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
|
||||||
)
|
)
|
||||||
group_id: Mapped[int] = mapped_column(
|
group_id: Mapped[int] = mapped_column(
|
||||||
Integer, ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True
|
Integer, ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True
|
||||||
)
|
)
|
||||||
|
|
||||||
# # 관계 정의
|
# # 관계 정의
|
||||||
# user: Mapped["User"] = relationship("User", back_populates="user_groups")
|
# user: Mapped["User"] = relationship("User", back_populates="user_groups")
|
||||||
# group: Mapped["Group"] = relationship("Group", back_populates="group_users")
|
# group: Mapped["Group"] = relationship("Group", back_populates="group_users")
|
||||||
# # 복합 기본 키 설정
|
# # 복합 기본 키 설정
|
||||||
|
|
||||||
# 기본 키 설정을 위한 __table_args__ 추가
|
# 기본 키 설정을 위한 __table_args__ 추가
|
||||||
__table_args__ = (PrimaryKeyConstraint("user_id", "group_id"),)
|
__table_args__ = (PrimaryKeyConstraint("user_id", "group_id"),)
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return f"UserGroupAssociation(user_id={self.user_id}, group_id={self.group_id})"
|
return f"UserGroupAssociation(user_id={self.user_id}, group_id={self.group_id})"
|
||||||
|
|
||||||
|
|
||||||
# Group 테이블
|
# Group 테이블
|
||||||
class Group(Base):
|
class Group(Base):
|
||||||
__tablename__ = "groups"
|
__tablename__ = "groups"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
|
name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
|
||||||
description: Mapped[str] = mapped_column(String(1000))
|
description: Mapped[str] = mapped_column(String(1000))
|
||||||
is_public: Mapped[bool] = mapped_column(Boolean, default=True)
|
is_public: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
created_at: Mapped[DateTime] = mapped_column(DateTime, server_default=func.now())
|
created_at: Mapped[DateTime] = mapped_column(DateTime, server_default=func.now())
|
||||||
updated_at: Mapped[DateTime] = mapped_column(
|
updated_at: Mapped[DateTime] = mapped_column(
|
||||||
DateTime, server_default=func.now(), onupdate=func.now()
|
DateTime, server_default=func.now(), onupdate=func.now()
|
||||||
)
|
)
|
||||||
|
|
||||||
user_group: Mapped[list["User"]] = relationship(
|
user_group: Mapped[list["User"]] = relationship(
|
||||||
"User",
|
"User",
|
||||||
secondary="user_group_association",
|
secondary="user_group_association",
|
||||||
back_populates="group_user",
|
back_populates="group_user",
|
||||||
lazy="selectin",
|
lazy="selectin",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Group을 만든 사용자와 관계 (일반적인 1:N 관계)
|
# Group을 만든 사용자와 관계 (일반적인 1:N 관계)
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return f"Group(id={self.id}, name={self.name})"
|
return f"Group(id={self.id}, name={self.name})"
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
Index("idx_groups_name", "name"),
|
Index("idx_groups_name", "name"),
|
||||||
Index("idx_groups_is_public", "is_public"),
|
Index("idx_groups_is_public", "is_public"),
|
||||||
Index("idx_groups_created_at", "created_at"),
|
Index("idx_groups_created_at", "created_at"),
|
||||||
Index("idx_groups_composite", "is_public", "created_at"),
|
Index("idx_groups_composite", "is_public", "created_at"),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 191 KiB |
|
Before Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 191 KiB |
|
Before Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 191 KiB |
|
Before Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 191 KiB |
|
Before Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 191 KiB |
|
Before Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 191 KiB |
|
Before Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 152 KiB |
108
main.py
|
|
@ -1,54 +1,54 @@
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from scalar_fastapi import get_scalar_api_reference
|
from scalar_fastapi import get_scalar_api_reference
|
||||||
|
|
||||||
from app.admin_manager import init_admin
|
from app.admin_manager import init_admin
|
||||||
from app.core.common import lifespan
|
from app.core.common import lifespan
|
||||||
from app.database.session import engine
|
from app.database.session import engine
|
||||||
from app.home.api.routers.v1.home import router as home_router
|
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.lyric.api.routers.v1.lyric import router as lyric_router
|
||||||
from app.song.api.routers.v1.song import router as song_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.video.api.routers.v1.video import router as video_router
|
||||||
from app.utils.cors import CustomCORSMiddleware
|
from app.utils.cors import CustomCORSMiddleware
|
||||||
from config import prj_settings
|
from config import prj_settings
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title=prj_settings.PROJECT_NAME,
|
title=prj_settings.PROJECT_NAME,
|
||||||
version=prj_settings.VERSION,
|
version=prj_settings.VERSION,
|
||||||
description=prj_settings.DESCRIPTION,
|
description=prj_settings.DESCRIPTION,
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
docs_url=None, # 기본 Swagger UI 비활성화
|
docs_url=None, # 기본 Swagger UI 비활성화
|
||||||
redoc_url=None, # 기본 ReDoc 비활성화
|
redoc_url=None, # 기본 ReDoc 비활성화
|
||||||
)
|
)
|
||||||
|
|
||||||
init_admin(app, engine)
|
init_admin(app, engine)
|
||||||
|
|
||||||
custom_cors_middleware = CustomCORSMiddleware(app)
|
custom_cors_middleware = CustomCORSMiddleware(app)
|
||||||
custom_cors_middleware.configure_cors()
|
custom_cors_middleware.configure_cors()
|
||||||
|
|
||||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||||
app.mount("/media", StaticFiles(directory="media"), name="media")
|
app.mount("/media", StaticFiles(directory="media"), name="media")
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"],
|
allow_origins=["*"],
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
max_age=-1,
|
max_age=-1,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/docs", include_in_schema=False)
|
@app.get("/docs", include_in_schema=False)
|
||||||
def get_scalar_docs():
|
def get_scalar_docs():
|
||||||
return get_scalar_api_reference(
|
return get_scalar_api_reference(
|
||||||
openapi_url=app.openapi_url,
|
openapi_url=app.openapi_url,
|
||||||
title="Scalar API",
|
title="Scalar API",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
app.include_router(home_router)
|
app.include_router(home_router)
|
||||||
app.include_router(lyric_router) # Lyric API 라우터 추가
|
app.include_router(lyric_router) # Lyric API 라우터 추가
|
||||||
app.include_router(song_router) # Song API 라우터 추가
|
app.include_router(song_router) # Song API 라우터 추가
|
||||||
app.include_router(video_router) # Video API 라우터 추가
|
app.include_router(video_router) # Video API 라우터 추가
|
||||||
|
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
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())
|
|
||||||
|
|
@ -1,113 +0,0 @@
|
||||||
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,119 +0,0 @@
|
||||||
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 copy
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
CREATOMATE_API_KEY = "df9e4382d7e84fe790bf8a2168152be195d5a3568524ceb66ed989a2dea809f7d3065d6803b2e3dd9d02b5e5ec1c9823"
|
CREATOMATE_API_KEY = "df9e4382d7e84fe790bf8a2168152be195d5a3568524ceb66ed989a2dea809f7d3065d6803b2e3dd9d02b5e5ec1c9823"
|
||||||
# ACCOUNT_URL = "https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/"
|
# ACCOUNT_URL = "https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/"
|
||||||
# Creatomate 템플릿 정보 전부 가져오기
|
# Creatomate 템플릿 정보 전부 가져오기
|
||||||
|
|
||||||
|
|
||||||
class Creatomate:
|
class Creatomate:
|
||||||
base_url: str = "https://api.creatomate.com"
|
base_url: str = "https://api.creatomate.com"
|
||||||
|
|
||||||
def __init__(self, api_key):
|
def __init__(self, api_key):
|
||||||
self.api_key = api_key
|
self.api_key = api_key
|
||||||
|
|
||||||
def get_all_templates_data(self) -> dict:
|
def get_all_templates_data(self) -> dict:
|
||||||
url = Creatomate.base_url + "/v1/templates"
|
url = Creatomate.base_url + "/v1/templates"
|
||||||
headers = {
|
headers = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Authorization": f"Bearer {self.api_key}",
|
"Authorization": f"Bearer {self.api_key}",
|
||||||
}
|
}
|
||||||
response = requests.get(url, headers=headers)
|
response = requests.get(url, headers=headers)
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
# Creatomate 템플릿 ID 로부터 해당 템플릿 정보 가져오기
|
# Creatomate 템플릿 ID 로부터 해당 템플릿 정보 가져오기
|
||||||
def get_one_template_data(self, template_id: str) -> dict:
|
def get_one_template_data(self, template_id: str) -> dict:
|
||||||
url = Creatomate.base_url + f"/v1/templates/{template_id}"
|
url = Creatomate.base_url + f"/v1/templates/{template_id}"
|
||||||
headers = {
|
headers = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Authorization": f"Bearer {self.api_key}",
|
"Authorization": f"Bearer {self.api_key}",
|
||||||
}
|
}
|
||||||
response = requests.get(url, headers=headers)
|
response = requests.get(url, headers=headers)
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
# 템플릿 정보 파싱하여 리소스 이름 추출하기
|
# 템플릿 정보 파싱하여 리소스 이름 추출하기
|
||||||
def parse_template_component_name(self, template_source: dict) -> dict:
|
def parse_template_component_name(self, template_source: dict) -> dict:
|
||||||
def recursive_parse_component(element: dict) -> dict:
|
def recursive_parse_component(element: dict) -> dict:
|
||||||
if "name" in element:
|
if "name" in element:
|
||||||
result_element_name_type = {element["name"]: element["type"]}
|
result_element_name_type = {element["name"]: element["type"]}
|
||||||
else:
|
else:
|
||||||
result_element_name_type = {}
|
result_element_name_type = {}
|
||||||
|
|
||||||
if element["type"] == "composition":
|
if element["type"] == "composition":
|
||||||
minor_component_list = [
|
minor_component_list = [
|
||||||
recursive_parse_component(minor) for minor in element["elements"]
|
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
|
for minor_component in minor_component_list: ## WARNING : Same name component should shroud other component. be aware
|
||||||
result_element_name_type.update(minor_component)
|
result_element_name_type.update(minor_component)
|
||||||
|
|
||||||
return result_element_name_type
|
return result_element_name_type
|
||||||
|
|
||||||
result = {}
|
result = {}
|
||||||
for result_element_dict in [
|
for result_element_dict in [
|
||||||
recursive_parse_component(component) for component in template_source
|
recursive_parse_component(component) for component in template_source
|
||||||
]:
|
]:
|
||||||
result.update(result_element_dict)
|
result.update(result_element_dict)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# 템플릿 정보 이미지/가사/음악 리소스와 매핑하기
|
# 템플릿 정보 이미지/가사/음악 리소스와 매핑하기
|
||||||
# 이미지는 순차적으로 집어넣기
|
# 이미지는 순차적으로 집어넣기
|
||||||
# 가사는 개행마다 한 텍스트 삽입
|
# 가사는 개행마다 한 텍스트 삽입
|
||||||
# Template에 audio-music 항목이 있어야 함. (추가된 템플릿 Cafe뿐임)
|
# Template에 audio-music 항목이 있어야 함. (추가된 템플릿 Cafe뿐임)
|
||||||
def template_connect_resource_blackbox(
|
def template_connect_resource_blackbox(
|
||||||
self, template_id: str, image_url_list: list[str], lyric: str, music_url: str
|
self, template_id: str, image_url_list: list[str], lyric: str, music_url: str
|
||||||
) -> dict:
|
) -> dict:
|
||||||
template_data = self.get_one_template_data(template_id)
|
template_data = self.get_one_template_data(template_id)
|
||||||
template_component_data = self.parse_template_component_name(
|
template_component_data = self.parse_template_component_name(
|
||||||
template_data["source"]["elements"]
|
template_data["source"]["elements"]
|
||||||
)
|
)
|
||||||
|
|
||||||
lyric.replace("\r", "")
|
lyric.replace("\r", "")
|
||||||
lyric_splited = lyric.split("\n")
|
lyric_splited = lyric.split("\n")
|
||||||
modifications = {}
|
modifications = {}
|
||||||
for idx, (template_component_name, template_type) in enumerate(
|
for idx, (template_component_name, template_type) in enumerate(
|
||||||
template_component_data.items()
|
template_component_data.items()
|
||||||
):
|
):
|
||||||
match template_type:
|
match template_type:
|
||||||
case "image":
|
case "image":
|
||||||
modifications[template_component_name] = image_url_list[
|
modifications[template_component_name] = image_url_list[
|
||||||
idx % len(image_url_list)
|
idx % len(image_url_list)
|
||||||
]
|
]
|
||||||
case "text":
|
case "text":
|
||||||
modifications[template_component_name] = lyric_splited[
|
modifications[template_component_name] = lyric_splited[
|
||||||
idx % len(lyric_splited)
|
idx % len(lyric_splited)
|
||||||
]
|
]
|
||||||
|
|
||||||
modifications["audio-music"] = music_url
|
modifications["audio-music"] = music_url
|
||||||
|
|
||||||
return modifications
|
return modifications
|
||||||
|
|
||||||
def elements_connect_resource_blackbox(
|
def elements_connect_resource_blackbox(
|
||||||
self, elements: list, image_url_list: list[str], lyric: str, music_url: str
|
self, elements: list, image_url_list: list[str], lyric: str, music_url: str
|
||||||
) -> dict:
|
) -> dict:
|
||||||
template_component_data = self.parse_template_component_name(elements)
|
template_component_data = self.parse_template_component_name(elements)
|
||||||
|
|
||||||
lyric.replace("\r", "")
|
lyric.replace("\r", "")
|
||||||
lyric_splited = lyric.split("\n")
|
lyric_splited = lyric.split("\n")
|
||||||
modifications = {}
|
modifications = {}
|
||||||
for idx, (template_component_name, template_type) in enumerate(
|
for idx, (template_component_name, template_type) in enumerate(
|
||||||
template_component_data.items()
|
template_component_data.items()
|
||||||
):
|
):
|
||||||
match template_type:
|
match template_type:
|
||||||
case "image":
|
case "image":
|
||||||
modifications[template_component_name] = image_url_list[
|
modifications[template_component_name] = image_url_list[
|
||||||
idx % len(image_url_list)
|
idx % len(image_url_list)
|
||||||
]
|
]
|
||||||
case "text":
|
case "text":
|
||||||
modifications[template_component_name] = lyric_splited[
|
modifications[template_component_name] = lyric_splited[
|
||||||
idx % len(lyric_splited)
|
idx % len(lyric_splited)
|
||||||
]
|
]
|
||||||
|
|
||||||
modifications["audio-music"] = music_url
|
modifications["audio-music"] = music_url
|
||||||
|
|
||||||
return modifications
|
return modifications
|
||||||
|
|
||||||
def modify_element(self, elements: list, modification: dict):
|
def modify_element(self, elements: list, modification: dict):
|
||||||
def recursive_modify(element: dict) -> dict:
|
def recursive_modify(element: dict) -> dict:
|
||||||
if "name" in element:
|
if "name" in element:
|
||||||
match element["type"]:
|
match element["type"]:
|
||||||
case "image":
|
case "image":
|
||||||
element["source"] = modification[element["name"]]
|
element["source"] = modification[element["name"]]
|
||||||
case "audio":
|
case "audio":
|
||||||
element["source"] = modification.get(element["name"], "")
|
element["source"] = modification.get(element["name"], "")
|
||||||
case "video":
|
case "video":
|
||||||
element["source"] = modification[element["name"]]
|
element["source"] = modification[element["name"]]
|
||||||
case "text":
|
case "text":
|
||||||
element["source"] = modification.get(element["name"], "")
|
element["source"] = modification.get(element["name"], "")
|
||||||
case "composition":
|
case "composition":
|
||||||
for minor in element["elements"]:
|
for minor in element["elements"]:
|
||||||
recursive_modify(minor)
|
recursive_modify(minor)
|
||||||
|
|
||||||
for minor in elements:
|
for minor in elements:
|
||||||
recursive_modify(minor)
|
recursive_modify(minor)
|
||||||
|
|
||||||
return elements
|
return elements
|
||||||
|
|
||||||
# Creatomate에 생성 요청
|
# Creatomate에 생성 요청
|
||||||
# response에 요청 정보 있으니 풀링 필요
|
# response에 요청 정보 있으니 풀링 필요
|
||||||
def make_creatomate_call(self, template_id: str, modifications: dict):
|
def make_creatomate_call(self, template_id: str, modifications: dict):
|
||||||
url = Creatomate.base_url + "/v2/renders"
|
url = Creatomate.base_url + "/v2/renders"
|
||||||
|
|
||||||
data = {"template_id": template_id, "modifications": modifications}
|
data = {"template_id": template_id, "modifications": modifications}
|
||||||
headers = {
|
headers = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Authorization": f"Bearer {self.api_key}",
|
"Authorization": f"Bearer {self.api_key}",
|
||||||
}
|
}
|
||||||
|
|
||||||
response = requests.post(url, json=data, headers=headers)
|
response = requests.post(url, json=data, headers=headers)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
# Creatomate에 생성 요청 without template
|
# Creatomate에 생성 요청 without template
|
||||||
# response에 요청 정보 있으니 풀링 필요
|
# response에 요청 정보 있으니 풀링 필요
|
||||||
def make_creatomate_custom_call(self, source: str):
|
def make_creatomate_custom_call(self, source: str):
|
||||||
url = Creatomate.base_url + "/v2/renders"
|
url = Creatomate.base_url + "/v2/renders"
|
||||||
data = source
|
data = source
|
||||||
headers = {
|
headers = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Authorization": f"Bearer {self.api_key}",
|
"Authorization": f"Bearer {self.api_key}",
|
||||||
}
|
}
|
||||||
|
|
||||||
response = requests.post(url, json=data, headers=headers)
|
response = requests.post(url, json=data, headers=headers)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
def calc_scene_duration(self, template: dict):
|
def calc_scene_duration(self, template: dict):
|
||||||
total_template_duration = 0
|
total_template_duration = 0
|
||||||
for elem in template["source"]["elements"]:
|
for elem in template["source"]["elements"]:
|
||||||
try:
|
try:
|
||||||
if elem["type"] == "audio":
|
if elem["type"] == "audio":
|
||||||
continue
|
continue
|
||||||
total_template_duration += elem["duration"]
|
total_template_duration += elem["duration"]
|
||||||
if "animations" not in elem:
|
if "animations" not in elem:
|
||||||
continue
|
continue
|
||||||
for animation in elem["animations"]:
|
for animation in elem["animations"]:
|
||||||
assert animation["time"] == 0 # 0이 아닌 경우 확인 필요
|
assert animation["time"] == 0 # 0이 아닌 경우 확인 필요
|
||||||
if animation["transition"]:
|
if animation["transition"]:
|
||||||
total_template_duration -= animation["duration"]
|
total_template_duration -= animation["duration"]
|
||||||
except:
|
except:
|
||||||
print(elem)
|
print(elem)
|
||||||
return total_template_duration
|
return total_template_duration
|
||||||
|
|
||||||
def extend_template_duration(self, template: dict, target_duration: float):
|
def extend_template_duration(self, template: dict, target_duration: float):
|
||||||
template["duration"] = target_duration
|
template["duration"] = target_duration
|
||||||
total_template_duration = self.calc_scene_duration(template)
|
total_template_duration = self.calc_scene_duration(template)
|
||||||
extend_rate = target_duration / total_template_duration
|
extend_rate = target_duration / total_template_duration
|
||||||
new_template = copy.deepcopy(template)
|
new_template = copy.deepcopy(template)
|
||||||
for elem in new_template["source"]["elements"]:
|
for elem in new_template["source"]["elements"]:
|
||||||
try:
|
try:
|
||||||
if elem["type"] == "audio":
|
if elem["type"] == "audio":
|
||||||
continue
|
continue
|
||||||
elem["duration"] = elem["duration"] * extend_rate
|
elem["duration"] = elem["duration"] * extend_rate
|
||||||
if "animations" not in elem:
|
if "animations" not in elem:
|
||||||
continue
|
continue
|
||||||
for animation in elem["animations"]:
|
for animation in elem["animations"]:
|
||||||
assert animation["time"] == 0 # 0이 아닌 경우 확인 필요
|
assert animation["time"] == 0 # 0이 아닌 경우 확인 필요
|
||||||
animation["duration"] = animation["duration"] * extend_rate
|
animation["duration"] = animation["duration"] * extend_rate
|
||||||
except:
|
except:
|
||||||
print(elem)
|
print(elem)
|
||||||
return new_template
|
return new_template
|
||||||
|
|
||||||
|
|
||||||
# Azure사용한 legacy 코드 원본
|
# Azure사용한 legacy 코드 원본
|
||||||
# def template_connect_resource_blackbox(template_id, user_idx, task_idx):
|
# def template_connect_resource_blackbox(template_id, user_idx, task_idx):
|
||||||
# secret_client = get_keyvault_client()
|
# secret_client = get_keyvault_client()
|
||||||
# account_url = secret_client.get_secret(BLOB_ACCOUNT_URL_KEY).value
|
# account_url = secret_client.get_secret(BLOB_ACCOUNT_URL_KEY).value
|
||||||
# media_folder_path = f"{user_idx}/{task_idx}"
|
# media_folder_path = f"{user_idx}/{task_idx}"
|
||||||
# lyric_path = f"{media_folder_path}/lyric.txt"
|
# lyric_path = f"{media_folder_path}/lyric.txt"
|
||||||
# lyric = az_storage.az_storage_read_ado2_media(lyric_path).readall().decode('UTF-8')
|
# 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)
|
# 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]
|
# image_list = [media.name for media in media_list if '/crawling-images/' in media.name]
|
||||||
# template_data = get_one_template_data(template_id)
|
# template_data = get_one_template_data(template_id)
|
||||||
# template_component_data = parse_template_component_name(template_data['source']['elements'])
|
# template_component_data = parse_template_component_name(template_data['source']['elements'])
|
||||||
# lyric.replace("\r", "")
|
# lyric.replace("\r", "")
|
||||||
# lyric_splited = lyric.split("\n")
|
# lyric_splited = lyric.split("\n")
|
||||||
# modifications = {}
|
# modifications = {}
|
||||||
# for idx, (template_component_name, template_type) in enumerate(template_component_data.items()):
|
# for idx, (template_component_name, template_type) in enumerate(template_component_data.items()):
|
||||||
# match template_type:
|
# match template_type:
|
||||||
# case 'image':
|
# case 'image':
|
||||||
# modifications[template_component_name] = f"{account_url}/{BLOB_CONTAINER_NAME}/{image_list[idx % len(image_list)]}"
|
# modifications[template_component_name] = f"{account_url}/{BLOB_CONTAINER_NAME}/{image_list[idx % len(image_list)]}"
|
||||||
# case 'text':
|
# case 'text':
|
||||||
# modifications[template_component_name] = lyric_splited[idx % len(lyric_splited)]
|
# 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"
|
# modifications["audio-music"] = f"{account_url}/{BLOB_CONTAINER_NAME}/{BLOB_MEDIA_FOLDER}/{media_folder_path}/music_mureka.mp3"
|
||||||
# print(modifications)
|
# print(modifications)
|
||||||
|
|
||||||
# return modifications
|
# return modifications
|
||||||
|
|
|
||||||
|
|
@ -1,55 +1,55 @@
|
||||||
import creatomate
|
import creatomate
|
||||||
|
|
||||||
CREATOMATE_API_KEY = "df9e4382d7e84fe790bf8a2168152be195d5a3568524ceb66ed989a2dea809f7d3065d6803b2e3dd9d02b5e5ec1c9823"
|
CREATOMATE_API_KEY = "df9e4382d7e84fe790bf8a2168152be195d5a3568524ceb66ed989a2dea809f7d3065d6803b2e3dd9d02b5e5ec1c9823"
|
||||||
shortform_4_template_id = "e8c7b43f-de4b-4ba3-b8eb-5df688569193"
|
shortform_4_template_id = "e8c7b43f-de4b-4ba3-b8eb-5df688569193"
|
||||||
target_duration = 90.0 # s
|
target_duration = 90.0 # s
|
||||||
|
|
||||||
creato = creatomate.Creatomate(CREATOMATE_API_KEY)
|
creato = creatomate.Creatomate(CREATOMATE_API_KEY)
|
||||||
|
|
||||||
template = creato.get_one_template_data(shortform_4_template_id)
|
template = creato.get_one_template_data(shortform_4_template_id)
|
||||||
|
|
||||||
uploaded_image_url_list = [
|
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_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_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_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_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_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",
|
"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 = """
|
lyric = """
|
||||||
진짜 맛있는 추어탕의 향연
|
진짜 맛있는 추어탕의 향연
|
||||||
청담추어정 본점이야 말로
|
청담추어정 본점이야 말로
|
||||||
온 가족이 함께 먹는 그 맛
|
온 가족이 함께 먹는 그 맛
|
||||||
여수동 맛집으로 명성을 떨쳐
|
여수동 맛집으로 명성을 떨쳐
|
||||||
|
|
||||||
주차 가능, 단체 이용도 OK
|
주차 가능, 단체 이용도 OK
|
||||||
내 입맛을 사로잡는 맛
|
내 입맛을 사로잡는 맛
|
||||||
청담추어정, 그 진정한 맛
|
청담추어정, 그 진정한 맛
|
||||||
말복을 지나고 느껴보세요
|
말복을 지나고 느껴보세요
|
||||||
|
|
||||||
한산한 분위기, 편안한 식사
|
한산한 분위기, 편안한 식사
|
||||||
상황 추어탕으로 더욱 완벽
|
상황 추어탕으로 더욱 완벽
|
||||||
톡톡 튀는 맛, 한 입에 느껴
|
톡톡 튀는 맛, 한 입에 느껴
|
||||||
청담추어정에서 즐겨보세요
|
청담추어정에서 즐겨보세요
|
||||||
|
|
||||||
성남 출신의 맛집으로
|
성남 출신의 맛집으로
|
||||||
여수대로에서 빛나는 그곳
|
여수대로에서 빛나는 그곳
|
||||||
청담추어정, 진짜 맛의 꿈
|
청담추어정, 진짜 맛의 꿈
|
||||||
여러분을 초대합니다 여기에
|
여러분을 초대합니다 여기에
|
||||||
|
|
||||||
#청담추어정 #여수동맛집
|
#청담추어정 #여수동맛집
|
||||||
성남에서 만나는 진짜 맛
|
성남에서 만나는 진짜 맛
|
||||||
"""
|
"""
|
||||||
|
|
||||||
song_url = "https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/dev-user-idx/dev-task-idx/stay.mp3"
|
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(
|
modifications = creato.elements_connect_resource_blackbox(
|
||||||
template["source"]["elements"], uploaded_image_url_list, lyric, song_url
|
template["source"]["elements"], uploaded_image_url_list, lyric, song_url
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
new_elements = creato.modify_element(template["source"]["elements"], modifications)
|
new_elements = creato.modify_element(template["source"]["elements"], modifications)
|
||||||
template["source"]["elements"] = new_elements
|
template["source"]["elements"] = new_elements
|
||||||
last_template = creato.extend_template_duration(template, target_duration)
|
last_template = creato.extend_template_duration(template, target_duration)
|
||||||
creato.make_creatomate_custom_call(last_template["source"])
|
creato.make_creatomate_custom_call(last_template["source"])
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ dependencies = [
|
||||||
"aiohttp>=3.13.2",
|
"aiohttp>=3.13.2",
|
||||||
"aiomysql>=0.3.2",
|
"aiomysql>=0.3.2",
|
||||||
"asyncmy>=0.2.10",
|
"asyncmy>=0.2.10",
|
||||||
"beautifulsoup4>=4.14.3",
|
|
||||||
"fastapi-cli>=0.0.16",
|
"fastapi-cli>=0.0.16",
|
||||||
"fastapi[standard]>=0.125.0",
|
"fastapi[standard]>=0.125.0",
|
||||||
"openai>=2.13.0",
|
"openai>=2.13.0",
|
||||||
|
|
|
||||||
24
uv.lock
|
|
@ -157,19 +157,6 @@ 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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "certifi"
|
name = "certifi"
|
||||||
version = "2025.11.12"
|
version = "2025.11.12"
|
||||||
|
|
@ -764,7 +751,6 @@ dependencies = [
|
||||||
{ name = "aiohttp" },
|
{ name = "aiohttp" },
|
||||||
{ name = "aiomysql" },
|
{ name = "aiomysql" },
|
||||||
{ name = "asyncmy" },
|
{ name = "asyncmy" },
|
||||||
{ name = "beautifulsoup4" },
|
|
||||||
{ name = "fastapi", extra = ["standard"] },
|
{ name = "fastapi", extra = ["standard"] },
|
||||||
{ name = "fastapi-cli" },
|
{ name = "fastapi-cli" },
|
||||||
{ name = "openai" },
|
{ name = "openai" },
|
||||||
|
|
@ -789,7 +775,6 @@ requires-dist = [
|
||||||
{ name = "aiohttp", specifier = ">=3.13.2" },
|
{ name = "aiohttp", specifier = ">=3.13.2" },
|
||||||
{ name = "aiomysql", specifier = ">=0.3.2" },
|
{ name = "aiomysql", specifier = ">=0.3.2" },
|
||||||
{ name = "asyncmy", specifier = ">=0.2.10" },
|
{ name = "asyncmy", specifier = ">=0.2.10" },
|
||||||
{ name = "beautifulsoup4", specifier = ">=4.14.3" },
|
|
||||||
{ name = "fastapi", extras = ["standard"], specifier = ">=0.125.0" },
|
{ name = "fastapi", extras = ["standard"], specifier = ">=0.125.0" },
|
||||||
{ name = "fastapi-cli", specifier = ">=0.0.16" },
|
{ name = "fastapi-cli", specifier = ">=0.0.16" },
|
||||||
{ name = "openai", specifier = ">=2.13.0" },
|
{ name = "openai", specifier = ">=2.13.0" },
|
||||||
|
|
@ -1256,15 +1241,6 @@ 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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "sqladmin"
|
name = "sqladmin"
|
||||||
version = "0.22.0"
|
version = "0.22.0"
|
||||||
|
|
|
||||||